From 0ef8c808159ffb685bad8fb9fbbd9cb0eb65dd19 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Tue, 18 Oct 2022 13:39:59 +0100 Subject: [PATCH 001/113] Fix usages of useContextMenu which never pass the ref to the element (#9449) --- src/components/views/messages/MessageActionBar.tsx | 8 ++++---- src/components/views/rooms/MessageComposerButtons.tsx | 1 + 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/components/views/messages/MessageActionBar.tsx b/src/components/views/messages/MessageActionBar.tsx index c5108051160..c1637b9a0cd 100644 --- a/src/components/views/messages/MessageActionBar.tsx +++ b/src/components/views/messages/MessageActionBar.tsx @@ -83,7 +83,7 @@ const OptionsButton: React.FC = ({ getRelationsForEvent, }) => { const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu(); - const [onFocus, isActive, ref] = useRovingTabIndex(button); + const [onFocus, isActive] = useRovingTabIndex(button); useEffect(() => { onFocusChange(menuDisplayed); }, [onFocusChange, menuDisplayed]); @@ -123,7 +123,7 @@ const OptionsButton: React.FC = ({ onClick={onOptionsClick} onContextMenu={onOptionsClick} isExpanded={menuDisplayed} - inputRef={ref} + inputRef={button} onFocus={onFocus} tabIndex={isActive ? 0 : -1} > @@ -141,7 +141,7 @@ interface IReactButtonProps { const ReactButton: React.FC = ({ mxEvent, reactions, onFocusChange }) => { const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu(); - const [onFocus, isActive, ref] = useRovingTabIndex(button); + const [onFocus, isActive] = useRovingTabIndex(button); useEffect(() => { onFocusChange(menuDisplayed); }, [onFocusChange, menuDisplayed]); @@ -173,7 +173,7 @@ const ReactButton: React.FC = ({ mxEvent, reactions, onFocusC onClick={onClick} onContextMenu={onClick} isExpanded={menuDisplayed} - inputRef={ref} + inputRef={button} onFocus={onFocus} tabIndex={isActive ? 0 : -1} > diff --git a/src/components/views/rooms/MessageComposerButtons.tsx b/src/components/views/rooms/MessageComposerButtons.tsx index cc7ce70f444..b77bff66a8f 100644 --- a/src/components/views/rooms/MessageComposerButtons.tsx +++ b/src/components/views/rooms/MessageComposerButtons.tsx @@ -179,6 +179,7 @@ const EmojiButton: React.FC = ({ addEmoji, menuPosition }) => iconClassName="mx_MessageComposer_emoji" onClick={openMenu} title={_t("Emoji")} + inputRef={button} /> { contextMenu } From 67dbb360260bea1da55fe518e50a50424d816abc Mon Sep 17 00:00:00 2001 From: kegsay Date: Tue, 18 Oct 2022 13:44:45 +0100 Subject: [PATCH 002/113] Listen for and update the notification state when they change (#9438) * Listen for and update the notification state when they change * Remove unnecessary listeners: justify each listener left remaining * Update removeListener too --- .../notifications/RoomNotificationState.ts | 24 ++++++++----------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/src/stores/notifications/RoomNotificationState.ts b/src/stores/notifications/RoomNotificationState.ts index c4c803483df..9c64b7ec424 100644 --- a/src/stores/notifications/RoomNotificationState.ts +++ b/src/stores/notifications/RoomNotificationState.ts @@ -32,16 +32,15 @@ import { ThreadsRoomNotificationState } from "./ThreadsRoomNotificationState"; export class RoomNotificationState extends NotificationState implements IDestroyable { constructor(public readonly room: Room, private readonly threadsState?: ThreadsRoomNotificationState) { super(); - this.room.on(RoomEvent.Receipt, this.handleReadReceipt); - this.room.on(RoomEvent.Timeline, this.handleRoomEventUpdate); - this.room.on(RoomEvent.Redaction, this.handleRoomEventUpdate); - this.room.on(RoomEvent.MyMembership, this.handleMembershipUpdate); - this.room.on(RoomEvent.LocalEchoUpdated, this.handleLocalEchoUpdated); + this.room.on(RoomEvent.Receipt, this.handleReadReceipt); // for unread indicators + this.room.on(RoomEvent.MyMembership, this.handleMembershipUpdate); // for redness on invites + this.room.on(RoomEvent.LocalEchoUpdated, this.handleLocalEchoUpdated); // for redness on unsent messages + this.room.on(RoomEvent.UnreadNotifications, this.handleNotificationCountUpdate); // for server-sent counts if (threadsState) { threadsState.on(NotificationStateEvents.Update, this.handleThreadsUpdate); } - MatrixClientPeg.get().on(MatrixEventEvent.Decrypted, this.onEventDecrypted); - MatrixClientPeg.get().on(ClientEvent.AccountData, this.handleAccountDataUpdate); + MatrixClientPeg.get().on(MatrixEventEvent.Decrypted, this.onEventDecrypted); // for local count calculation + MatrixClientPeg.get().on(ClientEvent.AccountData, this.handleAccountDataUpdate); // for push rules this.updateNotificationState(); } @@ -52,10 +51,9 @@ export class RoomNotificationState extends NotificationState implements IDestroy public destroy(): void { super.destroy(); this.room.removeListener(RoomEvent.Receipt, this.handleReadReceipt); - this.room.removeListener(RoomEvent.Timeline, this.handleRoomEventUpdate); - this.room.removeListener(RoomEvent.Redaction, this.handleRoomEventUpdate); this.room.removeListener(RoomEvent.MyMembership, this.handleMembershipUpdate); this.room.removeListener(RoomEvent.LocalEchoUpdated, this.handleLocalEchoUpdated); + this.room.removeListener(RoomEvent.UnreadNotifications, this.handleNotificationCountUpdate); if (this.threadsState) { this.threadsState.removeListener(NotificationStateEvents.Update, this.handleThreadsUpdate); } @@ -83,14 +81,12 @@ export class RoomNotificationState extends NotificationState implements IDestroy this.updateNotificationState(); }; - private onEventDecrypted = (event: MatrixEvent) => { - if (event.getRoomId() !== this.room.roomId) return; // ignore - not for us or notifications timeline - + private handleNotificationCountUpdate = () => { this.updateNotificationState(); }; - private handleRoomEventUpdate = (event: MatrixEvent, room: Room | null) => { - if (room?.roomId !== this.room.roomId) return; // ignore - not for us or notifications timeline + private onEventDecrypted = (event: MatrixEvent) => { + if (event.getRoomId() !== this.room.roomId) return; // ignore - not for us or notifications timeline this.updateNotificationState(); }; From b04991a9628f896e6f25ae178d3fc58b1a568c8d Mon Sep 17 00:00:00 2001 From: Kerry Date: Tue, 18 Oct 2022 15:00:01 +0200 Subject: [PATCH 003/113] Device manager - put client/browser device metadata in correct section (#9447) --- .../views/settings/devices/DeviceDetails.tsx | 2 +- src/i18n/strings/en_EN.json | 2 +- .../__snapshots__/DeviceDetails-test.tsx.snap | 24 +++++++++---------- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/components/views/settings/devices/DeviceDetails.tsx b/src/components/views/settings/devices/DeviceDetails.tsx index 4330798dcab..3921ae899e0 100644 --- a/src/components/views/settings/devices/DeviceDetails.tsx +++ b/src/components/views/settings/devices/DeviceDetails.tsx @@ -62,7 +62,6 @@ const DeviceDetails: React.FC = ({ id: 'session', values: [ { label: _t('Session ID'), value: device.device_id }, - { label: _t('Client'), value: device.client }, { label: _t('Last activity'), value: device.last_seen_ts && formatDate(new Date(device.last_seen_ts)), @@ -84,6 +83,7 @@ const DeviceDetails: React.FC = ({ values: [ { label: _t('Model'), value: device.deviceModel }, { label: _t('Operating system'), value: device.deviceOperatingSystem }, + { label: _t('Browser'), value: device.client }, { label: _t('IP address'), value: device.last_seen_ip }, ], }, diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index d24c763bec7..3f078172b3f 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1742,7 +1742,6 @@ "Rename session": "Rename session", "Please be aware that session names are also visible to people you communicate with": "Please be aware that session names are also visible to people you communicate with", "Session ID": "Session ID", - "Client": "Client", "Last activity": "Last activity", "Application": "Application", "Version": "Version", @@ -1750,6 +1749,7 @@ "Device": "Device", "Model": "Model", "Operating system": "Operating system", + "Browser": "Browser", "IP address": "IP address", "Session details": "Session details", "Toggle push notifications on this session.": "Toggle push notifications on this session.", diff --git a/test/components/views/settings/devices/__snapshots__/DeviceDetails-test.tsx.snap b/test/components/views/settings/devices/__snapshots__/DeviceDetails-test.tsx.snap index e681b652767..9f2f5386589 100644 --- a/test/components/views/settings/devices/__snapshots__/DeviceDetails-test.tsx.snap +++ b/test/components/views/settings/devices/__snapshots__/DeviceDetails-test.tsx.snap @@ -181,18 +181,6 @@ exports[` renders device with metadata 1`] = ` my-device - - - Client - - - Firefox 100 - - renders device with metadata 1`] = ` Windows 95 + + + Browser + + + Firefox 100 + + Date: Tue, 18 Oct 2022 17:07:23 +0100 Subject: [PATCH 004/113] Stabilise Cypress login tests (#9446) * Attempt to stabilise login tests * More stability * Stabilise s'more * don't clear LS as we rely on it for enablements * Add small delay * Iterate * Update login.ts --- cypress/e2e/create-room/create-room.spec.ts | 4 +- cypress/e2e/editing/editing.spec.ts | 2 +- cypress/e2e/login/consent.spec.ts | 4 +- cypress/e2e/login/login.spec.ts | 22 +++++------ cypress/e2e/polls/polls.spec.ts | 4 +- .../e2e/room-directory/room-directory.spec.ts | 2 +- cypress/e2e/sliding-sync/sliding-sync.ts | 6 +-- cypress/e2e/spaces/spaces.spec.ts | 38 +++++++++---------- cypress/e2e/threads/threads.spec.ts | 22 ++++++----- cypress/e2e/timeline/timeline.spec.ts | 2 +- cypress/e2e/toasts/analytics-toast.ts | 2 +- cypress/support/login.ts | 2 +- cypress/support/settings.ts | 6 +-- src/Lifecycle.ts | 2 +- 14 files changed, 60 insertions(+), 58 deletions(-) diff --git a/cypress/e2e/create-room/create-room.spec.ts b/cypress/e2e/create-room/create-room.spec.ts index 9bf38194d92..deac0728e35 100644 --- a/cypress/e2e/create-room/create-room.spec.ts +++ b/cypress/e2e/create-room/create-room.spec.ts @@ -60,7 +60,7 @@ describe("Create Room", () => { 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..ff963dfbfe7 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", () => { @@ -65,14 +62,17 @@ describe("Login", () => { 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 +94,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..50d2befb0f6 100644 --- a/cypress/e2e/polls/polls.spec.ts +++ b/cypress/e2e/polls/polls.spec.ts @@ -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 => { @@ -190,7 +190,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/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/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/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/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/src/Lifecycle.ts b/src/Lifecycle.ts index 64d1d9b5fdf..6c9c9558183 100644 --- a/src/Lifecycle.ts +++ b/src/Lifecycle.ts @@ -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) { From 26f3d107fd4dab2327b0704d966600cf39b64db7 Mon Sep 17 00:00:00 2001 From: Johannes Marbach Date: Tue, 18 Oct 2022 21:06:43 +0200 Subject: [PATCH 005/113] Set relations helper when creating event tile context menu (#9253) * Set relations helper when creating event tile context menu Fixes vector-im/element-web#22018 Signed-off-by: Johannes Marbach * Add e2e tests * Use idiomatic test names Signed-off-by: Johannes Marbach Co-authored-by: Travis Ralston --- cypress/e2e/polls/polls.spec.ts | 89 +++++++++++++++++++++++- src/components/views/rooms/EventTile.tsx | 1 + 2 files changed, 88 insertions(+), 2 deletions(-) diff --git a/cypress/e2e/polls/polls.spec.ts b/cypress/e2e/polls/polls.spec.ts index 50d2befb0f6..f4be3962ed4 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; @@ -159,7 +159,92 @@ 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]); + + // 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 => { diff --git a/src/components/views/rooms/EventTile.tsx b/src/components/views/rooms/EventTile.tsx index 654cc80b67d..b13eba33e47 100644 --- a/src/components/views/rooms/EventTile.tsx +++ b/src/components/views/rooms/EventTile.tsx @@ -932,6 +932,7 @@ export class UnwrappedEventTile extends React.Component { rightClick={true} reactions={this.state.reactions} link={this.state.contextMenu.link} + getRelationsForEvent={this.props.getRelationsForEvent} /> ); } From e0ab0ac5c9996ebaff0c949298e42fad1d128538 Mon Sep 17 00:00:00 2001 From: Andy Balaam Date: Wed, 19 Oct 2022 04:07:21 +0100 Subject: [PATCH 006/113] Allow pressing Enter to send messages in new composer (#9451) * Allow pressing Enter to send messages in new composer * Cypress tests for composer send behaviour --- cypress/e2e/composer/composer.spec.ts | 140 ++++++++++++++++++ package.json | 2 +- .../wysiwyg_composer/WysiwygComposer.tsx | 24 ++- .../wysiwyg_composer/WysiwygComposer-test.tsx | 82 +++++++++- yarn.lock | 87 ++++++++++- 5 files changed, 323 insertions(+), 12 deletions(-) create mode 100644 cypress/e2e/composer/composer.spec.ts diff --git a/cypress/e2e/composer/composer.spec.ts b/cypress/e2e/composer/composer.spec.ts new file mode 100644 index 00000000000..f3fc374cf02 --- /dev/null +++ b/cypress/e2e/composer/composer.spec.ts @@ -0,0 +1,140 @@ +/* +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'); + }); + + 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/package.json b/package.json index b203cf51e91..f0ab2c266b4 100644 --- a/package.json +++ b/package.json @@ -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.0", "@matrix-org/react-sdk-module-api": "^0.0.3", "@sentry/browser": "^6.11.0", "@sentry/tracing": "^6.11.0", diff --git a/src/components/views/rooms/wysiwyg_composer/WysiwygComposer.tsx b/src/components/views/rooms/wysiwyg_composer/WysiwygComposer.tsx index 8701f5be778..c22e3406fa6 100644 --- a/src/components/views/rooms/wysiwyg_composer/WysiwygComposer.tsx +++ b/src/components/views/rooms/wysiwyg_composer/WysiwygComposer.tsx @@ -16,7 +16,7 @@ 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 { useWysiwyg, Wysiwyg, WysiwygInputEvent } from "@matrix-org/matrix-wysiwyg"; import { Editor } from './Editor'; import { FormattingButtons } from './FormattingButtons'; @@ -25,6 +25,7 @@ import { sendMessage } from './message'; import { useMatrixClientContext } from '../../../../contexts/MatrixClientContext'; import { useRoomContext } from '../../../../contexts/RoomContext'; import { useWysiwygActionHandler } from './useWysiwygActionHandler'; +import { useSettingValue } from '../../../../hooks/useSettings'; interface WysiwygProps { disabled?: boolean; @@ -41,8 +42,27 @@ export function WysiwygComposer( ) { const roomContext = useRoomContext(); const mxClient = useMatrixClientContext(); + const ctrlEnterToSend = useSettingValue("MessageComposerInput.ctrlEnterToSend"); - const { ref, isWysiwygReady, content, formattingStates, wysiwyg } = useWysiwyg(); + function inputEventProcessor(event: WysiwygInputEvent, wysiwyg: Wysiwyg): WysiwygInputEvent | null { + if (event instanceof ClipboardEvent) { + return event; + } + + if ( + (event.inputType === 'insertParagraph' && !ctrlEnterToSend) || + event.inputType === 'sendMessage' + ) { + sendMessage(content, { mxClient, roomContext, ...props }); + wysiwyg.actions.clear(); + ref.current?.focus(); + return null; + } + + return event; + } + + const { ref, isWysiwygReady, content, formattingStates, wysiwyg } = useWysiwyg({ inputEventProcessor }); useEffect(() => { if (!disabled && content !== null) { diff --git a/test/components/views/rooms/wysiwyg_composer/WysiwygComposer-test.tsx b/test/components/views/rooms/wysiwyg_composer/WysiwygComposer-test.tsx index b0aa838879b..df2596809cc 100644 --- a/test/components/views/rooms/wysiwyg_composer/WysiwygComposer-test.tsx +++ b/test/components/views/rooms/wysiwyg_composer/WysiwygComposer-test.tsx @@ -17,6 +17,7 @@ limitations under the License. import "@testing-library/jest-dom"; import React from "react"; import { act, render, screen, waitFor } from "@testing-library/react"; +import { InputEventProcessor, Wysiwyg, WysiwygProps } from "@matrix-org/matrix-wysiwyg"; import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext"; import RoomContext, { TimelineRenderingType } from "../../../../../src/contexts/RoomContext"; @@ -26,13 +27,31 @@ import { IRoomState } from "../../../../../src/components/structures/RoomView"; import { Layout } from "../../../../../src/settings/enums/Layout"; import { WysiwygComposer } from "../../../../../src/components/views/rooms/wysiwyg_composer/WysiwygComposer"; import { createTestClient, mkEvent, mkStubRoom } from "../../../../test-utils"; +import SettingsStore from "../../../../../src/settings/SettingsStore"; + +// Work around missing ClipboardEvent type +class MyClipbardEvent {} +window.ClipboardEvent = MyClipbardEvent as any; + +let inputEventProcessor: InputEventProcessor | null = null; // The wysiwyg fetch wasm bytes and a specific workaround is needed to make it works in a node (jest) environnement // See https://github.com/matrix-org/matrix-wysiwyg/blob/main/platforms/web/test.setup.ts jest.mock("@matrix-org/matrix-wysiwyg", () => ({ - useWysiwyg: () => { - return { ref: { current: null }, content: 'html', isWysiwygReady: true, wysiwyg: { clear: () => void 0 }, - formattingStates: { bold: 'enabled', italic: 'enabled', underline: 'enabled', strikeThrough: 'enabled' } }; + useWysiwyg: (props: WysiwygProps) => { + inputEventProcessor = props.inputEventProcessor ?? null; + return { + ref: { current: null }, + content: 'html', + isWysiwygReady: true, + wysiwyg: { clear: () => void 0 }, + formattingStates: { + bold: 'enabled', + italic: 'enabled', + underline: 'enabled', + strikeThrough: 'enabled', + }, + }; }, })); @@ -196,5 +215,62 @@ describe('WysiwygComposer', () => { // Then we don't get it because we are disabled expect(screen.getByRole('textbox')).not.toHaveFocus(); }); + + it('sends a message when Enter is pressed', async () => { + // Given a composer + customRender(() => {}, false); + + // When we tell its inputEventProcesser that the user pressed Enter + const event = new InputEvent("insertParagraph", { inputType: "insertParagraph" }); + const wysiwyg = { actions: { clear: () => {} } } as Wysiwyg; + inputEventProcessor(event, wysiwyg); + + // Then it sends a message + expect(mockClient.sendMessage).toBeCalledWith( + "myfakeroom", + null, + { + "body": "html", + "format": "org.matrix.custom.html", + "formatted_body": "html", + "msgtype": "m.text", + }, + ); + // TODO: plain text body above is wrong - will be fixed when we provide markdown for it + }); + + describe('when settings require Ctrl+Enter to send', () => { + beforeEach(() => { + jest.spyOn(SettingsStore, "getValue").mockImplementation((name: string) => { + if (name === "MessageComposerInput.ctrlEnterToSend") return true; + }); + }); + + it('does not send a message when Enter is pressed', async () => { + // Given a composer + customRender(() => {}, false); + + // When we tell its inputEventProcesser that the user pressed Enter + const event = new InputEvent("input", { inputType: "insertParagraph" }); + const wysiwyg = { actions: { clear: () => {} } } as Wysiwyg; + inputEventProcessor(event, wysiwyg); + + // Then it does not send a message + expect(mockClient.sendMessage).toBeCalledTimes(0); + }); + + it('sends a message when Ctrl+Enter is pressed', async () => { + // Given a composer + customRender(() => {}, false); + + // When we tell its inputEventProcesser that the user pressed Ctrl+Enter + const event = new InputEvent("input", { inputType: "sendMessage" }); + const wysiwyg = { actions: { clear: () => {} } } as Wysiwyg; + inputEventProcessor(event, wysiwyg); + + // Then it sends a message + expect(mockClient.sendMessage).toBeCalledTimes(1); + }); + }); }); diff --git a/yarn.lock b/yarn.lock index b54bc1ec815..add14d4c3e5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1660,10 +1660,10 @@ resolved "https://registry.yarnpkg.com/@matrix-org/analytics-events/-/analytics-events-0.2.0.tgz#453925c939ecdd5ca6c797d293deb8cf0933f1b8" integrity sha512-+0/Sydm4MNOcqd8iySJmojVPB74Axba4BXlwTsiKmL5fgYqdUkwmqkO39K7Pn8i+a+8pg11oNvBPkpWs3O5Qww== -"@matrix-org/matrix-wysiwyg@^0.2.0": - version "0.2.0" - resolved "https://registry.yarnpkg.com/@matrix-org/matrix-wysiwyg/-/matrix-wysiwyg-0.2.0.tgz#651002ad67be3004698d4a89806cf344283a4ca3" - integrity sha512-m9R1NOd0ogkhrjqFNg159TMXL5dpME90G9RDrZrO106263Qtoj0TazyBaLhNjgvPkogbzbCJUULQWPFiLQfTjw== +"@matrix-org/matrix-wysiwyg@^0.3.0": + version "0.3.0" + resolved "https://registry.yarnpkg.com/@matrix-org/matrix-wysiwyg/-/matrix-wysiwyg-0.3.0.tgz#9a0b996c47fbb63fb235a0810b678158b253f721" + integrity sha512-m33qOo64VIZRqzMZ5vJ9m2gYns+sCaFFy3R5Nn9JfDnldQ1oh+ra611I9keFmO/Ls6548ZN8hUkv+49Ua3iBHA== "@matrix-org/olm@https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.8.tgz": version "3.2.8" @@ -2674,7 +2674,7 @@ ajv-keywords@^3.5.2: resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-3.5.2.tgz#31f29da5ab6e00d1c2d329acf7b5929614d5014d" integrity sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ== -ajv@^6.10.0, ajv@^6.12.4, ajv@^6.12.5: +ajv@^6.10.0, ajv@^6.12.3, ajv@^6.12.4, ajv@^6.12.5: version "6.12.6" resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4" integrity sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g== @@ -3198,6 +3198,11 @@ browser-process-hrtime@^1.0.0: resolved "https://registry.yarnpkg.com/browser-process-hrtime/-/browser-process-hrtime-1.0.0.tgz#3c9b4b7d782c8121e56f10106d84c0d0ffc94626" integrity sha512-9o5UecI3GhkpM6DrXr69PblIuWxPKk9Y0jHBRhdocZ2y7YECBFCsHm79Pr3OyR2AvjhDkabFJaDJMYRazHgsow== +browser-request@^0.3.3: + version "0.3.3" + resolved "https://registry.yarnpkg.com/browser-request/-/browser-request-0.3.3.tgz#9ece5b5aca89a29932242e18bf933def9876cc17" + integrity sha512-YyNI4qJJ+piQG6MMEuo7J3Bzaqssufx04zpEKYfSrl/1Op59HWali9zMtBpXnkmqMcOuWJPZvudrm9wISmnCbg== + browserslist@^4.20.2, browserslist@^4.21.3: version "4.21.3" resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.21.3.tgz#5df277694eb3c48bc5c4b05af3e8b7e09c5a6d1a" @@ -5280,6 +5285,19 @@ grid-index@^1.1.0: resolved "https://registry.yarnpkg.com/grid-index/-/grid-index-1.1.0.tgz#97f8221edec1026c8377b86446a7c71e79522ea7" integrity sha512-HZRwumpOGUrHyxO5bqKZL0B0GlUpwtCAzZ42sgxUPniu33R1LSFH5yrIcBCHjkctCAh3mtWKcKd9J4vDDdeVHA== +har-schema@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/har-schema/-/har-schema-2.0.0.tgz#a94c2224ebcac04782a0d9035521f24735b7ec92" + integrity sha512-Oqluz6zhGX8cyRaTQlFMPw80bSJVG2x/cFb8ZPhUILGgHka9SsokCCOQgpveePerqidZOrT14ipqfJb7ILcW5Q== + +har-validator@~5.1.3: + version "5.1.5" + resolved "https://registry.yarnpkg.com/har-validator/-/har-validator-5.1.5.tgz#1f0803b9f8cb20c0fa13822df1ecddb36bde1efd" + integrity sha512-nmT2T0lljbxdQZfspsno9hgrG3Uir6Ks5afism62poxqBM6sDnMEuPmzTq8XN0OEwqKLLdh1jQI3qyE66Nzb3w== + dependencies: + ajv "^6.12.3" + har-schema "^2.0.0" + hard-rejection@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/hard-rejection/-/hard-rejection-2.1.0.tgz#1c6eda5c1685c63942766d79bb40ae773cecd883" @@ -5440,6 +5458,15 @@ http-proxy-agent@^4.0.1: agent-base "6" debug "4" +http-signature@~1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/http-signature/-/http-signature-1.2.0.tgz#9aecd925114772f3d95b65a60abb8f7c18fbace1" + integrity sha512-CAbnr6Rz4CYQkLYUtSNXxQPUH2gK8f3iWexVlsnMeD+GjlsQ0Xsy1cOX+mN3dtxYomRy21CiOzU8Uhw6OwncEQ== + dependencies: + assert-plus "^1.0.0" + jsprim "^1.2.2" + sshpk "^1.7.0" + http-signature@~1.3.6: version "1.3.6" resolved "https://registry.yarnpkg.com/http-signature/-/http-signature-1.3.6.tgz#cb6fbfdf86d1c974f343be94e87f7fc128662cf9" @@ -6668,6 +6695,16 @@ jsonfile@^6.0.1: optionalDependencies: graceful-fs "^4.1.6" +jsprim@^1.2.2: + version "1.4.2" + resolved "https://registry.yarnpkg.com/jsprim/-/jsprim-1.4.2.tgz#712c65533a15c878ba59e9ed5f0e26d5b77c5feb" + integrity sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw== + dependencies: + assert-plus "1.0.0" + extsprintf "1.3.0" + json-schema "0.4.0" + verror "1.10.0" + jsprim@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/jsprim/-/jsprim-2.0.2.tgz#77ca23dbcd4135cd364800d22ff82c2185803d4d" @@ -7033,12 +7070,14 @@ matrix-events-sdk@^0.0.1-beta.7: dependencies: "@babel/runtime" "^7.12.5" another-json "^0.2.0" + browser-request "^0.3.3" bs58 "^5.0.0" content-type "^1.0.4" loglevel "^1.7.1" matrix-events-sdk "^0.0.1-beta.7" p-retry "4" qs "^6.9.6" + request "^2.88.2" unhomoglyph "^1.0.6" matrix-mock-request@^2.5.0: @@ -7357,6 +7396,11 @@ nwsapi@^2.2.0: resolved "https://registry.yarnpkg.com/nwsapi/-/nwsapi-2.2.1.tgz#10a9f268fbf4c461249ebcfe38e359aa36e2577c" integrity sha512-JYOWTeFoS0Z93587vRJgASD5Ut11fYl5NyihP3KrYBvMe1FRRs6RN7m20SA/16GM4P6hTnZjT+UmDOt38UeXNg== +oauth-sign@~0.9.0: + version "0.9.0" + resolved "https://registry.yarnpkg.com/oauth-sign/-/oauth-sign-0.9.0.tgz#47a7b016baa68b5fa0ecf3dee08a85c679ac6455" + integrity sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ== + object-assign@^4.1.0, object-assign@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" @@ -8259,6 +8303,32 @@ request-progress@^3.0.0: dependencies: throttleit "^1.0.0" +request@^2.88.2: + version "2.88.2" + resolved "https://registry.yarnpkg.com/request/-/request-2.88.2.tgz#d73c918731cb5a87da047e207234146f664d12b3" + integrity sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw== + dependencies: + aws-sign2 "~0.7.0" + aws4 "^1.8.0" + caseless "~0.12.0" + combined-stream "~1.0.6" + extend "~3.0.2" + forever-agent "~0.6.1" + form-data "~2.3.2" + har-validator "~5.1.3" + http-signature "~1.2.0" + is-typedarray "~1.0.0" + isstream "~0.1.2" + json-stringify-safe "~5.0.1" + mime-types "~2.1.19" + oauth-sign "~0.9.0" + performance-now "^2.1.0" + qs "~6.5.2" + safe-buffer "^5.1.2" + tough-cookie "~2.5.0" + tunnel-agent "^0.6.0" + uuid "^3.3.2" + require-directory@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" @@ -8725,7 +8795,7 @@ sprintf-js@~1.0.2: resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" integrity sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g== -sshpk@^1.14.1: +sshpk@^1.14.1, sshpk@^1.7.0: version "1.17.0" resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.17.0.tgz#578082d92d4fe612b13007496e543fa0fbcbe4c5" integrity sha512-/9HIEs1ZXGhSPE8X6Ccm7Nam1z8KcoCqPdI7ecm1N33EzAetWahvQWVqLZtaZQ+IDKX4IyA2o0gBzqIMkAagHQ== @@ -9439,6 +9509,11 @@ util-deprecate@^1.0.2, util-deprecate@~1.0.1: resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== +uuid@^3.3.2: + version "3.4.0" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee" + integrity sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A== + uuid@^8.3.2: version "8.3.2" resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" From 84f2974b570fcb5c6cc4b5d2d1228b0e55c2767a Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Wed, 19 Oct 2022 12:04:15 +0200 Subject: [PATCH 007/113] Always show voice broadcasts tile (#9444) --- .../views/messages/MessageEvent.tsx | 40 +---------- .../views/messages/MessageEvent-test.tsx | 66 ++----------------- 2 files changed, 7 insertions(+), 99 deletions(-) diff --git a/src/components/views/messages/MessageEvent.tsx b/src/components/views/messages/MessageEvent.tsx index 91807d568f6..858bf0eb6c5 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/test/components/views/messages/MessageEvent-test.tsx b/test/components/views/messages/MessageEvent-test.tsx index 82442855fc6..dadddca093a 100644 --- a/test/components/views/messages/MessageEvent-test.tsx +++ b/test/components/views/messages/MessageEvent-test.tsx @@ -16,11 +16,9 @@ limitations under the License. import React from "react"; import { render, RenderResult } from "@testing-library/react"; -import { mocked } from "jest-mock"; import { MatrixClient, MatrixEvent, Room } from "matrix-js-sdk/src/matrix"; -import { Features } from "../../../../src/settings/Settings"; -import SettingsStore, { CallbackFn } from "../../../../src/settings/SettingsStore"; +import SettingsStore from "../../../../src/settings/SettingsStore"; import { VoiceBroadcastInfoEventType, VoiceBroadcastInfoState } from "../../../../src/voice-broadcast"; import { mkEvent, mkRoom, stubClient } from "../../../test-utils"; import MessageEvent from "../../../../src/components/views/messages/MessageEvent"; @@ -57,8 +55,7 @@ describe("MessageEvent", () => { }); describe("when a voice broadcast start event occurs", () => { - const voiceBroadcastSettingWatcherRef = "vb ref"; - let onVoiceBroadcastSettingChanged: CallbackFn; + let result: RenderResult; beforeEach(() => { event = mkEvent({ @@ -70,64 +67,11 @@ describe("MessageEvent", () => { state: VoiceBroadcastInfoState.Started, }, }); - - mocked(SettingsStore.watchSetting).mockImplementation( - (settingName: string, roomId: string | null, callbackFn: CallbackFn) => { - if (settingName === Features.VoiceBroadcast) { - onVoiceBroadcastSettingChanged = callbackFn; - return voiceBroadcastSettingWatcherRef; - } - }, - ); + result = renderMessageEvent(); }); - describe("and the voice broadcast feature is enabled", () => { - let result: RenderResult; - - beforeEach(() => { - mocked(SettingsStore.getValue).mockImplementation((settingName: string) => { - return settingName === Features.VoiceBroadcast; - }); - result = renderMessageEvent(); - }); - - it("should render a VoiceBroadcast component", () => { - result.getByTestId("voice-broadcast-body"); - }); - - describe("and switching the voice broadcast feature off", () => { - beforeEach(() => { - onVoiceBroadcastSettingChanged(Features.VoiceBroadcast, null, null, null, false); - }); - - it("should render an UnknownBody component", () => { - const result = renderMessageEvent(); - result.getByTestId("unknown-body"); - }); - }); - - describe("and unmounted", () => { - beforeEach(() => { - result.unmount(); - }); - - it("should unregister the settings watcher", () => { - expect(SettingsStore.unwatchSetting).toHaveBeenCalled(); - }); - }); - }); - - describe("and the voice broadcast feature is disabled", () => { - beforeEach(() => { - mocked(SettingsStore.getValue).mockImplementation((settingName: string) => { - return false; - }); - }); - - it("should render an UnknownBody component", () => { - const result = renderMessageEvent(); - result.getByTestId("unknown-body"); - }); + it("should render a VoiceBroadcast component", () => { + result.getByTestId("voice-broadcast-body"); }); }); }); From 460f60e99d447fe1e47b14f6e223acbf1413c4c0 Mon Sep 17 00:00:00 2001 From: Florian Duros Date: Wed, 19 Oct 2022 12:45:51 +0200 Subject: [PATCH 008/113] First attempt to make the edition works in the WysiwygComposer --- src/components/views/messages/TextualBody.tsx | 6 +- .../views/rooms/BasicMessageComposer.tsx | 2 + .../views/rooms/EditMessageComposer.tsx | 1 + .../views/rooms/MessageComposer.tsx | 34 ++-- .../wysiwyg_composer/EditWysiwygComposer.tsx | 105 ++++++++++++ .../wysiwyg_composer/SendWysiwygComposer.tsx | 46 ++++++ .../wysiwyg_composer/WysiwygComposer.tsx | 68 -------- .../components/EditionButtons.tsx | 36 ++++ .../{ => components}/Editor.tsx | 0 .../{ => components}/FormattingButtons.tsx | 13 +- .../components/WysiwygComposer.tsx | 49 ++++++ .../hooks/useWysiwygEditActionHandler.ts | 48 ++++++ .../hooks/useWysiwygSendActionHandler.ts | 56 +++++++ .../utils.ts} | 36 +--- .../views/rooms/wysiwyg_composer/index.ts | 19 +++ .../views/rooms/wysiwyg_composer/types.ts | 21 +++ .../utils/createMessageContent.ts | 117 +++++++++++++ .../rooms/wysiwyg_composer/utils/editing.ts | 50 ++++++ .../utils/isContentModified.ts | 30 ++++ .../wysiwyg_composer/{ => utils}/message.ts | 155 +++++++++--------- src/dispatcher/actions.ts | 5 + .../views/rooms/MessageComposer-test.tsx | 2 +- .../FormattingButtons-test.tsx | 2 +- .../wysiwyg_composer/WysiwygComposer-test.tsx | 2 +- .../rooms/wysiwyg_composer/message-test.ts | 2 +- 25 files changed, 705 insertions(+), 200 deletions(-) create mode 100644 src/components/views/rooms/wysiwyg_composer/EditWysiwygComposer.tsx create mode 100644 src/components/views/rooms/wysiwyg_composer/SendWysiwygComposer.tsx delete mode 100644 src/components/views/rooms/wysiwyg_composer/WysiwygComposer.tsx create mode 100644 src/components/views/rooms/wysiwyg_composer/components/EditionButtons.tsx rename src/components/views/rooms/wysiwyg_composer/{ => components}/Editor.tsx (100%) rename src/components/views/rooms/wysiwyg_composer/{ => components}/FormattingButtons.tsx (87%) create mode 100644 src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer.tsx create mode 100644 src/components/views/rooms/wysiwyg_composer/hooks/useWysiwygEditActionHandler.ts create mode 100644 src/components/views/rooms/wysiwyg_composer/hooks/useWysiwygSendActionHandler.ts rename src/components/views/rooms/wysiwyg_composer/{useWysiwygActionHandler.ts => hooks/utils.ts} (52%) create mode 100644 src/components/views/rooms/wysiwyg_composer/index.ts create mode 100644 src/components/views/rooms/wysiwyg_composer/types.ts create mode 100644 src/components/views/rooms/wysiwyg_composer/utils/createMessageContent.ts create mode 100644 src/components/views/rooms/wysiwyg_composer/utils/editing.ts create mode 100644 src/components/views/rooms/wysiwyg_composer/utils/isContentModified.ts rename src/components/views/rooms/wysiwyg_composer/{ => utils}/message.ts (57%) diff --git a/src/components/views/messages/TextualBody.tsx b/src/components/views/messages/TextualBody.tsx index 23ba901acdf..983cbe51e37 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; @@ -562,7 +563,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/rooms/BasicMessageComposer.tsx b/src/components/views/rooms/BasicMessageComposer.tsx index d74c7b51484..962059091cc 100644 --- a/src/components/views/rooms/BasicMessageComposer.tsx +++ b/src/components/views/rooms/BasicMessageComposer.tsx @@ -833,6 +833,8 @@ export default class BasicMessageEditor extends React.Component } public insertPlaintext(text: string): void { + console.log('insertPlaintext', text); + debugger; this.modifiedFlag = true; const { model } = this.props; const { partCreator } = model; diff --git a/src/components/views/rooms/EditMessageComposer.tsx b/src/components/views/rooms/EditMessageComposer.tsx index 52312e1a998..bb014541278 100644 --- a/src/components/views/rooms/EditMessageComposer.tsx +++ b/src/components/views/rooms/EditMessageComposer.tsx @@ -350,6 +350,7 @@ class EditMessageComposer extends React.Component { +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; @@ -124,6 +125,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, @@ -315,7 +317,15 @@ export default class MessageComposer extends React.Component { } this.messageComposerInput.current?.sendMessage(); - this.composerSendMessage?.(); + // this.composerSendMessage?.(); + const isWysiwygComposerEnabled = SettingsStore.getValue("feature_wysiwyg_composer"); + + if (isWysiwygComposerEnabled) { + const { permalinkCreator, relation, replyToEvent } = this.props; + sendMessage(this.state.composerContent, + { mxClient: this.props.mxClient, roomContext: this.context, permalinkCreator, relation, replyToEvent }); + dis.dispatch({ action: Action.ClearAndFocusSendMessageComposer }); + } }; private onChange = (model: EditorModel) => { @@ -326,6 +336,7 @@ export default class MessageComposer extends React.Component { private onWysiwygChange = (content: string) => { this.setState({ + composerContent: content, isComposerEmpty: content?.length === 0, }); }; @@ -406,16 +417,10 @@ export default class MessageComposer extends React.Component { if (canSendMessages) { if (isWysiwygComposerEnabled) { controls.push( - - { (sendMessage) => { - this.composerSendMessage = sendMessage; - } } - , + />, ); } else { controls.push( @@ -555,3 +560,6 @@ export default class MessageComposer extends React.Component { ); } } + +const MessageComposerWithMatrixClient = withMatrixClientHOC(MessageComposer); +export default MessageComposerWithMatrixClient; diff --git a/src/components/views/rooms/wysiwyg_composer/EditWysiwygComposer.tsx b/src/components/views/rooms/wysiwyg_composer/EditWysiwygComposer.tsx new file mode 100644 index 00000000000..17b664410ce --- /dev/null +++ b/src/components/views/rooms/wysiwyg_composer/EditWysiwygComposer.tsx @@ -0,0 +1,105 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React, { forwardRef, RefObject, useMemo } from 'react'; +import { MatrixClient } from "matrix-js-sdk/src/matrix"; + +import { useRoomContext } from '../../../../contexts/RoomContext'; +import { useMatrixClientContext } from '../../../../contexts/MatrixClientContext'; +import EditorStateTransfer from '../../../../utils/EditorStateTransfer'; +import { CommandPartCreator, Part } from '../../../../editor/parts'; +import { IRoomState } from '../../../structures/RoomView'; +import SettingsStore from '../../../../settings/SettingsStore'; +import { parseEvent } from '../../../../editor/deserialize'; +import { WysiwygComposer } from './components/WysiwygComposer'; +import { EditionButtons } from './components/EditionButtons'; +import { useWysiwygEditActionHandler } from './hooks/useWysiwygEditActionHandler'; +import { endEditing } from './utils/editing'; +import { editMessage } from './utils/message'; + +function parseEditorStateTransfer( + editorStateTransfer: EditorStateTransfer, + roomContext: IRoomState, + mxClient: MatrixClient, +) { + if (!roomContext.room) { + return; + } + + const { room } = roomContext; + + const partCreator = new CommandPartCreator(room, mxClient); + + let parts: Part[]; + if (editorStateTransfer.hasEditorState()) { + // if restoring state from a previous editor, + // restore serialized parts from the state + parts = editorStateTransfer.getSerializedParts().map(p => partCreator.deserializePart(p)); + } else { + // otherwise, either restore serialized parts from localStorage or parse the body of the event + // TODO local storage + // const restoredParts = this.restoreStoredEditorState(partCreator); + + if (editorStateTransfer.getEvent().getContent().format === 'org.matrix.custom.html') { + return editorStateTransfer.getEvent().getContent().formatted_body || ""; + } + + parts = parseEvent(editorStateTransfer.getEvent(), partCreator, { + shouldEscape: SettingsStore.getValue("MessageComposerInput.useMarkdown"), + }); + } + + return parts.reduce((content, part) => content + part.text, ''); + // Todo local storage + // this.saveStoredEditorState(); +} + +interface ContentProps { + disabled: boolean; +} + +const Content = forwardRef( + function Content({ disabled }: ContentProps, forwardRef: RefObject) { + useWysiwygEditActionHandler(disabled, forwardRef); + return null; + }, +); + +interface EditWysiwygComposerProps { + disabled?: boolean; + onChange?: (content: string) => void; + editorStateTransfer?: EditorStateTransfer; +} + +export function EditWysiwygComposer({ editorStateTransfer, ...props }: EditWysiwygComposerProps) { + const roomContext = useRoomContext(); + const mxClient = useMatrixClientContext(); + + const initialContent = useMemo(() => { + if (editorStateTransfer) { + return parseEditorStateTransfer(editorStateTransfer, roomContext, mxClient); + } + }, [editorStateTransfer, roomContext, mxClient]); + const isReady = !editorStateTransfer || Boolean(initialContent); + + return isReady && { (ref, wysiwyg, content) => ( + <> + + endEditing(roomContext)} onSaveClick={() => editMessage(content, { roomContext, mxClient, editorStateTransfer })} /> + ) + } + ; +} 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..577374e116f --- /dev/null +++ b/src/components/views/rooms/wysiwyg_composer/SendWysiwygComposer.tsx @@ -0,0 +1,46 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React, { forwardRef, RefObject } from 'react'; + +import { useWysiwygSendActionHandler } from './hooks/useWysiwygSendActionHandler'; +import { WysiwygComposer } from './components/WysiwygComposer'; +import { Wysiwyg } from './types'; + +interface SendWysiwygComposerProps { + disabled?: boolean; + onChange?: (content: string) => void; +} + +export function SendWysiwygComposer(props: SendWysiwygComposerProps) { + return ( + { (ref, wysiwyg) => ( + + ) } + ); +} + +interface ContentProps { + disabled: boolean; + wysiwyg: Wysiwyg; +} + +const Content = forwardRef( + function Content({ disabled, wysiwyg }: ContentProps, forwardRef: RefObject) { + useWysiwygSendActionHandler(disabled, forwardRef, wysiwyg); + return null; + }, +); 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..20a3df2a7f7 --- /dev/null +++ b/src/components/views/rooms/wysiwyg_composer/components/EditionButtons.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 React, { MouseEventHandler } from 'react'; + +import { _t } from '../../../../../languageHandler'; +import AccessibleButton from '../../../elements/AccessibleButton'; + +interface EditionButtonsProps { + onCancelClick: MouseEventHandler; + onSaveClick: MouseEventHandler; +} + +export function EditionButtons({ onCancelClick, onSaveClick }: 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 87% rename from src/components/views/rooms/wysiwyg_composer/FormattingButtons.tsx rename to src/components/views/rooms/wysiwyg_composer/components/FormattingButtons.tsx index 19941ad3f94..c806c278615 100644 --- a/src/components/views/rooms/wysiwyg_composer/FormattingButtons.tsx +++ b/src/components/views/rooms/wysiwyg_composer/components/FormattingButtons.tsx @@ -18,11 +18,12 @@ import React, { MouseEventHandler } from "react"; import { useWysiwyg } from "@matrix-org/matrix-wysiwyg"; import classNames from "classnames"; -import AccessibleTooltipButton from "../../elements/AccessibleTooltipButton"; -import { Alignment } from "../../elements/Tooltip"; -import { KeyboardShortcut } from "../../settings/KeyboardShortcut"; -import { KeyCombo } from "../../../../KeyBindingsManager"; -import { _td } from "../../../../languageHandler"; +import AccessibleTooltipButton from "../../../elements/AccessibleTooltipButton"; +import { Alignment } from "../../../elements/Tooltip"; +import { KeyboardShortcut } from "../../../settings/KeyboardShortcut"; +import { KeyCombo } from "../../../../../KeyBindingsManager"; +import { _td } from "../../../../../languageHandler"; +import { Wysiwyg } from "../types"; interface TooltipProps { label: string; @@ -55,7 +56,7 @@ function Button({ label, keyCombo, onClick, isActive, className }: ButtonProps) } interface FormattingButtonsProps { - composer: ReturnType['wysiwyg']; + composer: Wysiwyg; formattingStates: ReturnType['formattingStates']; } diff --git a/src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer.tsx b/src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer.tsx new file mode 100644 index 00000000000..3e63c35fe35 --- /dev/null +++ b/src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer.tsx @@ -0,0 +1,49 @@ +/* +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, useEffect } from 'react'; +import { useWysiwyg } from "@matrix-org/matrix-wysiwyg"; + +import { FormattingButtons } from './FormattingButtons'; +import { Editor } from './Editor'; +import { Wysiwyg } from '../types'; + +interface WysiwygComposerProps { + disabled?: boolean; + onChange?: (content: string) => void; + initialContent?: string; + children?: (ref: MutableRefObject, wysiwyg: Wysiwyg, content: string) => ReactNode; +} + +export function WysiwygComposer( + { disabled = false, onChange, initialContent, children }: WysiwygComposerProps, +) { + const { ref, isWysiwygReady, content, formattingStates, wysiwyg } = useWysiwyg({ initialContent }); + + useEffect(() => { + if (!disabled && content !== null) { + onChange?.(content); + } + }, [onChange, content, disabled]); + + return ( +
+ + + { children?.(ref, wysiwyg, content) } +
+ ); +} diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/useWysiwygEditActionHandler.ts b/src/components/views/rooms/wysiwyg_composer/hooks/useWysiwygEditActionHandler.ts new file mode 100644 index 00000000000..2cbd7cf52cc --- /dev/null +++ b/src/components/views/rooms/wysiwyg_composer/hooks/useWysiwygEditActionHandler.ts @@ -0,0 +1,48 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { RefObject, useCallback, useRef } from "react"; + +import defaultDispatcher from "../../../../../dispatcher/dispatcher"; +import { Action } from "../../../../../dispatcher/actions"; +import { ActionPayload } from "../../../../../dispatcher/payloads"; +import { TimelineRenderingType, useRoomContext } from "../../../../../contexts/RoomContext"; +import { useDispatcher } from "../../../../../hooks/useDispatcher"; +import { focusComposer } from "./utils"; + +export function useWysiwygEditActionHandler( + disabled: boolean, + composerElement: RefObject, +) { + const roomContext = useRoomContext(); + const timeoutId = useRef(); + + const handler = useCallback((payload: ActionPayload) => { + // don't let the user into the composer if it is disabled - all of these branches lead + // to the cursor being in the composer + if (disabled || !composerElement.current) return; + + const context = payload.context ?? TimelineRenderingType.Room; + + switch (payload.action) { + case Action.FocusSendMessageComposer: + focusComposer(composerElement, context, roomContext, timeoutId); + break; + } + }, [disabled, composerElement, timeoutId, roomContext]); + + useDispatcher(defaultDispatcher, handler); +} diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/useWysiwygSendActionHandler.ts b/src/components/views/rooms/wysiwyg_composer/hooks/useWysiwygSendActionHandler.ts new file mode 100644 index 00000000000..41169a4e2d3 --- /dev/null +++ b/src/components/views/rooms/wysiwyg_composer/hooks/useWysiwygSendActionHandler.ts @@ -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 { RefObject, useCallback, useRef } from "react"; + +import defaultDispatcher from "../../../../../dispatcher/dispatcher"; +import { Action } from "../../../../../dispatcher/actions"; +import { ActionPayload } from "../../../../../dispatcher/payloads"; +import { TimelineRenderingType, useRoomContext } from "../../../../../contexts/RoomContext"; +import { useDispatcher } from "../../../../../hooks/useDispatcher"; +import { Wysiwyg } from "../types"; +import { focusComposer } from "./utils"; + +export function useWysiwygSendActionHandler( + disabled: boolean, + composerElement: RefObject, + wysiwyg: Wysiwyg, +) { + const roomContext = useRoomContext(); + const timeoutId = useRef(); + + const handler = useCallback((payload: ActionPayload) => { + // don't let the user into the composer if it is disabled - all of these branches lead + // to the cursor being in the composer + if (disabled || !composerElement.current) return; + + const context = payload.context ?? TimelineRenderingType.Room; + + switch (payload.action) { + case "reply_to_event": + case Action.FocusSendMessageComposer: + focusComposer(composerElement, context, roomContext, timeoutId); + break; + case Action.ClearAndFocusSendMessageComposer: + wysiwyg.clear(); + focusComposer(composerElement, context, roomContext, timeoutId); + break; + // TODO: case Action.ComposerInsert: - see SendMessageComposer + } + }, [disabled, composerElement, wysiwyg, timeoutId, roomContext]); + + useDispatcher(defaultDispatcher, handler); +} diff --git a/src/components/views/rooms/wysiwyg_composer/useWysiwygActionHandler.ts b/src/components/views/rooms/wysiwyg_composer/hooks/utils.ts similarity index 52% rename from src/components/views/rooms/wysiwyg_composer/useWysiwygActionHandler.ts rename to src/components/views/rooms/wysiwyg_composer/hooks/utils.ts index 683498d485e..eab855e0868 100644 --- a/src/components/views/rooms/wysiwyg_composer/useWysiwygActionHandler.ts +++ b/src/components/views/rooms/wysiwyg_composer/hooks/utils.ts @@ -14,40 +14,10 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { useRef } from "react"; +import { TimelineRenderingType } from "../../../../../contexts/RoomContext"; +import { IRoomState } from "../../../../structures/RoomView"; -import defaultDispatcher from "../../../../dispatcher/dispatcher"; -import { Action } from "../../../../dispatcher/actions"; -import { ActionPayload } from "../../../../dispatcher/payloads"; -import { IRoomState } from "../../../structures/RoomView"; -import { TimelineRenderingType, useRoomContext } from "../../../../contexts/RoomContext"; -import { useDispatcher } from "../../../../hooks/useDispatcher"; - -export function useWysiwygActionHandler( - disabled: boolean, - composerElement: React.MutableRefObject, -) { - const roomContext = useRoomContext(); - const timeoutId = useRef(); - - useDispatcher(defaultDispatcher, (payload: ActionPayload) => { - // don't let the user into the composer if it is disabled - all of these branches lead - // to the cursor being in the composer - if (disabled) return; - - const context = payload.context ?? TimelineRenderingType.Room; - - switch (payload.action) { - case "reply_to_event": - case Action.FocusSendMessageComposer: - focusComposer(composerElement, context, roomContext, timeoutId); - break; - // TODO: case Action.ComposerInsert: - see SendMessageComposer - } - }); -} - -function focusComposer( +export function focusComposer( composerElement: React.MutableRefObject, renderingType: TimelineRenderingType, roomContext: IRoomState, diff --git a/src/components/views/rooms/wysiwyg_composer/index.ts b/src/components/views/rooms/wysiwyg_composer/index.ts new file mode 100644 index 00000000000..ec8c9cff232 --- /dev/null +++ b/src/components/views/rooms/wysiwyg_composer/index.ts @@ -0,0 +1,19 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +export { SendWysiwygComposer } from './SendWysiwygComposer'; +export { EditWysiwygComposer } from './EditWysiwygComposer'; +export { sendMessage } from './utils/message'; diff --git a/src/components/views/rooms/wysiwyg_composer/types.ts b/src/components/views/rooms/wysiwyg_composer/types.ts new file mode 100644 index 00000000000..6c57ce6a86e --- /dev/null +++ b/src/components/views/rooms/wysiwyg_composer/types.ts @@ -0,0 +1,21 @@ +/* +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 { useWysiwyg } from "@matrix-org/matrix-wysiwyg"; + +// TODO +// Change when the matrix-wysiwyg typescript definition will be refined +export type Wysiwyg = ReturnType['wysiwyg']; diff --git a/src/components/views/rooms/wysiwyg_composer/utils/createMessageContent.ts b/src/components/views/rooms/wysiwyg_composer/utils/createMessageContent.ts new file mode 100644 index 00000000000..fe7f7706b4f --- /dev/null +++ b/src/components/views/rooms/wysiwyg_composer/utils/createMessageContent.ts @@ -0,0 +1,117 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { IContent, IEventRelation, MatrixEvent, MsgType } from "matrix-js-sdk/src/matrix"; + +import { RoomPermalinkCreator } from "../../../../../utils/permalinks/Permalinks"; +import { addReplyToMessageContent } from "../../../../../utils/Reply"; + +// Merges favouring the given relation +function attachRelation(content: IContent, relation?: IEventRelation): void { + if (relation) { + content['m.relates_to'] = { + ...(content['m.relates_to'] || {}), + ...relation, + }; + } +} + +function getHtmlReplyFallback(mxEvent: MatrixEvent): string { + const html = mxEvent.getContent().formatted_body; + if (!html) { + return ""; + } + const rootNode = new DOMParser().parseFromString(html, "text/html").body; + const mxReply = rootNode.querySelector("mx-reply"); + return (mxReply && mxReply.outerHTML) || ""; +} + +interface CreateMessageContentParams { + relation?: IEventRelation; + replyToEvent?: MatrixEvent; + permalinkCreator?: RoomPermalinkCreator; + includeReplyLegacyFallback?: boolean; + editedEvent?: MatrixEvent; +} + +export function createMessageContent( + message: string, + { relation, replyToEvent, permalinkCreator, includeReplyLegacyFallback = true, editedEvent }: + CreateMessageContentParams, +): IContent { + // TODO emote ? + + const isReply = Boolean(replyToEvent?.replyEventId); + const isEditing = Boolean(editedEvent); + + /*const isEmote = containsEmote(model); + if (isEmote) { + model = stripEmoteCommand(model); + } + if (startsWith(model, "//")) { + model = stripPrefix(model, "/"); + } + model = unescapeMessage(model);*/ + + // const body = textSerialize(model); + const body = message; + + const content: IContent = { + // TODO emote + // msgtype: isEmote ? "m.emote" : "m.text", + msgtype: MsgType.Text, + body: body, + }; + + // TODO markdown support + + /*const formattedBody = htmlSerializeIfNeeded(model, { + forceHTML: !!replyToEvent, + useMarkdown: SettingsStore.getValue("MessageComposerInput.useMarkdown"), + });*/ + const formattedBody = message; + + if (formattedBody) { + content.format = "org.matrix.custom.html"; + + const htmlPrefix = isReply ? getHtmlReplyFallback(editedEvent) : ''; + content.formatted_body = isEditing ? `${htmlPrefix} * ${formattedBody}` : formattedBody; + + if (isEditing) { + content['m.new_content'] = { + "msgtype": content.msgtype, + "body": body, + "format": "org.matrix.custom.html", + 'formatted_body': formattedBody, + }; + } + } + + const newRelation = isEditing ? + { ...relation, 'rel_type': 'm.replace', 'event_id': editedEvent.getId() } + : relation; + + attachRelation(content, newRelation); + + if (!isEditing && replyToEvent && permalinkCreator) { + addReplyToMessageContent(content, replyToEvent, { + permalinkCreator, + includeLegacyFallback: includeReplyLegacyFallback, + }); + } + + return content; +} diff --git a/src/components/views/rooms/wysiwyg_composer/utils/editing.ts b/src/components/views/rooms/wysiwyg_composer/utils/editing.ts new file mode 100644 index 00000000000..a0cb6083830 --- /dev/null +++ b/src/components/views/rooms/wysiwyg_composer/utils/editing.ts @@ -0,0 +1,50 @@ +/* +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 { EventStatus, MatrixClient } from "matrix-js-sdk/src/matrix"; + +import { IRoomState } from "../../../../structures/RoomView"; +import dis from '../../../../../dispatcher/dispatcher'; +import { Action } from "../../../../../dispatcher/actions"; +import EditorStateTransfer from "../../../../../utils/EditorStateTransfer"; + +export function endEditing(roomContext: IRoomState) { + // todo local storage + // localStorage.removeItem(this.editorRoomKey); + // localStorage.removeItem(this.editorStateKey); + + // close the event editing and focus composer + dis.dispatch({ + action: Action.EditEvent, + event: null, + timelineRenderingType: roomContext.timelineRenderingType, + }); + dis.dispatch({ + action: Action.FocusSendMessageComposer, + context: roomContext.timelineRenderingType, + }); +} + +export function cancelPreviousPendingEdit(mxClient: MatrixClient, editorStateTransfer: EditorStateTransfer) { + const originalEvent = editorStateTransfer.getEvent(); + const previousEdit = originalEvent.replacingEvent(); + if (previousEdit && ( + previousEdit.status === EventStatus.QUEUED || + previousEdit.status === EventStatus.NOT_SENT + )) { + mxClient.cancelPendingEvent(previousEdit); + } +} diff --git a/src/components/views/rooms/wysiwyg_composer/utils/isContentModified.ts b/src/components/views/rooms/wysiwyg_composer/utils/isContentModified.ts new file mode 100644 index 00000000000..88715dda385 --- /dev/null +++ b/src/components/views/rooms/wysiwyg_composer/utils/isContentModified.ts @@ -0,0 +1,30 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { IContent } from "matrix-js-sdk/src/matrix"; + +import EditorStateTransfer from "../../../../../utils/EditorStateTransfer"; + +export function isContentModified(newContent: IContent, editorStateTransfer: EditorStateTransfer): boolean { + // if nothing has changed then bail + const oldContent = editorStateTransfer.getEvent().getContent(); + if (oldContent["msgtype"] === newContent["msgtype"] && oldContent["body"] === newContent["body"] && + oldContent["format"] === newContent["format"] && + oldContent["formatted_body"] === newContent["formatted_body"]) { + return false; + } + return true; +} diff --git a/src/components/views/rooms/wysiwyg_composer/message.ts b/src/components/views/rooms/wysiwyg_composer/utils/message.ts similarity index 57% rename from src/components/views/rooms/wysiwyg_composer/message.ts rename to src/components/views/rooms/wysiwyg_composer/utils/message.ts index 5569af02a95..0f4de2d8a30 100644 --- a/src/components/views/rooms/wysiwyg_composer/message.ts +++ b/src/components/views/rooms/wysiwyg_composer/utils/message.ts @@ -19,90 +19,32 @@ import { IContent, IEventRelation, MatrixEvent } from 'matrix-js-sdk/src/models/ import { MatrixClient } from "matrix-js-sdk/src/matrix"; import { THREAD_RELATION_TYPE } from "matrix-js-sdk/src/models/thread"; -import { PosthogAnalytics } from "../../../../PosthogAnalytics"; -import SettingsStore from "../../../../settings/SettingsStore"; -import { decorateStartSendingTime, sendRoundTripMetric } from "../../../../sendTimePerformanceMetrics"; -import { RoomPermalinkCreator } from "../../../../utils/permalinks/Permalinks"; -import { doMaybeLocalRoomAction } from "../../../../utils/local-room"; -import { CHAT_EFFECTS } from "../../../../effects"; -import { containsEmoji } from "../../../../effects/utils"; -import { IRoomState } from "../../../structures/RoomView"; -import dis from '../../../../dispatcher/dispatcher'; -import { addReplyToMessageContent } from "../../../../utils/Reply"; - -// Merges favouring the given relation -function attachRelation(content: IContent, relation?: IEventRelation): void { - if (relation) { - content['m.relates_to'] = { - ...(content['m.relates_to'] || {}), - ...relation, - }; - } -} +import { PosthogAnalytics } from "../../../../../PosthogAnalytics"; +import SettingsStore from "../../../../../settings/SettingsStore"; +import { decorateStartSendingTime, sendRoundTripMetric } from "../../../../../sendTimePerformanceMetrics"; +import { RoomPermalinkCreator } from "../../../../../utils/permalinks/Permalinks"; +import { doMaybeLocalRoomAction } from "../../../../../utils/local-room"; +import { CHAT_EFFECTS } from "../../../../../effects"; +import { containsEmoji } from "../../../../../effects/utils"; +import { IRoomState } from "../../../../structures/RoomView"; +import dis from '../../../../../dispatcher/dispatcher'; +import { createRedactEventDialog } from "../../../dialogs/ConfirmRedactDialog"; +import { endEditing, cancelPreviousPendingEdit } from "./editing"; +import EditorStateTransfer from "../../../../../utils/EditorStateTransfer"; +import { createMessageContent } from "./createMessageContent"; +import { isContentModified } from "./isContentModified"; interface SendMessageParams { mxClient: MatrixClient; relation?: IEventRelation; replyToEvent?: MatrixEvent; roomContext: IRoomState; - permalinkCreator: RoomPermalinkCreator; + permalinkCreator?: RoomPermalinkCreator; includeReplyLegacyFallback?: boolean; } -// exported for tests -export function createMessageContent( - message: string, - { relation, replyToEvent, permalinkCreator, includeReplyLegacyFallback = true }: - Omit, -): IContent { - // TODO emote ? - - /*const isEmote = containsEmote(model); - if (isEmote) { - model = stripEmoteCommand(model); - } - if (startsWith(model, "//")) { - model = stripPrefix(model, "/"); - } - model = unescapeMessage(model);*/ - - // const body = textSerialize(model); - const body = message; - - const content: IContent = { - // TODO emote - // msgtype: isEmote ? "m.emote" : "m.text", - msgtype: "m.text", - body: body, - }; - - // TODO markdown support - - /*const formattedBody = htmlSerializeIfNeeded(model, { - forceHTML: !!replyToEvent, - useMarkdown: SettingsStore.getValue("MessageComposerInput.useMarkdown"), - });*/ - const formattedBody = message; - - if (formattedBody) { - content.format = "org.matrix.custom.html"; - content.formatted_body = formattedBody; - } - - attachRelation(content, relation); - - if (replyToEvent) { - addReplyToMessageContent(content, replyToEvent, { - permalinkCreator, - includeLegacyFallback: includeReplyLegacyFallback, - }); - } - - return content; -} - export function sendMessage( - message: string, + html: string, { roomContext, mxClient, ...params }: SendMessageParams, ) { const { relation, replyToEvent } = params; @@ -113,6 +55,7 @@ export function sendMessage( eventName: "Composer", isEditing: false, isReply: Boolean(replyToEvent), + // TODO thread inThread: relation?.rel_type === THREAD_RELATION_TYPE.name, }; @@ -133,7 +76,7 @@ export function sendMessage( if (!content) { content = createMessageContent( - message, + html, params, ); } @@ -197,3 +140,65 @@ export function sendMessage( return prom; } + +interface EditMessageParams { + mxClient: MatrixClient; + roomContext: IRoomState; + editorStateTransfer: EditorStateTransfer; +} + +export function editMessage( + html: string, + { roomContext, mxClient, editorStateTransfer }: EditMessageParams, +) { + const editedEvent = editorStateTransfer.getEvent(); + + PosthogAnalytics.instance.trackEvent({ + eventName: "Composer", + isEditing: true, + inThread: Boolean(editedEvent?.getThread()), + isReply: Boolean(editedEvent.replyEventId), + }); + + // Replace emoticon at the end of the message + /* if (SettingsStore.getValue('MessageComposerInput.autoReplaceEmoji')) { + const caret = this.editorRef.current?.getCaret(); + const position = this.model.positionForOffset(caret.offset, caret.atNodeEnd); + this.editorRef.current?.replaceEmoticon(position, REGEX_EMOTICON); + }*/ + const editContent = createMessageContent(html, { editedEvent }); + const newContent = editContent["m.new_content"]; + + const shouldSend = true; + + if (newContent?.body === '') { + cancelPreviousPendingEdit(mxClient, editorStateTransfer); + createRedactEventDialog({ + mxEvent: editedEvent, + onCloseDialog: () => { + endEditing(roomContext); + }, + }); + return; + } + + // If content is modified then send an updated event into the room + if (isContentModified(newContent, editorStateTransfer)) { + const roomId = editedEvent.getRoomId(); + + // TODO Slash Commands + + if (shouldSend) { + cancelPreviousPendingEdit(mxClient, editorStateTransfer); + + const event = editorStateTransfer.getEvent(); + const threadId = event.threadRootId || null; + + console.log('editContent', editContent); + mxClient.sendMessage(roomId, threadId, editContent); + dis.dispatch({ action: "message_sent" }); + } + } + + endEditing(roomContext); +} diff --git a/src/dispatcher/actions.ts b/src/dispatcher/actions.ts index 2b2e443e81d..7d2d935f705 100644 --- a/src/dispatcher/actions.ts +++ b/src/dispatcher/actions.ts @@ -75,6 +75,11 @@ export enum Action { */ FocusSendMessageComposer = "focus_send_message_composer", + /** + * Clear the to the send message composer. Should be used with a FocusComposerPayload. + */ + ClearAndFocusSendMessageComposer = "clear_focus_send_message_composer", + /** * Focuses the user's cursor to the edit message composer. Should be used with a FocusComposerPayload. */ diff --git a/test/components/views/rooms/MessageComposer-test.tsx b/test/components/views/rooms/MessageComposer-test.tsx index bc0b26f745a..596cf0bcfed 100644 --- a/test/components/views/rooms/MessageComposer-test.tsx +++ b/test/components/views/rooms/MessageComposer-test.tsx @@ -39,7 +39,7 @@ import { SendMessageComposer } from "../../../../src/components/views/rooms/Send import { E2EStatus } from "../../../../src/utils/ShieldUtils"; import { addTextToComposer } from "../../../test-utils/composer"; import UIStore, { UI_EVENTS } from "../../../../src/stores/UIStore"; -import { WysiwygComposer } from "../../../../src/components/views/rooms/wysiwyg_composer/WysiwygComposer"; +import { WysiwygComposer } from "../../../../src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer"; // The wysiwyg fetch wasm bytes and a specific workaround is needed to make it works in a node (jest) environnement // See https://github.com/matrix-org/matrix-wysiwyg/blob/main/platforms/web/test.setup.ts diff --git a/test/components/views/rooms/wysiwyg_composer/FormattingButtons-test.tsx b/test/components/views/rooms/wysiwyg_composer/FormattingButtons-test.tsx index 6c3e8573ae4..a9838ecacaa 100644 --- a/test/components/views/rooms/wysiwyg_composer/FormattingButtons-test.tsx +++ b/test/components/views/rooms/wysiwyg_composer/FormattingButtons-test.tsx @@ -18,7 +18,7 @@ import React from 'react'; import { render, screen } from "@testing-library/react"; import userEvent from '@testing-library/user-event'; -import { FormattingButtons } from "../../../../../src/components/views/rooms/wysiwyg_composer/FormattingButtons"; +import { FormattingButtons } from "../../../../../src/components/views/rooms/wysiwyg_composer/components/FormattingButtons"; describe('FormattingButtons', () => { const wysiwyg = { diff --git a/test/components/views/rooms/wysiwyg_composer/WysiwygComposer-test.tsx b/test/components/views/rooms/wysiwyg_composer/WysiwygComposer-test.tsx index b0aa838879b..91020250ae7 100644 --- a/test/components/views/rooms/wysiwyg_composer/WysiwygComposer-test.tsx +++ b/test/components/views/rooms/wysiwyg_composer/WysiwygComposer-test.tsx @@ -24,7 +24,7 @@ import defaultDispatcher from "../../../../../src/dispatcher/dispatcher"; import { Action } from "../../../../../src/dispatcher/actions"; import { IRoomState } from "../../../../../src/components/structures/RoomView"; import { Layout } from "../../../../../src/settings/enums/Layout"; -import { WysiwygComposer } from "../../../../../src/components/views/rooms/wysiwyg_composer/WysiwygComposer"; +import { WysiwygComposer } from "../../../../../src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer"; import { createTestClient, mkEvent, mkStubRoom } from "../../../../test-utils"; // The wysiwyg fetch wasm bytes and a specific workaround is needed to make it works in a node (jest) environnement diff --git a/test/components/views/rooms/wysiwyg_composer/message-test.ts b/test/components/views/rooms/wysiwyg_composer/message-test.ts index 712b671c9f7..50f0f77c1ab 100644 --- a/test/components/views/rooms/wysiwyg_composer/message-test.ts +++ b/test/components/views/rooms/wysiwyg_composer/message-test.ts @@ -15,7 +15,7 @@ limitations under the License. */ import { IRoomState } from "../../../../../src/components/structures/RoomView"; -import { createMessageContent, sendMessage } from "../../../../../src/components/views/rooms/wysiwyg_composer/message"; +import { createMessageContent, sendMessage } from "../../../../../src/components/views/rooms/wysiwyg_composer/utils/message"; import { TimelineRenderingType } from "../../../../../src/contexts/RoomContext"; import { Layout } from "../../../../../src/settings/enums/Layout"; import { createTestClient, mkEvent, mkStubRoom } from "../../../../test-utils"; From e946674df3be642eb06e17117390326d3d709df6 Mon Sep 17 00:00:00 2001 From: kegsay Date: Wed, 19 Oct 2022 13:07:03 +0100 Subject: [PATCH 009/113] Store refactor: use non-global stores in components (#9293) * Add Stores and StoresContext and use it in MatrixChat and RoomView Added a new kind of class: - Add God object `Stores` which will hold refs to all known stores and the `MatrixClient`. This object is NOT a singleton. - Add `StoresContext` to hold onto a ref of `Stores` for use inside components. `StoresContext` is created via: - Create `Stores` in `MatrixChat`, assigning the `MatrixClient` when we have one set. Currently sets the RVS to `RoomViewStore.instance`. - Wrap `MatrixChat`s `render()` function in a `StoresContext.Provider` so it can be used anywhere. `StoresContext` is currently only used in `RoomView` via the following changes: - Remove the HOC, which redundantly set `mxClient` as a prop. We don't need this as `RoomView` was using the client from `this.context`. - Change the type of context accepted from `MatrixClientContext` to `StoresContext`. - Modify alllll the places where `this.context` is used to interact with the client and suffix `.client`. - Modify places where we use `RoomViewStore.instance` and replace them with `this.context.roomViewStore`. This makes `RoomView` use a non-global instance of RVS. * Linting * SDKContext and make client an optional constructor arg * Move SDKContext to /src/contexts * Inject all RVS deps * Linting * Remove reset calls; deep copy the INITIAL_STATE to avoid test pollution * DI singletons used in RoomView; DI them in RoomView-test too * Initial RoomViewStore.instance after all files are imported to avoid cyclical deps * Lazily init stores to allow for circular dependencies Rather than stores accepting a list of other stores in their constructors, which doesn't work when A needs B and B needs A, make new-style stores simply accept Stores. When a store needs another store, they access it via `Stores` which then lazily constructs that store if it needs it. This breaks the circular dependency at constructor time, without needing to introduce wiring diagrams or any complex DI framework. * Delete RoomViewStore.instance Replaced with Stores.instance.roomViewStore * Linting * Move OverridableStores to test/TestStores * Rejig how eager stores get made; don't automatically do it else tests break * Linting * Linting and review comments * Fix new code to use Stores.instance * s/Stores/SdkContextClass/g * Update docs * Remove unused imports * Update src/stores/RoomViewStore.tsx Co-authored-by: Michael Telatynski <7t3chguy@gmail.com> * Remove empty c'tor to make sonar happy Co-authored-by: Michael Telatynski <7t3chguy@gmail.com> --- src/ContentMessages.ts | 4 +- src/Notifier.ts | 4 +- src/ScalarMessaging.ts | 4 +- src/SlashCommands.tsx | 8 +- src/audio/PlaybackQueue.ts | 4 +- src/components/structures/MatrixChat.tsx | 9 +- src/components/structures/RoomView.tsx | 239 +++++++++--------- src/components/structures/SpaceHierarchy.tsx | 4 +- src/components/structures/ThreadView.tsx | 4 +- .../views/beacon/RoomCallBanner.tsx | 4 +- .../views/context_menus/RoomContextMenu.tsx | 6 +- .../dialogs/spotlight/SpotlightDialog.tsx | 4 +- src/components/views/elements/AppTile.tsx | 4 +- .../views/right_panel/TimelineCard.tsx | 15 +- src/components/views/right_panel/UserInfo.tsx | 4 +- src/components/views/rooms/RoomList.tsx | 10 +- src/components/views/rooms/RoomTile.tsx | 8 +- .../views/spaces/QuickSettingsButton.tsx | 4 +- src/components/views/voip/PipView.tsx | 10 +- src/contexts/SDKContext.ts | 127 ++++++++++ src/stores/RoomViewStore.tsx | 38 ++- src/stores/right-panel/RightPanelStore.ts | 4 +- src/stores/room-list/RoomListStore.ts | 6 +- src/stores/room-list/SlidingRoomListStore.ts | 10 +- src/stores/spaces/SpaceStore.ts | 8 +- src/stores/widgets/StopGapWidget.ts | 6 +- src/stores/widgets/StopGapWidgetDriver.ts | 8 +- src/utils/DialogOpener.ts | 6 +- src/utils/leave-behaviour.ts | 4 +- src/utils/space.tsx | 6 +- test/SlashCommands-test.tsx | 8 +- test/TestStores.ts | 44 ++++ test/components/structures/RoomView-test.tsx | 41 +-- .../views/beacon/RoomCallBanner-test.tsx | 5 +- ...ewStore-test.tsx => RoomViewStore-test.ts} | 68 +++-- .../widgets/StopGapWidgetDriver-test.ts | 4 +- 36 files changed, 467 insertions(+), 275 deletions(-) create mode 100644 src/contexts/SDKContext.ts create mode 100644 test/TestStores.ts rename test/stores/{RoomViewStore-test.tsx => RoomViewStore-test.ts} (79%) 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/Notifier.ts b/src/Notifier.ts index dd0ebc296a2..cc84acb2fab 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"; @@ -435,7 +435,7 @@ export const Notifier = { if (actions?.notify) { this._performCustomEventHandling(ev); - if (RoomViewStore.instance.getRoomId() === room.roomId && + if (SdkContextClass.instance.roomViewStore.getRoomId() === room.roomId && UserActivity.sharedInstance().userActiveRecently() && !Modal.hasDialogs() ) { 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/SlashCommands.tsx b/src/SlashCommands.tsx index bbd936ce756..624c515b153 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,6 +69,7 @@ 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 { @@ -209,7 +209,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 +868,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 +909,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/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/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index 515355b63d9..06a73ff605f 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -137,6 +137,7 @@ import { TimelineRenderingType } from "../../contexts/RoomContext"; import { UseCaseSelection } from '../views/elements/UseCaseSelection'; import { ValidatedServerConfig } from '../../utils/ValidatedServerConfig'; import { isLocalRoom } from '../../utils/localRoom/isLocalRoom'; +import { SdkContextClass, SDKContext } from '../../contexts/SDKContext'; import { viewUserDeviceSettings } from '../../actions/handlers/viewUserDeviceSettings'; // legacy export @@ -238,9 +239,12 @@ export default class MatrixChat extends React.PureComponent { 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, @@ -762,6 +766,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 && @@ -2087,7 +2092,9 @@ export default class MatrixChat extends React.PureComponent { } return - { view } + + { view } + ; } } diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index 6425709ea74..77245e0eb81 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,6 +115,7 @@ 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"; @@ -133,7 +129,7 @@ if (DEBUG) { debuglog = logger.log.bind(console); } -interface IRoomProps extends MatrixClientProps { +interface IRoomProps { threepidInvite: IThreepidInvite; oobData?: IOOBData; @@ -381,13 +377,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, @@ -422,7 +418,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, @@ -430,25 +426,25 @@ 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(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(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); @@ -501,16 +497,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), }); @@ -518,12 +514,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; @@ -534,7 +530,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 @@ -549,45 +545,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 @@ -600,7 +596,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, ); @@ -616,21 +612,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(), }); } } @@ -657,7 +653,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 @@ -674,7 +670,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); @@ -784,7 +780,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; } @@ -817,7 +813,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 }); } } @@ -835,7 +831,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; } @@ -848,7 +844,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); } @@ -885,7 +881,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) { @@ -893,47 +889,47 @@ export class RoomView extends React.Component { } if (this.state.shouldPeek) { - this.context.stopPeeking(); + this.context.client.stopPeeking(); } // stop tracking room changes to format permalinks this.stopAllPermalinkCreators(); 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(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); + if (this.context.client) { + 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(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); - 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(); @@ -944,13 +940,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), }); }; @@ -1017,7 +1013,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: @@ -1043,7 +1039,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); @@ -1112,7 +1108,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) => { @@ -1145,7 +1141,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 @@ -1165,7 +1161,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 => { @@ -1202,7 +1198,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); @@ -1214,10 +1210,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({ @@ -1244,7 +1240,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(); @@ -1270,7 +1266,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), }); @@ -1283,7 +1279,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, ); @@ -1320,15 +1316,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; @@ -1374,7 +1370,7 @@ 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) @@ -1442,7 +1438,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>({ @@ -1499,13 +1495,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 @@ -1578,7 +1574,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) { @@ -1586,7 +1582,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); @@ -1658,7 +1654,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, @@ -1715,7 +1711,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, @@ -1742,13 +1738,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, @@ -1911,7 +1907,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, @@ -1924,7 +1920,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() { @@ -1953,7 +1949,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, ); @@ -1970,7 +1966,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"); @@ -2162,7 +2158,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(); @@ -2174,7 +2170,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 = ; @@ -2236,7 +2232,7 @@ export class RoomView extends React.Component { const auxPanel = ( @@ -2397,7 +2393,7 @@ export class RoomView extends React.Component { mainSplitBody = <> @@ -2451,7 +2447,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; @@ -2493,5 +2489,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 7336dfeb0cf..00ebfdacce0 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 042b8b3b924..a7b4ab10c8f 100644 --- a/src/components/structures/ThreadView.tsx +++ b/src/components/structures/ThreadView.tsx @@ -51,10 +51,10 @@ 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'; interface IProps { room: Room; @@ -113,7 +113,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, diff --git a/src/components/views/beacon/RoomCallBanner.tsx b/src/components/views/beacon/RoomCallBanner.tsx index 736c88649f1..6085fe141b3 100644 --- a/src/components/views/beacon/RoomCallBanner.tsx +++ b/src/components/views/beacon/RoomCallBanner.tsx @@ -24,13 +24,13 @@ import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; import { Action } from "../../../dispatcher/actions"; import { Call, ConnectionState, ElementCall } from "../../../models/Call"; import { useCall } from "../../../hooks/useCall"; -import { RoomViewStore } from "../../../stores/RoomViewStore"; import { useEventEmitterState } from "../../../hooks/useEventEmitter"; import { OwnBeaconStore, OwnBeaconStoreEvent, } from "../../../stores/OwnBeaconStore"; import { CallDurationFromEvent } from "../voip/CallDuration"; +import { SdkContextClass } from "../../../contexts/SDKContext"; interface RoomCallBannerProps { roomId: Room["roomId"]; @@ -114,7 +114,7 @@ const RoomCallBanner: React.FC = ({ 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/context_menus/RoomContextMenu.tsx b/src/components/views/context_menus/RoomContextMenu.tsx index b9923d92782..aadfd2d2688 100644 --- a/src/components/views/context_menus/RoomContextMenu.tsx +++ b/src/components/views/context_menus/RoomContextMenu.tsx @@ -37,7 +37,6 @@ import Modal from "../../../Modal"; import ExportDialog from "../dialogs/ExportDialog"; import { useFeatureEnabled } from "../../../hooks/useSettings"; import { usePinnedEvents } from "../right_panel/PinnedMessagesCard"; -import { RoomViewStore } from "../../../stores/RoomViewStore"; import { RightPanelPhases } from '../../../stores/right-panel/RightPanelStorePhases'; import { ROOM_NOTIFICATIONS_TAB } from "../dialogs/RoomSettingsDialog"; import { useEventEmitterState } from "../../../hooks/useEventEmitter"; @@ -50,6 +49,7 @@ import { getKeyBindingsManager } from "../../../KeyBindingsManager"; import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts"; import SettingsStore from "../../../settings/SettingsStore"; import DevtoolsDialog from "../dialogs/DevtoolsDialog"; +import { SdkContextClass } from "../../../contexts/SDKContext"; interface IProps extends IContextMenuProps { room: Room; @@ -332,7 +332,7 @@ const RoomContextMenu = ({ room, onFinished, ...props }: IProps) => { }; 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/dialogs/spotlight/SpotlightDialog.tsx b/src/components/views/dialogs/spotlight/SpotlightDialog.tsx index b04299869c1..dfec2ab5097 100644 --- a/src/components/views/dialogs/spotlight/SpotlightDialog.tsx +++ b/src/components/views/dialogs/spotlight/SpotlightDialog.tsx @@ -66,7 +66,7 @@ import { BreadcrumbsStore } from "../../../../stores/BreadcrumbsStore"; import { RoomNotificationState } from "../../../../stores/notifications/RoomNotificationState"; import { RoomNotificationStateStore } from "../../../../stores/notifications/RoomNotificationStateStore"; import { RecentAlgorithm } from "../../../../stores/room-list/algorithms/tag-sorting/RecentAlgorithm"; -import { RoomViewStore } from "../../../../stores/RoomViewStore"; +import { SdkContextClass } from "../../../../contexts/SDKContext"; import { getMetaSpaceName } from "../../../../stores/spaces"; import SpaceStore from "../../../../stores/spaces/SpaceStore"; import { DirectoryMember, Member, startDmOnFirstMessage } from "../../../../utils/direct-messages"; @@ -1060,7 +1060,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 => ( { ); 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/right_panel/TimelineCard.tsx b/src/components/views/right_panel/TimelineCard.tsx index f1eea5ad491..c88a47406a1 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/RoomList.tsx b/src/components/views/rooms/RoomList.tsx index 13b1011088d..0cd38f7b30d 100644 --- a/src/components/views/rooms/RoomList.tsx +++ b/src/components/views/rooms/RoomList.tsx @@ -38,7 +38,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, @@ -62,6 +61,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; @@ -421,7 +421,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 = @@ -436,19 +436,19 @@ export default class RoomList extends React.PureComponent { RoomListStore.instance.off(LISTS_UPDATE_EVENT, this.updateLists); SettingsStore.unwatchSetting(this.favouriteMessageWatcher); defaultDispatcher.unregister(this.dispatcherRef); - RoomViewStore.instance.off(UPDATE_EVENT, this.onRoomViewStoreUpdate); + SdkContextClass.instance.roomViewStore.off(UPDATE_EVENT, this.onRoomViewStoreUpdate); } 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/RoomTile.tsx b/src/components/views/rooms/RoomTile.tsx index 219295d23dc..68f4dfe4de2 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), @@ -146,7 +146,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), @@ -163,7 +163,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/spaces/QuickSettingsButton.tsx b/src/components/views/spaces/QuickSettingsButton.tsx index 2f4ee9315f3..eb7244f9943 100644 --- a/src/components/views/spaces/QuickSettingsButton.tsx +++ b/src/components/views/spaces/QuickSettingsButton.tsx @@ -36,7 +36,7 @@ import { Icon as FavoriteIcon } from '../../../../res/img/element-icons/roomlist import SettingsStore from "../../../settings/SettingsStore"; import Modal from "../../../Modal"; import DevtoolsDialog from "../dialogs/DevtoolsDialog"; -import { RoomViewStore } from "../../../stores/RoomViewStore"; +import { SdkContextClass } from "../../../contexts/SDKContext"; const QuickSettingsButton = ({ isPanelCollapsed = false }) => { const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu(); @@ -72,7 +72,7 @@ const QuickSettingsButton = ({ isPanelCollapsed = false }) => { onClick={() => { closeMenu(); Modal.createDialog(DevtoolsDialog, { - roomId: RoomViewStore.instance.getRoomId(), + roomId: SdkContextClass.instance.roomViewStore.getRoomId(), }, "mx_DevtoolsDialog_wrapper"); }} kind="danger_outline" diff --git a/src/components/views/voip/PipView.tsx b/src/components/views/voip/PipView.tsx index 0bebfe1bf32..3aaa9ac4308 100644 --- a/src/components/views/voip/PipView.tsx +++ b/src/components/views/voip/PipView.tsx @@ -21,7 +21,6 @@ import classNames from 'classnames'; import { Room } from "matrix-js-sdk/src/models/room"; import LegacyCallView from "./LegacyCallView"; -import { RoomViewStore } from '../../../stores/RoomViewStore'; import LegacyCallHandler, { LegacyCallHandlerEvent } from '../../../LegacyCallHandler'; import PersistentApp from "../elements/PersistentApp"; import { MatrixClientPeg } from '../../../MatrixClientPeg'; @@ -34,6 +33,7 @@ import ActiveWidgetStore, { ActiveWidgetStoreEvent } from '../../../stores/Activ import WidgetStore, { IApp } from "../../../stores/WidgetStore"; import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; import { UPDATE_EVENT } from '../../../stores/AsyncStore'; +import { SdkContextClass } from '../../../contexts/SDKContext'; import { CallStore } from "../../../stores/CallStore"; import { VoiceBroadcastRecording, @@ -129,7 +129,7 @@ class PipView extends React.Component { constructor(props: IProps) { super(props); - const roomId = RoomViewStore.instance.getRoomId(); + const roomId = SdkContextClass.instance.roomViewStore.getRoomId(); const [primaryCall, secondaryCalls] = getPrimarySecondaryCallsForPip(roomId); @@ -147,7 +147,7 @@ class PipView extends React.Component { public componentDidMount() { LegacyCallHandler.instance.addListener(LegacyCallHandlerEvent.CallChangeRoom, this.updateCalls); LegacyCallHandler.instance.addListener(LegacyCallHandlerEvent.CallState, this.updateCalls); - RoomViewStore.instance.addListener(UPDATE_EVENT, this.onRoomViewStoreUpdate); + SdkContextClass.instance.roomViewStore.addListener(UPDATE_EVENT, this.onRoomViewStoreUpdate); MatrixClientPeg.get().on(CallEvent.RemoteHoldUnhold, this.onCallRemoteHold); const room = MatrixClientPeg.get()?.getRoom(this.state.viewedRoomId); if (room) { @@ -164,7 +164,7 @@ class PipView extends React.Component { LegacyCallHandler.instance.removeListener(LegacyCallHandlerEvent.CallState, this.updateCalls); const cli = MatrixClientPeg.get(); cli?.removeListener(CallEvent.RemoteHoldUnhold, this.onCallRemoteHold); - RoomViewStore.instance.removeListener(UPDATE_EVENT, this.onRoomViewStoreUpdate); + SdkContextClass.instance.roomViewStore.removeListener(UPDATE_EVENT, this.onRoomViewStoreUpdate); const room = cli?.getRoom(this.state.viewedRoomId); if (room) { WidgetLayoutStore.instance.off(WidgetLayoutStore.emissionForRoom(room), this.updateCalls); @@ -186,7 +186,7 @@ class PipView extends React.Component { private onMove = () => this.movePersistedElement.current?.(); private onRoomViewStoreUpdate = () => { - const newRoomId = RoomViewStore.instance.getRoomId(); + const newRoomId = SdkContextClass.instance.roomViewStore.getRoomId(); const oldRoomId = this.state.viewedRoomId; if (newRoomId === oldRoomId) return; // The WidgetLayoutStore observer always tracks the currently viewed Room, diff --git a/src/contexts/SDKContext.ts b/src/contexts/SDKContext.ts new file mode 100644 index 00000000000..61905dca926 --- /dev/null +++ b/src/contexts/SDKContext.ts @@ -0,0 +1,127 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { MatrixClient } from "matrix-js-sdk/src/matrix"; +import { createContext } from "react"; + +import defaultDispatcher from "../dispatcher/dispatcher"; +import LegacyCallHandler from "../LegacyCallHandler"; +import { PosthogAnalytics } from "../PosthogAnalytics"; +import { SlidingSyncManager } from "../SlidingSyncManager"; +import { RoomNotificationStateStore } from "../stores/notifications/RoomNotificationStateStore"; +import RightPanelStore from "../stores/right-panel/RightPanelStore"; +import { RoomViewStore } from "../stores/RoomViewStore"; +import SpaceStore, { SpaceStoreClass } from "../stores/spaces/SpaceStore"; +import { WidgetLayoutStore } from "../stores/widgets/WidgetLayoutStore"; +import WidgetStore from "../stores/WidgetStore"; + +export const SDKContext = createContext(undefined); +SDKContext.displayName = "SDKContext"; + +/** + * A class which lazily initialises stores as and when they are requested, ensuring they remain + * as singletons scoped to this object. + */ +export class SdkContextClass { + /** + * The global SdkContextClass instance. This is a temporary measure whilst so many stores remain global + * as well. Over time, these stores should accept a `SdkContextClass` instance in their constructor. + * When all stores do this, this static variable can be deleted. + */ + public static readonly instance = new SdkContextClass(); + + // Optional as we don't have a client on initial load if unregistered. This should be set + // when the MatrixClient is first acquired in the dispatcher event Action.OnLoggedIn. + // It is only safe to set this once, as updating this value will NOT notify components using + // this Context. + public client?: MatrixClient; + + // All protected fields to make it easier to derive test stores + protected _RightPanelStore?: RightPanelStore; + protected _RoomNotificationStateStore?: RoomNotificationStateStore; + protected _RoomViewStore?: RoomViewStore; + protected _WidgetLayoutStore?: WidgetLayoutStore; + protected _WidgetStore?: WidgetStore; + protected _PosthogAnalytics?: PosthogAnalytics; + protected _SlidingSyncManager?: SlidingSyncManager; + protected _SpaceStore?: SpaceStoreClass; + protected _LegacyCallHandler?: LegacyCallHandler; + + /** + * Automatically construct stores which need to be created eagerly so they can register with + * the dispatcher. + */ + public constructEagerStores() { + this._RoomViewStore = this.roomViewStore; + } + + public get legacyCallHandler(): LegacyCallHandler { + if (!this._LegacyCallHandler) { + this._LegacyCallHandler = LegacyCallHandler.instance; + } + return this._LegacyCallHandler; + } + public get rightPanelStore(): RightPanelStore { + if (!this._RightPanelStore) { + this._RightPanelStore = RightPanelStore.instance; + } + return this._RightPanelStore; + } + public get roomNotificationStateStore(): RoomNotificationStateStore { + if (!this._RoomNotificationStateStore) { + this._RoomNotificationStateStore = RoomNotificationStateStore.instance; + } + return this._RoomNotificationStateStore; + } + public get roomViewStore(): RoomViewStore { + if (!this._RoomViewStore) { + this._RoomViewStore = new RoomViewStore( + defaultDispatcher, this, + ); + } + return this._RoomViewStore; + } + public get widgetLayoutStore(): WidgetLayoutStore { + if (!this._WidgetLayoutStore) { + this._WidgetLayoutStore = WidgetLayoutStore.instance; + } + return this._WidgetLayoutStore; + } + public get widgetStore(): WidgetStore { + if (!this._WidgetStore) { + this._WidgetStore = WidgetStore.instance; + } + return this._WidgetStore; + } + public get posthogAnalytics(): PosthogAnalytics { + if (!this._PosthogAnalytics) { + this._PosthogAnalytics = PosthogAnalytics.instance; + } + return this._PosthogAnalytics; + } + public get slidingSyncManager(): SlidingSyncManager { + if (!this._SlidingSyncManager) { + this._SlidingSyncManager = SlidingSyncManager.instance; + } + return this._SlidingSyncManager; + } + public get spaceStore(): SpaceStoreClass { + if (!this._SpaceStore) { + this._SpaceStore = SpaceStore.instance; + } + return this._SpaceStore; + } +} diff --git a/src/stores/RoomViewStore.tsx b/src/stores/RoomViewStore.tsx index 0a15ce18607..b3814f7a324 100644 --- a/src/stores/RoomViewStore.tsx +++ b/src/stores/RoomViewStore.tsx @@ -17,6 +17,7 @@ limitations under the License. */ import React, { ReactNode } from "react"; +import * as utils from 'matrix-js-sdk/src/utils'; import { MatrixError } from "matrix-js-sdk/src/http-api"; import { logger } from "matrix-js-sdk/src/logger"; import { ViewRoom as ViewRoomEvent } from "@matrix-org/analytics-events/types/typescript/ViewRoom"; @@ -27,7 +28,7 @@ import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { Optional } from "matrix-events-sdk"; import EventEmitter from "events"; -import { defaultDispatcher, MatrixDispatcher } from '../dispatcher/dispatcher'; +import { MatrixDispatcher } from '../dispatcher/dispatcher'; import { MatrixClientPeg } from '../MatrixClientPeg'; import Modal from '../Modal'; import { _t } from '../languageHandler'; @@ -35,10 +36,8 @@ import { getCachedRoomIDForAlias, storeRoomAliasInCache } from '../RoomAliasCach import { Action } from "../dispatcher/actions"; import { retry } from "../utils/promise"; import { TimelineRenderingType } from "../contexts/RoomContext"; -import { PosthogAnalytics } from "../PosthogAnalytics"; import { ViewRoomPayload } from "../dispatcher/payloads/ViewRoomPayload"; import DMRoomMap from "../utils/DMRoomMap"; -import SpaceStore from "./spaces/SpaceStore"; import { isMetaSpace, MetaSpace } from "./spaces"; import { JoinRoomPayload } from "../dispatcher/payloads/JoinRoomPayload"; import { JoinRoomReadyPayload } from "../dispatcher/payloads/JoinRoomReadyPayload"; @@ -47,9 +46,9 @@ import { ViewRoomErrorPayload } from "../dispatcher/payloads/ViewRoomErrorPayloa import ErrorDialog from "../components/views/dialogs/ErrorDialog"; import { ActiveRoomChangedPayload } from "../dispatcher/payloads/ActiveRoomChangedPayload"; import SettingsStore from "../settings/SettingsStore"; -import { SlidingSyncManager } from "../SlidingSyncManager"; import { awaitRoomDownSync } from "../utils/RoomUpgrade"; import { UPDATE_EVENT } from "./AsyncStore"; +import { SdkContextClass } from "../contexts/SDKContext"; import { CallStore } from "./CallStore"; const NUM_JOIN_RETRY = 5; @@ -131,17 +130,16 @@ type Listener = (isActive: boolean) => void; * A class for storing application state for RoomView. */ export class RoomViewStore extends EventEmitter { - // Important: This cannot be a dynamic getter (lazily-constructed instance) because - // otherwise we'll miss view_room dispatches during startup, breaking relaunches of - // the app. We need to eagerly create the instance. - public static readonly instance = new RoomViewStore(defaultDispatcher); - - private state: State = INITIAL_STATE; // initialize state + // initialize state as a copy of the initial state. We need to copy else one RVS can talk to + // another RVS via INITIAL_STATE as they share the same underlying object. Mostly relevant for tests. + private state = utils.deepCopy(INITIAL_STATE); private dis: MatrixDispatcher; private dispatchToken: string; - public constructor(dis: MatrixDispatcher) { + public constructor( + dis: MatrixDispatcher, private readonly stores: SdkContextClass, + ) { super(); this.resetDispatcher(dis); } @@ -248,7 +246,7 @@ export class RoomViewStore extends EventEmitter { : numMembers > 1 ? "Two" : "One"; - PosthogAnalytics.instance.trackEvent({ + this.stores.posthogAnalytics.trackEvent({ eventName: "JoinedRoom", trigger: payload.metricsTrigger, roomSize, @@ -291,17 +289,17 @@ export class RoomViewStore extends EventEmitter { if (payload.metricsTrigger !== null && payload.room_id !== this.state.roomId) { let activeSpace: ViewRoomEvent["activeSpace"]; - if (SpaceStore.instance.activeSpace === MetaSpace.Home) { + if (this.stores.spaceStore.activeSpace === MetaSpace.Home) { activeSpace = "Home"; - } else if (isMetaSpace(SpaceStore.instance.activeSpace)) { + } else if (isMetaSpace(this.stores.spaceStore.activeSpace)) { activeSpace = "Meta"; } else { - activeSpace = SpaceStore.instance.activeSpaceRoom.getJoinRule() === JoinRule.Public + activeSpace = this.stores.spaceStore.activeSpaceRoom?.getJoinRule() === JoinRule.Public ? "Public" : "Private"; } - PosthogAnalytics.instance.trackEvent({ + this.stores.posthogAnalytics.trackEvent({ eventName: "ViewRoom", trigger: payload.metricsTrigger, viaKeyboard: payload.metricsViaKeyboard, @@ -314,7 +312,7 @@ export class RoomViewStore extends EventEmitter { if (SettingsStore.getValue("feature_sliding_sync") && this.state.roomId !== payload.room_id) { if (this.state.subscribingRoomId && this.state.subscribingRoomId !== payload.room_id) { // unsubscribe from this room, but don't await it as we don't care when this gets done. - SlidingSyncManager.instance.setRoomVisible(this.state.subscribingRoomId, false); + this.stores.slidingSyncManager.setRoomVisible(this.state.subscribingRoomId, false); } this.setState({ subscribingRoomId: payload.room_id, @@ -332,11 +330,11 @@ export class RoomViewStore extends EventEmitter { }); // set this room as the room subscription. We need to await for it as this will fetch // all room state for this room, which is required before we get the state below. - await SlidingSyncManager.instance.setRoomVisible(payload.room_id, true); + await this.stores.slidingSyncManager.setRoomVisible(payload.room_id, true); // Whilst we were subscribing another room was viewed, so stop what we're doing and // unsubscribe if (this.state.subscribingRoomId !== payload.room_id) { - SlidingSyncManager.instance.setRoomVisible(payload.room_id, false); + this.stores.slidingSyncManager.setRoomVisible(payload.room_id, false); return; } // Re-fire the payload: we won't re-process it because the prev room ID == payload room ID now @@ -599,7 +597,7 @@ export class RoomViewStore extends EventEmitter { // // Not joined // } // } else { - // if (RoomViewStore.instance.isJoining()) { + // if (this.stores.roomViewStore.isJoining()) { // // show spinner // } else { // // show join prompt diff --git a/src/stores/right-panel/RightPanelStore.ts b/src/stores/right-panel/RightPanelStore.ts index 327f82153ff..9aa4c1b27c8 100644 --- a/src/stores/right-panel/RightPanelStore.ts +++ b/src/stores/right-panel/RightPanelStore.ts @@ -34,7 +34,7 @@ import { import { ActionPayload } from "../../dispatcher/payloads"; import { Action } from "../../dispatcher/actions"; import { ActiveRoomChangedPayload } from "../../dispatcher/payloads/ActiveRoomChangedPayload"; -import { RoomViewStore } from "../RoomViewStore"; +import { SdkContextClass } from "../../contexts/SDKContext"; /** * A class for tracking the state of the right panel between layouts and @@ -64,7 +64,7 @@ export default class RightPanelStore extends ReadyWatchingStore { } protected async onReady(): Promise { - this.viewedRoomId = RoomViewStore.instance.getRoomId(); + this.viewedRoomId = SdkContextClass.instance.roomViewStore.getRoomId(); this.matrixClient.on(CryptoEvent.VerificationRequest, this.onVerificationRequestUpdate); this.loadCacheFromSettings(); this.emitAndUpdateSettings(); diff --git a/src/stores/room-list/RoomListStore.ts b/src/stores/room-list/RoomListStore.ts index 83c79a16a9a..d6f9de79c31 100644 --- a/src/stores/room-list/RoomListStore.ts +++ b/src/stores/room-list/RoomListStore.ts @@ -27,7 +27,6 @@ import { ActionPayload } from "../../dispatcher/payloads"; import defaultDispatcher from "../../dispatcher/dispatcher"; import { readReceiptChangeIsFor } from "../../utils/read-receipts"; import { FILTER_CHANGED, IFilterCondition } from "./filters/IFilterCondition"; -import { RoomViewStore } from "../RoomViewStore"; import { Algorithm, LIST_UPDATED_EVENT } from "./algorithms/Algorithm"; import { EffectiveMembership, getEffectiveMembership } from "../../utils/membership"; import RoomListLayoutStore from "./RoomListLayoutStore"; @@ -40,6 +39,7 @@ import { IRoomTimelineActionPayload } from "../../actions/MatrixActionCreators"; import { RoomListStore as Interface, RoomListStoreEvent } from "./Interface"; import { SlidingRoomListStoreClass } from "./SlidingRoomListStore"; import { UPDATE_EVENT } from "../AsyncStore"; +import { SdkContextClass } from "../../contexts/SDKContext"; interface IState { // state is tracked in underlying classes @@ -105,7 +105,7 @@ export class RoomListStoreClass extends AsyncStoreWithClient implements this.readyStore.useUnitTestClient(forcedClient); } - RoomViewStore.instance.addListener(UPDATE_EVENT, () => this.handleRVSUpdate({})); + SdkContextClass.instance.roomViewStore.addListener(UPDATE_EVENT, () => this.handleRVSUpdate({})); this.algorithm.on(LIST_UPDATED_EVENT, this.onAlgorithmListUpdated); this.algorithm.on(FILTER_CHANGED, this.onAlgorithmFilterUpdated); this.setupWatchers(); @@ -128,7 +128,7 @@ export class RoomListStoreClass extends AsyncStoreWithClient implements private handleRVSUpdate({ trigger = true }) { if (!this.matrixClient) return; // We assume there won't be RVS updates without a client - const activeRoomId = RoomViewStore.instance.getRoomId(); + const activeRoomId = SdkContextClass.instance.roomViewStore.getRoomId(); if (!activeRoomId && this.algorithm.stickyRoom) { this.algorithm.setStickyRoom(null); } else if (activeRoomId) { diff --git a/src/stores/room-list/SlidingRoomListStore.ts b/src/stores/room-list/SlidingRoomListStore.ts index 3d532fe0c93..35550d04f1e 100644 --- a/src/stores/room-list/SlidingRoomListStore.ts +++ b/src/stores/room-list/SlidingRoomListStore.ts @@ -29,8 +29,8 @@ import { SlidingSyncManager } from "../../SlidingSyncManager"; import SpaceStore from "../spaces/SpaceStore"; import { MetaSpace, SpaceKey, UPDATE_SELECTED_SPACE } from "../spaces"; import { LISTS_LOADING_EVENT } from "./RoomListStore"; -import { RoomViewStore } from "../RoomViewStore"; import { UPDATE_EVENT } from "../AsyncStore"; +import { SdkContextClass } from "../../contexts/SDKContext"; interface IState { // state is tracked in underlying classes @@ -207,7 +207,7 @@ export class SlidingRoomListStoreClass extends AsyncStoreWithClient impl // this room will not move due to it being viewed: it is sticky. This can be null to indicate // no sticky room if you aren't viewing a room. - this.stickyRoomId = RoomViewStore.instance.getRoomId(); + this.stickyRoomId = SdkContextClass.instance.roomViewStore.getRoomId(); let stickyRoomNewIndex = -1; const stickyRoomOldIndex = (tagMap[tagId] || []).findIndex((room) => { return room.roomId === this.stickyRoomId; @@ -273,7 +273,7 @@ export class SlidingRoomListStoreClass extends AsyncStoreWithClient impl private onRoomViewStoreUpdated() { // we only care about this to know when the user has clicked on a room to set the stickiness value - if (RoomViewStore.instance.getRoomId() === this.stickyRoomId) { + if (SdkContextClass.instance.roomViewStore.getRoomId() === this.stickyRoomId) { return; } @@ -303,7 +303,7 @@ export class SlidingRoomListStoreClass extends AsyncStoreWithClient impl } } // in the event we didn't call refreshOrderedLists, it helps to still remember the sticky room ID. - this.stickyRoomId = RoomViewStore.instance.getRoomId(); + this.stickyRoomId = SdkContextClass.instance.roomViewStore.getRoomId(); if (hasUpdatedAnyList) { this.emit(LISTS_UPDATE_EVENT); @@ -314,7 +314,7 @@ export class SlidingRoomListStoreClass extends AsyncStoreWithClient impl logger.info("SlidingRoomListStore.onReady"); // permanent listeners: never get destroyed. Could be an issue if we want to test this in isolation. SlidingSyncManager.instance.slidingSync.on(SlidingSyncEvent.List, this.onSlidingSyncListUpdate.bind(this)); - RoomViewStore.instance.addListener(UPDATE_EVENT, this.onRoomViewStoreUpdated.bind(this)); + SdkContextClass.instance.roomViewStore.addListener(UPDATE_EVENT, this.onRoomViewStoreUpdated.bind(this)); SpaceStore.instance.on(UPDATE_SELECTED_SPACE, this.onSelectedSpaceUpdated.bind(this)); if (SpaceStore.instance.activeSpace) { this.onSelectedSpaceUpdated(SpaceStore.instance.activeSpace, false); diff --git a/src/stores/spaces/SpaceStore.ts b/src/stores/spaces/SpaceStore.ts index ce86b6ec0f1..f4802a1520c 100644 --- a/src/stores/spaces/SpaceStore.ts +++ b/src/stores/spaces/SpaceStore.ts @@ -34,7 +34,6 @@ import { RoomNotificationStateStore } from "../notifications/RoomNotificationSta import { DefaultTagID } from "../room-list/models"; import { EnhancedMap, mapDiff } from "../../utils/maps"; import { setDiff, setHasDiff } from "../../utils/sets"; -import { RoomViewStore } from "../RoomViewStore"; import { Action } from "../../dispatcher/actions"; import { arrayHasDiff, arrayHasOrderChange } from "../../utils/arrays"; import { reorderLexicographically } from "../../utils/stringOrderField"; @@ -64,6 +63,7 @@ import { ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload"; import { ViewHomePagePayload } from "../../dispatcher/payloads/ViewHomePagePayload"; import { SwitchSpacePayload } from "../../dispatcher/payloads/SwitchSpacePayload"; import { AfterLeaveRoomPayload } from "../../dispatcher/payloads/AfterLeaveRoomPayload"; +import { SdkContextClass } from "../../contexts/SDKContext"; interface IState { } @@ -797,7 +797,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { this.updateNotificationStates(notificationStatesToUpdate); }; - private switchSpaceIfNeeded = (roomId = RoomViewStore.instance.getRoomId()) => { + private switchSpaceIfNeeded = (roomId = SdkContextClass.instance.roomViewStore.getRoomId()) => { if (!this.isRoomInSpace(this.activeSpace, roomId) && !this.matrixClient.getRoom(roomId)?.isSpaceRoom()) { this.switchToRelatedSpace(roomId); } @@ -848,7 +848,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { } // if the room currently being viewed was just joined then switch to its related space - if (newMembership === "join" && room.roomId === RoomViewStore.instance.getRoomId()) { + if (newMembership === "join" && room.roomId === SdkContextClass.instance.roomViewStore.getRoomId()) { this.switchSpaceIfNeeded(room.roomId); } } @@ -875,7 +875,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { this.emit(room.roomId); } - if (membership === "join" && room.roomId === RoomViewStore.instance.getRoomId()) { + if (membership === "join" && room.roomId === SdkContextClass.instance.roomViewStore.getRoomId()) { // if the user was looking at the space and then joined: select that space this.setActiveSpace(room.roomId, false); } else if (membership === "leave" && room.roomId === this.activeSpace) { diff --git a/src/stores/widgets/StopGapWidget.ts b/src/stores/widgets/StopGapWidget.ts index 91a262fdcae..aa1ad2c3934 100644 --- a/src/stores/widgets/StopGapWidget.ts +++ b/src/stores/widgets/StopGapWidget.ts @@ -41,7 +41,6 @@ import { ClientEvent } from "matrix-js-sdk/src/client"; import { _t } from "../../languageHandler"; import { StopGapWidgetDriver } from "./StopGapWidgetDriver"; import { WidgetMessagingStore } from "./WidgetMessagingStore"; -import { RoomViewStore } from "../RoomViewStore"; import { MatrixClientPeg } from "../../MatrixClientPeg"; import { OwnProfileStore } from "../OwnProfileStore"; import WidgetUtils from '../../utils/WidgetUtils'; @@ -65,6 +64,7 @@ import { arrayFastClone } from "../../utils/arrays"; import { ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload"; import Modal from "../../Modal"; import ErrorDialog from "../../components/views/dialogs/ErrorDialog"; +import { SdkContextClass } from "../../contexts/SDKContext"; import { VoiceBroadcastRecordingsStore } from "../../voice-broadcast"; // TODO: Destroy all of this code @@ -185,7 +185,7 @@ export class StopGapWidget extends EventEmitter { if (this.roomId) return this.roomId; - return RoomViewStore.instance.getRoomId(); + return SdkContextClass.instance.roomViewStore.getRoomId(); } public get widgetApi(): ClientWidgetApi { @@ -381,7 +381,7 @@ export class StopGapWidget extends EventEmitter { // noinspection JSIgnoredPromiseFromCall IntegrationManagers.sharedInstance().getPrimaryManager().open( - this.client.getRoom(RoomViewStore.instance.getRoomId()), + this.client.getRoom(SdkContextClass.instance.roomViewStore.getRoomId()), `type_${integType}`, integId, ); diff --git a/src/stores/widgets/StopGapWidgetDriver.ts b/src/stores/widgets/StopGapWidgetDriver.ts index 752d6d57e6f..ba01a10926b 100644 --- a/src/stores/widgets/StopGapWidgetDriver.ts +++ b/src/stores/widgets/StopGapWidgetDriver.ts @@ -53,9 +53,9 @@ import { CHAT_EFFECTS } from "../../effects"; import { containsEmoji } from "../../effects/utils"; import dis from "../../dispatcher/dispatcher"; import SettingsStore from "../../settings/SettingsStore"; -import { RoomViewStore } from "../RoomViewStore"; import { ElementWidgetCapabilities } from "./ElementWidgetCapabilities"; import { navigateToPermalink } from "../../utils/permalinks/navigator"; +import { SdkContextClass } from "../../contexts/SDKContext"; // TODO: Purge this from the universe @@ -210,7 +210,7 @@ export class StopGapWidgetDriver extends WidgetDriver { targetRoomId: string = null, ): Promise { const client = MatrixClientPeg.get(); - const roomId = targetRoomId || RoomViewStore.instance.getRoomId(); + const roomId = targetRoomId || SdkContextClass.instance.roomViewStore.getRoomId(); if (!client || !roomId) throw new Error("Not in a room or not attached to a client"); @@ -291,7 +291,7 @@ export class StopGapWidgetDriver extends WidgetDriver { const targetRooms = roomIds ? (roomIds.includes(Symbols.AnyRoom) ? client.getVisibleRooms() : roomIds.map(r => client.getRoom(r))) - : [client.getRoom(RoomViewStore.instance.getRoomId())]; + : [client.getRoom(SdkContextClass.instance.roomViewStore.getRoomId())]; return targetRooms.filter(r => !!r); } @@ -430,7 +430,7 @@ export class StopGapWidgetDriver extends WidgetDriver { ): Promise { const client = MatrixClientPeg.get(); const dir = direction as Direction; - roomId = roomId ?? RoomViewStore.instance.getRoomId() ?? undefined; + roomId = roomId ?? SdkContextClass.instance.roomViewStore.getRoomId() ?? undefined; if (typeof roomId !== "string") { throw new Error('Error while reading the current room'); diff --git a/src/utils/DialogOpener.ts b/src/utils/DialogOpener.ts index 0e5a3d2b11a..82d16962b26 100644 --- a/src/utils/DialogOpener.ts +++ b/src/utils/DialogOpener.ts @@ -20,7 +20,6 @@ import defaultDispatcher from "../dispatcher/dispatcher"; import { ActionPayload } from "../dispatcher/payloads"; import Modal from "../Modal"; import RoomSettingsDialog from "../components/views/dialogs/RoomSettingsDialog"; -import { RoomViewStore } from "../stores/RoomViewStore"; import ForwardDialog from "../components/views/dialogs/ForwardDialog"; import { MatrixClientPeg } from "../MatrixClientPeg"; import { Action } from "../dispatcher/actions"; @@ -32,6 +31,7 @@ import AddExistingToSpaceDialog from "../components/views/dialogs/AddExistingToS import { ButtonEvent } from "../components/views/elements/AccessibleButton"; import PosthogTrackers from "../PosthogTrackers"; import { showAddExistingSubspace, showCreateNewRoom } from "./space"; +import { SdkContextClass } from "../contexts/SDKContext"; /** * Auxiliary class to listen for dialog opening over the dispatcher and @@ -58,7 +58,7 @@ export class DialogOpener { switch (payload.action) { case 'open_room_settings': Modal.createDialog(RoomSettingsDialog, { - roomId: payload.room_id || RoomViewStore.instance.getRoomId(), + roomId: payload.room_id || SdkContextClass.instance.roomViewStore.getRoomId(), initialTabId: payload.initial_tab_id, }, /*className=*/null, /*isPriority=*/false, /*isStatic=*/true); break; @@ -108,7 +108,7 @@ export class DialogOpener { onAddSubspaceClick: () => showAddExistingSubspace(space), space, onFinished: (added: boolean) => { - if (added && RoomViewStore.instance.getRoomId() === space.roomId) { + if (added && SdkContextClass.instance.roomViewStore.getRoomId() === space.roomId) { defaultDispatcher.fire(Action.UpdateSpaceHierarchy); } }, diff --git a/src/utils/leave-behaviour.ts b/src/utils/leave-behaviour.ts index a12cd70ebff..83054ce1b49 100644 --- a/src/utils/leave-behaviour.ts +++ b/src/utils/leave-behaviour.ts @@ -27,7 +27,6 @@ import { _t } from "../languageHandler"; import ErrorDialog from "../components/views/dialogs/ErrorDialog"; import { isMetaSpace } from "../stores/spaces"; import SpaceStore from "../stores/spaces/SpaceStore"; -import { RoomViewStore } from "../stores/RoomViewStore"; import dis from "../dispatcher/dispatcher"; import { ViewRoomPayload } from "../dispatcher/payloads/ViewRoomPayload"; import { Action } from "../dispatcher/actions"; @@ -35,6 +34,7 @@ import { ViewHomePagePayload } from "../dispatcher/payloads/ViewHomePagePayload" import LeaveSpaceDialog from "../components/views/dialogs/LeaveSpaceDialog"; import { AfterLeaveRoomPayload } from "../dispatcher/payloads/AfterLeaveRoomPayload"; import { bulkSpaceBehaviour } from "./space"; +import { SdkContextClass } from "../contexts/SDKContext"; export async function leaveRoomBehaviour(roomId: string, retry = true, spinner = true) { let spinnerModal: IHandle; @@ -130,7 +130,7 @@ export async function leaveRoomBehaviour(roomId: string, retry = true, spinner = if (!isMetaSpace(SpaceStore.instance.activeSpace) && SpaceStore.instance.activeSpace !== roomId && - RoomViewStore.instance.getRoomId() === roomId + SdkContextClass.instance.roomViewStore.getRoomId() === roomId ) { dis.dispatch({ action: Action.ViewRoom, diff --git a/src/utils/space.tsx b/src/utils/space.tsx index 9e05f0444ba..1e30b7235aa 100644 --- a/src/utils/space.tsx +++ b/src/utils/space.tsx @@ -30,7 +30,6 @@ import { showRoomInviteDialog } from "../RoomInvite"; import CreateSubspaceDialog from "../components/views/dialogs/CreateSubspaceDialog"; import AddExistingSubspaceDialog from "../components/views/dialogs/AddExistingSubspaceDialog"; import defaultDispatcher from "../dispatcher/dispatcher"; -import { RoomViewStore } from "../stores/RoomViewStore"; import { Action } from "../dispatcher/actions"; import Spinner from "../components/views/elements/Spinner"; import { shouldShowComponent } from "../customisations/helpers/UIComponents"; @@ -38,6 +37,7 @@ import { UIComponent } from "../settings/UIFeature"; import { OpenSpacePreferencesPayload, SpacePreferenceTab } from "../dispatcher/payloads/OpenSpacePreferencesPayload"; import { OpenSpaceSettingsPayload } from "../dispatcher/payloads/OpenSpaceSettingsPayload"; import { OpenAddExistingToSpaceDialogPayload } from "../dispatcher/payloads/OpenAddExistingToSpaceDialogPayload"; +import { SdkContextClass } from "../contexts/SDKContext"; export const shouldShowSpaceSettings = (space: Room) => { const userId = space.client.getUserId(); @@ -113,7 +113,7 @@ export const showAddExistingSubspace = (space: Room): void => { space, onCreateSubspaceClick: () => showCreateNewSubspace(space), onFinished: (added: boolean) => { - if (added && RoomViewStore.instance.getRoomId() === space.roomId) { + if (added && SdkContextClass.instance.roomViewStore.getRoomId() === space.roomId) { defaultDispatcher.fire(Action.UpdateSpaceHierarchy); } }, @@ -125,7 +125,7 @@ export const showCreateNewSubspace = (space: Room): void => { space, onAddExistingSpaceClick: () => showAddExistingSubspace(space), onFinished: (added: boolean) => { - if (added && RoomViewStore.instance.getRoomId() === space.roomId) { + if (added && SdkContextClass.instance.roomViewStore.getRoomId() === space.roomId) { defaultDispatcher.fire(Action.UpdateSpaceHierarchy); } }, diff --git a/test/SlashCommands-test.tsx b/test/SlashCommands-test.tsx index 39d3986270c..c31d6d70c12 100644 --- a/test/SlashCommands-test.tsx +++ b/test/SlashCommands-test.tsx @@ -21,9 +21,9 @@ import { Command, Commands, getCommand } from '../src/SlashCommands'; import { createTestClient } from './test-utils'; import { MatrixClientPeg } from '../src/MatrixClientPeg'; import { LocalRoom, LOCAL_ROOM_ID_PREFIX } from '../src/models/LocalRoom'; -import { RoomViewStore } from '../src/stores/RoomViewStore'; import SettingsStore from '../src/settings/SettingsStore'; import LegacyCallHandler from '../src/LegacyCallHandler'; +import { SdkContextClass } from '../src/contexts/SDKContext'; describe('SlashCommands', () => { let client: MatrixClient; @@ -38,14 +38,14 @@ describe('SlashCommands', () => { }; const setCurrentRoom = (): void => { - mocked(RoomViewStore.instance.getRoomId).mockReturnValue(roomId); + mocked(SdkContextClass.instance.roomViewStore.getRoomId).mockReturnValue(roomId); mocked(client.getRoom).mockImplementation((rId: string): Room => { if (rId === roomId) return room; }); }; const setCurrentLocalRoon = (): void => { - mocked(RoomViewStore.instance.getRoomId).mockReturnValue(localRoomId); + mocked(SdkContextClass.instance.roomViewStore.getRoomId).mockReturnValue(localRoomId); mocked(client.getRoom).mockImplementation((rId: string): Room => { if (rId === localRoomId) return localRoom; }); @@ -60,7 +60,7 @@ describe('SlashCommands', () => { room = new Room(roomId, client, client.getUserId()); localRoom = new LocalRoom(localRoomId, client, client.getUserId()); - jest.spyOn(RoomViewStore.instance, "getRoomId"); + jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId"); }); describe('/topic', () => { diff --git a/test/TestStores.ts b/test/TestStores.ts new file mode 100644 index 00000000000..dbaa51f504a --- /dev/null +++ b/test/TestStores.ts @@ -0,0 +1,44 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { SdkContextClass } from "../src/contexts/SDKContext"; +import { PosthogAnalytics } from "../src/PosthogAnalytics"; +import { SlidingSyncManager } from "../src/SlidingSyncManager"; +import { RoomNotificationStateStore } from "../src/stores/notifications/RoomNotificationStateStore"; +import RightPanelStore from "../src/stores/right-panel/RightPanelStore"; +import { RoomViewStore } from "../src/stores/RoomViewStore"; +import { SpaceStoreClass } from "../src/stores/spaces/SpaceStore"; +import { WidgetLayoutStore } from "../src/stores/widgets/WidgetLayoutStore"; +import WidgetStore from "../src/stores/WidgetStore"; + +/** + * A class which provides the same API as Stores but adds additional unsafe setters which can + * replace individual stores. This is useful for tests which need to mock out stores. + */ +export class TestStores extends SdkContextClass { + public _RightPanelStore?: RightPanelStore; + public _RoomNotificationStateStore?: RoomNotificationStateStore; + public _RoomViewStore?: RoomViewStore; + public _WidgetLayoutStore?: WidgetLayoutStore; + public _WidgetStore?: WidgetStore; + public _PosthogAnalytics?: PosthogAnalytics; + public _SlidingSyncManager?: SlidingSyncManager; + public _SpaceStore?: SpaceStoreClass; + + constructor() { + super(); + } +} diff --git a/test/components/structures/RoomView-test.tsx b/test/components/structures/RoomView-test.tsx index dd45c7df099..a4131100c5a 100644 --- a/test/components/structures/RoomView-test.tsx +++ b/test/components/structures/RoomView-test.tsx @@ -32,17 +32,16 @@ import { defaultDispatcher } from "../../../src/dispatcher/dispatcher"; import { ViewRoomPayload } from "../../../src/dispatcher/payloads/ViewRoomPayload"; import { RoomView as _RoomView } from "../../../src/components/structures/RoomView"; import ResizeNotifier from "../../../src/utils/ResizeNotifier"; -import { RoomViewStore } from "../../../src/stores/RoomViewStore"; import SettingsStore from "../../../src/settings/SettingsStore"; import { SettingLevel } from "../../../src/settings/SettingLevel"; import DMRoomMap from "../../../src/utils/DMRoomMap"; import { NotificationState } from "../../../src/stores/notifications/NotificationState"; -import RightPanelStore from "../../../src/stores/right-panel/RightPanelStore"; import { RightPanelPhases } from "../../../src/stores/right-panel/RightPanelStorePhases"; import { LocalRoom, LocalRoomState } from "../../../src/models/LocalRoom"; import { DirectoryMember } from "../../../src/utils/direct-messages"; import { createDmLocalRoom } from "../../../src/utils/dm/createDmLocalRoom"; import { UPDATE_EVENT } from "../../../src/stores/AsyncStore"; +import { SdkContextClass, SDKContext } from "../../../src/contexts/SDKContext"; const RoomView = wrapInMatrixClientContext(_RoomView); @@ -50,6 +49,7 @@ describe("RoomView", () => { let cli: MockedObject; let room: Room; let roomCount = 0; + let stores: SdkContextClass; beforeEach(async () => { mockPlatformPeg({ reload: () => {} }); @@ -64,7 +64,9 @@ describe("RoomView", () => { room.on(RoomEvent.TimelineReset, (...args) => cli.emit(RoomEvent.TimelineReset, ...args)); DMRoomMap.makeShared(); - RightPanelStore.instance.useUnitTestClient(cli); + stores = new SdkContextClass(); + stores.client = cli; + stores.rightPanelStore.useUnitTestClient(cli); }); afterEach(async () => { @@ -73,15 +75,15 @@ describe("RoomView", () => { }); const mountRoomView = async (): Promise => { - if (RoomViewStore.instance.getRoomId() !== room.roomId) { + if (stores.roomViewStore.getRoomId() !== room.roomId) { const switchedRoom = new Promise(resolve => { const subFn = () => { - if (RoomViewStore.instance.getRoomId()) { - RoomViewStore.instance.off(UPDATE_EVENT, subFn); + if (stores.roomViewStore.getRoomId()) { + stores.roomViewStore.off(UPDATE_EVENT, subFn); resolve(); } }; - RoomViewStore.instance.on(UPDATE_EVENT, subFn); + stores.roomViewStore.on(UPDATE_EVENT, subFn); }); defaultDispatcher.dispatch({ @@ -94,15 +96,16 @@ describe("RoomView", () => { } const roomView = mount( - , + + + , ); await act(() => Promise.resolve()); // Allow state to settle return roomView; @@ -162,14 +165,14 @@ describe("RoomView", () => { it("normally doesn't open the chat panel", async () => { jest.spyOn(NotificationState.prototype, "isUnread", "get").mockReturnValue(false); await mountRoomView(); - expect(RightPanelStore.instance.isOpen).toEqual(false); + expect(stores.rightPanelStore.isOpen).toEqual(false); }); it("opens the chat panel if there are unread messages", async () => { jest.spyOn(NotificationState.prototype, "isUnread", "get").mockReturnValue(true); await mountRoomView(); - expect(RightPanelStore.instance.isOpen).toEqual(true); - expect(RightPanelStore.instance.currentCard.phase).toEqual(RightPanelPhases.Timeline); + expect(stores.rightPanelStore.isOpen).toEqual(true); + expect(stores.rightPanelStore.currentCard.phase).toEqual(RightPanelPhases.Timeline); }); }); diff --git a/test/components/views/beacon/RoomCallBanner-test.tsx b/test/components/views/beacon/RoomCallBanner-test.tsx index 52d0ed27d36..722c28ff1f6 100644 --- a/test/components/views/beacon/RoomCallBanner-test.tsx +++ b/test/components/views/beacon/RoomCallBanner-test.tsx @@ -42,8 +42,8 @@ import RoomCallBanner from "../../../../src/components/views/beacon/RoomCallBann import { CallStore } from "../../../../src/stores/CallStore"; import { WidgetMessagingStore } from "../../../../src/stores/widgets/WidgetMessagingStore"; import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; -import { RoomViewStore } from "../../../../src/stores/RoomViewStore"; import { ConnectionState } from "../../../../src/models/Call"; +import { SdkContextClass } from "../../../../src/contexts/SDKContext"; describe("", () => { let client: Mocked; @@ -132,7 +132,8 @@ describe("", () => { }); it("doesn't show banner if the call is shown", async () => { - jest.spyOn(RoomViewStore.instance, 'isViewingCall').mockReturnValue(true); + jest.spyOn(SdkContextClass.instance.roomViewStore, "isViewingCall"); + mocked(SdkContextClass.instance.roomViewStore.isViewingCall).mockReturnValue(true); await renderBanner(); const banner = await screen.queryByText("Video call"); expect(banner).toBeFalsy(); diff --git a/test/stores/RoomViewStore-test.tsx b/test/stores/RoomViewStore-test.ts similarity index 79% rename from test/stores/RoomViewStore-test.tsx rename to test/stores/RoomViewStore-test.ts index 3ea402438db..f6f6bf2cc71 100644 --- a/test/stores/RoomViewStore-test.tsx +++ b/test/stores/RoomViewStore-test.ts @@ -21,10 +21,21 @@ import { Action } from '../../src/dispatcher/actions'; import { getMockClientWithEventEmitter, untilDispatch, untilEmission } from '../test-utils'; import SettingsStore from '../../src/settings/SettingsStore'; import { SlidingSyncManager } from '../../src/SlidingSyncManager'; +import { PosthogAnalytics } from '../../src/PosthogAnalytics'; import { TimelineRenderingType } from '../../src/contexts/RoomContext'; import { MatrixDispatcher } from '../../src/dispatcher/dispatcher'; import { UPDATE_EVENT } from '../../src/stores/AsyncStore'; import { ActiveRoomChangedPayload } from '../../src/dispatcher/payloads/ActiveRoomChangedPayload'; +import { SpaceStoreClass } from '../../src/stores/spaces/SpaceStore'; +import { TestStores } from '../TestStores'; + +// mock out the injected classes +jest.mock('../../src/PosthogAnalytics'); +const MockPosthogAnalytics = >PosthogAnalytics; +jest.mock('../../src/SlidingSyncManager'); +const MockSlidingSyncManager = >SlidingSyncManager; +jest.mock('../../src/stores/spaces/SpaceStore'); +const MockSpaceStore = >SpaceStoreClass; jest.mock('../../src/utils/DMRoomMap', () => { const mock = { @@ -51,6 +62,9 @@ describe('RoomViewStore', function() { isGuest: jest.fn(), }); const room = new Room(roomId, mockClient, userId); + + let roomViewStore: RoomViewStore; + let slidingSyncManager: SlidingSyncManager; let dis: MatrixDispatcher; beforeEach(function() { @@ -60,10 +74,17 @@ describe('RoomViewStore', function() { mockClient.getRoom.mockReturnValue(room); mockClient.isGuest.mockReturnValue(false); - // Reset the state of the store + // Make the RVS to test dis = new MatrixDispatcher(); - RoomViewStore.instance.reset(); - RoomViewStore.instance.resetDispatcher(dis); + slidingSyncManager = new MockSlidingSyncManager(); + const stores = new TestStores(); + stores._SlidingSyncManager = slidingSyncManager; + stores._PosthogAnalytics = new MockPosthogAnalytics(); + stores._SpaceStore = new MockSpaceStore(); + roomViewStore = new RoomViewStore( + dis, stores, + ); + stores._RoomViewStore = roomViewStore; }); it('can be used to view a room by ID and join', async () => { @@ -71,14 +92,14 @@ describe('RoomViewStore', function() { dis.dispatch({ action: Action.JoinRoom }); await untilDispatch(Action.JoinRoomReady, dis); expect(mockClient.joinRoom).toHaveBeenCalledWith(roomId, { viaServers: [] }); - expect(RoomViewStore.instance.isJoining()).toBe(true); + expect(roomViewStore.isJoining()).toBe(true); }); it('can auto-join a room', async () => { dis.dispatch({ action: Action.ViewRoom, room_id: roomId, auto_join: true }); await untilDispatch(Action.JoinRoomReady, dis); expect(mockClient.joinRoom).toHaveBeenCalledWith(roomId, { viaServers: [] }); - expect(RoomViewStore.instance.isJoining()).toBe(true); + expect(roomViewStore.isJoining()).toBe(true); }); it('emits ActiveRoomChanged when the viewed room changes', async () => { @@ -97,7 +118,7 @@ describe('RoomViewStore', function() { it('invokes room activity listeners when the viewed room changes', async () => { const roomId2 = "!roomid:2"; const callback = jest.fn(); - RoomViewStore.instance.addRoomListener(roomId, callback); + roomViewStore.addRoomListener(roomId, callback); dis.dispatch({ action: Action.ViewRoom, room_id: roomId }); await untilDispatch(Action.ActiveRoomChanged, dis) as ActiveRoomChangedPayload; expect(callback).toHaveBeenCalledWith(true); @@ -116,14 +137,14 @@ describe('RoomViewStore', function() { }, dis); // roomId is set to id of the room alias - expect(RoomViewStore.instance.getRoomId()).toBe(roomId); + expect(roomViewStore.getRoomId()).toBe(roomId); // join the room dis.dispatch({ action: Action.JoinRoom }, true); await untilDispatch(Action.JoinRoomReady, dis); - expect(RoomViewStore.instance.isJoining()).toBeTruthy(); + expect(roomViewStore.isJoining()).toBeTruthy(); expect(mockClient.joinRoom).toHaveBeenCalledWith(alias, { viaServers: [] }); }); @@ -134,7 +155,7 @@ describe('RoomViewStore', function() { const payload = await untilDispatch(Action.ViewRoomError, dis); expect(payload.room_id).toBeNull(); expect(payload.room_alias).toEqual(alias); - expect(RoomViewStore.instance.getRoomAlias()).toEqual(alias); + expect(roomViewStore.getRoomAlias()).toEqual(alias); }); it('emits JoinRoomError if joining the room fails', async () => { @@ -143,8 +164,8 @@ describe('RoomViewStore', function() { dis.dispatch({ action: Action.ViewRoom, room_id: roomId }); dis.dispatch({ action: Action.JoinRoom }); await untilDispatch(Action.JoinRoomError, dis); - expect(RoomViewStore.instance.isJoining()).toBe(false); - expect(RoomViewStore.instance.getJoinError()).toEqual(joinErr); + expect(roomViewStore.isJoining()).toBe(false); + expect(roomViewStore.getJoinError()).toEqual(joinErr); }); it('remembers the event being replied to when swapping rooms', async () => { @@ -154,13 +175,13 @@ describe('RoomViewStore', function() { getRoomId: () => roomId, }; dis.dispatch({ action: 'reply_to_event', event: replyToEvent, context: TimelineRenderingType.Room }); - await untilEmission(RoomViewStore.instance, UPDATE_EVENT); - expect(RoomViewStore.instance.getQuotingEvent()).toEqual(replyToEvent); + await untilEmission(roomViewStore, UPDATE_EVENT); + expect(roomViewStore.getQuotingEvent()).toEqual(replyToEvent); // view the same room, should remember the event. // set the highlighed flag to make sure there is a state change so we get an update event dis.dispatch({ action: Action.ViewRoom, room_id: roomId, highlighted: true }); - await untilEmission(RoomViewStore.instance, UPDATE_EVENT); - expect(RoomViewStore.instance.getQuotingEvent()).toEqual(replyToEvent); + await untilEmission(roomViewStore, UPDATE_EVENT); + expect(roomViewStore.getQuotingEvent()).toEqual(replyToEvent); }); it('swaps to the replied event room if it is not the current room', async () => { @@ -172,18 +193,18 @@ describe('RoomViewStore', function() { }; dis.dispatch({ action: 'reply_to_event', event: replyToEvent, context: TimelineRenderingType.Room }); await untilDispatch(Action.ViewRoom, dis); - expect(RoomViewStore.instance.getQuotingEvent()).toEqual(replyToEvent); - expect(RoomViewStore.instance.getRoomId()).toEqual(roomId2); + expect(roomViewStore.getQuotingEvent()).toEqual(replyToEvent); + expect(roomViewStore.getRoomId()).toEqual(roomId2); }); it('removes the roomId on ViewHomePage', async () => { dis.dispatch({ action: Action.ViewRoom, room_id: roomId }); await untilDispatch(Action.ActiveRoomChanged, dis); - expect(RoomViewStore.instance.getRoomId()).toEqual(roomId); + expect(roomViewStore.getRoomId()).toEqual(roomId); dis.dispatch({ action: Action.ViewHomePage }); - await untilEmission(RoomViewStore.instance, UPDATE_EVENT); - expect(RoomViewStore.instance.getRoomId()).toBeNull(); + await untilEmission(roomViewStore, UPDATE_EVENT); + expect(roomViewStore.getRoomId()).toBeNull(); }); describe('Sliding Sync', function() { @@ -191,23 +212,22 @@ describe('RoomViewStore', function() { jest.spyOn(SettingsStore, 'getValue').mockImplementation((settingName, roomId, value) => { return settingName === "feature_sliding_sync"; // this is enabled, everything else is disabled. }); - RoomViewStore.instance.reset(); }); it("subscribes to the room", async () => { - const setRoomVisible = jest.spyOn(SlidingSyncManager.instance, "setRoomVisible").mockReturnValue( + const setRoomVisible = jest.spyOn(slidingSyncManager, "setRoomVisible").mockReturnValue( Promise.resolve(""), ); const subscribedRoomId = "!sub1:localhost"; dis.dispatch({ action: Action.ViewRoom, room_id: subscribedRoomId }); await untilDispatch(Action.ActiveRoomChanged, dis); - expect(RoomViewStore.instance.getRoomId()).toBe(subscribedRoomId); + expect(roomViewStore.getRoomId()).toBe(subscribedRoomId); expect(setRoomVisible).toHaveBeenCalledWith(subscribedRoomId, true); }); // Regression test for an in-the-wild bug where rooms would rapidly switch forever in sliding sync mode it("doesn't get stuck in a loop if you view rooms quickly", async () => { - const setRoomVisible = jest.spyOn(SlidingSyncManager.instance, "setRoomVisible").mockReturnValue( + const setRoomVisible = jest.spyOn(slidingSyncManager, "setRoomVisible").mockReturnValue( Promise.resolve(""), ); const subscribedRoomId = "!sub1:localhost"; diff --git a/test/stores/widgets/StopGapWidgetDriver-test.ts b/test/stores/widgets/StopGapWidgetDriver-test.ts index d1816bdac32..0fd2f18be79 100644 --- a/test/stores/widgets/StopGapWidgetDriver-test.ts +++ b/test/stores/widgets/StopGapWidgetDriver-test.ts @@ -20,8 +20,8 @@ import { DeviceInfo } from "matrix-js-sdk/src/crypto/deviceinfo"; import { Direction, MatrixEvent } from "matrix-js-sdk/src/matrix"; import { Widget, MatrixWidgetType, WidgetKind, WidgetDriver, ITurnServer } from "matrix-widget-api"; +import { SdkContextClass } from "../../../src/contexts/SDKContext"; import { MatrixClientPeg } from "../../../src/MatrixClientPeg"; -import { RoomViewStore } from "../../../src/stores/RoomViewStore"; import { StopGapWidgetDriver } from "../../../src/stores/widgets/StopGapWidgetDriver"; import { stubClient } from "../../test-utils"; @@ -201,7 +201,7 @@ describe("StopGapWidgetDriver", () => { beforeEach(() => { driver = mkDefaultDriver(); }); it('reads related events from the current room', async () => { - jest.spyOn(RoomViewStore.instance, 'getRoomId').mockReturnValue('!this-room-id'); + jest.spyOn(SdkContextClass.instance.roomViewStore, 'getRoomId').mockReturnValue('!this-room-id'); client.relations.mockResolvedValue({ originalEvent: new MatrixEvent(), From 3c3df11d32f439cd84d057f7ef54dbd3fd75fd53 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Wed, 19 Oct 2022 13:31:20 +0100 Subject: [PATCH 010/113] Support for login + E2EE set up with QR (#9403) * Support for login + E2EE set up with QR * Whitespace * Padding * Refactor of fetch * Whitespace * CSS whitespace * Add link to MSC3906 * Handle incorrect typing in MatrixClientPeg.get() * Use unstable class name * fix: use unstable class name * Use default fetch client instead * Update to revised function name * Refactor device manager panel and make it work with new sessions manager * Lint fix * Add missing interstitials and update wording * Linting * i18n * Lint * Use sensible sdk config name for fallback server * Improve error handling for QR code generation * Refactor feature availability logic * Hide device manager panel if no options available * Put sign in with QR behind lab setting * Reduce scope of PR to just showing code on existing device * i18n updates * Handle null features * Testing for LoginWithQRSection * Refactor to handle UIA * Imports * Reduce diff complexity * Remove unnecessary change * Remove unused styles * Support UIA * Tidy up * i18n * Remove additional unused parts of flow * Add extra instruction when showing QR code * Add getVersions to server mocks * Use proper colours for theme support * Test cases * Lint * Remove obsolete snapshot * Don't override error if already set * Remove unused var * Update src/components/views/settings/devices/LoginWithQRSection.tsx Co-authored-by: Travis Ralston * Update src/components/views/auth/LoginWithQR.tsx Co-authored-by: Travis Ralston * Update src/components/views/auth/LoginWithQR.tsx Co-authored-by: Travis Ralston * Update src/components/views/auth/LoginWithQR.tsx Co-authored-by: Travis Ralston * Update src/components/views/auth/LoginWithQR.tsx Co-authored-by: Travis Ralston * Update src/components/views/auth/LoginWithQR.tsx Co-authored-by: Travis Ralston * Update res/css/views/auth/_LoginWithQR.pcss Co-authored-by: Kerry * Use spacing variables * Remove debug * Style + docs * preventDefault * Names of tests * Fixes for js-sdk refactor * Update snapshots to match test names * Refactor labs config to make deployment simpler * i18n * Unused imports * Typo * Stateless component * Whitespace * Use context not MatrixClientPeg * Add missing context * Type updates to match js-sdk * Wrap click handlers in useCallback * Update src/components/views/settings/DevicesPanel.tsx Co-authored-by: Travis Ralston * Wait for DOM update instead of timeout * Add missing snapshot update from last commit * Remove void keyword in favour of then() clauses * test main paths in LoginWithQR Co-authored-by: Travis Ralston Co-authored-by: Kerry --- res/css/_components.pcss | 1 + res/css/views/auth/_LoginWithQR.pcss | 171 ++++++++ res/img/element-icons/back.svg | 3 + res/img/element-icons/devices.svg | 11 + res/img/element-icons/qrcode.svg | 4 + src/components/views/auth/LoginWithQR.tsx | 396 ++++++++++++++++++ .../views/dialogs/InteractiveAuthDialog.tsx | 6 +- .../views/settings/DevicesPanel.tsx | 22 +- .../settings/devices/LoginWithQRSection.tsx | 63 +++ .../views/settings/devices/useOwnDevices.ts | 7 + .../tabs/user/SecurityUserSettingsTab.tsx | 28 ++ .../settings/tabs/user/SessionManagerTab.tsx | 29 ++ src/i18n/strings/en_EN.json | 24 ++ src/settings/Settings.tsx | 10 + src/utils/UserInteractiveAuth.ts | 55 +++ .../views/settings/DevicesPanel-test.tsx | 6 +- .../settings/devices/LoginWithQR-test.tsx | 297 +++++++++++++ .../devices/LoginWithQRSection-test.tsx | 94 +++++ .../__snapshots__/LoginWithQR-test.tsx.snap | 367 ++++++++++++++++ .../LoginWithQRSection-test.tsx.snap | 45 ++ .../user/SecurityUserSettingsTab-test.tsx | 9 +- .../tabs/user/SessionManagerTab-test.tsx | 1 + test/test-utils/client.ts | 1 + 23 files changed, 1638 insertions(+), 12 deletions(-) create mode 100644 res/css/views/auth/_LoginWithQR.pcss create mode 100644 res/img/element-icons/back.svg create mode 100644 res/img/element-icons/devices.svg create mode 100644 res/img/element-icons/qrcode.svg create mode 100644 src/components/views/auth/LoginWithQR.tsx create mode 100644 src/components/views/settings/devices/LoginWithQRSection.tsx create mode 100644 src/utils/UserInteractiveAuth.ts create mode 100644 test/components/views/settings/devices/LoginWithQR-test.tsx create mode 100644 test/components/views/settings/devices/LoginWithQRSection-test.tsx create mode 100644 test/components/views/settings/devices/__snapshots__/LoginWithQR-test.tsx.snap create mode 100644 test/components/views/settings/devices/__snapshots__/LoginWithQRSection-test.tsx.snap diff --git a/res/css/_components.pcss b/res/css/_components.pcss index 00661bd56b8..b2fcb0dd4f8 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -96,6 +96,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"; 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/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/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/src/components/views/auth/LoginWithQR.tsx b/src/components/views/auth/LoginWithQR.tsx new file mode 100644 index 00000000000..f95e618cc52 --- /dev/null +++ b/src/components/views/auth/LoginWithQR.tsx @@ -0,0 +1,396 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from 'react'; +import { MSC3906Rendezvous, MSC3906RendezvousPayload, RendezvousFailureReason } from 'matrix-js-sdk/src/rendezvous'; +import { MSC3886SimpleHttpRendezvousTransport } from 'matrix-js-sdk/src/rendezvous/transports'; +import { MSC3903ECDHPayload, MSC3903ECDHv1RendezvousChannel } from 'matrix-js-sdk/src/rendezvous/channels'; +import { logger } from 'matrix-js-sdk/src/logger'; +import { MatrixClient } from 'matrix-js-sdk/src/client'; + +import { _t } from "../../../languageHandler"; +import AccessibleButton from '../elements/AccessibleButton'; +import QRCode from '../elements/QRCode'; +import Spinner from '../elements/Spinner'; +import { Icon as BackButtonIcon } from "../../../../res/img/element-icons/back.svg"; +import { Icon as DevicesIcon } from "../../../../res/img/element-icons/devices.svg"; +import { Icon as WarningBadge } from "../../../../res/img/element-icons/warning-badge.svg"; +import { Icon as InfoIcon } from "../../../../res/img/element-icons/i.svg"; +import { wrapRequestWithDialog } from '../../../utils/UserInteractiveAuth'; + +/** + * The intention of this enum is to have a mode that scans a QR code instead of generating one. + */ +export enum Mode { + /** + * A QR code with be generated and shown + */ + Show = "show", +} + +enum Phase { + Loading, + ShowingQR, + Connecting, + Connected, + WaitingForDevice, + Verifying, + Error, +} + +interface IProps { + client: MatrixClient; + mode: Mode; + onFinished(...args: any): void; +} + +interface IState { + phase: Phase; + rendezvous?: MSC3906Rendezvous; + confirmationDigits?: string; + failureReason?: RendezvousFailureReason; + mediaPermissionError?: boolean; +} + +/** + * A component that allows sign in and E2EE set up with a QR code. + * + * It implements both `login.start` and `login-reciprocate` capabilities as well as both scanning and showing QR codes. + * + * This uses the unstable feature of MSC3906: https://github.com/matrix-org/matrix-spec-proposals/pull/3906 + */ +export default class LoginWithQR extends React.Component { + public constructor(props) { + super(props); + + this.state = { + phase: Phase.Loading, + }; + } + + public componentDidMount(): void { + this.updateMode(this.props.mode).then(() => {}); + } + + public componentDidUpdate(prevProps: Readonly): void { + if (prevProps.mode !== this.props.mode) { + this.updateMode(this.props.mode).then(() => {}); + } + } + + private async updateMode(mode: Mode) { + this.setState({ phase: Phase.Loading }); + if (this.state.rendezvous) { + this.state.rendezvous.onFailure = undefined; + await this.state.rendezvous.cancel(RendezvousFailureReason.UserCancelled); + this.setState({ rendezvous: undefined }); + } + if (mode === Mode.Show) { + await this.generateCode(); + } + } + + public componentWillUnmount(): void { + if (this.state.rendezvous) { + // eslint-disable-next-line react/no-direct-mutation-state + this.state.rendezvous.onFailure = undefined; + // calling cancel will call close() as well to clean up the resources + this.state.rendezvous.cancel(RendezvousFailureReason.UserCancelled).then(() => {}); + } + } + + private approveLogin = async (): Promise => { + if (!this.state.rendezvous) { + throw new Error('Rendezvous not found'); + } + this.setState({ phase: Phase.Loading }); + + try { + logger.info("Requesting login token"); + + const { login_token: loginToken } = await wrapRequestWithDialog(this.props.client.requestLoginToken, { + matrixClient: this.props.client, + title: _t("Sign in new device"), + })(); + + this.setState({ phase: Phase.WaitingForDevice }); + + const newDeviceId = await this.state.rendezvous.approveLoginOnExistingDevice(loginToken); + if (!newDeviceId) { + // user denied + return; + } + if (!this.props.client.crypto) { + // no E2EE to set up + this.props.onFinished(true); + return; + } + await this.state.rendezvous.verifyNewDeviceOnExistingDevice(); + this.props.onFinished(true); + } catch (e) { + logger.error('Error whilst approving sign in', e); + this.setState({ phase: Phase.Error, failureReason: RendezvousFailureReason.Unknown }); + } + }; + + private generateCode = async () => { + let rendezvous: MSC3906Rendezvous; + try { + const transport = new MSC3886SimpleHttpRendezvousTransport({ + onFailure: this.onFailure, + client: this.props.client, + }); + + const channel = new MSC3903ECDHv1RendezvousChannel( + transport, undefined, this.onFailure, + ); + + rendezvous = new MSC3906Rendezvous(channel, this.props.client, this.onFailure); + + await rendezvous.generateCode(); + this.setState({ + phase: Phase.ShowingQR, + rendezvous, + failureReason: undefined, + }); + } catch (e) { + logger.error('Error whilst generating QR code', e); + this.setState({ phase: Phase.Error, failureReason: RendezvousFailureReason.HomeserverLacksSupport }); + return; + } + + try { + const confirmationDigits = await rendezvous.startAfterShowingCode(); + this.setState({ phase: Phase.Connected, confirmationDigits }); + } catch (e) { + logger.error('Error whilst doing QR login', e); + // only set to error phase if it hasn't already been set by onFailure or similar + if (this.state.phase !== Phase.Error) { + this.setState({ phase: Phase.Error, failureReason: RendezvousFailureReason.Unknown }); + } + } + }; + + private onFailure = (reason: RendezvousFailureReason) => { + logger.info(`Rendezvous failed: ${reason}`); + this.setState({ phase: Phase.Error, failureReason: reason }); + }; + + public reset() { + this.setState({ + rendezvous: undefined, + confirmationDigits: undefined, + failureReason: undefined, + }); + } + + private cancelClicked = async (e: React.FormEvent) => { + e.preventDefault(); + await this.state.rendezvous?.cancel(RendezvousFailureReason.UserCancelled); + this.reset(); + this.props.onFinished(false); + }; + + private declineClicked = async (e: React.FormEvent) => { + e.preventDefault(); + await this.state.rendezvous?.declineLoginOnExistingDevice(); + this.reset(); + this.props.onFinished(false); + }; + + private tryAgainClicked = async (e: React.FormEvent) => { + e.preventDefault(); + this.reset(); + await this.updateMode(this.props.mode); + }; + + private onBackClick = async () => { + await this.state.rendezvous?.cancel(RendezvousFailureReason.UserCancelled); + + this.props.onFinished(false); + }; + + private cancelButton = () => + { _t("Cancel") } + ; + + private simpleSpinner = (description?: string): JSX.Element => { + return
+
+ + { description &&

{ description }

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

{ cancellationMessage }

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

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

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

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

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

{ titleIcon }{ title }

+
+
+ { main } +
+
+ { buttons } +
+
+ ); + } +} diff --git a/src/components/views/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/settings/DevicesPanel.tsx b/src/components/views/settings/DevicesPanel.tsx index f32f7997fed..1b06fa06fe6 100644 --- a/src/components/views/settings/DevicesPanel.tsx +++ b/src/components/views/settings/DevicesPanel.tsx @@ -19,13 +19,14 @@ import classNames from 'classnames'; import { IMyDevice } from "matrix-js-sdk/src/client"; import { logger } from "matrix-js-sdk/src/logger"; import { CrossSigningInfo } from "matrix-js-sdk/src/crypto/CrossSigning"; +import { CryptoEvent } from 'matrix-js-sdk/src/crypto'; -import { MatrixClientPeg } from '../../../MatrixClientPeg'; import { _t } from '../../../languageHandler'; import DevicesPanelEntry from "./DevicesPanelEntry"; import Spinner from "../elements/Spinner"; import AccessibleButton from "../elements/AccessibleButton"; import { deleteDevicesWithInteractiveAuth } from './devices/deleteDevices'; +import MatrixClientContext from '../../../contexts/MatrixClientContext'; interface IProps { className?: string; @@ -40,6 +41,8 @@ interface IState { } export default class DevicesPanel extends React.Component { + public static contextType = MatrixClientContext; + public context!: React.ContextType; private unmounted = false; constructor(props: IProps) { @@ -52,15 +55,22 @@ export default class DevicesPanel extends React.Component { } public componentDidMount(): void { + this.context.on(CryptoEvent.DevicesUpdated, this.onDevicesUpdated); this.loadDevices(); } public componentWillUnmount(): void { + this.context.off(CryptoEvent.DevicesUpdated, this.onDevicesUpdated); this.unmounted = true; } + private onDevicesUpdated = (users: string[]) => { + if (!users.includes(this.context.getUserId())) return; + this.loadDevices(); + }; + private loadDevices(): void { - const cli = MatrixClientPeg.get(); + const cli = this.context; cli.getDevices().then( (resp) => { if (this.unmounted) { return; } @@ -111,7 +121,7 @@ export default class DevicesPanel extends React.Component { private isDeviceVerified(device: IMyDevice): boolean | null { try { - const cli = MatrixClientPeg.get(); + const cli = this.context; const deviceInfo = cli.getStoredDevice(cli.getUserId(), device.device_id); return this.state.crossSigningInfo.checkDeviceTrust( this.state.crossSigningInfo, @@ -184,7 +194,7 @@ export default class DevicesPanel extends React.Component { try { await deleteDevicesWithInteractiveAuth( - MatrixClientPeg.get(), + this.context, this.state.selectedDevices, (success) => { if (success) { @@ -208,7 +218,7 @@ export default class DevicesPanel extends React.Component { }; private renderDevice = (device: IMyDevice): JSX.Element => { - const myDeviceId = MatrixClientPeg.get().getDeviceId(); + const myDeviceId = this.context.getDeviceId(); const myDevice = this.state.devices.find((device) => (device.device_id === myDeviceId)); const isOwnDevice = device.device_id === myDeviceId; @@ -246,7 +256,7 @@ export default class DevicesPanel extends React.Component { return ; } - const myDeviceId = MatrixClientPeg.get().getDeviceId(); + const myDeviceId = this.context.getDeviceId(); const myDevice = devices.find((device) => (device.device_id === myDeviceId)); if (!myDevice) { diff --git a/src/components/views/settings/devices/LoginWithQRSection.tsx b/src/components/views/settings/devices/LoginWithQRSection.tsx new file mode 100644 index 00000000000..20cdb37902e --- /dev/null +++ b/src/components/views/settings/devices/LoginWithQRSection.tsx @@ -0,0 +1,63 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from 'react'; + +import type { IServerVersions } from 'matrix-js-sdk/src/matrix'; +import { _t } from '../../../../languageHandler'; +import AccessibleButton from '../../elements/AccessibleButton'; +import SettingsSubsection from '../shared/SettingsSubsection'; +import SettingsStore from '../../../../settings/SettingsStore'; + +interface IProps { + onShowQr: () => void; + versions: IServerVersions; +} + +export default class LoginWithQRSection extends React.Component { + public constructor(props: IProps) { + super(props); + } + + public render(): JSX.Element { + const msc3882Supported = !!this.props.versions?.unstable_features?.['org.matrix.msc3882']; + const msc3886Supported = !!this.props.versions?.unstable_features?.['org.matrix.msc3886']; + + // Needs to be enabled as a feature + server support MSC3886 or have a default rendezvous server configured: + const offerShowQr = SettingsStore.getValue("feature_qr_signin_reciprocate_show") && + msc3882Supported && msc3886Supported; // We don't support configuration of a fallback at the moment so we just check the MSCs + + // don't show anything if no method is available + if (!offerShowQr) { + return null; + } + + return +
+

{ + _t("You can use this device to sign in a new device with a QR code. You will need to " + + "scan the QR code shown on this device with your device that's signed out.") + }

+ { _t("Show QR code") } +
+
; + } +} diff --git a/src/components/views/settings/devices/useOwnDevices.ts b/src/components/views/settings/devices/useOwnDevices.ts index c3b8cb0212a..f56ed85c87b 100644 --- a/src/components/views/settings/devices/useOwnDevices.ts +++ b/src/components/views/settings/devices/useOwnDevices.ts @@ -31,6 +31,7 @@ import { VerificationRequest } from "matrix-js-sdk/src/crypto/verification/reque import { MatrixError } from "matrix-js-sdk/src/http-api"; import { logger } from "matrix-js-sdk/src/logger"; import { LocalNotificationSettings } from "matrix-js-sdk/src/@types/local_notifications"; +import { CryptoEvent } from "matrix-js-sdk/src/crypto"; import MatrixClientContext from "../../../../contexts/MatrixClientContext"; import { _t } from "../../../../languageHandler"; @@ -179,6 +180,12 @@ export const useOwnDevices = (): DevicesState => { refreshDevices(); }, [refreshDevices]); + useEventEmitter(matrixClient, CryptoEvent.DevicesUpdated, (users: string[]): void => { + if (users.includes(userId)) { + refreshDevices(); + } + }); + useEventEmitter(matrixClient, ClientEvent.AccountData, (event: MatrixEvent): void => { const type = event.getType(); if (type.startsWith(LOCAL_NOTIFICATION_SETTINGS_PREFIX.name)) { diff --git a/src/components/views/settings/tabs/user/SecurityUserSettingsTab.tsx b/src/components/views/settings/tabs/user/SecurityUserSettingsTab.tsx index f4e4e55513d..b960e65a61e 100644 --- a/src/components/views/settings/tabs/user/SecurityUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/SecurityUserSettingsTab.tsx @@ -38,6 +38,9 @@ import InlineSpinner from "../../../elements/InlineSpinner"; import { PosthogAnalytics } from "../../../../../PosthogAnalytics"; import { showDialog as showAnalyticsLearnMoreDialog } from "../../../dialogs/AnalyticsLearnMoreDialog"; import { privateShouldBeEncrypted } from "../../../../../utils/rooms"; +import LoginWithQR, { Mode } from '../../../auth/LoginWithQR'; +import LoginWithQRSection from '../../devices/LoginWithQRSection'; +import type { IServerVersions } from 'matrix-js-sdk/src/matrix'; interface IIgnoredUserProps { userId: string; @@ -72,6 +75,8 @@ interface IState { waitingUnignored: string[]; managingInvites: boolean; invitedRoomIds: Set; + showLoginWithQR: Mode | null; + versions?: IServerVersions; } export default class SecurityUserSettingsTab extends React.Component { @@ -88,6 +93,7 @@ export default class SecurityUserSettingsTab extends React.Component this.setState({ versions })); } public componentWillUnmount(): void { @@ -251,6 +258,14 @@ export default class SecurityUserSettingsTab extends React.Component { + this.setState({ showLoginWithQR: Mode.Show }); + }; + + private onLoginWithQRFinished = (): void => { + this.setState({ showLoginWithQR: null }); + }; + public render(): JSX.Element { const secureBackup = (
@@ -347,6 +362,7 @@ export default class SecurityUserSettingsTab extends React.Component @@ -363,8 +379,20 @@ export default class SecurityUserSettingsTab extends React.Component
+ { showQrCodeEnabled ? + + : null + } ; + const client = MatrixClientPeg.get(); + + if (showQrCodeEnabled && this.state.showLoginWithQR) { + return
+ +
; + } + return (
{ warning } diff --git a/src/components/views/settings/tabs/user/SessionManagerTab.tsx b/src/components/views/settings/tabs/user/SessionManagerTab.tsx index d1fbb6ce5c1..49ca1bdbf29 100644 --- a/src/components/views/settings/tabs/user/SessionManagerTab.tsx +++ b/src/components/views/settings/tabs/user/SessionManagerTab.tsx @@ -32,6 +32,10 @@ import SecurityRecommendations from '../../devices/SecurityRecommendations'; import { DeviceSecurityVariation, ExtendedDevice } from '../../devices/types'; import { deleteDevicesWithInteractiveAuth } from '../../devices/deleteDevices'; import SettingsTab from '../SettingsTab'; +import LoginWithQRSection from '../../devices/LoginWithQRSection'; +import LoginWithQR, { Mode } from '../../../auth/LoginWithQR'; +import SettingsStore from '../../../../../settings/SettingsStore'; +import { useAsyncMemo } from '../../../../../hooks/useAsyncMemo'; const useSignOut = ( matrixClient: MatrixClient, @@ -104,6 +108,7 @@ const SessionManagerTab: React.FC = () => { const matrixClient = useContext(MatrixClientContext); const userId = matrixClient.getUserId(); const currentUserMember = userId && matrixClient.getUser(userId) || undefined; + const clientVersions = useAsyncMemo(() => matrixClient.getVersions(), [matrixClient]); const onDeviceExpandToggle = (deviceId: ExtendedDevice['device_id']): void => { if (expandedDeviceIds.includes(deviceId)) { @@ -175,6 +180,26 @@ const SessionManagerTab: React.FC = () => { onSignOutOtherDevices(Object.keys(otherDevices)); }: undefined; + const [signInWithQrMode, setSignInWithQrMode] = useState(); + + const showQrCodeEnabled = SettingsStore.getValue("feature_qr_signin_reciprocate_show"); + + const onQrFinish = useCallback(() => { + setSignInWithQrMode(null); + }, [setSignInWithQrMode]); + + const onShowQrClicked = useCallback(() => { + setSignInWithQrMode(Mode.Show); + }, [setSignInWithQrMode]); + + if (showQrCodeEnabled && signInWithQrMode) { + return ; + } + return { /> } + { showQrCodeEnabled ? + + : null + } ; }; diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 3f078172b3f..3f69088f78e 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -935,6 +935,7 @@ "New session manager": "New session manager", "Have greater visibility and control over all your sessions.": "Have greater visibility and control over all your sessions.", "Our new sessions manager provides better visibility of all your sessions, and greater control over them including the ability to remotely toggle push notifications.": "Our new sessions manager provides better visibility of all your sessions, and greater control over them including the ability to remotely toggle push notifications.", + "Allow a QR code to be shown in session manager to sign in another device (requires compatible homeserver)": "Allow a QR code to be shown in session manager to sign in another device (requires compatible homeserver)", "Font size": "Font size", "Use custom size": "Use custom size", "Enable Emoji suggestions while typing": "Enable Emoji suggestions while typing", @@ -1788,6 +1789,9 @@ "Filter devices": "Filter devices", "Show": "Show", "%(selectedDeviceCount)s sessions selected": "%(selectedDeviceCount)s sessions selected", + "Sign in with QR code": "Sign in with QR code", + "You can use this device to sign in a new device with a QR code. You will need to scan the QR code shown on this device with your device that's signed out.": "You can use this device to sign in a new device with a QR code. You will need to scan the QR code shown on this device with your device that's signed out.", + "Show QR code": "Show QR code", "Security recommendations": "Security recommendations", "Improve your account security by following these recommendations": "Improve your account security by following these recommendations", "View all": "View all", @@ -3181,6 +3185,26 @@ "Submit": "Submit", "Something went wrong in confirming your identity. Cancel and try again.": "Something went wrong in confirming your identity. Cancel and try again.", "Start authentication": "Start authentication", + "Sign in new device": "Sign in new device", + "The linking wasn't completed in the required time.": "The linking wasn't completed in the required time.", + "The scanned code is invalid.": "The scanned code is invalid.", + "Linking with this device is not supported.": "Linking with this device is not supported.", + "The request was declined on the other device.": "The request was declined on the other device.", + "The other device is already signed in.": "The other device is already signed in.", + "The other device isn't signed in.": "The other device isn't signed in.", + "The request was cancelled.": "The request was cancelled.", + "An unexpected error occurred.": "An unexpected error occurred.", + "The homeserver doesn't support signing in another device.": "The homeserver doesn't support signing in another device.", + "Devices connected": "Devices connected", + "Check that the code below matches with your other device:": "Check that the code below matches with your other device:", + "By approving access for this device, it will have full access to your account.": "By approving access for this device, it will have full access to your account.", + "Scan the QR code below with your device that's signed out.": "Scan the QR code below with your device that's signed out.", + "Start at the sign in screen": "Start at the sign in screen", + "Select 'Scan QR code'": "Select 'Scan QR code'", + "Review and approve the sign in": "Review and approve the sign in", + "Connecting...": "Connecting...", + "Waiting for device to sign in": "Waiting for device to sign in", + "Completing set up of your new device": "Completing set up of your new device", "Enter password": "Enter password", "Nice, strong password!": "Nice, strong password!", "Password is allowed, but unsafe": "Password is allowed, but unsafe", diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index 9b6e09c772b..723b789ab01 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -494,6 +494,16 @@ export const SETTINGS: {[setting: string]: ISetting} = { , }, }, + "feature_qr_signin_reciprocate_show": { + isFeature: true, + labsGroup: LabGroup.Experimental, + supportedLevels: LEVELS_FEATURE, + displayName: _td( + "Allow a QR code to be shown in session manager to sign in another device " + + "(requires compatible homeserver)", + ), + default: false, + }, "baseFontSize": { displayName: _td("Font size"), supportedLevels: LEVELS_ACCOUNT_SETTINGS, diff --git a/src/utils/UserInteractiveAuth.ts b/src/utils/UserInteractiveAuth.ts new file mode 100644 index 00000000000..e3088fb3cb4 --- /dev/null +++ b/src/utils/UserInteractiveAuth.ts @@ -0,0 +1,55 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { IAuthData } from "matrix-js-sdk/src/interactive-auth"; +import { UIAResponse } from "matrix-js-sdk/src/@types/uia"; + +import Modal from "../Modal"; +import InteractiveAuthDialog, { InteractiveAuthDialogProps } from "../components/views/dialogs/InteractiveAuthDialog"; + +type FunctionWithUIA = (auth?: IAuthData, ...args: A[]) => Promise>; + +export function wrapRequestWithDialog( + requestFunction: FunctionWithUIA, + opts: Omit, +): ((...args: A[]) => Promise) { + return async function(...args): Promise { + return new Promise((resolve, reject) => { + const boundFunction = requestFunction.bind(opts.matrixClient) as FunctionWithUIA; + boundFunction(undefined, ...args) + .then((res) => resolve(res as R)) + .catch(error => { + if (error.httpStatus !== 401 || !error.data?.flows) { + // doesn't look like an interactive-auth failure + return reject(error); + } + + Modal.createDialog(InteractiveAuthDialog, { + ...opts, + authData: error.data, + makeRequest: (authData) => boundFunction(authData, ...args), + onFinished: (success, result) => { + if (success) { + resolve(result); + } else { + reject(result); + } + }, + }); + }); + }); + }; +} diff --git a/test/components/views/settings/DevicesPanel-test.tsx b/test/components/views/settings/DevicesPanel-test.tsx index a7baf139af3..81f6fb328a6 100644 --- a/test/components/views/settings/DevicesPanel-test.tsx +++ b/test/components/views/settings/DevicesPanel-test.tsx @@ -28,6 +28,7 @@ import { mkPusher, mockClientMethodsUser, } from "../../../test-utils"; +import MatrixClientContext from '../../../../src/contexts/MatrixClientContext'; describe('', () => { const userId = '@alice:server.org'; @@ -46,7 +47,10 @@ describe('', () => { setPusher: jest.fn(), }); - const getComponent = () => ; + const getComponent = () => + + + ; beforeEach(() => { jest.clearAllMocks(); diff --git a/test/components/views/settings/devices/LoginWithQR-test.tsx b/test/components/views/settings/devices/LoginWithQR-test.tsx new file mode 100644 index 00000000000..c106b2f9a86 --- /dev/null +++ b/test/components/views/settings/devices/LoginWithQR-test.tsx @@ -0,0 +1,297 @@ +/* +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 { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { mocked } from 'jest-mock'; +import React from 'react'; +import { MSC3886SimpleHttpRendezvousTransport } from 'matrix-js-sdk/src/rendezvous/transports'; +import { MSC3906Rendezvous, RendezvousFailureReason } from 'matrix-js-sdk/src/rendezvous'; + +import LoginWithQR, { Mode } from '../../../../../src/components/views/auth/LoginWithQR'; +import type { MatrixClient } from 'matrix-js-sdk/src/matrix'; +import { flushPromisesWithFakeTimers } from '../../../../test-utils'; + +jest.useFakeTimers(); + +jest.mock('matrix-js-sdk/src/rendezvous'); +jest.mock('matrix-js-sdk/src/rendezvous/transports'); +jest.mock('matrix-js-sdk/src/rendezvous/channels'); + +function makeClient() { + return mocked({ + getUser: jest.fn(), + isGuest: jest.fn().mockReturnValue(false), + isUserIgnored: jest.fn(), + isCryptoEnabled: jest.fn(), + getUserId: jest.fn(), + on: jest.fn(), + isSynapseAdministrator: jest.fn().mockResolvedValue(false), + isRoomEncrypted: jest.fn().mockReturnValue(false), + mxcUrlToHttp: jest.fn().mockReturnValue('mock-mxcUrlToHttp'), + doesServerSupportUnstableFeature: jest.fn().mockReturnValue(true), + removeListener: jest.fn(), + requestLoginToken: jest.fn(), + currentState: { + on: jest.fn(), + }, + } as unknown as MatrixClient); +} + +describe('', () => { + const client = makeClient(); + const defaultProps = { + mode: Mode.Show, + onFinished: jest.fn(), + }; + const mockConfirmationDigits = 'mock-confirmation-digits'; + const newDeviceId = 'new-device-id'; + + const getComponent = (props: { client: MatrixClient, onFinished?: () => void }) => + (); + + beforeEach(() => { + jest.clearAllMocks(); + jest.spyOn(MSC3906Rendezvous.prototype, 'generateCode').mockRestore(); + jest.spyOn(MSC3906Rendezvous.prototype, 'cancel').mockResolvedValue(); + jest.spyOn(MSC3906Rendezvous.prototype, 'declineLoginOnExistingDevice').mockResolvedValue(); + jest.spyOn(MSC3906Rendezvous.prototype, 'startAfterShowingCode').mockResolvedValue(mockConfirmationDigits); + jest.spyOn(MSC3906Rendezvous.prototype, 'approveLoginOnExistingDevice').mockResolvedValue(newDeviceId); + client.requestLoginToken.mockResolvedValue({ + login_token: 'token', + expires_in: 1000, + }); + // @ts-ignore + client.crypto = undefined; + }); + + it('no content in case of no support', async () => { + // simulate no support + jest.spyOn(MSC3906Rendezvous.prototype, 'generateCode').mockRejectedValue(''); + const { container } = render(getComponent({ client })); + await waitFor(() => screen.getAllByTestId('cancellation-message').length === 1); + expect(container).toMatchSnapshot(); + }); + + it('renders spinner while generating code', async () => { + const { container } = render(getComponent({ client })); + expect(container).toMatchSnapshot(); + }); + + it('cancels rendezvous after user goes back', async () => { + const { getByTestId } = render(getComponent({ client })); + const rendezvous = mocked(MSC3906Rendezvous).mock.instances[0]; + // @ts-ignore assign to private prop + rendezvous.code = 'rendezvous-code'; + + // flush generate code promise + await flushPromisesWithFakeTimers(); + + fireEvent.click(getByTestId('back-button')); + + // wait for cancel + await flushPromisesWithFakeTimers(); + + expect(rendezvous.cancel).toHaveBeenCalledWith(RendezvousFailureReason.UserCancelled); + }); + + it('displays qr code after it is created', async () => { + const { container, getByText } = render(getComponent({ client })); + const rendezvous = mocked(MSC3906Rendezvous).mock.instances[0]; + // @ts-ignore assign to private prop + rendezvous.code = 'rendezvous-code'; + + await flushPromisesWithFakeTimers(); + + expect(rendezvous.generateCode).toHaveBeenCalled(); + expect(getByText('Sign in with QR code')).toBeTruthy(); + expect(container).toMatchSnapshot(); + }); + + it('displays confirmation digits after connected to rendezvous', async () => { + const { container, getByText } = render(getComponent({ client })); + const rendezvous = mocked(MSC3906Rendezvous).mock.instances[0]; + // @ts-ignore assign to private prop + rendezvous.code = 'rendezvous-code'; + + // flush generate code promise + await flushPromisesWithFakeTimers(); + // flush waiting for connection promise + await flushPromisesWithFakeTimers(); + + expect(container).toMatchSnapshot(); + expect(getByText(mockConfirmationDigits)).toBeTruthy(); + }); + + it('displays unknown error if connection to rendezvous fails', async () => { + const { container } = render(getComponent({ client })); + expect(MSC3886SimpleHttpRendezvousTransport).toHaveBeenCalledWith({ + onFailure: expect.any(Function), + client, + }); + const rendezvous = mocked(MSC3906Rendezvous).mock.instances[0]; + // @ts-ignore assign to private prop + rendezvous.code = 'rendezvous-code'; + mocked(rendezvous).startAfterShowingCode.mockRejectedValue('oups'); + + // flush generate code promise + await flushPromisesWithFakeTimers(); + // flush waiting for connection promise + await flushPromisesWithFakeTimers(); + + expect(container).toMatchSnapshot(); + }); + + it('declines login', async () => { + const { getByTestId } = render(getComponent({ client })); + const rendezvous = mocked(MSC3906Rendezvous).mock.instances[0]; + // @ts-ignore assign to private prop + rendezvous.code = 'rendezvous-code'; + + // flush generate code promise + await flushPromisesWithFakeTimers(); + // flush waiting for connection promise + await flushPromisesWithFakeTimers(); + + fireEvent.click(getByTestId('decline-login-button')); + + expect(rendezvous.declineLoginOnExistingDevice).toHaveBeenCalled(); + }); + + it('displays error when approving login fails', async () => { + const { container, getByTestId } = render(getComponent({ client })); + const rendezvous = mocked(MSC3906Rendezvous).mock.instances[0]; + // @ts-ignore assign to private prop + rendezvous.code = 'rendezvous-code'; + client.requestLoginToken.mockRejectedValue('oups'); + + // flush generate code promise + await flushPromisesWithFakeTimers(); + // flush waiting for connection promise + await flushPromisesWithFakeTimers(); + + fireEvent.click(getByTestId('approve-login-button')); + + expect(client.requestLoginToken).toHaveBeenCalled(); + // flush token request promise + await flushPromisesWithFakeTimers(); + await flushPromisesWithFakeTimers(); + + expect(container).toMatchSnapshot(); + }); + + it('approves login and waits for new device', async () => { + const { container, getByTestId, getByText } = render(getComponent({ client })); + const rendezvous = mocked(MSC3906Rendezvous).mock.instances[0]; + // @ts-ignore assign to private prop + rendezvous.code = 'rendezvous-code'; + + // flush generate code promise + await flushPromisesWithFakeTimers(); + // flush waiting for connection promise + await flushPromisesWithFakeTimers(); + + fireEvent.click(getByTestId('approve-login-button')); + + expect(client.requestLoginToken).toHaveBeenCalled(); + // flush token request promise + await flushPromisesWithFakeTimers(); + await flushPromisesWithFakeTimers(); + + expect(getByText('Waiting for device to sign in')).toBeTruthy(); + expect(container).toMatchSnapshot(); + }); + + it('does not continue with verification when user denies login', async () => { + const onFinished = jest.fn(); + const { getByTestId } = render(getComponent({ client, onFinished })); + const rendezvous = mocked(MSC3906Rendezvous).mock.instances[0]; + // @ts-ignore assign to private prop + rendezvous.code = 'rendezvous-code'; + // no device id returned => user denied + mocked(rendezvous).approveLoginOnExistingDevice.mockReturnValue(undefined); + + // flush generate code promise + await flushPromisesWithFakeTimers(); + // flush waiting for connection promise + await flushPromisesWithFakeTimers(); + + fireEvent.click(getByTestId('approve-login-button')); + + // flush token request promise + await flushPromisesWithFakeTimers(); + await flushPromisesWithFakeTimers(); + + expect(rendezvous.approveLoginOnExistingDevice).toHaveBeenCalled(); + + await flushPromisesWithFakeTimers(); + expect(onFinished).not.toHaveBeenCalled(); + expect(rendezvous.verifyNewDeviceOnExistingDevice).not.toHaveBeenCalled(); + }); + + it('waits for device approval on existing device and finishes when crypto is not setup', async () => { + const { getByTestId } = render(getComponent({ client })); + const rendezvous = mocked(MSC3906Rendezvous).mock.instances[0]; + // @ts-ignore assign to private prop + rendezvous.code = 'rendezvous-code'; + + // flush generate code promise + await flushPromisesWithFakeTimers(); + // flush waiting for connection promise + await flushPromisesWithFakeTimers(); + + fireEvent.click(getByTestId('approve-login-button')); + + // flush token request promise + await flushPromisesWithFakeTimers(); + await flushPromisesWithFakeTimers(); + + expect(rendezvous.approveLoginOnExistingDevice).toHaveBeenCalled(); + await flushPromisesWithFakeTimers(); + expect(defaultProps.onFinished).toHaveBeenCalledWith(true); + // didnt attempt verification + expect(rendezvous.verifyNewDeviceOnExistingDevice).not.toHaveBeenCalled(); + }); + + it('waits for device approval on existing device and verifies device', async () => { + const { getByTestId } = render(getComponent({ client })); + const rendezvous = mocked(MSC3906Rendezvous).mock.instances[0]; + // @ts-ignore assign to private prop + rendezvous.code = 'rendezvous-code'; + // we just check for presence of crypto + // pretend it is set up + // @ts-ignore + client.crypto = {}; + + // flush generate code promise + await flushPromisesWithFakeTimers(); + // flush waiting for connection promise + await flushPromisesWithFakeTimers(); + + fireEvent.click(getByTestId('approve-login-button')); + + // flush token request promise + await flushPromisesWithFakeTimers(); + await flushPromisesWithFakeTimers(); + + expect(rendezvous.approveLoginOnExistingDevice).toHaveBeenCalled(); + // flush login approval + await flushPromisesWithFakeTimers(); + expect(rendezvous.verifyNewDeviceOnExistingDevice).toHaveBeenCalled(); + // flush verification + await flushPromisesWithFakeTimers(); + expect(defaultProps.onFinished).toHaveBeenCalledWith(true); + }); +}); diff --git a/test/components/views/settings/devices/LoginWithQRSection-test.tsx b/test/components/views/settings/devices/LoginWithQRSection-test.tsx new file mode 100644 index 00000000000..711f4710350 --- /dev/null +++ b/test/components/views/settings/devices/LoginWithQRSection-test.tsx @@ -0,0 +1,94 @@ +/* +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 { render } from '@testing-library/react'; +import { mocked } from 'jest-mock'; +import { IServerVersions, MatrixClient } from 'matrix-js-sdk/src/matrix'; +import React from 'react'; + +import LoginWithQRSection from '../../../../../src/components/views/settings/devices/LoginWithQRSection'; +import { MatrixClientPeg } from '../../../../../src/MatrixClientPeg'; +import { SettingLevel } from '../../../../../src/settings/SettingLevel'; +import SettingsStore from '../../../../../src/settings/SettingsStore'; + +function makeClient() { + return mocked({ + getUser: jest.fn(), + isGuest: jest.fn().mockReturnValue(false), + isUserIgnored: jest.fn(), + isCryptoEnabled: jest.fn(), + getUserId: jest.fn(), + on: jest.fn(), + isSynapseAdministrator: jest.fn().mockResolvedValue(false), + isRoomEncrypted: jest.fn().mockReturnValue(false), + mxcUrlToHttp: jest.fn().mockReturnValue('mock-mxcUrlToHttp'), + removeListener: jest.fn(), + currentState: { + on: jest.fn(), + }, + } as unknown as MatrixClient); +} + +function makeVersions(unstableFeatures: Record): IServerVersions { + return { + versions: [], + unstable_features: unstableFeatures, + }; +} + +describe('', () => { + beforeAll(() => { + jest.spyOn(MatrixClientPeg, 'get').mockReturnValue(makeClient()); + }); + + const defaultProps = { + onShowQr: () => {}, + versions: undefined, + }; + + const getComponent = (props = {}) => + (); + + describe('should not render', () => { + it('no support at all', () => { + const { container } = render(getComponent()); + expect(container).toMatchSnapshot(); + }); + + it('feature enabled', async () => { + await SettingsStore.setValue('feature_qr_signin_reciprocate_show', null, SettingLevel.DEVICE, true); + const { container } = render(getComponent()); + expect(container).toMatchSnapshot(); + }); + + it('only feature + MSC3882 enabled', async () => { + await SettingsStore.setValue('feature_qr_signin_reciprocate_show', null, SettingLevel.DEVICE, true); + const { container } = render(getComponent({ versions: makeVersions({ 'org.matrix.msc3882': true }) })); + expect(container).toMatchSnapshot(); + }); + }); + + describe('should render panel', () => { + it('enabled by feature + MSC3882 + MSC3886', async () => { + await SettingsStore.setValue('feature_qr_signin_reciprocate_show', null, SettingLevel.DEVICE, true); + const { container } = render(getComponent({ versions: makeVersions({ + 'org.matrix.msc3882': true, + 'org.matrix.msc3886': true, + }) })); + expect(container).toMatchSnapshot(); + }); + }); +}); diff --git a/test/components/views/settings/devices/__snapshots__/LoginWithQR-test.tsx.snap b/test/components/views/settings/devices/__snapshots__/LoginWithQR-test.tsx.snap new file mode 100644 index 00000000000..91fe73abf40 --- /dev/null +++ b/test/components/views/settings/devices/__snapshots__/LoginWithQR-test.tsx.snap @@ -0,0 +1,367 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` approves login and waits for new device 1`] = ` +
+
+
+
+
+
+

+

+
+
+
+
+
+
+

+ Waiting for device to sign in +

+
+
+
+
+
+ Cancel +
+
+
+
+`; + +exports[` displays confirmation digits after connected to rendezvous 1`] = ` +
+
+
+

+
+ Devices connected +

+
+
+

+ Check that the code below matches with your other device: +

+
+ mock-confirmation-digits +
+
+
+
+
+
+ By approving access for this device, it will have full access to your account. +
+
+
+
+
+ Cancel +
+
+ Approve +
+
+
+
+`; + +exports[` displays error when approving login fails 1`] = ` +
+
+
+

+
+ Connection failed +

+
+
+

+ An unexpected error occurred. +

+
+
+
+ Try again +
+
+ Cancel +
+
+
+
+`; + +exports[` displays qr code after it is created 1`] = ` +
+
+
+
+
+
+

+ Sign in with QR code +

+
+
+

+ Scan the QR code below with your device that's signed out. +

+
    +
  1. + Start at the sign in screen +
  2. +
  3. + Select 'Scan QR code' +
  4. +
  5. + Review and approve the sign in +
  6. +
+
+
+
+
+
+
+
+
+
+
+
+`; + +exports[` displays unknown error if connection to rendezvous fails 1`] = ` +
+
+
+

+
+ Connection failed +

+
+
+

+ An unexpected error occurred. +

+
+
+
+ Try again +
+
+ Cancel +
+
+
+
+`; + +exports[` no content in case of no support 1`] = ` +
+
+
+

+
+ Connection failed +

+
+
+

+ The homeserver doesn't support signing in another device. +

+
+
+
+ Try again +
+
+ Cancel +
+
+
+
+`; + +exports[` renders spinner while generating code 1`] = ` +
+
+
+
+
+
+

+

+
+
+
+
+
+
+
+
+
+
+
+
+`; diff --git a/test/components/views/settings/devices/__snapshots__/LoginWithQRSection-test.tsx.snap b/test/components/views/settings/devices/__snapshots__/LoginWithQRSection-test.tsx.snap new file mode 100644 index 00000000000..2cf0d24cc6c --- /dev/null +++ b/test/components/views/settings/devices/__snapshots__/LoginWithQRSection-test.tsx.snap @@ -0,0 +1,45 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` should not render feature enabled 1`] = `
`; + +exports[` should not render no support at all 1`] = `
`; + +exports[` should not render only feature + MSC3882 enabled 1`] = `
`; + +exports[` should render panel enabled by feature + MSC3882 + MSC3886 1`] = ` +
+
+
+

+ Sign in with QR code +

+
+
+
+

+ You can use this device to sign in a new device with a QR code. You will need to scan the QR code shown on this device with your device that's signed out. +

+
+ Show QR code +
+
+
+
+
+`; diff --git a/test/components/views/settings/tabs/user/SecurityUserSettingsTab-test.tsx b/test/components/views/settings/tabs/user/SecurityUserSettingsTab-test.tsx index bddb493463f..3497f2f161f 100644 --- a/test/components/views/settings/tabs/user/SecurityUserSettingsTab-test.tsx +++ b/test/components/views/settings/tabs/user/SecurityUserSettingsTab-test.tsx @@ -17,6 +17,7 @@ import { render } from '@testing-library/react'; import React from 'react'; import SecurityUserSettingsTab from "../../../../../../src/components/views/settings/tabs/user/SecurityUserSettingsTab"; +import MatrixClientContext from '../../../../../../src/contexts/MatrixClientContext'; import SettingsStore from '../../../../../../src/settings/SettingsStore'; import { getMockClientWithEventEmitter, @@ -31,11 +32,10 @@ describe('', () => { const defaultProps = { closeSettingsFn: jest.fn(), }; - const getComponent = () => ; const userId = '@alice:server.org'; const deviceId = 'alices-device'; - getMockClientWithEventEmitter({ + const mockClient = getMockClientWithEventEmitter({ ...mockClientMethodsUser(userId), ...mockClientMethodsServer(), ...mockClientMethodsDevice(deviceId), @@ -44,6 +44,11 @@ describe('', () => { getIgnoredUsers: jest.fn(), }); + const getComponent = () => + + + ; + const settingsValueSpy = jest.spyOn(SettingsStore, 'getValue'); beforeEach(() => { diff --git a/test/components/views/settings/tabs/user/SessionManagerTab-test.tsx b/test/components/views/settings/tabs/user/SessionManagerTab-test.tsx index 5bcb6cc36c9..7826b3cc809 100644 --- a/test/components/views/settings/tabs/user/SessionManagerTab-test.tsx +++ b/test/components/views/settings/tabs/user/SessionManagerTab-test.tsx @@ -92,6 +92,7 @@ describe('', () => { getPushers: jest.fn(), setPusher: jest.fn(), setLocalNotificationSettings: jest.fn(), + getVersions: jest.fn().mockResolvedValue({}), }); const defaultProps = {}; diff --git a/test/test-utils/client.ts b/test/test-utils/client.ts index d3274c589a8..e0c532c0216 100644 --- a/test/test-utils/client.ts +++ b/test/test-utils/client.ts @@ -104,6 +104,7 @@ export const mockClientMethodsServer = (): Partial Date: Wed, 19 Oct 2022 15:01:14 +0200 Subject: [PATCH 011/113] Display info dialogs if unable to start voice broadcasts (#9453) --- src/components/structures/RoomView.tsx | 6 - .../views/rooms/MessageComposer.tsx | 9 +- src/contexts/RoomContext.ts | 1 - src/i18n/strings/en_EN.json | 4 + src/voice-broadcast/index.ts | 1 + .../utils/hasRoomLiveVoiceBroadcast.ts | 54 +++++++ .../utils/startNewVoiceBroadcastRecording.ts | 76 --------- .../utils/startNewVoiceBroadcastRecording.tsx | 136 ++++++++++++++++ .../views/rooms/MessageComposer-test.tsx | 13 +- .../rooms/MessageComposerButtons-test.tsx | 1 - .../views/rooms/SendMessageComposer-test.tsx | 1 - .../wysiwyg_composer/WysiwygComposer-test.tsx | 1 - .../rooms/wysiwyg_composer/message-test.ts | 1 - ...artNewVoiceBroadcastRecording-test.ts.snap | 70 ++++++++ .../utils/hasRoomLiveVoiceBroadcast-test.ts | 144 +++++++++++++++++ .../startNewVoiceBroadcastRecording-test.ts | 151 +++++++++++------- test/voice-broadcast/utils/test-utils.ts | 37 +++++ 17 files changed, 546 insertions(+), 160 deletions(-) create mode 100644 src/voice-broadcast/utils/hasRoomLiveVoiceBroadcast.ts delete mode 100644 src/voice-broadcast/utils/startNewVoiceBroadcastRecording.ts create mode 100644 src/voice-broadcast/utils/startNewVoiceBroadcastRecording.tsx create mode 100644 test/voice-broadcast/utils/__snapshots__/startNewVoiceBroadcastRecording-test.ts.snap create mode 100644 test/voice-broadcast/utils/hasRoomLiveVoiceBroadcast-test.ts create mode 100644 test/voice-broadcast/utils/test-utils.ts diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index 77245e0eb81..2dfe61aefa3 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -113,7 +113,6 @@ import { isLocalRoom } from '../../utils/localRoom/isLocalRoom'; import { ShowThreadPayload } from "../../dispatcher/payloads/ShowThreadPayload"; 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"; @@ -199,7 +198,6 @@ export interface IRoomState { upgradeRecommendation?: IRecommendedVersion; canReact: boolean; canSendMessages: boolean; - canSendVoiceBroadcasts: boolean; tombstone?: MatrixEvent; resizing: boolean; layout: Layout; @@ -404,7 +402,6 @@ export class RoomView extends React.Component { statusBarVisible: false, canReact: false, canSendMessages: false, - canSendVoiceBroadcasts: false, resizing: false, layout: SettingsStore.getValue("layout"), lowBandwidth: SettingsStore.getValue("lowBandwidth"), @@ -1377,12 +1374,10 @@ export class RoomView extends React.Component { ); 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, }); } @@ -2253,7 +2248,6 @@ export class RoomView extends React.Component { resizeNotifier={this.props.resizeNotifier} replyToEvent={this.state.replyToEvent} permalinkCreator={this.permalinkCreator} - showVoiceBroadcastButton={this.state.canSendVoiceBroadcasts} />; } diff --git a/src/components/views/rooms/MessageComposer.tsx b/src/components/views/rooms/MessageComposer.tsx index 9783e30756a..6c83b75b875 100644 --- a/src/components/views/rooms/MessageComposer.tsx +++ b/src/components/views/rooms/MessageComposer.tsx @@ -85,7 +85,6 @@ interface IProps { relation?: IEventRelation; e2eStatus?: E2EStatus; compact?: boolean; - showVoiceBroadcastButton?: boolean; } interface IState { @@ -384,10 +383,6 @@ 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 = [ @@ -532,10 +527,10 @@ export default class MessageComposer extends React.Component { showPollsButton={this.state.showPollsButton} showStickersButton={this.showStickersButton} toggleButtonMenu={this.toggleButtonMenu} - showVoiceBroadcastButton={this.showVoiceBroadcastButton} + showVoiceBroadcastButton={this.state.showVoiceBroadcastButton} onStartVoiceBroadcastClick={() => { startNewVoiceBroadcastRecording( - this.props.room.roomId, + this.props.room, MatrixClientPeg.get(), VoiceBroadcastRecordingsStore.instance(), ); diff --git a/src/contexts/RoomContext.ts b/src/contexts/RoomContext.ts index 5bc648e736a..8193c83cccb 100644 --- a/src/contexts/RoomContext.ts +++ b/src/contexts/RoomContext.ts @@ -45,7 +45,6 @@ const RoomContext = createContext({ canReact: false, canSelfRedact: false, canSendMessages: false, - canSendVoiceBroadcasts: false, resizing: false, layout: Layout.Group, lowBandwidth: false, diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 3f69088f78e..f322c5de8d8 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -637,6 +637,10 @@ "Send %(msgtype)s messages as you in your active room": "Send %(msgtype)s messages as you in your active room", "See %(msgtype)s messages posted to this room": "See %(msgtype)s messages posted to this room", "See %(msgtype)s messages posted to your active room": "See %(msgtype)s messages posted to your active room", + "Can't start a new voice broadcast": "Can't start a new voice broadcast", + "You are already recording a voice broadcast. Please end your current voice broadcast to start a new one.": "You are already recording a voice broadcast. Please end your current voice broadcast to start a new one.", + "You don't have the required permissions to start a voice broadcast in this room. Contact a room administrator to upgrade your permissions.": "You don't have the required permissions to start a voice broadcast in this room. Contact a room administrator to upgrade your permissions.", + "Someone else is already recording a voice broadcast. Wait for their voice broadcast to end to start a new one.": "Someone else is already recording a voice broadcast. Wait for their voice broadcast to end to start a new one.", "Stop live broadcasting?": "Stop live broadcasting?", "Are you sure you want to stop your live broadcast?This will end the broadcast and the full recording will be available in the room.": "Are you sure you want to stop your live broadcast?This will end the broadcast and the full recording will be available in the room.", "Yes, stop broadcast": "Yes, stop broadcast", diff --git a/src/voice-broadcast/index.ts b/src/voice-broadcast/index.ts index 7262382b0c5..8f01c089c68 100644 --- a/src/voice-broadcast/index.ts +++ b/src/voice-broadcast/index.ts @@ -35,6 +35,7 @@ export * from "./components/molecules/VoiceBroadcastRecordingPip"; export * from "./hooks/useVoiceBroadcastRecording"; export * from "./stores/VoiceBroadcastPlaybacksStore"; export * from "./stores/VoiceBroadcastRecordingsStore"; +export * from "./utils/hasRoomLiveVoiceBroadcast"; export * from "./utils/shouldDisplayAsVoiceBroadcastRecordingTile"; export * from "./utils/shouldDisplayAsVoiceBroadcastTile"; export * from "./utils/startNewVoiceBroadcastRecording"; diff --git a/src/voice-broadcast/utils/hasRoomLiveVoiceBroadcast.ts b/src/voice-broadcast/utils/hasRoomLiveVoiceBroadcast.ts new file mode 100644 index 00000000000..577b9ed8805 --- /dev/null +++ b/src/voice-broadcast/utils/hasRoomLiveVoiceBroadcast.ts @@ -0,0 +1,54 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { MatrixEvent, Room } from "matrix-js-sdk/src/matrix"; + +import { VoiceBroadcastInfoEventType, VoiceBroadcastInfoState } from ".."; + +interface Result { + hasBroadcast: boolean; + startedByUser: boolean; +} + +/** + * Finds out whether there is a live broadcast in a room. + * Also returns if the user started the broadcast (if any). + */ +export const hasRoomLiveVoiceBroadcast = (room: Room, userId: string): Result => { + let hasBroadcast = false; + let startedByUser = false; + + const stateEvents = room.currentState.getStateEvents(VoiceBroadcastInfoEventType); + stateEvents.forEach((event: MatrixEvent) => { + const state = event.getContent()?.state; + + if (state && state !== VoiceBroadcastInfoState.Stopped) { + hasBroadcast = true; + + // state key = sender's MXID + if (event.getStateKey() === userId) { + startedByUser = true; + // break here, because more than true / true is not possible + return false; + } + } + }); + + return { + hasBroadcast, + startedByUser, + }; +}; diff --git a/src/voice-broadcast/utils/startNewVoiceBroadcastRecording.ts b/src/voice-broadcast/utils/startNewVoiceBroadcastRecording.ts deleted file mode 100644 index 272958e5d0c..00000000000 --- a/src/voice-broadcast/utils/startNewVoiceBroadcastRecording.ts +++ /dev/null @@ -1,76 +0,0 @@ -/* -Copyright 2022 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import { ISendEventResponse, MatrixClient, RoomStateEvent } from "matrix-js-sdk/src/matrix"; -import { defer } from "matrix-js-sdk/src/utils"; - -import { - VoiceBroadcastInfoEventContent, - VoiceBroadcastInfoEventType, - VoiceBroadcastInfoState, - VoiceBroadcastRecordingsStore, - VoiceBroadcastRecording, -} from ".."; - -/** - * Starts a new Voice Broadcast Recording. - * Sends a voice_broadcast_info state event and waits for the event to actually appear in the room state. - */ -export const startNewVoiceBroadcastRecording = async ( - roomId: string, - client: MatrixClient, - recordingsStore: VoiceBroadcastRecordingsStore, -): Promise => { - const room = client.getRoom(roomId); - const { promise, resolve } = defer(); - let result: ISendEventResponse = null; - - const onRoomStateEvents = () => { - if (!result) return; - - const voiceBroadcastEvent = room.currentState.getStateEvents( - VoiceBroadcastInfoEventType, - client.getUserId(), - ); - - if (voiceBroadcastEvent?.getId() === result.event_id) { - room.off(RoomStateEvent.Events, onRoomStateEvents); - const recording = new VoiceBroadcastRecording( - voiceBroadcastEvent, - client, - ); - recordingsStore.setCurrent(recording); - recording.start(); - resolve(recording); - } - }; - - room.on(RoomStateEvent.Events, onRoomStateEvents); - - // XXX Michael W: refactor to live event - result = await client.sendStateEvent( - roomId, - VoiceBroadcastInfoEventType, - { - device_id: client.getDeviceId(), - state: VoiceBroadcastInfoState.Started, - chunk_length: 300, - } as VoiceBroadcastInfoEventContent, - client.getUserId(), - ); - - return promise; -}; diff --git a/src/voice-broadcast/utils/startNewVoiceBroadcastRecording.tsx b/src/voice-broadcast/utils/startNewVoiceBroadcastRecording.tsx new file mode 100644 index 00000000000..cff195c6682 --- /dev/null +++ b/src/voice-broadcast/utils/startNewVoiceBroadcastRecording.tsx @@ -0,0 +1,136 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from "react"; +import { ISendEventResponse, MatrixClient, Room, RoomStateEvent } from "matrix-js-sdk/src/matrix"; +import { defer } from "matrix-js-sdk/src/utils"; + +import { _t } from "../../languageHandler"; +import InfoDialog from "../../components/views/dialogs/InfoDialog"; +import Modal from "../../Modal"; +import { + VoiceBroadcastInfoEventContent, + VoiceBroadcastInfoEventType, + VoiceBroadcastInfoState, + VoiceBroadcastRecordingsStore, + VoiceBroadcastRecording, + hasRoomLiveVoiceBroadcast, +} from ".."; + +const startBroadcast = async ( + room: Room, + client: MatrixClient, + recordingsStore: VoiceBroadcastRecordingsStore, +): Promise => { + const { promise, resolve } = defer(); + let result: ISendEventResponse = null; + + const onRoomStateEvents = () => { + if (!result) return; + + const voiceBroadcastEvent = room.currentState.getStateEvents( + VoiceBroadcastInfoEventType, + client.getUserId(), + ); + + if (voiceBroadcastEvent?.getId() === result.event_id) { + room.off(RoomStateEvent.Events, onRoomStateEvents); + const recording = new VoiceBroadcastRecording( + voiceBroadcastEvent, + client, + ); + recordingsStore.setCurrent(recording); + recording.start(); + resolve(recording); + } + }; + + room.on(RoomStateEvent.Events, onRoomStateEvents); + + // XXX Michael W: refactor to live event + result = await client.sendStateEvent( + room.roomId, + VoiceBroadcastInfoEventType, + { + device_id: client.getDeviceId(), + state: VoiceBroadcastInfoState.Started, + chunk_length: 300, + } as VoiceBroadcastInfoEventContent, + client.getUserId(), + ); + + return promise; +}; + +const showAlreadyRecordingDialog = () => { + Modal.createDialog(InfoDialog, { + title: _t("Can't start a new voice broadcast"), + description:

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

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

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

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

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

, + hasCloseButton: true, + }); +}; + +/** + * Starts a new Voice Broadcast Recording, if + * - the user has the permissions to do so in the room + * - there is no other broadcast being recorded in the room, yet + * Sends a voice_broadcast_info state event and waits for the event to actually appear in the room state. + */ +export const startNewVoiceBroadcastRecording = async ( + room: Room, + client: MatrixClient, + recordingsStore: VoiceBroadcastRecordingsStore, +): Promise => { + const currentUserId = client.getUserId(); + + if (!room.currentState.maySendStateEvent(VoiceBroadcastInfoEventType, currentUserId)) { + showInsufficientPermissionsDialog(); + return null; + } + + const { hasBroadcast, startedByUser } = hasRoomLiveVoiceBroadcast(room, currentUserId); + + if (hasBroadcast && startedByUser) { + showAlreadyRecordingDialog(); + return null; + } + + if (hasBroadcast) { + showOthersAlreadyRecordingDialog(); + return null; + } + + return startBroadcast(room, client, recordingsStore); +}; diff --git a/test/components/views/rooms/MessageComposer-test.tsx b/test/components/views/rooms/MessageComposer-test.tsx index bc0b26f745a..8ebeda676a6 100644 --- a/test/components/views/rooms/MessageComposer-test.tsx +++ b/test/components/views/rooms/MessageComposer-test.tsx @@ -147,7 +147,7 @@ describe("MessageComposer", () => { beforeEach(() => { SettingsStore.setValue(setting, null, SettingLevel.DEVICE, value); - wrapper = wrapAndRender({ room, showVoiceBroadcastButton: true }); + wrapper = wrapAndRender({ room }); }); it(`should pass the prop ${prop} = ${value}`, () => { @@ -174,17 +174,6 @@ describe("MessageComposer", () => { }); }); - [false, undefined].forEach((value) => { - it(`should pass showVoiceBroadcastButton = false if the MessageComposer prop is ${value}`, () => { - SettingsStore.setValue(Features.VoiceBroadcast, null, SettingLevel.DEVICE, true); - const wrapper = wrapAndRender({ - room, - showVoiceBroadcastButton: value, - }); - expect(wrapper.find(MessageComposerButtons).props().showVoiceBroadcastButton).toBe(false); - }); - }); - it("should not render the send button", () => { const wrapper = wrapAndRender({ room }); expect(wrapper.find("SendButton")).toHaveLength(0); diff --git a/test/components/views/rooms/MessageComposerButtons-test.tsx b/test/components/views/rooms/MessageComposerButtons-test.tsx index 472b0e73683..f41901dd7a9 100644 --- a/test/components/views/rooms/MessageComposerButtons-test.tsx +++ b/test/components/views/rooms/MessageComposerButtons-test.tsx @@ -250,7 +250,6 @@ function createRoomState(room: Room, narrow: boolean): IRoomState { statusBarVisible: false, canReact: false, canSendMessages: false, - canSendVoiceBroadcasts: false, layout: Layout.Group, lowBandwidth: false, alwaysShowTimestamps: false, diff --git a/test/components/views/rooms/SendMessageComposer-test.tsx b/test/components/views/rooms/SendMessageComposer-test.tsx index 96b1be95ec5..1d01c4a5a5c 100644 --- a/test/components/views/rooms/SendMessageComposer-test.tsx +++ b/test/components/views/rooms/SendMessageComposer-test.tsx @@ -72,7 +72,6 @@ describe('', () => { statusBarVisible: false, canReact: false, canSendMessages: false, - canSendVoiceBroadcasts: false, layout: Layout.Group, lowBandwidth: false, alwaysShowTimestamps: false, diff --git a/test/components/views/rooms/wysiwyg_composer/WysiwygComposer-test.tsx b/test/components/views/rooms/wysiwyg_composer/WysiwygComposer-test.tsx index df2596809cc..12161ae8161 100644 --- a/test/components/views/rooms/wysiwyg_composer/WysiwygComposer-test.tsx +++ b/test/components/views/rooms/wysiwyg_composer/WysiwygComposer-test.tsx @@ -91,7 +91,6 @@ describe('WysiwygComposer', () => { statusBarVisible: false, canReact: false, canSendMessages: false, - canSendVoiceBroadcasts: false, layout: Layout.Group, lowBandwidth: false, alwaysShowTimestamps: false, diff --git a/test/components/views/rooms/wysiwyg_composer/message-test.ts b/test/components/views/rooms/wysiwyg_composer/message-test.ts index 712b671c9f7..79197a3188c 100644 --- a/test/components/views/rooms/wysiwyg_composer/message-test.ts +++ b/test/components/views/rooms/wysiwyg_composer/message-test.ts @@ -123,7 +123,6 @@ describe('message', () => { statusBarVisible: false, canReact: false, canSendMessages: false, - canSendVoiceBroadcasts: false, layout: Layout.Group, lowBandwidth: false, alwaysShowTimestamps: false, diff --git a/test/voice-broadcast/utils/__snapshots__/startNewVoiceBroadcastRecording-test.ts.snap b/test/voice-broadcast/utils/__snapshots__/startNewVoiceBroadcastRecording-test.ts.snap new file mode 100644 index 00000000000..c38673e3b66 --- /dev/null +++ b/test/voice-broadcast/utils/__snapshots__/startNewVoiceBroadcastRecording-test.ts.snap @@ -0,0 +1,70 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`startNewVoiceBroadcastRecording when the current user is allowed to send voice broadcast info state events when there already is a live broadcast of another user should show an info dialog 1`] = ` +[MockFunction] { + "calls": Array [ + Array [ + [Function], + Object { + "description":

+ Someone else is already recording a voice broadcast. Wait for their voice broadcast to end to start a new one. +

, + "hasCloseButton": true, + "title": "Can't start a new voice broadcast", + }, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, + }, + ], +} +`; + +exports[`startNewVoiceBroadcastRecording when the current user is allowed to send voice broadcast info state events when there already is a live broadcast of the current user should show an info dialog 1`] = ` +[MockFunction] { + "calls": Array [ + Array [ + [Function], + Object { + "description":

+ You are already recording a voice broadcast. Please end your current voice broadcast to start a new one. +

, + "hasCloseButton": true, + "title": "Can't start a new voice broadcast", + }, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, + }, + ], +} +`; + +exports[`startNewVoiceBroadcastRecording when the current user is not allowed to send voice broadcast info state events should show an info dialog 1`] = ` +[MockFunction] { + "calls": Array [ + Array [ + [Function], + Object { + "description":

+ You don't have the required permissions to start a voice broadcast in this room. Contact a room administrator to upgrade your permissions. +

, + "hasCloseButton": true, + "title": "Can't start a new voice broadcast", + }, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, + }, + ], +} +`; diff --git a/test/voice-broadcast/utils/hasRoomLiveVoiceBroadcast-test.ts b/test/voice-broadcast/utils/hasRoomLiveVoiceBroadcast-test.ts new file mode 100644 index 00000000000..c9fbc5f09e8 --- /dev/null +++ b/test/voice-broadcast/utils/hasRoomLiveVoiceBroadcast-test.ts @@ -0,0 +1,144 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { MatrixClient, Room } from "matrix-js-sdk/src/matrix"; + +import { + hasRoomLiveVoiceBroadcast, + VoiceBroadcastInfoEventType, + VoiceBroadcastInfoState, +} from "../../../src/voice-broadcast"; +import { mkEvent, stubClient } from "../../test-utils"; +import { mkVoiceBroadcastInfoStateEvent } from "./test-utils"; + +describe("hasRoomLiveVoiceBroadcast", () => { + const otherUserId = "@other:example.com"; + const roomId = "!room:example.com"; + let client: MatrixClient; + let room: Room; + + const addVoiceBroadcastInfoEvent = ( + state: VoiceBroadcastInfoState, + sender: string, + ) => { + room.currentState.setStateEvents([ + mkVoiceBroadcastInfoStateEvent(room.roomId, state, sender), + ]); + }; + + const itShouldReturnTrueTrue = () => { + it("should return true/true", () => { + expect(hasRoomLiveVoiceBroadcast(room, client.getUserId())).toEqual({ + hasBroadcast: true, + startedByUser: true, + }); + }); + }; + + const itShouldReturnTrueFalse = () => { + it("should return true/false", () => { + expect(hasRoomLiveVoiceBroadcast(room, client.getUserId())).toEqual({ + hasBroadcast: true, + startedByUser: false, + }); + }); + }; + + const itShouldReturnFalseFalse = () => { + it("should return false/false", () => { + expect(hasRoomLiveVoiceBroadcast(room, client.getUserId())).toEqual({ + hasBroadcast: false, + startedByUser: false, + }); + }); + }; + + beforeAll(() => { + client = stubClient(); + }); + + beforeEach(() => { + room = new Room(roomId, client, client.getUserId()); + }); + + describe("when there is no voice broadcast info at all", () => { + itShouldReturnFalseFalse(); + }); + + describe("when the »state« prop is missing", () => { + beforeEach(() => { + room.currentState.setStateEvents([ + mkEvent({ + event: true, + room: room.roomId, + user: client.getUserId(), + type: VoiceBroadcastInfoEventType, + skey: client.getUserId(), + content: {}, + }), + ]); + }); + itShouldReturnFalseFalse(); + }); + + describe("when there is a live broadcast from the current and another user", () => { + beforeEach(() => { + addVoiceBroadcastInfoEvent(VoiceBroadcastInfoState.Started, client.getUserId()); + addVoiceBroadcastInfoEvent(VoiceBroadcastInfoState.Started, otherUserId); + }); + + itShouldReturnTrueTrue(); + }); + + describe("when there are only stopped info events", () => { + beforeEach(() => { + addVoiceBroadcastInfoEvent(VoiceBroadcastInfoState.Stopped, client.getUserId()); + addVoiceBroadcastInfoEvent(VoiceBroadcastInfoState.Stopped, otherUserId); + }); + + itShouldReturnFalseFalse(); + }); + + describe.each([ + // all there are kind of live states + VoiceBroadcastInfoState.Started, + VoiceBroadcastInfoState.Paused, + VoiceBroadcastInfoState.Running, + ])("when there is a live broadcast (%s) from the current user", (state: VoiceBroadcastInfoState) => { + beforeEach(() => { + addVoiceBroadcastInfoEvent(state, client.getUserId()); + }); + + itShouldReturnTrueTrue(); + }); + + describe("when there was a live broadcast, that has been stopped", () => { + beforeEach(() => { + addVoiceBroadcastInfoEvent(VoiceBroadcastInfoState.Running, client.getUserId()); + addVoiceBroadcastInfoEvent(VoiceBroadcastInfoState.Stopped, client.getUserId()); + }); + + itShouldReturnFalseFalse(); + }); + + describe("when there is a live broadcast from another user", () => { + beforeEach(() => { + addVoiceBroadcastInfoEvent(VoiceBroadcastInfoState.Running, otherUserId); + }); + + itShouldReturnTrueFalse(); + }); +}); diff --git a/test/voice-broadcast/utils/startNewVoiceBroadcastRecording-test.ts b/test/voice-broadcast/utils/startNewVoiceBroadcastRecording-test.ts index 570719539aa..a320bca2ebd 100644 --- a/test/voice-broadcast/utils/startNewVoiceBroadcastRecording-test.ts +++ b/test/voice-broadcast/utils/startNewVoiceBroadcastRecording-test.ts @@ -15,8 +15,9 @@ limitations under the License. */ import { mocked } from "jest-mock"; -import { EventType, MatrixClient, MatrixEvent, Room, RoomStateEvent } from "matrix-js-sdk/src/matrix"; +import { EventType, MatrixClient, MatrixEvent, Room } from "matrix-js-sdk/src/matrix"; +import Modal from "../../../src/Modal"; import { startNewVoiceBroadcastRecording, VoiceBroadcastInfoEventType, @@ -25,46 +26,29 @@ import { VoiceBroadcastRecording, } from "../../../src/voice-broadcast"; import { mkEvent, stubClient } from "../../test-utils"; +import { mkVoiceBroadcastInfoStateEvent } from "./test-utils"; jest.mock("../../../src/voice-broadcast/models/VoiceBroadcastRecording", () => ({ VoiceBroadcastRecording: jest.fn(), })); +jest.mock("../../../src/Modal"); + describe("startNewVoiceBroadcastRecording", () => { const roomId = "!room:example.com"; + const otherUserId = "@other:example.com"; let client: MatrixClient; let recordingsStore: VoiceBroadcastRecordingsStore; let room: Room; - let roomOnStateEventsCallbackRegistered: Promise; - let roomOnStateEventsCallbackRegisteredResolver: Function; - let roomOnStateEventsCallback: () => void; let infoEvent: MatrixEvent; let otherEvent: MatrixEvent; - let stateEvent: MatrixEvent; + let result: VoiceBroadcastRecording | null; beforeEach(() => { - roomOnStateEventsCallbackRegistered = new Promise((resolve) => { - roomOnStateEventsCallbackRegisteredResolver = resolve; - }); - - room = { - currentState: { - getStateEvents: jest.fn().mockImplementation((type, userId) => { - if (type === VoiceBroadcastInfoEventType && userId === client.getUserId()) { - return stateEvent; - } - }), - }, - on: jest.fn().mockImplementation((eventType, callback) => { - if (eventType === RoomStateEvent.Events) { - roomOnStateEventsCallback = callback; - roomOnStateEventsCallbackRegisteredResolver(); - } - }), - off: jest.fn(), - } as unknown as Room; - client = stubClient(); + room = new Room(roomId, client, client.getUserId()); + jest.spyOn(room.currentState, "maySendStateEvent"); + mocked(client.getRoom).mockImplementation((getRoomId: string) => { if (getRoomId === roomId) { return room; @@ -85,22 +69,14 @@ describe("startNewVoiceBroadcastRecording", () => { setCurrent: jest.fn(), } as unknown as VoiceBroadcastRecordingsStore; - infoEvent = mkEvent({ - event: true, - type: VoiceBroadcastInfoEventType, - content: { - device_id: client.getDeviceId(), - state: VoiceBroadcastInfoState.Started, - }, - user: client.getUserId(), - room: roomId, - }); + infoEvent = mkVoiceBroadcastInfoStateEvent(roomId, VoiceBroadcastInfoState.Started, client.getUserId()); otherEvent = mkEvent({ event: true, type: EventType.RoomMember, content: {}, user: client.getUserId(), room: roomId, + skey: "", }); mocked(VoiceBroadcastRecording).mockImplementation(( @@ -115,29 +91,96 @@ describe("startNewVoiceBroadcastRecording", () => { }); }); - it("should create a new Voice Broadcast", (done) => { - let ok = false; + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("when the current user is allowed to send voice broadcast info state events", () => { + beforeEach(() => { + mocked(room.currentState.maySendStateEvent).mockReturnValue(true); + }); - startNewVoiceBroadcastRecording(roomId, client, recordingsStore).then((recording) => { - expect(ok).toBe(true); - expect(mocked(room.off)).toHaveBeenCalledWith(RoomStateEvent.Events, roomOnStateEventsCallback); - expect(recording.infoEvent).toBe(infoEvent); - expect(recording.start).toHaveBeenCalled(); - done(); + describe("when there currently is no other broadcast", () => { + it("should create a new Voice Broadcast", async () => { + mocked(client.sendStateEvent).mockImplementation(async ( + _roomId: string, + _eventType: string, + _content: any, + _stateKey = "", + ) => { + setTimeout(() => { + // emit state events after resolving the promise + room.currentState.setStateEvents([otherEvent]); + room.currentState.setStateEvents([infoEvent]); + }, 0); + return { event_id: infoEvent.getId() }; + }); + const recording = await startNewVoiceBroadcastRecording(room, client, recordingsStore); + + expect(client.sendStateEvent).toHaveBeenCalledWith( + roomId, + VoiceBroadcastInfoEventType, + { + chunk_length: 300, + device_id: client.getDeviceId(), + state: VoiceBroadcastInfoState.Started, + }, + client.getUserId(), + ); + expect(recording.infoEvent).toBe(infoEvent); + expect(recording.start).toHaveBeenCalled(); + }); }); - roomOnStateEventsCallbackRegistered.then(() => { - // no state event, yet - roomOnStateEventsCallback(); + describe("when there already is a live broadcast of the current user", () => { + beforeEach(async () => { + room.currentState.setStateEvents([ + mkVoiceBroadcastInfoStateEvent(roomId, VoiceBroadcastInfoState.Running, client.getUserId()), + ]); + + result = await startNewVoiceBroadcastRecording(room, client, recordingsStore); + }); - // other state event - stateEvent = otherEvent; - roomOnStateEventsCallback(); + it("should not start a voice broadcast", () => { + expect(result).toBeNull(); + }); + + it("should show an info dialog", () => { + expect(Modal.createDialog).toMatchSnapshot(); + }); + }); + + describe("when there already is a live broadcast of another user", () => { + beforeEach(async () => { + room.currentState.setStateEvents([ + mkVoiceBroadcastInfoStateEvent(roomId, VoiceBroadcastInfoState.Running, otherUserId), + ]); + + result = await startNewVoiceBroadcastRecording(room, client, recordingsStore); + }); + + it("should not start a voice broadcast", () => { + expect(result).toBeNull(); + }); + + it("should show an info dialog", () => { + expect(Modal.createDialog).toMatchSnapshot(); + }); + }); + }); + + describe("when the current user is not allowed to send voice broadcast info state events", () => { + beforeEach(async () => { + mocked(room.currentState.maySendStateEvent).mockReturnValue(false); + result = await startNewVoiceBroadcastRecording(room, client, recordingsStore); + }); + + it("should not start a voice broadcast", () => { + expect(result).toBeNull(); + }); - // the expected Voice Broadcast Info event - stateEvent = infoEvent; - ok = true; - roomOnStateEventsCallback(); + it("should show an info dialog", () => { + expect(Modal.createDialog).toMatchSnapshot(); }); }); }); diff --git a/test/voice-broadcast/utils/test-utils.ts b/test/voice-broadcast/utils/test-utils.ts new file mode 100644 index 00000000000..2a73877474f --- /dev/null +++ b/test/voice-broadcast/utils/test-utils.ts @@ -0,0 +1,37 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { MatrixEvent } from "matrix-js-sdk/src/matrix"; + +import { VoiceBroadcastInfoEventType, VoiceBroadcastInfoState } from "../../../src/voice-broadcast"; +import { mkEvent } from "../../test-utils"; + +export const mkVoiceBroadcastInfoStateEvent = ( + roomId: string, + state: VoiceBroadcastInfoState, + sender: string, +): MatrixEvent => { + return mkEvent({ + event: true, + room: roomId, + user: sender, + type: VoiceBroadcastInfoEventType, + skey: sender, + content: { + state, + }, + }); +}; From 13fbd096b0ef42f412bdb20c429c5c0e1c382423 Mon Sep 17 00:00:00 2001 From: kegsay Date: Wed, 19 Oct 2022 14:14:14 +0100 Subject: [PATCH 012/113] Stores refactor: convert TypingStore; rename TestStores to TestSdkContext (#9454) --- src/Lifecycle.ts | 6 +++--- src/components/views/rooms/BasicMessageComposer.tsx | 4 ++-- src/contexts/SDKContext.ts | 9 +++++++++ src/stores/TypingStore.ts | 13 +++---------- test/{TestStores.ts => TestSdkContext.ts} | 4 ++-- test/stores/RoomViewStore-test.ts | 4 ++-- test/stores/TypingStore-test.ts | 8 +++++--- 7 files changed, 26 insertions(+), 22 deletions(-) rename test/{TestStores.ts => TestSdkContext.ts} (91%) diff --git a/src/Lifecycle.ts b/src/Lifecycle.ts index 6c9c9558183..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"; @@ -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/components/views/rooms/BasicMessageComposer.tsx b/src/components/views/rooms/BasicMessageComposer.tsx index d74c7b51484..4c2201a628e 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 } from "../../../emoji"; @@ -47,6 +46,7 @@ import { getKeyBindingsManager } from '../../../KeyBindingsManager'; import { ALTERNATE_KEY_NAME, KeyBindingAction } from '../../../accessibility/KeyboardShortcuts'; import { _t } from "../../../languageHandler"; import { linkify } from '../../../linkify-matrix'; +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|:^$'); @@ -246,7 +246,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, diff --git a/src/contexts/SDKContext.ts b/src/contexts/SDKContext.ts index 61905dca926..8e6222512be 100644 --- a/src/contexts/SDKContext.ts +++ b/src/contexts/SDKContext.ts @@ -25,6 +25,7 @@ import { RoomNotificationStateStore } from "../stores/notifications/RoomNotifica import RightPanelStore from "../stores/right-panel/RightPanelStore"; import { RoomViewStore } from "../stores/RoomViewStore"; import SpaceStore, { SpaceStoreClass } from "../stores/spaces/SpaceStore"; +import TypingStore from "../stores/TypingStore"; import { WidgetLayoutStore } from "../stores/widgets/WidgetLayoutStore"; import WidgetStore from "../stores/WidgetStore"; @@ -59,6 +60,7 @@ export class SdkContextClass { protected _SlidingSyncManager?: SlidingSyncManager; protected _SpaceStore?: SpaceStoreClass; protected _LegacyCallHandler?: LegacyCallHandler; + protected _TypingStore?: TypingStore; /** * Automatically construct stores which need to be created eagerly so they can register with @@ -124,4 +126,11 @@ export class SdkContextClass { } return this._SpaceStore; } + public get typingStore(): TypingStore { + if (!this._TypingStore) { + this._TypingStore = new TypingStore(this); + window.mxTypingStore = this._TypingStore; + } + return this._TypingStore; + } } diff --git a/src/stores/TypingStore.ts b/src/stores/TypingStore.ts index d642f3fea7f..be17da6e4e6 100644 --- a/src/stores/TypingStore.ts +++ b/src/stores/TypingStore.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { MatrixClientPeg } from "../MatrixClientPeg"; +import { SdkContextClass } from "../contexts/SDKContext"; import SettingsStore from "../settings/SettingsStore"; import { isLocalRoom } from "../utils/localRoom/isLocalRoom"; import Timer from "../utils/Timer"; @@ -34,17 +34,10 @@ export default class TypingStore { }; }; - constructor() { + constructor(private readonly context: SdkContextClass) { this.reset(); } - public static sharedInstance(): TypingStore { - if (window.mxTypingStore === undefined) { - window.mxTypingStore = new TypingStore(); - } - return window.mxTypingStore; - } - /** * Clears all cached typing states. Intended to be called when the * MatrixClientPeg client changes. @@ -108,6 +101,6 @@ export default class TypingStore { } else currentTyping.userTimer.restart(); } - MatrixClientPeg.get().sendTyping(roomId, isTyping, TYPING_SERVER_TIMEOUT); + this.context.client?.sendTyping(roomId, isTyping, TYPING_SERVER_TIMEOUT); } } diff --git a/test/TestStores.ts b/test/TestSdkContext.ts similarity index 91% rename from test/TestStores.ts rename to test/TestSdkContext.ts index dbaa51f504a..137b71f9a37 100644 --- a/test/TestStores.ts +++ b/test/TestSdkContext.ts @@ -25,10 +25,10 @@ import { WidgetLayoutStore } from "../src/stores/widgets/WidgetLayoutStore"; import WidgetStore from "../src/stores/WidgetStore"; /** - * A class which provides the same API as Stores but adds additional unsafe setters which can + * A class which provides the same API as SdkContextClass but adds additional unsafe setters which can * replace individual stores. This is useful for tests which need to mock out stores. */ -export class TestStores extends SdkContextClass { +export class TestSdkContext extends SdkContextClass { public _RightPanelStore?: RightPanelStore; public _RoomNotificationStateStore?: RoomNotificationStateStore; public _RoomViewStore?: RoomViewStore; diff --git a/test/stores/RoomViewStore-test.ts b/test/stores/RoomViewStore-test.ts index f6f6bf2cc71..5f1bb98d3da 100644 --- a/test/stores/RoomViewStore-test.ts +++ b/test/stores/RoomViewStore-test.ts @@ -27,7 +27,7 @@ import { MatrixDispatcher } from '../../src/dispatcher/dispatcher'; import { UPDATE_EVENT } from '../../src/stores/AsyncStore'; import { ActiveRoomChangedPayload } from '../../src/dispatcher/payloads/ActiveRoomChangedPayload'; import { SpaceStoreClass } from '../../src/stores/spaces/SpaceStore'; -import { TestStores } from '../TestStores'; +import { TestSdkContext } from '../TestSdkContext'; // mock out the injected classes jest.mock('../../src/PosthogAnalytics'); @@ -77,7 +77,7 @@ describe('RoomViewStore', function() { // Make the RVS to test dis = new MatrixDispatcher(); slidingSyncManager = new MockSlidingSyncManager(); - const stores = new TestStores(); + const stores = new TestSdkContext(); stores._SlidingSyncManager = slidingSyncManager; stores._PosthogAnalytics = new MockPosthogAnalytics(); stores._SpaceStore = new MockSpaceStore(); diff --git a/test/stores/TypingStore-test.ts b/test/stores/TypingStore-test.ts index 98ddfca3c40..a5b4437f148 100644 --- a/test/stores/TypingStore-test.ts +++ b/test/stores/TypingStore-test.ts @@ -17,13 +17,14 @@ limitations under the License. import { mocked } from "jest-mock"; import { MatrixClient } from "matrix-js-sdk/src/matrix"; -import { MatrixClientPeg } from "../../src/MatrixClientPeg"; import TypingStore from "../../src/stores/TypingStore"; import { LOCAL_ROOM_ID_PREFIX } from "../../src/models/LocalRoom"; import SettingsStore from "../../src/settings/SettingsStore"; +import { TestSdkContext } from "../TestSdkContext"; jest.mock("../../src/settings/SettingsStore", () => ({ getValue: jest.fn(), + monitorSetting: jest.fn(), })); describe("TypingStore", () => { @@ -37,11 +38,12 @@ describe("TypingStore", () => { const localRoomId = LOCAL_ROOM_ID_PREFIX + "test"; beforeEach(() => { - typingStore = new TypingStore(); mockClient = { sendTyping: jest.fn(), } as unknown as MatrixClient; - MatrixClientPeg.get = () => mockClient; + const context = new TestSdkContext(); + context.client = mockClient; + typingStore = new TypingStore(context); mocked(SettingsStore.getValue).mockImplementation((setting: string) => { return settings[setting]; }); From 8066b9ffbe4b13c92bb2cb67ca559843a0052696 Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Wed, 19 Oct 2022 16:22:07 +0200 Subject: [PATCH 013/113] Prevent starting another voice broadcast (#9457) --- .../utils/startNewVoiceBroadcastRecording.tsx | 5 ++++ ...artNewVoiceBroadcastRecording-test.ts.snap | 25 ++++++++++++++++++- .../startNewVoiceBroadcastRecording-test.ts | 21 +++++++++++++++- 3 files changed, 49 insertions(+), 2 deletions(-) diff --git a/src/voice-broadcast/utils/startNewVoiceBroadcastRecording.tsx b/src/voice-broadcast/utils/startNewVoiceBroadcastRecording.tsx index cff195c6682..1084d6d2f39 100644 --- a/src/voice-broadcast/utils/startNewVoiceBroadcastRecording.tsx +++ b/src/voice-broadcast/utils/startNewVoiceBroadcastRecording.tsx @@ -113,6 +113,11 @@ export const startNewVoiceBroadcastRecording = async ( client: MatrixClient, recordingsStore: VoiceBroadcastRecordingsStore, ): Promise => { + if (recordingsStore.getCurrent()) { + showAlreadyRecordingDialog(); + return null; + } + const currentUserId = client.getUserId(); if (!room.currentState.maySendStateEvent(VoiceBroadcastInfoEventType, currentUserId)) { diff --git a/test/voice-broadcast/utils/__snapshots__/startNewVoiceBroadcastRecording-test.ts.snap b/test/voice-broadcast/utils/__snapshots__/startNewVoiceBroadcastRecording-test.ts.snap index c38673e3b66..7ea5b24355e 100644 --- a/test/voice-broadcast/utils/__snapshots__/startNewVoiceBroadcastRecording-test.ts.snap +++ b/test/voice-broadcast/utils/__snapshots__/startNewVoiceBroadcastRecording-test.ts.snap @@ -23,7 +23,30 @@ exports[`startNewVoiceBroadcastRecording when the current user is allowed to sen } `; -exports[`startNewVoiceBroadcastRecording when the current user is allowed to send voice broadcast info state events when there already is a live broadcast of the current user should show an info dialog 1`] = ` +exports[`startNewVoiceBroadcastRecording when the current user is allowed to send voice broadcast info state events when there already is a live broadcast of the current user in the room should show an info dialog 1`] = ` +[MockFunction] { + "calls": Array [ + Array [ + [Function], + Object { + "description":

+ You are already recording a voice broadcast. Please end your current voice broadcast to start a new one. +

, + "hasCloseButton": true, + "title": "Can't start a new voice broadcast", + }, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, + }, + ], +} +`; + +exports[`startNewVoiceBroadcastRecording when the current user is allowed to send voice broadcast info state events when there is already a current voice broadcast should show an info dialog 1`] = ` [MockFunction] { "calls": Array [ Array [ diff --git a/test/voice-broadcast/utils/startNewVoiceBroadcastRecording-test.ts b/test/voice-broadcast/utils/startNewVoiceBroadcastRecording-test.ts index a320bca2ebd..0fc0d14cb20 100644 --- a/test/voice-broadcast/utils/startNewVoiceBroadcastRecording-test.ts +++ b/test/voice-broadcast/utils/startNewVoiceBroadcastRecording-test.ts @@ -67,6 +67,7 @@ describe("startNewVoiceBroadcastRecording", () => { recordingsStore = { setCurrent: jest.fn(), + getCurrent: jest.fn(), } as unknown as VoiceBroadcastRecordingsStore; infoEvent = mkVoiceBroadcastInfoStateEvent(roomId, VoiceBroadcastInfoState.Started, client.getUserId()); @@ -132,7 +133,25 @@ describe("startNewVoiceBroadcastRecording", () => { }); }); - describe("when there already is a live broadcast of the current user", () => { + describe("when there is already a current voice broadcast", () => { + beforeEach(async () => { + mocked(recordingsStore.getCurrent).mockReturnValue( + new VoiceBroadcastRecording(infoEvent, client), + ); + + result = await startNewVoiceBroadcastRecording(room, client, recordingsStore); + }); + + it("should not start a voice broadcast", () => { + expect(result).toBeNull(); + }); + + it("should show an info dialog", () => { + expect(Modal.createDialog).toMatchSnapshot(); + }); + }); + + describe("when there already is a live broadcast of the current user in the room", () => { beforeEach(async () => { room.currentState.setStateEvents([ mkVoiceBroadcastInfoStateEvent(roomId, VoiceBroadcastInfoState.Running, client.getUserId()), From d5a4718d461b7c60b571914217d8a9425da3af1e Mon Sep 17 00:00:00 2001 From: Kerry Date: Wed, 19 Oct 2022 17:11:42 +0200 Subject: [PATCH 014/113] Test display of qr code login section (#9456) * Support for login + E2EE set up with QR * Whitespace * Padding * Refactor of fetch * Whitespace * CSS whitespace * Add link to MSC3906 * Handle incorrect typing in MatrixClientPeg.get() * Use unstable class name * fix: use unstable class name * Use default fetch client instead * Update to revised function name * Refactor device manager panel and make it work with new sessions manager * Lint fix * Add missing interstitials and update wording * Linting * i18n * Lint * Use sensible sdk config name for fallback server * Improve error handling for QR code generation * Refactor feature availability logic * Hide device manager panel if no options available * Put sign in with QR behind lab setting * Reduce scope of PR to just showing code on existing device * i18n updates * Handle null features * Testing for LoginWithQRSection * Refactor to handle UIA * Imports * Reduce diff complexity * Remove unnecessary change * Remove unused styles * Support UIA * Tidy up * i18n * Remove additional unused parts of flow * Add extra instruction when showing QR code * Add getVersions to server mocks * Use proper colours for theme support * Test cases * Lint * Remove obsolete snapshot * Don't override error if already set * Remove unused var * Update src/components/views/settings/devices/LoginWithQRSection.tsx Co-authored-by: Travis Ralston * Update src/components/views/auth/LoginWithQR.tsx Co-authored-by: Travis Ralston * Update src/components/views/auth/LoginWithQR.tsx Co-authored-by: Travis Ralston * Update src/components/views/auth/LoginWithQR.tsx Co-authored-by: Travis Ralston * Update src/components/views/auth/LoginWithQR.tsx Co-authored-by: Travis Ralston * Update src/components/views/auth/LoginWithQR.tsx Co-authored-by: Travis Ralston * Update res/css/views/auth/_LoginWithQR.pcss Co-authored-by: Kerry * Use spacing variables * Remove debug * Style + docs * preventDefault * Names of tests * Fixes for js-sdk refactor * Update snapshots to match test names * Refactor labs config to make deployment simpler * i18n * Unused imports * Typo * Stateless component * Whitespace * Use context not MatrixClientPeg * Add missing context * Type updates to match js-sdk * Wrap click handlers in useCallback * Update src/components/views/settings/DevicesPanel.tsx Co-authored-by: Travis Ralston * Wait for DOM update instead of timeout * Add missing snapshot update from last commit * Remove void keyword in favour of then() clauses * test main paths in LoginWithQR * test coverage for display of qr code section * remove unused test props Co-authored-by: Hugh Nimmo-Smith Co-authored-by: Hugh Nimmo-Smith Co-authored-by: Travis Ralston --- src/components/views/auth/LoginWithQR.tsx | 2 +- .../__snapshots__/LoginWithQR-test.tsx.snap | 7 +++ .../user/SecurityUserSettingsTab-test.tsx | 39 ++++++++++++++- .../tabs/user/SessionManagerTab-test.tsx | 48 +++++++++++++++++++ 4 files changed, 94 insertions(+), 2 deletions(-) diff --git a/src/components/views/auth/LoginWithQR.tsx b/src/components/views/auth/LoginWithQR.tsx index f95e618cc52..3d3f76be957 100644 --- a/src/components/views/auth/LoginWithQR.tsx +++ b/src/components/views/auth/LoginWithQR.tsx @@ -370,7 +370,7 @@ export default class LoginWithQR extends React.Component { } return ( -
+
{ backButton ? approves login and waits for new device 1`] = `
displays confirmation digits after connected to rendezv
displays error when approving login fails 1`] = `
displays qr code after it is created 1`] = `
displays unknown error if connection to rendezvous fail
no content in case of no support 1`] = `
renders spinner while generating code 1`] = `
', () => { @@ -42,6 +43,12 @@ describe('', () => { ...mockClientMethodsCrypto(), getRooms: jest.fn().mockReturnValue([]), getIgnoredUsers: jest.fn(), + getVersions: jest.fn().mockResolvedValue({ + unstable_features: { + 'org.matrix.msc3882': true, + 'org.matrix.msc3886': true, + }, + }), }); const getComponent = () => @@ -70,4 +77,34 @@ describe('', () => { expect(queryByTestId('devices-section')).toBeFalsy(); }); + + it('does not render qr code login section when disabled', () => { + settingsValueSpy.mockReturnValue(false); + const { queryByText } = render(getComponent()); + + expect(settingsValueSpy).toHaveBeenCalledWith('feature_qr_signin_reciprocate_show'); + + expect(queryByText('Sign in with QR code')).toBeFalsy(); + }); + + it('renders qr code login section when enabled', async () => { + settingsValueSpy.mockImplementation(settingName => settingName === 'feature_qr_signin_reciprocate_show'); + const { getByText } = render(getComponent()); + + // wait for versions call to settle + await flushPromises(); + + expect(getByText('Sign in with QR code')).toBeTruthy(); + }); + + it('enters qr code login section when show QR code button clicked', async () => { + settingsValueSpy.mockImplementation(settingName => settingName === 'feature_qr_signin_reciprocate_show'); + const { getByText, getByTestId } = render(getComponent()); + // wait for versions call to settle + await flushPromises(); + + fireEvent.click(getByText('Show QR code')); + + expect(getByTestId("login-with-qr")).toBeTruthy(); + }); }); diff --git a/test/components/views/settings/tabs/user/SessionManagerTab-test.tsx b/test/components/views/settings/tabs/user/SessionManagerTab-test.tsx index 7826b3cc809..e9dd3529036 100644 --- a/test/components/views/settings/tabs/user/SessionManagerTab-test.tsx +++ b/test/components/views/settings/tabs/user/SessionManagerTab-test.tsx @@ -34,6 +34,7 @@ import { import SessionManagerTab from '../../../../../../src/components/views/settings/tabs/user/SessionManagerTab'; import MatrixClientContext from '../../../../../../src/contexts/MatrixClientContext'; import { + flushPromises, flushPromisesWithFakeTimers, getMockClientWithEventEmitter, mkPusher, @@ -47,6 +48,7 @@ import { ExtendedDevice, } from '../../../../../../src/components/views/settings/devices/types'; import { INACTIVE_DEVICE_AGE_MS } from '../../../../../../src/components/views/settings/devices/filter'; +import SettingsStore from '../../../../../../src/settings/SettingsStore'; mockPlatformPeg(); @@ -1142,4 +1144,50 @@ describe('', () => { expect(checkbox.getAttribute('aria-checked')).toEqual("false"); }); + + describe('QR code login', () => { + const settingsValueSpy = jest.spyOn(SettingsStore, 'getValue'); + + beforeEach(() => { + settingsValueSpy.mockClear().mockReturnValue(false); + // enable server support for qr login + mockClient.getVersions.mockResolvedValue({ + versions: [], + unstable_features: { + 'org.matrix.msc3882': true, + 'org.matrix.msc3886': true, + }, + }); + }); + + it('does not render qr code login section when disabled', () => { + settingsValueSpy.mockReturnValue(false); + const { queryByText } = render(getComponent()); + + expect(settingsValueSpy).toHaveBeenCalledWith('feature_qr_signin_reciprocate_show'); + + expect(queryByText('Sign in with QR code')).toBeFalsy(); + }); + + it('renders qr code login section when enabled', async () => { + settingsValueSpy.mockImplementation(settingName => settingName === 'feature_qr_signin_reciprocate_show'); + const { getByText } = render(getComponent()); + + // wait for versions call to settle + await flushPromises(); + + expect(getByText('Sign in with QR code')).toBeTruthy(); + }); + + it('enters qr code login section when show QR code button clicked', async () => { + settingsValueSpy.mockImplementation(settingName => settingName === 'feature_qr_signin_reciprocate_show'); + const { getByText, getByTestId } = render(getComponent()); + // wait for versions call to settle + await flushPromises(); + + fireEvent.click(getByText('Show QR code')); + + expect(getByTestId("login-with-qr")).toBeTruthy(); + }); + }); }); From 07a1e9a00941a53b528b119680c823d99ae19e47 Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Wed, 19 Oct 2022 18:02:48 +0200 Subject: [PATCH 015/113] Stop voice broadcast recording on redaction (#9455) --- .../hooks/useVoiceBroadcastRecording.tsx | 2 - .../models/VoiceBroadcastRecording.ts | 12 +++- .../stores/VoiceBroadcastRecordingsStore.ts | 16 ++++- .../models/VoiceBroadcastRecording-test.ts | 14 +++++ .../VoiceBroadcastRecordingsStore-test.ts | 62 +++++++++++-------- 5 files changed, 76 insertions(+), 30 deletions(-) diff --git a/src/voice-broadcast/hooks/useVoiceBroadcastRecording.tsx b/src/voice-broadcast/hooks/useVoiceBroadcastRecording.tsx index c0db5617461..341283c2adc 100644 --- a/src/voice-broadcast/hooks/useVoiceBroadcastRecording.tsx +++ b/src/voice-broadcast/hooks/useVoiceBroadcastRecording.tsx @@ -20,7 +20,6 @@ import { VoiceBroadcastInfoState, VoiceBroadcastRecording, VoiceBroadcastRecordingEvent, - VoiceBroadcastRecordingsStore, } from ".."; import QuestionDialog from "../../components/views/dialogs/QuestionDialog"; import { useTypedEventEmitter } from "../../hooks/useEventEmitter"; @@ -54,7 +53,6 @@ export const useVoiceBroadcastRecording = (recording: VoiceBroadcastRecording) = if (confirmed) { recording.stop(); - VoiceBroadcastRecordingsStore.instance().clearCurrent(); } }; diff --git a/src/voice-broadcast/models/VoiceBroadcastRecording.ts b/src/voice-broadcast/models/VoiceBroadcastRecording.ts index 96b62a670f3..f7faa0876e0 100644 --- a/src/voice-broadcast/models/VoiceBroadcastRecording.ts +++ b/src/voice-broadcast/models/VoiceBroadcastRecording.ts @@ -15,7 +15,7 @@ limitations under the License. */ import { logger } from "matrix-js-sdk/src/logger"; -import { MatrixClient, MatrixEvent, RelationType } from "matrix-js-sdk/src/matrix"; +import { MatrixClient, MatrixEvent, MatrixEventEvent, RelationType } from "matrix-js-sdk/src/matrix"; import { TypedEventEmitter } from "matrix-js-sdk/src/models/typed-event-emitter"; import { @@ -67,6 +67,7 @@ export class VoiceBroadcastRecording }) ? VoiceBroadcastInfoState.Started : VoiceBroadcastInfoState.Stopped; // TODO Michael W: add listening for updates + this.infoEvent.on(MatrixEventEvent.BeforeRedaction, this.onBeforeRedaction); this.dispatcherRef = dis.register(this.onAction); } @@ -99,10 +100,19 @@ export class VoiceBroadcastRecording this.recorder.stop(); } + this.infoEvent.off(MatrixEventEvent.BeforeRedaction, this.onBeforeRedaction); this.removeAllListeners(); dis.unregister(this.dispatcherRef); } + private onBeforeRedaction = () => { + if (this.getState() !== VoiceBroadcastInfoState.Stopped) { + this.setState(VoiceBroadcastInfoState.Stopped); + // destroy cleans up everything + this.destroy(); + } + }; + private onAction = (payload: ActionPayload) => { if (payload.action !== "call_state") return; diff --git a/src/voice-broadcast/stores/VoiceBroadcastRecordingsStore.ts b/src/voice-broadcast/stores/VoiceBroadcastRecordingsStore.ts index cc12b474e8d..b5c78a1b0e5 100644 --- a/src/voice-broadcast/stores/VoiceBroadcastRecordingsStore.ts +++ b/src/voice-broadcast/stores/VoiceBroadcastRecordingsStore.ts @@ -17,7 +17,7 @@ limitations under the License. import { MatrixClient, MatrixEvent } from "matrix-js-sdk/src/matrix"; import { TypedEventEmitter } from "matrix-js-sdk/src/models/typed-event-emitter"; -import { VoiceBroadcastRecording } from ".."; +import { VoiceBroadcastInfoState, VoiceBroadcastRecording, VoiceBroadcastRecordingEvent } from ".."; export enum VoiceBroadcastRecordingsStoreEvent { CurrentChanged = "current_changed", @@ -41,7 +41,12 @@ export class VoiceBroadcastRecordingsStore extends TypedEventEmitter { + if (state === VoiceBroadcastInfoState.Stopped) { + this.clearCurrent(); + } + }; + private static readonly cachedInstance = new VoiceBroadcastRecordingsStore(); /** diff --git a/test/voice-broadcast/models/VoiceBroadcastRecording-test.ts b/test/voice-broadcast/models/VoiceBroadcastRecording-test.ts index 25a325aba79..049c03c5a45 100644 --- a/test/voice-broadcast/models/VoiceBroadcastRecording-test.ts +++ b/test/voice-broadcast/models/VoiceBroadcastRecording-test.ts @@ -20,6 +20,7 @@ import { EventType, MatrixClient, MatrixEvent, + MatrixEventEvent, MsgType, RelationType, Room, @@ -81,6 +82,7 @@ describe("VoiceBroadcastRecording", () => { const setUpVoiceBroadcastRecording = () => { voiceBroadcastRecording = new VoiceBroadcastRecording(infoEvent, client); voiceBroadcastRecording.on(VoiceBroadcastRecordingEvent.StateChanged, onStateChanged); + jest.spyOn(voiceBroadcastRecording, "destroy"); jest.spyOn(voiceBroadcastRecording, "removeAllListeners"); }; @@ -214,6 +216,18 @@ describe("VoiceBroadcastRecording", () => { expect(voiceBroadcastRecorder.start).toHaveBeenCalled(); }); + describe("and the info event is redacted", () => { + beforeEach(() => { + infoEvent.emit(MatrixEventEvent.BeforeRedaction, null, null); + }); + + itShouldBeInState(VoiceBroadcastInfoState.Stopped); + + it("should destroy the recording", () => { + expect(voiceBroadcastRecording.destroy).toHaveBeenCalled(); + }); + }); + describe("and receiving a call action", () => { beforeEach(() => { dis.dispatch({ diff --git a/test/voice-broadcast/stores/VoiceBroadcastRecordingsStore-test.ts b/test/voice-broadcast/stores/VoiceBroadcastRecordingsStore-test.ts index 56b90b73aec..3edb74592e0 100644 --- a/test/voice-broadcast/stores/VoiceBroadcastRecordingsStore-test.ts +++ b/test/voice-broadcast/stores/VoiceBroadcastRecordingsStore-test.ts @@ -18,28 +18,22 @@ import { mocked } from "jest-mock"; import { MatrixClient, MatrixEvent, Room } from "matrix-js-sdk/src/matrix"; import { - VoiceBroadcastInfoEventType, VoiceBroadcastRecordingsStore, VoiceBroadcastRecordingsStoreEvent, VoiceBroadcastRecording, + VoiceBroadcastInfoState, } from "../../../src/voice-broadcast"; -import { mkEvent, mkStubRoom, stubClient } from "../../test-utils"; - -jest.mock("../../../src/voice-broadcast/models/VoiceBroadcastRecording.ts", () => ({ - VoiceBroadcastRecording: jest.fn().mockImplementation( - ( - infoEvent: MatrixEvent, - client: MatrixClient, - ) => ({ infoEvent, client }), - ), -})); +import { mkStubRoom, stubClient } from "../../test-utils"; +import { mkVoiceBroadcastInfoStateEvent } from "../utils/test-utils"; describe("VoiceBroadcastRecordingsStore", () => { const roomId = "!room:example.com"; let client: MatrixClient; let room: Room; let infoEvent: MatrixEvent; + let otherInfoEvent: MatrixEvent; let recording: VoiceBroadcastRecording; + let otherRecording: VoiceBroadcastRecording; let recordings: VoiceBroadcastRecordingsStore; let onCurrentChanged: (recording: VoiceBroadcastRecording) => void; @@ -51,22 +45,17 @@ describe("VoiceBroadcastRecordingsStore", () => { return room; } }); - infoEvent = mkEvent({ - event: true, - type: VoiceBroadcastInfoEventType, - user: client.getUserId(), - room: roomId, - content: {}, - }); - recording = { - infoEvent, - } as unknown as VoiceBroadcastRecording; + infoEvent = mkVoiceBroadcastInfoStateEvent(roomId, VoiceBroadcastInfoState.Started, client.getUserId()); + otherInfoEvent = mkVoiceBroadcastInfoStateEvent(roomId, VoiceBroadcastInfoState.Started, client.getUserId()); + recording = new VoiceBroadcastRecording(infoEvent, client); + otherRecording = new VoiceBroadcastRecording(otherInfoEvent, client); recordings = new VoiceBroadcastRecordingsStore(); onCurrentChanged = jest.fn(); recordings.on(VoiceBroadcastRecordingsStoreEvent.CurrentChanged, onCurrentChanged); }); afterEach(() => { + recording.destroy(); recordings.off(VoiceBroadcastRecordingsStoreEvent.CurrentChanged, onCurrentChanged); }); @@ -110,6 +99,32 @@ describe("VoiceBroadcastRecordingsStore", () => { it("should emit a current changed event", () => { expect(onCurrentChanged).toHaveBeenCalledWith(null); }); + + it("and calling it again should work", () => { + recordings.clearCurrent(); + expect(recordings.getCurrent()).toBeNull(); + }); + }); + + describe("and setting another recording and stopping the previous recording", () => { + beforeEach(() => { + recordings.setCurrent(otherRecording); + recording.stop(); + }); + + it("should keep the current recording", () => { + expect(recordings.getCurrent()).toBe(otherRecording); + }); + }); + + describe("and the recording stops", () => { + beforeEach(() => { + recording.stop(); + }); + + it("should clear the current recording", () => { + expect(recordings.getCurrent()).toBeNull(); + }); }); }); @@ -133,10 +148,7 @@ describe("VoiceBroadcastRecordingsStore", () => { }); it("should return the recording", () => { - expect(returnedRecording).toEqual({ - infoEvent, - client, - }); + expect(returnedRecording.infoEvent).toBe(infoEvent); }); }); }); From 0a65d919a1a04bb457d59e4b24505a87c65ff001 Mon Sep 17 00:00:00 2001 From: Florian Duros Date: Wed, 19 Oct 2022 18:17:03 +0200 Subject: [PATCH 016/113] Fix typing --- .../views/rooms/wysiwyg_composer/SendWysiwygComposer.tsx | 3 +-- .../wysiwyg_composer/components/FormattingButtons.tsx | 7 +++---- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/components/views/rooms/wysiwyg_composer/SendWysiwygComposer.tsx b/src/components/views/rooms/wysiwyg_composer/SendWysiwygComposer.tsx index f36dbd2d9bd..24909bf2ee4 100644 --- a/src/components/views/rooms/wysiwyg_composer/SendWysiwygComposer.tsx +++ b/src/components/views/rooms/wysiwyg_composer/SendWysiwygComposer.tsx @@ -22,10 +22,9 @@ import { WysiwygComposer } from './components/WysiwygComposer'; interface SendWysiwygComposerProps { disabled?: boolean; - onChange?: (content: string) => void; + onChange: (content: string) => void; onSend(): () => void; } - interface ContentProps { disabled: boolean; formattingFunctions: FormattingFunctions; diff --git a/src/components/views/rooms/wysiwyg_composer/components/FormattingButtons.tsx b/src/components/views/rooms/wysiwyg_composer/components/FormattingButtons.tsx index c806c278615..00127e5e430 100644 --- a/src/components/views/rooms/wysiwyg_composer/components/FormattingButtons.tsx +++ b/src/components/views/rooms/wysiwyg_composer/components/FormattingButtons.tsx @@ -15,7 +15,7 @@ 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"; @@ -23,7 +23,6 @@ import { Alignment } from "../../../elements/Tooltip"; import { KeyboardShortcut } from "../../../settings/KeyboardShortcut"; import { KeyCombo } from "../../../../../KeyBindingsManager"; import { _td } from "../../../../../languageHandler"; -import { Wysiwyg } from "../types"; interface TooltipProps { label: string; @@ -56,8 +55,8 @@ function Button({ label, keyCombo, onClick, isActive, className }: ButtonProps) } interface FormattingButtonsProps { - composer: Wysiwyg; - formattingStates: ReturnType['formattingStates']; + composer: FormattingFunctions; + formattingStates: FormattingStates; } export function FormattingButtons({ composer, formattingStates }: FormattingButtonsProps) { From 63c3a55758ed45b3ad90e38f5afd0fd6f8914efe Mon Sep 17 00:00:00 2001 From: Florian Duros Date: Wed, 19 Oct 2022 18:57:49 +0200 Subject: [PATCH 017/113] Disable save button until change --- .../views/rooms/wysiwyg_composer/EditWysiwygComposer.tsx | 6 +++--- .../rooms/wysiwyg_composer/components/EditionButtons.tsx | 5 +++-- .../views/rooms/wysiwyg_composer/hooks/useEditing.ts | 9 +++++++-- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/src/components/views/rooms/wysiwyg_composer/EditWysiwygComposer.tsx b/src/components/views/rooms/wysiwyg_composer/EditWysiwygComposer.tsx index cef10032855..341d045ba4e 100644 --- a/src/components/views/rooms/wysiwyg_composer/EditWysiwygComposer.tsx +++ b/src/components/views/rooms/wysiwyg_composer/EditWysiwygComposer.tsx @@ -44,18 +44,18 @@ export function EditWysiwygComposer({ editorStateTransfer, ...props }: EditWysiw const initialContent = useInitialContent(editorStateTransfer); const isReady = !editorStateTransfer || Boolean(initialContent); - const { editMessage, endEditing, setContent } = useEditing(initialContent, editorStateTransfer); + const { editMessage, endEditing, onChange, isSaveDisabled } = useEditing(initialContent, editorStateTransfer); return isReady && { (ref, wysiwyg, content) => ( <> - + ) } ; diff --git a/src/components/views/rooms/wysiwyg_composer/components/EditionButtons.tsx b/src/components/views/rooms/wysiwyg_composer/components/EditionButtons.tsx index 20a3df2a7f7..9e94c12470a 100644 --- a/src/components/views/rooms/wysiwyg_composer/components/EditionButtons.tsx +++ b/src/components/views/rooms/wysiwyg_composer/components/EditionButtons.tsx @@ -22,14 +22,15 @@ import AccessibleButton from '../../../elements/AccessibleButton'; interface EditionButtonsProps { onCancelClick: MouseEventHandler; onSaveClick: MouseEventHandler; + isSaveDisabled?: boolean; } -export function EditionButtons({ onCancelClick, onSaveClick }: EditionButtonsProps) { +export function EditionButtons({ onCancelClick, onSaveClick, isSaveDisabled = false }: EditionButtonsProps) { return
{ _t("Cancel") } - + { _t("Save") }
; diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/useEditing.ts b/src/components/views/rooms/wysiwyg_composer/hooks/useEditing.ts index 8076c0062ac..fcd4471cb1e 100644 --- a/src/components/views/rooms/wysiwyg_composer/hooks/useEditing.ts +++ b/src/components/views/rooms/wysiwyg_composer/hooks/useEditing.ts @@ -26,13 +26,18 @@ export function useEditing(initialContent: string, editorStateTransfer: EditorSt const roomContext = useRoomContext(); const mxClient = useMatrixClientContext(); + const [isSaveDisabled, setIsSaveDisabled] = useState(true); const [content, setContent] = useState(initialContent); + const onChange = useCallback((_content: string) => { + setContent(_content); + setIsSaveDisabled(_isSaveDisabled => _isSaveDisabled && _content === initialContent); + }, [initialContent]); + const editMessageMemoized = useCallback(() => editMessage(content, { roomContext, mxClient, editorStateTransfer }), [content, roomContext, mxClient, editorStateTransfer], ); const endEditingMemoized = useCallback(() => endEditing(roomContext), [roomContext]); - - return { setContent, editMessage: editMessageMemoized, endEditing: endEditingMemoized }; + return { onChange, editMessage: editMessageMemoized, endEditing: endEditingMemoized, isSaveDisabled }; } From c7e83baa360d874fc2c61d411813e8e100eeb070 Mon Sep 17 00:00:00 2001 From: Florian Duros Date: Wed, 19 Oct 2022 19:29:42 +0200 Subject: [PATCH 018/113] Remove unused parameters --- .../views/rooms/wysiwyg_composer/EditWysiwygComposer.tsx | 3 +-- .../rooms/wysiwyg_composer/components/WysiwygComposer.tsx | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/components/views/rooms/wysiwyg_composer/EditWysiwygComposer.tsx b/src/components/views/rooms/wysiwyg_composer/EditWysiwygComposer.tsx index 341d045ba4e..d60d98ee9ff 100644 --- a/src/components/views/rooms/wysiwyg_composer/EditWysiwygComposer.tsx +++ b/src/components/views/rooms/wysiwyg_composer/EditWysiwygComposer.tsx @@ -51,8 +51,7 @@ export function EditWysiwygComposer({ editorStateTransfer, ...props }: EditWysiw onChange={onChange} onSend={editMessage} {...props}> - { (ref, wysiwyg, - content) => ( + { (ref) => ( <> diff --git a/src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer.tsx b/src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer.tsx index 308a3fd2028..4a58b3693f2 100644 --- a/src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer.tsx +++ b/src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer.tsx @@ -29,7 +29,7 @@ interface WysiwygComposerProps { children?: ( ref: MutableRefObject, wysiwyg: FormattingFunctions, - content: string) => ReactNode; + ) => ReactNode; } export const WysiwygComposer = memo(function WysiwygComposer( @@ -50,7 +50,7 @@ export const WysiwygComposer = memo(function WysiwygComposer(
- { children?.(ref, wysiwyg, content) } + { children?.(ref, wysiwyg) }
); }); From e9b285c5e058f4349b3856391089c51e4f0858a1 Mon Sep 17 00:00:00 2001 From: Florian Duros Date: Wed, 19 Oct 2022 19:44:49 +0200 Subject: [PATCH 019/113] Cleaning files --- src/components/views/rooms/BasicMessageComposer.tsx | 2 -- src/components/views/rooms/EditMessageComposer.tsx | 1 - src/components/views/rooms/MessageComposer.tsx | 3 +-- 3 files changed, 1 insertion(+), 5 deletions(-) diff --git a/src/components/views/rooms/BasicMessageComposer.tsx b/src/components/views/rooms/BasicMessageComposer.tsx index 962059091cc..d74c7b51484 100644 --- a/src/components/views/rooms/BasicMessageComposer.tsx +++ b/src/components/views/rooms/BasicMessageComposer.tsx @@ -833,8 +833,6 @@ export default class BasicMessageEditor extends React.Component } public insertPlaintext(text: string): void { - console.log('insertPlaintext', text); - debugger; this.modifiedFlag = true; const { model } = this.props; const { partCreator } = model; diff --git a/src/components/views/rooms/EditMessageComposer.tsx b/src/components/views/rooms/EditMessageComposer.tsx index bb014541278..52312e1a998 100644 --- a/src/components/views/rooms/EditMessageComposer.tsx +++ b/src/components/views/rooms/EditMessageComposer.tsx @@ -350,7 +350,6 @@ class EditMessageComposer extends React.Component { } this.messageComposerInput.current?.sendMessage(); - // this.composerSendMessage?.(); - const isWysiwygComposerEnabled = SettingsStore.getValue("feature_wysiwyg_composer"); + const isWysiwygComposerEnabled = SettingsStore.getValue("feature_wysiwyg_composer"); if (isWysiwygComposerEnabled) { const { permalinkCreator, relation, replyToEvent } = this.props; sendMessage(this.state.composerContent, From f9a7d9fb7b2ef57cb291f154fcc0764772606687 Mon Sep 17 00:00:00 2001 From: Robin Date: Wed, 19 Oct 2022 15:11:13 -0400 Subject: [PATCH 020/113] Align video call icon with banner text (#9460) --- res/css/views/rooms/_RoomCallBanner.pcss | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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"); } } From 7d0af1dca46b75aec23f7aa1a4d2c80746f5e56e Mon Sep 17 00:00:00 2001 From: Element Translate Bot Date: Wed, 19 Oct 2022 21:33:08 +0200 Subject: [PATCH 021/113] Translations update from Weblate (#9465) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Translated using Weblate (German) Currently translated at 99.4% (3608 of 3629 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/de/ * Translated using Weblate (German) Currently translated at 99.5% (3617 of 3633 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/de/ * Translated using Weblate (German) Currently translated at 99.7% (3624 of 3633 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/de/ * Translated using Weblate (German) Currently translated at 99.7% (3624 of 3633 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/de/ * Translated using Weblate (German) Currently translated at 100.0% (3633 of 3633 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/de/ * Translated using Weblate (German) Currently translated at 100.0% (3633 of 3633 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/de/ * Translated using Weblate (German) Currently translated at 100.0% (3633 of 3633 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/de/ * Translated using Weblate (Italian) Currently translated at 99.2% (3607 of 3633 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/it/ * Translated using Weblate (Estonian) Currently translated at 99.7% (3624 of 3633 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/et/ * Translated using Weblate (Hungarian) Currently translated at 99.2% (3606 of 3633 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/hu/ * Translated using Weblate (Ukrainian) Currently translated at 99.3% (3611 of 3633 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/uk/ * Translated using Weblate (Czech) Currently translated at 99.3% (3608 of 3633 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/cs/ * Translated using Weblate (German) Currently translated at 100.0% (3633 of 3633 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/de/ * Translated using Weblate (Hungarian) Currently translated at 99.8% (3629 of 3633 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/hu/ * Translated using Weblate (Ukrainian) Currently translated at 100.0% (3633 of 3633 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/uk/ * Translated using Weblate (Czech) Currently translated at 100.0% (3633 of 3633 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/cs/ * Translated using Weblate (Slovak) Currently translated at 100.0% (3633 of 3633 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/sk/ * Translated using Weblate (Estonian) Currently translated at 100.0% (3633 of 3633 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/et/ Co-authored-by: Johannes Marbach Co-authored-by: Weblate Co-authored-by: Vri Co-authored-by: random Co-authored-by: Priit Jõerüüt Co-authored-by: Szimszon Co-authored-by: Ihor Hordiichuk Co-authored-by: waclaw66 Co-authored-by: Jozef Gaal --- src/i18n/strings/cs.json | 35 ++++++++++++++++++++++++++++++++--- src/i18n/strings/de_DE.json | 37 +++++++++++++++++++++++++++++++++---- src/i18n/strings/et.json | 34 +++++++++++++++++++++++++++++++++- src/i18n/strings/hu.json | 30 +++++++++++++++++++++++++++++- src/i18n/strings/it.json | 9 ++++++--- src/i18n/strings/sk.json | 34 +++++++++++++++++++++++++++++++++- src/i18n/strings/uk.json | 34 +++++++++++++++++++++++++++++++++- 7 files changed, 199 insertions(+), 14 deletions(-) diff --git a/src/i18n/strings/cs.json b/src/i18n/strings/cs.json index 9bd59626f6d..eed5bb72c99 100644 --- a/src/i18n/strings/cs.json +++ b/src/i18n/strings/cs.json @@ -941,7 +941,7 @@ "You can reset your password, but some features will be unavailable until the identity server is back online. If you keep seeing this warning, check your configuration or contact a server admin.": "Můžete si změnit heslo, ale některé funkce nebudou dostupné dokud nezačne server identity fungovat. Pokud se toto varování zobrazuje i nadále, zkontrolujte svojí konfiguraci nebo kontaktujte správce serveru.", "You can log in, but some features will be unavailable until the identity server is back online. If you keep seeing this warning, check your configuration or contact a server admin.": "Můžete se přihlásit, ale některé funkce nebudou dostupné dokud nezačne server identity fungovat. Pokud se vám toto varování zobrazuje i nadále, zkontrolujte svojí konfiguraci nebo kontaktujte správce serveru.", "Call failed due to misconfigured server": "Volání selhalo, protože je rozbitá konfigurace serveru", - "Please ask the administrator of your homeserver (%(homeserverDomain)s) to configure a TURN server in order for calls to work reliably.": "Požádejte správce svého homeserveru (%(homeserverDomain)s) jestli by nemohl nakonfigurovat TURN server, aby volání fungovala spolehlivě.", + "Please ask the administrator of your homeserver (%(homeserverDomain)s) to configure a TURN server in order for calls to work reliably.": "Požádejte správce svého domovského serveru (%(homeserverDomain)s) jestli by nemohl nakonfigurovat TURN server, aby volání fungovala spolehlivě.", "Alternatively, you can try to use the public server at turn.matrix.org, but this will not be as reliable, and it will share your IP address with that server. You can also manage this in Settings.": "Můžete také zkusit použít veřejný server na adrese turn.matrix.org, ale ten nebude tak spolehlivý a bude sdílet vaši IP adresu s tímto serverem. To můžete spravovat také v Nastavení.", "Try using turn.matrix.org": "Zkuste použít turn.matrix.org", "Messages": "Zprávy", @@ -1441,7 +1441,7 @@ "Manually Verify by Text": "Manuální textové ověření", "Interactively verify by Emoji": "Interaktivní ověření s emotikonami", "Support adding custom themes": "Umožnit přidání vlastního vzhledu", - "Manually verify all remote sessions": "Manuálně ověřit všechny relace", + "Manually verify all remote sessions": "Ručně ověřit všechny relace", "cached locally": "uložen lokálně", "not found locally": "nenalezen lolálně", "Individually verify each session used by a user to mark it as trusted, not trusting cross-signed devices.": "Individuálně ověřit každou uživatelovu relaci a označit jí za důvěryhodnou, bez důvěry v křížový podpis.", @@ -3635,5 +3635,34 @@ "Notifications silenced": "Oznámení ztlumena", "Yes, stop broadcast": "Ano, zastavit vysílání", "Are you sure you want to stop your live broadcast?This will end the broadcast and the full recording will be available in the room.": "Opravdu chcete ukončit živé vysílání? Tím se vysílání ukončí a v místnosti bude k dispozici celý záznam.", - "Stop live broadcasting?": "Ukončit živé vysílání?" + "Stop live broadcasting?": "Ukončit živé vysílání?", + "Someone else is already recording a voice broadcast. Wait for their voice broadcast to end to start a new one.": "Hlasové vysílání už nahrává někdo jiný. Počkejte, až jeho hlasové vysílání skončí, a spusťte nové.", + "You don't have the required permissions to start a voice broadcast in this room. Contact a room administrator to upgrade your permissions.": "Nemáte potřebná oprávnění ke spuštění hlasového vysílání v této místnosti. Obraťte se na správce místnosti, aby vám zvýšil oprávnění.", + "You are already recording a voice broadcast. Please end your current voice broadcast to start a new one.": "Již nahráváte hlasové vysílání. Ukončete prosím aktuální hlasové vysílání a spusťte nové.", + "Can't start a new voice broadcast": "Nelze spustit nové hlasové vysílání", + "Completing set up of your new device": "Dokončování nastavení nového zařízení", + "Waiting for device to sign in": "Čekání na přihlášení zařízení", + "Connecting...": "Připojování...", + "Review and approve the sign in": "Zkontrolovat a schválit přihlášení", + "Select 'Scan QR code'": "Vyberte \"Naskenovat QR kód\"", + "Start at the sign in screen": "Začněte na přihlašovací obrazovce", + "Scan the QR code below with your device that's signed out.": "Níže uvedený QR kód naskenujte pomocí přihlašovaného zařízení.", + "By approving access for this device, it will have full access to your account.": "Schválením přístupu tohoto zařízení získá zařízení plný přístup k vašemu účtu.", + "Check that the code below matches with your other device:": "Zkontrolujte, zda se níže uvedený kód shoduje s vaším dalším zařízením:", + "Devices connected": "Zařízení byla propojena", + "The homeserver doesn't support signing in another device.": "Domovský server nepodporuje přihlášení pomocí jiného zařízení.", + "An unexpected error occurred.": "Došlo k neočekávané chybě.", + "The request was cancelled.": "Požadavek byl zrušen.", + "The other device isn't signed in.": "Druhé zařízení není přihlášeno.", + "The other device is already signed in.": "Druhé zařízení je již přihlášeno.", + "The request was declined on the other device.": "Požadavek byl na druhém zařízení odmítnut.", + "Linking with this device is not supported.": "Propojení s tímto zařízením není podporováno.", + "The scanned code is invalid.": "Naskenovaný kód je neplatný.", + "The linking wasn't completed in the required time.": "Propojení nebylo dokončeno v požadovaném čase.", + "Sign in new device": "Přihlásit nové zařízení", + "Show QR code": "Zobrazit QR kód", + "You can use this device to sign in a new device with a QR code. You will need to scan the QR code shown on this device with your device that's signed out.": "Toto zařízení můžete použít k přihlášení nového zařízení pomocí QR kódu. QR kód zobrazený na tomto zařízení musíte naskenovat pomocí odhlášeného zařízení.", + "Sign in with QR code": "Přihlásit se pomocí QR kódu", + "Browser": "Prohlížeč", + "Allow a QR code to be shown in session manager to sign in another device (requires compatible homeserver)": "Povolit zobrazení QR kódu ve správci relací pro přihlášení do jiného zařízení (vyžaduje kompatibilní domovský server)" } diff --git a/src/i18n/strings/de_DE.json b/src/i18n/strings/de_DE.json index 5b07a37cec4..60586e4088d 100644 --- a/src/i18n/strings/de_DE.json +++ b/src/i18n/strings/de_DE.json @@ -919,7 +919,7 @@ "Only continue if you trust the owner of the server.": "Fahre nur fort, wenn du den Server-Betreibenden vertraust.", "Trust": "Vertrauen", "Custom (%(level)s)": "Benutzerdefiniert (%(level)s)", - "Sends a message as plain text, without interpreting it as markdown": "Verschickt eine Nachricht in Rohtext, ohne sie als Markdown darzustellen", + "Sends a message as plain text, without interpreting it as markdown": "Sendet eine Nachricht als Klartext, ohne sie als Markdown darzustellen", "Use an identity server to invite by email. Manage in Settings.": "Verwende einen Identitäts-Server, um per E-Mail einladen zu können. Lege einen in den Einstellungen fest.", "%(name)s (%(userId)s)": "%(name)s (%(userId)s)", "Try out new ways to ignore people (experimental)": "Verwende neue Möglichkeiten, Menschen zu blockieren", @@ -1166,7 +1166,7 @@ "%(creator)s created and configured the room.": "%(creator)s hat den Raum erstellt und konfiguriert.", "Keep a copy of it somewhere secure, like a password manager or even a safe.": "Bewahre eine Kopie an einem sicheren Ort, wie einem Passwort-Manager oder in einem Safe auf.", "Copy": "Kopieren", - "Sends a message as html, without interpreting it as markdown": "Verschickt eine Nachricht im HTML-Format, ohne sie als Markdown zu darzustellen", + "Sends a message as html, without interpreting it as markdown": "Sendet eine Nachricht als HTML, ohne sie als Markdown darzustellen", "Show rooms with unread notifications first": "Zeige Räume mit ungelesenen Benachrichtigungen zuerst an", "Show shortcuts to recently viewed rooms above the room list": "Kürzlich besuchte Räume anzeigen", "Use Single Sign On to continue": "Einmalanmeldung zum Fortfahren nutzen", @@ -3624,11 +3624,40 @@ "resume voice broadcast": "Sprachübertragung fortsetzen", "Italic": "Kursiv", "Underline": "Unterstrichen", - "Try out the rich text editor (plain text mode coming soon)": "Probiere den Rich-Text-Editor aus (bald auch mit Plain-Text-Modus)", + "Try out the rich text editor (plain text mode coming soon)": "Probiere den Textverarbeitungs-Editor (bald auch mit Klartext-Modus)", "You have already joined this call from another device": "Du nimmst an diesem Anruf bereits mit einem anderen Gerät teil", "stop voice broadcast": "Sprachübertragung beenden", "Notifications silenced": "Benachrichtigungen stummgeschaltet", "Are you sure you want to stop your live broadcast?This will end the broadcast and the full recording will be available in the room.": "Willst du die Sprachübertragung wirklich beenden? Damit endet auch die Aufnahme.", "Yes, stop broadcast": "Ja, Sprachübertragung beenden", - "Stop live broadcasting?": "Sprachübertragung beenden?" + "Stop live broadcasting?": "Sprachübertragung beenden?", + "Sign in with QR code": "Mit QR-Code anmelden", + "Browser": "Browser", + "Allow a QR code to be shown in session manager to sign in another device (requires compatible homeserver)": "Erlaube es andere Geräte mittels QR-Code in der Sitzungsverwaltung anzumelden (kompatibler Heim-Server benötigt)", + "Completing set up of your new device": "Schließe Anmeldung deines neuen Gerätes ab", + "Waiting for device to sign in": "Warte auf Anmeldung des Gerätes", + "Connecting...": "Verbinde …", + "Review and approve the sign in": "Überprüfe und genehmige die Anmeldung", + "Select 'Scan QR code'": "Wähle „QR-Code einlesen“", + "Start at the sign in screen": "Beginne auf dem Anmeldebildschirm", + "Scan the QR code below with your device that's signed out.": "Lese den folgenden QR-Code mit deinem nicht angemeldeten Gerät ein.", + "By approving access for this device, it will have full access to your account.": "Indem du den Zugriff dieses Gerätes bestätigst, erhält es vollen Zugang zu deinem Account.", + "Check that the code below matches with your other device:": "Überprüfe, dass der unten angezeigte Code mit deinem anderen Gerät übereinstimmt:", + "Devices connected": "Geräte verbunden", + "The homeserver doesn't support signing in another device.": "Der Heim-Server unterstützt die Anmeldung eines anderen Gerätes nicht.", + "An unexpected error occurred.": "Ein unerwarteter Fehler ist aufgetreten.", + "The request was cancelled.": "Die Anfrage wurde abgebrochen.", + "The other device isn't signed in.": "Das andere Gerät ist nicht angemeldet.", + "The other device is already signed in.": "Das andere Gerät ist bereits angemeldet.", + "The request was declined on the other device.": "Die Anfrage wurde auf dem anderen Gerät abgelehnt.", + "Linking with this device is not supported.": "Die Verbindung mit diesem Gerät wird nicht unterstützt.", + "The scanned code is invalid.": "Der gescannte Code ist ungültig.", + "The linking wasn't completed in the required time.": "Die Verbindung konnte nicht in der erforderlichen Zeit hergestellt werden.", + "Sign in new device": "Neues Gerät anmelden", + "Show QR code": "QR-Code anzeigen", + "Someone else is already recording a voice broadcast. Wait for their voice broadcast to end to start a new one.": "Jemand anderes nimmt bereits eine Sprachübertragung auf. Warte auf das Ende der Übertragung, bevor du eine neue startest.", + "You don't have the required permissions to start a voice broadcast in this room. Contact a room administrator to upgrade your permissions.": "Du hast nicht die nötigen Berechtigungen, um eine Sprachübertragung in diesem Raum zu starten. Kontaktiere einen Raumadministrator, um deine Berechtigungen anzupassen.", + "You are already recording a voice broadcast. Please end your current voice broadcast to start a new one.": "Du zeichnest bereits eine Sprachübertragung auf. Bitte beende die laufende Übertragung, um eine neue zu beginnen.", + "Can't start a new voice broadcast": "Sprachübertragung kann nicht gestartet werden", + "You can use this device to sign in a new device with a QR code. You will need to scan the QR code shown on this device with your device that's signed out.": "Du kannst dieses Gerät verwenden, um ein neues Gerät per QR-Code anzumelden. Dazu musst du den auf diesem Gerät angezeigten QR-Code mit deinem nicht angemeldeten Gerät einlesen." } diff --git a/src/i18n/strings/et.json b/src/i18n/strings/et.json index f6a6e49b25c..fabcc930193 100644 --- a/src/i18n/strings/et.json +++ b/src/i18n/strings/et.json @@ -3631,5 +3631,37 @@ "New session manager": "Uus sessioonihaldur", "Use new session manager": "Kasuta uut sessioonihaldurit", "Try out the rich text editor (plain text mode coming soon)": "Proovi vormindatud teksti alusel töötavat tekstitoimetit (varsti lisandub ka vormindamata teksti režiim)", - "Notifications silenced": "Teavitused on summutatud" + "Notifications silenced": "Teavitused on summutatud", + "Completing set up of your new device": "Lõpetame uue seadme seadistamise", + "Waiting for device to sign in": "Ootame, et teine seade logiks võrku", + "Connecting...": "Ühendamisel…", + "Review and approve the sign in": "Vaata üle ja kinnita sisselogimine Matrixi'i võrku", + "Select 'Scan QR code'": "Vali „Loe QR-koodi“", + "Start at the sign in screen": "Alusta sisselogimisvaatest", + "Scan the QR code below with your device that's signed out.": "Loe QR-koodi seadmega, kus sa oled Matrix'i võrgust välja loginud.", + "By approving access for this device, it will have full access to your account.": "Lubades ligipääsu sellele seadmele, annad talle ka täismahulise ligipääsu oma kasutajakontole.", + "Check that the code below matches with your other device:": "Kontrolli, et järgnev kood klapib teises seadmes kuvatava koodiga:", + "Devices connected": "Seadmed on ühendatud", + "The homeserver doesn't support signing in another device.": "Koduserver ei toeta muude seadmete võrku logimise võimalust.", + "An unexpected error occurred.": "Tekkis teadmata viga.", + "The request was cancelled.": "Päring katkestati.", + "The other device isn't signed in.": "Teine seade ei ole võrku loginud.", + "The other device is already signed in.": "Teine seade on juba võrku loginud.", + "The request was declined on the other device.": "Teine seade lükkas päringu tagasi.", + "Linking with this device is not supported.": "Sidumine selle seadmega ei ole toetatud.", + "The scanned code is invalid.": "Skaneeritud QR-kood on vigane.", + "The linking wasn't completed in the required time.": "Sidumine ei lõppenud etteantud aja jooksul.", + "Sign in new device": "Logi sisse uus seade", + "Show QR code": "Näita QR-koodi", + "Sign in with QR code": "Logi sisse QR-koodi abil", + "Browser": "Brauser", + "You can use this device to sign in a new device with a QR code. You will need to scan the QR code shown on this device with your device that's signed out.": "Sa saad kasutada seda seadet mõne muu seadme logimiseks Matrix'i võrku QR-koodi alusel. Selleks skaneeri võrgust väljalogitud seadmega seda QR-koodi.", + "Allow a QR code to be shown in session manager to sign in another device (requires compatible homeserver)": "Teise seadme sisselogimiseks luba QR-koodi kuvamine sessioonihalduris (eeldab, et koduserver sellist võimalust toetab)", + "Yes, stop broadcast": "Jah, lõpeta", + "Are you sure you want to stop your live broadcast?This will end the broadcast and the full recording will be available in the room.": "Kas sa oled kindel, et soovid otseeetri lõpetada? Sellega ringhäälingukõne salvestamine lõppeb ja salvestis on kättesaadav kõigile jututoas.", + "Stop live broadcasting?": "Kas lõpetame otseeetri?", + "Someone else is already recording a voice broadcast. Wait for their voice broadcast to end to start a new one.": "Keegi juba salvestab ringhäälingukõnet. Uue ringhäälingukõne salvestamiseks palun oota, kuni see teine ringhäälingukõne on lõppenud.", + "You don't have the required permissions to start a voice broadcast in this room. Contact a room administrator to upgrade your permissions.": "Sul pole piisavalt õigusi selles jututoas ringhäälingukõne algatamiseks. Õiguste lisamiseks palun võta ühendust jututoa haldajaga.", + "You are already recording a voice broadcast. Please end your current voice broadcast to start a new one.": "Sa juba salvestad ringhäälingukõnet. Uue alustamiseks palun lõpeta eelmine salvestus.", + "Can't start a new voice broadcast": "Uue ringhäälingukõne alustamine pole võimalik" } diff --git a/src/i18n/strings/hu.json b/src/i18n/strings/hu.json index bba23baba97..de6d822e02d 100644 --- a/src/i18n/strings/hu.json +++ b/src/i18n/strings/hu.json @@ -3631,5 +3631,33 @@ "You do not have sufficient permissions to change this.": "Nincs megfelelő jogosultság a megváltoztatáshoz.", "%(brand)s is end-to-end encrypted, but is currently limited to smaller numbers of users.": "%(brand)s végpontok között titkosított de jelenleg csak kevés számú résztvevővel működik.", "Enable %(brand)s as an additional calling option in this room": "%(brand)s engedélyezése mint további opció hívásokhoz a szobában", - "Notifications silenced": "Értesítések elnémítva" + "Notifications silenced": "Értesítések elnémítva", + "Stop live broadcasting?": "Megszakítja az élő közvetítést?", + "Someone else is already recording a voice broadcast. Wait for their voice broadcast to end to start a new one.": "Valaki már elindított egy hang közvetítést. Várja meg a közvetítés végét az új indításához.", + "You don't have the required permissions to start a voice broadcast in this room. Contact a room administrator to upgrade your permissions.": "Nincs jogosultsága hang közvetítést indítani ebben a szobában. Vegye fel a kapcsolatot a szoba adminisztrátorával a szükséges jogosultság megszerzéséhez.", + "You are already recording a voice broadcast. Please end your current voice broadcast to start a new one.": "Egy hang közvetítés már folyamatban van. Először fejezze be a jelenlegi közvetítést egy új indításához.", + "Can't start a new voice broadcast": "Az új hang közvetítés nem indítható el", + "Completing set up of your new device": "Új eszköz beállításának elvégzése", + "Waiting for device to sign in": "Várakozás a másik eszköz bejelentkezésére", + "Connecting...": "Kapcsolás…", + "Select 'Scan QR code'": "Válassza ezt: „QR kód beolvasása”", + "Start at the sign in screen": "Kezdje a bejelentkező képernyőn", + "Scan the QR code below with your device that's signed out.": "A kijelentkezett eszközzel olvasd be a QR kódot alább.", + "By approving access for this device, it will have full access to your account.": "Ennek az eszköznek a hozzáférés engedélyezése után az eszköznek teljes hozzáférése lesz a fiókjához.", + "Check that the code below matches with your other device:": "Ellenőrizze, hogy az alábbi kód megegyezik a másik eszközödön lévővel:", + "Devices connected": "Összekötött eszközök", + "The homeserver doesn't support signing in another device.": "A matrix szerver nem támogatja más eszköz bejelentkeztetését.", + "An unexpected error occurred.": "Nemvárt hiba történt.", + "The request was cancelled.": "A kérés megszakítva.", + "The other device isn't signed in.": "A másik eszköz még nincs bejelentkezve.", + "The other device is already signed in.": "A másik eszköz már bejelentkezett.", + "The request was declined on the other device.": "A kérést elutasították a másik eszközön.", + "Linking with this device is not supported.": "Összekötés ezzel az eszközzel nem támogatott.", + "The scanned code is invalid.": "A beolvasott kód érvénytelen.", + "The linking wasn't completed in the required time.": "Az összekötés az elvárt időn belül nem fejeződött be.", + "Sign in new device": "Új eszköz bejelentkeztetése", + "Show QR code": "QR kód beolvasása", + "Sign in with QR code": "Belépés QR kóddal", + "Browser": "Böngésző", + "Yes, stop broadcast": "Igen, közvetítés megállítása" } diff --git a/src/i18n/strings/it.json b/src/i18n/strings/it.json index 08ea1f92349..6c4c692e651 100644 --- a/src/i18n/strings/it.json +++ b/src/i18n/strings/it.json @@ -2267,8 +2267,8 @@ "Value": "Valore", "Setting ID": "ID impostazione", "Show chat effects (animations when receiving e.g. confetti)": "Mostra effetti chat (animazioni quando si ricevono ad es. coriandoli)", - "Original event source": "Fonte dell'evento originale", - "Decrypted event source": "Fonte dell'evento decifrato", + "Original event source": "Sorgente dell'evento originale", + "Decrypted event source": "Sorgente dell'evento decifrato", "Inviting...": "Invito...", "Invite by username": "Invita per nome utente", "Invite your teammates": "Invita la tua squadra", @@ -3635,5 +3635,8 @@ "stop voice broadcast": "ferma broadcast voce", "Yes, stop broadcast": "Sì, ferma il broadcast", "Are you sure you want to stop your live broadcast?This will end the broadcast and the full recording will be available in the room.": "Vuoi davvero fermare il tuo broadcast in diretta? Verrà terminato il broadcast e la registrazione completa sarà disponibile nella stanza.", - "Stop live broadcasting?": "Fermare il broadcast in diretta?" + "Stop live broadcasting?": "Fermare il broadcast in diretta?", + "You don't have the required permissions to start a voice broadcast in this room. Contact a room administrator to upgrade your permissions.": "Non hai l'autorizzazione necessaria per iniziare un broadcast vocale in questa stanza. Contatta un amministratore della stanza per aggiornare le tue autorizzazioni.", + "You are already recording a voice broadcast. Please end your current voice broadcast to start a new one.": "Stai già registrando un broadcast vocale. Termina quello in corso per iniziarne uno nuovo.", + "Can't start a new voice broadcast": "Impossibile iniziare un nuovo broadcast vocale" } diff --git a/src/i18n/strings/sk.json b/src/i18n/strings/sk.json index 0a7596d94e4..dd4555caf64 100644 --- a/src/i18n/strings/sk.json +++ b/src/i18n/strings/sk.json @@ -3632,5 +3632,37 @@ "stop voice broadcast": "zastaviť hlasové vysielanie", "resume voice broadcast": "obnoviť hlasové vysielanie", "pause voice broadcast": "pozastaviť hlasové vysielanie", - "Notifications silenced": "Oznámenia stlmené" + "Notifications silenced": "Oznámenia stlmené", + "Completing set up of your new device": "Dokončenie nastavenia nového zariadenia", + "Waiting for device to sign in": "Čaká sa na prihlásenie zariadenia", + "Connecting...": "Pripájanie…", + "Review and approve the sign in": "Skontrolujte a schváľte prihlásenie", + "Select 'Scan QR code'": "Vyberte možnosť \"Skenovať QR kód\"", + "Start at the sign in screen": "Začnite na prihlasovacej obrazovke", + "Scan the QR code below with your device that's signed out.": "Naskenujte nižšie uvedený QR kód pomocou zariadenia, ktoré je odhlásené.", + "By approving access for this device, it will have full access to your account.": "Schválením prístupu pre toto zariadenie bude mať plný prístup k vášmu účtu.", + "Check that the code below matches with your other device:": "Skontrolujte, či sa nižšie uvedený kód zhoduje s vaším druhým zariadením:", + "Devices connected": "Zariadenia pripojené", + "The homeserver doesn't support signing in another device.": "Domovský server nepodporuje prihlasovanie do iného zariadenia.", + "An unexpected error occurred.": "Vyskytla sa neočakávaná chyba.", + "The request was cancelled.": "Žiadosť bola zrušená.", + "The other device isn't signed in.": "Druhé zariadenie nie je prihlásené.", + "The other device is already signed in.": "Druhé zariadenie je už prihlásené.", + "The request was declined on the other device.": "Žiadosť bola na druhom zariadení zamietnutá.", + "Linking with this device is not supported.": "Prepojenie s týmto zariadením nie je podporované.", + "The scanned code is invalid.": "Naskenovaný kód je neplatný.", + "The linking wasn't completed in the required time.": "Prepojenie nebolo dokončené v požadovanom čase.", + "Sign in new device": "Prihlásiť nové zariadenie", + "Show QR code": "Zobraziť QR kód", + "You can use this device to sign in a new device with a QR code. You will need to scan the QR code shown on this device with your device that's signed out.": "Toto zariadenie môžete použiť na prihlásenie nového zariadenia pomocou QR kódu. QR kód zobrazený na tomto zariadení musíte naskenovať pomocou zariadenia, ktoré je odhlásené.", + "Sign in with QR code": "Prihlásiť sa pomocou QR kódu", + "Browser": "Prehliadač", + "Allow a QR code to be shown in session manager to sign in another device (requires compatible homeserver)": "Umožniť zobrazenie QR kódu v správcovi relácií na prihlásenie do iného zariadenia (vyžaduje kompatibilný domovský server)", + "Yes, stop broadcast": "Áno, zastaviť vysielanie", + "Are you sure you want to stop your live broadcast?This will end the broadcast and the full recording will be available in the room.": "Určite chcete zastaviť vaše vysielanie naživo? Tým sa vysielanie ukončí a v miestnosti bude k dispozícii celý záznam.", + "Stop live broadcasting?": "Zastaviť vysielanie naživo?", + "Someone else is already recording a voice broadcast. Wait for their voice broadcast to end to start a new one.": "Niekto iný už nahráva hlasové vysielanie. Počkajte, kým sa skončí jeho hlasové vysielanie, a potom spustite nové.", + "You don't have the required permissions to start a voice broadcast in this room. Contact a room administrator to upgrade your permissions.": "Nemáte požadované oprávnenia na spustenie hlasového vysielania v tejto miestnosti. Obráťte sa na správcu miestnosti, aby vám rozšíril oprávnenia.", + "You are already recording a voice broadcast. Please end your current voice broadcast to start a new one.": "Už nahrávate hlasové vysielanie. Ukončite aktuálne hlasové vysielanie a spustite nové.", + "Can't start a new voice broadcast": "Nemôžete spustiť nové hlasové vysielanie" } diff --git a/src/i18n/strings/uk.json b/src/i18n/strings/uk.json index 04b1c056e9c..0d2adc0ad8f 100644 --- a/src/i18n/strings/uk.json +++ b/src/i18n/strings/uk.json @@ -3632,5 +3632,37 @@ "pause voice broadcast": "призупинити голосове мовлення", "You have already joined this call from another device": "Ви вже приєдналися до цього виклику з іншого пристрою", "stop voice broadcast": "припинити голосове мовлення", - "Notifications silenced": "Сповіщення стишено" + "Notifications silenced": "Сповіщення стишено", + "Sign in with QR code": "Увійти за допомогою QR-коду", + "Browser": "Браузер", + "Allow a QR code to be shown in session manager to sign in another device (requires compatible homeserver)": "Дозволити показ QR-коду в менеджері сеансів для входу на іншому пристрої (потрібен сумісний домашній сервер)", + "Yes, stop broadcast": "Так, припинити мовлення", + "Are you sure you want to stop your live broadcast?This will end the broadcast and the full recording will be available in the room.": "Ви впевнені, що хочете припинити голосове мовлення? На цьому трансляція завершиться, і повний запис буде доступний у кімнаті.", + "Stop live broadcasting?": "Припинити голосове мовлення?", + "Someone else is already recording a voice broadcast. Wait for their voice broadcast to end to start a new one.": "Хтось інший вже записує голосову трансляцію. Зачекайте, поки вона завершиться, щоб почати нову.", + "You don't have the required permissions to start a voice broadcast in this room. Contact a room administrator to upgrade your permissions.": "Ви не маєте необхідних дозволів для початку голосового мовлення в цій кімнаті. Зверніться до адміністратора кімнати, щоб оновити ваші дозволи.", + "You are already recording a voice broadcast. Please end your current voice broadcast to start a new one.": "Ви вже записуєте голосову трансляцію. Завершіть поточну трансляцію, щоб розпочати нову.", + "Can't start a new voice broadcast": "Не вдалося розпочати нову голосове мовлення", + "Completing set up of your new device": "Завершення налаштування нового пристрою", + "Waiting for device to sign in": "Очікування входу з пристрою", + "Connecting...": "З'єднання...", + "Review and approve the sign in": "Розглянути та схвалити вхід", + "Select 'Scan QR code'": "Виберіть «Сканувати QR-код»", + "Start at the sign in screen": "Почніть з екрана входу", + "Scan the QR code below with your device that's signed out.": "Скануйте QR-код знизу своїм пристроєм, на якому ви вийшли.", + "By approving access for this device, it will have full access to your account.": "Затвердивши доступ для цього пристрою, ви надасте йому повний доступ до вашого облікового запису.", + "Check that the code below matches with your other device:": "Перевірте, чи збігається наведений внизу код з кодом на вашому іншому пристрої:", + "Devices connected": "Пристрої під'єднано", + "The homeserver doesn't support signing in another device.": "Домашній сервер не підтримує вхід на іншому пристрої.", + "An unexpected error occurred.": "Виникла непередбачувана помилка.", + "The request was cancelled.": "Запит було скасовано.", + "The other device isn't signed in.": "На іншому пристрої вхід не виконано.", + "The other device is already signed in.": "На іншому пристрої вхід було виконано.", + "The request was declined on the other device.": "На іншому пристрої запит відхилено.", + "Linking with this device is not supported.": "Зв'язок з цим пристроєм не підтримується.", + "The scanned code is invalid.": "Сканований код недійсний.", + "The linking wasn't completed in the required time.": "У встановлені терміни з'єднання не було виконано.", + "Sign in new device": "Увійти на новому пристрої", + "Show QR code": "Показати QR-код", + "You can use this device to sign in a new device with a QR code. You will need to scan the QR code shown on this device with your device that's signed out.": "Ви можете використовувати цей пристрій для входу на новому пристрої за допомогою QR-коду. Вам потрібно буде сканувати QR-код, показаний на цьому пристрої, своїм пристроєм, на якому ви вийшли." } From 17c3fb89c113a2924c7ddf3969fd8f8cc44feea2 Mon Sep 17 00:00:00 2001 From: kegsay Date: Wed, 19 Oct 2022 21:00:53 +0100 Subject: [PATCH 022/113] Store refactor: convert WidgetPermissionStore (#9458) * Store refactor: convert WidgetPermissionStore Add Jest tests as well. * More tests * Review comments --- .../dialogs/WidgetOpenIDPermissionsDialog.tsx | 5 +- src/contexts/SDKContext.ts | 8 ++ src/stores/widgets/StopGapWidgetDriver.ts | 4 +- src/stores/widgets/WidgetPermissionStore.ts | 20 ++-- test/TestSdkContext.ts | 2 + .../widgets/WidgetPermissionStore-test.ts | 107 ++++++++++++++++++ 6 files changed, 129 insertions(+), 17 deletions(-) create mode 100644 test/stores/widgets/WidgetPermissionStore-test.ts 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(undefined); @@ -51,6 +52,7 @@ export class SdkContextClass { public client?: MatrixClient; // All protected fields to make it easier to derive test stores + protected _WidgetPermissionStore?: WidgetPermissionStore; protected _RightPanelStore?: RightPanelStore; protected _RoomNotificationStateStore?: RoomNotificationStateStore; protected _RoomViewStore?: RoomViewStore; @@ -102,6 +104,12 @@ export class SdkContextClass { } return this._WidgetLayoutStore; } + public get widgetPermissionStore(): WidgetPermissionStore { + if (!this._WidgetPermissionStore) { + this._WidgetPermissionStore = new WidgetPermissionStore(this); + } + return this._WidgetPermissionStore; + } public get widgetStore(): WidgetStore { if (!this._WidgetStore) { this._WidgetStore = WidgetStore.instance; diff --git a/src/stores/widgets/StopGapWidgetDriver.ts b/src/stores/widgets/StopGapWidgetDriver.ts index ba01a10926b..ff2619ad594 100644 --- a/src/stores/widgets/StopGapWidgetDriver.ts +++ b/src/stores/widgets/StopGapWidgetDriver.ts @@ -47,7 +47,7 @@ import Modal from "../../Modal"; import WidgetOpenIDPermissionsDialog from "../../components/views/dialogs/WidgetOpenIDPermissionsDialog"; import WidgetCapabilitiesPromptDialog from "../../components/views/dialogs/WidgetCapabilitiesPromptDialog"; import { WidgetPermissionCustomisations } from "../../customisations/WidgetPermissions"; -import { OIDCState, WidgetPermissionStore } from "./WidgetPermissionStore"; +import { OIDCState } from "./WidgetPermissionStore"; import { WidgetType } from "../../widgets/WidgetType"; import { CHAT_EFFECTS } from "../../effects"; import { containsEmoji } from "../../effects/utils"; @@ -350,7 +350,7 @@ export class StopGapWidgetDriver extends WidgetDriver { } public async askOpenID(observer: SimpleObservable) { - const oidcState = WidgetPermissionStore.instance.getOIDCState( + const oidcState = SdkContextClass.instance.widgetPermissionStore.getOIDCState( this.forWidget, this.forWidgetKind, this.inRoomId, ); diff --git a/src/stores/widgets/WidgetPermissionStore.ts b/src/stores/widgets/WidgetPermissionStore.ts index 246492333c3..fca018ca5c7 100644 --- a/src/stores/widgets/WidgetPermissionStore.ts +++ b/src/stores/widgets/WidgetPermissionStore.ts @@ -17,8 +17,8 @@ import { Widget, WidgetKind } from "matrix-widget-api"; import SettingsStore from "../../settings/SettingsStore"; -import { MatrixClientPeg } from "../../MatrixClientPeg"; import { SettingLevel } from "../../settings/SettingLevel"; +import { SdkContextClass } from "../../contexts/SDKContext"; export enum OIDCState { Allowed, // user has set the remembered value as allowed @@ -27,16 +27,7 @@ export enum OIDCState { } export class WidgetPermissionStore { - private static internalInstance: WidgetPermissionStore; - - private constructor() { - } - - public static get instance(): WidgetPermissionStore { - if (!WidgetPermissionStore.internalInstance) { - WidgetPermissionStore.internalInstance = new WidgetPermissionStore(); - } - return WidgetPermissionStore.internalInstance; + public constructor(private readonly context: SdkContextClass) { } // TODO (all functions here): Merge widgetKind with the widget definition @@ -44,7 +35,7 @@ export class WidgetPermissionStore { private packSettingKey(widget: Widget, kind: WidgetKind, roomId?: string): string { let location = roomId; if (kind !== WidgetKind.Room) { - location = MatrixClientPeg.get().getUserId(); + location = this.context.client?.getUserId(); } if (kind === WidgetKind.Modal) { location = '*MODAL*-' + location; // to guarantee differentiation from whatever spawned it @@ -71,7 +62,10 @@ export class WidgetPermissionStore { public setOIDCState(widget: Widget, kind: WidgetKind, roomId: string, newState: OIDCState) { const settingsKey = this.packSettingKey(widget, kind, roomId); - const currentValues = SettingsStore.getValue("widgetOpenIDPermissions"); + let currentValues = SettingsStore.getValue("widgetOpenIDPermissions"); + if (!currentValues) { + currentValues = {}; + } if (!currentValues.allow) currentValues.allow = []; if (!currentValues.deny) currentValues.deny = []; diff --git a/test/TestSdkContext.ts b/test/TestSdkContext.ts index 137b71f9a37..4ce9100a94d 100644 --- a/test/TestSdkContext.ts +++ b/test/TestSdkContext.ts @@ -22,6 +22,7 @@ import RightPanelStore from "../src/stores/right-panel/RightPanelStore"; import { RoomViewStore } from "../src/stores/RoomViewStore"; import { SpaceStoreClass } from "../src/stores/spaces/SpaceStore"; import { WidgetLayoutStore } from "../src/stores/widgets/WidgetLayoutStore"; +import { WidgetPermissionStore } from "../src/stores/widgets/WidgetPermissionStore"; import WidgetStore from "../src/stores/WidgetStore"; /** @@ -32,6 +33,7 @@ export class TestSdkContext extends SdkContextClass { public _RightPanelStore?: RightPanelStore; public _RoomNotificationStateStore?: RoomNotificationStateStore; public _RoomViewStore?: RoomViewStore; + public _WidgetPermissionStore?: WidgetPermissionStore; public _WidgetLayoutStore?: WidgetLayoutStore; public _WidgetStore?: WidgetStore; public _PosthogAnalytics?: PosthogAnalytics; diff --git a/test/stores/widgets/WidgetPermissionStore-test.ts b/test/stores/widgets/WidgetPermissionStore-test.ts new file mode 100644 index 00000000000..3ebb7fc9f53 --- /dev/null +++ b/test/stores/widgets/WidgetPermissionStore-test.ts @@ -0,0 +1,107 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { mocked } from "jest-mock"; +import { MatrixClient } from "matrix-js-sdk/src/matrix"; +import { Widget, WidgetKind } from "matrix-widget-api"; + +import { OIDCState, WidgetPermissionStore } from "../../../src/stores/widgets/WidgetPermissionStore"; +import SettingsStore from "../../../src/settings/SettingsStore"; +import { TestSdkContext } from "../../TestSdkContext"; +import { SettingLevel } from "../../../src/settings/SettingLevel"; +import { SdkContextClass } from "../../../src/contexts/SDKContext"; +import { stubClient } from "../../test-utils"; + +jest.mock("../../../src/settings/SettingsStore"); + +describe("WidgetPermissionStore", () => { + let widgetPermissionStore: WidgetPermissionStore; + let mockClient: MatrixClient; + const userId = "@alice:localhost"; + const roomId = "!room:localhost"; + const w = new Widget({ + id: "wid", + creatorUserId: userId, + type: "m.custom", + url: "https://invalid.address.here", + }); + let settings = {}; // key value store + + beforeEach(() => { + settings = {}; // clear settings + mocked(SettingsStore.getValue).mockImplementation((setting: string) => { + return settings[setting]; + }); + mocked(SettingsStore.setValue).mockImplementation((settingName: string, + roomId: string | null, + level: SettingLevel, + value: any, + ): Promise => { + // the store doesn't use any specific level or room ID (room IDs are packed into keys in `value`) + settings[settingName] = value; + return Promise.resolve(); + }); + mockClient = stubClient(); + const context = new TestSdkContext(); + context.client = mockClient; + widgetPermissionStore = new WidgetPermissionStore(context); + }); + + it("should persist OIDCState.Allowed for a widget", () => { + widgetPermissionStore.setOIDCState(w, WidgetKind.Account, null, OIDCState.Allowed); + // check it remembered the value + expect( + widgetPermissionStore.getOIDCState(w, WidgetKind.Account, null), + ).toEqual(OIDCState.Allowed); + }); + + it("should persist OIDCState.Denied for a widget", () => { + widgetPermissionStore.setOIDCState(w, WidgetKind.Account, null, OIDCState.Denied); + // check it remembered the value + expect( + widgetPermissionStore.getOIDCState(w, WidgetKind.Account, null), + ).toEqual(OIDCState.Denied); + }); + + it("should update OIDCState for a widget", () => { + widgetPermissionStore.setOIDCState(w, WidgetKind.Account, null, OIDCState.Allowed); + widgetPermissionStore.setOIDCState(w, WidgetKind.Account, null, OIDCState.Denied); + // check it remembered the latest value + expect( + widgetPermissionStore.getOIDCState(w, WidgetKind.Account, null), + ).toEqual(OIDCState.Denied); + }); + + it("should scope the location for a widget when setting OIDC state", () => { + // allow this widget for this room + widgetPermissionStore.setOIDCState(w, WidgetKind.Room, roomId, OIDCState.Allowed); + // check it remembered the value + expect( + widgetPermissionStore.getOIDCState(w, WidgetKind.Room, roomId), + ).toEqual(OIDCState.Allowed); + // check this is not the case for the entire account + expect( + widgetPermissionStore.getOIDCState(w, WidgetKind.Account, roomId), + ).toEqual(OIDCState.Unknown); + }); + it("is created once in SdkContextClass", () => { + const context = new SdkContextClass(); + const store = context.widgetPermissionStore; + expect(store).toBeDefined(); + const store2 = context.widgetPermissionStore; + expect(store2).toStrictEqual(store); + }); +}); From dade38086cb6904d4e8138914f53e792b740a02d Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Thu, 20 Oct 2022 00:52:06 -0600 Subject: [PATCH 023/113] Fix slightly noisy warning when switching spaces (#9468) Sometimes `spaceName` can be `undefined` because of function timing - use a different method for getting the space's name when this happens. --- src/components/views/rooms/RoomListHeader.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/views/rooms/RoomListHeader.tsx b/src/components/views/rooms/RoomListHeader.tsx index f783e628f30..9c7b5c11aa0 100644 --- a/src/components/views/rooms/RoomListHeader.tsx +++ b/src/components/views/rooms/RoomListHeader.tsx @@ -379,7 +379,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 } From 6fe8744e4dfd8f9661f7dc92719cac5d69841142 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Thu, 20 Oct 2022 00:52:17 -0600 Subject: [PATCH 024/113] Remove performance metrics from cypress (#9467) They are unfortunately unreliable and have been broken since June anyways. --- .github/workflows/cypress.yaml | 44 ------------ cypress/e2e/create-room/create-room.spec.ts | 2 - cypress/e2e/login/login.spec.ts | 2 - cypress/e2e/register/register.spec.ts | 4 -- cypress/plugins/index.ts | 2 - cypress/plugins/performance.ts | 47 ------------- cypress/support/e2e.ts | 1 - cypress/support/performance.ts | 74 --------------------- 8 files changed, 176 deletions(-) delete mode 100644 cypress/plugins/performance.ts delete mode 100644 cypress/support/performance.ts diff --git a/.github/workflows/cypress.yaml b/.github/workflows/cypress.yaml index a76c00918bb..57e6a7837e1 100644 --- a/.github/workflows/cypress.yaml +++ b/.github/workflows/cypress.yaml @@ -155,17 +155,6 @@ jobs: cypress/videos cypress/synapselogs - - run: mv cypress/performance/*.json cypress/performance/measurements-${{ strategy.job-index }}.json - continue-on-error: true - - - name: Upload Benchmark - uses: actions/upload-artifact@v2 - with: - name: cypress-benchmark - path: cypress/performance/* - if-no-files-found: ignore - retention-days: 1 - report: name: Report results needs: tests @@ -181,36 +170,3 @@ jobs: context: ${{ github.workflow }} / cypress (${{ github.event.workflow_run.event }} => ${{ github.event_name }}) sha: ${{ github.event.workflow_run.head_sha }} target_url: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} - - store-benchmark: - needs: tests - runs-on: ubuntu-latest - if: | - github.event.workflow_run.event != 'pull_request' && - github.event.workflow_run.head_branch == 'develop' && - github.event.workflow_run.head_repository.full_name == github.repository - permissions: - contents: write - steps: - - uses: actions/checkout@v2 - - - name: Download benchmark result - uses: actions/download-artifact@v3 - with: - name: cypress-benchmark - - - name: Merge measurements - run: jq -s add measurements-*.json > measurements.json - - - name: Store benchmark result - uses: matrix-org/github-action-benchmark@jsperfentry-6 - with: - name: Cypress measurements - tool: 'jsperformanceentry' - output-file-path: measurements.json - # The dashboard is available at https://matrix-org.github.io/matrix-react-sdk/cypress/bench/ - benchmark-data-dir-path: cypress/bench - fail-on-alert: false - comment-on-alert: false - github-token: ${{ secrets.GITHUB_TOKEN }} - auto-push: ${{ github.event.workflow_run.event != 'pull_request' }} diff --git a/cypress/e2e/create-room/create-room.spec.ts b/cypress/e2e/create-room/create-room.spec.ts index deac0728e35..1217c917b64 100644 --- a/cypress/e2e/create-room/create-room.spec.ts +++ b/cypress/e2e/create-room/create-room.spec.ts @@ -54,12 +54,10 @@ 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.contains(".mx_RoomHeader_nametext", name); cy.contains(".mx_RoomHeader_topic", topic); }); diff --git a/cypress/e2e/login/login.spec.ts b/cypress/e2e/login/login.spec.ts index ff963dfbfe7..10582870102 100644 --- a/cypress/e2e/login/login.spec.ts +++ b/cypress/e2e/login/login.spec.ts @@ -52,11 +52,9 @@ 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"); }); }); 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/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/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/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 { }; From 3c9ba3e69f743145bf4caf158738a2278a4b0f2b Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Thu, 20 Oct 2022 10:04:14 +0200 Subject: [PATCH 025/113] Replace Icon with webpack loaded SVG (#9464) --- res/css/_components.pcss | 3 +- .../{components/atoms => compound}/_Icon.pcss | 23 +--- .../atoms/_VoiceBroadcastControl.pcss | 27 ++++ res/img/element-icons/Stop.svg | 2 +- res/img/element-icons/live.svg | 41 +----- res/img/element-icons/pause.svg | 4 +- res/img/element-icons/play.svg | 2 +- res/img/voip/call-view/mic-on.svg | 2 +- src/components/atoms/Icon.tsx | 83 ------------ src/i18n/strings/en_EN.json | 6 +- .../components/atoms/LiveBadge.tsx | 4 +- .../atoms/PlaybackControlButton.tsx | 53 -------- ...opButton.tsx => VoiceBroadcastControl.tsx} | 17 ++- .../components/atoms/VoiceBroadcastHeader.tsx | 7 +- .../molecules/VoiceBroadcastPlaybackBody.tsx | 37 +++++- .../molecules/VoiceBroadcastRecordingPip.tsx | 9 +- src/voice-broadcast/index.ts | 3 +- test/components/atoms/Icon-test.tsx | 47 ------- .../atoms/__snapshots__/Icon-test.tsx.snap | 34 ----- .../atoms/PlaybackControlButton-test.tsx | 45 ------- .../components/atoms/StopButton-test.tsx | 45 ------- .../atoms/VoiceBroadcastControl-test.tsx | 55 ++++++++ .../__snapshots__/LiveBadge-test.tsx.snap | 7 +- .../PlaybackControlButton-test.tsx.snap | 55 -------- .../__snapshots__/StopButton-test.tsx.snap | 19 --- .../VoiceBroadcastControl-test.tsx.snap | 16 +++ .../VoiceBroadcastHeader-test.tsx.snap | 28 ++-- .../VoiceBroadcastPlaybackBody-test.tsx | 26 ++-- .../VoiceBroadcastRecordingPip-test.tsx | 2 +- .../VoiceBroadcastPlaybackBody-test.tsx.snap | 121 ++++++++++++------ .../VoiceBroadcastRecordingBody-test.tsx.snap | 14 +- .../VoiceBroadcastRecordingPip-test.tsx.snap | 25 ++-- 32 files changed, 298 insertions(+), 564 deletions(-) rename res/css/{components/atoms => compound}/_Icon.pcss (68%) create mode 100644 res/css/voice-broadcast/atoms/_VoiceBroadcastControl.pcss delete mode 100644 src/components/atoms/Icon.tsx delete mode 100644 src/voice-broadcast/components/atoms/PlaybackControlButton.tsx rename src/voice-broadcast/components/atoms/{StopButton.tsx => VoiceBroadcastControl.tsx} (68%) delete mode 100644 test/components/atoms/Icon-test.tsx delete mode 100644 test/components/atoms/__snapshots__/Icon-test.tsx.snap delete mode 100644 test/voice-broadcast/components/atoms/PlaybackControlButton-test.tsx delete mode 100644 test/voice-broadcast/components/atoms/StopButton-test.tsx create mode 100644 test/voice-broadcast/components/atoms/VoiceBroadcastControl-test.tsx delete mode 100644 test/voice-broadcast/components/atoms/__snapshots__/PlaybackControlButton-test.tsx.snap delete mode 100644 test/voice-broadcast/components/atoms/__snapshots__/StopButton-test.tsx.snap create mode 100644 test/voice-broadcast/components/atoms/__snapshots__/VoiceBroadcastControl-test.tsx.snap diff --git a/res/css/_components.pcss b/res/css/_components.pcss index b2fcb0dd4f8..819afe64a40 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -4,7 +4,7 @@ @import "./_font-sizes.pcss"; @import "./_font-weights.pcss"; @import "./_spacing.pcss"; -@import "./components/atoms/_Icon.pcss"; +@import "./compound/_Icon.pcss"; @import "./components/views/beacon/_BeaconListItem.pcss"; @import "./components/views/beacon/_BeaconStatus.pcss"; @import "./components/views/beacon/_BeaconStatusTooltip.pcss"; @@ -369,6 +369,7 @@ @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"; diff --git a/res/css/components/atoms/_Icon.pcss b/res/css/compound/_Icon.pcss similarity index 68% rename from res/css/components/atoms/_Icon.pcss rename to res/css/compound/_Icon.pcss index b9d994e43f4..88f49f9da06 100644 --- a/res/css/components/atoms/_Icon.pcss +++ b/res/css/compound/_Icon.pcss @@ -14,13 +14,14 @@ See the License for the specific language governing permissions and limitations under the License. */ +/* + * Compound icon + + * {@link https://www.figma.com/file/X4XTH9iS2KGJ2wFKDqkyed} + */ + .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; } @@ -28,15 +29,3 @@ limitations under the License. height: 16px; width: 16px; } - -.mx_Icon_accent { - background-color: $accent; -} - -.mx_Icon_live-badge { - background-color: #fff; -} - -.mx_Icon_compound-secondary-content { - background-color: $secondary-content; -} diff --git a/res/css/voice-broadcast/atoms/_VoiceBroadcastControl.pcss b/res/css/voice-broadcast/atoms/_VoiceBroadcastControl.pcss new file mode 100644 index 00000000000..bf07157a68b --- /dev/null +++ b/res/css/voice-broadcast/atoms/_VoiceBroadcastControl.pcss @@ -0,0 +1,27 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_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; +} diff --git a/res/img/element-icons/Stop.svg b/res/img/element-icons/Stop.svg index 29c7a0cef7b..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/live.svg b/res/img/element-icons/live.svg index 40a7a666770..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 293c0a10d86..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 339e20b729b..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/voip/call-view/mic-on.svg b/res/img/voip/call-view/mic-on.svg index 57428a3cd8d..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/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/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index f322c5de8d8..f40e1658040 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -644,10 +644,10 @@ "Stop live broadcasting?": "Stop live broadcasting?", "Are you sure you want to stop your live broadcast?This will end the broadcast and the full recording will be available in the room.": "Are you sure you want to stop your live broadcast?This will end the broadcast and the full recording will be available in the room.", "Yes, stop broadcast": "Yes, stop broadcast", - "Live": "Live", - "pause voice broadcast": "pause voice broadcast", + "play voice broadcast": "play voice broadcast", "resume voice broadcast": "resume voice broadcast", - "stop voice broadcast": "stop voice broadcast", + "pause voice broadcast": "pause voice broadcast", + "Live": "Live", "Voice broadcast": "Voice broadcast", "Cannot reach homeserver": "Cannot reach homeserver", "Ensure you have a stable internet connection, or get in touch with the server admin": "Ensure you have a stable internet connection, or get in touch with the server admin", diff --git a/src/voice-broadcast/components/atoms/LiveBadge.tsx b/src/voice-broadcast/components/atoms/LiveBadge.tsx index cd2a16e797d..ba94aa14a99 100644 --- a/src/voice-broadcast/components/atoms/LiveBadge.tsx +++ b/src/voice-broadcast/components/atoms/LiveBadge.tsx @@ -16,12 +16,12 @@ limitations under the License. import React from "react"; -import { Icon, IconColour, IconType } from "../../../components/atoms/Icon"; +import { Icon as LiveIcon } from "../../../../res/img/element-icons/live.svg"; import { _t } from "../../../languageHandler"; export const LiveBadge: React.FC = () => { return
- + { _t("Live") }
; }; diff --git a/src/voice-broadcast/components/atoms/PlaybackControlButton.tsx b/src/voice-broadcast/components/atoms/PlaybackControlButton.tsx deleted file mode 100644 index b67e6b3e24f..00000000000 --- a/src/voice-broadcast/components/atoms/PlaybackControlButton.tsx +++ /dev/null @@ -1,53 +0,0 @@ -/* -Copyright 2022 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import React from "react"; - -import { VoiceBroadcastPlaybackState } from "../.."; -import { Icon, IconColour, IconType } from "../../../components/atoms/Icon"; -import AccessibleButton from "../../../components/views/elements/AccessibleButton"; -import { _t } from "../../../languageHandler"; - -const stateIconMap = new Map([ - [VoiceBroadcastPlaybackState.Playing, IconType.Pause], - [VoiceBroadcastPlaybackState.Paused, IconType.Play], - [VoiceBroadcastPlaybackState.Stopped, IconType.Play], -]); - -interface Props { - onClick: () => void; - state: VoiceBroadcastPlaybackState; -} - -export const PlaybackControlButton: React.FC = ({ - onClick, - state, -}) => { - const ariaLabel = state === VoiceBroadcastPlaybackState.Playing - ? _t("pause voice broadcast") - : _t("resume voice broadcast"); - - return - - ; -}; diff --git a/src/voice-broadcast/components/atoms/StopButton.tsx b/src/voice-broadcast/components/atoms/VoiceBroadcastControl.tsx similarity index 68% rename from src/voice-broadcast/components/atoms/StopButton.tsx rename to src/voice-broadcast/components/atoms/VoiceBroadcastControl.tsx index 50abb209d05..238d138698b 100644 --- a/src/voice-broadcast/components/atoms/StopButton.tsx +++ b/src/voice-broadcast/components/atoms/VoiceBroadcastControl.tsx @@ -16,25 +16,24 @@ limitations under the License. import React from "react"; -import { Icon, IconColour, IconType } from "../../../components/atoms/Icon"; import AccessibleButton from "../../../components/views/elements/AccessibleButton"; -import { _t } from "../../../languageHandler"; interface Props { + icon: React.FC>; + label: string; onClick: () => void; } -export const StopButton: React.FC = ({ +export const VoiceBroadcastControl: React.FC = ({ + icon: Icon, + label, onClick, }) => { return - + ; }; diff --git a/src/voice-broadcast/components/atoms/VoiceBroadcastHeader.tsx b/src/voice-broadcast/components/atoms/VoiceBroadcastHeader.tsx index 5abc4d21e41..c83e8e8a0c5 100644 --- a/src/voice-broadcast/components/atoms/VoiceBroadcastHeader.tsx +++ b/src/voice-broadcast/components/atoms/VoiceBroadcastHeader.tsx @@ -15,7 +15,8 @@ import React from "react"; import { Room, RoomMember } from "matrix-js-sdk/src/matrix"; import { LiveBadge } from "../.."; -import { Icon, IconColour, IconType } from "../../../components/atoms/Icon"; +import { Icon as LiveIcon } from "../../../../res/img/element-icons/live.svg"; +import { Icon as MicrophoneIcon } from "../../../../res/img/voip/call-view/mic-on.svg"; import { _t } from "../../../languageHandler"; import RoomAvatar from "../../../components/views/avatars/RoomAvatar"; @@ -34,7 +35,7 @@ export const VoiceBroadcastHeader: React.FC = ({ }) => { const broadcast = showBroadcast ?
- + { _t("Voice broadcast") }
: null; @@ -46,7 +47,7 @@ export const VoiceBroadcastHeader: React.FC = ({ { room.name }
- + { sender.name }
{ broadcast } diff --git a/src/voice-broadcast/components/molecules/VoiceBroadcastPlaybackBody.tsx b/src/voice-broadcast/components/molecules/VoiceBroadcastPlaybackBody.tsx index 035b3ce6e57..e0634636a74 100644 --- a/src/voice-broadcast/components/molecules/VoiceBroadcastPlaybackBody.tsx +++ b/src/voice-broadcast/components/molecules/VoiceBroadcastPlaybackBody.tsx @@ -17,13 +17,16 @@ limitations under the License. import React from "react"; import { - PlaybackControlButton, + VoiceBroadcastControl, VoiceBroadcastHeader, VoiceBroadcastPlayback, VoiceBroadcastPlaybackState, } from "../.."; import Spinner from "../../../components/views/elements/Spinner"; import { useVoiceBroadcastPlayback } from "../../hooks/useVoiceBroadcastPlayback"; +import { Icon as PlayIcon } from "../../../../res/img/element-icons/play.svg"; +import { Icon as PauseIcon } from "../../../../res/img/element-icons/pause.svg"; +import { _t } from "../../../languageHandler"; interface VoiceBroadcastPlaybackBodyProps { playback: VoiceBroadcastPlayback; @@ -40,9 +43,35 @@ export const VoiceBroadcastPlaybackBody: React.FC - : ; + let control: React.ReactNode; + + if (playbackState === VoiceBroadcastPlaybackState.Buffering) { + control = ; + } else { + let controlIcon: React.FC>; + let controlLabel: string; + + switch (playbackState) { + case VoiceBroadcastPlaybackState.Stopped: + controlIcon = PlayIcon; + controlLabel = _t("play voice broadcast"); + break; + case VoiceBroadcastPlaybackState.Paused: + controlIcon = PlayIcon; + controlLabel = _t("resume voice broadcast"); + break; + case VoiceBroadcastPlaybackState.Playing: + controlIcon = PauseIcon; + controlLabel = _t("pause voice broadcast"); + break; + } + + control = ; + } return (
diff --git a/src/voice-broadcast/components/molecules/VoiceBroadcastRecordingPip.tsx b/src/voice-broadcast/components/molecules/VoiceBroadcastRecordingPip.tsx index c7604b7d900..7178f659650 100644 --- a/src/voice-broadcast/components/molecules/VoiceBroadcastRecordingPip.tsx +++ b/src/voice-broadcast/components/molecules/VoiceBroadcastRecordingPip.tsx @@ -17,11 +17,12 @@ limitations under the License. import React from "react"; import { - StopButton, + VoiceBroadcastControl, VoiceBroadcastRecording, } from "../.."; import { useVoiceBroadcastRecording } from "../../hooks/useVoiceBroadcastRecording"; import { VoiceBroadcastHeader } from "../atoms/VoiceBroadcastHeader"; +import { Icon as StopIcon } from "../../../../res/img/element-icons/Stop.svg"; interface VoiceBroadcastRecordingPipProps { recording: VoiceBroadcastRecording; @@ -45,7 +46,11 @@ export const VoiceBroadcastRecordingPip: React.FC
- +
; }; diff --git a/src/voice-broadcast/index.ts b/src/voice-broadcast/index.ts index 8f01c089c68..10329e224d4 100644 --- a/src/voice-broadcast/index.ts +++ b/src/voice-broadcast/index.ts @@ -26,8 +26,7 @@ export * from "./models/VoiceBroadcastRecording"; export * from "./audio/VoiceBroadcastRecorder"; export * from "./components/VoiceBroadcastBody"; export * from "./components/atoms/LiveBadge"; -export * from "./components/atoms/PlaybackControlButton"; -export * from "./components/atoms/StopButton"; +export * from "./components/atoms/VoiceBroadcastControl"; export * from "./components/atoms/VoiceBroadcastHeader"; export * from "./components/molecules/VoiceBroadcastPlaybackBody"; export * from "./components/molecules/VoiceBroadcastRecordingBody"; diff --git a/test/components/atoms/Icon-test.tsx b/test/components/atoms/Icon-test.tsx deleted file mode 100644 index 57e6e3990c4..00000000000 --- a/test/components/atoms/Icon-test.tsx +++ /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 React from "react"; -import { render } from "@testing-library/react"; - -import { Icon, IconColour, IconSize, IconType } from "../../../src/components/atoms/Icon"; - -describe("Icon", () => { - it.each([ - IconColour.Accent, - IconColour.LiveBadge, - ])("should render the colour %s", (colour: IconColour) => { - const { container } = render( - , - ); - expect(container).toMatchSnapshot(); - }); - - it.each([ - IconSize.S16, - ])("should render the size %s", (size: IconSize) => { - const { container } = render( - , - ); - expect(container).toMatchSnapshot(); - }); -}); diff --git a/test/components/atoms/__snapshots__/Icon-test.tsx.snap b/test/components/atoms/__snapshots__/Icon-test.tsx.snap deleted file mode 100644 index c30b4ba3323..00000000000 --- a/test/components/atoms/__snapshots__/Icon-test.tsx.snap +++ /dev/null @@ -1,34 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Icon should render the colour accent 1`] = ` -
-