diff --git a/.github/workflows/cypress.yaml b/.github/workflows/cypress.yaml index a76c00918b..57e6a7837e 100644 --- a/.github/workflows/cypress.yaml +++ b/.github/workflows/cypress.yaml @@ -155,17 +155,6 @@ jobs: cypress/videos cypress/synapselogs - - run: mv cypress/performance/*.json cypress/performance/measurements-${{ strategy.job-index }}.json - continue-on-error: true - - - name: Upload Benchmark - uses: actions/upload-artifact@v2 - with: - name: cypress-benchmark - path: cypress/performance/* - if-no-files-found: ignore - retention-days: 1 - report: name: Report results needs: tests @@ -181,36 +170,3 @@ jobs: context: ${{ github.workflow }} / cypress (${{ github.event.workflow_run.event }} => ${{ github.event_name }}) sha: ${{ github.event.workflow_run.head_sha }} target_url: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} - - store-benchmark: - needs: tests - runs-on: ubuntu-latest - if: | - github.event.workflow_run.event != 'pull_request' && - github.event.workflow_run.head_branch == 'develop' && - github.event.workflow_run.head_repository.full_name == github.repository - permissions: - contents: write - steps: - - uses: actions/checkout@v2 - - - name: Download benchmark result - uses: actions/download-artifact@v3 - with: - name: cypress-benchmark - - - name: Merge measurements - run: jq -s add measurements-*.json > measurements.json - - - name: Store benchmark result - uses: matrix-org/github-action-benchmark@jsperfentry-6 - with: - name: Cypress measurements - tool: 'jsperformanceentry' - output-file-path: measurements.json - # The dashboard is available at https://matrix-org.github.io/matrix-react-sdk/cypress/bench/ - benchmark-data-dir-path: cypress/bench - fail-on-alert: false - comment-on-alert: false - github-token: ${{ secrets.GITHUB_TOKEN }} - auto-push: ${{ github.event.workflow_run.event != 'pull_request' }} diff --git a/.node-version b/.node-version index 8351c19397..b6a7d89c68 100644 --- a/.node-version +++ b/.node-version @@ -1 +1 @@ -14 +16 diff --git a/cypress/e2e/composer/composer.spec.ts b/cypress/e2e/composer/composer.spec.ts new file mode 100644 index 0000000000..f3fc374cf0 --- /dev/null +++ b/cypress/e2e/composer/composer.spec.ts @@ -0,0 +1,140 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/// + +import { SynapseInstance } from "../../plugins/synapsedocker"; +import { SettingLevel } from "../../../src/settings/SettingLevel"; + +describe("Composer", () => { + let synapse: SynapseInstance; + + beforeEach(() => { + cy.startSynapse("default").then(data => { + synapse = data; + }); + }); + + afterEach(() => { + cy.stopSynapse(synapse); + }); + + describe("CIDER", () => { + beforeEach(() => { + cy.initTestUser(synapse, "Janet").then(() => { + cy.createRoom({ name: "Composing Room" }); + }); + cy.viewRoomByName("Composing Room"); + }); + + it("sends a message when you click send or press Enter", () => { + // Type a message + cy.get('div[contenteditable=true]').type('my message 0'); + // It has not been sent yet + cy.contains('.mx_EventTile_body', 'my message 0').should('not.exist'); + + // Click send + cy.get('div[aria-label="Send message"]').click(); + // It has been sent + cy.contains('.mx_EventTile_body', 'my message 0'); + + // Type another and press Enter afterwards + cy.get('div[contenteditable=true]').type('my message 1{enter}'); + // It was sent + cy.contains('.mx_EventTile_body', 'my message 1'); + }); + + it("can write formatted text", () => { + cy.get('div[contenteditable=true]').type('my bold{ctrl+b} message'); + cy.get('div[aria-label="Send message"]').click(); + // Note: both "bold" and "message" are bold, which is probably surprising + cy.contains('.mx_EventTile_body strong', 'bold message'); + }); + + describe("when Ctrl+Enter is required to send", () => { + beforeEach(() => { + cy.setSettingValue("MessageComposerInput.ctrlEnterToSend", null, SettingLevel.ACCOUNT, true); + }); + + it("only sends when you press Ctrl+Enter", () => { + // Type a message and press Enter + cy.get('div[contenteditable=true]').type('my message 3{enter}'); + // It has not been sent yet + cy.contains('.mx_EventTile_body', 'my message 3').should('not.exist'); + + // Press Ctrl+Enter + cy.get('div[contenteditable=true]').type('{ctrl+enter}'); + // It was sent + cy.contains('.mx_EventTile_body', 'my message 3'); + }); + }); + }); + + describe("WYSIWYG", () => { + beforeEach(() => { + cy.enableLabsFeature("feature_wysiwyg_composer"); + cy.initTestUser(synapse, "Janet").then(() => { + cy.createRoom({ name: "Composing Room" }); + }); + cy.viewRoomByName("Composing Room"); + }); + + it("sends a message when you click send or press Enter", () => { + // Type a message + cy.get('div[contenteditable=true]').type('my message 0'); + // It has not been sent yet + cy.contains('.mx_EventTile_body', 'my message 0').should('not.exist'); + + // Click send + cy.get('div[aria-label="Send message"]').click(); + // It has been sent + cy.contains('.mx_EventTile_body', 'my message 0'); + + // Type another + cy.get('div[contenteditable=true]').type('my message 1'); + // Press enter. Would be nice to just use {enter} but we can't because Cypress + // does not trigger an insertParagraph when you do that. + cy.get('div[contenteditable=true]').trigger('input', { inputType: "insertParagraph" }); + // It was sent + cy.contains('.mx_EventTile_body', 'my message 1'); + }); + + it("can write formatted text", () => { + cy.get('div[contenteditable=true]').type('my {ctrl+b}bold{ctrl+b} message'); + cy.get('div[aria-label="Send message"]').click(); + cy.contains('.mx_EventTile_body strong', 'bold'); + }); + + describe("when Ctrl+Enter is required to send", () => { + beforeEach(() => { + cy.setSettingValue("MessageComposerInput.ctrlEnterToSend", null, SettingLevel.ACCOUNT, true); + }); + + it("only sends when you press Ctrl+Enter", () => { + // Type a message and press Enter + cy.get('div[contenteditable=true]').type('my message 3'); + cy.get('div[contenteditable=true]').trigger('input', { inputType: "insertParagraph" }); + // It has not been sent yet + cy.contains('.mx_EventTile_body', 'my message 3').should('not.exist'); + + // Press Ctrl+Enter + cy.get('div[contenteditable=true]').type('{ctrl+enter}'); + // It was sent + cy.contains('.mx_EventTile_body', 'my message 3'); + }); + }); + }); +}); diff --git a/cypress/e2e/create-room/create-room.spec.ts b/cypress/e2e/create-room/create-room.spec.ts index 9bf38194d9..1217c917b6 100644 --- a/cypress/e2e/create-room/create-room.spec.ts +++ b/cypress/e2e/create-room/create-room.spec.ts @@ -54,13 +54,11 @@ describe("Create Room", () => { // Fill room address cy.get('[label="Room address"]').type("test-room-1"); // Submit - cy.startMeasuring("from-submit-to-room"); cy.get(".mx_Dialog_primary").click(); }); cy.url().should("contain", "/#/room/#test-room-1:localhost"); - cy.stopMeasuring("from-submit-to-room"); - cy.get(".mx_RoomHeader_nametext").contains(name); - cy.get(".mx_RoomHeader_topic").contains(topic); + cy.contains(".mx_RoomHeader_nametext", name); + cy.contains(".mx_RoomHeader_topic", topic); }); }); diff --git a/cypress/e2e/editing/editing.spec.ts b/cypress/e2e/editing/editing.spec.ts index 49e4ae79b3..f08466ab30 100644 --- a/cypress/e2e/editing/editing.spec.ts +++ b/cypress/e2e/editing/editing.spec.ts @@ -62,7 +62,7 @@ describe("Editing", () => { cy.get(".mx_BasicMessageComposer_input").type("Foo{backspace}{backspace}{backspace}{enter}"); cy.checkA11y(); }); - cy.get(".mx_RoomView_body .mx_EventTile").contains(".mx_EventTile[data-scroll-tokens]", "Message"); + cy.contains(".mx_RoomView_body .mx_EventTile[data-scroll-tokens]", "Message"); // Assert that the edit composer has gone away cy.get(".mx_EditMessageComposer").should("not.exist"); diff --git a/cypress/e2e/login/consent.spec.ts b/cypress/e2e/login/consent.spec.ts index a4cd31bd26..c6af9eab22 100644 --- a/cypress/e2e/login/consent.spec.ts +++ b/cypress/e2e/login/consent.spec.ts @@ -46,7 +46,7 @@ describe("Consent", () => { // Accept terms & conditions cy.get(".mx_QuestionDialog").within(() => { - cy.get("#mx_BaseDialog_title").contains("Terms and Conditions"); + cy.contains("#mx_BaseDialog_title", "Terms and Conditions"); cy.get(".mx_Dialog_primary").click(); }); @@ -58,7 +58,7 @@ describe("Consent", () => { cy.visit(url); cy.get('[type="submit"]').click(); - cy.get("p").contains("Danke schon"); + cy.contains("p", "Danke schon"); }); }); diff --git a/cypress/e2e/login/login.spec.ts b/cypress/e2e/login/login.spec.ts index 2ba2e33f9b..1058287010 100644 --- a/cypress/e2e/login/login.spec.ts +++ b/cypress/e2e/login/login.spec.ts @@ -21,13 +21,6 @@ import { SynapseInstance } from "../../plugins/synapsedocker"; describe("Login", () => { let synapse: SynapseInstance; - beforeEach(() => { - cy.visit("/#/login"); - cy.startSynapse("consent").then(data => { - synapse = data; - }); - }); - afterEach(() => { cy.stopSynapse(synapse); }); @@ -37,7 +30,11 @@ describe("Login", () => { const password = "p4s5W0rD"; beforeEach(() => { - cy.registerUser(synapse, username, password); + cy.startSynapse("consent").then(data => { + synapse = data; + cy.registerUser(synapse, username, password); + cy.visit("/#/login"); + }); }); it("logs in with an existing account and lands on the home screen", () => { @@ -55,24 +52,25 @@ describe("Login", () => { cy.get("#mx_LoginForm_username").type(username); cy.get("#mx_LoginForm_password").type(password); - cy.startMeasuring("from-submit-to-home"); cy.get(".mx_Login_submit").click(); cy.url().should('contain', '/#/home', { timeout: 30000 }); - cy.stopMeasuring("from-submit-to-home"); }); }); describe("logout", () => { beforeEach(() => { - cy.initTestUser(synapse, "Erin"); + cy.startSynapse("consent").then(data => { + synapse = data; + cy.initTestUser(synapse, "Erin"); + }); }); it("should go to login page on logout", () => { cy.get('[aria-label="User menu"]').click(); // give a change for the outstanding requests queue to settle before logging out - cy.wait(500); + cy.wait(2000); cy.get(".mx_UserMenu_contextMenu").within(() => { cy.get(".mx_UserMenu_iconSignOut").click(); @@ -94,7 +92,7 @@ describe("Login", () => { cy.get('[aria-label="User menu"]').click(); // give a change for the outstanding requests queue to settle before logging out - cy.wait(500); + cy.wait(2000); cy.get(".mx_UserMenu_contextMenu").within(() => { cy.get(".mx_UserMenu_iconSignOut").click(); diff --git a/cypress/e2e/polls/polls.spec.ts b/cypress/e2e/polls/polls.spec.ts index 470c69d8cf..00b944ce9d 100644 --- a/cypress/e2e/polls/polls.spec.ts +++ b/cypress/e2e/polls/polls.spec.ts @@ -94,7 +94,7 @@ describe("Polls", () => { cy.stopSynapse(synapse); }); - it("Open polls can be created and voted in", () => { + it("should be creatable and votable", () => { let bot: MatrixClient; cy.getBot(synapse, { displayName: "BotBob" }).then(_bot => { bot = _bot; @@ -122,7 +122,7 @@ describe("Polls", () => { createPoll(pollParams); // Wait for message to send, get its ID and save as @pollId - cy.get(".mx_RoomView_body .mx_EventTile").contains(".mx_EventTile[data-scroll-tokens]", pollParams.title) + cy.contains(".mx_RoomView_body .mx_EventTile[data-scroll-tokens]", pollParams.title) .invoke("attr", "data-scroll-tokens").as("pollId"); cy.get("@pollId").then(pollId => { @@ -159,7 +159,95 @@ describe("Polls", () => { }); }); - it("displays polls correctly in thread panel", () => { + it("should be editable from context menu if no votes have been cast", () => { + let bot: MatrixClient; + cy.getBot(synapse, { displayName: "BotBob" }).then(_bot => { + bot = _bot; + }); + + let roomId: string; + cy.createRoom({}).then(_roomId => { + roomId = _roomId; + cy.inviteUser(roomId, bot.getUserId()); + cy.visit('/#/room/' + roomId); + }); + + cy.openMessageComposerOptions().within(() => { + cy.get('[aria-label="Poll"]').click(); + }); + + const pollParams = { + title: 'Does the polls feature work?', + options: ['Yes', 'No', 'Maybe'], + }; + createPoll(pollParams); + + // Wait for message to send, get its ID and save as @pollId + cy.get(".mx_RoomView_body .mx_EventTile").contains(".mx_EventTile[data-scroll-tokens]", pollParams.title) + .invoke("attr", "data-scroll-tokens").as("pollId"); + + cy.get("@pollId").then(pollId => { + // Open context menu + getPollTile(pollId).rightclick(); + + // Select edit item + cy.get('.mx_ContextualMenu').within(() => { + cy.get('[aria-label="Edit"]').click(); + }); + + // Expect poll editing dialog + cy.get('.mx_PollCreateDialog'); + }); + }); + + it("should not be editable from context menu if votes have been cast", () => { + let bot: MatrixClient; + cy.getBot(synapse, { displayName: "BotBob" }).then(_bot => { + bot = _bot; + }); + + let roomId: string; + cy.createRoom({}).then(_roomId => { + roomId = _roomId; + cy.inviteUser(roomId, bot.getUserId()); + cy.visit('/#/room/' + roomId); + }); + + cy.openMessageComposerOptions().within(() => { + cy.get('[aria-label="Poll"]').click(); + }); + + const pollParams = { + title: 'Does the polls feature work?', + options: ['Yes', 'No', 'Maybe'], + }; + createPoll(pollParams); + + // Wait for message to send, get its ID and save as @pollId + cy.get(".mx_RoomView_body .mx_EventTile").contains(".mx_EventTile[data-scroll-tokens]", pollParams.title) + .invoke("attr", "data-scroll-tokens").as("pollId"); + + cy.get("@pollId").then(pollId => { + // Bot votes 'Maybe' in the poll + botVoteForOption(bot, roomId, pollId, pollParams.options[2]); + + // wait for bot's vote to arrive + cy.get('.mx_MPollBody_totalVotes').should('contain', '1 vote cast'); + + // Open context menu + getPollTile(pollId).rightclick(); + + // Select edit item + cy.get('.mx_ContextualMenu').within(() => { + cy.get('[aria-label="Edit"]').click(); + }); + + // Expect error dialog + cy.get('.mx_ErrorDialog'); + }); + }); + + it("should be displayed correctly in thread panel", () => { let botBob: MatrixClient; let botCharlie: MatrixClient; cy.getBot(synapse, { displayName: "BotBob" }).then(_bot => { @@ -190,7 +278,7 @@ describe("Polls", () => { createPoll(pollParams); // Wait for message to send, get its ID and save as @pollId - cy.get(".mx_RoomView_body .mx_EventTile").contains(".mx_EventTile[data-scroll-tokens]", pollParams.title) + cy.contains(".mx_RoomView_body .mx_EventTile[data-scroll-tokens]", pollParams.title) .invoke("attr", "data-scroll-tokens").as("pollId"); cy.get("@pollId").then(pollId => { diff --git a/cypress/e2e/register/register.spec.ts b/cypress/e2e/register/register.spec.ts index 1945eb7fec..98ef2bd729 100644 --- a/cypress/e2e/register/register.spec.ts +++ b/cypress/e2e/register/register.spec.ts @@ -55,7 +55,6 @@ describe("Registration", () => { cy.get("#mx_RegistrationForm_username").type("alice"); cy.get("#mx_RegistrationForm_password").type("totally a great password"); cy.get("#mx_RegistrationForm_passwordConfirm").type("totally a great password"); - cy.startMeasuring("create-account"); cy.get(".mx_Login_submit").click(); cy.get(".mx_RegistrationEmailPromptDialog").should("be.visible"); @@ -63,13 +62,11 @@ describe("Registration", () => { cy.checkA11y(); cy.get(".mx_RegistrationEmailPromptDialog button.mx_Dialog_primary").click(); - cy.stopMeasuring("create-account"); cy.get(".mx_InteractiveAuthEntryComponents_termsPolicy").should("be.visible"); cy.percySnapshot("Registration terms prompt", { percyCSS }); cy.checkA11y(); cy.get(".mx_InteractiveAuthEntryComponents_termsPolicy input").click(); - cy.startMeasuring("from-submit-to-home"); cy.get(".mx_InteractiveAuthEntryComponents_termsSubmit").click(); cy.get(".mx_UseCaseSelection_skip", { timeout: 30000 }).should("exist"); @@ -78,7 +75,6 @@ describe("Registration", () => { cy.get(".mx_UseCaseSelection_skip .mx_AccessibleButton").click(); cy.url().should('contain', '/#/home'); - cy.stopMeasuring("from-submit-to-home"); cy.get('[aria-label="User menu"]').click(); cy.get('[aria-label="Security & Privacy"]').click(); diff --git a/cypress/e2e/room-directory/room-directory.spec.ts b/cypress/e2e/room-directory/room-directory.spec.ts index 18464e2071..f179b0988c 100644 --- a/cypress/e2e/room-directory/room-directory.spec.ts +++ b/cypress/e2e/room-directory/room-directory.spec.ts @@ -93,7 +93,7 @@ describe("Room Directory", () => { cy.get(".mx_RoomDirectory_dialogWrapper").percySnapshotElement("Room Directory - filtered no results"); cy.get('.mx_RoomDirectory_dialogWrapper [name="dirsearch"]').type("{selectAll}{backspace}test1234"); - cy.get(".mx_RoomDirectory_dialogWrapper").contains(".mx_RoomDirectory_listItem", name) + cy.contains(".mx_RoomDirectory_dialogWrapper .mx_RoomDirectory_listItem", name) .should("exist").as("resultRow"); cy.get(".mx_RoomDirectory_dialogWrapper").percySnapshotElement("Room Directory - filtered one result"); cy.get("@resultRow").find(".mx_AccessibleButton").contains("Join").click(); diff --git a/cypress/e2e/sliding-sync/sliding-sync.ts b/cypress/e2e/sliding-sync/sliding-sync.ts index cfd4fd4185..e0e7c974a7 100644 --- a/cypress/e2e/sliding-sync/sliding-sync.ts +++ b/cypress/e2e/sliding-sync/sliding-sync.ts @@ -293,7 +293,7 @@ describe("Sliding Sync", () => { ]); cy.contains(".mx_RoomTile", "Reject").click(); - cy.get(".mx_RoomView").contains(".mx_AccessibleButton", "Reject").click(); + cy.contains(".mx_RoomView .mx_AccessibleButton", "Reject").click(); // wait for the rejected room to disappear cy.get(".mx_RoomTile").should('have.length', 3); @@ -328,8 +328,8 @@ describe("Sliding Sync", () => { cy.getClient().then(cli => cli.setRoomTag(roomId, "m.favourite", { order: 0.5 })); }); - cy.get('.mx_RoomSublist[aria-label="Favourites"]').contains(".mx_RoomTile", "Favourite DM").should("exist"); - cy.get('.mx_RoomSublist[aria-label="People"]').contains(".mx_RoomTile", "Favourite DM").should("not.exist"); + cy.contains('.mx_RoomSublist[aria-label="Favourites"] .mx_RoomTile', "Favourite DM").should("exist"); + cy.contains('.mx_RoomSublist[aria-label="People"] .mx_RoomTile', "Favourite DM").should("not.exist"); }); // Regression test for a bug in SS mode, but would be useful to have in non-SS mode too. diff --git a/cypress/e2e/spaces/spaces.spec.ts b/cypress/e2e/spaces/spaces.spec.ts index e7767de942..893f48239b 100644 --- a/cypress/e2e/spaces/spaces.spec.ts +++ b/cypress/e2e/spaces/spaces.spec.ts @@ -83,26 +83,26 @@ describe("Spaces", () => { cy.get('input[label="Name"]').type("Let's have a Riot"); cy.get('input[label="Address"]').should("have.value", "lets-have-a-riot"); cy.get('textarea[label="Description"]').type("This is a space to reminisce Riot.im!"); - cy.get(".mx_AccessibleButton").contains("Create").click(); + cy.contains(".mx_AccessibleButton", "Create").click(); }); // Create the default General & Random rooms, as well as a custom "Jokes" room cy.get('input[label="Room name"][value="General"]').should("exist"); cy.get('input[label="Room name"][value="Random"]').should("exist"); cy.get('input[placeholder="Support"]').type("Jokes"); - cy.get(".mx_AccessibleButton").contains("Continue").click(); + cy.contains(".mx_AccessibleButton", "Continue").click(); // Copy matrix.to link cy.get(".mx_SpacePublicShare_shareButton").focus().realClick(); cy.getClipboardText().should("eq", "https://matrix.to/#/#lets-have-a-riot:localhost"); // Go to space home - cy.get(".mx_AccessibleButton").contains("Go to my first room").click(); + cy.contains(".mx_AccessibleButton", "Go to my first room").click(); // Assert rooms exist in the room list - cy.get(".mx_RoomList").contains(".mx_RoomTile", "General").should("exist"); - cy.get(".mx_RoomList").contains(".mx_RoomTile", "Random").should("exist"); - cy.get(".mx_RoomList").contains(".mx_RoomTile", "Jokes").should("exist"); + cy.contains(".mx_RoomList .mx_RoomTile", "General").should("exist"); + cy.contains(".mx_RoomList .mx_RoomTile", "Random").should("exist"); + cy.contains(".mx_RoomList .mx_RoomTile", "Jokes").should("exist"); }); it("should allow user to create private space", () => { @@ -113,7 +113,7 @@ describe("Spaces", () => { cy.get('input[label="Name"]').type("This is not a Riot"); cy.get('input[label="Address"]').should("not.exist"); cy.get('textarea[label="Description"]').type("This is a private space of mourning Riot.im..."); - cy.get(".mx_AccessibleButton").contains("Create").click(); + cy.contains(".mx_AccessibleButton", "Create").click(); }); cy.get(".mx_SpaceRoomView_privateScope_meAndMyTeammatesButton").click(); @@ -122,20 +122,20 @@ describe("Spaces", () => { cy.get('input[label="Room name"][value="General"]').should("exist"); cy.get('input[label="Room name"][value="Random"]').should("exist"); cy.get('input[placeholder="Support"]').type("Projects"); - cy.get(".mx_AccessibleButton").contains("Continue").click(); + cy.contains(".mx_AccessibleButton", "Continue").click(); cy.get(".mx_SpaceRoomView").should("contain", "Invite your teammates"); - cy.get(".mx_AccessibleButton").contains("Skip for now").click(); + cy.contains(".mx_AccessibleButton", "Skip for now").click(); // Assert rooms exist in the room list - cy.get(".mx_RoomList").contains(".mx_RoomTile", "General").should("exist"); - cy.get(".mx_RoomList").contains(".mx_RoomTile", "Random").should("exist"); - cy.get(".mx_RoomList").contains(".mx_RoomTile", "Projects").should("exist"); + cy.contains(".mx_RoomList .mx_RoomTile", "General").should("exist"); + cy.contains(".mx_RoomList .mx_RoomTile", "Random").should("exist"); + cy.contains(".mx_RoomList .mx_RoomTile", "Projects").should("exist"); // Assert rooms exist in the space explorer - cy.get(".mx_SpaceHierarchy_list").contains(".mx_SpaceHierarchy_roomTile", "General").should("exist"); - cy.get(".mx_SpaceHierarchy_list").contains(".mx_SpaceHierarchy_roomTile", "Random").should("exist"); - cy.get(".mx_SpaceHierarchy_list").contains(".mx_SpaceHierarchy_roomTile", "Projects").should("exist"); + cy.contains(".mx_SpaceHierarchy_list .mx_SpaceHierarchy_roomTile", "General").should("exist"); + cy.contains(".mx_SpaceHierarchy_list .mx_SpaceHierarchy_roomTile", "Random").should("exist"); + cy.contains(".mx_SpaceHierarchy_list .mx_SpaceHierarchy_roomTile", "Projects").should("exist"); }); it("should allow user to create just-me space", () => { @@ -155,10 +155,10 @@ describe("Spaces", () => { cy.get(".mx_SpaceRoomView_privateScope_justMeButton").click(); cy.get(".mx_AddExistingToSpace_entry").click(); - cy.get(".mx_AccessibleButton").contains("Add").click(); + cy.contains(".mx_AccessibleButton", "Add").click(); - cy.get(".mx_RoomList").contains(".mx_RoomTile", "Sample Room").should("exist"); - cy.get(".mx_SpaceHierarchy_list").contains(".mx_SpaceHierarchy_roomTile", "Sample Room").should("exist"); + cy.contains(".mx_RoomList .mx_RoomTile", "Sample Room").should("exist"); + cy.contains(".mx_SpaceHierarchy_list .mx_SpaceHierarchy_roomTile", "Sample Room").should("exist"); }); it("should allow user to invite another to a space", () => { @@ -186,7 +186,7 @@ describe("Spaces", () => { cy.get(".mx_InviteDialog_other").within(() => { cy.get('input[type="text"]').type(bot.getUserId()); - cy.get(".mx_AccessibleButton").contains("Invite").click(); + cy.contains(".mx_AccessibleButton", "Invite").click(); }); cy.get(".mx_InviteDialog_other").should("not.exist"); diff --git a/cypress/e2e/threads/threads.spec.ts b/cypress/e2e/threads/threads.spec.ts index 5af2d07d79..6aea5815e5 100644 --- a/cypress/e2e/threads/threads.spec.ts +++ b/cypress/e2e/threads/threads.spec.ts @@ -53,6 +53,7 @@ describe("Threads", () => { cy.window().should("have.prop", "beforeReload", true); cy.leaveBeta("Threads"); + cy.wait(1000); // after reload the property should be gone cy.window().should("not.have.prop", "beforeReload"); }); @@ -66,6 +67,7 @@ describe("Threads", () => { cy.window().should("have.prop", "beforeReload", true); cy.joinBeta("Threads"); + cy.wait(1000); // after reload the property should be gone cy.window().should("not.have.prop", "beforeReload"); }); @@ -92,7 +94,7 @@ describe("Threads", () => { cy.get(".mx_RoomView_body .mx_BasicMessageComposer_input").type("Hello Mr. Bot{enter}"); // Wait for message to send, get its ID and save as @threadId - cy.get(".mx_RoomView_body .mx_EventTile").contains(".mx_EventTile[data-scroll-tokens]", "Hello Mr. Bot") + cy.contains(".mx_RoomView_body .mx_EventTile[data-scroll-tokens]", "Hello Mr. Bot") .invoke("attr", "data-scroll-tokens").as("threadId"); // Bot starts thread @@ -116,21 +118,21 @@ describe("Threads", () => { cy.get(".mx_RoomView_body .mx_ThreadSummary .mx_ThreadSummary_content").should("contain", "Test"); // User reacts to message instead - cy.get(".mx_ThreadView .mx_EventTile").contains(".mx_EventTile_line", "Hello there") + cy.contains(".mx_ThreadView .mx_EventTile .mx_EventTile_line", "Hello there") .find('[aria-label="React"]').click({ force: true }); // Cypress has no ability to hover cy.get(".mx_EmojiPicker").within(() => { cy.get('input[type="text"]').type("wave"); - cy.get('[role="menuitem"]').contains("👋").click(); + cy.contains('[role="menuitem"]', "👋").click(); }); // User redacts their prior response - cy.get(".mx_ThreadView .mx_EventTile").contains(".mx_EventTile_line", "Test") + cy.contains(".mx_ThreadView .mx_EventTile .mx_EventTile_line", "Test") .find('[aria-label="Options"]').click({ force: true }); // Cypress has no ability to hover cy.get(".mx_IconizedContextMenu").within(() => { - cy.get('[role="menuitem"]').contains("Remove").click(); + cy.contains('[role="menuitem"]', "Remove").click(); }); cy.get(".mx_TextInputDialog").within(() => { - cy.get(".mx_Dialog_primary").contains("Remove").click(); + cy.contains(".mx_Dialog_primary", "Remove").click(); }); // User asserts summary was updated correctly @@ -171,7 +173,7 @@ describe("Threads", () => { cy.get(".mx_RoomView_body .mx_ThreadSummary .mx_ThreadSummary_content").should("contain", "Great!"); // User edits & asserts - cy.get(".mx_ThreadView .mx_EventTile_last").contains(".mx_EventTile_line", "Great!").within(() => { + cy.contains(".mx_ThreadView .mx_EventTile_last .mx_EventTile_line", "Great!").within(() => { cy.get('[aria-label="Edit"]').click({ force: true }); // Cypress has no ability to hover cy.get(".mx_BasicMessageComposer_input").type(" How about yourself?{enter}"); }); @@ -234,7 +236,7 @@ describe("Threads", () => { cy.get(".mx_RoomView_body .mx_BasicMessageComposer_input").type("Hello Mr. Bot{enter}"); // Create thread - cy.get(".mx_RoomView_body .mx_EventTile").contains(".mx_EventTile[data-scroll-tokens]", "Hello Mr. Bot") + cy.contains(".mx_RoomView_body .mx_EventTile[data-scroll-tokens]", "Hello Mr. Bot") .realHover().find(".mx_MessageActionBar_threadButton").click(); cy.get(".mx_ThreadView_timelinePanelWrapper").should("have.length", 1); @@ -256,7 +258,7 @@ describe("Threads", () => { cy.get(".mx_RoomView_body .mx_BasicMessageComposer_input").type("Hello Mr. Bot{enter}"); // Create thread - cy.get(".mx_RoomView_body .mx_EventTile").contains(".mx_EventTile[data-scroll-tokens]", "Hello Mr. Bot") + cy.contains(".mx_RoomView_body .mx_EventTile[data-scroll-tokens]", "Hello Mr. Bot") .realHover().find(".mx_MessageActionBar_threadButton").click(); cy.get(".mx_ThreadView_timelinePanelWrapper").should("have.length", 1); @@ -268,7 +270,7 @@ describe("Threads", () => { cy.get(".mx_BaseCard_close").click(); // Open existing thread - cy.get(".mx_RoomView_body .mx_EventTile").contains(".mx_EventTile[data-scroll-tokens]", "Hello Mr. Bot") + cy.contains(".mx_RoomView_body .mx_EventTile[data-scroll-tokens]", "Hello Mr. Bot") .realHover().find(".mx_MessageActionBar_threadButton").click(); cy.get(".mx_ThreadView_timelinePanelWrapper").should("have.length", 1); cy.get(".mx_BaseCard .mx_EventTile").should("contain", "Hello Mr. Bot"); diff --git a/cypress/e2e/timeline/timeline.spec.ts b/cypress/e2e/timeline/timeline.spec.ts index 6cebbfd181..68e0300ce3 100644 --- a/cypress/e2e/timeline/timeline.spec.ts +++ b/cypress/e2e/timeline/timeline.spec.ts @@ -329,7 +329,7 @@ describe("Timeline", () => { cy.getComposer().type(`${MESSAGE}{enter}`); // Reply to the message - cy.get(".mx_RoomView_body").contains(".mx_EventTile_line", "Hello world").within(() => { + cy.contains(".mx_RoomView_body .mx_EventTile_line", "Hello world").within(() => { cy.get('[aria-label="Reply"]').click({ force: true }); // Cypress has no ability to hover }); }; diff --git a/cypress/e2e/toasts/analytics-toast.ts b/cypress/e2e/toasts/analytics-toast.ts index 547e46bf68..518a544a1c 100644 --- a/cypress/e2e/toasts/analytics-toast.ts +++ b/cypress/e2e/toasts/analytics-toast.ts @@ -24,7 +24,7 @@ function assertNoToasts(): void { } function getToast(expectedTitle: string): Chainable { - return cy.get(".mx_Toast_toast").contains("h2", expectedTitle).should("exist").closest(".mx_Toast_toast"); + return cy.contains(".mx_Toast_toast h2", expectedTitle).should("exist").closest(".mx_Toast_toast"); } function acceptToast(expectedTitle: string): void { diff --git a/cypress/plugins/index.ts b/cypress/plugins/index.ts index 09b2bdb53b..ce154ee0bc 100644 --- a/cypress/plugins/index.ts +++ b/cypress/plugins/index.ts @@ -18,7 +18,6 @@ limitations under the License. import PluginEvents = Cypress.PluginEvents; import PluginConfigOptions = Cypress.PluginConfigOptions; -import { performance } from "./performance"; import { synapseDocker } from "./synapsedocker"; import { slidingSyncProxyDocker } from "./sliding-sync"; import { webserver } from "./webserver"; @@ -30,7 +29,6 @@ import { log } from "./log"; */ export default function(on: PluginEvents, config: PluginConfigOptions) { docker(on, config); - performance(on, config); synapseDocker(on, config); slidingSyncProxyDocker(on, config); webserver(on, config); diff --git a/cypress/plugins/performance.ts b/cypress/plugins/performance.ts deleted file mode 100644 index c6bd3e4ce9..0000000000 --- a/cypress/plugins/performance.ts +++ /dev/null @@ -1,47 +0,0 @@ -/* -Copyright 2022 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -/// - -import * as path from "path"; -import * as fse from "fs-extra"; - -import PluginEvents = Cypress.PluginEvents; -import PluginConfigOptions = Cypress.PluginConfigOptions; - -// This holds all the performance measurements throughout the run -let bufferedMeasurements: PerformanceEntry[] = []; - -function addMeasurements(measurements: PerformanceEntry[]): void { - bufferedMeasurements = bufferedMeasurements.concat(measurements); - return null; -} - -async function writeMeasurementsFile() { - try { - const measurementsPath = path.join("cypress", "performance", "measurements.json"); - await fse.outputJSON(measurementsPath, bufferedMeasurements, { - spaces: 4, - }); - } finally { - bufferedMeasurements = []; - } -} - -export function performance(on: PluginEvents, config: PluginConfigOptions) { - on("task", { addMeasurements }); - on("after:run", writeMeasurementsFile); -} diff --git a/cypress/support/e2e.ts b/cypress/support/e2e.ts index 899d41c5b8..4470c2192e 100644 --- a/cypress/support/e2e.ts +++ b/cypress/support/e2e.ts @@ -19,7 +19,6 @@ limitations under the License. import "@percy/cypress"; import "cypress-real-events"; -import "./performance"; import "./synapse"; import "./login"; import "./labs"; diff --git a/cypress/support/login.ts b/cypress/support/login.ts index e44be78123..6c44158941 100644 --- a/cypress/support/login.ts +++ b/cypress/support/login.ts @@ -91,7 +91,7 @@ Cypress.Commands.add("loginUser", (synapse: SynapseInstance, username: string, p Cypress.Commands.add("initTestUser", (synapse: SynapseInstance, displayName: string, prelaunchFn?: () => void): Chainable => { // XXX: work around Cypress not clearing IDB between tests cy.window({ log: false }).then(win => { - win.indexedDB.databases().then(databases => { + win.indexedDB.databases()?.then(databases => { databases.forEach(database => { win.indexedDB.deleteDatabase(database.name); }); diff --git a/cypress/support/performance.ts b/cypress/support/performance.ts deleted file mode 100644 index bbd1fe217d..0000000000 --- a/cypress/support/performance.ts +++ /dev/null @@ -1,74 +0,0 @@ -/* -Copyright 2022 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -/// - -import Chainable = Cypress.Chainable; -import AUTWindow = Cypress.AUTWindow; - -declare global { - // eslint-disable-next-line @typescript-eslint/no-namespace - namespace Cypress { - interface Chainable { - /** - * Start measuring the duration of some task. - * @param task The task name. - */ - startMeasuring(task: string): Chainable; - /** - * Stop measuring the duration of some task. - * The duration is reported in the Cypress log. - * @param task The task name. - */ - stopMeasuring(task: string): Chainable; - } - } -} - -function getPrefix(task: string): string { - return `cy:${Cypress.spec.name.split(".")[0]}:${task}`; -} - -function startMeasuring(task: string): Chainable { - return cy.window({ log: false }).then((win) => { - win.mxPerformanceMonitor.start(getPrefix(task)); - }); -} - -function stopMeasuring(task: string): Chainable { - return cy.window({ log: false }).then((win) => { - const measure = win.mxPerformanceMonitor.stop(getPrefix(task)); - cy.log(`**${task}** ${measure.duration} ms`); - }); -} - -Cypress.Commands.add("startMeasuring", startMeasuring); -Cypress.Commands.add("stopMeasuring", stopMeasuring); - -Cypress.on("window:before:unload", (event: BeforeUnloadEvent) => { - const doc = event.target as Document; - if (doc.location.href === "about:blank") return; - const win = doc.defaultView as AUTWindow; - if (!win.mxPerformanceMonitor) return; - const entries = win.mxPerformanceMonitor.getEntries().filter(entry => { - return entry.name.startsWith("cy:"); - }); - if (!entries || entries.length === 0) return; - cy.task("addMeasurements", entries); -}); - -// Needed to make this file a module -export { }; diff --git a/cypress/support/settings.ts b/cypress/support/settings.ts index 63c91ddda0..42a78792a0 100644 --- a/cypress/support/settings.ts +++ b/cypress/support/settings.ts @@ -153,7 +153,7 @@ Cypress.Commands.add("openRoomSettings", (tab?: string): Chainable> => { return cy.get(".mx_TabbedView_tabLabels").within(() => { - cy.get(".mx_TabbedView_tabLabel").contains(tab).click(); + cy.contains(".mx_TabbedView_tabLabel", tab).click(); }); }); @@ -162,13 +162,13 @@ Cypress.Commands.add("closeDialog", (): Chainable> => { }); Cypress.Commands.add("joinBeta", (name: string): Chainable> => { - return cy.get(".mx_BetaCard_title").contains(name).closest(".mx_BetaCard").within(() => { + return cy.contains(".mx_BetaCard_title", name).closest(".mx_BetaCard").within(() => { return cy.get(".mx_BetaCard_buttons").contains("Join the beta").click(); }); }); Cypress.Commands.add("leaveBeta", (name: string): Chainable> => { - return cy.get(".mx_BetaCard_title").contains(name).closest(".mx_BetaCard").within(() => { + return cy.contains(".mx_BetaCard_title", name).closest(".mx_BetaCard").within(() => { return cy.get(".mx_BetaCard_buttons").contains("Leave the beta").click(); }); }); diff --git a/package.json b/package.json index 82ec05f81a..f0ab2c266b 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,7 @@ "dependencies": { "@babel/runtime": "^7.12.5", "@matrix-org/analytics-events": "^0.2.0", - "@matrix-org/matrix-wysiwyg": "^0.2.0", + "@matrix-org/matrix-wysiwyg": "^0.3.0", "@matrix-org/react-sdk-module-api": "^0.0.3", "@sentry/browser": "^6.11.0", "@sentry/tracing": "^6.11.0", @@ -111,6 +111,7 @@ "react-focus-lock": "^2.5.1", "react-transition-group": "^4.4.1", "rfc4648": "^1.4.0", + "sanitize-filename": "^1.6.3", "sanitize-html": "^2.3.2", "tar-js": "^0.3.0", "ua-parser-js": "^1.0.2", diff --git a/res/css/_components.pcss b/res/css/_components.pcss index f916d5925d..819afe64a4 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -4,7 +4,7 @@ @import "./_font-sizes.pcss"; @import "./_font-weights.pcss"; @import "./_spacing.pcss"; -@import "./components/atoms/_Icon.pcss"; +@import "./compound/_Icon.pcss"; @import "./components/views/beacon/_BeaconListItem.pcss"; @import "./components/views/beacon/_BeaconStatus.pcss"; @import "./components/views/beacon/_BeaconStatusTooltip.pcss"; @@ -96,6 +96,7 @@ @import "./views/auth/_CountryDropdown.pcss"; @import "./views/auth/_InteractiveAuthEntryComponents.pcss"; @import "./views/auth/_LanguageSelector.pcss"; +@import "./views/auth/_LoginWithQR.pcss"; @import "./views/auth/_PassphraseField.pcss"; @import "./views/auth/_Welcome.pcss"; @import "./views/avatars/_BaseAvatar.pcss"; @@ -281,6 +282,7 @@ @import "./views/rooms/_ReplyPreview.pcss"; @import "./views/rooms/_ReplyTile.pcss"; @import "./views/rooms/_RoomBreadcrumbs.pcss"; +@import "./views/rooms/_RoomCallBanner.pcss"; @import "./views/rooms/_RoomHeader.pcss"; @import "./views/rooms/_RoomInfoLine.pcss"; @import "./views/rooms/_RoomList.pcss"; @@ -367,6 +369,7 @@ @import "./views/voip/_VideoFeed.pcss"; @import "./voice-broadcast/atoms/_LiveBadge.pcss"; @import "./voice-broadcast/atoms/_PlaybackControlButton.pcss"; +@import "./voice-broadcast/atoms/_VoiceBroadcastControl.pcss"; @import "./voice-broadcast/atoms/_VoiceBroadcastHeader.pcss"; @import "./voice-broadcast/molecules/_VoiceBroadcastPlaybackBody.pcss"; @import "./voice-broadcast/molecules/_VoiceBroadcastRecordingBody.pcss"; diff --git a/res/css/components/atoms/_Icon.pcss b/res/css/compound/_Icon.pcss similarity index 68% rename from res/css/components/atoms/_Icon.pcss rename to res/css/compound/_Icon.pcss index b9d994e43f..88f49f9da0 100644 --- a/res/css/components/atoms/_Icon.pcss +++ b/res/css/compound/_Icon.pcss @@ -14,13 +14,14 @@ See the License for the specific language governing permissions and limitations under the License. */ +/* + * Compound icon + + * {@link https://www.figma.com/file/X4XTH9iS2KGJ2wFKDqkyed} + */ + .mx_Icon { box-sizing: border-box; - display: inline-block; - mask-origin: content-box; - mask-position: center; - mask-repeat: no-repeat; - mask-size: contain; padding: 1px; } @@ -28,15 +29,3 @@ limitations under the License. height: 16px; width: 16px; } - -.mx_Icon_accent { - background-color: $accent; -} - -.mx_Icon_live-badge { - background-color: #fff; -} - -.mx_Icon_compound-secondary-content { - background-color: $secondary-content; -} diff --git a/res/css/views/auth/_LoginWithQR.pcss b/res/css/views/auth/_LoginWithQR.pcss new file mode 100644 index 0000000000..390cf8311d --- /dev/null +++ b/res/css/views/auth/_LoginWithQR.pcss @@ -0,0 +1,171 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_LoginWithQRSection .mx_AccessibleButton { + margin-right: $spacing-12; +} + +.mx_AuthPage .mx_LoginWithQR { + .mx_AccessibleButton { + display: block !important; + } + + .mx_AccessibleButton + .mx_AccessibleButton { + margin-top: $spacing-8; + } + + .mx_LoginWithQR_separator { + display: flex; + align-items: center; + text-align: center; + + &::before, &::after { + content: ''; + flex: 1; + border-bottom: 1px solid $quinary-content; + } + + &:not(:empty) { + &::before { + margin-right: 1em; + } + &::after { + margin-left: 1em; + } + } + } + + font-size: $font-15px; +} + +.mx_UserSettingsDialog .mx_LoginWithQR { + .mx_AccessibleButton + .mx_AccessibleButton { + margin-left: $spacing-12; + } + + font-size: $font-14px; + + h1 { + font-size: $font-24px; + margin-bottom: 0; + } + + li { + line-height: 1.8; + } + + .mx_QRCode { + padding: $spacing-12 $spacing-40; + margin: $spacing-28 0; + } + + .mx_LoginWithQR_buttons { + text-align: center; + } + + .mx_LoginWithQR_qrWrapper { + display: flex; + } +} + +.mx_LoginWithQR { + min-height: 350px; + display: flex; + flex-direction: column; + + .mx_LoginWithQR_centreTitle { + h1 { + text-align: centre; + } + } + + h1 > svg { + &.normal { + color: $secondary-content; + } + &.error { + color: $alert; + } + &.success { + color: $accent; + } + height: 1.3em; + margin-right: $spacing-8; + vertical-align: middle; + } + + .mx_LoginWithQR_confirmationDigits { + text-align: center; + margin: $spacing-48 auto; + font-weight: 600; + font-size: $font-24px; + color: $primary-content; + } + + .mx_LoginWithQR_confirmationAlert { + border: 1px solid $quaternary-content; + border-radius: $spacing-8; + padding: $spacing-8; + line-height: 1.5em; + display: flex; + + svg { + height: 30px; + } + } + + .mx_LoginWithQR_separator { + margin: 1em 0; + } + + ol { + list-style-position: inside; + padding-inline-start: 0; + + li::marker { + color: $accent; + } + } + + .mx_LoginWithQR_BackButton { + height: $spacing-12; + margin-bottom: $spacing-24; + svg { + height: 100%; + } + } + + .mx_LoginWithQR_main { + display: flex; + flex-direction: column; + flex-grow: 1; + } + + .mx_QRCode { + border: 1px solid $quinary-content; + border-radius: $spacing-8; + display: flex; + justify-content: center; + } + + .mx_LoginWithQR_spinner { + flex-grow: 1; + display: flex; + justify-content: center; + align-items: center; + height: 100%; + } +} diff --git a/res/css/views/rooms/_RoomCallBanner.pcss b/res/css/views/rooms/_RoomCallBanner.pcss new file mode 100644 index 0000000000..ec26807bb1 --- /dev/null +++ b/res/css/views/rooms/_RoomCallBanner.pcss @@ -0,0 +1,54 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_RoomCallBanner { + width: 100%; + display: flex; + flex-direction: row; + align-items: center; + + box-sizing: border-box; + padding: $spacing-12 $spacing-16; + + color: $primary-content; + background-color: $system; + cursor: pointer; +} + +.mx_RoomCallBanner_text { + display: flex; + flex: 1; + align-items: center; +} + +.mx_RoomCallBanner_label { + color: $primary-content; + font-weight: 600; + padding-right: $spacing-8; + + &::before { + display: inline-block; + vertical-align: middle; + content: ""; + background-color: $secondary-content; + mask-size: 16px; + mask-position-y: center; + width: 16px; + height: 1.2em; /* to match line height */ + margin-right: 8px; + mask-image: url("$(res)/img/element-icons/call/video-call.svg"); + } +} diff --git a/res/css/voice-broadcast/atoms/_VoiceBroadcastControl.pcss b/res/css/voice-broadcast/atoms/_VoiceBroadcastControl.pcss new file mode 100644 index 0000000000..f7cba04870 --- /dev/null +++ b/res/css/voice-broadcast/atoms/_VoiceBroadcastControl.pcss @@ -0,0 +1,31 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_VoiceBroadcastControl { + align-items: center; + background-color: $background; + border-radius: 50%; + color: $secondary-content; + display: flex; + height: 32px; + justify-content: center; + margin-bottom: $spacing-8; + width: 32px; +} + +.mx_VoiceBroadcastControl-recording { + color: $alert; +} diff --git a/res/css/voice-broadcast/atoms/_VoiceBroadcastHeader.pcss b/res/css/voice-broadcast/atoms/_VoiceBroadcastHeader.pcss index b7d934a414..0e2395cacb 100644 --- a/res/css/voice-broadcast/atoms/_VoiceBroadcastHeader.pcss +++ b/res/css/voice-broadcast/atoms/_VoiceBroadcastHeader.pcss @@ -20,6 +20,11 @@ limitations under the License. width: 266px; } +.mx_VoiceBroadcastHeader_content { + flex-grow: 1; + min-width: 0; +} + .mx_VoiceBroadcastHeader_room { font-size: $font-12px; font-weight: $font-semi-bold; @@ -34,4 +39,14 @@ limitations under the License. font-size: $font-12px; display: flex; gap: $spacing-4; + + i { + flex-shrink: 0; + } + + span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } } diff --git a/res/css/voice-broadcast/molecules/_VoiceBroadcastRecordingPip.pcss b/res/css/voice-broadcast/molecules/_VoiceBroadcastRecordingPip.pcss index b01b1b80db..11534a4797 100644 --- a/res/css/voice-broadcast/molecules/_VoiceBroadcastRecordingPip.pcss +++ b/res/css/voice-broadcast/molecules/_VoiceBroadcastRecordingPip.pcss @@ -31,5 +31,5 @@ limitations under the License. .mx_VoiceBroadcastRecordingPip_controls { display: flex; - justify-content: center; + justify-content: space-around; } diff --git a/res/img/element-icons/Record.svg b/res/img/element-icons/Record.svg new file mode 100644 index 0000000000..a16ce774b0 --- /dev/null +++ b/res/img/element-icons/Record.svg @@ -0,0 +1,3 @@ + + + diff --git a/res/img/element-icons/Stop.svg b/res/img/element-icons/Stop.svg index 29c7a0cef7..d63459e1db 100644 --- a/res/img/element-icons/Stop.svg +++ b/res/img/element-icons/Stop.svg @@ -1,3 +1,3 @@ - + diff --git a/res/img/element-icons/back.svg b/res/img/element-icons/back.svg new file mode 100644 index 0000000000..62aef5df27 --- /dev/null +++ b/res/img/element-icons/back.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/res/img/element-icons/devices.svg b/res/img/element-icons/devices.svg new file mode 100644 index 0000000000..6c26cfe97e --- /dev/null +++ b/res/img/element-icons/devices.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/res/img/element-icons/live.svg b/res/img/element-icons/live.svg index 40a7a66677..31341f1ef6 100644 --- a/res/img/element-icons/live.svg +++ b/res/img/element-icons/live.svg @@ -5,54 +5,23 @@ viewBox="0 0 21.799 21.799" fill="none" version="1.1" - id="svg12" - sodipodi:docname="live.svg" - inkscape:version="1.2.1 (9c6d41e410, 2022-07-14)" - xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" - xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg"> - - + fill="currentColor" /> + fill="currentColor" /> + fill="currentColor" /> + fill="currentColor" /> + fill="currentColor" /> diff --git a/res/img/element-icons/pause.svg b/res/img/element-icons/pause.svg index 293c0a10d8..4b7be99e3b 100644 --- a/res/img/element-icons/pause.svg +++ b/res/img/element-icons/pause.svg @@ -1,4 +1,4 @@ - - + + diff --git a/res/img/element-icons/play.svg b/res/img/element-icons/play.svg index 339e20b729..3443ae01fa 100644 --- a/res/img/element-icons/play.svg +++ b/res/img/element-icons/play.svg @@ -1,3 +1,3 @@ - + diff --git a/res/img/element-icons/qrcode.svg b/res/img/element-icons/qrcode.svg new file mode 100644 index 0000000000..7787141ad5 --- /dev/null +++ b/res/img/element-icons/qrcode.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/res/img/voip/call-view/mic-on.svg b/res/img/voip/call-view/mic-on.svg index 57428a3cd8..317d10b296 100644 --- a/res/img/voip/call-view/mic-on.svg +++ b/res/img/voip/call-view/mic-on.svg @@ -1,3 +1,3 @@ - + diff --git a/src/ContentMessages.ts b/src/ContentMessages.ts index d4cf3cc0ab..8135eaab0e 100644 --- a/src/ContentMessages.ts +++ b/src/ContentMessages.ts @@ -43,7 +43,6 @@ import { RoomUpload } from "./models/RoomUpload"; import SettingsStore from "./settings/SettingsStore"; import { decorateStartSendingTime, sendRoundTripMetric } from "./sendTimePerformanceMetrics"; import { TimelineRenderingType } from "./contexts/RoomContext"; -import { RoomViewStore } from "./stores/RoomViewStore"; import { addReplyToMessageContent } from "./utils/Reply"; import ErrorDialog from "./components/views/dialogs/ErrorDialog"; import UploadFailureDialog from "./components/views/dialogs/UploadFailureDialog"; @@ -51,6 +50,7 @@ import UploadConfirmDialog from "./components/views/dialogs/UploadConfirmDialog" import { createThumbnail } from "./utils/image-media"; import { attachRelation } from "./components/views/rooms/SendMessageComposer"; import { doMaybeLocalRoomAction } from "./utils/local-room"; +import { SdkContextClass } from "./contexts/SDKContext"; // scraped out of a macOS hidpi (5660ppm) screenshot png // 5669 px (x-axis) , 5669 px (y-axis) , per metre @@ -361,7 +361,7 @@ export default class ContentMessages { return; } - const replyToEvent = RoomViewStore.instance.getQuotingEvent(); + const replyToEvent = SdkContextClass.instance.roomViewStore.getQuotingEvent(); if (!this.mediaConfig) { // hot-path optimization to not flash a spinner if we don't need to const modal = Modal.createDialog(Spinner, null, 'mx_Dialog_spinner'); await this.ensureMediaConfigFetched(matrixClient); diff --git a/src/DateUtils.ts b/src/DateUtils.ts index 20227354f0..2b50bddb85 100644 --- a/src/DateUtils.ts +++ b/src/DateUtils.ts @@ -1,6 +1,7 @@ /* Copyright 2015, 2016 OpenMarket Ltd Copyright 2017 Vector Creations Ltd +Copyright 2022 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -177,6 +178,16 @@ export function formatFullDateNoDay(date: Date) { }); } +/** + * Returns an ISO date string without textual description of the date (ie: no "Wednesday" or + * similar) + * @param date The date to format. + * @returns The date string in ISO format. + */ +export function formatFullDateNoDayISO(date: Date): string { + return date.toISOString(); +} + export function formatFullDateNoDayNoTime(date: Date) { return ( date.getFullYear() + diff --git a/src/Lifecycle.ts b/src/Lifecycle.ts index 64d1d9b5fd..9351e91ae4 100644 --- a/src/Lifecycle.ts +++ b/src/Lifecycle.ts @@ -39,7 +39,6 @@ import PlatformPeg from "./PlatformPeg"; import { sendLoginRequest } from "./Login"; import * as StorageManager from './utils/StorageManager'; import SettingsStore from "./settings/SettingsStore"; -import TypingStore from "./stores/TypingStore"; import ToastStore from "./stores/ToastStore"; import { IntegrationManagers } from "./integrations/IntegrationManagers"; import { Mjolnir } from "./mjolnir/Mjolnir"; @@ -62,6 +61,7 @@ import { DialogOpener } from "./utils/DialogOpener"; import { Action } from "./dispatcher/actions"; import AbstractLocalStorageSettingsHandler from "./settings/handlers/AbstractLocalStorageSettingsHandler"; import { OverwriteLoginPayload } from "./dispatcher/payloads/OverwriteLoginPayload"; +import { SdkContextClass } from './contexts/SDKContext'; const HOMESERVER_URL_KEY = "mx_hs_url"; const ID_SERVER_URL_KEY = "mx_is_url"; @@ -426,7 +426,7 @@ export async function restoreFromLocalStorage(opts?: { ignoreGuest?: boolean }): const { hsUrl, isUrl, hasAccessToken, accessToken, userId, deviceId, isGuest } = await getStoredSessionVars(); if (hasAccessToken && !accessToken) { - abortLogin(); + await abortLogin(); } if (accessToken && userId && hsUrl) { @@ -797,7 +797,7 @@ async function startMatrixClient(startSyncing = true): Promise { dis.dispatch({ action: 'will_start_client' }, true); // reset things first just in case - TypingStore.sharedInstance().reset(); + SdkContextClass.instance.typingStore.reset(); ToastStore.sharedInstance().reset(); DialogOpener.instance.prepare(); @@ -927,7 +927,7 @@ export function stopMatrixClient(unsetClient = true): void { Notifier.stop(); LegacyCallHandler.instance.stop(); UserActivity.sharedInstance().stop(); - TypingStore.sharedInstance().reset(); + SdkContextClass.instance.typingStore.reset(); Presence.stop(); ActiveWidgetStore.instance.stop(); IntegrationManagers.sharedInstance().stopWatching(); diff --git a/src/Notifier.ts b/src/Notifier.ts index dd0ebc296a..cc84acb2fa 100644 --- a/src/Notifier.ts +++ b/src/Notifier.ts @@ -41,12 +41,12 @@ import SettingsStore from "./settings/SettingsStore"; import { hideToast as hideNotificationsToast } from "./toasts/DesktopNotificationsToast"; import { SettingLevel } from "./settings/SettingLevel"; import { isPushNotifyDisabled } from "./settings/controllers/NotificationControllers"; -import { RoomViewStore } from "./stores/RoomViewStore"; import UserActivity from "./UserActivity"; import { mediaFromMxc } from "./customisations/Media"; import ErrorDialog from "./components/views/dialogs/ErrorDialog"; import LegacyCallHandler from "./LegacyCallHandler"; import VoipUserMapper from "./VoipUserMapper"; +import { SdkContextClass } from "./contexts/SDKContext"; import { localNotificationsAreSilenced } from "./utils/notifications"; import { getIncomingCallToastKey, IncomingCallToast } from "./toasts/IncomingCallToast"; import ToastStore from "./stores/ToastStore"; @@ -435,7 +435,7 @@ export const Notifier = { if (actions?.notify) { this._performCustomEventHandling(ev); - if (RoomViewStore.instance.getRoomId() === room.roomId && + if (SdkContextClass.instance.roomViewStore.getRoomId() === room.roomId && UserActivity.sharedInstance().userActiveRecently() && !Modal.hasDialogs() ) { diff --git a/src/ScalarMessaging.ts b/src/ScalarMessaging.ts index c511d291ce..72ff94d4d3 100644 --- a/src/ScalarMessaging.ts +++ b/src/ScalarMessaging.ts @@ -272,12 +272,12 @@ import { logger } from "matrix-js-sdk/src/logger"; import { MatrixClientPeg } from './MatrixClientPeg'; import dis from './dispatcher/dispatcher'; import WidgetUtils from './utils/WidgetUtils'; -import { RoomViewStore } from './stores/RoomViewStore'; import { _t } from './languageHandler'; import { IntegrationManagers } from "./integrations/IntegrationManagers"; import { WidgetType } from "./widgets/WidgetType"; import { objectClone } from "./utils/objects"; import { EffectiveMembership, getEffectiveMembership } from './utils/membership'; +import { SdkContextClass } from './contexts/SDKContext'; enum Action { CloseScalar = "close_scalar", @@ -721,7 +721,7 @@ const onMessage = function(event: MessageEvent): void { } } - if (roomId !== RoomViewStore.instance.getRoomId()) { + if (roomId !== SdkContextClass.instance.roomViewStore.getRoomId()) { sendError(event, _t('Room %(roomId)s not visible', { roomId: roomId })); return; } diff --git a/src/SdkConfig.ts b/src/SdkConfig.ts index 6698f3ffb2..1c26fd4e8b 100644 --- a/src/SdkConfig.ts +++ b/src/SdkConfig.ts @@ -47,7 +47,7 @@ export const DEFAULTS: IConfigOptions = { url: "https://element.io/get-started", }, voice_broadcast: { - chunk_length: 60, // one minute + chunk_length: 120, // two minutes }, }; diff --git a/src/SlashCommands.tsx b/src/SlashCommands.tsx index bbd936ce75..624c515b15 100644 --- a/src/SlashCommands.tsx +++ b/src/SlashCommands.tsx @@ -62,7 +62,6 @@ import InfoDialog from "./components/views/dialogs/InfoDialog"; import SlashCommandHelpDialog from "./components/views/dialogs/SlashCommandHelpDialog"; import { shouldShowComponent } from "./customisations/helpers/UIComponents"; import { TimelineRenderingType } from './contexts/RoomContext'; -import { RoomViewStore } from "./stores/RoomViewStore"; import { XOR } from "./@types/common"; import { PosthogAnalytics } from "./PosthogAnalytics"; import { ViewRoomPayload } from "./dispatcher/payloads/ViewRoomPayload"; @@ -70,6 +69,7 @@ import VoipUserMapper from './VoipUserMapper'; import { htmlSerializeFromMdIfNeeded } from './editor/serialize'; import { leaveRoomBehaviour } from "./utils/leave-behaviour"; import { isLocalRoom } from './utils/localRoom/isLocalRoom'; +import { SdkContextClass } from './contexts/SDKContext'; // XXX: workaround for https://github.com/microsoft/TypeScript/issues/31816 interface HTMLInputEvent extends Event { @@ -209,7 +209,7 @@ function successSync(value: any) { const isCurrentLocalRoom = (): boolean => { const cli = MatrixClientPeg.get(); - const room = cli.getRoom(RoomViewStore.instance.getRoomId()); + const room = cli.getRoom(SdkContextClass.instance.roomViewStore.getRoomId()); return isLocalRoom(room); }; @@ -868,7 +868,7 @@ export const Commands = [ description: _td('Define the power level of a user'), isEnabled(): boolean { const cli = MatrixClientPeg.get(); - const room = cli.getRoom(RoomViewStore.instance.getRoomId()); + const room = cli.getRoom(SdkContextClass.instance.roomViewStore.getRoomId()); return room?.currentState.maySendStateEvent(EventType.RoomPowerLevels, cli.getUserId()) && !isLocalRoom(room); }, @@ -909,7 +909,7 @@ export const Commands = [ description: _td('Deops user with given id'), isEnabled(): boolean { const cli = MatrixClientPeg.get(); - const room = cli.getRoom(RoomViewStore.instance.getRoomId()); + const room = cli.getRoom(SdkContextClass.instance.roomViewStore.getRoomId()); return room?.currentState.maySendStateEvent(EventType.RoomPowerLevels, cli.getUserId()) && !isLocalRoom(room); }, diff --git a/src/audio/PlaybackQueue.ts b/src/audio/PlaybackQueue.ts index 72ed8cf169..c5a6ee64f2 100644 --- a/src/audio/PlaybackQueue.ts +++ b/src/audio/PlaybackQueue.ts @@ -25,7 +25,7 @@ import { MatrixClientPeg } from "../MatrixClientPeg"; import { arrayFastClone } from "../utils/arrays"; import { PlaybackManager } from "./PlaybackManager"; import { isVoiceMessage } from "../utils/EventUtils"; -import { RoomViewStore } from "../stores/RoomViewStore"; +import { SdkContextClass } from "../contexts/SDKContext"; /** * Audio playback queue management for a given room. This keeps track of where the user @@ -51,7 +51,7 @@ export class PlaybackQueue { constructor(private room: Room) { this.loadClocks(); - RoomViewStore.instance.addRoomListener(this.room.roomId, (isActive) => { + SdkContextClass.instance.roomViewStore.addRoomListener(this.room.roomId, (isActive) => { if (!isActive) return; // Reset the state of the playbacks before they start mounting and enqueuing updates. diff --git a/src/components/atoms/Icon.tsx b/src/components/atoms/Icon.tsx deleted file mode 100644 index 56d8236250..0000000000 --- a/src/components/atoms/Icon.tsx +++ /dev/null @@ -1,83 +0,0 @@ -/* -Copyright 2022 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import React from "react"; - -import liveIcon from "../../../res/img/element-icons/live.svg"; -import microphoneIcon from "../../../res/img/voip/call-view/mic-on.svg"; -import pauseIcon from "../../../res/img/element-icons/pause.svg"; -import playIcon from "../../../res/img/element-icons/play.svg"; -import stopIcon from "../../../res/img/element-icons/Stop.svg"; - -export enum IconType { - Live, - Microphone, - Pause, - Play, - Stop, -} - -const iconTypeMap = new Map([ - [IconType.Live, liveIcon], - [IconType.Microphone, microphoneIcon], - [IconType.Pause, pauseIcon], - [IconType.Play, playIcon], - [IconType.Stop, stopIcon], -]); - -export enum IconColour { - Accent = "accent", - LiveBadge = "live-badge", - CompoundSecondaryContent = "compound-secondary-content", -} - -export enum IconSize { - S16 = "16", -} - -interface IconProps { - colour?: IconColour; - size?: IconSize; - type: IconType; -} - -export const Icon: React.FC = ({ - size = IconSize.S16, - colour = IconColour.Accent, - type, - ...rest -}) => { - const classes = [ - "mx_Icon", - `mx_Icon_${size}`, - `mx_Icon_${colour}`, - ]; - - const styles: React.CSSProperties = { - maskImage: `url("${iconTypeMap.get(type)}")`, - WebkitMaskImage: `url("${iconTypeMap.get(type)}")`, - }; - - return ( - - ); -}; diff --git a/src/components/structures/ContextMenu.tsx b/src/components/structures/ContextMenu.tsx index 9a13d62424..b62c4b6d7b 100644 --- a/src/components/structures/ContextMenu.tsx +++ b/src/components/structures/ContextMenu.tsx @@ -565,8 +565,13 @@ type ContextMenuTuple = [ (val: boolean) => void, ]; // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-constraint -export const useContextMenu = (): ContextMenuTuple => { - const button = useRef(null); +export const useContextMenu = (inputRef?: RefObject): ContextMenuTuple => { + let button = useRef(null); + if (inputRef) { + // if we are given a ref, use it instead of ours + button = inputRef; + } + const [isOpen, setIsOpen] = useState(false); const open = (ev?: SyntheticEvent) => { ev?.preventDefault(); @@ -579,7 +584,7 @@ export const useContextMenu = (): ContextMenuTuple< setIsOpen(false); }; - return [isOpen, button, open, close, setIsOpen]; + return [button.current ? isOpen : false, button, open, close, setIsOpen]; }; // XXX: Deprecated, used only for dynamic Tooltips. Avoid using at all costs. diff --git a/src/components/structures/FileDropTarget.tsx b/src/components/structures/FileDropTarget.tsx index f6572a05e8..a775017c20 100644 --- a/src/components/structures/FileDropTarget.tsx +++ b/src/components/structures/FileDropTarget.tsx @@ -19,7 +19,7 @@ import React, { useEffect, useState } from "react"; import { _t } from "../../languageHandler"; interface IProps { - parent: HTMLElement; + parent: HTMLElement | null; onFileDrop(dataTransfer: DataTransfer): void; } @@ -90,20 +90,20 @@ const FileDropTarget: React.FC = ({ parent, onFileDrop }) => { })); }; - parent.addEventListener("drop", onDrop); - parent.addEventListener("dragover", onDragOver); - parent.addEventListener("dragenter", onDragEnter); - parent.addEventListener("dragleave", onDragLeave); + parent?.addEventListener("drop", onDrop); + parent?.addEventListener("dragover", onDragOver); + parent?.addEventListener("dragenter", onDragEnter); + parent?.addEventListener("dragleave", onDragLeave); return () => { // disconnect the D&D event listeners from the room view. This // is really just for hygiene - we're going to be // deleted anyway, so it doesn't matter if the event listeners // don't get cleaned up. - parent.removeEventListener("drop", onDrop); - parent.removeEventListener("dragover", onDragOver); - parent.removeEventListener("dragenter", onDragEnter); - parent.removeEventListener("dragleave", onDragLeave); + parent?.removeEventListener("drop", onDrop); + parent?.removeEventListener("dragover", onDragOver); + parent?.removeEventListener("dragenter", onDragEnter); + parent?.removeEventListener("dragleave", onDragLeave); }; }, [parent, onFileDrop]); diff --git a/src/components/structures/LoggedInView.tsx b/src/components/structures/LoggedInView.tsx index 872c69c01d..f359f091f1 100644 --- a/src/components/structures/LoggedInView.tsx +++ b/src/components/structures/LoggedInView.tsx @@ -22,6 +22,7 @@ import classNames from 'classnames'; import { ISyncStateData, SyncState } from 'matrix-js-sdk/src/sync'; import { IUsageLimit } from 'matrix-js-sdk/src/@types/partials'; import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state"; +import { MatrixError } from 'matrix-js-sdk/src/matrix'; import { isOnlyCtrlOrCmdKeyEvent, Key } from '../../Keyboard'; import PageTypes from '../../PageTypes'; @@ -288,8 +289,8 @@ class LoggedInView extends React.Component { }; private onSync = (syncState: SyncState, oldSyncState?: SyncState, data?: ISyncStateData): void => { - const oldErrCode = this.state.syncErrorData?.error?.errcode; - const newErrCode = data && data.error && data.error.errcode; + const oldErrCode = (this.state.syncErrorData?.error as MatrixError)?.errcode; + const newErrCode = (data?.error as MatrixError)?.errcode; if (syncState === oldSyncState && oldErrCode === newErrCode) return; this.setState({ @@ -317,9 +318,9 @@ class LoggedInView extends React.Component { }; private calculateServerLimitToast(syncError: IState["syncErrorData"], usageLimitEventContent?: IUsageLimit) { - const error = syncError && syncError.error && syncError.error.errcode === "M_RESOURCE_LIMIT_EXCEEDED"; + const error = (syncError?.error as MatrixError)?.errcode === "M_RESOURCE_LIMIT_EXCEEDED"; if (error) { - usageLimitEventContent = syncError.error.data as IUsageLimit; + usageLimitEventContent = (syncError?.error as MatrixError).data as IUsageLimit; } // usageLimitDismissed is true when the user has explicitly hidden the toast diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index 368a0492a5..847b495f51 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -24,7 +24,6 @@ import { MatrixEventEvent, } from 'matrix-js-sdk/src/matrix'; import { ISyncStateData, SyncState } from 'matrix-js-sdk/src/sync'; -import { MatrixError } from 'matrix-js-sdk/src/http-api'; import { InvalidStoreError } from "matrix-js-sdk/src/errors"; import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { defer, IDeferred, QueryDict } from "matrix-js-sdk/src/utils"; @@ -137,8 +136,10 @@ import { TimelineRenderingType } from "../../contexts/RoomContext"; import { UseCaseSelection } from '../views/elements/UseCaseSelection'; import { ValidatedServerConfig } from '../../utils/ValidatedServerConfig'; import { isLocalRoom } from '../../utils/localRoom/isLocalRoom'; +import { SdkContextClass, SDKContext } from '../../contexts/SDKContext'; import { viewUserDeviceSettings } from '../../actions/handlers/viewUserDeviceSettings'; import { isNumberArray } from '../../utils/TypeUtils'; +import { VoiceBroadcastResumer } from '../../voice-broadcast'; // legacy export export { default as Views } from "../../Views"; @@ -202,7 +203,7 @@ interface IState { // When showing Modal dialogs we need to set aria-hidden on the root app element // and disable it when there are no dialogs hideToSRUsers: boolean; - syncError?: MatrixError; + syncError?: Error; resizeNotifier: ResizeNotifier; serverConfig?: ValidatedServerConfig; ready: boolean; @@ -234,14 +235,18 @@ export default class MatrixChat extends React.PureComponent { private focusComposer: boolean; private subTitleStatus: string; private prevWindowWidth: number; + private voiceBroadcastResumer: VoiceBroadcastResumer; private readonly loggedInView: React.RefObject; private readonly dispatcherRef: string; private readonly themeWatcher: ThemeWatcher; private readonly fontWatcher: FontWatcher; + private readonly stores: SdkContextClass; constructor(props: IProps) { super(props); + this.stores = SdkContextClass.instance; + this.stores.constructEagerStores(); this.state = { view: Views.LOADING, @@ -430,6 +435,7 @@ export default class MatrixChat extends React.PureComponent { window.removeEventListener("resize", this.onWindowResized); if (this.accountPasswordTimer !== null) clearTimeout(this.accountPasswordTimer); + if (this.voiceBroadcastResumer) this.voiceBroadcastResumer.destroy(); } private onWindowResized = (): void => { @@ -763,6 +769,7 @@ export default class MatrixChat extends React.PureComponent { Modal.createDialog(DialPadModal, {}, "mx_Dialog_dialPadWrapper"); break; case Action.OnLoggedIn: + this.stores.client = MatrixClientPeg.get(); if ( // Skip this handling for token login as that always calls onLoggedIn itself !this.tokenLogin && @@ -1473,7 +1480,7 @@ export default class MatrixChat extends React.PureComponent { if (data.error instanceof InvalidStoreError) { Lifecycle.handleInvalidStoreError(data.error); } - this.setState({ syncError: data.error || {} as MatrixError }); + this.setState({ syncError: data.error }); } else if (this.state.syncError) { this.setState({ syncError: null }); } @@ -1637,6 +1644,8 @@ export default class MatrixChat extends React.PureComponent { }); } }); + + this.voiceBroadcastResumer = new VoiceBroadcastResumer(cli); } /** @@ -2111,7 +2120,9 @@ export default class MatrixChat extends React.PureComponent { } return - { view } + + { view } + ; } } diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index 6425709ea7..2dfe61aefa 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -44,21 +44,18 @@ import { RoomPermalinkCreator } from '../../utils/permalinks/Permalinks'; import ResizeNotifier from '../../utils/ResizeNotifier'; import ContentMessages from '../../ContentMessages'; import Modal from '../../Modal'; -import LegacyCallHandler, { LegacyCallHandlerEvent } from '../../LegacyCallHandler'; +import { LegacyCallHandlerEvent } from '../../LegacyCallHandler'; import dis, { defaultDispatcher } from '../../dispatcher/dispatcher'; import * as Rooms from '../../Rooms'; import eventSearch, { searchPagination } from '../../Searching'; import MainSplit from './MainSplit'; import RightPanel from './RightPanel'; -import { RoomViewStore } from '../../stores/RoomViewStore'; import RoomScrollStateStore, { ScrollState } from '../../stores/RoomScrollStateStore'; import WidgetEchoStore from '../../stores/WidgetEchoStore'; import SettingsStore from "../../settings/SettingsStore"; import { Layout } from "../../settings/enums/Layout"; import AccessibleButton from "../views/elements/AccessibleButton"; -import RightPanelStore from "../../stores/right-panel/RightPanelStore"; import RoomContext, { TimelineRenderingType } from "../../contexts/RoomContext"; -import MatrixClientContext, { MatrixClientProps, withMatrixClientHOC } from "../../contexts/MatrixClientContext"; import { E2EStatus, shieldStatusForRoom } from '../../utils/ShieldUtils'; import { Action } from "../../dispatcher/actions"; import { IMatrixClientCreds } from "../../MatrixClientPeg"; @@ -76,12 +73,10 @@ import { IOOBData, IThreepidInvite } from "../../stores/ThreepidInviteStore"; import EffectsOverlay from "../views/elements/EffectsOverlay"; import { containsEmoji } from '../../effects/utils'; import { CHAT_EFFECTS } from '../../effects'; -import WidgetStore from "../../stores/WidgetStore"; import { CallView } from "../views/voip/CallView"; import { UPDATE_EVENT } from "../../stores/AsyncStore"; import Notifier from "../../Notifier"; import { showToast as showNotificationsToast } from "../../toasts/DesktopNotificationsToast"; -import { RoomNotificationStateStore } from "../../stores/notifications/RoomNotificationStateStore"; import { Container, WidgetLayoutStore } from "../../stores/widgets/WidgetLayoutStore"; import { getKeyBindingsManager } from '../../KeyBindingsManager'; import { objectHasDiff } from "../../utils/objects"; @@ -118,8 +113,8 @@ import { isLocalRoom } from '../../utils/localRoom/isLocalRoom'; import { ShowThreadPayload } from "../../dispatcher/payloads/ShowThreadPayload"; import { RoomStatusBarUnsentMessages } from './RoomStatusBarUnsentMessages'; import { LargeLoader } from './LargeLoader'; -import { VoiceBroadcastInfoEventType } from '../../voice-broadcast'; import { isVideoRoom } from '../../utils/video-rooms'; +import { SDKContext } from '../../contexts/SDKContext'; import { CallStore, CallStoreEvent } from "../../stores/CallStore"; import { Call } from "../../models/Call"; @@ -133,7 +128,7 @@ if (DEBUG) { debuglog = logger.log.bind(console); } -interface IRoomProps extends MatrixClientProps { +interface IRoomProps { threepidInvite: IThreepidInvite; oobData?: IOOBData; @@ -203,7 +198,6 @@ export interface IRoomState { upgradeRecommendation?: IRecommendedVersion; canReact: boolean; canSendMessages: boolean; - canSendVoiceBroadcasts: boolean; tombstone?: MatrixEvent; resizing: boolean; layout: Layout; @@ -381,13 +375,13 @@ export class RoomView extends React.Component { private messagePanel: TimelinePanel; private roomViewBody = createRef(); - static contextType = MatrixClientContext; - public context!: React.ContextType; + static contextType = SDKContext; + public context!: React.ContextType; - constructor(props: IRoomProps, context: React.ContextType) { + constructor(props: IRoomProps, context: React.ContextType) { super(props, context); - const llMembers = context.hasLazyLoadMembersEnabled(); + const llMembers = context.client.hasLazyLoadMembersEnabled(); this.state = { roomId: null, roomLoading: true, @@ -408,7 +402,6 @@ export class RoomView extends React.Component { statusBarVisible: false, canReact: false, canSendMessages: false, - canSendVoiceBroadcasts: false, resizing: false, layout: SettingsStore.getValue("layout"), lowBandwidth: SettingsStore.getValue("lowBandwidth"), @@ -422,7 +415,7 @@ export class RoomView extends React.Component { showJoinLeaves: true, showAvatarChanges: true, showDisplaynameChanges: true, - matrixClientIsReady: context?.isInitialSyncComplete(), + matrixClientIsReady: context.client?.isInitialSyncComplete(), mainSplitContentType: MainSplitContentType.Timeline, timelineRenderingType: TimelineRenderingType.Room, liveTimeline: undefined, @@ -430,25 +423,25 @@ export class RoomView extends React.Component { }; this.dispatcherRef = dis.register(this.onAction); - context.on(ClientEvent.Room, this.onRoom); - context.on(RoomEvent.Timeline, this.onRoomTimeline); - context.on(RoomEvent.TimelineReset, this.onRoomTimelineReset); - context.on(RoomEvent.Name, this.onRoomName); - context.on(RoomStateEvent.Events, this.onRoomStateEvents); - context.on(RoomStateEvent.Update, this.onRoomStateUpdate); - context.on(RoomEvent.MyMembership, this.onMyMembership); - context.on(CryptoEvent.KeyBackupStatus, this.onKeyBackupStatus); - context.on(CryptoEvent.DeviceVerificationChanged, this.onDeviceVerificationChanged); - context.on(CryptoEvent.UserTrustStatusChanged, this.onUserVerificationChanged); - context.on(CryptoEvent.KeysChanged, this.onCrossSigningKeysChanged); - context.on(MatrixEventEvent.Decrypted, this.onEventDecrypted); + context.client.on(ClientEvent.Room, this.onRoom); + context.client.on(RoomEvent.Timeline, this.onRoomTimeline); + context.client.on(RoomEvent.TimelineReset, this.onRoomTimelineReset); + context.client.on(RoomEvent.Name, this.onRoomName); + context.client.on(RoomStateEvent.Events, this.onRoomStateEvents); + context.client.on(RoomStateEvent.Update, this.onRoomStateUpdate); + context.client.on(RoomEvent.MyMembership, this.onMyMembership); + context.client.on(CryptoEvent.KeyBackupStatus, this.onKeyBackupStatus); + context.client.on(CryptoEvent.DeviceVerificationChanged, this.onDeviceVerificationChanged); + context.client.on(CryptoEvent.UserTrustStatusChanged, this.onUserVerificationChanged); + context.client.on(CryptoEvent.KeysChanged, this.onCrossSigningKeysChanged); + context.client.on(MatrixEventEvent.Decrypted, this.onEventDecrypted); // Start listening for RoomViewStore updates - RoomViewStore.instance.on(UPDATE_EVENT, this.onRoomViewStoreUpdate); + context.roomViewStore.on(UPDATE_EVENT, this.onRoomViewStoreUpdate); - RightPanelStore.instance.on(UPDATE_EVENT, this.onRightPanelStoreUpdate); + context.rightPanelStore.on(UPDATE_EVENT, this.onRightPanelStoreUpdate); WidgetEchoStore.on(UPDATE_EVENT, this.onWidgetEchoStoreUpdate); - WidgetStore.instance.on(UPDATE_EVENT, this.onWidgetStoreUpdate); + context.widgetStore.on(UPDATE_EVENT, this.onWidgetStoreUpdate); CallStore.instance.on(CallStoreEvent.ActiveCalls, this.onActiveCalls); @@ -501,16 +494,16 @@ export class RoomView extends React.Component { action: "appsDrawer", show: true, }); - if (WidgetLayoutStore.instance.hasMaximisedWidget(this.state.room)) { + if (this.context.widgetLayoutStore.hasMaximisedWidget(this.state.room)) { // Show chat in right panel when a widget is maximised - RightPanelStore.instance.setCard({ phase: RightPanelPhases.Timeline }); + this.context.rightPanelStore.setCard({ phase: RightPanelPhases.Timeline }); } this.checkWidgets(this.state.room); }; private checkWidgets = (room: Room): void => { this.setState({ - hasPinnedWidgets: WidgetLayoutStore.instance.hasPinnedWidgets(room), + hasPinnedWidgets: this.context.widgetLayoutStore.hasPinnedWidgets(room), mainSplitContentType: this.getMainSplitContentType(room), showApps: this.shouldShowApps(room), }); @@ -518,12 +511,12 @@ export class RoomView extends React.Component { private getMainSplitContentType = (room: Room) => { if ( - (SettingsStore.getValue("feature_group_calls") && RoomViewStore.instance.isViewingCall()) + (SettingsStore.getValue("feature_group_calls") && this.context.roomViewStore.isViewingCall()) || isVideoRoom(room) ) { return MainSplitContentType.Call; } - if (WidgetLayoutStore.instance.hasMaximisedWidget(room)) { + if (this.context.widgetLayoutStore.hasMaximisedWidget(room)) { return MainSplitContentType.MaximisedWidget; } return MainSplitContentType.Timeline; @@ -534,7 +527,7 @@ export class RoomView extends React.Component { return; } - if (!initial && this.state.roomId !== RoomViewStore.instance.getRoomId()) { + if (!initial && this.state.roomId !== this.context.roomViewStore.getRoomId()) { // RoomView explicitly does not support changing what room // is being viewed: instead it should just be re-mounted when // switching rooms. Therefore, if the room ID changes, we @@ -549,45 +542,45 @@ export class RoomView extends React.Component { return; } - const roomId = RoomViewStore.instance.getRoomId(); - const room = this.context.getRoom(roomId); + const roomId = this.context.roomViewStore.getRoomId(); + const room = this.context.client.getRoom(roomId); // This convoluted type signature ensures we get IntelliSense *and* correct typing const newState: Partial & Pick = { roomId, - roomAlias: RoomViewStore.instance.getRoomAlias(), - roomLoading: RoomViewStore.instance.isRoomLoading(), - roomLoadError: RoomViewStore.instance.getRoomLoadError(), - joining: RoomViewStore.instance.isJoining(), - replyToEvent: RoomViewStore.instance.getQuotingEvent(), + roomAlias: this.context.roomViewStore.getRoomAlias(), + roomLoading: this.context.roomViewStore.isRoomLoading(), + roomLoadError: this.context.roomViewStore.getRoomLoadError(), + joining: this.context.roomViewStore.isJoining(), + replyToEvent: this.context.roomViewStore.getQuotingEvent(), // we should only peek once we have a ready client - shouldPeek: this.state.matrixClientIsReady && RoomViewStore.instance.shouldPeek(), + shouldPeek: this.state.matrixClientIsReady && this.context.roomViewStore.shouldPeek(), showReadReceipts: SettingsStore.getValue("showReadReceipts", roomId), showRedactions: SettingsStore.getValue("showRedactions", roomId), showJoinLeaves: SettingsStore.getValue("showJoinLeaves", roomId), showAvatarChanges: SettingsStore.getValue("showAvatarChanges", roomId), showDisplaynameChanges: SettingsStore.getValue("showDisplaynameChanges", roomId), - wasContextSwitch: RoomViewStore.instance.getWasContextSwitch(), + wasContextSwitch: this.context.roomViewStore.getWasContextSwitch(), mainSplitContentType: room === null ? undefined : this.getMainSplitContentType(room), initialEventId: null, // default to clearing this, will get set later in the method if needed - showRightPanel: RightPanelStore.instance.isOpenForRoom(roomId), + showRightPanel: this.context.rightPanelStore.isOpenForRoom(roomId), activeCall: CallStore.instance.getActiveCall(roomId), }; if ( this.state.mainSplitContentType !== MainSplitContentType.Timeline && newState.mainSplitContentType === MainSplitContentType.Timeline - && RightPanelStore.instance.isOpen - && RightPanelStore.instance.currentCard.phase === RightPanelPhases.Timeline - && RightPanelStore.instance.roomPhaseHistory.some(card => (card.phase === RightPanelPhases.Timeline)) + && this.context.rightPanelStore.isOpen + && this.context.rightPanelStore.currentCard.phase === RightPanelPhases.Timeline + && this.context.rightPanelStore.roomPhaseHistory.some(card => (card.phase === RightPanelPhases.Timeline)) ) { // We're returning to the main timeline, so hide the right panel timeline - RightPanelStore.instance.setCard({ phase: RightPanelPhases.RoomSummary }); - RightPanelStore.instance.togglePanel(this.state.roomId ?? null); + this.context.rightPanelStore.setCard({ phase: RightPanelPhases.RoomSummary }); + this.context.rightPanelStore.togglePanel(this.state.roomId ?? null); newState.showRightPanel = false; } - const initialEventId = RoomViewStore.instance.getInitialEventId(); + const initialEventId = this.context.roomViewStore.getInitialEventId(); if (initialEventId) { let initialEvent = room?.findEventById(initialEventId); // The event does not exist in the current sync data @@ -600,7 +593,7 @@ export class RoomView extends React.Component { // becomes available to fetch a whole thread if (!initialEvent) { initialEvent = await fetchInitialEvent( - this.context, + this.context.client, roomId, initialEventId, ); @@ -616,21 +609,21 @@ export class RoomView extends React.Component { action: Action.ShowThread, rootEvent: thread.rootEvent, initialEvent, - highlighted: RoomViewStore.instance.isInitialEventHighlighted(), - scroll_into_view: RoomViewStore.instance.initialEventScrollIntoView(), + highlighted: this.context.roomViewStore.isInitialEventHighlighted(), + scroll_into_view: this.context.roomViewStore.initialEventScrollIntoView(), }); } else { newState.initialEventId = initialEventId; - newState.isInitialEventHighlighted = RoomViewStore.instance.isInitialEventHighlighted(); - newState.initialEventScrollIntoView = RoomViewStore.instance.initialEventScrollIntoView(); + newState.isInitialEventHighlighted = this.context.roomViewStore.isInitialEventHighlighted(); + newState.initialEventScrollIntoView = this.context.roomViewStore.initialEventScrollIntoView(); if (thread && initialEvent?.isThreadRoot) { dis.dispatch({ action: Action.ShowThread, rootEvent: thread.rootEvent, initialEvent, - highlighted: RoomViewStore.instance.isInitialEventHighlighted(), - scroll_into_view: RoomViewStore.instance.initialEventScrollIntoView(), + highlighted: this.context.roomViewStore.isInitialEventHighlighted(), + scroll_into_view: this.context.roomViewStore.initialEventScrollIntoView(), }); } } @@ -657,7 +650,7 @@ export class RoomView extends React.Component { if (!initial && this.state.shouldPeek && !newState.shouldPeek) { // Stop peeking because we have joined this room now - this.context.stopPeeking(); + this.context.client.stopPeeking(); } // Temporary logging to diagnose https://github.com/vector-im/element-web/issues/4307 @@ -674,7 +667,7 @@ export class RoomView extends React.Component { // NB: This does assume that the roomID will not change for the lifetime of // the RoomView instance if (initial) { - newState.room = this.context.getRoom(newState.roomId); + newState.room = this.context.client.getRoom(newState.roomId); if (newState.room) { newState.showApps = this.shouldShowApps(newState.room); this.onRoomLoaded(newState.room); @@ -784,7 +777,7 @@ export class RoomView extends React.Component { peekLoading: true, isPeeking: true, // this will change to false if peeking fails }); - this.context.peekInRoom(roomId).then((room) => { + this.context.client.peekInRoom(roomId).then((room) => { if (this.unmounted) { return; } @@ -817,7 +810,7 @@ export class RoomView extends React.Component { }); } else if (room) { // Stop peeking because we have joined this room previously - this.context.stopPeeking(); + this.context.client.stopPeeking(); this.setState({ isPeeking: false }); } } @@ -835,7 +828,7 @@ export class RoomView extends React.Component { // Otherwise (in case the user set hideWidgetDrawer by clicking the button) follow the parameter. const isManuallyShown = hideWidgetDrawer ? hideWidgetDrawer === "false": true; - const widgets = WidgetLayoutStore.instance.getContainerWidgets(room, Container.Top); + const widgets = this.context.widgetLayoutStore.getContainerWidgets(room, Container.Top); return isManuallyShown && widgets.length > 0; } @@ -848,7 +841,7 @@ export class RoomView extends React.Component { callState: callState, }); - LegacyCallHandler.instance.on(LegacyCallHandlerEvent.CallState, this.onCallState); + this.context.legacyCallHandler.on(LegacyCallHandlerEvent.CallState, this.onCallState); window.addEventListener('beforeunload', this.onPageUnload); } @@ -885,7 +878,7 @@ export class RoomView extends React.Component { // (We could use isMounted, but facebook have deprecated that.) this.unmounted = true; - LegacyCallHandler.instance.removeListener(LegacyCallHandlerEvent.CallState, this.onCallState); + this.context.legacyCallHandler.removeListener(LegacyCallHandlerEvent.CallState, this.onCallState); // update the scroll map before we get unmounted if (this.state.roomId) { @@ -893,47 +886,47 @@ export class RoomView extends React.Component { } if (this.state.shouldPeek) { - this.context.stopPeeking(); + this.context.client.stopPeeking(); } // stop tracking room changes to format permalinks this.stopAllPermalinkCreators(); dis.unregister(this.dispatcherRef); - if (this.context) { - this.context.removeListener(ClientEvent.Room, this.onRoom); - this.context.removeListener(RoomEvent.Timeline, this.onRoomTimeline); - this.context.removeListener(RoomEvent.TimelineReset, this.onRoomTimelineReset); - this.context.removeListener(RoomEvent.Name, this.onRoomName); - this.context.removeListener(RoomStateEvent.Events, this.onRoomStateEvents); - this.context.removeListener(RoomEvent.MyMembership, this.onMyMembership); - this.context.removeListener(RoomStateEvent.Update, this.onRoomStateUpdate); - this.context.removeListener(CryptoEvent.KeyBackupStatus, this.onKeyBackupStatus); - this.context.removeListener(CryptoEvent.DeviceVerificationChanged, this.onDeviceVerificationChanged); - this.context.removeListener(CryptoEvent.UserTrustStatusChanged, this.onUserVerificationChanged); - this.context.removeListener(CryptoEvent.KeysChanged, this.onCrossSigningKeysChanged); - this.context.removeListener(MatrixEventEvent.Decrypted, this.onEventDecrypted); + if (this.context.client) { + this.context.client.removeListener(ClientEvent.Room, this.onRoom); + this.context.client.removeListener(RoomEvent.Timeline, this.onRoomTimeline); + this.context.client.removeListener(RoomEvent.TimelineReset, this.onRoomTimelineReset); + this.context.client.removeListener(RoomEvent.Name, this.onRoomName); + this.context.client.removeListener(RoomStateEvent.Events, this.onRoomStateEvents); + this.context.client.removeListener(RoomEvent.MyMembership, this.onMyMembership); + this.context.client.removeListener(RoomStateEvent.Update, this.onRoomStateUpdate); + this.context.client.removeListener(CryptoEvent.KeyBackupStatus, this.onKeyBackupStatus); + this.context.client.removeListener(CryptoEvent.DeviceVerificationChanged, this.onDeviceVerificationChanged); + this.context.client.removeListener(CryptoEvent.UserTrustStatusChanged, this.onUserVerificationChanged); + this.context.client.removeListener(CryptoEvent.KeysChanged, this.onCrossSigningKeysChanged); + this.context.client.removeListener(MatrixEventEvent.Decrypted, this.onEventDecrypted); } window.removeEventListener('beforeunload', this.onPageUnload); - RoomViewStore.instance.off(UPDATE_EVENT, this.onRoomViewStoreUpdate); + this.context.roomViewStore.off(UPDATE_EVENT, this.onRoomViewStoreUpdate); - RightPanelStore.instance.off(UPDATE_EVENT, this.onRightPanelStoreUpdate); + this.context.rightPanelStore.off(UPDATE_EVENT, this.onRightPanelStoreUpdate); WidgetEchoStore.removeListener(UPDATE_EVENT, this.onWidgetEchoStoreUpdate); - WidgetStore.instance.removeListener(UPDATE_EVENT, this.onWidgetStoreUpdate); + this.context.widgetStore.removeListener(UPDATE_EVENT, this.onWidgetStoreUpdate); this.props.resizeNotifier.off("isResizing", this.onIsResizing); if (this.state.room) { - WidgetLayoutStore.instance.off( + this.context.widgetLayoutStore.off( WidgetLayoutStore.emissionForRoom(this.state.room), this.onWidgetLayoutChange, ); } CallStore.instance.off(CallStoreEvent.ActiveCalls, this.onActiveCalls); - LegacyCallHandler.instance.off(LegacyCallHandlerEvent.CallState, this.onCallState); + this.context.legacyCallHandler.off(LegacyCallHandlerEvent.CallState, this.onCallState); // cancel any pending calls to the throttled updated this.updateRoomMembers.cancel(); @@ -944,13 +937,13 @@ export class RoomView extends React.Component { if (this.viewsLocalRoom) { // clean up if this was a local room - this.props.mxClient.store.removeRoom(this.state.room.roomId); + this.context.client.store.removeRoom(this.state.room.roomId); } } private onRightPanelStoreUpdate = () => { this.setState({ - showRightPanel: RightPanelStore.instance.isOpenForRoom(this.state.roomId), + showRightPanel: this.context.rightPanelStore.isOpenForRoom(this.state.roomId), }); }; @@ -1017,7 +1010,7 @@ export class RoomView extends React.Component { break; case 'picture_snapshot': ContentMessages.sharedInstance().sendContentListToRoom( - [payload.file], this.state.room.roomId, null, this.context); + [payload.file], this.state.room.roomId, null, this.context.client); break; case 'notifier_enabled': case Action.UploadStarted: @@ -1043,7 +1036,7 @@ export class RoomView extends React.Component { case 'MatrixActions.sync': if (!this.state.matrixClientIsReady) { this.setState({ - matrixClientIsReady: this.context?.isInitialSyncComplete(), + matrixClientIsReady: this.context.client?.isInitialSyncComplete(), }, () => { // send another "initial" RVS update to trigger peeking if needed this.onRoomViewStoreUpdate(true); @@ -1112,7 +1105,7 @@ export class RoomView extends React.Component { private onLocalRoomEvent(roomId: string) { if (roomId !== this.state.room.roomId) return; - createRoomFromLocalRoom(this.props.mxClient, this.state.room as LocalRoom); + createRoomFromLocalRoom(this.context.client, this.state.room as LocalRoom); } private onRoomTimeline = (ev: MatrixEvent, room: Room | null, toStartOfTimeline: boolean, removed, data) => { @@ -1145,7 +1138,7 @@ export class RoomView extends React.Component { this.handleEffects(ev); } - if (ev.getSender() !== this.context.credentials.userId) { + if (ev.getSender() !== this.context.client.credentials.userId) { // update unread count when scrolled up if (!this.state.searchResults && this.state.atEndOfLiveTimeline) { // no change @@ -1165,7 +1158,7 @@ export class RoomView extends React.Component { }; private handleEffects = (ev: MatrixEvent) => { - const notifState = RoomNotificationStateStore.instance.getRoomState(this.state.room); + const notifState = this.context.roomNotificationStateStore.getRoomState(this.state.room); if (!notifState.isUnread) return; CHAT_EFFECTS.forEach(effect => { @@ -1202,7 +1195,7 @@ export class RoomView extends React.Component { private onRoomLoaded = (room: Room) => { if (this.unmounted) return; // Attach a widget store listener only when we get a room - WidgetLayoutStore.instance.on(WidgetLayoutStore.emissionForRoom(room), this.onWidgetLayoutChange); + this.context.widgetLayoutStore.on(WidgetLayoutStore.emissionForRoom(room), this.onWidgetLayoutChange); this.calculatePeekRules(room); this.updatePreviewUrlVisibility(room); @@ -1214,10 +1207,10 @@ export class RoomView extends React.Component { if ( this.getMainSplitContentType(room) !== MainSplitContentType.Timeline - && RoomNotificationStateStore.instance.getRoomState(room).isUnread + && this.context.roomNotificationStateStore.getRoomState(room).isUnread ) { // Automatically open the chat panel to make unread messages easier to discover - RightPanelStore.instance.setCard({ phase: RightPanelPhases.Timeline }, true, room.roomId); + this.context.rightPanelStore.setCard({ phase: RightPanelPhases.Timeline }, true, room.roomId); } this.setState({ @@ -1244,7 +1237,7 @@ export class RoomView extends React.Component { private async loadMembersIfJoined(room: Room) { // lazy load members if enabled - if (this.context.hasLazyLoadMembersEnabled()) { + if (this.context.client.hasLazyLoadMembersEnabled()) { if (room && room.getMyMembership() === 'join') { try { await room.loadMembersIfNeeded(); @@ -1270,7 +1263,7 @@ export class RoomView extends React.Component { private updatePreviewUrlVisibility({ roomId }: Room) { // URL Previews in E2EE rooms can be a privacy leak so use a different setting which is per-room explicit - const key = this.context.isRoomEncrypted(roomId) ? 'urlPreviewsEnabled_e2ee' : 'urlPreviewsEnabled'; + const key = this.context.client.isRoomEncrypted(roomId) ? 'urlPreviewsEnabled_e2ee' : 'urlPreviewsEnabled'; this.setState({ showUrlPreview: SettingsStore.getValue(key, roomId), }); @@ -1283,7 +1276,7 @@ export class RoomView extends React.Component { // Detach the listener if the room is changing for some reason if (this.state.room) { - WidgetLayoutStore.instance.off( + this.context.widgetLayoutStore.off( WidgetLayoutStore.emissionForRoom(this.state.room), this.onWidgetLayoutChange, ); @@ -1320,15 +1313,15 @@ export class RoomView extends React.Component { }; private async updateE2EStatus(room: Room) { - if (!this.context.isRoomEncrypted(room.roomId)) return; + if (!this.context.client.isRoomEncrypted(room.roomId)) return; // If crypto is not currently enabled, we aren't tracking devices at all, // so we don't know what the answer is. Let's error on the safe side and show // a warning for this case. let e2eStatus = E2EStatus.Warning; - if (this.context.isCryptoEnabled()) { + if (this.context.client.isCryptoEnabled()) { /* At this point, the user has encryption on and cross-signing on */ - e2eStatus = await shieldStatusForRoom(this.context, room); + e2eStatus = await shieldStatusForRoom(this.context.client, room); } if (this.unmounted) return; @@ -1374,19 +1367,17 @@ export class RoomView extends React.Component { private updatePermissions(room: Room) { if (room) { - const me = this.context.getUserId(); + const me = this.context.client.getUserId(); const canReact = ( room.getMyMembership() === "join" && room.currentState.maySendEvent(EventType.Reaction, me) ); const canSendMessages = room.maySendMessage(); const canSelfRedact = room.currentState.maySendEvent(EventType.RoomRedaction, me); - const canSendVoiceBroadcasts = room.currentState.maySendEvent(VoiceBroadcastInfoEventType, me); this.setState({ canReact, canSendMessages, - canSendVoiceBroadcasts, canSelfRedact, }); } @@ -1442,7 +1433,7 @@ export class RoomView extends React.Component { private onJoinButtonClicked = () => { // If the user is a ROU, allow them to transition to a PWLU - if (this.context?.isGuest()) { + if (this.context.client?.isGuest()) { // Join this room once the user has registered and logged in // (If we failed to peek, we may not have a valid room object.) dis.dispatch>({ @@ -1499,13 +1490,13 @@ export class RoomView extends React.Component { }; private injectSticker(url: string, info: object, text: string, threadId: string | null) { - if (this.context.isGuest()) { + if (this.context.client.isGuest()) { dis.dispatch({ action: 'require_registration' }); return; } ContentMessages.sharedInstance() - .sendStickerContentToRoom(url, this.state.room.roomId, threadId, info, text, this.context) + .sendStickerContentToRoom(url, this.state.room.roomId, threadId, info, text, this.context.client) .then(undefined, (error) => { if (error.name === "UnknownDeviceError") { // Let the staus bar handle this @@ -1578,7 +1569,7 @@ export class RoomView extends React.Component { return b.length - a.length; }); - if (this.context.supportsExperimentalThreads()) { + if (this.context.client.supportsExperimentalThreads()) { // Process all thread roots returned in this batch of search results // XXX: This won't work for results coming from Seshat which won't include the bundled relationship for (const result of results.results) { @@ -1586,7 +1577,7 @@ export class RoomView extends React.Component { const bundledRelationship = event .getServerAggregatedRelation(THREAD_RELATION_TYPE.name); if (!bundledRelationship || event.getThread()) continue; - const room = this.context.getRoom(event.getRoomId()); + const room = this.context.client.getRoom(event.getRoomId()); const thread = room.findThreadForEvent(event); if (thread) { event.setThread(thread); @@ -1658,7 +1649,7 @@ export class RoomView extends React.Component { const mxEv = result.context.getEvent(); const roomId = mxEv.getRoomId(); - const room = this.context.getRoom(roomId); + const room = this.context.client.getRoom(roomId); if (!room) { // if we do not have the room in js-sdk stores then hide it as we cannot easily show it // As per the spec, an all rooms search can create this condition, @@ -1715,7 +1706,7 @@ export class RoomView extends React.Component { this.setState({ rejecting: true, }); - this.context.leave(this.state.roomId).then(() => { + this.context.client.leave(this.state.roomId).then(() => { dis.dispatch({ action: Action.ViewHomePage }); this.setState({ rejecting: false, @@ -1742,13 +1733,13 @@ export class RoomView extends React.Component { }); try { - const myMember = this.state.room.getMember(this.context.getUserId()); + const myMember = this.state.room.getMember(this.context.client.getUserId()); const inviteEvent = myMember.events.member; - const ignoredUsers = this.context.getIgnoredUsers(); + const ignoredUsers = this.context.client.getIgnoredUsers(); ignoredUsers.push(inviteEvent.getSender()); // de-duped internally in the js-sdk - await this.context.setIgnoredUsers(ignoredUsers); + await this.context.client.setIgnoredUsers(ignoredUsers); - await this.context.leave(this.state.roomId); + await this.context.client.leave(this.state.roomId); dis.dispatch({ action: Action.ViewHomePage }); this.setState({ rejecting: false, @@ -1911,7 +1902,7 @@ export class RoomView extends React.Component { if (!this.state.room) { return null; } - return LegacyCallHandler.instance.getCallForRoom(this.state.room.roomId); + return this.context.legacyCallHandler.getCallForRoom(this.state.room.roomId); } // this has to be a proper method rather than an unnamed function, @@ -1924,7 +1915,7 @@ export class RoomView extends React.Component { const createEvent = this.state.room.currentState.getStateEvents(EventType.RoomCreate, ""); if (!createEvent || !createEvent.getContent()['predecessor']) return null; - return this.context.getRoom(createEvent.getContent()['predecessor']['room_id']); + return this.context.client.getRoom(createEvent.getContent()['predecessor']['room_id']); } getHiddenHighlightCount() { @@ -1953,7 +1944,7 @@ export class RoomView extends React.Component { Array.from(dataTransfer.files), this.state.room?.roomId ?? this.state.roomId, null, - this.context, + this.context.client, TimelineRenderingType.Room, ); @@ -1970,7 +1961,7 @@ export class RoomView extends React.Component { } private renderLocalRoomCreateLoader(): ReactElement { - const names = this.state.room.getDefaultRoomName(this.props.mxClient.getUserId()); + const names = this.state.room.getDefaultRoomName(this.context.client.getUserId()); return { ); } else { - const myUserId = this.context.credentials.userId; + const myUserId = this.context.client.credentials.userId; const myMember = this.state.room.getMember(myUserId); const inviteEvent = myMember ? myMember.events.member : null; let inviterName = _t("Unknown"); @@ -2162,7 +2153,7 @@ export class RoomView extends React.Component { const showRoomUpgradeBar = ( roomVersionRecommendation && roomVersionRecommendation.needsUpgrade && - this.state.room.userMayUpgradeRoom(this.context.credentials.userId) + this.state.room.userMayUpgradeRoom(this.context.client.credentials.userId) ); const hiddenHighlightCount = this.getHiddenHighlightCount(); @@ -2174,7 +2165,7 @@ export class RoomView extends React.Component { searchInProgress={this.state.searchInProgress} onCancelClick={this.onCancelSearchClick} onSearch={this.onSearch} - isRoomEncrypted={this.context.isRoomEncrypted(this.state.room.roomId)} + isRoomEncrypted={this.context.client.isRoomEncrypted(this.state.room.roomId)} />; } else if (showRoomUpgradeBar) { aux = ; @@ -2236,7 +2227,7 @@ export class RoomView extends React.Component { const auxPanel = ( @@ -2257,7 +2248,6 @@ export class RoomView extends React.Component { resizeNotifier={this.props.resizeNotifier} replyToEvent={this.state.replyToEvent} permalinkCreator={this.permalinkCreator} - showVoiceBroadcastButton={this.state.canSendVoiceBroadcasts} />; } @@ -2397,7 +2387,7 @@ export class RoomView extends React.Component { mainSplitBody = <> @@ -2451,7 +2441,7 @@ export class RoomView extends React.Component { onAppsClick = null; onForgetClick = null; onSearchClick = null; - if (this.state.room.canInvite(this.context.credentials.userId)) { + if (this.state.room.canInvite(this.context.client.credentials.userId)) { onInviteClick = this.onInviteClick; } viewingCall = true; @@ -2493,5 +2483,4 @@ export class RoomView extends React.Component { } } -const RoomViewWithMatrixClient = withMatrixClientHOC(RoomView); -export default RoomViewWithMatrixClient; +export default RoomView; diff --git a/src/components/structures/SpaceHierarchy.tsx b/src/components/structures/SpaceHierarchy.tsx index 7336dfeb0c..00ebfdacce 100644 --- a/src/components/structures/SpaceHierarchy.tsx +++ b/src/components/structures/SpaceHierarchy.tsx @@ -60,13 +60,13 @@ import MatrixClientContext from "../../contexts/MatrixClientContext"; import { useTypedEventEmitterState } from "../../hooks/useEventEmitter"; import { IOOBData } from "../../stores/ThreepidInviteStore"; import { awaitRoomDownSync } from "../../utils/RoomUpgrade"; -import { RoomViewStore } from "../../stores/RoomViewStore"; import { ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload"; import { JoinRoomReadyPayload } from "../../dispatcher/payloads/JoinRoomReadyPayload"; import { KeyBindingAction } from "../../accessibility/KeyboardShortcuts"; import { getKeyBindingsManager } from "../../KeyBindingsManager"; import { Alignment } from "../views/elements/Tooltip"; import { getTopic } from "../../hooks/room/useTopic"; +import { SdkContextClass } from "../../contexts/SDKContext"; interface IProps { space: Room; @@ -378,7 +378,7 @@ export const joinRoom = (cli: MatrixClient, hierarchy: RoomHierarchy, roomId: st metricsTrigger: "SpaceHierarchy", }); }, err => { - RoomViewStore.instance.showJoinRoomError(err, roomId); + SdkContextClass.instance.roomViewStore.showJoinRoomError(err, roomId); }); return prom; diff --git a/src/components/structures/ThreadView.tsx b/src/components/structures/ThreadView.tsx index 042b8b3b92..8350b5e734 100644 --- a/src/components/structures/ThreadView.tsx +++ b/src/components/structures/ThreadView.tsx @@ -16,7 +16,7 @@ limitations under the License. import React, { createRef, KeyboardEvent } from 'react'; import { Thread, THREAD_RELATION_TYPE, ThreadEvent } from 'matrix-js-sdk/src/models/thread'; -import { Room } from 'matrix-js-sdk/src/models/room'; +import { Room, RoomEvent } from 'matrix-js-sdk/src/models/room'; import { IEventRelation, MatrixEvent } from 'matrix-js-sdk/src/models/event'; import { TimelineWindow } from 'matrix-js-sdk/src/timeline-window'; import { Direction } from 'matrix-js-sdk/src/models/event-timeline'; @@ -51,10 +51,10 @@ import { KeyBindingAction } from "../../accessibility/KeyboardShortcuts"; import Measured from '../views/elements/Measured'; import PosthogTrackers from "../../PosthogTrackers"; import { ButtonEvent } from "../views/elements/AccessibleButton"; -import { RoomViewStore } from '../../stores/RoomViewStore'; import Spinner from "../views/elements/Spinner"; import { ComposerInsertPayload, ComposerType } from "../../dispatcher/payloads/ComposerInsertPayload"; import Heading from '../views/typography/Heading'; +import { SdkContextClass } from '../../contexts/SDKContext'; interface IProps { room: Room; @@ -70,6 +70,7 @@ interface IProps { interface IState { thread?: Thread; + lastReply?: MatrixEvent | null; layout: Layout; editState?: EditorStateTransfer; replyToEvent?: MatrixEvent; @@ -88,9 +89,16 @@ export default class ThreadView extends React.Component { constructor(props: IProps) { super(props); + const thread = this.props.room.getThread(this.props.mxEvent.getId()); + + this.setupThreadListeners(thread); this.state = { layout: SettingsStore.getValue("layout"), narrow: false, + thread, + lastReply: thread?.lastReply((ev: MatrixEvent) => { + return ev.isRelation(THREAD_RELATION_TYPE.name) && !ev.status; + }), }; this.layoutWatcherRef = SettingsStore.watchSetting("layout", null, (...[,,, value]) => @@ -99,6 +107,9 @@ export default class ThreadView extends React.Component { } public componentDidMount(): void { + if (this.state.thread) { + this.postThreadUpdate(this.state.thread); + } this.setupThread(this.props.mxEvent); this.dispatcherRef = dis.register(this.onAction); @@ -113,7 +124,7 @@ export default class ThreadView extends React.Component { room.removeListener(ThreadEvent.New, this.onNewThread); SettingsStore.unwatchSetting(this.layoutWatcherRef); - const hasRoomChanged = RoomViewStore.instance.getRoomId() !== roomId; + const hasRoomChanged = SdkContextClass.instance.roomViewStore.getRoomId() !== roomId; if (this.props.isInitialEventHighlighted && !hasRoomChanged) { dis.dispatch({ action: Action.ViewRoom, @@ -189,19 +200,49 @@ export default class ThreadView extends React.Component { } }; + private updateThreadRelation = (): void => { + this.setState({ + lastReply: this.threadLastReply, + }); + }; + + private get threadLastReply(): MatrixEvent | undefined { + return this.state.thread?.lastReply((ev: MatrixEvent) => { + return ev.isRelation(THREAD_RELATION_TYPE.name) && !ev.status; + }); + } + private updateThread = (thread?: Thread) => { - if (thread && this.state.thread !== thread) { + if (this.state.thread === thread) return; + + this.setupThreadListeners(thread, this.state.thread); + if (thread) { this.setState({ thread, - }, async () => { - thread.emit(ThreadEvent.ViewThread); - await thread.fetchInitialEvents(); - this.nextBatch = thread.liveTimeline.getPaginationToken(Direction.Backward); - this.timelinePanel.current?.refreshTimeline(); - }); + lastReply: this.threadLastReply, + }, async () => this.postThreadUpdate(thread)); } }; + private async postThreadUpdate(thread: Thread): Promise { + thread.emit(ThreadEvent.ViewThread); + await thread.fetchInitialEvents(); + this.updateThreadRelation(); + this.nextBatch = thread.liveTimeline.getPaginationToken(Direction.Backward); + this.timelinePanel.current?.refreshTimeline(); + } + + private setupThreadListeners(thread?: Thread | undefined, oldThread?: Thread | undefined): void { + if (oldThread) { + this.state.thread.off(ThreadEvent.NewReply, this.updateThreadRelation); + this.props.room.off(RoomEvent.LocalEchoUpdated, this.updateThreadRelation); + } + if (thread) { + thread.on(ThreadEvent.NewReply, this.updateThreadRelation); + this.props.room.on(RoomEvent.LocalEchoUpdated, this.updateThreadRelation); + } + } + private resetJumpToEvent = (event?: string): void => { if (this.props.initialEvent && this.props.initialEventScrollIntoView && event === this.props.initialEvent?.getId()) { @@ -242,14 +283,14 @@ export default class ThreadView extends React.Component { } }; - private nextBatch: string; + private nextBatch: string | undefined | null = null; private onPaginationRequest = async ( timelineWindow: TimelineWindow | null, direction = Direction.Backward, limit = 20, ): Promise => { - if (!Thread.hasServerSideSupport) { + if (!Thread.hasServerSideSupport && timelineWindow) { timelineWindow.extend(direction, limit); return true; } @@ -262,40 +303,50 @@ export default class ThreadView extends React.Component { opts.from = this.nextBatch; } - const { nextBatch } = await this.state.thread.fetchEvents(opts); - - this.nextBatch = nextBatch; + let nextBatch: string | null | undefined = null; + if (this.state.thread) { + const response = await this.state.thread.fetchEvents(opts); + nextBatch = response.nextBatch; + this.nextBatch = nextBatch; + } // Advances the marker on the TimelineWindow to define the correct // window of events to display on screen - timelineWindow.extend(direction, limit); + timelineWindow?.extend(direction, limit); return !!nextBatch; }; private onFileDrop = (dataTransfer: DataTransfer) => { - ContentMessages.sharedInstance().sendContentListToRoom( - Array.from(dataTransfer.files), - this.props.mxEvent.getRoomId(), - this.threadRelation, - MatrixClientPeg.get(), - TimelineRenderingType.Thread, - ); + const roomId = this.props.mxEvent.getRoomId(); + if (roomId) { + ContentMessages.sharedInstance().sendContentListToRoom( + Array.from(dataTransfer.files), + roomId, + this.threadRelation, + MatrixClientPeg.get(), + TimelineRenderingType.Thread, + ); + } else { + console.warn("Unknwon roomId for event", this.props.mxEvent); + } }; private get threadRelation(): IEventRelation { - const lastThreadReply = this.state.thread?.lastReply((ev: MatrixEvent) => { - return ev.isRelation(THREAD_RELATION_TYPE.name) && !ev.status; - }); - - return { + const relation = { "rel_type": THREAD_RELATION_TYPE.name, "event_id": this.state.thread?.id, "is_falling_back": true, - "m.in_reply_to": { - "event_id": lastThreadReply?.getId() ?? this.state.thread?.id, - }, }; + + const fallbackEventId = this.state.lastReply?.getId() ?? this.state.thread?.id; + if (fallbackEventId) { + relation["m.in_reply_to"] = { + "event_id": fallbackEventId, + }; + } + + return relation; } private renderThreadViewHeader = (): JSX.Element => { @@ -314,7 +365,7 @@ export default class ThreadView extends React.Component { const threadRelation = this.threadRelation; - let timeline: JSX.Element; + let timeline: JSX.Element | null; if (this.state.thread) { if (this.props.initialEvent && this.props.initialEvent.getRoomId() !== this.state.thread.roomId) { logger.warn("ThreadView attempting to render TimelinePanel with mismatched initialEvent", diff --git a/src/components/views/auth/LoginWithQR.tsx b/src/components/views/auth/LoginWithQR.tsx new file mode 100644 index 0000000000..3d3f76be95 --- /dev/null +++ b/src/components/views/auth/LoginWithQR.tsx @@ -0,0 +1,396 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from 'react'; +import { MSC3906Rendezvous, MSC3906RendezvousPayload, RendezvousFailureReason } from 'matrix-js-sdk/src/rendezvous'; +import { MSC3886SimpleHttpRendezvousTransport } from 'matrix-js-sdk/src/rendezvous/transports'; +import { MSC3903ECDHPayload, MSC3903ECDHv1RendezvousChannel } from 'matrix-js-sdk/src/rendezvous/channels'; +import { logger } from 'matrix-js-sdk/src/logger'; +import { MatrixClient } from 'matrix-js-sdk/src/client'; + +import { _t } from "../../../languageHandler"; +import AccessibleButton from '../elements/AccessibleButton'; +import QRCode from '../elements/QRCode'; +import Spinner from '../elements/Spinner'; +import { Icon as BackButtonIcon } from "../../../../res/img/element-icons/back.svg"; +import { Icon as DevicesIcon } from "../../../../res/img/element-icons/devices.svg"; +import { Icon as WarningBadge } from "../../../../res/img/element-icons/warning-badge.svg"; +import { Icon as InfoIcon } from "../../../../res/img/element-icons/i.svg"; +import { wrapRequestWithDialog } from '../../../utils/UserInteractiveAuth'; + +/** + * The intention of this enum is to have a mode that scans a QR code instead of generating one. + */ +export enum Mode { + /** + * A QR code with be generated and shown + */ + Show = "show", +} + +enum Phase { + Loading, + ShowingQR, + Connecting, + Connected, + WaitingForDevice, + Verifying, + Error, +} + +interface IProps { + client: MatrixClient; + mode: Mode; + onFinished(...args: any): void; +} + +interface IState { + phase: Phase; + rendezvous?: MSC3906Rendezvous; + confirmationDigits?: string; + failureReason?: RendezvousFailureReason; + mediaPermissionError?: boolean; +} + +/** + * A component that allows sign in and E2EE set up with a QR code. + * + * It implements both `login.start` and `login-reciprocate` capabilities as well as both scanning and showing QR codes. + * + * This uses the unstable feature of MSC3906: https://github.com/matrix-org/matrix-spec-proposals/pull/3906 + */ +export default class LoginWithQR extends React.Component { + public constructor(props) { + super(props); + + this.state = { + phase: Phase.Loading, + }; + } + + public componentDidMount(): void { + this.updateMode(this.props.mode).then(() => {}); + } + + public componentDidUpdate(prevProps: Readonly): void { + if (prevProps.mode !== this.props.mode) { + this.updateMode(this.props.mode).then(() => {}); + } + } + + private async updateMode(mode: Mode) { + this.setState({ phase: Phase.Loading }); + if (this.state.rendezvous) { + this.state.rendezvous.onFailure = undefined; + await this.state.rendezvous.cancel(RendezvousFailureReason.UserCancelled); + this.setState({ rendezvous: undefined }); + } + if (mode === Mode.Show) { + await this.generateCode(); + } + } + + public componentWillUnmount(): void { + if (this.state.rendezvous) { + // eslint-disable-next-line react/no-direct-mutation-state + this.state.rendezvous.onFailure = undefined; + // calling cancel will call close() as well to clean up the resources + this.state.rendezvous.cancel(RendezvousFailureReason.UserCancelled).then(() => {}); + } + } + + private approveLogin = async (): Promise => { + if (!this.state.rendezvous) { + throw new Error('Rendezvous not found'); + } + this.setState({ phase: Phase.Loading }); + + try { + logger.info("Requesting login token"); + + const { login_token: loginToken } = await wrapRequestWithDialog(this.props.client.requestLoginToken, { + matrixClient: this.props.client, + title: _t("Sign in new device"), + })(); + + this.setState({ phase: Phase.WaitingForDevice }); + + const newDeviceId = await this.state.rendezvous.approveLoginOnExistingDevice(loginToken); + if (!newDeviceId) { + // user denied + return; + } + if (!this.props.client.crypto) { + // no E2EE to set up + this.props.onFinished(true); + return; + } + await this.state.rendezvous.verifyNewDeviceOnExistingDevice(); + this.props.onFinished(true); + } catch (e) { + logger.error('Error whilst approving sign in', e); + this.setState({ phase: Phase.Error, failureReason: RendezvousFailureReason.Unknown }); + } + }; + + private generateCode = async () => { + let rendezvous: MSC3906Rendezvous; + try { + const transport = new MSC3886SimpleHttpRendezvousTransport({ + onFailure: this.onFailure, + client: this.props.client, + }); + + const channel = new MSC3903ECDHv1RendezvousChannel( + transport, undefined, this.onFailure, + ); + + rendezvous = new MSC3906Rendezvous(channel, this.props.client, this.onFailure); + + await rendezvous.generateCode(); + this.setState({ + phase: Phase.ShowingQR, + rendezvous, + failureReason: undefined, + }); + } catch (e) { + logger.error('Error whilst generating QR code', e); + this.setState({ phase: Phase.Error, failureReason: RendezvousFailureReason.HomeserverLacksSupport }); + return; + } + + try { + const confirmationDigits = await rendezvous.startAfterShowingCode(); + this.setState({ phase: Phase.Connected, confirmationDigits }); + } catch (e) { + logger.error('Error whilst doing QR login', e); + // only set to error phase if it hasn't already been set by onFailure or similar + if (this.state.phase !== Phase.Error) { + this.setState({ phase: Phase.Error, failureReason: RendezvousFailureReason.Unknown }); + } + } + }; + + private onFailure = (reason: RendezvousFailureReason) => { + logger.info(`Rendezvous failed: ${reason}`); + this.setState({ phase: Phase.Error, failureReason: reason }); + }; + + public reset() { + this.setState({ + rendezvous: undefined, + confirmationDigits: undefined, + failureReason: undefined, + }); + } + + private cancelClicked = async (e: React.FormEvent) => { + e.preventDefault(); + await this.state.rendezvous?.cancel(RendezvousFailureReason.UserCancelled); + this.reset(); + this.props.onFinished(false); + }; + + private declineClicked = async (e: React.FormEvent) => { + e.preventDefault(); + await this.state.rendezvous?.declineLoginOnExistingDevice(); + this.reset(); + this.props.onFinished(false); + }; + + private tryAgainClicked = async (e: React.FormEvent) => { + e.preventDefault(); + this.reset(); + await this.updateMode(this.props.mode); + }; + + private onBackClick = async () => { + await this.state.rendezvous?.cancel(RendezvousFailureReason.UserCancelled); + + this.props.onFinished(false); + }; + + private cancelButton = () => + { _t("Cancel") } + ; + + private simpleSpinner = (description?: string): JSX.Element => { + return
+
+ + { description &&

{ description }

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

{ cancellationMessage }

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

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

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

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

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

{ titleIcon }{ title }

+
+
+ { main } +
+
+ { buttons } +
+
+ ); + } +} diff --git a/src/components/views/beacon/RoomCallBanner.tsx b/src/components/views/beacon/RoomCallBanner.tsx new file mode 100644 index 0000000000..6085fe141b --- /dev/null +++ b/src/components/views/beacon/RoomCallBanner.tsx @@ -0,0 +1,131 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React, { useCallback } from "react"; +import { MatrixEvent, Room } from "matrix-js-sdk/src/matrix"; + +import { _t } from "../../../languageHandler"; +import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton"; +import dispatcher, { defaultDispatcher } from "../../../dispatcher/dispatcher"; +import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; +import { Action } from "../../../dispatcher/actions"; +import { Call, ConnectionState, ElementCall } from "../../../models/Call"; +import { useCall } from "../../../hooks/useCall"; +import { useEventEmitterState } from "../../../hooks/useEventEmitter"; +import { + OwnBeaconStore, + OwnBeaconStoreEvent, +} from "../../../stores/OwnBeaconStore"; +import { CallDurationFromEvent } from "../voip/CallDuration"; +import { SdkContextClass } from "../../../contexts/SDKContext"; + +interface RoomCallBannerProps { + roomId: Room["roomId"]; + call: Call; +} + +const RoomCallBannerInner: React.FC = ({ + roomId, + call, +}) => { + const callEvent: MatrixEvent | null = (call as ElementCall)?.groupCall; + + const connect = useCallback( + (ev: ButtonEvent) => { + ev.preventDefault(); + defaultDispatcher.dispatch({ + action: Action.ViewRoom, + room_id: roomId, + view_call: true, + metricsTrigger: undefined, + }); + }, + [roomId], + ); + + const onClick = useCallback(() => { + dispatcher.dispatch({ + action: Action.ViewRoom, + room_id: roomId, + metricsTrigger: undefined, + event_id: callEvent.getId(), + scroll_into_view: true, + highlighted: true, + }); + }, [callEvent, roomId]); + + return ( +
+
+ { _t("Video call") } + +
+ + + { _t("Join") } + +
+ ); +}; + +interface Props { + roomId: Room["roomId"]; +} + +const RoomCallBanner: React.FC = ({ roomId }) => { + const call = useCall(roomId); + + // this section is to check if we have a live location share. If so, we dont show the call banner + const isMonitoringLiveLocation = useEventEmitterState( + OwnBeaconStore.instance, + OwnBeaconStoreEvent.MonitoringLivePosition, + () => OwnBeaconStore.instance.isMonitoringLiveLocation, + ); + + const liveBeaconIds = useEventEmitterState( + OwnBeaconStore.instance, + OwnBeaconStoreEvent.LivenessChange, + () => OwnBeaconStore.instance.getLiveBeaconIds(roomId), + ); + + if (isMonitoringLiveLocation && liveBeaconIds.length) { + return null; + } + + // Check if the call is already showing. No banner is needed in this case. + if (SdkContextClass.instance.roomViewStore.isViewingCall()) { + return null; + } + + // Split into outer/inner to avoid watching various parts if there is no call + if (call) { + // No banner if the call is connected (or connecting/disconnecting) + if (call.connectionState !== ConnectionState.Disconnected) return null; + + return ; + } + return null; +}; + +export default RoomCallBanner; diff --git a/src/components/views/beacon/RoomLiveShareWarning.tsx b/src/components/views/beacon/RoomLiveShareWarning.tsx index f5b5c1a720..ca94ea98f3 100644 --- a/src/components/views/beacon/RoomLiveShareWarning.tsx +++ b/src/components/views/beacon/RoomLiveShareWarning.tsx @@ -141,6 +141,7 @@ const RoomLiveShareWarning: React.FC = ({ roomId }) => { ); if (!isMonitoringLiveLocation || !liveBeaconIds.length) { + // This logic is entangled with the RoomCallBanner-test's. The tests need updating if this logic changes. return null; } diff --git a/src/components/views/context_menus/RoomContextMenu.tsx b/src/components/views/context_menus/RoomContextMenu.tsx index b9923d9278..aadfd2d268 100644 --- a/src/components/views/context_menus/RoomContextMenu.tsx +++ b/src/components/views/context_menus/RoomContextMenu.tsx @@ -37,7 +37,6 @@ import Modal from "../../../Modal"; import ExportDialog from "../dialogs/ExportDialog"; import { useFeatureEnabled } from "../../../hooks/useSettings"; import { usePinnedEvents } from "../right_panel/PinnedMessagesCard"; -import { RoomViewStore } from "../../../stores/RoomViewStore"; import { RightPanelPhases } from '../../../stores/right-panel/RightPanelStorePhases'; import { ROOM_NOTIFICATIONS_TAB } from "../dialogs/RoomSettingsDialog"; import { useEventEmitterState } from "../../../hooks/useEventEmitter"; @@ -50,6 +49,7 @@ import { getKeyBindingsManager } from "../../../KeyBindingsManager"; import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts"; import SettingsStore from "../../../settings/SettingsStore"; import DevtoolsDialog from "../dialogs/DevtoolsDialog"; +import { SdkContextClass } from "../../../contexts/SDKContext"; interface IProps extends IContextMenuProps { room: Room; @@ -332,7 +332,7 @@ const RoomContextMenu = ({ room, onFinished, ...props }: IProps) => { }; const ensureViewingRoom = (ev: ButtonEvent) => { - if (RoomViewStore.instance.getRoomId() === room.roomId) return; + if (SdkContextClass.instance.roomViewStore.getRoomId() === room.roomId) return; dis.dispatch({ action: Action.ViewRoom, room_id: room.roomId, @@ -377,7 +377,7 @@ const RoomContextMenu = ({ room, onFinished, ...props }: IProps) => { ev.stopPropagation(); Modal.createDialog(DevtoolsDialog, { - roomId: RoomViewStore.instance.getRoomId(), + roomId: SdkContextClass.instance.roomViewStore.getRoomId(), }, "mx_DevtoolsDialog_wrapper"); onFinished(); }} diff --git a/src/components/views/context_menus/ThreadListContextMenu.tsx b/src/components/views/context_menus/ThreadListContextMenu.tsx index 73fa52ef3c..3740327ca5 100644 --- a/src/components/views/context_menus/ThreadListContextMenu.tsx +++ b/src/components/views/context_menus/ThreadListContextMenu.tsx @@ -29,9 +29,9 @@ import { WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; -interface IProps { +export interface ThreadListContextMenuProps { mxEvent: MatrixEvent; - permalinkCreator: RoomPermalinkCreator; + permalinkCreator?: RoomPermalinkCreator; onMenuToggle?: (open: boolean) => void; } @@ -43,7 +43,7 @@ const contextMenuBelow = (elementRect: DOMRect) => { return { left, top, chevronFace }; }; -const ThreadListContextMenu: React.FC = ({ +const ThreadListContextMenu: React.FC = ({ mxEvent, permalinkCreator, onMenuToggle, @@ -64,12 +64,14 @@ const ThreadListContextMenu: React.FC = ({ closeThreadOptions(); }, [mxEvent, closeThreadOptions]); - const copyLinkToThread = useCallback(async (evt: ButtonEvent) => { - evt.preventDefault(); - evt.stopPropagation(); - const matrixToUrl = permalinkCreator.forEvent(mxEvent.getId()); - await copyPlaintext(matrixToUrl); - closeThreadOptions(); + const copyLinkToThread = useCallback(async (evt: ButtonEvent | undefined) => { + if (permalinkCreator) { + evt?.preventDefault(); + evt?.stopPropagation(); + const matrixToUrl = permalinkCreator.forEvent(mxEvent.getId()); + await copyPlaintext(matrixToUrl); + closeThreadOptions(); + } }, [mxEvent, closeThreadOptions, permalinkCreator]); useEffect(() => { @@ -87,6 +89,7 @@ const ThreadListContextMenu: React.FC = ({ title={_t("Thread options")} isExpanded={menuDisplayed} inputRef={button} + data-testid="threadlist-dropdown-button" /> { menuDisplayed && ( = ({ label={_t("View in room")} iconClassName="mx_ThreadPanel_viewInRoom" /> } - copyLinkToThread(e)} - label={_t("Copy link to thread")} - iconClassName="mx_ThreadPanel_copyLinkToThread" - /> + { permalinkCreator && + copyLinkToThread(e)} + label={_t("Copy link to thread")} + iconClassName="mx_ThreadPanel_copyLinkToThread" + /> + } ) } ; diff --git a/src/components/views/dialogs/InteractiveAuthDialog.tsx b/src/components/views/dialogs/InteractiveAuthDialog.tsx index 6f10790811..5d8fc2f952 100644 --- a/src/components/views/dialogs/InteractiveAuthDialog.tsx +++ b/src/components/views/dialogs/InteractiveAuthDialog.tsx @@ -38,7 +38,7 @@ interface IDialogAesthetics { }; } -interface IProps extends IDialogProps { +export interface InteractiveAuthDialogProps extends IDialogProps { // matrix client to use for UI auth requests matrixClient: MatrixClient; @@ -82,8 +82,8 @@ interface IState { uiaStagePhase: number | string; } -export default class InteractiveAuthDialog extends React.Component { - constructor(props: IProps) { +export default class InteractiveAuthDialog extends React.Component { + constructor(props: InteractiveAuthDialogProps) { super(props); this.state = { diff --git a/src/components/views/dialogs/WidgetOpenIDPermissionsDialog.tsx b/src/components/views/dialogs/WidgetOpenIDPermissionsDialog.tsx index 68c2991ed8..2d2d638af9 100644 --- a/src/components/views/dialogs/WidgetOpenIDPermissionsDialog.tsx +++ b/src/components/views/dialogs/WidgetOpenIDPermissionsDialog.tsx @@ -21,10 +21,11 @@ import { logger } from "matrix-js-sdk/src/logger"; import { _t } from "../../../languageHandler"; import LabelledToggleSwitch from "../elements/LabelledToggleSwitch"; -import { OIDCState, WidgetPermissionStore } from "../../../stores/widgets/WidgetPermissionStore"; +import { OIDCState } from "../../../stores/widgets/WidgetPermissionStore"; import { IDialogProps } from "./IDialogProps"; import BaseDialog from "./BaseDialog"; import DialogButtons from "../elements/DialogButtons"; +import { SdkContextClass } from '../../../contexts/SDKContext'; interface IProps extends IDialogProps { widget: Widget; @@ -57,7 +58,7 @@ export default class WidgetOpenIDPermissionsDialog extends React.PureComponent = ({ initialText = "", initialFilter = n
{ BreadcrumbsStore.instance.rooms - .filter(r => r.roomId !== RoomViewStore.instance.getRoomId()) + .filter(r => r.roomId !== SdkContextClass.instance.roomViewStore.getRoomId()) .map(room => ( { ); if (isActiveWidget) { // We just left the room that the active widget was from. - if (this.props.room && RoomViewStore.instance.getRoomId() !== this.props.room.roomId) { + if (this.props.room && SdkContextClass.instance.roomViewStore.getRoomId() !== this.props.room.roomId) { // If we are not actively looking at the room then destroy this widget entirely. this.endWidgetActions(); } else if (WidgetType.JITSI.matches(this.props.app.type)) { diff --git a/src/components/views/elements/Spinner.tsx b/src/components/views/elements/Spinner.tsx index 410d1b69cb..8677884c33 100644 --- a/src/components/views/elements/Spinner.tsx +++ b/src/components/views/elements/Spinner.tsx @@ -40,6 +40,7 @@ export default class Spinner extends React.PureComponent { style={{ width: w, height: h }} aria-label={_t("Loading...")} role="progressbar" + data-testid="spinner" />
); diff --git a/src/components/views/elements/Tooltip.tsx b/src/components/views/elements/Tooltip.tsx index ab27f4f9d8..12013d58fc 100644 --- a/src/components/views/elements/Tooltip.tsx +++ b/src/components/views/elements/Tooltip.tsx @@ -57,7 +57,7 @@ type State = Partial { private static container: HTMLElement; - private parent: Element; + private parent: Element | null = null; // XXX: This is because some components (Field) are unable to `import` the Tooltip class, // so we expose the Alignment options off of us statically. @@ -87,7 +87,7 @@ export default class Tooltip extends React.PureComponent { capture: true, }); - this.parent = ReactDOM.findDOMNode(this).parentNode as Element; + this.parent = ReactDOM.findDOMNode(this)?.parentNode as Element ?? null; this.updatePosition(); } @@ -109,7 +109,7 @@ export default class Tooltip extends React.PureComponent { // positioned, also taking into account any window zoom private updatePosition = (): void => { // When the tooltip is hidden, no need to thrash the DOM with `style` attribute updates (performance) - if (!this.props.visible) return; + if (!this.props.visible || !this.parent) return; const parentBox = this.parent.getBoundingClientRect(); const width = UIStore.instance.windowWidth; diff --git a/src/components/views/location/LocationButton.tsx b/src/components/views/location/LocationButton.tsx index 7efd350b6b..6d9885cf91 100644 --- a/src/components/views/location/LocationButton.tsx +++ b/src/components/views/location/LocationButton.tsx @@ -69,6 +69,7 @@ export const LocationButton: React.FC = ({ roomId, sender, menuPosition, iconClassName="mx_MessageComposer_location" onClick={openMenu} title={_t("Location")} + inputRef={button} /> { contextMenu } diff --git a/src/components/views/messages/MessageActionBar.tsx b/src/components/views/messages/MessageActionBar.tsx index c510805116..c1637b9a0c 100644 --- a/src/components/views/messages/MessageActionBar.tsx +++ b/src/components/views/messages/MessageActionBar.tsx @@ -83,7 +83,7 @@ const OptionsButton: React.FC = ({ getRelationsForEvent, }) => { const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu(); - const [onFocus, isActive, ref] = useRovingTabIndex(button); + const [onFocus, isActive] = useRovingTabIndex(button); useEffect(() => { onFocusChange(menuDisplayed); }, [onFocusChange, menuDisplayed]); @@ -123,7 +123,7 @@ const OptionsButton: React.FC = ({ onClick={onOptionsClick} onContextMenu={onOptionsClick} isExpanded={menuDisplayed} - inputRef={ref} + inputRef={button} onFocus={onFocus} tabIndex={isActive ? 0 : -1} > @@ -141,7 +141,7 @@ interface IReactButtonProps { const ReactButton: React.FC = ({ mxEvent, reactions, onFocusChange }) => { const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu(); - const [onFocus, isActive, ref] = useRovingTabIndex(button); + const [onFocus, isActive] = useRovingTabIndex(button); useEffect(() => { onFocusChange(menuDisplayed); }, [onFocusChange, menuDisplayed]); @@ -173,7 +173,7 @@ const ReactButton: React.FC = ({ mxEvent, reactions, onFocusC onClick={onClick} onContextMenu={onClick} isExpanded={menuDisplayed} - inputRef={ref} + inputRef={button} onFocus={onFocus} tabIndex={isActive ? 0 : -1} > diff --git a/src/components/views/messages/MessageEvent.tsx b/src/components/views/messages/MessageEvent.tsx index 91807d568f..858bf0eb6c 100644 --- a/src/components/views/messages/MessageEvent.tsx +++ b/src/components/views/messages/MessageEvent.tsx @@ -43,8 +43,6 @@ import MjolnirBody from "./MjolnirBody"; import MBeaconBody from "./MBeaconBody"; import { IEventTileOps } from "../rooms/EventTile"; import { VoiceBroadcastBody, VoiceBroadcastInfoEventType, VoiceBroadcastInfoState } from '../../../voice-broadcast'; -import { Features } from '../../../settings/Settings'; -import { SettingLevel } from '../../../settings/SettingLevel'; // onMessageAllowed is handled internally interface IProps extends Omit { @@ -58,18 +56,10 @@ interface IProps extends Omit([ [MsgType.Text, TextualBody], [MsgType.Notice, TextualBody], @@ -87,7 +77,7 @@ const baseEvTypes = new Map>>([ [M_BEACON_INFO.altName, MBeaconBody], ]); -export default class MessageEvent extends React.Component implements IMediaBody, IOperableEventTile { +export default class MessageEvent extends React.Component implements IMediaBody, IOperableEventTile { private body: React.RefObject = createRef(); private mediaHelper: MediaEventHelper; private bodyTypes = new Map(baseBodyTypes.entries()); @@ -95,7 +85,6 @@ export default class MessageEvent extends React.Component impleme public static contextType = MatrixClientContext; public context!: React.ContextType; - private voiceBroadcastSettingWatcherRef: string; public constructor(props: IProps, context: React.ContextType) { super(props, context); @@ -105,29 +94,15 @@ export default class MessageEvent extends React.Component impleme } this.updateComponentMaps(); - - this.state = { - // only check voice broadcast settings for a voice broadcast event - voiceBroadcastEnabled: this.props.mxEvent.getType() === VoiceBroadcastInfoEventType - && SettingsStore.getValue(Features.VoiceBroadcast), - }; } public componentDidMount(): void { this.props.mxEvent.addListener(MatrixEventEvent.Decrypted, this.onDecrypted); - - if (this.props.mxEvent.getType() === VoiceBroadcastInfoEventType) { - this.watchVoiceBroadcastFeatureSetting(); - } } public componentWillUnmount() { this.props.mxEvent.removeListener(MatrixEventEvent.Decrypted, this.onDecrypted); this.mediaHelper?.destroy(); - - if (this.voiceBroadcastSettingWatcherRef) { - SettingsStore.unwatchSetting(this.voiceBroadcastSettingWatcherRef); - } } public componentDidUpdate(prevProps: Readonly) { @@ -171,16 +146,6 @@ export default class MessageEvent extends React.Component impleme this.forceUpdate(); }; - private watchVoiceBroadcastFeatureSetting(): void { - this.voiceBroadcastSettingWatcherRef = SettingsStore.watchSetting( - Features.VoiceBroadcast, - null, - (settingName: string, roomId: string, atLevel: SettingLevel, newValAtLevel, newValue: boolean) => { - this.setState({ voiceBroadcastEnabled: newValue }); - }, - ); - } - public render() { const content = this.props.mxEvent.getContent(); const type = this.props.mxEvent.getType(); @@ -209,8 +174,7 @@ export default class MessageEvent extends React.Component impleme } if ( - this.state.voiceBroadcastEnabled - && type === VoiceBroadcastInfoEventType + type === VoiceBroadcastInfoEventType && content?.state === VoiceBroadcastInfoState.Started ) { BodyType = VoiceBroadcastBody; diff --git a/src/components/views/right_panel/TimelineCard.tsx b/src/components/views/right_panel/TimelineCard.tsx index f1eea5ad49..c88a47406a 100644 --- a/src/components/views/right_panel/TimelineCard.tsx +++ b/src/components/views/right_panel/TimelineCard.tsx @@ -33,7 +33,6 @@ import dis from '../../../dispatcher/dispatcher'; import { _t } from '../../../languageHandler'; import { ActionPayload } from '../../../dispatcher/payloads'; import { Action } from '../../../dispatcher/actions'; -import { RoomViewStore } from '../../../stores/RoomViewStore'; import ContentMessages from '../../../ContentMessages'; import UploadBar from '../../structures/UploadBar'; import SettingsStore from '../../../settings/SettingsStore'; @@ -42,6 +41,7 @@ import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; import Measured from '../elements/Measured'; import Heading from '../typography/Heading'; import { UPDATE_EVENT } from '../../../stores/AsyncStore'; +import { SdkContextClass } from '../../../contexts/SDKContext'; interface IProps { room: Room; @@ -91,7 +91,7 @@ export default class TimelineCard extends React.Component { } public componentDidMount(): void { - RoomViewStore.instance.addListener(UPDATE_EVENT, this.onRoomViewStoreUpdate); + SdkContextClass.instance.roomViewStore.addListener(UPDATE_EVENT, this.onRoomViewStoreUpdate); this.dispatcherRef = dis.register(this.onAction); this.readReceiptsSettingWatcher = SettingsStore.watchSetting("showReadReceipts", null, (...[,,, value]) => this.setState({ showReadReceipts: value as boolean }), @@ -102,7 +102,7 @@ export default class TimelineCard extends React.Component { } public componentWillUnmount(): void { - RoomViewStore.instance.removeListener(UPDATE_EVENT, this.onRoomViewStoreUpdate); + SdkContextClass.instance.roomViewStore.removeListener(UPDATE_EVENT, this.onRoomViewStoreUpdate); if (this.readReceiptsSettingWatcher) { SettingsStore.unwatchSetting(this.readReceiptsSettingWatcher); @@ -116,12 +116,9 @@ export default class TimelineCard extends React.Component { private onRoomViewStoreUpdate = async (initial?: boolean): Promise => { const newState: Pick = { - // roomLoading: RoomViewStore.instance.isRoomLoading(), - // roomLoadError: RoomViewStore.instance.getRoomLoadError(), - - initialEventId: RoomViewStore.instance.getInitialEventId(), - isInitialEventHighlighted: RoomViewStore.instance.isInitialEventHighlighted(), - replyToEvent: RoomViewStore.instance.getQuotingEvent(), + initialEventId: SdkContextClass.instance.roomViewStore.getInitialEventId(), + isInitialEventHighlighted: SdkContextClass.instance.roomViewStore.isInitialEventHighlighted(), + replyToEvent: SdkContextClass.instance.roomViewStore.getQuotingEvent(), }; this.setState(newState); diff --git a/src/components/views/right_panel/UserInfo.tsx b/src/components/views/right_panel/UserInfo.tsx index 810ae48dd7..49201d52bc 100644 --- a/src/components/views/right_panel/UserInfo.tsx +++ b/src/components/views/right_panel/UserInfo.tsx @@ -36,7 +36,6 @@ import { _t } from '../../../languageHandler'; import DMRoomMap from '../../../utils/DMRoomMap'; import AccessibleButton, { ButtonEvent } from '../elements/AccessibleButton'; import SdkConfig from '../../../SdkConfig'; -import { RoomViewStore } from "../../../stores/RoomViewStore"; import MultiInviter from "../../../utils/MultiInviter"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; import E2EIcon from "../rooms/E2EIcon"; @@ -77,6 +76,7 @@ import UserIdentifierCustomisations from '../../../customisations/UserIdentifier import PosthogTrackers from "../../../PosthogTrackers"; import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; import { DirectoryMember, startDmOnFirstMessage } from '../../../utils/direct-messages'; +import { SdkContextClass } from '../../../contexts/SDKContext'; export interface IDevice { deviceId: string; @@ -412,7 +412,7 @@ const UserOptionsSection: React.FC<{ } if (canInvite && (member?.membership ?? 'leave') === 'leave' && shouldShowComponent(UIComponent.InviteUsers)) { - const roomId = member && member.roomId ? member.roomId : RoomViewStore.instance.getRoomId(); + const roomId = member && member.roomId ? member.roomId : SdkContextClass.instance.roomViewStore.getRoomId(); const onInviteUserButton = async (ev: ButtonEvent) => { try { // We use a MultiInviter to re-use the invite logic, even though we're only inviting one user. diff --git a/src/components/views/rooms/BasicMessageComposer.tsx b/src/components/views/rooms/BasicMessageComposer.tsx index d74c7b5148..4fb72cd65a 100644 --- a/src/components/views/rooms/BasicMessageComposer.tsx +++ b/src/components/views/rooms/BasicMessageComposer.tsx @@ -31,7 +31,6 @@ import Autocomplete, { generateCompletionDomId } from '../rooms/Autocomplete'; import { getAutoCompleteCreator, Part, Type } from '../../../editor/parts'; import { parseEvent, parsePlainTextMessage } from '../../../editor/deserialize'; import { renderModel } from '../../../editor/render'; -import TypingStore from "../../../stores/TypingStore"; import SettingsStore from "../../../settings/SettingsStore"; import { IS_MAC, Key } from "../../../Keyboard"; import { EMOTICON_TO_EMOJI } from "../../../emoji"; @@ -47,6 +46,7 @@ import { getKeyBindingsManager } from '../../../KeyBindingsManager'; import { ALTERNATE_KEY_NAME, KeyBindingAction } from '../../../accessibility/KeyboardShortcuts'; import { _t } from "../../../languageHandler"; import { linkify } from '../../../linkify-matrix'; +import { SdkContextClass } from '../../../contexts/SDKContext'; // matches emoticons which follow the start of a line or whitespace const REGEX_EMOTICON_WHITESPACE = new RegExp('(?:^|\\s)(' + EMOTICON_REGEX.source + ')\\s|:^$'); @@ -246,7 +246,7 @@ export default class BasicMessageEditor extends React.Component isTyping = false; } } - TypingStore.sharedInstance().setSelfTyping( + SdkContextClass.instance.typingStore.setSelfTyping( this.props.room.roomId, this.props.threadId, isTyping, @@ -789,6 +789,7 @@ export default class BasicMessageEditor extends React.Component aria-activedescendant={activeDescendant} dir="auto" aria-disabled={this.props.disabled} + data-testid="basicmessagecomposer" /> ); } diff --git a/src/components/views/rooms/EventTile.tsx b/src/components/views/rooms/EventTile.tsx index 654cc80b67..b13eba33e4 100644 --- a/src/components/views/rooms/EventTile.tsx +++ b/src/components/views/rooms/EventTile.tsx @@ -932,6 +932,7 @@ export class UnwrappedEventTile extends React.Component { rightClick={true} reactions={this.state.reactions} link={this.state.contextMenu.link} + getRelationsForEvent={this.props.getRelationsForEvent} /> ); } diff --git a/src/components/views/rooms/MessageComposer.tsx b/src/components/views/rooms/MessageComposer.tsx index 9783e30756..d1521c7b0c 100644 --- a/src/components/views/rooms/MessageComposer.tsx +++ b/src/components/views/rooms/MessageComposer.tsx @@ -73,6 +73,7 @@ function SendButton(props: ISendButtonProps) { className="mx_MessageComposer_sendMessage" onClick={props.onClick} title={props.title ?? _t('Send message')} + data-testid="sendmessagebtn" /> ); } @@ -85,7 +86,6 @@ interface IProps { relation?: IEventRelation; e2eStatus?: E2EStatus; compact?: boolean; - showVoiceBroadcastButton?: boolean; } interface IState { @@ -384,10 +384,6 @@ export default class MessageComposer extends React.Component { return this.state.showStickersButton && !isLocalRoom(this.props.room); } - private get showVoiceBroadcastButton(): boolean { - return this.props.showVoiceBroadcastButton && this.state.showVoiceBroadcastButton; - } - public render() { const isWysiwygComposerEnabled = SettingsStore.getValue("feature_wysiwyg_composer"); const controls = [ @@ -532,10 +528,10 @@ export default class MessageComposer extends React.Component { showPollsButton={this.state.showPollsButton} showStickersButton={this.showStickersButton} toggleButtonMenu={this.toggleButtonMenu} - showVoiceBroadcastButton={this.showVoiceBroadcastButton} + showVoiceBroadcastButton={this.state.showVoiceBroadcastButton} onStartVoiceBroadcastClick={() => { startNewVoiceBroadcastRecording( - this.props.room.roomId, + this.props.room, MatrixClientPeg.get(), VoiceBroadcastRecordingsStore.instance(), ); diff --git a/src/components/views/rooms/MessageComposerButtons.tsx b/src/components/views/rooms/MessageComposerButtons.tsx index cc7ce70f44..b77bff66a8 100644 --- a/src/components/views/rooms/MessageComposerButtons.tsx +++ b/src/components/views/rooms/MessageComposerButtons.tsx @@ -179,6 +179,7 @@ const EmojiButton: React.FC = ({ addEmoji, menuPosition }) => iconClassName="mx_MessageComposer_emoji" onClick={openMenu} title={_t("Emoji")} + inputRef={button} /> { contextMenu } diff --git a/src/components/views/rooms/ReadReceiptGroup.tsx b/src/components/views/rooms/ReadReceiptGroup.tsx index 34bcb431e2..4e4b4c8a9d 100644 --- a/src/components/views/rooms/ReadReceiptGroup.tsx +++ b/src/components/views/rooms/ReadReceiptGroup.tsx @@ -209,7 +209,8 @@ export function ReadReceiptGroup( onMouseOver={showTooltip} onMouseLeave={hideTooltip} onFocus={showTooltip} - onBlur={hideTooltip}> + onBlur={hideTooltip} + > { remText } { { betaPill } { buttons } + { !isVideoRoom && } ); diff --git a/src/components/views/rooms/RoomList.tsx b/src/components/views/rooms/RoomList.tsx index 13b1011088..0cd38f7b30 100644 --- a/src/components/views/rooms/RoomList.tsx +++ b/src/components/views/rooms/RoomList.tsx @@ -38,7 +38,6 @@ import { ITagMap } from "../../../stores/room-list/algorithms/models"; import { DefaultTagID, TagID } from "../../../stores/room-list/models"; import { UPDATE_EVENT } from "../../../stores/AsyncStore"; import RoomListStore, { LISTS_UPDATE_EVENT } from "../../../stores/room-list/RoomListStore"; -import { RoomViewStore } from "../../../stores/RoomViewStore"; import { isMetaSpace, ISuggestedRoom, @@ -62,6 +61,7 @@ import IconizedContextMenu, { import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import ExtraTile from "./ExtraTile"; import RoomSublist, { IAuxButtonProps } from "./RoomSublist"; +import { SdkContextClass } from "../../../contexts/SDKContext"; interface IProps { onKeyDown: (ev: React.KeyboardEvent, state: IRovingTabIndexState) => void; @@ -421,7 +421,7 @@ export default class RoomList extends React.PureComponent { public componentDidMount(): void { this.dispatcherRef = defaultDispatcher.register(this.onAction); - RoomViewStore.instance.on(UPDATE_EVENT, this.onRoomViewStoreUpdate); + SdkContextClass.instance.roomViewStore.on(UPDATE_EVENT, this.onRoomViewStoreUpdate); SpaceStore.instance.on(UPDATE_SUGGESTED_ROOMS, this.updateSuggestedRooms); RoomListStore.instance.on(LISTS_UPDATE_EVENT, this.updateLists); this.favouriteMessageWatcher = @@ -436,19 +436,19 @@ export default class RoomList extends React.PureComponent { RoomListStore.instance.off(LISTS_UPDATE_EVENT, this.updateLists); SettingsStore.unwatchSetting(this.favouriteMessageWatcher); defaultDispatcher.unregister(this.dispatcherRef); - RoomViewStore.instance.off(UPDATE_EVENT, this.onRoomViewStoreUpdate); + SdkContextClass.instance.roomViewStore.off(UPDATE_EVENT, this.onRoomViewStoreUpdate); } private onRoomViewStoreUpdate = () => { this.setState({ - currentRoomId: RoomViewStore.instance.getRoomId(), + currentRoomId: SdkContextClass.instance.roomViewStore.getRoomId(), }); }; private onAction = (payload: ActionPayload) => { if (payload.action === Action.ViewRoomDelta) { const viewRoomDeltaPayload = payload as ViewRoomDeltaPayload; - const currentRoomId = RoomViewStore.instance.getRoomId(); + const currentRoomId = SdkContextClass.instance.roomViewStore.getRoomId(); const room = this.getRoomDelta(currentRoomId, viewRoomDeltaPayload.delta, viewRoomDeltaPayload.unread); if (room) { defaultDispatcher.dispatch({ diff --git a/src/components/views/rooms/RoomListHeader.tsx b/src/components/views/rooms/RoomListHeader.tsx index f783e628f3..9c7b5c11aa 100644 --- a/src/components/views/rooms/RoomListHeader.tsx +++ b/src/components/views/rooms/RoomListHeader.tsx @@ -379,7 +379,7 @@ const RoomListHeader = ({ onVisibilityChange }: IProps) => { isExpanded={mainMenuDisplayed} className="mx_RoomListHeader_contextMenuButton" title={activeSpace - ? _t("%(spaceName)s menu", { spaceName }) + ? _t("%(spaceName)s menu", { spaceName: spaceName ?? activeSpace.name }) : _t("Home options")} > { title } diff --git a/src/components/views/rooms/RoomTile.tsx b/src/components/views/rooms/RoomTile.tsx index 219295d23d..68f4dfe4de 100644 --- a/src/components/views/rooms/RoomTile.tsx +++ b/src/components/views/rooms/RoomTile.tsx @@ -44,10 +44,10 @@ import PosthogTrackers from "../../../PosthogTrackers"; import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts"; import { getKeyBindingsManager } from "../../../KeyBindingsManager"; -import { RoomViewStore } from "../../../stores/RoomViewStore"; import { RoomTileCallSummary } from "./RoomTileCallSummary"; import { RoomGeneralContextMenu } from "../context_menus/RoomGeneralContextMenu"; import { CallStore, CallStoreEvent } from "../../../stores/CallStore"; +import { SdkContextClass } from "../../../contexts/SDKContext"; interface IProps { room: Room; @@ -86,7 +86,7 @@ export default class RoomTile extends React.PureComponent { super(props); this.state = { - selected: RoomViewStore.instance.getRoomId() === this.props.room.roomId, + selected: SdkContextClass.instance.roomViewStore.getRoomId() === this.props.room.roomId, notificationsMenuPosition: null, generalMenuPosition: null, call: CallStore.instance.getCall(this.props.room.roomId), @@ -146,7 +146,7 @@ export default class RoomTile extends React.PureComponent { this.scrollIntoView(); } - RoomViewStore.instance.addRoomListener(this.props.room.roomId, this.onActiveRoomUpdate); + SdkContextClass.instance.roomViewStore.addRoomListener(this.props.room.roomId, this.onActiveRoomUpdate); this.dispatcherRef = defaultDispatcher.register(this.onAction); MessagePreviewStore.instance.on( MessagePreviewStore.getPreviewChangedEventName(this.props.room), @@ -163,7 +163,7 @@ export default class RoomTile extends React.PureComponent { } public componentWillUnmount() { - RoomViewStore.instance.removeRoomListener(this.props.room.roomId, this.onActiveRoomUpdate); + SdkContextClass.instance.roomViewStore.removeRoomListener(this.props.room.roomId, this.onActiveRoomUpdate); MessagePreviewStore.instance.off( MessagePreviewStore.getPreviewChangedEventName(this.props.room), this.onRoomPreviewChanged, diff --git a/src/components/views/rooms/wysiwyg_composer/WysiwygComposer.tsx b/src/components/views/rooms/wysiwyg_composer/WysiwygComposer.tsx index 8701f5be77..c22e3406fa 100644 --- a/src/components/views/rooms/wysiwyg_composer/WysiwygComposer.tsx +++ b/src/components/views/rooms/wysiwyg_composer/WysiwygComposer.tsx @@ -16,7 +16,7 @@ limitations under the License. import React, { useCallback, useEffect } from 'react'; import { IEventRelation, MatrixEvent } from 'matrix-js-sdk/src/models/event'; -import { useWysiwyg } from "@matrix-org/matrix-wysiwyg"; +import { useWysiwyg, Wysiwyg, WysiwygInputEvent } from "@matrix-org/matrix-wysiwyg"; import { Editor } from './Editor'; import { FormattingButtons } from './FormattingButtons'; @@ -25,6 +25,7 @@ import { sendMessage } from './message'; import { useMatrixClientContext } from '../../../../contexts/MatrixClientContext'; import { useRoomContext } from '../../../../contexts/RoomContext'; import { useWysiwygActionHandler } from './useWysiwygActionHandler'; +import { useSettingValue } from '../../../../hooks/useSettings'; interface WysiwygProps { disabled?: boolean; @@ -41,8 +42,27 @@ export function WysiwygComposer( ) { const roomContext = useRoomContext(); const mxClient = useMatrixClientContext(); + const ctrlEnterToSend = useSettingValue("MessageComposerInput.ctrlEnterToSend"); - const { ref, isWysiwygReady, content, formattingStates, wysiwyg } = useWysiwyg(); + function inputEventProcessor(event: WysiwygInputEvent, wysiwyg: Wysiwyg): WysiwygInputEvent | null { + if (event instanceof ClipboardEvent) { + return event; + } + + if ( + (event.inputType === 'insertParagraph' && !ctrlEnterToSend) || + event.inputType === 'sendMessage' + ) { + sendMessage(content, { mxClient, roomContext, ...props }); + wysiwyg.actions.clear(); + ref.current?.focus(); + return null; + } + + return event; + } + + const { ref, isWysiwygReady, content, formattingStates, wysiwyg } = useWysiwyg({ inputEventProcessor }); useEffect(() => { if (!disabled && content !== null) { diff --git a/src/components/views/settings/DevicesPanel.tsx b/src/components/views/settings/DevicesPanel.tsx index f32f7997fe..1b06fa06fe 100644 --- a/src/components/views/settings/DevicesPanel.tsx +++ b/src/components/views/settings/DevicesPanel.tsx @@ -19,13 +19,14 @@ import classNames from 'classnames'; import { IMyDevice } from "matrix-js-sdk/src/client"; import { logger } from "matrix-js-sdk/src/logger"; import { CrossSigningInfo } from "matrix-js-sdk/src/crypto/CrossSigning"; +import { CryptoEvent } from 'matrix-js-sdk/src/crypto'; -import { MatrixClientPeg } from '../../../MatrixClientPeg'; import { _t } from '../../../languageHandler'; import DevicesPanelEntry from "./DevicesPanelEntry"; import Spinner from "../elements/Spinner"; import AccessibleButton from "../elements/AccessibleButton"; import { deleteDevicesWithInteractiveAuth } from './devices/deleteDevices'; +import MatrixClientContext from '../../../contexts/MatrixClientContext'; interface IProps { className?: string; @@ -40,6 +41,8 @@ interface IState { } export default class DevicesPanel extends React.Component { + public static contextType = MatrixClientContext; + public context!: React.ContextType; private unmounted = false; constructor(props: IProps) { @@ -52,15 +55,22 @@ export default class DevicesPanel extends React.Component { } public componentDidMount(): void { + this.context.on(CryptoEvent.DevicesUpdated, this.onDevicesUpdated); this.loadDevices(); } public componentWillUnmount(): void { + this.context.off(CryptoEvent.DevicesUpdated, this.onDevicesUpdated); this.unmounted = true; } + private onDevicesUpdated = (users: string[]) => { + if (!users.includes(this.context.getUserId())) return; + this.loadDevices(); + }; + private loadDevices(): void { - const cli = MatrixClientPeg.get(); + const cli = this.context; cli.getDevices().then( (resp) => { if (this.unmounted) { return; } @@ -111,7 +121,7 @@ export default class DevicesPanel extends React.Component { private isDeviceVerified(device: IMyDevice): boolean | null { try { - const cli = MatrixClientPeg.get(); + const cli = this.context; const deviceInfo = cli.getStoredDevice(cli.getUserId(), device.device_id); return this.state.crossSigningInfo.checkDeviceTrust( this.state.crossSigningInfo, @@ -184,7 +194,7 @@ export default class DevicesPanel extends React.Component { try { await deleteDevicesWithInteractiveAuth( - MatrixClientPeg.get(), + this.context, this.state.selectedDevices, (success) => { if (success) { @@ -208,7 +218,7 @@ export default class DevicesPanel extends React.Component { }; private renderDevice = (device: IMyDevice): JSX.Element => { - const myDeviceId = MatrixClientPeg.get().getDeviceId(); + const myDeviceId = this.context.getDeviceId(); const myDevice = this.state.devices.find((device) => (device.device_id === myDeviceId)); const isOwnDevice = device.device_id === myDeviceId; @@ -246,7 +256,7 @@ export default class DevicesPanel extends React.Component { return ; } - const myDeviceId = MatrixClientPeg.get().getDeviceId(); + const myDeviceId = this.context.getDeviceId(); const myDevice = devices.find((device) => (device.device_id === myDeviceId)); if (!myDevice) { diff --git a/src/components/views/settings/devices/DeviceDetails.tsx b/src/components/views/settings/devices/DeviceDetails.tsx index 4330798dca..3921ae899e 100644 --- a/src/components/views/settings/devices/DeviceDetails.tsx +++ b/src/components/views/settings/devices/DeviceDetails.tsx @@ -62,7 +62,6 @@ const DeviceDetails: React.FC = ({ id: 'session', values: [ { label: _t('Session ID'), value: device.device_id }, - { label: _t('Client'), value: device.client }, { label: _t('Last activity'), value: device.last_seen_ts && formatDate(new Date(device.last_seen_ts)), @@ -84,6 +83,7 @@ const DeviceDetails: React.FC = ({ values: [ { label: _t('Model'), value: device.deviceModel }, { label: _t('Operating system'), value: device.deviceOperatingSystem }, + { label: _t('Browser'), value: device.client }, { label: _t('IP address'), value: device.last_seen_ip }, ], }, diff --git a/src/components/views/settings/devices/LoginWithQRSection.tsx b/src/components/views/settings/devices/LoginWithQRSection.tsx new file mode 100644 index 0000000000..20cdb37902 --- /dev/null +++ b/src/components/views/settings/devices/LoginWithQRSection.tsx @@ -0,0 +1,63 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from 'react'; + +import type { IServerVersions } from 'matrix-js-sdk/src/matrix'; +import { _t } from '../../../../languageHandler'; +import AccessibleButton from '../../elements/AccessibleButton'; +import SettingsSubsection from '../shared/SettingsSubsection'; +import SettingsStore from '../../../../settings/SettingsStore'; + +interface IProps { + onShowQr: () => void; + versions: IServerVersions; +} + +export default class LoginWithQRSection extends React.Component { + public constructor(props: IProps) { + super(props); + } + + public render(): JSX.Element { + const msc3882Supported = !!this.props.versions?.unstable_features?.['org.matrix.msc3882']; + const msc3886Supported = !!this.props.versions?.unstable_features?.['org.matrix.msc3886']; + + // Needs to be enabled as a feature + server support MSC3886 or have a default rendezvous server configured: + const offerShowQr = SettingsStore.getValue("feature_qr_signin_reciprocate_show") && + msc3882Supported && msc3886Supported; // We don't support configuration of a fallback at the moment so we just check the MSCs + + // don't show anything if no method is available + if (!offerShowQr) { + return null; + } + + return +
+

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

+ { _t("Show QR code") } +
+
; + } +} diff --git a/src/components/views/settings/devices/useOwnDevices.ts b/src/components/views/settings/devices/useOwnDevices.ts index c3b8cb0212..f56ed85c87 100644 --- a/src/components/views/settings/devices/useOwnDevices.ts +++ b/src/components/views/settings/devices/useOwnDevices.ts @@ -31,6 +31,7 @@ import { VerificationRequest } from "matrix-js-sdk/src/crypto/verification/reque import { MatrixError } from "matrix-js-sdk/src/http-api"; import { logger } from "matrix-js-sdk/src/logger"; import { LocalNotificationSettings } from "matrix-js-sdk/src/@types/local_notifications"; +import { CryptoEvent } from "matrix-js-sdk/src/crypto"; import MatrixClientContext from "../../../../contexts/MatrixClientContext"; import { _t } from "../../../../languageHandler"; @@ -179,6 +180,12 @@ export const useOwnDevices = (): DevicesState => { refreshDevices(); }, [refreshDevices]); + useEventEmitter(matrixClient, CryptoEvent.DevicesUpdated, (users: string[]): void => { + if (users.includes(userId)) { + refreshDevices(); + } + }); + useEventEmitter(matrixClient, ClientEvent.AccountData, (event: MatrixEvent): void => { const type = event.getType(); if (type.startsWith(LOCAL_NOTIFICATION_SETTINGS_PREFIX.name)) { diff --git a/src/components/views/settings/tabs/user/SecurityUserSettingsTab.tsx b/src/components/views/settings/tabs/user/SecurityUserSettingsTab.tsx index f4e4e55513..b960e65a61 100644 --- a/src/components/views/settings/tabs/user/SecurityUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/SecurityUserSettingsTab.tsx @@ -38,6 +38,9 @@ import InlineSpinner from "../../../elements/InlineSpinner"; import { PosthogAnalytics } from "../../../../../PosthogAnalytics"; import { showDialog as showAnalyticsLearnMoreDialog } from "../../../dialogs/AnalyticsLearnMoreDialog"; import { privateShouldBeEncrypted } from "../../../../../utils/rooms"; +import LoginWithQR, { Mode } from '../../../auth/LoginWithQR'; +import LoginWithQRSection from '../../devices/LoginWithQRSection'; +import type { IServerVersions } from 'matrix-js-sdk/src/matrix'; interface IIgnoredUserProps { userId: string; @@ -72,6 +75,8 @@ interface IState { waitingUnignored: string[]; managingInvites: boolean; invitedRoomIds: Set; + showLoginWithQR: Mode | null; + versions?: IServerVersions; } export default class SecurityUserSettingsTab extends React.Component { @@ -88,6 +93,7 @@ export default class SecurityUserSettingsTab extends React.Component this.setState({ versions })); } public componentWillUnmount(): void { @@ -251,6 +258,14 @@ export default class SecurityUserSettingsTab extends React.Component { + this.setState({ showLoginWithQR: Mode.Show }); + }; + + private onLoginWithQRFinished = (): void => { + this.setState({ showLoginWithQR: null }); + }; + public render(): JSX.Element { const secureBackup = (
@@ -347,6 +362,7 @@ export default class SecurityUserSettingsTab extends React.Component @@ -363,8 +379,20 @@ export default class SecurityUserSettingsTab extends React.Component
+ { showQrCodeEnabled ? + + : null + } ; + const client = MatrixClientPeg.get(); + + if (showQrCodeEnabled && this.state.showLoginWithQR) { + return
+ +
; + } + return (
{ warning } diff --git a/src/components/views/settings/tabs/user/SessionManagerTab.tsx b/src/components/views/settings/tabs/user/SessionManagerTab.tsx index d1fbb6ce5c..49ca1bdbf2 100644 --- a/src/components/views/settings/tabs/user/SessionManagerTab.tsx +++ b/src/components/views/settings/tabs/user/SessionManagerTab.tsx @@ -32,6 +32,10 @@ import SecurityRecommendations from '../../devices/SecurityRecommendations'; import { DeviceSecurityVariation, ExtendedDevice } from '../../devices/types'; import { deleteDevicesWithInteractiveAuth } from '../../devices/deleteDevices'; import SettingsTab from '../SettingsTab'; +import LoginWithQRSection from '../../devices/LoginWithQRSection'; +import LoginWithQR, { Mode } from '../../../auth/LoginWithQR'; +import SettingsStore from '../../../../../settings/SettingsStore'; +import { useAsyncMemo } from '../../../../../hooks/useAsyncMemo'; const useSignOut = ( matrixClient: MatrixClient, @@ -104,6 +108,7 @@ const SessionManagerTab: React.FC = () => { const matrixClient = useContext(MatrixClientContext); const userId = matrixClient.getUserId(); const currentUserMember = userId && matrixClient.getUser(userId) || undefined; + const clientVersions = useAsyncMemo(() => matrixClient.getVersions(), [matrixClient]); const onDeviceExpandToggle = (deviceId: ExtendedDevice['device_id']): void => { if (expandedDeviceIds.includes(deviceId)) { @@ -175,6 +180,26 @@ const SessionManagerTab: React.FC = () => { onSignOutOtherDevices(Object.keys(otherDevices)); }: undefined; + const [signInWithQrMode, setSignInWithQrMode] = useState(); + + const showQrCodeEnabled = SettingsStore.getValue("feature_qr_signin_reciprocate_show"); + + const onQrFinish = useCallback(() => { + setSignInWithQrMode(null); + }, [setSignInWithQrMode]); + + const onShowQrClicked = useCallback(() => { + setSignInWithQrMode(Mode.Show); + }, [setSignInWithQrMode]); + + if (showQrCodeEnabled && signInWithQrMode) { + return ; + } + return { /> } + { showQrCodeEnabled ? + + : null + } ; }; diff --git a/src/components/views/spaces/QuickSettingsButton.tsx b/src/components/views/spaces/QuickSettingsButton.tsx index 2f4ee9315f..eb7244f994 100644 --- a/src/components/views/spaces/QuickSettingsButton.tsx +++ b/src/components/views/spaces/QuickSettingsButton.tsx @@ -36,7 +36,7 @@ import { Icon as FavoriteIcon } from '../../../../res/img/element-icons/roomlist import SettingsStore from "../../../settings/SettingsStore"; import Modal from "../../../Modal"; import DevtoolsDialog from "../dialogs/DevtoolsDialog"; -import { RoomViewStore } from "../../../stores/RoomViewStore"; +import { SdkContextClass } from "../../../contexts/SDKContext"; const QuickSettingsButton = ({ isPanelCollapsed = false }) => { const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu(); @@ -72,7 +72,7 @@ const QuickSettingsButton = ({ isPanelCollapsed = false }) => { onClick={() => { closeMenu(); Modal.createDialog(DevtoolsDialog, { - roomId: RoomViewStore.instance.getRoomId(), + roomId: SdkContextClass.instance.roomViewStore.getRoomId(), }, "mx_DevtoolsDialog_wrapper"); }} kind="danger_outline" diff --git a/src/components/views/spaces/SpacePanel.tsx b/src/components/views/spaces/SpacePanel.tsx index 129e6f3584..e26a78b5a3 100644 --- a/src/components/views/spaces/SpacePanel.tsx +++ b/src/components/views/spaces/SpacePanel.tsx @@ -204,9 +204,7 @@ const CreateSpaceButton = ({ isPanelCollapsed, setPanelCollapsed, }: Pick) => { - // We don't need the handle as we position the menu in a constant location - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const [menuDisplayed, _handle, openMenu, closeMenu] = useContextMenu(); + const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu(); useEffect(() => { if (!isPanelCollapsed && menuDisplayed) { @@ -231,13 +229,14 @@ const CreateSpaceButton = ({ role="treeitem" > { contextMenu } diff --git a/src/components/views/spaces/SpaceTreeLevel.tsx b/src/components/views/spaces/SpaceTreeLevel.tsx index cab7bc3c76..5952e877d9 100644 --- a/src/components/views/spaces/SpaceTreeLevel.tsx +++ b/src/components/views/spaces/SpaceTreeLevel.tsx @@ -14,7 +14,16 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { MouseEvent, ComponentProps, ComponentType, createRef, InputHTMLAttributes, LegacyRef } from "react"; +import React, { + MouseEvent, + ComponentProps, + ComponentType, + createRef, + InputHTMLAttributes, + LegacyRef, + forwardRef, + RefObject, +} from "react"; import classNames from "classnames"; import { Room, RoomEvent } from "matrix-js-sdk/src/models/room"; import { DraggableProvidedDragHandleProps } from "react-beautiful-dnd"; @@ -54,7 +63,7 @@ interface IButtonProps extends Omit = ({ +export const SpaceButton = forwardRef(({ space, spaceKey, className, @@ -67,9 +76,9 @@ export const SpaceButton: React.FC = ({ children, ContextMenuComponent, ...props -}) => { - const [menuDisplayed, ref, openMenu, closeMenu] = useContextMenu(); - const [onFocus, isActive, handle] = useRovingTabIndex(ref); +}, ref: RefObject) => { + const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu(ref); + const [onFocus, isActive] = useRovingTabIndex(handle); const tabIndex = isActive ? 0 : -1; let avatar =
; @@ -150,7 +159,7 @@ export const SpaceButton: React.FC = ({
); -}; +}); interface IItemProps extends InputHTMLAttributes { space: Room; diff --git a/src/components/views/voip/PipView.tsx b/src/components/views/voip/PipView.tsx index 0bebfe1bf3..3aaa9ac430 100644 --- a/src/components/views/voip/PipView.tsx +++ b/src/components/views/voip/PipView.tsx @@ -21,7 +21,6 @@ import classNames from 'classnames'; import { Room } from "matrix-js-sdk/src/models/room"; import LegacyCallView from "./LegacyCallView"; -import { RoomViewStore } from '../../../stores/RoomViewStore'; import LegacyCallHandler, { LegacyCallHandlerEvent } from '../../../LegacyCallHandler'; import PersistentApp from "../elements/PersistentApp"; import { MatrixClientPeg } from '../../../MatrixClientPeg'; @@ -34,6 +33,7 @@ import ActiveWidgetStore, { ActiveWidgetStoreEvent } from '../../../stores/Activ import WidgetStore, { IApp } from "../../../stores/WidgetStore"; import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; import { UPDATE_EVENT } from '../../../stores/AsyncStore'; +import { SdkContextClass } from '../../../contexts/SDKContext'; import { CallStore } from "../../../stores/CallStore"; import { VoiceBroadcastRecording, @@ -129,7 +129,7 @@ class PipView extends React.Component { constructor(props: IProps) { super(props); - const roomId = RoomViewStore.instance.getRoomId(); + const roomId = SdkContextClass.instance.roomViewStore.getRoomId(); const [primaryCall, secondaryCalls] = getPrimarySecondaryCallsForPip(roomId); @@ -147,7 +147,7 @@ class PipView extends React.Component { public componentDidMount() { LegacyCallHandler.instance.addListener(LegacyCallHandlerEvent.CallChangeRoom, this.updateCalls); LegacyCallHandler.instance.addListener(LegacyCallHandlerEvent.CallState, this.updateCalls); - RoomViewStore.instance.addListener(UPDATE_EVENT, this.onRoomViewStoreUpdate); + SdkContextClass.instance.roomViewStore.addListener(UPDATE_EVENT, this.onRoomViewStoreUpdate); MatrixClientPeg.get().on(CallEvent.RemoteHoldUnhold, this.onCallRemoteHold); const room = MatrixClientPeg.get()?.getRoom(this.state.viewedRoomId); if (room) { @@ -164,7 +164,7 @@ class PipView extends React.Component { LegacyCallHandler.instance.removeListener(LegacyCallHandlerEvent.CallState, this.updateCalls); const cli = MatrixClientPeg.get(); cli?.removeListener(CallEvent.RemoteHoldUnhold, this.onCallRemoteHold); - RoomViewStore.instance.removeListener(UPDATE_EVENT, this.onRoomViewStoreUpdate); + SdkContextClass.instance.roomViewStore.removeListener(UPDATE_EVENT, this.onRoomViewStoreUpdate); const room = cli?.getRoom(this.state.viewedRoomId); if (room) { WidgetLayoutStore.instance.off(WidgetLayoutStore.emissionForRoom(room), this.updateCalls); @@ -186,7 +186,7 @@ class PipView extends React.Component { private onMove = () => this.movePersistedElement.current?.(); private onRoomViewStoreUpdate = () => { - const newRoomId = RoomViewStore.instance.getRoomId(); + const newRoomId = SdkContextClass.instance.roomViewStore.getRoomId(); const oldRoomId = this.state.viewedRoomId; if (newRoomId === oldRoomId) return; // The WidgetLayoutStore observer always tracks the currently viewed Room, diff --git a/src/contexts/RoomContext.ts b/src/contexts/RoomContext.ts index 5bc648e736..8193c83ccc 100644 --- a/src/contexts/RoomContext.ts +++ b/src/contexts/RoomContext.ts @@ -45,7 +45,6 @@ const RoomContext = createContext({ canReact: false, canSelfRedact: false, canSendMessages: false, - canSendVoiceBroadcasts: false, resizing: false, layout: Layout.Group, lowBandwidth: false, diff --git a/src/contexts/SDKContext.ts b/src/contexts/SDKContext.ts new file mode 100644 index 0000000000..09f882ba89 --- /dev/null +++ b/src/contexts/SDKContext.ts @@ -0,0 +1,144 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { MatrixClient } from "matrix-js-sdk/src/matrix"; +import { createContext } from "react"; + +import defaultDispatcher from "../dispatcher/dispatcher"; +import LegacyCallHandler from "../LegacyCallHandler"; +import { PosthogAnalytics } from "../PosthogAnalytics"; +import { SlidingSyncManager } from "../SlidingSyncManager"; +import { RoomNotificationStateStore } from "../stores/notifications/RoomNotificationStateStore"; +import RightPanelStore from "../stores/right-panel/RightPanelStore"; +import { RoomViewStore } from "../stores/RoomViewStore"; +import SpaceStore, { SpaceStoreClass } from "../stores/spaces/SpaceStore"; +import TypingStore from "../stores/TypingStore"; +import { WidgetLayoutStore } from "../stores/widgets/WidgetLayoutStore"; +import { WidgetPermissionStore } from "../stores/widgets/WidgetPermissionStore"; +import WidgetStore from "../stores/WidgetStore"; + +export const SDKContext = createContext(undefined); +SDKContext.displayName = "SDKContext"; + +/** + * A class which lazily initialises stores as and when they are requested, ensuring they remain + * as singletons scoped to this object. + */ +export class SdkContextClass { + /** + * The global SdkContextClass instance. This is a temporary measure whilst so many stores remain global + * as well. Over time, these stores should accept a `SdkContextClass` instance in their constructor. + * When all stores do this, this static variable can be deleted. + */ + public static readonly instance = new SdkContextClass(); + + // Optional as we don't have a client on initial load if unregistered. This should be set + // when the MatrixClient is first acquired in the dispatcher event Action.OnLoggedIn. + // It is only safe to set this once, as updating this value will NOT notify components using + // this Context. + public client?: MatrixClient; + + // All protected fields to make it easier to derive test stores + protected _WidgetPermissionStore?: WidgetPermissionStore; + protected _RightPanelStore?: RightPanelStore; + protected _RoomNotificationStateStore?: RoomNotificationStateStore; + protected _RoomViewStore?: RoomViewStore; + protected _WidgetLayoutStore?: WidgetLayoutStore; + protected _WidgetStore?: WidgetStore; + protected _PosthogAnalytics?: PosthogAnalytics; + protected _SlidingSyncManager?: SlidingSyncManager; + protected _SpaceStore?: SpaceStoreClass; + protected _LegacyCallHandler?: LegacyCallHandler; + protected _TypingStore?: TypingStore; + + /** + * Automatically construct stores which need to be created eagerly so they can register with + * the dispatcher. + */ + public constructEagerStores() { + this._RoomViewStore = this.roomViewStore; + } + + public get legacyCallHandler(): LegacyCallHandler { + if (!this._LegacyCallHandler) { + this._LegacyCallHandler = LegacyCallHandler.instance; + } + return this._LegacyCallHandler; + } + public get rightPanelStore(): RightPanelStore { + if (!this._RightPanelStore) { + this._RightPanelStore = RightPanelStore.instance; + } + return this._RightPanelStore; + } + public get roomNotificationStateStore(): RoomNotificationStateStore { + if (!this._RoomNotificationStateStore) { + this._RoomNotificationStateStore = RoomNotificationStateStore.instance; + } + return this._RoomNotificationStateStore; + } + public get roomViewStore(): RoomViewStore { + if (!this._RoomViewStore) { + this._RoomViewStore = new RoomViewStore( + defaultDispatcher, this, + ); + } + return this._RoomViewStore; + } + public get widgetLayoutStore(): WidgetLayoutStore { + if (!this._WidgetLayoutStore) { + this._WidgetLayoutStore = WidgetLayoutStore.instance; + } + return this._WidgetLayoutStore; + } + public get widgetPermissionStore(): WidgetPermissionStore { + if (!this._WidgetPermissionStore) { + this._WidgetPermissionStore = new WidgetPermissionStore(this); + } + return this._WidgetPermissionStore; + } + public get widgetStore(): WidgetStore { + if (!this._WidgetStore) { + this._WidgetStore = WidgetStore.instance; + } + return this._WidgetStore; + } + public get posthogAnalytics(): PosthogAnalytics { + if (!this._PosthogAnalytics) { + this._PosthogAnalytics = PosthogAnalytics.instance; + } + return this._PosthogAnalytics; + } + public get slidingSyncManager(): SlidingSyncManager { + if (!this._SlidingSyncManager) { + this._SlidingSyncManager = SlidingSyncManager.instance; + } + return this._SlidingSyncManager; + } + public get spaceStore(): SpaceStoreClass { + if (!this._SpaceStore) { + this._SpaceStore = SpaceStore.instance; + } + return this._SpaceStore; + } + public get typingStore(): TypingStore { + if (!this._TypingStore) { + this._TypingStore = new TypingStore(this); + window.mxTypingStore = this._TypingStore; + } + return this._TypingStore; + } +} diff --git a/src/i18n/strings/bg.json b/src/i18n/strings/bg.json index 65bd8ab877..aad0be9261 100644 --- a/src/i18n/strings/bg.json +++ b/src/i18n/strings/bg.json @@ -2163,5 +2163,15 @@ "You cannot place calls in this browser.": "Не можете да провеждате обаждания в този браузър.", "Calls are unsupported": "Обажданията не се поддържат", "The user you called is busy.": "Потребителят, когото потърсихте, е зает.", - "User Busy": "Потребителят е зает" + "User Busy": "Потребителят е зает", + "Some invites couldn't be sent": "Някои покани не можаха да бъдат изпратени", + "We sent the others, but the below people couldn't be invited to ": "Изпратихме останалите покани, но следните хора не можаха да бъдат поканени в ", + "Empty room (was %(oldName)s)": "Празна стая (беше %(oldName)s)", + "Inviting %(user)s and %(count)s others|one": "Канене на %(user)s и още 1 друг", + "Inviting %(user)s and %(count)s others|other": "Канене на %(user)s и %(count)s други", + "Inviting %(user1)s and %(user2)s": "Канене на %(user1)s и %(user2)s", + "%(user)s and %(count)s others|one": "%(user)s и още 1", + "%(user)s and %(count)s others|other": "%(user)s и %(count)s други", + "%(user1)s and %(user2)s": "%(user1)s и %(user2)s", + "Empty room": "Празна стая" } diff --git a/src/i18n/strings/cs.json b/src/i18n/strings/cs.json index 5516b1dd8c..eed5bb72c9 100644 --- a/src/i18n/strings/cs.json +++ b/src/i18n/strings/cs.json @@ -538,7 +538,7 @@ "Upgrade this room to version %(version)s": "Aktualizace místnosti na verzi %(version)s", "Security & Privacy": "Zabezpečení a soukromí", "Encryption": "Šifrování", - "Once enabled, encryption cannot be disabled.": "Jakmile je šifrování povoleno, nelze jej zakázat.", + "Once enabled, encryption cannot be disabled.": "Po zapnutí šifrování ho není možné vypnout.", "Encrypted": "Šifrováno", "General": "Obecné", "General failure": "Nějaká chyba", @@ -941,7 +941,7 @@ "You can reset your password, but some features will be unavailable until the identity server is back online. If you keep seeing this warning, check your configuration or contact a server admin.": "Můžete si změnit heslo, ale některé funkce nebudou dostupné dokud nezačne server identity fungovat. Pokud se toto varování zobrazuje i nadále, zkontrolujte svojí konfiguraci nebo kontaktujte správce serveru.", "You can log in, but some features will be unavailable until the identity server is back online. If you keep seeing this warning, check your configuration or contact a server admin.": "Můžete se přihlásit, ale některé funkce nebudou dostupné dokud nezačne server identity fungovat. Pokud se vám toto varování zobrazuje i nadále, zkontrolujte svojí konfiguraci nebo kontaktujte správce serveru.", "Call failed due to misconfigured server": "Volání selhalo, protože je rozbitá konfigurace serveru", - "Please ask the administrator of your homeserver (%(homeserverDomain)s) to configure a TURN server in order for calls to work reliably.": "Požádejte správce svého homeserveru (%(homeserverDomain)s) jestli by nemohl nakonfigurovat TURN server, aby volání fungovala spolehlivě.", + "Please ask the administrator of your homeserver (%(homeserverDomain)s) to configure a TURN server in order for calls to work reliably.": "Požádejte správce svého domovského serveru (%(homeserverDomain)s) jestli by nemohl nakonfigurovat TURN server, aby volání fungovala spolehlivě.", "Alternatively, you can try to use the public server at turn.matrix.org, but this will not be as reliable, and it will share your IP address with that server. You can also manage this in Settings.": "Můžete také zkusit použít veřejný server na adrese turn.matrix.org, ale ten nebude tak spolehlivý a bude sdílet vaši IP adresu s tímto serverem. To můžete spravovat také v Nastavení.", "Try using turn.matrix.org": "Zkuste použít turn.matrix.org", "Messages": "Zprávy", @@ -1262,7 +1262,7 @@ "about a day from now": "asi za den", "%(num)s days from now": "za %(num)s dní", "Show info about bridges in room settings": "Zobrazovat v nastavení místnosti informace o propojeních", - "Never send encrypted messages to unverified sessions from this session": "Nikdy neposílat šifrované zprávy neověřených zařízením", + "Never send encrypted messages to unverified sessions from this session": "Nikdy neposílat šifrované zprávy do neověřených relací z této relace", "Never send encrypted messages to unverified sessions in this room from this session": "Nikdy v této místnosti neposílat šifrované zprávy neověřeným relacím", "Enable message search in encrypted rooms": "Povolit vyhledávání v šifrovaných místnostech", "How fast should messages be downloaded.": "Jak rychle se mají zprávy stahovat.", @@ -1441,7 +1441,7 @@ "Manually Verify by Text": "Manuální textové ověření", "Interactively verify by Emoji": "Interaktivní ověření s emotikonami", "Support adding custom themes": "Umožnit přidání vlastního vzhledu", - "Manually verify all remote sessions": "Manuálně ověřit všechny relace", + "Manually verify all remote sessions": "Ručně ověřit všechny relace", "cached locally": "uložen lokálně", "not found locally": "nenalezen lolálně", "Individually verify each session used by a user to mark it as trusted, not trusting cross-signed devices.": "Individuálně ověřit každou uživatelovu relaci a označit jí za důvěryhodnou, bez důvěry v křížový podpis.", @@ -3582,12 +3582,87 @@ "Failed to set pusher state": "Nepodařilo se nastavit stav push oznámení", "%(selectedDeviceCount)s sessions selected": "%(selectedDeviceCount)s vybraných relací", "Receive push notifications on this session.": "Přijímat push oznámení v této relaci.", - "Toggle push notifications on this session.": "Přepnout push notifikace v této relaci.", - "Push notifications": "Push notifikace", + "Toggle push notifications on this session.": "Přepnout push oznámení v této relaci.", + "Push notifications": "Push oznámení", "Enable notifications for this device": "Povolit oznámení pro toto zařízení", "Turn off to disable notifications on all your devices and sessions": "Vypnutím zakážete oznámení na všech zařízeních a relacích", "Enable notifications for this account": "Povolit oznámení pro tento účet", "Video call ended": "Videohovor ukončen", "%(name)s started a video call": "%(name)s zahájil(a) videohovor", - "Record the client name, version, and url to recognise sessions more easily in session manager": "Zaznamenat název, verzi a url pro snadnější rozpoznání relací ve správci relací" + "Record the client name, version, and url to recognise sessions more easily in session manager": "Zaznamenat název, verzi a url pro snadnější rozpoznání relací ve správci relací", + "URL": "URL", + "Version": "Verze", + "Application": "Aplikace", + "Room info": "Informace o místnosti", + "View chat timeline": "Zobrazit časovou osu konverzace", + "Close call": "Zavřít hovor", + "Freedom": "Svoboda", + "Layout type": "Typ rozložení", + "Spotlight": "Reflektor", + "Unknown session type": "Neznámý typ relace", + "Web session": "Relace na webu", + "Mobile session": "Relace mobilního zařízení", + "Desktop session": "Relace stolního počítače", + "Fill screen": "Vyplnit obrazovku", + "Video call started": "Videohovor byl zahájen", + "Unknown room": "Neznámá místnost", + "Video call started in %(roomName)s. (not supported by this browser)": "Videohovor byl zahájen v %(roomName)s. (není podporováno tímto prohlížečem)", + "Video call started in %(roomName)s.": "Videohovor byl zahájen v %(roomName)s.", + "Operating system": "Operační systém", + "Model": "Model", + "Client": "Klient", + "Video call (%(brand)s)": "Videohovor (%(brand)s)", + "Call type": "Typ volání", + "You do not have sufficient permissions to change this.": "Ke změně nemáte dostatečná oprávnění.", + "%(brand)s is end-to-end encrypted, but is currently limited to smaller numbers of users.": "%(brand)s je koncově šifrovaný, ale v současné době je omezen na menší počet uživatelů.", + "Enable %(brand)s as an additional calling option in this room": "Povolit %(brand)s jako další možnost volání v této místnosti", + "Join %(brand)s calls": "Připojit se k %(brand)s volání", + "Start %(brand)s calls": "Zahájit %(brand)s volání", + "Sorry — this call is currently full": "Omlouváme se — tento hovor je v současné době plný", + "Our new sessions manager provides better visibility of all your sessions, and greater control over them including the ability to remotely toggle push notifications.": "Náš nový správce relací poskytuje lepší přehled o všech relacích a lepší kontrolu nad nimi, včetně možnosti vzdáleně přepínat push oznámení.", + "Have greater visibility and control over all your sessions.": "Získejte větší přehled a kontrolu nad všemi relacemi.", + "New session manager": "Nový správce relací", + "Use new session manager": "Použít nový správce relací", + "Wysiwyg composer (plain text mode coming soon) (under active development)": "Wysiwyg editor (textový režim již brzy) (v aktivním vývoji)", + "Sign out all other sessions": "Odhlásit všechny ostatní relace", + "resume voice broadcast": "obnovit hlasové vysílání", + "pause voice broadcast": "pozastavit hlasové vysílání", + "Underline": "Podtržení", + "Italic": "Kurzíva", + "Try out the rich text editor (plain text mode coming soon)": "Vyzkoušejte nový editor (textový režim již brzy)", + "You have already joined this call from another device": "K tomuto hovoru jste se již připojili z jiného zařízení", + "stop voice broadcast": "zastavit hlasové vysílání", + "Notifications silenced": "Oznámení ztlumena", + "Yes, stop broadcast": "Ano, zastavit vysílání", + "Are you sure you want to stop your live broadcast?This will end the broadcast and the full recording will be available in the room.": "Opravdu chcete ukončit živé vysílání? Tím se vysílání ukončí a v místnosti bude k dispozici celý záznam.", + "Stop live broadcasting?": "Ukončit živé vysílání?", + "Someone else is already recording a voice broadcast. Wait for their voice broadcast to end to start a new one.": "Hlasové vysílání už nahrává někdo jiný. Počkejte, až jeho hlasové vysílání skončí, a spusťte nové.", + "You don't have the required permissions to start a voice broadcast in this room. Contact a room administrator to upgrade your permissions.": "Nemáte potřebná oprávnění ke spuštění hlasového vysílání v této místnosti. Obraťte se na správce místnosti, aby vám zvýšil oprávnění.", + "You are already recording a voice broadcast. Please end your current voice broadcast to start a new one.": "Již nahráváte hlasové vysílání. Ukončete prosím aktuální hlasové vysílání a spusťte nové.", + "Can't start a new voice broadcast": "Nelze spustit nové hlasové vysílání", + "Completing set up of your new device": "Dokončování nastavení nového zařízení", + "Waiting for device to sign in": "Čekání na přihlášení zařízení", + "Connecting...": "Připojování...", + "Review and approve the sign in": "Zkontrolovat a schválit přihlášení", + "Select 'Scan QR code'": "Vyberte \"Naskenovat QR kód\"", + "Start at the sign in screen": "Začněte na přihlašovací obrazovce", + "Scan the QR code below with your device that's signed out.": "Níže uvedený QR kód naskenujte pomocí přihlašovaného zařízení.", + "By approving access for this device, it will have full access to your account.": "Schválením přístupu tohoto zařízení získá zařízení plný přístup k vašemu účtu.", + "Check that the code below matches with your other device:": "Zkontrolujte, zda se níže uvedený kód shoduje s vaším dalším zařízením:", + "Devices connected": "Zařízení byla propojena", + "The homeserver doesn't support signing in another device.": "Domovský server nepodporuje přihlášení pomocí jiného zařízení.", + "An unexpected error occurred.": "Došlo k neočekávané chybě.", + "The request was cancelled.": "Požadavek byl zrušen.", + "The other device isn't signed in.": "Druhé zařízení není přihlášeno.", + "The other device is already signed in.": "Druhé zařízení je již přihlášeno.", + "The request was declined on the other device.": "Požadavek byl na druhém zařízení odmítnut.", + "Linking with this device is not supported.": "Propojení s tímto zařízením není podporováno.", + "The scanned code is invalid.": "Naskenovaný kód je neplatný.", + "The linking wasn't completed in the required time.": "Propojení nebylo dokončeno v požadovaném čase.", + "Sign in new device": "Přihlásit nové zařízení", + "Show QR code": "Zobrazit QR kód", + "You can use this device to sign in a new device with a QR code. You will need to scan the QR code shown on this device with your device that's signed out.": "Toto zařízení můžete použít k přihlášení nového zařízení pomocí QR kódu. QR kód zobrazený na tomto zařízení musíte naskenovat pomocí odhlášeného zařízení.", + "Sign in with QR code": "Přihlásit se pomocí QR kódu", + "Browser": "Prohlížeč", + "Allow a QR code to be shown in session manager to sign in another device (requires compatible homeserver)": "Povolit zobrazení QR kódu ve správci relací pro přihlášení do jiného zařízení (vyžaduje kompatibilní domovský server)" } diff --git a/src/i18n/strings/de_DE.json b/src/i18n/strings/de_DE.json index 5116fcf8f5..60586e4088 100644 --- a/src/i18n/strings/de_DE.json +++ b/src/i18n/strings/de_DE.json @@ -142,7 +142,7 @@ "and %(count)s others...|one": "und ein weiterer …", "Are you sure?": "Bist du sicher?", "Attachment": "Anhang", - "Can't connect to homeserver via HTTP when an HTTPS URL is in your browser bar. Either use HTTPS or enable unsafe scripts.": "Es kann keine Verbindung zum Heimserver via HTTP aufgebaut werden, wenn die Adresszeile des Browsers eine HTTPS-URL enthält. Entweder HTTPS verwenden oder alternativ unsichere Skripte erlauben.", + "Can't connect to homeserver via HTTP when an HTTPS URL is in your browser bar. Either use HTTPS or enable unsafe scripts.": "Es kann keine Verbindung zum Heim-Server via HTTP aufgebaut werden, wenn die Adresszeile des Browsers eine HTTPS-URL enthält. Entweder HTTPS verwenden oder alternativ unsichere Skripte erlauben.", "Command error": "Fehler im Befehl", "Decrypt %(text)s": "%(text)s entschlüsseln", "Download %(text)s": "%(text)s herunterladen", @@ -159,7 +159,7 @@ "OK": "Ok", "Search": "Suchen", "Search failed": "Suche ist fehlgeschlagen", - "Server error": "Serverfehler", + "Server error": "Server-Fehler", "Server may be unavailable, overloaded, or search timed out :(": "Der Server ist entweder nicht verfügbar, überlastet oder die Suche wurde wegen Zeitüberschreitung abgebrochen :(", "Server unavailable, overloaded, or something else went wrong.": "Server ist nicht verfügbar, überlastet oder ein anderer Fehler ist aufgetreten.", "Submit": "Absenden", @@ -264,7 +264,7 @@ "Home": "Startseite", "Accept": "Annehmen", "Admin Tools": "Administrationswerkzeuge", - "Can't connect to homeserver - please check your connectivity, ensure your homeserver's SSL certificate is trusted, and that a browser extension is not blocking requests.": "Verbindung zum Heimserver fehlgeschlagen - bitte überprüfe die Internetverbindung und stelle sicher, dass dem SSL-Zertifikat deines Heimservers vertraut wird und dass Anfragen nicht durch eine Browser-Erweiterung blockiert werden.", + "Can't connect to homeserver - please check your connectivity, ensure your homeserver's SSL certificate is trusted, and that a browser extension is not blocking requests.": "Verbindung zum Heim-Server fehlgeschlagen – bitte überprüfe die Internetverbindung und stelle sicher, dass dem SSL-Zertifikat deines Heimservers vertraut wird und dass Anfragen nicht durch eine Browser-Erweiterung blockiert werden.", "Close": "Schließen", "Decline": "Ablehnen", "Failed to upload profile picture!": "Hochladen des Profilbilds fehlgeschlagen!", @@ -360,7 +360,7 @@ "%(oneUser)schanged their avatar %(count)s times|one": "%(oneUser)shat das Profilbild geändert", "Members only (since the point in time of selecting this option)": "Mitglieder", "Members only (since they were invited)": "Mitglieder (ab Einladung)", - "Members only (since they joined)": "Mitglieder (ab Beitreten)", + "Members only (since they joined)": "Mitglieder (ab Betreten)", "A text message has been sent to %(msisdn)s": "Eine Textnachricht wurde an %(msisdn)s gesendet", "%(severalUsers)srejected their invitations %(count)s times|other": "%(severalUsers)shaben ihre Einladungen %(count)s-mal abgelehnt", "%(oneUser)srejected their invitation %(count)s times|other": "%(oneUser)shat die Einladung %(count)s-mal abgelehnt", @@ -380,7 +380,7 @@ "Enable inline URL previews by default": "URL-Vorschau standardmäßig aktivieren", "Enable URL previews for this room (only affects you)": "URL-Vorschau für dich in diesem Raum", "Enable URL previews by default for participants in this room": "URL-Vorschau für Raummitglieder", - "Please note you are logging into the %(hs)s server, not matrix.org.": "Du meldest dich gerade am Server von %(hs)s an, nicht auf matrix.org.", + "Please note you are logging into the %(hs)s server, not matrix.org.": "Du meldest dich gerade auf dem Server von %(hs)s an, nicht auf matrix.org.", "URL previews are disabled by default for participants in this room.": "URL-Vorschau ist für Mitglieder des Raumes standardmäßig deaktiviert.", "URL previews are enabled by default for participants in this room.": "URL-Vorschau ist für Mitglieder des Raumes standardmäßig aktiviert.", "Restricted": "Eingeschränkt", @@ -392,7 +392,7 @@ "Idle for %(duration)s": "Abwesend seit %(duration)s", "Offline for %(duration)s": "Offline seit %(duration)s", "Unknown for %(duration)s": "Unbekannt seit %(duration)s", - "This homeserver doesn't offer any login flows which are supported by this client.": "Dieser Heimserver verfügt über keines von dieser Anwendung unterstütztes Anmeldeverfahren.", + "This homeserver doesn't offer any login flows which are supported by this client.": "Dieser Heim-Server verfügt über keines von dieser Anwendung unterstütztes Anmeldeverfahren.", "Call Failed": "Anruf fehlgeschlagen", "Send": "Senden", "collapse": "Verbergen", @@ -424,7 +424,7 @@ "What's New": "Was ist neu", "On": "An", "Changelog": "Änderungsprotokoll", - "Waiting for response from server": "Auf Antwort vom Server warten", + "Waiting for response from server": "Warte auf Antwort vom Server", "Failed to send logs: ": "Senden von Protokolldateien fehlgeschlagen: ", "This Room": "In diesem Raum", "Resend": "Erneut senden", @@ -444,7 +444,7 @@ "Developer Tools": "Entwicklungswerkzeuge", "Preparing to send logs": "Senden von Protokolldateien wird vorbereitet", "Saturday": "Samstag", - "The server may be unavailable or overloaded": "Der Server ist vermutlich nicht erreichbar oder überlastet", + "The server may be unavailable or overloaded": "Der Server ist möglicherweise nicht erreichbar oder überlastet", "Reject": "Ablehnen", "Monday": "Montag", "Remove from Directory": "Aus dem Raum-Verzeichnis entfernen", @@ -494,10 +494,10 @@ "Enable widget screenshots on supported widgets": "Bildschirmfotos für unterstützte Widgets", "Send analytics data": "Analysedaten senden", "Muted Users": "Stummgeschaltete Benutzer", - "Can't leave Server Notices room": "Du kannst den Raum für Servernotizen nicht verlassen", - "This room is used for important messages from the Homeserver, so you cannot leave it.": "Du kannst diesen Raum nicht verlassen, da dieser Raum für wichtige Nachrichten vom Heim-Server verwendet wird.", + "Can't leave Server Notices room": "Der Raum für Server-Mitteilungen kann nicht verlassen werden", + "This room is used for important messages from the Homeserver, so you cannot leave it.": "Du kannst diesen Raum nicht verlassen, da dieser Raum für wichtige Mitteilungen vom Heim-Server verwendet wird.", "Terms and Conditions": "Geschäftsbedingungen", - "To continue using the %(homeserverDomain)s homeserver you must review and agree to our terms and conditions.": "Um den %(homeserverDomain)s -Heimserver weiter zu verwenden, musst du die Geschäftsbedingungen sichten und ihnen zustimmen.", + "To continue using the %(homeserverDomain)s homeserver you must review and agree to our terms and conditions.": "Um den %(homeserverDomain)s-Heim-Server weiterzuverwenden, musst du die Nutzungsbedingungen sichten und akzeptieren.", "Review terms and conditions": "Geschäftsbedingungen anzeigen", "Share Link to User": "Link zu Benutzer teilen", "Share room": "Raum teilen", @@ -508,7 +508,7 @@ "Link to selected message": "Link zur ausgewählten Nachricht", "No Audio Outputs detected": "Keine Audioausgabe erkannt", "Audio Output": "Audioausgabe", - "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.": "In verschlüsselten Räumen wie diesem ist die Linkvorschau standardmäßig deaktiviert, damit dein Heimserver (der die Vorschau erzeugt) keine Informationen über Links in diesem Raum bekommt.", + "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.": "In verschlüsselten Räumen wie diesem ist die Linkvorschau standardmäßig deaktiviert, damit dein Heim-Server (der die Vorschau erzeugt) keine Informationen über Links in diesem Raum erhält.", "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.": "Die URL-Vorschau kann Informationen wie den Titel, die Beschreibung sowie ein Vorschaubild der Website enthalten.", "You can't send any messages until you review and agree to our terms and conditions.": "Du kannst keine Nachrichten senden bis du unsere Geschäftsbedingungen gelesen und akzeptiert hast.", "Demote yourself?": "Dein eigenes Berechtigungslevel herabsetzen?", @@ -523,11 +523,11 @@ "This homeserver has exceeded one of its resource limits.": "Dieser Heim-Server hat einen seiner Ressourcengrenzwerte überschritten.", "Upgrade Room Version": "Raumversion aktualisieren", "Create a new room with the same name, description and avatar": "Einen neuen Raum mit demselben Namen, Beschreibung und Profilbild erstellen", - "Update any local room aliases to point to the new room": "Alle lokalen Raum-Aliase aktualisieren, damit sie auf den neuen Raum zeigen", + "Update any local room aliases to point to the new room": "Alle lokalen Raumaliase aktualisieren, damit sie auf den neuen Raum zeigen", "Stop users from speaking in the old version of the room, and post a message advising users to move to the new room": "Nutzern verbieten in dem Raum mit der alten Version zu schreiben und eine Nachricht senden, die den Nutzern rät in den neuen Raum zu wechseln", "Put a link back to the old room at the start of the new room so people can see old messages": "Zu Beginn des neuen Raumes einen Link zum alten Raum setzen, damit Personen die alten Nachrichten sehen können", - "Your message wasn't sent because this homeserver has hit its Monthly Active User Limit. Please contact your service administrator to continue using the service.": "Deine Nachricht wurde nicht gesendet, weil dieser Heimserver sein Limit an monatlich aktiven Benutzern erreicht hat. Bitte kontaktiere deinen Systemadministrator um diesen Dienst weiter zu nutzen.", - "Your message wasn't sent because this homeserver has exceeded a resource limit. Please contact your service administrator to continue using the service.": "Deine Nachricht wurde nicht gesendet, weil dieser Heimserver ein Ressourcen-Limit erreicht hat. Bitte kontaktiere deinen Systemadministrator um diesen Dienst weiter zu nutzen.", + "Your message wasn't sent because this homeserver has hit its Monthly Active User Limit. Please contact your service administrator to continue using the service.": "Deine Nachricht wurde nicht gesendet, weil dieser Heim-Server sein Limit an monatlich aktiven Benutzern erreicht hat. Bitte kontaktiere deine Systemadministration, um diesen Dienst weiterzunutzen.", + "Your message wasn't sent because this homeserver has exceeded a resource limit. Please contact your service administrator to continue using the service.": "Deine Nachricht wurde nicht gesendet, weil dieser Heim-Server ein Ressourcen-Limit erreicht hat. Bitte kontaktiere deine Systemadministration, um diesen Dienst weiterzunutzen.", "Please contact your service administrator to continue using this service.": "Bitte kontaktiere deinen Systemadministrator um diesen Dienst weiter zu nutzen.", "Please contact your homeserver administrator.": "Bitte setze dich mit der Administration deines Heim-Servers in Verbindung.", "Legal": "Rechtliches", @@ -543,16 +543,16 @@ "%(senderName)s set the main address for this room to %(address)s.": "%(senderName)s hat als Hauptadresse des Raums %(address)s festgelegt.", "%(senderName)s removed the main address for this room.": "%(senderName)s hat die Hauptadresse von diesem Raum entfernt.", "Before submitting logs, you must create a GitHub issue to describe your problem.": "Bevor du Protokolldateien übermittelst, musst du auf GitHub einen \"Issue\" erstellen um dein Problem zu beschreiben.", - "%(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 benutzt nun 3 - 5-mal weniger Arbeitsspeicher, indem Informationen über andere Nutzer erst bei Bedarf geladen werden. Bitte warte, während die Daten erneut mit dem Server abgeglichen werden!", + "%(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 benutzt nun 3 bis 5 Mal weniger Arbeitsspeicher, indem Informationen über andere Nutzer erst bei Bedarf geladen werden. Bitte warte, während die Daten erneut mit dem Server abgeglichen werden!", "Updating %(brand)s": "Aktualisiere %(brand)s", "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.": "Du hast zuvor %(brand)s auf %(host)s ohne das verzögerte Laden von Mitgliedern genutzt. In dieser Version war das verzögerte Laden deaktiviert. Da die lokal zwischengespeicherten Daten zwischen diesen Einstellungen nicht kompatibel sind, muss %(brand)s dein Konto neu synchronisieren.", "If the other version of %(brand)s is still open in another tab, please close it as using %(brand)s on the same host with both lazy loading enabled and disabled simultaneously will cause issues.": "Wenn %(brand)s mit der alten Version in einem anderen Tab geöffnet ist, schließe dies bitte, da das parallele Nutzen von %(brand)s auf demselben Host mit aktivierten und deaktivierten verzögertem Laden, Probleme verursachen wird.", "Incompatible local cache": "Inkompatibler lokaler Zwischenspeicher", "Clear cache and resync": "Zwischenspeicher löschen und erneut synchronisieren", - "Please review and accept the policies of this homeserver:": "Bitte sieh dir alle Bedingungen dieses Heimservers an und akzeptiere sie:", + "Please review and accept the policies of this homeserver:": "Bitte sieh dir alle Bedingungen dieses Heim-Servers an und akzeptiere sie:", "Add some now": "Jetzt hinzufügen", "Unable to load! Check your network connectivity and try again.": "Konnte nicht geladen werden! Überprüfe die Netzwerkverbindung und versuche es erneut.", - "Delete Backup": "Sicherung löschen", + "Delete Backup": "Lösche Sicherung", "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": "Um zu vermeiden, dass dein Verlauf verloren geht, musst du deine Raumschlüssel exportieren, bevor du dich abmeldest. Dazu musst du auf die neuere Version von %(brand)s zurückgehen", "Incompatible Database": "Inkompatible Datenbanken", "Continue With Encryption Disabled": "Mit deaktivierter Verschlüsselung fortfahren", @@ -593,7 +593,7 @@ "Names and surnames by themselves are easy to guess": "Namen und Familiennamen alleine sind einfach zu erraten", "Common names and surnames are easy to guess": "Häufige Namen und Familiennamen sind einfach zu erraten", "You do not have permission to invite people to this room.": "Du hast keine Berechtigung, Personen in diesen Raum einzuladen.", - "Unknown server error": "Unbekannter Serverfehler", + "Unknown server error": "Unbekannter Server-Fehler", "Short keyboard patterns are easy to guess": "Kurze Tastaturmuster sind einfach zu erraten", "Messages containing @room": "Nachrichten mit @room", "Encrypted messages in one-to-one chats": "Verschlüsselte Direktnachrichten", @@ -602,12 +602,12 @@ "Straight rows of keys are easy to guess": "Gerade Reihen von Tasten sind einfach zu erraten", "Unable to load key backup status": "Konnte Status der Schlüsselsicherung nicht laden", "Set up": "Einrichten", - "Please review and accept all of the homeserver's policies": "Bitte prüfe und akzeptiere alle Richtlinien des Heimservers", + "Please review and accept all of the homeserver's policies": "Bitte prüfe und akzeptiere alle Richtlinien des Heim-Servers", "Unable to load commit detail: %(msg)s": "Konnte Übermittlungsdetails nicht laden: %(msg)s", "Unable to load backup status": "Konnte Sicherungsstatus nicht laden", "Failed to decrypt %(failedCount)s sessions!": "Konnte %(failedCount)s Sitzungen nicht entschlüsseln!", "Invalid homeserver discovery response": "Ungültige Antwort beim Aufspüren des Heim-Servers", - "Invalid identity server discovery response": "Ungültige Antwort beim Aufspüren des Identitätsservers", + "Invalid identity server discovery response": "Ungültige Antwort beim Aufspüren des Identitäts-Servers", "General failure": "Allgemeiner Fehler", "Failed to perform homeserver discovery": "Fehler beim Aufspüren des Heim-Servers", "Set up Secure Message Recovery": "Richte Sichere Nachrichten-Wiederherstellung ein", @@ -659,7 +659,7 @@ "Room version": "Raumversion", "Room version:": "Raumversion:", "General": "Allgemein", - "Set a new account password...": "Neues Passwort festlegen …", + "Set a new account password...": "Neues Kontopasswort festlegen …", "Email addresses": "E-Mail-Adressen", "Phone numbers": "Telefonnummern", "Language and region": "Sprache und Region", @@ -675,7 +675,7 @@ "Room Addresses": "Raumadressen", "Preferences": "Optionen", "Room list": "Raumliste", - "The file '%(fileName)s' exceeds this homeserver's size limit for uploads": "Die Datei '%(fileName)s' überschreitet die maximale Uploadgröße deines Heim-Servers", + "The file '%(fileName)s' exceeds this homeserver's size limit for uploads": "Die Datei '%(fileName)s' überschreitet das Hochladelimit deines Heim-Servers", "This room has no topic.": "Dieser Raum hat kein Thema.", "%(senderDisplayName)s made the room public to whoever knows the link.": "%(senderDisplayName)s hat den Raum für jeden, der den Link kennt, öffentlich gemacht.", "%(senderDisplayName)s made the room invite only.": "%(senderDisplayName)s hat den Raum auf eingeladene Benutzer beschränkt.", @@ -796,7 +796,7 @@ "Sign in instead": "Stattdessen anmelden", "Your password has been reset.": "Dein Passwort wurde zurückgesetzt.", "Set a new password": "Erstelle ein neues Passwort", - "This homeserver does not support login using email address.": "Dieser Heimserver unterstützt eine Anmeldung über E-Mail-Adresse nicht.", + "This homeserver does not support login using email address.": "Dieser Heim-Server unterstützt die Anmeldung per E-Mail-Adresse nicht.", "Create account": "Konto anlegen", "Registration has been disabled on this homeserver.": "Registrierungen wurden auf diesem Heim-Server deaktiviert.", "Keep going...": "Fortfahren …", @@ -830,7 +830,7 @@ "Send %(eventType)s events": "%(eventType)s-Ereignisse senden", "Select the roles required to change various parts of the room": "Wähle Rollen, die benötigt werden, um einige Teile des Raumes zu ändern", "Enable encryption?": "Verschlüsselung aktivieren?", - "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.": "Sobald aktiviert, kann die Verschlüsselung für einen Raum nicht mehr deaktiviert werden. Nachrichten in einem verschlüsselten Raum können nur noch von Teilnehmern aber nicht mehr vom Server gelesen werden. Einige Bots und Brücken werden vielleicht nicht mehr funktionieren. Erfahre mehr über Verschlüsselung.", + "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.": "Sobald aktiviert, kann die Verschlüsselung für einen Raum nicht mehr deaktiviert werden. Nachrichten in einem verschlüsselten Raum können nur noch von Teilnehmern, aber nicht mehr vom Server gelesen werden. Einige Bots und Brücken werden vielleicht nicht mehr funktionieren. Erfahre mehr über Verschlüsselung.", "Error updating main address": "Fehler beim Aktualisieren der Hauptadresse", "There was an error updating the room's main address. It may not be allowed by the server or a temporary failure occurred.": "Es gab ein Problem beim Aktualisieren der Raum-Hauptadresse. Es kann sein, dass der Server dies verbietet oder ein temporäres Problem aufgetreten ist.", "Power level": "Berechtigungsstufe", @@ -849,19 +849,19 @@ "Sends the given emote coloured as a rainbow": "Zeigt Aktionen in Regenbogenfarben", "%(senderName)s revoked the invitation for %(targetDisplayName)s to join the room.": "%(senderName)s hat die Einladung für %(targetDisplayName)s zurückgezogen.", "Cannot reach homeserver": "Heim-Server nicht erreichbar", - "Ensure you have a stable internet connection, or get in touch with the server admin": "Stelle sicher, dass du eine stabile Internetverbindung hast oder wende dich an deinen Serveradministrator", + "Ensure you have a stable internet connection, or get in touch with the server admin": "Stelle sicher, dass du eine stabile Internetverbindung hast oder wende dich an deine Server-Administration", "Ask your %(brand)s admin to check your config for incorrect or duplicate entries.": "Wende dich an deinen %(brand)s-Admin um deine Konfiguration auf ungültige oder doppelte Einträge zu überprüfen.", "Unexpected error resolving identity server configuration": "Ein unerwarteter Fehler ist beim Laden der Identitäts-Server-Konfiguration aufgetreten", "Cannot reach identity server": "Identitäts-Server nicht erreichbar", - "You can register, but some features will be unavailable until the identity server is back online. If you keep seeing this warning, check your configuration or contact a server admin.": "Du kannst dich registrieren, einige Funktionen werden allerdings erst verfügbar sein, sobald der Identitäts-Server wieder in Betrieb ist. Sollte diese Warnmeldung weiterhin erscheinen, überprüfe deine Konfiguration oder kontaktiere die Serveradministration.", - "You can reset your password, but some features will be unavailable until the identity server is back online. If you keep seeing this warning, check your configuration or contact a server admin.": "Du kannst dein Passwort zurücksetzen, einige Funktionen werden allerdings erst verfügbar sein, sobald der Identitäts-Server wieder in Betrieb ist. Sollte diese Warnmeldung weiterhin erscheinen, überprüfe deine Konfiguration oder kontaktiere die Serveradministration.", - "You can log in, but some features will be unavailable until the identity server is back online. If you keep seeing this warning, check your configuration or contact a server admin.": "Du kannst dich anmelden, einige Funktionen werden allerdings erst verfügbar sein, sobald der Identitäts-Server wieder in Betrieb ist. Sollte diese Warnmeldung weiterhin erscheinen, überprüfe deine Konfiguration oder kontaktiere deine Serveradministration.", + "You can register, but some features will be unavailable until the identity server is back online. If you keep seeing this warning, check your configuration or contact a server admin.": "Du kannst dich registrieren, einige Funktionen werden allerdings erst verfügbar sein, sobald der Identitäts-Server wieder in Betrieb ist. Sollte diese Warnmeldung weiterhin erscheinen, überprüfe deine Konfiguration oder kontaktiere die Server-Administration.", + "You can reset your password, but some features will be unavailable until the identity server is back online. If you keep seeing this warning, check your configuration or contact a server admin.": "Du kannst dein Passwort zurücksetzen, einige Funktionen werden allerdings erst verfügbar sein, sobald der Identitäts-Server wieder in Betrieb ist. Sollte diese Warnmeldung weiterhin erscheinen, überprüfe deine Konfiguration oder kontaktiere die Server-Administration.", + "You can log in, but some features will be unavailable until the identity server is back online. If you keep seeing this warning, check your configuration or contact a server admin.": "Du kannst dich anmelden, einige Funktionen werden allerdings erst verfügbar sein, sobald der Identitäts-Server wieder in Betrieb ist. Sollte diese Warnmeldung weiterhin erscheinen, überprüfe deine Konfiguration oder kontaktiere deine Server-Administration.", "No homeserver URL provided": "Keine Heim-Server-URL angegeben", "Unexpected error resolving homeserver configuration": "Ein unerwarteter Fehler ist beim Laden der Heim-Server-Konfiguration aufgetreten", "The user's homeserver does not support the version of the room.": "Die Raumversion wird vom Heim-Server des Benutzers nicht unterstützt.", "Show hidden events in timeline": "Zeige versteckte Ereignisse im Verlauf", "Reset": "Zurücksetzen", - "Joining room …": "Raum betreten …", + "Joining room …": "Betrete Raum …", "Rejecting invite …": "Einladung ablehnen…", "Sign Up": "Registrieren", "Sign In": "Anmelden", @@ -893,19 +893,19 @@ "Call failed due to misconfigured server": "Anruf aufgrund eines falsch konfigurierten Servers fehlgeschlagen", "Try using turn.matrix.org": "Versuche es mit turn.matrix.org", "You do not have the required permissions to use this command.": "Du hast nicht die erforderlichen Berechtigungen, diesen Befehl zu verwenden.", - "Checking server": "Server wird überprüft", + "Checking server": "Überprüfe Server", "Identity server has no terms of service": "Der Identitäts-Server hat keine Nutzungsbedingungen", "Disconnect": "Trennen", - "Use an identity server": "Benutze einen Identitätsserver", - "Use an identity server to invite by email. Click continue to use the default identity server (%(defaultIdentityServerName)s) or manage in Settings.": "Benutze einen Identitätsserver, um andere mittels E-Mail einzuladen. Klicke auf fortfahren, um den Standardidentitätsserver (%(defaultIdentityServerName)s) zu benutzen oder ändere ihn in den Einstellungen.", + "Use an identity server": "Benutze einen Identitäts-Server", + "Use an identity server to invite by email. Click continue to use the default identity server (%(defaultIdentityServerName)s) or manage in Settings.": "Benutze einen Identitäts-Server, um andere mittels E-Mail einzuladen. Klicke auf fortfahren, um den Standard-Identitäts-Server (%(defaultIdentityServerName)s) zu benutzen oder ändere ihn in den Einstellungen.", "Terms of service not accepted or the identity server is invalid.": "Nutzungsbedingungen nicht akzeptiert oder der Identitäts-Server ist ungültig.", "Using an identity server is optional. If you choose not to use an identity server, you won't be discoverable by other users and you won't be able to invite others by email or phone.": "Die Verwendung eines Identitäts-Servers ist optional. Solltest du dich dazu entschließen, keinen Identitäts-Server zu verwenden, kannst du von anderen Nutzern nicht gefunden werden und andere nicht per E-Mail-Adresse oder Telefonnummer einladen.", - "Do not use an identity server": "Keinen Identitätsserver verwenden", - "Enter a new identity server": "Gib einen neuen Identitätsserver ein", + "Do not use an identity server": "Keinen Identitäts-Server verwenden", + "Enter a new identity server": "Gib einen neuen Identitäts-Server ein", "Clear personal data": "Persönliche Daten löschen", - "Disconnecting from your identity server will mean you won't be discoverable by other users and you won't be able to invite others by email or phone.": "Wenn du die Verbindung zu deinem Identitätsserver trennst, kannst du nicht mehr von anderen Benutzern gefunden werden und andere nicht mehr per E-Mail oder Telefonnummer einladen.", + "Disconnecting from your identity server will mean you won't be discoverable by other users and you won't be able to invite others by email or phone.": "Wenn du die Verbindung zu deinem Identitäts-Server trennst, kannst du nicht mehr von anderen Benutzern gefunden werden und andere nicht mehr per E-Mail oder Telefonnummer einladen.", "Please ask the administrator of your homeserver (%(homeserverDomain)s) to configure a TURN server in order for calls to work reliably.": "Bitte frage die Administration deines Heim-Servers (%(homeserverDomain)s) darum, einen TURN-Server einzurichten, damit Anrufe zuverlässig funktionieren.", - "Disconnect from the identity server ?": "Verbindung zum Identitätsserver trennen?", + "Disconnect from the identity server ?": "Verbindung zum Identitäts-Server trennen?", "Add Email Address": "E-Mail-Adresse hinzufügen", "Add Phone Number": "Telefonnummer hinzufügen", "Changes the avatar of the current room": "Ändert das Icon vom Raum", @@ -919,17 +919,17 @@ "Only continue if you trust the owner of the server.": "Fahre nur fort, wenn du den Server-Betreibenden vertraust.", "Trust": "Vertrauen", "Custom (%(level)s)": "Benutzerdefiniert (%(level)s)", - "Sends a message as plain text, without interpreting it as markdown": "Verschickt eine Nachricht in Rohtext, ohne sie als Markdown darzustellen", - "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.", + "Sends a message as plain text, without interpreting it as markdown": "Sendet eine Nachricht als Klartext, ohne sie als Markdown darzustellen", + "Use an identity server to invite by email. Manage in Settings.": "Verwende einen Identitäts-Server, um per E-Mail einladen zu können. Lege einen in den Einstellungen fest.", "%(name)s (%(userId)s)": "%(name)s (%(userId)s)", "Try out new ways to ignore people (experimental)": "Verwende neue Möglichkeiten, Menschen zu blockieren", "My Ban List": "Meine Bannliste", - "This is your list of users/servers you have blocked - don't leave the room!": "Dies ist die Liste von Benutzer und Servern, die du blockiert hast - verlasse diesen Raum nicht!", + "This is your list of users/servers you have blocked - don't leave the room!": "Dies ist die Liste von Benutzer und Servern, die du blockiert hast – verlasse diesen Raum nicht!", "Accept to continue:": "Akzeptiere , um fortzufahren:", - "Change identity server": "Identitätsserver wechseln", + "Change identity server": "Identitäts-Server wechseln", "You should remove your personal data from identity server before disconnecting. Unfortunately, identity server is currently offline or cannot be reached.": "Du solltest deine persönlichen Daten vom Identitäts-Server entfernen, bevor du die Verbindung trennst. Leider ist der Identitäts-Server derzeit außer Betrieb oder kann nicht erreicht werden.", "You should:": "Du solltest:", - "check your browser plugins for anything that might block the identity server (such as Privacy Badger)": "Überprüfe deinen Browser auf Erweiterungen, die den Identitätsserver blockieren könnten (z.B. Privacy Badger)", + "check your browser plugins for anything that might block the identity server (such as Privacy Badger)": "Überprüfe deinen Browser auf Erweiterungen, die den Identitäts-Server blockieren könnten (z. B. Privacy Badger)", "Error upgrading room": "Fehler bei Raumaktualisierung", "Double check that your server supports the room version chosen and try again.": "Überprüfe nochmal ob dein Server die ausgewählte Raumversion unterstützt und versuche es nochmal.", "%(senderName)s placed a voice call.": "%(senderName)s hat einen Sprachanruf getätigt.", @@ -968,17 +968,17 @@ "not stored": "nicht gespeichert", "Backup has a signature from unknown user with ID %(deviceId)s": "Die Sicherung hat eine Signatur von unbekanntem Nutzer mit ID %(deviceId)s", "Clear notifications": "Benachrichtigungen löschen", - "Disconnect from the identity server and connect to instead?": "Vom Identitätsserver trennen, und stattdessen eine Verbindung zu aufbauen?", - "The identity server you have chosen does not have any terms of service.": "Der von dir gewählte Identitätsserver gibt keine Nutzungsbedingungen an.", - "Disconnect identity server": "Verbindung zum Identitätsserver trennen", - "contact the administrators of identity server ": "Administration des Identitätsservers kontaktieren", + "Disconnect from the identity server and connect to instead?": "Vom Identitäts-Server trennen, und stattdessen mit verbinden?", + "The identity server you have chosen does not have any terms of service.": "Der von dir gewählte Identitäts-Server gibt keine Nutzungsbedingungen an.", + "Disconnect identity server": "Verbindung zum Identitäts-Server trennen", + "contact the administrators of identity server ": "Kontaktiere die Administration des Identitäts-Servers ", "wait and try again later": "warte und versuche es später erneut", "Disconnect anyway": "Verbindung trotzdem trennen", - "You are still sharing your personal data on the identity server .": "Du teilst deine persönlichen Daten immer noch auf dem Identitätsserver .", - "We recommend that you remove your email addresses and phone numbers from the identity server before disconnecting.": "Wir empfehlen, dass du deine E-Mail-Adressen und Telefonnummern vom Identitätsserver löschst, bevor du die Verbindung trennst.", + "You are still sharing your personal data on the identity server .": "Du teilst deine persönlichen Daten noch immer auf dem Identitäts-Server .", + "We recommend that you remove your email addresses and phone numbers from the identity server before disconnecting.": "Wir empfehlen, dass du deine E-Mail-Adressen und Telefonnummern vom Identitäts-Server löschst, bevor du die Verbindung trennst.", "You are not currently using an identity server. To discover and be discoverable by existing contacts you know, add one below.": "Zurzeit benutzt du keinen Identitäts-Server. Trage unten einen Server ein, um Kontakte zu finden und von anderen gefunden zu werden.", "Manage integrations": "Integrationen verwalten", - "Agree to the identity server (%(serverName)s) Terms of Service to allow yourself to be discoverable by email address or phone number.": "Stimme den Nutzungsbedingungen des Identitätsservers %(serverName)s zu, um dich per E-Mail-Adresse und Telefonnummer auffindbar zu machen.", + "Agree to the identity server (%(serverName)s) Terms of Service to allow yourself to be discoverable by email address or phone number.": "Stimme den Nutzungsbedingungen des Identitäts-Servers %(serverName)s zu, um per E-Mail-Adresse oder Telefonnummer auffindbar zu werden.", "Clear cache and reload": "Zwischenspeicher löschen und neu laden", "Ignored/Blocked": "Ignoriert/Blockiert", "Something went wrong. Please try again or view your console for hints.": "Etwas ist schief gelaufen. Bitte versuche es erneut oder sieh für weitere Hinweise in deiner Konsole nach.", @@ -986,7 +986,7 @@ "Error removing ignored user/server": "Fehler beim Entfernen eines blockierten Benutzers/Servers", "Error unsubscribing from list": "Fehler beim Deabonnieren der Liste", "Please try again or view your console for hints.": "Bitte versuche es erneut oder sieh für weitere Hinweise in deine Konsole.", - "Server rules": "Serverregeln", + "Server rules": "Server-Regeln", "User rules": "Nutzerregeln", "You have not ignored anyone.": "Du hast niemanden blockiert.", "You are currently ignoring:": "Du ignorierst momentan:", @@ -1066,7 +1066,7 @@ "Done": "Fertig", "Trusted": "Vertrauenswürdig", "Not trusted": "Nicht vertrauenswürdig", - "%(count)s verified sessions|one": "1 verifizierte Sitzung", + "%(count)s verified sessions|one": "Eine verifizierte Sitzung", "%(count)s sessions|one": "%(count)s Sitzung", "Messages in this room are not end-to-end encrypted.": "Nachrichten in diesem Raum sind nicht Ende-zu-Ende verschlüsselt.", "Security": "Sicherheit", @@ -1088,12 +1088,12 @@ "Match system theme": "An Systemdesign anpassen", "Unable to load session list": "Sitzungsliste kann nicht geladen werden", "This session is backing up your keys. ": "Diese Sitzung sichert deine Schlüssel. ", - "Connect this session to Key Backup": "Diese Sitzung mit der Schlüsselsicherung verbinden", + "Connect this session to Key Backup": "Verbinde diese Sitzung mit einer Schlüsselsicherung", "Backup has a signature from unknown session with ID %(deviceId)s": "Die Sicherung hat eine Signatur von einer unbekannten Sitzung mit der ID %(deviceId)s", "Backup has a valid signature from this session": "Die Sicherung hat eine gültige Signatur von dieser Sitzung", "Backup has an invalid signature from this session": "Die Sicherung hat eine ungültige Signatur von dieser Sitzung", - "Discovery options will appear once you have added an email above.": "Möglichkeiten zur Auffindbarkeit werden angezeigt, sobald du oben eine E-Mail-Adresse hinzugefügt hast.", - "Discovery options will appear once you have added a phone number above.": "Möglichkeiten zur Auffindbarkeit werden angezeigt, sobald du oben eine Telefonnummer hinzugefügt hast.", + "Discovery options will appear once you have added an email above.": "Entdeckungsoptionen werden angezeigt, sobald du eine E-Mail-Adresse hinzugefügt hast.", + "Discovery options will appear once you have added a phone number above.": "Entdeckungsoptionen werden angezeigt, sobald du eine Telefonnummer hinzugefügt hast.", "Close preview": "Vorschau schließen", "Join the discussion": "An Diskussion teilnehmen", "Remove for everyone": "Für alle entfernen", @@ -1151,22 +1151,22 @@ "Encrypted by a deleted session": "Von einer gelöschten Sitzung verschlüsselt", "The encryption used by this room isn't supported.": "Die von diesem Raum verwendete Verschlüsselung wird nicht unterstützt.", "React": "Reagieren", - "e.g. my-room": "z.B. mein-raum", - "Use an identity server to invite by email. Use the default (%(defaultIdentityServerName)s) or manage in Settings.": "Verwende einen Identitätsserver, um per E-Mail einzuladen. Nutze den Standardidentitätsserver (%(defaultIdentityServerName)s) oder konfiguriere einen in den Einstellungen.", - "Use an identity server to invite by email. Manage in Settings.": "Verwende einen Identitätsserver, um mit einer E-Mail-Adresse einzuladen. Diese können in den Einstellungen konfiguriert werden.", + "e.g. my-room": "z. B. mein-raum", + "Use an identity server to invite by email. Use the default (%(defaultIdentityServerName)s) or manage in Settings.": "Verwende einen Identitäts-Server, um per E-Mail einzuladen. Nutze den Standardidentitäts-Server (%(defaultIdentityServerName)s) oder konfiguriere einen in den Einstellungen.", + "Use an identity server to invite by email. Manage in Settings.": "Verwende einen Identitäts-Server, um per E-Mail-Adresse einladen zu können. Lege einen in den Einstellungen fest.", "Create a public room": "Öffentlichen Raum erstellen", "Show advanced": "Erweiterte Einstellungen", "Verify session": "Sitzung verifizieren", "Session key": "Sitzungsschlüssel", "Recent Conversations": "Letzte Unterhaltungen", "Report Content to Your Homeserver Administrator": "Inhalte an die Administration deines Heim-Servers melden", - "Reporting this message will send its unique 'event ID' to the administrator of your homeserver. If messages in this room are encrypted, your homeserver administrator will not be able to read the message text or view any files or images.": "Wenn du diese Nachricht meldest, wird die eindeutige Event-ID an die Administration deines Heimservers übermittelt. Wenn die Nachrichten in diesem Raum verschlüsselt sind, wird deine Heimserver-Administration nicht in der Lage sein, Nachrichten zu lesen oder Medien einzusehen.", + "Reporting this message will send its unique 'event ID' to the administrator of your homeserver. If messages in this room are encrypted, your homeserver administrator will not be able to read the message text or view any files or images.": "Wenn du diese Nachricht meldest, wird die eindeutige Ereignis-ID an die Administration deines Heim-Servers übermittelt. Wenn die Nachrichten in diesem Raum verschlüsselt sind, wird deine Heim-Server-Administration nicht in der Lage sein, Nachrichten zu lesen oder Medien einzusehen.", "Send report": "Bericht senden", "Report Content": "Inhalt melden", "%(creator)s created and configured the room.": "%(creator)s hat den Raum erstellt und konfiguriert.", "Keep a copy of it somewhere secure, like a password manager or even a safe.": "Bewahre eine Kopie an einem sicheren Ort, wie einem Passwort-Manager oder in einem Safe auf.", "Copy": "Kopieren", - "Sends a message as html, without interpreting it as markdown": "Verschickt eine Nachricht im HTML-Format, ohne sie als Markdown zu darzustellen", + "Sends a message as html, without interpreting it as markdown": "Sendet eine Nachricht als HTML, ohne sie als Markdown darzustellen", "Show rooms with unread notifications first": "Zeige Räume mit ungelesenen Benachrichtigungen zuerst an", "Show shortcuts to recently viewed rooms above the room list": "Kürzlich besuchte Räume anzeigen", "Use Single Sign On to continue": "Einmalanmeldung zum Fortfahren nutzen", @@ -1179,8 +1179,8 @@ "Could not find user in room": "Benutzer konnte nicht im Raum gefunden werden", "Click the button below to confirm adding this email address.": "Klicke unten auf den Knopf, um die hinzugefügte E-Mail-Adresse zu bestätigen.", "Confirm adding phone number": "Hinzugefügte Telefonnummer bestätigen", - "%(senderName)s changed a rule that was banning servers matching %(oldGlob)s to matching %(newGlob)s for %(reason)s": "%(senderName)s ändert eine Ausschlussregel für Server von %(oldGlob)s nach %(newGlob)s wegen %(reason)s", - "%(senderName)s updated a ban rule that was matching %(oldGlob)s to matching %(newGlob)s for %(reason)s": "%(senderName)s erneuert eine Ausschlussregel von %(oldGlob)s nach %(newGlob)s wegen %(reason)s", + "%(senderName)s changed a rule that was banning servers matching %(oldGlob)s to matching %(newGlob)s for %(reason)s": "%(senderName)s änderte eine Ausschlussregel für Server von %(oldGlob)s nach %(newGlob)s wegen %(reason)s", + "%(senderName)s updated a ban rule that was matching %(oldGlob)s to matching %(newGlob)s for %(reason)s": "%(senderName)s aktualisierte eine Ausschlussregel von %(oldGlob)s nach %(newGlob)s wegen %(reason)s", "Not Trusted": "Nicht vertraut", "Manually Verify by Text": "Verifiziere manuell mit einem Text", "Interactively verify by Emoji": "Verifiziere interaktiv mit Emojis", @@ -1218,12 +1218,12 @@ "Error adding ignored user/server": "Fehler beim Blockieren eines Nutzers/Servers", "None": "Nichts", "Ban list rules - %(roomName)s": "Verbotslistenregeln - %(roomName)s", - "Add users and servers you want to ignore here. Use asterisks to have %(brand)s match any characters. For example, @bot:* would ignore all users that have the name 'bot' on any server.": "Füge hier die Benutzer und Server hinzu, die du blockieren willst. Verwende Sternchen, damit %(brand)s mit beliebigen Zeichen übereinstimmt. Bspw. würde @bot: * alle Benutzer blockieren, die auf einem Server den Namen 'bot' haben.", + "Add users and servers you want to ignore here. Use asterisks to have %(brand)s match any characters. For example, @bot:* would ignore all users that have the name 'bot' on any server.": "Füge hier die Benutzer und Server hinzu, die du blockieren willst. Verwende Sternchen, um %(brand)s alle Zeichen abgleichen zu lassen. So würde @bot:* alle Benutzer mit dem Namen „bot“, auf jedem beliebigen Server, blockieren.", "Ignoring people is done through ban lists which contain rules for who to ban. Subscribing to a ban list means the users/servers blocked by that list will be hidden from you.": "Das Ignorieren von Personen erfolgt über Sperrlisten. Wenn eine Sperrliste abonniert wird, werden die von dieser Liste blockierten Benutzer und Server ausgeblendet.", "Personal ban list": "Persönliche Sperrliste", - "Your personal ban list holds all the users/servers you personally don't want to see messages from. After ignoring your first user/server, a new room will show up in your room list named 'My Ban List' - stay in this room to keep the ban list in effect.": "Deine persönliche Sperrliste enthält alle Benutzer/Server, von denen du persönlich keine Nachrichten sehen willst. Nachdem du den ersten Benutzer/Server blockiert hast, wird in der Raumliste \"Meine Sperrliste\" angezeigt - bleibe in diesem Raum, um die Sperrliste aufrecht zu halten.", + "Your personal ban list holds all the users/servers you personally don't want to see messages from. After ignoring your first user/server, a new room will show up in your room list named 'My Ban List' - stay in this room to keep the ban list in effect.": "Deine persönliche Sperrliste enthält alle Benutzer/Server, von denen du persönlich keine Nachrichten sehen willst. Nachdem du den ersten Benutzer/Server blockiert hast, wird in der Raumliste „Meine Sperrliste“ angezeigt – bleibe in diesem Raum, um die Sperrliste aktiv zu halten.", "Server or user ID to ignore": "Zu blockierende Server- oder Benutzer-ID", - "eg: @bot:* or example.org": "z.B. @bot:* oder example.org", + "eg: @bot:* or example.org": "z. B. @bot:* oder example.org", "Subscribed lists": "Abonnierte Listen", "Subscribing to a ban list will cause you to join it!": "Eine Verbotsliste abonnieren bedeutet ihr beizutreten!", "If this isn't what you want, please use a different tool to ignore users.": "Wenn dies nicht das ist, was du willst, verwende ein anderes Werkzeug, um Benutzer zu blockieren.", @@ -1307,7 +1307,7 @@ "This invite to %(roomName)s was sent to %(email)s which is not associated with your account": "Diese Einladung zu %(roomName)s wurde an die Adresse %(email)s gesendet, die nicht zu deinem Konto gehört", "Link this email with your account in Settings to receive invites directly in %(brand)s.": "Verbinde diese E-Mail-Adresse in den Einstellungen mit deinem Konto, um die Einladungen direkt in %(brand)s zu erhalten.", "This invite to %(roomName)s was sent to %(email)s": "Diese Einladung zu %(roomName)s wurde an %(email)s gesendet", - "Use an identity server in Settings to receive invites directly in %(brand)s.": "Verknüpfe einen Identitätsserver in den Einstellungen um die Einladungen direkt in %(brand)s zu erhalten.", + "Use an identity server in Settings to receive invites directly in %(brand)s.": "Verknüpfe einen Identitäts-Server in den Einstellungen, um die Einladungen direkt in %(brand)s zu erhalten.", "Share this email in Settings to receive invites directly in %(brand)s.": "Teile diese E-Mail-Adresse in den Einstellungen, um Einladungen direkt in %(brand)s zu erhalten.", "%(roomName)s can't be previewed. Do you want to join it?": "Vorschau von %(roomName)s kann nicht angezeigt werden. Möchtest du den Raum betreten?", "%(count)s unread messages including mentions.|other": "%(count)s ungelesene Nachrichten einschließlich Erwähnungen.", @@ -1316,21 +1316,21 @@ "%(count)s unread messages.|one": "1 ungelesene Nachricht.", "Unread messages.": "Ungelesene Nachrichten.", "This room has already been upgraded.": "Dieser Raum wurde bereits aktualisiert.", - "This room is running room version , which this homeserver has marked as unstable.": "Dieser Raum läuft mit der Raumversion , welche dieser Heimserver als instabil markiert hat.", + "This room is running room version , which this homeserver has marked as unstable.": "Dieser Raum läuft mit der Raumversion , welche dieser Heim-Server als instabil markiert hat.", "Unknown Command": "Unbekannter Befehl", "Unrecognised command: %(commandText)s": "Unbekannter Befehl: %(commandText)s", "Hint: Begin your message with // to start it with a slash.": "Hinweis: Beginne deine Nachricht mit //, um sie mit einem Schrägstrich zu beginnen.", "Send as message": "Als Nachricht senden", - "Failed to connect to integration manager": "Fehler beim Verbinden mit dem Integrationsserver", + "Failed to connect to integration manager": "Fehler beim Verbinden mit dem Integrations-Server", "Could not revoke the invite. The server may be experiencing a temporary problem or you do not have sufficient permissions to revoke the invite.": "Die Einladung konnte nicht zurückgezogen werden. Der Server hat möglicherweise ein vorübergehendes Problem oder du hast nicht ausreichende Berechtigungen, um die Einladung zurückzuziehen.", "Mark all as read": "Alle als gelesen markieren", "Local address": "Lokale Adresse", "Published Addresses": "Öffentliche Adresse", "Other published addresses:": "Andere öffentliche Adressen:", "No other published addresses yet, add one below": "Keine anderen öffentlichen Adressen vorhanden. Du kannst weiter unten eine hinzufügen", - "New published address (e.g. #alias:server)": "Neue öffentliche Adresse (z.B. #alias:server)", + "New published address (e.g. #alias:server)": "Neue öffentliche Adresse (z. B. #alias:server)", "Local Addresses": "Lokale Adressen", - "Set addresses for this room so users can find this room through your homeserver (%(localDomain)s)": "Erstelle Adressen für diesen Raum, damit andere Benutzer den Raum auf deinem Heimserver (%(localDomain)s) finden können", + "Set addresses for this room so users can find this room through your homeserver (%(localDomain)s)": "Erstelle Adressen für diesen Raum, damit andere Benutzer den Raum auf deinem Heim-Server (%(localDomain)s) finden können", "Waiting for %(displayName)s to accept…": "Warte auf die Annahme von %(displayName)s …", "Accepting…": "Annehmen…", "Start Verification": "Verifizierung starten", @@ -1358,7 +1358,7 @@ "You have ignored this user, so their message is hidden. Show anyways.": "Du blockierst diesen Benutzer, deshalb werden seine Nachrichten nicht angezeigt. Trotzdem anzeigen.", "You accepted": "Du hast angenommen", "You declined": "Du hast abgelehnt", - "You cancelled": "Du hast abgebrochen", + "You cancelled": "Du brachst ab", "Accepting …": "Annehmen …", "Declining …": "Ablehnen …", "You sent a verification request": "Du hast eine Verifizierungsanfrage gesendet", @@ -1405,13 +1405,13 @@ "Your server": "Dein Server", "Matrix": "Matrix", "Add a new server": "Einen Server hinzufügen", - "Enter the name of a new server you want to explore.": "Gib den Namen des Servers an den du erforschen möchtest.", - "Server name": "Servername", + "Enter the name of a new server you want to explore.": "Gib den Namen des Servers an, den du erkunden möchtest.", + "Server name": "Server-Name", "Close dialog": "Dialog schließen", "Please tell us what went wrong or, better, create a GitHub issue that describes the problem.": "Bitte teile uns mit, was schief lief - oder besser, beschreibe das Problem auf GitHub in einem \"Issue\".", "Reminder: Your browser is unsupported, so your experience may be unpredictable.": "Warnung: Dein Browser wird nicht unterstützt. Die Anwendung kann instabil sein.", "Notes": "Notizen", - "If there is additional context that would help in analysing the issue, such as what you were doing at the time, room IDs, user IDs, etc., please include those things here.": "Wenn du mehr Informationen hast, die uns bei Untersuchung des Problems helfen (z.B. was du gerade getan hast, Raum-IDs, Benutzer-IDs, etc.), gib sie bitte hier an.", + "If there is additional context that would help in analysing the issue, such as what you were doing at the time, room IDs, user IDs, etc., please include those things here.": "Wenn es mehr Informationen gibt, die uns bei der Auswertung des Problems würden – z. B. was du getan hast, Raum- oder Benutzer-IDs … – gib sie bitte hier an.", "Removing…": "Löschen…", "Destroy cross-signing keys?": "Cross-Signing-Schlüssel zerstören?", "Clear cross-signing keys": "Cross-Signing-Schlüssel löschen", @@ -1445,7 +1445,7 @@ "Please fill why you're reporting.": "Bitte gib an, weshalb du einen Fehler meldest.", "Upgrade private room": "Privaten Raum aktualisieren", "Upgrade public room": "Öffentlichen Raum aktualisieren", - "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.": "Dies wirkt sich normalerweise nur darauf aus, wie der Raum auf dem Server verarbeitet wird. Wenn du Probleme mit deinem %(brand)s hast, melde bitte einen Bug.", + "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.": "Dies beeinflusst meistens nur, wie der Raum auf dem Server verarbeitet wird. Solltest du Probleme mit %(brand)s haben, melde bitte einen Programmfehler.", "You'll upgrade this room from to .": "Du wirst diesen Raum von zu aktualisieren.", "Missing session data": "Fehlende Sitzungsdaten", "Your browser likely removed this data when running low on disk space.": "Dein Browser hat diese Daten wahrscheinlich entfernt als der Festplattenspeicher knapp wurde.", @@ -1460,13 +1460,13 @@ "Upload %(count)s other files|one": "%(count)s andere Datei hochladen", "Remember my selection for this widget": "Speichere meine Auswahl für dieses Widget", "Restoring keys from backup": "Schlüssel aus der Sicherung wiederherstellen", - "Fetching keys from server...": "Lade Schlüssel vom Server …", + "Fetching keys from server...": "Beziehe Schlüssel vom Server …", "%(completed)s of %(total)s keys restored": "%(completed)s von %(total)s Schlüsseln wiederhergestellt", "Keys restored": "Schlüssel wiederhergestellt", "Successfully restored %(sessionCount)s keys": "%(sessionCount)s Schlüssel erfolgreich wiederhergestellt", "Country Dropdown": "Landauswahl", "Resend %(unsentCount)s reaction(s)": "%(unsentCount)s Reaktion(en) erneut senden", - "Missing captcha public key in homeserver configuration. Please report this to your homeserver administrator.": "Fehlender öffentlicher Captcha-Schlüssel in der Heimserver-Konfiguration. Bitte melde dies deinem Heimserver-Administrator.", + "Missing captcha public key in homeserver configuration. Please report this to your homeserver administrator.": "Fehlender öffentlicher Captcha-Schlüssel in der Heim-Server-Konfiguration. Bitte melde dies deiner Heimserver-Administration.", "Use an email address to recover your account": "Verwende eine E-Mail-Adresse, um dein Konto wiederherzustellen", "Enter email address (required on this homeserver)": "E-Mail-Adresse eingeben (auf diesem Heim-Server erforderlich)", "Doesn't look like a valid email address": "Das sieht nicht nach einer gültigen E-Mail-Adresse aus", @@ -1479,15 +1479,15 @@ "%(brand)s failed to get the public room list.": "%(brand)s konnte die Liste der öffentlichen Räume nicht laden.", "Syncing...": "Synchronisiere …", "Signing In...": "Melde an …", - "The homeserver may be unavailable or overloaded.": "Der Heim-Server ist möglicherweise nicht verfügbar oder überlastet.", + "The homeserver may be unavailable or overloaded.": "Der Heim-Server ist möglicherweise nicht erreichbar oder überlastet.", "Jump to first unread room.": "Zum ersten ungelesenen Raum springen.", "Jump to first invite.": "Zur ersten Einladung springen.", "You have %(count)s unread notifications in a prior version of this room.|other": "Du hast %(count)s ungelesene Benachrichtigungen in einer früheren Version dieses Raums.", "Failed to get autodiscovery configuration from server": "Abrufen der Autodiscovery-Konfiguration vom Server fehlgeschlagen", "Invalid base_url for m.homeserver": "Ungültige base_url für m.homeserver", - "Homeserver URL does not appear to be a valid Matrix homeserver": "Die Heimserver-URL scheint kein gültiger Matrix-Heimserver zu sein", + "Homeserver URL does not appear to be a valid Matrix homeserver": "Die Heim-Server-URL scheint kein gültiger Matrix-Heim-Server zu sein", "Invalid base_url for m.identity_server": "Ungültige base_url für m.identity_server", - "Identity server URL does not appear to be a valid identity server": "Die Identitätsserver-URL scheint kein gültiger Identitätsserver zu sein", + "Identity server URL does not appear to be a valid identity server": "Die Identitäts-Server-URL scheint kein gültiger Identitäts-Server zu sein", "This account has been deactivated.": "Dieses Konto wurde deaktiviert.", "Continue with previous account": "Mit vorherigem Konto fortfahren", "Log in to your new account.": "Mit deinem neuen Konto anmelden.", @@ -1526,9 +1526,9 @@ "Other users can invite you to rooms using your contact details": "Andere Personen können dich mit deinen Kontaktdaten in Räume einladen", "Explore Public Rooms": "Öffentliche Räume erkunden", "If you've joined lots of rooms, this might take a while": "Du bist einer Menge Räumen beigetreten, das kann eine Weile dauern", - "%(brand)s failed to get the protocol list from the homeserver. The homeserver may be too old to support third party networks.": "%(brand)s konnte die Protokollliste nicht vom Heimserver abrufen. Der Heimserver ist möglicherweise zu alt, um Netzwerke von Drittanbietern zu unterstützen.", + "%(brand)s failed to get the protocol list from the homeserver. The homeserver may be too old to support third party networks.": "%(brand)s konnte die Protokollliste nicht vom Heim-Server abrufen. Der Heim-Server ist möglicherweise zu alt, um Netzwerke von Drittanbietern zu unterstützen.", "Your new account (%(newAccountId)s) is registered, but you're already logged into a different account (%(loggedInUserId)s).": "Dein neues Konto (%(newAccountId)s) ist registriert, aber du hast dich bereits in mit einem anderen Konto (%(loggedInUserId)s) angemeldet.", - "Failed to re-authenticate due to a homeserver problem": "Erneute Authentifizierung aufgrund eines Problems im Heim-Server fehlgeschlagen", + "Failed to re-authenticate due to a homeserver problem": "Erneute Authentifizierung aufgrund eines Problems des Heim-Servers fehlgeschlagen", "Failed to re-authenticate": "Erneute Authentifizierung fehlgeschlagen", "Command Autocomplete": "Autovervollständigung aktivieren", "Emoji Autocomplete": "Emoji-Auto-Vervollständigung", @@ -1559,7 +1559,7 @@ "%(doneRooms)s out of %(totalRooms)s": "%(doneRooms)s von %(totalRooms)s", "Unable to query secret storage status": "Status des sicheren Speichers kann nicht gelesen werden", "Without setting up Secure Message Recovery, you won't be able to restore your encrypted message history if you log out or use another session.": "Ohne eine Schlüsselsicherung kann dein verschlüsselter Nachrichtenverlauf nicht wiederhergestellt werden wenn du dich abmeldest oder eine andere Sitzung verwendest.", - "There was an error updating the room's alternative addresses. It may not be allowed by the server or a temporary failure occurred.": "Es gab einen Fehler beim Ändern des Raumaliases. Entweder erlaubt es der Server nicht oder es gab ein temporäres Problem.", + "There was an error updating the room's alternative addresses. It may not be allowed by the server or a temporary failure occurred.": "Es gab einen Fehler beim Ändern des Raumalias. Entweder erlaubt es der Server nicht oder es gab ein temporäres Problem.", "%(brand)s is securely caching encrypted messages locally for them to appear in search results:": "%(brand)s verwendet einen sicheren Zwischenspeicher für verschlüsselte Nachrichten, damit sie in den Suchergebnissen angezeigt werden:", "Message downloading sleep time(ms)": "Wartezeit zwischen dem Herunterladen von Nachrichten (ms)", "If you did this accidentally, you can setup Secure Messages on this session which will re-encrypt this session's message history with a new recovery method.": "Wenn du dies versehentlich getan hast, kannst du in dieser Sitzung \"sichere Nachrichten\" einrichten, die den Nachrichtenverlauf dieser Sitzung mit einer neuen Wiederherstellungsmethode erneut verschlüsseln.", @@ -1606,7 +1606,7 @@ "This address is already in use": "Diese Adresse wird bereits verwendet", "delete the address.": "lösche die Adresse.", "Use a different passphrase?": "Eine andere Passphrase verwenden?", - "Your server admin has disabled end-to-end encryption by default in private rooms & Direct Messages.": "Deine Serveradministration hat die Ende-zu-Ende-Verschlüsselung für private Räume und Direktnachrichten standardmäßig deaktiviert.", + "Your server admin has disabled end-to-end encryption by default in private rooms & Direct Messages.": "Deine Server-Administration hat die Ende-zu-Ende-Verschlüsselung für private Räume und Direktnachrichten standardmäßig deaktiviert.", "People": "Personen", "There was an error removing that address. It may no longer exist or a temporary error occurred.": "Beim Entfernen dieser Adresse ist ein Fehler aufgetreten. Vielleicht existiert sie nicht mehr oder es kam zu einem temporären Fehler.", "You've previously used a newer version of %(brand)s with this session. To use this version again with end to end encryption, you will need to sign out and back in again.": "Du hast für diese Sitzung zuvor eine neuere Version von %(brand)s verwendet. Um diese Version mit Ende-zu-Ende-Verschlüsselung wieder zu benutzen, musst du dich erst ab- und dann wieder anmelden.", @@ -1671,19 +1671,19 @@ "%(brand)s encountered an error during upload of:": "%(brand)s hat einen Fehler festgestellt beim hochladen von:", "Change notification settings": "Benachrichtigungseinstellungen ändern", "Your server isn't responding to some requests.": "Dein Server antwortet auf einige Anfragen nicht.", - "Server isn't responding": "Server antwortet nicht", - "Your server isn't responding to some of your requests. Below are some of the most likely reasons.": "Server reagiert nicht auf einige deiner Anfragen. Im Folgenden sind einige der wahrscheinlichsten Gründe aufgeführt.", - "The server (%(serverName)s) took too long to respond.": "Der Server (%(serverName)s) brauchte zu lange zum antworten.", + "Server isn't responding": "Server reagiert nicht", + "Your server isn't responding to some of your requests. Below are some of the most likely reasons.": "Server reagiert auf einige deiner Anfragen nicht. Folgend sind einige der wahrscheinlichsten Gründe aufgeführt.", + "The server (%(serverName)s) took too long to respond.": "Die Reaktionszeit des Servers (%(serverName)s) war zu hoch.", "Your firewall or anti-virus is blocking the request.": "Deine Firewall oder Anti-Virus-Programm blockiert die Anfrage.", "A browser extension is preventing the request.": "Eine Browser-Erweiterung verhindert die Anfrage.", - "The server is offline.": "Der Server ist offline.", + "The server is offline.": "Der Server ist außer Betrieb.", "The server has denied your request.": "Der Server hat deine Anfrage abgewiesen.", "Your area is experiencing difficulties connecting to the internet.": "Deine Region hat Schwierigkeiten, eine Verbindung zum Internet herzustellen.", "A connection error occurred while trying to contact the server.": "Beim Versuch, den Server zu kontaktieren, ist ein Verbindungsfehler aufgetreten.", "Master private key:": "Privater Hauptschlüssel:", "Set the name of a font installed on your system & %(brand)s will attempt to use it.": "Setze den Schriftnamen auf eine in deinem System installierte Schriftart und %(brand)s wird versuchen, sie zu verwenden.", "You're all caught up.": "Du bist auf dem neuesten Stand.", - "The server is not configured to indicate what the problem is (CORS).": "Der Server ist nicht so konfiguriert, dass das Problem angezeigt wird (CORS).", + "The server is not configured to indicate what the problem is (CORS).": "Der Server ist nicht dafür konfiguriert, das Problem anzuzeigen (CORS).", "Recent changes that have not yet been received": "Letzte Änderungen, die noch nicht eingegangen sind", "Set a Security Phrase": "Sicherheitsphrase setzen", "Confirm Security Phrase": "Sicherheitsphrase bestätigen", @@ -1693,7 +1693,7 @@ "Use your Security Key to continue.": "Benutze deinen Sicherheitsschlüssel um fortzufahren.", "No files visible in this room": "Keine Dateien in diesem Raum", "Attach files from chat or just drag and drop them anywhere in a room.": "Hänge Dateien aus der Unterhaltung an oder ziehe sie einfach an eine beliebige Stelle im Raum.", - "Safeguard against losing access to encrypted messages & data by backing up encryption keys on your server.": "Schütze dich vor dem Verlust des Zugriffs auf verschlüsselte Nachrichten und Daten, indem du Verschlüsselungsschlüssel auf deinem Server sicherst.", + "Safeguard against losing access to encrypted messages & data by backing up encryption keys on your server.": "Verhindere, den Zugriff auf verschlüsselte Nachrichten und Daten zu verlieren, indem du die Verschlüsselungs-Schlüssel auf deinem Server sicherst.", "Generate a Security Key": "Sicherheitsschlüssel generieren", "Enter a Security Phrase": "Sicherheitsphrase eingeben", "Use a secret phrase only you know, and optionally save a Security Key to use for backup.": "Verwende für deine Sicherung eine geheime Phrase, die nur du kennst, und speichere optional einen Sicherheitsschlüssel.", @@ -1701,19 +1701,19 @@ "You can also set up Secure Backup & manage your keys in Settings.": "Du kannst auch in den Einstellungen Sicherungen einrichten und deine Schlüssel verwalten.", "Show message previews for reactions in DMs": "Anzeigen einer Nachrichtenvorschau für Reaktionen in DMs", "Show message previews for reactions in all rooms": "Zeige eine Nachrichtenvorschau für Reaktionen in allen Räumen an", - "Uploading logs": "Protokolle werden hochgeladen", - "Downloading logs": "Protokolle werden heruntergeladen", + "Uploading logs": "Lade Protokolle hoch", + "Downloading logs": "Lade Protokolle herunter", "Explore public rooms": "Öffentliche Räume erkunden", "Explore all public rooms": "Alle öffentlichen Räume erkunden", "%(count)s results|other": "%(count)s Ergebnisse", "Preparing to download logs": "Bereite das Herunterladen der Protokolle vor", "Download logs": "Protokolle herunterladen", - "Unexpected server error trying to leave the room": "Unerwarteter Serverfehler beim Versuch den Raum zu verlassen", + "Unexpected server error trying to leave the room": "Unerwarteter Server-Fehler beim Versuch den Raum zu verlassen", "Error leaving room": "Fehler beim Verlassen des Raums", "Set up Secure Backup": "Schlüsselsicherung einrichten", "Information": "Information", - "You might enable this if the room will only be used for collaborating with internal teams on your homeserver. This cannot be changed later.": "Du solltest dies aktivieren, wenn der Raum nur für die Zusammenarbeit mit Benutzern von deinem Heimserver verwendet werden soll. Dies kann später nicht mehr geändert werden.", - "You might disable this if the room will be used for collaborating with external teams who have their own homeserver. This cannot be changed later.": "Du solltest dies deaktivieren, wenn der Raum für die Zusammenarbeit mit Benutzern von anderen Heimserver verwendet werden soll. Dies kann später nicht mehr geändert werden.", + "You might enable this if the room will only be used for collaborating with internal teams on your homeserver. This cannot be changed later.": "Du solltest dies aktivieren, wenn der Raum nur für die Zusammenarbeit mit Benutzern von deinem Heim-Server verwendet werden soll. Dies kann später nicht mehr geändert werden.", + "You might disable this if the room will be used for collaborating with external teams who have their own homeserver. This cannot be changed later.": "Du solltest dies deaktivieren, wenn der Raum für die Zusammenarbeit mit Benutzern von anderen Heim-Server verwendet werden soll. Dies kann später nicht mehr geändert werden.", "Block anyone not part of %(serverName)s from ever joining this room.": "Betreten nur für Nutzer von %(serverName)s erlauben.", "Privacy": "Privatsphäre", "Prepends ( ͡° ͜ʖ ͡°) to a plain-text message": "Stellt ( ͡° ͜ʖ ͡°) einer Klartextnachricht voran", @@ -1723,7 +1723,7 @@ "Not encrypted": "Nicht verschlüsselt", "About": "Über", "Room settings": "Raumeinstellungen", - "Take a picture": "Foto aufnehmen", + "Take a picture": "Bildschirmfoto", "Unpin": "Nicht mehr anheften", "Cross-signing is ready for use.": "Quersignaturen sind bereits in Anwendung.", "Cross-signing is not set up.": "Quersignierung wurde nicht eingerichtet.", @@ -1734,7 +1734,7 @@ "Secret storage:": "Sicherer Speicher:", "ready": "bereit", "not ready": "nicht bereit", - "Secure Backup": "Geschützte Sicherung", + "Secure Backup": "Verschlüsselte Sicherung", "Safeguard against losing access to encrypted messages & data": "Schütze dich vor dem Verlust verschlüsselter Nachrichten und Daten", "not found in storage": "nicht im Speicher gefunden", "Widgets": "Widgets", @@ -1751,7 +1751,7 @@ "Join the conference at the top of this room": "An Konferenz oberhalb des Verlaufs teilnehmen", "Join the conference from the room information card on the right": "An der Konferenz kannst du über die rechte Seitenleiste (Rauminfo) teilnehmen", "Video conference ended by %(senderName)s": "Videokonferenz von %(senderName)s beendet", - "Video conference updated by %(senderName)s": "Videokonferenz wurde %(senderName)s aktualisiert", + "Video conference updated by %(senderName)s": "Videokonferenz wurde von %(senderName)s aktualisiert", "Video conference started by %(senderName)s": "Videokonferenz von %(senderName)s gestartet", "Ignored attempt to disable encryption": "Versuch, die Verschlüsselung zu deaktivieren, wurde ignoriert", "Failed to save your profile": "Speichern des Profils fehlgeschlagen", @@ -1856,7 +1856,7 @@ "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 ihn betreten, kannst du sie in ihrem Profil verifizieren, indem du auf ihren 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 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.", + "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-Protokolle hinzu, um uns beim Finden des Problems zu helfen.", "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 ).", "Invite someone using their name, email address, username (like ) or share this room.": "Lade jemanden mittels Name, E-Mail-Adresse oder Benutzername (wie ) ein, oder teile diesen Raum.", @@ -2131,7 +2131,7 @@ "%(ssoButtons)s Or %(usernamePassword)s": "%(ssoButtons)s oder %(usernamePassword)s", "Continue with %(ssoButtons)s": "Mit %(ssoButtons)s anmelden", "New? Create account": "Neu? Erstelle ein Konto", - "There was a problem communicating with the homeserver, please try again later.": "Es gab ein Problem bei der Kommunikation mit dem Homseserver. Bitte versuche es später erneut.", + "There was a problem communicating with the homeserver, please try again later.": "Es gab ein Problem bei der Kommunikation mit dem Heim-Server. Bitte versuche es später erneut.", "New here? Create an account": "Neu hier? Erstelle ein Konto", "Got an account? Sign in": "Du hast bereits ein Konto? Melde dich an", "Use email to optionally be discoverable by existing contacts.": "Nutze optional eine E-Mail-Adresse, um von Nutzern gefunden werden zu können.", @@ -2140,7 +2140,7 @@ "Forgot password?": "Passwort vergessen?", "That phone number doesn't look quite right, please check and try again": "Diese Telefonummer sieht nicht ganz richtig aus. Bitte überprüfe deine Eingabe und versuche es erneut", "About homeservers": "Über Heim-Server", - "Learn more": "Mehr dazu", + "Learn more": "Mehr erfahren", "Use your preferred Matrix homeserver if you have one, or host your own.": "Verwende einen Matrix-Heim-Server deiner Wahl oder betreibe deinen eigenen.", "Other homeserver": "Anderer Heim-Server", "Sign into your homeserver": "Melde dich bei deinem Heim-Server an", @@ -2214,7 +2214,7 @@ "Repeat your Security Phrase...": "Wiederhole deine Sicherheitsphrase …", "Great! This Security Phrase looks strong enough.": "Großartig! Diese Sicherheitsphrase sieht stark genug aus.", "Access your secure message history and set up secure messaging by entering your Security Phrase.": "Greife auf deinen verschlüsselten Nachrichtenverlauf zu und richte die sichere Kommunikation ein, indem du deine Sicherheitsphrase eingibst.", - "We'll store an encrypted copy of your keys on our server. Secure your backup with a Security Phrase.": "Wir werden eine verschlüsselte Kopie deiner Schlüssel auf unserem Server speichern. Sichere dein Backup mit einer Sicherheitsphrase (z.B. einem langen Satz, den niemand errät).", + "We'll store an encrypted copy of your keys on our server. Secure your backup with a Security Phrase.": "Wir werden eine verschlüsselte Kopie deiner Schlüssel auf unserem Server speichern. Schütze deine Sicherung mit einer Sicherheitsphrase.", "If you've forgotten your Security Key you can ": "Wenn du deinen Sicherheitsschlüssel vergessen hast, kannst du ", "Access your secure message history and set up secure messaging by entering your Security Key.": "Greife auf deinen verschlüsselten Nachrichtenverlauf zu und richte die sichere Kommunikation ein, indem du deinen Sicherheitsschlüssel eingibst.", "If you've forgotten your Security Phrase you can use your Security Key or set up new recovery options": "Wenn du deine Sicherheitsphrase vergessen hast, kannst du deinen Sicherheitsschlüssel nutzen oder neue Wiederherstellungsoptionen einrichten", @@ -2279,7 +2279,7 @@ "Public": "Öffentlich", "Create a space": "Neuen Space erstellen", "Delete": "Löschen", - "This homeserver has been blocked by its administrator.": "Dieser Heim-Server wurde von ihrer Administration geblockt.", + "This homeserver has been blocked by its administrator.": "Dieser Heim-Server wurde von seiner Administration geblockt.", "You're already in a call with this person.": "Du bist schon in einem Anruf mit dieser Person.", "Already in call": "Schon im Anruf", "Invite people": "Personen einladen", @@ -2294,8 +2294,8 @@ "Invite to this space": "In diesen Space einladen", "Failed to invite the following users to your space: %(csvUsers)s": "Die folgenden Leute konnten nicht eingeladen werden: %(csvUsers)s", "Share %(name)s": "%(name)s teilen", - "Skip for now": "Für jetzt überspringen", - "Random": "Zufällig", + "Skip for now": "Vorerst überspringen", + "Random": "Ohne Thema", "Welcome to ": "Willkommen bei ", "Private space": "Privater Space", "Public space": "Öffentlicher Space", @@ -2308,10 +2308,10 @@ "%(count)s members|one": "%(count)s Mitglied", "Are you sure you want to leave the space '%(spaceName)s'?": "Willst du %(spaceName)s wirklich verlassen?", "Start audio stream": "Audiostream starten", - "Failed to start livestream": "Livestream kann nicht gestartet werden", + "Failed to start livestream": "Livestream konnte nicht gestartet werden", "Unable to start audio streaming.": "Audiostream kann nicht gestartet werden.", "Leave Space": "Space verlassen", - "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.": "Dies beeinflusst meistens nur die Verarbeitung des Raumes am Server. Falls du Probleme mit %(brand)s hast, erstelle bitte einen Bug-Report.", + "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.": "Dies beeinflusst meistens nur, wie der Raum auf dem Server verarbeitet wird. Solltest du Probleme mit %(brand)s haben, erstelle bitte einen Fehlerbericht.", "Invite someone using their name, username (like ) or share this space.": "Lade Leute mittels Anzeigename oder Benutzername (z. B. ) ein oder teile diesen Space.", "Invite someone using their name, email address, username (like ) or share this space.": "Lade Leute mittels Anzeigename, E-Mail-Adresse oder Benutzername (z. B. ) ein oder teile diesen Space.", "Invite to %(roomName)s": "In %(roomName)s einladen", @@ -2323,11 +2323,11 @@ "Invite by username": "Mit Benutzername einladen", "Make sure the right people have access. You can invite more later.": "Stelle sicher, dass die richtigen Personen Zugriff haben. Du kannst später weitere einladen.", "Invite your teammates": "Lade deine Kollegen ein", - "A private space for you and your teammates": "Ein privater Space für dich und dein Team", + "A private space for you and your teammates": "Ein privater Space für dich und deine Kollegen", "Me and my teammates": "Für mich und meine Kollegen", - "A private space to organise your rooms": "Ein privater Space zum Organisieren von Räumen", + "A private space to organise your rooms": "Ein privater Space zum Organisieren deiner Räume", "Just me": "Nur für mich", - "Who are you working with?": "Für wen soll dieser Space sein?", + "Who are you working with?": "Für wen ist dieser Space gedacht?", "Make sure the right people have access to %(name)s": "Stelle sicher, dass die richtigen Personen Zugriff auf %(name)s haben", "Go to my first room": "Zum ersten Raum springen", "It's just you at the moment, it will be even better with others.": "Momentan bist nur du hier. Mit anderen Leuten wird es noch viel besser.", @@ -2362,7 +2362,7 @@ "%(deviceId)s from %(ip)s": "%(deviceId)s von %(ip)s", "You have unverified logins": "Du hast nicht-bestätigte Anmeldungen", "Review to ensure your account is safe": "Überprüfen, um sicher zu sein, dass dein Konto sicher ist", - "Support": "Support", + "Support": "Unterstützung", "This room is suggested as a good one to join": "Dieser Raum wird empfohlen", "Verification requested": "Verifizierung angefragt", "Avatar": "Avatar", @@ -2378,12 +2378,12 @@ "Reset event store?": "Ereignisspeicher zurück setzen?", "You most likely do not want to reset your event index store": "Es ist wahrscheinlich, dass du den Ereignis-Indexspeicher nicht zurück setzen möchtest", "Reset event store": "Ereignisspeicher zurück setzen", - "You can add more later too, including already existing ones.": "Natürlich kannst du jederzeit weitere Räume hinzufügen.", - "Let's create a room for each of them.": "Wir erstellen dir für jedes Thema einen Raum.", + "You can add more later too, including already existing ones.": "Du kannst später weitere hinzufügen, auch bereits bestehende.", + "Let's create a room for each of them.": "Lass uns für jedes einen Raum erstellen.", "What are some things you want to discuss in %(spaceName)s?": "Welche Themen willst du in %(spaceName)s besprechen?", "Inviting...": "Einladen …", "Failed to create initial space rooms": "Fehler beim Initialisieren des Space", - "You are the only person here. If you leave, no one will be able to join in the future, including you.": "Du bist die einzige Person hier. Wenn du ihn jetzt verlässt, ist er für immer verloren (eine lange Zeit).", + "You are the only person here. If you leave, no one will be able to join in the future, including you.": "Du bist die einzige Person im Raum. Sobald du ihn verlässt, wird niemand mehr hineingelangen, auch du nicht.", "Edit settings relating to your space.": "Einstellungen vom Space bearbeiten.", "If you reset everything, you will restart with no trusted sessions, no trusted users, and might not be able to see past messages.": "Wenn du alles zurücksetzt, gehen alle verifizierten Anmeldungen, Benutzer und vergangenen Nachrichten verloren.", "Only do this if you have no other device to complete verification with.": "Verwende es nur, wenn du kein Gerät, mit dem du dich verifizieren, kannst bei dir hast.", @@ -2435,7 +2435,7 @@ "Search names and descriptions": "Nach Name und Beschreibung filtern", "Not all selected were added": "Nicht alle Ausgewählten konnten hinzugefügt werden", "Add reaction": "Reaktion hinzufügen", - "This is an experimental feature. For now, new users receiving an invite will have to open the invite on to actually join.": "Diese Funktion ist experimentell. Falls du eine Einladung erhältst, musst du sie momentan noch auf öffnen, um beizutreten.", + "This is an experimental feature. For now, new users receiving an invite will have to open the invite on to actually join.": "Diese Funktion ist experimentell. Falls du eine Einladung erhältst, musst du sie momentan noch auf öffnen, um den Raum zu betreten.", "You may contact me if you have any follow up questions": "Kontaktiert mich, falls ihr weitere Fragen zu meiner Rückmeldung habt", "To leave the beta, visit your settings.": "Du kannst die Beta in den Einstellungen deaktivieren.", "Your platform and username will be noted to help us use your feedback as much as we can.": "Deine Systeminformationen und dein Benutzername werden mitgeschickt, damit wir deine Rückmeldung bestmöglich nachvollziehen können.", @@ -2489,8 +2489,8 @@ "%(severalUsers)schanged the server ACLs %(count)s times|other": "%(severalUsers)s haben die Server-ACLs %(count)s-mal geändert", "Set addresses for this space so users can find this space through your homeserver (%(localDomain)s)": "Füge Adressen für diesen Space hinzu, damit andere Leute ihn über deinen Heim-Server (%(localDomain)s) finden können", "To publish an address, it needs to be set as a local address first.": "Damit du die Adresse veröffentlichen kannst, musst du sie zuerst als lokale Adresse hinzufügen.", - "Published addresses can be used by anyone on any server to join your room.": "Veröffentlichte Adressen erlauben jedem, dem Raum beizutreten.", - "Published addresses can be used by anyone on any server to join your space.": "Veröffentlichte Adressen erlauben jedem, dem Space beizutreten.", + "Published addresses can be used by anyone on any server to join your room.": "Veröffentlichte Adressen erlauben jedem, den Raum zu betreten.", + "Published addresses can be used by anyone on any server to join your space.": "Veröffentlichte Adressen erlauben jedem, den Space zu betreten.", "This space has no local addresses": "Dieser Space hat keine lokale Adresse", "Space information": "Information über den Space", "Collapse": "Verbergen", @@ -2507,7 +2507,7 @@ "Failed to update the guest access of this space": "Gastzugriff des Space konnte nicht geändert werden", "Failed to update the visibility of this space": "Sichtbarkeit des Space konnte nicht geändert werden", "Address": "Adresse", - "e.g. my-space": "z.B. Mein-Space", + "e.g. my-space": "z. B. mein-space", "Sound on": "Ton an", "Show all rooms in Home": "Alle Räume auf Startseite anzeigen", "Report to moderators prototype. In rooms that support moderation, the `report` button will let you report abuse to room moderators": "Inhalte an Mods melden. In Räumen, die Moderation unterstützen, kannst du so unerwünschte Inhalte direkt der Raummoderation melden", @@ -2551,13 +2551,13 @@ "Using this widget may share data with %(widgetDomain)s & your integration manager.": "Wenn du dieses Widget verwendest, können Daten zu %(widgetDomain)s und deinem Integrationsmanager übertragen werden.", "Identity server is": "Dein Identitäts-Server ist", "Integration managers receive configuration data, and can modify widgets, send room invites, and set power levels on your behalf.": "Integrationsassistenten erhalten Konfigurationsdaten und können Widgets modifizieren, Raumeinladungen verschicken und in deinem Namen Berechtigungslevel setzen.", - "Use an integration manager to manage bots, widgets, and sticker packs.": "Verwende einen Integrationsserver, um Bots, Widgets und Sticker-Pakete zu verwalten.", - "Use an integration manager (%(serverName)s) to manage bots, widgets, and sticker packs.": "Nutze einen Integrationsserver (%(serverName)s), um Bots, Widgets und Sticker-Pakete zu verwalten.", - "Identity server": "Identitätsserver", - "Identity server (%(server)s)": "Identitätsserver (%(server)s)", - "Could not connect to identity server": "Verbindung zum Identitätsserver konnte nicht hergestellt werden", - "Not a valid identity server (status code %(code)s)": "Ungültiger Identitätsserver (Fehlercode %(code)s)", - "Identity server URL must be HTTPS": "Der Identitätsserver muss über HTTPS erreichbar sein", + "Use an integration manager to manage bots, widgets, and sticker packs.": "Verwende einen Integrations-Server, um Bots, Widgets und Sticker-Pakete zu verwalten.", + "Use an integration manager (%(serverName)s) to manage bots, widgets, and sticker packs.": "Nutze einen Integrations-Server (%(serverName)s), um Bots, Widgets und Sticker-Pakete zu verwalten.", + "Identity server": "Identitäts-Server", + "Identity server (%(server)s)": "Identitäts-Server (%(server)s)", + "Could not connect to identity server": "Verbindung zum Identitäts-Server konnte nicht hergestellt werden", + "Not a valid identity server (status code %(code)s)": "Ungültiger Identitäts-Server (Fehlercode %(code)s)", + "Identity server URL must be HTTPS": "Identitäts-Server-URL muss mit HTTPS anfangen", "Error processing audio message": "Fehler beim Verarbeiten der Audionachricht", "Code blocks": "Quelltextblöcke", "There was an error loading your notification settings.": "Fehler beim Laden der Benachrichtigungseinstellungen.", @@ -2591,7 +2591,7 @@ "Missed call": "Verpasster Anruf", "Call declined": "Anruf abgelehnt", "Dialpad": "Telefontastatur", - "Stop the camera": "Kamera stoppen", + "Stop the camera": "Kamera beenden", "Start the camera": "Kamera starten", "You can change this at any time from room settings.": "Du kannst das jederzeit in den Raumeinstellungen ändern.", "Everyone in will be able to find and join this room.": "Mitglieder von können diesen Raum finden und betreten.", @@ -2611,7 +2611,7 @@ "Access": "Zugriff", "Decide who can join %(roomName)s.": "Entscheide, wer %(roomName)s betreten kann.", "Space members": "Spacemitglieder", - "Anyone in a space can find and join. You can select multiple spaces.": "Das Beitreten ist allen in den gewählten Spaces möglich.", + "Anyone in a space can find and join. You can select multiple spaces.": "Das Betreten ist allen in den gewählten Spaces möglich.", "Spaces with access": "Spaces mit Zugriff", "Anyone in a space can find and join. Edit which spaces can access here.": "Das Betreten ist allen in diesen Spaces möglich. Ändere, welche Spaces Zugriff haben.", "Currently, %(count)s spaces have access|other": "%(count)s Spaces haben Zugriff", @@ -2659,7 +2659,7 @@ "Spaces you know that contain this room": "Spaces, in denen du Mitglied bist und die diesen Raum enthalten", "You're removing all spaces. Access will default to invite only": "Du entfernst alle Spaces. Der Zugriff wird auf den Standard (Privat) zurückgesetzt", "People with supported clients will be able to join the room without having a registered account.": "Personen mit unterstützter Anwendung werden diesen Raum ohne registriertes Konto betreten können.", - "Anyone can find and join.": "Jeder kann den Raum finden und betreten.", + "Anyone can find and join.": "Sichtbar und zugänglich für jeden.", "Mute the microphone": "Stummschalten", "Unmute the microphone": "Stummschaltung deaktivieren", "Displaying time": "Zeitanzeige", @@ -2678,7 +2678,7 @@ "Send a sticker": "Sticker senden", "Are you sure you want to make this encrypted room public?": "Willst du diesen verschlüsselten Raum wirklich öffentlich machen?", "Unknown failure": "Unbekannter Fehler", - "Failed to update the join rules": "Fehler beim updaten der Beitrittsregeln", + "Failed to update the join rules": "Fehler beim Aktualisieren der Beitrittsregeln", "To avoid these issues, create a new encrypted room for the conversation you plan to have.": "Um dieses Problem zu vermeiden, erstelle einen neuen verschlüsselten Raum für deine Konversation.", "It's not recommended to add encryption to public rooms.Anyone can find and join public rooms, so anyone can read messages in them. You'll get none of the benefits of encryption, and you won't be able to turn it off later. Encrypting messages in a public room will make receiving and sending messages slower.": "Verschlüsselung ist für öffentliche Räume nicht empfohlen. Alle können öffentliche Räume finden und betreten und so auch Nachrichten lesen und Senden und Empfangen wird langsamer. Du hast daher von der Verschlüsselung keinen Vorteil und kannst sie später nicht mehr ausschalten.", "Are you sure you want to add encryption to this public room?": "Dieser Raum ist öffentlich. Willst du die Verschlüsselung wirklich aktivieren?", @@ -2697,7 +2697,7 @@ "%(senderName)s unpinned a message from this room. See all pinned messages.": "%(senderName)s hat eine Nachricht losgeheftet. Alle angehefteten Nachrichten anzeigen.", "%(senderName)s pinned a message to this room. See all pinned messages.": "%(senderName)s hat eine Nachricht angeheftet. Alle angehefteten Nachrichten anzeigen.", "%(senderName)s pinned a message to this room. See all pinned messages.": "%(senderName)s hat eine Nachricht angeheftet. Alle angehefteten Nachrichten anzeigen.", - "Joining space …": "Space betreten …", + "Joining space …": "Betrete Space …", "To join a space you'll need an invite.": "Um einen Space zu betreten, brauchst du eine Einladung.", "You are about to leave .": "Du bist dabei, zu verlassen.", "Leave some rooms": "Zu verlassende Räume auswählen", @@ -2721,7 +2721,7 @@ "Shows all threads from current room": "Alle Threads des Raums anzeigen", "All threads": "Alle Threads", "My threads": "Meine Threads", - "What projects are your team working on?": "An welchen Projekten arbeitet dein Team?", + "What projects are your team working on?": "Welche Projekte bearbeitet euer Team?", "Joined": "Beigetreten", "See room timeline (devtools)": "Nachrichtenverlauf anzeigen (Entwicklungswerkzeuge)", "View in room": "Im Raum anzeigen", @@ -2732,7 +2732,7 @@ "Format": "Format", "Export Chat": "Unterhaltung exportieren", "Exporting your data": "Deine Daten werden exportiert", - "Stop": "Stopp", + "Stop": "Beenden", "Are you sure you want to stop exporting your data? If you do, you'll need to start over.": "Willst du das Exportieren deiner Daten wirklich abbrechen? Falls ja, musst du komplett von neu beginnen.", "Your export was successful. Find it in your Downloads folder.": "Export erfolgreich. Du kannst in bei deinen Downloads finden.", "The export was cancelled successfully": "Exportieren abgebrochen", @@ -2744,7 +2744,7 @@ "Enter a number between %(min)s and %(max)s": "Gib eine Zahl zwischen %(min)s und %(max)s ein", "In reply to this message": "Antwort auf diese Nachricht", "Downloading": "Herunterladen", - "No answer": "Nicht beantwortet", + "No answer": "Keine Antwort", "Unban from %(roomName)s": "Von %(roomName)s entbannen", "Disinvite from %(roomName)s": "Einladung für %(roomName)s zurückziehen", "Export chat": "Unterhaltung exportieren", @@ -2777,7 +2777,7 @@ "%(date)s at %(time)s": "%(date)s um %(time)s", "Store your Security Key somewhere safe, like a password manager or a safe, as it's used to safeguard your encrypted data.": "Bewahre deinen Sicherheitsschlüssel sicher auf, etwa in einem Passwortmanager oder einem Safe, da er verwendet wird, um deine Daten zu sichern.", "Enter a security phrase only you know, as it's used to safeguard your data. To be secure, you shouldn't re-use your account password.": "Gib eine Sicherheitsphrase ein, die nur du kennst. Sie wird verwendet, um deine Daten zu sichern. Zu deiner Sicherheit solltest du dein Kontopasswort nicht wiederverwenden.", - "We'll generate a Security Key for you to store somewhere safe, like a password manager or a safe.": "Wir generieren einen Sicherheitsschlüssel für dich, den du sicher aufbewahren solltest, etwa in einem Passwortmanager oder einem Safe.", + "We'll generate a Security Key for you to store somewhere safe, like a password manager or a safe.": "Wir generieren einen Sicherheitsschlüssel für dich, den du in einem Passwort-Manager oder Safe sicher aufbewahren solltest.", "Regain access to your account and recover encryption keys stored in this session. Without them, you won't be able to read all of your secure messages in any session.": "Zugriff auf dein Konto wiederherstellen und in dieser Sitzung gespeicherte Verschlüsselungs-Schlüssel wiederherstellen. Ohne diese wirst du nicht all deine verschlüsselten Nachrichten lesen können.", "Please only proceed if you're sure you've lost all of your other devices and your security key.": "Bitte fahre nur fort, falls du dir sicher bist, dass du alle deine anderen Geräte und deinen Sicherheitsschlüssel verloren hast.", "Resetting your verification keys cannot be undone. After resetting, you won't have access to old encrypted messages, and any friends who have previously verified you will see security warnings until you re-verify with them.": "Das Zurücksetzen deiner Sicherheitsschlüssel kann nicht rückgängig gemacht werden. Nach dem Zurücksetzen wirst du alte Nachrichten nicht mehr lesen können un Freunde, die dich vorher verifiziert haben werden Sicherheitswarnungen bekommen, bis du dich erneut mit ihnen verifizierst.", @@ -2875,7 +2875,7 @@ "Get notified only with mentions and keywords as set up in your settings": "Nur bei Erwähnungen und Schlüsselwörtern benachrichtigen, die du in den Einstellungen konfigurieren kannst", "@mentions & keywords": "@Erwähnungen und Schlüsselwörter", "Get notified for every message": "Bei jeder Nachricht benachrichtigen", - "Rooms outside of a space": "Räume ohne Zugehörigkeit zu einem Space", + "Rooms outside of a space": "Räume außerhalb von Spaces", "Manage rooms in this space": "Räume in diesem Space verwalten", "Clear": "Löschen", "%(count)s votes|one": "%(count)s Stimme", @@ -2901,7 +2901,7 @@ "%(spaceName)s and %(count)s others|other": "%(spaceName)s und %(count)s andere", "Okay": "Okay", "Share anonymous data to help us identify issues. Nothing personal. No third parties. Learn More": "Teile Daten anonymisiert um uns zu helfen Probleme zu identifizieren. Nichts persönliches. Keine Dritten. Mehr dazu hier", - "You previously consented to share anonymous usage data with us. We're updating how that works.": "Sie haben vorher zugestimmt anonymisierte Nutzungsdaten mit uns zu teilen. Wir updaten wie das funktioniert.", + "You previously consented to share anonymous usage data with us. We're updating how that works.": "Sie haben zuvor zugestimmt, anonymisierte Nutzungsdaten mit uns zu teilen. Wir aktualisieren, wie das funktioniert.", "Help improve %(analyticsOwner)s": "Hilf mit, %(analyticsOwner)s zu verbessern", "That's fine": "Das ist okay", "You cannot place calls without a connection to the server.": "Sie können keine Anrufe starten ohne Verbindung zum Server.", @@ -3035,8 +3035,8 @@ "Verify other device": "Anderes Gerät verifizieren", "Edit setting": "Einstellung bearbeiten", "You can read all our terms here": "Du kannst unsere Datenschutzbedingungen hier lesen", - "Missing room name or separator e.g. (my-room:domain.org)": "Fehlender Raumname oder Doppelpunkt (z.B. dein-raum:domain.org)", - "Missing domain separator e.g. (:domain.org)": "Fehlender Doppelpunkt vor Server (z.B. :domain.org)", + "Missing room name or separator e.g. (my-room:domain.org)": "Fehlender Raumname oder Doppelpunkt (z. B. dein-raum:domain.org)", + "Missing domain separator e.g. (:domain.org)": "Fehlender Doppelpunkt vor Server (z. B. :domain.org)", "was removed %(count)s times|other": "wurde %(count)s mal entfernt", "was removed %(count)s times|one": "wurde entfernt", "were removed %(count)s times|one": "wurden entfernt", @@ -3069,10 +3069,10 @@ "Navigate to previous message to edit": "Vorherige Nachricht bearbeiten", "Navigate to next message to edit": "Nächste Nachricht bearbeiten", "Internal room ID": "Interne Raum-ID", - "Group all your rooms that aren't part of a space in one place.": "Alle Räume, die nicht Teil eines Spaces sind, gruppieren.", - "Group all your people in one place.": "Alle Direktnachrichten gruppieren.", - "Group all your favourite rooms and people in one place.": "Gruppiere all deine Unterhaltungen an einem Ort.", - "Spaces are ways to group rooms and people. Alongside the spaces you're in, you can use some pre-built ones too.": "Mit Spaces kannst du deine Chats gruppieren. Zusätzlich kannst du dir einige vorgefertigte Spaces anzeigen lassen.", + "Group all your rooms that aren't part of a space in one place.": "Gruppiere all deine Räume, die nicht Teil eines Spaces sind, an einem Ort.", + "Group all your people in one place.": "Gruppiere all deine Direktnachrichten an einem Ort.", + "Group all your favourite rooms and people in one place.": "Chats all deine favorisierten Unterhaltungen an einem Ort.", + "Spaces are ways to group rooms and people. Alongside the spaces you're in, you can use some pre-built ones too.": "Mit Spaces kannst du deine Unterhaltungen organisieren. Neben Spaces, in denen du dich befindest, kannst du dir auch dynamische anzeigen lassen.", "IRC (Experimental)": "IRC (Experimentell)", "Call": "Anruf", "Right panel stays open (defaults to room member list)": "Rechtes Panel offen lassen (Standardmäßig Liste der Mitglieder)", @@ -3084,7 +3084,7 @@ "Toggle hidden event visibility": "Sichtbarkeit versteckter Events umschalten", "If you know what you're doing, Element is open-source, be sure to check out our GitHub (https://github.com/vector-im/element-web/) and contribute!": "Falls du weißt, was du machst: Element ist Open Source! Checke unser GitHub aus (https://github.com/vector-im/element-web/) und hilf mit!", "If someone told you to copy/paste something here, there is a high likelihood you're being scammed!": "Wenn dir jemand gesagt hat, dass du hier etwas einfügen sollst, ist die Wahrscheinlichkeit sehr groß, dass du von der Person betrogen wirst!", - "Wait!": "Halt Stopp!", + "Wait!": "Warte!", "This address does not point at this room": "Diese Adresse verweist nicht auf diesen Raum", "Pick a date to jump to": "Wähle eine Datum aus", "Jump to date": "Zu Datum springen", @@ -3094,7 +3094,7 @@ "Unable to find event at that date. (%(code)s)": "An diesem Datum gab es kein Event. (%(code)s)", "Jump to date (adds /jumptodate and jump to date headers)": "Zu Datum springen ( /jumptodate bzw. Zu Datum springen im Header)", "Location": "Standort", - "Poll": "Abstimmung", + "Poll": "Umfrage", "Voice Message": "Sprachnachricht", "Hide stickers": "Sticker ausblenden", "You do not have permissions to add spaces to this space": "Du hast keine Berechtigung, um Spaces zu diesem Space hinzuzufügen", @@ -3155,7 +3155,7 @@ "Match system": "An System anpassen", "Insert a trailing colon after user mentions at the start of a message": "Doppelpunkt nach Erwähnungen einfügen", "Reply to an ongoing thread or use “%(replyInThread)s” when hovering over a message to start a new one.": "Antworte auf einen Thread oder klicke bei einer Nachricht auf „%(replyInThread)s“, um einen Thread zu starten.", - "We'll create rooms for each of them.": "Wir werden dir dafür entsprechende Räume erstellen.", + "We'll create rooms for each of them.": "Wir werden für jedes einen Raum erstellen.", "Export Cancelled": "Exportieren abgebrochen", "%(oneUser)schanged the pinned messages for the room %(count)s times|one": "%(oneUser)s hat die angehefteten Nachrichten des Raumes bearbeitet", "%(oneUser)schanged the pinned messages for the room %(count)s times|other": "%(oneUser)s hat die angehefteten Nachrichten des Raumes %(count)s-Mal bearbeitet", @@ -3163,21 +3163,21 @@ "%(severalUsers)schanged the pinned messages for the room %(count)s times|other": "%(severalUsers)s haben die angehefteten Nachrichten des Raumes %(count)s-Mal bearbeitet", "What location type do you want to share?": "Wie willst du deinen Standort teilen?", "Drop a Pin": "Standort setzen", - "My live location": "Mein Live-Standort", + "My live location": "Mein Echtzeit-Standort", "My current location": "Mein Standort", - "%(brand)s could not send your location. Please try again later.": "%(brand)s konnte deinen Standort nicht senden. Versuche es später bitte erneut.", - "We couldn't send your location": "Wir können deinen Standort nicht senden", - "This homeserver is not configured correctly to display maps, or the configured map server may be unreachable.": "Dein Homeserver unterstützt das Anzeigen von Karten nicht oder der Kartenanbieter ist nicht erreichbar.", + "%(brand)s could not send your location. Please try again later.": "%(brand)s konnte deinen Standort nicht senden. Bitte versuche es später erneut.", + "We couldn't send your location": "Wir konnten deinen Standort nicht senden", + "This homeserver is not configured correctly to display maps, or the configured map server may be unreachable.": "Dein Home-Server unterstützt das Anzeigen von Karten nicht oder der Kartenanbieter ist nicht erreichbar.", "Busy": "Beschäftigt", "If you've submitted a bug via GitHub, debug logs can help us track down the problem. ": "Wenn du uns einen Bug auf GitHub gemeldet hast, können uns Debug-Logs helfen, das Problem zu finden. ", "Toggle Link": "Linkfomatierung umschalten", "Toggle Code Block": "Quelltextblock-Formatierung umschalten", - "You are sharing your live location": "Du teilst deinen Live-Standort", - "Uncheck if you also want to remove system messages on this user (e.g. membership change, profile change…)": "Entferne das Häkchen, wenn du auch Systemnachrichten des Nutzers löschen willst (z.B. Mitglieds- und Profiländerungen)", + "You are sharing your live location": "Du teilst deinen Echtzeit-Standort", + "Uncheck if you also want to remove system messages on this user (e.g. membership change, profile change…)": "Deaktivieren, wenn du auch Systemnachrichten bzgl. des Nutzers löschen willst (z. B. Mitglieds- und Profiländerungen …)", "Preserve system messages": "Systemnachrichten behalten", "You are about to remove %(count)s messages by %(user)s. This will remove them permanently for everyone in the conversation. Do you wish to continue?|one": "Du bist gerade dabei, %(count)s Nachricht von %(user)s Benutzern zu löschen. Die Nachrichten werden für niemanden mehr sichtbar sein. Willst du fortfahren?", "You are about to remove %(count)s messages by %(user)s. This will remove them permanently for everyone in the conversation. Do you wish to continue?|other": "Du bist gerade dabei, %(count)s Nachrichten von %(user)s Benutzern zu löschen. Die Nachrichten werden für niemanden mehr sichtbar sein. Willst du fortfahren?", - "%(displayName)s's live location": "Aktueller Standort von %(displayName)s", + "%(displayName)s's live location": "Echtzeit-Standort von %(displayName)s", "Currently removing messages in %(count)s rooms|one": "Entferne Nachrichten in %(count)s Raum", "Currently removing messages in %(count)s rooms|other": "Entferne Nachrichten in %(count)s Räumen", "Stop sharing": "Nicht mehr teilen", @@ -3189,7 +3189,7 @@ "%(value)sd": "%(value)sd", "Start messages with /plain to send without markdown and /md to send with.": "Beginne Nachrichten mit /plain, um Nachrichten ohne Markdown zu schreiben und mit /md, um sie mit Markdown zu schreiben.", "Enable Markdown": "Markdown aktivieren", - "Live Location Sharing (temporary implementation: locations persist in room history)": "Standort Live teilen (Temporäre Implementation; Die Standorte bleiben in Raumverlauf bestehen)", + "Live Location Sharing (temporary implementation: locations persist in room history)": "Echtzeit-Standortfreigabe (Temporäre Implementation: Die Standorte bleiben in Raumverlauf bestehen)", "Location sharing - pin drop": "Standort teilen - Position auswählen", "Right-click message context menu": "Rechtsklick-Kontextmenü", "To leave, return to this page and use the “%(leaveTheBeta)s” button.": "Zum Verlassen, gehe auf diese Seite zurück und klicke auf „%(leaveTheBeta)s“.", @@ -3215,10 +3215,10 @@ "Failed to invite users to %(roomName)s": "Fehler beim Einladen von Benutzern in %(roomName)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.": "Du versuchst, einer Community beizutreten (%(groupId)s).
Diese wurden jedoch durch Spaces ersetzt.Mehr Infos über Spaces gibt es hier.", "That link is no longer supported": "Dieser Link wird leider nicht mehr unterstützt", - "Live location sharing": "Live Standort teilen", + "Live location sharing": "Echtzeit-Standortfreigabe", "Beta feature. Click to learn more.": "Betafunktion. Klicken, für mehr Informationen.", "Beta feature": "Betafunktion", - "View live location": "Live-Standort anzeigen", + "View live location": "Echtzeit-Standort anzeigen", "Ban from room": "Bannen", "Unban from room": "Entbannen", "Ban from space": "Bannen", @@ -3243,7 +3243,7 @@ "Forget this space": "Diesen Space vergessen", "You were removed by %(memberName)s": "Du wurdest von %(memberName)s entfernt", "Loading preview": "Lade Vorschau", - "Joining …": "Betreten …", + "Joining …": "Betrete …", "New video room": "Neuer Videoraum", "New room": "Neuer Raum", "Seen by %(count)s people|one": "Von %(count)s Person gesehen", @@ -3293,13 +3293,13 @@ "Client Versions": "Anwendungsversionen", "Send custom state event": "Benutzerdefiniertes Status-Event senden", "Failed to send event!": "Event konnte nicht gesendet werden!", - "Server info": "Serverinfo", + "Server info": "Server-Info", "Explore account data": "Kontodaten erkunden", "View servers in room": "Zeige Server im Raum", "Explore room state": "Raumstatus erkunden", "Hide my messages from new joiners": "Meine Nachrichten vor neuen Teilnehmern verstecken", "Your old messages will still be visible to people who received them, just like emails you sent in the past. Would you like to hide your sent messages from people who join rooms in the future?": "Deine alten Nachrichten werden weiterhin für Personen sichtbar bleiben, die sie erhalten haben, so wie es bei E-Mails der Fall ist. Möchtest du deine Nachrichten vor Personen verbergen, die Räume in der Zukunft betreten?", - "You will be removed from the identity server: your friends will no longer be able to find you with your email or phone number": "Du wirst vom Identitätsserver entfernt: Deine Freunde werden nicht mehr in der Lage sein dich über deine E-Mail-Adresse oder Telefonnummer zu finden", + "You will be removed from the identity server: your friends will no longer be able to find you with your email or phone number": "Du wirst vom Identitäts-Server entfernt: Deine Freunde werden nicht mehr in der Lage sein, dich über deine E-Mail-Adresse oder Telefonnummer zu finden", "You will leave all rooms and DMs that you are in": "Du wirst alle Unterhaltungen verlassen, in denen du dich befindest", "No one will be able to reuse your username (MXID), including you: this username will remain unavailable": "Niemand wird in der Lage sein deinen Benutzernamen (MXID) wiederzuverwenden, dich eingeschlossen: Der Benutzername wird nicht verfügbar bleiben", "You will no longer be able to log in": "Du wirst dich nicht mehr anmelden können", @@ -3311,7 +3311,7 @@ "Help us identify issues and improve %(analyticsOwner)s by sharing anonymous usage data. To understand how people use multiple devices, we'll generate a random identifier, shared by your devices.": "Hilf uns dabei Probleme zu identifizieren und %(analyticsOwner)s zu verbessern, indem du anonyme Nutzungsdaten teilst. Um zu verstehen, wie Personen mehrere Geräte verwenden, werden wir eine zufällige Kennung generieren, die zwischen deinen Geräten geteilt wird.", "You can use the custom server options to sign into other Matrix servers by specifying a different homeserver URL. This allows you to use %(brand)s with an existing Matrix account on a different homeserver.": "Du kannst in den benutzerdefinierten Server-Optionen eine andere Heim-Server-URL angeben, um dich bei anderen Matrix-Servern anzumelden. Dadurch kannst du %(brand)s mit einem auf einem anderen Heim-Server liegenden Matrix-Konto nutzen.", "%(brand)s was denied permission to fetch your location. Please allow location access in your browser settings.": "%(brand)s wurde der Zugriff auf deinen Standort verweigert. Bitte erlaube den Zugriff in den Einstellungen deines Browsers.", - "Enable live location sharing": "Standortfreigabe in Echtzeit aktivieren", + "Enable live location sharing": "Aktiviere Echtzeit-Standortfreigabe", "To view %(roomName)s, you need an invite": "Du musst eingeladen sein, um %(roomName)s zu sehen", "%(errcode)s was returned while trying to access the room or space. If you think you're seeing this message in error, please submit a bug report.": "Beim Betreten des Raums oder Spaces ist ein Fehler aufgetreten %(errcode)s. Wenn du denkst dass diese Meldung nicht korrekt ist sende bitte einen Fehlerbericht.", "Private room": "Privater Raum", @@ -3321,12 +3321,12 @@ "Previous recently visited room or space": "Vorheriger kürzlich besuchter Raum oder Space", "You have been logged out of all devices and will no longer receive push notifications. To re-enable notifications, sign in again on each device.": "Du wurdest von allen Geräten abgemeldet und erhältst keine Push-Benachrichtigungen mehr. Um Benachrichtigungen wieder zu aktivieren, melde dich auf jedem Gerät erneut an.", "Sign out all devices": "Alle Geräte abmelden", - "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.": "Wenn du dein Passwort zurücksetzt, werden alle deine anderen Geräte abgemeldet. Wenn auf diesen Ende-zu-Ende-Schlüssel gespeichert sind, kann der Verlauf deiner verschlüsselten Unterhaltungen verloren gehen.", + "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.": "Wenn du dein Passwort zurücksetzt, werden all deine anderen Geräte abgemeldet. Wenn auf diesen Ende-zu-Ende-Schlüssel gespeichert sind, kann der Verlauf deiner verschlüsselten Unterhaltungen verloren gehen.", "Event ID: %(eventId)s": "Event-ID: %(eventId)s", "Give feedback": "Rückmeldung geben", "Threads are a beta feature": "Threads sind eine Betafunktion", "Threads help keep your conversations on-topic and easy to track.": "Threads helfen dabei, dass deine Konversationen beim Thema und leicht nachverfolgbar bleiben.", - "Your message wasn't sent because this homeserver has been blocked by its administrator. Please contact your service administrator to continue using the service.": "Deine Nachricht wurde nicht gesendet, weil dieser Heimserver von dessen Administrator gesperrt wurde. Bitte kontaktiere deinen Dienstadministrator um den Dienst weiterzunutzen.", + "Your message wasn't sent because this homeserver has been blocked by its administrator. Please contact your service administrator to continue using the service.": "Deine Nachricht wurde nicht gesendet, weil dieser Heim-Server von dessen Administration gesperrt wurde. Bitte kontaktiere deine Dienstadministration, um den Dienst weiterzunutzen.", "Video rooms": "Videoräume", "You were disconnected from the call. (Error: %(message)s)": "Du wurdest vom Anruf getrennt. (Error: %(message)s)", "Connection lost": "Verbindung verloren", @@ -3339,7 +3339,7 @@ "%(count)s people joined|one": "%(count)s Person hat teilgenommen", "%(count)s people joined|other": "%(count)s Personen haben teilgenommen", "Enable hardware acceleration": "Aktiviere die Hardwarebeschleunigung", - "Please note: this is a labs feature using a temporary implementation. This means you will not be able to delete your location history, and advanced users will be able to see your location history even after you stop sharing your live location with this room.": "Bitte beachte: Dies ist eine experimentelle Funktion, die eine temporäre Implementierung nutzt. Das bedeutet, dass du deinen Standortverlauf nicht löschen kannst und erfahrene Nutzer ihn sehen können, selbst wenn du deinen Live-Standort nicht mehr mit diesem Raum teilst.", + "Please note: this is a labs feature using a temporary implementation. This means you will not be able to delete your location history, and advanced users will be able to see your location history even after you stop sharing your live location with this room.": "Bitte beachte: Dies ist eine experimentelle Funktion, die eine temporäre Implementierung nutzt. Das bedeutet, dass du deinen Standortverlauf nicht löschen kannst und erfahrene Nutzer ihn sehen können, selbst wenn du deinen Echtzeit-Standort nicht mehr mit diesem Raum teilst.", "Video room": "Videoraum", "Video rooms are a beta feature": "Videoräume sind eine Betafunktion", "Minimise": "Minimieren", @@ -3351,7 +3351,7 @@ "Show: Matrix rooms": "Zeige: Matrix-Räume", "Create a video room": "Videoraum erstellen", "Open room": "Raum öffnen", - "When you sign out, these keys will be deleted from this device, which means you won't be able to read encrypted messages unless you have the keys for them on your other devices, or backed them up to the server.": "Wenn du dich abmeldest werden die Schlüssel auf diesem Gerät gelöscht. Das bedeutet, dass du keine verschlüsselten Nachrichten mehr lesen kannst, außer du hast die Schlüssel auf einem anderen Gerät oder ein Backup der Schlüssel auf dem Server.", + "When you sign out, these keys will be deleted from this device, which means you won't be able to read encrypted messages unless you have the keys for them on your other devices, or backed them up to the server.": "Wenn du dich abmeldest, werden die Schlüssel auf diesem Gerät gelöscht. Das bedeutet, dass du keine verschlüsselten Nachrichten mehr lesen kannst, wenn du die Schlüssel nicht auf einem anderen Gerät oder eine Sicherung auf dem Server hast.", "Ignore user": "Nutzer ignorieren", "Show rooms": "Räume zeigen", "Search for": "Suche nach", @@ -3364,11 +3364,11 @@ "Copy invite link": "Einladungslink kopieren", "Some results may be hidden": "Einige Ergebnisse können ausgeblendet sein", "Close sidebar": "Seitenleiste schließen", - "No live locations": "Keine Live-Standorte", - "Live location error": "Live-Standort Fehler", - "Live location ended": "Live-Standort beendet", - "Loading live location...": "Lade Live-Standort …", - "Live until %(expiryTime)s": "Existiert bis %(expiryTime)s", + "No live locations": "Keine Echtzeit-Standorte", + "Live location error": "Echtzeit-Standort-Fehler", + "Live location ended": "Echtzeit-Standort beendet", + "Loading live location...": "Lade Echtzeit-Standort …", + "Live until %(expiryTime)s": "Echtzeit bis %(expiryTime)s", "Updated %(humanizedUpdateTime)s": "%(humanizedUpdateTime)s aktualisiert", "Joining the beta will reload %(brand)s.": "Die Teilnahme an der Beta wird %(brand)s neustarten.", "Leaving the beta will reload %(brand)s.": "Das Verlassen der Beta wird %(brand)s neustarten.", @@ -3381,11 +3381,11 @@ "Doesn't look like valid JSON.": "Scheint kein gültiges JSON zu sein.", "Other options": "Andere Optionen", "If you can't find the room you're looking for, ask for an invite or create a new room.": "Falls du den Raum nicht findest, frag nach einer Einladung oder erstelle einen neuen Raum.", - "An error occurred whilst sharing your live location, please try again": "Ein Fehler ist während des Teilens deines Live-Standorts aufgetreten. Bitte versuche es erneut", - "Live location enabled": "Live-Standort aktiviert", - "An error occurred whilst sharing your live location": "Ein Fehler ist während des Teilens deines Live-Standorts aufgetreten", - "An error occurred while stopping your live location": "Ein Fehler ist beim Stoppen des Live-Standorts aufgetreten", - "An error occurred while stopping your live location, please try again": "Ein Fehler ist beim Stoppen des Live-Standorts aufgetreten. Bitte versuche es erneut", + "An error occurred whilst sharing your live location, please try again": "Ein Fehler ist während des Teilens deines Echtzeit-Standorts aufgetreten, bitte versuche es erneut", + "Live location enabled": "Echtzeit-Standort aktiviert", + "An error occurred whilst sharing your live location": "Ein Fehler ist während des Teilens deines Echtzeit-Standorts aufgetreten", + "An error occurred while stopping your live location": "Ein Fehler ist während des Beendens deines Echtzeit-Standorts aufgetreten", + "An error occurred while stopping your live location, please try again": "Ein Fehler ist während des Beendens deines Echtzeit-Standorts aufgetreten, bitte versuche es erneut", "Check your email to continue": "Zum Fortfahren prüfe deine E-Mails", "Failed to set direct message tag": "Fehler beim Setzen der Nachrichtenmarkierung", "Resent!": "Verschickt!", @@ -3398,12 +3398,12 @@ "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.", + "You need to have the right permissions in order to share locations in this room.": "Du benötigst die entsprechenden Berechtigungen, um deinen Echtzeit-Standort in diesem Raum freizugeben.", "Who will you chat to the most?": "Mit wem wirst du am meisten schreiben?", "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 Unterhaltung werden Ende-zu-Ende-verschlüsselt.", "Send your first message to invite to chat": "Schreibe deine erste Nachricht, um zur Unterhaltung einzuladen", - "Your server doesn't support disabling sending read receipts.": "Dein Server unterstützt das deaktivieren von Lesebestätigungen nicht.", + "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", @@ -3413,12 +3413,12 @@ "Developer command: Discards the current outbound group session and sets up new Olm sessions": "Entwicklungsbefehl: Verwirft die aktuell ausgehende Gruppensitzung und setzt eine neue Olm-Sitzung auf", "Toggle attribution": "Info ein-/ausblenden", "In spaces %(space1Name)s and %(space2Name)s.": "In den Spaces %(space1Name)s und %(space2Name)s.", - "Joining…": "Betreten …", + "Joining…": "Betrete …", "Show Labs settings": "Zeige die \"Labor\" Einstellungen", "To view, please enable video rooms in Labs first": "Zum Anzeigen, aktiviere bitte Videoräume in den Laboreinstellungen", "Use the “+” button in the room section of the left panel.": "Verwende die „+“-Schaltfläche des Räumebereichs der linken Seitenleiste.", "View all": "Alles anzeigen", - "Improve your account security by following these recommendations": "Verstärke die Sicherheit deines Benutzerkontos mit folgenden Empfehlungen", + "Improve your account security by following these recommendations": "Verbessere deine Kontosicherheit, indem du diese Empfehlungen beherzigst", "Security recommendations": "Sicherheitsempfehlungen", "Filter devices": "Geräte filtern", "Inactive for %(inactiveAgeDays)s days or longer": "Seit %(inactiveAgeDays)s oder mehr Tagen inaktiv", @@ -3429,11 +3429,11 @@ "No sessions found.": "Keine Sitzungen gefunden.", "No inactive sessions found.": "Keine inaktiven Sitzungen gefunden.", "No unverified sessions found.": "Keine unverifizierten Sitzungen gefunden.", - "No verified sessions found.": "Keine verifizierte Sitzung gefunden.", - "Consider signing out from old sessions (%(inactiveAgeDays)s days or older) you don't use anymore": "Erwäge, dich aus alten Sitzungen (%(inactiveAgeDays)s oder mehr Tage) abzumelden, die du nicht mehr benutzt", + "No verified sessions found.": "Keine verifizierten Sitzungen gefunden.", + "Consider signing out from old sessions (%(inactiveAgeDays)s days or older) you don't use anymore": "Erwäge, dich aus alten (%(inactiveAgeDays)s Tage oder mehr), nicht mehr verwendeten Sitzungen abzumelden", "Inactive sessions": "Inaktive Sitzungen", "Unverified sessions": "Nicht verifizierte Sitzungen", - "For best security, sign out from any session that you don't recognize or use anymore.": "Für die bestmögliche Sicherheit, melde dich von allen Sitzungen ab, die du nicht erkennst oder nicht mehr benutzt.", + "For best security, sign out from any session that you don't recognize or use anymore.": "Für bestmögliche Sicherheit, melde dich von allen Sitzungen ab, die du nicht erkennst oder benutzt.", "Verified sessions": "Verifizierte Sitzungen", "Unverified session": "Nicht verifizierte Sitzung", "This session is ready for secure messaging.": "Diese Sitzung ist für sichere Kommunikation bereit.", @@ -3445,7 +3445,7 @@ "Session details": "Sitzungsdetails", "IP address": "IP-Adresse", "Device": "Gerät", - "Last activity": "Neuste Aktivität", + "Last activity": "Neueste Aktivität", "Current session": "Aktuelle Sitzung", "For best security, verify your sessions and sign out from any session that you don't recognize or use anymore.": "Für bestmögliche Sicherheit verifiziere deine Sitzungen und melde dich von allen ab, die du nicht erkennst oder nutzt.", "Other sessions": "Andere Sitzungen", @@ -3485,7 +3485,7 @@ "Map feedback": "Rückmeldung zur Karte", "Online community members": "Online Community-Mitglieder", "Help": "Hilfe", - "You don't have permission to share locations": "Du hast keine Berechtigung Live-Standorte zu teilen", + "You don't have permission to share locations": "Dir fehlt die Berechtigung, Echtzeit-Standorte freigeben zu dürfen", "Un-maximise": "Maximieren rückgängig machen", "Create video room": "Videoraum erstellen", "Google Play and the Google Play logo are trademarks of Google LLC.": "Google Play und das Google Play Logo sind eingetragene Markenzeichen von Google LLC.", @@ -3525,14 +3525,14 @@ "Saved Items": "Gespeicherte Elemente", "Read receipts": "Lesebestätigungen", "Verify your sessions for enhanced secure messaging or sign out from those you don't recognize or use anymore.": "Für besonders sichere Kommunikation verifiziere deine Sitzungen oder melde dich von ihnen ab, falls du sie nicht mehr identifizieren kannst.", - "Verify or sign out from this session for best security and reliability.": "Für bestmögliche Sicherheit und Zuverlässigkeit verifiziere diese Sitzung oder melden sie ab.", + "Verify or sign out from this session for best security and reliability.": "Für bestmögliche Sicherheit und Zuverlässigkeit verifiziere diese Sitzung oder melde sie ab.", "Keep ownership and control of community discussion.\nScale to support millions, with powerful moderation and interoperability.": "Verfüge und behalte die Kontrolle über Gespräche deiner Gemeinschaft.\nSkalierbar für Millionen von Nutzenden, mit mächtigen Moderationswerkzeugen und Interoperabilität.", "Community ownership": "In gemeinschaftlicher Hand", "Join the room to participate": "Betrete den Raum, um teilzunehmen", "Show shortcut to welcome checklist above the room list": "Verknüpfung zu ersten Schritten (Willkommen) anzeigen", "Find people": "Finde Personen", "Find your people": "Finde deine Leute", - "It’s what you’re here for, so lets get to it": "Deshalb bist du hier, also lass uns beginnen", + "It’s what you’re here for, so lets get to it": "Dafür bist du hier, also dann mal los", "It's not recommended to add encryption to public rooms. Anyone can find and join public rooms, so anyone can read messages in them. You'll get none of the benefits of encryption, and you won't be able to turn it off later. Encrypting messages in a public room will make receiving and sending messages slower.": "Verschlüsselung ist für öffentliche Räume nicht empfohlen. Jeder kann öffentliche Räume finden und betreten, also kann auch jeder die Nachrichten lesen. Du wirst keine der Vorteile von Verschlüsselung erhalten und kannst sie später auch nicht mehr deaktivieren. Nachrichten in öffentlichen Räumen zu verschlüsseln, wird das empfangen und senden verlangsamen.", "Empty room (was %(oldName)s)": "Leerer Raum (war %(oldName)s)", "Inviting %(user)s and %(count)s others|other": "Lade %(user)s und %(count)s weitere Person ein", @@ -3573,7 +3573,7 @@ "Video call (Element Call)": "Videoanruf (Element Call)", "Video call (Jitsi)": "Videoanruf (Jitsi)", "New group call experience": "Neue Gruppenanruf-Erfahrung", - "Live": "Live-Übertragung", + "Live": "Live", "Receive push notifications on this session.": "Erhalte Push-Benachrichtigungen in dieser Sitzung.", "Push notifications": "Push-Benachrichtigungen", "Toggle push notifications on this session.": "(De)Aktiviere Push-Benachrichtigungen in dieser Sitzung.", @@ -3584,5 +3584,80 @@ "%(selectedDeviceCount)s sessions selected": "%(selectedDeviceCount)s Sitzungen ausgewählt", "Video call ended": "Videoanruf beendet", "%(name)s started a video call": "%(name)s hat einen Videoanruf begonnen", - "Record the client name, version, and url to recognise sessions more easily in session manager": "Anwendungsbezeichnung, -version und -adresse registrieren, damit diese Sitzung in der Sitzungsverwaltung besser erkennbar ist" + "Record the client name, version, and url to recognise sessions more easily in session manager": "Bezeichnung, Version und URL der Anwendung registrieren, damit diese Sitzung in der Sitzungsverwaltung besser erkennbar ist", + "Application": "Anwendung", + "URL": "URL", + "Version": "Version", + "Mobile session": "Mobil-Sitzung", + "Desktop session": "Desktop-Sitzung", + "Web session": "Web-Sitzung", + "Unknown session type": "Unbekannter Sitzungstyp", + "Video call started": "Videoanruf hat begonnen", + "Unknown room": "Unbekannter Raum", + "Video call started in %(roomName)s. (not supported by this browser)": "Ein Videoanruf hat in %(roomName)s begonnen. (Von diesem Browser nicht unterstützt)", + "Video call started in %(roomName)s.": "Ein Videoanruf hat in %(roomName)s begonnen.", + "Fill screen": "Bildschirm füllen", + "Freedom": "Freiraum", + "Spotlight": "Rampenlicht", + "Room info": "Raum-Info", + "View chat timeline": "Nachrichtenverlauf anzeigen", + "Close call": "Anruf schließen", + "Layout type": "Anordnungsart", + "Client": "Anwendung", + "Model": "Modell", + "Operating system": "Betriebssystem", + "Call type": "Anrufart", + "You do not have sufficient permissions to change this.": "Du hast nicht die erforderlichen Berechtigungen, um dies zu ändern.", + "Start %(brand)s calls": "Beginne %(brand)s-Anrufe", + "Video call (%(brand)s)": "Videoanruf (%(brand)s)", + "%(brand)s is end-to-end encrypted, but is currently limited to smaller numbers of users.": "%(brand)s ist Ende-zu-Ende-verschlüsselt, allerdings noch auf eine geringere Anzahl Benutzer beschränkt.", + "Enable %(brand)s as an additional calling option in this room": "Verwende %(brand)s als alternative Anrufoption in diesem Raum", + "Join %(brand)s calls": "Trete %(brand)s-Anrufen bei", + "Sorry — this call is currently full": "Entschuldigung — dieser Anruf ist aktuell besetzt", + "Wysiwyg composer (plain text mode coming soon) (under active development)": "WYSIWYG-Eingabe (demnächst mit Klartextmodus) (in aktiver Entwicklung)", + "Our new sessions manager provides better visibility of all your sessions, and greater control over them including the ability to remotely toggle push notifications.": "Unsere neue Sitzungsverwaltung bietet bessere Übersicht und Kontrolle über all deine Sitzungen, inklusive der Möglichkeit, aus der Ferne Push-Benachrichtigungen umzuschalten.", + "Have greater visibility and control over all your sessions.": "Bessere Übersicht und Kontrolle über all deine Sitzungen.", + "New session manager": "Neue Sitzungsverwaltung", + "Use new session manager": "Neue Sitzungsverwaltung nutzen", + "Sign out all other sessions": "Alle anderen Sitzungen abmelden", + "pause voice broadcast": "Sprachübertragung pausieren", + "resume voice broadcast": "Sprachübertragung fortsetzen", + "Italic": "Kursiv", + "Underline": "Unterstrichen", + "Try out the rich text editor (plain text mode coming soon)": "Probiere den Textverarbeitungs-Editor (bald auch mit Klartext-Modus)", + "You have already joined this call from another device": "Du nimmst an diesem Anruf bereits mit einem anderen Gerät teil", + "stop voice broadcast": "Sprachübertragung beenden", + "Notifications silenced": "Benachrichtigungen stummgeschaltet", + "Are you sure you want to stop your live broadcast?This will end the broadcast and the full recording will be available in the room.": "Willst du die Sprachübertragung wirklich beenden? Damit endet auch die Aufnahme.", + "Yes, stop broadcast": "Ja, Sprachübertragung beenden", + "Stop live broadcasting?": "Sprachübertragung beenden?", + "Sign in with QR code": "Mit QR-Code anmelden", + "Browser": "Browser", + "Allow a QR code to be shown in session manager to sign in another device (requires compatible homeserver)": "Erlaube es andere Geräte mittels QR-Code in der Sitzungsverwaltung anzumelden (kompatibler Heim-Server benötigt)", + "Completing set up of your new device": "Schließe Anmeldung deines neuen Gerätes ab", + "Waiting for device to sign in": "Warte auf Anmeldung des Gerätes", + "Connecting...": "Verbinde …", + "Review and approve the sign in": "Überprüfe und genehmige die Anmeldung", + "Select 'Scan QR code'": "Wähle „QR-Code einlesen“", + "Start at the sign in screen": "Beginne auf dem Anmeldebildschirm", + "Scan the QR code below with your device that's signed out.": "Lese den folgenden QR-Code mit deinem nicht angemeldeten Gerät ein.", + "By approving access for this device, it will have full access to your account.": "Indem du den Zugriff dieses Gerätes bestätigst, erhält es vollen Zugang zu deinem Account.", + "Check that the code below matches with your other device:": "Überprüfe, dass der unten angezeigte Code mit deinem anderen Gerät übereinstimmt:", + "Devices connected": "Geräte verbunden", + "The homeserver doesn't support signing in another device.": "Der Heim-Server unterstützt die Anmeldung eines anderen Gerätes nicht.", + "An unexpected error occurred.": "Ein unerwarteter Fehler ist aufgetreten.", + "The request was cancelled.": "Die Anfrage wurde abgebrochen.", + "The other device isn't signed in.": "Das andere Gerät ist nicht angemeldet.", + "The other device is already signed in.": "Das andere Gerät ist bereits angemeldet.", + "The request was declined on the other device.": "Die Anfrage wurde auf dem anderen Gerät abgelehnt.", + "Linking with this device is not supported.": "Die Verbindung mit diesem Gerät wird nicht unterstützt.", + "The scanned code is invalid.": "Der gescannte Code ist ungültig.", + "The linking wasn't completed in the required time.": "Die Verbindung konnte nicht in der erforderlichen Zeit hergestellt werden.", + "Sign in new device": "Neues Gerät anmelden", + "Show QR code": "QR-Code anzeigen", + "Someone else is already recording a voice broadcast. Wait for their voice broadcast to end to start a new one.": "Jemand anderes nimmt bereits eine Sprachübertragung auf. Warte auf das Ende der Übertragung, bevor du eine neue startest.", + "You don't have the required permissions to start a voice broadcast in this room. Contact a room administrator to upgrade your permissions.": "Du hast nicht die nötigen Berechtigungen, um eine Sprachübertragung in diesem Raum zu starten. Kontaktiere einen Raumadministrator, um deine Berechtigungen anzupassen.", + "You are already recording a voice broadcast. Please end your current voice broadcast to start a new one.": "Du zeichnest bereits eine Sprachübertragung auf. Bitte beende die laufende Übertragung, um eine neue zu beginnen.", + "Can't start a new voice broadcast": "Sprachübertragung kann nicht gestartet werden", + "You can use this device to sign in a new device with a QR code. You will need to scan the QR code shown on this device with your device that's signed out.": "Du kannst dieses Gerät verwenden, um ein neues Gerät per QR-Code anzumelden. Dazu musst du den auf diesem Gerät angezeigten QR-Code mit deinem nicht angemeldeten Gerät einlesen." } diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 661aae2c7a..807737bdda 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -637,10 +637,17 @@ "Send %(msgtype)s messages as you in your active room": "Send %(msgtype)s messages as you in your active room", "See %(msgtype)s messages posted to this room": "See %(msgtype)s messages posted to this room", "See %(msgtype)s messages posted to your active room": "See %(msgtype)s messages posted to your active room", - "Live": "Live", - "pause voice broadcast": "pause voice broadcast", + "Can't start a new voice broadcast": "Can't start a new voice broadcast", + "You are already recording a voice broadcast. Please end your current voice broadcast to start a new one.": "You are already recording a voice broadcast. Please end your current voice broadcast to start a new one.", + "You don't have the required permissions to start a voice broadcast in this room. Contact a room administrator to upgrade your permissions.": "You don't have the required permissions to start a voice broadcast in this room. Contact a room administrator to upgrade your permissions.", + "Someone else is already recording a voice broadcast. Wait for their voice broadcast to end to start a new one.": "Someone else is already recording a voice broadcast. Wait for their voice broadcast to end to start a new one.", + "Stop live broadcasting?": "Stop live broadcasting?", + "Are you sure you want to stop your live broadcast?This will end the broadcast and the full recording will be available in the room.": "Are you sure you want to stop your live broadcast?This will end the broadcast and the full recording will be available in the room.", + "Yes, stop broadcast": "Yes, stop broadcast", + "play voice broadcast": "play voice broadcast", "resume voice broadcast": "resume voice broadcast", - "stop voice broadcast": "stop voice broadcast", + "pause voice broadcast": "pause voice broadcast", + "Live": "Live", "Voice broadcast": "Voice broadcast", "Cannot reach homeserver": "Cannot reach homeserver", "Ensure you have a stable internet connection, or get in touch with the server admin": "Ensure you have a stable internet connection, or get in touch with the server admin", @@ -756,6 +763,7 @@ "Zoom in": "Zoom in", "Zoom out": "Zoom out", "Are you sure you want to exit during this export?": "Are you sure you want to exit during this export?", + "Unnamed Room": "Unnamed Room", "Generating a ZIP": "Generating a ZIP", "Fetched %(count)s events out of %(total)s|other": "Fetched %(count)s events out of %(total)s", "Fetched %(count)s events out of %(total)s|one": "Fetched %(count)s event out of %(total)s", @@ -931,6 +939,7 @@ "New session manager": "New session manager", "Have greater visibility and control over all your sessions.": "Have greater visibility and control over all your sessions.", "Our new sessions manager provides better visibility of all your sessions, and greater control over them including the ability to remotely toggle push notifications.": "Our new sessions manager provides better visibility of all your sessions, and greater control over them including the ability to remotely toggle push notifications.", + "Allow a QR code to be shown in session manager to sign in another device (requires compatible homeserver)": "Allow a QR code to be shown in session manager to sign in another device (requires compatible homeserver)", "Font size": "Font size", "Use custom size": "Use custom size", "Enable Emoji suggestions while typing": "Enable Emoji suggestions while typing", @@ -1738,7 +1747,6 @@ "Rename session": "Rename session", "Please be aware that session names are also visible to people you communicate with": "Please be aware that session names are also visible to people you communicate with", "Session ID": "Session ID", - "Client": "Client", "Last activity": "Last activity", "Application": "Application", "Version": "Version", @@ -1746,6 +1754,7 @@ "Device": "Device", "Model": "Model", "Operating system": "Operating system", + "Browser": "Browser", "IP address": "IP address", "Session details": "Session details", "Toggle push notifications on this session.": "Toggle push notifications on this session.", @@ -1784,6 +1793,9 @@ "Filter devices": "Filter devices", "Show": "Show", "%(selectedDeviceCount)s sessions selected": "%(selectedDeviceCount)s sessions selected", + "Sign in with QR code": "Sign in with QR code", + "You can use this device to sign in a new device with a QR code. You will need to scan the QR code shown on this device with your device that's signed out.": "You can use this device to sign in a new device with a QR code. You will need to scan the QR code shown on this device with your device that's signed out.", + "Show QR code": "Show QR code", "Security recommendations": "Security recommendations", "Improve your account security by following these recommendations": "Improve your account security by following these recommendations", "View all": "View all", @@ -2768,7 +2780,6 @@ "Or send invite link": "Or send invite link", "Unnamed Space": "Unnamed Space", "Invite to %(roomName)s": "Invite to %(roomName)s", - "Unnamed Room": "Unnamed Room", "Invite someone using their name, email address, username (like ) or share this space.": "Invite someone using their name, email address, username (like ) or share this space.", "Invite someone using their name, username (like ) or share this space.": "Invite someone using their name, username (like ) or share this space.", "Invite someone using their name, email address, username (like ) or share this room.": "Invite someone using their name, email address, username (like ) or share this room.", @@ -3178,6 +3189,26 @@ "Submit": "Submit", "Something went wrong in confirming your identity. Cancel and try again.": "Something went wrong in confirming your identity. Cancel and try again.", "Start authentication": "Start authentication", + "Sign in new device": "Sign in new device", + "The linking wasn't completed in the required time.": "The linking wasn't completed in the required time.", + "The scanned code is invalid.": "The scanned code is invalid.", + "Linking with this device is not supported.": "Linking with this device is not supported.", + "The request was declined on the other device.": "The request was declined on the other device.", + "The other device is already signed in.": "The other device is already signed in.", + "The other device isn't signed in.": "The other device isn't signed in.", + "The request was cancelled.": "The request was cancelled.", + "An unexpected error occurred.": "An unexpected error occurred.", + "The homeserver doesn't support signing in another device.": "The homeserver doesn't support signing in another device.", + "Devices connected": "Devices connected", + "Check that the code below matches with your other device:": "Check that the code below matches with your other device:", + "By approving access for this device, it will have full access to your account.": "By approving access for this device, it will have full access to your account.", + "Scan the QR code below with your device that's signed out.": "Scan the QR code below with your device that's signed out.", + "Start at the sign in screen": "Start at the sign in screen", + "Select 'Scan QR code'": "Select 'Scan QR code'", + "Review and approve the sign in": "Review and approve the sign in", + "Connecting...": "Connecting...", + "Waiting for device to sign in": "Waiting for device to sign in", + "Completing set up of your new device": "Completing set up of your new device", "Enter password": "Enter password", "Nice, strong password!": "Nice, strong password!", "Password is allowed, but unsafe": "Password is allowed, but unsafe", diff --git a/src/i18n/strings/es.json b/src/i18n/strings/es.json index 613e60e5ef..fa355fc59a 100644 --- a/src/i18n/strings/es.json +++ b/src/i18n/strings/es.json @@ -3549,5 +3549,83 @@ "Empty room (was %(oldName)s)": "Sala vacía (antes era %(oldName)s)", "%(user)s and %(count)s others|one": "%(user)s y 1 más", "%(user)s and %(count)s others|other": "%(user)s y %(count)s más", - "%(user1)s and %(user2)s": "%(user1)s y %(user2)s" + "%(user1)s and %(user2)s": "%(user1)s y %(user2)s", + "Spotlight": "Spotlight", + "Your server lacks native support, you must specify a proxy": "Tu servidor no es compatible, debes configurar un intermediario (proxy)", + "View chat timeline": "Ver historial del chat", + "You do not have permission to start voice calls": "No tienes permiso para iniciar llamadas de voz", + "Failed to set pusher state": "Fallo al establecer el estado push", + "Sign out of this session": "Cerrar esta sesión", + "Receive push notifications on this session.": "Recibir notificaciones push en esta sesión.", + "Please be aware that session names are also visible to people you communicate with": "Ten en cuenta que cualquiera con quien te comuniques puede ver los nombres de las sesiones", + "Sign out all other sessions": "Cerrar el resto de sesiones", + "You do not have sufficient permissions to change this.": "No tienes suficientes permisos para cambiar esto.", + "%(brand)s is end-to-end encrypted, but is currently limited to smaller numbers of users.": "%(brand)s está cifrado de extremo a extremo, pero actualmente está limitado a unos pocos participantes.", + "Enable %(brand)s as an additional calling option in this room": "Activar %(brand)s como una opción para las llamadas de esta sala", + "Enable notifications for this device": "Activar notificaciones en este dispositivo", + "Turn off to disable notifications on all your devices and sessions": "Desactiva para no recibir notificaciones en todos tus dispositivos y sesiones", + "You need to be able to kick users to do that.": "Debes poder sacar usuarios para hacer eso.", + "Video call started in %(roomName)s. (not supported by this browser)": "Videollamada empezada en %(roomName)s. (no compatible con este navegador)", + "Video call started in %(roomName)s.": "Videollamada empezada en %(roomName)s.", + "Layout type": "Tipo de disposición", + "%(downloadButton)s or %(copyButton)s": "%(downloadButton)s o %(copyButton)s", + "%(securityKey)s or %(recoveryFile)s": "%(securityKey)s o %(recoveryFile)s", + "Proxy URL": "URL de servidor proxy", + "Proxy URL (optional)": "URL de servidor proxy (opcional)", + "To disable you will need to log out and back in, use with caution!": "Para desactivarlo, tendrás que cerrar sesión y volverla a iniciar. ¡Ten cuidado!", + "Sliding Sync configuration": "Configuración de la sincronización progresiva", + "Your server lacks native support": "Tu servidor no es compatible", + "Your server has native support": "Tu servidor es compatible", + "Checking...": "Comprobando…", + "%(qrCode)s or %(appLinks)s": "%(qrCode)s o %(appLinks)s", + "Video call ended": "Videollamada terminada", + "%(name)s started a video call": "%(name)s comenzó una videollamada", + "%(qrCode)s or %(emojiCompare)s": "%(qrCode)s o %(emojiCompare)s", + "Room info": "Info. de la sala", + "Underline": "Subrayado", + "Italic": "Cursiva", + "Close call": "Terminar llamada", + "Freedom": "Libertad", + "There's no one here to call": "No hay nadie a quien llamar aquí", + "You do not have permission to start video calls": "No tienes permiso para empezar videollamadas", + "Ongoing call": "Llamada en curso", + "Video call (%(brand)s)": "Videollamada (%(brand)s)", + "Video call (Jitsi)": "Videollamada (Jitsi)", + "%(selectedDeviceCount)s sessions selected": "%(selectedDeviceCount)s sesiones seleccionadas", + "Unknown session type": "Sesión de tipo desconocido", + "Web session": "Sesión web", + "Mobile session": "Sesión móvil", + "Desktop session": "Sesión de escritorio", + "Toggle push notifications on this session.": "Activar/desactivar notificaciones push en esta sesión.", + "Push notifications": "Notificaciones push", + "Operating system": "Sistema operativo", + "Model": "Modelo", + "URL": "URL", + "Version": "Versión", + "Application": "Aplicación", + "Client": "Cliente", + "Rename session": "Renombrar sesión", + "Call type": "Tipo de llamada", + "Join %(brand)s calls": "Unirte a llamadas de %(brand)s", + "Start %(brand)s calls": "Empezar llamadas de %(brand)s", + "Voice broadcasts": "Retransmisiones de voz", + "Enable notifications for this account": "Activar notificaciones para esta cuenta", + "Fill screen": "Llenar la pantalla", + "You have already joined this call from another device": "Ya te has unido a la llamada desde otro dispositivo", + "Sorry — this call is currently full": "Lo sentimos — la llamada está llena", + "Use new session manager": "Usar el nuevo gestor de sesiones", + "New session manager": "Nuevo gestor de sesiones", + "Voice broadcast (under active development)": "Retransmisión de voz (en desarrollo)", + "New group call experience": "Nueva experiencia de llamadas grupales", + "Element Call video rooms": "Salas de vídeo Element Call", + "Sliding Sync mode (under active development, cannot be disabled)": "Modo de sincronización progresiva (en pleno desarrollo, no puede desactivarse)", + "Try out the rich text editor (plain text mode coming soon)": "Prueba el nuevo editor de texto con formato (un modo sin formato estará disponible próximamente)", + "Notifications silenced": "Notificaciones silenciadas", + "Video call started": "Videollamada iniciada", + "Unknown room": "Sala desconocida", + "Voice broadcast": "Retransmisión de voz", + "stop voice broadcast": "parar retransmisión de voz", + "resume voice broadcast": "reanudar retransmisión de voz", + "pause voice broadcast": "pausar retransmisión de voz", + "Live": "En directo" } diff --git a/src/i18n/strings/et.json b/src/i18n/strings/et.json index b2a004cb4c..fabcc93019 100644 --- a/src/i18n/strings/et.json +++ b/src/i18n/strings/et.json @@ -3587,5 +3587,81 @@ "Push notifications": "Tõuketeavitused", "Toggle push notifications on this session.": "Lülita tõuketeavitused selles sessioonis sisse/välja.", "Enable notifications for this device": "Võta teavitused selles seadmes kasutusele", - "Turn off to disable notifications on all your devices and sessions": "Välja lülitades keelad teavitused kõikides oma seadmetes ja sessioonides" + "Turn off to disable notifications on all your devices and sessions": "Välja lülitades keelad teavitused kõikides oma seadmetes ja sessioonides", + "Room info": "Jututoa teave", + "View chat timeline": "Vaata vestluse ajajoont", + "Close call": "Lõpeta kõne", + "Layout type": "Kujunduse tüüp", + "Spotlight": "Rambivalgus", + "Freedom": "Vabadus", + "Unknown session type": "Tundmatu sessioonitüüp", + "Web session": "Veebirakendus", + "Mobile session": "Nutirakendus", + "Desktop session": "Töölauarakendus", + "URL": "URL", + "Version": "Versioon", + "Application": "Rakendus", + "Fill screen": "Täida ekraan", + "Record the client name, version, and url to recognise sessions more easily in session manager": "Sessioonide paremaks tuvastamiseks saad nüüd sessioonihalduris salvestada klientrakenduse nime, versiooni ja aadressi", + "Video call started": "Videokõne algas", + "Unknown room": "Teadmata jututuba", + "Live": "Otseeeter", + "Video call started in %(roomName)s. (not supported by this browser)": "Videokõne algas %(roomName)s jututoas. (ei ole selles brauseris toetatud)", + "Video call started in %(roomName)s.": "Videokõne algas %(roomName)s jututoas.", + "Video call (%(brand)s)": "Videokõne (%(brand)s)", + "Operating system": "Operatsioonisüsteem", + "Model": "Mudel", + "Client": "Klient", + "Call type": "Kõne tüüp", + "You do not have sufficient permissions to change this.": "Sul pole piisavalt õigusi selle muutmiseks.", + "%(brand)s is end-to-end encrypted, but is currently limited to smaller numbers of users.": "%(brand)s kasutab läbivat krüptimist, kuid on hetkel piiratud väikese osalejate arvuga ühes kõnes.", + "Enable %(brand)s as an additional calling option in this room": "Võta kasutusele %(brand)s kui lisavõimalus kõnedeks selles jututoas", + "Join %(brand)s calls": "Liitu %(brand)s kõnedega", + "Start %(brand)s calls": "Alusta helistamist %(brand)s abil", + "Sorry — this call is currently full": "Vabandust, selles kõnes ei saa rohkem osalejaid olla", + "stop voice broadcast": "lõpeta ringhäälingukõne", + "resume voice broadcast": "jätka ringhäälingukõnet", + "pause voice broadcast": "peata ringhäälingukõne", + "Underline": "Allajoonitud tekst", + "Italic": "Kaldkiri", + "Sign out all other sessions": "Logi välja kõikidest oma muudest sessioonidest", + "You have already joined this call from another device": "Sa oled selle kõnega juba ühest teisest seadmest liitunud", + "Our new sessions manager provides better visibility of all your sessions, and greater control over them including the ability to remotely toggle push notifications.": "Uues sessioonihalduris saad parema ülevaate kõikidest oma sessioonidest ning rohkem võimalusi neid hallata, sealhulgas tõuketeavituste sisse- ja väljalülitamine.", + "Have greater visibility and control over all your sessions.": "Sellega saad parema ülevaate oma sessioonidest ja võimaluse neid mugavasti hallata.", + "New session manager": "Uus sessioonihaldur", + "Use new session manager": "Kasuta uut sessioonihaldurit", + "Try out the rich text editor (plain text mode coming soon)": "Proovi vormindatud teksti alusel töötavat tekstitoimetit (varsti lisandub ka vormindamata teksti režiim)", + "Notifications silenced": "Teavitused on summutatud", + "Completing set up of your new device": "Lõpetame uue seadme seadistamise", + "Waiting for device to sign in": "Ootame, et teine seade logiks võrku", + "Connecting...": "Ühendamisel…", + "Review and approve the sign in": "Vaata üle ja kinnita sisselogimine Matrixi'i võrku", + "Select 'Scan QR code'": "Vali „Loe QR-koodi“", + "Start at the sign in screen": "Alusta sisselogimisvaatest", + "Scan the QR code below with your device that's signed out.": "Loe QR-koodi seadmega, kus sa oled Matrix'i võrgust välja loginud.", + "By approving access for this device, it will have full access to your account.": "Lubades ligipääsu sellele seadmele, annad talle ka täismahulise ligipääsu oma kasutajakontole.", + "Check that the code below matches with your other device:": "Kontrolli, et järgnev kood klapib teises seadmes kuvatava koodiga:", + "Devices connected": "Seadmed on ühendatud", + "The homeserver doesn't support signing in another device.": "Koduserver ei toeta muude seadmete võrku logimise võimalust.", + "An unexpected error occurred.": "Tekkis teadmata viga.", + "The request was cancelled.": "Päring katkestati.", + "The other device isn't signed in.": "Teine seade ei ole võrku loginud.", + "The other device is already signed in.": "Teine seade on juba võrku loginud.", + "The request was declined on the other device.": "Teine seade lükkas päringu tagasi.", + "Linking with this device is not supported.": "Sidumine selle seadmega ei ole toetatud.", + "The scanned code is invalid.": "Skaneeritud QR-kood on vigane.", + "The linking wasn't completed in the required time.": "Sidumine ei lõppenud etteantud aja jooksul.", + "Sign in new device": "Logi sisse uus seade", + "Show QR code": "Näita QR-koodi", + "Sign in with QR code": "Logi sisse QR-koodi abil", + "Browser": "Brauser", + "You can use this device to sign in a new device with a QR code. You will need to scan the QR code shown on this device with your device that's signed out.": "Sa saad kasutada seda seadet mõne muu seadme logimiseks Matrix'i võrku QR-koodi alusel. Selleks skaneeri võrgust väljalogitud seadmega seda QR-koodi.", + "Allow a QR code to be shown in session manager to sign in another device (requires compatible homeserver)": "Teise seadme sisselogimiseks luba QR-koodi kuvamine sessioonihalduris (eeldab, et koduserver sellist võimalust toetab)", + "Yes, stop broadcast": "Jah, lõpeta", + "Are you sure you want to stop your live broadcast?This will end the broadcast and the full recording will be available in the room.": "Kas sa oled kindel, et soovid otseeetri lõpetada? Sellega ringhäälingukõne salvestamine lõppeb ja salvestis on kättesaadav kõigile jututoas.", + "Stop live broadcasting?": "Kas lõpetame otseeetri?", + "Someone else is already recording a voice broadcast. Wait for their voice broadcast to end to start a new one.": "Keegi juba salvestab ringhäälingukõnet. Uue ringhäälingukõne salvestamiseks palun oota, kuni see teine ringhäälingukõne on lõppenud.", + "You don't have the required permissions to start a voice broadcast in this room. Contact a room administrator to upgrade your permissions.": "Sul pole piisavalt õigusi selles jututoas ringhäälingukõne algatamiseks. Õiguste lisamiseks palun võta ühendust jututoa haldajaga.", + "You are already recording a voice broadcast. Please end your current voice broadcast to start a new one.": "Sa juba salvestad ringhäälingukõnet. Uue alustamiseks palun lõpeta eelmine salvestus.", + "Can't start a new voice broadcast": "Uue ringhäälingukõne alustamine pole võimalik" } diff --git a/src/i18n/strings/fa.json b/src/i18n/strings/fa.json index ac9b4a9f8c..c6ebc564a5 100644 --- a/src/i18n/strings/fa.json +++ b/src/i18n/strings/fa.json @@ -2502,5 +2502,12 @@ "Jump to the given date in the timeline": "پرش به تاریخ تعیین شده در جدول زمانی", "Failed to invite users to %(roomName)s": "افزودن کاربران به %(roomName)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 را دارید.
انجمن ها دیگر پشتیبانی نمی شوند و با فضاها جایگزین شده اند. در مورد فضا بیشتر بدانید", - "That link is no longer supported": "لینک موردنظر دیگر پشتیبانی نمی شود" + "That link is no longer supported": "لینک موردنظر دیگر پشتیبانی نمی شود", + "Inviting %(user)s and %(count)s others|other": "دعوت کردن %(user)s و %(count)s دیگر", + "Video call started in %(roomName)s. (not supported by this browser)": "تماس ویدئویی در %(roomName)s شروع شد. (توسط این مرورگر پشتیبانی نمی‌شود.)", + "Video call started in %(roomName)s.": "تماس ویدئویی در %(roomName)s شروع شد.", + "No virtual room for this room": "اتاق مجازی برای این اتاق وجود ندارد", + "Switches to this room's virtual room, if it has one": "جابجایی به اتاق مجازی این اتاق، اگر یکی وجود داشت", + "You need to be able to kick users to do that.": "برای انجام این کار نیاز دارید که بتوانید کاربران را حذف کنید.", + "Empty room (was %(oldName)s)": "اتاق خالی (نام قبلی: %(oldName)s)" } diff --git a/src/i18n/strings/fr.json b/src/i18n/strings/fr.json index 07c651812c..f1751965e7 100644 --- a/src/i18n/strings/fr.json +++ b/src/i18n/strings/fr.json @@ -3589,5 +3589,47 @@ "Enable notifications for this account": "Activer les notifications pour ce compte", "Video call ended": "Appel vidéo terminé", "%(name)s started a video call": "%(name)s a démarré un appel vidéo", - "Record the client name, version, and url to recognise sessions more easily in session manager": "Enregistrez le nom, la version et l'URL du client afin de reconnaitre les sessions plus facilement dans le gestionnaire de sessions" + "Record the client name, version, and url to recognise sessions more easily in session manager": "Enregistrez le nom, la version et l'URL du client afin de reconnaitre les sessions plus facilement dans le gestionnaire de sessions", + "Version": "Version", + "Application": "Application", + "URL": "URL", + "Unknown session type": "Type de session inconnu", + "Web session": "session internet", + "Mobile session": "Session de téléphone portable", + "Desktop session": "Session de bureau", + "Video call started": "Appel vidéo commencé", + "Unknown room": "Salon inconnu", + "Video call started in %(roomName)s. (not supported by this browser)": "Appel vidéo commencé dans %(roomName)s. (non supporté par ce navigateur)", + "Video call started in %(roomName)s.": "Appel vidéo commencé dans %(roomName)s.", + "Close call": "Terminer l’appel", + "Layout type": "Type de mise en page", + "Spotlight": "Projecteur", + "Freedom": "Liberté", + "Fill screen": "Remplir l’écran", + "Room info": "Information du salon", + "View chat timeline": "Afficher la chronologie du chat", + "Video call (%(brand)s)": "Appel vidéo (%(brand)s)", + "Operating system": "Système d’exploitation", + "Model": "Modèle", + "Client": "Client", + "Call type": "Type d’appel", + "You do not have sufficient permissions to change this.": "Vous n’avez pas assez de permissions pour changer ceci.", + "%(brand)s is end-to-end encrypted, but is currently limited to smaller numbers of users.": "%(brand)s est chiffré de bout en bout, mais n’est actuellement utilisable qu’avec un petit nombre d’utilisateurs.", + "Enable %(brand)s as an additional calling option in this room": "Activer %(brand)s comme une option supplémentaire d’appel dans ce salon", + "Join %(brand)s calls": "Rejoindre des appels %(brand)s", + "Start %(brand)s calls": "Démarrer des appels %(brand)s", + "Sorry — this call is currently full": "Désolé — Cet appel est actuellement complet", + "Our new sessions manager provides better visibility of all your sessions, and greater control over them including the ability to remotely toggle push notifications.": "Notre nouveau gestionnaire de sessions fournit une meilleure visibilité sur toutes vos sessions, et un plus grand contrôle sur ces dernières avec la possibilité de désactiver à distance les notifications push.", + "Have greater visibility and control over all your sessions.": "Ayez une meilleur visibilité et plus de contrôle sur toutes vos sessions.", + "New session manager": "Nouveau gestionnaire de sessions", + "Use new session manager": "Utiliser le nouveau gestionnaire de session", + "Wysiwyg composer (plain text mode coming soon) (under active development)": "Compositeur Wysiwyg (le mode texte brut arrive prochainement) (en cours de développement)", + "Sign out all other sessions": "Déconnecter toutes les autres sessions", + "resume voice broadcast": "continuer la diffusion audio", + "pause voice broadcast": "mettre en pause la diffusion audio", + "Underline": "Souligné", + "Italic": "Italique", + "You have already joined this call from another device": "Vous avez déjà rejoint cet appel depuis un autre appareil", + "Try out the rich text editor (plain text mode coming soon)": "Essayer l’éditeur de texte formaté (le mode texte brut arrive bientôt)", + "stop voice broadcast": "arrêter la diffusion audio" } diff --git a/src/i18n/strings/he.json b/src/i18n/strings/he.json index ee7567b4ef..3f83f79593 100644 --- a/src/i18n/strings/he.json +++ b/src/i18n/strings/he.json @@ -2774,5 +2774,6 @@ "Start messages with /plain to send without markdown and /md to send with.": "התחילו הודעות עם /plain לשליחה ללא סימון ו-/md לשליחה.", "Get notified only with mentions and keywords as set up in your settings": "קבלו התראה רק עם אזכורים ומילות מפתח כפי שהוגדרו בהגדרות שלכם", "New keyword": "מילת מפתח חדשה", - "Keyword": "מילת מפתח" + "Keyword": "מילת מפתח", + "Empty room": "חדר ריק" } diff --git a/src/i18n/strings/hu.json b/src/i18n/strings/hu.json index d42f8136f9..de6d822e02 100644 --- a/src/i18n/strings/hu.json +++ b/src/i18n/strings/hu.json @@ -3588,5 +3588,76 @@ "Turn off to disable notifications on all your devices and sessions": "Kikapcsolva az eszközökön és munkamenetekben az értesítések tiltva lesznek", "Enable notifications for this account": "Értesítések engedélyezése ehhez a fiókhoz", "New group call experience": "Új konferenciahívás élmény", - "Live": "Élő" + "Live": "Élő", + "Join %(brand)s calls": "Csatlakozás ebbe a hívásba: %(brand)s", + "Start %(brand)s calls": "%(brand)s hívás indítása", + "Fill screen": "Képernyő kitöltése", + "You have already joined this call from another device": "Már csatlakozott ehhez a híváshoz egy másik eszközön", + "Sorry — this call is currently full": "Bocsánat — ez a hívás betelt", + "Record the client name, version, and url to recognise sessions more easily in session manager": "Kliens neve, verziója és url felvétele a munkamenet könnyebb azonosításához a munkamenet kezelőben", + "Our new sessions manager provides better visibility of all your sessions, and greater control over them including the ability to remotely toggle push notifications.": "Az új munkamenet kezelő jobb rálátást biztosít a munkamenetekre és jobb felügyeletet beleértve, hogy távolról ki-, bekapcsolhatóak a „push” értesítések.", + "Have greater visibility and control over all your sessions.": "Jobb áttekintés és felügyelet a munkamenetek felett.", + "New session manager": "Új munkamenet kezelő", + "Use new session manager": "Új munkamenet kezelő használata", + "Try out the rich text editor (plain text mode coming soon)": "Próbálja ki az új szövegbevitelt (hamarosan érkezik a sima szöveges üzemmód)", + "Video call started": "Videó hívás elindult", + "Unknown room": "Ismeretlen szoba", + "stop voice broadcast": "hang közvetítés beállítása", + "resume voice broadcast": "hang közvetítés folytatása", + "pause voice broadcast": "hang közvetítés szüneteltetése", + "Video call started in %(roomName)s. (not supported by this browser)": "Videó hívás indult itt: %(roomName)s. (ebben a böngészőben ez nem támogatott)", + "Video call started in %(roomName)s.": "Videó hívás indult itt: %(roomName)s.", + "Room info": "Szoba információ", + "Underline": "Aláhúzott", + "Italic": "Dőlt", + "View chat timeline": "Beszélgetés idővonal megjelenítése", + "Close call": "Hívás befejezése", + "Layout type": "Kinézet típusa", + "Spotlight": "Reflektor", + "Freedom": "Szabadság", + "Video call (%(brand)s)": "Videó hívás (%(brand)s)", + "Unknown session type": "Ismeretlen munkamenet típus", + "Web session": "Webes munkamenet", + "Mobile session": "Mobil munkamenet", + "Desktop session": "Asztali munkamenet", + "Operating system": "Operációs rendszer", + "Model": "Modell", + "URL": "URL", + "Version": "Verzió", + "Application": "Alkalmazás", + "Client": "Kliens", + "Sign out all other sessions": "Kijelentkezés minden más munkamenetből", + "Call type": "Hívás típusa", + "You do not have sufficient permissions to change this.": "Nincs megfelelő jogosultság a megváltoztatáshoz.", + "%(brand)s is end-to-end encrypted, but is currently limited to smaller numbers of users.": "%(brand)s végpontok között titkosított de jelenleg csak kevés számú résztvevővel működik.", + "Enable %(brand)s as an additional calling option in this room": "%(brand)s engedélyezése mint további opció hívásokhoz a szobában", + "Notifications silenced": "Értesítések elnémítva", + "Stop live broadcasting?": "Megszakítja az élő közvetítést?", + "Someone else is already recording a voice broadcast. Wait for their voice broadcast to end to start a new one.": "Valaki már elindított egy hang közvetítést. Várja meg a közvetítés végét az új indításához.", + "You don't have the required permissions to start a voice broadcast in this room. Contact a room administrator to upgrade your permissions.": "Nincs jogosultsága hang közvetítést indítani ebben a szobában. Vegye fel a kapcsolatot a szoba adminisztrátorával a szükséges jogosultság megszerzéséhez.", + "You are already recording a voice broadcast. Please end your current voice broadcast to start a new one.": "Egy hang közvetítés már folyamatban van. Először fejezze be a jelenlegi közvetítést egy új indításához.", + "Can't start a new voice broadcast": "Az új hang közvetítés nem indítható el", + "Completing set up of your new device": "Új eszköz beállításának elvégzése", + "Waiting for device to sign in": "Várakozás a másik eszköz bejelentkezésére", + "Connecting...": "Kapcsolás…", + "Select 'Scan QR code'": "Válassza ezt: „QR kód beolvasása”", + "Start at the sign in screen": "Kezdje a bejelentkező képernyőn", + "Scan the QR code below with your device that's signed out.": "A kijelentkezett eszközzel olvasd be a QR kódot alább.", + "By approving access for this device, it will have full access to your account.": "Ennek az eszköznek a hozzáférés engedélyezése után az eszköznek teljes hozzáférése lesz a fiókjához.", + "Check that the code below matches with your other device:": "Ellenőrizze, hogy az alábbi kód megegyezik a másik eszközödön lévővel:", + "Devices connected": "Összekötött eszközök", + "The homeserver doesn't support signing in another device.": "A matrix szerver nem támogatja más eszköz bejelentkeztetését.", + "An unexpected error occurred.": "Nemvárt hiba történt.", + "The request was cancelled.": "A kérés megszakítva.", + "The other device isn't signed in.": "A másik eszköz még nincs bejelentkezve.", + "The other device is already signed in.": "A másik eszköz már bejelentkezett.", + "The request was declined on the other device.": "A kérést elutasították a másik eszközön.", + "Linking with this device is not supported.": "Összekötés ezzel az eszközzel nem támogatott.", + "The scanned code is invalid.": "A beolvasott kód érvénytelen.", + "The linking wasn't completed in the required time.": "Az összekötés az elvárt időn belül nem fejeződött be.", + "Sign in new device": "Új eszköz bejelentkeztetése", + "Show QR code": "QR kód beolvasása", + "Sign in with QR code": "Belépés QR kóddal", + "Browser": "Böngésző", + "Yes, stop broadcast": "Igen, közvetítés megállítása" } diff --git a/src/i18n/strings/id.json b/src/i18n/strings/id.json index 2567745ca7..934cb31071 100644 --- a/src/i18n/strings/id.json +++ b/src/i18n/strings/id.json @@ -30,7 +30,7 @@ "Favourites": "Favorit", "Import": "Impor", "Incorrect verification code": "Kode verifikasi tidak benar", - "Invalid Email Address": "Alamat Email Tidak Valid", + "Invalid Email Address": "Alamat Email Tidak Absah", "Invited": "Diundang", "Sign in with": "Masuk dengan", "Leave room": "Tinggalkan ruangan", @@ -255,7 +255,7 @@ "Unbans user with given ID": "Menhilangkan cekalan pengguna dengan ID yang dicantumkan", "Joins room with given address": "Bergabung ke ruangan dengan alamat yang dicantumkan", "Use an identity server to invite by email. Manage in Settings.": "Gunakan server identitas untuk mengundang melalui email. Kelola di Pengaturan.", - "Use an identity server to invite by email. Click continue to use the default identity server (%(defaultIdentityServerName)s) or manage in Settings.": "Gunakan server identitas untuk mengundang melalui email. Klik lanjutkan untuk menggunakan server identitas default (%(defaultIdentityServerName)s) atau kelola di Pengaturan.", + "Use an identity server to invite by email. Click continue to use the default identity server (%(defaultIdentityServerName)s) or manage in Settings.": "Gunakan server identitas untuk mengundang melalui email. Klik lanjutkan untuk menggunakan server identitas bawaan (%(defaultIdentityServerName)s) atau kelola di Pengaturan.", "Use an identity server": "Gunakan sebuah server identitias", "Invites user with given id to current room": "Mengundang pengguna dengan ID yang dicantumkan ke ruangan saat ini", "Sets the room name": "Mengatur nama ruangan", @@ -563,7 +563,7 @@ "We couldn't log you in": "Kami tidak dapat memasukkan Anda", "Trust": "Percayakan", "Only continue if you trust the owner of the server.": "Hanya lanjutkan jika Anda mempercayai pemilik server ini.", - "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.": "Aksi ini memerlukan mengakses server identitas bawaan untuk memvalidasi sebuah alamat email atau nomor telepon, tetapi server ini tidak memiliki syarat layanan apapun.", + "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.": "Aksi ini memerlukan mengakses server identitas bawaan untuk memvalidasi sebuah alamat email atau nomor telepon, tetapi server ini tidak memiliki syarat layanan apa pun.", "Identity server has no terms of service": "Identitas server ini tidak memiliki syarat layanan", "Unnamed Room": "Ruangan Tanpa Nama", "%(date)s at %(time)s": "%(date)s pada %(time)s", @@ -870,7 +870,7 @@ "Keep going...": "Lanjutkan...", "Email (optional)": "Email (opsional)", "Enable encryption?": "Aktifkan enkripsi?", - "Notify everyone": "Beritahu semua", + "Notify everyone": "Beri tahu semua", "Ban users": "Cekal pengguna", "Change settings": "Ubah pengaturan", "Invite users": "Undang pengguna", @@ -947,7 +947,7 @@ "Reject invitation": "Tolak undangan", "Confirm Removal": "Konfirmasi Penghapusan", "Unknown Address": "Alamat Tidak Dikenal", - "Invalid file%(extra)s": "File tidak valid%(extra)s", + "Invalid file%(extra)s": "File tidak absah%(extra)s", "not specified": "tidak ditentukan", "Start chat": "Mulai obrolan", "Join Room": "Bergabung ke Ruangan", @@ -1064,7 +1064,7 @@ "%(senderName)s updated a ban rule matching %(glob)s for %(reason)s": "%(senderName)s memperbarui sebuah peraturan pencekalan yang berisi %(glob)s untuk %(reason)s", "%(senderName)s updated the rule banning servers matching %(glob)s for %(reason)s": "%(senderName)s memperbarui peraturan pencekalan server yang berisi %(glob)s untuk %(reason)s", "%(senderName)s updated the rule banning rooms matching %(glob)s for %(reason)s": "%(senderName)s memperbarui peraturan pencekalan ruangan yang berisi %(glob)s untuk %(reason)s", - "%(senderName)s updated an invalid ban rule": "%(senderName)s memperbarui sebuah peraturan pencekalan yang tidak valid", + "%(senderName)s updated an invalid ban rule": "%(senderName)s memperbarui sebuah peraturan pencekalan yang tidak absah", "%(senderName)s removed a ban rule matching %(glob)s": "%(senderName)s menghapus sebuah peraturan pencekalan yang berisi %(glob)s", "%(senderName)s removed the rule banning servers matching %(glob)s": "%(senderName)s menghapus peraturan pencekalan server yang berisi %(glob)s", "%(senderName)s removed the rule banning rooms matching %(glob)s": "%(senderName)s menghapus peraturan pencekalan ruangan yang berisi %(glob)s", @@ -1144,7 +1144,7 @@ "Can't leave Server Notices room": "Tidak dapat meninggalkan ruangan Pemberitahuan Server", "Unexpected server error trying to leave the room": "Kesalahan server yang tidak terduga saat mencoba untuk meninggalkan ruangannya", "Authentication check failed: incorrect password?": "Pemeriksaan otentikasi gagal: kata sandi salah?", - "Not a valid %(brand)s keyfile": "Bukan keyfile %(brand)s yang valid", + "Not a valid %(brand)s keyfile": "Bukan keyfile %(brand)s yang absah", "Your browser does not support the required cryptography extensions": "Browser Anda tidak mendukung ekstensi kriptografi yang dibutuhkan", "%(name)s (%(userId)s)": "%(name)s (%(userId)s)", "%(num)s days from now": "%(num)s hari dari sekarang", @@ -1221,11 +1221,11 @@ "Disconnect from the identity server ?": "Putuskan hubungan dari server identitas ?", "Disconnect identity server": "Putuskan hubungan server identitas", "The identity server you have chosen does not have any terms of service.": "Server identitas yang Anda pilih tidak memiliki persyaratan layanan.", - "Terms of service not accepted or the identity server is invalid.": "Persyaratan layanan tidak diterima atau server identitasnya tidak valid.", + "Terms of service not accepted or the identity server is invalid.": "Persyaratan layanan tidak diterima atau server identitasnya tidak absah.", "Disconnect from the identity server and connect to instead?": "Putuskan hubungan dari server identitas dan hubungkan ke ?", "Change identity server": "Ubah server identitas", "Could not connect to identity server": "Tidak dapat menghubung ke server identitas", - "Not a valid identity server (status code %(code)s)": "Bukan server identitas yang valid (kode status %(code)s)", + "Not a valid identity server (status code %(code)s)": "Bukan server identitas yang absah (kode status %(code)s)", "Identity server URL must be HTTPS": "URL server identitas harus HTTPS", "not ready": "belum siap", "Secret storage:": "Penyimpanan rahasia:", @@ -1242,16 +1242,16 @@ "Backup version:": "Versi cadangan:", "This backup is trusted because it has been restored on this session": "Cadangan ini dipercayai karena telah dipulihkan di sesi ini", "Backup is not signed by any of your sessions": "Cadangan tidak ditandatangani oleh sesi-sesi Anda", - "Backup has an invalid signature from unverified session ": "Cadangan mempunyai tanda tangan yang tidak valid dari sesi yang tidak diverifikasi ", - "Backup has an invalid signature from verified session ": "Cadangan mempunyai tanda tangan yang tidak valid dari sesi yang terverifikasi ", - "Backup has a valid signature from unverified session ": "Cadangan mempunyai tanda tangan yang valid dari sesi yang belum diverifikasi ", - "Backup has a valid signature from verified session ": "Cadangan mempunyai tanda tangan yang valid dari sesi yang terverifikasi ", - "Backup has an invalid signature from this session": "Cadangan mempunyai tanda tangan yang tidak valid dari sesi ini", - "Backup has a valid signature from this session": "Cadangan mempunyai tanda tangan yang valid dari sesi ini", + "Backup has an invalid signature from unverified session ": "Cadangan mempunyai tanda tangan yang tidak absah dari sesi yang tidak diverifikasi ", + "Backup has an invalid signature from verified session ": "Cadangan mempunyai tanda tangan yang tidak absah dari sesi yang terverifikasi ", + "Backup has a valid signature from unverified session ": "Cadangan mempunyai tanda tangan yang absah dari sesi yang belum diverifikasi ", + "Backup has a valid signature from verified session ": "Cadangan mempunyai tanda tangan yang absah dari sesi yang terverifikasi ", + "Backup has an invalid signature from this session": "Cadangan mempunyai tanda tangan yang tidak absah dari sesi ini", + "Backup has a valid signature from this session": "Cadangan mempunyai tanda tangan yang absah dari sesi ini", "Backup has a signature from unknown session with ID %(deviceId)s": "Cadangan mempunyai tanda tangan dari sesi tidak dikenal dengan ID %(deviceId)s", "Backup has a signature from unknown user with ID %(deviceId)s": "Cadangan mempunyai tanda tangan dari seseorang tidak dikenal dengan ID %(deviceId)s", - "Backup has a invalid signature from this user": "Cadangan mempunyai tanda tangan yang tidak valid dari pengguna ini", - "Backup has a valid signature from this user": "Cadangan mempunyai tanda tangan yang valid dari pengguna ini", + "Backup has a invalid signature from this user": "Cadangan mempunyai tanda tangan yang tidak absah dari pengguna ini", + "Backup has a valid signature from this user": "Cadangan mempunyai tanda tangan yang absah dari pengguna ini", "All keys backed up": "Semua kunci telah dicadangkan", "Backing up %(sessionsRemaining)s keys...": "Mencadangkan %(sessionsRemaining)s kunci...", "Connect this session to Key Backup": "Hubungkan sesi ini ke Pencadangan Kunci", @@ -1284,7 +1284,7 @@ "Loading new room": "Memuat ruangan baru", "Upgrading room": "Meningkatkan ruangan", "This upgrade will allow members of selected spaces access to this room without an invite.": "Peningkatan ini akan mengizinkan anggota di space yang terpilih untuk dapat mengakses ruangan ini tanpa sebuah undangan.", - "This room is in some spaces you're not an admin of. In those spaces, the old room will still be shown, but people will be prompted to join the new one.": "Ruangan ini masih ada di dalam space yang Anda bukan admin di sana. Di space-space itu, ruangan yang lama masih terlihat, tetapi orang-orang akan diberitahu untuk bergabung ke ruangan yang baru.", + "This room is in some spaces you're not an admin of. In those spaces, the old room will still be shown, but people will be prompted to join the new one.": "Ruangan ini masih ada di dalam space yang Anda bukan admin di sana. Di space itu, ruangan yang lama masih terlihat, tetapi orang akan diberi tahu untuk bergabung ke ruangan yang baru.", "Space members": "Anggota space", "Anyone in a space can find and join. You can select multiple spaces.": "Siapa saja di sebuah space dapat menemukan dan bergabung. Anda dapat memilih beberapa space.", "Anyone in can find and join. You can select other spaces too.": "Siapa saja di dapat menemukan dan bergabung. Anda juga dapat memilih space yang lain.", @@ -1332,7 +1332,7 @@ "Your homeserver does not support device management.": "Homeserver Anda tidak mendukung pengelolaan perangkat.", "Session key:": "Kunci sesi:", "Session ID:": "ID Sesi:", - "Import E2E room keys": "Impor kunci enkripsi ujung-ke-ujung", + "Import E2E room keys": "Impor kunci enkripsi ujung ke ujung", "Homeserver feature support:": "Dukungan fitur homeserver:", "Self signing private key:": "Kunci privat penandatanganan diri:", "User signing private key:": "Kunci rahasia penandatanganan pengguna:", @@ -1352,7 +1352,7 @@ "Your homeserver does not support cross-signing.": "Homeserver Anda tidak mendukung penandatanganan silang.", "Passwords don't match": "Kata sandi tidak cocok", "Do you want to set an email address?": "Apakah Anda ingin menetapkan sebuah alamat email?", - "Export E2E room keys": "Ekspor kunci ruangan enkripsi ujung-ke-ujung", + "Export E2E room keys": "Ekspor kunci ruangan enkripsi ujung ke ujung", "No display name": "Tidak ada nama tampilan", "Failed to upload profile picture!": "Gagal untuk mengunggah foto profil!", "Channel: ": "Saluran: ", @@ -1403,7 +1403,7 @@ "Accept to continue:": "Terima untuk melanjutkan:", "Decline (%(counter)s)": "Tolak (%(counter)s)", "Your server isn't responding to some requests.": "Server Anda tidak menanggapi beberapa permintaan.", - "Prompt before sending invites to potentially invalid matrix IDs": "Tanyakan sebelum mengirim undangan ke ID Matrix yang mungkin tidak valid", + "Prompt before sending invites to potentially invalid matrix IDs": "Tanyakan sebelum mengirim undangan ke ID Matrix yang mungkin tidak absah", "Update %(brand)s": "Perbarui %(brand)s", "This is the start of export of . Exported by at %(exportDate)s.": "Ini adalah awalan dari ekspor . Diekspor oleh di %(exportDate)s.", "Waiting for %(displayName)s to verify…": "Menunggu %(displayName)s untuk memverifikasi…", @@ -1413,7 +1413,7 @@ "Unable to find a supported verification method.": "Tidak dapat menemukan metode verifikasi yang didukung.", "Verify this user by confirming the following number appears on their screen.": "Verifikasi pengguna ini dengan mengkonfirmasi nomor berikut yang ditampilkan.", "Verify this user by confirming the following emoji appear on their screen.": "Verifikasi pengguna ini dengan mengkonfirmasi emoji berikut yang ditampilkan.", - "Secure messages with this user are end-to-end encrypted and not able to be read by third parties.": "Pesan dengan pengguna ini terenkripsi secara ujung-ke-ujung dan tidak dapat dibaca oleh pihak ketiga.", + "Secure messages with this user are end-to-end encrypted and not able to be read by third parties.": "Pesan dengan pengguna ini terenkripsi secara ujung ke ujung dan tidak dapat dibaca oleh pihak ketiga.", "You've successfully verified this user.": "Anda berhasil memverifikasi pengguna ini.", "The other party cancelled the verification.": "Pengguna yang lain membatalkan proses verifikasi ini.", "%(name)s on hold": "%(name)s ditahan", @@ -1470,9 +1470,9 @@ "Show rooms with unread notifications first": "Tampilkan ruangan dengan notifikasi yang belum dibaca dulu", "Order rooms by name": "Urutkan ruangan oleh nama", "Enable widget screenshots on supported widgets": "Aktifkan tangkapan layar widget di widget yang didukung", - "Enable URL previews by default for participants in this room": "Aktifkan tampilan URL secara default untuk anggota di ruangan ini", - "Enable URL previews for this room (only affects you)": "Aktifkan tampilan URL secara default (hanya mempengaruhi Anda)", - "Enable inline URL previews by default": "Aktifkan tampilan URL secara default", + "Enable URL previews by default for participants in this room": "Aktifkan tampilan URL secara bawaan untuk anggota di ruangan ini", + "Enable URL previews for this room (only affects you)": "Aktifkan tampilan URL secara bawaan (hanya memengaruhi Anda)", + "Enable inline URL previews by default": "Aktifkan tampilan URL secara bawaan", "Never send encrypted messages to unverified sessions in this room from this session": "Jangan kirim pesan terenkripsi ke sesi yang belum diverifikasi di ruangan ini dari sesi ini", "Never send encrypted messages to unverified sessions from this session": "Jangan kirim pesan terenkripsi ke sesi yang belum diverifikasi dari sesi ini", "Send analytics data": "Kirim data analitik", @@ -1493,7 +1493,7 @@ "Show avatars in user and room mentions": "Tampilkan avatar di sebutan pengguna dan ruangan", "Jump to the bottom of the timeline when you send a message": "Pergi ke bawah linimasa ketika Anda mengirim pesan", "Show line numbers in code blocks": "Tampilkan nomor barisan di blok kode", - "Expand code blocks by default": "Buka blok kode secara default", + "Expand code blocks by default": "Buka blok kode secara bawaan", "Enable automatic language detection for syntax highlighting": "Aktifkan deteksi bahasa otomatis untuk penyorotan sintaks", "Autoplay videos": "Mainkan video secara otomatis", "Autoplay GIFs": "Mainkan GIF secara otomatis", @@ -1580,7 +1580,7 @@ "Use high contrast": "Gunakan kontras tinggi", "Theme added!": "Tema ditambahkan!", "Error downloading theme information.": "Terjadi kesalahan saat mengunduh informasi tema.", - "Invalid theme schema.": "Skema tema tidak valid.", + "Invalid theme schema.": "Skema tema tidak absah.", "Integration managers receive configuration data, and can modify widgets, send room invites, and set power levels on your behalf.": "Manajer integrasi menerima data pengaturan, dan dapat mengubah widget, mengirimkan undangan ruangan, dan mengatur tingkat daya dengan sepengetahuan Anda.", "Manage integrations": "Kelola integrasi", "Use an integration manager to manage bots, widgets, and sticker packs.": "Gunakan sebuah manajer integrasi untuk mengelola bot, widget, dan paket stiker.", @@ -1609,7 +1609,7 @@ "Sidebar": "Bilah Samping", "Manage your signed-in devices below. A device's name is visible to people you communicate with.": "Kelola sesi Anda di bawah. Sebuah nama sesi dapat dilihat oleh siapa saja yang Anda berkomunikasi.", "Where you're signed in": "Di mana Anda masuk", - "Your server admin has disabled end-to-end encryption by default in private rooms & Direct Messages.": "Admin server Anda telah menonaktifkan enkripsi ujung-ke-ujung secara default di ruangan privat & Pesan Langsung.", + "Your server admin has disabled end-to-end encryption by default in private rooms & Direct Messages.": "Admin server Anda telah menonaktifkan enkripsi ujung ke ujung secara bawaan di ruangan privat & Pesan Langsung.", "Message search": "Pencarian pesan", "Secure Backup": "Cadangan Aman", "Reject all %(invitedRooms)s invites": "Tolak semua %(invitedRooms)s undangan", @@ -1624,7 +1624,7 @@ "Keyboard shortcuts": "Pintasan keyboard", "Show tray icon and minimise window to it on close": "Tampilkan ikon baki dan minimalkan window ke ikonnya jika ditutup", "Always show the window menu bar": "Selalu tampilkan bilah menu window", - "Warn before quitting": "Beritahu sebelum keluar", + "Warn before quitting": "Beri tahu sebelum keluar", "Start automatically after system login": "Mulai setelah login sistem secara otomatis", "Room ID or address of ban list": "ID ruangan atau alamat daftar larangan", "If this isn't what you want, please use a different tool to ignore users.": "Jika itu bukan yang Anda ingin, mohon pakai alat yang lain untuk mengabaikan pengguna.", @@ -1639,9 +1639,9 @@ "⚠ These settings are meant for advanced users.": "⚠ Pengaturan ini hanya untuk pengguna berkelanjutan saja.", "You are currently subscribed to:": "Anda saat ini berlangganan:", "View rules": "Tampilkan aturan", - "You are not subscribed to any lists": "Anda belum berlangganan daftar apapun", + "You are not subscribed to any lists": "Anda belum berlangganan daftar apa pun", "You are currently ignoring:": "Anda saat ini mengabaikan:", - "You have not ignored anyone.": "Anda belum mengabaikan siapapun.", + "You have not ignored anyone.": "Anda belum mengabaikan siapa pun.", "User rules": "Aturan pengguna", "Server rules": "Aturan server", "Ban list rules - %(roomName)s": "Daftar aturan cekalan — %(roomName)s", @@ -1697,17 +1697,17 @@ "For extra security, verify this user by checking a one-time code on both of your devices.": "Untuk keamanan lebih, verifikasi pengguna ini dengan memeriksa kode satu kali di kedua perangkat Anda.", "Verify User": "Verifikasi Pengguna", "In encrypted rooms, your messages are secured and only you and the recipient have the unique keys to unlock them.": "Di ruangan terenkripsi, pesan Anda diamankan dan hanya Anda dan penerimanya mempunyai kunci yang unik untuk mengaksesnya.", - "Messages in this room are not end-to-end encrypted.": "Pesan di ruangan ini tidak dienkripsi secara ujung-ke-ujung.", + "Messages in this room are not end-to-end encrypted.": "Pesan di ruangan ini tidak dienkripsi secara ujung ke ujung.", "Your messages are secured and only you and the recipient have the unique keys to unlock them.": "Pesan Anda diamankan dan hanya Anda dan penerimanya mempunyai kunci yang unik untuk mengaksesnya.", - "Messages in this room are end-to-end encrypted.": "Pesan di ruangan ini terenkripsi secara ujung-ke-ujung.", + "Messages in this room are end-to-end encrypted.": "Pesan di ruangan ini terenkripsi secara ujung ke ujung.", "Start Verification": "Mulai Verifikasi", "Waiting for %(displayName)s to accept…": "Menunggu untuk %(displayName)s untuk menerima…", "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.": "Ketika seseorang menambahkan URL di pesannya, sebuah tampilan URL dapat ditampilkan untuk memberikan informasi lainnya tentang tautan itu seperti judul, deskripsi, dan sebuah gambar dari website.", - "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.": "Di ruangan terenkripsi, seperti ruangan ini, tampilan URL dinonaktifkan untuk memastikan homeserver Anda (di mana tampilannya dibuat) tidak mendapatkan informasi tentang tautan yang Anda lihat di ruangan ini.", - "URL previews are disabled by default for participants in this room.": "Tampilan URL dinonaktifkan secara default untuk anggota di ruangan ini.", - "URL previews are enabled by default for participants in this room.": "Tampilan URL diaktifkan secara default untuk anggota di ruangan ini.", - "You have disabled URL previews by default.": "Anda telah menonaktifkan tampilan URL secara default.", - "You have enabled URL previews by default.": "Anda telah mengaktifkan tampilan URL secara default.", + "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.": "Di ruangan terenkripsi, seperti ruangan ini, tampilan URL dinonaktifkan secara bawaan untuk memastikan homeserver Anda (di mana tampilannya dibuat) tidak mendapatkan informasi tentang tautan yang Anda lihat di ruangan ini.", + "URL previews are disabled by default for participants in this room.": "Tampilan URL dinonaktifkan secara bawaan untuk anggota di ruangan ini.", + "URL previews are enabled by default for participants in this room.": "Tampilan URL diaktifkan secara bawaan untuk anggota di ruangan ini.", + "You have disabled URL previews by default.": "Anda telah menonaktifkan tampilan URL secara bawaan.", + "You have enabled URL previews by default.": "Anda telah mengaktifkan tampilan URL secara bawaan.", "Publish this room to the public in %(domain)s's room directory?": "Publikasi ruangan ini ke publik di direktori ruangan %(domain)s?", "Show more": "Tampilkan lebih banyak", "Set addresses for this room so users can find this room through your homeserver (%(localDomain)s)": "Tetapkan alamat untuk ruangan ini supaya pengguna dapat menemukan space ini melalui homeserver Anda (%(localDomain)s)", @@ -1756,7 +1756,7 @@ "Forget Room": "Lupakan Ruangan", "Notification options": "Opsi notifikasi", "Mentions & Keywords": "Sebutan & Kata Kunci", - "Use default": "Gunakan default", + "Use default": "Gunakan bawaan", "Show less": "Tampilkan lebih sedikit", "Show %(count)s more|one": "Tampilkan %(count)s lagi", "Show %(count)s more|other": "Tampilkan %(count)s lagi", @@ -1813,7 +1813,7 @@ "Online for %(duration)s": "Daring selama %(duration)s", "View message": "Tampilkan pesan", "Message didn't send. Click for info.": "Pesan tidak terkirim. Klik untuk informasi.", - "End-to-end encryption isn't enabled": "Enkripsi ujung-ke-ujung belum diaktifkan", + "End-to-end encryption isn't enabled": "Enkripsi ujung ke ujung tidak diaktifkan", "Enable encryption in settings.": "Aktifkan enkripsi di pengaturan.", "Your private messages are normally encrypted, but this room isn't. Usually this is due to an unsupported device or method being used, like email invites.": "Pesan privat Anda biasanya dienkripsi, tetapi di ruangan ini tidak terenkripsi. Biasanya ini disebabkan oleh perangkat yang tidak mendukung atau metode yang sedang digunakan, seperti undangan email.", "This is the start of .": "Ini adalah awal dari .", @@ -1877,14 +1877,14 @@ "Unknown Command": "Perintah Tidak Diketahui", "Server unavailable, overloaded, or something else went wrong.": "Server tidak tersedia, terlalu penuh, atau ada sesuatu yang salah.", "Everyone in this room is verified": "Semuanya di ruangan ini telah terverifikasi", - "This room is end-to-end encrypted": "Ruangan ini dienkripsi secara ujung-ke-ujung", + "This room is end-to-end encrypted": "Ruangan ini dienkripsi secara ujung ke ujung", "Someone is using an unknown session": "Seseorang menggunakan sesi yang tidak dikenal", "You have verified this user. This user has verified all of their sessions.": "Anda telah memverifikasi pengguna ini. Pengguna ini telah memverifikasi semua sesinya.", "You have not verified this user.": "Anda belum memverifikasi pengguna ini.", "This user has not verified all of their sessions.": "Pengguna ini belum memverifikasi semua sesinya.", "A text message has been sent to +%(msisdn)s. Please enter the verification code it contains.": "Sebuah teks pesan telah dikirim ke +%(msisdn)s. Silakan masukkan kode verifikasinya.", "We've sent you an email to verify your address. Please follow the instructions there and then click the button below.": "Kami telah mengirim sebuah email untuk memverifikasi alamat Anda. Silakan ikuti instruksinya dan klik tombol di bawah.", - "This doesn't appear to be a valid email address": "Ini sepertinya bukan alamat email yang valid", + "This doesn't appear to be a valid email address": "Ini sepertinya bukan alamat email yang absah", "Unable to remove contact information": "Tidak dapat menghapus informasi kontak", "Discovery options will appear once you have added a phone number above.": "Opsi penemuan akan tersedia setelah Anda telah menambahkan sebuah nomor telepon di atas.", "Discovery options will appear once you have added an email above.": "Opsi penemuan akan tersedia setelah Anda telah menambahkan sebuah email di atas.", @@ -2002,8 +2002,8 @@ "Encryption not enabled": "Enkripsi tidak diaktifkan", "Ignored attempt to disable encryption": "Mengabaikan percobaan untuk menonaktifkan enkripsi", "Encryption enabled": "Enkripsi diaktifkan", - "Messages in this room are end-to-end encrypted. When people join, you can verify them in their profile, just tap on their avatar.": "Pesan di ruangan ini terenkripsi secara ujung-ke-ujung. Ketika orang-orang bergabung, Anda dapat memverifikasi mereka di profil mereka — klik pada avatar mereka.", - "Messages here are end-to-end encrypted. Verify %(displayName)s in their profile - tap on their avatar.": "Pesan di pesan langsung ini terenkripsi secara ujung-ke-ujung. Verifikasi %(displayName)s di profilnya — klik pada avatarnya.", + "Messages in this room are end-to-end encrypted. When people join, you can verify them in their profile, just tap on their avatar.": "Pesan di ruangan ini terenkripsi secara ujung ke ujung. Ketika orang-orang bergabung, Anda dapat memverifikasi mereka di profil mereka — klik pada avatar mereka.", + "Messages here are end-to-end encrypted. Verify %(displayName)s in their profile - tap on their avatar.": "Pesan di pesan langsung ini terenkripsi secara ujung ke ujung. Verifikasi %(displayName)s di profilnya — klik pada avatarnya.", "Some encryption parameters have been changed.": "Beberapa parameter enkripsi telah diubah.", "The call is in an unknown state!": "Panggilan ini berada di status yang tidak diketahui!", "Missed call": "Panggilan terlewat", @@ -2037,7 +2037,7 @@ "Compare unique emoji": "Bandingkan emoji unik", "Scan this unique code": "Pindai kode unik ini", "Edit devices": "Edit perangkat", - "This client does not support end-to-end encryption.": "Klien ini tidak mendukung enkripsi ujung-ke-ujung.", + "This client does not support end-to-end encryption.": "Klien ini tidak mendukung enkripsi ujung ke ujung.", "Role in ": "Peran di ", "Failed to deactivate user": "Gagal untuk menonaktifkan pengguna", "Deactivate user": "Nonaktifkan pengguna", @@ -2097,14 +2097,14 @@ "Setting ID": "ID Pengaturan", "There was an error finding this widget.": "Terjadi sebuah kesalahan menemukan widget ini.", "Active Widgets": "Widget Aktif", - "Server did not return valid authentication information.": "Server tidak memberikan informasi otentikasi yang valid.", + "Server did not return valid authentication information.": "Server tidak memberikan informasi otentikasi yang absah.", "Server did not require any authentication": "Server tidak membutuhkan otentikasi apa pun", "There was a problem communicating with the server. Please try again.": "Terjadi sebuah masalah ketika berkomunikasi dengan server. Mohon coba lagi.", "Confirm account deactivation": "Konfirmasi penonaktifan akun", "Confirm your account deactivation by using Single Sign On to prove your identity.": "Konfirmasi penonaktifan akun Anda dengan menggunakan Single Sign On untuk membuktikan identitas Anda.", "Are you sure you want to deactivate your account? This is irreversible.": "Apakah Anda yakin ingin menonaktifkan akun Anda? Ini tidak dapat dibatalkan.", "Continue With Encryption Disabled": "Lanjutkan Dengan Enkripsi Dinonaktifkan", - "You've previously used a newer version of %(brand)s with this session. To use this version again with end to end encryption, you will need to sign out and back in again.": "Anda sebelumnya menggunakan sebuah versi %(brand)s yang baru dengan sesi ini. Untuk menggunakan versi ini lagi dengan enkripsi ujung-ke-ujung, Anda harus keluar dan masuk lagi.", + "You've previously used a newer version of %(brand)s with this session. To use this version again with end to end encryption, you will need to sign out and back in again.": "Anda sebelumnya menggunakan sebuah versi %(brand)s yang baru dengan sesi ini. Untuk menggunakan versi ini lagi dengan enkripsi ujung ke ujung, Anda harus keluar dan masuk lagi.", "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": "Untuk menghindari kehilangan riwayat obrolan, Anda harus mengekspor kunci ruangan Anda sebelum keluar. Anda harus kembali ke versi %(brand)s yang baru untuk melakukannya", "Want to add an existing space instead?": "Ingin menambahkan sebuah space yang sudah ada saja?", "Add a space to a space you manage.": "Tambahkan sebuah space ke space yang Anda kelola.", @@ -2125,7 +2125,7 @@ "Create a room": "Buat sebuah ruangan", "You might disable this if the room will be used for collaborating with external teams who have their own homeserver. This cannot be changed later.": "Anda mungkin menonaktifkannya jika ruangan ini akan digunakan untuk berkolabroasi dengan tim eksternal yang mempunyai homeserver sendiri. Ini tidak dapat diubah nanti.", "You might enable this if the room will only be used for collaborating with internal teams on your homeserver. This cannot be changed later.": "Anda mungkin aktifkan jika ruangan ini hanya digunakan untuk berkolabroasi dengan tim internal di homeserver Anda. Ini tidak dapat diubah nanti.", - "Enable end-to-end encryption": "Aktifkan enkripsi ujung-ke-ujung", + "Enable end-to-end encryption": "Aktifkan enkripsi ujung ke ujung", "Your server requires encryption to be enabled in private rooms.": "Server Anda memerlukan mengaktifkan enkripsi di ruangan privat.", "You can't disable this later. Bridges & most bots won't work yet.": "Anda tidak dapat menonaktifkannya nanti. Jembatan & kebanyakan bot belum dapat digunakan.", "Anyone will be able to find and join this room.": "Siapa saja dapat menemukan dan bergabung ke ruangan ini.", @@ -2147,7 +2147,7 @@ "Preparing to download logs": "Mempersiapkan untuk mengunduh catatan", "Failed to send logs: ": "Gagal untuk mengirimkan catatan: ", "Preparing to send logs": "Mempersiapkan untuk mengirimkan catatan", - "Please tell us what went wrong or, better, create a GitHub issue that describes the problem.": "Mohon beritahu kami apa saja yang salah atau, lebih baik, buat sebuah issue GitHub yang menjelaskan masalahnya.", + "Please tell us what went wrong or, better, create a GitHub issue that describes the problem.": "Mohon beri tahu kami apa saja yang salah atau, lebih baik, buat sebuah issue GitHub yang menjelaskan masalahnya.", "To leave the beta, visit your settings.": "Untuk keluar dari beta, pergi ke pengaturan Anda.", "Close dialog": "Tutup dialog", "Invite anyway and never warn me again": "Undang saja dan jangan peringatkan saya lagi", @@ -2283,7 +2283,7 @@ "Sign into your homeserver": "Masuk ke homeserver Anda", "Matrix.org is the biggest public homeserver in the world, so it's a good place for many.": "Matrix.org adalah homeserver publik terbesar di dunia, jadi itu adalah tempat yang bagus untuk banyak orang.", "Specify a homeserver": "Tentukan sebuah homeserver", - "Invalid URL": "URL tidak valid", + "Invalid URL": "URL tidak absah", "Unable to validate homeserver": "Tidak dapat memvalidasi homeserver", "Recent changes that have not yet been received": "Perubahan terbaru yang belum diterima", "The server is not configured to indicate what the problem is (CORS).": "Server tidak diatur untuk menandakan apa masalahnya (CORS).", @@ -2305,7 +2305,7 @@ "Upgrade private room": "Tingkatkan ruangan privat", "Automatically invite members from this room to the new one": "Mengundang pengguna dari ruangan ini ke yang baru secara otomatis", "Put a link back to the old room at the start of the new room so people can see old messages": "Letakkan sebuah tautan kembali ke ruangan yang lama di awal ruangan baru supaya orang-orang dapat melihat pesan-pesan lama", - "Stop users from speaking in the old version of the room, and post a message advising users to move to the new room": "Menghentikan pengguna dari berbicara di versi ruangan yang lama, dan mengirimkan sebuah pesan memberitahu pengguna untuk pindah ke ruangan yang baru", + "Stop users from speaking in the old version of the room, and post a message advising users to move to the new room": "Menghentikan pengguna dari berbicara di versi ruangan yang lama, dan mengirimkan sebuah pesan memberi tahu pengguna untuk pindah ke ruangan yang baru", "Update any local room aliases to point to the new room": "Memperbarui alias ruangan lokal apa saja untuk diarahkan ke ruangan yang baru", "Create a new room with the same name, description and avatar": "Membuat ruangan baru dengan nama, deskripsi, dan avatar yang sama", "Upgrading this room requires closing down the current instance of the room and creating a new room in its place. To give room members the best possible experience, we will:": "Meningkatkan ruangan ini membutuhkan penutupan instansi ruangan saat ini dan membuat ruangan yang baru di tempatnya. Untuk memberikan anggota ruangan pengalaman yang baik, kami akan:", @@ -2386,13 +2386,13 @@ "This account has been deactivated.": "Akun ini telah dinonaktifkan.", "Please contact your service administrator to continue using this service.": "Mohon hubungi administrator layanan Anda untuk melanjutkan menggunakan layanannya.", "This homeserver does not support login using email address.": "Homeserver ini tidak mendukung login menggunakan alamat email.", - "Identity server URL does not appear to be a valid identity server": "URL server identitas terlihat bukan sebagai server identitas yang valid", - "Invalid base_url for m.identity_server": "base_url tidak valid untuk m.identity_server", - "Invalid identity server discovery response": "Respons penemuan server identitas tidak valid", - "Homeserver URL does not appear to be a valid Matrix homeserver": "URL homeserver sepertinya bukan sebagai homeserver Matrix yang valid", - "Invalid base_url for m.homeserver": "base_url tidak valid untuk m.homeserver", + "Identity server URL does not appear to be a valid identity server": "URL server identitas terlihat bukan sebagai server identitas yang absah", + "Invalid base_url for m.identity_server": "base_url tidak absah untuk m.identity_server", + "Invalid identity server discovery response": "Respons penemuan server identitas tidak absah", + "Homeserver URL does not appear to be a valid Matrix homeserver": "URL homeserver sepertinya bukan sebagai homeserver Matrix yang absah", + "Invalid base_url for m.homeserver": "base_url tidak absah untuk m.homeserver", "Failed to get autodiscovery configuration from server": "Gagal untuk mendapatkan konfigurasi penemuan otomatis dari server", - "Invalid homeserver discovery response": "Respons penemuan homeserver tidak valid", + "Invalid homeserver discovery response": "Respons penemuan homeserver tidak absah", "Set a new password": "Tetapkan kata sandi baru", "Your password has been reset.": "Kata sandi Anda telah diatur ulang.", "I have verified my email address": "Saya telah memverifikasi alamat email saya", @@ -2400,7 +2400,7 @@ "Sign in instead": "Masuk saja", "A verification email will be sent to your inbox to confirm setting your new password.": "Sebuah email verifikasi akan dikirim ke kotak masuk Anda untuk mengkonfirmasi mengatur kata sandi Anda yang baru.", "New passwords must match each other.": "Kata sandi baru harus cocok.", - "The email address doesn't appear to be valid.": "Alamat email ini tidak terlihat valid.", + "The email address doesn't appear to be valid.": "Alamat email ini tidak terlihat absah.", "The email address linked to your account must be entered.": "Alamat email yang tertaut ke akun Anda harus dimasukkan.", "Skip verification for now": "Lewatkan verifikasi untuk sementara", "Really reset verification keys?": "Benar-benar ingin mengatur ulang kunci-kunci verifikasi?", @@ -2446,7 +2446,7 @@ "Go to my first room": "Pergi ke ruangan pertama saya", "Share %(name)s": "Bagikan %(name)s", "Search for rooms or spaces": "Cari untuk ruangan atau space", - "Pick rooms or conversations to add. This is just a space for you, no one will be informed. You can add more later.": "Pilih ruangan atau percakapan untuk ditambahkan. Ini adalah hanya sebuah space untuk Anda, tidak ada siapa saja yang diberitahu. Anda dapat menambahkan lagi nanti.", + "Pick rooms or conversations to add. This is just a space for you, no one will be informed. You can add more later.": "Pilih ruangan atau percakapan untuk ditambahkan. Ini adalah hanya sebuah space untuk Anda, tidak ada siapa pun yang diberi tahu. Anda dapat menambahkan lagi nanti.", "What do you want to organise?": "Apa saja yang Anda ingin organisirkan?", "Creating rooms...": "Membuat ruangan...", "Skip for now": "Lewat untuk sementara", @@ -2497,7 +2497,7 @@ "%(creator)s created and configured the room.": "%(creator)s membuat dan mengatur ruangan ini.", "%(creator)s created this DM.": "%(creator)s membuat pesan langsung ini.", "Verification requested": "Verifikasi diminta", - "Data from an older version of %(brand)s has been detected. This will have caused end-to-end cryptography to malfunction in the older version. End-to-end encrypted messages exchanged recently whilst using the older version may not be decryptable in this version. This may also cause messages exchanged with this version to fail. If you experience problems, log out and back in again. To retain message history, export and re-import your keys.": "Data dari %(brand)s versi lama telah terdeteksi. Ini akan menyebabkan kriptografi ujung-ke-ujung tidak berfungsi di versi yang lebih lama. Pesan terenkripsi secara ujung-ke-ujung yang dipertukarkan baru-baru ini saat menggunakan versi yang lebih lama mungkin tidak dapat didekripsi dalam versi ini. Ini juga dapat menyebabkan pesan yang dipertukarkan dengan versi ini gagal. Jika Anda mengalami masalah, keluar dan masuk kembali. Untuk menyimpan riwayat pesan, ekspor dan impor ulang kunci Anda.", + "Data from an older version of %(brand)s has been detected. This will have caused end-to-end cryptography to malfunction in the older version. End-to-end encrypted messages exchanged recently whilst using the older version may not be decryptable in this version. This may also cause messages exchanged with this version to fail. If you experience problems, log out and back in again. To retain message history, export and re-import your keys.": "Data dari %(brand)s versi lama telah terdeteksi. Ini akan menyebabkan kriptografi ujung ke ujung tidak berfungsi di versi yang lebih lama. Pesan terenkripsi secara ujung ke ujung yang dipertukarkan baru-baru ini saat menggunakan versi yang lebih lama mungkin tidak dapat didekripsi dalam versi ini. Ini juga dapat menyebabkan pesan yang dipertukarkan dengan versi ini gagal. Jika Anda mengalami masalah, keluar dan masuk kembali. Untuk menyimpan riwayat pesan, ekspor dan impor ulang kunci Anda.", "Old cryptography data detected": "Data kriptografi lama terdeteksi", "Review terms and conditions": "Lihat syarat dan ketentuan", "To continue using the %(homeserverDomain)s homeserver you must review and agree to our terms and conditions.": "Untuk melanjutkan menggunakan homeserver %(homeserverDomain)s Anda harus lihat dan terima ke syarat dan ketentuan kami.", @@ -2549,7 +2549,7 @@ "Please review and accept all of the homeserver's policies": "Mohon lihat dan terima semua kebijakan homeserver ini", "Missing captcha public key in homeserver configuration. Please report this to your homeserver administrator.": "Tidak ada kunci publik captcha di konfigurasi homeserver. Mohon melaporkannya ke administrator homeserver Anda.", "Confirm your identity by entering your account password below.": "Konfirmasi identitas Anda dengan memasukkan kata sandi akun Anda di bawah.", - "Doesn't look like a valid email address": "Kelihatannya bukan sebuah alamat email yang valid", + "Doesn't look like a valid email address": "Kelihatannya bukan sebuah alamat email yang absah", "Enter email address": "Masukkan alamat email", "Country Dropdown": "Dropdown Negara", "This homeserver would like to make sure you are not a robot.": "Homeserver ini memastikan Anda bahwa Anda bukan sebuah robot.", @@ -2581,8 +2581,8 @@ "If you've forgotten your Security Key you can ": "Jika Anda lupa Kunci Keamanan, Anda dapat ", "Access your secure message history and set up secure messaging by entering your Security Key.": "Akses riwayat pesan aman Anda dan siapkan perpesanan aman dengan memasukkan Kunci Keamanan Anda.", "Warning: You should only set up key backup from a trusted computer.": "Peringatan: Anda seharusnya menyiapkan cadangan kunci di komputer yang dipercayai.", - "Not a valid Security Key": "Bukan Kunci Keamanan yang valid", - "This looks like a valid Security Key!": "Ini sepertinya Kunci Keamanan yang valid!", + "Not a valid Security Key": "Bukan Kunci Keamanan yang absah", + "This looks like a valid Security Key!": "Ini sepertinya Kunci Keamanan yang absah!", "Enter Security Key": "Masukkan Kunci Keamanan", "If you've forgotten your Security Phrase you can use your Security Key or set up new recovery options": "Jika Anda lupa Frasa Keamanan, Anda dapat menggunakan Kunci Keamanan Anda atau siapkan opsi pemulihan baru", "Access your secure message history and set up secure messaging by entering your Security Phrase.": "Akses riwayat pesan aman Anda dan siapkan perpesanan aman dengan memasukkan Frasa Keamanan Anda.", @@ -2616,7 +2616,7 @@ "Only do this if you have no other device to complete verification with.": "Hanya lakukan ini jika Anda tidak memiliki perangkat yang lain untuk menyelesaikan verifikasi.", "Reset everything": "Atur ulang semuanya", "Forgotten or lost all recovery methods? Reset all": "Lupa atau kehilangan semua metode pemulihan? Atur ulang semuanya", - "Invalid Security Key": "Kunci Keamanan tidak valid", + "Invalid Security Key": "Kunci Keamanan tidak absah", "Wrong Security Key": "Kunci Keamanan salah", "Looks good!": "Kelihatannya bagus!", "Wrong file type": "Tipe file salah", @@ -2669,7 +2669,7 @@ "Space Autocomplete": "Penyelesaian Space Otomatis", "Room Autocomplete": "Penyelesaian Ruangan Otomatis", "Notification Autocomplete": "Penyelesaian Notifikasi Otomatis", - "Notify the whole room": "Beritahu seluruh ruangan", + "Notify the whole room": "Beri tahu seluruh ruangan", "Command Autocomplete": "Penyelesaian Perintah Otomatis", "Warning: Your personal data (including encryption keys) is still stored in this session. Clear it if you're finished using this session, or want to sign in to another account.": "Peringatan: Data personal Anda (termasuk kunci enkripsi) masih disimpan di sesi ini. Hapus jika Anda selesai menggunakan sesi ini, atau jika ingin masuk ke akun yang lain.", "Clear personal data": "Hapus data personal", @@ -2753,7 +2753,7 @@ "Search spaces": "Cari space", "Decide which spaces can access this room. If a space is selected, its members can find and join .": "Tentukan space mana yang dapat mengakses ruangan ini. Jika sebuah space dipilih, anggotanya dapat menemukan dan bergabung .", "Select spaces": "Pilih space", - "You're removing all spaces. Access will default to invite only": "Anda menghilangkan semua space. Akses secara default ke undangan saja", + "You're removing all spaces. Access will default to invite only": "Anda menghilangkan semua space. Akses secara bawaan ke undangan saja", "%(count)s rooms|one": "%(count)s ruangan", "%(count)s rooms|other": "%(count)s ruangan", "%(count)s members|one": "%(count)s anggota", @@ -2763,7 +2763,7 @@ "Manually export keys": "Ekspor kunci secara manual", "I don't want my encrypted messages": "Saya tidak ingin pesan-pesan terenkripsi saya", "Start using Key Backup": "Mulai menggunakan Cadangan Kunci", - "Encrypted messages are secured with end-to-end encryption. Only you and the recipient(s) have the keys to read these messages.": "Pesan terenkripsi diamankan dengan enkripsi ujung-ke-ujung. Hanya Anda dan penerima punya kuncinya untuk membaca pesan-pesan ini.", + "Encrypted messages are secured with end-to-end encryption. Only you and the recipient(s) have the keys to read these messages.": "Pesan terenkripsi diamankan dengan enkripsi ujung ke ujung. Hanya Anda dan penerima punya kuncinya untuk membaca pesan ini.", "Leave space": "Tinggalkan space", "Leave some rooms": "Tinggalkan beberapa ruangan", "Leave all rooms": "Tinggalkan semua ruangan", @@ -2807,7 +2807,7 @@ "Start a conversation with someone using their name or username (like ).": "Mulai sebuah obrolan dengan seseorang menggunakan namanya atau nama pengguna (seperti ).", "Recently Direct Messaged": "Pesan Langsung Kini", "Recent Conversations": "Obrolan Terkini", - "The following users might not exist or are invalid, and cannot be invited: %(csvNames)s": "Pengguna berikut ini mungkin tidak ada atau tidak valid, dan tidak dapat diundang: %(csvNames)s", + "The following users might not exist or are invalid, and cannot be invited: %(csvNames)s": "Pengguna berikut ini mungkin tidak ada atau tidak absah, dan tidak dapat diundang: %(csvNames)s", "Failed to find the following users": "Gagal untuk mencari pengguna berikut ini", "A call can only be transferred to a single user.": "Sebuah panggilan dapat dipindah ke sebuah pengguna.", "We couldn't invite those users. Please check the users you want to invite and try again.": "Kami tidak dapat mengundang penggunanya. Mohon periksa pengguna yang Anda ingin undang dan coba lagi.", @@ -2824,9 +2824,9 @@ "Incoming Verification Request": "Permintaan Verifikasi Masuk", "Waiting for partner to confirm...": "Menunggu pengguna lain untuk mengkonfirmasi...", "Verifying this device will mark it as trusted, and users who have verified with you will trust this device.": "Memverifikasi perangkat ini akan menandainya sebagai terpercaya, dan pengguna yang telah diverifikasi dengan Anda akan mempercayai perangkat ini.", - "Verify this device to mark it as trusted. Trusting this device gives you and other users extra peace of mind when using end-to-end encrypted messages.": "Verifikasi perangkat ini untuk menandainya sebagai terpercaya. Mempercayai perangkat ini akan memberikan Anda dan pengguna lain ketenangan saat menggunakan pesan-pesan terenkripsi secara ujung-ke-ujung.", + "Verify this device to mark it as trusted. Trusting this device gives you and other users extra peace of mind when using end-to-end encrypted messages.": "Verifikasi perangkat ini untuk menandainya sebagai terpercaya. Mempercayai perangkat ini akan memberikan Anda dan pengguna lain ketenangan saat menggunakan pesan terenkripsi secara ujung ke ujung.", "Verifying this user will mark their session as trusted, and also mark your session as trusted to them.": "Memverifikasi pengguna ini akan menandai sesinya sebagai terpercaya, dan juga menandai sesi Anda sebagai terpercaya kepadanya.", - "Verify this user to mark them as trusted. Trusting users gives you extra peace of mind when using end-to-end encrypted messages.": "Verifikasi pengguna ini untuk menandainya sebagai terpercaya. Mempercayai pengguna memberikan Anda ketenangan saat menggunakan pesan-pesan terenkripsi secara ujung-ke-ujung.", + "Verify this user to mark them as trusted. Trusting users gives you extra peace of mind when using end-to-end encrypted messages.": "Verifikasi pengguna ini untuk menandainya sebagai terpercaya. Mempercayai pengguna memberikan Anda ketenangan saat menggunakan pesan terenkripsi secara ujung ke ujung.", "Based on %(count)s votes|one": "Berdasarkan oleh %(count)s suara", "Based on %(count)s votes|other": "Berdasarkan oleh %(count)s suara", "%(count)s votes|one": "%(count)s suara", @@ -2907,7 +2907,7 @@ "Spaces you know that contain this space": "Space yang Anda tahu yang berisi space ini", "Chat": "Obrolan", "Clear": "Hapus", - "You may contact me if you want to follow up or to let me test out upcoming ideas": "Anda mungkin hubungi saya jika Anda ingin menindaklanjuti atau memberitahu saya untuk menguji ide-ide baru", + "You may contact me if you want to follow up or to let me test out upcoming ideas": "Anda mungkin hubungi saya jika Anda ingin menindaklanjuti atau memberi tahu saya untuk menguji ide baru", "Home options": "Opsi Beranda", "%(spaceName)s menu": "Menu %(spaceName)s", "Join public room": "Bergabung ke ruangan publik", @@ -2923,7 +2923,7 @@ "To view all keyboard shortcuts, click here.": "Untuk melihat semua shortcut keyboard, klik di sini.", "You can turn this off anytime in settings": "Anda dapat mematikannya kapan saja di pengaturan", "We don't share information with third parties": "Kami tidak membagikan informasi ini dengan pihak ketiga", - "We don't record or profile any account data": "Kami tidak merekam atau memprofil data akun apapun", + "We don't record or profile any account data": "Kami tidak merekam atau memprofil data akun apa pun", "You can read all our terms here": "Anda dapat membaca kebijakan kami di sini", "Share anonymous data to help us identify issues. Nothing personal. No third parties.": "Bagikan data anonim untuk membantu kami mengidentifikasi masalah-masalah. Tidak ada yang pribadi. Tidak ada pihak ketiga.", "Okay": "Ok", @@ -2983,7 +2983,7 @@ "Dial": "Dial", "Missing room name or separator e.g. (my-room:domain.org)": "Kurang nama ruangan atau pemisah mis. (ruangan-saya:domain.org)", "Missing domain separator e.g. (:domain.org)": "Kurang pemisah domain mis. (:domain.org)", - "This address had invalid server or is already in use": "Alamat ini memiliki server yang tidak valid atau telah digunakan", + "This address had invalid server or is already in use": "Alamat ini memiliki server yang tidak absah atau telah digunakan", "Back to thread": "Kembali ke utasan", "Room members": "Anggota ruangan", "Back to chat": "Kembali ke obrolan", @@ -3076,7 +3076,7 @@ "Spaces are ways to group rooms and people. Alongside the spaces you're in, you can use some pre-built ones too.": "Space adalah cara untuk mengelompokkan ruangan dan orang. Di sampingnya space yang Anda berada, Anda dapat menggunakan space yang sudah dibuat.", "IRC (Experimental)": "IRC (Eksperimental)", "Call": "Panggil", - "Right panel stays open (defaults to room member list)": "Panel kanan tetap terbuka (menampilkan daftar anggota ruangan secara default)", + "Right panel stays open (defaults to room member list)": "Panel kanan tetap terbuka (menampilkan daftar anggota ruangan secara bawaan)", "Toggle hidden event visibility": "Alih visibilitas peristiwa tersembunyi", "Redo edit": "Ulangi editan", "Force complete": "Selesaikan dengan paksa", @@ -3220,7 +3220,7 @@ "Capabilities": "Kemampuan", "Send custom state event": "Kirim peristiwa status kustom", "Failed to send event!": "Gagal mengirimkan pertistiwa!", - "Doesn't look like valid JSON.": "Tidak terlihat seperti JSON yang valid.", + "Doesn't look like valid JSON.": "Tidak terlihat seperti JSON yang absah.", "Send custom room account data event": "Kirim peristiwa data akun ruangan kustom", "Send custom account data event": "Kirim peristiwa data akun kustom", "Room ID: %(roomId)s": "ID ruangan: %(roomId)s", @@ -3442,7 +3442,7 @@ "You're in": "Anda dalam", "You need to have the right permissions in order to share locations in this room.": "Anda harus mempunyai izin yang diperlukan untuk membagikan lokasi di ruangan ini.", "You don't have permission to share locations": "Anda tidak memiliki izin untuk membagikan lokasi", - "Messages in this chat will be end-to-end encrypted.": "Pesan di obrolan ini akan dienkripsi secara ujung-ke-ujung.", + "Messages in this chat will be end-to-end encrypted.": "Pesan di obrolan ini akan dienkripsi secara ujung ke ujung.", "Send your first message to invite to chat": "Kirim pesan pertama Anda untuk mengundang ke obrolan", "Favourite Messages (under active development)": "Pesan Favorit (dalam pengembangan aktif)", "Saved Items": "Item yang Tersimpan", @@ -3473,7 +3473,7 @@ "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.", + "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?", @@ -3588,5 +3588,49 @@ "Turn off to disable notifications on all your devices and sessions": "Matikan untuk menonaktifkan notifikasi pada semua perangkat dan sesi Anda", "Enable notifications for this account": "Aktifkan notifikasi untuk akun ini", "Video call ended": "Panggilan video berakhir", - "%(name)s started a video call": "%(name)s memulai sebuah panggilan video" + "%(name)s started a video call": "%(name)s memulai sebuah panggilan video", + "URL": "URL", + "Version": "Versi", + "Application": "Aplikasi", + "Record the client name, version, and url to recognise sessions more easily in session manager": "Rekam nama, versi, dan URL klien untuk dapat mengenal sesi dengan lebih muda dalam pengelola sesi", + "%(brand)s is end-to-end encrypted, but is currently limited to smaller numbers of users.": "%(brand)s terenkripsi secara ujung ke ujung, tetapi saat ini terbatas jumlah penggunanya.", + "Room info": "Informasi ruangan", + "View chat timeline": "Tampilkan linimasa obrolan", + "Close call": "Tutup panggilan", + "Layout type": "Jenis tata letak", + "Spotlight": "Sorotan", + "Freedom": "Bebas", + "Video call (%(brand)s)": "Panggilan video (%(brand)s)", + "Unknown session type": "Jenis sesi tidak diketahui", + "Web session": "Sesi web", + "Mobile session": "Sesi ponsel", + "Desktop session": "Sesi desktop", + "Operating system": "Sistem operasi", + "Model": "Model", + "Client": "Klien", + "Call type": "Jenis panggilan", + "You do not have sufficient permissions to change this.": "Anda tidak memiliki izin untuk mengubah ini.", + "Enable %(brand)s as an additional calling option in this room": "Aktifkan %(brand)s sebagai opsi panggilan tambahan di ruangan ini", + "Start %(brand)s calls": "Mulai panggilan %(brand)s", + "Join %(brand)s calls": "Bergabung panggilan %(brand)s", + "Fill screen": "Penuhi layar", + "Sorry — this call is currently full": "Maaf — panggilan ini saat ini penuh", + "Video call started": "Panggilan video dimulai", + "Unknown room": "Ruangan yang tidak diketahui", + "Video call started in %(roomName)s. (not supported by this browser)": "Panggilan video dimulai di %(roomName)s. (tidak didukung oleh peramban ini)", + "Video call started in %(roomName)s.": "Panggilan video dimulai di %(roomName)s.", + "Wysiwyg composer (plain text mode coming soon) (under active development)": "Komposer WYSIWYG (mode teks biasa akan datang) (dalam pengembangan aktif)", + "Our new sessions manager provides better visibility of all your sessions, and greater control over them including the ability to remotely toggle push notifications.": "Pengelola sesi kami yang baru memberikan pengelihatan yang lebih baik pada semua sesi Anda, dan pengendalian yang lebih baik pada semua sesi, termasuk kemampuan untuk mensaklar notifikasi dorongan.", + "Have greater visibility and control over all your sessions.": "Miliki pengelihatan dan pengendalian yang lebih baik pada semua sesi Anda.", + "New session manager": "Pengelola sesi baru", + "Use new session manager": "Gunakan pengelola sesi baru", + "Sign out all other sessions": "Keluarkan semua sesi lain", + "resume voice broadcast": "lanjutkan siaran suara", + "pause voice broadcast": "jeda siaran suara", + "Underline": "Garis Bawah", + "Italic": "Miring", + "Try out the rich text editor (plain text mode coming soon)": "Coba editor teks kaya (mode teks biasa akan datang)", + "You have already joined this call from another device": "Anda telah bergabung ke panggilan ini dari perangkat lain", + "stop voice broadcast": "hentikan siaran suara", + "Notifications silenced": "Notifikasi dibisukan" } diff --git a/src/i18n/strings/is.json b/src/i18n/strings/is.json index 897b2bd040..faf880f098 100644 --- a/src/i18n/strings/is.json +++ b/src/i18n/strings/is.json @@ -420,9 +420,9 @@ "Share Link to User": "Deila Hlekk að Notanda", "You have verified this user. This user has verified all of their sessions.": "Þú hefur sannreynt þennan notanda. Þessi notandi hefur sannreynt öll tæki þeirra.", "This user has not verified all of their sessions.": "Þessi notandi hefur ekki sannreynt öll tæki þeirra.", - "%(count)s verified sessions|one": "1 sannreynt tæki", - "%(count)s verified sessions|other": "%(count)s sannreyn tæki", - "Hide verified sessions": "Fela sannreynd tæki", + "%(count)s verified sessions|one": "1 sannreynd seta", + "%(count)s verified sessions|other": "%(count)s sannreyndar setur", + "Hide verified sessions": "Fela sannreyndar setur", "Remove recent messages": "Fjarlægja nýleg skilaboð", "Remove recent messages by %(user)s": "Fjarlægja nýleg skilaboð frá %(user)s", "Messages in this room are not end-to-end encrypted.": "Skilaboð í þessari spjallrás eru ekki enda-í-enda dulrituð.", @@ -1094,7 +1094,7 @@ "Topic: %(topic)s ": "Umfjöllunarefni: %(topic)s ", "Insert link": "Setja inn tengil", "Code block": "Kóðablokk", - "Poll": "Athuga", + "Poll": "Könnun", "Voice Message": "Talskilaboð", "Sticker": "Límmerki", "Hide stickers": "Fela límmerki", @@ -2971,7 +2971,7 @@ "Sends the given message with hearts": "Sendir skilaboðin með hjörtum", "Enable hardware acceleration": "Virkja vélbúnaðarhröðun", "Enable Markdown": "Virkja Markdown", - "Live Location Sharing (temporary implementation: locations persist in room history)": "Deiling staðsetninga í rautíma(tímabundið haldast staðsetningar í ferli spjallrása)", + "Live Location Sharing (temporary implementation: locations persist in room history)": "Deiling staðsetninga í rautíma (tímabundið haldast staðsetningar í ferli spjallrása)", "Location sharing - pin drop": "Deiling staðsetninga - festipinni", "To leave, return to this page and use the “%(leaveTheBeta)s” button.": "Til að hætta kemurðu einfaldlega aftur á þessa síðu og notar “%(leaveTheBeta)s” hnappinn.", "Use “%(replyInThread)s” when hovering over a message.": "Notaðu “%(replyInThread)s” þegar bendillinn svífur yfir skilaboðum.", @@ -3131,5 +3131,99 @@ "Inviting %(user1)s and %(user2)s": "Býð %(user1)s og %(user2)s", "%(user)s and %(count)s others|one": "%(user)s og 1 annar", "%(user)s and %(count)s others|other": "%(user)s og %(count)s til viðbótar", - "%(user1)s and %(user2)s": "%(user1)s og %(user2)s" + "%(user1)s and %(user2)s": "%(user1)s og %(user2)s", + "%(downloadButton)s or %(copyButton)s": "%(downloadButton)s eða %(copyButton)s", + "Did not receive it? Resend it": "Fékkstu hann ekki? Endursenda hann", + "Unread email icon": "Táknmynd fyrir ólesinn tölvupóst", + "No live locations": "Engar staðsetningar í rauntíma", + "Live location error": "Villa í rauntímastaðsetningu", + "Live location ended": "Staðsetningu í rauntíma lauk", + "Loading live location...": "Hleð inn rauntímastaðsetningu...", + "Interactively verify by emoji": "Sannprófa gagnvirkt með táknmyndum", + "Manually verify by text": "Sannreyna handvirkt með texta", + "%(featureName)s Beta feedback": "%(featureName)s beta umsögn", + "Show: %(instance)s rooms (%(server)s)": "Sýna: %(instance)s spjallrásir (%(server)s)", + "Who will you chat to the most?": "Við hverja muntu helst spjalla?", + "You're in": "Þú ert inni", + "Popout widget": "Sprettviðmótshluti", + "Un-maximise": "Ekki-hámarka", + "Live location sharing": "Deiling staðsetningar í rauntíma", + "View live location": "Skoða staðsetningu í rauntíma", + "%(name)s started a video call": "%(name)s hóf myndsímtal", + "To view %(roomName)s, you need an invite": "Til að skoða %(roomName)s þarftu boð", + "Ongoing call": "Símtal í gangi", + "Video call (Element Call)": "Myndsímtal (Element Call)", + "Video call (Jitsi)": "Myndsímtal (Jitsi)", + "Seen by %(count)s people|one": "Séð af %(count)s aðila", + "Seen by %(count)s people|other": "Séð af %(count)s aðilum", + "Send your first message to invite to chat": "Sendu fyrstu skilaboðin þín til að bjóða að spjalla", + "Security recommendations": "Ráðleggingar varðandi öryggi", + "%(selectedDeviceCount)s sessions selected": "%(selectedDeviceCount)s setur valdar", + "Filter devices": "Sía tæki", + "Inactive sessions": "Óvirkar setur", + "Verified sessions": "Sannreyndar setur", + "Sign out of this session": "Skrá út úr þessari setu", + "Receive push notifications on this session.": "Taka á móti ýti-tilkynningum á þessu tæki.", + "Toggle push notifications on this session.": "Víxla af/á ýti-tilkynningum á þessu tæki.", + "Download %(brand)s": "Sækja %(brand)s", + "Start a conversation with someone using their name or username (like ).": "Byrjaðu samtal með einhverjum með því að nota nafn viðkomandi eða notandanafn (eins og ).", + "Start a conversation with someone using their name, email address or username (like ).": "Byrjaðu samtal með einhverjum með því að nota nafn viðkomandi, tölvupóstfang eða notandanafn (eins og ).", + "Use an identity server to invite by email. Manage in Settings.": "Notaðu auðkennisþjón til að geta boðið með tölvupósti. Sýslaðu með þetta í stillingunum.", + "Something went wrong trying to invite the users.": "Eitthvað fór úrskeiðis við að bjóða notendunum.", + "Upgrade to %(hostSignupBrand)s": "Uppfæra í %(hostSignupBrand)s", + "Size can only be a number between %(min)s MB and %(max)s MB": "Stærð getur aðeins verið tala á milli %(min)s og %(max)s", + "The poll has ended. Top answer: %(topAnswer)s": "Könnuninni er lokið. Efsta svarið: %(topAnswer)s", + "Send custom timeline event": "Senda sérsniðinn tímalínuatburð", + "You will no longer be able to log in": "Þú munt ekki lengur geta skráð þig inn", + "You will not be able to reactivate your account": "Þú munt ekki geta endurvirkjað aðganginn þinn", + "Google Play and the Google Play logo are trademarks of Google LLC.": "Google Play og Google Play táknmerkið eru vörumerki í eigu Google LLC.", + "App Store® and the Apple logo® are trademarks of Apple Inc.": "App Store® og Apple logo® eru vörumerki í eigu Apple Inc.", + "%(severalUsers)ssent %(count)s hidden messages|one": "%(severalUsers)ssendu falin skilaboð", + "%(severalUsers)ssent %(count)s hidden messages|other": "%(severalUsers)ssendu %(count)s falin skilaboð", + "%(severalUsers)schanged the pinned messages for the room %(count)s times|one": "%(severalUsers)sbreyttu föstum skilaboðum fyrir spjallrásina", + "%(severalUsers)schanged the pinned messages for the room %(count)s times|other": "%(severalUsers)sbreyttu föstum skilaboðum fyrir spjallrásina %(count)s sinnum", + "%(oneUser)srejected their invitation %(count)s times|other": "%(oneUser)shafnaði boði sínu %(count)s sinnum", + "%(severalUsers)srejected their invitations %(count)s times|other": "%(severalUsers)shöfnuðu boðum þeirra %(count)s sinnum", + "Using this widget may share data with %(widgetDomain)s.": "Að nota þennan viðmótshluta gæti deilt gögnum með %(widgetDomain)s.", + "Any of the following data may be shared:": "Eftirfarandi gögnum gæti verið deilt:", + "You don't have permission to share locations": "Þú hefur ekki heimildir til að deila staðsetningum", + "Enable live location sharing": "Virkja deilingu rauntímastaðsetninga", + "Messages in this chat will be end-to-end encrypted.": "Skilaboð í þessu spjalli verða enda-í-enda dulrituð.", + "%(qrCode)s or %(emojiCompare)s": "%(qrCode)s eða %(emojiCompare)s", + "If you can't find the room you're looking for, ask for an invite or create a new room.": "Ef þú finnur ekki spjallrásina sem þú leitar að, skaltu biðja um boð eða útbúa nýja spjallrás.", + "Try different words or check for typos. Some results may not be visible as they're private and you need an invite to join them.": "Prófaðu önnur orð og aðgættu stafsetningu. Sumar niðurstöður gætu verið faldar þar sem þær eru einkamál og þá þarftu boð til að geta séð þær.", + "Joining the beta will reload %(brand)s.": "Ef tekið er þátt í beta-prófunum verður %(brand)s endurhlaðið.", + "Results not as expected? Please give feedback.": "Eru leitarniðurstöður ekki eins og þú áttir von á? Láttu okkur vita.", + "If you can't find the room you're looking for, ask for an invite or create a new room.": "Ef þú finnur ekki spjallrásina sem þú leitar að, skaltu biðja um boð eða útbúa nýja spjallrás.", + "If you can't see who you're looking for, send them your invite link.": "Ef þú sérð ekki þann sem þú ert að leita að, ættirðu að senda viðkomandi boðstengil.", + "Some results may be hidden for privacy": "Sumar niðurstöður gætu verið faldar þar sem þær eru einkamál", + "Add widgets, bridges & bots": "Bæta við viðmótshlutum, brúm og vélmennum", + "Edit widgets, bridges & bots": "Breyta viðmótshlutum, brúm og vélmennum", + "Close this widget to view it in this panel": "Lokaðu þessum viðmótshluta til að sjá hann á þessu spjaldi", + "Unpin this widget to view it in this panel": "Losaðu þennan viðmótshluta til að sjá hann á þessu spjaldi", + "Explore public spaces in the new search dialog": "Kannaðu opimber svæði í nýja leitarglugganum", + "Yes, the chat timeline is displayed alongside the video.": "Já, tímalína spjallsins birtist við hlið myndmerkisins.", + "Can I use text chat alongside the video call?": "Get ég notað textaspjall samhliða myndsímtali?", + "Use the “+” button in the room section of the left panel.": "Notaðu “+” hnappinn í spjallrásarhluta hliðarspjaldsins vinstra megin.", + "Video rooms are always-on VoIP channels embedded within a room in %(brand)s.": "Myndspjallrásir eru sívirkar VoIP-rásir sem ívafðar eru í spjallrásir innan %(brand)s.", + "A new way to chat over voice and video in %(brand)s.": "Ný leið til að spjalla með tali og myndmerki í %(brand)s.", + "How are you finding %(brand)s so far?": "Hvernig líst þér á %(brand)s hingað til?", + "Turn on notifications": "Kveikja á tilkynningum", + "Make sure people know it’s really you": "Láttu fólk vita að þetta sért þú", + "Set up your profile": "Settu upp notandasniðið þitt", + "Find and invite your co-workers": "Finndu og bjóddu samstarfsaðilum þínum", + "Do you want to enable threads anyway?": "Viltu samt virkja spjallþræði?", + "Partial Support for Threads": "Hlutastuðningur við þræði", + "Use new session manager (under active development)": "Ný setustýring (í virkri þróun)", + "Voice broadcast (under active development)": "Útvörpun tals (í virkri þróun)", + "Favourite Messages (under active development)": "Eftirlætisskilaboð (í virkri þróun)", + "Show HTML representation of room topics": "Birta HTML-framsetningu umfjöllunarefnis spjallrása", + "You were disconnected from the call. (Error: %(message)s)": "Þú varst aftengd/ur frá samtalinu. (Villa: %(message)s)", + "Reset bearing to north": "Frumstilla stefnu á norður", + "Toggle attribution": "Víxla tilvísun af/á", + "Unable to look up room ID from server": "Get ekki flett upp auðkenni spjallrásar á þjóninum", + "In %(spaceName)s and %(count)s other spaces.|one": "Á %(spaceName)s og %(count)s svæði til viðbótar.", + "In %(spaceName)s and %(count)s other spaces.|zero": "Á svæðinu %(spaceName)s.", + "In %(spaceName)s and %(count)s other spaces.|other": "Á %(spaceName)s og %(count)s svæðum til viðbótar.", + "In spaces %(space1Name)s and %(space2Name)s.": "Á svæðunum %(space1Name)s og %(space2Name)s." } diff --git a/src/i18n/strings/it.json b/src/i18n/strings/it.json index a5c166a246..6c4c692e65 100644 --- a/src/i18n/strings/it.json +++ b/src/i18n/strings/it.json @@ -1634,7 +1634,7 @@ "Looks good!": "Sembra giusta!", "Use custom size": "Usa dimensione personalizzata", "Hey you. You're the best!": "Ehi tu. Sei il migliore!", - "Message layout": "Layout messaggio", + "Message layout": "Disposizione del messaggio", "Modern": "Moderno", "Use a system font": "Usa un carattere di sistema", "System font name": "Nome carattere di sistema", @@ -2267,8 +2267,8 @@ "Value": "Valore", "Setting ID": "ID impostazione", "Show chat effects (animations when receiving e.g. confetti)": "Mostra effetti chat (animazioni quando si ricevono ad es. coriandoli)", - "Original event source": "Fonte dell'evento originale", - "Decrypted event source": "Fonte dell'evento decifrato", + "Original event source": "Sorgente dell'evento originale", + "Decrypted event source": "Sorgente dell'evento decifrato", "Inviting...": "Invito...", "Invite by username": "Invita per nome utente", "Invite your teammates": "Invita la tua squadra", @@ -3589,5 +3589,54 @@ "Enable notifications for this account": "Attiva le notifiche per questo account", "Video call ended": "Videochiamata terminata", "%(name)s started a video call": "%(name)s ha iniziato una videochiamata", - "Record the client name, version, and url to recognise sessions more easily in session manager": "Registra il nome, la versione e l'url del client per riconoscere le sessioni più facilmente nel gestore di sessioni" + "Record the client name, version, and url to recognise sessions more easily in session manager": "Registra il nome, la versione e l'url del client per riconoscere le sessioni più facilmente nel gestore di sessioni", + "URL": "URL", + "Version": "Versione", + "Application": "Applicazione", + "Unknown session type": "Tipo di sessione sconosciuta", + "Web session": "Sessione web", + "Mobile session": "Sessione mobile", + "Desktop session": "Sessione desktop", + "Video call started in %(roomName)s. (not supported by this browser)": "Videochiamata iniziata in %(roomName)s. (non supportata da questo browser)", + "Video call started in %(roomName)s.": "Videochiamata iniziata in %(roomName)s.", + "Room info": "Info stanza", + "View chat timeline": "Vedi linea temporale chat", + "Close call": "Chiudi chiamata", + "Layout type": "Tipo di disposizione", + "Spotlight": "Riflettore", + "Freedom": "Libertà", + "Operating system": "Sistema operativo", + "Model": "Modello", + "Client": "Client", + "Fill screen": "Riempi schermo", + "Video call started": "Videochiamata iniziata", + "Unknown room": "Stanza sconosciuta", + "Video call (%(brand)s)": "Videochiamata (%(brand)s)", + "Call type": "Tipo chiamata", + "You do not have sufficient permissions to change this.": "Non hai autorizzazioni sufficienti per cambiarlo.", + "%(brand)s is end-to-end encrypted, but is currently limited to smaller numbers of users.": "%(brand)s è crittografato end-to-end, ma attualmente è limitato ad un minore numero di utenti.", + "Enable %(brand)s as an additional calling option in this room": "Attiva %(brand)s come opzione di chiamata aggiuntiva in questa stanza", + "Join %(brand)s calls": "Entra in chiamate di %(brand)s", + "Start %(brand)s calls": "Inizia chiamate di %(brand)s", + "Sorry — this call is currently full": "Spiacenti — questa chiamata è piena", + "Wysiwyg composer (plain text mode coming soon) (under active development)": "Compositore wysiwyg (modalità a testo semplice in arrivo) (in sviluppo attivo)", + "Sign out all other sessions": "Disconnetti tutte le altre sessioni", + "Our new sessions manager provides better visibility of all your sessions, and greater control over them including the ability to remotely toggle push notifications.": "Il nostro nuovo gestore di sessioni offre una migliore visibilità e un maggiore controllo sulle tue sessioni, inclusa la possibilità di attivare/disattivare da remoto le notifiche push.", + "Have greater visibility and control over all your sessions.": "Maggiore visibilità e controllo su tutte le tue sessioni.", + "New session manager": "Nuovo gestore di sessioni", + "Use new session manager": "Usa nuovo gestore di sessioni", + "Underline": "Sottolineato", + "Italic": "Corsivo", + "Try out the rich text editor (plain text mode coming soon)": "Prova l'editor in rich text (il testo semplice è in arrivo)", + "resume voice broadcast": "riprendi broadcast voce", + "pause voice broadcast": "sospendi broadcast voce", + "You have already joined this call from another device": "Sei già in questa chiamata in un altro dispositivo", + "Notifications silenced": "Notifiche silenziose", + "stop voice broadcast": "ferma broadcast voce", + "Yes, stop broadcast": "Sì, ferma il broadcast", + "Are you sure you want to stop your live broadcast?This will end the broadcast and the full recording will be available in the room.": "Vuoi davvero fermare il tuo broadcast in diretta? Verrà terminato il broadcast e la registrazione completa sarà disponibile nella stanza.", + "Stop live broadcasting?": "Fermare il broadcast in diretta?", + "You don't have the required permissions to start a voice broadcast in this room. Contact a room administrator to upgrade your permissions.": "Non hai l'autorizzazione necessaria per iniziare un broadcast vocale in questa stanza. Contatta un amministratore della stanza per aggiornare le tue autorizzazioni.", + "You are already recording a voice broadcast. Please end your current voice broadcast to start a new one.": "Stai già registrando un broadcast vocale. Termina quello in corso per iniziarne uno nuovo.", + "Can't start a new voice broadcast": "Impossibile iniziare un nuovo broadcast vocale" } diff --git a/src/i18n/strings/lb.json b/src/i18n/strings/lb.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/src/i18n/strings/lb.json @@ -0,0 +1 @@ +{} diff --git a/src/i18n/strings/pt_BR.json b/src/i18n/strings/pt_BR.json index 9690353559..6b1bc3c576 100644 --- a/src/i18n/strings/pt_BR.json +++ b/src/i18n/strings/pt_BR.json @@ -2828,5 +2828,86 @@ "Inviting %(user1)s and %(user2)s": "Convidando %(user1)s e %(user2)s", "%(user1)s and %(user2)s": "%(user1)s e %(user2)s", "%(value)sh": "%(value)sh", - "%(value)sd": "%(value)sd" + "%(value)sd": "%(value)sd", + "Live": "Ao vivo", + "%(senderName)s has ended a poll": "%(senderName)s encerrou uma enquete", + "%(senderName)s has started a poll - %(pollQuestion)s": "%(senderName)s começou uma enquete - %(pollQuestion)s", + "%(senderName)s has shared their location": "%(senderName)s compartilhou sua localização", + "%(senderName)s removed %(targetName)s": "%(senderName)s removeu %(targetName)s", + "%(senderName)s removed %(targetName)s: %(reason)s": "%(senderName)s removeu %(targetName)s: %(reason)s", + "Video call started in %(roomName)s. (not supported by this browser)": "Chamada de vídeo iniciada em %(roomName)s. (não compatível com este navegador)", + "Video call started in %(roomName)s.": "Chamada de vídeo iniciada em %(roomName)s.", + "No active call in this room": "Nenhuma chamada ativa nesta sala", + "Unable to find Matrix ID for phone number": "Não foi possível encontrar o ID Matrix pelo número de telefone", + "Unknown (user, session) pair: (%(userId)s, %(deviceId)s)": "Par desconhecido (usuário, sessão): (%(userId)s, %(deviceId)s)", + "Command failed: Unable to find room (%(roomId)s": "Falha no comando: Não foi possível encontrar sala %(roomId)s", + "Removes user with given id from this room": "Remove desta sala o usuário com o ID determinado", + "Unrecognised room address: %(roomAlias)s": "Endereço da sala não reconhecido: %(roomAlias)s", + "Failed to get room topic: Unable to find room (%(roomId)s": "Falha ao obter tópico da sala: Não foi possível encontrar a sala %(roomId)s", + "We were unable to understand the given date (%(inputDate)s). Try using the format YYYY-MM-DD.": "Não foi possível entender a data fornecida (%(inputDate)s). Tente usando o formato AAAA-MM-DD.", + "You need to be able to kick users to do that.": "Você precisa ter permissão de expulsar usuários para fazer isso.", + "Failed to invite users to %(roomName)s": "Falha ao convidar usuários para %(roomName)s", + "Inviting %(user)s and %(count)s others|one": "Convidando %(user)s e 1 outro", + "Inviting %(user)s and %(count)s others|other": "Convidando %(user)s e %(count)s outros", + "%(user)s and %(count)s others|one": "%(user)s e 1 outro", + "%(user)s and %(count)s others|other": "%(user)s e %(count)s outros", + "You were disconnected from the call. (Error: %(message)s)": "Você foi desconectado da chamada. (Erro: %(message)s)", + "Remove messages sent by me": "", + "Welcome": "Boas-vindas", + "%(count)s people joined|one": "%(count)s pessoa entrou", + "%(count)s people joined|other": "%(count)s pessoas entraram", + "Audio devices": "Dispositivos de áudio", + "Unmute microphone": "Habilitar microfone", + "Mute microphone": "Silenciar microfone", + "Video devices": "Dispositivos de vídeo", + "Make sure people know it’s really you": "Certifique-se de que as pessoas saibam que é realmente você", + "Set up your profile": "Configure seu perfil", + "Video rooms": "Salas de vídeo", + "Room members": "Membros da sala", + "Back to chat": "Voltar ao chat", + "Connection lost": "Conexão perdida", + "Failed to join": "Falha ao entrar", + "The person who invited you has already left, or their server is offline.": "A pessoa que o convidou já saiu ou o servidor dela está offline.", + "The person who invited you has already left.": "A pessoa que o convidou já saiu.", + "There was an error joining.": "Ocorreu um erro ao entrar.", + "Video": "Vídeo", + "Video call started": "Videochamada iniciada", + "Unknown room": "Sala desconhecida", + "Location not available": "Local não disponível", + "Find my location": "Encontrar minha localização", + "Exit fullscreen": "Sair da tela cheia", + "Enter fullscreen": "Entrar em tela cheia", + "User may or may not exist": "O usuário pode ou não existir", + "User is already invited to the room": "O usuário já foi convidado para a sala", + "User is already invited to the space": "O usuário já foi convidado para o espaço", + "You do not have permission to invite people to this space.": "Você não tem permissão para convidar pessoas para este espaço.", + "In %(spaceName)s and %(count)s other spaces.|one": "Em %(spaceName)s e %(count)s outro espaço.", + "In %(spaceName)s and %(count)s other spaces.|zero": "No espaço %(spaceName)s.", + "In %(spaceName)s and %(count)s other spaces.|other": "Em %(spaceName)s e %(count)s outros espaços.", + "In spaces %(space1Name)s and %(space2Name)s.": "Nos espaços %(space1Name)s e %(space2Name)s.", + "Empty room (was %(oldName)s)": "Sala vazia (era %(oldName)s)", + "Unknown session type": "Tipo de sessão desconhecido", + "Mobile session": "Sessão móvel", + "Desktop session": "Sessão desktop", + "Web session": "Sessão web", + "Sign out of this session": "Sair desta sessão", + "Operating system": "Sistema operacional", + "Model": "Modelo", + "URL": "URL", + "Version": "Versão", + "Application": "Aplicação", + "Last activity": "Última atividade", + "Client": "Cliente", + "Confirm signing out these devices|other": "Confirme a saída destes dispositivos", + "Confirm signing out these devices|one": "Confirme a saída deste dispositivo", + "Sign out all other sessions": "Sair de todas as outras sessões", + "Current session": "Sessão atual", + "Developer tools": "Ferramentas de desenvolvimento", + "Welcome to %(brand)s": "Boas-vindas ao", + "Processing event %(number)s out of %(total)s": "Processando evento %(number)s de %(total)s", + "Exported %(count)s events in %(seconds)s seconds|one": "%(count)s evento exportado em %(seconds)s segundos", + "Exported %(count)s events in %(seconds)s seconds|other": "%(count)s eventos exportados em %(seconds)s segundos", + "Creating output...": "Criando resultado...", + "Fetching events...": "Buscando eventos...", + "Starting export process...": "Iniciando processo de exportação..." } diff --git a/src/i18n/strings/ru.json b/src/i18n/strings/ru.json index 6fb881f0f8..6c7d506816 100644 --- a/src/i18n/strings/ru.json +++ b/src/i18n/strings/ru.json @@ -40,7 +40,7 @@ "Logout": "Выйти", "Low priority": "Маловажные", "Moderator": "Модератор", - "Name": "Имя", + "Name": "Название", "New passwords must match each other.": "Новые пароли должны совпадать.", "Notifications": "Уведомления", "": "<не поддерживается>", @@ -187,7 +187,7 @@ "You may need to manually permit %(brand)s to access your microphone/webcam": "Вам необходимо предоставить %(brand)s доступ к микрофону или веб-камере вручную", "Anyone": "Все", "Are you sure you want to leave the room '%(roomName)s'?": "Уверены, что хотите покинуть '%(roomName)s'?", - "%(senderDisplayName)s removed the room name.": "%(senderDisplayName)s удалил(а) имя комнаты.", + "%(senderDisplayName)s removed the room name.": "%(senderDisplayName)s удалил(а) название комнаты.", "Custom level": "Специальные права", "Email address": "Электронная почта", "Error decrypting attachment": "Ошибка расшифровки вложения", @@ -195,7 +195,7 @@ "Import": "Импорт", "Incorrect username and/or password.": "Неверное имя пользователя и/или пароль.", "Invalid file%(extra)s": "Недопустимый файл%(extra)s", - "Invited": "Приглашен", + "Invited": "Приглашены", "Jump to first unread message.": "Перейти к первому непрочитанному сообщению.", "Privileged Users": "Привилегированные пользователи", "Register": "Зарегистрироваться", @@ -613,7 +613,7 @@ "Timeline": "Лента сообщений", "Autocomplete delay (ms)": "Задержка автодополнения (мс)", "Roles & Permissions": "Роли и права", - "Security & Privacy": "Безопасность и приватность", + "Security & Privacy": "Безопасность", "Encryption": "Шифрование", "Encrypted": "Зашифровано", "Ignored users": "Игнорируемые пользователи", @@ -1087,7 +1087,7 @@ "%(count)s unread messages including mentions.|other": "%(count)s непрочитанных сообщения(-й), включая упоминания.", "Failed to deactivate user": "Не удалось деактивировать пользователя", "This client does not support end-to-end encryption.": "Этот клиент не поддерживает сквозное шифрование.", - "Messages in this room are not end-to-end encrypted.": "Сообщения в этой комнате не шифруются сквозным шифрованием.", + "Messages in this room are not end-to-end encrypted.": "Сообщения в этой комнате не защищены сквозным шифрованием.", "Please create a new issue on GitHub so that we can investigate this bug.": "Пожалуйста, создайте новую проблему/вопрос на GitHub, чтобы мы могли расследовать эту ошибку.", "Use an identity server to invite by email. Use the default (%(defaultIdentityServerName)s) or manage in Settings.": "Используйте идентификационный сервер для приглашения по электронной почте. Используйте значение по умолчанию (%(defaultIdentityServerName)s) или управляйте в Настройках.", "Use an identity server to invite by email. Manage in Settings.": "Используйте идентификационный сервер для приглашения по электронной почте. Управление в Настройки.", @@ -1352,7 +1352,7 @@ "Waiting for %(displayName)s to accept…": "Ожидание принятия от %(displayName)s…", "Accepting…": "Принятие…", "Start Verification": "Начать проверку", - "Messages in this room are end-to-end encrypted.": "Сообщения в этой комнате зашифрованы сквозным шифрованием.", + "Messages in this room are end-to-end encrypted.": "Сообщения в этой комнате защищены сквозным шифрованием.", "Verify User": "Подтвердить пользователя", "Your messages are not secure": "Ваши сообщения не защищены", "Your homeserver": "Ваш домашний сервер", @@ -1516,7 +1516,7 @@ "Favourited": "В избранном", "Room options": "Настройки комнаты", "Welcome to %(appName)s": "Добро пожаловать в %(appName)s", - "Create a Group Chat": "Создать групповой чат", + "Create a Group Chat": "Создать комнату", "All settings": "Все настройки", "Feedback": "Отзыв", "* %(senderName)s %(emote)s": "* %(senderName)s %(emote)s", @@ -1615,8 +1615,8 @@ "Wrong file type": "Неправильный тип файла", "Looks good!": "Выглядит неплохо!", "Security Phrase": "Секретная фраза", - "Security Key": "Ключ безопасности", - "Use your Security Key to continue.": "Чтобы продолжить, используйте свой ключ безопасности.", + "Security Key": "Бумажный ключ", + "Use your Security Key to continue.": "Чтобы продолжить, используйте свой бумажный ключ.", "Restoring keys from backup": "Восстановление ключей из резервной копии", "Fetching keys from server...": "Получение ключей с сервера...", "%(completed)s of %(total)s keys restored": "%(completed)s из %(total)s ключей восстановлено", @@ -2753,7 +2753,7 @@ "Resetting your verification keys cannot be undone. After resetting, you won't have access to old encrypted messages, and any friends who have previously verified you will see security warnings until you re-verify with them.": "Сброс ключей проверки нельзя отменить. После сброса вы не сможете получить доступ к старым зашифрованным сообщениям, а друзья, которые ранее проверили вас, будут видеть предупреждения о безопасности, пока вы не пройдете повторную проверку.", "Skip verification for now": "Пока пропустить проверку", "I'll verify later": "Я проверю позже", - "Verify with Security Key": "Заверить с помощью ключа безопасности", + "Verify with Security Key": "Заверить бумажным ключом", "Verify with Security Key or Phrase": "Проверка с помощью ключа безопасности или фразы", "Proceed with reset": "Выполнить сброс", "It looks like you don't have a Security Key or any other devices you can verify against. This device will not be able to access old encrypted messages. In order to verify your identity on this device, you'll need to reset your verification keys.": "Похоже, у вас нет ключа шифрования, или каких-либо других устройств, которые вы можете проверить. Это устройство не сможет получить доступ к старым зашифрованным сообщениям. Чтобы подтвердить свою личность на этом устройстве, вам потребуется сбросить ключи подтверждения.", @@ -2827,11 +2827,11 @@ "We'll generate a Security Key for you to store somewhere safe, like a password manager or a safe.": "Мы создадим ключ безопасности для вас, чтобы вы могли хранить его в надежном месте, например, в менеджере паролей или сейфе.", "Regain access to your account and recover encryption keys stored in this session. Without them, you won't be able to read all of your secure messages in any session.": "Восстановите доступ к своей учетной записи и восстановите ключи шифрования, сохраненные в этом сеансе. Без них вы не сможете прочитать все свои защищенные сообщения в любой сессии.", "Without verifying, you won't have access to all your messages and may appear as untrusted to others.": "Без проверки вы не сможете получить доступ ко всем своим сообщениям и можете показаться другим людям недоверенным.", - "Your new device is now verified. Other users will see it as trusted.": "Теперь ваше новое устройство проверено. Другие пользователи будут видеть его как доверенное.", - "Your new device is now verified. It has access to your encrypted messages, and other users will see it as trusted.": "Теперь ваше новое устройство проверено. Оно имеет доступ к вашим зашифрованным сообщениям, и другие пользователи будут воспринимать его как доверенное.", + "Your new device is now verified. Other users will see it as trusted.": "Ваша новая сессия подтверждена. Другие пользователи будут воспринимать её как заверенную.", + "Your new device is now verified. It has access to your encrypted messages, and other users will see it as trusted.": "Ваша новая сессия подтверждена. Она имеет доступ к вашим зашифрованным сообщениям, и другие пользователи будут воспринимать её как заверенную.", "Verify with another device": "Заверить с помощью другого устройства", "Someone already has that username, please try another.": "У кого-то уже есть такое имя пользователя, пожалуйста, попробуйте другое.", - "Device verified": "Устройство заверено", + "Device verified": "Сессия заверена", "Verify this device": "Заверьте эту сессию", "Unable to verify this device": "Не удалось проверить это устройство", "Show all threads": "Показать все обсуждения", @@ -3547,12 +3547,50 @@ "%(downloadButton)s or %(copyButton)s": "%(downloadButton)s или %(copyButton)s", "Sign out of this session": "Выйти из этой сессии", "Please be aware that session names are also visible to people you communicate with": "Пожалуйста, имейте в виду, что названия сессий также видны людям, с которыми вы общаетесь", - "Push notifications": "Push-уведомления", + "Push notifications": "Уведомления", "Receive push notifications on this session.": "Получать push-уведомления в этой сессии.", "Toggle push notifications on this session.": "Push-уведомления для этой сессии.", "Enable notifications for this device": "Уведомления для этой сессии", "Enable notifications for this account": "Уведомления для этой учётной записи", "Turn off to disable notifications on all your devices and sessions": "Выключите, чтобы отключить уведомления во всех своих сессиях", "Failed to set pusher state": "Не удалось установить состояние push-службы", - "%(selectedDeviceCount)s sessions selected": "Выбрано сессий: %(selectedDeviceCount)s" + "%(selectedDeviceCount)s sessions selected": "Выбрано сессий: %(selectedDeviceCount)s", + "Application": "Приложение", + "Version": "Версия", + "URL": "URL-адрес", + "Client": "Клиент", + "Room info": "О комнате", + "Wysiwyg composer (plain text mode coming soon) (under active development)": "Редактор «Что видишь, то и получишь» (скоро появится режим обычного текста) (в активной разработке)", + "New session manager": "Новый менеджер сессий", + "Operating system": "Операционная система", + "Element Call video rooms": "Видеокомнаты Element Call", + "Video call (Jitsi)": "Видеозвонок (Jitsi)", + "Unknown session type": "Неизвестный тип сессии", + "Unknown room": "Неизвестная комната", + "View chat timeline": "Посмотреть ленту сообщений", + "Model": "Модель", + "Live": "В эфире", + "Video call (%(brand)s)": "Видеозвонок (%(brand)s)", + "Voice broadcast (under active development)": "Аудиовещание (в активной разработке)", + "Use new session manager": "Использовать новый менеджер сессий", + "Sign out all other sessions": "Выйти из всех других сессий", + "Voice broadcasts": "Аудиопередачи", + "Voice broadcast": "Аудиопередача", + "Have greater visibility and control over all your sessions.": "Получите наилучшую видимость и контроль над всеми вашими сеансами.", + "New group call experience": "Новый опыт группового вызова", + "Sliding Sync mode (under active development, cannot be disabled)": "Скользящий режим синхронизации (в активной разработке, не может быть отключен)", + "Video call started": "Начался видеозвонок", + "Video call started in %(roomName)s. (not supported by this browser)": "Видеовызов начался в %(roomName)s. (не поддерживается этим браузером)", + "Video call started in %(roomName)s.": "Видеовызов начался в %(roomName)s.", + "You need to be able to kick users to do that.": "Вы должны иметь возможность пинать пользователей, чтобы сделать это.", + "Inviting %(user)s and %(count)s others|one": "Приглашающий %(user)s и 1 других", + "Inviting %(user)s and %(count)s others|other": "Приглашение %(user)s и %(count)s других", + "Inviting %(user1)s and %(user2)s": "Приглашение %(user1)s и %(user2)s", + "Fill screen": "Заполнить экран", + "Sorry — this call is currently full": "Извините — этот вызов в настоящее время заполнен", + "Record the client name, version, and url to recognise sessions more easily in session manager": "Записывать название клиента, версию и URL-адрес для более лёгкого распознавания сессий в менеджере сессий", + "Our new sessions manager provides better visibility of all your sessions, and greater control over them including the ability to remotely toggle push notifications.": "Наш новый менеджер сеансов обеспечивает лучшую видимость всех ваших сеансов и больший контроль над ними, включая возможность удаленного переключения push-уведомлений.", + "Try out the rich text editor (plain text mode coming soon)": "Попробуйте визуальный редактор текста (скоро появится обычный текстовый режим)", + "Italic": "Курсив", + "Underline": "Подчёркнутый" } diff --git a/src/i18n/strings/sk.json b/src/i18n/strings/sk.json index 155fa55bdd..dd4555caf6 100644 --- a/src/i18n/strings/sk.json +++ b/src/i18n/strings/sk.json @@ -3504,7 +3504,7 @@ "Session details": "Podrobnosti o relácii", "IP address": "IP adresa", "Device": "Zariadenie", - "For best security, verify your sessions and sign out from any session that you don't recognize or use anymore.": "V záujme čo najlepšieho zabezpečenia overte svoje relácie a odhláste sa z každej relácie, ktorú už nepoznáte alebo nepoužívate.", + "For best security, verify your sessions and sign out from any session that you don't recognize or use anymore.": "V záujme čo najlepšieho zabezpečenia, overte svoje relácie a odhláste sa z každej relácie, ktorú už nepoznáte alebo nepoužívate.", "Other sessions": "Iné relácie", "Verify or sign out from this session for best security and reliability.": "V záujme čo najvyššej bezpečnosti a spoľahlivosti túto reláciu overte alebo sa z nej odhláste.", "Unverified session": "Neoverená relácia", @@ -3588,5 +3588,81 @@ "Enable notifications for this account": "Povoliť oznámenia pre tento účet", "%(selectedDeviceCount)s sessions selected": "%(selectedDeviceCount)s vybratých relácií", "Video call ended": "Videohovor ukončený", - "%(name)s started a video call": "%(name)s začal/a videohovor" + "%(name)s started a video call": "%(name)s začal/a videohovor", + "URL": "URL", + "Version": "Verzia", + "Application": "Aplikácia", + "Record the client name, version, and url to recognise sessions more easily in session manager": "Zaznamenať názov klienta, verziu a url, aby bolo možné ľahšie rozpoznať relácie v správcovi relácií", + "Unknown session type": "Neznámy typ relácie", + "Web session": "Webová relácia", + "Mobile session": "Relácia na mobile", + "Desktop session": "Relácia stolného počítača", + "Video call started": "Videohovor bol spustený", + "Unknown room": "Neznáma miestnosť", + "Video call started in %(roomName)s. (not supported by this browser)": "Videohovor sa začal v %(roomName)s. (nie je podporované v tomto prehliadači)", + "Video call started in %(roomName)s.": "Videohovor sa začal v %(roomName)s.", + "Close call": "Zavrieť hovor", + "Room info": "Informácie o miestnosti", + "View chat timeline": "Zobraziť časovú os konverzácie", + "Layout type": "Typ rozmiestnenia", + "Spotlight": "Stredobod", + "Freedom": "Sloboda", + "Video call (%(brand)s)": "Videohovor (%(brand)s)", + "Operating system": "Operačný systém", + "Model": "Model", + "Client": "Klient", + "Call type": "Typ hovoru", + "You do not have sufficient permissions to change this.": "Nemáte dostatočné oprávnenia na to, aby ste toto mohli zmeniť.", + "%(brand)s is end-to-end encrypted, but is currently limited to smaller numbers of users.": "%(brand)s je end-to-end šifrovaný, ale v súčasnosti je obmedzený pre menší počet používateľov.", + "Enable %(brand)s as an additional calling option in this room": "Zapnúť %(brand)s ako ďalšiu možnosť volania v tejto miestnosti", + "Join %(brand)s calls": "Pripojiť sa k %(brand)s hovorom", + "Start %(brand)s calls": "Spustiť %(brand)s hovory", + "Fill screen": "Vyplniť obrazovku", + "Sorry — this call is currently full": "Prepáčte — tento hovor je momentálne obsadený", + "Our new sessions manager provides better visibility of all your sessions, and greater control over them including the ability to remotely toggle push notifications.": "Náš nový správca relácií poskytuje lepší prehľad o všetkých vašich reláciách a lepšiu kontrolu nad nimi vrátane možnosti vzdialene prepínať push oznámenia.", + "Have greater visibility and control over all your sessions.": "Majte lepší prehľad a kontrolu nad všetkými reláciami.", + "New session manager": "Nový správca relácií", + "Use new session manager": "Použiť nového správcu relácií", + "Wysiwyg composer (plain text mode coming soon) (under active development)": "Wysiwyg composer (textový režim už čoskoro) (v štádiu aktívneho vývoja)", + "Sign out all other sessions": "Odhlásenie zo všetkých ostatných relácií", + "Underline": "Podčiarknuté", + "Italic": "Kurzíva", + "You have already joined this call from another device": "K tomuto hovoru ste sa už pripojili z iného zariadenia", + "Try out the rich text editor (plain text mode coming soon)": "Vyskúšajte rozšírený textový editor (čistý textový režim sa objaví čoskoro)", + "stop voice broadcast": "zastaviť hlasové vysielanie", + "resume voice broadcast": "obnoviť hlasové vysielanie", + "pause voice broadcast": "pozastaviť hlasové vysielanie", + "Notifications silenced": "Oznámenia stlmené", + "Completing set up of your new device": "Dokončenie nastavenia nového zariadenia", + "Waiting for device to sign in": "Čaká sa na prihlásenie zariadenia", + "Connecting...": "Pripájanie…", + "Review and approve the sign in": "Skontrolujte a schváľte prihlásenie", + "Select 'Scan QR code'": "Vyberte možnosť \"Skenovať QR kód\"", + "Start at the sign in screen": "Začnite na prihlasovacej obrazovke", + "Scan the QR code below with your device that's signed out.": "Naskenujte nižšie uvedený QR kód pomocou zariadenia, ktoré je odhlásené.", + "By approving access for this device, it will have full access to your account.": "Schválením prístupu pre toto zariadenie bude mať plný prístup k vášmu účtu.", + "Check that the code below matches with your other device:": "Skontrolujte, či sa nižšie uvedený kód zhoduje s vaším druhým zariadením:", + "Devices connected": "Zariadenia pripojené", + "The homeserver doesn't support signing in another device.": "Domovský server nepodporuje prihlasovanie do iného zariadenia.", + "An unexpected error occurred.": "Vyskytla sa neočakávaná chyba.", + "The request was cancelled.": "Žiadosť bola zrušená.", + "The other device isn't signed in.": "Druhé zariadenie nie je prihlásené.", + "The other device is already signed in.": "Druhé zariadenie je už prihlásené.", + "The request was declined on the other device.": "Žiadosť bola na druhom zariadení zamietnutá.", + "Linking with this device is not supported.": "Prepojenie s týmto zariadením nie je podporované.", + "The scanned code is invalid.": "Naskenovaný kód je neplatný.", + "The linking wasn't completed in the required time.": "Prepojenie nebolo dokončené v požadovanom čase.", + "Sign in new device": "Prihlásiť nové zariadenie", + "Show QR code": "Zobraziť QR kód", + "You can use this device to sign in a new device with a QR code. You will need to scan the QR code shown on this device with your device that's signed out.": "Toto zariadenie môžete použiť na prihlásenie nového zariadenia pomocou QR kódu. QR kód zobrazený na tomto zariadení musíte naskenovať pomocou zariadenia, ktoré je odhlásené.", + "Sign in with QR code": "Prihlásiť sa pomocou QR kódu", + "Browser": "Prehliadač", + "Allow a QR code to be shown in session manager to sign in another device (requires compatible homeserver)": "Umožniť zobrazenie QR kódu v správcovi relácií na prihlásenie do iného zariadenia (vyžaduje kompatibilný domovský server)", + "Yes, stop broadcast": "Áno, zastaviť vysielanie", + "Are you sure you want to stop your live broadcast?This will end the broadcast and the full recording will be available in the room.": "Určite chcete zastaviť vaše vysielanie naživo? Tým sa vysielanie ukončí a v miestnosti bude k dispozícii celý záznam.", + "Stop live broadcasting?": "Zastaviť vysielanie naživo?", + "Someone else is already recording a voice broadcast. Wait for their voice broadcast to end to start a new one.": "Niekto iný už nahráva hlasové vysielanie. Počkajte, kým sa skončí jeho hlasové vysielanie, a potom spustite nové.", + "You don't have the required permissions to start a voice broadcast in this room. Contact a room administrator to upgrade your permissions.": "Nemáte požadované oprávnenia na spustenie hlasového vysielania v tejto miestnosti. Obráťte sa na správcu miestnosti, aby vám rozšíril oprávnenia.", + "You are already recording a voice broadcast. Please end your current voice broadcast to start a new one.": "Už nahrávate hlasové vysielanie. Ukončite aktuálne hlasové vysielanie a spustite nové.", + "Can't start a new voice broadcast": "Nemôžete spustiť nové hlasové vysielanie" } diff --git a/src/i18n/strings/uk.json b/src/i18n/strings/uk.json index 3e978a7869..0d2adc0ad8 100644 --- a/src/i18n/strings/uk.json +++ b/src/i18n/strings/uk.json @@ -213,7 +213,7 @@ "%(senderName)s removed the main address for this room.": "%(senderName)s вилучає основу адресу цієї кімнати.", "Someone": "Хтось", "%(senderName)s sent an invitation to %(targetDisplayName)s to join the room.": "%(senderName)s надіслав(-ла) запрошення %(targetDisplayName)s приєднатися до кімнати.", - "Default": "Типово", + "Default": "Типовий", "%(senderName)s made future room history visible to all room members, from the point they are invited.": "%(senderName)s зробив(-ла) майбутню історію кімнати видимою для всіх учасників з моменту, коли вони приєдналися.", "%(senderName)s made future room history visible to all room members, from the point they joined.": "%(senderName)s робить майбутню історію кімнати видимою для всіх учасників від часу їхнього приєднання.", "%(senderName)s made future room history visible to all room members.": "%(senderName)s зробив(-ла) майбутню історію видимою для всіх учасників кімнати.", @@ -2162,7 +2162,7 @@ "Feeling experimental? Labs are the best way to get things early, test out new features and help shape them before they actually launch. Learn more.": "Почуваєтесь допитливо? Лабораторія дає змогу отримувати нову функціональність раніше всіх, випробовувати й допомагати допрацьовувати її перед запуском. Докладніше.", "Render LaTeX maths in messages": "Форматувати LaTeX-формули в повідомленнях", "Share anonymous data to help us identify issues. Nothing personal. No third parties. Learn More": "Збір анонімних даних дає нам змогу дізнаватися про збої. Жодних особистих даних. Жодних третіх сторін. Докладніше", - "Low bandwidth mode (requires compatible homeserver)": "Заощаджувати трафік (потрібен сумісний сервер)", + "Low bandwidth mode (requires compatible homeserver)": "Режим низької пропускної здатності (потрібен сумісний домашній сервер)", "Developer": "Розробка", "Moderation": "Модерування", "Experimental": "Експериментально", @@ -3570,7 +3570,7 @@ "Voice broadcast": "Голосове мовлення", "Voice broadcast (under active development)": "Голосове мовлення (в активній розробці)", "Element Call video rooms": "Відео кімнати Element Call", - "Voice broadcasts": "Голосові передачі", + "Voice broadcasts": "Голосове мовлення", "You do not have permission to start voice calls": "У вас немає дозволу розпочинати голосові виклики", "There's no one here to call": "Тут немає кого викликати", "You do not have permission to start video calls": "У вас немає дозволу розпочинати відеовиклики", @@ -3588,5 +3588,81 @@ "Enable notifications for this account": "Увімкнути сповіщення для цього облікового запису", "Video call ended": "Відеовиклик завершено", "%(name)s started a video call": "%(name)s розпочинає відеовиклик", - "%(selectedDeviceCount)s sessions selected": "Вибрано %(selectedDeviceCount)s сеансів" + "%(selectedDeviceCount)s sessions selected": "Вибрано %(selectedDeviceCount)s сеансів", + "URL": "URL", + "Version": "Версія", + "Application": "Застосунок", + "Record the client name, version, and url to recognise sessions more easily in session manager": "Записуйте назву клієнта, версію та URL-адресу, щоб легше розпізнавати сеанси в менеджері сеансів", + "Unknown session type": "Невідомий тип сеансу", + "Web session": "Сеанс у браузері", + "Mobile session": "Сеанс на мобільному", + "Desktop session": "Сеанс на комп'ютері", + "Video call started": "Відеовиклик розпочато", + "Unknown room": "Невідома кімната", + "Video call started in %(roomName)s. (not supported by this browser)": "Відеовиклик розпочато о %(roomName)s. (не підтримується цим браузером)", + "Video call started in %(roomName)s.": "Відеовиклик розпочато о %(roomName)s.", + "Room info": "Відомості про кімнату", + "View chat timeline": "Переглянути стрічку бесіди", + "Close call": "Закрити виклик", + "Layout type": "Тип макета", + "Spotlight": "У фокусі", + "Freedom": "Свобода", + "Operating system": "Операційна система", + "Model": "Модель", + "Client": "Клієнт", + "Fill screen": "Заповнити екран", + "Video call (%(brand)s)": "Відеовиклик (%(brand)s)", + "Call type": "Тип викликів", + "You do not have sufficient permissions to change this.": "Ви не маєте достатніх повноважень, щоб змінити це.", + "%(brand)s is end-to-end encrypted, but is currently limited to smaller numbers of users.": "%(brand)s наскрізно зашифровано, але наразі обмежений меншою кількістю користувачів.", + "Enable %(brand)s as an additional calling option in this room": "Увімкнути %(brand)s додатковою опцією викликів у цій кімнаті", + "Join %(brand)s calls": "Приєднатися до %(brand)s викликів", + "Start %(brand)s calls": "Розпочати %(brand)s викликів", + "Sorry — this call is currently full": "Перепрошуємо, цей виклик заповнено", + "Wysiwyg composer (plain text mode coming soon) (under active development)": "Редактор Wysiwyg (скоро з'явиться режим звичайного тексту) (в активній розробці)", + "Our new sessions manager provides better visibility of all your sessions, and greater control over them including the ability to remotely toggle push notifications.": "Наш новий менеджер сеансів забезпечує кращу видимість всіх ваших сеансів і більший контроль над ними, зокрема можливість віддаленого перемикання push-сповіщень.", + "Have greater visibility and control over all your sessions.": "Майте кращу видимість і контроль над усіма вашими сеансами.", + "New session manager": "Новий менеджер сеансів", + "Use new session manager": "Використовувати новий менеджер сеансів", + "Sign out all other sessions": "Вийти з усіх інших сеансів", + "Underline": "Підкреслений", + "Italic": "Курсив", + "Try out the rich text editor (plain text mode coming soon)": "Спробуйте розширений текстовий редактор (незабаром з'явиться режим звичайного тексту)", + "resume voice broadcast": "поновити голосове мовлення", + "pause voice broadcast": "призупинити голосове мовлення", + "You have already joined this call from another device": "Ви вже приєдналися до цього виклику з іншого пристрою", + "stop voice broadcast": "припинити голосове мовлення", + "Notifications silenced": "Сповіщення стишено", + "Sign in with QR code": "Увійти за допомогою QR-коду", + "Browser": "Браузер", + "Allow a QR code to be shown in session manager to sign in another device (requires compatible homeserver)": "Дозволити показ QR-коду в менеджері сеансів для входу на іншому пристрої (потрібен сумісний домашній сервер)", + "Yes, stop broadcast": "Так, припинити мовлення", + "Are you sure you want to stop your live broadcast?This will end the broadcast and the full recording will be available in the room.": "Ви впевнені, що хочете припинити голосове мовлення? На цьому трансляція завершиться, і повний запис буде доступний у кімнаті.", + "Stop live broadcasting?": "Припинити голосове мовлення?", + "Someone else is already recording a voice broadcast. Wait for their voice broadcast to end to start a new one.": "Хтось інший вже записує голосову трансляцію. Зачекайте, поки вона завершиться, щоб почати нову.", + "You don't have the required permissions to start a voice broadcast in this room. Contact a room administrator to upgrade your permissions.": "Ви не маєте необхідних дозволів для початку голосового мовлення в цій кімнаті. Зверніться до адміністратора кімнати, щоб оновити ваші дозволи.", + "You are already recording a voice broadcast. Please end your current voice broadcast to start a new one.": "Ви вже записуєте голосову трансляцію. Завершіть поточну трансляцію, щоб розпочати нову.", + "Can't start a new voice broadcast": "Не вдалося розпочати нову голосове мовлення", + "Completing set up of your new device": "Завершення налаштування нового пристрою", + "Waiting for device to sign in": "Очікування входу з пристрою", + "Connecting...": "З'єднання...", + "Review and approve the sign in": "Розглянути та схвалити вхід", + "Select 'Scan QR code'": "Виберіть «Сканувати QR-код»", + "Start at the sign in screen": "Почніть з екрана входу", + "Scan the QR code below with your device that's signed out.": "Скануйте QR-код знизу своїм пристроєм, на якому ви вийшли.", + "By approving access for this device, it will have full access to your account.": "Затвердивши доступ для цього пристрою, ви надасте йому повний доступ до вашого облікового запису.", + "Check that the code below matches with your other device:": "Перевірте, чи збігається наведений внизу код з кодом на вашому іншому пристрої:", + "Devices connected": "Пристрої під'єднано", + "The homeserver doesn't support signing in another device.": "Домашній сервер не підтримує вхід на іншому пристрої.", + "An unexpected error occurred.": "Виникла непередбачувана помилка.", + "The request was cancelled.": "Запит було скасовано.", + "The other device isn't signed in.": "На іншому пристрої вхід не виконано.", + "The other device is already signed in.": "На іншому пристрої вхід було виконано.", + "The request was declined on the other device.": "На іншому пристрої запит відхилено.", + "Linking with this device is not supported.": "Зв'язок з цим пристроєм не підтримується.", + "The scanned code is invalid.": "Сканований код недійсний.", + "The linking wasn't completed in the required time.": "У встановлені терміни з'єднання не було виконано.", + "Sign in new device": "Увійти на новому пристрої", + "Show QR code": "Показати QR-код", + "You can use this device to sign in a new device with a QR code. You will need to scan the QR code shown on this device with your device that's signed out.": "Ви можете використовувати цей пристрій для входу на новому пристрої за допомогою QR-коду. Вам потрібно буде сканувати QR-код, показаний на цьому пристрої, своїм пристроєм, на якому ви вийшли." } diff --git a/src/i18n/strings/zh_Hant.json b/src/i18n/strings/zh_Hant.json index 67a38c5d99..68647b9485 100644 --- a/src/i18n/strings/zh_Hant.json +++ b/src/i18n/strings/zh_Hant.json @@ -3588,5 +3588,49 @@ "Enable notifications for this account": "為此帳號啟用通知", "%(selectedDeviceCount)s sessions selected": "已選取 %(selectedDeviceCount)s 個工作階段", "Video call ended": "視訊通話已結束", - "%(name)s started a video call": "%(name)s 開始了視訊通話" + "%(name)s started a video call": "%(name)s 開始了視訊通話", + "URL": "URL", + "Version": "版本", + "Application": "應用程式", + "Record the client name, version, and url to recognise sessions more easily in session manager": "記錄客戶端名稱、版本與 URL,以便在工作階段管理程式更輕鬆地識別工作階段", + "Unknown session type": "未知工作階段類型", + "Web session": "網頁工作階段", + "Mobile session": "行動裝置工作階段", + "Desktop session": "桌面工作階段", + "Video call started": "視訊通話已開始", + "Unknown room": "未知的聊天室", + "Video call started in %(roomName)s. (not supported by this browser)": "視訊通話在 %(roomName)s 開始。(此瀏覽器不支援)", + "Video call started in %(roomName)s.": "視訊通話在 %(roomName)s 開始。", + "Room info": "聊天室資訊", + "View chat timeline": "檢視聊天時間軸", + "Close call": "關閉通話", + "Layout type": "佈局類型", + "Spotlight": "聚焦", + "Freedom": "自由", + "Video call (%(brand)s)": "視訊通話 (%(brand)s)", + "Operating system": "作業系統", + "Model": "模型", + "Client": "客戶端", + "Call type": "通話類型", + "You do not have sufficient permissions to change this.": "您沒有足夠的權限來變更此設定。", + "%(brand)s is end-to-end encrypted, but is currently limited to smaller numbers of users.": "%(brand)s 是端到端加密的,但目前僅限於少數使用者。", + "Enable %(brand)s as an additional calling option in this room": "啟用 %(brand)s 作為此聊天室的額外通話選項", + "Join %(brand)s calls": "加入 %(brand)s 通話", + "Start %(brand)s calls": "開始 %(brand)s 通話", + "Fill screen": "填滿螢幕", + "Sorry — this call is currently full": "抱歉 — 此通話目前已滿", + "Wysiwyg composer (plain text mode coming soon) (under active development)": "所見即所得編輯器(純文字模式即將推出)(正在積極開發中)", + "Our new sessions manager provides better visibility of all your sessions, and greater control over them including the ability to remotely toggle push notifications.": "我們的新工作階段管理程式可讓您更好地了解您的所有工作階段,並更好地控制它們,包含遠端切換推播通知的能力。", + "Have greater visibility and control over all your sessions.": "對您所有的工作階段有更大的能見度與控制。", + "New session manager": "新的工作階段管理程式", + "Use new session manager": "使用新的工作階段管理程式", + "Sign out all other sessions": "登出其他所有工作階段", + "Underline": "底線", + "Italic": "義式斜體", + "You have already joined this call from another device": "您已從另一台裝置加入了此通話", + "Try out the rich text editor (plain text mode coming soon)": "試用格式化文字編輯器(純文字模式即將推出)", + "stop voice broadcast": "停止語音廣播", + "resume voice broadcast": "恢復語音廣播", + "pause voice broadcast": "暫停語音廣播", + "Notifications silenced": "通知已靜音" } diff --git a/src/models/Call.ts b/src/models/Call.ts index fd207cf1be..ed9e227d24 100644 --- a/src/models/Call.ts +++ b/src/models/Call.ts @@ -43,6 +43,8 @@ import { WidgetMessagingStore, WidgetMessagingStoreEvent } from "../stores/widge import ActiveWidgetStore, { ActiveWidgetStoreEvent } from "../stores/ActiveWidgetStore"; import PlatformPeg from "../PlatformPeg"; import { getCurrentLanguage } from "../languageHandler"; +import DesktopCapturerSourcePicker from "../components/views/elements/DesktopCapturerSourcePicker"; +import Modal from "../Modal"; const TIMEOUT_MS = 16000; @@ -639,10 +641,6 @@ export class ElementCall extends Call { baseUrl: client.baseUrl, lang: getCurrentLanguage().replace("_", "-"), }); - // Currently, the screen-sharing support is the same is it is for Jitsi - if (!PlatformPeg.get().supportsJitsiScreensharing()) { - params.append("hideScreensharing", ""); - } url.hash = `#?${params.toString()}`; // To use Element Call without touching room state, we create a virtual @@ -818,6 +816,7 @@ export class ElementCall extends Call { this.messaging!.on(`action:${ElementWidgetActions.HangupCall}`, this.onHangup); this.messaging!.on(`action:${ElementWidgetActions.TileLayout}`, this.onTileLayout); this.messaging!.on(`action:${ElementWidgetActions.SpotlightLayout}`, this.onSpotlightLayout); + this.messaging!.on(`action:${ElementWidgetActions.Screenshare}`, this.onScreenshare); } protected async performDisconnection(): Promise { @@ -831,8 +830,9 @@ export class ElementCall extends Call { public setDisconnected() { this.client.off(ClientEvent.ToDeviceEvent, this.onToDeviceEvent); this.messaging!.off(`action:${ElementWidgetActions.HangupCall}`, this.onHangup); - this.messaging!.on(`action:${ElementWidgetActions.TileLayout}`, this.onTileLayout); - this.messaging!.on(`action:${ElementWidgetActions.SpotlightLayout}`, this.onSpotlightLayout); + this.messaging!.off(`action:${ElementWidgetActions.TileLayout}`, this.onTileLayout); + this.messaging!.off(`action:${ElementWidgetActions.SpotlightLayout}`, this.onSpotlightLayout); + this.messaging!.off(`action:${ElementWidgetActions.Screenshare}`, this.onSpotlightLayout); super.setDisconnected(); } @@ -951,4 +951,20 @@ export class ElementCall extends Call { this.layout = Layout.Spotlight; await this.messaging!.transport.reply(ev.detail, {}); // ack }; + + private onScreenshare = async (ev: CustomEvent) => { + ev.preventDefault(); + + if (PlatformPeg.get().supportsDesktopCapturer()) { + const { finished } = Modal.createDialog(DesktopCapturerSourcePicker); + const [source] = await finished; + + await this.messaging!.transport.reply(ev.detail, { + failed: !source, + desktopCapturerSourceId: source, + }); + } else { + await this.messaging!.transport.reply(ev.detail, {}); + } + }; } diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index 9b6e09c772..723b789ab0 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -494,6 +494,16 @@ export const SETTINGS: {[setting: string]: ISetting} = { , }, }, + "feature_qr_signin_reciprocate_show": { + isFeature: true, + labsGroup: LabGroup.Experimental, + supportedLevels: LEVELS_FEATURE, + displayName: _td( + "Allow a QR code to be shown in session manager to sign in another device " + + "(requires compatible homeserver)", + ), + default: false, + }, "baseFontSize": { displayName: _td("Font size"), supportedLevels: LEVELS_ACCOUNT_SETTINGS, diff --git a/src/stores/RoomViewStore.tsx b/src/stores/RoomViewStore.tsx index 0a15ce1860..b3814f7a32 100644 --- a/src/stores/RoomViewStore.tsx +++ b/src/stores/RoomViewStore.tsx @@ -17,6 +17,7 @@ limitations under the License. */ import React, { ReactNode } from "react"; +import * as utils from 'matrix-js-sdk/src/utils'; import { MatrixError } from "matrix-js-sdk/src/http-api"; import { logger } from "matrix-js-sdk/src/logger"; import { ViewRoom as ViewRoomEvent } from "@matrix-org/analytics-events/types/typescript/ViewRoom"; @@ -27,7 +28,7 @@ import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { Optional } from "matrix-events-sdk"; import EventEmitter from "events"; -import { defaultDispatcher, MatrixDispatcher } from '../dispatcher/dispatcher'; +import { MatrixDispatcher } from '../dispatcher/dispatcher'; import { MatrixClientPeg } from '../MatrixClientPeg'; import Modal from '../Modal'; import { _t } from '../languageHandler'; @@ -35,10 +36,8 @@ import { getCachedRoomIDForAlias, storeRoomAliasInCache } from '../RoomAliasCach import { Action } from "../dispatcher/actions"; import { retry } from "../utils/promise"; import { TimelineRenderingType } from "../contexts/RoomContext"; -import { PosthogAnalytics } from "../PosthogAnalytics"; import { ViewRoomPayload } from "../dispatcher/payloads/ViewRoomPayload"; import DMRoomMap from "../utils/DMRoomMap"; -import SpaceStore from "./spaces/SpaceStore"; import { isMetaSpace, MetaSpace } from "./spaces"; import { JoinRoomPayload } from "../dispatcher/payloads/JoinRoomPayload"; import { JoinRoomReadyPayload } from "../dispatcher/payloads/JoinRoomReadyPayload"; @@ -47,9 +46,9 @@ import { ViewRoomErrorPayload } from "../dispatcher/payloads/ViewRoomErrorPayloa import ErrorDialog from "../components/views/dialogs/ErrorDialog"; import { ActiveRoomChangedPayload } from "../dispatcher/payloads/ActiveRoomChangedPayload"; import SettingsStore from "../settings/SettingsStore"; -import { SlidingSyncManager } from "../SlidingSyncManager"; import { awaitRoomDownSync } from "../utils/RoomUpgrade"; import { UPDATE_EVENT } from "./AsyncStore"; +import { SdkContextClass } from "../contexts/SDKContext"; import { CallStore } from "./CallStore"; const NUM_JOIN_RETRY = 5; @@ -131,17 +130,16 @@ type Listener = (isActive: boolean) => void; * A class for storing application state for RoomView. */ export class RoomViewStore extends EventEmitter { - // Important: This cannot be a dynamic getter (lazily-constructed instance) because - // otherwise we'll miss view_room dispatches during startup, breaking relaunches of - // the app. We need to eagerly create the instance. - public static readonly instance = new RoomViewStore(defaultDispatcher); - - private state: State = INITIAL_STATE; // initialize state + // initialize state as a copy of the initial state. We need to copy else one RVS can talk to + // another RVS via INITIAL_STATE as they share the same underlying object. Mostly relevant for tests. + private state = utils.deepCopy(INITIAL_STATE); private dis: MatrixDispatcher; private dispatchToken: string; - public constructor(dis: MatrixDispatcher) { + public constructor( + dis: MatrixDispatcher, private readonly stores: SdkContextClass, + ) { super(); this.resetDispatcher(dis); } @@ -248,7 +246,7 @@ export class RoomViewStore extends EventEmitter { : numMembers > 1 ? "Two" : "One"; - PosthogAnalytics.instance.trackEvent({ + this.stores.posthogAnalytics.trackEvent({ eventName: "JoinedRoom", trigger: payload.metricsTrigger, roomSize, @@ -291,17 +289,17 @@ export class RoomViewStore extends EventEmitter { if (payload.metricsTrigger !== null && payload.room_id !== this.state.roomId) { let activeSpace: ViewRoomEvent["activeSpace"]; - if (SpaceStore.instance.activeSpace === MetaSpace.Home) { + if (this.stores.spaceStore.activeSpace === MetaSpace.Home) { activeSpace = "Home"; - } else if (isMetaSpace(SpaceStore.instance.activeSpace)) { + } else if (isMetaSpace(this.stores.spaceStore.activeSpace)) { activeSpace = "Meta"; } else { - activeSpace = SpaceStore.instance.activeSpaceRoom.getJoinRule() === JoinRule.Public + activeSpace = this.stores.spaceStore.activeSpaceRoom?.getJoinRule() === JoinRule.Public ? "Public" : "Private"; } - PosthogAnalytics.instance.trackEvent({ + this.stores.posthogAnalytics.trackEvent({ eventName: "ViewRoom", trigger: payload.metricsTrigger, viaKeyboard: payload.metricsViaKeyboard, @@ -314,7 +312,7 @@ export class RoomViewStore extends EventEmitter { if (SettingsStore.getValue("feature_sliding_sync") && this.state.roomId !== payload.room_id) { if (this.state.subscribingRoomId && this.state.subscribingRoomId !== payload.room_id) { // unsubscribe from this room, but don't await it as we don't care when this gets done. - SlidingSyncManager.instance.setRoomVisible(this.state.subscribingRoomId, false); + this.stores.slidingSyncManager.setRoomVisible(this.state.subscribingRoomId, false); } this.setState({ subscribingRoomId: payload.room_id, @@ -332,11 +330,11 @@ export class RoomViewStore extends EventEmitter { }); // set this room as the room subscription. We need to await for it as this will fetch // all room state for this room, which is required before we get the state below. - await SlidingSyncManager.instance.setRoomVisible(payload.room_id, true); + await this.stores.slidingSyncManager.setRoomVisible(payload.room_id, true); // Whilst we were subscribing another room was viewed, so stop what we're doing and // unsubscribe if (this.state.subscribingRoomId !== payload.room_id) { - SlidingSyncManager.instance.setRoomVisible(payload.room_id, false); + this.stores.slidingSyncManager.setRoomVisible(payload.room_id, false); return; } // Re-fire the payload: we won't re-process it because the prev room ID == payload room ID now @@ -599,7 +597,7 @@ export class RoomViewStore extends EventEmitter { // // Not joined // } // } else { - // if (RoomViewStore.instance.isJoining()) { + // if (this.stores.roomViewStore.isJoining()) { // // show spinner // } else { // // show join prompt diff --git a/src/stores/TypingStore.ts b/src/stores/TypingStore.ts index d642f3fea7..be17da6e4e 100644 --- a/src/stores/TypingStore.ts +++ b/src/stores/TypingStore.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { MatrixClientPeg } from "../MatrixClientPeg"; +import { SdkContextClass } from "../contexts/SDKContext"; import SettingsStore from "../settings/SettingsStore"; import { isLocalRoom } from "../utils/localRoom/isLocalRoom"; import Timer from "../utils/Timer"; @@ -34,17 +34,10 @@ export default class TypingStore { }; }; - constructor() { + constructor(private readonly context: SdkContextClass) { this.reset(); } - public static sharedInstance(): TypingStore { - if (window.mxTypingStore === undefined) { - window.mxTypingStore = new TypingStore(); - } - return window.mxTypingStore; - } - /** * Clears all cached typing states. Intended to be called when the * MatrixClientPeg client changes. @@ -108,6 +101,6 @@ export default class TypingStore { } else currentTyping.userTimer.restart(); } - MatrixClientPeg.get().sendTyping(roomId, isTyping, TYPING_SERVER_TIMEOUT); + this.context.client?.sendTyping(roomId, isTyping, TYPING_SERVER_TIMEOUT); } } diff --git a/src/stores/notifications/RoomNotificationState.ts b/src/stores/notifications/RoomNotificationState.ts index c4c803483d..9c64b7ec42 100644 --- a/src/stores/notifications/RoomNotificationState.ts +++ b/src/stores/notifications/RoomNotificationState.ts @@ -32,16 +32,15 @@ import { ThreadsRoomNotificationState } from "./ThreadsRoomNotificationState"; export class RoomNotificationState extends NotificationState implements IDestroyable { constructor(public readonly room: Room, private readonly threadsState?: ThreadsRoomNotificationState) { super(); - this.room.on(RoomEvent.Receipt, this.handleReadReceipt); - this.room.on(RoomEvent.Timeline, this.handleRoomEventUpdate); - this.room.on(RoomEvent.Redaction, this.handleRoomEventUpdate); - this.room.on(RoomEvent.MyMembership, this.handleMembershipUpdate); - this.room.on(RoomEvent.LocalEchoUpdated, this.handleLocalEchoUpdated); + this.room.on(RoomEvent.Receipt, this.handleReadReceipt); // for unread indicators + this.room.on(RoomEvent.MyMembership, this.handleMembershipUpdate); // for redness on invites + this.room.on(RoomEvent.LocalEchoUpdated, this.handleLocalEchoUpdated); // for redness on unsent messages + this.room.on(RoomEvent.UnreadNotifications, this.handleNotificationCountUpdate); // for server-sent counts if (threadsState) { threadsState.on(NotificationStateEvents.Update, this.handleThreadsUpdate); } - MatrixClientPeg.get().on(MatrixEventEvent.Decrypted, this.onEventDecrypted); - MatrixClientPeg.get().on(ClientEvent.AccountData, this.handleAccountDataUpdate); + MatrixClientPeg.get().on(MatrixEventEvent.Decrypted, this.onEventDecrypted); // for local count calculation + MatrixClientPeg.get().on(ClientEvent.AccountData, this.handleAccountDataUpdate); // for push rules this.updateNotificationState(); } @@ -52,10 +51,9 @@ export class RoomNotificationState extends NotificationState implements IDestroy public destroy(): void { super.destroy(); this.room.removeListener(RoomEvent.Receipt, this.handleReadReceipt); - this.room.removeListener(RoomEvent.Timeline, this.handleRoomEventUpdate); - this.room.removeListener(RoomEvent.Redaction, this.handleRoomEventUpdate); this.room.removeListener(RoomEvent.MyMembership, this.handleMembershipUpdate); this.room.removeListener(RoomEvent.LocalEchoUpdated, this.handleLocalEchoUpdated); + this.room.removeListener(RoomEvent.UnreadNotifications, this.handleNotificationCountUpdate); if (this.threadsState) { this.threadsState.removeListener(NotificationStateEvents.Update, this.handleThreadsUpdate); } @@ -83,14 +81,12 @@ export class RoomNotificationState extends NotificationState implements IDestroy this.updateNotificationState(); }; - private onEventDecrypted = (event: MatrixEvent) => { - if (event.getRoomId() !== this.room.roomId) return; // ignore - not for us or notifications timeline - + private handleNotificationCountUpdate = () => { this.updateNotificationState(); }; - private handleRoomEventUpdate = (event: MatrixEvent, room: Room | null) => { - if (room?.roomId !== this.room.roomId) return; // ignore - not for us or notifications timeline + private onEventDecrypted = (event: MatrixEvent) => { + if (event.getRoomId() !== this.room.roomId) return; // ignore - not for us or notifications timeline this.updateNotificationState(); }; diff --git a/src/stores/right-panel/RightPanelStore.ts b/src/stores/right-panel/RightPanelStore.ts index 327f82153f..9aa4c1b27c 100644 --- a/src/stores/right-panel/RightPanelStore.ts +++ b/src/stores/right-panel/RightPanelStore.ts @@ -34,7 +34,7 @@ import { import { ActionPayload } from "../../dispatcher/payloads"; import { Action } from "../../dispatcher/actions"; import { ActiveRoomChangedPayload } from "../../dispatcher/payloads/ActiveRoomChangedPayload"; -import { RoomViewStore } from "../RoomViewStore"; +import { SdkContextClass } from "../../contexts/SDKContext"; /** * A class for tracking the state of the right panel between layouts and @@ -64,7 +64,7 @@ export default class RightPanelStore extends ReadyWatchingStore { } protected async onReady(): Promise { - this.viewedRoomId = RoomViewStore.instance.getRoomId(); + this.viewedRoomId = SdkContextClass.instance.roomViewStore.getRoomId(); this.matrixClient.on(CryptoEvent.VerificationRequest, this.onVerificationRequestUpdate); this.loadCacheFromSettings(); this.emitAndUpdateSettings(); diff --git a/src/stores/room-list/RoomListStore.ts b/src/stores/room-list/RoomListStore.ts index 83c79a16a9..d6f9de79c3 100644 --- a/src/stores/room-list/RoomListStore.ts +++ b/src/stores/room-list/RoomListStore.ts @@ -27,7 +27,6 @@ import { ActionPayload } from "../../dispatcher/payloads"; import defaultDispatcher from "../../dispatcher/dispatcher"; import { readReceiptChangeIsFor } from "../../utils/read-receipts"; import { FILTER_CHANGED, IFilterCondition } from "./filters/IFilterCondition"; -import { RoomViewStore } from "../RoomViewStore"; import { Algorithm, LIST_UPDATED_EVENT } from "./algorithms/Algorithm"; import { EffectiveMembership, getEffectiveMembership } from "../../utils/membership"; import RoomListLayoutStore from "./RoomListLayoutStore"; @@ -40,6 +39,7 @@ import { IRoomTimelineActionPayload } from "../../actions/MatrixActionCreators"; import { RoomListStore as Interface, RoomListStoreEvent } from "./Interface"; import { SlidingRoomListStoreClass } from "./SlidingRoomListStore"; import { UPDATE_EVENT } from "../AsyncStore"; +import { SdkContextClass } from "../../contexts/SDKContext"; interface IState { // state is tracked in underlying classes @@ -105,7 +105,7 @@ export class RoomListStoreClass extends AsyncStoreWithClient implements this.readyStore.useUnitTestClient(forcedClient); } - RoomViewStore.instance.addListener(UPDATE_EVENT, () => this.handleRVSUpdate({})); + SdkContextClass.instance.roomViewStore.addListener(UPDATE_EVENT, () => this.handleRVSUpdate({})); this.algorithm.on(LIST_UPDATED_EVENT, this.onAlgorithmListUpdated); this.algorithm.on(FILTER_CHANGED, this.onAlgorithmFilterUpdated); this.setupWatchers(); @@ -128,7 +128,7 @@ export class RoomListStoreClass extends AsyncStoreWithClient implements private handleRVSUpdate({ trigger = true }) { if (!this.matrixClient) return; // We assume there won't be RVS updates without a client - const activeRoomId = RoomViewStore.instance.getRoomId(); + const activeRoomId = SdkContextClass.instance.roomViewStore.getRoomId(); if (!activeRoomId && this.algorithm.stickyRoom) { this.algorithm.setStickyRoom(null); } else if (activeRoomId) { diff --git a/src/stores/room-list/SlidingRoomListStore.ts b/src/stores/room-list/SlidingRoomListStore.ts index 3d532fe0c9..35550d04f1 100644 --- a/src/stores/room-list/SlidingRoomListStore.ts +++ b/src/stores/room-list/SlidingRoomListStore.ts @@ -29,8 +29,8 @@ import { SlidingSyncManager } from "../../SlidingSyncManager"; import SpaceStore from "../spaces/SpaceStore"; import { MetaSpace, SpaceKey, UPDATE_SELECTED_SPACE } from "../spaces"; import { LISTS_LOADING_EVENT } from "./RoomListStore"; -import { RoomViewStore } from "../RoomViewStore"; import { UPDATE_EVENT } from "../AsyncStore"; +import { SdkContextClass } from "../../contexts/SDKContext"; interface IState { // state is tracked in underlying classes @@ -207,7 +207,7 @@ export class SlidingRoomListStoreClass extends AsyncStoreWithClient impl // this room will not move due to it being viewed: it is sticky. This can be null to indicate // no sticky room if you aren't viewing a room. - this.stickyRoomId = RoomViewStore.instance.getRoomId(); + this.stickyRoomId = SdkContextClass.instance.roomViewStore.getRoomId(); let stickyRoomNewIndex = -1; const stickyRoomOldIndex = (tagMap[tagId] || []).findIndex((room) => { return room.roomId === this.stickyRoomId; @@ -273,7 +273,7 @@ export class SlidingRoomListStoreClass extends AsyncStoreWithClient impl private onRoomViewStoreUpdated() { // we only care about this to know when the user has clicked on a room to set the stickiness value - if (RoomViewStore.instance.getRoomId() === this.stickyRoomId) { + if (SdkContextClass.instance.roomViewStore.getRoomId() === this.stickyRoomId) { return; } @@ -303,7 +303,7 @@ export class SlidingRoomListStoreClass extends AsyncStoreWithClient impl } } // in the event we didn't call refreshOrderedLists, it helps to still remember the sticky room ID. - this.stickyRoomId = RoomViewStore.instance.getRoomId(); + this.stickyRoomId = SdkContextClass.instance.roomViewStore.getRoomId(); if (hasUpdatedAnyList) { this.emit(LISTS_UPDATE_EVENT); @@ -314,7 +314,7 @@ export class SlidingRoomListStoreClass extends AsyncStoreWithClient impl logger.info("SlidingRoomListStore.onReady"); // permanent listeners: never get destroyed. Could be an issue if we want to test this in isolation. SlidingSyncManager.instance.slidingSync.on(SlidingSyncEvent.List, this.onSlidingSyncListUpdate.bind(this)); - RoomViewStore.instance.addListener(UPDATE_EVENT, this.onRoomViewStoreUpdated.bind(this)); + SdkContextClass.instance.roomViewStore.addListener(UPDATE_EVENT, this.onRoomViewStoreUpdated.bind(this)); SpaceStore.instance.on(UPDATE_SELECTED_SPACE, this.onSelectedSpaceUpdated.bind(this)); if (SpaceStore.instance.activeSpace) { this.onSelectedSpaceUpdated(SpaceStore.instance.activeSpace, false); diff --git a/src/stores/spaces/SpaceStore.ts b/src/stores/spaces/SpaceStore.ts index ce86b6ec0f..f4802a1520 100644 --- a/src/stores/spaces/SpaceStore.ts +++ b/src/stores/spaces/SpaceStore.ts @@ -34,7 +34,6 @@ import { RoomNotificationStateStore } from "../notifications/RoomNotificationSta import { DefaultTagID } from "../room-list/models"; import { EnhancedMap, mapDiff } from "../../utils/maps"; import { setDiff, setHasDiff } from "../../utils/sets"; -import { RoomViewStore } from "../RoomViewStore"; import { Action } from "../../dispatcher/actions"; import { arrayHasDiff, arrayHasOrderChange } from "../../utils/arrays"; import { reorderLexicographically } from "../../utils/stringOrderField"; @@ -64,6 +63,7 @@ import { ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload"; import { ViewHomePagePayload } from "../../dispatcher/payloads/ViewHomePagePayload"; import { SwitchSpacePayload } from "../../dispatcher/payloads/SwitchSpacePayload"; import { AfterLeaveRoomPayload } from "../../dispatcher/payloads/AfterLeaveRoomPayload"; +import { SdkContextClass } from "../../contexts/SDKContext"; interface IState { } @@ -797,7 +797,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { this.updateNotificationStates(notificationStatesToUpdate); }; - private switchSpaceIfNeeded = (roomId = RoomViewStore.instance.getRoomId()) => { + private switchSpaceIfNeeded = (roomId = SdkContextClass.instance.roomViewStore.getRoomId()) => { if (!this.isRoomInSpace(this.activeSpace, roomId) && !this.matrixClient.getRoom(roomId)?.isSpaceRoom()) { this.switchToRelatedSpace(roomId); } @@ -848,7 +848,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { } // if the room currently being viewed was just joined then switch to its related space - if (newMembership === "join" && room.roomId === RoomViewStore.instance.getRoomId()) { + if (newMembership === "join" && room.roomId === SdkContextClass.instance.roomViewStore.getRoomId()) { this.switchSpaceIfNeeded(room.roomId); } } @@ -875,7 +875,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { this.emit(room.roomId); } - if (membership === "join" && room.roomId === RoomViewStore.instance.getRoomId()) { + if (membership === "join" && room.roomId === SdkContextClass.instance.roomViewStore.getRoomId()) { // if the user was looking at the space and then joined: select that space this.setActiveSpace(room.roomId, false); } else if (membership === "leave" && room.roomId === this.activeSpace) { diff --git a/src/stores/widgets/ElementWidgetActions.ts b/src/stores/widgets/ElementWidgetActions.ts index 5e9451efa0..fa60b9ea82 100644 --- a/src/stores/widgets/ElementWidgetActions.ts +++ b/src/stores/widgets/ElementWidgetActions.ts @@ -29,6 +29,7 @@ export enum ElementWidgetActions { // Actions for switching layouts TileLayout = "io.element.tile_layout", SpotlightLayout = "io.element.spotlight_layout", + Screenshare = "io.element.screenshare", OpenIntegrationManager = "integration_manager_open", diff --git a/src/stores/widgets/StopGapWidget.ts b/src/stores/widgets/StopGapWidget.ts index 91a262fdca..aa1ad2c393 100644 --- a/src/stores/widgets/StopGapWidget.ts +++ b/src/stores/widgets/StopGapWidget.ts @@ -41,7 +41,6 @@ import { ClientEvent } from "matrix-js-sdk/src/client"; import { _t } from "../../languageHandler"; import { StopGapWidgetDriver } from "./StopGapWidgetDriver"; import { WidgetMessagingStore } from "./WidgetMessagingStore"; -import { RoomViewStore } from "../RoomViewStore"; import { MatrixClientPeg } from "../../MatrixClientPeg"; import { OwnProfileStore } from "../OwnProfileStore"; import WidgetUtils from '../../utils/WidgetUtils'; @@ -65,6 +64,7 @@ import { arrayFastClone } from "../../utils/arrays"; import { ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload"; import Modal from "../../Modal"; import ErrorDialog from "../../components/views/dialogs/ErrorDialog"; +import { SdkContextClass } from "../../contexts/SDKContext"; import { VoiceBroadcastRecordingsStore } from "../../voice-broadcast"; // TODO: Destroy all of this code @@ -185,7 +185,7 @@ export class StopGapWidget extends EventEmitter { if (this.roomId) return this.roomId; - return RoomViewStore.instance.getRoomId(); + return SdkContextClass.instance.roomViewStore.getRoomId(); } public get widgetApi(): ClientWidgetApi { @@ -381,7 +381,7 @@ export class StopGapWidget extends EventEmitter { // noinspection JSIgnoredPromiseFromCall IntegrationManagers.sharedInstance().getPrimaryManager().open( - this.client.getRoom(RoomViewStore.instance.getRoomId()), + this.client.getRoom(SdkContextClass.instance.roomViewStore.getRoomId()), `type_${integType}`, integId, ); diff --git a/src/stores/widgets/StopGapWidgetDriver.ts b/src/stores/widgets/StopGapWidgetDriver.ts index 752d6d57e6..ff2619ad59 100644 --- a/src/stores/widgets/StopGapWidgetDriver.ts +++ b/src/stores/widgets/StopGapWidgetDriver.ts @@ -47,15 +47,15 @@ import Modal from "../../Modal"; import WidgetOpenIDPermissionsDialog from "../../components/views/dialogs/WidgetOpenIDPermissionsDialog"; import WidgetCapabilitiesPromptDialog from "../../components/views/dialogs/WidgetCapabilitiesPromptDialog"; import { WidgetPermissionCustomisations } from "../../customisations/WidgetPermissions"; -import { OIDCState, WidgetPermissionStore } from "./WidgetPermissionStore"; +import { OIDCState } from "./WidgetPermissionStore"; import { WidgetType } from "../../widgets/WidgetType"; import { CHAT_EFFECTS } from "../../effects"; import { containsEmoji } from "../../effects/utils"; import dis from "../../dispatcher/dispatcher"; import SettingsStore from "../../settings/SettingsStore"; -import { RoomViewStore } from "../RoomViewStore"; import { ElementWidgetCapabilities } from "./ElementWidgetCapabilities"; import { navigateToPermalink } from "../../utils/permalinks/navigator"; +import { SdkContextClass } from "../../contexts/SDKContext"; // TODO: Purge this from the universe @@ -210,7 +210,7 @@ export class StopGapWidgetDriver extends WidgetDriver { targetRoomId: string = null, ): Promise { const client = MatrixClientPeg.get(); - const roomId = targetRoomId || RoomViewStore.instance.getRoomId(); + const roomId = targetRoomId || SdkContextClass.instance.roomViewStore.getRoomId(); if (!client || !roomId) throw new Error("Not in a room or not attached to a client"); @@ -291,7 +291,7 @@ export class StopGapWidgetDriver extends WidgetDriver { const targetRooms = roomIds ? (roomIds.includes(Symbols.AnyRoom) ? client.getVisibleRooms() : roomIds.map(r => client.getRoom(r))) - : [client.getRoom(RoomViewStore.instance.getRoomId())]; + : [client.getRoom(SdkContextClass.instance.roomViewStore.getRoomId())]; return targetRooms.filter(r => !!r); } @@ -350,7 +350,7 @@ export class StopGapWidgetDriver extends WidgetDriver { } public async askOpenID(observer: SimpleObservable) { - const oidcState = WidgetPermissionStore.instance.getOIDCState( + const oidcState = SdkContextClass.instance.widgetPermissionStore.getOIDCState( this.forWidget, this.forWidgetKind, this.inRoomId, ); @@ -430,7 +430,7 @@ export class StopGapWidgetDriver extends WidgetDriver { ): Promise { const client = MatrixClientPeg.get(); const dir = direction as Direction; - roomId = roomId ?? RoomViewStore.instance.getRoomId() ?? undefined; + roomId = roomId ?? SdkContextClass.instance.roomViewStore.getRoomId() ?? undefined; if (typeof roomId !== "string") { throw new Error('Error while reading the current room'); diff --git a/src/stores/widgets/WidgetPermissionStore.ts b/src/stores/widgets/WidgetPermissionStore.ts index 246492333c..fca018ca5c 100644 --- a/src/stores/widgets/WidgetPermissionStore.ts +++ b/src/stores/widgets/WidgetPermissionStore.ts @@ -17,8 +17,8 @@ import { Widget, WidgetKind } from "matrix-widget-api"; import SettingsStore from "../../settings/SettingsStore"; -import { MatrixClientPeg } from "../../MatrixClientPeg"; import { SettingLevel } from "../../settings/SettingLevel"; +import { SdkContextClass } from "../../contexts/SDKContext"; export enum OIDCState { Allowed, // user has set the remembered value as allowed @@ -27,16 +27,7 @@ export enum OIDCState { } export class WidgetPermissionStore { - private static internalInstance: WidgetPermissionStore; - - private constructor() { - } - - public static get instance(): WidgetPermissionStore { - if (!WidgetPermissionStore.internalInstance) { - WidgetPermissionStore.internalInstance = new WidgetPermissionStore(); - } - return WidgetPermissionStore.internalInstance; + public constructor(private readonly context: SdkContextClass) { } // TODO (all functions here): Merge widgetKind with the widget definition @@ -44,7 +35,7 @@ export class WidgetPermissionStore { private packSettingKey(widget: Widget, kind: WidgetKind, roomId?: string): string { let location = roomId; if (kind !== WidgetKind.Room) { - location = MatrixClientPeg.get().getUserId(); + location = this.context.client?.getUserId(); } if (kind === WidgetKind.Modal) { location = '*MODAL*-' + location; // to guarantee differentiation from whatever spawned it @@ -71,7 +62,10 @@ export class WidgetPermissionStore { public setOIDCState(widget: Widget, kind: WidgetKind, roomId: string, newState: OIDCState) { const settingsKey = this.packSettingKey(widget, kind, roomId); - const currentValues = SettingsStore.getValue("widgetOpenIDPermissions"); + let currentValues = SettingsStore.getValue("widgetOpenIDPermissions"); + if (!currentValues) { + currentValues = {}; + } if (!currentValues.allow) currentValues.allow = []; if (!currentValues.deny) currentValues.deny = []; diff --git a/src/utils/DialogOpener.ts b/src/utils/DialogOpener.ts index 0e5a3d2b11..82d16962b2 100644 --- a/src/utils/DialogOpener.ts +++ b/src/utils/DialogOpener.ts @@ -20,7 +20,6 @@ import defaultDispatcher from "../dispatcher/dispatcher"; import { ActionPayload } from "../dispatcher/payloads"; import Modal from "../Modal"; import RoomSettingsDialog from "../components/views/dialogs/RoomSettingsDialog"; -import { RoomViewStore } from "../stores/RoomViewStore"; import ForwardDialog from "../components/views/dialogs/ForwardDialog"; import { MatrixClientPeg } from "../MatrixClientPeg"; import { Action } from "../dispatcher/actions"; @@ -32,6 +31,7 @@ import AddExistingToSpaceDialog from "../components/views/dialogs/AddExistingToS import { ButtonEvent } from "../components/views/elements/AccessibleButton"; import PosthogTrackers from "../PosthogTrackers"; import { showAddExistingSubspace, showCreateNewRoom } from "./space"; +import { SdkContextClass } from "../contexts/SDKContext"; /** * Auxiliary class to listen for dialog opening over the dispatcher and @@ -58,7 +58,7 @@ export class DialogOpener { switch (payload.action) { case 'open_room_settings': Modal.createDialog(RoomSettingsDialog, { - roomId: payload.room_id || RoomViewStore.instance.getRoomId(), + roomId: payload.room_id || SdkContextClass.instance.roomViewStore.getRoomId(), initialTabId: payload.initial_tab_id, }, /*className=*/null, /*isPriority=*/false, /*isStatic=*/true); break; @@ -108,7 +108,7 @@ export class DialogOpener { onAddSubspaceClick: () => showAddExistingSubspace(space), space, onFinished: (added: boolean) => { - if (added && RoomViewStore.instance.getRoomId() === space.roomId) { + if (added && SdkContextClass.instance.roomViewStore.getRoomId() === space.roomId) { defaultDispatcher.fire(Action.UpdateSpaceHierarchy); } }, diff --git a/src/utils/ErrorUtils.tsx b/src/utils/ErrorUtils.tsx index 6af61aca2e..ff78fe076c 100644 --- a/src/utils/ErrorUtils.tsx +++ b/src/utils/ErrorUtils.tsx @@ -57,8 +57,8 @@ export function messageForResourceLimitError( } } -export function messageForSyncError(err: MatrixError): ReactNode { - if (err.errcode === 'M_RESOURCE_LIMIT_EXCEEDED') { +export function messageForSyncError(err: Error): ReactNode { + if (err instanceof MatrixError && err.errcode === 'M_RESOURCE_LIMIT_EXCEEDED') { const limitError = messageForResourceLimitError( err.data.limit_type, err.data.admin_contact, diff --git a/src/utils/UserInteractiveAuth.ts b/src/utils/UserInteractiveAuth.ts new file mode 100644 index 0000000000..e3088fb3cb --- /dev/null +++ b/src/utils/UserInteractiveAuth.ts @@ -0,0 +1,55 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { IAuthData } from "matrix-js-sdk/src/interactive-auth"; +import { UIAResponse } from "matrix-js-sdk/src/@types/uia"; + +import Modal from "../Modal"; +import InteractiveAuthDialog, { InteractiveAuthDialogProps } from "../components/views/dialogs/InteractiveAuthDialog"; + +type FunctionWithUIA = (auth?: IAuthData, ...args: A[]) => Promise>; + +export function wrapRequestWithDialog( + requestFunction: FunctionWithUIA, + opts: Omit, +): ((...args: A[]) => Promise) { + return async function(...args): Promise { + return new Promise((resolve, reject) => { + const boundFunction = requestFunction.bind(opts.matrixClient) as FunctionWithUIA; + boundFunction(undefined, ...args) + .then((res) => resolve(res as R)) + .catch(error => { + if (error.httpStatus !== 401 || !error.data?.flows) { + // doesn't look like an interactive-auth failure + return reject(error); + } + + Modal.createDialog(InteractiveAuthDialog, { + ...opts, + authData: error.data, + makeRequest: (authData) => boundFunction(authData, ...args), + onFinished: (success, result) => { + if (success) { + resolve(result); + } else { + reject(result); + } + }, + }); + }); + }); + }; +} diff --git a/src/utils/exportUtils/Exporter.ts b/src/utils/exportUtils/Exporter.ts index 7f7f57f92e..ec20f395e3 100644 --- a/src/utils/exportUtils/Exporter.ts +++ b/src/utils/exportUtils/Exporter.ts @@ -1,5 +1,5 @@ /* -Copyright 2021 The Matrix.org Foundation C.I.C. +Copyright 2021 - 2022 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -20,12 +20,13 @@ import { MatrixClient } from "matrix-js-sdk/src/client"; import { Direction } from "matrix-js-sdk/src/models/event-timeline"; import { saveAs } from "file-saver"; import { logger } from "matrix-js-sdk/src/logger"; +import sanitizeFilename from "sanitize-filename"; import { MatrixClientPeg } from "../../MatrixClientPeg"; import { ExportType, IExportOptions } from "./exportUtils"; import { decryptFile } from "../DecryptFile"; import { mediaFromContent } from "../../customisations/Media"; -import { formatFullDateNoDay } from "../../DateUtils"; +import { formatFullDateNoDay, formatFullDateNoDayISO } from "../../DateUtils"; import { isVoiceMessage } from "../EventUtils"; import { IMediaEventContent } from "../../customisations/models/IMediaEventContent"; import { _t } from "../../languageHandler"; @@ -57,6 +58,10 @@ export default abstract class Exporter { window.addEventListener("beforeunload", this.onBeforeUnload); } + public get destinationFileName(): string { + return this.makeFileNameNoExtension(SdkConfig.get().brand) + ".zip"; + } + protected onBeforeUnload(e: BeforeUnloadEvent): string { e.preventDefault(); return e.returnValue = _t("Are you sure you want to exit during this export?"); @@ -75,10 +80,19 @@ export default abstract class Exporter { this.files.push(file); } + protected makeFileNameNoExtension(brand = "matrix"): string { + // First try to use the real name of the room, then a translated copy of a generic name, + // then finally hardcoded default to guarantee we'll have a name. + const safeRoomName = sanitizeFilename(this.room.name ?? _t("Unnamed Room")).trim() || "Unnamed Room"; + const safeDate = formatFullDateNoDayISO(new Date()) + .replace(/:/g, '-'); // ISO format automatically removes a lot of stuff for us + const safeBrand = sanitizeFilename(brand); + return `${safeBrand} - ${safeRoomName} - Chat Export - ${safeDate}`; + } + protected async downloadZIP(): Promise { - const brand = SdkConfig.get().brand; - const filenameWithoutExt = `${brand} - Chat Export - ${formatFullDateNoDay(new Date())}`; - const filename = `${filenameWithoutExt}.zip`; + const filename = this.destinationFileName; + const filenameWithoutExt = filename.substring(0, filename.length - 4); // take off the .zip const { default: JSZip } = await import('jszip'); const zip = new JSZip(); diff --git a/src/utils/exportUtils/JSONExport.ts b/src/utils/exportUtils/JSONExport.ts index b0b4b330b0..a0dc5e036e 100644 --- a/src/utils/exportUtils/JSONExport.ts +++ b/src/utils/exportUtils/JSONExport.ts @@ -1,5 +1,5 @@ /* -Copyright 2021 The Matrix.org Foundation C.I.C. +Copyright 2021 - 2022 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -20,7 +20,7 @@ import { EventType } from "matrix-js-sdk/src/@types/event"; import { logger } from "matrix-js-sdk/src/logger"; import Exporter from "./Exporter"; -import { formatFullDateNoDay, formatFullDateNoDayNoTime } from "../../DateUtils"; +import { formatFullDateNoDayNoTime } from "../../DateUtils"; import { ExportType, IExportOptions } from "./exportUtils"; import { _t } from "../../languageHandler"; import { haveRendererForEvent } from "../../events/EventTileFactory"; @@ -38,6 +38,10 @@ export default class JSONExporter extends Exporter { super(room, exportType, exportOptions, setProgressText); } + public get destinationFileName(): string { + return this.makeFileNameNoExtension() + ".json"; + } + protected createJSONString(): string { const exportDate = formatFullDateNoDayNoTime(new Date()); const creator = this.room.currentState.getStateEvents(EventType.RoomCreate, "")?.getSender(); @@ -108,7 +112,7 @@ export default class JSONExporter extends Exporter { this.addFile("export.json", new Blob([text])); await this.downloadZIP(); } else { - const fileName = `matrix-export-${formatFullDateNoDay(new Date())}.json`; + const fileName = this.destinationFileName; this.downloadPlainText(fileName, text); } diff --git a/src/utils/exportUtils/PlainTextExport.ts b/src/utils/exportUtils/PlainTextExport.ts index cc4cad1894..3150b15c64 100644 --- a/src/utils/exportUtils/PlainTextExport.ts +++ b/src/utils/exportUtils/PlainTextExport.ts @@ -1,5 +1,5 @@ /* -Copyright 2021 The Matrix.org Foundation C.I.C. +Copyright 2021 - 2022 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -20,7 +20,6 @@ import { logger } from "matrix-js-sdk/src/logger"; import React from "react"; import Exporter from "./Exporter"; -import { formatFullDateNoDay } from "../../DateUtils"; import { _t } from "../../languageHandler"; import { ExportType, IExportOptions } from "./exportUtils"; import { textForEvent } from "../../TextForEvent"; @@ -43,6 +42,10 @@ export default class PlainTextExporter extends Exporter { : _t("Media omitted - file size limit exceeded"); } + public get destinationFileName(): string { + return this.makeFileNameNoExtension() + ".txt"; + } + public textForReplyEvent = (content: IContent) => { const REPLY_REGEX = /> <(.*?)>(.*?)\n\n(.*)/s; const REPLY_SOURCE_MAX_LENGTH = 32; @@ -137,7 +140,7 @@ export default class PlainTextExporter extends Exporter { this.addFile("export.txt", new Blob([text])); await this.downloadZIP(); } else { - const fileName = `matrix-export-${formatFullDateNoDay(new Date())}.txt`; + const fileName = this.destinationFileName; this.downloadPlainText(fileName, text); } diff --git a/src/utils/leave-behaviour.ts b/src/utils/leave-behaviour.ts index a12cd70ebf..83054ce1b4 100644 --- a/src/utils/leave-behaviour.ts +++ b/src/utils/leave-behaviour.ts @@ -27,7 +27,6 @@ import { _t } from "../languageHandler"; import ErrorDialog from "../components/views/dialogs/ErrorDialog"; import { isMetaSpace } from "../stores/spaces"; import SpaceStore from "../stores/spaces/SpaceStore"; -import { RoomViewStore } from "../stores/RoomViewStore"; import dis from "../dispatcher/dispatcher"; import { ViewRoomPayload } from "../dispatcher/payloads/ViewRoomPayload"; import { Action } from "../dispatcher/actions"; @@ -35,6 +34,7 @@ import { ViewHomePagePayload } from "../dispatcher/payloads/ViewHomePagePayload" import LeaveSpaceDialog from "../components/views/dialogs/LeaveSpaceDialog"; import { AfterLeaveRoomPayload } from "../dispatcher/payloads/AfterLeaveRoomPayload"; import { bulkSpaceBehaviour } from "./space"; +import { SdkContextClass } from "../contexts/SDKContext"; export async function leaveRoomBehaviour(roomId: string, retry = true, spinner = true) { let spinnerModal: IHandle; @@ -130,7 +130,7 @@ export async function leaveRoomBehaviour(roomId: string, retry = true, spinner = if (!isMetaSpace(SpaceStore.instance.activeSpace) && SpaceStore.instance.activeSpace !== roomId && - RoomViewStore.instance.getRoomId() === roomId + SdkContextClass.instance.roomViewStore.getRoomId() === roomId ) { dis.dispatch({ action: Action.ViewRoom, diff --git a/src/utils/space.tsx b/src/utils/space.tsx index 9e05f0444b..1e30b7235a 100644 --- a/src/utils/space.tsx +++ b/src/utils/space.tsx @@ -30,7 +30,6 @@ import { showRoomInviteDialog } from "../RoomInvite"; import CreateSubspaceDialog from "../components/views/dialogs/CreateSubspaceDialog"; import AddExistingSubspaceDialog from "../components/views/dialogs/AddExistingSubspaceDialog"; import defaultDispatcher from "../dispatcher/dispatcher"; -import { RoomViewStore } from "../stores/RoomViewStore"; import { Action } from "../dispatcher/actions"; import Spinner from "../components/views/elements/Spinner"; import { shouldShowComponent } from "../customisations/helpers/UIComponents"; @@ -38,6 +37,7 @@ import { UIComponent } from "../settings/UIFeature"; import { OpenSpacePreferencesPayload, SpacePreferenceTab } from "../dispatcher/payloads/OpenSpacePreferencesPayload"; import { OpenSpaceSettingsPayload } from "../dispatcher/payloads/OpenSpaceSettingsPayload"; import { OpenAddExistingToSpaceDialogPayload } from "../dispatcher/payloads/OpenAddExistingToSpaceDialogPayload"; +import { SdkContextClass } from "../contexts/SDKContext"; export const shouldShowSpaceSettings = (space: Room) => { const userId = space.client.getUserId(); @@ -113,7 +113,7 @@ export const showAddExistingSubspace = (space: Room): void => { space, onCreateSubspaceClick: () => showCreateNewSubspace(space), onFinished: (added: boolean) => { - if (added && RoomViewStore.instance.getRoomId() === space.roomId) { + if (added && SdkContextClass.instance.roomViewStore.getRoomId() === space.roomId) { defaultDispatcher.fire(Action.UpdateSpaceHierarchy); } }, @@ -125,7 +125,7 @@ export const showCreateNewSubspace = (space: Room): void => { space, onAddExistingSpaceClick: () => showAddExistingSubspace(space), onFinished: (added: boolean) => { - if (added && RoomViewStore.instance.getRoomId() === space.roomId) { + if (added && SdkContextClass.instance.roomViewStore.getRoomId() === space.roomId) { defaultDispatcher.fire(Action.UpdateSpaceHierarchy); } }, diff --git a/src/voice-broadcast/audio/VoiceBroadcastRecorder.ts b/src/voice-broadcast/audio/VoiceBroadcastRecorder.ts index 7f084f3f4a..ff1d22a41c 100644 --- a/src/voice-broadcast/audio/VoiceBroadcastRecorder.ts +++ b/src/voice-broadcast/audio/VoiceBroadcastRecorder.ts @@ -17,10 +17,11 @@ limitations under the License. import { Optional } from "matrix-events-sdk"; import { TypedEventEmitter } from "matrix-js-sdk/src/models/typed-event-emitter"; +import { getChunkLength } from ".."; import { VoiceRecording } from "../../audio/VoiceRecording"; -import SdkConfig, { DEFAULTS } from "../../SdkConfig"; import { concat } from "../../utils/arrays"; import { IDestroyable } from "../../utils/IDestroyable"; +import { Singleflight } from "../../utils/Singleflight"; export enum VoiceBroadcastRecorderEvent { ChunkRecorded = "chunk_recorded", @@ -65,6 +66,8 @@ export class VoiceBroadcastRecorder */ public async stop(): Promise> { await this.voiceRecording.stop(); + // forget about that call, so that we can stop it again later + Singleflight.forgetAllFor(this.voiceRecording); return this.extractChunk(); } @@ -136,6 +139,5 @@ export class VoiceBroadcastRecorder } export const createVoiceBroadcastRecorder = (): VoiceBroadcastRecorder => { - const targetChunkLength = SdkConfig.get("voice_broadcast")?.chunk_length || DEFAULTS.voice_broadcast!.chunk_length; - return new VoiceBroadcastRecorder(new VoiceRecording(), targetChunkLength); + return new VoiceBroadcastRecorder(new VoiceRecording(), getChunkLength()); }; diff --git a/src/voice-broadcast/components/VoiceBroadcastBody.tsx b/src/voice-broadcast/components/VoiceBroadcastBody.tsx index 3bd0dd6ed1..95bc9fde06 100644 --- a/src/voice-broadcast/components/VoiceBroadcastBody.tsx +++ b/src/voice-broadcast/components/VoiceBroadcastBody.tsx @@ -14,8 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from "react"; -import { MatrixEvent } from "matrix-js-sdk/src/matrix"; +import React, { useEffect, useState } from "react"; +import { MatrixEvent, RelationType } from "matrix-js-sdk/src/matrix"; import { VoiceBroadcastRecordingBody, @@ -28,17 +28,35 @@ import { } from ".."; import { IBodyProps } from "../../components/views/messages/IBodyProps"; import { MatrixClientPeg } from "../../MatrixClientPeg"; -import { getReferenceRelationsForEvent } from "../../events"; +import { RelationsHelper, RelationsHelperEvent } from "../../events/RelationsHelper"; export const VoiceBroadcastBody: React.FC = ({ mxEvent }) => { const client = MatrixClientPeg.get(); - const relations = getReferenceRelationsForEvent(mxEvent, VoiceBroadcastInfoEventType, client); - const relatedEvents = relations?.getRelations(); - const state = !relatedEvents?.find((event: MatrixEvent) => { - return event.getContent()?.state === VoiceBroadcastInfoState.Stopped; - }) ? VoiceBroadcastInfoState.Started : VoiceBroadcastInfoState.Stopped; + const [infoState, setInfoState] = useState(mxEvent.getContent()?.state || VoiceBroadcastInfoState.Stopped); - if (shouldDisplayAsVoiceBroadcastRecordingTile(state, client, mxEvent)) { + useEffect(() => { + const onInfoEvent = (event: MatrixEvent) => { + if (event.getContent()?.state === VoiceBroadcastInfoState.Stopped) { + // only a stopped event can change the tile state + setInfoState(VoiceBroadcastInfoState.Stopped); + } + }; + + const relationsHelper = new RelationsHelper( + mxEvent, + RelationType.Reference, + VoiceBroadcastInfoEventType, + client, + ); + relationsHelper.on(RelationsHelperEvent.Add, onInfoEvent); + relationsHelper.emitCurrent(); + + return () => { + relationsHelper.destroy(); + }; + }); + + if (shouldDisplayAsVoiceBroadcastRecordingTile(infoState, client, mxEvent)) { const recording = VoiceBroadcastRecordingsStore.instance().getByInfoEvent(mxEvent, client); return { return
- + { _t("Live") }
; }; diff --git a/src/voice-broadcast/components/atoms/PlaybackControlButton.tsx b/src/voice-broadcast/components/atoms/PlaybackControlButton.tsx deleted file mode 100644 index b67e6b3e24..0000000000 --- a/src/voice-broadcast/components/atoms/PlaybackControlButton.tsx +++ /dev/null @@ -1,53 +0,0 @@ -/* -Copyright 2022 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import React from "react"; - -import { VoiceBroadcastPlaybackState } from "../.."; -import { Icon, IconColour, IconType } from "../../../components/atoms/Icon"; -import AccessibleButton from "../../../components/views/elements/AccessibleButton"; -import { _t } from "../../../languageHandler"; - -const stateIconMap = new Map([ - [VoiceBroadcastPlaybackState.Playing, IconType.Pause], - [VoiceBroadcastPlaybackState.Paused, IconType.Play], - [VoiceBroadcastPlaybackState.Stopped, IconType.Play], -]); - -interface Props { - onClick: () => void; - state: VoiceBroadcastPlaybackState; -} - -export const PlaybackControlButton: React.FC = ({ - onClick, - state, -}) => { - const ariaLabel = state === VoiceBroadcastPlaybackState.Playing - ? _t("pause voice broadcast") - : _t("resume voice broadcast"); - - return - - ; -}; diff --git a/src/voice-broadcast/components/atoms/StopButton.tsx b/src/voice-broadcast/components/atoms/VoiceBroadcastControl.tsx similarity index 68% rename from src/voice-broadcast/components/atoms/StopButton.tsx rename to src/voice-broadcast/components/atoms/VoiceBroadcastControl.tsx index 50abb209d0..276282d198 100644 --- a/src/voice-broadcast/components/atoms/StopButton.tsx +++ b/src/voice-broadcast/components/atoms/VoiceBroadcastControl.tsx @@ -14,27 +14,29 @@ See the License for the specific language governing permissions and limitations under the License. */ +import classNames from "classnames"; import React from "react"; -import { Icon, IconColour, IconType } from "../../../components/atoms/Icon"; import AccessibleButton from "../../../components/views/elements/AccessibleButton"; -import { _t } from "../../../languageHandler"; interface Props { + className?: string; + icon: React.FC>; + label: string; onClick: () => void; } -export const StopButton: React.FC = ({ +export const VoiceBroadcastControl: React.FC = ({ + className = "", + icon: Icon, + label, onClick, }) => { return - + ; }; diff --git a/src/voice-broadcast/components/atoms/VoiceBroadcastHeader.tsx b/src/voice-broadcast/components/atoms/VoiceBroadcastHeader.tsx index 4f91efc8d9..c83e8e8a0c 100644 --- a/src/voice-broadcast/components/atoms/VoiceBroadcastHeader.tsx +++ b/src/voice-broadcast/components/atoms/VoiceBroadcastHeader.tsx @@ -15,7 +15,8 @@ import React from "react"; import { Room, RoomMember } from "matrix-js-sdk/src/matrix"; import { LiveBadge } from "../.."; -import { Icon, IconColour, IconType } from "../../../components/atoms/Icon"; +import { Icon as LiveIcon } from "../../../../res/img/element-icons/live.svg"; +import { Icon as MicrophoneIcon } from "../../../../res/img/voip/call-view/mic-on.svg"; import { _t } from "../../../languageHandler"; import RoomAvatar from "../../../components/views/avatars/RoomAvatar"; @@ -34,7 +35,7 @@ export const VoiceBroadcastHeader: React.FC = ({ }) => { const broadcast = showBroadcast ?
- + { _t("Voice broadcast") }
: null; @@ -46,8 +47,8 @@ export const VoiceBroadcastHeader: React.FC = ({ { room.name }
- - { sender.name } + + { sender.name }
{ broadcast } diff --git a/src/voice-broadcast/components/molecules/VoiceBroadcastPlaybackBody.tsx b/src/voice-broadcast/components/molecules/VoiceBroadcastPlaybackBody.tsx index 8edc5d0d9a..e0634636a7 100644 --- a/src/voice-broadcast/components/molecules/VoiceBroadcastPlaybackBody.tsx +++ b/src/voice-broadcast/components/molecules/VoiceBroadcastPlaybackBody.tsx @@ -17,11 +17,16 @@ limitations under the License. import React from "react"; import { - PlaybackControlButton, + VoiceBroadcastControl, VoiceBroadcastHeader, VoiceBroadcastPlayback, + VoiceBroadcastPlaybackState, } from "../.."; +import Spinner from "../../../components/views/elements/Spinner"; import { useVoiceBroadcastPlayback } from "../../hooks/useVoiceBroadcastPlayback"; +import { Icon as PlayIcon } from "../../../../res/img/element-icons/play.svg"; +import { Icon as PauseIcon } from "../../../../res/img/element-icons/pause.svg"; +import { _t } from "../../../languageHandler"; interface VoiceBroadcastPlaybackBodyProps { playback: VoiceBroadcastPlayback; @@ -38,6 +43,36 @@ export const VoiceBroadcastPlaybackBody: React.FC; + } else { + let controlIcon: React.FC>; + let controlLabel: string; + + switch (playbackState) { + case VoiceBroadcastPlaybackState.Stopped: + controlIcon = PlayIcon; + controlLabel = _t("play voice broadcast"); + break; + case VoiceBroadcastPlaybackState.Paused: + controlIcon = PlayIcon; + controlLabel = _t("resume voice broadcast"); + break; + case VoiceBroadcastPlaybackState.Playing: + controlIcon = PauseIcon; + controlLabel = _t("pause voice broadcast"); + break; + } + + control = ; + } + return (
- + { control }
); diff --git a/src/voice-broadcast/components/molecules/VoiceBroadcastRecordingPip.tsx b/src/voice-broadcast/components/molecules/VoiceBroadcastRecordingPip.tsx index c7604b7d90..57e291cae0 100644 --- a/src/voice-broadcast/components/molecules/VoiceBroadcastRecordingPip.tsx +++ b/src/voice-broadcast/components/molecules/VoiceBroadcastRecordingPip.tsx @@ -17,11 +17,16 @@ limitations under the License. import React from "react"; import { - StopButton, + VoiceBroadcastControl, + VoiceBroadcastInfoState, VoiceBroadcastRecording, } from "../.."; import { useVoiceBroadcastRecording } from "../../hooks/useVoiceBroadcastRecording"; import { VoiceBroadcastHeader } from "../atoms/VoiceBroadcastHeader"; +import { Icon as StopIcon } from "../../../../res/img/element-icons/Stop.svg"; +import { Icon as PauseIcon } from "../../../../res/img/element-icons/pause.svg"; +import { Icon as RecordIcon } from "../../../../res/img/element-icons/Record.svg"; +import { _t } from "../../../languageHandler"; interface VoiceBroadcastRecordingPipProps { recording: VoiceBroadcastRecording; @@ -30,11 +35,22 @@ interface VoiceBroadcastRecordingPipProps { export const VoiceBroadcastRecordingPip: React.FC = ({ recording }) => { const { live, - sender, + recordingState, room, + sender, stopRecording, + toggleRecording, } = useVoiceBroadcastRecording(recording); + const toggleControl = recordingState === VoiceBroadcastInfoState.Paused + ? + : ; + return
@@ -45,7 +61,12 @@ export const VoiceBroadcastRecordingPip: React.FC
- + { toggleControl } +
; }; diff --git a/src/voice-broadcast/hooks/useVoiceBroadcastRecording.ts b/src/voice-broadcast/hooks/useVoiceBroadcastRecording.tsx similarity index 50% rename from src/voice-broadcast/hooks/useVoiceBroadcastRecording.ts rename to src/voice-broadcast/hooks/useVoiceBroadcastRecording.tsx index fcf6a36876..ed27119de1 100644 --- a/src/voice-broadcast/hooks/useVoiceBroadcastRecording.ts +++ b/src/voice-broadcast/hooks/useVoiceBroadcastRecording.tsx @@ -14,38 +14,69 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { useState } from "react"; +import React, { useState } from "react"; import { VoiceBroadcastInfoState, VoiceBroadcastRecording, VoiceBroadcastRecordingEvent, - VoiceBroadcastRecordingsStore, } from ".."; +import QuestionDialog from "../../components/views/dialogs/QuestionDialog"; import { useTypedEventEmitter } from "../../hooks/useEventEmitter"; +import { _t } from "../../languageHandler"; import { MatrixClientPeg } from "../../MatrixClientPeg"; +import Modal from "../../Modal"; + +const showStopBroadcastingDialog = async (): Promise => { + const { finished } = Modal.createDialog( + QuestionDialog, + { + title: _t("Stop live broadcasting?"), + description: ( +

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

+ ), + button: _t("Yes, stop broadcast"), + }, + ); + const [confirmed] = await finished; + return confirmed; +}; export const useVoiceBroadcastRecording = (recording: VoiceBroadcastRecording) => { const client = MatrixClientPeg.get(); const room = client.getRoom(recording.infoEvent.getRoomId()); - const stopRecording = () => { - recording.stop(); - VoiceBroadcastRecordingsStore.instance().clearCurrent(); + const stopRecording = async () => { + const confirmed = await showStopBroadcastingDialog(); + + if (confirmed) { + await recording.stop(); + } }; - const [live, setLive] = useState(recording.getState() === VoiceBroadcastInfoState.Started); + const [recordingState, setRecordingState] = useState(recording.getState()); useTypedEventEmitter( recording, VoiceBroadcastRecordingEvent.StateChanged, (state: VoiceBroadcastInfoState, _recording: VoiceBroadcastRecording) => { - setLive(state === VoiceBroadcastInfoState.Started); + setRecordingState(state); }, ); + const live = [ + VoiceBroadcastInfoState.Started, + VoiceBroadcastInfoState.Paused, + VoiceBroadcastInfoState.Running, + ].includes(recordingState); + return { live, + recordingState, room, sender: recording.infoEvent.sender, stopRecording, + toggleRecording: recording.toggle, }; }; diff --git a/src/voice-broadcast/index.ts b/src/voice-broadcast/index.ts index 7262382b0c..39149c0a78 100644 --- a/src/voice-broadcast/index.ts +++ b/src/voice-broadcast/index.ts @@ -26,8 +26,7 @@ export * from "./models/VoiceBroadcastRecording"; export * from "./audio/VoiceBroadcastRecorder"; export * from "./components/VoiceBroadcastBody"; export * from "./components/atoms/LiveBadge"; -export * from "./components/atoms/PlaybackControlButton"; -export * from "./components/atoms/StopButton"; +export * from "./components/atoms/VoiceBroadcastControl"; export * from "./components/atoms/VoiceBroadcastHeader"; export * from "./components/molecules/VoiceBroadcastPlaybackBody"; export * from "./components/molecules/VoiceBroadcastRecordingBody"; @@ -35,9 +34,14 @@ export * from "./components/molecules/VoiceBroadcastRecordingPip"; export * from "./hooks/useVoiceBroadcastRecording"; export * from "./stores/VoiceBroadcastPlaybacksStore"; export * from "./stores/VoiceBroadcastRecordingsStore"; +export * from "./utils/getChunkLength"; +export * from "./utils/hasRoomLiveVoiceBroadcast"; +export * from "./utils/findRoomLiveVoiceBroadcastFromUserAndDevice"; +export * from "./utils/resumeVoiceBroadcastInRoom"; export * from "./utils/shouldDisplayAsVoiceBroadcastRecordingTile"; export * from "./utils/shouldDisplayAsVoiceBroadcastTile"; export * from "./utils/startNewVoiceBroadcastRecording"; +export * from "./utils/VoiceBroadcastResumer"; export const VoiceBroadcastInfoEventType = "io.element.voice_broadcast_info"; export const VoiceBroadcastChunkEventType = "io.element.voice_broadcast_chunk"; diff --git a/src/voice-broadcast/models/VoiceBroadcastPlayback.ts b/src/voice-broadcast/models/VoiceBroadcastPlayback.ts index 16ae9317e0..641deb66ad 100644 --- a/src/voice-broadcast/models/VoiceBroadcastPlayback.ts +++ b/src/voice-broadcast/models/VoiceBroadcastPlayback.ts @@ -36,6 +36,7 @@ export enum VoiceBroadcastPlaybackState { Paused, Playing, Stopped, + Buffering, } export enum VoiceBroadcastPlaybackEvent { @@ -91,7 +92,7 @@ export class VoiceBroadcastPlayback this.chunkRelationHelper.emitCurrent(); } - private addChunkEvent(event: MatrixEvent): boolean { + private addChunkEvent = async (event: MatrixEvent): Promise => { const eventId = event.getId(); if (!eventId @@ -102,8 +103,17 @@ export class VoiceBroadcastPlayback } this.chunkEvents.set(eventId, event); + + if (this.getState() !== VoiceBroadcastPlaybackState.Stopped) { + await this.enqueueChunk(event); + } + + if (this.getState() === VoiceBroadcastPlaybackState.Buffering) { + await this.start(); + } + return true; - } + }; private addInfoEvent = (event: MatrixEvent): void => { if (this.lastInfoEvent && this.lastInfoEvent.getTs() >= event.getTs()) { @@ -149,20 +159,30 @@ export class VoiceBroadcastPlayback playback.on(UPDATE_EVENT, (state) => this.onPlaybackStateChange(playback, state)); } - private onPlaybackStateChange(playback: Playback, newState: PlaybackState) { + private async onPlaybackStateChange(playback: Playback, newState: PlaybackState) { if (newState !== PlaybackState.Stopped) { return; } - const next = this.queue[this.queue.indexOf(playback) + 1]; + await this.playNext(playback); + } + + private async playNext(current: Playback): Promise { + const next = this.queue[this.queue.indexOf(current) + 1]; if (next) { + this.setState(VoiceBroadcastPlaybackState.Playing); this.currentlyPlaying = next; - next.play(); + await next.play(); return; } - this.setState(VoiceBroadcastPlaybackState.Stopped); + if (this.getInfoState() === VoiceBroadcastInfoState.Stopped) { + this.setState(VoiceBroadcastPlaybackState.Stopped); + } else { + // No more chunks available, although the broadcast is not finished → enter buffering state. + this.setState(VoiceBroadcastPlaybackState.Buffering); + } } public async start(): Promise { @@ -174,14 +194,14 @@ export class VoiceBroadcastPlayback ? 0 // start at the beginning for an ended voice broadcast : this.queue.length - 1; // start at the current chunk for an ongoing voice broadcast - if (this.queue.length === 0 || !this.queue[toPlayIndex]) { - this.setState(VoiceBroadcastPlaybackState.Stopped); + if (this.queue[toPlayIndex]) { + this.setState(VoiceBroadcastPlaybackState.Playing); + this.currentlyPlaying = this.queue[toPlayIndex]; + await this.currentlyPlaying.play(); return; } - this.setState(VoiceBroadcastPlaybackState.Playing); - this.currentlyPlaying = this.queue[toPlayIndex]; - await this.currentlyPlaying.play(); + this.setState(VoiceBroadcastPlaybackState.Buffering); } public get length(): number { diff --git a/src/voice-broadcast/models/VoiceBroadcastRecording.ts b/src/voice-broadcast/models/VoiceBroadcastRecording.ts index 96b62a670f..28cdd72301 100644 --- a/src/voice-broadcast/models/VoiceBroadcastRecording.ts +++ b/src/voice-broadcast/models/VoiceBroadcastRecording.ts @@ -15,7 +15,7 @@ limitations under the License. */ import { logger } from "matrix-js-sdk/src/logger"; -import { MatrixClient, MatrixEvent, RelationType } from "matrix-js-sdk/src/matrix"; +import { MatrixClient, MatrixEvent, MatrixEventEvent, RelationType } from "matrix-js-sdk/src/matrix"; import { TypedEventEmitter } from "matrix-js-sdk/src/models/typed-event-emitter"; import { @@ -52,9 +52,23 @@ export class VoiceBroadcastRecording public constructor( public readonly infoEvent: MatrixEvent, private client: MatrixClient, + initialState?: VoiceBroadcastInfoState, ) { super(); + if (initialState) { + this.state = initialState; + } else { + this.setInitialStateFromInfoEvent(); + } + + // TODO Michael W: listen for state updates + // + this.infoEvent.on(MatrixEventEvent.BeforeRedaction, this.onBeforeRedaction); + this.dispatcherRef = dis.register(this.onAction); + } + + private setInitialStateFromInfoEvent(): void { const room = this.client.getRoom(this.infoEvent.getRoomId()); const relations = room?.getUnfilteredTimelineSet()?.relations?.getChildEventsForEvent( this.infoEvent.getId(), @@ -65,9 +79,6 @@ export class VoiceBroadcastRecording this.state = !relatedEvents?.find((event: MatrixEvent) => { return event.getContent()?.state === VoiceBroadcastInfoState.Stopped; }) ? VoiceBroadcastInfoState.Started : VoiceBroadcastInfoState.Stopped; - // TODO Michael W: add listening for updates - - this.dispatcherRef = dis.register(this.onAction); } public async start(): Promise { @@ -75,11 +86,38 @@ export class VoiceBroadcastRecording } public async stop(): Promise { + if (this.state === VoiceBroadcastInfoState.Stopped) return; + this.setState(VoiceBroadcastInfoState.Stopped); await this.stopRecorder(); - await this.sendStoppedStateEvent(); + await this.sendInfoStateEvent(VoiceBroadcastInfoState.Stopped); } + public async pause(): Promise { + // stopped or already paused recordings cannot be paused + if ([VoiceBroadcastInfoState.Stopped, VoiceBroadcastInfoState.Paused].includes(this.state)) return; + + this.setState(VoiceBroadcastInfoState.Paused); + await this.stopRecorder(); + await this.sendInfoStateEvent(VoiceBroadcastInfoState.Paused); + } + + public async resume(): Promise { + if (this.state !== VoiceBroadcastInfoState.Paused) return; + + this.setState(VoiceBroadcastInfoState.Running); + await this.getRecorder().start(); + await this.sendInfoStateEvent(VoiceBroadcastInfoState.Running); + } + + public toggle = async (): Promise => { + if (this.getState() === VoiceBroadcastInfoState.Paused) return this.resume(); + + if ([VoiceBroadcastInfoState.Started, VoiceBroadcastInfoState.Running].includes(this.getState())) { + return this.pause(); + } + }; + public getState(): VoiceBroadcastInfoState { return this.state; } @@ -99,10 +137,19 @@ export class VoiceBroadcastRecording this.recorder.stop(); } + this.infoEvent.off(MatrixEventEvent.BeforeRedaction, this.onBeforeRedaction); this.removeAllListeners(); dis.unregister(this.dispatcherRef); } + private onBeforeRedaction = () => { + if (this.getState() !== VoiceBroadcastInfoState.Stopped) { + this.setState(VoiceBroadcastInfoState.Stopped); + // destroy cleans up everything + this.destroy(); + } + }; + private onAction = (payload: ActionPayload) => { if (payload.action !== "call_state") return; @@ -152,14 +199,14 @@ export class VoiceBroadcastRecording await this.client.sendMessage(this.infoEvent.getRoomId(), content); } - private async sendStoppedStateEvent(): Promise { + private async sendInfoStateEvent(state: VoiceBroadcastInfoState): Promise { // TODO Michael W: add error handling for state event await this.client.sendStateEvent( this.infoEvent.getRoomId(), VoiceBroadcastInfoEventType, { device_id: this.client.getDeviceId(), - state: VoiceBroadcastInfoState.Stopped, + state, ["m.relates_to"]: { rel_type: RelationType.Reference, event_id: this.infoEvent.getId(), diff --git a/src/voice-broadcast/stores/VoiceBroadcastRecordingsStore.ts b/src/voice-broadcast/stores/VoiceBroadcastRecordingsStore.ts index cc12b474e8..b5c78a1b0e 100644 --- a/src/voice-broadcast/stores/VoiceBroadcastRecordingsStore.ts +++ b/src/voice-broadcast/stores/VoiceBroadcastRecordingsStore.ts @@ -17,7 +17,7 @@ limitations under the License. import { MatrixClient, MatrixEvent } from "matrix-js-sdk/src/matrix"; import { TypedEventEmitter } from "matrix-js-sdk/src/models/typed-event-emitter"; -import { VoiceBroadcastRecording } from ".."; +import { VoiceBroadcastInfoState, VoiceBroadcastRecording, VoiceBroadcastRecordingEvent } from ".."; export enum VoiceBroadcastRecordingsStoreEvent { CurrentChanged = "current_changed", @@ -41,7 +41,12 @@ export class VoiceBroadcastRecordingsStore extends TypedEventEmitter { + if (state === VoiceBroadcastInfoState.Stopped) { + this.clearCurrent(); + } + }; + private static readonly cachedInstance = new VoiceBroadcastRecordingsStore(); /** diff --git a/src/voice-broadcast/utils/VoiceBroadcastResumer.ts b/src/voice-broadcast/utils/VoiceBroadcastResumer.ts new file mode 100644 index 0000000000..c8b3407451 --- /dev/null +++ b/src/voice-broadcast/utils/VoiceBroadcastResumer.ts @@ -0,0 +1,56 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { ClientEvent, MatrixClient, Room } from "matrix-js-sdk/src/matrix"; + +import { IDestroyable } from "../../utils/IDestroyable"; +import { findRoomLiveVoiceBroadcastFromUserAndDevice } from "./findRoomLiveVoiceBroadcastFromUserAndDevice"; +import { resumeVoiceBroadcastInRoom } from "./resumeVoiceBroadcastInRoom"; + +export class VoiceBroadcastResumer implements IDestroyable { + private seenRooms = new Set(); + private userId: string; + private deviceId: string; + + public constructor( + private client: MatrixClient, + ) { + this.client.on(ClientEvent.Room, this.onRoom); + this.userId = this.client.getUserId(); + this.deviceId = this.client.getDeviceId(); + } + + private onRoom = (room: Room): void => { + if (this.seenRooms.has(room.roomId)) return; + + this.seenRooms.add(room.roomId); + + const infoEvent = findRoomLiveVoiceBroadcastFromUserAndDevice( + room, + this.userId, + this.deviceId, + ); + + if (infoEvent) { + resumeVoiceBroadcastInRoom(infoEvent, room, this.client); + } + }; + + destroy(): void { + this.client.off(ClientEvent.Room, this.onRoom); + this.seenRooms = new Set(); + } +} diff --git a/src/voice-broadcast/utils/findRoomLiveVoiceBroadcastFromUserAndDevice.ts b/src/voice-broadcast/utils/findRoomLiveVoiceBroadcastFromUserAndDevice.ts new file mode 100644 index 0000000000..61d54a7660 --- /dev/null +++ b/src/voice-broadcast/utils/findRoomLiveVoiceBroadcastFromUserAndDevice.ts @@ -0,0 +1,37 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { MatrixEvent, Room } from "matrix-js-sdk/src/matrix"; + +import { VoiceBroadcastInfoEventType, VoiceBroadcastInfoState } from ".."; + +export const findRoomLiveVoiceBroadcastFromUserAndDevice = ( + room: Room, + userId: string, + deviceId: string, +): MatrixEvent | null => { + const stateEvent = room.currentState.getStateEvents(VoiceBroadcastInfoEventType, userId); + + // no broadcast from that user + if (!stateEvent) return null; + + const content = stateEvent.getContent() || {}; + + // stopped broadcast + if (content.state === VoiceBroadcastInfoState.Stopped) return null; + + return content.device_id === deviceId ? stateEvent : null; +}; diff --git a/src/voice-broadcast/utils/getChunkLength.ts b/src/voice-broadcast/utils/getChunkLength.ts new file mode 100644 index 0000000000..9eebfe4979 --- /dev/null +++ b/src/voice-broadcast/utils/getChunkLength.ts @@ -0,0 +1,29 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import SdkConfig, { DEFAULTS } from "../../SdkConfig"; + +/** + * Returns the target chunk length for voice broadcasts: + * - Tries to get the value from the voice_broadcast.chunk_length config + * - If that fails from DEFAULTS + * - If that fails fall back to 120 (two minutes) + */ +export const getChunkLength = (): number => { + return SdkConfig.get("voice_broadcast")?.chunk_length + || DEFAULTS.voice_broadcast?.chunk_length + || 120; +}; diff --git a/src/voice-broadcast/utils/hasRoomLiveVoiceBroadcast.ts b/src/voice-broadcast/utils/hasRoomLiveVoiceBroadcast.ts new file mode 100644 index 0000000000..577b9ed880 --- /dev/null +++ b/src/voice-broadcast/utils/hasRoomLiveVoiceBroadcast.ts @@ -0,0 +1,54 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { MatrixEvent, Room } from "matrix-js-sdk/src/matrix"; + +import { VoiceBroadcastInfoEventType, VoiceBroadcastInfoState } from ".."; + +interface Result { + hasBroadcast: boolean; + startedByUser: boolean; +} + +/** + * Finds out whether there is a live broadcast in a room. + * Also returns if the user started the broadcast (if any). + */ +export const hasRoomLiveVoiceBroadcast = (room: Room, userId: string): Result => { + let hasBroadcast = false; + let startedByUser = false; + + const stateEvents = room.currentState.getStateEvents(VoiceBroadcastInfoEventType); + stateEvents.forEach((event: MatrixEvent) => { + const state = event.getContent()?.state; + + if (state && state !== VoiceBroadcastInfoState.Stopped) { + hasBroadcast = true; + + // state key = sender's MXID + if (event.getStateKey() === userId) { + startedByUser = true; + // break here, because more than true / true is not possible + return false; + } + } + }); + + return { + hasBroadcast, + startedByUser, + }; +}; diff --git a/src/voice-broadcast/utils/resumeVoiceBroadcastInRoom.ts b/src/voice-broadcast/utils/resumeVoiceBroadcastInRoom.ts new file mode 100644 index 0000000000..f365fce226 --- /dev/null +++ b/src/voice-broadcast/utils/resumeVoiceBroadcastInRoom.ts @@ -0,0 +1,34 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { MatrixClient, MatrixEvent, Room } from "matrix-js-sdk/src/matrix"; + +import { VoiceBroadcastInfoState, VoiceBroadcastRecording } from ".."; +import { VoiceBroadcastRecordingsStore } from "../stores/VoiceBroadcastRecordingsStore"; + +export const resumeVoiceBroadcastInRoom = (latestInfoEvent: MatrixEvent, room: Room, client: MatrixClient) => { + // voice broadcasts are based on their started event, try to find it + const infoEvent = latestInfoEvent.getContent()?.state === VoiceBroadcastInfoState.Started + ? latestInfoEvent + : room.findEventById(latestInfoEvent.getRelation()?.event_id); + + if (!infoEvent) { + return; + } + + const recording = new VoiceBroadcastRecording(infoEvent, client, VoiceBroadcastInfoState.Paused); + VoiceBroadcastRecordingsStore.instance().setCurrent(recording); +}; diff --git a/src/voice-broadcast/utils/startNewVoiceBroadcastRecording.ts b/src/voice-broadcast/utils/startNewVoiceBroadcastRecording.ts deleted file mode 100644 index 272958e5d0..0000000000 --- a/src/voice-broadcast/utils/startNewVoiceBroadcastRecording.ts +++ /dev/null @@ -1,76 +0,0 @@ -/* -Copyright 2022 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import { ISendEventResponse, MatrixClient, RoomStateEvent } from "matrix-js-sdk/src/matrix"; -import { defer } from "matrix-js-sdk/src/utils"; - -import { - VoiceBroadcastInfoEventContent, - VoiceBroadcastInfoEventType, - VoiceBroadcastInfoState, - VoiceBroadcastRecordingsStore, - VoiceBroadcastRecording, -} from ".."; - -/** - * Starts a new Voice Broadcast Recording. - * Sends a voice_broadcast_info state event and waits for the event to actually appear in the room state. - */ -export const startNewVoiceBroadcastRecording = async ( - roomId: string, - client: MatrixClient, - recordingsStore: VoiceBroadcastRecordingsStore, -): Promise => { - const room = client.getRoom(roomId); - const { promise, resolve } = defer(); - let result: ISendEventResponse = null; - - const onRoomStateEvents = () => { - if (!result) return; - - const voiceBroadcastEvent = room.currentState.getStateEvents( - VoiceBroadcastInfoEventType, - client.getUserId(), - ); - - if (voiceBroadcastEvent?.getId() === result.event_id) { - room.off(RoomStateEvent.Events, onRoomStateEvents); - const recording = new VoiceBroadcastRecording( - voiceBroadcastEvent, - client, - ); - recordingsStore.setCurrent(recording); - recording.start(); - resolve(recording); - } - }; - - room.on(RoomStateEvent.Events, onRoomStateEvents); - - // XXX Michael W: refactor to live event - result = await client.sendStateEvent( - roomId, - VoiceBroadcastInfoEventType, - { - device_id: client.getDeviceId(), - state: VoiceBroadcastInfoState.Started, - chunk_length: 300, - } as VoiceBroadcastInfoEventContent, - client.getUserId(), - ); - - return promise; -}; diff --git a/src/voice-broadcast/utils/startNewVoiceBroadcastRecording.tsx b/src/voice-broadcast/utils/startNewVoiceBroadcastRecording.tsx new file mode 100644 index 0000000000..ec57ea5312 --- /dev/null +++ b/src/voice-broadcast/utils/startNewVoiceBroadcastRecording.tsx @@ -0,0 +1,142 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from "react"; +import { ISendEventResponse, MatrixClient, Room, RoomStateEvent } from "matrix-js-sdk/src/matrix"; +import { defer } from "matrix-js-sdk/src/utils"; + +import { _t } from "../../languageHandler"; +import InfoDialog from "../../components/views/dialogs/InfoDialog"; +import Modal from "../../Modal"; +import { + VoiceBroadcastInfoEventContent, + VoiceBroadcastInfoEventType, + VoiceBroadcastInfoState, + VoiceBroadcastRecordingsStore, + VoiceBroadcastRecording, + hasRoomLiveVoiceBroadcast, + getChunkLength, +} from ".."; + +const startBroadcast = async ( + room: Room, + client: MatrixClient, + recordingsStore: VoiceBroadcastRecordingsStore, +): Promise => { + const { promise, resolve } = defer(); + let result: ISendEventResponse = null; + + const onRoomStateEvents = () => { + if (!result) return; + + const voiceBroadcastEvent = room.currentState.getStateEvents( + VoiceBroadcastInfoEventType, + client.getUserId(), + ); + + if (voiceBroadcastEvent?.getId() === result.event_id) { + room.off(RoomStateEvent.Events, onRoomStateEvents); + const recording = new VoiceBroadcastRecording( + voiceBroadcastEvent, + client, + ); + recordingsStore.setCurrent(recording); + recording.start(); + resolve(recording); + } + }; + + room.on(RoomStateEvent.Events, onRoomStateEvents); + + // XXX Michael W: refactor to live event + result = await client.sendStateEvent( + room.roomId, + VoiceBroadcastInfoEventType, + { + device_id: client.getDeviceId(), + state: VoiceBroadcastInfoState.Started, + chunk_length: getChunkLength(), + } as VoiceBroadcastInfoEventContent, + client.getUserId(), + ); + + return promise; +}; + +const showAlreadyRecordingDialog = () => { + Modal.createDialog(InfoDialog, { + title: _t("Can't start a new voice broadcast"), + description:

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

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

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

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

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

, + hasCloseButton: true, + }); +}; + +/** + * Starts a new Voice Broadcast Recording, if + * - the user has the permissions to do so in the room + * - there is no other broadcast being recorded in the room, yet + * Sends a voice_broadcast_info state event and waits for the event to actually appear in the room state. + */ +export const startNewVoiceBroadcastRecording = async ( + room: Room, + client: MatrixClient, + recordingsStore: VoiceBroadcastRecordingsStore, +): Promise => { + if (recordingsStore.getCurrent()) { + showAlreadyRecordingDialog(); + return null; + } + + const currentUserId = client.getUserId(); + + if (!room.currentState.maySendStateEvent(VoiceBroadcastInfoEventType, currentUserId)) { + showInsufficientPermissionsDialog(); + return null; + } + + const { hasBroadcast, startedByUser } = hasRoomLiveVoiceBroadcast(room, currentUserId); + + if (hasBroadcast && startedByUser) { + showAlreadyRecordingDialog(); + return null; + } + + if (hasBroadcast) { + showOthersAlreadyRecordingDialog(); + return null; + } + + return startBroadcast(room, client, recordingsStore); +}; diff --git a/test/SlashCommands-test.tsx b/test/SlashCommands-test.tsx index 39d3986270..c31d6d70c1 100644 --- a/test/SlashCommands-test.tsx +++ b/test/SlashCommands-test.tsx @@ -21,9 +21,9 @@ import { Command, Commands, getCommand } from '../src/SlashCommands'; import { createTestClient } from './test-utils'; import { MatrixClientPeg } from '../src/MatrixClientPeg'; import { LocalRoom, LOCAL_ROOM_ID_PREFIX } from '../src/models/LocalRoom'; -import { RoomViewStore } from '../src/stores/RoomViewStore'; import SettingsStore from '../src/settings/SettingsStore'; import LegacyCallHandler from '../src/LegacyCallHandler'; +import { SdkContextClass } from '../src/contexts/SDKContext'; describe('SlashCommands', () => { let client: MatrixClient; @@ -38,14 +38,14 @@ describe('SlashCommands', () => { }; const setCurrentRoom = (): void => { - mocked(RoomViewStore.instance.getRoomId).mockReturnValue(roomId); + mocked(SdkContextClass.instance.roomViewStore.getRoomId).mockReturnValue(roomId); mocked(client.getRoom).mockImplementation((rId: string): Room => { if (rId === roomId) return room; }); }; const setCurrentLocalRoon = (): void => { - mocked(RoomViewStore.instance.getRoomId).mockReturnValue(localRoomId); + mocked(SdkContextClass.instance.roomViewStore.getRoomId).mockReturnValue(localRoomId); mocked(client.getRoom).mockImplementation((rId: string): Room => { if (rId === localRoomId) return localRoom; }); @@ -60,7 +60,7 @@ describe('SlashCommands', () => { room = new Room(roomId, client, client.getUserId()); localRoom = new LocalRoom(localRoomId, client, client.getUserId()); - jest.spyOn(RoomViewStore.instance, "getRoomId"); + jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId"); }); describe('/topic', () => { diff --git a/test/TestSdkContext.ts b/test/TestSdkContext.ts new file mode 100644 index 0000000000..4ce9100a94 --- /dev/null +++ b/test/TestSdkContext.ts @@ -0,0 +1,46 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { SdkContextClass } from "../src/contexts/SDKContext"; +import { PosthogAnalytics } from "../src/PosthogAnalytics"; +import { SlidingSyncManager } from "../src/SlidingSyncManager"; +import { RoomNotificationStateStore } from "../src/stores/notifications/RoomNotificationStateStore"; +import RightPanelStore from "../src/stores/right-panel/RightPanelStore"; +import { RoomViewStore } from "../src/stores/RoomViewStore"; +import { SpaceStoreClass } from "../src/stores/spaces/SpaceStore"; +import { WidgetLayoutStore } from "../src/stores/widgets/WidgetLayoutStore"; +import { WidgetPermissionStore } from "../src/stores/widgets/WidgetPermissionStore"; +import WidgetStore from "../src/stores/WidgetStore"; + +/** + * A class which provides the same API as SdkContextClass but adds additional unsafe setters which can + * replace individual stores. This is useful for tests which need to mock out stores. + */ +export class TestSdkContext extends SdkContextClass { + public _RightPanelStore?: RightPanelStore; + public _RoomNotificationStateStore?: RoomNotificationStateStore; + public _RoomViewStore?: RoomViewStore; + public _WidgetPermissionStore?: WidgetPermissionStore; + public _WidgetLayoutStore?: WidgetLayoutStore; + public _WidgetStore?: WidgetStore; + public _PosthogAnalytics?: PosthogAnalytics; + public _SlidingSyncManager?: SlidingSyncManager; + public _SpaceStore?: SpaceStoreClass; + + constructor() { + super(); + } +} diff --git a/test/components/atoms/Icon-test.tsx b/test/components/atoms/Icon-test.tsx deleted file mode 100644 index 57e6e3990c..0000000000 --- a/test/components/atoms/Icon-test.tsx +++ /dev/null @@ -1,47 +0,0 @@ -/* -Copyright 2022 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import React from "react"; -import { render } from "@testing-library/react"; - -import { Icon, IconColour, IconSize, IconType } from "../../../src/components/atoms/Icon"; - -describe("Icon", () => { - it.each([ - IconColour.Accent, - IconColour.LiveBadge, - ])("should render the colour %s", (colour: IconColour) => { - const { container } = render( - , - ); - expect(container).toMatchSnapshot(); - }); - - it.each([ - IconSize.S16, - ])("should render the size %s", (size: IconSize) => { - const { container } = render( - , - ); - expect(container).toMatchSnapshot(); - }); -}); diff --git a/test/components/atoms/__snapshots__/Icon-test.tsx.snap b/test/components/atoms/__snapshots__/Icon-test.tsx.snap deleted file mode 100644 index c30b4ba332..0000000000 --- a/test/components/atoms/__snapshots__/Icon-test.tsx.snap +++ /dev/null @@ -1,34 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Icon should render the colour accent 1`] = ` -
-