diff --git a/CHANGELOG.md b/CHANGELOG.md index c27429be7f3..c6b8e22f013 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,35 @@ +Changes in [3.52.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.52.0) (2022-08-16) +===================================================================================================== + +## ✨ Features + * Device manager - New device tile info design ([\#9122](https://github.com/matrix-org/matrix-react-sdk/pull/9122)). Contributed by @kerryarchibald. + * Device manager generic settings subsection component ([\#9147](https://github.com/matrix-org/matrix-react-sdk/pull/9147)). Contributed by @kerryarchibald. + * Migrate the hidden read receipts flag to new "send read receipts" option ([\#9141](https://github.com/matrix-org/matrix-react-sdk/pull/9141)). + * Live location sharing - share location at most every 5 seconds ([\#9148](https://github.com/matrix-org/matrix-react-sdk/pull/9148)). Contributed by @kerryarchibald. + * Increase max length of voice messages to 15m ([\#9133](https://github.com/matrix-org/matrix-react-sdk/pull/9133)). Fixes vector-im/element-web#18620. + * Move pin drop out of labs ([\#9135](https://github.com/matrix-org/matrix-react-sdk/pull/9135)). + * Start DM on first message ([\#8612](https://github.com/matrix-org/matrix-react-sdk/pull/8612)). Fixes vector-im/element-web#14736. + * Remove "Add Space" button from RoomListHeader when user cannot create spaces ([\#9129](https://github.com/matrix-org/matrix-react-sdk/pull/9129)). + * The Welcome Home Screen: Dedicated Download Apps Dialog ([\#9120](https://github.com/matrix-org/matrix-react-sdk/pull/9120)). Fixes vector-im/element-web#22921. Contributed by @justjanne. + * The Welcome Home Screen: "Submit Feedback" pane ([\#9090](https://github.com/matrix-org/matrix-react-sdk/pull/9090)). Fixes vector-im/element-web#22918. Contributed by @justjanne. + * New User Onboarding Task List ([\#9083](https://github.com/matrix-org/matrix-react-sdk/pull/9083)). Fixes vector-im/element-web#22919. Contributed by @justjanne. + * Add support for disabling spell checking ([\#8604](https://github.com/matrix-org/matrix-react-sdk/pull/8604)). Fixes vector-im/element-web#21901. + * Live location share - leave maximised map open when beacons expire ([\#9098](https://github.com/matrix-org/matrix-react-sdk/pull/9098)). Contributed by @kerryarchibald. + +## 🐛 Bug Fixes + * Some slash-commands (`/myroomnick`) have temporarily been disabled before the first message in a DM is sent. ([\#9193](https://github.com/matrix-org/matrix-react-sdk/pull/9193)). + * Use stable reference for active tab in tabbedView ([\#9145](https://github.com/matrix-org/matrix-react-sdk/pull/9145)). Contributed by @kerryarchibald. + * Fix pillification sometimes doubling up ([\#9152](https://github.com/matrix-org/matrix-react-sdk/pull/9152)). Fixes vector-im/element-web#23036. + * Fix composer padding ([\#9137](https://github.com/matrix-org/matrix-react-sdk/pull/9137)). Fixes vector-im/element-web#22992. + * Fix highlights not being applied to plaintext messages ([\#9126](https://github.com/matrix-org/matrix-react-sdk/pull/9126)). Fixes vector-im/element-web#22787. + * Fix dismissing edit composer when change was undone ([\#9109](https://github.com/matrix-org/matrix-react-sdk/pull/9109)). Fixes vector-im/element-web#22932. + * 1-to-1 DM rooms with bots now act like DM rooms instead of multi-user-rooms before ([\#9124](https://github.com/matrix-org/matrix-react-sdk/pull/9124)). Fixes vector-im/element-web#22894. + * Apply inline start padding to selected lines on modern layout only ([\#9006](https://github.com/matrix-org/matrix-react-sdk/pull/9006)). Fixes vector-im/element-web#22768. Contributed by @luixxiul. + * Peek into world-readable rooms from spotlight ([\#9115](https://github.com/matrix-org/matrix-react-sdk/pull/9115)). Fixes vector-im/element-web#22862. + * Use default styling on nested numbered lists due to MD being sensitive ([\#9110](https://github.com/matrix-org/matrix-react-sdk/pull/9110)). Fixes vector-im/element-web#22935. + * Fix replying using chat effect commands ([\#9101](https://github.com/matrix-org/matrix-react-sdk/pull/9101)). Fixes vector-im/element-web#22824. + * The first message in a DM can no longer be a sticker. This has been changed to avoid issues with the integration manager. ([\#9180](https://github.com/matrix-org/matrix-react-sdk/pull/9180)). + Changes in [3.51.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.51.0) (2022-08-02) ===================================================================================================== diff --git a/cypress/e2e/regression-tests/pills-click-in-app.spec.ts b/cypress/e2e/regression-tests/pills-click-in-app.spec.ts index 33c86cbf3ac..cebbc86ed8b 100644 --- a/cypress/e2e/regression-tests/pills-click-in-app.spec.ts +++ b/cypress/e2e/regression-tests/pills-click-in-app.spec.ts @@ -59,8 +59,9 @@ describe("Pills", () => { // find the pill in the timeline and click it cy.get(".mx_EventTile_body .mx_Pill").click(); + const localUrl = `/#/room/#${targetLocalpart}:`; // verify we landed at a sane place - cy.url().should("contain", `/#/room/#${targetLocalpart}:`); + cy.url().should("contain", localUrl); cy.wait(250); // let the room list settle @@ -69,7 +70,7 @@ describe("Pills", () => { cy.get(".mx_EventTile_body .mx_Pill .mx_Pill_linkText") .should("have.css", "pointer-events", "none") .click({ force: true }); // force is to ensure we bypass pointer-events - cy.url().should("contain", `https://matrix.to/#/#${targetLocalpart}:`); + cy.url().should("contain", localUrl); }); }); }); diff --git a/cypress/e2e/spaces/spaces.spec.ts b/cypress/e2e/spaces/spaces.spec.ts index 0a8212ab8dd..e7767de9421 100644 --- a/cypress/e2e/spaces/spaces.spec.ts +++ b/cypress/e2e/spaces/spaces.spec.ts @@ -237,4 +237,42 @@ describe("Spaces", () => { cy.contains(".mx_SpaceHierarchy_roomTile", "Gaming").should("exist"); }); }); + + it("should render subspaces in the space panel only when expanded", () => { + cy.injectAxe(); + + cy.createSpace({ + name: "Child Space", + initial_state: [], + }).then(spaceId => { + cy.createSpace({ + name: "Root Space", + initial_state: [ + spaceChildInitialState(spaceId), + ], + }).as("spaceId"); + }); + cy.get('.mx_SpacePanel .mx_SpaceButton[aria-label="Root Space"]').should("exist"); + cy.get('.mx_SpacePanel .mx_SpaceButton[aria-label="Child Space"]').should("not.exist"); + + const axeOptions = { + rules: { + // Disable this check as it triggers on nested roving tab index elements which are in practice fine + 'nested-interactive': { + enabled: false, + }, + }, + }; + cy.checkA11y(undefined, axeOptions); + cy.get(".mx_SpacePanel").percySnapshotElement("Space panel collapsed", { widths: [68] }); + + cy.get(".mx_SpaceButton_toggleCollapse").click({ force: true }); + cy.get(".mx_SpacePanel:not(.collapsed)").should("exist"); + + cy.contains(".mx_SpaceItem", "Root Space").should("exist") + .contains(".mx_SpaceItem", "Child Space").should("exist"); + + cy.checkA11y(undefined, axeOptions); + cy.get(".mx_SpacePanel").percySnapshotElement("Space panel expanded", { widths: [258] }); + }); }); diff --git a/cypress/e2e/spotlight/spotlight.spec.ts b/cypress/e2e/spotlight/spotlight.spec.ts index fee1e390713..63b3d00e1bc 100644 --- a/cypress/e2e/spotlight/spotlight.spec.ts +++ b/cypress/e2e/spotlight/spotlight.spec.ts @@ -114,6 +114,7 @@ Cypress.Commands.add("startDM", (name: string) => { cy.openSpotlightDialog().within(() => { cy.spotlightFilter(Filter.People); cy.spotlightSearch().clear().type(name); + cy.wait(1000); // wait for the dialog code to settle cy.get(".mx_Spinner").should("not.exist"); cy.spotlightResults().should("have.length", 1); cy.spotlightResults().eq(0).should("contain", name); @@ -216,6 +217,7 @@ describe("Spotlight", () => { it("should find joined rooms", () => { cy.openSpotlightDialog().within(() => { cy.spotlightSearch().clear().type(room1Name); + cy.wait(1000); // wait for the dialog code to settle cy.spotlightResults().should("have.length", 1); cy.spotlightResults().eq(0).should("contain", room1Name); cy.spotlightResults().eq(0).click(); @@ -229,6 +231,7 @@ describe("Spotlight", () => { cy.openSpotlightDialog().within(() => { cy.spotlightFilter(Filter.PublicRooms); cy.spotlightSearch().clear().type(room1Name); + cy.wait(1000); // wait for the dialog code to settle cy.spotlightResults().should("have.length", 1); cy.spotlightResults().eq(0).should("contain", room1Name); cy.spotlightResults().eq(0).should("contain", "View"); @@ -243,6 +246,7 @@ describe("Spotlight", () => { cy.openSpotlightDialog().within(() => { cy.spotlightFilter(Filter.PublicRooms); cy.spotlightSearch().clear().type(room2Name); + cy.wait(1000); // wait for the dialog code to settle cy.spotlightResults().should("have.length", 1); cy.spotlightResults().eq(0).should("contain", room2Name); cy.spotlightResults().eq(0).should("contain", "Join"); @@ -258,6 +262,7 @@ describe("Spotlight", () => { cy.openSpotlightDialog().within(() => { cy.spotlightFilter(Filter.PublicRooms); cy.spotlightSearch().clear().type(room3Name); + cy.wait(1000); // wait for the dialog code to settle cy.spotlightResults().should("have.length", 1); cy.spotlightResults().eq(0).should("contain", room3Name); cy.spotlightResults().eq(0).should("contain", "View"); @@ -296,6 +301,7 @@ describe("Spotlight", () => { cy.openSpotlightDialog().within(() => { cy.spotlightFilter(Filter.People); cy.spotlightSearch().clear().type(bot1Name); + cy.wait(1000); // wait for the dialog code to settle cy.spotlightResults().should("have.length", 1); cy.spotlightResults().eq(0).should("contain", bot1Name); cy.spotlightResults().eq(0).click(); @@ -308,6 +314,7 @@ describe("Spotlight", () => { cy.openSpotlightDialog().within(() => { cy.spotlightFilter(Filter.People); cy.spotlightSearch().clear().type(bot2Name); + cy.wait(1000); // wait for the dialog code to settle cy.spotlightResults().should("have.length", 1); cy.spotlightResults().eq(0).should("contain", bot2Name); cy.spotlightResults().eq(0).click(); @@ -324,6 +331,7 @@ describe("Spotlight", () => { cy.openSpotlightDialog().within(() => { cy.spotlightFilter(Filter.People); cy.spotlightSearch().clear().type(bot2Name); + cy.wait(1000); // wait for the dialog code to settle cy.spotlightResults().should("have.length", 1); cy.spotlightResults().eq(0).should("contain", bot2Name); cy.spotlightResults().eq(0).click(); @@ -352,6 +360,7 @@ describe("Spotlight", () => { cy.openSpotlightDialog().within(() => { cy.spotlightFilter(Filter.People); cy.spotlightSearch().clear().type(bot1.getUserId()); + cy.wait(1000); // wait for the dialog code to settle cy.spotlightResults().should("have.length", 2); cy.spotlightResults().eq(0).should("contain", `${bot1Name} and ${bot2Name}`); }); @@ -360,15 +369,37 @@ describe("Spotlight", () => { cy.openSpotlightDialog().within(() => { cy.spotlightFilter(Filter.People); cy.spotlightSearch().clear().type(bot2.getUserId()); + cy.wait(1000); // wait for the dialog code to settle cy.spotlightResults().should("have.length", 2); cy.spotlightResults().eq(0).should("contain", `${bot1Name} and ${bot2Name}`); }); }); + // Test against https://github.com/vector-im/element-web/issues/22851 + it("should show each person result only once", () => { + cy.openSpotlightDialog().within(() => { + cy.spotlightFilter(Filter.People); + + // 2 rounds of search to simulate the bug conditions. Specifically, the first search + // should have 1 result (not 2) and the second search should also have 1 result (instead + // of the super buggy 3 described by https://github.com/vector-im/element-web/issues/22851) + // + // We search for user ID to trigger the profile lookup within the dialog. + for (let i = 0; i < 2; i++) { + cy.log("Iteration: " + i); + cy.spotlightSearch().clear().type(bot1.getUserId()); + cy.wait(1000); // wait for the dialog code to settle + cy.spotlightResults().should("have.length", 1); + cy.spotlightResults().eq(0).should("contain", bot1.getUserId()); + } + }); + }); + it("should allow opening group chat dialog", () => { cy.openSpotlightDialog().within(() => { cy.spotlightFilter(Filter.People); cy.spotlightSearch().clear().type(bot2Name); + cy.wait(1000); // wait for the dialog code to settle cy.spotlightResults().should("have.length", 1); cy.spotlightResults().eq(0).should("contain", bot2Name); cy.get(".mx_SpotlightDialog_startGroupChat").should("contain", "Start a group chat"); @@ -390,6 +421,7 @@ describe("Spotlight", () => { cy.openSpotlightDialog().within(() => { cy.spotlightFilter(Filter.People); cy.spotlightSearch().clear().type(bot1Name); + cy.wait(1000); // wait for the dialog code to settle cy.get(".mx_Spinner").should("not.exist"); cy.spotlightResults().should("have.length", 1); }); diff --git a/cypress/e2e/timeline/timeline.spec.ts b/cypress/e2e/timeline/timeline.spec.ts index 6eacacfed23..94b6ffaa425 100644 --- a/cypress/e2e/timeline/timeline.spec.ts +++ b/cypress/e2e/timeline/timeline.spec.ts @@ -155,7 +155,7 @@ describe("Timeline", () => { cy.visit("/#/room/" + roomId); cy.setSettingValue("layout", null, SettingLevel.DEVICE, Layout.IRC); cy.contains(".mx_RoomView_body .mx_GenericEventListSummary[data-layout=irc] " + - ".mx_GenericEventListSummary_summary", "created and configured the room."); + ".mx_GenericEventListSummary_summary", "created and configured the room.").should("exist"); cy.get(".mx_Spinner").should("not.exist"); cy.percySnapshot("Configured room on IRC layout"); }); @@ -166,7 +166,7 @@ describe("Timeline", () => { // Wait until configuration is finished cy.contains(".mx_RoomView_body .mx_GenericEventListSummary " + - ".mx_GenericEventListSummary_summary", "created and configured the room."); + ".mx_GenericEventListSummary_summary", "created and configured the room.").should("exist"); // Click "expand" link button cy.get(".mx_GenericEventListSummary_toggle[aria-expanded=false]").click(); @@ -193,14 +193,14 @@ describe("Timeline", () => { cy.visit("/#/room/" + roomId); cy.setSettingValue("showHiddenEventsInTimeline", null, SettingLevel.DEVICE, true); cy.contains(".mx_RoomView_body .mx_GenericEventListSummary .mx_GenericEventListSummary_summary", - "created and configured the room."); + "created and configured the room.").should("exist"); // Edit message cy.contains(".mx_RoomView_body .mx_EventTile .mx_EventTile_line", "Message").within(() => { cy.get('[aria-label="Edit"]').click({ force: true }); // Cypress has no ability to hover cy.get(".mx_BasicMessageComposer_input").type("Edit{enter}"); }); - cy.get(".mx_RoomView_body .mx_EventTile").contains(".mx_EventTile[data-scroll-tokens]", "MessageEdit"); + cy.contains(".mx_EventTile[data-scroll-tokens]", "MessageEdit").should("exist"); // Click timestamp to highlight hidden event line cy.get(".mx_RoomView_body .mx_EventTile_info .mx_MessageTimestamp").click(); @@ -228,18 +228,19 @@ describe("Timeline", () => { cy.visit("/#/room/" + roomId); cy.setSettingValue("showHiddenEventsInTimeline", null, SettingLevel.DEVICE, true); cy.contains(".mx_RoomView_body .mx_GenericEventListSummary " + - ".mx_GenericEventListSummary_summary", "created and configured the room."); + ".mx_GenericEventListSummary_summary", "created and configured the room.").should("exist"); // Edit message cy.contains(".mx_RoomView_body .mx_EventTile .mx_EventTile_line", "Message").within(() => { cy.get('[aria-label="Edit"]').click({ force: true }); // Cypress has no ability to hover cy.get(".mx_BasicMessageComposer_input").type("Edit{enter}"); }); - cy.contains(".mx_RoomView_body .mx_EventTile[data-scroll-tokens]", "MessageEdit"); + cy.contains(".mx_RoomView_body .mx_EventTile[data-scroll-tokens]", "MessageEdit").should("exist"); // Click top left of the event toggle, which should not be covered by MessageActionBar's safe area - cy.get(".mx_EventTile .mx_ViewSourceEvent").realHover() - .get(".mx_EventTile .mx_ViewSourceEvent .mx_ViewSourceEvent_toggle").click('topLeft', { force: false }); + cy.get(".mx_EventTile .mx_ViewSourceEvent").should("exist").realHover().within(() => { + cy.get(".mx_ViewSourceEvent_toggle").click('topLeft', { force: false }); + }); // Make sure the expand toggle worked cy.get(".mx_EventTile .mx_ViewSourceEvent_expanded .mx_ViewSourceEvent_toggle").should("be.visible"); @@ -249,17 +250,17 @@ describe("Timeline", () => { cy.visit("/#/room/" + roomId); cy.setSettingValue("layout", null, SettingLevel.DEVICE, Layout.Bubble); cy.contains(".mx_RoomView_body .mx_GenericEventListSummary[data-layout=bubble] " + - ".mx_GenericEventListSummary_summary", "created and configured the room."); + ".mx_GenericEventListSummary_summary", "created and configured the room.").should("exist"); // Click "expand" link button cy.get(".mx_GenericEventListSummary_toggle[aria-expanded=false]").click(); // Click "collapse" link button on the first hovered info event line - cy.get(".mx_GenericEventListSummary_unstyledList .mx_EventTile_info:first-of-type").realHover() - .get(".mx_GenericEventListSummary_toggle[aria-expanded=true]").click({ force: false }); + cy.get(".mx_GenericEventListSummary_unstyledList .mx_EventTile_info:first-of-type").realHover(); + cy.get(".mx_GenericEventListSummary_toggle[aria-expanded=true]").click({ force: false }); // Make sure "collapse" link button worked - cy.get(".mx_GenericEventListSummary_toggle[aria-expanded=false]"); + cy.get(".mx_GenericEventListSummary_toggle[aria-expanded=false]").should("exist"); }); it("should highlight search result words regardless of formatting", () => { @@ -273,6 +274,49 @@ describe("Timeline", () => { cy.get(".mx_EventTile:not(.mx_EventTile_contextual)").find(".mx_EventTile_searchHighlight").should("exist"); cy.get(".mx_RoomView_searchResultsPanel").percySnapshotElement("Highlighted search results"); }); + + it("should render url previews", () => { + cy.intercept("**/_matrix/media/r0/thumbnail/matrix.org/2022-08-16_yaiSVSRIsNFfxDnV?*", { + statusCode: 200, + fixture: "riot.png", + headers: { + "Content-Type": "image/png", + }, + }).as("mxc"); + cy.intercept("**/_matrix/media/r0/preview_url?url=https%3A%2F%2Fcall.element.io%2F&ts=*", { + statusCode: 200, + body: { + "og:title": "Element Call", + "og:description": null, + "og:image:width": 48, + "og:image:height": 48, + "og:image": "mxc://matrix.org/2022-08-16_yaiSVSRIsNFfxDnV", + "og:image:type": "image/png", + "matrix:image:size": 2121, + }, + headers: { + "Content-Type": "application/json", + }, + }).as("preview_url"); + + cy.sendEvent( + roomId, + null, + "m.room.message" as EventType, + MessageEvent.from("https://call.element.io/").serialize().content, + ); + cy.visit("/#/room/" + roomId); + + cy.get(".mx_LinkPreviewWidget").should("exist").should("contain.text", "Element Call"); + + cy.wait("@preview_url"); + cy.wait("@mxc"); + + cy.checkA11y(); + cy.get(".mx_EventTile_last").percySnapshotElement("URL Preview", { + widths: [800, 400], + }); + }); }); describe("message sending", () => { @@ -285,7 +329,7 @@ describe("Timeline", () => { cy.getComposer().type(`${MESSAGE}{enter}`); // Reply to the message - cy.get(".mx_RoomView_body .mx_EventTile").contains(".mx_EventTile_line", "Hello world").within(() => { + cy.get(".mx_RoomView_body").contains(".mx_EventTile_line", "Hello world").within(() => { cy.get('[aria-label="Reply"]').click({ force: true }); // Cypress has no ability to hover }); }; @@ -296,20 +340,22 @@ describe("Timeline", () => { cy.getComposer().type(`${reply}{enter}`); - cy.get(".mx_RoomView_body .mx_EventTile .mx_EventTile_line").find(".mx_ReplyTile .mx_MTextBody") + cy.get(".mx_RoomView_body .mx_EventTile .mx_EventTile_line .mx_ReplyTile .mx_MTextBody") .should("contain", MESSAGE); - cy.get(".mx_RoomView_body .mx_EventTile > .mx_EventTile_line > .mx_MTextBody").contains(reply) + cy.contains(".mx_RoomView_body .mx_EventTile > .mx_EventTile_line > .mx_MTextBody", reply) .should("have.length", 1); }); - xit("can reply with a voice message", () => { + it("can reply with a voice message", () => { viewRoomSendMessageAndSetupReply(); - cy.openMessageComposerOptions().find(`[aria-label="Voice Message"]`).click(); + cy.openMessageComposerOptions().within(() => { + cy.get(`[aria-label="Voice Message"]`).click(); + }); cy.wait(3000); - cy.getComposer().find(".mx_MessageComposer_sendMessage").click(); + cy.get(".mx_RoomView_body .mx_MessageComposer .mx_MessageComposer_sendMessage").click(); - cy.get(".mx_RoomView_body .mx_EventTile .mx_EventTile_line").find(".mx_ReplyTile .mx_MTextBody") + cy.get(".mx_RoomView_body .mx_EventTile .mx_EventTile_line .mx_ReplyTile .mx_MTextBody") .should("contain", MESSAGE); cy.get(".mx_RoomView_body .mx_EventTile > .mx_EventTile_line > .mx_MVoiceMessageBody") .should("have.length", 1); diff --git a/cypress/e2e/user-onboarding/user-onboarding-new.ts b/cypress/e2e/user-onboarding/user-onboarding-new.ts index 44787ee61e8..cef27803edf 100644 --- a/cypress/e2e/user-onboarding/user-onboarding-new.ts +++ b/cypress/e2e/user-onboarding/user-onboarding-new.ts @@ -40,6 +40,13 @@ describe("User Onboarding (new user)", () => { bot1 = _bot1; }); cy.get('.mx_UserOnboardingPage').should('exist'); + cy.get('.mx_UserOnboardingButton').should('exist'); + cy.get('.mx_UserOnboardingList') + .should('exist') + .should(($list) => { + const list = $list.get(0); + expect(getComputedStyle(list).opacity).to.be.eq("1"); + }); }); }); @@ -47,20 +54,14 @@ describe("User Onboarding (new user)", () => { cy.stopSynapse(synapse); }); - it("page is shown", () => { - cy.get('.mx_UserOnboardingPage').should('exist'); - cy.get('.mx_UserOnboardingList') - .should('exist') - .should(($list) => { - const list = $list.get(0); - expect(getComputedStyle(list).opacity).to.be.eq("1"); - }); + it("page is shown and preference exists", () => { cy.get('.mx_UserOnboardingPage') .percySnapshotElement("User onboarding page"); + cy.openUserSettings("Preferences"); + cy.contains("Show shortcut to welcome checklist above the room list").should("exist"); }); it("app download dialog", () => { - cy.get('.mx_UserOnboardingPage').should('exist'); cy.contains(".mx_UserOnboardingTask_action", "Download apps").click(); cy.get('[role=dialog]') .contains("#mx_BaseDialog_title", "Download Element") @@ -79,8 +80,7 @@ describe("User Onboarding (new user)", () => { cy.get(".mx_InviteDialog_editor input").type(bot1.getUserId()); cy.get(".mx_InviteDialog_buttonAndSpinner").click(); cy.get(".mx_InviteDialog_buttonAndSpinner").should("not.exist"); - cy.visit("/#/home"); - + cy.get(".mx_SendMessageComposer").type("Hi!{enter}"); cy.get(".mx_ProgressBar").invoke("val").should("be.greaterThan", oldProgress); }); }); diff --git a/cypress/e2e/user-onboarding/user-onboarding-old.ts b/cypress/e2e/user-onboarding/user-onboarding-old.ts index 2be066e0a1c..f079ed9a4c3 100644 --- a/cypress/e2e/user-onboarding/user-onboarding-old.ts +++ b/cypress/e2e/user-onboarding/user-onboarding-old.ts @@ -40,7 +40,10 @@ describe("User Onboarding (old user)", () => { cy.stopSynapse(synapse); }); - it("page is hidden", () => { + it("page and preference are hidden", () => { cy.get('.mx_UserOnboardingPage').should('not.exist'); + cy.get('.mx_UserOnboardingButton').should('not.exist'); + cy.openUserSettings("Preferences"); + cy.contains("Show shortcut to welcome page above the room list").should("not.exist"); }); }); diff --git a/cypress/plugins/docker/index.ts b/cypress/plugins/docker/index.ts index 2f3c6464083..98f8c2584db 100644 --- a/cypress/plugins/docker/index.ts +++ b/cypress/plugins/docker/index.ts @@ -61,9 +61,14 @@ export function dockerExec(args: { childProcess.execFile("docker", [ "exec", args.containerId, ...args.params, - ], { encoding: 'utf8' }, err => { - if (err) reject(err); - else resolve(); + ], { encoding: 'utf8' }, (err, stdout, stderr) => { + if (err) { + console.log(stdout); + console.log(stderr); + reject(err); + return; + } + resolve(); }); }); } diff --git a/cypress/plugins/synapsedocker/templates/consent/log.config b/cypress/plugins/synapsedocker/templates/consent/log.config index ac232762da3..b9123d0f5b9 100644 --- a/cypress/plugins/synapsedocker/templates/consent/log.config +++ b/cypress/plugins/synapsedocker/templates/consent/log.config @@ -26,7 +26,7 @@ loggers: synapse.storage.SQL: # beware: increasing this to DEBUG will make synapse log sensitive # information such as access tokens. - level: INFO + level: DEBUG twisted: # We send the twisted logging directly to the file handler, @@ -36,7 +36,7 @@ loggers: propagate: false root: - level: INFO + level: DEBUG # Write logs to the `buffer` handler, which will buffer them together in memory, # then write them to a file. diff --git a/cypress/plugins/synapsedocker/templates/default/homeserver.yaml b/cypress/plugins/synapsedocker/templates/default/homeserver.yaml index 842009bcae4..347dadc88f4 100644 --- a/cypress/plugins/synapsedocker/templates/default/homeserver.yaml +++ b/cypress/plugins/synapsedocker/templates/default/homeserver.yaml @@ -22,8 +22,29 @@ log_config: "/data/log.config" rc_messages_per_second: 10000 rc_message_burst_count: 10000 rc_registration: - per_second: 10000 - burst_count: 10000 + per_second: 10000 + burst_count: 10000 +rc_joins: + local: + per_second: 9999 + burst_count: 9999 + remote: + per_second: 9999 + burst_count: 9999 +rc_joins_per_room: + per_second: 9999 + burst_count: 9999 +rc_3pid_validation: + per_second: 1000 + burst_count: 1000 + +rc_invites: + per_room: + per_second: 1000 + burst_count: 1000 + per_user: + per_second: 1000 + burst_count: 1000 rc_login: address: diff --git a/cypress/plugins/synapsedocker/templates/default/log.config b/cypress/plugins/synapsedocker/templates/default/log.config index ac232762da3..b9123d0f5b9 100644 --- a/cypress/plugins/synapsedocker/templates/default/log.config +++ b/cypress/plugins/synapsedocker/templates/default/log.config @@ -26,7 +26,7 @@ loggers: synapse.storage.SQL: # beware: increasing this to DEBUG will make synapse log sensitive # information such as access tokens. - level: INFO + level: DEBUG twisted: # We send the twisted logging directly to the file handler, @@ -36,7 +36,7 @@ loggers: propagate: false root: - level: INFO + level: DEBUG # Write logs to the `buffer` handler, which will buffer them together in memory, # then write them to a file. diff --git a/cypress/support/settings.ts b/cypress/support/settings.ts index 06ec815364b..ec07df93aa1 100644 --- a/cypress/support/settings.ts +++ b/cypress/support/settings.ts @@ -82,7 +82,7 @@ declare global { * @param {*} value The new value of the setting, may be null. * @return {Promise} Resolves when the setting has been changed. */ - setSettingValue(name: string, roomId: string, level: SettingLevel, value: any): Chainable; + setSettingValue(settingName: string, roomId: string, level: SettingLevel, value: any): Chainable; /** * Gets the value of a setting. The room ID is optional if the @@ -96,7 +96,7 @@ declare global { * value. * @return {*} The value, or null if not found */ - getSettingValue(name: string, roomId?: string, excludeDefault?: boolean): Chainable; + getSettingValue(settingName: string, roomId?: string, excludeDefault?: boolean): Chainable; } } } diff --git a/package.json b/package.json index 1973a2f5b58..a34cc60ad8a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-react-sdk", - "version": "3.51.0", + "version": "3.52.0", "description": "SDK for matrix.org using React", "author": "matrix.org", "repository": { @@ -94,7 +94,7 @@ "matrix-encrypt-attachment": "^1.0.3", "matrix-events-sdk": "^0.0.1-beta.7", "matrix-js-sdk": "github:matrix-org/matrix-js-sdk#develop", - "matrix-widget-api": "^0.1.0-beta.18", + "matrix-widget-api": "^1.0.0", "minimist": "^1.2.5", "opus-recorder": "^8.0.3", "pako": "^2.0.3", @@ -150,6 +150,7 @@ "@types/flux": "^3.1.9", "@types/fs-extra": "^9.0.13", "@types/jest": "^26.0.20", + "@types/katex": "^0.14.0", "@types/lodash": "^4.14.168", "@types/modernizr": "^3.5.3", "@types/node": "^14.14.22", diff --git a/res/css/_components.pcss b/res/css/_components.pcss index fe23a1c3882..3f23e6de572 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -27,7 +27,14 @@ @import "./components/views/location/_ZoomButtons.pcss"; @import "./components/views/messages/_MBeaconBody.pcss"; @import "./components/views/messages/shared/_MediaProcessingError.pcss"; +@import "./components/views/settings/devices/_DeviceDetails.pcss"; +@import "./components/views/settings/devices/_DeviceExpandDetailsButton.pcss"; +@import "./components/views/settings/devices/_DeviceSecurityCard.pcss"; @import "./components/views/settings/devices/_DeviceTile.pcss"; +@import "./components/views/settings/devices/_FilteredDeviceList.pcss"; +@import "./components/views/settings/devices/_SecurityRecommendations.pcss"; +@import "./components/views/settings/devices/_SelectableDeviceTile.pcss"; +@import "./components/views/settings/shared/_SettingsSubsection.pcss"; @import "./components/views/spaces/_QuickThemeSwitcher.pcss"; @import "./structures/_AutoHideScrollbar.pcss"; @import "./structures/_BackdropPanel.pcss"; @@ -327,6 +334,7 @@ @import "./views/toasts/_IncomingCallToast.pcss"; @import "./views/toasts/_NonUrgentEchoFailureToast.pcss"; @import "./views/typography/_Heading.pcss"; +@import "./views/user-onboarding/_UserOnboardingButton.pcss"; @import "./views/user-onboarding/_UserOnboardingFeedback.pcss"; @import "./views/user-onboarding/_UserOnboardingHeader.pcss"; @import "./views/user-onboarding/_UserOnboardingList.pcss"; diff --git a/res/css/components/views/settings/devices/_DeviceDetails.pcss b/res/css/components/views/settings/devices/_DeviceDetails.pcss new file mode 100644 index 00000000000..3017935bb7b --- /dev/null +++ b/res/css/components/views/settings/devices/_DeviceDetails.pcss @@ -0,0 +1,74 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_DeviceDetails { + display: flex; + flex-direction: column; + box-sizing: border-box; + + width: 100%; + + margin-top: $spacing-16; + padding: $spacing-16; + border-radius: 8px; + border: 1px solid $quinary-content; +} + +.mx_DeviceDetails_section { + padding-bottom: $spacing-16; + margin-bottom: $spacing-16; + border-bottom: 1px solid $quinary-content; + + display: grid; + grid-gap: $spacing-16; + + &:last-child { + padding-bottom: 0; + border-bottom: 0; + margin-bottom: 0; + } +} + +.mx_DeviceDetails_sectionHeading { + margin: 0; +} + +.mxDeviceDetails_metadataTable { + font-size: $font-12px; + color: $secondary-content; + + width: 100%; + + border-spacing: 0; + + th { + text-transform: uppercase; + font-weight: normal; + text-align: left; + } + + td { + padding-top: $spacing-8; + } + + .mxDeviceDetails_metadataLabel { + width: 160px; + } + + .mxDeviceDetails_metadataValue { + color: $primary-content; + } +} diff --git a/res/css/components/views/settings/devices/_DeviceExpandDetailsButton.pcss b/res/css/components/views/settings/devices/_DeviceExpandDetailsButton.pcss new file mode 100644 index 00000000000..4c9d787fdbe --- /dev/null +++ b/res/css/components/views/settings/devices/_DeviceExpandDetailsButton.pcss @@ -0,0 +1,41 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_DeviceExpandDetailsButton { + height: 32px; + width: 32px; + background: transparent; + + border-radius: 4px; + color: $secondary-content; + + --icon-transform: rotate(-90deg); +} + +.mx_DeviceExpandDetailsButton.mx_DeviceExpandDetailsButton_expanded { + --icon-transform: rotate(0deg); + + background: $system; +} + +.mx_DeviceExpandDetailsButton_icon { + height: 12px; + width: 12px; + + transition: all 0.3s; + transform: var(--icon-transform); + transform-origin: center; +} diff --git a/res/css/components/views/settings/devices/_DeviceSecurityCard.pcss b/res/css/components/views/settings/devices/_DeviceSecurityCard.pcss new file mode 100644 index 00000000000..2c267b43144 --- /dev/null +++ b/res/css/components/views/settings/devices/_DeviceSecurityCard.pcss @@ -0,0 +1,70 @@ +/* +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_DeviceSecurityCard { + width: 100%; + display: flex; + flex-direction: row; + align-items: flex-start; + box-sizing: border-box; + + padding: $spacing-16; + + border: 1px solid $quinary-content; + border-radius: 8px; +} + +.mx_DeviceSecurityCard_icon { + flex: 0 0 40px; + display: flex; + align-items: center; + justify-content: center; + margin-right: $spacing-16; + border-radius: 8px; + + height: 40px; + width: 40px; + + color: var(--icon-color); + background-color: var(--background-color); + + &.Verified { + --icon-color: $e2e-verified-color; + --background-color: $e2e-verified-color-light; + } + + &.Unverified { + --icon-color: $e2e-warning-color; + --background-color: $e2e-warning-color-light; + } + + &.Inactive { + --icon-color: $secondary-content; + --background-color: $system; + } +} + +.mx_DeviceSecurityCard_content { + flex: 1 1; +} +.mx_DeviceSecurityCard_heading { + margin: 0 0 $spacing-4 0; +} +.mx_DeviceSecurityCard_description { + margin: 0; + font-size: $font-12px; + color: $secondary-content; +} diff --git a/res/css/components/views/settings/devices/_DeviceTile.pcss b/res/css/components/views/settings/devices/_DeviceTile.pcss index 159cace6ac0..d89fd9c76eb 100644 --- a/res/css/components/views/settings/devices/_DeviceTile.pcss +++ b/res/css/components/views/settings/devices/_DeviceTile.pcss @@ -18,7 +18,6 @@ limitations under the License. display: flex; flex-direction: row; align-items: center; - width: 100%; } @@ -27,15 +26,21 @@ limitations under the License. } .mx_DeviceTile_metadata { - margin-top: 2px; + margin-top: $spacing-4; font-size: $font-12px; color: $secondary-content; + line-height: $font-14px; +} + +.mx_DeviceTile_inactiveIcon { + height: 14px; + margin-right: $spacing-8; + vertical-align: middle; } .mx_DeviceTile_actions { display: grid; grid-gap: $spacing-8; grid-auto-flow: column; - margin-left: $spacing-8; } diff --git a/res/css/components/views/settings/devices/_FilteredDeviceList.pcss b/res/css/components/views/settings/devices/_FilteredDeviceList.pcss new file mode 100644 index 00000000000..01c8df787ef --- /dev/null +++ b/res/css/components/views/settings/devices/_FilteredDeviceList.pcss @@ -0,0 +1,64 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_FilteredDeviceList { + .mx_Dropdown { + flex: 1 0 80px; + } +} + +.mx_FilteredDeviceList_header { + display: flex; + flex-direction: row; + align-items: center; + box-sizing: border-box; + + width: 100%; + height: 48px; + padding: 0 $spacing-16; + margin-bottom: $spacing-32; + + background-color: $system; + border-radius: 8px; + color: $secondary-content; +} + +.mx_FilteredDeviceList_headerLabel { + flex: 1 1 100%; +} + +.mx_FilteredDeviceList_list { + list-style-type: none; + display: grid; + grid-gap: $spacing-16; + margin: 0; + padding: 0 $spacing-8; +} + +.mx_FilteredDeviceList_listItem { + display: flex; + flex-direction: column; +} + +.mx_FilteredDeviceList_securityCard { + margin-bottom: $spacing-32; +} + +.mx_FilteredDeviceList_noResults { + width: 100%; + text-align: center; + margin-bottom: $spacing-32; +} diff --git a/res/css/components/views/settings/devices/_SecurityRecommendations.pcss b/res/css/components/views/settings/devices/_SecurityRecommendations.pcss new file mode 100644 index 00000000000..d0a53335590 --- /dev/null +++ b/res/css/components/views/settings/devices/_SecurityRecommendations.pcss @@ -0,0 +1,19 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_SecurityRecommendations_spacing { + height: $spacing-16; +} diff --git a/res/css/components/views/settings/devices/_SelectableDeviceTile.pcss b/res/css/components/views/settings/devices/_SelectableDeviceTile.pcss new file mode 100644 index 00000000000..5d6a497e02c --- /dev/null +++ b/res/css/components/views/settings/devices/_SelectableDeviceTile.pcss @@ -0,0 +1,28 @@ +/* +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_SelectableDeviceTile { + display: flex; + flex-direction: row; + align-items: center; + width: 100%; + cursor: pointer; +} + +.mx_SelectableDeviceTile_checkbox { + flex: 0 0; + margin-right: $spacing-16; +} diff --git a/res/css/components/views/settings/shared/_SettingsSubsection.pcss b/res/css/components/views/settings/shared/_SettingsSubsection.pcss new file mode 100644 index 00000000000..9eb51696bab --- /dev/null +++ b/res/css/components/views/settings/shared/_SettingsSubsection.pcss @@ -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. +*/ + +.mx_SettingsSubsection { + width: 100%; + box-sizing: border-box; +} + +.mx_SettingsSubsection_heading { + padding-bottom: $spacing-8; +} + +.mx_SettingsSubsection_description { + width: 100%; + box-sizing: inherit; + line-height: $font-24px; + margin-bottom: $spacing-32; + color: $secondary-content; +} + +.mx_SettingsSubsection_content { + width: 100%; +} diff --git a/res/css/structures/_HomePage.pcss b/res/css/structures/_HomePage.pcss index 6bfabd9c87f..f35de9919ce 100644 --- a/res/css/structures/_HomePage.pcss +++ b/res/css/structures/_HomePage.pcss @@ -37,15 +37,15 @@ limitations under the License. } h1 { - font-weight: 600; + font-weight: $font-semi-bold; font-size: $font-32px; line-height: $font-44px; margin-bottom: 4px; } - h4 { + h2 { margin-top: 4px; - font-weight: 600; + font-weight: $font-semi-bold; font-size: $font-18px; line-height: $font-25px; color: $muted-fg-color; diff --git a/res/css/structures/_SpacePanel.pcss b/res/css/structures/_SpacePanel.pcss index 7fdb2500b45..e62a6c90a1b 100644 --- a/res/css/structures/_SpacePanel.pcss +++ b/res/css/structures/_SpacePanel.pcss @@ -78,10 +78,6 @@ $activeBorderColor: $primary-content; margin: 0; list-style: none; padding: 0; - - > .mx_SpaceItem { - padding-left: 16px; - } } .mx_SpaceButton_toggleCollapse { @@ -290,6 +286,11 @@ $activeBorderColor: $primary-content; visibility: hidden; } } + + .mx_SpaceTreeLevel { + // Indent subspaces + padding-left: 16px; + } } .mx_SpaceButton_avatarWrapper { @@ -378,11 +379,16 @@ $activeBorderColor: $primary-content; } .mx_SpacePanel_contextMenu { + max-width: 360px; + .mx_SpacePanel_contextMenu_header { margin: 12px 16px 12px; font-weight: $font-semi-bold; font-size: $font-15px; line-height: $font-18px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .mx_SpacePanel_iconHome::before { diff --git a/res/css/structures/_SpaceRoomView.pcss b/res/css/structures/_SpaceRoomView.pcss index b39f57cbb01..2664549b170 100644 --- a/res/css/structures/_SpaceRoomView.pcss +++ b/res/css/structures/_SpaceRoomView.pcss @@ -177,6 +177,10 @@ $SpaceRoomViewInnerWidth: 428px; h1 { display: inline-block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + width: 100%; } } diff --git a/res/css/views/elements/_AccessibleButton.pcss b/res/css/views/elements/_AccessibleButton.pcss index 7d01c17e125..8718d862337 100644 --- a/res/css/views/elements/_AccessibleButton.pcss +++ b/res/css/views/elements/_AccessibleButton.pcss @@ -76,6 +76,12 @@ limitations under the License. mask-image: url('$(res)/img/feather-customised/x.svg'); } } + + &.mx_AccessibleButton_kind_icon { + padding: 0; + height: 32px; + width: 32px; + } } &.mx_AccessibleButton_kind_primary, diff --git a/res/css/views/elements/_LabelledCheckbox.pcss b/res/css/views/elements/_LabelledCheckbox.pcss index d280d27ebae..8545c6747b4 100644 --- a/res/css/views/elements/_LabelledCheckbox.pcss +++ b/res/css/views/elements/_LabelledCheckbox.pcss @@ -16,6 +16,7 @@ limitations under the License. .mx_LabelledCheckbox { display: flex; + gap: 8px; flex-direction: row; .mx_Checkbox { diff --git a/res/css/views/messages/_DisambiguatedProfile.pcss b/res/css/views/messages/_DisambiguatedProfile.pcss index ef4bc7cfb30..4863dc3518c 100644 --- a/res/css/views/messages/_DisambiguatedProfile.pcss +++ b/res/css/views/messages/_DisambiguatedProfile.pcss @@ -34,3 +34,10 @@ limitations under the License. color: $primary-content; } } + +@media only percy { + .mx_DisambiguatedProfile_displayName { + /* Override the colour in percy tests for screenshot consistency */ + color: $username-variant1-color !important; + } +} diff --git a/res/css/views/rooms/_LinkPreviewWidget.pcss b/res/css/views/rooms/_LinkPreviewWidget.pcss index ceb715275db..7949233a9f4 100644 --- a/res/css/views/rooms/_LinkPreviewWidget.pcss +++ b/res/css/views/rooms/_LinkPreviewWidget.pcss @@ -32,6 +32,7 @@ limitations under the License. display: flex; flex-wrap: wrap; row-gap: $spacing-8; + flex: 1; .mx_LinkPreviewWidget_image, .mx_LinkPreviewWidget_caption { diff --git a/res/css/views/settings/_DevicesPanel.pcss b/res/css/views/settings/_DevicesPanel.pcss index 9cbdb6a2a1b..8581225cee8 100644 --- a/res/css/views/settings/_DevicesPanel.pcss +++ b/res/css/views/settings/_DevicesPanel.pcss @@ -56,10 +56,12 @@ limitations under the License. align-items: flex-start; margin-block: 10px; min-height: 35px; + padding: 0 $spacing-8; } -.mx_DevicesPanel_icon, .mx_DevicesPanel_checkbox { - margin-left: 9px; +.mx_DevicesPanel_icon { + margin-left: 0px; + margin-right: $spacing-16; margin-top: 2px; } diff --git a/res/css/views/settings/tabs/_SettingsTab.pcss b/res/css/views/settings/tabs/_SettingsTab.pcss index 8b4d17d8e97..544b5c623b2 100644 --- a/res/css/views/settings/tabs/_SettingsTab.pcss +++ b/res/css/views/settings/tabs/_SettingsTab.pcss @@ -103,5 +103,5 @@ limitations under the License. grid-template-columns: 1fr; grid-gap: $spacing-32; - padding: 0 $spacing-16; + padding: $spacing-16 0; } diff --git a/res/css/views/user-onboarding/_UserOnboardingButton.pcss b/res/css/views/user-onboarding/_UserOnboardingButton.pcss new file mode 100644 index 00000000000..3eba86045ac --- /dev/null +++ b/res/css/views/user-onboarding/_UserOnboardingButton.pcss @@ -0,0 +1,84 @@ +/* +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_UserOnboardingButton { + display: flex; + flex-direction: column; + align-content: stretch; + align-items: stretch; + border-radius: 8px; + margin: $spacing-8 $spacing-8 0; + padding: $spacing-12; + + &.mx_UserOnboardingButton_selected, + &:hover, + &:focus-within { + background-color: $panel-actions; + } + + .mx_UserOnboardingButton_content { + display: flex; + flex-direction: row; + gap: 5px; + align-items: center; + + .mx_Heading_h4 { + margin-right: auto; + font-size: $font-14px; + color: $primary-content; + } + + .mx_UserOnboardingButton_percentage { + font-size: $font-12px; + color: $secondary-content; + } + + .mx_UserOnboardingButton_close { + position: relative; + box-sizing: border-box; + width: 14px; + height: 14px; + border-radius: 7px; + border: 1px solid $secondary-content; + flex-shrink: 0; + + &::before { + background-color: $secondary-content; + content: ""; + mask-repeat: no-repeat; + mask-position: center; + mask-size: contain; + width: 7px; + height: 7px; + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + mask-image: url("$(res)/img/element-icons/cancel-rounded.svg"); + } + } + } + + .mx_ProgressBar { + width: auto; + margin-top: $spacing-8; + background: $background; + } + + &.mx_UserOnboardingButton_completed .mx_ProgressBar { + display: none; + } +} diff --git a/res/img/e2e/verified-deprecated.svg b/res/img/e2e/verified-deprecated.svg new file mode 100644 index 00000000000..f90d9db554c --- /dev/null +++ b/res/img/e2e/verified-deprecated.svg @@ -0,0 +1,3 @@ + + + diff --git a/res/img/e2e/verified.svg b/res/img/e2e/verified.svg index f90d9db554c..9213d2b05d9 100644 --- a/res/img/e2e/verified.svg +++ b/res/img/e2e/verified.svg @@ -1,3 +1,3 @@ - + diff --git a/res/img/e2e/warning-deprecated.svg b/res/img/e2e/warning-deprecated.svg new file mode 100644 index 00000000000..58f5c3b7d1c --- /dev/null +++ b/res/img/e2e/warning-deprecated.svg @@ -0,0 +1,3 @@ + + + diff --git a/res/img/e2e/warning.svg b/res/img/e2e/warning.svg index 58f5c3b7d1c..1acbb53bb71 100644 --- a/res/img/e2e/warning.svg +++ b/res/img/e2e/warning.svg @@ -1,3 +1,3 @@ - + diff --git a/res/img/element-icons/settings/inactive.svg b/res/img/element-icons/settings/inactive.svg new file mode 100644 index 00000000000..63b6b97bd59 --- /dev/null +++ b/res/img/element-icons/settings/inactive.svg @@ -0,0 +1,3 @@ + + + diff --git a/res/img/feather-customised/dropdown-arrow.svg b/res/img/feather-customised/dropdown-arrow.svg index a1d46fa61a6..24645d2bbaa 100644 --- a/res/img/feather-customised/dropdown-arrow.svg +++ b/res/img/feather-customised/dropdown-arrow.svg @@ -1,5 +1,5 @@ - + diff --git a/res/themes/legacy-light/css/_legacy-light.pcss b/res/themes/legacy-light/css/_legacy-light.pcss index 4da7e8e1230..20ca67a3cf7 100644 --- a/res/themes/legacy-light/css/_legacy-light.pcss +++ b/res/themes/legacy-light/css/_legacy-light.pcss @@ -217,6 +217,8 @@ $e2e-verified-color: #76cfa5; /* N.B. *NOT* the same as $accent */ $e2e-unknown-color: #e8bf37; $e2e-unverified-color: #e8bf37; $e2e-warning-color: #ba6363; +$e2e-verified-color-light: rgba($e2e-verified-color, 0.06); +$e2e-warning-color-light: rgba($e2e-warning-color, 0.06); /*** ImageView ***/ $lightbox-bg-color: #454545; diff --git a/res/themes/light/css/_light.pcss b/res/themes/light/css/_light.pcss index abde9cf52d1..fc9168ccf81 100644 --- a/res/themes/light/css/_light.pcss +++ b/res/themes/light/css/_light.pcss @@ -201,6 +201,8 @@ $e2e-verified-color: #76cfa5; /* N.B. *NOT* the same as $accent */ $e2e-unknown-color: #e8bf37; $e2e-unverified-color: #e8bf37; $e2e-warning-color: #ba6363; +$e2e-verified-color-light: rgba($e2e-verified-color, 0.06); +$e2e-warning-color-light: rgba($e2e-warning-color, 0.06); /* ******************** */ /* Tabbed views */ diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts index 7c595640fd0..00758371112 100644 --- a/src/@types/global.d.ts +++ b/src/@types/global.d.ts @@ -23,7 +23,7 @@ import ContentMessages from "../ContentMessages"; import { IMatrixClientPeg } from "../MatrixClientPeg"; import ToastStore from "../stores/ToastStore"; import DeviceListener from "../DeviceListener"; -import { RoomListStoreClass } from "../stores/room-list/RoomListStore"; +import { RoomListStore } from "../stores/room-list/Interface"; import { PlatformPeg } from "../PlatformPeg"; import RoomListLayoutStore from "../stores/room-list/RoomListLayoutStore"; import { IntegrationManagers } from "../integrations/IntegrationManagers"; @@ -79,7 +79,7 @@ declare global { mxContentMessages: ContentMessages; mxToastStore: ToastStore; mxDeviceListener: DeviceListener; - mxRoomListStore: RoomListStoreClass; + mxRoomListStore: RoomListStore; mxRoomListLayoutStore: RoomListLayoutStore; mxPlatformPeg: PlatformPeg; mxIntegrationManagers: typeof IntegrationManagers; diff --git a/src/HtmlUtils.tsx b/src/HtmlUtils.tsx index 4a4ac9e56c3..15fc2e075ef 100644 --- a/src/HtmlUtils.tsx +++ b/src/HtmlUtils.tsx @@ -632,13 +632,13 @@ export function topicToHtml( emojiBodyElements = formatEmojis(topic, false); } - return isFormattedTopic ? - : + /> + : { emojiBodyElements || topic } ; } diff --git a/src/SlashCommands.tsx b/src/SlashCommands.tsx index 9d3047a275b..5a5f59498fb 100644 --- a/src/SlashCommands.tsx +++ b/src/SlashCommands.tsx @@ -69,6 +69,7 @@ import { ViewRoomPayload } from "./dispatcher/payloads/ViewRoomPayload"; import VoipUserMapper from './VoipUserMapper'; import { htmlSerializeFromMdIfNeeded } from './editor/serialize'; import { leaveRoomBehaviour } from "./utils/leave-behaviour"; +import { isLocalRoom } from './utils/localRoom/isLocalRoom'; // XXX: workaround for https://github.com/microsoft/TypeScript/issues/31816 interface HTMLInputEvent extends Event { @@ -206,6 +207,12 @@ function successSync(value: any) { return success(Promise.resolve(value)); } +const isCurrentLocalRoom = (): boolean => { + const cli = MatrixClientPeg.get(); + const room = cli.getRoom(RoomViewStore.instance.getRoomId()); + return isLocalRoom(room); +}; + /* Disable the "unexpected this" error for these commands - all of the run * functions are called with `this` bound to the Command instance. */ @@ -297,6 +304,7 @@ export const Commands = [ command: 'upgraderoom', args: '', description: _td('Upgrades a room to a new version'), + isEnabled: () => !isCurrentLocalRoom(), runFn: function(roomId, args) { if (args) { const cli = MatrixClientPeg.get(); @@ -380,6 +388,7 @@ export const Commands = [ aliases: ['roomnick'], args: '', description: _td('Changes your display nickname in the current room only'), + isEnabled: () => !isCurrentLocalRoom(), runFn: function(roomId, args) { if (args) { const cli = MatrixClientPeg.get(); @@ -399,6 +408,7 @@ export const Commands = [ command: 'roomavatar', args: '[]', description: _td('Changes the avatar of the current room'), + isEnabled: () => !isCurrentLocalRoom(), runFn: function(roomId, args) { let promise = Promise.resolve(args); if (!args) { @@ -417,6 +427,7 @@ export const Commands = [ command: 'myroomavatar', args: '[]', description: _td('Changes your avatar in this current room only'), + isEnabled: () => !isCurrentLocalRoom(), runFn: function(roomId, args) { const cli = MatrixClientPeg.get(); const room = cli.getRoom(roomId); @@ -462,6 +473,7 @@ export const Commands = [ command: 'topic', args: '[]', description: _td('Gets or sets the room topic'), + isEnabled: () => !isCurrentLocalRoom(), runFn: function(roomId, args) { const cli = MatrixClientPeg.get(); if (args) { @@ -498,6 +510,7 @@ export const Commands = [ command: 'roomname', args: '', description: _td('Sets the room name'), + isEnabled: () => !isCurrentLocalRoom(), runFn: function(roomId, args) { if (args) { return success(MatrixClientPeg.get().setRoomName(roomId, args)); @@ -512,7 +525,7 @@ export const Commands = [ args: ' []', description: _td('Invites user with given id to current room'), analyticsName: "Invite", - isEnabled: () => shouldShowComponent(UIComponent.InviteUsers), + isEnabled: () => !isCurrentLocalRoom() && shouldShowComponent(UIComponent.InviteUsers), runFn: function(roomId, args) { if (args) { const [address, reason] = args.split(/\s+(.+)/); @@ -694,6 +707,7 @@ export const Commands = [ args: '[]', description: _td('Leave room'), analyticsName: "Part", + isEnabled: () => !isCurrentLocalRoom(), runFn: function(roomId, args) { const cli = MatrixClientPeg.get(); @@ -746,6 +760,7 @@ export const Commands = [ aliases: ["kick"], args: ' [reason]', description: _td('Removes user with given id from this room'), + isEnabled: () => !isCurrentLocalRoom(), runFn: function(roomId, args) { if (args) { const matches = args.match(/^(\S+?)( +(.*))?$/); @@ -762,6 +777,7 @@ export const Commands = [ command: 'ban', args: ' [reason]', description: _td('Bans user with given id'), + isEnabled: () => !isCurrentLocalRoom(), runFn: function(roomId, args) { if (args) { const matches = args.match(/^(\S+?)( +(.*))?$/); @@ -778,6 +794,7 @@ export const Commands = [ command: 'unban', args: '', description: _td('Unbans user with given ID'), + isEnabled: () => !isCurrentLocalRoom(), runFn: function(roomId, args) { if (args) { const matches = args.match(/^(\S+)$/); @@ -857,7 +874,8 @@ export const Commands = [ isEnabled(): boolean { const cli = MatrixClientPeg.get(); const room = cli.getRoom(RoomViewStore.instance.getRoomId()); - return room?.currentState.maySendStateEvent(EventType.RoomPowerLevels, cli.getUserId()); + return room?.currentState.maySendStateEvent(EventType.RoomPowerLevels, cli.getUserId()) + && !isLocalRoom(room); }, runFn: function(roomId, args) { if (args) { @@ -897,7 +915,8 @@ export const Commands = [ isEnabled(): boolean { const cli = MatrixClientPeg.get(); const room = cli.getRoom(RoomViewStore.instance.getRoomId()); - return room?.currentState.maySendStateEvent(EventType.RoomPowerLevels, cli.getUserId()); + return room?.currentState.maySendStateEvent(EventType.RoomPowerLevels, cli.getUserId()) + && !isLocalRoom(room); }, runFn: function(roomId, args) { if (args) { @@ -936,7 +955,9 @@ export const Commands = [ command: 'addwidget', args: '', description: _td('Adds a custom widget by URL to the room'), - isEnabled: () => SettingsStore.getValue(UIFeature.Widgets) && shouldShowComponent(UIComponent.AddIntegrations), + isEnabled: () => SettingsStore.getValue(UIFeature.Widgets) + && shouldShowComponent(UIComponent.AddIntegrations) + && !isCurrentLocalRoom(), runFn: function(roomId, widgetUrl) { if (!widgetUrl) { return reject(newTranslatableError("Please supply a widget URL or embed code")); @@ -1059,6 +1080,7 @@ export const Commands = [ new Command({ command: 'discardsession', description: _td('Forces the current outbound group session in an encrypted room to be discarded'), + isEnabled: () => !isCurrentLocalRoom(), runFn: function(roomId) { try { MatrixClientPeg.get().forceDiscardSession(roomId); @@ -1074,7 +1096,7 @@ export const Commands = [ command: 'remakeolm', description: _td('Developer command: Discards the current outbound group session and sets up new Olm sessions'), isEnabled: () => { - return SettingsStore.getValue("developerMode"); + return SettingsStore.getValue("developerMode") && !isCurrentLocalRoom(); }, runFn: (roomId) => { try { @@ -1125,6 +1147,7 @@ export const Commands = [ command: "whois", description: _td("Displays information about a user"), args: "", + isEnabled: () => !isCurrentLocalRoom(), runFn: function(roomId, userId) { if (!userId || !userId.startsWith("@") || !userId.includes(":")) { return reject(this.getUsage()); @@ -1160,7 +1183,7 @@ export const Commands = [ description: _td("Switches to this room's virtual room, if it has one"), category: CommandCategories.advanced, isEnabled(): boolean { - return CallHandler.instance.getSupportsVirtualRooms(); + return CallHandler.instance.getSupportsVirtualRooms() && !isCurrentLocalRoom(); }, runFn: (roomId) => { return success((async () => { @@ -1244,6 +1267,7 @@ export const Commands = [ command: "holdcall", description: _td("Places the call in the current room on hold"), category: CommandCategories.other, + isEnabled: () => !isCurrentLocalRoom(), runFn: function(roomId, args) { const call = CallHandler.instance.getCallForRoom(roomId); if (!call) { @@ -1258,6 +1282,7 @@ export const Commands = [ command: "unholdcall", description: _td("Takes the call in the current room off hold"), category: CommandCategories.other, + isEnabled: () => !isCurrentLocalRoom(), runFn: function(roomId, args) { const call = CallHandler.instance.getCallForRoom(roomId); if (!call) { @@ -1272,6 +1297,7 @@ export const Commands = [ command: "converttodm", description: _td("Converts the room to a DM"), category: CommandCategories.other, + isEnabled: () => !isCurrentLocalRoom(), runFn: function(roomId, args) { const room = MatrixClientPeg.get().getRoom(roomId); return success(guessAndSetDMRoom(room, true)); @@ -1282,6 +1308,7 @@ export const Commands = [ command: "converttoroom", description: _td("Converts the DM to a room"), category: CommandCategories.other, + isEnabled: () => !isCurrentLocalRoom(), runFn: function(roomId, args) { const room = MatrixClientPeg.get().getRoom(roomId); return success(guessAndSetDMRoom(room, false)); diff --git a/src/TextForEvent.tsx b/src/TextForEvent.tsx index 10530d7c9b4..9310391e3e2 100644 --- a/src/TextForEvent.tsx +++ b/src/TextForEvent.tsx @@ -434,29 +434,29 @@ function textForHistoryVisibilityEvent(event: MatrixEvent): () => string | null // Currently will only display a change if a user's power level is changed function textForPowerEvent(event: MatrixEvent): () => string | null { const senderName = getSenderName(event); - if (!event.getPrevContent() || !event.getPrevContent().users || - !event.getContent() || !event.getContent().users) { + if (!event.getPrevContent()?.users || !event.getContent()?.users) { return null; } - const previousUserDefault = event.getPrevContent().users_default || 0; - const currentUserDefault = event.getContent().users_default || 0; + const previousUserDefault: number = event.getPrevContent().users_default || 0; + const currentUserDefault: number = event.getContent().users_default || 0; // Construct set of userIds - const users = []; - Object.keys(event.getContent().users).forEach( - (userId) => { - if (users.indexOf(userId) === -1) users.push(userId); - }, - ); - Object.keys(event.getPrevContent().users).forEach( - (userId) => { - if (users.indexOf(userId) === -1) users.push(userId); - }, - ); - - const diffs = []; + const users: string[] = []; + Object.keys(event.getContent().users).forEach((userId) => { + if (users.indexOf(userId) === -1) users.push(userId); + }); + Object.keys(event.getPrevContent().users).forEach((userId) => { + if (users.indexOf(userId) === -1) users.push(userId); + }); + + const diffs: { + userId: string; + name: string; + from: number; + to: number; + }[] = []; users.forEach((userId) => { // Previous power level - let from = event.getPrevContent().users[userId]; + let from: number = event.getPrevContent().users[userId]; if (!Number.isInteger(from)) { from = previousUserDefault; } diff --git a/src/components/structures/AutoHideScrollbar.tsx b/src/components/structures/AutoHideScrollbar.tsx index d2134c33fef..9a6f5b26a0a 100644 --- a/src/components/structures/AutoHideScrollbar.tsx +++ b/src/components/structures/AutoHideScrollbar.tsx @@ -15,18 +15,28 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { HTMLAttributes, WheelEvent } from "react"; +import classNames from "classnames"; +import React, { HTMLAttributes, ReactHTML, WheelEvent } from "react"; -interface IProps extends Omit, "onScroll"> { +type DynamicHtmlElementProps = + JSX.IntrinsicElements[T] extends HTMLAttributes<{}> ? DynamicElementProps : DynamicElementProps<"div">; +type DynamicElementProps = Partial>; + +export type IProps = DynamicHtmlElementProps & { + element?: T; className?: string; onScroll?: (event: Event) => void; onWheel?: (event: WheelEvent) => void; style?: React.CSSProperties; tabIndex?: number; wrappedRef?: (ref: HTMLDivElement) => void; -} +}; + +export default class AutoHideScrollbar extends React.Component> { + static defaultProps = { + element: 'div' as keyof ReactHTML, + }; -export default class AutoHideScrollbar extends React.Component { public readonly containerRef: React.RefObject = React.createRef(); public componentDidMount() { @@ -36,9 +46,7 @@ export default class AutoHideScrollbar extends React.Component { this.containerRef.current.addEventListener("scroll", this.props.onScroll, { passive: true }); } - if (this.props.wrappedRef) { - this.props.wrappedRef(this.containerRef.current); - } + this.props.wrappedRef?.(this.containerRef.current); } public componentWillUnmount() { @@ -49,19 +57,15 @@ export default class AutoHideScrollbar extends React.Component { public render() { // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { className, onScroll, onWheel, style, tabIndex, wrappedRef, children, ...otherProps } = this.props; + const { element, className, onScroll, tabIndex, wrappedRef, children, ...otherProps } = this.props; - return (
- { children } -
); + tabIndex: tabIndex ?? -1, + }, children); } } diff --git a/src/components/structures/ContextMenu.tsx b/src/components/structures/ContextMenu.tsx index dc64dd23518..2445e0b38aa 100644 --- a/src/components/structures/ContextMenu.tsx +++ b/src/components/structures/ContextMenu.tsx @@ -225,35 +225,57 @@ export default class ContextMenu extends React.PureComponent { protected renderMenu(hasBackground = this.props.hasBackground) { const position: Partial> = {}; - const props = this.props; - - if (props.top) { - position.top = props.top; + const { + top, + bottom, + left, + right, + bottomAligned, + rightAligned, + menuClassName, + menuHeight, + menuWidth, + menuPaddingLeft, + menuPaddingRight, + menuPaddingBottom, + menuPaddingTop, + zIndex, + children, + focusLock, + managed, + wrapperClassName, + chevronFace: propsChevronFace, + chevronOffset: propsChevronOffset, + ...props + } = this.props; + + if (top) { + position.top = top; } else { - position.bottom = props.bottom; + position.bottom = bottom; } let chevronFace: ChevronFace; - if (props.left) { - position.left = props.left; + if (left) { + position.left = left; chevronFace = ChevronFace.Left; } else { - position.right = props.right; + position.right = right; chevronFace = ChevronFace.Right; } const contextMenuRect = this.state.contextMenuElem ? this.state.contextMenuElem.getBoundingClientRect() : null; const chevronOffset: CSSProperties = {}; - if (props.chevronFace) { - chevronFace = props.chevronFace; + if (propsChevronFace) { + chevronFace = propsChevronFace; } const hasChevron = chevronFace && chevronFace !== ChevronFace.None; if (chevronFace === ChevronFace.Top || chevronFace === ChevronFace.Bottom) { - chevronOffset.left = props.chevronOffset; + chevronOffset.left = propsChevronOffset; } else { - chevronOffset.top = props.chevronOffset; + chevronOffset.top = propsChevronOffset; } // If we know the dimensions of the context menu, adjust its position to @@ -262,13 +284,13 @@ export default class ContextMenu extends React.PureComponent { if (contextMenuRect) { if (position.top !== undefined) { let maxTop = windowHeight - WINDOW_PADDING; - if (!this.props.bottomAligned) { + if (!bottomAligned) { maxTop -= contextMenuRect.height; } position.top = Math.min(position.top, maxTop); // Adjust the chevron if necessary if (chevronOffset.top !== undefined) { - chevronOffset.top = props.chevronOffset + props.top - position.top; + chevronOffset.top = propsChevronOffset + top - position.top; } } else if (position.bottom !== undefined) { position.bottom = Math.min( @@ -276,17 +298,17 @@ export default class ContextMenu extends React.PureComponent { windowHeight - contextMenuRect.height - WINDOW_PADDING, ); if (chevronOffset.top !== undefined) { - chevronOffset.top = props.chevronOffset + position.bottom - props.bottom; + chevronOffset.top = propsChevronOffset + position.bottom - bottom; } } if (position.left !== undefined) { let maxLeft = windowWidth - WINDOW_PADDING; - if (!this.props.rightAligned) { + if (!rightAligned) { maxLeft -= contextMenuRect.width; } position.left = Math.min(position.left, maxLeft); if (chevronOffset.left !== undefined) { - chevronOffset.left = props.chevronOffset + props.left - position.left; + chevronOffset.left = propsChevronOffset + left - position.left; } } else if (position.right !== undefined) { position.right = Math.min( @@ -294,7 +316,7 @@ export default class ContextMenu extends React.PureComponent { windowWidth - contextMenuRect.width - WINDOW_PADDING, ); if (chevronOffset.left !== undefined) { - chevronOffset.left = props.chevronOffset + position.right - props.right; + chevronOffset.left = propsChevronOffset + position.right - right; } } } @@ -320,36 +342,36 @@ export default class ContextMenu extends React.PureComponent { 'mx_ContextualMenu_withChevron_right': chevronFace === ChevronFace.Right, 'mx_ContextualMenu_withChevron_top': chevronFace === ChevronFace.Top, 'mx_ContextualMenu_withChevron_bottom': chevronFace === ChevronFace.Bottom, - 'mx_ContextualMenu_rightAligned': this.props.rightAligned === true, - 'mx_ContextualMenu_bottomAligned': this.props.bottomAligned === true, - }, this.props.menuClassName); + 'mx_ContextualMenu_rightAligned': rightAligned === true, + 'mx_ContextualMenu_bottomAligned': bottomAligned === true, + }, menuClassName); const menuStyle: CSSProperties = {}; - if (props.menuWidth) { - menuStyle.width = props.menuWidth; + if (menuWidth) { + menuStyle.width = menuWidth; } - if (props.menuHeight) { - menuStyle.height = props.menuHeight; + if (menuHeight) { + menuStyle.height = menuHeight; } - if (!isNaN(Number(props.menuPaddingTop))) { - menuStyle["paddingTop"] = props.menuPaddingTop; + if (!isNaN(Number(menuPaddingTop))) { + menuStyle["paddingTop"] = menuPaddingTop; } - if (!isNaN(Number(props.menuPaddingLeft))) { - menuStyle["paddingLeft"] = props.menuPaddingLeft; + if (!isNaN(Number(menuPaddingLeft))) { + menuStyle["paddingLeft"] = menuPaddingLeft; } - if (!isNaN(Number(props.menuPaddingBottom))) { - menuStyle["paddingBottom"] = props.menuPaddingBottom; + if (!isNaN(Number(menuPaddingBottom))) { + menuStyle["paddingBottom"] = menuPaddingBottom; } - if (!isNaN(Number(props.menuPaddingRight))) { - menuStyle["paddingRight"] = props.menuPaddingRight; + if (!isNaN(Number(menuPaddingRight))) { + menuStyle["paddingRight"] = menuPaddingRight; } const wrapperStyle = {}; - if (!isNaN(Number(props.zIndex))) { - menuStyle["zIndex"] = props.zIndex + 1; - wrapperStyle["zIndex"] = props.zIndex; + if (!isNaN(Number(zIndex))) { + menuStyle["zIndex"] = zIndex + 1; + wrapperStyle["zIndex"] = zIndex; } let background; @@ -366,10 +388,10 @@ export default class ContextMenu extends React.PureComponent { let body = <> { chevron } - { props.children } + { children } ; - if (props.focusLock) { + if (focusLock) { body = { body } ; @@ -379,7 +401,7 @@ export default class ContextMenu extends React.PureComponent { { ({ onKeyDownHandler }) => (
{ className={menuClasses} style={menuStyle} ref={this.collectContextMenuRect} - role={this.props.managed ? "menu" : undefined} + role={managed ? "menu" : undefined} + {...props} > { body }
diff --git a/src/components/structures/HomePage.tsx b/src/components/structures/HomePage.tsx index 200c28d159f..b2596cee435 100644 --- a/src/components/structures/HomePage.tsx +++ b/src/components/structures/HomePage.tsx @@ -85,7 +85,7 @@ const UserWelcomeTop = () => {

{ _tDom("Welcome %(name)s", { name: ownProfile.displayName }) }

-

{ _tDom("Now, let's help you get started") }

+

{ _tDom("Now, let's help you get started") }

; }; @@ -97,8 +97,8 @@ const HomePage: React.FC = ({ justRegistered = false }) => { return ; } - let introSection; - if (justRegistered || !!OwnProfileStore.instance.getHttpAvatarUrl(AVATAR_SIZE)) { + let introSection: JSX.Element; + if (justRegistered || !OwnProfileStore.instance.getHttpAvatarUrl(AVATAR_SIZE)) { introSection = ; } else { const brandingConfig = SdkConfig.getObject("branding"); @@ -107,11 +107,11 @@ const HomePage: React.FC = ({ justRegistered = false }) => { introSection = {config.brand}

{ _tDom("Welcome to %(appName)s", { appName: config.brand }) }

-

{ _tDom("Own your conversations.") }

+

{ _tDom("Own your conversations.") }

; } - return + return
{ introSection }
diff --git a/src/components/structures/IndicatorScrollbar.tsx b/src/components/structures/IndicatorScrollbar.tsx index 4b122345b32..ea876f9ae29 100644 --- a/src/components/structures/IndicatorScrollbar.tsx +++ b/src/components/structures/IndicatorScrollbar.tsx @@ -14,12 +14,12 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { ComponentProps, createRef } from "react"; +import React, { createRef } from "react"; -import AutoHideScrollbar from "./AutoHideScrollbar"; +import AutoHideScrollbar, { IProps as AutoHideScrollbarProps } from "./AutoHideScrollbar"; import UIStore, { UI_EVENTS } from "../../stores/UIStore"; -interface IProps extends Omit, "onWheel"> { +export type IProps = Omit, "onWheel"> & { // If true, the scrollbar will append mx_IndicatorScrollbar_leftOverflowIndicator // and mx_IndicatorScrollbar_rightOverflowIndicator elements to the list for positioning // by the parent element. @@ -31,21 +31,22 @@ interface IProps extends Omit, "onWheel verticalScrollsHorizontally?: boolean; children: React.ReactNode; - className: string; -} +}; interface IState { leftIndicatorOffset: string; rightIndicatorOffset: string; } -export default class IndicatorScrollbar extends React.Component { - private autoHideScrollbar = createRef(); +export default class IndicatorScrollbar< + T extends keyof JSX.IntrinsicElements, +> extends React.Component, IState> { + private autoHideScrollbar = createRef>(); private scrollElement: HTMLDivElement; private likelyTrackpadUser: boolean = null; private checkAgainForTrackpad = 0; // ts in milliseconds to recheck this._likelyTrackpadUser - constructor(props: IProps) { + constructor(props: IProps) { super(props); this.state = { @@ -65,7 +66,7 @@ export default class IndicatorScrollbar extends React.Component } }; - public componentDidUpdate(prevProps: IProps): void { + public componentDidUpdate(prevProps: IProps): void { const prevLen = React.Children.count(prevProps.children); const curLen = React.Children.count(this.props.children); // check overflow only if amount of children changes. diff --git a/src/components/structures/LeftPanel.tsx b/src/components/structures/LeftPanel.tsx index 94f1b9e0eee..bd120529a90 100644 --- a/src/components/structures/LeftPanel.tsx +++ b/src/components/structures/LeftPanel.tsx @@ -45,9 +45,12 @@ import { shouldShowComponent } from "../../customisations/helpers/UIComponents"; import { UIComponent } from "../../settings/UIFeature"; import { ButtonEvent } from "../views/elements/AccessibleButton"; import PosthogTrackers from "../../PosthogTrackers"; +import PageType from "../../PageTypes"; +import { UserOnboardingButton } from "../views/user-onboarding/UserOnboardingButton"; interface IProps { isMinimized: boolean; + pageType: PageType; resizeNotifier: ResizeNotifier; } @@ -390,6 +393,10 @@ export default class LeftPanel extends React.Component { onVisibilityChange={this.refreshStickyHeaders} /> ) } +
{ data-collapsed={this.props.collapseLhs ? true : undefined} > diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index 41be62e1e69..8c173a36301 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -157,7 +157,7 @@ interface IScreen { params?: QueryDict; } -interface IProps { // TODO type things better +interface IProps { config: IConfigOptions; serverConfig?: ValidatedServerConfig; onNewScreen: (screen: string, replaceLast: boolean) => void; diff --git a/src/components/structures/SpaceHierarchy.tsx b/src/components/structures/SpaceHierarchy.tsx index cb0432efb5d..c22f1585f24 100644 --- a/src/components/structures/SpaceHierarchy.tsx +++ b/src/components/structures/SpaceHierarchy.tsx @@ -504,7 +504,7 @@ export const useRoomHierarchy = (space: Room): { loadMore(pageSize?: number): Promise; } => { const [rooms, setRooms] = useState([]); - const [hierarchy, setHierarchy] = useState(); + const [roomHierarchy, setHierarchy] = useState(); const [error, setError] = useState(); const resetHierarchy = useCallback(() => { @@ -526,15 +526,21 @@ export const useRoomHierarchy = (space: Room): { })); const loadMore = useCallback(async (pageSize?: number) => { - if (hierarchy.loading || !hierarchy.canLoadMore || hierarchy.noSupport || error) return; - await hierarchy.load(pageSize).catch(setError); - setRooms(hierarchy.rooms); - }, [error, hierarchy]); + if (roomHierarchy.loading || !roomHierarchy.canLoadMore || roomHierarchy.noSupport || error) return; + await roomHierarchy.load(pageSize).catch(setError); + setRooms(roomHierarchy.rooms); + }, [error, roomHierarchy]); + + // Only return the hierarchy if it is for the space requested + let hierarchy = roomHierarchy; + if (hierarchy?.root !== space) { + hierarchy = undefined; + } return { loading: hierarchy?.loading ?? true, rooms, - hierarchy: hierarchy?.root === space ? hierarchy : undefined, + hierarchy, loadMore, error, }; diff --git a/src/components/views/dialogs/AddExistingToSpaceDialog.tsx b/src/components/views/dialogs/AddExistingToSpaceDialog.tsx index 606f96553d7..6251faee41f 100644 --- a/src/components/views/dialogs/AddExistingToSpaceDialog.tsx +++ b/src/components/views/dialogs/AddExistingToSpaceDialog.tsx @@ -130,7 +130,7 @@ export const AddExistingToSpace: React.FC = ({ const cli = useContext(MatrixClientContext); const visibleRooms = useMemo(() => cli.getVisibleRooms().filter(r => r.getMyMembership() === "join"), [cli]); - const scrollRef = useRef(); + const scrollRef = useRef>(); const [scrollState, setScrollState] = useState({ // these are estimates which update as soon as it mounts scrollTop: 0, diff --git a/src/components/views/dialogs/UntrustedDeviceDialog.tsx b/src/components/views/dialogs/UntrustedDeviceDialog.tsx index 8039a67511e..f0f1abb3c1c 100644 --- a/src/components/views/dialogs/UntrustedDeviceDialog.tsx +++ b/src/components/views/dialogs/UntrustedDeviceDialog.tsx @@ -58,10 +58,10 @@ const UntrustedDeviceDialog: React.FC = ({ device, user, onFinished }) =
onFinished("legacy")}> - { _t("Manually Verify by Text") } + { _t("Manually verify by text") } onFinished("sas")}> - { _t("Interactively verify by Emoji") } + { _t("Interactively verify by emoji") } onFinished(false)}> { _t("Done") } diff --git a/src/components/views/dialogs/security/SetupEncryptionDialog.tsx b/src/components/views/dialogs/security/SetupEncryptionDialog.tsx index 1a945405023..63d9ad1d2cd 100644 --- a/src/components/views/dialogs/security/SetupEncryptionDialog.tsx +++ b/src/components/views/dialogs/security/SetupEncryptionDialog.tsx @@ -24,9 +24,9 @@ import { IDialogProps } from "../IDialogProps"; function iconFromPhase(phase: Phase) { if (phase === Phase.Done) { - return require("../../../../../res/img/e2e/verified.svg").default; + return require("../../../../../res/img/e2e/verified-deprecated.svg").default; } else { - return require("../../../../../res/img/e2e/warning.svg").default; + return require("../../../../../res/img/e2e/warning-deprecated.svg").default; } } diff --git a/src/components/views/dialogs/spotlight/SpotlightDialog.tsx b/src/components/views/dialogs/spotlight/SpotlightDialog.tsx index dd2b9232111..3d8b16c1f4f 100644 --- a/src/components/views/dialogs/spotlight/SpotlightDialog.tsx +++ b/src/components/views/dialogs/spotlight/SpotlightDialog.tsx @@ -376,7 +376,9 @@ const SpotlightDialog: React.FC = ({ initialText = "", initialFilter = n })), ...roomResults, ...userResults, - ...(profile ? [new DirectoryMember(profile)] : []).map(toMemberResult), + ...(profile && !alreadyAddedUserIds.has(profile.user_id) + ? [new DirectoryMember(profile)] + : []).map(toMemberResult), ...publicRooms.map(toPublicRoomResult), ].filter(result => filter === null || result.filter.includes(filter)); }, diff --git a/src/components/views/elements/AccessibleButton.tsx b/src/components/views/elements/AccessibleButton.tsx index f54a8d4bff5..a2337444cfa 100644 --- a/src/components/views/elements/AccessibleButton.tsx +++ b/src/components/views/elements/AccessibleButton.tsx @@ -33,7 +33,8 @@ type AccessibleButtonKind = | 'primary' | 'link_inline' | 'link_sm' | 'confirm_sm' - | 'cancel_sm'; + | 'cancel_sm' + | 'icon'; /** * This type construct allows us to specifically pass those props down to the element we’re creating that the element diff --git a/src/components/views/elements/StyledCheckbox.tsx b/src/components/views/elements/StyledCheckbox.tsx index 333fbb8adbc..35422366822 100644 --- a/src/components/views/elements/StyledCheckbox.tsx +++ b/src/components/views/elements/StyledCheckbox.tsx @@ -70,9 +70,11 @@ export default class StyledCheckbox extends React.PureComponent
-
- { this.props.children } -
+ { !!this.props.children && +
+ { this.props.children } +
+ } ; } diff --git a/src/components/views/elements/Tooltip.tsx b/src/components/views/elements/Tooltip.tsx index 3b7e6c96701..ae1aff26def 100644 --- a/src/components/views/elements/Tooltip.tsx +++ b/src/components/views/elements/Tooltip.tsx @@ -52,8 +52,10 @@ export interface ITooltipProps { maxParentWidth?: number; } -export default class Tooltip extends React.Component { - private tooltipContainer: HTMLElement; +type State = Partial>; + +export default class Tooltip extends React.PureComponent { + private static container: HTMLElement; private parent: Element; // XXX: This is because some components (Field) are unable to `import` the Tooltip class, @@ -65,37 +67,47 @@ export default class Tooltip extends React.Component { alignment: Alignment.Natural, }; - // Create a wrapper for the tooltip outside the parent and attach it to the body element + constructor(props) { + super(props); + + this.state = {}; + + // Create a wrapper for the tooltips and attach it to the body element + if (!Tooltip.container) { + Tooltip.container = document.createElement("div"); + Tooltip.container.className = "mx_Tooltip_wrapper"; + document.body.appendChild(Tooltip.container); + } + } + public componentDidMount() { - this.tooltipContainer = document.createElement("div"); - this.tooltipContainer.className = "mx_Tooltip_wrapper"; - document.body.appendChild(this.tooltipContainer); - window.addEventListener('scroll', this.renderTooltip, { + window.addEventListener('scroll', this.updatePosition, { passive: true, capture: true, }); this.parent = ReactDOM.findDOMNode(this).parentNode as Element; - this.renderTooltip(); + this.updatePosition(); } public componentDidUpdate() { - this.renderTooltip(); + this.updatePosition(); } // Remove the wrapper element, as the tooltip has finished using it public componentWillUnmount() { - ReactDOM.unmountComponentAtNode(this.tooltipContainer); - document.body.removeChild(this.tooltipContainer); - window.removeEventListener('scroll', this.renderTooltip, { + window.removeEventListener('scroll', this.updatePosition, { capture: true, }); } // Add the parent's position to the tooltips, so it's correctly // positioned, also taking into account any window zoom - private updatePosition(style: CSSProperties) { + private updatePosition = (): void => { + // When the tooltip is hidden, no need to thrash the DOM with `style` attribute updates (performance) + if (!this.props.visible) return; + const parentBox = this.parent.getBoundingClientRect(); const width = UIStore.instance.windowWidth; const spacing = 6; @@ -112,6 +124,7 @@ export default class Tooltip extends React.Component { parentBox.left - window.scrollX + (parentWidth / 2) ); + const style: State = {}; switch (this.props.alignment) { case Alignment.Natural: if (parentBox.right > width / 2) { @@ -153,25 +166,20 @@ export default class Tooltip extends React.Component { break; } - return style; - } - - private renderTooltip = () => { - let style: CSSProperties = {}; - // When the tooltip is hidden, no need to thrash the DOM with `style` - // attribute updates (performance) - if (this.props.visible) { - style = this.updatePosition({}); - } - // Hide the entire container when not visible. This prevents flashing of the tooltip - // if it is not meant to be visible on first mount. - style.display = this.props.visible ? "block" : "none"; + this.setState(style); + }; + public render() { const tooltipClasses = classNames("mx_Tooltip", this.props.tooltipClassName, { "mx_Tooltip_visible": this.props.visible, "mx_Tooltip_invisible": !this.props.visible, }); + const style = { ...this.state }; + // Hide the entire container when not visible. + // This prevents flashing of the tooltip if it is not meant to be visible on first mount. + style.display = this.props.visible ? "block" : "none"; + const tooltip = (
@@ -179,14 +187,10 @@ export default class Tooltip extends React.Component {
); - // Render the tooltip manually, as we wish it not to be rendered within the parent - ReactDOM.render(tooltip, this.tooltipContainer); - }; - - public render() { - // Render a placeholder return ( -
+
+ { ReactDOM.createPortal(tooltip, Tooltip.container) } +
); } } diff --git a/src/components/views/emojipicker/EmojiPicker.tsx b/src/components/views/emojipicker/EmojiPicker.tsx index e32f20b78b6..95e0e24ae18 100644 --- a/src/components/views/emojipicker/EmojiPicker.tsx +++ b/src/components/views/emojipicker/EmojiPicker.tsx @@ -55,7 +55,7 @@ class EmojiPicker extends React.Component { private readonly memoizedDataByCategory: Record; private readonly categories: ICategory[]; - private scrollRef = React.createRef(); + private scrollRef = React.createRef>(); constructor(props: IProps) { super(props); @@ -240,7 +240,7 @@ class EmojiPicker extends React.Component { render() { let heightBefore = 0; return ( -
+
{ isEmojiDisabled={this.isEmojiDisabled} selectedEmojis={this.state.selectedEmojis} showQuickReactions={true} - data-testid='mx_ReactionPicker' />; } } diff --git a/src/components/views/messages/MessageActionBar.tsx b/src/components/views/messages/MessageActionBar.tsx index 9d51c61074f..c5108051160 100644 --- a/src/components/views/messages/MessageActionBar.tsx +++ b/src/components/views/messages/MessageActionBar.tsx @@ -16,7 +16,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { ReactElement, useContext, useEffect } from 'react'; +import React, { ReactElement, useCallback, useContext, useEffect } from 'react'; import { EventStatus, MatrixEvent, MatrixEventEvent } from 'matrix-js-sdk/src/models/event'; import classNames from 'classnames'; import { MsgType, RelationType } from 'matrix-js-sdk/src/@types/event'; @@ -88,7 +88,7 @@ const OptionsButton: React.FC = ({ onFocusChange(menuDisplayed); }, [onFocusChange, menuDisplayed]); - const onOptionsClick = (e: React.MouseEvent): void => { + const onOptionsClick = useCallback((e: React.MouseEvent): void => { // Don't open the regular browser or our context menu on right-click e.preventDefault(); e.stopPropagation(); @@ -97,7 +97,7 @@ const OptionsButton: React.FC = ({ // the element that is currently focused is skipped. So we want to call onFocus manually to keep the // position in the page even when someone is clicking around. onFocus(); - }; + }, [openMenu, onFocus]); let contextMenu: ReactElement | null; if (menuDisplayed) { @@ -121,6 +121,7 @@ const OptionsButton: React.FC = ({ className="mx_MessageActionBar_iconButton mx_MessageActionBar_optionsButton" title={_t("Options")} onClick={onOptionsClick} + onContextMenu={onOptionsClick} isExpanded={menuDisplayed} inputRef={ref} onFocus={onFocus} @@ -153,17 +154,24 @@ const ReactButton: React.FC = ({ mxEvent, reactions, onFocusC ; } + const onClick = useCallback((e: React.MouseEvent) => { + // Don't open the regular browser or our context menu on right-click + e.preventDefault(); + e.stopPropagation(); + + openMenu(); + // when the context menu is opened directly, e.g. via mouse click, the onFocus handler which tracks + // the element that is currently focused is skipped. So we want to call onFocus manually to keep the + // position in the page even when someone is clicking around. + onFocus(); + }, [openMenu, onFocus]); + return { - openMenu(); - // when the context menu is opened directly, e.g. via mouse click, the onFocus handler which tracks - // the element that is currently focused is skipped. So we want to call onFocus manually to keep the - // position in the page even when someone is clicking around. - onFocus(); - }} + onClick={onClick} + onContextMenu={onClick} isExpanded={menuDisplayed} inputRef={ref} onFocus={onFocus} @@ -193,7 +201,11 @@ const ReplyInThreadButton = ({ mxEvent }: IReplyInThreadButton) => { return null; } - const onClick = (): void => { + const onClick = (e: React.MouseEvent): void => { + // Don't open the regular browser or our context menu on right-click + e.preventDefault(); + e.stopPropagation(); + if (firstTimeSeeingThreads) { localStorage.setItem("mx_seen_feature_thread", "true"); } @@ -245,6 +257,7 @@ const ReplyInThreadButton = ({ mxEvent }: IReplyInThreadButton) => { : _t("Can't create a thread from an event with an existing relation")} onClick={onClick} + onContextMenu={onClick} > { firstTimeSeeingThreads && !threadsEnabled && ( @@ -265,10 +278,19 @@ const FavouriteButton = ({ mxEvent }: IFavouriteButtonProp) => { 'mx_MessageActionBar_favouriteButton_fillstar': isFavourite(eventId), }); + const onClick = useCallback((e: React.MouseEvent) => { + // Don't open the regular browser or our context menu on right-click + e.preventDefault(); + e.stopPropagation(); + + toggleFavourite(eventId); + }, [toggleFavourite, eventId]); + return toggleFavourite(eventId)} + onClick={onClick} + onContextMenu={onClick} data-testid={eventId} > @@ -335,7 +357,11 @@ export default class MessageActionBar extends React.PureComponent { + private onReplyClick = (e: React.MouseEvent): void => { + // Don't open the regular browser or our context menu on right-click + e.preventDefault(); + e.stopPropagation(); + dis.dispatch({ action: 'reply_to_event', event: this.props.mxEvent, @@ -343,7 +369,11 @@ export default class MessageActionBar extends React.PureComponent { + private onEditClick = (e: React.MouseEvent): void => { + // Don't open the regular browser or our context menu on right-click + e.preventDefault(); + e.stopPropagation(); + editEvent(this.props.mxEvent, this.context.timelineRenderingType, this.props.getRelationsForEvent); }; @@ -406,6 +436,10 @@ export default class MessageActionBar extends React.PureComponent { + // Don't open the regular browser or our context menu on right-click + ev.preventDefault(); + ev.stopPropagation(); + this.runActionOnFailedEv((tarEv) => Resend.resend(tarEv)); }; @@ -423,6 +457,7 @@ export default class MessageActionBar extends React.PureComponent @@ -433,6 +468,7 @@ export default class MessageActionBar extends React.PureComponent @@ -453,6 +489,7 @@ export default class MessageActionBar extends React.PureComponent @@ -475,6 +512,7 @@ export default class MessageActionBar extends React.PureComponent diff --git a/src/components/views/messages/TextualBody.tsx b/src/components/views/messages/TextualBody.tsx index 459f3845580..23ba901acdf 100644 --- a/src/components/views/messages/TextualBody.tsx +++ b/src/components/views/messages/TextualBody.tsx @@ -432,11 +432,17 @@ export default class TextualBody extends React.Component { * to start with (e.g. pills, links in the content). */ private onBodyLinkClick = (e: MouseEvent): void => { - const target = e.target as Element; - if (target.nodeName !== "A" || target.classList.contains(linkifyOpts.className)) return; - const { href } = target as HTMLLinkElement; - const localHref = tryTransformPermalinkToLocalHref(href); - if (localHref !== href) { + let target = e.target as HTMLLinkElement; + // links processed by linkifyjs have their own handler so don't handle those here + if (target.classList.contains(linkifyOpts.className)) return; + if (target.nodeName !== "A") { + // Jump to parent as the `` may contain children, e.g. an anchor wrapping an inline code section + target = target.closest("a"); + } + if (!target) return; + + const localHref = tryTransformPermalinkToLocalHref(target.href); + if (localHref !== target.href) { // it could be converted to a localHref -> therefore handle locally e.preventDefault(); window.location.hash = localHref; diff --git a/src/components/views/right_panel/EncryptionPanel.tsx b/src/components/views/right_panel/EncryptionPanel.tsx index a9ab3e6ab38..d0d6c7bf5b9 100644 --- a/src/components/views/right_panel/EncryptionPanel.tsx +++ b/src/components/views/right_panel/EncryptionPanel.tsx @@ -85,7 +85,7 @@ const EncryptionPanel: React.FC = (props: IProps) => { // handle transitions -> cancelled for mismatches which fire a modal instead of showing a card if (request && request.cancelled && MISMATCHES.includes(request.cancellationCode)) { Modal.createDialog(ErrorDialog, { - headerImage: require("../../../../res/img/e2e/warning.svg").default, + headerImage: require("../../../../res/img/e2e/warning-deprecated.svg").default, title: _t("Your messages are not secure"), description:
{ _t("One of the following may be compromised:") } diff --git a/src/components/views/rooms/EventTile.tsx b/src/components/views/rooms/EventTile.tsx index 65c0de17e99..8a4056ce1a3 100644 --- a/src/components/views/rooms/EventTile.tsx +++ b/src/components/views/rooms/EventTile.tsx @@ -854,9 +854,6 @@ export class UnwrappedEventTile extends React.Component { private showContextMenu(ev: React.MouseEvent, permalink?: string): void { const clickTarget = ev.target as HTMLElement; - // Return if message right-click context menu isn't enabled - if (!SettingsStore.getValue("feature_message_right_click_context_menu")) return; - // Try to find an anchor element const anchorElement = (clickTarget instanceof HTMLAnchorElement) ? clickTarget : clickTarget.closest("a"); diff --git a/src/components/views/rooms/LinkPreviewWidget.tsx b/src/components/views/rooms/LinkPreviewWidget.tsx index 25d5fdd00b1..cf7c009e523 100644 --- a/src/components/views/rooms/LinkPreviewWidget.tsx +++ b/src/components/views/rooms/LinkPreviewWidget.tsx @@ -112,7 +112,13 @@ export default class LinkPreviewWidget extends React.Component { let img; if (image) { img =
- +
; } diff --git a/src/components/views/rooms/MessageComposer.tsx b/src/components/views/rooms/MessageComposer.tsx index d3dfd06d900..4e33fd30227 100644 --- a/src/components/views/rooms/MessageComposer.tsx +++ b/src/components/views/rooms/MessageComposer.tsx @@ -51,6 +51,7 @@ import { SettingUpdatedPayload } from "../../../dispatcher/payloads/SettingUpdat import MessageComposerButtons from './MessageComposerButtons'; import { ButtonEvent } from '../elements/AccessibleButton'; import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; +import { isLocalRoom } from '../../../utils/localRoom/isLocalRoom'; let instanceCount = 0; @@ -350,6 +351,10 @@ export default class MessageComposer extends React.Component { }); }; + private get showStickersButton(): boolean { + return this.state.showStickersButton && !isLocalRoom(this.props.room); + } + public render() { const controls = [ this.props.e2eStatus ? @@ -475,7 +480,7 @@ export default class MessageComposer extends React.Component { setStickerPickerOpen={this.setStickerPickerOpen} showLocationButton={!window.electron} showPollsButton={this.state.showPollsButton} - showStickersButton={this.state.showStickersButton} + showStickersButton={this.showStickersButton} toggleButtonMenu={this.toggleButtonMenu} /> } { showSendButton && ( diff --git a/src/components/views/rooms/RoomSublist.tsx b/src/components/views/rooms/RoomSublist.tsx index 677b63bcf95..bd09ecb4a01 100644 --- a/src/components/views/rooms/RoomSublist.tsx +++ b/src/components/views/rooms/RoomSublist.tsx @@ -372,7 +372,7 @@ export default class RoomSublist extends React.Component { }; private onTagSortChanged = async (sort: SortAlgorithm) => { - await RoomListStore.instance.setTagSorting(this.props.tagId, sort); + RoomListStore.instance.setTagSorting(this.props.tagId, sort); this.forceUpdate(); }; diff --git a/src/components/views/settings/DevicesPanel.tsx b/src/components/views/settings/DevicesPanel.tsx index 5ae034d9fec..f32f7997fed 100644 --- a/src/components/views/settings/DevicesPanel.tsx +++ b/src/components/views/settings/DevicesPanel.tsx @@ -22,12 +22,10 @@ import { CrossSigningInfo } from "matrix-js-sdk/src/crypto/CrossSigning"; import { MatrixClientPeg } from '../../../MatrixClientPeg'; import { _t } from '../../../languageHandler'; -import Modal from '../../../Modal'; -import { SSOAuthEntry } from "../auth/InteractiveAuthEntryComponents"; -import InteractiveAuthDialog from "../dialogs/InteractiveAuthDialog"; import DevicesPanelEntry from "./DevicesPanelEntry"; import Spinner from "../elements/Spinner"; import AccessibleButton from "../elements/AccessibleButton"; +import { deleteDevicesWithInteractiveAuth } from './devices/deleteDevices'; interface IProps { className?: string; @@ -79,7 +77,6 @@ export default class DevicesPanel extends React.Component { crossSigningInfo: crossSigningInfo, }; }); - console.log(this.state); }, (error) => { if (this.unmounted) { return; } @@ -178,76 +175,38 @@ export default class DevicesPanel extends React.Component { }); }; - private onDeleteClick = (): void => { + private onDeleteClick = async (): Promise => { if (this.state.selectedDevices.length === 0) { return; } this.setState({ deleting: true, }); - this.makeDeleteRequest(null).catch((error) => { - if (this.unmounted) { return; } - if (error.httpStatus !== 401 || !error.data || !error.data.flows) { - // doesn't look like an interactive-auth failure - throw error; - } - - // pop up an interactive auth dialog - - const numDevices = this.state.selectedDevices.length; - const dialogAesthetics = { - [SSOAuthEntry.PHASE_PREAUTH]: { - title: _t("Use Single Sign On to continue"), - body: _t("Confirm logging out these devices by using Single Sign On to prove your identity.", { - count: numDevices, - }), - continueText: _t("Single Sign On"), - continueKind: "primary", - }, - [SSOAuthEntry.PHASE_POSTAUTH]: { - title: _t("Confirm signing out these devices", { - count: numDevices, - }), - body: _t("Click the button below to confirm signing out these devices.", { - count: numDevices, - }), - continueText: _t("Sign out devices", { count: numDevices }), - continueKind: "danger", - }, - }; - Modal.createDialog(InteractiveAuthDialog, { - title: _t("Authentication"), - matrixClient: MatrixClientPeg.get(), - authData: error.data, - makeRequest: this.makeDeleteRequest.bind(this), - aestheticsForStagePhases: { - [SSOAuthEntry.LOGIN_TYPE]: dialogAesthetics, - [SSOAuthEntry.UNSTABLE_LOGIN_TYPE]: dialogAesthetics, + try { + await deleteDevicesWithInteractiveAuth( + MatrixClientPeg.get(), + this.state.selectedDevices, + (success) => { + if (success) { + // Reset selection to [], update device list + this.setState({ + selectedDevices: [], + }); + this.loadDevices(); + } + this.setState({ + deleting: false, + }); }, - }); - }).catch((e) => { - logger.error("Error deleting sessions", e); - if (this.unmounted) { return; } - }).finally(() => { + ); + } catch (error) { + logger.error("Error deleting sessions", error); this.setState({ deleting: false, }); - }); + } }; - // TODO: proper typing for auth - private makeDeleteRequest(auth?: any): Promise { - return MatrixClientPeg.get().deleteMultipleDevices(this.state.selectedDevices, auth).then( - () => { - // Reset selection to [], update device list - this.setState({ - selectedDevices: [], - }); - this.loadDevices(); - }, - ); - } - private renderDevice = (device: IMyDevice): JSX.Element => { const myDeviceId = MatrixClientPeg.get().getDeviceId(); const myDevice = this.state.devices.find((device) => (device.device_id === myDeviceId)); @@ -289,6 +248,7 @@ export default class DevicesPanel extends React.Component { const myDeviceId = MatrixClientPeg.get().getDeviceId(); const myDevice = devices.find((device) => (device.device_id === myDeviceId)); + if (!myDevice) { return loadError; } @@ -373,6 +333,7 @@ export default class DevicesPanel extends React.Component { onClick={this.onDeleteClick} kind="danger_outline" disabled={this.state.selectedDevices.length === 0} + data-testid='sign-out-devices-btn' > { _t("Sign out %(count)s selected devices", { count: this.state.selectedDevices.length }) } ; diff --git a/src/components/views/settings/DevicesPanelEntry.tsx b/src/components/views/settings/DevicesPanelEntry.tsx index 5a5330fd3ee..0109c37b9ba 100644 --- a/src/components/views/settings/DevicesPanelEntry.tsx +++ b/src/components/views/settings/DevicesPanelEntry.tsx @@ -17,10 +17,10 @@ limitations under the License. import React from 'react'; import { IMyDevice } from 'matrix-js-sdk/src/client'; import { logger } from "matrix-js-sdk/src/logger"; +import classNames from 'classnames'; import { _t } from '../../../languageHandler'; import { MatrixClientPeg } from '../../../MatrixClientPeg'; -import StyledCheckbox, { CheckboxStyle } from '../elements/StyledCheckbox'; import AccessibleButton from "../elements/AccessibleButton"; import Field from "../elements/Field"; import Modal from "../../../Modal"; @@ -28,6 +28,7 @@ import SetupEncryptionDialog from '../dialogs/security/SetupEncryptionDialog'; import VerificationRequestDialog from '../../views/dialogs/VerificationRequestDialog'; import LogoutDialog from '../dialogs/LogoutDialog'; import DeviceTile from './devices/DeviceTile'; +import SelectableDeviceTile from './devices/SelectableDeviceTile'; interface IProps { device: IMyDevice; @@ -113,8 +114,6 @@ export default class DevicesPanelEntry extends React.Component { }; public render(): JSX.Element { - const myDeviceClass = this.props.isOwnDevice ? " mx_DevicesPanel_myDevice" : ''; - let iconClass = ''; let verifyButton: JSX.Element; if (this.props.verified !== null) { @@ -133,14 +132,6 @@ export default class DevicesPanelEntry extends React.Component { ; } - const left = this.props.isOwnDevice ? -
- -
: -
- -
; - const buttons = this.state.renaming ?
{ ; - return ( -
- { left } - + const deviceWithVerification = { + ...this.props.device, + isVerified: this.props.verified, + }; + + if (this.props.isOwnDevice) { + return
+
+ +
+ { buttons } +
; + } + + return ( +
+ + { buttons } +
); } diff --git a/src/components/views/settings/devices/CurrentDeviceSection.tsx b/src/components/views/settings/devices/CurrentDeviceSection.tsx new file mode 100644 index 00000000000..cebbed64e6b --- /dev/null +++ b/src/components/views/settings/devices/CurrentDeviceSection.tsx @@ -0,0 +1,61 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React, { useState } from 'react'; + +import { _t } from '../../../../languageHandler'; +import Spinner from '../../elements/Spinner'; +import SettingsSubsection from '../shared/SettingsSubsection'; +import DeviceDetails from './DeviceDetails'; +import DeviceExpandDetailsButton from './DeviceExpandDetailsButton'; +import DeviceTile from './DeviceTile'; +import { DeviceVerificationStatusCard } from './DeviceVerificationStatusCard'; +import { DeviceWithVerification } from './types'; + +interface Props { + device?: DeviceWithVerification; + isLoading: boolean; +} + +const CurrentDeviceSection: React.FC = ({ + device, isLoading, +}) => { + const [isExpanded, setIsExpanded] = useState(false); + + return + { isLoading && } + { !!device && <> + + setIsExpanded(!isExpanded)} + /> + + { isExpanded && } +
+ + + } +
; +}; + +export default CurrentDeviceSection; diff --git a/src/components/views/settings/devices/DeviceDetails.tsx b/src/components/views/settings/devices/DeviceDetails.tsx new file mode 100644 index 00000000000..5a58efaa887 --- /dev/null +++ b/src/components/views/settings/devices/DeviceDetails.tsx @@ -0,0 +1,81 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from 'react'; + +import { formatDate } from '../../../../DateUtils'; +import { _t } from '../../../../languageHandler'; +import Heading from '../../typography/Heading'; +import { DeviceVerificationStatusCard } from './DeviceVerificationStatusCard'; +import { DeviceWithVerification } from './types'; + +interface Props { + device: DeviceWithVerification; +} + +interface MetadataTable { + heading?: string; + values: { label: string, value?: string | React.ReactNode }[]; +} + +const DeviceDetails: React.FC = ({ device }) => { + const metadata: MetadataTable[] = [ + { + values: [ + { label: _t('Session ID'), value: device.device_id }, + { + label: _t('Last activity'), + value: device.last_seen_ts && formatDate(new Date(device.last_seen_ts)), + }, + ], + }, + { + heading: _t('Device'), + values: [ + { label: _t('IP address'), value: device.last_seen_ip }, + ], + }, + ]; + return
+
+ { device.display_name ?? device.device_id } + +
+
+

{ _t('Session details') }

+ { metadata.map(({ heading, values }, index) => + { heading && + + + + } + + + { values.map(({ label, value }) => + + + ) } + +
{ heading }
{ label }{ value }
, + ) } +
+
; +}; + +export default DeviceDetails; diff --git a/src/components/views/settings/devices/DeviceExpandDetailsButton.tsx b/src/components/views/settings/devices/DeviceExpandDetailsButton.tsx new file mode 100644 index 00000000000..a0293fec64f --- /dev/null +++ b/src/components/views/settings/devices/DeviceExpandDetailsButton.tsx @@ -0,0 +1,43 @@ +/* +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 classNames from 'classnames'; +import React from 'react'; + +import { Icon as CaretIcon } from '../../../../../res/img/feather-customised/dropdown-arrow.svg'; +import { _t } from '../../../../languageHandler'; +import AccessibleButton from '../../elements/AccessibleButton'; + +interface Props { + isExpanded: boolean; + onClick: () => void; +} + +const DeviceExpandDetailsButton: React.FC = ({ isExpanded, onClick, ...rest }) => { + return + + ; +}; + +export default DeviceExpandDetailsButton; diff --git a/src/components/views/settings/devices/DeviceSecurityCard.tsx b/src/components/views/settings/devices/DeviceSecurityCard.tsx new file mode 100644 index 00000000000..01fe4888821 --- /dev/null +++ b/src/components/views/settings/devices/DeviceSecurityCard.tsx @@ -0,0 +1,55 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import classNames from 'classnames'; +import React from 'react'; + +import { Icon as VerifiedIcon } from '../../../../../res/img/e2e/verified.svg'; +import { Icon as UnverifiedIcon } from '../../../../../res/img/e2e/warning.svg'; +import { Icon as InactiveIcon } from '../../../../../res/img/element-icons/settings/inactive.svg'; +import { DeviceSecurityVariation } from './types'; +interface Props { + variation: DeviceSecurityVariation; + heading: string; + description: string | React.ReactNode; + children?: React.ReactNode; +} + +const VariationIcon: Record>> = { + [DeviceSecurityVariation.Inactive]: InactiveIcon, + [DeviceSecurityVariation.Verified]: VerifiedIcon, + [DeviceSecurityVariation.Unverified]: UnverifiedIcon, +}; + +const DeviceSecurityIcon: React.FC<{ variation: DeviceSecurityVariation }> = ({ variation }) => { + const Icon = VariationIcon[variation]; + return
+ +
; +}; + +const DeviceSecurityCard: React.FC = ({ variation, heading, description, children }) => { + return
+ +
+

{ heading }

+

{ description }

+ { children } +
+
; +}; + +export default DeviceSecurityCard; diff --git a/src/components/views/settings/devices/DeviceTile.tsx b/src/components/views/settings/devices/DeviceTile.tsx index 03d952fbb1e..c791d2cd259 100644 --- a/src/components/views/settings/devices/DeviceTile.tsx +++ b/src/components/views/settings/devices/DeviceTile.tsx @@ -15,23 +15,25 @@ limitations under the License. */ import React, { Fragment } from "react"; -import { IMyDevice } from "matrix-js-sdk/src/matrix"; +import { Icon as InactiveIcon } from '../../../../../res/img/element-icons/settings/inactive.svg'; import { _t } from "../../../../languageHandler"; import { formatDate, formatRelativeTime } from "../../../../DateUtils"; import TooltipTarget from "../../elements/TooltipTarget"; import { Alignment } from "../../elements/Tooltip"; import Heading from "../../typography/Heading"; - -interface Props { - device: IMyDevice; +import { INACTIVE_DEVICE_AGE_DAYS, isDeviceInactive } from "./filter"; +import { DeviceWithVerification } from "./types"; +export interface DeviceTileProps { + device: DeviceWithVerification; children?: React.ReactNode; + onClick?: () => void; } -const DeviceTileName: React.FC<{ device: IMyDevice }> = ({ device }) => { +const DeviceTileName: React.FC<{ device: DeviceWithVerification }> = ({ device }) => { if (device.display_name) { return @@ -44,7 +46,8 @@ const DeviceTileName: React.FC<{ device: IMyDevice }> = ({ device }) => { ; }; -const MS_6_DAYS = 6 * 24 * 60 * 60 * 1000; +const MS_DAY = 24 * 60 * 60 * 1000; +const MS_6_DAYS = 6 * MS_DAY; const formatLastActivity = (timestamp: number, now = new Date().getTime()): string => { // less than a week ago if (timestamp + MS_6_DAYS >= now) { @@ -55,19 +58,42 @@ const formatLastActivity = (timestamp: number, now = new Date().getTime()): stri return formatRelativeTime(new Date(timestamp)); }; -const DeviceMetadata: React.FC<{ value: string, id: string }> = ({ value, id }) => ( +const getInactiveMetadata = (device: DeviceWithVerification): { id: string, value: React.ReactNode } | undefined => { + const isInactive = isDeviceInactive(device); + + if (!isInactive) { + return undefined; + } + return { id: 'inactive', value: ( + <> + + { + _t('Inactive for %(inactiveAgeDays)s+ days', { inactiveAgeDays: INACTIVE_DEVICE_AGE_DAYS }) + + ` (${formatLastActivity(device.last_seen_ts)})` + } + ), + }; +}; + +const DeviceMetadata: React.FC<{ value: string | React.ReactNode, id: string }> = ({ value, id }) => ( value ? { value } : null ); -const DeviceTile: React.FC = ({ device, children }) => { +const DeviceTile: React.FC = ({ device, children, onClick }) => { + const inactive = getInactiveMetadata(device); const lastActivity = device.last_seen_ts && `${_t('Last activity')} ${formatLastActivity(device.last_seen_ts)}`; - const metadata = [ - { id: 'lastActivity', value: lastActivity }, - { id: 'lastSeenIp', value: device.last_seen_ip }, - ]; + const verificationStatus = device.isVerified ? _t('Verified') : _t('Unverified'); + // if device is inactive, don't display last activity or verificationStatus + const metadata = inactive + ? [inactive, { id: 'lastSeenIp', value: device.last_seen_ip }] + : [ + { id: 'isVerified', value: verificationStatus }, + { id: 'lastActivity', value: lastActivity }, + { id: 'lastSeenIp', value: device.last_seen_ip }, + ]; - return
-
+ return
+
{ metadata.map(({ id, value }, index) => diff --git a/src/components/views/settings/devices/DeviceVerificationStatusCard.tsx b/src/components/views/settings/devices/DeviceVerificationStatusCard.tsx new file mode 100644 index 00000000000..a59fd64d638 --- /dev/null +++ b/src/components/views/settings/devices/DeviceVerificationStatusCard.tsx @@ -0,0 +1,45 @@ +/* +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 { _t } from '../../../../languageHandler'; +import DeviceSecurityCard from './DeviceSecurityCard'; +import { + DeviceSecurityVariation, + DeviceWithVerification, +} from './types'; + +interface Props { + device: DeviceWithVerification; +} + +export const DeviceVerificationStatusCard: React.FC = ({ + device, +}) => { + const securityCardProps = device?.isVerified ? { + variation: DeviceSecurityVariation.Verified, + heading: _t('Verified session'), + description: _t('This session is ready for secure messaging.'), + } : { + variation: DeviceSecurityVariation.Unverified, + heading: _t('Unverified session'), + description: _t('Verify or sign out from this session for best security and reliability.'), + }; + return ; +}; diff --git a/src/components/views/settings/devices/FilteredDeviceList.tsx b/src/components/views/settings/devices/FilteredDeviceList.tsx new file mode 100644 index 00000000000..5af3d30a366 --- /dev/null +++ b/src/components/views/settings/devices/FilteredDeviceList.tsx @@ -0,0 +1,220 @@ +/* +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 { _t } from '../../../../languageHandler'; +import AccessibleButton from '../../elements/AccessibleButton'; +import Dropdown from '../../elements/Dropdown'; +import DeviceDetails from './DeviceDetails'; +import DeviceExpandDetailsButton from './DeviceExpandDetailsButton'; +import DeviceSecurityCard from './DeviceSecurityCard'; +import DeviceTile from './DeviceTile'; +import { + filterDevicesBySecurityRecommendation, + INACTIVE_DEVICE_AGE_DAYS, +} from './filter'; +import { + DevicesDictionary, + DeviceSecurityVariation, + DeviceWithVerification, +} from './types'; + +interface Props { + devices: DevicesDictionary; + expandedDeviceIds: DeviceWithVerification['device_id'][]; + filter?: DeviceSecurityVariation; + onFilterChange: (filter: DeviceSecurityVariation | undefined) => void; + onDeviceExpandToggle: (deviceId: DeviceWithVerification['device_id']) => void; +} + +// devices without timestamp metadata should be sorted last +const sortDevicesByLatestActivity = (left: DeviceWithVerification, right: DeviceWithVerification) => + (right.last_seen_ts || 0) - (left.last_seen_ts || 0); + +const getFilteredSortedDevices = (devices: DevicesDictionary, filter: DeviceSecurityVariation) => + filterDevicesBySecurityRecommendation(Object.values(devices), filter ? [filter] : []) + .sort(sortDevicesByLatestActivity); + +const ALL_FILTER_ID = 'ALL'; + +const FilterSecurityCard: React.FC<{ filter?: DeviceSecurityVariation | string }> = ({ filter }) => { + switch (filter) { + case DeviceSecurityVariation.Verified: + return
+ +
+ ; + case DeviceSecurityVariation.Unverified: + return
+ +
+ ; + case DeviceSecurityVariation.Inactive: + return
+ +
+ ; + default: + return null; + } +}; + +const getNoResultsMessage = (filter: DeviceSecurityVariation): string => { + switch (filter) { + case DeviceSecurityVariation.Verified: + return _t('No verified sessions found.'); + case DeviceSecurityVariation.Unverified: + return _t('No unverified sessions found.'); + case DeviceSecurityVariation.Inactive: + return _t('No inactive sessions found.'); + default: + return _t('No sessions found.'); + } +}; +interface NoResultsProps { filter: DeviceSecurityVariation, clearFilter: () => void} +const NoResults: React.FC = ({ filter, clearFilter }) => +
+ { getNoResultsMessage(filter) } + { + /* No clear filter button when filter is falsy (ie 'All') */ + !!filter && + <> +   + + { _t('Show all') } + + + } +
; + +const DeviceListItem: React.FC<{ + device: DeviceWithVerification; + isExpanded: boolean; + onDeviceExpandToggle: () => void; +}> = ({ + device, isExpanded, onDeviceExpandToggle, +}) =>
  • + + + + { isExpanded && } +
  • ; + +/** + * Filtered list of devices + * Sorted by latest activity descending + */ +const FilteredDeviceList: React.FC = ({ + devices, + filter, + expandedDeviceIds, + onFilterChange, + onDeviceExpandToggle, +}) => { + const sortedDevices = getFilteredSortedDevices(devices, filter); + + const options = [ + { id: ALL_FILTER_ID, label: _t('All') }, + { + id: DeviceSecurityVariation.Verified, + label: _t('Verified'), + description: _t('Ready for secure messaging'), + }, + { + id: DeviceSecurityVariation.Unverified, + label: _t('Unverified'), + description: _t('Not ready for secure messaging'), + }, + { + id: DeviceSecurityVariation.Inactive, + label: _t('Inactive'), + description: _t( + 'Inactive for %(inactiveAgeDays)s days or longer', + { inactiveAgeDays: INACTIVE_DEVICE_AGE_DAYS }, + ), + }, + ]; + + const onFilterOptionChange = (filterId: DeviceSecurityVariation | typeof ALL_FILTER_ID) => { + onFilterChange(filterId === ALL_FILTER_ID ? undefined : filterId as DeviceSecurityVariation); + }; + + return
    +
    + + { _t('Sessions') } + + + { options.map(({ id, label }) => +
    { label }
    , + ) } +
    +
    + { !!sortedDevices.length + ? + : onFilterChange(undefined)} /> + } +
      + { sortedDevices.map((device) => onDeviceExpandToggle(device.device_id)} + />, + ) } +
    +
    + ; +}; + +export default FilteredDeviceList; diff --git a/src/components/views/settings/devices/SecurityRecommendations.tsx b/src/components/views/settings/devices/SecurityRecommendations.tsx new file mode 100644 index 00000000000..00181f5674a --- /dev/null +++ b/src/components/views/settings/devices/SecurityRecommendations.tsx @@ -0,0 +1,103 @@ +/* +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 { _t } from '../../../../languageHandler'; +import AccessibleButton from '../../elements/AccessibleButton'; +import SettingsSubsection from '../shared/SettingsSubsection'; +import DeviceSecurityCard from './DeviceSecurityCard'; +import { filterDevicesBySecurityRecommendation, INACTIVE_DEVICE_AGE_DAYS } from './filter'; +import { + DeviceSecurityVariation, + DeviceWithVerification, + DevicesDictionary, +} from './types'; + +interface Props { + devices: DevicesDictionary; +} + +const SecurityRecommendations: React.FC = ({ devices }) => { + const devicesArray = Object.values(devices); + + const unverifiedDevicesCount = filterDevicesBySecurityRecommendation( + devicesArray, + [DeviceSecurityVariation.Unverified], + ).length; + const inactiveDevicesCount = filterDevicesBySecurityRecommendation( + devicesArray, + [DeviceSecurityVariation.Inactive], + ).length; + + if (!(unverifiedDevicesCount | inactiveDevicesCount)) { + return null; + } + + const inactiveAgeDays = INACTIVE_DEVICE_AGE_DAYS; + + // TODO(kerrya) stubbed until PSG-640/652 + const noop = () => {}; + + return + { + !!unverifiedDevicesCount && + + + { _t('View all') + ` (${unverifiedDevicesCount})` } + + + } + { + !!inactiveDevicesCount && + <> + { !!unverifiedDevicesCount &&
    } + + + { _t('View all') + ` (${inactiveDevicesCount})` } + + + + } + ; +}; + +export default SecurityRecommendations; diff --git a/src/components/views/settings/devices/SelectableDeviceTile.tsx b/src/components/views/settings/devices/SelectableDeviceTile.tsx new file mode 100644 index 00000000000..e232e5ff50a --- /dev/null +++ b/src/components/views/settings/devices/SelectableDeviceTile.tsx @@ -0,0 +1,42 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from 'react'; + +import StyledCheckbox, { CheckboxStyle } from '../../elements/StyledCheckbox'; +import DeviceTile, { DeviceTileProps } from './DeviceTile'; + +interface Props extends DeviceTileProps { + isSelected: boolean; + onClick: () => void; +} + +const SelectableDeviceTile: React.FC = ({ children, device, isSelected, onClick }) => { + return
    + + + { children } + +
    ; +}; + +export default SelectableDeviceTile; diff --git a/src/components/views/settings/devices/deleteDevices.tsx b/src/components/views/settings/devices/deleteDevices.tsx new file mode 100644 index 00000000000..8decacae78b --- /dev/null +++ b/src/components/views/settings/devices/deleteDevices.tsx @@ -0,0 +1,83 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { MatrixClient } from "matrix-js-sdk/src/matrix"; +import { IAuthData } from "matrix-js-sdk/src/interactive-auth"; + +import { _t } from "../../../../languageHandler"; +import Modal from "../../../../Modal"; +import { InteractiveAuthCallback } from "../../../structures/InteractiveAuth"; +import { SSOAuthEntry } from "../../auth/InteractiveAuthEntryComponents"; +import InteractiveAuthDialog from "../../dialogs/InteractiveAuthDialog"; + +const makeDeleteRequest = ( + matrixClient: MatrixClient, deviceIds: string[], +) => async (auth?: IAuthData): Promise => { + await matrixClient.deleteMultipleDevices(deviceIds, auth); +}; + +export const deleteDevicesWithInteractiveAuth = async ( + matrixClient: MatrixClient, deviceIds: string[], onFinished?: InteractiveAuthCallback, +) => { + if (!deviceIds.length) { + return; + } + try { + await makeDeleteRequest(matrixClient, deviceIds)(); + // no interactive auth needed + onFinished(true, undefined); + } catch (error) { + if (error.httpStatus !== 401 || !error.data?.flows) { + // doesn't look like an interactive-auth failure + throw error; + } + + // pop up an interactive auth dialog + + const numDevices = deviceIds.length; + const dialogAesthetics = { + [SSOAuthEntry.PHASE_PREAUTH]: { + title: _t("Use Single Sign On to continue"), + body: _t("Confirm logging out these devices by using Single Sign On to prove your identity.", { + count: numDevices, + }), + continueText: _t("Single Sign On"), + continueKind: "primary", + }, + [SSOAuthEntry.PHASE_POSTAUTH]: { + title: _t("Confirm signing out these devices", { + count: numDevices, + }), + body: _t("Click the button below to confirm signing out these devices.", { + count: numDevices, + }), + continueText: _t("Sign out devices", { count: numDevices }), + continueKind: "danger", + }, + }; + Modal.createDialog(InteractiveAuthDialog, { + title: _t("Authentication"), + matrixClient: matrixClient, + authData: error.data, + onFinished, + makeRequest: makeDeleteRequest(matrixClient, deviceIds), + aestheticsForStagePhases: { + [SSOAuthEntry.LOGIN_TYPE]: dialogAesthetics, + [SSOAuthEntry.UNSTABLE_LOGIN_TYPE]: dialogAesthetics, + }, + }); + } +}; diff --git a/src/components/views/settings/devices/filter.ts b/src/components/views/settings/devices/filter.ts new file mode 100644 index 00000000000..ad2bc92152c --- /dev/null +++ b/src/components/views/settings/devices/filter.ts @@ -0,0 +1,43 @@ +/* +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 { DeviceWithVerification, DeviceSecurityVariation } from "./types"; + +type DeviceFilterCondition = (device: DeviceWithVerification) => boolean; + +const MS_DAY = 24 * 60 * 60 * 1000; +export const INACTIVE_DEVICE_AGE_MS = 7.776e+9; // 90 days +export const INACTIVE_DEVICE_AGE_DAYS = INACTIVE_DEVICE_AGE_MS / MS_DAY; + +export const isDeviceInactive: DeviceFilterCondition = device => + !!device.last_seen_ts && device.last_seen_ts < Date.now() - INACTIVE_DEVICE_AGE_MS; + +const filters: Record = { + [DeviceSecurityVariation.Verified]: device => !!device.isVerified, + [DeviceSecurityVariation.Unverified]: device => !device.isVerified, + [DeviceSecurityVariation.Inactive]: isDeviceInactive, +}; + +export const filterDevicesBySecurityRecommendation = ( + devices: DeviceWithVerification[], + securityVariations: DeviceSecurityVariation[], +) => { + const activeFilters = securityVariations.map(variation => filters[variation]); + if (!activeFilters.length) { + return devices; + } + return devices.filter(device => activeFilters.every(filter => filter(device))); +}; diff --git a/src/components/views/settings/devices/types.ts b/src/components/views/settings/devices/types.ts new file mode 100644 index 00000000000..1f3328c09ef --- /dev/null +++ b/src/components/views/settings/devices/types.ts @@ -0,0 +1,26 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { IMyDevice } from "matrix-js-sdk/src/matrix"; + +export type DeviceWithVerification = IMyDevice & { isVerified: boolean | null }; +export type DevicesDictionary = Record; + +export enum DeviceSecurityVariation { + Verified = 'Verified', + Unverified = 'Unverified', + Inactive = 'Inactive', +} diff --git a/src/components/views/settings/devices/useOwnDevices.ts b/src/components/views/settings/devices/useOwnDevices.ts new file mode 100644 index 00000000000..ec5ee1ca189 --- /dev/null +++ b/src/components/views/settings/devices/useOwnDevices.ts @@ -0,0 +1,105 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { useContext, useEffect, useState } from "react"; +import { IMyDevice, MatrixClient } from "matrix-js-sdk/src/matrix"; +import { CrossSigningInfo } from "matrix-js-sdk/src/crypto/CrossSigning"; +import { logger } from "matrix-js-sdk/src/logger"; + +import MatrixClientContext from "../../../../contexts/MatrixClientContext"; +import { DevicesDictionary } from "./types"; + +const isDeviceVerified = ( + matrixClient: MatrixClient, + crossSigningInfo: CrossSigningInfo, + device: IMyDevice, +): boolean | null => { + try { + const deviceInfo = matrixClient.getStoredDevice(matrixClient.getUserId(), device.device_id); + return crossSigningInfo.checkDeviceTrust( + crossSigningInfo, + deviceInfo, + false, + true, + ).isCrossSigningVerified(); + } catch (error) { + logger.error("Error getting device cross-signing info", error); + return null; + } +}; + +const fetchDevicesWithVerification = async (matrixClient: MatrixClient): Promise => { + const { devices } = await matrixClient.getDevices(); + const crossSigningInfo = matrixClient.getStoredCrossSigningForUser(matrixClient.getUserId()); + + const devicesDict = devices.reduce((acc, device: IMyDevice) => ({ + ...acc, + [device.device_id]: { + ...device, + isVerified: isDeviceVerified(matrixClient, crossSigningInfo, device), + }, + }), {}); + + return devicesDict; +}; + +export enum OwnDevicesError { + Unsupported = 'Unsupported', + Default = 'Default', +} +type DevicesState = { + devices: DevicesDictionary; + currentDeviceId: string; + isLoading: boolean; + error?: OwnDevicesError; +}; +export const useOwnDevices = (): DevicesState => { + const matrixClient = useContext(MatrixClientContext); + + const currentDeviceId = matrixClient.getDeviceId(); + + const [devices, setDevices] = useState({}); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(); + + useEffect(() => { + const getDevicesAsync = async () => { + setIsLoading(true); + try { + const devices = await fetchDevicesWithVerification(matrixClient); + setDevices(devices); + setIsLoading(false); + } catch (error) { + if (error.httpStatus == 404) { + // 404 probably means the HS doesn't yet support the API. + setError(OwnDevicesError.Unsupported); + } else { + logger.error("Error loading sessions:", error); + setError(OwnDevicesError.Default); + } + setIsLoading(false); + } + }; + getDevicesAsync(); + }, [matrixClient]); + + return { + devices, + currentDeviceId, + isLoading, + error, + }; +}; diff --git a/src/components/views/settings/shared/SettingsSubsection.tsx b/src/components/views/settings/shared/SettingsSubsection.tsx new file mode 100644 index 00000000000..6d23a080caa --- /dev/null +++ b/src/components/views/settings/shared/SettingsSubsection.tsx @@ -0,0 +1,37 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React, { HTMLAttributes } from "react"; + +import Heading from "../../typography/Heading"; + +export interface SettingsSubsectionProps extends HTMLAttributes { + heading: string; + description?: string | React.ReactNode; + children?: React.ReactNode; +} + +const SettingsSubsection: React.FC = ({ heading, description, children, ...rest }) => ( +
    + { heading } + { !!description &&
    { description }
    } +
    + { children } +
    +
    +); + +export default SettingsSubsection; diff --git a/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.tsx b/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.tsx index 5a7aeba565d..2322d705102 100644 --- a/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.tsx @@ -46,6 +46,7 @@ interface IState { export default class PreferencesUserSettingsTab extends React.Component { private static ROOM_LIST_SETTINGS = [ 'breadcrumbs', + "FTUE.userOnboardingButton", ]; private static SPACES_SETTINGS = [ diff --git a/src/components/views/settings/tabs/user/SessionManagerTab.tsx b/src/components/views/settings/tabs/user/SessionManagerTab.tsx index afa663392ba..c4878dbb372 100644 --- a/src/components/views/settings/tabs/user/SessionManagerTab.tsx +++ b/src/components/views/settings/tabs/user/SessionManagerTab.tsx @@ -14,13 +14,59 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from 'react'; +import React, { useState } from 'react'; import { _t } from "../../../../../languageHandler"; +import { useOwnDevices } from '../../devices/useOwnDevices'; +import SettingsSubsection from '../../shared/SettingsSubsection'; +import FilteredDeviceList from '../../devices/FilteredDeviceList'; +import CurrentDeviceSection from '../../devices/CurrentDeviceSection'; +import SecurityRecommendations from '../../devices/SecurityRecommendations'; +import { DeviceSecurityVariation, DeviceWithVerification } from '../../devices/types'; import SettingsTab from '../SettingsTab'; const SessionManagerTab: React.FC = () => { - return ; + const { devices, currentDeviceId, isLoading } = useOwnDevices(); + const [filter, setFilter] = useState(); + const [expandedDeviceIds, setExpandedDeviceIds] = useState([]); + + const onDeviceExpandToggle = (deviceId: DeviceWithVerification['device_id']): void => { + if (expandedDeviceIds.includes(deviceId)) { + setExpandedDeviceIds(expandedDeviceIds.filter(id => id !== deviceId)); + } else { + setExpandedDeviceIds([...expandedDeviceIds, deviceId]); + } + }; + + const { [currentDeviceId]: currentDevice, ...otherDevices } = devices; + const shouldShowOtherSessions = Object.keys(otherDevices).length > 0; + + return + + + { + shouldShowOtherSessions && + + + + } + ; }; export default SessionManagerTab; diff --git a/src/components/views/spaces/SpacePanel.tsx b/src/components/views/spaces/SpacePanel.tsx index e1f62445f74..129e6f3584e 100644 --- a/src/components/views/spaces/SpacePanel.tsx +++ b/src/components/views/spaces/SpacePanel.tsx @@ -132,6 +132,7 @@ const MetaSpaceButton = ({ selected, isPanelCollapsed, ...props }: IMetaSpaceBut "collapsed": isPanelCollapsed, })} role="treeitem" + aria-selected={selected} > ; @@ -282,6 +283,9 @@ const InnerSpacePanel = React.memo(({ style={isDraggingOver ? { pointerEvents: "none", } : undefined} + element="ul" + role="tree" + aria-label={_t("Spaces")} > { metaSpacesSection } { invites.map(s => ( @@ -321,7 +325,7 @@ const InnerSpacePanel = React.memo(({ const SpacePanel = () => { const [isPanelCollapsed, setPanelCollapsed] = useState(true); - const ref = useRef(); + const ref = useRef(); useLayoutEffect(() => { UIStore.instance.trackElementDimensions("SpacePanel", ref.current); return () => UIStore.instance.stopTrackingElementDimensions("SpacePanel"); @@ -340,11 +344,9 @@ const SpacePanel = () => { }}> { ({ onKeyDownHandler }) => ( -
      @@ -381,7 +383,7 @@ const SpacePanel = () => { -
    +
    ) } diff --git a/src/components/views/spaces/SpaceTreeLevel.tsx b/src/components/views/spaces/SpaceTreeLevel.tsx index 80d678d3b61..cab7bc3c76b 100644 --- a/src/components/views/spaces/SpaceTreeLevel.tsx +++ b/src/components/views/spaces/SpaceTreeLevel.tsx @@ -315,6 +315,7 @@ export class SpaceItem extends React.PureComponent { // eslint-disable-next-line @typescript-eslint/no-unused-vars const { tabIndex, ...restDragHandleProps } = dragHandleProps || {}; + const selected = activeSpaces.includes(space.roomId); return (
  • { className={itemClasses} ref={innerRef} aria-expanded={hasChildren ? !collapsed : undefined} + aria-selected={selected} role="treeitem" > { + PosthogTrackers.trackInteraction("WebRoomListUserOnboardingIgnoreButton", ev); + SettingsStore.setValue("FTUE.userOnboardingButton", null, SettingLevel.ACCOUNT, false); + }, []); + + const onClick = useCallback((ev: ButtonEvent) => { + PosthogTrackers.trackInteraction("WebRoomListUserOnboardingButton", ev); + defaultDispatcher.fire(Action.ViewHomePage); + }, []); + + const useCase = useSettingValue("FTUE.useCaseSelection"); + const visible = useSettingValue("FTUE.userOnboardingButton"); + if (!visible || minimized || !showUserOnboardingPage(useCase)) { + return null; + } + + return ( + + { !minimized && ( + <> +
    + + { _t("Welcome") } + + { context && !completed && ( +
    + { toPercentage(progress) }% +
    + ) } + +
    + + + ) } +
    + ); +} diff --git a/src/components/views/user-onboarding/UserOnboardingPage.tsx b/src/components/views/user-onboarding/UserOnboardingPage.tsx index 7ca13232986..cc90a3d09d5 100644 --- a/src/components/views/user-onboarding/UserOnboardingPage.tsx +++ b/src/components/views/user-onboarding/UserOnboardingPage.tsx @@ -19,6 +19,7 @@ import * as React from "react"; import { useInitialSyncComplete } from "../../../hooks/useIsInitialSyncComplete"; import { useSettingValue } from "../../../hooks/useSettings"; +import { useUserOnboardingContext } from "../../../hooks/useUserOnboardingContext"; import { useUserOnboardingTasks } from "../../../hooks/useUserOnboardingTasks"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; import SdkConfig from "../../../SdkConfig"; @@ -47,7 +48,8 @@ export function UserOnboardingPage({ justRegistered = false }: Props) { const pageUrl = getHomePageUrl(config); const useCase = useSettingValue("FTUE.useCaseSelection"); - const [completedTasks, waitingTasks] = useUserOnboardingTasks(); + const context = useUserOnboardingContext(); + const [completedTasks, waitingTasks] = useUserOnboardingTasks(context); const initialSyncComplete = useInitialSyncComplete(); const [showList, setShowList] = useState(false); diff --git a/src/events/EventTileFactory.tsx b/src/events/EventTileFactory.tsx index 9315741c593..88982b373fe 100644 --- a/src/events/EventTileFactory.tsx +++ b/src/events/EventTileFactory.tsx @@ -225,6 +225,10 @@ export function pickFactory( return noEventFactoryFactory(); // improper event type to render } + if (STATE_EVENT_TILE_TYPES[evType] === TextualEventFactory && !hasText(mxEvent, showHiddenEvents)) { + return noEventFactoryFactory(); + } + return STATE_EVENT_TILE_TYPES[evType] ?? noEventFactoryFactory(); } diff --git a/src/hooks/useUserOnboardingContext.ts b/src/hooks/useUserOnboardingContext.ts index 8b1d6bcfb4f..d1f45217f81 100644 --- a/src/hooks/useUserOnboardingContext.ts +++ b/src/hooks/useUserOnboardingContext.ts @@ -14,8 +14,9 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { useCallback, useEffect, useState } from "react"; +import { logger } from "matrix-js-sdk/src/logger"; import { ClientEvent, IMyDevice, Room } from "matrix-js-sdk/src/matrix"; +import { useCallback, useEffect, useState } from "react"; import { MatrixClientPeg } from "../MatrixClientPeg"; import DMRoomMap from "../utils/DMRoomMap"; @@ -33,18 +34,23 @@ export function useUserOnboardingContext(): UserOnboardingContext | null { const cli = MatrixClientPeg.get(); const handler = useCallback(async () => { - const profile = await cli.getProfileInfo(cli.getUserId()); - - const myDevice = cli.getDeviceId(); - const devices = await cli.getDevices(); - - const dmRooms = DMRoomMap.shared().getUniqueRoomsWithIndividuals() ?? {}; - setContext({ - avatar: profile?.avatar_url ?? null, - myDevice, - devices: devices.devices, - dmRooms: dmRooms, - }); + try { + const profile = await cli.getProfileInfo(cli.getUserId()); + + const myDevice = cli.getDeviceId(); + const devices = await cli.getDevices(); + + const dmRooms = DMRoomMap.shared().getUniqueRoomsWithIndividuals() ?? {}; + setContext({ + avatar: profile?.avatar_url ?? null, + myDevice, + devices: devices.devices, + dmRooms: dmRooms, + }); + } catch (e) { + logger.warn("Could not load context for user onboarding task list: ", e); + setContext(null); + } }, [cli]); useEventEmitter(cli, ClientEvent.AccountData, handler); diff --git a/src/hooks/useUserOnboardingTasks.ts b/src/hooks/useUserOnboardingTasks.ts index daef154de02..9ac0b5d98b5 100644 --- a/src/hooks/useUserOnboardingTasks.ts +++ b/src/hooks/useUserOnboardingTasks.ts @@ -27,7 +27,7 @@ import { Notifier } from "../Notifier"; import PosthogTrackers from "../PosthogTrackers"; import { UseCase } from "../settings/enums/UseCase"; import { useSettingValue } from "./useSettings"; -import { UserOnboardingContext, useUserOnboardingContext } from "./useUserOnboardingContext"; +import { UserOnboardingContext } from "./useUserOnboardingContext"; export interface UserOnboardingTask { id: string; @@ -140,13 +140,12 @@ const tasks: InternalUserOnboardingTask[] = [ }, ]; -export function useUserOnboardingTasks(): [UserOnboardingTask[], UserOnboardingTask[]] { +export function useUserOnboardingTasks(context: UserOnboardingContext): [UserOnboardingTask[], UserOnboardingTask[]] { const useCase = useSettingValue("FTUE.useCaseSelection") ?? UseCase.Skip; const relevantTasks = useMemo( () => tasks.filter(it => !it.relevant || it.relevant.includes(useCase)), [useCase], ); - const onboardingInfo = useUserOnboardingContext(); - const completedTasks = relevantTasks.filter(it => onboardingInfo && it.completed(onboardingInfo)); + const completedTasks = relevantTasks.filter(it => context && it.completed(context)); return [completedTasks, relevantTasks.filter(it => !completedTasks.includes(it))]; } diff --git a/src/i18n/strings/cs.json b/src/i18n/strings/cs.json index ef39579c83a..e068de9d5c1 100644 --- a/src/i18n/strings/cs.json +++ b/src/i18n/strings/cs.json @@ -3478,5 +3478,7 @@ "It’s what you’re here for, so lets get to it": "Kvůli tomu jste tady, tak se do toho pusťte", "Find and invite your friends": "Najděte a pozvěte své přátele", "You made it!": "Zvládli jste to!", - "Help": "Nápověda" + "Help": "Nápověda", + "iOS": "iOS", + "Android": "Android" } diff --git a/src/i18n/strings/de_DE.json b/src/i18n/strings/de_DE.json index cf72b7ed8d6..4cb248cb20f 100644 --- a/src/i18n/strings/de_DE.json +++ b/src/i18n/strings/de_DE.json @@ -412,7 +412,7 @@ "Key request sent.": "Schlüsselanfrage gesendet.", "Submit debug logs": "Fehlerbericht abschicken", "Code": "Code", - "Opens the Developer Tools dialog": "Entwickler-Werkzeuge öffnen", + "Opens the Developer Tools dialog": "Öffnet die Entwicklerwerkzeuge", "You don't currently have any stickerpacks enabled": "Keine Stickerpakete aktiviert", "Stickerpack": "Stickerpaket", "Fetching third party location failed": "Das Abrufen des Drittanbieterstandorts ist fehlgeschlagen", @@ -812,7 +812,7 @@ "The user must be unbanned before they can be invited.": "Verbannte Nutzer können nicht eingeladen werden.", "Show read receipts sent by other users": "Lesebestätigungen anzeigen", "Scissors": "Schere", - "Upgrade to your own domain": "
    Upgrade zu deiner eigenen Domain", + "Upgrade to your own domain": "Zu deiner eigenen Domain aufwerten", "Accept all %(invitedRooms)s invites": "Akzeptiere alle %(invitedRooms)s Einladungen", "Change room avatar": "Raumbild ändern", "Change room name": "Raumname ändern", @@ -918,7 +918,7 @@ "This action requires accessing the default identity server to validate an email address or phone number, but the server does not have any terms of service.": "Diese Handlung erfordert es, auf den Standardidentitätsserver zuzugreifen, um eine E-Mail-Adresse oder Telefonnummer zu validieren, aber der Server hat keine Nutzungsbedingungen.", "Only continue if you trust the owner of the server.": "Fahre nur fort, wenn du den Betreibern des Servers vertraust.", "Trust": "Vertrauen", - "Custom (%(level)s)": "Selbstdefiniert (%(level)s)", + "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", "Use an identity server to invite by email. Manage in Settings.": "Mit einem Identitätsserver kannst du über E-Mail Einladungen zu verschicken. Verwalte ihn in den Einstellungen.", "%(name)s (%(userId)s)": "%(name)s (%(userId)s)", @@ -1855,7 +1855,7 @@ "Messages here are end-to-end encrypted. Verify %(displayName)s in their profile - tap on their avatar.": "Nachrichten hier sind Ende-zu-Ende-verschlüsselt. Verifiziere %(displayName)s im deren Profil - klicke auf deren Avatar.", "Messages in this room are end-to-end encrypted. When people join, you can verify them in their profile, just tap on their avatar.": "Nachrichten in diesem Raum sind Ende-zu-Ende-verschlüsselt. Wenn Personen beitreten, kannst du sie in ihrem Profil verifizieren, indem du auf deren Avatar klickst.", "Comment": "Kommentar", - "Please view existing bugs on Github first. No match? Start a new one.": "Bitte wirf einen Blick auf existierende Programmfehler auf Github. Keinen gefunden? Erstelle einen neuen.", + "Please view existing bugs on Github first. No match? Start a new one.": "Bitte wirf einen Blick auf existierende Programmfehler auf Github. Keinen passenden gefunden? Erstelle einen neuen.", "PRO TIP: If you start a bug, please submit debug logs to help us track down the problem.": "PRO TIPP: Wenn du einen Programmfehler meldest, füge bitte Debug-Logs hinzu um uns zu helfen das Problem zu finden.", "Invite by email": "Via Email einladen", "Start a conversation with someone using their name, email address or username (like ).": "Beginne eine Konversation mit jemanden unter Benutzung des Namens, der Email-Adresse oder der Matrix-ID (wie ).", @@ -3390,5 +3390,24 @@ "Failed to set direct message tag": "Fehler beim Setzen der Nachrichtenmarkierung", "Resent!": "Verschickt!", "Did not receive it? Resend it": "Nicht angekommen? Erneut senden", - "Unread email icon": "Ungelesene E-Mail Symbol" + "Unread email icon": "Ungelesene E-Mail Symbol", + "Video rooms are always-on VoIP channels embedded within a room in %(brand)s.": "Videoräume sind dauerhaft aktive VoIP-Kanäle, die in einem Raum in %(brand)s eingebettet sind.", + "A new way to chat over voice and video in %(brand)s.": "Eine neue Art in %(brand)s über Audio und Video zu kommunizieren.", + "Coworkers and teams": "Kollegen und Gruppen", + "Friends and family": "Freunde und Familie", + "iOS": "iOS", + "Android": "Android", + "You can't disable this later. The room will be encrypted but the embedded call will not.": "Dies kann später nicht deaktiviert werden. Der Raum wird verschlüsselt sein, nicht aber der eingebettete Anruf.", + "You need to have the right permissions in order to share locations in this room.": "Du brauchst du richtigen Berechtigungen, um deinen Live-Standort in diesem Raum zu teilen.", + "Who will you chat to the most?": "Mit wem wirst du am meisten chatten?", + "We're creating a room with %(names)s": "Wir erstellen einen Raum mit %(names)s", + "Messages in this chat will be end-to-end encrypted.": "Nachrichten in dieser Konversation werden Ende-zu-Ende verschlüsselt.", + "Send your first message to invite to chat": "Schreibe die erste Nachricht, um zur Konversation einzuladen", + "Your server doesn't support disabling sending read receipts.": "Dein Server unterstützt das deaktivieren von Lesebestätigungen nicht.", + "Send read receipts": "Sende Lesebestätigungen", + "Share your activity and status with others.": "Teile anderen deine Aktivität und deinen Status mit.", + "Presence": "Anwesenheit", + "Deactivating your account is a permanent action — be careful!": "Die Deaktivierung deines Kontos ist unwiderruflich - sei vorsichtig!", + "Favourite Messages (under active development)": "Favorisierte Nachrichten (in aktiver Entwicklung)", + "Use new session manager (under active development)": "Benutze neue Sitzungsverwaltung (in aktiver Entwicklung)" } diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 9716fc98139..d344f164ad2 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -899,7 +899,6 @@ "Right panel stays open (defaults to room member list)": "Right panel stays open (defaults to room member list)", "Jump to date (adds /jumptodate and jump to date headers)": "Jump to date (adds /jumptodate and jump to date headers)", "Send read receipts": "Send read receipts", - "Right-click message context menu": "Right-click message context menu", "Live Location Sharing (temporary implementation: locations persist in room history)": "Live Location Sharing (temporary implementation: locations persist in room history)", "Favourite Messages (under active development)": "Favourite Messages (under active development)", "Use new session manager (under active development)": "Use new session manager (under active development)", @@ -951,6 +950,7 @@ "Order rooms by name": "Order rooms by name", "Show rooms with unread notifications first": "Show rooms with unread notifications first", "Show shortcuts to recently viewed rooms above the room list": "Show shortcuts to recently viewed rooms above the room list", + "Show shortcut to welcome checklist above the room list": "Show shortcut to welcome checklist above the room list", "Show hidden events in timeline": "Show hidden events in timeline", "Low bandwidth mode (requires compatible homeserver)": "Low bandwidth mode (requires compatible homeserver)", "Allow fallback call assist server turn.matrix.org when your homeserver does not offer one (your IP address would be shared during a call)": "Allow fallback call assist server turn.matrix.org when your homeserver does not offer one (your IP address would be shared during a call)", @@ -1147,6 +1147,7 @@ "Anchor": "Anchor", "Headphones": "Headphones", "Folder": "Folder", + "Welcome": "Welcome", "How are you finding Element so far?": "How are you finding Element so far?", "We’d appreciate any feedback on how you’re finding Element.": "We’d appreciate any feedback on how you’re finding Element.", "Feedback": "Feedback", @@ -1284,15 +1285,6 @@ "Session key:": "Session key:", "Your homeserver does not support device management.": "Your homeserver does not support device management.", "Unable to load device list": "Unable to load device list", - "Confirm logging out these devices by using Single Sign On to prove your identity.|other": "Confirm logging out these devices by using Single Sign On to prove your identity.", - "Confirm logging out these devices by using Single Sign On to prove your identity.|one": "Confirm logging out this device by using Single Sign On to prove your identity.", - "Confirm signing out these devices|other": "Confirm signing out these devices", - "Confirm signing out these devices|one": "Confirm signing out this device", - "Click the button below to confirm signing out these devices.|other": "Click the button below to confirm signing out these devices.", - "Click the button below to confirm signing out these devices.|one": "Click the button below to confirm signing out this device.", - "Sign out devices|other": "Sign out devices", - "Sign out devices|one": "Sign out device", - "Authentication": "Authentication", "Deselect all": "Deselect all", "Select all": "Select all", "Verified devices": "Verified devices", @@ -1563,6 +1555,8 @@ "Where you're signed in": "Where you're signed in", "Manage your signed-in devices below. A device's name is visible to people you communicate with.": "Manage your signed-in devices below. A device's name is visible to people you communicate with.", "Sessions": "Sessions", + "Other sessions": "Other sessions", + "For best security, verify your sessions and sign out from any session that you don't recognize or use anymore.": "For best security, verify your sessions and sign out from any session that you don't recognize or use anymore.", "Sidebar": "Sidebar", "Spaces to show": "Spaces to show", "Spaces are ways to group rooms and people. Alongside the spaces you're in, you can use some pre-built ones too.": "Spaces are ways to group rooms and people. Alongside the spaces you're in, you can use some pre-built ones too.", @@ -1691,7 +1685,49 @@ "Please enter verification code sent via text.": "Please enter verification code sent via text.", "Verification code": "Verification code", "Discovery options will appear once you have added a phone number above.": "Discovery options will appear once you have added a phone number above.", + "Current session": "Current session", + "Confirm logging out these devices by using Single Sign On to prove your identity.|other": "Confirm logging out these devices by using Single Sign On to prove your identity.", + "Confirm logging out these devices by using Single Sign On to prove your identity.|one": "Confirm logging out this device by using Single Sign On to prove your identity.", + "Confirm signing out these devices|other": "Confirm signing out these devices", + "Confirm signing out these devices|one": "Confirm signing out this device", + "Click the button below to confirm signing out these devices.|other": "Click the button below to confirm signing out these devices.", + "Click the button below to confirm signing out these devices.|one": "Click the button below to confirm signing out this device.", + "Sign out devices|other": "Sign out devices", + "Sign out devices|one": "Sign out device", + "Authentication": "Authentication", + "Session ID": "Session ID", "Last activity": "Last activity", + "Device": "Device", + "IP address": "IP address", + "Session details": "Session details", + "Toggle device details": "Toggle device details", + "Inactive for %(inactiveAgeDays)s+ days": "Inactive for %(inactiveAgeDays)s+ days", + "Verified": "Verified", + "Unverified": "Unverified", + "Verified session": "Verified session", + "This session is ready for secure messaging.": "This session is ready for secure messaging.", + "Unverified session": "Unverified session", + "Verify or sign out from this session for best security and reliability.": "Verify or sign out from this session for best security and reliability.", + "Verified sessions": "Verified sessions", + "For best security, sign out from any session that you don't recognize or use anymore.": "For best security, sign out from any session that you don't recognize or use anymore.", + "Unverified sessions": "Unverified sessions", + "Verify your sessions for enhanced secure messaging or sign out from those you don't recognize or use anymore.": "Verify your sessions for enhanced secure messaging or sign out from those you don't recognize or use anymore.", + "Inactive sessions": "Inactive sessions", + "Consider signing out from old sessions (%(inactiveAgeDays)s days or older) you don't use anymore": "Consider signing out from old sessions (%(inactiveAgeDays)s days or older) you don't use anymore", + "No verified sessions found.": "No verified sessions found.", + "No unverified sessions found.": "No unverified sessions found.", + "No inactive sessions found.": "No inactive sessions found.", + "No sessions found.": "No sessions found.", + "Show all": "Show all", + "All": "All", + "Ready for secure messaging": "Ready for secure messaging", + "Not ready for secure messaging": "Not ready for secure messaging", + "Inactive": "Inactive", + "Inactive for %(inactiveAgeDays)s days or longer": "Inactive for %(inactiveAgeDays)s days or longer", + "Filter devices": "Filter devices", + "Security recommendations": "Security recommendations", + "Improve your account security by following these recommendations": "Improve your account security by following these recommendations", + "View all": "View all", "Unable to remove contact information": "Unable to remove contact information", "Remove %(email)s?": "Remove %(email)s?", "Invalid Email Address": "Invalid Email Address", @@ -2211,7 +2247,6 @@ "Error decrypting video": "Error decrypting video", "Error processing voice message": "Error processing voice message", "Add reaction": "Add reaction", - "Show all": "Show all", "Reactions": "Reactions", "%(reactors)s reacted with %(content)s": "%(reactors)s reacted with %(content)s", "reacted with %(shortName)s": "reacted with %(shortName)s", @@ -2717,7 +2752,6 @@ "Confirm by comparing the following with the User Settings in your other session:": "Confirm by comparing the following with the User Settings in your other session:", "Confirm this user's session by comparing the following with their User Settings:": "Confirm this user's session by comparing the following with their User Settings:", "Session name": "Session name", - "Session ID": "Session ID", "Session key": "Session key", "If they don't match, the security of your communication may be compromised.": "If they don't match, the security of your communication may be compromised.", "Verify session": "Verify session", @@ -2832,8 +2866,8 @@ "%(name)s (%(userId)s) signed in to a new session without verifying it:": "%(name)s (%(userId)s) signed in to a new session without verifying it:", "Ask this user to verify their session, or manually verify it below.": "Ask this user to verify their session, or manually verify it below.", "Not Trusted": "Not Trusted", - "Manually Verify by Text": "Manually Verify by Text", - "Interactively verify by Emoji": "Interactively verify by Emoji", + "Manually verify by text": "Manually verify by text", + "Interactively verify by emoji": "Interactively verify by emoji", "Upload files (%(current)s of %(total)s)": "Upload files (%(current)s of %(total)s)", "Upload files": "Upload files", "Upload all": "Upload all", diff --git a/src/i18n/strings/et.json b/src/i18n/strings/et.json index b96a1e1451e..f98bc3a873f 100644 --- a/src/i18n/strings/et.json +++ b/src/i18n/strings/et.json @@ -3474,5 +3474,25 @@ "Get stuff done by finding your teammates": "Saa tööd tehtud üheskoos oma kaasteelistega", "Find and invite your co-workers": "Leia kolleege ja saada neile kutse", "Find friends": "Leia sõpru", - "Find and invite your friends": "Leia sõpru ja saada neile kutse" + "Find and invite your friends": "Leia sõpru ja saada neile kutse", + "Google Play and the Google Play logo are trademarks of Google LLC.": "Google Play ja Google Play logo on Google LLC kaubamärgid.", + "App Store® and the Apple logo® are trademarks of Apple Inc.": "App Store® ja Apple logo® on Apple Inc kaubamärgid.", + "Get it on F-Droid": "Laadi alla F-Droid'ist", + "Get it on Google Play": "Laadi alla Google Play'st", + "Android": "Android", + "Download on the App Store": "Laadi alla App Store'st", + "iOS": "iOS", + "Download %(brand)s Desktop": "Laadi alla %(brand)s töölaua rakendusena", + "Download %(brand)s": "Laadi alla %(brand)s", + "We’d appreciate any feedback on how you’re finding Element.": "Meile meeldiks kui sa saadad meile oma arvamuse Element'i kohta.", + "How are you finding Element so far?": "Mis mulje sulle Element seni on jätnud?", + "Help": "Abiteave", + "Your server doesn't support disabling sending read receipts.": "Sinu koduserver ei võimalda lugemisteatiste keelamist.", + "Share your activity and status with others.": "Jaga teistega oma olekut ja tegevusi.", + "Presence": "Olek võrgus", + "Send read receipts": "Saada lugemisteatiseid", + "Last activity": "Viimased tegevused", + "Sessions": "Sessionid", + "Use new session manager (under active development)": "Uus sessioonihaldur (aktiivselt arendamisel)", + "Current session": "Praegune sessioon" } diff --git a/src/i18n/strings/fr.json b/src/i18n/strings/fr.json index c384660c359..f48c7468d26 100644 --- a/src/i18n/strings/fr.json +++ b/src/i18n/strings/fr.json @@ -3454,7 +3454,7 @@ "Only %(count)s steps to go|other": "Plus que %(count)s étapes", "Welcome to %(brand)s": "Bienvenue sur %(brand)s", "Find your people": "Trouvez vos contacts", - "Keep ownership and control of community discussion.\nScale to support millions, with powerful moderation and interoperability.": "Gardez le contrôle sur la discussion de votre communauté.\nPrend en charge des millions de messages, avec une interopérabilité et une modération efficaces.", + "Keep ownership and control of community discussion.\nScale to support millions, with powerful moderation and interoperability.": "Gardez le contrôle sur la discussion de votre communauté.\nPrend en charge des millions de messages, avec une interopérabilité et une modération efficace.", "Find your co-workers": "Trouver vos collègues", "Secure messaging for work": "Messagerie sécurisée pour le travail", "Start your first chat": "Démarrer votre première conversation", @@ -3479,5 +3479,24 @@ "You made it!": "Vous avez réussi !", "Help": "Aide", "We’d appreciate any feedback on how you’re finding Element.": "Nous apprécierions toutes vos remarques sur Element.", - "How are you finding Element so far?": "Comment trouvez-vous Element jusque-là ?" + "How are you finding Element so far?": "Comment trouvez-vous Element jusque-là ?", + "Google Play and the Google Play logo are trademarks of Google LLC.": "Google Play et le logo Google Play sont des marques déposées de Google LLC.", + "App Store® and the Apple logo® are trademarks of Apple Inc.": "App Store® et le logo Apple® sont des marques déposées de Apple Inc.", + "Get it on F-Droid": "Récupérez-le sur F-Droid", + "Get it on Google Play": "Récupérez-le sur Google Play", + "Android": "Android", + "Download on the App Store": "Télécharger sur l’App Store", + "iOS": "iOS", + "Download %(brand)s Desktop": "Télécharger %(brand)s Desktop", + "Download %(brand)s": "Télécharger %(brand)s", + "We're creating a room with %(names)s": "Nous créons un salon avec %(names)s", + "Community ownership": "Propriété de la communauté", + "Your server doesn't support disabling sending read receipts.": "Votre serveur ne supporte pas la désactivation de l’envoi des accusés de réception.", + "Share your activity and status with others.": "Partager votre activité et votre statut avec les autres.", + "Presence": "Présence", + "Send read receipts": "Envoyer les accusés de réception", + "Last activity": "Dernière activité", + "Current session": "Cette session", + "Sessions": "Sessions", + "Use new session manager (under active development)": "Utiliser un nouveau gestionnaire de session (en cours de développement)" } diff --git a/src/i18n/strings/gl.json b/src/i18n/strings/gl.json index 08d11fe7a53..1ceacdc5735 100644 --- a/src/i18n/strings/gl.json +++ b/src/i18n/strings/gl.json @@ -3480,5 +3480,19 @@ "Find friends": "Atopar amizades", "It’s what you’re here for, so lets get to it": "É a razón de que estés aquí, asi que imos", "Find and invite your friends": "Atopa e convida ás túas amizades", - "You made it!": "Conseguíchelo!" + "You made it!": "Conseguíchelo!", + "We're creating a room with %(names)s": "Estamos creando unha sala con %(names)s", + "Google Play and the Google Play logo are trademarks of Google LLC.": "Google Play e o logo de Google Play son marcas de Google LLC.", + "App Store® and the Apple logo® are trademarks of Apple Inc.": "App Store® e o Apple logo® son marcas de Apple Inc.", + "Get it on F-Droid": "Descargar desde F-Droid", + "Get it on Google Play": "Descargar desde Google Play", + "Android": "Android", + "Download on the App Store": "Descargar na App Store", + "iOS": "iOS", + "Download %(brand)s Desktop": "Descargar %(brand)s Desktop", + "Download %(brand)s": "Descargar %(brand)s", + "Your server doesn't support disabling sending read receipts.": "O teu servidor non ten soporte para desactivar o envío de resgardos de lectura.", + "Share your activity and status with others.": "Comparte a túa actividade e estado con outras persoas.", + "Presence": "Presenza", + "Send read receipts": "Enviar resgardos de lectura" } diff --git a/src/i18n/strings/hu.json b/src/i18n/strings/hu.json index 4c7f0129f57..dec3bbc71d8 100644 --- a/src/i18n/strings/hu.json +++ b/src/i18n/strings/hu.json @@ -3418,31 +3418,85 @@ "Video rooms are always-on VoIP channels embedded within a room in %(brand)s.": "Videó szobák állandó VoIP csatornák a szobákba ágyazva itt: %(brand)s.", "A new way to chat over voice and video in %(brand)s.": "Új lehetőség hanggal és videóval csevegni itt: %(brand)s.", "Video rooms": "Videó szobák", - "Stop and close": "Megállít és kilép", + "Stop and close": "Befejezés és kilépés", "You can't disable this later. The room will be encrypted but the embedded call will not.": "Ezt később nem lehet kikapcsolni. A szoba titkosítva lesz de a hívások nem.", - "Online community members": "Online tagok a közösségekből", - "Coworkers and teams": "Munkatársak és csapatok", + "Online community members": "Online közösségek tagjai", + "Coworkers and teams": "Munkatársak és csoportok", "Friends and family": "Barátok és család", "We'll help you get connected.": "Segítünk a kapcsolatteremtésben.", "Who will you chat to the most?": "Kivel beszélget a legtöbbet?", - "You're in": "Ön belépett", - "You need to have the right permissions in order to share locations in this room.": "A helymegosztáshoz ebben a szobában megfelelő jogosultságokra van szüksége.", - "You don't have permission to share locations": "Nincs jogosultsága a helymegosztáshoz", - "Join the room to participate": "Belépés a szobába a részvételhez", + "You're in": "Itt vagy:", + "You need to have the right permissions in order to share locations in this room.": "A helymegosztáshoz ebben a szobában megfelelő jogosultságokra van szükséged.", + "You don't have permission to share locations": "Nincs jogosultságod a helymegosztáshoz", + "Join the room to participate": "Csatlakozz a szobához, hogy részt vehess", "Favourite Messages (under active development)": "Kedvenc üzenetek (aktív fejlesztés alatt)", "Reset bearing to north": "Északi irányba állítás", - "Mapbox logo": "Térkép logó", + "Mapbox logo": "Mapbox logó", "Location not available": "Földrajzi helyzet nem meghatározható", "Find my location": "Jelenlegi helyzetem megkeresése", "Exit fullscreen": "Kilépés a teljes képernyőből", "Enter fullscreen": "Teljes képernyőre váltás", "Map feedback": "Térkép visszajelzés", "Toggle attribution": "Tulajdonságok átkapcsolása", - "In %(spaceName)s and %(count)s other spaces.|one": "Itt: %(spaceName)s és %(count)s másik tér.", - "In %(spaceName)s and %(count)s other spaces.|zero": "Téren: %(spaceName)s.", - "In %(spaceName)s and %(count)s other spaces.|other": "Itt: %(spaceName)s és %(count)s másik tér.", - "In spaces %(space1Name)s and %(space2Name)s.": "A téren: %(space1Name)s és %(space2Name)s.", - "Developer command: Discards the current outbound group session and sets up new Olm sessions": "Fejlesztői parancs: Eldobja a jelenlegi kimenő csoport kapcsolatot és új Olm kapcsolatot hoz létre", - "Send your first message to invite to chat": "Az első üzeneteddel hívd meg ide őt: ", - "Messages in this chat will be end-to-end encrypted.": "Az üzenetek a beszélgetésben végponttól végpontig titkosítottak." + "In %(spaceName)s and %(count)s other spaces.|one": "Itt: %(spaceName)s és %(count)s másik térben.", + "In %(spaceName)s and %(count)s other spaces.|zero": "Ebben a térben: %(spaceName)s.", + "In %(spaceName)s and %(count)s other spaces.|other": "Itt: %(spaceName)s és %(count)s másik térben.", + "In spaces %(space1Name)s and %(space2Name)s.": "Ezekben a terekben: %(space1Name)s és %(space2Name)s.", + "Developer command: Discards the current outbound group session and sets up new Olm sessions": "Fejlesztői parancs: Eldobja a jelenlegi kimenő csoport kapcsolatot és új Olm munkamenetet hoz létre", + "Send your first message to invite to chat": "Küldj egy üzenetet ahhoz, hogy meghívd felhasználót", + "Messages in this chat will be end-to-end encrypted.": "Az üzenetek ebben a beszélgetésben végponti titkosítással vannak védve.", + "You did it!": "Kész!", + "Only %(count)s steps to go|one": "Még %(count)s lépés", + "Only %(count)s steps to go|other": "Még %(count)s lépés", + "Welcome to %(brand)s": "Üdvözlöm itt: %(brand)s", + "Find your people": "Találja meg az embereket", + "Keep ownership and control of community discussion.\nScale to support millions, with powerful moderation and interoperability.": "Tartsa meg a beszélgetés feletti irányítást.\nMilliók támogatása erős moderációs képességekkel és együttműködési lehetőségekkel.", + "Community ownership": "Közösség tulajdonjoga", + "Find your co-workers": "Találja meg a munkatársait", + "Secure messaging for work": "Biztonságos üzenetküldés munkához", + "Start your first chat": "Az első beszélgetés elkezdése", + "With free end-to-end encrypted messaging, and unlimited voice and video calls, %(brand)s is a great way to stay in touch.": "Ingyenes végpontok közötti titkosított üzenetküldés és korlátlan hang és videó hívás, %(brand)s használata jó lehetőség a kapcsolattartáshoz.", + "Secure messaging for friends and family": "Biztonságos üzenetküldés barátokkal, családdal", + "We’d appreciate any feedback on how you’re finding Element.": "Minden visszajelzésnek örülünk azzal kapcsolatban, hogy milyennek találja Elementet.", + "How are you finding Element so far?": "Eddig milyennek találja Elementet?", + "Enable notifications": "Értesítések engedélyezése", + "Don’t miss a reply or important message": "Ne maradjon le válaszról vagy fontos üzenetről", + "Turn on notifications": "Értesítések bekapcsolása", + "Your profile": "Profil", + "Make sure people know it’s really you": "Biztosítsa a többieket arról, hogy Ön valójában Ön", + "Set up your profile": "Profil beállítása", + "Download apps": "Alkalmazások letöltése", + "Don’t miss a thing by taking Element with you": "Ne maradjon le semmiről vigye magával Elementet", + "Download Element": "Element letöltése", + "Find and invite your community members": "Közösség tagjának megkeresése és meghívása", + "Find people": "Emberek megkeresése", + "Get stuff done by finding your teammates": "Fejezzen be dolgokat csoporttárs megtalálásával", + "Find and invite your co-workers": "Munkatárs keresése és meghívása", + "Find friends": "Barátok keresése", + "It’s what you’re here for, so lets get to it": "Kezdjük amiért itt van", + "Find and invite your friends": "Keresse meg és hívja meg barátait", + "You made it!": "Elkészült!", + "Use new session manager (under active development)": "Új munkamenet kezelő használata (aktív fejlesztés alatt)", + "Send read receipts": "Olvasás visszajelzés küldése", + "We're creating a room with %(names)s": "Szobát készítünk: %(names)s", + "Google Play and the Google Play logo are trademarks of Google LLC.": "A Google Play és a Google Play logó a Google LLC védjegye.", + "App Store® and the Apple logo® are trademarks of Apple Inc.": "Az App Store® és az Apple logo® az Apple Inc. védjegyei.", + "Get it on F-Droid": "Letöltés az F-Droidról", + "Get it on Google Play": "Letöltés a Google Play-ből", + "Android": "Android", + "Download on the App Store": "Letöltés az App Store-ból", + "iOS": "iOS", + "Download %(brand)s Desktop": "Asztali %(brand)s letöltése", + "Download %(brand)s": "%(brand)s eltöltése", + "Choose a locale": "Válasszon nyelvet", + "Help": "Segítség", + "Saved Items": "Mentett elemek", + "Last activity": "Utolsó tevékenység", + "Current session": "Jelenlegi munkamenet", + "Sessions": "Munkamenetek", + "Your server doesn't support disabling sending read receipts.": "A matrix szervere nem támogatja az olvasás visszajelzések elküldésének tiltását.", + "Share your activity and status with others.": "Ossza meg a tevékenységét és állapotát másokkal.", + "Presence": "Állapot", + "Spell check": "Helyesírás ellenőrzés", + "Complete these to get the most out of %(brand)s": "Ezen lépések befejezésével hozhatod ki a legtöbbet %(brand)s alkalmazásból" } diff --git a/src/i18n/strings/id.json b/src/i18n/strings/id.json index 0794a0b34d7..e4761685980 100644 --- a/src/i18n/strings/id.json +++ b/src/i18n/strings/id.json @@ -3447,5 +3447,56 @@ "Favourite Messages (under active development)": "Pesan Favorit (dalam pengembangan aktif)", "Saved Items": "Item yang Tersimpan", "Choose a locale": "Pilih locale", - "Spell check": "Pemeriksa ejaan" + "Spell check": "Pemeriksa ejaan", + "Download %(brand)s": "Unduh %(brand)s", + "Complete these to get the most out of %(brand)s": "Selesaikan untuk mendapatkan hasil yang maksimal dari %(brand)s", + "Welcome to %(brand)s": "Selamat datang di %(brand)s", + "We're creating a room with %(names)s": "Kami sedang membuat sebuah ruangan dengan %(names)s", + "Google Play and the Google Play logo are trademarks of Google LLC.": "Google Play dan logo Google Play adalah merek dagang dari Google LLC.", + "App Store® and the Apple logo® are trademarks of Apple Inc.": "App Store® dan logo Apple® adalah merek dagang dari Apple Inc.", + "Get it on F-Droid": "Dapatkan di F-Droid", + "Get it on Google Play": "Dapatkan di Google Play", + "Android": "Android", + "Download on the App Store": "Unduh di App Store", + "iOS": "iOS", + "Download %(brand)s Desktop": "Unduh %(brand)s Desktop", + "Help": "Bantuan", + "Your server doesn't support disabling sending read receipts.": "Server Anda tidak mendukung penonaktifkan pengiriman laporan dibaca.", + "Share your activity and status with others.": "Bagikan aktivitas dan status Anda dengan orang lain.", + "Presence": "Presensi", + "You did it!": "Anda berhasil!", + "Only %(count)s steps to go|one": "Hanya %(count)s langkah lagi untuk dilalui", + "Only %(count)s steps to go|other": "Hanya %(count)s langkah lagi untuk dilalui", + "Find your people": "Temukan orang-orang Anda", + "Keep ownership and control of community discussion.\nScale to support millions, with powerful moderation and interoperability.": "Tetap miliki kemilikan dan kendali atas diskusi komunitas.\nBesar untuk mendukung jutaan anggota, dengan moderasi dan interoperabilitas berdaya.", + "Community ownership": "Kemilikan komunitas", + "Find your co-workers": "Temukan rekan kerja Anda", + "Secure messaging for work": "Perpesanan aman untuk berkerja", + "Start your first chat": "Mulai obrolan pertama Anda", + "With free end-to-end encrypted messaging, and unlimited voice and video calls, %(brand)s is a great way to stay in touch.": "Dengan perpesanan terenkripsi ujung-ke-ujung gratis, dan panggilan suara & video tidak terbatas, %(brand)s adalah cara yang baik untuk tetap terhubung.", + "Secure messaging for friends and family": "Perpesanan aman untuk teman dan keluarga", + "We’d appreciate any feedback on how you’re finding Element.": "Kami akan menghargai masukan apa pun tentang bagaimana Anda menemukan Element.", + "How are you finding Element so far?": "Bagaimana Anda menemukan Element sejauh ini?", + "Enable notifications": "Nyalakan notifikasi", + "Don’t miss a reply or important message": "Jangan lewatkan sebuah balasan atau pesan yang penting", + "Turn on notifications": "Nyalakan notifikasi", + "Your profile": "Profil Anda", + "Make sure people know it’s really you": "Pastikan orang-orang tahu bahwa itu memang Anda", + "Set up your profile": "Siapkan profil Anda", + "Download apps": "Unduh aplikasi", + "Don’t miss a thing by taking Element with you": "Jangan lewatkan apa pun dengan membawa Element dengan Anda", + "Download Element": "Unduh Element", + "Find and invite your community members": "Temukan dan undang anggota komunitas Anda", + "Find people": "Temukan orang-orang", + "Get stuff done by finding your teammates": "Selesaikan hal-hal dengan menemukan rekan setim Anda", + "Find and invite your co-workers": "Temukan dan undang rekan kerja Anda", + "Find friends": "Temukan teman-teman", + "It’s what you’re here for, so lets get to it": "Untuk itulah Anda di sini, jadi mari kita lakukan", + "Find and invite your friends": "Temukan dan undang teman Anda", + "You made it!": "Anda berhasil!", + "Send read receipts": "Kirim laporan dibaca", + "Last activity": "Aktivitas terakhir", + "Sessions": "Sesi", + "Use new session manager (under active development)": "Gunakan pengelola sesi baru (dalam pengembangan aktif)", + "Current session": "Sesi saat ini" } diff --git a/src/i18n/strings/it.json b/src/i18n/strings/it.json index 079415783d0..5ffaf3df34c 100644 --- a/src/i18n/strings/it.json +++ b/src/i18n/strings/it.json @@ -3480,5 +3480,23 @@ "Find and invite your friends": "Trova e invita i tuoi amici", "You made it!": "Ce l'hai fatta!", "We’d appreciate any feedback on how you’re finding Element.": "Ci piacerebbe avere una tua opinione riguardo Element.", - "How are you finding Element so far?": "Come ti sta sembrando Element?" + "How are you finding Element so far?": "Come ti sta sembrando Element?", + "Google Play and the Google Play logo are trademarks of Google LLC.": "Google Play e il logo Google Play sono marchi registrati di Google LLC.", + "App Store® and the Apple logo® are trademarks of Apple Inc.": "App Store® e il logo Apple® sono marchi registrati di Apple Inc.", + "Get it on F-Droid": "Ottienilo su F-Droid", + "Get it on Google Play": "Ottienilo su Google Play", + "Android": "Android", + "Download on the App Store": "Scarica dall'App Store", + "iOS": "iOS", + "Download %(brand)s Desktop": "Scarica %(brand)s Desktop", + "Download %(brand)s": "Scarica %(brand)s", + "We're creating a room with %(names)s": "Stiamo creando una stanza con %(names)s", + "Last activity": "Ultima attività", + "Current session": "Sessione attuale", + "Sessions": "Sessioni", + "Your server doesn't support disabling sending read receipts.": "Il tuo server non supporta la disattivazione delle conferme di lettura.", + "Share your activity and status with others.": "Condividi la tua attività e lo stato con gli altri.", + "Presence": "Presenza", + "Use new session manager (under active development)": "Usa il nuovo gestore di sessioni (in sviluppo attivo)", + "Send read receipts": "Invia le conferme di lettura" } diff --git a/src/i18n/strings/ko.json b/src/i18n/strings/ko.json index 5056fb30b13..042036debb2 100644 --- a/src/i18n/strings/ko.json +++ b/src/i18n/strings/ko.json @@ -429,17 +429,17 @@ "Jump to read receipt": "읽은 기록으로 건너뛰기", "Share room": "방 공유하기", "Members only (since they joined)": "구성원만(구성원들이 참여한 시점부터)", - "%(severalUsers)sjoined %(count)s times|one": "%(severalUsers)s이 참가했습니다", - "%(severalUsers)sjoined %(count)s times|other": "%(severalUsers)s이 %(count)s번 참가했습니다", - "%(oneUser)sjoined %(count)s times|other": "%(oneUser)s님이 %(count)s번 참가했습니다", - "%(oneUser)sjoined %(count)s times|one": "%(oneUser)s님이 참가했습니다", + "%(severalUsers)sjoined %(count)s times|one": "%(severalUsers)s이 참여했습니다", + "%(severalUsers)sjoined %(count)s times|other": "%(severalUsers)s이 %(count)s번 참여했습니다", + "%(oneUser)sjoined %(count)s times|other": "%(oneUser)s님이 %(count)s번 참여했습니다", + "%(oneUser)sjoined %(count)s times|one": "%(oneUser)s님이 참여했습니다", "%(severalUsers)sjoined and left %(count)s times|other": "%(severalUsers)s이 %(count)s번 참가하다가 떠났습니다", "%(severalUsers)sjoined and left %(count)s times|one": "%(severalUsers)s이 참가하다가 떠났습니다", "%(oneUser)sjoined and left %(count)s times|other": "%(oneUser)s님이 %(count)s번 참가하다가 떠났습니다", "%(oneUser)sjoined and left %(count)s times|one": "%(oneUser)s님이 참가하다가 떠났습니다", - "%(severalUsers)sleft and rejoined %(count)s times|one": "%(severalUsers)s이 떠나고 다시 참가했습니다", - "%(oneUser)sleft and rejoined %(count)s times|other": "%(oneUser)s님이 %(count)s번 떠나고 다시 참가했습니다", - "%(oneUser)sleft and rejoined %(count)s times|one": "%(oneUser)s님이 떠나고 다시 참가했습니다", + "%(severalUsers)sleft and rejoined %(count)s times|one": "%(severalUsers)s이 떠나고 다시 참여했습니다", + "%(oneUser)sleft and rejoined %(count)s times|other": "%(oneUser)s님이 %(count)s번 떠나고 다시 참여했습니다", + "%(oneUser)sleft and rejoined %(count)s times|one": "%(oneUser)s님이 떠나고 다시 참여했습니다", "%(severalUsers)sleft %(count)s times|other": "%(severalUsers)s이 %(count)s번 떠났습니다", "%(severalUsers)sleft %(count)s times|one": "%(severalUsers)s이 떠났습니다", "%(oneUser)sleft %(count)s times|other": "%(oneUser)s님이 %(count)s번 떠났습니다", @@ -511,7 +511,7 @@ "This room is a continuation of another conversation.": "이 방은 다른 대화방의 연장선입니다.", "Click here to see older messages.": "여길 눌러 오래된 메시지를 보세요.", "%(nameList)s %(transitionList)s": "%(nameList)s %(transitionList)s", - "%(severalUsers)sleft and rejoined %(count)s times|other": "%(severalUsers)s이 %(count)s번 떠나고 다시 참가했습니다", + "%(severalUsers)sleft and rejoined %(count)s times|other": "%(severalUsers)s이 %(count)s번 떠나고 다시 참여했습니다", "In reply to ": "관련 대화 ", "Updating %(brand)s": "%(brand)s 업데이트 중", "Upgrade this room to version %(version)s": "이 방을 %(version)s 버전으로 업그레이드", @@ -1219,5 +1219,110 @@ "Pin to sidebar": "사이드바 고정", "Developer tools": "개발자 도구", "All settings": "전체 설정", - "Use Single Sign On to continue": "SSO로 계속하기" + "Use Single Sign On to continue": "SSO로 계속하기", + "Join public room": "공개 방 참가하기", + "New room": "새로운 방 만들기", + "Start new chat": "새로운 대화 시작하기", + "Room options": "방 옵션", + "Mentions & Keywords": "멘션 및 키워드", + "Mentions & keywords": "멘션 및 키워드", + "Use default": "기본 설정 사용", + "Results not as expected? Please give feedback.": "예상한 결과가 아닌가요? 피드백 부탁드립니다.", + "Get notified only with mentions and keywords as set up in your settings": "설정에서 지정한 멘션과 키워드인 경우에만 알림을 받습니다", + "Get notifications as set up in your settings": "설정에서 지정한 알림만 받습니다", + "Get notified for every message": "모든 메세지 알림을 받습니다", + "You won't get any notifications": "어떤 알람도 받지 않습니다", + "Public": "공개", + "Space members": "스페이스 멤버 목록", + "Private room (invite only)": "비공개 방 (초대 필요)", + "Private (invite only)": "비공개 (초대 필요)", + "Never send encrypted messages to unverified sessions in this room from this session": "이 채팅방의 현재 세션에서 확인되지 않은 세션으로 암호화된 메시지를 보내지 않음", + "Decide who can view and join %(spaceName)s.": "누가 %(spaceName)s를 보거나 참여할 수 있는지 결정합니다.", + "Accessibility": "접근성", + "Access": "접근", + "Recommended for public spaces.": "공개 스페이스에 권장 합니다.", + "Allow people to preview your space before they join.": "스페이스에 참여하기 전에 미리볼 수 있도록 허용합니다.", + "Preview Space": "스페이스 미리보기", + "Message Previews": "메세지 미리보기", + "Anyone can find and join.": "누구나 찾고 참여할 수 있습니다.", + "Anyone in can find and join. You can select other spaces too.": "에 소속된 누구나 찾고 참여할 수 있습니다. 다른 스페이스도 선택 가능합니다.", + "Only invited people can join.": "초대한 경우에만 참여할 수 있습니다.", + "Visibility": "가시성", + "Explore Public Rooms": "공개 방 살펴보기", + "Manage & explore rooms": "관리 및 방 목록 보기", + "Space home": "스페이스 홈", + "Clear": "지우기", + "Clear notifications": "알림 지우기", + "Search for": "검색 기준", + "Search for rooms or people": "방 또는 사람 검색", + "Search for rooms": "방 검색", + "Search for spaces": "스페이스 검색", + "Recently Direct Messaged": "최근 다이렉트 메세지", + "No recently visited rooms": "최근에 방문하지 않은 방 목록", + "Recently visited rooms": "최근 방문한 방 목록", + "Recently viewed": "최근에 확인한", + "%(senderName)s removed their display name (%(oldDisplayName)s)": "%(senderName)s님이 표시이름을 제거했습니다 (%(oldDisplayName)s)", + "%(senderName)s changed their profile picture": "%(senderName)s님이 프로필 사진을 변경했습니다", + "%(senderName)s invited %(targetName)s": "%(senderName)s님이 %(targetName)s님을 초대했습니다", + "%(senderDisplayName)s changed the room name from %(oldRoomName)s to %(newRoomName)s.": "%(senderDisplayName)s님이 방 이름을 %(oldRoomName)s에서 %(newRoomName)s(으)로 변경했습니다.", + "%(oldDisplayName)s changed their display name to %(displayName)s": "%(oldDisplayName)s님이 표시이름을 %(displayName)s(으)로 변경했습니다", + "%(targetName)s left the room": "%(targetName)s님이 방을 떠났습니다", + "%(targetName)s joined the room": "%(targetName)s님이 방에 참여했습니다", + "%(senderName)s set a profile picture": "%(senderName)s님이 프로필 사진을 설정했습니다", + "Scroll to most recent messages": "가장 최근 메세지로 스크롤", + "Room Info": "방 정보", + "If you can't find the room you're looking for, ask for an invite or create a new room.": "만약 찾고 있는 방이 없다면, 초대를 요청하거나 새로운 방을 만드세요.", + "If you can't find the room you're looking for, ask for an invite or create a new room.": "만약 찾고 있는 방이 없다면, 초대를 요청하거나 새로운 방을 만드세요.", + "Unable to copy a link to the room to the clipboard.": "방 링크를 클립보드에 복사할 수 없습니다.", + "Unable to copy room link": "방 링크를 복사할 수 없습니다", + "Copy room link": "방 링크 복사", + "Share your public space": "당신의 공개 스페이스 공유하기", + "Your public space": "당신의 공개 스페이스", + "Public space": "공개 스페이스", + "Private space (invite only)": "비공개 스페이스 (초대 필요)", + "Your private space": "당신의 비공개 스페이스", + "Private space": "비공개 스페이스", + "Rooms and spaces": "방 및 스페이스 목록", + "Sidebar": "사이드바", + "Keyboard": "키보드 (단축키)", + "Send feedback": "피드백 보내기", + "Feedback sent": "피드백 보내기", + "Feedback": "피드백", + "Show all rooms in Home": "모든 방을 홈에서 보기", + "Leave all rooms": "모든 방에서 떠나기", + "Show all rooms": "모든 방 목록 보기", + "All rooms": "모든 방 목록", + "Expand": "펼치기", + "Create a new space": "새로운 스페이스 만들기", + "Create a space": "스페이스 만들기", + "Export Chat": "대화 내보내기", + "Export chat": "대화 내보내기", + "Room settings": "방 설정", + "Hide Widgets": "위젯 숨기기", + "Widgets": "위젯", + "Nothing pinned, yet": "아직 고정된 것이 없습니다", + "Pinned messages": "고정된 메세지", + "Pinned": "고정됨", + "Files": "파일 목록", + "Poll": "투표", + "Send voice message": "음성 메세지 보내기", + "Voice Message": "음성 메세지", + "Sticker": "스티커", + "View source": "소스 보기", + "Report a bug": "버그 보고", + "Report": "보고", + "Forward message": "전달 메세지", + "Forward": "전달", + "about a day ago": "약 1일 전", + "%(num)s days ago": "%(num)s일 전", + "about an hour ago": "약 1 시간 전", + "%(num)s minutes ago": "%(num)s분 전", + "Or send invite link": "또는 초대 링크 보내기", + "Suggestions": "추천 목록", + "Recent Conversations": "최근 대화 목록", + "Start a conversation with someone using their name or username (like ).": "이름이나 사용자명( 형식)을 사용하는 사람들과 대화를 시작하세요.", + "Direct Messages": "다이렉트 메세지", + "Explore public rooms": "공개 방 목록 살펴보기", + "Show %(count)s more|other": "%(count)s개 더 보기", + "People": "사람들" } diff --git a/src/i18n/strings/nl.json b/src/i18n/strings/nl.json index e85cf626e2f..ac3b344ef5d 100644 --- a/src/i18n/strings/nl.json +++ b/src/i18n/strings/nl.json @@ -3445,5 +3445,50 @@ "Messages in this chat will be end-to-end encrypted.": "Berichten in deze chat worden eind-tot-eind versleuteld.", "Saved Items": "Opgeslagen items", "Send your first message to invite to chat": "Stuur uw eerste bericht om uit te nodigen om te chatten", - "Favourite Messages (under active development)": "Favoriete berichten (in actieve ontwikkeling)" + "Favourite Messages (under active development)": "Favoriete berichten (in actieve ontwikkeling)", + "We're creating a room with %(names)s": "We maken een kamer aan met %(names)s", + "Google Play and the Google Play logo are trademarks of Google LLC.": "Google Play en het Google Play-logo zijn handelsmerken van Google LLC.", + "App Store® and the Apple logo® are trademarks of Apple Inc.": "App Store® en het Apple logo® zijn handelsmerken van Apple Inc.", + "Get it on F-Droid": "Download het op F-Droid", + "Get it on Google Play": "Verkrijg het via Google Play", + "Android": "Android", + "Download on the App Store": "Te downloaden in de App Store", + "iOS": "iOS", + "Download %(brand)s Desktop": "%(brand)s Desktop downloaden", + "Download %(brand)s": "%(brand)s downloaden", + "Choose a locale": "Kies een landinstelling", + "Help": "Help", + "Spell check": "Spellingscontrole", + "Complete these to get the most out of %(brand)s": "Voltooi deze om het meeste uit %(brand)s te halen", + "You did it!": "Het is u gelukt!", + "Only %(count)s steps to go|one": "Nog maar %(count)s stap te gaan", + "Only %(count)s steps to go|other": "Nog maar %(count)s stappen te gaan", + "Welcome to %(brand)s": "Welkom bij %(brand)s", + "Find your people": "Vind uw mensen", + "Keep ownership and control of community discussion.\nScale to support millions, with powerful moderation and interoperability.": "Houd het eigendom en de controle over de discussie in de gemeenschap.\nSchaal om miljoenen te ondersteunen, met krachtige beheersbaarheid en interoperabiliteit.", + "Community ownership": "Gemeenschapseigendom", + "Find your co-workers": "Vind uw collega's", + "Secure messaging for work": "Veilig berichten versturen voor werk", + "Start your first chat": "Start uw eerste chat", + "With free end-to-end encrypted messaging, and unlimited voice and video calls, %(brand)s is a great way to stay in touch.": "Met gratis eind-tot-eind versleutelde berichten en onbeperkte spraak- en video-oproepen, is %(brand)s een geweldige manier om in contact te blijven.", + "Secure messaging for friends and family": "Veilig berichten versturen voor vrienden en familie", + "We’d appreciate any feedback on how you’re finding Element.": "We stellen het op prijs als u feedback geeft over hoe u Element vindt.", + "How are you finding Element so far?": "Hoe vind u Element tot nu toe?", + "Enable notifications": "Meldingen inschakelen", + "Don’t miss a reply or important message": "Mis geen antwoord of belangrijk bericht", + "Turn on notifications": "Meldingen aanzetten", + "Your profile": "Uw profiel", + "Make sure people know it’s really you": "Zorg ervoor dat mensen weten dat u het echt bent", + "Set up your profile": "Stel uw profiel in", + "Download apps": "Apps downloaden", + "Don’t miss a thing by taking Element with you": "Mis niets door Element mee te nemen", + "Download Element": "Element downloaden", + "Find and invite your community members": "Vind en nodig uw communityleden uit", + "Find people": "Zoek mensen", + "Get stuff done by finding your teammates": "Krijg dingen gedaan door uw teamgenoten te vinden", + "Find and invite your co-workers": "Vind en nodig uw collega's uit", + "Find friends": "Zoek vrienden", + "It’s what you’re here for, so lets get to it": "Daar bent u voor, dus laten we beginnen", + "Find and invite your friends": "Zoek uw vrienden en nodig ze uit", + "You made it!": "Het is u gelukt!" } diff --git a/src/i18n/strings/ru.json b/src/i18n/strings/ru.json index 781b22ea5a4..7c2d4ffb17b 100644 --- a/src/i18n/strings/ru.json +++ b/src/i18n/strings/ru.json @@ -328,22 +328,22 @@ "Members only (since they joined)": "Только участники (с момента их входа)", "A text message has been sent to %(msisdn)s": "Текстовое сообщение отправлено на %(msisdn)s", "%(nameList)s %(transitionList)s": "%(nameList)s %(transitionList)s", - "%(severalUsers)sjoined %(count)s times|other": "%(severalUsers)sвошли %(count)s раз(а)", - "%(severalUsers)sjoined %(count)s times|one": "%(severalUsers)sвошли", - "%(oneUser)sjoined %(count)s times|other": "%(oneUser)sвошёл(-ла) %(count)s раз(а)", - "%(oneUser)sjoined %(count)s times|one": "%(oneUser)sвошёл(-ла)", - "%(severalUsers)sleft %(count)s times|other": "%(severalUsers)sвышли %(count)s раз(а)", - "%(severalUsers)sleft %(count)s times|one": "%(severalUsers)sвышли", - "%(oneUser)sleft %(count)s times|other": "%(oneUser)sвышел(-ла) %(count)s раз(а)", - "%(oneUser)sleft %(count)s times|one": "%(oneUser)sвышел(-ла)", - "%(severalUsers)sjoined and left %(count)s times|other": "%(severalUsers)sвошли и вышли %(count)s раз(а)", - "%(severalUsers)sjoined and left %(count)s times|one": "%(severalUsers)sвошли и вышли", - "%(oneUser)sjoined and left %(count)s times|other": "%(oneUser)sвошёл(-ла) и вышел(-ла) %(count)s раз(а)", - "%(oneUser)sjoined and left %(count)s times|one": "%(oneUser)sвошёл(-ла) и вышел(-ла)", - "%(severalUsers)sleft and rejoined %(count)s times|other": "%(severalUsers)sвышли и снова вошли %(count)s раз(а)", - "%(severalUsers)sleft and rejoined %(count)s times|one": "%(severalUsers)sвышли и снова вошли", - "%(oneUser)sleft and rejoined %(count)s times|other": "%(oneUser)sвышел(-ла) и снова вошёл(-ла) %(count)s раз(а)", - "%(oneUser)sleft and rejoined %(count)s times|one": "%(oneUser)sвышел(-ла) и снова вошёл(-ла)", + "%(severalUsers)sjoined %(count)s times|other": "%(severalUsers)s присоединились %(count)s раз(а)", + "%(severalUsers)sjoined %(count)s times|one": "%(severalUsers)s присоединились", + "%(oneUser)sjoined %(count)s times|other": "%(oneUser)s присоединился(лась) %(count)s раз(а)", + "%(oneUser)sjoined %(count)s times|one": "%(oneUser)s присоединился(лась)", + "%(severalUsers)sleft %(count)s times|other": "%(severalUsers)s покинули %(count)s раз(а)", + "%(severalUsers)sleft %(count)s times|one": "%(severalUsers)s покинули", + "%(oneUser)sleft %(count)s times|other": "%(oneUser)s покинул(а) %(count)s раз(а)", + "%(oneUser)sleft %(count)s times|one": "%(oneUser)s покинул(а)", + "%(severalUsers)sjoined and left %(count)s times|other": "%(severalUsers)s присоединились и покинули %(count)s раз(а)", + "%(severalUsers)sjoined and left %(count)s times|one": "%(severalUsers)s присоединились и покинули", + "%(oneUser)sjoined and left %(count)s times|other": "%(oneUser)s присоединился(лась) и покинул(а) %(count)s раз(а)", + "%(oneUser)sjoined and left %(count)s times|one": "%(oneUser)s присоединился(лась) и покинул(а)", + "%(severalUsers)sleft and rejoined %(count)s times|other": "%(severalUsers)s покинули и снова присоединились %(count)s раз(а)", + "%(severalUsers)sleft and rejoined %(count)s times|one": "%(severalUsers)s покинули и снова присоединились", + "%(oneUser)sleft and rejoined %(count)s times|other": "%(oneUser)s покинул(а) и снова присоединился(лась) %(count)s раз(а)", + "%(oneUser)sleft and rejoined %(count)s times|one": "%(oneUser)s покинул(а) и снова присоединился(лась)", "were invited %(count)s times|other": "приглашены %(count)s раз(а)", "were invited %(count)s times|one": "приглашены", "was invited %(count)s times|other": "приглашен(а) %(count)s раз(а)", @@ -526,10 +526,10 @@ "Put a link back to the old room at the start of the new room so people can see old messages": "Разместим ссылку на старую комнату, чтобы люди могли видеть старые сообщения", "Please contact your service administrator to continue using this service.": "Пожалуйста, обратитесь к вашему администратору, чтобы продолжить использовать этот сервис.", "Unable to load! Check your network connectivity and try again.": "Не удалось загрузить! Проверьте подключение к сети и попробуйте снова.", - "Upgrades a room to a new version": "Модернизирует комнату до новой версии", + "Upgrades a room to a new version": "Обновляет комнату до новой версии", "Sets the room name": "Устанавливает название комнаты", "Forces the current outbound group session in an encrypted room to be discarded": "Принудительно отбрасывает текущую групповую сессию для отправки сообщений в зашифрованную комнату", - "%(senderDisplayName)s upgraded this room.": "%(senderDisplayName)s модернизировал эту комнату.", + "%(senderDisplayName)s upgraded this room.": "%(senderDisplayName)s обновил(а) эту комнату.", "%(senderName)s set the main address for this room to %(address)s.": "%(senderName)s установил(а) %(address)s в качестве главного адреса комнаты.", "%(senderName)s removed the main address for this room.": "%(senderName)s удалил главный адрес комнаты.", "%(displayName)s is typing …": "%(displayName)s печатает…", @@ -1472,7 +1472,7 @@ "Ok": "Хорошо", "New login. Was this you?": "Новый вход в вашу учётную запись. Это были Вы?", "You joined the call": "Вы присоединились к звонку", - "%(senderName)s joined the call": "%(senderName)s присоединился(-ась) к звонку", + "%(senderName)s joined the call": "%(senderName)s присоединился(лась) к звонку", "Call in progress": "Звонок в процессе", "Call ended": "Звонок завершён", "You started a call": "Вы начали звонок", @@ -1594,8 +1594,8 @@ "Confirm by comparing the following with the User Settings in your other session:": "Подтвердите, сравнив следующие параметры с настройками пользователя в другой вашей сессии:", "Confirm this user's session by comparing the following with their User Settings:": "Подтвердите сессию этого пользователя, сравнив следующие параметры с его пользовательскими настройками:", "If they don't match, the security of your communication may be compromised.": "Если они не совпадают, безопасность вашего общения может быть поставлена под угрозу.", - "Upgrade private room": "Модернизировать приватную комнату", - "Upgrade public room": "Модернизировать публичную комнату", + "Upgrade private room": "Обновить приватную комнату", + "Upgrade public room": "Обновить публичную комнату", "Upgrading a room is an advanced action and is usually recommended when a room is unstable due to bugs, missing features or security vulnerabilities.": "Модернизация комнаты - это расширенное действие, которое обычно рекомендуется, когда комната нестабильна из-за ошибок, отсутствующих функций или уязвимостей безопасности.", "This usually only affects how the room is processed on the server. If you're having problems with your %(brand)s, please report a bug.": "Обычно это влияет только на то, как комната обрабатывается на сервере. Если у вас возникли проблемы с вашим %(brand)s, пожалуйста, сообщите об ошибке.", "You'll upgrade this room from to .": "Вы модернизируете эту комнату с до .", @@ -1792,7 +1792,7 @@ "Add a topic to help people know what it is about.": "Добавьте тему, чтобы люди знали, о чём комната.", "Topic: %(topic)s ": "Тема: %(topic)s ", "Topic: %(topic)s (edit)": "Тема: %(topic)s (изменить)", - "This is the beginning of your direct message history with .": "Это начало вашей истории прямых сообщений с .", + "This is the beginning of your direct message history with .": "Это начало вашей переписки с .", "Only the two of you are in this conversation, unless either of you invites anyone to join.": "В этом разговоре только вы двое, если только кто-нибудь из вас не пригласит кого-нибудь присоединиться.", "Takes the call in the current room off hold": "Прекратить удержание вызова в текущей комнате", "Places the call in the current room on hold": "Перевести вызов в текущей комнате на удержание", @@ -2342,7 +2342,7 @@ "A private space to organise your rooms": "Приватное пространство для организации ваших комнат", "Just me": "Только я", "Make sure the right people have access to %(name)s": "Убедитесь, что правильные люди имеют доступ к %(name)s", - "Who are you working with?": "С кем ты работаешь?", + "Who are you working with?": "С кем вы работаете?", "Go to my first room": "Перейти в мою первую комнату", "It's just you at the moment, it will be even better with others.": "Сейчас здесь только ты, с другими будет ещё лучше.", "Share %(name)s": "Поделиться %(name)s", @@ -2513,7 +2513,7 @@ "Zoom in": "Увеличить", "Zoom out": "Уменьшить", "%(count)s people you know have already joined|one": "%(count)s человек, которого вы знаете, уже присоединился", - "%(count)s people you know have already joined|other": "%(count)s человек, которых вы знаете, уже присоединились", + "%(count)s people you know have already joined|other": "%(count)s человек(а), которых вы знаете, уже присоединились", "Including %(commaSeparatedMembers)s": "Включая %(commaSeparatedMembers)s", "View all %(count)s members|one": "Посмотреть 1 участника", "View all %(count)s members|other": "Просмотреть всех %(count)s участников", @@ -2613,7 +2613,7 @@ "Give feedback.": "Оставить отзыв.", "Thank you for trying Spaces. Your feedback will help inform the next versions.": "Спасибо, что попробовали пространства. Ваши отзывы помогут при разработке следующих версий.", "Spaces feedback": "Отзыв о пространствах", - "Spaces are a new feature.": "Пространства - это новая функция.", + "Spaces are a new feature.": "Пространства — это новая функция.", "Please enter a name for the space": "Пожалуйста, введите название пространства", "Your camera is still enabled": "Ваша камера всё ещё включена", "Your camera is turned off": "Ваша камера выключена", @@ -2650,7 +2650,7 @@ "%(targetName)s left the room": "%(targetName)s покинул(а) комнату", "%(targetName)s left the room: %(reason)s": "%(targetName)s покинул(а) комнату: %(reason)s", "%(targetName)s rejected the invitation": "%(targetName)s отклонил(а) приглашение", - "%(targetName)s joined the room": "%(targetName)s вошёл(-ла) в комнату", + "%(targetName)s joined the room": "%(targetName)s присоединился(лась) к комнате", "%(senderName)s made no change": "%(senderName)s не сделал(а) изменений", "%(senderName)s set a profile picture": "%(senderName)s установил(а) аватар", "%(senderName)s changed their profile picture": "%(senderName)s изменил(а) аватар", @@ -3206,7 +3206,7 @@ "Can I use text chat alongside the video call?": "Можно ли использовать текстовый чат одновременно с видеозвонком?", "Use the “+” button in the room section of the left panel.": "Используйте кнопку \"+\" в разделе комнат на левой панели.", "How can I create a video room?": "Как создать видеокомнату?", - "A new way to chat over voice and video in %(brand)s.": "Новый способ голосового и видео общения в %(brand)s.", + "A new way to chat over voice and video in %(brand)s.": "Новый способ голосового и видеообщения в %(brand)s.", "Video rooms": "Видеокомнаты", "Connection lost": "Соединение потеряно", "The person who invited you has already left, or their server is offline.": "Пригласивший вас человек уже ушёл, или его сервер не подключён к сети.", @@ -3247,8 +3247,8 @@ "Video devices": "Видеоустройства", "Mute microphone": "Отключить микрофон", "Audio devices": "Аудиоустройства", - "%(count)s people joined|one": "%(count)s присоединившихся", - "%(count)s people joined|other": "%(count)s присоединившихся", + "%(count)s people joined|one": "%(count)s человек присоединился", + "%(count)s people joined|other": "%(count)s человек(а) присоединились", "sends hearts": "отправляет сердечки", "Enable hardware acceleration": "Включить аппаратное ускорение", "Remove from space": "Исключить из пространства", @@ -3264,7 +3264,7 @@ "Signing out your devices will delete the message encryption keys stored on them, making encrypted chat history unreadable.": "При выходе из устройств удаляются хранящиеся на них ключи шифрования сообщений, что сделает зашифрованную историю чатов нечитаемой.", "Resetting your password on this homeserver will cause all of your devices to be signed out. This will delete the message encryption keys stored on them, making encrypted chat history unreadable.": "Сброс пароля на этом домашнем сервере приведет к тому, что все ваши устройства будут отключены. Это приведет к удалению хранящихся на них ключей шифрования сообщений, что сделает зашифрованную историю чата нечитаемой.", "Event ID: %(eventId)s": "ID события: %(eventId)s", - "Threads are a beta feature": "Ветки - бета-функция", + "Threads are a beta feature": "Обсуждения — бета-функция", "Your message wasn't sent because this homeserver has been blocked by its administrator. Please contact your service administrator to continue using the service.": "Ваше сообщение не отправлено, поскольку домашний сервер заблокирован его администратором. Обратитесь к администратору службы, чтобы продолжить её использование.", "Resent!": "Отправлено повторно!", "Did not receive it? Resend it": "Не получили? Отправить его повторно", @@ -3372,8 +3372,8 @@ "%(brand)s was denied permission to fetch your location. Please allow location access in your browser settings.": "%(brand)s не получил доступа к вашему местонахождению. Разрешите доступ к местоположению в настройках браузера.", "Share for %(duration)s": "Поделиться на %(duration)s", "Live location sharing": "Отправка местонахождения в реальном времени", - "Beta feature. Click to learn more.": "Бетафункция. Нажмите, чтобы узнать больше.", - "Beta feature": "Бетафункция", + "Beta feature. Click to learn more.": "Бета-функция. Нажмите, чтобы узнать больше.", + "Beta feature": "Бета-функция", "Ban from room": "Заблокировать в комнате", "Unban from room": "Разблокировать в комнате", "Ban from space": "Заблокировать в пространстве", @@ -3403,7 +3403,7 @@ "If you've submitted a bug via GitHub, debug logs can help us track down the problem. ": "Если вы отправили ошибку через GitHub, журналы отладки могут помочь нам отследить проблему. ", "Right-click message context menu": "Правая кнопка мыши – контекстное меню сообщения", "Show HTML representation of room topics": "Показать HTML-представление тем комнаты", - "Video rooms are always-on VoIP channels embedded within a room in %(brand)s.": "Видеокомнаты - это постоянные VoIP-каналы, встроенные в комнату в %(brand)s.", + "Video rooms are always-on VoIP channels embedded within a room in %(brand)s.": "Видеокомнаты — это постоянные VoIP-каналы, встроенные в комнату в %(brand)s.", "You're trying to access a community link (%(groupId)s).
    Communities are no longer supported and have been replaced by spaces.Learn more about spaces here.": "Вы пытаетесь получить доступ к ссылке на сообщество (%(groupId)s).
    Сообщества больше не поддерживаются и их заменили пространствами.Узнайте больше о пространствах здесь.", "Enable live location sharing": "Включить функцию \"Поделиться трансляцией местоположения\"", "Live location ended": "Трансляция местоположения завершена", diff --git a/src/i18n/strings/sk.json b/src/i18n/strings/sk.json index 5304644a25d..05285a15234 100644 --- a/src/i18n/strings/sk.json +++ b/src/i18n/strings/sk.json @@ -3477,5 +3477,25 @@ "Find friends": "Nájsť priateľov", "It’s what you’re here for, so lets get to it": "Kvôli tomu ste tu, tak sa do toho pustite", "Find and invite your friends": "Nájdite a pozvite svojich priateľov", - "You made it!": "Zvládli ste to!" + "You made it!": "Zvládli ste to!", + "We're creating a room with %(names)s": "Vytvárame miestnosť s %(names)s", + "Google Play and the Google Play logo are trademarks of Google LLC.": "Google Play a logo Google Play sú ochranné známky spoločnosti Google LLC.", + "App Store® and the Apple logo® are trademarks of Apple Inc.": "App Store® a logo Apple® sú ochranné známky spoločnosti Apple Inc.", + "Get it on F-Droid": "Získajte ho v službe F-Droid", + "Get it on Google Play": "Získajte ho v službe Google Play", + "Android": "Android", + "Download on the App Store": "Stiahnuť v obchode App Store", + "iOS": "iOS", + "Download %(brand)s Desktop": "Stiahnuť %(brand)s Desktop", + "Download %(brand)s": "Stiahnuť %(brand)s", + "Help": "Pomocník", + "Your server doesn't support disabling sending read receipts.": "Váš server nepodporuje vypnutie odosielania potvrdení o prečítaní.", + "Share your activity and status with others.": "Zdieľajte svoju aktivitu a stav s ostatnými.", + "Presence": "Prítomnosť", + "We’d appreciate any feedback on how you’re finding Element.": "Budeme vďační za akúkoľvek spätnú väzbu o tom, ako sa vám Element osvedčil.", + "How are you finding Element so far?": "Ako sa vám zatiaľ páči Element?", + "Send read receipts": "Odosielať potvrdenia o prečítaní", + "Last activity": "Posledná aktivita", + "Sessions": "Relácie", + "Use new session manager (under active development)": "Použiť nového správcu relácií (v štádiu aktívneho vývoja)" } diff --git a/src/i18n/strings/uk.json b/src/i18n/strings/uk.json index dd087f8506e..d86168051bc 100644 --- a/src/i18n/strings/uk.json +++ b/src/i18n/strings/uk.json @@ -3480,5 +3480,23 @@ "You made it!": "Ви це зробили!", "Help": "Довідка", "We’d appreciate any feedback on how you’re finding Element.": "Ми будемо вдячні за ваш відгук про Element.", - "How are you finding Element so far?": "Як вам Element?" + "How are you finding Element so far?": "Як вам Element?", + "Google Play and the Google Play logo are trademarks of Google LLC.": "Google Play і логотип Google Play є товарними знаками Google LLC.", + "App Store® and the Apple logo® are trademarks of Apple Inc.": "App Store® і логотип Apple® є товарними знаками Apple Inc.", + "Get it on F-Droid": "Отримати з F-Droid", + "Get it on Google Play": "Отримати з Google Play", + "Android": "Android", + "Download on the App Store": "Завантажити з App Store", + "iOS": "iOS", + "Download %(brand)s Desktop": "Завантажити %(brand)s для компʼютера", + "Download %(brand)s": "Завантажити %(brand)s", + "We're creating a room with %(names)s": "Ми створюємо кімнату з %(names)s", + "Your server doesn't support disabling sending read receipts.": "Ваш сервер не підтримує вимкнення надсилання сповіщень про прочитання.", + "Share your activity and status with others.": "Діліться своєю активністю та станом з іншими.", + "Presence": "Присутність", + "Send read receipts": "Надсилати підтвердження прочитання", + "Last activity": "Остання активність", + "Sessions": "Сеанси", + "Use new session manager (under active development)": "Використовувати новий менеджер сеансів (в активній розробці)", + "Current session": "Поточний сеанс" } diff --git a/src/i18n/strings/zh_Hans.json b/src/i18n/strings/zh_Hans.json index ddc03caf37a..50f04c4800f 100644 --- a/src/i18n/strings/zh_Hans.json +++ b/src/i18n/strings/zh_Hans.json @@ -493,7 +493,7 @@ "Muted Users": "被禁言的用户", "In encrypted rooms, like this one, URL previews are disabled by default to ensure that your homeserver (where the previews are generated) cannot gather information about links you see in this room.": "在加密的房间中,比如这个,默认禁用URL预览,以确保主服务器(生成预览的地方)无法获知你在此房间中看到的链接的有关的信息。", "When someone puts a URL in their message, a URL preview can be shown to give more information about that link such as the title, description, and an image from the website.": "当有人发送一条带有链接的消息后,可显示链接的预览,链接预览可包含此链接的网页标题、描述以及图片。", - "Are you sure you wish to remove (delete) this event? Note that if you delete a room name or topic change, it could undo the change.": "你确定要移除(删除)此事件吗?注意,如果删除了房间名称或话题的修改事件,就会撤销此更改。", + "Are you sure you wish to remove (delete) this event? Note that if you delete a room name or topic change, it could undo the change.": "你确定要移除(删除)此事件吗?注意,如果删除房间名称或话题的更改,更改会被撤销。", "Clear Storage and Sign Out": "清除数据并退出登录", "Send Logs": "发送日志", "Refresh": "刷新", @@ -756,7 +756,7 @@ "Unable to load commit detail: %(msg)s": "无法加载提交详情:%(msg)s", "To avoid losing your chat history, you must export your room keys before logging out. You will need to go back to the newer version of %(brand)s to do this": "为避免丢失聊天记录,你必须在登出前导出房间密钥。你需要切换至新版 %(brand)s 方可继续执行此操作", "Verify this user to mark them as trusted. Trusting users gives you extra peace of mind when using end-to-end encrypted messages.": "验证此用户并将其标记为已信任。在收发端到端加密消息时,信任用户可让你更加放心。", - "Waiting for partner to confirm...": "等待对方确认中...", + "Waiting for partner to confirm...": "等待对方确认中……", "Incoming Verification Request": "收到验证请求", "You've previously used %(brand)s on %(host)s with lazy loading of members enabled. In this version lazy loading is disabled. As the local cache is not compatible between these two settings, %(brand)s needs to resync your account.": "你之前在 %(host)s 上开启了 %(brand)s 的成员列表延迟加载设置。目前版本中延迟加载功能已被停用。因为本地缓存在这两个设置项上不相容,%(brand)s 需要重新同步你的账户。", "%(brand)s now uses 3-5x less memory, by only loading information about other users when needed. Please wait whilst we resynchronise with the server!": "通过仅在需要时加载其他用户的信息,%(brand)s 现在使用的内存减少到了原来的三分之一至五分之一。 请等待与服务器重新同步!", @@ -794,7 +794,7 @@ "Create account": "创建账户", "Registration has been disabled on this homeserver.": "此主服务器已禁止注册。", "Unable to query for supported registration methods.": "无法查询支持的注册方法。", - "Keep going...": "请继续...", + "Keep going...": "请继续……", "For maximum security, this should be different from your account password.": "为确保最大的安全性,它应该与你的账户密码不同。", "That matches!": "匹配成功!", "That doesn't match.": "不匹配。", @@ -835,8 +835,8 @@ "Once enabled, encryption for a room cannot be disabled. Messages sent in an encrypted room cannot be seen by the server, only by the participants of the room. Enabling encryption may prevent many bots and bridges from working correctly. Learn more about encryption.": "房间加密一经启用,便无法禁用。在加密房间中,发送的消息无法被服务器看到,只能被房间的参与者看到。启用加密可能会使许多机器人和桥接无法正常运作。 详细了解加密。", "Power level": "权力级别", "Warning: Upgrading a room will not automatically migrate room members to the new version of the room. We'll post a link to the new room in the old version of the room - room members will have to click this link to join the new room.": "警告:升级房间 不会自动将房间成员转移到新版房间中。 我们将会在旧版房间中发布一个新版房间的链接——房间成员必须点击此链接以加入新房间。", - "Adds a custom widget by URL to the room": "通过链接为房间添加自定义挂件", - "Please supply a https:// or http:// widget URL": "请提供一个 https:// 或 http:// 形式的插件", + "Adds a custom widget by URL to the room": "通过URL添加自定义挂件到房间", + "Please supply a https:// or http:// widget URL": "请提供一个 https:// 或 http:// 挂件URL", "You cannot modify widgets in this room.": "你无法修改此房间的插件。", "%(senderName)s revoked the invitation for %(targetDisplayName)s to join the room.": "%(senderName)s 撤销了对 %(targetDisplayName)s 加入房间的邀请。", "Upgrade this room to the recommended room version": "升级此房间至推荐版本", @@ -883,7 +883,7 @@ "Use your account or create a new one to continue.": "使用已有账户或创建一个新账户。", "Create Account": "创建账户", "Sign In": "登录", - "Custom (%(level)s)": "访客(%(level)s)", + "Custom (%(level)s)": "自定义(%(level)s)", "Messages": "信息", "Actions": "动作", "Sends a message as plain text, without interpreting it as markdown": "以纯文本形式发送消息,不将其作为 markdown 处理", @@ -899,7 +899,7 @@ "Use an identity server to invite by email. Manage in Settings.": "使用身份服务器以通过电子邮件邀请其他用户。在设置中进行管理。", "Unbans user with given ID": "按照 ID 解封用户", "Could not find user in room": "房间中无用户", - "Please supply a widget URL or embed code": "请提供一个插件或嵌入代码", + "Please supply a widget URL or embed code": "请提供一个挂件URL或嵌入代码", "Verifies a user, session, and pubkey tuple": "验证用户、会话和公钥元组", "Session already verified!": "会话已验证!", "WARNING: Session already verified, but keys do NOT MATCH!": "警告:会话已验证,但密钥不匹配!", @@ -946,7 +946,7 @@ "%(name)s (%(userId)s) signed in to a new session without verifying it:": "%(name)s(%(userId)s)登录到未验证的新会话:", "Ask this user to verify their session, or manually verify it below.": "要求此用户验证其会话,或在下面手动进行验证。", "Not Trusted": "不可信任", - "Manually Verify by Text": "手动验证文字", + "Manually Verify by Text": "用文本手动验证", "Interactively verify by Emoji": "通过表情符号进行交互式验证", "Done": "完成", "Cannot reach homeserver": "无法连接到主服务器", @@ -996,7 +996,7 @@ "Call ended": "通话结束", "You started a call": "你开始了通话", "%(senderName)s started a call": "%(senderName)s开始了通话", - "Waiting for answer": "等待接听", + "Waiting for answer": "正在等待接听", "%(senderName)s is calling": "%(senderName)s正在通话", "Support adding custom themes": "支持添加自定义主题", "Font size": "字体大小", @@ -1017,8 +1017,8 @@ "My Ban List": "我的封禁列表", "This is your list of users/servers you have blocked - don't leave the room!": "这是你屏蔽的用户/服务器的列表——不要离开此房间!", "Unknown caller": "未知来电人", - "Waiting for %(displayName)s to verify…": "等待 %(displayName)s 进行验证…", - "Cancelling…": "正在取消…", + "Waiting for %(displayName)s to verify…": "正在等待%(displayName)s进行验证……", + "Cancelling…": "正在取消……", "They match": "它们匹配", "They don't match": "它们不匹配", "To be secure, do this in person or use a trusted way to communicate.": "为了安全,请当面完成或使用信任的方法交流。", @@ -1096,7 +1096,7 @@ "Custom font size can only be between %(min)s pt and %(max)s pt": "自定义字体大小只能介于 %(min)s pt 和 %(max)s pt 之间", "Error downloading theme information.": "下载主题信息时发生错误。", "Theme added!": "主题已添加!", - "Custom theme URL": "自定义主题链接", + "Custom theme URL": "自定义主题URL", "Add theme": "添加主题", "Message layout": "信息布局", "Modern": "现代", @@ -1145,7 +1145,7 @@ "Sounds": "声音", "Notification sound": "通知声音", "Reset": "重置", - "Set a new custom sound": "使用新的自定义声音", + "Set a new custom sound": "设置新的自定义声音", "Browse": "浏览", "Upgrade the room": "更新房间", "Enable room encryption": "启用房间加密", @@ -1223,7 +1223,7 @@ "Room %(name)s": "房间 %(name)s", "No recently visited rooms": "没有最近访问过的房间", "People": "联系人", - "Joining room …": "正在加入房间…", + "Joining room …": "正在加入房间……", "Loading …": "正在加载……", "Rejecting invite …": "正在拒绝邀请……", "Join the conversation with an account": "使用一个账户加入对话", @@ -1294,7 +1294,7 @@ "New published address (e.g. #alias:server)": "新的发布的地址(例如 #alias:server)", "Local Addresses": "本地地址", "Set addresses for this room so users can find this room through your homeserver (%(localDomain)s)": "为此房间设置地址以便用户通过你的主服务器(%(localDomain)s)找到此房间", - "Waiting for %(displayName)s to accept…": "等待%(displayName)s接受……", + "Waiting for %(displayName)s to accept…": "正在等待%(displayName)s接受……", "Accepting…": "正在接受……", "Start Verification": "开始验证", "Messages in this room are end-to-end encrypted.": "此房间内的消息是端对端加密的。", @@ -2198,7 +2198,7 @@ "Decrypted event source": "解密的事件源码", "Original event source": "原始事件源码", "Invite by username": "按照用户名邀请", - "Inviting...": "正在邀请…", + "Inviting...": "正在邀请……", "Welcome to ": "欢迎来到 ", "Share %(name)s": "分享 %(name)s", "Add a topic to help people know what it is about.": "添加话题,让大家知道这里是讨论什么的。", @@ -2306,7 +2306,7 @@ "This usually only affects how the room is processed on the server. If you're having problems with your %(brand)s, please report a bug.": "这通常仅影响服务器如何处理房间。如果你的 %(brand)s 遇到问题,请回报错误。", "Just a heads up, if you don't add an email and forget your password, you could permanently lose access to your account.": "请注意,如果你不添加电子邮箱并且忘记密码,你将永远失去对你账户的访问权。", "Continuing without email": "不使用电子邮箱并继续", - "Data on this screen is shared with %(widgetDomain)s": "在此画面上的资料会与 %(widgetDomain)s 分享", + "Data on this screen is shared with %(widgetDomain)s": "此屏幕上的数据与%(widgetDomain)s分享", "Consult first": "先询问", "Invited people will be able to read old messages.": "被邀请的人将能够阅读过去的消息。", "Invite someone using their name, username (like ) or share this room.": "使用某人的名字、用户名(如 )或分享此房间来邀请他们。", @@ -2498,7 +2498,7 @@ "Collapse": "折叠", "Expand": "展开", "Recommended for public spaces.": "建议用于公开空间。", - "Allow people to preview your space before they join.": "允许在加入前预览你的空间。", + "Allow people to preview your space before they join.": "允许人们在加入前预览你的空间。", "Preview Space": "预览空间", "Decide who can view and join %(spaceName)s.": "决定谁可以查看和加入 %(spaceName)s。", "Visibility": "可见性", @@ -2528,7 +2528,7 @@ "%(senderName)s removed their profile picture": "%(senderName)s 已移除他们的资料图片", "%(senderName)s removed their display name (%(oldDisplayName)s)": "%(senderName)s 已将他们的昵称移除(%(oldDisplayName)s)", "%(senderName)s set their display name to %(displayName)s": "%(senderName)s 已将他们的昵称设置为 %(displayName)s", - "%(oldDisplayName)s changed their display name to %(displayName)s": "%(oldDisplayName)s 已将他们的昵称更改为 %(displayName)s", + "%(oldDisplayName)s changed their display name to %(displayName)s": "%(oldDisplayName)s将其显示名称改为%(displayName)s", "%(senderName)s banned %(targetName)s": "%(senderName)s 已封禁 %(targetName)s", "%(senderName)s banned %(targetName)s: %(reason)s": "%(senderName)s 已封禁 %(targetName)s: %(reason)s", "%(senderName)s invited %(targetName)s": "%(senderName)s 已邀请 %(targetName)s", @@ -2876,7 +2876,7 @@ "Files": "文件", "You won't get any notifications": "你不会收到任何通知", "Get notified only with mentions and keywords as set up in your settings": "如设置中设定的那样仅通知提及和关键词", - "@mentions & keywords": "@提及 & 关键词", + "@mentions & keywords": "@提及和关键词", "Get notified for every message": "获得每条消息的通知", "Get notifications as set up in your settings": "如设置中设定的那样获取通知", "sends rainfall": "发送降雨", @@ -3339,5 +3339,34 @@ "Set up your profile": "设置你的用户资料", "Download apps": "下载应用", "Download Element": "下载Element", - "Help": "帮助" + "Help": "帮助", + "Results are only revealed when you end the poll": "结果仅在你结束投票后展示", + "Voters see results as soon as they have voted": "投票者一投完票就能看到结果", + "Closed poll": "封闭式投票", + "Open poll": "开放式投票", + "Poll type": "投票类型", + "App Store® and the Apple logo® are trademarks of Apple Inc.": "App Store®和Apple logo®是Apple Inc.的商标", + "Google Play and the Google Play logo are trademarks of Google LLC.": "Google Play及其logo是Google LLC的商标。", + "Community ownership": "社群所有权", + "With free end-to-end encrypted messaging, and unlimited voice and video calls, %(brand)s is a great way to stay in touch.": "%(brand)s提供免费的端到端加密消息传递以及无限制的语音和视频通话,是保持联系的绝佳方式。", + "We’d appreciate any feedback on how you’re finding Element.": "对于您如何找到Element的任何反馈,我们将不胜感激。", + "Don’t miss a reply or important message": "不要错过回复或重要消息", + "Make sure people know it’s really you": "确保人们知道这真的是你", + "Don’t miss a thing by taking Element with you": "随身携带Element,不要错过任何事", + "Find and invite your community members": "发现并邀请你的社群成员", + "Find people": "找人", + "Find and invite your co-workers": "发现并邀请你的同事", + "Find friends": "发现朋友", + "Find and invite your friends": "发现并邀请你的朋友", + "Find your people": "寻找你的人", + "Welcome to %(brand)s": "欢迎来到%(brand)s", + "Only %(count)s steps to go|other": "仅需%(count)s步", + "Only %(count)s steps to go|one": "仅需%(count)s步", + "How are you finding Element so far?": "你是如何发现Element的?", + "Download %(brand)s": "下载%(brand)s", + "Download %(brand)s Desktop": "下载%(brand)s桌面版", + "Download on the App Store": "在App Store下载", + "Send read receipts": "发送已读回执", + "Share your activity and status with others.": "与别人分享你的活动和状态。", + "Your server doesn't support disabling sending read receipts.": "你的服务器不支持禁用发送已读回执。" } diff --git a/src/i18n/strings/zh_Hant.json b/src/i18n/strings/zh_Hant.json index 06dac88d787..e17feb2b9a9 100644 --- a/src/i18n/strings/zh_Hant.json +++ b/src/i18n/strings/zh_Hant.json @@ -264,7 +264,7 @@ "Reject all %(invitedRooms)s invites": "拒絕所有 %(invitedRooms)s 邀請", "Failed to invite": "邀請失敗", "Confirm Removal": "確認移除", - "Are you sure you wish to remove (delete) this event? Note that if you delete a room name or topic change, it could undo the change.": "您確定您想要移除(刪除)此活動嗎?注意若您刪除房間名稱或主題變更,還是可以復原變更。", + "Are you sure you wish to remove (delete) this event? Note that if you delete a room name or topic change, it could undo the change.": "您確定您想要移除(刪除)此活動嗎?注意,若您刪除房間名稱或主題的變更,變更會被復原。", "Unknown error": "未知的錯誤", "Incorrect password": "不正確的密碼", "Unable to restore session": "無法復原工作階段", @@ -3129,7 +3129,7 @@ "Results are only revealed when you end the poll": "結果僅在您結束投票時顯示", "Voters see results as soon as they have voted": "投票者可以在投票後立刻看到結果", "Open poll": "開放投票", - "Closed poll": "已關閉的投票", + "Closed poll": "封闭式投票", "Poll type": "投票類型", "Results will be visible when the poll is ended": "結果將在投票結束時可見", "Open user settings": "開啟使用者設定", @@ -3480,5 +3480,22 @@ "You made it!": "您做到了!", "Help": "說明", "We’d appreciate any feedback on how you’re finding Element.": "對於您如何找到 Element 的任何回饋,我們將不勝感激。", - "How are you finding Element so far?": "您是如何找到 Element 的?" + "How are you finding Element so far?": "您是如何找到 Element 的?", + "Google Play and the Google Play logo are trademarks of Google LLC.": "Google Play 與 Google Play logo 是 Google 公司的商標。", + "App Store® and the Apple logo® are trademarks of Apple Inc.": "App Store® 與 Apple logo® 是蘋果公司的商標。", + "Get it on F-Droid": "在 F-Droid 上取得", + "Get it on Google Play": "在 Google Play 上取得", + "Android": "Android", + "Download on the App Store": "在 App Store 上下載", + "iOS": "iOS", + "Download %(brand)s Desktop": "下載 %(brand)s 桌面版", + "Download %(brand)s": "下載 %(brand)s", + "We're creating a room with %(names)s": "我們正在建立一個包含 %(names)s 的聊天室", + "Your server doesn't support disabling sending read receipts.": "您的伺服器不支援停用傳送讀取回條。", + "Share your activity and status with others.": "與他人分享您的活動與狀態。", + "Presence": "在場", + "Send read receipts": "傳送讀取回條", + "Last activity": "上次活動", + "Sessions": "工作階段", + "Use new session manager (under active development)": "使用新的工作階段管理程式(正在積極開發中)" } diff --git a/src/linkify-matrix.ts b/src/linkify-matrix.ts index b626756f7c6..896784cb454 100644 --- a/src/linkify-matrix.ts +++ b/src/linkify-matrix.ts @@ -130,8 +130,8 @@ function onAliasClick(event: MouseEvent, roomAlias: string) { }); } -const escapeRegExp = function(string): string { - return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +const escapeRegExp = function(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); }; // Recognise URLs from both our local and official Element deployments. diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index 1a3f361c1a8..0a10a37ad49 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -296,6 +296,11 @@ export const SETTINGS: {[setting: string]: ISetting} = { }, }, + "feature_thread_read_receipts": { + displayName: _td("Server support for threads notifications and read receipts"), + supportedLevels: LEVELS_FEATURE, + default: false, + }, "feature_state_counters": { isFeature: true, labsGroup: LabGroup.Rooms, @@ -406,13 +411,6 @@ export const SETTINGS: {[setting: string]: ISetting} = { displayName: _td("Send read receipts"), default: true, }, - "feature_message_right_click_context_menu": { - isFeature: true, - supportedLevels: LEVELS_FEATURE, - labsGroup: LabGroup.Rooms, - displayName: _td("Right-click message context menu"), - default: false, - }, "feature_location_share_live": { isFeature: true, labsGroup: LabGroup.Messaging, @@ -804,6 +802,11 @@ export const SETTINGS: {[setting: string]: ISetting} = { default: true, controller: new IncompatibleController("feature_breadcrumbs_v2", true), }, + "FTUE.userOnboardingButton": { + supportedLevels: LEVELS_ACCOUNT_SETTINGS, + displayName: _td("Show shortcut to welcome checklist above the room list"), + default: true, + }, "showHiddenEventsInTimeline": { displayName: _td("Show hidden events in timeline"), supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS, diff --git a/src/settings/controllers/SettingController.ts b/src/settings/controllers/SettingController.ts index a274bcff2c3..2d747e52930 100644 --- a/src/settings/controllers/SettingController.ts +++ b/src/settings/controllers/SettingController.ts @@ -63,16 +63,14 @@ export default abstract class SettingController { * @param {String} roomId The room ID, may be null. * @param {*} newValue The new value for the setting, may be null. */ - public onChange(level: SettingLevel, roomId: string, newValue: any) { + public onChange(level: SettingLevel, roomId: string, newValue: any): void { // do nothing by default - - // FIXME: force a fresh on the RoomView for the roomId in question } /** * Gets whether the setting has been disabled due to this controller. */ - public get settingDisabled() { + public get settingDisabled(): boolean { return false; } } diff --git a/src/settings/handlers/AccountSettingsHandler.ts b/src/settings/handlers/AccountSettingsHandler.ts index d1a2c6c6224..f7a5fe9ca5f 100644 --- a/src/settings/handlers/AccountSettingsHandler.ts +++ b/src/settings/handlers/AccountSettingsHandler.ts @@ -165,8 +165,8 @@ export default class AccountSettingsHandler extends MatrixClientBackedSettingsHa content[field] = value; - await this.client.setAccountData(eventType, content); - + // Attach a deferred *before* setting the account data to ensure we catch any requests + // which race between different lines. const deferred = defer(); const handler = (event: MatrixEvent) => { if (event.getType() !== eventType || event.getContent()[field] !== value) return; @@ -175,6 +175,8 @@ export default class AccountSettingsHandler extends MatrixClientBackedSettingsHa }; this.client.on(ClientEvent.AccountData, handler); + await this.client.setAccountData(eventType, content); + await deferred.promise; } diff --git a/src/stores/RoomViewStore.tsx b/src/stores/RoomViewStore.tsx index ea2b7e93ad2..4f0e7d5b13b 100644 --- a/src/stores/RoomViewStore.tsx +++ b/src/stores/RoomViewStore.tsx @@ -24,7 +24,6 @@ import { ViewRoom as ViewRoomEvent } from "@matrix-org/analytics-events/types/ty import { JoinedRoom as JoinedRoomEvent } from "@matrix-org/analytics-events/types/typescript/JoinedRoom"; import { JoinRule } from "matrix-js-sdk/src/@types/partials"; import { Room } from "matrix-js-sdk/src/models/room"; -import { ClientEvent } from "matrix-js-sdk/src/client"; import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { Optional } from "matrix-events-sdk"; @@ -48,6 +47,7 @@ import { JoinRoomErrorPayload } from "../dispatcher/payloads/JoinRoomErrorPayloa import { ViewRoomErrorPayload } from "../dispatcher/payloads/ViewRoomErrorPayload"; import ErrorDialog from "../components/views/dialogs/ErrorDialog"; import { ActiveRoomChangedPayload } from "../dispatcher/payloads/ActiveRoomChangedPayload"; +import { awaitRoomDownSync } from "../utils/RoomUpgrade"; const NUM_JOIN_RETRY = 5; @@ -209,10 +209,7 @@ export class RoomViewStore extends Store { this.setState({ shouldPeek: false }); } - const cli = MatrixClientPeg.get(); - - const updateMetrics = () => { - const room = cli.getRoom(payload.roomId); + awaitRoomDownSync(MatrixClientPeg.get(), payload.roomId).then(room => { const numMembers = room.getJoinedMemberCount(); const roomSize = numMembers > 1000 ? "MoreThanAThousand" : numMembers > 100 ? "OneHundredAndOneToAThousand" @@ -228,15 +225,7 @@ export class RoomViewStore extends Store { isDM: !!DMRoomMap.shared().getUserIdForRoomId(room.roomId), isSpace: room.isSpaceRoom(), }); - - cli.off(ClientEvent.Room, updateMetrics); - }; - - if (cli.getRoom(payload.roomId)) { - updateMetrics(); - } else { - cli.on(ClientEvent.Room, updateMetrics); - } + }); break; } diff --git a/src/stores/notifications/ThreadNotificationState.ts b/src/stores/notifications/ThreadNotificationState.ts index 2b2bcf175ce..5572ad5ffb3 100644 --- a/src/stores/notifications/ThreadNotificationState.ts +++ b/src/stores/notifications/ThreadNotificationState.ts @@ -14,64 +14,111 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { MatrixEvent } from "matrix-js-sdk/src/models/event"; -import { Thread, ThreadEvent } from "matrix-js-sdk/src/models/thread"; +import { MatrixEvent, MatrixEventEvent } from "matrix-js-sdk/src/models/event"; +import { NotificationCountType, Room, RoomEvent } from "matrix-js-sdk/src/models/room"; +import { ClientEvent } from "matrix-js-sdk/src/matrix"; import { NotificationColor } from "./NotificationColor"; import { IDestroyable } from "../../utils/IDestroyable"; import { MatrixClientPeg } from "../../MatrixClientPeg"; import { NotificationState } from "./NotificationState"; +import { readReceiptChangeIsFor } from "../../utils/read-receipts"; +import * as RoomNotifs from '../../RoomNotifs'; export class ThreadNotificationState extends NotificationState implements IDestroyable { - protected _symbol = null; - protected _count = 0; - protected _color = NotificationColor.None; - - constructor(public readonly thread: Thread) { + constructor(public readonly room: Room, public readonly threadId: string) { super(); - this.thread.on(ThreadEvent.NewReply, this.handleNewThreadReply); - this.thread.on(ThreadEvent.ViewThread, this.resetThreadNotification); - if (this.thread.replyToEvent) { - // Process the current tip event - this.handleNewThreadReply(this.thread, this.thread.replyToEvent); - } + 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); + MatrixClientPeg.get().on(MatrixEventEvent.Decrypted, this.onEventDecrypted); + MatrixClientPeg.get().on(ClientEvent.AccountData, this.handleAccountDataUpdate); + this.updateNotificationState(); } public destroy(): void { super.destroy(); - this.thread.off(ThreadEvent.NewReply, this.handleNewThreadReply); - this.thread.off(ThreadEvent.ViewThread, this.resetThreadNotification); + 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); + if (MatrixClientPeg.get()) { + MatrixClientPeg.get().removeListener(MatrixEventEvent.Decrypted, this.onEventDecrypted); + MatrixClientPeg.get().removeListener(ClientEvent.AccountData, this.handleAccountDataUpdate); + } } + private handleLocalEchoUpdated = () => { + this.updateNotificationState(); + }; - private handleNewThreadReply = (thread: Thread, event: MatrixEvent) => { - const client = MatrixClientPeg.get(); + private handleReadReceipt = (event: MatrixEvent, room: Room) => { + if (!readReceiptChangeIsFor(event, MatrixClientPeg.get())) return; // not our own - ignore + if (room.roomId !== this.room.roomId) return; // not for us - ignore + this.updateNotificationState(); + }; - const myUserId = client.getUserId(); + private handleMembershipUpdate = () => { + this.updateNotificationState(); + }; - const isOwn = myUserId === event.getSender(); - const readReceipt = this.thread.room.getReadReceiptForUserId(myUserId); + private onEventDecrypted = (event: MatrixEvent) => { + if (event.getRoomId() !== this.room.roomId) return; // ignore - not for us or notifications timeline - if (!isOwn && !readReceipt || (readReceipt && event.getTs() >= readReceipt.data.ts)) { - const actions = client.getPushActionsForEvent(event, true); + this.updateNotificationState(); + }; - if (actions?.tweaks) { - const color = !!actions.tweaks.highlight - ? NotificationColor.Red - : NotificationColor.Grey; + private handleRoomEventUpdate = (event: MatrixEvent, room: Room | null) => { + if (room?.roomId !== this.room.roomId) return; // ignore - not for us or notifications timeline - this.updateNotificationState(color); - } - } + this.updateNotificationState(); }; - private resetThreadNotification = (): void => { - this.updateNotificationState(NotificationColor.None); + private handleAccountDataUpdate = (ev: MatrixEvent) => { + if (ev.getType() === "m.push_rules") { + this.updateNotificationState(); + } }; - private updateNotificationState(color: NotificationColor) { + private updateNotificationState() { const snapshot = this.snapshot(); - this._color = color; + if (RoomNotifs.getRoomNotifsState(this.room.roomId) === RoomNotifs.RoomNotifState.Mute) { + // When muted we suppress all notification states, even if we have context on them. + this._color = NotificationColor.None; + this._symbol = null; + this._count = 0; + } else { + const redNotifs = this.room.getThreadUnreadNotificationCount( + this.threadId, + NotificationCountType.Highlight, + ); + const greyNotifs = this.room.getThreadUnreadNotificationCount( + this.threadId, + NotificationCountType.Total, + ); + + // For a 'true count' we pick the grey notifications first because they include the + // red notifications. If we don't have a grey count for some reason we use the red + // count. If that count is broken for some reason, assume zero. This avoids us showing + // a badge for 'NaN' (which formats as 'NaNB' for NaN Billion). + const trueCount = greyNotifs ? greyNotifs : (redNotifs ? redNotifs : 0); + + // Note: we only set the symbol if we have an actual count. We don't want to show + // zero on badges. + + if (redNotifs > 0) { + this._color = NotificationColor.Red; + this._count = trueCount; + this._symbol = null; // symbol calculated by component + } else if (greyNotifs > 0) { + this._color = NotificationColor.Grey; + this._count = trueCount; + this._symbol = null; // symbol calculated by component + } + } // finally, publish an update if needed this.emitIfUpdated(snapshot); diff --git a/src/stores/notifications/ThreadsRoomNotificationState.ts b/src/stores/notifications/ThreadsRoomNotificationState.ts index e0ec810cec4..ced50184828 100644 --- a/src/stores/notifications/ThreadsRoomNotificationState.ts +++ b/src/stores/notifications/ThreadsRoomNotificationState.ts @@ -14,70 +14,108 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { Room } from "matrix-js-sdk/src/models/room"; -import { Thread, ThreadEvent } from "matrix-js-sdk/src/models/thread"; +import { NotificationCountType, Room, RoomEvent } from "matrix-js-sdk/src/models/room"; +import { ClientEvent } from "matrix-js-sdk/src/matrix"; +import { MatrixEvent, MatrixEventEvent } from "matrix-js-sdk/src/models/event"; import { IDestroyable } from "../../utils/IDestroyable"; -import { NotificationState, NotificationStateEvents } from "./NotificationState"; -import { ThreadNotificationState } from "./ThreadNotificationState"; +import { NotificationState } from "./NotificationState"; import { NotificationColor } from "./NotificationColor"; +import { MatrixClientPeg } from "../../MatrixClientPeg"; +import * as RoomNotifs from '../../RoomNotifs'; +import { readReceiptChangeIsFor } from "../../utils/read-receipts"; export class ThreadsRoomNotificationState extends NotificationState implements IDestroyable { - public readonly threadsState = new Map(); - - protected _symbol = null; - protected _count = 0; - protected _color = NotificationColor.None; - constructor(public readonly room: Room) { super(); - for (const thread of this.room.getThreads()) { - this.onNewThread(thread); - } - this.room.on(ThreadEvent.New, this.onNewThread); + 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); + MatrixClientPeg.get().on(MatrixEventEvent.Decrypted, this.onEventDecrypted); + MatrixClientPeg.get().on(ClientEvent.AccountData, this.handleAccountDataUpdate); + this.updateNotificationState(); } public destroy(): void { super.destroy(); - this.room.off(ThreadEvent.New, this.onNewThread); - for (const [, notificationState] of this.threadsState) { - notificationState.off(NotificationStateEvents.Update, this.onThreadUpdate); + 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); + if (MatrixClientPeg.get()) { + MatrixClientPeg.get().removeListener(MatrixEventEvent.Decrypted, this.onEventDecrypted); + MatrixClientPeg.get().removeListener(ClientEvent.AccountData, this.handleAccountDataUpdate); } } + private handleLocalEchoUpdated = () => { + this.updateNotificationState(); + }; - public getThreadRoomState(thread: Thread): ThreadNotificationState { - if (!this.threadsState.has(thread)) { - this.threadsState.set(thread, new ThreadNotificationState(thread)); - } - return this.threadsState.get(thread); - } + private handleReadReceipt = (event: MatrixEvent, room: Room) => { + if (!readReceiptChangeIsFor(event, MatrixClientPeg.get())) return; // not our own - ignore + if (room.roomId !== this.room.roomId) return; // not for us - ignore + this.updateNotificationState(); + }; - private onNewThread = (thread: Thread): void => { - const notificationState = new ThreadNotificationState(thread); - this.threadsState.set( - thread, - notificationState, - ); - notificationState.on(NotificationStateEvents.Update, this.onThreadUpdate); + private handleMembershipUpdate = () => { + this.updateNotificationState(); }; - private onThreadUpdate = (): void => { - let color = NotificationColor.None; - for (const [, notificationState] of this.threadsState) { - if (notificationState.color === NotificationColor.Red) { - color = NotificationColor.Red; - break; - } else if (notificationState.color === NotificationColor.Grey) { - color = NotificationColor.Grey; - } + private onEventDecrypted = (event: MatrixEvent) => { + if (event.getRoomId() !== this.room.roomId) return; // ignore - not for us or notifications timeline + + this.updateNotificationState(); + }; + + private handleRoomEventUpdate = (event: MatrixEvent, room: Room | null) => { + if (room?.roomId !== this.room.roomId) return; // ignore - not for us or notifications timeline + + this.updateNotificationState(); + }; + + private handleAccountDataUpdate = (ev: MatrixEvent) => { + if (ev.getType() === "m.push_rules") { + this.updateNotificationState(); } - this.updateNotificationState(color); }; - private updateNotificationState(color: NotificationColor): void { + private updateNotificationState() { const snapshot = this.snapshot(); - this._color = color; + + if (RoomNotifs.getRoomNotifsState(this.room.roomId) === RoomNotifs.RoomNotifState.Mute) { + // When muted we suppress all notification states, even if we have context on them. + this._color = NotificationColor.None; + this._symbol = null; + this._count = 0; + } else { + const redNotifs = this.room.getTotalThreadsUnreadNotificationCount(NotificationCountType.Highlight); + const greyNotifs = this.room.getTotalThreadsUnreadNotificationCount(NotificationCountType.Total); + + // For a 'true count' we pick the grey notifications first because they include the + // red notifications. If we don't have a grey count for some reason we use the red + // count. If that count is broken for some reason, assume zero. This avoids us showing + // a badge for 'NaN' (which formats as 'NaNB' for NaN Billion). + const trueCount = greyNotifs ? greyNotifs : (redNotifs ? redNotifs : 0); + + // Note: we only set the symbol if we have an actual count. We don't want to show + // zero on badges. + + if (redNotifs > 0) { + this._color = NotificationColor.Red; + this._count = trueCount; + this._symbol = null; // symbol calculated by component + } else if (greyNotifs > 0) { + this._color = NotificationColor.Grey; + this._count = trueCount; + this._symbol = null; // symbol calculated by component + } + } + // finally, publish an update if needed this.emitIfUpdated(snapshot); } } + diff --git a/src/stores/room-list/Interface.ts b/src/stores/room-list/Interface.ts new file mode 100644 index 00000000000..ab538709896 --- /dev/null +++ b/src/stores/room-list/Interface.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 type { Room } from "matrix-js-sdk/src/models/room"; +import type { EventEmitter } from "events"; +import { ITagMap, ListAlgorithm, SortAlgorithm } from "./algorithms/models"; +import { RoomUpdateCause, TagID } from "./models"; +import { IFilterCondition } from "./filters/IFilterCondition"; + +export enum RoomListStoreEvent { + // The event/channel which is called when the room lists have been changed. + ListsUpdate = "lists_update", +} + +export interface RoomListStore extends EventEmitter { + /** + * Gets an ordered set of rooms for the all known tags. + * @returns {ITagMap} The cached list of rooms, ordered, + * for each tag. May be empty, but never null/undefined. + */ + get orderedLists(): ITagMap; + + /** + * Set the sort algorithm for the specified tag. + * @param tagId the tag to set the algorithm for + * @param sort the sort algorithm to set to + */ + setTagSorting(tagId: TagID, sort: SortAlgorithm): void; + + /** + * Get the sort algorithm for the specified tag. + * @param tagId tag to get the sort algorithm for + * @returns the sort algorithm + */ + getTagSorting(tagId: TagID): SortAlgorithm; + + /** + * Set the list algorithm for the specified tag. + * @param tagId the tag to set the algorithm for + * @param order the list algorithm to set to + */ + setListOrder(tagId: TagID, order: ListAlgorithm): void; + + /** + * Get the list algorithm for the specified tag. + * @param tagId tag to get the list algorithm for + * @returns the list algorithm + */ + getListOrder(tagId: TagID): ListAlgorithm; + + /** + * Regenerates the room whole room list, discarding any previous results. + * + * Note: This is only exposed externally for the tests. Do not call this from within + * the app. + * @param params.trigger Set to false to prevent a list update from being sent. Should only + * be used if the calling code will manually trigger the update. + */ + regenerateAllLists(params: { trigger: boolean }): void; + + /** + * Adds a filter condition to the room list store. Filters may be applied async, + * and thus might not cause an update to the store immediately. + * @param {IFilterCondition} filter The filter condition to add. + */ + addFilter(filter: IFilterCondition): Promise; + + /** + * Removes a filter condition from the room list store. If the filter was + * not previously added to the room list store, this will no-op. The effects + * of removing a filter may be applied async and therefore might not cause + * an update right away. + * @param {IFilterCondition} filter The filter condition to remove. + */ + removeFilter(filter: IFilterCondition): void; + + /** + * Gets the tags for a room identified by the store. The returned set + * should never be empty, and will contain DefaultTagID.Untagged if + * the store is not aware of any tags. + * @param room The room to get the tags for. + * @returns The tags for the room. + */ + getTagsForRoom(room: Room): TagID[]; + + /** + * Manually update a room with a given cause. This should only be used if the + * room list store would otherwise be incapable of doing the update itself. Note + * that this may race with the room list's regular operation. + * @param {Room} room The room to update. + * @param {RoomUpdateCause} cause The cause to update for. + */ + manualRoomUpdate(room: Room, cause: RoomUpdateCause): Promise; +} diff --git a/src/stores/room-list/RoomListStore.ts b/src/stores/room-list/RoomListStore.ts index c15567afdc8..9083943ed9f 100644 --- a/src/stores/room-list/RoomListStore.ts +++ b/src/stores/room-list/RoomListStore.ts @@ -37,18 +37,15 @@ import { RoomNotificationStateStore } from "../notifications/RoomNotificationSta import { VisibilityProvider } from "./filters/VisibilityProvider"; import { SpaceWatcher } from "./SpaceWatcher"; import { IRoomTimelineActionPayload } from "../../actions/MatrixActionCreators"; +import { RoomListStore as Interface, RoomListStoreEvent } from "./Interface"; interface IState { // state is tracked in underlying classes } -/** - * The event/channel which is called when the room lists have been changed. Raised - * with one argument: the instance of the store. - */ -export const LISTS_UPDATE_EVENT = "lists_update"; +export const LISTS_UPDATE_EVENT = RoomListStoreEvent.ListsUpdate; -export class RoomListStoreClass extends AsyncStoreWithClient { +export class RoomListStoreClass extends AsyncStoreWithClient implements Interface { /** * Set to true if you're running tests on the store. Should not be touched in * any other environment. @@ -365,7 +362,7 @@ export class RoomListStoreClass extends AsyncStoreWithClient { this.algorithm.updatesInhibited = false; } - public async setTagSorting(tagId: TagID, sort: SortAlgorithm) { + public setTagSorting(tagId: TagID, sort: SortAlgorithm) { this.setAndPersistTagSorting(tagId, sort); this.updateFn.trigger(); } @@ -602,9 +599,9 @@ export class RoomListStoreClass extends AsyncStoreWithClient { } export default class RoomListStore { - private static internalInstance: RoomListStoreClass; + private static internalInstance: Interface; - public static get instance(): RoomListStoreClass { + public static get instance(): Interface { if (!RoomListStore.internalInstance) { RoomListStore.internalInstance = new RoomListStoreClass(); } diff --git a/src/stores/widgets/StopGapWidget.ts b/src/stores/widgets/StopGapWidget.ts index 7a1c5e0ba53..889a050ebfd 100644 --- a/src/stores/widgets/StopGapWidget.ts +++ b/src/stores/widgets/StopGapWidget.ts @@ -33,6 +33,7 @@ import { WidgetKind, } from "matrix-widget-api"; import { EventEmitter } from "events"; +import { MatrixClient } from "matrix-js-sdk/src/client"; import { MatrixEvent, MatrixEventEvent } from "matrix-js-sdk/src/models/event"; import { logger } from "matrix-js-sdk/src/logger"; import { ClientEvent } from "matrix-js-sdk/src/client"; @@ -148,6 +149,7 @@ export class ElementWidget extends Widget { } export class StopGapWidget extends EventEmitter { + private client: MatrixClient; private messaging: ClientWidgetApi; private mockWidget: ElementWidget; private scalarToken: string; @@ -157,12 +159,13 @@ export class StopGapWidget extends EventEmitter { constructor(private appTileProps: IAppTileProps) { super(); - let app = appTileProps.app; + this.client = MatrixClientPeg.get(); + let app = appTileProps.app; // Backwards compatibility: not all old widgets have a creatorUserId if (!app.creatorUserId) { app = objectShallowClone(app); // clone to prevent accidental mutation - app.creatorUserId = MatrixClientPeg.get().getUserId(); + app.creatorUserId = this.client.getUserId(); } this.mockWidget = new ElementWidget(app); @@ -203,7 +206,7 @@ export class StopGapWidget extends EventEmitter { const fromCustomisation = WidgetVariableCustomisations?.provideVariables?.() ?? {}; const defaults: ITemplateParams = { widgetRoomId: this.roomId, - currentUserId: MatrixClientPeg.get().getUserId(), + currentUserId: this.client.getUserId(), userDisplayName: OwnProfileStore.instance.displayName, userHttpAvatarUrl: OwnProfileStore.instance.getHttpAvatarUrl(), clientId: ELEMENT_CLIENT_ID, @@ -260,8 +263,10 @@ export class StopGapWidget extends EventEmitter { */ public startMessaging(iframe: HTMLIFrameElement): any { if (this.started) return; + const allowedCapabilities = this.appTileProps.whitelistCapabilities || []; const driver = new StopGapWidgetDriver(allowedCapabilities, this.mockWidget, this.kind, this.roomId); + this.messaging = new ClientWidgetApi(this.mockWidget, iframe, driver); this.messaging.on("preparing", () => this.emit("preparing")); this.messaging.on("ready", () => this.emit("ready")); @@ -302,7 +307,7 @@ export class StopGapWidget extends EventEmitter { // Populate the map of "read up to" events for this widget with the current event in every room. // This is a bit inefficient, but should be okay. We do this for all rooms in case the widget // requests timeline capabilities in other rooms down the road. It's just easier to manage here. - for (const room of MatrixClientPeg.get().getRooms()) { + for (const room of this.client.getRooms()) { // Timelines are most recent last const events = room.getLiveTimeline()?.getEvents() || []; const roomEvent = events[events.length - 1]; @@ -311,8 +316,9 @@ export class StopGapWidget extends EventEmitter { } // Attach listeners for feeding events - the underlying widget classes handle permissions for us - MatrixClientPeg.get().on(ClientEvent.Event, this.onEvent); - MatrixClientPeg.get().on(MatrixEventEvent.Decrypted, this.onEventDecrypted); + this.client.on(ClientEvent.Event, this.onEvent); + this.client.on(MatrixEventEvent.Decrypted, this.onEventDecrypted); + this.client.on(ClientEvent.ToDeviceEvent, this.onToDeviceEvent); this.messaging.on(`action:${WidgetApiFromWidgetAction.UpdateAlwaysOnScreen}`, (ev: CustomEvent) => { @@ -363,7 +369,7 @@ export class StopGapWidget extends EventEmitter { // noinspection JSIgnoredPromiseFromCall IntegrationManagers.sharedInstance().getPrimaryManager().open( - MatrixClientPeg.get().getRoom(RoomViewStore.instance.getRoomId()), + this.client.getRoom(RoomViewStore.instance.getRoomId()), `type_${integType}`, integId, ); @@ -428,14 +434,13 @@ export class StopGapWidget extends EventEmitter { WidgetMessagingStore.instance.stopMessaging(this.mockWidget, this.roomId); this.messaging = null; - if (MatrixClientPeg.get()) { - MatrixClientPeg.get().off(ClientEvent.Event, this.onEvent); - MatrixClientPeg.get().off(MatrixEventEvent.Decrypted, this.onEventDecrypted); - } + this.client.off(ClientEvent.Event, this.onEvent); + this.client.off(MatrixEventEvent.Decrypted, this.onEventDecrypted); + this.client.off(ClientEvent.ToDeviceEvent, this.onToDeviceEvent); } private onEvent = (ev: MatrixEvent) => { - MatrixClientPeg.get().decryptEventIfNeeded(ev); + this.client.decryptEventIfNeeded(ev); if (ev.isBeingDecrypted() || ev.isDecryptionFailure()) return; this.feedEvent(ev); }; @@ -445,6 +450,12 @@ export class StopGapWidget extends EventEmitter { this.feedEvent(ev); }; + private onToDeviceEvent = async (ev: MatrixEvent) => { + await this.client.decryptEventIfNeeded(ev); + if (ev.isDecryptionFailure()) return; + await this.messaging.feedToDevice(ev.getEffectiveEvent(), ev.isEncrypted()); + }; + private feedEvent(ev: MatrixEvent) { if (!this.messaging) return; @@ -465,7 +476,7 @@ export class StopGapWidget extends EventEmitter { // Timelines are most recent last, so reverse the order and limit ourselves to 100 events // to avoid overusing the CPU. - const timeline = MatrixClientPeg.get().getRoom(ev.getRoomId()).getLiveTimeline(); + const timeline = this.client.getRoom(ev.getRoomId()).getLiveTimeline(); const events = arrayFastClone(timeline.getEvents()).reverse().slice(0, 100); for (const timelineEvent of events) { diff --git a/src/stores/widgets/StopGapWidgetDriver.ts b/src/stores/widgets/StopGapWidgetDriver.ts index 3b617e6f314..8fe18dbc8c0 100644 --- a/src/stores/widgets/StopGapWidgetDriver.ts +++ b/src/stores/widgets/StopGapWidgetDriver.ts @@ -1,5 +1,5 @@ /* - * Copyright 2020 - 2021 The Matrix.org Foundation C.I.C. + * Copyright 2020 - 2022 The Matrix.org Foundation C.I.C. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -20,6 +20,8 @@ import { IOpenIDCredentials, IOpenIDUpdate, ISendEventDetails, + ITurnServer, + IRoomEvent, MatrixCapabilities, OpenIDRequestState, SimpleObservable, @@ -29,6 +31,7 @@ import { WidgetEventCapability, WidgetKind, } from "matrix-widget-api"; +import { ClientEvent, ITurnServer as IClientTurnServer } from "matrix-js-sdk/src/client"; import { EventType } from "matrix-js-sdk/src/@types/event"; import { IContent, IEvent, MatrixEvent } from "matrix-js-sdk/src/models/event"; import { Room } from "matrix-js-sdk/src/models/room"; @@ -61,6 +64,12 @@ function setRememberedCapabilitiesForWidget(widget: Widget, caps: Capability[]) localStorage.setItem(`widget_${widget.id}_approved_caps`, JSON.stringify(caps)); } +const normalizeTurnServer = ({ urls, username, credential }: IClientTurnServer): ITurnServer => ({ + uris: urls, + username, + password: credential, +}); + export class StopGapWidgetDriver extends WidgetDriver { private allowedCapabilities: Set; @@ -182,6 +191,49 @@ export class StopGapWidgetDriver extends WidgetDriver { return { roomId, eventId: r.event_id }; } + public async sendToDevice( + eventType: string, + encrypted: boolean, + contentMap: { [userId: string]: { [deviceId: string]: object } }, + ): Promise { + const client = MatrixClientPeg.get(); + + if (encrypted) { + const deviceInfoMap = await client.crypto.deviceList.downloadKeys(Object.keys(contentMap), false); + + await Promise.all( + Object.entries(contentMap).flatMap(([userId, userContentMap]) => + Object.entries(userContentMap).map(async ([deviceId, content]) => { + if (deviceId === "*") { + // Send the message to all devices we have keys for + await client.encryptAndSendToDevices( + Object.values(deviceInfoMap[userId]).map(deviceInfo => ({ + userId, deviceInfo, + })), + content, + ); + } else { + // Send the message to a specific device + await client.encryptAndSendToDevices( + [{ userId, deviceInfo: deviceInfoMap[userId][deviceId] }], + content, + ); + } + }), + ), + ); + } else { + await client.queueToDevice({ + eventType, + batch: Object.entries(contentMap).flatMap(([userId, userContentMap]) => + Object.entries(userContentMap).map(([deviceId, content]) => + ({ userId, deviceId, payload: content }), + ), + ), + }); + } + } + private pickRooms(roomIds: (string | Symbols.AnyRoom)[] = null): Room[] { const client = MatrixClientPeg.get(); if (!client) throw new Error("Not attached to a client"); @@ -197,7 +249,7 @@ export class StopGapWidgetDriver extends WidgetDriver { msgtype: string | undefined, limitPerRoom: number, roomIds: (string | Symbols.AnyRoom)[] = null, - ): Promise { + ): Promise { limitPerRoom = limitPerRoom > 0 ? Math.min(limitPerRoom, Number.MAX_SAFE_INTEGER) : Number.MAX_SAFE_INTEGER; // relatively arbitrary const rooms = this.pickRooms(roomIds); @@ -224,7 +276,7 @@ export class StopGapWidgetDriver extends WidgetDriver { stateKey: string | undefined, limitPerRoom: number, roomIds: (string | Symbols.AnyRoom)[] = null, - ): Promise { + ): Promise { limitPerRoom = limitPerRoom > 0 ? Math.min(limitPerRoom, Number.MAX_SAFE_INTEGER) : Number.MAX_SAFE_INTEGER; // relatively arbitrary const rooms = this.pickRooms(roomIds); @@ -282,4 +334,36 @@ export class StopGapWidgetDriver extends WidgetDriver { public async navigate(uri: string): Promise { navigateToPermalink(uri); } + + public async* getTurnServers(): AsyncGenerator { + const client = MatrixClientPeg.get(); + if (!client.pollingTurnServers || !client.getTurnServers().length) return; + + let setTurnServer: (server: ITurnServer) => void; + let setError: (error: Error) => void; + + const onTurnServers = ([server]: IClientTurnServer[]) => setTurnServer(normalizeTurnServer(server)); + const onTurnServersError = (error: Error, fatal: boolean) => { if (fatal) setError(error); }; + + client.on(ClientEvent.TurnServers, onTurnServers); + client.on(ClientEvent.TurnServersError, onTurnServersError); + + try { + const initialTurnServer = client.getTurnServers()[0]; + yield normalizeTurnServer(initialTurnServer); + + // Repeatedly listen for new TURN servers until an error occurs or + // the caller stops this generator + while (true) { + yield await new Promise((resolve, reject) => { + setTurnServer = resolve; + setError = reject; + }); + } + } finally { + // The loop was broken - clean up + client.off(ClientEvent.TurnServers, onTurnServers); + client.off(ClientEvent.TurnServersError, onTurnServersError); + } + } } diff --git a/src/utils/GroupCallUtils.ts b/src/utils/GroupCallUtils.ts new file mode 100644 index 00000000000..3af6a2b07a0 --- /dev/null +++ b/src/utils/GroupCallUtils.ts @@ -0,0 +1,175 @@ +/* +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 { EventTimeline, MatrixClient, MatrixEvent, RoomState } from "matrix-js-sdk/src/matrix"; +import { UnstableValue } from "matrix-js-sdk/src/NamespacedValue"; +import { deepCopy } from "matrix-js-sdk/src/utils"; + +export const STUCK_DEVICE_TIMEOUT_MS = 1000 * 60 * 60; // 1 hour + +export const CALL_STATE_EVENT_TYPE = new UnstableValue("m.call", "org.matrix.msc3401.call"); +export const CALL_MEMBER_STATE_EVENT_TYPE = new UnstableValue("m.call.member", "org.matrix.msc3401.call.member"); +const CALL_STATE_EVENT_TERMINATED = "m.terminated"; + +interface MDevice { + ["m.device_id"]: string; +} + +interface MCall { + ["m.call_id"]: string; + ["m.devices"]: Array; +} + +interface MCallMemberContent { + ["m.expires_ts"]: number; + ["m.calls"]: Array; +} + +const getRoomState = (client: MatrixClient, roomId: string): RoomState => { + return client.getRoom(roomId) + ?.getLiveTimeline() + ?.getState?.(EventTimeline.FORWARDS); +}; + +/** + * Returns all room state events for the stable and unstable type value. + */ +const getRoomStateEvents = ( + client: MatrixClient, + roomId: string, + type: UnstableValue, +): MatrixEvent[] => { + const roomState = getRoomState(client, roomId); + if (!roomState) return []; + + return [ + ...roomState.getStateEvents(type.name), + ...roomState.getStateEvents(type.altName), + ]; +}; + +/** + * Finds the latest, non-terminated call state event. + */ +export const getGroupCall = (client: MatrixClient, roomId: string): MatrixEvent => { + return getRoomStateEvents(client, roomId, CALL_STATE_EVENT_TYPE) + .sort((a: MatrixEvent, b: MatrixEvent) => b.getTs() - a.getTs()) + .find((event: MatrixEvent) => { + return !(CALL_STATE_EVENT_TERMINATED in event.getContent()); + }); +}; + +/** + * Finds the "m.call.member" events for an "m.call" event. + * + * @returns {MatrixEvent[]} non-expired "m.call.member" events for the call + */ +export const useConnectedMembers = (client: MatrixClient, callEvent: MatrixEvent): MatrixEvent[] => { + if (!CALL_STATE_EVENT_TYPE.matches(callEvent.getType())) return []; + + const callId = callEvent.getStateKey(); + const now = Date.now(); + + return getRoomStateEvents(client, callEvent.getRoomId(), CALL_MEMBER_STATE_EVENT_TYPE) + .filter((callMemberEvent: MatrixEvent): boolean => { + const { + ["m.expires_ts"]: expiresTs, + ["m.calls"]: calls, + } = callMemberEvent.getContent(); + + // state event expired + if (expiresTs && expiresTs < now) return false; + + return !!calls?.find((call: MCall) => call["m.call_id"] === callId); + }) || []; +}; + +/** + * Removes a list of devices from a call. + * Only works for the current user's devices. + */ +const removeDevices = async (client: MatrixClient, callEvent: MatrixEvent, deviceIds: string[]): Promise => { + if (!CALL_STATE_EVENT_TYPE.matches(callEvent.getType())) return; + + const roomId = callEvent.getRoomId(); + const roomState = getRoomState(client, roomId); + if (!roomState) return; + + const callMemberEvent = roomState.getStateEvents(CALL_MEMBER_STATE_EVENT_TYPE.name, client.getUserId()) + ?? roomState.getStateEvents(CALL_MEMBER_STATE_EVENT_TYPE.altName, client.getUserId()); + const callMemberEventContent = callMemberEvent?.getContent(); + if ( + !Array.isArray(callMemberEventContent?.["m.calls"]) + || callMemberEventContent?.["m.calls"].length === 0 + ) { + return; + } + + // copy the content to prevent mutations + const newContent = deepCopy(callMemberEventContent); + const callId = callEvent.getStateKey(); + let changed = false; + + newContent["m.calls"].forEach((call: MCall) => { + // skip other calls + if (call["m.call_id"] !== callId) return; + + call["m.devices"] = call["m.devices"]?.filter((device: MDevice) => { + if (deviceIds.includes(device["m.device_id"])) { + changed = true; + return false; + } + + return true; + }); + }); + + if (changed) { + // only send a new state event if there has been a change + newContent["m.expires_ts"] = Date.now() + STUCK_DEVICE_TIMEOUT_MS; + await client.sendStateEvent( + roomId, + CALL_MEMBER_STATE_EVENT_TYPE.name, + newContent, + client.getUserId(), + ); + } +}; + +/** + * Removes the current device from a call. + */ +export const removeOurDevice = async (client: MatrixClient, callEvent: MatrixEvent) => { + return removeDevices(client, callEvent, [client.getDeviceId()]); +}; + +/** + * Removes all devices of the current user that have not been seen within the STUCK_DEVICE_TIMEOUT_MS. + * Does per default not remove the current device unless includeCurrentDevice is true. + * + * @param {boolean} includeCurrentDevice - Whether to include the current device of this session here. + */ +export const fixStuckDevices = async (client: MatrixClient, callEvent: MatrixEvent, includeCurrentDevice: boolean) => { + const now = Date.now(); + const { devices: myDevices } = await client.getDevices(); + const currentDeviceId = client.getDeviceId(); + const devicesToBeRemoved = myDevices.filter(({ last_seen_ts: lastSeenTs, device_id: deviceId }) => { + return lastSeenTs + && (deviceId !== currentDeviceId || includeCurrentDevice) + && (now - lastSeenTs) > STUCK_DEVICE_TIMEOUT_MS; + }).map(d => d.device_id); + return removeDevices(client, callEvent, devicesToBeRemoved); +}; diff --git a/src/utils/pillify.tsx b/src/utils/pillify.tsx index 87b95007e32..b7a1b4e5583 100644 --- a/src/utils/pillify.tsx +++ b/src/utils/pillify.tsx @@ -44,8 +44,8 @@ export function pillifyLinks(nodes: ArrayLike, mxEvent: MatrixEvent, pi while (node) { let pillified = false; - if (node.tagName === "PRE" || node.tagName === "CODE") { - // Skip code blocks + if (node.tagName === "PRE" || node.tagName === "CODE" || pills.includes(node)) { + // Skip code blocks and existing pills node = node.nextSibling as Element; continue; } else if (node.tagName === "A" && node.getAttribute("href")) { diff --git a/src/utils/tooltipify.tsx b/src/utils/tooltipify.tsx index 3f7042e1575..afdcf29609b 100644 --- a/src/utils/tooltipify.tsx +++ b/src/utils/tooltipify.tsx @@ -39,9 +39,7 @@ export function tooltipifyLinks(rootNodes: ArrayLike, ignoredNodes: Ele let node = rootNodes[0]; while (node) { - let tooltipified = false; - - if (ignoredNodes.indexOf(node) >= 0) { + if (ignoredNodes.includes(node) || containers.includes(node)) { node = node.nextSibling as Element; continue; } @@ -49,20 +47,18 @@ export function tooltipifyLinks(rootNodes: ArrayLike, ignoredNodes: Ele if (node.tagName === "A" && node.getAttribute("href") && node.getAttribute("href") !== node.textContent.trim() ) { - const container = document.createElement("span"); const href = node.getAttribute("href"); + // The node's innerHTML was already sanitized before being rendered in the first place, here we are just + // wrapping the link with the LinkWithTooltip component, keeping the same children. Ideally we'd do this + // without the superfluous span but this is not something React trivially supports at this time. const tooltip = - { node.innerHTML } + ; - ReactDOM.render(tooltip, container); - node.replaceChildren(container); - containers.push(container); - tooltipified = true; - } - - if (node.childNodes?.length && !tooltipified) { + ReactDOM.render(tooltip, node); + containers.push(node); + } else if (node.childNodes?.length) { tooltipifyLinks(node.childNodes as NodeListOf, ignoredNodes, containers); } diff --git a/src/widgets/CapabilityText.tsx b/src/widgets/CapabilityText.tsx index cd442f213b8..e4790eaad2a 100644 --- a/src/widgets/CapabilityText.tsx +++ b/src/widgets/CapabilityText.tsx @@ -17,6 +17,7 @@ limitations under the License. import { Capability, EventDirection, + EventKind, getTimelineRoomIDFromCapability, isTimelineCapability, isTimelineCapabilityFor, @@ -134,7 +135,7 @@ export class CapabilityText { }; private static bylineFor(eventCap: WidgetEventCapability): TranslatedString { - if (eventCap.isState) { + if (eventCap.kind === EventKind.State) { return !eventCap.keyStr ? _t("with an empty state key") : _t("with state key %(stateKey)s", { stateKey: eventCap.keyStr }); @@ -143,6 +144,8 @@ export class CapabilityText { } public static for(capability: Capability, kind: WidgetKind): TranslatedCapabilityText { + // TODO: Support MSC3819 (to-device capabilities) + // First see if we have a super simple line of text to provide back if (CapabilityText.simpleCaps[capability]) { const textForKind = CapabilityText.simpleCaps[capability]; @@ -184,13 +187,13 @@ export class CapabilityText { // Special case room messages so they show up a bit cleaner to the user. Result is // effectively "Send images" instead of "Send messages... of type images" if we were // to handle the msgtype nuances in this function. - if (!eventCap.isState && eventCap.eventType === EventType.RoomMessage) { + if (eventCap.kind === EventKind.Event && eventCap.eventType === EventType.RoomMessage) { return CapabilityText.forRoomMessageCap(eventCap, kind); } // See if we have a static line of text to provide for the given event type and // direction. The hope is that we do for common event types for friendlier copy. - const evSendRecv = eventCap.isState + const evSendRecv = eventCap.kind === EventKind.State ? CapabilityText.stateSendRecvCaps : CapabilityText.nonStateSendRecvCaps; if (evSendRecv[eventCap.eventType]) { diff --git a/test/SlashCommands-test.tsx b/test/SlashCommands-test.tsx index f2b15fb7294..09d8e3c5875 100644 --- a/test/SlashCommands-test.tsx +++ b/test/SlashCommands-test.tsx @@ -14,18 +14,53 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { MatrixClient } from 'matrix-js-sdk/src/matrix'; +import { MatrixClient, Room } from 'matrix-js-sdk/src/matrix'; +import { mocked } from 'jest-mock'; -import { getCommand } from '../src/SlashCommands'; +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 CallHandler from '../src/CallHandler'; describe('SlashCommands', () => { let client: MatrixClient; + const roomId = "!room:example.com"; + let room: Room; + const localRoomId = LOCAL_ROOM_ID_PREFIX + "test"; + let localRoom: LocalRoom; + let command: Command; + + const findCommand = (cmd: string): Command => { + return Commands.find((command: Command) => command.command === cmd); + }; + + const setCurrentRoom = (): void => { + mocked(RoomViewStore.instance.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(client.getRoom).mockImplementation((rId: string): Room => { + if (rId === localRoomId) return localRoom; + }); + }; beforeEach(() => { + jest.clearAllMocks(); + client = createTestClient(); jest.spyOn(MatrixClientPeg, 'get').mockReturnValue(client); + + room = new Room(roomId, client, client.getUserId()); + localRoom = new LocalRoom(localRoomId, client, client.getUserId()); + + jest.spyOn(RoomViewStore.instance, "getRoomId"); }); describe('/topic', () => { @@ -37,4 +72,127 @@ describe('SlashCommands', () => { expect(client.setRoomTopic).toHaveBeenCalledWith("room-id", "pizza", undefined); }); }); + + describe.each([ + ["upgraderoom"], + ["myroomnick"], + ["roomavatar"], + ["myroomavatar"], + ["topic"], + ["roomname"], + ["invite"], + ["part"], + ["remove"], + ["ban"], + ["unban"], + ["op"], + ["deop"], + ["addwidget"], + ["discardsession"], + ["whois"], + ["holdcall"], + ["unholdcall"], + ["converttodm"], + ["converttoroom"], + ])("/%s", (commandName: string) => { + beforeEach(() => { + command = findCommand(commandName); + }); + + describe("isEnabled", () => { + it("should return true for Room", () => { + setCurrentRoom(); + expect(command.isEnabled()).toBe(true); + }); + + it("should return false for LocalRoom", () => { + setCurrentLocalRoon(); + expect(command.isEnabled()).toBe(false); + }); + }); + }); + + describe("/tovirtual", () => { + beforeEach(() => { + command = findCommand("tovirtual"); + }); + + describe("isEnabled", () => { + describe("when virtual rooms are supported", () => { + beforeEach(() => { + jest.spyOn(CallHandler.instance, "getSupportsVirtualRooms").mockReturnValue(true); + }); + + it("should return true for Room", () => { + setCurrentRoom(); + expect(command.isEnabled()).toBe(true); + }); + + it("should return false for LocalRoom", () => { + setCurrentLocalRoon(); + expect(command.isEnabled()).toBe(false); + }); + }); + + describe("when virtual rooms are not supported", () => { + beforeEach(() => { + jest.spyOn(CallHandler.instance, "getSupportsVirtualRooms").mockReturnValue(false); + }); + + it("should return false for Room", () => { + setCurrentRoom(); + expect(command.isEnabled()).toBe(false); + }); + + it("should return false for LocalRoom", () => { + setCurrentLocalRoon(); + expect(command.isEnabled()).toBe(false); + }); + }); + }); + }); + + describe("/remakeolm", () => { + beforeEach(() => { + command = findCommand("remakeolm"); + }); + + describe("isEnabled", () => { + describe("when developer mode is enabled", () => { + beforeEach(() => { + jest.spyOn(SettingsStore, "getValue").mockImplementation((settingName: string) => { + if (settingName === "developerMode") return true; + }); + }); + + it("should return true for Room", () => { + setCurrentRoom(); + expect(command.isEnabled()).toBe(true); + }); + + it("should return false for LocalRoom", () => { + setCurrentLocalRoon(); + expect(command.isEnabled()).toBe(false); + }); + }); + + describe("when developer mode is not enabled", () => { + beforeEach(() => { + jest.spyOn(SettingsStore, "getValue").mockImplementation((settingName: string) => { + if (settingName === "developerMode") return false; + }); + }); + + it("should return false for Room", () => { + setCurrentRoom(); + expect(command.isEnabled()).toBe(false); + }); + + it("should return false for LocalRoom", () => { + setCurrentLocalRoon(); + expect(command.isEnabled()).toBe(false); + }); + }); + }); + }); }); diff --git a/test/accessibility/RovingTabIndex-test.tsx b/test/accessibility/RovingTabIndex-test.tsx index 708fc3c928a..9f7364658d8 100644 --- a/test/accessibility/RovingTabIndex-test.tsx +++ b/test/accessibility/RovingTabIndex-test.tsx @@ -15,8 +15,7 @@ limitations under the License. */ import * as React from "react"; -// eslint-disable-next-line deprecate/import -import { mount, ReactWrapper } from "enzyme"; +import { render } from "@testing-library/react"; import { IState, @@ -32,10 +31,10 @@ const Button = (props) => { return ; + const { getByTestId } = render(getComponent({ onClick, children })); + + act(() => { + fireEvent.click(getByTestId('device-action-button')); + }); + + // action click handler called + expect(onDeviceActionClick).toHaveBeenCalled(); + // main click handler not called + expect(onClick).not.toHaveBeenCalled(); + }); +}); diff --git a/test/components/views/settings/devices/__snapshots__/CurrentDeviceSection-test.tsx.snap b/test/components/views/settings/devices/__snapshots__/CurrentDeviceSection-test.tsx.snap new file mode 100644 index 00000000000..20c72fc9d38 --- /dev/null +++ b/test/components/views/settings/devices/__snapshots__/CurrentDeviceSection-test.tsx.snap @@ -0,0 +1,296 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` displays device details on toggle click 1`] = ` +HTMLCollection [ +
    +
    +

    + alices_device +

    +
    +
    +
    +
    +
    +

    + Unverified session +

    +

    + Verify or sign out from this session for best security and reliability. +

    +
    +
    +
    +
    +

    + Session details +

    + + + + + + + + + + +
    + Session ID + + alices_device +
    + Last activity + +
    + + + + + + + + + + + +
    + Device +
    + IP address + +
    +
    +
    , +] +`; + +exports[` handles when device is falsy 1`] = ` +
    +
    +

    + Current session +

    +
    +
    +
    +`; + +exports[` renders device and correct security card when device is unverified 1`] = ` +
    +
    +

    + Current session +

    +
    +
    +
    +

    + alices_device +

    + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +

    + Unverified session +

    +

    + Verify or sign out from this session for best security and reliability. +

    +
    +
    +
    +
    +
    +`; + +exports[` renders device and correct security card when device is verified 1`] = ` +
    +
    +

    + Current session +

    +
    +
    +
    +

    + alices_device +

    + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +

    + Unverified session +

    +

    + Verify or sign out from this session for best security and reliability. +

    +
    +
    +
    +
    +
    +`; diff --git a/test/components/views/settings/devices/__snapshots__/DeviceDetails-test.tsx.snap b/test/components/views/settings/devices/__snapshots__/DeviceDetails-test.tsx.snap new file mode 100644 index 00000000000..d83ca383150 --- /dev/null +++ b/test/components/views/settings/devices/__snapshots__/DeviceDetails-test.tsx.snap @@ -0,0 +1,320 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` renders a verified device 1`] = ` +
    +
    +
    +

    + my-device +

    +
    +
    +
    +
    +
    +

    + Verified session +

    +

    + This session is ready for secure messaging. +

    +
    +
    +
    +
    +

    + Session details +

    + + + + + + + + + + +
    + Session ID + + my-device +
    + Last activity + +
    + + + + + + + + + + + +
    + Device +
    + IP address + +
    +
    +
    +
    +`; + +exports[` renders device with metadata 1`] = ` +
    +
    +
    +

    + My Device +

    +
    +
    +
    +
    +
    +

    + Unverified session +

    +

    + Verify or sign out from this session for best security and reliability. +

    +
    +
    +
    +
    +

    + Session details +

    + + + + + + + + + + + +
    + Session ID + + my-device +
    + Last activity + + Sun 22:34 +
    + + + + + + + + + + + + +
    + Device +
    + IP address + + 123.456.789 +
    +
    +
    +
    +`; + +exports[` renders device without metadata 1`] = ` +
    +
    +
    +

    + my-device +

    +
    +
    +
    +
    +
    +

    + Unverified session +

    +

    + Verify or sign out from this session for best security and reliability. +

    +
    +
    +
    +
    +

    + Session details +

    + + + + + + + + + + +
    + Session ID + + my-device +
    + Last activity + +
    + + + + + + + + + + + +
    + Device +
    + IP address + +
    +
    +
    +
    +`; diff --git a/test/components/views/settings/devices/__snapshots__/DeviceExpandDetailsButton-test.tsx.snap b/test/components/views/settings/devices/__snapshots__/DeviceExpandDetailsButton-test.tsx.snap new file mode 100644 index 00000000000..d9c3edaba10 --- /dev/null +++ b/test/components/views/settings/devices/__snapshots__/DeviceExpandDetailsButton-test.tsx.snap @@ -0,0 +1,35 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` renders when expanded 1`] = ` +Object { + "container":
    +
    +
    +
    +
    , +} +`; + +exports[` renders when not expanded 1`] = ` +Object { + "container":
    +
    +
    +
    +
    , +} +`; diff --git a/test/components/views/settings/devices/__snapshots__/DeviceSecurityCard-test.tsx.snap b/test/components/views/settings/devices/__snapshots__/DeviceSecurityCard-test.tsx.snap new file mode 100644 index 00000000000..900c94d451d --- /dev/null +++ b/test/components/views/settings/devices/__snapshots__/DeviceSecurityCard-test.tsx.snap @@ -0,0 +1,66 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` renders basic card 1`] = ` +
    +
    +
    +
    +
    +
    +

    + Verified session +

    +

    + nice +

    +
    +
    +
    +`; + +exports[` renders with children 1`] = ` +
    +
    +
    +
    +
    +
    +

    + Verified session +

    +

    + nice +

    +
    + hey +
    +
    +
    +
    +`; diff --git a/test/components/views/settings/devices/__snapshots__/DeviceTile-test.tsx.snap b/test/components/views/settings/devices/__snapshots__/DeviceTile-test.tsx.snap index 299d72348c8..cafd47a8a74 100644 --- a/test/components/views/settings/devices/__snapshots__/DeviceTile-test.tsx.snap +++ b/test/components/views/settings/devices/__snapshots__/DeviceTile-test.tsx.snap @@ -4,6 +4,7 @@ exports[` renders a device with no metadata 1`] = `
    renders a device with no metadata 1`] = ` +
    +
    +
    +
    +`; + +exports[` renders a verified device with no metadata 1`] = ` +
    +
    +
    +

    + 123 +

    +
    @@ -30,6 +70,7 @@ exports[` renders display name with a tooltip 1`] = `
    renders display name with a tooltip 1`] = `
    @@ -60,6 +107,7 @@ exports[` separates metadata with a dot 1`] = `
    separates metadata with a dot 1`] = `