diff --git a/.eslintignore.errorfiles b/.eslintignore.errorfiles index 1c0a3d1254..d9177bebb5 100644 --- a/.eslintignore.errorfiles +++ b/.eslintignore.errorfiles @@ -1,7 +1,7 @@ # autogenerated file: run scripts/generate-eslint-error-ignore-file to update. src/Markdown.js -src/Velociraptor.js +src/NodeAnimator.js src/components/structures/RoomDirectory.js src/components/views/rooms/MemberList.js src/ratelimitedfunc.js diff --git a/.eslintrc.js b/.eslintrc.js index 99695b7a03..4959b133a0 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -15,7 +15,6 @@ module.exports = { "prefer-promise-reject-errors": "off", "no-async-promise-executor": "off", "quotes": "off", - "indent": "off", }, overrides: [{ diff --git a/.gitignore b/.gitignore index e1dd7726e1..50aa10fbfd 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ /*.log package-lock.json +/coverage /node_modules /lib diff --git a/.stylelintrc.js b/.stylelintrc.js index 313102ea83..0e6de7000f 100644 --- a/.stylelintrc.js +++ b/.stylelintrc.js @@ -4,6 +4,7 @@ module.exports = { "stylelint-scss", ], "rules": { + "color-hex-case": null, "indentation": 4, "comment-empty-line-before": null, "declaration-empty-line-before": null, diff --git a/CHANGELOG.md b/CHANGELOG.md index c839fc2b73..2582668ef9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,435 @@ +Changes in [3.21.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.21.0) (2021-05-17) +===================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.21.0-rc.1...v3.21.0) + +## Security notice + +matrix-react-sdk 3.21.0 fixes a low severity issue (GHSA-8796-gc9j-63rv) +related to file upload. When uploading a file, the local file preview can lead +to execution of scripts embedded in the uploaded file, but only after several +user interactions to open the preview in a separate tab. This only impacts the +local user while in the process of uploading. It cannot be exploited remotely +or by other users. Thanks to [Muhammad Zaid Ghifari](https://github.com/MR-ZHEEV) +for responsibly disclosing this via Matrix's Security Disclosure Policy. + +## All changes + + * Upgrade to JS SDK 11.0.0 + * [Release] Add missing space on beta feedback dialog + [\#6019](https://github.com/matrix-org/matrix-react-sdk/pull/6019) + * [Release] Add feedback mechanism for beta features, namely Spaces + [\#6013](https://github.com/matrix-org/matrix-react-sdk/pull/6013) + * Add feedback mechanism for beta features, namely Spaces + [\#6012](https://github.com/matrix-org/matrix-react-sdk/pull/6012) + +Changes in [3.21.0-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.21.0-rc.1) (2021-05-11) +=============================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.20.0...v3.21.0-rc.1) + + * Upgrade to JS SDK 11.0.0-rc.1 + * Add disclaimer about subspaces being experimental in add existing dialog + [\#5978](https://github.com/matrix-org/matrix-react-sdk/pull/5978) + * Spaces Beta release + [\#5933](https://github.com/matrix-org/matrix-react-sdk/pull/5933) + * Improve permissions error when adding new server to room directory + [\#6009](https://github.com/matrix-org/matrix-react-sdk/pull/6009) + * Allow user to progress through space creation & setup using Enter + [\#6006](https://github.com/matrix-org/matrix-react-sdk/pull/6006) + * Upgrade sanitize types + [\#6008](https://github.com/matrix-org/matrix-react-sdk/pull/6008) + * Upgrade `cheerio` and resolve type errors + [\#6007](https://github.com/matrix-org/matrix-react-sdk/pull/6007) + * Add slash commands support to edit message composer + [\#5865](https://github.com/matrix-org/matrix-react-sdk/pull/5865) + * Fix the two todays problem + [\#5940](https://github.com/matrix-org/matrix-react-sdk/pull/5940) + * Switch the Home Space out for an All rooms space + [\#5969](https://github.com/matrix-org/matrix-react-sdk/pull/5969) + * Show device ID in UserInfo when there is no device name + [\#5985](https://github.com/matrix-org/matrix-react-sdk/pull/5985) + * Switch back to release version of `sanitize-html` + [\#6005](https://github.com/matrix-org/matrix-react-sdk/pull/6005) + * Bump hosted-git-info from 2.8.8 to 2.8.9 + [\#5998](https://github.com/matrix-org/matrix-react-sdk/pull/5998) + * Don't use the event's metadata to calc the scale of an image + [\#5982](https://github.com/matrix-org/matrix-react-sdk/pull/5982) + * Adjust MIME type of upload confirmation if needed + [\#5981](https://github.com/matrix-org/matrix-react-sdk/pull/5981) + * Forbid redaction of encryption events + [\#5991](https://github.com/matrix-org/matrix-react-sdk/pull/5991) + * Fix voice message playback being squished up against send button + [\#5988](https://github.com/matrix-org/matrix-react-sdk/pull/5988) + * Improve style of notification badges on the space panel + [\#5983](https://github.com/matrix-org/matrix-react-sdk/pull/5983) + * Add dev dependency for parse5 typings + [\#5990](https://github.com/matrix-org/matrix-react-sdk/pull/5990) + * Iterate Spaces admin UX around room management + [\#5977](https://github.com/matrix-org/matrix-react-sdk/pull/5977) + * Guard all isSpaceRoom calls behind the labs flag + [\#5979](https://github.com/matrix-org/matrix-react-sdk/pull/5979) + * Bump lodash from 4.17.20 to 4.17.21 + [\#5986](https://github.com/matrix-org/matrix-react-sdk/pull/5986) + * Bump lodash from 4.17.19 to 4.17.21 in /test/end-to-end-tests + [\#5987](https://github.com/matrix-org/matrix-react-sdk/pull/5987) + * Bump ua-parser-js from 0.7.23 to 0.7.28 + [\#5984](https://github.com/matrix-org/matrix-react-sdk/pull/5984) + * Update visual style of plain files in the timeline + [\#5971](https://github.com/matrix-org/matrix-react-sdk/pull/5971) + * Support for multiple streams (not MSC3077) + [\#5833](https://github.com/matrix-org/matrix-react-sdk/pull/5833) + * Update space ordering behaviour to match updates in MSC + [\#5963](https://github.com/matrix-org/matrix-react-sdk/pull/5963) + * Improve performance of search all spaces and space switching + [\#5976](https://github.com/matrix-org/matrix-react-sdk/pull/5976) + * Update colours and sizing for voice messages + [\#5970](https://github.com/matrix-org/matrix-react-sdk/pull/5970) + * Update link to Android SDK + [\#5973](https://github.com/matrix-org/matrix-react-sdk/pull/5973) + * Add cleanup functions for image view + [\#5962](https://github.com/matrix-org/matrix-react-sdk/pull/5962) + * Add a note about sharing your IP in P2P calls + [\#5961](https://github.com/matrix-org/matrix-react-sdk/pull/5961) + * Only aggregate DM notifications on the Space Panel in the Home Space + [\#5968](https://github.com/matrix-org/matrix-react-sdk/pull/5968) + * Add retry mechanism and progress bar to add existing to space dialog + [\#5975](https://github.com/matrix-org/matrix-react-sdk/pull/5975) + * Warn on access token reveal + [\#5755](https://github.com/matrix-org/matrix-react-sdk/pull/5755) + * Fix newly joined room appearing under the wrong space + [\#5945](https://github.com/matrix-org/matrix-react-sdk/pull/5945) + * Early rendering for voice messages in the timeline + [\#5955](https://github.com/matrix-org/matrix-react-sdk/pull/5955) + * Calculate the real waveform in the Playback class for voice messages + [\#5956](https://github.com/matrix-org/matrix-react-sdk/pull/5956) + * Don't recurse on arrayFastResample + [\#5957](https://github.com/matrix-org/matrix-react-sdk/pull/5957) + * Support a dark theme for voice messages + [\#5958](https://github.com/matrix-org/matrix-react-sdk/pull/5958) + * Handle no/blocked microphones in voice messages + [\#5959](https://github.com/matrix-org/matrix-react-sdk/pull/5959) + +Changes in [3.20.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.20.0) (2021-05-10) +===================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.20.0-rc.1...v3.20.0) + + * Upgrade to JS SDK 10.1.0 + * [Release] Don't use the event's metadata to calc the scale of an image + [\#6004](https://github.com/matrix-org/matrix-react-sdk/pull/6004) + +Changes in [3.20.0-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.20.0-rc.1) (2021-05-04) +=============================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.19.0...v3.20.0-rc.1) + + * Upgrade to JS SDK 10.1.0-rc.1 + * Translations update from Weblate + [\#5966](https://github.com/matrix-org/matrix-react-sdk/pull/5966) + * Fix more space panel layout and hover behaviour issues + [\#5965](https://github.com/matrix-org/matrix-react-sdk/pull/5965) + * Fix edge case with space panel alignment with subspaces on ff + [\#5964](https://github.com/matrix-org/matrix-react-sdk/pull/5964) + * Fix saving room pill part to history + [\#5951](https://github.com/matrix-org/matrix-react-sdk/pull/5951) + * Generate room preview even when minimized + [\#5948](https://github.com/matrix-org/matrix-react-sdk/pull/5948) + * Another change from recovery passphrase to Security Phrase + [\#5934](https://github.com/matrix-org/matrix-react-sdk/pull/5934) + * Sort rooms in the add existing to space dialog based on recency + [\#5943](https://github.com/matrix-org/matrix-react-sdk/pull/5943) + * Inhibit sending RR when context switching to a room + [\#5944](https://github.com/matrix-org/matrix-react-sdk/pull/5944) + * Prevent room list keyboard handling from landing focus on hidden nodes + [\#5950](https://github.com/matrix-org/matrix-react-sdk/pull/5950) + * Make the text filter search all spaces instead of just the selected one + [\#5942](https://github.com/matrix-org/matrix-react-sdk/pull/5942) + * Enable indent rule and fix indent + [\#5931](https://github.com/matrix-org/matrix-react-sdk/pull/5931) + * Prevent peeking members from reacting + [\#5946](https://github.com/matrix-org/matrix-react-sdk/pull/5946) + * Disallow inline display maths + [\#5939](https://github.com/matrix-org/matrix-react-sdk/pull/5939) + * Space creation prompt user to add existing rooms for "Just Me" spaces + [\#5923](https://github.com/matrix-org/matrix-react-sdk/pull/5923) + * Add test coverage collection script + [\#5937](https://github.com/matrix-org/matrix-react-sdk/pull/5937) + * Fix joining room using via servers regression + [\#5936](https://github.com/matrix-org/matrix-react-sdk/pull/5936) + * Revert "Fixes the two Todays problem in Redaction" + [\#5938](https://github.com/matrix-org/matrix-react-sdk/pull/5938) + * Handle encoded matrix URLs + [\#5903](https://github.com/matrix-org/matrix-react-sdk/pull/5903) + * Render ignored users setting regardless of if there are any + [\#5860](https://github.com/matrix-org/matrix-react-sdk/pull/5860) + * Fix inserting trailing colon after mention/pill + [\#5830](https://github.com/matrix-org/matrix-react-sdk/pull/5830) + * Fixes the two Todays problem in Redaction + [\#5917](https://github.com/matrix-org/matrix-react-sdk/pull/5917) + * Fix page up/down scrolling only half a page + [\#5920](https://github.com/matrix-org/matrix-react-sdk/pull/5920) + * Voice messages: Composer controls + [\#5935](https://github.com/matrix-org/matrix-react-sdk/pull/5935) + * Support MSC3086 asserted identity + [\#5886](https://github.com/matrix-org/matrix-react-sdk/pull/5886) + * Handle possible edge case with getting stuck in "unsent messages" bar + [\#5930](https://github.com/matrix-org/matrix-react-sdk/pull/5930) + * Fix suggested rooms not showing up regression from room list optimisation + [\#5932](https://github.com/matrix-org/matrix-react-sdk/pull/5932) + * Broadcast language change to ElectronPlatform + [\#5913](https://github.com/matrix-org/matrix-react-sdk/pull/5913) + * Fix VoIP PIP frame color + [\#5701](https://github.com/matrix-org/matrix-react-sdk/pull/5701) + * Convert some Flow-typed files to TypeScript + [\#5912](https://github.com/matrix-org/matrix-react-sdk/pull/5912) + * Initial SpaceStore tests work + [\#5906](https://github.com/matrix-org/matrix-react-sdk/pull/5906) + * Fix issues with space hierarchy in layout and with incompatible servers + [\#5926](https://github.com/matrix-org/matrix-react-sdk/pull/5926) + * Scale all mxc thumbs using device pixel ratio for hidpi + [\#5928](https://github.com/matrix-org/matrix-react-sdk/pull/5928) + * Fix add existing to space dialog no longer showing rooms for public spaces + [\#5918](https://github.com/matrix-org/matrix-react-sdk/pull/5918) + * Disable spaces context switching for when exploring a space + [\#5924](https://github.com/matrix-org/matrix-react-sdk/pull/5924) + * Autofocus search box in the add existing to space dialog + [\#5921](https://github.com/matrix-org/matrix-react-sdk/pull/5921) + * Use label element in add existing to space dialog for easier hit target + [\#5922](https://github.com/matrix-org/matrix-react-sdk/pull/5922) + * Dynamic max and min zoom in the new ImageView + [\#5916](https://github.com/matrix-org/matrix-react-sdk/pull/5916) + * Improve message error states + [\#5897](https://github.com/matrix-org/matrix-react-sdk/pull/5897) + * Check for null room in `VisibilityProvider` + [\#5914](https://github.com/matrix-org/matrix-react-sdk/pull/5914) + * Add unit tests for various collection-based utility functions + [\#5910](https://github.com/matrix-org/matrix-react-sdk/pull/5910) + * Spaces visual fixes + [\#5909](https://github.com/matrix-org/matrix-react-sdk/pull/5909) + * Remove reliance on DOM API to generated message preview + [\#5908](https://github.com/matrix-org/matrix-react-sdk/pull/5908) + * Expand upon voice message event & include overall waveform + [\#5888](https://github.com/matrix-org/matrix-react-sdk/pull/5888) + * Use floats for image background opacity + [\#5905](https://github.com/matrix-org/matrix-react-sdk/pull/5905) + * Show invites to spaces at the top of the space panel + [\#5902](https://github.com/matrix-org/matrix-react-sdk/pull/5902) + * Improve edge cases with spaces context switching + [\#5899](https://github.com/matrix-org/matrix-react-sdk/pull/5899) + * Fix spaces notification dots wrongly including upgraded (hidden) rooms + [\#5900](https://github.com/matrix-org/matrix-react-sdk/pull/5900) + * Iterate the spaces face pile design + [\#5898](https://github.com/matrix-org/matrix-react-sdk/pull/5898) + * Fix alignment issue with nested spaces being cut off wrong + [\#5890](https://github.com/matrix-org/matrix-react-sdk/pull/5890) + +Changes in [3.19.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.19.0) (2021-04-26) +===================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.19.0-rc.1...v3.19.0) + + * Upgrade to JS SDK 10.0.0 + * [Release] Dynamic max and min zoom in the new ImageView + [\#5927](https://github.com/matrix-org/matrix-react-sdk/pull/5927) + * [Release] Add a WheelEvent normalization function + [\#5911](https://github.com/matrix-org/matrix-react-sdk/pull/5911) + * Add a WheelEvent normalization function + [\#5904](https://github.com/matrix-org/matrix-react-sdk/pull/5904) + * [Release] Use floats for image background opacity + [\#5907](https://github.com/matrix-org/matrix-react-sdk/pull/5907) + +Changes in [3.19.0-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.19.0-rc.1) (2021-04-21) +=============================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.18.0...v3.19.0-rc.1) + + * Upgrade to JS SDK 10.0.0-rc.1 + * Translations update from Weblate + [\#5896](https://github.com/matrix-org/matrix-react-sdk/pull/5896) + * Fix sticky tags header in room list + [\#5895](https://github.com/matrix-org/matrix-react-sdk/pull/5895) + * Fix spaces filtering sometimes lagging behind or behaving oddly + [\#5893](https://github.com/matrix-org/matrix-react-sdk/pull/5893) + * Fix issue with spaces context switching looping and breaking + [\#5894](https://github.com/matrix-org/matrix-react-sdk/pull/5894) + * Improve RoomList render time when filtering + [\#5874](https://github.com/matrix-org/matrix-react-sdk/pull/5874) + * Avoid being stuck in a space + [\#5891](https://github.com/matrix-org/matrix-react-sdk/pull/5891) + * [Spaces] Context switching + [\#5795](https://github.com/matrix-org/matrix-react-sdk/pull/5795) + * Warn when you attempt to leave room that you are the only member of + [\#5415](https://github.com/matrix-org/matrix-react-sdk/pull/5415) + * Ensure PersistedElement are unmounted on application logout + [\#5884](https://github.com/matrix-org/matrix-react-sdk/pull/5884) + * Add missing space in seshat dialog and the corresponding string + [\#5866](https://github.com/matrix-org/matrix-react-sdk/pull/5866) + * A tiny change to make the Add existing rooms dialog a little nicer + [\#5885](https://github.com/matrix-org/matrix-react-sdk/pull/5885) + * Remove weird margin from the file panel + [\#5889](https://github.com/matrix-org/matrix-react-sdk/pull/5889) + * Trigger lazy loading when filtering using spaces + [\#5882](https://github.com/matrix-org/matrix-react-sdk/pull/5882) + * Fix typo in method call in add existing to space dialog + [\#5883](https://github.com/matrix-org/matrix-react-sdk/pull/5883) + * New Image View fixes/improvements + [\#5872](https://github.com/matrix-org/matrix-react-sdk/pull/5872) + * Limit voice recording length + [\#5871](https://github.com/matrix-org/matrix-react-sdk/pull/5871) + * Clean up add existing to space dialog and include DMs in it too + [\#5881](https://github.com/matrix-org/matrix-react-sdk/pull/5881) + * Fix unknown slash command error exploding + [\#5853](https://github.com/matrix-org/matrix-react-sdk/pull/5853) + * Switch to a spec conforming email validation Regexp + [\#5852](https://github.com/matrix-org/matrix-react-sdk/pull/5852) + * Cleanup unused state in MessageComposer + [\#5877](https://github.com/matrix-org/matrix-react-sdk/pull/5877) + * Pulse animation for voice messages recording state + [\#5869](https://github.com/matrix-org/matrix-react-sdk/pull/5869) + * Don't include invisible rooms in notify summary + [\#5875](https://github.com/matrix-org/matrix-react-sdk/pull/5875) + * Properly disable composer access when recording a voice message + [\#5870](https://github.com/matrix-org/matrix-react-sdk/pull/5870) + * Stabilise starting a DM with multiple people flow + [\#5862](https://github.com/matrix-org/matrix-react-sdk/pull/5862) + * Render msgOption only if showReadReceipts is enabled + [\#5864](https://github.com/matrix-org/matrix-react-sdk/pull/5864) + * Labs: Add quick/cheap "do not disturb" flag + [\#5873](https://github.com/matrix-org/matrix-react-sdk/pull/5873) + * Fix ReadReceipts animations + [\#5836](https://github.com/matrix-org/matrix-react-sdk/pull/5836) + * Add tooltips to message previews + [\#5859](https://github.com/matrix-org/matrix-react-sdk/pull/5859) + * IRC Layout fix layout spacing in replies + [\#5855](https://github.com/matrix-org/matrix-react-sdk/pull/5855) + * Move user to welcome_page if continuing with previous session + [\#5849](https://github.com/matrix-org/matrix-react-sdk/pull/5849) + * Improve image view + [\#5521](https://github.com/matrix-org/matrix-react-sdk/pull/5521) + * Add a button to reset personal encryption state during login + [\#5819](https://github.com/matrix-org/matrix-react-sdk/pull/5819) + * Fix js-sdk import in SlashCommands + [\#5850](https://github.com/matrix-org/matrix-react-sdk/pull/5850) + * Fix useRoomPowerLevels hook + [\#5854](https://github.com/matrix-org/matrix-react-sdk/pull/5854) + * Prevent state events being rendered with invalid state keys + [\#5851](https://github.com/matrix-org/matrix-react-sdk/pull/5851) + * Give server ACLs a name in 'roles & permissions' tab + [\#5838](https://github.com/matrix-org/matrix-react-sdk/pull/5838) + * Don't hide notification badge on the home space button as it has no menu + [\#5845](https://github.com/matrix-org/matrix-react-sdk/pull/5845) + * User Info hide disambiguation as we always show MXID anyway + [\#5843](https://github.com/matrix-org/matrix-react-sdk/pull/5843) + * Improve kick state to not show if the target was not joined to begin with + [\#5846](https://github.com/matrix-org/matrix-react-sdk/pull/5846) + * Fix space store wrongly switching to a non-space filter + [\#5844](https://github.com/matrix-org/matrix-react-sdk/pull/5844) + * Tweak appearance of invite reason + [\#5847](https://github.com/matrix-org/matrix-react-sdk/pull/5847) + * Update Inter font to v3.18 + [\#5840](https://github.com/matrix-org/matrix-react-sdk/pull/5840) + * Enable sharing historical keys on invite + [\#5839](https://github.com/matrix-org/matrix-react-sdk/pull/5839) + * Add ability to hide post-login encryption setup with customisation point + [\#5834](https://github.com/matrix-org/matrix-react-sdk/pull/5834) + * Use LaTeX and TeX delimiters by default + [\#5515](https://github.com/matrix-org/matrix-react-sdk/pull/5515) + * Clone author's deps fork for Netlify previews + [\#5837](https://github.com/matrix-org/matrix-react-sdk/pull/5837) + * Show drop file UI only if dragging a file + [\#5827](https://github.com/matrix-org/matrix-react-sdk/pull/5827) + * Ignore punctuation when filtering rooms + [\#5824](https://github.com/matrix-org/matrix-react-sdk/pull/5824) + * Resizable CallView + [\#5710](https://github.com/matrix-org/matrix-react-sdk/pull/5710) + +Changes in [3.18.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.18.0) (2021-04-12) +===================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.18.0-rc.1...v3.18.0) + + * Upgrade to JS SDK 9.11.0 + * [Release] Tweak appearance of invite reason + [\#5848](https://github.com/matrix-org/matrix-react-sdk/pull/5848) + +Changes in [3.18.0-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.18.0-rc.1) (2021-04-07) +=============================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.17.0...v3.18.0-rc.1) + + * Upgrade to JS SDK 9.11.0-rc.1 + * Translations update from Weblate + [\#5832](https://github.com/matrix-org/matrix-react-sdk/pull/5832) + * Add fake fallback thumbnail URL for encrypted videos + [\#5826](https://github.com/matrix-org/matrix-react-sdk/pull/5826) + * Fix broken "Go to Home View" shortcut on macOS + [\#5818](https://github.com/matrix-org/matrix-react-sdk/pull/5818) + * Remove status area UI defects when in-call + [\#5828](https://github.com/matrix-org/matrix-react-sdk/pull/5828) + * Fix viewing invitations when the inviter has no avatar set + [\#5829](https://github.com/matrix-org/matrix-react-sdk/pull/5829) + * Restabilize room list ordering with prefiltering on spaces/communities + [\#5825](https://github.com/matrix-org/matrix-react-sdk/pull/5825) + * Show invite reasons + [\#5694](https://github.com/matrix-org/matrix-react-sdk/pull/5694) + * Require strong password in forgot password form + [\#5744](https://github.com/matrix-org/matrix-react-sdk/pull/5744) + * Attended transfer + [\#5798](https://github.com/matrix-org/matrix-react-sdk/pull/5798) + * Make user autocomplete query search beyond prefix + [\#5822](https://github.com/matrix-org/matrix-react-sdk/pull/5822) + * Add reset option for corrupted event index store + [\#5806](https://github.com/matrix-org/matrix-react-sdk/pull/5806) + * Prevent Re-request encryption keys from appearing under redacted messages + [\#5816](https://github.com/matrix-org/matrix-react-sdk/pull/5816) + * Keybindings follow up + [\#5815](https://github.com/matrix-org/matrix-react-sdk/pull/5815) + * Increase default visible tiles for room sublists + [\#5821](https://github.com/matrix-org/matrix-react-sdk/pull/5821) + * Change copy to point to native node modules docs in element desktop + [\#5817](https://github.com/matrix-org/matrix-react-sdk/pull/5817) + * Show waveform and timer in voice messages + [\#5801](https://github.com/matrix-org/matrix-react-sdk/pull/5801) + * Label unlabeled avatar button in event panel + [\#5585](https://github.com/matrix-org/matrix-react-sdk/pull/5585) + * Fix the theme engine breaking with some web theming extensions + [\#5810](https://github.com/matrix-org/matrix-react-sdk/pull/5810) + * Add /spoiler command + [\#5696](https://github.com/matrix-org/matrix-react-sdk/pull/5696) + * Don't specify sample rates for voice messages + [\#5802](https://github.com/matrix-org/matrix-react-sdk/pull/5802) + * Tweak security key error handling + [\#5812](https://github.com/matrix-org/matrix-react-sdk/pull/5812) + * Add user settings for warn before exit + [\#5793](https://github.com/matrix-org/matrix-react-sdk/pull/5793) + * Decouple key bindings from event handling + [\#5720](https://github.com/matrix-org/matrix-react-sdk/pull/5720) + * Fixing spaces papercuts + [\#5792](https://github.com/matrix-org/matrix-react-sdk/pull/5792) + * Share keys for historical messages when inviting users to encrypted rooms + [\#5763](https://github.com/matrix-org/matrix-react-sdk/pull/5763) + * Fix upload bar not populating when starting uploads + [\#5804](https://github.com/matrix-org/matrix-react-sdk/pull/5804) + * Fix crash on login when using social login + [\#5803](https://github.com/matrix-org/matrix-react-sdk/pull/5803) + * Convert AccessSecretStorageDialog to TypeScript + [\#5805](https://github.com/matrix-org/matrix-react-sdk/pull/5805) + * Tweak cross-signing copy + [\#5807](https://github.com/matrix-org/matrix-react-sdk/pull/5807) + * Fix password change popup message + [\#5791](https://github.com/matrix-org/matrix-react-sdk/pull/5791) + * View Source: make Event ID go below Event ID + [\#5790](https://github.com/matrix-org/matrix-react-sdk/pull/5790) + * Fix line numbers when missing trailing newline + [\#5800](https://github.com/matrix-org/matrix-react-sdk/pull/5800) + * Remember reply when switching rooms + [\#5796](https://github.com/matrix-org/matrix-react-sdk/pull/5796) + * Fix edge case with redaction grouper messing up continuations + [\#5797](https://github.com/matrix-org/matrix-react-sdk/pull/5797) + * Only show the ask anyway modal for explicit user lookup failures + [\#5785](https://github.com/matrix-org/matrix-react-sdk/pull/5785) + * Improve error reporting when EventIndex fails on a supported environment + [\#5787](https://github.com/matrix-org/matrix-react-sdk/pull/5787) + * Tweak and fix some space features + [\#5789](https://github.com/matrix-org/matrix-react-sdk/pull/5789) + * Support replying with a message command + [\#5686](https://github.com/matrix-org/matrix-react-sdk/pull/5686) + * Labs feature: Early implementation of voice messages + [\#5769](https://github.com/matrix-org/matrix-react-sdk/pull/5769) + Changes in [3.17.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.17.0) (2021-03-29) ===================================================================================================== [Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.17.0-rc.1...v3.17.0) @@ -220,11 +652,12 @@ Changes in [3.15.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/ ## Security notice -matrix-react-sdk 3.15.0 fixes a low severity issue (CVE-2021-21320) where the -user content sandbox can be abused to trick users into opening unexpected -documents. The content is opened with a `blob` origin that cannot access Matrix -user data, so messages and secrets are not at risk. Thanks to @keerok for -responsibly disclosing this via Matrix's Security Disclosure Policy. +matrix-react-sdk 3.15.0 fixes a moderate severity issue (CVE-2021-21320) where +the user content sandbox can be abused to trick users into opening unexpected +documents after several user interactions. The content can be opened with a +`blob` origin from the Matrix client, so it is possible for a malicious document +to access user messages and secrets. Thanks to @keerok for responsibly +disclosing this via Matrix's Security Disclosure Policy. ## All changes diff --git a/README.md b/README.md index 73afe34df0..b3e96ef001 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ Platform Targets: * WebRTC features (VoIP and Video calling) are only available in Chrome & Firefox. * Mobile Web is not currently a target platform - instead please use the native iOS (https://github.com/matrix-org/matrix-ios-kit) and Android - (https://github.com/matrix-org/matrix-android-sdk) SDKs. + (https://github.com/matrix-org/matrix-android-sdk2) SDKs. All code lands on the `develop` branch - `master` is only used for stable releases. **Please file PRs against `develop`!!** diff --git a/__mocks__/empty.js b/__mocks__/empty.js new file mode 100644 index 0000000000..51fb4fe937 --- /dev/null +++ b/__mocks__/empty.js @@ -0,0 +1,2 @@ +// Yes, this is empty. +module.exports = {}; diff --git a/package.json b/package.json index 6a8645adf3..f8d94e2d21 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-react-sdk", - "version": "3.17.0", + "version": "3.21.0", "description": "SDK for matrix.org using React", "author": "matrix.org", "repository": { @@ -23,9 +23,7 @@ "package.json" ], "bin": { - "reskindex": "scripts/reskindex.js", - "matrix-gen-i18n": "scripts/gen-i18n.js", - "matrix-prune-i18n": "scripts/prune-i18n.js" + "reskindex": "scripts/reskindex.js" }, "main": "./src/index.js", "matrix_src_main": "./src/index.js", @@ -35,7 +33,7 @@ "prepublishOnly": "yarn build", "i18n": "matrix-gen-i18n", "prunei18n": "matrix-prune-i18n", - "diff-i18n": "cp src/i18n/strings/en_EN.json src/i18n/strings/en_EN_orig.json && ./scripts/gen-i18n.js && node scripts/compare-file.js src/i18n/strings/en_EN_orig.json src/i18n/strings/en_EN.json", + "diff-i18n": "cp src/i18n/strings/en_EN.json src/i18n/strings/en_EN_orig.json && matrix-gen-i18n && matrix-compare-i18n-files src/i18n/strings/en_EN_orig.json src/i18n/strings/en_EN.json", "reskindex": "node scripts/reskindex.js -h header", "reskindex:watch": "node scripts/reskindex.js -h header -w", "rethemendex": "res/css/rethemendex.sh", @@ -51,7 +49,8 @@ "lint:types": "tsc --noEmit --jsx react", "lint:style": "stylelint 'res/css/**/*.scss'", "test": "jest", - "test:e2e": "./test/end-to-end-tests/run.sh --app-url http://localhost:8080" + "test:e2e": "./test/end-to-end-tests/run.sh --app-url http://localhost:8080", + "coverage": "yarn test --coverage" }, "dependencies": { "@babel/runtime": "^7.12.5", @@ -59,7 +58,7 @@ "blueimp-canvas-to-blob": "^3.28.0", "browser-encrypt-attachment": "^0.3.0", "browser-request": "^0.3.3", - "cheerio": "^1.0.0-rc.5", + "cheerio": "^1.0.0-rc.9", "classnames": "^2.2.6", "commonmark": "^0.29.3", "counterpart": "^0.18.6", @@ -81,7 +80,7 @@ "linkifyjs": "^2.1.9", "lodash": "^4.17.20", "matrix-js-sdk": "github:matrix-org/matrix-js-sdk#develop", - "matrix-widget-api": "^0.1.0-beta.13", + "matrix-widget-api": "^0.1.0-beta.14", "minimist": "^1.2.5", "opus-recorder": "^8.0.3", "pako": "^2.0.3", @@ -98,11 +97,10 @@ "react-transition-group": "^4.4.1", "resize-observer-polyfill": "^1.5.1", "rfc4648": "^1.4.0", - "sanitize-html": "github:apostrophecms/sanitize-html#3c7f93f2058f696f5359e3e58d464161647226db", + "sanitize-html": "^2.3.2", "tar-js": "^0.3.0", "text-encoding-utf-8": "^1.0.2", "url": "^0.11.0", - "velocity-animate": "^2.0.6", "what-input": "^5.2.10", "zxcvbn": "^4.4.2" }, @@ -134,11 +132,12 @@ "@types/modernizr": "^3.5.3", "@types/node": "^14.14.22", "@types/pako": "^1.0.1", + "@types/parse5": "^6.0.0", "@types/qrcode": "^1.3.5", "@types/react": "^16.9", "@types/react-dom": "^16.9.10", "@types/react-transition-group": "^4.4.0", - "@types/sanitize-html": "^1.27.0", + "@types/sanitize-html": "^2.3.1", "@types/zxcvbn": "^4.4.0", "@typescript-eslint/eslint-plugin": "^4.14.0", "@typescript-eslint/parser": "^4.14.0", @@ -161,6 +160,7 @@ "jest-fetch-mock": "^3.0.3", "matrix-mock-request": "^1.2.3", "matrix-react-test-utils": "^0.2.2", + "matrix-web-i18n": "github:matrix-org/matrix-web-i18n", "olm": "https://packages.matrix.org/npm/olm/olm-3.2.1.tgz", "react-test-renderer": "^16.14.0", "rimraf": "^3.0.2", @@ -186,10 +186,19 @@ ], "moduleNameMapper": { "\\.(gif|png|svg|ttf|woff2)$": "/__mocks__/imageMock.js", - "\\$webapp/i18n/languages.json": "/__mocks__/languages.json" + "\\$webapp/i18n/languages.json": "/__mocks__/languages.json", + "decoderWorker\\.min\\.js": "/__mocks__/empty.js", + "decoderWorker\\.min\\.wasm": "/__mocks__/empty.js", + "waveWorker\\.min\\.js": "/__mocks__/empty.js" }, "transformIgnorePatterns": [ "/node_modules/(?!matrix-js-sdk).+$" + ], + "collectCoverageFrom": [ + "/src/**/*.{js,ts,tsx}" + ], + "coverageReporters": [ + "text" ] } } diff --git a/res/css/_common.scss b/res/css/_common.scss index 0093bde0ab..d6f85edb86 100644 --- a/res/css/_common.scss +++ b/res/css/_common.scss @@ -28,6 +28,16 @@ $MessageTimestamp_width_hover: calc($MessageTimestamp_width - 2 * $EventTile_e2e :root { font-size: 10px; + + --transition-short: .1s; + --transition-standard: .3s; +} + +@media (prefers-reduced-motion) { + :root { + --transition-short: 0; + --transition-standard: 0; + } } html { @@ -303,7 +313,7 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus { } .mx_Dialog_lightbox .mx_Dialog_background { - opacity: 0.85; + opacity: $lightbox-background-bg-opacity; background-color: $lightbox-background-bg-color; } @@ -315,6 +325,7 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus { max-width: 100%; max-height: 100%; pointer-events: none; + padding: 0; } .mx_Dialog_header { diff --git a/res/css/_components.scss b/res/css/_components.scss index 215b6605a5..c8985cbb51 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -54,6 +54,7 @@ @import "./views/avatars/_MemberStatusMessageAvatar.scss"; @import "./views/avatars/_PulsedAvatar.scss"; @import "./views/avatars/_WidgetAvatar.scss"; +@import "./views/beta/_BetaCard.scss"; @import "./views/context_menus/_CallContextMenu.scss"; @import "./views/context_menus/_IconizedContextMenu.scss"; @import "./views/context_menus/_MessageContextMenu.scss"; @@ -62,6 +63,7 @@ @import "./views/dialogs/_AddExistingToSpaceDialog.scss"; @import "./views/dialogs/_AddressPickerDialog.scss"; @import "./views/dialogs/_Analytics.scss"; +@import "./views/dialogs/_BetaFeedbackDialog.scss"; @import "./views/dialogs/_BugReportDialog.scss"; @import "./views/dialogs/_ChangelogDialog.scss"; @import "./views/dialogs/_ChatCreateOrReuseChatDialog.scss"; @@ -96,6 +98,7 @@ @import "./views/dialogs/_SpaceSettingsDialog.scss"; @import "./views/dialogs/_TabbedIntegrationManagerDialog.scss"; @import "./views/dialogs/_TermsDialog.scss"; +@import "./views/dialogs/_UntrustedDeviceDialog.scss"; @import "./views/dialogs/_UploadConfirmDialog.scss"; @import "./views/dialogs/_UserSettingsDialog.scss"; @import "./views/dialogs/_WidgetCapabilitiesPromptDialog.scss"; @@ -123,6 +126,7 @@ @import "./views/elements/_ImageView.scss"; @import "./views/elements/_InfoTooltip.scss"; @import "./views/elements/_InlineSpinner.scss"; +@import "./views/elements/_InviteReason.scss"; @import "./views/elements/_ManageIntegsButton.scss"; @import "./views/elements/_MiniAvatarUploader.scss"; @import "./views/elements/_PowerSelector.scss"; @@ -160,6 +164,7 @@ @import "./views/messages/_MStickerBody.scss"; @import "./views/messages/_MTextBody.scss"; @import "./views/messages/_MVideoBody.scss"; +@import "./views/messages/_MVoiceMessageBody.scss"; @import "./views/messages/_MessageActionBar.scss"; @import "./views/messages/_MessageTimestamp.scss"; @import "./views/messages/_MjolnirBody.scss"; @@ -235,6 +240,7 @@ @import "./views/settings/tabs/user/_AppearanceUserSettingsTab.scss"; @import "./views/settings/tabs/user/_GeneralUserSettingsTab.scss"; @import "./views/settings/tabs/user/_HelpUserSettingsTab.scss"; +@import "./views/settings/tabs/user/_LabsUserSettingsTab.scss"; @import "./views/settings/tabs/user/_MjolnirUserSettingsTab.scss"; @import "./views/settings/tabs/user/_NotificationUserSettingsTab.scss"; @import "./views/settings/tabs/user/_PreferencesUserSettingsTab.scss"; @@ -247,9 +253,12 @@ @import "./views/toasts/_AnalyticsToast.scss"; @import "./views/toasts/_NonUrgentEchoFailureToast.scss"; @import "./views/verification/_VerificationShowSas.scss"; +@import "./views/voice_messages/_PlayPauseButton.scss"; +@import "./views/voice_messages/_PlaybackContainer.scss"; @import "./views/voice_messages/_Waveform.scss"; @import "./views/voip/_CallContainer.scss"; @import "./views/voip/_CallView.scss"; +@import "./views/voip/_CallViewForRoom.scss"; @import "./views/voip/_DialPad.scss"; @import "./views/voip/_DialPadContextMenu.scss"; @import "./views/voip/_DialPadModal.scss"; diff --git a/res/css/structures/_ContextualMenu.scss b/res/css/structures/_ContextualMenu.scss index 658033339a..4b33427a87 100644 --- a/res/css/structures/_ContextualMenu.scss +++ b/res/css/structures/_ContextualMenu.scss @@ -115,8 +115,3 @@ limitations under the License. border-top: 8px solid $menu-bg-color; border-right: 8px solid transparent; } - -.mx_ContextualMenu_spinner { - display: block; - margin: 0 auto; -} diff --git a/res/css/structures/_FilePanel.scss b/res/css/structures/_FilePanel.scss index 2aa068b674..7b975110e1 100644 --- a/res/css/structures/_FilePanel.scss +++ b/res/css/structures/_FilePanel.scss @@ -22,7 +22,6 @@ limitations under the License. } .mx_FilePanel .mx_RoomView_messageListWrapper { - margin-right: 20px; flex-direction: row; align-items: center; justify-content: center; diff --git a/res/css/structures/_GroupFilterPanel.scss b/res/css/structures/_GroupFilterPanel.scss index e5a8ef6df2..444435dd57 100644 --- a/res/css/structures/_GroupFilterPanel.scss +++ b/res/css/structures/_GroupFilterPanel.scss @@ -56,6 +56,12 @@ limitations under the License. .mx_GroupFilterPanel .mx_TagTile { // opacity: 0.5; position: relative; + + .mx_BetaDot { + position: absolute; + right: -13px; + top: -11px; + } } .mx_GroupFilterPanel .mx_TagTile.mx_TagTile_prototype { diff --git a/res/css/structures/_MyGroups.scss b/res/css/structures/_MyGroups.scss index 73f1332cd0..9c0062b72d 100644 --- a/res/css/structures/_MyGroups.scss +++ b/res/css/structures/_MyGroups.scss @@ -17,6 +17,11 @@ limitations under the License. .mx_MyGroups { display: flex; flex-direction: column; + + .mx_BetaCard { + margin: 0 72px; + max-width: 760px; + } } .mx_MyGroups .mx_RoomHeader_simpleHeader { @@ -30,7 +35,7 @@ limitations under the License. flex-wrap: wrap; } -.mx_MyGroups > :not(.mx_RoomHeader) { +.mx_MyGroups > :not(.mx_RoomHeader):not(.mx_BetaCard) { max-width: 960px; margin: 40px; } diff --git a/res/css/structures/_RoomDirectory.scss b/res/css/structures/_RoomDirectory.scss index 89cb21b7a6..ec07500af5 100644 --- a/res/css/structures/_RoomDirectory.scss +++ b/res/css/structures/_RoomDirectory.scss @@ -61,6 +61,39 @@ limitations under the License. .mx_RoomDirectory_tableWrapper { overflow-y: auto; flex: 1 1 0; + + .mx_RoomDirectory_footer { + margin-top: 24px; + text-align: center; + + > h5 { + margin: 0; + font-weight: $font-semi-bold; + font-size: $font-15px; + line-height: $font-18px; + color: $primary-fg-color; + } + + > p { + margin: 40px auto 60px; + font-size: $font-14px; + line-height: $font-20px; + color: $secondary-fg-color; + max-width: 464px; // easier reading + } + + > hr { + margin: 0; + border: none; + height: 1px; + background-color: $header-panel-bg-color; + } + + .mx_RoomDirectory_newRoom { + margin: 24px auto 0; + width: max-content; + } + } } .mx_RoomDirectory_table { @@ -138,11 +171,6 @@ limitations under the License. color: $settings-grey-fg-color; } -.mx_RoomDirectory_table tr { - padding-bottom: 10px; - cursor: pointer; -} - .mx_RoomDirectory .mx_RoomView_MessageList { padding: 0; } diff --git a/res/css/structures/_RoomStatusBar.scss b/res/css/structures/_RoomStatusBar.scss index 5bf2aee3ae..8cc00aba0f 100644 --- a/res/css/structures/_RoomStatusBar.scss +++ b/res/css/structures/_RoomStatusBar.scss @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_RoomStatusBar { +.mx_RoomStatusBar:not(.mx_RoomStatusBar_unsentMessages) { margin-left: 65px; min-height: 50px; } @@ -68,6 +68,99 @@ limitations under the License. min-height: 58px; } +.mx_RoomStatusBar_unsentMessages { + > div[role="alert"] { + // cheat some basic alignment + display: flex; + align-items: center; + min-height: 70px; + margin: 12px; + padding-left: 16px; + background-color: $header-panel-bg-color; + border-radius: 4px; + } + + .mx_RoomStatusBar_unsentBadge { + margin-right: 12px; + + .mx_NotificationBadge { + // Override sizing from the default badge + width: 24px !important; + height: 24px !important; + border-radius: 24px !important; + + .mx_NotificationBadge_count { + font-size: $font-16px !important; // override default + } + } + } + + .mx_RoomStatusBar_unsentTitle { + color: $warning-color; + font-size: $font-15px; + } + + .mx_RoomStatusBar_unsentDescription { + font-size: $font-12px; + } + + .mx_RoomStatusBar_unsentButtonBar { + flex-grow: 1; + text-align: right; + margin-right: 22px; + color: $muted-fg-color; + + .mx_AccessibleButton { + padding: 5px 10px; + padding-left: 28px; // 16px for the icon, 2px margin to text, 10px regular padding + display: inline-block; + position: relative; + + &:nth-child(2) { + border-left: 1px solid $resend-button-divider-color; + } + + &::before { + content: ''; + position: absolute; + left: 10px; // inset for regular button padding + background-color: $muted-fg-color; + mask-repeat: no-repeat; + mask-position: center; + mask-size: contain; + } + + &.mx_RoomStatusBar_unsentCancelAllBtn::before { + mask-image: url('$(res)/img/element-icons/trashcan.svg'); + width: 12px; + height: 16px; + top: calc(50% - 8px); // text sizes are dynamic + } + + &.mx_RoomStatusBar_unsentResendAllBtn { + padding-left: 34px; // 28px from above, but +6px to account for the wider icon + + &::before { + mask-image: url('$(res)/img/element-icons/retry.svg'); + width: 18px; + height: 18px; + top: calc(50% - 9px); // text sizes are dynamic + } + } + } + + .mx_InlineSpinner { + vertical-align: middle; + margin-right: 5px; + top: 1px; // just to help the vertical alignment be slightly better + + & + span { + margin-right: 10px; // same margin/padding as the rightmost button + } + } + } +} + .mx_RoomStatusBar_connectionLostBar img { padding-left: 10px; padding-right: 10px; @@ -103,7 +196,7 @@ limitations under the License. } .mx_MatrixChat_useCompactLayout { - .mx_RoomStatusBar { + .mx_RoomStatusBar:not(.mx_RoomStatusBar_unsentMessages) { min-height: 40px; } diff --git a/res/css/structures/_RoomView.scss b/res/css/structures/_RoomView.scss index 26382b55e8..cdbe47178d 100644 --- a/res/css/structures/_RoomView.scss +++ b/res/css/structures/_RoomView.scss @@ -262,12 +262,6 @@ hr.mx_RoomView_myReadMarker { padding-top: 1px; } -.mx_RoomView_inCall .mx_RoomView_statusAreaBox { - background-color: $accent-color; - color: $accent-fg-color; - position: relative; -} - .mx_RoomView_voipChevron { position: absolute; bottom: -11px; diff --git a/res/css/structures/_ScrollPanel.scss b/res/css/structures/_ScrollPanel.scss index 699224949b..a4e501b339 100644 --- a/res/css/structures/_ScrollPanel.scss +++ b/res/css/structures/_ScrollPanel.scss @@ -21,6 +21,5 @@ limitations under the License. display: flex; flex-direction: column; justify-content: flex-end; - overflow-y: hidden; } } diff --git a/res/css/structures/_SpacePanel.scss b/res/css/structures/_SpacePanel.scss index 873fa967ab..c433ccf275 100644 --- a/res/css/structures/_SpacePanel.scss +++ b/res/css/structures/_SpacePanel.scss @@ -35,7 +35,7 @@ $activeBorderColor: $secondary-fg-color; .mx_SpacePanel_spaceTreeWrapper { flex: 1; - overflow-y: scroll; + padding: 8px 8px 16px 0; } .mx_SpacePanel_toggleCollapse { @@ -59,11 +59,10 @@ $activeBorderColor: $secondary-fg-color; margin: 0; list-style: none; padding: 0; - padding-left: 16px; - } - .mx_AutoHideScrollbar { - padding: 8px 0 16px; + > .mx_SpaceItem { + padding-left: 16px; + } } .mx_SpaceButton_toggleCollapse { @@ -80,6 +79,10 @@ $activeBorderColor: $secondary-fg-color; .mx_SpaceItem { display: inline-flex; flex-flow: wrap; + + &.mx_SpaceItem_narrow { + align-self: baseline; + } } .mx_SpaceItem.collapsed { @@ -234,7 +237,6 @@ $activeBorderColor: $secondary-fg-color; .mx_SpacePanel_badgeContainer { position: absolute; - height: 16px; // Create a flexbox to make aligning dot badges easier display: flex; @@ -246,23 +248,37 @@ $activeBorderColor: $secondary-fg-color; .mx_NotificationBadge_dot { // make the smaller dot occupy the same width for centering - margin-left: 7px; - margin-right: 7px; + margin: 0 7px; } } &.collapsed { .mx_SpaceButton { .mx_SpacePanel_badgeContainer { - right: -3px; - top: -3px; + right: 0; + top: 0; + + .mx_NotificationBadge { + background-clip: padding-box; + } + + .mx_NotificationBadge_dot { + margin: 0 -1px 0 0; + border: 3px solid $groupFilterPanel-bg-color; + } + + .mx_NotificationBadge_2char, + .mx_NotificationBadge_3char { + margin: -5px -5px 0 0; + border: 2px solid $groupFilterPanel-bg-color; + } } &.mx_SpaceButton_active .mx_SpacePanel_badgeContainer { // when we draw the selection border we move the relative bounds of our parent // so update our position within the bounds of the parent to maintain position overall - right: -6px; - top: -6px; + right: -3px; + top: -3px; } } } @@ -276,15 +292,17 @@ $activeBorderColor: $secondary-fg-color; .mx_SpaceButton:hover, .mx_SpaceButton:focus-within, .mx_SpaceButton_hasMenuOpen { - // Hide the badge container on hover because it'll be a menu button - .mx_SpacePanel_badgeContainer { - width: 0; - height: 0; - display: none; - } + &:not(.mx_SpaceButton_home):not(.mx_SpaceButton_invite) { + // Hide the badge container on hover because it'll be a menu button + .mx_SpacePanel_badgeContainer { + width: 0; + height: 0; + display: none; + } - .mx_SpaceButton_menuButton { - display: block; + .mx_SpaceButton_menuButton { + display: block; + } } } } diff --git a/res/css/structures/_SpaceRoomDirectory.scss b/res/css/structures/_SpaceRoomDirectory.scss index dcceee6371..7925686bf1 100644 --- a/res/css/structures/_SpaceRoomDirectory.scss +++ b/res/css/structures/_SpaceRoomDirectory.scss @@ -26,7 +26,10 @@ limitations under the License. word-break: break-word; display: flex; flex-direction: column; +} +.mx_SpaceRoomDirectory, +.mx_SpaceRoomView_landing { .mx_Dialog_title { display: flex; @@ -56,65 +59,68 @@ limitations under the License. } } - .mx_Dialog_content { - .mx_AccessibleButton_kind_link { - padding: 0; - } + .mx_AccessibleButton_kind_link { + padding: 0; + } - .mx_SearchBox { - margin: 24px 0 16px; - } + .mx_SearchBox { + margin: 24px 0 16px; + } - .mx_SpaceRoomDirectory_noResults { - text-align: center; + .mx_SpaceRoomDirectory_noResults { + text-align: center; - > div { - font-size: $font-15px; - line-height: $font-24px; - color: $secondary-fg-color; - } - } - - .mx_SpaceRoomDirectory_listHeader { - display: flex; - min-height: 32px; - align-items: center; + > div { font-size: $font-15px; line-height: $font-24px; - color: $primary-fg-color; + color: $secondary-fg-color; + } + } - .mx_AccessibleButton { - padding: 2px 8px; - font-weight: normal; + .mx_SpaceRoomDirectory_listHeader { + display: flex; + min-height: 32px; + align-items: center; + font-size: $font-15px; + line-height: $font-24px; + color: $primary-fg-color; - & + .mx_AccessibleButton { - margin-left: 16px; - } - } + .mx_AccessibleButton { + padding: 4px 12px; + font-weight: normal; - > span { - margin-left: auto; + & + .mx_AccessibleButton { + margin-left: 16px; } } - .mx_SpaceRoomDirectory_error { - position: relative; - font-weight: $font-semi-bold; - color: $notice-primary-color; - font-size: $font-15px; - line-height: $font-18px; - margin: 20px auto 12px; - padding-left: 24px; - width: max-content; + .mx_AccessibleButton_kind_danger_outline, + .mx_AccessibleButton_kind_primary_outline { + padding: 3px 12px; // to account for the 1px border + } - &::before { - content: ""; - position: absolute; - height: 16px; - width: 16px; - left: 0; - background-image: url("$(res)/img/element-icons/warning-badge.svg"); - } + > span { + margin-left: auto; + } + } + + .mx_SpaceRoomDirectory_error { + position: relative; + font-weight: $font-semi-bold; + color: $notice-primary-color; + font-size: $font-15px; + line-height: $font-18px; + margin: 20px auto 12px; + padding-left: 24px; + width: max-content; + + &::before { + content: ""; + position: absolute; + height: 16px; + width: 16px; + left: 0; + background-image: url("$(res)/img/element-icons/warning-badge.svg"); } } } @@ -245,11 +251,17 @@ limitations under the License. grid-row: 1/3; .mx_AccessibleButton { - padding: 8px 18px; + line-height: $font-24px; + padding: 4px 16px; display: inline-block; visibility: hidden; } + .mx_AccessibleButton_kind_danger_outline, + .mx_AccessibleButton_kind_primary_outline { + padding: 3px 16px; // to account for the 1px border + } + .mx_Checkbox { display: inline-flex; vertical-align: middle; diff --git a/res/css/structures/_SpaceRoomView.scss b/res/css/structures/_SpaceRoomView.scss index 2e7cfb55d9..503fe72414 100644 --- a/res/css/structures/_SpaceRoomView.scss +++ b/res/css/structures/_SpaceRoomView.scss @@ -81,6 +81,20 @@ $SpaceRoomViewInnerWidth: 428px; color: $secondary-fg-color; margin-top: 12px; margin-bottom: 24px; + max-width: $SpaceRoomViewInnerWidth; + } + + .mx_AddExistingToSpace { + max-width: $SpaceRoomViewInnerWidth; + + .mx_AddExistingToSpace_content { + height: calc(100vh - 360px); + max-height: 400px; + } + } + + &:not(.mx_SpaceRoomView_landing) .mx_SpaceFeedbackPrompt { + width: $SpaceRoomViewInnerWidth; } .mx_SpaceRoomView_buttons { @@ -93,6 +107,10 @@ $SpaceRoomViewInnerWidth: 428px; padding: 8px 22px; margin-left: 16px; } + + input.mx_AccessibleButton { + border: none; // override default styles + } } .mx_Field { @@ -123,6 +141,44 @@ $SpaceRoomViewInnerWidth: 428px; box-sizing: border-box; box-shadow: 2px 15px 30px $dialog-shadow-color; border-radius: 8px; + position: relative; + + // XXX remove this when spaces leaves Beta + .mx_BetaCard_betaPill { + position: absolute; + right: 24px; + top: 32px; + } + // XXX remove this when spaces leaves Beta + .mx_SpaceRoomView_preview_spaceBetaPrompt { + font-weight: $font-semi-bold; + font-size: $font-14px; + line-height: $font-24px; + color: $primary-fg-color; + margin-top: 24px; + position: relative; + padding-left: 24px; + + .mx_AccessibleButton_kind_link { + display: inline; + padding: 0; + font-size: inherit; + line-height: inherit; + } + + &::before { + content: ""; + position: absolute; + height: $font-24px; + width: 20px; + left: 0; + mask-repeat: no-repeat; + mask-position: center; + mask-size: contain; + mask-image: url('$(res)/img/element-icons/room/room-summary.svg'); + background-color: $secondary-fg-color; + } + } .mx_SpaceRoomView_preview_inviter { display: flex; @@ -214,51 +270,22 @@ $SpaceRoomViewInnerWidth: 428px; .mx_SpaceRoomView_info { display: inline-block; - margin: 0; + margin: 0 auto 0 0; } .mx_FacePile { display: inline-block; - margin-left: auto; margin-right: 12px; .mx_FacePile_faces { cursor: pointer; - - > span:hover { - .mx_BaseAvatar { - filter: brightness(0.8); - } - } - - > span:first-child { - position: relative; - - .mx_BaseAvatar { - filter: brightness(0.8); - } - - &::before { - content: ""; - z-index: 1; - position: absolute; - top: 0; - left: 0; - height: 30px; - width: 30px; - background: #ffffff; // white icon fill - mask-position: center; - mask-size: 24px; - mask-repeat: no-repeat; - mask-image: url('$(res)/img/element-icons/room/ellipsis.svg'); - } - } } } .mx_SpaceRoomView_landing_inviteButton { position: relative; - padding-left: 40px; + padding: 4px 18px 4px 40px; + line-height: $font-24px; height: min-content; &::before { @@ -274,6 +301,27 @@ $SpaceRoomViewInnerWidth: 428px; mask-image: url('$(res)/img/element-icons/room/invite.svg'); } } + + .mx_SpaceRoomView_landing_settingsButton { + position: relative; + margin-left: 16px; + width: 24px; + height: 24px; + + &::before { + position: absolute; + content: ""; + left: 0; + top: 0; + height: 24px; + width: 24px; + background: $tertiary-fg-color; + mask-position: center; + mask-size: contain; + mask-repeat: no-repeat; + mask-image: url('$(res)/img/element-icons/settings.svg'); + } + } } .mx_SpaceRoomView_landing_topic { @@ -288,87 +336,22 @@ $SpaceRoomViewInnerWidth: 428px; background-color: $groupFilterPanel-bg-color; } - .mx_SpaceRoomView_landing_adminButtons { - margin-top: 24px; - - .mx_AccessibleButton { - position: relative; - width: 160px; - height: 124px; - box-sizing: border-box; - padding: 72px 16px 0; - border-radius: 12px; - border: 1px solid $input-border-color; - margin-right: 28px; - margin-bottom: 20px; - font-size: $font-14px; - display: inline-block; - vertical-align: bottom; - - &:last-child { - margin-right: 0; - } - - &:hover { - background-color: rgba(141, 151, 165, 0.1); - } - - &::before, &::after { - position: absolute; - content: ""; - left: 16px; - top: 16px; - height: 40px; - width: 40px; - border-radius: 20px; - } - - &::after { - mask-position: center; - mask-size: 30px; - mask-repeat: no-repeat; - background: #ffffff; // white icon fill - } - - &.mx_SpaceRoomView_landing_addButton { - &::before { - background-color: #ac3ba8; - } - - &::after { - mask-image: url('$(res)/img/element-icons/roomlist/explore.svg'); - } - } - - &.mx_SpaceRoomView_landing_createButton { - &::before { - background-color: #368bd6; - } - - &::after { - mask-image: url('$(res)/img/element-icons/roomlist/explore.svg'); - } - } - - &.mx_SpaceRoomView_landing_settingsButton { - &::before { - background-color: #5c56f5; - } - - &::after { - mask-image: url('$(res)/img/element-icons/settings.svg'); - } - } - } - } - .mx_SearchBox { margin: 0 0 20px; } + + .mx_SpaceFeedbackPrompt { + margin-bottom: 16px; + + // hide the HR as we have our own + & + hr { + display: none; + } + } } .mx_SpaceRoomView_privateScope { - .mx_AccessibleButton { + > .mx_AccessibleButton { @mixin SpacePillButton; } @@ -382,6 +365,23 @@ $SpaceRoomViewInnerWidth: 428px; } .mx_SpaceRoomView_inviteTeammates { + // XXX remove this when spaces leaves Beta + .mx_SpaceRoomView_inviteTeammates_betaDisclaimer { + padding: 58px 16px 16px; + position: relative; + border-radius: 8px; + background-color: $header-panel-bg-color; + max-width: $SpaceRoomViewInnerWidth; + margin: 20px 0 30px; + box-sizing: border-box; + + .mx_BetaCard_betaPill { + position: absolute; + left: 16px; + top: 16px; + } + } + .mx_SpaceRoomView_inviteTeammates_buttons { color: $secondary-fg-color; margin-top: 28px; @@ -463,3 +463,66 @@ $SpaceRoomViewInnerWidth: 428px; } } } + +.mx_SpaceFeedbackPrompt { + margin-top: 18px; + margin-bottom: 12px; + + > hr { + border: none; + border-top: 1px solid $input-border-color; + margin-bottom: 12px; + } + + > div { + display: flex; + flex-direction: row; + font-size: $font-15px; + line-height: $font-24px; + + > span { + color: $secondary-fg-color; + position: relative; + padding-left: 32px; + font-size: inherit; + line-height: inherit; + margin-right: auto; + + &::before { + content: ''; + position: absolute; + left: 0; + top: 2px; + height: 20px; + width: 20px; + background-color: $secondary-fg-color; + mask-repeat: no-repeat; + mask-size: contain; + mask-image: url('$(res)/img/element-icons/room/room-summary.svg'); + mask-position: center; + } + } + + .mx_AccessibleButton_kind_link { + color: $accent-color; + position: relative; + padding: 0 0 0 24px; + margin-left: 8px; + font-size: inherit; + line-height: inherit; + + &::before { + content: ''; + position: absolute; + left: 0; + height: 16px; + width: 16px; + background-color: $accent-color; + mask-repeat: no-repeat; + mask-size: contain; + mask-image: url('$(res)/img/element-icons/chat-bubbles.svg'); + mask-position: center; + } + } + } +} diff --git a/res/css/structures/_UserMenu.scss b/res/css/structures/_UserMenu.scss index 3badb0850c..17e6ad75df 100644 --- a/res/css/structures/_UserMenu.scss +++ b/res/css/structures/_UserMenu.scss @@ -117,6 +117,32 @@ limitations under the License. .mx_UserMenu_headerButtons { // No special styles: the rest of the layout happens to make it work. } + + .mx_UserMenu_dnd { + width: 24px; + height: 24px; + margin-right: 8px; + position: relative; + + &::before { + content: ''; + position: absolute; + width: 24px; + height: 24px; + mask-position: center; + mask-size: contain; + mask-repeat: no-repeat; + background: $muted-fg-color; + } + + &.mx_UserMenu_dnd_noisy::before { + mask-image: url('$(res)/img/element-icons/notifications.svg'); + } + + &.mx_UserMenu_dnd_muted::before { + mask-image: url('$(res)/img/element-icons/roomlist/notifications-off.svg'); + } + } } &.mx_UserMenu_minimized { diff --git a/res/css/views/beta/_BetaCard.scss b/res/css/views/beta/_BetaCard.scss new file mode 100644 index 0000000000..3463a653fc --- /dev/null +++ b/res/css/views/beta/_BetaCard.scss @@ -0,0 +1,114 @@ +/* +Copyright 2021 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_BetaCard { + margin-bottom: 20px; + padding: 24px; + background-color: $settings-profile-placeholder-bg-color; + border-radius: 8px; + display: flex; + box-sizing: border-box; + + > div { + .mx_BetaCard_title { + font-weight: $font-semi-bold; + font-size: $font-18px; + line-height: $font-22px; + color: $primary-fg-color; + margin: 4px 0 14px; + + .mx_BetaCard_betaPill { + margin-left: 12px; + } + } + + .mx_BetaCard_caption { + font-size: $font-15px; + line-height: $font-20px; + color: $secondary-fg-color; + margin-bottom: 20px; + } + + .mx_AccessibleButton { + display: block; + margin: 12px 0; + padding: 7px 40px; + width: auto; + } + + .mx_BetaCard_disclaimer { + font-size: $font-12px; + line-height: $font-15px; + color: $secondary-fg-color; + margin-top: 20px; + } + } + + > img { + margin: auto 0 auto 20px; + width: 300px; + object-fit: contain; + height: 100%; + } +} + +.mx_BetaCard_betaPill { + background-color: $accent-color-alt; + padding: 4px 10px; + border-radius: 8px; + text-transform: uppercase; + font-size: 12px; + line-height: 15px; + color: #FFFFFF; + display: inline-block; + vertical-align: text-bottom; + + &.mx_BetaCard_betaPill_clickable { + cursor: pointer; + } +} + +$pulse-color: $accent-color-alt; +$dot-size: 12px; + +.mx_BetaDot { + border-radius: 50%; + margin: 10px; + height: $dot-size; + width: $dot-size; + transform: scale(1); + background: rgba($pulse-color, 1); + box-shadow: 0 0 0 0 rgba($pulse-color, 1); + animation: mx_Beta_bluePulse 2s infinite; + animation-iteration-count: 20; +} + +@keyframes mx_Beta_bluePulse { + 0% { + transform: scale(0.95); + box-shadow: 0 0 0 0 rgba($pulse-color, 0.7); + } + + 70% { + transform: scale(1); + box-shadow: 0 0 0 10px rgba($pulse-color, 0); + } + + 100% { + transform: scale(0.95); + box-shadow: 0 0 0 0 rgba($pulse-color, 0); + } +} diff --git a/res/css/views/dialogs/_AddExistingToSpaceDialog.scss b/res/css/views/dialogs/_AddExistingToSpaceDialog.scss index a7cfd7bde6..2776c477fc 100644 --- a/res/css/views/dialogs/_AddExistingToSpaceDialog.scss +++ b/res/css/views/dialogs/_AddExistingToSpaceDialog.scss @@ -21,6 +21,181 @@ limitations under the License. } } +.mx_AddExistingToSpace { + .mx_SearchBox { + // To match the space around the title + margin: 0 0 15px 0; + flex-grow: 0; + } + + .mx_AddExistingToSpace_content { + flex-grow: 1; + } + + .mx_AddExistingToSpace_noResults { + display: block; + margin-top: 24px; + } + + .mx_AddExistingToSpace_section { + &:not(:first-child) { + margin-top: 24px; + } + + > h3 { + margin: 0; + color: $secondary-fg-color; + font-size: $font-12px; + font-weight: $font-semi-bold; + line-height: $font-15px; + } + + .mx_AddExistingToSpace_entry { + display: flex; + margin-top: 12px; + + // we can't target .mx_BaseAvatar here as it'll break the decorated avatar styling + .mx_DecoratedRoomAvatar { + margin-right: 12px; + } + + .mx_AddExistingToSpace_entry_name { + font-size: $font-15px; + line-height: 30px; + flex-grow: 1; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + margin-right: 12px; + } + + .mx_Checkbox { + align-items: center; + } + } + } + + .mx_AddExistingToSpace_section_spaces { + .mx_BaseAvatar { + margin-right: 12px; + } + + .mx_BaseAvatar_image { + border-radius: 8px; + } + } + + .mx_AddExistingToSpace_section_experimental { + position: relative; + border-radius: 8px; + margin: 12px 0; + padding: 8px 8px 8px 42px; + background-color: $header-panel-bg-color; + + font-size: $font-12px; + line-height: $font-15px; + color: $secondary-fg-color; + + &::before { + content: ''; + position: absolute; + left: 10px; + top: calc(50% - 8px); // vertical centering + height: 16px; + width: 16px; + background-color: $secondary-fg-color; + mask-repeat: no-repeat; + mask-size: contain; + mask-image: url('$(res)/img/element-icons/room/room-summary.svg'); + mask-position: center; + } + } + + .mx_AddExistingToSpace_footer { + display: flex; + margin-top: 20px; + + > span { + flex-grow: 1; + font-size: $font-12px; + line-height: $font-15px; + color: $secondary-fg-color; + + .mx_ProgressBar { + height: 8px; + width: 100%; + + @mixin ProgressBarBorderRadius 8px; + } + + .mx_AddExistingToSpace_progressText { + margin-top: 8px; + font-size: $font-15px; + line-height: $font-24px; + color: $primary-fg-color; + } + + > * { + vertical-align: middle; + } + } + + .mx_AddExistingToSpace_error { + padding-left: 12px; + + > img { + align-self: center; + } + + .mx_AddExistingToSpace_errorHeading { + font-weight: $font-semi-bold; + font-size: $font-15px; + line-height: $font-18px; + color: $notice-primary-color; + } + + .mx_AddExistingToSpace_errorCaption { + margin-top: 4px; + font-size: $font-12px; + line-height: $font-15px; + color: $primary-fg-color; + } + } + + .mx_AccessibleButton { + display: inline-block; + align-self: center; + } + + .mx_AccessibleButton_kind_primary { + padding: 8px 36px; + } + + .mx_AddExistingToSpace_retryButton { + margin-left: 12px; + padding-left: 24px; + position: relative; + + &::before { + content: ''; + position: absolute; + background-color: $primary-fg-color; + mask-repeat: no-repeat; + mask-position: center; + mask-size: contain; + mask-image: url('$(res)/img/element-icons/retry.svg'); + width: 18px; + height: 18px; + left: 0; + } + } + + .mx_AccessibleButton_kind_link { + padding: 0; + } + } +} + .mx_AddExistingToSpaceDialog { width: 480px; color: $primary-fg-color; @@ -41,7 +216,7 @@ limitations under the License. .mx_BaseAvatar { display: inline-flex; - margin: 5px 16px 5px 5px; + margin: auto 16px auto 5px; vertical-align: middle; } @@ -100,97 +275,7 @@ limitations under the License. } } - .mx_SearchBox { - margin: 0; - flex-grow: 0; - } - - .mx_AddExistingToSpaceDialog_errorText { - font-weight: $font-semi-bold; - font-size: $font-12px; - line-height: $font-15px; - color: $notice-primary-color; - margin-bottom: 28px; - } - - .mx_AddExistingToSpaceDialog_content { - flex-grow: 1; - - .mx_AddExistingToSpaceDialog_noResults { - display: block; - margin-top: 24px; - } - } - - .mx_AddExistingToSpaceDialog_section { - margin-top: 24px; - - > h3 { - margin: 0; - color: $secondary-fg-color; - font-size: $font-12px; - font-weight: $font-semi-bold; - line-height: $font-15px; - } - - .mx_AddExistingToSpaceDialog_entry { - display: flex; - margin-top: 12px; - - .mx_BaseAvatar { - margin-right: 12px; - } - - .mx_AddExistingToSpaceDialog_entry_name { - font-size: $font-15px; - line-height: 30px; - flex-grow: 1; - } - - .mx_FormButton { - min-width: 92px; - font-weight: normal; - box-sizing: border-box; - } - } - } - - .mx_AddExistingToSpaceDialog_section_spaces { - .mx_BaseAvatar_image { - border-radius: 8px; - } - } - - .mx_AddExistingToSpaceDialog_footer { - display: flex; - margin-top: 32px; - - > span { - flex-grow: 1; - font-size: $font-14px; - line-height: $font-15px; - font-weight: $font-semi-bold; - - .mx_AccessibleButton { - font-size: inherit; - display: inline-block; - } - - > * { - vertical-align: middle; - } - } - - .mx_AccessibleButton { - display: inline-block; - } - - .mx_AccessibleButton_kind_link { - padding: 0; - } - } - - .mx_FormButton { - padding: 8px 22px; + .mx_AddExistingToSpace { + display: contents; } } diff --git a/res/css/views/dialogs/_BetaFeedbackDialog.scss b/res/css/views/dialogs/_BetaFeedbackDialog.scss new file mode 100644 index 0000000000..9f5f6b512e --- /dev/null +++ b/res/css/views/dialogs/_BetaFeedbackDialog.scss @@ -0,0 +1,30 @@ +/* +Copyright 2021 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_BetaFeedbackDialog { + .mx_BetaFeedbackDialog_subheading { + color: $primary-fg-color; + font-size: $font-14px; + line-height: $font-20px; + margin-bottom: 24px; + } + + .mx_AccessibleButton_kind_link { + padding: 0; + font-size: inherit; + line-height: inherit; + } +} diff --git a/res/css/views/dialogs/_UntrustedDeviceDialog.scss b/res/css/views/dialogs/_UntrustedDeviceDialog.scss new file mode 100644 index 0000000000..0ecd9d4f71 --- /dev/null +++ b/res/css/views/dialogs/_UntrustedDeviceDialog.scss @@ -0,0 +1,26 @@ +/* +Copyright 2021 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_UntrustedDeviceDialog { + .mx_Dialog_title { + display: flex; + align-items: center; + + .mx_E2EIcon { + margin-left: 0; + } + } +} diff --git a/res/css/views/dialogs/security/_AccessSecretStorageDialog.scss b/res/css/views/dialogs/security/_AccessSecretStorageDialog.scss index 63d0ca555d..30b79c1a9a 100644 --- a/res/css/views/dialogs/security/_AccessSecretStorageDialog.scss +++ b/res/css/views/dialogs/security/_AccessSecretStorageDialog.scss @@ -1,6 +1,5 @@ /* -Copyright 2018 New Vector Ltd -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2018, 2019, 2021 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. @@ -15,6 +14,27 @@ See the License for the specific language governing permissions and limitations under the License. */ +.mx_AccessSecretStorageDialog_reset { + position: relative; + padding-left: 24px; // 16px icon + 8px padding + margin-top: 7px; // vertical alignment to buttons + + &::before { + content: ""; + display: inline-block; + position: absolute; + height: 16px; + width: 16px; + left: 0; + top: 2px; // alignment + background-image: url("$(res)/img/element-icons/warning-badge.svg"); + } + + .mx_AccessSecretStorageDialog_reset_link { + color: $warning-color; + } +} + .mx_AccessSecretStorageDialog_titleWithIcon::before { content: ''; display: inline-block; @@ -26,6 +46,13 @@ limitations under the License. background-color: $primary-fg-color; } +.mx_AccessSecretStorageDialog_resetBadge::before { + // The image isn't capable of masking, so we use a background instead. + background-image: url("$(res)/img/element-icons/warning-badge.svg"); + background-size: 24px; + background-color: transparent; +} + .mx_AccessSecretStorageDialog_secureBackupTitle::before { mask-image: url('$(res)/img/feather-customised/secure-backup.svg'); } diff --git a/res/css/views/elements/_AccessibleButton.scss b/res/css/views/elements/_AccessibleButton.scss index 0075dcb511..2997c83cfd 100644 --- a/res/css/views/elements/_AccessibleButton.scss +++ b/res/css/views/elements/_AccessibleButton.scss @@ -76,12 +76,16 @@ limitations under the License. border: 1px solid $button-danger-bg-color; } -.mx_AccessibleButton_kind_danger.mx_AccessibleButton_disabled, -.mx_AccessibleButton_kind_danger_outline.mx_AccessibleButton_disabled { +.mx_AccessibleButton_kind_danger.mx_AccessibleButton_disabled { color: $button-danger-disabled-fg-color; background-color: $button-danger-disabled-bg-color; } +.mx_AccessibleButton_kind_danger_outline.mx_AccessibleButton_disabled { + color: $button-danger-disabled-bg-color; + border-color: $button-danger-disabled-bg-color; +} + .mx_AccessibleButton_hasKind.mx_AccessibleButton_kind_danger_sm { padding: 5px 12px; color: $button-danger-fg-color; diff --git a/res/css/views/elements/_FacePile.scss b/res/css/views/elements/_FacePile.scss index 9a992f59d1..c691baffb5 100644 --- a/res/css/views/elements/_FacePile.scss +++ b/res/css/views/elements/_FacePile.scss @@ -20,7 +20,7 @@ limitations under the License. flex-direction: row-reverse; vertical-align: middle; - > span + span { + > .mx_FacePile_face + .mx_FacePile_face { margin-right: -8px; } @@ -31,9 +31,32 @@ limitations under the License. .mx_BaseAvatar_initial { margin: 1px; // to offset the border on the image } + + .mx_FacePile_more { + position: relative; + border-radius: 100%; + width: 30px; + height: 30px; + background-color: $groupFilterPanel-bg-color; + + &::before { + content: ""; + z-index: 1; + position: absolute; + top: 0; + left: 0; + height: inherit; + width: inherit; + background: $tertiary-fg-color; + mask-position: center; + mask-size: 20px; + mask-repeat: no-repeat; + mask-image: url('$(res)/img/element-icons/room/ellipsis.svg'); + } + } } - > span { + .mx_FacePile_summary { margin-left: 12px; font-size: $font-14px; line-height: $font-24px; diff --git a/res/css/views/elements/_ImageView.scss b/res/css/views/elements/_ImageView.scss index 0a4ed2a194..71035dadc3 100644 --- a/res/css/views/elements/_ImageView.scss +++ b/res/css/views/elements/_ImageView.scss @@ -14,139 +14,107 @@ See the License for the specific language governing permissions and limitations under the License. */ -/* This has got to be the most fragile piece of CSS ever written. - But empirically it works on Chrome/FF/Safari - */ - .mx_ImageView { display: flex; width: 100%; height: 100%; - align-items: center; -} - -.mx_ImageView_lhs { - order: 1; - flex: 1 1 10%; - min-width: 60px; - // background-color: #080; - // height: 20px; -} - -.mx_ImageView_content { - order: 2; - /* min-width hack needed for FF */ - min-width: 0px; - height: 90%; - flex: 15 15 0; - display: flex; - align-items: center; - justify-content: center; -} - -.mx_ImageView_content img { - max-width: 100%; - /* XXX: max-height interacts badly with flex on Chrome and doesn't relayout properly until you refresh */ - max-height: 100%; - /* object-fit hack needed for Chrome due to Chrome not re-laying-out until you refresh */ - object-fit: contain; - /* background-image: url('$(res)/img/trans.png'); */ - pointer-events: all; -} - -.mx_ImageView_labelWrapper { - position: absolute; - top: 0px; - right: 0px; - height: 100%; - overflow: auto; - pointer-events: all; -} - -.mx_ImageView_label { - text-align: left; - display: flex; - justify-content: center; flex-direction: column; - padding-left: 30px; - padding-right: 30px; - min-height: 100%; - max-width: 240px; +} + +.mx_ImageView_image_wrapper { + display: flex; + justify-content: center; + align-items: center; + height: 100%; + overflow: hidden; +} + +.mx_ImageView_image { + pointer-events: all; + flex-shrink: 0; +} + +.mx_ImageView_panel { + width: 100%; + height: 68px; + display: flex; + justify-content: space-between; + align-items: center; +} + +.mx_ImageView_info_wrapper { + pointer-events: all; + padding-left: 32px; + display: flex; + flex-direction: row; + align-items: center; color: $lightbox-fg-color; } -.mx_ImageView_cancel { - position: absolute; - // hack for mx_Dialog having a top padding of 40px - top: 40px; - right: 0px; - padding-top: 35px; - padding-right: 35px; - cursor: pointer; +.mx_ImageView_info { + padding-left: 12px; + display: flex; + flex-direction: column; } -.mx_ImageView_rotateClockwise { - position: absolute; - top: 40px; - right: 70px; - padding-top: 35px; - cursor: pointer; +.mx_ImageView_info_sender { + font-weight: bold; } -.mx_ImageView_rotateCounterClockwise { - position: absolute; - top: 40px; - right: 105px; - padding-top: 35px; - cursor: pointer; -} - -.mx_ImageView_name { - font-size: $font-18px; - margin-bottom: 6px; - word-wrap: break-word; -} - -.mx_ImageView_metadata { - font-size: $font-15px; - opacity: 0.5; -} - -.mx_ImageView_download { - display: table; - margin-top: 24px; - margin-bottom: 6px; - border-radius: 5px; - background-color: $lightbox-bg-color; - font-size: $font-14px; - padding: 9px; - border: 1px solid $lightbox-border-color; -} - -.mx_ImageView_size { - font-size: $font-11px; -} - -.mx_ImageView_link { - color: $lightbox-fg-color !important; - text-decoration: none !important; +.mx_ImageView_toolbar { + padding-right: 16px; + pointer-events: all; + display: flex; + align-items: center; } .mx_ImageView_button { - font-size: $font-15px; - opacity: 0.5; - margin-top: 18px; - cursor: pointer; + margin-left: 24px; + display: block; + + &::before { + content: ''; + height: 22px; + width: 22px; + mask-repeat: no-repeat; + mask-size: contain; + mask-position: center; + display: block; + background-color: $icon-button-color; + } } -.mx_ImageView_shim { - height: 30px; +.mx_ImageView_button_rotateCW::before { + mask-image: url('$(res)/img/image-view/rotate-cw.svg'); } -.mx_ImageView_rhs { - order: 3; - flex: 1 1 10%; - min-width: 300px; - // background-color: #800; - // height: 20px; +.mx_ImageView_button_rotateCCW::before { + mask-image: url('$(res)/img/image-view/rotate-ccw.svg'); +} + +.mx_ImageView_button_zoomOut::before { + mask-image: url('$(res)/img/image-view/zoom-out.svg'); +} + +.mx_ImageView_button_zoomIn::before { + mask-image: url('$(res)/img/image-view/zoom-in.svg'); +} + +.mx_ImageView_button_download::before { + mask-image: url('$(res)/img/image-view/download.svg'); +} + +.mx_ImageView_button_more::before { + mask-image: url('$(res)/img/image-view/more.svg'); +} + +.mx_ImageView_button_close { + border-radius: 100%; + background: #21262c; // same on all themes + &::before { + width: 32px; + height: 32px; + mask-image: url('$(res)/img/image-view/close.svg'); + mask-size: 40%; + } } diff --git a/res/css/views/elements/_InlineSpinner.scss b/res/css/views/elements/_InlineSpinner.scss index 6b91e45923..ca5cb5d3a8 100644 --- a/res/css/views/elements/_InlineSpinner.scss +++ b/res/css/views/elements/_InlineSpinner.scss @@ -18,7 +18,11 @@ limitations under the License. display: inline; } -.mx_InlineSpinner_spin img { +.mx_InlineSpinner img, .mx_InlineSpinner_icon { margin: 0px 6px; vertical-align: -3px; } + +.mx_InlineSpinner_icon { + display: inline-block; +} diff --git a/res/css/views/elements/_InviteReason.scss b/res/css/views/elements/_InviteReason.scss new file mode 100644 index 0000000000..2c2e5687e6 --- /dev/null +++ b/res/css/views/elements/_InviteReason.scss @@ -0,0 +1,57 @@ +/* +Copyright 2021 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_InviteReason { + position: relative; + margin-bottom: 1em; + + .mx_InviteReason_reason { + visibility: visible; + } + + .mx_InviteReason_view { + display: none; + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + justify-content: center; + align-items: center; + cursor: pointer; + color: $secondary-fg-color; + + &::before { + content: ""; + margin-right: 8px; + background-color: $secondary-fg-color; + mask-image: url('$(res)/img/feather-customised/eye.svg'); + display: inline-block; + width: 18px; + height: 14px; + } + } +} + +.mx_InviteReason_hidden { + .mx_InviteReason_reason { + visibility: hidden; + } + + .mx_InviteReason_view { + display: flex; + } +} diff --git a/res/css/views/elements/_MiniAvatarUploader.scss b/res/css/views/elements/_MiniAvatarUploader.scss index 698184a095..df4676ab56 100644 --- a/res/css/views/elements/_MiniAvatarUploader.scss +++ b/res/css/views/elements/_MiniAvatarUploader.scss @@ -28,8 +28,7 @@ limitations under the License. top: 0; } - &::before, &::after { - content: ''; + .mx_MiniAvatarUploader_indicator { position: absolute; height: 26px; @@ -37,27 +36,22 @@ limitations under the License. right: -6px; bottom: -6px; - } - &::before { background-color: $primary-bg-color; border-radius: 50%; z-index: 1; - } - &::after { - background-color: $secondary-fg-color; - mask-position: center; - mask-repeat: no-repeat; - mask-image: url('$(res)/img/element-icons/camera.svg'); - mask-size: 16px; - z-index: 2; - } + .mx_MiniAvatarUploader_cameraIcon { + height: 100%; + width: 100%; - &.mx_MiniAvatarUploader_busy::after { - background: url("$(res)/img/spinner.gif") no-repeat center; - background-size: 80%; - mask: unset; + background-color: $secondary-fg-color; + mask-position: center; + mask-repeat: no-repeat; + mask-image: url('$(res)/img/element-icons/camera.svg'); + mask-size: 16px; + z-index: 2; + } } } diff --git a/res/css/views/elements/_ProgressBar.scss b/res/css/views/elements/_ProgressBar.scss index 770978e921..c075ac74ff 100644 --- a/res/css/views/elements/_ProgressBar.scss +++ b/res/css/views/elements/_ProgressBar.scss @@ -21,7 +21,7 @@ progress.mx_ProgressBar { appearance: none; border: none; - @mixin ProgressBarBorderRadius "6px"; + @mixin ProgressBarBorderRadius 6px; @mixin ProgressBarColour $progressbar-fg-color; @mixin ProgressBarBgColour $progressbar-bg-color; ::-webkit-progress-value { diff --git a/res/css/views/elements/_Spinner.scss b/res/css/views/elements/_Spinner.scss index 01b4f23c2c..93d5e2d96c 100644 --- a/res/css/views/elements/_Spinner.scss +++ b/res/css/views/elements/_Spinner.scss @@ -26,3 +26,19 @@ limitations under the License. .mx_MatrixChat_middlePanel .mx_Spinner { height: auto; } + +@keyframes spin { + from { + transform: rotateZ(0deg); + } + to { + transform: rotateZ(360deg); + } +} + +.mx_Spinner_icon { + background-color: $primary-fg-color; + mask: url('$(res)/img/spinner.svg'); + mask-size: contain; + animation: 1.1s steps(12, end) infinite spin; +} diff --git a/res/css/views/messages/_MFileBody.scss b/res/css/views/messages/_MFileBody.scss index b45126acf8..c215d69ec2 100644 --- a/res/css/views/messages/_MFileBody.scss +++ b/res/css/views/messages/_MFileBody.scss @@ -61,9 +61,9 @@ limitations under the License. .mx_MFileBody_info { background-color: $message-body-panel-bg-color; - border-radius: 4px; - width: 270px; - padding: 8px; + border-radius: 12px; + width: 243px; // same width as a playable voice message, accounting for padding + padding: 6px 12px; color: $message-body-panel-fg-color; .mx_MFileBody_info_icon { @@ -82,7 +82,7 @@ limitations under the License. mask-position: center; mask-size: cover; mask-image: url('$(res)/img/element-icons/room/composer/attach.svg'); - background-color: $message-body-panel-fg-color; + background-color: $message-body-panel-icon-fg-color; width: 13px; height: 15px; diff --git a/res/css/views/messages/_MVoiceMessageBody.scss b/res/css/views/messages/_MVoiceMessageBody.scss new file mode 100644 index 0000000000..3dfb98f778 --- /dev/null +++ b/res/css/views/messages/_MVoiceMessageBody.scss @@ -0,0 +1,19 @@ +/* +Copyright 2021 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_MVoiceMessageBody { + display: inline-block; // makes the playback controls magically line up +} diff --git a/res/css/views/messages/_MessageActionBar.scss b/res/css/views/messages/_MessageActionBar.scss index 1254b496b5..d41ac3a4ba 100644 --- a/res/css/views/messages/_MessageActionBar.scss +++ b/res/css/views/messages/_MessageActionBar.scss @@ -85,6 +85,7 @@ limitations under the License. left: 0; height: 100%; width: 100%; + mask-size: 18px; mask-repeat: no-repeat; mask-position: center; background-color: $message-action-bar-fg-color; @@ -105,3 +106,11 @@ limitations under the License. .mx_MessageActionBar_optionsButton::after { mask-image: url('$(res)/img/element-icons/context-menu.svg'); } + +.mx_MessageActionBar_resendButton::after { + mask-image: url('$(res)/img/element-icons/retry.svg'); +} + +.mx_MessageActionBar_cancelButton::after { + mask-image: url('$(res)/img/element-icons/trashcan.svg'); +} diff --git a/res/css/views/messages/_ReactionsRow.scss b/res/css/views/messages/_ReactionsRow.scss index 2f5695e1fb..e05065eb02 100644 --- a/res/css/views/messages/_ReactionsRow.scss +++ b/res/css/views/messages/_ReactionsRow.scss @@ -17,18 +17,56 @@ limitations under the License. .mx_ReactionsRow { margin: 6px 0; color: $primary-fg-color; + + .mx_ReactionsRow_addReactionButton { + position: relative; + display: inline-block; + visibility: hidden; // show on hover of the .mx_EventTile + width: 24px; + height: 24px; + vertical-align: middle; + margin-left: 4px; + + &::before { + content: ''; + position: absolute; + height: 100%; + width: 100%; + mask-size: 16px; + mask-repeat: no-repeat; + mask-position: center; + background-color: $tertiary-fg-color; + mask-image: url('$(res)/img/element-icons/room/message-bar/emoji.svg'); + } + + &.mx_ReactionsRow_addReactionButton_active { + visibility: visible; // keep showing whilst the context menu is shown + } + + &:hover, &.mx_ReactionsRow_addReactionButton_active { + &::before { + background-color: $primary-fg-color; + } + } + } +} + +.mx_EventTile:hover .mx_ReactionsRow_addReactionButton { + visibility: visible; } .mx_ReactionsRow_showAll { text-decoration: none; - font-size: $font-10px; - font-weight: 600; - margin-left: 6px; - vertical-align: top; + font-size: $font-12px; + line-height: $font-20px; + margin-left: 4px; + vertical-align: middle; - &:hover, - &:link, - &:visited { - color: $accent-color; + &:link, &:visited { + color: $tertiary-fg-color; + } + + &:hover { + color: $primary-fg-color; } } diff --git a/res/css/views/messages/_ReactionsRowButton.scss b/res/css/views/messages/_ReactionsRowButton.scss index 7158ffc027..766fea2f8f 100644 --- a/res/css/views/messages/_ReactionsRowButton.scss +++ b/res/css/views/messages/_ReactionsRowButton.scss @@ -16,14 +16,15 @@ limitations under the License. .mx_ReactionsRowButton { display: inline-flex; - line-height: $font-21px; + line-height: $font-20px; margin-right: 6px; - padding: 0 6px; + padding: 1px 6px; border: 1px solid $reaction-row-button-border-color; border-radius: 10px; background-color: $reaction-row-button-bg-color; cursor: pointer; user-select: none; + vertical-align: middle; &:hover { border-color: $reaction-row-button-hover-border-color; @@ -34,6 +35,10 @@ limitations under the License. border-color: $reaction-row-button-selected-border-color; } + &.mx_AccessibleButton_disabled { + cursor: not-allowed; + } + .mx_ReactionsRowButton_content { max-width: 100px; overflow: hidden; diff --git a/res/css/views/right_panel/_RoomSummaryCard.scss b/res/css/views/right_panel/_RoomSummaryCard.scss index 36882f4e8b..dc7804d072 100644 --- a/res/css/views/right_panel/_RoomSummaryCard.scss +++ b/res/css/views/right_panel/_RoomSummaryCard.scss @@ -36,6 +36,7 @@ limitations under the License. -webkit-box-orient: vertical; overflow: hidden; text-overflow: ellipsis; + white-space: pre-wrap; } .mx_RoomSummaryCard_avatar { diff --git a/res/css/views/rooms/_BasicMessageComposer.scss b/res/css/views/rooms/_BasicMessageComposer.scss index 4f58c08617..e1ba468204 100644 --- a/res/css/views/rooms/_BasicMessageComposer.scss +++ b/res/css/views/rooms/_BasicMessageComposer.scss @@ -68,8 +68,8 @@ limitations under the License. } &.mx_BasicMessageComposer_input_disabled { + // Ignore all user input to avoid accidentally triggering the composer pointer-events: none; - cursor: not-allowed; } } diff --git a/res/css/views/rooms/_EventTile.scss b/res/css/views/rooms/_EventTile.scss index 028d9a7556..5d1dd04383 100644 --- a/res/css/views/rooms/_EventTile.scss +++ b/res/css/views/rooms/_EventTile.scss @@ -159,6 +159,7 @@ $left-gutter: 64px; .mx_EventTile.focus-visible:focus-within > div > a > .mx_MessageTimestamp, .mx_IRCLayout .mx_EventTile_last > a > .mx_MessageTimestamp, .mx_IRCLayout .mx_EventTile:hover > a > .mx_MessageTimestamp, +.mx_IRCLayout .mx_ReplyThread .mx_EventTile > a > .mx_MessageTimestamp, .mx_IRCLayout .mx_EventTile.mx_EventTile_actionBarFocused > a > .mx_MessageTimestamp, .mx_IRCLayout .mx_EventTile.focus-visible:focus-within > a > .mx_MessageTimestamp { visibility: visible; @@ -213,10 +214,6 @@ $left-gutter: 64px; color: $accent-fg-color; } -.mx_EventTile_notSent { - color: $event-notsent-color; -} - .mx_EventTile_receiptSent, .mx_EventTile_receiptSending { // We don't use `position: relative` on the element because then it won't line @@ -282,6 +279,10 @@ $left-gutter: 64px; display: inline-block; height: $font-14px; width: $font-14px; + + transition: + left var(--transition-short) ease-out, + top var(--transition-standard) ease-out; } .mx_EventTile_readAvatarRemainder { diff --git a/res/css/views/rooms/_IRCLayout.scss b/res/css/views/rooms/_IRCLayout.scss index 21baa795e6..b6b901757c 100644 --- a/res/css/views/rooms/_IRCLayout.scss +++ b/res/css/views/rooms/_IRCLayout.scss @@ -216,6 +216,25 @@ $irc-line-height: $font-18px; } } } + + .mx_EventTile_emote { + > .mx_EventTile_avatar { + margin-left: initial; + } + } + + .mx_MessageTimestamp { + width: initial; + } + + /** + * adding the icon back in the document flow + * if it's not present, there's no unwanted wasted space + */ + .mx_EventTile_e2eIcon { + position: relative; + order: -1; + } } .mx_ProfileResizer { diff --git a/res/css/views/rooms/_NewRoomIntro.scss b/res/css/views/rooms/_NewRoomIntro.scss index 9c2a428cb3..e0cccfa885 100644 --- a/res/css/views/rooms/_NewRoomIntro.scss +++ b/res/css/views/rooms/_NewRoomIntro.scss @@ -18,8 +18,8 @@ limitations under the License. margin: 40px 0 48px 64px; .mx_MiniAvatarUploader_hasAvatar:not(.mx_MiniAvatarUploader_busy):not(:hover) { - &::before, &::after { - content: unset; + .mx_MiniAvatarUploader_indicator { + display: none; } } diff --git a/res/css/views/rooms/_RoomPreviewBar.scss b/res/css/views/rooms/_RoomPreviewBar.scss index b340080837..0b1da7a41c 100644 --- a/res/css/views/rooms/_RoomPreviewBar.scss +++ b/res/css/views/rooms/_RoomPreviewBar.scss @@ -40,35 +40,6 @@ limitations under the License. word-break: break-word; } - .mx_RoomPreviewBar_reason { - text-align: left; - background-color: $primary-bg-color; - border: 1px solid $invite-reason-border-color; - border-radius: 10px; - padding: 0 16px 12px 16px; - margin: 5px 0 20px 0; - - div { - pointer-events: none; - } - - .mx_EventTile_msgOption { - display: none; - } - - .mx_MatrixChat_useCompactLayout & { - padding-top: 9px; - } - - &.mx_EventTilePreview_faded { - cursor: pointer; - - .mx_SenderProfile, .mx_EventTile_avatar { - opacity: 0.3; - } - } - } - .mx_Spinner { width: auto; height: auto; diff --git a/res/css/views/rooms/_RoomSublist.scss b/res/css/views/rooms/_RoomSublist.scss index 92a475694e..1aafa8da0e 100644 --- a/res/css/views/rooms/_RoomSublist.scss +++ b/res/css/views/rooms/_RoomSublist.scss @@ -18,6 +18,10 @@ limitations under the License. margin-left: 8px; margin-bottom: 4px; + &.mx_RoomSublist_hidden { + display: none; + } + .mx_RoomSublist_headerContainer { // Create a flexbox to make alignment easy display: flex; @@ -37,7 +41,9 @@ limitations under the License. // The combined height must be set in the LeftPanel component for sticky headers // to work correctly. padding-bottom: 8px; - height: 24px; + // Allow the container to collapse on itself if its children + // are not in the normal document flow + max-height: 24px; color: $roomlist-header-color; .mx_RoomSublist_stickable { @@ -92,7 +98,7 @@ limitations under the License. position: relative; width: 24px; height: 24px; - border-radius: 32px; + border-radius: 8px; &::before { content: ''; @@ -108,6 +114,11 @@ limitations under the License. } } + .mx_RoomSublist_auxButton:hover, + .mx_RoomSublist_menuButton:hover { + background: $roomlist-button-bg-color; + } + // Hide the menu button by default .mx_RoomSublist_menuButton { visibility: hidden; diff --git a/res/css/views/rooms/_VoiceRecordComposerTile.scss b/res/css/views/rooms/_VoiceRecordComposerTile.scss index 2fb112a38c..a3ee104bd8 100644 --- a/res/css/views/rooms/_VoiceRecordComposerTile.scss +++ b/res/css/views/rooms/_VoiceRecordComposerTile.scss @@ -35,42 +35,64 @@ limitations under the License. } } -.mx_VoiceRecordComposerTile_waveformContainer { - padding: 5px; - padding-right: 4px; // there's 1px from the waveform itself, so account for that - padding-left: 15px; // +10px for the live circle, +5px for regular padding - background-color: $voice-record-waveform-bg-color; - border-radius: 12px; - margin-right: 12px; // isolate from stop button +.mx_VoiceRecordComposerTile_delete { + width: 14px; // w&h are size of icon + height: 18px; + vertical-align: middle; + margin-right: 11px; // distance from left edge of waveform container (container has some margin too) + background-color: $voice-record-icon-color; + mask-repeat: no-repeat; + mask-size: contain; + mask-image: url('$(res)/img/element-icons/trashcan.svg'); +} - // Cheat at alignment a bit - display: flex; - align-items: center; +.mx_MessageComposer_row .mx_VoiceMessagePrimaryContainer { + // Note: remaining class properties are in the PlayerContainer CSS. + + margin: 6px; // force the composer area to put a gutter around us + margin-right: 12px; // isolate from stop/send button position: relative; // important for the live circle - color: $voice-record-waveform-fg-color; - font-size: $font-14px; + &.mx_VoiceRecordComposerTile_recording { + // We are putting the circle in this padding, so we need +10px from the regular + // padding on the left side. + padding-left: 22px; - &::before { - // TODO: @@ TravisR: Animate - content: ''; - background-color: $voice-record-live-circle-color; - width: 10px; - height: 10px; - position: absolute; - left: 8px; - top: 16px; // vertically center - border-radius: 10px; - } + &::before { + animation: recording-pulse 2s infinite; - .mx_Waveform_bar { - background-color: $voice-record-waveform-fg-color; - } - - .mx_Clock { - padding-right: 8px; // isolate from waveform - padding-left: 10px; // isolate from live circle - width: 42px; // we're not using a monospace font, so fake it + content: ''; + background-color: $voice-record-live-circle-color; + width: 10px; + height: 10px; + position: absolute; + left: 12px; // 12px from the left edge for container padding + top: 18px; // vertically center (middle align with clock) + border-radius: 10px; + } + } +} + +// The keyframes are slightly weird here to help make a ramping/punch effect +// for the recording dot. We start and end at 100% opacity to help make the +// dot feel a bit like a real lamp that is blinking: the animation ends up +// spending a lot of its time showing a steady state without a fade effect. +// This lamp effect extends into why the 0% opacity keyframe is not in the +// midpoint: lamps take longer to turn off than they do to turn on, and the +// extra frames give it a bit of a realistic punch for when the animation is +// ramping back up to 100% opacity. +// +// Target animation timings: steady for 1.5s, fade out for 0.3s, fade in for 0.2s +// (intended to be used in a loop for 2s animation speed) +@keyframes recording-pulse { + 0% { + opacity: 1; + } + 35% { + opacity: 0; + } + 65% { + opacity: 1; } } diff --git a/res/css/views/settings/tabs/user/_HelpUserSettingsTab.scss b/res/css/views/settings/tabs/user/_HelpUserSettingsTab.scss index 109edfff81..0f879d209e 100644 --- a/res/css/views/settings/tabs/user/_HelpUserSettingsTab.scss +++ b/res/css/views/settings/tabs/user/_HelpUserSettingsTab.scss @@ -22,3 +22,34 @@ limitations under the License. .mx_HelpUserSettingsTab span.mx_AccessibleButton { word-break: break-word; } + +.mx_HelpUserSettingsTab code { + word-break: break-all; + user-select: all; +} + +.mx_HelpUserSettingsTab_accessToken { + display: flex; + justify-content: space-between; + border-radius: 5px; + border: solid 1px $light-fg-color; + margin-bottom: 10px; + margin-top: 10px; + padding: 10px; +} + +.mx_HelpUserSettingsTab_accessToken_copy { + flex-shrink: 0; + cursor: pointer; + margin-left: 20px; + display: inherit; +} + +.mx_HelpUserSettingsTab_accessToken_copy > div { + mask-image: url($copy-button-url); + background-color: $message-action-bar-fg-color; + margin-left: 5px; + width: 20px; + height: 20px; + background-repeat: no-repeat; +} diff --git a/res/css/views/settings/tabs/user/_LabsUserSettingsTab.scss b/res/css/views/settings/tabs/user/_LabsUserSettingsTab.scss new file mode 100644 index 0000000000..540db48d65 --- /dev/null +++ b/res/css/views/settings/tabs/user/_LabsUserSettingsTab.scss @@ -0,0 +1,25 @@ +/* +Copyright 2021 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_LabsUserSettingsTab { + .mx_SettingsTab_section { + margin-top: 32px; + + .mx_SettingsFlag { + margin-right: 0; // remove right margin to align with beta cards + } + } +} diff --git a/res/css/views/spaces/_SpaceCreateMenu.scss b/res/css/views/spaces/_SpaceCreateMenu.scss index ef3fea351b..88b9d8f693 100644 --- a/res/css/views/spaces/_SpaceCreateMenu.scss +++ b/res/css/views/spaces/_SpaceCreateMenu.scss @@ -29,6 +29,7 @@ $spacePanelWidth: 71px; width: 480px; box-sizing: border-box; background-color: $primary-bg-color; + position: relative; > div { > h2 { @@ -44,6 +45,13 @@ $spacePanelWidth: 71px; } } + // XXX remove this when spaces leaves Beta + .mx_BetaCard_betaPill { + position: absolute; + top: 24px; + right: 24px; + } + .mx_SpaceCreateMenuType { @mixin SpacePillButton; } @@ -59,7 +67,7 @@ $spacePanelWidth: 71px; width: 28px; height: 28px; position: relative; - background-color: $theme-button-bg-color; + background-color: $roomlist-button-bg-color; border-radius: 14px; margin-bottom: 12px; @@ -70,7 +78,7 @@ $spacePanelWidth: 71px; width: 28px; top: 0; left: 0; - background-color: $muted-fg-color; + background-color: $tertiary-fg-color; transform: rotate(90deg); mask-repeat: no-repeat; mask-position: 2px 3px; diff --git a/res/css/views/voice_messages/_PlayPauseButton.scss b/res/css/views/voice_messages/_PlayPauseButton.scss new file mode 100644 index 0000000000..6caedafa29 --- /dev/null +++ b/res/css/views/voice_messages/_PlayPauseButton.scss @@ -0,0 +1,51 @@ +/* +Copyright 2021 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_PlayPauseButton { + position: relative; + width: 32px; + height: 32px; + border-radius: 32px; + background-color: $voice-playback-button-bg-color; + + &::before { + content: ''; + position: absolute; // sizing varies by icon + background-color: $voice-playback-button-fg-color; + mask-repeat: no-repeat; + mask-size: contain; + } + + &.mx_PlayPauseButton_disabled::before { + opacity: 0.5; + } + + &.mx_PlayPauseButton_play::before { + width: 13px; + height: 16px; + top: 8px; // center + left: 12px; // center + mask-image: url('$(res)/img/element-icons/play.svg'); + } + + &.mx_PlayPauseButton_pause::before { + width: 10px; + height: 12px; + top: 10px; // center + left: 11px; // center + mask-image: url('$(res)/img/element-icons/pause.svg'); + } +} diff --git a/res/css/views/voice_messages/_PlaybackContainer.scss b/res/css/views/voice_messages/_PlaybackContainer.scss new file mode 100644 index 0000000000..20def16d6a --- /dev/null +++ b/res/css/views/voice_messages/_PlaybackContainer.scss @@ -0,0 +1,53 @@ +/* +Copyright 2021 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. +*/ + +// Dev note: there's no actual component called . These classes +// are shared amongst multiple voice message components. + +// Container for live recording and playback controls +.mx_VoiceMessagePrimaryContainer { + // 7px top and bottom for visual design. 12px left & right, but the waveform (right) + // has a 1px padding on it that we want to account for. + padding: 7px 12px 7px 11px; + background-color: $voice-record-waveform-bg-color; + border-radius: 12px; + + // Cheat at alignment a bit + display: flex; + align-items: center; + + color: $voice-record-waveform-fg-color; + font-size: $font-14px; + line-height: $font-24px; + + .mx_Waveform { + .mx_Waveform_bar { + background-color: $voice-record-waveform-incomplete-fg-color; + + &.mx_Waveform_bar_100pct { + // Small animation to remove the mechanical feel of progress + transition: background-color 250ms ease; + background-color: $voice-record-waveform-fg-color; + } + } + } + + .mx_Clock { + width: $font-42px; // we're not using a monospace font, so fake it + padding-right: 6px; // with the fixed width this ends up as a visual 8px most of the time, as intended. + padding-left: 8px; // isolate from recording circle / play control + } +} diff --git a/res/css/views/voip/_CallView.scss b/res/css/views/voip/_CallView.scss index e032e4582d..0be75be28c 100644 --- a/res/css/views/voip/_CallView.scss +++ b/res/css/views/voip/_CallView.scss @@ -17,7 +17,7 @@ limitations under the License. .mx_CallView { border-radius: 8px; - background-color: $voipcall-plinth-color; + background-color: $dark-panel-bg-color; padding-left: 8px; padding-right: 8px; // XXX: CallContainer sets pointer-events: none - should probably be set back in a better place @@ -27,9 +27,12 @@ limitations under the License. .mx_CallView_large { padding-bottom: 10px; margin: 5px 5px 5px 18px; + display: flex; + flex-direction: column; + flex: 1; .mx_CallView_voice { - height: 360px; + flex: 1; } } @@ -37,7 +40,8 @@ limitations under the License. width: 320px; padding-bottom: 8px; margin-top: 10px; - box-shadow: 0px 14px 24px rgba(0, 0, 0, 0.08); + background-color: $voipcall-plinth-color; + box-shadow: 0px 4px 20px rgba(0, 0, 0, 0.20); border-radius: 8px; .mx_CallView_voice { @@ -61,14 +65,17 @@ limitations under the License. } } -.mx_CallView_voice { +.mx_CallView_content { position: relative; display: flex; - flex-direction: column; + border-radius: 8px; +} + +.mx_CallView_voice { align-items: center; justify-content: center; + flex-direction: column; background-color: $inverted-bg-color; - border-radius: 8px; } .mx_CallView_voice_avatarsContainer { @@ -104,9 +111,8 @@ limitations under the License. .mx_CallView_video { width: 100%; - position: relative; + height: 100%; z-index: 30; - border-radius: 8px; overflow: hidden; } @@ -177,6 +183,7 @@ limitations under the License. flex-direction: row; align-items: center; justify-content: left; + flex-shrink: 0; } .mx_CallView_header_callType { diff --git a/res/css/views/voip/_CallViewForRoom.scss b/res/css/views/voip/_CallViewForRoom.scss new file mode 100644 index 0000000000..769e00338e --- /dev/null +++ b/res/css/views/voip/_CallViewForRoom.scss @@ -0,0 +1,46 @@ +/* +Copyright 2021 Šimon Brandner + +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_CallViewForRoom { + overflow: hidden; + + .mx_CallViewForRoom_ResizeWrapper { + display: flex; + margin-bottom: 8px; + + &:hover .mx_CallViewForRoom_ResizeHandle { + // Need to use important to override element style attributes + // set by re-resizable + width: 100% !important; + + display: flex; + justify-content: center; + + &::after { + content: ''; + margin-top: 3px; + + border-radius: 4px; + + height: 4px; + width: 100%; + max-width: 64px; + + background-color: $primary-fg-color; + } + } + } +} diff --git a/res/css/views/voip/_VideoFeed.scss b/res/css/views/voip/_VideoFeed.scss index 3e473a80b2..7d85ac264e 100644 --- a/res/css/views/voip/_VideoFeed.scss +++ b/res/css/views/voip/_VideoFeed.scss @@ -14,21 +14,37 @@ See the License for the specific language governing permissions and limitations under the License. */ +.mx_VideoFeed_voice { + // We don't want to collide with the call controls that have 52px of height + padding-bottom: 52px; + background-color: $inverted-bg-color; +} + + .mx_VideoFeed_remote { width: 100%; - max-height: 100%; - background-color: #000; - z-index: 50; + height: 100%; + display: flex; + justify-content: center; + align-items: center; + + &.mx_VideoFeed_video { + background-color: #000; + } } .mx_VideoFeed_local { - width: 25%; - height: 25%; + max-width: 25%; + max-height: 25%; position: absolute; right: 10px; top: 10px; z-index: 100; border-radius: 4px; + + &.mx_VideoFeed_video { + background-color: transparent; + } } .mx_VideoFeed_mirror { diff --git a/res/fonts/Inter/Inter-Bold.woff b/res/fonts/Inter/Inter-Bold.woff index 61e1c25e64..2ec7ac3d21 100644 Binary files a/res/fonts/Inter/Inter-Bold.woff and b/res/fonts/Inter/Inter-Bold.woff differ diff --git a/res/fonts/Inter/Inter-Bold.woff2 b/res/fonts/Inter/Inter-Bold.woff2 index 6c401bb09b..6989c99229 100644 Binary files a/res/fonts/Inter/Inter-Bold.woff2 and b/res/fonts/Inter/Inter-Bold.woff2 differ diff --git a/res/fonts/Inter/Inter-BoldItalic.woff b/res/fonts/Inter/Inter-BoldItalic.woff index 2de403edd1..aa35b79745 100644 Binary files a/res/fonts/Inter/Inter-BoldItalic.woff and b/res/fonts/Inter/Inter-BoldItalic.woff differ diff --git a/res/fonts/Inter/Inter-BoldItalic.woff2 b/res/fonts/Inter/Inter-BoldItalic.woff2 index 80efd4848d..18b4c1ce5e 100644 Binary files a/res/fonts/Inter/Inter-BoldItalic.woff2 and b/res/fonts/Inter/Inter-BoldItalic.woff2 differ diff --git a/res/fonts/Inter/Inter-Italic.woff b/res/fonts/Inter/Inter-Italic.woff index e7da6663fe..4b765bd592 100644 Binary files a/res/fonts/Inter/Inter-Italic.woff and b/res/fonts/Inter/Inter-Italic.woff differ diff --git a/res/fonts/Inter/Inter-Italic.woff2 b/res/fonts/Inter/Inter-Italic.woff2 index 8559dfde38..bd5f255a98 100644 Binary files a/res/fonts/Inter/Inter-Italic.woff2 and b/res/fonts/Inter/Inter-Italic.woff2 differ diff --git a/res/fonts/Inter/Inter-Medium.woff b/res/fonts/Inter/Inter-Medium.woff index 8c36a6345e..7d55f34cca 100644 Binary files a/res/fonts/Inter/Inter-Medium.woff and b/res/fonts/Inter/Inter-Medium.woff differ diff --git a/res/fonts/Inter/Inter-Medium.woff2 b/res/fonts/Inter/Inter-Medium.woff2 index 3b31d3350a..a916b47fc8 100644 Binary files a/res/fonts/Inter/Inter-Medium.woff2 and b/res/fonts/Inter/Inter-Medium.woff2 differ diff --git a/res/fonts/Inter/Inter-MediumItalic.woff b/res/fonts/Inter/Inter-MediumItalic.woff index fb79e91ff4..422ab0576a 100644 Binary files a/res/fonts/Inter/Inter-MediumItalic.woff and b/res/fonts/Inter/Inter-MediumItalic.woff differ diff --git a/res/fonts/Inter/Inter-MediumItalic.woff2 b/res/fonts/Inter/Inter-MediumItalic.woff2 index d32c111f9c..f623924aea 100644 Binary files a/res/fonts/Inter/Inter-MediumItalic.woff2 and b/res/fonts/Inter/Inter-MediumItalic.woff2 differ diff --git a/res/fonts/Inter/Inter-Regular.woff b/res/fonts/Inter/Inter-Regular.woff index 7d587c40bf..7ff51b7d8f 100644 Binary files a/res/fonts/Inter/Inter-Regular.woff and b/res/fonts/Inter/Inter-Regular.woff differ diff --git a/res/fonts/Inter/Inter-Regular.woff2 b/res/fonts/Inter/Inter-Regular.woff2 index d5ffd2a1f1..554aed6612 100644 Binary files a/res/fonts/Inter/Inter-Regular.woff2 and b/res/fonts/Inter/Inter-Regular.woff2 differ diff --git a/res/fonts/Inter/Inter-SemiBold.woff b/res/fonts/Inter/Inter-SemiBold.woff index 99df06cbee..76e507a515 100644 Binary files a/res/fonts/Inter/Inter-SemiBold.woff and b/res/fonts/Inter/Inter-SemiBold.woff differ diff --git a/res/fonts/Inter/Inter-SemiBold.woff2 b/res/fonts/Inter/Inter-SemiBold.woff2 index df746af999..9307998993 100644 Binary files a/res/fonts/Inter/Inter-SemiBold.woff2 and b/res/fonts/Inter/Inter-SemiBold.woff2 differ diff --git a/res/fonts/Inter/Inter-SemiBoldItalic.woff b/res/fonts/Inter/Inter-SemiBoldItalic.woff index 91e192b9f1..382181212d 100644 Binary files a/res/fonts/Inter/Inter-SemiBoldItalic.woff and b/res/fonts/Inter/Inter-SemiBoldItalic.woff differ diff --git a/res/fonts/Inter/Inter-SemiBoldItalic.woff2 b/res/fonts/Inter/Inter-SemiBoldItalic.woff2 index ff8774ccb4..f19f5505ec 100644 Binary files a/res/fonts/Inter/Inter-SemiBoldItalic.woff2 and b/res/fonts/Inter/Inter-SemiBoldItalic.woff2 differ diff --git a/res/img/betas/spaces.png b/res/img/betas/spaces.png new file mode 100644 index 0000000000..f4cfa90b4e Binary files /dev/null and b/res/img/betas/spaces.png differ diff --git a/res/img/cancel-white.svg b/res/img/cancel-white.svg deleted file mode 100644 index 65e14c2fbc..0000000000 --- a/res/img/cancel-white.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - Slice 1 - Created with Sketch. - - - - - \ No newline at end of file diff --git a/res/img/element-icons/pause.svg b/res/img/element-icons/pause.svg new file mode 100644 index 0000000000..293c0a10d8 --- /dev/null +++ b/res/img/element-icons/pause.svg @@ -0,0 +1,4 @@ + + + + diff --git a/res/img/element-icons/play.svg b/res/img/element-icons/play.svg new file mode 100644 index 0000000000..339e20b729 --- /dev/null +++ b/res/img/element-icons/play.svg @@ -0,0 +1,3 @@ + + + diff --git a/res/img/element-icons/retry.svg b/res/img/element-icons/retry.svg new file mode 100644 index 0000000000..09448d6458 --- /dev/null +++ b/res/img/element-icons/retry.svg @@ -0,0 +1,3 @@ + + + diff --git a/res/img/element-icons/room/composer/emoji.svg b/res/img/element-icons/room/composer/emoji.svg index 9613d9edd9..b02cb69364 100644 --- a/res/img/element-icons/room/composer/emoji.svg +++ b/res/img/element-icons/room/composer/emoji.svg @@ -1,7 +1,7 @@ - - - - - + + + + + diff --git a/res/img/element-icons/room/message-bar/emoji.svg b/res/img/element-icons/room/message-bar/emoji.svg index 697f656b8a..07fee5b834 100644 --- a/res/img/element-icons/room/message-bar/emoji.svg +++ b/res/img/element-icons/room/message-bar/emoji.svg @@ -1,5 +1,3 @@ - - - - + + diff --git a/res/img/element-icons/trashcan.svg b/res/img/element-icons/trashcan.svg new file mode 100644 index 0000000000..f8fb8b5c46 --- /dev/null +++ b/res/img/element-icons/trashcan.svg @@ -0,0 +1,3 @@ + + + diff --git a/res/img/image-view/close.svg b/res/img/image-view/close.svg new file mode 100644 index 0000000000..d603b7f5cc --- /dev/null +++ b/res/img/image-view/close.svg @@ -0,0 +1,3 @@ + + + diff --git a/res/img/image-view/download.svg b/res/img/image-view/download.svg new file mode 100644 index 0000000000..c51deed876 --- /dev/null +++ b/res/img/image-view/download.svg @@ -0,0 +1,3 @@ + + + diff --git a/res/img/image-view/more.svg b/res/img/image-view/more.svg new file mode 100644 index 0000000000..4f5fa6f9b9 --- /dev/null +++ b/res/img/image-view/more.svg @@ -0,0 +1,3 @@ + + + diff --git a/res/img/image-view/rotate-ccw.svg b/res/img/image-view/rotate-ccw.svg new file mode 100644 index 0000000000..85ea3198de --- /dev/null +++ b/res/img/image-view/rotate-ccw.svg @@ -0,0 +1,3 @@ + + + diff --git a/res/img/image-view/rotate-cw.svg b/res/img/image-view/rotate-cw.svg new file mode 100644 index 0000000000..e337f3420e --- /dev/null +++ b/res/img/image-view/rotate-cw.svg @@ -0,0 +1,3 @@ + + + diff --git a/res/img/image-view/zoom-in.svg b/res/img/image-view/zoom-in.svg new file mode 100644 index 0000000000..c0816d489e --- /dev/null +++ b/res/img/image-view/zoom-in.svg @@ -0,0 +1,3 @@ + + + diff --git a/res/img/image-view/zoom-out.svg b/res/img/image-view/zoom-out.svg new file mode 100644 index 0000000000..0539e8c81a --- /dev/null +++ b/res/img/image-view/zoom-out.svg @@ -0,0 +1,3 @@ + + + diff --git a/res/img/rotate-ccw.svg b/res/img/rotate-ccw.svg deleted file mode 100644 index 3924eca040..0000000000 --- a/res/img/rotate-ccw.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/res/img/rotate-cw.svg b/res/img/rotate-cw.svg deleted file mode 100644 index 91021c96d8..0000000000 --- a/res/img/rotate-cw.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/res/img/spinner.gif b/res/img/spinner.gif deleted file mode 100644 index ab4871214b..0000000000 Binary files a/res/img/spinner.gif and /dev/null differ diff --git a/res/img/spinner.svg b/res/img/spinner.svg index 08965e982e..c3680f19d2 100644 --- a/res/img/spinner.svg +++ b/res/img/spinner.svg @@ -1,141 +1,96 @@ - - - start - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + diff --git a/res/themes/dark/css/_dark.scss b/res/themes/dark/css/_dark.scss index f7fda92346..2d0e3d2a8b 100644 --- a/res/themes/dark/css/_dark.scss +++ b/res/themes/dark/css/_dark.scss @@ -9,6 +9,7 @@ $header-panel-text-primary-color: #B9BEC6; $header-panel-text-secondary-color: #c8c8cd; $text-primary-color: #ffffff; $text-secondary-color: #B9BEC6; +$quaternary-fg-color: #6F7882; $search-bg-color: #181b21; $search-placeholder-color: #61708b; $room-highlight-color: #343a46; @@ -63,6 +64,8 @@ $input-invalid-border-color: $warning-color; $field-focused-label-bg-color: $bg-color; +$resend-button-divider-color: #b9bec64a; // muted-text with a 4A opacity. + // scrollbars $scrollbar-thumb-color: rgba(255, 255, 255, 0.2); $scrollbar-track-color: transparent; @@ -85,6 +88,7 @@ $dialog-close-fg-color: #9fa9ba; $dialog-background-bg-color: $header-panel-bg-color; $lightbox-background-bg-color: #000; +$lightbox-background-bg-opacity: 0.85; $settings-grey-fg-color: #a2a2a2; $settings-profile-placeholder-bg-color: #21262c; @@ -109,7 +113,7 @@ $header-divider-color: $header-panel-text-primary-color; $composer-e2e-icon-color: $header-panel-text-primary-color; // this probably shouldn't have it's own colour -$voipcall-plinth-color: #21262c; +$voipcall-plinth-color: #394049; // ******************** @@ -202,15 +206,22 @@ $breadcrumb-placeholder-bg-color: #272c35; $user-tile-hover-bg-color: $header-panel-bg-color; -$message-body-panel-bg-color: #21262c82; -$message-body-panel-icon-bg-color: #8e99a4; -$message-body-panel-fg-color: $primary-fg-color; +$message-body-panel-fg-color: $secondary-fg-color; +$message-body-panel-bg-color: #394049; // "Dark Tile" +$message-body-panel-icon-fg-color: #21262C; // "Separator" +$message-body-panel-icon-bg-color: $tertiary-fg-color; + +$voice-record-stop-border-color: $quaternary-fg-color; +$voice-record-waveform-bg-color: $message-body-panel-bg-color; +$voice-record-waveform-fg-color: $message-body-panel-fg-color; +$voice-record-waveform-incomplete-fg-color: $quaternary-fg-color; +$voice-record-icon-color: $quaternary-fg-color; +$voice-playback-button-bg-color: $message-body-panel-icon-bg-color; +$voice-playback-button-fg-color: $message-body-panel-icon-fg-color; // Appearance tab colors $appearance-tab-border-color: $room-highlight-color; -$invite-reason-border-color: $room-highlight-color; - // blur amounts for left left panel (only for element theme, used in _mods.scss) $roomlist-background-blur-amount: 60px; $groupFilterPanel-background-blur-amount: 30px; @@ -244,7 +255,7 @@ $composer-shadow-color: rgba(0, 0, 0, 0.28); @define-mixin mx_DialogButton_secondary { // flip colours for the secondary ones font-weight: 600; - border: 1px solid $accent-color ! important; + border: 1px solid $accent-color !important; color: $accent-color; background-color: $button-secondary-bg-color; } diff --git a/res/themes/legacy-dark/css/_legacy-dark.scss b/res/themes/legacy-dark/css/_legacy-dark.scss index 95c558a0e4..a852ad94e9 100644 --- a/res/themes/legacy-dark/css/_legacy-dark.scss +++ b/res/themes/legacy-dark/css/_legacy-dark.scss @@ -61,6 +61,8 @@ $input-invalid-border-color: $warning-color; $field-focused-label-bg-color: $bg-color; +$resend-button-divider-color: $muted-fg-color; + // scrollbars $scrollbar-thumb-color: rgba(255, 255, 255, 0.2); $scrollbar-track-color: transparent; @@ -83,6 +85,7 @@ $dialog-close-fg-color: #9fa9ba; $dialog-background-bg-color: $header-panel-bg-color; $lightbox-background-bg-color: #000; +$lightbox-background-bg-opacity: 0.85; $settings-grey-fg-color: #a2a2a2; $settings-profile-placeholder-bg-color: #e7e7e7; @@ -106,7 +109,7 @@ $header-divider-color: $header-panel-text-primary-color; $composer-e2e-icon-color: $header-panel-text-primary-color; // this probably shouldn't have it's own colour -$voipcall-plinth-color: #f2f5f8; +$voipcall-plinth-color: #394049; // ******************** @@ -197,15 +200,23 @@ $breadcrumb-placeholder-bg-color: #272c35; $user-tile-hover-bg-color: $header-panel-bg-color; -$message-body-panel-bg-color: #21262c82; -$message-body-panel-icon-bg-color: #8e99a4; -$message-body-panel-fg-color: $primary-fg-color; +$message-body-panel-fg-color: $secondary-fg-color; +$message-body-panel-bg-color: #394049; +$message-body-panel-icon-fg-color: $primary-bg-color; +$message-body-panel-icon-bg-color: $secondary-fg-color; + +// See non-legacy dark for variable information +$voice-record-stop-border-color: #6F7882; +$voice-record-waveform-bg-color: $message-body-panel-bg-color; +$voice-record-waveform-fg-color: $message-body-panel-fg-color; +$voice-record-waveform-incomplete-fg-color: #6F7882; +$voice-record-icon-color: #6F7882; +$voice-playback-button-bg-color: $tertiary-fg-color; +$voice-playback-button-fg-color: #21262C; // Appearance tab colors $appearance-tab-border-color: $room-highlight-color; -$invite-reason-border-color: $room-highlight-color; - $composer-shadow-color: tranparent; // ***** Mixins! ***** diff --git a/res/themes/legacy-light/css/_legacy-light.scss b/res/themes/legacy-light/css/_legacy-light.scss index a3f83cabe0..84666bc662 100644 --- a/res/themes/legacy-light/css/_legacy-light.scss +++ b/res/themes/legacy-light/css/_legacy-light.scss @@ -97,6 +97,8 @@ $input-invalid-border-color: $warning-color; $field-focused-label-bg-color: #ffffff; +$resend-button-divider-color: $input-darker-bg-color; + $button-bg-color: $accent-color; $button-fg-color: white; @@ -127,6 +129,7 @@ $dialog-close-fg-color: #c1c1c1; $dialog-background-bg-color: #e9e9e9; $lightbox-background-bg-color: #000; +$lightbox-background-bg-opacity: 0.95; $imagebody-giflabel: rgba(0, 0, 0, 0.7); $imagebody-giflabel-border: rgba(0, 0, 0, 0.2); @@ -173,7 +176,7 @@ $composer-e2e-icon-color: #91a1c0; $header-divider-color: #91a1c0; // this probably shouldn't have it's own colour -$voipcall-plinth-color: #f2f5f8; +$voipcall-plinth-color: #F4F6FA; // ******************** @@ -188,12 +191,6 @@ $roomsublist-skeleton-ui-bg: linear-gradient(180deg, #ffffff 0%, #ffffff00 100%) $groupFilterPanel-divider-color: $roomlist-header-color; -$voice-record-stop-border-color: #E3E8F0; -$voice-record-stop-symbol-color: $warning-color; -$voice-record-waveform-bg-color: #E3E8F0; -$voice-record-waveform-fg-color: $muted-fg-color; -$voice-record-live-circle-color: $warning-color; - $roomtile-preview-color: #9e9e9e; $roomtile-default-badge-bg-color: #61708b; $roomtile-selected-bg-color: #fff; @@ -326,15 +323,25 @@ $breadcrumb-placeholder-bg-color: #e8eef5; $user-tile-hover-bg-color: $header-panel-bg-color; -$message-body-panel-bg-color: #e3e8f082; -$message-body-panel-icon-bg-color: #ffffff; -$message-body-panel-fg-color: $muted-fg-color; +$message-body-panel-fg-color: $secondary-fg-color; +$message-body-panel-bg-color: #E3E8F0; +$message-body-panel-icon-fg-color: $secondary-fg-color; +$message-body-panel-icon-bg-color: $primary-bg-color; + +// See non-legacy _light for variable information +$voice-record-stop-symbol-color: #ff4b55; +$voice-record-live-circle-color: #ff4b55; +$voice-record-stop-border-color: #E3E8F0; +$voice-record-waveform-bg-color: $message-body-panel-bg-color; +$voice-record-waveform-fg-color: $message-body-panel-fg-color; +$voice-record-waveform-incomplete-fg-color: #C1C6CD; +$voice-record-icon-color: $tertiary-fg-color; +$voice-playback-button-bg-color: $message-body-panel-icon-bg-color; +$voice-playback-button-fg-color: $message-body-panel-icon-fg-color; // FontSlider colors $appearance-tab-border-color: $input-darker-bg-color; -$invite-reason-border-color: $input-darker-bg-color; - $composer-shadow-color: tranparent; // ***** Mixins! ***** diff --git a/res/themes/light/css/_fonts.scss b/res/themes/light/css/_fonts.scss index ba64830f15..68d9496276 100644 --- a/res/themes/light/css/_fonts.scss +++ b/res/themes/light/css/_fonts.scss @@ -15,8 +15,8 @@ $inter-unicode-range: U+0000-20e2,U+20e4-23ce,U+23d0-24c1,U+24c3-259f,U+25c2-266 font-weight: 400; font-display: swap; unicode-range: $inter-unicode-range; - src: url("$(res)/fonts/Inter/Inter-Regular.woff2?v=3.13") format("woff2"), - url("$(res)/fonts/Inter/Inter-Regular.woff?v=3.13") format("woff"); + src: url("$(res)/fonts/Inter/Inter-Regular.woff2?v=3.18") format("woff2"), + url("$(res)/fonts/Inter/Inter-Regular.woff?v=3.18") format("woff"); } @font-face { font-family: 'Inter'; @@ -24,8 +24,8 @@ $inter-unicode-range: U+0000-20e2,U+20e4-23ce,U+23d0-24c1,U+24c3-259f,U+25c2-266 font-weight: 400; font-display: swap; unicode-range: $inter-unicode-range; - src: url("$(res)/fonts/Inter/Inter-Italic.woff2?v=3.13") format("woff2"), - url("$(res)/fonts/Inter/Inter-Italic.woff?v=3.13") format("woff"); + src: url("$(res)/fonts/Inter/Inter-Italic.woff2?v=3.18") format("woff2"), + url("$(res)/fonts/Inter/Inter-Italic.woff?v=3.18") format("woff"); } @font-face { @@ -34,8 +34,8 @@ $inter-unicode-range: U+0000-20e2,U+20e4-23ce,U+23d0-24c1,U+24c3-259f,U+25c2-266 font-weight: 500; font-display: swap; unicode-range: $inter-unicode-range; - src: url("$(res)/fonts/Inter/Inter-Medium.woff2?v=3.13") format("woff2"), - url("$(res)/fonts/Inter/Inter-Medium.woff?v=3.13") format("woff"); + src: url("$(res)/fonts/Inter/Inter-Medium.woff2?v=3.18") format("woff2"), + url("$(res)/fonts/Inter/Inter-Medium.woff?v=3.18") format("woff"); } @font-face { font-family: 'Inter'; @@ -43,8 +43,8 @@ $inter-unicode-range: U+0000-20e2,U+20e4-23ce,U+23d0-24c1,U+24c3-259f,U+25c2-266 font-weight: 500; font-display: swap; unicode-range: $inter-unicode-range; - src: url("$(res)/fonts/Inter/Inter-MediumItalic.woff2?v=3.13") format("woff2"), - url("$(res)/fonts/Inter/Inter-MediumItalic.woff?v=3.13") format("woff"); + src: url("$(res)/fonts/Inter/Inter-MediumItalic.woff2?v=3.18") format("woff2"), + url("$(res)/fonts/Inter/Inter-MediumItalic.woff?v=3.18") format("woff"); } @font-face { @@ -53,8 +53,8 @@ $inter-unicode-range: U+0000-20e2,U+20e4-23ce,U+23d0-24c1,U+24c3-259f,U+25c2-266 font-weight: 600; font-display: swap; unicode-range: $inter-unicode-range; - src: url("$(res)/fonts/Inter/Inter-SemiBold.woff2?v=3.13") format("woff2"), - url("$(res)/fonts/Inter/Inter-SemiBold.woff?v=3.13") format("woff"); + src: url("$(res)/fonts/Inter/Inter-SemiBold.woff2?v=3.18") format("woff2"), + url("$(res)/fonts/Inter/Inter-SemiBold.woff?v=3.18") format("woff"); } @font-face { font-family: 'Inter'; @@ -62,8 +62,8 @@ $inter-unicode-range: U+0000-20e2,U+20e4-23ce,U+23d0-24c1,U+24c3-259f,U+25c2-266 font-weight: 600; font-display: swap; unicode-range: $inter-unicode-range; - src: url("$(res)/fonts/Inter/Inter-SemiBoldItalic.woff2?v=3.13") format("woff2"), - url("$(res)/fonts/Inter/Inter-SemiBoldItalic.woff?v=3.13") format("woff"); + src: url("$(res)/fonts/Inter/Inter-SemiBoldItalic.woff2?v=3.18") format("woff2"), + url("$(res)/fonts/Inter/Inter-SemiBoldItalic.woff?v=3.18") format("woff"); } @font-face { @@ -72,8 +72,8 @@ $inter-unicode-range: U+0000-20e2,U+20e4-23ce,U+23d0-24c1,U+24c3-259f,U+25c2-266 font-weight: 700; font-display: swap; unicode-range: $inter-unicode-range; - src: url("$(res)/fonts/Inter/Inter-Bold.woff2?v=3.13") format("woff2"), - url("$(res)/fonts/Inter/Inter-Bold.woff?v=3.13") format("woff"); + src: url("$(res)/fonts/Inter/Inter-Bold.woff2?v=3.18") format("woff2"), + url("$(res)/fonts/Inter/Inter-Bold.woff?v=3.18") format("woff"); } @font-face { font-family: 'Inter'; @@ -81,8 +81,8 @@ $inter-unicode-range: U+0000-20e2,U+20e4-23ce,U+23d0-24c1,U+24c3-259f,U+25c2-266 font-weight: 700; font-display: swap; unicode-range: $inter-unicode-range; - src: url("$(res)/fonts/Inter/Inter-BoldItalic.woff2?v=3.13") format("woff2"), - url("$(res)/fonts/Inter/Inter-BoldItalic.woff?v=3.13") format("woff"); + src: url("$(res)/fonts/Inter/Inter-BoldItalic.woff2?v=3.18") format("woff2"), + url("$(res)/fonts/Inter/Inter-BoldItalic.woff?v=3.18") format("woff"); } /* latin-ext */ diff --git a/res/themes/light/css/_light.scss b/res/themes/light/css/_light.scss index 3465f555b6..c889f43d0b 100644 --- a/res/themes/light/css/_light.scss +++ b/res/themes/light/css/_light.scss @@ -21,6 +21,7 @@ $notice-primary-bg-color: rgba(255, 75, 85, 0.16); $primary-fg-color: #2e2f32; $secondary-fg-color: #737D8C; $tertiary-fg-color: #8D99A5; +$quaternary-fg-color: #C1C6CD; $header-panel-bg-color: #f3f8fd; // typical text (dark-on-white in light skin) @@ -91,6 +92,8 @@ $field-focused-label-bg-color: #ffffff; $button-bg-color: $accent-color; $button-fg-color: white; +$resend-button-divider-color: $input-darker-bg-color; + // apart from login forms, which have stronger border $strong-input-border-color: #c7c7c7; @@ -118,6 +121,7 @@ $dialog-close-fg-color: #c1c1c1; $dialog-background-bg-color: #e9e9e9; $lightbox-background-bg-color: #000; +$lightbox-background-bg-opacity: 0.95; $imagebody-giflabel: rgba(0, 0, 0, 0.7); $imagebody-giflabel-border: rgba(0, 0, 0, 0.2); @@ -164,7 +168,7 @@ $composer-e2e-icon-color: #91A1C0; $header-divider-color: #91A1C0; // this probably shouldn't have it's own colour -$voipcall-plinth-color: #f2f5f8; +$voipcall-plinth-color: #F4F6FA; // ******************** @@ -179,12 +183,6 @@ $roomsublist-skeleton-ui-bg: linear-gradient(180deg, #ffffff 0%, #ffffff00 100%) $groupFilterPanel-divider-color: $roomlist-header-color; -$voice-record-stop-border-color: #E3E8F0; -$voice-record-stop-symbol-color: $warning-color; -$voice-record-waveform-bg-color: #E3E8F0; -$voice-record-waveform-fg-color: $muted-fg-color; -$voice-record-live-circle-color: $warning-color; - $roomtile-preview-color: $secondary-fg-color; $roomtile-default-badge-bg-color: #61708b; $roomtile-selected-bg-color: #FFF; @@ -324,15 +322,27 @@ $breadcrumb-placeholder-bg-color: #e8eef5; $user-tile-hover-bg-color: $header-panel-bg-color; -$message-body-panel-bg-color: #e3e8f082; -$message-body-panel-icon-bg-color: #ffffff; -$message-body-panel-fg-color: $muted-fg-color; +$message-body-panel-fg-color: $secondary-fg-color; +$message-body-panel-bg-color: #E3E8F0; // "Separator" +$message-body-panel-icon-fg-color: $secondary-fg-color; +$message-body-panel-icon-bg-color: $primary-bg-color; + +// These two don't change between themes. They are the $warning-color, but we don't +// want custom themes to affect them by accident. +$voice-record-stop-symbol-color: #ff4b55; +$voice-record-live-circle-color: #ff4b55; + +$voice-record-stop-border-color: #E3E8F0; // "Separator" +$voice-record-waveform-bg-color: $message-body-panel-bg-color; +$voice-record-waveform-fg-color: $message-body-panel-fg-color; +$voice-record-waveform-incomplete-fg-color: $quaternary-fg-color; +$voice-record-icon-color: $tertiary-fg-color; +$voice-playback-button-bg-color: $message-body-panel-icon-bg-color; +$voice-playback-button-fg-color: $message-body-panel-icon-fg-color; // FontSlider colors $appearance-tab-border-color: $input-darker-bg-color; -$invite-reason-border-color: $input-darker-bg-color; - // blur amounts for left left panel (only for element theme, used in _mods.scss) $roomlist-background-blur-amount: 40px; $groupFilterPanel-background-blur-amount: 20px; diff --git a/scripts/compare-file.js b/scripts/compare-file.js deleted file mode 100644 index f53275ebfa..0000000000 --- a/scripts/compare-file.js +++ /dev/null @@ -1,10 +0,0 @@ -const fs = require("fs"); - -if (process.argv.length < 4) throw new Error("Missing source and target file arguments"); - -const sourceFile = fs.readFileSync(process.argv[2], 'utf8'); -const targetFile = fs.readFileSync(process.argv[3], 'utf8'); - -if (sourceFile !== targetFile) { - throw new Error("Files do not match"); -} diff --git a/scripts/fetchdep.sh b/scripts/fetchdep.sh index 850eef25ec..fe1f49c361 100755 --- a/scripts/fetchdep.sh +++ b/scripts/fetchdep.sh @@ -22,15 +22,26 @@ clone() { } # Try the PR author's branch in case it exists on the deps as well. -# If BUILDKITE_BRANCH is set, it will contain either: +# First we check if BUILDKITE_BRANCH is defined, +# if it isn't we can assume this is a Netlify build +if [ -z ${BUILDKITE_BRANCH+x} ]; then + # Netlify doesn't give us info about the fork so we have to get it from GitHub API + apiEndpoint="https://api.github.com/repos/matrix-org/matrix-react-sdk/pulls/" + apiEndpoint+=$REVIEW_ID + head=$(curl $apiEndpoint | jq -r '.head.label') +else + head=$BUILDKITE_BRANCH +fi + +# If head is set, it will contain either: # * "branch" when the author's branch and target branch are in the same repo -# * "author:branch" when the author's branch is in their fork +# * "fork:branch" when the author's branch is in their fork or if this is a Netlify build # We can split on `:` into an array to check. -BUILDKITE_BRANCH_ARRAY=(${BUILDKITE_BRANCH//:/ }) -if [[ "${#BUILDKITE_BRANCH_ARRAY[@]}" == "1" ]]; then +BRANCH_ARRAY=(${head//:/ }) +if [[ "${#BRANCH_ARRAY[@]}" == "1" ]]; then clone $deforg $defrepo $BUILDKITE_BRANCH -elif [[ "${#BUILDKITE_BRANCH_ARRAY[@]}" == "2" ]]; then - clone ${BUILDKITE_BRANCH_ARRAY[0]} $defrepo ${BUILDKITE_BRANCH_ARRAY[1]} +elif [[ "${#BRANCH_ARRAY[@]}" == "2" ]]; then + clone ${BRANCH_ARRAY[0]} $defrepo ${BRANCH_ARRAY[1]} fi # Try the target branch of the push or PR. clone $deforg $defrepo $BUILDKITE_PULL_REQUEST_BASE_BRANCH diff --git a/scripts/gen-i18n.js b/scripts/gen-i18n.js deleted file mode 100755 index 91733469f7..0000000000 --- a/scripts/gen-i18n.js +++ /dev/null @@ -1,304 +0,0 @@ -#!/usr/bin/env node - -/* -Copyright 2017 New Vector Ltd - -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. -*/ - -/** - * Regenerates the translations en_EN file by walking the source tree and - * parsing each file with the appropriate parser. Emits a JSON file with the - * translatable strings mapped to themselves in the order they appeared - * in the files and grouped by the file they appeared in. - * - * Usage: node scripts/gen-i18n.js - */ -const fs = require('fs'); -const path = require('path'); - -const walk = require('walk'); - -const parser = require("@babel/parser"); -const traverse = require("@babel/traverse"); - -const TRANSLATIONS_FUNCS = ['_t', '_td']; - -const INPUT_TRANSLATIONS_FILE = 'src/i18n/strings/en_EN.json'; -const OUTPUT_FILE = 'src/i18n/strings/en_EN.json'; - -// NB. The sync version of walk is broken for single files so we walk -// all of res rather than just res/home.html. -// https://git.daplie.com/Daplie/node-walk/merge_requests/1 fixes it, -// or if we get bored waiting for it to be merged, we could switch -// to a project that's actively maintained. -const SEARCH_PATHS = ['src', 'res']; - -function getObjectValue(obj, key) { - for (const prop of obj.properties) { - if (prop.key.type === 'Identifier' && prop.key.name === key) { - return prop.value; - } - } - return null; -} - -function getTKey(arg) { - if (arg.type === 'Literal' || arg.type === "StringLiteral") { - return arg.value; - } else if (arg.type === 'BinaryExpression' && arg.operator === '+') { - return getTKey(arg.left) + getTKey(arg.right); - } else if (arg.type === 'TemplateLiteral') { - return arg.quasis.map((q) => { - return q.value.raw; - }).join(''); - } - return null; -} - -function getFormatStrings(str) { - // Match anything that starts with % - // We could make a regex that matched the full placeholder, but this - // would just not match invalid placeholders and so wouldn't help us - // detect the invalid ones. - // Also note that for simplicity, this just matches a % character and then - // anything up to the next % character (or a single %, or end of string). - const formatStringRe = /%([^%]+|%|$)/g; - const formatStrings = new Set(); - - let match; - while ( (match = formatStringRe.exec(str)) !== null ) { - const placeholder = match[1]; // Minus the leading '%' - if (placeholder === '%') continue; // Literal % is %% - - const placeholderMatch = placeholder.match(/^\((.*?)\)(.)/); - if (placeholderMatch === null) { - throw new Error("Invalid format specifier: '"+match[0]+"'"); - } - if (placeholderMatch.length < 3) { - throw new Error("Malformed format specifier"); - } - const placeholderName = placeholderMatch[1]; - const placeholderFormat = placeholderMatch[2]; - - if (placeholderFormat !== 's') { - throw new Error(`'${placeholderFormat}' used as format character: you probably meant 's'`); - } - - formatStrings.add(placeholderName); - } - - return formatStrings; -} - -function getTranslationsJs(file) { - const contents = fs.readFileSync(file, { encoding: 'utf8' }); - - const trs = new Set(); - - try { - const plugins = [ - // https://babeljs.io/docs/en/babel-parser#plugins - "classProperties", - "objectRestSpread", - "throwExpressions", - "exportDefaultFrom", - "decorators-legacy", - ]; - - if (file.endsWith(".js") || file.endsWith(".jsx")) { - // all JS is assumed to be flow or react - plugins.push("flow", "jsx"); - } else if (file.endsWith(".ts")) { - // TS can't use JSX unless it's a TSX file (otherwise angle casts fail) - plugins.push("typescript"); - } else if (file.endsWith(".tsx")) { - // When the file is a TSX file though, enable JSX parsing - plugins.push("typescript", "jsx"); - } - - const babelParsed = parser.parse(contents, { - allowImportExportEverywhere: true, - errorRecovery: true, - sourceFilename: file, - tokens: true, - plugins, - }); - traverse.default(babelParsed, { - enter: (p) => { - const node = p.node; - if (p.isCallExpression() && node.callee && TRANSLATIONS_FUNCS.includes(node.callee.name)) { - const tKey = getTKey(node.arguments[0]); - - // This happens whenever we call _t with non-literals (ie. whenever we've - // had to use a _td to compensate) so is expected. - if (tKey === null) return; - - // check the format string against the args - // We only check _t: _td has no args - if (node.callee.name === '_t') { - try { - const placeholders = getFormatStrings(tKey); - for (const placeholder of placeholders) { - if (node.arguments.length < 2) { - throw new Error(`Placeholder found ('${placeholder}') but no substitutions given`); - } - const value = getObjectValue(node.arguments[1], placeholder); - if (value === null) { - throw new Error(`No value found for placeholder '${placeholder}'`); - } - } - - // Validate tag replacements - if (node.arguments.length > 2) { - const tagMap = node.arguments[2]; - for (const prop of tagMap.properties || []) { - if (prop.key.type === 'Literal') { - const tag = prop.key.value; - // RegExp same as in src/languageHandler.js - const regexp = new RegExp(`(<${tag}>(.*?)<\\/${tag}>|<${tag}>|<${tag}\\s*\\/>)`); - if (!tKey.match(regexp)) { - throw new Error(`No match for ${regexp} in ${tKey}`); - } - } - } - } - - } catch (e) { - console.log(); - console.error(`ERROR: ${file}:${node.loc.start.line} ${tKey}`); - console.error(e); - process.exit(1); - } - } - - let isPlural = false; - if (node.arguments.length > 1 && node.arguments[1].type === 'ObjectExpression') { - const countVal = getObjectValue(node.arguments[1], 'count'); - if (countVal) { - isPlural = true; - } - } - - if (isPlural) { - trs.add(tKey + "|other"); - const plurals = enPlurals[tKey]; - if (plurals) { - for (const pluralType of Object.keys(plurals)) { - trs.add(tKey + "|" + pluralType); - } - } - } else { - trs.add(tKey); - } - } - }, - }); - } catch (e) { - console.error(e); - process.exit(1); - } - - return trs; -} - -function getTranslationsOther(file) { - const contents = fs.readFileSync(file, { encoding: 'utf8' }); - - const trs = new Set(); - - // Taken from element-web src/components/structures/HomePage.js - const translationsRegex = /_t\(['"]([\s\S]*?)['"]\)/mg; - let matches; - while (matches = translationsRegex.exec(contents)) { - trs.add(matches[1]); - } - return trs; -} - -// gather en_EN plural strings from the input translations file: -// the en_EN strings are all in the source with the exception of -// pluralised strings, which we need to pull in from elsewhere. -const inputTranslationsRaw = JSON.parse(fs.readFileSync(INPUT_TRANSLATIONS_FILE, { encoding: 'utf8' })); -const enPlurals = {}; - -for (const key of Object.keys(inputTranslationsRaw)) { - const parts = key.split("|"); - if (parts.length > 1) { - const plurals = enPlurals[parts[0]] || {}; - plurals[parts[1]] = inputTranslationsRaw[key]; - enPlurals[parts[0]] = plurals; - } -} - -const translatables = new Set(); - -const walkOpts = { - listeners: { - names: function(root, nodeNamesArray) { - // Sort the names case insensitively and alphabetically to - // maintain some sense of order between the different strings. - nodeNamesArray.sort((a, b) => { - a = a.toLowerCase(); - b = b.toLowerCase(); - if (a > b) return 1; - if (a < b) return -1; - return 0; - }); - }, - file: function(root, fileStats, next) { - const fullPath = path.join(root, fileStats.name); - - let trs; - if (fileStats.name.endsWith('.js') || fileStats.name.endsWith('.ts') || fileStats.name.endsWith('.tsx')) { - trs = getTranslationsJs(fullPath); - } else if (fileStats.name.endsWith('.html')) { - trs = getTranslationsOther(fullPath); - } else { - return; - } - console.log(`${fullPath} (${trs.size} strings)`); - for (const tr of trs.values()) { - // Convert DOS line endings to unix - translatables.add(tr.replace(/\r\n/g, "\n")); - } - }, - } -}; - -for (const path of SEARCH_PATHS) { - if (fs.existsSync(path)) { - walk.walkSync(path, walkOpts); - } -} - -const trObj = {}; -for (const tr of translatables) { - if (tr.includes("|")) { - if (inputTranslationsRaw[tr]) { - trObj[tr] = inputTranslationsRaw[tr]; - } else { - trObj[tr] = tr.split("|")[0]; - } - } else { - trObj[tr] = tr; - } -} - -fs.writeFileSync( - OUTPUT_FILE, - JSON.stringify(trObj, translatables.values(), 4) + "\n" -); - -console.log(); -console.log(`Wrote ${translatables.size} strings to ${OUTPUT_FILE}`); diff --git a/scripts/prune-i18n.js b/scripts/prune-i18n.js deleted file mode 100755 index b4fe8d69f5..0000000000 --- a/scripts/prune-i18n.js +++ /dev/null @@ -1,68 +0,0 @@ -#!/usr/bin/env node - -/* -Copyright 2017 New Vector Ltd - -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. -*/ - -/* - * Looks through all the translation files and removes any strings - * which don't appear in en_EN.json. - * Use this if you remove a translation, but merge any outstanding changes - * from weblate first or you'll need to resolve the conflict in weblate. - */ - -const fs = require('fs'); -const path = require('path'); - -const I18NDIR = 'src/i18n/strings'; - -const enStringsRaw = JSON.parse(fs.readFileSync(path.join(I18NDIR, 'en_EN.json'))); - -const enStrings = new Set(); -for (const str of Object.keys(enStringsRaw)) { - const parts = str.split('|'); - if (parts.length > 1) { - enStrings.add(parts[0]); - } else { - enStrings.add(str); - } -} - -for (const filename of fs.readdirSync(I18NDIR)) { - if (filename === 'en_EN.json') continue; - if (filename === 'basefile.json') continue; - if (!filename.endsWith('.json')) continue; - - const trs = JSON.parse(fs.readFileSync(path.join(I18NDIR, filename))); - const oldLen = Object.keys(trs).length; - for (const tr of Object.keys(trs)) { - const parts = tr.split('|'); - const trKey = parts.length > 1 ? parts[0] : tr; - if (!enStrings.has(trKey)) { - delete trs[tr]; - } - } - - const removed = oldLen - Object.keys(trs).length; - if (removed > 0) { - console.log(`${filename}: removed ${removed} translations`); - // XXX: This is totally relying on the impl serialising the JSON object in the - // same order as they were parsed from the file. JSON.stringify() has a specific argument - // that can be used to control the order, but JSON.parse() lacks any kind of equivalent. - // Empirically this does maintain the order on my system, so I'm going to leave it like - // this for now. - fs.writeFileSync(path.join(I18NDIR, filename), JSON.stringify(trs, undefined, 4) + "\n"); - } -} diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts index 051e5cc429..63966d96fa 100644 --- a/src/@types/global.d.ts +++ b/src/@types/global.d.ts @@ -1,5 +1,5 @@ /* -Copyright 2020 The Matrix.org Foundation C.I.C. +Copyright 2020-2021 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. @@ -39,7 +39,10 @@ import {ModalWidgetStore} from "../stores/ModalWidgetStore"; import { WidgetLayoutStore } from "../stores/widgets/WidgetLayoutStore"; import VoipUserMapper from "../VoipUserMapper"; import {SpaceStoreClass} from "../stores/SpaceStore"; -import {VoiceRecorder} from "../voice/VoiceRecorder"; +import TypingStore from "../stores/TypingStore"; +import { EventIndexPeg } from "../indexing/EventIndexPeg"; +import {VoiceRecordingStore} from "../stores/VoiceRecordingStore"; +import PerformanceMonitor from "../performance"; declare global { interface Window { @@ -50,6 +53,9 @@ declare global { init: () => Promise; }; + // Needed for Safari, unknown to TypeScript + webkitAudioContext: typeof AudioContext; + mxContentMessages: ContentMessages; mxToastStore: ToastStore; mxDeviceListener: DeviceListener; @@ -71,12 +77,18 @@ declare global { mxModalWidgetStore: ModalWidgetStore; mxVoipUserMapper: VoipUserMapper; mxSpaceStore: SpaceStoreClass; - mxVoiceRecorder: typeof VoiceRecorder; + mxVoiceRecordingStore: VoiceRecordingStore; + mxTypingStore: TypingStore; + mxEventIndexPeg: EventIndexPeg; + mxPerformanceMonitor: PerformanceMonitor; + mxPerformanceEntryNames: any; } interface Document { // https://developer.mozilla.org/en-US/docs/Web/API/Document/hasStorageAccess hasStorageAccess?: () => Promise; + // https://developer.mozilla.org/en-US/docs/Web/API/Document/requestStorageAccess + requestStorageAccess?: () => Promise; // Safari & IE11 only have this prefixed: we used prefixed versions // previously so let's continue to support them for now @@ -112,6 +124,16 @@ declare global { interface HTMLAudioElement { type?: string; + // sinkId & setSinkId are experimental and typescript doesn't know about them + sinkId: string; + setSinkId(outputId: string); + } + + interface HTMLVideoElement { + type?: string; + // sinkId & setSinkId are experimental and typescript doesn't know about them + sinkId: string; + setSinkId(outputId: string); } interface Element { @@ -129,4 +151,30 @@ declare global { // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/columnNumber columnNumber?: number; } + + // https://github.com/microsoft/TypeScript/issues/28308#issuecomment-650802278 + interface AudioWorkletProcessor { + readonly port: MessagePort; + process( + inputs: Float32Array[][], + outputs: Float32Array[][], + parameters: Record + ): boolean; + } + + // https://github.com/microsoft/TypeScript/issues/28308#issuecomment-650802278 + const AudioWorkletProcessor: { + prototype: AudioWorkletProcessor; + new (options?: AudioWorkletNodeOptions): AudioWorkletProcessor; + }; + + // https://github.com/microsoft/TypeScript/issues/28308#issuecomment-650802278 + function registerProcessor( + name: string, + processorCtor: (new ( + options?: AudioWorkletNodeOptions + ) => AudioWorkletProcessor) & { + parameterDescriptors?: AudioParamDescriptor[]; + } + ); } diff --git a/src/Avatar.ts b/src/Avatar.ts index 76c88faa1c..a6499c688e 100644 --- a/src/Avatar.ts +++ b/src/Avatar.ts @@ -20,6 +20,7 @@ import {Room} from "matrix-js-sdk/src/models/room"; import DMRoomMap from './utils/DMRoomMap'; import {mediaFromMxc} from "./customisations/Media"; +import SettingsStore from "./settings/SettingsStore"; export type ResizeMethod = "crop" | "scale"; @@ -27,11 +28,7 @@ export type ResizeMethod = "crop" | "scale"; export function avatarUrlForMember(member: RoomMember, width: number, height: number, resizeMethod: ResizeMethod) { let url: string; if (member?.getMxcAvatarUrl()) { - url = mediaFromMxc(member.getMxcAvatarUrl()).getThumbnailOfSourceHttp( - Math.floor(width * window.devicePixelRatio), - Math.floor(height * window.devicePixelRatio), - resizeMethod, - ); + url = mediaFromMxc(member.getMxcAvatarUrl()).getThumbnailOfSourceHttp(width, height, resizeMethod); } if (!url) { // member can be null here currently since on invites, the JS SDK @@ -44,11 +41,7 @@ export function avatarUrlForMember(member: RoomMember, width: number, height: nu export function avatarUrlForUser(user: User, width: number, height: number, resizeMethod?: ResizeMethod) { if (!user.avatarUrl) return null; - return mediaFromMxc(user.avatarUrl).getThumbnailOfSourceHttp( - Math.floor(width * window.devicePixelRatio), - Math.floor(height * window.devicePixelRatio), - resizeMethod, - ); + return mediaFromMxc(user.avatarUrl).getThumbnailOfSourceHttp(width, height, resizeMethod); } function isValidHexColor(color: string): boolean { @@ -151,7 +144,7 @@ export function avatarUrlForRoom(room: Room, width: number, height: number, resi } // space rooms cannot be DMs so skip the rest - if (room.isSpaceRoom()) return null; + if (SettingsStore.getValue("feature_spaces") && room.isSpaceRoom()) return null; let otherMember = null; const otherUserId = DMRoomMap.shared().getUserIdForRoomId(room.roomId); diff --git a/src/BasePlatform.ts b/src/BasePlatform.ts index b6012d7597..5483ea6874 100644 --- a/src/BasePlatform.ts +++ b/src/BasePlatform.ts @@ -258,7 +258,7 @@ export default abstract class BasePlatform { return null; } - setLanguage(preferredLangs: string[]) {} + async setLanguage(preferredLangs: string[]) {} setSpellCheckLanguages(preferredLangs: string[]) {} diff --git a/src/CallHandler.tsx b/src/CallHandler.tsx index be687a4474..0268ebfe46 100644 --- a/src/CallHandler.tsx +++ b/src/CallHandler.tsx @@ -59,7 +59,6 @@ import {MatrixClientPeg} from './MatrixClientPeg'; import PlatformPeg from './PlatformPeg'; import Modal from './Modal'; import { _t } from './languageHandler'; -import { createNewMatrixCall } from 'matrix-js-sdk/src/webrtc/call'; import dis from './dispatcher/dispatcher'; import WidgetUtils from './utils/WidgetUtils'; import WidgetEchoStore from './stores/WidgetEchoStore'; @@ -86,6 +85,9 @@ import { Action } from './dispatcher/actions'; import VoipUserMapper from './VoipUserMapper'; import { addManagedHybridWidget, isManagedHybridWidgetEnabled } from './widgets/ManagedHybrid'; import { randomUppercaseString, randomLowercaseString } from "matrix-js-sdk/src/randomstring"; +import EventEmitter from 'events'; +import SdkConfig from './SdkConfig'; +import { ensureDMExists, findDMForUser } from './createRoom'; export const PROTOCOL_PSTN = 'm.protocol.pstn'; export const PROTOCOL_PSTN_PREFIXED = 'im.vector.protocol.pstn'; @@ -137,22 +139,12 @@ export enum PlaceCallType { ScreenSharing = 'screensharing', } -function getRemoteAudioElement(): HTMLAudioElement { - // this needs to be somewhere at the top of the DOM which - // always exists to avoid audio interruptions. - // Might as well just use DOM. - const remoteAudioElement = document.getElementById("remoteAudio") as HTMLAudioElement; - if (!remoteAudioElement) { - console.error( - "Failed to find remoteAudio element - cannot play audio!" + - "You need to add an to the DOM.", - ); - return null; - } - return remoteAudioElement; +export enum CallHandlerEvent { + CallsChanged = "calls_changed", + CallChangeRoom = "call_change_room", } -export default class CallHandler { +export default class CallHandler extends EventEmitter { private calls = new Map(); // roomId -> call // Calls started as an attended transfer, ie. with the intention of transferring another // call with a different party to this one. @@ -167,6 +159,11 @@ export default class CallHandler { private invitedRoomsAreVirtual = new Map(); private invitedRoomCheckInProgress = false; + // Map of the asserted identity users after we've looked them up using the API. + // We need to be be able to determine the mapped room synchronously, so we + // do the async lookup when we get new information and then store these mappings here + private assertedIdentityNativeUsers = new Map(); + static sharedInstance() { if (!window.mxCallHandler) { window.mxCallHandler = new CallHandler() @@ -179,8 +176,19 @@ export default class CallHandler { * Gets the user-facing room associated with a call (call.roomId may be the call "virtual room" * if a voip_mxid_translate_pattern is set in the config) */ - public static roomIdForCall(call: MatrixCall): string { + public roomIdForCall(call: MatrixCall): string { if (!call) return null; + + const voipConfig = SdkConfig.get()['voip']; + + if (voipConfig && voipConfig.obeyAssertedIdentity) { + const nativeUser = this.assertedIdentityNativeUsers[call.callId]; + if (nativeUser) { + const room = findDMForUser(MatrixClientPeg.get(), nativeUser); + if (room) return room.roomId + } + } + return VoipUserMapper.sharedInstance().nativeRoomForVirtualRoom(call.roomId) || call.roomId; } @@ -379,14 +387,14 @@ export default class CallHandler { // We don't allow placing more than one call per room, but that doesn't mean there // can't be more than one, eg. in a glare situation. This checks that the given call // is the call we consider 'the' call for its room. - const mappedRoomId = CallHandler.roomIdForCall(call); + const mappedRoomId = this.roomIdForCall(call); const callForThisRoom = this.getCallForRoom(mappedRoomId); return callForThisRoom && call.callId === callForThisRoom.callId; } private setCallListeners(call: MatrixCall) { - const mappedRoomId = CallHandler.roomIdForCall(call); + let mappedRoomId = CallHandler.sharedInstance().roomIdForCall(call); call.on(CallEvent.Error, (err: CallError) => { if (!this.matchesCallForThisRoom(call)) return; @@ -497,9 +505,43 @@ export default class CallHandler { } this.calls.set(mappedRoomId, newCall); + this.emit(CallHandlerEvent.CallsChanged, this.calls); this.setCallListeners(newCall); this.setCallState(newCall, newCall.state); }); + call.on(CallEvent.AssertedIdentityChanged, async () => { + if (!this.matchesCallForThisRoom(call)) return; + + console.log(`Call ID ${call.callId} got new asserted identity:`, call.getRemoteAssertedIdentity()); + + const newAssertedIdentity = call.getRemoteAssertedIdentity().id; + let newNativeAssertedIdentity = newAssertedIdentity; + if (newAssertedIdentity) { + const response = await this.sipNativeLookup(newAssertedIdentity); + if (response.length) newNativeAssertedIdentity = response[0].userid; + } + console.log(`Asserted identity ${newAssertedIdentity} mapped to ${newNativeAssertedIdentity}`); + + if (newNativeAssertedIdentity) { + this.assertedIdentityNativeUsers[call.callId] = newNativeAssertedIdentity; + + // If we don't already have a room with this user, make one. This will be slightly odd + // if they called us because we'll be inviting them, but there's not much we can do about + // this if we want the actual, native room to exist (which we do). This is why it's + // important to only obey asserted identity in trusted environments, since anyone you're + // on a call with can cause you to send a room invite to someone. + await ensureDMExists(MatrixClientPeg.get(), newNativeAssertedIdentity); + + const newMappedRoomId = this.roomIdForCall(call); + console.log(`Old room ID: ${mappedRoomId}, new room ID: ${newMappedRoomId}`); + if (newMappedRoomId !== mappedRoomId) { + this.removeCallForRoom(mappedRoomId); + mappedRoomId = newMappedRoomId; + this.calls.set(mappedRoomId, call); + this.emit(CallHandlerEvent.CallChangeRoom, call); + } + } + }); } private async logCallStats(call: MatrixCall, mappedRoomId: string) { @@ -545,13 +587,8 @@ export default class CallHandler { } } - private setCallAudioElement(call: MatrixCall) { - const audioElement = getRemoteAudioElement(); - if (audioElement) call.setRemoteAudioElement(audioElement); - } - private setCallState(call: MatrixCall, status: CallState) { - const mappedRoomId = CallHandler.roomIdForCall(call); + const mappedRoomId = CallHandler.sharedInstance().roomIdForCall(call); console.log( `Call state in ${mappedRoomId} changed to ${status}`, @@ -566,6 +603,7 @@ export default class CallHandler { private removeCallForRoom(roomId: string) { this.calls.delete(roomId); + this.emit(CallHandlerEvent.CallsChanged, this.calls); } private showICEFallbackPrompt() { @@ -626,11 +664,7 @@ export default class CallHandler { }, null, true); } - private async placeCall( - roomId: string, type: PlaceCallType, - localElement: HTMLVideoElement, remoteElement: HTMLVideoElement, - transferee: MatrixCall, - ) { + private async placeCall(roomId: string, type: PlaceCallType, transferee: MatrixCall) { Analytics.trackEvent('voip', 'placeCall', 'type', type); CountlyAnalytics.instance.trackStartCall(roomId, type === PlaceCallType.Video, false); @@ -639,25 +673,22 @@ export default class CallHandler { const timeUntilTurnCresExpire = MatrixClientPeg.get().getTurnServersExpiry() - Date.now(); console.log("Current turn creds expire in " + timeUntilTurnCresExpire + " ms"); - const call = createNewMatrixCall(MatrixClientPeg.get(), mappedRoomId); + const call = MatrixClientPeg.get().createCall(mappedRoomId); this.calls.set(roomId, call); + this.emit(CallHandlerEvent.CallsChanged, this.calls); if (transferee) { this.transferees[call.callId] = transferee; } this.setCallListeners(call); - this.setCallAudioElement(call); this.setActiveCallRoomId(roomId); if (type === PlaceCallType.Voice) { call.placeVoiceCall(); } else if (type === 'video') { - call.placeVideoCall( - remoteElement, - localElement, - ); + call.placeVideoCall(); } else if (type === PlaceCallType.ScreenSharing) { const screenCapErrorString = PlatformPeg.get().screenCaptureErrorString(); if (screenCapErrorString) { @@ -671,13 +702,12 @@ export default class CallHandler { } call.placeScreenSharingCall( - remoteElement, - localElement, - async () : Promise => { + async (): Promise => { const {finished} = Modal.createDialog(DesktopCapturerSourcePicker); const [source] = await finished; return source; - }); + }, + ); } else { console.error("Unknown conf call type: " + type); } @@ -734,17 +764,12 @@ export default class CallHandler { } else if (members.length === 2) { console.info(`Place ${payload.type} call in ${payload.room_id}`); - this.placeCall( - payload.room_id, payload.type, payload.local_element, payload.remote_element, - payload.transferee, - ); + this.placeCall(payload.room_id, payload.type, payload.transferee); } else { // > 2 dis.dispatch({ action: "place_conference_call", room_id: payload.room_id, type: payload.type, - remote_element: payload.remote_element, - local_element: payload.local_element, }); } } @@ -772,7 +797,7 @@ export default class CallHandler { const call = payload.call as MatrixCall; - const mappedRoomId = CallHandler.roomIdForCall(call); + const mappedRoomId = CallHandler.sharedInstance().roomIdForCall(call); if (this.getCallForRoom(mappedRoomId)) { // ignore multiple incoming calls to the same room return; @@ -780,6 +805,7 @@ export default class CallHandler { Analytics.trackEvent('voip', 'receiveCall', 'type', call.type); this.calls.set(mappedRoomId, call) + this.emit(CallHandlerEvent.CallsChanged, this.calls); this.setCallListeners(call); // get ready to send encrypted events in the room, so if the user does answer @@ -822,7 +848,6 @@ export default class CallHandler { const call = this.calls.get(payload.room_id); call.answer(); - this.setCallAudioElement(call); this.setActiveCallRoomId(payload.room_id); CountlyAnalytics.instance.trackJoinCall(payload.room_id, call.type === CallType.Video, false); dis.dispatch({ diff --git a/src/CallMediaHandler.js b/src/CallMediaHandler.js index 7c7940cab5..634f0bb336 100644 --- a/src/CallMediaHandler.js +++ b/src/CallMediaHandler.js @@ -16,7 +16,7 @@ import SettingsStore from "./settings/SettingsStore"; import {SettingLevel} from "./settings/SettingLevel"; -import {setMatrixCallAudioInput, setMatrixCallAudioOutput, setMatrixCallVideoInput} from "matrix-js-sdk/src/matrix"; +import {setMatrixCallAudioInput, setMatrixCallVideoInput} from "matrix-js-sdk/src/matrix"; export default { hasAnyLabeledDevices: async function() { @@ -50,18 +50,15 @@ export default { }, loadDevices: function() { - const audioOutDeviceId = SettingsStore.getValue("webrtc_audiooutput"); const audioDeviceId = SettingsStore.getValue("webrtc_audioinput"); const videoDeviceId = SettingsStore.getValue("webrtc_videoinput"); - setMatrixCallAudioOutput(audioOutDeviceId); setMatrixCallAudioInput(audioDeviceId); setMatrixCallVideoInput(videoDeviceId); }, setAudioOutput: function(deviceId) { SettingsStore.setValue("webrtc_audiooutput", null, SettingLevel.DEVICE, deviceId); - setMatrixCallAudioOutput(deviceId); }, setAudioInput: function(deviceId) { diff --git a/src/DateUtils.ts b/src/DateUtils.ts index 9b1edf0775..e4a1175d88 100644 --- a/src/DateUtils.ts +++ b/src/DateUtils.ts @@ -97,7 +97,7 @@ export function formatFullDateNoTime(date: Date): string { }); } -export function formatFullDate(date: Date, showTwelveHour = false): string { +export function formatFullDate(date: Date, showTwelveHour = false, showSeconds = true): string { const days = getDaysArray(); const months = getMonthsArray(); return _t('%(weekDayName)s, %(monthName)s %(day)s %(fullYear)s %(time)s', { @@ -105,7 +105,7 @@ export function formatFullDate(date: Date, showTwelveHour = false): string { monthName: months[date.getMonth()], day: date.getDate(), fullYear: date.getFullYear(), - time: formatFullTime(date, showTwelveHour), + time: showSeconds ? formatFullTime(date, showTwelveHour) : formatTime(date, showTwelveHour), }); } diff --git a/src/GroupAddressPicker.js b/src/GroupAddressPicker.js index e7ae3217bb..d956189f0d 100644 --- a/src/GroupAddressPicker.js +++ b/src/GroupAddressPicker.js @@ -148,13 +148,15 @@ function _onGroupAddRoomFinished(groupId, addrs, addRoomsPublicly) { const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); Modal.createTrackedDialog( 'Failed to add the following room to the group', - '', ErrorDialog, - { - title: _t( - "Failed to add the following rooms to %(groupId)s:", - {groupId}, - ), - description: errorList.join(", "), - }); + '', + ErrorDialog, + { + title: _t( + "Failed to add the following rooms to %(groupId)s:", + {groupId}, + ), + description: errorList.join(", "), + }, + ); }); } diff --git a/src/HtmlUtils.tsx b/src/HtmlUtils.tsx index 1dc342fac5..ef5ac383e3 100644 --- a/src/HtmlUtils.tsx +++ b/src/HtmlUtils.tsx @@ -130,11 +130,14 @@ export function sanitizedHtmlNode(insaneHtml: string) { return
; } -export function sanitizedHtmlNodeInnerText(insaneHtml: string) { - const saneHtml = sanitizeHtml(insaneHtml, sanitizeHtmlParams); - const contentDiv = document.createElement("div"); - contentDiv.innerHTML = saneHtml; - return contentDiv.innerText; +export function getHtmlText(insaneHtml: string) { + return sanitizeHtml(insaneHtml, { + allowedTags: [], + allowedAttributes: {}, + selfClosing: [], + allowedSchemes: [], + disallowedTagsMode: 'discard', + }) } /** @@ -419,8 +422,12 @@ export function bodyToHtml(content: IContent, highlights: string[], opts: IOpts safeBody = sanitizeHtml(formattedBody, sanitizeParams); if (SettingsStore.getValue("feature_latex_maths")) { - const phtml = cheerio.load(safeBody, - { _useHtmlParser2: true, decodeEntities: false }) + const phtml = cheerio.load(safeBody, { + // @ts-ignore: The `_useHtmlParser2` internal option is the + // simplest way to both parse and render using `htmlparser2`. + _useHtmlParser2: true, + decodeEntities: false, + }); // @ts-ignore - The types for `replaceWith` wrongly expect // Cheerio instance to be returned. phtml('div, span[data-mx-maths!=""]').replaceWith(function(i, e) { @@ -428,6 +435,7 @@ export function bodyToHtml(content: IContent, highlights: string[], opts: IOpts AllHtmlEntities.decode(phtml(e).attr('data-mx-maths')), { throwOnError: false, + // @ts-ignore - `e` can be an Element, not just a Node displayMode: e.name == 'div', output: "htmlAndMathml", }); diff --git a/src/IdentityAuthClient.js b/src/IdentityAuthClient.js index 1687adf13b..9239c1bc75 100644 --- a/src/IdentityAuthClient.js +++ b/src/IdentityAuthClient.js @@ -163,7 +163,7 @@ export default class IdentityAuthClient {
), button: _t("Trust"), - }); + }); const [confirmed] = await finished; if (confirmed) { // eslint-disable-next-line react-hooks/rules-of-hooks diff --git a/src/KeyBindingsDefaults.ts b/src/KeyBindingsDefaults.ts index ac9ef1f8cc..63c4ac0f86 100644 --- a/src/KeyBindingsDefaults.ts +++ b/src/KeyBindingsDefaults.ts @@ -360,11 +360,11 @@ const navigationBindings = (): KeyBinding[] => { action: NavigationAction.GoToHome, keyCombo: { key: Key.H, - ctrlOrCmd: true, - altKey: true, + ctrlKey: true, + altKey: !isMac, + shiftKey: isMac, }, }, - { action: NavigationAction.SelectPrevRoom, keyCombo: { diff --git a/src/KeyBindingsManager.ts b/src/KeyBindingsManager.ts index d862f10c02..aac14bde20 100644 --- a/src/KeyBindingsManager.ts +++ b/src/KeyBindingsManager.ts @@ -231,8 +231,10 @@ export class KeyBindingsManager { /** * Finds a matching KeyAction for a given KeyboardEvent */ - private getAction(getters: KeyBindingGetter[], ev: KeyboardEvent | React.KeyboardEvent) - : T | undefined { + private getAction( + getters: KeyBindingGetter[], + ev: KeyboardEvent | React.KeyboardEvent, + ): T | undefined { for (const getter of getters) { const bindings = getter(); const binding = bindings.find(it => isKeyComboMatch(ev, it.keyCombo, isMac)); diff --git a/src/Login.ts b/src/Login.ts index db3c4c11e4..d584df7dfe 100644 --- a/src/Login.ts +++ b/src/Login.ts @@ -1,9 +1,6 @@ /* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2017 Vector Creations Ltd -Copyright 2018 New Vector Ltd +Copyright 2015-2021 The Matrix.org Foundation C.I.C. Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> -Copyright 2020 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. @@ -59,7 +56,7 @@ export type LoginFlow = ISSOFlow | IPasswordFlow; // TODO: Move this to JS SDK /* eslint-disable camelcase */ interface ILoginParams { - identifier?: string; + identifier?: object; password?: string; token?: string; device_id?: string; diff --git a/src/Modal.tsx b/src/Modal.tsx index ab582b9b22..ce11c571b6 100644 --- a/src/Modal.tsx +++ b/src/Modal.tsx @@ -36,6 +36,7 @@ export interface IModal { onBeforeClose?(reason?: string): Promise; onFinished(...args: T): void; close(...args: T): void; + hidden?: boolean; } export interface IHandle { @@ -93,6 +94,12 @@ export class ModalManager { return container; } + public toggleCurrentDialogVisibility() { + const modal = this.getCurrentModal(); + if (!modal) return; + modal.hidden = !modal.hidden; + } + public hasDialogs() { return this.priorityModal || this.staticModal || this.modals.length > 0; } @@ -364,7 +371,7 @@ export class ModalManager { } const modal = this.getCurrentModal(); - if (modal !== this.staticModal) { + if (modal !== this.staticModal && !modal.hidden) { const classes = classNames("mx_Dialog_wrapper", modal.className, { mx_Dialog_wrapperWithStaticUnder: this.staticModal, }); diff --git a/src/Velociraptor.js b/src/NodeAnimator.js similarity index 59% rename from src/Velociraptor.js rename to src/NodeAnimator.js index 2da54babe5..8456e6e9fd 100644 --- a/src/Velociraptor.js +++ b/src/NodeAnimator.js @@ -1,16 +1,15 @@ import React from "react"; import ReactDom from "react-dom"; -import Velocity from "velocity-animate"; import PropTypes from 'prop-types'; /** - * The Velociraptor contains components and animates transitions with velocity. + * The NodeAnimator contains components and animates transitions. * It will only pick up direct changes to properties ('left', currently), and so * will not work for animating positional changes where the position is implicit * from DOM order. This makes it a lot simpler and lighter: if you need fully * automatic positional animation, look at react-shuffle or similar libraries. */ -export default class Velociraptor extends React.Component { +export default class NodeAnimator extends React.Component { static propTypes = { // either a list of child nodes, or a single child. children: PropTypes.any, @@ -20,14 +19,10 @@ export default class Velociraptor extends React.Component { // a list of state objects to apply to each child node in turn startStyles: PropTypes.array, - - // a list of transition options from the corresponding startStyle - enterTransitionOpts: PropTypes.array, }; static defaultProps = { startStyles: [], - enterTransitionOpts: [], }; constructor(props) { @@ -41,6 +36,18 @@ export default class Velociraptor extends React.Component { this._updateChildren(this.props.children); } + /** + * + * @param {HTMLElement} node element to apply styles to + * @param {object} styles a key/value pair of CSS properties + * @returns {void} + */ + _applyStyles(node, styles) { + Object.entries(styles).forEach(([property, value]) => { + node.style[property] = value; + }); + } + _updateChildren(newChildren) { const oldChildren = this.children || {}; this.children = {}; @@ -50,17 +57,8 @@ export default class Velociraptor extends React.Component { const oldNode = ReactDom.findDOMNode(this.nodes[old.key]); if (oldNode && oldNode.style.left !== c.props.style.left) { - Velocity(oldNode, { left: c.props.style.left }, this.props.transition).then(() => { - // special case visibility because it's nonsensical to animate an invisible element - // so we always hidden->visible pre-transition and visible->hidden after - if (oldNode.style.visibility === 'visible' && c.props.style.visibility === 'hidden') { - oldNode.style.visibility = c.props.style.visibility; - } - }); - //console.log("translation: "+oldNode.style.left+" -> "+c.props.style.left); - } - if (oldNode && oldNode.style.visibility === 'hidden' && c.props.style.visibility === 'visible') { - oldNode.style.visibility = c.props.style.visibility; + this._applyStyles(oldNode, { left: c.props.style.left }); + // console.log("translation: "+oldNode.style.left+" -> "+c.props.style.left); } // clone the old element with the props (and children) of the new element // so prop updates are still received by the children. @@ -94,33 +92,22 @@ export default class Velociraptor extends React.Component { this.props.startStyles.length > 0 ) { const startStyles = this.props.startStyles; - const transitionOpts = this.props.enterTransitionOpts; const domNode = ReactDom.findDOMNode(node); // start from startStyle 1: 0 is the one we gave it // to start with, so now we animate 1 etc. - for (var i = 1; i < startStyles.length; ++i) { - Velocity(domNode, startStyles[i], transitionOpts[i-1]); - /* - console.log("start:", - JSON.stringify(transitionOpts[i-1]), - "->", - JSON.stringify(startStyles[i]), - ); - */ + for (let i = 1; i < startStyles.length; ++i) { + this._applyStyles(domNode, startStyles[i]); + // console.log("start:" + // JSON.stringify(startStyles[i]), + // ); } // and then we animate to the resting state - Velocity(domNode, restingStyle, - transitionOpts[i-1]) - .then(() => { - // once we've reached the resting state, hide the element if - // appropriate - domNode.style.visibility = restingStyle.visibility; - }); + setTimeout(() => { + this._applyStyles(domNode, restingStyle); + }, 0); // console.log("enter:", - // JSON.stringify(transitionOpts[i-1]), - // "->", // JSON.stringify(restingStyle)); } this.nodes[k] = node; @@ -128,9 +115,7 @@ export default class Velociraptor extends React.Component { render() { return ( - - { Object.values(this.children) } - + <>{ Object.values(this.children) } ); } } diff --git a/src/Notifier.ts b/src/Notifier.ts index f68bfabc18..4f55046e72 100644 --- a/src/Notifier.ts +++ b/src/Notifier.ts @@ -331,6 +331,8 @@ export const Notifier = { if (!this.isSyncing) return; // don't alert for any messages initially if (ev.sender && ev.sender.userId === MatrixClientPeg.get().credentials.userId) return; + MatrixClientPeg.get().decryptEventIfNeeded(ev); + // If it's an encrypted event and the type is still 'm.room.encrypted', // it hasn't yet been decrypted, so wait until it is. if (ev.isBeingDecrypted() || ev.isDecryptionFailure()) { @@ -383,6 +385,10 @@ export const Notifier = { // don't bother notifying as user was recently active in this room return; } + if (SettingsStore.getValue("doNotDisturb")) { + // Don't bother the user if they didn't ask to be bothered + return; + } if (this.isEnabled()) { this._displayPopupNotification(ev, room); diff --git a/src/PasswordReset.js b/src/PasswordReset.js index 6fe6ca82cc..88ae00d088 100644 --- a/src/PasswordReset.js +++ b/src/PasswordReset.js @@ -54,7 +54,7 @@ export default class PasswordReset { return res; }, function(err) { if (err.errcode === 'M_THREEPID_NOT_FOUND') { - err.message = _t('This email address was not found'); + err.message = _t('This email address was not found'); } else if (err.httpStatus) { err.message = err.message + ` (Status ${err.httpStatus})`; } diff --git a/src/Resend.js b/src/Resend.js index bf69e59c1a..f1e5fb38f5 100644 --- a/src/Resend.js +++ b/src/Resend.js @@ -21,11 +21,11 @@ import { EventStatus } from 'matrix-js-sdk/src/models/event'; export default class Resend { static resendUnsentEvents(room) { - room.getPendingEvents().filter(function(ev) { + return Promise.all(room.getPendingEvents().filter(function(ev) { return ev.status === EventStatus.NOT_SENT; - }).forEach(function(event) { - Resend.resend(event); - }); + }).map(function(event) { + return Resend.resend(event); + })); } static cancelUnsentEvents(room) { @@ -38,7 +38,7 @@ export default class Resend { static resend(event) { const room = MatrixClientPeg.get().getRoom(event.getRoomId()); - MatrixClientPeg.get().resendEvent(event, room).then(function(res) { + return MatrixClientPeg.get().resendEvent(event, room).then(function(res) { dis.dispatch({ action: 'message_sent', event: event, diff --git a/src/ScalarAuthClient.js b/src/ScalarAuthClient.ts similarity index 84% rename from src/ScalarAuthClient.js rename to src/ScalarAuthClient.ts index 200b4fd7b9..a09c3494a8 100644 --- a/src/ScalarAuthClient.js +++ b/src/ScalarAuthClient.ts @@ -1,6 +1,5 @@ /* -Copyright 2016 OpenMarket Ltd -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2016, 2019, 2021 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. @@ -17,13 +16,14 @@ limitations under the License. import url from 'url'; import SettingsStore from "./settings/SettingsStore"; -import { Service, startTermsFlow, TermsNotSignedError } from './Terms'; +import { Service, startTermsFlow, TermsInteractionCallback, TermsNotSignedError } from './Terms'; import {MatrixClientPeg} from "./MatrixClientPeg"; import request from "browser-request"; import SdkConfig from "./SdkConfig"; import {WidgetType} from "./widgets/WidgetType"; import {SERVICE_TYPES} from "matrix-js-sdk/src/service-types"; +import { Room } from "matrix-js-sdk/src/models/room"; // The version of the integration manager API we're intending to work with const imApiVersion = "1.1"; @@ -31,9 +31,11 @@ const imApiVersion = "1.1"; // TODO: Generify the name of this class and all components within - it's not just for Scalar. export default class ScalarAuthClient { - constructor(apiUrl, uiUrl) { - this.apiUrl = apiUrl; - this.uiUrl = uiUrl; + private scalarToken: string; + private termsInteractionCallback: TermsInteractionCallback; + private isDefaultManager: boolean; + + constructor(private apiUrl: string, private uiUrl: string) { this.scalarToken = null; // `undefined` to allow `startTermsFlow` to fallback to a default // callback if this is unset. @@ -46,7 +48,7 @@ export default class ScalarAuthClient { this.isDefaultManager = apiUrl === configApiUrl && configUiUrl === uiUrl; } - _writeTokenToStore() { + private writeTokenToStore() { window.localStorage.setItem("mx_scalar_token_at_" + this.apiUrl, this.scalarToken); if (this.isDefaultManager) { // We remove the old token from storage to migrate upwards. This is safe @@ -56,7 +58,7 @@ export default class ScalarAuthClient { } } - _readTokenFromStore() { + private readTokenFromStore(): string { let token = window.localStorage.getItem("mx_scalar_token_at_" + this.apiUrl); if (!token && this.isDefaultManager) { token = window.localStorage.getItem("mx_scalar_token"); @@ -64,33 +66,33 @@ export default class ScalarAuthClient { return token; } - _readToken() { + private readToken(): string { if (this.scalarToken) return this.scalarToken; - return this._readTokenFromStore(); + return this.readTokenFromStore(); } setTermsInteractionCallback(callback) { this.termsInteractionCallback = callback; } - connect() { + connect(): Promise { return this.getScalarToken().then((tok) => { this.scalarToken = tok; }); } - hasCredentials() { + hasCredentials(): boolean { return this.scalarToken != null; // undef or null } // Returns a promise that resolves to a scalar_token string - getScalarToken() { - const token = this._readToken(); + getScalarToken(): Promise { + const token = this.readToken(); if (!token) { return this.registerForToken(); } else { - return this._checkToken(token).catch((e) => { + return this.checkToken(token).catch((e) => { if (e instanceof TermsNotSignedError) { // retrying won't help this throw e; @@ -100,7 +102,7 @@ export default class ScalarAuthClient { } } - _getAccountName(token) { + private getAccountName(token: string): Promise { const url = this.apiUrl + "/account"; return new Promise(function(resolve, reject) { @@ -125,8 +127,8 @@ export default class ScalarAuthClient { }); } - _checkToken(token) { - return this._getAccountName(token).then(userId => { + private checkToken(token: string): Promise { + return this.getAccountName(token).then(userId => { const me = MatrixClientPeg.get().getUserId(); if (userId !== me) { throw new Error("Scalar token is owned by someone else: " + me); @@ -154,7 +156,7 @@ export default class ScalarAuthClient { parsedImRestUrl.pathname = ''; return startTermsFlow([new Service( SERVICE_TYPES.IM, - parsedImRestUrl.format(), + url.format(parsedImRestUrl), token, )], this.termsInteractionCallback).then(() => { return token; @@ -165,22 +167,22 @@ export default class ScalarAuthClient { }); } - registerForToken() { + registerForToken(): Promise { // Get openid bearer token from the HS as the first part of our dance return MatrixClientPeg.get().getOpenIdToken().then((tokenObject) => { // Now we can send that to scalar and exchange it for a scalar token return this.exchangeForScalarToken(tokenObject); }).then((token) => { // Validate it (this mostly checks to see if the IM needs us to agree to some terms) - return this._checkToken(token); + return this.checkToken(token); }).then((token) => { this.scalarToken = token; - this._writeTokenToStore(); + this.writeTokenToStore(); return token; }); } - exchangeForScalarToken(openidTokenObject) { + exchangeForScalarToken(openidTokenObject: any): Promise { const scalarRestUrl = this.apiUrl; return new Promise(function(resolve, reject) { @@ -194,7 +196,7 @@ export default class ScalarAuthClient { if (err) { reject(err); } else if (response.statusCode / 100 !== 2) { - reject({statusCode: response.statusCode}); + reject(new Error(`Scalar request failed: ${response.statusCode}`)); } else if (!body || !body.scalar_token) { reject(new Error("Missing scalar_token in response")); } else { @@ -204,7 +206,7 @@ export default class ScalarAuthClient { }); } - getScalarPageTitle(url) { + getScalarPageTitle(url: string): Promise { let scalarPageLookupUrl = this.apiUrl + '/widgets/title_lookup'; scalarPageLookupUrl = this.getStarterLink(scalarPageLookupUrl); scalarPageLookupUrl += '&curl=' + encodeURIComponent(url); @@ -218,7 +220,7 @@ export default class ScalarAuthClient { if (err) { reject(err); } else if (response.statusCode / 100 !== 2) { - reject({statusCode: response.statusCode}); + reject(new Error(`Scalar request failed: ${response.statusCode}`)); } else if (!body) { reject(new Error("Missing page title in response")); } else { @@ -240,10 +242,10 @@ export default class ScalarAuthClient { * @param {string} widgetId The widget ID to disable assets for * @return {Promise} Resolves on completion */ - disableWidgetAssets(widgetType: WidgetType, widgetId) { + disableWidgetAssets(widgetType: WidgetType, widgetId: string): Promise { let url = this.apiUrl + '/widgets/set_assets_state'; url = this.getStarterLink(url); - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { request({ method: 'GET', // XXX: Actions shouldn't be GET requests uri: url, @@ -257,7 +259,7 @@ export default class ScalarAuthClient { if (err) { reject(err); } else if (response.statusCode / 100 !== 2) { - reject({statusCode: response.statusCode}); + reject(new Error(`Scalar request failed: ${response.statusCode}`)); } else if (!body) { reject(new Error("Failed to set widget assets state")); } else { @@ -267,7 +269,7 @@ export default class ScalarAuthClient { }); } - getScalarInterfaceUrlForRoom(room, screen, id) { + getScalarInterfaceUrlForRoom(room: Room, screen: string, id: string): string { const roomId = room.roomId; const roomName = room.name; let url = this.uiUrl; @@ -284,7 +286,7 @@ export default class ScalarAuthClient { return url; } - getStarterLink(starterLinkUrl) { + getStarterLink(starterLinkUrl: string): string { return starterLinkUrl + "?scalar_token=" + encodeURIComponent(this.scalarToken); } } diff --git a/src/SlashCommands.tsx b/src/SlashCommands.tsx index 3b6a202cf6..4a7b37b5e5 100644 --- a/src/SlashCommands.tsx +++ b/src/SlashCommands.tsx @@ -20,7 +20,7 @@ limitations under the License. import * as React from 'react'; -import { ContentHelpers } from 'matrix-js-sdk'; +import * as ContentHelpers from 'matrix-js-sdk/src/content-helpers'; import {MatrixClientPeg} from './MatrixClientPeg'; import dis from './dispatcher/dispatcher'; import * as sdk from './index'; @@ -38,7 +38,7 @@ import {isPermalinkHost, parsePermalink} from "./utils/permalinks/Permalinks"; import {inviteUsersToRoom} from "./RoomInvite"; import { WidgetType } from "./widgets/WidgetType"; import { Jitsi } from "./widgets/Jitsi"; -import { parseFragment as parseHtml } from "parse5"; +import { parseFragment as parseHtml, Element as ChildElement } from "parse5"; import BugReportDialog from "./components/views/dialogs/BugReportDialog"; import { ensureDMExists } from "./createRoom"; import { ViewUserPayload } from "./dispatcher/payloads/ViewUserPayload"; @@ -856,7 +856,7 @@ export const Commands = [ // some superfast regex over the text so we don't have to. const embed = parseHtml(widgetUrl); if (embed && embed.childNodes && embed.childNodes.length === 1) { - const iframe = embed.childNodes[0]; + const iframe = embed.childNodes[0] as ChildElement; if (iframe.tagName.toLowerCase() === 'iframe' && iframe.attrs) { const srcAttr = iframe.attrs.find(a => a.name === 'src'); console.log("Pulling URL out of iframe (embed code)"); @@ -1222,4 +1222,5 @@ export function getCommand(input: string) { args, }; } + return {}; } diff --git a/src/Terms.js b/src/Terms.ts similarity index 87% rename from src/Terms.js rename to src/Terms.ts index 6ae89f9a2c..1bdff36cbc 100644 --- a/src/Terms.js +++ b/src/Terms.ts @@ -1,5 +1,5 @@ /* -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2019, 2021 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. @@ -17,7 +17,7 @@ limitations under the License. import classNames from 'classnames'; import {MatrixClientPeg} from './MatrixClientPeg'; -import * as sdk from './'; +import * as sdk from '.'; import Modal from './Modal'; export class TermsNotSignedError extends Error {} @@ -32,13 +32,30 @@ export class Service { * @param {string} baseUrl The Base URL of the service (ie. before '/_matrix') * @param {string} accessToken The user's access token for the service */ - constructor(serviceType, baseUrl, accessToken) { - this.serviceType = serviceType; - this.baseUrl = baseUrl; - this.accessToken = accessToken; + constructor(public serviceType: string, public baseUrl: string, public accessToken: string) { } } +interface Policy { + // @ts-ignore: No great way to express indexed types together with other keys + version: string; + [lang: string]: { + url: string; + }; +} +type Policies = { + [policy: string]: Policy, +}; + +export type TermsInteractionCallback = ( + policiesAndServicePairs: { + service: Service, + policies: Policies, + }[], + agreedUrls: string[], + extraClassNames?: string, +) => Promise; + /** * Start a flow where the user is presented with terms & conditions for some services * @@ -51,8 +68,8 @@ export class Service { * if they cancel. */ export async function startTermsFlow( - services, - interactionCallback = dialogTermsInteractionCallback, + services: Service[], + interactionCallback: TermsInteractionCallback = dialogTermsInteractionCallback, ) { const termsPromises = services.map( (s) => MatrixClientPeg.get().getTerms(s.serviceType, s.baseUrl), @@ -77,7 +94,7 @@ export async function startTermsFlow( * } */ - const terms = await Promise.all(termsPromises); + const terms: { policies: Policies }[] = await Promise.all(termsPromises); const policiesAndServicePairs = terms.map((t, i) => { return { 'service': services[i], 'policies': t.policies }; }); // fetch the set of agreed policy URLs from account data @@ -158,10 +175,13 @@ export async function startTermsFlow( } export function dialogTermsInteractionCallback( - policiesAndServicePairs, - agreedUrls, - extraClassNames, -) { + policiesAndServicePairs: { + service: Service, + policies: { [policy: string]: Policy }, + }[], + agreedUrls: string[], + extraClassNames?: string, +): Promise { return new Promise((resolve, reject) => { console.log("Terms that need agreement", policiesAndServicePairs); const TermsDialog = sdk.getComponent("views.dialogs.TermsDialog"); diff --git a/src/TextForEvent.js b/src/TextForEvent.js index 3afe41d216..86f9ff20f4 100644 --- a/src/TextForEvent.js +++ b/src/TextForEvent.js @@ -95,9 +95,10 @@ function textForMemberEvent(ev) { senderName, targetName, }) + ' ' + reason; - } else { - // sender is not target and made the target leave, if not from invite/ban then this is a kick + } else if (prevContent.membership === "join") { return _t('%(senderName)s kicked %(targetName)s.', {senderName, targetName}) + ' ' + reason; + } else { + return ""; } } } @@ -546,17 +547,23 @@ function textForMjolnirEvent(event) { // else the entity !== prevEntity - count as a removal & add if (USER_RULE_TYPES.includes(event.getType())) { - return _t("%(senderName)s changed a rule that was banning users matching %(oldGlob)s to matching " + + return _t( + "%(senderName)s changed a rule that was banning users matching %(oldGlob)s to matching " + "%(newGlob)s for %(reason)s", - {senderName, oldGlob: prevEntity, newGlob: entity, reason}); + {senderName, oldGlob: prevEntity, newGlob: entity, reason}, + ); } else if (ROOM_RULE_TYPES.includes(event.getType())) { - return _t("%(senderName)s changed a rule that was banning rooms matching %(oldGlob)s to matching " + + return _t( + "%(senderName)s changed a rule that was banning rooms matching %(oldGlob)s to matching " + "%(newGlob)s for %(reason)s", - {senderName, oldGlob: prevEntity, newGlob: entity, reason}); + {senderName, oldGlob: prevEntity, newGlob: entity, reason}, + ); } else if (SERVER_RULE_TYPES.includes(event.getType())) { - return _t("%(senderName)s changed a rule that was banning servers matching %(oldGlob)s to matching " + + return _t( + "%(senderName)s changed a rule that was banning servers matching %(oldGlob)s to matching " + "%(newGlob)s for %(reason)s", - {senderName, oldGlob: prevEntity, newGlob: entity, reason}); + {senderName, oldGlob: prevEntity, newGlob: entity, reason}, + ); } // Unknown type. We'll say something but we shouldn't end up here. diff --git a/src/Unread.js b/src/Unread.js index ddf225ac64..25c425aa9a 100644 --- a/src/Unread.js +++ b/src/Unread.js @@ -40,12 +40,14 @@ export function eventTriggersUnreadCount(ev) { return false; } else if (ev.getType() == 'm.room.server_acl') { return false; + } else if (ev.isRedacted()) { + return false; } return haveTileForEvent(ev); } export function doesRoomHaveUnreadMessages(room) { - const myUserId = MatrixClientPeg.get().credentials.userId; + const myUserId = MatrixClientPeg.get().getUserId(); // get the most recent read receipt sent by our account. // N.B. this is NOT a read marker (RM, aka "read up to marker"), diff --git a/src/VelocityBounce.js b/src/VelocityBounce.js deleted file mode 100644 index ffbf7de829..0000000000 --- a/src/VelocityBounce.js +++ /dev/null @@ -1,17 +0,0 @@ -import Velocity from "velocity-animate"; - -// courtesy of https://github.com/julianshapiro/velocity/issues/283 -// We only use easeOutBounce (easeInBounce is just sort of nonsensical) -function bounce( p ) { - let pow2; - let bounce = 4; - - while ( p < ( ( pow2 = Math.pow( 2, --bounce ) ) - 1 ) / 11 ) { - // just sets pow2 - } - return 1 / Math.pow( 4, 3 - bounce ) - 7.5625 * Math.pow( ( pow2 * 3 - 2 ) / 22 - p, 2 ); -} - -Velocity.Easings.easeOutBounce = function(p) { - return 1 - bounce(1 - p); -}; diff --git a/src/VoipUserMapper.ts b/src/VoipUserMapper.ts index 4f5613b4a8..e5bed2e812 100644 --- a/src/VoipUserMapper.ts +++ b/src/VoipUserMapper.ts @@ -57,7 +57,11 @@ export default class VoipUserMapper { if (!virtualRoom) return null; const virtualRoomEvent = virtualRoom.getAccountData(VIRTUAL_ROOM_EVENT_TYPE); if (!virtualRoomEvent || !virtualRoomEvent.getContent()) return null; - return virtualRoomEvent.getContent()['native_room'] || null; + const nativeRoomID = virtualRoomEvent.getContent()['native_room']; + const nativeRoom = MatrixClientPeg.get().getRoom(nativeRoomID); + if (!nativeRoom || nativeRoom.getMyMembership() !== 'join') return null; + + return nativeRoomID; } public isVirtualRoom(room: Room): boolean { diff --git a/src/accessibility/KeyboardShortcuts.tsx b/src/accessibility/KeyboardShortcuts.tsx index 7a0ba58c97..2a3e576e31 100644 --- a/src/accessibility/KeyboardShortcuts.tsx +++ b/src/accessibility/KeyboardShortcuts.tsx @@ -265,7 +265,7 @@ const shortcuts: Record = { description: _td("Toggle this dialog"), }, { keybinds: [{ - modifiers: [CMD_OR_CTRL, Modifiers.ALT], + modifiers: [Modifiers.CONTROL, isMac ? Modifiers.SHIFT : Modifiers.ALT], key: Key.H, }], description: _td("Go to Home View"), diff --git a/src/accessibility/RovingTabIndex.tsx b/src/accessibility/RovingTabIndex.tsx index b49a90d175..4cb537f318 100644 --- a/src/accessibility/RovingTabIndex.tsx +++ b/src/accessibility/RovingTabIndex.tsx @@ -167,7 +167,7 @@ export const RovingTabIndexProvider: React.FC = ({children, handleHomeEn const onKeyDownHandler = useCallback((ev) => { let handled = false; // Don't interfere with input default keydown behaviour - if (handleHomeEnd && ev.target.tagName !== "INPUT") { + if (handleHomeEnd && ev.target.tagName !== "INPUT" && ev.target.tagName !== "TEXTAREA") { // check if we actually have any items switch (ev.key) { case Key.HOME: diff --git a/src/async-components/views/dialogs/eventindex/ManageEventIndexDialog.js b/src/async-components/views/dialogs/eventindex/ManageEventIndexDialog.tsx similarity index 90% rename from src/async-components/views/dialogs/eventindex/ManageEventIndexDialog.js rename to src/async-components/views/dialogs/eventindex/ManageEventIndexDialog.tsx index be3368b87b..0710c513da 100644 --- a/src/async-components/views/dialogs/eventindex/ManageEventIndexDialog.js +++ b/src/async-components/views/dialogs/eventindex/ManageEventIndexDialog.tsx @@ -1,5 +1,5 @@ /* -Copyright 2020 The Matrix.org Foundation C.I.C. +Copyright 2020-2021 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. @@ -16,7 +16,6 @@ limitations under the License. import React from 'react'; import * as sdk from '../../../../index'; -import PropTypes from 'prop-types'; import { _t } from '../../../../languageHandler'; import SdkConfig from '../../../../SdkConfig'; import SettingsStore from "../../../../settings/SettingsStore"; @@ -26,14 +25,23 @@ import {formatBytes, formatCountLong} from "../../../../utils/FormattingUtils"; import EventIndexPeg from "../../../../indexing/EventIndexPeg"; import {SettingLevel} from "../../../../settings/SettingLevel"; +interface IProps { + onFinished: (confirmed: boolean) => void; +} + +interface IState { + eventIndexSize: number; + eventCount: number; + crawlingRoomsCount: number; + roomCount: number; + currentRoom: string; + crawlerSleepTime: number; +} + /* * Allows the user to introspect the event index state and disable it. */ -export default class ManageEventIndexDialog extends React.Component { - static propTypes = { - onFinished: PropTypes.func.isRequired, - }; - +export default class ManageEventIndexDialog extends React.Component { constructor(props) { super(props); @@ -84,7 +92,7 @@ export default class ManageEventIndexDialog extends React.Component { } } - async componentDidMount(): void { + async componentDidMount(): Promise { let eventIndexSize = 0; let crawlingRoomsCount = 0; let roomCount = 0; @@ -123,14 +131,14 @@ export default class ManageEventIndexDialog extends React.Component { }); } - _onDisable = async () => { + private onDisable = async () => { Modal.createTrackedDialogAsync("Disable message search", "Disable message search", import("./DisableEventIndexDialog"), null, null, /* priority = */ false, /* static = */ true, ); }; - _onCrawlerSleepTimeChange = (e) => { + private onCrawlerSleepTimeChange = (e) => { this.setState({crawlerSleepTime: e.target.value}); SettingsStore.setValue("crawlerSleepTime", null, SettingLevel.DEVICE, e.target.value); }; @@ -144,7 +152,7 @@ export default class ManageEventIndexDialog extends React.Component { crawlerState = _t("Not currently indexing messages for any room."); } else { crawlerState = ( - _t("Currently indexing: %(currentRoom)s", { currentRoom: this.state.currentRoom }) + _t("Currently indexing: %(currentRoom)s", { currentRoom: this.state.currentRoom }) ); } @@ -169,7 +177,7 @@ export default class ManageEventIndexDialog extends React.Component { label={_t('Message downloading sleep time(ms)')} type='number' value={this.state.crawlerSleepTime} - onChange={this._onCrawlerSleepTimeChange} /> + onChange={this.onCrawlerSleepTimeChange} /> ); @@ -188,7 +196,7 @@ export default class ManageEventIndexDialog extends React.Component { onPrimaryButtonClick={this.props.onFinished} primaryButtonClass="primary" cancelButton={_t("Disable")} - onCancel={this._onDisable} + onCancel={this.onDisable} cancelButtonClass="danger" /> diff --git a/src/async-components/views/dialogs/security/CreateKeyBackupDialog.js b/src/async-components/views/dialogs/security/CreateKeyBackupDialog.js index 863ee2b427..549494b5cb 100644 --- a/src/async-components/views/dialogs/security/CreateKeyBackupDialog.js +++ b/src/async-components/views/dialogs/security/CreateKeyBackupDialog.js @@ -310,7 +310,7 @@ export default class CreateKeyBackupDialog extends React.PureComponent { const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); return

{_t( - "Please enter your Security Phrase a second time to confirm.", + "Enter your Security Phrase a second time to confirm it.", )}

@@ -498,9 +498,9 @@ export default class CreateKeyBackupDialog extends React.PureComponent { title={this._titleForPhase(this.state.phase)} hasCancel={[PHASE_PASSPHRASE, PHASE_DONE].includes(this.state.phase)} > -
- {content} -
+
+ {content} +
); } diff --git a/src/async-components/views/dialogs/security/CreateSecretStorageDialog.js b/src/async-components/views/dialogs/security/CreateSecretStorageDialog.js index 84cb58536a..6d5703a768 100644 --- a/src/async-components/views/dialogs/security/CreateSecretStorageDialog.js +++ b/src/async-components/views/dialogs/security/CreateSecretStorageDialog.js @@ -647,7 +647,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent { } return

{_t( - "Enter your recovery passphrase a second time to confirm it.", + "Enter your Security Phrase a second time to confirm it.", )}

@@ -856,9 +856,9 @@ export default class CreateSecretStorageDialog extends React.PureComponent { hasCancel={this.props.hasCancel && [PHASE_PASSPHRASE].includes(this.state.phase)} fixedWidth={false} > -
- {content} -
+
+ {content} +
); } diff --git a/src/async-components/views/dialogs/security/ExportE2eKeysDialog.js b/src/async-components/views/dialogs/security/ExportE2eKeysDialog.js index eeb68b94bd..60f2ca9168 100644 --- a/src/async-components/views/dialogs/security/ExportE2eKeysDialog.js +++ b/src/async-components/views/dialogs/security/ExportE2eKeysDialog.js @@ -170,8 +170,11 @@ export default class ExportE2eKeysDialog extends React.Component {
-
-
- -
-
- -
+
+ +
+
+ +
-
- -
-
- -
+
+ +
+
+ +
diff --git a/src/autocomplete/AutocompleteProvider.tsx b/src/autocomplete/AutocompleteProvider.tsx index a40ce7144d..2242fec914 100644 --- a/src/autocomplete/AutocompleteProvider.tsx +++ b/src/autocomplete/AutocompleteProvider.tsx @@ -93,7 +93,12 @@ export default class AutocompleteProvider { }; } - async getCompletions(query: string, selection: ISelectionRange, force = false): Promise { + async getCompletions( + query: string, + selection: ISelectionRange, + force = false, + limit = -1, + ): Promise { return []; } diff --git a/src/autocomplete/Autocompleter.ts b/src/autocomplete/Autocompleter.ts index 2615736e09..5409825f45 100644 --- a/src/autocomplete/Autocompleter.ts +++ b/src/autocomplete/Autocompleter.ts @@ -26,6 +26,8 @@ import EmojiProvider from './EmojiProvider'; import NotifProvider from './NotifProvider'; import {timeout} from "../utils/promise"; import AutocompleteProvider, {ICommand} from "./AutocompleteProvider"; +import SettingsStore from "../settings/SettingsStore"; +import SpaceProvider from "./SpaceProvider"; export interface ISelectionRange { beginning?: boolean; // whether the selection is in the first block of the editor or not @@ -56,6 +58,11 @@ const PROVIDERS = [ DuckDuckGoProvider, ]; +// as the spaces feature is device configurable only, and toggling it refreshes the page, we can do this here +if (SettingsStore.getValue("feature_spaces")) { + PROVIDERS.push(SpaceProvider); +} + // Providers will get rejected if they take longer than this. const PROVIDER_COMPLETION_TIMEOUT = 3000; @@ -82,15 +89,24 @@ export default class Autocompleter { }); } - async getCompletions(query: string, selection: ISelectionRange, force = false): Promise { + async getCompletions( + query: string, + selection: ISelectionRange, + force = false, + limit = -1, + ): Promise { /* Note: This intentionally waits for all providers to return, otherwise, we run into a condition where new completions are displayed while the user is interacting with the list, which makes it difficult to predict whether an action will actually do what is intended */ // list of results from each provider, each being a list of completions or null if it times out - const completionsList: ICompletion[][] = await Promise.all(this.providers.map(provider => { - return timeout(provider.getCompletions(query, selection, force), null, PROVIDER_COMPLETION_TIMEOUT); + const completionsList: ICompletion[][] = await Promise.all(this.providers.map(async provider => { + return await timeout( + provider.getCompletions(query, selection, force, limit), + null, + PROVIDER_COMPLETION_TIMEOUT, + ); })); // map then filter to maintain the index for the map-operation, for this.providers to line up diff --git a/src/autocomplete/CommandProvider.tsx b/src/autocomplete/CommandProvider.tsx index c2d1290e08..9de25c0d84 100644 --- a/src/autocomplete/CommandProvider.tsx +++ b/src/autocomplete/CommandProvider.tsx @@ -38,7 +38,12 @@ export default class CommandProvider extends AutocompleteProvider { }); } - async getCompletions(query: string, selection: ISelectionRange, force?: boolean): Promise { + async getCompletions( + query: string, + selection: ISelectionRange, + force?: boolean, + limit = -1, + ): Promise { const {command, range} = this.getCurrentCommand(query, selection); if (!command) return []; @@ -55,10 +60,11 @@ export default class CommandProvider extends AutocompleteProvider { } else { if (query === '/') { // If they have just entered `/` show everything + // We exclude the limit on purpose to have a comprehensive list matches = Commands; } else { // otherwise fuzzy match against all of the fields - matches = this.matcher.match(command[1]); + matches = this.matcher.match(command[1], limit); } } diff --git a/src/autocomplete/CommunityProvider.tsx b/src/autocomplete/CommunityProvider.tsx index b7a4e0960e..c9358b0c61 100644 --- a/src/autocomplete/CommunityProvider.tsx +++ b/src/autocomplete/CommunityProvider.tsx @@ -50,7 +50,12 @@ export default class CommunityProvider extends AutocompleteProvider { }); } - async getCompletions(query: string, selection: ISelectionRange, force = false): Promise { + async getCompletions( + query: string, + selection: ISelectionRange, + force = false, + limit = -1, + ): Promise { const BaseAvatar = sdk.getComponent('views.avatars.BaseAvatar'); // Disable autocompletions when composing commands because of various issues @@ -81,7 +86,7 @@ export default class CommunityProvider extends AutocompleteProvider { this.matcher.setObjects(groups); const matchedString = command[0]; - completions = this.matcher.match(matchedString); + completions = this.matcher.match(matchedString, limit); completions = sortBy(completions, [ (c) => score(matchedString, c.groupId), (c) => c.groupId.length, diff --git a/src/autocomplete/DuckDuckGoProvider.tsx b/src/autocomplete/DuckDuckGoProvider.tsx index e63f7255dc..3ef9cc2f6f 100644 --- a/src/autocomplete/DuckDuckGoProvider.tsx +++ b/src/autocomplete/DuckDuckGoProvider.tsx @@ -36,7 +36,12 @@ export default class DuckDuckGoProvider extends AutocompleteProvider { + `&format=json&no_redirect=1&no_html=1&t=${encodeURIComponent(REFERRER)}`; } - async getCompletions(query: string, selection: ISelectionRange, force= false): Promise { + async getCompletions( + query: string, + selection: ISelectionRange, + force = false, + limit = -1, + ): Promise { const {command, range} = this.getCurrentCommand(query, selection); if (!query || !command) { return []; @@ -46,7 +51,8 @@ export default class DuckDuckGoProvider extends AutocompleteProvider { method: 'GET', }); const json = await response.json(); - const results = json.Results.map((result) => { + const maxLength = limit > -1 ? limit : json.Results.length; + const results = json.Results.slice(0, maxLength).map((result) => { return { completion: result.Text, component: ( diff --git a/src/autocomplete/EmojiProvider.tsx b/src/autocomplete/EmojiProvider.tsx index 705474f8d0..b7c4a5120a 100644 --- a/src/autocomplete/EmojiProvider.tsx +++ b/src/autocomplete/EmojiProvider.tsx @@ -84,7 +84,12 @@ export default class EmojiProvider extends AutocompleteProvider { }); } - async getCompletions(query: string, selection: ISelectionRange, force?: boolean): Promise { + async getCompletions( + query: string, + selection: ISelectionRange, + force?: boolean, + limit = -1, + ): Promise { if (!SettingsStore.getValue("MessageComposerInput.suggestEmoji")) { return []; // don't give any suggestions if the user doesn't want them } @@ -93,7 +98,7 @@ export default class EmojiProvider extends AutocompleteProvider { const {command, range} = this.getCurrentCommand(query, selection); if (command) { const matchedString = command[0]; - completions = this.matcher.match(matchedString); + completions = this.matcher.match(matchedString, limit); // Do second match with shouldMatchWordsOnly in order to match against 'name' completions = completions.concat(this.nameMatcher.match(matchedString)); diff --git a/src/autocomplete/NotifProvider.tsx b/src/autocomplete/NotifProvider.tsx index ef1823c0ca..0bc7ead097 100644 --- a/src/autocomplete/NotifProvider.tsx +++ b/src/autocomplete/NotifProvider.tsx @@ -33,7 +33,12 @@ export default class NotifProvider extends AutocompleteProvider { this.room = room; } - async getCompletions(query: string, selection: ISelectionRange, force= false): Promise { + async getCompletions( + query: string, + selection: ISelectionRange, + force = false, + limit = -1, + ): Promise { const RoomAvatar = sdk.getComponent('views.avatars.RoomAvatar'); const client = MatrixClientPeg.get(); diff --git a/src/autocomplete/QueryMatcher.ts b/src/autocomplete/QueryMatcher.ts index 91fbea4d6a..73bb37ff0f 100644 --- a/src/autocomplete/QueryMatcher.ts +++ b/src/autocomplete/QueryMatcher.ts @@ -21,7 +21,7 @@ import {removeHiddenChars} from "matrix-js-sdk/src/utils"; interface IOptions { keys: Array; - funcs?: Array<(T) => string>; + funcs?: Array<(T) => string | string[]>; shouldMatchWordsOnly?: boolean; // whether to apply unhomoglyph and strip diacritics to fuzz up the search. Defaults to true fuzzy?: boolean; @@ -69,7 +69,12 @@ export default class QueryMatcher { if (this._options.funcs) { for (const f of this._options.funcs) { - keyValues.push(f(object)); + const v = f(object); + if (Array.isArray(v)) { + keyValues.push(...v); + } else { + keyValues.push(v); + } } } @@ -87,7 +92,7 @@ export default class QueryMatcher { } } - match(query: string): T[] { + match(query: string, limit = -1): T[] { query = this.processQuery(query); if (this._options.shouldMatchWordsOnly) { query = query.replace(/[^\w]/g, ''); @@ -129,7 +134,10 @@ export default class QueryMatcher { }); // Now map the keys to the result objects. Also remove any duplicates. - return uniq(matches.map((match) => match.object)); + const dedupped = uniq(matches.map((match) => match.object)); + const maxLength = limit === -1 ? dedupped.length : limit; + + return dedupped.slice(0, maxLength); } private processQuery(query: string): string { diff --git a/src/autocomplete/RoomProvider.tsx b/src/autocomplete/RoomProvider.tsx index 74deacf61f..ad55b19101 100644 --- a/src/autocomplete/RoomProvider.tsx +++ b/src/autocomplete/RoomProvider.tsx @@ -1,8 +1,7 @@ /* Copyright 2016 Aviral Dasgupta -Copyright 2017 Vector Creations Ltd -Copyright 2017, 2018 New Vector Ltd Copyright 2018 Michael Telatynski <7t3chguy@gmail.com> +Copyright 2017, 2018, 2021 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. @@ -17,17 +16,19 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from 'react'; +import React from "react"; +import {uniqBy, sortBy} from "lodash"; import Room from "matrix-js-sdk/src/models/room"; + import { _t } from '../languageHandler'; import AutocompleteProvider from './AutocompleteProvider'; import {MatrixClientPeg} from '../MatrixClientPeg'; import QueryMatcher from './QueryMatcher'; import {PillCompletion} from './Components'; -import * as sdk from '../index'; import {makeRoomPermalink} from "../utils/permalinks/Permalinks"; import {ICompletion, ISelectionRange} from "./Autocompleter"; -import {uniqBy, sortBy} from "lodash"; +import RoomAvatar from '../components/views/avatars/RoomAvatar'; +import SettingsStore from "../settings/SettingsStore"; const ROOM_REGEX = /\B#\S*/g; @@ -49,7 +50,7 @@ function matcherObject(room: Room, displayedAlias: string, matchName = "") { } export default class RoomProvider extends AutocompleteProvider { - matcher: QueryMatcher; + protected matcher: QueryMatcher; constructor() { super(ROOM_REGEX); @@ -58,15 +59,28 @@ export default class RoomProvider extends AutocompleteProvider { }); } - async getCompletions(query: string, selection: ISelectionRange, force = false): Promise { - const RoomAvatar = sdk.getComponent('views.avatars.RoomAvatar'); + protected getRooms() { + const cli = MatrixClientPeg.get(); + let rooms = cli.getVisibleRooms(); - const client = MatrixClientPeg.get(); + if (SettingsStore.getValue("feature_spaces")) { + rooms = rooms.filter(r => !r.isSpaceRoom()); + } + + return rooms; + } + + async getCompletions( + query: string, + selection: ISelectionRange, + force = false, + limit = -1, + ): Promise { let completions = []; const {command, range} = this.getCurrentCommand(query, selection, force); if (command) { // the only reason we need to do this is because Fuse only matches on properties - let matcherObjects = client.getVisibleRooms().reduce((aliases, room) => { + let matcherObjects = this.getRooms().reduce((aliases, room) => { if (room.getCanonicalAlias()) { aliases = aliases.concat(matcherObject(room, room.getCanonicalAlias(), room.name)); } @@ -90,7 +104,7 @@ export default class RoomProvider extends AutocompleteProvider { this.matcher.setObjects(matcherObjects); const matchedString = command[0]; - completions = this.matcher.match(matchedString); + completions = this.matcher.match(matchedString, limit); completions = sortBy(completions, [ (c) => score(matchedString, c.displayedAlias), (c) => c.displayedAlias.length, @@ -110,7 +124,7 @@ export default class RoomProvider extends AutocompleteProvider { ), range, }; - }).filter((completion) => !!completion.completion && completion.completion.length > 0).slice(0, 4); + }).filter((completion) => !!completion.completion && completion.completion.length > 0); } return completions; } diff --git a/src/autocomplete/SpaceProvider.tsx b/src/autocomplete/SpaceProvider.tsx new file mode 100644 index 0000000000..0361a2c91e --- /dev/null +++ b/src/autocomplete/SpaceProvider.tsx @@ -0,0 +1,43 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from "react"; + +import { _t } from '../languageHandler'; +import {MatrixClientPeg} from '../MatrixClientPeg'; +import RoomProvider from "./RoomProvider"; + +export default class SpaceProvider extends RoomProvider { + protected getRooms() { + return MatrixClientPeg.get().getVisibleRooms().filter(r => r.isSpaceRoom()); + } + + getName() { + return _t("Spaces"); + } + + renderCompletions(completions: React.ReactNode[]): React.ReactNode { + return ( +
+ { completions } +
+ ); + } +} diff --git a/src/autocomplete/UserProvider.tsx b/src/autocomplete/UserProvider.tsx index 5f0cfc2df1..3cf43d0b84 100644 --- a/src/autocomplete/UserProvider.tsx +++ b/src/autocomplete/UserProvider.tsx @@ -102,7 +102,12 @@ export default class UserProvider extends AutocompleteProvider { this.users = null; }; - async getCompletions(rawQuery: string, selection: ISelectionRange, force = false): Promise { + async getCompletions( + rawQuery: string, + selection: ISelectionRange, + force = false, + limit = -1, + ): Promise { const MemberAvatar = sdk.getComponent('views.avatars.MemberAvatar'); // lazy-load user list into matcher @@ -118,7 +123,7 @@ export default class UserProvider extends AutocompleteProvider { if (fullMatch && fullMatch !== '@') { // Don't include the '@' in our search query - it's only used as a way to trigger completion const query = fullMatch.startsWith('@') ? fullMatch.substring(1) : fullMatch; - completions = this.matcher.match(query).map((user) => { + completions = this.matcher.match(query, limit).map((user) => { const displayName = (user.name || user.userId || ''); return { // Length of completion should equal length of text in decorator. draft-js diff --git a/src/components/structures/ContextMenu.tsx b/src/components/structures/ContextMenu.tsx index 9d9d57d8a6..ad0f75e162 100644 --- a/src/components/structures/ContextMenu.tsx +++ b/src/components/structures/ContextMenu.tsx @@ -222,10 +222,12 @@ export class ContextMenu extends React.PureComponent { }; private onKeyDown = (ev: React.KeyboardEvent) => { + // don't let keyboard handling escape the context menu + ev.stopPropagation(); + if (!this.props.managed) { if (ev.key === Key.ESCAPE) { this.props.onFinished(); - ev.stopPropagation(); ev.preventDefault(); } return; @@ -258,7 +260,6 @@ export class ContextMenu extends React.PureComponent { if (handled) { // consume all other keys in context menu - ev.stopPropagation(); ev.preventDefault(); } }; diff --git a/src/components/structures/FilePanel.js b/src/components/structures/FilePanel.js index 32db5c251c..bb7c1f9642 100644 --- a/src/components/structures/FilePanel.js +++ b/src/components/structures/FilePanel.js @@ -50,6 +50,9 @@ class FilePanel extends React.Component { if (room?.roomId !== this.props?.roomId) return; if (toStartOfTimeline || !data || !data.liveEvent || ev.isRedacted()) return; + const client = MatrixClientPeg.get(); + client.decryptEventIfNeeded(ev); + if (ev.isBeingDecrypted()) { this.decryptingEvents.add(ev.getId()); } else { @@ -200,10 +203,10 @@ class FilePanel extends React.Component { previousPhase={RightPanelPhases.RoomSummary} >
- { _t("You must register to use this functionality", - {}, - { 'a': (sub) => { sub } }) - } + { _t("You must register to use this functionality", + {}, + { 'a': (sub) => { sub } }) + }
; } else if (this.noRoom) { diff --git a/src/components/structures/GroupFilterPanel.js b/src/components/structures/GroupFilterPanel.js index 976b2d81a5..2ff91e4976 100644 --- a/src/components/structures/GroupFilterPanel.js +++ b/src/components/structures/GroupFilterPanel.js @@ -123,12 +123,19 @@ class GroupFilterPanel extends React.Component { mx_GroupFilterPanel_items_selected: itemsSelected, }); + let betaDot; + if (SettingsStore.getBetaInfo("feature_spaces") && !localStorage.getItem("mx_seenSpacesBeta")) { + betaDot =
; + } + let createButton = ( + className="mx_TagTile mx_TagTile_plus"> + { betaDot } + ); if (SettingsStore.getValue("feature_communities_v2_prototypes")) { @@ -153,17 +160,17 @@ class GroupFilterPanel extends React.Component { type="draggable-TagTile" > { (provided, snapshot) => ( -
- { this.renderGlobalIcon() } - { tags } -
- {createButton} -
- { provided.placeholder } +
+ { this.renderGlobalIcon() } + { tags } +
+ {createButton}
+ { provided.placeholder } +
) } diff --git a/src/components/structures/GroupView.js b/src/components/structures/GroupView.js index ed6167cbe7..3ab009d7b8 100644 --- a/src/components/structures/GroupView.js +++ b/src/components/structures/GroupView.js @@ -43,7 +43,7 @@ import {mediaFromMxc} from "../../customisations/Media"; import {replaceableComponent} from "../../utils/replaceableComponent"; const LONG_DESC_PLACEHOLDER = _td( -`

HTML for your community's page

+ `

HTML for your community's page

Use the long description to introduce new members to the community, or distribute some important links @@ -110,14 +110,16 @@ class CategoryRoomList extends React.Component { const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); Modal.createTrackedDialog( 'Failed to add the following room to the group summary', - '', ErrorDialog, - { - title: _t( - "Failed to add the following rooms to the summary of %(groupId)s:", - {groupId: this.props.groupId}, - ), - description: errorList.join(", "), - }); + '', + ErrorDialog, + { + title: _t( + "Failed to add the following rooms to the summary of %(groupId)s:", + {groupId: this.props.groupId}, + ), + description: errorList.join(", "), + }, + ); }); }, }, /*className=*/null, /*isPriority=*/false, /*isStatic=*/true); @@ -146,8 +148,8 @@ class CategoryRoomList extends React.Component { let catHeader =

; if (this.props.category && this.props.category.profile) { catHeader =
- { this.props.category.profile.name } -
; + { this.props.category.profile.name } +
; } return
{ catHeader } @@ -190,13 +192,14 @@ class FeaturedRoom extends React.Component { Modal.createTrackedDialog( 'Failed to remove room from group summary', '', ErrorDialog, - { - title: _t( - "Failed to remove the room from the summary of %(groupId)s", - {groupId: this.props.groupId}, - ), - description: _t("The room '%(roomName)s' could not be removed from the summary.", {roomName}), - }); + { + title: _t( + "Failed to remove the room from the summary of %(groupId)s", + {groupId: this.props.groupId}, + ), + description: _t("The room '%(roomName)s' could not be removed from the summary.", {roomName}), + }, + ); }); }; @@ -283,13 +286,14 @@ class RoleUserList extends React.Component { Modal.createTrackedDialog( 'Failed to add the following users to the community summary', '', ErrorDialog, - { - title: _t( - "Failed to add the following users to the summary of %(groupId)s:", - {groupId: this.props.groupId}, - ), - description: errorList.join(", "), - }); + { + title: _t( + "Failed to add the following users to the summary of %(groupId)s:", + {groupId: this.props.groupId}, + ), + description: errorList.join(", "), + }, + ); }); }, }, /*className=*/null, /*isPriority=*/false, /*isStatic=*/true); @@ -299,11 +303,11 @@ class RoleUserList extends React.Component { const TintableSvg = sdk.getComponent("elements.TintableSvg"); const addButton = this.props.editing ? ( - -
- { _t('Add a User') } -
-
) :
; + +
+ { _t('Add a User') } +
+ ) :
; const userNodes = this.props.users.map((u) => { return - { _t("Leave %(groupName)s?", {groupName: this.props.groupId}) } - { warnings } + { _t("Leave %(groupName)s?", {groupName: this.props.groupId}) } + { warnings } ), button: _t("Leave"), @@ -1055,10 +1061,11 @@ export default class GroupView extends React.Component { return null; } - const membershipButtonClasses = classnames([ - 'mx_RoomHeader_textButton', - 'mx_GroupView_textButton', - ], + const membershipButtonClasses = classnames( + [ + 'mx_RoomHeader_textButton', + 'mx_GroupView_textButton', + ], membershipButtonExtraClasses, ); diff --git a/src/components/structures/LeftPanel.tsx b/src/components/structures/LeftPanel.tsx index cbfc7b476b..7f9ef7516e 100644 --- a/src/components/structures/LeftPanel.tsx +++ b/src/components/structures/LeftPanel.tsx @@ -154,7 +154,7 @@ export default class LeftPanel extends React.Component { private doStickyHeaders(list: HTMLDivElement) { const topEdge = list.scrollTop; const bottomEdge = list.offsetHeight + list.scrollTop; - const sublists = list.querySelectorAll(".mx_RoomSublist"); + const sublists = list.querySelectorAll(".mx_RoomSublist:not(.mx_RoomSublist_hidden)"); const headerRightMargin = 15; // calculated from margins and widths to align with non-sticky tiles const headerStickyWidth = list.clientWidth - headerRightMargin; @@ -347,7 +347,7 @@ export default class LeftPanel extends React.Component { if (element) { classes = element.classList; } - } while (element && !cssClasses.some(c => classes.contains(c))); + } while (element && (!cssClasses.some(c => classes.contains(c)) || element.offsetParent === null)); if (element) { element.focus(); @@ -416,7 +416,7 @@ export default class LeftPanel extends React.Component { const roomList = ; } /** @@ -160,6 +164,7 @@ class LoggedInView extends React.Component { // use compact timeline view useCompactLayout: SettingsStore.getValue('useCompactLayout'), usageLimitDismissed: false, + activeCalls: [], }; // stash the MatrixClient in case we log out before we are unmounted @@ -175,6 +180,7 @@ class LoggedInView extends React.Component { componentDidMount() { document.addEventListener('keydown', this._onNativeKeyDown, false); + CallHandler.sharedInstance().addListener(CallHandlerEvent.CallsChanged, this.onCallsChanged); this._updateServerNoticeEvents(); @@ -199,6 +205,7 @@ class LoggedInView extends React.Component { componentWillUnmount() { document.removeEventListener('keydown', this._onNativeKeyDown, false); + CallHandler.sharedInstance().removeListener(CallHandlerEvent.CallsChanged, this.onCallsChanged); this._matrixClient.removeListener("accountData", this.onAccountData); this._matrixClient.removeListener("sync", this.onSync); this._matrixClient.removeListener("RoomState.events", this.onRoomStateEvents); @@ -206,15 +213,11 @@ class LoggedInView extends React.Component { this.resizer.detach(); } - // Child components assume that the client peg will not be null, so give them some - // sort of assurance here by only allowing a re-render if the client is truthy. - // - // This is required because `LoggedInView` maintains its own state and if this state - // updates after the client peg has been made null (during logout), then it will - // attempt to re-render and the children will throw errors. - shouldComponentUpdate() { - return Boolean(MatrixClientPeg.get()); - } + private onCallsChanged = () => { + this.setState({ + activeCalls: CallHandler.sharedInstance().getAllActiveCalls(), + }); + }; canResetTimelineInRoom = (roomId) => { if (!this._roomView.current) { @@ -661,6 +664,12 @@ class LoggedInView extends React.Component { bodyClasses += ' mx_MatrixChat_useCompactLayout'; } + const audioFeedArraysForCalls = this.state.activeCalls.map((call) => { + return ( + + ); + }); + return (
{ + {audioFeedArraysForCalls} ); } diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index 532b1f4225..49386c5f65 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -84,6 +84,9 @@ import {replaceableComponent} from "../../utils/replaceableComponent"; import RoomListStore from "../../stores/room-list/RoomListStore"; import {RoomUpdateCause} from "../../stores/room-list/models"; import defaultDispatcher from "../../dispatcher/dispatcher"; +import SecurityCustomisations from "../../customisations/Security"; + +import PerformanceMonitor, { PerformanceEntryNames } from "../../performance"; /** constants for MatrixChat.state.view */ export enum Views { @@ -395,7 +398,11 @@ export default class MatrixChat extends React.PureComponent { const crossSigningIsSetUp = cli.getStoredCrossSigningForUser(cli.getUserId()); if (crossSigningIsSetUp) { - this.setStateForNewView({ view: Views.COMPLETE_SECURITY }); + if (SecurityCustomisations.SHOW_ENCRYPTION_SETUP_UI === false) { + this.onLoggedIn(); + } else { + this.setStateForNewView({view: Views.COMPLETE_SECURITY}); + } } else if (await cli.doesServerSupportUnstableFeature("org.matrix.e2e_cross_signing")) { this.setStateForNewView({ view: Views.E2E_SETUP }); } else { @@ -479,42 +486,22 @@ export default class MatrixChat extends React.PureComponent { } startPageChangeTimer() { - // Tor doesn't support performance - if (!performance || !performance.mark) return null; - - // This shouldn't happen because UNSAFE_componentWillUpdate and componentDidUpdate - // are used. - if (this.pageChanging) { - console.warn('MatrixChat.startPageChangeTimer: timer already started'); - return; - } - this.pageChanging = true; - performance.mark('element_MatrixChat_page_change_start'); + PerformanceMonitor.instance.start(PerformanceEntryNames.PAGE_CHANGE); } stopPageChangeTimer() { - // Tor doesn't support performance - if (!performance || !performance.mark) return null; + const perfMonitor = PerformanceMonitor.instance; - if (!this.pageChanging) { - console.warn('MatrixChat.stopPageChangeTimer: timer not started'); - return; - } - this.pageChanging = false; - performance.mark('element_MatrixChat_page_change_stop'); - performance.measure( - 'element_MatrixChat_page_change_delta', - 'element_MatrixChat_page_change_start', - 'element_MatrixChat_page_change_stop', - ); - performance.clearMarks('element_MatrixChat_page_change_start'); - performance.clearMarks('element_MatrixChat_page_change_stop'); - const measurement = performance.getEntriesByName('element_MatrixChat_page_change_delta').pop(); + perfMonitor.stop(PerformanceEntryNames.PAGE_CHANGE); - // In practice, sometimes the entries list is empty, so we get no measurement - if (!measurement) return null; + const entries = perfMonitor.getEntries({ + name: PerformanceEntryNames.PAGE_CHANGE, + }); + const measurement = entries.pop(); - return measurement.duration; + return measurement + ? measurement.duration + : null; } shouldTrackPageChange(prevState: IState, state: IState) { @@ -678,7 +665,7 @@ export default class MatrixChat extends React.PureComponent { break; } case 'view_create_room': - this.createRoom(payload.public); + this.createRoom(payload.public, payload.defaultName); break; case 'view_create_group': { let CreateGroupDialog = sdk.getComponent("dialogs.CreateGroupDialog") @@ -735,6 +722,8 @@ export default class MatrixChat extends React.PureComponent { this.showScreenAfterLogin(); break; case 'toggle_my_groups': + // persist that the user has interacted with this, use it to dismiss the beta dot + localStorage.setItem("mx_seenSpacesBeta", "1"); // We just dispatch the page change rather than have to worry about // what the logic is for each of these branches. if (this.state.page_type === PageTypes.MyGroups) { @@ -901,6 +890,11 @@ export default class MatrixChat extends React.PureComponent { let presentedId = roomInfo.room_alias || roomInfo.room_id; const room = MatrixClientPeg.get().getRoom(roomInfo.room_id); if (room) { + // Not all timeline events are decrypted ahead of time anymore + // Only the critical ones for a typical UI are + // This will start the decryption process for all events when a + // user views a room + room.decryptAllEvents(); const theAlias = Rooms.getDisplayAliasForRoom(room); if (theAlias) { presentedId = theAlias; @@ -1017,7 +1011,7 @@ export default class MatrixChat extends React.PureComponent { }); } - private async createRoom(defaultPublic = false) { + private async createRoom(defaultPublic = false, defaultName?: string) { const communityId = CommunityPrototypeStore.instance.getSelectedCommunityId(); if (communityId) { // double check the user will have permission to associate this room with the community @@ -1031,7 +1025,10 @@ export default class MatrixChat extends React.PureComponent { } const CreateRoomDialog = sdk.getComponent('dialogs.CreateRoomDialog'); - const modal = Modal.createTrackedDialog('Create Room', '', CreateRoomDialog, { defaultPublic }); + const modal = Modal.createTrackedDialog('Create Room', '', CreateRoomDialog, { + defaultPublic, + defaultName, + }); const [shouldCreate, opts] = await modal.finished; if (shouldCreate) { @@ -1089,10 +1086,24 @@ export default class MatrixChat extends React.PureComponent { private leaveRoomWarnings(roomId: string) { const roomToLeave = MatrixClientPeg.get().getRoom(roomId); - const isSpace = roomToLeave?.isSpaceRoom(); + const isSpace = SettingsStore.getValue("feature_spaces") && roomToLeave?.isSpaceRoom(); // Show a warning if there are additional complications. - const joinRules = roomToLeave.currentState.getStateEvents('m.room.join_rules', ''); const warnings = []; + + const memberCount = roomToLeave.currentState.getJoinedMemberCount(); + if (memberCount === 1) { + warnings.push(( + + {' '/* Whitespace, otherwise the sentences get smashed together */ } + { _t("You are the only person here. " + + "If you leave, no one will be able to join in the future, including you.") } + + )); + + return warnings; + } + + const joinRules = roomToLeave.currentState.getStateEvents('m.room.join_rules', ''); if (joinRules) { const rule = joinRules.getContent().join_rule; if (rule !== "public") { @@ -1114,7 +1125,7 @@ export default class MatrixChat extends React.PureComponent { const roomToLeave = MatrixClientPeg.get().getRoom(roomId); const warnings = this.leaveRoomWarnings(roomId); - const isSpace = roomToLeave?.isSpaceRoom(); + const isSpace = SettingsStore.getValue("feature_spaces") && roomToLeave?.isSpaceRoom(); Modal.createTrackedDialog(isSpace ? "Leave space" : "Leave room", '', QuestionDialog, { title: isSpace ? _t("Leave space") : _t("Leave room"), description: ( @@ -1606,11 +1617,13 @@ export default class MatrixChat extends React.PureComponent { action: 'start_registration', params: params, }); + PerformanceMonitor.instance.start(PerformanceEntryNames.REGISTER); } else if (screen === 'login') { dis.dispatch({ action: 'start_login', params: params, }); + PerformanceMonitor.instance.start(PerformanceEntryNames.LOGIN); } else if (screen === 'forgot_password') { dis.dispatch({ action: 'start_password_recovery', @@ -1665,6 +1678,10 @@ export default class MatrixChat extends React.PureComponent { const type = screen === "start_sso" ? "sso" : "cas"; PlatformPeg.get().startSingleSignOn(cli, type, this.getFragmentAfterLogin()); } else if (screen === 'groups') { + if (SettingsStore.getValue("feature_spaces")) { + dis.dispatch({ action: "view_home_page" }); + return; + } dis.dispatch({ action: 'view_my_groups', }); @@ -1748,6 +1765,11 @@ export default class MatrixChat extends React.PureComponent { subAction: params.action, }); } else if (screen.indexOf('group/') === 0) { + if (SettingsStore.getValue("feature_spaces")) { + dis.dispatch({ action: "view_home_page" }); + return; + } + const groupId = screen.substring(6); // TODO: Check valid group ID @@ -1930,6 +1952,8 @@ export default class MatrixChat extends React.PureComponent { // Create and start the client await Lifecycle.setLoggedIn(credentials); await this.postLoginSetup(); + PerformanceMonitor.instance.stop(PerformanceEntryNames.LOGIN); + PerformanceMonitor.instance.stop(PerformanceEntryNames.REGISTER); }; // complete security / e2e setup has finished diff --git a/src/components/structures/MessagePanel.js b/src/components/structures/MessagePanel.js index 371ee5dce7..53a6d4d92b 100644 --- a/src/components/structures/MessagePanel.js +++ b/src/components/structures/MessagePanel.js @@ -34,6 +34,7 @@ import IRCTimelineProfileResizer from "../views/elements/IRCTimelineProfileResiz import DMRoomMap from "../../utils/DMRoomMap"; import NewRoomIntro from "../views/rooms/NewRoomIntro"; import {replaceableComponent} from "../../utils/replaceableComponent"; +import defaultDispatcher from '../../dispatcher/dispatcher'; const CONTINUATION_MAX_INTERVAL = 5 * 60 * 1000; // 5 minutes const continuedTypes = ['m.sticker', 'm.room.message']; @@ -430,8 +431,10 @@ export default class MessagePanel extends React.Component { // we get a new DOM node (restarting the animation) when the ghost // moves to a different event. return ( -
  • +
  • { hr }
  • ); @@ -472,6 +475,10 @@ export default class MessagePanel extends React.Component { return {nextEvent, nextTile}; } + get _roomHasPendingEdit() { + return this.props.room && localStorage.getItem(`mx_edit_room_${this.props.room.roomId}`); + } + _getEventTiles() { this.eventNodes = {}; @@ -545,11 +552,13 @@ export default class MessagePanel extends React.Component { } if (!grouper) { const wantTile = this._shouldShowEvent(mxEv); + const isGrouped = false; if (wantTile) { // make sure we unpack the array returned by _getTilesForEvent, // otherwise react will auto-generate keys and we will end up // replacing all of the DOM elements every time we paginate. - ret.push(...this._getTilesForEvent(prevEvent, mxEv, last, nextEvent, nextTile)); + ret.push(...this._getTilesForEvent(prevEvent, mxEv, last, isGrouped, + nextEvent, nextTile)); prevEvent = mxEv; } @@ -558,6 +567,13 @@ export default class MessagePanel extends React.Component { } } + if (!this.props.editState && this._roomHasPendingEdit) { + defaultDispatcher.dispatch({ + action: "edit_event", + event: this.props.room.findEventById(this._roomHasPendingEdit), + }); + } + if (grouper) { ret.push(...grouper.getTiles()); } @@ -565,7 +581,7 @@ export default class MessagePanel extends React.Component { return ret; } - _getTilesForEvent(prevEvent, mxEv, last, nextEvent, nextEventWithTile) { + _getTilesForEvent(prevEvent, mxEv, last, isGrouped=false, nextEvent, nextEventWithTile) { const TileErrorBoundary = sdk.getComponent('messages.TileErrorBoundary'); const EventTile = sdk.getComponent('rooms.EventTile'); const DateSeparator = sdk.getComponent('messages.DateSeparator'); @@ -573,7 +589,6 @@ export default class MessagePanel extends React.Component { const isEditing = this.props.editState && this.props.editState.getEvent().getId() === mxEv.getId(); - // local echoes have a fake date, which could even be yesterday. Treat them // as 'today' for the date separators. let ts1 = mxEv.getTs(); @@ -585,7 +600,7 @@ export default class MessagePanel extends React.Component { // do we need a date separator since the last event? const wantsDateSeparator = this._wantsDateSeparator(prevEvent, eventDate); - if (wantsDateSeparator) { + if (wantsDateSeparator && !isGrouped) { const dateSeparator =
  • ; ret.push(dateSeparator); } @@ -662,6 +677,7 @@ export default class MessagePanel extends React.Component { showReactions={this.props.showReactions} layout={this.props.layout} enableFlair={this.props.enableFlair} + showReadReceipts={this.props.showReadReceipts} /> , @@ -969,9 +985,9 @@ class CreationGrouper { const DateSeparator = sdk.getComponent('messages.DateSeparator'); const EventListSummary = sdk.getComponent('views.elements.EventListSummary'); - const panel = this.panel; const ret = []; + const isGrouped = true; const createEvent = this.createEvent; const lastShownEvent = this.lastShownEvent; @@ -985,12 +1001,12 @@ class CreationGrouper { // If this m.room.create event should be shown (room upgrade) then show it before the summary if (panel._shouldShowEvent(createEvent)) { // pass in the createEvent as prevEvent as well so no extra DateSeparator is rendered - ret.push(...panel._getTilesForEvent(createEvent, createEvent, false)); + ret.push(...panel._getTilesForEvent(createEvent, createEvent)); } for (const ejected of this.ejectedEvents) { ret.push(...panel._getTilesForEvent( - createEvent, ejected, createEvent === lastShownEvent, + createEvent, ejected, createEvent === lastShownEvent, isGrouped, )); } @@ -999,7 +1015,7 @@ class CreationGrouper { // of EventListSummary, render each member event as if the previous // one was itself. This way, the timestamp of the previous event === the // timestamp of the current event, and no DateSeparator is inserted. - return panel._getTilesForEvent(e, e, e === lastShownEvent); + return panel._getTilesForEvent(e, e, e === lastShownEvent, isGrouped); }).reduce((a, b) => a.concat(b), []); // Get sender profile from the latest event in the summary as the m.room.create doesn't contain one const ev = this.events[this.events.length - 1]; @@ -1017,13 +1033,13 @@ class CreationGrouper { ret.push( - { eventTiles } + { eventTiles } , ); @@ -1084,7 +1100,7 @@ class RedactionGrouper { const DateSeparator = sdk.getComponent('messages.DateSeparator'); const EventListSummary = sdk.getComponent('views.elements.EventListSummary'); - + const isGrouped = true; const panel = this.panel; const ret = []; const lastShownEvent = this.lastShownEvent; @@ -1104,7 +1120,8 @@ class RedactionGrouper { let eventTiles = this.events.map((e, i) => { senders.add(e.sender); const prevEvent = i === 0 ? this.prevEvent : this.events[i - 1]; - return panel._getTilesForEvent(prevEvent, e, e === lastShownEvent, this.nextEvent, this.nextEventTile); + return panel._getTilesForEvent( + prevEvent, e, e === lastShownEvent, isGrouped, this.nextEvent, this.nextEventTile); }).reduce((a, b) => a.concat(b), []); if (eventTiles.length === 0) { @@ -1183,7 +1200,7 @@ class MemberGrouper { const DateSeparator = sdk.getComponent('messages.DateSeparator'); const MemberEventListSummary = sdk.getComponent('views.elements.MemberEventListSummary'); - + const isGrouped = true; const panel = this.panel; const lastShownEvent = this.lastShownEvent; const ret = []; @@ -1216,7 +1233,7 @@ class MemberGrouper { // of MemberEventListSummary, render each member event as if the previous // one was itself. This way, the timestamp of the previous event === the // timestamp of the current event, and no DateSeparator is inserted. - return panel._getTilesForEvent(e, e, e === lastShownEvent); + return panel._getTilesForEvent(e, e, e === lastShownEvent, isGrouped); }).reduce((a, b) => a.concat(b), []); if (eventTiles.length === 0) { @@ -1225,11 +1242,11 @@ class MemberGrouper { ret.push( - { eventTiles } + { eventTiles } , ); diff --git a/src/components/structures/MyGroups.js b/src/components/structures/MyGroups.js index 2ab11dad25..1fab6c4348 100644 --- a/src/components/structures/MyGroups.js +++ b/src/components/structures/MyGroups.js @@ -25,6 +25,7 @@ import AccessibleButton from '../views/elements/AccessibleButton'; import MatrixClientContext from "../../contexts/MatrixClientContext"; import AutoHideScrollbar from "./AutoHideScrollbar"; import {replaceableComponent} from "../../utils/replaceableComponent"; +import BetaCard from "../views/beta/BetaCard"; @replaceableComponent("structures.MyGroups") export default class MyGroups extends React.Component { @@ -139,6 +140,7 @@ export default class MyGroups extends React.Component {
    */}
    +
    { contentHeader } { content } diff --git a/src/components/structures/RightPanel.js b/src/components/structures/RightPanel.js index 5bcb3b2450..d8c763eabd 100644 --- a/src/components/structures/RightPanel.js +++ b/src/components/structures/RightPanel.js @@ -35,6 +35,7 @@ import {Action} from "../../dispatcher/actions"; import RoomSummaryCard from "../views/right_panel/RoomSummaryCard"; import WidgetCard from "../views/right_panel/WidgetCard"; import {replaceableComponent} from "../../utils/replaceableComponent"; +import SettingsStore from "../../settings/SettingsStore"; @replaceableComponent("structures.RightPanel") export default class RightPanel extends React.Component { @@ -85,7 +86,9 @@ export default class RightPanel extends React.Component { return RightPanelPhases.GroupMemberList; } return rps.groupPanelPhase; - } else if (this.props.room?.isSpaceRoom() && !RIGHT_PANEL_SPACE_PHASES.includes(rps.roomPanelPhase)) { + } else if (SettingsStore.getValue("feature_spaces") && this.props.room?.isSpaceRoom() + && !RIGHT_PANEL_SPACE_PHASES.includes(rps.roomPanelPhase) + ) { return RightPanelPhases.SpaceMemberList; } else if (userForPanel) { // XXX FIXME AAAAAARGH: What is going on with this class!? It takes some of its state diff --git a/src/components/structures/RoomDirectory.js b/src/components/structures/RoomDirectory.tsx similarity index 71% rename from src/components/structures/RoomDirectory.js rename to src/components/structures/RoomDirectory.tsx index 3613261da6..1e0605f263 100644 --- a/src/components/structures/RoomDirectory.js +++ b/src/components/structures/RoomDirectory.tsx @@ -1,7 +1,6 @@ /* -Copyright 2015, 2016 OpenMarket Ltd Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> -Copyright 2019, 2020 The Matrix.org Foundation C.I.C. +Copyright 2015, 2016, 2019, 2020, 2021 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. @@ -16,39 +15,90 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from 'react'; -import {MatrixClientPeg} from "../../MatrixClientPeg"; -import * as sdk from "../../index"; +import React from "react"; + +import { MatrixClientPeg } from "../../MatrixClientPeg"; import dis from "../../dispatcher/dispatcher"; import Modal from "../../Modal"; import { linkifyAndSanitizeHtml } from '../../HtmlUtils'; -import PropTypes from 'prop-types'; import { _t } from '../../languageHandler'; import SdkConfig from '../../SdkConfig'; import { instanceForInstanceId, protocolNameForInstanceId } from '../../utils/DirectoryUtils'; import Analytics from '../../Analytics'; -import {ALL_ROOMS} from "../views/directory/NetworkDropdown"; +import {ALL_ROOMS, IFieldType, IInstance, IProtocol, Protocols} from "../views/directory/NetworkDropdown"; import SettingsStore from "../../settings/SettingsStore"; import GroupFilterOrderStore from "../../stores/GroupFilterOrderStore"; import GroupStore from "../../stores/GroupStore"; import FlairStore from "../../stores/FlairStore"; import CountlyAnalytics from "../../CountlyAnalytics"; -import {replaceableComponent} from "../../utils/replaceableComponent"; -import {mediaFromMxc} from "../../customisations/Media"; +import { replaceableComponent } from "../../utils/replaceableComponent"; +import { mediaFromMxc } from "../../customisations/Media"; +import { IDialogProps } from "../views/dialogs/IDialogProps"; +import AccessibleButton, {ButtonEvent} from "../views/elements/AccessibleButton"; +import BaseAvatar from "../views/avatars/BaseAvatar"; +import ErrorDialog from "../views/dialogs/ErrorDialog"; +import QuestionDialog from "../views/dialogs/QuestionDialog"; +import BaseDialog from "../views/dialogs/BaseDialog"; +import DirectorySearchBox from "../views/elements/DirectorySearchBox"; +import NetworkDropdown from "../views/directory/NetworkDropdown"; +import ScrollPanel from "./ScrollPanel"; +import Spinner from "../views/elements/Spinner"; +import { ActionPayload } from "../../dispatcher/payloads"; + const MAX_NAME_LENGTH = 80; const MAX_TOPIC_LENGTH = 800; -function track(action) { +function track(action: string) { Analytics.trackEvent('RoomDirectory', action); } +interface IProps extends IDialogProps { + initialText?: string; +} + +interface IState { + publicRooms: IRoom[]; + loading: boolean; + protocolsLoading: boolean; + error?: string; + instanceId: string | symbol; + roomServer: string; + filterString: string; + selectedCommunityId?: string; + communityName?: string; +} + +/* eslint-disable camelcase */ +interface IRoom { + room_id: string; + name?: string; + avatar_url?: string; + topic?: string; + canonical_alias?: string; + aliases?: string[]; + world_readable: boolean; + guest_can_join: boolean; + num_joined_members: number; +} + +interface IPublicRoomsRequest { + limit?: number; + since?: string; + server?: string; + filter?: object; + include_all_networks?: boolean; + third_party_instance_id?: string; +} +/* eslint-enable camelcase */ + @replaceableComponent("structures.RoomDirectory") -export default class RoomDirectory extends React.Component { - static propTypes = { - initialText: PropTypes.string, - onFinished: PropTypes.func.isRequired, - }; +export default class RoomDirectory extends React.Component { + private readonly startTime: number; + private unmounted = false + private nextBatch: string = null; + private filterTimeout: NodeJS.Timeout; + private protocols: Protocols; constructor(props) { super(props); @@ -56,41 +106,21 @@ export default class RoomDirectory extends React.Component { CountlyAnalytics.instance.trackRoomDirectoryBegin(); this.startTime = CountlyAnalytics.getTimestamp(); - const selectedCommunityId = GroupFilterOrderStore.getSelectedTags()[0]; - this.state = { - publicRooms: [], - loading: true, - protocolsLoading: true, - error: null, - instanceId: undefined, - roomServer: MatrixClientPeg.getHomeserverName(), - filterString: this.props.initialText || "", - selectedCommunityId: SettingsStore.getValue("feature_communities_v2_prototypes") - ? selectedCommunityId - : null, - communityName: null, - }; + const selectedCommunityId = SettingsStore.getValue("feature_communities_v2_prototypes") + ? GroupFilterOrderStore.getSelectedTags()[0] + : null; - this._unmounted = false; - this.nextBatch = null; - this.filterTimeout = null; - this.scrollPanel = null; - this.protocols = null; - - this.state.protocolsLoading = true; + let protocolsLoading = true; if (!MatrixClientPeg.get()) { // We may not have a client yet when invoked from welcome page - this.state.protocolsLoading = false; - return; - } - - if (!this.state.selectedCommunityId) { + protocolsLoading = false; + } else if (!selectedCommunityId) { MatrixClientPeg.get().getThirdpartyProtocols().then((response) => { this.protocols = response; - this.setState({protocolsLoading: false}); + this.setState({ protocolsLoading: false }); }, (err) => { console.warn(`error loading third party protocols: ${err}`); - this.setState({protocolsLoading: false}); + this.setState({ protocolsLoading: false }); if (MatrixClientPeg.get().isGuest()) { // Guests currently aren't allowed to use this API, so // ignore this as otherwise this error is literally the @@ -103,19 +133,31 @@ export default class RoomDirectory extends React.Component { error: _t( '%(brand)s failed to get the protocol list from the homeserver. ' + 'The homeserver may be too old to support third party networks.', - {brand}, + { brand }, ), }); }); } else { // We don't use the protocols in the communities v2 prototype experience - this.state.protocolsLoading = false; + protocolsLoading = false; // Grab the profile info async FlairStore.getGroupProfileCached(MatrixClientPeg.get(), this.state.selectedCommunityId).then(profile => { - this.setState({communityName: profile.name}); + this.setState({ communityName: profile.name }); }); } + + this.state = { + publicRooms: [], + loading: true, + error: null, + instanceId: undefined, + roomServer: MatrixClientPeg.getHomeserverName(), + filterString: this.props.initialText || "", + selectedCommunityId, + communityName: null, + protocolsLoading, + }; } componentDidMount() { @@ -126,10 +168,10 @@ export default class RoomDirectory extends React.Component { if (this.filterTimeout) { clearTimeout(this.filterTimeout); } - this._unmounted = true; + this.unmounted = true; } - refreshRoomList = () => { + private refreshRoomList = () => { if (this.state.selectedCommunityId) { this.setState({ publicRooms: GroupStore.getGroupRooms(this.state.selectedCommunityId).map(r => { @@ -165,7 +207,7 @@ export default class RoomDirectory extends React.Component { this.getMoreRooms(); }; - getMoreRooms() { + private getMoreRooms() { if (this.state.selectedCommunityId) return Promise.resolve(); // no more rooms if (!MatrixClientPeg.get()) return Promise.resolve(); @@ -173,34 +215,34 @@ export default class RoomDirectory extends React.Component { loading: true, }); - const my_filter_string = this.state.filterString; - const my_server = this.state.roomServer; + const filterString = this.state.filterString; + const roomServer = this.state.roomServer; // remember the next batch token when we sent the request // too. If it's changed, appending to the list will corrupt it. - const my_next_batch = this.nextBatch; - const opts = {limit: 20}; - if (my_server != MatrixClientPeg.getHomeserverName()) { - opts.server = my_server; + const nextBatch = this.nextBatch; + const opts: IPublicRoomsRequest = { limit: 20 }; + if (roomServer != MatrixClientPeg.getHomeserverName()) { + opts.server = roomServer; } if (this.state.instanceId === ALL_ROOMS) { opts.include_all_networks = true; } else if (this.state.instanceId) { - opts.third_party_instance_id = this.state.instanceId; + opts.third_party_instance_id = this.state.instanceId as string; } if (this.nextBatch) opts.since = this.nextBatch; - if (my_filter_string) opts.filter = { generic_search_term: my_filter_string }; + if (filterString) opts.filter = { generic_search_term: filterString }; return MatrixClientPeg.get().publicRooms(opts).then((data) => { if ( - my_filter_string != this.state.filterString || - my_server != this.state.roomServer || - my_next_batch != this.nextBatch) { + filterString != this.state.filterString || + roomServer != this.state.roomServer || + nextBatch != this.nextBatch) { // if the filter or server has changed since this request was sent, // throw away the result (don't even clear the busy flag // since we must still have a request in flight) return; } - if (this._unmounted) { + if (this.unmounted) { // if we've been unmounted, we don't care either. return; } @@ -211,23 +253,23 @@ export default class RoomDirectory extends React.Component { } this.nextBatch = data.next_batch; - this.setState((s) => { - s.publicRooms.push(...(data.chunk || [])); - s.loading = false; - return s; - }); + this.setState((s) => ({ + ...s, + publicRooms: [...s.publicRooms, ...(data.chunk || [])], + loading: false, + })); return Boolean(data.next_batch); }, (err) => { if ( - my_filter_string != this.state.filterString || - my_server != this.state.roomServer || - my_next_batch != this.nextBatch) { + filterString != this.state.filterString || + roomServer != this.state.roomServer || + nextBatch != this.nextBatch) { // as above: we don't care about errors for old // requests either return; } - if (this._unmounted) { + if (this.unmounted) { // if we've been unmounted, we don't care either. return; } @@ -252,13 +294,10 @@ export default class RoomDirectory extends React.Component { * HS admins to do this through the RoomSettings interface, but * this needs SPEC-417. */ - removeFromDirectory(room) { - const alias = get_display_alias_for_room(room); + private removeFromDirectory(room: IRoom) { + const alias = getDisplayAliasForRoom(room); const name = room.name || alias || _t('Unnamed room'); - const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - let desc; if (alias) { desc = _t('Delete the room address %(alias)s and remove %(name)s from the directory?', {alias, name}); @@ -269,11 +308,10 @@ export default class RoomDirectory extends React.Component { Modal.createTrackedDialog('Remove from Directory', '', QuestionDialog, { title: _t('Remove from Directory'), description: desc, - onFinished: (should_delete) => { - if (!should_delete) return; + onFinished: (shouldDelete: boolean) => { + if (!shouldDelete) return; - const Loader = sdk.getComponent("elements.Spinner"); - const modal = Modal.createDialog(Loader); + const modal = Modal.createDialog(Spinner); let step = _t('remove %(name)s from the directory.', {name: name}); MatrixClientPeg.get().setRoomDirectoryVisibility(room.room_id, 'private').then(() => { @@ -289,14 +327,16 @@ export default class RoomDirectory extends React.Component { console.error("Failed to " + step + ": " + err); Modal.createTrackedDialog('Remove from Directory Error', '', ErrorDialog, { title: _t('Error'), - description: ((err && err.message) ? err.message : _t('The server may be unavailable or overloaded')), + description: (err && err.message) + ? err.message + : _t('The server may be unavailable or overloaded'), }); }); }, }); } - onRoomClicked = (room, ev) => { + private onRoomClicked = (room: IRoom, ev: ButtonEvent) => { if (ev.shiftKey && !this.state.selectedCommunityId) { ev.preventDefault(); this.removeFromDirectory(room); @@ -305,7 +345,7 @@ export default class RoomDirectory extends React.Component { } }; - onOptionChange = (server, instanceId) => { + private onOptionChange = (server: string, instanceId?: string | symbol) => { // clear next batch so we don't try to load more rooms this.nextBatch = null; this.setState({ @@ -325,13 +365,13 @@ export default class RoomDirectory extends React.Component { // Easiest to just blow away the state & re-fetch. }; - onFillRequest = (backwards) => { + private onFillRequest = (backwards: boolean) => { if (backwards || !this.nextBatch) return Promise.resolve(false); return this.getMoreRooms(); }; - onFilterChange = (alias) => { + private onFilterChange = (alias: string) => { this.setState({ filterString: alias || null, }); @@ -349,7 +389,7 @@ export default class RoomDirectory extends React.Component { }, 700); }; - onFilterClear = () => { + private onFilterClear = () => { // update immediately this.setState({ filterString: null, @@ -360,7 +400,7 @@ export default class RoomDirectory extends React.Component { } }; - onJoinFromSearchClick = (alias) => { + private onJoinFromSearchClick = (alias: string) => { // If we don't have a particular instance id selected, just show that rooms alias if (!this.state.instanceId || this.state.instanceId === ALL_ROOMS) { // If the user specified an alias without a domain, add on whichever server is selected @@ -373,9 +413,10 @@ export default class RoomDirectory extends React.Component { // This is a 3rd party protocol. Let's see if we can join it const protocolName = protocolNameForInstanceId(this.protocols, this.state.instanceId); const instance = instanceForInstanceId(this.protocols, this.state.instanceId); - const fields = protocolName ? this._getFieldsForThirdPartyLocation(alias, this.protocols[protocolName], instance) : null; + const fields = protocolName + ? this.getFieldsForThirdPartyLocation(alias, this.protocols[protocolName], instance) + : null; if (!fields) { - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); const brand = SdkConfig.get().brand; Modal.createTrackedDialog('Unable to join network', '', ErrorDialog, { title: _t('Unable to join network'), @@ -387,14 +428,12 @@ export default class RoomDirectory extends React.Component { if (resp.length > 0 && resp[0].alias) { this.showRoomAlias(resp[0].alias, true); } else { - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); Modal.createTrackedDialog('Room not found', '', ErrorDialog, { title: _t('Room not found'), description: _t('Couldn\'t find a matching Matrix room'), }); } }, (e) => { - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); Modal.createTrackedDialog('Fetching third party location failed', '', ErrorDialog, { title: _t('Fetching third party location failed'), description: _t('Unable to look up room ID from server'), @@ -403,36 +442,37 @@ export default class RoomDirectory extends React.Component { } }; - onPreviewClick = (ev, room) => { + private onPreviewClick = (ev: ButtonEvent, room: IRoom) => { this.showRoom(room, null, false, true); ev.stopPropagation(); }; - onViewClick = (ev, room) => { + private onViewClick = (ev: ButtonEvent, room: IRoom) => { this.showRoom(room); ev.stopPropagation(); }; - onJoinClick = (ev, room) => { + private onJoinClick = (ev: ButtonEvent, room: IRoom) => { this.showRoom(room, null, true); ev.stopPropagation(); }; - onCreateRoomClick = room => { + private onCreateRoomClick = () => { this.onFinished(); dis.dispatch({ action: 'view_create_room', public: true, + defaultName: this.state.filterString.trim(), }); }; - showRoomAlias(alias, autoJoin=false) { + private showRoomAlias(alias: string, autoJoin = false) { this.showRoom(null, alias, autoJoin); } - showRoom(room, room_alias, autoJoin = false, shouldPeek = false) { + private showRoom(room: IRoom, roomAlias?: string, autoJoin = false, shouldPeek = false) { this.onFinished(); - const payload = { + const payload: ActionPayload = { action: 'view_room', auto_join: autoJoin, should_peek: shouldPeek, @@ -449,15 +489,15 @@ export default class RoomDirectory extends React.Component { } } - if (!room_alias) { - room_alias = get_display_alias_for_room(room); + if (!roomAlias) { + roomAlias = getDisplayAliasForRoom(room); } payload.oob_data = { avatarUrl: room.avatar_url, // XXX: This logic is duplicated from the JS SDK which // would normally decide what the name is. - name: room.name || room_alias || _t('Unnamed room'), + name: room.name || roomAlias || _t('Unnamed room'), }; if (this.state.roomServer) { @@ -471,21 +511,19 @@ export default class RoomDirectory extends React.Component { // which servers to start querying. However, there's no other way to join rooms in // this list without aliases at present, so if roomAlias isn't set here we have no // choice but to supply the ID. - if (room_alias) { - payload.room_alias = room_alias; + if (roomAlias) { + payload.room_alias = roomAlias; } else { payload.room_id = room.room_id; } dis.dispatch(payload); } - createRoomCells(room) { + private createRoomCells(room: IRoom) { const client = MatrixClientPeg.get(); const clientRoom = client.getRoom(room.room_id); const hasJoinedRoom = clientRoom && clientRoom.getMyMembership() === "join"; const isGuest = client.isGuest(); - const BaseAvatar = sdk.getComponent('avatars.BaseAvatar'); - const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); let previewButton; let joinOrViewButton; @@ -495,20 +533,26 @@ export default class RoomDirectory extends React.Component { // it is readable, the preview appears as normal. if (!hasJoinedRoom && (room.world_readable || isGuest)) { previewButton = ( - this.onPreviewClick(ev, room)}>{_t("Preview")} + this.onPreviewClick(ev, room)}> + { _t("Preview") } + ); } if (hasJoinedRoom) { joinOrViewButton = ( - this.onViewClick(ev, room)}>{_t("View")} + this.onViewClick(ev, room)}> + { _t("View") } + ); } else if (!isGuest) { joinOrViewButton = ( - this.onJoinClick(ev, room)}>{_t("Join")} + this.onJoinClick(ev, room)}> + { _t("Join") } + ); } - let name = room.name || get_display_alias_for_room(room) || _t('Unnamed room'); + let name = room.name || getDisplayAliasForRoom(room) || _t('Unnamed room'); if (name.length > MAX_NAME_LENGTH) { name = `${name.substring(0, MAX_NAME_LENGTH)}...`; } @@ -531,9 +575,13 @@ export default class RoomDirectory extends React.Component { onMouseDown={(ev) => {ev.preventDefault();}} className="mx_RoomDirectory_roomAvatar" > -
    ,
    { ev.stopPropagation(); } } dangerouslySetInnerHTML={{ __html: topic }} /> -
    { get_display_alias_for_room(room) }
    +
    { getDisplayAliasForRoom(room) }
    ,
    this.onRoomClicked(room, ev)} @@ -576,20 +624,16 @@ export default class RoomDirectory extends React.Component { ]; } - collectScrollPanel = (element) => { - this.scrollPanel = element; - }; - - _stringLooksLikeId(s, field_type) { + private stringLooksLikeId(s: string, fieldType: IFieldType) { let pat = /^#[^\s]+:[^\s]/; - if (field_type && field_type.regexp) { - pat = new RegExp(field_type.regexp); + if (fieldType && fieldType.regexp) { + pat = new RegExp(fieldType.regexp); } return pat.test(s); } - _getFieldsForThirdPartyLocation(userInput, protocol, instance) { + private getFieldsForThirdPartyLocation(userInput: string, protocol: IProtocol, instance: IInstance) { // make an object with the fields specified by that protocol. We // require that the values of all but the last field come from the // instance. The last is the user input. @@ -605,71 +649,73 @@ export default class RoomDirectory extends React.Component { return fields; } - /** - * called by the parent component when PageUp/Down/etc is pressed. - * - * We pass it down to the scroll panel. - */ - handleScrollKey = ev => { - if (this.scrollPanel) { - this.scrollPanel.handleScrollKey(ev); - } - }; - - onFinished = () => { + private onFinished = () => { CountlyAnalytics.instance.trackRoomDirectory(this.startTime); - this.props.onFinished(); + this.props.onFinished(false); }; render() { - const Loader = sdk.getComponent("elements.Spinner"); - const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); - const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); - let content; if (this.state.error) { content = this.state.error; } else if (this.state.protocolsLoading) { - content = ; + content = ; } else { const cells = (this.state.publicRooms || []) - .reduce((cells, room) => cells.concat(this.createRoomCells(room)), [],); + .reduce((cells, room) => cells.concat(this.createRoomCells(room)), []); // we still show the scrollpanel, at least for now, because // otherwise we don't fetch more because we don't get a fill // request from the scrollpanel because there isn't one let spinner; if (this.state.loading) { - spinner = ; + spinner = ; } - let scrollpanel_content; + const createNewButton = <> +
    + + { _t("Create new room") } + + ; + + let scrollPanelContent; + let footer; if (cells.length === 0 && !this.state.loading) { - scrollpanel_content = { _t('No rooms to show') }; + footer = <> +
    { _t('No results for "%(query)s"', { query: this.state.filterString.trim() }) }
    +

    + { _t("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.") } +

    + { createNewButton } + ; } else { - scrollpanel_content =
    + scrollPanelContent =
    { cells }
    ; + if (!this.state.loading && !this.nextBatch) { + footer = createNewButton; + } } - const ScrollPanel = sdk.getComponent("structures.ScrollPanel"); - content = - { scrollpanel_content } + { scrollPanelContent } { spinner } + { footer &&
    + { footer } +
    }
    ; } let listHeader; if (!this.state.protocolsLoading) { - const NetworkDropdown = sdk.getComponent('directory.NetworkDropdown'); - const DirectorySearchBox = sdk.getComponent('elements.DirectorySearchBox'); - const protocolName = protocolNameForInstanceId(this.protocols, this.state.instanceId); - let instance_expected_field_type; + let instanceExpectedFieldType; if ( protocolName && this.protocols && @@ -677,21 +723,27 @@ export default class RoomDirectory extends React.Component { this.protocols[protocolName].location_fields.length > 0 && this.protocols[protocolName].field_types ) { - const last_field = this.protocols[protocolName].location_fields.slice(-1)[0]; - instance_expected_field_type = this.protocols[protocolName].field_types[last_field]; + const lastField = this.protocols[protocolName].location_fields.slice(-1)[0]; + instanceExpectedFieldType = this.protocols[protocolName].field_types[lastField]; } let placeholder = _t('Find a room…'); if (!this.state.instanceId || this.state.instanceId === ALL_ROOMS) { - placeholder = _t("Find a room… (e.g. %(exampleRoom)s)", {exampleRoom: "#example:" + this.state.roomServer}); - } else if (instance_expected_field_type) { - placeholder = instance_expected_field_type.placeholder; + placeholder = _t("Find a room… (e.g. %(exampleRoom)s)", { + exampleRoom: "#example:" + this.state.roomServer, + }); + } else if (instanceExpectedFieldType) { + placeholder = instanceExpectedFieldType.placeholder; } - let showJoinButton = this._stringLooksLikeId(this.state.filterString, instance_expected_field_type); + let showJoinButton = this.stringLooksLikeId(this.state.filterString, instanceExpectedFieldType); if (protocolName) { const instance = instanceForInstanceId(this.protocols, this.state.instanceId); - if (this._getFieldsForThirdPartyLocation(this.state.filterString, this.protocols[protocolName], instance) === null) { + if (this.getFieldsForThirdPartyLocation( + this.state.filterString, + this.protocols[protocolName], + instance, + ) === null) { showJoinButton = false; } } @@ -723,12 +775,11 @@ export default class RoomDirectory extends React.Component { } const explanation = _t("If you can't find the room you're looking for, ask for an invite or Create a new room.", null, - {a: sub => { - return ({sub}); - }}, + {a: sub => ( + + { sub } + + )}, ); const title = this.state.selectedCommunityId @@ -756,6 +807,6 @@ export default class RoomDirectory extends React.Component { // Similar to matrix-react-sdk's MatrixTools.getDisplayAliasForRoom // but works with the objects we get from the public room list -function get_display_alias_for_room(room) { - return room.canonical_alias || (room.aliases ? room.aliases[0] : ""); +function getDisplayAliasForRoom(room: IRoom) { + return room.canonical_alias || room.aliases?.[0] || ""; } diff --git a/src/components/structures/RoomSearch.tsx b/src/components/structures/RoomSearch.tsx index a64feed42c..bda46aef07 100644 --- a/src/components/structures/RoomSearch.tsx +++ b/src/components/structures/RoomSearch.tsx @@ -17,6 +17,8 @@ limitations under the License. import * as React from "react"; import { createRef } from "react"; import classNames from "classnames"; +import { Room } from "matrix-js-sdk/src/models/room"; + import defaultDispatcher from "../../dispatcher/dispatcher"; import { _t } from "../../languageHandler"; import { ActionPayload } from "../../dispatcher/payloads"; @@ -25,8 +27,8 @@ import { Action } from "../../dispatcher/actions"; import RoomListStore from "../../stores/room-list/RoomListStore"; import { NameFilterCondition } from "../../stores/room-list/filters/NameFilterCondition"; import { getKeyBindingsManager, RoomListAction } from "../../KeyBindingsManager"; -import {replaceableComponent} from "../../utils/replaceableComponent"; -import SpaceStore, {UPDATE_SELECTED_SPACE} from "../../stores/SpaceStore"; +import { replaceableComponent } from "../../utils/replaceableComponent"; +import SpaceStore, { UPDATE_SELECTED_SPACE, UPDATE_TOP_LEVEL_SPACES } from "../../stores/SpaceStore"; interface IProps { isMinimized: boolean; @@ -40,6 +42,7 @@ interface IProps { interface IState { query: string; focused: boolean; + inSpaces: boolean; } @replaceableComponent("structures.RoomSearch") @@ -54,11 +57,13 @@ export default class RoomSearch extends React.PureComponent { this.state = { query: "", focused: false, + inSpaces: false, }; this.dispatcherRef = defaultDispatcher.register(this.onAction); // clear filter when changing spaces, in future we may wish to maintain a filter per-space SpaceStore.instance.on(UPDATE_SELECTED_SPACE, this.clearInput); + SpaceStore.instance.on(UPDATE_TOP_LEVEL_SPACES, this.onSpaces); } public componentDidUpdate(prevProps: Readonly, prevState: Readonly): void { @@ -79,8 +84,15 @@ export default class RoomSearch extends React.PureComponent { public componentWillUnmount() { defaultDispatcher.unregister(this.dispatcherRef); SpaceStore.instance.off(UPDATE_SELECTED_SPACE, this.clearInput); + SpaceStore.instance.off(UPDATE_TOP_LEVEL_SPACES, this.onSpaces); } + private onSpaces = (spaces: Room[]) => { + this.setState({ + inSpaces: spaces.length > 0, + }); + }; + private onAction = (payload: ActionPayload) => { if (payload.action === 'view_room' && payload.clear_search) { this.clearInput(); @@ -152,6 +164,11 @@ export default class RoomSearch extends React.PureComponent { 'mx_RoomSearch_inputExpanded': this.state.query || this.state.focused, }); + let placeholder = _t("Filter"); + if (this.state.inSpaces) { + placeholder = _t("Filter all spaces"); + } + let icon = (
    ); @@ -165,7 +182,7 @@ export default class RoomSearch extends React.PureComponent { onBlur={this.onBlur} onChange={this.onChange} onKeyDown={this.onKeyDown} - placeholder={_t("Filter")} + placeholder={placeholder} autoComplete="off" /> ); diff --git a/src/components/structures/RoomStatusBar.js b/src/components/structures/RoomStatusBar.js index 54b6fee233..b2f0c70bd7 100644 --- a/src/components/structures/RoomStatusBar.js +++ b/src/components/structures/RoomStatusBar.js @@ -1,5 +1,5 @@ /* -Copyright 2015-2020 The Matrix.org Foundation C.I.C. +Copyright 2015-2021 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,16 +20,20 @@ import { _t, _td } from '../../languageHandler'; import {MatrixClientPeg} from '../../MatrixClientPeg'; import Resend from '../../Resend'; import dis from '../../dispatcher/dispatcher'; -import {messageForResourceLimitError, messageForSendError} from '../../utils/ErrorUtils'; +import {messageForResourceLimitError} from '../../utils/ErrorUtils'; import {Action} from "../../dispatcher/actions"; import {replaceableComponent} from "../../utils/replaceableComponent"; import {EventStatus} from "matrix-js-sdk/src/models/event"; +import NotificationBadge from "../views/rooms/NotificationBadge"; +import {StaticNotificationState} from "../../stores/notifications/StaticNotificationState"; +import AccessibleButton from "../views/elements/AccessibleButton"; +import InlineSpinner from "../views/elements/InlineSpinner"; const STATUS_BAR_HIDDEN = 0; const STATUS_BAR_EXPANDED = 1; const STATUS_BAR_EXPANDED_LARGE = 2; -function getUnsentMessages(room) { +export function getUnsentMessages(room) { if (!room) { return []; } return room.getPendingEvents().filter(function(ev) { return ev.status === EventStatus.NOT_SENT; @@ -76,6 +80,7 @@ export default class RoomStatusBar extends React.Component { syncState: MatrixClientPeg.get().getSyncState(), syncStateData: MatrixClientPeg.get().getSyncStateData(), unsentMessages: getUnsentMessages(this.props.room), + isResending: false, }; componentDidMount() { @@ -109,7 +114,10 @@ export default class RoomStatusBar extends React.Component { }; _onResendAllClick = () => { - Resend.resendUnsentEvents(this.props.room); + Resend.resendUnsentEvents(this.props.room).then(() => { + this.setState({isResending: false}); + }); + this.setState({isResending: true}); dis.fire(Action.FocusComposer); }; @@ -120,9 +128,10 @@ export default class RoomStatusBar extends React.Component { _onRoomLocalEchoUpdated = (event, room, oldEventId, oldStatus) => { if (room.roomId !== this.props.room.roomId) return; - + const messages = getUnsentMessages(this.props.room); this.setState({ - unsentMessages: getUnsentMessages(this.props.room), + unsentMessages: messages, + isResending: messages.length > 0 && this.state.isResending, }); }; @@ -141,7 +150,7 @@ export default class RoomStatusBar extends React.Component { _getSize() { if (this._shouldShowConnectionError()) { return STATUS_BAR_EXPANDED; - } else if (this.state.unsentMessages.length > 0) { + } else if (this.state.unsentMessages.length > 0 || this.state.isResending) { return STATUS_BAR_EXPANDED_LARGE; } return STATUS_BAR_HIDDEN; @@ -162,7 +171,6 @@ export default class RoomStatusBar extends React.Component { _getUnsentMessageContent() { const unsentMessages = this.state.unsentMessages; - if (!unsentMessages.length) return null; let title; @@ -192,89 +200,92 @@ export default class RoomStatusBar extends React.Component { } else if (resourceLimitError) { title = messageForResourceLimitError( resourceLimitError.data.limit_type, - resourceLimitError.data.admin_contact, { - 'monthly_active_user': _td( - "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.", - ), - 'hs_disabled': _td( - "Your message wasn't sent because this homeserver has been blocked by it's administrator. " + - "Please contact your service administrator to continue using the service.", - ), - '': _td( - "Your message wasn't sent because this homeserver has exceeded a resource limit. " + - "Please contact your service administrator to continue using the service.", - ), - }); - } else if ( - unsentMessages.length === 1 && - unsentMessages[0].error && - unsentMessages[0].error.data && - unsentMessages[0].error.data.error - ) { - title = messageForSendError(unsentMessages[0].error.data) || unsentMessages[0].error.data.error; + resourceLimitError.data.admin_contact, + { + 'monthly_active_user': _td( + "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.", + ), + 'hs_disabled': _td( + "Your message wasn't sent because this homeserver has been blocked by it's administrator. " + + "Please contact your service administrator to continue using the service.", + ), + '': _td( + "Your message wasn't sent because this homeserver has exceeded a resource limit. " + + "Please contact your service administrator to continue using the service.", + ), + }, + ); } else { - title = _t('%(count)s of your messages have not been sent.', { count: unsentMessages.length }); + title = _t('Some of your messages have not been sent'); } - const content = _t("%(count)s Resend all or cancel all " + - "now. You can also select individual messages to resend or cancel.", - { count: unsentMessages.length }, - { - 'resendText': (sub) => - { sub }, - 'cancelText': (sub) => - { sub }, - }, - ); + let buttonRow = <> + + {_t("Delete all")} + + + {_t("Retry all")} + + ; + if (this.state.isResending) { + buttonRow = <> + + {/* span for css */} + {_t("Sending")} + ; + } - return
    - -
    -
    - { title } -
    -
    - { content } + return <> +
    +
    +
    + +
    +
    +
    + { title } +
    +
    + { _t("You can select all or individual messages to retry or delete") } +
    +
    +
    + {buttonRow} +
    -
    ; + ; } - // return suitable content for the main (text) part of the status bar. - _getContent() { + render() { if (this._shouldShowConnectionError()) { return ( -
    - /!\ -
    -
    - { _t('Connectivity to the server has been lost.') } -
    -
    - { _t('Sent messages will be stored until your connection has returned.') } +
    +
    +
    + /!\ +
    +
    + {_t('Connectivity to the server has been lost.')} +
    +
    + {_t('Sent messages will be stored until your connection has returned.')} +
    +
    ); } - if (this.state.unsentMessages.length > 0) { + if (this.state.unsentMessages.length > 0 || this.state.isResending) { return this._getUnsentMessageContent(); } return null; } - - render() { - const content = this._getContent(); - - return ( -
    -
    - { content } -
    -
    - ); - } } diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index 52608d1db1..7173c4ab46 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -190,6 +190,9 @@ export interface IState { rejectError?: Error; hasPinnedWidgets?: boolean; dragCounter: number; + // whether or not a spaces context switch brought us here, + // if it did we don't want the room to be marked as read as soon as it is loaded. + wasContextSwitch?: boolean; } @replaceableComponent("structures.RoomView") @@ -326,6 +329,7 @@ export default class RoomView extends React.Component { shouldPeek: this.state.matrixClientIsReady && RoomViewStore.shouldPeek(), showingPinned: SettingsStore.getValue("PinnedEvents.isOpen", roomId), showReadReceipts: SettingsStore.getValue("showReadReceipts", roomId), + wasContextSwitch: RoomViewStore.getWasContextSwitch(), }; if (!initial && this.state.shouldPeek && !newState.shouldPeek) { @@ -818,7 +822,7 @@ export default class RoomView extends React.Component { }; private onEvent = (ev) => { - if (ev.isBeingDecrypted() || ev.isDecryptionFailure()) return; + if (ev.isBeingDecrypted() || ev.isDecryptionFailure() || ev.shouldAttemptDecryption()) return; this.handleEffects(ev); }; @@ -1148,10 +1152,16 @@ export default class RoomView extends React.Component { ev.stopPropagation(); ev.preventDefault(); - this.setState({ - dragCounter: this.state.dragCounter + 1, - draggingFile: true, - }); + // We always increment the counter no matter the types, because dragging is + // still happening. If we didn't, the drag counter would get out of sync. + this.setState({dragCounter: this.state.dragCounter + 1}); + + // See: + // https://docs.w3cub.com/dom/datatransfer/types + // https://developer.mozilla.org/en-US/docs/Web/API/HTML_Drag_and_Drop_API/Recommended_drag_types#file + if (ev.dataTransfer.types.includes("Files") || ev.dataTransfer.types.includes("application/x-moz-file")) { + this.setState({draggingFile: true}); + } }; private onDragLeave = ev => { @@ -1175,6 +1185,9 @@ export default class RoomView extends React.Component { ev.dataTransfer.dropEffect = 'none'; + // See: + // https://docs.w3cub.com/dom/datatransfer/types + // https://developer.mozilla.org/en-US/docs/Web/API/HTML_Drag_and_Drop_API/Recommended_drag_types#file if (ev.dataTransfer.types.includes("Files") || ev.dataTransfer.types.includes("application/x-moz-file")) { ev.dataTransfer.dropEffect = 'copy'; } @@ -1598,33 +1611,6 @@ export default class RoomView extends React.Component { this.setState({auxPanelMaxHeight: auxPanelMaxHeight}); }; - private onFullscreenClick = () => { - dis.dispatch({ - action: 'video_fullscreen', - fullscreen: true, - }, true); - }; - - private onMuteAudioClick = () => { - const call = this.getCallForRoom(); - if (!call) { - return; - } - const newState = !call.isMicrophoneMuted(); - call.setMicrophoneMuted(newState); - this.forceUpdate(); // TODO: just update the voip buttons - }; - - private onMuteVideoClick = () => { - const call = this.getCallForRoom(); - if (!call) { - return; - } - const newState = !call.isLocalVideoMuted(); - call.setLocalVideoMuted(newState); - this.forceUpdate(); // TODO: just update the voip buttons - }; - private onStatusBarVisible = () => { if (this.unmounted) return; this.setState({ @@ -1640,24 +1626,6 @@ export default class RoomView extends React.Component { }); }; - /** - * called by the parent component when PageUp/Down/etc is pressed. - * - * We pass it down to the scroll panel. - */ - private handleScrollKey = ev => { - let panel; - if (this.searchResultsPanel.current) { - panel = this.searchResultsPanel.current; - } else if (this.messagePanel) { - panel = this.messagePanel; - } - - if (panel) { - panel.handleScrollKey(ev); - } - }; - /** * get any current call for this room */ @@ -1750,7 +1718,10 @@ export default class RoomView extends React.Component { } const myMembership = this.state.room.getMyMembership(); - if (myMembership === "invite" && !this.state.room.isSpaceRoom()) { // SpaceRoomView handles invites itself + if (myMembership === "invite" + // SpaceRoomView handles invites itself + && (!SettingsStore.getValue("feature_spaces") || !this.state.room.isSpaceRoom()) + ) { if (this.state.joining || this.state.rejecting) { return ( @@ -1892,7 +1863,7 @@ export default class RoomView extends React.Component { room={this.state.room} /> ); - if (!this.state.canPeek && !this.state.room?.isSpaceRoom()) { + if (!this.state.canPeek && (!SettingsStore.getValue("feature_spaces") || !this.state.room?.isSpaceRoom())) { return (
    { previewBar } @@ -1914,7 +1885,7 @@ export default class RoomView extends React.Component { ); } - if (SettingsStore.getValue("feature_spaces") && this.state.room?.isSpaceRoom()) { + if (this.state.room?.isSpaceRoom()) { return { timelineSet={this.state.room.getUnfilteredTimelineSet()} showReadReceipts={this.state.showReadReceipts} manageReadReceipts={!this.state.isPeeking} + sendReadReceiptOnLoad={!this.state.wasContextSwitch} manageReadMarkers={!this.state.isPeeking} hidden={hideMessagePanel} highlightedEventId={highlightedEventId} diff --git a/src/components/structures/ScrollPanel.js b/src/components/structures/ScrollPanel.js index 3c305524b8..c9a912ad7f 100644 --- a/src/components/structures/ScrollPanel.js +++ b/src/components/structures/ScrollPanel.js @@ -529,7 +529,7 @@ export default class ScrollPanel extends React.Component { */ scrollRelative = mult => { const scrollNode = this._getScrollNode(); - const delta = mult * scrollNode.clientHeight * 0.5; + const delta = mult * scrollNode.clientHeight * 0.9; scrollNode.scrollBy(0, delta); this._saveScrollState(); }; @@ -896,7 +896,9 @@ export default class ScrollPanel extends React.Component { // give the
      an explicit role=list because Safari+VoiceOver seems to think an ordered-list with // list-style-type: none; is no longer a list - return ( diff --git a/src/components/structures/SpaceRoomDirectory.tsx b/src/components/structures/SpaceRoomDirectory.tsx index 930cfa15a9..dde8dd8331 100644 --- a/src/components/structures/SpaceRoomDirectory.tsx +++ b/src/components/structures/SpaceRoomDirectory.tsx @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, {useMemo, useState} from "react"; +import React, {ReactNode, useMemo, useState} from "react"; import {Room} from "matrix-js-sdk/src/models/room"; import {MatrixClient} from "matrix-js-sdk/src/client"; import {EventType, RoomType} from "matrix-js-sdk/src/@types/event"; @@ -24,7 +24,7 @@ import {sortBy} from "lodash"; import {MatrixClientPeg} from "../../MatrixClientPeg"; import dis from "../../dispatcher/dispatcher"; import {_t} from "../../languageHandler"; -import AccessibleButton from "../views/elements/AccessibleButton"; +import AccessibleButton, {ButtonEvent} from "../views/elements/AccessibleButton"; import BaseDialog from "../views/dialogs/BaseDialog"; import Spinner from "../views/elements/Spinner"; import SearchBox from "./SearchBox"; @@ -39,11 +39,15 @@ import {mediaFromMxc} from "../../customisations/Media"; import InfoTooltip from "../views/elements/InfoTooltip"; import TextWithTooltip from "../views/elements/TextWithTooltip"; import {useStateToggle} from "../../hooks/useStateToggle"; +import {getOrder} from "../../stores/SpaceStore"; +import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton"; +import {linkifyElement} from "../../HtmlUtils"; interface IHierarchyProps { space: Room; initialText?: string; refreshToken?: any; + additionalButtons?: ReactNode; showRoom(room: ISpaceSummaryRoom, viaServers?: string[], autoJoin?: boolean): void; } @@ -72,7 +76,7 @@ export interface ISpaceSummaryEvent { order?: string; suggested?: boolean; auto_join?: boolean; - via?: string; + via?: string[]; }; } /* eslint-enable camelcase */ @@ -106,8 +110,16 @@ const Tile: React.FC = ({ const cliRoom = cli.getRoom(room.room_id); const myMembership = cliRoom?.getMyMembership(); - const onPreviewClick = () => onViewRoomClick(false); - const onJoinClick = () => onViewRoomClick(true); + const onPreviewClick = (ev: ButtonEvent) => { + ev.preventDefault(); + ev.stopPropagation(); + onViewRoomClick(false); + } + const onJoinClick = (ev: ButtonEvent) => { + ev.preventDefault(); + ev.stopPropagation(); + onViewRoomClick(true); + } let button; if (myMembership === "join") { @@ -136,11 +148,11 @@ const Tile: React.FC = ({ let url: string; if (room.avatar_url) { - url = mediaFromMxc(room.avatar_url).getSquareThumbnailHttp(Math.floor(20 * window.devicePixelRatio)); + url = mediaFromMxc(room.avatar_url).getSquareThumbnailHttp(20); } let description = _t("%(count)s members", { count: room.num_joined_members }); - if (numChildRooms) { + if (numChildRooms !== undefined) { description += " · " + _t("%(count)s rooms", { count: numChildRooms }); } if (room.topic) { @@ -161,7 +173,16 @@ const Tile: React.FC = ({ { suggestedSection }
    -
    +
    e && linkifyElement(e)} + onClick={ev => { + // prevent clicks on links from bubbling up to the room tile + if ((ev.target as HTMLElement).tagName === "A") { + ev.stopPropagation(); + } + }} + > { description }
    @@ -254,7 +275,11 @@ export const HierarchyLevel = ({ const space = cli.getRoom(spaceId); const hasPermissions = space?.currentState.maySendStateEvent(EventType.SpaceChild, cli.getUserId()); - const sortedChildren = sortBy([...(relations.get(spaceId)?.values() || [])], ev => ev.content.order || null); + const children = Array.from(relations.get(spaceId)?.values() || []); + const sortedChildren = sortBy(children, ev => { + // XXX: Space Summary API doesn't give the child origin_server_ts but once it does we should use it for sorting + return getOrder(ev.content.order, null, ev.state_key); + }); const [subspaces, childRooms] = sortedChildren.reduce((result, ev: ISpaceSummaryEvent) => { const roomId = ev.state_key; if (!rooms.has(roomId)) return result; @@ -312,11 +337,12 @@ export const HierarchyLevel = ({ // mutate argument refreshToken to force a reload export const useSpaceSummary = (cli: MatrixClient, space: Room, refreshToken?: any): [ + null, ISpaceSummaryRoom[], - Map>, - Map>, - Map>, -] | [] => { + Map>?, + Map>?, + Map>?, +] | [Error] => { // TODO pagination return useAsyncMemo(async () => { try { @@ -330,19 +356,18 @@ export const useSpaceSummary = (cli: MatrixClient, space: Room, refreshToken?: a parentChildRelations.getOrCreate(ev.room_id, new Map()).set(ev.state_key, ev); childParentRelations.getOrCreate(ev.state_key, new Set()).add(ev.room_id); } - if (Array.isArray(ev.content["via"])) { + if (Array.isArray(ev.content.via)) { const set = viaMap.getOrCreate(ev.state_key, new Set()); - ev.content["via"].forEach(via => set.add(via)); + ev.content.via.forEach(via => set.add(via)); } }); - return [data.rooms as ISpaceSummaryRoom[], parentChildRelations, viaMap, childParentRelations]; + return [null, data.rooms as ISpaceSummaryRoom[], parentChildRelations, viaMap, childParentRelations]; } catch (e) { console.error(e); // TODO + return [e]; } - - return []; - }, [space, refreshToken], []); + }, [space, refreshToken], [undefined]); }; export const SpaceHierarchy: React.FC = ({ @@ -350,6 +375,7 @@ export const SpaceHierarchy: React.FC = ({ initialText = "", showRoom, refreshToken, + additionalButtons, children, }) => { const cli = MatrixClientPeg.get(); @@ -358,7 +384,7 @@ export const SpaceHierarchy: React.FC = ({ const [selected, setSelected] = useState(new Map>()); // Map> - const [rooms, parentChildMap, viaMap, childParentMap] = useSpaceSummary(cli, space, refreshToken); + const [summaryError, rooms, parentChildMap, viaMap, childParentMap] = useSpaceSummary(cli, space, refreshToken); const roomsMap = useMemo(() => { if (!rooms) return null; @@ -397,6 +423,10 @@ export const SpaceHierarchy: React.FC = ({ const [removing, setRemoving] = useState(false); const [saving, setSaving] = useState(false); + if (summaryError) { + return

    {_t("Your server does not support showing space hierarchies.")}

    ; + } + let content; if (roomsMap) { const numRooms = Array.from(roomsMap.values()).filter(r => r.room_type !== RoomType.Space).length; @@ -411,78 +441,87 @@ export const SpaceHierarchy: React.FC = ({ countsStr = _t("%(count)s rooms", { count: numRooms, numSpaces }); } - let editSection; + let manageButtons; if (space.getMyMembership() === "join" && space.currentState.maySendStateEvent(EventType.SpaceChild, userId)) { const selectedRelations = Array.from(selected.keys()).flatMap(parentId => { return [...selected.get(parentId).values()].map(childId => [parentId, childId]) as [string, string][]; }); - let buttons; - if (selectedRelations.length) { - const selectionAllSuggested = selectedRelations.every(([parentId, childId]) => { - return parentChildMap.get(parentId)?.get(childId)?.content.suggested; - }); + const selectionAllSuggested = selectedRelations.every(([parentId, childId]) => { + return parentChildMap.get(parentId)?.get(childId)?.content.suggested; + }); - const disabled = removing || saving; + const disabled = !selectedRelations.length || removing || saving; - buttons = <> - { - setRemoving(true); - try { - for (const [parentId, childId] of selectedRelations) { - await cli.sendStateEvent(parentId, EventType.SpaceChild, {}, childId); - parentChildMap.get(parentId).get(childId).content = {}; - parentChildMap.set(parentId, new Map(parentChildMap.get(parentId))); - } - } catch (e) { - setError(_t("Failed to remove some rooms. Try again later")); - } - setRemoving(false); - }} - kind="danger_outline" - disabled={disabled} - > - { removing ? _t("Removing...") : _t("Remove") } - - { - setSaving(true); - try { - for (const [parentId, childId] of selectedRelations) { - const suggested = !selectionAllSuggested; - const existingContent = parentChildMap.get(parentId)?.get(childId)?.content; - if (!existingContent || existingContent.suggested === suggested) continue; - - const content = { - ...existingContent, - suggested: !selectionAllSuggested, - }; - - await cli.sendStateEvent(parentId, EventType.SpaceChild, content, childId); - - parentChildMap.get(parentId).get(childId).content = content; - parentChildMap.set(parentId, new Map(parentChildMap.get(parentId))); - } - } catch (e) { - setError("Failed to update some suggestions. Try again later"); - } - setSaving(false); - }} - kind="primary_outline" - disabled={disabled} - > - { saving - ? _t("Saving...") - : (selectionAllSuggested ? _t("Mark as not suggested") : _t("Mark as suggested")) - } - - ; + let Button: React.ComponentType> = AccessibleButton; + let props = {}; + if (!selectedRelations.length) { + Button = AccessibleTooltipButton; + props = { + tooltip: _t("Select a room below first"), + yOffset: -40, + }; } - editSection = - { buttons } - ; + manageButtons = <> + + + ; } let results; @@ -528,7 +567,10 @@ export const SpaceHierarchy: React.FC = ({ content = <>
    { countsStr } - { editSection } + + { additionalButtons } + { manageButtons } +
    { error &&
    { error } @@ -538,17 +580,15 @@ export const SpaceHierarchy: React.FC = ({ { children } ; - } else if (!rooms) { - content = ; } else { - content =

    {_t("Your server does not support showing space hierarchies.")}

    ; + content = ; } // TODO loading state/error state return <> void }) => { + if (!SdkConfig.get().bug_report_endpoint_url) return null; + + return
    +
    +
    + { _t("Spaces are a beta feature.") } + { + if (onClick) onClick(); + Modal.createTrackedDialog("Beta Feedback", "feature_spaces", BetaFeedbackDialog, { + featureId: "feature_spaces", + }); + }}> + { _t("Feedback") } + +
    +
    ; +}; + const RoomMemberCount = ({ room, children }) => { const members = useRoomMembers(room); const count = members.length; @@ -127,15 +162,39 @@ const SpaceInfo = ({ space }) => {
    }; +const onBetaClick = () => { + defaultDispatcher.dispatch({ + action: Action.ViewUserSettings, + initialTabId: USER_LABS_TAB, + }); +}; + const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) => { const cli = useContext(MatrixClientContext); const myMembership = useMyRoomMembership(space); const [busy, setBusy] = useState(false); + const spacesEnabled = SettingsStore.getValue("feature_spaces"); + let inviterSection; let joinButtons; - if (myMembership === "invite") { + if (myMembership === "join") { + // XXX remove this when spaces leaves Beta + joinButtons = ( + { + dis.dispatch({ + action: "leave_room", + room_id: space.roomId, + }); + }} + > + { _t("Leave") } + + ); + } else if (myMembership === "invite") { const inviteSender = space.getMember(cli.getUserId())?.events.member?.getSender(); const inviter = inviteSender && space.getMember(inviteSender); @@ -171,6 +230,7 @@ const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) => setBusy(true); onJoinButtonClicked(); }} + disabled={!spacesEnabled} > { _t("Accept") } @@ -183,10 +243,11 @@ const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) => setBusy(true); onJoinButtonClicked(); }} + disabled={!spacesEnabled} > { _t("Join") } - ) + ); } if (busy) { @@ -194,6 +255,7 @@ const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) => } return
    + { inviterSection }

    @@ -211,9 +273,84 @@ const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) =>
    { joinButtons }
    + { !spacesEnabled &&
    + { myMembership === "join" + ? _t("To view %(spaceName)s, turn on the Spaces beta", { + spaceName: space.name, + }, { + a: sub => { sub }, + }) + : _t("To join %(spaceName)s, turn on the Spaces beta", { + spaceName: space.name, + }, { + a: sub => { sub }, + }) + } +
    }

    ; }; +const SpaceLandingAddButton = ({ space, onNewRoomAdded }) => { + const cli = useContext(MatrixClientContext); + const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu(); + + let contextMenu; + if (menuDisplayed) { + const rect = handle.current.getBoundingClientRect(); + contextMenu = + + { + e.preventDefault(); + e.stopPropagation(); + closeMenu(); + + if (await showCreateNewRoom(cli, space)) { + onNewRoomAdded(); + } + }} + /> + { + e.preventDefault(); + e.stopPropagation(); + closeMenu(); + + const [added] = await showAddExistingRooms(cli, space); + if (added) { + onNewRoomAdded(); + } + }} + /> + + ; + } + + return <> + + { _t("Add") } + + { contextMenu } + ; +}; + const SpaceLanding = ({ space }) => { const cli = useContext(MatrixClientContext); const myMembership = useMyRoomMembership(space); @@ -238,32 +375,20 @@ const SpaceLanding = ({ space }) => { const [refreshToken, forceUpdate] = useStateToggle(false); - let addRoomButtons; + let addRoomButton; if (canAddRooms) { - addRoomButtons = - { - const [added] = await showAddExistingRooms(cli, space); - if (added) { - forceUpdate(); - } - }}> - { _t("Add existing rooms & spaces") } - - { - showCreateNewRoom(cli, space); - }}> - { _t("Create a new room") } - - ; + addRoomButton = ; } let settingsButton; if (shouldShowSpaceSettings(cli, space)) { - settingsButton = { - showSpaceSettings(cli, space); - }}> - { _t("Settings") } - ; + settingsButton = { + showSpaceSettings(cli, space); + }} + title={_t("Settings")} + />; } const onMembersClick = () => { @@ -290,17 +415,20 @@ const SpaceLanding = ({ space }) => { { inviteButton } + { settingsButton }
    +
    -
    - { addRoomButtons } - { settingsButton } -
    - +
    ; }; @@ -322,14 +450,18 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => { value={roomNames[i]} onChange={ev => setRoomName(i, ev.target.value)} autoFocus={i === 2} + disabled={busy} />; }); - const onNextClick = async () => { + const onNextClick = async (ev) => { + ev.preventDefault(); + if (busy) return; setError(""); setBusy(true); try { - await Promise.all(roomNames.map(name => name.trim()).filter(Boolean).map(name => { + const filteredRoomNames = roomNames.map(name => name.trim()).filter(Boolean); + await Promise.all(filteredRoomNames.map(name => { return createRoom({ createOpts: { preset: space.getJoinRule() === "public" ? Preset.PublicChat : Preset.PrivateChat, @@ -342,7 +474,7 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => { parentSpace: space, }); })); - onFinished(); + onFinished(filteredRoomNames.length > 0); } catch (e) { console.error("Failed to create initial space rooms", e); setError(_t("Failed to create initial space rooms")); @@ -350,11 +482,14 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => { setBusy(false); }; - let onClick = onFinished; + let onClick = (ev) => { + ev.preventDefault(); + onFinished(false); + }; let buttonLabel = _t("Skip for now"); if (roomNames.some(name => name.trim())) { onClick = onNextClick; - buttonLabel = busy ? _t("Creating rooms...") : _t("Continue") + buttonLabel = busy ? _t("Creating rooms...") : _t("Continue"); } return
    @@ -362,23 +497,55 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => {
    { description }
    { error &&
    { error }
    } - { fields } + + { fields } +
    - { buttonLabel } - + element="input" + type="submit" + form="mx_SpaceSetupFirstRooms" + value={buttonLabel} + />
    +
    ; }; -const SpaceSetupPublicShare = ({ space, onFinished }) => { +const SpaceAddExistingRooms = ({ space, onFinished }) => { + return
    +

    { _t("What do you want to organise?") }

    +
    + { _t("Pick rooms or conversations to add. This is just a space for you, " + + "no one will be informed. You can add more later.") } +
    + + + { _t("Skip for now") } + + } + onFinished={onFinished} + /> + +
    + +
    + +
    ; +}; + +const SpaceSetupPublicShare = ({ justCreatedOpts, space, onFinished, createdRooms }) => { return
    -

    { _t("Share %(name)s", { name: space.name }) }

    +

    { _t("Share %(name)s", { + name: justCreatedOpts?.createOpts?.name || space.name, + }) }

    { _t("It's just you at the moment, it will be even better with others.") }
    @@ -387,17 +554,20 @@ const SpaceSetupPublicShare = ({ space, onFinished }) => {
    - { _t("Go to my first room") } + { createdRooms ? _t("Go to my first room") : _t("Go to my space") }
    +
    ; }; -const SpaceSetupPrivateScope = ({ space, onFinished }) => { +const SpaceSetupPrivateScope = ({ space, justCreatedOpts, onFinished }) => { return

    { _t("Who are you working with?") }

    - { _t("Make sure the right people have access to %(name)s", { name: space.name }) } + { _t("Make sure the right people have access to %(name)s", { + name: justCreatedOpts?.createOpts?.name || space.name, + }) }
    {

    { _t("Me and my teammates") }

    { _t("A private space for you and your teammates") }
    +
    ; }; @@ -444,10 +615,13 @@ const SpaceSetupPrivateInvite = ({ space, onFinished }) => { ref={fieldRefs[i]} onValidate={validateEmailRules} autoFocus={i === 0} + disabled={busy} />; }); - const onNextClick = async () => { + const onNextClick = async (ev) => { + ev.preventDefault(); + if (busy) return; setError(""); for (let i = 0; i < fieldRefs.length; i++) { const fieldRef = fieldRefs[i]; @@ -481,7 +655,10 @@ const SpaceSetupPrivateInvite = ({ space, onFinished }) => { setBusy(false); }; - let onClick = onFinished; + let onClick = (ev) => { + ev.preventDefault(); + onFinished(); + }; let buttonLabel = _t("Skip for now"); if (emailAddresses.some(name => name.trim())) { onClick = onNextClick; @@ -494,8 +671,21 @@ const SpaceSetupPrivateInvite = ({ space, onFinished }) => { { _t("Make sure the right people have access. You can invite more later.") }
    +
    + + { _t("This is an experimental feature. For now, " + + "new users receiving an invite will have to open the invite on to actually join.", {}, { + b: sub => { sub }, + link: () => + app.element.io + , + }) } +
    + { error &&
    { error }
    } - { fields } +
    + { fields } +
    {
    - - { buttonLabel } - +
    +
    ; }; @@ -609,7 +806,7 @@ export default class SpaceRoomView extends React.PureComponent { let suggestedRooms = SpaceStore.instance.suggestedRooms; if (SpaceStore.instance.activeSpace !== this.props.space) { // the space store has the suggested rooms loaded for a different space, fetch the right ones - suggestedRooms = (await SpaceStore.instance.fetchSuggestedRooms(this.props.space, 1)).rooms; + suggestedRooms = (await SpaceStore.instance.fetchSuggestedRooms(this.props.space, 1)); } if (suggestedRooms.length) { @@ -617,9 +814,11 @@ export default class SpaceRoomView extends React.PureComponent { defaultDispatcher.dispatch({ action: "view_room", room_id: room.room_id, + room_alias: room.canonical_alias || room.aliases?.[0], + via_servers: room.viaServers, oobData: { avatarUrl: room.avatar_url, - name: room.name || room.canonical_alias || room.aliases.pop() || _t("Empty room"), + name: room.name || room.canonical_alias || room.aliases?.[0] || _t("Empty room"), }, }); return; @@ -631,7 +830,7 @@ export default class SpaceRoomView extends React.PureComponent { private renderBody() { switch (this.state.phase) { case Phase.Landing: - if (this.state.myMembership === "join") { + if (this.state.myMembership === "join" && SettingsStore.getValue("feature_spaces")) { return ; } else { return { return this.setState({ phase: Phase.PublicShare })} + onFinished={(createdRooms: boolean) => this.setState({ phase: Phase.PublicShare, createdRooms })} />; case Phase.PublicShare: - return ; + return ; case Phase.PrivateScope: return { - this.setState({ phase: invite ? Phase.PrivateInvite : Phase.PrivateCreateRooms }); + this.setState({ phase: invite ? Phase.PrivateInvite : Phase.PrivateExistingRooms }); }} />; case Phase.PrivateInvite: @@ -673,6 +878,11 @@ export default class SpaceRoomView extends React.PureComponent { title={_t("What projects are you working on?")} description={_t("We'll create rooms for each of them. " + "You can add more later too, including already existing ones.")} + onFinished={(createdRooms: boolean) => this.setState({ phase: Phase.Landing, createdRooms })} + />; + case Phase.PrivateExistingRooms: + return this.setState({ phase: Phase.Landing })} />; } diff --git a/src/components/structures/TimelinePanel.js b/src/components/structures/TimelinePanel.js index b1d1e16719..c826199533 100644 --- a/src/components/structures/TimelinePanel.js +++ b/src/components/structures/TimelinePanel.js @@ -38,6 +38,7 @@ import {haveTileForEvent} from "../views/rooms/EventTile"; import {UIFeature} from "../../settings/UIFeature"; import {objectHasDiff} from "../../utils/objects"; import {replaceableComponent} from "../../utils/replaceableComponent"; +import { arrayFastClone } from "../../utils/arrays"; const PAGINATE_SIZE = 20; const INITIAL_SIZE = 20; @@ -68,6 +69,7 @@ class TimelinePanel extends React.Component { showReadReceipts: PropTypes.bool, // Enable managing RRs and RMs. These require the timelineSet to have a room. manageReadReceipts: PropTypes.bool, + sendReadReceiptOnLoad: PropTypes.bool, manageReadMarkers: PropTypes.bool, // true to give the component a 'display: none' style. @@ -129,6 +131,7 @@ class TimelinePanel extends React.Component { // event tile heights. (See _unpaginateEvents) timelineCap: Number.MAX_VALUE, className: 'mx_RoomView_messagePanel', + sendReadReceiptOnLoad: true, }; constructor(props) { @@ -790,8 +793,10 @@ class TimelinePanel extends React.Component { return; } const lastDisplayedEvent = this.state.events[lastDisplayedIndex]; - this._setReadMarker(lastDisplayedEvent.getId(), - lastDisplayedEvent.getTs()); + this._setReadMarker( + lastDisplayedEvent.getId(), + lastDisplayedEvent.getTs(), + ); // the read-marker should become invisible, so that if the user scrolls // down, they don't see it. @@ -877,7 +882,7 @@ class TimelinePanel extends React.Component { // The messagepanel knows where the RM is, so we must have loaded // the relevant event. this._messagePanel.current.scrollToEvent(this.state.readMarkerEventId, - 0, 1/3); + 0, 1/3); return; } @@ -1049,12 +1054,14 @@ class TimelinePanel extends React.Component { } if (eventId) { this._messagePanel.current.scrollToEvent(eventId, pixelOffset, - offsetBase); + offsetBase); } else { this._messagePanel.current.scrollToBottom(); } - this.sendReadReceipt(); + if (this.props.sendReadReceiptOnLoad) { + this.sendReadReceipt(); + } }); }; @@ -1140,6 +1147,17 @@ class TimelinePanel extends React.Component { // get the list of events from the timeline window and the pending event list _getEvents() { const events = this._timelineWindow.getEvents(); + + // `arrayFastClone` performs a shallow copy of the array + // we want the last event to be decrypted first but displayed last + // `reverse` is destructive and unfortunately mutates the "events" array + arrayFastClone(events) + .reverse() + .forEach(event => { + const client = MatrixClientPeg.get(); + client.decryptEventIfNeeded(event); + }); + const firstVisibleEventIndex = this._checkForPreJoinUISI(events); // Hold onto the live events separately. The read receipt and read marker @@ -1423,8 +1441,8 @@ class TimelinePanel extends React.Component { ['PREPARED', 'CATCHUP'].includes(this.state.clientSyncState) ); const events = this.state.firstVisibleEventIndex - ? this.state.events.slice(this.state.firstVisibleEventIndex) - : this.state.events; + ? this.state.events.slice(this.state.firstVisibleEventIndex) + : this.state.events; return ( { private dispatcherRef: string; private themeWatcherRef: string; + private dndWatcherRef: string; private buttonRef: React.RefObject = createRef(); private tagStoreRef: fbEmitter.EventSubscription; @@ -89,6 +90,9 @@ export default class UserMenu extends React.Component { if (SettingsStore.getValue("feature_spaces")) { SpaceStore.instance.on(UPDATE_SELECTED_SPACE, this.onSelectedSpaceUpdate); } + + // Force update is the easiest way to trigger the UI update (we don't store state for this) + this.dndWatcherRef = SettingsStore.watchSetting("doNotDisturb", null, () => this.forceUpdate()); } private get hasHomePage(): boolean { @@ -103,6 +107,7 @@ export default class UserMenu extends React.Component { public componentWillUnmount() { if (this.themeWatcherRef) SettingsStore.unwatchSetting(this.themeWatcherRef); + if (this.dndWatcherRef) SettingsStore.unwatchSetting(this.dndWatcherRef); if (this.dispatcherRef) defaultDispatcher.unregister(this.dispatcherRef); OwnProfileStore.instance.off(UPDATE_EVENT, this.onProfileUpdate); this.tagStoreRef.remove(); @@ -288,6 +293,12 @@ export default class UserMenu extends React.Component { this.setState({contextMenuPosition: null}); // also close the menu }; + private onDndToggle = (ev) => { + ev.stopPropagation(); + const current = SettingsStore.getValue("doNotDisturb"); + SettingsStore.setValue("doNotDisturb", null, SettingLevel.DEVICE, !current); + }; + private renderContextMenu = (): React.ReactNode => { if (!this.state.contextMenuPosition) return null; @@ -534,6 +545,7 @@ export default class UserMenu extends React.Component { {/* masked image in CSS */} ); + let dnd; if (this.state.selectedSpace) { name = (
    @@ -560,6 +572,16 @@ export default class UserMenu extends React.Component {
    ); isPrototype = true; + } else if (SettingsStore.getValue("feature_dnd")) { + const isDnd = SettingsStore.getValue("doNotDisturb"); + dnd = ; } if (this.props.isMinimized) { name = null; @@ -595,6 +617,7 @@ export default class UserMenu extends React.Component { /> {name} + {dnd} {buttons}
    diff --git a/src/components/structures/auth/Login.tsx b/src/components/structures/auth/Login.tsx index 3ab73fb9ac..34a5410928 100644 --- a/src/components/structures/auth/Login.tsx +++ b/src/components/structures/auth/Login.tsx @@ -1,5 +1,5 @@ /* -Copyright 2015, 2016, 2017, 2018, 2019 The Matrix.org Foundation C.I.C. +Copyright 2015-2021 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. @@ -94,7 +94,7 @@ interface IState { // be seeing. serverIsAlive: boolean; serverErrorIsFatal: boolean; - serverDeadError: string; + serverDeadError?: ReactNode; } /* diff --git a/src/components/structures/auth/Registration.tsx b/src/components/structures/auth/Registration.tsx index 9d004de2ec..96fb9bdc82 100644 --- a/src/components/structures/auth/Registration.tsx +++ b/src/components/structures/auth/Registration.tsx @@ -1,5 +1,5 @@ /* -Copyright 2015, 2016, 2017, 2018, 2019, 2020 The Matrix.org Foundation C.I.C. +Copyright 2015-2021 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. @@ -95,7 +95,7 @@ interface IState { // be seeing. serverIsAlive: boolean; serverErrorIsFatal: boolean; - serverDeadError: string; + serverDeadError?: ReactNode; // Our matrix client - part of state because we can't render the UI auth // component without it. @@ -436,6 +436,8 @@ export default class Registration extends React.Component { // ok fine, there's still no session: really go to the login page this.props.onLoginClick(); } + + return sessionLoaded; }; private renderRegisterComponent() { @@ -557,7 +559,12 @@ export default class Registration extends React.Component { loggedInUserId: this.state.differentLoggedInUserId, }, )}

    -

    +

    { + const sessionLoaded = await this.onLoginClickWithCheck(event); + if (sessionLoaded) { + dis.dispatch({action: "view_welcome_page"}); + } + }}> {_t("Continue with previous account")}

    ; diff --git a/src/components/structures/auth/SoftLogout.js b/src/components/structures/auth/SoftLogout.tsx similarity index 92% rename from src/components/structures/auth/SoftLogout.js rename to src/components/structures/auth/SoftLogout.tsx index 08db3b2efe..fa9207efdd 100644 --- a/src/components/structures/auth/SoftLogout.js +++ b/src/components/structures/auth/SoftLogout.tsx @@ -1,5 +1,5 @@ /* -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2019-2021 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. @@ -15,14 +15,13 @@ limitations under the License. */ import React from 'react'; -import PropTypes from 'prop-types'; import {_t} from '../../../languageHandler'; import * as sdk from '../../../index'; import dis from '../../../dispatcher/dispatcher'; import * as Lifecycle from '../../../Lifecycle'; import Modal from '../../../Modal'; import {MatrixClientPeg} from "../../../MatrixClientPeg"; -import {sendLoginRequest} from "../../../Login"; +import {ISSOFlow, LoginFlow, sendLoginRequest} from "../../../Login"; import AuthPage from "../../views/auth/AuthPage"; import {SSO_HOMESERVER_URL_KEY, SSO_ID_SERVER_URL_KEY} from "../../../BasePlatform"; import SSOButtons from "../../views/elements/SSOButtons"; @@ -42,26 +41,38 @@ const FLOWS_TO_VIEWS = { "m.login.sso": LOGIN_VIEW.SSO, }; -@replaceableComponent("structures.auth.SoftLogout") -export default class SoftLogout extends React.Component { - static propTypes = { - // Query parameters from MatrixChat - realQueryParams: PropTypes.object, // {loginToken} - - // Called when the SSO login completes - onTokenLoginCompleted: PropTypes.func, +interface IProps { + // Query parameters from MatrixChat + realQueryParams: { + loginToken?: string; }; + fragmentAfterLogin?: string; - constructor() { - super(); + // Called when the SSO login completes + onTokenLoginCompleted: () => void, +} + +interface IState { + loginView: number; + keyBackupNeeded: boolean; + busy: boolean; + password: string; + errorText: string; + flows: LoginFlow[]; +} + +@replaceableComponent("structures.auth.SoftLogout") +export default class SoftLogout extends React.Component { + constructor(props) { + super(props); this.state = { loginView: LOGIN_VIEW.LOADING, keyBackupNeeded: true, // assume we do while we figure it out (see componentDidMount) - busy: false, password: "", errorText: "", + flows: [], }; } @@ -72,7 +83,7 @@ export default class SoftLogout extends React.Component { return; } - this._initLogin(); + this.initLogin(); const cli = MatrixClientPeg.get(); if (cli.isCryptoEnabled()) { @@ -94,7 +105,7 @@ export default class SoftLogout extends React.Component { }); }; - async _initLogin() { + private async initLogin() { const queryParams = this.props.realQueryParams; const hasAllParams = queryParams && queryParams['loginToken']; if (hasAllParams) { @@ -189,7 +200,7 @@ export default class SoftLogout extends React.Component { }); } - _renderSignInSection() { + private renderSignInSection() { if (this.state.loginView === LOGIN_VIEW.LOADING) { const Spinner = sdk.getComponent("elements.Spinner"); return ; @@ -247,7 +258,7 @@ export default class SoftLogout extends React.Component { } // else we already have a message and should use it (key backup warning) const loginType = this.state.loginView === LOGIN_VIEW.CAS ? "cas" : "sso"; - const flow = this.state.flows.find(flow => flow.type === "m.login." + loginType); + const flow = this.state.flows.find(flow => flow.type === "m.login." + loginType) as ISSOFlow; return (
    @@ -289,7 +300,7 @@ export default class SoftLogout extends React.Component {

    {_t("Sign in")}

    - {this._renderSignInSection()} + {this.renderSignInSection()}

    {_t("Clear personal data")}

    diff --git a/src/components/views/auth/InteractiveAuthEntryComponents.js b/src/components/views/auth/InteractiveAuthEntryComponents.js index 6cbecd22ee..e34349c474 100644 --- a/src/components/views/auth/InteractiveAuthEntryComponents.js +++ b/src/components/views/auth/InteractiveAuthEntryComponents.js @@ -169,7 +169,7 @@ export class PasswordAuthEntry extends React.Component { { submitButtonOrSpinner }
    - { errorSection } + { errorSection }
    ); } @@ -375,7 +375,7 @@ export class TermsAuthEntry extends React.Component { if (this.props.showContinue !== false) { // XXX: button classes submitButton = ; + onClick={this._trySubmit} disabled={!allChecked}>{_t("Accept")}; } return ( diff --git a/src/components/views/avatars/BaseAvatar.tsx b/src/components/views/avatars/BaseAvatar.tsx index 5ecdd4ec5a..8ce05e0a55 100644 --- a/src/components/views/avatars/BaseAvatar.tsx +++ b/src/components/views/avatars/BaseAvatar.tsx @@ -179,7 +179,7 @@ const BaseAvatar = (props: IProps) => { width: toPx(width), height: toPx(height), }} - title={title} alt="" + title={title} alt={_t("Avatar")} inputRef={inputRef} {...otherProps} /> ); diff --git a/src/components/views/avatars/DecoratedRoomAvatar.tsx b/src/components/views/avatars/DecoratedRoomAvatar.tsx index e95022687a..f15538eabf 100644 --- a/src/components/views/avatars/DecoratedRoomAvatar.tsx +++ b/src/components/views/avatars/DecoratedRoomAvatar.tsx @@ -20,7 +20,6 @@ import { Room } from "matrix-js-sdk/src/models/room"; import { User } from "matrix-js-sdk/src/models/user"; import { MatrixEvent } from "matrix-js-sdk/src/models/event"; -import { TagID } from '../../../stores/room-list/models'; import RoomAvatar from "./RoomAvatar"; import NotificationBadge from '../rooms/NotificationBadge'; import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore"; @@ -35,7 +34,6 @@ import {replaceableComponent} from "../../../utils/replaceableComponent"; interface IProps { room: Room; avatarSize: number; - tag: TagID; displayBadge?: boolean; forceCount?: boolean; oobData?: object; diff --git a/src/components/views/avatars/MemberAvatar.tsx b/src/components/views/avatars/MemberAvatar.tsx index c79cbc0d32..3205ca372c 100644 --- a/src/components/views/avatars/MemberAvatar.tsx +++ b/src/components/views/avatars/MemberAvatar.tsx @@ -68,8 +68,8 @@ export default class MemberAvatar extends React.Component { let imageUrl = null; if (props.member.getMxcAvatarUrl()) { imageUrl = mediaFromMxc(props.member.getMxcAvatarUrl()).getThumbnailOfSourceHttp( - Math.floor(props.width * window.devicePixelRatio), - Math.floor(props.height * window.devicePixelRatio), + props.width, + props.height, props.resizeMethod, ); } diff --git a/src/components/views/avatars/RoomAvatar.tsx b/src/components/views/avatars/RoomAvatar.tsx index 31245b44b7..4693d907ba 100644 --- a/src/components/views/avatars/RoomAvatar.tsx +++ b/src/components/views/avatars/RoomAvatar.tsx @@ -93,8 +93,8 @@ export default class RoomAvatar extends React.Component { let oobAvatar = null; if (props.oobData.avatarUrl) { oobAvatar = mediaFromMxc(props.oobData.avatarUrl).getThumbnailOfSourceHttp( - Math.floor(props.width * window.devicePixelRatio), - Math.floor(props.height * window.devicePixelRatio), + props.width, + props.height, props.resizeMethod, ); } @@ -109,12 +109,7 @@ export default class RoomAvatar extends React.Component { private static getRoomAvatarUrl(props: IProps): string { if (!props.room) return null; - return Avatar.avatarUrlForRoom( - props.room, - Math.floor(props.width * window.devicePixelRatio), - Math.floor(props.height * window.devicePixelRatio), - props.resizeMethod, - ); + return Avatar.avatarUrlForRoom(props.room, props.width, props.height, props.resizeMethod); } private onRoomAvatarClick = () => { @@ -129,7 +124,7 @@ export default class RoomAvatar extends React.Component { name: this.props.room.name, }; - Modal.createDialog(ImageView, params, "mx_Dialog_lightbox"); + Modal.createDialog(ImageView, params, "mx_Dialog_lightbox", null, true); }; public render() { diff --git a/src/components/views/beta/BetaCard.tsx b/src/components/views/beta/BetaCard.tsx new file mode 100644 index 0000000000..821c448f4f --- /dev/null +++ b/src/components/views/beta/BetaCard.tsx @@ -0,0 +1,108 @@ +/* +Copyright 2021 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 classNames from "classnames"; + +import {_t} from "../../../languageHandler"; +import AccessibleButton from "../elements/AccessibleButton"; +import SettingsStore from "../../../settings/SettingsStore"; +import {SettingLevel} from "../../../settings/SettingLevel"; +import TextWithTooltip from "../elements/TextWithTooltip"; +import Modal from "../../../Modal"; +import BetaFeedbackDialog from "../dialogs/BetaFeedbackDialog"; +import SdkConfig from "../../../SdkConfig"; + +interface IProps { + title?: string; + featureId: string; +} + +export const BetaPill = ({ onClick }: { onClick?: () => void }) => { + if (onClick) { + return +
    + { _t("Spaces is a beta feature") } +
    +
    + { _t("Tap for more info") } +
    +
    } + onClick={onClick} + tooltipProps={{ yOffset: -10 }} + > + { _t("Beta") } + ; + } + + return + { _t("Beta") } + ; +}; + +const BetaCard = ({ title: titleOverride, featureId }: IProps) => { + const info = SettingsStore.getBetaInfo(featureId); + if (!info) return null; // Beta is invalid/disabled + + const { title, caption, disclaimer, image, feedbackLabel, feedbackSubheading } = info; + const value = SettingsStore.getValue(featureId); + + let feedbackButton; + if (value && feedbackLabel && feedbackSubheading && SdkConfig.get().bug_report_endpoint_url) { + feedbackButton = { + Modal.createTrackedDialog("Beta Feedback", featureId, BetaFeedbackDialog, { featureId }); + }} + kind="primary" + > + { _t("Feedback") } + ; + } + + return
    +
    +

    + { titleOverride || _t(title) } + +

    + { _t(caption) } +
    + { feedbackButton } + SettingsStore.setValue(featureId, null, SettingLevel.DEVICE, !value)} + kind={feedbackButton ? "primary_outline" : "primary"} + > + { value ? _t("Leave the beta") : _t("Join the beta") } + +
    + { disclaimer &&
    + { disclaimer(value) } +
    } +
    + +
    ; +}; + +export default BetaCard; diff --git a/src/components/views/context_menus/MessageContextMenu.js b/src/components/views/context_menus/MessageContextMenu.js index 56f070ba36..365f2ab1de 100644 --- a/src/components/views/context_menus/MessageContextMenu.js +++ b/src/components/views/context_menus/MessageContextMenu.js @@ -1,8 +1,6 @@ /* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2018 New Vector Ltd Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2015, 2016, 2018, 2019, 2021 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. @@ -34,7 +32,7 @@ import {MenuItem} from "../../structures/ContextMenu"; import {EventType} from "matrix-js-sdk/src/@types/event"; import {replaceableComponent} from "../../../utils/replaceableComponent"; -function canCancel(eventStatus) { +export function canCancel(eventStatus) { return eventStatus === EventStatus.QUEUED || eventStatus === EventStatus.NOT_SENT; } @@ -52,6 +50,9 @@ export default class MessageContextMenu extends React.Component { /* callback called when the menu is dismissed */ onFinished: PropTypes.func, + + /* if the menu is inside a dialog, we sometimes need to close that dialog after click (forwarding) */ + onCloseDialog: PropTypes.func, }; state = { @@ -77,8 +78,10 @@ export default class MessageContextMenu extends React.Component { // We explicitly decline to show the redact option on ACL events as it has a potential // to obliterate the room - https://github.com/matrix-org/synapse/issues/4042 + // Similarly for encryption events, since redacting them "breaks everything" const canRedact = room.currentState.maySendRedactionForEvent(this.props.mxEvent, cli.credentials.userId) - && this.props.mxEvent.getType() !== EventType.RoomServerAcl; + && this.props.mxEvent.getType() !== EventType.RoomServerAcl + && this.props.mxEvent.getType() !== EventType.RoomEncryption; let canPin = room.currentState.mayClientSendStateEvent('m.room.pinned_events', cli); // HACK: Intentionally say we can't pin if the user doesn't want to use the functionality @@ -95,21 +98,6 @@ export default class MessageContextMenu extends React.Component { return content.pinned && Array.isArray(content.pinned) && content.pinned.includes(this.props.mxEvent.getId()); } - onResendClick = () => { - Resend.resend(this.props.mxEvent); - this.closeMenu(); - }; - - onResendEditClick = () => { - Resend.resend(this.props.mxEvent.replacingEvent()); - this.closeMenu(); - }; - - onResendRedactionClick = () => { - Resend.resend(this.props.mxEvent.localRedactionEvent()); - this.closeMenu(); - }; - onResendReactionsClick = () => { for (const reaction of this._getUnsentReactions()) { Resend.resend(reaction); @@ -141,6 +129,7 @@ export default class MessageContextMenu extends React.Component { const cli = MatrixClientPeg.get(); try { + if (this.props.onCloseDialog) this.props.onCloseDialog(); await cli.redactEvent( this.props.mxEvent.getRoomId(), this.props.mxEvent.getId(), @@ -166,30 +155,8 @@ export default class MessageContextMenu extends React.Component { this.closeMenu(); }; - onCancelSendClick = () => { - const mxEvent = this.props.mxEvent; - const editEvent = mxEvent.replacingEvent(); - const redactEvent = mxEvent.localRedactionEvent(); - const pendingReactions = this._getPendingReactions(); - - if (editEvent && canCancel(editEvent.status)) { - Resend.removeFromQueue(editEvent); - } - if (redactEvent && canCancel(redactEvent.status)) { - Resend.removeFromQueue(redactEvent); - } - if (pendingReactions.length) { - for (const reaction of pendingReactions) { - Resend.removeFromQueue(reaction); - } - } - if (canCancel(mxEvent.status)) { - Resend.removeFromQueue(this.props.mxEvent); - } - this.closeMenu(); - }; - onForwardClick = () => { + if (this.props.onCloseDialog) this.props.onCloseDialog(); dis.dispatch({ action: 'forward_event', event: this.props.mxEvent, @@ -280,20 +247,9 @@ export default class MessageContextMenu extends React.Component { const me = cli.getUserId(); const mxEvent = this.props.mxEvent; const eventStatus = mxEvent.status; - const editStatus = mxEvent.replacingEvent() && mxEvent.replacingEvent().status; - const redactStatus = mxEvent.localRedactionEvent() && mxEvent.localRedactionEvent().status; const unsentReactionsCount = this._getUnsentReactions().length; - const pendingReactionsCount = this._getPendingReactions().length; - const allowCancel = canCancel(mxEvent.status) || - canCancel(editStatus) || - canCancel(redactStatus) || - pendingReactionsCount !== 0; - let resendButton; - let resendEditButton; let resendReactionsButton; - let resendRedactionButton; let redactButton; - let cancelButton; let forwardButton; let pinButton; let unhidePreviewButton; @@ -304,22 +260,6 @@ export default class MessageContextMenu extends React.Component { // status is SENT before remote-echo, null after const isSent = !eventStatus || eventStatus === EventStatus.SENT; if (!mxEvent.isRedacted()) { - if (eventStatus === EventStatus.NOT_SENT) { - resendButton = ( - - { _t('Resend') } - - ); - } - - if (editStatus === EventStatus.NOT_SENT) { - resendEditButton = ( - - { _t('Resend edit') } - - ); - } - if (unsentReactionsCount !== 0) { resendReactionsButton = ( @@ -329,14 +269,6 @@ export default class MessageContextMenu extends React.Component { } } - if (redactStatus === EventStatus.NOT_SENT) { - resendRedactionButton = ( - - { _t('Resend removal') } - - ); - } - if (isSent && this.state.canRedact) { redactButton = ( @@ -345,14 +277,6 @@ export default class MessageContextMenu extends React.Component { ); } - if (allowCancel) { - cancelButton = ( - - { _t('Cancel Sending') } - - ); - } - if (isContentActionable(mxEvent)) { forwardButton = ( @@ -428,7 +352,7 @@ export default class MessageContextMenu extends React.Component { > { _t('Source URL') } - ); + ); } if (this.props.collapseReplyThread) { @@ -450,12 +374,8 @@ export default class MessageContextMenu extends React.Component { return (
    - { resendButton } - { resendEditButton } { resendReactionsButton } - { resendRedactionButton } { redactButton } - { cancelButton } { forwardButton } { pinButton } { viewSourceButton } diff --git a/src/components/views/dialogs/AddExistingToSpaceDialog.tsx b/src/components/views/dialogs/AddExistingToSpaceDialog.tsx index 04bec39238..9a7f96e653 100644 --- a/src/components/views/dialogs/AddExistingToSpaceDialog.tsx +++ b/src/components/views/dialogs/AddExistingToSpaceDialog.tsx @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, {useState} from "react"; +import React, {ReactNode, useContext, useMemo, useState} from "react"; import classNames from "classnames"; import {Room} from "matrix-js-sdk/src/models/room"; import {MatrixClient} from "matrix-js-sdk/src/client"; @@ -29,10 +29,16 @@ import RoomAvatar from "../avatars/RoomAvatar"; import {getDisplayAliasForRoom} from "../../../Rooms"; import AccessibleButton from "../elements/AccessibleButton"; import AutoHideScrollbar from "../../structures/AutoHideScrollbar"; -import {allSettled} from "../../../utils/promise"; +import {sleep} from "../../../utils/promise"; import DMRoomMap from "../../../utils/DMRoomMap"; import {calculateRoomVia} from "../../../utils/permalinks/Permalinks"; import StyledCheckbox from "../elements/StyledCheckbox"; +import MatrixClientContext from "../../../contexts/MatrixClientContext"; +import {sortRooms} from "../../../stores/room-list/algorithms/tag-sorting/RecentAlgorithm"; +import ProgressBar from "../elements/ProgressBar"; +import {SpaceFeedbackPrompt} from "../../structures/SpaceRoomView"; +import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar"; +import QueryMatcher from "../../../autocomplete/QueryMatcher"; interface IProps extends IDialogProps { matrixClient: MatrixClient; @@ -41,43 +47,241 @@ interface IProps extends IDialogProps { } const Entry = ({ room, checked, onChange }) => { - return
    - - { room.name } - onChange(e.target.checked)} checked={checked} /> + return ; +}; + +interface IAddExistingToSpaceProps { + space: Room; + footerPrompt?: ReactNode; + emptySelectionButton?: ReactNode; + onFinished(added: boolean): void; +} + +export const AddExistingToSpace: React.FC = ({ + space, + footerPrompt, + emptySelectionButton, + onFinished, +}) => { + const cli = useContext(MatrixClientContext); + const visibleRooms = useMemo(() => cli.getVisibleRooms().filter(r => r.getMyMembership() === "join"), [cli]); + + const [selectedToAdd, setSelectedToAdd] = useState(new Set()); + const [progress, setProgress] = useState(null); + const [error, setError] = useState(null); + const [query, setQuery] = useState(""); + const lcQuery = query.toLowerCase().trim(); + + const existingSubspacesSet = useMemo(() => new Set(SpaceStore.instance.getChildSpaces(space.roomId)), [space]); + const existingRoomsSet = useMemo(() => new Set(SpaceStore.instance.getChildRooms(space.roomId)), [space]); + + const [spaces, rooms, dms] = useMemo(() => { + let rooms = visibleRooms; + + if (lcQuery) { + const matcher = new QueryMatcher(visibleRooms, { + keys: ["name"], + funcs: [r => [r.getCanonicalAlias(), ...r.getAltAliases()].filter(Boolean)], + shouldMatchWordsOnly: false, + }); + + rooms = matcher.match(lcQuery); + } + + const joinRule = space.getJoinRule(); + return sortRooms(rooms).reduce((arr, room) => { + if (room.isSpaceRoom()) { + if (room !== space && !existingSubspacesSet.has(room)) { + arr[0].push(room); + } + } else if (!existingRoomsSet.has(room)) { + if (!DMRoomMap.shared().getUserIdForRoomId(room.roomId)) { + arr[1].push(room); + } else if (joinRule !== "public") { + // Only show DMs for non-public spaces as they make very little sense in spaces other than "Just Me" ones. + arr[2].push(room); + } + } + return arr; + }, [[], [], []]); + }, [visibleRooms, space, lcQuery, existingRoomsSet, existingSubspacesSet]); + + const addRooms = async () => { + setError(null); + setProgress(0); + + let error; + + for (const room of selectedToAdd) { + const via = calculateRoomVia(room); + try { + await SpaceStore.instance.addRoomToSpace(space, room.roomId, via).catch(async e => { + if (e.errcode === "M_LIMIT_EXCEEDED") { + await sleep(e.data.retry_after_ms); + return SpaceStore.instance.addRoomToSpace(space, room.roomId, via); // retry + } + + throw e; + }); + setProgress(i => i + 1); + } catch (e) { + console.error("Failed to add rooms to space", e); + setError(error = e); + break; + } + } + + if (!error) { + onFinished(true); + } + }; + + const busy = progress !== null; + + let footer; + if (error) { + footer = <> + + + +
    { _t("Not all selected were added") }
    +
    { _t("Try again") }
    +
    + + + { _t("Retry") } + + ; + } else if (busy) { + footer = + +
    + { _t("Adding rooms... (%(progress)s out of %(count)s)", { + count: selectedToAdd.size, + progress, + }) } +
    +
    ; + } else { + let button = emptySelectionButton; + if (!button || selectedToAdd.size > 0) { + button = + { _t("Add") } + ; + } + + footer = <> + + { footerPrompt } + + + { button } + ; + } + + const onChange = !busy && !error ? (checked, room) => { + if (checked) { + selectedToAdd.add(room); + } else { + selectedToAdd.delete(room); + } + setSelectedToAdd(new Set(selectedToAdd)); + } : null; + + return
    + + + { rooms.length > 0 ? ( +
    +

    { _t("Rooms") }

    + { rooms.map(room => { + return { + onChange(checked, room); + } : null} + />; + }) } +
    + ) : undefined } + + { spaces.length > 0 ? ( +
    +

    { _t("Spaces") }

    +
    +
    { _t("Feeling experimental?") }
    +
    { _t("You can add existing spaces to a space.") }
    +
    + { spaces.map(space => { + return { + onChange(checked, space); + } : null} + />; + }) } +
    + ) : null } + + { dms.length > 0 ? ( +
    +

    { _t("Direct Messages") }

    + { dms.map(room => { + return { + onChange(checked, room); + } : null} + />; + }) } +
    + ) : null } + + { spaces.length + rooms.length + dms.length < 1 ? + { _t("No results") } + : undefined } +
    + +
    + { footer } +
    ; }; const AddExistingToSpaceDialog: React.FC = ({ matrixClient: cli, space, onCreateRoomClick, onFinished }) => { - const [query, setQuery] = useState(""); - const lcQuery = query.toLowerCase(); - const [selectedSpace, setSelectedSpace] = useState(space); - const [selectedToAdd, setSelectedToAdd] = useState(new Set()); - const existingSubspaces = SpaceStore.instance.getChildSpaces(space.roomId); - const existingSubspacesSet = new Set(existingSubspaces); - const spaces = SpaceStore.instance.getSpaces().filter(s => { - return !existingSubspacesSet.has(s) // not already in space - && space !== s // not the top-level space - && selectedSpace !== s // not the selected space - && s.name.toLowerCase().includes(lcQuery); // contains query - }); - - const existingRooms = SpaceStore.instance.getChildRooms(space.roomId); - const existingRoomsSet = new Set(existingRooms); - const rooms = cli.getVisibleRooms().filter(room => { - return !existingRoomsSet.has(room) // not already in space - && !room.isSpaceRoom() // not a space itself - && room.name.toLowerCase().includes(lcQuery) // contains query - && !DMRoomMap.shared().getUserIdForRoomId(room.roomId); // not a DM - }); - - const [busy, setBusy] = useState(false); - const [error, setError] = useState(""); let spaceOptionSection; - if (existingSubspacesSet.size > 0) { + if (existingSubspaces.length > 0) { const options = [space, ...existingSubspaces].map((space) => { const classes = classNames("mx_AddExistingToSpaceDialog_dropdownOption", { mx_AddExistingToSpaceDialog_dropdownOptionActive: space === selectedSpace, @@ -117,93 +321,24 @@ const AddExistingToSpaceDialog: React.FC = ({ matrixClient: cli, space, return - { error &&
    { error }
    } + + +
    { _t("Want to add a new room instead?") }
    + onCreateRoomClick(cli, space)} kind="link"> + { _t("Create a new room") } + + } + /> +
    - - - { rooms.length > 0 ? ( -
    -

    { _t("Rooms") }

    - { rooms.map(room => { - return { - if (checked) { - selectedToAdd.add(room); - } else { - selectedToAdd.delete(room); - } - setSelectedToAdd(new Set(selectedToAdd)); - }} - />; - }) } -
    - ) : undefined } - - { spaces.length > 0 ? ( -
    -

    { _t("Spaces") }

    - { spaces.map(space => { - return { - if (checked) { - selectedToAdd.add(space); - } else { - selectedToAdd.delete(space); - } - setSelectedToAdd(new Set(selectedToAdd)); - }} - />; - }) } -
    - ) : null } - - { spaces.length + rooms.length < 1 ? - { _t("No results") } - : undefined } -
    - -
    - -
    { _t("Don't want to add an existing room?") }
    - onCreateRoomClick(cli, space)} kind="link"> - { _t("Create a new room") } - -
    - - { - setBusy(true); - try { - await allSettled(Array.from(selectedToAdd).map((room) => - SpaceStore.instance.addRoomToSpace(space, room.roomId, calculateRoomVia(room)))); - onFinished(true); - } catch (e) { - console.error("Failed to add rooms to space", e); - setError(_t("Failed to add rooms to space")); - } - setBusy(false); - }} - > - { busy ? _t("Adding...") : _t("Add") } - -
    + onFinished(false)} />
    ; }; diff --git a/src/components/views/dialogs/BetaFeedbackDialog.tsx b/src/components/views/dialogs/BetaFeedbackDialog.tsx new file mode 100644 index 0000000000..1ae50dd66f --- /dev/null +++ b/src/components/views/dialogs/BetaFeedbackDialog.tsx @@ -0,0 +1,106 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React, {useState} from "react"; + +import QuestionDialog from './QuestionDialog'; +import { _t } from '../../../languageHandler'; +import Field from "../elements/Field"; +import SdkConfig from "../../../SdkConfig"; +import {IDialogProps} from "./IDialogProps"; +import SettingsStore from "../../../settings/SettingsStore"; +import {submitFeedback} from "../../../rageshake/submit-rageshake"; +import StyledCheckbox from "../elements/StyledCheckbox"; +import Modal from "../../../Modal"; +import InfoDialog from "./InfoDialog"; +import AccessibleButton from "../elements/AccessibleButton"; +import defaultDispatcher from "../../../dispatcher/dispatcher"; +import {Action} from "../../../dispatcher/actions"; +import {USER_LABS_TAB} from "./UserSettingsDialog"; + +interface IProps extends IDialogProps { + featureId: string; +} + +const BetaFeedbackDialog: React.FC = ({featureId, onFinished}) => { + const info = SettingsStore.getBetaInfo(featureId); + + const [comment, setComment] = useState(""); + const [canContact, setCanContact] = useState(false); + + const sendFeedback = async (ok: boolean) => { + if (!ok) return onFinished(false); + + submitFeedback(SdkConfig.get().bug_report_endpoint_url, info.feedbackLabel, comment, canContact); + onFinished(true); + + Modal.createTrackedDialog("Beta Dialog Sent", featureId, InfoDialog, { + title: _t("Beta feedback"), + description: _t("Thank you for your feedback, we really appreciate it."), + button: _t("Done"), + hasCloseButton: false, + fixedWidth: false, + }); + }; + + return ( +
    + { _t(info.feedbackSubheading) } +   + { _t("Your platform and username will be noted to help us use your feedback as much as we can.")} + + { + onFinished(false); + defaultDispatcher.dispatch({ + action: Action.ViewUserSettings, + initialTabId: USER_LABS_TAB, + }); + }}> + { _t("To leave the beta, visit your settings.") } + +
    + + { + setComment(ev.target.value); + }} + autoFocus={true} + /> + + setCanContact((e.target as HTMLInputElement).checked)} + > + { _t("You may contact me if you have any follow up questions") } + + } + button={_t("Send feedback")} + buttonDisabled={!comment} + onFinished={sendFeedback} + />); +}; + +export default BetaFeedbackDialog; diff --git a/src/components/views/dialogs/BugReportDialog.js b/src/components/views/dialogs/BugReportDialog.js index 8948c14c7c..cbe0130649 100644 --- a/src/components/views/dialogs/BugReportDialog.js +++ b/src/components/views/dialogs/BugReportDialog.js @@ -184,7 +184,7 @@ export default class BugReportDialog extends React.Component { return (
    diff --git a/src/components/views/dialogs/ChangelogDialog.js b/src/components/views/dialogs/ChangelogDialog.js index 50bc13cff5..efbeba3977 100644 --- a/src/components/views/dialogs/ChangelogDialog.js +++ b/src/components/views/dialogs/ChangelogDialog.js @@ -95,7 +95,7 @@ export default class ChangelogDialog extends React.Component { description={content} button={_t("Update")} onFinished={this.props.onFinished} - /> + /> ); } } diff --git a/src/components/views/dialogs/ConfirmWipeDeviceDialog.js b/src/components/views/dialogs/ConfirmWipeDeviceDialog.js index 4faaad0f7e..333e1522f1 100644 --- a/src/components/views/dialogs/ConfirmWipeDeviceDialog.js +++ b/src/components/views/dialogs/ConfirmWipeDeviceDialog.js @@ -39,9 +39,12 @@ export default class ConfirmWipeDeviceDialog extends React.Component { const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); return ( - +

    {_t( diff --git a/src/components/views/dialogs/CreateRoomDialog.js b/src/components/views/dialogs/CreateRoomDialog.tsx similarity index 60% rename from src/components/views/dialogs/CreateRoomDialog.js rename to src/components/views/dialogs/CreateRoomDialog.tsx index e9dc6e2be0..cce6b6c34c 100644 --- a/src/components/views/dialogs/CreateRoomDialog.js +++ b/src/components/views/dialogs/CreateRoomDialog.tsx @@ -1,6 +1,6 @@ /* Copyright 2017 Michael Telatynski <7t3chguy@gmail.com> -Copyright 2020 The Matrix.org Foundation C.I.C. +Copyright 2020, 2021 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. @@ -15,27 +15,46 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from 'react'; -import PropTypes from 'prop-types'; +import React, {ChangeEvent, createRef, KeyboardEvent, SyntheticEvent} from "react"; import {Room} from "matrix-js-sdk/src/models/room"; -import * as sdk from '../../../index'; import SdkConfig from '../../../SdkConfig'; -import withValidation from '../elements/Validation'; -import { _t } from '../../../languageHandler'; +import withValidation, {IFieldState} from '../elements/Validation'; +import {_t} from '../../../languageHandler'; import {MatrixClientPeg} from '../../../MatrixClientPeg'; import {Key} from "../../../Keyboard"; -import {privateShouldBeEncrypted} from "../../../createRoom"; +import {IOpts, Preset, privateShouldBeEncrypted, Visibility} from "../../../createRoom"; import {CommunityPrototypeStore} from "../../../stores/CommunityPrototypeStore"; import {replaceableComponent} from "../../../utils/replaceableComponent"; +import Field from "../elements/Field"; +import RoomAliasField from "../elements/RoomAliasField"; +import LabelledToggleSwitch from "../elements/LabelledToggleSwitch"; +import DialogButtons from "../elements/DialogButtons"; +import BaseDialog from "../dialogs/BaseDialog"; + +interface IProps { + defaultPublic?: boolean; + defaultName?: string; + parentSpace?: Room; + onFinished(proceed: boolean, opts?: IOpts): void; +} + +interface IState { + isPublic: boolean; + isEncrypted: boolean; + name: string; + topic: string; + alias: string; + detailsOpen: boolean; + noFederate: boolean; + nameIsValid: boolean; + canChangeEncryption: boolean; +} @replaceableComponent("views.dialogs.CreateRoomDialog") -export default class CreateRoomDialog extends React.Component { - static propTypes = { - onFinished: PropTypes.func.isRequired, - defaultPublic: PropTypes.bool, - parentSpace: PropTypes.instanceOf(Room), - }; +export default class CreateRoomDialog extends React.Component { + private nameField = createRef(); + private aliasField = createRef(); constructor(props) { super(props); @@ -44,7 +63,7 @@ export default class CreateRoomDialog extends React.Component { this.state = { isPublic: this.props.defaultPublic || false, isEncrypted: privateShouldBeEncrypted(), - name: "", + name: this.props.defaultName || "", topic: "", alias: "", detailsOpen: false, @@ -54,26 +73,25 @@ export default class CreateRoomDialog extends React.Component { }; MatrixClientPeg.get().doesServerForceEncryptionForPreset("private") - .then(isForced => this.setState({canChangeEncryption: !isForced})); + .then(isForced => this.setState({ canChangeEncryption: !isForced })); } - _roomCreateOptions() { - const opts = {}; - const createOpts = opts.createOpts = {}; + private roomCreateOptions() { + const opts: IOpts = {}; + const createOpts: IOpts["createOpts"] = opts.createOpts = {}; createOpts.name = this.state.name; if (this.state.isPublic) { - createOpts.visibility = "public"; - createOpts.preset = "public_chat"; + createOpts.visibility = Visibility.Public; + createOpts.preset = Preset.PublicChat; opts.guestAccess = false; - const {alias} = this.state; - const localPart = alias.substr(1, alias.indexOf(":") - 1); - createOpts['room_alias_name'] = localPart; + const { alias } = this.state; + createOpts.room_alias_name = alias.substr(1, alias.indexOf(":") - 1); } if (this.state.topic) { createOpts.topic = this.state.topic; } if (this.state.noFederate) { - createOpts.creation_content = {'m.federate': false}; + createOpts.creation_content = { 'm.federate': false }; } if (!this.state.isPublic) { @@ -98,16 +116,14 @@ export default class CreateRoomDialog extends React.Component { } componentDidMount() { - this._detailsRef.addEventListener("toggle", this.onDetailsToggled); // move focus to first field when showing dialog - this._nameFieldRef.focus(); + this.nameField.current.focus(); } componentWillUnmount() { - this._detailsRef.removeEventListener("toggle", this.onDetailsToggled); } - _onKeyDown = event => { + private onKeyDown = (event: KeyboardEvent) => { if (event.key === Key.ENTER) { this.onOk(); event.preventDefault(); @@ -115,26 +131,26 @@ export default class CreateRoomDialog extends React.Component { } }; - onOk = async () => { - const activeElement = document.activeElement; + private onOk = async () => { + const activeElement = document.activeElement as HTMLElement; if (activeElement) { activeElement.blur(); } - await this._nameFieldRef.validate({allowEmpty: false}); - if (this._aliasFieldRef) { - await this._aliasFieldRef.validate({allowEmpty: false}); + await this.nameField.current.validate({allowEmpty: false}); + if (this.aliasField.current) { + await this.aliasField.current.validate({allowEmpty: false}); } // Validation and state updates are async, so we need to wait for them to complete // first. Queue a `setState` callback and wait for it to resolve. - await new Promise(resolve => this.setState({}, resolve)); - if (this.state.nameIsValid && (!this._aliasFieldRef || this._aliasFieldRef.isValid)) { - this.props.onFinished(true, this._roomCreateOptions()); + await new Promise(resolve => this.setState({}, resolve)); + if (this.state.nameIsValid && (!this.aliasField.current || this.aliasField.current.isValid)) { + this.props.onFinished(true, this.roomCreateOptions()); } else { let field; if (!this.state.nameIsValid) { - field = this._nameFieldRef; - } else if (this._aliasFieldRef && !this._aliasFieldRef.isValid) { - field = this._aliasFieldRef; + field = this.nameField.current; + } else if (this.aliasField.current && !this.aliasField.current.isValid) { + field = this.aliasField.current; } if (field) { field.focus(); @@ -143,49 +159,45 @@ export default class CreateRoomDialog extends React.Component { } }; - onCancel = () => { + private onCancel = () => { this.props.onFinished(false); }; - onNameChange = ev => { - this.setState({name: ev.target.value}); + private onNameChange = (ev: ChangeEvent) => { + this.setState({ name: ev.target.value }); }; - onTopicChange = ev => { - this.setState({topic: ev.target.value}); + private onTopicChange = (ev: ChangeEvent) => { + this.setState({ topic: ev.target.value }); }; - onPublicChange = isPublic => { - this.setState({isPublic}); + private onPublicChange = (isPublic: boolean) => { + this.setState({ isPublic }); }; - onEncryptedChange = isEncrypted => { - this.setState({isEncrypted}); + private onEncryptedChange = (isEncrypted: boolean) => { + this.setState({ isEncrypted }); }; - onAliasChange = alias => { - this.setState({alias}); + private onAliasChange = (alias: string) => { + this.setState({ alias }); }; - onDetailsToggled = ev => { - this.setState({detailsOpen: ev.target.open}); + private onDetailsToggled = (ev: SyntheticEvent) => { + this.setState({ detailsOpen: (ev.target as HTMLDetailsElement).open }); }; - onNoFederateChange = noFederate => { - this.setState({noFederate}); + private onNoFederateChange = (noFederate: boolean) => { + this.setState({ noFederate }); }; - collectDetailsRef = ref => { - this._detailsRef = ref; - }; - - onNameValidate = async fieldState => { - const result = await CreateRoomDialog._validateRoomName(fieldState); + private onNameValidate = async (fieldState: IFieldState) => { + const result = await CreateRoomDialog.validateRoomName(fieldState); this.setState({nameIsValid: result.valid}); return result; }; - static _validateRoomName = withValidation({ + private static validateRoomName = withValidation({ rules: [ { key: "required", @@ -196,18 +208,17 @@ export default class CreateRoomDialog extends React.Component { }); render() { - const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); - const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); - const Field = sdk.getComponent('views.elements.Field'); - const LabelledToggleSwitch = sdk.getComponent('views.elements.LabelledToggleSwitch'); - const RoomAliasField = sdk.getComponent('views.elements.RoomAliasField'); - let aliasField; if (this.state.isPublic) { const domain = MatrixClientPeg.get().getDomain(); aliasField = (

    - this._aliasFieldRef = ref} onChange={this.onAliasChange} domain={domain} value={this.state.alias} /> +
    ); } @@ -270,16 +281,34 @@ export default class CreateRoomDialog extends React.Component { -
    +
    - this._nameFieldRef = ref} label={ _t('Name') } onChange={this.onNameChange} onValidate={this.onNameValidate} value={this.state.name} className="mx_CreateRoomDialog_name" /> - - + + + { publicPrivateLabel } { e2eeSection } { aliasField } -
    - { this.state.detailsOpen ? _t('Hide advanced') : _t('Show advanced') } +
    + + { this.state.detailsOpen ? _t('Hide advanced') : _t('Show advanced') } + ; + return ; } } @@ -155,7 +163,7 @@ export class SendCustomEvent extends GenericEditor {
    + autoComplete="off" value={this.state.evContent} onChange={this._onChange} element="textarea" />
    @@ -239,7 +247,7 @@ class SendAccountData extends GenericEditor {
    + autoComplete="off" value={this.state.evContent} onChange={this._onChange} element="textarea" />
    @@ -315,15 +323,15 @@ class FilteredList extends React.PureComponent { const TruncatedList = sdk.getComponent("elements.TruncatedList"); return
    + type="text" autoComplete="off" value={this.props.query} onChange={this.onQuery} + className="mx_TextInputDialog_input mx_DevTools_RoomStateExplorer_query" + // force re-render so that autoFocus is applied when this component is re-used + key={this.props.children[0] ? this.props.children[0].key : ''} /> + getChildCount={this.getChildCount} + truncateAt={this.state.truncateAt} + createOverflowElement={this.createOverflowElement} />
    ; } } @@ -647,7 +655,7 @@ function VerificationRequest({txnId, request}) { /* Note that request.timeout is a getter, so its value changes */ const id = setInterval(() => { - setRequestTimeout(request.timeout); + setRequestTimeout(request.timeout); }, 500); return () => { clearInterval(id); }; @@ -941,35 +949,35 @@ class SettingsExplorer extends React.Component { /> - - - - - + + + + + - {allSettings.map(i => ( - - + - - - - ))} + + + + + + ))}
    {_t("Setting ID")}{_t("Value")}{_t("Value in this room")}
    {_t("Setting ID")}{_t("Value")}{_t("Value in this room")}
    - this.onViewClick(e, i)}> - {i} - - this.onEditClick(e, i)} - className='mx_DevTools_SettingsExplorer_edit' - > + {allSettings.map(i => ( +
    + this.onViewClick(e, i)}> + {i} + + this.onEditClick(e, i)} + className='mx_DevTools_SettingsExplorer_edit' + > ✏ - - - {this.renderSettingValue(SettingsStore.getValue(i))} - - - {this.renderSettingValue(SettingsStore.getValue(i, room.roomId))} - -
    + {this.renderSettingValue(SettingsStore.getValue(i))} + + + {this.renderSettingValue(SettingsStore.getValue(i, room.roomId))} + +
    @@ -998,11 +1006,11 @@ class SettingsExplorer extends React.Component {
    - - - - - + + + + + {LEVEL_ORDER.map(lvl => ( diff --git a/src/components/views/dialogs/IncomingSasDialog.js b/src/components/views/dialogs/IncomingSasDialog.js index f18b7a9d0c..5df02d7a6f 100644 --- a/src/components/views/dialogs/IncomingSasDialog.js +++ b/src/components/views/dialogs/IncomingSasDialog.js @@ -130,7 +130,7 @@ export default class IncomingSasDialog extends React.Component { const oppProfile = this.state.opponentProfile; if (oppProfile) { const url = oppProfile.avatar_url - ? mediaFromMxc(oppProfile.avatar_url).getSquareThumbnailHttp(Math.floor(48 * window.devicePixelRatio)) + ? mediaFromMxc(oppProfile.avatar_url).getSquareThumbnailHttp(48) : null; profile =
    +

    {_t("Enable 'Manage Integrations' in Settings to do this.")}

    diff --git a/src/components/views/dialogs/IntegrationsImpossibleDialog.js b/src/components/views/dialogs/IntegrationsImpossibleDialog.js index 9bc9d02ba6..e14d40aaef 100644 --- a/src/components/views/dialogs/IntegrationsImpossibleDialog.js +++ b/src/components/views/dialogs/IntegrationsImpossibleDialog.js @@ -37,9 +37,12 @@ export default class IntegrationsImpossibleDialog extends React.Component { const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); return ( - +

    {_t( diff --git a/src/components/views/dialogs/InviteDialog.tsx b/src/components/views/dialogs/InviteDialog.tsx index a274f96a17..ec9c71ccbe 100644 --- a/src/components/views/dialogs/InviteDialog.tsx +++ b/src/components/views/dialogs/InviteDialog.tsx @@ -31,6 +31,7 @@ import Modal from "../../../Modal"; import {humanizeTime} from "../../../utils/humanize"; import createRoom, { canEncryptToAllUsers, ensureDMExists, findDMForUser, privateShouldBeEncrypted, + IInvite3PID, } from "../../../createRoom"; import {inviteMultipleToRoom, showCommunityInviteDialog} from "../../../RoomInvite"; import {Key} from "../../../Keyboard"; @@ -618,13 +619,14 @@ export default class InviteDialog extends React.PureComponent { this.setState({busy: true}); + const client = MatrixClientPeg.get(); const targets = this._convertFilter(); const targetIds = targets.map(t => t.userId); // Check if there is already a DM with these people and reuse it if possible. let existingRoom: Room; if (targetIds.length === 1) { - existingRoom = findDMForUser(MatrixClientPeg.get(), targetIds[0]); + existingRoom = findDMForUser(client, targetIds[0]); } else { existingRoom = DMRoomMap.shared().getDMRoomForIdentifiers(targetIds); } @@ -646,7 +648,6 @@ export default class InviteDialog extends React.PureComponent t instanceof ThreepidMember); if (!has3PidMembers) { - const client = MatrixClientPeg.get(); const allHaveDeviceKeys = await canEncryptToAllUsers(client, targetIds); if (allHaveDeviceKeys) { createRoomOptions.encryption = true; @@ -656,35 +657,41 @@ export default class InviteDialog extends React.PureComponent; - const isSelf = targetIds.length === 1 && targetIds[0] === MatrixClientPeg.get().getUserId(); - if (targetIds.length === 1 && !isSelf) { - createRoomOptions.dmUserId = targetIds[0]; - createRoomPromise = createRoom(createRoomOptions); - } else if (isSelf) { - createRoomPromise = createRoom(createRoomOptions); - } else { - // Create a boring room and try to invite the targets manually. - createRoomPromise = createRoom(createRoomOptions).then(roomId => { - return inviteMultipleToRoom(roomId, targetIds); - }).then(result => { - if (this._shouldAbortAfterInviteError(result)) { - return true; // abort - } - }); - } + try { + const isSelf = targetIds.length === 1 && targetIds[0] === client.getUserId(); + if (targetIds.length === 1 && !isSelf) { + createRoomOptions.dmUserId = targetIds[0]; + } - // the createRoom call will show the room for us, so we don't need to worry about that. - createRoomPromise.then(abort => { - if (abort === true) return; // only abort on true booleans, not roomIds or something + if (targetIds.length > 1) { + createRoomOptions.createOpts = targetIds.reduce( + (roomOptions, address) => { + const type = getAddressType(address); + if (type === 'email') { + const invite: IInvite3PID = { + id_server: client.getIdentityServerUrl(true), + medium: 'email', + address, + }; + roomOptions.invite_3pid.push(invite); + } else if (type === 'mx-user-id') { + roomOptions.invite.push(address); + } + return roomOptions; + }, + { invite: [], invite_3pid: [] }, + ) + } + + await createRoom(createRoomOptions); this.props.onFinished(); - }).catch(err => { + } catch (err) { console.error(err); this.setState({ busy: false, errorText: _t("We couldn't create your DM."), }); - }); + } }; _inviteUsers = async () => { @@ -712,8 +719,7 @@ export default class InviteDialog extends React.PureComponent {success ? - {_t("Upload completed")} : - cancelled ? - {_t("Cancelled signature upload")} : - {_t("Unable to upload")}} + {_t("Upload completed")} : + cancelled ? + {_t("Cancelled signature upload")} : + {_t("Unable to upload")}} + {content} ); diff --git a/src/components/views/dialogs/ModalWidgetDialog.tsx b/src/components/views/dialogs/ModalWidgetDialog.tsx index 59eaab7b81..0c474b160c 100644 --- a/src/components/views/dialogs/ModalWidgetDialog.tsx +++ b/src/components/views/dialogs/ModalWidgetDialog.tsx @@ -1,5 +1,5 @@ /* -Copyright 2020 The Matrix.org Foundation C.I.C. +Copyright 2020, 2021 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. @@ -16,7 +16,7 @@ limitations under the License. import * as React from 'react'; import BaseDialog from './BaseDialog'; -import { _t } from '../../../languageHandler'; +import { _t, getUserLanguage } from '../../../languageHandler'; import AccessibleButton from "../elements/AccessibleButton"; import { ClientWidgetApi, @@ -39,6 +39,8 @@ import {OwnProfileStore} from "../../../stores/OwnProfileStore"; import { arrayFastClone } from "../../../utils/arrays"; import { ElementWidget } from "../../../stores/widgets/StopGapWidget"; import {replaceableComponent} from "../../../utils/replaceableComponent"; +import {ELEMENT_CLIENT_ID} from "../../../identifiers"; +import SettingsStore from "../../../settings/SettingsStore"; interface IProps { widgetDefinition: IModalWidgetOpenRequestData; @@ -129,6 +131,9 @@ export default class ModalWidgetDialog extends React.PureComponent +

    diff --git a/src/components/views/dialogs/ServerPickerDialog.tsx b/src/components/views/dialogs/ServerPickerDialog.tsx index 4abc0a88b1..11fef9e75d 100644 --- a/src/components/views/dialogs/ServerPickerDialog.tsx +++ b/src/components/views/dialogs/ServerPickerDialog.tsx @@ -1,5 +1,5 @@ /* -Copyright 2020 The Matrix.org Foundation C.I.C. +Copyright 2020-2021 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. @@ -110,7 +110,7 @@ export default class ServerPickerDialog extends React.PureComponent @@ -217,6 +217,7 @@ export default class ServerPickerDialog extends React.PureComponent

    diff --git a/src/components/views/dialogs/SeshatResetDialog.tsx b/src/components/views/dialogs/SeshatResetDialog.tsx index 135f5d8197..63654ca949 100644 --- a/src/components/views/dialogs/SeshatResetDialog.tsx +++ b/src/components/views/dialogs/SeshatResetDialog.tsx @@ -36,7 +36,7 @@ export default class SeshatResetDialog extends React.PureComponent {_t("You most likely do not want to reset your event index store")}
    {_t("If you do, please note that none of your messages will be deleted, " + - "but the search experience might be degraded for a few moments" + + "but the search experience might be degraded for a few moments " + "whilst the index is recreated", )}

    diff --git a/src/components/views/dialogs/SessionRestoreErrorDialog.js b/src/components/views/dialogs/SessionRestoreErrorDialog.js index 50d7fbea09..43e73a2f83 100644 --- a/src/components/views/dialogs/SessionRestoreErrorDialog.js +++ b/src/components/views/dialogs/SessionRestoreErrorDialog.js @@ -98,7 +98,7 @@ export default class SessionRestoreErrorDialog extends React.Component { "may be incompatible with this version. Close this window and return " + "to the more recent version.", { brand }, - ) }

    + ) }

    { _t( "Clearing your browser's storage may fix the problem, but will sign you " + diff --git a/src/components/views/dialogs/SpaceSettingsDialog.tsx b/src/components/views/dialogs/SpaceSettingsDialog.tsx index 83f5d7141b..dc6052650a 100644 --- a/src/components/views/dialogs/SpaceSettingsDialog.tsx +++ b/src/components/views/dialogs/SpaceSettingsDialog.tsx @@ -32,6 +32,7 @@ import Modal from "../../../Modal"; import defaultDispatcher from "../../../dispatcher/dispatcher"; import {allSettled} from "../../../utils/promise"; import {useDispatcher} from "../../../hooks/useDispatcher"; +import {SpaceFeedbackPrompt} from "../../structures/SpaceRoomView"; interface IProps extends IDialogProps { matrixClient: MatrixClient; @@ -111,15 +112,17 @@ const SpaceSettingsDialog: React.FC = ({ matrixClient: cli, space, onFin { error &&

    { error }
    } + onFinished(false)} /> + diff --git a/src/components/views/dialogs/StorageEvictedDialog.js b/src/components/views/dialogs/StorageEvictedDialog.js index 15c5347644..1e17ab1738 100644 --- a/src/components/views/dialogs/StorageEvictedDialog.js +++ b/src/components/views/dialogs/StorageEvictedDialog.js @@ -45,10 +45,12 @@ export default class StorageEvictedDialog extends React.Component { let logRequest; if (SdkConfig.get().bug_report_endpoint_url) { logRequest = _t( - "To help us prevent this in future, please send us logs.", {}, - { - a: text => {text}, - }); + "To help us prevent this in future, please send us logs.", + {}, + { + a: text => {text}, + }, + ); } return ( diff --git a/src/components/views/dialogs/UntrustedDeviceDialog.tsx b/src/components/views/dialogs/UntrustedDeviceDialog.tsx new file mode 100644 index 0000000000..da1492e30d --- /dev/null +++ b/src/components/views/dialogs/UntrustedDeviceDialog.tsx @@ -0,0 +1,73 @@ +/* +Copyright 2019, 2020, 2021 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 { User } from "matrix-js-sdk/src/models/user"; + +import { _t } from "../../../languageHandler"; +import { MatrixClientPeg } from "../../../MatrixClientPeg"; +import E2EIcon from "../rooms/E2EIcon"; +import AccessibleButton from "../elements/AccessibleButton"; +import BaseDialog from "./BaseDialog"; +import { IDialogProps } from "./IDialogProps"; +import { IDevice } from "../right_panel/UserInfo"; + +interface IProps extends IDialogProps { + user: User; + device: IDevice; +} + +const UntrustedDeviceDialog: React.FC = ({device, user, onFinished}) => { + let askToVerifyText; + let newSessionText; + + if (MatrixClientPeg.get().getUserId() === user.userId) { + newSessionText = _t("You signed in to a new session without verifying it:"); + askToVerifyText = _t("Verify your other session using one of the options below."); + } else { + newSessionText = _t("%(name)s (%(userId)s) signed in to a new session without verifying it:", + {name: user.displayName, userId: user.userId}); + askToVerifyText = _t("Ask this user to verify their session, or manually verify it below."); + } + + return + + { _t("Not Trusted")} + } + > +
    +

    {newSessionText}

    +

    {device.getDisplayName()} ({device.deviceId})

    +

    {askToVerifyText}

    +
    +
    + onFinished("legacy")}> + { _t("Manually Verify by Text") } + + onFinished("sas")}> + { _t("Interactively verify by Emoji") } + + onFinished(false)}> + { _t("Done") } + +
    +
    ; +}; + +export default UntrustedDeviceDialog; diff --git a/src/components/views/dialogs/UploadConfirmDialog.js b/src/components/views/dialogs/UploadConfirmDialog.tsx similarity index 72% rename from src/components/views/dialogs/UploadConfirmDialog.js rename to src/components/views/dialogs/UploadConfirmDialog.tsx index 2ff16b9440..7f6bcd27d1 100644 --- a/src/components/views/dialogs/UploadConfirmDialog.js +++ b/src/components/views/dialogs/UploadConfirmDialog.tsx @@ -1,5 +1,5 @@ /* -Copyright 2019 New Vector Ltd +Copyright 2019, 2021 The Matrix.org Foundation C.I.C. Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> Licensed under the Apache License, Version 2.0 (the "License"); @@ -16,20 +16,23 @@ limitations under the License. */ import React from 'react'; -import PropTypes from 'prop-types'; import * as sdk from '../../../index'; import { _t } from '../../../languageHandler'; import filesize from "filesize"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import { getBlobSafeMimeType } from '../../../utils/blobs'; + +interface IProps { + file: File; + currentIndex: number; + totalFiles?: number; + onFinished: (uploadConfirmed: boolean, uploadAll?: boolean) => void; +} @replaceableComponent("views.dialogs.UploadConfirmDialog") -export default class UploadConfirmDialog extends React.Component { - static propTypes = { - file: PropTypes.object.isRequired, - currentIndex: PropTypes.number, - totalFiles: PropTypes.number, - onFinished: PropTypes.func.isRequired, - } +export default class UploadConfirmDialog extends React.Component { + private objectUrl: string; + private mimeType: string; static defaultProps = { totalFiles: 1, @@ -38,22 +41,28 @@ export default class UploadConfirmDialog extends React.Component { constructor(props) { super(props); - this._objectUrl = URL.createObjectURL(props.file); + // Create a fresh `Blob` for previewing (even though `File` already is + // one) so we can adjust the MIME type if needed. + this.mimeType = getBlobSafeMimeType(props.file.type); + const blob = new Blob([props.file], { type: + this.mimeType, + }); + this.objectUrl = URL.createObjectURL(blob); } componentWillUnmount() { - if (this._objectUrl) URL.revokeObjectURL(this._objectUrl); + if (this.objectUrl) URL.revokeObjectURL(this.objectUrl); } - _onCancelClick = () => { + private onCancelClick = () => { this.props.onFinished(false); } - _onUploadClick = () => { + private onUploadClick = () => { this.props.onFinished(true); } - _onUploadAllClick = () => { + private onUploadAllClick = () => { this.props.onFinished(true, true); } @@ -75,10 +84,10 @@ export default class UploadConfirmDialog extends React.Component { } let preview; - if (this.props.file.type.startsWith('image/')) { + if (this.mimeType.startsWith('image/')) { preview =
    -
    +
    {this.props.file.name} ({filesize(this.props.file.size)})
    ; @@ -95,7 +104,7 @@ export default class UploadConfirmDialog extends React.Component { let uploadAllButton; if (this.props.currentIndex + 1 < this.props.totalFiles) { - uploadAllButton = ; } @@ -103,7 +112,7 @@ export default class UploadConfirmDialog extends React.Component { return ( @@ -113,7 +122,7 @@ export default class UploadConfirmDialog extends React.Component { {uploadAllButton} diff --git a/src/components/views/dialogs/UserSettingsDialog.js b/src/components/views/dialogs/UserSettingsDialog.js index eb9eaeb5dd..fe29b85aea 100644 --- a/src/components/views/dialogs/UserSettingsDialog.js +++ b/src/components/views/dialogs/UserSettingsDialog.js @@ -125,7 +125,10 @@ export default class UserSettingsDialog extends React.Component { "mx_UserSettingsDialog_securityIcon", , )); - if (SdkConfig.get()['showLabsSettings']) { + // Show the Labs tab if enabled or if there are any active betas + if (SdkConfig.get()['showLabsSettings'] + || SettingsStore.getFeatureSettingNames().some(k => SettingsStore.getBetaInfo(k)) + ) { tabs.push(new Tab( USER_LABS_TAB, _td("Labs"), @@ -155,8 +158,12 @@ export default class UserSettingsDialog extends React.Component { const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); return ( - +
    diff --git a/src/components/views/dialogs/VerificationRequestDialog.js b/src/components/views/dialogs/VerificationRequestDialog.js index 205597a1c4..9281275e6a 100644 --- a/src/components/views/dialogs/VerificationRequestDialog.js +++ b/src/components/views/dialogs/VerificationRequestDialog.js @@ -52,11 +52,13 @@ export default class VerificationRequestDialog extends React.Component { const title = request && request.isSelfVerification ? _t("Verify other login") : _t("Verification Request"); - return + return +

    {_t("The widget will verify your user ID, but won't be able to perform actions for you:")} diff --git a/src/components/views/dialogs/security/AccessSecretStorageDialog.tsx b/src/components/views/dialogs/security/AccessSecretStorageDialog.tsx index 3c09470b39..e09b39f4c7 100644 --- a/src/components/views/dialogs/security/AccessSecretStorageDialog.tsx +++ b/src/components/views/dialogs/security/AccessSecretStorageDialog.tsx @@ -25,6 +25,8 @@ import Field from '../../elements/Field'; import AccessibleButton from '../../elements/AccessibleButton'; import {_t} from '../../../../languageHandler'; import {IDialogProps} from "../IDialogProps"; +import {accessSecretStorage} from "../../../../SecurityManager"; +import Modal from "../../../../Modal"; // Maximum acceptable size of a key file. It's 59 characters including the spaces we encode, // so this should be plenty and allow for people putting extra whitespace in the file because @@ -47,6 +49,7 @@ interface IState { forceRecoveryKey: boolean; passPhrase: string; keyMatches: boolean | null; + resetting: boolean; } /* @@ -66,10 +69,14 @@ export default class AccessSecretStorageDialog extends React.PureComponent { + if (this.state.resetting) { + this.setState({resetting: false}); + } this.props.onFinished(false); }; @@ -201,6 +208,55 @@ export default class AccessSecretStorageDialog extends React.PureComponent) => { + ev.preventDefault(); + this.setState({resetting: true}); + }; + + private onConfirmResetAllClick = async () => { + // Hide ourselves so the user can interact with the reset dialogs. + // We don't conclude the promise chain (onFinished) yet to avoid confusing + // any upstream code flows. + // + // Note: this will unmount us, so don't call `setState` or anything in the + // rest of this function. + Modal.toggleCurrentDialogVisibility(); + + try { + // Force reset secret storage (which resets the key backup) + await accessSecretStorage(async () => { + // Now reset cross-signing so everything Just Works™ again. + const cli = MatrixClientPeg.get(); + await cli.bootstrapCrossSigning({ + authUploadDeviceSigningKeys: async (makeRequest) => { + // XXX: Making this an import breaks the app. + const InteractiveAuthDialog = sdk.getComponent("views.dialogs.InteractiveAuthDialog"); + const {finished} = Modal.createTrackedDialog( + 'Cross-signing keys dialog', '', InteractiveAuthDialog, + { + title: _t("Setting up keys"), + matrixClient: cli, + makeRequest, + }, + ); + const [confirmed] = await finished; + if (!confirmed) { + throw new Error("Cross-signing key upload auth canceled"); + } + }, + setupNewCrossSigning: true, + }); + + // Now we can indicate that the user is done pressing buttons, finally. + // Upstream flows will detect the new secret storage, key backup, etc and use it. + this.props.onFinished(true); + }, true); + } catch (e) { + console.error(e); + this.props.onFinished(false); + } + }; + private getKeyValidationText(): string { if (this.state.recoveryKeyFileError) { return _t("Wrong file type"); @@ -216,8 +272,9 @@ export default class AccessSecretStorageDialog extends React.PureComponent + {_t("Forgotten or lost all recovery methods? Reset all", null, { + a: (sub) => {sub}, + })} +

    + ); + let content; let title; let titleClass; - if (hasPassphrase && !this.state.forceRecoveryKey) { - const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); + if (this.state.resetting) { + title = _t("Reset everything"); + titleClass = ['mx_AccessSecretStorageDialog_titleWithIcon mx_AccessSecretStorageDialog_resetBadge']; + content =
    +

    {_t("Only do this if you have no other device to complete verification with.")}

    +

    {_t("If you reset everything, you will restart with no trusted sessions, no trusted users, and " + + "might not be able to see past messages.")}

    + +
    ; + } else if (hasPassphrase && !this.state.forceRecoveryKey) { const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); title = _t("Security Phrase"); titleClass = ['mx_AccessSecretStorageDialog_titleWithIcon mx_AccessSecretStorageDialog_securePhraseTitle']; @@ -263,6 +345,7 @@ export default class AccessSecretStorageDialog extends React.PureComponent
    ; } else { title = _t("Security Key"); titleClass = ['mx_AccessSecretStorageDialog_titleWithIcon mx_AccessSecretStorageDialog_secureBackupTitle']; - const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); const feedbackClasses = classNames({ 'mx_AccessSecretStorageDialog_recoveryKeyFeedback': true, @@ -339,6 +422,7 @@ export default class AccessSecretStorageDialog extends React.PureComponent
    ; diff --git a/src/components/views/dialogs/security/ConfirmDestroyCrossSigningDialog.js b/src/components/views/dialogs/security/ConfirmDestroyCrossSigningDialog.js index 43fb25f152..e71983b074 100644 --- a/src/components/views/dialogs/security/ConfirmDestroyCrossSigningDialog.js +++ b/src/components/views/dialogs/security/ConfirmDestroyCrossSigningDialog.js @@ -40,10 +40,11 @@ export default class ConfirmDestroyCrossSigningDialog extends React.Component { return ( + className='mx_ConfirmDestroyCrossSigningDialog' + hasCancel={true} + onFinished={this.props.onFinished} + title={_t("Destroy cross-signing keys?")} + >

    {_t( diff --git a/src/components/views/dialogs/security/RestoreKeyBackupDialog.js b/src/components/views/dialogs/security/RestoreKeyBackupDialog.js index 1fafe03d95..4ac15ab5a3 100644 --- a/src/components/views/dialogs/security/RestoreKeyBackupDialog.js +++ b/src/components/views/dialogs/security/RestoreKeyBackupDialog.js @@ -373,21 +373,24 @@ export default class RestoreKeyBackupDialog extends React.PureComponent { {_t( "If you've forgotten your Security Phrase you can "+ "use your Security Key or " + - "set up new recovery options" - , {}, { - button1: s => - {s} - , - button2: s => - {s} - , - })} + "set up new recovery options", + {}, + { + button1: s => + {s} + , + button2: s => + {s} + , + })}

    ; } else { title = _t("Enter Security Key"); @@ -435,15 +438,17 @@ export default class RestoreKeyBackupDialog extends React.PureComponent { {_t( "If you've forgotten your Security Key you can "+ - "" - , {}, { - button: s => - {s} - , - })} + "", + {}, + { + button: s => + {s} + , + }, + )} ; } @@ -452,9 +457,9 @@ export default class RestoreKeyBackupDialog extends React.PureComponent { onFinished={this.props.onFinished} title={title} > -
    - {content} -
    +
    + {content} +
    ); } diff --git a/src/components/views/directory/NetworkDropdown.js b/src/components/views/directory/NetworkDropdown.tsx similarity index 76% rename from src/components/views/directory/NetworkDropdown.js rename to src/components/views/directory/NetworkDropdown.tsx index 2fabda1a74..66b7321ce0 100644 --- a/src/components/views/directory/NetworkDropdown.js +++ b/src/components/views/directory/NetworkDropdown.tsx @@ -1,7 +1,6 @@ /* -Copyright 2016 OpenMarket Ltd Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> -Copyright 2020 The Matrix.org Foundation C.I.C. +Copyright 2016, 2020 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. @@ -16,39 +15,54 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, {useEffect, useState} from 'react'; -import PropTypes from 'prop-types'; +import React, { useEffect, useState } from "react"; +import { MatrixError } from "matrix-js-sdk/src/http-api"; -import {MatrixClientPeg} from '../../../MatrixClientPeg'; -import {instanceForInstanceId} from '../../../utils/DirectoryUtils'; +import { MatrixClientPeg } from '../../../MatrixClientPeg'; +import { instanceForInstanceId } from '../../../utils/DirectoryUtils'; import { + ChevronFace, ContextMenu, - useContextMenu, ContextMenuButton, - MenuItemRadio, - MenuItem, MenuGroup, + MenuItem, + MenuItemRadio, + useContextMenu, } from "../../structures/ContextMenu"; -import {_t} from "../../../languageHandler"; +import { _t } from "../../../languageHandler"; import SdkConfig from "../../../SdkConfig"; -import {useSettingValue} from "../../../hooks/useSettings"; -import * as sdk from "../../../index"; +import { useSettingValue } from "../../../hooks/useSettings"; import Modal from "../../../Modal"; import SettingsStore from "../../../settings/SettingsStore"; import withValidation from "../elements/Validation"; +import { SettingLevel } from "../../../settings/SettingLevel"; +import TextInputDialog from "../dialogs/TextInputDialog"; +import QuestionDialog from "../dialogs/QuestionDialog"; export const ALL_ROOMS = Symbol("ALL_ROOMS"); const SETTING_NAME = "room_directory_servers"; -const inPlaceOf = (elementRect) => ({ +const inPlaceOf = (elementRect: Pick) => ({ right: window.innerWidth - elementRect.right, top: elementRect.top, chevronOffset: 0, - chevronFace: "none", + chevronFace: ChevronFace.None, }); -const validServer = withValidation({ +const validServer = withValidation({ + deriveData: async ({ value }) => { + try { + // check if we can successfully load this server's room directory + await MatrixClientPeg.get().publicRooms({ + limit: 1, + server: value, + }); + return {}; + } catch (error) { + return { error }; + } + }, rules: [ { key: "required", @@ -57,34 +71,58 @@ const validServer = withValidation({ }, { key: "available", final: true, - test: async ({ value }) => { - try { - const opts = { - limit: 1, - server: value, - }; - // check if we can successfully load this server's room directory - await MatrixClientPeg.get().publicRooms(opts); - return true; - } catch (e) { - return false; - } - }, + test: async (_, { error }) => !error, valid: () => _t("Looks good"), - invalid: () => _t("Can't find this server or its room list"), + invalid: ({ error }) => error.errcode === "M_FORBIDDEN" + ? _t("You are not allowed to view this server's rooms list") + : _t("Can't find this server or its room list"), }, ], }); +/* eslint-disable camelcase */ +export interface IFieldType { + regexp: string; + placeholder: string; +} + +export interface IInstance { + desc: string; + icon?: string; + fields: object; + network_id: string; + // XXX: this is undocumented but we rely on it. + // we inject a fake entry with a symbolic instance_id. + instance_id: string | symbol; +} + +export interface IProtocol { + user_fields: string[]; + location_fields: string[]; + icon: string; + field_types: Record; + instances: IInstance[]; +} +/* eslint-enable camelcase */ + +export type Protocols = Record; + +interface IProps { + protocols: Protocols; + selectedServerName: string; + selectedInstanceId: string | symbol; + onOptionChange(server: string, instanceId?: string | symbol): void; +} + // This dropdown sources homeservers from three places: // + your currently connected homeserver // + homeservers in config.json["roomDirectory"] // + homeservers in SettingsStore["room_directory_servers"] // if a server exists in multiple, only keep the top-most entry. -const NetworkDropdown = ({onOptionChange, protocols = {}, selectedServerName, selectedInstanceId}) => { - const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu(); - const _userDefinedServers = useSettingValue(SETTING_NAME); +const NetworkDropdown = ({ onOptionChange, protocols = {}, selectedServerName, selectedInstanceId }: IProps) => { + const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu(); + const _userDefinedServers: string[] = useSettingValue(SETTING_NAME); const [userDefinedServers, _setUserDefinedServers] = useState(_userDefinedServers); const handlerFactory = (server, instanceId) => { @@ -96,7 +134,7 @@ const NetworkDropdown = ({onOptionChange, protocols = {}, selectedServerName, se const setUserDefinedServers = servers => { _setUserDefinedServers(servers); - SettingsStore.setValue(SETTING_NAME, null, "account", servers); + SettingsStore.setValue(SETTING_NAME, null, SettingLevel.ACCOUNT, servers); }; // keep local echo up to date with external changes useEffect(() => { @@ -110,7 +148,7 @@ const NetworkDropdown = ({onOptionChange, protocols = {}, selectedServerName, se const roomDirectory = config.roomDirectory || {}; const hsName = MatrixClientPeg.getHomeserverName(); - const configServers = new Set(roomDirectory.servers); + const configServers = new Set(roomDirectory.servers); // configured servers take preference over user-defined ones, if one occurs in both ignore the latter one. const removableServers = new Set(userDefinedServers.filter(s => !configServers.has(s) && s !== hsName)); @@ -134,9 +172,15 @@ const NetworkDropdown = ({onOptionChange, protocols = {}, selectedServerName, se // add a fake protocol with the ALL_ROOMS symbol protocolsList.push({ instances: [{ + fields: [], + network_id: "", instance_id: ALL_ROOMS, desc: _t("All rooms"), }], + location_fields: [], + user_fields: [], + field_types: {}, + icon: "", }); } @@ -170,7 +214,6 @@ const NetworkDropdown = ({onOptionChange, protocols = {}, selectedServerName, se if (removableServers.has(server)) { const onClick = async () => { closeMenu(); - const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); const {finished} = Modal.createTrackedDialog("Network Dropdown", "Remove server", QuestionDialog, { title: _t("Are you sure?"), description: _t("Are you sure you want to remove %(serverName)s", { @@ -189,7 +232,7 @@ const NetworkDropdown = ({onOptionChange, protocols = {}, selectedServerName, se setUserDefinedServers(servers.filter(s => s !== server)); // the selected server is being removed, reset to our HS - if (serverSelected === server) { + if (serverSelected) { onOptionChange(hsName, undefined); } }; @@ -221,7 +264,6 @@ const NetworkDropdown = ({onOptionChange, protocols = {}, selectedServerName, se const onClick = async () => { closeMenu(); - const TextInputDialog = sdk.getComponent("dialogs.TextInputDialog"); const { finished } = Modal.createTrackedDialog("Network Dropdown", "Add a new server", TextInputDialog, { title: _t("Add a new server"), description: _t("Enter the name of a new server you want to explore."), @@ -282,9 +324,4 @@ const NetworkDropdown = ({onOptionChange, protocols = {}, selectedServerName, se ; }; -NetworkDropdown.propTypes = { - onOptionChange: PropTypes.func.isRequired, - protocols: PropTypes.object, -}; - export default NetworkDropdown; diff --git a/src/components/views/elements/ActionButton.js b/src/components/views/elements/ActionButton.js index 1714891cb5..5013bcec0d 100644 --- a/src/components/views/elements/ActionButton.js +++ b/src/components/views/elements/ActionButton.js @@ -32,6 +32,7 @@ export default class ActionButton extends React.Component { label: PropTypes.string.isRequired, iconPath: PropTypes.string, className: PropTypes.string, + children: PropTypes.node, }; static defaultProps = { @@ -70,8 +71,8 @@ export default class ActionButton extends React.Component { } const icon = this.props.iconPath ? - () : - undefined; + () : + undefined; const classNames = ["mx_RoleButton"]; if (this.props.className) { @@ -79,7 +80,8 @@ export default class ActionButton extends React.Component { } return ( - { icon } { tooltip } + { this.props.children } ); } diff --git a/src/components/views/elements/AppTile.js b/src/components/views/elements/AppTile.js index e206fda797..b898ad2ebc 100644 --- a/src/components/views/elements/AppTile.js +++ b/src/components/views/elements/AppTile.js @@ -109,7 +109,7 @@ export default class AppTile extends React.Component { const childContentProtocol = u.protocol; if (parentContentProtocol === 'https:' && childContentProtocol !== 'https:') { console.warn("Refusing to load mixed-content app:", - parentContentProtocol, childContentProtocol, window.location, this.props.app.url); + parentContentProtocol, childContentProtocol, window.location, this.props.app.url); return true; } return false; diff --git a/src/components/views/elements/EditableItemList.js b/src/components/views/elements/EditableItemList.js index ff62f169fa..d8ec5af278 100644 --- a/src/components/views/elements/EditableItemList.js +++ b/src/components/views/elements/EditableItemList.js @@ -65,12 +65,18 @@ export class EditableItem extends React.Component { {_t("Are you sure?")} - + {_t("Yes")} - + {_t("No")} @@ -121,11 +127,15 @@ export default class EditableItemList extends React.Component { _renderNewItemField() { return ( -
    + + autoComplete="off" value={this.props.newItem || ""} onChange={this._onNewItemChanged} + list={this.props.suggestionsListId} /> {_t("Add")} diff --git a/src/components/views/elements/EditableText.js b/src/components/views/elements/EditableText.js index 638fd02553..7c38ac1777 100644 --- a/src/components/views/elements/EditableText.js +++ b/src/components/views/elements/EditableText.js @@ -221,13 +221,15 @@ export default class EditableText extends React.Component { ; } else { // show the content editable div, but manually manage its contents as react and contentEditable don't play nice together - editableEl =
    ; + editableEl =
    ; } return editableEl; diff --git a/src/components/views/elements/EffectsOverlay.tsx b/src/components/views/elements/EffectsOverlay.tsx index 38be8da9a8..7bed0222b0 100644 --- a/src/components/views/elements/EffectsOverlay.tsx +++ b/src/components/views/elements/EffectsOverlay.tsx @@ -37,7 +37,7 @@ const EffectsOverlay: FunctionComponent = ({ roomWidth }) => { effect = new Effect(options); effectsRef.current[name] = effect; } catch (err) { - console.warn('Unable to load effect module at \'../../../effects/${name}\'.', err); + console.warn(`Unable to load effect module at '../../../effects/${name}.`, err); } } return effect; diff --git a/src/components/views/elements/EventTilePreview.tsx b/src/components/views/elements/EventTilePreview.tsx index 077c116873..b15fbbed2b 100644 --- a/src/components/views/elements/EventTilePreview.tsx +++ b/src/components/views/elements/EventTilePreview.tsx @@ -55,22 +55,10 @@ interface IProps { * The mxc:// avatar URL of the displayed user */ avatarUrl?: string; - - /** - * Whether the EventTile should appear faded - */ - faded?: boolean; - - /** - * Callback for when the component is clicked - */ - onClick?: () => void; } interface IState { message: string; - faded: boolean; - eventTileKey: number; } const AVATAR_SIZE = 32; @@ -81,23 +69,9 @@ export default class EventTilePreview extends React.Component { super(props); this.state = { message: props.message, - faded: !!props.faded, - eventTileKey: 0, }; } - changeMessage(message: string) { - this.setState({ - message, - // Change the EventTile key to force React to create a new instance - eventTileKey: this.state.eventTileKey + 1, - }); - } - - unfade() { - this.setState({ faded: false }); - } - private fakeEvent({message}: IState) { // Fake it till we make it /* eslint-disable quote-props */ @@ -147,12 +121,10 @@ export default class EventTilePreview extends React.Component { const className = classnames(this.props.className, { "mx_IRCLayout": this.props.layout == Layout.IRC, "mx_GroupLayout": this.props.layout == Layout.Group, - "mx_EventTilePreview_faded": this.state.faded, }); - return
    + return
    { const isKnownMember = (member: RoomMember) => !!DMRoomMap.shared().getDMRoomsForUserId(member.userId)?.length; const FacePile = ({ room, onlyKnownUsers = true, numShown = DEFAULT_NUM_FACES, ...props }: IProps) => { + const cli = useContext(MatrixClientContext); let members = useRoomMembers(room); // sort users with an explicit avatar first @@ -46,21 +48,42 @@ const FacePile = ({ room, onlyKnownUsers = true, numShown = DEFAULT_NUM_FACES, . // sort known users first iteratees.unshift(member => isKnownMember(member)); } - if (members.length < 1) return null; - const shownMembers = sortBy(members, iteratees).slice(0, numShown); + // exclude ourselves from the shown members list + const shownMembers = sortBy(members.filter(m => m.userId !== cli.getUserId()), iteratees).slice(0, numShown); + if (shownMembers.length < 1) return null; + + // We reverse the order of the shown faces in CSS to simplify their visual overlap, + // reverse members in tooltip order to make the order between the two match up. + const commaSeparatedMembers = shownMembers.map(m => m.rawDisplayName).reverse().join(", "); + + let tooltip: ReactNode; + if (props.onClick) { + tooltip =
    +
    + { _t("View all %(count)s members", { count: members.length }) } +
    +
    + { _t("Including %(commaSeparatedMembers)s", { commaSeparatedMembers }) } +
    +
    ; + } else { + tooltip = _t("%(count)s members including %(commaSeparatedMembers)s", { + count: members.length, + commaSeparatedMembers, + }); + } + return
    -
    - { shownMembers.map(member => { - return - - ; - }) } -
    - { onlyKnownUsers && + + { members.length > numShown ? : null } + { shownMembers.map(m => + )} + + { onlyKnownUsers && { _t("%(count)s people you know have already joined", { count: members.length }) } } -
    +
    ; }; export default FacePile; diff --git a/src/components/views/elements/Field.tsx b/src/components/views/elements/Field.tsx index f5754da9ae..59d9a11596 100644 --- a/src/components/views/elements/Field.tsx +++ b/src/components/views/elements/Field.tsx @@ -262,7 +262,7 @@ export default class Field extends React.PureComponent { tooltipClassName={classNames("mx_Field_tooltip", tooltipClassName)} visible={(this.state.focused && this.props.forceTooltipVisible) || this.state.feedbackVisible} label={tooltipContent || this.state.feedback} - forceOnRight + alignment={Tooltip.Alignment.Right} />; } diff --git a/src/components/views/elements/ImageView.js b/src/components/views/elements/ImageView.js deleted file mode 100644 index 96b6de832d..0000000000 --- a/src/components/views/elements/ImageView.js +++ /dev/null @@ -1,235 +0,0 @@ -/* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> - -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 PropTypes from 'prop-types'; -import {MatrixClientPeg} from "../../../MatrixClientPeg"; -import {formatDate} from '../../../DateUtils'; -import { _t } from '../../../languageHandler'; -import filesize from "filesize"; -import AccessibleButton from "./AccessibleButton"; -import Modal from "../../../Modal"; -import * as sdk from "../../../index"; -import {Key} from "../../../Keyboard"; -import FocusLock from "react-focus-lock"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; - -@replaceableComponent("views.elements.ImageView") -export default class ImageView extends React.Component { - static propTypes = { - src: PropTypes.string.isRequired, // the source of the image being displayed - name: PropTypes.string, // the main title ('name') for the image - link: PropTypes.string, // the link (if any) applied to the name of the image - width: PropTypes.number, // width of the image src in pixels - height: PropTypes.number, // height of the image src in pixels - fileSize: PropTypes.number, // size of the image src in bytes - onFinished: PropTypes.func.isRequired, // callback when the lightbox is dismissed - - // the event (if any) that the Image is displaying. Used for event-specific stuff like - // redactions, senders, timestamps etc. Other descriptors are taken from the explicit - // properties above, which let us use lightboxes to display images which aren't associated - // with events. - mxEvent: PropTypes.object, - }; - - constructor(props) { - super(props); - this.state = { rotationDegrees: 0 }; - } - - onKeyDown = (ev) => { - if (ev.key === Key.ESCAPE) { - ev.stopPropagation(); - ev.preventDefault(); - this.props.onFinished(); - } - }; - - onRedactClick = () => { - const ConfirmRedactDialog = sdk.getComponent("dialogs.ConfirmRedactDialog"); - Modal.createTrackedDialog('Confirm Redact Dialog', 'Image View', ConfirmRedactDialog, { - onFinished: (proceed) => { - if (!proceed) return; - this.props.onFinished(); - MatrixClientPeg.get().redactEvent( - this.props.mxEvent.getRoomId(), this.props.mxEvent.getId(), - ).catch(function(e) { - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - // display error message stating you couldn't delete this. - const code = e.errcode || e.statusCode; - Modal.createTrackedDialog('You cannot delete this image.', '', ErrorDialog, { - title: _t('Error'), - description: _t('You cannot delete this image. (%(code)s)', {code: code}), - }); - }); - }, - }); - }; - - getName() { - let name = this.props.name; - if (name && this.props.link) { - name = { name }; - } - return name; - } - - rotateCounterClockwise = () => { - const cur = this.state.rotationDegrees; - const rotationDegrees = (cur - 90) % 360; - this.setState({ rotationDegrees }); - }; - - rotateClockwise = () => { - const cur = this.state.rotationDegrees; - const rotationDegrees = (cur + 90) % 360; - this.setState({ rotationDegrees }); - }; - - render() { -/* - // In theory max-width: 80%, max-height: 80% on the CSS should work - // but in practice, it doesn't, so do it manually: - - var width = this.props.width || 500; - var height = this.props.height || 500; - - var maxWidth = document.documentElement.clientWidth * 0.8; - var maxHeight = document.documentElement.clientHeight * 0.8; - - var widthFrac = width / maxWidth; - var heightFrac = height / maxHeight; - - var displayWidth; - var displayHeight; - if (widthFrac > heightFrac) { - displayWidth = Math.min(width, maxWidth); - displayHeight = (displayWidth / width) * height; - } else { - displayHeight = Math.min(height, maxHeight); - displayWidth = (displayHeight / height) * width; - } - - var style = { - width: displayWidth, - height: displayHeight - }; -*/ - let style = {}; - let res; - - if (this.props.width && this.props.height) { - style = { - width: this.props.width, - height: this.props.height, - }; - res = style.width + "x" + style.height + "px"; - } - - let size; - if (this.props.fileSize) { - size = filesize(this.props.fileSize); - } - - let sizeRes; - if (size && res) { - sizeRes = size + ", " + res; - } else { - sizeRes = size || res; - } - - let mayRedact = false; - const showEventMeta = !!this.props.mxEvent; - - let eventMeta; - if (showEventMeta) { - // Figure out the sender, defaulting to mxid - let sender = this.props.mxEvent.getSender(); - const cli = MatrixClientPeg.get(); - const room = cli.getRoom(this.props.mxEvent.getRoomId()); - if (room) { - mayRedact = room.currentState.maySendRedactionForEvent(this.props.mxEvent, cli.credentials.userId); - const member = room.getMember(sender); - if (member) sender = member.name; - } - - eventMeta = (
    - { _t('Uploaded on %(date)s by %(user)s', { - date: formatDate(new Date(this.props.mxEvent.getTs())), - user: sender, - }) } -
    ); - } - - let eventRedact; - if (mayRedact) { - eventRedact = (
    - { _t('Remove') } -
    ); - } - - const rotationDegrees = this.state.rotationDegrees; - const effectiveStyle = {transform: `rotate(${rotationDegrees}deg)`, ...style}; - - return ( - -
    -
    -
    - -
    -
    - - { - - - { - - - { - -
    -
    -
    - { this.getName() } -
    - { eventMeta } - -
    - { _t('Download this file') }
    - { sizeRes } -
    -
    - { eventRedact } -
    -
    -
    -
    -
    -
    -
    -
    - ); - } -} diff --git a/src/components/views/elements/ImageView.tsx b/src/components/views/elements/ImageView.tsx new file mode 100644 index 0000000000..df73e1a8cb --- /dev/null +++ b/src/components/views/elements/ImageView.tsx @@ -0,0 +1,491 @@ +/* +Copyright 2015, 2016 OpenMarket Ltd +Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> +Copyright 2020, 2021 Šimon Brandner + +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, { createRef } from 'react'; +import { _t } from '../../../languageHandler'; +import AccessibleTooltipButton from "./AccessibleTooltipButton"; +import {Key} from "../../../Keyboard"; +import FocusLock from "react-focus-lock"; +import MemberAvatar from "../avatars/MemberAvatar"; +import {ContextMenuTooltipButton} from "../../../accessibility/context_menu/ContextMenuTooltipButton"; +import MessageContextMenu from "../context_menus/MessageContextMenu"; +import {aboveLeftOf, ContextMenu} from '../../structures/ContextMenu'; +import MessageTimestamp from "../messages/MessageTimestamp"; +import SettingsStore from "../../../settings/SettingsStore"; +import {formatFullDate} from "../../../DateUtils"; +import dis from '../../../dispatcher/dispatcher'; +import {replaceableComponent} from "../../../utils/replaceableComponent"; +import {RoomPermalinkCreator} from "../../../utils/permalinks/Permalinks" +import {MatrixEvent} from "matrix-js-sdk/src/models/event"; +import {normalizeWheelEvent} from "../../../utils/Mouse"; + +// Max scale to keep gaps around the image +const MAX_SCALE = 0.95; +// This is used for the buttons +const ZOOM_STEP = 0.10; +// This is used for mouse wheel events +const ZOOM_COEFFICIENT = 0.0025; +// If we have moved only this much we can zoom +const ZOOM_DISTANCE = 10; + +interface IProps { + src: string, // the source of the image being displayed + name?: string, // the main title ('name') for the image + link?: string, // the link (if any) applied to the name of the image + width?: number, // width of the image src in pixels + height?: number, // height of the image src in pixels + fileSize?: number, // size of the image src in bytes + onFinished(): void, // callback when the lightbox is dismissed + + // the event (if any) that the Image is displaying. Used for event-specific stuff like + // redactions, senders, timestamps etc. Other descriptors are taken from the explicit + // properties above, which let us use lightboxes to display images which aren't associated + // with events. + mxEvent: MatrixEvent, + permalinkCreator: RoomPermalinkCreator, +} + +interface IState { + zoom: number, + minZoom: number, + maxZoom: number, + rotation: number, + translationX: number, + translationY: number, + moving: boolean, + contextMenuDisplayed: boolean, +} + +@replaceableComponent("views.elements.ImageView") +export default class ImageView extends React.Component { + constructor(props) { + super(props); + this.state = { + zoom: 0, + minZoom: MAX_SCALE, + maxZoom: MAX_SCALE, + rotation: 0, + translationX: 0, + translationY: 0, + moving: false, + contextMenuDisplayed: false, + }; + } + + // XXX: Refs to functional components + private contextMenuButton = createRef(); + private focusLock = createRef(); + private imageWrapper = createRef(); + private image = createRef(); + + private initX = 0; + private initY = 0; + private lastX = 0; + private lastY = 0; + private previousX = 0; + private previousY = 0; + + componentDidMount() { + // We have to use addEventListener() because the listener + // needs to be passive in order to work with Chromium + this.focusLock.current.addEventListener('wheel', this.onWheel, { passive: false }); + // We want to recalculate zoom whenever the window's size changes + window.addEventListener("resize", this.calculateZoom); + // After the image loads for the first time we want to calculate the zoom + this.image.current.addEventListener("load", this.calculateZoom); + } + + componentWillUnmount() { + this.focusLock.current.removeEventListener('wheel', this.onWheel); + window.removeEventListener("resize", this.calculateZoom); + this.image.current.removeEventListener("load", this.calculateZoom); + } + + private calculateZoom = () => { + const image = this.image.current; + const imageWrapper = this.imageWrapper.current; + + const zoomX = imageWrapper.clientWidth / image.naturalWidth; + const zoomY = imageWrapper.clientHeight / image.naturalHeight; + + // If the image is smaller in both dimensions set its the zoom to 1 to + // display it in its original size + if (zoomX >= 1 && zoomY >= 1) { + this.setState({ + zoom: 1, + minZoom: 1, + maxZoom: 1, + }); + return; + } + // We set minZoom to the min of the zoomX and zoomY to avoid overflow in + // any direction. We also multiply by MAX_SCALE to get a gap around the + // image by default + const minZoom = Math.min(zoomX, zoomY) * MAX_SCALE; + + if (this.state.zoom <= this.state.minZoom) this.setState({zoom: minZoom}); + this.setState({ + minZoom: minZoom, + maxZoom: 1, + }); + } + + private zoom(delta: number) { + const newZoom = this.state.zoom + delta; + + if (newZoom <= this.state.minZoom) { + this.setState({ + zoom: this.state.minZoom, + translationX: 0, + translationY: 0, + }); + return; + } + if (newZoom >= this.state.maxZoom) { + this.setState({zoom: this.state.maxZoom}); + return; + } + + this.setState({ + zoom: newZoom, + }); + } + + private onWheel = (ev: WheelEvent) => { + ev.stopPropagation(); + ev.preventDefault(); + + const {deltaY} = normalizeWheelEvent(ev); + this.zoom(-(deltaY * ZOOM_COEFFICIENT)); + }; + + private onZoomInClick = () => { + this.zoom(ZOOM_STEP); + }; + + private onZoomOutClick = () => { + this.zoom(-ZOOM_STEP); + }; + + private onKeyDown = (ev: KeyboardEvent) => { + if (ev.key === Key.ESCAPE) { + ev.stopPropagation(); + ev.preventDefault(); + this.props.onFinished(); + } + }; + + private onRotateCounterClockwiseClick = () => { + const cur = this.state.rotation; + const rotationDegrees = cur - 90; + this.setState({ rotation: rotationDegrees }); + }; + + private onRotateClockwiseClick = () => { + const cur = this.state.rotation; + const rotationDegrees = cur + 90; + this.setState({ rotation: rotationDegrees }); + }; + + private onDownloadClick = () => { + const a = document.createElement("a"); + a.href = this.props.src; + a.download = this.props.name; + a.target = "_blank"; + a.rel = "noreferrer noopener"; + a.click(); + }; + + private onOpenContextMenu = () => { + this.setState({ + contextMenuDisplayed: true, + }); + }; + + private onCloseContextMenu = () => { + this.setState({ + contextMenuDisplayed: false, + }); + }; + + private onPermalinkClicked = (ev: React.MouseEvent) => { + // This allows the permalink to be opened in a new tab/window or copied as + // matrix.to, but also for it to enable routing within Element when clicked. + ev.preventDefault(); + dis.dispatch({ + action: 'view_room', + event_id: this.props.mxEvent.getId(), + highlighted: true, + room_id: this.props.mxEvent.getRoomId(), + }); + this.props.onFinished(); + }; + + private onStartMoving = (ev: React.MouseEvent) => { + ev.stopPropagation(); + ev.preventDefault(); + + // Don't do anything if we pressed any + // other button than the left one + if (ev.button !== 0) return; + + // Zoom in if we are completely zoomed out + if (this.state.zoom === this.state.minZoom) { + this.setState({zoom: this.state.maxZoom}); + return; + } + + this.setState({moving: true}); + this.previousX = this.state.translationX; + this.previousY = this.state.translationY; + this.initX = ev.pageX - this.lastX; + this.initY = ev.pageY - this.lastY; + }; + + private onMoving = (ev: React.MouseEvent) => { + ev.stopPropagation(); + ev.preventDefault(); + + if (!this.state.moving) return; + + this.lastX = ev.pageX - this.initX; + this.lastY = ev.pageY - this.initY; + this.setState({ + translationX: this.lastX, + translationY: this.lastY, + }); + }; + + private onEndMoving = () => { + // Zoom out if we haven't moved much + if ( + this.state.moving === true && + Math.abs(this.state.translationX - this.previousX) < ZOOM_DISTANCE && + Math.abs(this.state.translationY - this.previousY) < ZOOM_DISTANCE + ) { + this.setState({ + zoom: this.state.minZoom, + translationX: 0, + translationY: 0, + }); + } + this.setState({moving: false}); + }; + + private renderContextMenu() { + let contextMenu = null; + if (this.state.contextMenuDisplayed) { + contextMenu = ( + + + + ); + } + + return ( + + { contextMenu } + + ); + } + + render() { + const showEventMeta = !!this.props.mxEvent; + const zoomingDisabled = this.state.maxZoom === this.state.minZoom; + + let cursor; + if (this.state.moving) { + cursor= "grabbing"; + } else if (zoomingDisabled) { + cursor = "default"; + } else if (this.state.zoom === this.state.minZoom) { + cursor = "zoom-in"; + } else { + cursor = "zoom-out"; + } + const rotationDegrees = this.state.rotation + "deg"; + const zoom = this.state.zoom; + const translatePixelsX = this.state.translationX + "px"; + const translatePixelsY = this.state.translationY + "px"; + // The order of the values is important! + // First, we translate and only then we rotate, otherwise + // we would apply the translation to an already rotated + // image causing it translate in the wrong direction. + const style = { + cursor: cursor, + transition: this.state.moving ? null : "transform 200ms ease 0s", + transform: `translateX(${translatePixelsX}) + translateY(${translatePixelsY}) + scale(${zoom}) + rotate(${rotationDegrees})`, + }; + + let info; + if (showEventMeta) { + const mxEvent = this.props.mxEvent; + const showTwelveHour = SettingsStore.getValue("showTwelveHourTimestamps"); + let permalink = "#"; + if (this.props.permalinkCreator) { + permalink = this.props.permalinkCreator.forEvent(this.props.mxEvent.getId()); + } + + const senderName = mxEvent.sender ? mxEvent.sender.name : mxEvent.getSender(); + const sender = ( +
    + {senderName} +
    + ); + const messageTimestamp = ( + + + + ); + const avatar = ( + + ); + + info = ( +
    + {avatar} +
    + {sender} + {messageTimestamp} +
    +
    + ); + } else { + // If there is no event - we're viewing an avatar, we set + // an empty div here, since the panel uses space-between + // and we want the same placement of elements + info = ( +
    + ); + } + + let contextMenuButton; + if (this.props.mxEvent) { + contextMenuButton = ( + + ); + } + + let zoomOutButton; + let zoomInButton; + if (!zoomingDisabled) { + zoomOutButton = ( + + + ); + zoomInButton = ( + + + ); + } + + return ( + +
    + {info} +
    + + + + + {zoomOutButton} + {zoomInButton} + + + {contextMenuButton} + + + {this.renderContextMenu()} +
    +
    +
    + +
    +
    + ); + } +} diff --git a/src/components/views/elements/InfoTooltip.tsx b/src/components/views/elements/InfoTooltip.tsx index 8f7f1ea53f..d49090dbae 100644 --- a/src/components/views/elements/InfoTooltip.tsx +++ b/src/components/views/elements/InfoTooltip.tsx @@ -18,8 +18,8 @@ limitations under the License. import React from 'react'; import classNames from 'classnames'; -import Tooltip from './Tooltip'; -import { _t } from "../../../languageHandler"; +import Tooltip, {Alignment} from './Tooltip'; +import {_t} from "../../../languageHandler"; import {replaceableComponent} from "../../../utils/replaceableComponent"; interface ITooltipProps { @@ -61,7 +61,7 @@ export default class InfoTooltip extends React.PureComponent :
    ; return (
    diff --git a/src/components/views/elements/InlineSpinner.js b/src/components/views/elements/InlineSpinner.js index 3654a1f34c..bbbe60d500 100644 --- a/src/components/views/elements/InlineSpinner.js +++ b/src/components/views/elements/InlineSpinner.js @@ -16,7 +16,6 @@ limitations under the License. import React from "react"; import {_t} from "../../../languageHandler"; -import SettingsStore from "../../../settings/SettingsStore"; import {replaceableComponent} from "../../../utils/replaceableComponent"; @replaceableComponent("views.elements.InlineSpinner") @@ -24,24 +23,14 @@ export default class InlineSpinner extends React.Component { render() { const w = this.props.w || 16; const h = this.props.h || 16; - const imgClass = this.props.imgClassName || ""; - - let imageSource; - if (SettingsStore.getValue('feature_new_spinner')) { - imageSource = require("../../../../res/img/spinner.svg"); - } else { - imageSource = require("../../../../res/img/spinner.gif"); - } return (
    - + >
    ); } diff --git a/src/components/views/elements/InviteReason.tsx b/src/components/views/elements/InviteReason.tsx new file mode 100644 index 0000000000..ddce7552ed --- /dev/null +++ b/src/components/views/elements/InviteReason.tsx @@ -0,0 +1,62 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import classNames from "classnames"; +import React from "react"; +import { _t } from "../../../languageHandler"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; + +interface IProps { + reason: string; +} + +interface IState { + hidden: boolean; +} + +@replaceableComponent("views.elements.InviteReason") +export default class InviteReason extends React.PureComponent { + constructor(props) { + super(props); + this.state = { + // We hide the reason for invitation by default, since it can be a + // vector for spam/harassment. + hidden: true, + }; + } + + onViewClick = () => { + this.setState({ + hidden: false, + }); + } + + render() { + const classes = classNames({ + "mx_InviteReason": true, + "mx_InviteReason_hidden": this.state.hidden, + }); + + return
    +
    {this.props.reason}
    +
    + {_t("View message")} +
    +
    ; + } +} diff --git a/src/components/views/elements/LabelledToggleSwitch.js b/src/components/views/elements/LabelledToggleSwitch.js index e6378f0e6a..ef60eeed7b 100644 --- a/src/components/views/elements/LabelledToggleSwitch.js +++ b/src/components/views/elements/LabelledToggleSwitch.js @@ -46,8 +46,12 @@ export default class LabelledToggleSwitch extends React.Component { // This is a minimal version of a SettingsFlag let firstPart = {this.props.label}; - let secondPart = ; + let secondPart = ; if (this.props.toggleInFront) { const temp = firstPart; diff --git a/src/components/views/elements/LanguageDropdown.js b/src/components/views/elements/LanguageDropdown.js index 2e961be700..9420061a74 100644 --- a/src/components/views/elements/LanguageDropdown.js +++ b/src/components/views/elements/LanguageDropdown.js @@ -58,13 +58,8 @@ export default class LanguageDropdown extends React.Component { // If no value is given, we start with the first // country selected, but our parent component // doesn't know this, therefore we do this. - const language = SettingsStore.getValue("language", null, /*excludeDefault:*/true); - if (language) { - this.props.onOptionChange(language); - } else { - const language = languageHandler.normalizeLanguageKey(languageHandler.getLanguageFromBrowser()); - this.props.onOptionChange(language); - } + const language = languageHandler.getUserLanguage(); + this.props.onOptionChange(language); } } diff --git a/src/components/views/elements/MiniAvatarUploader.tsx b/src/components/views/elements/MiniAvatarUploader.tsx index b2609027d4..32ef0d4da2 100644 --- a/src/components/views/elements/MiniAvatarUploader.tsx +++ b/src/components/views/elements/MiniAvatarUploader.tsx @@ -19,6 +19,7 @@ import {EventType} from 'matrix-js-sdk/src/@types/event'; import classNames from 'classnames'; import AccessibleButton from "./AccessibleButton"; +import Spinner from "./Spinner"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; import {useTimeout} from "../../../hooks/useTimeout"; import Analytics from "../../../Analytics"; @@ -88,6 +89,12 @@ const MiniAvatarUploader: React.FC = ({ hasAvatar, hasAvatarLabel, noAva > { children } +
    + { busy ? + : +
    } +
    +
    -
    {_t("Level")}{_t("Settable at global")}{_t("Settable at room")}
    {_t("Level")}{_t("Settable at global")}{_t("Settable at room")}
    {_t("Homeserver feature support:")} {homeserverSupportsCrossSigning ? _t("exists") : _t("not found")}
    + {errorSection} {actionRow} diff --git a/src/components/views/settings/DevicesPanel.js b/src/components/views/settings/DevicesPanel.js index e7d300b0f8..b1ad605a37 100644 --- a/src/components/views/settings/DevicesPanel.js +++ b/src/components/views/settings/DevicesPanel.js @@ -214,7 +214,7 @@ export default class DevicesPanel extends React.Component { const deleteButton = this.state.deleting ? : - { _t("Delete %(count)s sessions", {count: this.state.selectedDevices.length}) } + { _t("Delete %(count)s sessions", {count: this.state.selectedDevices.length})} ; const classes = classNames(this.props.className, "mx_DevicesPanel"); diff --git a/src/components/views/settings/E2eAdvancedPanel.js b/src/components/views/settings/E2eAdvancedPanel.tsx similarity index 100% rename from src/components/views/settings/E2eAdvancedPanel.js rename to src/components/views/settings/E2eAdvancedPanel.tsx diff --git a/src/components/views/settings/EventIndexPanel.js b/src/components/views/settings/EventIndexPanel.tsx similarity index 64% rename from src/components/views/settings/EventIndexPanel.js rename to src/components/views/settings/EventIndexPanel.tsx index d1a02de16d..e693f45c5f 100644 --- a/src/components/views/settings/EventIndexPanel.js +++ b/src/components/views/settings/EventIndexPanel.tsx @@ -1,5 +1,5 @@ /* -Copyright 2020 The Matrix.org Foundation C.I.C. +Copyright 2020-2021 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. @@ -28,10 +28,17 @@ import {SettingLevel} from "../../../settings/SettingLevel"; import {replaceableComponent} from "../../../utils/replaceableComponent"; import SeshatResetDialog from '../dialogs/SeshatResetDialog'; +interface IState { + enabling: boolean; + eventIndexSize: number; + roomCount: number; + eventIndexingEnabled: boolean; +} + @replaceableComponent("views.settings.EventIndexPanel") -export default class EventIndexPanel extends React.Component { - constructor() { - super(); +export default class EventIndexPanel extends React.Component<{}, IState> { + constructor(props) { + super(props); this.state = { enabling: false, @@ -68,7 +75,7 @@ export default class EventIndexPanel extends React.Component { } } - async componentDidMount(): void { + componentDidMount(): void { this.updateState(); } @@ -102,8 +109,10 @@ export default class EventIndexPanel extends React.Component { }); } - _onManage = async () => { + private onManage = async () => { Modal.createTrackedDialogAsync('Message search', 'Message search', + // @ts-ignore: TS doesn't seem to like the type of this now that it + // has also been converted to TS as well, but I can't figure out why... import('../../../async-components/views/dialogs/eventindex/ManageEventIndexDialog'), { onFinished: () => {}, @@ -111,7 +120,7 @@ export default class EventIndexPanel extends React.Component { ); } - _onEnable = async () => { + private onEnable = async () => { this.setState({ enabling: true, }); @@ -123,14 +132,13 @@ export default class EventIndexPanel extends React.Component { await this.updateState(); } - _confirmEventStoreReset = () => { - const self = this; + private confirmEventStoreReset = () => { const { close } = Modal.createDialog(SeshatResetDialog, { onFinished: async (success) => { if (success) { await SettingsStore.setValue('enableEventIndexing', null, SettingLevel.DEVICE, false); await EventIndexPeg.deleteEventIndex(); - await self._onEnable(); + await this.onEnable(); close(); } }, @@ -145,20 +153,19 @@ export default class EventIndexPanel extends React.Component { if (EventIndexPeg.get() !== null) { eventIndexingSettings = (
    -
    - {_t("Securely cache encrypted messages locally for them " + - "to appear in search results, using %(size)s to store messages from %(rooms)s rooms.", - { - size: formatBytes(this.state.eventIndexSize, 0), - // This drives the singular / plural string - // selection for "room" / "rooms" only. - count: this.state.roomCount, - rooms: formatCountLong(this.state.roomCount), - }, - )} -
    +
    {_t( + "Securely cache encrypted messages locally for them " + + "to appear in search results, using %(size)s to store messages from %(rooms)s rooms.", + { + size: formatBytes(this.state.eventIndexSize, 0), + // This drives the singular / plural string + // selection for "room" / "rooms" only. + count: this.state.roomCount, + rooms: formatCountLong(this.state.roomCount), + }, + )}
    - + {_t("Manage")}
    @@ -167,13 +174,13 @@ export default class EventIndexPanel extends React.Component { } else if (!this.state.eventIndexingEnabled && EventIndexPeg.supportIsInstalled()) { eventIndexingSettings = (
    -
    - {_t( "Securely cache encrypted messages locally for them to " + - "appear in search results.")} -
    +
    {_t( + "Securely cache encrypted messages locally for them to " + + "appear in search results.", + )}
    + onClick={this.onEnable}> {_t("Enable")} {this.state.enabling ? :
    } @@ -188,40 +195,36 @@ export default class EventIndexPanel extends React.Component { ); eventIndexingSettings = ( -
    +
    {_t( + "%(brand)s is missing some components required for securely " + + "caching encrypted messages locally. If you'd like to " + + "experiment with this feature, build a custom %(brand)s Desktop " + + "with search components added.", { - _t( "%(brand)s is missing some components required for securely " + - "caching encrypted messages locally. If you'd like to " + - "experiment with this feature, build a custom %(brand)s Desktop " + - "with search components added.", - { - brand, - }, - { - 'nativeLink': (sub) => {sub}, - }, - ) - } -
    + brand, + }, + { + nativeLink: sub => {sub}, + }, + )}
    ); } else if (!EventIndexPeg.platformHasSupport()) { eventIndexingSettings = ( -
    +
    {_t( + "%(brand)s can't securely cache encrypted messages locally " + + "while running in a web browser. Use %(brand)s Desktop " + + "for encrypted messages to appear in search results.", { - _t( "%(brand)s can't securely cache encrypted messages locally " + - "while running in a web browser. Use %(brand)s Desktop " + - "for encrypted messages to appear in search results.", - { - brand, - }, - { - 'desktopLink': (sub) => {sub}, - }, - ) - } -
    + brand, + }, + { + desktopLink: sub => {sub}, + }, + )}
    ); } else { eventIndexingSettings = ( @@ -229,23 +232,22 @@ export default class EventIndexPanel extends React.Component {

    {this.state.enabling ? - : _t("Message search initilisation failed") + : _t("Message search initialisation failed") }

    {EventIndexPeg.error && ( -
    - {_t("Advanced")} - - {EventIndexPeg.error.message} - -

    - - {_t("Reset")} - -

    -
    +
    + {_t("Advanced")} + + {EventIndexPeg.error.message} + +

    + + {_t("Reset")} + +

    +
    )} -
    ); } diff --git a/src/components/views/settings/Notifications.js b/src/components/views/settings/Notifications.js index 25fe434994..5756536085 100644 --- a/src/components/views/settings/Notifications.js +++ b/src/components/views/settings/Notifications.js @@ -100,7 +100,7 @@ export default class Notifications extends React.Component { MatrixClientPeg.get().setPushRuleEnabled( 'global', self.state.masterPushRule.kind, self.state.masterPushRule.rule_id, !checked, ).then(function() { - self._refreshFromServer(); + self._refreshFromServer(); }); }; @@ -580,12 +580,12 @@ export default class Notifications extends React.Component { "vectorRuleId": "_keywords", "description": ( - { _t('Messages containing keywords', - {}, - { 'span': (sub) => - {sub}, - }, - )} + { _t('Messages containing keywords', + {}, + { 'span': (sub) => + {sub}, + }, + )} ), "vectorState": self.state.vectorContentRules.vectorState, @@ -743,8 +743,8 @@ export default class Notifications extends React.Component { emailNotificationsRow(address, label) { return ; + onChange={this.onEnableEmailNotificationsChange.bind(this, address)} + label={label} key={`emailNotif_${label}`} />; } render() { @@ -757,8 +757,8 @@ export default class Notifications extends React.Component { let masterPushRuleDiv; if (this.state.masterPushRule) { masterPushRuleDiv = ; + onChange={this.onEnableNotificationsChange} + label={_t('Enable notifications for this account')} />; } let clearNotificationsButton; @@ -874,16 +874,16 @@ export default class Notifications extends React.Component { { spinner } + onChange={this.onEnableDesktopNotificationsChange} + label={_t('Enable desktop notifications for this session')} /> + onChange={this.onEnableDesktopNotificationBodyChange} + label={_t('Show message in desktop notification')} /> + onChange={this.onEnableAudioNotificationsChange} + label={_t('Enable audible notifications for this session')} /> { emailNotificationsRows } diff --git a/src/components/views/settings/ProfileSettings.js b/src/components/views/settings/ProfileSettings.js index 971b868751..9ecf369eba 100644 --- a/src/components/views/settings/ProfileSettings.js +++ b/src/components/views/settings/ProfileSettings.js @@ -170,8 +170,12 @@ export default class ProfileSettings extends React.Component { noValidate={true} className="mx_ProfileSettings_profileForm" > - +
    {_t("Profile")} diff --git a/src/components/views/settings/SetIdServer.js b/src/components/views/settings/SetIdServer.tsx similarity index 89% rename from src/components/views/settings/SetIdServer.js rename to src/components/views/settings/SetIdServer.tsx index fa2a36476d..05d1f83387 100644 --- a/src/components/views/settings/SetIdServer.js +++ b/src/components/views/settings/SetIdServer.tsx @@ -1,5 +1,5 @@ /* -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2019-2021 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. @@ -16,7 +16,6 @@ limitations under the License. import url from 'url'; import React from 'react'; -import PropTypes from 'prop-types'; import {_t} from "../../../languageHandler"; import * as sdk from '../../../index'; import {MatrixClientPeg} from "../../../MatrixClientPeg"; @@ -28,6 +27,7 @@ import {abbreviateUrl, unabbreviateUrl} from "../../../utils/UrlUtils"; import { getDefaultIdentityServerUrl, doesIdentityServerHaveTerms } from '../../../utils/IdentityServerUtils'; import {timeout} from "../../../utils/promise"; import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { ActionPayload } from '../../../dispatcher/payloads'; // We'll wait up to this long when checking for 3PID bindings on the IS. const REACHABILITY_TIMEOUT = 10000; // ms @@ -59,16 +59,28 @@ async function checkIdentityServerUrl(u) { } } -@replaceableComponent("views.settings.SetIdServer") -export default class SetIdServer extends React.Component { - static propTypes = { - // Whether or not the ID server is missing terms. This affects the text - // shown to the user. - missingTerms: PropTypes.bool, - }; +interface IProps { + // Whether or not the ID server is missing terms. This affects the text + // shown to the user. + missingTerms: boolean; +} - constructor() { - super(); +interface IState { + defaultIdServer?: string; + currentClientIdServer: string; + idServer?: string; + error?: string; + busy: boolean; + disconnectBusy: boolean; + checking: boolean; +} + +@replaceableComponent("views.settings.SetIdServer") +export default class SetIdServer extends React.Component { + private dispatcherRef: string; + + constructor(props) { + super(props); let defaultIdServer = ''; if (!MatrixClientPeg.get().getIdentityServerUrl() && getDefaultIdentityServerUrl()) { @@ -96,7 +108,7 @@ export default class SetIdServer extends React.Component { dis.unregister(this.dispatcherRef); } - onAction = (payload) => { + private onAction = (payload: ActionPayload) => { // We react to changes in the ID server in the event the user is staring at this form // when changing their identity server on another device. if (payload.action !== "id_server_changed") return; @@ -106,13 +118,13 @@ export default class SetIdServer extends React.Component { }); }; - _onIdentityServerChanged = (ev) => { + private onIdentityServerChanged = (ev) => { const u = ev.target.value; this.setState({idServer: u}); }; - _getTooltip = () => { + private getTooltip = () => { if (this.state.checking) { const InlineSpinner = sdk.getComponent('views.elements.InlineSpinner'); return
    @@ -126,11 +138,11 @@ export default class SetIdServer extends React.Component { } }; - _idServerChangeEnabled = () => { + private idServerChangeEnabled = () => { return !!this.state.idServer && !this.state.busy; }; - _saveIdServer = (fullUrl) => { + private saveIdServer = (fullUrl) => { // Account data change will update localstorage, client, etc through dispatcher MatrixClientPeg.get().setAccountData("m.identity_server", { base_url: fullUrl, @@ -143,7 +155,7 @@ export default class SetIdServer extends React.Component { }); }; - _checkIdServer = async (e) => { + private checkIdServer = async (e) => { e.preventDefault(); const { idServer, currentClientIdServer } = this.state; @@ -166,14 +178,14 @@ export default class SetIdServer extends React.Component { // Double check that the identity server even has terms of service. const hasTerms = await doesIdentityServerHaveTerms(fullUrl); if (!hasTerms) { - const [confirmed] = await this._showNoTermsWarning(fullUrl); + const [confirmed] = await this.showNoTermsWarning(fullUrl); save = confirmed; } // Show a general warning, possibly with details about any bound // 3PIDs that would be left behind. if (save && currentClientIdServer && fullUrl !== currentClientIdServer) { - const [confirmed] = await this._showServerChangeWarning({ + const [confirmed] = await this.showServerChangeWarning({ title: _t("Change identity server"), unboundMessage: _t( "Disconnect from the identity server and " + @@ -189,7 +201,7 @@ export default class SetIdServer extends React.Component { } if (save) { - this._saveIdServer(fullUrl); + this.saveIdServer(fullUrl); } } catch (e) { console.error(e); @@ -204,7 +216,7 @@ export default class SetIdServer extends React.Component { }); }; - _showNoTermsWarning(fullUrl) { + private showNoTermsWarning(fullUrl) { const QuestionDialog = sdk.getComponent("views.dialogs.QuestionDialog"); const { finished } = Modal.createTrackedDialog('No Terms Warning', '', QuestionDialog, { title: _t("Identity server has no terms of service"), @@ -223,10 +235,10 @@ export default class SetIdServer extends React.Component { return finished; } - _onDisconnectClicked = async () => { + private onDisconnectClicked = async () => { this.setState({disconnectBusy: true}); try { - const [confirmed] = await this._showServerChangeWarning({ + const [confirmed] = await this.showServerChangeWarning({ title: _t("Disconnect identity server"), unboundMessage: _t( "Disconnect from the identity server ?", {}, @@ -235,14 +247,14 @@ export default class SetIdServer extends React.Component { button: _t("Disconnect"), }); if (confirmed) { - this._disconnectIdServer(); + this.disconnectIdServer(); } } finally { this.setState({disconnectBusy: false}); } }; - async _showServerChangeWarning({ title, unboundMessage, button }) { + private async showServerChangeWarning({ title, unboundMessage, button }) { const { currentClientIdServer } = this.state; let threepids = []; @@ -318,7 +330,7 @@ export default class SetIdServer extends React.Component { return finished; } - _disconnectIdServer = () => { + private disconnectIdServer = () => { // Account data change will update localstorage, client, etc through dispatcher MatrixClientPeg.get().setAccountData("m.identity_server", { base_url: null, // clear @@ -371,7 +383,7 @@ export default class SetIdServer extends React.Component { let discoSection; if (idServerUrl) { - let discoButtonContent = _t("Disconnect"); + let discoButtonContent: React.ReactNode = _t("Disconnect"); let discoBodyText = _t( "Disconnecting from your identity server will mean you " + "won't be discoverable by other users and you won't be " + @@ -391,14 +403,14 @@ export default class SetIdServer extends React.Component { } discoSection =
    {discoBodyText} - + {discoButtonContent}
    ; } return ( - + {sectionTitle} @@ -411,15 +423,15 @@ export default class SetIdServer extends React.Component { autoComplete="off" placeholder={this.state.defaultIdServer} value={this.state.idServer} - onChange={this._onIdentityServerChanged} - tooltipContent={this._getTooltip()} + onChange={this.onIdentityServerChanged} + tooltipContent={this.getTooltip()} tooltipClassName="mx_SetIdServer_tooltip" disabled={this.state.busy} forceValidity={this.state.error ? false : null} /> {_t("Change")} {discoSection} diff --git a/src/components/views/settings/account/EmailAddresses.js b/src/components/views/settings/account/EmailAddresses.js index 1ebd374173..a36369cf88 100644 --- a/src/components/views/settings/account/EmailAddresses.js +++ b/src/components/views/settings/account/EmailAddresses.js @@ -90,12 +90,18 @@ export class ExistingEmailAddress extends React.Component { {_t("Remove %(email)s?", {email: this.props.email.address} )} - + {_t("Remove")} - + {_t("Cancel")}
    @@ -228,21 +234,28 @@ export default class EmailAddresses extends React.Component { ); if (this.state.verifying) { addButton = ( -
    -
    {_t("We've sent you an email to verify your address. Please follow the instructions there and then click the button below.")}
    - - {_t("Continue")} - -
    +
    +
    {_t("We've sent you an email to verify your address. Please follow the instructions there and then click the button below.")}
    + + {_t("Continue")} + +
    ); } return (
    {existingEmailElements} -
    + {_t("Remove %(phone)s?", {phone: this.props.msisdn.address})} - + {_t("Remove")} - + {_t("Cancel")}
    @@ -246,8 +252,11 @@ export default class PhoneNumbers extends React.Component { value={this.state.newPhoneNumberCode} onChange={this._onChangeNewPhoneNumberCode} /> - + {_t("Continue")} diff --git a/src/components/views/settings/tabs/room/GeneralRoomSettingsTab.js b/src/components/views/settings/tabs/room/GeneralRoomSettingsTab.js index cd4a043622..139cfd5fbd 100644 --- a/src/components/views/settings/tabs/room/GeneralRoomSettingsTab.js +++ b/src/components/views/settings/tabs/room/GeneralRoomSettingsTab.js @@ -80,9 +80,11 @@ export default class GeneralRoomSettingsTab extends React.Component { flairSection = <> {_t("Flair")}
    - +
    ; } @@ -97,8 +99,8 @@ export default class GeneralRoomSettingsTab extends React.Component {
    {_t("Room Addresses")}
    + canSetCanonicalAlias={canSetCanonical} canSetAliases={canSetAliases} + canonicalAliasEvent={canonicalAliasEv} aliasEvents={aliasEvents} />
    {_t("Other")}
    { flairSection } diff --git a/src/components/views/settings/tabs/room/NotificationSettingsTab.js b/src/components/views/settings/tabs/room/NotificationSettingsTab.js index baefb5ae20..fa56fa2cb6 100644 --- a/src/components/views/settings/tabs/room/NotificationSettingsTab.js +++ b/src/components/views/settings/tabs/room/NotificationSettingsTab.js @@ -155,7 +155,7 @@ export default class NotificationsSettingsTab extends React.Component {
    {_t("Notification sound")}: {this.state.currentSound}
    - {_t("Reset")} + {_t("Reset")}
    @@ -167,11 +167,11 @@ export default class NotificationsSettingsTab extends React.Component { {currentUploadedFile} - {_t("Browse")} + {_t("Browse")} - {_t("Save")} + {_t("Save")}
    diff --git a/src/components/views/settings/tabs/room/RolesRoomSettingsTab.js b/src/components/views/settings/tabs/room/RolesRoomSettingsTab.tsx similarity index 84% rename from src/components/views/settings/tabs/room/RolesRoomSettingsTab.js rename to src/components/views/settings/tabs/room/RolesRoomSettingsTab.tsx index 09498e0d4a..4fa521f598 100644 --- a/src/components/views/settings/tabs/room/RolesRoomSettingsTab.js +++ b/src/components/views/settings/tabs/room/RolesRoomSettingsTab.tsx @@ -1,5 +1,5 @@ /* -Copyright 2019 New Vector Ltd +Copyright 2019-2021 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. @@ -15,24 +15,28 @@ limitations under the License. */ import React from 'react'; -import PropTypes from 'prop-types'; import {_t, _td} from "../../../../../languageHandler"; import {MatrixClientPeg} from "../../../../../MatrixClientPeg"; import * as sdk from "../../../../.."; import AccessibleButton from "../../../elements/AccessibleButton"; import Modal from "../../../../../Modal"; import {replaceableComponent} from "../../../../../utils/replaceableComponent"; +import {EventType} from "matrix-js-sdk/src/@types/event"; +import { RoomMember } from "matrix-js-sdk/src/models/room-member"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { RoomState } from "matrix-js-sdk/src/models/room-state"; const plEventsToLabels = { // These will be translated for us later. - "m.room.avatar": _td("Change room avatar"), - "m.room.name": _td("Change room name"), - "m.room.canonical_alias": _td("Change main address for the room"), - "m.room.history_visibility": _td("Change history visibility"), - "m.room.power_levels": _td("Change permissions"), - "m.room.topic": _td("Change topic"), - "m.room.tombstone": _td("Upgrade the room"), - "m.room.encryption": _td("Enable room encryption"), + [EventType.RoomAvatar]: _td("Change room avatar"), + [EventType.RoomName]: _td("Change room name"), + [EventType.RoomCanonicalAlias]: _td("Change main address for the room"), + [EventType.RoomHistoryVisibility]: _td("Change history visibility"), + [EventType.RoomPowerLevels]: _td("Change permissions"), + [EventType.RoomTopic]: _td("Change topic"), + [EventType.RoomTombstone]: _td("Upgrade the room"), + [EventType.RoomEncryption]: _td("Enable room encryption"), + [EventType.RoomServerAcl]: _td("Change server ACLs"), // TODO: Enable support for m.widget event type (https://github.com/vector-im/element-web/issues/13111) "im.vector.modular.widgets": _td("Modify widgets"), @@ -40,14 +44,15 @@ const plEventsToLabels = { const plEventsToShow = { // If an event is listed here, it will be shown in the PL settings. Defaults will be calculated. - "m.room.avatar": {isState: true}, - "m.room.name": {isState: true}, - "m.room.canonical_alias": {isState: true}, - "m.room.history_visibility": {isState: true}, - "m.room.power_levels": {isState: true}, - "m.room.topic": {isState: true}, - "m.room.tombstone": {isState: true}, - "m.room.encryption": {isState: true}, + [EventType.RoomAvatar]: {isState: true}, + [EventType.RoomName]: {isState: true}, + [EventType.RoomCanonicalAlias]: {isState: true}, + [EventType.RoomHistoryVisibility]: {isState: true}, + [EventType.RoomPowerLevels]: {isState: true}, + [EventType.RoomTopic]: {isState: true}, + [EventType.RoomTombstone]: {isState: true}, + [EventType.RoomEncryption]: {isState: true}, + [EventType.RoomServerAcl]: {isState: true}, // TODO: Enable support for m.widget event type (https://github.com/vector-im/element-web/issues/13111) "im.vector.modular.widgets": {isState: true}, @@ -60,15 +65,15 @@ function parseIntWithDefault(val, def) { return isNaN(res) ? def : res; } -export class BannedUser extends React.Component { - static propTypes = { - canUnban: PropTypes.bool, - member: PropTypes.object.isRequired, // js-sdk RoomMember - by: PropTypes.string.isRequired, - reason: PropTypes.string, - }; +interface IBannedUserProps { + canUnban?: boolean; + member: RoomMember; + by: string; + reason?: string; +} - _onUnbanClick = (e) => { +export class BannedUser extends React.Component { + private onUnbanClick = (e) => { MatrixClientPeg.get().unban(this.props.member.roomId, this.props.member.userId).catch((err) => { const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); console.error("Failed to unban: " + err); @@ -84,8 +89,10 @@ export class BannedUser extends React.Component { if (this.props.canUnban) { unbanButton = ( - + { _t('Unban') } ); @@ -104,29 +111,29 @@ export class BannedUser extends React.Component { } } -@replaceableComponent("views.settings.tabs.room.RolesRoomSettingsTab") -export default class RolesRoomSettingsTab extends React.Component { - static propTypes = { - roomId: PropTypes.string.isRequired, - }; +interface IProps { + roomId: string; +} - componentDidMount(): void { - MatrixClientPeg.get().on("RoomState.members", this._onRoomMembership); +@replaceableComponent("views.settings.tabs.room.RolesRoomSettingsTab") +export default class RolesRoomSettingsTab extends React.Component { + componentDidMount() { + MatrixClientPeg.get().on("RoomState.members", this.onRoomMembership); } - componentWillUnmount(): void { + componentWillUnmount() { const client = MatrixClientPeg.get(); if (client) { - client.removeListener("RoomState.members", this._onRoomMembership); + client.removeListener("RoomState.members", this.onRoomMembership); } } - _onRoomMembership = (event, state, member) => { + private onRoomMembership = (event: MatrixEvent, state: RoomState, member: RoomMember) => { if (state.roomId !== this.props.roomId) return; this.forceUpdate(); }; - _populateDefaultPlEvents(eventsSection, stateLevel, eventsLevel) { + private populateDefaultPlEvents(eventsSection: Record, stateLevel: number, eventsLevel: number) { for (const desiredEvent of Object.keys(plEventsToShow)) { if (!(desiredEvent in eventsSection)) { eventsSection[desiredEvent] = (plEventsToShow[desiredEvent].isState ? stateLevel : eventsLevel); @@ -134,7 +141,7 @@ export default class RolesRoomSettingsTab extends React.Component { } } - _onPowerLevelsChanged = (value, powerLevelKey) => { + private onPowerLevelsChanged = (inputValue: string, powerLevelKey: string) => { const client = MatrixClientPeg.get(); const room = client.getRoom(this.props.roomId); const plEvent = room.currentState.getStateEvents('m.room.power_levels', ''); @@ -145,7 +152,7 @@ export default class RolesRoomSettingsTab extends React.Component { const eventsLevelPrefix = "event_levels_"; - value = parseInt(value); + const value = parseInt(inputValue); if (powerLevelKey.startsWith(eventsLevelPrefix)) { // deep copy "events" object, Object.assign itself won't deep copy @@ -179,7 +186,7 @@ export default class RolesRoomSettingsTab extends React.Component { }); }; - _onUserPowerLevelChanged = (value, powerLevelKey) => { + private onUserPowerLevelChanged = (value: string, powerLevelKey: string) => { const client = MatrixClientPeg.get(); const room = client.getRoom(this.props.roomId); const plEvent = room.currentState.getStateEvents('m.room.power_levels', ''); @@ -263,7 +270,7 @@ export default class RolesRoomSettingsTab extends React.Component { currentUserLevel = defaultUserLevel; } - this._populateDefaultPlEvents( + this.populateDefaultPlEvents( eventsLevels, parseIntWithDefault(plContent.state_default, powerLevelDescriptors.state_default.defaultValue), parseIntWithDefault(plContent.events_default, powerLevelDescriptors.events_default.defaultValue), @@ -285,7 +292,7 @@ export default class RolesRoomSettingsTab extends React.Component { label={user} key={user} powerLevelKey={user} // Will be sent as the second parameter to `onChange` - onChange={this._onUserPowerLevelChanged} + onChange={this.onUserPowerLevelChanged} />, ); } else if (userLevels[user] < defaultUserLevel) { // muted @@ -296,7 +303,7 @@ export default class RolesRoomSettingsTab extends React.Component { label={user} key={user} powerLevelKey={user} // Will be sent as the second parameter to `onChange` - onChange={this._onUserPowerLevelChanged} + onChange={this.onUserPowerLevelChanged} />, ); } @@ -342,8 +349,9 @@ export default class RolesRoomSettingsTab extends React.Component { if (sender) bannedBy = sender.name; return ( + member={member} reason={banEvent.reason} + by={bannedBy} + /> ); })} @@ -370,7 +378,7 @@ export default class RolesRoomSettingsTab extends React.Component { usersDefault={defaultUserLevel} disabled={!canChangeLevels || currentUserLevel < value} powerLevelKey={key} // Will be sent as the second parameter to `onChange` - onChange={this._onPowerLevelsChanged} + onChange={this.onPowerLevelsChanged} />
    ; }); @@ -395,7 +403,7 @@ export default class RolesRoomSettingsTab extends React.Component { usersDefault={defaultUserLevel} disabled={!canChangeLevels || currentUserLevel < eventsLevels[eventType]} powerLevelKey={"event_levels_" + eventType} - onChange={this._onPowerLevelsChanged} + onChange={this.onPowerLevelsChanged} />
    ); diff --git a/src/components/views/settings/tabs/room/SecurityRoomSettingsTab.js b/src/components/views/settings/tabs/room/SecurityRoomSettingsTab.tsx similarity index 79% rename from src/components/views/settings/tabs/room/SecurityRoomSettingsTab.js rename to src/components/views/settings/tabs/room/SecurityRoomSettingsTab.tsx index ce883c6d23..02bbcfb751 100644 --- a/src/components/views/settings/tabs/room/SecurityRoomSettingsTab.js +++ b/src/components/views/settings/tabs/room/SecurityRoomSettingsTab.tsx @@ -1,5 +1,5 @@ /* -Copyright 2019 New Vector Ltd +Copyright 2019-2021 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. @@ -15,7 +15,7 @@ limitations under the License. */ import React from 'react'; -import PropTypes from 'prop-types'; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import {_t} from "../../../../../languageHandler"; import {MatrixClientPeg} from "../../../../../MatrixClientPeg"; import * as sdk from "../../../../.."; @@ -26,64 +26,92 @@ import StyledRadioGroup from '../../../elements/StyledRadioGroup'; import {SettingLevel} from "../../../../../settings/SettingLevel"; import SettingsStore from "../../../../../settings/SettingsStore"; import {UIFeature} from "../../../../../settings/UIFeature"; -import {replaceableComponent} from "../../../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../../../utils/replaceableComponent"; + +// Knock and private are reserved keywords which are not yet implemented. +enum JoinRule { + Public = "public", + Knock = "knock", + Invite = "invite", + Private = "private", +} + +enum GuestAccess { + CanJoin = "can_join", + Forbidden = "forbidden", +} + +enum HistoryVisibility { + Invited = "invited", + Joined = "joined", + Shared = "shared", + WorldReadable = "world_readable", +} + +interface IProps { + roomId: string; +} + +interface IState { + joinRule: JoinRule; + guestAccess: GuestAccess; + history: HistoryVisibility; + hasAliases: boolean; + encrypted: boolean; +} @replaceableComponent("views.settings.tabs.room.SecurityRoomSettingsTab") -export default class SecurityRoomSettingsTab extends React.Component { - static propTypes = { - roomId: PropTypes.string.isRequired, - }; - - constructor() { - super(); +export default class SecurityRoomSettingsTab extends React.Component { + constructor(props) { + super(props); this.state = { - joinRule: "invite", - guestAccess: "can_join", - history: "shared", + joinRule: JoinRule.Invite, + guestAccess: GuestAccess.CanJoin, + history: HistoryVisibility.Shared, hasAliases: false, encrypted: false, }; } // TODO: [REACT-WARNING] Move this to constructor - async UNSAFE_componentWillMount(): void { // eslint-disable-line camelcase - MatrixClientPeg.get().on("RoomState.events", this._onStateEvent); + async UNSAFE_componentWillMount() { // eslint-disable-line camelcase + MatrixClientPeg.get().on("RoomState.events", this.onStateEvent); const room = MatrixClientPeg.get().getRoom(this.props.roomId); const state = room.currentState; - const joinRule = this._pullContentPropertyFromEvent( + const joinRule: JoinRule = this.pullContentPropertyFromEvent( state.getStateEvents("m.room.join_rules", ""), 'join_rule', - 'invite', + JoinRule.Invite, ); - const guestAccess = this._pullContentPropertyFromEvent( + const guestAccess: GuestAccess = this.pullContentPropertyFromEvent( state.getStateEvents("m.room.guest_access", ""), 'guest_access', - 'forbidden', + GuestAccess.Forbidden, ); - const history = this._pullContentPropertyFromEvent( + const history: HistoryVisibility = this.pullContentPropertyFromEvent( state.getStateEvents("m.room.history_visibility", ""), 'history_visibility', - 'shared', + HistoryVisibility.Shared, ); const encrypted = MatrixClientPeg.get().isRoomEncrypted(this.props.roomId); this.setState({joinRule, guestAccess, history, encrypted}); - const hasAliases = await this._hasAliases(); + const hasAliases = await this.hasAliases(); this.setState({hasAliases}); } - _pullContentPropertyFromEvent(event, key, defaultValue) { + private pullContentPropertyFromEvent(event: MatrixEvent, key: string, defaultValue: T): T { if (!event || !event.getContent()) return defaultValue; return event.getContent()[key] || defaultValue; } - componentWillUnmount(): void { - MatrixClientPeg.get().removeListener("RoomState.events", this._onStateEvent); + componentWillUnmount() { + MatrixClientPeg.get().removeListener("RoomState.events", this.onStateEvent); } - _onStateEvent = (e) => { + private onStateEvent = (e: MatrixEvent) => { const refreshWhenTypes = [ 'm.room.join_rules', 'm.room.guest_access', @@ -93,7 +121,7 @@ export default class SecurityRoomSettingsTab extends React.Component { if (refreshWhenTypes.includes(e.getType())) this.forceUpdate(); }; - _onEncryptionChange = (e) => { + private onEncryptionChange = (e: React.ChangeEvent) => { Modal.createTrackedDialog('Enable encryption', '', QuestionDialog, { title: _t('Enable encryption?'), description: _t( @@ -102,10 +130,9 @@ export default class SecurityRoomSettingsTab extends React.Component { "may prevent many bots and bridges from working correctly. Learn more about encryption.", {}, { - 'a': (sub) => { - return {sub}; - }, + a: sub => {sub}, }, ), onFinished: (confirm) => { @@ -127,12 +154,12 @@ export default class SecurityRoomSettingsTab extends React.Component { }); }; - _fixGuestAccess = (e) => { + private fixGuestAccess = (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); - const joinRule = "invite"; - const guestAccess = "can_join"; + const joinRule = JoinRule.Invite; + const guestAccess = GuestAccess.CanJoin; const beforeJoinRule = this.state.joinRule; const beforeGuestAccess = this.state.guestAccess; @@ -149,7 +176,7 @@ export default class SecurityRoomSettingsTab extends React.Component { }); }; - _onRoomAccessRadioToggle = (roomAccess) => { + private onRoomAccessRadioToggle = (roomAccess: string) => { // join_rule // INVITE | PUBLIC // ----------------------+---------------- @@ -163,20 +190,20 @@ export default class SecurityRoomSettingsTab extends React.Component { // invite them, you clearly want them to join, whether they're a // guest or not. In practice, guest_access should probably have // been implemented as part of the join_rules enum. - let joinRule = "invite"; - let guestAccess = "can_join"; + let joinRule = JoinRule.Invite; + let guestAccess = GuestAccess.CanJoin; switch (roomAccess) { case "invite_only": // no change - use defaults above break; case "public_no_guests": - joinRule = "public"; - guestAccess = "forbidden"; + joinRule = JoinRule.Public; + guestAccess = GuestAccess.Forbidden; break; case "public_with_guests": - joinRule = "public"; - guestAccess = "can_join"; + joinRule = JoinRule.Public; + guestAccess = GuestAccess.CanJoin; break; } @@ -195,7 +222,7 @@ export default class SecurityRoomSettingsTab extends React.Component { }); }; - _onHistoryRadioToggle = (history) => { + private onHistoryRadioToggle = (history: HistoryVisibility) => { const beforeHistory = this.state.history; this.setState({history: history}); MatrixClientPeg.get().sendStateEvent(this.props.roomId, "m.room.history_visibility", { @@ -206,11 +233,11 @@ export default class SecurityRoomSettingsTab extends React.Component { }); }; - _updateBlacklistDevicesFlag = (checked) => { + private updateBlacklistDevicesFlag = (checked: boolean) => { MatrixClientPeg.get().getRoom(this.props.roomId).setBlacklistUnverifiedDevices(checked); }; - async _hasAliases() { + private async hasAliases(): Promise { const cli = MatrixClientPeg.get(); if (await cli.doesServerSupportUnstableFeature("org.matrix.msc2432")) { const response = await cli.unstableGetLocalAliases(this.props.roomId); @@ -224,7 +251,7 @@ export default class SecurityRoomSettingsTab extends React.Component { } } - _renderRoomAccess() { + private renderRoomAccess() { const client = MatrixClientPeg.get(); const room = client.getRoom(this.props.roomId); const joinRule = this.state.joinRule; @@ -240,7 +267,7 @@ export default class SecurityRoomSettingsTab extends React.Component { {_t("Guests cannot join this room even if explicitly invited.")}  - {_t("Click here to fix")} + {_t("Click here to fix")}
    ); @@ -265,7 +292,7 @@ export default class SecurityRoomSettingsTab extends React.Component { ; } @@ -356,7 +383,7 @@ export default class SecurityRoomSettingsTab extends React.Component { let historySection = (<> {_t("Who can read history?")}
    - {this._renderHistory()} + {this.renderHistory()}
    ); if (!SettingsStore.getValue(UIFeature.RoomHistorySettings)) { @@ -373,15 +400,16 @@ export default class SecurityRoomSettingsTab extends React.Component {
    {_t("Once enabled, encryption cannot be disabled.")}
    - +
    {encryptionSettings}
    {_t("Who can access this room?")}
    - {this._renderRoomAccess()} + {this.renderRoomAccess()}
    {historySection} diff --git a/src/components/views/settings/tabs/user/GeneralUserSettingsTab.js b/src/components/views/settings/tabs/user/GeneralUserSettingsTab.js index b1ad9f3d23..5118414903 100644 --- a/src/components/views/settings/tabs/user/GeneralUserSettingsTab.js +++ b/src/components/views/settings/tabs/user/GeneralUserSettingsTab.js @@ -192,7 +192,11 @@ export default class GeneralUserSettingsTab extends React.Component { SettingsStore.setValue("language", null, SettingLevel.DEVICE, newLanguage); this.setState({language: newLanguage}); - PlatformPeg.get().reload(); + const platform = PlatformPeg.get(); + if (platform) { + platform.setLanguage(newLanguage); + platform.reload(); + } }; _onSpellCheckLanguagesChange = (languages) => { @@ -319,8 +323,11 @@ export default class GeneralUserSettingsTab extends React.Component { return (
    {_t("Language and region")} - +
    ); } @@ -329,8 +336,10 @@ export default class GeneralUserSettingsTab extends React.Component { return (
    {_t("Spell check dictionaries")} - +
    ); } diff --git a/src/components/views/settings/tabs/user/HelpUserSettingsTab.js b/src/components/views/settings/tabs/user/HelpUserSettingsTab.tsx similarity index 64% rename from src/components/views/settings/tabs/user/HelpUserSettingsTab.js rename to src/components/views/settings/tabs/user/HelpUserSettingsTab.tsx index e16ee686f5..3fa0be478c 100644 --- a/src/components/views/settings/tabs/user/HelpUserSettingsTab.js +++ b/src/components/views/settings/tabs/user/HelpUserSettingsTab.tsx @@ -1,6 +1,5 @@ /* -Copyright 2019 New Vector Ltd -Copyright 2020 The Matrix.org Foundation C.I.C. +Copyright 2019-2021 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. @@ -16,27 +15,37 @@ limitations under the License. */ import React from 'react'; -import PropTypes from 'prop-types'; import {_t, getCurrentLanguage} from "../../../../../languageHandler"; import {MatrixClientPeg} from "../../../../../MatrixClientPeg"; import AccessibleButton from "../../../elements/AccessibleButton"; +import AccessibleTooltipButton from '../../../elements/AccessibleTooltipButton'; import SdkConfig from "../../../../../SdkConfig"; import createRoom from "../../../../../createRoom"; import Modal from "../../../../../Modal"; -import * as sdk from "../../../../../"; +import * as sdk from "../../../../.."; import PlatformPeg from "../../../../../PlatformPeg"; import * as KeyboardShortcuts from "../../../../../accessibility/KeyboardShortcuts"; import UpdateCheckButton from "../../UpdateCheckButton"; -import {replaceableComponent} from "../../../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../../../utils/replaceableComponent"; +import { copyPlaintext } from "../../../../../utils/strings"; +import * as ContextMenu from "../../../../structures/ContextMenu"; +import { toRightOf } from "../../../../structures/ContextMenu"; + +interface IProps { + closeSettingsFn: () => {}; +} + +interface IState { + appVersion: string; + canUpdate: boolean; +} @replaceableComponent("views.settings.tabs.user.HelpUserSettingsTab") -export default class HelpUserSettingsTab extends React.Component { - static propTypes = { - closeSettingsFn: PropTypes.func.isRequired, - }; +export default class HelpUserSettingsTab extends React.Component { + protected closeCopiedTooltip: () => void; - constructor() { - super(); + constructor(props) { + super(props); this.state = { appVersion: null, @@ -53,7 +62,13 @@ export default class HelpUserSettingsTab extends React.Component { }); } - _onClearCacheAndReload = (e) => { + componentWillUnmount() { + // if the Copied tooltip is open then get rid of it, there are ways to close the modal which wouldn't close + // the tooltip otherwise, such as pressing Escape + if (this.closeCopiedTooltip) this.closeCopiedTooltip(); + } + + private onClearCacheAndReload = (e) => { if (!PlatformPeg.get()) return; // Dev note: please keep this log line, it's useful when troubleshooting a MatrixClient suddenly @@ -65,7 +80,7 @@ export default class HelpUserSettingsTab extends React.Component { }); }; - _onBugReport = (e) => { + private onBugReport = (e) => { const BugReportDialog = sdk.getComponent("dialogs.BugReportDialog"); if (!BugReportDialog) { return; @@ -73,7 +88,7 @@ export default class HelpUserSettingsTab extends React.Component { Modal.createTrackedDialog('Bug Report Dialog', '', BugReportDialog, {}); }; - _onStartBotChat = (e) => { + private onStartBotChat = (e) => { this.props.closeSettingsFn(); createRoom({ dmUserId: SdkConfig.get().welcomeUserId, @@ -81,7 +96,7 @@ export default class HelpUserSettingsTab extends React.Component { }); }; - _showSpoiler = (event) => { + private showSpoiler = (event) => { const target = event.target; target.innerHTML = target.getAttribute('data-spoiler'); @@ -93,7 +108,7 @@ export default class HelpUserSettingsTab extends React.Component { selection.addRange(range); }; - _renderLegal() { + private renderLegal() { const tocLinks = SdkConfig.get().terms_and_conditions_links; if (!tocLinks) return null; @@ -114,7 +129,7 @@ export default class HelpUserSettingsTab extends React.Component { ); } - _renderCredits() { + private renderCredits() { // Note: This is not translated because it is legal text. // Also,   is ugly but necessary. return ( @@ -122,34 +137,48 @@ export default class HelpUserSettingsTab extends React.Component { {_t("Credits")}
    ); } + onAccessTokenCopyClick = async (e) => { + e.preventDefault(); + const target = e.target; // copy target before we go async and React throws it away + + const successful = await copyPlaintext(MatrixClientPeg.get().getAccessToken()); + const buttonRect = target.getBoundingClientRect(); + const GenericTextContextMenu = sdk.getComponent('context_menus.GenericTextContextMenu'); + const {close} = ContextMenu.createMenu(GenericTextContextMenu, { + ...toRightOf(buttonRect, 2), + message: successful ? _t('Copied!') : _t('Failed to copy'), + }); + this.closeCopiedTooltip = target.onmouseleave = close; + } + render() { const brand = SdkConfig.get().brand; @@ -188,7 +217,7 @@ export default class HelpUserSettingsTab extends React.Component { }, )}
    - + {_t("Chat with %(brand)s Bot", { brand })}
    @@ -212,28 +241,27 @@ export default class HelpUserSettingsTab extends React.Component {
    {_t('Bug reporting')}
    - { - _t( "If you've submitted a bug via GitHub, debug logs can help " + - "us track down the problem. Debug logs contain application " + - "usage data including your username, the IDs or aliases of " + - "the rooms or groups you have visited and the usernames of " + - "other users. They do not contain messages.", - ) - } + {_t( + "If you've submitted a bug via GitHub, debug logs can help " + + "us track down the problem. Debug logs contain application " + + "usage data including your username, the IDs or aliases of " + + "the rooms or groups you have visited and the usernames of " + + "other users. They do not contain messages.", + )}
    - + {_t("Submit debug logs")}
    - { - _t( "To report a Matrix-related security issue, please read the Matrix.org " + - "Security Disclosure Policy.", {}, - { - 'a': (sub) => - {sub}, - }) - } + {_t( + "To report a Matrix-related security issue, please read the Matrix.org " + + "Security Disclosure Policy.", {}, + { + a: sub => {sub}, + }, + )}
    ); @@ -260,20 +288,29 @@ export default class HelpUserSettingsTab extends React.Component { {updateButton}
    - {this._renderLegal()} - {this._renderCredits()} + {this.renderLegal()} + {this.renderCredits()}
    {_t("Advanced")}
    {_t("Homeserver is")} {MatrixClientPeg.get().getHomeserverUrl()}
    {_t("Identity Server is")} {MatrixClientPeg.get().getIdentityServerUrl()}
    - {_t("Access Token:") + ' '} - - <{ _t("click to reveal") }> - +
    +
    + {_t("Access Token")}
    + {_t("Your access token gives full access to your account." + + " Do not share it with anyone." )} +
    + {MatrixClientPeg.get().getAccessToken()} + +
    +

    - + {_t("Clear cache and reload")}
    diff --git a/src/components/views/settings/tabs/user/LabsUserSettingsTab.js b/src/components/views/settings/tabs/user/LabsUserSettingsTab.js index f515f1862b..98148b19e0 100644 --- a/src/components/views/settings/tabs/user/LabsUserSettingsTab.js +++ b/src/components/views/settings/tabs/user/LabsUserSettingsTab.js @@ -22,6 +22,8 @@ import LabelledToggleSwitch from "../../../elements/LabelledToggleSwitch"; import * as sdk from "../../../../../index"; import {SettingLevel} from "../../../../../settings/SettingLevel"; import {replaceableComponent} from "../../../../../utils/replaceableComponent"; +import SdkConfig from "../../../../../SdkConfig"; +import BetaCard from "../../../beta/BetaCard"; export class LabsSettingToggle extends React.Component { static propTypes = { @@ -48,14 +50,40 @@ export default class LabsUserSettingsTab extends React.Component { } render() { - const SettingsFlag = sdk.getComponent("views.elements.SettingsFlag"); - const flags = SettingsStore.getFeatureSettingNames().map(f => ); + const features = SettingsStore.getFeatureSettingNames(); + const [labs, betas] = features.reduce((arr, f) => { + arr[SettingsStore.getBetaInfo(f) ? 1 : 0].push(f); + return arr; + }, [[], []]); + + let betaSection; + if (betas.length) { + betaSection =
    + { betas.map(f => ) } +
    ; + } + + let labsSection; + if (SdkConfig.get()['showLabsSettings']) { + const SettingsFlag = sdk.getComponent("views.elements.SettingsFlag"); + const flags = labs.map(f => ); + + labsSection =
    + {flags} + + + + +
    ; + } + return ( -
    +
    {_t("Labs")}
    { - _t('Customise your experience with experimental labs features. ' + + _t('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.', {}, { 'a': (sub) => { return -
    - {flags} - - - - -
    + { betaSection } + { labsSection }
    ); } diff --git a/src/components/views/settings/tabs/user/MjolnirUserSettingsTab.js b/src/components/views/settings/tabs/user/MjolnirUserSettingsTab.tsx similarity index 90% rename from src/components/views/settings/tabs/user/MjolnirUserSettingsTab.js rename to src/components/views/settings/tabs/user/MjolnirUserSettingsTab.tsx index 91f6728a7a..6997defea9 100644 --- a/src/components/views/settings/tabs/user/MjolnirUserSettingsTab.js +++ b/src/components/views/settings/tabs/user/MjolnirUserSettingsTab.tsx @@ -1,5 +1,5 @@ /* -Copyright 2019, 2020 The Matrix.org Foundation C.I.C. +Copyright 2019-2021 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. @@ -25,10 +25,16 @@ import {MatrixClientPeg} from "../../../../../MatrixClientPeg"; import * as sdk from "../../../../../index"; import {replaceableComponent} from "../../../../../utils/replaceableComponent"; +interface IState { + busy: boolean; + newPersonalRule: string; + newList: string; +} + @replaceableComponent("views.settings.tabs.user.MjolnirUserSettingsTab") -export default class MjolnirUserSettingsTab extends React.Component { - constructor() { - super(); +export default class MjolnirUserSettingsTab extends React.Component<{}, IState> { + constructor(props) { + super(props); this.state = { busy: false, @@ -37,15 +43,15 @@ export default class MjolnirUserSettingsTab extends React.Component { }; } - _onPersonalRuleChanged = (e) => { + private onPersonalRuleChanged = (e) => { this.setState({newPersonalRule: e.target.value}); }; - _onNewListChanged = (e) => { + private onNewListChanged = (e) => { this.setState({newList: e.target.value}); }; - _onAddPersonalRule = async (e) => { + private onAddPersonalRule = async (e) => { e.preventDefault(); e.stopPropagation(); @@ -72,7 +78,7 @@ export default class MjolnirUserSettingsTab extends React.Component { } }; - _onSubscribeList = async (e) => { + private onSubscribeList = async (e) => { e.preventDefault(); e.stopPropagation(); @@ -94,7 +100,7 @@ export default class MjolnirUserSettingsTab extends React.Component { } }; - async _removePersonalRule(rule: ListRule) { + private async removePersonalRule(rule: ListRule) { this.setState({busy: true}); try { const list = Mjolnir.sharedInstance().getPersonalList(); @@ -112,7 +118,7 @@ export default class MjolnirUserSettingsTab extends React.Component { } } - async _unsubscribeFromList(list: BanList) { + private async unsubscribeFromList(list: BanList) { this.setState({busy: true}); try { await Mjolnir.sharedInstance().unsubscribeFromList(list.roomId); @@ -130,7 +136,7 @@ export default class MjolnirUserSettingsTab extends React.Component { } } - _viewListRules(list: BanList) { + private viewListRules(list: BanList) { const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); const room = MatrixClientPeg.get().getRoom(list.roomId); @@ -161,7 +167,7 @@ export default class MjolnirUserSettingsTab extends React.Component { }); } - _renderPersonalBanListRules() { + private renderPersonalBanListRules() { const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); const list = Mjolnir.sharedInstance().getPersonalList(); @@ -174,7 +180,7 @@ export default class MjolnirUserSettingsTab extends React.Component {
  • this._removePersonalRule(rule)} + onClick={() => this.removePersonalRule(rule)} disabled={this.state.busy} > {_t("Remove")} @@ -192,7 +198,7 @@ export default class MjolnirUserSettingsTab extends React.Component { ); } - _renderSubscribedBanLists() { + private renderSubscribedBanLists() { const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); const personalList = Mjolnir.sharedInstance().getPersonalList(); @@ -209,14 +215,14 @@ export default class MjolnirUserSettingsTab extends React.Component {
  • this._unsubscribeFromList(list)} + onClick={() => this.unsubscribeFromList(list)} disabled={this.state.busy} > {_t("Unsubscribe")}   this._viewListRules(list)} + onClick={() => this.viewListRules(list)} disabled={this.state.busy} > {_t("View rules")} @@ -271,21 +277,21 @@ export default class MjolnirUserSettingsTab extends React.Component { )}
  • - {this._renderPersonalBanListRules()} + {this.renderPersonalBanListRules()}
    -
    + {_t("Ignore")} @@ -303,20 +309,20 @@ export default class MjolnirUserSettingsTab extends React.Component { )}
    - {this._renderSubscribedBanLists()} + {this.renderSubscribedBanLists()}
    - + {_t("Subscribe")} diff --git a/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.js b/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.tsx similarity index 80% rename from src/components/views/settings/tabs/user/PreferencesUserSettingsTab.js rename to src/components/views/settings/tabs/user/PreferencesUserSettingsTab.tsx index 0cd3dd6698..f02c5c9ce0 100644 --- a/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.js +++ b/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.tsx @@ -1,5 +1,5 @@ /* -Copyright 2019 New Vector Ltd +Copyright 2019-2021 The Matrix.org Foundation C.I.C. Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> Licensed under the Apache License, Version 2.0 (the "License"); @@ -23,10 +23,24 @@ import Field from "../../../elements/Field"; import * as sdk from "../../../../.."; import PlatformPeg from "../../../../../PlatformPeg"; import {SettingLevel} from "../../../../../settings/SettingLevel"; -import {replaceableComponent} from "../../../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../../../utils/replaceableComponent"; + +interface IState { + autoLaunch: boolean; + autoLaunchSupported: boolean; + warnBeforeExit: boolean; + warnBeforeExitSupported: boolean; + alwaysShowMenuBarSupported: boolean; + alwaysShowMenuBar: boolean; + minimizeToTraySupported: boolean; + minimizeToTray: boolean; + autocompleteDelay: string; + readMarkerInViewThresholdMs: string; + readMarkerOutOfViewThresholdMs: string; +} @replaceableComponent("views.settings.tabs.user.PreferencesUserSettingsTab") -export default class PreferencesUserSettingsTab extends React.Component { +export default class PreferencesUserSettingsTab extends React.Component<{}, IState> { static ROOM_LIST_SETTINGS = [ 'breadcrumbs', ]; @@ -68,8 +82,8 @@ export default class PreferencesUserSettingsTab extends React.Component { // Autocomplete delay (niche text box) ]; - constructor() { - super(); + constructor(props) { + super(props); this.state = { autoLaunch: false, @@ -89,7 +103,7 @@ export default class PreferencesUserSettingsTab extends React.Component { }; } - async componentDidMount(): void { + async componentDidMount() { const platform = PlatformPeg.get(); const autoLaunchSupported = await platform.supportsAutoLaunch(); @@ -128,38 +142,38 @@ export default class PreferencesUserSettingsTab extends React.Component { }); } - _onAutoLaunchChange = (checked) => { + private onAutoLaunchChange = (checked: boolean) => { PlatformPeg.get().setAutoLaunchEnabled(checked).then(() => this.setState({autoLaunch: checked})); }; - _onWarnBeforeExitChange = (checked) => { + private onWarnBeforeExitChange = (checked: boolean) => { PlatformPeg.get().setWarnBeforeExit(checked).then(() => this.setState({warnBeforeExit: checked})); } - _onAlwaysShowMenuBarChange = (checked) => { + private onAlwaysShowMenuBarChange = (checked: boolean) => { PlatformPeg.get().setAutoHideMenuBarEnabled(!checked).then(() => this.setState({alwaysShowMenuBar: checked})); }; - _onMinimizeToTrayChange = (checked) => { + private onMinimizeToTrayChange = (checked: boolean) => { PlatformPeg.get().setMinimizeToTrayEnabled(checked).then(() => this.setState({minimizeToTray: checked})); }; - _onAutocompleteDelayChange = (e) => { + private onAutocompleteDelayChange = (e: React.ChangeEvent) => { this.setState({autocompleteDelay: e.target.value}); SettingsStore.setValue("autocompleteDelay", null, SettingLevel.DEVICE, e.target.value); }; - _onReadMarkerInViewThresholdMs = (e) => { + private onReadMarkerInViewThresholdMs = (e: React.ChangeEvent) => { this.setState({readMarkerInViewThresholdMs: e.target.value}); SettingsStore.setValue("readMarkerInViewThresholdMs", null, SettingLevel.DEVICE, e.target.value); }; - _onReadMarkerOutOfViewThresholdMs = (e) => { + private onReadMarkerOutOfViewThresholdMs = (e: React.ChangeEvent) => { this.setState({readMarkerOutOfViewThresholdMs: e.target.value}); SettingsStore.setValue("readMarkerOutOfViewThresholdMs", null, SettingLevel.DEVICE, e.target.value); }; - _renderGroup(settingIds) { + private renderGroup(settingIds: string[]): React.ReactNodeArray { const SettingsFlag = sdk.getComponent("views.elements.SettingsFlag"); return settingIds.filter(SettingsStore.isEnabled).map(i => { return ; @@ -171,7 +185,7 @@ export default class PreferencesUserSettingsTab extends React.Component { if (this.state.autoLaunchSupported) { autoLaunchOption = ; } @@ -179,7 +193,7 @@ export default class PreferencesUserSettingsTab extends React.Component { if (this.state.warnBeforeExitSupported) { warnBeforeExitOption = ; } @@ -187,7 +201,7 @@ export default class PreferencesUserSettingsTab extends React.Component { if (this.state.alwaysShowMenuBarSupported) { autoHideMenuOption = ; } @@ -195,7 +209,7 @@ export default class PreferencesUserSettingsTab extends React.Component { if (this.state.minimizeToTraySupported) { minimizeToTrayOption = ; } @@ -205,22 +219,22 @@ export default class PreferencesUserSettingsTab extends React.Component {
    {_t("Room list")} - {this._renderGroup(PreferencesUserSettingsTab.ROOM_LIST_SETTINGS)} + {this.renderGroup(PreferencesUserSettingsTab.ROOM_LIST_SETTINGS)}
    {_t("Composer")} - {this._renderGroup(PreferencesUserSettingsTab.COMPOSER_SETTINGS)} + {this.renderGroup(PreferencesUserSettingsTab.COMPOSER_SETTINGS)}
    {_t("Timeline")} - {this._renderGroup(PreferencesUserSettingsTab.TIMELINE_SETTINGS)} + {this.renderGroup(PreferencesUserSettingsTab.TIMELINE_SETTINGS)}
    {_t("General")} - {this._renderGroup(PreferencesUserSettingsTab.GENERAL_SETTINGS)} + {this.renderGroup(PreferencesUserSettingsTab.GENERAL_SETTINGS)} {minimizeToTrayOption} {autoHideMenuOption} {autoLaunchOption} @@ -229,17 +243,17 @@ export default class PreferencesUserSettingsTab extends React.Component { label={_t('Autocomplete delay (ms)')} type='number' value={this.state.autocompleteDelay} - onChange={this._onAutocompleteDelayChange} /> + onChange={this.onAutocompleteDelayChange} /> + onChange={this.onReadMarkerInViewThresholdMs} /> + onChange={this.onReadMarkerOutOfViewThresholdMs} />
    ); diff --git a/src/components/views/settings/tabs/user/SecurityUserSettingsTab.js b/src/components/views/settings/tabs/user/SecurityUserSettingsTab.js index 8a70811399..53ed511b0a 100644 --- a/src/components/views/settings/tabs/user/SecurityUserSettingsTab.js +++ b/src/components/views/settings/tabs/user/SecurityUserSettingsTab.js @@ -255,15 +255,18 @@ export default class SecurityUserSettingsTab extends React.Component { _renderIgnoredUsers() { const {waitingUnignored, ignoredUserIds} = this.state; - if (!ignoredUserIds || ignoredUserIds.length === 0) return null; - - const userIds = ignoredUserIds - .map((u) => ); + const userIds = !ignoredUserIds?.length + ? _t('You have no ignored users.') + : ignoredUserIds.map((u) => { + return ( + + ); + }); return (
    diff --git a/src/components/views/settings/tabs/user/VoiceUserSettingsTab.js b/src/components/views/settings/tabs/user/VoiceUserSettingsTab.js index d8adab55f6..362059f8ed 100644 --- a/src/components/views/settings/tabs/user/VoiceUserSettingsTab.js +++ b/src/components/views/settings/tabs/user/VoiceUserSettingsTab.js @@ -176,8 +176,8 @@ export default class VoiceUserSettingsTab extends React.Component { const defaultDevice = getDefaultDevice(audioOutputs); speakerDropdown = ( + value={this.state.activeAudioOutput || defaultDevice} + onChange={this._setAudioOutput}> {this._renderDeviceOptions(audioOutputs, 'audioOutput')} ); @@ -188,8 +188,8 @@ export default class VoiceUserSettingsTab extends React.Component { const defaultDevice = getDefaultDevice(audioInputs); microphoneDropdown = ( + value={this.state.activeAudioInput || defaultDevice} + onChange={this._setAudioInput}> {this._renderDeviceOptions(audioInputs, 'audioInput')} ); @@ -200,8 +200,8 @@ export default class VoiceUserSettingsTab extends React.Component { const defaultDevice = getDefaultDevice(videoInputs); webcamDropdown = ( + value={this.state.activeVideoInput || defaultDevice} + onChange={this._setVideoInput}> {this._renderDeviceOptions(videoInputs, 'videoInput')} ); diff --git a/src/components/views/spaces/SpaceBasicSettings.tsx b/src/components/views/spaces/SpaceBasicSettings.tsx index bc378ab956..ec40f7bed8 100644 --- a/src/components/views/spaces/SpaceBasicSettings.tsx +++ b/src/components/views/spaces/SpaceBasicSettings.tsx @@ -32,17 +32,11 @@ interface IProps { setTopic(topic: string): void; } -const SpaceBasicSettings = ({ +export const SpaceAvatar = ({ avatarUrl, avatarDisabled = false, setAvatar, - name = "", - nameDisabled = false, - setName, - topic = "", - topicDisabled = false, - setTopic, -}: IProps) => { +}: Pick) => { const avatarUploadRef = useRef(); const [avatar, setAvatarDataUrl] = useState(avatarUrl); // avatar data url cache @@ -81,20 +75,34 @@ const SpaceBasicSettings = ({ } } + return
    + { avatarSection } + { + if (!e.target.files?.length) return; + const file = e.target.files[0]; + setAvatar(file); + const reader = new FileReader(); + reader.onload = (ev) => { + setAvatarDataUrl(ev.target.result as string); + }; + reader.readAsDataURL(file); + }} accept="image/*" /> +
    ; +}; + +const SpaceBasicSettings = ({ + avatarUrl, + avatarDisabled = false, + setAvatar, + name = "", + nameDisabled = false, + setName, + topic = "", + topicDisabled = false, + setTopic, +}: IProps) => { return
    -
    - { avatarSection } - { - if (!e.target.files?.length) return; - const file = e.target.files[0]; - setAvatar(file); - const reader = new FileReader(); - reader.onload = (ev) => { - setAvatarDataUrl(ev.target.result as string); - }; - reader.readAsDataURL(file); - }} accept="image/*" /> -
    + { return ( @@ -41,17 +48,39 @@ enum Visibility { Private, } +const spaceNameValidator = withValidation({ + rules: [ + { + key: "required", + test: async ({ value }) => !!value, + invalid: () => _t("Please enter a name for the space"), + }, + ], +}); + const SpaceCreateMenu = ({ onFinished }) => { const cli = useContext(MatrixClientContext); const [visibility, setVisibility] = useState(null); - const [name, setName] = useState(""); - const [avatar, setAvatar] = useState(null); - const [topic, setTopic] = useState(""); const [busy, setBusy] = useState(false); - const onSpaceCreateClick = async () => { + const [name, setName] = useState(""); + const spaceNameField = useRef(); + const [avatar, setAvatar] = useState(null); + const [topic, setTopic] = useState(""); + + const onSpaceCreateClick = async (e) => { + e.preventDefault(); if (busy) return; + setBusy(true); + // require & validate the space name field + if (!await spaceNameField.current.validate({ allowEmpty: false })) { + spaceNameField.current.focus(); + spaceNameField.current.validate({ allowEmpty: false, focused: true }); + setBusy(false); + return; + } + const initialState: IStateEvent[] = [ { type: EventType.RoomHistoryVisibility, @@ -107,7 +136,7 @@ const SpaceCreateMenu = ({ onFinished }) => { if (visibility === null) { body =

    { _t("Create a space") }

    -

    { _t("Spaces are new ways to group rooms and people. " + +

    { _t("Spaces are a new way to group rooms and people. " + "To join an existing space you'll need an invite.") }

    { />

    { _t("You can change this later") }

    + +
    ; } else { body = @@ -146,9 +177,32 @@ const SpaceCreateMenu = ({ onFinished }) => { }

    - + + - + setName(ev.target.value)} + ref={spaceNameField} + onValidate={spaceNameValidator} + disabled={busy} + /> + + setTopic(ev.target.value)} + rows={3} + disabled={busy} + /> + + + { busy ? _t("Creating...") : _t("Create") }
    ; @@ -164,6 +218,13 @@ const SpaceCreateMenu = ({ onFinished }) => { managed={false} > + { + onFinished(); + defaultDispatcher.dispatch({ + action: Action.ViewUserSettings, + initialTabId: USER_LABS_TAB, + }); + }} /> { body } ; diff --git a/src/components/views/spaces/SpacePanel.tsx b/src/components/views/spaces/SpacePanel.tsx index bacf1bd929..411b0f9b5e 100644 --- a/src/components/views/spaces/SpacePanel.tsx +++ b/src/components/views/spaces/SpacePanel.tsx @@ -25,9 +25,12 @@ import SpaceCreateMenu from "./SpaceCreateMenu"; import {SpaceItem} from "./SpaceTreeLevel"; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import {useEventEmitter} from "../../../hooks/useEventEmitter"; -import SpaceStore, {HOME_SPACE, UPDATE_SELECTED_SPACE, UPDATE_TOP_LEVEL_SPACES} from "../../../stores/SpaceStore"; +import SpaceStore, { + UPDATE_INVITED_SPACES, + UPDATE_SELECTED_SPACE, + UPDATE_TOP_LEVEL_SPACES, +} from "../../../stores/SpaceStore"; import AutoHideScrollbar from "../../structures/AutoHideScrollbar"; -import {SpaceNotificationState} from "../../../stores/notifications/SpaceNotificationState"; import NotificationBadge from "../rooms/NotificationBadge"; import { RovingAccessibleButton, @@ -35,13 +38,15 @@ import { RovingTabIndexProvider, } from "../../../accessibility/RovingTabIndex"; import {Key} from "../../../Keyboard"; +import {RoomNotificationStateStore} from "../../../stores/notifications/RoomNotificationStateStore"; +import {NotificationState} from "../../../stores/notifications/NotificationState"; interface IButtonProps { space?: Room; className?: string; selected?: boolean; tooltip?: string; - notificationState?: SpaceNotificationState; + notificationState?: NotificationState; isNarrow?: boolean; onClick(): void; } @@ -105,19 +110,21 @@ const SpaceButton: React.FC = ({ ; } -const useSpaces = (): [Room[], Room | null] => { +const useSpaces = (): [Room[], Room[], Room | null] => { + const [invites, setInvites] = useState(SpaceStore.instance.invitedSpaces); + useEventEmitter(SpaceStore.instance, UPDATE_INVITED_SPACES, setInvites); const [spaces, setSpaces] = useState(SpaceStore.instance.spacePanelSpaces); useEventEmitter(SpaceStore.instance, UPDATE_TOP_LEVEL_SPACES, setSpaces); const [activeSpace, setActiveSpace] = useState(SpaceStore.instance.activeSpace); useEventEmitter(SpaceStore.instance, UPDATE_SELECTED_SPACE, setActiveSpace); - return [spaces, activeSpace]; + return [invites, spaces, activeSpace]; }; const SpacePanel = () => { // 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 [spaces, activeSpace] = useSpaces(); + const [invites, spaces, activeSpace] = useSpaces(); const [isPanelCollapsed, setPanelCollapsed] = useState(true); const newClasses = classNames("mx_SpaceButton_new", { @@ -205,10 +212,17 @@ const SpacePanel = () => { className="mx_SpaceButton_home" onClick={() => SpaceStore.instance.setActiveSpace(null)} selected={!activeSpace} - tooltip={_t("Home")} - notificationState={SpaceStore.instance.getNotificationState(HOME_SPACE)} + tooltip={_t("All rooms")} + notificationState={RoomNotificationStateStore.instance.globalState} isNarrow={isPanelCollapsed} /> + { invites.map(s => setPanelCollapsed(false)} + />) } { spaces.map(s => {

    { _t("Share invite link") }

    { copiedText } - { showRoomInviteDialog(space.roomId); @@ -59,7 +60,7 @@ const SpacePublicShare = ({ space, onFinished }: IProps) => { >

    { _t("Invite people") }

    { _t("Invite with email or username") } -
    + : null }
    ; }; diff --git a/src/components/views/spaces/SpaceTreeLevel.tsx b/src/components/views/spaces/SpaceTreeLevel.tsx index ca9e26cabe..f34baf256b 100644 --- a/src/components/views/spaces/SpaceTreeLevel.tsx +++ b/src/components/views/spaces/SpaceTreeLevel.tsx @@ -20,6 +20,7 @@ import {Room} from "matrix-js-sdk/src/models/room"; import RoomAvatar from "../avatars/RoomAvatar"; import SpaceStore from "../../../stores/SpaceStore"; +import SpaceTreeLevelLayoutStore from "../../../stores/SpaceTreeLevelLayoutStore"; import NotificationBadge from "../rooms/NotificationBadge"; import {RovingAccessibleButton} from "../../../accessibility/roving/RovingAccessibleButton"; import {RovingAccessibleTooltipButton} from "../../../accessibility/roving/RovingAccessibleTooltipButton"; @@ -45,6 +46,8 @@ import RoomViewStore from "../../../stores/RoomViewStore"; import {SetRightPanelPhasePayload} from "../../../dispatcher/payloads/SetRightPanelPhasePayload"; import {RightPanelPhases} from "../../../stores/RightPanelStorePhases"; import {EventType} from "matrix-js-sdk/src/@types/event"; +import {StaticNotificationState} from "../../../stores/notifications/StaticNotificationState"; +import {NotificationColor} from "../../../stores/notifications/NotificationColor"; interface IItemProps { space?: Room; @@ -66,8 +69,14 @@ export class SpaceItem extends React.PureComponent { constructor(props) { super(props); + const collapsed = SpaceTreeLevelLayoutStore.instance.getSpaceCollapsedState( + props.space.roomId, + this.props.parents, + !props.isNested, // default to collapsed for root items + ); + this.state = { - collapsed: !props.isNested, // default to collapsed for root items + collapsed: collapsed, contextMenuPosition: null, }; } @@ -76,13 +85,21 @@ export class SpaceItem extends React.PureComponent { if (this.props.onExpand && this.state.collapsed) { this.props.onExpand(); } - this.setState({collapsed: !this.state.collapsed}); + const newCollapsedState = !this.state.collapsed; + + SpaceTreeLevelLayoutStore.instance.setSpaceCollapsedState( + this.props.space.roomId, + this.props.parents, + newCollapsedState, + ); + this.setState({collapsed: newCollapsedState}); // don't bubble up so encapsulating button for space // doesn't get triggered evt.stopPropagation(); } private onContextMenu = (ev: React.MouseEvent) => { + if (this.props.space.getMyMembership() !== "join") return; ev.preventDefault(); ev.stopPropagation(); this.setState({ @@ -185,12 +202,14 @@ export class SpaceItem extends React.PureComponent { }; private renderContextMenu(): React.ReactElement { + if (this.props.space.getMyMembership() !== "join") return null; + let contextMenu = null; if (this.state.contextMenuPosition) { const userId = this.context.getUserId(); let inviteOption; - if (this.props.space.canInvite(userId)) { + if (this.props.space.getJoinRule() === "public" || this.props.space.canInvite(userId)) { inviteOption = ( { const isActive = activeSpaces.includes(space); const itemClasses = classNames({ "mx_SpaceItem": true, + "mx_SpaceItem_narrow": isNarrow, "collapsed": collapsed, "hasSubSpaces": childSpaces && childSpaces.length, }); + + const isInvite = space.getMyMembership() === "invite"; const classes = classNames("mx_SpaceButton", { mx_SpaceButton_active: isActive, mx_SpaceButton_hasMenuOpen: !!this.state.contextMenuPosition, mx_SpaceButton_narrow: isNarrow, + mx_SpaceButton_invite: isInvite, }); - const notificationState = SpaceStore.instance.getNotificationState(space.roomId); + const notificationState = isInvite + ? StaticNotificationState.forSymbol("!", NotificationColor.Red) + : SpaceStore.instance.getNotificationState(space.roomId); let childItems; if (childSpaces && !collapsed) { diff --git a/src/components/views/verification/VerificationCancelled.js b/src/components/views/verification/VerificationCancelled.js index 0bbaea1804..c57094d9b5 100644 --- a/src/components/views/verification/VerificationCancelled.js +++ b/src/components/views/verification/VerificationCancelled.js @@ -29,14 +29,14 @@ export default class VerificationCancelled extends React.Component { render() { const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); return
    -

    {_t( - "The other party cancelled the verification.", - )}

    - +

    {_t( + "The other party cancelled the verification.", + )}

    +
    ; } } diff --git a/src/components/views/voice_messages/Clock.tsx b/src/components/views/voice_messages/Clock.tsx index 6c256957e9..23e6762c52 100644 --- a/src/components/views/voice_messages/Clock.tsx +++ b/src/components/views/voice_messages/Clock.tsx @@ -29,14 +29,20 @@ interface IState { * displayed, making it possible to see "82:29". */ @replaceableComponent("views.voice_messages.Clock") -export default class Clock extends React.PureComponent { +export default class Clock extends React.Component { public constructor(props) { super(props); } + shouldComponentUpdate(nextProps: Readonly, nextState: Readonly, nextContext: any): boolean { + const currentFloor = Math.floor(this.props.seconds); + const nextFloor = Math.floor(nextProps.seconds); + return currentFloor !== nextFloor; + } + public render() { const minutes = Math.floor(this.props.seconds / 60).toFixed(0).padStart(2, '0'); - const seconds = Math.round(this.props.seconds % 60).toFixed(0).padStart(2, '0'); // hide millis + const seconds = Math.floor(this.props.seconds % 60).toFixed(0).padStart(2, '0'); // hide millis return {minutes}:{seconds}; } } diff --git a/src/components/views/voice_messages/LiveRecordingClock.tsx b/src/components/views/voice_messages/LiveRecordingClock.tsx index 00316d196a..b82539eb16 100644 --- a/src/components/views/voice_messages/LiveRecordingClock.tsx +++ b/src/components/views/voice_messages/LiveRecordingClock.tsx @@ -15,12 +15,12 @@ limitations under the License. */ import React from "react"; -import {IRecordingUpdate, VoiceRecorder} from "../../../voice/VoiceRecorder"; +import {IRecordingUpdate, VoiceRecording} from "../../../voice/VoiceRecording"; import {replaceableComponent} from "../../../utils/replaceableComponent"; import Clock from "./Clock"; interface IProps { - recorder: VoiceRecorder; + recorder: VoiceRecording; } interface IState { @@ -31,7 +31,7 @@ interface IState { * A clock for a live recording. */ @replaceableComponent("views.voice_messages.LiveRecordingClock") -export default class LiveRecordingClock extends React.Component { +export default class LiveRecordingClock extends React.PureComponent { public constructor(props) { super(props); @@ -39,12 +39,6 @@ export default class LiveRecordingClock extends React.Component this.props.recorder.liveData.onUpdate(this.onRecordingUpdate); } - shouldComponentUpdate(nextProps: Readonly, nextState: Readonly, nextContext: any): boolean { - const currentFloor = Math.floor(this.state.seconds); - const nextFloor = Math.floor(nextState.seconds); - return currentFloor !== nextFloor; - } - private onRecordingUpdate = (update: IRecordingUpdate) => { this.setState({seconds: update.timeSeconds}); }; diff --git a/src/components/views/voice_messages/LiveRecordingWaveform.tsx b/src/components/views/voice_messages/LiveRecordingWaveform.tsx index e7cab4a5cb..aab89f6ab1 100644 --- a/src/components/views/voice_messages/LiveRecordingWaveform.tsx +++ b/src/components/views/voice_messages/LiveRecordingWaveform.tsx @@ -15,22 +15,20 @@ limitations under the License. */ import React from "react"; -import {IRecordingUpdate, VoiceRecorder} from "../../../voice/VoiceRecorder"; +import {IRecordingUpdate, RECORDING_PLAYBACK_SAMPLES, VoiceRecording} from "../../../voice/VoiceRecording"; import {replaceableComponent} from "../../../utils/replaceableComponent"; import {arrayFastResample, arraySeed} from "../../../utils/arrays"; import {percentageOf} from "../../../utils/numbers"; import Waveform from "./Waveform"; interface IProps { - recorder: VoiceRecorder; + recorder: VoiceRecording; } interface IState { heights: number[]; } -const DOWNSAMPLE_TARGET = 35; // number of bars we want - /** * A waveform which shows the waveform of a live recording */ @@ -39,14 +37,14 @@ export default class LiveRecordingWaveform extends React.PureComponent { // The waveform and the downsample target are pretty close, so we should be fine to // do this, despite the docs on arrayFastResample. - const bars = arrayFastResample(Array.from(update.waveform), DOWNSAMPLE_TARGET); + const bars = arrayFastResample(Array.from(update.waveform), RECORDING_PLAYBACK_SAMPLES); this.setState({ // The incoming data is between zero and one, but typically even screaming into a // microphone won't send you over 0.6, so we artificially adjust the gain for the diff --git a/src/components/views/voice_messages/PlayPauseButton.tsx b/src/components/views/voice_messages/PlayPauseButton.tsx new file mode 100644 index 0000000000..1f87eb012d --- /dev/null +++ b/src/components/views/voice_messages/PlayPauseButton.tsx @@ -0,0 +1,61 @@ +/* +Copyright 2021 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, {ReactNode} from "react"; +import {replaceableComponent} from "../../../utils/replaceableComponent"; +import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; +import {_t} from "../../../languageHandler"; +import {Playback, PlaybackState} from "../../../voice/Playback"; +import classNames from "classnames"; + +interface IProps { + // Playback instance to manipulate. Cannot change during the component lifecycle. + playback: Playback; + + // The playback phase to render. Able to change during the component lifecycle. + playbackPhase: PlaybackState; +} + +/** + * Displays a play/pause button (activating the play/pause function of the recorder) + * to be displayed in reference to a recording. + */ +@replaceableComponent("views.voice_messages.PlayPauseButton") +export default class PlayPauseButton extends React.PureComponent { + public constructor(props) { + super(props); + } + + private onClick = async () => { + await this.props.playback.toggle(); + }; + + public render(): ReactNode { + const isPlaying = this.props.playback.isPlaying; + const isDisabled = this.props.playbackPhase === PlaybackState.Decoding; + const classes = classNames('mx_PlayPauseButton', { + 'mx_PlayPauseButton_play': !isPlaying, + 'mx_PlayPauseButton_pause': isPlaying, + 'mx_PlayPauseButton_disabled': isDisabled, + }); + return ; + } +} diff --git a/src/components/views/voice_messages/PlaybackClock.tsx b/src/components/views/voice_messages/PlaybackClock.tsx new file mode 100644 index 0000000000..2e8ec9a3e7 --- /dev/null +++ b/src/components/views/voice_messages/PlaybackClock.tsx @@ -0,0 +1,71 @@ +/* +Copyright 2021 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 {replaceableComponent} from "../../../utils/replaceableComponent"; +import Clock from "./Clock"; +import {Playback, PlaybackState} from "../../../voice/Playback"; +import {UPDATE_EVENT} from "../../../stores/AsyncStore"; + +interface IProps { + playback: Playback; +} + +interface IState { + seconds: number; + durationSeconds: number; + playbackPhase: PlaybackState; +} + +/** + * A clock for a playback of a recording. + */ +@replaceableComponent("views.voice_messages.PlaybackClock") +export default class PlaybackClock extends React.PureComponent { + public constructor(props) { + super(props); + + this.state = { + seconds: this.props.playback.clockInfo.timeSeconds, + // we track the duration on state because we won't really know what the clip duration + // is until the first time update, and as a PureComponent we are trying to dedupe state + // updates as much as possible. This is just the easiest way to avoid a forceUpdate() or + // member property to track "did we get a duration". + durationSeconds: this.props.playback.clockInfo.durationSeconds, + playbackPhase: PlaybackState.Stopped, // assume not started, so full clock + }; + this.props.playback.on(UPDATE_EVENT, this.onPlaybackUpdate); + this.props.playback.clockInfo.liveData.onUpdate(this.onTimeUpdate); + } + + private onPlaybackUpdate = (ev: PlaybackState) => { + // Convert Decoding -> Stopped because we don't care about the distinction here + if (ev === PlaybackState.Decoding) ev = PlaybackState.Stopped; + this.setState({playbackPhase: ev}); + }; + + private onTimeUpdate = (time: number[]) => { + this.setState({seconds: time[0], durationSeconds: time[1]}); + }; + + public render() { + let seconds = this.state.seconds; + if (this.state.playbackPhase === PlaybackState.Stopped) { + seconds = this.state.durationSeconds; + } + return ; + } +} diff --git a/src/components/views/voice_messages/PlaybackWaveform.tsx b/src/components/views/voice_messages/PlaybackWaveform.tsx new file mode 100644 index 0000000000..2e9f163f5e --- /dev/null +++ b/src/components/views/voice_messages/PlaybackWaveform.tsx @@ -0,0 +1,68 @@ +/* +Copyright 2021 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 {replaceableComponent} from "../../../utils/replaceableComponent"; +import {arraySeed, arrayTrimFill} from "../../../utils/arrays"; +import Waveform from "./Waveform"; +import {Playback, PLAYBACK_WAVEFORM_SAMPLES} from "../../../voice/Playback"; +import {percentageOf} from "../../../utils/numbers"; + +interface IProps { + playback: Playback; +} + +interface IState { + heights: number[]; + progress: number; +} + +/** + * A waveform which shows the waveform of a previously recorded recording + */ +@replaceableComponent("views.voice_messages.PlaybackWaveform") +export default class PlaybackWaveform extends React.PureComponent { + public constructor(props) { + super(props); + + this.state = { + heights: this.toHeights(this.props.playback.waveform), + progress: 0, // default no progress + }; + + this.props.playback.waveformData.onUpdate(this.onWaveformUpdate); + this.props.playback.clockInfo.liveData.onUpdate(this.onTimeUpdate); + } + + private toHeights(waveform: number[]) { + const seed = arraySeed(0, PLAYBACK_WAVEFORM_SAMPLES); + return arrayTrimFill(waveform, PLAYBACK_WAVEFORM_SAMPLES, seed); + } + + private onWaveformUpdate = (waveform: number[]) => { + this.setState({heights: this.toHeights(waveform)}); + }; + + private onTimeUpdate = (time: number[]) => { + // Track percentages to a general precision to avoid over-waking the component. + const progress = Number(percentageOf(time[0], 0, time[1]).toFixed(3)); + this.setState({progress}); + }; + + public render() { + return ; + } +} diff --git a/src/components/views/voice_messages/RecordingPlayback.tsx b/src/components/views/voice_messages/RecordingPlayback.tsx new file mode 100644 index 0000000000..776997cec2 --- /dev/null +++ b/src/components/views/voice_messages/RecordingPlayback.tsx @@ -0,0 +1,62 @@ +/* +Copyright 2021 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 {Playback, PlaybackState} from "../../../voice/Playback"; +import React, {ReactNode} from "react"; +import {UPDATE_EVENT} from "../../../stores/AsyncStore"; +import PlaybackWaveform from "./PlaybackWaveform"; +import PlayPauseButton from "./PlayPauseButton"; +import PlaybackClock from "./PlaybackClock"; + +interface IProps { + // Playback instance to render. Cannot change during component lifecycle: create + // an all-new component instead. + playback: Playback; +} + +interface IState { + playbackPhase: PlaybackState; +} + +export default class RecordingPlayback extends React.PureComponent { + constructor(props: IProps) { + super(props); + + this.state = { + playbackPhase: PlaybackState.Decoding, // default assumption + }; + + // We don't need to de-register: the class handles this for us internally + this.props.playback.on(UPDATE_EVENT, this.onPlaybackUpdate); + + // Don't wait for the promise to complete - it will emit a progress update when it + // is done, and it's not meant to take long anyhow. + // noinspection JSIgnoredPromiseFromCall + this.props.playback.prepare(); + } + + private onPlaybackUpdate = (ev: PlaybackState) => { + this.setState({playbackPhase: ev}); + }; + + public render(): ReactNode { + return
    + + + +
    + } +} diff --git a/src/components/views/voice_messages/Waveform.tsx b/src/components/views/voice_messages/Waveform.tsx index 5fa68dcadc..840a5a12b3 100644 --- a/src/components/views/voice_messages/Waveform.tsx +++ b/src/components/views/voice_messages/Waveform.tsx @@ -16,9 +16,11 @@ limitations under the License. import React from "react"; import {replaceableComponent} from "../../../utils/replaceableComponent"; +import classNames from "classnames"; interface IProps { relHeights: number[]; // relative heights (0-1) + progress: number; // percent complete, 0-1, default 100% } interface IState { @@ -28,9 +30,16 @@ interface IState { * A simple waveform component. This renders bars (centered vertically) for each * height provided in the component properties. Updating the properties will update * the rendered waveform. + * + * For CSS purposes, a mx_Waveform_bar_100pct class is added when the bar should be + * "filled", as a demonstration of the progress property. */ @replaceableComponent("views.voice_messages.Waveform") export default class Waveform extends React.PureComponent { + public static defaultProps = { + progress: 1, + }; + public constructor(props) { super(props); } @@ -38,7 +47,13 @@ export default class Waveform extends React.PureComponent { public render() { return
    {this.props.relHeights.map((h, i) => { - return ; + const progress = this.props.progress; + const isCompleteBar = (i / this.props.relHeights.length) <= progress && progress > 0; + const classes = classNames({ + 'mx_Waveform_bar': true, + 'mx_Waveform_bar_100pct': isCompleteBar, + }); + return ; })}
    ; } diff --git a/src/components/views/voip/AudioFeed.tsx b/src/components/views/voip/AudioFeed.tsx new file mode 100644 index 0000000000..c78f0c0fc8 --- /dev/null +++ b/src/components/views/voip/AudioFeed.tsx @@ -0,0 +1,97 @@ +/* +Copyright 2021 Šimon Brandner + +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, {createRef} from 'react'; +import { CallFeed, CallFeedEvent } from 'matrix-js-sdk/src/webrtc/callFeed'; +import { logger } from 'matrix-js-sdk/src/logger'; +import CallMediaHandler from "../../../CallMediaHandler"; + +interface IProps { + feed: CallFeed, +} + +export default class AudioFeed extends React.Component { + private element = createRef(); + + componentDidMount() { + this.props.feed.addListener(CallFeedEvent.NewStream, this.onNewStream); + this.playMedia(); + } + + componentWillUnmount() { + this.props.feed.removeListener(CallFeedEvent.NewStream, this.onNewStream); + this.stopMedia(); + } + + private playMedia() { + const element = this.element.current; + const audioOutput = CallMediaHandler.getAudioOutput(); + + if (audioOutput) { + try { + // This seems quite unreliable in Chrome, although I haven't yet managed to make a jsfiddle where + // it fails. + // It seems reliable if you set the sink ID after setting the srcObject and then set the sink ID + // back to the default after the call is over - Dave + element.setSinkId(audioOutput); + } catch (e) { + console.error("Couldn't set requested audio output device: using default", e); + logger.warn("Couldn't set requested audio output device: using default", e); + } + } + + element.muted = false; + element.srcObject = this.props.feed.stream; + element.autoplay = true; + + try { + // A note on calling methods on media elements: + // We used to have queues per media element to serialise all calls on those elements. + // The reason given for this was that load() and play() were racing. However, we now + // never call load() explicitly so this seems unnecessary. However, serialising every + // operation was causing bugs where video would not resume because some play command + // had got stuck and all media operations were queued up behind it. If necessary, we + // should serialise the ones that need to be serialised but then be able to interrupt + // them with another load() which will cancel the pending one, but since we don't call + // load() explicitly, it shouldn't be a problem. - Dave + element.play() + } catch (e) { + logger.info("Failed to play media element with feed", this.props.feed, e); + } + } + + private stopMedia() { + const element = this.element.current; + + element.pause(); + element.src = null; + + // As per comment in componentDidMount, setting the sink ID back to the + // default once the call is over makes setSinkId work reliably. - Dave + // Since we are not using the same element anymore, the above doesn't + // seem to be necessary - Šimon + } + + private onNewStream = () => { + this.playMedia(); + }; + + render() { + return ( +
    ; + const avatarSize = this.props.pipMode ? 76 : 160; + // The 'content' for the call, ie. the videos for a video call and profile picture // for voice calls (fills the bg) let contentView: React.ReactNode; @@ -488,11 +492,13 @@ export default class CallView extends React.Component { const isOnHold = this.state.isLocalOnHold || this.state.isRemoteOnHold; let holdTransferContent; if (transfereeCall) { - const transferTargetRoom = MatrixClientPeg.get().getRoom(CallHandler.roomIdForCall(this.props.call)); + const transferTargetRoom = MatrixClientPeg.get().getRoom( + CallHandler.sharedInstance().roomIdForCall(this.props.call), + ); const transferTargetName = transferTargetRoom ? transferTargetRoom.name : _t("unknown person"); const transfereeRoom = MatrixClientPeg.get().getRoom( - CallHandler.roomIdForCall(transfereeCall), + CallHandler.sharedInstance().roomIdForCall(transfereeCall), ); const transfereeName = transfereeRoom ? transfereeRoom.name : _t("unknown person"); @@ -528,52 +534,85 @@ export default class CallView extends React.Component {
    ; } - if (this.props.call.type === CallType.Video) { - let localVideoFeed = null; - let onHoldBackground = null; - const backgroundStyle: CSSProperties = {}; - const containerClasses = classNames({ - mx_CallView_video: true, - mx_CallView_video_hold: isOnHold, - }); - if (isOnHold) { + // This is a bit messy. I can't see a reason to have two onHold/transfer screens + if (isOnHold || transfereeCall) { + if (this.props.call.type === CallType.Video) { + const containerClasses = classNames({ + mx_CallView_content: true, + mx_CallView_video: true, + mx_CallView_video_hold: isOnHold, + }); + let onHoldBackground = null; + const backgroundStyle: CSSProperties = {}; const backgroundAvatarUrl = avatarUrlForMember( - // is it worth getting the size of the div to pass here? + // is it worth getting the size of the div to pass here? this.props.call.getOpponentMember(), 1024, 1024, 'crop', ); backgroundStyle.backgroundImage = 'url(' + backgroundAvatarUrl + ')'; onHoldBackground =
    ; - } - if (!this.state.vidMuted) { - localVideoFeed = ; - } - // if we're fullscreen, we don't want to set a maxHeight on the video element. - const maxVideoHeight = getFullScreenElement() || !this.props.maxVideoHeight ? null : ( - this.props.maxVideoHeight - (HEADER_HEIGHT + BOTTOM_PADDING + BOTTOM_MARGIN_TOP_BOTTOM) - ); - contentView =
    - {onHoldBackground} - - {localVideoFeed} - {holdTransferContent} - {callControls} -
    ; - } else { - const avatarSize = this.props.pipMode ? 76 : 160; + contentView = ( +
    + {onHoldBackground} + {holdTransferContent} + {callControls} +
    + ); + } else { + const classes = classNames({ + mx_CallView_content: true, + mx_CallView_voice: true, + mx_CallView_voice_hold: isOnHold, + }); + + contentView =( +
    +
    +
    + +
    +
    + {holdTransferContent} + {callControls} +
    + ); + } + } else if (this.props.call.noIncomingFeeds()) { + // Here we're reusing the css classes from voice on hold, because + // I am lazy. If this gets merged, the CallView might be subject + // to change anyway - I might take an axe to this file in order to + // try to get other things working const classes = classNames({ + mx_CallView_content: true, mx_CallView_voice: true, - mx_CallView_voice_hold: isOnHold, }); + const feeds = this.props.call.getLocalFeeds().map((feed, i) => { + // Here we check to hide local audio feeds to achieve the same UI/UX + // as before. But once again this might be subject to change + if (feed.isVideoMuted()) return; + return ( + + ); + }); + + // Saying "Connecting" here isn't really true, but the best thing + // I can come up with, but this might be subject to change as well contentView =
    + {feeds}
    { />
    - {holdTransferContent} +
    {_t("Connecting")}
    + {callControls} +
    ; + } else { + const containerClasses = classNames({ + mx_CallView_content: true, + mx_CallView_video: true, + }); + + // TODO: Later the CallView should probably be reworked to support + // any number of feeds but now we can always expect there to be two + // feeds. This is because the js-sdk ignores any new incoming streams + const feeds = this.state.feeds.map((feed, i) => { + // Here we check to hide local audio feeds to achieve the same UI/UX + // as before. But once again this might be subject to change + if (feed.isVideoMuted() && feed.isLocal()) return; + return ( + + ); + }); + + contentView =
    + {feeds} {callControls}
    ; } diff --git a/src/components/views/voip/CallViewForRoom.tsx b/src/components/views/voip/CallViewForRoom.tsx index 97960d1e0b..0c785f758d 100644 --- a/src/components/views/voip/CallViewForRoom.tsx +++ b/src/components/views/voip/CallViewForRoom.tsx @@ -16,9 +16,11 @@ limitations under the License. import { CallState, MatrixCall } from 'matrix-js-sdk/src/webrtc/call'; import React from 'react'; -import CallHandler from '../../../CallHandler'; +import CallHandler, { CallHandlerEvent } from '../../../CallHandler'; import CallView from './CallView'; import dis from '../../../dispatcher/dispatcher'; +import {Resizable} from "re-resizable"; +import ResizeNotifier from "../../../utils/ResizeNotifier"; import {replaceableComponent} from "../../../utils/replaceableComponent"; interface IProps { @@ -28,9 +30,7 @@ interface IProps { // maxHeight style attribute for the video panel maxVideoHeight?: number; - // a callback which is called when the content in the callview changes - // in a way that is likely to cause a resize. - onResize?: any; + resizeNotifier: ResizeNotifier, } interface IState { @@ -54,24 +54,30 @@ export default class CallViewForRoom extends React.Component { public componentDidMount() { this.dispatcherRef = dis.register(this.onAction); + CallHandler.sharedInstance().addListener(CallHandlerEvent.CallChangeRoom, this.updateCall); } public componentWillUnmount() { dis.unregister(this.dispatcherRef); + CallHandler.sharedInstance().removeListener(CallHandlerEvent.CallChangeRoom, this.updateCall); } private onAction = (payload) => { switch (payload.action) { case 'call_state': { - const newCall = this.getCall(); - if (newCall !== this.state.call) { - this.setState({call: newCall}); - } + this.updateCall(); break; } } }; + private updateCall = () => { + const newCall = this.getCall(); + if (newCall !== this.state.call) { + this.setState({call: newCall}); + } + }; + private getCall(): MatrixCall { const call = CallHandler.sharedInstance().getCallForRoom(this.props.roomId); @@ -79,11 +85,50 @@ export default class CallViewForRoom extends React.Component { return call; } + private onResizeStart = () => { + this.props.resizeNotifier.startResizing(); + }; + + private onResize = () => { + this.props.resizeNotifier.notifyTimelineHeightChanged(); + }; + + private onResizeStop = () => { + this.props.resizeNotifier.stopResizing(); + }; + public render() { if (!this.state.call) return null; + // We subtract 8 as it the margin-bottom of the mx_CallViewForRoom_ResizeWrapper + const maxHeight = this.props.maxVideoHeight - 8; - return ; + return ( +
    + + + +
    + ); } } diff --git a/src/components/views/voip/IncomingCallBox.tsx b/src/components/views/voip/IncomingCallBox.tsx index 0ca2a196c2..2abdc0641d 100644 --- a/src/components/views/voip/IncomingCallBox.tsx +++ b/src/components/views/voip/IncomingCallBox.tsx @@ -72,7 +72,7 @@ export default class IncomingCallBox extends React.Component { e.stopPropagation(); dis.dispatch({ action: 'answer', - room_id: CallHandler.roomIdForCall(this.state.incomingCall), + room_id: CallHandler.sharedInstance().roomIdForCall(this.state.incomingCall), }); }; @@ -80,7 +80,7 @@ export default class IncomingCallBox extends React.Component { e.stopPropagation(); dis.dispatch({ action: 'reject', - room_id: CallHandler.roomIdForCall(this.state.incomingCall), + room_id: CallHandler.sharedInstance().roomIdForCall(this.state.incomingCall), }); }; @@ -91,7 +91,7 @@ export default class IncomingCallBox extends React.Component { let room = null; if (this.state.incomingCall) { - room = MatrixClientPeg.get().getRoom(CallHandler.roomIdForCall(this.state.incomingCall)); + room = MatrixClientPeg.get().getRoom(CallHandler.sharedInstance().roomIdForCall(this.state.incomingCall)); } const caller = room ? room.name : _t("Unknown caller"); diff --git a/src/components/views/voip/VideoFeed.tsx b/src/components/views/voip/VideoFeed.tsx index 23dbe4d46b..d22fa055ce 100644 --- a/src/components/views/voip/VideoFeed.tsx +++ b/src/components/views/voip/VideoFeed.tsx @@ -18,55 +18,102 @@ import classnames from 'classnames'; import { MatrixCall } from 'matrix-js-sdk/src/webrtc/call'; import React, {createRef} from 'react'; import SettingsStore from "../../../settings/SettingsStore"; +import { CallFeed, CallFeedEvent } from 'matrix-js-sdk/src/webrtc/callFeed'; +import { logger } from 'matrix-js-sdk/src/logger'; +import MemberAvatar from "../avatars/MemberAvatar" import {replaceableComponent} from "../../../utils/replaceableComponent"; -export enum VideoFeedType { - Local, - Remote, -} - interface IProps { call: MatrixCall, - type: VideoFeedType, + feed: CallFeed, - // maxHeight style attribute for the video element - maxHeight?: number, + // Whether this call view is for picture-in-picture mode + // otherwise, it's the larger call view when viewing the room the call is in. + // This is sort of a proxy for a number of things but we currently have no + // need to control those things separately, so this is simpler. + pipMode?: boolean; // a callback which is called when the video element is resized // due to a change in video metadata onResize?: (e: Event) => void, } -@replaceableComponent("views.voip.VideoFeed") -export default class VideoFeed extends React.Component { - private vid = createRef(); +interface IState { + audioMuted: boolean; + videoMuted: boolean; +} - componentDidMount() { - this.vid.current.addEventListener('resize', this.onResize); - this.setVideoElement(); +@replaceableComponent("views.voip.VideoFeed") +export default class VideoFeed extends React.Component { + private element = createRef(); + + constructor(props: IProps) { + super(props); + + this.state = { + audioMuted: this.props.feed.isAudioMuted(), + videoMuted: this.props.feed.isVideoMuted(), + }; } - componentDidUpdate(prevProps) { - if (this.props.call !== prevProps.call) { - this.setVideoElement(); - } + componentDidMount() { + this.props.feed.addListener(CallFeedEvent.NewStream, this.onNewStream); + this.playMedia(); } componentWillUnmount() { - this.vid.current.removeEventListener('resize', this.onResize); + this.props.feed.removeListener(CallFeedEvent.NewStream, this.onNewStream); + this.element.current?.removeEventListener('resize', this.onResize); + this.stopMedia(); } - private setVideoElement() { - if (this.props.type === VideoFeedType.Local) { - this.props.call.setLocalVideoElement(this.vid.current); - } else { - this.props.call.setRemoteVideoElement(this.vid.current); + private playMedia() { + const element = this.element.current; + if (!element) return; + // We play audio in AudioFeed, not here + element.muted = true; + element.srcObject = this.props.feed.stream; + element.autoplay = true; + try { + // A note on calling methods on media elements: + // We used to have queues per media element to serialise all calls on those elements. + // The reason given for this was that load() and play() were racing. However, we now + // never call load() explicitly so this seems unnecessary. However, serialising every + // operation was causing bugs where video would not resume because some play command + // had got stuck and all media operations were queued up behind it. If necessary, we + // should serialise the ones that need to be serialised but then be able to interrupt + // them with another load() which will cancel the pending one, but since we don't call + // load() explicitly, it shouldn't be a problem. - Dave + element.play() + } catch (e) { + logger.info("Failed to play media element with feed", this.props.feed, e); } } - onResize = (e) => { - if (this.props.onResize) { + private stopMedia() { + const element = this.element.current; + if (!element) return; + + element.pause(); + element.src = null; + + // As per comment in componentDidMount, setting the sink ID back to the + // default once the call is over makes setSinkId work reliably. - Dave + // Since we are not using the same element anymore, the above doesn't + // seem to be necessary - Šimon + } + + private onNewStream = () => { + this.setState({ + audioMuted: this.props.feed.isAudioMuted(), + videoMuted: this.props.feed.isVideoMuted(), + }); + this.playMedia(); + }; + + private onResize = (e) => { + if (this.props.onResize && !this.props.feed.isLocal()) { this.props.onResize(e); } }; @@ -74,17 +121,33 @@ export default class VideoFeed extends React.Component { render() { const videoClasses = { mx_VideoFeed: true, - mx_VideoFeed_local: this.props.type === VideoFeedType.Local, - mx_VideoFeed_remote: this.props.type === VideoFeedType.Remote, + mx_VideoFeed_local: this.props.feed.isLocal(), + mx_VideoFeed_remote: !this.props.feed.isLocal(), + mx_VideoFeed_voice: this.state.videoMuted, + mx_VideoFeed_video: !this.state.videoMuted, mx_VideoFeed_mirror: ( - this.props.type === VideoFeedType.Local && + this.props.feed.isLocal() && SettingsStore.getValue('VideoView.flipVideoHorizontally') ), }; - let videoStyle = {}; - if (this.props.maxHeight) videoStyle = { maxHeight: this.props.maxHeight }; + if (this.state.videoMuted) { + const member = this.props.feed.getMember(); + const avatarSize = this.props.pipMode ? 76 : 160; - return
    \n\n`; - }); + // conditions for display math detection $$...$$: + // - pattern starts and ends on a new line + // - left delimiter ($$) is not escaped by backslash + "display": "(^)\\$\\$(([^$]|\\\\\\$)+?)\\$\\$$", - md = md.replace(RegExp(inlinePattern, "gm"), function(m, p1) { - const p1e = AllHtmlEntities.encode(p1); - return ``; + // conditions for inline math detection $...$: + // - pattern starts at beginning of line, follows whitespace character or punctuation + // - pattern is on a single line + // - left and right delimiters ($) are not escaped by backslashes + // - left delimiter is not followed by whitespace character + // - right delimiter is not prefixed with whitespace character + "inline": + "(^|\\s|[.,!?:;])(?!\\\\)\\$(?!\\s)(([^$\\n]|\\\\\\$)*([^\\\\\\s\\$]|\\\\\\$)(?:\\\\\\$)?)\\$", + }, + "latex": { + // detect math with latex delimiters, inline: \(...\), display \[...\] + + // conditions for display math detection \[...\]: + // - pattern starts and ends on a new line + // - pattern is not empty + "display": "(^)\\\\\\[(?!\\\\\\])(.*?)\\\\\\]$", + + // conditions for inline math detection \(...\): + // - pattern starts at beginning of line or is not prefixed with backslash + // - pattern is not empty + "inline": "(^|[^\\\\])\\\\\\((?!\\\\\\))(.*?)\\\\\\)", + }, + }; + + patternNames.forEach(function(patternName) { + patternTypes.forEach(function(patternType) { + // get the regex replace pattern from config or use the default + const pattern = (((SdkConfig.get()["latex_maths_delims"] || + {})[patternType] || {})["pattern"] || {})[patternName] || + patternDefaults[patternName][patternType]; + + md = md.replace(RegExp(pattern, "gms"), function(m, p1, p2) { + const p2e = AllHtmlEntities.encode(p2); + switch (patternType) { + case "display": + return `${p1}
    \n\n
    \n\n`; + case "inline": + return `${p1}`; + } + }); + }); }); // make sure div tags always start on a new line, otherwise it will confuse @@ -72,16 +116,38 @@ export function htmlSerializeIfNeeded(model: EditorModel, {forceHTML = false} = const parser = new Markdown(md); if (!parser.isPlainText() || forceHTML) { // feed Markdown output to HTML parser - const phtml = cheerio.load(parser.toHTML(), - { _useHtmlParser2: true, decodeEntities: false }) - - // add fallback output for latex math, which should not be interpreted as markdown - phtml('div, span').each(function(i, e) { - const tex = phtml(e).attr('data-mx-maths') - if (tex) { - phtml(e).html(`${tex}`) - } + const phtml = cheerio.load(parser.toHTML(), { + // @ts-ignore: The `_useHtmlParser2` internal option is the + // simplest way to both parse and render using `htmlparser2`. + _useHtmlParser2: true, + decodeEntities: false, }); + + if (SettingsStore.getValue("feature_latex_maths")) { + // original Markdown without LaTeX replacements + const parserOrig = new Markdown(orig); + const phtmlOrig = cheerio.load(parserOrig.toHTML(), { + // @ts-ignore: The `_useHtmlParser2` internal option is the + // simplest way to both parse and render using `htmlparser2`. + _useHtmlParser2: true, + decodeEntities: false, + }); + + // since maths delimiters are handled before Markdown, + // code blocks could contain mangled content. + // replace code blocks with original content + phtmlOrig('code').each(function(i) { + phtml('code').eq(i).text(phtmlOrig('code').eq(i).text()); + }); + + // add fallback output for latex math, which should not be interpreted as markdown + phtml('div, span').each(function(i, e) { + const tex = phtml(e).attr('data-mx-maths') + if (tex) { + phtml(e).html(`${tex}`) + } + }); + } return phtml.html(); } // ensure removal of escape backslashes in non-Markdown messages diff --git a/src/effects/effect.ts b/src/effects/effect.ts new file mode 100644 index 0000000000..9011b07b61 --- /dev/null +++ b/src/effects/effect.ts @@ -0,0 +1,43 @@ +/* + Copyright 2020 Nurjin Jafar + Copyright 2020 Nordeck IT + Consulting GmbH. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +export type Effect = { + /** + * one or more emojis that will trigger this effect + */ + emojis: Array; + /** + * the matrix message type that will trigger this effect + */ + msgType: string; + /** + * the room command to trigger this effect + */ + command: string; + /** + * a function that returns the translated description of the effect + */ + description: () => string; + /** + * a function that returns the translated fallback message. this message will be shown if the user did not provide a custom message + */ + fallbackMessage: () => string; + /** + * animation options + */ + options: TOptions; +} diff --git a/src/effects/index.ts b/src/effects/index.ts index a22948ebcf..8ecb80020d 100644 --- a/src/effects/index.ts +++ b/src/effects/index.ts @@ -15,80 +15,11 @@ limitations under the License. */ import { _t, _td } from "../languageHandler"; - -export type Effect = { - /** - * one or more emojis that will trigger this effect - */ - emojis: Array; - /** - * the matrix message type that will trigger this effect - */ - msgType: string; - /** - * the room command to trigger this effect - */ - command: string; - /** - * a function that returns the translated description of the effect - */ - description: () => string; - /** - * a function that returns the translated fallback message. this message will be shown if the user did not provide a custom message - */ - fallbackMessage: () => string; - /** - * animation options - */ - options: TOptions; -} - -type ConfettiOptions = { - /** - * max confetti count - */ - maxCount: number; - /** - * particle animation speed - */ - speed: number; - /** - * the confetti animation frame interval in milliseconds - */ - frameInterval: number; - /** - * the alpha opacity of the confetti (between 0 and 1, where 1 is opaque and 0 is invisible) - */ - alpha: number; - /** - * use gradient instead of solid particle color - */ - gradient: boolean; -}; -type FireworksOptions = { - /** - * max fireworks count - */ - maxCount: number; - /** - * gravity value that firework adds to shift from it's start position - */ - gravity: number; -} -type SnowfallOptions = { - /** - * The maximum number of snowflakes to render at a given time - */ - maxCount: number; - /** - * The amount of gravity to apply to the snowflakes - */ - gravity: number; - /** - * The amount of drift (horizontal sway) to apply to the snowflakes. Each snowflake varies. - */ - maxDrift: number; -} +import { ConfettiOptions } from "./confetti"; +import { Effect } from "./effect"; +import { FireworksOptions } from "./fireworks"; +import { SnowfallOptions } from "./snowfall"; +import { SpaceInvadersOptions } from "./spaceinvaders"; /** * This configuration defines room effects that can be triggered by custom message types and emojis @@ -131,6 +62,17 @@ export const CHAT_EFFECTS: Array> = [ maxDrift: 5, }, } as Effect, + { + emojis: ["👾", "🌌"], + msgType: "io.element.effects.space_invaders", + command: "spaceinvaders", + description: () => _td("Sends the given message with a space themed effect"), + fallbackMessage: () => _t("sends space invaders") + " 👾", + options: { + maxCount: 50, + gravity: 0.01, + }, + } as Effect, ]; diff --git a/src/effects/spaceinvaders/index.ts b/src/effects/spaceinvaders/index.ts new file mode 100644 index 0000000000..9520e8366a --- /dev/null +++ b/src/effects/spaceinvaders/index.ts @@ -0,0 +1,119 @@ +/* + Copyright 2021 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 ICanvasEffect from '../ICanvasEffect'; +import { arrayFastClone } from "../../utils/arrays"; + +export type SpaceInvadersOptions = { + /** + * The maximum number of invaders to render at a given time + */ + maxCount: number; + /** + * The amount of gravity to apply to the invaders + */ + gravity: number; +} + +type Invader = { + x: number; + y: number; + xCol: number; + gravity: number; +} + +export const DefaultOptions: SpaceInvadersOptions = { + maxCount: 50, + gravity: 0.005, +}; + +const KEY_FRAME_INTERVAL = 15; // 15ms, roughly +const GLYPH = "👾"; + +export default class SpaceInvaders implements ICanvasEffect { + private readonly options: SpaceInvadersOptions; + + constructor(options: { [key: string]: any }) { + this.options = {...DefaultOptions, ...options}; + } + + private context: CanvasRenderingContext2D | null = null; + private particles: Array = []; + private lastAnimationTime: number; + + public isRunning: boolean; + + public start = async (canvas: HTMLCanvasElement, timeout = 3000) => { + if (!canvas) { + return; + } + this.context = canvas.getContext('2d'); + this.particles = []; + const count = this.options.maxCount; + while (this.particles.length < count) { + this.particles.push(this.resetParticle({} as Invader, canvas.width, canvas.height)); + } + this.isRunning = true; + requestAnimationFrame(this.renderLoop); + if (timeout) { + window.setTimeout(this.stop, timeout); + } + } + + public stop = async () => { + this.isRunning = false; + } + + private resetParticle = (particle: Invader, width: number, height: number): Invader => { + particle.x = Math.random() * width; + particle.y = Math.random() * -height; + particle.xCol = particle.x; + particle.gravity = this.options.gravity + (Math.random() * 6) + 4; + return particle; + } + + private renderLoop = (): void => { + if (!this.context || !this.context.canvas) { + return; + } + if (this.particles.length === 0) { + this.context.clearRect(0, 0, this.context.canvas.width, this.context.canvas.height); + } else { + const timeDelta = Date.now() - this.lastAnimationTime; + if (timeDelta >= KEY_FRAME_INTERVAL || !this.lastAnimationTime) { + // Clear the screen first + this.context.clearRect(0, 0, this.context.canvas.width, this.context.canvas.height); + + this.lastAnimationTime = Date.now(); + this.animateAndRenderInvaders(); + } + requestAnimationFrame(this.renderLoop); + } + }; + + private animateAndRenderInvaders() { + if (!this.context || !this.context.canvas) { + return; + } + this.context.font = "50px Twemoji"; + for (const particle of arrayFastClone(this.particles)) { + particle.y += particle.gravity; + + this.context.save(); + this.context.fillText(GLYPH, particle.x, particle.y); + this.context.restore(); + } + } +} diff --git a/src/email.ts b/src/email.ts index 6642a51541..0476d4467c 100644 --- a/src/email.ts +++ b/src/email.ts @@ -14,7 +14,10 @@ See the License for the specific language governing permissions and limitations under the License. */ -const EMAIL_ADDRESS_REGEX = /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i; +// Regexp based on Simpler Version from https://gist.github.com/gregseth/5582254 - matches RFC2822 +const EMAIL_ADDRESS_REGEX = new RegExp( + "^[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*" + // localpart + "@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$", "i"); export function looksValid(email: string): boolean { return EMAIL_ADDRESS_REGEX.test(email); diff --git a/src/i18n/strings/ar.json b/src/i18n/strings/ar.json index 5da09afd23..cc63995e0f 100644 --- a/src/i18n/strings/ar.json +++ b/src/i18n/strings/ar.json @@ -748,7 +748,7 @@ "Chat with %(brand)s Bot": "تخاطب مع الروبوت الخاص ب%(brand)s", "For help with using %(brand)s, click
    here or start a chat with our bot using the button below.": "للمساعدة في استخدام %(brand)s ، انقر هنا أو ابدأ محادثة مع برنامج الروبوت الخاص بنا باستخدام الزر أدناه.", "For help with using %(brand)s, click here.": "للمساعدة في استخدام %(brand)s انقر هنا.", - "Credits": "أمانة", + "Credits": "رصيد", "Legal": "قانوني", "General": "عام", "Discovery": "الاكتشاف", @@ -1488,5 +1488,69 @@ "Call failed because no webcam or microphone could not be accessed. Check that:": "فشلت المكالمة نظرًا لتعذر الوصول إلى كاميرا الويب أو الميكروفون. تحقق مما يلي:", "Unable to access webcam / microphone": "تعذر الوصول إلى كاميرا الويب / الميكروفون", "Call failed because no microphone could not be accessed. Check that a microphone is plugged in and set up correctly.": "فشلت المكالمة لأنه لا يمكن الوصول إلى ميكروفون. تحقق من توصيل الميكروفون وإعداده بشكل صحيح.", - "Unable to access microphone": "تعذر الوصول إلى الميكروفون" + "Unable to access microphone": "تعذر الوصول إلى الميكروفون", + "Cuba": "كوبا", + "Croatia": "كرواتيا", + "Costa Rica": "كوستا ريكا", + "Cook Islands": "جزر كوك", + "Congo - Kinshasa": "الكونغو - كينشاسا", + "Congo - Brazzaville": "الكونغو - برازافيل", + "Comoros": "جزر القمر", + "Colombia": "كولومبيا", + "Cocos (Keeling) Islands": "جزر كوكوس (كيلينغ)", + "Christmas Island": "جزيرة الكريسماس", + "China": "الصين", + "Chile": "تشيلي", + "Chad": "تشاد", + "Central African Republic": "جمهورية افريقيا الوسطى", + "Cayman Islands": "جزر كايمان", + "Caribbean Netherlands": "هولندا الكاريبية", + "Cape Verde": "الرأس الأخضر", + "Canada": "كندا", + "Cameroon": "الكاميرون", + "Cambodia": "كمبوديا", + "Burundi": "بوروندي", + "Burkina Faso": "بوركينا فاسو", + "Bulgaria": "بلغاريا", + "Brunei": "بروناي", + "British Virgin Islands": "جزر فيرجن البريطانية", + "British Indian Ocean Territory": "إقليم المحيط البريطاني الهندي", + "Brazil": "البرازيل", + "Bouvet Island": "جزيرة بوفيت", + "Botswana": "بوتسوانا", + "Bosnia": "البوسنة", + "Bolivia": "بوليفيا", + "Bhutan": "بوتان", + "Bermuda": "برمودا", + "Benin": "بنين", + "Belize": "بليز", + "Belgium": "بلجيكا", + "Belarus": "بيلاروسيا", + "Barbados": "بربادوس", + "Bangladesh": "بنغلاديش", + "Bahrain": "البحرين", + "Bahamas": "جزر البهاما", + "Azerbaijan": "أذربيجان", + "Austria": "النمسا", + "Australia": "أستراليا", + "Aruba": "أروبا", + "Armenia": "أرمينيا", + "Argentina": "الأرجنتين", + "Antigua & Barbuda": "أنتيغوا وبربودا", + "Antarctica": "أنتاركتيكا", + "Anguilla": "أنغيلا", + "Angola": "انجولا", + "Andorra": "أندورا", + "American Samoa": "ساموا الأمريكية", + "Algeria": "الجزائر", + "Åland Islands": "جزر آلاند", + "Try again": "حاول مجددا", + "We couldn't log you in": "لا يمكننا تسجيل دخولك", + "You're already in a call with this person.": "انت بالفعل في مكالمة مع هذا الشخص.", + "Already in call": "في مكالمة بالفعل", + "You've reached the maximum number of simultaneous calls.": "لقد وصلت للحد الاقصى من المكالمات المتزامنة.", + "Too Many Calls": "مكالمات كثيرة جدا", + "Call failed because webcam or microphone could not be accessed. Check that:": "فشلت المكالمة لعدم امكانية الوصل للميكروفون او الكاميرا , من فضلك قم بالتأكد.", + "Call failed because microphone could not be accessed. Check that a microphone is plugged in and set up correctly.": "فشلت المكالمة لعدم امكانية الوصل للميكروفون , تأكد من ان المكروفون متصل وتم اعداده بشكل صحيح.", + "Explore rooms": "استكشِف الغرف" } diff --git a/src/i18n/strings/az.json b/src/i18n/strings/az.json index 5a8dec76f0..987cef73b2 100644 --- a/src/i18n/strings/az.json +++ b/src/i18n/strings/az.json @@ -380,5 +380,8 @@ "%(senderDisplayName)s disabled flair for %(groups)s in this room.": "Bu otaqda %(groups)s üçün %(senderDisplayName)s aktiv oldu.", "powered by Matrix": "Matrix tərəfindən təchiz edilmişdir", "Custom Server Options": "Fərdi Server Seçimləri", - "%(senderDisplayName)s enabled flair for %(newGroups)s and disabled flair for %(oldGroups)s in this room.": "Bu otaqda %(newGroups)s üçün aktiv və %(oldGroups)s üçün %(senderDisplayName)s deaktiv oldu." + "%(senderDisplayName)s enabled flair for %(newGroups)s and disabled flair for %(oldGroups)s in this room.": "Bu otaqda %(newGroups)s üçün aktiv və %(oldGroups)s üçün %(senderDisplayName)s deaktiv oldu.", + "Create Account": "Hesab Aç", + "Explore rooms": "Otaqları kəşf edin", + "Sign In": "Daxil ol" } diff --git a/src/i18n/strings/bg.json b/src/i18n/strings/bg.json index 8c6ea60a8d..294d5a4979 100644 --- a/src/i18n/strings/bg.json +++ b/src/i18n/strings/bg.json @@ -728,7 +728,7 @@ "Enable them now": "Включете ги сега", "Toolbox": "Инструменти", "Collecting logs": "Събиране на логове", - "You must specify an event type!": "Трябва да укажате тип на събитието", + "You must specify an event type!": "Трябва да укажате тип на събитието!", "(HTTP status %(httpStatus)s)": "(HTTP статус %(httpStatus)s)", "All Rooms": "Във всички стаи", "Wednesday": "Сряда", @@ -2831,5 +2831,71 @@ "Åland Islands": "Оландски острови", "Afghanistan": "Афганистан", "United States": "Съединените щати", - "United Kingdom": "Обединеното кралство" + "United Kingdom": "Обединеното кралство", + "Manage & explore rooms": "Управление и откриване на стаи", + "Space options": "Опции на пространството", + "Workspace: ": "Работна област: ", + "Channel: ": "Канал: ", + "Spaces are a new way to group rooms and people. To join an existing space you'll need an invite.": "Пространствата са нов начин за групиране на стаи и хора. За да се присъедините към съществуващо пространство, се нуждаете от покана.", + "Open space for anyone, best for communities": "Открийте пространство за всеки, най-добро за общности", + "Add existing room": "Добави съществуваща стая", + "Leave space": "Напусни пространство", + "Invite with email or username": "Покани чрез имейл или потребителско име", + "Invite people": "Покани хора", + "Share invite link": "Сподели връзка с покана", + "Click to copy": "Натиснете за копиране", + "Delete": "Изтрий", + "Please enter a name for the space": "Моля, въведете име на пространството", + "Create a space": "Създаване на пространство", + "Add some details to help people recognise it.": "Добавете някои подробности, за да помогнете на хората да го разпознаят.", + "Invite only, best for yourself or teams": "Само с покана, най-добро за вас самият или отбори", + "Public": "Публично", + "Private": "Лично", + "You can change this later": "Можете да го промените по-късно", + "Your public space": "Вашето публично пространство", + "Your private space": "Вашето лично пространство", + "You can change these anytime.": "Можете да ги промените по всяко време.", + "Creating...": "Създаване...", + "Collapse space panel": "Свий панел с пространства", + "Expand space panel": "Разшири панел с пространства", + "unknown person": "", + "sends snowfall": "изпраща снеговалеж", + "Sends the given message with snowfall": "Изпраща даденото съобщение със снеговалеж", + "Sends the given message with fireworks": "Изпраща даденото съобщение с фойерверки", + "sends fireworks": "изпраща фойерверки", + "sends confetti": "изпраща конфети", + "Sends the given message with confetti": "Изпраща даденото съобщение с конфети", + "Show chat effects (animations when receiving e.g. confetti)": "Покажи чат ефектите (анимации при получаване, като например конфети)", + "Use Ctrl + Enter to send a message": "Използвай Ctrl + Enter за изпращане на съобщение", + "Use Command + Enter to send a message": "Използвай Command + Enter за изпращане на съобщение", + "Use Ctrl + F to search": "Използвай Ctrl + F за търсене", + "Use Command + F to search": "Използвай Command + F за търсене", + "Your feedback will help make spaces better. The more detail you can go into, the better.": "Вашата обратна връзка ще направи пространствата по-добри. Kолкото повече изпаднете в подобробности, толкова по-добре.", + "Beta available for web, desktop and Android. Some features may be unavailable on your homeserver.": "Бета версията е налична за уеб, десктоп и Android. Някои функции може да не са налични на вашия Home сървър.", + "You can leave the beta any time from settings or tapping on a beta badge, like the one above.": "Можете да напуснете бета версията по всяко време от настройките или чрез докосване на симовола за бета версията, като този по-горе.", + "%(brand)s will reload with Spaces enabled. Communities and custom tags will be hidden.": "%(brand)s ще се презареди с пуснати Пространства. Общностите и собствените етикети ще бъдат скрити.", + "If you leave, %(brand)s will reload with Spaces disabled. Communities and custom tags will be visible again.": "Ако напуснете, %(brand)s ще се презареди със изключени Пространства. Общностите и собствените етикети ще бъдат видими отново.", + "Beta available for web, desktop and Android. Thank you for trying the beta.": "Бета версията е налична за уеб, десктоп и Android. Благодарим ви, че изпробвахте бета версията.", + "Spaces are a new way to group rooms and people.": "Пространствата са нов начин за групиране на стаи и хора.", + "Spaces": "Пространства", + "Check your devices": "Проверете устройствата си", + "%(deviceId)s from %(ip)s": "%(deviceId)s от %(ip)s", + "This homeserver has been blocked by it's administrator.": "Този Home сървър е бил блокиран от администратора му.", + "Use app for a better experience": "Използвайте приложението за по-добра работа", + "Use app": "Използване на приложението", + "Element Web is experimental on mobile. For a better experience and the latest features, use our free native app.": "Element Web е експериментален на мобилни устройства. За по-добра работа и най-новите функции, използвайте нашето безплатно приложение.", + "Review to ensure your account is safe": "Прегледайте, за да уверите, че профилът ви е в безопастност", + "You have unverified logins": "Имате неверифицирани сесии", + "Share your public space": "Споделете публичното си място", + "Invite to %(spaceName)s": "Покани в %(spaceName)s", + "%(senderName)s has updated the widget layout": "%(senderName)s обнови оформлението на приспособлението", + "Sends the given message as a spoiler": "Изпраща даденото съобщение като спойлер", + "Your homeserver rejected your log in attempt. This could be due to things just taking too long. Please try again. If this continues, please contact your homeserver administrator.": "Вашият Home сървър отхвърли вашия опит за влизане. Това може да се дължи на неща, които просто отнемат твърде много време. Моля, опитайте отново. Ако това продължи, моля, свържете се със админитратора на вашия Home сървър.", + "Your homeserver was unreachable and was not able to log you in. Please try again. If this continues, please contact your homeserver administrator.": "Вашият Home сървър беше недостижим и не можа да ви впише. Моля, опитайте отново. Ако това продължи, моля, свържете се със админитратора на вашия Home сървър.", + "Try again": "Опитайте отново", + "We asked the browser to remember which homeserver you use to let you sign in, but unfortunately your browser has forgotten it. Go to the sign in page and try again.": "Помолихме браузъра да запомни кой Home сървър използвате за влизане, но за съжаление браузърът ви го е забравил. Отидете на страницата за влизане и опитайте отново.", + "Already in call": "Вече в разговор", + "You're already in a call with this person.": "Вече сте в разговор в този човек.", + "Too Many Calls": "Твърде много повиквания", + "Call failed because microphone could not be accessed. Check that a microphone is plugged in and set up correctly.": "Неуспешно повикване поради неуспешен достъп до микрофон. Проверете дали микрофонът е включен и настроен правилно." } diff --git a/src/i18n/strings/ca.json b/src/i18n/strings/ca.json index 7db987b8af..9a9e0efaa7 100644 --- a/src/i18n/strings/ca.json +++ b/src/i18n/strings/ca.json @@ -950,5 +950,6 @@ "Confirm": "Confirma", "Click the button below to confirm adding this email address.": "Fes clic al botó de sota per confirmar l'addició d'aquesta adreça de correu electrònic.", "Unable to access webcam / microphone": "No s'ha pogut accedir a la càmera web / micròfon", - "Unable to access microphone": "No s'ha pogut accedir al micròfon" + "Unable to access microphone": "No s'ha pogut accedir al micròfon", + "Explore rooms": "Explora sales" } diff --git a/src/i18n/strings/cs.json b/src/i18n/strings/cs.json index 0efb3df22a..9701afa0e5 100644 --- a/src/i18n/strings/cs.json +++ b/src/i18n/strings/cs.json @@ -323,7 +323,7 @@ "%(senderName)s removed their display name (%(oldDisplayName)s).": "%(senderName)s odstranil(a) své zobrazované jméno (%(oldDisplayName)s).", "%(senderName)s withdrew %(targetName)s's invitation.": "%(senderName)s zrušil(a) pozvání pro uživatele %(targetName)s.", "%(senderName)s made future room history visible to all room members, from the point they are invited.": "%(senderName)s nastavil(a) viditelnost budoucích zpráv v této místnosti pro všechny její členy, a to od chvíle jejich pozvání.", - "%(senderName)s made future room history visible to all room members, from the point they joined.": "%(senderName)s nastavil(a) viditelnost budoucích zpráv v této místnosti pro všechny její členy, a to od chvíle jejich vstupu.", + "%(senderName)s made future room history visible to all room members, from the point they joined.": "%(senderName)s nastavil(a) viditelnost budoucích zpráv v této místnosti pro všechny její členy od chvíle jejich vstupu.", "%(senderName)s made future room history visible to all room members.": "%(senderName)s nastavil(a) viditelnost budoucích zpráv v této místnosti pro všechny její členy.", "%(senderName)s made future room history visible to anyone.": "%(senderName)s nastavil(a) viditelnost budoucích zpráv pro kohokoliv.", "%(senderName)s changed the pinned messages for the room.": "%(senderName)s změnil(a) připíchnuté zprávy této místnosti.", @@ -469,8 +469,8 @@ "%(oneUser)sjoined %(count)s times|one": "%(oneUser)svstoupil(a)", "%(severalUsers)sleft %(count)s times|other": "%(severalUsers)s %(count)s krát opustili", "%(severalUsers)sleft %(count)s times|one": "%(severalUsers)sopustili", - "%(oneUser)sleft %(count)s times|other": "%(oneUser)s %(count)s krát opustil", - "%(oneUser)sleft %(count)s times|one": "%(oneUser)sopustil", + "%(oneUser)sleft %(count)s times|other": "%(oneUser)s %(count)s krát opustil(a)", + "%(oneUser)sleft %(count)s times|one": "%(oneUser)sopustil(a)", "%(severalUsers)sjoined and left %(count)s times|other": "%(severalUsers)s %(count)s krát vstoupili a opustili", "%(severalUsers)sjoined and left %(count)s times|one": "%(severalUsers)svstoupili a opustili", "%(oneUser)sjoined and left %(count)s times|other": "%(oneUser)s %(count)s krát vstoupil(a) a opustil(a)", @@ -478,7 +478,7 @@ "%(severalUsers)sleft and rejoined %(count)s times|other": "%(severalUsers)s %(count)s krát opustili a znovu vstoupili", "%(severalUsers)sleft and rejoined %(count)s times|one": "%(severalUsers)sopustili a znovu vstoupili", "%(oneUser)sleft and rejoined %(count)s times|other": "%(oneUser)s %(count)s krát opustil(a) a znovu vstoupil(a)", - "%(oneUser)sleft and rejoined %(count)s times|one": "%(oneUser)sopustil a znovu vstoupil", + "%(oneUser)sleft and rejoined %(count)s times|one": "%(oneUser)sopustil(a) a znovu vstoupil(a)", "%(severalUsers)srejected their invitations %(count)s times|other": "%(severalUsers)s %(count)s krát odmítli pozvání", "%(severalUsers)srejected their invitations %(count)s times|one": "%(severalUsers)sodmítli pozvání", "%(oneUser)srejected their invitation %(count)s times|other": "%(oneUser)s %(count)s krát odmítl pozvání", @@ -491,14 +491,14 @@ "were invited %(count)s times|one": "byli pozváni", "was invited %(count)s times|other": "byl %(count)s krát pozván", "was invited %(count)s times|one": "byl(a) pozván(a)", - "were banned %(count)s times|other": "mělid %(count)s krát zakázaný vstup", - "were banned %(count)s times|one": "měli zakázaný vstup", - "was banned %(count)s times|other": "měl %(count)s krát zakázaný vstup", - "was banned %(count)s times|one": "měl zakázaný vstup", - "were unbanned %(count)s times|other": "měli %(count)s krát povolený vstup", - "were unbanned %(count)s times|one": "měli povolený vstup", - "was unbanned %(count)s times|other": "měl %(count)s krát povolený vstup", - "was unbanned %(count)s times|one": "měl povolený vstup", + "were banned %(count)s times|other": "byli %(count)s krát vykázáni", + "were banned %(count)s times|one": "byl(a) vykázán(a)", + "was banned %(count)s times|other": "byli %(count)s krát vykázáni", + "was banned %(count)s times|one": "byl(a) vykázán(a)", + "were unbanned %(count)s times|other": "byli %(count)s přijati zpět", + "were unbanned %(count)s times|one": "byl(a) přijat(a) zpět", + "was unbanned %(count)s times|other": "byli %(count)s krát přijati zpět", + "was unbanned %(count)s times|one": "byl(a) přijat(a) zpět", "were kicked %(count)s times|other": "byli %(count)s krát vyhozeni", "were kicked %(count)s times|one": "byli vyhozeni", "was kicked %(count)s times|other": "byl %(count)s krát vyhozen", @@ -744,7 +744,7 @@ "e.g. ": "např. ", "Your device resolution": "Rozlišení obrazovky vašeho zařízení", "The information being sent to us to help make %(brand)s better includes:": "Abychom mohli %(brand)s zlepšovat, posíláte nám následující informace:", - "Where this page includes identifiable information, such as a room, user or group ID, that data is removed before being sent to the server.": "V případě, že se na stránce vyskytují identifikační údaje, jako například název místnosti, ID uživatele, místnosti a nebo skupiny, jsou tyto údaje před odesláním na server odstraněny.", + "Where this page includes identifiable information, such as a room, user or group ID, that data is removed before being sent to the server.": "Pokud tato stránka obsahuje identifikovatelné údaje, například ID místnosti, uživatele nebo skupiny, jsou tyto údaje před odesláním na server odstraněny.", "Call in Progress": "Probíhající hovor", "A call is currently being placed!": "Právě probíhá jiný hovor!", "A call is already in progress!": "Jeden hovor už probíhá!", @@ -896,7 +896,7 @@ "Prompt before sending invites to potentially invalid matrix IDs": "Potvrdit odeslání pozvánky potenciálně neplatným Matrix ID", "Show avatar changes": "Zobrazovat změny avatarů", "Show join/leave messages (invites/kicks/bans unaffected)": "Zobrazovat zprávy o vstupu/opuštění (netýká se pozvánek/vykopnutí/vykázání)", - "Show a placeholder for removed messages": "Zobrazovat smazané zprávy jako zamazané", + "Show a placeholder for removed messages": "Zobrazovat smazané zprávy", "Show display name changes": "Zobrazovat změny zobrazovaného jména", "Messages containing my username": "Zprávy obsahující moje uživatelské jméno", "Messages containing @room": "Zprávy obsahující @room", @@ -990,7 +990,7 @@ "Straight rows of keys are easy to guess": "Řádky na klávesnici je moc jednoduché uhodnout", "Short keyboard patterns are easy to guess": "Krátké sekvence kláves je moc jednoduché uhodnout", "There was an error joining the room": "Při vstupu do místnosti došlo k chybě", - "Sorry, your homeserver is too old to participate in this room.": "Jejda, váš domovský server je moc zastaralý abyste do této místnosti mohli vstoupit.", + "Sorry, your homeserver is too old to participate in this room.": "Omlouváme se, ale váš domovský server je příliš zastaralý pro vstup do této místnosti.", "Please contact your homeserver administrator.": "Kontaktujte prosím správce domovského serveru.", "The other party cancelled the verification.": "Druhá strana ověření zrušila.", "Verified!": "Ověřeno!", @@ -1130,9 +1130,9 @@ "Without setting up Secure Message Recovery, you'll lose your secure message history when you log out.": "Bez nastavení bezpečného obnovení zpráv přijdete po odhlášení o historii šifrované komunikace.", "Show a reminder to enable Secure Message Recovery in encrypted rooms": "V šifrovaných konverzacích zobrazovat upozornění na možnost aktivovat bezpečné obnovení zpráv", "Gets or sets the room topic": "Nastaví nebo zjistí téma místnosti", - "Forces the current outbound group session in an encrypted room to be discarded": "Zahodí aktuálně používanou kryptografickou session na odchozí zprávy", + "Forces the current outbound group session in an encrypted room to be discarded": "Vynutí zahození aktuálně používané relace skupiny v zašifrované místnosti", "Custom user status messages": "Vlastní statusy", - "Group & filter rooms by custom tags (refresh to apply changes)": "Skupinkování a filtování místností podle štítků (vyžaduje znovunačtení stránky)", + "Group & filter rooms by custom tags (refresh to apply changes)": "Seskupování a filtrování místností podle vlastních štítků (vyžaduje znovunačtení stránky)", "Render simple counters in room header": "Zobrazovat stavová počítadla v hlavičce místnosti", "Enable Community Filter Panel": "Povolit panel Filtr skupiny", "Show developer tools": "Zobrazit nástroje pro vývojáře", @@ -1187,7 +1187,7 @@ "Changes your display nickname in the current room only": "Změní vaši zobrazovanou přezdívku pouze v této místnosti", "User %(userId)s is already in the room": "Uživatel %(userId)s už je v této místnosti", "The user must be unbanned before they can be invited.": "Uživatel je vykázán, nelze ho pozvat.", - "Show read receipts sent by other users": "Zobrazovat potvrzení o přijetí", + "Show read receipts sent by other users": "Zobrazovat potvrzení o přečtení", "Scissors": "Nůžky", "Accept all %(invitedRooms)s invites": "Přijmout pozvání do všech těchto místností: %(invitedRooms)s", "Change room avatar": "Změnit avatar místnosti", @@ -1205,7 +1205,7 @@ "Remove messages": "Mazat zprávy", "Notify everyone": "Oznámení pro celou místnost", "Enable encryption?": "Povolit šifrování?", - "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.": "Po zapnutí už nelze šifrování v této místnosti vypnout. Zprávy v šifrovaných místnostech mohou číst jenom členové místnosti, server se k obsahu nedostane. Šifrování místností nepodporuje většina botů a propojení. Více informací o šifrování.", + "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.": "Po zapnutí již nelze šifrování v této místnosti vypnout. Zprávy v šifrovaných místnostech mohou číst jen členové místnosti, server se k obsahu nedostane. Šifrování místností nepodporuje většina botů a propojení. Více informací o šifrování.", "Error updating main address": "Nepovedlo se změnit hlavní adresu", "There was an error updating the room's main address. It may not be allowed by the server or a temporary failure occurred.": "Nastala chyba při pokusu o nastavení hlavní adresy místnosti. Mohl to zakázat server, nebo to může být dočasná chyba.", "Power level": "Úroveň oprávnění", @@ -1219,7 +1219,7 @@ "Your Matrix account on %(serverName)s": "Váš účet Matrix na serveru %(serverName)s", "Whether or not you're using the 'breadcrumbs' feature (avatars above the room list)": "Zda používáte funkci „breadcrumb“ (ikony nad seznamem místností)", "Replying With Files": "Odpovídání souborem", - "At this time it is not possible to reply with a file. Would you like to upload this file without replying?": "Aktuálně nelze odpovědět souborem. Chcete soubor nahrát a poslat bez odpovídání?", + "At this time it is not possible to reply with a file. Would you like to upload this file without replying?": "V tuto chvíli není možné odpovědět souborem. Chcete tento soubor nahrát bez odpovědi?", "The file '%(fileName)s' failed to upload.": "Soubor '%(fileName)s' se nepodařilo nahrát.", "The server does not support the room version specified.": "Server nepodporuje určenou verzi místnosti.", "Name or Matrix ID": "Jméno nebo Matrix ID", @@ -1280,7 +1280,7 @@ "Invited by %(sender)s": "Pozván od uživatele %(sender)s", "Error updating flair": "Nepovedlo se změnit příslušnost ke skupině", "There was an error updating the flair for this room. The server may not allow it or a temporary error occurred.": "Pro tuto místnost se nepovedlo změnit příslušnost ke skupině. Možná to server neumožňuje, nebo došlo k dočasné chybě.", - "reacted with %(shortName)s": " reagoval(a) s %(shortName)s", + "reacted with %(shortName)s": " reagoval(a) %(shortName)s", "edited": "upraveno", "Maximize apps": "Maximalizovat aplikace", "Rotate Left": "Otočit doleva", @@ -1354,12 +1354,12 @@ "Unexpected error resolving identity server configuration": "Chyba při hledání konfigurace serveru identity", "Use lowercase letters, numbers, dashes and underscores only": "Používejte pouze malá písmena, čísla, pomlčky a podtržítka", "Cannot reach identity server": "Nelze se připojit k serveru identity", - "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.": "Můžete se zaregistrovat, ale některé funkce nebudou dostupné dokud nezačne server identity fungovat. Pokud se vám toto varování zobrazuje pořád, tak zkontrolujte svojí konfiguraci a nebo kontaktujte správce serveru.", - "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 vám toto varování zobrazuje pořád, tak zkontrolujte svojí konfiguraci a 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 pořád, tak zkontrolujte svojí konfiguraci a nebo kontaktujte správce serveru.", + "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.": "Můžete se zaregistrovat, 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.", + "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.": "Zeptejte se správce (%(homeserverDomain)s) jestli by nemohl nakonfigurovat server TURN, aby začalo fungoval volání.", - "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.": "Případně můžete zkusit použít veřejný server turn.matrix.org, což nemusí fungovat tak spolehlivě a řekne to tomu cizímu serveru vaší IP adresu. Můžete to udělat v Nastavení.", + "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ě.", + "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", "Actions": "Akce", @@ -1368,7 +1368,7 @@ "Changes the avatar of the current room": "Změní avatar této místnosti", "Changes your avatar in all rooms": "Změní váš avatar pro všechny místnosti", "Use an identity server": "Používat server identit", - "Use an identity server to invite by email. Click continue to use the default identity server (%(defaultIdentityServerName)s) or manage in Settings.": "Použít server identit k odeslání e-mailové pozvánky. Pokračováním použijete výchozí server identit (%(defaultIdentityServerName)s) nebo ho můžete změnit v Nastavení.", + "Use an identity server to invite by email. Click continue to use the default identity server (%(defaultIdentityServerName)s) or manage in Settings.": "K pozvání e-mailem použijte server identit. Pokračováním použijete výchozí server identit (%(defaultIdentityServerName)s) nebo ho můžete změnit v Nastavení.", "Use an identity server to invite by email. Manage in Settings.": "Použít server identit na odeslání e-mailové pozvánky. Můžete spravovat v Nastavení.", "Displays list of commands with usages and descriptions": "Zobrazuje seznam příkazu s popiskem", "%(senderName)s made no change.": "%(senderName)s neudělal žádnou změnu.", @@ -1663,7 +1663,7 @@ "Verify": "Ověřit", "You have ignored this user, so their message is hidden. Show anyways.": "Tohoto uživatele ignorujete, takže jsou jeho zprávy skryté. Přesto zobrazit.", "Reactions": "Reakce", - " reacted with %(content)s": " reagoval %(content)s", + " reacted with %(content)s": " reagoval(a) %(content)s", "Any of the following data may be shared:": "Následující data můžou být sdílena:", "Your display name": "Vaše zobrazované jméno", "Your avatar URL": "URL vašeho avataru", @@ -1708,7 +1708,7 @@ "Backup has a invalid signature from this user": "Záloha má neplatný podpis od tohoto uživatele", "Backup has a signature from unknown user with ID %(deviceId)s": "Záloha je podepsaná neznámým uživatelem %(deviceId)s", "Close preview": "Zavřít náhled", - "Hide verified sessions": "Schovat ověřené relace", + "Hide verified sessions": "Skrýt ověřené relace", "%(count)s verified sessions|other": "%(count)s ověřených relací", "%(count)s verified sessions|one": "1 ověřená relace", "Language Dropdown": "Menu jazyků", @@ -1721,8 +1721,8 @@ "Unknown (user, session) pair:": "Neznámý pár (uživatel, relace):", "Session already verified!": "Relace je už ověřená!", "WARNING: Session already verified, but keys do NOT MATCH!": "VAROVÁNÍ: Relace je už ověřená, ale klíče NEODPOVÍDAJÍ!", - "WARNING: KEY VERIFICATION FAILED! The signing key for %(userId)s and session %(deviceId)s is \"%(fprint)s\" which does not match the provided key \"%(fingerprint)s\". This could mean your communications are being intercepted!": "VAROVÁNÍ: OVĚŘENÍ KLÍČŮ SELHALO! Podpisový klíč pro uživatele %(userId)s a relaci %(deviceId)s je „%(fprint)s“, což neodpovídá klíči „%(fingerprint)s“. Může to znamenat, že je vaše komunikace rušena!", - "The signing key you provided matches the signing key you received from %(userId)s's session %(deviceId)s. Session marked as verified.": "Zadaný podpisový klíč odpovídá klíči relace %(deviceId)s od uživatele %(userId)s. Relace byla označena za platnou.", + "WARNING: KEY VERIFICATION FAILED! The signing key for %(userId)s and session %(deviceId)s is \"%(fprint)s\" which does not match the provided key \"%(fingerprint)s\". This could mean your communications are being intercepted!": "VAROVÁNÍ: OVĚŘENÍ KLÍČE SE NEZDAŘILO! Podpisový klíč pro uživatele %(userId)s a relaci %(deviceId)s je „%(fprint)s“, což neodpovídá klíči „%(fingerprint)s“. To by mohlo znamenat, že vaše komunikace je zachycována!", + "The signing key you provided matches the signing key you received from %(userId)s's session %(deviceId)s. Session marked as verified.": "Zadaný podpisový klíč odpovídá klíči relace %(deviceId)s od uživatele %(userId)s. Relace byla označena jako ověřená.", "a few seconds ago": "před pár vteřinami", "about a minute ago": "před minutou", "%(num)s minutes ago": "před %(num)s minutami", @@ -1917,8 +1917,8 @@ "Compare unique emoji": "Porovnejte jedinečnou kombinaci emoji", "Compare a unique set of emoji if you don't have a camera on either device": "Pokud na žádném zařízení nemáte kameru, porovnejte jedinečnou kombinaci emoji", "Not Trusted": "Nedůvěryhodné", - "%(name)s (%(userId)s) signed in to a new session without verifying it:": "%(name)s (%(userId)s) se přihlásil do nové relace a neověřil ji:", - "Ask this user to verify their session, or manually verify it below.": "Poproste tohoto uživatele aby svojí relaci ověřil a nebo jí níže můžete ověřit manuálně.", + "%(name)s (%(userId)s) signed in to a new session without verifying it:": "%(name)s (%(userId)s) se přihlásil do nové relace bez ověření:", + "Ask this user to verify their session, or manually verify it below.": "Požádejte tohoto uživatele, aby ověřil svou relaci, nebo jí níže můžete ověřit manuálně.", "The session you are trying to verify doesn't support scanning a QR code or emoji verification, which is what %(brand)s supports. Try with a different client.": "Relace, kterou se snažíte ověřit, neumožňuje ověření QR kódem ani pomocí emoji, což je to, co %(brand)s podporuje. Zkuste použít jiného klienta.", "Verify by scanning": "Ověřte naskenováním", "You declined": "Odmítli jste", @@ -1936,7 +1936,7 @@ "If disabled, messages from encrypted rooms won't appear in search results.": "Když je to zakázané, zprávy v šifrovaných místnostech se nebudou objevovat ve výsledcích vyhledávání.", "Disable": "Zakázat", "%(brand)s is securely caching encrypted messages locally for them to appear in search results:": "%(brand)s si bezpečně uchovává šifrované zprávy lokálně, aby v nich mohl vyhledávat:", - "Space used:": "Použitý prostor:", + "Space used:": "Použité místo:", "Indexed messages:": "Indexované zprávy:", "Indexed rooms:": "Indexované místnosti:", "Message downloading sleep time(ms)": "Čas na stažení zprávy (ms)", @@ -2032,8 +2032,8 @@ "Could not find user in room": "Nepovedlo se najít uživatele v místnosti", "Please supply a widget URL or embed code": "Zadejte prosím URL widgetu nebo jeho kód", "Send a bug report with logs": "Zaslat hlášení o chybě", - "You signed in to a new session without verifying it:": "Přihlásili jste se do nové relace, ale neoveřili jste ji:", - "Verify your other session using one of the options below.": "Ověřte ostatní relací jedním z následujících způsobů.", + "You signed in to a new session without verifying it:": "Přihlásili jste se do nové relace, aniž byste ji ověřili:", + "Verify your other session using one of the options below.": "Ověřte další relaci jedním z následujících způsobů.", "Click the button below to confirm deleting these sessions.|other": "Zmáčknutím tlačítka potvrdíte smazání těchto relací.", "Click the button below to confirm deleting these sessions.|one": "Zmáčknutím tlačítka potvrdíte smazání této relace.", "Delete sessions|other": "Smazat relace", @@ -2093,7 +2093,7 @@ "Please verify the room ID or address and try again.": "Ověřte prosím, že ID místnosti je správné a zkuste to znovu.", "Room ID or address of ban list": "ID nebo adresa seznamu zablokovaných", "Help us improve %(brand)s": "Pomozte nám zlepšovat %(brand)s", - "Send anonymous usage data which helps us improve %(brand)s. This will use a cookie.": "Zasílat anonymní data o použití aplikace, která nám pomáhají %(brand)s zlepšovat. Bedeme na to používat soubory cookie.", + "Send anonymous usage data which helps us improve %(brand)s. This will use a cookie.": "Zasílat anonymní údaje o použití aplikace, která nám pomáhají %(brand)s zlepšovat. K tomu se použije soubor cookie.", "I want to help": "Chci pomoci", "Your homeserver has exceeded its user limit.": "Na vašem domovském serveru byl překročen limit počtu uživatelů.", "Your homeserver has exceeded one of its resource limits.": "Na vašem domovském serveru byl překročen limit systémových požadavků.", @@ -2166,8 +2166,8 @@ "Signature upload failed": "Podpis se nepodařilo nahrát", "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.": "Je-li jiná verze programu %(brand)s stále otevřená na jiné kartě, tak ji prosím zavřete, neboť užívání programu %(brand)s stejným hostitelem se zpožděným nahráváním současně povoleným i zakázaným bude působit problémy.", "Unexpected server error trying to leave the room": "Neočekávaná chyba serveru při odcházení z místnosti", - "The person who invited you already left the room.": "Uživatel který vás pozval už místnosti není.", - "The person who invited you already left the room, or their server is offline.": "Uživatel který vás pozvat už odešel z místnosti a nebo je jeho server offline.", + "The person who invited you already left the room.": "Uživatel, který vás pozval, již opustil místnost.", + "The person who invited you already left the room, or their server is offline.": "Uživatel, který vás pozval, již opustil místnost nebo je jeho server offline.", "You left the call": "Odešli jste z hovoru", "%(senderName)s left the call": "%(senderName)s opustil/a hovor", "Call ended": "Hovor skončil", @@ -2954,13 +2954,13 @@ "Mobile experience": "Zážitek na mobilních zařízeních", "Element Web is currently experimental on mobile. The native apps are recommended for most people.": "Element Web je v současné době experimentální na mobilních zařízeních. Nativní aplikace se doporučují pro většinu lidí.", "Use app for a better experience": "Pro lepší zážitek použijte aplikaci", - "Element Web is experimental on mobile. For a better experience and the latest features, use our free native app.": "Element Web je experimentální na mobilních zařízeních. Pro lepší zážitek a nejnovější funkce použijte naši bezplatnou nativní aplikaci.", + "Element Web is experimental on mobile. For a better experience and the latest features, use our free native app.": "Element Web je v mobilní verzi experimentální. Chcete-li získat lepší zážitek a nejnovější funkce, použijte naši bezplatnou nativní aplikaci.", "Use app": "Použijte aplikaci", "Something went wrong in confirming your identity. Cancel and try again.": "Při ověřování vaší identity se něco pokazilo. Zrušte to a zkuste to znovu.", - "Your homeserver rejected your log in attempt. This could be due to things just taking too long. Please try again. If this continues, please contact your homeserver administrator.": "Váš domovský server odmítl váš pokus o přihlášení. Může to být způsobeno tím, že věci trvají příliš dlouho. Prosím zkuste to znovu. Pokud to bude pokračovat, obraťte se na správce domovského serveru.", + "Your homeserver rejected your log in attempt. This could be due to things just taking too long. Please try again. If this continues, please contact your homeserver administrator.": "Váš domovský server odmítl váš pokus o přihlášení. To může to být způsobeno tím, že vše trvá příliš dlouho. Zkuste to prosím znovu. Pokud tento problém přetrvává, obraťte se na správce domovského serveru.", "Your homeserver was unreachable and was not able to log you in. Please try again. If this continues, please contact your homeserver administrator.": "Váš domovský server nebyl dosažitelný a nemohl vás přihlásit. Zkuste to prosím znovu. Pokud to bude pokračovat, obraťte se na správce domovského serveru.", "Try again": "Zkuste to znovu", - "We asked the browser to remember which homeserver you use to let you sign in, but unfortunately your browser has forgotten it. Go to the sign in page and try again.": "Požádali jsme prohlížeč, aby si pamatoval, který domovský server používáte k přihlášení, ale váš prohlížeč to bohužel zapomněl. Přejděte na přihlašovací stránku a zkuste to znovu.", + "We asked the browser to remember which homeserver you use to let you sign in, but unfortunately your browser has forgotten it. Go to the sign in page and try again.": "Požádali jsme prohlížeč, aby si zapamatoval, který domovský server používáte k přihlášení, ale váš prohlížeč to bohužel zapomněl. Přejděte na přihlašovací stránku a zkuste to znovu.", "We couldn't log you in": "Nemohli jsme vás přihlásit", "Show stickers button": "Tlačítko Zobrazit nálepky", "Windows": "Okna", @@ -3012,8 +3012,8 @@ "Inviting...": "Pozvání...", "Invite by username": "Pozvat podle uživatelského jména", "Invite your teammates": "Pozvěte své spolupracovníky", - "Failed to invite the following users to your space: %(csvUsers)s": "Nepodařilo se pozvat následující uživatele do vašeho space: %(csvUsers)s", - "A private space for you and your teammates": "Soukromý space pro Vás a vaše spolupracovníky", + "Failed to invite the following users to your space: %(csvUsers)s": "Nepodařilo se pozvat následující uživatele do vašeho prostoru: %(csvUsers)s", + "A private space for you and your teammates": "Soukromý prostor pro Vás a vaše spolupracovníky", "Me and my teammates": "Já a moji spolupracovníci", "A private space just for you": "Soukromý space právě pro vás", "Just Me": "Pouze já", @@ -3022,7 +3022,7 @@ "At the moment only you can see it.": "V tuto chvíli to vidíte jen Vy.", "Creating rooms...": "Vytváření místností...", "Skip for now": "Prozatím přeskočit", - "Failed to create initial space rooms": "Vytvoření počátečních místností ve space se nezdařilo", + "Failed to create initial space rooms": "Vytvoření počátečních místností v prostoru se nezdařilo", "Random": "Náhodný", "Your private space ": "Váš soukromý space ", "Your public space ": "Váš veřejný space ", @@ -3030,9 +3030,9 @@ " invited you to ": " vás pozval do ", "%(count)s members|one": "%(count)s člen", "%(count)s members|other": "%(count)s členů", - "Your server does not support showing space hierarchies.": "Váš server nepodporuje zobrazování hierarchií spaces.", + "Your server does not support showing space hierarchies.": "Váš server nepodporuje zobrazování hierarchií prostorů.", "Default Rooms": "Výchozí místnosti", - "Add existing rooms & spaces": "Přidat stávající místnosti a spaces", + "Add existing rooms & spaces": "Přidat stávající místnosti a prostory", "Accept Invite": "Přijmout pozvání", "Find a room...": "Najít místnost...", "Manage rooms": "Spravovat místnosti", @@ -3044,68 +3044,68 @@ "Remove from Space": "Odebrat ze space", "Undo": "Vrátit", "Your message wasn't sent because this homeserver has been blocked by it's administrator. Please contact your service administrator to continue using the service.": "Vaše zpráva nebyla odeslána, protože tento domovský server byl zablokován jeho správcem. Kontaktujte svého správce služby, abyste mohli službu nadále používat.", - "Are you sure you want to leave the space '%(spaceName)s'?": "Opravdu chcete opustit space '%(spaceName)s'?", - "This space is not public. You will not be able to rejoin without an invite.": "Tento space není veřejný. Bez pozvánky se nebudete moci znovu připojit.", + "Are you sure you want to leave the space '%(spaceName)s'?": "Opravdu chcete opustit prostor '%(spaceName)s'?", + "This space is not public. You will not be able to rejoin without an invite.": "Tento prostor není veřejný. Bez pozvánky se nebudete moci znovu připojit.", "Start audio stream": "Zahájit audio přenos", "Failed to start livestream": "Nepodařilo spustit živý přenos", "Unable to start audio streaming.": "Nelze spustit streamování zvuku.", "View dev tools": "Zobrazit nástroje pro vývojáře", - "Leave Space": "Opustit space", - "Make this space private": "Nastavit tento space jako soukromý", - "Edit settings relating to your space.": "Upravte nastavení týkající se vašeho space.", - "Space settings": "Nastavení space", - "Failed to save space settings.": "Nastavení space se nepodařilo uložit.", - "Invite someone using their name, username (like ) or share this space.": "Pozvěte někoho pomocí jeho jména, uživatelského jména (například ) nebo sdílejte tento space.", - "Invite someone using their name, email address, username (like ) or share this space.": "Pozvěte někoho pomocí jeho jména, e-mailové adresy, uživatelského jména (například ) nebo sdílejte tento space.", - "Unnamed Space": "Nejmenovaný space", + "Leave Space": "Opustit prostor", + "Make this space private": "Nastavit tento prostor jako soukromý", + "Edit settings relating to your space.": "Upravte nastavení týkající se vašeho prostoru.", + "Space settings": "Nastavení prostoru", + "Failed to save space settings.": "Nastavení prostoru se nepodařilo uložit.", + "Invite someone using their name, username (like ) or share this space.": "Pozvěte někoho pomocí jeho jména, uživatelského jména (například ) nebo sdílejte tento prostor.", + "Invite someone using their name, email address, username (like ) or share this space.": "Pozvěte někoho pomocí jeho jména, e-mailové adresy, uživatelského jména (například ) nebo sdílejte tento prostor.", + "Unnamed Space": "Nejmenovaný prostor", "Invite to %(spaceName)s": "Pozvat do %(spaceName)s", - "Failed to add rooms to space": "Nepodařilo se přidat místnosti do space", + "Failed to add rooms to space": "Nepodařilo se přidat místnosti do prostoru", "Applying...": "Potvrzuji...", "Apply": "Použít", "Create a new room": "Vytvořit novou místnost", "Don't want to add an existing room?": "Nechcete přidat existující místnost?", - "Spaces": "Spaces", - "Filter your rooms and spaces": "Filtrujte své místnosti a spaces", + "Spaces": "Prostory", + "Filter your rooms and spaces": "Filtrujte své místnosti a prostory", "Add existing spaces/rooms": "Přidat existující space/místnost", - "Space selection": "Výběr space", - "You will not be able to undo this change as you are demoting yourself, if you are the last privileged user in the space it will be impossible to regain privileges.": "Tuto změnu nebudete moci vrátit zpět, protože budete degradováni, pokud jste posledním privilegovaným uživatelem v daném space, nebude možné znovu získat oprávnění.", + "Space selection": "Výběr prostoru", + "You will not be able to undo this change as you are demoting yourself, if you are the last privileged user in the space it will be impossible to regain privileges.": "Tuto změnu nebudete moci vrátit zpět, protože budete degradováni, pokud jste posledním privilegovaným uživatelem v daném prostoru, nebude možné znovu získat oprávnění.", "Empty room": "Prázdná místnost", "Suggested Rooms": "Doporučené místnosti", "Explore space rooms": "Prozkoumat místnosti space", - "You do not have permissions to add rooms to this space": "Nemáte oprávnění k přidávání místností do tohoto space", + "You do not have permissions to add rooms to this space": "Nemáte oprávnění k přidávání místností do tohoto prostoru", "Add existing room": "Přidat existující místnost", - "You do not have permissions to create new rooms in this space": "Nemáte oprávnění k vytváření nových místností v tomto space", + "You do not have permissions to create new rooms in this space": "Nemáte oprávnění k vytváření nových místností v tomto prostoru", "Send message": "Poslat zprávu", - "Invite to this space": "Pozvat do tohoto space", + "Invite to this space": "Pozvat do tohoto prostoru", "Your message was sent": "Zpráva byla odeslána", "Encrypting your message...": "Šifrování zprávy...", "Sending your message...": "Odesílání zprávy...", "Spell check dictionaries": "Slovníky pro kontrolu pravopisu", - "Space options": "Nastavení space", + "Space options": "Nastavení prostoru", "Space Home": "Domov space", "New room": "Nová místnost", - "Leave space": "Opusit space", + "Leave space": "Opusit prostor", "Invite people": "Pozvat lidi", - "Share your public space": "Sdílejte svůj veřejný space", + "Share your public space": "Sdílejte svůj veřejný prostor", "Invite members": "Pozvat členy", "Invite by email or username": "Pozvěte e-mailem nebo uživatelským jménem", "Share invite link": "Sdílet odkaz na pozvánku", "Click to copy": "Kliknutím zkopírujte", - "Expand space panel": "Rozbalit space panel", - "Collapse space panel": "Sbalit space panel", + "Expand space panel": "Rozbalit panel prostoru", + "Collapse space panel": "Sbalit panel prostoru", "Creating...": "Vytváření...", "You can change these at any point.": "Můžete je kdykoli změnit.", "Give it a photo, name and description to help you identify it.": "Přiřaďte mu obrázek, jméno a popis, abyste jej mohli lépe identifikovat.", - "Your private space": "Váš soukromý space", - "Your public space": "Váš veřejný space", + "Your private space": "Váš soukromý prostor", + "Your public space": "Váš veřejný prostor", "You can change this later": "Toto můžete změnit později", "Invite only, best for yourself or teams": "Pouze pozvat, nejlepší pro sebe nebo pro týmy", "Private": "Soukromý", - "Open space for anyone, best for communities": "Otevřený space pro kohokoli, nejlepší pro komunity", + "Open space for anyone, best for communities": "Otevřený prostor pro kohokoli, nejlepší pro komunity", "Public": "Veřejný", - "Create a space": "Vytvořit space", + "Create a space": "Vytvořit prostor", "Jump to the bottom of the timeline when you send a message": "Při odesílání zprávy přeskočit na konec časové osy", - "Spaces prototype. Incompatible with Communities, Communities v2 and Custom Tags. Requires compatible homeserver for some features.": "Spaces prototyp. Nejsou kompatibilní se skupinami, skupinami v2 a vlastními štítky. Pro některé funkce je vyžadován kompatibilní domovský server.", + "Spaces prototype. Incompatible with Communities, Communities v2 and Custom Tags. Requires compatible homeserver for some features.": "Prototyp prostorů. Nejsou kompatibilní se skupinami, skupinami v2 a vlastními štítky. Pro některé funkce je vyžadován kompatibilní domovský server.", "This homeserver has been blocked by its administrator.": "Tento domovský server byl zablokován jeho správcem.", "You're already in a call with this person.": "S touto osobou již telefonujete.", "Already in call": "Již máte hovor", @@ -3118,5 +3118,171 @@ "Welcome to ": "Vítejte v ", "Support": "Podpora", "Room name": "Název místnosti", - "Finish": "Dokončit" + "Finish": "Dokončit", + "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.": "Toto obvykle ovlivňuje pouze to, jak je místnost zpracována na serveru. Pokud máte problémy s %(brand)s, nahlaste prosím chybu.", + "We'll create rooms for each of them. You can add more later too, including already existing ones.": "Pro každého z nich vytvoříme místnost. Později můžete přidat další, včetně již existujících.", + "Let's create a room for each of them. You can add more later too, including already existing ones.": "Vytvořme místnost pro každého z nich. Později můžete přidat i další, včetně již existujících.", + "Make sure the right people have access. You can invite more later.": "Zajistěte přístup pro správné lidi. Další můžete pozvat později.", + "A private space to organise your rooms": "Soukromý prostor pro uspořádání vašich místností", + "Make sure the right people have access to %(name)s": "Zajistěte, aby do %(name)s měli přístup správní lidé", + "Go to my first room": "Jít do mé první místnosti", + "It's just you at the moment, it will be even better with others.": "V tuto chvíli to jste jen vy, s ostatními to bude ještě lepší.", + "Share %(name)s": "Sdílet %(name)s", + "Private space": "Soukromý prostor", + "Public space": "Veřejný prostor", + " invites you": " vás zve", + "Search names and description": "Prohledat jména a popisy", + "Create room": "Vytvořit místnost", + "You may want to try a different search or check for typos.": "Možná budete chtít zkusit vyhledat něco jiného nebo zkontrolovat překlepy.", + "No results found": "Nebyly nalezeny žádné výsledky", + "Mark as suggested": "Označit jako doporučené", + "Mark as not suggested": "Označit jako nedoporučené", + "Removing...": "Odebírání...", + "Failed to remove some rooms. Try again later": "Odebrání některých místností se nezdařilo. Zkuste to později znovu", + "%(count)s rooms and 1 space|one": "%(count)s místnost a 1 prostor", + "%(count)s rooms and 1 space|other": "%(count)s místností a 1 prostor", + "%(count)s rooms and %(numSpaces)s spaces|one": "%(count)s místnost a %(numSpaces)s prostorů", + "%(count)s rooms and %(numSpaces)s spaces|other": "%(count)s místností a %(numSpaces)s prostorů", + "If you can't find the room you're looking for, ask for an invite or create a new room.": "Pokud nemůžete najít místnost, kterou hledáte, požádejte o pozvánku nebo vytvořte novou místnost.", + "This room is suggested as a good one to join": "Tato místnost je doporučena jako dobrá pro připojení", + "Suggested": "Doporučeno", + "%(count)s rooms|one": "%(count)s místnost", + "%(count)s rooms|other": "%(count)s místností", + "You don't have permission": "Nemáte povolení", + "%(count)s messages deleted.|one": "%(count)s zpráva smazána.", + "%(count)s messages deleted.|other": "%(count)s zpráv smazáno.", + "Invite to %(roomName)s": "Pozvat do %(roomName)s", + "Invite People": "Pozvat lidi", + "Invite with email or username": "Pozvěte e-mailem nebo uživatelským jménem", + "You can change these anytime.": "Tyto údaje můžete kdykoli změnit.", + "Add some details to help people recognise it.": "Přidejte nějaké podrobnosti, aby ho lidé lépe rozpoznali.", + "Spaces are new ways to group rooms and people. To join an existing space you'll need an invite.": "Prostory jsou nový způsob, jak seskupovat místnosti a lidi. Chcete-li se připojit ke stávajícímu prostoru, budete potřebovat pozvánku.", + "A new login is accessing your account: %(name)s (%(deviceID)s) at %(ip)s": "K vašemu účtu přistupuje nové přihlášení: %(name)s (%(deviceID)s) pomocí %(ip)s", + "From %(deviceName)s (%(deviceId)s) at %(ip)s": "Z %(deviceName)s (%(deviceId)s) pomocí %(ip)s", + "Verify this login to access your encrypted messages and prove to others that this login is really you.": "Ověřte toto přihlášení, abyste získali přístup k šifrovaným zprávám a dokázali ostatním, že jste to opravdu vy.", + "Verify with another session": "Ověřit pomocí jiné relace", + "Just me": "Jen já", + "Edit devices": "Upravit zařízení", + "Check your devices": "Zkontrolujte svá zařízení", + "You have unverified logins": "Máte neověřená přihlášení", + "Open": "Otevřít", + "Share decryption keys for room history when inviting users": "Při pozvání uživatelů sdílet dešifrovací klíče pro historii místnosti", + "Manage & explore rooms": "Spravovat a prozkoumat místnosti", + "Message search initilisation failed": "Inicializace vyhledávání zpráv se nezdařila", + "%(count)s people you know have already joined|one": "%(count)s osoba, kterou znáte, se již připojila", + "Invited people will be able to read old messages.": "Pozvaní lidé budou moci číst staré zprávy.", + "If you do, please note that none of your messages will be deleted, but the search experience might be degraded for a few momentswhilst the index is recreated": "Pokud tak učiníte, nezapomeňte, že žádná z vašich zpráv nebude smazána, ale vyhledávání může být na několik okamžiků degradováno, zatímco index bude znovu vytvářen", + "You can add more later too, including already existing ones.": "Později můžete přidat i další, včetně již existujících.", + "Verify your identity to access encrypted messages and prove your identity to others.": "Ověřte svou identitu, abyste získali přístup k šifrovaným zprávám a prokázali svou identitu ostatním.", + "Sends the given message as a spoiler": "Odešle danou zprávu jako spoiler", + "Review to ensure your account is safe": "Zkontrolujte, zda je váš účet v bezpečí", + "%(deviceId)s from %(ip)s": "%(deviceId)s z %(ip)s", + "Send and receive voice messages (in development)": "Odesílat a přijímat hlasové zprávy (ve vývoji)", + "unknown person": "neznámá osoba", + "Consulting with %(transferTarget)s. Transfer to %(transferee)s": "Konzultace s %(transferTarget)s. Převod na %(transferee)s", + "Warn before quitting": "Varovat před ukončením", + "Invite to just this room": "Pozvat jen do této místnosti", + "Quick actions": "Rychlé akce", + "Invite messages are hidden by default. Click to show the message.": "Zprávy s pozvánkou jsou ve výchozím nastavení skryté. Kliknutím zobrazíte zprávu.", + "Record a voice message": "Nahrát hlasovou zprávu", + "Stop & send recording": "Zastavit a odeslat záznam", + "Accept on your other login…": "Přijměte ve svém dalším přihlášení…", + "%(count)s people you know have already joined|other": "%(count)s lidí, které znáte, se již připojili", + "Add existing rooms": "Přidat stávající místnosti", + "Adding...": "Přidávání...", + "We couldn't create your DM.": "Nemohli jsme vytvořit vaši přímou zprávu.", + "Consult first": "Nejprve se poraďte", + "You most likely do not want to reset your event index store": "Pravděpodobně nechcete resetovat úložiště indexů událostí", + "Reset event store": "Resetovat úložiště událostí", + "Reset event store?": "Resetovat úložiště událostí?", + "Verify other login": "Ověřit další přihlášení", + "Avatar": "Avatar", + "Verification requested": "Žádost ověření", + "Please choose a strong password": "Vyberte silné heslo", + "What are some things you want to discuss in %(spaceName)s?": "O kterých tématech chcete diskutovat v %(spaceName)s?", + "Let's create a room for each of them.": "Vytvořme pro každé z nich místnost.", + "Use another login": "Použít jinou relaci", + "Without verifying, you won’t have access to all your messages and may appear as untrusted to others.": "Bez ověření nebudete mít přístup ke všem svým zprávám a ostatním se můžete zobrazit jako nedůvěryhodný.", + "You are the only person here. If you leave, no one will be able to join in the future, including you.": "Jste zde jediná osoba. Pokud odejdete, nikdo se v budoucnu nebude moci připojit, včetně vás.", + "If you reset everything, you will restart with no trusted sessions, no trusted users, and might not be able to see past messages.": "Pokud vše resetujete, začnete bez důvěryhodných relací, bez důvěryhodných uživatelů a možná nebudete moci zobrazit minulé zprávy.", + "Only do this if you have no other device to complete verification with.": "Udělejte to, pouze pokud nemáte žádné jiné zařízení, se kterým byste mohli dokončit ověření.", + "Reset everything": "Resetovat vše", + "Forgotten or lost all recovery methods? Reset all": "Zapomněli nebo ztratili jste všechny metody obnovy? Obnovit vše", + "If you do, please note that none of your messages will be deleted, but the search experience might be degraded for a few moments whilst the index is recreated": "Pokud tak učiníte, nezapomeňte, že žádná z vašich zpráv nebude smazána, ale vyhledávání může být na několik okamžiků zpomaleno během opětovného vytvoření indexu", + "View message": "Zobrazit zprávu", + "Zoom in": "Přiblížit", + "Zoom out": "Oddálit", + "%(seconds)ss left": "Zbývá %(seconds)ss", + "Change server ACLs": "Změnit seznamy přístupů serveru", + "Show options to enable 'Do not disturb' mode": "Zobrazit možnosti pro povolení režimu „Nerušit“", + "You can select all or individual messages to retry or delete": "Můžete vybrat všechny nebo jednotlivé zprávy, které chcete zkusit poslat znovu nebo odstranit", + "Sending": "Odesílání", + "Retry all": "Zkusit všechny znovu", + "Delete all": "Smazat všechny", + "Some of your messages have not been sent": "Některé z vašich zpráv nebyly odeslány", + "%(count)s members including %(commaSeparatedMembers)s|one": "%(commaSeparatedMembers)s", + "%(count)s members including %(commaSeparatedMembers)s|other": "%(count)s členů včetně %(commaSeparatedMembers)s", + "Including %(commaSeparatedMembers)s": "Včetně %(commaSeparatedMembers)s", + "View all %(count)s members|one": "Zobrazit jednoho člena", + "View all %(count)s members|other": "Zobrazit všech %(count)s členů", + "Failed to send": "Odeslání se nezdařilo", + "What do you want to organise?": "Co si přejete organizovat?", + "Filter all spaces": "Filtrovat všechny prostory", + "Delete recording": "Smazat zvukovou zprávu", + "Stop the recording": "Zastavit nahrávání", + "%(count)s results in all spaces|one": "%(count)s výsledek ve všech prostorech", + "%(count)s results in all spaces|other": "%(count)s výsledků ve všech prostorech", + "Play": "Přehrát", + "Pause": "Pozastavit", + "Enter your Security Phrase a second time to confirm it.": "Zadejte bezpečnostní frázi podruhé a potvrďte ji.", + "Pick rooms or conversations to add. This is just a space for you, no one will be informed. You can add more later.": "Vyberte místnosti nebo konverzace, které chcete přidat. Toto je prostor pouze pro vás, nikdo nebude informován. Později můžete přidat další.", + "You have no ignored users.": "Nemáte žádné ignorované uživatele.", + "This is an experimental feature. For now, new users receiving an invite will have to open the invite on to actually join.": "Jedná se o experimentální funkci. Noví uživatelé, kteří obdrží pozvánku, ji budou muset otevřít na , aby se mohli připojit.", + "To join %(spaceName)s, turn on the Spaces beta": "Pro připojení k %(spaceName)s, zapněte Prostory beta", + "To view %(spaceName)s, turn on the Spaces beta": "Pro zobrazení %(spaceName)s, zapněte Prostory beta", + "Select a room below first": "Nejprve si vyberte místnost níže", + "Communities are changing to Spaces": "Skupiny se mění na Prostory", + "Join the beta": "Připojit se k beta verzi", + "Leave the beta": "Opustit beta verzi", + "Beta": "Beta", + "Tap for more info": "Klepněte pro více informací", + "Spaces is a beta feature": "Prostory jsou beta verze", + "Want to add a new room instead?": "Chcete místo toho přidat novou místnost?", + "Adding rooms... (%(progress)s out of %(count)s)|one": "Přidávání místnosti...", + "Adding rooms... (%(progress)s out of %(count)s)|other": "Přidávání místností... (%(progress)s z %(count)s)", + "Not all selected were added": "Ne všechny vybrané byly přidány", + "You can add existing spaces to a space.": "Do prostoru můžete přidat existující prostory.", + "Feeling experimental?": "Chcete experimentovat?", + "You are not allowed to view this server's rooms list": "Namáte oprávnění zobrazit seznam místností tohoto serveru", + "Error processing voice message": "Chyba při zpracování hlasové zprávy", + "We didn't find a microphone on your device. Please check your settings and try again.": "Ve vašem zařízení nebyl nalezen žádný mikrofon. Zkontrolujte prosím nastavení a zkuste to znovu.", + "No microphone found": "Nebyl nalezen žádný mikrofon", + "We were unable to access your microphone. Please check your browser settings and try again.": "Nepodařilo se získat přístup k vašemu mikrofonu . Zkontrolujte prosím nastavení prohlížeče a zkuste to znovu.", + "Unable to access your microphone": "Nelze získat přístup k mikrofonu", + "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.": "Chcete experimentovat? Laboratoře jsou nejlepším způsobem, jak získat novinky v raném stádiu, vyzkoušet nové funkce a pomoci je formovat ještě před jejich spuštěním. Zjistěte více.", + "Your access token gives full access to your account. Do not share it with anyone.": "Přístupový token vám umožní plný přístup k účtu. Nikomu ho nesdělujte.", + "Access Token": "Přístupový token", + "Spaces are a new way to group rooms and people. To join an existing space you'll need an invite.": "Prostory představují nový způsob seskupování místností a osob. Chcete-li se připojit k existujícímu prostoru, potřebujete pozvánku.", + "Please enter a name for the space": "Zadejte prosím název prostoru", + "Connecting": "Spojování", + "Allow Peer-to-Peer for 1:1 calls (if you enable this, the other party might be able to see your IP address)": "Povolit Peer-to-Peer pro hovory 1:1 (pokud tuto funkci povolíte, druhá strana může vidět vaši IP adresu)", + "Beta available for web, desktop and Android. Some features may be unavailable on your homeserver.": "Beta verze je k dispozici pro web, desktop a Android. Některé funkce mohou být na vašem domovském serveru nedostupné.", + "You can leave the beta any time from settings or tapping on a beta badge, like the one above.": "Beta verzi můžete kdykoli opustit v nastavení nebo klepnutím na štítek beta verze, jako je ten výše.", + "%(brand)s will reload with Spaces enabled. Communities and custom tags will be hidden.": "%(brand)s se znovu načte s povolenými Prostory. Skupiny a vlastní značky budou skryty.", + "Beta available for web, desktop and Android. Thank you for trying the beta.": "Beta verze je k dispozici pro web, desktop a Android. Děkujeme vám za vyzkoušení beta verze.", + "%(brand)s will reload with Spaces disabled. Communities and custom tags will be visible again.": "%(brand)s se znovu načte s vypnutými Prostory. Skupiny a vlastní značky budou opět viditelné.", + "Spaces are a new way to group rooms and people.": "Prostory představují nový způsob seskupování místností a osob.", + "Message search initialisation failed": "Inicializace vyhledávání zpráv se nezdařila", + "Spaces are a beta feature.": "Prostory jsou funkcí beta verze.", + "Search names and descriptions": "Hledat názvy a popisy", + "You may contact me if you have any follow up questions": "V případě dalších dotazů se na mě můžete obrátit", + "To leave the beta, visit your settings.": "Chcete-li opustit beta verzi, jděte do nastavení.", + "Your platform and username will be noted to help us use your feedback as much as we can.": "Vaše platforma a uživatelské jméno budou zaznamenány, abychom mohli co nejlépe využít vaši zpětnou vazbu.", + "%(featureName)s beta feedback": "%(featureName)s zpětná vazba beta verze", + "Thank you for your feedback, we really appreciate it.": "Děkujeme za vaši zpětnou vazbu, velmi si jí vážíme.", + "Beta feedback": "Zpětná vazba na betaverzi", + "Add reaction": "Přidat reakci", + "Send and receive voice messages": "Odeslat a přijmout hlasové zprávy", + "Your feedback will help make spaces better. The more detail you can go into, the better.": "Vaše zpětná vazba pomůže zlepšit prostory. Čím podrobnější bude, tím lépe.", + "If you leave, %(brand)s will reload with Spaces disabled. Communities and custom tags will be visible again.": "Pokud odejdete, %(brand)s se znovu načte s vypnutými Prostory. Skupiny a vlastní značky budou opět viditelné." } diff --git a/src/i18n/strings/cy.json b/src/i18n/strings/cy.json index 99c5296be5..b99b834636 100644 --- a/src/i18n/strings/cy.json +++ b/src/i18n/strings/cy.json @@ -8,5 +8,8 @@ "The version of %(brand)s": "Fersiwn %(brand)s", "Whether or not you're logged in (we don't record your username)": "Os ydych wedi mewngofnodi ai peidio (nid ydym yn cofnodi'ch enw defnyddiwr)", "Your language of choice": "Eich iaith o ddewis", - "The version of %(brand)s": "Fersiwn %(brand)s" + "Sign In": "Mewngofnodi", + "Create Account": "Creu Cyfrif", + "Dismiss": "Wfftio", + "Explore rooms": "Archwilio Ystafelloedd" } diff --git a/src/i18n/strings/da.json b/src/i18n/strings/da.json index 1f4eef4d93..15ce2986da 100644 --- a/src/i18n/strings/da.json +++ b/src/i18n/strings/da.json @@ -54,7 +54,7 @@ "OK": "OK", "Search": "Søg", "Custom Server Options": "Brugerdefinerede serverindstillinger", - "Dismiss": "Afskedige", + "Dismiss": "Afslut", "powered by Matrix": "Drevet af Matrix", "Close": "Luk", "Cancel": "Afbryd", @@ -71,7 +71,7 @@ "No rooms to show": "Ingen rum at vise", "This email address is already in use": "Denne email adresse er allerede i brug", "This phone number is already in use": "Dette telefonnummer er allerede i brug", - "Failed to verify email address: make sure you clicked the link in the email": "Kunne ikke bekræfte emailaddressen: vær sikker på at klikke på linket i emailen", + "Failed to verify email address: make sure you clicked the link in the email": "Kunne ikke bekræfte emailaddressen: vær sikker på at klikke på linket i e-mailen", "Call Timeout": "Opkalds Timeout", "The remote side failed to pick up": "Den anden side tog den ikke", "Unable to capture screen": "Kunne ikke optage skærm", @@ -473,9 +473,9 @@ "Show a placeholder for removed messages": "Vis en pladsholder for fjernede beskeder", "Whether you're using %(brand)s on a device where touch is the primary input mechanism": "Hvorvidt du benytter %(brand)s på en enhed, hvor touch er den primære input-grænseflade", "Your user agent": "Din user agent", - "Use Single Sign On to continue": "Brug Single Sign On til at fortsætte", + "Use Single Sign On to continue": "Brug engangs login for at fortsætte", "Confirm adding this email address by using Single Sign On to prove your identity.": "Bekræft tilføjelsen af denne email adresse ved at bruge Single Sign On til at bevise din identitet.", - "Single Sign On": "Single Sign On", + "Single Sign On": "Engangs login", "Confirm adding email": "Bekræft tilføjelse af email", "Click the button below to confirm adding this email address.": "Klik på knappen herunder for at bekræfte tilføjelsen af denne email adresse.", "Confirm": "Bekræft", @@ -618,5 +618,45 @@ "Unable to access microphone": "Kan ikke tilgå mikrofonen", "The call could not be established": "Opkaldet kunne ikke etableres", "Call Declined": "Opkald afvist", - "Folder": "Mappe" + "Folder": "Mappe", + "We couldn't log you in": "Vi kunne ikke logge dig ind", + "Try again": "Prøv igen", + "Already in call": "", + "You're already in a call with this person.": "Du har allerede i et opkald med denne person.", + "Chile": "Chile", + "Call failed because webcam or microphone could not be accessed. Check that:": "Opkald fejlede på grund af kamera og mikrofon ikke kunne nås. Tjek dette:", + "Call failed because microphone could not be accessed. Check that a microphone is plugged in and set up correctly.": "Opkald fejlede på grund af mikrofon ikke kunne nås. Tjek at din mikrofon er tilsluttet og sat op rigtigt.", + "India": "Indien", + "Iceland": "Island", + "Hong Kong": "Hong Kong", + "Greenland": "Grønland", + "Greece": "Grækenland", + "Ghana": "Ghana", + "Germany": "Tyskland", + "Faroe Islands": "Færøerne", + "Estonia": "Estonien", + "Ecuador": "Ecuador", + "Czech Republic": "Tjekkiet", + "Colombia": "Colombien", + "Chad": "Chad", + "Bulgaria": "Bulgarien", + "Brazil": "Brazilien", + "Bosnia": "Bosnien", + "Bolivia": "Bolivien", + "Belarus": "Hviderusland", + "Austria": "Østrig", + "Australia": "Australien", + "Armenia": "Armenien", + "Argentina": "Argentina", + "Antarctica": "Antarktis", + "Angola": "Angola", + "Albania": "Albanien", + "Afghanistan": "Afghanistan", + "United States": "Amerikas Forenede Stater", + "United Kingdom": "Storbritanien", + "This will end the conference for everyone. Continue?": "Dette vil afbryde opkaldet for alle. Fortsæt?", + "No other application is using the webcam": "Ingen anden application bruger kameraet", + "A microphone and webcam are plugged in and set up correctly": "En mikrofon og kamera er tilsluttet og sat op rigtigt", + "Croatia": "Kroatien", + "Answered Elsewhere": "Svaret andet sted" } diff --git a/src/i18n/strings/de_DE.json b/src/i18n/strings/de_DE.json index 174c60170f..cfe87ad823 100644 --- a/src/i18n/strings/de_DE.json +++ b/src/i18n/strings/de_DE.json @@ -11,22 +11,22 @@ "The email address linked to your account must be entered.": "Es muss die mit dem Benutzerkonto verbundene E-Mail-Adresse eingegeben werden.", "Name": "Name", "Session ID": "Sitzungs-ID", - "Displays action": "Zeigt Aktionen an", + "Displays action": "Als Aktionen anzeigen", "Bans user with given id": "Verbannt den Benutzer mit der angegebenen ID", "Deops user with given id": "Setzt das Berechtigungslevel beim Benutzer mit der angegebenen ID zurück", "Invites user with given id to current room": "Lädt den Benutzer mit der angegebenen ID in den aktuellen Raum ein", "Kicks user with given id": "Benutzer mit der angegebenen ID kicken", "Changes your display nickname": "Ändert deinen Anzeigenamen", "Change Password": "Passwort ändern", - "Searches DuckDuckGo for results": "Verwendet DuckDuckGo für Suchergebnisse", + "Searches DuckDuckGo for results": "Verwendet DuckDuckGo zum Suchen", "Commands": "Kommandos", "Emoji": "Emojis", "Sign in": "Anmelden", "Warning!": "Warnung!", "Error": "Fehler", "Advanced": "Erweitert", - "Anyone who knows the room's link, apart from guests": "Alle, denen der Raum-Link bekannt ist (ausgenommen Gäste)", - "Anyone who knows the room's link, including guests": "Alle, denen der Raum-Link bekannt ist (auch Gäste)", + "Anyone who knows the room's link, apart from guests": "Alle, die den Raumlink kennen (ausgenommen Gäste)", + "Anyone who knows the room's link, including guests": "Alle, die den Raumlink kennen (auch Gäste)", "Are you sure you want to reject the invitation?": "Bist du sicher, dass du die Einladung ablehnen willst?", "Banned users": "Verbannte Benutzer", "Continue": "Fortfahren", @@ -47,10 +47,10 @@ "For security, this session has been signed out. Please sign in again.": "Aus Sicherheitsgründen wurde diese Sitzung beendet. Bitte melde dich erneut an.", "Guests cannot join this room even if explicitly invited.": "Gäste können diesem Raum nicht beitreten, auch wenn sie explizit eingeladen wurden.", "Hangup": "Auflegen", - "Homeserver is": "Der Heimserver ist", + "Homeserver is": "Dein Heimserver ist", "Identity Server is": "Der Identitätsserver ist", "I have verified my email address": "Ich habe meine E-Mail-Adresse verifiziert", - "Import E2E room keys": "E2E-Raum-Schlüssel importieren", + "Import E2E room keys": "E2E-Raumschlüssel importieren", "Invalid Email Address": "Ungültige E-Mail-Adresse", "Sign in with": "Anmelden mit", "Leave room": "Raum verlassen", @@ -78,7 +78,7 @@ "Someone": "Jemand", "Success": "Erfolg", "This doesn't appear to be a valid email address": "Dies scheint keine gültige E-Mail-Adresse zu sein", - "This room is not accessible by remote Matrix servers": "Remote-Matrix-Server können auf diesen Raum nicht zugreifen", + "This room is not accessible by remote Matrix servers": "Dieser Raum ist von Personen auf anderen Matrix-Servern nicht betretbar", "Admin": "Administrator", "Server may be unavailable, overloaded, or you hit a bug.": "Server ist nicht verfügbar, überlastet oder du bist auf einen Softwarefehler gestoßen.", "Labs": "Labor", @@ -88,7 +88,7 @@ "Unban": "Verbannung aufheben", "unknown error code": "Unbekannter Fehlercode", "Upload avatar": "Profilbild hochladen", - "Upload file": "Datei hochladen", + "Upload file": "Datei senden", "Users": "Benutzer", "Verification Pending": "Verifizierung ausstehend", "Video call": "Videoanruf", @@ -102,7 +102,7 @@ "Existing Call": "Bereits bestehender Anruf", "Failed to verify email address: make sure you clicked the link in the email": "Verifizierung der E-Mail-Adresse fehlgeschlagen: Bitte stelle sicher, dass du den Link in der E-Mail angeklickt hast", "Failure to create room": "Raumerstellung fehlgeschlagen", - "%(brand)s does not have permission to send you notifications - please check your browser settings": "%(brand)s hat keine Berechtigung, um Benachrichtigungen zu senden - bitte Browser-Einstellungen überprüfen", + "%(brand)s does not have permission to send you notifications - please check your browser settings": "%(brand)s hat keine Berechtigung, Benachrichtigungen zu senden - Bitte überprüfe deine Browsereinstellungen", "%(brand)s was not given permission to send notifications - please try again": "%(brand)s hat keine Berechtigung für das Senden von Benachrichtigungen erhalten - Bitte versuche es erneut", "This email address is already in use": "Diese E-Mail-Adresse wird bereits verwendet", "This email address was not found": "Diese E-Mail-Adresse konnte nicht gefunden werden", @@ -110,12 +110,12 @@ "This phone number is already in use": "Diese Telefonnummer wird bereits verwendet", "Unable to capture screen": "Der Bildschirm kann nicht aufgenommen werden", "Unable to enable Notifications": "Benachrichtigungen konnten nicht aktiviert werden", - "Upload Failed": "Upload fehlgeschlagen", + "Upload Failed": "Hochladen fehlgeschlagen", "VoIP is unsupported": "VoIP wird nicht unterstützt", "You are already in a call.": "Du bist bereits in einem Gespräch.", "You cannot place a call with yourself.": "Du kannst keinen Anruf mit dir selbst starten.", - "You cannot place VoIP calls in this browser.": "VoIP-Gespräche werden von diesem Browser nicht unterstützt.", - "Your email address does not appear to be associated with a Matrix ID on this Homeserver.": "Deine E-Mail-Adresse scheint nicht mit einer Matrix-ID auf diesem Homeserver verbunden zu sein.", + "You cannot place VoIP calls in this browser.": "Anrufe werden von diesem Browser nicht unterstützt.", + "Your email address does not appear to be associated with a Matrix ID on this Homeserver.": "Deine E-Mail-Adresse scheint nicht mit einer Matrix-ID auf diesem Heimserver verbunden zu sein.", "Sun": "So", "Mon": "Mo", "Tue": "Di", @@ -125,13 +125,13 @@ "Sat": "Sa", "Jan": "Jan", "Feb": "Feb", - "Mar": "Mrz", - "Apr": "April", + "Mar": "Mär", + "Apr": "Apr", "May": "Mai", "Jun": "Jun", "Jul": "Jul", "Aug": "Aug", - "Sep": "Sep", + "Sep": "Sept", "Oct": "Okt", "Nov": "Nov", "Dec": "Dez", @@ -155,10 +155,10 @@ "%(userId)s from %(fromPowerLevel)s to %(toPowerLevel)s": "%(userId)s von %(fromPowerLevel)s zu %(toPowerLevel)s", "%(senderName)s invited %(targetName)s.": "%(senderName)s hat %(targetName)s eingeladen.", "%(targetName)s joined the room.": "%(targetName)s hat den Raum betreten.", - "%(senderName)s kicked %(targetName)s.": "%(senderName)s hat %(targetName)s gekickt.", + "%(senderName)s kicked %(targetName)s.": "%(senderName)s hat %(targetName)s rausgeworfen.", "%(targetName)s left the room.": "%(targetName)s hat den Raum verlassen.", - "%(senderName)s made future room history visible to all room members, from the point they are invited.": "%(senderName)s hat den zukünftigen Chatverlauf für alle Raum-Mitglieder sichtbar gemacht (ab dem Zeitpunkt, an dem sie eingeladen wurden).", - "%(senderName)s made future room history visible to all room members, from the point they joined.": "%(senderName)s hat den zukünftigen Chatverlauf für alle Raum-Mitglieder sichtbar gemacht (ab dem Zeitpunkt, an dem sie beigetreten sind).", + "%(senderName)s made future room history visible to all room members, from the point they are invited.": "%(senderName)s hat den Chatverlauf für alle Raummitglieder ab ihrer Einladung sichtbar gemacht.", + "%(senderName)s made future room history visible to all room members, from the point they joined.": "%(senderName)s hat den Chatverlauf für alle Raummitglieder ab ihrem Beitreten sichtbar gemacht.", "%(senderName)s made future room history visible to all room members.": "%(senderName)s hat den zukünftigen Chatverlauf für alle Raummitglieder sichtbar gemacht.", "%(senderName)s made future room history visible to anyone.": "%(senderName)s hat den zukünftigen Chatverlauf für alle sichtbar gemacht.", "%(senderName)s made future room history visible to unknown (%(visibility)s).": "%(senderName)s hat den zukünftigen Chatverlauf für Unbekannte sichtbar gemacht (%(visibility)s).", @@ -190,7 +190,7 @@ "click to reveal": "anzeigen", "Failed to forget room %(errCode)s": "Das Entfernen des Raums ist fehlgeschlagen %(errCode)s", "and %(count)s others...|other": "und %(count)s weitere...", - "and %(count)s others...|one": "und ein(e) weitere(r)...", + "and %(count)s others...|one": "und ein weiterer...", "Are you sure?": "Bist du sicher?", "Attachment": "Anhang", "Ban": "Bannen", @@ -204,18 +204,18 @@ "Failed to ban user": "Verbannen des Benutzers fehlgeschlagen", "Failed to change power level": "Ändern der Berechtigungsstufe fehlgeschlagen", "Failed to join room": "Betreten des Raumes ist fehlgeschlagen", - "Failed to kick": "Kicken fehlgeschlagen", + "Failed to kick": "Rauswurf fehlgeschlagen", "Failed to mute user": "Stummschalten des Nutzers fehlgeschlagen", "Failed to reject invite": "Ablehnen der Einladung ist fehlgeschlagen", - "Failed to set display name": "Anzeigename konnte nicht gesetzt werden", + "Failed to set display name": "Anzeigename konnte nicht geändert werden", "Fill screen": "Fülle Bildschirm", "Incorrect verification code": "Falscher Verifizierungscode", "Join Room": "Raum beitreten", - "Kick": "Kicken", - "not specified": "nicht spezifiziert", + "Kick": "Rausschmeißen", + "not specified": "nicht angegeben", "No more results": "Keine weiteren Ergebnisse", "No results": "Keine Ergebnisse", - "OK": "OK", + "OK": "Ok", "Search": "Suchen", "Search failed": "Suche ist fehlgeschlagen", "Server error": "Serverfehler", @@ -224,7 +224,7 @@ "%(count)s of your messages have not been sent.|other": "Einige deiner Nachrichten wurden nicht gesendet.", "Submit": "Absenden", "This room has no local addresses": "Dieser Raum hat keine lokale Adresse", - "Tried to load a specific point in this room's timeline, but you do not have permission to view the message in question.": "Es wurde versucht, einen bestimmten Punkt im Chatverlauf dieses Raumes zu laden. Dir fehlt jedoch die Berechtigung, die betreffende Nachricht zu sehen.", + "Tried to load a specific point in this room's timeline, but you do not have permission to view the message in question.": "Dir fehlt die Berechtigung, diese alten Nachrichten zu lesen.", "Tried to load a specific point in this room's timeline, but was unable to find it.": "Es wurde versucht, einen bestimmten Punkt im Chatverlauf dieses Raumes zu laden, der Punkt konnte jedoch nicht gefunden werden.", "You seem to be in a call, are you sure you want to quit?": "Du scheinst in einem Gespräch zu sein, bist du sicher, dass du aufhören willst?", "You seem to be uploading files, are you sure you want to quit?": "Du scheinst Dateien hochzuladen. Bist du sicher schließen zu wollen?", @@ -234,11 +234,11 @@ "Click to unmute video": "Klicken, um die Video-Stummschaltung zu deaktivieren", "Click to unmute audio": "Klicken, um den Ton wieder einzuschalten", "Failed to load timeline position": "Laden der Chat-Position fehlgeschlagen", - "Autoplay GIFs and videos": "GIF-Dateien und Videos automatisch abspielen", + "Autoplay GIFs and videos": "Videos und GIFs automatisch abspielen", "%(items)s and %(lastItem)s": "%(items)s und %(lastItem)s", "%(weekDayName)s, %(monthName)s %(day)s %(fullYear)s %(time)s": "%(weekDayName)s, %(day)s. %(monthName)s %(fullYear)s %(time)s", - "Access Token:": "Zugangs-Token:", - "Always show message timestamps": "Nachrichten-Zeitstempel immer anzeigen", + "Access Token:": "Zugangstoken:", + "Always show message timestamps": "Nachrichtenzeitstempel immer anzeigen", "Authentication": "Authentifizierung", "An error has occurred.": "Ein Fehler ist aufgetreten.", "Confirm password": "Passwort bestätigen", @@ -248,7 +248,7 @@ "olm version:": "Version von olm:", "Passwords can't be empty": "Passwortfelder dürfen nicht leer sein", "%(brand)s version:": "Version von %(brand)s:", - "Show timestamps in 12 hour format (e.g. 2:30pm)": "Zeitstempel im 12-Stunden-Format anzeigen (z. B. 2:30pm)", + "Show timestamps in 12 hour format (e.g. 2:30pm)": "Uhrzeiten im 12-Stunden-Format (z. B. 2:30pm)", "Email address": "E-Mail-Adresse", "Error decrypting attachment": "Fehler beim Entschlüsseln des Anhangs", "Mute": "Stummschalten", @@ -256,7 +256,7 @@ "Unmute": "Stummschalten aufheben", "Invalid file%(extra)s": "Ungültige Datei%(extra)s", "Please select the destination room for this message": "Wähle den Raum aus, an den du die Nachricht schicken willst", - "%(senderDisplayName)s removed the room name.": "%(senderDisplayName)s hat den Raum-Namen entfernt.", + "%(senderDisplayName)s removed the room name.": "%(senderDisplayName)s hat den Raumnamen entfernt.", "Passphrases must match": "Passphrases müssen übereinstimmen", "Passphrase must not be empty": "Passphrase darf nicht leer sein", "Export room keys": "Raum-Schlüssel exportieren", @@ -282,15 +282,15 @@ "If you don't specify an email address, you won't be able to reset your password. Are you sure?": "Wenn du keine E-Mail-Adresse angibst, wirst du nicht in der Lage sein, dein Passwort zurückzusetzen. Bist du sicher?", "Error decrypting audio": "Entschlüsseln des Audios fehlgeschlagen", "Error decrypting image": "Entschlüsselung des Bilds fehlgeschlagen", - "Error decrypting video": "Video-Entschlüsselung fehlgeschlagen", + "Error decrypting video": "Videoentschlüsselung fehlgeschlagen", "Import room keys": "Raum-Schlüssel importieren", "File to import": "Zu importierende Datei", "Failed to invite the following users to the %(roomName)s room:": "Folgende Benutzer konnten nicht in den Raum \"%(roomName)s\" eingeladen werden:", - "Are you sure you wish to remove (delete) this event? Note that if you delete a room name or topic change, it could undo the change.": "Bist du sicher, dass du dieses Ereignis entfernen (löschen) möchtest? Wenn du die Änderung eines Raum-Namens oder eines Raum-Themas löscht, kann dies dazu führen, dass die ursprüngliche Änderung rückgängig gemacht wird.", + "Are you sure you wish to remove (delete) this event? Note that if you delete a room name or topic change, it could undo the change.": "Bist du sicher, dass du dieses Ereignis entfernen (löschen) möchtest? Wenn du die Änderung eines Raumnamens oder eines Raumthemas löscht, kann dies dazu führen, dass die ursprüngliche Änderung rückgängig gemacht wird.", "This process allows you to export the keys for messages you have received in encrypted rooms to a local file. You will then be able to import the file into another Matrix client in the future, so that client will also be able to decrypt these messages.": "Dieser Prozess erlaubt es dir, die Schlüssel für die in verschlüsselten Räumen empfangenen Nachrichten in eine lokale Datei zu exportieren. In Zukunft wird es möglich sein, diese Datei in einen anderen Matrix-Client zu importieren, sodass dieser Client diese Nachrichten ebenfalls entschlüsseln kann.", "The exported file will allow anyone who can read it to decrypt any encrypted messages that you can see, so you should be careful to keep it secure. To help with this, you should enter a passphrase below, which will be used to encrypt the exported data. It will only be possible to import the data by using the same passphrase.": "Mit der exportierten Datei kann jeder, der diese Datei lesen kann, jede verschlüsselte Nachricht entschlüsseln, die für dich lesbar ist. Du solltest die Datei also unbedingt sicher verwahren. Um den Vorgang sicherer zu gestalten, solltest du unten eine Passphrase eingeben, die dazu verwendet wird, die exportierten Daten zu verschlüsseln. Anschließend wird es nur möglich sein, die Daten zu importieren, wenn dieselbe Passphrase verwendet wird.", "Analytics": "Datenverkehrsanalyse", - "%(brand)s collects anonymous analytics to allow us to improve the application.": "%(brand)s sammelt anonymisierte Analysedaten, um die Anwendung kontinuierlich verbessern zu können.", + "%(brand)s collects anonymous analytics to allow us to improve the application.": "Wir sammeln anonymisierte Analysedaten, um %(brand)s verbessern zu können.", "Add an Integration": "Eine Integration hinzufügen", "URL Previews": "URL-Vorschau", "Offline": "Offline", @@ -302,7 +302,7 @@ "Idle": "Abwesend", "Ongoing conference call%(supportedText)s.": "Laufendes Konferenzgespräch%(supportedText)s.", "You are about to be taken to a third-party site so you can authenticate your account for use with %(integrationsUrl)s. Do you wish to continue?": "Um dein Konto für die Verwendung von %(integrationsUrl)s zu authentifizieren, wirst du jetzt auf die Website eines Drittanbieters weitergeleitet. Möchtest du fortfahren?", - "Start automatically after system login": "Nach System-Login automatisch starten", + "Start automatically after system login": "Nach Systemstart automatisch starten", "Jump to first unread message.": "Zur ersten ungelesenen Nachricht springen.", "Options": "Optionen", "Invited": "Eingeladen", @@ -326,8 +326,8 @@ "Register": "Registrieren", "Save": "Speichern", "Verified key": "Verifizierter Schlüssel", - "You have disabled URL previews by default.": "Du hast die URL-Vorschau standardmäßig deaktiviert.", - "You have enabled URL previews by default.": "Du hast die URL-Vorschau standardmäßig aktiviert.", + "You have disabled URL previews by default.": "Du hast die URL-Vorschau standardmäßig deaktiviert.", + "You have enabled URL previews by default.": "Du hast die URL-Vorschau standardmäßig aktiviert.", "%(senderDisplayName)s changed the room avatar to ": "%(senderDisplayName)s hat das Raumbild zu geändert", "%(senderDisplayName)s changed the avatar for %(roomName)s": "%(senderDisplayName)s hat das Raumbild von %(roomName)s geändert", "Add": "Hinzufügen", @@ -338,7 +338,7 @@ "Uploading %(filename)s and %(count)s others|one": "%(filename)s und %(count)s weitere Dateien werden hochgeladen", "Uploading %(filename)s and %(count)s others|other": "%(filename)s und %(count)s weitere Dateien werden hochgeladen", "You must register to use this functionality": "Du musst dich registrieren, um diese Funktionalität nutzen zu können", - "Create new room": "Neuen Raum erstellen", + "Create new room": "Neuer Raum", "Room directory": "Raum-Verzeichnis", "Start chat": "Chat starten", "New Password": "Neues Passwort", @@ -349,7 +349,7 @@ "If you already have a Matrix account you can log in instead.": "Wenn du bereits ein Matrix-Benutzerkonto hast, kannst du dich stattdessen auch direkt anmelden.", "Home": "Startseite", "Username invalid: %(errMessage)s": "Ungültiger Benutzername: %(errMessage)s", - "Accept": "Akzeptieren", + "Accept": "Annehmen", "Active call (%(roomName)s)": "Aktiver Anruf (%(roomName)s)", "Admin Tools": "Administratorwerkzeuge", "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.", @@ -362,7 +362,7 @@ "Incoming video call from %(name)s": "Eingehender Videoanruf von %(name)s", "Incoming voice call from %(name)s": "Eingehender Sprachanruf von %(name)s", "Join as voice or video.": "Per Sprachanruf oder Videoanruf beitreten.", - "Last seen": "Zuletzt gesehen", + "Last seen": "Zuletzt gesehen um", "No display name": "Kein Anzeigename", "Private Chat": "Privater Chat", "Public Chat": "Öffentlicher Chat", @@ -380,19 +380,19 @@ "(could not connect media)": "(Medienverbindung konnte nicht hergestellt werden)", "(no answer)": "(keine Antwort)", "(unknown failure: %(reason)s)": "(Unbekannter Fehler: %(reason)s)", - "Your browser does not support the required cryptography extensions": "Dein Browser unterstützt die benötigten Verschlüsselungs-Erweiterungen nicht", + "Your browser does not support the required cryptography extensions": "Dein Browser unterstützt die benötigten Verschlüsselungserweiterungen nicht", "Not a valid %(brand)s keyfile": "Keine gültige %(brand)s-Schlüsseldatei", "Authentication check failed: incorrect password?": "Authentifizierung fehlgeschlagen: Falsches Passwort?", "Do you want to set an email address?": "Möchtest du eine E-Mail-Adresse setzen?", "This will allow you to reset your password and receive notifications.": "Dies ermöglicht es dir, dein Passwort zurückzusetzen und Benachrichtigungen zu empfangen.", "Skip": "Überspringen", - "Check for update": "Suche nach Updates", + "Check for update": "Nach Aktualisierung suchen", "Add a widget": "Widget hinzufügen", "Allow": "Erlauben", "Delete widget": "Widget entfernen", "Define the power level of a user": "Berechtigungsstufe einers Benutzers setzen", "Edit": "Bearbeiten", - "Enable automatic language detection for syntax highlighting": "Automatische Spracherkennung für die Syntax-Hervorhebung aktivieren", + "Enable automatic language detection for syntax highlighting": "Automatische Spracherkennung für die Syntaxhervorhebung", "To get started, please pick a username!": "Um zu starten, wähle bitte einen Nutzernamen!", "Unable to create widget.": "Widget kann nicht erstellt werden.", "You are not in this room.": "Du bist nicht in diesem Raum.", @@ -412,9 +412,9 @@ "%(widgetName)s widget modified by %(senderName)s": "Das Widget '%(widgetName)s' wurde von %(senderName)s bearbeitet", "Copied!": "Kopiert!", "Failed to copy": "Kopieren fehlgeschlagen", - "Ignore": "Ignorieren", - "You are now ignoring %(userId)s": "%(userId)s wird jetzt ignoriert", - "You are no longer ignoring %(userId)s": "%(userId)s wird nicht mehr ignoriert", + "Ignore": "Blockieren", + "You are now ignoring %(userId)s": "%(userId)s ist jetzt blockiert", + "You are no longer ignoring %(userId)s": "%(userId)s wird nicht mehr blockiert", "Leave": "Verlassen", "Failed to invite the following users to %(groupId)s:": "Die folgenden Benutzer konnten nicht in die Gruppe %(groupId)s eingeladen werden:", "Leave %(groupName)s?": "%(groupName)s verlassen?", @@ -422,15 +422,15 @@ "Add a User": "Benutzer hinzufügen", "You have entered an invalid address.": "Du hast eine ungültige Adresse eingegeben.", "Matrix ID": "Matrix-ID", - "Unignore": "Nicht mehr ignorieren", - "Unignored user": "Benutzer nicht mehr ignoriert", - "Ignored user": "Benutzer ignoriert", + "Unignore": "Nicht mehr blockieren", + "Unignored user": "Benutzer nicht mehr blockiert", + "Ignored user": "Benutzer blockiert", "Stops ignoring a user, showing their messages going forward": "Benutzer nicht mehr ignorieren und neue Nachrichten wieder anzeigen", - "Ignores a user, hiding their messages from you": "Ignoriert einen Benutzer und verbirgt dessen Nachrichten", + "Ignores a user, hiding their messages from you": "Nutzer blockieren und dessen Nachrichten ausblenden", "Banned by %(displayName)s": "Verbannt von %(displayName)s", "Description": "Beschreibung", "Unable to accept invite": "Einladung kann nicht angenommen werden", - "Failed to invite users to %(groupId)s": "Benutzer konnten nicht in %(groupId)s eingeladen werden", + "Failed to invite users to %(groupId)s": "Einige Leute konnten nicht in %(groupId)s eingeladen werden", "Unable to reject invite": "Einladung konnte nicht abgelehnt werden", "Who would you like to add to this summary?": "Wen möchtest zu dieser Übersicht hinzufügen?", "Add to summary": "Zur Übersicht hinzufügen", @@ -443,7 +443,7 @@ "The user '%(displayName)s' could not be removed from the summary.": "Der Benutzer '%(displayName)s' konnte nicht aus der Übersicht entfernt werden.", "Unknown": "Unbekannt", "Failed to add the following rooms to %(groupId)s:": "Die folgenden Räume konnten nicht zu %(groupId)s hinzugefügt werden:", - "Matrix Room ID": "Matrix-Raum-ID", + "Matrix Room ID": "Matrixraum-ID", "email address": "E-Mail-Adresse", "Try using one of the following valid address types: %(validTypesList)s.": "Bitte einen der folgenden gültigen Adresstypen verwenden: %(validTypesList)s.", "Failed to remove '%(roomName)s' from %(groupId)s": "Entfernen von '%(roomName)s' aus %(groupId)s fehlgeschlagen", @@ -451,7 +451,7 @@ "Pinned Messages": "Angeheftete Nachrichten", "%(senderName)s changed the pinned messages for the room.": "%(senderName)s hat die angehefteten Nachrichten für diesen Raum geändert.", "Jump to read receipt": "Zur Lesebestätigung springen", - "Message Pinning": "Nachrichten-Anheftung", + "Message Pinning": "Nachrichten anheften", "Long Description (HTML)": "Lange Beschreibung (HTML)", "Jump to message": "Zur Nachricht springen", "No pinned messages.": "Keine angehefteten Nachrichten vorhanden.", @@ -469,19 +469,19 @@ "Which rooms would you like to add to this community?": "Welche Räume sollen zu dieser Community hinzugefügt werden?", "Add rooms to the community": "Räume zur Community hinzufügen", "Add to community": "Zur Community hinzufügen", - "Failed to invite users to community": "Benutzer konnten nicht in die Community eingeladen werden", + "Failed to invite users to community": "Einige Leute konnten nicht in die Community eingeladen werden", "Communities": "Communities", "Invalid community ID": "Ungültige Community-ID", "'%(groupId)s' is not a valid community ID": "'%(groupId)s' ist keine gültige Community-ID", "New community ID (e.g. +foo:%(localDomain)s)": "Neue Community-ID (z. B. +foo:%(localDomain)s)", "Remove from community": "Aus Community entfernen", "Failed to remove user from community": "Entfernen des Benutzers aus der Community fehlgeschlagen", - "Filter community members": "Community-Mitglieder filtern", - "Filter community rooms": "Community-Räume filtern", + "Filter community members": "Communitymitglieder filtern", + "Filter community rooms": "Communityräume filtern", "Failed to remove room from community": "Entfernen des Raumes aus der Community fehlgeschlagen", - "Removing a room from the community will also remove it from the community page.": "Das Entfernen eines Raumes aus der Community wird ihn auch von der Community-Seite entfernen.", + "Removing a room from the community will also remove it from the community page.": "Das Entfernen eines Raumes aus der Community wird ihn auch von der Communityseite entfernen.", "Create Community": "Community erstellen", - "Community Name": "Community-Name", + "Community Name": "Communityname", "Community ID": "Community-ID", "example": "Beispiel", "Add rooms to the community summary": "Fügt Räume zur Community-Übersicht hinzu", @@ -504,7 +504,7 @@ "Delete Widget": "Widget löschen", "Mention": "Erwähnen", "Invite": "Einladen", - "Deleting a widget removes it for all users in this room. Are you sure you want to delete this widget?": "Das Löschen eines Widgets entfernt es für alle Nutzer in diesem Raum. Möchtest du es wirklich löschen?", + "Deleting a widget removes it for all users in this room. Are you sure you want to delete this widget?": "Das Löschen des Widgets entfernt es für alle in diesem Raum. Wirklich löschen?", "Mirror local video feed": "Lokalen Video-Feed spiegeln", "Failed to withdraw invitation": "Die Einladung konnte nicht zurückgezogen werden", "Community IDs may only contain characters a-z, 0-9, or '=_-./'": "Community-IDs dürfen nur die folgenden Zeichen enthalten: a-z, 0-9, or '=_-./'", @@ -539,10 +539,10 @@ "were banned %(count)s times|one": "wurden verbannt", "was banned %(count)s times|other": "wurde %(count)s-mal verbannt", "was banned %(count)s times|one": "wurde verbannt", - "were kicked %(count)s times|other": "wurden %(count)s-mal gekickt", - "were kicked %(count)s times|one": "wurden gekickt", - "was kicked %(count)s times|other": "wurde %(count)s-mal gekickt", - "was kicked %(count)s times|one": "wurde gekickt", + "were kicked %(count)s times|other": "wurden %(count)s-mal rausgeworfen", + "were kicked %(count)s times|one": "wurden rausgeworfen", + "was kicked %(count)s times|other": "wurde %(count)s-mal rausgeworfen", + "was kicked %(count)s times|one": "wurde rausgeworfen", "%(severalUsers)schanged their name %(count)s times|other": "%(severalUsers)shaben %(count)s-mal ihren Namen geändert", "%(severalUsers)schanged their name %(count)s times|one": "%(severalUsers)shaben ihren Namen geändert", "%(oneUser)schanged their name %(count)s times|other": "%(oneUser)shat %(count)s-mal den Namen geändert", @@ -551,12 +551,12 @@ "%(oneUser)schanged their avatar %(count)s times|other": "%(oneUser)shat das Profilbild %(count)s-mal geändert", "%(oneUser)schanged their avatar %(count)s times|one": "%(oneUser)shat das Profilbild geändert", "Disinvite this user?": "Einladung für diesen Benutzer zurückziehen?", - "Kick this user?": "Diesen Benutzer kicken?", + "Kick this user?": "Diesen Benutzer rausschmeißen?", "Unban this user?": "Verbannung für diesen Benutzer aufheben?", "Ban this user?": "Diesen Benutzer verbannen?", - "Members only (since the point in time of selecting this option)": "Nur Mitglieder (ab dem Zeitpunkt, an dem diese Option ausgewählt wird)", - "Members only (since they were invited)": "Nur Mitglieder (ab dem Zeitpunkt, an dem sie eingeladen wurden)", - "Members only (since they joined)": "Nur Mitglieder (ab dem Zeitpunkt, an dem sie beigetreten sind)", + "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)", "An email has been sent to %(emailAddress)s": "Eine E-Mail wurde an %(emailAddress)s gesendet", "A text message has been sent to %(msisdn)s": "Eine Textnachricht wurde an %(msisdn)s gesendet", "Disinvite this user from community?": "Community-Einladung für diesen Benutzer zurückziehen?", @@ -577,21 +577,21 @@ "The visibility of '%(roomName)s' in %(groupId)s could not be updated.": "Die Sichtbarkeit von '%(roomName)s' in %(groupId)s konnte nicht aktualisiert werden.", "Visibility in Room List": "Sichtbarkeit in Raumliste", "Visible to everyone": "Für alle sichtbar", - "Only visible to community members": "Nur für Community-Mitglieder sichtbar", + "Only visible to community members": "Nur für Communitymitglieder sichtbar", "Community Invites": "Community-Einladungen", "Notify the whole room": "Alle im Raum benachrichtigen", "Room Notification": "Raum-Benachrichtigung", "These rooms are displayed to community members on the community page. Community members can join the rooms by clicking on them.": "Diese Räume werden Community-Mitgliedern auf der Community-Seite angezeigt. Community-Mitglieder können diesen Räumen beitreten, indem sie diese anklicken.", - "Show these rooms to non-members on the community page and room list?": "Sollen diese Räume Nicht-Mitgliedern auf der Community-Seite und in der Raum-Liste angezeigt werden?", + "Show these rooms to non-members on the community page and room list?": "Sollen diese Räume öffentlich auf der Communityseite und in der Raumliste angezeigt werden?", "

    HTML for your community's page

    \n

    \n Use the long description to introduce new members to the community, or distribute\n some important links\n

    \n

    \n You can even use 'img' tags\n

    \n": "

    HTML für deine Community-Seite

    \n

    \n Nutze die ausführliche Beschreibung, um neuen Mitgliedern diese Community vorzustellen\n oder um wichtige Links bereitzustellen.\n

    \n

    \n Du kannst sogar 'img'-Tags (HTML) verwenden\n

    \n", "Your community hasn't got a Long Description, a HTML page to show to community members.
    Click here to open settings and give it one!": "Deine Community hat noch keine ausführliche Beschreibung, d. h. eine HTML-Seite, die Community-Mitgliedern angezeigt wird.
    Hier klicken, um die Einstellungen zu öffnen und eine Beschreibung zu erstellen!", "Enable inline URL previews by default": "URL-Vorschau standardmäßig aktivieren", - "Enable URL previews for this room (only affects you)": "URL-Vorschau für diesen Raum aktivieren (betrifft nur dich)", - "Enable URL previews by default for participants in this room": "URL-Vorschau standardmäßig für Mitglieder dieses Raumes aktivieren", - "Please note you are logging into the %(hs)s server, not matrix.org.": "Du meldest dich gerade am %(hs)s-Server an, nicht auf matrix.org.", + "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.", "There's no one else here! Would you like to invite others or stop warning about the empty room?": "Sonst ist hier aktuell niemand. Möchtest du Benutzer einladen oder die Warnmeldung bezüglich des leeren Raums deaktivieren?", - "URL previews are disabled by default for participants in this room.": "URL-Vorschau ist für Mitglieder dieses Raumes standardmäßig deaktiviert.", - "URL previews are enabled by default for participants in this room.": "URL-Vorschau ist für Mitglieder dieses Raumes standardmäßig aktiviert.", + "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", "%(duration)ss": "%(duration)ss", "%(duration)sm": "%(duration)sm", @@ -627,41 +627,41 @@ "The version of %(brand)s": "Die %(brand)s-Version", "Your language of choice": "Deine ausgewählte Sprache", "Whether or not you're using the Richtext mode of the Rich Text Editor": "Ob du den Richtext-Modus des Editors benutzt oder nicht", - "Your homeserver's URL": "Deine Homeserver-URL", + "Your homeserver's URL": "Deine Heimserver-URL", "%(weekDayName)s, %(monthName)s %(day)s %(fullYear)s": "%(weekDayName)s, %(day)s. %(monthName)s %(fullYear)s", "You will not be able to undo this change as you are demoting yourself, if you are the last privileged user in the room it will be impossible to regain privileges.": "Du wirst nicht in der Lage sein, die Änderung zurückzusetzen, da du dich degradierst. Wenn du der letze Nutzer mit Berechtigungen bist, wird es unmöglich sein die Privilegien zurückzubekommen.", "Community IDs cannot be empty.": "Community-IDs können nicht leer sein.", "Learn more about how we use analytics.": "Lerne mehr darüber, wie wir die Analysedaten nutzen.", - "Where this page includes identifiable information, such as a room, user or group ID, that data is removed before being sent to the server.": "Wenn diese Seite identifizierbare Informationen wie Raum-, Nutzer- oder Gruppen-ID enthält, werden diese Daten entfernt bevor sie an den Server gesendet werden.", + "Where this page includes identifiable information, such as a room, user or group ID, that data is removed before being sent to the server.": "Wenn diese Seite identifizierbare Informationen wie Raum-, Nutzer- oder Gruppen-ID enthält, werden diese Daten entfernt, bevor sie an den Server gesendet werden.", "Which officially provided instance you are using, if any": "Welche offiziell angebotene Instanz du nutzt, wenn überhaupt eine", "In reply to ": "Als Antwort auf ", "This room is not public. You will not be able to rejoin without an invite.": "Dies ist kein öffentlicher Raum. Du wirst diesen nicht ohne Einladung wieder beitreten können.", "%(oldDisplayName)s changed their display name to %(displayName)s.": "%(oldDisplayName)s hat den Anzeigenamen zu %(displayName)s geändert.", - "Failed to set direct chat tag": "Fehler beim Setzen der Direkt-Chat-Markierung", + "Failed to set direct chat tag": "Fehler beim Setzen der Direktchat-Markierung", "Failed to remove tag %(tagName)s from room": "Entfernen der Raum-Kennzeichnung %(tagName)s fehlgeschlagen", "Failed to add tag %(tagName)s to room": "Fehler beim Hinzufügen des \"%(tagName)s\"-Tags an dem Raum", "Did you know: you can use communities to filter your %(brand)s experience!": "Wusstest du: Du kannst Communities nutzen um deine %(brand)s-Erfahrung zu filtern!", "To set up a filter, drag a community avatar over to the filter panel on the far left hand side of the screen. You can click on an avatar in the filter panel at any time to see only the rooms and people associated with that community.": "Um einen Filter zu setzen, ziehe ein Community-Bild auf das Filter-Panel ganz links. Du kannst jederzeit auf einen Avatar im Filter-Panel klicken, um nur die Räume und Personen aus der Community zu sehen.", "Clear filter": "Filter zurücksetzen", "Key request sent.": "Schlüsselanfrage gesendet.", - "If you've submitted a bug via GitHub, debug logs can help us track down the problem. Debug logs contain application usage data including your username, the IDs or aliases of the rooms or groups you have visited and the usernames of other users. They do not contain messages.": "Wenn du einen Fehler via GitHub meldest, können Fehlerberichte uns helfen um das Problem zu finden. Sie enthalten Anwendungsdaten wie deinen Nutzernamen, Raum- und Gruppen-ID's und Aliase die du besucht hast und Nutzernamen anderer Nutzer. Sie enthalten keine Nachrichten.", + "If you've submitted a bug via GitHub, debug logs can help us track down the problem. Debug logs contain application usage data including your username, the IDs or aliases of the rooms or groups you have visited and the usernames of other users. They do not contain messages.": "Wenn du einen Fehler via GitHub meldest, können Fehlerberichte uns helfen um das Problem zu finden. Sie enthalten Anwendungsdaten wie deinen Nutzernamen, Raum- und Gruppen-IDs und Aliase, die du besucht hast sowie Nutzernamen anderer Nutzer mit denen du schreibst. Sie enthalten keine Nachrichten.", "Submit debug logs": "Fehlerberichte einreichen", "Code": "Code", - "Opens the Developer Tools dialog": "Öffnet die Entwickler-Werkzeuge", + "Opens the Developer Tools dialog": "Entwickler-Werkzeuge öffnen", "Seen by %(displayName)s (%(userName)s) at %(dateTime)s": "Von %(displayName)s (%(userName)s) um %(dateTime)s gesehen", "Unable to join community": "Community konnte nicht betreten werden", "Unable to leave community": "Community konnte nicht verlassen werden", "Changes made to your community name and avatar might not be seen by other users for up to 30 minutes.": "Änderungen am Namen und Bild deiner Community werden evtl. erst nach 30 Minuten von anderen Nutzern gesehen werden.", "Join this community": "Community beitreten", "Leave this community": "Community verlassen", - "You don't currently have any stickerpacks enabled": "Du hast aktuell keine Stickerpacks aktiviert", + "You don't currently have any stickerpacks enabled": "Keine Stickerpakete aktiviert", "Hide Stickers": "Sticker ausblenden", "Show Stickers": "Sticker anzeigen", "Who can join this community?": "Wer kann dieser Community beitreten?", "Everyone": "Jeder", - "Stickerpack": "Stickerpack", + "Stickerpack": "Stickerpaket", "Fetching third party location failed": "Das Abrufen des Drittanbieterstandorts ist fehlgeschlagen", - "Send Account Data": "Benutzerkonto-Daten senden", + "Send Account Data": "Benutzerkontodaten senden", "All notifications are currently disabled for all targets.": "Aktuell sind alle Benachrichtigungen für alle Ziele deaktiviert.", "Uploading report": "Lade Bericht hoch", "Sunday": "Sonntag", @@ -676,16 +676,16 @@ "Changelog": "Änderungsprotokoll", "Waiting for response from server": "Auf Antwort vom Server warten", "Send Custom Event": "Benutzerdefiniertes Event senden", - "Advanced notification settings": "Erweiterte Benachrichtigungs-Einstellungen", - "Failed to send logs: ": "Senden von Logs fehlgeschlagen: ", + "Advanced notification settings": "Erweiterte Benachrichtigungseinstellungen", + "Failed to send logs: ": "Senden von Protokolldateien fehlgeschlagen: ", "Forget": "Entfernen", "You cannot delete this image. (%(code)s)": "Das Bild kann nicht gelöscht werden. (%(code)s)", "Cancel Sending": "Senden abbrechen", "This Room": "In diesem Raum", "Resend": "Erneut senden", "Room not found": "Raum nicht gefunden", - "Messages containing my display name": "Nachrichten, die meinen Anzeigenamen enthalten", - "Messages in one-to-one chats": "Nachrichten in Einzel-Chats", + "Messages containing my display name": "Nachrichten mit meinem Anzeigenamen", + "Messages in one-to-one chats": "Direktnachrichten", "Unavailable": "Nicht verfügbar", "View Decrypted Source": "Entschlüsselten Quellcode ansehen", "Failed to update keywords": "Schlüsselwörter konnten nicht aktualisiert werden", @@ -694,29 +694,29 @@ "Please set a password!": "Bitte setze ein Passwort!", "You have successfully set a password!": "Du hast erfolgreich ein Passwort gesetzt!", "An error occurred whilst saving your email notification preferences.": "Beim Speichern deiner E-Mail-Benachrichtigungseinstellungen ist ein Fehler aufgetreten.", - "Explore Room State": "Raum-Status erkunden", + "Explore Room State": "Raumstatus erkunden", "Source URL": "Quell-URL", "Messages sent by bot": "Nachrichten von Bots", "Filter results": "Ergebnisse filtern", "Members": "Mitglieder", - "No update available.": "Kein Update verfügbar.", + "No update available.": "Keine Aktualisierung verfügbar.", "Noisy": "Laut", "Collecting app version information": "App-Versionsinformationen werden abgerufen", "Keywords": "Schlüsselwörter", - "Enable notifications for this account": "Benachrichtigungen für dieses Benutzerkonto aktivieren", + "Enable notifications for this account": "Benachrichtigungen für dieses Konto", "Invite to this community": "In diese Community einladen", - "Messages containing keywords": "Nachrichten, die Schlüsselwörter enthalten", + "Messages containing keywords": "Nachrichten mit Schlüsselwörtern", "Error saving email notification preferences": "Fehler beim Speichern der E-Mail-Benachrichtigungseinstellungen", "Tuesday": "Dienstag", - "Enter keywords separated by a comma:": "Schlüsselwörter kommagetrennt eingeben:", + "Enter keywords separated by a comma:": "Gib die Schlüsselwörter durch einen Beistrich getrennt ein:", "Forward Message": "Nachricht weiterleiten", "You have successfully set a password and an email address!": "Du hast erfolgreich ein Passwort und eine E-Mail-Adresse gesetzt!", "Remove %(name)s from the directory?": "Soll der Raum %(name)s aus dem Verzeichnis entfernt werden?", "%(brand)s uses many advanced browser features, some of which are not available or experimental in your current browser.": "%(brand)s nutzt zahlreiche fortgeschrittene Browser-Funktionen, die teilweise in deinem aktuell verwendeten Browser noch nicht verfügbar sind oder sich noch im experimentellen Status befinden.", "Developer Tools": "Entwicklerwerkzeuge", - "Preparing to send logs": "Senden von Logs wird vorbereitet", + "Preparing to send logs": "Senden von Protokolldateien wird vorbereitet", "Remember, you can always set an email address in user settings if you change your mind.": "Vergiss nicht, dass du in den Benutzereinstellungen jederzeit eine E-Mail-Adresse setzen kannst, wenn du deine Meinung änderst.", - "Explore Account Data": "Konto-Daten erkunden", + "Explore Account Data": "Kontodaten erkunden", "All messages (noisy)": "Alle Nachrichten (laut)", "Saturday": "Samstag", "I understand the risks and wish to continue": "Ich verstehe die Risiken und möchte fortfahren", @@ -729,37 +729,37 @@ "Enable them now": "Diese jetzt aktivieren", "Toolbox": "Werkzeugkasten", "Collecting logs": "Protokolle werden abgerufen", - "You must specify an event type!": "Du musst einen Event-Typ spezifizieren!", + "You must specify an event type!": "Du musst einen Eventtyp spezifizieren!", "(HTTP status %(httpStatus)s)": "(HTTP-Status %(httpStatus)s)", "Invite to this room": "In diesen Raum einladen", "Wednesday": "Mittwoch", "You cannot delete this message. (%(code)s)": "Diese Nachricht kann nicht gelöscht werden. (%(code)s)", "Quote": "Zitat", - "Send logs": "Logdateien übermitteln", + "Send logs": "Protokolldateien übermitteln", "All messages": "Alle Nachrichten", - "Call invitation": "Anruf-Einladung", - "Downloading update...": "Update wird heruntergeladen...", - "State Key": "Status-Schlüssel", + "Call invitation": "Anrufe", + "Downloading update...": "Aktualisierung wird heruntergeladen...", + "State Key": "Statusschlüssel", "Failed to send custom event.": "Senden des benutzerdefinierten Events fehlgeschlagen.", "What's new?": "Was ist neu?", "Notify me for anything else": "Über alles andere benachrichtigen", - "When I'm invited to a room": "Wenn ich in einen Raum eingeladen werde", - "Can't update user notification settings": "Benachrichtigungs-Einstellungen des Benutzers konnten nicht aktualisiert werden", + "When I'm invited to a room": "Einladungen", + "Can't update user notification settings": "Benachrichtigungseinstellungen des Benutzers konnten nicht aktualisiert werden", "Notify for all other messages/rooms": "Benachrichtigungen für alle anderen Mitteilungen/Räume aktivieren", "Unable to look up room ID from server": "Es ist nicht möglich, die Raum-ID auf dem Server nachzuschlagen", "Couldn't find a matching Matrix room": "Konnte keinen entsprechenden Matrix-Raum finden", "All Rooms": "In allen Räumen", "Thursday": "Donnerstag", "Search…": "Suchen…", - "Logs sent": "Logs gesendet", + "Logs sent": "Protokolldateien gesendet", "Back": "Zurück", "Reply": "Antworten", "Show message in desktop notification": "Nachrichteninhalt in der Desktopbenachrichtigung anzeigen", - "Debug logs contain application usage data including your username, the IDs or aliases of the rooms or groups you have visited and the usernames of other users. They do not contain messages.": "Fehlerberichte enthalten Anwendungsdaten wie deinen Nutzernamen, Raum- und Gruppen-ID's und Aliase die du besucht hast sowie Nutzernamen anderer Nutzer. Sie enthalten keine Nachrichten.", + "Debug logs contain application usage data including your username, the IDs or aliases of the rooms or groups you have visited and the usernames of other users. They do not contain messages.": "Fehlerberichte enthalten Anwendungsdaten wie deinen Nutzernamen, Raum- und Gruppen-IDs und Aliase die du besucht hast sowie Nutzernamen anderer Nutzer. Sie enthalten keine Nachrichten.", "Unhide Preview": "Vorschau wieder anzeigen", "Unable to join network": "Es ist nicht möglich, dem Netzwerk beizutreten", "Sorry, your browser is not able to run %(brand)s.": "Es tut uns leid, aber dein Browser kann %(brand)s nicht ausführen.", - "Messages in group chats": "Nachrichten in Gruppenchats", + "Messages in group chats": "Gruppenchats", "Yesterday": "Gestern", "Error encountered (%(errorDetail)s).": "Es ist ein Fehler aufgetreten (%(errorDetail)s).", "Low Priority": "Niedrige Priorität", @@ -769,19 +769,19 @@ "%(brand)s does not know how to join a room on this network": "%(brand)s weiß nicht, wie es einem Raum auf diesem Netzwerk beitreten soll", "Mentions only": "Nur, wenn du erwähnt wirst", "You can now return to your account after signing out, and sign in on other devices.": "Du kannst nun zu deinem Benutzerkonto zurückkehren, nachdem du dich abgemeldet hast. Anschließend kannst du dich an anderen Geräten anmelden.", - "Enable email notifications": "E-Mail-Benachrichtigungen aktivieren", - "Event Type": "Event-Typ", + "Enable email notifications": "Benachrichtigungen per E-Mail", + "Event Type": "Eventtyp", "Download this file": "Datei herunterladen", "Pin Message": "Nachricht anheften", "Failed to change settings": "Einstellungen konnten nicht geändert werden", "View Community": "Community ansehen", "Event sent!": "Event gesendet!", - "View Source": "Quellcode ansehen", - "Event Content": "Event-Inhalt", + "View Source": "Rohdaten anzeigen", + "Event Content": "Eventinhalt", "Thank you!": "Danke!", "Uploaded on %(date)s by %(user)s": "Hochgeladen: %(date)s von %(user)s", "With your current browser, the look and feel of the application may be completely incorrect, and some or all features may not function. If you want to try it anyway you can continue, but you are on your own in terms of any issues you may encounter!": "In deinem aktuell verwendeten Browser können Aussehen und Handhabung der Anwendung unter Umständen noch komplett fehlerhaft sein, so dass einige bzw. im Extremfall alle Funktionen nicht zur Verfügung stehen. Du kannst es trotzdem versuchen und fortfahren, bist dabei aber bezüglich aller auftretenden Probleme auf dich allein gestellt!", - "Checking for an update...": "Nach Updates suchen...", + "Checking for an update...": "Nach Aktualisierungen suchen...", "Missing roomId.": "Fehlende Raum-ID.", "Every page you use in the app": "Jede Seite, die du in der App benutzt", "e.g. ": "z. B. ", @@ -795,16 +795,16 @@ "We encountered an error trying to restore your previous session.": "Wir haben ein Problem beim Wiederherstellen deiner vorherigen Sitzung festgestellt.", "Clearing your browser's storage may fix the problem, but will sign you out and cause any encrypted chat history to become unreadable.": "Den Browser-Speicher zu löschen kann das Problem lösen, wird dich aber abmelden und verschlüsselte Chats unlesbar machen.", "Collapse Reply Thread": "Antwort-Thread zusammenklappen", - "Enable widget screenshots on supported widgets": "Widget-Screenshots bei unterstützten Widgets aktivieren", + "Enable widget screenshots on supported widgets": "Bildschirmfotos für unterstützte Widgets", "Send analytics data": "Analysedaten senden", "e.g. %(exampleValue)s": "z.B. %(exampleValue)s", "Muted Users": "Stummgeschaltete Benutzer", - "This will make your account permanently unusable. You will not be able to log in, and no one will be able to re-register the same user ID. This will cause your account to leave all rooms it is participating in, and it will remove your account details from your identity server. This action is irreversible.": "Dies wird deinen Konto permanent unbenutzbar machen. Du wirst nicht in der Lage sein, dich anzumelden und keiner wird dieselbe Benutzer-ID erneut registrieren können. Alle Räume, in denen der Account ist, werden verlassen und deine Account-Daten werden vom Identitätsserver gelöscht. Diese Aktion ist unumkehrbar.", + "This will make your account permanently unusable. You will not be able to log in, and no one will be able to re-register the same user ID. This will cause your account to leave all rooms it is participating in, and it will remove your account details from your identity server. This action is irreversible.": "Dies wird deinen Konto permanent unbenutzbar machen. Du wirst nicht in der Lage sein, dich anzumelden und keiner wird dieselbe Benutzer-ID erneut registrieren können. Alle Räume, in denen dein Konto ist, werden verlassen und deine Kontodaten werden vom Identitätsserver gelöscht. Diese Aktion ist unumkehrbar.", "Deactivating your account does not by default cause us to forget messages you have sent. If you would like us to forget your messages, please tick the box below.": "Standardmäßig werden die von dir gesendeten Nachrichten beim Deaktiveren nicht gelöscht. Wenn du dies von uns möchtest, aktivere das Auswahlfeld unten.", "Message visibility in Matrix is similar to email. Our forgetting your messages means that messages you have sent will not be shared with any new or unregistered users, but registered users who already have access to these messages will still have access to their copy.": "Die Sichtbarkeit der Nachrichten in Matrix ist vergleichbar mit E-Mails: Wenn wir deine Nachrichten vergessen heißt das, dass diese nicht mit neuen oder nicht registrierten Nutzern teilen werden, aber registrierte Nutzer, die bereits zugriff haben, werden Zugriff auf ihre Kopie behalten.", "Please forget all messages I have sent when my account is deactivated (Warning: this will cause future users to see an incomplete view of conversations)": "Bitte vergesst alle Nachrichten, die ich gesendet habe, wenn mein Konto deaktiviert wird. (Warnung: Zukünftige Nutzer werden eine unvollständige Konversation sehen)", "To continue, please enter your password:": "Um fortzufahren, bitte Passwort eingeben:", - "Can't leave Server Notices room": "Du kannst den Raum für Server-Notizen nicht verlassen", + "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 Heimserver 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.", @@ -821,8 +821,8 @@ "Share Message": "Nachricht teilen", "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 Link-Vorschau standardmäßig deaktiviert, damit dein Heimserver (der die Vorschau erzeugt) keine Informationen über Links in diesem Raum bekommt.", - "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.": "Wenn jemand eine URL sendet, kann die Vorschau Informationen wie Titel, Beschreibung oder ein Vorschaubild enthalten.", + "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.", + "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.", "The email field must not be blank.": "Das E-Mail-Feld darf nicht leer sein.", "The phone number field must not be blank.": "Das Telefonnummern-Feld darf nicht leer sein.", "The password field must not be blank.": "Das Passwort-Feld darf nicht leer sein.", @@ -838,10 +838,10 @@ "Failed to remove widget": "Widget konnte nicht entfernt werden", "An error ocurred whilst trying to remove the widget from the room": "Ein Fehler trat auf während versucht wurde, das Widget aus diesem Raum zu entfernen", "System Alerts": "Systembenachrichtigung", - "Only room administrators will see this warning": "Nur Raum-Administratoren werden diese Nachricht sehen", - "Please contact your service administrator to continue using the service.": "Bitte kontaktiere deinen Systemadministrator um diesen Dienst weiter zu nutzen.", - "This homeserver has hit its Monthly Active User limit.": "Dieser Heimserver hat sein Limit an monatlich aktiven Nutzern erreicht.", - "This homeserver has exceeded one of its resource limits.": "Dieser Heimserver hat einen seiner Ressourcen-Limits überschritten.", + "Only room administrators will see this warning": "Nur Raumadministratoren werden diese Nachricht sehen", + "Please contact your service administrator to continue using the service.": "Bitte kontaktiere deinen Systemadministrator, um diesen Dienst weiter zu nutzen.", + "This homeserver has hit its Monthly Active User limit.": "Dieser Heimserver hat seinen Grenzwert an monatlich aktiven Nutzern erreicht.", + "This homeserver has exceeded one of its resource limits.": "Dieser Heimserver hat einen seiner Ressourcengrenzwert überschritten.", "Upgrade Room Version": "Raum-Version aufrüsten", "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", @@ -850,8 +850,8 @@ "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.", "Please contact your service administrator to continue using this service.": "Bitte kontaktiere deinen Systemadministrator um diesen Dienst weiter zu nutzen.", - "Sorry, your homeserver is too old to participate in this room.": "Sorry, dein Homeserver ist zu alt, um an diesem Raum teilzunehmen.", - "Please contact your homeserver administrator.": "Bitte setze dich mit der Administration deines Homeservers in Verbindung.", + "Sorry, your homeserver is too old to participate in this room.": "Leider ist dein Heimserver zu alt, um an diesem Raum teilzunehmen.", + "Please contact your homeserver administrator.": "Bitte setze dich mit der Administration deines Heimservers in Verbindung.", "Legal": "Rechtliches", "This room has been replaced and is no longer active.": "Dieser Raum wurde ersetzt und ist nicht länger aktiv.", "The conversation continues here.": "Die Konversation wird hier fortgesetzt.", @@ -860,11 +860,11 @@ "Failed to upgrade room": "Konnte Raum nicht aufrüsten", "The room upgrade could not be completed": "Die Raum-Aufrüstung konnte nicht fertiggestellt werden", "Upgrade this room to version %(version)s": "Diesen Raum zur Version %(version)s aufrüsten", - "Forces the current outbound group session in an encrypted room to be discarded": "Erzwingt, dass die aktuell ausgehende Gruppen-Sitzung in einem verschlüsseltem Raum verworfen wird", + "Forces the current outbound group session in an encrypted room to be discarded": "Erzwingt, dass die aktuell ausgehende Gruppensitzung in einem verschlüsseltem Raum verworfen wird", "Unable to connect to Homeserver. Retrying...": "Verbindung mit Heimserver nicht möglich. Versuche erneut...", "%(senderName)s set the main address for this room to %(address)s.": "%(senderName)s hat die Hauptadresse zu diesem Raum auf %(address)s gesetzt.", - "%(senderName)s removed the main address for this room.": "%(senderName)s entfernte die Hauptadresse von diesem Raum.", - "Before submitting logs, you must create a GitHub issue to describe your problem.": "Bevor du Log-Dateien übermittelst, musst du ein GitHub-Issue erstellen um dein Problem zu beschreiben.", + "%(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!", "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.", @@ -880,7 +880,7 @@ "Delete Backup": "Sicherung löschen", "Backup version: ": "Sicherungsversion: ", "Algorithm: ": "Algorithmus: ", - "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 Chat-Verlauf verloren geht, musst du deine Raum-Schlüssel exportieren, bevor du dich abmeldest. Dazu musst du auf die neuere Version von %(brand)s zurückgehen", + "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 Chatverlauf 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", "Next": "Weiter", @@ -903,10 +903,10 @@ "Avoid repeated words and characters": "Vermeide wiederholte Worte und Zeichen", "Avoid sequences": "Vermeide Sätze", "Avoid recent years": "Vermeide die letzten Jahre", - "Avoid years that are associated with you": "Vermeide Jahre, die mit dir zusammenhängen", + "Avoid years that are associated with you": "Vermeide Jahreszahlen, die mit dir zu tun haben", "Avoid dates and years that are associated with you": "Vermeide Daten und Jahre, die mit dir in Verbindung stehen", "Capitalization doesn't help very much": "Großschreibung hilft nicht viel", - "All-uppercase is almost as easy to guess as all-lowercase": "Alles groß zu geschrieben ist fast genauso schnell zu raten, wie alles klein zu schreiben", + "All-uppercase is almost as easy to guess as all-lowercase": "Alles groß zu schreiben ist genauso einfach zu erraten, wie alles klein zu schreiben", "Reversed words aren't much harder to guess": "Umgedrehte Worte sind nicht schwerer zu erraten", "Predictable substitutions like '@' instead of 'a' don't help very much": "Vorhersagbare Ersetzungen wie '@' anstelle von 'a' helfen nicht viel", "Add another word or two. Uncommon words are better.": "Füge ein weiteres Wort - oder mehr - hinzu. Ungewöhnliche Worte sind besser.", @@ -916,31 +916,31 @@ "Recent years are easy to guess": "Kürzlich vergangene Jahre sind einfach zu raten", "Dates are often easy to guess": "Ein Datum ist leicht zu erraten", "This is a top-10 common password": "Dies ist unter den Top 10 der häufigsten Passwörter", - "This is a top-100 common password": "Dies ist unter den Top 100 der üblichen Passwörter", + "This is a top-100 common password": "Dies ist unter den Top 100 der häufigsten Passwörter", "This is a very common password": "Dies ist ein recht bekanntes Passwort", "This is similar to a commonly used password": "Dies ist ähnlich zu einem oft genutzten Passwort", - "A word by itself is easy to guess": "Ein Wort alleine ist einfach zu erraten", + "A word by itself is easy to guess": "Ein einzelnes Wort ist einfach zu erraten", "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 kannst keine Personen in diesen Raum einladen.", + "You do not have permission to invite people to this room.": "Du hast keine Berechtigung, Personen in diesen Raum einzuladen.", "User %(user_id)s does not exist": "Benutzer %(user_id)s existiert nicht", "Unknown server error": "Unbekannter Serverfehler", "Failed to invite users to the room:": "Konnte Benutzer nicht in den Raum einladen:", "Short keyboard patterns are easy to guess": "Kurze Tastaturmuster sind einfach zu erraten", "Show a reminder to enable Secure Message Recovery in encrypted rooms": "Zeige eine Erinnerung um die Sichere Nachrichten-Wiederherstellung in verschlüsselten Räumen zu aktivieren", - "Messages containing @room": "Nachrichten, die \"@room\" enthalten", - "Encrypted messages in one-to-one chats": "Verschlüsselte Nachrichten in 1:1 Chats", - "Encrypted messages in group chats": "Verschlüsselte Nachrichten in Gruppenchats", + "Messages containing @room": "Nachrichten mit \"@room\"", + "Encrypted messages in one-to-one chats": "Verschlüsselte Direktnachrichten", + "Encrypted messages in group chats": "Verschlüsselte Gruppenchats", "Use a longer keyboard pattern with more turns": "Nutze ein längeres Tastaturmuster mit mehr Abwechslung", "Straight rows of keys are easy to guess": "Gerade Reihen von Tasten sind einfach zu erraten", - "Custom user status messages": "Angepasste Nutzerstatus-Nachrichten", + "Custom user status messages": "Angepasste Nutzerstatusnachrichten", "Unable to load key backup status": "Konnte Status der Schlüsselsicherung nicht laden", "Don't ask again": "Nicht erneut fragen", "Set up": "Einrichten", "Please review and accept all of the homeserver's policies": "Bitte prüfe und akzeptiere alle Richtlinien des Heimservers", "Failed to load group members": "Gruppenmitglieder konnten nicht geladen werden", "That doesn't look like a valid email address": "Sieht nicht nach einer gültigen E-Mail-Adresse aus", - "Unable to load commit detail: %(msg)s": "Konnte Commit-Details nicht laden: %(msg)s", + "Unable to load commit detail: %(msg)s": "Konnte Übermittlungsdetails nicht laden: %(msg)s", "Checking...": "Überprüfe...", "Unable to load backup status": "Konnte Sicherungsstatus nicht laden", "Failed to decrypt %(failedCount)s sessions!": "Konnte %(failedCount)s Sitzungen nicht entschlüsseln!", @@ -960,33 +960,33 @@ "If you didn't set the new recovery method, an attacker may be trying to access your account. Change your account password and set a new recovery method immediately in Settings.": "Wenn du die neue Wiederherstellungsmethode nicht festgelegt hast, versucht ein Angreifer möglicherweise, auf dein Konto zuzugreifen. Ändere dein Kontopasswort und lege sofort eine neue Wiederherstellungsmethode in den Einstellungen fest.", "Set up Secure Messages": "Richte sichere Nachrichten ein", "Go to Settings": "Gehe zu Einstellungen", - "Sign in with single sign-on": "Melde dich mit „Single Sign-On“ an", + "Sign in with single sign-on": "Einmalanmeldung nutzen", "Unrecognised address": "Nicht erkannte Adresse", "User %(user_id)s may or may not exist": "Unklar, ob Benutzer %(user_id)s existiert", - "Prompt before sending invites to potentially invalid matrix IDs": "Nachfragen, bevor Einladungen zu möglichen ungültigen Matrix-IDs gesendet werden", + "Prompt before sending invites to potentially invalid matrix IDs": "Warnen, bevor du Einladungen zu ungültigen Matrix-IDs sendest", "The following users may not exist": "Eventuell existieren folgende Benutzer nicht", - "Unable to find profiles for the Matrix IDs listed below - would you like to invite them anyway?": "Profile für die unteren Matrix IDs wurden nicht gefunden - willst Du sie trotzdem einladen?", + "Unable to find profiles for the Matrix IDs listed below - would you like to invite them anyway?": "Profile für die unteren Matrix-IDs wurden nicht gefunden - willst Du sie trotzdem einladen?", "Invite anyway and never warn me again": "Trotzdem einladen und mich nicht mehr warnen", "Invite anyway": "Trotzdem einladen", "Whether or not you're logged in (we don't record your username)": "Ob du angemeldet bist oder nicht (wir speichern deinen Benutzernamen nicht)", "Upgrades a room to a new version": "Aktualisiert den Raum auf eine neue Version", "Sets the room name": "Setze einen Raumnamen", - "%(senderDisplayName)s upgraded this room.": "%(senderDisplayName)s aktualisierte diesen Raum.", + "%(senderDisplayName)s upgraded this room.": "%(senderDisplayName)s hat diesen Raum aktualisiert.", "%(displayName)s is typing …": "%(displayName)s tippt…", "%(names)s and %(count)s others are typing …|other": "%(names)s und %(count)s andere tippen…", "%(names)s and %(count)s others are typing …|one": "%(names)s und eine weitere Person tippen…", "%(names)s and %(lastPerson)s are typing …": "%(names)s und %(lastPerson)s tippen…", - "Render simple counters in room header": "Einfache Zähler in Raum-Kopfzeile anzeigen", - "Enable Emoji suggestions while typing": "Emoji-Vorschläge während der Eingabe aktivieren", - "Show a placeholder for removed messages": "Zeigt einen Platzhalter für gelöschte Nachrichten an", - "Show join/leave messages (invites/kicks/bans unaffected)": "Nachrichten beim Betreten oder Verlassen von Benutzern anzeigen (betrifft nicht Einladungen/Kicks/Bans)", - "Show avatar changes": "Avatar-Änderungen anzeigen", - "Show display name changes": "Anzeigenamen-Änderungen anzeigen", - "Send typing notifications": "Tipp-Benachrichtigungen senden", - "Show avatars in user and room mentions": "Avatare in Benutzer- und Raumerwähnungen anzeigen", - "Enable big emoji in chat": "Aktiviere große Emoji im Chat", - "Enable Community Filter Panel": "Community-Filter-Panel aktivieren", - "Messages containing my username": "Nachrichten, die meinen Benutzernamen enthalten", + "Render simple counters in room header": "Einfache Zähler in Raumkopfzeile anzeigen", + "Enable Emoji suggestions while typing": "Emojivorschläge während Eingabe", + "Show a placeholder for removed messages": "Platzhalter für gelöschte Nachrichten", + "Show join/leave messages (invites/kicks/bans unaffected)": "Betreten oder Verlassen von Benutzern (ausgen. Einladungen/Rauswürfe/Banne)", + "Show avatar changes": "Avataränderungen anzeigen", + "Show display name changes": "Änderungen von Anzeigenamen", + "Send typing notifications": "Tippbenachrichtigungen senden", + "Show avatars in user and room mentions": "Avatare in Benutzer- und Raumerwähnungen", + "Enable big emoji in chat": "Große Emojis im Chat anzeigen", + "Enable Community Filter Panel": "Community-Filtertafel", + "Messages containing my username": "Nachrichten mit meinem Benutzernamen", "The other party cancelled the verification.": "Die Gegenstelle hat die Überprüfung abgebrochen.", "Verified!": "Verifiziert!", "You've successfully verified this user.": "Du hast diesen Benutzer erfolgreich verifiziert.", @@ -1011,24 +1011,24 @@ "Room version:": "Raumversion:", "Developer options": "Entwickleroptionen", "General": "Allgemein", - "Set a new account password...": "Neues Benutzerkonto-Passwort festlegen...", + "Set a new account password...": "Neues Passwort festlegen...", "Email addresses": "E-Mail-Adressen", "Phone numbers": "Telefonnummern", "Language and region": "Sprache und Region", "Theme": "Design", "Account management": "Benutzerkontenverwaltung", - "For help with using %(brand)s, click here.": "Um Hilfe zur Benutzung von %(brand)s zu erhalten, klicke hier.", + "For help with using %(brand)s, click here.": "Um Hilfe zur Benutzung von %(brand)s zu erhalten, klicke hier.", "For help with using %(brand)s, click here or start a chat with our bot using the button below.": "Um Hilfe zur Benutzung von %(brand)s zu erhalten, klicke hier oder beginne einen Chat mit unserem Bot. Klicke dazu auf den unteren Knopf.", "Chat with %(brand)s Bot": "Chatte mit dem %(brand)s-Bot", - "Help & About": "Hilfe & Über", + "Help & About": "Hilfe und Über", "Bug reporting": "Fehler melden", "FAQ": "Häufige Fragen", "Versions": "Versionen", - "Room Addresses": "Raum-Adressen", - "Deactivating your account is a permanent action - be careful!": "Die Deaktivierung deines Kontos ist nicht widerruflich - sei vorsichtig!", - "Preferences": "Einstellungen", + "Room Addresses": "Raumadressen", + "Deactivating your account is a permanent action - be careful!": "Die Deaktivierung deines Kontos ist unwiderruflich - sei vorsichtig!", + "Preferences": "Chats", "Room list": "Raumliste", - "The file '%(fileName)s' exceeds this homeserver's size limit for uploads": "Die Datei '%(fileName)s' überschreitet die maximale Größe für Uploads auf diesem Heimserver", + "The file '%(fileName)s' exceeds this homeserver's size limit for uploads": "Die Datei '%(fileName)s' überschreitet die maximale Uploadgröße deines Heimservers", "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.", @@ -1036,7 +1036,7 @@ "%(senderDisplayName)s has allowed guests to join the room.": "%(senderDisplayName)s erlaubte Gäste diesem Raum beizutreten.", "%(senderDisplayName)s has prevented guests from joining the room.": "%(senderDisplayName)s hat Gästen verboten, diesem Raum beizutreten.", "%(senderDisplayName)s changed guest access to %(rule)s": "%(senderDisplayName)s änderte den Gastzugriff auf '%(rule)s'", - "Group & filter rooms by custom tags (refresh to apply changes)": "Gruppiere & filtere Räume nach eigenen Tags (neu laden um Änderungen zu übernehmen)", + "Group & filter rooms by custom tags (refresh to apply changes)": "[Veraltet] Gruppiere und filtere Räume nach eigenen Tags (neu laden um Änderungen zu übernehmen)", "Unable to find a supported verification method.": "Konnte keine unterstützte Verifikationsmethode finden.", "Dog": "Hund", "Cat": "Katze", @@ -1101,14 +1101,14 @@ "Folder": "Ordner", "Pin": "Anheften", "Timeline": "Chatverlauf", - "Autocomplete delay (ms)": "Verzögerung zur Autovervollständigung (ms)", - "Roles & Permissions": "Rollen & Berechtigungen", + "Autocomplete delay (ms)": "Verzögerung vor Autovervollständigung (ms)", + "Roles & Permissions": "Rollen und Berechtigungen", "Changes to who can read history will only apply to future messages in this room. The visibility of existing history will be unchanged.": "Änderungen an der Sichtbarkeit des Chatverlaufs gelten nur für zukünftige Nachrichten. Die Sichtbarkeit des existierenden Verlaufs bleibt unverändert.", - "Security & Privacy": "Sicherheit & Datenschutz", + "Security & Privacy": "Sicherheit", "Encryption": "Verschlüsselung", - "Once enabled, encryption cannot be disabled.": "Sobald aktiviert, kann die Verschlüsselung nicht mehr deaktiviert werden.", + "Once enabled, encryption cannot be disabled.": "Sobald du die Verschlüsselung aktivierst, kann du sie nicht mehr deaktivieren.", "Encrypted": "Verschlüsselt", - "Ignored users": "Ignorierte Benutzer", + "Ignored users": "Blockierte Benutzer", "Key backup": "Schlüsselsicherung", "Gets or sets the room topic": "Raumthema anzeigen oder ändern", "Verify this user by confirming the following emoji appear on their screen.": "Verifiziere diesen Nutzer, indem du bestätigst, dass folgende Emojis auf dessen Bildschirm erscheinen.", @@ -1117,30 +1117,30 @@ "Main address": "Primäre Adresse", "Room avatar": "Raumbild", "Room Name": "Raumname", - "Room Topic": "Raum-Thema", + "Room Topic": "Raumthema", "Join": "Beitreten", "Waiting for partner to confirm...": "Warte auf Bestätigung des Gesprächspartners...", "Incoming Verification Request": "Eingehende Verifikationsanfrage", - "Allow Peer-to-Peer for 1:1 calls": "Peer-to-Peer-Verbindungen für 1:1-Anrufe erlauben", - "Are you sure? You will lose your encrypted messages if your keys are not backed up properly.": "Bist du sicher? Du wirst deine verschlüsselten Nachrichten verlieren, wenn deine Schlüssel nicht gut gesichert sind.", + "Allow Peer-to-Peer for 1:1 calls": "Peer-to-Peer-Verbindungen für Direktanrufe erlauben", + "Are you sure? You will lose your encrypted messages if your keys are not backed up properly.": "Bist du sicher? Du wirst alle deine verschlüsselten Nachrichten verlieren, wenn deine Schlüssel nicht gut gesichert sind.", "Encrypted messages are secured with end-to-end encryption. Only you and the recipient(s) have the keys to read these messages.": "Verschlüsselte Nachrichten sind mit Ende-zu-Ende-Verschlüsselung gesichert. Nur du und der/die Empfänger haben die Schlüssel um diese Nachrichten zu lesen.", "Restore from Backup": "Von Sicherung wiederherstellen", - "Back up your keys before signing out to avoid losing them.": "Sichere deine Schlüssel bevor du dich abmeldest, damit du sie nicht verlierst.", + "Back up your keys before signing out to avoid losing them.": "Um deine Schlüssel nicht zu verlieren, musst du sie vor der Abmeldung sichern.", "Start using Key Backup": "Beginne Schlüsselsicherung zu nutzen", "Credits": "Danksagungen", "Starting backup...": "Starte Sicherung...", "Success!": "Erfolgreich!", "Your keys are being backed up (the first backup could take a few minutes).": "Deine Schlüssel werden gesichert (Das erste Backup könnte ein paar Minuten in Anspruch nehmen).", - "Voice & Video": "Sprach- & Videoanruf", + "Voice & Video": "Anrufe", "Never lose encrypted messages": "Verliere niemals verschlüsselte Nachrichten", "Messages in this room are secured with end-to-end encryption. Only you and the recipient(s) have the keys to read these messages.": "Nachrichten in diesem Raum sind mit einer Ende-zu-Ende-Verschlüsselung gesichert. Nur du und dein(e) Gesprächspartner haben die Schlüssel, um die Nachrichten zu lesen.", "Securely back up your keys to avoid losing them. Learn more.": "Speichere deine Schlüssel an einem sicheren Ort, um diese nicht zu verlieren. Lerne wie.", "Not now": "Später", "Don't ask me again": "Nicht mehr fragen", - "Go back": "Gehe zurück", + "Go back": "Zurück", "Are you sure you want to sign out?": "Bist du sicher, dass du dich abmelden möchtest?", "Manually export keys": "Manueller Schlüssel Export", - "Composer": "Nachrichteneingabefeld", + "Composer": "Nachrichteneingabe", "Verify this user to mark them as trusted. Trusting users gives you extra peace of mind when using end-to-end encrypted messages.": "Überprüfe diesen Benutzer, um ihn als vertrauenswürdig zu kennzeichnen. Benutzern zu vertrauen gibt dir zusätzliche Sicherheit bei der Verwendung von Ende-zu-Ende-verschlüsselten Nachrichten.", "I don't want my encrypted messages": "Ich möchte meine verschlüsselten Nachrichten nicht", "You'll lose access to your encrypted messages": "Du wirst den Zugang zu deinen verschlüsselten Nachrichten verlieren", @@ -1149,7 +1149,7 @@ "Report bugs & give feedback": "Melde Fehler & gib Rückmeldungen", "Update status": "Aktualisiere Status", "Set status": "Setze Status", - "Hide": "Verberge", + "Hide": "Verbergen", "This homeserver would like to make sure you are not a robot.": "Dieser Heimserver möchte sicherstellen, dass du kein Roboter bist.", "Server Name": "Servername", "Your Modular server": "Dein Modular-Server", @@ -1190,28 +1190,28 @@ "Bulk options": "Sammeloptionen", "Join millions for free on the largest public server": "Schließe dich kostenlos auf dem größten öffentlichen Server Millionen von Menschen an", "Prepends ¯\\_(ツ)_/¯ to a plain-text message": "Stellt ¯\\_(ツ)_/¯ einer Klartextnachricht voran", - "Changes your display nickname in the current room only": "Ändert den Anzeigenamen ausschließlich für den aktuellen Raum", + "Changes your display nickname in the current room only": "Ändert deinen Anzeigenamen nur für den aktuellen Raum", "%(senderDisplayName)s enabled flair for %(groups)s in this room.": "%(senderDisplayName)s hat Abzeichen der Gruppen %(groups)s für diesen Raum aktiviert.", "%(senderDisplayName)s disabled flair for %(groups)s in this room.": "%(senderDisplayName)s hat Abzeichen der Gruppen %(groups)s in diesem Raum deaktiviert.", "%(senderDisplayName)s enabled flair for %(newGroups)s and disabled flair for %(oldGroups)s in this room.": "%(senderDisplayName)s hat Abzeichen von %(newGroups)s aktiviert und von %(oldGroups)s deaktiviert.", "User %(userId)s is already in the room": "Nutzer %(userId)s ist bereits im Raum", - "The user must be unbanned before they can be invited.": "Nutzer müssen entbannt werden, bevor sie eingeladen werden können.", - "Show read receipts sent by other users": "Zeige Lesebestätigungen anderer Benutzer", - "Scissors": "Scheren", + "The user must be unbanned before they can be invited.": "Verbannte Nutzer können nicht eingeladen werden.", + "Show read receipts sent by other users": "Lesebestätigungen anzeigen", + "Scissors": "Schere", "Upgrade to your own domain": "Upgrade zu deiner eigenen Domain", "Accept all %(invitedRooms)s invites": "Akzeptiere alle %(invitedRooms)s Einladungen", "Change room avatar": "Ändere Raumbild", "Change room name": "Ändere Raumname", "Change main address for the room": "Ändere Hauptadresse für den Raum", - "Change history visibility": "Ändere Sichtbarkeit der Historie", + "Change history visibility": "Sichtbarkeit des Verlaufs ändern", "Change permissions": "Ändere Berechtigungen", - "Change topic": "Ändere das Thema", + "Change topic": "Thema ändern", "Modify widgets": "Widgets bearbeiten", - "Default role": "Standard Rolle", + "Default role": "Standard-Rolle", "Send messages": "Nachrichten senden", "Invite users": "Benutzer einladen", "Change settings": "Einstellungen ändern", - "Kick users": "Benutzer kicken", + "Kick users": "Benutzer rauswerfen", "Ban users": "Benutzer verbannen", "Remove messages": "Nachrichten löschen", "Notify everyone": "Jeden benachrichtigen", @@ -1220,8 +1220,8 @@ "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.", "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 es vom Server verboten ist oder ein temporäres Problem auftrat.", - "Error updating flair": "Abzeichen-Aktualisierung fehlgeschlagen", + "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 Raumhauptadresse. Es kann sein, dass es vom Server verboten ist oder ein temporäres Problem auftrat.", + "Error updating flair": "Abzeichenaktualisierung fehlgeschlagen", "There was an error updating the flair for this room. The server may not allow it or a temporary error occurred.": "Es gab ein Problem beim Aktualisieren des Abzeichens für diesen Raum. Es kann sein, dass der Server es nicht erlaubt oder ein temporäres Problem auftrat.", "Power level": "Berechtigungsstufe", "Room Settings - %(roomName)s": "Raumeinstellungen - %(roomName)s", @@ -1236,30 +1236,30 @@ "Name or Matrix ID": "Name oder Matrix-ID", "Your %(brand)s is misconfigured": "Dein %(brand)s ist falsch konfiguriert", "You cannot modify widgets in this room.": "Du darfst in diesem Raum keine Widgets verändern.", - "Whether or not you're using the 'breadcrumbs' feature (avatars above the room list)": "Ob du die \"Breadcrumbs\"-Funktion nutzt oder nicht (Avatare oberhalb der Raumliste)", + "Whether or not you're using the 'breadcrumbs' feature (avatars above the room list)": "Ob du die Liste der kürzlich besuchten Räume oberhalb der Raumliste nutzt", "The server does not support the room version specified.": "Der Server unterstützt die angegebene Raumversion nicht.", - "Warning: Upgrading a room will not automatically migrate room members to the new version of the room. We'll post a link to the new room in the old version of the room - room members will have to click this link to join the new room.": "Achtung: Ein Raum-Upgrade wird die Mitglieder des Raumes nicht automatisch auf die neue Version migrieren. Wir werden in der alten Raumversion einen Link zum neuen Raum posten - Raum-Mitglieder müssen dann auf diesen Link klicken um dem neuen Raum beizutreten.", + "Warning: Upgrading a room will not automatically migrate room members to the new version of the room. We'll post a link to the new room in the old version of the room - room members will have to click this link to join the new room.": "Achtung: Ein Raum-Upgrade wird die Mitglieder des Raumes nicht automatisch auf die neue Version migrieren. Wir werden in der alten Raumversion einen Link zum neuen Raum posten - Raummitglieder müssen dann auf diesen Link klicken um dem neuen Raum beizutreten.", "Replying With Files": "Mit Dateien antworten", - "At this time it is not possible to reply with a file. Would you like to upload this file without replying?": "Momentan ist es nicht möglich mit einer Datei zu antworten. Möchtest Du die Datei hochladen ohne zu antworten?", + "At this time it is not possible to reply with a file. Would you like to upload this file without replying?": "Momentan ist es nicht möglich mit einer Datei zu antworten. Möchtest Du die Datei hochladen, ohne zu antworten?", "The file '%(fileName)s' failed to upload.": "Die Datei \"%(fileName)s\" konnte nicht hochgeladen werden.", "Changes your avatar in this current room only": "Ändert deinen Avatar für diesen Raum", "Unbans user with given ID": "Entbannt den Benutzer mit der angegebenen ID", "Sends the given message coloured as a rainbow": "Sendet die Nachricht in Regenbogenfarben", - "Adds a custom widget by URL to the room": "Fügt ein Benutzer-Widget über eine URL zum Raum hinzu", - "Please supply a https:// or http:// widget URL": "Bitte gib eine https:// oder http:// Widget-URL an", + "Adds a custom widget by URL to the room": "Fügt ein Benutzerwidget über eine URL zum Raum hinzu", + "Please supply a https:// or http:// widget URL": "Bitte gib eine mit https:// oder http:// beginnende Widget-URL an", "Sends the given emote coloured as a rainbow": "Zeigt Aktionen in Regenbogenfarben", "%(senderName)s made no change.": "%(senderName)s hat keine Änderung vorgenommen.", - "%(senderName)s revoked the invitation for %(targetDisplayName)s to join the room.": "%(senderName)s hat die Einladung zum Raumbeitritt für %(targetDisplayName)s zurückgezogen.", + "%(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": "Der Heimserver ist 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 Server-Administrator", - "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ätsserver-Konfiguration aufgetreten", + "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", + "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ätsserverkonfiguration aufgetreten", "Cannot reach identity server": "Der Identitätsserver ist 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, aber manche Funktionen werden erst wieder verfügbar sein, wenn der Identitätsserver wieder online ist. Wenn diese Warnmeldung weiterhin angezeigt wird, ü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, aber manche Funktionen werden nicht verfügbar sein, bis der Identitätsserver wieder online ist. Wenn du diese Warnmeldung weiterhin siehst, ü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 einloggen, aber manche Funktionen werden nicht verfügbar sein bis der Identitätsserver wieder online ist. Wenn du diese Warnmeldung weiterhin siehst, überprüfe deine Konfiguration oder kontaktiere deinen Server-Administrator.", + "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, aber manche Funktionen werden erst wieder verfügbar sein, wenn der Identitätsserver wieder online ist. Wenn diese Warnmeldung weiterhin angezeigt wird, ü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, aber manche Funktionen werden nicht verfügbar sein, bis der Identitätsserver wieder online ist. Wenn du diese Warnmeldung weiterhin siehst, überprüfe deine Konfiguration oder kontaktiere die Serveradministrator.", + "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, aber manche Funktionen werden nicht verfügbar sein bis der Identitätsserver wieder erreichbar ist. Wenn du diese Warnmeldung weiterhin siehst, überprüfe deine Konfiguration oder kontaktiere deinen Serveradministrator.", "No homeserver URL provided": "Keine Heimserver-URL angegeben", - "Unexpected error resolving homeserver configuration": "Ein unerwarteter Fehler ist beim Laden der Heimserver-Konfiguration aufgetreten", + "Unexpected error resolving homeserver configuration": "Ein unerwarteter Fehler ist beim Laden der Heimserverkonfiguration aufgetreten", "The user's homeserver does not support the version of the room.": "Die Raumversion wird vom Heimserver des Benutzers nicht unterstützt.", "Show hidden events in timeline": "Zeige versteckte Ereignisse in der Chronik", "Low bandwidth mode": "Modus für niedrige Bandbreite", @@ -1274,7 +1274,7 @@ " invited you": " hat dich eingeladen", "edited": "bearbeitet", "Edit message": "Nachricht bearbeiten", - "GitHub issue": "GitHub-Issue", + "GitHub issue": "\"Issue\" auf Github", "Upload files": "Dateien hochladen", "Upload all": "Alle hochladen", "Upload": "Hochladen", @@ -1294,25 +1294,25 @@ "Changes your avatar in all rooms": "Verändert dein Profilbild in allen Räumen", "Messages": "Nachrichten", "Actions": "Aktionen", - "Displays list of commands with usages and descriptions": "Zeigt eine Liste von Befehlen mit Verwendungen und Beschreibungen an", + "Displays list of commands with usages and descriptions": "Zeigt die Liste verfügbarer Befehle mit Verwendungen und Beschreibungen an", "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, um diesen Befehl zu verwenden.", - "Multiple integration managers": "Mehrere Integrationsmanager", + "You do not have the required permissions to use this command.": "Du hast nicht die erforderlichen Berechtigungen, diesen Befehl zu verwenden.", + "Multiple integration managers": "Mehrere Integrationsverwalter", "Public Name": "Öffentlicher Name", - "Identity Server URL must be HTTPS": "Die Identity-Server-URL über HTTPS erreichbar sein", + "Identity Server URL must be HTTPS": "Identitätsserver-URL muss HTTPS sein", "Could not connect to Identity Server": "Verbindung zum Identitätsserver konnte nicht hergestellt werden", "Checking server": "Server wird überprüft", "Identity server has no terms of service": "Der Identitätsserver hat keine Nutzungsbedingungen", "Disconnect": "Trennen", "Identity Server": "Identitätsserver", "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 Standard-Identitätsserver (%(defaultIdentityServerName)s) zu benutzen oder ändere ihn in den Einstellungen.", + "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.", "ID": "ID", - "Not a valid Identity Server (status code %(code)s)": "Kein gültiger Identitätsserver (status code %(code)s)", + "Not a valid Identity Server (status code %(code)s)": "Ungültiger Identitätsserver (Fehlercode %(code)s)", "Terms of service not accepted or the identity server is invalid.": "Die Nutzungsbedingungen wurden nicht akzeptiert oder der Identitätsserver ist ungültig.", "Identity Server (%(server)s)": "Identitätsserver (%(server)s)", - "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ätsserver ist optional. Wenn du dich dazu entschließen solltest, keinen Identitätsserver zu verwenden, wirst du von anderen Nutzern nicht gefunden werden können und du kannst andere nicht mittels E-Mail oder Telefonnummer einladen.", + "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ätsserver ist optional. Solltest du dich dazu entschließen, keinen Identitätsserver zu verwenden, kannst du von anderen Nutzern nicht gefunden werden und andere nicht per E-Mail oder Telefonnummer einladen.", "Do not use an identity server": "Keinen Identitätsserver verwenden", "Enter a new identity server": "Gib einen neuen Identitätsserver ein", "Clear personal data": "Persönliche Daten löschen", @@ -1323,20 +1323,20 @@ "Add Phone Number": "Telefonnummer hinzufügen", "Changes the avatar of the current room": "Ändert den Avatar für diesen Raum", "Deactivate account": "Benutzerkonto deaktivieren", - "Show previews/thumbnails for images": "Zeige Vorschauen/Thumbnails für Bilder", + "Show previews/thumbnails for images": "Vorschauen für Bilder", "View": "Vorschau", "Find a room…": "Einen Raum suchen…", "Find a room… (e.g. %(exampleRoom)s)": "Einen Raum suchen… (z.B. %(exampleRoom)s)", "If you can't find the room you're looking for, ask for an invite or Create a new room.": "Wenn du den gesuchten Raum nicht finden kannst, frage nach einer Einladung für den Raum oder Erstelle einen neuen Raum.", - "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.": "Alternativ kannst du versuchen, den öffentlichen Server unter turn.matrix.org zu verwenden. Allerdings wird dieser nicht so zuverlässig sein und du teilst deine IP-Adresse mit diesem Server. Du kannst dies auch in den Einstellungen konfigurieren.", - "This action requires accessing the default identity server to validate an email address or phone number, but the server does not have any terms of service.": "Diese Handlung erfordert es, auf den Standard-Identitätsserver zuzugreifen, um eine E-Mail Adresse oder Telefonnummer zu validieren, aber der Server hat keine Nutzungsbedingungen.", - "Only continue if you trust the owner of the server.": "Fahre nur fort, wenn du dem Besitzer des Servers vertraust.", + "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.": "Alternativ kannst du versuchen, den öffentlichen Server unter turn.matrix.org zu verwenden. Allerdings wird dieser nicht so zuverlässig sein und du teilst deine IP-Adresse mit dem Server. Du kannst dies auch in den Einstellungen konfigurieren.", + "This action requires accessing the default identity server to validate an email address or phone number, but the server does not have any terms of service.": "Diese Handlung erfordert es, auf den Standardidentitätsserver zuzugreifen, um eine E-Mail-Adresse oder Telefonnummer zu validieren, aber der Server hat keine Nutzungsbedingungen.", + "Only continue if you trust the owner of the server.": "Fahre nur fort, wenn du den Betreibern des Servers vertraust.", "Trust": "Vertrauen", "Custom (%(level)s)": "Benutzerdefinierte (%(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.": "Nutze einen Identitätsserver, um über E-Mail Einladungen zu verschicken. Verwalte es in den Einstellungen.", + "Use an identity server to invite by email. Manage in Settings.": "Mit einem Identitätsserver kannst du über E-Mail Einladungen zu verschicken. Verwalte ihn in den Einstellungen.", "%(name)s (%(userId)s)": "%(name)s (%(userId)s)", - "Try out new ways to ignore people (experimental)": "Versuche neue Möglichkeiten, um Menschen zu ignorieren (experimentell)", + "Try out new ways to ignore people (experimental)": "Verwende neue Möglichkeiten, Menschen zu blockieren", "Send read receipts for messages (requires compatible homeserver to disable)": "Lesebestätigungen für Nachrichten senden (Deaktivieren erfordert einen kompatiblen Heimserver)", "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!", @@ -1353,7 +1353,7 @@ "%(senderName)s placed a video call. (not supported by this browser)": "%(senderName)s hat einen Videoanruf getätigt. (Nicht von diesem Browser unterstützt)", "Verify this session": "Sitzung verifizieren", "Set up encryption": "Verschlüsselung einrichten", - "%(senderName)s updated an invalid ban rule": "%(senderName)s aktualisierte eine ungültige Ausschluss-Regel", + "%(senderName)s updated an invalid ban rule": "%(senderName)s aktualisierte eine ungültige Ausschlussregel", "The message you are trying to send is too large.": "Die Nachricht, die du versuchst zu senden, ist zu lang.", "a few seconds ago": "vor ein paar Sekunden", "about a minute ago": "vor etwa einer Minute", @@ -1368,7 +1368,7 @@ "%(num)s hours from now": "in %(num)s Stunden", "about a day from now": "in etwa einem Tag", "%(num)s days from now": "in %(num)s Tagen", - "Show info about bridges in room settings": "Information über Bridges in den Raumeinstellungen anzeigen", + "Show info about bridges in room settings": "Information über Brücken in Raumeinstellungen", "Enable message search in encrypted rooms": "Nachrichtensuche in verschlüsselten Räumen aktivieren", "Lock": "Schloss", "Later": "Später", @@ -1378,11 +1378,11 @@ "not found": "nicht gefunden", "rooms.": "Räumen zu speichern.", "Manage": "Verwalten", - "Securely cache encrypted messages locally for them to appear in search results.": "Speichere verschlüsselte Nachrichten sicher lokal zwischen, sodass sie in Suchergebnissen erscheinen können.", + "Securely cache encrypted messages locally for them to appear in search results.": "Speichere verschlüsselte Nachrichten lokal, sodass sie deinen Suchergebnissen erscheinen können.", "Enable": "Aktivieren", - "Connecting to integration manager...": "Verbinde mit Integrationsmanager...", - "Cannot connect to integration manager": "Verbindung zum Integrationsmanager fehlgeschlagen", - "The integration manager is offline or it cannot reach your homeserver.": "Der Integrationsmanager ist offline oder er kann den Heimserver nicht erreichen.", + "Connecting to integration manager...": "Verbinde mit Integrationsverwalter...", + "Cannot connect to integration manager": "Verbindung zum Integrationsverwalter fehlgeschlagen", + "The integration manager is offline or it cannot reach your homeserver.": "Der Integrationsverwalter ist offline oder er kann den Heimserver nicht erreichen.", "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", "Backup key stored: ": "Backup Schlüssel gespeichert: ", @@ -1394,10 +1394,10 @@ "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 Email Adressen und Telefonnummern vom Identitätsserver löschst, bevor du die Verbindung trennst.", + "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 not currently using an identity server. To discover and be discoverable by existing contacts you know, add one below.": "Zur Zeit benutzt du keinen Identitätsserver. Trage unten einen Server ein, um Kontakte finden und von anderen gefunden zu werden.", - "Use an Integration Manager (%(serverName)s) to manage bots, widgets, and sticker packs.": "Nutze einen Integrationsmanager (%(serverName)s), um Bots, Widgets und Stickerpacks zu verwalten.", - "Use an Integration Manager to manage bots, widgets, and sticker packs.": "Verwende einen Integrationsmanager, um Bots, Widgets und Sticker Packs zu verwalten.", + "Use an Integration Manager (%(serverName)s) to manage bots, widgets, and sticker packs.": "Nutze einen Integrationsverwalter (%(serverName)s), um Bots, Widgets und Stickerpakete zu verwalten.", + "Use an Integration Manager to manage bots, widgets, and sticker packs.": "Verwende einen Integrationsverwalter, um Bots, Widgets und Stickerpakete zu verwalten.", "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.", "Clear cache and reload": "Zwischenspeicher löschen und neu laden", @@ -1405,53 +1405,53 @@ "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.", "Error subscribing to list": "Fehler beim Abonnieren der Liste", - "Error removing ignored user/server": "Fehler beim Entfernen eines ignorierten Benutzers/Servers", + "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", "User rules": "Nutzerregeln", - "You have not ignored anyone.": "Du hast niemanden ignoriert.", + "You have not ignored anyone.": "Du hast niemanden blockiert.", "You are currently ignoring:": "Du ignorierst momentan:", "Unsubscribe": "Deabonnieren", "View rules": "Regeln öffnen", "You are currently subscribed to:": "Du abonnierst momentan:", "⚠ These settings are meant for advanced users.": "⚠ Diese Einstellungen sind für fortgeschrittene Nutzer gedacht.", - "Whether you're using %(brand)s on a device where touch is the primary input mechanism": "Ob du %(brand)s auf einem Gerät verwendest, bei dem das Tasten die primäre Eingabemöglichkeit ist", - "Whether you're using %(brand)s as an installed Progressive Web App": "Ob du %(brand)s als installierte progressive Web-App verwendest", - "Your user agent": "Dein User-Agent", + "Whether you're using %(brand)s on a device where touch is the primary input mechanism": "Ob du %(brand)s auf einem Gerät verwendest, bei dem die Berührung die primäre Eingabemöglichkeit ist", + "Whether you're using %(brand)s as an installed Progressive Web App": "Ob du %(brand)s als installierte progressive Web-App (PWA) verwendest", + "Your user agent": "Dein Useragent", "If you cancel now, you won't complete verifying the other user.": "Wenn Sie jetzt abbrechen, werden Sie die Verifizierung des anderen Nutzers nicht beenden können.", "If you cancel now, you won't complete verifying your other session.": "Wenn Sie jetzt abbrechen, werden Sie die Verifizierung der anderen Sitzung nicht beenden können.", "Cancel entering passphrase?": "Eingabe der Passphrase abbrechen?", "Setting up keys": "Einrichten der Schlüssel", - "Encryption upgrade available": "Verschlüsselungs-Update verfügbar", - "Verifies a user, session, and pubkey tuple": "Verifiziert einen Benutzer, eine Sitzung und Pubkey-Tupel", - "Unknown (user, session) pair:": "Unbekanntes (Nutzer-, Sitzungs-) Paar:", + "Encryption upgrade available": "Verschlüsselungsaufstufung verfügbar", + "Verifies a user, session, and pubkey tuple": "Verifiziert einen Benutzer, eine Sitzung und die öffentlichen Schlüsselpaare", + "Unknown (user, session) pair:": "Unbekanntes Nutzer-/Sitzungspaar:", "Session already verified!": "Sitzung bereits verifiziert!", "WARNING: Session already verified, but keys do NOT MATCH!": "WARNUNG: Die Sitzung wurde bereits verifiziert, aber die Schlüssel passen NICHT ZUSAMMEN!", - "WARNING: KEY VERIFICATION FAILED! The signing key for %(userId)s and session %(deviceId)s is \"%(fprint)s\" which does not match the provided key \"%(fingerprint)s\". This could mean your communications are being intercepted!": "ACHTUNG: SCHLÜSSEL-VERIFIZIERUNG FEHLGESCHLAGEN! Der Signierschlüssel für %(userId)s und Sitzung %(deviceId)s ist \"%(fprint)s\", was nicht mit dem bereitgestellten Schlüssel \"%(fingerprint)s\" übereinstimmt. Das könnte bedeuten, dass deine Kommunikation abgehört wird!", - "Never send encrypted messages to unverified sessions from this session": "Sende niemals verschlüsselte Nachrichten von dieser Sitzung zu unverifizierten Sitzungen", - "Never send encrypted messages to unverified sessions in this room from this session": "Sende niemals verschlüsselte Nachrichten von dieser Sitzung zu unverifizierten Sitzungen in diesem Raum", - "Changing password will currently reset any end-to-end encryption keys on all sessions, making encrypted chat history unreadable, unless you first export your room keys and re-import them afterwards. In future this will be improved.": "Durch die Änderung des Passworts werden derzeit alle Ende-zu-Ende-Verschlüsselungsschlüssel in allen Sitzungen zurückgesetzt, sodass der verschlüsselte Chat-Verlauf nicht mehr lesbar ist, es sei denn, du exportierst zuerst deine Raumschlüssel und importierst sie anschließend wieder. In Zukunft wird dies verbessert werden.", + "WARNING: KEY VERIFICATION FAILED! The signing key for %(userId)s and session %(deviceId)s is \"%(fprint)s\" which does not match the provided key \"%(fingerprint)s\". This could mean your communications are being intercepted!": "ACHTUNG: SCHLÜSSELVERIFIZIERUNG FEHLGESCHLAGEN! Der Signierschlüssel für %(userId)s und Sitzung %(deviceId)s ist \"%(fprint)s\", was nicht mit dem bereitgestellten Schlüssel \"%(fingerprint)s\" übereinstimmt. Das könnte bedeuten, dass deine Kommunikation abgehört wird!", + "Never send encrypted messages to unverified sessions from this session": "Niemals verschlüsselte Nachrichten von dieser Sitzung zu unverifizierten Sitzungen senden", + "Never send encrypted messages to unverified sessions in this room from this session": "Niemals verschlüsselte Nachrichten von dieser Sitzung zu unverifizierten Sitzungen in diesem Raum senden", + "Changing password will currently reset any end-to-end encryption keys on all sessions, making encrypted chat history unreadable, unless you first export your room keys and re-import them afterwards. In future this will be improved.": "Durch die Änderung des Passworts werden derzeit alle Ende-zu-Ende-Verschlüsselungsschlüssel in allen Sitzungen zurückgesetzt, sodass der verschlüsselte Chatverlauf nicht mehr lesbar ist, es sei denn, du exportierst zuerst deine Raumschlüssel und importierst sie anschließend wieder. In Zukunft wird dies verbessert werden.", "Delete %(count)s sessions|other": "%(count)s Sitzungen löschen", "Backup is not signed by any of your sessions": "Die Sicherung wurde von keiner deiner Sitzungen bestätigt", - "Your password was successfully changed. You will not receive push notifications on other sessions until you log back in to them": "Dein Passwort wurde erfolgreich geändert. Du erhältst keine Push-Benachrichtigungen zu anderen Sitzungen, bis du dich wieder bei diesen anmeldest", + "Your password was successfully changed. You will not receive push notifications on other sessions until you log back in to them": "Dein Passwort wurde erfolgreich geändert. Du erhältst keine Puschbenachrichtigungen zu anderen Sitzungen, bis du dich wieder bei diesen anmeldest", "Notification sound": "Benachrichtigungston", - "Set a new custom sound": "Setze einen neuen benutzerdefinierten Ton", + "Set a new custom sound": "Benutzerdefinierten Ton setzen", "Browse": "Durchsuchen", "Direct Messages": "Direktnachrichten", "You can use /help to list available commands. Did you mean to send this as a message?": "Du kannst /help benutzen, um alle verfügbaren Befehle aufzulisten. Willst du es stattdessen als Nachricht senden?", "Direct message": "Direktnachricht", "Suggestions": "Vorschläge", - "Recently Direct Messaged": "Kürzlich direkt verschickt", + "Recently Direct Messaged": "Zuletzt kontaktiert", "Go": "Los", "Command Help": "Befehl Hilfe", "To help us prevent this in future, please send us logs.": "Um uns zu helfen, dies in Zukunft zu vermeiden, sende uns bitte die Protokolldateien.", - "Notification settings": "Benachrichtigungseinstellungen", + "Notification settings": "Benachrichtigungen", "Help": "Hilf uns", "Filter": "Filtern", "Filter rooms…": "Räume filtern…", "You have %(count)s unread notifications in a prior version of this room.|one": "Du hast %(count)s ungelesene Benachrichtigungen in einer früheren Version dieses Raumes.", - "Go Back": "Zurückgehen", + "Go Back": "Zurück", "Notification Autocomplete": "Benachrichtigung Autovervollständigen", "If disabled, messages from encrypted rooms won't appear in search results.": "Wenn deaktiviert, werden Nachrichten von verschlüsselten Räumen nicht in den Ergebnissen auftauchen.", "This user has not verified all of their sessions.": "Dieser Benutzer hat nicht alle seine Sitzungen verifiziert.", @@ -1459,9 +1459,9 @@ "Your key share request has been sent - please check your other sessions for key share requests.": "Deine Schlüsselanfrage wurde gesendet - sieh in deinen anderen Sitzungen nach der Schlüsselanfrage.", "Key share requests are sent to your other sessions automatically. If you rejected or dismissed the key share request on your other sessions, click here to request the keys for this session again.": "Schlüsselanfragen werden automatisch an deine anderen Sitzungen gesendet. Wenn du sie abgelehnt oder ignoriert hast, klicke hier, um die Schlüssel erneut anzufordern.", "If your other sessions do not have the key for this message you will not be able to decrypt them.": "Wenn deine anderen Sitzungen nicht über den Schlüssel für diese Nachricht verfügen, kannst du die Nachricht nicht entschlüsseln.", - "Re-request encryption keys from your other sessions.": "Fordere die Verschlüsselungsschlüssel aus deinen anderen Sitzungen erneut an.", + "Re-request encryption keys from your other sessions.": "Schlüssel aus deinen anderen Sitzungen erneut anfordern.", "Room %(name)s": "Raum %(name)s", - "Upgrading this room will shut down the current instance of the room and create an upgraded room with the same name.": "Ein Upgrade dieses Raums schaltet die aktuelle Instanz des Raums ab und erstellt einen aktualisierten Raum mit demselben Namen.", + "Upgrading this room will shut down the current instance of the room and create an upgraded room with the same name.": "Ein Aktualisierung dieses Raums deaktiviert die aktuelle Instanz des Raums und erstellt einen aktualisierten Raum mit demselben Namen.", "%(name)s (%(userId)s) signed in to a new session without verifying it:": "%(name)s (%(userId)s) hat sich zu einer neuen Sitzung angemeldet, ohne sie zu verifizieren:", "%(count)s verified sessions|other": "%(count)s verifizierte Sitzungen", "Hide verified sessions": "Verifizierte Sitzungen ausblenden", @@ -1480,16 +1480,16 @@ "Your display name": "Dein Anzeigename", "Please enter a name for the room": "Bitte gib einen Namen für den Raum ein", "This room is private, and can only be joined by invitation.": "Dieser Raum ist privat und kann nur auf Einladung betreten werden.", - "Create a private room": "Erstelle einen privaten Raum", + "Create a private room": "Einen privaten Raum erstellen", "Topic (optional)": "Thema (optional)", "Make this room public": "Mache diesen Raum öffentlich", - "Hide advanced": "Weitere Einstellungen ausblenden", + "Hide advanced": "Erweiterte Einstellungen ausblenden", "Block users on other matrix homeservers from joining this room (This setting cannot be changed later!)": "Hindere Benutzer auf anderen Matrix-Homeservern daran, diesem Raum beizutreten (Diese Einstellung kann später nicht geändert werden!)", "Session name": "Name der Sitzung", "This will allow you to return to your account after signing out, and sign in on other sessions.": "So kannst du nach der Abmeldung zu deinem Konto zurückkehren und dich bei anderen Sitzungen anmelden.", "Use bots, bridges, widgets and sticker packs": "Benutze Bots, Bridges, Widgets und Sticker-Packs", "Changing your password will reset any end-to-end encryption keys on all of your sessions, making encrypted chat history unreadable. Set up Key Backup or export your room keys from another session before resetting your password.": "Wenn du dein Passwort änderst, werden alle Ende-zu-Ende-Verschlüsselungsschlüssel für alle deine Sitzungen zurückgesetzt, sodass der verschlüsselte Chat-Verlauf nicht mehr lesbar ist. Richte ein Schlüssel-Backup ein oder exportiere deine Raumschlüssel aus einer anderen Sitzung, bevor du dein Passwort zurücksetzst.", - "You have been logged out of all sessions and will no longer receive push notifications. To re-enable notifications, sign in again on each device.": "Du wurdest von allen Sitzungen abgemeldet und erhälst keine Push-Benachrichtigungen mehr. Um die Benachrichtigungen wieder zu aktivieren, melde dich auf jedem Gerät erneut an.", + "You have been logged out of all sessions and will no longer receive push notifications. To re-enable notifications, sign in again on each device.": "Du wurdest von allen Sitzungen abgemeldet und erhältst keine Push-Benachrichtigungen mehr. Um sie wieder zu aktivieren, melde dich auf jedem Gerät erneut an.", "Upgrade this session to allow it to verify other sessions, granting them access to encrypted messages and marking them as trusted for other users.": "Aktualisiere diese Sitzung, damit sie andere Sitzungen verifizieren kann, indem sie dir Zugang zu verschlüsselten Nachrichten gewährt und sie für andere Benutzer als vertrauenswürdig markiert.", "Sign out and remove encryption keys?": "Abmelden und Verschlüsselungsschlüssel entfernen?", "Sign in to your Matrix account on ": "Melde dich bei deinem Matrix-Konto auf an", @@ -1499,9 +1499,9 @@ "Sign In or Create Account": "Anmelden oder Konto erstellen", "Use your account or create a new one to continue.": "Benutze dein Konto oder erstelle ein neues, um fortzufahren.", "Create Account": "Konto erstellen", - "Show typing notifications": "Zeige Tipp-Benachrichtigungen", + "Show typing notifications": "Tippbenachrichtigungen zeigen", "Order rooms by name": "Sortiere Räume nach Name", - "When rooms are upgraded": "Wenn Räume verbessert werden", + "When rooms are upgraded": "Raumupgrades", "Scan this unique code": "Scanne diesen einzigartigen Code", "or": "oder", "Compare unique emoji": "Vergleiche einzigartige Emojis", @@ -1569,36 +1569,36 @@ "%(senderName)s removed the alternative addresses %(addresses)s for this room.|one": "%(senderName)s hat die alternative Adresse %(addresses)s für diesen Raum entfernt.", "%(senderName)s changed the alternative addresses for this room.": "%(senderName)s hat die alternative Adresse für diesen Raum geändert.", "%(senderName)s changed the main and alternative addresses for this room.": "%(senderName)s hat die Haupt- und Alternativadressen für diesen Raum geändert.", - "%(senderName)s removed the rule banning users matching %(glob)s": "%(senderName)s entfernte die Ausschluss-Regel für Benutzer, die %(glob)s entsprechen", - "%(senderName)s removed the rule banning rooms matching %(glob)s": "%(senderName)s entfernte die Ausschluss-Regel für Räume, die %(glob)s entsprechen", - "%(senderName)s removed the rule banning servers matching %(glob)s": "%(senderName)s entfernte die Ausschluss-Regel für Server, die %(glob)s entsprechen", - "%(senderName)s removed a ban rule matching %(glob)s": "%(senderName)s entfernte die Ausschluss-Regel, die %(glob)s entspricht", - "%(senderName)s updated the rule banning users matching %(glob)s for %(reason)s": "%(senderName)s aktualisierte die Ausschluss-Regel für Benutzer, die aufgrund von %(reason)s %(glob)s entsprechen", - "%(senderName)s updated the rule banning rooms matching %(glob)s for %(reason)s": "%(senderName)s aktualisierte die Ausschluss-Regel für Räume, die aufgrund von %(reason)s %(glob)s entsprechen", - "%(senderName)s updated the rule banning servers matching %(glob)s for %(reason)s": "%(senderName)s aktualisierte die Ausschluss-Regel für Server, die aufgrund von %(reason)s %(glob)s entsprechen", - "%(senderName)s updated a ban rule matching %(glob)s for %(reason)s": "%(senderName)s aktualisierte eine Ausschluss-Regel, die wegen %(reason)s %(glob)s entspricht", - "%(senderName)s created a rule banning users matching %(glob)s for %(reason)s": "%(senderName)s erstellte eine Ausschluss-Regel für Nutzer, die wegen %(reason)s %(glob)s entspricht", - "%(senderName)s created a rule banning rooms matching %(glob)s for %(reason)s": "%(senderName)s erstellt eine Ausschluss-Regel für Räume, die %(glob)s aufgrund von %(reason)s entspricht", - "%(senderName)s created a rule banning servers matching %(glob)s for %(reason)s": "%(senderName)s erstellte eine Ausschluss-Regel für Server, die aufgrund von %(reason)s %(glob)s entsprechen", - "%(senderName)s created a ban rule matching %(glob)s for %(reason)s": "%(senderName)s erstellte eine Ausschluss-Regel, die aufgrund von %(reason)s %(glob)s entspricht", + "%(senderName)s removed the rule banning users matching %(glob)s": "%(senderName)s entfernte die Ausschlussregel für Benutzer, die %(glob)s entsprechen", + "%(senderName)s removed the rule banning rooms matching %(glob)s": "%(senderName)s entfernte die Ausschlussregel für Räume, die %(glob)s entsprechen", + "%(senderName)s removed the rule banning servers matching %(glob)s": "%(senderName)s entfernte die Ausschlussregel für Server, die %(glob)s entsprechen", + "%(senderName)s removed a ban rule matching %(glob)s": "%(senderName)s entfernte die Ausschlussregel, die %(glob)s entspricht", + "%(senderName)s updated the rule banning users matching %(glob)s for %(reason)s": "%(senderName)s aktualisierte die Ausschlussregel für Benutzer, die aufgrund von %(reason)s %(glob)s entsprechen", + "%(senderName)s updated the rule banning rooms matching %(glob)s for %(reason)s": "%(senderName)s aktualisierte die Ausschlussregel für Räume, die aufgrund von %(reason)s %(glob)s entsprechen", + "%(senderName)s updated the rule banning servers matching %(glob)s for %(reason)s": "%(senderName)s aktualisierte die Ausschlussregel für Server, die aufgrund von %(reason)s %(glob)s entsprechen", + "%(senderName)s updated a ban rule matching %(glob)s for %(reason)s": "%(senderName)s aktualisierte eine Ausschlussregel, die wegen %(reason)s %(glob)s entspricht", + "%(senderName)s created a rule banning users matching %(glob)s for %(reason)s": "%(senderName)s hat eine Ausschlussregel für Nutzer erstellt, die aufgrund %(reason)s %(glob)s entsprechen", + "%(senderName)s created a rule banning rooms matching %(glob)s for %(reason)s": "%(senderName)s erstellt eine Ausschlussregel für Räume, die %(glob)s aufgrund von %(reason)s entspricht", + "%(senderName)s created a rule banning servers matching %(glob)s for %(reason)s": "%(senderName)s erstellte eine Ausschlussregel für Server, die aufgrund von %(reason)s %(glob)s entsprechen", + "%(senderName)s created a ban rule matching %(glob)s for %(reason)s": "%(senderName)s erstellte eine Ausschlussregel, die aufgrund von %(reason)s %(glob)s entspricht", "Do you want to chat with %(user)s?": "Möchtest du mit %(user)s chatten?", " wants to chat": " möchte mit dir chatten", "Start chatting": "Chat starten", - "Reject & Ignore user": "Ablehnen und Nutzer ignorieren", - "%(senderName)s changed a rule that was banning users matching %(oldGlob)s to matching %(newGlob)s for %(reason)s": "%(senderName)s ändert eine Ausschluss-Regel von %(oldGlob)s nach %(newGlob)s, wegen %(reason)s", - "%(senderName)s changed a rule that was banning rooms matching %(oldGlob)s to matching %(newGlob)s for %(reason)s": "%(senderName)s ändert eine Ausschluss-Regel für Räume von %(oldGlob)s nach %(newGlob)s, wegen %(reason)s", + "Reject & Ignore user": "Ablehnen und Nutzer blockieren", + "%(senderName)s changed a rule that was banning users matching %(oldGlob)s to matching %(newGlob)s for %(reason)s": "%(senderName)s ändert eine Ausschlussregel von %(oldGlob)s nach %(newGlob)s, wegen %(reason)s", + "%(senderName)s changed a rule that was banning rooms matching %(oldGlob)s to matching %(newGlob)s for %(reason)s": "%(senderName)s ändert eine Ausschlussregel für Räume von %(oldGlob)s nach %(newGlob)s, wegen %(reason)s", "Allow fallback call assist server turn.matrix.org when your homeserver does not offer one (your IP address would be shared during a call)": "Auf den Server turn.matrix.org zurückgreifen, falls deine Heimserver keine Anruf-Assistenz anbietet (deine IP-Adresse wird während eines Anrufs geteilt)", "Show more": "Mehr zeigen", "This session is not backing up your keys, but you do have an existing backup you can restore from and add to going forward.": "Diese Sitzung sichert deine Schlüssel nicht, aber du hast eine vorhandene Sicherung, die du wiederherstellen und in Zukunft hinzufügen kannst.", "Connect this session to key backup before signing out to avoid losing any keys that may only be on this session.": "Verbinde diese Sitzung mit deiner Schlüsselsicherung bevor du dich abmeldest, um den Verlust von Schlüsseln zu vermeiden.", "This backup is trusted because it has been restored on this session": "Dieser Sicherung wird vertraut, da sie während dieser Sitzung wiederhergestellt wurde", - "Enable desktop notifications for this session": "Desktop-Benachrichtigungen für diese Sitzung aktivieren", - "Enable audible notifications for this session": "Aktiviere die akustischen Benachrichtigungen für diese Sitzung", - "Integration Managers receive configuration data, and can modify widgets, send room invites, and set power levels on your behalf.": "Integrationsmanager erhalten Konfigurationsdaten und können Widgets modifizieren, Raumeinladungen verschicken und in deinem Namen Berechtigungslevel setzen.", + "Enable desktop notifications for this session": "Desktopbenachrichtigungen in dieser Sitzung", + "Enable audible notifications for this session": "Benachrichtigungstöne in dieser Sitzung", + "Integration Managers receive configuration data, and can modify widgets, send room invites, and set power levels on your behalf.": "Integrationsverwalter erhalten Konfigurationsdaten und können Widgets modifizieren, Raumeinladungen verschicken und in deinem Namen Berechtigungslevel setzen.", "Read Marker lifetime (ms)": "Gültigkeitsdauer der Gelesen-Markierung (ms)", "Read Marker off-screen lifetime (ms)": "Gültigkeitsdauer der Gelesen-Markierung außerhalb des Bildschirms (ms)", "Session key:": "Sitzungsschlüssel:", - "A session's public name is visible to people you communicate with": "Der öffentliche Sitzungsname ist sichtbar für Personen, mit denen du kommunizierst", + "A session's public name is visible to people you communicate with": "Der Sitzungsname ist für alle Personen sichtbar", "Sounds": "Töne", "Upgrade the room": "Raum hochstufen", "Enable room encryption": "Raumverschlüsselung aktivieren", @@ -1609,10 +1609,10 @@ "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 Standard-Identitätsserver (%(defaultIdentityServerName)s) oder konfiguriere einen in den Einstellungen.", + "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.", "Create a public room": "Öffentlichen Raum erstellen", - "Show advanced": "Weitere Einstellungen anzeigen", + "Show advanced": "Erweiterte Einstellungen", "Verify session": "Sitzung verifizieren", "Session key": "Sitzungsschlüssel", "Recent Conversations": "Letzte Unterhaltungen", @@ -1632,12 +1632,12 @@ "Make a copy of your recovery key": "Speichere deinen Wiederherstellungsschlüssel", "Sends a message as html, without interpreting it as markdown": "Verschickt eine Nachricht im HTML-Format, ohne sie als Markdown zu darzustellen", "Show rooms with unread notifications first": "Räume mit ungelesenen Benachrichtigungen zuerst zeigen", - "Show shortcuts to recently viewed rooms above the room list": "Kurzbefehle zu den kürzlich gesichteten Räumen über der Raumliste anzeigen", - "Use Single Sign On to continue": "Einmal-Anmeldung zum Fortfahren nutzen", - "Confirm adding this email address by using Single Sign On to prove your identity.": "Bestätige die hinzugefügte E-Mail-Adresse mit der Einmal-Anmeldung, um deine Identität nachzuweisen.", - "Single Sign On": "Einmal-Anmeldung", + "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", + "Confirm adding this email address by using Single Sign On to prove your identity.": "Bestätige das Hinzufügen dieser E-Mail-Adresse durch Single Sign-on, um deine Identität nachzuweisen.", + "Single Sign On": "Einmalanmeldung", "Confirm adding email": "Hinzugefügte E-Mail-Addresse bestätigen", - "Confirm adding this phone number by using Single Sign On to prove your identity.": "Bestätige die hinzugefügte Telefonnummer, indem du deine Identität mittels der Einmal-Anmeldung nachweist.", + "Confirm adding this phone number by using Single Sign On to prove your identity.": "Bestätige die hinzugefügte Telefonnummer, indem du deine Identität mittels der Einmalanmeldung nachweist.", "Click the button below to confirm adding this phone number.": "Klicke unten die Schaltfläche, um die hinzugefügte Telefonnummer zu bestätigen.", "If you cancel now, you won't complete your operation.": "Wenn du jetzt abbrichst, wirst du deinen Vorgang nicht fertigstellen.", "%(name)s is requesting verification": "%(name)s fordert eine Verifizierung an", @@ -1646,15 +1646,15 @@ "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 die Schaltfläche, 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 Ausschluss-Regel 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 Ausschluss-Regel 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 ä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", "Not Trusted": "Nicht vertraut", "Manually Verify by Text": "Verifiziere manuell mit einem Text", "Interactively verify by Emoji": "Verifiziere interaktiv mit Emojis", - "Support adding custom themes": "Unterstütze das Hinzufügen von benutzerdefinierten Designs", + "Support adding custom themes": "Benutzerdefinierte Designs", "Ask this user to verify their session, or manually verify it below.": "Bitte diesen Nutzer, seine Sitzung zu verifizieren, oder verifiziere diese unten manuell.", "a few seconds from now": "in ein paar Sekunden", - "Manually verify all remote sessions": "Alle Remotesitzungen manuell verifizieren", + "Manually verify all remote sessions": "Remotesitzungen manuell verifizieren", "Confirm the emoji below are displayed on both sessions, in the same order:": "Bestätige, dass die unten angezeigten Emojis auf beiden Sitzungen in der selben Reihenfolge angezeigt werden:", "Verify this session by confirming the following number appears on its screen.": "Verfiziere diese Sitzung, indem du bestätigst, dass die folgende Nummer auf ihrem Bildschirm erscheint.", "Waiting for your other session, %(deviceName)s (%(deviceId)s), to verify…": "Warte auf deine andere Sitzung,%(deviceName)s /%(deviceId)s), um zu verfizieren…", @@ -1676,14 +1676,14 @@ "Forgotten your password?": "Passwort vergessen?", "You're signed out": "Du wurdest abgemeldet", "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.": "Achtung: Deine persönlichen Daten (einschließlich Verschlüsselungsschlüssel) sind noch in dieser Sitzung gespeichert. Lösche diese Daten, wenn du diese Sitzung nicht mehr benötigst, oder dich mit einem anderen Konto anmelden möchtest.", - "Confirm deleting these sessions by using Single Sign On to prove your identity.|other": "Bestätige das Löschen dieser Sitzung indem du dich mittels Single Sign-On anmeldest um deine Identität nachzuweisen.", - "Confirm deleting these sessions by using Single Sign On to prove your identity.|one": "Bestätige das Löschen dieser Sitzung indem du dich mittels Single Sign-On anmeldest um deine Identität nachzuweisen.", + "Confirm deleting these sessions by using Single Sign On to prove your identity.|other": "Melde dich mittels Einmalanmeldung an, um das Löschen der Sitzungen zu bestätigen.", + "Confirm deleting these sessions by using Single Sign On to prove your identity.|one": "Bestätige das Löschen dieser Sitzung mittels Einmalanmeldung um deine Identität nachzuweisen.", "Confirm deleting these sessions": "Bestätige das Löschen dieser Sitzungen", "Click the button below to confirm deleting these sessions.|other": "Klicke den Knopf, um das Löschen dieser Sitzungen zu bestätigen.", "Click the button below to confirm deleting these sessions.|one": "Klicke den Knopf, um das Löschen dieser Sitzung zu bestätigen.", "Clear all data in this session?": "Alle Daten dieser Sitzung löschen?", "Clear all data": "Alle Daten löschen", - "Confirm your account deactivation by using Single Sign On to prove your identity.": "Bestätige das Löschen deines Kontos indem du dich mittels Single Sign-On anmeldest um deine Identität nachzuweisen.", + "Confirm your account deactivation by using Single Sign On to prove your identity.": "Bestätige das Löschen deines Kontos indem du dich mittels Einmalanmeldung anmeldest um deine Identität nachzuweisen.", "Confirm account deactivation": "Konto löschen bestätigen", "Confirm your identity by entering your account password below.": "Bestätige deine Identität, indem du unten dein Kontopasswort eingibst.", "Confirm your identity by verifying this login from one of your other sessions, granting it access to encrypted messages.": "Bestätige deine Identität indem du diesen Login von einer deiner anderen Sitzungen verifizierst um Zugriff auf deine verschlüsselten Nachrichten zu erhalten.", @@ -1699,32 +1699,32 @@ "Someone is using an unknown session": "Jemand verwendet eine unbekannte Sitzung", "This room is end-to-end encrypted": "Dieser Raum ist Ende-zu-Ende verschlüsselt", "You are not subscribed to any lists": "Du hast keine Listen abonniert", - "Error adding ignored user/server": "Fehler beim Hinzufügen eines ignorierten Nutzers/Servers", - "None": "Keine", + "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 ignorieren willst. Verwende Sternchen, damit %(brand)s mit beliebigen Zeichen übereinstimmt. Bspw. würde @bot: * alle Benutzer ignorieren, 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, damit %(brand)s mit beliebigen Zeichen übereinstimmt. Bspw. würde @bot: * alle Benutzer blockieren, die auf einem Server den Namen 'bot' haben.", "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 ignoriert hast, wird in der Raumliste \"Meine Sperrliste\" angezeigt - bleibe in diesem Raum, um die Sperrliste aufrecht zu halten.", - "Server or user ID to ignore": "Zu ignorierende Server- oder Benutzer-ID", + "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.", + "Server or user ID to ignore": "Zu blockierende Server- oder Benutzer-ID", "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 Tool, um Benutzer zu ignorieren.", + "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.", "Subscribe": "Abonnieren", "Always show the window menu bar": "Fenstermenüleiste immer anzeigen", - "Show tray icon and minimize window to it on close": "Taskleistensymbol anzeigen und Fenster beim Schließen dorthin minimieren", + "Show tray icon and minimize window to it on close": "Beim Schließen des Fensters in die Taskleiste minimieren", "Session ID:": "Sitzungs-ID:", "Message search": "Nachrichtensuche", - "Cross-signing": "Cross-Signing", + "Cross-signing": "Quersignierung", "This room is bridging messages to the following platforms. Learn more.": "Dieser Raum verbindet Nachrichten mit den folgenden Plattformen. Mehr erfahren.", - "This room isn’t bridging messages to any platforms. Learn more.": "Dieser Raum verbindet keine Nachrichten mit Plattformen. Mehr erfahren.", - "Bridges": "Bridges", + "This room isn’t bridging messages to any platforms. Learn more.": "Dieser Raum verbindet keine Nachrichten mit anderen Plattformen. Mehr erfahren.", + "Bridges": "Brücken", "Uploaded sound": "Hochgeladener Ton", "Upgrade this room to the recommended room version": "Aktualisiere diesen Raum auf die empfohlene Raumversion", "this room": "Dieser Raum", "View older messages in %(roomName)s.": "Zeige alte Nachrichten in %(roomName)s.", - "Send a bug report with logs": "Einen Fehlerbericht mit Logs senden", + "Send a bug report with logs": "Einen Fehlerbericht mit der Protokolldatei senden", "Verify all your sessions to ensure your account & messages are safe": "Verifiziere alle deine Sitzungen, um dein Konto und deine Nachrichten zu schützen", "Verify your other session using one of the options below.": "Verifiziere deine andere Sitzung mit einer der folgenden Optionen.", "You signed in to a new session without verifying it:": "Du hast dich in einer neuen Sitzung angemeldet ohne sie zu verifizieren:", @@ -1732,39 +1732,39 @@ "Upgrade": "Hochstufen", "Verify the new login accessing your account: %(name)s": "Verifiziere die neue Anmeldung an deinem Konto: %(name)s", "From %(deviceName)s (%(deviceId)s)": "Von %(deviceName)s (%(deviceId)s)", - "Your homeserver does not support cross-signing.": "Dein Heimserver unterstützt kein Cross-Signing.", + "Your homeserver does not support cross-signing.": "Dein Heimserver unterstützt keine Quersignierung.", "Cross-signing and secret storage are enabled.": "Cross-signing und der sichere Speicher wurden eingerichtet.", - "Your account has a cross-signing identity in secret storage, but it is not yet trusted by this session.": "Dein Konto hat eine Cross-Signing-Identität im sicheren Speicher, der von dieser Sitzung jedoch noch nicht vertraut wird.", + "Your account has a cross-signing identity in secret storage, but it is not yet trusted by this session.": "Dein Konto hat eine Quersignaturidentität im sicheren Speicher, der von dieser Sitzung jedoch noch nicht vertraut wird.", "Cross-signing and secret storage are not yet set up.": "Cross-Signing und der sichere Speicher sind noch nicht eingerichtet.", "Reset cross-signing and secret storage": "Cross-Signing und den sicheren Speicher zurücksetzen", "Bootstrap cross-signing and secret storage": "Richte Cross-Signing und den sicheren Speicher ein", "unexpected type": "unbekannter Typ", - "Cross-signing public keys:": "Öffentliche Cross-Signing-Schlüssel:", + "Cross-signing public keys:": "Öffentlicher Quersignaturschlüssel:", "in memory": "im Speicher", - "Cross-signing private keys:": "Private Cross-Signing-Schlüssel:", + "Cross-signing private keys:": "Private Quersignaturschlüssel:", "in secret storage": "im Schlüsselspeicher", "Self signing private key:": "Selbst signierter privater Schlüssel:", "cached locally": "lokal zwischengespeichert", "not found locally": "lokal nicht gefunden", - "User signing private key:": "Privater Benutzer-Schlüssel:", + "User signing private key:": "Privater Benutzerschlüssel:", "Session backup key:": "Sitzungswiederherstellungsschlüssel:", "Secret storage public key:": "Öffentlicher Schlüssel des sicheren Speichers:", "in account data": "in den Kontodaten", - "Homeserver feature support:": "Home-Server-Funktionsunterstützung:", + "Homeserver feature support:": "Unterstützte Funktionen des Heimservers:", "exists": "existiert", "Delete sessions|other": "Sitzungen löschen", "Delete sessions|one": "Sitzung löschen", - "Individually verify each session used by a user to mark it as trusted, not trusting cross-signed devices.": "Alle Sitzungen einzeln verifizieren, anstatt auch Sitzungen zu vertrauen, die durch Cross-Signing verifiziert sind.", + "Individually verify each session used by a user to mark it as trusted, not trusting cross-signed devices.": "Alle Sitzungen einzeln verifizieren, anstatt auch Sitzungen zu vertrauen, die durch Quersignierungen verifiziert sind.", "Securely cache encrypted messages locally for them to appear in search results, using ": "Der Zwischenspeicher für die lokale Suche in verschlüsselten Nachrichten benötigt ", " to store messages from ": " um Nachrichten von ", - "%(brand)s is missing some components required for securely caching encrypted messages locally. If you'd like to experiment with this feature, build a custom %(brand)s Desktop with search components added.": "%(brand)s benötigt weitere Komponenten um verschlüsselte Nachrichten lokal zu durchsuchen. Wenn du diese Funktion testen möchtest kannst du dir deine eigene Version von %(brand)s Desktop mit der integrierten Suchfunktion bauen.", + "%(brand)s is missing some components required for securely caching encrypted messages locally. If you'd like to experiment with this feature, build a custom %(brand)s Desktop with search components added.": "Um verschlüsselte Nachrichten lokal zu durchsuchen, benötigt %(brand)s weitere Komponenten. Wenn du diese Funktion testen möchtest, kannst du dir deine eigene Version von %(brand)s Desktop mit der integrierten Suchfunktion kompilieren.", "Backup has a valid signature from this user": "Die Sicherung hat eine gültige Signatur dieses Benutzers", "Backup has a invalid signature from this user": "Die Sicherung hat eine ungültige Signatur von diesem Benutzer", "Backup has a valid signature from verified session ": "Die Sicherung hat eine gültige Signatur von einer verifizierten Sitzung ", "Backup has a valid signature from unverified session ": "Die Sicherung hat eine gültige Signatur von einer nicht verifizierten Sitzung ", "Backup has an invalid signature from verified session ": "Die Sicherung hat eine ungültige Signatur von einer verifizierten Sitzung ", "Backup has an invalid signature from unverified session ": "Die Sicherung hat eine ungültige Signatur von einer nicht verifizierten Sitzung ", - "Your keys are not being backed up from this session.": "Deine Schlüssel werden nicht von dieser Sitzung gesichert.", + "Your keys are not being backed up from this session.": "Deine Schlüssel werden von dieser Sitzung nicht gesichert.", "You are currently using to discover and be discoverable by existing contacts you know. You can change your identity server below.": "Zur Zeit verwendest du , um Kontakte zu finden und von anderen gefunden zu werden. Du kannst deinen Identitätsserver weiter unten ändern.", "Invalid theme schema.": "Ungültiges Designschema.", "Error downloading theme information.": "Fehler beim herunterladen des Themas.", @@ -1776,22 +1776,22 @@ "Manage the names of and sign out of your sessions below or verify them in your User Profile.": "Benenne deine Sitzungen, melde dich aus den Sitzungen ab oder verifiziere sie in deinen Benutzereinstellungen.", "Error changing power level requirement": "Fehler beim Ändern der Anforderungen für Benutzerrechte", "Error changing power level": "Fehler beim Ändern der Benutzerrechte", - "Your email address hasn't been verified yet": "Deine E-Mail-Adresse wurde noch nicht überprüft", + "Your email address hasn't been verified yet": "Deine E-Mail-Adresse wurde noch nicht verifiziert", "Verify the link in your inbox": "Verifiziere den Link in deinem Posteingang", "Complete": "Abschließen", "Revoke": "Widerrufen", "Share": "Teilen", "You have not verified this user.": "Du hast diesen Nutzer nicht verifiziert.", "Everyone in this room is verified": "Alle in diesem Raum sind verifiziert", - "Mod": "Mod", + "Mod": "Moderator", "Invite only": "Nur auf Einladung", "Scroll to most recent messages": "Zur neusten Nachricht springen", "No recent messages by %(user)s found": "Keine neuen Nachrichten von %(user)s gefunden", "Try scrolling up in the timeline to see if there are any earlier ones.": "Versuche nach oben zu scrollen, um zu sehen ob sich dort frühere Nachrichten befinden.", "For a large amount of messages, this might take some time. Please don't refresh your client in the meantime.": "Dies kann bei vielen Nachrichten einige Zeit dauern. Bitte lade die Anwendung in dieser Zeit nicht neu.", - "Deactivate user?": "Benutzer deaktivieren?", - "Deactivating this user will log them out and prevent them from logging back in. Additionally, they will leave all the rooms they are in. This action cannot be reversed. Are you sure you want to deactivate this user?": "Beim Deaktivieren wird der Benutzer abgemeldet und ein erneutes Anmelden verhindert. Zusätzlich wird er aus allen Räumen entfernt. Diese Aktion kann nicht rückgängig gemacht werden. Bist du sicher dass du diesen Benutzer deaktivieren willst?", - "Deactivate user": "Benutzer deaktivieren", + "Deactivate user?": "Konto deaktivieren?", + "Deactivating this user will log them out and prevent them from logging back in. Additionally, they will leave all the rooms they are in. This action cannot be reversed. Are you sure you want to deactivate this user?": "Beim Deaktivieren wirst du abgemeldet und ein erneutes Anmelden verhindert. Zusätzlich wirst du aus allen Räumen entfernt. Diese Aktion kann nicht rückgängig gemacht werden. Bist du sicher, dass du dieses Konto deaktivieren willst?", + "Deactivate user": "Konto deaktivieren", "Failed to deactivate user": "Benutzer konnte nicht deaktiviert werden", "Send a reply…": "Antwort senden…", "Send a message…": "Nachricht senden…", @@ -1806,7 +1806,7 @@ "Re-join": "Wieder beitreten", "You were banned from %(roomName)s by %(memberName)s": "Du wurdest von %(memberName)s aus %(roomName)s verbannt", "Something went wrong with your invite to %(roomName)s": "Bei deiner Einladung zu %(roomName)s ist ein Fehler aufgetreten", - "An error (%(errcode)s) was returned while trying to validate your invite. You could try to pass this information on to a room admin.": "Während der Verifizierung deiner Einladung ist ein Fehler (%(errcode)s) aufgetreten. Du kannst diese Information einem Raum-Administrator weitergeben.", + "An error (%(errcode)s) was returned while trying to validate your invite. You could try to pass this information on to a room admin.": "Während der Verifizierung deiner Einladung ist ein Fehler (%(errcode)s) aufgetreten. Du kannst diese Information einem Raumadministrator weitergeben.", "You can only join it with a working invite.": "Du kannst nur mit einer gültigen Einladung beitreten.", "Try to join anyway": "Dennoch versuchen beizutreten", "You can still join it because this is a public room.": "Du kannst trotzdem beitreten, weil es ein öffentlicher Raum ist.", @@ -1817,7 +1817,7 @@ "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?", "This room doesn't exist. Are you sure you're at the right place?": "Dieser Raum existiert nicht. Bist du sicher, dass du hier richtig bist?", - "Try again later, or ask a room admin to check if you have access.": "Versuche es später erneut oder bitte einen Raum-Administrator zu prüfen, ob du berechtigt bist.", + "Try again later, or ask a room admin to check if you have access.": "Versuche es später erneut oder bitte einen Raumadministrator zu prüfen, ob du berechtigt bist.", "%(errcode)s was returned while trying to access the room. If you think you're seeing this message in error, please submit a bug report.": "%(errcode)s wurde beim Versuch den Raum zu betreten, zurückgegeben. Wenn du denkst dass diese Meldung nicht korrekt ist, erstelle bitte einen Fehlerbericht.", "%(count)s unread messages including mentions.|other": "%(count)s ungelesene Nachrichten einschließlich Erwähnungen.", "%(count)s unread messages including mentions.|one": "1 ungelesene Erwähnung.", @@ -1825,7 +1825,7 @@ "%(count)s unread messages.|one": "1 ungelesene Nachricht.", "Unread mentions.": "Ungelesene Erwähnungen.", "Unread messages.": "Ungelesene Nachrichten.", - "This room has already been upgraded.": "Diese Raum wurde bereits aktualisiert.", + "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.", "Unknown Command": "Unbekannter Befehl", "Unrecognised command: %(commandText)s": "Unbekannter Befehl: %(commandText)s", @@ -1848,7 +1848,7 @@ "Start Verification": "Verifizierung starten", "Messages in this room are end-to-end encrypted.": "Nachrichten in diesem Raum sind Ende-zu-Ende verschlüsselt.", "Your messages are secured and only you and the recipient have the unique keys to unlock them.": "Diese Nachrichten sind verschlüsselt und nur du und der Empfänger könnt sie lesen.", - "In encrypted rooms, your messages are secured and only you and the recipient have the unique keys to unlock them.": "In verschlüsselten Räumen sind deine Nachrichten verschlüsselt und nur du und der Empfänger habt die Schlüssel um sie zu entschlüsseln.", + "In encrypted rooms, your messages are secured and only you and the recipient have the unique keys to unlock them.": "Nachrichten in verschlüsselten Räumen können nur von dir und vom Empfänger gelesen werden.", "Verify User": "Nutzer verifizieren", "For extra security, verify this user by checking a one-time code on both of your devices.": "Für zusätzliche Sicherheit, verifiziere diesen Nutzer, durch Vergleichen eines Einmal-Codes auf euren beiden Geräten.", "Your messages are not secure": "Deine Nachrichten sind nicht sicher", @@ -1858,9 +1858,9 @@ "Yours, or the other users’ internet connection": "Deine oder die Internetverbindung des Gegenüber", "Yours, or the other users’ session": "Deine Sitzung oder die des Gegenüber", "%(role)s in %(roomName)s": "%(role)s in %(roomName)s", - "This client does not support end-to-end encryption.": "Diese Anwendung unterstützt keine Ende-zu-Ende-Verschlüsselung.", + "This client does not support end-to-end encryption.": "Dieser Client unterstützt keine Ende-zu-Ende-Verschlüsselung.", "Verify by scanning": "Verifizierung durch Scannen eines QR-Codes", - "If you can't scan the code above, verify by comparing unique emoji.": "Wenn du den obigen Code nicht scannen kannst, verifiziere stattdessen durch den Emoji-Vergleich.", + "If you can't scan the code above, verify by comparing unique emoji.": "Wenn du den obigen Code nicht scannen kannst, verifiziere stattdessen durch den Emojivergleich.", "Verify all users in a room to ensure it's secure.": "Verifiziere alle Benutzer in einem Raum um die vollständige Sicherheit zu gewährleisten.", "In encrypted rooms, verify all users to ensure it’s secure.": "Verifiziere alle Benutzer in verschlüsselten Räumen, um die vollständige Sicherheit zu gewährleisten.", "You've successfully verified %(deviceName)s (%(deviceId)s)!": "Du hast %(deviceName)s (%(deviceId)s) erfolgreich verifiziert!", @@ -1875,7 +1875,7 @@ "Compare emoji": "Emojis vergleichen", "Message Actions": "Nachrichtenaktionen", "Show image": "Bild anzeigen", - "You have ignored this user, so their message is hidden. Show anyways.": "Du ignorierst diesen Benutzer, deshalb werden seine Nachrichten nicht angezeigt. Trotzdem anzeigen.", + "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", @@ -1890,13 +1890,13 @@ "Message deleted by %(name)s": "Nachricht von %(name)s gelöscht", "Edited at %(date)s. Click to view edits.": "Am %(date)s geändert. Klicke, um Änderungen anzuzeigen.", "Can't load this message": "Diese Nachricht kann nicht geladen werden", - "Submit logs": "Log-Dateien senden", + "Submit logs": "Protokolldateien senden", "Frequently Used": "Oft verwendet", - "Smileys & People": "Smileys & Leute", - "Animals & Nature": "Tiere & Natur", - "Food & Drink": "Essen & Trinken", + "Smileys & People": "Smileys und Leute", + "Animals & Nature": "Tiere und Natur", + "Food & Drink": "Essen und Trinken", "Activities": "Aktivitäten", - "Travel & Places": "Reisen & Orte", + "Travel & Places": "Reisen und Orte", "Objects": "Objekte", "Symbols": "Symbole", "Flags": "Flaggen", @@ -1935,20 +1935,20 @@ "Server name": "Servername", "Add a new server...": "Füge einen Server hinzu...", "%(networkName)s rooms": "%(networkName)s Räume", - "Matrix rooms": "Matrix Räume", + "Matrix rooms": "Matrixräume", "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, erstelle ein GitHub-Issue, das das Problem beschreibt.", + "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.", "Removing…": "Löschen…", "Destroy cross-signing keys?": "Cross-Signing-Schlüssel zerstören?", "Clear cross-signing keys": "Cross-Signing-Schlüssel löschen", - "Enable end-to-end encryption": "Ende-zu-Ende Verschlüsselung aktivieren", + "Enable end-to-end encryption": "Ende-zu-Ende-Verschlüsselung aktivieren", "You can’t disable this later. Bridges & most bots won’t work yet.": "Du kannst dies später nicht mehr ändern. Bridges und die meisten Bots werden nicht funktionieren.", "Server did not require any authentication": "Der Server benötigt keine Authentifizierung", "Server did not return valid authentication information.": "Der Server lieferte keine gültigen Authentifizierungsinformationen.", - "Are you sure you want to deactivate your account? This is irreversible.": "Bist du sicher dass du dein Konto deaktivieren möchtest? Dies kann nicht rückgängig gemacht werden.", + "Are you sure you want to deactivate your account? This is irreversible.": "Willst du dein Konto wirklich deaktivieren? Du kannst das nicht rückgängig machen.", "There was a problem communicating with the server. Please try again.": "Bei der Kommunikation mit dem Server ist ein Fehler aufgetreten. Bitte versuche es erneut.", "View Servers in Room": "Zeige Server im Raum", "Verification Requests": "Verifizierungsanfrage", @@ -2028,10 +2028,10 @@ "Doesn't look like a valid email address": "Das sieht nicht nach einer gültigen E-Mail-Adresse aus", "Enter phone number (required on this homeserver)": "Telefonnummer eingeben (auf diesem Heimserver erforderlich)", "Doesn't look like a valid phone number": "Das sieht nicht nach einer gültigen Telefonnummer aus", - "Sign in with SSO": "Mit Single-Sign-On anmelden", + "Sign in with SSO": "Einmalanmeldung verwenden", "Welcome to %(appName)s": "Willkommen bei %(appName)s", - "Send a Direct Message": "Sende eine Direktnachricht", - "Create a Group Chat": "Erstelle einen Gruppenchat", + "Send a Direct Message": "Direktnachricht senden", + "Create a Group Chat": "Gruppenchat erstellen", "Use lowercase letters, numbers, dashes and underscores only": "Verwende nur Kleinbuchstaben, Zahlen, Bindestriche und Unterstriche", "Enter your custom identity server URL What does this mean?": "URL deines benutzerdefinierten Identitätsservers eingeben Was bedeutet das?", "%(brand)s failed to get the public room list.": "%(brand)s konnte die Liste der öffentlichen Räume nicht laden.", @@ -2106,8 +2106,8 @@ "This requires the latest %(brand)s on your other devices:": "Dies benötigt die neuste Version von %(brand)s auf deinen anderen Geräten:", "Failed to re-authenticate due to a homeserver problem": "Erneute Authentifizierung aufgrund eines Problems im Heimserver fehlgeschlagen", "Failed to re-authenticate": "Erneute Authentifizierung fehlgeschlagen", - "Command Autocomplete": "Auto-Vervollständigung aktivieren", - "Community Autocomplete": "Community-Auto-Vervollständigung", + "Command Autocomplete": "Autovervollständigung aktivieren", + "Community Autocomplete": "Community-Autovervollständigung", "DuckDuckGo Results": "DuckDuckGo Ergebnisse", "Great! This recovery passphrase looks strong enough.": "Super! Diese Wiederherstellungspassphrase sieht stark genug aus.", "Enter a recovery passphrase": "Gib eine Wiederherstellungspassphrase ein", @@ -2129,13 +2129,13 @@ "Navigation": "Navigation", "Calls": "Anrufe", "Room List": "Raumliste", - "Autocomplete": "Auto-Vervollständigung", + "Autocomplete": "Autovervollständigung", "Alt": "Alt", - "Toggle microphone mute": "Schalte Mikrofon stumm/an", - "Toggle video on/off": "Schalte Video an/aus", - "Jump to room search": "Springe zur Raumsuche", - "Close dialog or context menu": "Schließe Dialog oder Kontextmenü", - "Cancel autocomplete": "Deaktiviere Auto-Vervollständigung", + "Toggle microphone mute": "Mikrofon an-/ausschalten", + "Toggle video on/off": "Video an-/ausschalten", + "Jump to room search": "Zur Raumsuche springen", + "Close dialog or context menu": "Dialog oder Kontextmenü schließen", + "Cancel autocomplete": "Autovervollständigung deaktivieren", "Unable to revoke sharing for email address": "Dem Teilen der E-Mail-Adresse kann nicht widerrufen werden", "Unable to validate homeserver/identity server": "Heimserver/Identitätsserver nicht validierbar", "Without completing security on this session, it won’t have access to encrypted messages.": "Ohne Abschluss der Sicherungseinrichtung in dieser Sitzung wird sie keinen Zugriff auf verschlüsselte Nachrichten erhalten.", @@ -2149,14 +2149,14 @@ "Unable to query secret storage status": "Status des sicheren Speichers kann nicht gelesen werden", "We'll store an encrypted copy of your keys on our server. Secure your backup with a recovery passphrase.": "Wir werden eine verschlüsselte Kopie deiner Schlüssel auf unserem Server speichern. Schütze deine Sicherung mit einer Wiederherstellungspassphrase.", "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 Raum-Aliases. 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 Raumaliases. Entweder erlaubt es der Server nicht oder es gab ein temporäres Problem.", "Self-verification request": "Selbstverifikationsanfrage", "or another cross-signing capable Matrix client": "oder einen anderen Matrix Client der Cross-signing fähig ist", "%(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:", "Liberate your communication": "Befreie deine Kommunikation", "Message downloading sleep time(ms)": "Wartezeit zwischen dem Herunterladen von Nachrichten (ms)", "Navigate recent messages to edit": "Letzte Nachrichten zur Bearbeitung ansehen", - "Jump to start/end of the composer": "Springe zum Anfang/Ende der Nachrichteneingabe", + "Jump to start/end of the composer": "Zu Anfang/Ende des Textfelds springen", "Navigate composer history": "Verlauf der Nachrichteneingabe durchsuchen", "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.", "Cancel replying to a message": "Nachricht beantworten abbrechen", @@ -2172,20 +2172,20 @@ "Activate selected button": "Ausgewählten Button aktivieren", "Toggle right panel": "Rechtes Panel ein-/ausblenden", "Toggle this dialog": "Diesen Dialog ein-/ausblenden", - "Move autocomplete selection up/down": "Auto-Vervollständigung nach oben/unten verschieben", + "Move autocomplete selection up/down": "Autovervollständigung nach oben/unten verschieben", "Opens chat with the given user": "Öffnet einen Chat mit diesem Benutzer", "Sends a message to the given user": "Sendet diesem Benutzer eine Nachricht", "Waiting for your other session to verify…": "Warte auf die Verifikation deiner anderen Sitzungen…", "You've successfully verified your device!": "Du hast dein Gerät erfolgreich verifiziert!", "QR Code": "QR-Code", - "To continue, use Single Sign On to prove your identity.": "Zum Fortfahren, nutze Single Sign-On um deine Identität zu bestätigen.", + "To continue, use Single Sign On to prove your identity.": "Zum Fortfahren nutze die Einmalanmeldung um deine Identität zu bestätigen.", "Confirm to continue": "Bestätige um fortzufahren", "Click the button below to confirm your identity.": "Klicke den Button unten um deine Identität zu bestätigen.", "Confirm encryption setup": "Bestätige die Einrichtung der Verschlüsselung", "Click the button below to confirm setting up encryption.": "Klick die Schaltfläche unten um die Einstellungen der Verschlüsselung zu bestätigen.", "Font scaling": "Schriftskalierung", "Font size": "Schriftgröße", - "IRC display name width": "Breite des IRC Anzeigenamens", + "IRC display name width": "Breite des IRC-Anzeigenamens", "Size must be a number": "Schriftgröße muss eine Zahl sein", "Custom font size can only be between %(min)s pt and %(max)s pt": "Eigene Schriftgröße kann nur eine Zahl zwischen %(min)s pt und %(max)s pt sein", "Use between %(min)s pt and %(max)s pt": "Verwende eine Zahl zwischen %(min)s pt und %(max)s pt", @@ -2195,14 +2195,14 @@ "Upload a file": "Eine Datei hochladen", "Dismiss read marker and jump to bottom": "Entferne Lesemarker und springe nach unten", "Room name or address": "Raumname oder -adresse", - "Joins room with given address": "Tritt dem Raum unter der angegebenen Adresse bei", + "Joins room with given address": "Tritt dem Raum mit der angegebenen Adresse bei", "Unrecognised room address:": "Unbekannte Raumadresse:", "Help us improve %(brand)s": "Hilf uns, %(brand)s zu verbessern", "Send anonymous usage data which helps us improve %(brand)s. This will use a cookie.": "Hilf uns, %(brand)s zu verbessern, indem du anonyme Nutzungsdaten schickst. Dies wird ein Cookie verwenden.", "I want to help": "Ich möchte helfen", - "Your homeserver has exceeded its user limit.": "Dein Heimserver hat das Benutzerlimit erreicht.", + "Your homeserver has exceeded its user limit.": "Dein Heimserver hat das Benutzergrenzwert erreicht.", "Your homeserver has exceeded one of its resource limits.": "Dein Heimserver hat eine seiner Ressourcengrenzen erreicht.", - "Contact your server admin.": "Kontaktiere deine Heimserver-Administration.", + "Contact your server admin.": "Kontaktiere deine Heimserveradministration.", "Ok": "Ok", "Set password": "Setze Passwort", "To return to your account in future you need to set a password": "Um dein Konto zukünftig wieder verwenden zu können, setze ein Passwort", @@ -2225,7 +2225,7 @@ "Address (optional)": "Adresse (optional)", "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 Server-Administration 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 Serveradministration 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.", "Set a room address to easily share your room with other people.": "Vergebe eine Raum-Adresse, um diesen Raum auf einfache Weise mit anderen Personen teilen zu können.", @@ -2257,15 +2257,15 @@ "Light": "Hell", "Dark": "Dunkel", "Use the improved room list (will refresh to apply changes)": "Verwende die verbesserte Raumliste (lädt die Anwendung neu)", - "Use custom size": "Verwende individuelle Größe", - "Hey you. You're the best!": "Hey du. Du bist der Beste!", + "Use custom size": "Andere Schriftgröße verwenden", + "Hey you. You're the best!": "Hey du. Du bist großartig!", "Message layout": "Nachrichtenlayout", "Compact": "Kompakt", "Modern": "Modern", - "Use a system font": "Verwende eine System-Schriftart", - "System font name": "System-Schriftart", + "Use a system font": "Systemschriftart verwenden", + "System font name": "Systemschriftart", "Customise your appearance": "Verändere das Erscheinungsbild", - "Appearance Settings only affect this %(brand)s session.": "Einstellungen zum Erscheinungsbild wirken sich nur auf diese %(brand)s Sitzung aus.", + "Appearance Settings only affect this %(brand)s session.": "Einstellungen zum Erscheinungsbild wirken sich nur auf diese Sitzung aus.", "The authenticity of this encrypted message can't be guaranteed on this device.": "Die Echtheit dieser verschlüsselten Nachricht kann auf diesem Gerät nicht garantiert werden.", "You joined the call": "Du bist dem Anruf beigetreten", "%(senderName)s joined the call": "%(senderName)s ist dem Anruf beigetreten", @@ -2275,7 +2275,7 @@ "Call ended": "Anruf beendet", "You started a call": "Du hast einen Anruf gestartet", "%(senderName)s started a call": "%(senderName)s hat einen Anruf gestartet", - "Waiting for answer": "Warte auf Antwort", + "Waiting for answer": "Warte auf eine Antwort", "%(senderName)s is calling": "%(senderName)s ruft an", "You created the room": "Du hast den Raum erstellt", "%(senderName)s created the room": "%(senderName)s hat den Raum erstellt", @@ -2321,8 +2321,8 @@ "%(senderName)s invited %(targetName)s": "%(senderName)s hat %(targetName)s eingeladen", "You changed the room topic": "Du hast das Raumthema geändert", "%(senderName)s changed the room topic": "%(senderName)s hat das Raumthema geändert", - "New spinner design": "Neue Warteanimation", - "Use a more compact ‘Modern’ layout": "Kompakteres 'modernes' Layout verwenden", + "New spinner design": "Neue Ladeanimation", + "Use a more compact ‘Modern’ layout": "Modernes kompaktes Layout", "Message deleted on %(date)s": "Nachricht am %(date)s gelöscht", "Wrong file type": "Falscher Dateityp", "Wrong Recovery Key": "Falscher Wiederherstellungsschlüssel", @@ -2337,18 +2337,18 @@ "Are you sure you want to cancel entering passphrase?": "Bist du sicher, dass du die Eingabe der Passphrase abbrechen möchtest?", "Use your account to sign in to the latest version": "Melde dich mit deinem Account in der neuesten Version an", "* %(senderName)s %(emote)s": "* %(senderName)s %(emote)s", - "Enable advanced debugging for the room list": "Erweiterte Fehlersuche für die Raumliste aktivieren", - "Enable experimental, compact IRC style layout": "Kompaktes Layout im IRC-Stil verwenden(experimentell)", + "Enable advanced debugging for the room list": "Erweiterte Fehlersuche für die Raumliste", + "Enable experimental, compact IRC style layout": "Kompaktes Layout im IRC-Stil (experimentell)", "User menu": "Benutzermenü", "%(brand)s Web": "%(brand)s Web", "%(brand)s Desktop": "%(brand)s Desktop", "%(brand)s iOS": "%(brand)s iOS", "%(brand)s X for Android": "%(brand)s X für Android", "We’re excited to announce Riot is now Element": "Wir freuen uns zu verkünden, dass Riot jetzt Element ist", - "%(brand)s can't securely cache encrypted messages locally while running in a web browser. Use %(brand)s Desktop for encrypted messages to appear in search results.": "%(brand)s kann verschlüsselte Nachrichten nicht sicher während der Ausführung im Browser durchsuchen. Benutze %(brand)s Desktop, um verschlüsselte Nachrichten in den Suchergebnissen angezeigt zu bekommen.", + "%(brand)s can't securely cache encrypted messages locally while running in a web browser. Use %(brand)s Desktop for encrypted messages to appear in search results.": "Das Durchsuchen von verschlüsselten Nachrichten wird aus Sicherheitsgründen nur von %(brand)s Desktop unterstützt. Hier geht's zum Download.", "Show rooms with unread messages first": "Räume mit ungelesenen Nachrichten zuerst zeigen", "Show previews of messages": "Nachrichtenvorschau anzeigen", - "Use default": "Standardeinstellungen benutzen", + "Use default": "Standardeinstellungen", "Mentions & Keywords": "Erwähnungen und Schlüsselwörter", "Notification options": "Benachrichtigungsoptionen", "Forget Room": "Raum vergessen", @@ -2363,9 +2363,9 @@ "We’re excited to announce Riot is now Element!": "Wir freuen uns bekanntzugeben: Riot ist jetzt Element!", "Learn more at element.io/previously-riot": "Erfahre mehr unter element.io/previously-riot", "The person who invited you already left the room.": "Die Person, die dich eingeladen hat, hat den Raum bereits verlassen.", - "The person who invited you already left the room, or their server is offline.": "Die Person, die dich eingeladen hat, hat den Raum bereits verlassen oder ihr Server ist offline.", + "The person who invited you already left the room, or their server is offline.": "Die Person, die dich eingeladen hat, hat den Raum bereits verlassen oder ihr Server ist nicht erreichbar bzw. aus.", "Change notification settings": "Benachrichtigungseinstellungen ändern", - "Your server isn't responding to some requests.": "Dein Server antwortet nicht auf einige Anfragen.", + "Your server isn't responding to some requests.": "Dein Server antwortet auf einige Anfragen nicht.", "Go to Element": "Zu Element gehen", "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.", @@ -2419,9 +2419,9 @@ "%(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 Server-Fehler beim Versuch den Raum zu verlassen", + "Unexpected server error trying to leave the room": "Unerwarteter Serverfehler beim Versuch den Raum zu verlassen", "Error leaving room": "Fehler beim Verlassen des Raums", - "Communities v2 prototypes. Requires compatible homeserver. Highly experimental - use with caution.": "Communities v2 Prototyp. Benötigt einen kompatiblen Heimserver. Höchst experimentell - mit Vorsicht verwenden.", + "Communities v2 prototypes. Requires compatible homeserver. Highly experimental - use with caution.": "[Veraltet] Communities v2 Prototyp. Benötigt einen kompatiblen Heimserver. Höchst experimentell - mit Vorsicht verwenden.", "Explore rooms in %(communityName)s": "Räume in %(communityName)s erkunden", "Set up Secure Backup": "Schlüsselsicherung einrichten", "Information": "Information", @@ -2429,7 +2429,7 @@ "Send %(count)s invites|other": "%(count)s Einladungen senden", "There was an error creating your community. The name may be taken or the server is unable to process your request.": "Beim Erstellen deiner Community ist ein Fehler aufgetreten. Entweder ist der Name schon vergeben oder der Server kann die Anfrage nicht verarbeiten.", "Community ID: +:%(domain)s": "Community-ID: +:%(domain)s", - "Explore community rooms": "Entdecke Community Räume", + "Explore community rooms": "Entdecke Communityräume", "You can change this later if needed.": "Falls nötig, kannst du es später noch ändern.", "What's the name of your community or team?": "Welchen Namen hat deine Community oder dein Team?", "Enter name": "Namen eingeben", @@ -2445,9 +2445,9 @@ "Use this when referencing your community to others. The community ID cannot be changed.": "Verwende dies, um deine Community von andere referenzieren zu lassen. Die Community-ID kann später nicht geändert werden.", "Private rooms can be found and joined by invitation only. Public rooms can be found and joined by anyone.": "Private Räume können nur auf Einladung gefunden und betreten werden. Öffentliche Räume können von jedem gefunden und betreten werden.", "Private rooms can be found and joined by invitation only. Public rooms can be found and joined by anyone in this community.": "Private Räume können nur auf Einladung gefunden und betreten werden. Öffentliche Räume können von jedem in dieser Community gefunden und betreten 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 internen Teams auf deinem Heimserver verwendet wird. 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 externen Teams auf deren Home-Server verwendet wird. Dies kann später nicht mehr geändert werden.", - "Block anyone not part of %(serverName)s from ever joining this room.": "Blockiere alle, die nicht Teil von %(serverName)s sind, diesen Raum jemals zu betreten.", + "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.", + "Block anyone not part of %(serverName)s from ever joining this room.": "Betreten nur für Nutzer von %(serverName)s erlauben.", "Privacy": "Privatsphäre", "There was an error updating your community. The server is unable to process your request.": "Beim Aktualisieren deiner Community ist ein Fehler aufgetreten. Der Server kann deine Anfrage nicht verarbeiten.", "Update community": "Community aktualisieren", @@ -2478,9 +2478,9 @@ "Group call modified by %(senderName)s": "Gruppenanruf wurde von %(senderName)s verändert", "Group call started by %(senderName)s": "Gruppenanruf von %(senderName)s gestartet", "Group call ended by %(senderName)s": "Gruppenanruf wurde von %(senderName)s beendet", - "Cross-signing is ready for use.": "Cross-Signing ist bereit zur Anwendung.", - "Cross-signing is not set up.": "Cross-Signing wurde nicht eingerichtet.", - "Backup version:": "Backup-Version:", + "Cross-signing is ready for use.": "Quersignaturen sind bereits in Anwendung.", + "Cross-signing is not set up.": "Quersignierung wurde nicht eingerichtet.", + "Backup version:": "Version der Sicherung:", "Algorithm:": "Algorithmus:", "Back up your encryption keys with your account data in case you lose access to your sessions. Your keys will be secured with a unique Recovery Key.": "Sichere deine Verschlüsselungsschlüssel mit deinen Kontodaten, falls du den Zugriff auf deine Sitzungen verlierst. Deine Schlüssel werden mit einem eindeutigen Wiederherstellungsschlüssel gesichert.", "Backup key stored:": "Sicherungsschlüssel gespeichert:", @@ -2492,11 +2492,11 @@ "End Call": "Anruf beenden", "Remove the group call from the room?": "Konferenzgespräch aus diesem Raum entfernen?", "You don't have permission to remove the call from the room": "Du hast keine Berechtigung um den Konferenzanruf aus dem Raum zu entfernen", - "Safeguard against losing access to encrypted messages & data": "Schütze dich vor dem Verlust des Zugriffs auf verschlüsselte Nachrichten und Daten", + "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", - "Edit widgets, bridges & bots": "Widgets, Bridges & Bots bearbeiten", - "Add widgets, bridges & bots": "Widgets, Bridges & Bots hinzufügen", + "Edit widgets, bridges & bots": "Widgets, Brücken und Bots bearbeiten", + "Add widgets, bridges & bots": "Widgets, Brücken und Bots hinzufügen", "You can only pin 2 widgets at a time": "Du kannst jeweils nur 2 Widgets anheften", "Minimize widget": "Widget minimieren", "Maximize widget": "Widget maximieren", @@ -2516,10 +2516,10 @@ "Join the conference at the top of this room": "Konferenzgespräch oben in diesem Raum beitreten", "Join the conference from the room information card on the right": "Konferenzgespräch in den Rauminformationen rechts beitreten", "Video conference ended by %(senderName)s": "Videokonferenz von %(senderName)s beendet", - "Video conference updated by %(senderName)s": "Videokonferenz wurde von %(senderName)s aktualisiert", - "Video conference started by %(senderName)s": "Videokonferenz wurde von %(senderName)s gestartet", + "Video conference updated by %(senderName)s": "Videokonferenz wurde %(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": "Profil speichern fehlgeschlagen", + "Failed to save your profile": "Speichern des Profils fehlgeschlagen", "The operation could not be completed": "Die Operation konnte nicht abgeschlossen werden", "Remove messages sent by others": "Nachrichten von anderen entfernen", "Starting camera...": "Starte Kamera...", @@ -2535,14 +2535,14 @@ "Hide Widgets": "Widgets verstecken", "%(senderName)s declined the call.": "%(senderName)s hat den Anruf abgelehnt.", "(an error occurred)": "(ein Fehler ist aufgetreten)", - "(their device couldn't start the camera / microphone)": "(ihr/sein Gerät konnte Kamera / Mikrophon nicht starten)", + "(their device couldn't start the camera / microphone)": "(Gerät des Gegenübers konnte Kamera oder Mikrofon nicht starten)", "(connection failed)": "(Verbindung fehlgeschlagen)", "🎉 All servers are banned from participating! This room can no longer be used.": "🎉 Alle Server sind von der Teilnahme ausgeschlossen! Dieser Raum kann nicht mehr genutzt werden.", "%(senderDisplayName)s changed the server ACLs for this room.": "%(senderDisplayName)s hat die Server-ACLs für diesen Raum geändert.", "%(senderDisplayName)s set the server ACLs for this room.": "%(senderDisplayName)s hat die Server-ACLs für diesen Raum gesetzt.", - "The call was answered on another device.": "Der Anruf wurde an einem anderen Gerät angenommen.", + "The call was answered on another device.": "Der Anruf wurde auf einem anderen Gerät angenommen.", "Answered Elsewhere": "Anderswo beantwortet", - "The call could not be established": "Der Anruf konnte nicht hergestellt werden", + "The call could not be established": "Der Anruf kann nicht getätigt werden", "The other party declined the call.": "Die andere Seite hat den Anruf abgelehnt.", "Call Declined": "Anruf abgelehnt", "Data on this screen is shared with %(widgetDomain)s": "Daten auf diesem Bildschirm werden mit %(widgetDomain)s geteilt", @@ -2552,7 +2552,7 @@ "Report a bug": "Einen Fehler melden", "Add comment": "Kommentar hinzufügen", "Rate %(brand)s": "%(brand)s bewerten", - "Feedback sent": "Feedback gesendet", + "Feedback sent": "Rückmeldung gesendet", "Takes the call in the current room off hold": "Beendet das Halten des Anrufs", "Places the call in the current room on hold": "Den aktuellen Anruf halten", "Uzbekistan": "Usbekistan", @@ -2560,17 +2560,17 @@ "Send stickers into your active room": "Sticker in deinen aktiven Raum senden", "Change which room you're viewing": "Ändern, welchen Raum du siehst", "Change the topic of this room": "Das Thema von diesem Raum ändern", - "See when the topic changes in this room": "Sehen wenn sich das Thema in diesem Raum ändert", + "See when the topic changes in this room": "Sehen, wenn sich das Thema in diesem Raum ändert", "Change the topic of your active room": "Das Thema von deinem aktiven Raum ändern", - "See when the topic changes in your active room": "Sehen wenn sich das Thema in deinem aktiven Raum ändert", + "See when the topic changes in your active room": "Sehen, wenn sich das Thema im aktuellen Raum ändert", "Change the name of this room": "Name von diesem Raum ändern", "See when the name changes in this room": "Sehen wenn sich der Name in diesem Raum ändert", "Change the name of your active room": "Den Namen deines aktiven Raums ändern", "See when the name changes in your active room": "Sehen wenn der Name sich in deinem aktiven Raum ändert", "Change the avatar of this room": "Icon von diesem Raum ändern", - "See when the avatar changes in this room": "Sehen wenn der Avatar sich in diesem Raum ändert", + "See when the avatar changes in this room": "Sehen, wenn sich das Icon des Raums ändert", "Change the avatar of your active room": "Den Avatar deines aktiven Raums ändern", - "See when the avatar changes in your active room": "Sehen wenn ein Avatar in deinem aktiven Raum geändert wird", + "See when the avatar changes in your active room": "Sehen, wenn das Icon in deinem aktiven Raum geändert wird", "Send stickers to this room as you": "Einen Sticker in diesen Raum senden", "See when a sticker is posted in this room": "Sehe wenn ein Sticker in diesen Raum gesendet wird", "Send stickers to your active room as you": "Einen Sticker als du in deinen aktiven Raum senden", @@ -2597,7 +2597,7 @@ "See videos posted to your active room": "In deinen aktiven Raum gesendete Videos anzeigen", "See videos posted to this room": "In diesen Raum gesendete Videos anzeigen", "Send images as you in this room": "Bilder als du in diesen Raum senden", - "Send images as you in your active room": "Bilder als du in deinem aktiven Raum senden", + "Send images as you in your active room": "Sende Bilder in den aktuellen Raum", "See images posted to this room": "In diesen Raum gesendete Bilder anzeigen", "See images posted to your active room": "In deinen aktiven Raum gesendete Bilder anzeigen", "Send videos as you in this room": "Videos als du in diesen Raum senden", @@ -2616,12 +2616,12 @@ "New version of %(brand)s is available": "Neue Version von %(brand)s verfügbar", "You ended the call": "Du hast den Anruf beendet", "%(senderName)s ended the call": "%(senderName)s hat den Anruf beendet", - "Use Command + Enter to send a message": "Benutze Betriebssystemtaste + Enter um eine Nachricht zu senden", - "Use Ctrl + Enter to send a message": "Benutze Strg + Enter um eine Nachricht zu senden", + "Use Command + Enter to send a message": "Benutze Betriebssystemtaste + Eingabe um eine Nachricht zu senden", + "Use Ctrl + Enter to send a message": "Nachrichten mit Strg + Enter senden", "Call Paused": "Anruf pausiert", "Securely cache encrypted messages locally for them to appear in search results, using %(size)s to store messages from %(rooms)s rooms.|other": "Verschlüsselte Nachrichten sicher lokal zwischenspeichern, um sie in Suchergebnissen finden zu können. Es werden %(size)s benötigt, um die Nachrichten von %(rooms)s Räumen zu speichern.", "Securely cache encrypted messages locally for them to appear in search results, using %(size)s to store messages from %(rooms)s rooms.|one": "Verschlüsselte Nachrichten sicher lokal zwischenspeichern, um sie in Suchergebnissen finden zu können. Es werden %(size)s benötigt, um die Nachrichten vom Raum %(rooms)s zu speichern.", - "Only the two of you are in this conversation, unless either of you invites anyone to join.": "Nur ihr zwei seid in dieser Konversation, außer ihr lädt jemanden neues ein.", + "Only the two of you are in this conversation, unless either of you invites anyone to join.": "Nur ihr beide nehmt an dieser Konversation teil, es sei denn, ihr ladet jemanden ein.", "This is the beginning of your direct message history with .": "Dies ist der Beginn deiner Direktnachrichten mit .", "Topic: %(topic)s (edit)": "Thema: %(topic)s (ändern)", "Topic: %(topic)s ": "Thema: %(topic)s ", @@ -2649,7 +2649,7 @@ "Decline All": "Alles ablehnen", "Go to Home View": "Zur Startseite gehen", "Filter rooms and people": "Räume und Personen filtern", - "%(creator)s created this DM.": "%(creator)s hat diese DM erstellt.", + "%(creator)s created this DM.": "%(creator)s hat diese Direktnachricht erstellt.", "Now, let's help you get started": "Nun, lassen Sie uns Ihnen den Einstieg erleichtern", "Welcome %(name)s": "Willkommen %(name)s", "Add a photo so people know it's you.": "Fügen Sie ein Foto hinzu, damit die Leute wissen, dass Sie es sind.", @@ -2659,7 +2659,7 @@ "Enter email address": "E-Mail-Adresse eingeben", "Open the link in the email to continue registration.": "Öffnen Sie den Link in der E-Mail, um mit der Registrierung fortzufahren.", "A confirmation email has been sent to %(emailAddress)s": "Eine Bestätigungs-E-Mail wurde an %(emailAddress)s gesendet", - "Use the + to make a new room or explore existing ones below": "Benutze das + um einen neuen Raum zu erstellen oder darunter um existierende Räume zu suchen", + "Use the + to make a new room or explore existing ones below": "Benutze das + um einen neuen Raum zu erstellen oder um existierende Räume zu entdecken", "Return to call": "Zurück zum Anruf", "Fill Screen": "Bildschirm ausfüllen", "Voice Call": "Sprachanruf", @@ -2916,7 +2916,7 @@ "United Kingdom": "Großbritannien", "We call the places you where you can host your account ‘homeservers’.": "Orte, an denen du dein Benutzerkonto hosten kannst, nennen wir \"Homeserver\".", "Specify a homeserver": "Gib einen Homeserver an", - "Render LaTeX maths in messages": "Zeige LaTeX-Matheformeln in Nachrichten an", + "Render LaTeX maths in messages": "LaTeX-Matheformeln", "Decide where your account is hosted": "Gib an wo dein Benutzerkonto gehostet werden soll", "Already have an account? Sign in here": "Hast du schon ein Benutzerkonto? Melde dich hier an", "%(ssoButtons)s Or %(usernamePassword)s": "%(ssoButtons)s oder %(usernamePassword)s", @@ -2945,16 +2945,16 @@ "You can use the custom server options to sign into other Matrix servers by specifying a different homeserver URL. This allows you to use Element with an existing Matrix account on a different homeserver.": "Du kannst in den benutzerdefinierten Serveroptionen eine andere Heimserver-URL angeben, um dich bei anderen Matrixservern anzumelden.", "Server Options": "Servereinstellungen", "No other application is using the webcam": "keine andere Anwendung auf die Webcam zugreift", - "Permission is granted to use the webcam": "auf die Webcam zugegriffen werden darf", + "Permission is granted to use the webcam": "Zugriff auf Webcam gestattet", "A microphone and webcam are plugged in and set up correctly": "Mikrofon und Webcam eingesteckt und richtig eingerichtet sind", "Call failed because no microphone could not be accessed. Check that a microphone is plugged in and set up correctly.": "Der Anruf ist fehlgeschlagen weil nicht auf das Mikrofon zugegriffen werden konnte. Stelle sicher, dass das Mikrofon richtig eingesteckt und eingerichtet ist.", "Call failed because no webcam or microphone could not be accessed. Check that:": "Der Anruf ist fehlgeschlagen weil nicht auf das Mikrofon oder die Webcam zugegriffen werden konnte. Stelle sicher, dass:", "Unable to access webcam / microphone": "Auf Webcam / Mikrofon konnte nicht zugegriffen werden", "Unable to access microphone": "Es konnte nicht auf das Mikrofon zugegriffen werden", - "Host account on": "Benutzer*innenkonto betreiben an", + "Host account on": "Konto betreiben auf", "Hold": "Halten", "Resume": "Fortsetzen", - "We call the places where you can host your account ‘homeservers’.": "Den Ort, an dem du dein Benutzer*innenkonto betreibst, nennen wir „Heimserver“.", + "We call the places where you can host your account ‘homeservers’.": "Den Ort, an dem du dein Konto betreibst, nennen wir „Heimserver“.", "Invalid URL": "Ungültiger Link", "Unable to validate homeserver": "Überprüfung des Heimservers nicht möglich", "%(name)s paused": "%(name)s hat pausiert", @@ -3003,7 +3003,7 @@ "Workspace: ": "Arbeitsraum: ", "Dial pad": "Wähltastatur", "There was an error looking up the phone number": "Beim Suchen der Telefonnummer ist ein Fehler aufgetreten", - "Change which room, message, or user you're viewing": "Ändere welchen Raum, Nachricht oder Nutzer du siehst", + "Change which room, message, or user you're viewing": "Ändere den sichtbaren Raum, Nachricht oder Nutzer", "Unable to look up phone number": "Telefonnummer konnte nicht gefunden werden", "This session has detected that your Security Phrase and key for Secure Messages have been removed.": "In dieser Sitzung wurde festgestellt, dass deine Sicherheitsphrase und dein Schlüssel für sichere Nachrichten entfernt wurden.", "A new Security Phrase and key for Secure Messages have been detected.": "Eine neue Sicherheitsphrase und ein neuer Schlüssel für sichere Nachrichten wurden erkannt.", @@ -3023,32 +3023,32 @@ "Access your secure message history and set up secure messaging by entering your Security Key.": "Greife auf deinen sicheren Chatverlauf zu und richte die sichere Nachrichtenübermittlung 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", "Security Key mismatch": "Nicht übereinstimmende Sicherheitsschlüssel", - "Set my room layout for everyone": "Dieses Raum-Layout für alle setzen", + "Set my room layout for everyone": "Diese Raumgestaltung für alle setzen", "%(senderName)s has updated the widget layout": "%(senderName)s hat das Widget-Layout aktualisiert", - "Search (must be enabled)": "Suche (muss aktiviert sein)", + "Search (must be enabled)": "Suchen (muss in den Einstellungen aktiviert sein)", "Remember this": "Dies merken", "The widget will verify your user ID, but won't be able to perform actions for you:": "Das Widget überprüft deine Nutzer-ID, kann jedoch keine Aktionen für dich ausführen:", "Allow this widget to verify your identity": "Erlaube diesem Widget deine Identität zu überprüfen", "Use Command + F to search": "Nutze Befehlstaste (⌘) + F zum Suchen", - "Use Ctrl + F to search": "Nutze Strg + F zum Suchen", + "Use Ctrl + F to search": "Strg + F zum Suchen", "Element Web is currently experimental on mobile. The native apps are recommended for most people.": "Element Web ist derzeit experimentell auf mobilen Endgeräten. Die nativen Apps werden empfohlen.", - "Converts the DM to a room": "Wandelt die Direktnachricht zu Raum um", - "Converts the room to a DM": "Wandelt den Raum zu Direktnachricht um", + "Converts the DM to a room": "Wandelt die Direktnachricht in einen Raum um", + "Converts the room to a DM": "Wandelt den Raum in eine Direktnachricht um", "Something went wrong in confirming your identity. Cancel and try again.": "Bei der Bestätigung deiner Identität ist ein Fehler aufgetreten. Abbrechen und erneut versuchen.", "Use app": "App verwenden", "Element Web is experimental on mobile. For a better experience and the latest features, use our free native app.": "Element Web ist auf mobilen Endgeräten experimentell. Für eine bessere Erfahrung und die neuesten Erweiterungen, nutze unsere freie, native App.", "Use app for a better experience": "Nutze die App für eine bessere Erfahrung", - "We asked the browser to remember which homeserver you use to let you sign in, but unfortunately your browser has forgotten it. Go to the sign in page and try again.": "Wir haben deinen Browser gebeten, sich zu merken, bei welchem Homeserver du dich anmeldest, aber dein Browser hat dies leider vergessen. Gehe zur Anmeldeseite und versuche es erneut.", - "Show stickers button": "Sticker-Schaltfläche anzeigen", - "Your homeserver rejected your log in attempt. This could be due to things just taking too long. Please try again. If this continues, please contact your homeserver administrator.": "Dein Homeserver hat deinen Anmeldeversuch abgelehnt. Vielleicht dauert der Prozess einfach zu lange. Bitte versuche es erneut. Wenn dies öfters passiert, wende dich bitte an deine Homeserver-Administration.", - "Your homeserver was unreachable and was not able to log you in. Please try again. If this continues, please contact your homeserver administrator.": "Dein Homeserver war nicht erreichbar und konnte dich nicht anmelden. Bitte versuche es erneut. Wenn dies so weitergeht, wende dich bitte an deinem Homeserver-Administrator.", + "We asked the browser to remember which homeserver you use to let you sign in, but unfortunately your browser has forgotten it. Go to the sign in page and try again.": "Wir haben deinen Browser gebeten, sich zu merken, bei welchem Heimserver du dich anmeldest, aber dein Browser hat dies leider vergessen. Gehe zur Anmeldeseite und versuche es erneut.", + "Show stickers button": "Stickerschaltfläche", + "Your homeserver rejected your log in attempt. This could be due to things just taking too long. Please try again. If this continues, please contact your homeserver administrator.": "Dein Heimserver hat deinen Anmeldeversuch abgelehnt. Vielleicht dauert der Prozess einfach zu lange. Bitte versuche es erneut. Wenn dies öfters passiert, wende dich bitte an deine Heimserveradministrator.", + "Your homeserver was unreachable and was not able to log you in. Please try again. If this continues, please contact your homeserver administrator.": "Dein Heimserver war nicht erreichbar und konnte dich nicht anmelden. Bitte versuche es erneut. Wenn dies öfters passiert, wende dich bitte an deine Heimserveradministrator.", "We couldn't log you in": "Wir konnten dich nicht anmelden", "Windows": "Fenster", "Screens": "Bildschirme", "Share your screen": "Bildschirm teilen", "Recently visited rooms": "Kürzlich besuchte Räume", - "Show line numbers in code blocks": "Zeilennummern in Code-Blöcken anzeigen", - "Expand code blocks by default": "Code-Blöcke standardmäßig erweitern", + "Show line numbers in code blocks": "Zeilennummern in Codeblöcken", + "Expand code blocks by default": "Lange Codeblöcke vollständig anzeigen", "Try again": "Erneut versuchen", "Upgrade to pro": "Hochstufen zu Pro", "Minimize dialog": "Dialog minimieren", @@ -3059,7 +3059,7 @@ "Cookie Policy": "Cookie-Richtlinie", "Learn more in our , and .": "Erfahre mehr in unserer , und .", "Failed to connect to your homeserver. Please close this dialog and try again.": "Verbindung zum Homeserver fehlgeschlagen. Bitte schließe diesen Dialog and versuche es erneut.", - "Abort": "Abbrechen", + "Abort": "Beenden", "Upgrade to %(hostSignupBrand)s": "Zu %(hostSignupBrand)s upgraden", "Edit Values": "Werte bearbeiten", "Value in this room:": "Wert in diesem Raum:", @@ -3070,51 +3070,276 @@ "Value": "Wert", "Setting ID": "Einstellungs-ID", "Failed to save settings": "Einstellungen konnten nicht gespeichert werden", - "Show chat effects (animations when receiving e.g. confetti)": "Animierte Chateffekte zeigen, wenn z.B. Konfetti-Emojis erhalten werden", + "Show chat effects (animations when receiving e.g. confetti)": "Chateffekte bei manchen Emojis", "Save setting values": "Einstellungswerte speichern", "Caution:": "Vorsicht:", "Settable at global": "Global einstellbar", "Setting definition:": "Definition der Einstellung:", "Value in this room": "Wert in diesem Raum", - "Settings Explorer": "Einstellungs-Explorer", - "Values at explicit levels": "Werte für explizite Levels", + "Settings Explorer": "Einstellungsdurchsucher", + "Values at explicit levels": "Werte für explizite Stufen", "Settable at room": "Für den Raum einstellbar", "Room name": "Raumname", "%(count)s members|other": "%(count)s Mitglieder", "Accept Invite": "Einladung akzeptieren", "Save changes": "Änderungen speichern", "Undo": "Rückgängig", - "Save Changes": "Änderungen Speichern", - "View dev tools": "Entwicklereinstellungen anzeigen", + "Save Changes": "Speichern", + "View dev tools": "Entwicklerwerkzeuge", "Apply": "Anwenden", "Create a new room": "Neuen Raum erstellen", "Suggested Rooms": "Vorgeschlagene Räume", - "Add existing room": "Bereits existierenden Raum hinzufügen", + "Add existing room": "Existierenden Raum", "Send message": "Nachricht senden", "New room": "Neuer Raum", "Share invite link": "Einladungslink teilen", "Click to copy": "Klicken um zu kopieren", - "Collapse space panel": "Space-Feld zuklappen", - "Expand space panel": "Space-Feld aufweiten", + "Collapse space panel": "Space-Feld einklappen", + "Expand space panel": "Space-Feld aufklappen", "Creating...": "Erstelle...", "You can change these at any point.": "Du kannst diese jederzeit ändern.", "Your private space": "Dein privater Space", "Your public space": "Dein öffentlicher Space", - "You can change this later": "Du kannst dies später ändern", - "Invite only, best for yourself or teams": "Nur Einladen - am Besten für dich selbst oder Teams", + "You can change this later": "Du kannst die Sichtbarkeit später ändern", + "Invite only, best for yourself or teams": "Nur Eingeladene können beitreten - am besten für dich selbst oder Teams", "Open space for anyone, best for communities": "Öffne den Space für alle - am Besten für Communities", "Private": "Privat", "Public": "Öffentlich", "Spaces are new ways to group rooms and people. To join an existing space you’ll need an invite": "Spaces sind ein neuer Weg Räume und Leute zu gruppieren. Um einen bestehenden Space zu betreten brauchst du eine Einladung", - "Create a space": "Einen Space erstellen", + "Create a space": "Neuen Space erstellen", "Delete": "Löschen", "This homeserver has been blocked by its administrator.": "Dieser Heimserver wurde von ihrer 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", - "Jump to the bottom of the timeline when you send a message": "Nach dem Senden einer Nachricht im Chatverlauf nach unten scrollen", + "Jump to the bottom of the timeline when you send a message": "Nach Senden einer Nachricht im Chatverlauf nach unten scrollen", "Empty room": "Leerer Raum", "Your message was sent": "Die Nachricht wurde gesendet", "Encrypting your message...": "Nachricht wird verschlüsselt...", - "Sending your message...": "Nachricht wird gesendet..." + "Sending your message...": "Nachricht wird gesendet...", + "Leave space": "Space verlassen", + "Share your public space": "Teile deinen öffentlichen Space mit der Welt", + "Invite members": "Mitglieder einladen", + "Add some details to help people recognise it.": "Gib einige Infos über deinen neuen Space an.", + "Spaces are new ways to group rooms and people. To join an existing space you'll need an invite.": "Mit Matrix-Spaces kannst du Räume und Personen gruppieren. Um einen existierenden Space zu betreten, musst du eingeladen werden.", + "Spaces prototype. Incompatible with Communities, Communities v2 and Custom Tags. Requires compatible homeserver for some features.": "Spaces Prototyp. Inkompatibel mit Communities, Communities v2 und benutzerdefinierte Tags. Für einige Funktionen wird ein kompatibler Heimserver benötigt.", + "Invite to this space": "In diesen Space enladen", + "Verify this login to access your encrypted messages and prove to others that this login is really you.": "Verifiziere diese Anmeldung um deine Identität zu bestätigen und Zugriff auf verschlüsselte Nachrichten zu erhalten.", + "What projects are you working on?": "An welchen Projekten arbeitest du gerade?", + "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", + "Welcome to ": "Willkommen bei ", + "Add existing rooms & spaces": "Existierende Räume oder Spaces hinzufügen", + "Private space": "Privater Space", + "Public space": "Öffentlicher Space", + " invites you": "Du wirst von eingeladen", + "No results found": "Keine Ergebnisse", + "Failed to remove some rooms. Try again later": "Einige Räume konnten nicht entfernt werden. Versuche es bitte später nocheinmal", + "%(count)s rooms and 1 space|one": "%(count)s Raum und 1 space", + "%(count)s rooms and 1 space|other": "%(count)s Räume und 1 Space", + "%(count)s rooms and %(numSpaces)s spaces|one": "%(count)s Raum und %(numSpaces)s Spaces", + "%(count)s rooms and %(numSpaces)s spaces|other": "%(count)s Räume und %(numSpaces)s Spaces", + "Suggested": "Vorgeschlagen", + "%(count)s rooms|one": "%(count)s Raum", + "%(count)s rooms|other": "%(count)s Räume", + "%(count)s members|one": "%(count)s Mitglied", + "%(count)s messages deleted.|one": "%(count)s Nachricht gelöscht.", + "%(count)s messages deleted.|other": "%(count)s Nachrichten gelöscht.", + "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", + "Unable to start audio streaming.": "Audiostream kann nicht gestartet werden.", + "Leave Space": "Space verlassen", + "Make this space private": "Diesen Space als Privat markieren", + "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.", + "Invite someone using their name, username (like ) or share this space.": "Lade Leute mit Namen und Benutzername (z.B. ) ein oder teile diesen Space.", + "Invite someone using their name, email address, username (like ) or share this space.": "Lade Leute mit Namen, E-Mail und Benutzername (z.B. ) ein oder teile diesen Space.", + "Invite to %(roomName)s": "Leute zu %(roomName)s einladen", + "Unnamed Space": "Unbenannter Space", + "Invite to %(spaceName)s": "Leute zu %(spaceName)s einladen", + "Spaces": "Spaces", + "Invite People": "Personen einladen", + "Invite with email or username": "Personen mit E-Mail oder Benutzernamen einladen", + "You can change these anytime.": "Du kannst diese jederzeit ändern.", + "From %(deviceName)s (%(deviceId)s) at %(ip)s": "Von %(deviceName)s (%(deviceId)s) mit der Adresse %(ip)s", + "A new login is accessing your account: %(name)s (%(deviceID)s) at %(ip)s": "Neue Anmeldung: %(name)s (%(deviceID)s) mit der IP-Adresse %(ip)s", + "Verify with another session": "Mit anderer Sitzung verifizieren", + "We'll create rooms for each of them. You can add more later too, including already existing ones.": "Wir erstellen dir für jedes einen Raum. Du kannst jederzeit neue oder existierende Räume hinzufügen.", + "Let's create a room for each of them. You can add more later too, including already existing ones.": "Wir erstellen dir für jedes Thema einen Raum. Du kannst jederzeit neue hinzufügen.", + "What are some things you want to discuss?": "Über welche Themen wollt ihr schreiben?", + "Invite by username": "Mit Benutzername einladen", + "Make sure the right people have access. You can invite more later.": "Stelle sicher, dass die richtigen Leute Zugriff haben. Du kannst jederzeit neue 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", + "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", + "Just me": "Nur für mich", + "Who are you working with?": "Für wen soll dieser Space sein?", + "Make sure the right people have access to %(name)s": "Stelle sicher, dass die richtigen Leute 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.", + "Creating rooms...": "Räume werden erstellt...", + "Your server does not support showing space hierarchies.": "Dein Homeserver unterstützt hierarchische Spaces nicht.", + "Search names and description": "Name und Beschreibung durchsuchen", + "You may want to try a different search or check for typos.": "Versuche es mit etwas anderem oder prüfe auf Tippfehler.", + "Mark as not suggested": "Als nicht empfohlen markieren", + "Mark as suggested": "Als empfohlen markieren", + "Removing...": "Wird entfernt...", + "If you can't find the room you're looking for, ask for an invite or create a new room.": "Findest du den Raum, den du suchst nicht? Frag nach einer Einladung oder erstelle einen neuen Raum.", + "You don't have permission": "Du hast dazu keine Berechtigung", + "This space is not public. You will not be able to rejoin without an invite.": "Du wirst diesen privaten Space nur mit einer Einladung wieder betreten können.", + "Saving...": "Speichern...", + "Space settings": "Spaceeinstellungen", + "Failed to save space settings.": "Spaceeinstellungen konnten nicht gespeichert werden.", + "Continuing temporarily allows the %(hostSignupBrand)s setup process to access your account to fetch verified email addresses. This data is not stored.": "Wenn du fortfährst, geben wir %(hostSignupBrand)s für den Erstellungsprozess kurzzeitig Zugriff auf deinen Account und deine E-Mail-Adresse. Diese Daten werden nicht gespeichert.", + "Failed to add rooms to space": "Raum konnte nicht zum Space hinzugefügt werden", + "Applying...": "Anwenden...", + "Filter your rooms and spaces": "Durchsuche deine Räume und Spaces", + "Add existing spaces/rooms": "Existierende Spaces oder Räume hinzufügen", + "You will not be able to undo this change as you are demoting yourself, if you are the last privileged user in the space it will be impossible to regain privileges.": "Das Entfernen von Rechten kann nicht rückgängig gemacht werden. Falls sie dir niemand anderer zurückgeben kann, kannst du sie nie wieder erhalten.", + "You do not have permissions to add rooms to this space": "Keine Berechtigung zum Hinzufügen neuer Räume zum Space", + "You do not have permissions to create new rooms in this space": "Keine Berechtigung zum Erstellen neuer Räume in diesem Space", + "Don't want to add an existing room?": "Willst du keinen existierenden Raum hinzufügen?", + "Edit devices": "Sitzungen anzeigen", + "Your private space ": "Dein privater Space ", + "Your public space ": "Dein öffentlicher Space ", + "Quick actions": "Schnellaktionen", + "We couldn't create your DM.": "Wir konnten deine Direktnachricht nicht erstellen.", + "Adding...": "Hinzufügen...", + "Add existing rooms": "Bestehende Räume hinzufügen", + "Space selection": "Matrix-Space-Auswahl", + "%(count)s people you know have already joined|one": "%(count)s Person, die du kennst, ist schon beigetreten", + "%(count)s people you know have already joined|other": "%(count)s Leute, die du kennst, sind bereits beigetreten", + "Accept on your other login…": "Akzeptiere in deiner anderen Anmeldung…", + "Stop & send recording": "Stoppen und Aufzeichnung senden", + "Record a voice message": "Eine Sprachnachricht aufnehmen", + "Invite messages are hidden by default. Click to show the message.": "Einladungsnachrichten sind standardmäßig ausgeblendet. Klicken um diese anzuzeigen.", + "Warn before quitting": "Vor Beenden warnen", + "Spell check dictionaries": "Wörterbücher für Rechtschreibprüfung", + "Space options": "Matrix-Space-Optionen", + "Manage & explore rooms": "Räume entdecken und verwalten", + "unknown person": "unbekannte Person", + "Send and receive voice messages (in development)": "Sprachnachrichten senden und empfangen (in der Entwicklung)", + "Check your devices": "Überprüfe deine Sitzungen", + "%(deviceId)s from %(ip)s": "%(deviceId)s von %(ip)s", + "This homeserver has been blocked by it's administrator.": "Dieser Heimserver wurde von seiner Administration blockiert.", + "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", + "Message search initilisation failed": "Initialisierung der Nachrichtensuche fehlgeschlagen", + "Support": "Unterstützen", + "This room is suggested as a good one to join": "Dieser Raum wurde als gut zum Beitreten vorgeschlagen", + "Your message wasn't sent because this homeserver has been blocked by it's administrator. Please contact your service administrator to continue using the service.": "Deine Nachricht wurde nicht versendet, weil dieser Heimserver von dessen Administrator gesperrt wurde. Bitte kontaktiere deinen Dienstadministrator um den Dienst weiterzunutzen.", + "Verification requested": "Verifizierung angefragt", + "Avatar": "Avatar", + "Verify other login": "Andere Anmeldung verifizieren", + "Invited people will be able to read old messages.": "Eingeladene Leute werden ältere Nachrichten lesen können.", + "Sends the given message as a spoiler": "Die gegebene Nachricht als Spoiler senden", + "Values at explicit levels in this room:": "Werte für explizite Stufen in diesem Raum:", + "Values at explicit levels:": "Werte für explizite Stufen:", + "Values at explicit levels in this room": "Werte für explizite Stufen in diesem Raum", + "Confirm abort of host creation": "Bestätige das Beenden der Host-Erstellung", + "Are you sure you wish to abort creation of the host? The process cannot be continued.": "Soll die Host-Erstellung wirklich beendet werden? Dieser Prozess kann nicht wieder fortgesetzt werden.", + "Invite to just this room": "Nur in diesen Raum einladen", + "Consult first": "Konsultiere zuerst", + "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", + "If you do, please note that none of your messages will be deleted, but the search experience might be degraded for a few momentswhilst the index is recreated": "Falls du dies tust, werden keine deiner Nachrichten gelöscht. Allerdings wird die Such-Funktion eine Weile lang schlecht funktionieren, bis der Index wieder hergestellt ist", + "Reset event store": "Ereignisspeicher zurück setzen", + "Show options to enable 'Do not disturb' mode": "Optionen für den \"Nicht-Stören-Modus\" anzeigen", + "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.", + "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 den Space verlässt, ist er für immer verloren (eine lange Zeit).", + "Edit settings relating to your space.": "Einstellungen vom Space bearbeiten.", + "Please choose a strong password": "Bitte gib ein sicheres Passwort ein", + "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 verschlüsselte 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.", + "Reset everything": "Alles zurücksetzen", + "Forgotten or lost all recovery methods? Reset all": "Hast du alle Wiederherstellungsmethoden vergessen? Setze sie hier zurück", + "View message": "Nachricht anzeigen", + "Zoom in": "Vergrößern", + "Zoom out": "Verkleinern", + "%(seconds)ss left": "%(seconds)s vergangen", + "Change server ACLs": "ACLs des Servers bearbeiten", + "Failed to send": "Fehler beim Senden", + "View all %(count)s members|other": "Alle %(count)s Mitglieder anzeigen", + "View all %(count)s members|one": "Mitglied anzeigen", + "%(count)s members including %(commaSeparatedMembers)s|one": "%(commaSeparatedMembers)s", + "Some of your messages have not been sent": "Einige Nachrichten konnten nicht gesendet werden", + "Original event source": "Ursprüngliche Rohdaten", + "Decrypted event source": "Entschlüsselte Rohdaten", + "Sending": "Senden", + "You can select all or individual messages to retry or delete": "Du kannst einzelne oder alle Nachrichten erneut senden oder löschen", + "Delete all": "Alle löschen", + "Retry all": "Alle erneut senden", + "Without verifying, you won’t have access to all your messages and may appear as untrusted to others.": "Ohne deine Verifikation kannst du keine deiner verschlüsselten Nachrichten lesen und andere vertrauen dir möglicherweise nicht.", + "Verify your identity to access encrypted messages and prove your identity to others.": "Verifiziere diese Anmeldung, um auf verschlüsselte Nachrichten zuzugreifen und dich anderen gegenüber zu identifizieren.", + "Use another login": "Mit anderem Gerät verifizeren", + "If you do, please note that none of your messages will be deleted, but the search experience might be degraded for a few moments whilst the index is recreated": "Falls du es wirklich willst: Es werden keine Nachrichten gelöscht. Außerdem wird die Suche, während der Index erstellt wird, etwas langsamer sein", + "%(count)s members including %(commaSeparatedMembers)s|other": "%(count)s Mitglieder inklusive %(commaSeparatedMembers)s", + "Including %(commaSeparatedMembers)s": "Inklusive%(commaSeparatedMembers)s", + "Consulting with %(transferTarget)s. Transfer to %(transferee)s": "Beratung mit %(transferTarget)s. Übertragung zu %(transferee)s", + "Play": "Abspielen", + "Pause": "Pause", + "What do you want to organise?": "Was willst du organisieren?", + "Enter your Security Phrase a second time to confirm it.": "Gib dein Kennwort ein zweites Mal zur Bestätigung ein.", + "Pick rooms or conversations to add. This is just a space for you, no one will be informed. You can add more later.": "Wähle Räume oder Konversationen die Du hinzufügen möchtest. Dieser Bereich ist nur für Dich, niemand wird informiert. Du kannst später mehr hinzufügen.", + "Filter all spaces": "Alle Spaces durchsuchen", + "Delete recording": "Aufnahme löschen", + "Stop the recording": "Aufnahme stoppen", + "%(count)s results in all spaces|one": "%(count)s Ergebnis", + "%(count)s results in all spaces|other": "%(count)s Ergebnisse", + "You have no ignored users.": "Du ignorierst keine Benutzer.", + "Error processing voice message": "Fehler beim Verarbeiten der Sprachnachricht", + "To join %(spaceName)s, turn on the Spaces beta": "Um %(spaceName)s beizutreten, aktiviere die Spaces Betaversion", + "To view %(spaceName)s, turn on the Spaces beta": "Um %(spaceName)s zu betreten, aktiviere die Spaces Beta", + "Select a room below first": "Wähle zuerst einen Raum aus", + "Communities are changing to Spaces": "Spaces ersetzen Communities", + "Join the beta": "Beta beitreten", + "Leave the beta": "Beta verlassen", + "Beta": "Beta", + "Tap for more info": "Klicke für mehr Infos", + "Spaces is a beta feature": "Spaces sind noch in der Entwicklung und möglicherweise instabil", + "Want to add a new room instead?": "Willst du einen neuen Raum hinzufügen?", + "Adding rooms... (%(progress)s out of %(count)s)|one": "Raum wird hinzugefügt...", + "Adding rooms... (%(progress)s out of %(count)s)|other": "Räume werden hinzugefügt... (%(progress)s von %(count)s)", + "You can add existing spaces to a space.": "Du kannst existierende Spaces zu einem Space hinzfügen.", + "Feeling experimental?": "Willst du die Entwicklung von Element hautnah miterleben?", + "You are not allowed to view this server's rooms list": "Du darfst diese Raumliste nicht sehen", + "We didn't find a microphone on your device. Please check your settings and try again.": "Es konnte kein Mikrofon gefunden werden. Überprüfe deine Einstellungen und versuche es erneut.", + "No microphone found": "Kein Mikrofon gefunden", + "We were unable to access your microphone. Please check your browser settings and try again.": "Fehler beim Zugriff auf dein Mikrofon. Überprüfe deine Browsereinstellungen und versuche es nochmal.", + "Unable to access your microphone": "Fehler beim Zugriff auf Mikrofon", + "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.": "Hier kannst du zukünftige Features noch vor der Veröffentlichung testen und uns mit Feedback beim Verbessern helfen. Mehr Infos.", + "Please enter a name for the space": "Gib den Namen des Spaces ein", + "Connecting": "Verbinden", + "Allow Peer-to-Peer for 1:1 calls (if you enable this, the other party might be able to see your IP address)": "Direktverbindung für Direktanrufe aktivieren. Dadurch sieht dein Gegenüber möglicherweise deine IP-Adresse.", + "Beta available for web, desktop and Android. Some features may be unavailable on your homeserver.": "Die Betaversion ist verfügbar für Browser, Desktop und Android. Je nach Homeserver sind einige Funktionen möglicherweise nicht verfügbar.", + "You can leave the beta any time from settings or tapping on a beta badge, like the one above.": "Du kannst die Betaversion jederzeit verlassen. Mache dies entweder in den Einstellungen oder klicke auf eines der \"Beta\"-Icons wie das hier oben.", + "%(brand)s will reload with Spaces enabled. Communities and custom tags will be hidden.": "%(brand)s wird mit aktivierten Spaces neuladen. Danach kannst Communities und Custom Tags nicht verwenden.", + "Beta available for web, desktop and Android. Thank you for trying the beta.": "Die Betaversion ist für Browser, Desktop und Android verfügbar. Danke, dass Du die Betaversion testest.", + "%(brand)s will reload with Spaces disabled. Communities and custom tags will be visible again.": "%(brand)s wird mit deaktivierten Spaces neuladen und du kannst Communities und Custom Tags wieder verwenden können.", + "Spaces are a beta feature.": "Spaces sind in der Beta.", + "Spaces are a new way to group rooms and people. To join an existing space you'll need an invite.": "Wir haben Spaces entwickelt, damit ihr eure vielen Räume besser organisieren könnt. Um einen existierenden Space beitreten zu können musst du (noch) von jemandem eingeladen werden.", + "Spaces are a new way to group rooms and people.": "Wir haben Spaces entwickelt, damit ihr eure vielen Räume besser organisieren könnt.", + "Message search initialisation failed": "Initialisierung der Nachrichtensuche fehlgeschlagen", + "Send and receive voice messages": "Sprachnachrichten", + "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.": "Dieses Feature ist experimentell. Falls du eine Einladung erhältst musst du sie momentan noch auf öffnen, um beizutreten.", + "You may contact me if you have any follow up questions": "Kontaktiert mich, falls ihr weitere Fragen zu meinem Feedback 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.": "Die Platform von Element und dein Benutzername werden mitgeschickt, damit wir dein Feedback bestmöglich nachvollziehen können.", + "%(featureName)s beta feedback": "%(featureName)s-Beta Feedback", + "Thank you for your feedback, we really appreciate it.": "Uns liegt es am Herzen, Element zu verbessern. Deshalb ein großes Danke für dein Feedback.", + "Beta feedback": "Beta Feedback", + "Your access token gives full access to your account. Do not share it with anyone.": "Dein Zugriffstoken gibt vollen Zugriff auf dein Konto. Teile es niemals mit jemanden anderen.", + "Access Token": "Zugriffstoken", + "Your feedback will help make spaces better. The more detail you can go into, the better.": "Dein Feedback hilfst uns, die Spaces zu verbessern. Je genauer, desto besser.", + "If you leave, %(brand)s will reload with Spaces disabled. Communities and custom tags will be visible again.": "Durchs Verlassen lädt %(brand)s mit deaktivierten Spaces neu. Danach kannst du wieder Communities und Custom Tags verwenden." } diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index c192e51f96..7ceb039822 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -578,14 +578,6 @@ "%(senderName)s updated a ban rule that was matching %(oldGlob)s to matching %(newGlob)s for %(reason)s": "%(senderName)s updated a ban rule that was matching %(oldGlob)s to matching %(newGlob)s for %(reason)s", "Light": "Light", "Dark": "Dark", - "You signed in to a new session without verifying it:": "You signed in to a new session without verifying it:", - "Verify your other session using one of the options below.": "Verify your other session using one of the options below.", - "%(name)s (%(userId)s) signed in to a new session without verifying it:": "%(name)s (%(userId)s) signed in to a new session without verifying it:", - "Ask this user to verify their session, or manually verify it below.": "Ask this user to verify their session, or manually verify it below.", - "Not Trusted": "Not Trusted", - "Manually Verify by Text": "Manually Verify by Text", - "Interactively verify by Emoji": "Interactively verify by Emoji", - "Done": "Done", "%(displayName)s is typing …": "%(displayName)s is typing …", "%(names)s and %(count)s others are typing …|other": "%(names)s and %(count)s others are typing …", "%(names)s and %(count)s others are typing …|one": "%(names)s and one other is typing …", @@ -608,6 +600,10 @@ "See when the avatar changes in this room": "See when the avatar changes in this room", "Change the avatar of your active room": "Change the avatar of your active room", "See when the avatar changes in your active room": "See when the avatar changes in your active room", + "Kick, ban, or invite people to this room, and make you leave": "Kick, ban, or invite people to this room, and make you leave", + "See when people join, leave, or are invited to this room": "See when people join, leave, or are invited to this room", + "Kick, ban, or invite people to your active room, and make you leave": "Kick, ban, or invite people to your active room, and make you leave", + "See when people join, leave, or are invited to your active room": "See when people join, leave, or are invited to your active room", "Send stickers to this room as you": "Send stickers to this room as you", "See when a sticker is posted in this room": "See when a sticker is posted in this room", "Send stickers to your active room as you": "Send stickers to your active room as you", @@ -658,7 +654,6 @@ "No homeserver URL provided": "No homeserver URL provided", "Unexpected error resolving homeserver configuration": "Unexpected error resolving homeserver configuration", "Unexpected error resolving identity server configuration": "Unexpected error resolving identity server configuration", - "The message you are trying to send is too large.": "The message you are trying to send is too large.", "This homeserver has hit its Monthly Active User limit.": "This homeserver has hit its Monthly Active User limit.", "This homeserver has been blocked by its administrator.": "This homeserver has been blocked by its administrator.", "This homeserver has exceeded one of its resource limits.": "This homeserver has exceeded one of its resource limits.", @@ -786,10 +781,18 @@ "%(senderName)s: %(stickerName)s": "%(senderName)s: %(stickerName)s", "Change notification settings": "Change notification settings", "Spaces prototype. Incompatible with Communities, Communities v2 and Custom Tags. Requires compatible homeserver for some features.": "Spaces prototype. Incompatible with Communities, Communities v2 and Custom Tags. Requires compatible homeserver for some features.", - "Send and receive voice messages (in development)": "Send and receive voice messages (in development)", + "Spaces": "Spaces", + "Spaces are a new way to group rooms and people.": "Spaces are a new way to group rooms and people.", + "If you leave, %(brand)s will reload with Spaces disabled. Communities and custom tags will be visible again.": "If you leave, %(brand)s will reload with Spaces disabled. Communities and custom tags will be visible again.", + "Beta available for web, desktop and Android. Thank you for trying the beta.": "Beta available for web, desktop and Android. Thank you for trying the beta.", + "%(brand)s will reload with Spaces enabled. Communities and custom tags will be hidden.": "%(brand)s will reload with Spaces enabled. Communities and custom tags will be hidden.", + "You can leave the beta any time from settings or tapping on a beta badge, like the one above.": "You can leave the beta any time from settings or tapping on a beta badge, like the one above.", + "Beta available for web, desktop and Android. Some features may be unavailable on your homeserver.": "Beta available for web, desktop and Android. Some features may be unavailable on your homeserver.", + "Your feedback will help make spaces better. The more detail you can go into, the better.": "Your feedback will help make spaces better. The more detail you can go into, the better.", + "Show options to enable 'Do not disturb' mode": "Show options to enable 'Do not disturb' mode", + "Send and receive voice messages": "Send and receive voice messages", "Render LaTeX maths in messages": "Render LaTeX maths in messages", "Communities v2 prototypes. Requires compatible homeserver. Highly experimental - use with caution.": "Communities v2 prototypes. Requires compatible homeserver. Highly experimental - use with caution.", - "New spinner design": "New spinner design", "Message Pinning": "Message Pinning", "Custom user status messages": "Custom user status messages", "Group & filter rooms by custom tags (refresh to apply changes)": "Group & filter rooms by custom tags (refresh to apply changes)", @@ -800,7 +803,6 @@ "Show message previews for reactions in DMs": "Show message previews for reactions in DMs", "Show message previews for reactions in all rooms": "Show message previews for reactions in all rooms", "Offline encrypted messaging using dehydrated devices": "Offline encrypted messaging using dehydrated devices", - "Share decryption keys for room history when inviting users": "Share decryption keys for room history when inviting users", "Enable advanced debugging for the room list": "Enable advanced debugging for the room list", "Show info about bridges in room settings": "Show info about bridges in room settings", "Font size": "Font size", @@ -834,7 +836,7 @@ "Match system theme": "Match system theme", "Use a system font": "Use a system font", "System font name": "System font name", - "Allow Peer-to-Peer for 1:1 calls": "Allow Peer-to-Peer for 1:1 calls", + "Allow Peer-to-Peer for 1:1 calls (if you enable this, the other party might be able to see your IP address)": "Allow Peer-to-Peer for 1:1 calls (if you enable this, the other party might be able to see your IP address)", "Send analytics data": "Send analytics data", "Never send encrypted messages to unverified sessions from this session": "Never send encrypted messages to unverified sessions from this session", "Never send encrypted messages to unverified sessions in this room from this session": "Never send encrypted messages to unverified sessions in this room from this session", @@ -881,11 +883,14 @@ "sends fireworks": "sends fireworks", "Sends the given message with snowfall": "Sends the given message with snowfall", "sends snowfall": "sends snowfall", + "Sends the given message with a space themed effect": "Sends the given message with a space themed effect", + "sends space invaders": "sends space invaders", "unknown person": "unknown person", "Consulting with %(transferTarget)s. Transfer to %(transferee)s": "Consulting with %(transferTarget)s. Transfer to %(transferee)s", "You held the call Switch": "You held the call Switch", "You held the call Resume": "You held the call Resume", "%(peerName)s held the call": "%(peerName)s held the call", + "Connecting": "Connecting", "Video Call": "Video Call", "Voice Call": "Voice Call", "Fill Screen": "Fill Screen", @@ -900,6 +905,8 @@ "Incoming call": "Incoming call", "Decline": "Decline", "Accept": "Accept", + "Pause": "Pause", + "Play": "Play", "The other party cancelled the verification.": "The other party cancelled the verification.", "Verified!": "Verified!", "You've successfully verified this user.": "You've successfully verified this user.", @@ -994,8 +1001,9 @@ "Upload": "Upload", "Name": "Name", "Description": "Description", + "Please enter a name for the space": "Please enter a name for the space", "Create a space": "Create a space", - "Spaces are new ways to group rooms and people. To join an existing space you'll need an invite.": "Spaces are new ways to group rooms and people. To join an existing space you'll need an invite.", + "Spaces are a new way to group rooms and people. To join an existing space you'll need an invite.": "Spaces are a new way to group rooms and people. To join an existing space you'll need an invite.", "Public": "Public", "Open space for anyone, best for communities": "Open space for anyone, best for communities", "Private": "Private", @@ -1010,7 +1018,7 @@ "Create": "Create", "Expand space panel": "Expand space panel", "Collapse space panel": "Collapse space panel", - "Home": "Home", + "All rooms": "All rooms", "Click to copy": "Click to copy", "Copied!": "Copied!", "Failed to copy": "Failed to copy", @@ -1086,7 +1094,7 @@ "Securely cache encrypted messages locally for them to appear in search results.": "Securely cache encrypted messages locally for them to appear in search results.", "%(brand)s is missing some components required for securely caching encrypted messages locally. If you'd like to experiment with this feature, build a custom %(brand)s Desktop with search components added.": "%(brand)s is missing some components required for securely caching encrypted messages locally. If you'd like to experiment with this feature, build a custom %(brand)s Desktop with search components added.", "%(brand)s can't securely cache encrypted messages locally while running in a web browser. Use %(brand)s Desktop for encrypted messages to appear in search results.": "%(brand)s can't securely cache encrypted messages locally while running in a web browser. Use %(brand)s Desktop for encrypted messages to appear in search results.", - "Message search initilisation failed": "Message search initilisation failed", + "Message search initialisation failed": "Message search initialisation failed", "Connecting to integration manager...": "Connecting to integration manager...", "Cannot connect to integration manager": "Cannot connect to integration manager", "The integration manager is offline or it cannot reach your homeserver.": "The integration manager is offline or it cannot reach your homeserver.", @@ -1251,11 +1259,12 @@ "olm version:": "olm version:", "Homeserver is": "Homeserver is", "Identity Server is": "Identity Server is", - "Access Token:": "Access Token:", - "click to reveal": "click to reveal", + "Access Token": "Access Token", + "Your access token gives full access to your account. Do not share it with anyone.": "Your access token gives full access to your account. Do not share it with anyone.", + "Copy": "Copy", "Clear cache and reload": "Clear cache and reload", "Labs": "Labs", - "Customise your experience with experimental labs features. Learn more.": "Customise your experience with experimental labs features. Learn more.", + "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.": "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.", "Ignored/Blocked": "Ignored/Blocked", "Error adding ignored user/server": "Error adding ignored user/server", "Something went wrong. Please try again or view your console for hints.": "Something went wrong. Please try again or view your console for hints.", @@ -1306,6 +1315,7 @@ "Cryptography": "Cryptography", "Session ID:": "Session ID:", "Session key:": "Session key:", + "You have no ignored users.": "You have no ignored users.", "Bulk options": "Bulk options", "Accept all %(invitedRooms)s invites": "Accept all %(invitedRooms)s invites", "Reject all %(invitedRooms)s invites": "Reject all %(invitedRooms)s invites", @@ -1361,6 +1371,7 @@ "Change topic": "Change topic", "Upgrade the room": "Upgrade the room", "Enable room encryption": "Enable room encryption", + "Change server ACLs": "Change server ACLs", "Modify widgets": "Modify widgets", "Failed to unban": "Failed to unban", "Unban": "Unban", @@ -1436,6 +1447,13 @@ "Someone is using an unknown session": "Someone is using an unknown session", "This room is end-to-end encrypted": "This room is end-to-end encrypted", "Everyone in this room is verified": "Everyone in this room is verified", + "Server error": "Server error", + "Server unavailable, overloaded, or something else went wrong.": "Server unavailable, overloaded, or something else went wrong.", + "Unknown Command": "Unknown Command", + "Unrecognised command: %(commandText)s": "Unrecognised command: %(commandText)s", + "You can use /help to list available commands. Did you mean to send this as a message?": "You can use /help to list available commands. Did you mean to send this as a message?", + "Hint: Begin your message with // to start it with a slash.": "Hint: Begin your message with // to start it with a slash.", + "Send as message": "Send as message", "Edit message": "Edit message", "Mod": "Mod", "This event could not be displayed": "This event could not be displayed", @@ -1452,6 +1470,7 @@ "Sending your message...": "Sending your message...", "Encrypting your message...": "Encrypting your message...", "Your message was sent": "Your message was sent", + "Failed to send": "Failed to send", "Please select the destination room for this message": "Please select the destination room for this message", "Scroll to most recent messages": "Scroll to most recent messages", "Close preview": "Close preview", @@ -1473,6 +1492,7 @@ "The conversation continues here.": "The conversation continues here.", "This room has been replaced and is no longer active.": "This room has been replaced and is no longer active.", "You do not have permission to post to this room": "You do not have permission to post to this room", + "%(seconds)ss left": "%(seconds)ss left", "Bold": "Bold", "Italics": "Italics", "Strikethrough": "Strikethrough", @@ -1547,6 +1567,8 @@ "Explore all public rooms": "Explore all public rooms", "Quick actions": "Quick actions", "Use the + to make a new room or explore existing ones below": "Use the + to make a new room or explore existing ones below", + "%(count)s results in all spaces|other": "%(count)s results in all spaces", + "%(count)s results in all spaces|one": "%(count)s result in all spaces", "%(count)s results|other": "%(count)s results", "%(count)s results|one": "%(count)s result", "This room": "This room", @@ -1578,7 +1600,6 @@ "Start chatting": "Start chatting", "Do you want to join %(roomName)s?": "Do you want to join %(roomName)s?", " invited you": " invited you", - "Invite messages are hidden by default. Click to show the message.": "Invite messages are hidden by default. Click to show the message.", "Reject": "Reject", "Reject & Ignore user": "Reject & Ignore user", "You're previewing %(roomName)s. Want to join it?": "You're previewing %(roomName)s. Want to join it?", @@ -1623,13 +1644,6 @@ "This Room": "This Room", "All Rooms": "All Rooms", "Search…": "Search…", - "Server error": "Server error", - "Server unavailable, overloaded, or something else went wrong.": "Server unavailable, overloaded, or something else went wrong.", - "Unknown Command": "Unknown Command", - "Unrecognised command: %(commandText)s": "Unrecognised command: %(commandText)s", - "You can use /help to list available commands. Did you mean to send this as a message?": "You can use /help to list available commands. Did you mean to send this as a message?", - "Hint: Begin your message with // to start it with a slash.": "Hint: Begin your message with // to start it with a slash.", - "Send as message": "Send as message", "Failed to connect to integration manager": "Failed to connect to integration manager", "You don't currently have any stickerpacks enabled": "You don't currently have any stickerpacks enabled", "Add some now": "Add some now", @@ -1643,8 +1657,13 @@ "Invited by %(sender)s": "Invited by %(sender)s", "Jump to first unread message.": "Jump to first unread message.", "Mark all as read": "Mark all as read", + "Unable to access your microphone": "Unable to access your microphone", + "We were unable to access your microphone. Please check your browser settings and try again.": "We were unable to access your microphone. Please check your browser settings and try again.", + "No microphone found": "No microphone found", + "We didn't find a microphone on your device. Please check your settings and try again.": "We didn't find a microphone on your device. Please check your settings and try again.", "Record a voice message": "Record a voice message", - "Stop & send recording": "Stop & send recording", + "Stop the recording": "Stop the recording", + "Delete recording": "Delete recording", "Error updating main address": "Error updating main address", "There was an error updating the room's main address. It may not be allowed by the server or a temporary failure occurred.": "There was an error updating the room's main address. It may not be allowed by the server or a temporary failure occurred.", "There was an error updating the room's alternative addresses. It may not be allowed by the server or a temporary failure occurred.": "There was an error updating the room's alternative addresses. It may not be allowed by the server or a temporary failure occurred.", @@ -1810,8 +1829,9 @@ "The encryption used by this room isn't supported.": "The encryption used by this room isn't supported.", "Error decrypting audio": "Error decrypting audio", "React": "React", - "Reply": "Reply", "Edit": "Edit", + "Retry": "Retry", + "Reply": "Reply", "Message Actions": "Message Actions", "Attachment": "Attachment", "Error decrypting attachment": "Error decrypting attachment", @@ -1840,6 +1860,8 @@ "%(name)s wants to verify": "%(name)s wants to verify", "You sent a verification request": "You sent a verification request", "Error decrypting video": "Error decrypting video", + "Error processing voice message": "Error processing voice message", + "Add reaction": "Add reaction", "Show all": "Show all", "Reactions": "Reactions", " reacted with %(content)s": " reacted with %(content)s", @@ -1915,16 +1937,20 @@ "Please create a new issue on GitHub so that we can investigate this bug.": "Please create a new issue on GitHub so that we can investigate this bug.", "collapse": "collapse", "expand": "expand", + "View all %(count)s members|other": "View all %(count)s members", + "View all %(count)s members|one": "View 1 member", + "Including %(commaSeparatedMembers)s": "Including %(commaSeparatedMembers)s", + "%(count)s members including %(commaSeparatedMembers)s|other": "%(count)s members including %(commaSeparatedMembers)s", + "%(count)s members including %(commaSeparatedMembers)s|one": "%(commaSeparatedMembers)s", "%(count)s people you know have already joined|other": "%(count)s people you know have already joined", "%(count)s people you know have already joined|one": "%(count)s person you know has already joined", - "You cannot delete this image. (%(code)s)": "You cannot delete this image. (%(code)s)", - "Uploaded on %(date)s by %(user)s": "Uploaded on %(date)s by %(user)s", + "Zoom out": "Zoom out", + "Zoom in": "Zoom in", "Rotate Left": "Rotate Left", - "Rotate counter-clockwise": "Rotate counter-clockwise", "Rotate Right": "Rotate Right", - "Rotate clockwise": "Rotate clockwise", - "Download this file": "Download this file", + "Download": "Download", "Information": "Information", + "View message": "View message", "Language Dropdown": "Language Dropdown", "%(nameList)s %(transitionList)s": "%(nameList)s %(transitionList)s", "%(severalUsers)sjoined %(count)s times|other": "%(severalUsers)sjoined %(count)s times", @@ -1997,10 +2023,11 @@ "Continue with %(provider)s": "Continue with %(provider)s", "Sign in with single sign-on": "Sign in with single sign-on", "And %(count)s more...|other": "And %(count)s more...", + "Home": "Home", "Enter a server name": "Enter a server name", "Looks good": "Looks good", + "You are not allowed to view this server's rooms list": "You are not allowed to view this server's rooms list", "Can't find this server or its room list": "Can't find this server or its room list", - "All rooms": "All rooms", "Your server": "Your server", "Are you sure you want to remove %(serverName)s": "Are you sure you want to remove %(serverName)s", "Remove server": "Remove server", @@ -2011,14 +2038,17 @@ "Add a new server...": "Add a new server...", "%(networkName)s rooms": "%(networkName)s rooms", "Matrix rooms": "Matrix rooms", + "Not all selected were added": "Not all selected were added", + "Adding rooms... (%(progress)s out of %(count)s)|other": "Adding rooms... (%(progress)s out of %(count)s)", + "Adding rooms... (%(progress)s out of %(count)s)|one": "Adding room...", + "Filter your rooms and spaces": "Filter your rooms and spaces", + "Feeling experimental?": "Feeling experimental?", + "You can add existing spaces to a space.": "You can add existing spaces to a space.", + "Direct Messages": "Direct Messages", "Space selection": "Space selection", "Add existing rooms": "Add existing rooms", - "Filter your rooms and spaces": "Filter your rooms and spaces", - "Spaces": "Spaces", - "Don't want to add an existing room?": "Don't want to add an existing room?", + "Want to add a new room instead?": "Want to add a new room instead?", "Create a new room": "Create a new room", - "Failed to add rooms to space": "Failed to add rooms to space", - "Adding...": "Adding...", "Matrix ID": "Matrix ID", "Matrix Room ID": "Matrix Room ID", "email address": "email address", @@ -2032,6 +2062,15 @@ "Invite anyway and never warn me again": "Invite anyway and never warn me again", "Invite anyway": "Invite anyway", "Close dialog": "Close dialog", + "Beta feedback": "Beta feedback", + "Thank you for your feedback, we really appreciate it.": "Thank you for your feedback, we really appreciate it.", + "Done": "Done", + "%(featureName)s beta feedback": "%(featureName)s beta feedback", + "Your platform and username will be noted to help us use your feedback as much as we can.": "Your platform and username will be noted to help us use your feedback as much as we can.", + "To leave the beta, visit your settings.": "To leave the beta, visit your settings.", + "Feedback": "Feedback", + "You may contact me if you have any follow up questions": "You may contact me if you have any follow up questions", + "Send feedback": "Send feedback", "Please tell us what went wrong or, better, create a GitHub issue that describes the problem.": "Please tell us what went wrong or, better, create a GitHub issue that describes the problem.", "Preparing to send logs": "Preparing to send logs", "Logs sent": "Logs sent", @@ -2162,10 +2201,8 @@ "Comment": "Comment", "There are two ways you can provide feedback and help us improve %(brand)s.": "There are two ways you can provide feedback and help us improve %(brand)s.", "PRO TIP: If you start a bug, please submit debug logs to help us track down the problem.": "PRO TIP: If you start a bug, please submit debug logs to help us track down the problem.", - "Feedback": "Feedback", "Report a bug": "Report a bug", "Please view existing bugs on Github first. No match? Start a new one.": "Please view existing bugs on Github first. No match? Start a new one.", - "Send feedback": "Send feedback", "Confirm abort of host creation": "Confirm abort of host creation", "Are you sure you wish to abort creation of the host? The process cannot be continued.": "Are you sure you wish to abort creation of the host? The process cannot be continued.", "Abort": "Abort", @@ -2205,7 +2242,6 @@ "Suggestions": "Suggestions", "May include members not in %(communityName)s": "May include members not in %(communityName)s", "Recently Direct Messaged": "Recently Direct Messaged", - "Direct Messages": "Direct Messages", "Start a conversation with someone using their name, email address or username (like ).": "Start a conversation with someone using their name, email address or username (like ).", "Start a conversation with someone using their name or username (like ).": "Start a conversation with someone using their name or username (like ).", "This won't invite them to %(communityName)s. To invite someone to %(communityName)s, click here": "This won't invite them to %(communityName)s. To invite someone to %(communityName)s, click here", @@ -2311,7 +2347,7 @@ "About homeservers": "About homeservers", "Reset event store?": "Reset event store?", "You most likely do not want to reset your event index store": "You most likely do not want to reset your event index store", - "If you do, please note that none of your messages will be deleted, but the search experience might be degraded for a few momentswhilst the index is recreated": "If you do, please note that none of your messages will be deleted, but the search experience might be degraded for a few momentswhilst the index is recreated", + "If you do, please note that none of your messages will be deleted, but the search experience might be degraded for a few moments whilst the index is recreated": "If you do, please note that none of your messages will be deleted, but the search experience might be degraded for a few moments whilst the index is recreated", "Reset event store": "Reset event store", "Sign out and remove encryption keys?": "Sign out and remove encryption keys?", "Clear Storage and Sign Out": "Clear Storage and Sign Out", @@ -2330,7 +2366,6 @@ "Share Community": "Share Community", "Share Room Message": "Share Room Message", "Link to selected message": "Link to selected message", - "Copy": "Copy", "Command Help": "Command Help", "Failed to save space settings.": "Failed to save space settings.", "Space settings": "Space settings", @@ -2353,6 +2388,13 @@ "Summary": "Summary", "Document": "Document", "Next": "Next", + "You signed in to a new session without verifying it:": "You signed in to a new session without verifying it:", + "Verify your other session using one of the options below.": "Verify your other session using one of the options below.", + "%(name)s (%(userId)s) signed in to a new session without verifying it:": "%(name)s (%(userId)s) signed in to a new session without verifying it:", + "Ask this user to verify their session, or manually verify it below.": "Ask this user to verify their session, or manually verify it below.", + "Not Trusted": "Not Trusted", + "Manually Verify by Text": "Manually Verify by Text", + "Interactively verify by Emoji": "Interactively verify by Emoji", "Upload files (%(current)s of %(total)s)": "Upload files (%(current)s of %(total)s)", "Upload files": "Upload files", "Upload all": "Upload all", @@ -2377,6 +2419,10 @@ "Looks good!": "Looks good!", "Wrong Security Key": "Wrong Security Key", "Invalid Security Key": "Invalid Security Key", + "Forgotten or lost all recovery methods? Reset all": "Forgotten or lost all recovery methods? Reset all", + "Reset everything": "Reset everything", + "Only do this if you have no other device to complete verification with.": "Only do this if you have no other device to complete verification with.", + "If you reset everything, you will restart with no trusted sessions, no trusted users, and might not be able to see past messages.": "If you reset everything, you will restart with no trusted sessions, no trusted users, and might not be able to see past messages.", "Security Phrase": "Security Phrase", "Unable to access secret storage. Please verify that you entered the correct Security Phrase.": "Unable to access secret storage. Please verify that you entered the correct Security Phrase.", "Enter your Security Phrase or to continue.": "Enter your Security Phrase or to continue.", @@ -2388,7 +2434,6 @@ "Confirm encryption setup": "Confirm encryption setup", "Click the button below to confirm setting up encryption.": "Click the button below to confirm setting up encryption.", "Unable to set up keys": "Unable to set up keys", - "Retry": "Retry", "Restoring keys from backup": "Restoring keys from backup", "Fetching keys from server...": "Fetching keys from server...", "%(completed)s of %(total)s keys restored": "%(completed)s of %(total)s keys restored", @@ -2417,10 +2462,7 @@ "Reject invitation": "Reject invitation", "Are you sure you want to reject the invitation?": "Are you sure you want to reject the invitation?", "Unable to reject invite": "Unable to reject invite", - "Resend edit": "Resend edit", "Resend %(unsentCount)s reaction(s)": "Resend %(unsentCount)s reaction(s)", - "Resend removal": "Resend removal", - "Cancel Sending": "Cancel Sending", "Forward Message": "Forward Message", "Pin Message": "Pin Message", "Unhide Preview": "Unhide Preview", @@ -2445,6 +2487,11 @@ "Revoke permissions": "Revoke permissions", "Move left": "Move left", "Move right": "Move right", + "Spaces is a beta feature": "Spaces is a beta feature", + "Tap for more info": "Tap for more info", + "Beta": "Beta", + "Leave the beta": "Leave the beta", + "Join the beta": "Join the beta", "Avatar": "Avatar", "This room is public": "This room is public", "Away": "Away", @@ -2553,6 +2600,7 @@ "Failed to reject invitation": "Failed to reject invitation", "Cannot create rooms in this community": "Cannot create rooms in this community", "You do not have permission to create rooms in this community.": "You do not have permission to create rooms in this community.", + "You are the only person here. If you leave, no one will be able to join in the future, including you.": "You are the only person here. If you leave, no one will be able to join in the future, including you.", "This space is not public. You will not be able to rejoin without an invite.": "This space is not public. You will not be able to rejoin without an invite.", "This room is not public. You will not be able to rejoin without an invite.": "This room is not public. You will not be able to rejoin without an invite.", "Are you sure you want to leave the space '%(spaceName)s'?": "Are you sure you want to leave the space '%(spaceName)s'?", @@ -2577,6 +2625,7 @@ "Error whilst fetching joined communities": "Error whilst fetching joined communities", "Create a new community": "Create a new community", "Create a community to group together users and rooms! Build a custom homepage to mark out your space in the Matrix universe.": "Create a community to group together users and rooms! Build a custom homepage to mark out your space in the Matrix universe.", + "Communities are changing to Spaces": "Communities are changing to Spaces", "You’re all caught up": "You’re all caught up", "You have no visible notifications.": "You have no visible notifications.", "%(brand)s failed to get the protocol list from the homeserver. The homeserver may be too old to support third party networks.": "%(brand)s failed to get the protocol list from the homeserver. The homeserver may be too old to support third party networks.", @@ -2596,21 +2645,25 @@ "Unable to look up room ID from server": "Unable to look up room ID from server", "Preview": "Preview", "View": "View", + "No results for \"%(query)s\"": "No results for \"%(query)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.": "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.", "Find a room…": "Find a room…", "Find a room… (e.g. %(exampleRoom)s)": "Find a room… (e.g. %(exampleRoom)s)", "If you can't find the room you're looking for, ask for an invite or Create a new room.": "If you can't find the room you're looking for, ask for an invite or Create a new room.", "Explore rooms in %(communityName)s": "Explore rooms in %(communityName)s", "Filter": "Filter", + "Filter all spaces": "Filter all spaces", "Clear filter": "Clear filter", "Filter rooms and people": "Filter rooms and people", "You can't send any messages until you review and agree to our terms and conditions.": "You can't send any messages until you review and agree to our terms and conditions.", "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.": "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.", "Your message wasn't sent because this homeserver has been blocked by it's administrator. Please contact your service administrator to continue using the service.": "Your message wasn't sent because this homeserver has been blocked by it's administrator. Please contact your service administrator to continue using the service.", "Your message wasn't sent because this homeserver has exceeded a resource limit. Please contact your service administrator to continue using the service.": "Your message wasn't sent because this homeserver has exceeded a resource limit. Please contact your service administrator to continue using the service.", - "%(count)s of your messages have not been sent.|other": "Some of your messages have not been sent.", - "%(count)s of your messages have not been sent.|one": "Your message was not sent.", - "%(count)s Resend all or cancel all now. You can also select individual messages to resend or cancel.|other": "Resend all or cancel all now. You can also select individual messages to resend or cancel.", - "%(count)s Resend all or cancel all now. You can also select individual messages to resend or cancel.|one": "Resend message or cancel message now.", + "Some of your messages have not been sent": "Some of your messages have not been sent", + "Delete all": "Delete all", + "Retry all": "Retry all", + "Sending": "Sending", + "You can select all or individual messages to retry or delete": "You can select all or individual messages to retry or delete", "Connectivity to the server has been lost.": "Connectivity to the server has been lost.", "Sent messages will be stored until your connection has returned.": "Sent messages will be stored until your connection has returned.", "You seem to be uploading files, are you sure you want to quit?": "You seem to be uploading files, are you sure you want to quit?", @@ -2630,24 +2683,27 @@ "%(count)s rooms|one": "%(count)s room", "This room is suggested as a good one to join": "This room is suggested as a good one to join", "Suggested": "Suggested", + "Your server does not support showing space hierarchies.": "Your server does not support showing space hierarchies.", "%(count)s rooms and %(numSpaces)s spaces|other": "%(count)s rooms and %(numSpaces)s spaces", "%(count)s rooms and %(numSpaces)s spaces|one": "%(count)s room and %(numSpaces)s spaces", "%(count)s rooms and 1 space|other": "%(count)s rooms and 1 space", "%(count)s rooms and 1 space|one": "%(count)s room and 1 space", + "Select a room below first": "Select a room below first", "Failed to remove some rooms. Try again later": "Failed to remove some rooms. Try again later", "Removing...": "Removing...", "Mark as not suggested": "Mark as not suggested", "Mark as suggested": "Mark as suggested", "No results found": "No results found", "You may want to try a different search or check for typos.": "You may want to try a different search or check for typos.", - "Your server does not support showing space hierarchies.": "Your server does not support showing space hierarchies.", - "Search names and description": "Search names and description", + "Search names and descriptions": "Search names and descriptions", "If you can't find the room you're looking for, ask for an invite or create a new room.": "If you can't find the room you're looking for, ask for an invite or create a new room.", "Create room": "Create room", + "Spaces are a beta feature.": "Spaces are a beta feature.", "Public space": "Public space", "Private space": "Private space", " invites you": " invites you", - "Add existing rooms & spaces": "Add existing rooms & spaces", + "To view %(spaceName)s, turn on the Spaces beta": "To view %(spaceName)s, turn on the Spaces beta", + "To join %(spaceName)s, turn on the Spaces beta": "To join %(spaceName)s, turn on the Spaces beta", "Welcome to ": "Welcome to ", "Random": "Random", "Support": "Support", @@ -2655,9 +2711,12 @@ "Failed to create initial space rooms": "Failed to create initial space rooms", "Skip for now": "Skip for now", "Creating rooms...": "Creating rooms...", + "What do you want to organise?": "What do you want to organise?", + "Pick rooms or conversations to add. This is just a space for you, no one will be informed. You can add more later.": "Pick rooms or conversations to add. This is just a space for you, no one will be informed. You can add more later.", "Share %(name)s": "Share %(name)s", "It's just you at the moment, it will be even better with others.": "It's just you at the moment, it will be even better with others.", "Go to my first room": "Go to my first room", + "Go to my space": "Go to my space", "Who are you working with?": "Who are you working with?", "Make sure the right people have access to %(name)s": "Make sure the right people have access to %(name)s", "Just me": "Just me", @@ -2668,6 +2727,7 @@ "Inviting...": "Inviting...", "Invite your teammates": "Invite your teammates", "Make sure the right people have access. You can invite more later.": "Make sure the right people have access. You can invite more later.", + "This is an experimental feature. For now, new users receiving an invite will have to open the invite on to actually join.": "This is an experimental feature. For now, new users receiving an invite will have to open the invite on to actually join.", "Invite by username": "Invite by username", "What are some things you want to discuss in %(spaceName)s?": "What are some things you want to discuss in %(spaceName)s?", "Let's create a room for each of them.": "Let's create a room for each of them.", @@ -2780,6 +2840,7 @@ "Room Notification": "Room Notification", "Notification Autocomplete": "Notification Autocomplete", "Room Autocomplete": "Room Autocomplete", + "Space Autocomplete": "Space Autocomplete", "Users": "Users", "User Autocomplete": "User Autocomplete", "We'll store an encrypted copy of your keys on our server. Secure your backup with a Security Phrase.": "We'll store an encrypted copy of your keys on our server. Secure your backup with a Security Phrase.", @@ -2791,12 +2852,11 @@ "Use a different passphrase?": "Use a different passphrase?", "That doesn't match.": "That doesn't match.", "Go back to set it again.": "Go back to set it again.", - "Please enter your Security Phrase a second time to confirm.": "Please enter your Security Phrase a second time to confirm.", + "Enter your Security Phrase a second time to confirm it.": "Enter your Security Phrase a second time to confirm it.", "Repeat your Security Phrase...": "Repeat your Security Phrase...", "Your Security Key is a safety net - you can use it to restore access to your encrypted messages if you forget your Security Phrase.": "Your Security Key is a safety net - you can use it to restore access to your encrypted messages if you forget your Security Phrase.", "Keep a copy of it somewhere secure, like a password manager or even a safe.": "Keep a copy of it somewhere secure, like a password manager or even a safe.", "Your Security Key": "Your Security Key", - "Download": "Download", "Your Security Key has been copied to your clipboard, paste it to:": "Your Security Key has been copied to your clipboard, paste it to:", "Your Security Key is in your Downloads folder.": "Your Security Key is in your Downloads folder.", "Print it and store it somewhere safe": "Print it and store it somewhere safe", @@ -2822,8 +2882,6 @@ "You'll need to authenticate with the server to confirm the upgrade.": "You'll need to authenticate with the server to confirm the upgrade.", "Upgrade this session to allow it to verify other sessions, granting them access to encrypted messages and marking them as trusted for other users.": "Upgrade this session to allow it to verify other sessions, granting them access to encrypted messages and marking them as trusted for other users.", "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.": "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.", - "Enter your recovery passphrase a second time to confirm it.": "Enter your recovery passphrase a second time to confirm it.", - "Confirm your recovery passphrase": "Confirm your recovery passphrase", "Store your Security Key somewhere safe, like a password manager or a safe, as it’s used to safeguard your encrypted data.": "Store your Security Key somewhere safe, like a password manager or a safe, as it’s used to safeguard your encrypted data.", "Unable to query secret storage status": "Unable to query secret storage status", "If you cancel now, you may lose encrypted messages & data if you lose access to your logins.": "If you cancel now, you may lose encrypted messages & data if you lose access to your logins.", diff --git a/src/i18n/strings/en_US.json b/src/i18n/strings/en_US.json index a1275fb089..a5d7756de8 100644 --- a/src/i18n/strings/en_US.json +++ b/src/i18n/strings/en_US.json @@ -650,5 +650,21 @@ "Error upgrading room": "Error upgrading room", "Double check that your server supports the room version chosen and try again.": "Double check that your server supports the room version chosen and try again.", "Changes the avatar of the current room": "Changes the avatar of the current room", - "Changes your avatar in all rooms": "Changes your avatar in all rooms" + "Changes your avatar in all rooms": "Changes your avatar in all rooms", + "Favourited": "Favorited", + "Explore rooms": "Explore rooms", + "Click the button below to confirm adding this email address.": "Click the button below to confirm adding this email address.", + "Confirm adding email": "Confirm adding email", + "Single Sign On": "Single Sign On", + "Confirm adding this email address by using Single Sign On to prove your identity.": "Confirm adding this email address by using Single Sign On to prove your identity.", + "Use Single Sign On to continue": "Use Single Sign On to continue", + "Message search initilisation failed": "Message search initialization failed", + "%(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 now uses 3-5x less memory, by only loading information about other users when needed. Please wait while we resynchronize with the server!", + "Customise your experience with experimental labs features. Learn more.": "Customize your experience with experimental labs features. Learn more.", + "Customise your appearance": "Customize your appearance", + "Unrecognised command: %(commandText)s": "Unrecognized command: %(commandText)s", + "Add some details to help people recognise it.": "Add some details to help people recognize it.", + "Unrecognised room address:": "Unrecognized room address:", + "A private space to organise your rooms": "A private space to organize your rooms", + "Message search initialisation failed": "Message search initialization failed" } diff --git a/src/i18n/strings/eo.json b/src/i18n/strings/eo.json index 390fb74099..f4d30b40b7 100644 --- a/src/i18n/strings/eo.json +++ b/src/i18n/strings/eo.json @@ -1671,7 +1671,7 @@ "More options": "Pliaj elektebloj", "Integrations are disabled": "Kunigoj estas malŝaltitaj", "Integrations not allowed": "Kunigoj ne estas permesitaj", - "Suggestions": "Proponoj", + "Suggestions": "Rekomendoj", "Automatically invite users": "Memage inviti uzantojn", "Upgrade private room": "Gradaltigi privatan ĉambron", "Upgrade public room": "Gradaltigi publikan ĉambron", @@ -2113,7 +2113,7 @@ "Delete sessions|other": "Forigi salutaĵojn", "Delete sessions|one": "Forigi salutaĵon", "Where you’re logged in": "Kie vi salutis", - "Manage the names of and sign out of your sessions below or verify them in your User Profile.": "Sube administru la nomojn de viaj salutaĵoj kaj ilin adiaŭu, aŭ kontrolu ilin en via profilo de uzanto.", + "Manage the names of and sign out of your sessions below or verify them in your User Profile.": "Sube administru la nomojn de viaj salutaĵoj kaj ilin adiaŭu, aŭ ilin kontrolu en via profilo de uzanto.", "Waiting for you to accept on your other session…": "Atendante vian akcepton en via alia salutaĵo…", "Almost there! Is your other session showing the same shield?": "Preskaŭ finite! Ĉu via alia salutaĵo montras la saman ŝildon?", "Almost there! Is %(displayName)s showing the same shield?": "Preskaŭ finite! Ĉu %(displayName)s montras la saman ŝildon?", @@ -2376,7 +2376,7 @@ "Set a Security Phrase": "Agordi Sekurecan frazon", "Confirm Security Phrase": "Konfirmi Sekurecan frazon", "Save your Security Key": "Konservi vian Sekurecan ŝlosilon", - "New spinner design": "Nova fasono de la turniĝilo", + "New spinner design": "Nova aspekto de la atendosimbolo", "Show rooms with unread messages first": "Montri ĉambrojn kun nelegitaj mesaĝoj kiel unuajn", "Show previews of messages": "Montri antaŭrigardojn al mesaĝoj", "This room is public": "Ĉi tiu ĉambro estas publika", @@ -3013,11 +3013,11 @@ "Just a heads up, if you don't add an email and forget your password, you could permanently lose access to your account.": "Averte, se vi ne aldonos retpoŝtadreson kaj poste forgesos vian pasvorton, vi eble por ĉiam perdos aliron al via konto.", "Continuing without email": "Daŭrigante sen retpoŝtadreso", "We recommend you change your password and Security Key in Settings immediately": "Ni rekomendas, ke vi tuj ŝanĝu viajn pasvorton kaj Sekurecan ŝlosilon per la Agordoj", - "Transfer": "Transigi", + "Transfer": "Transdoni", "Invite someone using their name, email address, username (like ) or share this room.": "Invitu iun per ĝia nomo, retpoŝtadreso, uzantonomo (ekz. ), aŭ konigu ĉi tiun ĉambron.", "Start a conversation with someone using their name, email address or username (like ).": "Komencu interparolon kun iu per ĝia nomo, retpoŝtadreso, aŭ uzantonomo (ekz. ).", - "Failed to transfer call": "Malsukcesis transigi vokon", - "A call can only be transferred to a single user.": "Voko povas transiĝi nur al unu uzanto.", + "Failed to transfer call": "Malsukcesis transdoni vokon", + "A call can only be transferred to a single user.": "Voko povas transdoniĝi nur al unu uzanto.", "Learn more in our , and .": "Eksciu plion per niaj , kaj .", "Failed to connect to your homeserver. Please close this dialog and try again.": "Malsukcesis konektiĝi al via hejmservilo. Bonvolu fermi ĉi tiun interagujon kaj reprovi.", "Edit Values": "Redakti valorojn", @@ -3045,5 +3045,178 @@ "Use Ctrl + F to search": "Serĉu per stirklavo (Ctrl) + F", "Use Command + F to search": "Serĉu per komanda klavo + F", "%(senderDisplayName)s changed the server ACLs for this room.": "%(senderDisplayName)s ŝanĝis la servilblokajn listojn por ĉi tiu ĉambro.", - "%(senderDisplayName)s set the server ACLs for this room.": "%(senderDisplayName)s agordis la servilblokajn listojn por ĉi tiu ĉambro." + "%(senderDisplayName)s set the server ACLs for this room.": "%(senderDisplayName)s agordis la servilblokajn listojn por ĉi tiu ĉambro.", + "Public": "Publika", + "Delete": "Forigi", + "From %(deviceName)s (%(deviceId)s) at %(ip)s": "De %(deviceName)s (%(deviceId)s) de %(ip)s", + "Jump to the bottom of the timeline when you send a message": "Salti al subo de historio sendinte mesaĝon", + "Check your devices": "Kontrolu viajn aparatojn", + "A new login is accessing your account: %(name)s (%(deviceID)s) at %(ip)s": "Nova saluto aliras vian konton: %(name)s (%(deviceID)s) de %(ip)s", + "You have unverified logins": "Vi havas nekontrolitajn salutojn", + "You're already in a call with this person.": "Vi jam vokas ĉi tiun personon.", + "Already in call": "Jam vokanta", + "

    HTML for your community's page

    \n

    \n Use the long description to introduce new members to the community, or distribute\n some important links\n

    \n

    \n You can even add images with Matrix URLs \n

    \n": "

    HTML por la paĝo de via komunumo

    \n

    \n Uzu la longan priskribon por enkonduki novajn anojn en la komunumon, aŭ disdoni\n kelkajn gravajn ligilojn.\n

    \n

    \n Vi povas eĉ aldoni bildojn per Matriks-URL \n

    \n", + "View dev tools": "Montri programistilojn", + "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.": "Ĉi tiu kutime influas nur traktadon de la ĉambro servil-flanke. Se vi spertas problemojn pri via %(brand)s, bonvolu raporti eraron.", + "Mark as suggested": "Marki rekomendata", + "Mark as not suggested": "Marki nerekomendata", + "Suggested": "Rekomendata", + "This room is suggested as a good one to join": "Ĉi tiu ĉambro estas rekomendata kiel aliĝinda", + "Suggested Rooms": "Rekomendataj ĉambroj", + "Failed to create initial space rooms": "Malsukcesis krei komencajn ĉambrojn de aro", + "Room name": "Nomo de ĉambro", + "Support": "Subteno", + "Random": "Hazarda", + "Welcome to ": "Bonvenu al ", + "Your private space ": "Via privata aro ", + "Your public space ": "Via publika aro ", + "Your server does not support showing space hierarchies.": "Via servilo ne subtenas montradon de hierarĥioj de aroj.", + "Add existing rooms & spaces": "Aldoni jamajn ĉambrojn kaj arojn", + "Private space": "Privata aro", + "Public space": "Publika aro", + " invites you": " invitas vin", + "Search names and description": "Serĉi nomojn kaj priskribojn", + "No results found": "Neniuj rezultoj troviĝis", + "Removing...": "Forigante…", + "Failed to remove some rooms. Try again later": "Malsukcesis forigi iujn arojn. Reprovu poste", + "%(count)s rooms and 1 space|one": "%(count)s ĉambro kaj 1 aro", + "%(count)s rooms and 1 space|other": "%(count)s ĉambroj kaj 1 aro", + "%(count)s rooms and %(numSpaces)s spaces|one": "%(count)s ĉambro kaj %(numSpaces)s aroj", + "%(count)s rooms and %(numSpaces)s spaces|other": "%(count)s ĉambroj kaj %(numSpaces)s aroj", + "If you can't find the room you're looking for, ask for an invite or create a new room.": "Se vi ne povas trovi la ĉambron, kiun vi serĉas, petu inviton aŭ kreu novan ĉambron.", + "%(count)s rooms|one": "%(count)s ĉambro", + "%(count)s rooms|other": "%(count)s ĉambroj", + "%(count)s members|one": "%(count)s ano", + "%(count)s members|other": "%(count)s anoj", + "Open": "Malfermi", + "%(count)s messages deleted.|one": "%(count)s mesaĝo foriĝis.", + "%(count)s messages deleted.|other": "%(count)s mesaĝoj foriĝis.", + "Are you sure you want to leave the space '%(spaceName)s'?": "Ĉu vi certe volas forlasi la aron «%(spaceName)s»?", + "This space is not public. You will not be able to rejoin without an invite.": "Ĉi tiu aro ne estas publika. Vi ne povos re-aliĝi sen invito.", + "Upgrade to %(hostSignupBrand)s": "Gradaltigi al %(hostSignupBrand)s", + "Start audio stream": "Komenci sonelsendon", + "Failed to start livestream": "Malsukcesis komenci tujelsendon", + "Unable to start audio streaming.": "Ne povas komenci sonelsendon.", + "Save Changes": "Konservi ŝanĝojn", + "Saving...": "Konservante…", + "Leave Space": "Forlasi aron", + "Make this space private": "Privatigi ĉi tiun aron", + "Edit settings relating to your space.": "Redaktu agordojn pri via aro.", + "Space settings": "Agordoj de aro", + "Failed to save space settings.": "Malsukcesis konservi agordojn de aro.", + "Invite someone using their name, username (like ) or share this space.": "Invitu iun per ĝia nomo, uzantonomo (kiel ), aŭ diskonigu ĉi tiun aron.", + "Invite someone using their name, email address, username (like ) or share this space.": "Invitu iun per ĝia nomo, retpoŝtadreso, uzantonomo (kiel ), aŭ diskonigu ĉi tiun aron.", + "Invite to %(roomName)s": "Inviti al %(roomName)s", + "Unnamed Space": "Sennoma aro", + "Invite to %(spaceName)s": "Inviti al %(spaceName)s", + "Abort": "Nuligi", + "Don't want to add an existing room?": "Ĉu vi ne volas aldoni jaman ĉambron?", + "Failed to add rooms to space": "Malsukcesis aldoni ĉambrojn al aro", + "Apply": "Apliki", + "Applying...": "Aplikante…", + "Create a new room": "Krei novan ĉambron", + "Spaces": "Aroj", + "Filter your rooms and spaces": "Filtru viajn ĉambrojn kaj arojn", + "Add existing spaces/rooms": "Aldoni jamajn arojn/ĉambrojn", + "Space selection": "Elekto de aro", + "Edit devices": "Redakti aparatojn", + "You will not be able to undo this change as you are demoting yourself, if you are the last privileged user in the space it will be impossible to regain privileges.": "Vi ne povos malfari ĉi tiun ŝanĝon, ĉar vi malrangaltigas vin mem; se vi estas la lasta altranga uzanto de la aro, vi ne plu povos rehavi viajn rajtojn.", + "Invite People": "Inviti personojn", + "Empty room": "Malplena ĉamrbo", + "You do not have permissions to add rooms to this space": "Vi ne havas permeson aldoni ĉambrojn al ĉi tiu aro", + "Explore space rooms": "Esplori ĉambrojn de aro", + "Add existing room": "Aldoni jaman ĉambron", + "You do not have permissions to create new rooms in this space": "Vi ne havas permeson krei novajn ĉambrojn en ĉi tiu aro", + "Send message": "Sendi mesaĝon", + "Invite to this space": "Inviti al ĉi tiu aro", + "Your message was sent": "Via mesaĝo sendiĝis", + "Encrypting your message...": "Ĉifrante mesaĝon…", + "Sending your message...": "Sendante mesaĝon…", + "New room": "Nova ĉambro", + "Leave space": "Forlasi aron", + "Share your public space": "Diskonigu vian publikan aron", + "Invite members": "Inviti anojn", + "Invite with email or username": "Inviti per retpoŝtadreso aŭ uzantonomo", + "Invite people": "Inviti personojn", + "Share invite link": "Diskonigi invitan ligilon", + "Click to copy": "Klaku por kopii", + "Collapse space panel": "Maletendi arbreton", + "Expand space panel": "Etendi arbreton", + "Creating...": "Kreante…", + "You can change these anytime.": "Vi povas ŝanĝi ĉi tiujn kiam ajn vi volas.", + "Add some details to help people recognise it.": "Aldonu kelkajn detalojn, por ke ĝi estu rekonebla.", + "Your private space": "Via privata aro", + "Your public space": "Via publika aro", + "You can change this later": "Vi povas ŝanĝi ĉi tion poste", + "Invite only, best for yourself or teams": "Nur invita, ideala por vi mem aŭ por skipoj", + "Private": "Privata", + "Open space for anyone, best for communities": "Malferma aro por ĉiu ajn, ideala por komunumoj", + "Spaces are new ways to group rooms and people. To join an existing space you'll need an invite.": "Aroj estas novaj manieroj grupigi ĉambrojn kaj personojn. Por aliĝi al aro, vi bezonas inviton.", + "Create a space": "Krei aron", + "Spaces prototype. Incompatible with Communities, Communities v2 and Custom Tags. Requires compatible homeserver for some features.": "Pratipo de Aroj. Malkonforma kun Komunumoj, Komunumoj v2, kaj Propraj etikedoj. Bezonas konforman hejmservilon por iuj funkcioj.", + "Verify this login to access your encrypted messages and prove to others that this login is really you.": "Kontrolu ĉi tiun saluton por aliri viajn ĉifritajn mesaĝojn, kaj pruvi al aliuloj, ke la salutanto vere estas vi.", + "Verify with another session": "Knotroli per alia salutaĵo", + "Original event source": "Originala fonto de evento", + "Decrypted event source": "Malĉifrita fonto de evento", + "We'll create rooms for each of them. You can add more later too, including already existing ones.": "Por ĉiu el ili ni kreos ĉambron. Vi povos aldoni pliajn pli poste, inkluzive jam ekzistantajn.", + "What projects are you working on?": "Kiujn projektojn vi prilaboras?", + "Let's create a room for each of them. You can add more later too, including already existing ones.": "Ni kreu ĉambron por ĉiu el ili. Vi povas aldoni pliajn poste, inkluzive jam ekzistantajn.", + "What are some things you want to discuss?": "Pri kio volas vi paroli?", + "Invite by username": "Inviti per uzantonomo", + "Make sure the right people have access. You can invite more later.": "Certigu, ke la ĝustaj personoj povas aliri. Vi povas inviti pliajn pli poste.", + "Invite your teammates": "Invitu viajn kunulojn", + "Inviting...": "Invitante…", + "Failed to invite the following users to your space: %(csvUsers)s": "Malsukcesis inviti la jenajn uzantojn al via aro: %(csvUsers)s", + "A private space for you and your teammates": "Privata aro por vi kaj viaj kunuloj", + "Me and my teammates": "Mi kaj miaj kunuloj", + "A private space to organise your rooms": "Privata aro por organizado de viaj ĉambroj", + "Just me": "Nur mi", + "Make sure the right people have access to %(name)s": "Certigu, ke la ĝustaj personoj povas aliri al %(name)s", + "Who are you working with?": "Kun kiu vi laboras?", + "Go to my first room": "Eniri mian unuan ĉambron", + "It's just you at the moment, it will be even better with others.": "Nun estas sole vi; estos eĉ pli bone kun aliuloj.", + "Share %(name)s": "Diskonigi %(name)s", + "Creating rooms...": "Kreante ĉambrojn…", + "You may want to try a different search or check for typos.": "Eble vi provu serĉi alion, aŭ kontroli je mistajpoj.", + "You don't have permission": "Vi ne rajtas", + "Values at explicit levels in this room:": "Valoroj por malimplicitaj niveloj en ĉi tiu ĉambro:", + "Values at explicit levels:": "Valoroj por malimplicitaj niveloj:", + "Save setting values": "Konservi valorojn de la agordoj", + "Values at explicit levels in this room": "Valoroj por malimplicitaj niveloj en ĉi tiu ĉambro", + "Values at explicit levels": "Valoroj por malimplicitaj niveloj", + "Spell check dictionaries": "Literumadaj vortaroj", + "Space options": "Agordoj de aro", + "Space Home": "Hejmo de aro", + "with state key %(stateKey)s": "kun statŝlosilo %(stateKey)s", + "with an empty state key": "kun malplena statŝlosilo", + "Invited people will be able to read old messages.": "Invititoj povos legi malnovajn mesaĝojn.", + "Adding...": "Aldonante…", + "Add existing rooms": "Aldoni jamajn ĉambrojn", + "View message": "Montri mesaĝon", + "Zoom in": "Zomi", + "Zoom out": "Malzomi", + "%(count)s people you know have already joined|one": "%(count)s persono, kiun vi konas, jam aliĝis", + "%(count)s people you know have already joined|other": "%(count)s personoj, kiujn vi konas, jam aliĝis", + "%(count)s members including %(commaSeparatedMembers)s|one": "%(commaSeparatedMembers)s", + "%(count)s members including %(commaSeparatedMembers)s|other": "%(count)s anoj inkluzive je %(commaSeparatedMembers)s", + "Including %(commaSeparatedMembers)s": "Inkluzive je %(commaSeparatedMembers)s", + "View all %(count)s members|one": "Montri 1 anon", + "View all %(count)s members|other": "Montri ĉiujn %(count)s anojn", + "Accept on your other login…": "Akceptu per via alia saluto…", + "Stop & send recording": "Ĉesi kaj sendi registrajon", + "Record a voice message": "Registri voĉmesaĝon", + "Quick actions": "Rapidaj agoj", + "Invite to just this room": "Inviti nur al ĉi tiu ĉambro", + "%(seconds)ss left": "%(seconds)s sekundoj restas", + "Failed to send": "Malsukcesis sendi", + "Change server ACLs": "Ŝanĝi servilblokajn listojn", + "Warn before quitting": "Averti antaŭ ĉesigo", + "Workspace: ": "Laborspaco: ", + "Manage & explore rooms": "Administri kaj esplori ĉambrojn", + "unknown person": "nekonata persono", + "Send and receive voice messages (in development)": "Sendi kaj ricevi voĉmesaĝojn (evoluigate)", + "Show options to enable 'Do not disturb' mode": "Montri elekteblojn por ŝalti sendistran reĝimon", + "%(deviceId)s from %(ip)s": "%(deviceId)s de %(ip)s", + "Review to ensure your account is safe": "Kontrolu por certigi sekurecon de via konto", + "Sends the given message as a spoiler": "Sendas la donitan mesaĝon kiel malkaŝon de intrigo" } diff --git a/src/i18n/strings/es.json b/src/i18n/strings/es.json index 3965433b8c..0e4d50210a 100644 --- a/src/i18n/strings/es.json +++ b/src/i18n/strings/es.json @@ -1941,7 +1941,7 @@ "User Status": "Estado de usuario", "This homeserver would like to make sure you are not a robot.": "A este servidor le gustaría asegurarse de que no eres un robot.", "Country Dropdown": "Seleccione país", - "Confirm your identity by entering your account password below.": "Confirme su identidad introduciendo la contraseña de su cuenta.", + "Confirm your identity by entering your account password below.": "Confirma tu identidad introduciendo la contraseña de tu cuenta.", "Missing captcha public key in homeserver configuration. Please report this to your homeserver administrator.": "Falta la clave pública del captcha en la configuración del servidor base. Por favor, informa de esto al administrador de tu servidor base.", "Please review and accept all of the homeserver's policies": "Por favor, revisa y acepta todas las políticas del servidor base", "Please review and accept the policies of this homeserver:": "Por favor, revisa y acepta las políticas de este servidor base:", @@ -2102,7 +2102,7 @@ "Communities v2 prototypes. Requires compatible homeserver. Highly experimental - use with caution.": "Prototipo de comunidades v2. Requiere un servidor compatible. Altamente experimental - usar con precuación.", "Font size": "Tamaño del texto", "Use custom size": "Usar un tamaño personalizado", - "Use a more compact ‘Modern’ layout": "Usar un diseño más «moderno y compacto", + "Use a more compact ‘Modern’ layout": "Usar un diseño más «moderno y compacto»", "Use a system font": "Usar una fuente del sistema", "System font name": "Nombre de la fuente", "Enable experimental, compact IRC style layout": "Activar el diseño experimental de IRC compacto", @@ -3070,7 +3070,7 @@ "%(count)s members|other": "%(count)s miembros", "Your server does not support showing space hierarchies.": "Este servidor no soporta mostrar jerarquías de espacios.", "Default Rooms": "Salas por defecto", - "Add existing rooms & spaces": "Añadir salas y espacios ya existentes", + "Add existing rooms & spaces": "Añadir salas y espacios existentes", "Accept Invite": "Aceptar invitación", "Manage rooms": "Gestionar salas", "Save changes": "Guardar cambios", @@ -3098,11 +3098,11 @@ "Invite to %(spaceName)s": "Invitar a %(spaceName)s", "Failed to add rooms to space": "No se han podido añadir las salas al espacio", "Apply": "Aplicar", - "Create a new room": "Crea una nueva", + "Create a new room": "Crear una nueva sala", "Don't want to add an existing room?": "¿No quieres añadir una sala que ya exista?", "Spaces": "Espacios", "Filter your rooms and spaces": "Filtra tus salas y espacios", - "Add existing spaces/rooms": "Añadir espacios o salas ya existentes", + "Add existing spaces/rooms": "Añadir espacios o salas existentes", "Space selection": "Selección de espacio", "Empty room": "Sala vacía", "Suggested Rooms": "Salas sugeridas", @@ -3141,5 +3141,169 @@ "Spaces prototype. Incompatible with Communities, Communities v2 and Custom Tags. Requires compatible homeserver for some features.": "Prototipo de espacios. No compatible con comunidades, comunidades v2 o etiquetas personalizadas. Necesita un servidor base compatible para algunas funcionalidades.", "This homeserver has been blocked by its administrator.": "Este servidor base ha sido bloqueado por su administración.", "This homeserver has been blocked by it's administrator.": "Este servidor base ha sido bloqueado por su administración.", - "You're already in a call with this person.": "Ya estás en una llamada con esta persona." + "You're already in a call with this person.": "Ya estás en una llamada con esta persona.", + "This room is suggested as a good one to join": "Unirse a esta sala está sugerido", + "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.": "Esto solo afecta normalmente a cómo el servidor procesa la sala. Si estás teniendo problemas con %(brand)s, por favor, infórmanos del problema.", + "It's just you at the moment, it will be even better with others.": "Ahora mismo no hay nadie más.", + "Verify this login to access your encrypted messages and prove to others that this login is really you.": "Verifica este inicio de sesión para acceder a tus mensajes cifrados y probar a otras personas que realmente eres tú quien está iniciando sesión.", + "Verify with another session": "Verificar con otra sesión", + "We'll create rooms for each of them. You can add more later too, including already existing ones.": "Crearemos salas para cada uno. Puedes añadir más después, incluso salas que ya existan.", + "Let's create a room for each of them. You can add more later too, including already existing ones.": "Vamos a crear una sala para cada uno. Puedes añadir más después, incluso salas que ya existan.", + "Make sure the right people have access. You can invite more later.": "Vamos a asegurarnos de que solo la gente adecuada tiene acceso. Puedes invitar a más después.", + "A private space to organise your rooms": "Un espacio privado para organizar tus salas", + "Make sure the right people have access to %(name)s": "Vamos a asegurarnos de que solo la gente adecuada tiene acceso a %(name)s", + "Just me": "Solo yo", + "Go to my first room": "Ir a mi primera sala", + "Share %(name)s": "Compartir %(name)s", + "Private space": "Espacio privado", + "Public space": "Espacio público", + " invites you": " te ha invitado", + "Search names and description": "Buscar nombres y descripciones", + "You may want to try a different search or check for typos.": "Prueba con otro término de búsqueda o comprueba que no haya erratas.", + "Create room": "Crear sala", + "No results found": "Ningún resultado", + "Mark as suggested": "Sugerir", + "Mark as not suggested": "No sugerir", + "Removing...": "Quitando...", + "Failed to remove some rooms. Try again later": "No se han podido quitar algunas salas. Prueba de nuevo más tarde", + "%(count)s rooms and 1 space|one": "%(count)s sala y 1 espacio", + "%(count)s rooms and 1 space|other": "%(count)s salas y 1 espacio", + "%(count)s rooms and %(numSpaces)s spaces|one": "%(count)s sala y %(numSpaces)s espacios", + "%(count)s rooms and %(numSpaces)s spaces|other": "%(count)s salas y %(numSpaces)s espacios", + "If you can't find the room you're looking for, ask for an invite or create a new room.": "Si no encuentras la sala que estás buscando, pide que te inviten o crea una nueva.", + "Suggested": "Sugerencias", + "%(count)s rooms|one": "%(count)s sala", + "%(count)s rooms|other": "%(count)s salas", + "You don't have permission": "No tienes permisos", + "Open": "Abrir", + "%(count)s messages deleted.|one": "%(count)s mensaje eliminado.", + "%(count)s messages deleted.|other": "%(count)s mensajes eliminados.", + "Invite to %(roomName)s": "Invitar a %(roomName)s", + "Edit devices": "Editar dispositivos", + "Invite People": "Invitar a gente", + "Invite with email or username": "Invitar correos electrónicos o nombres de usuario", + "You can change these anytime.": "Puedes cambiar todo esto en cualquier momento.", + "Add some details to help people recognise it.": "Añade algún detalle para ayudar a que la gente lo reconozca.", + "Spaces are new ways to group rooms and people. To join an existing space you'll need an invite.": "Los espacios son la nueva manera de agrupar personas y salas. Para unirte a un espacio, necesitarás que te inviten.", + "From %(deviceName)s (%(deviceId)s) at %(ip)s": "De %(deviceName)s (%(deviceId)s) en", + "Check your devices": "Comprueba tus dispositivos", + "A new login is accessing your account: %(name)s (%(deviceID)s) at %(ip)s": "Alguien está iniciando sesión a tu cuenta: %(name)s (%(deviceID)s) en %(ip)s", + "You have unverified logins": "Tienes inicios de sesión sin verificar", + "Verification requested": "Verificación solicitada", + "Avatar": "Imagen de perfil", + "Verify other login": "Verificar otro inicio de sesión", + "Consult first": "Consultar primero", + "Invited people will be able to read old messages.": "Las personas invitadas podrán leer mensajes antiguos.", + "We couldn't create your DM.": "No hemos podido crear tu mensaje directo.", + "Adding...": "Añadiendo...", + "Add existing rooms": "Añadir salas existentes", + "%(count)s people you know have already joined|one": "%(count)s persona que ya conoces se ha unido", + "%(count)s people you know have already joined|other": "%(count)s personas que ya conoces se han unido", + "Accept on your other login…": "Acepta en tu otro inicio de sesión…", + "Stop & send recording": "Parar y enviar grabación", + "Record a voice message": "Grabar un mensaje de voz", + "Quick actions": "Acciones rápidas", + "Invite to just this room": "Invitar solo a esta sala", + "Warn before quitting": "Avisar antes de salir", + "Manage & explore rooms": "Gestionar y explorar salas", + "unknown person": "persona desconocida", + "Share decryption keys for room history when inviting users": "Compartir claves para descifrar el historial de la sala al invitar a gente", + "Send and receive voice messages (in development)": "Enviar y recibir mensajes de voz (en desarrollo)", + "%(deviceId)s from %(ip)s": "%(deviceId)s desde %(ip)s", + "Review to ensure your account is safe": "Revisa que tu cuenta esté segura", + "Sends the given message as a spoiler": "Envía el mensaje como un spoiler", + "Consulting with %(transferTarget)s. Transfer to %(transferee)s": "Consultando a %(transferTarget)s. Transferir a %(transferee)s", + "Message search initilisation failed": "Ha fallado la inicialización de la búsqueda de mensajes", + "Reset event store?": "¿Restablecer almacenamiento de eventos?", + "You most likely do not want to reset your event index store": "Lo más probable es que no quieras restablecer tu almacenamiento de índice de ecentos", + "If you do, please note that none of your messages will be deleted, but the search experience might be degraded for a few momentswhilst the index is recreated": "Si lo haces, ten en cuenta que no se borrarán tus mensajes, pero la experiencia de búsqueda será peor durante unos momentos mientras se recrea el índice", + "Reset event store": "Restablecer el almacenamiento de eventos", + "What are some things you want to discuss in %(spaceName)s?": "¿De qué quieres hablar en %(spaceName)s?", + "Let's create a room for each of them.": "Crearemos una sala para cada uno.", + "You can add more later too, including already existing ones.": "Puedes añadir más después, incluso si ya existen.", + "Please choose a strong password": "Por favor, elige una contraseña segura", + "Use another login": "Usar otro inicio de sesión", + "Verify your identity to access encrypted messages and prove your identity to others.": "Verifica tu identidad para acceder a mensajes cifrados y probar tu identidad a otros.", + "Without verifying, you won’t have access to all your messages and may appear as untrusted to others.": "Si no verificas no tendrás acceso a todos tus mensajes y puede que aparezcas como no confiable para otros usuarios.", + "Invite messages are hidden by default. Click to show the message.": "Los mensajes de invitación no se muestran por defecto. Haz clic para mostrarlo.", + "You can select all or individual messages to retry or delete": "Puedes seleccionar uno o todos los mensajes para reintentar o eliminar", + "Sending": "Enviando", + "Retry all": "Reintentar todo", + "Delete all": "Borrar todo", + "Some of your messages have not been sent": "Algunos de tus mensajes no se han enviado", + "You are the only person here. If you leave, no one will be able to join in the future, including you.": "Eres la única persona aquí. Si te vas, no podrá unirse nadie en el futuro, incluyéndote a ti.", + "Forgotten or lost all recovery methods? Reset all": "¿Has olvidado o perdido todos los métodos de recuperación? Restablecer todo", + "If you reset everything, you will restart with no trusted sessions, no trusted users, and might not be able to see past messages.": "Si restableces todo, volverás a empezar sin sesiones ni usuarios de confianza, y puede que no puedas ver mensajes anteriores.", + "Only do this if you have no other device to complete verification with.": "Solo haz esto si no tienes ningún otro dispositivo con el que completar la verificación.", + "Reset everything": "Restablecer todo", + "If you do, please note that none of your messages will be deleted, but the search experience might be degraded for a few moments whilst the index is recreated": "Si lo haces, ten en cuenta que ninguno de tus mensajes serán eliminados, pero la experiencia de búsqueda será peor durante unos momentos mientras recreamos el índice", + "View message": "Ver mensaje", + "Zoom in": "Acercar", + "Zoom out": "Alejar", + "%(count)s members including %(commaSeparatedMembers)s|one": "%(commaSeparatedMembers)s", + "%(count)s members including %(commaSeparatedMembers)s|other": "%(count)s miembros, incluyendo a %(commaSeparatedMembers)s", + "Including %(commaSeparatedMembers)s": "Incluyendo %(commaSeparatedMembers)s", + "View all %(count)s members|one": "Ver 1 miembro", + "View all %(count)s members|other": "Ver los %(count)s miembros", + "%(seconds)ss left": "%(seconds)ss restantes", + "Failed to send": "No se ha podido mandar", + "Change server ACLs": "Cambiar los ACLs del servidor", + "Show options to enable 'Do not disturb' mode": "Mostrar opciones para activar el modo «no molestar»", + "Stop the recording": "Parar grabación", + "Delete recording": "Borrar grabación", + "Enter your Security Phrase a second time to confirm it.": "Escribe tu frase de seguridad de nuevo para confirmarla.", + "Pick rooms or conversations to add. This is just a space for you, no one will be informed. You can add more later.": "Elige salas o conversaciones para añadirlas. Este espacio es solo para ti, no informaremos a nadie. Puedes añadir más más tarde.", + "What do you want to organise?": "¿Qué quieres organizar?", + "Filter all spaces": "Filtrar todos los espacios", + "%(count)s results in all spaces|one": "%(count)s resultado en todos los espacios", + "%(count)s results in all spaces|other": "%(count)s resultados en todos los espacios", + "You have no ignored users.": "No has ignorado a nadie.", + "Pause": "Pausar", + "Play": "Reproducir", + "This is an experimental feature. For now, new users receiving an invite will have to open the invite on to actually join.": "Esto es una funcionalidad experimental. Por ahora, los usuarios nuevos que reciban una invitación tendrán que abrirla en para unirse.", + "To view %(spaceName)s, turn on the Spaces beta": "Para ver %(spaceName)s, activa la beta de los espacios", + "To join %(spaceName)s, turn on the Spaces beta": "Para unirte a %(spaceName)s, activa la beta de los espacios", + "Select a room below first": "Selecciona una sala de abajo primero", + "Communities are changing to Spaces": "Las comunidades se van a convertir en espacios", + "Join the beta": "Unirse a la beta", + "Leave the beta": "Salir de la beta", + "Beta": "Beta", + "Tap for more info": "Pulsa para más información", + "Spaces is a beta feature": "Los espacios son una funcionalidad en beta", + "Want to add a new room instead?": "¿Quieres añadir una sala nueva en su lugar?", + "Adding rooms... (%(progress)s out of %(count)s)|other": "Añadiendo salas… (%(progress)s de %(count)s)", + "Adding rooms... (%(progress)s out of %(count)s)|one": "Añadiendo sala…", + "Not all selected were added": "No se han añadido todas las seleccionadas", + "You can add existing spaces to a space.": "Puedes añadir espacios ya existentes dentro de otros espacios.", + "Feeling experimental?": "¿Te animas a probar cosas nuevas?", + "You are not allowed to view this server's rooms list": "No tienes permiso para ver la lista de salas de este servidor", + "Error processing voice message": "Ha ocurrido un error al procesar el mensaje de voz", + "We didn't find a microphone on your device. Please check your settings and try again.": "No hemos encontrado un micrófono en tu dispositivo. Por favor, consulta tus ajustes e inténtalo de nuevo.", + "No microphone found": "No se ha encontrado ningún micrófono", + "We were unable to access your microphone. Please check your browser settings and try again.": "No hemos podido acceder a tu micrófono. Por favor, comprueba los ajustes de tu navegador e inténtalo de nuevo.", + "Unable to access your microphone": "No se ha podido acceder a tu micrófono", + "Your access token gives full access to your account. Do not share it with anyone.": "Tu token de acceso da acceso completo a tu cuenta. No lo compartas con nadie.", + "Access Token": "Token de acceso", + "Spaces are a new way to group rooms and people. To join an existing space you'll need an invite.": "Los espacios son una nueva forma de agrupar salas y personas. Para unirte a uno ya existente, necesitarás que te inviten a él.", + "Please enter a name for the space": "Por favor, elige un nombre para el espacio", + "Connecting": "Conectando", + "You can leave the beta any time from settings or tapping on a beta badge, like the one above.": "Puedes salirte de la beta en cualquier momento desde tus ajustes o pulsando sobre la etiqueta de beta, como la que hay arriba.", + "%(brand)s will reload with Spaces enabled. Communities and custom tags will be hidden.": "%(brand)s se volverá a cargar con los espacios activados. Las comunidades y etiquetas personalizadas se ocultarán.", + "Beta available for web, desktop and Android. Thank you for trying the beta.": "Versión beta disponible para web, escritorio y Android. Gracias por usar la beta.", + "%(brand)s will reload with Spaces disabled. Communities and custom tags will be visible again.": "%(brand)s volverá a cargarse con los espacios desactivados. Las comunidades y etiquetas personalizadas serán visibles de nuevo.", + "Spaces are a new way to group rooms and people.": "Los espacios son una nueva manera de agrupar salas y gente.", + "Message search initialisation failed": "Ha fallado la inicialización de la búsqueda de mensajes", + "Spaces are a beta feature.": "Los espacios son una funcionalidad en beta.", + "Search names and descriptions": "Buscar por nombre y descripción", + "You may contact me if you have any follow up questions": "Os podéis poner en contacto conmigo si tenéis alguna pregunta", + "To leave the beta, visit your settings.": "Para salir de la beta, ve a tus ajustes.", + "Your platform and username will be noted to help us use your feedback as much as we can.": "Tu nombre de usuario y plataforma serán adjuntados, para que podamos interpretar tus comentarios lo mejor posible.", + "%(featureName)s beta feedback": "Comentarios sobre la funcionalidad beta %(featureName)s", + "Thank you for your feedback, we really appreciate it.": "Muchas gracias por tus comentarios.", + "Beta feedback": "Danos tu opinión sobre la beta", + "Add reaction": "Reaccionar", + "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.": "¿Te apetece probar cosas nuevas? Los experimentos son la mejor manera de conseguir acceso anticipado a nuevas funcionalidades, probarlas y ayudar a mejorarlas antes de su lanzamiento. Más información.", + "Send and receive voice messages": "Enviar y recibir mensajes de voz", + "Your feedback will help make spaces better. The more detail you can go into, the better.": "Tus comentarios ayudarán a mejorar los espacios. Cuanto más detalle incluyas, mejor.", + "Beta available for web, desktop and Android. Some features may be unavailable on your homeserver.": "Beta disponible para la versión web, de escritorio o Android. Puede que algunas funcionalidades no estén disponibles en tu servidor base." } diff --git a/src/i18n/strings/et.json b/src/i18n/strings/et.json index 7ae8c1a4c1..5e8d744cca 100644 --- a/src/i18n/strings/et.json +++ b/src/i18n/strings/et.json @@ -652,7 +652,7 @@ "A session's public name is visible to people you communicate with": "Sessiooni avalik nimi on nähtav neile, kellega sa suhtled", "%(brand)s collects anonymous analytics to allow us to improve the application.": "Võimaldamaks meil rakendust parandada kogub %(brand)s anonüümset teavet rakenduse kasutuse kohta.", "Privacy is important to us, so we don't collect any personal or identifiable data for our analytics.": "Privaatsus on meile oluline ning seega me ei kogu ei isiklikke ega isikustatavaid andmeid.", - "Learn more about how we use analytics.": "Loe lisaks kuidas me kasutama analüütikat.", + "Learn more about how we use analytics.": "Loe lisaks selles kohta, kuidas me kasutame analüütikat.", "No media permissions": "Meediaõigused puuduvad", "You may need to manually permit %(brand)s to access your microphone/webcam": "Sa võib-olla pead andma %(brand)s'ile loa mikrofoni ja veebikaamera kasutamiseks", "Missing media permissions, click the button below to request.": "Meediaga seotud õigused puuduvad. Nende nõutamiseks klõpsi järgnevat nuppu.", @@ -1019,7 +1019,7 @@ "Welcome to %(appName)s": "Tere tulemast suhtlusrakenduse %(appName)s kasutajaks", "Liberate your communication": "Vabasta oma suhtlus", "Send a Direct Message": "Saada otsesõnum", - "Are you sure you want to leave the room '%(roomName)s'?": "Kas oled kindel, et soovid lahkuda jututoast '%(roomName)s'?", + "Are you sure you want to leave the room '%(roomName)s'?": "Kas oled kindel, et soovid lahkuda jututoast „%(roomName)s“?", "Unknown error": "Teadmata viga", "To continue using the %(homeserverDomain)s homeserver you must review and agree to our terms and conditions.": "Selleks et jätkata koduserveri %(homeserverDomain)s kasutamist sa pead üle vaatama ja nõustuma meie kasutustingimustega.", "Permissions": "Õigused", @@ -1202,8 +1202,8 @@ "eg: @bot:* or example.org": "näiteks: @bot:* või example.org", "Subscribed lists": "Tellitud loendid", "Subscribe": "Telli", - "Start automatically after system login": "Käivita automaatselt peale arvutisse sisselogimist", - "Always show the window menu bar": "Näita alati aknas menüüriba", + "Start automatically after system login": "Käivita Element automaatselt peale arvutisse sisselogimist", + "Always show the window menu bar": "Näita aknas alati menüüriba", "Preferences": "Eelistused", "Room list": "Jututubade loend", "Timeline": "Ajajoon", @@ -2517,7 +2517,7 @@ "Join the conference from the room information card on the right": "Liitu konverentsiga selle jututoa infolehelt paremal", "Video conference ended by %(senderName)s": "%(senderName)s lõpetas video rühmakõne", "Video conference updated by %(senderName)s": "%(senderName)s uuendas video rühmakõne", - "Video conference started by %(senderName)s": "%(senderName)s alustas video rühmakõne", + "Video conference started by %(senderName)s": "%(senderName)s alustas video rühmakõnet", "End conference": "Lõpeta videokonverents", "This will end the conference for everyone. Continue?": "Sellega lõpetame kõikide osalejate jaoks videokonverentsi. Nõus?", "Ignored attempt to disable encryption": "Eirasin katset lõpetada krüptimise kasutamine", @@ -3092,5 +3092,258 @@ "This homeserver has been blocked by it's administrator.": "Ligipääs sellele koduserverile on sinu serveri haldaja poolt blokeeritud.", "This homeserver has been blocked by its administrator.": "Ligipääs sellele koduserverile on sinu serveri haldaja poolt blokeeritud.", "You're already in a call with this person.": "Sinul juba kõne käsil selle osapoolega.", - "Already in call": "Kõne on juba pooleli" + "Already in call": "Kõne on juba pooleli", + "Your message wasn't sent because this homeserver has been blocked by it's administrator. Please contact your service administrator to continue using the service.": "Sinu sõnumit ei saadetud, kuna see koduserver on haldaja poolt blokeeritud. Teenuse kasutamiseks palun võta ühendust serveri haldajaga.", + "Are you sure you want to leave the space '%(spaceName)s'?": "Kas oled kindel, et soovid lahkuda kogukonnakeskusest „%(spaceName)s“?", + "This space is not public. You will not be able to rejoin without an invite.": "See ei ole avalik kogukonnakeskus. Ilma kutseta sa ei saa uuesti liituda.", + "Start audio stream": "Käivita audiovoog", + "Failed to start livestream": "Videovoo käivitamine ei õnnestu", + "Unable to start audio streaming.": "Audiovoo käivitamine ei õnnestu.", + "Save Changes": "Salvesta muutused", + "Saving...": "Salvestan...", + "View dev tools": "Näita arendaja töövahendeid", + "Leave Space": "Lahku kogukonnakeskusest", + "Make this space private": "Muuda see kogukonnakeskus privaatseks", + "Edit settings relating to your space.": "Muuda oma kogukonnakeskuse seadistusi.", + "Space settings": "Kogukonnakeskuse seadistused", + "Failed to save space settings.": "Kogukonnakeskuse seadistuste salvestamine ei õnnestunud.", + "Invite someone using their name, username (like ) or share this space.": "Kutsu kedagi tema nime, kasutajanime (nagu ) alusel või jaga seda kogukonnakeskust.", + "Invite someone using their name, email address, username (like ) or share this space.": "Kutsu teist osapoolt tema nime, e-posti aadressi, kasutajanime (nagu ) alusel või jaga seda kogukonnakeskust.", + "Unnamed Space": "Nimetu kogukonnakeskus", + "Invite to %(spaceName)s": "Kutsu kogukonnakeskusesse %(spaceName)s", + "Failed to add rooms to space": "Jututubade lisamine kogukonnakeskusesse ei õnnestunud", + "Apply": "Rakenda", + "Applying...": "Rakendan...", + "Create a new room": "Loo uus jututuba", + "Don't want to add an existing room?": "Kas sa ei soovi lisada olemasolevat jututuba?", + "Spaces": "Kogukonnakeskused", + "Filter your rooms and spaces": "Otsi olemasolevate kogukonnakeskuste ja jututubade seast", + "Add existing spaces/rooms": "Lisa olemasolevaid kogukonnakeskuseid ja jututube", + "Space selection": "Kogukonnakeskuse valik", + "You will not be able to undo this change as you are demoting yourself, if you are the last privileged user in the space it will be impossible to regain privileges.": "Kuna sa vähendad enda õigusi, siis sul ei pruugi hiljem olla võimalik seda muutust tagasi pöörata. Kui sa juhtumisi oled viimane haldusõigustega kasutaja kogukonnakeskuses, siis hiljem on võimatu samu õigusi tagasi saada.", + "Empty room": "Tühi jututuba", + "Suggested Rooms": "Soovitatud jututoad", + "Explore space rooms": "Tutvu kogukonnakeskuses leiduvate jututubadega", + "You do not have permissions to add rooms to this space": "Sul pole õigusi siia kogukonnakeskusesse lisada jututubasid", + "Add existing room": "Lisa olemasolev jututuba", + "You do not have permissions to create new rooms in this space": "Sul pole õigusi luua siin kogukonnakeskuses uusi jututubasid", + "Send message": "Saada sõnum", + "Invite to this space": "Kutsu siia kogukonnakeskusesse", + "Your message was sent": "Sinu sõnum sai saadetud", + "Encrypting your message...": "Krüptin sinu sõnumit...", + "Sending your message...": "Saadan sinu sõnumit...", + "Spell check dictionaries": "Õigekirja sõnastikud", + "Space options": "Kogukonnakeskus eelistused", + "Space Home": "Kogukonnakeskuse avaleht", + "New room": "Uus jututuba", + "Leave space": "Lahku kogukonnakeskusest", + "Share your public space": "Jaga oma avalikku kogukonnakeskust", + "Invite members": "Kutsu uusi osalejaid", + "Invite people": "Kutsu teisi kasutajaid", + "Share invite link": "Jaga kutse linki", + "Click to copy": "Kopeerimiseks klõpsa", + "Collapse space panel": "Ahenda kogukonnakeskuste paneeli", + "Expand space panel": "Laienda kogukonnakeskuste paneeli", + "Creating...": "Loon...", + "Your private space": "Sinu privaatne kogukonnakeskus", + "Your public space": "Sinu avalik kogukonnakeskus", + "You can change this later": "Sa võid seda hiljem muuta", + "Invite only, best for yourself or teams": "Liitumine vaid kutse alusel, sobib sulle ja sinu lähematele kaaslastele", + "Private": "Privaatne", + "Open space for anyone, best for communities": "Avaliku ligipääsuga kogukonnakeskus", + "Public": "Avalik", + "Create a space": "Loo kogukonnakeskus", + "Delete": "Kustuta", + "Jump to the bottom of the timeline when you send a message": "Sõnumi saatmiseks hüppa ajajoone lõppu", + "Spaces prototype. Incompatible with Communities, Communities v2 and Custom Tags. Requires compatible homeserver for some features.": "Kogukonnakeskuse prototüüp. Ei ühildu varasemate kogukonnalehtedega ega kohandatud siltidega. Mõned funktsionaalsused eeldavad ühilduva koduserveri kasutamist.", + "%(count)s members|other": "%(count)s liiget", + "%(count)s members|one": "%(count)s liige", + "From %(deviceName)s (%(deviceId)s) at %(ip)s": "Seadmest %(deviceName)s (%(deviceId)s) aadressiga %(ip)s", + "Spaces are new ways to group rooms and people. To join an existing space you'll need an invite.": "Kogukonnakeskused on uus viis inimeste ja jututubade ühendamiseks. Kogukonnakeskusega liitumiseks vajad sa kutset.", + "Add some details to help people recognise it.": "Tegemaks teiste jaoks äratundmise lihtsamaks, palun lisa natuke teavet.", + "You can change these anytime.": "Sa võid neid alati muuta.", + "Invite with email or username": "Kutsu e-posti aadressi või kasutajanime alusel", + "Invite People": "Kutsu teisi kasutajaid", + "Edit devices": "Muuda seadmeid", + "Invite to %(roomName)s": "Kutsu jututuppa %(roomName)s", + "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.": "See tavaliselt mõjutab vaid viisi, kuidas server jututuba teenindab. Kui sul tekib %(brand)s kasutamisel vigu, siis palun anna sellest meile teada.", + "%(count)s messages deleted.|other": "%(count)s sõnumit on kustutatud.", + "%(count)s messages deleted.|one": "%(count)s sõnum on kustutatud.", + "You don't have permission": "Sul puuduvad selleks õigused", + "%(count)s rooms|other": "%(count)s jututuba", + "%(count)s rooms|one": "%(count)s jututuba", + "This room is suggested as a good one to join": "Teised kasutajad soovitavad liitumist selle jututoaga", + "Suggested": "Soovitatud", + "If you can't find the room you're looking for, ask for an invite or create a new room.": "Kui sa ei leia otsitavat jututuba, siis palu sinna kutset või loo uus jututuba.", + "%(count)s rooms and %(numSpaces)s spaces|other": "%(count)s jututuba ja %(numSpaces)s kogukonnakeskust", + "%(count)s rooms and %(numSpaces)s spaces|one": "%(count)s jututuba ja %(numSpaces)s kogukonnakeskust", + "%(count)s rooms and 1 space|other": "%(count)s jututuba ja 1 kogukonnakeskus", + "%(count)s rooms and 1 space|one": "%(count)s jututuba ja 1 kogukonnakeskus", + "Add existing rooms & spaces": "Lisa olemasolevaid jututubasid ja kogukonnakeskuseid", + "Default Rooms": "Vaikimisi jututoad", + "Your server does not support showing space hierarchies.": "Sinu koduserver ei võimalda kuvada kogukonnakeskuste hierarhiat.", + "Your public space ": "Sinu avalik kogukonnakeskus ", + "Your private space ": "Sinu privaatne kogukonnakeskus ", + "Welcome to ": "Tete tulemast liikmeks", + "Random": "Juhuslik", + "Support": "Toeta", + "Room name": "Jututoa nimi", + "Failed to create initial space rooms": "Algsete jututubade loomine ei õnnestunud", + "Skip for now": "Hetkel jäta vahele", + "Creating rooms...": "Loon jututubasid…", + "Who are you working with?": "Kellega sa koos töötad?", + "Me and my teammates": "Mina ja minu kaasteelised", + "A private space for you and your teammates": "Privaatne kogukonnakeskus sinu ja sinu kaasteeliste jaoks", + "Failed to invite the following users to your space: %(csvUsers)s": "Järgnevate kasutajate kutsumine kogukonnakeskusesse ei õnnestunud: %(csvUsers)s", + "Inviting...": "Kutsun...", + "Invite your teammates": "Kutsu oma kaasteelisi", + "Invite by username": "Kutsu kasutajanime alusel", + "What are some things you want to discuss?": "Mis on need teemad, mida tahaksid arutada?", + "What projects are you working on?": "Mis ettevõtmistega sa tegeled?", + "Decrypted event source": "Sündmuse dekrüptitud lähtekood", + "Original event source": "Algse sündmuse lähtekood", + "Failed to remove some rooms. Try again later": "Mõnede jututubade eemaldamine ei õnnestunud. Proovi hiljem uuesti", + "Removing...": "Eemaldan...", + "Mark as not suggested": "Eemalda soovitus", + "Mark as suggested": "Märgi soovituseks", + "No results found": "Tulemusi ei ole", + "You may want to try a different search or check for typos.": "Aga proovi muuta otsingusõna või kontrolli ega neis trükivigu polnud.", + "Search names and description": "Otsi nimede ja kirjelduste seast", + " invites you": " saatis sulle kutse", + "Public space": "Avalik kogukonnakeskus", + "Private space": "Privaatne kogukonnakeskus", + "Share %(name)s": "Jaga %(name)s", + "It's just you at the moment, it will be even better with others.": "Hetkel oled siin vaid sina, aga aina paremaks läheb, kui teised liituvad.", + "Go to my first room": "Mine minu esimese jututoa juurde", + "Make sure the right people have access to %(name)s": "Palun kontrolli, et vajalikel inimestel oleks ligipääs siia - %(name)s", + "Just me": "Vaid mina", + "A private space to organise your rooms": "Privaatne kogukonnakeskus jututubade koondamiseks", + "Make sure the right people have access. You can invite more later.": "Kontrolli, et vajalikel inimestel oleks siia ligipääs. Teistele võid kutse saata ka hiljem.", + "Let's create a room for each of them. You can add more later too, including already existing ones.": "Loome nüüd igaühe jaoks jututoa. Sa võid neid ka hiljem lisada, sealhulgas olemasolevaid.", + "We'll create rooms for each of them. You can add more later too, including already existing ones.": "Ma loome igaühe jaoks jututoa. Sa võid neid ka hiljem lisada, sealhulgas olemasolevaid.", + "Verify with another session": "Verifitseeri teise sessiooniga", + "Verify this login to access your encrypted messages and prove to others that this login is really you.": "Oma krüptitud sõnumite lugemiseks verifitseeri see sisselogimissessioon. Samaga kinnitad ka teistele, et tegemist on tõesti sinuga.", + "Open": "Ava", + "Check your devices": "Kontrolli oma seadmeid", + "A new login is accessing your account: %(name)s (%(deviceID)s) at %(ip)s": "Uus sisselogimissessioon kasutab sinu Matrixi kontot: %(name)s %(deviceID)s aadressil %(ip)s", + "You have unverified logins": "Sul on verifitseerimata sisselogimissessioone", + "Manage & explore rooms": "Halda ja uuri jututubasid", + "Warn before quitting": "Hoiata enne rakenduse töö lõpetamist", + "Invite to just this room": "Kutsi vaid siia jututuppa", + "Quick actions": "Kiirtoimingud", + "Adding...": "Lisan...", + "Sends the given message as a spoiler": "Saadab selle sõnumi rõõmurikkujana", + "unknown person": "tundmatu isik", + "Send and receive voice messages (in development)": "Saada ja võta vastu häälsõnumeid (arendusjärgus)", + "%(deviceId)s from %(ip)s": "%(deviceId)s ip-aadressil %(ip)s", + "Review to ensure your account is safe": "Tagamaks, et su konto on sinu kontrolli all, vaata andmed üle", + "Share decryption keys for room history when inviting users": "Kasutajate kutsumisel jaga jututoa ajaloo võtmeid", + "Record a voice message": "Salvesta häälsõnum", + "Stop & send recording": "Lõpeta salvestamine ja saada häälsõnum", + "Add existing rooms": "Lisa olemasolevaid jututubasid", + "%(count)s people you know have already joined|other": "%(count)s sulle tuttavat kasutajat on juba liitunud", + "We couldn't create your DM.": "Otsesuhtluse loomine ei õnnestunud.", + "Invited people will be able to read old messages.": "Kutse saanud kasutajad saavad lugeda vanu sõnumeid.", + "Consult first": "Pea esmalt nõu", + "Reset event store?": "Kas lähtestame sündmuste andmekogu?", + "Reset event store": "Lähtesta sündmuste andmekogu", + "Verify other login": "Verifitseeri muu sisselogimissessioon", + "Consulting with %(transferTarget)s. Transfer to %(transferee)s": "Suhtlen teise osapoolega %(transferTarget)s. Saadan andmeid kasutajale %(transferee)s", + "Message search initilisation failed": "Sõnumite otsingu alustamine ei õnnestunud", + "Invite messages are hidden by default. Click to show the message.": "Kutsed on vaikimisi peidetud. Sõnumi nägemiseks klõpsi.", + "Accept on your other login…": "Nõustu oma teise sisselogimissessiooniga…", + "Avatar": "Tunnuspilt", + "Verification requested": "Verifitseerimistaotlus on saadetud", + "%(count)s people you know have already joined|one": "%(count)s sulle tuttav kasutaja on juba liitunud", + "You most likely do not want to reset your event index store": "Pigem sa siiski ei taha lähtestada sündmuste andmekogu ja selle indeksit", + "If you do, please note that none of your messages will be deleted, but the search experience might be degraded for a few momentswhilst the index is recreated": "Kui sa siiski soovid seda teha, siis sinu sõnumeid me ei kustuta, aga seniks kuni sõnumite indeks taustal uuesti luuakse, toimib otsing aeglaselt ja ebatõhusalt", + "You can add more later too, including already existing ones.": "Sa võid ka hiljem siia luua uusi jututubasid või lisada olemasolevaid.", + "What are some things you want to discuss in %(spaceName)s?": "Mida sa sooviksid arutada %(spaceName)s kogukonnakeskuses?", + "Please choose a strong password": "Palun tee üks korralik salasõna", + "Use another login": "Pruugi muud kasutajakontot", + "Verify your identity to access encrypted messages and prove your identity to others.": "Tagamaks ligipääsu oma krüptitud sõnumitele ja tõestamaks oma isikut teistele kasutajatale, verifitseeri end.", + "Let's create a room for each of them.": "Teeme siis iga teema jaoks oma jututoa.", + "Without verifying, you won’t have access to all your messages and may appear as untrusted to others.": "Ilma verifitseerimiseta sul puudub ligipääs kõikidele oma sõnumitele ning teised ei näe sinu kasutajakontot usaldusväärsena.", + "You are the only person here. If you leave, no one will be able to join in the future, including you.": "Sa oled siin viimane osaleja. Kui sa nüüd lahkud, siis mitte keegi, kaasa arvatud sa ise, ei saa hiljem enam liituda.", + "If you reset everything, you will restart with no trusted sessions, no trusted users, and might not be able to see past messages.": "Kui sa kõik krüptoseosed lähtestad, siis sul esimese hooga pole ühtegi usaldusväärseks tunnistatud sessiooni ega kasutajat ning ilmselt ei saa sa lugeda vanu sõnumeid.", + "Only do this if you have no other device to complete verification with.": "Toimi nii vaid siis, kui sul pole jäänud ühtegi seadet, millega verifitseerimist lõpuni teha.", + "Reset everything": "Alusta kõigega algusest", + "Forgotten or lost all recovery methods? Reset all": "Unustasid või oled kaotanud kõik võimalused ligipääsu taastamiseks? Lähtesta kõik ühe korraga", + "If you do, please note that none of your messages will be deleted, but the search experience might be degraded for a few moments whilst the index is recreated": "Kui sa siiski soovid seda teha, siis sinu sõnumeid me ei kustuta, aga seniks kuni sõnumite indeks taustal uuesti luuakse, toimib otsing aeglaselt ja ebatõhusalt", + "View message": "Vaata sõnumit", + "Zoom in": "Suumi sisse", + "Zoom out": "Suumi välja", + "%(seconds)ss left": "jäänud %(seconds)s sekundit", + "Change server ACLs": "Muuda serveri ligipääsuõigusi", + "Show options to enable 'Do not disturb' mode": "Näita valikuid „Ära sega“ režiimi sisse lülitamiseks", + "You can select all or individual messages to retry or delete": "Sa võid valida kas kõik või mõned sõnumid kas kustutamiseks või uuesti saatmiseks", + "Sending": "Saadan", + "Retry all": "Proovi kõikidega uuesti", + "Delete all": "Kustuta kõik", + "Some of your messages have not been sent": "Mõned sinu sõnumid on saatmata", + "%(count)s members including %(commaSeparatedMembers)s|one": "%(commaSeparatedMembers)s", + "%(count)s members including %(commaSeparatedMembers)s|other": "%(count)s liiget, sealhulgas %(commaSeparatedMembers)s", + "Including %(commaSeparatedMembers)s": "Sealhulgas %(commaSeparatedMembers)s", + "View all %(count)s members|one": "Vaata üht liiget", + "View all %(count)s members|other": "Vaata kõiki %(count)s liiget", + "Failed to send": "Saatmine ei õnnestunud", + "Enter your Security Phrase a second time to confirm it.": "Kinnitamiseks palun sisesta turvafraas teist korda.", + "Pick rooms or conversations to add. This is just a space for you, no one will be informed. You can add more later.": "Lisamiseks vali vestlusi ja jututubasid. Hetkel on see kogukonnakeskus vaid sinu jaoks ja esialgu keegi ei saa sellest teada. Teisi saad liituma kutsuda hiljem.", + "What do you want to organise?": "Mida sa soovid ette võtta?", + "Filter all spaces": "Otsi kõikides kogukonnakeskustest", + "Delete recording": "Kustuta salvestus", + "Stop the recording": "Lõpeta salvestamine", + "%(count)s results in all spaces|one": "%(count)s tulemus kõikides kogukonnakeskustes", + "%(count)s results in all spaces|other": "%(count)s tulemust kõikides kogukonnakeskustes", + "You have no ignored users.": "Sa ei ole veel kedagi eiranud.", + "Play": "Esita", + "Pause": "Peata", + "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.": "Kas sa tahaksid katsetada? Sa tutvud meie rakenduse uuendustega teistest varem ja võib-olla isegi saad mõjutada arenduse lõpptulemust. Lisateavet liad siit.", + "This is an experimental feature. For now, new users receiving an invite will have to open the invite on to actually join.": "See on katseline funktsionaalsus. Seetõttu uued kutse saanud kasutajad peavad tegelikuks liitumiseks avama kutse siin .", + "To join %(spaceName)s, turn on the Spaces beta": "%(spaceName)s kogukonnakeskusega liitumiseks lülita sisse vastav katseline funktsionaalsus", + "To view %(spaceName)s, turn on the Spaces beta": "%(spaceName)s kogukonnakeskuse vaatamiseks lülita sisse vastav katseline funktsionaalsus", + "Select a room below first": "Esmalt vali alljärgnevast üks jututuba", + "Communities are changing to Spaces": "Seniste kogukondade asemele tulevad kogukonnakeskused", + "Join the beta": "Hakka kasutama beetaversiooni", + "Leave the beta": "Lõpeta beetaversiooni kasutamine", + "Beta": "Beetaversioon", + "Tap for more info": "Lisateabe jaoks klõpsi", + "Spaces is a beta feature": "Kogukonnakeskused on veel katsetamisjärgus funktsionaalsus", + "Want to add a new room instead?": "Kas sa selle asemel soovid lisada jututuba?", + "Adding rooms... (%(progress)s out of %(count)s)|one": "Lisan jututuba...", + "Adding rooms... (%(progress)s out of %(count)s)|other": "Lisan jututubasid... (%(progress)s/%(count)s)", + "Not all selected were added": "Kõiki valituid me ei lisanud", + "You can add existing spaces to a space.": "Sa võid kogukonnakeskusele lisada ka teisi kogukonnakeskuseid.", + "Feeling experimental?": "Kas sa tahaksid natukene katsetada?", + "You are not allowed to view this server's rooms list": "Sul puuduvad õigused selle serveri jututubade loendi vaatamiseks", + "Error processing voice message": "Viga häälsõnumi töötlemisel", + "We didn't find a microphone on your device. Please check your settings and try again.": "Me ei suutnud sinu seadmest leida mikrofoni. Palun kontrolli seadistusi ja proovi siis uuesti.", + "No microphone found": "Mikrofoni ei leidu", + "We were unable to access your microphone. Please check your browser settings and try again.": "Meil puudub ligipääs sinu mikrofonile. Palun kontrolli oma veebibrauseri seadistusi ja proovi uuesti.", + "Unable to access your microphone": "Puudub ligipääs mikrofonile", + "Your access token gives full access to your account. Do not share it with anyone.": "Sinu pääsuluba annab täismahulise ligipääsu sinu kasutajakontole. Palun ära jaga seda teistega.", + "Access Token": "Pääsuluba", + "Spaces are a new way to group rooms and people. To join an existing space you'll need an invite.": "Kogukonnakeskused on uus viis inimeste ja jututubade ühendamiseks. Kogukonnakeskusega liitumiseks vajad sa kutset.", + "Please enter a name for the space": "Palun sisesta kogukonnakeskuse nimi", + "Connecting": "Kõne on ühendamisel", + "Allow Peer-to-Peer for 1:1 calls (if you enable this, the other party might be able to see your IP address)": "Kasuta võrdõigusvõrku 1:1 kõnede jaoks (kui sa P2P-võrgu sisse lülitad, siis teine osapool ilmselt näeb sinu IP-aadressi)", + "Beta available for web, desktop and Android. Some features may be unavailable on your homeserver.": "Rakenduse beetaversioon on saadaval veebirakendusena, töölauarakendusena ja Androidi jaoks. Kõik funtsionaalsused ei pruugi sinu koduserveri poolt olla toetatud.", + "You can leave the beta any time from settings or tapping on a beta badge, like the one above.": "Sa võid beetaversiooni kasutamise lõpetada niipea, kui tahad. Selleks klõpsi beeta-silti, mida näed siin samas ülal.", + "%(brand)s will reload with Spaces enabled. Communities and custom tags will be hidden.": "Käivitame %(brand)s uuesti nii, et kogukonnakeskused on kasutusel. Vana tüüpi kogukonnad ja kohandatud sildid on siis välja lülitatud.", + "%(brand)s will reload with Spaces disabled. Communities and custom tags will be visible again.": "Käivitame %(brand)s uuesti nii, et kogukonnakeskused ei ole kasutusel. Vana tüüpi kogukonnad ja kohandatud sildid saavad jälle olema kasutusel.", + "Beta available for web, desktop and Android. Thank you for trying the beta.": "Rakenduse beetaversioon on saadaval veebirakendusena, töölauarakendusena ja Androidi jaoks. Tänud, et oled huviline katsetama meie rakendust.", + "Spaces are a new way to group rooms and people.": "Kogukonnakeskused on uus viis jututubade ja inimeste ühendamiseks.", + "Spaces are a beta feature.": "Kogukonnakeskused on veel katsetamisjärgus funktsionaalsus.", + "Search names and descriptions": "Otsi nimede ja kirjelduste seast", + "You may contact me if you have any follow up questions": "Kui sul on lisaküsimusi, siis vastan neile hea meelega", + "To leave the beta, visit your settings.": "Beetaversiooni saad välja lülitada rakenduse seadistustest.", + "Your platform and username will be noted to help us use your feedback as much as we can.": "Lisame sinu kommentaaridele ka kasutajanime ja operatsioonisüsteemi.", + "%(featureName)s beta feedback": "%(featureName)s testversiooni tagasiside", + "Thank you for your feedback, we really appreciate it.": "Täname sind nende kommentaaride eest.", + "Beta feedback": "Tagasiside testversioonile", + "Add reaction": "Lisa reaktsioon", + "Send and receive voice messages": "Saada ja võta vastu häälsõnumeid", + "Your feedback will help make spaces better. The more detail you can go into, the better.": "Sinu tagasiside aitab teha kogukonnakeskuseid paremaks. Mida detailsemalt sa oma arvamust kirjeldad, seda parem.", + "If you leave, %(brand)s will reload with Spaces disabled. Communities and custom tags will be visible again.": "Kui sa lahkud, siis käivitame %(brand)s uuesti nii, et kogukonnakeskused ei ole kasutusel. Vana tüüpi kogukonnad ja kohandatud sildid saavad jälle olema kasutusel.", + "Message search initialisation failed": "Sõnumite otsingu alustamine ei õnnestunud" } diff --git a/src/i18n/strings/fa.json b/src/i18n/strings/fa.json index b16700ead6..e248bc4c0f 100644 --- a/src/i18n/strings/fa.json +++ b/src/i18n/strings/fa.json @@ -222,5 +222,441 @@ "I have verified my email address": "ایمیل خود را تأید کردم", "Home": "خانه", "Hangup": "قطع", - "For security, this session has been signed out. Please sign in again.": "برای امنیت، این نشست نامعتبر شده است. لطفاً دوباره وارد سیستم شوید." + "For security, this session has been signed out. Please sign in again.": "برای امنیت، این نشست نامعتبر شده است. لطفاً دوباره وارد سیستم شوید.", + "We couldn't log you in": "نتوانستیم شما را وارد کنیم", + "Trust": "اعتماد کن", + "Only continue if you trust the owner of the server.": "تنها در صورتی که به صاحب سرور اطمینان دارید، ادامه دهید.", + "Identity server has no terms of service": "سرور هویت‌سنجی، شرایط استفاده از خدمت (terms of service) را مشخص نکرده‌است", + "Unnamed Room": "اتاق بدون نام", + "Failed to add the following rooms to %(groupId)s:": "افزودن این اتاق‌ها به فضای کاری %(groupId)s موفقیت‌آمیز نبود:", + "Failed to invite users to %(groupId)s": "دعوت کاربران به فضای کاری %(groupId)s موفقیت‌آمیز نبود", + "Failed to invite users to community": "دعوت کاربران به این فضای کاری موفقیت‌آمیز نبود", + "Failed to invite the following users to %(groupId)s:": "دعوت این کاربران به فضای کاری %(groupId)s موفقیت‌آمیز نبود:", + "Add to community": "افزودن به فضای کاری", + "Room name or address": "نام یا آدرس اتاق", + "Invite new community members": "اعضای جدیدی را به فضای کاری دعوت کنید", + "Add rooms to the community": "افزودن اتاق به فضای کاری", + "Show these rooms to non-members on the community page and room list?": "آیا تمایل دارید نام این اتاق‌ها در صفحه‌ی این فضای کاری و همچنین لیست اتاق‌ها، به کاربرانی که عضو آن‌ها نیستند نمایش داده شود؟", + "Which rooms would you like to add to this community?": "تمایل دارید کدام اتاق‌ها را به این فضای کاری اضافه کنید؟", + "Invite to Community": "دعوت به فضای کاری", + "Warning: any person you add to a community will be publicly visible to anyone who knows the community ID": "هشدار: هر کاربری را که به این فضای کاری اضافه می‌کنید، برای تمام افرادی که شناسه این فضای کاری را در اختیار داشته باشند، قابل مشاهده هستند", + "Who would you like to add to this community?": "تمایل دارید چه افراد دیگری را به این فضای کاری اضافه کنید؟", + "Name or Matrix ID": "نام یا شناسه ماتریکس", + "%(weekDayName)s, %(monthName)s %(day)s %(fullYear)s %(time)s": "%(weekDayName)s, %(monthName)s.%(day)s.%(fullYear)s.%(time)s", + "%(weekDayName)s, %(monthName)s %(day)s %(fullYear)s": "%(weekDayName)s, %(monthName)s.%(day)s.%(fullYear)s", + "%(weekDayName)s, %(monthName)s %(day)s %(time)s": "%(weekDayName)s, %(monthName)s.%(day)s.%(time)s", + "%(weekDayName)s %(time)s": "%(weekDayName)s.%(time)s", + "AM": "قبل از ظهر", + "PM": "بعد از ظهر", + "Dec": "دسامبر", + "Nov": "نوامبر", + "Oct": "اکتبر", + "Sep": "سپتامبر", + "Aug": "اوت", + "Jul": "ژوئیه", + "Jun": "ژوئن", + "May": "می", + "Apr": "آوریل", + "Mar": "مارس", + "Feb": "فوریه", + "Jan": "ژانویه", + "Sat": "شنبه", + "Fri": "جمعه", + "Thu": "پنجشنبه", + "Wed": "چهارشنبه", + "Tue": "سه‌شنبه", + "Mon": "دوشنبه", + "Sun": "یکشنبه", + "The server does not support the room version specified.": "سرور از نسخه‌ی اتاقی که مشخص شده‌است، پشتیبانی نمی‌کند.", + "Server may be unavailable, overloaded, or you hit a bug.": "سرور ممکن است از دسترس خارج شده، یا فشار بار زیادی را تحمل کرده، و یا به یک باگ نرم‌افزاری برخورد کرده باشد.", + "Upload Failed": "بارگذاری موفقیت‌آمیز نبود", + "The file '%(fileName)s' exceeds this homeserver's size limit for uploads": "حجم پرونده‌ی '%(fileName)s' از آستانه‌ی تنظیم‌شده بر روی سرور بیشتر است", + "The file '%(fileName)s' failed to upload.": "بارگذاری پرونده '%(fileName)s' موفقیت‌آمیز نبود.", + "At this time it is not possible to reply with a file. Would you like to upload this file without replying?": "در حال حاضر امکان پاسخ از طریق یک پرونده وجود ندارد. آیا تمایل دارید این پرونده را در حالتی که پاسخ نیست، بارگذاری کنید؟", + "Replying With Files": "در حال پاسخ با پرونده", + "This will end the conference for everyone. Continue?": "با این کار، جلسه‌ی تصویری برای همه به اتمام می‌رسد. ادامه می‌دهید؟", + "End conference": "جلسه را پایان بده", + "You do not have permission to start a conference call in this room": "شما اجازه‌ی شروع جلسه‌ی تصویری در این اتاق را ندارید", + "Permission Required": "اجازه نیاز است", + "A call is currently being placed!": "یک تماس هم‌اکنون برقرار است!", + "Call in Progress": "تماس در جریان است", + "You cannot place a call with yourself.": "امکان برقراری تماس با خودتان وجود ندارد.", + "You're already in a call with this person.": "شما هم‌اکنون با این فرد در تماس هستید.", + "Already in call": "هم‌اکنون در تماس هستید", + "You've reached the maximum number of simultaneous calls.": "شما به بیشینه‌ی تعداد تماس‌های هم‌زمان رسیده‌اید.", + "Too Many Calls": "تعداد زیاد تماس", + "You cannot place VoIP calls in this browser.": "امکان برقراری تماس بر بستر VoIP در این مرورگر وجود ندارد.", + "VoIP is unsupported": "قابلیت VoIP پشتیبانی نمی‌شود", + "Unable to capture screen": "امکان ضبط صفحه‌ی نمایش وجود ندارد", + "No other application is using the webcam": "برنامه‌ی دیگری از دوربین استفاده نکند", + "Permission is granted to use the webcam": "دسترسی مورد نیاز به دوربین داده شده باشد", + "A microphone and webcam are plugged in and set up correctly": "میکروفون و دوربین به درستی تنظیم شده باشند", + "Call failed because webcam or microphone could not be accessed. Check that:": "تماس به دلیل مشکل در دسترسی به دوربین یا میکروفون موفقیت‌آمیز نبود. لطفا بررسی کنید:", + "Unable to access webcam / microphone": "امکان دسترسی به دوربین/میکروفون وجود ندارد", + "Call failed because microphone could not be accessed. Check that a microphone is plugged in and set up correctly.": "تماس به دلیل عدم دسترسی به میکروفون موفقیت‌آمیز نبود. لطفا اتصال و تنظیمات صحیح میکروفون را بررسی نمائید.", + "Unable to access microphone": "دسترسی به میکروفون امکان‌پذیر نیست", + "Try using turn.matrix.org": "turn.hivaa.im را امتحان کنید", + "Alternatively, you can try to use the public server at turn.matrix.org, but this will not be as reliable, and it will share your IP address with that server. You can also manage this in Settings.": "همچنین شما می‌توانید از سرور TURN عمومی turn.hivaa.im استفاده نمائید؛ توجه کنید در این حالت، میزان کیفیت تماس‌ها چندان قابل اتکاء نبوده و همچنین آدرس IP کاربران برای سرور مشخص می‌شود. در صورت نیاز می‌توانید پیکربندی این بخش را در تنظیمات برنامه تغییر دهید.", + "Please ask the administrator of your homeserver (%(homeserverDomain)s) to configure a TURN server in order for calls to work reliably.": "لطفا برای برقراری تماس، از مدیر %(homeserverDomain)s بخواهید سرور TURN را پیکربندی نماید.", + "Call failed due to misconfigured server": "تماس به دلیل پیکربندی نادرست سرور موفقیت‌آمیز نبود", + "The call was answered on another device.": "تماس بر روی دستگاه دیگری پاسخ داده شد.", + "Answered Elsewhere": "در جای دیگری پاسخ داده شد", + "The call could not be established": "امکان برقراری تماس وجود ندارد", + "The other party declined the call.": "طرف مقابل تماس را رد کرد.", + "Call Declined": "تماس رد شد", + "Call Failed": "تماس موفقیت‌آمیز نبود", + "Unable to load! Check your network connectivity and try again.": "امکان بارگیری محتوا وجود ندارد! لطفا وضعیت اتصال خود به اینترنت را بررسی کرده و مجددا اقدام نمائید.", + "The information being sent to us to help make %(brand)s better includes:": "اطلاعاتی که به ما برای افزایش کیفیت %(brand)s ارسال می‌شوند عبارتند از:", + "Analytics": "تجزیه و تحلیل", + "Your device resolution": "وضوح دستگاه شما", + "e.g. ": "برای مثال ", + "Every page you use in the app": "هر صفحه‌ی برنامه از که آن استفاده می‌کنید", + "e.g. %(exampleValue)s": "برای مثال %(exampleValue)s", + "Explore rooms": "کاوش اتاق", + "Sign In": "ورود", + "Create Account": "ایجاد اکانت", + "Use an identity server": "از سرور هویت‌سنجی استفاده کنید", + "Invites user with given id to current room": "کاربر با شناسه داده شده را به اتاق فعلی دعوت کن", + "Sets the room name": "نام اتاق را تنظیم می کند", + "This room has no topic.": "این اتاق هیچ موضوعی ندارد.", + "Failed to set topic": "تنظیم موضوع موفقیت‌آمیز نبود", + "Gets or sets the room topic": "موضوع اتاق را دریافت یا تنظیم می‌کند", + "Changes your avatar in all rooms": "تصویر نمایه خود را در همه‌ی اتاق‌ها تغییر دهید", + "Changes your avatar in this current room only": "تصویر نمایه خود را تنها در این اتاق تغییر دهید", + "Changes the avatar of the current room": "تصویر نمایه اتاق فعلی را تغییر دهید", + "Changes your display nickname": "نام نمایشی خود را تغییر دهید", + "Changes your display nickname in the current room only": "نام نمایشی خود را تنها در اتاق فعلی تغییر دهید", + "Double check that your server supports the room version chosen and try again.": "بررسی کنید که کارگزار شما از نسخه اتاق انتخاب‌شده پشتیبانی کرده و دوباره امتحان کنید.", + "Error upgrading room": "خطا در ارتقاء نسخه اتاق", + "You do not have the required permissions to use this command.": "شما مجوزهای لازم را برای استفاده از این دستور ندارید.", + "Upgrades a room to a new version": "یک اتاق را به نسخه جدید ارتقا دهید", + "To use it, just wait for autocomplete results to load and tab through them.": "برای استفاده از آن ، لطفا منتظر بمانید تا نتایج تکمیل خودکار بارگیری شده و آنها را مرور کنید.", + "Searches DuckDuckGo for results": "در سایت DuckDuckGo جستجو می‌کند", + "Sends a message as html, without interpreting it as markdown": "پیام را به صورت html می فرستد ، بدون اینکه آن را به عنوان markdown تفسیر کند", + "Sends a message as plain text, without interpreting it as markdown": "پیام را به صورت متن ساده و بدون تفسیر آن به عنوان markdown ارسال می کند", + "Prepends ( ͡° ͜ʖ ͡°) to a plain-text message": "(͡ ° ͜ʖ ͡ °) را به ابتدای یک پیام متنی ساده اضافه می‌کند", + "Prepends ┬──┬ ノ( ゜-゜ノ) to a plain-text message": "┬──┬ ノ (゜ - ゜ ノ) را به ابتدای یک پیام متنی ساده اضافه می‌کند", + "Prepends (╯°□°)╯︵ ┻━┻ to a plain-text message": "(╯ ° □ °) ╯︵ ┻━┻) را به ابتدای یک پیام متنی ساده اضافه می‌کند", + "Prepends ¯\\_(ツ)_/¯ to a plain-text message": "¯ \\ _ (ツ) _ / ¯ را به ابتدای یک پیام متنی ساده اضافه می‌کند", + "Sends the given message as a spoiler": "پیام داده شده را به عنوان اسپویلر ارسال می کند", + "Usage": "استفاده", + "Other": "دیگر", + "Effects": "جلوه‌ها", + "Actions": "اقدامات", + "Messages": "پیام ها", + "Setting up keys": "تنظیم کلیدها", + "Go Back": "برگرد", + "Are you sure you want to cancel entering passphrase?": "آیا مطمئن هستید که می خواهید وارد کردن عبارت امنیتی را لغو کنید؟", + "Cancel entering passphrase?": "وارد کردن عبارت امنیتی لغو شود؟", + "Missing room_id in request": "room_id در صورت درخواست وجود ندارد", + "Missing user_id in request": "user_id در صورت درخواست وجود ندارد", + "Room %(roomId)s not visible": "اتاق %(roomId)s قابل مشاهده نیست", + "You do not have permission to do that in this room.": "شما مجاز به انجام این کار در این اتاق نیستید.", + "You are not in this room.": "شما در این اتاق نیستید.", + "Power level must be positive integer.": "سطح قدرت باید عدد صحیح مثبت باشد.", + "This room is not recognised.": "این اتاق شناخته نشده است.", + "Missing roomId.": "شناسه‌ی اتاق گم‌شده", + "Unable to create widget.": "ایجاد ابزارک امکان پذیر نیست.", + "You need to be able to invite users to do that.": "نیاز است که شما قادر به دعوت کاربران به آن باشید.", + "You need to be logged in.": "شما باید وارد شوید.", + "Failed to invite the following users to the %(roomName)s room:": "دعوت کاربران زیر به اتاق %(roomName)s موفقیت‌آمیز نبود:", + "Failed to invite users to the room:": "دعوت کاربران به اتاق موفقیت‌آمیز نبود:", + "Failed to invite": "دعوت موفقیت‌آمیز نبود", + "Custom (%(level)s)": "%(level)s دلخواه", + "Moderator": "معاون", + "Restricted": "ممنوع", + "Use your account or create a new one to continue.": "برای ادامه کار از حساب کاربری خود استفاده کرده و یا حساب کاربری جدیدی ایجاد کنید.", + "Sign In or Create Account": "وارد شوید یا حساب کاربری بسازید", + "Zimbabwe": "زیمبابوه", + "Zambia": "زامبیا", + "Yemen": "یمن", + "Western Sahara": "صحرای غربی", + "Wallis & Futuna": "والیس و فوتونا", + "Vietnam": "ویتنام", + "Venezuela": "ونزوئلا", + "Vatican City": "شهر واتیکان", + "Vanuatu": "وانواتو", + "Uzbekistan": "ازبکستان", + "Uruguay": "اروگوئه", + "United Arab Emirates": "امارات متحده عربی", + "Ukraine": "اوکراین", + "Uganda": "اوگاندا", + "U.S. Virgin Islands": "جزایر ویرجین ایالات متحده", + "Tuvalu": "تووالو", + "Turks & Caicos Islands": "جزایر ترک و کایکوس", + "Turkmenistan": "ترکمنستان", + "Turkey": "بوقلمون", + "Tunisia": "تونس", + "Trinidad & Tobago": "ترینیداد و توباگو", + "Tonga": "تونگا", + "Tokelau": "توكلائو", + "Togo": "رفتن", + "Timor-Leste": "تیمور-لسته", + "Thailand": "تایلند", + "Tanzania": "تانزانیا", + "Tajikistan": "تاجیکستان", + "Taiwan": "تایوان", + "São Tomé & Príncipe": "سائو تومه و پرنسیپ", + "Syria": "سوریه", + "Switzerland": "سوئیس", + "Sweden": "سوئد", + "Swaziland": "سوازیلند", + "Svalbard & Jan Mayen": "سوالبارد و جان ماین", + "Suriname": "سورینام", + "Sudan": "سودان", + "St. Vincent & Grenadines": "سنت وینسنت و گرنادین ها", + "St. Pierre & Miquelon": "سنت پیر و میکلون", + "St. Martin": "سنت مارتین", + "St. Lucia": "سنت لوسیا", + "St. Kitts & Nevis": "سنت کیتس و نویس", + "St. Helena": "سنت هلنا", + "St. Barthélemy": "سنت بارتلمی", + "Sri Lanka": "سری لانکا", + "Spain": "اسپانیا", + "South Sudan": "سودان جنوبی", + "South Korea": "کره جنوبی", + "South Georgia & South Sandwich Islands": "جزایر جورجیا جنوبی", + "South Africa": "آفریقای جنوبی", + "Somalia": "سومالی", + "Solomon Islands": "جزایر سلیمان", + "Slovenia": "اسلوونی", + "Slovakia": "اسلواکی", + "Sint Maarten": "سینت مارتن", + "Singapore": "سنگاپور", + "Sierra Leone": "سیرا لئون", + "Seychelles": "سیشل", + "Serbia": "صربستان", + "Senegal": "سنگال", + "Saudi Arabia": "عربستان سعودی", + "San Marino": "سان مارینو", + "Samoa": "ساموآ", + "Réunion": "ریونیون", + "Rwanda": "رواندا", + "Russia": "روسیه", + "Romania": "رومانی", + "Qatar": "قطر", + "Puerto Rico": "پورتوریکو", + "Portugal": "کشور پرتغال", + "Poland": "لهستان", + "Pitcairn Islands": "جزایر پیتکرن", + "Philippines": "فیلیپین", + "Peru": "پرو", + "Paraguay": "پاراگوئه", + "Papua New Guinea": "پاپوآ گینه نو", + "Panama": "پاناما", + "Palestine": "فلسطین", + "Palau": "پالائو", + "Pakistan": "پاکستان", + "Oman": "عمان", + "Norway": "نروژ", + "Northern Mariana Islands": "جزایر ماریانای شمالی", + "North Korea": "کره شمالی", + "Norfolk Island": "جزیره نورفولک", + "Niue": "نیوئه", + "Nigeria": "نیجریه", + "Niger": "نیجر", + "Nicaragua": "نیکاراگوئه", + "New Zealand": "نیوزلند", + "New Caledonia": "کالدونیای جدید", + "Netherlands": "هلند", + "Nepal": "نپال", + "Nauru": "نائورو", + "Namibia": "ناميبيا", + "Myanmar": "میانمار", + "Mozambique": "موزامبیک", + "Morocco": "مراکش", + "Montserrat": "مونتسرات", + "Montenegro": "مونته نگرو", + "Mongolia": "مغولستان", + "Monaco": "موناکو", + "Moldova": "مولداوی", + "Micronesia": "میکرونزی", + "Mexico": "مکزیک", + "Mayotte": "مایوت", + "Mauritius": "موریس", + "Mauritania": "موریتانی", + "Martinique": "مارتینیک", + "Marshall Islands": "جزایر مارشال", + "Malta": "مالت", + "Mali": "مالی", + "Maldives": "مالدیو", + "Malaysia": "مالزی", + "Malawi": "مالاوی", + "Madagascar": "ماداگاسکار", + "Macedonia": "مقدونیه", + "Macau": "ماکائو", + "Luxembourg": "لوکزامبورگ", + "Lithuania": "لیتوانی", + "Liechtenstein": "لیختن اشتاین", + "Libya": "لیبی", + "Liberia": "لیبریا", + "Lesotho": "لسوتو", + "Lebanon": "لبنان", + "Latvia": "لتونی", + "Laos": "لائوس", + "Kyrgyzstan": "قرقیزستان", + "Kuwait": "کویت", + "Kosovo": "کوزوو", + "Kiribati": "کیریباتی", + "Kenya": "کنیا", + "Kazakhstan": "قزاقستان", + "Jordan": "اردن", + "Jersey": "جرسی", + "Japan": "ژاپن", + "Jamaica": "جامائیکا", + "Italy": "ایتالیا", + "Israel": "رژیم غاصب صهیونیستی", + "Isle of Man": "جزیره من", + "Ireland": "ایرلند", + "Iraq": "عراق", + "Iran": "ایران", + "Indonesia": "اندونزی", + "India": "هند", + "Iceland": "ایسلند", + "Hungary": "مجارستان", + "Hong Kong": "هنگ کنگ", + "Honduras": "هندوراس", + "Heard & McDonald Islands": "جزایر هرد و مک دونالد", + "Haiti": "هائیتی", + "Guyana": "گویان", + "Guinea-Bissau": "گینه بیسائو", + "Guinea": "گینه", + "Guernsey": "گرنزی", + "Guatemala": "گواتمالا", + "Guam": "گوام", + "Guadeloupe": "گوادلوپ", + "Grenada": "گرنادا", + "Greenland": "گرینلند", + "Greece": "یونان", + "Gibraltar": "جبل الطارق", + "Ghana": "غنا", + "Germany": "آلمان", + "Georgia": "گرجستان", + "Gambia": "گامبیا", + "Gabon": "گابن", + "French Southern Territories": "سرزمین های جنوبی فرانسه", + "French Polynesia": "پلینزی فرانسه", + "French Guiana": "گویان فرانسه", + "France": "فرانسه", + "Finland": "فنلاند", + "Fiji": "فیجی", + "Faroe Islands": "جزایر فارو", + "Falkland Islands": "جزایر فالکلند", + "Ethiopia": "اتیوپی", + "Estonia": "استونی", + "Eritrea": "اریتره", + "Equatorial Guinea": "گینه استوایی", + "El Salvador": "السالوادور", + "Egypt": "مصر", + "Ecuador": "اکوادور", + "Dominican Republic": "جمهوری دومینیکن", + "Dominica": "دومینیکا", + "Djibouti": "جیبوتی", + "Denmark": "دانمارک", + "Côte d’Ivoire": "ساحل عاج", + "Czech Republic": "جمهوری چک", + "Cyprus": "قبرس", + "Curaçao": "کوراسائو", + "Cuba": "کوبا", + "Croatia": "کرواسی", + "Costa Rica": "کاستاریکا", + "Cook Islands": "جزایر کوک", + "Congo - Kinshasa": "کنگو - کینشاسا", + "Congo - Brazzaville": "کنگو - برازاویل", + "Comoros": "کومور", + "Colombia": "کلمبیا", + "Cocos (Keeling) Islands": "جزایر کوکوس (کیلینگ)", + "Christmas Island": "جزیره کریسمس", + "China": "چین", + "Chile": "شیلی", + "Chad": "چاد", + "Central African Republic": "جمهوری آفریقای مرکزی", + "Cayman Islands": "جزایر کیمن", + "Caribbean Netherlands": "کارائیب هلند", + "Cape Verde": "کیپ ورد", + "Canada": "کانادا", + "Cameroon": "کامرون", + "Cambodia": "کامبوج", + "Burundi": "بوروندی", + "Burkina Faso": "بورکینافاسو", + "Bulgaria": "بلغارستان", + "Brunei": "برونئی", + "British Virgin Islands": "جزایر ویرجین بریتانیا", + "British Indian Ocean Territory": "قلمرو اقیانوس هند بریتانیا", + "Brazil": "برزیل", + "Bouvet Island": "جزیره بووت", + "Botswana": "بوتسوانا", + "Bosnia": "بوسنی", + "Bolivia": "بولیوی", + "Bhutan": "بوتان", + "Bermuda": "برمودا", + "Benin": "بنین", + "Belize": "بلیز", + "Belgium": "بلژیک", + "Belarus": "بلاروس", + "Barbados": "باربادوس", + "Bangladesh": "بنگلادش", + "Bahrain": "بحرین", + "Bahamas": "باهاما", + "Azerbaijan": "آذربایجان", + "Austria": "اتریش", + "Australia": "استرالیا", + "Aruba": "آروبا", + "Armenia": "ارمنستان", + "Argentina": "آرژانتین", + "Antigua & Barbuda": "آنتیگوا و باربودا", + "Antarctica": "جنوبگان", + "Anguilla": "آنگویلا", + "Angola": "آنگولا", + "Andorra": "آندورا", + "American Samoa": "ساموآ آمریکایی", + "Algeria": "الجزایر", + "Albania": "آلبانی", + "Åland Islands": "جزایر الند", + "Afghanistan": "افغانستان", + "United States": "ایالات متحده", + "United Kingdom": "انگلستان", + "Your email address does not appear to be associated with a Matrix ID on this Homeserver.": "به نظر نمی‌رسد آدرس ایمیل شما با هیچ کدام از شناسه ماتریکس در این سرور مرتبط باشد.", + "This email address was not found": "این آدرس ایمیل یافت نشد", + "Unable to enable Notifications": "فعال کردن اعلان ها امکان پذیر نیست", + "%(brand)s was not given permission to send notifications - please try again": "به %(brand)s اجازه ارسال اعلان داده نشده است - لطفاً دوباره امتحان کنید", + "%(brand)s does not have permission to send you notifications - please check your browser settings": "%(brand)s اجازه ارسال اعلان به شما را ندارد - لطفاً تنظیمات مرورگر خود را بررسی کنید", + "%(name)s is requesting verification": "%(name)s درخواست تائید دارد", + "Your homeserver rejected your log in attempt. This could be due to things just taking too long. Please try again. If this continues, please contact your homeserver administrator.": "سرور درخواست ورود شما را رد کرد. این می‌تواند به خاطر طولانی شدن فرآیندها باشد. لطفا دوباره امتحان کنید. اگر این مشکل ادامه داشت، لطفا با مدیر سرور تماس بگیرید.", + "Your homeserver was unreachable and was not able to log you in. Please try again. If this continues, please contact your homeserver administrator.": "سرور شما در دسترس نبود و امکان ورود شما میسر نیست. لطفا دوباره امتحان کنید. اگر مشکل ادامه داشت، لطفا با مدیر سرور تماس بگیرید.", + "Try again": "دوباره امتحان کنید", + "Whether you're using %(brand)s on a device where touch is the primary input mechanism": "این که شما از %(brand)s روی دستگاهی استفاده می‌کنید که در آن لامسه مکانیزم اصلی ورودی است", + "We asked the browser to remember which homeserver you use to let you sign in, but unfortunately your browser has forgotten it. Go to the sign in page and try again.": "ما از مرورگر خواستیم تا سروری را که شما برای ورود استفاده می‌کنید به خاطر بسپارد، اما متاسفانه مرورگر شما آن را فراموش کرده‌است. به صفحه‌ی ورود بروید و دوباره امتحان کنید.", + "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.": "این اقدام نیاز به دسترسی به سرور هویت‌سنجی پیش‌فرض برای تایید آدرس ایمیل یا شماره تماس دارد، اما کارگزار هیچ گونه شرایط خدماتی (terms of service) ندارد.", + "The remote side failed to pick up": "طرف دیگر قادر به پاسخ‌دادن نیست", + "Where this page includes identifiable information, such as a room, user or group ID, that data is removed before being sent to the server.": "هرگاه این صفحه شامل اطلاعات قابل شناسایی مانند شناسه‌ی اتاق ، کاربر یا گروه باشد ، این داده‌ها قبل از ارسال به سرور حذف می شوند.", + "Your user agent": "نماینده کاربری شما", + "Whether you're using %(brand)s as an installed Progressive Web App": "این که آیا شما از%(brand)s به عنوان یک PWA استفاده می‌کنید یا نه", + "Whether or not you're using the 'breadcrumbs' feature (avatars above the room list)": "این که آیا از ویژگی 'breadcrumbs' (نمایه‌ی کاربری بالای فهرست اتاق‌ها) استفاده می‌کنید یا خیر", + "Use an identity server to invite by email. Manage in Settings.": "برای دعوت از یک سرور هویت‌سنجی استفاده نمائید. می‌توانید این مورد را در تنظیمات پیکربندی نمائید.", + "Use an identity server to invite by email. Click continue to use the default identity server (%(defaultIdentityServerName)s) or manage in Settings.": "برای دعوت با استفاده از ایمیل از یک سرور هویت‌سنجی استفاده نمائید. جهت استفاده از سرور هویت‌سنجی پیش‌فرض (%(defaultIdentityServerName)s) بر روی ادامه کلیک کنید، وگرنه آن را در بخش تنظیمات پیکربندی نمائید.", + "Joins room with given address": "به اتاق با آدرس داده‌شده بپیوندید", + "WARNING: Session already verified, but keys do NOT MATCH!": "هشدار امنیتی: نشست پیش از این تائید شده، اما کلیدها مطابقت ندارد!", + "Session already verified!": "نشست پیش از این تائید شده‌است!", + "Unknown (user, session) pair:": "جفت (کاربر، نشست) ناشناخته:", + "Verifies a user, session, and pubkey tuple": "یک کاربر، نشست و عبارت کلید عمومی را تائید می‌کند", + "You cannot modify widgets in this room.": "شما امکان تغییر ویجت‌ها در این اتاق را ندارید.", + "Please supply a https:// or http:// widget URL": "لطفا نشانی یک ویجت را به پروتکل http:// یا https:// وارد کنید", + "Please supply a widget URL or embed code": "لطفا نشانی (URL) ویجت یا یک کد قابل جاسازی (embeded) وارد کنید", + "Adds a custom widget by URL to the room": "یک ویجت سفارشی را با استفاده از نشانی (URL) به اتاق اضافه می‌کند", + "Opens the Developer Tools dialog": "پنجره‌ی ابزار توسعه را باز می‌کند", + "Could not find user in room": "کاربر در اتاق یافت نشد", + "Command failed": "دستور موفقیت‌آمیز نبود", + "Define the power level of a user": "سطح قدرت یک کاربر را تعریف کنید", + "You are no longer ignoring %(userId)s": "شما دیگر کاربر %(userId)s را نادیده نمی‌گیرید", + "Unignored user": "کاربران نادیده گرفته‌نشده", + "Stops ignoring a user, showing their messages going forward": "توقف نادیده گرفتن یک کاربر، باعث می‌شود پیام‌های او به شما نمایش داده شود", + "You are now ignoring %(userId)s": "شما هم‌اکنون کاربر %(userId)s را نادیده گرفتید", + "Ignored user": "کاربران نادیده گرفته‌شده", + "Ignores a user, hiding their messages from you": "نادیده گرفتن یک کاربر، باعث می‌شود پیام‌های او به شما نمایش داده نشود", + "Unbans user with given ID": "رفع تحریم کاربر با شناسه‌ی مذکور", + "Bans user with given id": "تحریم کاربر با شناسه‌ی مذکور", + "Kicks user with given id": "اخراج کاربر با شناسه‌ی مذکور", + "Unrecognised room address:": "آدرس اتاق قابل تشخیص نیست:", + "Leave room": "ترک اتاق" } diff --git a/src/i18n/strings/fi.json b/src/i18n/strings/fi.json index 74fd5f5a9a..5452176cd9 100644 --- a/src/i18n/strings/fi.json +++ b/src/i18n/strings/fi.json @@ -2756,7 +2756,7 @@ "Invite by email": "Kutsu sähköpostilla", "Report a bug": "Raportoi virheestä", "Invalid Recovery Key": "Virheellinen palautusavain", - "Confirm Security Phrase": "Vahvista turvalauseke", + "Confirm Security Phrase": "Vahvista turvalause", "Upload a file": "Lähetä tiedosto", "Confirm encryption setup": "Vahvista salauksen asetukset", "Verify other session": "Vahvista toinen istunto", @@ -2775,7 +2775,7 @@ "Rate %(brand)s": "Arvioi %(brand)s", "%(brand)s Desktop": "%(brand)s Desktop", "%(brand)s Web": "%(brand)s Web", - "Security Phrase": "Turvalauseke", + "Security Phrase": "Turvalause", "Security Key": "Turva-avain", "Verify session": "Vahvista istunto", "Hold": "Pidä", @@ -2820,8 +2820,8 @@ "Prepends ┬──┬ ノ( ゜-゜ノ) to a plain-text message": "Lisää ┬──┬ ノ( ゜-゜ノ) viestin alkuun", "Prepends (╯°□°)╯︵ ┻━┻ to a plain-text message": "Lisää (╯°□°)╯︵ ┻━┻ viestin alkuun", "Unable to access secret storage. Please verify that you entered the correct recovery passphrase.": "Salaisen tallenustilan avaaminen epäonnistui. Varmista, että syötit oikean palautuksen salasanan.", - "Enter a Security Phrase": "Kirjoita turvalauseke", - "Set a Security Phrase": "Aseta turvalauseke", + "Enter a Security Phrase": "Kirjoita turvalause", + "Set a Security Phrase": "Aseta turvalause", "Unable to query secret storage status": "Salaisen tallennustilan tilaa ei voi kysellä", "If you cancel now, you may lose encrypted messages & data if you lose access to your logins.": "Jos peruutat nyt, voit menettää salattuja viestejä ja tietoja, jos menetät pääsyn kirjautumistietoihisi.", "You can also set up Secure Backup & manage your keys in Settings.": "Voit myös ottaa käyttöön suojatun varmuuskopioinnin ja hallita avaimia asetuksista.", @@ -2870,5 +2870,85 @@ "Privacy Policy": "Tietosuojakäytäntö", "Cookie Policy": "Evästekäytäntö", "Recent changes that have not yet been received": "Tuoreet muutokset, joita ei ole vielä otettu vastaan", - "We asked the browser to remember which homeserver you use to let you sign in, but unfortunately your browser has forgotten it. Go to the sign in page and try again.": "Pyysimme selainta muistamaan kirjautumista varten mitä kotipalvelinta käytät, mutta selain on unohtanut sen. Mene kirjautumissivulle ja yritä uudelleen." + "We asked the browser to remember which homeserver you use to let you sign in, but unfortunately your browser has forgotten it. Go to the sign in page and try again.": "Pyysimme selainta muistamaan kirjautumista varten mitä kotipalvelinta käytät, mutta selain on unohtanut sen. Mene kirjautumissivulle ja yritä uudelleen.", + "Search (must be enabled)": "Haku (pitää olla käytössä)", + "Apply": "Käytä", + "Applying...": "Käytetään...", + "Channel: ": "Kanava: ", + "This homeserver has been blocked by it's administrator.": "Tämän kotipalvelimen ylläpitäjä on estänyt sen.", + "We'll create rooms for each of them. You can add more later too, including already existing ones.": "Tehdään huone jokaiselle. Voit myös lisätä niitä myöhemmin, mukaan lukien olemassa olevia.", + "Let's create a room for each of them. You can add more later too, including already existing ones.": "Tehdään huone jokaiselle. Voit myös lisätä niitä myöhemmin, mukaan lukien olemassa olevia.", + "What are some things you want to discuss?": "Mistä asioista haluat keskustella?", + "Inviting...": "Kutsutaan...", + "Share %(name)s": "Jaa %(name)s", + "Creating rooms...": "Luodaan huoneita...", + "Skip for now": "Ohita tältä erää", + "Room name": "Huoneen nimi", + "Default Rooms": "Oletushuoneet", + " invites you": " kutsuu sinut", + "You may want to try a different search or check for typos.": "Kokeile eri hakua tai tarkista haku kirjoitusvirheiden varalta.", + "No results found": "Tuloksia ei löytynyt", + "Mark as not suggested": "Merkitse ei-ehdotetuksi", + "Mark as suggested": "Merkitse ehdotetuksi", + "Removing...": "Poistetaan...", + "If you can't find the room you're looking for, ask for an invite or create a new room.": "Jos et löydä etsimääsi huonetta, pyydä kutsua tai luo uusi huone.", + "%(count)s rooms|one": "%(count)s huone", + "%(count)s rooms|other": "%(count)s huonetta", + "%(count)s members|one": "%(count)s jäsen", + "%(count)s members|other": "%(count)s jäsentä", + "You don't have permission": "Sinulla ei ole lupaa", + "%(count)s messages deleted.|one": "%(count)s viesti poistettu.", + "%(count)s messages deleted.|other": "%(count)s viestiä poistettu.", + "Remember this": "Muista tämä", + "Save Changes": "Tallenna muutokset", + "Saving...": "Tallennetaan...", + "View dev tools": "Näytä kehitystyökalut", + "Invite to %(roomName)s": "Kutsu huoneeseen %(roomName)s", + "Minimize dialog": "Pienennä ikkuna", + "Maximize dialog": "Suurenna ikkuna", + "Edit Values": "Muokkaa arvoja", + "Value in this room:": "Arvo tässä huoneessa:", + "Value:": "Arvo:", + "Save setting values": "Tallenna asetusarvot", + "Settable at room": "Asetettavissa huoneessa", + "Settable at global": "Asetettavissa globaalisti", + "Level": "Taso", + "Setting definition:": "Asetuksen määritelmä:", + "This UI does NOT check the types of the values. Use at your own risk.": "Tämä käyttöliittymä EI tarkista arvojen tyyppejä. Käytä omalla vastuullasi.", + "Caution:": "Varoitus:", + "Setting:": "Asetus:", + "Value in this room": "Arvo tässä huoneessa", + "Value": "Arvo", + "Failed to save settings": "Asetusten tallentaminen epäonnistui", + "Create a new room": "Luo uusi huone", + "Don't want to add an existing room?": "Etkö halua lisätä olemassa olevaa huonetta?", + "Edit devices": "Muokkaa laitteita", + "Invite People": "Kutsu ihmisiä", + "Empty room": "Tyhjä huone", + "Suggested Rooms": "Ehdotetut huoneet", + "Add existing room": "Lisää olemassa oleva huone", + "Send message": "Lähetä viesti", + "Your message was sent": "Viestisi lähetettiin", + "Encrypting your message...": "Viestiäsi salataan...", + "Sending your message...": "Viestiäsi lähetetään...", + "Spell check dictionaries": "Oikolukusanastot", + "New room": "Uusi huone", + "Invite members": "Kutsu jäseniä", + "Invite people": "Kutsu ihmisiä", + "Share invite link": "Jaa kutsulinkki", + "Click to copy": "Kopioi napsauttamalla", + "Creating...": "Luodaan...", + "You can change these anytime.": "Voit muuttaa näitä koska tahansa.", + "You can change this later": "Voit muuttaa tätä myöhemmin", + "Private": "Yksityinen", + "Public": "Julkinen", + "Delete": "Poista", + "From %(deviceName)s (%(deviceId)s) at %(ip)s": "Laitteelta %(deviceName)s (%(deviceId)s) osoitteesta %(ip)s", + "Show chat effects (animations when receiving e.g. confetti)": "Näytä keskustelutehosteet (animaatiot, kun saat esim. konfettia)", + "Jump to the bottom of the timeline when you send a message": "Siirry aikajanan pohjalle, kun lähetät viestin", + "Check your devices": "Tarkista laitteesi", + "A new login is accessing your account: %(name)s (%(deviceID)s) at %(ip)s": "Uusi kirjautuminen tilillesi: %(name)s (%(deviceID)s) osoitteesta %(ip)s", + "This homeserver has been blocked by its administrator.": "Tämä kotipalvelin on ylläpitäjänsä estämä.", + "You're already in a call with this person.": "Olet jo puhelussa tämän henkilön kanssa.", + "Already in call": "Olet jo puhelussa" } diff --git a/src/i18n/strings/fr.json b/src/i18n/strings/fr.json index 6fcc0323d7..dc8b701e35 100644 --- a/src/i18n/strings/fr.json +++ b/src/i18n/strings/fr.json @@ -102,7 +102,7 @@ "%(senderName)s kicked %(targetName)s.": "%(senderName)s a expulsé %(targetName)s.", "Kick": "Expulser", "Kicks user with given id": "Expulse l’utilisateur à partir de son identifiant", - "Labs": "Labo", + "Labs": "Expérimental", "Leave room": "Quitter le salon", "%(targetName)s left the room.": "%(targetName)s a quitté le salon.", "Logout": "Se déconnecter", @@ -637,7 +637,7 @@ "Community IDs cannot be empty.": "Les identifiants de communauté ne peuvent pas être vides.", "In reply to ": "En réponse à ", "%(oldDisplayName)s changed their display name to %(displayName)s.": "%(oldDisplayName)s a modifié son nom d’affichage en %(displayName)s.", - "Failed to set direct chat tag": "Échec de l’ajout de l’étiquette discussion directe", + "Failed to set direct chat tag": "Échec de l’ajout de l’étiquette de conversation privée", "Failed to remove tag %(tagName)s from room": "Échec de la suppression de l’étiquette %(tagName)s du salon", "Failed to add tag %(tagName)s to room": "Échec de l’ajout de l’étiquette %(tagName)s au salon", "Clear filter": "Supprimer les filtres", @@ -684,7 +684,7 @@ "Noisy": "Sonore", "Room not found": "Salon non trouvé", "Messages containing my display name": "Messages contenant mon nom d’affichage", - "Messages in one-to-one chats": "Messages dans les discussions directes", + "Messages in one-to-one chats": "Messages dans les conversations privées", "Unavailable": "Indisponible", "View Decrypted Source": "Voir la source déchiffrée", "Failed to update keywords": "Échec de la mise à jour des mots-clés", @@ -945,7 +945,7 @@ "Without setting up Secure Message Recovery, you'll lose your secure message history when you log out.": "Si vous ne configurez pas la récupération de messages sécurisée, vous perdrez l'historique de vos messages sécurisés quand vous vous déconnectez.", "If you don't want to set this up now, you can later in Settings.": "Si vous ne voulez pas le configurer maintenant, vous pouvez le faire plus tard dans les paramètres.", "Messages containing @room": "Messages contenant @room", - "Encrypted messages in one-to-one chats": "Messages chiffrés dans les discussions directes", + "Encrypted messages in one-to-one chats": "Messages chiffrés dans les conversations privées", "Encrypted messages in group chats": "Messages chiffrés dans les discussions de groupe", "That doesn't look like a valid email address": "Cela ne ressemble pas à une adresse e-mail valide", "Checking...": "Vérification…", @@ -1029,7 +1029,7 @@ "Composer": "Compositeur", "Room list": "Liste de salons", "Timeline": "Fil de discussion", - "Autocomplete delay (ms)": "Retard pour l’autocomplétion (ms)", + "Autocomplete delay (ms)": "Délai pour l’autocomplétion (ms)", "Chat with %(brand)s Bot": "Discuter avec le bot %(brand)s", "Roles & Permissions": "Rôles et permissions", "Changes to who can read history will only apply to future messages in this room. The visibility of existing history will be unchanged.": "Les modifications concernant l'accès à l’historique ne s'appliqueront qu’aux futurs messages de ce salon. La visibilité de l’historique existant ne sera pas modifiée.", @@ -1626,7 +1626,7 @@ "Custom (%(level)s)": "Personnalisé (%(level)s)", "Trusted": "Fiable", "Not trusted": "Non fiable", - "Direct message": "Message direct", + "Direct message": "Conversation privée", "%(role)s in %(roomName)s": "%(role)s dans %(roomName)s", "Messages in this room are end-to-end encrypted.": "Les messages dans ce salon sont chiffrés de bout en bout.", "Security": "Sécurité", @@ -1669,7 +1669,7 @@ "%(senderName)s placed a video call.": "%(senderName)s a passé un appel vidéo.", "%(senderName)s placed a video call. (not supported by this browser)": "%(senderName)s a passé un appel vidéo. (non pris en charge par ce navigateur)", "Clear notifications": "Vider les notifications", - "Customise your experience with experimental labs features. Learn more.": "Personnalisez votre expérience avec des fonctionnalités expérimentales du labo. En savoir plus.", + "Customise your experience with experimental labs features. Learn more.": "Personnalisez votre expérience avec des fonctionnalités expérimentales. En savoir plus.", "Error upgrading room": "Erreur lors de la mise à niveau du salon", "Double check that your server supports the room version chosen and try again.": "Vérifiez que votre serveur prend en charge la version de salon choisie et réessayez.", "This message cannot be decrypted": "Ce message ne peut pas être déchiffré", @@ -1735,7 +1735,7 @@ "Help": "Aide", "Show more": "En voir plus", "Recent Conversations": "Conversations récentes", - "Direct Messages": "Messages directs", + "Direct Messages": "Conversations privées", "Go": "C’est parti", "Show info about bridges in room settings": "Afficher des informations à propos des passerelles dans les paramètres du salon", "This bridge is managed by .": "Cette passerelle est gérée par .", @@ -1762,7 +1762,7 @@ "We couldn't create your DM. Please check the users you want to invite and try again.": "Impossible de créer votre conversation privée. Vérifiez quels utilisateurs que vous souhaitez inviter et réessayez.", "Something went wrong trying to invite the users.": "Une erreur est survenue en essayant d’inviter les utilisateurs.", "We couldn't invite those users. Please check the users you want to invite and try again.": "Impossible d’inviter ces utilisateurs. Vérifiez quels utilisateurs que vous souhaitez inviter et réessayez.", - "Recently Direct Messaged": "Messages directs récents", + "Recently Direct Messaged": "Conversations privées récentes", "Start": "Commencer", "Session verified": "Session vérifiée", "Your new session is now verified. It has access to your encrypted messages, and other users will see it as trusted.": "Votre nouvelle session est maintenant vérifiée. Elle a accès à vos messages chiffrés et les autres utilisateurs la verront comme fiable.", @@ -2113,7 +2113,7 @@ "Click the button below to confirm deleting these sessions.|one": "Cliquez sur le bouton ci-dessous pour confirmer la suppression de cette session.", "Welcome to %(appName)s": "Bienvenue sur %(appName)s", "Liberate your communication": "Libérez votre communication", - "Send a Direct Message": "Envoyez un message direct", + "Send a Direct Message": "Envoyez un message privé", "Explore Public Rooms": "Explorez les salons publics", "Create a Group Chat": "Créez une discussion de groupe", "%(name)s is requesting verification": "%(name)s demande une vérification", @@ -2234,7 +2234,7 @@ "A new version of %(brand)s is available!": "Une nouvelle version de %(brand)s est disponible !", "New version available. Update now.": "Nouvelle version disponible. Faire la mise à niveau maintenant.", "Emoji picker": "Sélecteur d’émojis", - "Your server admin has disabled end-to-end encryption by default in private rooms & Direct Messages.": "L’administrateur de votre serveur a désactivé le chiffrement de bout en bout par défaut dans les salons privés et les messages directs.", + "Your server admin has disabled end-to-end encryption by default in private rooms & Direct Messages.": "L’administrateur de votre serveur a désactivé le chiffrement de bout en bout par défaut dans les salons privés et les conversations privées.", "People": "Personnes", "Switch to light mode": "Passer au mode clair", "Switch to dark mode": "Passer au mode sombre", @@ -2264,7 +2264,7 @@ "Use Recovery Key": "Utiliser la clé de récupération", "Use the improved room list (will refresh to apply changes)": "Utiliser la liste de salons améliorée (actualisera pour appliquer les changements)", "Use custom size": "Utiliser une taille personnalisée", - "Hey you. You're the best!": "Eh vous. Vous êtes les meilleurs !", + "Hey you. You're the best!": "Hé vous. Vous êtes le meilleur !", "Message layout": "Mise en page des messages", "Compact": "Compacte", "Modern": "Moderne", @@ -2905,7 +2905,7 @@ "Add a topic to help people know what it is about.": "Ajoutez un sujet pour aider les gens à savoir de quoi il est question.", "Topic: %(topic)s ": "Sujet : %(topic)s ", "Topic: %(topic)s (edit)": "Sujet : %(topic)s (modifier)", - "This is the beginning of your direct message history with .": "C’est le début de votre historique de messages privés avec .", + "This is the beginning of your direct message history with .": "C’est le début de l’historique de votre conversation privée avec .", "Only the two of you are in this conversation, unless either of you invites anyone to join.": "Vous n’êtes que tous les deux dans cette conversation, à moins que l’un de vous invite quelqu’un à vous rejoindre.", "%(name)s on hold": "%(name)s est en attente", "Return to call": "Revenir à l’appel", @@ -3024,7 +3024,7 @@ "Use Command + F to search": "Utilisez Commande + F pour rechercher", "Show line numbers in code blocks": "Afficher les numéros de ligne dans les blocs de code", "Expand code blocks by default": "Développer les blocs de code par défaut", - "Show stickers button": "Afficher le bouton autocollants", + "Show stickers button": "Afficher le bouton des autocollants", "Use app": "Utiliser l’application", "Element Web is experimental on mobile. For a better experience and the latest features, use our free native app.": "Element Web est expérimental sur téléphone. Pour une meilleure expérience et bénéficier des dernières fonctionnalités, utilisez notre application native gratuite.", "Use app for a better experience": "Utilisez une application pour une meilleure expérience", @@ -3043,7 +3043,7 @@ "Your homeserver was unreachable and was not able to log you in. Please try again. If this continues, please contact your homeserver administrator.": "Votre serveur d’accueil n’est pas accessible, nous n’avons pas pu vous connecter. Merci de réessayer. Si cela persiste, merci de contacter l’administrateur de votre serveur d’accueil.", "Try again": "Réessayez", "We asked the browser to remember which homeserver you use to let you sign in, but unfortunately your browser has forgotten it. Go to the sign in page and try again.": "Nous avons demandé à votre navigateur de mémoriser votre serveur d’accueil, mais il semble l’avoir oublié. Rendez-vous à la page de connexion et réessayez.", - "We couldn't log you in": "Impossible de vous déconnecter", + "We couldn't log you in": "Nous n’avons pas pu vous connecter", "Upgrade to %(hostSignupBrand)s": "Mettre à jour vers %(hostSignupBrand)s", "Edit Values": "Modifier les valeurs", "Values at explicit levels in this room:": "Valeurs pour les rangs explicites de ce salon :", @@ -3071,7 +3071,7 @@ "Original event source": "Événement source original", "Decrypted event source": "Événement source déchiffré", "We'll create rooms for each of them. You can add existing rooms after setup.": "Nous allons créer un salon pour chacun d’entre eux. Vous pourrez ajouter des salons après l’initialisation.", - "What projects are you working on?": "Sur quels projets travaillez vous ?", + "What projects are you working on?": "Sur quels projets travaillez-vous ?", "We'll create rooms for each topic.": "Nous allons créer un salon pour chaque sujet.", "What are some things you want to discuss?": "De quoi voulez vous discuter ?", "Inviting...": "Invitation…", @@ -3083,7 +3083,7 @@ "A private space just for you": "Un espace privé seulement pour vous", "Just Me": "Seulement moi", "Ensure the right people have access to the space.": "Vérifiez que les bonnes personnes ont accès à cet espace.", - "Who are you working with?": "Avec qui travaillez vous ?", + "Who are you working with?": "Avec qui travaillez-vous ?", "Finish": "Finir", "At the moment only you can see it.": "Pour l’instant vous seul pouvez le voir.", "Creating rooms...": "Création des salons…", @@ -3112,7 +3112,7 @@ "Remove from Space": "Supprimer de l’espace", "Undo": "Annuler", "Your message wasn't sent because this homeserver has been blocked by it's administrator. Please contact your service administrator to continue using the service.": "Votre message n’a pas été envoyé car ce serveur d’accueil a été banni par son administrateur. Merci de contacter votre administrateur de service pour poursuivre l’usage de ce service.", - "Are you sure you want to leave the space '%(spaceName)s'?": "Êtes vous sûr de vouloir quitter l’espace « %(spaceName)s » ?", + "Are you sure you want to leave the space '%(spaceName)s'?": "Êtes-vous sûr de vouloir quitter l’espace « %(spaceName)s » ?", "This space is not public. You will not be able to rejoin without an invite.": "Cet espace n’est pas public. Vous ne pourrez pas le rejoindre sans invitation.", "Start audio stream": "Démarrer une diffusion audio", "Failed to start livestream": "Échec lors du démarrage de la diffusion en direct", @@ -3137,7 +3137,7 @@ "Add existing spaces/rooms": "Ajouter des espaces/salons existants", "You will not be able to undo this change as you are demoting yourself, if you are the last privileged user in the space it will be impossible to regain privileges.": "Vous ne pourrez pas annuler ce changement puisque vous vous rétrogradez. Si vous êtes le dernier utilisateur a privilèges de cet espace, il deviendra impossible d’en reprendre contrôle.", "Empty room": "Salon vide", - "Suggested Rooms": "Salons suggérés", + "Suggested Rooms": "Salons recommandés", "Explore space rooms": "Parcourir les salons de cet espace", "You do not have permissions to add rooms to this space": "Vous n’avez pas la permission d’ajouter des salons à cet espace", "Add existing room": "Ajouter un salon existant", @@ -3178,5 +3178,171 @@ "This homeserver has been blocked by it's administrator.": "Ce serveur d’accueil a été banni par ses administrateurs.", "This homeserver has been blocked by its administrator.": "Ce serveur d’accueil a été banni par ses administrateurs.", "You're already in a call with this person.": "Vous êtes déjà en cours d’appel avec cette personne.", - "Already in call": "Déjà en cours d’appel" + "Already in call": "Déjà en cours d’appel", + "Space selection": "Sélection d’un espace", + "Search names and description": "Rechercher par nom ou description", + "Go to my first room": "Rejoindre mon premier salon", + "Mark as suggested": "Marquer comme recommandé", + "Mark as not suggested": "Marquer comme non recommandé", + "Suggested": "Recommandé", + "This room is suggested as a good one to join": "Ce salon recommandé peut être intéressant à rejoindre", + "Verify this login to access your encrypted messages and prove to others that this login is really you.": "Vérifiez cette connexion pour accéder à vos messages chiffrés et prouver aux autres qu’il s’agit bien de vous.", + "Verify with another session": "Vérifier avec une autre session", + "We'll create rooms for each of them. You can add more later too, including already existing ones.": "Nous allons créer un salon pour chacun d’entre eux. Vous pourrez aussi en ajouter plus tard, y compris certains déjà existant.", + "Let's create a room for each of them. You can add more later too, including already existing ones.": "Créons un salon pour chacun d’entre eux. Vous pourrez en ajouter plus tard, y compris certains déjà existant.", + "Make sure the right people have access. You can invite more later.": "Assurez-vous que les accès sont accordés aux bonnes personnes. Vous pourrez en inviter d’autres plus tard.", + "A private space to organise your rooms": "Un espace privé pour organiser vos salons", + "Just me": "Seulement moi", + "Make sure the right people have access to %(name)s": "Assurez-vous que les bonnes personnes ont accès à %(name)s", + "It's just you at the moment, it will be even better with others.": "Vous êtes seul pour l’instant, ce sera plus agréable avec de la compagnie.", + "Share %(name)s": "Partager %(name)s", + "Private space": "Espace privé", + "Public space": "Espace public", + " invites you": " vous a invité", + "You may want to try a different search or check for typos.": "Essayez une requête différente, ou vérifiez que vous n’avez pas fait de faute de frappe.", + "No results found": "Aucun résultat", + "Removing...": "Suppression…", + "Failed to remove some rooms. Try again later": "Échec de la suppression de certains salons. Veuillez réessayez plus tard", + "%(count)s rooms and 1 space|one": "%(count)s salon et 1 espace", + "%(count)s rooms and 1 space|other": "%(count)s salons et 1 espace", + "%(count)s rooms and %(numSpaces)s spaces|one": "%(count)s salon et %(numSpaces)s espaces", + "%(count)s rooms and %(numSpaces)s spaces|other": "%(count)s salons et %(numSpaces)s espaces", + "If you can't find the room you're looking for, ask for an invite or create a new room.": "Si vous ne trouvez pas le salon que vous cherchez, demandez une invitation ou créez un nouveau salon.", + "%(count)s rooms|one": "%(count)s salon", + "%(count)s rooms|other": "%(count)s salons", + "You don't have permission": "Vous n’avez pas l’autorisation", + "Open": "Ouvrir", + "%(count)s messages deleted.|one": "%(count)s message supprimé.", + "%(count)s messages deleted.|other": "%(count)s messages supprimés.", + "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.": "Cela n’affecte généralement que la façon dont le salon est traité sur le serveur. Si vous avez des problèmes avec votre %(brand)s, signalez une anomalie.", + "Invite to %(roomName)s": "Inviter dans %(roomName)s", + "Edit devices": "Modifier les appareils", + "Invite People": "Inviter des personnes", + "Invite with email or username": "Inviter par e-mail ou nom d’utilisateur", + "You can change these anytime.": "Vous pouvez les changer à n’importe quel moment.", + "Add some details to help people recognise it.": "Ajoutez des informations pour aider les personnes à l’identifier.", + "Spaces are new ways to group rooms and people. To join an existing space you'll need an invite.": "Les espaces permettent de grouper les salons et les personnes. Pour rejoindre un espace existant, il vous faut une invitation.", + "From %(deviceName)s (%(deviceId)s) at %(ip)s": "Sur %(deviceName)s %(deviceId)s depuis %(ip)s", + "Check your devices": "Vérifiez vos appareils", + "A new login is accessing your account: %(name)s (%(deviceID)s) at %(ip)s": "Une nouvelle session a accès à votre compte : %(name)s %(deviceID)s depuis %(ip)s", + "You have unverified logins": "Vous avez des sessions non-vérifiées", + "Without verifying, you won’t have access to all your messages and may appear as untrusted to others.": "Sans vérification vous n’aurez pas accès à tous vos messages et n’apparaîtrez pas comme de confiance aux autres.", + "Verify your identity to access encrypted messages and prove your identity to others.": "Vérifiez votre identité pour accéder aux messages chiffrés et prouver votre identité aux autres.", + "Use another login": "Utiliser un autre identifiant", + "Please choose a strong password": "Merci de choisir un mot de passe fort", + "You can add more later too, including already existing ones.": "Vous pourrez en ajouter plus tard, y compris certains déjà existant.", + "Let's create a room for each of them.": "Créons un salon pour chacun d’entre eux.", + "What are some things you want to discuss in %(spaceName)s?": "De quoi voulez-vous discuter dans %(spaceName)s ?", + "Verification requested": "Vérification requise", + "Avatar": "Avatar", + "Verify other login": "Vérifier l’autre connexion", + "Reset event store": "Réinitialiser le magasin d’événements", + "If you do, please note that none of your messages will be deleted, but the search experience might be degraded for a few momentswhilst the index is recreated": "Si vous le faites, notes qu’aucun de vos messages ne sera supprimé, mais la recherche pourrait être dégradée pendant quelques instants, le temps de recréer l’index", + "You most likely do not want to reset your event index store": "Il est probable que vous ne vouliez pas réinitialiser votre magasin d’index d’événements", + "Reset event store?": "Réinitialiser le magasin d’événements ?", + "Consult first": "Consulter d’abord", + "Invited people will be able to read old messages.": "Les personnes invitées pourront lire les anciens messages.", + "We couldn't create your DM.": "Nous n’avons pas pu créer votre message direct.", + "Adding...": "Ajout…", + "Add existing rooms": "Ajouter des salons existants", + "%(count)s people you know have already joined|one": "%(count)s personne que vous connaissez en fait déjà partie", + "%(count)s people you know have already joined|other": "%(count)s personnes que vous connaissez en font déjà partie", + "Accept on your other login…": "Acceptez sur votre autre connexion…", + "Stop & send recording": "Terminer et envoyer l’enregistrement", + "Record a voice message": "Enregistrer un message vocal", + "Invite messages are hidden by default. Click to show the message.": "Les messages d’invitation sont masqués par défaut. Cliquez pour voir le message.", + "Quick actions": "Actions rapides", + "Invite to just this room": "Inviter seulement dans ce salon", + "Warn before quitting": "Avertir avant de quitter", + "Message search initilisation failed": "Échec de l’initialisation de la recherche de message", + "Manage & explore rooms": "Gérer et découvrir les salons", + "Consulting with %(transferTarget)s. Transfer to %(transferee)s": "Consultation avec %(transferTarget)s. Transfert à %(transferee)s", + "unknown person": "personne inconnue", + "Share decryption keys for room history when inviting users": "Partager les clés de déchiffrement lors de l’invitation d’utilisateurs", + "Send and receive voice messages (in development)": "Envoyez et recevez des messages vocaux (en développement)", + "%(deviceId)s from %(ip)s": "%(deviceId)s depuis %(ip)s", + "Review to ensure your account is safe": "Vérifiez pour assurer la sécurité de votre compte", + "Sends the given message as a spoiler": "Envoie le message flouté", + "You are the only person here. If you leave, no one will be able to join in the future, including you.": "Vous êtes la seule personne ici. Si vous partez, plus personne ne pourra rejoindre cette conversation, y compris vous.", + "If you reset everything, you will restart with no trusted sessions, no trusted users, and might not be able to see past messages.": "Si vous réinitialisez tout, vous allez repartir sans session et utilisateur de confiance. Vous pourriez ne pas voir certains messages passés.", + "Only do this if you have no other device to complete verification with.": "Poursuivez seulement si vous n’avez aucun autre appareil avec lequel procéder à la vérification.", + "Reset everything": "Tout réinitialiser", + "Forgotten or lost all recovery methods? Reset all": "Vous avez perdu ou oublié tous vos moyens de récupération ? Tout réinitialiser", + "If you do, please note that none of your messages will be deleted, but the search experience might be degraded for a few moments whilst the index is recreated": "Si vous le faites, notez qu’aucun de vos messages ne sera supprimé, mais la recherche pourrait être dégradée pendant quelques instants, le temps de recréer l’index", + "View message": "Afficher le message", + "Zoom in": "Zoomer", + "Zoom out": "Dé-zoomer", + "%(seconds)ss left": "%(seconds)s secondes restantes", + "Change server ACLs": "Modifier les ACL du serveur", + "Show options to enable 'Do not disturb' mode": "Afficher une option pour activer le mode « Ne pas déranger »", + "You can select all or individual messages to retry or delete": "Vous pouvez choisir de renvoyer ou supprimer tous les messages ou seulement certains", + "Sending": "Envoi", + "Retry all": "Tout renvoyer", + "Delete all": "Tout supprimer", + "Some of your messages have not been sent": "Certains de vos messages n’ont pas été envoyés", + "%(count)s members including %(commaSeparatedMembers)s|one": "%(commaSeparatedMembers)s", + "%(count)s members including %(commaSeparatedMembers)s|other": "%(count)s membres dont %(commaSeparatedMembers)s", + "Including %(commaSeparatedMembers)s": "Dont %(commaSeparatedMembers)s", + "View all %(count)s members|one": "Afficher le membre", + "View all %(count)s members|other": "Afficher les %(count)s membres", + "Failed to send": "Échec de l’envoi", + "Play": "Lecture", + "Pause": "Pause", + "Enter your Security Phrase a second time to confirm it.": "Saisissez à nouveau votre phrase secrète pour la confirmer.", + "Pick rooms or conversations to add. This is just a space for you, no one will be informed. You can add more later.": "Choisissez des salons ou conversations à ajouter. C’est un espace rien que pour vous, personne n’en sera informé. Vous pourrez en ajouter plus tard.", + "What do you want to organise?": "Que voulez-vous organiser ?", + "Filter all spaces": "Filtrer tous les espaces", + "Delete recording": "Supprimer l’enregistrement", + "Stop the recording": "Arrêter l’enregistrement", + "%(count)s results in all spaces|one": "%(count)s résultat dans tous les espaces", + "%(count)s results in all spaces|other": "%(count)s résultats dans tous les espaces", + "You have no ignored users.": "Vous n’avez ignoré personne.", + "Your access token gives full access to your account. Do not share it with anyone.": "Votre jeton d’accès donne un accès intégral à votre compte. Ne le partagez avec personne.", + "This is an experimental feature. For now, new users receiving an invite will have to open the invite on to actually join.": "Ceci est une fonctionnalité expérimentale. Pour l’instant, les nouveaux utilisateurs recevant une invitation devront l’ouvrir sur pour poursuivre.", + "To join %(spaceName)s, turn on the Spaces beta": "Pour rejoindre %(spaceName)s, activez les espaces en bêta", + "To view %(spaceName)s, turn on the Spaces beta": "Pour visualiser %(spaceName)s, activez les espaces en bêta", + "Select a room below first": "Sélectionnez un salon ci-dessous d’abord", + "Communities are changing to Spaces": "Les communautés deviennent des espaces", + "Join the beta": "Rejoindre la bêta", + "Leave the beta": "Quitter la bêta", + "Beta": "Bêta", + "Tap for more info": "Appuyez pour plus d’information", + "Spaces is a beta feature": "Les espaces sont une fonctionnalité en bêta", + "Want to add a new room instead?": "Voulez-vous plutôt ajouter un nouveau salon ?", + "Adding rooms... (%(progress)s out of %(count)s)|one": "Ajout du salon…", + "Adding rooms... (%(progress)s out of %(count)s)|other": "Ajout des salons… (%(progress)s sur %(count)s)", + "Not all selected were added": "Toute la sélection n’a pas été ajoutée", + "You can add existing spaces to a space.": "Vous pouvez ajouter des espaces existants à un espace.", + "Feeling experimental?": "L’esprit aventurier ?", + "You are not allowed to view this server's rooms list": "Vous n’avez pas l’autorisation d’accéder à la liste des salons de ce serveur", + "Error processing voice message": "Erreur lors du traitement du message vocal", + "We didn't find a microphone on your device. Please check your settings and try again.": "Nous n’avons pas détecté de microphone sur votre appareil. Merci de vérifier vos paramètres et de réessayer.", + "No microphone found": "Aucun microphone détecté", + "We were unable to access your microphone. Please check your browser settings and try again.": "Nous n’avons pas pu accéder à votre microphone. Merci de vérifier les paramètres de votre navigateur et de réessayer.", + "Unable to access your microphone": "Impossible d’accéder à votre microphone", + "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.": "L’esprit aventurier ? Les fonctionnalités expérimentales vous permettent de tester les nouveautés et aider à les polir avant leur lancement. En apprendre plus.", + "Access Token": "Jeton d’accès", + "Spaces are a new way to group rooms and people. To join an existing space you'll need an invite.": "Les espaces sont un nouveau moyen de grouper les salons et les personnes. Une invitation est nécessaire pour rejoindre un espace existant.", + "Please enter a name for the space": "Veuillez renseigner un nom pour l’espace", + "Connecting": "Connexion", + "Allow Peer-to-Peer for 1:1 calls (if you enable this, the other party might be able to see your IP address)": "Autoriser le pair-à-pair (p2p) pour les appels individuels (si activé, votre correspondant pourra voir votre adresse IP)", + "Beta available for web, desktop and Android. Some features may be unavailable on your homeserver.": "Bêta disponible pour l’application web, de bureau et Android. Certains fonctionnalités pourraient ne pas être disponibles sur votre serveur d’accueil.", + "You can leave the beta any time from settings or tapping on a beta badge, like the one above.": "Vous pouvez quitter la bêta n’importe quand à partir des paramètres, ou en appuyant sur le badge bêta comme celui ci-dessus.", + "%(brand)s will reload with Spaces enabled. Communities and custom tags will be hidden.": "%(brand)s va être redémarré avec les espaces activés. Les communautés et les étiquettes personnalisées seront cachés.", + "Beta available for web, desktop and Android. Thank you for trying the beta.": "Bêta disponible pour l’application web, de bureau et Android. Merci d’essayer la bêta.", + "%(brand)s will reload with Spaces disabled. Communities and custom tags will be visible again.": "%(brand)s va être redémarré avec les espaces désactivés. Les communautés et les étiquettes personnalisées seront de nouveau visibles.", + "Spaces are a new way to group rooms and people.": "Les espaces sont un nouveau moyen de regrouper les salons et les personnes.", + "Message search initialisation failed": "Échec de l’initialisation de la recherche de message", + "Your feedback will help make spaces better. The more detail you can go into, the better.": "Vos commentaires aideront à améliorer les espaces. N’hésitez pas à entrer dans les détails.", + "%(featureName)s beta feedback": "Commentaires sur la bêta de %(featureName)s", + "Thank you for your feedback, we really appreciate it.": "Merci pour vos commentaires, nous en sommes vraiment reconnaissants.", + "Beta feedback": "Commentaires sur la bêta", + "If you leave, %(brand)s will reload with Spaces disabled. Communities and custom tags will be visible again.": "Si vous quittez, %(brand)s sera rechargé avec les espaces désactivés. Les communautés et les étiquettes personnalisées seront à nouveau visibles.", + "Spaces are a beta feature.": "Les espaces sont une fonctionnalité en bêta.", + "Search names and descriptions": "Rechercher par nom et description", + "You may contact me if you have any follow up questions": "Vous pouvez me contacter si vous avez des questions par la suite", + "To leave the beta, visit your settings.": "Pour quitter la bêta, consultez les paramètres.", + "Your platform and username will be noted to help us use your feedback as much as we can.": "Votre plateforme et nom d’utilisateur seront consignés pour nous aider à tirer le maximum de vos retours.", + "Add reaction": "Ajouter une réaction", + "Send and receive voice messages": "Envoyer et recevoir des messages vocaux" } diff --git a/src/i18n/strings/gl.json b/src/i18n/strings/gl.json index f581013250..019929b081 100644 --- a/src/i18n/strings/gl.json +++ b/src/i18n/strings/gl.json @@ -2956,7 +2956,7 @@ "You can use the custom server options to sign into other Matrix servers by specifying a different homeserver URL. This allows you to use Element with an existing Matrix account on a different homeserver.": "Podes usar as opcións do servidor para poder conectarte a outros servidores Matrix indicando o URL dese servidor. Esto permíteche usar Element cunha conta Matrix existente noutro servidor.", "Server Options": "Opcións do servidor", "Reason (optional)": "Razón (optativa)", - "We call the places where you can host your account ‘homeservers’.": "Ós lugares onde podes ter unha conta chamámoslle 'servidores de inicio'.", + "We call the places where you can host your account ‘homeservers’.": "Chamámoslle 'servidores de inicio' aos lugares onde podes ter unha conta.", "Invalid URL": "URL non válido", "Unable to validate homeserver": "Non se puido validar o servidor de inicio", "sends confetti": "envía confetti", @@ -3202,5 +3202,170 @@ "This homeserver has been blocked by it's administrator.": "Este servidor de inicio foi bloqueado pola súa administración.", "This homeserver has been blocked by its administrator.": "O servidor de inicio foi bloqueado pola súa administración.", "You're already in a call with this person.": "Xa estás nunha conversa con esta persoa.", - "Already in call": "Xa estás nunha chamada" + "Already in call": "Xa estás nunha chamada", + "Verify this login to access your encrypted messages and prove to others that this login is really you.": "Verifica esta conexión para acceder ás túas mensaxes cifradas e demostrarlle a outras persoas que es ti realmente.", + "Verify with another session": "Verificar con outra sesión", + "We'll create rooms for each of them. You can add more later too, including already existing ones.": "Crearemos salas para cada un. Podes engadir outras máis tarde, incluíndo as xa existentes.", + "Let's create a room for each of them. You can add more later too, including already existing ones.": "Crea unha sala para cada un. Podes engadir outras máis tarde, incluíndo as xa existentes.", + "Make sure the right people have access. You can invite more later.": "Asegúrate de que as persoas axeitadas teñen acceso. Podes convidar a outras máis tarde.", + "A private space to organise your rooms": "Un espazo privado para organizar as túas salas", + "Just me": "Só eu", + "Make sure the right people have access to %(name)s": "Asegúrate de que as persoas axeitadas teñen acceso a %(name)s", + "Go to my first room": "Ir á miña primeira sala", + "It's just you at the moment, it will be even better with others.": "Por agora só estás ti, será incluso mellor con outras persoas.", + "Share %(name)s": "Compartir %(name)s", + "Private space": "Espazo privado", + "Public space": "Espazo público", + " invites you": " convídate", + "Search names and description": "Busca por nomes e descrición", + "You may want to try a different search or check for typos.": "Podes intentar unha busca diferente ou comprobar o escrito.", + "No results found": "Sen resultados", + "Mark as suggested": "Marcar como suxerida", + "Mark as not suggested": "Marcar como non suxerida", + "Removing...": "Eliminando...", + "Failed to remove some rooms. Try again later": "Fallou a eliminación de algunhas salas. Inténtao máis tarde", + "%(count)s rooms and 1 space|one": "%(count)s sala e 1 espazo", + "%(count)s rooms and 1 space|other": "%(count)s salas e 1 espazo", + "%(count)s rooms and %(numSpaces)s spaces|one": "%(count)s sala e %(numSpaces)s espazos", + "%(count)s rooms and %(numSpaces)s spaces|other": "%(count)s salas e %(numSpaces)s espazos", + "If you can't find the room you're looking for, ask for an invite or create a new room.": "Se non atopas a sala que buscas, pide un convite ou crea unha nova sala.", + "Suggested": "Recomendada", + "This room is suggested as a good one to join": "Esta sala é recomendada como apropiada para unirse", + "%(count)s rooms|one": "%(count)s sala", + "%(count)s rooms|other": "%(count)s salas", + "You don't have permission": "Non tes permiso", + "Open": "Abrir", + "%(count)s messages deleted.|one": "%(count)s mensaxe eliminada.", + "%(count)s messages deleted.|other": "%(count)s mensaxes eliminadas.", + "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.": "Normalmente esto só afecta a como se xestiona a sala no servidor. Se tes problemas co teu %(brand)s, informa do fallo.", + "Invite to %(roomName)s": "Convidar a %(roomName)s", + "Edit devices": "Editar dispositivos", + "Invite People": "Convida a persoas", + "Invite with email or username": "Convida con email ou nome de usuaria", + "You can change these anytime.": "Poderás cambialo en calquera momento.", + "Spaces are new ways to group rooms and people. To join an existing space you'll need an invite.": "Espazos é un novo xeito de agrupar salas e persoas. Para unirte a un espazo existente precisarás un convite.", + "Add some details to help people recognise it.": "Engade algún detalle para que sexa recoñecible.", + "From %(deviceName)s (%(deviceId)s) at %(ip)s": "Desde %(deviceName)s%(deviceId)s en %(ip)s", + "Check your devices": "Comproba os teus dispositivos", + "A new login is accessing your account: %(name)s (%(deviceID)s) at %(ip)s": "Hai unha nova conexión á túa conta: %(name)s %(deviceID)s desde %(ip)s", + "You have unverified logins": "Tes conexións sen verificar", + "Sends the given message as a spoiler": "Envía a mensaxe dada como un spoiler", + "Review to ensure your account is safe": "Revisa para asegurarte de que a túa conta está protexida", + "Share decryption keys for room history when inviting users": "Comparte chaves de descifrado para o historial da sala ao convidar usuarias", + "Warn before quitting": "Aviso antes de saír", + "Invite to just this room": "Convida só a esta sala", + "Stop & send recording": "Deter e enviar e a gravación", + "We couldn't create your DM.": "Non puidemos crear o teu MD.", + "Invited people will be able to read old messages.": "As persoas convidadas poderán ler as mensaxes antigas.", + "Reset event store?": "Restablecer almacenaxe do evento?", + "You most likely do not want to reset your event index store": "Probablemente non queiras restablecer o índice de almacenaxe do evento", + "If you do, please note that none of your messages will be deleted, but the search experience might be degraded for a few momentswhilst the index is recreated": "Se o fas, ten en conta que ningunha das mensaxes será eliminada, pero a experiencia de busca podería degradarse durante o tempo en que o índice volve a crearse", + "Avatar": "Avatar", + "Please choose a strong password": "Escolle un contrasinal forte", + "Verify your identity to access encrypted messages and prove your identity to others.": "Verifica a túa identidade para acceder a mensaxes cifradas e acreditar a túa identidade ante outras.", + "Without verifying, you won’t have access to all your messages and may appear as untrusted to others.": "Sen verificación, non terás acceso a tódalas túas mensaxes e poderías aparecer antes outras como non confiable.", + "%(deviceId)s from %(ip)s": "%(deviceId)s desde %(ip)s", + "Send and receive voice messages (in development)": "Enviar e recibir mensaxes de voz (en desenvolvemento)", + "unknown person": "persoa descoñecida", + "Consulting with %(transferTarget)s. Transfer to %(transferee)s": "Consultando con %(transferTarget)s. Transferir a %(transferee)s", + "Manage & explore rooms": "Xestionar e explorar salas", + "Message search initilisation failed": "Fallo a inicialización da busca de mensaxes", + "Quick actions": "Accións rápidas", + "Invite messages are hidden by default. Click to show the message.": "As mensaxes de convite están agochadas por defecto. Preme para amosar a mensaxe.", + "Record a voice message": "Gravar mensaxe de voz", + "Accept on your other login…": "Acepta na túa outra sesión…", + "%(count)s people you know have already joined|other": "%(count)s persoas que coñeces xa se uniron", + "%(count)s people you know have already joined|one": "%(count)s persoa que coñeces xa se uniu", + "Add existing rooms": "Engadir salas existentes", + "Adding...": "Engadindo...", + "Consult first": "Preguntar primeiro", + "Reset event store": "Restablecer almacenaxe de eventos", + "Verify other login": "Verificar outra conexión", + "Verification requested": "Verificación solicitada", + "What are some things you want to discuss in %(spaceName)s?": "Sobre que temas queres conversar en %(spaceName)s?", + "Let's create a room for each of them.": "Crea unha sala para cada un deles.", + "You can add more later too, including already existing ones.": "Podes engadir máis posteriormente, incluíndo os xa existentes.", + "Use another login": "Usar outra conexión", + "You are the only person here. If you leave, no one will be able to join in the future, including you.": "Es a única persoa aquí. Se saes, ninguén poderá unirse no futuro, incluíndote a ti.", + "If you reset everything, you will restart with no trusted sessions, no trusted users, and might not be able to see past messages.": "Se restableces todo, volverás a comezar sen sesións verificadas, usuarias de confianza, e poderías non poder ver as mensaxes anteriores.", + "Only do this if you have no other device to complete verification with.": "Fai isto únicamente se non tes outro dispositivo co que completar a verificación.", + "Reset everything": "Restablecer todo", + "Forgotten or lost all recovery methods? Reset all": "Perdidos ou esquecidos tódolos métodos de recuperación? Restabléceos", + "If you do, please note that none of your messages will be deleted, but the search experience might be degraded for a few moments whilst the index is recreated": "Se o fas, ten en conta que non se borrará ningunha das túas mensaxes, mais a experiencia de busca podería degradarse durante uns momentos ata que se recrea o índice", + "View message": "Ver mensaxe", + "Zoom in": "Achegar", + "Zoom out": "Alonxar", + "%(seconds)ss left": "%(seconds)ss restantes", + "Change server ACLs": "Cambiar ACLs do servidor", + "Show options to enable 'Do not disturb' mode": "Mostrar opcións para activar o modo 'Non molestar'", + "You can select all or individual messages to retry or delete": "Podes elexir todo ou mensaxes individuais para reintentar ou eliminar", + "Sending": "Enviando", + "Retry all": "Reintentar todo", + "Delete all": "Eliminar todo", + "Some of your messages have not been sent": "Algunha das túas mensaxes non se enviou", + "%(count)s members including %(commaSeparatedMembers)s|one": "%(commaSeparatedMembers)s", + "%(count)s members including %(commaSeparatedMembers)s|other": "%(count)s membros, incluíndo a %(commaSeparatedMembers)s", + "Including %(commaSeparatedMembers)s": "Incluíndo a %(commaSeparatedMembers)s", + "View all %(count)s members|one": "Ver 1 membro", + "View all %(count)s members|other": "Ver tódolos %(count)s membros", + "Failed to send": "Fallou o envío", + "Enter your Security Phrase a second time to confirm it.": "Escribe a túa Frase de Seguridade por segunda vez para confirmala.", + "Pick rooms or conversations to add. This is just a space for you, no one will be informed. You can add more later.": "Elixe salas ou conversas para engadilas. Este é un espazo para ti, ninguén será notificado. Podes engadir máis posteriormente.", + "What do you want to organise?": "Que queres organizar?", + "Filter all spaces": "Filtrar os espazos", + "Delete recording": "Eliminar a gravación", + "Stop the recording": "Deter a gravación", + "%(count)s results in all spaces|one": "%(count)s resultado en tódolos espazos", + "%(count)s results in all spaces|other": "%(count)s resultados en tódolos espazos", + "You have no ignored users.": "Non tes usuarias ignoradas.", + "Play": "Reproducir", + "Pause": "Deter", + "This is an experimental feature. For now, new users receiving an invite will have to open the invite on to actually join.": "Esta é unha característica experimental. Por agora as novas usuarias convidadas deberán abrir o convite en para poder unirse.", + "To join %(spaceName)s, turn on the Spaces beta": "Para unirte a %(spaceName)s, activa a beta de Espazos", + "To view %(spaceName)s, turn on the Spaces beta": "Para ver %(spaceName)s, cambia á beta de Espazos", + "Select a room below first": "Primeiro elixe embaixo unha sala", + "Communities are changing to Spaces": "Comunidades cambia a Espazos", + "Join the beta": "Unirse á beta", + "Leave the beta": "Saír da beta", + "Beta": "Beta", + "Tap for more info": "Toca para ter máis información", + "Spaces is a beta feature": "Espazos é unha característica en beta", + "Want to add a new room instead?": "Queres engadir unha nova sala?", + "Adding rooms... (%(progress)s out of %(count)s)|one": "Engadindo sala...", + "Adding rooms... (%(progress)s out of %(count)s)|other": "Engadindo salas... (%(progress)s de %(count)s)", + "Not all selected were added": "Non se engadiron tódolos seleccionados", + "You can add existing spaces to a space.": "Podes engadir espazos existentes a un espazo.", + "Feeling experimental?": "Sínteste aventureira?", + "You are not allowed to view this server's rooms list": "Non tes permiso para ver a lista de salas deste servidor", + "Error processing voice message": "Erro ao procesar a mensaxe de voz", + "We didn't find a microphone on your device. Please check your settings and try again.": "Non atopamos ningún micrófono no teu dispositivo. Comproba os axustes e proba outra vez.", + "No microphone found": "Non atopamos ningún micrófono", + "We were unable to access your microphone. Please check your browser settings and try again.": "Non puidemos acceder ao teu micrófono. Comproba os axustes do navegador e proba outra vez.", + "Unable to access your microphone": "Non se puido acceder ao micrófono", + "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.": "Gañas de experimentar? Labs é o mellor xeito para un acceso temperá e probar novas funcións e axudar a melloralas antes de ser publicadas. Coñece máis.", + "Your access token gives full access to your account. Do not share it with anyone.": "O teu token de acceso da acceso completo á túa conta. Non o compartas con ninguén.", + "Access Token": "Token de acceso", + "Spaces are a new way to group rooms and people. To join an existing space you'll need an invite.": "Espazos é un novo xeito de agrupar salas e persoas. Precisas un convite para unirte a un espazo existente.", + "Please enter a name for the space": "Escribe un nome para o espazo", + "Connecting": "Conectando", + "Allow Peer-to-Peer for 1:1 calls (if you enable this, the other party might be able to see your IP address)": "Permitir Peer-to-Peer en chamadas 1:1 (se activas isto a outra parte podería coñecer o teu enderezo IP)", + "Beta available for web, desktop and Android. Some features may be unavailable on your homeserver.": "Beta dispoñible para web, escritorio e Android. Algunhas características poderían non estar dispoñibles no teu servidor de inicio.", + "You can leave the beta any time from settings or tapping on a beta badge, like the one above.": "Podes saír da beta desde os axustes cando queiras ou tocando na insignia beta, como a superior.", + "%(brand)s will reload with Spaces enabled. Communities and custom tags will be hidden.": "%(brand)s cargará con Espazos activado. Comunidades e etiquetas personais estarán agochadas.", + "Beta available for web, desktop and Android. Thank you for trying the beta.": "Beta dispoñible para web, escritorio e Android. Grazas por probar a beta.", + "%(brand)s will reload with Spaces disabled. Communities and custom tags will be visible again.": "%(brand)s volverá a cargar con Espazos desactivado. Comunidade e etiquetas personalizadas estarán visibles de volta.", + "Spaces are a new way to group rooms and people.": "Espazos é un novo xeito de agrupar salas e persoas.", + "Spaces are a beta feature.": "Espazos é unha ferramenta en beta.", + "Search names and descriptions": "Buscar nome e descricións", + "You may contact me if you have any follow up questions": "Podes contactar conmigo se tes algunha outra suxestión", + "To leave the beta, visit your settings.": "Para saír da beta, vai aos axustes.", + "Your platform and username will be noted to help us use your feedback as much as we can.": "A túa plataforma e nome de usuaria serán notificados para axudarnos a utilizar a túa opinión do mellor xeito posible.", + "%(featureName)s beta feedback": "Opinión acerca de %(featureName)s beta", + "Thank you for your feedback, we really appreciate it.": "Grazas pola túa opinión, realmente apreciámola.", + "Beta feedback": "Opinión sobre a beta", + "Add reaction": "Engadir reacción", + "Send and receive voice messages": "Enviar e recibir mensaxes de voz", + "Your feedback will help make spaces better. The more detail you can go into, the better.": "A túa opinión axudaranos a mellorar os espazos. Canto máis detallada sexa moito mellor para nós.", + "If you leave, %(brand)s will reload with Spaces disabled. Communities and custom tags will be visible again.": "Se saes, %(brand)s volverá a cargar con Espazos desactivados. Comunidades e etiquetas personais serán visibles outra vez.", + "Message search initialisation failed": "Fallou a inicialización da busca de mensaxes" } diff --git a/src/i18n/strings/he.json b/src/i18n/strings/he.json index dda9902e72..5baa1d7c67 100644 --- a/src/i18n/strings/he.json +++ b/src/i18n/strings/he.json @@ -52,7 +52,7 @@ "Operation failed": "פעולה נכשלה", "Search": "חפש", "Custom Server Options": "הגדרות שרת מותאמות אישית", - "Dismiss": "שחרר", + "Dismiss": "התעלם", "powered by Matrix": "מופעל ע\"י Matrix", "Error": "שגיאה", "Remove": "הסר", diff --git a/src/i18n/strings/hi.json b/src/i18n/strings/hi.json index 75b14cca18..f71c024342 100644 --- a/src/i18n/strings/hi.json +++ b/src/i18n/strings/hi.json @@ -585,5 +585,8 @@ "You cannot modify widgets in this room.": "आप इस रूम में विजेट्स को संशोधित नहीं कर सकते।", "%(senderName)s revoked the invitation for %(targetDisplayName)s to join the room.": "%(senderName)s ने कमरे में शामिल होने के लिए %(targetDisplayName)s के निमंत्रण को रद्द कर दिया।", "User %(userId)s is already in the room": "उपयोगकर्ता %(userId)s पहले से ही रूम में है", - "The user must be unbanned before they can be invited.": "उपयोगकर्ता को आमंत्रित करने से पहले उन्हें प्रतिबंधित किया जाना चाहिए।" + "The user must be unbanned before they can be invited.": "उपयोगकर्ता को आमंत्रित करने से पहले उन्हें प्रतिबंधित किया जाना चाहिए।", + "Explore rooms": "रूम का अन्वेषण करें", + "Sign In": "साइन करना", + "Create Account": "खाता बनाएं" } diff --git a/src/i18n/strings/hr.json b/src/i18n/strings/hr.json index 527b86e0a7..2511771578 100644 --- a/src/i18n/strings/hr.json +++ b/src/i18n/strings/hr.json @@ -4,5 +4,6 @@ "Failed to verify email address: make sure you clicked the link in the email": "Nismo u mogućnosti verificirati Vašu email adresu. Provjerite dali ste kliknuli link u mailu", "The platform you're on": "Platforma na kojoj se nalazite", "The version of %(brand)s": "Verzija %(brand)s", - "Your language of choice": "Izabrani jezik" + "Your language of choice": "Izabrani jezik", + "Dismiss": "Odbaci" } diff --git a/src/i18n/strings/hu.json b/src/i18n/strings/hu.json index ccc871d097..bc38d20716 100644 --- a/src/i18n/strings/hu.json +++ b/src/i18n/strings/hu.json @@ -3197,5 +3197,167 @@ "This homeserver has been blocked by it's administrator.": "Ezt a matrix szervert az adminisztrátor lezárta.", "This homeserver has been blocked by its administrator.": "Ezt a matrix szervert az adminisztrátor lezárta.", "You're already in a call with this person.": "Már hívásban van ezzel a személlyel.", - "Already in call": "A hívás már folyamatban van" + "Already in call": "A hívás már folyamatban van", + "Verify this login to access your encrypted messages and prove to others that this login is really you.": "Ellenőrizze ezt a bejelentkezést, hogy hozzáférjen a titkosított üzeneteihez, valamint be tudja bizonyítani másoknak, hogy ez bejelentkezés önhöz tartozik.", + "Verify with another session": "Ellenőrizze egy másik munkamenettel", + "We'll create rooms for each of them. You can add more later too, including already existing ones.": "Készítünk mindegyik szobához egyet. Később is hozzáadhat újakat vagy akár meglévőket.", + "Let's create a room for each of them. You can add more later too, including already existing ones.": "Készítsünk mindegyik szobához egyet. Később is hozzáadhat újakat vagy akár meglévőket.", + "Make sure the right people have access. You can invite more later.": "Ellenőrizze, hogy a megfelelő személyeknek van hozzáférése. Később meghívhat másokat is.", + "A private space to organise your rooms": "Privát tér a szobái csoportosításához", + "Just me": "Csak én", + "Make sure the right people have access to %(name)s": "Ellenőrizze, hogy a megfelelő személyeknek hozzáférése van ehhez: %(name)s", + "Go to my first room": "Ugrás az első szobámra", + "It's just you at the moment, it will be even better with others.": "Egyenlőre csak ön, még jobb lehet másokkal együtt.", + "Share %(name)s": "Megosztás: %(name)s", + "Private space": "Privát tér", + "Public space": "Nyilvános tér", + " invites you": " meghívta", + "Search names and description": "Nevek és leírás keresése", + "You may want to try a different search or check for typos.": "Esetleg próbáljon ki egy másik keresést vagy nézze át elgépelések után.", + "No results found": "Nincs találat", + "Mark as suggested": "Javasoltnak jelölés", + "Mark as not suggested": "Nem javasoltnak jelölés", + "Removing...": "Törlés...", + "Failed to remove some rooms. Try again later": "Néhány szoba törlése sikertelen. Próbálja később", + "%(count)s rooms and 1 space|one": "%(count)s szoba és 1 tér", + "%(count)s rooms and 1 space|other": "%(count)s szoba és 1 tér", + "%(count)s rooms and %(numSpaces)s spaces|one": "%(count)s szoba és %(numSpaces)s tér", + "%(count)s rooms and %(numSpaces)s spaces|other": "%(count)s szoba és %(numSpaces)s tér", + "If you can't find the room you're looking for, ask for an invite or create a new room.": "Ha nem található a szoba amit keresett kérjen egy meghívót vagy Készítsen egy új szobát.", + "Suggested": "Javaslat", + "This room is suggested as a good one to join": "Ez egy javasolt szoba csatlakozáshoz", + "%(count)s rooms|one": "%(count)s szoba", + "%(count)s rooms|other": "%(count)s szoba", + "You don't have permission": "Nincs jogosultsága", + "Open": "Megnyitás", + "%(count)s messages deleted.|one": "%(count)s üzenet törölve.", + "%(count)s messages deleted.|other": "%(count)s üzenet törölve.", + "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.": "Ez általában a szoba szerver oldali kezelésében jelent változást. Ha probléma van itt: %(brand)s, kérjük küldjön hibajelentést.", + "Invite to %(roomName)s": "Meghívás ide: %(roomName)s", + "Edit devices": "Eszközök szerkesztése", + "Invite People": "Személyek meghívása", + "Invite with email or username": "Meghívás e-mail címmel vagy felhasználói névvel", + "You can change these anytime.": "Bármikor megváltoztatható.", + "Add some details to help people recognise it.": "Információ hozzáadása, hogy könnyebben felismerhető legyen.", + "Spaces are new ways to group rooms and people. To join an existing space you'll need an invite.": "A terek egy új lehetőség a szobák és emberek csoportosításához. Létező térhez meghívóval lehet csatlakozni.", + "From %(deviceName)s (%(deviceId)s) at %(ip)s": "Innen: %(deviceName)s (%(deviceId)s), %(ip)s", + "Check your devices": "Ellenőrizze az eszközeit", + "A new login is accessing your account: %(name)s (%(deviceID)s) at %(ip)s": "Új bejelentkezéssel hozzáférés történik a fiókjához: %(name)s (%(deviceID)s), %(ip)s", + "You have unverified logins": "Ellenőrizetlen bejelentkezései vannak", + "Without verifying, you won’t have access to all your messages and may appear as untrusted to others.": "Az ellenőrzés nélkül nem fér hozzá az összes üzenetéhez és mások számára megbízhatatlannak fog látszani.", + "Verify your identity to access encrypted messages and prove your identity to others.": "Ellenőrizze a személyazonosságát, hogy hozzáférjen a titkosított üzeneteihez és másoknak is bizonyítani tudja személyazonosságát.", + "Use another login": "Másik munkamenet használata", + "Please choose a strong password": "Kérem válasszon erős jelszót", + "You can add more later too, including already existing ones.": "Később is hozzáadhat többet, beleértve meglévőket is.", + "Let's create a room for each of them.": "Készítsünk szobát mindhez.", + "What are some things you want to discuss in %(spaceName)s?": "Mik azok amikről beszélni szeretne itt: %(spaceName)s?", + "Verification requested": "Hitelesítés kérés elküldve", + "Avatar": "Profilkép", + "Verify other login": "Másik munkamenet ellenőrzése", + "Reset event store": "Az esemény tárolót alaphelyzetbe állítása", + "If you do, please note that none of your messages will be deleted, but the search experience might be degraded for a few momentswhilst the index is recreated": "Ha ezt teszi, tudnia kell, hogy az üzenetek nem kerülnek törlésre de keresés nem lesz tökéletes amíg az indexek nem készülnek el újra", + "You most likely do not want to reset your event index store": "Az esemény index tárolót nagy valószínűséggel nem szeretné alaphelyzetbe állítani", + "Reset event store?": "Az esemény tárolót alaphelyzetbe állítja?", + "Consult first": "Kérjen először véleményt", + "Invited people will be able to read old messages.": "A meghívott személyek el tudják olvasni a régi üzeneteket.", + "We couldn't create your DM.": "Nem tudjuk elkészíteni a közvetlen üzenetét.", + "Adding...": "Hozzáadás…", + "Add existing rooms": "Létező szobák hozzáadása", + "%(count)s people you know have already joined|one": "%(count)s ismerős már csatlakozott", + "%(count)s people you know have already joined|other": "%(count)s ismerős már csatlakozott", + "Accept on your other login…": "Egy másik bejelentkezésében fogadta el…", + "Stop & send recording": "Megállít és a felvétel elküldése", + "Record a voice message": "Hang üzenet felvétele", + "Invite messages are hidden by default. Click to show the message.": "A meghívók alapesetben rejtve vannak. A megjelenítéshez kattintson.", + "Quick actions": "Gyors műveletek", + "Invite to just this room": "Meghívás csak ebbe a szobába", + "Warn before quitting": "Kilépés előtt figyelmeztet", + "Message search initilisation failed": "Üzenet keresés beállítása sikertelen", + "Manage & explore rooms": "Szobák kezelése és felderítése", + "Consulting with %(transferTarget)s. Transfer to %(transferee)s": "Egyeztetés vele: %(transferTarget)s. Átadás ide: %(transferee)s", + "unknown person": "ismeretlen személy", + "Share decryption keys for room history when inviting users": "Visszafejtéshez szükséges kulcsok megosztása a szoba előzményekhez felhasználók meghívásakor", + "Send and receive voice messages (in development)": "Hang üzenetek küldése és fogadása (fejlesztés alatt)", + "%(deviceId)s from %(ip)s": "%(deviceId)s innen: %(ip)s", + "Review to ensure your account is safe": "Tekintse át, hogy meggyőződjön arról, hogy a fiókja biztonságban van", + "Sends the given message as a spoiler": "A megadott üzenet szpojlerként küldése", + "Change server ACLs": "Kiszolgáló ACL-ek módosítása", + "You are the only person here. If you leave, no one will be able to join in the future, including you.": "Csak ön van itt. Ha kilép, akkor a jövőben senki nem tud majd ide belépni, beleértve önt is.", + "If you reset everything, you will restart with no trusted sessions, no trusted users, and might not be able to see past messages.": "Ha mindent alapállapotba helyez, nem lesz megbízható munkamenete, nem lesznek megbízható felhasználók és a régi üzenetekhez sem biztos, hogy hozzáfér majd.", + "Only do this if you have no other device to complete verification with.": "Csak akkor tegye meg, ha nincs egyetlen másik eszköze sem az ellenőrzés elvégzéséhez.", + "Reset everything": "Minden visszaállítása", + "Forgotten or lost all recovery methods? Reset all": "Elfelejtette vagy elveszett minden visszaállítási lehetőség? Mind alaphelyzetbe állítása", + "If you do, please note that none of your messages will be deleted, but the search experience might be degraded for a few moments whilst the index is recreated": "Ha ezt teszi, tudnia kell, hogy az üzenetek nem kerülnek törlésre de keresés nem lesz tökéletes amíg az indexek nem készülnek el újra", + "View message": "Üzenet megjelenítése", + "Zoom in": "Nagyít", + "Zoom out": "Kicsinyít", + "%(seconds)ss left": "%(seconds)s mp van vissza", + "Show options to enable 'Do not disturb' mode": "Mutassa a lehetőséget a „Ne zavarjanak” módhoz", + "You can select all or individual messages to retry or delete": "Újraküldéshez vagy törléshez kiválaszthatja az üzeneteket egyenként vagy az összeset együtt", + "Retry all": "Mind újraküldése", + "Sending": "Küldés", + "Delete all": "Mind törlése", + "Some of your messages have not been sent": "Néhány üzenete nem lett elküldve", + "%(count)s members including %(commaSeparatedMembers)s|one": "%(commaSeparatedMembers)s", + "%(count)s members including %(commaSeparatedMembers)s|other": "%(count)s résztvevő beleértve: %(commaSeparatedMembers)s", + "Including %(commaSeparatedMembers)s": "Beleértve: %(commaSeparatedMembers)s", + "View all %(count)s members|one": "1 résztvevő megmutatása", + "View all %(count)s members|other": "Az összes %(count)s résztvevő megmutatása", + "Failed to send": "Küldés sikertelen", + "Enter your Security Phrase a second time to confirm it.": "A megerősítéshez adja meg a biztonsági jelmondatot még egyszer.", + "Pick rooms or conversations to add. This is just a space for you, no one will be informed. You can add more later.": "Válassz szobákat vagy beszélgetéseket amit hozzáadhat. Ez csak az ön tere, senki nem lesz értesítve. Továbbiakat később is hozzáadhat.", + "What do you want to organise?": "Mit szeretne megszervezni?", + "Filter all spaces": "Minden tér szűrése", + "Delete recording": "Felvétel törlése", + "Stop the recording": "Felvétel megállítása", + "%(count)s results in all spaces|one": "%(count)s találat van az összes térben", + "%(count)s results in all spaces|other": "%(count)s találat a terekben", + "You have no ignored users.": "Nincs figyelmen kívül hagyott felhasználó.", + "Play": "Lejátszás", + "Pause": "Szünet", + "This is an experimental feature. For now, new users receiving an invite will have to open the invite on to actually join.": "Ez egy kísérleti funkció Egyenlőre az a felhasználó aki meghívót kap a meghívóban lévő linkre kattintva tud csatlakozni.", + "To join %(spaceName)s, turn on the Spaces beta": "A csatlakozáshoz ide: %(spaceName)s először kapcsolja be a béta Tereket", + "To view %(spaceName)s, turn on the Spaces beta": "A %(spaceName)s megjelenítéséhez először kapcsolja be a béta Tereket", + "Select a room below first": "Először válasszon ki szobát alulról", + "Communities are changing to Spaces": "A közösségek Terek lesznek", + "Join the beta": "Csatlakozás béta lehetőségekhez", + "Leave the beta": "Béta kikapcsolása", + "Beta": "Béta", + "Tap for more info": "Koppints további információért", + "Spaces is a beta feature": "A terek béta állapotban van", + "Want to add a new room instead?": "Inkább új szobát adna hozzá?", + "Adding rooms... (%(progress)s out of %(count)s)|one": "Szobák hozzáadása…", + "Adding rooms... (%(progress)s out of %(count)s)|other": "Szobák hozzáadása… (%(progress)s ennyiből: %(count)s)", + "Not all selected were added": "Nem az összes kijelölt lett hozzáadva", + "You can add existing spaces to a space.": "Létező tereket adhat a térhez.", + "Feeling experimental?": "Kísérletezni szeretne?", + "You are not allowed to view this server's rooms list": "Nincs joga ennek a szervernek a szobalistáját megnézni", + "Error processing voice message": "Hiba a hangüzenet feldolgozásánál", + "We didn't find a microphone on your device. Please check your settings and try again.": "Nem található mikrofon. Ellenőrizze a beállításokat és próbálja újra.", + "No microphone found": "Nem található mikrofon", + "We were unable to access your microphone. Please check your browser settings and try again.": "Nem lehet a mikrofont használni. Ellenőrizze a böngésző beállításait és próbálja újra.", + "Unable to access your microphone": "A mikrofont nem lehet használni", + "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.": "Kedve van kísérletezni? Labs az a hely ahol először hozzá lehet jutni az új dolgokhoz, kipróbálni új lehetőségeket és segíteni a fejlődésüket mielőtt mindenkihez eljut. Tudj meg többet.", + "Your access token gives full access to your account. Do not share it with anyone.": "A hozzáférési kulcs teljes elérést biztosít a fiókhoz. Soha ne ossza meg mással.", + "Access Token": "Elérési kulcs", + "Spaces are a new way to group rooms and people. To join an existing space you'll need an invite.": "A terek egy új lehetőség a szobák és emberek csoportosításához. Létező térhez meghívóval lehet csatlakozni.", + "Please enter a name for the space": "Kérem adjon meg egy nevet a térhez", + "Connecting": "Kapcsolás", + "Allow Peer-to-Peer for 1:1 calls (if you enable this, the other party might be able to see your IP address)": "Közvetlen hívás engedélyezése két fél között (ha ezt engedélyezi a másik fél láthatja az ön IP címét)", + "Beta available for web, desktop and Android. Some features may be unavailable on your homeserver.": "Béta verzió elérhető webre, asztali kliensre és Androidra. Bizonyos funkciók lehet, hogy nem elérhetők a matrix szerverén.", + "You can leave the beta any time from settings or tapping on a beta badge, like the one above.": "Bármikor elhagyhatja a béta változatot a beállításokban vagy a béta kitűzőre koppintva, mint alább.", + "%(brand)s will reload with Spaces enabled. Communities and custom tags will be hidden.": "%(brand)s a Terekkel lesz újra betöltve. A közösségek és egyedi címkék rejtve maradnak.", + "Beta available for web, desktop and Android. Thank you for trying the beta.": "Béta verzió elérhető webre, asztali kliensre és Androidra. Köszönjük, hogy kipróbálja.", + "Spaces are a new way to group rooms and people.": "Szobák és emberek csoportosításának új lehetősége a Terek használata.", + "%(brand)s will reload with Spaces disabled. Communities and custom tags will be visible again.": "%(brand)s a Terek nélkül lesz újra betöltve. A közösségek és egyedi címkék újra megjelennek.", + "To leave the beta, visit your settings.": "A beállításokban tudja elhagyni a bétát.", + "Your platform and username will be noted to help us use your feedback as much as we can.": "A platform és a felhasználói neve felhasználásra kerül ami segít nekünk a visszajelzést minél jobban felhasználni.", + "%(featureName)s beta feedback": "%(featureName)s béta visszajelzés", + "Thank you for your feedback, we really appreciate it.": "Köszönjük a visszajelzését, ezt nagyra értékeljük.", + "Beta feedback": "Béta visszajelzés", + "Add reaction": "Reakció hozzáadása", + "Send and receive voice messages": "Hangüzenet küldése, fogadása", + "Your feedback will help make spaces better. The more detail you can go into, the better.": "A visszajelzése segítség a terek javításához. Minél részletesebb annál jobb.", + "If you leave, %(brand)s will reload with Spaces disabled. Communities and custom tags will be visible again.": "Távozás után %(brand)s Terek nélkül lesz újra betöltve. A közösségek és egyedi címkék újra megjelennek.", + "Message search initialisation failed": "Üzenet keresés beállítása sikertelen" } diff --git a/src/i18n/strings/is.json b/src/i18n/strings/is.json index ddb3bbc66d..03fa871f29 100644 --- a/src/i18n/strings/is.json +++ b/src/i18n/strings/is.json @@ -136,7 +136,7 @@ "Attachment": "Viðhengi", "Hangup": "Leggja á", "Voice call": "Raddsamtal", - "Video call": "_Myndsímtal", + "Video call": "Myndsímtal", "Upload file": "Hlaða inn skrá", "Send an encrypted message…": "Senda dulrituð skilaboð…", "You do not have permission to post to this room": "Þú hefur ekki heimild til að senda skilaboð á þessa spjallrás", @@ -198,7 +198,7 @@ "Today": "Í dag", "Yesterday": "Í gær", "Error decrypting attachment": "Villa við afkóðun viðhengis", - "Copied!": "Afritað", + "Copied!": "Afritað!", "Custom Server Options": "Sérsniðnir valkostir vefþjóns", "Dismiss": "Hunsa", "Please check your email to continue registration.": "Skoðaðu tölvupóstinn þinn til að geta haldið áfram með skráningu.", @@ -460,5 +460,261 @@ "Create Account": "Stofna Reikning", "Please install Chrome, Firefox, or Safari for the best experience.": "vinsamlegast setja upp Chrome, Firefox, eða Safari fyrir besta reynsluna.", "Explore rooms": "Kanna herbergi", - "Sign In": "Skrá inn" + "Sign In": "Skrá inn", + "The user's homeserver does not support the version of the room.": "Heimaþjónn notandans styður ekki útgáfu herbergis.", + "The user must be unbanned before they can be invited.": "Notandinn þarf að vera afbannaður áður en að hægt er að bjóða þeim.", + "User %(user_id)s may or may not exist": "Notandi %(user_id)s gæti verið til", + "User %(user_id)s does not exist": "Notandi %(user_id)s er ekki til", + "User %(userId)s is already in the room": "Notandi %(userId)s er nú þegar í herberginu", + "You do not have permission to invite people to this room.": "Þú hefur ekki heimild til að bjóða fólk í þessa spjallrás.", + "Leave Room": "Fara af Spjallrás", + "Add room": "Bæta við herbergi", + "Use a more compact ‘Modern’ layout": "Nota þéttara ‘nútímalegt’ skipulag", + "Switch to dark mode": "Skiptu yfir í dökkstillingu", + "Switch to light mode": "Skiptu yfir í ljósstillingu", + "Modify widgets": "Breyta viðmótshluta", + "Room Info": "Herbergis upplýsingar", + "Room information": "Upplýsingar um herbergi", + "Room options": "Herbergisvalkostir", + "Invite People": "Bjóða Fólki", + "Invite people": "Bjóða fólki", + "%(count)s people|other": "%(count)s manns", + "%(count)s people|one": "%(count)s manneskja", + "People": "Fólk", + "Finland": "Finnland", + "Norway": "Noreg", + "Denmark": "Danmörk", + "Iceland": "Ísland", + "Mentions & Keywords": "Nefnir og stikkorð", + "If you cancel now, you may lose encrypted messages & data if you lose access to your logins.": "Ef þú hættir við núna geturðu tapað dulkóðuðum skilaboðum og gögnum ef þú missir aðgang að innskráningum þínum.", + "Your server admin has disabled end-to-end encryption by default in private rooms & Direct Messages.": "Heimaþjónastjórnandi þinn hefur lokað á sjálfkrafa dulkóðun í einkaherbergjum og beinskilaboðum.", + "You can’t disable this later. Bridges & most bots won’t work yet.": "Þú getur ekki gert þetta óvirkt síðar. Brýr og flest vélmenni virka ekki ennþá.", + "Travel & Places": "Ferðalög og staðir", + "Food & Drink": "Mat og drykkur", + "Animals & Nature": "Dýr og náttúra", + "Smileys & People": "Broskarlar og fólk", + "Voice & Video": "Rödd og myndband", + "Roles & Permissions": "Hlutverk og heimildir", + "Help & About": "Hjálp og um", + "Reject & Ignore user": "Hafna og hunsa notanda", + "Security & privacy": "Öryggi og einkalíf", + "Security & Privacy": "Öryggi & Einkalíf", + "Feedback sent": "Endurgjöf sent", + "Send feedback": "Senda endurgjöf", + "Feedback": "Endurgjöf", + "%(featureName)s beta feedback": "%(featureName)s beta endurgjöf", + "Thank you for your feedback, we really appreciate it.": "Þakka þér fyrir athugasemdir þínar.", + "Beta feedback": "Beta endurgjöf", + "All settings": "Allar stillingar", + "Notification settings": "Tilkynningarstillingar", + "Change notification settings": "Breytta tilkynningastillingum", + "You can't send any messages until you review and agree to our terms and conditions.": "Þú getur ekki sent nein skilaboð fyrr en þú hefur farið yfir og samþykkir skilmála okkar.", + "Send a Direct Message": "Senda beinskilaboð", + "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.": "Tilkynning um þessi skilaboð mun senda einstakt 'atburðarauðkenni' til stjórnanda heimaþjónns. Ef skilaboð í þessu herbergi eru dulkóðuð getur stjórnandi heimaþjónns ekki lesið skilaboðatextann eða skoðað skrár eða myndir.", + "Send a message…": "Senda skilaboð…", + "Send message": "Senda skilaboð", + "Sending your message...": "Er að senda skilaboð þitt...", + "Send as message": "Senda sem skilaboð", + "You can use /help to list available commands. Did you mean to send this as a message?": "Þú getur notað /help til að lista tilteknar skipanir. Ætlaðir þú að senda þetta sem skilaboð?", + "Send messages": "Senda skilaboð", + "Sends the given message with snowfall": "Sendir skilaboðið með snjókomu", + "Sends the given message with fireworks": "Sendir skilaboðið með flugeldum", + "Sends the given message with confetti": "Sendir skilaboðið með skrauti", + "Never send encrypted messages to unverified sessions in this room from this session": "Aldrei senda dulrituð skilaboð af þessu tæki til ósannvottaðra tækja í þessu herbergi", + "Never send encrypted messages to unverified sessions from this session": "Aldrei senda dulrituð skilaboð af þessu tæki til ósannvottaðra tækja", + "Use Ctrl + Enter to send a message": "Notaðu Ctrl + Enter til að senda skilaboð", + "Use Command + Enter to send a message": "Notaðu Command + Enter til að senda skilaboð", + "Jump to the bottom of the timeline when you send a message": "Hoppaðu neðst á tímalínunni þegar þú sendir skilaboð", + "Send and receive voice messages": "Senda og taka á móti talskilaboðum", + "%(senderName)s: %(message)s": "%(senderName)s: %(message)s", + "Send %(msgtype)s messages as you in your active room": "Senda %(msgtype)s skilaboð sem þú í virka herbergi þínu", + "Send %(msgtype)s messages as you in this room": "Senda %(msgtype)s skilaboð sem þú í þessu herbergi", + "Send text messages as you in your active room": "Senda texta skilaboð sem þú í virku herbergi þínu", + "Send text messages as you in this room": "Senda texta skilaboð sem þú í þessu herbergi", + "Send messages as you in your active room": "Senda skilaboð sem þú í virku herbergi þínu", + "Send messages as you in this room": "Senda skilaboð sem þú í þessu herbergi", + "%(senderName)s changed the pinned messages for the room.": "%(senderName)s breytti föstum skilaboðum fyrir herbergið.", + "Sends a message to the given user": "Sendir skilaboð til viðkomandi notanda", + "Sends the given message coloured as a rainbow": "Sendir gefið skilaboð litað sem regnbogi", + "Sends a message as html, without interpreting it as markdown": "Sendir skilaboð sem html, án þess að túlka það sem markdown", + "Sends a message as plain text, without interpreting it as markdown": "Sendir skilaboð sem óbreyttur texti án þess að túlka það sem markdown", + "Sends the given message as a spoiler": "Sendir skilaboðið sem spoiler", + "No need for symbols, digits, or uppercase letters": "Engin þörf á táknum, tölustöfum, eða hástöfum", + "Use a few words, avoid common phrases": "Notaðu nokkur orð. Forðastu algengar setningar", + "Unknown server error": "Óþekkt villa á þjóni", + "Message deleted by %(name)s": "Skilaboð eytt af %(name)s", + "Message deleted": "Skilaboð eytt", + "Room list": "Herbergislisti", + "Subscribed lists": "Skráðir listar", + "eg: @bot:* or example.org": "t.d.: @vélmenni:* eða dæmi.is", + "Personal ban list": "Persónulegur bann listi", + "⚠ These settings are meant for advanced users.": "⚠ Þessar stillingar eru ætlaðar fyrir háþróaða notendur.", + "Ignored users": "Hunsaðir notendur", + "You are currently subscribed to:": "Þú ert skráður til:", + "View rules": "Skoða reglur", + "You are not subscribed to any lists": "Þú ert ekki skráður fyrir neina lista", + "You are currently ignoring:": "Þú ert að hunsa:", + "You have not ignored anyone.": "Þú hefur ekki hunsað nein.", + "User rules": "Reglur notanda", + "Server rules": "Reglur netþjóns", + "Please try again or view your console for hints.": "Vinsamlegast reyndu aftur eða skoðaðu framkvæmdaraðilaatvikuskrá þína fyrir vísbendingar.", + "Error unsubscribing from list": "Galli við að afskrá frá lista", + "Error removing ignored user/server": "Villa við að fjarlægja hunsaða notanda/netþjón", + "Use the Desktop app to search encrypted messages": "Notaðu tölvuforritið til að sía dulkóðuð skilaboð", + "Use the Desktop app to see all encrypted files": "Notaðu tölvuforritið til að sjá öll dulkóðuð gögn", + "Not encrypted": "Ekki dulkóðað", + "Encrypted by a deleted session": "Dulkóðað af eyddu tæki", + "Encrypted by an unverified session": "Dulkóðað af ósannreynu tæki", + "Enable message search in encrypted rooms": "Virka skilaboðleit í dulkóðuð herbergjum", + "This room is end-to-end encrypted": "Þetta herbergi er enda-til-enda dulkóðað", + "Unencrypted": "Ódulkóðað", + "Messages in this room are end-to-end encrypted.": "Skilaboð í þessu herbergi eru enda-til-enda dulkóðuð.", + "Clearing your browser's storage may fix the problem, but will sign you out and cause any encrypted chat history to become unreadable.": "Hreinsun geymslu vafrans gæti lagað vandamálið en mun skrá þig út og valda því að dulkóðaður spjallferil sé ólæsilegur.", + "Send an encrypted reply…": "Senda dulritað svar…", + "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.": "Þegar hún er gerð virk er ekki hægt að óvirka dulkóðun. Skilaboð í dulkóðuðu herbergi geta ekki verið séð af netþjóni en bara af þátttakendum í herberginu. Virkun dulkóðuns gæti komið í veg fyrir að vélmenni og brúr virki rétt. Lærðu meira um dulkóðun.", + "Once enabled, encryption cannot be disabled.": "Þegar kveikt er á dulkóðun er ekki hægt að slökkva á henni.", + "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.": "Í dulkóðuðum herbergjum eins og þetta er slökkt á forskoðun vefslóða sjálfgefið til að tryggja að heimaþjónn þinn (þar sem forsýningin myndast) geti ekki safnað upplýsingum um tengla sem þú sérð í þessu herbergi.", + "URL Previews": "Forskoðun Vefslóða", + "URL previews are disabled by default for participants in this room.": "Forskoðun vefslóða er ekki sjálfgefið fyrir þátttakendur í þessu herbergi.", + "URL previews are enabled by default for participants in this room.": "Forskoðun vefslóða er sjálfgefið fyrir þátttakendur í þessu herbergi.", + "You have disabled URL previews by default.": "Þú hefur óvirkt forskoðun vefslóða sjálfgefið.", + "You have enabled URL previews by default.": "Þú hefur virkt forskoðun vefslóða sjálfgefið.", + "Enable URL previews by default for participants in this room": "Virkja forskoðun vefslóða sjálfgefið fyrir þátttakendur í þessu herbergi", + "Enable URL previews for this room (only affects you)": "Virkja forskoðun vefslóða fyrir þetta herbergi (einungis fyrir þig)", + "Room settings": "Herbergisstillingar", + "Room Settings - %(roomName)s": "Herbergisstillingar - %(roomName)s", + "Pinned Messages": "Föst Skilaboð", + "No pinned messages.": "Engin föst skilaboð.", + "This is the beginning of your direct message history with .": "Þetta er upphaf beinna skilaboðasögu með .", + "Recently Direct Messaged": "Nýlega Fékk Bein Skilaboð", + "Direct Messages": "Bein skilaboð", + "Direct message": "Beint skilaboð", + "Frequently Used": "Oft notað", + "Filter all spaces": "Sía öll rými", + "Filter your rooms and spaces": "Sía rými og herbergin þín", + "Filter rooms and people": "Sía fólk og herbergi", + "Filter": "Sía", + "Your Security Key is in your Downloads folder.": "Öryggislykillinn þinn er Niðurhals möppu þinni.", + "Download logs": "Niðurhal atvikaskrá", + "Preparing to download logs": "Undirbý niðurhal atvikaskráa", + "Downloading logs": "Er að niðurhala atvikaskrá", + "Error downloading theme information.": "Villa við að niðurhala þemaupplýsingum.", + "Message downloading sleep time(ms)": "Skilaboða niðurhal svefn tími(ms)", + "How fast should messages be downloaded.": "Hve hratt ætti að hlaða niður skilaboðum.", + "Download %(text)s": "Niðurhala %(text)s", + "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 sannreyn tæki", + "Remove recent messages": "Fjarlægja nýleg skilaboð", + "Remove recent messages by %(user)s": "Fjarlægja nýleg skilaboð af %(user)s", + "Messages in this room are not end-to-end encrypted.": "Skilaboð í þessu herbergi eru ekki enda-til-enda dulkóðuð.", + "Who would you like to add to this community?": "Hvern viltu bæta við í þetta samfélagi?", + "You cannot place a call with yourself.": "Þú getur ekki byrjað símtal með sjálfum þér.", + "You cannot place VoIP calls in this browser.": "Þú getur ekki byrjað netsímtal (VoIP) köll í þessum vafra.", + "Call Failed": "Símtal Mistókst", + "Every page you use in the app": "Sérhver síða sem þú notar í forritinu", + "Which officially provided instance you are using, if any": "Hvaða opinberlega veittan heimaþjón sem þú notar, ef einhvern", + "Whether or not you're logged in (we don't record your username)": "Hvort sem þú ert skráð(ur) inn (við skráum ekki notendanafnið þitt)", + "Add Phone Number": "Bæta Við Símanúmeri", + "Click the button below to confirm adding this phone number.": "Smelltu á hnappinn hér að neðan til að staðfesta að bæta við þessu símanúmeri.", + "Confirm adding phone number": "Staðfestu að bæta við símanúmeri", + "Add Email Address": "Bæta Við Tölvupóstfangi", + "Click the button below to confirm adding this email address.": "Smelltu á hnappinn hér að neðan til að staðfesta að bæta við þessu netfangi.", + "Confirm adding email": "Staðfestu að bæta við tölvupósti", + "Upgrade": "Uppfæra", + "Verify": "Sannreyna", + "Security": "Öryggi", + "Trusted": "Traustað", + "Subscribe": "Skrá", + "Unsubscribe": "Afskrá", + "None": "Ekkert", + "Ignored/Blocked": "Hunsað/Hindrað", + "Trust": "Treysta", + "Flags": "Fánar", + "Symbols": "Tákn", + "Objects": "Hlutir", + "Activities": "Starfsemi", + "Document": "Skjal", + "Complete": "Búið", + "View": "Skoða", + "Preview": "Forskoðun", + "Strikethrough": "Yfirstrikletrað", + "Italics": "Skáletrað", + "Bold": "Feitletrað", + "ID": "Auðkenni (ID)", + "Disconnect": "Aftengja", + "Share": "Deila", + "Revoke": "Afturkalla", + "Discovery": "Uppgötvun", + "Actions": "Aðgerðir", + "Messages": "Skilaboð", + "Summary": "Yfirlit", + "Service": "Þjónusta", + "Removing…": "Er að fjarlægja…", + "Browse": "Skoða", + "Reset": "Endursetja", + "Sounds": "Hljóð", + "edited": "breytt", + "Re-join": "Taka þátt aftur", + "Banana": "Banani", + "Fire": "Eldur", + "Cloud": "Ský", + "Moon": "Tungl", + "Globe": "Heiminn", + "Mushroom": "Sveppur", + "Cactus": "Kaktus", + "Tree": "Tré", + "Flower": "Blóm", + "Butterfly": "Fiðrildi", + "Octopus": "Kolkrabbi", + "Fish": "Fiskur", + "Turtle": "Skjaldbaka", + "Penguin": "Mörgæs", + "Rooster": "Hani", + "Panda": "Pandabjörn", + "Rabbit": "Kanína", + "Elephant": "Fíll", + "Pig": "Svín", + "Unicorn": "Einhyrningur", + "Horse": "Hestur", + "Lion": "Ljón", + "Cat": "Köttur", + "Dog": "Hundur", + "Guest": "Gestur", + "Other": "Annað", + "Confirm": "Staðfesta", + "Username": "Notandanafn", + "Join": "Taka þátt", + "Encrypted": "Dulkóðað", + "Encryption": "Dulkóðun", + "Timeline": "Tímalína", + "Composer": "Ritari", + "Preferences": "Stillingar", + "Versions": "Útgáfur", + "FAQ": "Algengar spurningar", + "Theme": "Þema", + "General": "Almennt", + "No": "Nei", + "Yes": "Já", + "Verified!": "Sannreynt!", + "Retry": "Reyna aftur", + "Download": "Niðurhal", + "Next": "Næsta", + "Legal": "Löglegt", + "Demote": "Leggja til baka", + "%(oneUser)sleft %(count)s times|one": "%(oneUser)sfór", + "%(severalUsers)sleft %(count)s times|one": "%(severalUsers)sfóru", + "%(oneUser)sjoined %(count)s times|one": "%(oneUser)sskráðist", + "%(severalUsers)sjoined %(count)s times|one": "%(severalUsers)sskráðust", + "Stickerpack": "Límmiða pakki", + "Replying": "Svara", + "%(duration)sd": "%(duration)sd", + "%(duration)sh": "%(duration)sklst", + "%(duration)sm": "%(duration)sm", + "%(duration)ss": "%(duration)ss", + "Emoji picker": "Tjáningartáknmyndvalmynd", + "Show less": "Sýna minna" } diff --git a/src/i18n/strings/it.json b/src/i18n/strings/it.json index 62989bd16e..d109837bb1 100644 --- a/src/i18n/strings/it.json +++ b/src/i18n/strings/it.json @@ -3202,5 +3202,170 @@ "This homeserver has been blocked by it's administrator.": "Questo homeserver è stato bloccato dal suo amministratore.", "This homeserver has been blocked by its administrator.": "Questo homeserver è stato bloccato dal suo amministratore.", "You're already in a call with this person.": "Sei già in una chiamata con questa persona.", - "Already in call": "Già in una chiamata" + "Already in call": "Già in una chiamata", + "Verify this login to access your encrypted messages and prove to others that this login is really you.": "Verifica questa sessione per accedere ai tuoi messaggi cifrati e provare agli altri che questo sei veramente tu.", + "Verify with another session": "Verifica con un'altra sessione", + "We'll create rooms for each of them. You can add more later too, including already existing ones.": "Creeremo stanze per ognuno di essi. Puoi aggiungerne altri dopo, inclusi quelli già esistenti.", + "Let's create a room for each of them. You can add more later too, including already existing ones.": "Inizia a creare una stanza per ognuno di essi. Puoi aggiungerne altri dopo, inclusi quelli già esistenti.", + "Make sure the right people have access. You can invite more later.": "Assicurati che le persone giuste abbiano accesso. Puoi invitarne altre dopo.", + "A private space to organise your rooms": "Uno spazio privato per organizzare le tue stanze", + "Just me": "Solo io", + "Make sure the right people have access to %(name)s": "Assicurati che le persone giuste abbiano accesso a %(name)s", + "Go to my first room": "Vai alla mia prima stanza", + "It's just you at the moment, it will be even better with others.": "Ci sei solo tu al momento, sarà ancora meglio con gli altri.", + "Share %(name)s": "Condividi %(name)s", + "Private space": "Spazio privato", + "Public space": "Spazio pubblico", + " invites you": " ti ha invitato", + "Search names and description": "Cerca nomi e descrizioni", + "You may want to try a different search or check for typos.": "Prova a fare una ricerca diversa o controllare errori di battitura.", + "No results found": "Nessun risultato trovato", + "Mark as suggested": "Segna come consigliato", + "Mark as not suggested": "Segna come non consigliato", + "Removing...": "Rimozione...", + "Failed to remove some rooms. Try again later": "Rimozione di alcune stanze fallita. Riprova più tardi", + "%(count)s rooms and 1 space|one": "%(count)s stanza e 1 spazio", + "%(count)s rooms and 1 space|other": "%(count)s stanze e 1 spazio", + "%(count)s rooms and %(numSpaces)s spaces|one": "%(count)s stanza e %(numSpaces)s spazi", + "%(count)s rooms and %(numSpaces)s spaces|other": "%(count)s stanze e %(numSpaces)s spazi", + "If you can't find the room you're looking for, ask for an invite or create a new room.": "Se non trovi la stanza che stai cercando, chiedi un invito o crea una stanza nuova.", + "Suggested": "Consigliato", + "This room is suggested as a good one to join": "Questa è una buona stanza in cui entrare", + "%(count)s rooms|one": "%(count)s stanza", + "%(count)s rooms|other": "%(count)s stanze", + "You don't have permission": "Non hai il permesso", + "%(count)s messages deleted.|one": "%(count)s messaggio eliminato.", + "%(count)s messages deleted.|other": "%(count)s messaggi eliminati.", + "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.": "Solitamente ciò influisce solo su come la stanza viene elaborata sul server. Se stai riscontrando problemi con il tuo %(brand)s, segnala un errore.", + "Invite to %(roomName)s": "Invita in %(roomName)s", + "Edit devices": "Modifica dispositivi", + "Invite People": "Invita persone", + "Invite with email or username": "Invita con email o nome utente", + "You can change these anytime.": "Puoi cambiarli in qualsiasi momento.", + "Add some details to help people recognise it.": "Aggiungi qualche dettaglio per aiutare le persone a riconoscerlo.", + "Spaces are new ways to group rooms and people. To join an existing space you'll need an invite.": "Gli spazi sono nuovi modi di raggruppare stanze e persone. Per entrare in uno spazio esistente ti serve un invito.", + "From %(deviceName)s (%(deviceId)s) at %(ip)s": "Da %(deviceName)s (%(deviceId)s) al %(ip)s", + "Check your devices": "Controlla i tuoi dispositivi", + "A new login is accessing your account: %(name)s (%(deviceID)s) at %(ip)s": "Una nuova sessione sta accedendo al tuo account: %(name)s (%(deviceID)s) al %(ip)s", + "You have unverified logins": "Hai accessi non verificati", + "Open": "Apri", + "Send and receive voice messages (in development)": "Invia e ricevi messaggi vocali (in sviluppo)", + "unknown person": "persona sconosciuta", + "Sends the given message as a spoiler": "Invia il messaggio come spoiler", + "Review to ensure your account is safe": "Controlla per assicurarti che l'account sia sicuro", + "%(deviceId)s from %(ip)s": "%(deviceId)s da %(ip)s", + "Share decryption keys for room history when inviting users": "Condividi le chiavi di decifrazione della cronologia della stanza quando inviti utenti", + "Consulting with %(transferTarget)s. Transfer to %(transferee)s": "Consultazione con %(transferTarget)s. Trasferisci a %(transferee)s", + "Manage & explore rooms": "Gestisci ed esplora le stanze", + "Invite to just this room": "Invita solo in questa stanza", + "%(count)s people you know have already joined|other": "%(count)s persone che conosci sono già entrate", + "%(count)s people you know have already joined|one": "%(count)s persona che conosci è già entrata", + "Message search initilisation failed": "Inizializzazione ricerca messaggi fallita", + "Add existing rooms": "Aggiungi stanze esistenti", + "Warn before quitting": "Avvisa prima di uscire", + "Invited people will be able to read old messages.": "Le persone invitate potranno leggere i vecchi messaggi.", + "You most likely do not want to reset your event index store": "Probabilmente non hai bisogno di reinizializzare il tuo archivio indice degli eventi", + "If you do, please note that none of your messages will be deleted, but the search experience might be degraded for a few momentswhilst the index is recreated": "Se lo fai, ricorda che nessuno dei tuoi messaggi verrà eliminato, ma l'esperienza di ricerca potrà peggiorare per qualche momento mentre l'indice viene ricreato", + "Avatar": "Avatar", + "Verification requested": "Verifica richiesta", + "What are some things you want to discuss in %(spaceName)s?": "Quali sono le cose di cui vuoi discutere in %(spaceName)s?", + "Please choose a strong password": "Scegli una password robusta", + "Quick actions": "Azioni rapide", + "Invite messages are hidden by default. Click to show the message.": "I messaggi di invito sono nascosti in modo predefinito. Clicca per mostrare il messaggio.", + "Record a voice message": "Registra un messaggio vocale", + "Stop & send recording": "Ferma e invia la registrazione", + "Accept on your other login…": "Accetta nella tua altra sessione…", + "Adding...": "Aggiunta...", + "We couldn't create your DM.": "Non abbiamo potuto creare il tuo messaggio diretto.", + "Consult first": "Prima consulta", + "Reset event store?": "Reinizializzare l'archivio eventi?", + "Reset event store": "Reinizializza archivio eventi", + "Verify other login": "Verifica l'altra sessione", + "Let's create a room for each of them.": "Creiamo una stanza per ognuno di essi.", + "You can add more later too, including already existing ones.": "Puoi aggiungerne anche altri in seguito, inclusi quelli già esistenti.", + "Use another login": "Usa un altro accesso", + "Verify your identity to access encrypted messages and prove your identity to others.": "Verifica la tua identità per accedere ai messaggi cifrati e provare agli altri che sei tu.", + "Without verifying, you won’t have access to all your messages and may appear as untrusted to others.": "Senza la verifica, non avrai accesso a tutti i tuoi messaggi e potresti apparire agli altri come non fidato.", + "You are the only person here. If you leave, no one will be able to join in the future, including you.": "Sei l'unica persona qui. Se esci, nessuno potrà entrare in futuro, incluso te.", + "If you reset everything, you will restart with no trusted sessions, no trusted users, and might not be able to see past messages.": "Se reimposti tutto, ricomincerai senza sessioni fidate, senza utenti fidati e potresti non riuscire a vedere i messaggi passati.", + "Only do this if you have no other device to complete verification with.": "Fallo solo se non hai altri dispositivi con cui completare la verifica.", + "Reset everything": "Reimposta tutto", + "Forgotten or lost all recovery methods? Reset all": "Hai dimenticato o perso tutti i metodi di recupero? Reimposta tutto", + "If you do, please note that none of your messages will be deleted, but the search experience might be degraded for a few moments whilst the index is recreated": "Se lo fai, ricorda che nessuno dei tuoi messaggi verrà eliminato, ma l'esperienza di ricerca potrà peggiorare per qualche momento mentre l'indice viene ricreato", + "View message": "Vedi messaggio", + "Zoom in": "Ingrandisci", + "Zoom out": "Rimpicciolisci", + "%(seconds)ss left": "%(seconds)ss rimanenti", + "Change server ACLs": "Modifica le ACL del server", + "Show options to enable 'Do not disturb' mode": "Mostra opzioni per attivare la modalità \"Non disturbare\"", + "You can select all or individual messages to retry or delete": "Puoi selezionare tutti o alcuni messaggi da riprovare o eliminare", + "Sending": "Invio in corso", + "Retry all": "Riprova tutti", + "Delete all": "Elimina tutti", + "Some of your messages have not been sent": "Alcuni tuoi messaggi non sono stati inviati", + "%(count)s members including %(commaSeparatedMembers)s|one": "%(commaSeparatedMembers)s", + "%(count)s members including %(commaSeparatedMembers)s|other": "%(count)s membri inclusi %(commaSeparatedMembers)s", + "Including %(commaSeparatedMembers)s": "Inclusi %(commaSeparatedMembers)s", + "View all %(count)s members|one": "Vedi 1 membro", + "View all %(count)s members|other": "Vedi tutti i %(count)s membri", + "Failed to send": "Invio fallito", + "Enter your Security Phrase a second time to confirm it.": "Inserisci di nuovo la password di sicurezza per confermarla.", + "Pick rooms or conversations to add. This is just a space for you, no one will be informed. You can add more later.": "Scegli le stanze o le conversazioni da aggiungere. Questo è uno spazio solo per te, nessuno ne saprà nulla. Puoi aggiungerne altre in seguito.", + "What do you want to organise?": "Cosa vuoi organizzare?", + "Filter all spaces": "Filtra tutti gli spazi", + "Delete recording": "Elimina registrazione", + "Stop the recording": "Ferma la registrazione", + "%(count)s results in all spaces|one": "%(count)s risultato in tutti gli spazi", + "%(count)s results in all spaces|other": "%(count)s risultati in tutti gli spazi", + "You have no ignored users.": "Non hai utenti ignorati.", + "Play": "Riproduci", + "Pause": "Pausa", + "This is an experimental feature. For now, new users receiving an invite will have to open the invite on to actually join.": "Questa è una funzione sperimentale. Per ora, i nuovi utenti che ricevono un invito dovranno aprirlo su per entrare.", + "To join %(spaceName)s, turn on the Spaces beta": "Per entrare in %(spaceName)s, attiva la beta degli spazi", + "To view %(spaceName)s, turn on the Spaces beta": "Per vedere %(spaceName)s, attiva la beta degli spazi", + "Select a room below first": "Prima seleziona una stanza sotto", + "Communities are changing to Spaces": "Le comunità stanno diventando spazi", + "Join the beta": "Unisciti alla beta", + "Leave the beta": "Abbandona la beta", + "Beta": "Beta", + "Tap for more info": "Tocca per maggiori info", + "Spaces is a beta feature": "Gli spazi sono una funzionalità beta", + "Want to add a new room instead?": "Vuoi invece aggiungere una nuova stanza?", + "Adding rooms... (%(progress)s out of %(count)s)|one": "Aggiunta stanza...", + "Adding rooms... (%(progress)s out of %(count)s)|other": "Aggiunta stanze... (%(progress)s di %(count)s)", + "Not all selected were added": "Non tutti i selezionati sono stati aggiunti", + "You can add existing spaces to a space.": "Puoi aggiungere spazi esistenti ad uno spazio.", + "Feeling experimental?": "Ti va di sperimentare?", + "You are not allowed to view this server's rooms list": "Non hai i permessi per vedere l'elenco di stanze del server", + "Error processing voice message": "Errore di elaborazione del vocale", + "We didn't find a microphone on your device. Please check your settings and try again.": "Non abbiamo trovato un microfono nel tuo dispositivo. Controlla le impostazioni e riprova.", + "No microphone found": "Nessun microfono trovato", + "We were unable to access your microphone. Please check your browser settings and try again.": "Non abbiamo potuto accedere al tuo microfono. Controlla le impostazioni del browser e riprova.", + "Unable to access your microphone": "Impossibile accedere al microfono", + "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.": "Ti va di sperimentare? I laboratori sono il miglior modo di ottenere anteprime, testare nuove funzioni ed aiutare a modellarle prima che vengano pubblicate. Maggiori informazioni.", + "Your access token gives full access to your account. Do not share it with anyone.": "Il tuo token di accesso ti dà l'accesso al tuo account. Non condividerlo con nessuno.", + "Access Token": "Token di accesso", + "Spaces are a new way to group rooms and people. To join an existing space you'll need an invite.": "Gli spazi sono un nuovo modo di raggruppare stanze e persone. Per entrare in uno spazio esistente ti serve un invito.", + "Please enter a name for the space": "Inserisci un nome per lo spazio", + "Connecting": "In connessione", + "Allow Peer-to-Peer for 1:1 calls (if you enable this, the other party might be able to see your IP address)": "Permetti Peer-to-Peer per chiamate 1:1 (se lo attivi, l'altra parte potrebbe essere in grado di vedere il tuo indirizzo IP)", + "Beta available for web, desktop and Android. Some features may be unavailable on your homeserver.": "Beta disponibile per web, desktop e Android. Alcune funzioni potrebbero non essere disponibili nel tuo homeserver.", + "You can leave the beta any time from settings or tapping on a beta badge, like the one above.": "Puoi abbandonare la beta quando vuoi dalle impostazioni o toccando un'etichetta beta, come quella sopra.", + "%(brand)s will reload with Spaces enabled. Communities and custom tags will be hidden.": "%(brand)s si ricaricherà con gli spazi attivati. Le comunità e le etichette personalizzate saranno nascoste.", + "Beta available for web, desktop and Android. Thank you for trying the beta.": "Beta disponibile per web, desktop e Android. Grazie per la partecipazione alla beta.", + "%(brand)s will reload with Spaces disabled. Communities and custom tags will be visible again.": "%(brand)s si ricaricherà con gli spazi disattivati. Le comunità e le etichette personalizzate saranno di nuovo visibili.", + "Spaces are a new way to group rooms and people.": "Gli spazi sono un nuovo modo di raggruppare stanze e persone.", + "Spaces are a beta feature.": "Gli spazi sono una funzionalità beta.", + "Search names and descriptions": "Cerca nomi e descrizioni", + "You may contact me if you have any follow up questions": "Potete contattarmi se avete altre domande", + "To leave the beta, visit your settings.": "Per abbandonare la beta, vai nelle impostazioni.", + "Your platform and username will be noted to help us use your feedback as much as we can.": "Verranno annotate la tua piattaforma e il nome utente per aiutarci ad usare la tua opinione al meglio.", + "%(featureName)s beta feedback": "Feedback %(featureName)s beta", + "Thank you for your feedback, we really appreciate it.": "Grazie per la tua opinione, lo appreziamo molto.", + "Beta feedback": "Feedback beta", + "Add reaction": "Aggiungi reazione", + "Send and receive voice messages": "Invia e ricevi messaggi vocali", + "Your feedback will help make spaces better. The more detail you can go into, the better.": "La tua opinione aiuterà a migliorare gli spazi. Più dettagli dai, meglio è.", + "If you leave, %(brand)s will reload with Spaces disabled. Communities and custom tags will be visible again.": "Se esci, %(brand)s si ricaricherà con gli spazi disattivati. Le comunità e le etichette personalizzate saranno di nuovo visibili.", + "Message search initialisation failed": "Inizializzazione ricerca messaggi fallita" } diff --git a/src/i18n/strings/ja.json b/src/i18n/strings/ja.json index 09329fb359..4eb49e45e2 100644 --- a/src/i18n/strings/ja.json +++ b/src/i18n/strings/ja.json @@ -629,7 +629,7 @@ "ex. @bob:example.com": "例 @bob:example.com", "Add User": "ユーザーを追加", "Matrix ID": "Matirx ID", - "Matrix Room ID": "Matrix 部屋ID", + "Matrix Room ID": "Matrix 部屋 ID", "email address": "メールアドレス", "You have entered an invalid address.": "無効なアドレスを入力しました。", "Try using one of the following valid address types: %(validTypesList)s.": "次の有効なアドレスタイプのいずれかを使用してください:%(validTypesList)s", @@ -1212,7 +1212,7 @@ "WARNING: Session already verified, but keys do NOT MATCH!": "警告: このセッションは検証済みです、しかし鍵が一致していません!", "WARNING: KEY VERIFICATION FAILED! The signing key for %(userId)s and session %(deviceId)s is \"%(fprint)s\" which does not match the provided key \"%(fingerprint)s\". This could mean your communications are being intercepted!": "警告: 鍵の検証に失敗しました!提供された鍵「%(fingerprint)s」は、%(userId)s およびセッション %(deviceId)s の署名鍵「%(fprint)s」と一致しません。これはつまり、あなたの会話が傍受・盗聴されようとしている恐れがあるということです!", "Show typing notifications": "入力中通知を表示する", - "Allow fallback call assist server turn.matrix.org when your homeserver does not offer one (your IP address would be shared during a call)": "あなたのホームサーバーが対応していない場合は (通話中に自己の IP アドレスが相手に共有されるのを防ぐために) 代替通話支援サーバー turn.matrix.org の使用を許可する", + "Allow fallback call assist server turn.matrix.org when your homeserver does not offer one (your IP address would be shared during a call)": "あなたのホームサーバーが対応していない場合は代替通話支援サーバー turn.matrix.org の使用を許可 (あなたの IP アドレスが通話相手に漏洩するのを防ぎます)", "Your homeserver does not support cross-signing.": "あなたのホームサーバーはクロス署名に対応していません。", "Cross-signing and secret storage are enabled.": "クロス署名および機密ストレージは有効です。", "Reset cross-signing and secret storage": "クロス署名および機密ストレージをリセット", @@ -2440,9 +2440,42 @@ "Create a space": "スペースを作成する", "Delete": "削除", "Jump to the bottom of the timeline when you send a message": "メッセージを送信する際にタイムライン最下部に移動します", - "Spaces prototype. Incompatible with Communities, Communities v2 and Custom Tags. Requires compatible homeserver for some features.": "Spacesはプロトタイプです。 コミュニティ、コミュニティv2、カスタムタグとは互換性がありません。 一部の機能には互換性のあるホームサーバーが必要です。", + "Spaces prototype. Incompatible with Communities, Communities v2 and Custom Tags. Requires compatible homeserver for some features.": "スペースはプロトタイプです。 コミュニティ、コミュニティv2、カスタムタグとは互換性がありません。 一部の機能には互換性のあるホームサーバーが必要です。", "This homeserver has been blocked by it's administrator.": "このホームサーバーは管理者によりブロックされています。", "This homeserver has been blocked by its administrator.": "このホームサーバーは管理者によりブロックされています。", "You're already in a call with this person.": "あなたは既にこの人と通話中です。", - "Already in call": "既に電話中です" + "Already in call": "既に電話中です", + "Invite People": "ユーザーを招待", + "Edit devices": "デバイスを編集", + "%(count)s messages deleted.|one": "%(count)s 件のメッセージが削除されました。", + "%(count)s messages deleted.|other": "%(count)s 件のメッセージが削除されました。", + "To view %(spaceName)s, turn on the Spaces beta": "スペース Beta を有効にすると %(spaceName)s を表示できます", + "Allow Peer-to-Peer for 1:1 calls (if you enable this, the other party might be able to see your IP address)": "1 対 1 の通話で P2P の使用を許可 (有効にするとあなたの IP アドレスが通話相手に漏洩する可能性があります)", + "You have no ignored users.": "無視しているユーザーはいません。", + "Join the beta": "Beta に参加", + "Leave the beta": "Beta を終了", + "Beta available for web, desktop and Android. Thank you for trying the beta.": "Beta は、ウェブ、デスクトップ、Android で利用可能です。Beta をお試しいただきありがとうございます。", + "Your access token gives full access to your account. Do not share it with anyone.": "アクセストークンを用いるとあなたのアカウントの全てにアクセスできます。外部に公開しないでください。", + "Access Token": "アクセストークン", + "Filter all spaces": "全スペースを検索", + "Save Changes": "変更を保存", + "Edit settings relating to your space.": "スペースの設定を変更します。", + "Space settings": "スペースの設定", + "Spaces are a beta feature.": "スペースは Beta 機能です。", + "Spaces is a beta feature": "スペースは Beta 機能です", + "Spaces are a new way to group rooms and people.": "スペースは、部屋や人をグループ化する新しい方法です。", + "Spaces": "スペース", + "Welcome to ": "ようこそ ", + "Invite to just this room": "この部屋に招待", + "Invite to %(spaceName)s": "%(spaceName)s に招待", + "Quick actions": "クイックアクション", + "A private space for you and your teammates": "", + "Me and my teammates": "自分とチームメイト", + "Just me": "自分専用", + "Make sure the right people have access to %(name)s": "必要な人が %(name)s にアクセスできるようにします", + "Who are you working with?": "誰が使いますか?", + "Beta": "Beta", + "Tap for more info": "タップして詳細を表示", + "Spaces are a new way to group rooms and people. To join an existing space you'll need an invite.": "スペースは、部屋や人をグループ化する新しい方法です。既存のスペースに参加するには、招待が必要です。", + "Check your devices": "デバイスを確認" } diff --git a/src/i18n/strings/jbo.json b/src/i18n/strings/jbo.json index f2c9dc6e43..b19d4bb95d 100644 --- a/src/i18n/strings/jbo.json +++ b/src/i18n/strings/jbo.json @@ -580,5 +580,8 @@ "%(displayName)s cancelled verification.": ".i la'o zoi. %(displayName)s .zoi co'u co'a lacri", "Decrypt %(text)s": "nu facki le du'u mifra la'o zoi. %(text)s .zoi", "Download %(text)s": "nu kibycpa la'o zoi. %(text)s .zoi", - "Download this file": "nu kibycpa le vreji" + "Download this file": "nu kibycpa le vreji", + "Explore rooms": "nu facki le du'u ve zilbe'i", + "Create Account": "nu pa re'u co'a jaspu", + "Dismiss": "nu mipri" } diff --git a/src/i18n/strings/kab.json b/src/i18n/strings/kab.json index c4e0cc7099..b6e1b3020f 100644 --- a/src/i18n/strings/kab.json +++ b/src/i18n/strings/kab.json @@ -2,7 +2,7 @@ "Confirm": "Sentem", "Analytics": "Tiselḍin", "Error": "Tuccḍa", - "Dismiss": "Agi", + "Dismiss": "Agwi", "OK": "IH", "Permission Required": "Tasiregt tlaq", "Continue": "Kemmel", diff --git a/src/i18n/strings/ko.json b/src/i18n/strings/ko.json index 59bb68af94..f817dbc26b 100644 --- a/src/i18n/strings/ko.json +++ b/src/i18n/strings/ko.json @@ -1666,5 +1666,6 @@ "WARNING: KEY VERIFICATION FAILED! The signing key for %(userId)s and session %(deviceId)s is \"%(fprint)s\" which does not match the provided key \"%(fingerprint)s\". This could mean your communications are being intercepted!": "경고: 키 검증 실패! 제공된 키인 \"%(fingerprint)s\"가 사용자 %(userId)s와 %(deviceId)s 세션의 서명 키인 \"%(fprint)s\"와 일치하지 않습니다. 이는 통신이 탈취되고 있는 중일 수도 있다는 뜻입니다!", "The signing key you provided matches the signing key you received from %(userId)s's session %(deviceId)s. Session marked as verified.": "사용자 %(userId)s의 세션 %(deviceId)s에서 받은 서명 키와 당신이 제공한 서명 키가 일치합니다. 세션이 검증되었습니다.", "Show more": "더 보기", - "Changing password will currently reset any end-to-end encryption keys on all sessions, making encrypted chat history unreadable, unless you first export your room keys and re-import them afterwards. In future this will be improved.": "비밀번호를 변경한다면 방의 암호화 키를 내보낸 후 다시 가져오지 않는 이상 모든 종단간 암호화 키는 초기화 될 것이고, 암호화된 대화 내역은 읽을 수 없게 될 것입니다. 이 문제는 추후에 개선될 것입니다." + "Changing password will currently reset any end-to-end encryption keys on all sessions, making encrypted chat history unreadable, unless you first export your room keys and re-import them afterwards. In future this will be improved.": "비밀번호를 변경한다면 방의 암호화 키를 내보낸 후 다시 가져오지 않는 이상 모든 종단간 암호화 키는 초기화 될 것이고, 암호화된 대화 내역은 읽을 수 없게 될 것입니다. 이 문제는 추후에 개선될 것입니다.", + "Create Account": "계정 만들기" } diff --git a/src/i18n/strings/lt.json b/src/i18n/strings/lt.json index 6ba078ad64..83b59681e7 100644 --- a/src/i18n/strings/lt.json +++ b/src/i18n/strings/lt.json @@ -1184,7 +1184,7 @@ "Manage integrations": "Valdyti integracijas", "Integration Managers receive configuration data, and can modify widgets, send room invites, and set power levels on your behalf.": "Integracijų Tvarkytuvai gauna konfigūracijos duomenis ir jūsų vardu gali keisti valdiklius, siųsti kambario pakvietimus ir nustatyti galios lygius.", "Invalid theme schema.": "Klaidinga temos schema.", - "Error downloading theme information.": "Klaida parsisiunčiant temos informaciją.", + "Error downloading theme information.": "Klaida atsisiunčiant temos informaciją.", "Theme added!": "Tema pridėta!", "Custom theme URL": "Pasirinktinės temos URL", "Add theme": "Pridėti temą", @@ -2091,5 +2091,16 @@ "Successfully restored %(sessionCount)s keys": "Sėkmingai atkurti %(sessionCount)s raktai", "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.": "Įspėjimas: Jūsų asmeniniai duomenys (įskaitant šifravimo raktus) vis dar yra saugomi šiame seanse. Išvalykite juos, jei baigėte naudoti šį seansą, arba norite prisijungti prie kitos paskyros.", "Reason (optional)": "Priežastis (nebūtina)", - "Reason: %(reason)s": "Priežastis: %(reason)s" + "Reason: %(reason)s": "Priežastis: %(reason)s", + "Already have an account? Sign in here": "Jau turite paskyrą? Prisijunkite čia", + "Host account on": "Kurti paskyrą serveryje", + "Forgotten your password?": "Pamiršote savo slaptažodį?", + "Homeserver": "Serveris", + "New? Create account": "Naujas vartotojas? Sukurkite paskyrą", + "Forgot password?": "Pamiršote slaptažodį?", + "Preparing to download logs": "Ruošiamasi parsiųsti žurnalus", + "You can use the custom server options to sign into other Matrix servers by specifying a different homeserver URL. This allows you to use Element with an existing Matrix account on a different homeserver.": "Jūs galite naudoti serverio parinktis, norėdami prisijungti prie kitų Matrix serverių, nurodydami kitą serverio URL. Tai leidžia jums naudoti Element su egzistuojančia paskyra kitame serveryje.", + "Server Options": "Serverio Parinktys", + "Your homeserver": "Jūsų serveris", + "Download logs": "Parsisiųsti žurnalus" } diff --git a/src/i18n/strings/lv.json b/src/i18n/strings/lv.json index d294b38d82..b56599f26e 100644 --- a/src/i18n/strings/lv.json +++ b/src/i18n/strings/lv.json @@ -1,5 +1,5 @@ { - "Accept": "Pieņemt", + "Accept": "Akceptēt", "%(targetName)s accepted an invitation.": "%(targetName)s pieņēma uzaicinājumu.", "%(targetName)s accepted the invitation for %(displayName)s.": "%(targetName)s pieņēma uzaicinājumu no %(displayName)s.", "Account": "Konts", @@ -27,8 +27,8 @@ "Anyone who knows the room's link, apart from guests": "Ikviens, kurš zina adreses saiti uz istabu, izņemot viesus", "Anyone who knows the room's link, including guests": "Ikviens, kurš zina adreses saiti uz istabu, tai skaitā arī viesi", "Are you sure?": "Vai tiešām to vēlaties?", - "Are you sure you want to leave the room '%(roomName)s'?": "Vai tiešām vēlies pamest istabu: '%(roomName)s'?", - "Are you sure you want to reject the invitation?": "Vai tiešām vēlies noraidīt šo uzaicinājumu?", + "Are you sure you want to leave the room '%(roomName)s'?": "Vai tiešām vēlaties pamest istabu: '%(roomName)s'?", + "Are you sure you want to reject the invitation?": "Vai tiešām vēlaties noraidīt šo uzaicinājumu?", "Attachment": "Pielikums", "Autoplay GIFs and videos": "Automātiski rādīt GIF animācijas un video", "%(senderName)s banned %(targetName)s.": "%(senderName)s liedza pieeju %(targetName)s.", @@ -60,7 +60,7 @@ "Cryptography": "Kriptogrāfija", "Current password": "Pašreizējā parole", "Custom": "Pielāgots", - "Custom level": "Īpašais līmenis", + "Custom level": "Pielāgots līmenis", "/ddg is not a command": "/ddg nav komanda", "Deactivate Account": "Deaktivizēt kontu", "Decline": "Noraidīt", @@ -74,7 +74,7 @@ "Email": "Epasts", "Email address": "Epasta adrese", "Emoji": "Emocijzīmes", - "%(senderName)s ended the call.": "%(senderName)s pārtrauca zvanu.", + "%(senderName)s ended the call.": "%(senderName)s pabeidza zvanu.", "Enter passphrase": "Ievadiet frāzveida paroli", "Error": "Kļūda", "Error decrypting attachment": "Kļūda atšifrējot pielikumu", @@ -182,7 +182,7 @@ "Register": "Reģistrēties", "%(targetName)s rejected the invitation.": "%(targetName)s noraidīja uzaicinājumu.", "Reject invitation": "Noraidīt uzaicinājumu", - "%(senderName)s removed their display name (%(oldDisplayName)s).": "%(senderName)s dzēsa attēlojamo/redzamo vārdu (%(oldDisplayName)s).", + "%(senderName)s removed their display name (%(oldDisplayName)s).": "%(senderName)s dzēsa parādāmo vārdu (%(oldDisplayName)s).", "%(senderName)s removed their profile picture.": "%(senderName)s dzēsa profila attēlu.", "Remove": "Dzēst", "%(senderName)s requested a VoIP conference.": "%(senderName)s vēlas VoIP konferenci.", @@ -191,7 +191,7 @@ "%(brand)s does not have permission to send you notifications - please check your browser settings": "%(brand)s nav atļauts nosūtīt jums paziņojumus. Lūdzu pārbaudi sava pārlūka iestatījumus", "%(brand)s was not given permission to send notifications - please try again": "%(brand)s nav piešķirta atļauja nosūtīt paziņojumus. Lūdzu mēģini vēlreiz", "%(brand)s version:": "%(brand)s versija:", - "Unable to enable Notifications": "Nav iespējams iespējot paziņojumus", + "Unable to enable Notifications": "Neizdevās iespējot paziņojumus", "You have no visible notifications": "Tev nav redzamo paziņojumu", "This will allow you to reset your password and receive notifications.": "Tas atļaus Tev atiestatīt paroli un saņemt paziņojumus.", "Room %(roomId)s not visible": "Istaba %(roomId)s nav redzama", @@ -201,7 +201,7 @@ "%(senderDisplayName)s sent an image.": "%(senderDisplayName)s nosūtīja attēlu.", "%(senderName)s sent an invitation to %(targetDisplayName)s to join the room.": "%(senderName)s nosūtīja uzaicinājumu %(targetDisplayName)s pievienoties istabai.", "%(senderName)s set a profile picture.": "%(senderName)s uzstādīja profila attēlu.", - "%(senderName)s set their display name to %(displayName)s.": "%(senderName)s nomainīja attēlojamo/redzamo vārdu uz: %(displayName)s.", + "%(senderName)s set their display name to %(displayName)s.": "%(senderName)s nomainīja parādāmo vārdu uz: %(displayName)s.", "%(senderName)s unbanned %(targetName)s.": "%(senderName)s atcēla pieejas liegumu %(targetName)s.", "Uploading %(filename)s and %(count)s others|zero": "Tiek augšupielādēts %(filename)s", "Uploading %(filename)s and %(count)s others|one": "Tiek augšupielādēts %(filename)s un %(count)s citi", @@ -258,9 +258,9 @@ "To use it, just wait for autocomplete results to load and tab through them.": "Lai to izmantotu, vienkārši gaidi, kamēr ielādējas automātiski ieteiktie rezultāti, un pārvietojies caur tiem.", "Tried to load a specific point in this room's timeline, but you do not have permission to view the message in question.": "Notika mēģinājums ielādēt šīs istabas specifisku laikpaziņojumu sadaļu, bet Tev nav atļaujas skatīt šo ziņu.", "Tried to load a specific point in this room's timeline, but was unable to find it.": "Mēģinājums ielādēt šīs istabas čata vēstures izvēlēto posmu neizdevās, jo tas netika atrasts.", - "Unable to add email address": "Nav iespējams pievienot epasta adresi", - "Unable to remove contact information": "Nav iespējams dzēst kontaktinformāciju", - "Unable to verify email address.": "Nav iespējams apstiprināt epasta adresi.", + "Unable to add email address": "Neizdevās pievienot epasta adresi", + "Unable to remove contact information": "Neizdevās dzēst kontaktinformāciju", + "Unable to verify email address.": "Neizdevās apstiprināt epasta adresi.", "Unban": "Atcelt pieejas liegumu", "Unable to capture screen": "Neizdevās uzņemt ekrānattēlu", "unknown caller": "nezināms zvanītājs", @@ -300,7 +300,7 @@ "You need to be logged in.": "Tev ir jāpierakstās.", "Your email address does not appear to be associated with a Matrix ID on this Homeserver.": "Jūsu epasta adrese nav piesaistīta nevienam Matrix ID šajā bāzes serverī.", "You seem to be in a call, are you sure you want to quit?": "Izskatās, ka atrodies zvana režīmā. Vai tiešām vēlies iziet?", - "You seem to be uploading files, are you sure you want to quit?": "Izskatās, ka šobrīd augšuplādē failus. Vai tiešām vēlies iziet?", + "You seem to be uploading files, are you sure you want to quit?": "Izskatās, ka šobrīd notiek failu augšupielāde. Vai tiešām vēlaties iziet?", "Sun": "Sv.", "Mon": "P.", "Tue": "O.", @@ -350,10 +350,10 @@ "You must join the room to see its files": "Tev ir jāpievienojas istabai, lai redzētu tās failus", "Failed to invite": "Neizdevās uzaicināt", "Confirm Removal": "Apstipriniet dzēšanu", - "Are you sure you wish to remove (delete) this event? Note that if you delete a room name or topic change, it could undo the change.": "Vai tiešām vēlies dzēst šo notikumu? Ņem vērā, ka istabas nosaukuma vai tēmas nosaukuma maiņa var ietekmēt (atsaukt) izmaiņas.", + "Are you sure you wish to remove (delete) this event? Note that if you delete a room name or topic change, it could undo the change.": "Vai tiešām vēlaties dzēst šo notikumu? Ņemiet vērā, ka istabas nosaukuma dzēšana vai temata maiņa var atcelt izmaiņas.", "Unknown error": "Nezināma kļūda", "Incorrect password": "Nepareiza parole", - "Unable to restore session": "Nav iespējams atjaunot sesiju", + "Unable to restore session": "Neizdevās atjaunot sesiju", "If you have previously used a more recent version of %(brand)s, your session may be incompatible with this version. Close this window and return to the more recent version.": "Ja iepriekš izmantojāt jaunāku %(brand)s versiju, jūsu sesija var nebūt saderīga ar šo versiju. Aizveriet šo logu un atgriezieties jaunākajā versijā.", "Unknown Address": "Nezināma adrese", "ex. @bob:example.com": "piemēram, @valters:smaidu.lv", @@ -398,7 +398,7 @@ "PM": "PM", "The maximum permitted number of widgets have already been added to this room.": "Maksimāli atļautais vidžetu skaits šai istabai jau sasniegts.", "To get started, please pick a username!": "Lai sāktu, lūdzu izvēlies lietotājvārdu!", - "Unable to create widget.": "Nav iespējams izveidot widžetu.", + "Unable to create widget.": "Neizdevās izveidot widžetu.", "You are not in this room.": "Tu neatrodies šajā istabā.", "You do not have permission to do that in this room.": "Tev nav atļaujas šai darbībai šajā istabā.", "Example": "Piemērs", @@ -433,7 +433,7 @@ "Invite to Community": "Uzaicināt kopienā", "Which rooms would you like to add to this community?": "Kuras istabas vēlies pievienot šai kopienai?", "Show these rooms to non-members on the community page and room list?": "Vai ne-biedriem rādīt kopienas lapā un istabu sarakstā šīs istabas?", - "Add rooms to the community": "Istabu pievienošana kopienai", + "Add rooms to the community": "Pievienot istabas kopienai", "Add to community": "Pievienot kopienai", "Failed to invite the following users to %(groupId)s:": "Neizdevās uzaicināt sekojošus lietotājus grupā %(groupId)s:", "Failed to invite users to community": "Neizdevās uzaicināt lietotājus komūnā", @@ -444,7 +444,7 @@ "You are now ignoring %(userId)s": "Tagad Tu ignorē %(userId)s", "Unignored user": "Atignorēts lietotājs", "You are no longer ignoring %(userId)s": "Tu vairāk neignorē %(userId)s", - "%(oldDisplayName)s changed their display name to %(displayName)s.": "%(oldDisplayName)s nomainīja savu attēlojamo/redzamo vārdu uz %(displayName)s.", + "%(oldDisplayName)s changed their display name to %(displayName)s.": "%(oldDisplayName)s nomainīja savu parādāmo vārdu uz %(displayName)s.", "%(senderName)s changed the pinned messages for the room.": "%(senderName)s nomainīja šajā istabā piespraustās ziņas.", "%(widgetName)s widget modified by %(senderName)s": "%(widgetName)s vidžets, kuru mainīja %(senderName)s", "Message Pinning": "Ziņu piespraušana", @@ -457,7 +457,7 @@ "%(senderName)s uploaded a file": "%(senderName)s augšupielādēja failu", "Disinvite this user?": "Atsaukt uzaicinājumu šim lietotājam?", "Kick this user?": "Padzīt šo lietotāju?", - "Unban this user?": "Atbanot/atbloķēt šo lietotāju (atcelt liegumu šim lietotājam)?", + "Unban this user?": "Atcelt liegumu šim lietotājam?", "Ban this user?": "Nobanot/bloķēt šo lietotāju (uzlikt liegumu šim lietotājam)?", "You will not be able to undo this change as you are demoting yourself, if you are the last privileged user in the room it will be impossible to regain privileges.": "Jūs nevarēsiet atcelt šīs izmaiņas pēc sava statusa pazemināšanas. Gadījumā, ja esat pēdējais priviliģētais lietotājs istabā, būs neiespējami atgūt šīs privilēģijas.", "Unignore": "Atcelt ignorēšanu", @@ -473,7 +473,7 @@ "Pinned Messages": "Piespraustās ziņas", "%(duration)ss": "%(duration)s sek", "%(duration)sm": "%(duration)smin", - "%(duration)sh": "%(duration)sstundas", + "%(duration)sh": "%(duration)s stundas", "%(duration)sd": "%(duration)s dienas", "Online for %(duration)s": "Tiešsaistē %(duration)s", "Idle for %(duration)s": "Dīkstāvē (neaktīvs) %(duration)s", @@ -502,13 +502,13 @@ "Failed to copy": "Nokopēt neizdevās", "An email has been sent to %(emailAddress)s": "Vēstule tika nosūtīta uz %(emailAddress)s", "A text message has been sent to %(msisdn)s": "Teksta ziņa tika nosūtīta uz %(msisdn)s", - "Remove from community": "Izdzēst no kopienas", + "Remove from community": "Dzēst no kopienas", "Disinvite this user from community?": "Atcelt šim lietotājam nosūtīto uzaicinājumu pievienoties kopienai?", "Remove this user from community?": "Izdzēst šo lietotāju no kopienas?", "Failed to withdraw invitation": "Neizdevās atcelt uzaicinājumu", "Failed to remove user from community": "Neizdevās izdzēst lietotāju no kopienas", "Filter community members": "Kopienas biedru filtrs", - "Are you sure you want to remove '%(roomName)s' from %(groupId)s?": "Vai tiešām vēlies izdzēst '%(roomName)s' no %(groupId)s?", + "Are you sure you want to remove '%(roomName)s' from %(groupId)s?": "Vai tiešām vēlaties dzēst '%(roomName)s' no %(groupId)s?", "Removing a room from the community will also remove it from the community page.": "Dzēšot istabu no kopienas tā tiks dzēsta arī no kopienas lapas.", "Failed to remove room from community": "Neizdevās dzēst istabu no kopienas", "Failed to remove '%(roomName)s' from %(groupId)s": "Neizdevās dzēst '%(roomName)s' no %(groupId)s", @@ -528,16 +528,16 @@ "%(severalUsers)sjoined %(count)s times|other": "%(severalUsers)spievienojās %(count)s reizes", "%(severalUsers)sjoined %(count)s times|one": "%(severalUsers)spievienojās", "%(oneUser)sjoined %(count)s times|other": "%(oneUser)spievienojās %(count)s reizes", - "%(oneUser)sjoined %(count)s times|one": "%(oneUser)s pievienojās", - "%(severalUsers)sleft %(count)s times|other": "%(severalUsers)s izgāja %(count)s reizes", - "%(severalUsers)sleft %(count)s times|one": "%(severalUsers)s izgāja", - "%(oneUser)sleft %(count)s times|other": "%(oneUser)s izgāja %(count)s reizes", - "%(oneUser)sleft %(count)s times|one": "%(oneUser)s izgāja", - "%(severalUsers)sjoined and left %(count)s times|other": "%(severalUsers)s pievienojās un izgāja %(count)s reizes", - "%(severalUsers)sjoined and left %(count)s times|one": "%(severalUsers)s pievienojās un izgāja", - "%(oneUser)sjoined and left %(count)s times|other": "%(oneUser)s pievienojās un izgāja %(count)s reizes", - "%(oneUser)sjoined and left %(count)s times|one": "%(oneUser)s pievienojās un izgāja", - "%(severalUsers)sleft and rejoined %(count)s times|other": "%(severalUsers)s izgāja un atkal pievienojās %(count)s reizes", + "%(oneUser)sjoined %(count)s times|one": "%(oneUser)spievienojās", + "%(severalUsers)sleft %(count)s times|other": "%(severalUsers)spameta %(count)s reizes", + "%(severalUsers)sleft %(count)s times|one": "%(severalUsers)spameta", + "%(oneUser)sleft %(count)s times|other": "%(oneUser)spameta %(count)s reizes", + "%(oneUser)sleft %(count)s times|one": "%(oneUser)spameta", + "%(severalUsers)sjoined and left %(count)s times|other": "%(severalUsers)spievienojās un pameta %(count)s reizes", + "%(severalUsers)sjoined and left %(count)s times|one": "%(severalUsers)spievienojās un pameta", + "%(oneUser)sjoined and left %(count)s times|other": "%(oneUser)spievienojās un pameta %(count)s reizes", + "%(oneUser)sjoined and left %(count)s times|one": "%(oneUser)spievienojās un pameta", + "%(severalUsers)sleft and rejoined %(count)s times|other": "%(severalUsers)spameta un atkal pievienojās %(count)s reizes", "%(severalUsers)srejected their invitations %(count)s times|other": "%(severalUsers)s noraidīja uzaicinājumus %(count)s reizes", "%(severalUsers)shad their invitations withdrawn %(count)s times|other": "%(severalUsers)s atsauca izsniegtos uzaicinājumus %(count)s reizes", "were banned %(count)s times|other": "tika bloķēti (liegta piekļuve) %(count)s reizes", @@ -560,9 +560,9 @@ "Community Name": "Kopienas nosaukums", "Community ID": "Kopienas ID", "example": "piemērs", - "%(severalUsers)sleft and rejoined %(count)s times|one": "%(severalUsers)s aizgājuši un atgriezušies", - "%(oneUser)sleft and rejoined %(count)s times|other": "%(oneUser)s aizgājis un atgriezies %(count)s reizes", - "%(oneUser)sleft and rejoined %(count)s times|one": "%(oneUser)s aizgājis un atgriezies", + "%(severalUsers)sleft and rejoined %(count)s times|one": "%(severalUsers)spameta un atkal pievienojās", + "%(oneUser)sleft and rejoined %(count)s times|other": "%(oneUser)spameta un atkal pievienojās %(count)s reizes", + "%(oneUser)sleft and rejoined %(count)s times|one": "%(oneUser)spameta un atkal pievienojās", "%(severalUsers)srejected their invitations %(count)s times|one": "%(severalUsers)s noraidīja uzaicinājumus", "were invited %(count)s times|one": "tika uzaicināti", "was invited %(count)s times|other": "tika uzaicināta %(count)s reizes", @@ -575,8 +575,8 @@ "were kicked %(count)s times|one": "tika padzīti", "was kicked %(count)s times|other": "tika padzīts %(count)s reizes", "was kicked %(count)s times|one": "tika padzīts", - "%(severalUsers)schanged their name %(count)s times|other": "%(severalUsers)s izmainīja savu lietotājvārdu %(count)s reizes", - "%(severalUsers)schanged their name %(count)s times|one": "%(severalUsers)s izmainīja savu lietotājvārdu", + "%(severalUsers)schanged their name %(count)s times|other": "%(severalUsers)sizmainīja savu lietotājvārdu %(count)s reizes", + "%(severalUsers)schanged their name %(count)s times|one": "%(severalUsers)sizmainīja savu lietotājvārdu", "

    HTML for your community's page

    \n

    \n Use the long description to introduce new members to the community, or distribute\n some important links\n

    \n

    \n You can even use 'img' tags\n

    \n": "

    Tavas kopienas lapas HTML

    \n

    \n Izmanto garāku aprakstu, lai iepazīstinātu jaunos lietoājus ar kopienu, \n vai padalies ar kādām attiecināmām web-saitēm\n

    \n

    \n Vari izmantot arī 'img' birkas\n

    \n", "Add rooms to the community summary": "Pievienot istabas kopienas informatīvajā kopsavilkumā", "Which rooms would you like to add to this summary?": "Kuras istabas vēlaties pievienot šim kopsavilkumam?", @@ -590,7 +590,7 @@ "These rooms are displayed to community members on the community page. Community members can join the rooms by clicking on them.": "Šīs istabas tiek rādītas kopienas dalībniekiem šīs kopienas lapā. Kopienas dalībnieki var pievienoties istabām, uzklikšķinot uz tām.", "Your community hasn't got a Long Description, a HTML page to show to community members.
    Click here to open settings and give it one!": "Jūsu kopienai nav plašāka HTML-lapas apraksta ko parādīt dalībniekiem.
    Klikšķini šeit, lai atvērtu iestatījumus un to pievienotu!", "Description": "Apraksts", - "Failed to load %(groupId)s": "Neizdevās ielādēt %(groupId)s", + "Failed to load %(groupId)s": "%(groupId)s ielādes kļūda", "This room is not public. You will not be able to rejoin without an invite.": "Šī istaba nav publiska un jūs nevarēsiet atkārtoti pievienoties bez uzaicinājuma.", "Old cryptography data detected": "Tika uzieti novecojuši šifrēšanas dati", "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.": "Uzieti dati no vecākas %(brand)s versijas. Tas novedīs pie \"end-to-end\" šifrēšanas problēmām vecākajā versijā. Šajā versijā nevar tikt atšifrēti ziņojumi, kuri radīti izmantojot vecākajā versijā \"end-to-end\" šifrētas ziņas. Tas var arī novest pie ziņapmaiņas, kas veikta ar šo versiju, neizdošanās. Ja rodas ķibeles, izraksties un par jaunu pieraksties sistēmā. Lai saglabātu ziņu vēsturi, eksportē un tad importē savas šifrēšanas atslēgas.", @@ -615,9 +615,9 @@ "%(oneUser)shad their invitation withdrawn %(count)s times|other": "%(oneUser)satsauca savus uzaicinājumus %(count)s reizes", "%(oneUser)shad their invitation withdrawn %(count)s times|one": "%(oneUser)satsauca savu uzaicinājumu", "were invited %(count)s times|other": "bija uzaicināti %(count)s reizes", - "%(oneUser)schanged their name %(count)s times|other": "%(oneUser)s izmainīja savu vārdu %(count)s reizes", - "%(oneUser)schanged their name %(count)s times|one": "%(oneUser)s izmainīja savu vārdu", - "%(severalUsers)schanged their avatar %(count)s times|other": "%(severalUsers)s nomainīja savu avataru %(count)s reizes", + "%(oneUser)schanged their name %(count)s times|other": "%(oneUser)sizmainīja savu vārdu %(count)s reizes", + "%(oneUser)schanged their name %(count)s times|one": "%(oneUser)sizmainīja savu vārdu", + "%(severalUsers)schanged their avatar %(count)s times|other": "%(severalUsers)snomainīja savu avataru %(count)s reizes", "%(severalUsers)schanged their avatar %(count)s times|one": "%(severalUsers)snomainīja savu avataru", "%(items)s and %(count)s others|one": "%(items)s un viens cits", "%(oneUser)schanged their avatar %(count)s times|other": "%(oneUser)snomainīja savu avataru %(count)s reizes", @@ -630,10 +630,10 @@ "Failed to remove a user from the summary of %(groupId)s": "Neizdevās dzēst lietotāju no %(groupId)s kopsavilkuma", "The user '%(displayName)s' could not be removed from the summary.": "Lietotājs '%(displayName)s' nevarēja tikt dzēsts no kopsavilkuma.", "Failed to update community": "Neizdevās atjaunināt kopienu", - "Unable to accept invite": "Nav iespējams pieņemt uzaicinājumu", - "Unable to reject invite": "Nav iespējams noraidīt uzaicinājumu", + "Unable to accept invite": "Neizdevās pieņemt uzaicinājumu", + "Unable to reject invite": "Neizdevās noraidīt uzaicinājumu", "Leave %(groupName)s?": "Pamest %(groupName)s?", - "%(inviter)s has invited you to join this community": "%(inviter)s uzaicina Tevi pievienoties šai kopienai", + "%(inviter)s has invited you to join this community": "%(inviter)s uzaicināja jūs pievienoties šai kopienai", "You are an administrator of this community": "Tu esi šīs kopienas administrators", "You are a member of this community": "Tu esi šīs kopienas biedrs", "Long Description (HTML)": "Garais apraksts (HTML)", @@ -666,7 +666,7 @@ "Advanced notification settings": "Paziņojumu papildu iestatījumi", "Failed to send logs: ": "Neizdevās nosūtīt logfailus: ", "Forget": "Aizmirst", - "You cannot delete this image. (%(code)s)": "Šo attēlu nevar izdzēst (%(code)s)", + "You cannot delete this image. (%(code)s)": "Jūs nevarat dzēst šo attēlu. (%(code)s)", "Cancel Sending": "Atcelt sūtīšanu", "This Room": "Šajā istabā", "Noisy": "Ar skaņu", @@ -734,7 +734,7 @@ "When I'm invited to a room": "Kad esmu uzaicināts/a istabā", "Can't update user notification settings": "Neizdodas atjaunināt lietotāja paziņojumu iestatījumus", "Notify for all other messages/rooms": "Paziņot par visām citām ziņām/istabām", - "Unable to look up room ID from server": "Nav iespējams no servera iegūt istabas Id", + "Unable to look up room ID from server": "Neizdevās no servera iegūt istabas ID", "Couldn't find a matching Matrix room": "Atbilstoša Matrix istaba netika atrasta", "Invite to this room": "Uzaicināt uz šo istabu", "Thursday": "Ceturtdiena", @@ -745,9 +745,9 @@ "Show message in desktop notification": "Parādīt ziņu darbvirsmas paziņojumos", "Debug logs contain application usage data including your username, the IDs or aliases of the rooms or groups you have visited and the usernames of other users. They do not contain messages.": "Atutošanas logfaili satur programmas datus, ieskaitot Tavu lietotājvārdu, istabu/grupu ID vai aliases, kuras esi apmeklējis un citu lietotāju lietotājvārdus. Tie nesatur pašas ziņas.", "Unhide Preview": "Rādīt priekšskatījumu", - "Unable to join network": "Nav iespējams pievienoties tīklam", + "Unable to join network": "Neizdodas pievienoties tīklam", "Sorry, your browser is not able to run %(brand)s.": "Atvaino, diemžēl tavs tīmekļa pārlūks nespēj darbināt %(brand)s.", - "Uploaded on %(date)s by %(user)s": "Augšuplādēja %(user)s %(date)s", + "Uploaded on %(date)s by %(user)s": "Augšupielādēja %(user)s %(date)s", "Messages in group chats": "Ziņas grupas čatos", "Yesterday": "Vakardien", "Error encountered (%(errorDetail)s).": "Gadījās kļūda (%(errorDetail)s).", @@ -785,10 +785,10 @@ "Replying With Files": "Atbildot ar failiem", "At this time it is not possible to reply with a file. Would you like to upload this file without replying?": "Šobrīd nav iespējams atbildēt ar failu. Vai vēlaties augšupielādēt šo failu, neatbildot?", "Your %(brand)s is misconfigured": "Jūsu %(brand)s ir nepareizi konfigurēts", - "Add Email Address": "Pievienot e-pasta adresi", + "Add Email Address": "Pievienot epasta adresi", "Add Phone Number": "Pievienot tālruņa numuru", "Call failed due to misconfigured server": "Zvans neizdevās nekorekti nokonfigurēta servera dēļ", - "Verify this login": "Verificējiet šo sesiju", + "Verify this login": "Verificēt šo pierakstīšanos", "You sent a verification request": "Jūs nosūtījāt verifikācijas pieprasījumu", "Start Verification": "Uzsākt verifikāciju", "Your new session is now verified. It has access to your encrypted messages, and other users will see it as trusted.": "Jaunā sesija ir verificēta un ir dota piekļuve jūsu šifrētajām ziņām, kā arī citi lietotāji redzēs, ka šī sesija ir uzticama.", @@ -797,7 +797,7 @@ "%(count)s verified sessions|one": "1 verificēta sesija", "%(count)s verified sessions|other": "%(count)s verificētas sesijas", "Encrypted by an unverified session": "Šifrēts ar neverificētu sesiju", - "Waiting for your other session to verify…": "Teik gaidīts uz verificēšanu no citas jūsu sesijas…", + "Waiting for your other session to verify…": "Tiek gaidīts uz verificēšanu no citas jūsu sesijas…", "Waiting for your other session, %(deviceName)s (%(deviceId)s), to verify…": "Tiek gaidīts uz citu jūsu sesiju, %(deviceName)s (%(deviceId)s), lai verificētu…", "Verify your other session using one of the options below.": "Verificējiet citas jūsu sesijas, izmantojot kādu no iespējām zemāk.", "%(names)s and %(count)s others are typing …|other": "%(names)s un %(count)s citi raksta…", @@ -972,14 +972,14 @@ "Set a new password": "Iestati jaunu paroli", "Set a new account password...": "Iestatiet jaunu konta paroli...", "Sign in instead": "Pierakstīties", - "A verification email will be sent to your inbox to confirm setting your new password.": "Apstiprinājuma vēstule tiks nosūtīta uz tavu epasta adresi, lai apstiprinātu paroles nomaiņu.", + "A verification email will be sent to your inbox to confirm setting your new password.": "Apstiprinājuma vēstule tiks nosūtīta uz jūsu epasta adresi, lai apstiprinātu paroles nomaiņu.", "Forgot password?": "Aizmirsi paroli?", "No homeserver URL provided": "Nav iestatīts bāzes servera URL", "Cannot reach homeserver": "Neizdodas savienoties ar bāzes serveri", "The file '%(fileName)s' exceeds this homeserver's size limit for uploads": "Fails '%(fileName)s pārsniedz augšupielādējama faila izmēra limitu šajā bāzes serverī", "Please ask the administrator of your homeserver (%(homeserverDomain)s) to configure a TURN server in order for calls to work reliably.": "Lūdzu, jautājiet sava bāzes servera administratoram (%(homeserverDomain)s) sakonfigurēt TURN serveri, lai zvani strādātu stabili.", "Join millions for free on the largest public server": "Pievienojieties bez maksas miljoniem lietotāju lielākajā publiskajā serverī", - "You can use the custom server options to sign into other Matrix servers by specifying a different homeserver URL. This allows you to use Element with an existing Matrix account on a different homeserver.": "Jūs varat pielāgot servera parametrus, lai pierakstītos citos Matrix bāzes serveros, norādot atbilstošu bāzes servera URL. Tas ļauj jums izmantot Element ar eksistējošu Matrix kontu uz cita bāzes servera.", + "You can use the custom server options to sign into other Matrix servers by specifying a different homeserver URL. This allows you to use Element with an existing Matrix account on a different homeserver.": "Jūs varat pielāgot servera parametrus, lai pierakstītos citos Matrix bāzes serveros, norādot atbilstošu bāzes servera URL. Tas ļauj jums izmantot Element ar uz cita bāzes servera izveidotu Matrix kontu.", "Server Options": "Servera parametri", "%(ssoButtons)s Or %(usernamePassword)s": "%(ssoButtons)s vai %(usernamePassword)s", "That username already exists, please try another.": "Šis lietotājvārds jau eksistē, mēģiniet citu.", @@ -995,7 +995,7 @@ "Invite people to join %(communityName)s": "Aiciniet cilvēkus pievienoties %(communityName)s", "People you know on %(brand)s": "Cilvēki %(brand)s, kurus jūs pazīstat", "Smileys & People": "Smaidiņi & cilvēki", - "%(count)s people|one": "%(count)s cilvēki", + "%(count)s people|one": "%(count)s cilvēks", "%(count)s people|other": "%(count)s cilvēki", "People": "Cilvēki", "Add a photo, so people can easily spot your room.": "Pievienojiet foto, lai padarītu istabu vieglāk pamanāmu citiem cilvēkiem.", @@ -1073,7 +1073,7 @@ "Show display name changes": "Rādīt parādāmā vārda izmaiņas", "%(displayName)s cancelled verification.": "%(displayName)s atcēla verificēšanu.", "Your display name": "Jūsu parādāmais vārds", - "Add an email address to configure email notifications": "Pievienojiet e-pasta adresi, lai konfigurētu e-pasta paziņojumus", + "Add an email address to configure email notifications": "Pievienojiet epasta adresi, lai konfigurētu epasta paziņojumus", "Enable audible notifications for this session": "Iespējot dzirdamus paziņojumus šai sesijai", "Enable desktop notifications for this session": "Iespējot darbvirsmas paziņojumus šai sesijai", "Enable 'Manage Integrations' in Settings to do this.": "Iespējojiet 'Pārvaldīt integrācijas' iestatījumos, lai to izdarītu.", @@ -1172,8 +1172,8 @@ "Your server requires encryption to be enabled in private rooms.": "Jūsu serveris pieprasa iespējotu šifrēšānu privātās istabās.", "Enable end-to-end encryption": "Iespējot pilnīgu šifrēšanu", "Make this room public": "Padarīt istabu publiski pieejamu", - "Create a private room": "Privātas istabas izveidošana", - "Create a public room": "Publiskas istabas izveidošana", + "Create a private room": "Izveidot privātu istabu", + "Create a public room": "Izveidot publisku istabu", "Add a new server...": "Pievienot jaunu serveri...", "Add a new server": "Pievienot jaunu serveri", "Your homeserver": "Jūsu bāzes serveris", @@ -1188,7 +1188,7 @@ "Show files": "Rādīt failus", "Help & About": "Palīdzība un par lietotni", "About homeservers": "Par bāzes serveriem", - "About": "Detaļas", + "About": "Par", "%(senderName)s placed a voice call. (not supported by this browser)": "%(senderName)s uzsāka balss zvanu. (Netiek atbalstīts šajā pārlūkā)", "%(senderName)s placed a voice call.": "%(senderName)s uzsāka balss zvanu.", "Incoming voice call": "Ienākošais balss zvans", @@ -1277,7 +1277,7 @@ "Already have an account? Sign in here": "Jau ir konts? Pierakstieties šeit", "Continue with %(ssoButtons)s": "Turpināt ar %(ssoButtons)s", "Registration has been disabled on this homeserver.": "Šajā bāzes serverī reģistrācija ir atspējota.", - "Unable to query for supported registration methods.": "Nevar pieprasīt atbalstītās reģistrācijas metodes.", + "Unable to query for supported registration methods.": "Neizdevās pieprasīt atbalstītās reģistrācijas metodes.", "New? Create account": "Pirmā reize? Izveidojiet kontu", "If you've joined lots of rooms, this might take a while": "Ja esat pievienojies daudzām istabām, tas var aizņemt kādu laiku", "Signing In...": "Pierakstīšanās…", @@ -1307,9 +1307,9 @@ "Who can join this community?": "Kas var pievienoties šai kopienai?", "Leave this community": "Pamest šo kopienu", "Join this community": "Pievienoties šai kopienai", - "Unable to leave community": "Neizdodas pamest kopienu", + "Unable to leave community": "Neizdevās pamest kopienu", "You are an administrator of this community. You will not be able to rejoin without an invite from another administrator.": "Jūs esat šīs kopienas administrators. Jūs nevarēsit atkārtoti pievienoties bez cita administratora ielūguma.", - "Unable to join community": "Neizdodas pievienoties kopienai", + "Unable to join community": "Neizdevās pievienoties kopienai", "Create community": "Izveidot kopienu", "Couldn't load page": "Neizdevās ielādēt lapu", "Sign in with SSO": "Pierakstieties, izmantojot SSO", @@ -1330,7 +1330,7 @@ "Enter password": "Ievadiet paroli", "Something went wrong in confirming your identity. Cancel and try again.": "Kaut kas nogāja greizi, mēģinot apstiprināt jūsu identitāti. Atceliet un mēģiniet vēlreiz.", "Session key": "Sesijas atslēga", - "Secure Backup": "Droša reze", + "Secure Backup": "Droša rezerves kopija", "Accept all %(invitedRooms)s invites": "Pieņemt visus %(invitedRooms)s uzaicinājumus", "Bulk options": "Lielapjoma opcijas", "Clear cache and reload": "Notīrīt kešatmiņu un pārlādēt", @@ -1394,5 +1394,193 @@ "Macau": "Makao", "Luxembourg": "Luksemburga", "Lithuania": "Lietuva", - "Latvia": "Latvija" + "Latvia": "Latvija", + "Link to selected message": "Saite uz izvēlēto ziņu", + "Share Room Message": "Dalīties ar istabas ziņu", + "Share Message": "Dalīties ar ziņu", + "Unable to load! Check your network connectivity and try again.": "Ielāde neizdevās! Pārbaudiet interneta savienojumu un mēģiniet vēlreiz.", + "Open": "Atvērt", + "Are you sure you want to sign out?": "Vai tiešām vēlaties izrakstīties?", + "Almost there! Is %(displayName)s showing the same shield?": "Gandrīz galā! Vai %(displayName)s tiek parādīts tas pats vairogs?", + "Almost there! Is your other session showing the same shield?": "Gandrīz galā! Vai jūsu otrā sesijā tiek parādīts tas pats vairogs?", + "Verify by emoji": "Verificēt ar emocijzīmēm", + "Verify by comparing unique emoji.": "Verificēt, salīdzinot unikālās emocijzīmes.", + "If you can't scan the code above, verify by comparing unique emoji.": "Ja nevarat noskenēt kodu, veiciet verifkāciju, salīdzinot unikālās emocijzīmes.", + "Verify this user by confirming the following emoji appear on their screen.": "Verificēt šo lietotāju, apstiprinot, ka sekojošās emocijzīmes pārādās lietotāja ekrānā.", + "Ask %(displayName)s to scan your code:": "Aiciniet %(displayName)s noskenēt jūsu kodu:", + "Verify by scanning": "Verificēt noskenējot", + "%(name)s wants to verify": "%(name)s vēlas veikt verifikāciju", + "Decline All": "Noraidīt visu", + "%(name)s declined": "%(name)s noraidīja", + "You declined": "Jūs noraidījāt", + "Decline (%(counter)s)": "Noraidīt (%(counter)s)", + "Incoming Verification Request": "Ienākošais veifikācijas pieprasījums", + "%(name)s is requesting verification": "%(name)s pieprasa verifikāciju", + "Self-verification request": "Pašverifikācijas pieprasījums", + "Verification Requests": "Verifikācijas pieprasījumi", + "Verification Request": "Verifikācijas pieprasījums", + "Your Security Key is a safety net - you can use it to restore access to your encrypted messages if you forget your Security Phrase.": "Jūsu drošības atslēga ir drošības tīkls - jūs to var izmantot, lai atjaunotu piekļuvi šifrētām ziņām, ja esat aizmirsis savu slepeno frāzi.", + "Store your Security Key somewhere safe, like a password manager or a safe, as it’s used to safeguard your encrypted data.": "Glabājiet drošības atslēgu kaut kur drošā vietā, piemēram, paroļu pārvaldniekā vai seifā, jo tā tiek izmantota jūsu šifrēto datu aizsardzībai.", + "Download": "Lejupielādēt", + "Copy": "Kopēt", + "Activate selected button": "Aktivizēt izvēlēto pogu", + "Currently indexing: %(currentRoom)s": "Pašlaik indeksē: %(currentRoom)s", + "A private space for you and your teammates": "Privāta vieta jums un jūsu komandas dalībniekiem", + "A private space to organise your rooms": "Privāta vieta, kur organizēt jūsu istabas", + "Default Rooms": "Noklusējuma istabas", + "Add existing rooms & spaces": "Pievienot eksistējošas istabas un vietas", + " invites you": " uzaicina jūs", + "%(count)s rooms and 1 space|one": "%(count)s istaba un viena vieta", + "%(count)s rooms and 1 space|other": "%(count)s istabas un 1 vieta", + "%(count)s rooms and %(numSpaces)s spaces|one": "%(count)s istaba un %(numSpaces)s vietas", + "%(count)s rooms and %(numSpaces)s spaces|other": "%(count)s istabas un %(numSpaces)s vietas", + "%(count)s rooms|one": "%(count)s istaba", + "%(count)s rooms|other": "%(count)s istabas", + "Are you sure you want to leave the space '%(spaceName)s'?": "Vai tiešām vēlaties pamest vietu '%(spaceName)s'?", + "Create a Group Chat": "Izveidot grupas čatu", + "Missing session data": "Trūkst sesijas datu", + "Create a new room with the same name, description and avatar": "Izveidot istabu ar to pašu nosaukumu, aprakstu un avataru", + "Email (optional)": "Epasts (izvēles)", + "Invite to %(roomName)s": "Uzaicināt uz %(roomName)s", + "Invite to %(spaceName)s": "Uzaicināt uz %(spaceName)s", + "Abort": "Pārtraukt", + "Add comment": "Pievienot komentāru", + "Continue With Encryption Disabled": "Turpināt ar atspējotu šifrēšanu", + "Create a room in %(communityName)s": "Izveidot istabu kopienā %(communityName)s", + "Add image (optional)": "Pievienot attēlu (izvēles)", + "Add another email": "Pievienot citu epasta adresi", + "Create a new room": "Izveidot jaunu istabu", + "Add existing spaces/rooms": "Pievienot eksistējošas vietas/istabas", + "Are you sure you want to remove %(serverName)s": "Vai tiešām vēlaties dzēst %(serverName)s", + "All rooms": "Visas istabas", + "Continue with %(provider)s": "Turpināt ar %(provider)s", + "%(oneUser)smade no changes %(count)s times|one": "%(oneUser)sneveica nekādas izmaiņas", + "%(oneUser)smade no changes %(count)s times|other": "%(oneUser)sneveica nekādas izmaiņas %(count)s reizes", + "%(severalUsers)smade no changes %(count)s times|one": "%(severalUsers)sneveica nekādas izmaiņas", + "%(severalUsers)smade no changes %(count)s times|other": "%(severalUsers)sneveica nekādas izmaiņas %(count)s reizes", + " reacted with %(content)s": " reaģēja ar %(content)s", + "Declining …": "Noraida …", + "Accepting …": "Akceptē …", + "%(name)s cancelled": "%(name)s atcēla", + "%(name)s cancelled verifying": "%(name)s atcēla verifikāciju", + "Deactivate user": "Deaktivizēt lietotāju", + "Deactivate user?": "Deaktivizēt lietotāju?", + "Demote": "Pazemināt", + "Demote yourself?": "Pazemināt sevi?", + "Accepting…": "Akceptē…", + "%(count)s unread messages.|one": "1 nelasīta ziņa.", + "%(count)s unread messages.|other": "%(count)s nelasītas ziņas.", + "%(count)s unread messages including mentions.|one": "1 neslasīts pieminējums.", + "%(count)s unread messages including mentions.|other": "%(count)s nelasītas ziņas ieskaitot pieminējumus.", + "A-Z": "A-Ž", + "%(roomName)s can't be previewed. Do you want to join it?": "%(roomName)s priekšskatījums nav pieejams. Vai vēlaties tai pievienoties?", + "%(count)s results|one": "%(count)s rezultāts", + "%(count)s results|other": "%(count)s rezultāti", + "Empty room": "Tukša istaba", + "Add existing room": "Pievienot eksistējošu istabu", + "Add room": "Pievienot istabu", + "Invite to this space": "Uzaicināt uz šo vietu", + "A text message has been sent to +%(msisdn)s. Please enter the verification code it contains.": "Teksta ziņa tika nosūtīta uz +%(msisdn)s. Lūdzu, ievadiet tajā esošo verifikācijas kodu.", + "Always show the window menu bar": "Vienmēr parādīt loga izvēlnes joslu", + "Agree to the identity server (%(serverName)s) Terms of Service to allow yourself to be discoverable by email address or phone number.": "Piekrītiet identitāšu servera (%(serverName)s) pakalpojumu sniegšanas noteikumiem, lai padarītu sevi atrodamu citiem, izmantojot epasta adresi vai tālruņa numuru.", + "Add theme": "Pievienot tēmu", + "Algorithm:": "Algoritms:", + "Display Name": "Parādāmais vārds", + "Add some details to help people recognise it.": "Pievienojiet aprakstu, lai palīdzētu cilvēkiem to atpazīt.", + "Create a space": "Izveidot vietu", + "Delete": "Dzēst", + "Accept to continue:": "Akceptēt , lai turpinātu:", + "Anchor": "Enkurs", + "Aeroplane": "Aeroplāns", + "%(senderName)s ended the call": "%(senderName)s pabeidza zvanu", + "A word by itself is easy to guess": "Vārds pats par sevi ir viegli uzminams", + "Add another word or two. Uncommon words are better.": "Papildiniet ar vēl kādiem vārdiem. Netipiski vārdi ir labāk.", + "All-uppercase is almost as easy to guess as all-lowercase": "Visus lielos burtus ir gandrīz tikpat viegli uzminēt kā visus mazos", + "%(name)s (%(userId)s)": "%(name)s (%(userId)s)", + "%(num)s days from now": "%(num)s dienas kopš šī brīža", + "about a day from now": "aptuveni dienu kopš šī brīža", + "%(num)s hours from now": "%(num)s stundas kopš šī brīža", + "about an hour from now": "aptuveni stundu kopš šī brīža", + "%(num)s minutes from now": "%(num)s minūtes kopš šī brīža", + "about a minute from now": "aptuveni minūti kopš šī brīža", + "a few seconds from now": "dažas sekundes kopš šī brīža", + "%(name)s (%(userId)s) signed in to a new session without verifying it:": "%(name)s (%(userId)s) pierakstījās jaunā sesijā, neveicot tās verifikāciju:", + "(an error occurred)": "(notika kļūda)", + "Actions": "Darbības", + "Denmark": "Dānija", + "American Samoa": "Amerikāņu Samoa", + "Algeria": "Alžīrija", + "Verify with another session": "Verificēt ar citu sesiju", + "Original event source": "Oriģinālais notikuma pirmkods", + "Decrypted event source": "Atšifrēt notikuma pirmkods", + "Removing...": "Dzēš…", + "You don't have permission": "Jums nav atļaujas", + "You do not have permission to create rooms in this community.": "Jums nav atļaujas veidot istabas šajā kopienā.", + "Attach files from chat or just drag and drop them anywhere in a room.": "Pievienojiet failus no čata vai vienkārši velciet un nometiet tos jebkur istabā.", + "No files visible in this room": "Šajā istabā nav redzamu failu", + "Remove for everyone": "Dzēst visiem", + "Verify other session": "Verificēt citu sesiju", + "Share User": "Dalīties ar lietotāja kontaktdatiem", + "Verify session": "Verificēt sesiju", + "Verifying this device will mark it as trusted, and users who have verified with you will trust this device.": "Verificējot šo ierīci, tā tiks atzīmēta kā uzticama, un ierīci verificējušie lietotāji tai uzticēsies.", + "Verifying this user will mark their session as trusted, and also mark your session as trusted to them.": "Verificējot šo lietotāju, tā sesija tiks atzīmēta kā uzticama, kā arī jūsu sesija viņiem tiks atzīmēta kā uzticama.", + "Removing…": "Dzēš…", + "Remove server": "Dzēst serveri", + "Homeserver": "Bāzes serveris", + "Use the Desktop app to see all encrypted files": "Lietojiet Desktop lietotni, lai apskatītu visus šifrētos failus", + "Room ID": "Istabas ID", + "edited": "rediģēts", + "Edited at %(date)s. Click to view edits.": "Rediģēts %(date)s. Noklikšķiniet, lai skatītu redakcijas.", + "Edited at %(date)s": "Rediģēts %(date)s", + "You cancelled": "Jūs atcēlāt", + "You cancelled verifying %(name)s": "Jūs atvēlāt %(name)s verifikāciju", + "You cancelled verification.": "Jūs atcēlāt verifikāciju.", + "You cancelled verification on your other session.": "Jūs atcēlāt verifikāciju citā savā sesijā.", + "Edit devices": "Rediģēt ierīces", + "Remove %(count)s messages|one": "Dzēst 1 ziņu", + "Remove %(count)s messages|other": "Dzēst %(count)s ziņas", + "For a large amount of messages, this might take some time. Please don't refresh your client in the meantime.": "Lielam ziņu apjomam tas var aizņemt kādu laiku. Lūdzu, tikmēr neatsvaidziniet klientu.", + "You are about to remove %(count)s messages by %(user)s. This cannot be undone. Do you wish to continue?|one": "Tiks neatgriezeniski dzēsta 1 ziņa no %(user)s. Vai vēlaties turpināt?", + "You are about to remove %(count)s messages by %(user)s. This cannot be undone. Do you wish to continue?|other": "Tiks neatgriezeniski dzēsta %(count)s ziņas no %(user)s. Vai vēlaties turpināt?", + "You don't have permission to delete the address.": "Jums nav atļaujas dzēst adresi.", + "Add some now": "Pievienot kādu tagad", + "You don't currently have any stickerpacks enabled": "Neviena uzlīmju paka nav iespējota", + "This invite to %(roomName)s was sent to %(email)s": "Šis uzaicinājums uz %(roomName)s tika nosūtīts %(email)s", + "This invite to %(roomName)s was sent to %(email)s which is not associated with your account": "Šis uzaicinājums uz %(roomName)s tika nosūtīts %(email)s, kas nav saistīts ar jūsu kontu", + "Your message was sent": "Jūsu ziņa ir nosūtīta", + "Remove %(phone)s?": "Dzēst %(phone)s?", + "Remove %(email)s?": "Dēst %(email)s?", + "Waiting for %(displayName)s to verify…": "Gaida uz %(displayName)s, lai verificētu…", + "Verify this user by confirming the following number appears on their screen.": "Verificēt šo lietotāju, apstiprinot, ka šāds numurs pārādās lietotāja ekrānā.", + "Verify this session by confirming the following number appears on its screen.": "Verificējiet šo sesiju, apstiprinot, ka tās ekrānā parādās šāds numurs.", + "You ended the call": "Jūs pabeidzāt zvanu", + "Other users may not trust it": "Citi lietotāji var neuzskatīt to par uzticamu", + "Verify": "Verificēt", + "Verify this session": "Verificēt šo sesiju", + "You signed in to a new session without verifying it:": "Jūs pierakstījāties jaunā sesijā, neveicot tās verifikāciju:", + "You're already in a call with this person.": "Jums jau notiek zvans ar šo personu.", + "Already in call": "Notiek zvans", + "%(deviceId)s from %(ip)s": "%(deviceId)s no %(ip)s", + "%(count)s people you know have already joined|other": "%(count)s pazīstami cilvēki ir jau pievienojusies", + "%(count)s people you know have already joined|one": "%(count)s pazīstama persona ir jau pievienojusies", + "Saving...": "Saglabā…", + "%(count)s members|one": "%(count)s dalībnieks", + "Save Changes": "Saglabāt izmaiņas", + "%(count)s messages deleted.|other": "%(count)s ziņas ir dzēstas.", + "%(count)s messages deleted.|one": "%(count)s ziņa ir dzēsta.", + "Welcome to ": "Laipni lūdzam uz ", + "Room name": "Istabas nosaukums", + "%(count)s members|other": "%(count)s dalībnieki", + "Room List": "Istabu saraksts", + "Send as message": "Nosūtīt kā ziņu", + "%(brand)s URL": "%(brand)s URL", + "Send a message…": "Nosūtīt ziņu…", + "Send a reply…": "Nosūtīt atbildi…", + "Room version": "Istabas versija", + "Room list": "Istabu saraksts", + "Failed to set topic": "Neizdevās iestatīt tematu", + "Upload files": "Failu augšupielāde", + "These files are too large to upload. The file size limit is %(limit)s.": "Šie faili pārsniedz augšupielādes izmēra limitu %(limit)s.", + "Upload files (%(current)s of %(total)s)": "Failu augšupielāde (%(current)s no %(total)s)", + "Check your devices": "Pārskatiet savas ierīces" } diff --git a/src/i18n/strings/ml.json b/src/i18n/strings/ml.json index 23740fefda..6183fe7de2 100644 --- a/src/i18n/strings/ml.json +++ b/src/i18n/strings/ml.json @@ -127,5 +127,8 @@ "Failed to change settings": "സജ്ജീകരണങ്ങള്‍ മാറ്റുന്നവാന്‍ സാധിച്ചില്ല", "View Source": "സോഴ്സ് കാണുക", "With your current browser, the look and feel of the application may be completely incorrect, and some or all features may not function. If you want to try it anyway you can continue, but you are on your own in terms of any issues you may encounter!": "നിങ്ങളുടെ ഇപ്പോളത്തെ ബ്രൌസര്‍ റയട്ട് പ്രവര്‍ത്തിപ്പിക്കാന്‍ പൂര്‍ണമായും പര്യാപത്മല്ല. പല ഫീച്ചറുകളും പ്രവര്‍ത്തിക്കാതെയിരിക്കാം. ഈ ബ്രൌസര്‍ തന്നെ ഉപയോഗിക്കണമെങ്കില്‍ മുന്നോട്ട് പോകാം. പക്ഷേ നിങ്ങള്‍ നേരിടുന്ന പ്രശ്നങ്ങള്‍ നിങ്ങളുടെ ഉത്തരവാദിത്തത്തില്‍ ആയിരിക്കും!", - "Checking for an update...": "അപ്ഡേറ്റ് ഉണ്ടോ എന്ന് തിരയുന്നു..." + "Checking for an update...": "അപ്ഡേറ്റ് ഉണ്ടോ എന്ന് തിരയുന്നു...", + "Explore rooms": "മുറികൾ കണ്ടെത്തുക", + "Sign In": "പ്രവേശിക്കുക", + "Create Account": "അക്കൗണ്ട് സൃഷ്ടിക്കുക" } diff --git a/src/i18n/strings/mn.json b/src/i18n/strings/mn.json index 0967ef424b..5e44298332 100644 --- a/src/i18n/strings/mn.json +++ b/src/i18n/strings/mn.json @@ -1 +1,6 @@ -{} +{ + "Explore rooms": "Өрөөнүүд үзэх", + "Sign In": "Нэвтрэх", + "Create Account": "Хэрэглэгч үүсгэх", + "Dismiss": "Орхих" +} diff --git a/src/i18n/strings/nb_NO.json b/src/i18n/strings/nb_NO.json index 6388983f77..d3be9cd2ea 100644 --- a/src/i18n/strings/nb_NO.json +++ b/src/i18n/strings/nb_NO.json @@ -82,7 +82,7 @@ "Yesterday": "I går", "Low Priority": "Lav Prioritet", "%(brand)s does not know how to join a room on this network": "%(brand)s vet ikke hvordan man kan komme inn på et rom på dette nettverket", - "An error occurred whilst saving your email notification preferences.": "En feil oppsto i forbindelse med lagring av epost varsel innstillinger.", + "An error occurred whilst saving your email notification preferences.": "En feil oppsto i forbindelse med lagring av innstillinger for e-postvarsel.", "remove %(name)s from the directory.": "fjern %(name)s fra katalogen.", "Off": "Av", "Failed to remove tag %(tagName)s from room": "Kunne ikke fjerne tagg %(tagName)s fra rommet", @@ -116,7 +116,7 @@ "You cannot place VoIP calls in this browser.": "Du kan ikke ringe via VoIP i denne nettleseren.", "You cannot place a call with yourself.": "Du kan ikke ringe deg selv.", "Call in Progress": "Samtale pågår", - "A call is currently being placed!": "En samtale holder allerede på å starte", + "A call is currently being placed!": "En samtale holder på å starte!", "A call is already in progress!": "En samtale er allerede i gang!", "Permission Required": "Tillatelse kreves", "You do not have permission to start a conference call in this room": "Du har ikke tillatelse til å starte en konferansesamtale i dette rommet", @@ -167,8 +167,8 @@ "%(brand)s does not have permission to send you notifications - please check your browser settings": "%(brand)s har ikke tillatelse til å sende deg varsler - vennligst sjekk nettleserinnstillingene", "%(brand)s was not given permission to send notifications - please try again": "%(brand)s fikk ikke tillatelse til å sende deg varsler - vennligst prøv igjen", "Unable to enable Notifications": "Klarte ikke slå på Varslinger", - "This email address was not found": "Denne e-post adressen ble ikke funnet", - "Your email address does not appear to be associated with a Matrix ID on this Homeserver.": "E-post adressen din ser ikke ut til å være koplet til en Matrix-ID på denne hjemmetjeneren.", + "This email address was not found": "Denne e-postadressen ble ikke funnet", + "Your email address does not appear to be associated with a Matrix ID on this Homeserver.": "E-postadressen din ser ikke ut til å være koplet til en Matrix-ID på denne hjemmetjeneren.", "Register": "Registrer", "Default": "Standard", "Restricted": "Begrenset", @@ -494,7 +494,7 @@ "Logout": "Logg ut", "Preview": "Forhåndsvisning", "View": "Vis", - "Explore rooms": "Utforsk rom", + "Explore rooms": "Se alle rom", "Room": "Rom", "Clear filter": "Tøm filtret", "Guest": "Gjest", @@ -590,7 +590,7 @@ "Change identity server": "Bytt ut identitetstjener", "You should:": "Du burde:", "Identity Server": "Identitetstjener", - "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.": "Å bruke en identitetstjener er valgfritt. Dersom du velger å ikke en identitetstjener, vil du ikke kunne oppdages av andre brukere, og du vil ikke kunne invitere andre ut i fra E-postadresse eller telefonnummer.", + "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.": "Å bruke en identitetstjener er valgfritt. Dersom du velger å ikke bruke en identitetstjener, vil du ikke kunne oppdages av andre brukere, og du vil ikke kunne invitere andre ut i fra E-postadresse eller telefonnummer.", "Do not use an identity server": "Ikke bruk en identitetstjener", "Use an Integration Manager (%(serverName)s) to manage bots, widgets, and sticker packs.": "Bruk en integreringsbehandler (%(serverName)s) til å behandle botter, moduler, og klistremerkepakker.", "Use an Integration Manager to manage bots, widgets, and sticker packs.": "Bruk en integreringsbehandler til å behandle botter, moduler, og klistremerkepakker.", @@ -1038,7 +1038,7 @@ "Kick this user?": "Vil du sparke ut denne brukeren?", "No pinned messages.": "Ingen klistrede meldinger.", "Pinned Messages": "Klistrede meldinger", - "Unpin Message": "Avklistre meldingen", + "Unpin Message": "Løsne meldingen", "Try to join anyway": "Forsøk å bli med likevel", "%(count)s unread messages including mentions.|one": "1 ulest nevnelse.", "%(count)s unread messages.|other": "%(count)s uleste meldinger.", @@ -1441,5 +1441,545 @@ "User menu": "Brukermeny", "Use Recovery Key": "Bruk gjenopprettingsnøkkel", "%(brand)s iOS": "%(brand)s iOS", - "%(brand)s Android": "%(brand)s Android" + "%(brand)s Android": "%(brand)s Android", + "Add image (optional)": "Legg til bilde (valgfritt)", + "Enter name": "Skriv navn", + "Please select the destination room for this message": "Vennligst velg mottagerrom for denne meldingen", + "Your message was sent": "Meldingen ble sendt", + "Encrypting your message...": "Krypterer meldingen...", + "Sending your message...": "Sender meldingen...", + "The authenticity of this encrypted message can't be guaranteed on this device.": "Autentisiteten av denne krypterte meldingen kan ikke garanteres på denne enheten.", + "Encrypted by a deleted session": "Kryptert av en slettet sesjon", + "Jordan": "Jordan", + "Jersey": "Jersey", + "Japan": "Japan", + "Italy": "Italia", + "Israel": "Israel", + "Ireland": "Irland", + "Iraq": "Irak", + "Indonesia": "Indonesia", + "Iran": "Iran", + "India": "India", + "Backup could not be decrypted with this Security Key: please verify that you entered the correct Security Key.": "Sikkerhetskopien kunne ikke dekrypteres med denne sikkerhetsnøkkelen: Vennligst verifiser at du tastet korrekt sikkerhetsnøkkel.", + "Security Key mismatch": "Sikkerhetsnøkkel uoverensstemmelse", + "Unable to load backup status": "Klarte ikke å laste sikkerhetskopi-status", + "%(completed)s of %(total)s keys restored": "%(completed)s av %(total)s nøkler gjenopprettet", + "Revoke permissions": "Trekk tilbake rettigheter", + "Could not revoke the invite. The server may be experiencing a temporary problem or you do not have sufficient permissions to revoke the invite.": "Klarte ikke å trekke tilbake invitasjonen. Tjener kan ha et forbigående problem, eller det kan hende at du ikke har tilstrekkelige rettigheter for å trekke tilbake invitasjonen.", + "Failed to revoke invite": "Klarte ikke å trekke tilbake invitasjon", + "Unable to revoke sharing for phone number": "Klarte ikke trekke tilbake deling for telefonnummer", + "Unable to revoke sharing for email address": "Klarte ikke å trekke tilbake deling for denne e-postadressen", + "%(senderName)s revoked the invitation for %(targetDisplayName)s to join the room.": "%(senderName)s trakk tilbake invitasjonen dette rommet for %(targetDisplayName)s.", + "Unpin a widget to view it in this panel": "Løsne en widget for å se den i dette panelet", + "Unpin": "Løsne", + "Mentions & Keywords": "Der du nevnes & nøkkelord", + "Great, that'll help people know it's you": "Flott, det vil hjelp folk å ha tillit til at det er deg", + "Put a link back to the old room at the start of the new room so people can see old messages": "Legg inn en lenke tilbake til det gamle rommet i starten av det nye rommet slik at folk kan finne eldre meldinger", + "People you know on %(brand)s": "Folk du kjenner i %(brand)s", + "Messages in this room are end-to-end encrypted. When people join, you can verify them in their profile, just tap on their avatar.": "Meldinger i dette rommer er ende-til-ende-kryptert. Når folk kommer med kan du verifisere dem ved klikke på avataren i profilen deres.", + "%(count)s people|one": "%(count)s person", + "Invite People": "Inviter Personer", + "Add a photo, so people can easily spot your room.": "Legg til et bilde så folk lettere kan finne rommet ditt.", + "Add a topic to help people know what it is about.": "Legg til et tema for hjelpe folk å forstå hva dette handler om.", + "Invite people": "Inviter personer", + "Add some details to help people recognise it.": "Legg til mer detaljer for å gjøre det letter å gjenkjenne.", + "You do not have permission to invite people to this room.": "Du har ikke tilgang til å invitere personer til dette rommet.", + "Click the button below to confirm adding this email address.": "Klikk på knappen under for å bekrefte at du vil legge til denne e-postadressen.", + "Hey you. You're the best!": "Hei der. Du er fantastisk!", + "Use custom size": "Bruk tilpasset størrelse", + "Use Single Sign On to continue": "Bruk Single Sign On for å fortsette", + "Appearance Settings only affect this %(brand)s session.": "Stilendringer gjelder kun i denne %(brand)s sesjonen.", + "Use Ctrl + Enter to send a message": "Bruk Ctrl + Enter for å sende en melding", + "Use Ctrl + F to search": "Bruk Ctrl + F for å søke", + "%(count)s people|other": "%(count)s personer", + "%(count)s unread messages including mentions.|other": "%(count)s uleste meldinger inkludert der du nevnes.", + "Creating...": "Oppretter...", + "User settings": "Brukerinnstillinger", + "Open": "Åpne", + "Try using one of the following valid address types: %(validTypesList)s.": "Prøv å bruke en av følgende gyldige adresser: %(validTypesList)s.", + "The user '%(displayName)s' could not be removed from the summary.": "Brukeren '%(displayName)s' kunne ikke fjernes fra oversikten.", + "Belgium": "Belgia", + "American Samoa": "Amerikansk Samoa", + "United States": "USA", + "%(name)s is requesting verification": "%(name)s ber om verifisering", + "Try again": "Prøv igjen", + "We couldn't log you in": "Vi kunne ikke logge deg inn", + "This will end the conference for everyone. Continue?": "Dette vil avslutte konferansen for alle. Fortsett?", + "End conference": "Avslutt konferanse", + "You're already in a call with this person.": "Du er allerede i en samtale med denne personen.", + "Already in call": "Allerede i en samtale", + "Are you sure you want to remove '%(roomName)s' from %(groupId)s?": "Er du sikke på at du vil fjerne '%(roomName)s' fra %(groupId)s?", + "Burundi": "Burundi", + "Burkina Faso": "Burkina Faso", + "Bulgaria": "Bulgaria", + "Brunei": "Brunei", + "Brazil": "Brazil", + "Botswana": "Botswana", + "Bolivia": "Bolivia", + "Bhutan": "Bhutan", + "Bermuda": "Bermuda", + "Benin": "Benin", + "Belize": "Belize", + "Belarus": "Hviterussland", + "Barbados": "Barbados", + "Bangladesh": "Bangladesh", + "Bahrain": "Bahrain", + "Bahamas": "Bahamas", + "Azerbaijan": "Azerbaijan", + "Austria": "Østerrike", + "Australia": "Australia", + "Aruba": "Aruba", + "Armenia": "Armenia", + "Argentina": "Argentina", + "Antigua & Barbuda": "Antigua og Barbuda", + "Antarctica": "Antarktis", + "Anguilla": "Anguilla", + "Angola": "Angola", + "Andorra": "Andorra", + "Algeria": "Algeria", + "Albania": "Albania", + "Åland Islands": "Åland", + "Afghanistan": "Afghanistan", + "United Kingdom": "Storbritannia", + "Your homeserver was unreachable and was not able to log you in. Please try again. If this continues, please contact your homeserver administrator.": "Din hjemmeserver kunne ikke nås, og kan derfor ikke logge deg inn. Vennligst prøv igjen. Hvis dette fortsetter, kontakt administratoren til din hjemmeserver.", + "Only continue if you trust the owner of the server.": "Fortsett kun om du stoler på eieren av serveren.", + "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.": "Denne handlingen krever tilgang til standard identitetsserver for å kunne validere en epostaddresse eller telefonnummer, men serveren har ikke bruksvilkår.", + "Too Many Calls": "For mange samtaler", + "Call failed because webcam or microphone could not be accessed. Check that:": "Samtalen mislyktes fordi du fikk ikke tilgang til webkamera eller mikrofon. Sørg for at:", + "Unable to access webcam / microphone": "Ingen tilgang til webkamera / mikrofon", + "The call was answered on another device.": "Samtalen ble besvart på en annen enhet.", + "The call could not be established": "Samtalen kunne ikke etableres", + "The other party declined the call.": "Den andre parten avviste samtalen.", + "Call Declined": "Samtale avvist", + "Click the button below to confirm adding this phone number.": "Klikk knappen nedenfor for å bekrefte dette telefonnummeret.", + "Single Sign On": "Single Sign On", + "Confirm adding this phone number by using Single Sign On to prove your identity.": "Bekreft dette telefonnummeret ved å bruke Single Sign On for å bevise din identitet.", + "Confirm adding this email address by using Single Sign On to prove your identity.": "Befrekt denne e-postadressen ved å bruke Single Sign On for å bevise din identitet.", + "Show stickers button": "Vis klistremerkeknappen", + "Recently visited rooms": "Nylig besøkte rom", + "Windows": "Vinduer", + "Abort": "Avbryt", + "You have unverified logins": "Du har uverifiserte pålogginger", + "Check your devices": "Sjekk enhetene dine", + "Record a voice message": "Send en stemmebeskjed", + "Edit devices": "Rediger enheter", + "Homeserver": "Hjemmetjener", + "Edit Values": "Rediger verdier", + "Add existing room": "Legg til et eksisterende rom", + "Spell check dictionaries": "Stavesjekk-ordbøker", + "Invite to this space": "Inviter til dette området", + "Send message": "Send melding", + "Cookie Policy": "Infokapselretningslinjer", + "Invite to %(roomName)s": "Inviter til %(roomName)s", + "Resume": "Fortsett", + "Avatar": "Profilbilde", + "A confirmation email has been sent to %(emailAddress)s": "En bekreftelses-E-post har blitt sendt til %(emailAddress)s", + "Suggested Rooms": "Foreslåtte rom", + "Welcome %(name)s": "Velkommen, %(name)s", + "Upgrade to %(hostSignupBrand)s": "Oppgrader til %(hostSignupBrand)s", + "Verification requested": "Verifisering ble forespurt", + "%(count)s members|one": "%(count)s medlem", + "Removing...": "Fjerner …", + "No results found": "Ingen resultater ble funnet", + "Public space": "Offentlig område", + "Private space": "Privat område", + "Support": "Support", + "What projects are you working on?": "Hvilke prosjekter jobber du på?", + "Suggested": "Anbefalte", + "%(deviceId)s from %(ip)s": "%(deviceId)s fra %(ip)s", + "Accept on your other login…": "Aksepter på din andre pålogging …", + "Value:": "Verdi:", + "Leave Space": "Forlat området", + "View dev tools": "Vis utviklerverktøy", + "Saving...": "Lagrer …", + "Save Changes": "Lagre endringer", + "Verify other login": "Verifiser en annen pålogging", + "You don't have permission": "Du har ikke tillatelse", + "%(count)s rooms|other": "%(count)s rom", + "%(count)s rooms|one": "%(count)s rom", + "Invite by username": "Inviter etter brukernavn", + "Delete": "Slett", + "Your public space": "Ditt offentlige område", + "Your private space": "Ditt private område", + "Invite to %(spaceName)s": "Inviter til %(spaceName)s", + "%(count)s members|other": "%(count)s medlemmer", + "Random": "Tilfeldig", + "unknown person": "ukjent person", + "Public": "Offentlig", + "Private": "Privat", + "Click to copy": "Klikk for å kopiere", + "Share invite link": "Del invitasjonslenke", + "Leave space": "Forlat området", + "Warn before quitting": "Advar før avslutning", + "Quick actions": "Hurtigvalg", + "Screens": "Skjermer", + "%(count)s people you know have already joined|other": "%(count)s personer du kjenner har allerede blitt med", + "Add existing rooms": "Legg til eksisterende rom", + "Don't want to add an existing room?": "Vil du ikke legge til et eksisterende rom?", + "Create a new room": "Opprett et nytt rom", + "Adding...": "Legger til …", + "Settings Explorer": "Innstillingsutforsker", + "Value": "Verdi", + "Setting:": "Innstilling:", + "Caution:": "Advarsel:", + "Level": "Nivå", + "Privacy Policy": "Personvern", + "You should know": "Du bør vite", + "Room name": "Rommets navn", + "Skip for now": "Hopp over for nå", + "Creating rooms...": "Oppretter rom …", + "Share %(name)s": "Del %(name)s", + "Just me": "Bare meg selv", + "Inviting...": "Inviterer …", + "Please choose a strong password": "Vennligst velg et sterkt passord", + "New? Create account": "Er du ny her? Opprett en konto", + "Use another login": "Bruk en annen pålogging", + "Use Security Key or Phrase": "Bruk sikkerhetsnøkkel eller -frase", + "Use Security Key": "Bruk sikkerhetsnøkkel", + "Upgrade private room": "Oppgrader privat rom", + "Upgrade public room": "Oppgrader offentlig rom", + "Decline All": "Avslå alle", + "Enter Security Key": "Skriv inn sikkerhetsnøkkel", + "Germany": "Tyskland", + "Malta": "Malta", + "Uruguay": "Uruguay", + "Community settings": "Fellesskapsinnstillinger", + "You’re all caught up": "Du har lest deg opp på alt det nye", + "Remember this": "Husk dette", + "Move right": "Gå til høyre", + "Notify the whole room": "Varsle hele rommet", + "Got an account? Sign in": "Har du en konto? Logg på", + "You created this room.": "Du opprettet dette rommet.", + "Security Phrase": "Sikkerhetsfrase", + "Start a Conversation": "Start en samtale", + "Open dial pad": "Åpne nummerpanelet", + "Message deleted on %(date)s": "Meldingen ble slettet den %(date)s", + "Approve": "Godkjenn", + "Create community": "Opprett fellesskap", + "Already have an account? Sign in here": "Har du allerede en konto? Logg på", + "%(ssoButtons)s Or %(usernamePassword)s": "%(ssoButtons)s eller %(usernamePassword)s", + "That username already exists, please try another.": "Det brukernavnet finnes allerede, vennligst prøv et annet et", + "New here? Create an account": "Er du ny her? Opprett en konto", + "Now, let's help you get started": "Nå, la oss hjelpe deg med å komme i gang", + "Forgot password?": "Glemt passord?", + "Enter email address": "Legg inn e-postadresse", + "Enter phone number": "Skriv inn telefonnummer", + "Please enter the code it contains:": "Vennligst skriv inn koden den inneholder:", + "Token incorrect": "Sjetongen er feil", + "A text message has been sent to %(msisdn)s": "En SMS har blitt sendt til %(msisdn)s", + "Open the link in the email to continue registration.": "Åpne lenken i E-posten for å fortsette registreringen.", + "This room is public": "Dette rommet er offentlig", + "Move left": "Gå til venstre", + "Take a picture": "Ta et bilde", + "Hold": "Hold", + "Enter Security Phrase": "Skriv inn sikkerhetsfrase", + "Security Key": "Sikkerhetsnøkkel", + "Invalid Security Key": "Ugyldig sikkerhetsnøkkel", + "Wrong Security Key": "Feil sikkerhetsnøkkel", + "About homeservers": "Om hjemmetjenere", + "New Recovery Method": "Ny gjenopprettingsmetode", + "Generate a Security Key": "Generer en sikkerhetsnøkkel", + "Confirm your Security Phrase": "Bekreft sikkerhetsfrasen din", + "Your Security Key": "Sikkerhetsnøkkelen din", + "Repeat your Security Phrase...": "Gjenta sikkerhetsfrasen din", + "Set up with a Security Key": "Sett opp med en sikkerhetsnøkkel", + "Use app": "Bruk app", + "Learn more": "Lær mer", + "Use app for a better experience": "Bruk appen for en bedre opplevelse", + "Continue with %(provider)s": "Fortsett med %(provider)s", + "This address is already in use": "Denne adressen er allerede i bruk", + "In reply to ": "Som svar på ", + "%(oneUser)schanged their name %(count)s times|other": "%(oneUser)sendret navnet sitt %(count)s ganger", + "%(oneUser)shad their invitation withdrawn %(count)s times|one": "%(oneUser)sfikk sin invitasjon trukket tilbake", + "%(severalUsers)shad their invitations withdrawn %(count)s times|one": "%(severalUsers)sfikk sine invitasjoner trukket tilbake", + "%(oneUser)srejected their invitation %(count)s times|one": "%(oneUser)savslo invitasjonen sin", + "%(oneUser)sleft and rejoined %(count)s times|one": "%(oneUser)sforlot og ble med igjen", + "%(severalUsers)sleft and rejoined %(count)s times|one": "%(severalUsers)sforlot og ble med igjen", + "%(oneUser)sjoined and left %(count)s times|one": "%(oneUser)sble med og forlot igjen", + "%(severalUsers)sjoined and left %(count)s times|one": "%(severalUsers)sble med og forlot igjen", + "Information": "Informasjon", + "Add rooms to this community": "Legg til rom i dette fellesskapet", + "%(name)s cancelled verifying": "%(name)s avbrøt verifiseringen", + "You cancelled verifying %(name)s": "Du avbrøt verifiseringen av %(name)s", + "Invalid file%(extra)s": "Ugyldig fil%(extra)s", + "Failed to ban user": "Mislyktes i å bannlyse brukeren", + "Room settings": "Rominnstillinger", + "Show files": "Vis filer", + "Not encrypted": "Ikke kryptert", + "About": "Om", + "Widgets": "Komponenter", + "Room Info": "Rominfo", + "Favourited": "Favorittmerket", + "Forget Room": "Glem rommet", + "Show previews of messages": "Vis forhåndsvisninger av meldinger", + "Invalid URL": "Ugyldig URL", + "Continuing without email": "Fortsetter uten E-post", + "Are you sure you want to sign out?": "Er du sikker på at du vil logge av?", + "Transfer": "Overfør", + "Invite by email": "Inviter gjennom E-post", + "Waiting for partner to confirm...": "Venter på at partneren skal bekrefte …", + "Report a bug": "Rapporter en feil", + "Comment": "Kommentar", + "Add comment": "Legg til kommentar", + "Active Widgets": "Aktive moduler", + "Create a room in %(communityName)s": "Opprett et rom i %(communityName)s", + "Reason (optional)": "Årsak (valgfritt)", + "Send %(count)s invites|one": "Send %(count)s invitasjon", + "Send %(count)s invites|other": "Send %(count)s invitasjoner", + "Add another email": "Legg til en annen E-postadresse", + "%(count)s results|one": "%(count)s resultat", + "%(count)s results|other": "%(count)s resultater", + "Start a new chat": "Start en ny chat", + "Custom Tag": "Egendefinert merkelapp", + "Explore public rooms": "Utforsk offentlige rom", + "Explore community rooms": "Utforsk samfunnsrom", + "Invite to this community": "Inviter til dette fellesskapet", + "Verify the link in your inbox": "Verifiser lenken i innboksen din", + "Bridges": "Broer", + "Privacy": "Personvern", + "Reject all %(invitedRooms)s invites": "Avslå alle %(invitedRooms)s-invitasjoner", + "Upgrade Room Version": "Oppgrader romversjon", + "You cancelled verification.": "Du avbrøt verifiseringen.", + "Ask %(displayName)s to scan your code:": "Be %(displayName)s om å skanne koden:", + "Role": "Rolle", + "Failed to deactivate user": "Mislyktes i å deaktivere brukeren", + "Accept all %(invitedRooms)s invites": "Aksepter alle %(invitedRooms)s-invitasjoner", + "": "", + "Custom theme URL": "URL-en til et selvvalgt tema", + "not ready": "ikke klar", + "ready": "klar", + "Algorithm:": "Algoritme:", + "Backing up %(sessionsRemaining)s keys...": "Sikkerhetskopierer %(sessionsRemaining)s nøkler …", + "Away": "Borte", + "Start chat": "Start chat", + "Show Widgets": "Vis moduler", + "Hide Widgets": "Skjul moduler", + "Unknown for %(duration)s": "Ukjent i %(duration)s", + "Update %(brand)s": "Oppdater %(brand)s", + "You are currently ignoring:": "Du ignorerer for øyeblikket:", + "Unknown caller": "Ukjent oppringer", + "Dial pad": "Nummerpanel", + "%(name)s on hold": "%(name)s står på vent", + "Fill Screen": "Fyll skjermen", + "Voice Call": "Taleanrop", + "Video Call": "Videoanrop", + "sends confetti": "sender konfetti", + "System font name": "Systemskrifttypenavn", + "Use a system font": "Bruk en systemskrifttype", + "Waiting for answer": "Venter på svar", + "Call in progress": "Anrop pågår", + "Channel: ": "Kanal: ", + "Enable desktop notifications": "Aktiver skrivebordsvarsler", + "Don't miss a reply": "Ikke gå glipp av noen svar", + "Help us improve %(brand)s": "Hjelp oss å forbedre %(brand)s", + "Unknown App": "Ukjent app", + "Short keyboard patterns are easy to guess": "Korte tastatur mønstre er lett å gjette", + "This is similar to a commonly used password": "Dette ligner på et passord som er brukt mye", + "Predictable substitutions like '@' instead of 'a' don't help very much": "Forutsigbar erstatninger som ‘ @‘ istedet for ‘a’ hjelper ikke mye", + "Reversed words aren't much harder to guess": "Ord som er skrevet baklengs er vanskeligere å huske.", + "All-uppercase is almost as easy to guess as all-lowercase": "Bare store bokstaver er nesten like enkelt å gjette som bare små bokstaver", + "Capitalization doesn't help very much": "Store bokstaver er ikke spesielt nyttig", + "Use a longer keyboard pattern with more turns": "Bruke et lengre og mer uventet tastatur mønster", + "No need for symbols, digits, or uppercase letters": "Ikke nødvendig med symboler, sifre eller bokstaver", + "See images posted to this room": "Se bilder som er lagt ut i dette rommet", + "%(senderName)s declined the call.": "%(senderName)s avslo oppringingen.", + "(an error occurred)": "(en feil oppstod)", + "(connection failed)": "(tilkobling mislyktes)", + "Change the topic of this room": "Endre dette rommets tema", + "Effects": "Effekter", + "Zimbabwe": "Zimbabwe", + "Yemen": "Jemen", + "Zambia": "Zambia", + "Western Sahara": "Vest-Sahara", + "Wallis & Futuna": "Wallis og Futuna", + "Venezuela": "Venezuela", + "Vietnam": "Vietnam", + "Vatican City": "Vatikanstaten", + "Vanuatu": "Vanuatu", + "Uzbekistan": "Usbekistan", + "United Arab Emirates": "De forente arabiske emirater", + "Ukraine": "Ukraina", + "U.S. Virgin Islands": "De amerikanske jomfruøyene", + "Uganda": "Uganda", + "Tuvalu": "Tuvalu", + "Turks & Caicos Islands": "Turks- og Caicosøyene", + "Turkmenistan": "Turkmenistan", + "Tunisia": "Tunis", + "Turkey": "Tyrkia", + "Trinidad & Tobago": "Trinidad og Tobago", + "Tonga": "Tonga", + "Tokelau": "Tokelau", + "Togo": "Togo", + "Timor-Leste": "Timor-Leste", + "Thailand": "Thailand", + "Tanzania": "Tanzania", + "Tajikistan": "Tadsjikistan", + "Taiwan": "Taiwan", + "São Tomé & Príncipe": "São Tomé og Príncipe", + "Syria": "Syria", + "Sweden": "Sverige", + "Switzerland": "Sveits", + "Swaziland": "Swaziland", + "Svalbard & Jan Mayen": "Svalbard og Jan Mayen", + "Suriname": "Surinam", + "Sudan": "Sudan", + "St. Vincent & Grenadines": "St. Vincent og Grenadinene", + "St. Kitts & Nevis": "St. Kitts og Nevis", + "St. Helena": "St. Helena", + "Sri Lanka": "Sri Lanka", + "Spain": "Spania", + "South Sudan": "Sør-Sudan", + "South Korea": "Syd-Korea", + "Somalia": "Somalia", + "South Africa": "Sør-Afrika", + "Solomon Islands": "Solomonøyene", + "Slovenia": "Slovenia", + "Slovakia": "Slovakia", + "Sint Maarten": "Sint Maarten", + "Singapore": "Singapore", + "Sierra Leone": "Sierra Leone", + "Seychelles": "Seyschellene", + "Serbia": "Serbia", + "Saudi Arabia": "Saudi-Arabia", + "Senegal": "Senegal", + "San Marino": "San Marino", + "Samoa": "Samoa", + "Réunion": "Réunion", + "Rwanda": "Rwanda", + "Russia": "Russland", + "Qatar": "Qatar", + "Romania": "Romania", + "Puerto Rico": "Puerto Rico", + "Portugal": "Portugal", + "Poland": "Polen", + "Pitcairn Islands": "Pitcairn-øyene", + "Philippines": "Filippinene", + "Peru": "Peru", + "Papua New Guinea": "Papua New Guinea", + "Paraguay": "Paraguay", + "Panama": "Panama", + "Palestine": "Palestina", + "Pakistan": "Pakistan", + "Palau": "Palau", + "Oman": "Oman", + "Norway": "Norge", + "Northern Mariana Islands": "Northern Mariana Islands", + "North Korea": "Nord-Korea", + "Norfolk Island": "Norfolkøyene", + "Niue": "Niue", + "Nigeria": "Nigeria", + "Niger": "Niger", + "New Zealand": "New Zealand", + "Nicaragua": "Nicaragua", + "New Caledonia": "New Caledonia", + "Netherlands": "Nederland", + "Nepal": "Nepal", + "Nauru": "Nauru", + "Namibia": "Namibia", + "Myanmar": "Myanmar", + "Mozambique": "Mosambik", + "Morocco": "Marokko", + "Montenegro": "Montenegro", + "Montserrat": "Montserrat", + "Mongolia": "Mongolia", + "Monaco": "Monaco", + "Moldova": "Moldova", + "Micronesia": "Mikronesia", + "Mexico": "Mexico", + "Mayotte": "Mayotte", + "Mauritius": "Mauritius", + "Mauritania": "Mauretania", + "Martinique": "Martinique", + "Marshall Islands": "Marshall Islands", + "Maldives": "Maldivene", + "Mali": "Mali", + "Malaysia": "Malaysia", + "Malawi": "Malawi", + "Madagascar": "Madagaskar", + "Macedonia": "Nord-Makedonia", + "Macau": "Macau", + "Luxembourg": "Luxemburg", + "Lithuania": "Litauen", + "Liechtenstein": "Liechtenstein", + "Libya": "Libya", + "Liberia": "Liberia", + "Lesotho": "Lesotho", + "Lebanon": "Libanon", + "Latvia": "Latvia", + "Laos": "Laos", + "Kyrgyzstan": "Kirgistan", + "Kuwait": "Kuwait", + "Kosovo": "Kosovo", + "Kiribati": "Kiribati", + "Kazakhstan": "Kasakstan", + "Kenya": "Kenya", + "Jamaica": "Jamaica", + "Isle of Man": "Man", + "Iceland": "Island", + "Hungary": "Ungarn", + "Hong Kong": "Hong Kong", + "Honduras": "Honduras", + "Haiti": "Haiti", + "Guinea-Bissau": "Guinea-Bissau", + "Guyana": "Guyana", + "Guinea": "Guinea", + "Guernsey": "Guernsey", + "Guatemala": "Guatemala", + "Guam": "Guam", + "Guadeloupe": "Guadeloupe", + "Grenada": "Grenada", + "Greece": "Hellas", + "Greenland": "Grønland", + "Gibraltar": "Gibraltar", + "Ghana": "Ghana", + "Georgia": "Georgia", + "Gambia": "Gambia", + "Gabon": "Gabon", + "French Southern Territories": "De franske sørterritoriene", + "French Polynesia": "Fransk polynesia", + "French Guiana": "Fransk Guyana", + "France": "Frankrike", + "Finland": "Finnland", + "Fiji": "Fiji", + "Falkland Islands": "Falklandsøyene", + "Faroe Islands": "Færøyene", + "Ethiopia": "Etiopia", + "Estonia": "Estland", + "Eritrea": "Eritrea", + "Equatorial Guinea": "Ekvatorial-Guinea", + "El Salvador": "El Salvador", + "Egypt": "Egypt", + "Ecuador": "Ecuador", + "Dominican Republic": "Dominikanske republikk", + "Djibouti": "Djibouti", + "Dominica": "Dominica", + "Denmark": "Danmark", + "Côte d’Ivoire": "Elfenbenskysten", + "Czech Republic": "Tsjekkia", + "Cyprus": "Kypros", + "Curaçao": "Curaçao", + "Cuba": "Kuba", + "Colombia": "Colombia", + "Comoros": "Komorene", + "Cocos (Keeling) Islands": "Cocos- (Keeling) øyene", + "Christmas Island": "Juløya", + "China": "Kina", + "Chad": "Tsjad", + "Chile": "Chile", + "Central African Republic": "Sentralafrikanske republikk", + "Cayman Islands": "Caymanøyene", + "Caribbean Netherlands": "Karibisk Nederland", + "Cape Verde": "Kapp Verde", + "Canada": "Canada", + "Cameroon": "Kamerun", + "Cambodia": "Kambodsja", + "British Virgin Islands": "De britiske jomfruøyer", + "British Indian Ocean Territory": "Britiske havområder i det indiske hav", + "Bouvet Island": "Bouvetøya", + "Bosnia": "Bosnia", + "Croatia": "Kroatia", + "Costa Rica": "Costa Rica", + "Cook Islands": "Cook-øyene", + "All keys backed up": "Alle nøkler er sikkerhetskopiert", + "Secret storage:": "Hemmelig lagring:" } diff --git a/src/i18n/strings/nl.json b/src/i18n/strings/nl.json index e9e90f4b92..867478453f 100644 --- a/src/i18n/strings/nl.json +++ b/src/i18n/strings/nl.json @@ -6,7 +6,7 @@ "Admin": "Beheerder", "Advanced": "Geavanceerd", "Always show message timestamps": "Altijd tijdstempels van berichten tonen", - "Authentication": "Authenticatie", + "Authentication": "Login bevestigen", "%(items)s and %(lastItem)s": "%(items)s en %(lastItem)s", "and %(count)s others...|other": "en %(count)s anderen…", "and %(count)s others...|one": "en één andere…", @@ -57,9 +57,9 @@ "Anyone": "Iedereen", "Are you sure you want to leave the room '%(roomName)s'?": "Weet u zeker dat u het gesprek ‘%(roomName)s’ wilt verlaten?", "Close": "Sluiten", - "Create new room": "Een nieuw gesprek aanmaken", + "Create new room": "Nieuw gesprek aanmaken", "Custom Server Options": "Aangepaste serverinstellingen", - "Dismiss": "Afwijzen", + "Dismiss": "Sluiten", "Error": "Fout", "Failed to forget room %(errCode)s": "Vergeten van gesprek is mislukt %(errCode)s", "Favourite": "Favoriet", @@ -177,7 +177,7 @@ "Hangup": "Ophangen", "Historical": "Historisch", "Home": "Thuis", - "Homeserver is": "Thuisserver is", + "Homeserver is": "Homeserver is", "Identity Server is": "Identiteitsserver is", "I have verified my email address": "Ik heb mijn e-mailadres geverifieerd", "Import": "Inlezen", @@ -193,12 +193,12 @@ "Invited": "Uitgenodigd", "Invites": "Uitnodigingen", "Invites user with given id to current room": "Nodigt de gebruiker met de gegeven ID uit in het huidige gesprek", - "Sign in with": "Aanmelden met", + "Sign in with": "Inloggen met", "Join as voice or video.": "Deelnemen met spraak of video.", "Join Room": "Gesprek toetreden", "%(targetName)s joined the room.": "%(targetName)s is tot het gesprek toegetreden.", "Jump to first unread message.": "Spring naar het eerste ongelezen bericht.", - "Labs": "Experimenteel", + "Labs": "Labs", "Last seen": "Laatst gezien", "Leave room": "Gesprek verlaten", "%(targetName)s left the room.": "%(targetName)s heeft het gesprek verlaten.", @@ -230,7 +230,7 @@ "Room Colour": "Gesprekskleur", "%(roomName)s does not exist.": "%(roomName)s bestaat niet.", "%(roomName)s is not accessible at this time.": "%(roomName)s is op dit moment niet toegankelijk.", - "Rooms": "Groepen", + "Rooms": "Gesprekken", "Save": "Opslaan", "Search failed": "Zoeken mislukt", "Searches DuckDuckGo for results": "Zoekt op DuckDuckGo voor resultaten", @@ -240,7 +240,7 @@ "%(senderName)s sent an invitation to %(targetDisplayName)s to join the room.": "%(senderName)s heeft %(targetDisplayName)s in het gesprek uitgenodigd.", "Server error": "Serverfout", "Server may be unavailable, overloaded, or search timed out :(": "De server is misschien onbereikbaar of overbelast, of het zoeken duurde te lang :(", - "Server may be unavailable, overloaded, or you hit a bug.": "De server is misschien onbereikbaar of overbelast, of je bent een fout tegengekomen.", + "Server may be unavailable, overloaded, or you hit a bug.": "De server is misschien onbereikbaar of overbelast, of je bent een bug tegengekomen.", "Server unavailable, overloaded, or something else went wrong.": "De server is onbereikbaar of overbelast, of er is iets anders foutgegaan.", "Session ID": "Sessie-ID", "%(senderName)s kicked %(targetName)s.": "%(senderName)s heeft %(targetName)s het gesprek uitgestuurd.", @@ -250,8 +250,8 @@ "%(senderName)s set their display name to %(displayName)s.": "%(senderName)s heeft %(displayName)s als weergavenaam aangenomen.", "Show timestamps in 12 hour format (e.g. 2:30pm)": "Tijd in 12-uursformaat tonen (bv. 2:30pm)", "Signed Out": "Afgemeld", - "Sign in": "Aanmelden", - "Sign out": "Afmelden", + "Sign in": "Inloggen", + "Sign out": "Uitloggen", "%(count)s of your messages have not been sent.|other": "Enkele van uw berichten zijn niet verstuurd.", "Someone": "Iemand", "The phone number entered looks invalid": "Het ingevoerde telefoonnummer ziet er ongeldig uit", @@ -266,7 +266,7 @@ "This room": "Dit gesprek", "This room is not accessible by remote Matrix servers": "Dit gesprek is niet toegankelijk vanaf externe Matrix-servers", "To use it, just wait for autocomplete results to load and tab through them.": "Om het te gebruiken, wacht u tot de autoaanvullen resultaten geladen zijn en tabt u erdoorheen.", - "Tried to load a specific point in this room's timeline, but you do not have permission to view the message in question.": "U heeft gepoogd een gegeven punt in de tijdslijn van dit gesprek te laden, maar u bent niet bevoegd het desbetreffende bericht te zien.", + "Tried to load a specific point in this room's timeline, but you do not have permission to view the message in question.": "U probeert een punt in de tijdlijn van dit gesprek te laden, maar u heeft niet voldoende rechten om het bericht te lezen.", "Tried to load a specific point in this room's timeline, but was unable to find it.": "Geprobeerd een gegeven punt in de tijdslijn van dit gesprek te laden, maar kon dit niet vinden.", "Unable to add email address": "Kan e-mailadres niet toevoegen", "Unable to remove contact information": "Kan contactinformatie niet verwijderen", @@ -329,7 +329,7 @@ "Please select the destination room for this message": "Selecteer het bestemmingsgesprek voor dit bericht", "New Password": "Nieuw wachtwoord", "Start automatically after system login": "Automatisch starten na systeemlogin", - "Analytics": "Statistische gegevens", + "Analytics": "Gebruiksgegevens", "Options": "Opties", "%(brand)s collects anonymous analytics to allow us to improve the application.": "%(brand)s verzamelt anonieme analysegegevens die het mogelijk maken de toepassing te verbeteren.", "Passphrases must match": "Wachtwoorden moeten overeenkomen", @@ -393,7 +393,7 @@ "Delete widget": "Widget verwijderen", "Edit": "Bewerken", "Enable automatic language detection for syntax highlighting": "Automatische taaldetectie voor zinsbouwmarkeringen inschakelen", - "Publish this room to the public in %(domain)s's room directory?": "Dit gesprek vermelden in de gesprekkencatalogus van %(domain)s?", + "Publish this room to the public in %(domain)s's room directory?": "Dit gesprek vermelden in de openbare gesprekkencatalogus van %(domain)s?", "AM": "AM", "PM": "PM", "The maximum permitted number of widgets have already been added to this room.": "Het maximum aan toegestane widgets voor dit gesprek is al bereikt.", @@ -628,11 +628,11 @@ "Room Notification": "Groepsgespreksmelding", "The information being sent to us to help make %(brand)s better includes:": "De informatie die naar ons wordt verstuurd om %(brand)s te verbeteren bevat:", "Where this page includes identifiable information, such as a room, user or group ID, that data is removed before being sent to the server.": "Waar deze pagina identificeerbare informatie bevat, zoals een gespreks-, gebruikers- of groeps-ID, zullen deze gegevens verwijderd worden voordat ze naar de server gestuurd worden.", - "The platform you're on": "Het platform dat je gebruikt", + "The platform you're on": "Het platform dat u gebruikt", "The version of %(brand)s": "De versie van %(brand)s", "Your language of choice": "De door jou gekozen taal", - "Which officially provided instance you are using, if any": "Welke officieel aangeboden instantie je eventueel gebruikt", - "Whether or not you're using the Richtext mode of the Rich Text Editor": "Of je de tekstverwerker al dan niet in de modus voor opgemaakte tekst gebruikt", + "Which officially provided instance you are using, if any": "Welke officieel aangeboden instantie u eventueel gebruikt", + "Whether or not you're using the Richtext mode of the Rich Text Editor": "Of u de tekstverwerker al dan niet in de modus voor opgemaakte tekst gebruikt", "Your homeserver's URL": "De URL van je homeserver", "In reply to ": "Als antwoord op ", "This room is not public. You will not be able to rejoin without an invite.": "Dit is geen openbaar gesprek. Slechts op uitnodiging zult u opnieuw kunnen toetreden.", @@ -658,8 +658,8 @@ "Who can join this community?": "Wie kan er tot deze gemeenschap toetreden?", "Everyone": "Iedereen", "Leave this community": "Deze gemeenschap verlaten", - "If you've submitted a bug via GitHub, debug logs can help us track down the problem. Debug logs contain application usage data including your username, the IDs or aliases of the rooms or groups you have visited and the usernames of other users. They do not contain messages.": "Voor het oplossen van, via GitHub, gemelde problemen helpen foutopsporingslogboeken ons enorm. Deze bevatten wel gebruiksgegevens (waaronder uw gebruikersnaam, de ID’s of bijnamen van de gesprekken en groepen die u heeft bezocht, en de namen van andere gebruikers), maar geen berichten.", - "Submit debug logs": "Foutopsporingslogboeken indienen", + "If you've submitted a bug via GitHub, debug logs can help us track down the problem. Debug logs contain application usage data including your username, the IDs or aliases of the rooms or groups you have visited and the usernames of other users. They do not contain messages.": "Voor het oplossen van, via GitHub, gemelde bugs helpen foutenlogboeken ons enorm. Deze bevatten wel uw gebruiksgegevens, maar geen berichten. Het bevat onder meer uw gebruikersnaam, de ID’s of bijnamen van de gesprekken en groepen die u heeft bezocht en de namen van andere gebruikers.", + "Submit debug logs": "Foutenlogboek versturen", "Opens the Developer Tools dialog": "Opent het dialoogvenster met ontwikkelaarsgereedschap", "Fetching third party location failed": "Het ophalen van de locatie van de derde partij is mislukt", "I understand the risks and wish to continue": "Ik begrijp de risico’s en wil graag verdergaan", @@ -672,7 +672,7 @@ "Files": "Bestanden", "You are not receiving desktop notifications": "U ontvangt momenteel geen bureaubladmeldingen", "Friday": "Vrijdag", - "Update": "Bijwerken", + "Update": "Updaten", "What's New": "Wat is er nieuw", "On": "Aan", "Changelog": "Wijzigingslogboek", @@ -727,11 +727,11 @@ "All messages (noisy)": "Alle berichten (luid)", "Enable them now": "Deze nu inschakelen", "Toolbox": "Gereedschap", - "Collecting logs": "Logboeken worden verzameld", + "Collecting logs": "Logs worden verzameld", "You must specify an event type!": "U dient een gebeurtenistype op te geven!", "(HTTP status %(httpStatus)s)": "(HTTP-status %(httpStatus)s)", "Invite to this room": "Uitnodigen voor dit gesprek", - "Send logs": "Logboeken versturen", + "Send logs": "Logs versturen", "All messages": "Alle berichten", "Call invitation": "Oproep-uitnodiging", "Downloading update...": "Update wordt gedownload…", @@ -778,17 +778,17 @@ "Thank you!": "Bedankt!", "With your current browser, the look and feel of the application may be completely incorrect, and some or all features may not function. If you want to try it anyway you can continue, but you are on your own in terms of any issues you may encounter!": "Met uw huidige browser kan de toepassing er volledig onjuist uitzien. Tevens is het mogelijk dat niet alle functies naar behoren werken. U kunt doorgaan als u het toch wilt proberen, maar bij problemen bent u volledig op uzelf aangewezen!", "Checking for an update...": "Bezig met controleren op updates…", - "Logs sent": "Logboeken verstuurd", - "Debug logs contain application usage data including your username, the IDs or aliases of the rooms or groups you have visited and the usernames of other users. They do not contain messages.": "Foutopsporingslogboeken bevatten gebruiksgegevens over de toepassing, inclusief uw gebruikersnaam, de ID’s of bijnamen van de gesprekken en groepen die u heeft bezocht, evenals de gebruikersnamen van andere gebruikers. Ze bevatten geen berichten.", - "Failed to send logs: ": "Versturen van logboeken mislukt: ", - "Preparing to send logs": "Logboeken worden voorbereid voor versturen", + "Logs sent": "Logs verstuurd", + "Debug logs contain application usage data including your username, the IDs or aliases of the rooms or groups you have visited and the usernames of other users. They do not contain messages.": "Foutenlogboeken bevatten gebruiksgegevens van de app inclusief uw gebruikersnaam, de ID’s of bijnamen van de gesprekken die u heeft bezocht, en de gebruikersnamen van andere gebruikers. Ze bevatten geen berichten.", + "Failed to send logs: ": "Versturen van logs mislukt: ", + "Preparing to send logs": "Logs voorbereiden voor versturen", "e.g. %(exampleValue)s": "bv. %(exampleValue)s", - "Every page you use in the app": "Iedere bladzijde die je in de toepassing gebruikt", + "Every page you use in the app": "Iedere bladzijde die u in de app gebruikt", "e.g. ": "bv. ", "Your device resolution": "De resolutie van je apparaat", "Missing roomId.": "roomId ontbreekt.", "Always show encryption icons": "Versleutelingspictogrammen altijd tonen", - "Send analytics data": "Statistische gegevens versturen", + "Send analytics data": "Gebruiksgegevens delen", "Enable widget screenshots on supported widgets": "Widget-schermafbeeldingen inschakelen op ondersteunde widgets", "Muted Users": "Gedempte gebruikers", "Popout widget": "Widget in nieuw venster openen", @@ -798,11 +798,11 @@ "Message visibility in Matrix is similar to email. Our forgetting your messages means that messages you have sent will not be shared with any new or unregistered users, but registered users who already have access to these messages will still have access to their copy.": "De zichtbaarheid van berichten in Matrix is zoals bij e-mails. Het vergeten van uw berichten betekent dat berichten die u heeft verstuurd niet meer gedeeld worden met nieuwe of ongeregistreerde gebruikers, maar geregistreerde gebruikers die al toegang hebben tot deze berichten zullen alsnog toegang hebben tot hun eigen kopie ervan.", "Please forget all messages I have sent when my account is deactivated (Warning: this will cause future users to see an incomplete view of conversations)": "Vergeet bij het sluiten van mijn account alle door mij verstuurde berichten (Let op: hierdoor zullen personen een onvolledig beeld krijgen van gesprekken)", "To continue, please enter your password:": "Voer uw wachtwoord in om verder te gaan:", - "Clear Storage and Sign Out": "Opslag wissen en afmelden", - "Send Logs": "Logboek versturen", + "Clear Storage and Sign Out": "Opslag wissen en uitloggen", + "Send Logs": "Logs versturen", "Refresh": "Herladen", "We encountered an error trying to restore your previous session.": "Het herstel van uw vorige sessie is mislukt.", - "Clearing your browser's storage may fix the problem, but will sign you out and cause any encrypted chat history to become unreadable.": "Het legen van de opslag van uw browser zal het probleem misschien verhelpen, maar zal u ook afmelden en uw gehele versleutelde gespreksgeschiedenis onleesbaar maken.", + "Clearing your browser's storage may fix the problem, but will sign you out and cause any encrypted chat history to become unreadable.": "Het legen van de opslag van uw browser zal het probleem misschien verhelpen, maar zal u ook uitloggen en uw gehele versleutelde gespreksgeschiedenis onleesbaar maken.", "Collapse Reply Thread": "Reactieketting dichtvouwen", "Can't leave Server Notices room": "Kan servermeldingsgesprek niet verlaten", "This room is used for important messages from the Homeserver, so you cannot leave it.": "Dit gesprek is bedoeld voor belangrijke berichten van de homeserver, dus u kunt het niet verlaten.", @@ -842,7 +842,7 @@ "Bulk options": "Bulkopties", "This homeserver has hit its Monthly Active User limit.": "Deze homeserver heeft zijn limiet voor maandelijks actieve gebruikers bereikt.", "This homeserver has exceeded one of its resource limits.": "Deze homeserver heeft één van zijn systeembronlimieten overschreden.", - "Whether or not you're logged in (we don't record your username)": "Of je al dan niet ingelogd bent (we slaan je gebruikersnaam niet op)", + "Whether or not you're logged in (we don't record your username)": "Of u al dan niet ingelogd bent (we slaan je gebruikersnaam niet op)", "The file '%(fileName)s' exceeds this homeserver's size limit for uploads": "Het bestand ‘%(fileName)s’ is groter dan de uploadlimiet van de homeserver", "Unable to load! Check your network connectivity and try again.": "Laden mislukt! Controleer je netwerktoegang en probeer het nogmaals.", "Failed to invite users to the room:": "Kon de volgende gebruikers hier niet uitnodigen:", @@ -1023,20 +1023,20 @@ "Profile picture": "Profielfoto", "Upgrade to your own domain": "Upgrade naar uw eigen domein", "Display Name": "Weergavenaam", - "Set a new account password...": "Stel een nieuw accountwachtwoord in…", + "Set a new account password...": "Stel een nieuw wachtwoord in…", "Email addresses": "E-mailadressen", "Phone numbers": "Telefoonnummers", "Language and region": "Taal en regio", "Theme": "Thema", "Account management": "Accountbeheer", - "Deactivating your account is a permanent action - be careful!": "Pas op! Het sluiten van uw account is onherroepelijk!", + "Deactivating your account is a permanent action - be careful!": "Pas op! Het sluiten van uw account kan niet teruggedraaid worden!", "General": "Algemeen", - "Legal": "Wettelijk", + "Legal": "Juridisch", "Credits": "Met dank aan", "For help with using %(brand)s, click here.": "Klik hier voor hulp bij het gebruiken van %(brand)s.", "For help with using %(brand)s, click here or start a chat with our bot using the button below.": "Klik hier voor hulp bij het gebruiken van %(brand)s, of begin een gesprek met onze robot met de knop hieronder.", - "Help & About": "Hulp & Info", - "Bug reporting": "Foutmeldingen", + "Help & About": "Hulp & info", + "Bug reporting": "Bug meldingen", "FAQ": "FAQ", "Versions": "Versies", "Preferences": "Instellingen", @@ -1046,10 +1046,10 @@ "Autocomplete delay (ms)": "Vertraging voor autoaanvullen (ms)", "Accept all %(invitedRooms)s invites": "Alle %(invitedRooms)s de uitnodigingen aannemen", "Key backup": "Sleutelback-up", - "Security & Privacy": "Veiligheid & Privacy", + "Security & Privacy": "Veiligheid & privacy", "Missing media permissions, click the button below to request.": "Mediatoestemmingen ontbreken, klik op de knop hieronder om deze aan te vragen.", "Request media permissions": "Mediatoestemmingen verzoeken", - "Voice & Video": "Spraak & Video", + "Voice & Video": "Spraak & video", "Room information": "Gespreksinformatie", "Internal room ID:": "Interne gespreks-ID:", "Room version": "Gespreksversie", @@ -1108,7 +1108,7 @@ "Unable to find profiles for the Matrix IDs listed below - would you like to invite them anyway?": "Kan geen profielen voor de Matrix-ID’s hieronder vinden - wilt u ze toch uitnodigen?", "Invite anyway and never warn me again": "Alsnog uitnodigen en mij nooit meer waarschuwen", "Invite anyway": "Alsnog uitnodigen", - "Before submitting logs, you must create a GitHub issue to describe your problem.": "Voor u logboeken indient, dient u uw probleem te melden op GitHub.", + "Before submitting logs, you must create a GitHub issue to describe your problem.": "Voordat u logs indient, dient u uw probleem te melden in een GitHub issue.", "Unable to load commit detail: %(msg)s": "Kan commitdetail niet laden: %(msg)s", "To avoid losing your chat history, you must export your room keys before logging out. You will need to go back to the newer version of %(brand)s to do this": "Om uw gespreksgeschiedenis niet te verliezen vóór het uitloggen dient u uw veiligheidssleutel te exporteren. Dat moet vanuit de nieuwere versie van %(brand)s", "Incompatible Database": "Incompatibele database", @@ -1125,7 +1125,7 @@ "I don't want my encrypted messages": "Ik wil mijn versleutelde berichten niet", "Manually export keys": "Sleutels handmatig wegschrijven", "You'll lose access to your encrypted messages": "U zult de toegang tot uw versleutelde berichten verliezen", - "Are you sure you want to sign out?": "Weet u zeker dat u zich wilt afmelden?", + "Are you sure you want to sign out?": "Weet u zeker dat u wilt uitloggen?", "If you run into any bugs or have feedback you'd like to share, please let us know on GitHub.": "Als u fouten zou tegenkomen of voorstellen zou hebben, laat het ons dan weten op GitHub.", "To help avoid duplicate issues, please view existing issues first (and add a +1) or create a new issue if you can't find it.": "Voorkom dubbele meldingen: doorzoek eerst de bestaande meldingen (en voeg desgewenst een +1 toe). Maak enkel een nieuwe melding aan indien u niets kunt vinden.", "Report bugs & give feedback": "Fouten melden & feedback geven", @@ -1137,7 +1137,7 @@ "Upgrade Room Version": "Gespreksversie upgraden", "Create a new room with the same name, description and avatar": "Een nieuw gesprek aanmaken met dezelfde naam, beschrijving en avatar", "Update any local room aliases to point to the new room": "Alle lokale gespreksbijnamen naar het nieuwe gesprek laten verwijzen", - "Stop users from speaking in the old version of the room, and post a message advising users to move to the new room": "Gebruikers verhinderen aan de oude versie van het gesprek bij te dragen, en daar een bericht plaatsen dat de gebruikers verwijst naar het nieuwe gesprek", + "Stop users from speaking in the old version of the room, and post a message advising users to move to the new room": "Mensen verhinderen aan de oude versie van het gesprek bij te dragen en daar een bericht te plaatsen dat de gebruikers verwijst naar het nieuwe gesprek", "Put a link back to the old room at the start of the new room so people can see old messages": "Bovenaan het nieuwe gesprek naar het oude verwijzen, om oude berichten te lezen", "A username can only contain lower case letters, numbers and '=_-./'": "Een gebruikersnaam mag enkel kleine letters, cijfers en ‘=_-./’ bevatten", "Checking...": "Bezig met controleren…", @@ -1177,7 +1177,7 @@ "Homeserver URL": "Thuisserver-URL", "Identity Server URL": "Identiteitsserver-URL", "Free": "Gratis", - "Join millions for free on the largest public server": "Doe mee met miljoenen anderen op de grootste publieke server", + "Join millions for free on the largest public server": "Doe mee met miljoenen anderen op de grootste openbare server", "Premium": "Premium", "Premium hosting for organisations Learn more": "Premium hosting voor organisaties Lees meer", "Other": "Overige", @@ -1193,7 +1193,7 @@ "Could not load user profile": "Kon gebruikersprofiel niet laden", "Your Matrix account on %(serverName)s": "Uw Matrix-account op %(serverName)s", "A verification email will be sent to your inbox to confirm setting your new password.": "Er is een verificatie-e-mail naar u gestuurd om het instellen van uw nieuwe wachtwoord te bevestigen.", - "Sign in instead": "Aanmelden", + "Sign in instead": "In plaats daarvan inloggen", "Your password has been reset.": "Uw wachtwoord is opnieuw ingesteld.", "Set a new password": "Stel een nieuw wachtwoord in", "Invalid homeserver discovery response": "Ongeldig homeserver-vindbaarheids-antwoord", @@ -1202,13 +1202,13 @@ "This homeserver does not support login using email address.": "Deze homeserver biedt geen ondersteuning voor inloggen met e-mailadres.", "Please contact your service administrator to continue using this service.": "Gelieve contact op te nemen met uw dienstbeheerder om deze dienst te blijven gebruiken.", "Failed to perform homeserver discovery": "Ontdekken van homeserver is mislukt", - "Sign in with single sign-on": "Aanmelden met eenmalige aanmelding", + "Sign in with single sign-on": "Inloggen met eenmalig inloggen", "Create account": "Account aanmaken", "Registration has been disabled on this homeserver.": "Registratie is uitgeschakeld op deze homeserver.", "Unable to query for supported registration methods.": "Kan ondersteunde registratiemethoden niet opvragen.", "Create your account": "Maak uw account aan", "Keep going...": "Doe verder…", - "For maximum security, this should be different from your account password.": "Voor maximale veiligheid zou dit moeten verschillen van uw accountwachtwoord.", + "For maximum security, this should be different from your account password.": "Voor maximale veiligheid moet dit verschillen van uw accountwachtwoord.", "That matches!": "Dat komt overeen!", "That doesn't match.": "Dat komt niet overeen.", "Go back to set it again.": "Ga terug om het opnieuw in te stellen.", @@ -1228,11 +1228,11 @@ "Don't ask again": "Niet opnieuw vragen", "New Recovery Method": "Nieuwe herstelmethode", "A new recovery passphrase and key for Secure Messages have been detected.": "Er zijn een nieuw herstelwachtwoord en een nieuwe herstelsleutel voor beveiligde berichten gedetecteerd.", - "If you didn't set the new recovery method, an attacker may be trying to access your account. Change your account password and set a new recovery method immediately in Settings.": "Als u deze nieuwe herstelmethode niet heeft ingesteld, is het mogelijk dat een aanvaller toegang tot uw account probeert te krijgen. Wijzig onmiddellijk uw accountwachtwoord en stel in het instellingenmenu een nieuwe herstelmethode in.", + "If you didn't set the new recovery method, an attacker may be trying to access your account. Change your account password and set a new recovery method immediately in Settings.": "Als u deze nieuwe herstelmethode niet heeft ingesteld, is het mogelijk dat een aanvaller toegang tot uw account probeert te krijgen. Wijzig onmiddellijk uw wachtwoord en stel bij instellingen een nieuwe herstelmethode in.", "Go to Settings": "Ga naar instellingen", "Set up Secure Messages": "Beveiligde berichten instellen", "Recovery Method Removed": "Herstelmethode verwijderd", - "If you didn't remove the recovery method, an attacker may be trying to access your account. Change your account password and set a new recovery method immediately in Settings.": "Als u de herstelmethode niet heeft verwijderd, is het mogelijk dat er een aanvaller toegang tot uw account probeert te verkrijgen. Wijzig onmiddellijk uw accountwachtwoord en stel in het instellingenmenu een nieuwe herstelmethode in.", + "If you didn't remove the recovery method, an attacker may be trying to access your account. Change your account password and set a new recovery method immediately in Settings.": "Als u de herstelmethode niet heeft verwijderd, is het mogelijk dat er een aanvaller toegang tot uw account probeert te verkrijgen. Wijzig onmiddellijk uw wachtwoord en stel bij instellingen een nieuwe herstelmethode in.", "Warning: Upgrading a room will not automatically migrate room members to the new version of the room. We'll post a link to the new room in the old version of the room - room members will have to click this link to join the new room.": "Let op: gesprekken bijwerken voegt gespreksleden niet automatisch toe aan de nieuwe versie van het gesprek. Er komt in het oude gesprek een koppeling naar het nieuwe, waarop gespreksleden moeten klikken om aan het nieuwe gesprek deel te nemen.", "Adds a custom widget by URL to the room": "Voegt met een URL een aangepaste widget toe aan het gesprek", "Please supply a https:// or http:// widget URL": "Voer een https://- of http://-widget-URL in", @@ -1255,7 +1255,7 @@ "The homeserver may be unavailable or overloaded.": "De homeserver is mogelijk onbereikbaar of overbelast.", "You have %(count)s unread notifications in a prior version of this room.|other": "U heeft %(count)s ongelezen meldingen in een vorige versie van dit gesprek.", "You have %(count)s unread notifications in a prior version of this room.|one": "U heeft %(count)s ongelezen meldingen in een vorige versie van dit gesprek.", - "Whether or not you're using the 'breadcrumbs' feature (avatars above the room list)": "Of je de icoontjes voor recente gesprekken (boven de gesprekkenlijst) al dan niet gebruikt", + "Whether or not you're using the 'breadcrumbs' feature (avatars above the room list)": "Of u de icoontjes voor recente gesprekken (boven de gesprekkenlijst) al dan niet gebruikt", "Replying With Files": "Beantwoorden met bestanden", "At this time it is not possible to reply with a file. Would you like to upload this file without replying?": "Het is momenteel niet mogelijk met een bestand te antwoorden. Wil je dit bestand uploaden zonder te antwoorden?", "The file '%(fileName)s' failed to upload.": "Het bestand ‘%(fileName)s’ kon niet geüpload worden.", @@ -1264,8 +1264,8 @@ "GitHub issue": "GitHub-melding", "Notes": "Opmerkingen", "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.": "Gelieve alle verdere informatie die zou kunnen helpen het probleem te analyseren (wat u aan het doen was, relevante gespreks-ID’s, gebruikers-ID’s, enz.) bij te voegen.", - "Sign out and remove encryption keys?": "Afmelden en versleutelingssleutels verwijderen?", - "To help us prevent this in future, please send us logs.": "Gelieve ons logboeken te sturen om dit in de toekomst te helpen voorkomen.", + "Sign out and remove encryption keys?": "Uitloggen en versleutelingssleutels verwijderen?", + "To help us prevent this in future, please send us logs.": "Stuur ons uw logs om dit in de toekomst te helpen voorkomen.", "Missing session data": "Sessiegegevens ontbreken", "Some session data, including encrypted message keys, is missing. Sign out and sign in to fix this, restoring keys from backup.": "Sommige sessiegegevens, waaronder sleutels voor versleutelde berichten, ontbreken. Herstel de sleutels uit uw back-up door u af- en weer aan te melden.", "Your browser likely removed this data when running low on disk space.": "Uw browser heeft deze gegevens wellicht verwijderd toen de beschikbare opslagruimte vol was.", @@ -1297,7 +1297,7 @@ "Rejecting invite …": "Uitnodiging wordt geweigerd…", "Join the conversation with an account": "Neem deel aan het gesprek met een account", "Sign Up": "Registreren", - "Sign In": "Aanmelden", + "Sign In": "Inloggen", "You were kicked from %(roomName)s by %(memberName)s": "U bent uit %(roomName)s gezet door %(memberName)s", "Reason: %(reason)s": "Reden: %(reason)s", "Forget this room": "Dit gesprek vergeten", @@ -1315,7 +1315,7 @@ "%(roomName)s can't be previewed. Do you want to join it?": "%(roomName)s kan niet vooraf bekeken worden. Wilt u eraan deelnemen?", "This room doesn't exist. Are you sure you're at the right place?": "Dit gesprek bestaat niet. Weet u zeker dat u zich op de juiste plaats bevindt?", "Try again later, or ask a room admin to check if you have access.": "Probeer het later opnieuw, of vraag een gespreksbeheerder om te controleren of u wel toegang heeft.", - "%(errcode)s was returned while trying to access the room. If you think you're seeing this message in error, please submit a bug report.": "De foutcode %(errcode)s is weergegeven bij het toetreden van het gesprek. Als u meent dat u dit bericht foutief te zien krijgt, gelieve dan een foutmelding in te dienen.", + "%(errcode)s was returned while trying to access the room. If you think you're seeing this message in error, please submit a bug report.": "De foutcode %(errcode)s is weergegeven bij het toetreden van het gesprek. Als u meent dat u dit bericht foutief te zien krijgt, gelieve dan een bugmelding indienen.", "This room has already been upgraded.": "Dit gesprek is reeds geüpgraded.", "reacted with %(shortName)s": "heeft gereageerd met %(shortName)s", "edited": "bewerkt", @@ -1386,8 +1386,8 @@ "Resend edit": "Bewerking opnieuw versturen", "Resend %(unsentCount)s reaction(s)": "%(unsentCount)s reactie(s) opnieuw versturen", "Resend removal": "Verwijdering opnieuw versturen", - "Failed to re-authenticate due to a homeserver problem": "Opnieuw aanmelden is mislukt wegens een probleem met de homeserver", - "Failed to re-authenticate": "Opnieuw aanmelden is mislukt", + "Failed to re-authenticate due to a homeserver problem": "Opnieuw inloggen is mislukt wegens een probleem met de homeserver", + "Failed to re-authenticate": "Opnieuw inloggen is mislukt", "Enter your password to sign in and regain access to your account.": "Voer uw wachtwoord in om u aan te melden en toegang tot uw account te herkrijgen.", "Forgotten your password?": "Wachtwoord vergeten?", "You're signed out": "U bent afgemeld", @@ -1401,7 +1401,7 @@ "Service": "Dienst", "Summary": "Samenvatting", "Sign in and regain access to your account.": "Meld u aan en herkrijg toegang tot uw account.", - "You cannot sign in to your account. Please contact your homeserver admin for more information.": "U kunt zich niet aanmelden met uw account. Neem voor meer informatie contact op met de beheerder van uw homeserver.", + "You cannot sign in to your account. Please contact your homeserver admin for more information.": "U kunt niet inloggen met uw account. Neem voor meer informatie contact op met de beheerder van uw homeserver.", "This account has been deactivated.": "Deze account is gesloten.", "Messages": "Berichten", "Actions": "Acties", @@ -1478,11 +1478,11 @@ "No recent messages by %(user)s found": "Geen recente berichten door %(user)s gevonden", "Try scrolling up in the timeline to see if there are any earlier ones.": "Probeer omhoog te scrollen in de tijdslijn om te kijken of er eerdere zijn.", "Remove recent messages by %(user)s": "Recente berichten door %(user)s verwijderen", - "You are about to remove %(count)s messages by %(user)s. This cannot be undone. Do you wish to continue?|other": "U staat op het punt %(count)s berichten door %(user)s te verwijderen. Dit is onherroepelijk. Wilt u doorgaan?", + "You are about to remove %(count)s messages by %(user)s. This cannot be undone. Do you wish to continue?|other": "U staat op het punt %(count)s berichten van %(user)s te verwijderen. Dit kan niet teruggedraaid worden. Wilt u doorgaan?", "For a large amount of messages, this might take some time. Please don't refresh your client in the meantime.": "Bij een groot aantal berichten kan dit even duren. Herlaad uw cliënt niet gedurende deze tijd.", "Remove %(count)s messages|other": "%(count)s berichten verwijderen", "Deactivate user?": "Gebruiker deactiveren?", - "Deactivating this user will log them out and prevent them from logging back in. Additionally, they will leave all the rooms they are in. This action cannot be reversed. Are you sure you want to deactivate this user?": "Deze gebruiker deactiveren zal deze gebruiker uitloggen en verhinderen dat de gebruiker weer inlogt. Bovendien zal de gebruiker alle gesprekken waaraan de gebruiker deelneemt verlaten. Deze actie is onherroepelijk. Weet u zeker dat u deze gebruiker wilt deactiveren?", + "Deactivating this user will log them out and prevent them from logging back in. Additionally, they will leave all the rooms they are in. This action cannot be reversed. Are you sure you want to deactivate this user?": "Deze gebruiker deactiveren zal deze gebruiker uitloggen en verhinderen dat de gebruiker weer inlogt. Bovendien zal de gebruiker alle gesprekken waaraan de gebruiker deelneemt verlaten. Deze actie is niet terug te draaien. Weet u zeker dat u deze gebruiker wilt deactiveren?", "Deactivate user": "Gebruiker deactiveren", "Remove recent messages": "Recente berichten verwijderen", "Bold": "Vet", @@ -1517,13 +1517,13 @@ "Explore rooms": "Gesprekken ontdekken", "Show previews/thumbnails for images": "Miniaturen voor afbeeldingen tonen", "Clear cache and reload": "Cache wissen en herladen", - "You are about to remove %(count)s messages by %(user)s. This cannot be undone. Do you wish to continue?|one": "U staat op het punt 1 bericht door %(user)s te verwijderen. Dit is onherroepelijk. Wilt u doorgaan?", + "You are about to remove %(count)s messages by %(user)s. This cannot be undone. Do you wish to continue?|one": "U staat op het punt 1 bericht door %(user)s te verwijderen. Dit kan niet ongedaan gemaakt worden. Wilt u doorgaan?", "Remove %(count)s messages|one": "1 bericht verwijderen", "%(count)s unread messages including mentions.|other": "%(count)s ongelezen berichten, inclusief vermeldingen.", "%(count)s unread messages.|other": "%(count)s ongelezen berichten.", "Unread mentions.": "Ongelezen vermeldingen.", "Show image": "Afbeelding tonen", - "Please create a new issue on GitHub so that we can investigate this bug.": "Maak een nieuw rapport aan op GitHub opdat we dit probleem kunnen onderzoeken.", + "Please create a new issue on GitHub so that we can investigate this bug.": "Maak een nieuwe issue aan op GitHub zodat we deze bug kunnen onderzoeken.", "e.g. my-room": "bv. mijn-gesprek", "Close dialog": "Dialoog sluiten", "Please enter a name for the room": "Geef een naam voor het gesprek op", @@ -1549,7 +1549,7 @@ "Click the link in the email you received to verify and then click continue again.": "Open de koppeling in de ontvangen verificatie-e-mail, en klik dan op ‘Doorgaan’.", "%(creator)s created and configured the room.": "Gesprek gestart en ingesteld door %(creator)s.", "Setting up keys": "Sleutelconfiguratie", - "Verify this session": "Deze sessie verifiëren", + "Verify this session": "Verifieer deze sessie", "Encryption upgrade available": "Versleutelingsupgrade beschikbaar", "You can use /help to list available commands. Did you mean to send this as a message?": "Typ /help om alle opdrachten te zien. Was het uw bedoeling dit als bericht te sturen?", "Help": "Hulp", @@ -1621,7 +1621,7 @@ "Upgrade": "Upgraden", "Verify": "Verifiëren", "Later": "Later", - "Review": "Controle", + "Review": "Controleer", "Decline (%(counter)s)": "Afwijzen (%(counter)s)", "This bridge was provisioned by .": "Dank aan voor de brug.", "This bridge is managed by .": "Brug onderhouden door .", @@ -1636,14 +1636,14 @@ "Unable to load session list": "Kan sessielijst niet laden", "Delete %(count)s sessions|other": "%(count)s sessies verwijderen", "Delete %(count)s sessions|one": "%(count)s sessie verwijderen", - "Whether you're using %(brand)s on a device where touch is the primary input mechanism": "Of je %(brand)s op een apparaat gebruikt waarop een aanraakscherm de voornaamste invoermethode is", - "Whether you're using %(brand)s as an installed Progressive Web App": "Of je %(brand)s gebruikt als een geïnstalleerde Progressive-Web-App", + "Whether you're using %(brand)s on a device where touch is the primary input mechanism": "Of u %(brand)s op een apparaat gebruikt waarop een aanraakscherm de voornaamste invoermethode is", + "Whether you're using %(brand)s as an installed Progressive Web App": "Of u %(brand)s gebruikt als een geïnstalleerde Progressieve Web-App", "Your user agent": "Jouw gebruikersagent", "If you cancel now, you won't complete verifying the other user.": "Als u nu annuleert zult u de andere gebruiker niet verifiëren.", "If you cancel now, you won't complete verifying your other session.": "Als u nu annuleert zult u uw andere sessie niet verifiëren.", "Cancel entering passphrase?": "Wachtwoord annuleren?", "Show typing notifications": "Typmeldingen weergeven", - "Verify this session by completing one of the following:": "Verifieer deze sessie door een van het volgende te doen:", + "Verify this session by completing one of the following:": "Verifieer deze sessie door een van het volgende handelingen te doen:", "Scan this unique code": "Scan deze unieke code", "or": "of", "Compare unique emoji": "Vergelijk unieke emoji", @@ -1689,7 +1689,7 @@ "wait and try again later": "wachten en het later weer proberen", "Use an Integration Manager (%(serverName)s) to manage bots, widgets, and sticker packs.": "Gebruik een integratiebeheerder (%(serverName)s) om robots, widgets en stickerpakketten te beheren.", "Use an Integration Manager to manage bots, widgets, and sticker packs.": "Gebruik een integratiebeheerder om robots, widgets en stickerpakketten te beheren.", - "Manage integrations": "Beheer integraties", + "Manage integrations": "Integratiebeheerder", "Integration Managers receive configuration data, and can modify widgets, send room invites, and set power levels on your behalf.": "Integratiebeheerders ontvangen configuratie-informatie en kunnen widgets aanpassen, gespreksuitnodigingen versturen en machtsniveau’s namens u aanpassen.", "Ban list rules - %(roomName)s": "Banlijstregels - %(roomName)s", "Server rules": "Serverregels", @@ -1746,7 +1746,7 @@ "Clear notifications": "Meldingen wissen", "You should remove your personal data from identity server before disconnecting. Unfortunately, identity server is currently offline or cannot be reached.": "U moet uw persoonlijke informatie van de identiteitsserver verwijderen voordat u zich ontkoppelt. Helaas kan de identiteitsserver op dit moment niet worden bereikt. Mogelijk is hij offline.", "Your homeserver does not support cross-signing.": "Uw homeserver biedt geen ondersteuning voor kruiselings ondertekenen.", - "Homeserver feature support:": "Homeserver ondersteund deze functies:", + "Homeserver feature support:": "Homeserver functie ondersteuning:", "exists": "aanwezig", "Sign In or Create Account": "Meld u aan of maak een account aan", "Use your account or create a new one to continue.": "Gebruik uw bestaande account of maak een nieuwe aan om verder te gaan.", @@ -1758,7 +1758,7 @@ "Cancelling…": "Bezig met annuleren…", "%(brand)s is missing some components required for securely caching encrypted messages locally. If you'd like to experiment with this feature, build a custom %(brand)s Desktop with search components added.": "In %(brand)s ontbreken enige modulen vereist voor het veilig lokaal bewaren van versleutelde berichten. Wilt u deze functie uittesten, compileer dan een aangepaste versie van %(brand)s Desktop die de zoekmodulen bevat.", "This session is not backing up your keys, but you do have an existing backup you can restore from and add to going forward.": "Deze sessie maakt geen back-ups van uw sleutels, maar u beschikt over een reeds bestaande back-up waaruit u kunt herstellen en waaraan u nieuwe sleutels vanaf nu kunt toevoegen.", - "Customise your experience with experimental labs features. Learn more.": "Personaliseer uw ervaring met experimentele functies. Klik hier voor meer informatie.", + "Customise your experience with experimental labs features. Learn more.": "Personaliseer uw ervaring met experimentele labs functies. Lees verder.", "Cross-signing": "Kruiselings ondertekenen", "Your key share request has been sent - please check your other sessions for key share requests.": "Uw sleuteldeelverzoek is verstuurd - controleer de sleuteldeelverzoeken op uw andere sessies.", "Key share requests are sent to your other sessions automatically. If you rejected or dismissed the key share request on your other sessions, click here to request the keys for this session again.": "Sleuteldeelverzoeken worden automatisch naar andere sessies verstuurd. Als u op uw andere sessies het sleuteldeelverzoek geweigerd of genegeerd hebt, kunt u hier klikken op de sleutels voor deze sessie opnieuw aan te vragen.", @@ -1872,10 +1872,10 @@ "More options": "Meer opties", "Language Dropdown": "Taalselectie", "Destroy cross-signing keys?": "Sleutels voor kruiselings ondertekenen verwijderen?", - "Deleting cross-signing keys is permanent. Anyone you have verified with will see security alerts. You almost certainly don't want to do this, unless you've lost every device you can cross-sign from.": "Het verwijderen van sleutels voor kruiselings ondertekenen is onherroepelijk. Iedereen waarmee u geverifieerd heeft zal beveiligingswaarschuwingen te zien krijgen. U wilt dit hoogstwaarschijnlijk niet doen, tenzij u alle apparaten heeft verloren waarmee u kruiselings kon ondertekenen.", + "Deleting cross-signing keys is permanent. Anyone you have verified with will see security alerts. You almost certainly don't want to do this, unless you've lost every device you can cross-sign from.": "Het verwijderen van sleutels voor kruiselings ondertekenen is niet terug te draaien. Iedereen waarmee u geverifieerd heeft zal beveiligingswaarschuwingen te zien krijgen. U wilt dit hoogstwaarschijnlijk niet doen, tenzij u alle apparaten heeft verloren waarmee u kruiselings kon ondertekenen.", "Clear cross-signing keys": "Sleutels voor kruiselings ondertekenen wissen", "Clear all data in this session?": "Alle gegevens in deze sessie verwijderen?", - "Clearing all data from this session is permanent. Encrypted messages will be lost unless their keys have been backed up.": "Het verwijderen van alle gegevens in deze sessie is onherroepelijk. Versleutelde berichten zullen verloren gaan, tenzij u een back-up van de sleutels heeft.", + "Clearing all data from this session is permanent. Encrypted messages will be lost unless their keys have been backed up.": "Het verwijderen van alle gegevens in deze sessie is niet terug te draaien. Versleutelde berichten zullen verloren gaan, tenzij u een back-up van de sleutels heeft.", "Verify session": "Sessie verifiëren", "Session name": "Sessienaam", "Session key": "Sessiesleutel", @@ -1884,7 +1884,7 @@ "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.": "Verifieer dit apparaat om het als vertrouwd te markeren. Door dit apparaat te vertrouwen geeft u extra gemoedsrust aan uzelf en andere gebruikers bij het gebruik van eind-tot-eind-versleutelde berichten.", "Verifying this device will mark it as trusted, and users who have verified with you will trust this device.": "Dit apparaat verifiëren zal het als vertrouwd markeren, en gebruikers die met u geverifieerd hebben zullen het vertrouwen.", "Integrations are disabled": "Integraties zijn uitgeschakeld", - "Enable 'Manage Integrations' in Settings to do this.": "Schakel in het Algemene Instellingenmenu ‘Beheer integraties’ in om dit te doen.", + "Enable 'Manage Integrations' in Settings to do this.": "Schakel de ‘Integratiebeheerder’ in in uw Instellingen om dit te doen.", "Integrations not allowed": "Integraties niet toegestaan", "Your %(brand)s doesn't allow you to use an Integration Manager to do this. Please contact an admin.": "Uw %(brand)s laat u geen integratiebeheerder gebruiken om dit te doen. Neem contact op met een beheerder.", "Failed to invite the following users to chat: %(csvUsers)s": "Het uitnodigen van volgende gebruikers voor gesprek is mislukt: %(csvUsers)s", @@ -1909,7 +1909,7 @@ "Automatically invite users": "Gebruikers automatisch uitnodigen", "Upgrade private room": "Privégesprek upgraden", "Upgrade public room": "Openbaar gesprek upgraden", - "Upgrading a room is an advanced action and is usually recommended when a room is unstable due to bugs, missing features or security vulnerabilities.": "Het bijwerken van een gesprek is een gevorderde actie en wordt meestal aanbevolen wanneer een gesprek onstabiel is door fouten, ontbrekende functies of problemen met de beveiliging.", + "Upgrading a room is an advanced action and is usually recommended when a room is unstable due to bugs, missing features or security vulnerabilities.": "Het bijwerken van een gesprek is een gevorderde actie en wordt meestal aanbevolen wanneer een gesprek onstabiel is door bugs, ontbrekende functies of problemen met de beveiliging.", "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.": "Dit heeft meestal enkel een invloed op de manier waarop het gesprek door de server verwerkt wordt. Als u problemen met uw %(brand)s ondervindt, dien dan een foutmelding in.", "You'll upgrade this room from to .": "U upgrade dit gesprek van naar .", "This will allow you to return to your account after signing out, and sign in on other sessions.": "Daardoor kunt u na afmelding terugkeren tot uw account, en u bij andere sessies aanmelden.", @@ -1927,25 +1927,25 @@ "Remove for me": "Verwijderen voor mezelf", "User Status": "Gebruikersstatus", "Country Dropdown": "Landselectie", - "Confirm your identity by entering your account password below.": "Bevestig uw identiteit door hieronder uw accountwachtwoord in te voeren.", + "Confirm your identity by entering your account password below.": "Bevestig uw identiteit door hieronder uw wachtwoord in te voeren.", "No identity server is configured so you cannot add an email address in order to reset your password in the future.": "Er is geen identiteitsserver geconfigureerd, dus u kunt geen e-mailadres toevoegen om in de toekomst een nieuw wachtwoord in te stellen.", "Jump to first unread room.": "Ga naar het eerste ongelezen gesprek.", "Jump to first invite.": "Ga naar de eerste uitnodiging.", "Session verified": "Sessie geverifieerd", - "Your new session is now verified. It has access to your encrypted messages, and other users will see it as trusted.": "Uw nieuwe sessie is nu geverifieerd. Ze heeft nu toegang tot uw versleutelde berichten, en de sessie zal voor andere gebruikers als vertrouwd gemarkeerd worden.", + "Your new session is now verified. It has access to your encrypted messages, and other users will see it as trusted.": "Uw nieuwe sessie is nu geverifieerd. U heeft nu toegang tot uw versleutelde berichten, en deze sessie zal voor andere gebruikers als vertrouwd gemarkeerd worden.", "Your new session is now verified. Other users will see it as trusted.": "Uw nieuwe sessie is nu geverifieerd. Ze zal voor andere gebruikers als vertrouwd gemarkeerd worden.", "Without completing security on this session, it won’t have access to encrypted messages.": "Als u de beveiliging van deze sessie niet vervolledigt, zal ze geen toegang hebben tot uw versleutelde berichten.", "Go Back": "Terugkeren", "Changing your password will reset any end-to-end encryption keys on all of your sessions, making encrypted chat history unreadable. Set up Key Backup or export your room keys from another session before resetting your password.": "Door uw wachtwoord te wijzigen stelt u alle eind-tot-eind-versleutelingssleutels op al uw sessies opnieuw in, waardoor uw versleutelde gespreksgeschiedenis onleesbaar wordt. Stel uw sleutelback-up in of sla uw gesprekssleutels van een andere sessie op voor u een nieuw wachtwoord instelt.", "You have been logged out of all sessions and will no longer receive push notifications. To re-enable notifications, sign in again on each device.": "U bent uitgelogd bij al uw sessies en zult geen pushberichten meer ontvangen. Meld u op elk apparaat opnieuw aan om meldingen opnieuw in te schakelen.", "Regain access to your account and recover encryption keys stored in this session. Without them, you won’t be able to read all of your secure messages in any session.": "Ontvang toegang tot uw account en herstel de tijdens deze sessie opgeslagen versleutelingssleutels, zonder deze sleutels zijn sommige van uw versleutelde berichten in uw sessies onleesbaar.", - "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.": "Let op: uw persoonlijke gegevens (waaronder versleutelingssleutels) zijn nog steeds opgeslagen in deze sessie. Wis ze wanneer u klaar bent met deze sessie, of wanneer u zich wilt aanmelden met een andere account.", + "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.": "Let op: uw persoonlijke gegevens (waaronder versleutelingssleutels) zijn nog steeds opgeslagen in deze sessie. Wis ze wanneer u klaar bent met deze sessie, of wanneer u wilt inloggen met een andere account.", "Command Autocomplete": "Opdrachten autoaanvullen", "DuckDuckGo Results": "DuckDuckGo-resultaten", - "Enter your account password to confirm the upgrade:": "Voer uw accountwachtwoord in om het upgraden te bevestigen:", + "Enter your account password to confirm the upgrade:": "Voer uw wachtwoord in om het upgraden te bevestigen:", "Restore your key backup to upgrade your encryption": "Herstel uw sleutelback-up om uw versleuteling te upgraden", "Restore": "Herstellen", - "You'll need to authenticate with the server to confirm the upgrade.": "U zult zich moeten aanmelden bij de server om het upgraden te bevestigen.", + "You'll need to authenticate with the server to confirm the upgrade.": "U zult moeten inloggen bij de server om het upgraden te bevestigen.", "Upgrade this session to allow it to verify other sessions, granting them access to encrypted messages and marking them as trusted for other users.": "Upgrade deze sessie om er andere sessies mee te verifiëren, waardoor deze ook de toegang verkrijgen tot uw versleutelde berichten en deze voor andere gebruikers als vertrouwd gemarkeerd worden.", "Set up with a recovery key": "Instellen met een herstelsleutel", "Keep a copy of it somewhere secure, like a password manager or even a safe.": "Bewaar een kopie op een veilige plaats, zoals in een wachtwoordbeheerder of een kluis.", @@ -1970,7 +1970,7 @@ "To report a Matrix-related security issue, please read the Matrix.org Security Disclosure Policy.": "Bekijk eerst het beveiligingsopenbaarmakingsbeleid van Matrix.org als u een probleem met de beveiliging van Matrix wilt melden.", "Not currently indexing messages for any room.": "Er worden momenteel voor geen enkel gesprek berichten geïndexeerd.", "%(doneRooms)s out of %(totalRooms)s": "%(doneRooms)s van %(totalRooms)s", - "Where you’re logged in": "Waar u ingelogd bent", + "Where you’re logged in": "Waar u bent ingelogd", "Manage the names of and sign out of your sessions below or verify them in your User Profile.": "Beheer hieronder de namen van uw sessies en meld ze af. Of verifieer ze in uw gebruikersprofiel.", "Use Single Sign On to continue": "Ga verder met eenmalige aanmelding", "Confirm adding this email address by using Single Sign On to prove your identity.": "Bevestig je identiteit met je eenmalige aanmelding om dit e-mailadres toe te voegen.", @@ -1989,7 +1989,7 @@ "Command failed": "Opdracht mislukt", "Could not find user in room": "Kon die deelnemer aan het gesprek niet vinden", "Please supply a widget URL or embed code": "Gelieve een widgetURL of in te bedden code te geven", - "Send a bug report with logs": "Rapporteer een fout, met foutopsporingslogboek bijgesloten", + "Send a bug report with logs": "Stuur een bugrapport met logs", "%(senderDisplayName)s changed the room name from %(oldRoomName)s to %(newRoomName)s.": "%(senderDisplayName)s heeft het gesprek %(oldRoomName)s hernoemd tot %(newRoomName)s.", "%(senderName)s added the alternative addresses %(addresses)s for this room.|other": "%(senderName)s heeft dit gesprek de nevenadressen %(addresses)s toegekend.", "%(senderName)s added the alternative addresses %(addresses)s for this room.|one": "%(senderName)s heeft dit gesprek het nevenadres %(addresses)s toegekend.", @@ -2315,16 +2315,16 @@ "Your homeserver rejected your log in attempt. This could be due to things just taking too long. Please try again. If this continues, please contact your homeserver administrator.": "Uw homeserver wees uw inlogpoging af. Dit kan zijn doordat het te lang heeft geduurd. Probeer het opnieuw. Als dit probleem zich blijft voordoen, neem contact op met de beheerder van uw homeserver.", "Your homeserver was unreachable and was not able to log you in. Please try again. If this continues, please contact your homeserver administrator.": "Uw homeserver was onbereikbaar en kon u niet inloggen, probeer het opnieuw. Wanneer dit probleem zich blijft voordoen, neem contact op met de beheerder van uw homeserver.", "Try again": "Probeer opnieuw", - "We asked the browser to remember which homeserver you use to let you sign in, but unfortunately your browser has forgotten it. Go to the sign in page and try again.": "De browser is verzocht uw homeserver te onthouden die u gebruikt om zich aan te melden, maar is deze vergeten. Ga naar de aanmeldpagina en probeer het opnieuw.", + "We asked the browser to remember which homeserver you use to let you sign in, but unfortunately your browser has forgotten it. Go to the sign in page and try again.": "De browser is verzocht uw homeserver te onthouden die u gebruikt om in te loggen, maar helaas heeft de browser deze vergeten. Ga naar de inlog-pagina en probeer het opnieuw.", "We couldn't log you in": "We konden u niet inloggen", "Room Info": "Gespreksinfo", "Matrix.org is the biggest public homeserver in the world, so it’s a good place for many.": "Matrix.org is de grootste openbare homeserver van de wereld, dus het is een goede plek voor vele.", - "Explore Public Rooms": "Verken openbare groepen", + "Explore Public Rooms": "Verken openbare gesprekken", "Private rooms can be found and joined by invitation only. Public rooms can be found and joined by anyone in this community.": "Privégesprekken zijn alleen zichtbaar en toegankelijk met een uitnodiging. Openbare gesprekken zijn zichtbaar en toegankelijk voor iedereen in deze gemeenschap.", "This room is public": "Dit gesprek is openbaar", "Show previews of messages": "Voorvertoning van berichten inschakelen", "Show message previews for reactions in all rooms": "Toon berichtvoorbeelden voor reacties in alle gesprekken", - "Explore public rooms": "Verken openbare groepen", + "Explore public rooms": "Verken openbare gesprekken", "Leave Room": "Gesprek verlaten", "Room options": "Gesprekopties", "Start a conversation with someone using their name, email address or username (like ).": "Start een gesprek met iemand door hun naam, emailadres of gebruikersnaam (zoals ) te typen.", @@ -2349,8 +2349,8 @@ "Show rooms with unread messages first": "Gesprekken met ongelezen berichten als eerste tonen", "%(count)s results|one": "%(count)s resultaten", "%(count)s results|other": "%(count)s resultaten", - "Explore all public rooms": "Verken alle openbare groepen", - "Start a new chat": "Een nieuw gesprek beginnen", + "Explore all public rooms": "Verken alle openbare gespreken", + "Start a new chat": "Nieuw gesprek beginnen", "Can't see what you’re looking for?": "Niet kunnen vinden waar u naar zocht?", "Custom Tag": "Aangepast label", "Explore community rooms": "Gemeenschapsgesprekken verkennen", @@ -2366,7 +2366,7 @@ "Your server admin has disabled end-to-end encryption by default in private rooms & Direct Messages.": "De beheerder van uw server heeft eind-tot-eind-versleuteling standaard uitgeschakeld in alle privégesprekken en directe gesprekken.", "Scroll to most recent messages": "Spring naar meest recente bericht", "The authenticity of this encrypted message can't be guaranteed on this device.": "De echtheid van dit versleutelde bericht kan op dit apparaat niet worden gegarandeerd.", - "To link to this room, please add an address.": "Voeg een adres toe om naar deze kamer te verwijzen.", + "To link to this room, please add an address.": "Voeg een adres toe om naar dit gesprek te kunnen verwijzen.", "Remove messages sent by others": "Berichten van anderen verwijderen", "Privacy": "Privacy", "Keyboard Shortcuts": "Sneltoetsen", @@ -2416,8 +2416,8 @@ "sends snowfall": "Stuur sneeuwvlokken", "sends confetti": "verstuurt confetti", "sends fireworks": "Stuur vuurwerk", - "Downloading logs": "Logboeken downloaden", - "Uploading logs": "Logboeken versturen", + "Downloading logs": "Logs downloaden", + "Uploading logs": "Logs uploaden", "Use Ctrl + Enter to send a message": "Gebruik Ctrl + Enter om een bericht te sturen", "Use Command + Enter to send a message": "Gebruik Command (⌘) + Enter om een bericht te sturen", "Use Ctrl + F to search": "Ctrl + F om te zoeken gebruiken", @@ -2425,7 +2425,7 @@ "Use a more compact ‘Modern’ layout": "Compacte 'Modern'-layout inschakelen", "Use custom size": "Aangepaste lettergrootte gebruiken", "Font size": "Lettergrootte", - "Enable advanced debugging for the room list": "Geavanceerde foutopsporing voor de gesprekkenlijst inschakelen", + "Enable advanced debugging for the room list": "Geavanceerde bugopsporing voor de gesprekkenlijst inschakelen", "Render LaTeX maths in messages": "Weergeef LaTeX-wiskundenotatie in berichten", "Change notification settings": "Meldingsinstellingen wijzigen", "%(senderName)s: %(stickerName)s": "%(senderName)s: %(stickerName)s", @@ -2449,7 +2449,7 @@ "%(senderName)s has updated the widget layout": "%(senderName)s heeft de widget-indeling bijgewerkt", "%(senderName)s declined the call.": "%(senderName)s heeft de oproep afgewezen.", "(an error occurred)": "(een fout is opgetreden)", - "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.": "U heeft eerder een nieuwere versie van %(brand)s in deze sessie gebruikt. Om deze versie opnieuw met eind-tot-eind-versleuteling te gebruiken, zult u zich moeten afmelden en opnieuw aanmelden.", + "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.": "U heeft eerder een nieuwere versie van %(brand)s in deze sessie gebruikt. Om deze versie opnieuw met eind-tot-eind-versleuteling te gebruiken, zult u moeten uitloggen en opnieuw inloggen.", "Block anyone not part of %(serverName)s from ever joining this room.": "Weiger iedereen die geen deel uitmaakt van %(serverName)s aan dit gesprek deel te nemen.", "Create a room in %(communityName)s": "Een gesprek aanmaken in %(communityName)s", "Enable end-to-end encryption": "Eind-tot-eind-versleuteling inschakelen", @@ -2467,7 +2467,7 @@ "Show": "Toon", "People you know on %(brand)s": "Personen die u kent van %(brand)s", "Add another email": "Nog een e-mailadres toevoegen", - "Download logs": "Download logboeken", + "Download logs": "Logs downloaden", "Add a new server...": "Een nieuwe server toevoegen…", "Server name": "Servernaam", "Add a new server": "Een nieuwe server toevoegen", @@ -2479,8 +2479,8 @@ "Enter a server name": "Geef een servernaam", "Continue with %(provider)s": "Doorgaan met %(provider)s", "Homeserver": "Homeserver", - "You can use the custom server options to sign into other Matrix servers by specifying a different homeserver URL. This allows you to use Element with an existing Matrix account on a different homeserver.": "U kunt de aangepaste serverinstellingen gebruiken om u aan te melden bij andere Matrix-servers, door een andere homeserver-URL in te voeren. Dit laat u toe Element te gebruiken met een bestaande Matrix-account bij een andere homeserver.", - "Server Options": "Serverinstellingen", + "You can use the custom server options to sign into other Matrix servers by specifying a different homeserver URL. This allows you to use Element with an existing Matrix account on a different homeserver.": "U kunt de server opties wijzigen om in te loggen bij andere Matrix-servers, wijzig hiervoor de homeserver-URL. Hiermee kunt u Element gebruiken met een al bestaand Matrix-account van een andere homeserver.", + "Server Options": "Server opties", "This address is already in use": "Dit adres is al in gebruik", "This address is available to use": "Dit adres kan worden gebruikt", "Please provide a room address": "Geef een gespreksadres", @@ -2539,7 +2539,7 @@ "Invite by email": "Via e-mail uitnodigen", "Click the button below to confirm your identity.": "Druk op de knop hieronder om uw identiteit te bevestigen.", "Confirm to continue": "Bevestig om door te gaan", - "Report a bug": "Een fout rapporteren", + "Report a bug": "Een bug rapporteren", "Comment": "Opmerking", "Add comment": "Opmerking toevoegen", "Tell us below how you feel about %(brand)s so far.": "Vertel ons hoe %(brand)s u tot dusver bevalt.", @@ -2640,7 +2640,7 @@ "Use this when referencing your community to others. The community ID cannot be changed.": "Gebruik dit om anderen naar uw gemeenschap te verwijzen. De gemeenschaps-ID kan later niet meer veranderd worden.", "Please go into as much detail as you like, so we can track down the problem.": "Gebruik a.u.b. zoveel mogelijk details, zodat wij uw probleem kunnen vinden.", "There are two ways you can provide feedback and help us improve %(brand)s.": "U kunt op twee manieren feedback geven en ons helpen %(brand)s te verbeteren.", - "Please view existing bugs on Github first. No match? Start a new one.": "Bekijk eerst de bestaande problemen op Github. Maak een nieuwe aan wanneer u uw probleem niet heeft gevonden.", + "Please view existing bugs on Github first. No match? Start a new one.": "Bekijk eerst de bestaande bugs op GitHub. Maak een nieuwe aan wanneer u uw bugs niet heeft gevonden.", "Invite someone using their name, email address, username (like ) or share this room.": "Nodig iemand uit door gebruik te maken van hun naam, e-mailadres, gebruikersnaam (zoals ) of deel dit gesprek.", "Invite someone using their name, username (like ) or share this room.": "Nodig iemand uit door gebruik te maken van hun naam, gebruikersnaam (zoals ) of deel dit gesprek.", "Send feedback": "Feedback versturen", @@ -2738,13 +2738,13 @@ "Use Security Key or Phrase": "Gebruik veiligheidssleutel of -wachtwoord", "Decide where your account is hosted": "Kies waar uw account wordt gehost", "Host account on": "Host uw account op", - "Already have an account? Sign in here": "Heeft u al een account? Aanmelden", + "Already have an account? Sign in here": "Heeft u al een account? Inloggen", "%(ssoButtons)s Or %(usernamePassword)s": "%(ssoButtons)s of %(usernamePassword)s", "Continue with %(ssoButtons)s": "Ga verder met %(ssoButtons)s", "That username already exists, please try another.": "Die gebruikersnaam bestaat al, probeer een andere.", "New? Create account": "Nieuw? Maak een account aan", "If you've joined lots of rooms, this might take a while": "Als u zich bij veel gesprekken heeft aangesloten, kan dit een tijdje duren", - "Signing In...": "Aanmelden...", + "Signing In...": "Inloggen...", "Syncing...": "Synchroniseren...", "There was a problem communicating with the homeserver, please try again later.": "Er was een communicatieprobleem met de homeserver, probeer het later opnieuw.", "Community and user menu": "Gemeenschaps- en gebruikersmenu", @@ -2754,7 +2754,7 @@ "User settings": "Gebruikersinstellingen", "Security & privacy": "Veiligheid & privacy", "New here? Create an account": "Nieuw hier? Maak een account", - "Got an account? Sign in": "Heeft u een account? Aanmelden", + "Got an account? Sign in": "Heeft u een account? Inloggen", "Failed to find the general chat for this community": "De algemene chat voor deze gemeenschap werd niet gevonden", "Filter rooms and people": "Gespreken en personen filteren", "Explore rooms in %(communityName)s": "Ontdek de gesprekken van %(communityName)s", @@ -2774,8 +2774,8 @@ "Create community": "Gemeenschap aanmaken", "Attach files from chat or just drag and drop them anywhere in a room.": "Voeg bestanden toe vanuit het gesprek of sleep ze in een gesprek.", "No files visible in this room": "Geen bestanden zichtbaar in dit gesprek", - "Sign in with SSO": "Aanmelden met SSO", - "Use email to optionally be discoverable by existing contacts.": "Gebruik e-mail om optioneel ontdekt te worden door bestaande contacten.", + "Sign in with SSO": "Inloggen met SSO", + "Use email to optionally be discoverable by existing contacts.": "Gebruik e-mail ook om optioneel ontdekt te worden door bestaande contacten.", "Use email or phone to optionally be discoverable by existing contacts.": "Gebruik e-mail of telefoon om optioneel ontdekt te kunnen worden door bestaande contacten.", "Add an email to be able to reset your password.": "Voeg een e-mail toe om uw wachtwoord te kunnen resetten.", "Forgot password?": "Wachtwoord vergeten?", @@ -2852,7 +2852,7 @@ "Continuing temporarily allows the %(hostSignupBrand)s setup process to access your account to fetch verified email addresses. This data is not stored.": "Door tijdelijk door te gaan, krijgt het installatieproces van %(hostSignupBrand)s toegang tot uw account om geverifieerde e-mailadressen op te halen. Deze gegevens worden niet opgeslagen.", "Failed to connect to your homeserver. Please close this dialog and try again.": "Kan geen verbinding maken met uw homeserver. Sluit dit dialoogvenster en probeer het opnieuw.", "Are you sure you wish to abort creation of the host? The process cannot be continued.": "Weet u zeker dat u het aanmaken van de host wilt afbreken? Het proces kan niet worden voortgezet.", - "PRO TIP: If you start a bug, please submit debug logs to help us track down the problem.": "PRO TIP: Als u een bug start, stuur ons dan debug logs om ons te helpen het probleem op te sporen.", + "PRO TIP: If you start a bug, please submit debug logs to help us track down the problem.": "PRO TIP: Als u een nieuwe bug maakt, stuur ons dan uw foutenlogboek om ons te helpen het probleem op te sporen.", "There was an error updating your community. The server is unable to process your request.": "Er is een fout opgetreden bij het updaten van uw gemeenschap. De server is niet in staat om uw verzoek te verwerken.", "There was an error finding this widget.": "Er is een fout opgetreden bij het vinden van deze widget.", "Server did not return valid authentication information.": "Server heeft geen geldige verificatiegegevens teruggestuurd.", @@ -2889,7 +2889,7 @@ "Submit logs": "Logs versturen", "Messages in this room are end-to-end encrypted. When people join, you can verify them in their profile, just tap on their avatar.": "Berichten in dit gesprek zijn eind-tot-eind-versleuteld. Als personen deelnemen, kan u ze verifiëren in hun profiel, tik hiervoor op hun avatar.", "In encrypted rooms, verify all users to ensure it’s secure.": "Controleer alle gebruikers in versleutelde gesprekken om er zeker van te zijn dat het veilig is.", - "Verify all users in a room to ensure it's secure.": "Controleer alle gebruikers in een gesprek om er zeker van te zijn dat hij veilig is.", + "Verify all users in a room to ensure it's secure.": "Controleer alle gebruikers in een gesprek om er zeker van te zijn dat het veilig is.", "%(count)s people|one": "%(count)s persoon", "Add widgets, bridges & bots": "Widgets, bruggen & bots toevoegen", "Edit widgets, bridges & bots": "Widgets, bruggen & bots bewerken", @@ -2900,7 +2900,7 @@ "Published addresses can be used by anyone on any server to join your room. To publish an address, it needs to be set as a local address first.": "Gepubliceerde adressen kunnen door iedereen op elke server gebruikt worden om aan je groep deel te nemen. Om een adres te publiceren moet het eerste ingesteld worden als lokaaladres.", "Published Addresses": "Gepubliceerde adressen", "Mentions & Keywords": "Vermeldingen & Trefwoorden", - "Use the + to make a new room or explore existing ones below": "Gebruik de + om een nieuw gesprek te starten of ontdek de bestaande groepen hieronder", + "Use the + to make a new room or explore existing ones below": "Gebruik de + om een nieuw gesprek te beginnen of ontdek de bestaande gesprekken hieronder", "Open dial pad": "Kiestoetsen openen", "Recently visited rooms": "Onlangs geopende gesprekken", "Add a photo, so people can easily spot your room.": "Voeg een foto toe, zodat personen u gemakkelijk kunnen herkennen in het gesprek.", @@ -2921,10 +2921,10 @@ "%(brand)s can't securely cache encrypted messages locally while running in a web browser. Use %(brand)s Desktop for encrypted messages to appear in search results.": "%(brand)s kan versleutelde berichten niet veilig lokaal opslaan in een webbrowser. Gebruik %(brand)s Desktop om versleutelde berichten in zoekresultaten te laten verschijnen.", "Securely cache encrypted messages locally for them to appear in search results, using %(size)s to store messages from %(rooms)s rooms.|one": "Veilig lokaal opslaan van versleutelde berichten zodat ze in de zoekresultaten verschijnen, gebruik %(size)s voor het opslaan van berichten uit %(rooms)s gesprek.", "Securely cache encrypted messages locally for them to appear in search results, using %(size)s to store messages from %(rooms)s rooms.|other": "Veilig lokaal opslaan van versleutelde berichten zodat ze in de zoekresultaten verschijnen, gebruik %(size)s voor het opslaan van berichten uit %(rooms)s gesprekken.", - "Individually verify each session used by a user to mark it as trusted, not trusting cross-signed devices.": "Elke sessie die door een gebruiker wordt gebruikt, afzonderlijk verifiëren om deze als vertrouwd aan te merken, waarbij geen vertrouwen wordt gesteld in kruiselings ondertekende apparaten.", - "User signing private key:": "Gebruiker ondertekening privésleutel:", - "Master private key:": "Hoofd privésleutel:", - "Self signing private key:": "Zelfondertekenende privésleutel:", + "Individually verify each session used by a user to mark it as trusted, not trusting cross-signed devices.": "Verifieer elke sessie die door een gebruiker wordt gebruikt afzonderlijk. Dit markeert hem als vertrouwd zonder te vertrouwen op kruislings ondertekende apparaten.", + "User signing private key:": "Gebruikerondertekening-privésleutel:", + "Master private key:": "Hoofdprivésleutel:", + "Self signing private key:": "Zelfondertekening-privésleutel:", "Cross-signing is not set up.": "Kruiselings ondertekenen is niet ingesteld.", "Cross-signing is ready for use.": "Kruiselings ondertekenen is klaar voor gebruik.", "Your server isn't responding to some requests.": "Uw server reageert niet op sommige verzoeken.", @@ -2941,13 +2941,13 @@ "Minimize dialog": "Dialoog minimaliseren", "If you cancel now, you may lose encrypted messages & data if you lose access to your logins.": "Als u nu annuleert, kunt u versleutelde berichten en gegevens verliezen als u geen toegang meer heeft tot uw login.", "Confirm your identity by verifying this login from one of your other sessions, granting it access to encrypted messages.": "Bevestig uw identiteit door deze login te verifiëren vanuit een van uw andere sessies, waardoor u toegang krijgt tot uw versleutelde berichten.", - "Verify this login": "Controleer deze login", + "Verify this login": "Deze inlog verifiëren", "To continue, use Single Sign On to prove your identity.": "Om verder te gaan, gebruik uw eenmalige aanmelding om uw identiteit te bewijzen.", "Prepends ( ͡° ͜ʖ ͡°) to a plain-text message": "Plakt ( ͡° ͜ʖ ͡°) vóór een bericht zonder opmaak", "Prepends ┬──┬ ノ( ゜-゜ノ) to a plain-text message": "Plakt ┬──┬ ノ( ゜-゜ノ) vóór een bericht zonder opmaak", "Prepends (╯°□°)╯︵ ┻━┻ to a plain-text message": "Plakt (╯°□°)╯︵ ┻━┻ vóór een bericht zonder opmaak", "Liberate your communication": "Bevrijd uw communicatie", - "Create a Group Chat": "Maak een groepsgesprek aan", + "Create a Group Chat": "Maak een groepsgesprek", "Send a Direct Message": "Start een direct gesprek", "Welcome to %(appName)s": "Welkom bij %(appName)s", "Add a topic to help people know what it is about.": "Stel een gespreksonderwerp in zodat de personen weten waar het over gaat.", @@ -3024,7 +3024,7 @@ "Start audio stream": "Audio-stream starten", "Failed to start livestream": "Starten van livestream is mislukt", "Unable to start audio streaming.": "Kan audio-streaming niet starten.", - "Save Changes": "Wijzigingen Opslaan", + "Save Changes": "Wijzigingen opslaan", "Saving...": "Opslaan...", "View dev tools": "Bekijk dev tools", "Leave Space": "Space verlaten", @@ -3039,7 +3039,7 @@ "Failed to add rooms to space": "Het toevoegen van gesprekken aan de space is mislukt", "Apply": "Toepassen", "Applying...": "Toepassen...", - "Create a new room": "Een nieuw gesprek aanmaken", + "Create a new room": "Nieuw gesprek aanmaken", "Don't want to add an existing room?": "Wilt u geen bestaand gesprek toevoegen?", "Spaces": "Spaces", "Filter your rooms and spaces": "Gesprekken en spaces filteren", @@ -3063,7 +3063,7 @@ "New room": "Nieuw gesprek", "Leave space": "Space verlaten", "Invite people": "Personen uitnodigen", - "Share your public space": "Deel uw publieke space", + "Share your public space": "Deel uw openbare space", "Invite members": "Leden uitnodigen", "Invite by email or username": "Uitnodigen per e-mail of gebruikersnaam", "Share invite link": "Deel uitnodigingskoppeling", @@ -3087,5 +3087,171 @@ "This homeserver has been blocked by it's administrator.": "Deze homeserver is geblokkeerd door zijn beheerder.", "This homeserver has been blocked by its administrator.": "Deze homeserver is geblokkeerd door uw beheerder.", "Already in call": "Al in gesprek", - "You're already in a call with this person.": "U bent al in gesprek met deze persoon." + "You're already in a call with this person.": "U bent al in gesprek met deze persoon.", + "Verify this login to access your encrypted messages and prove to others that this login is really you.": "Verifieer deze login om toegang te krijgen tot uw versleutelde berichten en om anderen te bewijzen dat deze login echt van u is.", + "Verify with another session": "Verifieer met een andere sessie", + "We'll create rooms for each of them. You can add more later too, including already existing ones.": "We zullen voor elk een gesprek maken. U kunt er later meer toevoegen, inclusief al bestaande gesprekken.", + "Let's create a room for each of them. You can add more later too, including already existing ones.": "Laten we voor elk een gesprek maken. U kunt er later meer toevoegen, inclusief al bestaande gesprekken.", + "Make sure the right people have access. You can invite more later.": "Controleer of de juiste mensen toegang hebben. U kunt later meer mensen uitnodigen.", + "A private space to organise your rooms": "Een privé space om uw gesprekken te organiseren", + "Just me": "Alleen ik", + "Make sure the right people have access to %(name)s": "Controleer of de juiste mensen toegang hebben tot %(name)s", + "Go to my first room": "Ga naar mijn eerste gesprek", + "It's just you at the moment, it will be even better with others.": "Het is alleen u op dit moment, het zal nog beter zijn met anderen.", + "Share %(name)s": "Deel %(name)s", + "Private space": "Privé space", + "Public space": "Openbare space", + " invites you": " nodigt u uit", + "Search names and description": "Zoek in namen en beschrijvingen", + "Create room": "Gesprek aanmaken", + "You may want to try a different search or check for typos.": "U kunt een andere zoekterm proberen of controleren op een typefout.", + "No results found": "Geen resultaten gevonden", + "Mark as suggested": "Markeer als aanbeveling", + "Mark as not suggested": "Markeer als geen aanbeveling", + "Removing...": "Verwijderen...", + "Failed to remove some rooms. Try again later": "Het verwijderen van sommige gesprekken is mislukt. Probeer het opnieuw", + "%(count)s rooms and 1 space|one": "%(count)s gesprek en 1 space", + "%(count)s rooms and 1 space|other": "%(count)s gesprekken en 1 space", + "%(count)s rooms and %(numSpaces)s spaces|one": "%(count)s gesprek en %(numSpaces)s spaces", + "%(count)s rooms and %(numSpaces)s spaces|other": "%(count)s gesprekken en %(numSpaces)s spaces", + "If you can't find the room you're looking for, ask for an invite or create a new room.": "Als u uw gesprek niet kan vinden, vraag dan om een uitnodiging of maak een nieuw gesprek.", + "Suggested": "Aanbevolen", + "This room is suggested as a good one to join": "Dit is een aanbevolen gesprek om aan deel te nemen", + "%(count)s rooms|one": "%(count)s gesprek", + "%(count)s rooms|other": "%(count)s gesprekken", + "You don't have permission": "U heeft geen toestemming", + "Open": "Openen", + "%(count)s messages deleted.|one": "%(count)s bericht verwijderd.", + "%(count)s messages deleted.|other": "%(count)s berichten verwijderd.", + "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.": "Normaal gesproken heeft dit alleen invloed op het verwerken van het gesprek op de server. Als u problemen ervaart met %(brand)s, stuur dan een bugmelding.", + "Invite to %(roomName)s": "Uitnodiging voor %(roomName)s", + "Edit devices": "Apparaten bewerken", + "Invite People": "Mensen uitnodigen", + "Invite with email or username": "Uitnodigen per e-mail of gebruikersnaam", + "You can change these anytime.": "U kan dit elk moment nog aanpassen.", + "Add some details to help people recognise it.": "Voeg details toe zodat mensen het herkennen.", + "Spaces are new ways to group rooms and people. To join an existing space you'll need an invite.": "Spaces zijn een nieuwe manier voor het groeperen van gesprekken. Voor deelname aan een bestaande space heeft u een uitnodiging nodig.", + "From %(deviceName)s (%(deviceId)s) at %(ip)s": "Van %(deviceName)s (%(deviceId)s) op %(ip)s", + "Check your devices": "Controleer uw apparaten", + "A new login is accessing your account: %(name)s (%(deviceID)s) at %(ip)s": "Een nieuwe login heeft toegang tot uw account: %(name)s (%(deviceID)s) op %(ip)s", + "You have unverified logins": "U heeft ongeverifieerde logins", + "Without verifying, you won’t have access to all your messages and may appear as untrusted to others.": "Zonder verifiëren heeft u geen toegang tot al uw berichten en kan u als onvertrouwd aangemerkt staan bij anderen.", + "Verify your identity to access encrypted messages and prove your identity to others.": "Verifeer uw identiteit om toegang te krijgen tot uw versleutelde berichten en om uw identiteit te bewijzen voor anderen.", + "Use another login": "Gebruik andere login", + "Please choose a strong password": "Kies een sterk wachtwoord", + "You can add more later too, including already existing ones.": "U kunt er later nog meer toevoegen, inclusief al bestaande gesprekken.", + "Let's create a room for each of them.": "Laten we voor elk een los gesprek maken.", + "What are some things you want to discuss in %(spaceName)s?": "Wat wilt u allemaal bespreken in %(spaceName)s?", + "Verification requested": "Verificatieverzocht", + "Avatar": "Avatar", + "Verify other login": "Verifieer andere login", + "You most likely do not want to reset your event index store": "U wilt waarschijnlijk niet uw gebeurtenisopslag-index resetten", + "Reset event store?": "Gebeurtenisopslag resetten?", + "Reset event store": "Gebeurtenisopslag resetten", + "If you do, please note that none of your messages will be deleted, but the search experience might be degraded for a few momentswhilst the index is recreated": "Als u dit wilt, let op uw berichten worden niet verwijderd, zal het zoeken tijdelijk minder goed werken terwijl we uw index opnieuw opbouwen", + "Consult first": "Eerst overleggen", + "Invited people will be able to read old messages.": "Uitgenodigde personen kunnen de oude berichten lezen.", + "We couldn't create your DM.": "We konden uw DM niet aanmaken.", + "Adding...": "Toevoegen...", + "Add existing rooms": "Bestaande gesprekken toevoegen", + "%(count)s people you know have already joined|one": "%(count)s persoon die u kent is al geregistreerd", + "%(count)s people you know have already joined|other": "%(count)s personen die u kent hebben zijn al geregistreerd", + "Accept on your other login…": "Accepteer op uw andere login…", + "Stop & send recording": "Stop & verstuur opname", + "Record a voice message": "Audiobericht opnemen", + "Invite messages are hidden by default. Click to show the message.": "Uitnodigingen zijn standaard verborgen. Klik om de uitnodigingen weer te geven.", + "Quick actions": "Snelle acties", + "Invite to just this room": "Uitnodigen voor alleen dit gesprek", + "Warn before quitting": "Waarschuwen voordat u afsluit", + "Message search initilisation failed": "Zoeken in berichten opstarten is mislukt", + "Manage & explore rooms": "Beheer & ontdek gesprekken", + "Consulting with %(transferTarget)s. Transfer to %(transferee)s": "Overleggen met %(transferTarget)s. Verstuur naar %(transferee)s", + "unknown person": "onbekend persoon", + "Share decryption keys for room history when inviting users": "Deel ontsleutelsleutels voor de gespreksgeschiedenis wanneer u personen uitnodigd", + "Send and receive voice messages (in development)": "Verstuur en ontvang audioberichten (in ontwikkeling)", + "%(deviceId)s from %(ip)s": "%(deviceId)s van %(ip)s", + "Review to ensure your account is safe": "Controleer ze zodat uw account veilig is", + "Sends the given message as a spoiler": "Verstuurt het bericht als een spoiler", + "You are the only person here. If you leave, no one will be able to join in the future, including you.": "U bent de enige persoon hier. Als u weggaat, zal niemand in de toekomst kunnen toetreden, u ook niet.", + "If you reset everything, you will restart with no trusted sessions, no trusted users, and might not be able to see past messages.": "Als u alles reset, zult u opnieuw opstarten zonder vertrouwde sessies, zonder vertrouwde gebruikers, en zult u misschien geen vroegere berichten meer kunnen zien.", + "Only do this if you have no other device to complete verification with.": "Doe dit alleen als u geen ander apparaat hebt om de verificatie mee uit te voeren.", + "Reset everything": "Alles opnieuw instellen", + "Forgotten or lost all recovery methods? Reset all": "Alles vergeten of alle herstelmethoden verloren? Alles opnieuw instellen", + "If you do, please note that none of your messages will be deleted, but the search experience might be degraded for a few moments whilst the index is recreated": "Als u dat doet, let wel geen van uw berichten wordt verwijderd, maar de zoekresultaten zullen gedurende enkele ogenblikken verslechteren terwijl de index opnieuw wordt aangemaakt", + "View message": "Bericht bekijken", + "Zoom in": "Inzoomen", + "Zoom out": "Uitzoomen", + "%(seconds)ss left": "%(seconds)s's over", + "Change server ACLs": "Wijzig server ACL's", + "Show options to enable 'Do not disturb' mode": "Toon opties om de 'Niet storen' modus in te schakelen", + "You can select all or individual messages to retry or delete": "U kunt alles selecteren of per individueel bericht opnieuw verzenden of verwijderen", + "Sending": "Wordt verstuurd", + "Retry all": "Alles opnieuw proberen", + "Delete all": "Verwijder alles", + "Some of your messages have not been sent": "Enkele van uw berichten zijn niet verstuurd", + "%(count)s members including %(commaSeparatedMembers)s|one": "%(commaSeparatedMembers)s", + "%(count)s members including %(commaSeparatedMembers)s|other": "%(count)s leden inclusief %(commaSeparatedMembers)s", + "Including %(commaSeparatedMembers)s": "Inclusief %(commaSeparatedMembers)s", + "View all %(count)s members|one": "1 lid bekijken", + "View all %(count)s members|other": "Bekijk alle %(count)s leden", + "Failed to send": "Verzenden is mislukt", + "Enter your Security Phrase a second time to confirm it.": "Voor uw veiligheidswachtwoord een tweede keer in om het te bevestigen.", + "Pick rooms or conversations to add. This is just a space for you, no one will be informed. You can add more later.": "Kies een gesprek om hem toe te voegen. Dit is een space voor u, niemand zal hiervan een melding krijgen. U kan er later meer toevoegen.", + "What do you want to organise?": "Wat wilt u organiseren?", + "Filter all spaces": "Alle spaces filteren", + "Delete recording": "Opname verwijderen", + "Stop the recording": "Opname stoppen", + "%(count)s results in all spaces|one": "%(count)s resultaat in alle spaces", + "%(count)s results in all spaces|other": "%(count)s resultaten in alle spaces", + "You have no ignored users.": "U heeft geen gebruiker genegeerd.", + "Play": "Afspelen", + "Pause": "Pauze", + "This is an experimental feature. For now, new users receiving an invite will have to open the invite on to actually join.": "Dit is een experimentele functie. Voorlopig moeten nieuwe personen die een uitnodiging krijgen de gebruiken om daadwerkelijk deel te nemen.", + "To join %(spaceName)s, turn on the Spaces beta": "Om aan %(spaceName)s deel te nemen moet u de Spaces beta inschakelen", + "To view %(spaceName)s, turn on the Spaces beta": "Om %(spaceName)s te bekijken moet u de Spaces beta inschakelen", + "Select a room below first": "Start met selecteren van een gesprek hieronder", + "Communities are changing to Spaces": "Gemeenschappen worden vervangen door Spaces", + "Join the beta": "Aan beta deelnemen", + "Leave the beta": "Beta verlaten", + "Beta": "Beta", + "Tap for more info": "Klik voor meer info", + "Spaces is a beta feature": "Spaces zijn in beta", + "Want to add a new room instead?": "Wilt u anders een nieuw gesprek toevoegen?", + "Adding rooms... (%(progress)s out of %(count)s)|one": "Gesprek toevoegen...", + "Adding rooms... (%(progress)s out of %(count)s)|other": "Gesprekken toevoegen... (%(progress)s van %(count)s)", + "Not all selected were added": "Niet alle geselecteerden zijn toegevoegd", + "You can add existing spaces to a space.": "U kunt bestaande spaces toevoegen aan een space.", + "Feeling experimental?": "Zin in een experiment?", + "You are not allowed to view this server's rooms list": "U heeft geen toegang tot deze server zijn gesprekkenlijst", + "Error processing voice message": "Fout bij verwerking spraakbericht", + "We didn't find a microphone on your device. Please check your settings and try again.": "We hebben geen microfoon gevonden op uw apparaat. Controleer uw instellingen en probeer het opnieuw.", + "No microphone found": "Geen microfoon gevonden", + "We were unable to access your microphone. Please check your browser settings and try again.": "We hebben geen toegang tot uw microfoon. Controleer uw browserinstellingen en probeer het opnieuw.", + "Unable to access your microphone": "Geen toegang tot uw microfoon", + "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.": "Zin in een experiment? Labs is de beste manier om dingen vroeg te krijgen, nieuwe functies uit te testen en ze te helpen vormen voordat ze daadwerkelijk worden gelanceerd. Lees meer.", + "Your access token gives full access to your account. Do not share it with anyone.": "Uw toegangstoken geeft u toegang to uw account. Deel hem niet met anderen.", + "Access Token": "Toegangstoken", + "Spaces are a new way to group rooms and people. To join an existing space you'll need an invite.": "Spaces zijn de nieuwe manier om gesprekken en personen te groeperen. Om aan een bestaande space deel te nemen heeft u een uitnodiging nodig.", + "Please enter a name for the space": "Vul een naam in voor deze space", + "Connecting": "Verbinden", + "Allow Peer-to-Peer for 1:1 calls (if you enable this, the other party might be able to see your IP address)": "Peer-to-peer voor 1op1 oproepen toestaan (als u dit inschakelt kunnen andere personen mogelijk uw ipadres zien)", + "Beta available for web, desktop and Android. Some features may be unavailable on your homeserver.": "Beta beschikbaar voor web, desktop en Android. Sommige functies zijn nog niet beschikbaar op uw homeserver.", + "You can leave the beta any time from settings or tapping on a beta badge, like the one above.": "U kunt de beta elk moment verlaten via instellingen of door op de beta badge hierboven te klikken.", + "%(brand)s will reload with Spaces enabled. Communities and custom tags will be hidden.": "%(brand)s zal herladen met Spaces ingeschakeld. Gemeenschappen en labels worden verborgen.", + "Beta available for web, desktop and Android. Thank you for trying the beta.": "Beta beschikbaar voor web, desktop en Android. Bedankt dat u de beta wilt proberen.", + "%(brand)s will reload with Spaces disabled. Communities and custom tags will be visible again.": "%(brand)s zal herladen met Spaces uitgeschakeld. Gemeenschappen en labels zullen weer zichtbaar worden.", + "Spaces are a new way to group rooms and people.": "Spaces zijn de nieuwe manier om gesprekken en personen te groeperen.", + "Message search initialisation failed": "Zoeken in berichten opstarten is mislukt", + "Spaces are a beta feature.": "Spaces zijn een beta functie.", + "Search names and descriptions": "Namen en beschrijvingen zoeken", + "You may contact me if you have any follow up questions": "U mag contact met mij opnemen als u nog vervolg vragen heeft", + "To leave the beta, visit your settings.": "Om de beta te verlaten, ga naar uw instellingen.", + "Your platform and username will be noted to help us use your feedback as much as we can.": "Uw platform en gebruikersnaam zullen worden opgeslagen om onze te helpen uw feedback zo goed mogelijk te gebruiken.", + "%(featureName)s beta feedback": "%(featureName)s beta feedback", + "Thank you for your feedback, we really appreciate it.": "Bedankt voor uw feedback, we waarderen het enorm.", + "Beta feedback": "Beta feedback", + "Add reaction": "Reactie toevoegen", + "Send and receive voice messages": "Stuur en ontvang spraakberichten", + "Your feedback will help make spaces better. The more detail you can go into, the better.": "Uw feedback maakt spaces beter. Hoe meer details u kan geven, des te beter.", + "If you leave, %(brand)s will reload with Spaces disabled. Communities and custom tags will be visible again.": "Als u de pagina nu verlaat zal %(brand)s herladen met Spaces uitgeschakeld. Gemeenschappen en labels zullen weer zichtbaar worden." } diff --git a/src/i18n/strings/oc.json b/src/i18n/strings/oc.json index cd62ff69db..d882b04ac9 100644 --- a/src/i18n/strings/oc.json +++ b/src/i18n/strings/oc.json @@ -62,7 +62,7 @@ "Server error": "Error servidor", "Single Sign On": "Autentificacion unica", "Confirm": "Confirmar", - "Dismiss": "Far desaparéisser", + "Dismiss": "Refusar", "OK": "D’acòrdi", "Continue": "Contunhar", "Go Back": "En arrièr", @@ -118,7 +118,7 @@ "Incoming call": "Sonada entranta", "Accept": "Acceptar", "Start": "Començament", - "Cancelling…": "Anullacion...", + "Cancelling…": "Anullacion…", "Fish": "Pes", "Butterfly": "Parpalhòl", "Tree": "Arborescéncia", @@ -338,5 +338,13 @@ "Esc": "Escap", "Enter": "Entrada", "Space": "Espaci", - "End": "Fin" + "End": "Fin", + "Explore rooms": "Percórrer las salas", + "Create Account": "Crear un compte", + "Click the button below to confirm adding this email address.": "Clicatz sus lo boton aicí dejós per confirmar l'adicion de l'adreça e-mail.", + "Confirm adding email": "Confirmar l'adicion de l'adressa e-mail", + "Confirm adding this email address by using Single Sign On to prove your identity.": "Confirmatz l'adicion d'aquela adreça e-mail en utilizant l'autentificacion unica per provar la vòstra identitat.", + "Use Single Sign On to continue": "Utilizar l'autentificacion unica (SSO) per contunhar", + "This phone number is already in use": "Aquel numèro de telefòn es ja utilizat", + "This email address is already in use": "Aquela adreça e-mail es ja utilizada" } diff --git a/src/i18n/strings/pl.json b/src/i18n/strings/pl.json index 7a98a8f003..83c6c25833 100644 --- a/src/i18n/strings/pl.json +++ b/src/i18n/strings/pl.json @@ -1277,8 +1277,8 @@ "Enable desktop notifications for this session": "Włącz powiadomienia na pulpicie dla tej sesji", "Enable audible notifications for this session": "Włącz powiadomienia dźwiękowe dla tej sesji", "Direct Messages": "Wiadomości bezpośrednie", - "Create Account": "Utwórz konto", - "Sign In": "Zaloguj się", + "Create Account": "Stwórz konto", + "Sign In": "Zaloguj", "a few seconds ago": "kilka sekund temu", "%(num)s minutes ago": "%(num)s minut temu", "%(num)s hours ago": "%(num)s godzin temu", @@ -1410,7 +1410,7 @@ "Integrations are disabled": "Integracje są wyłączone", "Enable 'Manage Integrations' in Settings to do this.": "Włącz „Zarządzaj integracjami” w ustawieniach, aby to zrobić.", "Encryption upgrade available": "Dostępna aktualizacja szyfrowania", - "Upgrade": "Uaktualnij", + "Upgrade": "Ulepsz", "Delete sessions|other": "Usuń sesje", "Delete %(count)s sessions|one": "Usuń %(count)s sesję", "Manage": "Zarządzaj", @@ -2265,5 +2265,11 @@ "There was an error finding this widget.": "Wystąpił błąd podczas próby odnalezienia tego widżetu.", "Active Widgets": "Aktywne widżety", "Encryption not enabled": "Nie włączono szyfrowania", - "Encryption enabled": "Włączono szyfrowanie" + "Encryption enabled": "Włączono szyfrowanie", + "Your homeserver was unreachable and was not able to log you in. Please try again. If this continues, please contact your homeserver administrator.": "Twój serwer domowy był nieosiągalny i nie mógł Cię zalogować. Spróbuj ponownie. Jeśli to się powtórzy, skontaktuj się z administratorem swojego serwera.", + "Try again": "Spróbuj ponownie", + "We asked the browser to remember which homeserver you use to let you sign in, but unfortunately your browser has forgotten it. Go to the sign in page and try again.": "Poprosiliśmy przeglądarkę o zapamiętanie, z którego serwera głównego korzystasz, aby umożliwić Ci logowanie, ale niestety Twoja przeglądarka o tym zapomniała. Przejdź do strony logowania i spróbuj ponownie.", + "We couldn't log you in": "Nie mogliśmy Cię zalogować", + "You're already in a call with this person.": "Prowadzisz już rozmowę z tą osobą.", + "Already in call": "Już dzwoni" } diff --git a/src/i18n/strings/pt.json b/src/i18n/strings/pt.json index f72edc150d..4047aae760 100644 --- a/src/i18n/strings/pt.json +++ b/src/i18n/strings/pt.json @@ -569,5 +569,8 @@ "Try using turn.matrix.org": "Tente utilizar turn.matrix.org", "Whether you're using %(brand)s on a device where touch is the primary input mechanism": "Quer esteja a usar o %(brand)s num dispositivo onde o touch é o mecanismo de entrada primário", "Whether you're using %(brand)s as an installed Progressive Web App": "Quer esteja a usar o %(brand)s como uma Progressive Web App (PWA)", - "Your user agent": "O seu user agent" + "Your user agent": "O seu user agent", + "Explore rooms": "Explorar rooms", + "Sign In": "Iniciar sessão", + "Create Account": "Criar conta" } diff --git a/src/i18n/strings/pt_BR.json b/src/i18n/strings/pt_BR.json index 0ec835362a..8497ae7164 100644 --- a/src/i18n/strings/pt_BR.json +++ b/src/i18n/strings/pt_BR.json @@ -1175,7 +1175,7 @@ "Learn More": "Saiba mais", "Sign In or Create Account": "Faça login ou crie uma conta", "Use your account or create a new one to continue.": "Use sua conta ou crie uma nova para continuar.", - "Create Account": "Criar conta", + "Create Account": "Criar Conta", "Sign In": "Entrar", "Custom (%(level)s)": "Personalizado (%(level)s)", "Messages": "Mensagens", diff --git a/src/i18n/strings/ro.json b/src/i18n/strings/ro.json index aa87d0a912..062a89f2e3 100644 --- a/src/i18n/strings/ro.json +++ b/src/i18n/strings/ro.json @@ -70,5 +70,9 @@ "Add to community": "Adăugați la comunitate", "Failed to invite the following users to %(groupId)s:": "Nu a putut fi invitat următorii utilizatori %(groupId)s", "Failed to invite users to community": "Nu a fost posibilă invitarea utilizatorilor la comunitate", - "Failed to invite users to %(groupId)s": "Nu a fost posibilă invitarea utilizatorilor la %(groupId)s" + "Failed to invite users to %(groupId)s": "Nu a fost posibilă invitarea utilizatorilor la %(groupId)s", + "Explore rooms": "Explorează camerele", + "Sign In": "Autentificare", + "Create Account": "Înregistare", + "Dismiss": "Închide" } diff --git a/src/i18n/strings/ru.json b/src/i18n/strings/ru.json index 4cf444ac0e..da42347b49 100644 --- a/src/i18n/strings/ru.json +++ b/src/i18n/strings/ru.json @@ -730,7 +730,7 @@ "Enable them now": "Включить их сейчас", "Toolbox": "Панель инструментов", "Collecting logs": "Сбор журналов", - "You must specify an event type!": "Необходимо указать тип мероприятия!", + "You must specify an event type!": "Необходимо указать тип события!", "(HTTP status %(httpStatus)s)": "(статус HTTP %(httpStatus)s)", "Invite to this room": "Пригласить в комнату", "Send logs": "Отправить журналы", @@ -770,13 +770,13 @@ "Wednesday": "Среда", "You can now return to your account after signing out, and sign in on other devices.": "Теперь вы сможете вернуться к своей учётной записи после выхода и войти на других устройствах.", "Enable email notifications": "Включить уведомления на email", - "Event Type": "Тип мероприятия", + "Event Type": "Тип события", "Download this file": "Скачать файл", "Pin Message": "Закрепить сообщение", "Failed to change settings": "Не удалось изменить настройки", "View Community": "Просмотр сообщества", "Event sent!": "Событие отправлено!", - "Event Content": "Содержание мероприятия", + "Event Content": "Содержимое события", "Thank you!": "Спасибо!", "Quote": "Цитата", "With your current browser, the look and feel of the application may be completely incorrect, and some or all features may not function. If you want to try it anyway you can continue, but you are on your own in terms of any issues you may encounter!": "В текущем браузере внешний вид приложения может быть полностью неверным, а некоторые или все функции могут не работать. Если вы хотите попробовать в любом случае, то можете продолжить, но с теми проблемами, с которыми вы можете столкнуться вам придется разбираться самостоятельно!", @@ -831,7 +831,7 @@ "You can't send any messages until you review and agree to our terms and conditions.": "Вы не можете отправлять сообщения до тех пор, пока вы не примете наши правила и положения.", "Demote": "Понижение", "Demote yourself?": "Понизить самого себя?", - "This event could not be displayed": "Это событие отобразить невозможно", + "This event could not be displayed": "Не удалось отобразить это событие", "Permission Required": "Требуется разрешение", "You do not have permission to start a conference call in this room": "У вас нет разрешения на запуск конференции в этой комнате", "A call is currently being placed!": "Есть активный вызов!", @@ -3074,5 +3074,142 @@ "Failed to save settings": "Не удалось сохранить настройки", "Show chat effects (animations when receiving e.g. confetti)": "Показать эффекты чата (анимация при получении, например, конфетти)", "Caution:": "Предупреждение:", - "Settings Explorer": "Обзор настроек" + "Settings Explorer": "Обзор настроек", + "Suggested": "Рекомендуется", + "This room is suggested as a good one to join": "Эта комната рекомендуется, чтобы присоединиться", + "%(count)s rooms|one": "%(count)s комната", + "%(count)s rooms|other": "%(count)s комнат", + "%(count)s members|one": "%(count)s участник", + "%(count)s members|other": "%(count)s участников", + "You don't have permission": "У вас нет разрешения", + "Open": "Открыть", + "Your message wasn't sent because this homeserver has been blocked by it's administrator. Please contact your service administrator to continue using the service.": "Ваше сообщение не было отправлено, потому что этот домашний сервер был заблокирован администратором. Пожалуйста, обратитесь к вашему администратору, чтобы продолжить использование сервиса.", + "%(count)s messages deleted.|one": "%(count)s сообщение удалено.", + "%(count)s messages deleted.|other": "%(count)s сообщений удалено.", + "Are you sure you want to leave the space '%(spaceName)s'?": "Вы уверены, что хотите покинуть пространство \"%(spaceName)s\"?", + "This space is not public. You will not be able to rejoin without an invite.": "Это пространство не публично. Вы не сможете вновь войти без приглашения.", + "Unable to start audio streaming.": "Невозможно запустить аудио трансляцию.", + "Start audio stream": "Запустить аудио трансляцию", + "Failed to start livestream": "Не удалось запустить прямую трансляцию", + "Save Changes": "Сохранить изменения", + "Saving...": "Сохранение…", + "View dev tools": "Просмотр инструментов для разработчиков", + "Leave Space": "Покинуть пространство", + "Make this space private": "Сделать это пространство приватным", + "Edit settings relating to your space.": "Редактировать настройки, относящиеся к вашему пространству.", + "Space settings": "Настройки пространства", + "Failed to save space settings.": "Не удалось сохранить настройки пространства.", + "This usually only affects how the room is processed on the server. If you're having problems with your %(brand)s, please report a bug.": "Обычно это только влияет на то, как комната обрабатывается на сервере. Если у вас проблемы с вашим %(brand)s, сообщите об ошибке.", + "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 to %(roomName)s": "Пригласить в %(roomName)s", + "Unnamed Space": "Безымянное пространство", + "Invite to %(spaceName)s": "Пригласить в %(spaceName)s", + "Setting definition:": "Установка определения:", + "Failed to add rooms to space": "Не удалось добавить комнаты в пространство", + "Apply": "Применить", + "Applying...": "Применение…", + "Create a new room": "Создать новую комнату", + "Don't want to add an existing room?": "Не хотите добавить существующую комнату?", + "Spaces": "Пространства", + "Filter your rooms and spaces": "Отфильтруйте свои комнаты и пространства", + "Add existing spaces/rooms": "Добавить существующие пространства/комнаты", + "Space selection": "Выбор пространства", + "Edit devices": "Редактировать устройства", + "You will not be able to undo this change as you are demoting yourself, if you are the last privileged user in the space it will be impossible to regain privileges.": "Вы не сможете отменить это изменение, поскольку вы понижаете свои права, если вы являетесь последним привилегированным пользователем в пространстве, будет невозможно восстановить привилегии вбудущем.", + "Invite People": "Пригласить людей", + "Empty room": "Пустая комната", + "Suggested Rooms": "Предлагаемые комнаты", + "Explore space rooms": "Исследовать комнаты пространства", + "You do not have permissions to add rooms to this space": "У вас нет разрешений, чтобы добавить комнаты в это пространство", + "Add existing room": "Добавить существующую комнату", + "You do not have permissions to create new rooms in this space": "У вас нет разрешений для создания новых комнат в этом пространстве", + "Send message": "Отправить сообщение", + "Invite to this space": "Пригласить в это пространство", + "Your message was sent": "Ваше сообщение было отправлено", + "Encrypting your message...": "Шифрование вашего сообщения…", + "Sending your message...": "Отправка вашего сообщения…", + "Spell check dictionaries": "Словари для проверки орфографии", + "Space options": "Настройки пространства", + "Space Home": "Домашняя страница пространства", + "New room": "Новая комната", + "Leave space": "Покинуть пространство", + "Share your public space": "Поделитесь своим публичным пространством", + "Invite members": "Пригласить участников", + "Invite with email or username": "Пригласить по электронной почте или имени пользователя", + "Invite people": "Пригласить людей", + "Share invite link": "Поделиться ссылкой на приглашение", + "Click to copy": "Нажмите, чтобы скопировать", + "Collapse space panel": "Свернуть панель пространств", + "Expand space panel": "Развернуть панель пространств", + "Creating...": "Создание…", + "You can change this later": "Вы можете изменить это позже", + "You can change these anytime.": "Вы можете изменить их в любое время.", + "Add some details to help people recognise it.": "Добавьте некоторые подробности, чтобы помочь людям узнать его.", + "Your private space": "Ваше приватное пространство", + "Your public space": "Ваше публичное пространство", + "Invite only, best for yourself or teams": "Только по приглашениям, лучший вариант для себя или команды", + "Private": "Приватное", + "Open space for anyone, best for communities": "Открытое пространство для всех, лучший вариант для сообществ", + "Public": "Публичное", + "Create a space": "Создать пространство", + "Spaces are new ways to group rooms and people. To join an existing space you'll need an invite.": "Пространства являются новыми способами группировки комнат и людей. Чтобы присоединиться к существующему пространству, вам понадобится приглашение.", + "Delete": "Удалить", + "From %(deviceName)s (%(deviceId)s) at %(ip)s": "От %(deviceName)s (%(deviceId)s) %(ip)s", + "Jump to the bottom of the timeline when you send a message": "Перейти к нижней части временной шкалы, когда вы отправляете сообщение", + "Spaces prototype. Incompatible with Communities, Communities v2 and Custom Tags. Requires compatible homeserver for some features.": "Прототип пространства. Несовместимо с сообществами, сообществами версии 2 и пользовательскими тегами. Требуется совместимый домашний сервер для некоторых функций.", + "Check your devices": "Проверьте ваши устройства", + "You have unverified logins": "У вас есть непроверенные входы в систему", + "A new login is accessing your account: %(name)s (%(deviceID)s) at %(ip)s": "Новый вход в систему через вашу учётную запись: %(name)s (%(deviceID)s) %(ip)s", + "This homeserver has been blocked by it's administrator.": "Доступ к этому домашнему серверу заблокирован вашим администратором.", + "This homeserver has been blocked by its administrator.": "Доступ к этому домашнему серверу заблокирован вашим администратором.", + "You're already in a call with this person.": "Вы уже разговариваете с этим человеком.", + "Already in call": "Уже в вызове", + "Original event source": "Оригинальный исходный код", + "Decrypted event source": "Расшифрованный исходный код", + "%(count)s rooms and %(numSpaces)s spaces|one": "%(count)s комната и %(numSpaces)s пространств", + "%(count)s rooms and %(numSpaces)s spaces|other": "%(count)s комнат и %(numSpaces)s пространств", + "If you can't find the room you're looking for, ask for an invite or create a new room.": "Если вы не можете найти комнату, попросите приглашение или создайте новую комнату.", + "Values at explicit levels in this room:": "Значения уровня чувствительности в этой комнате:", + "Values at explicit levels:": "Значения уровня чувствительности:", + "Values at explicit levels in this room": "Значения уровня чувствительности в этой комнате", + "Values at explicit levels": "Значения уровня чувствительности", + "We'll create rooms for each of them. You can add more later too, including already existing ones.": "Мы создадим комнаты для каждого из них. Вы можете добавить ещё больше позже, включая уже существующие.", + "What projects are you working on?": "Над какими проектами вы работаете?", + "Invite by username": "Пригласить по имени пользователя", + "Make sure the right people have access. You can invite more later.": "Убедитесь, что правильные люди имеют доступ. Вы можете пригласить больше людей позже.", + "Invite your teammates": "Пригласите своих товарищей по команде", + "Inviting...": "Приглашение…", + "Failed to invite the following users to your space: %(csvUsers)s": "Не удалось пригласить следующих пользователей в ваше пространство: %(csvUsers)s", + "Me and my teammates": "Я и мои товарищи по команде", + "A private space for you and your teammates": "Приватное пространство для вас и ваших товарищей по команде", + "A private space to organise your rooms": "Приватное пространство для организации ваших комнат", + "Just me": "Только я", + "Make sure the right people have access to %(name)s": "Убедитесь, что правильные люди имеют доступ к %(name)s", + "Who are you working with?": "С кем ты работаешь?", + "Go to my first room": "Перейти в мою первую комнату", + "It's just you at the moment, it will be even better with others.": "Сейчас здесь только ты, с другими будет ещё лучше.", + "Share %(name)s": "Поделиться %(name)s", + "Creating rooms...": "Создание комнат…", + "Skip for now": "Пропустить сейчас", + "Failed to create initial space rooms": "Не удалось создать первоначальные комнаты пространства", + "Room name": "Название комнаты", + "Support": "Поддержка", + "Random": "Случайный", + "Welcome to ": "Добро пожаловать в ", + "Your server does not support showing space hierarchies.": "Ваш сервер не поддерживает отображение пространственных иерархий.", + "Add existing rooms & spaces": "Добавить существующие комнаты и пространства", + "Private space": "Приватное пространство", + "Public space": "Публичное пространство", + " invites you": " пригласил(а) тебя", + "Search names and description": "Искать имена и описание", + "You may want to try a different search or check for typos.": "Вы можете попробовать другой поиск или проверить опечатки.", + "No results found": "Результаты не найдены", + "Mark as suggested": "Отметить как рекомендуется", + "Mark as not suggested": "Отметить как не рекомендуется", + "Removing...": "Удаление…", + "Failed to remove some rooms. Try again later": "Не удалось удалить несколько комнат. Попробуйте позже", + "%(count)s rooms and 1 space|one": "%(count)s комната и одно пространство", + "%(count)s rooms and 1 space|other": "%(count)s комнат и одно пространство", + "Sends the given message as a spoiler": "Отправить данное сообщение под спойлером" } diff --git a/src/i18n/strings/sl.json b/src/i18n/strings/sl.json index 0e9bdb3d3e..aa2019ad45 100644 --- a/src/i18n/strings/sl.json +++ b/src/i18n/strings/sl.json @@ -27,5 +27,7 @@ "Your homeserver's URL": "URL domačega strežnika", "End": "Konec", "Use default": "Uporabi privzeto", - "Change": "Sprememba" + "Change": "Sprememba", + "Explore rooms": "Raziščite sobe", + "Create Account": "Registracija" } diff --git a/src/i18n/strings/sq.json b/src/i18n/strings/sq.json index cb08a6b5f9..8935b5e348 100644 --- a/src/i18n/strings/sq.json +++ b/src/i18n/strings/sq.json @@ -874,7 +874,7 @@ "Incompatible Database": "Bazë të dhënash e Papërputhshme", "Continue With Encryption Disabled": "Vazhdo Me Fshehtëzimin të Çaktivizuar", "Unable to load! Check your network connectivity and try again.": "S’arrihet të ngarkohet! Kontrolloni lidhjen tuaj në rrjet dhe riprovoni.", - "Forces the current outbound group session in an encrypted room to be discarded": "", + "Forces the current outbound group session in an encrypted room to be discarded": "E detyron të hidhet tej sesionin e tanishëm outbound grupi në një dhomë të fshehtëzuar", "Delete Backup": "Fshije Kopjeruajtjen", "Unable to load key backup status": "S’arrihet të ngarkohet gjendje kopjeruajtjeje kyçesh", "Backup version: ": "Version kopjeruajtjeje: ", @@ -2987,7 +2987,7 @@ "We'll store an encrypted copy of your keys on our server. Secure your backup with a Security Phrase.": "Do të ruajmë një kopje të fshehtëzuar të kyçeve tuaj në shërbyesin tonë. Siguroni kopjeruajtjen tuaj me një Frazë Sigurie.", "Use Security Key": "Përdorni Kyç Sigurie", "A new Security Phrase and key for Secure Messages have been detected.": "Janë pikasur një Frazë e re Sigurie dhe kyç i ri për Mesazhe të Sigurt.", - "If you've forgotten your Security Key you can ": "Nëse keni harruar Kyçin tuaj të Sigurisë, mund të .", + "If you've forgotten your Security Key you can ": "Nëse keni harruar Kyçin tuaj të Sigurisë, mund të ", "Your Security Key has been copied to your clipboard, paste it to:": "Kyçi juaj i Sigurisë është kopjuar te e papastra juaj, ngjiteni te:", "Confirm your Security Phrase": "Ripohoni Frazën tuaj të Sigurisë", "Secure your backup with a Security Phrase": "Sigurojeni kopjeruajtjen tuaj me një Frazë Sigurie", @@ -3130,7 +3130,7 @@ "View dev tools": "Shihni mjete zhvilluesi", "Leave Space": "Braktiseni Hapësirën", "Make this space private": "Bëje këtë hapësirë private", - "Edit settings relating to your space.": "Përpunoni rregullime që lidhen me hapësirën tuaj", + "Edit settings relating to your space.": "Përpunoni rregullime që lidhen me hapësirën tuaj.", "Space settings": "Rregullime hapësire", "Failed to save space settings.": "S’u arrit të ruhen rregullime hapësire.", "Invite someone using their name, username (like ) or share this space.": "Ftoni dikë duke përdorur emrin e tij, emrin e tij të përdoruesit (bie fjala, ) ose ndani me të këtë hapësirë.", @@ -3191,5 +3191,170 @@ "This homeserver has been blocked by it's administrator.": "Ky shërbyes Home është bllokuar nga përgjegjësi i tij.", "This homeserver has been blocked by its administrator.": "Ky shërbyes Home është bllokuar nga përgjegjësit e tij.", "You're already in a call with this person.": "Gjendeni tashmë në thirrje me këtë person.", - "Already in call": "Tashmë në thirrje" + "Already in call": "Tashmë në thirrje", + "Verify this login to access your encrypted messages and prove to others that this login is really you.": "Verifikoni këto kredenciale për hyrje te mesazhet tuaja të fshehtëzuara dhe dëshmojuni të tjerëve se këto kredenciale hyrjeje janë vërtet tuajat.", + "Verify with another session": "Verifikojeni me tjetër sesion", + "We'll create rooms for each of them. You can add more later too, including already existing ones.": "Do të krijojmë dhoma për çdo një prej tyre. Mund të shtoni edhe të tjera më vonë, përfshi ato ekzistueset tashmë.", + "Let's create a room for each of them. You can add more later too, including already existing ones.": "Le të krijojmë një dhomë për secilën prej tyre. Mund të shtoni të tjera më vonë, përfshi ato ekzistuese tashmë.", + "Make sure the right people have access. You can invite more later.": "Siguroni se kanë hyrje personat e duhur. Mund të shtoni të tjerë më vonë.", + "A private space to organise your rooms": "Një hapësirë private për të sistemuar dhomat tuaja", + "Just me": "Vetëm unë", + "Make sure the right people have access to %(name)s": "Siguroni se te %(name)s kanë hyrje personat e duhur", + "Go to my first room": "Kalo te dhoma ime e parë", + "It's just you at the moment, it will be even better with others.": "Vetëm ju, hëpërhë, do të jetë edhe më mirë me të tjerë.", + "Share %(name)s": "Ndajeni %(name)s me të tjerët", + "Private space": "Hapësirë private", + "Public space": "Hapësirë publike", + " invites you": " ju fton", + "Search names and description": "Kërkoni emra dhe përshkrim", + "You may want to try a different search or check for typos.": "Mund të doni të provoni një tjetër kërkim ose të kontrolloni për gabime shkrimi.", + "No results found": "S’u gjetën përfundime", + "Mark as suggested": "Vëri shenjë si e sugjeruar", + "Mark as not suggested": "Hiqi shenjë si e sugjeruar", + "Removing...": "Po hiqet…", + "Failed to remove some rooms. Try again later": "S’ua arrit të hiqen disa dhoma. Riprovoni më vonë", + "%(count)s rooms and 1 space|one": "%(count)s dhomë dhe 1 hapësirë", + "%(count)s rooms and 1 space|other": "%(count)s dhoma dhe 1 hapësirë", + "%(count)s rooms and %(numSpaces)s spaces|one": "%(count)s dhomë dhe %(numSpaces)s hapësira", + "%(count)s rooms and %(numSpaces)s spaces|other": "%(count)s dhoma dhe %(numSpaces)s hapësira", + "If you can't find the room you're looking for, ask for an invite or create a new room.": "Nëse s’gjeni dot dhomën që po kërkoni, kërkoni një ftesë ose krijoni një dhomë të re.", + "Suggested": "E sugjeruar", + "This room is suggested as a good one to join": "Kjo dhomë sugjerohet si një e mirë për të marrë pjesë", + "%(count)s rooms|one": "%(count)s dhomë", + "%(count)s rooms|other": "%(count)s dhoma", + "You don't have permission": "S’keni leje", + "You’re all caught up": "Jeni në rregull", + "%(count)s messages deleted.|one": "%(count)s mesazh i fshirë.", + "%(count)s messages deleted.|other": "%(count)s mesazhe të fshirë.", + "Failed to start livestream": "S’u arrit të nisej transmetim i drejtpërdrejtë", + "You're all caught up.": "Jeni në rregull.", + "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.": "Kjo zakonisht prek vetëm mënyrën se si përpunohet dhoma te shërbyesi. Nëse keni probleme me %(brand)s-in, ju lutemi, njoftoni një të metë.", + "Invite to %(roomName)s": "Ftojeni te %(roomName)s", + "Windows": "Windows", + "Edit devices": "Përpunoni pajisje", + "Invite People": "Ftoni Njerëz", + "Invite with email or username": "Ftoni përmes email-i ose emri përdoruesi", + "You can change these anytime.": "Këto mund t’i ndryshoni në çfarëdo kohe.", + "Add some details to help people recognise it.": "Shtoni ca hollësi që të ndihmoni njerëzit ta dallojnë.", + "Spaces are new ways to group rooms and people. To join an existing space you'll need an invite.": "Hapësirat janë rrugë e re për të grupuar dhoma dhe njerëz. Për t’u bërë pjesë e një hapësire ekzistuese, do t’ju duhet një ftesë.", + "From %(deviceName)s (%(deviceId)s) at %(ip)s": "Nga %(deviceName)s (%(deviceId)s) te %(ip)s", + "Check your devices": "Kontrolloni pajisjet tuaja", + "A new login is accessing your account: %(name)s (%(deviceID)s) at %(ip)s": "Në llogarinë tuaj po hyhet nga një palë kredenciale të reja: %(name)s (%(deviceID)s) te %(ip)s", + "You have unverified logins": "Keni kredenciale të erifikuar", + "Without verifying, you won’t have access to all your messages and may appear as untrusted to others.": "Pa e verifikuar, s’do të mund të hyni te krejt mesazhet tuaja dhe mund të dukeni jo i besueshëm për të tjerët.", + "Verify your identity to access encrypted messages and prove your identity to others.": "Verifikoni identitetin tuaj që të hyhet në mesazhe të fshehtëzuar dhe t’u provoni të tjerëve identitetin tuaj.", + "Use another login": "Përdorni të tjera kredenciale hyrjesh", + "Please choose a strong password": "Ju lutemi, zgjidhni një fjalëkalim të fuqishëm", + "You can add more later too, including already existing ones.": "Mund të shtoni edhe të tjera më vonë, përfshi ato ekzistueset tashmë.", + "Let's create a room for each of them.": "Le të krijojmë një dhomë për secilën prej tyre.", + "What are some things you want to discuss in %(spaceName)s?": "Cilat janë disa nga gjërat që doni të diskutoni në %(spaceName)s?", + "Verification requested": "U kërkua verifikim", + "Avatar": "Avatar", + "Verify other login": "Verifikoni kredencialet e tjera për hyrje", + "If you do, please note that none of your messages will be deleted, but the search experience might be degraded for a few momentswhilst the index is recreated": "Nëse e bëni, ju lutemi, kini parasysh se s’do të fshihet asnjë prej mesazheve tuaja, por puna me kërkimet mund të bjerë, për ca çaste, teksa rikrijohet treguesi", + "Consult first": "Konsultohu së pari", + "Invited people will be able to read old messages.": "Personat e ftuar do të jenë në gjendje të lexojnë mesazhe të vjetër.", + "We couldn't create your DM.": "S’e krijuam dot DM-në tuaj.", + "Adding...": "Po shtohet…", + "Add existing rooms": "Shtoni dhoma ekzistuese", + "%(count)s people you know have already joined|one": "%(count)s person që e njihni është bërë pjesë tashmë", + "%(count)s people you know have already joined|other": "%(count)s persona që i njihni janë bërë pjesë tashmë", + "Stop & send recording": "Ndale & dërgo incizimin", + "Record a voice message": "Incizoni një mesazh zanor", + "Invite messages are hidden by default. Click to show the message.": "Mesazhet e ftesave, si parazgjedhje, janë të fshehur. Klikoni që të shfaqet mesazhi.", + "Quick actions": "Veprime të shpejta", + "Invite to just this room": "Ftoje thjesht te kjo dhomë", + "Warn before quitting": "Sinjalizo përpara daljes", + "Message search initilisation failed": "Dështoi gatitje kërkimi mesazhesh", + "Manage & explore rooms": "Administroni & eksploroni dhoma", + "unknown person": "person i panjohur", + "Sends the given message as a spoiler": "E dërgon mesazhin e dhënë si spoiler", + "Share decryption keys for room history when inviting users": "Ndani me përdorues kyçe shfshehtëzimi, kur ftohen përdorues", + "Send and receive voice messages (in development)": "Dërgoni dhe merrni mesazhe zanorë (në zhvillim)", + "%(deviceId)s from %(ip)s": "%(deviceId)s prej %(ip)s", + "Review to ensure your account is safe": "Shqyrtojeni për t’u siguruar se llogaria është e parrezik", + "You are the only person here. If you leave, no one will be able to join in the future, including you.": "Jeni i vetmi person këtu. Nëse e braktisni, askush s’do të jetë në gjendje të hyjë në të ardhmen, përfshi ju.", + "If you reset everything, you will restart with no trusted sessions, no trusted users, and might not be able to see past messages.": "Nëse riktheni gjithçka te parazgjedhjet, do të rifilloni pa sesione të besuara, pa përdorues të besuar, dhe mund të mos jeni në gjendje të shihni mesazhe të dikurshëm.", + "Only do this if you have no other device to complete verification with.": "Bëjeni këtë vetëm nëse s’keni pajisje tjetër me të cilën të plotësoni verifikimin.", + "Reset everything": "Kthe gjithçka te parazgjedhjet", + "Forgotten or lost all recovery methods? Reset all": "Harruat, ose humbët krejt metodat e rimarrjes? Riujdisini të gjitha", + "If you do, please note that none of your messages will be deleted, but the search experience might be degraded for a few moments whilst the index is recreated": "Nëse e bëni, ju lutemi, kini parasysh se s’do të fshihet asnjë nga mesazhet tuaj, por puna me kërkimin mund degradojë për pak çaste, ndërkohë që rikrijohet treguesi", + "View message": "Shihni mesazh", + "%(seconds)ss left": "Edhe %(seconds)ss", + "Change server ACLs": "Ndryshoni ACL-ra shërbyesi", + "Consulting with %(transferTarget)s. Transfer to %(transferee)s": "Po kryhet këshillim me %(transferTarget)s. Shpërngule te %(transferee)s", + "Show options to enable 'Do not disturb' mode": "Shfaq mundësi për aktivizim të mënyrës “Mos më shqetësoni”", + "You can select all or individual messages to retry or delete": "Për riprovim ose fshirje mund të përzgjidhni krejt mesazhet, ose të tillë individualë", + "Sending": "Po dërgohet", + "Retry all": "Riprovoji krejt", + "Delete all": "Fshiji krejt", + "Some of your messages have not been sent": "Disa nga mesazhet tuaj s’janë dërguar", + "%(count)s members including %(commaSeparatedMembers)s|one": "%(commaSeparatedMembers)s", + "%(count)s members including %(commaSeparatedMembers)s|other": "%(count)s anëtarë, përfshi %(commaSeparatedMembers)s", + "Including %(commaSeparatedMembers)s": "Prfshi %(commaSeparatedMembers)s", + "View all %(count)s members|one": "Shihni 1 anëtar", + "View all %(count)s members|other": "Shihni krejt %(count)s anëtarët", + "Failed to send": "S’u arrit të dërgohet", + "Enter your Security Phrase a second time to confirm it.": "Jepni Frazën tuaj të Sigurisë edhe një herë, për ta ripohuar.", + "Pick rooms or conversations to add. This is just a space for you, no one will be informed. You can add more later.": "Zgjidhni dhoma ose biseda që të shtohen. Kjo është thjesht një hapësirë për ju, s’do ta dijë kush tjetër. Mund të shtoni të tjerë më vonë.", + "What do you want to organise?": "Ç’doni të sistemoni?", + "Filter all spaces": "Filtro krejt hapësirat", + "Delete recording": "Fshije regjistrimin", + "Stop the recording": "Ndale regjistrimin", + "%(count)s results in all spaces|one": "%(count)s përfundim në krejt hapësirat", + "%(count)s results in all spaces|other": "%(count)s përfundime në krejt hapësirat", + "You have no ignored users.": "S’keni përdorues të shpërfillur.", + "Play": "Luaje", + "Pause": "Ndalesë", + "This is an experimental feature. For now, new users receiving an invite will have to open the invite on to actually join.": "Kjo është një veçori eksperimentale. Hëpërhë, përdoruesve të rinj që marrin një ftesë, do t’u duhet ta hapin ftesën në , që të marrin pjesë.", + "To join %(spaceName)s, turn on the Spaces beta": "Për të hyrë në %(spaceName)s, aktivizoni beta-n për Hapësira", + "To view %(spaceName)s, turn on the Spaces beta": "Për të parë %(spaceName)s, aktivizoni beta-n për Hapësira", + "Spaces are a beta feature.": "Hapësirat janë një veçori në version beta.", + "Search names and descriptions": "Kërko te emra dhe përshkrime", + "Select a room below first": "Së pari, përzgjidhni më poshtë një dhomë", + "Communities are changing to Spaces": "Bashkësitë po ndryshojnë në Hapësira", + "Join the beta": "Merrni pjesë te beta", + "Leave the beta": "Braktiseni beta-n", + "Beta": "Beta", + "Tap for more info": "Për më tepër hollësi, prekeni", + "Spaces is a beta feature": "Hapësirat janë një veçori në version beta", + "You may contact me if you have any follow up questions": "Mund të lidheni me mua, nëse keni pyetje të mëtejshme", + "To leave the beta, visit your settings.": "Që të braktisni beta-n, vizitoni rregullimet tuaja.", + "Your platform and username will be noted to help us use your feedback as much as we can.": "Platforma dhe emri juaj i përdoruesit do të mbahen shënim, për të na ndihmuar t’i përdorim përshtypjet tuaja sa më shumë që të mundemi.", + "%(featureName)s beta feedback": "Përshtypje për beta %(featureName)s", + "Thank you for your feedback, we really appreciate it.": "Faleminderit për përshtypjet tuaja, vërtet e çmojmë.", + "Beta feedback": "Përshtypje për versionin Beta", + "Want to add a new room instead?": "Doni të shtohet një dhomë e re, në vend të kësaj?", + "Adding rooms... (%(progress)s out of %(count)s)|one": "Po shtohet dhomë…", + "Adding rooms... (%(progress)s out of %(count)s)|other": "Po shtohen dhoma… (%(progress)s nga %(count)s)", + "Not all selected were added": "S’u shtuan të gjithë të përzgjedhurit", + "You can add existing spaces to a space.": "Mund të shtoni hapësira ekzistuese te një hapësirë.", + "Feeling experimental?": "Ndiheni eksperimentues?", + "You are not allowed to view this server's rooms list": "S’keni leje të shihni listën e dhomave të këtij shërbyesi", + "Zoom in": "Zmadhoje", + "Zoom out": "Zvogëloje", + "Add reaction": "Shtoni reagim", + "Error processing voice message": "Gabim në përpunimin e mesazhit zanor", + "Accept on your other login…": "Pranojeni te hyrja tjetër e juaja…", + "We didn't find a microphone on your device. Please check your settings and try again.": "S’gjetëm mikrofon në pajisjen tuaj. Ju lutemi, kontrolloni rregullimet tuaja dhe riprovoni.", + "No microphone found": "S’u gjet mikrofon", + "We were unable to access your microphone. Please check your browser settings and try again.": "S’qemë në gjendje të përdorim mikrofonin tuaj. Ju lutemi, kontrolloni rregullimet e shfletuesit tuaj dhe riprovoni.", + "Unable to access your microphone": "S’arrihet të përdoret mikrofoni juaj", + "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.": "Ndiheni eksperimentues? Laboratorët janë rruga më e mirë për t’u marrë herët me gjërat, për të provuar veçori të reja dhe për të ndihmuar t’u jepet formë atyre, përpara se të hidhen faktikisht në qarkullim. Mësoni më tepër.", + "Your access token gives full access to your account. Do not share it with anyone.": "Tokeni-i juaj i hyrjeve jep hyrje të plotë në llogarinë tuaj. Mos ia jepni kujt.", + "Access Token": "Token Hyrjesh", + "Spaces are a new way to group rooms and people. To join an existing space you'll need an invite.": "Hapësirat janë rrugë e re për të grupuar dhoma dhe njerëz. Për t’u bërë pjesë e një hapësire ekzistuese, do t’ju duhet një ftesë.", + "Please enter a name for the space": "Ju lutemi, jepni një emër për hapësirën", + "Connecting": "Po lidhet", + "Allow Peer-to-Peer for 1:1 calls (if you enable this, the other party might be able to see your IP address)": "Lejo Tek-për-Tek për thirrje 1:1 (nëse e aktivizoni këtë, pala tjetër mund të jetë në gjendje të shohë adresën tuaj IP)", + "New spinner design": "Rrotullues i ri", + "Send and receive voice messages": "Dërgoni dhe merrni mesazhe zanorë", + "Your feedback will help make spaces better. The more detail you can go into, the better.": "Përshtypjet tuaja do t’i bëjnë hapësirat më të mira. Sa më shumë hollësi që të jepni, aq më mirë.", + "Beta available for web, desktop and Android. Some features may be unavailable on your homeserver.": "Beta e gatshme për web, desktop dhe Android. Disa veçori mund të mos jenë të përdorshme në shërbyesin tuaj Home.", + "You can leave the beta any time from settings or tapping on a beta badge, like the one above.": "Beta-n mund ta braktisni në çfarëdo kohe, që nga rregullimet, ose duke prekur një stemë beta, si ajo më sipër.", + "%(brand)s will reload with Spaces enabled. Communities and custom tags will be hidden.": "%(brand)s do të ringarkohet me Hapësirat të aktivizuara. Bashkësitë dhe etiketat vetjake do të jenë të fshehura.", + "Beta available for web, desktop and Android. Thank you for trying the beta.": "Beta e gatshme për web, desktop dhe Android. Faleminderit që provoni beta-n.", + "If you leave, %(brand)s will reload with Spaces disabled. Communities and custom tags will be visible again.": "Nëse ikni, %(brand)s-i do të ringarkohet me Hapësira të çaktivizuara. Bashkësitë dhe etiketat vetjake do të jenë sërish të dukshme.", + "Spaces are a new way to group rooms and people.": "Hapësirat janë një rrugë e re për të grupuar dhoma dhe njerëz.", + "Message search initialisation failed": "Dështoi gatitje kërkimi mesazhesh" } diff --git a/src/i18n/strings/sr.json b/src/i18n/strings/sr.json index ca79955f72..49f87321f7 100644 --- a/src/i18n/strings/sr.json +++ b/src/i18n/strings/sr.json @@ -87,42 +87,42 @@ "%(senderName)s requested a VoIP conference.": "%(senderName)s је затражио VoIP конференцију.", "%(senderName)s invited %(targetName)s.": "%(senderName)s је позвао %(targetName)s.", "%(senderName)s banned %(targetName)s.": "%(senderName)s је бановао %(targetName)s.", - "%(senderName)s set their display name to %(displayName)s.": "%(senderName)s постави приказно име на %(displayName)s.", - "%(senderName)s removed their display name (%(oldDisplayName)s).": "Корисник %(senderName)s је себи уклонио приказно име %(oldDisplayName)s.", - "%(senderName)s removed their profile picture.": "Корисник %(senderName)s је себи уклонио профилну слику.", - "%(senderName)s changed their profile picture.": "Корисник %(senderName)s је себи променио профилну слику.", - "%(senderName)s set a profile picture.": "Корисник %(senderName)s је себи поставио профилну слику.", + "%(senderName)s set their display name to %(displayName)s.": "%(senderName)s је поставио приказно име на %(displayName)s.", + "%(senderName)s removed their display name (%(oldDisplayName)s).": "%(senderName)s је себи уклонио приказно име %(oldDisplayName)s.", + "%(senderName)s removed their profile picture.": "%(senderName)s је себи уклонио профилну слику.", + "%(senderName)s changed their profile picture.": "%(senderName)s је себи променио профилну слику.", + "%(senderName)s set a profile picture.": "%(senderName)s је себи поставио профилну слику.", "VoIP conference started.": "VoIP конференција је започета.", - "%(targetName)s joined the room.": "Корисник %(targetName)s је ушао у собу.", + "%(targetName)s joined the room.": "%(targetName)s је ушао у собу.", "VoIP conference finished.": "VoIP конференција је завршена.", - "%(targetName)s rejected the invitation.": "Корисник %(targetName)s је одбацио позивницу.", - "%(targetName)s left the room.": "Корисник %(targetName)s је напустио собу.", - "%(senderName)s unbanned %(targetName)s.": "Корисник %(senderName)s је скинуо забрану приступа са %(targetName)s.", - "%(senderName)s kicked %(targetName)s.": "Корисник %(senderName)s је избацио %(targetName)s.", - "%(senderName)s withdrew %(targetName)s's invitation.": "Пошиљалац %(senderName)s је повукао позивницу за %(targetName)s.", - "%(senderDisplayName)s changed the topic to \"%(topic)s\".": "Корисник %(senderDisplayName)s је променио тему у „%(topic)s“.", - "%(senderDisplayName)s removed the room name.": "Корисник %(senderDisplayName)s је уклонио назив собе.", - "%(senderDisplayName)s changed the room name to %(roomName)s.": "Корисник %(senderDisplayName)s је променио назив собе у %(roomName)s.", - "%(senderDisplayName)s sent an image.": "Корисник %(senderDisplayName)s је послао слику.", + "%(targetName)s rejected the invitation.": "%(targetName)s је одбацио позивницу.", + "%(targetName)s left the room.": "%(targetName)s је напустио собу.", + "%(senderName)s unbanned %(targetName)s.": "%(senderName)s је скинуо забрану приступа са %(targetName)s.", + "%(senderName)s kicked %(targetName)s.": "%(senderName)s је избацио %(targetName)s.", + "%(senderName)s withdrew %(targetName)s's invitation.": "%(senderName)s је повукао позивницу за %(targetName)s.", + "%(senderDisplayName)s changed the topic to \"%(topic)s\".": "%(senderDisplayName)s је променио тему у „%(topic)s“.", + "%(senderDisplayName)s removed the room name.": "%(senderDisplayName)s је уклонио назив собе.", + "%(senderDisplayName)s changed the room name to %(roomName)s.": "%(senderDisplayName)s је променио назив собе у %(roomName)s.", + "%(senderDisplayName)s sent an image.": "%(senderDisplayName)s је послао слику.", "Someone": "Неко", "(not supported by this browser)": "(није подржано од стране овог прегледача)", - "%(senderName)s answered the call.": "Корисник %(senderName)s се јавио.", + "%(senderName)s answered the call.": "%(senderName)s се јавио.", "(could not connect media)": "(не могу да повежем медије)", "(no answer)": "(нема одговора)", "(unknown failure: %(reason)s)": "(непозната грешка: %(reason)s)", - "%(senderName)s ended the call.": "Корисник %(senderName)s је окончао позив.", - "%(senderName)s sent an invitation to %(targetDisplayName)s to join the room.": "Корисник %(senderName)s је послао позивницу за приступ соби ка %(targetDisplayName)s.", - "%(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 је учинио будући историјат собе видљивим свим члановима собе.", - "%(senderName)s made future room history visible to anyone.": "Корисник %(senderName)s је учинио будући историјат собе видљивим свима.", - "%(senderName)s made future room history visible to unknown (%(visibility)s).": "Корисник %(senderName)s је учинио будући историјат собе непознатим (%(visibility)s).", + "%(senderName)s ended the call.": "%(senderName)s је окончао позив.", + "%(senderName)s sent an invitation to %(targetDisplayName)s to join the room.": "%(senderName)s је послао позивницу за приступ соби ка %(targetDisplayName)s.", + "%(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 је учинио будући историјат собе видљивим свим члановима собе.", + "%(senderName)s made future room history visible to anyone.": "%(senderName)s је учинио будући историјат собе видљивим свима.", + "%(senderName)s made future room history visible to unknown (%(visibility)s).": "%(senderName)s је учинио будући историјат собе непознатим (%(visibility)s).", "%(userId)s from %(fromPowerLevel)s to %(toPowerLevel)s": "%(userId)s са %(fromPowerLevel)s на %(toPowerLevel)s", "%(senderName)s changed the power level of %(powerLevelDiffText)s.": "%(senderName)s промени ниво снаге за %(powerLevelDiffText)s.", - "%(senderName)s changed the pinned messages for the room.": "Корисник %(senderName)s је променио закачене поруке у соби.", - "%(widgetName)s widget modified by %(senderName)s": "Корисник %(senderName)s је променио виџет %(widgetName)s", - "%(widgetName)s widget added by %(senderName)s": "Корисник %(senderName)s је додао виџет %(widgetName)s", - "%(widgetName)s widget removed by %(senderName)s": "Корисник %(senderName)s је уклонио виџет %(widgetName)s", + "%(senderName)s changed the pinned messages for the room.": "%(senderName)s је променио закачене поруке у соби.", + "%(widgetName)s widget modified by %(senderName)s": "%(senderName)s је променио виџет %(widgetName)s", + "%(widgetName)s widget added by %(senderName)s": "%(senderName)s је додао виџет %(widgetName)s", + "%(widgetName)s widget removed by %(senderName)s": "%(senderName)s је уклонио виџет %(widgetName)s", "Failure to create room": "Неуспех при прављењу собе", "Server may be unavailable, overloaded, or you hit a bug.": "Сервер је можда недоступан, преоптерећен или сте нашли грешку.", "Send": "Пошаљи", @@ -443,7 +443,7 @@ "Create Room": "Направи собу", "Unknown error": "Непозната грешка", "Incorrect password": "Нетачна лозинка", - "Deactivate Account": "Угаси налог", + "Deactivate Account": "Деактивирај налог", "An error has occurred.": "Догодила се грешка.", "OK": "У реду", "Unable to restore session": "Не могу да повратим сесију", @@ -521,7 +521,7 @@ "Analytics": "Аналитика", "The information being sent to us to help make %(brand)s better includes:": "У податке које нам шаљете зарад побољшавања %(brand)s-а спадају:", "Where this page includes identifiable information, such as a room, user or group ID, that data is removed before being sent to the server.": "Ако страница садржи поверљиве податке (као што је назив собе, ИД корисника или групе), ти подаци се уклањају пре слања на сервер.", - "%(oldDisplayName)s changed their display name to %(displayName)s.": "Корисник %(oldDisplayName)s је променио приказно име у %(displayName)s.", + "%(oldDisplayName)s changed their display name to %(displayName)s.": "%(oldDisplayName)s је променио приказно име у %(displayName)s.", "Failed to set direct chat tag": "Нисам успео да поставим ознаку директног ћаскања", "Failed to remove tag %(tagName)s from room": "Нисам успео да скинем ознаку %(tagName)s са собе", "Failed to add tag %(tagName)s to room": "Нисам успео да додам ознаку %(tagName)s на собу", @@ -838,7 +838,7 @@ "This room has no topic.": "Ова соба нема тему.", "Sets the room name": "Поставља назив собе", "Forces the current outbound group session in an encrypted room to be discarded": "Присиљава одбацивање тренутне одлазне сесије групе у шифрованој соби", - "%(senderDisplayName)s upgraded this room.": "Корисник %(senderDisplayName)s је надоградио ову собу.", + "%(senderDisplayName)s upgraded this room.": "%(senderDisplayName)s је надоградио ову собу.", "Free": "Бесплатан", "Join millions for free on the largest public server": "Придружите се милионима других бесплатно на највећем јавном серверу", "Premium": "Премијум", @@ -877,10 +877,10 @@ "Room name or address": "Назив собе или адреса", "Identity server has no terms of service": "Идентитетски сервер нема услове коришћења", "Changes your avatar in all rooms": "Промените ваш аватар у свим собама", - "%(senderName)s placed a voice call.": "Корисник %(senderName)s је започео гласовни позив.", - "%(senderName)s placed a voice call. (not supported by this browser)": "Корисник %(senderName)s је започео гласовни позив. (није подржано од стране овог прегледача)", - "%(senderName)s placed a video call.": "Корисник %(senderName)s је започео видео позив.", - "%(senderName)s placed a video call. (not supported by this browser)": "Корисник %(senderName)s је започео видео позив. (није подржано од стране овог прегледача)", + "%(senderName)s placed a voice call.": "%(senderName)s је започео гласовни позив.", + "%(senderName)s placed a voice call. (not supported by this browser)": "%(senderName)s је започео гласовни позив. (није подржано од стране овог прегледача)", + "%(senderName)s placed a video call.": "%(senderName)s је започео видео позив.", + "%(senderName)s placed a video call. (not supported by this browser)": "%(senderName)s је започео видео позив. (није подржано од стране овог прегледача)", "You do not have permission to invite people to this room.": "Немате дозволу за позивање људи у ову собу.", "Set up encryption": "Подеси шифровање", "Encryption upgrade available": "Надоградња шифровања је доступна", @@ -903,7 +903,7 @@ "Set a new account password...": "Подеси нову лозинку налога…", "Language and region": "Језик и област", "General": "Опште", - "Discovery": "Откривање", + "Discovery": "Откриће", "None": "Ништа", "Security & Privacy": "Безбедност и приватност", "Change room name": "Промени назив собе", @@ -1359,18 +1359,18 @@ "Unexpected error resolving homeserver configuration": "Неочекивана грешка при откривању подешавања сервера", "No homeserver URL provided": "Није наведен УРЛ сервера", "Cannot reach homeserver": "Сервер недоступан", - "%(senderName)s added the alternative addresses %(addresses)s for this room.|other": "%(senderName)s додаде алтернативну адресу %(addresses)s за ову собу.", - "%(senderName)s removed the main address for this room.": "%(senderName)s уклони главну адресу за ову собу.", - "%(senderName)s set the main address for this room to %(address)s.": "%(senderName)s постави главну адресу собе на %(address)s.", + "%(senderName)s added the alternative addresses %(addresses)s for this room.|other": "%(senderName)s је додао алтернативну адресу %(addresses)s за ову собу.", + "%(senderName)s removed the main address for this room.": "%(senderName)s је уклони главну адресу за ову собу.", + "%(senderName)s set the main address for this room to %(address)s.": "%(senderName)s је постави главну адресу собе на %(address)s.", "🎉 All servers are banned from participating! This room can no longer be used.": "🎉 Свим серверима је забрањено да учествују! Ова соба се више не може користити.", - "%(senderDisplayName)s changed guest access to %(rule)s": "%(senderDisplayName)s измени гостински приступ на %(rule)s", - "%(senderDisplayName)s has prevented guests from joining the room.": "%(senderDisplayName)s спречи госте да се придруже у соби.", - "%(senderDisplayName)s has allowed guests to join the room.": "%(senderDisplayName)s дозволи гостима да се придруже у собу.", - "%(senderDisplayName)s changed the join rule to %(rule)s": "%(senderDisplayName)s измени правило придруживања на %(rule)s", - "%(senderDisplayName)s made the room invite only.": "%(senderDisplayName)s учини собу доступном само позивницом.", - "%(senderDisplayName)s made the room public to whoever knows the link.": "%(senderDisplayName)s учини собу јавном за све који знају везу.", - "%(senderDisplayName)s changed the room name from %(oldRoomName)s to %(newRoomName)s.": "%(senderDisplayName)s измени назив собе из %(oldRoomName)s у %(newRoomName)s.", - "%(senderName)s made no change.": "%(senderName)s не направи измене.", + "%(senderDisplayName)s changed guest access to %(rule)s": "%(senderDisplayName)s је изменио гостински приступ на %(rule)s", + "%(senderDisplayName)s has prevented guests from joining the room.": "%(senderDisplayName)s је спречио госте да се придруже у соби.", + "%(senderDisplayName)s has allowed guests to join the room.": "%(senderDisplayName)s је дозволи гостима да се придруже у собу.", + "%(senderDisplayName)s changed the join rule to %(rule)s": "%(senderDisplayName)s је измени правило придруживања на %(rule)s", + "%(senderDisplayName)s made the room invite only.": "%(senderDisplayName)s је учини собу доступном само позивницом.", + "%(senderDisplayName)s made the room public to whoever knows the link.": "%(senderDisplayName)s је учини собу јавном за све који знају везу.", + "%(senderDisplayName)s changed the room name from %(oldRoomName)s to %(newRoomName)s.": "%(senderDisplayName)s је изменио назив собе из %(oldRoomName)s у %(newRoomName)s.", + "%(senderName)s made no change.": "%(senderName)s није направио никакву измену.", "Takes the call in the current room off hold": "Узима позив са чекања у тренутној соби", "Places the call in the current room on hold": "Ставља позив на чекање у тренутној соби", "Sends a message to the given user": "Шаље поруку наведеном кориснику", @@ -1403,7 +1403,7 @@ "If you didn't remove the recovery method, an attacker may be trying to access your account. Change your account password and set a new recovery method immediately in Settings.": "Ако нисте ви уклонили начин опоравка, нападач можда покушава да приступи вашем налогу. Промените своју лозинку и поставите нови начин опоравка у поставкама, одмах.", "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.": "Ако сте то случајно учинили, безбедне поруке можете подесити у овој сесији, која ће поново шифровати историју порука сесије помоћу новог начина опоравка.", "This session has detected that your Security Phrase and key for Secure Messages have been removed.": "Сесија је открила да су ваша безбедносна фраза и кључ за безбедне поруке уклоњени.", - "Cancel autocomplete": "Откажи ауто-довршавање", + "Cancel autocomplete": "Откажи само-довршавање", "Direct message": "Директна порука", "Hide sessions": "Сакриј сесије", "Trusted": "поуздан", @@ -1419,18 +1419,18 @@ "Error changing power level": "Грешка при промени нивоа снаге", "Power level": "Ниво снаге", "Explore rooms": "Истражи собе", - "%(senderName)s has updated the widget layout": "%(senderName)s освежи распоред виџета", - "%(senderName)s revoked the invitation for %(targetDisplayName)s to join the room.": "%(senderName)s повуче позивницу за приступ соби кориснику %(targetDisplayName)s.", + "%(senderName)s has updated the widget layout": "%(senderName)s је освежио распоред виџета", + "%(senderName)s revoked the invitation for %(targetDisplayName)s to join the room.": "%(senderName)s је повукао позивницу за приступ соби кориснику %(targetDisplayName)s.", "%(senderName)s declined the call.": "%(senderName)s одби позив.", "(an error occurred)": "(дошло је до грешке)", "(their device couldn't start the camera / microphone)": "(туђи уређај не може да покрене камеру / микрофон)", "(connection failed)": "(неуспела веза)", - "%(senderName)s changed the addresses for this room.": "%(senderName)s измени адресе за ову собу.", - "%(senderName)s changed the main and alternative addresses for this room.": "%(senderName)s измени главну и алтернативне адресе за ову собу.", - "%(senderName)s changed the alternative addresses for this room.": "%(senderName)s измени алтернативне адресе за ову собу.", - "%(senderName)s removed the alternative addresses %(addresses)s for this room.|other": "%(senderName)s уклони алтернативне адресе %(addresses)s за ову собу.", - "%(senderName)s removed the alternative addresses %(addresses)s for this room.|one": "%(senderName)s уклони алтернативну адресу %(addresses)s за ову собу.", - "%(senderName)s added the alternative addresses %(addresses)s for this room.|one": "%(senderName)s додаде алтернативну адресу %(addresses)s за ову собу.", + "%(senderName)s changed the addresses for this room.": "%(senderName)s је изменио адресе за ову собу.", + "%(senderName)s changed the main and alternative addresses for this room.": "%(senderName)s је изменио главну и алтернативне адресе за ову собу.", + "%(senderName)s changed the alternative addresses for this room.": "%(senderName)s је изменио алтернативне адресе за ову собу.", + "%(senderName)s removed the alternative addresses %(addresses)s for this room.|other": "%(senderName)s је уклонио алтернативне адресе %(addresses)s за ову собу.", + "%(senderName)s removed the alternative addresses %(addresses)s for this room.|one": "%(senderName)s је уклонио алтернативну адресу %(addresses)s за ову собу.", + "%(senderName)s added the alternative addresses %(addresses)s for this room.|one": "%(senderName)s је додао алтернативну адресу %(addresses)s за ову собу.", "Converts the room to a DM": "Претвара собу у директно дописивање", "Converts the DM to a room": "Претвара директно дописивање у собу", "Changes your avatar in this current room only": "Мења ваш аватар само у тренутној соби", @@ -1503,10 +1503,262 @@ "Horse": "коњ", "Lion": "лав", "Cat": "мачка", - "Dog": "пас", + "Dog": "Пас", "To be secure, do this in person or use a trusted way to communicate.": "Да будете сигурни, ово обавите лично или путем поузданог начина комуникације.", "They don't match": "Не поклапају се", "They match": "Поклапају се", "Cancelling…": "Отказујем…", - "Show stickers button": "Прикажи дугме за налепнице" + "Show stickers button": "Прикажи дугме за налепнице", + "%(brand)s is securely caching encrypted messages locally for them to appear in search results:": "%(brand)s је локално сигурно кешира шифроване поруке да би се појавиле у резултатима претраге:", + "Search names and description": "Претражите имена и опис", + "You may want to try a different search or check for typos.": "Можда ћете желети да испробате другачију претрагу или да проверите да ли имате правописне грешке.", + "This version of %(brand)s does not support searching encrypted messages": "Ова верзија %(brand)s с не подржава претраживање шифрованих порука", + "Cancel search": "Откажи претрагу", + "Message search": "Претрага порука", + "Securely cache encrypted messages locally for them to appear in search results.": "Сигурно локално кеширајте шифроване поруке да би се појавиле у резултатима претраге.", + "Enable message search in encrypted rooms": "Омогућите претрагу порука у шифрованим собама", + "Space settings": "Подешавања простора", + "Failed to save space settings.": "Чување подешавања простора није успело.", + "We recommend you change your password and Security Key in Settings immediately": "Препоручујемо вам да одмах промените лозинку и безбедносни кључ у подешавањима", + "Confirm this user's session by comparing the following with their User Settings:": "Потврдите сесију овог корисника упоређивањем следећег са њиховим корисничким подешавањима:", + "Confirm by comparing the following with the User Settings in your other session:": "Потврдите упоређивањем следећег са корисничким подешавањима у вашој другој сесији:", + "You can also set up Secure Backup & manage your keys in Settings.": "Такође можете да подесите Сигурносну копију и управљате својим тастерима у подешавањима.", + "User settings": "Подешавања корисника", + "Community settings": "Подешавања заједнице", + "Edit settings relating to your space.": "Уредите поставке које се односе на ваш простор.", + "Go to Settings": "Идите на подешавања", + "Enable 'Manage Integrations' in Settings to do this.": "Омогућите „Управљање интеграцијама“ у подешавањима да бисте то урадили.", + "Failed to save settings": "Неуспешно чување подешавања", + "Settings Explorer": "Подешавања истраживаача", + "Share this email in Settings to receive invites directly in %(brand)s.": "Поделите ову е-пошту у подешавањима да бисте директно добијали позиве у %(brand)s.", + "Use an identity server in Settings to receive invites directly in %(brand)s.": "Користите сервер за идентитет у Подешавањима за директно примање позивница %(brand)s.", + "Link this email with your account in Settings to receive invites directly in %(brand)s.": "Повежите ову е-пошту са својим налогом у Подешавањима да бисте директно добијали позиве у %(brand)s.", + "Change settings": "Промени подешавања", + "⚠ These settings are meant for advanced users.": "⚠ Ова подешавања су намењена напредним корисницима.", + "Change notification settings": "Промените подешавања обавештења", + "Verification code": "Верификациони код", + "Please enter verification code sent via text.": "Унесите верификациони код послат путем текста.", + "Unable to verify phone number.": "Није могуће верификовати број телефона.", + "Unable to share phone number": "Није могуће делити телефонски број", + "Share": "Објави", + "Complete": "Заврши", + "You'll need to authenticate with the server to confirm the upgrade.": "Да бисте потврдили надоградњу, мораћете да се пријавите на серверу.", + "Restore": "Врати", + "Restore your key backup to upgrade your encryption": "Вратите сигурносну копију кључа да бисте надоградили шифровање", + "Enter your account password to confirm the upgrade:": "Унесите лозинку за налог да бисте потврдили надоградњу:", + "Safeguard against losing access to encrypted messages & data by backing up encryption keys on your server.": "Заштитите од губитка приступа шифрованим порукама и подацима је подржан сигурносном копијом кључева за шифровање на серверу.", + "Enter name": "Унесите име", + "What's the name of your community or team?": "Како се зове ваша заједница или тим?", + "Show": "Прикажи", + "Hide": "Сакриј", + "Clear all data": "Очисти све податке", + "Filter": "Филтер", + "Failed to load group members": "Учитавање чланова групе није успело", + "The person who invited you already left the room, or their server is offline.": "Особа која вас је позвала већ је напустила собу или је њен сервер ван мреже.", + "The person who invited you already left the room.": "Особа која вас је позвала већ је напустила собу.", + "Guest": "Гост", + "New version of %(brand)s is available": "Доступна је нова верзија %(brand)s", + "Update %(brand)s": "Ажурирај %(brand)s", + "Check your devices": "Проверите своје уређаје", + "New login. Was this you?": "Нова пријава. Да ли сте то били Ви?", + "Other users may not trust it": "Други корисници можда немају поверења у то", + "Safeguard against losing access to encrypted messages & data": "Заштитите се од губитка приступа шифрованим порукама и подацима", + "Profile picture": "Слика профила", + "Display Name": "Прикажи име", + "You cancelled verification on your other session.": "Отказали сте верификацију током друге сесије.", + "Cannot reach identity server": "Није могуће приступити серверу идентитета", + "Your %(brand)s is misconfigured": "Ваш %(brand)s је погрешно конфигурисан", + "Ensure you have a stable internet connection, or get in touch with the server admin": "Уверите се да имате стабилну интернет везу или контактирајте администратора сервера", + "See %(msgtype)s messages posted to your active room": "Видите %(msgtype)s поруке објављене у Вашој активној соби", + "See %(msgtype)s messages posted to this room": "Видите %(msgtype)s поруке објављене у овој соби", + "Send %(msgtype)s messages as you in your active room": "Пошаљи %(msgtype)s поруке као Ви у активној соби", + "Send %(msgtype)s messages as you in this room": "Пошаљи %(msgtype)s поруке као Ви у овој соби", + "See general files posted to your active room": "Погледајте опште датотеке објављене у Вашој активној соби", + "See general files posted to this room": "Погледајте опште датотеке објављене у овој соби", + "Send general files as you in your active room": "Шаљите опште датотеке као у активној соби", + "Send general files as you in this room": "Шаљите опште датотеке као у овој соби", + "See videos posted to your active room": "Погледајте видео снимке објављене у вашој активној соби", + "See videos posted to this room": "Погледајте видео снимке објављене у овој соби", + "Send videos as you in your active room": "Шаљите видео снимке као Ви у активној соби", + "Send videos as you in this room": "Шаљите видео записе као Ви у овој соби", + "See images posted to your active room": "Погледајте слике објављене у вашој активној соби", + "See images posted to this room": "Погледајте слике објављене у овој соби", + "Send images as you in your active room": "Пошаљите слике као Ви у активној соби", + "Send images as you in this room": "Пошаљите слике као Ви у овој соби", + "See emotes posted to your active room": "Погледајте емоције објављене у Вашој активној соби", + "See emotes posted to this room": "Погледајте емоције објављене у овој соби", + "Send emotes as you in your active room": "Шаљите емоције као у активној соби", + "Send emotes as you in this room": "Пошаљите емоције као Ви у ову собу", + "See text messages posted to your active room": "Погледајте текстуалне поруке објављене у Вашој активној соби", + "See text messages posted to this room": "Погледајте текстуалне поруке објављене у овој соби", + "Send text messages as you in your active room": "Шаљите текстуалне поруке као Ви у активној соби", + "Send text messages as you in this room": "Шаљите текстуалне поруке као Ви у овој соби", + "See messages posted to your active room": "Погледајте поруке објављене у Вашој активној соби", + "See messages posted to this room": "Погледајте поруке објављене у овој соби", + "Send messages as you in your active room": "Шаљите поруке као Ви у активној соби", + "Send messages as you in this room": "Шаљите поруке као Ви у овој соби", + "The %(capability)s capability": "%(capability)s способност", + "See %(eventType)s events posted to your active room": "Видите %(eventType)s догађаје објављене у вашој активној соби", + "Send %(eventType)s events as you in your active room": "Пошаљите %(eventType)s догађаја у активној соби", + "See %(eventType)s events posted to this room": "Видите %(eventType)s догађаји објављени у овој соби", + "Send %(eventType)s events as you in this room": "Шаљите %(eventType)s догађаје као у овој соби", + "with an empty state key": "са празним статусним кључем", + "with state key %(stateKey)s": "са статусним кључем %(stateKey)s", + "See when anyone posts a sticker to your active room": "Погледајте када неко постави налепницу у вашу активну собу", + "Send stickers to your active room as you": "Пошаљите налепнице у своју активну собу као и Ви", + "See when a sticker is posted in this room": "Погледајте када је налепница постављена у овој соби", + "Send stickers to this room as you": "Пошаљите налепнице у ову собу као и Ви", + "See when the avatar changes in your active room": "Погледајте када се аватар промени у вашој активној соби", + "Change the avatar of your active room": "Промените аватар своје активне собе", + "See when the avatar changes in this room": "Погледајте када се аватар промени у овој соби", + "Change the avatar of this room": "Промените аватар ове собе", + "See when the name changes in your active room": "Погледајте када се име промени у вашој активној соби", + "Change the name of your active room": "Промените име своје активне собе", + "See when the name changes in this room": "Погледајте када се име промени у овој соби", + "Change the name of this room": "Промените име ове собе", + "See when the topic changes in your active room": "Погледајте када се тема промени у вашој активној соби", + "Change the topic of your active room": "Промените тему своје активне собе", + "See when the topic changes in this room": "Погледајте када се тема промени у овој соби", + "Change the topic of this room": "Промените тему ове собе", + "Change which room, message, or user you're viewing": "Промените коју собу, поруку или корисника гледате", + "Change which room you're viewing": "Промените коју собу гледате", + "Send stickers into your active room": "Пошаљите налепнице у своју активну собу", + "Send stickers into this room": "Пошаљите налепнице у ову собу", + "Remain on your screen when viewing another room, when running": "Останите на екрану док гледате другу собу, током рада", + "Remain on your screen while running": "Останите на екрану током рада", + "%(names)s and %(lastPerson)s are typing …": "%(names)s и %(lastPerson)s куцају…", + "%(names)s and %(count)s others are typing …|one": "%(names)s и још један корисник куца…", + "%(names)s and %(count)s others are typing …|other": "%(names)s и %(count)s корисници куцају…", + "%(displayName)s is typing …": "%(displayName)s куца …", + "Couldn't load page": "Учитавање странице није успело", + "Sign in with SSO": "Пријавите се помоћу SSO", + "Use email to optionally be discoverable by existing contacts.": "Користите е-пошту да бисте је по жељи могли открити постојећи контакти.", + "Use email or phone to optionally be discoverable by existing contacts.": "Користите е-пошту или телефон да би вас постојећи контакти опционално могли открити.", + "Add an email to be able to reset your password.": "Додајте е-пошту да бисте могли да ресетујете лозинку.", + "Phone (optional)": "Телефон (необавезно)", + "Use lowercase letters, numbers, dashes and underscores only": "Користите само мала слова, бројеве, цртице и доње црте", + "Enter phone number (required on this homeserver)": "Унесите број телефона (захтева на овом кућном серверу)", + "Other users can invite you to rooms using your contact details": "Други корисници могу да вас позову у собе користећи ваше контакт податке", + "Enter email address (required on this homeserver)": "Унесите адресу е-поште (захтева на овом кућном серверу)", + "Use an email address to recover your account": "Користите адресу е-поште за опоравак налога", + "Forgot password?": "Заборавили сте лозинку?", + "That phone number doesn't look quite right, please check and try again": "Тај телефонски број не изгледа сасвим у реду, проверите и покушајте поново", + "Enter phone number": "Унесите број телефона", + "Enter email address": "Унесите адресу е-поште", + "Enter username": "Унесите корисничко име", + "Keep going...": "Настави...", + "Password is allowed, but unsafe": "Лозинка је дозвољена, али небезбедна", + "Nice, strong password!": "Лепа, јака лозинка!", + "Enter password": "Унесите лозинку", + "Something went wrong in confirming your identity. Cancel and try again.": "Нешто је пошло по наопако у потврђивању вашег идентитета. Откажите и покушајте поново.", + "Kosovo": "/", + "Open the link in the email to continue registration.": "Отворите везу у е-поруци да бисте наставили регистрацију.", + "A confirmation email has been sent to %(emailAddress)s": "Е-пошта са потврдом је послат на %(emailAddress)s", + "Please review and accept the policies of this homeserver:": "Молимо вас да прегледате и прихватите смернице овог кућног сервера:", + "Please review and accept all of the homeserver's policies": "Молимо вас да прегледате и прихватите све смернице кућног сервера", + "Missing captcha public key in homeserver configuration. Please report this to your homeserver administrator.": "Недостаје јавни кључ captcha-е у конфигурацији матичног сервера. Молимо пријавите ово администратору кућног сервера.", + "Confirm your identity by entering your account password below.": "Потврдите свој идентитет уносом лозинке за налог испод.", + "Country Dropdown": "Падајући списак земаља", + "This homeserver would like to make sure you are not a robot.": "Овај кућни сервер жели да се увери да нисте робот.", + "User Status": "Статус корисника", + "Away": "Неприсутан", + "Toggle this dialog": "Укључи / искључи овај дијалог", + "Go to Home View": "Идите на почетни приказ", + "Move autocomplete selection up/down": "Померите избор само-довршавање горе / доле", + "End": "", + "Credits": "Заслуге", + "Legal": "Легално", + "Deactivating your account is a permanent action - be careful!": "Драктивирање вашег налога је трајна акција - будите опрезни!", + "Deactivate account": "Деактивирај налог", + "Account management": "Управљање профилом", + "Server name": "Име сервера", + "Enter the name of a new server you want to explore.": "Унесите име новог сервера који желите да истражите.", + "Add a new server": "Додајте нови сервер", + "Matrix": "Матрикс", + "Remove server": "Уклоните сервер", + "Are you sure you want to remove %(serverName)s": "Да ли сте сигурни да желите да уклоните %(serverName)s", + "Your server": "Ваш сервер", + "All rooms": "Све собе", + "Low bandwidth mode": "Режим ниског протока", + "Who are you working with?": "Са ким радите?", + "Screens": "Екрани", + "Share your screen": "Поделите свој екран", + "Alt Gr": "Алт Гр", + "Alt": "Алт", + "Autocomplete": "Аутоматско довршавање", + "This room is public": "Ова соба је јавна", + "Caution:": "Опрез:", + "Change room avatar": "Промените аватар собе", + "Browse": "Прегледајте", + "Versions": "Верзије", + "Set a new status...": "Поставите нови статус ...", + "Set status": "Постави статус", + "Update status": "Ажурирај статус", + "Clear status": "Очисти статус", + "User rules": "Корисничка правила", + "Use the Desktop app to see all encrypted files": "Користи десктоп апликација да видиш све шифроване датотеке", + "This widget may use cookies.": "Овај виџет може користити колачиће.", + "Widget added by": "Додао је виџет", + "Using this widget may share data with %(widgetDomain)s.": "Коришћење овог виџета може да дели податке са %(widgetDomain)s.", + "Using this widget may share data with %(widgetDomain)s & your Integration Manager.": "Коришћење овог виџета може да дели податке са %(widgetDomain)s и вашим интеграционим менаџером.", + "Widget ID": "ИД виџета", + "Room ID": "ИД собе", + "%(brand)s URL": "%(brand)s УРЛ", + "Your user ID": "Ваша корисничка ИД", + "Your avatar URL": "УРЛ вашег аватара", + "Your display name": "Ваше име за приказ", + "exists": "постоји", + "Collapse room list section": "Скупи одељак листе соба", + "Select room from the room list": "Изаберите собу са листе соба", + "Navigate up/down in the room list": "Крећите се горе / доле у листи соба", + "Jump to room search": "Пређите на претрагу собе", + "Search (must be enabled)": "Претрага (мора бити омогућена)", + "Upload a file": "Отпремите датотеку", + "Jump to oldest unread message": "Скочите на најстарију непрочитану поруку", + "Dismiss read marker and jump to bottom": "Одбаците ознаку за читање и скочите до дна", + "Done": "Готово", + "Interactively verify by Emoji": "Интерактивно верификујте смајлићима", + "Manually Verify by Text": "Ручно потврди текстом", + "Not Trusted": "Није поуздано", + "Ask this user to verify their session, or manually verify it below.": "Питајте овог корисника да потврди његову сесију или ручно да потврди у наставку.", + "%(name)s (%(userId)s) signed in to a new session without verifying it:": "%(name)s (%(userId)s) се улоговао у нову сесију без потврђивања:", + "Verify your other session using one of the options below.": "Потврдите другу сесију помоћу једних од опција у испод.", + "%(senderName)s created a ban rule matching %(glob)s for %(reason)s": "%(senderName)s је створиоправило о забрани које се подударају са %(glob)s због %(reason)s", + "%(senderName)s created a rule banning servers matching %(glob)s for %(reason)s": "%(senderName)s је створио правило које забрањије сервере који се подударају са %(glob)s због %(reason)s", + "%(senderName)s created a rule banning rooms matching %(glob)s for %(reason)s": "%(senderName)s је створио правило које забрањује собе које се подударају са %(glob)s због %(reason)s", + "%(senderName)s created a rule banning users matching %(glob)s for %(reason)s": "%(senderName)s је створио правило које забрањује кориснике који се подударају са %(glob)s због %(reason)s", + "%(senderName)s updated a ban rule matching %(glob)s for %(reason)s": "%(senderName)s је аужурирао правило о забрани које се поударају са %(glob)s због %(reason)s", + "%(senderName)s removed the rule banning users matching %(glob)s": "%(senderName)s је уклонио правило које забрањује кориснике који се подударају са %(glob)s", + "%(senderName)s removed the rule banning rooms matching %(glob)s": "%(senderName)s је уклонио правило које забрањује собе које подударају са %(glob)s", + "%(senderName)s removed the rule banning servers matching %(glob)s": "%(senderName)s је уклонио правил које забрањује сервере који подударају са %(glob)s", + "%(senderName)s updated the rule banning servers matching %(glob)s for %(reason)s": "%(senderName)s је аужурирао правило које забрањује сервере које се подударају са %(glob)s због %(reason)s", + "%(senderName)s updated the rule banning rooms matching %(glob)s for %(reason)s": "%(senderName)s је аужурирао правило које забрањује соба које се подударају са %(glob)s због %(reason)s", + "%(senderName)s updated the rule banning users matching %(glob)s for %(reason)s": "%(senderName)s је аужурирао правило о забрани корисника који се подударају са %(glob)s због %(reason)s", + "You signed in to a new session without verifying it:": "Пријавили сте се у нову сесију без потврђивања:", + "%(senderName)s updated a ban rule that was matching %(oldGlob)s to matching %(newGlob)s for %(reason)s": "%(senderName)s је аужурирао правило о забрани које се подударало са %(oldGlob)s да би се подударало са %(newGlob)s због %(reason)s", + "%(senderName)s changed a rule that was banning servers matching %(oldGlob)s to matching %(newGlob)s for %(reason)s": "%(senderName)s је променио правило које је забрањинвало сервере који су се подударале са %(oldGlob)s да би се подударале са %(newGlob)s због %(reason)s", + "%(senderName)s changed a rule that was banning rooms matching %(oldGlob)s to matching %(newGlob)s for %(reason)s": "%(senderName)s је променио правило које је забрањивало собе који се подударају са %(oldGlob)s да би се подударале са %(newGlob)s због %(reason)s", + "%(senderName)s changed a rule that was banning users matching %(oldGlob)s to matching %(newGlob)s for %(reason)s": "%(senderName)s је променио правило које забрањије кориснике који се подударају са %(oldGlob)s да се подудара са %(newGlob)s због %(reason)s", + "Open": "Отвори", + "Accept all %(invitedRooms)s invites": "Прихвати све %(invitedRooms)s позивнице", + "%(senderName)s updated an invalid ban rule": "%(senderName)s је аужурирао неважеће правило о забрани", + "%(senderName)s removed a ban rule matching %(glob)s": "%(senderName)s је уклонио правило о забрани које подудара са %(glob)s", + "Use an identity server to invite by email. Click continue to use the default identity server (%(defaultIdentityServerName)s) or manage in Settings.": "Користите сервер за идентитет да бисте послали позивнице е-поштом. Кликните на даље да бисте користили уобичајни сервер идентитета %(defaultIdentityServerName)s или управљајте у подешавањима.", + "%(senderDisplayName)s enabled flair for %(groups)s in this room.": "%(senderDisplayName)s је омогућио њух за %(groups)s у овој суби.", + "%(senderDisplayName)s changed the server ACLs for this room.": "%(senderDisplayName)s је променио ACL сервере за ову собу.", + "%(senderDisplayName)s disabled flair for %(groups)s in this room.": "%(senderDisplayName)s је онемогућио њух за %(groups)s у овој соби.", + "%(senderDisplayName)s enabled flair for %(newGroups)s and disabled flair for %(oldGroups)s in this room.": "%(senderDisplayName)s је омогућио њух за %(newGroups)s и онемогућио њух за %(oldGroups)s у овој соби.", + "%(senderDisplayName)s set the server ACLs for this room.": "%(senderDisplayName)s је подесио ACL сервере за ову собу.", + "Sends the given emote coloured as a rainbow": "Шаље дату емоцију обојену као дуга", + "The signing key you provided matches the signing key you received from %(userId)s's session %(deviceId)s. Session marked as verified.": "Кључ за потписивање који сте навели поклапа се са кључем за потписивање који сте добили од %(userId)s сесије %(deviceId)s. Сесија је означена као проверена.", + "WARNING: KEY VERIFICATION FAILED! The signing key for %(userId)s and session %(deviceId)s is \"%(fprint)s\" which does not match the provided key \"%(fingerprint)s\". This could mean your communications are being intercepted!": "УПОЗОРЕЊЕ: ПРОВЕРА КЉУЧА НИЈЕ УСПЕЛА! Кључ за потписивање за %(userId)s и сесију %(deviceId)s је \"%(fprint)s\", који се не подудара са наведеним кључем \"%(fingerprint)s\". То може значити да су ваше комуникације пресретнуте!", + "Verifies a user, session, and pubkey tuple": "Верификује корисника, сесију и pubkey tuple", + "Réunion": "Реунион", + "Your homeserver rejected your log in attempt. This could be due to things just taking too long. Please try again. If this continues, please contact your homeserver administrator.": "Ваш кућни сервер је одбио ваш покушај пријављивања. То би могло бити због ствари које предуго трају. Молим вас, покушајте поново. Ако се ово настави, контактирајте администратора кућног сервера.", + "Your homeserver was unreachable and was not able to log you in. Please try again. If this continues, please contact your homeserver administrator.": "Ваш кућни сервер није био доступан и није могао да вас пријави. Покушајте поново. Ако се ово настави, контактирајте администратора кућног сервера.", + "We asked the browser to remember which homeserver you use to let you sign in, but unfortunately your browser has forgotten it. Go to the sign in page and try again.": "Тражили смо од прегледача да запамти који кућни сервер користите за пријаву, али нажалост ваш претраживач га је заборавио. Идите на страницу за пријављивање и покушајте поново.", + "You're already in a call with this person.": "Већ разговарате са овом особом.", + "Already in call": "Већ у позиву", + "Whether you're using %(brand)s as an installed Progressive Web App": "Без обзира да ли користите %(brand)s као инсталирану Прогресивну веб апликацију", + "Whether or not you're using the 'breadcrumbs' feature (avatars above the room list)": "Без обзира да ли користите функцију „breadcrumbs“ (аватари изнад листе соба)" } diff --git a/src/i18n/strings/sr_Latn.json b/src/i18n/strings/sr_Latn.json index 19778858d0..96a5d89411 100644 --- a/src/i18n/strings/sr_Latn.json +++ b/src/i18n/strings/sr_Latn.json @@ -58,5 +58,6 @@ "Failed to invite users to the room:": "Nije uspelo pozivanje korisnika u sobu:", "You need to be logged in.": "Morate biti prijavljeni", "You need to be able to invite users to do that.": "Mora vam biti dozvoljeno da pozovete korisnike kako bi to uradili.", - "Failed to send request.": "Slanje zahteva nije uspelo." + "Failed to send request.": "Slanje zahteva nije uspelo.", + "Create Account": "Napravite nalog" } diff --git a/src/i18n/strings/sv.json b/src/i18n/strings/sv.json index 002b825cb0..1337dc47b7 100644 --- a/src/i18n/strings/sv.json +++ b/src/i18n/strings/sv.json @@ -3133,5 +3133,169 @@ "This homeserver has been blocked by it's administrator.": "Den här hemservern har blockerats av sin administratör.", "This homeserver has been blocked by its administrator.": "Hemservern har blockerats av sin administratör.", "You're already in a call with this person.": "Du är redan i ett samtal med den här personen.", - "Already in call": "Redan i samtal" + "Already in call": "Redan i samtal", + "Verify this login to access your encrypted messages and prove to others that this login is really you.": "Verifiera den här inloggningen för att komma åt dina krypterade meddelanden och visa för andra att den här inloggningen verkligen är du.", + "Verify with another session": "Verifiera med en annan session", + "We'll create rooms for each of them. You can add more later too, including already existing ones.": "Vi kommer att skapa rum för varje. Du kan lägga till fler senare, inklusive såna som redan finns.", + "Let's create a room for each of them. You can add more later too, including already existing ones.": "Låt oss skapa ett rum för varje. Du kan lägga till fler sen, inklusive såna som redan finns.", + "Make sure the right people have access. You can invite more later.": "Se till att rätt personer har tillgång. Du kan bjuda in fler senare.", + "A private space to organise your rooms": "Ett privat utrymme för att organisera dina rum", + "Just me": "Bara jag", + "Make sure the right people have access to %(name)s": "Försäkra dig om att rätt personer har tillgång till %(name)s", + "Go to my first room": "Gå till mitt första rum", + "It's just you at the moment, it will be even better with others.": "Bara du är här för tillfället, det kommer att vara ännu bättre med andra.", + "Share %(name)s": "Dela %(name)s", + "Private space": "Privat utrymme", + "Public space": "Offentligt utrymme", + " invites you": " bjuder in dig", + "Search names and description": "Sök bland namn och beskrivningar", + "Create room": "Skapa rum", + "You may want to try a different search or check for typos.": "Du kanske vill pröva en annan söksträng eller kolla efter felstavningar.", + "No results found": "Inga resultat funna", + "Mark as suggested": "Markera som föreslaget", + "Mark as not suggested": "Markera som inte föreslaget", + "Removing...": "Tar bort…", + "Failed to remove some rooms. Try again later": "Misslyckades att ta bort vissa rum. Försök igen senare", + "%(count)s rooms and 1 space|one": "%(count)s rum och 1 utrymme", + "%(count)s rooms and 1 space|other": "%(count)s rum och 1 utrymme", + "%(count)s rooms and %(numSpaces)s spaces|one": "%(count)s rum och %(numSpaces)s utrymmen", + "%(count)s rooms and %(numSpaces)s spaces|other": "%(count)s rum och %(numSpaces)s utrymmen", + "If you can't find the room you're looking for, ask for an invite or create a new room.": "Om du inte hittar rummet du letar efter, be om en inbjudan eller skapa ett nytt rum.", + "Suggested": "Föreslaget", + "This room is suggested as a good one to join": "Det här rummet föreslås som ett bra att gå med i", + "%(count)s rooms|one": "%(count)s rum", + "%(count)s rooms|other": "%(count)s rum", + "You don't have permission": "Du har inte behörighet", + "Open": "Öppna", + "%(count)s messages deleted.|one": "%(count)s meddelande raderat.", + "%(count)s messages deleted.|other": "%(count)s meddelanden raderade.", + "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.": "Detta påverkar normalt bara hur rummet hanteras på serven. Om du upplever problem med din %(brand)s, vänligen rapportera en bugg.", + "Invite to %(roomName)s": "Bjud in till %(roomName)s", + "Edit devices": "Redigera enheter", + "Invite People": "Bjud in personer", + "Invite with email or username": "Bjud in med e-postadress eller användarnamn", + "You can change these anytime.": "Du kan ändra dessa när som helst.", + "Add some details to help people recognise it.": "Lägg till några detaljer för att hjälpa folk att känn igen det.", + "Spaces are new ways to group rooms and people. To join an existing space you'll need an invite.": "Utrymmen är nya sätt att gruppera rum och personer. För att gå med i ett existerande utrymme så behöver du en inbjudan.", + "From %(deviceName)s (%(deviceId)s) at %(ip)s": "Från %(deviceName)s %(deviceId)s på %(ip)s", + "Check your devices": "Kolla dina enheter", + "A new login is accessing your account: %(name)s (%(deviceID)s) at %(ip)s": "En ny inloggning kommer åt ditt konto: %(name)s %(deviceID)s på %(ip)s", + "You have unverified logins": "Du har overifierade inloggningar", + "%(count)s people you know have already joined|other": "%(count)s personer du känner har redan gått med", + "If you do, please note that none of your messages will be deleted, but the search experience might be degraded for a few momentswhilst the index is recreated": "Om du gör det, observera att inga av dina meddelanden kommer att raderas, men din sökupplevelse kommer att degraderas en stund medans registret byggs upp igen", + "What are some things you want to discuss in %(spaceName)s?": "Vad är några saker du vill diskutera i %(spaceName)s?", + "You can add more later too, including already existing ones.": "Du kan lägga till flera senare också, inklusive redan existerande.", + "Consulting with %(transferTarget)s. Transfer to %(transferee)s": "Tillfrågar %(transferTarget)s. %(transferTarget)sÖverför till %(transferee)s", + "Review to ensure your account is safe": "Granska för att försäkra dig om att ditt konto är säkert", + "%(deviceId)s from %(ip)s": "%(deviceId)s från %(ip)s", + "Send and receive voice messages (in development)": "Skicka och ta emot röstmeddelanden (under utveckling)", + "unknown person": "okänd person", + "Warn before quitting": "Varna innan avslutning", + "Invite to just this room": "Bjud in till bara det här rummet", + "Invite messages are hidden by default. Click to show the message.": "Inbjudningsmeddelanden är dolda som förval. Klicka för att visa meddelandet.", + "Record a voice message": "Spela in ett röstmeddelande", + "Stop & send recording": "Stoppa och skicka inspelning", + "Accept on your other login…": "Acceptera på din andra inloggning…", + "%(count)s people you know have already joined|one": "%(count)s person du känner har redan gått med", + "Quick actions": "Snabbhandlingar", + "Add existing rooms": "Lägg till existerande rum", + "Adding...": "Lägger till…", + "We couldn't create your DM.": "Vi kunde inte skapa ditt DM.", + "Reset event store": "Återställ händelselagring", + "Invited people will be able to read old messages.": "Inbjudna personer kommer att kunna läsa gamla meddelanden.", + "Reset event store?": "Återställ händelselagring?", + "You most likely do not want to reset your event index store": "Du vill troligen inte återställa din händelseregisterlagring", + "Consult first": "Tillfråga först", + "Verify other login": "Verifiera annan inloggning", + "Avatar": "Avatar", + "Let's create a room for each of them.": "Låt oss skapa ett rum för varje.", + "Verification requested": "Verifiering begärd", + "Sends the given message as a spoiler": "Skickar det angivna meddelandet som en spoiler", + "Manage & explore rooms": "Hantera och utforska rum", + "Message search initilisation failed": "Initialisering av meddelandesökning misslyckades", + "Please choose a strong password": "Vänligen välj ett starkt lösenord", + "Use another login": "Använd annan inloggning", + "Verify your identity to access encrypted messages and prove your identity to others.": "Verifiera din identitet för att komma åt krypterade meddelanden och bevisa din identitet för andra.", + "Without verifying, you won’t have access to all your messages and may appear as untrusted to others.": "Om du inte verifierar så kommer du inte ha åtkomst till alla dina meddelanden och kan synas som ej betrodd för andra.", + "You are the only person here. If you leave, no one will be able to join in the future, including you.": "Du är den enda personen här. Om du lämnar så kommer ingen kunna gå med igen, inklusive du.", + "If you reset everything, you will restart with no trusted sessions, no trusted users, and might not be able to see past messages.": "Om du återställer allt så kommer du att börja om utan betrodda sessioner eller betrodda användare, och kommer kanske inte kunna se gamla meddelanden.", + "Only do this if you have no other device to complete verification with.": "Gör detta endast om du inte har någon annan enhet att slutföra verifikationen med.", + "Reset everything": "Återställ allt", + "Forgotten or lost all recovery methods? Reset all": "Glömt eller förlorat alla återställningsalternativ? Återställ allt", + "If you do, please note that none of your messages will be deleted, but the search experience might be degraded for a few moments whilst the index is recreated": "Om du gör det, observera att inga av dina meddelanden kommer att raderas, men sökupplevelsen kan degraderas en stund medans registret byggs upp igen", + "View message": "Visa meddelande", + "Zoom in": "Zooma in", + "Zoom out": "Zooma ut", + "%(seconds)ss left": "%(seconds)ss kvar", + "Change server ACLs": "Ändra server-ACLer", + "Show options to enable 'Do not disturb' mode": "Visa alternativ för att aktivera 'Stör ej'-läget", + "Delete all": "Radera alla", + "View all %(count)s members|one": "Visa 1 medlem", + "View all %(count)s members|other": "Visa alla %(count)s medlemmar", + "You can select all or individual messages to retry or delete": "Du kan välja alla eller individuella meddelanden att försöka igen eller radera", + "Sending": "Skickar", + "Retry all": "Försök alla igen", + "Some of your messages have not been sent": "Vissa av dina meddelanden har inte skickats", + "%(count)s members including %(commaSeparatedMembers)s|one": "%(commaSeparatedMembers)s", + "%(count)s members including %(commaSeparatedMembers)s|other": "%(count)s medlemmar inklusive %(commaSeparatedMembers)s", + "Including %(commaSeparatedMembers)s": "Inklusive %(commaSeparatedMembers)s", + "Failed to send": "Misslyckades att skicka", + "Enter your Security Phrase a second time to confirm it.": "Ange din säkerhetsfras igen för att bekräfta den.", + "Pick rooms or conversations to add. This is just a space for you, no one will be informed. You can add more later.": "Välj rum eller konversationer att lägga till. Detta är bara ett utrymmer för dig, ingen kommer att informeras. Du kan lägga till fler senare.", + "What do you want to organise?": "Vad vill du organisera?", + "Filter all spaces": "Filtrera alla utrymmen", + "Delete recording": "Radera inspelningen", + "Stop the recording": "Stoppa inspelningen", + "%(count)s results in all spaces|one": "%(count)s resultat i alla utrymmen", + "%(count)s results in all spaces|other": "%(count)s resultat i alla utrymmen", + "You have no ignored users.": "Du har inga ignorerade användare.", + "Play": "Spela", + "Pause": "Pausa", + "Message search initialisation failed": "Initialisering av meddelandesökning misslyckades", + "To view %(spaceName)s, turn on the Spaces beta": "För att se %(spaceName)s, aktivera utrymmesbetan", + "Spaces are a beta feature.": "Utrymmen är en betafunktion.", + "Search names and descriptions": "Sök namn och beskrivningar", + "Select a room below first": "Välj ett rum nedan först", + "Communities are changing to Spaces": "Gemenskaper byts ut mot utrymmen", + "Join the beta": "Gå med i betan", + "Leave the beta": "Lämna betan", + "Beta": "Beta", + "Tap for more info": "Klicka för mer info", + "Spaces is a beta feature": "Utrymmen är en betafunktion", + "You may contact me if you have any follow up questions": "Ni kan kontakta mig om ni har vidare frågor", + "To leave the beta, visit your settings.": "För att lämna betan, besök dina inställningar.", + "Your platform and username will be noted to help us use your feedback as much as we can.": "Din plattform och ditt användarnamn kommer att noteras för att hjälpa oss att använda din återkoppling så mycket vi kan.", + "%(featureName)s beta feedback": "%(featureName)s betaåterkoppling", + "Thank you for your feedback, we really appreciate it.": "Tack för din återkoppling, vi uppskattar det verkligen.", + "Beta feedback": "Betaåterkoppling", + "Want to add a new room instead?": "Vill du lägga till ett nytt rum istället?", + "Adding rooms... (%(progress)s out of %(count)s)|one": "Lägger till rum…", + "Adding rooms... (%(progress)s out of %(count)s)|other": "Lägger till rum… (%(progress)s av %(count)s)", + "Not all selected were added": "Inte alla valda tillades", + "You can add existing spaces to a space.": "Du kan lägga till existerande utrymmen till ett utrymme.", + "Feeling experimental?": "Känner du dig äventyrlig?", + "You are not allowed to view this server's rooms list": "Du tillåts inte att se den här serverns rumslista", + "Add reaction": "Lägg till reaktion", + "Error processing voice message": "Fel vid hantering av röstmeddelande", + "We didn't find a microphone on your device. Please check your settings and try again.": "Vi kunde inte hitta en mikrofon på din enhet. Vänligen kolla dina inställningar och försök igen.", + "No microphone found": "Ingen mikrofon hittad", + "We were unable to access your microphone. Please check your browser settings and try again.": "Vi kunde inte komma åt din mikrofon. Vänligen kolla dina webbläsarinställningar och försök igen.", + "Unable to access your microphone": "Kan inte komma åt din mikrofon", + "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.": "Känner du dig äventyrlig? Experiment är det bästa sättet att få saker tidigt, testa nya funktioner och hjälpa till att forma dem innan de egentligen släpps Läs mer.", + "Your access token gives full access to your account. Do not share it with anyone.": "Din åtkomsttoken ger full åtkomst till ditt konto. Dela den inte med någon.", + "Access Token": "Åtkomsttoken", + "Spaces are a new way to group rooms and people. To join an existing space you'll need an invite.": "Utrymmen är ett nytt sätt att gruppera rum och personer. För att gå med i existerande utrymme så behöver du en inbjudan.", + "Please enter a name for the space": "Vänligen ange ett namn för utrymmet", + "Connecting": "Ansluter", + "Allow Peer-to-Peer for 1:1 calls (if you enable this, the other party might be able to see your IP address)": "Tillåt peer-to-peer för 1:1-samtal (om du aktiverar det hör så kan den andra parten kanske se din IP-adress)", + "Send and receive voice messages": "Skicka och ta emot röstmeddelanden", + "Your feedback will help make spaces better. The more detail you can go into, the better.": "Din återkoppling kommer att hjälpa till att göra utrymmen bättre. Ju fler detaljer du kan ge desto bättre.", + "Beta available for web, desktop and Android. Some features may be unavailable on your homeserver.": "Beta tillgänglig för webben, skrivbord och Android. Vissa funktioner kan vara otillgängliga på din hemserver.", + "You can leave the beta any time from settings or tapping on a beta badge, like the one above.": "Du kan lämna betan när som helst från inställningarna eller genom att trycka en betabricka, som den ovan.", + "%(brand)s will reload with Spaces enabled. Communities and custom tags will be hidden.": "%(brand)s kommer att ladda om med utrymmen aktiverade. Gemenskaper och anpassade taggar kommer att döljas.", + "Beta available for web, desktop and Android. Thank you for trying the beta.": "Beta tillgänglig för webben, skrivbord och Android. Tack för att du provar betan.", + "If you leave, %(brand)s will reload with Spaces disabled. Communities and custom tags will be visible again.": "Om du lämnar så kommer %(brand)s att ladda om med utrymmen inaktiverade. Gemenskaper och anpassade taggar kommer att synas igen.", + "Spaces are a new way to group rooms and people.": "Utrymmen är nya sätt att gruppera rum och personer.", + "This is an experimental feature. For now, new users receiving an invite will have to open the invite on to actually join.": "Det här är en experimentell funktion. För tillfället så behöver nya inbjudna användare öppna inbjudan på för att faktiskt gå med.", + "To join %(spaceName)s, turn on the Spaces beta": "För att gå med i %(spaceName)s, aktivera utrymmesbetan" } diff --git a/src/i18n/strings/ta.json b/src/i18n/strings/ta.json index 9cb046ed39..4f87230ef3 100644 --- a/src/i18n/strings/ta.json +++ b/src/i18n/strings/ta.json @@ -179,5 +179,7 @@ "Mar": "மார்ச்", "Apr": "ஏப்ரல்", "May": "மே", - "Jun": "ஜூன்" + "Jun": "ஜூன்", + "Explore rooms": "அறைகளை ஆராயுங்கள்", + "Create Account": "உங்கள் கணக்கை துவங்குங்கள்" } diff --git a/src/i18n/strings/th.json b/src/i18n/strings/th.json index 811d549d54..16a9e521c2 100644 --- a/src/i18n/strings/th.json +++ b/src/i18n/strings/th.json @@ -26,7 +26,7 @@ "Results from DuckDuckGo": "ผลจาก DuckDuckGo", "%(brand)s version:": "เวอร์ชัน %(brand)s:", "Cancel": "ยกเลิก", - "Dismiss": "ไม่สนใจ", + "Dismiss": "ปิด", "Mute": "เงียบ", "Notifications": "การแจ้งเตือน", "Operation failed": "การดำเนินการล้มเหลว", @@ -378,5 +378,10 @@ "Unable to fetch notification target list": "ไม่สามารถรับรายชื่ออุปกรณ์แจ้งเตือน", "Quote": "อ้างอิง", "With your current browser, the look and feel of the application may be completely incorrect, and some or all features may not function. If you want to try it anyway you can continue, but you are on your own in terms of any issues you may encounter!": "การแสดงผลของโปรแกรมอาจผิดพลาด ฟังก์ชันบางอย่างหรือทั้งหมดอาจไม่ทำงานในเบราว์เซอร์ปัจจุบันของคุณ หากคุณต้องการลองดำเนินการต่อ คุณต้องรับมือกับปัญหาที่อาจจะเกิดขึ้นด้วยตัวคุณเอง!", - "Checking for an update...": "กำลังตรวจหาอัปเดต..." + "Checking for an update...": "กำลังตรวจหาอัปเดต...", + "Explore rooms": "สำรวจห้อง", + "Sign In": "ลงชื่อเข้า", + "Create Account": "สร้างบัญชี", + "Add Email Address": "เพิ่มที่อยู่อีเมล", + "Confirm": "ยืนยัน" } diff --git a/src/i18n/strings/tzm.json b/src/i18n/strings/tzm.json index 8363c2d7c6..f9ac9cf574 100644 --- a/src/i18n/strings/tzm.json +++ b/src/i18n/strings/tzm.json @@ -4,7 +4,7 @@ "Actions": "Tugawin", "Messages": "Tuzinin", "Cancel": "Sser", - "Create Account": "Ssenflul amiḍan", + "Create Account": "senflul amiḍan", "Sign In": "Kcem", "Name or Matrix ID": "Isem neɣ ID Matrix", "Dec": "Duj", @@ -34,5 +34,123 @@ "e.g. %(exampleValue)s": "a.m. %(exampleValue)s", "The version of %(brand)s": "Taleqqemt n %(brand)s", "Add Phone Number": "Rnu uṭṭun n utilifun", - "Add Email Address": "Rnu tasna imayl" + "Add Email Address": "Rnu tasna imayl", + "Open": "Ṛẓem", + "Permissions": "Tisirag", + "Subscribe": "Zemmem", + "Change": "Senfel", + "Disconnect": "Kkes azday", + "exists": "illa", + "Santa": "Santa", + "Pizza": "Tapizzat", + "Corn": "Akbal", + "Cloud": "Tagut", + "Globe": "Amaḍal", + "Flower": "Ajeǧǧig", + "Butterfly": "Aferteṭṭu", + "Rooster": "Ayaẓiḍ", + "Panda": "Apanda", + "Upgrade": "Leqqem", + "Confirm": "Sentem", + "Brazil": "Brazil", + "Bolivia": "Bulivya", + "Bhutan": "Buṭan", + "Bermuda": "Birmuda", + "Benin": "Binin", + "Belize": "Biliz", + "Belgium": "Beljika", + "Belarus": "Bilarusya", + "Bahamas": "Bahamas", + "Aruba": "Aruba", + "Angola": "Angula", + "Andorra": "Andura", + "Algeria": "Dzayer", + "Albania": "Albanya", + "End": "End", + "Space": "Space", + "Shift": "Shift", + "Super": "Super", + "Ctrl": "Ctrl", + "Esc": "Esc", + "Calls": "Iɣuṛiten", + "Emoji": "Imuji", + "Afghanistan": "Afɣanistan", + "Logout": "Ffeɣ", + "Leave": "Fel", + "Phone": "Atilifun", + "Email": "Imayl", + "Go": "Ddu", + "Send": "Azen", + "example": "amedya", + "Example": "Amedya", + "Hide": "Ffer", + "Name": "Isem", + "Flags": "Icenyalen", + "Join": "Lkem", + "edited": "infel", + "Copied!": "inɣel!", + "Home": "Asnubeg", + "Reply": "Rar", + "Yes": "Yah", + "About": "Xef", + "Search…": "Arezzu…", + "A-Z": "A-Ẓ", + "Settings": "Tisɣal", + "Reject": "Agy", + "Re-join": "als-lkem", + "People": "Midden", + "Search": "Rzu", + "%(duration)sd": "%(duration)sas", + "Loading...": "Azdam...", + "Share": "Bḍu", + "Camera": "Takamiṛa", + "Microphone": "Amikṛu", + "Add": "Rnu", + "Ignore": "Nexxel", + "None": "Walu", + "Account": "Amiḍan", + "Theme": "Asgum", + "Algorithm:": "Talguritmit:", + "Save": "Ḥḍu", + "Profile": "Ifres", + "ID": "ID", + "Remove": "KKes", + "Folder": "Asdaw", + "Guitar": "Agiṭaṛ", + "Ball": "Tacama", + "Flag": "Acenyal", + "Telephone": "Atilifun", + "Key": "Tasarut", + "Book": "Adlis", + "Gift": "Timucit", + "Hat": "Tarazal", + "Robot": "Aṛubu", + "Heart": "Ul", + "Apple": "Tadeffuyt", + "Banana": "Tabanant", + "Fire": "Timessi", + "Moon": "Ayyur", + "Mushroom": "Agursel", + "Tree": "Aseklu", + "Fish": "Aselm", + "Turtle": "Ifker", + "Rabbit": "Agnin", + "Elephant": "Ilew", + "Pig": "Ilef", + "Close": "Rgel", + "Horse": "Ayyis", + "Lion": "Izem", + "Cat": "Amuc", + "Dog": "Aydi", + "or": "neɣ", + "Decline": "Agy", + "Guest": "Anebgi", + "Ok": "Wax", + "Notifications": "Tineɣmisin", + "No": "Uhu", + "Dark": "Adeɣmum", + "Usage": "Asemres", + "Feb": "Bṛa", + "Jan": "Yen", + "Continue": "Kemmel" } diff --git a/src/i18n/strings/uk.json b/src/i18n/strings/uk.json index 5f392295c3..db5ce9b360 100644 --- a/src/i18n/strings/uk.json +++ b/src/i18n/strings/uk.json @@ -54,7 +54,7 @@ "Anyone who knows the room's link, apart from guests": "Кожний, хто знає посилання на кімнату, окрім гостей", "Anyone who knows the room's link, including guests": "Кожний, хто знає посилання на кімнату, включно гостей", "Are you sure?": "Ви впевнені?", - "Are you sure you want to leave the room '%(roomName)s'?": "Ви впевнені, що хочете покинути '%(roomName)s'?", + "Are you sure you want to leave the room '%(roomName)s'?": "Ви впевнені, що хочете залишити '%(roomName)s'?", "Are you sure you want to reject the invitation?": "Ви впевнені, що ви хочете відхилити запрошення?", "Attachment": "Прикріплення", "Autoplay GIFs and videos": "Автовідтворення GIF і відео", @@ -310,7 +310,7 @@ "To use it, just wait for autocomplete results to load and tab through them.": "Щоб цим скористатися, просто почекайте на підказки доповнення й перемикайтеся між ними клавішею TAB.", "Changes your display nickname": "Змінює ваш нік", "Invites user with given id to current room": "Запрошує користувача з вказаним ідентифікатором до кімнати", - "Leave room": "Покинути кімнату", + "Leave room": "Залишити кімнату", "Kicks user with given id": "Викидає з кімнати користувача з вказаним ідентифікатором", "Ignores a user, hiding their messages from you": "Ігнорує користувача, приховуючи його повідомлення від вас", "Ignored user": "Зігнорований користувач", @@ -609,7 +609,7 @@ "You have %(count)s unread notifications in a prior version of this room.|other": "Ви маєте %(count)s непрочитаних сповіщень у попередній версії цієї кімнати.", "You have %(count)s unread notifications in a prior version of this room.|one": "У вас %(count)s непрочитане сповіщення у попередній версії цієї кімнати.", "Deactivate user?": "Знедіяти користувача?", - "Deactivating this user will log them out and prevent them from logging back in. Additionally, they will leave all the rooms they are in. This action cannot be reversed. Are you sure you want to deactivate this user?": "Знедіювання цього користувача виведе їх з системи і унеможливить вхід у майбутньому. До того ж, вони залишать усі кімнати, в яких перебувають. Ця дія є безповоротною. Ви впевнені, що хочете знедіяти цього користувача?", + "Deactivating this user will log them out and prevent them from logging back in. Additionally, they will leave all the rooms they are in. This action cannot be reversed. Are you sure you want to deactivate this user?": "Знедіювання цього користувача виведе їх з системи й унеможливить вхід у майбутньому. До того ж, вони залишать усі кімнати, в яких перебувають. Ця дія є безповоротною. Ви впевнені, що хочете знедіяти цього користувача?", "Deactivate user": "Знедіяти користувача", "Failed to deactivate user": "Не вдалось знедіяти користувача", "Deactivating your account does not by default cause us to forget messages you have sent. If you would like us to forget your messages, please tick the box below.": "Знедіювання вашого облікового запису типово не призводить до забуття надісланих вами повідомлень. Якщо ви бажаєте, щоб ми забули ваші повідомлення, поставте прапорець внизу.", @@ -1032,7 +1032,7 @@ "Use default": "Типово", "Mentions & Keywords": "Згадки та ключові слова", "Notification options": "Параметри сповіщень", - "Leave Room": "Вийти з кімнати", + "Leave Room": "Залишити кімнату", "Forget Room": "Забути кімнату", "Favourited": "Улюблено", "%(count)s unread messages including mentions.|other": "%(count)s непрочитаних повідомлень включно зі згадками.", @@ -1600,5 +1600,7 @@ "Try again": "Спробувати ще раз", "%(creator)s created this DM.": "%(creator)s створює цю приватну розмову.", "Share Link to User": "Поділитися посиланням на користувача", - "Messages here are end-to-end encrypted. Verify %(displayName)s in their profile - tap on their avatar.": "Повідомлення тут захищено наскрізним шифруванням. Підтвердьте %(displayName)s у їхньому профілі — натиснувши на їх аватар." + "Messages here are end-to-end encrypted. Verify %(displayName)s in their profile - tap on their avatar.": "Повідомлення тут захищено наскрізним шифруванням. Підтвердьте %(displayName)s у їхньому профілі — натиснувши на їх аватар.", + "Open": "Відкрити", + "In reply to ": "У відповідь на " } diff --git a/src/i18n/strings/vi.json b/src/i18n/strings/vi.json index 744310675c..eebbaef3d0 100644 --- a/src/i18n/strings/vi.json +++ b/src/i18n/strings/vi.json @@ -293,5 +293,7 @@ "Enable URL previews by default for participants in this room": "Bật mặc định xem trước nội dung đường link cho mọi người trong phòng", "Room Colour": "Màu phòng chat", "Enable widget screenshots on supported widgets": "Bật widget chụp màn hình cho các widget có hỗ trợ", - "Sign In": "Đăng nhập" + "Sign In": "Đăng nhập", + "Explore rooms": "Khám phá phòng chat", + "Create Account": "Tạo tài khoản" } diff --git a/src/i18n/strings/vls.json b/src/i18n/strings/vls.json index 1172804efa..75ab903ebe 100644 --- a/src/i18n/strings/vls.json +++ b/src/i18n/strings/vls.json @@ -1443,5 +1443,7 @@ "Terms of service not accepted or the identity server is invalid.": "Dienstvoorwoardn nie anveird, of den identiteitsserver is oungeldig.", "Enter a new identity server": "Gift e nieuwen identiteitsserver in", "Remove %(email)s?": "%(email)s verwydern?", - "Remove %(phone)s?": "%(phone)s verwydern?" + "Remove %(phone)s?": "%(phone)s verwydern?", + "Explore rooms": "Gesprekkn ountdekkn", + "Create Account": "Account anmoakn" } diff --git a/src/i18n/strings/zh_Hans.json b/src/i18n/strings/zh_Hans.json index d07d6c83b6..6afe74dbee 100644 --- a/src/i18n/strings/zh_Hans.json +++ b/src/i18n/strings/zh_Hans.json @@ -49,7 +49,7 @@ "Import E2E room keys": "导入聊天室端到端加密密钥", "Incorrect verification code": "验证码错误", "Invalid Email Address": "邮箱地址格式错误", - "Invalid file%(extra)s": "非法文件%(extra)s", + "Invalid file%(extra)s": "无效文件%(extra)s", "Return to login screen": "返回登录页面", "%(brand)s does not have permission to send you notifications - please check your browser settings": "%(brand)s 没有通知发送权限 - 请检查您的浏览器设置", "%(brand)s was not given permission to send notifications - please try again": "%(brand)s 没有通知发送权限 - 请重试", @@ -59,7 +59,7 @@ "Rooms": "聊天室", "Search": "搜索", "Search failed": "搜索失败", - "Searches DuckDuckGo for results": "搜索 DuckDuckGo", + "Searches DuckDuckGo for results": "使用 DuckDuckGo 搜索", "Send Reset Email": "发送密码重设邮件", "%(senderDisplayName)s sent an image.": "%(senderDisplayName)s 发送了一张图片。", "%(senderName)s sent an invitation to %(targetDisplayName)s to join the room.": "%(senderName)s 向 %(targetDisplayName)s 发了加入聊天室的邀请。", @@ -98,7 +98,7 @@ "Join Room": "加入聊天室", "%(targetName)s joined the room.": "%(targetName)s 已加入聊天室。", "Jump to first unread message.": "跳到第一条未读消息。", - "%(senderName)s kicked %(targetName)s.": "%(senderName)s 把 %(targetName)s 踢出了聊天室。", + "%(senderName)s kicked %(targetName)s.": "%(senderName)s 移除了 %(targetName)s。", "Leave room": "退出聊天室", "Add a topic": "添加主题", "Admin": "管理员", @@ -120,7 +120,7 @@ "Are you sure?": "你确定吗?", "Are you sure you want to leave the room '%(roomName)s'?": "你确定要退出聊天室 “%(roomName)s” 吗?", "Are you sure you want to reject the invitation?": "你确定要拒绝邀请吗?", - "Bans user with given id": "按照 ID 封禁指定的用户", + "Bans user with given id": "按照 ID 封禁用户", "Call Timeout": "通话超时", "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.": "无法连接主服务器 - 请检查网络连接,确保你的主服务器 SSL 证书被信任,且没有浏览器插件拦截请求。", "Can't connect to homeserver via HTTP when an HTTPS URL is in your browser bar. Either use HTTPS or enable unsafe scripts.": "当浏览器地址栏里有 HTTPS 的 URL 时,不能使用 HTTP 连接主服务器。请使用 HTTPS 或者允许不安全的脚本。", @@ -142,7 +142,7 @@ "Custom level": "自定义级别", "Decline": "拒绝", "Drop File Here": "把文件拖拽到这里", - "Enter passphrase": "输入密码", + "Enter passphrase": "输入密语", "Error: Problem communicating with the given homeserver.": "错误: 与指定的主服务器通信时出错。", "Export": "导出", "Failed to fetch avatar URL": "获取 Avatar URL 失败", @@ -179,13 +179,13 @@ "Cancel": "取消", "Create new room": "创建新聊天室", "Custom Server Options": "自定义服务器选项", - "Dismiss": "标记为已读", + "Dismiss": "忽略", "powered by Matrix": "由 Matrix 驱动", "Remove": "移除", "Room directory": "聊天室目录", "Start chat": "开始聊天", "unknown error code": "未知错误代码", - "Account": "账户", + "Account": "账号", "Add": "添加", "Allow": "允许", "Edit": "编辑", @@ -216,10 +216,10 @@ "Connectivity to the server has been lost.": "到服务器的连接已经丢失。", "New Password": "新密码", "Options": "选项", - "Passphrases must match": "密码必须匹配", - "Passphrase must not be empty": "密码不能为空", + "Passphrases must match": "密语必须匹配", + "Passphrase must not be empty": "密语不能为空", "Export room keys": "导出聊天室密钥", - "Confirm passphrase": "确认密码", + "Confirm passphrase": "确认密语", "Import room keys": "导入聊天室密钥", "File to import": "要导入的文件", "Failed to invite": "邀请失败", @@ -237,19 +237,19 @@ "Username available": "用户名可用", "Username not available": "用户名不可用", "Skip": "跳过", - "Example": "例子", + "Example": "示例", "Create": "创建", "Failed to upload image": "上传图像失败", "Add a widget": "添加小挂件", "Accept": "接受", "Access Token:": "访问令牌:", "Cannot add any more widgets": "无法添加更多小挂件", - "Delete widget": "删除小挂件", + "Delete widget": "删除挂件", "Define the power level of a user": "定义一位用户的滥权等级", "Enable automatic language detection for syntax highlighting": "启用语法高亮的自动语言检测", "Failed to change power level": "滥权等级修改失败", "Kick": "移除", - "Kicks user with given id": "按照 ID 移除特定的用户", + "Kicks user with given id": "按照 ID 移除用户", "Last seen": "最近一次上线", "New passwords must match each other.": "新密码必须互相匹配。", "Power level must be positive integer.": "滥权等级必须是正整数。", @@ -261,7 +261,7 @@ "This phone number is already in use": "此电话号码已被使用", "This room": "此聊天室", "This room is not accessible by remote Matrix servers": "此聊天室无法被远程 Matrix 服务器访问", - "Unable to create widget.": "无法创建小挂件。", + "Unable to create widget.": "无法创建挂件。", "Unban": "解除封禁", "Unable to capture screen": "无法录制屏幕", "Unable to enable Notifications": "无法启用通知", @@ -334,8 +334,8 @@ "(unknown failure: %(reason)s)": "(未知错误:%(reason)s)", "%(senderName)s withdrew %(targetName)s's invitation.": "%(senderName)s 收回了 %(targetName)s 的邀请。", "You cannot place a call with yourself.": "您无法向自己发起通话。", - "You have disabled URL previews by default.": "你已经默认 禁用 链接预览。", - "You have enabled URL previews by default.": "你已经默认 启用 链接预览。", + "You have disabled URL previews by default.": "你已经默认禁用链接预览。", + "You have enabled URL previews by default.": "你已经默认启用链接预览。", "Set a display name:": "设置昵称:", "This server does not support authentication with a phone number.": "此服务器不支持使用电话号码认证。", "Copied!": "已复制!", @@ -381,16 +381,16 @@ "This will be your account name on the homeserver, or you can pick a different server.": "这将会成为你在 主服务器上的账户名,或者你可以选择一个 不同的服务器。", "Authentication check failed: incorrect password?": "身份验证失败:密码错误?", "This will allow you to reset your password and receive notifications.": "这将允许你重置你的密码和接收通知。", - "%(widgetName)s widget added by %(senderName)s": "%(senderName)s 添加了 %(widgetName)s 小挂件", - "%(widgetName)s widget removed by %(senderName)s": "%(senderName)s 移除了 %(widgetName)s 小挂件", - "%(widgetName)s widget modified by %(senderName)s": "%(senderName)s 修改了 %(widgetName)s 小挂件", + "%(widgetName)s widget added by %(senderName)s": "%(senderName)s 添加了 %(widgetName)s 挂件", + "%(widgetName)s widget removed by %(senderName)s": "%(senderName)s 移除了 %(widgetName)s 挂件", + "%(widgetName)s widget modified by %(senderName)s": "%(senderName)s 修改了 %(widgetName)s 挂件", "Unpin Message": "取消置顶消息", "Add rooms to this community": "添加聊天室到此社区", "Call Failed": "呼叫失败", "Invite new community members": "邀请新社区成员", "Invite to Community": "邀请到社区", "Ignored user": "已忽略的用户", - "You are now ignoring %(userId)s": "你正在忽视 %(userId)s", + "You are now ignoring %(userId)s": "你忽略了 %(userId)s", "Unignored user": "未忽略的用户", "You are no longer ignoring %(userId)s": "你不再忽视 %(userId)s", "%(senderName)s unbanned %(targetName)s.": "%(senderName)s 解除了 %(targetName)s 的封禁。", @@ -420,7 +420,7 @@ "An email has been sent to %(emailAddress)s": "一封邮件已发送到 %(emailAddress)s", "A text message has been sent to %(msisdn)s": "一封短信已发送到 %(msisdn)s", "Visible to everyone": "对所有人可见", - "Delete Widget": "删除小挂件", + "Delete Widget": "删除挂件", "were invited %(count)s times|other": "被邀请 %(count)s 次", "were invited %(count)s times|one": "被邀请", "was invited %(count)s times|other": "被邀请 %(count)s 次", @@ -433,18 +433,18 @@ "were unbanned %(count)s times|one": "被解封", "was unbanned %(count)s times|other": "被解封 %(count)s 次", "was unbanned %(count)s times|one": "被解封", - "were kicked %(count)s times|other": "被踢出 %(count)s 次", - "were kicked %(count)s times|one": "被踢出", - "was kicked %(count)s times|other": "被踢出 %(count)s 次", - "was kicked %(count)s times|one": "被踢出", - "%(severalUsers)schanged their name %(count)s times|other": "%(severalUsers)s 改了他们的名称 %(count)s 次", - "%(severalUsers)schanged their name %(count)s times|one": "%(severalUsers)s 改了他们的名称", - "%(oneUser)schanged their name %(count)s times|other": "%(oneUser)s 改了他们的名称 %(count)s 次", - "%(oneUser)schanged their name %(count)s times|one": "%(oneUser)s 改了他们的名称", - "%(severalUsers)schanged their avatar %(count)s times|other": "%(severalUsers)s 更换了他们的的头像 %(count)s 次", + "were kicked %(count)s times|other": "被移除 %(count)s 次", + "were kicked %(count)s times|one": "被移除", + "was kicked %(count)s times|other": "被移除 %(count)s 次", + "was kicked %(count)s times|one": "被移除", + "%(severalUsers)schanged their name %(count)s times|other": "%(severalUsers)s 修改了他们的名称 %(count)s 次", + "%(severalUsers)schanged their name %(count)s times|one": "%(severalUsers)s 修改了他们的名称", + "%(oneUser)schanged their name %(count)s times|other": "%(oneUser)s 修改了自己的名称 %(count)s 次", + "%(oneUser)schanged their name %(count)s times|one": "%(oneUser)s 修改了自己的名称", + "%(severalUsers)schanged their avatar %(count)s times|other": "%(severalUsers)s 更换了他们的头像 %(count)s 次", "%(severalUsers)schanged their avatar %(count)s times|one": "%(severalUsers)s 更换了他们的头像", - "%(oneUser)schanged their avatar %(count)s times|other": "%(oneUser)s 更换了他们的头像 %(count)s 次", - "%(oneUser)schanged their avatar %(count)s times|one": "%(oneUser)s 更换了他们的头像", + "%(oneUser)schanged their avatar %(count)s times|other": "%(oneUser)s 更换了自己的头像 %(count)s 次", + "%(oneUser)schanged their avatar %(count)s times|one": "%(oneUser)s 更换了自己的头像", "%(items)s and %(count)s others|other": "%(items)s 和其他 %(count)s 人", "%(items)s and %(count)s others|one": "%(items)s 与另一个", "collapse": "折叠", @@ -487,9 +487,9 @@ "Members only (since they joined)": "只有成员(从他们加入开始)", "Invalid community ID": "无效的社区 ID", "Create Community": "创建社区", - "Community Name": "社区名", + "Community Name": "社区名称", "Community ID": "社区 ID", - "example": "例子", + "example": "示例", "Add a Room": "添加聊天室", "Add a User": "添加用户", "Unable to accept invite": "无法接受邀请", @@ -623,8 +623,8 @@ "Opens the Developer Tools dialog": "打开开发者工具窗口", "Notify the whole room": "通知聊天室全体成员", "This process allows you to export the keys for messages you have received in encrypted rooms to a local file. You will then be able to import the file into another Matrix client in the future, so that client will also be able to decrypt these messages.": "此操作允许您将加密聊天室中收到的消息的密钥导出为本地文件。您可以将文件导入其他 Matrix 客户端,以便让别的客户端在未收到密钥的情况下解密这些消息。", - "The exported file will allow anyone who can read it to decrypt any encrypted messages that you can see, so you should be careful to keep it secure. To help with this, you should enter a passphrase below, which will be used to encrypt the exported data. It will only be possible to import the data by using the same passphrase.": "导出的文件将允许任何可以读取它的人解密任何他们可以看到的加密消息,因此您应该小心以确保其安全。为解决此问题,您应该在下面输入密码以加密导出的数据。只有输入相同的密码才能导入数据。", - "The export file will be protected with a passphrase. You should enter the passphrase here, to decrypt the file.": "导出文件有密码保护。你需要在此输入密码以解密此文件。", + "The exported file will allow anyone who can read it to decrypt any encrypted messages that you can see, so you should be careful to keep it secure. To help with this, you should enter a passphrase below, which will be used to encrypt the exported data. It will only be possible to import the data by using the same passphrase.": "导出的文件将允许任何可以读取它的人解密任何他们可以看到的加密消息,因此,您应该小心对待,以确保其安全。为解决此问题,您应当在下面输入密语以加密导出的数据。只有输入相同的密语才能导入数据。", + "The export file will be protected with a passphrase. You should enter the passphrase here, to decrypt the file.": "导出文件受密语保护。必须输入密语以解密此文件。", "This process allows you to import encryption keys that you had previously exported from another Matrix client. You will then be able to decrypt any messages that the other client could decrypt.": "此操作允许您导入之前从另一个 Matrix 客户端中导出的加密密钥文件。导入完成后,您将能够解密那个客户端可以解密的加密消息。", "Ignores a user, hiding their messages from you": "忽略用户,隐藏他们发送的消息", "Stops ignoring a user, showing their messages going forward": "解除忽略用户,显示他们的消息", @@ -634,9 +634,9 @@ "%(count)s Resend all or cancel all now. You can also select individual messages to resend or cancel.|other": "現在 重新发送消息取消发送 。你也可以单独选择消息以重新发送或取消。", "Visibility in Room List": "是否在聊天室目录中可见", "Something went wrong when trying to get your communities.": "获取你加入的社区时发生错误。", - "Deleting a widget removes it for all users in this room. Are you sure you want to delete this widget?": "删除小挂件时将为聊天室中的所有成员删除。您确定要删除此小挂件吗?", + "Deleting a widget removes it for all users in this room. Are you sure you want to delete this widget?": "删除挂件时将为聊天室中的所有成员删除。您确定要删除此挂件吗?", "Fetching third party location failed": "获取第三方位置失败", - "Send Account Data": "发送账户数据", + "Send Account Data": "发送账号数据", "All notifications are currently disabled for all targets.": "目前所有通知都已禁用。", "Uploading report": "上传报告", "Sunday": "星期日", @@ -668,7 +668,7 @@ "Please set a password!": "请设置密码!", "You have successfully set a password!": "您已成功设置密码!", "An error occurred whilst saving your email notification preferences.": "保存电子邮件通知选项时出现错误。", - "Explore Room State": "探索聊天室状态", + "Explore Room State": "检查聊天室状态", "Source URL": "源网址", "Messages sent by bot": "由机器人发出的消息", "Filter results": "过滤结果", @@ -689,9 +689,9 @@ "Remove %(name)s from the directory?": "是否从目录中移除 %(name)s?", "%(brand)s uses many advanced browser features, some of which are not available or experimental in your current browser.": "%(brand)s 使用了许多先进的浏览器功能,有些在你目前所用的浏览器上无法使用或仅为实验性的功能。", "Developer Tools": "开发者工具", - "Preparing to send logs": "准备发送日志", + "Preparing to send logs": "正在准备发送日志", "Remember, you can always set an email address in user settings if you change your mind.": "请记住,如果您改变想法,您永远可以在用户设置中设置电子邮件。", - "Explore Account Data": "探索账户数据", + "Explore Account Data": "检查账号数据", "All messages (noisy)": "全部消息(响铃)", "Saturday": "星期六", "I understand the risks and wish to continue": "我了解这些风险并愿意继续", @@ -708,13 +708,13 @@ "(HTTP status %(httpStatus)s)": "(HTTP 状态 %(httpStatus)s)", "All Rooms": "全部聊天室", "Wednesday": "星期三", - "You cannot delete this message. (%(code)s)": "您不能删除此消息。(%(code)s)", + "You cannot delete this message. (%(code)s)": "你无法删除这条消息。(%(code)s)", "Quote": "引述", "Send logs": "发送日志", "All messages": "全部消息", "Call invitation": "语音邀请", "Downloading update...": "正在下载更新…", - "State Key": "状态密钥", + "State Key": "状态键(State Key)", "Failed to send custom event.": "自定义事件发送失败。", "What's new?": "更新内容", "Notify me for anything else": "通知所有消息", @@ -726,11 +726,11 @@ "Invite to this room": "邀请别人加入此聊天室", "Thursday": "星期四", "Search…": "搜索…", - "Logs sent": "记录已发送", + "Logs sent": "日志已发送", "Back": "返回", "Reply": "回复", "Show message in desktop notification": "在桌面通知中显示信息", - "Debug logs contain application usage data including your username, the IDs or aliases of the rooms or groups you have visited and the usernames of other users. They do not contain messages.": "调试日志包含使用数据(包括您的用户名,您访问过的聊天室 / 小组的 ID 或别名以及其他用户的用户名)。它们不包含聊天信息。", + "Debug logs contain application usage data including your username, the IDs or aliases of the rooms or groups you have visited and the usernames of other users. They do not contain messages.": "调试日志包含使用数据(包括您的用户名、您访问过的聊天室/群组的 ID 或别名,以及其他用户的用户名),不含聊天消息。", "Unhide Preview": "取消隐藏预览", "Unable to join network": "无法加入网络", "Sorry, your browser is not able to run %(brand)s.": "抱歉,您的浏览器 无法 运行 %(brand)s.", @@ -766,7 +766,7 @@ "You are about to be taken to a third-party site so you can authenticate your account for use with %(integrationsUrl)s. Do you wish to continue?": "您将被带到一个第三方网站以便验证您的账号来使用 %(integrationsUrl)s 提供的集成。您希望继续吗?", "The visibility of '%(roomName)s' in %(groupId)s could not be updated.": "无法更新聊天室 %(roomName)s 在社区 “%(groupId)s” 中的可见性。", "Minimize apps": "最小化应用程序", - "Popout widget": "在弹出式窗口中打开小挂件", + "Popout widget": "在弹出式窗口中打开挂件", "Unable to load event that was replied to, it either does not exist or you do not have permission to view it.": "无法加载被回复的事件,它可能不存在,也可能是您没有权限查看它。", "And %(count)s more...|other": "和 %(count)s 个其他…", "Try using one of the following valid address types: %(validTypesList)s.": "请尝试使用以下的有效邮箱地址格式中的一种:%(validTypesList)s", @@ -774,7 +774,7 @@ "Call in Progress": "正在通话", "A call is already in progress!": "您已在通话中!", "Send analytics data": "发送统计数据", - "Enable widget screenshots on supported widgets": "对支持的小挂件启用小挂件截图", + "Enable widget screenshots on supported widgets": "对支持的挂件启用挂件截图", "Demote yourself?": "是否降低您自己的权限?", "Demote": "降权", "A call is currently being placed!": "正在发起通话!", @@ -785,7 +785,7 @@ "Share room": "分享聊天室", "System Alerts": "系统警告", "Muted Users": "被禁言的用户", - "In encrypted rooms, like this one, URL previews are disabled by default to ensure that your homeserver (where the previews are generated) cannot gather information about links you see in this room.": "在启用加密的聊天室中,比如此聊天室,链接预览被默认禁用以确保主服务器(访问链接、生成预览的地方)无法获知聊天室中的链接及其信息。", + "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.": "在启用加密的聊天室中,比如此聊天室,链接预览被默认禁用,以确保主服务器(访问链接、生成预览的地方)无法获知聊天室中的链接及其信息。", "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.": "当有人发送一条带有链接的消息后,可显示链接的预览,链接预览可包含此链接的网页标题、描述以及图片。", "The email field must not be blank.": "必须输入电子邮箱。", "The phone number field must not be blank.": "必须输入电话号码。", @@ -793,11 +793,11 @@ "Display your community flair in rooms configured to show it.": "在启用“显示徽章”的聊天室中显示本社区的个性徽章。", "Failed to remove widget": "移除小挂件失败", "An error ocurred whilst trying to remove the widget from the room": "尝试从聊天室中移除小部件时发生了错误", - "Are you sure you wish to remove (delete) this event? Note that if you delete a room name or topic change, it could undo the change.": "您确定要移除(删除)此事件吗?注意,如果删除了聊天室名称或话题的变化,就会撤销此更改。", - "This will make your account permanently unusable. You will not be able to log in, and no one will be able to re-register the same user ID. This will cause your account to leave all rooms it is participating in, and it will remove your account details from your identity server. This action is irreversible.": "这将使您的账户永远不再可用。您将不能登录,或使用相同的用户 ID 重新注册。您的账户将退出所有已加入的聊天室,身份服务器上的账户信息也会被删除。此操作是不可逆的。", - "Deactivating your account does not by default cause us to forget messages you have sent. If you would like us to forget your messages, please tick the box below.": "停用您的账户 默认不会忘记您发送的消息 。如果您希望我们忘记您发送的消息,请勾选下面的选择框。", - "Message visibility in Matrix is similar to email. Our forgetting your messages means that messages you have sent will not be shared with any new or unregistered users, but registered users who already have access to these messages will still have access to their copy.": "Matrix 中的信息可见性类似于电子邮件。我们忘记您的消息意味着您发送的消息将不会被发至新注册或未注册的用户,但是已收到您的消息的注册用户依旧可以看到他们的副本。", - "Please forget all messages I have sent when my account is deactivated (Warning: this will cause future users to see an incomplete view of conversations)": "请在我停用账户的同时忘记我发送的所有消息(警告:这将导致未来的用户看到残缺的对话)", + "Are you sure you wish to remove (delete) this event? Note that if you delete a room name or topic change, it could undo the change.": "您确定要移除(删除)此事件吗?注意,如果删除了聊天室名称或话题的修改事件,就会撤销此更改。", + "This will make your account permanently unusable. You will not be able to log in, and no one will be able to re-register the same user ID. This will cause your account to leave all rooms it is participating in, and it will remove your account details from your identity server. This action is irreversible.": "这将使您的账号永远不再可用。您将不能登录,或使用相同的用户 ID 重新注册。您的账号将退出所有已加入的聊天室,身份服务器上的账号信息也会被删除。此操作是不可逆的。", + "Deactivating your account does not by default cause us to forget messages you have sent. If you would like us to forget your messages, please tick the box below.": "默认情况下,停用您的账号不会忘记您发送的消息 。如果您希望我们忘记您发送的消息,请勾选下面的选择框。", + "Message visibility in Matrix is similar to email. Our forgetting your messages means that messages you have sent will not be shared with any new or unregistered users, but registered users who already have access to these messages will still have access to their copy.": "Matrix 中的(历史)信息可见性类似于电子邮件。我们忘记您的消息意味着您发送的消息将不会被发至新注册或未注册的用户,但是已收到您的消息的注册用户依旧可以看到他们的副本。", + "Please forget all messages I have sent when my account is deactivated (Warning: this will cause future users to see an incomplete view of conversations)": "请在停用我的账号的同时忘记我发送的所有消息(警告:这将导致未来的用户看到的对话记录不完整)", "To continue, please enter your password:": "请输入您的密码以继续:", "Clear Storage and Sign Out": "清除数据并退出登录", "Send Logs": "发送日志", @@ -944,7 +944,7 @@ "Render simple counters in room header": "在聊天室标题中显示简单计数", "Enable Emoji suggestions while typing": "启用实时表情符号建议", "Show a placeholder for removed messages": "已移除的消息显示为一个占位符", - "Show join/leave messages (invites/kicks/bans unaffected)": "显示 加入/离开 信息(邀请/踢出/禁止 不受影响)", + "Show join/leave messages (invites/kicks/bans unaffected)": "显示 加入/离开 消息(邀请/移除/封禁 不受影响)", "Show avatar changes": "显示头像更改", "Show display name changes": "显示昵称更改", "Show read receipts sent by other users": "显示其他用户发送的已读回执", @@ -1084,7 +1084,7 @@ "Roles & Permissions": "角色与权限", "Changes to who can read history will only apply to future messages in this room. The visibility of existing history will be unchanged.": "历史记录阅读权限的变更只会应用到此聊天室中将来的消息。既有历史记录的可见性将不会变更。", "Encryption": "加密", - "Once enabled, encryption cannot be disabled.": "一旦启用加密就无法停止。", + "Once enabled, encryption cannot be disabled.": "加密一经启用,便无法禁用。", "Encrypted": "已加密", "Never lose encrypted messages": "永不丢失加密消息", "Messages in this room are secured with end-to-end encryption. Only you and the recipient(s) have the keys to read these messages.": "此聊天室中的消息已被端对端加密保护。只有您和拥有密钥的收件人才可以于都这些消息。", @@ -1101,15 +1101,15 @@ "Room Name": "聊天室名称", "Room Topic": "聊天室话题", "Join": "加入", - "That doesn't look like a valid email address": "看起来不像是个有效的电子邮箱地址", + "That doesn't look like a valid email address": "这看起来不像是有效的电子邮箱地址", "The following users may not exist": "以下用户可能不存在", - "Unable to find profiles for the Matrix IDs listed below - would you like to invite them anyway?": "无法找到以下列表中 Matrix ID 的用户资料 - 您还是要邀请吗?", + "Unable to find profiles for the Matrix IDs listed below - would you like to invite them anyway?": "找不到下列 Matrix ID 的用户资料,您还是要邀请吗?", "Invite anyway and never warn me again": "还是邀请,不用再提醒我", "Invite anyway": "还是邀请", - "Before submitting logs, you must create a GitHub issue to describe your problem.": "在提交日志之前,您必须 创建一个GitHub issue 来描述您的问题。", + "Before submitting logs, you must create a GitHub issue to describe your problem.": "在提交日志之前,您必须创建一个GitHub issue 来描述您的问题。", "Unable to load commit detail: %(msg)s": "无法加载提交详情:%(msg)s", "To avoid losing your chat history, you must export your room keys before logging out. You will need to go back to the newer version of %(brand)s to do this": "为避免丢失聊天记录,您必须在登出前导出房间密钥。 您需要回到较新版本的 %(brand)s 才能执行此操作", - "Verify this user to mark them as trusted. Trusting users gives you extra peace of mind when using end-to-end encrypted messages.": "验证此用户并标记为受信任。在使用端到端加密消息时,信任用户可让您更加放心。", + "Verify this user to mark them as trusted. Trusting users gives you extra peace of mind when using end-to-end encrypted messages.": "验证此用户并将其标记为已信任。在收发端到端加密消息时,信任用户可让您更加放心。", "Waiting for partner to confirm...": "等待对方确认中...", "Incoming Verification Request": "收到验证请求", "You've previously used %(brand)s on %(host)s with lazy loading of members enabled. In this version lazy loading is disabled. As the local cache is not compatible between these two settings, %(brand)s needs to resync your account.": "您之前在 %(host)s 上开启了 %(brand)s 的成员列表延迟加载设置。目前版本中延迟加载功能已被停用。因为本地缓存在这两个设置项上不相容,%(brand)s 需要重新同步您的账号。", @@ -1156,7 +1156,7 @@ "Homeserver URL": "主服务器网址", "Identity Server URL": "身份服务器网址", "Free": "免费", - "Join millions for free on the largest public server": "免费加入最大的公共服务器成为数百万用户中的一员", + "Join millions for free on the largest public server": "免费加入最大的公共服务器,成为数百万用户中的一员", "Premium": "高级", "Premium hosting for organisations Learn more": "组织机构的高级主机托管 了解更多", "Other": "其他", @@ -1202,7 +1202,7 @@ "Set up Secure Messages": "设置安全消息", "Recovery Method Removed": "恢复方式已移除", "If you didn't remove the recovery method, an attacker may be trying to access your account. Change your account password and set a new recovery method immediately in Settings.": "如果您没有移除该恢复方式,可能有攻击者正试图侵入您的账号。请立即更改您的账号密码并在设置中设定一个新的恢复方式。", - "Prepends ¯\\_(ツ)_/¯ to a plain-text message": "将 ¯\\_(ツ)_/¯ 添加到纯文本消息中", + "Prepends ¯\\_(ツ)_/¯ to a plain-text message": "在纯文本消息开头添加 ¯\\_(ツ)_/¯", "User %(userId)s is already in the room": "用户 %(userId)s 已在聊天室中", "The user must be unbanned before they can be invited.": "用户必须先解封才能被邀请。", "Upgrade to your own domain": "升级 到您自己的域名", @@ -1213,7 +1213,7 @@ "Change history visibility": "更改历史记录可见性", "Change permissions": "更改权限", "Change topic": "更改话题", - "Modify widgets": "修改小部件", + "Modify widgets": "修改挂件", "Default role": "默认角色", "Send messages": "发送消息", "Invite users": "邀请用户", @@ -1225,12 +1225,12 @@ "Send %(eventType)s events": "发送 %(eventType)s 事件", "Select the roles required to change various parts of the room": "选择更改聊天室各个部分所需的角色", "Enable encryption?": "启用加密?", - "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.": "聊天室加密一旦启用就无法再被停用。在加密聊天室内传送的消息不会被服务器看到,而只能被聊天室的参与者看到。启用加密可能会使许多机器人和桥接工作不正常。 详细了解加密。", + "Once enabled, encryption for a room cannot be disabled. Messages sent in an encrypted room cannot be seen by the server, only by the participants of the room. Enabling encryption may prevent many bots and bridges from working correctly. Learn more about encryption.": "聊天室加密一经启用,便无法禁用。在加密聊天室中,发送的消息无法被服务器看到,只能被聊天室的参与者看到。启用加密可能会使许多机器人和桥接无法正常运作。 详细了解加密。", "Power level": "权限级别", "Want more than a community? Get your own server": "想要的不只是社区? 架设您自己的服务器", "Please install Chrome, Firefox, or Safari for the best experience.": "请安装 ChromeFirefox,或 Safari 以获得最佳体验。", "Warning: Upgrading a room will not automatically migrate room members to the new version of the room. We'll post a link to the new room in the old version of the room - room members will have to click this link to join the new room.": "警告:升级聊天室 不会自动将聊天室成员转移到新版聊天室中。 我们将会在旧版聊天室中发布一个新版聊天室的链接 - 聊天室成员必须点击该链接以加入新聊天室。", - "Adds a custom widget by URL to the room": "用链接方式为聊天室添加自定义小部件", + "Adds a custom widget by URL to the room": "通过链接为聊天室添加自定义挂件", "Please supply a https:// or http:// widget URL": "请提供一个 https:// 或 http:// 形式的插件", "You cannot modify widgets in this room.": "您无法修改此聊天室的插件。", "%(senderName)s revoked the invitation for %(targetDisplayName)s to join the room.": "%(senderName)s 撤销了对 %(targetDisplayName)s 加入聊天室的邀请。", @@ -1244,7 +1244,7 @@ "Maximize apps": "最大化应用程序", "A widget would like to verify your identity": "小部件想要验证您的身份", "A widget located at %(widgetUrl)s would like to verify your identity. By allowing this, the widget will be able to verify your user ID, but not perform actions as you.": "位于 %(widgetUrl)s 的小部件想要验证您的身份。在您允许后,小部件就可以验证您的用户 ID,但不能代您执行操作。", - "Remember my selection for this widget": "记住我对此小部件的选择", + "Remember my selection for this widget": "记住我对此挂件的选择", "Deny": "拒绝", "%(brand)s failed to get the protocol list from the homeserver. The homeserver may be too old to support third party networks.": "%(brand)s 无法从主服务器处获取协议列表。该主服务器上的软件可能过旧,不支持第三方网络。", "%(brand)s failed to get the public room list.": "%(brand)s 无法获取公开聊天室列表。", @@ -1268,8 +1268,8 @@ "Confirm adding phone number": "确认添加电话号码", "Click the button below to confirm adding this phone number.": "点击下面的按钮以确认添加此电话号码。", "Whether you're using %(brand)s on a device where touch is the primary input mechanism": "是否在触屏设备上使用 %(brand)s", - "Whether you're using %(brand)s as an installed Progressive Web App": "您是否已经安装 %(brand)s 作为一种渐进式的 Web 应用", - "Your user agent": "您的代理用户", + "Whether you're using %(brand)s as an installed Progressive Web App": "您是否已将 %(brand)s 作为渐进式 Web 应用(PWA)安装", + "Your user agent": "您的用户代理(user agent)", "Replying With Files": "回复文件", "At this time it is not possible to reply with a file. Would you like to upload this file without replying?": "当前无法在回复中附加文件。您想要仅上传此文件而不回复吗?", "The file '%(fileName)s' failed to upload.": "上传文件 ‘%(fileName)s’ 失败。", @@ -1277,7 +1277,7 @@ "If you cancel now, you won't complete verifying the other user.": "如果现在取消,您将无法完成验证其他用户。", "If you cancel now, you won't complete verifying your other session.": "如果现在取消,您将无法完成验证您的其他会话。", "If you cancel now, you won't complete your operation.": "如果现在取消,您将无法完成您的操作。", - "Cancel entering passphrase?": "取消输入密码?", + "Cancel entering passphrase?": "取消输入密语?", "Setting up keys": "设置密钥", "Verify this session": "验证此会话", "Encryption upgrade available": "提供加密升级", @@ -1290,26 +1290,26 @@ "Only continue if you trust the owner of the server.": "只有您信任服务器所有者才能继续。", "Trust": "信任", "%(name)s is requesting verification": "%(name)s 正在请求验证", - "Sign In or Create Account": "登录或创建账户", - "Use your account or create a new one to continue.": "使用已有账户或创建一个新账户。", - "Create Account": "创建账户", + "Sign In or Create Account": "登录或创建账号", + "Use your account or create a new one to continue.": "使用已有账号或创建一个新账号。", + "Create Account": "创建账号", "Sign In": "登录", "Custom (%(level)s)": "访客(%(level)s)", "Messages": "信息", "Actions": "动作", - "Sends a message as plain text, without interpreting it as markdown": "以纯文本形式发送消息,而不是markdown", - "Sends a message as html, without interpreting it as markdown": "以html格式发送消息,而不是markdown", + "Sends a message as plain text, without interpreting it as markdown": "以纯文本形式发送消息,不将其作为 markdown 处理", + "Sends a message as html, without interpreting it as markdown": "以 html 格式发送消息,不将其作为 markdown 处理", "You do not have the required permissions to use this command.": "您没有权限使用此命令。", - "Error upgrading room": "升级聊天室出错", + "Error upgrading room": "升级聊天室时发生错误", "Double check that your server supports the room version chosen and try again.": "请再次检查您的服务器是否支持所选聊天室版本,然后再试一次。", "Changes the avatar of the current room": "更改当前聊天室头像", "Changes your avatar in this current room only": "仅改变您在当前聊天室的头像", "Changes your avatar in all rooms": "改变您在所有聊天室的头像", - "Failed to set topic": "设置话题失败", + "Failed to set topic": "话题设置失败", "Use an identity server": "使用身份服务器", - "Use an identity server to invite by email. Click continue to use the default identity server (%(defaultIdentityServerName)s) or manage in Settings.": "使用身份服务器通过电子邮件邀请。单击继续以使用默认身份服务器(%(defaultIdentityServerName)s)或在设置中进行管理。", - "Use an identity server to invite by email. Manage in Settings.": "使用身份服务器以电子邮件邀请。在设置中进行管理。", - "Unbans user with given ID": "禁止给定ID的用户", + "Use an identity server to invite by email. Click continue to use the default identity server (%(defaultIdentityServerName)s) or manage in Settings.": "使用身份服务器以通过电子邮件邀请其他用户。单击继续以使用默认身份服务器(%(defaultIdentityServerName)s),或在设置中进行管理。", + "Use an identity server to invite by email. Manage in Settings.": "使用身份服务器以通过电子邮件邀请其他用户。在设置中进行管理。", + "Unbans user with given ID": "按照 ID 解封用户", "Command failed": "命令失败", "Could not find user in room": "聊天室中无用户", "Please supply a widget URL or embed code": "请提供一个插件或嵌入代码", @@ -1374,7 +1374,7 @@ "Confirm your identity by verifying this login from one of your other sessions, granting it access to encrypted messages.": "通过从其他会话之一验证此登录名并授予其访问加密信息的权限来确认您的身份。", "Which officially provided instance you are using, if any": "如果您在使用官方实例,是哪一个", "Every page you use in the app": "您在应用中使用的每个页面", - "Are you sure you want to cancel entering passphrase?": "确定要取消输入密码?", + "Are you sure you want to cancel entering passphrase?": "您确定要取消输入密语吗?", "Go Back": "后退", "Use your account to sign in to the latest version": "使用您的帐户登录到最新版本", "We’re excited to announce Riot is now Element": "我们很高兴地宣布Riot现在更名为Element", @@ -1409,7 +1409,7 @@ "Help us improve %(brand)s": "请协助我们改进%(brand)s", "Send anonymous usage data which helps us improve %(brand)s. This will use a cookie.": "发送匿名使用情况数据,以协助我们改进%(brand)s。这将使用cookie。", "I want to help": "我乐意协助", - "Verify all your sessions to ensure your account & messages are safe": "验证您的所有会话,以确保帐户和消息安全", + "Verify all your sessions to ensure your account & messages are safe": "验证您的所有会话,以确保账号和消息安全", "Review": "开始验证", "Later": "稍后再说", "Your homeserver has exceeded its user limit.": "您的主服务器已超过用户限制。", @@ -1489,7 +1489,7 @@ "Changing password will currently reset any end-to-end encryption keys on all sessions, making encrypted chat history unreadable, unless you first export your room keys and re-import them afterwards. In future this will be improved.": "修改密码会重置所有会话上的端对端加密的密钥,使加密聊天记录不可读,除非您先导出您的聊天室密钥,之后再重新导入。在未来会有所改进。", "Your homeserver does not support cross-signing.": "您的主服务器不支持交叉签名。", "Cross-signing and secret storage are enabled.": "交叉签名和秘密存储已启用。", - "Your account has a cross-signing identity in secret storage, but it is not yet trusted by this session.": "您的账户在秘密存储中有交叉签名身份,但并没有被此会话信任。", + "Your account has a cross-signing identity in secret storage, but it is not yet trusted by this session.": "您的账号在秘密存储中有交叉签名身份,但并没有被此会话信任。", "Cross-signing and secret storage are not yet set up.": "交叉签名和秘密存储尚未设置。", "Reset cross-signing and secret storage": "重置交叉签名和秘密存储", "Bootstrap cross-signing and secret storage": "自举交叉签名和秘密存储", @@ -1503,7 +1503,7 @@ "not found locally": "本地未找到", "Session backup key:": "会话备份密钥:", "Secret storage public key:": "秘密存储公钥:", - "in account data": "在账户数据中", + "in account data": "在账号数据中", "exists": "存在", "Your homeserver does not support session management.": "您的主服务器不支持会话管理。", "Unable to load session list": "无法加载会话列表", @@ -1515,7 +1515,7 @@ "Delete %(count)s sessions|other": "删除 %(count)s 个会话", "Delete %(count)s sessions|one": "删除 %(count)s 个会话", "ID": "账号", - "Individually verify each session used by a user to mark it as trusted, not trusting cross-signed devices.": "逐一验证用户的每一个会话以将其标记为受信任的,而不信任交叉签名的设备。", + "Individually verify each session used by a user to mark it as trusted, not trusting cross-signed devices.": "逐一验证用户的每一个会话以将其标记为已信任,而不信任交叉签名的设备。", "Manage": "管理", "Enable": "启用", "%(brand)s is missing some components required for securely caching encrypted messages locally. If you'd like to experiment with this feature, build a custom %(brand)s Desktop with search components added.": "%(brand)s 缺少安全地在本地缓存加密信息所必须的部件。如果您想实验此功能,请构建一个自定义的带有搜索部件的 %(brand)s 桌面版。", @@ -1686,12 +1686,12 @@ "Cannot connect to integration manager": "不能连接到集成管理器", "The integration manager is offline or it cannot reach your homeserver.": "此集成管理器为离线状态或者其不能访问您的主服务器。", "check your browser plugins for anything that might block the identity server (such as Privacy Badger)": "检查您的浏览器是否安装有可能屏蔽身份服务器的插件(例如 Privacy Badger)", - "Use an Integration Manager (%(serverName)s) to manage bots, widgets, and sticker packs.": "使用集成管理器 (%(serverName)s) 以管理机器人、小挂件和贴图集。", - "Use an Integration Manager to manage bots, widgets, and sticker packs.": "使用集成管理器以管理机器人、小挂件和贴图集。", + "Use an Integration Manager (%(serverName)s) to manage bots, widgets, and sticker packs.": "使用集成管理器 (%(serverName)s) 以管理机器人、挂件和贴图集。", + "Use an Integration Manager to manage bots, widgets, and sticker packs.": "使用集成管理器以管理机器人、挂件和贴图集。", "Manage integrations": "管理集成", - "Integration Managers receive configuration data, and can modify widgets, send room invites, and set power levels on your behalf.": "集成管理器接收配置数据,并可以以您的名义修改小挂件、发送聊天室邀请及设置权限级别。", + "Integration Managers receive configuration data, and can modify widgets, send room invites, and set power levels on your behalf.": "集成管理器接收配置数据,并可以以您的名义修改挂件、发送聊天室邀请及设置权限级别。", "Use between %(min)s pt and %(max)s pt": "请使用介于 %(min)s pt 和 %(max)s pt 之间的大小", - "Deactivate account": "停用帐号", + "Deactivate account": "停用账号", "To report a Matrix-related security issue, please read the Matrix.org Security Disclosure Policy.": "要报告 Matrix 相关的安全问题,请阅读 Matrix.org 的安全公开策略。", "Something went wrong. Please try again or view your console for hints.": "出现问题。请重试或查看您的终端以获得提示。", "Please try again or view your console for hints.": "请重试或查看您的终端以获得提示。", @@ -1730,7 +1730,7 @@ "Joining room …": "正在加入聊天室…", "Loading …": "正在加载…", "Rejecting invite …": "正在拒绝邀请…", - "Join the conversation with an account": "使用一个账户加入对话", + "Join the conversation with an account": "使用一个账号加入对话", "Sign Up": "注册", "Loading room preview": "正在加载聊天室预览", "You were kicked from %(roomName)s by %(memberName)s": "您被 %(memberName)s 踢出了 %(roomName)s", @@ -1743,8 +1743,8 @@ "Try to join anyway": "仍然尝试加入", "You can still join it because this is a public room.": "您仍然能加入,因为这是一个公共聊天室。", "Join the discussion": "加入讨论", - "This invite to %(roomName)s was sent to %(email)s which is not associated with your account": "这个到 %(roomName)s 的邀请是发送给 %(email)s 的,而此邮箱没有关联您的账户", - "Link this email with your account in Settings to receive invites directly in %(brand)s.": "要在 %(brand)s 中直接接收邀请,请在设置中将您的账户连接到此邮箱。", + "This invite to %(roomName)s was sent to %(email)s which is not associated with your account": "这个到 %(roomName)s 的邀请是发送给 %(email)s 的,而此邮箱没有关联您的账号", + "Link this email with your account in Settings to receive invites directly in %(brand)s.": "要在 %(brand)s 中直接接收邀请,请在设置中将您的账号连接到此邮箱。", "This invite to %(roomName)s was sent to %(email)s": "这个到 %(roomName)s 的邀请是发送给 %(email)s 的", "Use an identity server in Settings to receive invites directly in %(brand)s.": "要直接在 %(brand)s 中接收邀请,请在设置中使用一个身份服务器。", "Share this email in Settings to receive invites directly in %(brand)s.": "要在 %(brand)s 中直接接收邀请,请在设置中共享此邮箱。", @@ -1870,9 +1870,9 @@ "You cancelled verification.": "您取消了验证。", "Verification cancelled": "验证已取消", "Compare emoji": "比较表情符号", - "Encryption enabled": "加密已启用", + "Encryption enabled": "已启用加密", "Messages in this room are end-to-end encrypted. Learn more & verify this user in their user profile.": "此聊天室中的消息是端对端加密的。请在其用户资料中了解更多并验证此用户。", - "Encryption not enabled": "加密未启用", + "Encryption not enabled": "未启用加密", "The encryption used by this room isn't supported.": "不支持此聊天室使用的加密方式。", "React": "回应", "Message Actions": "消息操作", @@ -1899,9 +1899,9 @@ "Message deleted by %(name)s": "消息被 %(name)s 删除", "Message deleted on %(date)s": "消息于 %(date)s 被删除", "Edited at %(date)s": "编辑于 %(date)s", - "Click to view edits": "点击查看编辑", - "Edited at %(date)s. Click to view edits.": "编辑于 %(date)s。点击以查看编辑。", - "edited": "被编辑过", + "Click to view edits": "点击查看编辑历史", + "Edited at %(date)s. Click to view edits.": "编辑于 %(date)s。点击以查看编辑历史。", + "edited": "已编辑", "Can't load this message": "无法加载此消息", "Submit logs": "提交日志", "Frequently Used": "经常使用", @@ -1923,36 +1923,36 @@ "Your theme": "您的主题", "%(brand)s URL": "%(brand)s 的链接", "Room ID": "聊天室 ID", - "Widget ID": "小挂件 ID", - "Using this widget may share data with %(widgetDomain)s & your Integration Manager.": "使用此小挂件可能会和 %(widgetDomain)s 及您的集成管理器共享数据 。", - "Using this widget may share data with %(widgetDomain)s.": "使用此小挂件可能会和 %(widgetDomain)s 共享数据 。", - "Widgets do not use message encryption.": "小挂件不适用消息加密。", - "This widget may use cookies.": "此小挂件可能使用 cookie。", + "Widget ID": "挂件 ID", + "Using this widget may share data with %(widgetDomain)s & your Integration Manager.": "使用此挂件可能会和 %(widgetDomain)s 及您的集成管理器共享数据 。", + "Using this widget may share data with %(widgetDomain)s.": "使用此挂件可能会和 %(widgetDomain)s 共享数据 。", + "Widgets do not use message encryption.": "挂件不适用消息加密。", + "This widget may use cookies.": "此挂件可能使用 cookie。", "More options": "更多选项", "Please create a new issue on GitHub so that we can investigate this bug.": "请在 GitHub 上创建一个新 issue 以便我们调查此错误。", "Rotate Left": "向左旋转", "Rotate counter-clockwise": "逆时针旋转", "Rotate Right": "向右旋转", "Rotate clockwise": "顺时针旋转", - "QR Code": "QR 码", + "QR Code": "二维码", "Room address": "聊天室地址", "e.g. my-room": "例如 my-room", - "Some characters not allowed": "一些字符不被允许", - "Please provide a room address": "请提供一个聊天室地址", + "Some characters not allowed": "不允许使用某些字符", + "Please provide a room address": "请提供聊天室地址", "This address is available to use": "此地址可用", "This address is already in use": "此地址已被使用", - "Enter a server name": "输入一个服务器名", + "Enter a server name": "请输入服务器名", "Looks good": "看着不错", - "Can't find this server or its room list": "不能找到此服务器或其聊天室列表", + "Can't find this server or its room list": "找不到此服务器或其聊天室列表", "All rooms": "所有聊天室", "Your server": "您的服务器", - "Are you sure you want to remove %(serverName)s": "您确定想删除 %(serverName)s 吗", - "Remove server": "删除服务器", + "Are you sure you want to remove %(serverName)s": "您确定要移除 %(serverName)s 吗", + "Remove server": "移除服务器", "Matrix": "Matrix", - "Add a new server": "添加一个新服务器", - "Enter the name of a new server you want to explore.": "输入您想探索的新服务器名。", + "Add a new server": "添加新服务器", + "Enter the name of a new server you want to explore.": "输入您想探索的新服务器的服务器名。", "Server name": "服务器名", - "Add a new server...": "添加一个新服务器...", + "Add a new server...": "添加新服务器…", "%(networkName)s rooms": "%(networkName)s 的聊天室", "Matrix rooms": "Matrix 聊天室", "Use an identity server to invite by email. Use the default (%(defaultIdentityServerName)s) or manage in Settings.": "使用一个身份服务器以通过邮箱邀请。使用默认(%(defaultIdentityServerName)s)或在设置中管理。", @@ -1963,27 +1963,27 @@ "GitHub issue": "GitHub 上的 issue", "Notes": "提示", "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.": "如果有额外的上下文可以帮助我们分析问题,比如您当时在做什么、房间 ID、用户 ID 等等,请将其列于此处。", - "Removing…": "正在删除…", + "Removing…": "正在移除…", "Destroy cross-signing keys?": "销毁交叉签名密钥?", "Deleting cross-signing keys is permanent. Anyone you have verified with will see security alerts. You almost certainly don't want to do this, unless you've lost every device you can cross-sign from.": "删除交叉签名密钥是永久的。所有您验证过的人都会看到安全警报。除非您丢失了所有可以交叉签名的设备,否则几乎可以确定您不想这么做。", "Clear cross-signing keys": "清楚交叉签名密钥", - "Clear all data in this session?": "清除此会话中的所有数据吗?", + "Clear all data in this session?": "是否清除此会话中的所有数据?", "Clearing all data from this session is permanent. Encrypted messages will be lost unless their keys have been backed up.": "清除此会话中的所有数据是永久的。加密消息会丢失,除非其密钥已被备份。", "Clear all data": "清除所有数据", - "Please enter a name for the room": "请为此聊天室输入一个名称", + "Please enter a name for the room": "请输入聊天室名称", "Set a room address to easily share your room with other people.": "设置一个聊天室地址以轻松地和别人共享您的聊天室。", "This room is private, and can only be joined by invitation.": "此聊天室是私人的,只能通过邀请加入。", "You can’t disable this later. Bridges & most bots won’t work yet.": "您之后不能禁用此项。桥接和大部分机器人还不能正常工作。", "Enable end-to-end encryption": "启用端对端加密", "Create a public room": "创建一个公共聊天室", "Create a private room": "创建一个私人聊天室", - "Topic (optional)": "主题(可选)", + "Topic (optional)": "话题(可选)", "Make this room public": "将此聊天室设为公共的", "Hide advanced": "隐藏高级", "Show advanced": "显示高级", "Block users on other matrix homeservers from joining this room (This setting cannot be changed later!)": "阻止别的 matrix 主服务器上的用户加入此聊天室(此设置之后不能更改!)", "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.": "您曾在此会话中使用了一个更新版本的 %(brand)s。要再使用此版本并使用端对端加密,您需要登出再重新登录。", - "Confirm your account deactivation by using Single Sign On to prove your identity.": "通过单点登录证明您的身份并确认停用您的账户。", + "Confirm your account deactivation by using Single Sign On to prove your identity.": "通过单点登录证明您的身份并确认停用您的账号。", "Are you sure you want to deactivate your account? This is irreversible.": "您确定要停用您的账号吗?此操作不可逆。", "Confirm account deactivation": "确认账号停用", "There was a problem communicating with the server. Please try again.": "联系服务器时出现问题。请重试。", @@ -1991,9 +1991,9 @@ "Server did not return valid authentication information.": "服务器未返回有效认证信息。", "View Servers in Room": "查看聊天室中的服务器", "Verification Requests": "验证请求", - "Verifying this user will mark their session as trusted, and also mark your session as trusted to them.": "验证此用户会将其会话标记为受信任的,并将您的会话对其标记为受信任的。", - "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.": "验证此设备以将其标记为受信任的。信任此设备可让您和别的用户在使用端对端加密消息时更加放心。", - "Verifying this device will mark it as trusted, and users who have verified with you will trust this device.": "验证此设备会将其标记为受信任的,而验证了您的用户将会信任此设备。", + "Verifying this user will mark their session as trusted, and also mark your session as trusted to them.": "验证此用户会将其会话标记为已信任,与此同时,您的会话也会被此用户标记为已信任。", + "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.": "验证此设备以将其标记为已信任。在收发端对端加密消息时,信任设备可让您与其他用户更加放心。", + "Verifying this device will mark it as trusted, and users who have verified with you will trust this device.": "验证此设备会将其标记为已信任,与此同时,其他验证了您的用户也会信任此设备。", "Integrations are disabled": "集成已禁用", "Enable 'Manage Integrations' in Settings to do this.": "在设置中启用「管理集成」以执行此操作。", "Integrations not allowed": "集成未被允许", @@ -2030,15 +2030,15 @@ "If they don't match, the security of your communication may be compromised.": "如果它们不匹配,您通讯的安全性可能已受损。", "Verify session": "验证会话", "Your homeserver doesn't seem to support this feature.": "您的主服务器似乎不支持此功能。", - "Message edits": "消息编辑", - "Your account is not secure": "您的账户不安全", + "Message edits": "消息编辑历史", + "Your account is not secure": "您的账号不安全", "Your password": "您的密码", "This session, or the other session": "此会话,或别的会话", "The internet connection either session is using": "您会话使用的网络连接", "We recommend you change your password and recovery key in Settings immediately": "我们推荐您立刻在设置中更改您的密码和恢复密钥", "New session": "新会话", "Use this session to verify your new one, granting it access to encrypted messages:": "使用此会话以验证您的新会话,并允许其访问加密信息:", - "If you didn’t sign in to this session, your account may be compromised.": "如果您没有登录进此会话,您的账户可能已受损。", + "If you didn’t sign in to this session, your account may be compromised.": "如果您没有登录进此会话,您的账号可能已受损。", "This wasn't me": "这不是我", "Use your account to sign in to the latest version of the app at ": "使用您的账户在 登录此应用的最新版", "You’re already signed in and good to go here, but you can also grab the latest versions of the app on all platforms at element.io/get-started.": "您已经登录且一切已就绪,但您也可以在 element.io/get-started 获取此应用在全平台上的最新版。", @@ -2077,7 +2077,7 @@ "Integration Manager": "集成管理器", "Find others by phone or email": "通过电话或邮箱寻找别人", "Be found by phone or email": "通过电话或邮箱被寻找", - "Use bots, bridges, widgets and sticker packs": "使用机器人、桥接、小挂件和贴图集", + "Use bots, bridges, widgets and sticker packs": "使用机器人、桥接、挂件和贴图集", "Terms of Service": "服务协议", "To continue you need to accept the terms of this service.": "要继续,您需要接受此服务协议。", "Service": "服务", @@ -2130,7 +2130,7 @@ "Remove for me": "为我删除", "User Status": "用户状态", "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.": "您可以使用自定义服务器选项以使用不同的主服务器链接登录至别的 Matrix 服务器。这允许您通过不同的主服务器上的现存 Matrix 账户使用 %(brand)s。", - "Confirm your identity by entering your account password below.": "在下方输入账户密码以确认您的身份。", + "Confirm your identity by entering your account password below.": "在下方输入账号密码以确认您的身份。", "Missing captcha public key in homeserver configuration. Please report this to your homeserver administrator.": "在主服务器配置中缺少验证码公钥。请将此报告给您的主服务器管理员。", "Unable to validate homeserver/identity server": "无法验证主服务器/身份服务器", "Enter the location of your Element Matrix Services homeserver. It may use your own domain name or be a subdomain of element.io.": "输入您的 Element Matrix Services 主服务器的地址。它可能使用您自己的域名,也可能是 element.io 的子域名。", @@ -2138,7 +2138,7 @@ "Nice, strong password!": "不错,是个强密码!", "Password is allowed, but unsafe": "密码允许但不安全", "No identity server is configured so you cannot add an email address in order to reset your password in the future.": "没有配置身份服务器因此您不能添加邮件地址以在将来重置您的密码。", - "Use an email address to recover your account": "使用邮件地址恢复您的账户", + "Use an email address to recover your account": "使用邮件地址恢复您的账号", "Enter email address (required on this homeserver)": "输入邮件地址(此主服务器上必须)", "Doesn't look like a valid email address": "看起来不像有效的邮件地址", "Passwords don't match": "密码不匹配", @@ -2193,10 +2193,10 @@ "Syncing...": "正在同步...", "Signing In...": "正在登录...", "If you've joined lots of rooms, this might take a while": "如果您加入了很多聊天室,可能会消耗一些时间", - "Your new account (%(newAccountId)s) is registered, but you're already logged into a different account (%(loggedInUserId)s).": "您的新账户(%(newAccountId)s)已注册,但您已经登录了一个不同的账户(%(loggedInUserId)s)。", - "Continue with previous account": "用之前的账户继续", - "Log in to your new account.": "登录到您的新账户。", - "You can now close this window or log in to your new account.": "您现在可以关闭此窗口或登录到您的新账户。", + "Your new account (%(newAccountId)s) is registered, but you're already logged into a different account (%(loggedInUserId)s).": "您的新账号(%(newAccountId)s)已注册,但您已经登录了一个不同的账号(%(loggedInUserId)s)。", + "Continue with previous account": "用之前的账号继续", + "Log in to your new account.": "登录到您的新账号。", + "You can now close this window or log in to your new account.": "您现在可以关闭此窗口或登录到您的新账号。", "Registration Successful": "注册成功", "Use Recovery Key or Passphrase": "使用恢复密钥或密码", "Use Recovery Key": "使用恢复密钥", @@ -2211,14 +2211,14 @@ "Without completing security on this session, it won’t have access to encrypted messages.": "若不在此会话中完成安全验证,它便不能访问加密消息。", "Failed to re-authenticate due to a homeserver problem": "由于主服务器的问题,重新认证失败", "Failed to re-authenticate": "重新认证失败", - "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.": "重新获得访问您账户的权限,并恢复存储在此会话中的加密密钥。没有这些密钥,您将不能在任何会话中阅读您的所有安全消息。", - "Enter your password to sign in and regain access to your account.": "输入您的密码以登录并重新获取访问您账户的权限。", + "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.": "重新获得访问您账号的权限,并恢复存储在此会话中的加密密钥。没有这些密钥,您将不能在任何会话中阅读您的所有安全消息。", + "Enter your password to sign in and regain access to your account.": "输入您的密码以登录并重新获取访问您账号的权限。", "Forgotten your password?": "忘记您的密码了吗?", - "Sign in and regain access to your account.": "请登录以重新获取访问您账户的权限。", - "You cannot sign in to your account. Please contact your homeserver admin for more information.": "您不能登录进您的账户。请联系您的主服务器管理员以获取更多信息。", + "Sign in and regain access to your account.": "请登录以重新获取访问您账号的权限。", + "You cannot sign in to your account. Please contact your homeserver admin for more information.": "您不能登录进您的账号。请联系您的主服务器管理员以获取更多信息。", "You're signed out": "您已登出", "Clear personal data": "清除个人信息", - "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.": "警告:您的个人信息(包括加密密钥)仍存储于此会话中。如果您不用再使用此会话或想登录进另一个账户,请清除它。", + "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.": "警告:您的个人信息(包括加密密钥)仍存储于此会话中。如果您不用再使用此会话或想登录进另一个账号,请清除它。", "Command Autocomplete": "命令自动补全", "Community Autocomplete": "社区自动补全", "DuckDuckGo Results": "DuckDuckGo 结果", @@ -2233,17 +2233,17 @@ "We’ll generate a Security Key for you to store somewhere safe, like a password manager or a safe.": "我们会生成一个安全密钥以便让您存储在安全的地方,比如密码管理器或保险箱里。", "Enter a Security Phrase": "输入一个安全密码", "Use a secret phrase only you know, and optionally save a Security Key to use for backup.": "使用一个只有您知道的密码,您也可以保存安全密钥以供备份使用。", - "Enter your account password to confirm the upgrade:": "输入您的账户密码以确认更新:", + "Enter your account password to confirm the upgrade:": "输入您的账号密码以确认更新:", "Restore your key backup to upgrade your encryption": "恢复您的密钥备份以更新您的加密方式", "Restore": "恢复", "You'll need to authenticate with the server to confirm the upgrade.": "您需要和服务器进行认证以确认更新。", - "Upgrade this session to allow it to verify other sessions, granting them access to encrypted messages and marking them as trusted for other users.": "更新此会话以允许其验证别的会话,以允许它们访问加密消息并将其对别的用户标记为受信任的。", - "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.": "输入一个只有您知道的安全密码,它将被用来保护您的数据。为了安全,您不应该复用您的账户密码。", + "Upgrade this session to allow it to verify other sessions, granting them access to encrypted messages and marking them as trusted for other users.": "更新此会话以允许其验证其他会话、允许其他会话访问加密消息,并将它们对别的用户标记为已信任。", + "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.": "输入一个只有您知道的安全密码,它将被用来保护您的数据。为了安全,您不应该复用您的账号密码。", "Enter a recovery passphrase": "输入一个恢复密码", "Great! This recovery passphrase looks strong enough.": "棒!这个恢复密码看着够强。", - "Use a different passphrase?": "使用不同的密码?", - "Enter your recovery passphrase a second time to confirm it.": "再次输入您的恢复密码以确认。", - "Confirm your recovery passphrase": "确认您的恢复密码", + "Use a different passphrase?": "使用不同的密语?", + "Enter your recovery passphrase a second time to confirm it.": "再次输入您的恢复密语以确认。", + "Confirm your recovery passphrase": "确认您的恢复密语", "Store your Security Key somewhere safe, like a password manager or a safe, as it’s used to safeguard your encrypted data.": "将您的安全密钥存储在安全的地方,像是密码管理器或保险箱里,它将被用来保护您的加密数据。", "Copy": "复制", "Unable to query secret storage status": "无法查询秘密存储状态", @@ -2290,7 +2290,7 @@ "Super": "超级", "Ctrl": "Ctrl", "New line": "换行", - "Jump to start/end of the composer": "跳转到编辑器的开始/结束", + "Jump to start/end of the composer": "跳转到编辑器的开头/结尾", "Cancel replying to a message": "取消回复消息", "Scroll up/down in the timeline": "在时间线中向上/下滚动", "Dismiss read marker and jump to bottom": "忽略已读标记并跳转到底部", @@ -2344,9 +2344,9 @@ "You can only join it with a working invite.": "您只能通过有效邀请加入。", "The session you are trying to verify doesn't support scanning a QR code or emoji verification, which is what %(brand)s supports. Try with a different client.": "您尝试验证的会话不支持 %(brand)s 支持的扫描二维码或表情符号验证。尝试使用其他客户端。", "Language Dropdown": "语言下拉菜单", - "%(severalUsers)smade no changes %(count)s times|other": "%(severalUsers)s %(count)s 次未做更改", + "%(severalUsers)smade no changes %(count)s times|other": "%(severalUsers)s 未做更改 %(count)s 次", "%(severalUsers)smade no changes %(count)s times|one": "%(severalUsers)s 未做更改", - "%(oneUser)smade no changes %(count)s times|other": "%(oneUser)s %(count)s 次未做更改", + "%(oneUser)smade no changes %(count)s times|other": "%(oneUser)s 未做更改 %(count)s 次", "%(oneUser)smade no changes %(count)s times|one": "%(oneUser)s 未做更改", "Preparing to download logs": "正在准备下载日志", "Download logs": "下载日志", @@ -2375,7 +2375,7 @@ "End Call": "结束通话", "Remove the group call from the room?": "是否从聊天室中移除聊天室?", "You don't have permission to remove the call from the room": "您没有权限从聊天室中移除此通话", - "Prepends ( ͡° ͜ʖ ͡°) to a plain-text message": "在纯文本消息之前附加 ( ͡° ͜ʖ ͡°)", + "Prepends ( ͡° ͜ʖ ͡°) to a plain-text message": "在纯文本消息开头添加 ( ͡° ͜ʖ ͡°)", "Group call modified by %(senderName)s": "群通话被 %(senderName)s 修改", "Group call started by %(senderName)s": "%(senderName)s 发起的群通话", "Group call ended by %(senderName)s": "%(senderName)s 结束了群通话", @@ -2539,8 +2539,8 @@ "Change the avatar of this room": "更改当前聊天室的头像", "Change the name of your active room": "更改活跃聊天室的名称", "Change the name of this room": "更改当前聊天室的名称", - "Change the topic of your active room": "更改当前活跃聊天室的讨论主题", - "Change the topic of this room": "更改当前聊天室的讨论主题", + "Change the topic of your active room": "更改当前活跃聊天室的话题", + "Change the topic of this room": "更改当前聊天室的话题", "Change which room, message, or user you're viewing": "更改当前正在查看哪个聊天室、消息或用户", "Change which room you're viewing": "更改当前正在查看哪个聊天室", "Send stickers into your active room": "发送贴纸到您的活跃聊天室", @@ -2552,8 +2552,8 @@ "(their device couldn't start the camera / microphone)": "(对方的设备无法开启摄像头/麦克风)", "Converts the room to a DM": "将此聊天室会话转化为私聊会话", "Converts the DM to a room": "将此私聊会话转化为聊天室会话", - "Prepends ┬──┬ ノ( ゜-゜ノ) to a plain-text message": "在纯文字信息前添加 ┬──┬ ノ( ゜-゜ノ)", - "Prepends (╯°□°)╯︵ ┻━┻ to a plain-text message": "在纯文字信息前添加 (╯°□°)╯︵ ┻━┻", + "Prepends ┬──┬ ノ( ゜-゜ノ) to a plain-text message": "在纯文本消息开头添加 ┬──┬ ノ( ゜-゜ノ)", + "Prepends (╯°□°)╯︵ ┻━┻ to a plain-text message": "在纯文本消息开头添加 (╯°□°)╯︵ ┻━┻", "You're already in a call with this person.": "您与此人已处在通话中。", "Already in call": "已在通话中", "Navigate composer history": "浏览编辑区历史", @@ -2624,8 +2624,8 @@ "Add existing room": "添加现有的聊天室", "Open dial pad": "打开拨号键盘", "Start a Conversation": "开始对话", - "Show Widgets": "显示小挂件", - "Hide Widgets": "隐藏小挂件", + "Show Widgets": "显示挂件", + "Hide Widgets": "隐藏挂件", "%(displayName)s created this room.": "%(displayName)s 创建了此聊天室。", "You created this room.": "你创建了此聊天室。", "Remove messages sent by others": "移除其他人的消息", @@ -2634,7 +2634,7 @@ "Send message": "发送消息", "Invite to this space": "邀请至此空间", "Your message was sent": "消息已发送", - "Back up your encryption keys with your account data in case you lose access to your sessions. Your keys will be secured with a unique Security Key.": "请使用您的帐户数据备份加密密钥,以免您无法访问您的会话。密钥将通过一个唯一的安全密钥进行保护。", + "Back up your encryption keys with your account data in case you lose access to your sessions. Your keys will be secured with a unique Security Key.": "请使用您的账号数据备份加密密钥,以免您无法访问您的会话。密钥将通过一个唯一的安全密钥进行保护。", "Spell check dictionaries": "拼写检查字典", "Failed to save your profile": "个人资料保存失败", "The operation could not be completed": "操作无法完成", @@ -2656,7 +2656,7 @@ "Offline encrypted messaging using dehydrated devices": "需要离线设备(dehydrated devices)的加密消息离线传递", "Spaces prototype. Incompatible with Communities, Communities v2 and Custom Tags. Requires compatible homeserver for some features.": "正在开发的空间功能的原型。与社区、社区 V2 和自定义标签功能不兼容。需要主服务器兼容才能使用某些功能。", "The %(capability)s capability": "%(capability)s 容量", - "%(senderName)s has updated the widget layout": "%(senderName)s 已更新小挂件布局", + "%(senderName)s has updated the widget layout": "%(senderName)s 已更新挂件布局", "Support": "支持", "Your server does not support showing space hierarchies.": "您的服务器不支持显示空间层次结构。", "This version of %(brand)s does not support searching encrypted messages": "当前版本的 %(brand)s 不支持搜索加密消息", @@ -2740,5 +2740,278 @@ "Curaçao": "库拉索", "Cuba": "古巴", "Croatia": "克罗地亚", - "Costa Rica": "哥斯达黎加" + "Costa Rica": "哥斯达黎加", + " invites you": " 邀请了你", + "Search names and description": "搜索名称与描述", + "No results found": "找不到结果", + "Mark as suggested": "标记为建议", + "Mark as not suggested": "标记为不建议", + "Removing...": "正在移除…", + "Failed to remove some rooms. Try again later": "无法移除某些聊天室。请稍后再试", + "%(count)s rooms and %(numSpaces)s spaces|one": "%(count)s 个聊天室和 %(numSpaces)s 个空间", + "%(count)s rooms and 1 space|other": "%(count)s 个聊天室和 1 个空间", + "%(count)s rooms and 1 space|one": "%(count)s 个聊天室和 1 个空间", + "Suggested": "建议", + "%(count)s rooms|one": "%(count)s 个聊天室", + "%(count)s rooms|other": "%(count)s 个聊天室", + "You don't have permission": "你没有权限", + "Filter rooms and people": "筛选聊天室与人员", + "Filter": "筛选", + "Explore rooms in %(communityName)s": "在 %(communityName)s 中探索聊天室", + "%(count)s messages deleted.|one": "已删除 %(count)s 条消息。", + "%(count)s messages deleted.|other": "已删除 %(count)s 条消息。", + "Cannot create rooms in this community": "无法在此社区中创建聊天室", + "Upgrade to %(hostSignupBrand)s": "升级至 %(hostSignupBrand)s", + "Enter phone number": "输入电话号码", + "Enter email address": "输入邮箱地址", + "Move right": "向右移动", + "Move left": "向左移动", + "Revoke permissions": "撤销权限", + "Take a picture": "拍照", + "Enter Security Phrase": "输入安全密语", + "Allow this widget to verify your identity": "允许此挂件验证您的身份", + "Decline All": "全部拒绝", + "Approve": "批准", + "This widget would like to:": "此挂件想要:", + "Approve widget permissions": "批准挂件权限", + "Failed to save space settings.": "空间设置保存失败。", + "Sign into your homeserver": "登录您的主服务器", + "Unable to validate homeserver": "无法验证主服务器", + "Invalid URL": "URL 无效", + "Modal Widget": "模态框挂件(Modal Widget)", + "Widget added by": "挂件添加者", + "Set my room layout for everyone": "将我的聊天室布局设置给所有人", + "Edit widgets, bridges & bots": "编辑挂件、桥接和机器人", + "Add widgets, bridges & bots": "添加挂件、桥接和机器人", + "Join the conference at the top of this room": "点击聊天室顶部加入会议", + "Join the conference from the room information card on the right": "从右侧的聊天室信息卡片加入会议", + "Video conference ended by %(senderName)s": "由 %(senderName)s 结束的视频会议", + "Video conference updated by %(senderName)s": "由 %(senderName)s 更新的视频会议", + "Video conference started by %(senderName)s": "由 %(senderName)s 发起的视频会议", + "There was an error finding this widget.": "查找此挂件时出现错误。", + "Active Widgets": "已启用的挂件", + "Widgets": "挂件", + "This is the start of .": "这里是 的开始。", + "Add a photo, so people can easily spot your room.": "添加图片,让人们一眼就能看到你的聊天室。", + "You can change these anytime.": "您随时可以更改它们。", + "Add some details to help people recognise it.": "添加一些细节,以便人们辨识你的社区。", + "Open space for anyone, best for communities": "适合每一个人的开放空间,社区的理想选择", + "Spaces are new ways to group rooms and people. To join an existing space you'll need an invite.": "空间是为房间和人员分组的新方法。要加入现有的空间,您需要被邀请。", + "From %(deviceName)s (%(deviceId)s) at %(ip)s": "来自 %(deviceName)s(%(deviceId)s)于 %(ip)s", + "New version of %(brand)s is available": "%(brand)s 有新版本可用", + "You have unverified logins": "您有未验证的登录", + "You should know": "你应当知道", + "Learn more in our , and .": "请通过我们的了解更多信息。", + "Failed to connect to your homeserver. Please close this dialog and try again.": "无法连接至您的主服务器。请关闭此对话框并再试一次。", + "Are you sure you wish to abort creation of the host? The process cannot be continued.": "您确定要放弃创建主机吗?被放弃的创建流程将无法再继续。", + "Confirm abort of host creation": "确定放弃创建主机", + "Please view existing bugs on Github first. No match? Start a new one.": "请先查找一下 Github 上已有的问题,以免重复。找不到重复问题?发起一个吧。", + "PRO TIP: If you start a bug, please submit debug logs to help us track down the problem.": "专业建议:如果您要发起新问题,请一并提交调试日志,以便我们找出问题根源。", + "There are two ways you can provide feedback and help us improve %(brand)s.": "有两种方式可以提供反馈,并帮助我们改进 %(brand)s。", + "Please go into as much detail as you like, so we can track down the problem.": "请按照你的意愿,尽可能详细地描述问题,以便我们找出问题根源。", + "Tell us below how you feel about %(brand)s so far.": "请在下面告诉我们直到目前为止您使用 %(brand)s 的感受。", + "There was an error updating your community. The server is unable to process your request.": "更新你的社区时出现错误。服务器无法处理你的请求。", + "Values at explicit levels": "各层级的值", + "Values at explicit levels:": "各层级的值:", + "Values at explicit levels in this room": "此聊天室中各层级的值", + "Values at explicit levels in this room:": "此聊天室中各层级的值:", + "Value in this room:": "此聊天室中的值:", + "Save setting values": "保存设置值", + "Settable at global": "全局可设置性", + "Settable at room": "聊天室可设置性", + "Level": "层级", + "This UI does NOT check the types of the values. Use at your own risk.": "此界面不会检查值的类型。使用风险自负。", + "Value in this room": "此聊天室中的值", + "Settings Explorer": "设置浏览器", + "with state key %(stateKey)s": "附带有状态键(state key)%(stateKey)s", + "Your server requires encryption to be enabled in private rooms.": "您的服务器要求私人房间启用加密。", + "An image will help people identify your community.": "图片可以让人们辨识您的社区。", + "What's the name of your community or team?": "你的社区或者团队的名称是什么?", + "You can change this later if needed.": "如果需要,您可以稍后更改。", + "Use this when referencing your community to others. The community ID cannot be changed.": "在将您的社区推荐给其他人时使用此 ID,社区 ID 不能更改。", + "There was an error creating your community. The name may be taken or the server is unable to process your request.": "创建社区时发生错误。名称可能已被使用,或者服务器无法处理您的请求。", + "Invite people to join %(communityName)s": "邀请人们加入 %(communityName)s", + "Send %(count)s invites|one": "发送 %(count)s 个邀请", + "Send %(count)s invites|other": "发送 %(count)s 个邀请", + "People you know on %(brand)s": "你在 %(brand)s 上认识的人", + "Add another email": "添加其他邮箱", + "Failed to add rooms to space": "添加聊天室到空间失败", + "Don't want to add an existing room?": "不想添加现有的聊天室?", + "Filter your rooms and spaces": "筛选你的空间/聊天室", + "Add existing spaces/rooms": "添加现有的空间/聊天室", + "Space selection": "空间选择", + "You can use the custom server options to sign into other Matrix servers by specifying a different homeserver URL. This allows you to use Element with an existing Matrix account on a different homeserver.": "通过自定义服务器选项,你可以自行指定并登录其他 Matrix 主服务器。这允许你使用其他主服务器上现有的 Matrix 账号。", + "See when the avatar changes in this room": "查看此聊天室的头像何时被修改", + "See when the name changes in your active room": "查看你的活跃聊天室的名称何时被修改", + "See when the name changes in this room": "查看此聊天室的名称何时被修改", + "See when the topic changes in your active room": "查看你的活跃聊天室的话题何时被修改", + "See when the topic changes in this room": "查看此聊天室的话题何时被修改", + "See %(eventType)s events posted to this room": "查看此聊天室中发送的 %(eventType)s 事件", + "See %(eventType)s events posted to your active room": "查看你的活跃聊天室中发送的 %(eventType)s 事件", + "Send %(eventType)s events as you in your active room": "使用当前账号在你的活跃聊天室发送 %(eventType)s 事件", + "Send %(eventType)s events as you in this room": "使用当前账号在此聊天室发送 %(eventType)s 事件", + "with an empty state key": "附带一个空的状态键(state key)", + "See when a sticker is posted in this room": "查看此聊天室中何时有人发送贴纸", + "See when the avatar changes in your active room": "查看你的活跃聊天室的头像何时修改", + "Invite to %(roomName)s": "邀请至 %(roomName)s", + "Invite to %(spaceName)s": "邀请至 %(spaceName)s", + "Failed to transfer call": "通话转移失败", + "Invite by email": "通过邮箱邀请", + "Minimize dialog": "最小化对话框", + "Maximize dialog": "最大化对话框", + "%(hostSignupBrand)s Setup": "%(hostSignupBrand)s 设置", + "Comment": "备注", + "Add comment": "添加备注", + "Rate %(brand)s": "评价 %(brand)s", + "Feedback sent": "反馈已发送", + "Update community": "更新社区", + "Failed to save settings": "设置保存失败", + "Create a room in %(communityName)s": "在 %(communityName)s 中创建聊天室", + "Add image (optional)": "添加图片(可选)", + "Edit devices": "编辑设备", + "%(count)s people|other": "%(count)s 人", + "Invite People": "邀请人们", + "Suggested Rooms": "建议的聊天室", + "Explore space rooms": "探索空间聊天室", + "Recently visited rooms": "最近访问的聊天室", + "Channel: ": "频道:", + "Workspace: ": "工作空间:", + "Invite with email or username": "使用邮箱或者用户名邀请", + "Invite people": "邀请人们", + "Update %(brand)s": "更新 %(brand)s", + "Check your devices": "检查您的设备", + "Zimbabwe": "津巴布韦", + "Zambia": "赞比亚", + "Western Sahara": "西撒哈拉", + "Wallis & Futuna": "瓦利斯和富图纳群岛", + "Vietnam": "越南", + "Venezuela": "委内瑞拉", + "Vatican City": "梵蒂冈", + "Vanuatu": "瓦努阿图", + "Uzbekistan": "乌兹别克斯坦", + "Uruguay": "乌拉圭", + "Tuvalu": "图瓦卢", + "Turks & Caicos Islands": "特克斯和凯科斯群岛", + "Tunisia": "突尼斯", + "Trinidad & Tobago": "特立尼达和多巴哥", + "Tonga": "汤加", + "Tokelau": "托克劳群岛", + "Togo": "多哥", + "Timor-Leste": "东帝汶", + "Thailand": "泰国", + "Tanzania": "坦桑尼亚", + "Tajikistan": "塔吉克斯坦", + "São Tomé & Príncipe": "圣多美和普林西比", + "Syria": "叙利亚", + "Switzerland": "瑞士", + "Swaziland": "埃斯瓦蒂尼(斯威士兰)", + "Svalbard & Jan Mayen": "斯瓦尔巴群岛&扬马延", + "Suriname": "苏里南", + "Sudan": "苏丹", + "St. Vincent & Grenadines": "圣文森特和格林纳丁斯", + "St. Pierre & Miquelon": "圣皮埃尔和密克隆群岛", + "St. Martin": "圣马丁岛", + "St. Lucia": "圣卢西亚", + "St. Kitts & Nevis": "圣基茨和尼维斯", + "St. Helena": "圣赫勒拿岛", + "St. Barthélemy": "圣巴托洛缪岛", + "Sri Lanka": "斯里兰卡", + "South Sudan": "南苏丹", + "South Georgia & South Sandwich Islands": "南乔治亚岛和南桑威奇群岛", + "Somalia": "索马里", + "Solomon Islands": "所罗门群岛", + "Sierra Leone": "塞拉利昂", + "Seychelles": "塞舌尔", + "Serbia": "塞尔维亚", + "Senegal": "塞内加尔", + "Saudi Arabia": "沙特阿拉伯", + "San Marino": "圣马力诺", + "Samoa": "萨摩亚", + "Réunion": "留尼汪岛", + "Rwanda": "卢旺达", + "Pitcairn Islands": "皮特凯恩群岛", + "Peru": "秘鲁", + "Paraguay": "巴拉圭", + "Papua New Guinea": "巴布亚新几内亚", + "Panama": "巴拿马", + "Palestine": "巴勒斯坦", + "Palau": "帕劳", + "Oman": "阿曼", + "New Caledonia": "新喀里多尼亚", + "Nepal": "尼泊尔", + "Nauru": "瑙鲁", + "Namibia": "纳米比亚", + "Myanmar": "缅甸", + "Mozambique": "莫桑比克", + "Morocco": "摩洛哥", + "Montserrat": "蒙特塞拉特", + "Montenegro": "黑山", + "Mongolia": "蒙古", + "Monaco": "摩纳哥", + "Moldova": "摩尔多瓦", + "Micronesia": "密克罗尼西亚", + "Mayotte": "马约特岛", + "Mauritius": "毛里求斯", + "Mauritania": "毛里塔尼亚", + "Martinique": "马提尼克岛", + "Marshall Islands": "马绍尔群岛", + "Malta": "马耳他", + "Mali": "马里", + "Ignored attempt to disable encryption": "已忽略禁用加密的尝试", + "Messages in this room are end-to-end encrypted. When people join, you can verify them in their profile, just tap on their avatar.": "此聊天室中的消息已被端对端加密。当人们加入,你可以点击他们的头像,在他们的资料中验证他们。", + "Messages here are end-to-end encrypted. Verify %(displayName)s in their profile - tap on their avatar.": "此处的消息已被端对端加密。请点击对方头像,在其资料中验证 %(displayName)s。", + "Secure your backup with a Security Phrase": "使用安全密语保护您的备份", + "Confirm your Security Phrase": "确认您的安全密语", + "Verify with another session": "使用另一个会话验证", + "Use Security Key or Phrase": "使用安全密钥或密语", + "Continue with %(ssoButtons)s": "使用 %(ssoButtons)s 继续", + "There was a problem communicating with the homeserver, please try again later.": "与主服务器通讯时出现问题,请稍后再试。", + "Decrypted event source": "解密事件源码", + "Original event source": "原始事件源码", + "Community and user menu": "社区与用户菜单", + "Community settings": "社区设置", + "Invite by username": "按照用户名邀请", + "Inviting...": "正在邀请…", + "Welcome to ": "欢迎来到 ", + "Share %(name)s": "分享 %(name)s", + "Open": "打开", + "Add a topic to help people know what it is about.": "添加话题,让大家知道这里是讨论什么的。", + "Topic: %(topic)s (edit)": "话题:%(topic)s(编辑)", + "Topic: %(topic)s ": "话题:%(topic)s ", + "Sint Maarten": "圣马丁岛", + "Slovenia": "斯洛文尼亚", + "Singapore": "新加坡", + "Slovakia": "斯洛伐克", + "Portugal": "葡萄牙", + "Poland": "波兰", + "Qatar": "卡塔尔", + "Puerto Rico": "波多黎各", + "Northern Mariana Islands": "北马里亚纳群岛", + "Norfolk Island": "诺福克岛", + "Niue": "纽埃", + "Nigeria": "奈及利亚", + "Niger": "尼日尔", + "Nicaragua": "尼加拉瓜", + "Maldives": "马尔代夫", + "Malawi": "马拉维", + "Madagascar": "马达加斯加", + "Macedonia": "马其顿", + "Lesotho": "莱索托", + "Kyrgyzstan": "吉尔吉斯斯坦", + "Kuwait": "科威特", + "Kosovo": "科索沃", + "Kiribati": "基里巴斯", + "Kenya": "肯尼亚", + "Kazakhstan": "哈萨克斯坦", + "Jordan": "约旦", + "Jersey": "泽西岛", + "Isle of Man": "马恩岛", + "Hungary": "匈牙利", + "Honduras": "洪都拉斯", + "Heard & McDonald Islands": "赫德岛和麦克唐纳群岛", + "Haiti": "海地", + "Guyana": "圭亚那", + "Guinea-Bissau": "几内亚比绍", + "Guinea": "几内亚", + "Guernsey": "根西岛" } diff --git a/src/i18n/strings/zh_Hant.json b/src/i18n/strings/zh_Hant.json index 6932b1ba9a..1ab5a59911 100644 --- a/src/i18n/strings/zh_Hant.json +++ b/src/i18n/strings/zh_Hant.json @@ -3205,5 +3205,170 @@ "This homeserver has been blocked by it's administrator.": "此家伺服器已被其管理員封鎖。", "This homeserver has been blocked by its administrator.": "此家伺服器已被其管理員封鎖。", "You're already in a call with this person.": "您已與此人通話。", - "Already in call": "已在通話中" + "Already in call": "已在通話中", + "Verify this login to access your encrypted messages and prove to others that this login is really you.": "驗證此登入已存取您的已加密訊息,並向其他人證明此登入真的視您。", + "Verify with another session": "使用另一個工作階段進行驗證", + "We'll create rooms for each of them. You can add more later too, including already existing ones.": "我們將會為每個主題建立一個聊天室。稍後您還可以新增更多,包含既有的。", + "Let's create a room for each of them. You can add more later too, including already existing ones.": "讓我們為每個主題建立一個聊天室。稍後您還可以新增更多,包含既有的。", + "Make sure the right people have access. You can invite more later.": "確保合適的人有權存取。稍後您可以邀請更多人。", + "A private space to organise your rooms": "供整理您聊天室的私人空間", + "Just me": "只有我", + "Make sure the right people have access to %(name)s": "確保合適的人有權存取 %(name)s", + "Go to my first room": "到我的第一個聊天室", + "It's just you at the moment, it will be even better with others.": "目前只有您一個人,有其他人會更好。", + "Share %(name)s": "分享 %(name)s", + "Private space": "私人空間", + "Public space": "公開空間", + " invites you": " 邀請您", + "Search names and description": "搜尋名稱與描述", + "You may want to try a different search or check for typos.": "您可能要嘗試其他搜尋或檢查是否有拼字錯誤。", + "No results found": "找不到結果", + "Mark as suggested": "標記為建議", + "Mark as not suggested": "標記為不建議", + "Removing...": "正在移除……", + "Failed to remove some rooms. Try again later": "移除部份聊天是失敗。稍後再試", + "%(count)s rooms and 1 space|one": "%(count)s 個聊天室與 1 個空間", + "%(count)s rooms and 1 space|other": "%(count)s 個聊天室與 1 個空間", + "%(count)s rooms and %(numSpaces)s spaces|one": "%(count)s 個聊天室與 %(numSpaces)s 個空間", + "%(count)s rooms and %(numSpaces)s spaces|other": "%(count)s 個聊天室與 %(numSpaces)s 個空間", + "If you can't find the room you're looking for, ask for an invite or create a new room.": "如果您找不到您在找的聊天室,請尋求邀請或建立新聊天室。", + "Suggested": "建議", + "This room is suggested as a good one to join": "建議加入這個相當不錯的聊天室", + "%(count)s rooms|one": "%(count)s 個聊天室", + "%(count)s rooms|other": "%(count)s 個聊天室", + "You don't have permission": "您沒有權限", + "Open": "開啟", + "%(count)s messages deleted.|one": "已刪除 %(count)s 則訊息。", + "%(count)s messages deleted.|other": "已刪除 %(count)s 則訊息。", + "This usually only affects how the room is processed on the server. If you're having problems with your %(brand)s, please report a bug.": "這通常只影響伺服器如何處理聊天是。如果您的 %(brand)s 遇到問題,請回報臭蟲。", + "Invite to %(roomName)s": "邀請至 %(roomName)s", + "Edit devices": "編輯裝置", + "Invite People": "邀請夥伴", + "Invite with email or username": "使用電子郵件或使用者名稱邀請", + "You can change these anytime.": "您隨時可以變更這些。", + "Add some details to help people recognise it.": "新增一些詳細資訊來協助人們識別它。", + "Spaces are new ways to group rooms and people. To join an existing space you'll need an invite.": "空間是將聊天室與人們分類的新方法。要加入既有的空間,您需要邀請。", + "From %(deviceName)s (%(deviceId)s) at %(ip)s": "從 %(deviceName)s (%(deviceId)s) 於 %(ip)s", + "Check your devices": "檢查您的裝置", + "A new login is accessing your account: %(name)s (%(deviceID)s) at %(ip)s": "新登入正在存取您的帳號:%(name)s (%(deviceID)s) 於 %(ip)s", + "You have unverified logins": "您有未驗證的登入", + "unknown person": "不明身份的人", + "Consulting with %(transferTarget)s. Transfer to %(transferee)s": "與 %(transferTarget)s 進行協商。轉讓至 %(transferee)s", + "Message search initilisation failed": "訊息搜尋初始化失敗", + "Invite to just this room": "邀請到此聊天室", + "If you do, please note that none of your messages will be deleted, but the search experience might be degraded for a few momentswhilst the index is recreated": "如果這樣做,請注意,您的任何訊息都不會被刪除,但是在重新建立索引的同時,搜索體驗可能會降低片刻", + "Let's create a room for each of them.": "讓我們為每個主題建立一個聊天室吧。", + "Verify your identity to access encrypted messages and prove your identity to others.": "驗證您的身份來存取已加密的訊息並對其他人證明您的身份。", + "Sends the given message as a spoiler": "將指定訊息以劇透傳送", + "Review to ensure your account is safe": "請審閱以確保您的帳號安全", + "%(deviceId)s from %(ip)s": "從 %(ip)s 而來的 %(deviceId)s", + "Send and receive voice messages (in development)": "傳送與接收語音訊息(開發中)", + "Share decryption keys for room history when inviting users": "邀請使用者時分享聊天室歷史紀錄的解密金鑰", + "Manage & explore rooms": "管理與探索聊天室", + "Warn before quitting": "離開前警告", + "Quick actions": "快速動作", + "Invite messages are hidden by default. Click to show the message.": "邀請訊息預設隱藏。點擊以顯示訊息。", + "Record a voice message": "錄製語音訊息", + "Stop & send recording": "停止並傳送錄音", + "Accept on your other login…": "接受您的其他登入……", + "%(count)s people you know have already joined|other": "%(count)s 個您認識的人已加入", + "%(count)s people you know have already joined|one": "%(count)s 個您認識的人已加入", + "Add existing rooms": "新增既有聊天室", + "Adding...": "正在新增……", + "We couldn't create your DM.": "我們無法建立您的直接訊息。", + "Invited people will be able to read old messages.": "被邀請的人將能閱讀舊訊息。", + "Consult first": "先協商", + "Reset event store?": "重設活動儲存?", + "You most likely do not want to reset your event index store": "您很可能不想重設您的活動索引儲存", + "Reset event store": "重設活動儲存", + "Verify other login": "驗證其他登入", + "Avatar": "大頭貼", + "Verification requested": "已請求驗證", + "What are some things you want to discuss in %(spaceName)s?": "您想在 %(spaceName)s 中討論什麼?", + "You can add more later too, including already existing ones.": "您稍後可以新增更多內容,包含既有的。", + "Please choose a strong password": "請選擇強密碼", + "Use another login": "使用其他登入", + "Without verifying, you won’t have access to all your messages and may appear as untrusted to others.": "未經驗證,您將無法存取您的所有訊息,且可能不被其他人信任。", + "You are the only person here. If you leave, no one will be able to join in the future, including you.": "您是這裡唯一的人。如果您離開,包含您在內的任何人都無法加入。", + "If you reset everything, you will restart with no trusted sessions, no trusted users, and might not be able to see past messages.": "如果您重設所有東西,您將會在沒有受信任的工作階段、沒有受信任的使用者,且可能會看不到過去的訊息。", + "Only do this if you have no other device to complete verification with.": "當您沒有其他裝置可以完成驗證時,才執行此動作。", + "Reset everything": "重設所有東西", + "Forgotten or lost all recovery methods? Reset all": "忘記或遺失了所有復原方法?重設全部", + "If you do, please note that none of your messages will be deleted, but the search experience might be degraded for a few moments whilst the index is recreated": "如果這樣做,請注意,您的訊息不會被刪除,但在重新建立索引時,搜尋體驗可能會降低片刻", + "View message": "檢視訊息", + "Zoom in": "放大", + "Zoom out": "縮小", + "%(seconds)ss left": "剩%(seconds)s秒", + "Change server ACLs": "變更伺服器 ACL", + "Show options to enable 'Do not disturb' mode": "顯示啟用「勿打擾」模式的選項", + "You can select all or individual messages to retry or delete": "您可以選取全部或單獨的訊息來重試或刪除", + "Sending": "正在傳送", + "Retry all": "重試全部", + "Delete all": "刪除全部", + "Some of your messages have not been sent": "您的部份訊息未傳送", + "%(count)s members including %(commaSeparatedMembers)s|one": "%(commaSeparatedMembers)s", + "%(count)s members including %(commaSeparatedMembers)s|other": "%(count)s 個成員包含 %(commaSeparatedMembers)s", + "Including %(commaSeparatedMembers)s": "包含 %(commaSeparatedMembers)s", + "View all %(count)s members|one": "檢視 1 個成員", + "View all %(count)s members|other": "檢視全部 %(count)s 個成員", + "Failed to send": "傳送失敗", + "Enter your Security Phrase a second time to confirm it.": "再次輸入您的安全密語以進行確認。", + "Pick rooms or conversations to add. This is just a space for you, no one will be informed. You can add more later.": "挑選要新增的聊天室或對話。這是專屬於您的空間,不會有人被通知。您稍後可以再新增更多。", + "What do you want to organise?": "您想要整理什麼?", + "Filter all spaces": "過濾所有空間", + "Delete recording": "刪除錄製", + "Stop the recording": "停止錄製", + "%(count)s results in all spaces|one": "所有空間中有 %(count)s 個結果", + "%(count)s results in all spaces|other": "所有空間中有 %(count)s 個結果", + "You have no ignored users.": "您沒有忽略的使用者。", + "Play": "播放", + "Pause": "暫停", + "This is an experimental feature. For now, new users receiving an invite will have to open the invite on to actually join.": "這是實驗性功能。目前,收到邀請的新使用者必須在 上開啟邀請才能真的加入。", + "To join %(spaceName)s, turn on the Spaces beta": "要加入 %(spaceName)s,請開啟空間測試版", + "To view %(spaceName)s, turn on the Spaces beta": "要檢視 %(spaceName)s,開啟空間測試版", + "Select a room below first": "首先選取一個聊天室", + "Communities are changing to Spaces": "社群正在變更為空間", + "Join the beta": "加入測試版", + "Leave the beta": "離開測試版", + "Beta": "測試", + "Tap for more info": "點擊以取得更多資訊", + "Spaces is a beta feature": "空間為測試功能", + "Want to add a new room instead?": "想要新增新聊天室嗎?", + "Adding rooms... (%(progress)s out of %(count)s)|one": "正在新增聊天室……", + "Adding rooms... (%(progress)s out of %(count)s)|other": "正在新增聊天室……(%(count)s 中的第 %(progress)s 個)", + "Not all selected were added": "並非所有選定的都被新增了", + "You can add existing spaces to a space.": "您可以新增既有的空間至空間中。", + "Feeling experimental?": "想要來點實驗嗎?", + "You are not allowed to view this server's rooms list": "您不被允許檢視此伺服器的聊天室清單", + "Error processing voice message": "處理語音訊息時發生錯誤", + "We didn't find a microphone on your device. Please check your settings and try again.": "我們在您的裝置上找不到麥克風。請檢查您的設定並再試一次。", + "No microphone found": "找不到麥克風", + "We were unable to access your microphone. Please check your browser settings and try again.": "我們無法存取您的麥克風。請檢查您的瀏覽器設定並再試一次。", + "Unable to access your microphone": "無法存取您的麥克風", + "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.": "想要來點實驗嗎?實驗室是儘早取得成果,測試新功能並在實際發佈前協助塑造它們的最佳方式。取得更多資訊。", + "Your access token gives full access to your account. Do not share it with anyone.": "您的存取權杖可給您帳號完整的存取權限。不要將其與任何人分享。", + "Access Token": "存取權杖", + "Spaces are a new way to group rooms and people. To join an existing space you'll need an invite.": "空間是將聊天室與人們分組的一種新方式。要加入既有的空間,您需要邀請。", + "Please enter a name for the space": "請輸入空間名稱", + "Connecting": "正在連線", + "Allow Peer-to-Peer for 1:1 calls (if you enable this, the other party might be able to see your IP address)": "允許在 1:1 通話中使用點對點通訊(若您啟用此功能,對方就能看到您的 IP 位置)", + "Beta available for web, desktop and Android. Some features may be unavailable on your homeserver.": "供網頁、桌面與 Android 使用的測試版。部份功能可能在您的家伺服器上不可用。", + "You can leave the beta any time from settings or tapping on a beta badge, like the one above.": "您可以隨時從設定中退出測試版,或是點擊測試版徽章,例如上面那個。", + "%(brand)s will reload with Spaces enabled. Communities and custom tags will be hidden.": "%(brand)s 將在啟用空間的情況下重新載入。社群與自訂標籤將會隱藏。", + "Beta available for web, desktop and Android. Thank you for trying the beta.": "測試版可用於網路、桌面與 Android。感謝您試用測試版。", + "%(brand)s will reload with Spaces disabled. Communities and custom tags will be visible again.": "%(brand)s 將在停用空間的情況下重新載入。社群與自訂標籤將再次可見。", + "Spaces are a new way to group rooms and people.": "空間是將聊天室與人們分組的一種新方式。", + "Message search initialisation failed": "訊息搜尋初始化失敗", + "Spaces are a beta feature.": "空間為測試版功能。", + "Search names and descriptions": "搜尋名稱與描述", + "You may contact me if you have any follow up questions": "如果您還有任何後續問題,可以聯絡我", + "To leave the beta, visit your settings.": "要離開測試版,請造訪您的設定。", + "Your platform and username will be noted to help us use your feedback as much as we can.": "我們將會記錄您的平台與使用者名稱,以協助我們盡可能使用您的回饋。", + "%(featureName)s beta feedback": "%(featureName)s 測試版回饋", + "Thank you for your feedback, we really appreciate it.": "感謝您的回饋,我們衷心感謝。", + "Beta feedback": "測試版回饋", + "Add reaction": "新增反應", + "Send and receive voice messages": "傳送與接收語音訊息", + "Your feedback will help make spaces better. The more detail you can go into, the better.": "您的回饋意見將會讓空間變得更好。您可以輸入愈多細節愈好。", + "If you leave, %(brand)s will reload with Spaces disabled. Communities and custom tags will be visible again.": "若您離開,%(brand)s 將在停用空間的情況下重新載入。社群與自訂標籤將再次可見。" } diff --git a/src/identifiers.ts b/src/identifiers.ts new file mode 100644 index 0000000000..cc8b2fee4d --- /dev/null +++ b/src/identifiers.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2021 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export const ELEMENT_CLIENT_ID = "io.element.web"; diff --git a/src/indexing/BaseEventIndexManager.ts b/src/indexing/BaseEventIndexManager.ts index 2474406618..6349f31524 100644 --- a/src/indexing/BaseEventIndexManager.ts +++ b/src/indexing/BaseEventIndexManager.ts @@ -1,5 +1,5 @@ /* -Copyright 2019, 2020 The Matrix.org Foundation C.I.C. +Copyright 2019-2021 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. @@ -133,6 +133,10 @@ export default abstract class BaseEventIndexManager { throw new Error("Unimplemented"); } + async isEventIndexEmpty(): Promise { + throw new Error("Unimplemented"); + } + /** * Check if our event index is empty. */ diff --git a/src/indexing/EventIndex.js b/src/indexing/EventIndex.js index 2dcdb9e3a3..ed4418140b 100644 --- a/src/indexing/EventIndex.js +++ b/src/indexing/EventIndex.js @@ -38,7 +38,6 @@ export default class EventIndex extends EventEmitter { this._eventsPerCrawl = 100; this._crawler = null; this._currentCheckpoint = null; - this.liveEventsForIndex = new Set(); } async init() { @@ -127,8 +126,13 @@ export default class EventIndex extends EventEmitter { this.crawlerCheckpoints.push(forwardCheckpoint); } } catch (e) { - console.log("EventIndex: Error adding initial checkpoints for room", - room.roomId, backCheckpoint, forwardCheckpoint, e); + console.log( + "EventIndex: Error adding initial checkpoints for room", + room.roomId, + backCheckpoint, + forwardCheckpoint, + e, + ); } })); } @@ -173,8 +177,10 @@ export default class EventIndex extends EventEmitter { * listener. */ onRoomTimeline = async (ev, room, toStartOfTimeline, removed, data) => { + const client = MatrixClientPeg.get(); + // We only index encrypted rooms locally. - if (!MatrixClientPeg.get().isRoomEncrypted(room.roomId)) return; + if (!client.isRoomEncrypted(room.roomId)) return; // If it isn't a live event or if it's redacted there's nothing to // do. @@ -183,16 +189,9 @@ export default class EventIndex extends EventEmitter { return; } - // If the event is not yet decrypted mark it for the - // Event.decrypted callback. - if (ev.isBeingDecrypted()) { - const eventId = ev.getId(); - this.liveEventsForIndex.add(eventId); - } else { - // If the event is decrypted or is unencrypted add it to the - // index now. - await this.addLiveEventToIndex(ev); - } + await client.decryptEventIfNeeded(ev); + + await this.addLiveEventToIndex(ev); } onRoomStateEvent = async (ev, state) => { @@ -211,10 +210,7 @@ export default class EventIndex extends EventEmitter { * listener, if so queues it up to be added to the index. */ onEventDecrypted = async (ev, err) => { - const eventId = ev.getId(); - // If the event isn't in our live event set, ignore it. - if (!this.liveEventsForIndex.delete(eventId)) return; if (err) return; await this.addLiveEventToIndex(ev); } @@ -379,8 +375,12 @@ export default class EventIndex extends EventEmitter { try { await indexManager.addCrawlerCheckpoint(checkpoint); } catch (e) { - console.log("EventIndex: Error adding new checkpoint for room", - room.roomId, checkpoint, e); + console.log( + "EventIndex: Error adding new checkpoint for room", + room.roomId, + checkpoint, + e, + ); } this.crawlerCheckpoints.push(checkpoint); @@ -459,7 +459,7 @@ export default class EventIndex extends EventEmitter { } catch (e) { if (e.httpStatus === 403) { console.log("EventIndex: Removing checkpoint as we don't have ", - "permissions to fetch messages from this room.", checkpoint); + "permissions to fetch messages from this room.", checkpoint); try { await indexManager.removeCrawlerCheckpoint(checkpoint); } catch (e) { @@ -514,18 +514,14 @@ export default class EventIndex extends EventEmitter { } }); - const decryptionPromises = []; - - matrixEvents.forEach(ev => { - if (ev.isBeingDecrypted() || ev.isDecryptionFailure()) { - // TODO the decryption promise is a private property, this - // should either be made public or we should convert the - // event that gets fired when decryption is done into a - // promise using the once event emitter method: - // https://nodejs.org/api/events.html#events_events_once_emitter_name - decryptionPromises.push(ev._decryptionPromise); - } - }); + const decryptionPromises = matrixEvents + .filter(event => event.isEncrypted()) + .map(event => { + return client.decryptEventIfNeeded(event, { + isRetry: true, + emit: false, + }); + }); // Let us wait for all the events to get decrypted. await Promise.all(decryptionPromises); @@ -589,7 +585,7 @@ export default class EventIndex extends EventEmitter { // to do here anymore. if (!newCheckpoint) { console.log("EventIndex: The server didn't return a valid ", - "new checkpoint, not continuing the crawl.", checkpoint); + "new checkpoint, not continuing the crawl.", checkpoint); continue; } @@ -599,12 +595,12 @@ export default class EventIndex extends EventEmitter { // the new checkpoint to be used by the crawler. if (eventsAlreadyAdded === true && newCheckpoint.fullCrawl !== true) { console.log("EventIndex: Checkpoint had already all events", - "added, stopping the crawl", checkpoint); + "added, stopping the crawl", checkpoint); await indexManager.removeCrawlerCheckpoint(newCheckpoint); } else { if (eventsAlreadyAdded === true) { console.log("EventIndex: Checkpoint had already all events", - "added, but continuing due to a full crawl", checkpoint); + "added, but continuing due to a full crawl", checkpoint); } this.crawlerCheckpoints.push(newCheckpoint); } @@ -776,8 +772,14 @@ export default class EventIndex extends EventEmitter { * @returns {Promise} Resolves to true if events were added to the * timeline, false otherwise. */ - async populateFileTimeline(timelineSet, timeline, room, limit = 10, - fromEvent = null, direction = EventTimeline.BACKWARDS) { + async populateFileTimeline( + timelineSet, + timeline, + room, + limit = 10, + fromEvent = null, + direction = EventTimeline.BACKWARDS, + ) { const matrixEvents = await this.loadFileEvents(room, limit, fromEvent, direction); // If this is a normal fill request, not a pagination request, we need @@ -807,7 +809,7 @@ export default class EventIndex extends EventEmitter { } console.log("EventIndex: Populating file panel with", matrixEvents.length, - "events and setting the pagination token to", paginationToken); + "events and setting the pagination token to", paginationToken); timeline.setPaginationToken(paginationToken, EventTimeline.BACKWARDS); return ret; diff --git a/src/indexing/EventIndexPeg.js b/src/indexing/EventIndexPeg.ts similarity index 94% rename from src/indexing/EventIndexPeg.js rename to src/indexing/EventIndexPeg.ts index 7004efc554..4356d882d5 100644 --- a/src/indexing/EventIndexPeg.js +++ b/src/indexing/EventIndexPeg.ts @@ -1,5 +1,5 @@ /* -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2019-2021 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. @@ -27,12 +27,11 @@ import {SettingLevel} from "../settings/SettingLevel"; const INDEX_VERSION = 1; -class EventIndexPeg { - constructor() { - this.index = null; - this._supportIsInstalled = false; - this.error = null; - } +export class EventIndexPeg { + public index: EventIndex = null; + public error: Error = null; + + private _supportIsInstalled = false; /** * Initialize the EventIndexPeg and if event indexing is enabled initialize @@ -181,7 +180,7 @@ class EventIndexPeg { } } -if (!global.mxEventIndexPeg) { - global.mxEventIndexPeg = new EventIndexPeg(); +if (!window.mxEventIndexPeg) { + window.mxEventIndexPeg = new EventIndexPeg(); } -export default global.mxEventIndexPeg; +export default window.mxEventIndexPeg; diff --git a/src/languageHandler.tsx b/src/languageHandler.tsx index b61f57d4b3..26c89afec6 100644 --- a/src/languageHandler.tsx +++ b/src/languageHandler.tsx @@ -56,6 +56,15 @@ export function newTranslatableError(message: string) { return error; } +export function getUserLanguage(): string { + const language = SettingsStore.getValue("language", null, /*excludeDefault:*/true); + if (language) { + return language; + } else { + return normalizeLanguageKey(getLanguageFromBrowser()); + } +} + // Function which only purpose is to mark that a string is translatable // Does not actually do anything. It's helpful for automatic extraction of translatable strings export function _td(s: string): string { @@ -335,7 +344,10 @@ export function setLanguage(preferredLangs: string | string[]) { counterpart.registerTranslations(langToUse, langData); counterpart.setLocale(langToUse); SettingsStore.setValue("language", null, SettingLevel.DEVICE, langToUse); - console.log("set language to " + langToUse); + // Adds a lot of noise to test runs, so disable logging there. + if (process.env.NODE_ENV !== "test") { + console.log("set language to " + langToUse); + } // Set 'en' as fallback language: if (langToUse !== "en") { @@ -455,10 +467,14 @@ function getLangsJson(): Promise { request( { method: "GET", url }, (err, response, body) => { - if (err || response.status < 200 || response.status >= 300) { + if (err) { reject(err); return; } + if (response.status < 200 || response.status >= 300) { + reject(new Error(`Failed to load ${url}, got ${response.status}`)); + return; + } resolve(JSON.parse(body)); }, ); @@ -498,10 +514,14 @@ function getLanguage(langPath: string): Promise { request( { method: "GET", url: langPath }, (err, response, body) => { - if (err || response.status < 200 || response.status >= 300) { + if (err) { reject(err); return; } + if (response.status < 200 || response.status >= 300) { + reject(new Error(`Failed to load ${langPath}, got ${response.status}`)); + return; + } resolve(weblateToCounterpart(JSON.parse(body))); }, ); diff --git a/src/linkify-matrix.js b/src/linkify-matrix.js index 1a40fde26f..feda257d8b 100644 --- a/src/linkify-matrix.js +++ b/src/linkify-matrix.js @@ -254,11 +254,15 @@ matrixLinkify.options = { target: function(href, type) { if (type === 'url') { - const transformed = tryTransformPermalinkToLocalHref(href); - if (transformed !== href || href.match(matrixLinkify.ELEMENT_URL_PATTERN)) { - return null; - } else { - return '_blank'; + try { + const transformed = tryTransformPermalinkToLocalHref(href); + if (transformed !== href || decodeURIComponent(href).match(matrixLinkify.ELEMENT_URL_PATTERN)) { + return null; + } else { + return '_blank'; + } + } catch (e) { + // malformed URI } } return null; diff --git a/src/mjolnir/BanList.js b/src/mjolnir/BanList.ts similarity index 100% rename from src/mjolnir/BanList.js rename to src/mjolnir/BanList.ts diff --git a/src/mjolnir/ListRule.js b/src/mjolnir/ListRule.ts similarity index 100% rename from src/mjolnir/ListRule.js rename to src/mjolnir/ListRule.ts diff --git a/src/mjolnir/Mjolnir.js b/src/mjolnir/Mjolnir.ts similarity index 100% rename from src/mjolnir/Mjolnir.js rename to src/mjolnir/Mjolnir.ts diff --git a/src/performance/entry-names.ts b/src/performance/entry-names.ts new file mode 100644 index 0000000000..effd9506f6 --- /dev/null +++ b/src/performance/entry-names.ts @@ -0,0 +1,57 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +export enum PerformanceEntryNames { + + /** + * Application wide + */ + + APP_STARTUP = "mx_AppStartup", + PAGE_CHANGE = "mx_PageChange", + + /** + * Events + */ + + RESEND_EVENT = "mx_ResendEvent", + SEND_E2EE_EVENT = "mx_SendE2EEEvent", + SEND_ATTACHMENT = "mx_SendAttachment", + + /** + * Rooms + */ + + SWITCH_ROOM = "mx_SwithRoom", + JUMP_TO_ROOM = "mx_JumpToRoom", + JOIN_ROOM = "mx_JoinRoom", + CREATE_DM = "mx_CreateDM", + PEEK_ROOM = "mx_PeekRoom", + + /** + * User + */ + + VERIFY_E2EE_USER = "mx_VerifyE2EEUser", + LOGIN = "mx_Login", + REGISTER = "mx_Register", + + /** + * VoIP + */ + + SETUP_VOIP_CALL = "mx_SetupVoIPCall", +} diff --git a/src/performance/index.ts b/src/performance/index.ts new file mode 100644 index 0000000000..bfb5b4a9c7 --- /dev/null +++ b/src/performance/index.ts @@ -0,0 +1,178 @@ +/* +Copyright 2021 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 { PerformanceEntryNames } from "./entry-names"; + +interface GetEntriesOptions { + name?: string, + type?: string, +} + +type PerformanceCallbackFunction = (entry: PerformanceEntry[]) => void; + +interface PerformanceDataListener { + entryNames?: string[], + callback: PerformanceCallbackFunction +} + +export default class PerformanceMonitor { + static _instance: PerformanceMonitor; + + private START_PREFIX = "start:" + private STOP_PREFIX = "stop:" + + private listeners: PerformanceDataListener[] = [] + private entries: PerformanceEntry[] = [] + + public static get instance(): PerformanceMonitor { + if (!PerformanceMonitor._instance) { + PerformanceMonitor._instance = new PerformanceMonitor(); + } + return PerformanceMonitor._instance; + } + + /** + * Starts a performance recording + * @param name Name of the recording + * @param id Specify an identifier appended to the measurement name + * @returns {void} + */ + start(name: string, id?: string): void { + if (!this.supportsPerformanceApi()) { + return; + } + const key = this.buildKey(name, id); + + if (performance.getEntriesByName(this.START_PREFIX + key).length > 0) { + console.warn(`Recording already started for: ${name}`); + return; + } + + performance.mark(this.START_PREFIX + key); + } + + /** + * Stops a performance recording and stores delta duration + * with the start marker + * @param name Name of the recording + * @param id Specify an identifier appended to the measurement name + * @returns {void} + */ + stop(name: string, id?: string): PerformanceEntry { + if (!this.supportsPerformanceApi()) { + return; + } + const key = this.buildKey(name, id); + if (performance.getEntriesByName(this.START_PREFIX + key).length === 0) { + console.warn(`No recording started for: ${name}`); + return; + } + + performance.mark(this.STOP_PREFIX + key); + performance.measure( + key, + this.START_PREFIX + key, + this.STOP_PREFIX + key, + ); + + this.clear(name, id); + + const measurement = performance.getEntriesByName(key).pop(); + + // Keeping a reference to all PerformanceEntry created + // by this abstraction for historical events collection + // when adding a data callback + this.entries.push(measurement); + + this.listeners.forEach(listener => { + if (this.shouldEmit(listener, measurement)) { + listener.callback([measurement]) + } + }); + + return measurement; + } + + clear(name: string, id?: string): void { + if (!this.supportsPerformanceApi()) { + return; + } + const key = this.buildKey(name, id); + performance.clearMarks(this.START_PREFIX + key); + performance.clearMarks(this.STOP_PREFIX + key); + } + + getEntries({ name, type }: GetEntriesOptions = {}): PerformanceEntry[] { + return this.entries.filter(entry => { + const satisfiesName = !name || entry.name === name; + const satisfiedType = !type || entry.entryType === type; + return satisfiesName && satisfiedType; + }); + } + + addPerformanceDataCallback(listener: PerformanceDataListener, buffer = false) { + this.listeners.push(listener); + if (buffer) { + const toEmit = this.entries.filter(entry => this.shouldEmit(listener, entry)); + if (toEmit.length > 0) { + listener.callback(toEmit); + } + } + } + + removePerformanceDataCallback(callback?: PerformanceCallbackFunction) { + if (!callback) { + this.listeners = []; + } else { + this.listeners.splice( + this.listeners.findIndex(listener => listener.callback === callback), + 1, + ); + } + } + + /** + * Tor browser does not support the Performance API + * @returns {boolean} true if the Performance API is supported + */ + private supportsPerformanceApi(): boolean { + return performance !== undefined && performance.mark !== undefined; + } + + private shouldEmit(listener: PerformanceDataListener, entry: PerformanceEntry): boolean { + return !listener.entryNames || listener.entryNames.includes(entry.name); + } + + /** + * Internal utility to ensure consistent name for the recording + * @param name Name of the recording + * @param id Specify an identifier appended to the measurement name + * @returns {string} a compound of the name and identifier if present + */ + private buildKey(name: string, id?: string): string { + return `${name}${id ? `:${id}` : ''}`; + } +} + + +// Convenience exports +export { + PerformanceEntryNames, +} + +// Exposing those to the window object to bridge them from tests +window.mxPerformanceMonitor = PerformanceMonitor.instance; +window.mxPerformanceEntryNames = PerformanceEntryNames; diff --git a/src/rageshake/rageshake.js b/src/rageshake/rageshake.js index b886f369df..9512f62e42 100644 --- a/src/rageshake/rageshake.js +++ b/src/rageshake/rageshake.js @@ -73,7 +73,9 @@ class ConsoleLogger { // Convert objects and errors to helpful things args = args.map((arg) => { - if (arg instanceof Error) { + if (arg instanceof DOMException) { + return arg.message + ` (${arg.name} | ${arg.code}) ` + (arg.stack ? `\n${arg.stack}` : ''); + } else if (arg instanceof Error) { return arg.message + (arg.stack ? `\n${arg.stack}` : ''); } else if (typeof (arg) === 'object') { try { diff --git a/src/rageshake/submit-rageshake.ts b/src/rageshake/submit-rageshake.ts index 29856b1a86..f46dd88fba 100644 --- a/src/rageshake/submit-rageshake.ts +++ b/src/rageshake/submit-rageshake.ts @@ -28,6 +28,7 @@ import * as rageshake from './rageshake'; // polyfill textencoder if necessary import * as TextEncodingUtf8 from 'text-encoding-utf-8'; import SettingsStore from "../settings/SettingsStore"; +import SdkConfig from "../SdkConfig"; let TextEncoder = window.TextEncoder; if (!TextEncoder) { TextEncoder = TextEncodingUtf8.TextEncoder; @@ -268,6 +269,25 @@ function uint8ToString(buf: Buffer) { return out; } +export async function submitFeedback(endpoint: string, label: string, comment: string, canContact = false) { + let version = "UNKNOWN"; + try { + version = await PlatformPeg.get().getAppVersion(); + } catch (err) {} // PlatformPeg already logs this. + + const body = new FormData(); + body.append("label", label); + body.append("text", comment); + body.append("can_contact", canContact ? "yes" : "no"); + + body.append("app", "element-web"); + body.append("version", version); + body.append("platform", PlatformPeg.get().getHumanReadableName()); + body.append("user_id", MatrixClientPeg.get()?.getUserId()); + + await _submitReport(SdkConfig.get().bug_report_endpoint_url, body, () => {}); +} + function _submitReport(endpoint: string, body: FormData, progressCallback: (string) => void) { return new Promise((resolve, reject) => { const req = new XMLHttpRequest(); diff --git a/src/settings/Settings.ts b/src/settings/Settings.tsx similarity index 91% rename from src/settings/Settings.ts rename to src/settings/Settings.tsx index 55fddc4a35..6ff14c16b5 100644 --- a/src/settings/Settings.ts +++ b/src/settings/Settings.tsx @@ -16,8 +16,9 @@ limitations under the License. */ import { MatrixClient } from 'matrix-js-sdk/src/client'; +import React, { ReactNode } from "react"; -import { _td } from '../languageHandler'; +import { _t, _td } from '../languageHandler'; import { NotificationBodyEnabledController, NotificationsEnabledController, @@ -39,6 +40,7 @@ import { OrderedMultiController } from "./controllers/OrderedMultiController"; import { Layout } from "./Layout"; import ReducedMotionController from './controllers/ReducedMotionController'; import IncompatibleController from "./controllers/IncompatibleController"; +import SdkConfig from "../SdkConfig"; // These are just a bunch of helper arrays to avoid copy/pasting a bunch of times const LEVELS_ROOM_SETTINGS = [ @@ -117,6 +119,15 @@ export interface ISetting { // historical settings which we don't want existing user's values be wiped. Do // not use this for new settings. invertedSettingName?: string; + + betaInfo?: { + title: string; // _td + caption: string; // _td + disclaimer?: (enabled: boolean) => ReactNode; + image: string; // require(...) + feedbackSubheading?: string; + feedbackLabel?: string; + }; } export const SETTINGS: {[setting: string]: ISetting} = { @@ -127,10 +138,46 @@ export const SETTINGS: {[setting: string]: ISetting} = { supportedLevels: LEVELS_FEATURE, default: false, controller: new ReloadOnChangeController(), + betaInfo: { + title: _td("Spaces"), + caption: _td("Spaces are a new way to group rooms and people."), + disclaimer: (enabled) => { + if (enabled) { + return <> +

    { _t("If you leave, %(brand)s will reload with Spaces disabled. " + + "Communities and custom tags will be visible again.", { + brand: SdkConfig.get().brand, + }) }

    +

    { _t("Beta available for web, desktop and Android. Thank you for trying the beta.") }

    + ; + } + + return <> +

    { _t("%(brand)s will reload with Spaces enabled. " + + "Communities and custom tags will be hidden.", { + brand: SdkConfig.get().brand, + }) }

    + { _t("You can leave the beta any time from settings or tapping on a beta badge, " + + "like the one above.") } +

    { _t("Beta available for web, desktop and Android. " + + "Some features may be unavailable on your homeserver.") }

    + ; + }, + image: require("../../res/img/betas/spaces.png"), + feedbackSubheading: _td("Your feedback will help make spaces better. " + + "The more detail you can go into, the better."), + feedbackLabel: "spaces-feedback", + }, + }, + "feature_dnd": { + isFeature: true, + displayName: _td("Show options to enable 'Do not disturb' mode"), + supportedLevels: LEVELS_FEATURE, + default: false, }, "feature_voice_messages": { isFeature: true, - displayName: _td("Send and receive voice messages (in development)"), + displayName: _td("Send and receive voice messages"), supportedLevels: LEVELS_FEATURE, default: false, }, @@ -150,12 +197,6 @@ export const SETTINGS: {[setting: string]: ISetting} = { default: false, controller: new IncompatibleController("feature_spaces"), }, - "feature_new_spinner": { - isFeature: true, - displayName: _td("New spinner design"), - supportedLevels: LEVELS_FEATURE, - default: false, - }, "feature_pinning": { isFeature: true, displayName: _td("Message Pinning"), @@ -220,18 +261,16 @@ export const SETTINGS: {[setting: string]: ISetting} = { supportedLevels: LEVELS_FEATURE, default: false, }, - "feature_room_history_key_sharing": { - isFeature: true, - displayName: _td("Share decryption keys for room history when inviting users"), - supportedLevels: LEVELS_FEATURE, - default: false, - }, "advancedRoomListLogging": { // TODO: Remove flag before launch: https://github.com/vector-im/element-web/issues/14231 displayName: _td("Enable advanced debugging for the room list"), supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS, default: false, }, + "doNotDisturb": { + supportedLevels: [SettingLevel.DEVICE], + default: false, + }, "mjolnirRooms": { supportedLevels: [SettingLevel.ACCOUNT], default: [], @@ -434,7 +473,10 @@ export const SETTINGS: {[setting: string]: ISetting} = { }, "webRtcAllowPeerToPeer": { supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS_WITH_CONFIG, - displayName: _td('Allow Peer-to-Peer for 1:1 calls'), + displayName: _td( + "Allow Peer-to-Peer for 1:1 calls " + + "(if you enable this, the other party might be able to see your IP address)", + ), default: true, invertedSettingName: 'webRtcForceTURN', }, @@ -682,7 +724,7 @@ export const SETTINGS: {[setting: string]: ISetting} = { default: Layout.Group, }, "showChatEffects": { - supportedLevels: LEVELS_ACCOUNT_SETTINGS, + supportedLevels: LEVELS_ROOM_SETTINGS_WITH_ROOM, displayName: _td("Show chat effects (animations when receiving e.g. confetti)"), default: true, controller: new ReducedMotionController(), diff --git a/src/settings/SettingsStore.ts b/src/settings/SettingsStore.ts index c2675bd8f8..c32bbe731d 100644 --- a/src/settings/SettingsStore.ts +++ b/src/settings/SettingsStore.ts @@ -257,6 +257,15 @@ export default class SettingsStore { return SETTINGS[settingName].isFeature; } + public static getBetaInfo(settingName: string) { + // consider a beta disabled if the config is explicitly set to false, in which case treat as normal Labs flag + if (SettingsStore.isFeature(settingName) + && SettingsStore.getValueAt(SettingLevel.CONFIG, settingName, null, true, true) !== false + ) { + return SETTINGS[settingName]?.betaInfo; + } + } + /** * Determines if a setting is enabled. * If a setting is disabled then it should be hidden from the user. @@ -445,8 +454,8 @@ export default class SettingsStore { throw new Error("Setting '" + settingName + "' does not appear to be a setting."); } - // When features are specified in the config.json, we force them as enabled or disabled. - if (SettingsStore.isFeature(settingName)) { + // When non-beta features are specified in the config.json, we force them as enabled or disabled. + if (SettingsStore.isFeature(settingName) && !SETTINGS[settingName]?.betaInfo) { const configVal = SettingsStore.getValueAt(SettingLevel.CONFIG, settingName, roomId, true, true); if (configVal === true || configVal === false) return false; } diff --git a/src/stores/BreadcrumbsStore.ts b/src/stores/BreadcrumbsStore.ts index 393f4f27a1..1a78a1b485 100644 --- a/src/stores/BreadcrumbsStore.ts +++ b/src/stores/BreadcrumbsStore.ts @@ -122,7 +122,7 @@ export class BreadcrumbsStore extends AsyncStoreWithClient { } private async appendRoom(room: Room) { - if (room.isSpaceRoom() && SettingsStore.getValue("feature_spaces")) return; // hide space rooms + if (SettingsStore.getValue("feature_spaces") && room.isSpaceRoom()) return; // hide space rooms let updated = false; const rooms = (this.state.rooms || []).slice(); // cheap clone diff --git a/src/stores/CustomRoomTagStore.js b/src/stores/CustomRoomTagStore.js index edfc0003cf..55c9699f7a 100644 --- a/src/stores/CustomRoomTagStore.js +++ b/src/stores/CustomRoomTagStore.js @@ -124,15 +124,15 @@ class CustomRoomTagStore extends EventEmitter { const tags = Object.assign({}, oldTags, tag); this._setState({tags}); } + break; } - break; case 'on_client_not_viable': case 'on_logged_out': { // we assume to always have a tags object in the state this._state = {tags: {}}; RoomListStore.instance.off(LISTS_UPDATE_EVENT, this._onListsUpdated); + break; } - break; } } diff --git a/src/stores/GroupFilterOrderStore.js b/src/stores/GroupFilterOrderStore.js index 492322146e..b18abaa001 100644 --- a/src/stores/GroupFilterOrderStore.js +++ b/src/stores/GroupFilterOrderStore.js @@ -168,7 +168,7 @@ class GroupFilterOrderStore extends Store { Analytics.trackEvent('FilterStore', 'select_tag'); } - break; + break; case 'deselect_tags': if (payload.tag) { // if a tag is passed, only deselect that tag @@ -181,7 +181,7 @@ class GroupFilterOrderStore extends Store { }); } Analytics.trackEvent('FilterStore', 'deselect_tags'); - break; + break; case 'on_client_not_viable': case 'on_logged_out': { // Reset state without pushing an update to the view, which generally assumes that @@ -207,8 +207,8 @@ class GroupFilterOrderStore extends Store { groupIds.forEach(groupId => { const rooms = GroupStore.getGroupRooms(groupId) - .map(r => client.getRoom(r.roomId)) // to Room objects - .filter(r => r !== null && r !== undefined); // filter out rooms we haven't joined from the group + .map(r => client.getRoom(r.roomId)) // to Room objects + .filter(r => r !== null && r !== undefined); // filter out rooms we haven't joined from the group const badge = rooms && RoomNotifs.aggregateNotificationCount(rooms); changedBadges[groupId] = (badge && badge.count !== 0) ? badge : undefined; }); diff --git a/src/stores/RoomViewStore.tsx b/src/stores/RoomViewStore.tsx index 601c77cdf3..fe2e0a66b2 100644 --- a/src/stores/RoomViewStore.tsx +++ b/src/stores/RoomViewStore.tsx @@ -60,6 +60,10 @@ const INITIAL_STATE = { replyingToEvent: null, shouldPeek: false, + + viaServers: [], + + wasContextSwitch: false, }; /** @@ -113,6 +117,8 @@ class RoomViewStore extends Store { this.setState({ roomId: null, roomAlias: null, + viaServers: [], + wasContextSwitch: false, }); break; case 'view_room_error': @@ -191,6 +197,8 @@ class RoomViewStore extends Store { replyingToEvent: null, // pull the user out of Room Settings isEditingSettings: false, + viaServers: payload.via_servers, + wasContextSwitch: payload.context_switch, }; // Allow being given an event to be replied to when switching rooms but sanity check its for this room @@ -226,6 +234,8 @@ class RoomViewStore extends Store { roomAlias: payload.room_alias, roomLoading: true, roomLoadError: null, + viaServers: payload.via_servers, + wasContextSwitch: payload.context_switch, }); try { const result = await MatrixClientPeg.get().getRoomIdForAlias(payload.room_alias); @@ -251,6 +261,8 @@ class RoomViewStore extends Store { room_alias: payload.room_alias, auto_join: payload.auto_join, oob_data: payload.oob_data, + viaServers: payload.via_servers, + wasContextSwitch: payload.context_switch, }); } } @@ -272,9 +284,10 @@ class RoomViewStore extends Store { const cli = MatrixClientPeg.get(); const address = this.state.roomAlias || this.state.roomId; + const viaServers = this.state.viaServers || []; try { await retry(() => cli.joinRoom(address, { - viaServers: payload.via_servers, + viaServers, ...payload.opts, }), NUM_JOIN_RETRY, (err) => { // if we received a Gateway timeout then retry @@ -419,6 +432,10 @@ class RoomViewStore extends Store { public shouldPeek() { return this.state.shouldPeek; } + + public getWasContextSwitch() { + return this.state.wasContextSwitch; + } } let singletonRoomViewStore = null; diff --git a/src/stores/SpaceStore.tsx b/src/stores/SpaceStore.tsx index dec8832792..40997d30a8 100644 --- a/src/stores/SpaceStore.tsx +++ b/src/stores/SpaceStore.tsx @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {sortBy, throttle} from "lodash"; +import {ListIteratee, Many, sortBy, throttle} from "lodash"; import {EventType, RoomType} from "matrix-js-sdk/src/@types/event"; import {Room} from "matrix-js-sdk/src/models/room"; import {MatrixEvent} from "matrix-js-sdk/src/models/event"; @@ -31,26 +31,28 @@ import {RoomNotificationStateStore} from "./notifications/RoomNotificationStateS import {DefaultTagID} from "./room-list/models"; import {EnhancedMap, mapDiff} from "../utils/maps"; import {setHasDiff} from "../utils/sets"; -import {objectDiff} from "../utils/objects"; -import {arrayHasDiff} from "../utils/arrays"; import {ISpaceSummaryEvent, ISpaceSummaryRoom} from "../components/structures/SpaceRoomDirectory"; import RoomViewStore from "./RoomViewStore"; -type SpaceKey = string | symbol; - interface IState {} const ACTIVE_SPACE_LS_KEY = "mx_active_space"; -export const HOME_SPACE = Symbol("home-space"); export const SUGGESTED_ROOMS = Symbol("suggested-rooms"); export const UPDATE_TOP_LEVEL_SPACES = Symbol("top-level-spaces"); +export const UPDATE_INVITED_SPACES = Symbol("invited-spaces"); export const UPDATE_SELECTED_SPACE = Symbol("selected-space"); -// Space Room ID/HOME_SPACE will be emitted when a Space's children change +// Space Room ID will be emitted when a Space's children change + +export interface ISuggestedRoom extends ISpaceSummaryRoom { + viaServers: string[]; +} const MAX_SUGGESTED_ROOMS = 20; +const getSpaceContextKey = (space?: Room) => `mx_space_context_${space?.roomId || "ALL_ROOMS"}`; + const partitionSpacesAndRooms = (arr: Room[]): [Room[], Room[]] => { // [spaces, rooms] return arr.reduce((result, room: Room) => { result[room.isSpaceRoom() ? 0 : 1].push(room); @@ -58,15 +60,18 @@ const partitionSpacesAndRooms = (arr: Room[]): [Room[], Room[]] => { // [spaces, }, [[], []]); }; -const getOrder = (ev: MatrixEvent): string | null => { - const content = ev.getContent(); - if (typeof content.order === "string" && Array.from(content.order).every((c: string) => { +// For sorting space children using a validated `order`, `m.room.create`'s `origin_server_ts`, `room_id` +export const getOrder = (order: string, creationTs: number, roomId: string): Array>> => { + let validatedOrder: string = null; + + if (typeof order === "string" && Array.from(order).every((c: string) => { const charCode = c.charCodeAt(0); - return charCode >= 0x20 && charCode <= 0x7F; + return charCode >= 0x20 && charCode <= 0x7E; })) { - return content.order; + validatedOrder = order; } - return null; + + return [validatedOrder, creationTs, roomId]; } const getRoomFn: FetchRoomFn = (room: Room) => { @@ -80,17 +85,20 @@ export class SpaceStoreClass extends AsyncStoreWithClient { // The spaces representing the roots of the various tree-like hierarchies private rootSpaces: Room[] = []; - // The list of rooms not present in any currently joined spaces - private orphanedRooms = new Set(); // Map from room ID to set of spaces which list it as a child private parentMap = new EnhancedMap>(); - // Map from space key to SpaceNotificationState instance representing that space - private notificationStateMap = new Map(); + // Map from spaceId to SpaceNotificationState instance representing that space + private notificationStateMap = new Map(); // Map from space key to Set of room IDs that should be shown as part of that space's filter - private spaceFilteredRooms = new Map>(); - // The space currently selected in the Space Panel - if null then `Home` is selected + private spaceFilteredRooms = new Map>(); + // The space currently selected in the Space Panel - if null then All Rooms is selected private _activeSpace?: Room = null; - private _suggestedRooms: ISpaceSummaryRoom[] = []; + private _suggestedRooms: ISuggestedRoom[] = []; + private _invitedSpaces = new Set(); + + public get invitedSpaces(): Room[] { + return Array.from(this._invitedSpaces); + } public get spacePanelSpaces(): Room[] { return this.rootSpaces; @@ -100,17 +108,52 @@ export class SpaceStoreClass extends AsyncStoreWithClient { return this._activeSpace || null; } - public get suggestedRooms(): ISpaceSummaryRoom[] { + public get suggestedRooms(): ISuggestedRoom[] { return this._suggestedRooms; } - public async setActiveSpace(space: Room | null) { - if (space === this.activeSpace) return; + /** + * Sets the active space, updates room list filters, + * optionally switches the user's room back to where they were when they last viewed that space. + * @param space which space to switch to. + * @param contextSwitch whether to switch the user's context, + * should not be done when the space switch is done implicitly due to another event like switching room. + */ + public async setActiveSpace(space: Room | null, contextSwitch = true) { + if (space === this.activeSpace || (space && !space?.isSpaceRoom())) return; this._activeSpace = space; this.emit(UPDATE_SELECTED_SPACE, this.activeSpace); this.emit(SUGGESTED_ROOMS, this._suggestedRooms = []); + if (contextSwitch) { + // view last selected room from space + const roomId = window.localStorage.getItem(getSpaceContextKey(this.activeSpace)); + + // if the space being selected is an invite then always view that invite + // else if the last viewed room in this space is joined then view that + // else view space home or home depending on what is being clicked on + if (space?.getMyMembership !== "invite" && + this.matrixClient?.getRoom(roomId)?.getMyMembership() === "join" + ) { + defaultDispatcher.dispatch({ + action: "view_room", + room_id: roomId, + context_switch: true, + }); + } else if (space) { + defaultDispatcher.dispatch({ + action: "view_room", + room_id: space.roomId, + context_switch: true, + }); + } else { + defaultDispatcher.dispatch({ + action: "view_home_page", + }); + } + } + // persist space selected if (space) { window.localStorage.setItem(ACTIVE_SPACE_LS_KEY, space.roomId); @@ -119,31 +162,41 @@ export class SpaceStoreClass extends AsyncStoreWithClient { } if (space) { - const data = await this.fetchSuggestedRooms(space); + const suggestedRooms = await this.fetchSuggestedRooms(space); if (this._activeSpace === space) { - this._suggestedRooms = data.rooms.filter(roomInfo => { - return roomInfo.room_type !== RoomType.Space - && this.matrixClient.getRoom(roomInfo.room_id)?.getMyMembership() !== "join"; - }); + this._suggestedRooms = suggestedRooms; this.emit(SUGGESTED_ROOMS, this._suggestedRooms); } } } - public fetchSuggestedRooms = async (space: Room, limit = MAX_SUGGESTED_ROOMS) => { + public fetchSuggestedRooms = async (space: Room, limit = MAX_SUGGESTED_ROOMS): Promise => { try { const data: { rooms: ISpaceSummaryRoom[]; events: ISpaceSummaryEvent[]; } = await this.matrixClient.getSpaceSummary(space.roomId, 0, true, false, limit); - return data; + + const viaMap = new EnhancedMap>(); + data.events.forEach(ev => { + if (ev.type === EventType.SpaceChild && ev.content.via?.length) { + ev.content.via.forEach(via => { + viaMap.getOrCreate(ev.state_key, new Set()).add(via); + }); + } + }); + + return data.rooms.filter(roomInfo => { + return roomInfo.room_type !== RoomType.Space + && this.matrixClient.getRoom(roomInfo.room_id)?.getMyMembership() !== "join"; + }).map(roomInfo => ({ + ...roomInfo, + viaServers: Array.from(viaMap.get(roomInfo.room_id) || []), + })); } catch (e) { console.error(e); } - return { - rooms: [], - events: [], - }; + return []; }; public addRoomToSpace(space: Room, roomId: string, via: string[], suggested = false, autoJoin = false) { @@ -157,9 +210,16 @@ export class SpaceStoreClass extends AsyncStoreWithClient { private getChildren(spaceId: string): Room[] { const room = this.matrixClient?.getRoom(spaceId); const childEvents = room?.currentState.getStateEvents(EventType.SpaceChild).filter(ev => ev.getContent()?.via); - return sortBy(childEvents, getOrder) - .map(ev => this.matrixClient.getRoom(ev.getStateKey())) - .filter(room => room?.getMyMembership() === "join") || []; + return sortBy(childEvents, ev => { + const roomId = ev.getStateKey(); + const childRoom = this.matrixClient?.getRoom(roomId); + const createTs = childRoom?.currentState.getStateEvents(EventType.RoomCreate, "")?.getTs(); + return getOrder(ev.getContent().order, createTs, roomId); + }).map(ev => { + return this.matrixClient.getRoom(ev.getStateKey()); + }).filter(room => { + return room?.getMyMembership() === "join" || room?.getMyMembership() === "invite"; + }) || []; } public getChildRooms(spaceId: string): Room[] { @@ -167,7 +227,8 @@ export class SpaceStoreClass extends AsyncStoreWithClient { } public getChildSpaces(spaceId: string): Room[] { - return this.getChildren(spaceId).filter(r => r.isSpaceRoom()); + // don't show invited subspaces as they surface at the top level for better visibility + return this.getChildren(spaceId).filter(r => r.isSpaceRoom() && r.getMyMembership() === "join"); } public getParents(roomId: string, canonicalOnly = false): Room[] { @@ -175,7 +236,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { return room?.currentState.getStateEvents(EventType.SpaceParent) .filter(ev => { const content = ev.getContent(); - if (!content?.via) return false; + if (!content?.via?.length) return false; // TODO apply permissions check to verify that the parent mapping is valid if (canonicalOnly && !content?.canonical) return false; return true; @@ -189,25 +250,30 @@ export class SpaceStoreClass extends AsyncStoreWithClient { return sortBy(parents, r => r.roomId)?.[0] || null; } - public getSpaces = () => { - return this.matrixClient.getRooms().filter(r => r.isSpaceRoom() && r.getMyMembership() === "join"); - }; - public getSpaceFilteredRoomIds = (space: Room | null): Set => { - return this.spaceFilteredRooms.get(space?.roomId || HOME_SPACE) || new Set(); + if (!space) { + return new Set(this.matrixClient.getVisibleRooms().map(r => r.roomId)); + } + return this.spaceFilteredRooms.get(space.roomId) || new Set(); }; private rebuild = throttle(() => { - // get all most-upgraded rooms & spaces except spaces which have been left (historical) - const visibleRooms = this.matrixClient.getVisibleRooms().filter(r => { - return !r.isSpaceRoom() || r.getMyMembership() === "join"; - }); + const [visibleSpaces, visibleRooms] = partitionSpacesAndRooms(this.matrixClient.getVisibleRooms()); + const [joinedSpaces, invitedSpaces] = visibleSpaces.reduce((arr, s) => { + if (s.getMyMembership() === "join") { + arr[0].push(s); + } else if (s.getMyMembership() === "invite") { + arr[1].push(s); + } + return arr; + }, [[], []]); - const unseenChildren = new Set(visibleRooms); + // exclude invited spaces from unseenChildren as they will be forcibly shown at the top level of the treeview + const unseenChildren = new Set([...visibleRooms, ...joinedSpaces]); const backrefs = new EnhancedMap>(); // Sort spaces by room ID to force the cycle breaking to be deterministic - const spaces = sortBy(visibleRooms.filter(r => r.isSpaceRoom()), space => space.roomId); + const spaces = sortBy(joinedSpaces, space => space.roomId); // TODO handle cleaning up links when a Space is removed spaces.forEach(space => { @@ -219,7 +285,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { }); }); - const [rootSpaces, orphanedRooms] = partitionSpacesAndRooms(Array.from(unseenChildren)); + const [rootSpaces] = partitionSpacesAndRooms(Array.from(unseenChildren)); // somewhat algorithm to handle full-cycles const detachedNodes = new Set(spaces); @@ -260,41 +326,26 @@ export class SpaceStoreClass extends AsyncStoreWithClient { // rootSpaces.push(space); // }); - this.orphanedRooms = new Set(orphanedRooms); this.rootSpaces = rootSpaces; this.parentMap = backrefs; // if the currently selected space no longer exists, remove its selection if (this._activeSpace && detachedNodes.has(this._activeSpace)) { - this.setActiveSpace(null); + this.setActiveSpace(null, false); } this.onRoomsUpdate(); // TODO only do this if a change has happened this.emit(UPDATE_TOP_LEVEL_SPACES, this.spacePanelSpaces); + + // build initial state of invited spaces as we would have missed the emitted events about the room at launch + this._invitedSpaces = new Set(invitedSpaces); + this.emit(UPDATE_INVITED_SPACES, this.invitedSpaces); }, 100, {trailing: true, leading: true}); onSpaceUpdate = () => { this.rebuild(); } - private showInHomeSpace = (room: Room) => { - return !this.parentMap.get(room.roomId)?.size // put all orphaned rooms in the Home Space - || DMRoomMap.shared().getUserIdForRoomId(room.roomId) // put all DMs in the Home Space - || RoomListStore.instance.getTagsForRoom(room).includes(DefaultTagID.Favourite) // show all favourites - }; - - // Update a given room due to its tag changing (e.g DM-ness or Fav-ness) - // This can only change whether it shows up in the HOME_SPACE or not - private onRoomUpdate = (room: Room) => { - if (this.showInHomeSpace(room)) { - this.spaceFilteredRooms.get(HOME_SPACE)?.add(room.roomId); - this.emit(HOME_SPACE); - } else if (!this.orphanedRooms.has(room.roomId)) { - this.spaceFilteredRooms.get(HOME_SPACE)?.delete(room.roomId); - this.emit(HOME_SPACE); - } - }; - private onSpaceMembersChange = (ev: MatrixEvent) => { // skip this update if we do not have a DM with this user if (DMRoomMap.shared().getDMRoomsForUserId(ev.getStateKey()).length < 1) return; @@ -308,16 +359,6 @@ export class SpaceStoreClass extends AsyncStoreWithClient { const oldFilteredRooms = this.spaceFilteredRooms; this.spaceFilteredRooms = new Map(); - // put all invites (rooms & spaces) in the Home Space - const invites = this.matrixClient.getRooms().filter(r => r.getMyMembership() === "invite"); - this.spaceFilteredRooms.set(HOME_SPACE, new Set(invites.map(room => room.roomId))); - - visibleRooms.forEach(room => { - if (this.showInHomeSpace(room)) { - this.spaceFilteredRooms.get(HOME_SPACE).add(room.roomId); - } - }); - this.rootSpaces.forEach(s => { // traverse each space tree in DFS to build up the supersets as you go up, // reusing results from like subtrees. @@ -334,7 +375,8 @@ export class SpaceStoreClass extends AsyncStoreWithClient { const space = this.matrixClient?.getRoom(spaceId); // Add relevant DMs - space?.getJoinedMembers().forEach(member => { + space?.getMembers().forEach(member => { + if (member.membership !== "join" && member.membership !== "invite") return; DMRoomMap.shared().getDMRoomsForUserId(member.userId).forEach(roomId => { roomIds.add(roomId); }); @@ -362,31 +404,71 @@ export class SpaceStoreClass extends AsyncStoreWithClient { this.spaceFilteredRooms.forEach((roomIds, s) => { // Update NotificationStates - const rooms = this.matrixClient.getRooms().filter(room => roomIds.has(room.roomId)); - this.getNotificationState(s)?.setRooms(rooms); + this.getNotificationState(s)?.setRooms(visibleRooms.filter(room => { + if (roomIds.has(room.roomId)) { + return !DMRoomMap.shared().getUserIdForRoomId(room.roomId) + || RoomListStore.instance.getTagsForRoom(room).includes(DefaultTagID.Favourite); + } + + return false; + })); }); }, 100, {trailing: true, leading: true}); - private onRoom = (room: Room) => { - if (room?.isSpaceRoom()) { - this.onSpaceUpdate(); - this.emit(room.roomId); - } else { + private switchToRelatedSpace = (roomId: string) => { + if (this.suggestedRooms.find(r => r.room_id === roomId)) return; + + let parent = this.getCanonicalParent(roomId); + if (!parent) { + parent = this.rootSpaces.find(s => this.spaceFilteredRooms.get(s.roomId)?.has(roomId)); + } + if (!parent) { + const parents = Array.from(this.parentMap.get(roomId) || []); + parent = parents.find(p => this.matrixClient.getRoom(p)); + } + + // don't trigger a context switch when we are switching a space to match the chosen room + this.setActiveSpace(parent || null, false); + }; + + private onRoom = (room: Room, newMembership?: string, oldMembership?: string) => { + const membership = newMembership || room.getMyMembership(); + + if (!room.isSpaceRoom()) { // this.onRoomUpdate(room); this.onRoomsUpdate(); - } - // if the user was looking at the room and then joined select that space - if (room.getMyMembership() === "join" && room.roomId === RoomViewStore.getRoomId()) { - this.setActiveSpace(room); - } + if (membership === "join") { + // the user just joined a room, remove it from the suggested list if it was there + const numSuggestedRooms = this._suggestedRooms.length; + this._suggestedRooms = this._suggestedRooms.filter(r => r.room_id !== room.roomId); + if (numSuggestedRooms !== this._suggestedRooms.length) { + this.emit(SUGGESTED_ROOMS, this._suggestedRooms); + } - if (room.getMyMembership() === "join") { - const numSuggestedRooms = this._suggestedRooms.length; - this._suggestedRooms = this._suggestedRooms.filter(r => r.room_id !== room.roomId); - if (numSuggestedRooms !== this._suggestedRooms.length) { - this.emit(SUGGESTED_ROOMS, this._suggestedRooms); + // if the room currently being viewed was just joined then switch to its related space + if (newMembership === "join" && room.roomId === RoomViewStore.getRoomId()) { + this.switchToRelatedSpace(room.roomId); + } } + return; + } + + // Space + if (membership === "invite") { + this._invitedSpaces.add(room); + this.emit(UPDATE_INVITED_SPACES, this.invitedSpaces); + } else if (oldMembership === "invite" && membership !== "join") { + this._invitedSpaces.delete(room); + this.emit(UPDATE_INVITED_SPACES, this.invitedSpaces); + } else { + this.onSpaceUpdate(); + this.emit(room.roomId); + } + + if (membership === "join" && room.roomId === RoomViewStore.getRoomId()) { + // if the user was looking at the space and then joined: select that space + this.setActiveSpace(room, false); } }; @@ -407,8 +489,6 @@ export class SpaceStoreClass extends AsyncStoreWithClient { // TODO confirm this after implementing parenting behaviour if (room.isSpaceRoom()) { this.onSpaceUpdate(); - } else { - this.onRoomUpdate(room); } this.emit(room.roomId); break; @@ -421,45 +501,24 @@ export class SpaceStoreClass extends AsyncStoreWithClient { } }; - private onRoomAccountData = (ev: MatrixEvent, room: Room, lastEvent: MatrixEvent) => { - if (ev.getType() === EventType.Tag && !room.isSpaceRoom()) { - // If the room was in favourites and now isn't or the opposite then update its position in the trees - const oldTags = lastEvent.getContent()?.tags; - const newTags = ev.getContent()?.tags; - if (!!oldTags[DefaultTagID.Favourite] !== !!newTags[DefaultTagID.Favourite]) { - this.onRoomUpdate(room); - } - } + protected async reset() { + this.rootSpaces = []; + this.parentMap = new EnhancedMap(); + this.notificationStateMap = new Map(); + this.spaceFilteredRooms = new Map(); + this._activeSpace = null; + this._suggestedRooms = []; + this._invitedSpaces = new Set(); } - private onAccountData = (ev: MatrixEvent, lastEvent: MatrixEvent) => { - if (ev.getType() === EventType.Direct) { - const lastContent = lastEvent.getContent(); - const content = ev.getContent(); - - const diff = objectDiff>(lastContent, content); - // filter out keys which changed by reference only by checking whether the sets differ - const changed = diff.changed.filter(k => arrayHasDiff(lastContent[k], content[k])); - // DM tag changes, refresh relevant rooms - new Set([...diff.added, ...diff.removed, ...changed]).forEach(roomId => { - const room = this.matrixClient?.getRoom(roomId); - if (room) { - this.onRoomUpdate(room); - } - }); - } - }; - protected async onNotReady() { if (!SettingsStore.getValue("feature_spaces")) return; if (this.matrixClient) { this.matrixClient.removeListener("Room", this.onRoom); this.matrixClient.removeListener("Room.myMembership", this.onRoom); this.matrixClient.removeListener("RoomState.events", this.onRoomState); - this.matrixClient.removeListener("Room.accountData", this.onRoomAccountData); - this.matrixClient.removeListener("accountData", this.onAccountData); } - await this.reset({}); + await this.reset(); } protected async onReady() { @@ -467,18 +526,13 @@ export class SpaceStoreClass extends AsyncStoreWithClient { this.matrixClient.on("Room", this.onRoom); this.matrixClient.on("Room.myMembership", this.onRoom); this.matrixClient.on("RoomState.events", this.onRoomState); - this.matrixClient.on("Room.accountData", this.onRoomAccountData); - this.matrixClient.on("accountData", this.onAccountData); await this.onSpaceUpdate(); // trigger an initial update // restore selected state from last session if any and still valid const lastSpaceId = window.localStorage.getItem(ACTIVE_SPACE_LS_KEY); if (lastSpaceId) { - const space = this.rootSpaces.find(s => s.roomId === lastSpaceId); - if (space) { - this.setActiveSpace(space); - } + this.setActiveSpace(this.matrixClient.getRoom(lastSpaceId)); } } @@ -486,37 +540,35 @@ export class SpaceStoreClass extends AsyncStoreWithClient { if (!SettingsStore.getValue("feature_spaces")) return; switch (payload.action) { case "view_room": { - const room = this.matrixClient?.getRoom(payload.room_id); + // Don't auto-switch rooms when reacting to a context-switch + // as this is not helpful and can create loops of rooms/space switching + if (payload.context_switch) break; - if (room?.getMyMembership() === "join") { - if (room.isSpaceRoom()) { - this.setActiveSpace(room); - } else if (!this.spaceFilteredRooms.get(this._activeSpace?.roomId || HOME_SPACE).has(room.roomId)) { - // TODO maybe reverse these first 2 clauses once space panel active is fixed - let parent = this.rootSpaces.find(s => this.spaceFilteredRooms.get(s.roomId)?.has(room.roomId)); - if (!parent) { - parent = this.getCanonicalParent(room.roomId); - } - if (!parent) { - const parents = Array.from(this.parentMap.get(room.roomId) || []); - parent = parents.find(p => this.matrixClient.getRoom(p)); - } - if (parent) { - this.setActiveSpace(parent); - } - } + const roomId = payload.room_id; + const room = this.matrixClient?.getRoom(roomId); + if (room?.isSpaceRoom()) { + // Don't context switch when navigating to the space room + // as it will cause you to end up in the wrong room + this.setActiveSpace(room, false); + } else if (this.activeSpace && !this.getSpaceFilteredRoomIds(this.activeSpace).has(roomId)) { + this.switchToRelatedSpace(roomId); } + + // Persist last viewed room from a space + // we don't await setActiveSpace above as we only care about this.activeSpace being up to date + // synchronously for the below code - everything else can and should be async. + window.localStorage.setItem(getSpaceContextKey(this.activeSpace), payload.room_id); break; } case "after_leave_room": if (this._activeSpace && payload.room_id === this._activeSpace.roomId) { - this.setActiveSpace(null); + this.setActiveSpace(null, false); } break; } } - public getNotificationState(key: SpaceKey): SpaceNotificationState { + public getNotificationState(key: string): SpaceNotificationState { if (this.notificationStateMap.has(key)) { return this.notificationStateMap.get(key); } @@ -525,6 +577,28 @@ export class SpaceStoreClass extends AsyncStoreWithClient { this.notificationStateMap.set(key, state); return state; } + + // traverse space tree with DFS calling fn on each space including the given root one, + // if includeRooms is true then fn will be called on each leaf room, if it is present in multiple sub-spaces + // then fn will be called with it multiple times. + public traverseSpace( + spaceId: string, + fn: (roomId: string) => void, + includeRooms = false, + parentPath?: Set, + ) { + if (parentPath && parentPath.has(spaceId)) return; // prevent cycles + + fn(spaceId); + + const newPath = new Set(parentPath).add(spaceId); + const [childSpaces, childRooms] = partitionSpacesAndRooms(this.getChildren(spaceId)); + + if (includeRooms) { + childRooms.forEach(r => fn(r.roomId)); + } + childSpaces.forEach(s => this.traverseSpace(s.roomId, fn, includeRooms, newPath)); + } } export default class SpaceStore { diff --git a/src/stores/SpaceTreeLevelLayoutStore.ts b/src/stores/SpaceTreeLevelLayoutStore.ts new file mode 100644 index 0000000000..424e9f4012 --- /dev/null +++ b/src/stores/SpaceTreeLevelLayoutStore.ts @@ -0,0 +1,48 @@ +/* +Copyright 2021 Šimon Brandner + +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. +*/ + +const getSpaceCollapsedKey = (roomId: string, parents: Set): string => { + const separator = "/"; + let path = ""; + if (parents) { + for (const entry of parents.entries()) { + path += entry + separator; + } + } + return `mx_space_collapsed_${path + roomId}`; +}; + +export default class SpaceTreeLevelLayoutStore { + private static internalInstance: SpaceTreeLevelLayoutStore; + + public static get instance(): SpaceTreeLevelLayoutStore { + if (!SpaceTreeLevelLayoutStore.internalInstance) { + SpaceTreeLevelLayoutStore.internalInstance = new SpaceTreeLevelLayoutStore(); + } + return SpaceTreeLevelLayoutStore.internalInstance; + } + + public setSpaceCollapsedState(roomId: string, parents: Set, collapsed: boolean) { + // XXX: localStorage doesn't allow booleans + localStorage.setItem(getSpaceCollapsedKey(roomId, parents), collapsed.toString()); + } + + public getSpaceCollapsedState(roomId: string, parents: Set, fallback: boolean): boolean { + const collapsedLocalStorage = localStorage.getItem(getSpaceCollapsedKey(roomId, parents)); + // XXX: localStorage doesn't allow booleans + return collapsedLocalStorage ? collapsedLocalStorage === "true" : fallback; + } +} diff --git a/src/stores/TypingStore.js b/src/stores/TypingStore.ts similarity index 84% rename from src/stores/TypingStore.js rename to src/stores/TypingStore.ts index e86d698eac..d5177a33a0 100644 --- a/src/stores/TypingStore.js +++ b/src/stores/TypingStore.ts @@ -1,5 +1,5 @@ /* -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2019, 2021 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. @@ -25,15 +25,23 @@ const TYPING_SERVER_TIMEOUT = 30000; * Tracks typing state for users. */ export default class TypingStore { + private typingStates: { + [roomId: string]: { + isTyping: boolean, + userTimer: Timer, + serverTimer: Timer, + }, + }; + constructor() { this.reset(); } static sharedInstance(): TypingStore { - if (global.mxTypingStore === undefined) { - global.mxTypingStore = new TypingStore(); + if (window.mxTypingStore === undefined) { + window.mxTypingStore = new TypingStore(); } - return global.mxTypingStore; + return window.mxTypingStore; } /** @@ -41,7 +49,7 @@ export default class TypingStore { * MatrixClientPeg client changes. */ reset() { - this._typingStates = { + this.typingStates = { // "roomId": { // isTyping: bool, // Whether the user is typing or not // userTimer: Timer, // Local timeout for "user has stopped typing" @@ -59,14 +67,14 @@ export default class TypingStore { if (!SettingsStore.getValue('sendTypingNotifications')) return; if (SettingsStore.getValue('lowBandwidth')) return; - let currentTyping = this._typingStates[roomId]; + let currentTyping = this.typingStates[roomId]; if ((!isTyping && !currentTyping) || (currentTyping && currentTyping.isTyping === isTyping)) { // No change in state, so don't do anything. We'll let the timer run its course. return; } if (!currentTyping) { - currentTyping = this._typingStates[roomId] = { + currentTyping = this.typingStates[roomId] = { isTyping: isTyping, serverTimer: new Timer(TYPING_SERVER_TIMEOUT), userTimer: new Timer(TYPING_USER_TIMEOUT), @@ -78,7 +86,7 @@ export default class TypingStore { if (isTyping) { if (!currentTyping.serverTimer.isRunning()) { currentTyping.serverTimer.restart().finished().then(() => { - const currentTyping = this._typingStates[roomId]; + const currentTyping = this.typingStates[roomId]; if (currentTyping) currentTyping.isTyping = false; // The server will (should) time us out on typing, so we don't diff --git a/src/stores/VoiceRecordingStore.ts b/src/stores/VoiceRecordingStore.ts new file mode 100644 index 0000000000..8ee44359fb --- /dev/null +++ b/src/stores/VoiceRecordingStore.ts @@ -0,0 +1,82 @@ +/* +Copyright 2021 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 {AsyncStoreWithClient} from "./AsyncStoreWithClient"; +import defaultDispatcher from "../dispatcher/dispatcher"; +import {ActionPayload} from "../dispatcher/payloads"; +import {VoiceRecording} from "../voice/VoiceRecording"; + +interface IState { + recording?: VoiceRecording; +} + +export class VoiceRecordingStore extends AsyncStoreWithClient { + private static internalInstance: VoiceRecordingStore; + + public constructor() { + super(defaultDispatcher, {}); + } + + /** + * Gets the active recording instance, if any. + */ + public get activeRecording(): VoiceRecording | null { + return this.state.recording; + } + + public static get instance(): VoiceRecordingStore { + if (!VoiceRecordingStore.internalInstance) { + VoiceRecordingStore.internalInstance = new VoiceRecordingStore(); + } + return VoiceRecordingStore.internalInstance; + } + + protected async onAction(payload: ActionPayload): Promise { + // Nothing to do, but we're required to override the function + return; + } + + /** + * Starts a new recording if one isn't already in progress. Note that this simply + * creates a recording instance - whether or not recording is actively in progress + * can be seen via the VoiceRecording class. + * @returns {VoiceRecording} The recording. + */ + public startRecording(): VoiceRecording { + if (!this.matrixClient) throw new Error("Cannot start a recording without a MatrixClient"); + if (this.state.recording) throw new Error("A recording is already in progress"); + + const recording = new VoiceRecording(this.matrixClient); + + // noinspection JSIgnoredPromiseFromCall - we can safely run this async + this.updateState({recording}); + + return recording; + } + + /** + * Disposes of the current recording, no matter the state of it. + * @returns {Promise} Resolves when complete. + */ + public disposeRecording(): Promise { + if (this.state.recording) { + this.state.recording.destroy(); // stops internally + } + return this.updateState({recording: null}); + } +} + +window.mxVoiceRecordingStore = VoiceRecordingStore.instance; diff --git a/src/stores/WidgetEchoStore.js b/src/stores/WidgetEchoStore.ts similarity index 71% rename from src/stores/WidgetEchoStore.js rename to src/stores/WidgetEchoStore.ts index 3aef1beb3e..09120d6108 100644 --- a/src/stores/WidgetEchoStore.js +++ b/src/stores/WidgetEchoStore.ts @@ -1,6 +1,5 @@ /* -Copyright 2018 New Vector Ltd -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2018-2021 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. @@ -16,6 +15,8 @@ limitations under the License. */ import EventEmitter from 'events'; +import { IWidget } from 'matrix-widget-api'; +import MatrixEvent from "matrix-js-sdk/src/models/event"; import {WidgetType} from "../widgets/WidgetType"; /** @@ -23,14 +24,20 @@ import {WidgetType} from "../widgets/WidgetType"; * proxying through state from the js-sdk. */ class WidgetEchoStore extends EventEmitter { + private roomWidgetEcho: { + [roomId: string]: { + [widgetId: string]: IWidget, + }, + }; + constructor() { super(); - this._roomWidgetEcho = { + this.roomWidgetEcho = { // Map as below. Object is the content of the widget state event, // so for widgets that have been deleted locally, the object is empty. // roomId: { - // widgetId: [object] + // widgetId: IWidget // } }; } @@ -42,14 +49,14 @@ class WidgetEchoStore extends EventEmitter { * and we don't really need the actual widget events anyway since we just want to * show a spinner / prevent widgets being added twice. * - * @param {Room} roomId The ID of the room to get widgets for + * @param {string} roomId The ID of the room to get widgets for * @param {MatrixEvent[]} currentRoomWidgets Current widgets for the room * @returns {MatrixEvent[]} List of widgets in the room, minus any pending removal */ - getEchoedRoomWidgets(roomId, currentRoomWidgets) { + getEchoedRoomWidgets(roomId: string, currentRoomWidgets: MatrixEvent[]): MatrixEvent[] { const echoedWidgets = []; - const roomEchoState = Object.assign({}, this._roomWidgetEcho[roomId]); + const roomEchoState = Object.assign({}, this.roomWidgetEcho[roomId]); for (const w of currentRoomWidgets) { const widgetId = w.getStateKey(); @@ -65,8 +72,8 @@ class WidgetEchoStore extends EventEmitter { return echoedWidgets; } - roomHasPendingWidgetsOfType(roomId, currentRoomWidgets, type: WidgetType) { - const roomEchoState = Object.assign({}, this._roomWidgetEcho[roomId]); + roomHasPendingWidgetsOfType(roomId: string, currentRoomWidgets: MatrixEvent[], type?: WidgetType): boolean { + const roomEchoState = Object.assign({}, this.roomWidgetEcho[roomId]); // any widget IDs that are already in the room are not pending, so // echoes for them don't count as pending. @@ -85,20 +92,20 @@ class WidgetEchoStore extends EventEmitter { } } - roomHasPendingWidgets(roomId, currentRoomWidgets) { + roomHasPendingWidgets(roomId: string, currentRoomWidgets: MatrixEvent[]): boolean { return this.roomHasPendingWidgetsOfType(roomId, currentRoomWidgets); } - setRoomWidgetEcho(roomId, widgetId, state) { - if (this._roomWidgetEcho[roomId] === undefined) this._roomWidgetEcho[roomId] = {}; + setRoomWidgetEcho(roomId: string, widgetId: string, state: IWidget) { + if (this.roomWidgetEcho[roomId] === undefined) this.roomWidgetEcho[roomId] = {}; - this._roomWidgetEcho[roomId][widgetId] = state; + this.roomWidgetEcho[roomId][widgetId] = state; this.emit('update', roomId, widgetId); } - removeRoomWidgetEcho(roomId, widgetId) { - delete this._roomWidgetEcho[roomId][widgetId]; - if (Object.keys(this._roomWidgetEcho[roomId]).length === 0) delete this._roomWidgetEcho[roomId]; + removeRoomWidgetEcho(roomId: string, widgetId: string) { + delete this.roomWidgetEcho[roomId][widgetId]; + if (Object.keys(this.roomWidgetEcho[roomId]).length === 0) delete this.roomWidgetEcho[roomId]; this.emit('update', roomId, widgetId); } } diff --git a/src/stores/notifications/RoomNotificationStateStore.ts b/src/stores/notifications/RoomNotificationStateStore.ts index 8b5da674f5..7253b46ddd 100644 --- a/src/stores/notifications/RoomNotificationStateStore.ts +++ b/src/stores/notifications/RoomNotificationStateStore.ts @@ -22,6 +22,7 @@ import { FetchRoomFn, ListNotificationState } from "./ListNotificationState"; import { Room } from "matrix-js-sdk/src/models/room"; import { RoomNotificationState } from "./RoomNotificationState"; import { SummarizedNotificationState } from "./SummarizedNotificationState"; +import { VisibilityProvider } from "../room-list/filters/VisibilityProvider"; interface IState {} @@ -47,7 +48,9 @@ export class RoomNotificationStateStore extends AsyncStoreWithClient { // This will include highlights from the previous version of the room internally const globalState = new SummarizedNotificationState(); for (const room of this.matrixClient.getVisibleRooms()) { - globalState.add(this.getRoomState(room)); + if (VisibilityProvider.instance.isRoomVisible(room)) { + globalState.add(this.getRoomState(room)); + } } return globalState; } diff --git a/src/stores/notifications/StaticNotificationState.ts b/src/stores/notifications/StaticNotificationState.ts index 0392ed3716..b18aa78e0f 100644 --- a/src/stores/notifications/StaticNotificationState.ts +++ b/src/stores/notifications/StaticNotificationState.ts @@ -18,6 +18,8 @@ import { NotificationColor } from "./NotificationColor"; import { NotificationState } from "./NotificationState"; export class StaticNotificationState extends NotificationState { + public static readonly RED_EXCLAMATION = StaticNotificationState.forSymbol("!", NotificationColor.Red); + constructor(symbol: string, count: number, color: NotificationColor) { super(); this._symbol = symbol; diff --git a/src/stores/room-list/MessagePreviewStore.ts b/src/stores/room-list/MessagePreviewStore.ts index 1da0e661e8..10e5cf554e 100644 --- a/src/stores/room-list/MessagePreviewStore.ts +++ b/src/stores/room-list/MessagePreviewStore.ts @@ -94,10 +94,10 @@ export class MessagePreviewStore extends AsyncStoreWithClient { * @param inTagId The tag ID in which the room resides * @returns The preview, or null if none present. */ - public getPreviewForRoom(room: Room, inTagId: TagID): string { + public async getPreviewForRoom(room: Room, inTagId: TagID): Promise { if (!room) return null; // invalid room, just return nothing - if (!this.previews.has(room.roomId)) this.generatePreview(room, inTagId); + if (!this.previews.has(room.roomId)) await this.generatePreview(room, inTagId); const previews = this.previews.get(room.roomId); if (!previews) return null; @@ -108,7 +108,7 @@ export class MessagePreviewStore extends AsyncStoreWithClient { return previews.get(inTagId); } - private generatePreview(room: Room, tagId?: TagID) { + private async generatePreview(room: Room, tagId?: TagID) { const events = room.timeline; if (!events) return; // should only happen in tests @@ -130,6 +130,9 @@ export class MessagePreviewStore extends AsyncStoreWithClient { } const event = events[i]; + + await this.matrixClient.decryptEventIfNeeded(event); + const previewDef = PREVIEWS[event.getType()]; if (!previewDef) continue; if (previewDef.isState && isNullOrUndefined(event.getStateKey())) continue; @@ -174,7 +177,7 @@ export class MessagePreviewStore extends AsyncStoreWithClient { if (payload.action === 'MatrixActions.Room.timeline' || payload.action === 'MatrixActions.Event.decrypted') { const event = payload.event; // TODO: Type out the dispatcher if (!this.previews.has(event.getRoomId())) return; // not important - this.generatePreview(this.matrixClient.getRoom(event.getRoomId()), TAG_ANY); + await this.generatePreview(this.matrixClient.getRoom(event.getRoomId()), TAG_ANY); } } } diff --git a/src/stores/room-list/RoomListStore.ts b/src/stores/room-list/RoomListStore.ts index 88df05b5d0..a23401e4c9 100644 --- a/src/stores/room-list/RoomListStore.ts +++ b/src/stores/room-list/RoomListStore.ts @@ -426,6 +426,10 @@ export class RoomListStoreClass extends AsyncStoreWithClient { return; // don't do anything on rooms that aren't visible } + if (cause === RoomUpdateCause.NewRoom && !this.prefilterConditions.every(c => c.isVisible(room))) { + return; // don't do anything on new rooms which ought not to be shown + } + const shouldUpdate = await this.algorithm.handleRoomUpdate(room, cause); if (shouldUpdate) { if (SettingsStore.getValue("advancedRoomListLogging")) { @@ -599,13 +603,13 @@ export class RoomListStoreClass extends AsyncStoreWithClient { private getPlausibleRooms(): Room[] { if (!this.matrixClient) return []; - let rooms = [ - ...this.matrixClient.getVisibleRooms(), - // also show space invites in the room list - ...this.matrixClient.getRooms().filter(r => r.isSpaceRoom() && r.getMyMembership() === "invite"), - ].filter(r => VisibilityProvider.instance.isRoomVisible(r)); + let rooms = this.matrixClient.getVisibleRooms().filter(r => VisibilityProvider.instance.isRoomVisible(r)); - if (this.prefilterConditions.length > 0) { + // if spaces are enabled only consider the prefilter conditions when there are no runtime conditions + // for the search all spaces feature + if (this.prefilterConditions.length > 0 + && (!SettingsStore.getValue("feature_spaces") || !this.filterConditions.length) + ) { rooms = rooms.filter(r => { for (const filter of this.prefilterConditions) { if (!filter.isVisible(r)) { @@ -664,7 +668,7 @@ export class RoomListStoreClass extends AsyncStoreWithClient { * and thus might not cause an update to the store immediately. * @param {IFilterCondition} filter The filter condition to add. */ - public addFilter(filter: IFilterCondition): void { + public async addFilter(filter: IFilterCondition): Promise { if (SettingsStore.getValue("advancedRoomListLogging")) { // TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602 console.log("Adding filter condition:", filter); @@ -676,6 +680,12 @@ export class RoomListStoreClass extends AsyncStoreWithClient { promise = this.recalculatePrefiltering(); } else { this.filterConditions.push(filter); + // Runtime filters with spaces disable prefiltering for the search all spaces feature + if (SettingsStore.getValue("feature_spaces")) { + // this has to be awaited so that `setKnownRooms` is called in time for the `addFilterCondition` below + // this way the runtime filters are only evaluated on one dataset and not both. + await this.recalculatePrefiltering(); + } if (this.algorithm) { this.algorithm.addFilterCondition(filter); } @@ -703,6 +713,10 @@ export class RoomListStoreClass extends AsyncStoreWithClient { if (this.algorithm) { this.algorithm.removeFilterCondition(filter); } + // Runtime filters with spaces disable prefiltering for the search all spaces feature + if (SettingsStore.getValue("feature_spaces")) { + promise = this.recalculatePrefiltering(); + } } idx = this.prefilterConditions.indexOf(filter); if (idx >= 0) { diff --git a/src/stores/room-list/SpaceWatcher.ts b/src/stores/room-list/SpaceWatcher.ts index d26f563a91..0b1b78bc75 100644 --- a/src/stores/room-list/SpaceWatcher.ts +++ b/src/stores/room-list/SpaceWatcher.ts @@ -24,16 +24,34 @@ import SpaceStore, { UPDATE_SELECTED_SPACE } from "../SpaceStore"; * Watches for changes in spaces to manage the filter on the provided RoomListStore */ export class SpaceWatcher { - private filter = new SpaceFilterCondition(); + private filter: SpaceFilterCondition; private activeSpace: Room = SpaceStore.instance.activeSpace; constructor(private store: RoomListStoreClass) { - this.filter.updateSpace(this.activeSpace); // get the filter into a consistent state - store.addFilter(this.filter); SpaceStore.instance.on(UPDATE_SELECTED_SPACE, this.onSelectedSpaceUpdated); } - private onSelectedSpaceUpdated = (activeSpace) => { - this.filter.updateSpace(this.activeSpace = activeSpace); + private onSelectedSpaceUpdated = (activeSpace?: Room) => { + this.activeSpace = activeSpace; + + if (this.filter) { + if (activeSpace) { + this.updateFilter(); + } else { + this.store.removeFilter(this.filter); + this.filter = null; + } + } else if (activeSpace) { + this.filter = new SpaceFilterCondition(); + this.updateFilter(); + this.store.addFilter(this.filter); + } + }; + + private updateFilter = () => { + SpaceStore.instance.traverseSpace(this.activeSpace.roomId, roomId => { + this.store.matrixClient?.getRoom(roomId)?.loadMembersIfNeeded(); + }); + this.filter.updateSpace(this.activeSpace); }; } diff --git a/src/stores/room-list/algorithms/Algorithm.ts b/src/stores/room-list/algorithms/Algorithm.ts index 83ee803115..024c484c41 100644 --- a/src/stores/room-list/algorithms/Algorithm.ts +++ b/src/stores/room-list/algorithms/Algorithm.ts @@ -199,8 +199,10 @@ export class Algorithm extends EventEmitter { } private async doUpdateStickyRoom(val: Room) { - // no-op sticky rooms for spaces - they're effectively virtual rooms - if (val?.isSpaceRoom() && val.getMyMembership() !== "invite") val = null; + if (SettingsStore.getValue("feature_spaces") && val?.isSpaceRoom() && val.getMyMembership() !== "invite") { + // no-op sticky rooms for spaces - they're effectively virtual rooms + val = null; + } // Note throughout: We need async so we can wait for handleRoomUpdate() to do its thing, // otherwise we risk duplicating rooms. @@ -577,9 +579,8 @@ export class Algorithm extends EventEmitter { await this.generateFreshTags(newTags); - this.cachedRooms = newTags; + this.cachedRooms = newTags; // this recalculates the filtered rooms for us this.updateTagsFromCache(); - this.recalculateFilteredRooms(); // Now that we've finished generation, we need to update the sticky room to what // it was. It's entirely possible that it changed lists though, so if it did then diff --git a/src/stores/room-list/algorithms/tag-sorting/RecentAlgorithm.ts b/src/stores/room-list/algorithms/tag-sorting/RecentAlgorithm.ts index 7c8c879cf6..49cfd9e520 100644 --- a/src/stores/room-list/algorithms/tag-sorting/RecentAlgorithm.ts +++ b/src/stores/room-list/algorithms/tag-sorting/RecentAlgorithm.ts @@ -21,79 +21,83 @@ import { MatrixClientPeg } from "../../../../MatrixClientPeg"; import * as Unread from "../../../../Unread"; import { EffectiveMembership, getEffectiveMembership } from "../../../../utils/membership"; +export const sortRooms = (rooms: Room[]): Room[] => { + // We cache the timestamp lookup to avoid iterating forever on the timeline + // of events. This cache only survives a single sort though. + // We wouldn't need this if `.sort()` didn't constantly try and compare all + // of the rooms to each other. + + // TODO: We could probably improve the sorting algorithm here by finding changes. + // See https://github.com/vector-im/element-web/issues/14459 + // For example, if we spent a little bit of time to determine which elements have + // actually changed (probably needs to be done higher up?) then we could do an + // insertion sort or similar on the limited set of changes. + + // TODO: Don't assume we're using the same client as the peg + // See https://github.com/vector-im/element-web/issues/14458 + let myUserId = ''; + if (MatrixClientPeg.get()) { + myUserId = MatrixClientPeg.get().getUserId(); + } + + const tsCache: { [roomId: string]: number } = {}; + const getLastTs = (r: Room) => { + if (tsCache[r.roomId]) { + return tsCache[r.roomId]; + } + + const ts = (() => { + // Apparently we can have rooms without timelines, at least under testing + // environments. Just return MAX_INT when this happens. + if (!r || !r.timeline) { + return Number.MAX_SAFE_INTEGER; + } + + // If the room hasn't been joined yet, it probably won't have a timeline to + // parse. We'll still fall back to the timeline if this fails, but chances + // are we'll at least have our own membership event to go off of. + const effectiveMembership = getEffectiveMembership(r.getMyMembership()); + if (effectiveMembership !== EffectiveMembership.Join) { + const membershipEvent = r.currentState.getStateEvents("m.room.member", myUserId); + if (membershipEvent && !Array.isArray(membershipEvent)) { + return membershipEvent.getTs(); + } + } + + for (let i = r.timeline.length - 1; i >= 0; --i) { + const ev = r.timeline[i]; + if (!ev.getTs()) continue; // skip events that don't have timestamps (tests only?) + + if (ev.getSender() === myUserId || Unread.eventTriggersUnreadCount(ev)) { + return ev.getTs(); + } + } + + // we might only have events that don't trigger the unread indicator, + // in which case use the oldest event even if normally it wouldn't count. + // This is better than just assuming the last event was forever ago. + if (r.timeline.length && r.timeline[0].getTs()) { + return r.timeline[0].getTs(); + } else { + return Number.MAX_SAFE_INTEGER; + } + })(); + + tsCache[r.roomId] = ts; + return ts; + }; + + return rooms.sort((a, b) => { + return getLastTs(b) - getLastTs(a); + }); +}; + /** * Sorts rooms according to the last event's timestamp in each room that seems * useful to the user. */ export class RecentAlgorithm implements IAlgorithm { public async sortRooms(rooms: Room[], tagId: TagID): Promise { - // We cache the timestamp lookup to avoid iterating forever on the timeline - // of events. This cache only survives a single sort though. - // We wouldn't need this if `.sort()` didn't constantly try and compare all - // of the rooms to each other. - - // TODO: We could probably improve the sorting algorithm here by finding changes. - // See https://github.com/vector-im/element-web/issues/14459 - // For example, if we spent a little bit of time to determine which elements have - // actually changed (probably needs to be done higher up?) then we could do an - // insertion sort or similar on the limited set of changes. - - // TODO: Don't assume we're using the same client as the peg - // See https://github.com/vector-im/element-web/issues/14458 - let myUserId = ''; - if (MatrixClientPeg.get()) { - myUserId = MatrixClientPeg.get().getUserId(); - } - - const tsCache: { [roomId: string]: number } = {}; - const getLastTs = (r: Room) => { - if (tsCache[r.roomId]) { - return tsCache[r.roomId]; - } - - const ts = (() => { - // Apparently we can have rooms without timelines, at least under testing - // environments. Just return MAX_INT when this happens. - if (!r || !r.timeline) { - return Number.MAX_SAFE_INTEGER; - } - - // If the room hasn't been joined yet, it probably won't have a timeline to - // parse. We'll still fall back to the timeline if this fails, but chances - // are we'll at least have our own membership event to go off of. - const effectiveMembership = getEffectiveMembership(r.getMyMembership()); - if (effectiveMembership !== EffectiveMembership.Join) { - const membershipEvent = r.currentState.getStateEvents("m.room.member", myUserId); - if (membershipEvent && !Array.isArray(membershipEvent)) { - return membershipEvent.getTs(); - } - } - - for (let i = r.timeline.length - 1; i >= 0; --i) { - const ev = r.timeline[i]; - if (!ev.getTs()) continue; // skip events that don't have timestamps (tests only?) - - if (ev.getSender() === myUserId || Unread.eventTriggersUnreadCount(ev)) { - return ev.getTs(); - } - } - - // we might only have events that don't trigger the unread indicator, - // in which case use the oldest event even if normally it wouldn't count. - // This is better than just assuming the last event was forever ago. - if (r.timeline.length && r.timeline[0].getTs()) { - return r.timeline[0].getTs(); - } else { - return Number.MAX_SAFE_INTEGER; - } - })(); - - tsCache[r.roomId] = ts; - return ts; - }; - - return rooms.sort((a, b) => { - return getLastTs(b) - getLastTs(a); - }); + return sortRooms(rooms); } } diff --git a/src/stores/room-list/filters/NameFilterCondition.ts b/src/stores/room-list/filters/NameFilterCondition.ts index 68c5a9bd6d..7ec91a3249 100644 --- a/src/stores/room-list/filters/NameFilterCondition.ts +++ b/src/stores/room-list/filters/NameFilterCondition.ts @@ -17,7 +17,7 @@ limitations under the License. import { Room } from "matrix-js-sdk/src/models/room"; import { FILTER_CHANGED, FilterKind, IFilterCondition } from "./IFilterCondition"; import { EventEmitter } from "events"; -import { removeHiddenChars } from "matrix-js-sdk/src/utils"; +import { normalize } from "matrix-js-sdk/src/utils"; import { throttle } from "lodash"; /** @@ -62,15 +62,10 @@ export class NameFilterCondition extends EventEmitter implements IFilterConditio if (!room.name) return false; // should realistically not happen: the js-sdk always calculates a name - return this.matches(room.name); + return this.matches(room.normalizedName); } - public matches(val: string): boolean { - // Note: we have to match the filter with the removeHiddenChars() room name because the - // function strips spaces and other characters (M becomes RN for example, in lowercase). - // We also doubly convert to lowercase to work around oddities of the library. - const noSecretsFilter = removeHiddenChars(this.search.toLowerCase()).toLowerCase(); - const noSecretsName = removeHiddenChars(val.toLowerCase()).toLowerCase(); - return noSecretsName.includes(noSecretsFilter); + public matches(normalizedName: string): boolean { + return normalizedName.includes(normalize(this.search)); } } diff --git a/src/stores/room-list/filters/SpaceFilterCondition.ts b/src/stores/room-list/filters/SpaceFilterCondition.ts index ad0ab88868..6a06bee0d8 100644 --- a/src/stores/room-list/filters/SpaceFilterCondition.ts +++ b/src/stores/room-list/filters/SpaceFilterCondition.ts @@ -19,7 +19,7 @@ import { Room } from "matrix-js-sdk/src/models/room"; import { FILTER_CHANGED, FilterKind, IFilterCondition } from "./IFilterCondition"; import { IDestroyable } from "../../../utils/IDestroyable"; -import SpaceStore, {HOME_SPACE} from "../../SpaceStore"; +import SpaceStore from "../../SpaceStore"; import { setHasDiff } from "../../../utils/sets"; /** @@ -42,17 +42,25 @@ export class SpaceFilterCondition extends EventEmitter implements IFilterConditi private onStoreUpdate = async (): Promise => { const beforeRoomIds = this.roomIds; - this.roomIds = SpaceStore.instance.getSpaceFilteredRoomIds(this.space); + // clone the set as it may be mutated by the space store internally + this.roomIds = new Set(SpaceStore.instance.getSpaceFilteredRoomIds(this.space)); if (setHasDiff(beforeRoomIds, this.roomIds)) { this.emit(FILTER_CHANGED); + // XXX: Room List Store has a bug where updates to the pre-filter during a local echo of a + // tags transition seem to be ignored, so refire in the next tick to work around it + setImmediate(() => { + this.emit(FILTER_CHANGED); + }); } }; - private getSpaceEventKey = (space: Room | null) => space ? space.roomId : HOME_SPACE; + private getSpaceEventKey = (space: Room) => space.roomId; public updateSpace(space: Room) { - SpaceStore.instance.off(this.getSpaceEventKey(this.space), this.onStoreUpdate); + if (this.space) { + SpaceStore.instance.off(this.getSpaceEventKey(this.space), this.onStoreUpdate); + } SpaceStore.instance.on(this.getSpaceEventKey(this.space = space), this.onStoreUpdate); this.onStoreUpdate(); // initial update from the change to the space } diff --git a/src/stores/room-list/filters/VisibilityProvider.ts b/src/stores/room-list/filters/VisibilityProvider.ts index 388bb061e3..c07c2b0b26 100644 --- a/src/stores/room-list/filters/VisibilityProvider.ts +++ b/src/stores/room-list/filters/VisibilityProvider.ts @@ -37,7 +37,11 @@ export class VisibilityProvider { await VoipUserMapper.sharedInstance().onNewInvitedRoom(room); } - public isRoomVisible(room: Room): boolean { + public isRoomVisible(room?: Room): boolean { + if (!room) { + return false; + } + if ( CallHandler.sharedInstance().getSupportsVirtualRooms() && VoipUserMapper.sharedInstance().isVirtualRoom(room) @@ -46,7 +50,7 @@ export class VisibilityProvider { } // hide space rooms as they'll be shown in the SpacePanel - if (room.isSpaceRoom() && SettingsStore.getValue("feature_spaces")) { + if (SettingsStore.getValue("feature_spaces") && room.isSpaceRoom()) { return false; } diff --git a/src/stores/room-list/previews/MessageEventPreview.ts b/src/stores/room-list/previews/MessageEventPreview.ts index deed7dcf2c..b900afc13f 100644 --- a/src/stores/room-list/previews/MessageEventPreview.ts +++ b/src/stores/room-list/previews/MessageEventPreview.ts @@ -20,7 +20,7 @@ import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { _t } from "../../../languageHandler"; import { getSenderName, isSelf, shouldPrefixMessagesIn } from "./utils"; import ReplyThread from "../../../components/views/elements/ReplyThread"; -import { sanitizedHtmlNodeInnerText } from "../../../HtmlUtils"; +import { getHtmlText } from "../../../HtmlUtils"; export class MessageEventPreview implements IPreview { public getTextFor(event: MatrixEvent, tagId?: TagID): string { @@ -55,7 +55,7 @@ export class MessageEventPreview implements IPreview { } if (hasHtml) { - body = sanitizedHtmlNodeInnerText(body); + body = getHtmlText(body); } if (msgtype === 'm.emote') { diff --git a/src/stores/widgets/StopGapWidget.ts b/src/stores/widgets/StopGapWidget.ts index 17371d6d45..397d637125 100644 --- a/src/stores/widgets/StopGapWidget.ts +++ b/src/stores/widgets/StopGapWidget.ts @@ -1,5 +1,5 @@ /* - * Copyright 2020 The Matrix.org Foundation C.I.C. + * Copyright 2020, 2021 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. @@ -52,6 +52,8 @@ import {getCustomTheme} from "../../theme"; import CountlyAnalytics from "../../CountlyAnalytics"; import { ElementWidgetCapabilities } from "./ElementWidgetCapabilities"; import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { ELEMENT_CLIENT_ID } from "../../identifiers"; +import { getUserLanguage } from "../../languageHandler"; // TODO: Destroy all of this code @@ -194,6 +196,9 @@ export class StopGapWidget extends EventEmitter { currentUserId: MatrixClientPeg.get().getUserId(), userDisplayName: OwnProfileStore.instance.displayName, userHttpAvatarUrl: OwnProfileStore.instance.getHttpAvatarUrl(), + clientId: ELEMENT_CLIENT_ID, + clientTheme: SettingsStore.getValue("theme"), + clientLanguage: getUserLanguage(), }, opts?.asPopout); const parsed = new URL(templated); @@ -395,6 +400,7 @@ export class StopGapWidget extends EventEmitter { } private onEvent = (ev: MatrixEvent) => { + MatrixClientPeg.get().decryptEventIfNeeded(ev); if (ev.isBeingDecrypted() || ev.isDecryptionFailure()) return; if (ev.getRoomId() !== this.eventListenerRoomId) return; this.feedEvent(ev); diff --git a/src/stores/widgets/StopGapWidgetDriver.ts b/src/stores/widgets/StopGapWidgetDriver.ts index 8a286d909b..25e81c47a2 100644 --- a/src/stores/widgets/StopGapWidgetDriver.ts +++ b/src/stores/widgets/StopGapWidgetDriver.ts @@ -44,6 +44,7 @@ import { CHAT_EFFECTS } from "../../effects"; import { containsEmoji } from "../../effects/utils"; import dis from "../../dispatcher/dispatcher"; import {tryTransformPermalinkToLocalHref} from "../../utils/permalinks/Permalinks"; +import {MatrixEvent} from "matrix-js-sdk/src/models/event"; // TODO: Purge this from the universe @@ -144,6 +145,52 @@ export class StopGapWidgetDriver extends WidgetDriver { return {roomId, eventId: r.event_id}; } + public async readRoomEvents(eventType: string, msgtype: string | undefined, limit: number): Promise { + limit = limit > 0 ? Math.min(limit, 25) : 25; // arbitrary choice + + const client = MatrixClientPeg.get(); + const roomId = ActiveRoomObserver.activeRoomId; + const room = client.getRoom(roomId); + if (!client || !roomId || !room) throw new Error("Not in a room or not attached to a client"); + + const results: MatrixEvent[] = []; + const events = room.getLiveTimeline().getEvents(); // timelines are most recent last + for (let i = events.length - 1; i > 0; i--) { + if (results.length >= limit) break; + + const ev = events[i]; + if (ev.getType() !== eventType) continue; + if (eventType === EventType.RoomMessage && msgtype && msgtype !== ev.getContent()['msgtype']) continue; + results.push(ev); + } + + return results.map(e => e.event); + } + + public async readStateEvents( + eventType: string, stateKey: string | undefined, limit: number, + ): Promise { + limit = limit > 0 ? Math.min(limit, 100) : 100; // arbitrary choice + + const client = MatrixClientPeg.get(); + const roomId = ActiveRoomObserver.activeRoomId; + const room = client.getRoom(roomId); + if (!client || !roomId || !room) throw new Error("Not in a room or not attached to a client"); + + const results: MatrixEvent[] = []; + const state = room.currentState.events.get(eventType); + if (state) { + if (stateKey === "" || !!stateKey) { + const forKey = state.get(stateKey); + if (forKey) results.push(forKey); + } else { + results.push(...Array.from(state.values())); + } + } + + return results.slice(0, limit).map(e => e.event); + } + public async askOpenID(observer: SimpleObservable) { const oidcState = WidgetPermissionStore.instance.getOIDCState( this.forWidget, this.forWidgetKind, this.inRoomId, diff --git a/src/utils/AutoDiscoveryUtils.js b/src/utils/AutoDiscoveryUtils.tsx similarity index 91% rename from src/utils/AutoDiscoveryUtils.js rename to src/utils/AutoDiscoveryUtils.tsx index 614aa4cea8..e3a7fd2d0b 100644 --- a/src/utils/AutoDiscoveryUtils.js +++ b/src/utils/AutoDiscoveryUtils.tsx @@ -1,6 +1,5 @@ /* -Copyright 2019 New Vector Ltd -Copyright 2019, 2020 The Matrix.org Foundation C.I.C. +Copyright 2019-2021 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. @@ -15,13 +14,13 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from 'react'; +import React, { ReactNode } from 'react'; import {AutoDiscovery} from "matrix-js-sdk/src/autodiscovery"; import {_t, _td, newTranslatableError} from "../languageHandler"; import {makeType} from "./TypeUtils"; import SdkConfig from '../SdkConfig'; -const LIVELINESS_DISCOVERY_ERRORS = [ +const LIVELINESS_DISCOVERY_ERRORS: string[] = [ AutoDiscovery.ERROR_INVALID_HOMESERVER, AutoDiscovery.ERROR_INVALID_IDENTITY_SERVER, ]; @@ -40,17 +39,23 @@ export class ValidatedServerConfig { warning: string; } +export interface IAuthComponentState { + serverIsAlive: boolean; + serverErrorIsFatal: boolean; + serverDeadError?: ReactNode; +} + export default class AutoDiscoveryUtils { /** * Checks if a given error or error message is considered an error * relating to the liveliness of the server. Must be an error returned * from this AutoDiscoveryUtils class. - * @param {string|Error} error The error to check + * @param {string | Error} error The error to check * @returns {boolean} True if the error is a liveliness error. */ - static isLivelinessError(error: string|Error): boolean { + static isLivelinessError(error: string | Error): boolean { if (!error) return false; - return !!LIVELINESS_DISCOVERY_ERRORS.find(e => e === error || e === error.message); + return !!LIVELINESS_DISCOVERY_ERRORS.find(e => typeof error === "string" ? e === error : e === error.message); } /** @@ -61,7 +66,7 @@ export default class AutoDiscoveryUtils { * implementation for known values. * @returns {*} The state for the component, given the error. */ - static authComponentStateForError(err: string | Error | null, pageName = "login"): Object { + static authComponentStateForError(err: string | Error | null, pageName = "login"): IAuthComponentState { if (!err) { return { serverIsAlive: true, @@ -70,7 +75,7 @@ export default class AutoDiscoveryUtils { }; } let title = _t("Cannot reach homeserver"); - let body = _t("Ensure you have a stable internet connection, or get in touch with the server admin"); + let body: ReactNode = _t("Ensure you have a stable internet connection, or get in touch with the server admin"); if (!AutoDiscoveryUtils.isLivelinessError(err)) { const brand = SdkConfig.get().brand; title = _t("Your %(brand)s is misconfigured", { brand }); @@ -92,7 +97,7 @@ export default class AutoDiscoveryUtils { } let isFatalError = true; - const errorMessage = err.message ? err.message : err; + const errorMessage = typeof err === "string" ? err : err.message; if (errorMessage === AutoDiscovery.ERROR_INVALID_IDENTITY_SERVER) { isFatalError = false; title = _t("Cannot reach identity server"); @@ -141,7 +146,10 @@ export default class AutoDiscoveryUtils { * @returns {Promise} Resolves to the validated configuration. */ static async validateServerConfigWithStaticUrls( - homeserverUrl: string, identityUrl: string, syntaxOnly = false): ValidatedServerConfig { + homeserverUrl: string, + identityUrl?: string, + syntaxOnly = false, + ): Promise { if (!homeserverUrl) { throw newTranslatableError(_td("No homeserver URL provided")); } @@ -171,7 +179,7 @@ export default class AutoDiscoveryUtils { * @param {string} serverName The homeserver domain name (eg: "matrix.org") to validate. * @returns {Promise} Resolves to the validated configuration. */ - static async validateServerName(serverName: string): ValidatedServerConfig { + static async validateServerName(serverName: string): Promise { const result = await AutoDiscovery.findClientConfig(serverName); return AutoDiscoveryUtils.buildValidatedConfigFromDiscovery(serverName, result); } diff --git a/src/utils/DMRoomMap.ts b/src/utils/DMRoomMap.ts index e49b74c380..b166674043 100644 --- a/src/utils/DMRoomMap.ts +++ b/src/utils/DMRoomMap.ts @@ -55,6 +55,15 @@ export default class DMRoomMap { return DMRoomMap.sharedInstance; } + /** + * Set the shared instance to the instance supplied + * Used by tests + * @param inst the new shared instance + */ + public static setShared(inst: DMRoomMap) { + DMRoomMap.sharedInstance = inst; + } + /** * Returns a shared instance of the class * that uses the singleton matrix client diff --git a/src/utils/DecryptFile.ts b/src/utils/DecryptFile.ts index 93cedbc707..d073393170 100644 --- a/src/utils/DecryptFile.ts +++ b/src/utils/DecryptFile.ts @@ -17,63 +17,8 @@ limitations under the License. // Pull in the encryption lib so that we can decrypt attachments. import encrypt from 'browser-encrypt-attachment'; import {mediaFromContent} from "../customisations/Media"; -import {IEncryptedFile} from "../customisations/models/IMediaEventContent"; - -// WARNING: We have to be very careful about what mime-types we allow into blobs, -// as for performance reasons these are now rendered via URL.createObjectURL() -// rather than by converting into data: URIs. -// -// This means that the content is rendered using the origin of the script which -// called createObjectURL(), and so if the content contains any scripting then it -// will pose a XSS vulnerability when the browser renders it. This is particularly -// bad if the user right-clicks the URI and pastes it into a new window or tab, -// as the blob will then execute with access to Element's full JS environment(!) -// -// See https://github.com/matrix-org/matrix-react-sdk/pull/1820#issuecomment-385210647 -// for details. -// -// We mitigate this by only allowing mime-types into blobs which we know don't -// contain any scripting, and instantiate all others as application/octet-stream -// regardless of what mime-type the event claimed. Even if the payload itself -// is some malicious HTML, the fact we instantiate it with a media mimetype or -// application/octet-stream means the browser doesn't try to render it as such. -// -// One interesting edge case is image/svg+xml, which empirically *is* rendered -// correctly if the blob is set to the src attribute of an img tag (for thumbnails) -// *even if the mimetype is application/octet-stream*. However, empirically JS -// in the SVG isn't executed in this scenario, so we seem to be okay. -// -// Tested on Chrome 65 and Firefox 60 -// -// The list below is taken mainly from -// https://developer.mozilla.org/en-US/docs/Web/HTML/Supported_media_formats -// N.B. Matrix doesn't currently specify which mimetypes are valid in given -// events, so we pick the ones which HTML5 browsers should be able to display -// -// For the record, mime-types which must NEVER enter this list below include: -// text/html, text/xhtml, image/svg, image/svg+xml, image/pdf, and similar. - -const ALLOWED_BLOB_MIMETYPES = [ - 'image/jpeg', - 'image/gif', - 'image/png', - - 'video/mp4', - 'video/webm', - 'video/ogg', - - 'audio/mp4', - 'audio/webm', - 'audio/aac', - 'audio/mpeg', - 'audio/ogg', - 'audio/wave', - 'audio/wav', - 'audio/x-wav', - 'audio/x-pn-wav', - 'audio/flac', - 'audio/x-flac', -]; +import { IEncryptedFile } from "../customisations/models/IMediaEventContent"; +import { getBlobSafeMimeType } from "./blobs"; /** * Decrypt a file attached to a matrix event. @@ -100,9 +45,7 @@ export function decryptFile(file: IEncryptedFile): Promise { // browser (e.g. by copying the URI into a new tab or window.) // See warning at top of file. let mimetype = file.mimetype ? file.mimetype.split(";")[0].trim() : ''; - if (!ALLOWED_BLOB_MIMETYPES.includes(mimetype)) { - mimetype = 'application/octet-stream'; - } + mimetype = getBlobSafeMimeType(mimetype); return new Blob([dataArray], {type: mimetype}); }); diff --git a/src/utils/ErrorUtils.js b/src/utils/ErrorUtils.js index 2c6acd5503..b5bd5b0af0 100644 --- a/src/utils/ErrorUtils.js +++ b/src/utils/ErrorUtils.js @@ -49,12 +49,6 @@ export function messageForResourceLimitError(limitType, adminContact, strings, e } } -export function messageForSendError(errorData) { - if (errorData.errcode === "M_TOO_LARGE") { - return _t("The message you are trying to send is too large."); - } -} - export function messageForSyncError(err) { if (err.errcode === 'M_RESOURCE_LIMIT_EXCEEDED') { const limitError = messageForResourceLimitError( diff --git a/src/utils/MatrixGlob.js b/src/utils/MatrixGlob.ts similarity index 100% rename from src/utils/MatrixGlob.js rename to src/utils/MatrixGlob.ts diff --git a/src/utils/MegolmExportEncryption.js b/src/utils/MegolmExportEncryption.js index be7472901a..6f5c7104b1 100644 --- a/src/utils/MegolmExportEncryption.js +++ b/src/utils/MegolmExportEncryption.js @@ -310,8 +310,7 @@ function unpackMegolmKeyFile(data) { // look for the end line while (1) { const lineEnd = fileStr.indexOf('\n', lineStart); - const line = fileStr.slice(lineStart, lineEnd < 0 ? undefined : lineEnd) - .trim(); + const line = fileStr.slice(lineStart, lineEnd < 0 ? undefined : lineEnd).trim(); if (line === TRAILER_LINE) { break; } diff --git a/src/utils/Mouse.ts b/src/utils/Mouse.ts new file mode 100644 index 0000000000..a85c6492c4 --- /dev/null +++ b/src/utils/Mouse.ts @@ -0,0 +1,50 @@ +/* +Copyright 2021 Šimon Brandner + +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. +*/ + +/** + * Different browsers use different deltaModes. This causes different behaviour. + * To avoid that we use this function to convert any event to pixels. + * @param {WheelEvent} event to normalize + * @returns {WheelEvent} normalized event event + */ +export function normalizeWheelEvent(event: WheelEvent): WheelEvent { + const LINE_HEIGHT = 18; + + let deltaX; + let deltaY; + let deltaZ; + + if (event.deltaMode === 1) { // Units are lines + deltaX = (event.deltaX * LINE_HEIGHT); + deltaY = (event.deltaY * LINE_HEIGHT); + deltaZ = (event.deltaZ * LINE_HEIGHT); + } else { + deltaX = event.deltaX; + deltaY = event.deltaY; + deltaZ = event.deltaZ; + } + + return new WheelEvent( + "syntheticWheel", + { + deltaMode: 0, + deltaY: deltaY, + deltaX: deltaX, + deltaZ: deltaZ, + ...event, + }, + ); +} diff --git a/src/utils/Singleflight.ts b/src/utils/Singleflight.ts new file mode 100644 index 0000000000..c2a564ea3e --- /dev/null +++ b/src/utils/Singleflight.ts @@ -0,0 +1,126 @@ +/* +Copyright 2021 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 {EnhancedMap} from "./maps"; + +// Inspired by https://pkg.go.dev/golang.org/x/sync/singleflight + +const keyMap = new EnhancedMap>(); + +/** + * Access class to get a singleflight context. Singleflights execute a + * function exactly once, unless instructed to forget about a result. + * + * Typically this is used to de-duplicate an action, such as a save button + * being pressed, without having to track state internally for an operation + * already being in progress. This doesn't expose a flag which can be used + * to disable a button, however it would be capable of returning a Promise + * from the first call. + * + * The result of the function call is cached indefinitely, just in case a + * second call comes through late. There are various functions named "forget" + * to have the cache be cleared of a result. + * + * Singleflights in our usecase are tied to an instance of something, combined + * with a string key to differentiate between multiple possible actions. This + * means that a "save" key will be scoped to the instance which defined it and + * not leak between other instances. This is done to avoid having to concatenate + * variables to strings to essentially namespace the field, for most cases. + */ +export class Singleflight { + private constructor() { + } + + /** + * A void marker to help with returning a value in a singleflight context. + * If your code doesn't return anything, return this instead. + */ + public static Void = Symbol("void"); + + /** + * Acquire a singleflight context. + * @param {Object} instance An instance to associate the context with. Can be any object. + * @param {string} key A string key relevant to that instance to namespace under. + * @returns {SingleflightContext} Returns the context to execute the function. + */ + public static for(instance: Object, key: string): SingleflightContext { + if (!instance || !key) throw new Error("An instance and key must be supplied"); + return new SingleflightContext(instance, key); + } + + /** + * Forgets all results for a given instance. + * @param {Object} instance The instance to forget about. + */ + public static forgetAllFor(instance: Object) { + keyMap.delete(instance); + } + + /** + * Forgets all cached results for all instances. Intended for use by tests. + */ + public static forgetAll() { + for (const k of keyMap.keys()) { + keyMap.remove(k); + } + } +} + +class SingleflightContext { + public constructor(private instance: Object, private key: string) { + } + + /** + * Forget this particular instance and key combination, discarding the result. + */ + public forget() { + const map = keyMap.get(this.instance); + if (!map) return; + map.remove(this.key); + if (!map.size) keyMap.remove(this.instance); + } + + /** + * Execute a function. If a result is already known, that will be returned instead + * of executing the provided function. However, if no result is known then the function + * will be called, with its return value cached. The function must return a value + * other than `undefined` - take a look at Singleflight.Void if you don't have a return + * to make. + * + * Note that this technically allows the caller to provide a different function each time: + * this is largely considered a bad idea and should not be done. Singleflights work off the + * premise that something needs to happen once, so duplicate executions will be ignored. + * + * For ideal performance and behaviour, functions which return promises are preferred. If + * a function is not returning a promise, it should return as soon as possible to avoid a + * second call potentially racing it. The promise returned by this function will be that + * of the first execution of the function, even on duplicate calls. + * @param {Function} fn The function to execute. + * @returns The recorded value. + */ + public do(fn: () => T): T { + const map = keyMap.getOrCreate(this.instance, new EnhancedMap()); + + // We have to manually getOrCreate() because we need to execute the fn + let val = map.get(this.key); + if (val === undefined) { + val = fn(); + map.set(this.key, val); + } + + return val; + } +} diff --git a/src/utils/StorageManager.js b/src/utils/StorageManager.ts similarity index 96% rename from src/utils/StorageManager.js rename to src/utils/StorageManager.ts index 23c27a2d1c..883c032771 100644 --- a/src/utils/StorageManager.js +++ b/src/utils/StorageManager.ts @@ -1,5 +1,5 @@ /* -Copyright 2019 New Vector Ltd +Copyright 2019-2021 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. @@ -32,15 +32,15 @@ try { const SYNC_STORE_NAME = "riot-web-sync"; const CRYPTO_STORE_NAME = "matrix-js-sdk:crypto"; -function log(msg) { +function log(msg: string) { console.log(`StorageManager: ${msg}`); } -function error(msg) { - console.error(`StorageManager: ${msg}`); +function error(msg: string, ...args: string[]) { + console.error(`StorageManager: ${msg}`, ...args); } -function track(action) { +function track(action: string) { Analytics.trackEvent("StorageManager", action); } @@ -73,7 +73,7 @@ export async function checkConsistency() { dataInLocalStorage = localStorage.length > 0; log(`Local storage contains data? ${dataInLocalStorage}`); - cryptoInited = localStorage.getItem("mx_crypto_initialised"); + cryptoInited = !!localStorage.getItem("mx_crypto_initialised"); log(`Crypto initialised? ${cryptoInited}`); } else { healthy = false; diff --git a/src/utils/Timer.js b/src/utils/Timer.ts similarity index 60% rename from src/utils/Timer.js rename to src/utils/Timer.ts index ca06237fbf..9760631d09 100644 --- a/src/utils/Timer.js +++ b/src/utils/Timer.ts @@ -1,5 +1,5 @@ /* -Copyright 2018 New Vector Ltd +Copyright 2018, 2021 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. @@ -26,44 +26,48 @@ Once a timer is finished or aborted, it can't be started again a new one through `clone()` or `cloneIfRun()`. */ export default class Timer { - constructor(timeout) { - this._timeout = timeout; - this._onTimeout = this._onTimeout.bind(this); - this._setNotStarted(); + private timerHandle: NodeJS.Timeout; + private startTs: number; + private promise: Promise; + private resolve: () => void; + private reject: (Error) => void; + + constructor(private timeout: number) { + this.setNotStarted(); } - _setNotStarted() { - this._timerHandle = null; - this._startTs = null; - this._promise = new Promise((resolve, reject) => { - this._resolve = resolve; - this._reject = reject; + private setNotStarted() { + this.timerHandle = null; + this.startTs = null; + this.promise = new Promise((resolve, reject) => { + this.resolve = resolve; + this.reject = reject; }).finally(() => { - this._timerHandle = null; + this.timerHandle = null; }); } - _onTimeout() { + private onTimeout = () => { const now = Date.now(); - const elapsed = now - this._startTs; - if (elapsed >= this._timeout) { - this._resolve(); - this._setNotStarted(); + const elapsed = now - this.startTs; + if (elapsed >= this.timeout) { + this.resolve(); + this.setNotStarted(); } else { - const delta = this._timeout - elapsed; - this._timerHandle = setTimeout(this._onTimeout, delta); + const delta = this.timeout - elapsed; + this.timerHandle = setTimeout(this.onTimeout, delta); } } - changeTimeout(timeout) { - if (timeout === this._timeout) { + changeTimeout(timeout: number) { + if (timeout === this.timeout) { return; } - const isSmallerTimeout = timeout < this._timeout; - this._timeout = timeout; + const isSmallerTimeout = timeout < this.timeout; + this.timeout = timeout; if (this.isRunning() && isSmallerTimeout) { - clearTimeout(this._timerHandle); - this._onTimeout(); + clearTimeout(this.timerHandle); + this.onTimeout(); } } @@ -73,8 +77,8 @@ export default class Timer { */ start() { if (!this.isRunning()) { - this._startTs = Date.now(); - this._timerHandle = setTimeout(this._onTimeout, this._timeout); + this.startTs = Date.now(); + this.timerHandle = setTimeout(this.onTimeout, this.timeout); } return this; } @@ -89,7 +93,7 @@ export default class Timer { // can be called in fast succession, // instead just take note and compare // when the already running timeout expires - this._startTs = Date.now(); + this.startTs = Date.now(); return this; } else { return this.start(); @@ -103,9 +107,9 @@ export default class Timer { */ abort() { if (this.isRunning()) { - clearTimeout(this._timerHandle); - this._reject(new Error("Timer was aborted.")); - this._setNotStarted(); + clearTimeout(this.timerHandle); + this.reject(new Error("Timer was aborted.")); + this.setNotStarted(); } return this; } @@ -116,10 +120,10 @@ export default class Timer { *@return {Promise} */ finished() { - return this._promise; + return this.promise; } isRunning() { - return this._timerHandle !== null; + return this.timerHandle !== null; } } diff --git a/src/utils/TypeUtils.js b/src/utils/TypeUtils.ts similarity index 100% rename from src/utils/TypeUtils.js rename to src/utils/TypeUtils.ts diff --git a/src/utils/arrays.ts b/src/utils/arrays.ts index 52308937f7..e527f43c29 100644 --- a/src/utils/arrays.ts +++ b/src/utils/arrays.ts @@ -1,5 +1,5 @@ /* -Copyright 2020 The Matrix.org Foundation C.I.C. +Copyright 2020, 2021 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. @@ -14,25 +14,95 @@ See the License for the specific language governing permissions and limitations under the License. */ +import {percentageOf, percentageWithin} from "./numbers"; + /** - * Quickly resample an array to have less data points. This isn't a perfect representation, - * though this does work best if given a large array to downsample to a much smaller array. - * @param {number[]} input The input array to downsample. + * Quickly resample an array to have less/more data points. If an input which is larger + * than the desired size is provided, it will be downsampled. Similarly, if the input + * is smaller than the desired size then it will be upsampled. + * @param {number[]} input The input array to resample. * @param {number} points The number of samples to end up with. - * @returns {number[]} The downsampled array. + * @returns {number[]} The resampled array. */ export function arrayFastResample(input: number[], points: number): number[] { - // Heavily inpired by matrix-media-repo (used with permission) + if (input.length === points) return input; // short-circuit a complicated call + + // Heavily inspired by matrix-media-repo (used with permission) // https://github.com/turt2live/matrix-media-repo/blob/abe72c87d2e29/util/util_audio/fastsample.go#L10 - const everyNth = Math.round(input.length / points); const samples: number[] = []; - for (let i = 0; i < input.length; i += everyNth) { - samples.push(input[i]); + if (input.length > points) { + // Danger: this loop can cause out of memory conditions if the input is too small. + const everyNth = Math.round(input.length / points); + for (let i = 0; i < input.length; i += everyNth) { + samples.push(input[i]); + } + } else { + // Smaller inputs mean we have to spread the values over the desired length. We + // end up overshooting the target length in doing this, but we're not looking to + // be super accurate so we'll let the sanity trims do their job. + const spreadFactor = Math.ceil(points / input.length); + for (const val of input) { + samples.push(...arraySeed(val, spreadFactor)); + } } - while (samples.length < points) { - samples.push(input[input.length - 1]); + + // Trim to size & return + return arrayTrimFill(samples, points, arraySeed(input[input.length - 1], points)); +} + +/** + * Attempts a smooth resample of the given array. This is functionally similar to arrayFastResample + * though can take longer due to the smoothing of data. + * @param {number[]} input The input array to resample. + * @param {number} points The number of samples to end up with. + * @returns {number[]} The resampled array. + */ +export function arraySmoothingResample(input: number[], points: number): number[] { + if (input.length === points) return input; // short-circuit a complicated call + + let samples: number[] = []; + if (input.length > points) { + // We're downsampling. To preserve the curve we'll actually reduce our sample + // selection and average some points between them. + + // All we're doing here is repeatedly averaging the waveform down to near our + // target value. We don't average down to exactly our target as the loop might + // never end, and we can over-average the data. Instead, we'll get as far as + // we can and do a followup fast resample (the neighbouring points will be close + // to the actual waveform, so we can get away with this safely). + while (samples.length > (points * 2) || samples.length === 0) { + samples = []; + for (let i = 1; i < input.length - 1; i += 2) { + const prevPoint = input[i - 1]; + const nextPoint = input[i + 1]; + const currPoint = input[i]; + const average = (prevPoint + nextPoint + currPoint) / 3; + samples.push(average); + } + input = samples; + } + + return arrayFastResample(samples, points); + } else { + // In practice there's not much purpose in burning CPU for short arrays only to + // end up with a result that can't possibly look much different than the fast + // resample, so just skip ahead to the fast resample. + return arrayFastResample(input, points); } - return samples; +} + +/** + * Rescales the input array to have values that are inclusively within the provided + * minimum and maximum. + * @param {number[]} input The array to rescale. + * @param {number} newMin The minimum value to scale to. + * @param {number} newMax The maximum value to scale to. + * @returns {number[]} The rescaled array. + */ +export function arrayRescale(input: number[], newMin: number, newMax: number): number[] { + const min: number = Math.min(...input); + const max: number = Math.max(...input); + return input.map(v => percentageWithin(percentageOf(v, min, max), newMin, newMax)); } /** @@ -49,12 +119,32 @@ export function arraySeed(val: T, length: number): T[] { return a; } +/** + * Trims or fills the array to ensure it meets the desired length. The seed array + * given is pulled from to fill any missing slots - it is recommended that this be + * at least `len` long. The resulting array will be exactly `len` long, either + * trimmed from the source or filled with the some/all of the seed array. + * @param {T[]} a The array to trim/fill. + * @param {number} len The length to trim or fill to, as needed. + * @param {T[]} seed Values to pull from if the array needs filling. + * @returns {T[]} The resulting array of `len` length. + */ +export function arrayTrimFill(a: T[], len: number, seed: T[]): T[] { + // Dev note: we do length checks because the spread operator can result in some + // performance penalties in more critical code paths. As a utility, it should be + // as fast as possible to not cause a problem for the call stack, no matter how + // critical that stack is. + if (a.length === len) return a; + if (a.length > len) return a.slice(0, len); + return a.concat(seed.slice(0, len - a.length)); +} + /** * Clones an array as fast as possible, retaining references of the array's values. * @param a The array to clone. Must be defined. * @returns A copy of the array. */ -export function arrayFastClone(a: any[]): any[] { +export function arrayFastClone(a: T[]): T[] { return a.slice(0, a.length); } @@ -178,6 +268,13 @@ export class GroupedArray { constructor(private val: Map) { } + /** + * The value of this group, after all applicable alterations. + */ + public get value(): Map { + return this.val; + } + /** * Orders the grouping into an array using the provided key order. * @param keyOrder The key order. diff --git a/src/utils/blobs.ts b/src/utils/blobs.ts new file mode 100644 index 0000000000..4e073a3936 --- /dev/null +++ b/src/utils/blobs.ts @@ -0,0 +1,78 @@ +/* +Copyright 2021 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. +*/ + +// WARNING: We have to be very careful about what mime-types we allow into blobs, +// as for performance reasons these are now rendered via URL.createObjectURL() +// rather than by converting into data: URIs. +// +// This means that the content is rendered using the origin of the script which +// called createObjectURL(), and so if the content contains any scripting then it +// will pose a XSS vulnerability when the browser renders it. This is particularly +// bad if the user right-clicks the URI and pastes it into a new window or tab, +// as the blob will then execute with access to Element's full JS environment(!) +// +// See https://github.com/matrix-org/matrix-react-sdk/pull/1820#issuecomment-385210647 +// for details. +// +// We mitigate this by only allowing mime-types into blobs which we know don't +// contain any scripting, and instantiate all others as application/octet-stream +// regardless of what mime-type the event claimed. Even if the payload itself +// is some malicious HTML, the fact we instantiate it with a media mimetype or +// application/octet-stream means the browser doesn't try to render it as such. +// +// One interesting edge case is image/svg+xml, which empirically *is* rendered +// correctly if the blob is set to the src attribute of an img tag (for thumbnails) +// *even if the mimetype is application/octet-stream*. However, empirically JS +// in the SVG isn't executed in this scenario, so we seem to be okay. +// +// Tested on Chrome 65 and Firefox 60 +// +// The list below is taken mainly from +// https://developer.mozilla.org/en-US/docs/Web/HTML/Supported_media_formats +// N.B. Matrix doesn't currently specify which mimetypes are valid in given +// events, so we pick the ones which HTML5 browsers should be able to display +// +// For the record, mime-types which must NEVER enter this list below include: +// text/html, text/xhtml, image/svg, image/svg+xml, image/pdf, and similar. + +const ALLOWED_BLOB_MIMETYPES = [ + 'image/jpeg', + 'image/gif', + 'image/png', + + 'video/mp4', + 'video/webm', + 'video/ogg', + + 'audio/mp4', + 'audio/webm', + 'audio/aac', + 'audio/mpeg', + 'audio/ogg', + 'audio/wave', + 'audio/wav', + 'audio/x-wav', + 'audio/x-pn-wav', + 'audio/flac', + 'audio/x-flac', +]; + +export function getBlobSafeMimeType(mimetype: string): string { + if (!ALLOWED_BLOB_MIMETYPES.includes(mimetype)) { + return 'application/octet-stream'; + } + return mimetype; +} diff --git a/src/utils/enums.ts b/src/utils/enums.ts index f7f4787896..d3ca318c28 100644 --- a/src/utils/enums.ts +++ b/src/utils/enums.ts @@ -1,5 +1,5 @@ /* -Copyright 2020 The Matrix.org Foundation C.I.C. +Copyright 2020, 2021 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. @@ -19,11 +19,23 @@ limitations under the License. * @param e The enum. * @returns The enum values. */ -export function getEnumValues(e: any): T[] { +export function getEnumValues(e: any): (string | number)[] { + // String-based enums will simply be objects ({Key: "value"}), but number-based + // enums will instead map themselves twice: in one direction for {Key: 12} and + // the reverse for easy lookup, presumably ({12: Key}). In the reverse mapping, + // the key is a string, not a number. + // + // For this reason, we try to determine what kind of enum we're dealing with. + const keys = Object.keys(e); - return keys - .filter(k => ['string', 'number'].includes(typeof(e[k]))) - .map(k => e[k]); + const values: (string | number)[] = []; + for (const key of keys) { + const value = e[key]; + if (Number.isFinite(value) || e[value.toString()] !== Number(key)) { + values.push(value); + } + } + return values; } /** diff --git a/src/utils/objects.ts b/src/utils/objects.ts index e7f4f0f907..2c9361beba 100644 --- a/src/utils/objects.ts +++ b/src/utils/objects.ts @@ -1,5 +1,5 @@ /* -Copyright 2020 The Matrix.org Foundation C.I.C. +Copyright 2020, 2021 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. @@ -141,3 +141,21 @@ export function objectKeyChanges(a: O, b: O): (keyof O)[] { export function objectClone(obj: O): O { return JSON.parse(JSON.stringify(obj)); } + +/** + * Converts a series of entries to an object. + * @param entries The entries to convert. + * @returns The converted object. + */ +// NOTE: Deprecated once we have Object.fromEntries() support. +// @ts-ignore - return type is complaining about non-string keys, but we know better +export function objectFromEntries(entries: Iterable<[K, V]>): {[k: K]: V} { + const obj: { + // @ts-ignore - same as return type + [k: K]: V} = {}; + for (const e of entries) { + // @ts-ignore - same as return type + obj[e[0]] = e[1]; + } + return obj; +} diff --git a/src/utils/permalinks/ElementPermalinkConstructor.js b/src/utils/permalinks/ElementPermalinkConstructor.ts similarity index 82% rename from src/utils/permalinks/ElementPermalinkConstructor.js rename to src/utils/permalinks/ElementPermalinkConstructor.ts index da7f5797ea..cd7f2b9d2c 100644 --- a/src/utils/permalinks/ElementPermalinkConstructor.js +++ b/src/utils/permalinks/ElementPermalinkConstructor.ts @@ -20,31 +20,31 @@ import PermalinkConstructor, {PermalinkParts} from "./PermalinkConstructor"; * Generates permalinks that self-reference the running webapp */ export default class ElementPermalinkConstructor extends PermalinkConstructor { - _elementUrl: string; + private elementUrl: string; constructor(elementUrl: string) { super(); - this._elementUrl = elementUrl; + this.elementUrl = elementUrl; - if (!this._elementUrl.startsWith("http:") && !this._elementUrl.startsWith("https:")) { + if (!this.elementUrl.startsWith("http:") && !this.elementUrl.startsWith("https:")) { throw new Error("Element prefix URL does not appear to be an HTTP(S) URL"); } } forEvent(roomId: string, eventId: string, serverCandidates: string[]): string { - return `${this._elementUrl}/#/room/${roomId}/${eventId}${this.encodeServerCandidates(serverCandidates)}`; + return `${this.elementUrl}/#/room/${roomId}/${eventId}${this.encodeServerCandidates(serverCandidates)}`; } - forRoom(roomIdOrAlias: string, serverCandidates: string[]): string { - return `${this._elementUrl}/#/room/${roomIdOrAlias}${this.encodeServerCandidates(serverCandidates)}`; + forRoom(roomIdOrAlias: string, serverCandidates?: string[]): string { + return `${this.elementUrl}/#/room/${roomIdOrAlias}${this.encodeServerCandidates(serverCandidates)}`; } forUser(userId: string): string { - return `${this._elementUrl}/#/user/${userId}`; + return `${this.elementUrl}/#/user/${userId}`; } forGroup(groupId: string): string { - return `${this._elementUrl}/#/group/${groupId}`; + return `${this.elementUrl}/#/group/${groupId}`; } forEntity(entityId: string): string { @@ -58,11 +58,11 @@ export default class ElementPermalinkConstructor extends PermalinkConstructor { } isPermalinkHost(testHost: string): boolean { - const parsedUrl = new URL(this._elementUrl); + const parsedUrl = new URL(this.elementUrl); return testHost === (parsedUrl.host || parsedUrl.hostname); // one of the hosts should match } - encodeServerCandidates(candidates: string[]) { + encodeServerCandidates(candidates?: string[]) { if (!candidates || candidates.length === 0) return ''; return `?via=${candidates.map(c => encodeURIComponent(c)).join("&via=")}`; } @@ -71,11 +71,11 @@ export default class ElementPermalinkConstructor extends PermalinkConstructor { // https://github.com/turt2live/matrix-js-bot-sdk/blob/7c4665c9a25c2c8e0fe4e509f2616505b5b66a1c/src/Permalinks.ts#L33-L61 // Adapted for Element's URL format parsePermalink(fullUrl: string): PermalinkParts { - if (!fullUrl || !fullUrl.startsWith(this._elementUrl)) { + if (!fullUrl || !fullUrl.startsWith(this.elementUrl)) { throw new Error("Does not appear to be a permalink"); } - const parts = fullUrl.substring(`${this._elementUrl}/#/`.length); + const parts = fullUrl.substring(`${this.elementUrl}/#/`.length); return ElementPermalinkConstructor.parseAppRoute(parts); } diff --git a/src/utils/permalinks/PermalinkConstructor.js b/src/utils/permalinks/PermalinkConstructor.ts similarity index 100% rename from src/utils/permalinks/PermalinkConstructor.js rename to src/utils/permalinks/PermalinkConstructor.ts diff --git a/src/utils/permalinks/Permalinks.js b/src/utils/permalinks/Permalinks.ts similarity index 75% rename from src/utils/permalinks/Permalinks.js rename to src/utils/permalinks/Permalinks.ts index bcf4d87136..d87c826cc2 100644 --- a/src/utils/permalinks/Permalinks.js +++ b/src/utils/permalinks/Permalinks.ts @@ -17,6 +17,9 @@ limitations under the License. import isIp from "is-ip"; import * as utils from "matrix-js-sdk/src/utils"; import {Room} from "matrix-js-sdk/src/models/room"; +import {EventType} from "matrix-js-sdk/src/@types/event"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { RoomMember } from "matrix-js-sdk/src/models/room-member"; import {MatrixClientPeg} from "../../MatrixClientPeg"; import SpecPermalinkConstructor, {baseUrl as matrixtoBaseUrl} from "./SpecPermalinkConstructor"; @@ -74,29 +77,35 @@ const MAX_SERVER_CANDIDATES = 3; // the list and magically have the link work. export class RoomPermalinkCreator { + private room: Room; + private roomId: string; + private highestPlUserId: string; + private populationMap: { [serverName: string]: number }; + private bannedHostsRegexps: RegExp[]; + private allowedHostsRegexps: RegExp[]; + private _serverCandidates: string[]; + private started: boolean; + // We support being given a roomId as a fallback in the event the `room` object // doesn't exist or is not healthy for us to rely on. For example, loading a // permalink to a room which the MatrixClient doesn't know about. - constructor(room, roomId = null) { - this._room = room; - this._roomId = room ? room.roomId : roomId; - this._highestPlUserId = null; - this._populationMap = null; - this._bannedHostsRegexps = null; - this._allowedHostsRegexps = null; + constructor(room: Room, roomId: string = null) { + this.room = room; + this.roomId = room ? room.roomId : roomId; + this.highestPlUserId = null; + this.populationMap = null; + this.bannedHostsRegexps = null; + this.allowedHostsRegexps = null; this._serverCandidates = null; - this._started = false; + this.started = false; - if (!this._roomId) { + if (!this.roomId) { throw new Error("Failed to resolve a roomId for the permalink creator to use"); } - - this.onMembership = this.onMembership.bind(this); - this.onRoomState = this.onRoomState.bind(this); } load() { - if (!this._room || !this._room.currentState) { + if (!this.room || !this.room.currentState) { // Under rare and unknown circumstances it is possible to have a room with no // currentState, at least potentially at the early stages of joining a room. // To avoid breaking everything, we'll just warn rather than throw as well as @@ -104,23 +113,23 @@ export class RoomPermalinkCreator { console.warn("Tried to load a permalink creator with no room state"); return; } - this._updateAllowedServers(); - this._updateHighestPlUser(); - this._updatePopulationMap(); - this._updateServerCandidates(); + this.updateAllowedServers(); + this.updateHighestPlUser(); + this.updatePopulationMap(); + this.updateServerCandidates(); } start() { this.load(); - this._room.on("RoomMember.membership", this.onMembership); - this._room.on("RoomState.events", this.onRoomState); - this._started = true; + this.room.on("RoomMember.membership", this.onMembership); + this.room.on("RoomState.events", this.onRoomState); + this.started = true; } stop() { - this._room.removeListener("RoomMember.membership", this.onMembership); - this._room.removeListener("RoomState.events", this.onRoomState); - this._started = false; + this.room.removeListener("RoomMember.membership", this.onMembership); + this.room.removeListener("RoomState.events", this.onRoomState); + this.started = false; } get serverCandidates() { @@ -128,44 +137,44 @@ export class RoomPermalinkCreator { } isStarted() { - return this._started; + return this.started; } - forEvent(eventId) { - return getPermalinkConstructor().forEvent(this._roomId, eventId, this._serverCandidates); + forEvent(eventId: string): string { + return getPermalinkConstructor().forEvent(this.roomId, eventId, this._serverCandidates); } - forShareableRoom() { - if (this._room) { + forShareableRoom(): string { + if (this.room) { // Prefer to use canonical alias for permalink if possible - const alias = this._room.getCanonicalAlias(); + const alias = this.room.getCanonicalAlias(); if (alias) { return getPermalinkConstructor().forRoom(alias, this._serverCandidates); } } - return getPermalinkConstructor().forRoom(this._roomId, this._serverCandidates); + return getPermalinkConstructor().forRoom(this.roomId, this._serverCandidates); } - forRoom() { - return getPermalinkConstructor().forRoom(this._roomId, this._serverCandidates); + forRoom(): string { + return getPermalinkConstructor().forRoom(this.roomId, this._serverCandidates); } - onRoomState(event) { + private onRoomState = (event: MatrixEvent) => { switch (event.getType()) { - case "m.room.server_acl": - this._updateAllowedServers(); - this._updateHighestPlUser(); - this._updatePopulationMap(); - this._updateServerCandidates(); + case EventType.RoomServerAcl: + this.updateAllowedServers(); + this.updateHighestPlUser(); + this.updatePopulationMap(); + this.updateServerCandidates(); return; - case "m.room.power_levels": - this._updateHighestPlUser(); - this._updateServerCandidates(); + case EventType.RoomPowerLevels: + this.updateHighestPlUser(); + this.updateServerCandidates(); return; } } - onMembership(evt, member, oldMembership) { + private onMembership = (evt: MatrixEvent, member: RoomMember, oldMembership: string) => { const userId = member.userId; const membership = member.membership; const serverName = getServerName(userId); @@ -173,17 +182,17 @@ export class RoomPermalinkCreator { const hasLeft = oldMembership === "join" && membership !== "join"; if (hasLeft) { - this._populationMap[serverName]--; + this.populationMap[serverName]--; } else if (hasJoined) { - this._populationMap[serverName]++; + this.populationMap[serverName]++; } - this._updateHighestPlUser(); - this._updateServerCandidates(); + this.updateHighestPlUser(); + this.updateServerCandidates(); } - _updateHighestPlUser() { - const plEvent = this._room.currentState.getStateEvents("m.room.power_levels", ""); + private updateHighestPlUser() { + const plEvent = this.room.currentState.getStateEvents("m.room.power_levels", ""); if (plEvent) { const content = plEvent.getContent(); if (content) { @@ -191,14 +200,14 @@ export class RoomPermalinkCreator { if (users) { const entries = Object.entries(users); const allowedEntries = entries.filter(([userId]) => { - const member = this._room.getMember(userId); + const member = this.room.getMember(userId); if (!member || member.membership !== "join") { return false; } const serverName = getServerName(userId); return !isHostnameIpAddress(serverName) && - !isHostInRegex(serverName, this._bannedHostsRegexps) && - isHostInRegex(serverName, this._allowedHostsRegexps); + !isHostInRegex(serverName, this.bannedHostsRegexps) && + isHostInRegex(serverName, this.allowedHostsRegexps); }); const maxEntry = allowedEntries.reduce((max, entry) => { return (entry[1] > max[1]) ? entry : max; @@ -206,20 +215,20 @@ export class RoomPermalinkCreator { const [userId, powerLevel] = maxEntry; // object wasn't empty, and max entry wasn't a demotion from the default if (userId !== null && powerLevel >= 50) { - this._highestPlUserId = userId; + this.highestPlUserId = userId; return; } } } } - this._highestPlUserId = null; + this.highestPlUserId = null; } - _updateAllowedServers() { + private updateAllowedServers() { const bannedHostsRegexps = []; let allowedHostsRegexps = [new RegExp(".*")]; // default allow everyone - if (this._room.currentState) { - const aclEvent = this._room.currentState.getStateEvents("m.room.server_acl", ""); + if (this.room.currentState) { + const aclEvent = this.room.currentState.getStateEvents("m.room.server_acl", ""); if (aclEvent && aclEvent.getContent()) { const getRegex = (hostname) => new RegExp("^" + utils.globToRegexp(hostname, false) + "$"); @@ -231,35 +240,35 @@ export class RoomPermalinkCreator { allowed.forEach(h => allowedHostsRegexps.push(getRegex(h))); } } - this._bannedHostsRegexps = bannedHostsRegexps; - this._allowedHostsRegexps = allowedHostsRegexps; + this.bannedHostsRegexps = bannedHostsRegexps; + this.allowedHostsRegexps = allowedHostsRegexps; } - _updatePopulationMap() { + private updatePopulationMap() { const populationMap: { [server: string]: number } = {}; - for (const member of this._room.getJoinedMembers()) { + for (const member of this.room.getJoinedMembers()) { const serverName = getServerName(member.userId); if (!populationMap[serverName]) { populationMap[serverName] = 0; } populationMap[serverName]++; } - this._populationMap = populationMap; + this.populationMap = populationMap; } - _updateServerCandidates() { + private updateServerCandidates() { let candidates = []; - if (this._highestPlUserId) { - candidates.push(getServerName(this._highestPlUserId)); + if (this.highestPlUserId) { + candidates.push(getServerName(this.highestPlUserId)); } - const serversByPopulation = Object.keys(this._populationMap) - .sort((a, b) => this._populationMap[b] - this._populationMap[a]) + const serversByPopulation = Object.keys(this.populationMap) + .sort((a, b) => this.populationMap[b] - this.populationMap[a]) .filter(a => { return !candidates.includes(a) && !isHostnameIpAddress(a) && - !isHostInRegex(a, this._bannedHostsRegexps) && - isHostInRegex(a, this._allowedHostsRegexps); + !isHostInRegex(a, this.bannedHostsRegexps) && + isHostInRegex(a, this.allowedHostsRegexps); }); const remainingServers = serversByPopulation.slice(0, MAX_SERVER_CANDIDATES - candidates.length); @@ -273,11 +282,11 @@ export function makeGenericPermalink(entityId: string): string { return getPermalinkConstructor().forEntity(entityId); } -export function makeUserPermalink(userId) { +export function makeUserPermalink(userId: string): string { return getPermalinkConstructor().forUser(userId); } -export function makeRoomPermalink(roomId) { +export function makeRoomPermalink(roomId: string): string { if (!roomId) { throw new Error("can't permalink a falsey roomId"); } @@ -296,7 +305,7 @@ export function makeRoomPermalink(roomId) { return permalinkCreator.forRoom(); } -export function makeGroupPermalink(groupId) { +export function makeGroupPermalink(groupId: string): string { return getPermalinkConstructor().forGroup(groupId); } @@ -337,9 +346,14 @@ export function tryTransformPermalinkToLocalHref(permalink: string): string { return permalink; } - const m = permalink.match(matrixLinkify.ELEMENT_URL_PATTERN); - if (m) { - return m[1]; + try { + const m = decodeURIComponent(permalink).match(matrixLinkify.ELEMENT_URL_PATTERN); + if (m) { + return m[1]; + } + } catch (e) { + // Not a valid URI + return permalink; } // A bit of a hack to convert permalinks of unknown origin to Element links @@ -402,8 +416,8 @@ function getPermalinkConstructor(): PermalinkConstructor { export function parsePermalink(fullUrl: string): PermalinkParts { const elementPrefix = SdkConfig.get()['permalinkPrefix']; - if (fullUrl.startsWith(matrixtoBaseUrl)) { - return new SpecPermalinkConstructor().parsePermalink(fullUrl); + if (decodeURIComponent(fullUrl).startsWith(matrixtoBaseUrl)) { + return new SpecPermalinkConstructor().parsePermalink(decodeURIComponent(fullUrl)); } else if (elementPrefix && fullUrl.startsWith(elementPrefix)) { return new ElementPermalinkConstructor(elementPrefix).parsePermalink(fullUrl); } @@ -428,24 +442,24 @@ export function parseAppLocalLink(localLink: string): PermalinkParts { return null; } -function getServerName(userId) { +function getServerName(userId: string): string { return userId.split(":").splice(1).join(":"); } -function getHostnameFromMatrixDomain(domain) { +function getHostnameFromMatrixDomain(domain: string): string { if (!domain) return null; return new URL(`https://${domain}`).hostname; } -function isHostInRegex(hostname, regexps) { +function isHostInRegex(hostname: string, regexps: RegExp[]) { hostname = getHostnameFromMatrixDomain(hostname); if (!hostname) return true; // assumed - if (regexps.length > 0 && !regexps[0].test) throw new Error(regexps[0]); + if (regexps.length > 0 && !regexps[0].test) throw new Error(regexps[0].toString()); return regexps.filter(h => h.test(hostname)).length > 0; } -function isHostnameIpAddress(hostname) { +function isHostnameIpAddress(hostname: string): boolean { hostname = getHostnameFromMatrixDomain(hostname); if (!hostname) return false; diff --git a/src/utils/permalinks/SpecPermalinkConstructor.js b/src/utils/permalinks/SpecPermalinkConstructor.ts similarity index 100% rename from src/utils/permalinks/SpecPermalinkConstructor.js rename to src/utils/permalinks/SpecPermalinkConstructor.ts diff --git a/src/utils/space.tsx b/src/utils/space.tsx index 3f2b6f9bb4..c14dc988d2 100644 --- a/src/utils/space.tsx +++ b/src/utils/space.tsx @@ -83,6 +83,7 @@ export const showCreateNewRoom = async (cli: MatrixClient, space: Room) => { if (shouldCreate) { await createRoom(opts); } + return shouldCreate; }; export const showSpaceInvite = (space: Room, initialText = "") => { diff --git a/src/verification.js b/src/verification.ts similarity index 62% rename from src/verification.js rename to src/verification.ts index 74e3897d3a..acd9f6d2b2 100644 --- a/src/verification.js +++ b/src/verification.ts @@ -1,5 +1,5 @@ /* -Copyright 2019, 2020 The Matrix.org Foundation C.I.C. +Copyright 2019, 2020, 2021 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. @@ -14,16 +14,19 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {MatrixClientPeg} from './MatrixClientPeg'; +import { User } from "matrix-js-sdk/src/models/user"; + +import { MatrixClientPeg } from './MatrixClientPeg'; import dis from "./dispatcher/dispatcher"; import Modal from './Modal'; import * as sdk from './index'; -import { _t } from './languageHandler'; -import {RightPanelPhases} from "./stores/RightPanelStorePhases"; -import {findDMForUser} from './createRoom'; -import {accessSecretStorage} from './SecurityManager'; -import {verificationMethods} from 'matrix-js-sdk/src/crypto'; -import {Action} from './dispatcher/actions'; +import { RightPanelPhases } from "./stores/RightPanelStorePhases"; +import { findDMForUser } from './createRoom'; +import { accessSecretStorage } from './SecurityManager'; +import { verificationMethods } from 'matrix-js-sdk/src/crypto'; +import { Action } from './dispatcher/actions'; +import UntrustedDeviceDialog from "./components/views/dialogs/UntrustedDeviceDialog"; +import {IDevice} from "./components/views/right_panel/UserInfo"; async function enable4SIfNeeded() { const cli = MatrixClientPeg.get(); @@ -39,40 +42,7 @@ async function enable4SIfNeeded() { return true; } -function UntrustedDeviceDialog(props) { - const {device, user, onFinished} = props; - const BaseDialog = sdk.getComponent("dialogs.BaseDialog"); - const AccessibleButton = sdk.getComponent("elements.AccessibleButton"); - let askToVerifyText; - let newSessionText; - - if (MatrixClientPeg.get().getUserId() === user.userId) { - newSessionText = _t("You signed in to a new session without verifying it:"); - askToVerifyText = _t("Verify your other session using one of the options below."); - } else { - newSessionText = _t("%(name)s (%(userId)s) signed in to a new session without verifying it:", - {name: user.displayName, userId: user.userId}); - askToVerifyText = _t("Ask this user to verify their session, or manually verify it below."); - } - - return -
    -

    {newSessionText}

    -

    {device.getDisplayName()} ({device.deviceId})

    -

    {askToVerifyText}

    -
    -
    - onFinished("legacy")}>{_t("Manually Verify by Text")} - onFinished("sas")}>{_t("Interactively verify by Emoji")} - onFinished()}>{_t("Done")} -
    -
    ; -} - -export async function verifyDevice(user, device) { +export async function verifyDevice(user: User, device: IDevice) { const cli = MatrixClientPeg.get(); if (cli.isGuest()) { dis.dispatch({action: 'require_registration'}); @@ -115,7 +85,7 @@ export async function verifyDevice(user, device) { }); } -export async function legacyVerifyUser(user) { +export async function legacyVerifyUser(user: User) { const cli = MatrixClientPeg.get(); if (cli.isGuest()) { dis.dispatch({action: 'require_registration'}); @@ -135,7 +105,7 @@ export async function legacyVerifyUser(user) { }); } -export async function verifyUser(user) { +export async function verifyUser(user: User) { const cli = MatrixClientPeg.get(); if (cli.isGuest()) { dis.dispatch({action: 'require_registration'}); @@ -155,7 +125,7 @@ export async function verifyUser(user) { }); } -export function pendingVerificationRequestForUser(user) { +export function pendingVerificationRequestForUser(user: User) { const cli = MatrixClientPeg.get(); const dmRoom = findDMForUser(cli, user.userId); if (dmRoom) { diff --git a/src/voice/Playback.ts b/src/voice/Playback.ts new file mode 100644 index 0000000000..61da435151 --- /dev/null +++ b/src/voice/Playback.ts @@ -0,0 +1,186 @@ +/* +Copyright 2021 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 EventEmitter from "events"; +import { UPDATE_EVENT } from "../stores/AsyncStore"; +import { arrayFastResample, arrayRescale, arraySeed, arraySmoothingResample } from "../utils/arrays"; +import { SimpleObservable } from "matrix-widget-api"; +import { IDestroyable } from "../utils/IDestroyable"; +import { PlaybackClock } from "./PlaybackClock"; +import { createAudioContext, decodeOgg } from "./compat"; +import { clamp } from "../utils/numbers"; + +export enum PlaybackState { + Decoding = "decoding", + Stopped = "stopped", // no progress on timeline + Paused = "paused", // some progress on timeline + Playing = "playing", // active progress through timeline +} + +export const PLAYBACK_WAVEFORM_SAMPLES = 39; +const DEFAULT_WAVEFORM = arraySeed(0, PLAYBACK_WAVEFORM_SAMPLES); + +function makePlaybackWaveform(input: number[]): number[] { + // First, convert negative amplitudes to positive so we don't detect zero as "noisy". + const noiseWaveform = input.map(v => Math.abs(v)); + + // Next, we'll resample the waveform using a smoothing approach so we can keep the same rough shape. + // We also rescale the waveform to be 0-1 for the remaining function logic. + const resampled = arrayRescale(arraySmoothingResample(noiseWaveform, PLAYBACK_WAVEFORM_SAMPLES), 0, 1); + + // Then, we'll do a high and low pass filter to isolate actual speaking volumes within the rescaled + // waveform. Most speech happens below the 0.5 mark. + const filtered = resampled.map(v => clamp(v, 0.1, 0.5)); + + // Finally, we'll rescale the filtered waveform (0.1-0.5 becomes 0-1 again) so the user sees something + // sensible. This is what we return to keep our contract of "values between zero and one". + return arrayRescale(filtered, 0, 1); +} + +export class Playback extends EventEmitter implements IDestroyable { + private readonly context: AudioContext; + private source: AudioBufferSourceNode; + private state = PlaybackState.Decoding; + private audioBuf: AudioBuffer; + private resampledWaveform: number[]; + private waveformObservable = new SimpleObservable(); + private readonly clock: PlaybackClock; + + /** + * Creates a new playback instance from a buffer. + * @param {ArrayBuffer} buf The buffer containing the sound sample. + * @param {number[]} seedWaveform Optional seed waveform to present until the proper waveform + * can be calculated. Contains values between zero and one, inclusive. + */ + constructor(private buf: ArrayBuffer, seedWaveform = DEFAULT_WAVEFORM) { + super(); + this.context = createAudioContext(); + this.resampledWaveform = arrayFastResample(seedWaveform ?? DEFAULT_WAVEFORM, PLAYBACK_WAVEFORM_SAMPLES); + this.waveformObservable.update(this.resampledWaveform); + this.clock = new PlaybackClock(this.context); + } + + /** + * Stable waveform for the playback. Values are guaranteed to be between + * zero and one, inclusive. + */ + public get waveform(): number[] { + return this.resampledWaveform; + } + + public get waveformData(): SimpleObservable { + return this.waveformObservable; + } + + public get clockInfo(): PlaybackClock { + return this.clock; + } + + public get currentState(): PlaybackState { + return this.state; + } + + public get isPlaying(): boolean { + return this.currentState === PlaybackState.Playing; + } + + public emit(event: PlaybackState, ...args: any[]): boolean { + this.state = event; + super.emit(event, ...args); + super.emit(UPDATE_EVENT, event, ...args); + return true; // we don't ever care if the event had listeners, so just return "yes" + } + + public destroy() { + // noinspection JSIgnoredPromiseFromCall - not concerned about being called async here + this.stop(); + this.removeAllListeners(); + this.clock.destroy(); + this.waveformObservable.close(); + } + + public async prepare() { + // Safari compat: promise API not supported on this function + this.audioBuf = await new Promise((resolve, reject) => { + this.context.decodeAudioData(this.buf, b => resolve(b), async e => { + // This error handler is largely for Safari as well, which doesn't support Opus/Ogg + // very well. + console.error("Error decoding recording: ", e); + console.warn("Trying to re-encode to WAV instead..."); + + const wav = await decodeOgg(this.buf); + + // noinspection ES6MissingAwait - not needed when using callbacks + this.context.decodeAudioData(wav, b => resolve(b), e => { + console.error("Still failed to decode recording: ", e); + reject(e); + }); + }); + }); + + // Update the waveform to the real waveform once we have channel data to use. We don't + // exactly trust the user-provided waveform to be accurate... + const waveform = Array.from(this.audioBuf.getChannelData(0)); + this.resampledWaveform = makePlaybackWaveform(waveform); + this.waveformObservable.update(this.resampledWaveform); + + this.emit(PlaybackState.Stopped); // signal that we're not decoding anymore + this.clock.flagLoadTime(); // must happen first because setting the duration fires a clock update + this.clock.durationSeconds = this.audioBuf.duration; + } + + private onPlaybackEnd = async () => { + await this.context.suspend(); + this.emit(PlaybackState.Stopped); + }; + + public async play() { + // We can't restart a buffer source, so we need to create a new one if we hit the end + if (this.state === PlaybackState.Stopped) { + if (this.source) { + this.source.disconnect(); + this.source.removeEventListener("ended", this.onPlaybackEnd); + } + + this.source = this.context.createBufferSource(); + this.source.connect(this.context.destination); + this.source.buffer = this.audioBuf; + this.source.start(); // start immediately + this.source.addEventListener("ended", this.onPlaybackEnd); + } + + // We use the context suspend/resume functions because it allows us to pause a source + // node, but that still doesn't help us when the source node runs out (see above). + await this.context.resume(); + this.clock.flagStart(); + this.emit(PlaybackState.Playing); + } + + public async pause() { + await this.context.suspend(); + this.emit(PlaybackState.Paused); + } + + public async stop() { + await this.onPlaybackEnd(); + this.clock.flagStop(); + } + + public async toggle() { + if (this.isPlaying) await this.pause(); + else await this.play(); + } +} diff --git a/src/voice/PlaybackClock.ts b/src/voice/PlaybackClock.ts new file mode 100644 index 0000000000..d6d36e861f --- /dev/null +++ b/src/voice/PlaybackClock.ts @@ -0,0 +1,87 @@ +/* +Copyright 2021 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 {SimpleObservable} from "matrix-widget-api"; +import {IDestroyable} from "../utils/IDestroyable"; + +// Because keeping track of time is sufficiently complicated... +export class PlaybackClock implements IDestroyable { + private clipStart = 0; + private stopped = true; + private lastCheck = 0; + private observable = new SimpleObservable(); + private timerId: number; + private clipDuration = 0; + + public constructor(private context: AudioContext) { + } + + public get durationSeconds(): number { + return this.clipDuration; + } + + public set durationSeconds(val: number) { + this.clipDuration = val; + this.observable.update([this.timeSeconds, this.clipDuration]); + } + + public get timeSeconds(): number { + return (this.context.currentTime - this.clipStart) % this.clipDuration; + } + + public get liveData(): SimpleObservable { + return this.observable; + } + + private checkTime = () => { + const now = this.timeSeconds; + if (this.lastCheck !== now) { + this.observable.update([now, this.durationSeconds]); + this.lastCheck = now; + } + }; + + /** + * Mark the time in the audio context where the clip starts/has been loaded. + * This is to ensure the clock isn't skewed into thinking it is ~0.5s into + * a clip when the duration is set. + */ + public flagLoadTime() { + this.clipStart = this.context.currentTime; + } + + public flagStart() { + if (this.stopped) { + this.clipStart = this.context.currentTime; + this.stopped = false; + } + + if (!this.timerId) { + // case to number because the types are wrong + // 100ms interval to make sure the time is as accurate as possible + this.timerId = setInterval(this.checkTime, 100); + } + } + + public flagStop() { + this.stopped = true; + } + + public destroy() { + this.observable.close(); + if (this.timerId) clearInterval(this.timerId); + } +} diff --git a/src/voice/RecorderWorklet.ts b/src/voice/RecorderWorklet.ts new file mode 100644 index 0000000000..7343d37066 --- /dev/null +++ b/src/voice/RecorderWorklet.ts @@ -0,0 +1,67 @@ +/* +Copyright 2021 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 {IAmplitudePayload, ITimingPayload, PayloadEvent, WORKLET_NAME} from "./consts"; +import {percentageOf} from "../utils/numbers"; + +// from AudioWorkletGlobalScope: https://developer.mozilla.org/en-US/docs/Web/API/AudioWorkletGlobalScope +declare const currentTime: number; +// declare const currentFrame: number; +// declare const sampleRate: number; + +class MxVoiceWorklet extends AudioWorkletProcessor { + private nextAmplitudeSecond = 0; + + process(inputs, outputs, parameters) { + // We only fire amplitude updates once a second to avoid flooding the recording instance + // with useless data. Much of the data would end up discarded, so we ratelimit ourselves + // here. + const currentSecond = Math.round(currentTime); + if (currentSecond === this.nextAmplitudeSecond) { + // We're expecting exactly one mono input source, so just grab the very first frame of + // samples for the analysis. + const monoChan = inputs[0][0]; + + // The amplitude of the frame's samples is effectively the loudness of the frame. This + // translates into a bar which can be rendered as part of the whole recording clip's + // waveform. + // + // We translate the amplitude down to 0-1 for sanity's sake. + const minVal = Math.min(...monoChan); + const maxVal = Math.max(...monoChan); + const amplitude = percentageOf(maxVal, -1, 1) - percentageOf(minVal, -1, 1); + + this.port.postMessage({ + ev: PayloadEvent.AmplitudeMark, + amplitude: amplitude, + forSecond: currentSecond, + }); + this.nextAmplitudeSecond++; + } + + // We mostly use this worklet to fire regular clock updates through to components + this.port.postMessage({ev: PayloadEvent.Timekeep, timeSeconds: currentTime}); + + // We're supposed to return false when we're "done" with the audio clip, but seeing as + // we are acting as a passive processor we are never truly "done". The browser will clean + // us up when it is done with us. + return true; + } +} + +registerProcessor(WORKLET_NAME, MxVoiceWorklet); + +export default null; // to appease module loaders (we never use the export) diff --git a/src/voice/VoiceRecorder.ts b/src/voice/VoiceRecorder.ts deleted file mode 100644 index 077990ac17..0000000000 --- a/src/voice/VoiceRecorder.ts +++ /dev/null @@ -1,212 +0,0 @@ -/* -Copyright 2021 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 Recorder from 'opus-recorder'; -import encoderPath from 'opus-recorder/dist/encoderWorker.min.js'; -import {MatrixClient} from "matrix-js-sdk/src/client"; -import CallMediaHandler from "../CallMediaHandler"; -import {SimpleObservable} from "matrix-widget-api"; -import {clamp} from "../utils/numbers"; - -const CHANNELS = 1; // stereo isn't important -const SAMPLE_RATE = 48000; // 48khz is what WebRTC uses. 12khz is where we lose quality. -const BITRATE = 24000; // 24kbps is pretty high quality for our use case in opus. - -export interface IRecordingUpdate { - waveform: number[]; // floating points between 0 (low) and 1 (high). - timeSeconds: number; // float -} - -export class VoiceRecorder { - private recorder: Recorder; - private recorderContext: AudioContext; - private recorderSource: MediaStreamAudioSourceNode; - private recorderStream: MediaStream; - private recorderFFT: AnalyserNode; - private recorderProcessor: ScriptProcessorNode; - private buffer = new Uint8Array(0); - private mxc: string; - private recording = false; - private observable: SimpleObservable; - - public constructor(private client: MatrixClient) { - } - - private async makeRecorder() { - this.recorderStream = await navigator.mediaDevices.getUserMedia({ - audio: { - channelCount: CHANNELS, - noiseSuppression: true, // browsers ignore constraints they can't honour - deviceId: CallMediaHandler.getAudioInput(), - }, - }); - this.recorderContext = new AudioContext({ - // latencyHint: "interactive", // we don't want a latency hint (this causes data smoothing) - }); - this.recorderSource = this.recorderContext.createMediaStreamSource(this.recorderStream); - this.recorderFFT = this.recorderContext.createAnalyser(); - - // Bring the FFT time domain down a bit. The default is 2048, and this must be a power - // of two. We use 64 points because we happen to know down the line we need less than - // that, but 32 would be too few. Large numbers are not helpful here and do not add - // precision: they introduce higher precision outputs of the FFT (frequency data), but - // it makes the time domain less than helpful. - this.recorderFFT.fftSize = 64; - - // We use an audio processor to get accurate timing information. - // The size of the audio buffer largely decides how quickly we push timing/waveform data - // out of this class. Smaller buffers mean we update more frequently as we can't hold as - // many bytes. Larger buffers mean slower updates. For scale, 1024 gives us about 30Hz of - // updates and 2048 gives us about 20Hz. We use 1024 to get as close to perceived realtime - // as possible. Must be a power of 2. - this.recorderProcessor = this.recorderContext.createScriptProcessor(1024, CHANNELS, CHANNELS); - - // Connect our inputs and outputs - this.recorderSource.connect(this.recorderFFT); - this.recorderSource.connect(this.recorderProcessor); - this.recorderProcessor.connect(this.recorderContext.destination); - - this.recorder = new Recorder({ - encoderPath, // magic from webpack - encoderSampleRate: SAMPLE_RATE, - encoderApplication: 2048, // voice (default is "audio") - streamPages: true, // this speeds up the encoding process by using CPU over time - encoderFrameSize: 20, // ms, arbitrary frame size we send to the encoder - numberOfChannels: CHANNELS, - sourceNode: this.recorderSource, - encoderBitRate: BITRATE, - - // We use low values for the following to ease CPU usage - the resulting waveform - // is indistinguishable for a voice message. Note that the underlying library will - // pick defaults which prefer the highest possible quality, CPU be damned. - encoderComplexity: 3, // 0-10, 10 is slow and high quality. - resampleQuality: 3, // 0-10, 10 is slow and high quality - }); - this.recorder.ondataavailable = (a: ArrayBuffer) => { - const buf = new Uint8Array(a); - const newBuf = new Uint8Array(this.buffer.length + buf.length); - newBuf.set(this.buffer, 0); - newBuf.set(buf, this.buffer.length); - this.buffer = newBuf; - }; - } - - public get liveData(): SimpleObservable { - if (!this.recording) throw new Error("No observable when not recording"); - return this.observable; - } - - public get isSupported(): boolean { - return !!Recorder.isRecordingSupported(); - } - - public get hasRecording(): boolean { - return this.buffer.length > 0; - } - - public get mxcUri(): string { - if (!this.mxc) { - throw new Error("Recording has not been uploaded yet"); - } - return this.mxc; - } - - private tryUpdateLiveData = (ev: AudioProcessingEvent) => { - if (!this.recording) return; - - // The time domain is the input to the FFT, which means we use an array of the same - // size. The time domain is also known as the audio waveform. We're ignoring the - // output of the FFT here (frequency data) because we're not interested in it. - const data = new Float32Array(this.recorderFFT.fftSize); - this.recorderFFT.getFloatTimeDomainData(data); - - // We can't just `Array.from()` the array because we're dealing with 32bit floats - // and the built-in function won't consider that when converting between numbers. - // However, the runtime will convert the float32 to a float64 during the math operations - // which is why the loop works below. Note that a `.map()` call also doesn't work - // and will instead return a Float32Array still. - const translatedData: number[] = []; - for (let i = 0; i < data.length; i++) { - // We're clamping the values so we can do that math operation mentioned above, - // and to ensure that we produce consistent data (it's possible for the array - // to exceed the specified range with some audio input devices). - translatedData.push(clamp(data[i], 0, 1)); - } - - this.observable.update({ - waveform: translatedData, - timeSeconds: ev.playbackTime, - }); - }; - - public async start(): Promise { - if (this.mxc || this.hasRecording) { - throw new Error("Recording already prepared"); - } - if (this.recording) { - throw new Error("Recording already in progress"); - } - if (this.observable) { - this.observable.close(); - } - this.observable = new SimpleObservable(); - await this.makeRecorder(); - this.recorderProcessor.addEventListener("audioprocess", this.tryUpdateLiveData); - await this.recorder.start(); - this.recording = true; - } - - public async stop(): Promise { - if (!this.recording) { - throw new Error("No recording to stop"); - } - - // Disconnect the source early to start shutting down resources - this.recorderSource.disconnect(); - await this.recorder.stop(); - - // close the context after the recorder so the recorder doesn't try to - // connect anything to the context (this would generate a warning) - await this.recorderContext.close(); - - // Now stop all the media tracks so we can release them back to the user/OS - this.recorderStream.getTracks().forEach(t => t.stop()); - - // Finally do our post-processing and clean up - this.recording = false; - this.recorderProcessor.removeEventListener("audioprocess", this.tryUpdateLiveData); - await this.recorder.close(); - - return this.buffer; - } - - public async upload(): Promise { - if (!this.hasRecording) { - throw new Error("No recording available to upload"); - } - - if (this.mxc) return this.mxc; - - this.mxc = await this.client.uploadContent(new Blob([this.buffer], { - type: "audio/ogg", - }), { - onlyContentUri: false, // to stop the warnings in the console - }).then(r => r['content_uri']); - return this.mxc; - } -} - -window.mxVoiceRecorder = VoiceRecorder; diff --git a/src/voice/VoiceRecording.ts b/src/voice/VoiceRecording.ts new file mode 100644 index 0000000000..fde5779fa2 --- /dev/null +++ b/src/voice/VoiceRecording.ts @@ -0,0 +1,381 @@ +/* +Copyright 2021 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 Recorder from 'opus-recorder'; +import encoderPath from 'opus-recorder/dist/encoderWorker.min.js'; +import {MatrixClient} from "matrix-js-sdk/src/client"; +import CallMediaHandler from "../CallMediaHandler"; +import {SimpleObservable} from "matrix-widget-api"; +import {clamp, percentageOf, percentageWithin} from "../utils/numbers"; +import EventEmitter from "events"; +import {IDestroyable} from "../utils/IDestroyable"; +import {Singleflight} from "../utils/Singleflight"; +import {PayloadEvent, WORKLET_NAME} from "./consts"; +import {UPDATE_EVENT} from "../stores/AsyncStore"; +import {Playback} from "./Playback"; +import {createAudioContext} from "./compat"; + +const CHANNELS = 1; // stereo isn't important +export const SAMPLE_RATE = 48000; // 48khz is what WebRTC uses. 12khz is where we lose quality. +const BITRATE = 24000; // 24kbps is pretty high quality for our use case in opus. +const TARGET_MAX_LENGTH = 120; // 2 minutes in seconds. Somewhat arbitrary, though longer == larger files. +const TARGET_WARN_TIME_LEFT = 10; // 10 seconds, also somewhat arbitrary. + +export const RECORDING_PLAYBACK_SAMPLES = 44; + +export interface IRecordingUpdate { + waveform: number[]; // floating points between 0 (low) and 1 (high). + timeSeconds: number; // float +} + +export enum RecordingState { + Started = "started", + EndingSoon = "ending_soon", // emits an object with a single numerical value: secondsLeft + Ended = "ended", + Uploading = "uploading", + Uploaded = "uploaded", +} + +export class VoiceRecording extends EventEmitter implements IDestroyable { + private recorder: Recorder; + private recorderContext: AudioContext; + private recorderSource: MediaStreamAudioSourceNode; + private recorderStream: MediaStream; + private recorderFFT: AnalyserNode; + private recorderWorklet: AudioWorkletNode; + private recorderProcessor: ScriptProcessorNode; + private buffer = new Uint8Array(0); // use this.audioBuffer to access + private mxc: string; + private recording = false; + private observable: SimpleObservable; + private amplitudes: number[] = []; // at each second mark, generated + private playback: Playback; + + public constructor(private client: MatrixClient) { + super(); + } + + public get contentType(): string { + return "audio/ogg"; + } + + public get contentLength(): number { + return this.buffer.length; + } + + public get durationSeconds(): number { + if (!this.recorder) throw new Error("Duration not available without a recording"); + return this.recorderContext.currentTime; + } + + public get isRecording(): boolean { + return this.recording; + } + + public emit(event: string, ...args: any[]): boolean { + super.emit(event, ...args); + super.emit(UPDATE_EVENT, event, ...args); + return true; // we don't ever care if the event had listeners, so just return "yes" + } + + private async makeRecorder() { + try { + this.recorderStream = await navigator.mediaDevices.getUserMedia({ + audio: { + channelCount: CHANNELS, + noiseSuppression: true, // browsers ignore constraints they can't honour + deviceId: CallMediaHandler.getAudioInput(), + }, + }); + this.recorderContext = createAudioContext({ + // latencyHint: "interactive", // we don't want a latency hint (this causes data smoothing) + }); + this.recorderSource = this.recorderContext.createMediaStreamSource(this.recorderStream); + this.recorderFFT = this.recorderContext.createAnalyser(); + + // Bring the FFT time domain down a bit. The default is 2048, and this must be a power + // of two. We use 64 points because we happen to know down the line we need less than + // that, but 32 would be too few. Large numbers are not helpful here and do not add + // precision: they introduce higher precision outputs of the FFT (frequency data), but + // it makes the time domain less than helpful. + this.recorderFFT.fftSize = 64; + + // Set up our worklet. We use this for timing information and waveform analysis: the + // web audio API prefers this be done async to avoid holding the main thread with math. + const mxRecorderWorkletPath = document.body.dataset.vectorRecorderWorkletScript; + if (!mxRecorderWorkletPath) { + // noinspection ExceptionCaughtLocallyJS + throw new Error("Unable to create recorder: no worklet script registered"); + } + + // Connect our inputs and outputs + this.recorderSource.connect(this.recorderFFT); + + if (this.recorderContext.audioWorklet) { + await this.recorderContext.audioWorklet.addModule(mxRecorderWorkletPath); + this.recorderWorklet = new AudioWorkletNode(this.recorderContext, WORKLET_NAME); + this.recorderSource.connect(this.recorderWorklet); + this.recorderWorklet.connect(this.recorderContext.destination); + + // Dev note: we can't use `addEventListener` for some reason. It just doesn't work. + this.recorderWorklet.port.onmessage = (ev) => { + switch (ev.data['ev']) { + case PayloadEvent.Timekeep: + this.processAudioUpdate(ev.data['timeSeconds']); + break; + case PayloadEvent.AmplitudeMark: + // Sanity check to make sure we're adding about one sample per second + if (ev.data['forSecond'] === this.amplitudes.length) { + this.amplitudes.push(ev.data['amplitude']); + } + break; + } + }; + } else { + // Safari fallback: use a processor node instead, buffered to 1024 bytes of data + // like the worklet is. + this.recorderProcessor = this.recorderContext.createScriptProcessor(1024, CHANNELS, CHANNELS); + this.recorderSource.connect(this.recorderProcessor); + this.recorderProcessor.connect(this.recorderContext.destination); + this.recorderProcessor.addEventListener("audioprocess", this.onAudioProcess); + } + + this.recorder = new Recorder({ + encoderPath, // magic from webpack + encoderSampleRate: SAMPLE_RATE, + encoderApplication: 2048, // voice (default is "audio") + streamPages: true, // this speeds up the encoding process by using CPU over time + encoderFrameSize: 20, // ms, arbitrary frame size we send to the encoder + numberOfChannels: CHANNELS, + sourceNode: this.recorderSource, + encoderBitRate: BITRATE, + + // We use low values for the following to ease CPU usage - the resulting waveform + // is indistinguishable for a voice message. Note that the underlying library will + // pick defaults which prefer the highest possible quality, CPU be damned. + encoderComplexity: 3, // 0-10, 10 is slow and high quality. + resampleQuality: 3, // 0-10, 10 is slow and high quality + }); + this.recorder.ondataavailable = (a: ArrayBuffer) => { + const buf = new Uint8Array(a); + const newBuf = new Uint8Array(this.buffer.length + buf.length); + newBuf.set(this.buffer, 0); + newBuf.set(buf, this.buffer.length); + this.buffer = newBuf; + }; + } catch (e) { + console.error("Error starting recording: ", e); + if (e instanceof DOMException) { // Unhelpful DOMExceptions are common - parse them sanely + console.error(`${e.name} (${e.code}): ${e.message}`); + } + + // Clean up as best as possible + if (this.recorderStream) this.recorderStream.getTracks().forEach(t => t.stop()); + if (this.recorderSource) this.recorderSource.disconnect(); + if (this.recorder) this.recorder.close(); + if (this.recorderContext) { + // noinspection ES6MissingAwait - not important that we wait + this.recorderContext.close(); + } + + throw e; // rethrow so upstream can handle it + } + } + + private get audioBuffer(): Uint8Array { + // We need a clone of the buffer to avoid accidentally changing the position + // on the real thing. + return this.buffer.slice(0); + } + + public get liveData(): SimpleObservable { + if (!this.recording) throw new Error("No observable when not recording"); + return this.observable; + } + + public get isSupported(): boolean { + return !!Recorder.isRecordingSupported(); + } + + public get hasRecording(): boolean { + return this.buffer.length > 0; + } + + public get mxcUri(): string { + if (!this.mxc) { + throw new Error("Recording has not been uploaded yet"); + } + return this.mxc; + } + + private onAudioProcess = (ev: AudioProcessingEvent) => { + this.processAudioUpdate(ev.playbackTime); + + // We skip the functionality of the worklet regarding waveform calculations: we + // should get that information pretty quick during the playback info. + }; + + private processAudioUpdate = (timeSeconds: number) => { + if (!this.recording) return; + + // The time domain is the input to the FFT, which means we use an array of the same + // size. The time domain is also known as the audio waveform. We're ignoring the + // output of the FFT here (frequency data) because we're not interested in it. + const data = new Float32Array(this.recorderFFT.fftSize); + if (!this.recorderFFT.getFloatTimeDomainData) { + // Safari compat + const data2 = new Uint8Array(this.recorderFFT.fftSize); + this.recorderFFT.getByteTimeDomainData(data2); + for (let i = 0; i < data2.length; i++) { + data[i] = percentageWithin(percentageOf(data2[i], 0, 256), -1, 1); + } + } else { + this.recorderFFT.getFloatTimeDomainData(data); + } + + // We can't just `Array.from()` the array because we're dealing with 32bit floats + // and the built-in function won't consider that when converting between numbers. + // However, the runtime will convert the float32 to a float64 during the math operations + // which is why the loop works below. Note that a `.map()` call also doesn't work + // and will instead return a Float32Array still. + const translatedData: number[] = []; + for (let i = 0; i < data.length; i++) { + // We're clamping the values so we can do that math operation mentioned above, + // and to ensure that we produce consistent data (it's possible for the array + // to exceed the specified range with some audio input devices). + translatedData.push(clamp(data[i], 0, 1)); + } + + this.observable.update({ + waveform: translatedData, + timeSeconds: timeSeconds, + }); + + // Now that we've updated the data/waveform, let's do a time check. We don't want to + // go horribly over the limit. We also emit a warning state if needed. + // + // We use the recorder's perspective of time to make sure we don't cut off the last + // frame of audio, otherwise we end up with a 1:59 clip (119.68 seconds). This extra + // safety can allow us to overshoot the target a bit, but at least when we say 2min + // maximum we actually mean it. + // + // In testing, recorder time and worker time lag by about 400ms, which is roughly the + // time needed to encode a sample/frame. + // + // Ref for recorderSeconds: https://github.com/chris-rudmin/opus-recorder#instance-fields + const recorderSeconds = this.recorder.encodedSamplePosition / 48000; + const secondsLeft = TARGET_MAX_LENGTH - recorderSeconds; + if (secondsLeft < 0) { // go over to make sure we definitely capture that last frame + // noinspection JSIgnoredPromiseFromCall - we aren't concerned with it overlapping + this.stop(); + } else if (secondsLeft <= TARGET_WARN_TIME_LEFT) { + Singleflight.for(this, "ending_soon").do(() => { + this.emit(RecordingState.EndingSoon, {secondsLeft}); + return Singleflight.Void; + }); + } + }; + + public async start(): Promise { + if (this.mxc || this.hasRecording) { + throw new Error("Recording already prepared"); + } + if (this.recording) { + throw new Error("Recording already in progress"); + } + if (this.observable) { + this.observable.close(); + } + this.observable = new SimpleObservable(); + await this.makeRecorder(); + await this.recorder.start(); + this.recording = true; + this.emit(RecordingState.Started); + } + + public async stop(): Promise { + return Singleflight.for(this, "stop").do(async () => { + if (!this.recording) { + throw new Error("No recording to stop"); + } + + // Disconnect the source early to start shutting down resources + await this.recorder.stop(); // stop first to flush the last frame + this.recorderSource.disconnect(); + if (this.recorderWorklet) this.recorderWorklet.disconnect(); + if (this.recorderProcessor) { + this.recorderProcessor.disconnect(); + this.recorderProcessor.removeEventListener("audioprocess", this.onAudioProcess); + } + + // close the context after the recorder so the recorder doesn't try to + // connect anything to the context (this would generate a warning) + await this.recorderContext.close(); + + // Now stop all the media tracks so we can release them back to the user/OS + this.recorderStream.getTracks().forEach(t => t.stop()); + + // Finally do our post-processing and clean up + this.recording = false; + await this.recorder.close(); + this.emit(RecordingState.Ended); + + return this.audioBuffer; + }); + } + + /** + * Gets a playback instance for this voice recording. Note that the playback will not + * have been prepared fully, meaning the `prepare()` function needs to be called on it. + * + * The same playback instance is returned each time. + * + * @returns {Playback} The playback instance. + */ + public getPlayback(): Playback { + this.playback = Singleflight.for(this, "playback").do(() => { + return new Playback(this.audioBuffer.buffer, this.amplitudes); // cast to ArrayBuffer proper; + }); + return this.playback; + } + + public destroy() { + // noinspection JSIgnoredPromiseFromCall - not concerned about stop() being called async here + this.stop(); + this.removeAllListeners(); + Singleflight.forgetAllFor(this); + // noinspection JSIgnoredPromiseFromCall - not concerned about being called async here + this.playback?.destroy(); + this.observable.close(); + } + + public async upload(): Promise { + if (!this.hasRecording) { + throw new Error("No recording available to upload"); + } + + if (this.mxc) return this.mxc; + + this.emit(RecordingState.Uploading); + this.mxc = await this.client.uploadContent(new Blob([this.audioBuffer], { + type: this.contentType, + }), { + onlyContentUri: false, // to stop the warnings in the console + }).then(r => r['content_uri']); + this.emit(RecordingState.Uploaded); + return this.mxc; + } +} diff --git a/src/voice/compat.ts b/src/voice/compat.ts new file mode 100644 index 0000000000..316d779e28 --- /dev/null +++ b/src/voice/compat.ts @@ -0,0 +1,82 @@ +/* +Copyright 2021 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 {SAMPLE_RATE} from "./VoiceRecording"; + +// @ts-ignore - we know that this is not a module. We're looking for a path. +import decoderWasmPath from 'opus-recorder/dist/decoderWorker.min.wasm'; +import wavEncoderPath from 'opus-recorder/dist/waveWorker.min.js'; +import decoderPath from 'opus-recorder/dist/decoderWorker.min.js'; + +export function createAudioContext(opts?: AudioContextOptions): AudioContext { + if (window.AudioContext) { + return new AudioContext(opts); + } else if (window.webkitAudioContext) { + // While the linter is correct that "a constructor name should not start with + // a lowercase letter", it's also wrong to think that we have control over this. + // eslint-disable-next-line new-cap + return new window.webkitAudioContext(opts); + } else { + throw new Error("Unsupported browser"); + } +} + +export function decodeOgg(audioBuffer: ArrayBuffer): Promise { + // Condensed version of decoder example, using a promise: + // https://github.com/chris-rudmin/opus-recorder/blob/master/example/decoder.html + return new Promise((resolve) => { // no reject because the workers don't seem to have a fail path + console.log("Decoder WASM path: " + decoderWasmPath); // so we use the variable (avoid tree shake) + const typedArray = new Uint8Array(audioBuffer); + const decoderWorker = new Worker(decoderPath); + const wavWorker = new Worker(wavEncoderPath); + + decoderWorker.postMessage({ + command: 'init', + decoderSampleRate: SAMPLE_RATE, + outputBufferSampleRate: SAMPLE_RATE, + }); + + wavWorker.postMessage({ + command: 'init', + wavBitDepth: 24, // standard for 48khz (SAMPLE_RATE) + wavSampleRate: SAMPLE_RATE, + }); + + decoderWorker.onmessage = (ev) => { + if (ev.data === null) { // null == done + wavWorker.postMessage({command: 'done'}); + return; + } + + wavWorker.postMessage({ + command: 'encode', + buffers: ev.data, + }, ev.data.map(b => b.buffer)); + }; + + wavWorker.onmessage = (ev) => { + if (ev.data.message === 'page') { + // The encoding comes through as a single page + resolve(new Blob([ev.data.page], {type: "audio/wav"}).arrayBuffer()); + } + }; + + decoderWorker.postMessage({ + command: 'decode', + pages: typedArray, + }, [typedArray.buffer]); + }); +} diff --git a/src/voice/consts.ts b/src/voice/consts.ts new file mode 100644 index 0000000000..c530c60f0b --- /dev/null +++ b/src/voice/consts.ts @@ -0,0 +1,37 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +export const WORKLET_NAME = "mx-voice-worklet"; + +export enum PayloadEvent { + Timekeep = "timekeep", + AmplitudeMark = "amplitude_mark", +} + +export interface IPayload { + ev: PayloadEvent; +} + +export interface ITimingPayload extends IPayload { + ev: PayloadEvent.Timekeep; + timeSeconds: number; +} + +export interface IAmplitudePayload extends IPayload { + ev: PayloadEvent.AmplitudeMark; + forSecond: number; + amplitude: number; +} diff --git a/src/widgets/CapabilityText.tsx b/src/widgets/CapabilityText.tsx index 273d22dc81..05e6c59083 100644 --- a/src/widgets/CapabilityText.tsx +++ b/src/widgets/CapabilityText.tsx @@ -96,6 +96,16 @@ export class CapabilityText { [EventDirection.Receive]: _td("See when the avatar changes in your active room"), }, }, + [EventType.RoomMember]: { + [WidgetKind.Room]: { + [EventDirection.Send]: _td("Kick, ban, or invite people to this room, and make you leave"), + [EventDirection.Receive]: _td("See when people join, leave, or are invited to this room"), + }, + [GENERIC_WIDGET_KIND]: { + [EventDirection.Send]: _td("Kick, ban, or invite people to your active room, and make you leave"), + [EventDirection.Receive]: _td("See when people join, leave, or are invited to your active room"), + }, + }, }; private static nonStateSendRecvCaps: ISendRecvStaticCapText = { diff --git a/test/CallHandler-test.ts b/test/CallHandler-test.ts new file mode 100644 index 0000000000..1e3f92e788 --- /dev/null +++ b/test/CallHandler-test.ts @@ -0,0 +1,214 @@ +/* +Copyright 2021 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 './skinned-sdk'; + +import CallHandler, { PlaceCallType, CallHandlerEvent } from '../src/CallHandler'; +import { stubClient, mkStubRoom } from './test-utils'; +import { MatrixClientPeg } from '../src/MatrixClientPeg'; +import dis from '../src/dispatcher/dispatcher'; +import { CallEvent, CallState } from 'matrix-js-sdk/src/webrtc/call'; +import DMRoomMap from '../src/utils/DMRoomMap'; +import EventEmitter from 'events'; +import { Action } from '../src/dispatcher/actions'; +import SdkConfig from '../src/SdkConfig'; + +const REAL_ROOM_ID = '$room1:example.org'; +const MAPPED_ROOM_ID = '$room2:example.org'; +const MAPPED_ROOM_ID_2 = '$room3:example.org'; + +function mkStubDM(roomId, userId) { + const room = mkStubRoom(roomId); + room.getJoinedMembers = jest.fn().mockReturnValue([ + { + userId: '@me:example.org', + name: 'Member', + rawDisplayName: 'Member', + roomId: roomId, + membership: 'join', + getAvatarUrl: () => 'mxc://avatar.url/image.png', + getMxcAvatarUrl: () => 'mxc://avatar.url/image.png', + }, + { + userId: userId, + name: 'Member', + rawDisplayName: 'Member', + roomId: roomId, + membership: 'join', + getAvatarUrl: () => 'mxc://avatar.url/image.png', + getMxcAvatarUrl: () => 'mxc://avatar.url/image.png', + }, + ]); + room.currentState.getMembers = room.getJoinedMembers; + + return room; +} + +class FakeCall extends EventEmitter { + roomId: string; + callId = "fake call id"; + + constructor(roomId) { + super(); + + this.roomId = roomId; + } + + setRemoteOnHold() {} + setRemoteAudioElement() {} + + placeVoiceCall() { + this.emit(CallEvent.State, CallState.Connected, null); + } +} + +describe('CallHandler', () => { + let dmRoomMap; + let callHandler; + let audioElement; + let fakeCall; + + beforeEach(() => { + stubClient(); + MatrixClientPeg.get().createCall = roomId => { + if (fakeCall && fakeCall.roomId !== roomId) { + throw new Error("Only one call is supported!"); + } + fakeCall = new FakeCall(roomId); + return fakeCall; + }; + + callHandler = new CallHandler(); + callHandler.start(); + + dmRoomMap = { + getUserIdForRoomId: roomId => { + if (roomId === REAL_ROOM_ID) { + return '@user1:example.org'; + } else if (roomId === MAPPED_ROOM_ID) { + return '@user2:example.org'; + } else if (roomId === MAPPED_ROOM_ID_2) { + return '@user3:example.org'; + } else { + return null; + } + }, + getDMRoomsForUserId: userId => { + if (userId === '@user2:example.org') { + return [MAPPED_ROOM_ID]; + } else if (userId === '@user3:example.org') { + return [MAPPED_ROOM_ID_2]; + } else { + return []; + } + }, + }; + DMRoomMap.setShared(dmRoomMap); + + audioElement = document.createElement('audio'); + audioElement.id = "remoteAudio"; + document.body.appendChild(audioElement); + }); + + afterEach(() => { + callHandler.stop(); + DMRoomMap.setShared(null); + // @ts-ignore + window.mxCallHandler = null; + MatrixClientPeg.unset(); + + document.body.removeChild(audioElement); + SdkConfig.unset(); + }); + + it('should move calls between rooms when remote asserted identity changes', async () => { + const realRoom = mkStubDM(REAL_ROOM_ID, '@user1:example.org'); + const mappedRoom = mkStubDM(MAPPED_ROOM_ID, '@user2:example.org'); + const mappedRoom2 = mkStubDM(MAPPED_ROOM_ID_2, '@user3:example.org'); + + MatrixClientPeg.get().getRoom = roomId => { + switch (roomId) { + case REAL_ROOM_ID: + return realRoom; + case MAPPED_ROOM_ID: + return mappedRoom; + case MAPPED_ROOM_ID_2: + return mappedRoom2; + } + }; + + dis.dispatch({ + action: 'place_call', + type: PlaceCallType.Voice, + room_id: REAL_ROOM_ID, + }, true); + + let dispatchHandle; + // wait for the call to be set up + await new Promise(resolve => { + dispatchHandle = dis.register(payload => { + if (payload.action === 'call_state') { + resolve(); + } + }); + }); + dis.unregister(dispatchHandle); + + // should start off in the actual room ID it's in at the protocol level + expect(callHandler.getCallForRoom(REAL_ROOM_ID)).toBe(fakeCall); + + let callRoomChangeEventCount = 0; + const roomChangePromise = new Promise(resolve => { + callHandler.addListener(CallHandlerEvent.CallChangeRoom, () => { + ++callRoomChangeEventCount; + resolve(); + }); + }); + + // Now emit an asserted identity for user2: this should be ignored + // because we haven't set the config option to obey asserted identity + fakeCall.getRemoteAssertedIdentity = jest.fn().mockReturnValue({ + id: "@user2:example.org", + }); + fakeCall.emit(CallEvent.AssertedIdentityChanged); + + // Now set the config option + SdkConfig.put({ + voip: { + obeyAssertedIdentity: true, + }, + }); + + // ...and send another asserted identity event for a different user + fakeCall.getRemoteAssertedIdentity = jest.fn().mockReturnValue({ + id: "@user3:example.org", + }); + fakeCall.emit(CallEvent.AssertedIdentityChanged); + + await roomChangePromise; + callHandler.removeAllListeners(); + + // If everything's gone well, we should have seen only one room change + // event and the call should now be in user 3's room. + // If it's not obeying any, the call will still be in REAL_ROOM_ID. + // If it incorrectly obeyed both asserted identity changes, either it will + // have just processed one and the call will be in the wrong room, or we'll + // have seen two room change dispatches. + expect(callRoomChangeEventCount).toEqual(1); + expect(callHandler.getCallForRoom(REAL_ROOM_ID)).toBeNull(); + expect(callHandler.getCallForRoom(MAPPED_ROOM_ID_2)).toBe(fakeCall); + }); +}); diff --git a/test/ScalarAuthClient-test.js b/test/ScalarAuthClient-test.js index 83f357811a..3435f70932 100644 --- a/test/ScalarAuthClient-test.js +++ b/test/ScalarAuthClient-test.js @@ -29,7 +29,7 @@ describe('ScalarAuthClient', function() { it('should request a new token if the old one fails', async function() { const sac = new ScalarAuthClient(); - sac._getAccountName = jest.fn((arg) => { + sac.getAccountName = jest.fn((arg) => { switch (arg) { case "brokentoken": return Promise.reject({ diff --git a/test/autocomplete/QueryMatcher-test.js b/test/autocomplete/QueryMatcher-test.js index 3d383f08d7..cae71841d4 100644 --- a/test/autocomplete/QueryMatcher-test.js +++ b/test/autocomplete/QueryMatcher-test.js @@ -177,7 +177,7 @@ describe('QueryMatcher', function() { const qm = new QueryMatcher(NONWORDOBJECTS, { keys: ["name"], shouldMatchWordsOnly: false, - }); + }); const results = qm.match('bob'); expect(results.length).toBe(1); diff --git a/test/components/structures/MessagePanel-test.js b/test/components/structures/MessagePanel-test.js index 7347ff2658..dc70e3f7f6 100644 --- a/test/components/structures/MessagePanel-test.js +++ b/test/components/structures/MessagePanel-test.js @@ -77,7 +77,7 @@ describe('MessagePanel', function() { DMRoomMap.makeShared(); }); - afterEach(function() { + afterEach(function () { clock.uninstall(); }); @@ -88,7 +88,21 @@ describe('MessagePanel', function() { events.push(test_utils.mkMessage( { event: true, room: "!room:id", user: "@user:id", - ts: ts0 + i*1000, + ts: ts0 + i * 1000, + })); + } + return events; + } + + // Just to avoid breaking Dateseparator tests that might run at 00hrs + function mkOneDayEvents() { + const events = []; + const ts0 = Date.parse('09 May 2004 00:12:00 GMT'); + for (let i = 0; i < 10; i++) { + events.push(test_utils.mkMessage( + { + event: true, room: "!room:id", user: "@user:id", + ts: ts0 + i * 1000, })); } return events; @@ -104,7 +118,7 @@ describe('MessagePanel', function() { let i = 0; events.push(test_utils.mkMessage({ event: true, room: "!room:id", user: "@user:id", - ts: ts0 + ++i*1000, + ts: ts0 + ++i * 1000, })); for (i = 0; i < 10; i++) { @@ -151,7 +165,7 @@ describe('MessagePanel', function() { }, getMxcAvatarUrl: () => 'mxc://avatar.url/image.png', }, - ts: ts0 + i*1000, + ts: ts0 + i * 1000, mship: 'join', prevMship: 'join', name: 'A user', @@ -250,7 +264,6 @@ describe('MessagePanel', function() { }), ]; } - function isReadMarkerVisible(rmContainer) { return rmContainer && rmContainer.children.length > 0; } @@ -437,4 +450,17 @@ describe('MessagePanel', function() { // read marker should be hidden given props and at the last event expect(isReadMarkerVisible(rm)).toBeFalsy(); }); + + it('should render Date separators for the events', function () { + const events = mkOneDayEvents(); + const res = mount( + , + ); + const Dates = res.find(sdk.getComponent('messages.DateSeparator')); + + expect(Dates.length).toEqual(1); + }); }); diff --git a/test/components/views/dialogs/AccessSecretStorageDialog-test.js b/test/components/views/dialogs/AccessSecretStorageDialog-test.js index 13b39ab0d0..d9e07a2d74 100644 --- a/test/components/views/dialogs/AccessSecretStorageDialog-test.js +++ b/test/components/views/dialogs/AccessSecretStorageDialog-test.js @@ -26,9 +26,9 @@ describe("AccessSecretStorageDialog", function() { it("Closes the dialog if _onRecoveryKeyNext is called with a valid key", (done) => { const testInstance = TestRenderer.create( p && p.recoveryKey && p.recoveryKey == "a"} - onFinished={(v) => { - if (v) { done(); } + checkPrivateKey={(p) => p && p.recoveryKey && p.recoveryKey == "a"} + onFinished={(v) => { + if (v) { done(); } }} />, ); @@ -43,7 +43,7 @@ describe("AccessSecretStorageDialog", function() { it("Considers a valid key to be valid", async function() { const testInstance = TestRenderer.create( true} + checkPrivateKey={() => true} />, ); const v = "asdf"; @@ -61,7 +61,7 @@ describe("AccessSecretStorageDialog", function() { it("Notifies the user if they input an invalid Security Key", async function(done) { const testInstance = TestRenderer.create( false} + checkPrivateKey={async () => false} />, ); const e = { target: { value: "a" } }; @@ -87,12 +87,14 @@ describe("AccessSecretStorageDialog", function() { it("Notifies the user if they input an invalid passphrase", async function(done) { const testInstance = TestRenderer.create( false} - onFinished={() => {}} - keyInfo={ { passphrase: { - salt: 'nonempty', - iterations: 2, - } } } + checkPrivateKey={() => false} + onFinished={() => {}} + keyInfo={{ + passphrase: { + salt: 'nonempty', + iterations: 2, + }, + }} />, ); const e = { target: { value: "a" } }; diff --git a/test/components/views/elements/MemberEventListSummary-test.js b/test/components/views/elements/MemberEventListSummary-test.js index dd6febc7d7..95bf206d02 100644 --- a/test/components/views/elements/MemberEventListSummary-test.js +++ b/test/components/views/elements/MemberEventListSummary-test.js @@ -245,8 +245,7 @@ describe('MemberEventListSummary', function() { ); }); - it('truncates multiple sequences of repetitions with other events between', - function() { + it('truncates multiple sequences of repetitions with other events between', function() { const events = generateEvents([ { userId: "@user_1:some.domain", @@ -395,8 +394,7 @@ describe('MemberEventListSummary', function() { ); }); - it('correctly orders sequences of transitions by the order of their first event', - function() { + it('correctly orders sequences of transitions by the order of their first event', function() { const events = generateEvents([ { userId: "@user_2:some.domain", @@ -568,8 +566,7 @@ describe('MemberEventListSummary', function() { ); }); - it('handles invitation plurals correctly when there are multiple invites', - function() { + it('handles invitation plurals correctly when there are multiple invites', function() { const events = generateEvents([ { userId: "@user_1:some.domain", diff --git a/test/components/views/rooms/MemberList-test.js b/test/components/views/rooms/MemberList-test.js index 068d358dcd..50b40dea20 100644 --- a/test/components/views/rooms/MemberList-test.js +++ b/test/components/views/rooms/MemberList-test.js @@ -88,6 +88,7 @@ describe('MemberList', () => { }; memberListRoom.currentState = { members: {}, + getMember: jest.fn(), getStateEvents: (eventType, stateKey) => stateKey === undefined ? [] : null, // ignore 3pid invites }; for (const member of [...adminUsers, ...moderatorUsers, ...defaultUsers]) { @@ -100,7 +101,7 @@ describe('MemberList', () => { memberList = r; }; root = ReactDOM.render(, parentDiv); + wrappedRef={gatherWrappedRef} />, parentDiv); }); afterEach((done) => { diff --git a/test/components/views/rooms/RoomList-test.js b/test/components/views/rooms/RoomList-test.js index fcdd71629e..bfb8e1afd4 100644 --- a/test/components/views/rooms/RoomList-test.js +++ b/test/components/views/rooms/RoomList-test.js @@ -29,7 +29,10 @@ function waitForRoomListStoreUpdate() { describe('RoomList', () => { function createRoom(opts) { - const room = new Room(generateRoomId(), null, client.getUserId()); + const room = new Room(generateRoomId(), MatrixClientPeg.get(), client.getUserId(), { + // The room list now uses getPendingEvents(), so we need a detached ordering. + pendingEventOrdering: "detached", + }); if (opts) { Object.assign(room, opts); } @@ -67,8 +70,9 @@ describe('RoomList', () => { root = ReactDOM.render( {}} /> - - , parentDiv); + , + parentDiv, + ); ReactTestUtils.findRenderedComponentWithType(root, RoomList); movingRoom = createRoom({name: 'Moving room'}); diff --git a/test/editor/deserialize-test.js b/test/editor/deserialize-test.js index 07b75aaae5..7c7a2f84fb 100644 --- a/test/editor/deserialize-test.js +++ b/test/editor/deserialize-test.js @@ -178,7 +178,7 @@ describe('editor/deserialize', function() { const parts = normalize(parseEvent(htmlMessage(html), createPartCreator())); expect(parts.length).toBe(3); expect(parts[0]).toStrictEqual({type: "plain", text: "Try "}); - expect(parts[1]).toStrictEqual({type: "room-pill", text: "#room:hs.tld"}); + expect(parts[1]).toStrictEqual({type: "room-pill", text: "#room:hs.tld", resourceId: "#room:hs.tld"}); expect(parts[2]).toStrictEqual({type: "plain", text: "?"}); }); it('@room pill', function() { diff --git a/test/end-to-end-tests/.gitignore b/test/end-to-end-tests/.gitignore index 61f9012393..9180d32e90 100644 --- a/test/end-to-end-tests/.gitignore +++ b/test/end-to-end-tests/.gitignore @@ -1,3 +1,4 @@ node_modules *.png element/env +performance-entries.json diff --git a/test/end-to-end-tests/src/session.js b/test/end-to-end-tests/src/session.js index 433baa5e48..6c68929a0b 100644 --- a/test/end-to-end-tests/src/session.js +++ b/test/end-to-end-tests/src/session.js @@ -93,10 +93,10 @@ module.exports = class ElementSession { const type = req.resourceType(); const response = await req.response(); //if (type === 'xhr' || type === 'fetch') { - buffer += `${type} ${response.status()} ${req.method()} ${req.url()} \n`; - // if (req.method() === "POST") { - // buffer += " Post data: " + req.postData(); - // } + buffer += `${type} ${response.status()} ${req.method()} ${req.url()} \n`; + // if (req.method() === "POST") { + // buffer += " Post data: " + req.postData(); + // } //} }); return { @@ -208,7 +208,7 @@ module.exports = class ElementSession { this.log.done(); } - close() { + async close() { return this.browser.close(); } diff --git a/test/end-to-end-tests/src/usecases/create-room.js b/test/end-to-end-tests/src/usecases/create-room.js index 35b9d5879e..3830e3e0da 100644 --- a/test/end-to-end-tests/src/usecases/create-room.js +++ b/test/end-to-end-tests/src/usecases/create-room.js @@ -21,15 +21,7 @@ async function openRoomDirectory(session) { } async function findSublist(session, name) { - const sublists = await session.queryAll('.mx_RoomSublist'); - for (const sublist of sublists) { - const header = await sublist.$('.mx_RoomSublist_headerText'); - const headerText = await session.innerText(header); - if (headerText.toLowerCase().includes(name.toLowerCase())) { - return sublist; - } - } - throw new Error(`could not find room list section that contains '${name}' in header`); + return await session.query(`.mx_RoomSublist[aria-label="${name}" i]`); } async function createRoom(session, roomName, encrypted=false) { diff --git a/test/end-to-end-tests/src/usecases/signup.js b/test/end-to-end-tests/src/usecases/signup.js index 804cee9599..a3391e99f3 100644 --- a/test/end-to-end-tests/src/usecases/signup.js +++ b/test/end-to-end-tests/src/usecases/signup.js @@ -31,6 +31,9 @@ module.exports = async function signup(session, username, password, homeserver) // accept homeserver await nextButton.click(); } + // Delay required because of local race condition on macOs + // Where the form is not query-able despite being present in the DOM + await session.delay(100); //fill out form const usernameField = await session.query("#mx_RegistrationForm_username"); const passwordField = await session.query("#mx_RegistrationForm_password"); diff --git a/test/end-to-end-tests/start.js b/test/end-to-end-tests/start.js index 234d60da9f..f29b485c84 100644 --- a/test/end-to-end-tests/start.js +++ b/test/end-to-end-tests/start.js @@ -79,8 +79,26 @@ async function runTests() { await new Promise((resolve) => setTimeout(resolve, 5 * 60 * 1000)); } - await Promise.all(sessions.map((session) => session.close())); + const performanceEntries = {}; + await Promise.all(sessions.map(async (session) => { + // Collecting all performance monitoring data before closing the session + const measurements = await session.page.evaluate(() => { + let measurements = []; + window.mxPerformanceMonitor.addPerformanceDataCallback({ + entryNames: [ + window.mxPerformanceEntryNames.REGISTER, + ], + callback: (events) => { + measurements = JSON.stringify(events); + }, + }, true); + return measurements; + }); + performanceEntries[session.username] = JSON.parse(measurements); + return session.close(); + })); + fs.writeFileSync(`performance-entries.json`, JSON.stringify(performanceEntries)); if (failure) { process.exit(-1); } else { diff --git a/test/end-to-end-tests/yarn.lock b/test/end-to-end-tests/yarn.lock index 2f4d9979fb..97b348fe50 100644 --- a/test/end-to-end-tests/yarn.lock +++ b/test/end-to-end-tests/yarn.lock @@ -37,9 +37,9 @@ assert-plus@1.0.0, assert-plus@^1.0.0: integrity sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU= async-limiter@~1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/async-limiter/-/async-limiter-1.0.0.tgz#78faed8c3d074ab81f22b4e985d79e8738f720f8" - integrity sha512-jp/uFnooOiO+L211eZOoSyzpOITMXx1rBITauYykG3BRYPu8h0UcxsPNB04RR5vo4Tyz3+ay17tR6JVf9qzYWg== + version "1.0.1" + resolved "https://registry.yarnpkg.com/async-limiter/-/async-limiter-1.0.1.tgz#dd379e94f0db8310b08291f9d64c3209766617fd" + integrity sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ== asynckit@^0.4.0: version "0.4.0" @@ -57,9 +57,9 @@ aws4@^1.8.0: integrity sha512-ReZxvNHIOv88FlT7rxcXIIC0fPt4KZqZbOlivyWtXLt8ESx84zd3kMC6iK5jVeS2qt+g7ftS7ye4fi06X5rtRQ== balanced-match@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767" - integrity sha1-ibTRmasr7kneFk6gK4nORi1xt2c= + version "1.0.2" + resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" + integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== bcrypt-pbkdf@^1.0.0: version "1.0.2" @@ -81,6 +81,11 @@ brace-expansion@^1.1.7: balanced-match "^1.0.0" concat-map "0.0.1" +buffer-crc32@~0.2.3: + version "0.2.13" + resolved "https://registry.yarnpkg.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz#0d333e3f00eac50aa1454abd30ef8c2a5d9a7242" + integrity sha1-DTM+PwDqxQqhRUq9MO+MKl2ackI= + buffer-from@^1.0.0: version "1.1.1" resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.1.tgz#32713bc028f75c02fdb710d7c7bcec1f2c6070ef" @@ -120,7 +125,7 @@ concat-map@0.0.1: resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" integrity sha1-2Klr13/Wjfd5OnMDajug1UBdR3s= -concat-stream@1.6.2: +concat-stream@^1.6.2: version "1.6.2" resolved "https://registry.yarnpkg.com/concat-stream/-/concat-stream-1.6.2.tgz#904bdf194cd3122fc675c77fc4ac3d4ff0fd1a34" integrity sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw== @@ -157,7 +162,7 @@ dashdash@^1.12.0: dependencies: assert-plus "^1.0.0" -debug@2.6.9: +debug@^2.6.9: version "2.6.9" resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== @@ -165,18 +170,18 @@ debug@2.6.9: ms "2.0.0" debug@^3.1.0: - version "3.2.6" - resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.6.tgz#e83d17de16d8a7efb7717edbe5fb10135eee629b" - integrity sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ== + version "3.2.7" + resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.7.tgz#72580b7e9145fb39b6676f9c5e5fb100b934179a" + integrity sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ== dependencies: ms "^2.1.1" debug@^4.1.0: - version "4.1.1" - resolved "https://registry.yarnpkg.com/debug/-/debug-4.1.1.tgz#3b72260255109c6b589cee050f1d516139664791" - integrity sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw== + version "4.3.1" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.1.tgz#f0d229c505e0c6d8c49ac553d1b13dc183f6b2ee" + integrity sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ== dependencies: - ms "^2.1.1" + ms "2.1.2" delayed-stream@~1.0.0: version "1.0.0" @@ -250,14 +255,14 @@ extend@~3.0.2: integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g== extract-zip@^1.6.6: - version "1.6.7" - resolved "https://registry.yarnpkg.com/extract-zip/-/extract-zip-1.6.7.tgz#a840b4b8af6403264c8db57f4f1a74333ef81fe9" - integrity sha1-qEC0uK9kAyZMjbV/Txp0Mz74H+k= + version "1.7.0" + resolved "https://registry.yarnpkg.com/extract-zip/-/extract-zip-1.7.0.tgz#556cc3ae9df7f452c493a0cfb51cc30277940927" + integrity sha512-xoh5G1W/PB0/27lXgMQyIhP5DSY/LhoCsOyZgb+6iMmRtCwVBo55uKaMoEYrDCKQhWvqEip5ZPKAc6eFNyf/MA== dependencies: - concat-stream "1.6.2" - debug "2.6.9" - mkdirp "0.5.1" - yauzl "2.4.1" + concat-stream "^1.6.2" + debug "^2.6.9" + mkdirp "^0.5.4" + yauzl "^2.10.0" extsprintf@1.3.0: version "1.3.0" @@ -279,10 +284,10 @@ fast-json-stable-stringify@^2.0.0: resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.0.0.tgz#d5142c0caee6b1189f87d3a76111064f86c8bbf2" integrity sha1-1RQsDK7msRifh9OnYREGT4bIu/I= -fd-slicer@~1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/fd-slicer/-/fd-slicer-1.0.1.tgz#8b5bcbd9ec327c5041bf9ab023fd6750f1177e65" - integrity sha1-i1vL2ewyfFBBv5qwI/1nUPEXfmU= +fd-slicer@~1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/fd-slicer/-/fd-slicer-1.1.0.tgz#25c7c89cb1f9077f8891bbe61d8f390eae256f1e" + integrity sha1-JcfInLH5B3+IkbvmHY85Dq4lbx4= dependencies: pend "~1.2.0" @@ -313,9 +318,9 @@ getpass@^0.1.1: assert-plus "^1.0.0" glob@^7.1.3: - version "7.1.3" - resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.3.tgz#3960832d3f1574108342dafd3a67b332c0969df1" - integrity sha512-vcfuiIxogLV4DlGBHIUOwI0IbrJ8HWPc4MU7HzviGeNho/UJDfi6B5p3sHeWIQ0KGIU0Jpxi5ZHxemQfLkkAwQ== + version "7.1.6" + resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.6.tgz#141f33b81a7c2492e125594307480c46679278a6" + integrity sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA== dependencies: fs.realpath "^1.0.0" inflight "^1.0.4" @@ -374,7 +379,12 @@ inflight@^1.0.4: once "^1.3.0" wrappy "1" -inherits@2, inherits@^2.0.1, inherits@^2.0.3, inherits@~2.0.3: +inherits@2, inherits@^2.0.3, inherits@~2.0.3: + version "2.0.4" + resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" + integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== + +inherits@^2.0.1: version "2.0.3" resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de" integrity sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4= @@ -425,9 +435,9 @@ jsprim@^1.2.2: verror "1.10.0" lodash@^4.15.0, lodash@^4.17.11: - version "4.17.19" - resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.19.tgz#e48ddedbe30b3321783c5b4301fbd353bc1e4a4b" - integrity sha512-JNvd8XER9GQX0v2qJgsaN/mzFCNA5BRe/j8JN9d+tWyGLSodKQHKFicdwNYzWwI3wjRnaKPsGj1XkBjx/F96DQ== + version "4.17.21" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" + integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== mime-db@~1.38.0: version "1.38.0" @@ -442,9 +452,9 @@ mime-types@^2.1.12, mime-types@~2.1.19: mime-db "~1.38.0" mime@^2.0.3: - version "2.4.0" - resolved "https://registry.yarnpkg.com/mime/-/mime-2.4.0.tgz#e051fd881358585f3279df333fe694da0bcffdd6" - integrity sha512-ikBcWwyqXQSHKtciCcctu9YfPbFYZ4+gbHEmE0Q8jzcTYQg5dHCr3g2wwAZjPoJfQVXZq6KXAjpXOTf5/cjT7w== + version "2.5.2" + resolved "https://registry.yarnpkg.com/mime/-/mime-2.5.2.tgz#6e3dc6cc2b9510643830e5f19d5cb753da5eeabe" + integrity sha512-tqkh47FzKeCPD2PUiPB6pkbMzsCasjxAfC62/Wap5qrUWcb+sFasXUC5I3gYM5iBM8v/Qpn4UK0x+j0iHyFPDg== minimatch@^3.0.4: version "3.0.4" @@ -453,28 +463,33 @@ minimatch@^3.0.4: dependencies: brace-expansion "^1.1.7" -minimist@0.0.8: - version "0.0.8" - resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.8.tgz#857fcabfc3397d2625b8228262e86aa7a011b05d" - integrity sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0= +minimist@^1.2.5: + version "1.2.5" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602" + integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw== -mkdirp@0.5.1: - version "0.5.1" - resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.1.tgz#30057438eac6cf7f8c4767f38648d6697d75c903" - integrity sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM= +mkdirp@^0.5.4: + version "0.5.5" + resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.5.tgz#d91cefd62d1436ca0f41620e251288d420099def" + integrity sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ== dependencies: - minimist "0.0.8" + minimist "^1.2.5" ms@2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" integrity sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g= -ms@^2.1.1: +ms@2.1.2: version "2.1.2" resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== +ms@^2.1.1: + version "2.1.3" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" + integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== + nth-check@~1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/nth-check/-/nth-check-1.0.2.tgz#b2bd295c37e3dd58a3bf0700376663ba4d9cf05c" @@ -517,9 +532,9 @@ performance-now@^2.1.0: integrity sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns= process-nextick-args@~2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.0.tgz#a37d732f4271b4ab1ad070d35508e8290788ffaa" - integrity sha512-MtEC1TqN0EU5nephaJ4rAtThHtC86dNN9qCuEhtshvpVBkAW5ZO7BASN9REnF9eoXGcRub+pFuKEpOHE+HbEMw== + version "2.0.1" + resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2" + integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag== progress@^2.0.1: version "2.0.3" @@ -527,9 +542,9 @@ progress@^2.0.1: integrity sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA== proxy-from-env@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.0.0.tgz#33c50398f70ea7eb96d21f7b817630a55791c7ee" - integrity sha1-M8UDmPcOp+uW0h97gXYwpVeRx+4= + version "1.1.0" + resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2" + integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg== psl@^1.1.24, psl@^1.1.28: version "1.1.31" @@ -547,9 +562,9 @@ punycode@^2.1.0, punycode@^2.1.1: integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A== puppeteer@^1.14.0: - version "1.14.0" - resolved "https://registry.yarnpkg.com/puppeteer/-/puppeteer-1.14.0.tgz#828c1926b307200d5fc8289b99df4e13e962d339" - integrity sha512-SayS2wUX/8LF8Yo2Rkpc5nkAu4Jg3qu+OLTDSOZtisVQMB2Z5vjlY2TdPi/5CgZKiZroYIiyUN3sRX63El9iaw== + version "1.20.0" + resolved "https://registry.yarnpkg.com/puppeteer/-/puppeteer-1.20.0.tgz#e3d267786f74e1d87cf2d15acc59177f471bbe38" + integrity sha512-bt48RDBy2eIwZPrkgbcwHtb51mj2nKvHOPMaSH2IsWiv7lOG9k9zhaRzpDZafrk05ajMc3cu+lSQYYOfH2DkVQ== dependencies: debug "^4.1.0" extract-zip "^1.6.6" @@ -566,9 +581,9 @@ qs@~6.5.2: integrity sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA== readable-stream@^2.2.2: - version "2.3.6" - resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.6.tgz#b11c27d88b8ff1fbe070643cf94b0c79ae1b0aaf" - integrity sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw== + version "2.3.7" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.7.tgz#1eca1cf711aef814c04f62252a36a62f6cb23b57" + integrity sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw== dependencies: core-util-is "~1.0.0" inherits "~2.0.3" @@ -630,9 +645,9 @@ request@^2.88.0: uuid "^3.3.2" rimraf@^2.6.1: - version "2.6.3" - resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.6.3.tgz#b2d104fe0d8fb27cf9e0a1cda8262dd3833c6cab" - integrity sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA== + version "2.7.1" + resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.7.1.tgz#35797f13a7fdadc566142c29d4f07ccad483e3ec" + integrity sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w== dependencies: glob "^7.1.3" @@ -751,9 +766,10 @@ ws@^6.1.0: dependencies: async-limiter "~1.0.0" -yauzl@2.4.1: - version "2.4.1" - resolved "https://registry.yarnpkg.com/yauzl/-/yauzl-2.4.1.tgz#9528f442dab1b2284e58b4379bb194e22e0c4005" - integrity sha1-lSj0QtqxsihOWLQ3m7GU4i4MQAU= +yauzl@^2.10.0: + version "2.10.0" + resolved "https://registry.yarnpkg.com/yauzl/-/yauzl-2.10.0.tgz#c7eb17c93e112cb1086fa6d8e51fb0667b79a5f9" + integrity sha1-x+sXyT4RLLEIb6bY5R+wZnt5pfk= dependencies: - fd-slicer "~1.0.1" + buffer-crc32 "~0.2.3" + fd-slicer "~1.1.0" diff --git a/test/stores/SpaceStore-test.ts b/test/stores/SpaceStore-test.ts new file mode 100644 index 0000000000..20c48c29db --- /dev/null +++ b/test/stores/SpaceStore-test.ts @@ -0,0 +1,722 @@ +/* +Copyright 2021 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 { EventEmitter } from "events"; +import { EventType } from "matrix-js-sdk/src/@types/event"; + +import "../skinned-sdk"; // Must be first for skinning to work +import SpaceStore, { + UPDATE_INVITED_SPACES, + UPDATE_SELECTED_SPACE, + UPDATE_TOP_LEVEL_SPACES +} from "../../src/stores/SpaceStore"; +import { resetAsyncStoreWithClient, setupAsyncStoreWithClient } from "../utils/test-utils"; +import { mkEvent, mkStubRoom, stubClient } from "../test-utils"; +import { EnhancedMap } from "../../src/utils/maps"; +import SettingsStore from "../../src/settings/SettingsStore"; +import DMRoomMap from "../../src/utils/DMRoomMap"; +import { MatrixClientPeg } from "../../src/MatrixClientPeg"; +import defaultDispatcher from "../../src/dispatcher/dispatcher"; + +type MatrixEvent = any; // importing from js-sdk upsets things + +jest.useFakeTimers(); + +const mockStateEventImplementation = (events: MatrixEvent[]) => { + const stateMap = new EnhancedMap>(); + events.forEach(event => { + stateMap.getOrCreate(event.getType(), new Map()).set(event.getStateKey(), event); + }); + + return (eventType: string, stateKey?: string) => { + if (stateKey || stateKey === "") { + return stateMap.get(eventType)?.get(stateKey) || null; + } + return Array.from(stateMap.get(eventType)?.values() || []); + }; +}; + +const emitPromise = (e: EventEmitter, k: string | symbol) => new Promise(r => e.once(k, r)); + +const testUserId = "@test:user"; + +let rooms = []; + +const mkRoom = (roomId: string) => { + const room = mkStubRoom(roomId); + room.currentState.getStateEvents.mockImplementation(mockStateEventImplementation([])); + rooms.push(room); + return room; +}; + +const mkSpace = (spaceId: string, children: string[] = []) => { + const space = mkRoom(spaceId); + space.isSpaceRoom.mockReturnValue(true); + space.currentState.getStateEvents.mockImplementation(mockStateEventImplementation(children.map(roomId => + mkEvent({ + event: true, + type: EventType.SpaceChild, + room: spaceId, + user: testUserId, + skey: roomId, + content: { via: [] }, + ts: Date.now(), + }), + ))); + return space; +}; + +const getValue = jest.fn(); +SettingsStore.getValue = getValue; + +const getUserIdForRoomId = jest.fn(); +// @ts-ignore +DMRoomMap.sharedInstance = { getUserIdForRoomId }; + +const fav1 = "!fav1:server"; +const fav2 = "!fav2:server"; +const fav3 = "!fav3:server"; +const dm1 = "!dm1:server"; +const dm1Partner = "@dm1Partner:server"; +const dm2 = "!dm2:server"; +const dm2Partner = "@dm2Partner:server"; +const dm3 = "!dm3:server"; +const dm3Partner = "@dm3Partner:server"; +const orphan1 = "!orphan1:server"; +const orphan2 = "!orphan2:server"; +const invite1 = "!invite1:server"; +const invite2 = "!invite2:server"; +const room1 = "!room1:server"; +const room2 = "!room2:server"; +const room3 = "!room3:server"; +const space1 = "!space1:server"; +const space2 = "!space2:server"; +const space3 = "!space3:server"; + +describe("SpaceStore", () => { + stubClient(); + const store = SpaceStore.instance; + const client = MatrixClientPeg.get(); + + const viewRoom = roomId => defaultDispatcher.dispatch({ action: "view_room", room_id: roomId }, true); + + const run = async () => { + client.getRoom.mockImplementation(roomId => rooms.find(room => room.roomId === roomId)); + await setupAsyncStoreWithClient(store, client); + jest.runAllTimers(); + }; + + beforeEach(() => { + jest.runAllTimers(); + client.getVisibleRooms.mockReturnValue(rooms = []); + getValue.mockImplementation(settingName => { + if (settingName === "feature_spaces") { + return true; + } + }); + }); + afterEach(async () => { + await resetAsyncStoreWithClient(store); + }); + + describe("static hierarchy resolution tests", () => { + it("handles no spaces", async () => { + await run(); + + expect(store.spacePanelSpaces).toStrictEqual([]); + expect(store.invitedSpaces).toStrictEqual([]); + }); + + it("handles 3 joined top level spaces", async () => { + mkSpace("!space1:server"); + mkSpace("!space2:server"); + mkSpace("!space3:server"); + await run(); + + expect(store.spacePanelSpaces.sort()).toStrictEqual(client.getVisibleRooms().sort()); + expect(store.invitedSpaces).toStrictEqual([]); + }); + + it("handles a basic hierarchy", async () => { + mkSpace("!space1:server"); + mkSpace("!space2:server"); + mkSpace("!company:server", [ + mkSpace("!company_dept1:server", [ + mkSpace("!company_dept1_group1:server").roomId, + ]).roomId, + mkSpace("!company_dept2:server").roomId, + ]); + await run(); + + expect(store.spacePanelSpaces.map(r => r.roomId).sort()).toStrictEqual([ + "!space1:server", + "!space2:server", + "!company:server", + ].sort()); + expect(store.invitedSpaces).toStrictEqual([]); + + expect(store.getChildRooms("!space1:server")).toStrictEqual([]); + expect(store.getChildSpaces("!space1:server")).toStrictEqual([]); + expect(store.getChildRooms("!space2:server")).toStrictEqual([]); + expect(store.getChildSpaces("!space2:server")).toStrictEqual([]); + expect(store.getChildRooms("!company:server")).toStrictEqual([]); + expect(store.getChildSpaces("!company:server")).toStrictEqual([ + client.getRoom("!company_dept1:server"), + client.getRoom("!company_dept2:server"), + ]); + expect(store.getChildRooms("!company_dept1:server")).toStrictEqual([]); + expect(store.getChildSpaces("!company_dept1:server")).toStrictEqual([ + client.getRoom("!company_dept1_group1:server"), + ]); + expect(store.getChildRooms("!company_dept1_group1:server")).toStrictEqual([]); + expect(store.getChildSpaces("!company_dept1_group1:server")).toStrictEqual([]); + expect(store.getChildRooms("!company_dept2:server")).toStrictEqual([]); + expect(store.getChildSpaces("!company_dept2:server")).toStrictEqual([]); + }); + + it("handles a sub-space existing in multiple places in the space tree", async () => { + const subspace = mkSpace("!subspace:server"); + mkSpace("!space1:server"); + mkSpace("!space2:server"); + mkSpace("!company:server", [ + mkSpace("!company_dept1:server", [ + mkSpace("!company_dept1_group1:server", [subspace.roomId]).roomId, + ]).roomId, + mkSpace("!company_dept2:server", [subspace.roomId]).roomId, + subspace.roomId, + ]); + await run(); + + expect(store.spacePanelSpaces.map(r => r.roomId).sort()).toStrictEqual([ + "!space1:server", + "!space2:server", + "!company:server", + ].sort()); + expect(store.invitedSpaces).toStrictEqual([]); + + expect(store.getChildRooms("!space1:server")).toStrictEqual([]); + expect(store.getChildSpaces("!space1:server")).toStrictEqual([]); + expect(store.getChildRooms("!space2:server")).toStrictEqual([]); + expect(store.getChildSpaces("!space2:server")).toStrictEqual([]); + expect(store.getChildRooms("!company:server")).toStrictEqual([]); + expect(store.getChildSpaces("!company:server")).toStrictEqual([ + client.getRoom("!company_dept1:server"), + client.getRoom("!company_dept2:server"), + subspace, + ]); + expect(store.getChildRooms("!company_dept1:server")).toStrictEqual([]); + expect(store.getChildSpaces("!company_dept1:server")).toStrictEqual([ + client.getRoom("!company_dept1_group1:server"), + ]); + expect(store.getChildRooms("!company_dept1_group1:server")).toStrictEqual([]); + expect(store.getChildSpaces("!company_dept1_group1:server")).toStrictEqual([subspace]); + expect(store.getChildRooms("!company_dept2:server")).toStrictEqual([]); + expect(store.getChildSpaces("!company_dept2:server")).toStrictEqual([subspace]); + }); + + it("handles full cycles", async () => { + mkSpace("!a:server", [ + mkSpace("!b:server", [ + mkSpace("!c:server", [ + "!a:server", + ]).roomId, + ]).roomId, + ]); + await run(); + + expect(store.spacePanelSpaces.map(r => r.roomId)).toStrictEqual(["!a:server"]); + expect(store.invitedSpaces).toStrictEqual([]); + + expect(store.getChildRooms("!a:server")).toStrictEqual([]); + expect(store.getChildSpaces("!a:server")).toStrictEqual([client.getRoom("!b:server")]); + expect(store.getChildRooms("!b:server")).toStrictEqual([]); + expect(store.getChildSpaces("!b:server")).toStrictEqual([client.getRoom("!c:server")]); + expect(store.getChildRooms("!c:server")).toStrictEqual([]); + expect(store.getChildSpaces("!c:server")).toStrictEqual([client.getRoom("!a:server")]); + }); + + it("handles partial cycles", async () => { + mkSpace("!b:server", [ + mkSpace("!a:server", [ + mkSpace("!c:server", [ + "!a:server", + ]).roomId, + ]).roomId, + ]); + await run(); + + expect(store.spacePanelSpaces.map(r => r.roomId)).toStrictEqual(["!b:server"]); + expect(store.invitedSpaces).toStrictEqual([]); + + expect(store.getChildRooms("!b:server")).toStrictEqual([]); + expect(store.getChildSpaces("!b:server")).toStrictEqual([client.getRoom("!a:server")]); + expect(store.getChildRooms("!a:server")).toStrictEqual([]); + expect(store.getChildSpaces("!a:server")).toStrictEqual([client.getRoom("!c:server")]); + expect(store.getChildRooms("!c:server")).toStrictEqual([]); + expect(store.getChildSpaces("!c:server")).toStrictEqual([client.getRoom("!a:server")]); + }); + + it("handles partial cycles with additional spaces coming off them", async () => { + // TODO this test should be failing right now + mkSpace("!a:server", [ + mkSpace("!b:server", [ + mkSpace("!c:server", [ + "!a:server", + mkSpace("!d:server").roomId, + ]).roomId, + ]).roomId, + ]); + await run(); + + expect(store.spacePanelSpaces.map(r => r.roomId)).toStrictEqual(["!a:server"]); + expect(store.invitedSpaces).toStrictEqual([]); + + expect(store.getChildRooms("!a:server")).toStrictEqual([]); + expect(store.getChildSpaces("!a:server")).toStrictEqual([client.getRoom("!b:server")]); + expect(store.getChildRooms("!b:server")).toStrictEqual([]); + expect(store.getChildSpaces("!b:server")).toStrictEqual([client.getRoom("!c:server")]); + expect(store.getChildRooms("!c:server")).toStrictEqual([]); + expect(store.getChildSpaces("!c:server")).toStrictEqual([ + client.getRoom("!a:server"), + client.getRoom("!d:server"), + ]); + expect(store.getChildRooms("!d:server")).toStrictEqual([]); + expect(store.getChildSpaces("!d:server")).toStrictEqual([]); + }); + + it("invite to a subspace is only shown at the top level", async () => { + mkSpace(invite1).getMyMembership.mockReturnValue("invite"); + mkSpace(space1, [invite1]); + await run(); + + expect(store.spacePanelSpaces).toStrictEqual([client.getRoom(space1)]); + expect(store.getChildSpaces(space1)).toStrictEqual([]); + expect(store.getChildRooms(space1)).toStrictEqual([]); + expect(store.invitedSpaces).toStrictEqual([client.getRoom(invite1)]); + }); + + describe("test fixture 1", () => { + beforeEach(async () => { + [fav1, fav2, fav3, dm1, dm2, dm3, orphan1, orphan2, invite1, invite2, room1].forEach(mkRoom); + mkSpace(space1, [fav1, room1]); + mkSpace(space2, [fav1, fav2, fav3, room1]); + mkSpace(space3, [invite2]); + + [fav1, fav2, fav3].forEach(roomId => { + client.getRoom(roomId).tags = { + "m.favourite": { + order: 0.5, + }, + }; + }); + + [invite1, invite2].forEach(roomId => { + client.getRoom(roomId).getMyMembership.mockReturnValue("invite"); + }); + + getUserIdForRoomId.mockImplementation(roomId => { + return { + [dm1]: dm1Partner, + [dm2]: dm2Partner, + [dm3]: dm3Partner, + }[roomId]; + }); + await run(); + }); + + it("home space contains orphaned rooms", () => { + expect(store.getSpaceFilteredRoomIds(null).has(orphan1)).toBeTruthy(); + expect(store.getSpaceFilteredRoomIds(null).has(orphan2)).toBeTruthy(); + }); + + it("home space contains favourites", () => { + expect(store.getSpaceFilteredRoomIds(null).has(fav1)).toBeTruthy(); + expect(store.getSpaceFilteredRoomIds(null).has(fav2)).toBeTruthy(); + expect(store.getSpaceFilteredRoomIds(null).has(fav3)).toBeTruthy(); + }); + + it("home space contains dm rooms", () => { + expect(store.getSpaceFilteredRoomIds(null).has(dm1)).toBeTruthy(); + expect(store.getSpaceFilteredRoomIds(null).has(dm2)).toBeTruthy(); + expect(store.getSpaceFilteredRoomIds(null).has(dm3)).toBeTruthy(); + }); + + it("home space contains invites", () => { + expect(store.getSpaceFilteredRoomIds(null).has(invite1)).toBeTruthy(); + }); + + it("home space contains invites even if they are also shown in a space", () => { + expect(store.getSpaceFilteredRoomIds(null).has(invite2)).toBeTruthy(); + }); + + it("home space does contain rooms/low priority even if they are also shown in a space", () => { + expect(store.getSpaceFilteredRoomIds(null).has(room1)).toBeTruthy(); + }); + + it("space contains child rooms", () => { + const space = client.getRoom(space1); + expect(store.getSpaceFilteredRoomIds(space).has(fav1)).toBeTruthy(); + expect(store.getSpaceFilteredRoomIds(space).has(room1)).toBeTruthy(); + }); + + it("space contains child favourites", () => { + const space = client.getRoom(space2); + expect(store.getSpaceFilteredRoomIds(space).has(fav1)).toBeTruthy(); + expect(store.getSpaceFilteredRoomIds(space).has(fav2)).toBeTruthy(); + expect(store.getSpaceFilteredRoomIds(space).has(fav3)).toBeTruthy(); + expect(store.getSpaceFilteredRoomIds(space).has(room1)).toBeTruthy(); + }); + + it("space contains child invites", () => { + const space = client.getRoom(space3); + expect(store.getSpaceFilteredRoomIds(space).has(invite2)).toBeTruthy(); + }); + }); + }); + + describe("hierarchy resolution update tests", () => { + let emitter: EventEmitter; + beforeEach(async () => { + emitter = new EventEmitter(); + client.on.mockImplementation(emitter.on.bind(emitter)); + client.removeListener.mockImplementation(emitter.removeListener.bind(emitter)); + }); + afterEach(() => { + client.on.mockReset(); + client.removeListener.mockReset(); + }); + + it("updates state when spaces are joined", async () => { + await run(); + expect(store.spacePanelSpaces).toStrictEqual([]); + const space = mkSpace(space1); + const prom = emitPromise(store, UPDATE_TOP_LEVEL_SPACES); + emitter.emit("Room", space); + await prom; + expect(store.spacePanelSpaces).toStrictEqual([space]); + expect(store.invitedSpaces).toStrictEqual([]); + }); + + it("updates state when spaces are left", async () => { + const space = mkSpace(space1); + await run(); + + expect(store.spacePanelSpaces).toStrictEqual([space]); + space.getMyMembership.mockReturnValue("leave"); + const prom = emitPromise(store, UPDATE_TOP_LEVEL_SPACES); + emitter.emit("Room.myMembership", space, "leave", "join"); + await prom; + expect(store.spacePanelSpaces).toStrictEqual([]); + }); + + it("updates state when space invite comes in", async () => { + await run(); + expect(store.spacePanelSpaces).toStrictEqual([]); + expect(store.invitedSpaces).toStrictEqual([]); + const space = mkSpace(space1); + space.getMyMembership.mockReturnValue("invite"); + const prom = emitPromise(store, UPDATE_INVITED_SPACES); + emitter.emit("Room", space); + await prom; + expect(store.spacePanelSpaces).toStrictEqual([]); + expect(store.invitedSpaces).toStrictEqual([space]); + }); + + it("updates state when space invite is accepted", async () => { + const space = mkSpace(space1); + space.getMyMembership.mockReturnValue("invite"); + await run(); + + expect(store.spacePanelSpaces).toStrictEqual([]); + expect(store.invitedSpaces).toStrictEqual([space]); + space.getMyMembership.mockReturnValue("join"); + const prom = emitPromise(store, UPDATE_TOP_LEVEL_SPACES); + emitter.emit("Room.myMembership", space, "join", "invite"); + await prom; + expect(store.spacePanelSpaces).toStrictEqual([space]); + expect(store.invitedSpaces).toStrictEqual([]); + }); + + it("updates state when space invite is rejected", async () => { + const space = mkSpace(space1); + space.getMyMembership.mockReturnValue("invite"); + await run(); + + expect(store.spacePanelSpaces).toStrictEqual([]); + expect(store.invitedSpaces).toStrictEqual([space]); + space.getMyMembership.mockReturnValue("leave"); + const prom = emitPromise(store, UPDATE_INVITED_SPACES); + emitter.emit("Room.myMembership", space, "leave", "invite"); + await prom; + expect(store.spacePanelSpaces).toStrictEqual([]); + expect(store.invitedSpaces).toStrictEqual([]); + }); + + it("room invite gets added to relevant space filters", async () => { + const space = mkSpace(space1, [invite1]); + await run(); + + expect(store.spacePanelSpaces).toStrictEqual([space]); + expect(store.invitedSpaces).toStrictEqual([]); + expect(store.getChildSpaces(space1)).toStrictEqual([]); + expect(store.getChildRooms(space1)).toStrictEqual([]); + expect(store.getSpaceFilteredRoomIds(client.getRoom(space1)).has(invite1)).toBeFalsy(); + expect(store.getSpaceFilteredRoomIds(null).has(invite1)).toBeFalsy(); + + const invite = mkRoom(invite1); + invite.getMyMembership.mockReturnValue("invite"); + const prom = emitPromise(store, space1); + emitter.emit("Room", space); + await prom; + + expect(store.spacePanelSpaces).toStrictEqual([space]); + expect(store.invitedSpaces).toStrictEqual([]); + expect(store.getChildSpaces(space1)).toStrictEqual([]); + expect(store.getChildRooms(space1)).toStrictEqual([invite]); + expect(store.getSpaceFilteredRoomIds(client.getRoom(space1)).has(invite1)).toBeTruthy(); + expect(store.getSpaceFilteredRoomIds(null).has(invite1)).toBeTruthy(); + }); + }); + + describe("active space switching tests", () => { + const fn = jest.spyOn(store, "emit"); + + beforeEach(async () => { + mkRoom(room1); // not a space + mkSpace(space1, [ + mkSpace(space2).roomId, + ]); + mkSpace(space3).getMyMembership.mockReturnValue("invite"); + await run(); + await store.setActiveSpace(null); + expect(store.activeSpace).toBe(null); + }); + afterEach(() => { + fn.mockClear(); + }); + + it("switch to home space", async () => { + await store.setActiveSpace(client.getRoom(space1)); + fn.mockClear(); + + await store.setActiveSpace(null); + expect(fn).toHaveBeenCalledWith(UPDATE_SELECTED_SPACE, null); + expect(store.activeSpace).toBe(null); + }); + + it("switch to invited space", async () => { + const space = client.getRoom(space3); + await store.setActiveSpace(space); + expect(fn).toHaveBeenCalledWith(UPDATE_SELECTED_SPACE, space); + expect(store.activeSpace).toBe(space); + }); + + it("switch to top level space", async () => { + const space = client.getRoom(space1); + await store.setActiveSpace(space); + expect(fn).toHaveBeenCalledWith(UPDATE_SELECTED_SPACE, space); + expect(store.activeSpace).toBe(space); + }); + + it("switch to subspace", async () => { + const space = client.getRoom(space2); + await store.setActiveSpace(space); + expect(fn).toHaveBeenCalledWith(UPDATE_SELECTED_SPACE, space); + expect(store.activeSpace).toBe(space); + }); + + it("switch to unknown space is a nop", async () => { + expect(store.activeSpace).toBe(null); + const space = client.getRoom(room1); // not a space + await store.setActiveSpace(space); + expect(fn).not.toHaveBeenCalledWith(UPDATE_SELECTED_SPACE, space); + expect(store.activeSpace).toBe(null); + }); + }); + + describe("context switching tests", () => { + const fn = jest.spyOn(defaultDispatcher, "dispatch"); + + beforeEach(async () => { + [room1, room2, orphan1].forEach(mkRoom); + mkSpace(space1, [room1, room2]); + mkSpace(space2, [room2]); + await run(); + }); + afterEach(() => { + fn.mockClear(); + localStorage.clear(); + }); + + const getCurrentRoom = () => fn.mock.calls.reverse().find(([p]) => p.action === "view_room")?.[0].room_id; + + it("last viewed room in target space is the current viewed and in both spaces", async () => { + await store.setActiveSpace(client.getRoom(space1)); + viewRoom(room2); + await store.setActiveSpace(client.getRoom(space2)); + viewRoom(room2); + await store.setActiveSpace(client.getRoom(space1)); + expect(getCurrentRoom()).toBe(room2); + }); + + it("last viewed room in target space is in the current space", async () => { + await store.setActiveSpace(client.getRoom(space1)); + viewRoom(room2); + await store.setActiveSpace(client.getRoom(space2)); + expect(getCurrentRoom()).toBe(space2); + await store.setActiveSpace(client.getRoom(space1)); + expect(getCurrentRoom()).toBe(room2); + }); + + it("last viewed room in target space is not in the current space", async () => { + await store.setActiveSpace(client.getRoom(space1)); + viewRoom(room1); + await store.setActiveSpace(client.getRoom(space2)); + viewRoom(room2); + await store.setActiveSpace(client.getRoom(space1)); + expect(getCurrentRoom()).toBe(room1); + }); + + it("last viewed room is target space is not known", async () => { + await store.setActiveSpace(client.getRoom(space1)); + viewRoom(room1); + localStorage.setItem(`mx_space_context_${space2}`, orphan2); + await store.setActiveSpace(client.getRoom(space2)); + expect(getCurrentRoom()).toBe(space2); + }); + + it("no last viewed room in target space", async () => { + await store.setActiveSpace(client.getRoom(space1)); + viewRoom(room1); + await store.setActiveSpace(client.getRoom(space2)); + expect(getCurrentRoom()).toBe(space2); + }); + + it("no last viewed room in home space", async () => { + await store.setActiveSpace(client.getRoom(space1)); + viewRoom(room1); + await store.setActiveSpace(null); + expect(fn.mock.calls[fn.mock.calls.length - 1][0]).toStrictEqual({ action: "view_home_page" }); + }); + }); + + describe("space auto switching tests", () => { + beforeEach(async () => { + [room1, room2, room3, orphan1].forEach(mkRoom); + mkSpace(space1, [room1, room2, room3]); + mkSpace(space2, [room1, room2]); + + client.getRoom(room2).currentState.getStateEvents.mockImplementation(mockStateEventImplementation([ + mkEvent({ + event: true, + type: EventType.SpaceParent, + room: room2, + user: testUserId, + skey: space2, + content: { via: [], canonical: true }, + ts: Date.now(), + }), + ])); + await run(); + }); + + it("no switch required, room is in current space", async () => { + viewRoom(room1); + await store.setActiveSpace(client.getRoom(space1), false); + viewRoom(room2); + expect(store.activeSpace).toBe(client.getRoom(space1)); + }); + + it("switch to canonical parent space for room", async () => { + viewRoom(room1); + await store.setActiveSpace(client.getRoom(space2), false); + viewRoom(room2); + expect(store.activeSpace).toBe(client.getRoom(space2)); + }); + + it("switch to first containing space for room", async () => { + viewRoom(room2); + await store.setActiveSpace(client.getRoom(space2), false); + viewRoom(room3); + expect(store.activeSpace).toBe(client.getRoom(space1)); + }); + + it("switch to home for orphaned room", async () => { + viewRoom(room1); + await store.setActiveSpace(client.getRoom(space1), false); + viewRoom(orphan1); + expect(store.activeSpace).toBeNull(); + }); + + it("when switching rooms in the all rooms home space don't switch to related space", async () => { + viewRoom(room2); + await store.setActiveSpace(null, false); + viewRoom(room1); + expect(store.activeSpace).toBeNull(); + }); + }); + + describe("traverseSpace", () => { + beforeEach(() => { + mkSpace("!a:server", [ + mkSpace("!b:server", [ + mkSpace("!c:server", [ + "!a:server", + mkRoom("!c-child:server").roomId, + mkRoom("!shared-child:server").roomId, + ]).roomId, + mkRoom("!b-child:server").roomId, + ]).roomId, + mkRoom("!a-child:server").roomId, + "!shared-child:server", + ]); + }); + + it("avoids cycles", () => { + const fn = jest.fn(); + store.traverseSpace("!b:server", fn); + + expect(fn).toBeCalledTimes(3); + expect(fn).toBeCalledWith("!a:server"); + expect(fn).toBeCalledWith("!b:server"); + expect(fn).toBeCalledWith("!c:server"); + }); + + it("including rooms", () => { + const fn = jest.fn(); + store.traverseSpace("!b:server", fn, true); + + expect(fn).toBeCalledTimes(8); // twice for shared-child + expect(fn).toBeCalledWith("!a:server"); + expect(fn).toBeCalledWith("!a-child:server"); + expect(fn).toBeCalledWith("!b:server"); + expect(fn).toBeCalledWith("!b-child:server"); + expect(fn).toBeCalledWith("!c:server"); + expect(fn).toBeCalledWith("!c-child:server"); + expect(fn).toBeCalledWith("!shared-child:server"); + }); + + it("excluding rooms", () => { + const fn = jest.fn(); + store.traverseSpace("!b:server", fn, false); + + expect(fn).toBeCalledTimes(3); + expect(fn).toBeCalledWith("!a:server"); + expect(fn).toBeCalledWith("!b:server"); + expect(fn).toBeCalledWith("!c:server"); + }); + }); +}); diff --git a/test/test-utils.js b/test/test-utils.js index d259fcb95f..4faf948178 100644 --- a/test/test-utils.js +++ b/test/test-utils.js @@ -64,6 +64,11 @@ export function createTestClient() { getRoomIdForAlias: jest.fn().mockResolvedValue(undefined), getRoomDirectoryVisibility: jest.fn().mockResolvedValue(undefined), getProfileInfo: jest.fn().mockResolvedValue({}), + getThirdpartyProtocols: jest.fn().mockResolvedValue({}), + getClientWellKnown: jest.fn().mockReturnValue(null), + supportsVoip: jest.fn().mockReturnValue(true), + getTurnServersExpiry: jest.fn().mockReturnValue(2^32), + getThirdpartyUser: jest.fn().mockResolvedValue([]), getAccountData: (type) => { return mkEvent({ type, @@ -79,6 +84,18 @@ export function createTestClient() { generateClientSecret: () => "t35tcl1Ent5ECr3T", isGuest: () => false, isCryptoEnabled: () => false, + getSpaceSummary: jest.fn().mockReturnValue({ + rooms: [], + events: [], + }), + + // Used by various internal bits we aren't concerned with (yet) + _sessionStore: { + store: { + getItem: jest.fn(), + }, + }, + decryptEventIfNeeded: () => Promise.resolve(), }; } @@ -88,8 +105,8 @@ export function createTestClient() { * @param {string} opts.type The event.type * @param {string} opts.room The event.room_id * @param {string} opts.user The event.user_id - * @param {string} opts.skey Optional. The state key (auto inserts empty string) - * @param {Number} opts.ts Optional. Timestamp for the event + * @param {string=} opts.skey Optional. The state key (auto inserts empty string) + * @param {number=} opts.ts Optional. Timestamp for the event * @param {Object} opts.content The event.content * @param {boolean} opts.event True to make a MatrixEvent. * @return {Object} a JSON object representing this event. @@ -217,6 +234,7 @@ export function mkStubRoom(roomId = null) { }), getMembersWithMembership: jest.fn().mockReturnValue([]), getJoinedMembers: jest.fn().mockReturnValue([]), + getMembers: jest.fn().mockReturnValue([]), getPendingEvents: () => [], getLiveTimeline: () => stubTimeline, getUnfilteredTimelineSet: () => null, @@ -224,26 +242,27 @@ export function mkStubRoom(roomId = null) { hasMembershipState: () => null, getVersion: () => '1', shouldUpgradeToVersion: () => null, - getMyMembership: () => "join", + getMyMembership: jest.fn().mockReturnValue("join"), maySendMessage: jest.fn().mockReturnValue(true), currentState: { getStateEvents: jest.fn(), + getMember: jest.fn(), mayClientSendStateEvent: jest.fn().mockReturnValue(true), maySendStateEvent: jest.fn().mockReturnValue(true), maySendEvent: jest.fn().mockReturnValue(true), members: [], }, - tags: { - "m.favourite": { - order: 0.5, - }, - }, + tags: {}, setBlacklistUnverifiedDevices: jest.fn(), on: jest.fn(), removeListener: jest.fn(), getDMInviter: jest.fn(), getAvatarUrl: () => 'mxc://avatar.url/room.png', getMxcAvatarUrl: () => 'mxc://avatar.url/room.png', + isSpaceRoom: jest.fn(() => false), + getUnreadNotificationCount: jest.fn(() => 0), + getEventReadUpTo: jest.fn(() => null), + timeline: [], }; } diff --git a/test/utils/MegolmExportEncryption-test.js b/test/utils/MegolmExportEncryption-test.js index e0ed5ba26a..07ec03860b 100644 --- a/test/utils/MegolmExportEncryption-test.js +++ b/test/utils/MegolmExportEncryption-test.js @@ -84,22 +84,22 @@ describe('MegolmExportEncryption', function() { it('should handle missing header', function() { const input=stringToArray(`-----`); return MegolmExportEncryption.decryptMegolmKeyFile(input, '') - .then((res) => { - throw new Error('expected to throw'); - }, (error) => { - expect(error.message).toEqual('Header line not found'); - }); + .then((res) => { + throw new Error('expected to throw'); + }, (error) => { + expect(error.message).toEqual('Header line not found'); + }); }); it('should handle missing trailer', function() { const input=stringToArray(`-----BEGIN MEGOLM SESSION DATA----- -----`); return MegolmExportEncryption.decryptMegolmKeyFile(input, '') - .then((res) => { - throw new Error('expected to throw'); - }, (error) => { - expect(error.message).toEqual('Trailer line not found'); - }); + .then((res) => { + throw new Error('expected to throw'); + }, (error) => { + expect(error.message).toEqual('Trailer line not found'); + }); }); it('should handle a too-short body', function() { @@ -109,11 +109,11 @@ cissyYBxjsfsAn -----END MEGOLM SESSION DATA----- `); return MegolmExportEncryption.decryptMegolmKeyFile(input, '') - .then((res) => { - throw new Error('expected to throw'); - }, (error) => { - expect(error.message).toEqual('Invalid file: too short'); - }); + .then((res) => { + throw new Error('expected to throw'); + }, (error) => { + expect(error.message).toEqual('Invalid file: too short'); + }); }); // TODO find a subtlecrypto shim which doesn't break this test diff --git a/test/utils/ShieldUtils-test.js b/test/utils/ShieldUtils-test.js index 8e3b19c1c4..fdf4f527ee 100644 --- a/test/utils/ShieldUtils-test.js +++ b/test/utils/ShieldUtils-test.js @@ -26,7 +26,7 @@ describe("mkClient self-test", function() { ["@TF:h", true], ["@FT:h", false], ["@FF:h", false]], - )("behaves well for user trust %s", (userId, trust) => { + )("behaves well for user trust %s", (userId, trust) => { expect(mkClient().checkUserTrust(userId).isCrossSigningVerified()).toBe(trust); }); @@ -35,7 +35,7 @@ describe("mkClient self-test", function() { ["@TF:h", false], ["@FT:h", true], ["@FF:h", false]], - )("behaves well for device trust %s", (userId, trust) => { + )("behaves well for device trust %s", (userId, trust) => { expect(mkClient().checkDeviceTrust(userId, "device").isVerified()).toBe(trust); }); }); @@ -128,7 +128,7 @@ describe("shieldStatusForMembership self-trust behaviour", function() { describe("shieldStatusForMembership other-trust behaviour", function() { beforeAll(() => { - DMRoomMap._sharedInstance = { + DMRoomMap.sharedInstance = { getUserIdForRoomId: (roomId) => roomId === "DM" ? "@any:h" : null, }; }); diff --git a/test/utils/Singleflight-test.ts b/test/utils/Singleflight-test.ts new file mode 100644 index 0000000000..80258701bb --- /dev/null +++ b/test/utils/Singleflight-test.ts @@ -0,0 +1,115 @@ +/* +Copyright 2021 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 {Singleflight} from "../../src/utils/Singleflight"; + +describe('Singleflight', () => { + afterEach(() => { + Singleflight.forgetAll(); + }); + + it('should throw for bad context variables', () => { + const permutations: [Object, string][] = [ + [null, null], + [{}, null], + [null, "test"], + ]; + for (const p of permutations) { + try { + Singleflight.for(p[0], p[1]); + // noinspection ExceptionCaughtLocallyJS + throw new Error("failed to fail: " + JSON.stringify(p)); + } catch (e) { + expect(e.message).toBe("An instance and key must be supplied"); + } + } + }); + + it('should execute the function once', () => { + const instance = {}; + const key = "test"; + const val = {}; // unique object for reference check + const fn = jest.fn().mockReturnValue(val); + const sf = Singleflight.for(instance, key); + const r1 = sf.do(fn); + expect(r1).toBe(val); + expect(fn.mock.calls.length).toBe(1); + const r2 = sf.do(fn); + expect(r2).toBe(val); + expect(fn.mock.calls.length).toBe(1); + }); + + it('should execute the function once, even with new contexts', () => { + const instance = {}; + const key = "test"; + const val = {}; // unique object for reference check + const fn = jest.fn().mockReturnValue(val); + let sf = Singleflight.for(instance, key); + const r1 = sf.do(fn); + expect(r1).toBe(val); + expect(fn.mock.calls.length).toBe(1); + sf = Singleflight.for(instance, key); // RESET FOR TEST + const r2 = sf.do(fn); + expect(r2).toBe(val); + expect(fn.mock.calls.length).toBe(1); + }); + + it('should execute the function twice if the result was forgotten', () => { + const instance = {}; + const key = "test"; + const val = {}; // unique object for reference check + const fn = jest.fn().mockReturnValue(val); + const sf = Singleflight.for(instance, key); + const r1 = sf.do(fn); + expect(r1).toBe(val); + expect(fn.mock.calls.length).toBe(1); + sf.forget(); + const r2 = sf.do(fn); + expect(r2).toBe(val); + expect(fn.mock.calls.length).toBe(2); + }); + + it('should execute the function twice if the instance was forgotten', () => { + const instance = {}; + const key = "test"; + const val = {}; // unique object for reference check + const fn = jest.fn().mockReturnValue(val); + const sf = Singleflight.for(instance, key); + const r1 = sf.do(fn); + expect(r1).toBe(val); + expect(fn.mock.calls.length).toBe(1); + Singleflight.forgetAllFor(instance); + const r2 = sf.do(fn); + expect(r2).toBe(val); + expect(fn.mock.calls.length).toBe(2); + }); + + it('should execute the function twice if everything was forgotten', () => { + const instance = {}; + const key = "test"; + const val = {}; // unique object for reference check + const fn = jest.fn().mockReturnValue(val); + const sf = Singleflight.for(instance, key); + const r1 = sf.do(fn); + expect(r1).toBe(val); + expect(fn.mock.calls.length).toBe(1); + Singleflight.forgetAll(); + const r2 = sf.do(fn); + expect(r2).toBe(val); + expect(fn.mock.calls.length).toBe(2); + }); +}); + diff --git a/test/utils/arrays-test.ts b/test/utils/arrays-test.ts new file mode 100644 index 0000000000..5974915965 --- /dev/null +++ b/test/utils/arrays-test.ts @@ -0,0 +1,370 @@ +/* +Copyright 2021 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 { + arrayDiff, + arrayFastClone, + arrayFastResample, + arrayHasDiff, + arrayHasOrderChange, + arrayMerge, + arrayRescale, + arraySeed, + arraySmoothingResample, + arrayTrimFill, + arrayUnion, + ArrayUtil, + GroupedArray, +} from "../../src/utils/arrays"; +import {objectFromEntries} from "../../src/utils/objects"; + +function expectSample(i: number, input: number[], expected: number[], smooth = false) { + console.log(`Resample case index: ${i}`); // for debugging test failures + const result = (smooth ? arraySmoothingResample : arrayFastResample)(input, expected.length); + expect(result).toBeDefined(); + expect(result).toHaveLength(expected.length); + expect(result).toEqual(expected); +} + +describe('arrays', () => { + describe('arrayFastResample', () => { + it('should downsample', () => { + [ + {input: [1, 2, 3, 4, 5], output: [1, 4]}, // Odd -> Even + {input: [1, 2, 3, 4, 5], output: [1, 3, 5]}, // Odd -> Odd + {input: [1, 2, 3, 4], output: [1, 2, 3]}, // Even -> Odd + {input: [1, 2, 3, 4], output: [1, 3]}, // Even -> Even + ].forEach((c, i) => expectSample(i, c.input, c.output)); + }); + + it('should upsample', () => { + [ + {input: [1, 2, 3], output: [1, 1, 2, 2, 3, 3]}, // Odd -> Even + {input: [1, 2, 3], output: [1, 1, 2, 2, 3]}, // Odd -> Odd + {input: [1, 2], output: [1, 1, 1, 2, 2]}, // Even -> Odd + {input: [1, 2], output: [1, 1, 1, 2, 2, 2]}, // Even -> Even + ].forEach((c, i) => expectSample(i, c.input, c.output)); + }); + + it('should maintain sample', () => { + [ + {input: [1, 2, 3], output: [1, 2, 3]}, // Odd + {input: [1, 2], output: [1, 2]}, // Even + ].forEach((c, i) => expectSample(i, c.input, c.output)); + }); + }); + + describe('arraySmoothingResample', () => { + it('should downsample', () => { + // Dev note: these aren't great samples, but they demonstrate the bare minimum. Ideally + // we'd be feeding a thousand values in and seeing what a curve of 250 values looks like, + // but that's not really feasible to manually verify accuracy. + [ + {input: [4, 4, 1, 4, 4, 1, 4, 4, 1], output: [3, 3, 3, 3]}, // Odd -> Even + {input: [4, 4, 1, 4, 4, 1, 4, 4, 1], output: [3, 3, 3]}, // Odd -> Odd + {input: [4, 4, 1, 4, 4, 1, 4, 4], output: [3, 3, 3]}, // Even -> Odd + {input: [4, 4, 1, 4, 4, 1, 4, 4], output: [3, 3]}, // Even -> Even + ].forEach((c, i) => expectSample(i, c.input, c.output, true)); + }); + + it('should upsample', () => { + [ + {input: [2, 0, 2], output: [2, 2, 0, 0, 2, 2]}, // Odd -> Even + {input: [2, 0, 2], output: [2, 2, 0, 0, 2]}, // Odd -> Odd + {input: [2, 0], output: [2, 2, 2, 0, 0]}, // Even -> Odd + {input: [2, 0], output: [2, 2, 2, 0, 0, 0]}, // Even -> Even + ].forEach((c, i) => expectSample(i, c.input, c.output, true)); + }); + + it('should maintain sample', () => { + [ + {input: [2, 0, 2], output: [2, 0, 2]}, // Odd + {input: [2, 0], output: [2, 0]}, // Even + ].forEach((c, i) => expectSample(i, c.input, c.output, true)); + }); + }); + + describe('arrayRescale', () => { + it('should rescale', () => { + const input = [8, 9, 1, 0, 2, 7, 10]; + const output = [80, 90, 10, 0, 20, 70, 100]; + const result = arrayRescale(input, 0, 100); + expect(result).toBeDefined(); + expect(result).toHaveLength(output.length); + expect(result).toEqual(output); + }); + }); + + describe('arrayTrimFill', () => { + it('should shrink arrays', () => { + const input = [1, 2, 3]; + const output = [1, 2]; + const seed = [4, 5, 6]; + const result = arrayTrimFill(input, output.length, seed); + expect(result).toBeDefined(); + expect(result).toHaveLength(output.length); + expect(result).toEqual(output); + }); + + it('should expand arrays', () => { + const input = [1, 2, 3]; + const output = [1, 2, 3, 4, 5]; + const seed = [4, 5, 6]; + const result = arrayTrimFill(input, output.length, seed); + expect(result).toBeDefined(); + expect(result).toHaveLength(output.length); + expect(result).toEqual(output); + }); + + it('should keep arrays the same', () => { + const input = [1, 2, 3]; + const output = [1, 2, 3]; + const seed = [4, 5, 6]; + const result = arrayTrimFill(input, output.length, seed); + expect(result).toBeDefined(); + expect(result).toHaveLength(output.length); + expect(result).toEqual(output); + }); + }); + + describe('arraySeed', () => { + it('should create an array of given length', () => { + const val = 1; + const output = [val, val, val]; + const result = arraySeed(val, output.length); + expect(result).toBeDefined(); + expect(result).toHaveLength(output.length); + expect(result).toEqual(output); + }); + it('should maintain pointers', () => { + const val = {}; // this works because `{} !== {}`, which is what toEqual checks + const output = [val, val, val]; + const result = arraySeed(val, output.length); + expect(result).toBeDefined(); + expect(result).toHaveLength(output.length); + expect(result).toEqual(output); + }); + }); + + describe('arrayFastClone', () => { + it('should break pointer reference on source array', () => { + const val = {}; // we'll test to make sure the values maintain pointers too + const input = [val, val, val]; + const result = arrayFastClone(input); + expect(result).toBeDefined(); + expect(result).toHaveLength(input.length); + expect(result).toEqual(input); // we want the array contents to match... + expect(result).not.toBe(input); // ... but be a different reference + }); + }); + + describe('arrayHasOrderChange', () => { + it('should flag true on B ordering difference', () => { + const a = [1, 2, 3]; + const b = [3, 2, 1]; + const result = arrayHasOrderChange(a, b); + expect(result).toBe(true); + }); + + it('should flag false on no ordering difference', () => { + const a = [1, 2, 3]; + const b = [1, 2, 3]; + const result = arrayHasOrderChange(a, b); + expect(result).toBe(false); + }); + + it('should flag true on A length > B length', () => { + const a = [1, 2, 3, 4]; + const b = [1, 2, 3]; + const result = arrayHasOrderChange(a, b); + expect(result).toBe(true); + }); + + it('should flag true on A length < B length', () => { + const a = [1, 2, 3]; + const b = [1, 2, 3, 4]; + const result = arrayHasOrderChange(a, b); + expect(result).toBe(true); + }); + }); + + describe('arrayHasDiff', () => { + it('should flag true on A length > B length', () => { + const a = [1, 2, 3, 4]; + const b = [1, 2, 3]; + const result = arrayHasDiff(a, b); + expect(result).toBe(true); + }); + + it('should flag true on A length < B length', () => { + const a = [1, 2, 3]; + const b = [1, 2, 3, 4]; + const result = arrayHasDiff(a, b); + expect(result).toBe(true); + }); + + it('should flag true on element differences', () => { + const a = [1, 2, 3]; + const b = [4, 5, 6]; + const result = arrayHasDiff(a, b); + expect(result).toBe(true); + }); + + it('should flag false if same but order different', () => { + const a = [1, 2, 3]; + const b = [3, 1, 2]; + const result = arrayHasDiff(a, b); + expect(result).toBe(false); + }); + + it('should flag false if same', () => { + const a = [1, 2, 3]; + const b = [1, 2, 3]; + const result = arrayHasDiff(a, b); + expect(result).toBe(false); + }); + }); + + describe('arrayDiff', () => { + it('should see added from A->B', () => { + const a = [1, 2, 3]; + const b = [1, 2, 3, 4]; + const result = arrayDiff(a, b); + expect(result).toBeDefined(); + expect(result.added).toBeDefined(); + expect(result.removed).toBeDefined(); + expect(result.added).toHaveLength(1); + expect(result.removed).toHaveLength(0); + expect(result.added).toEqual([4]); + }); + + it('should see removed from A->B', () => { + const a = [1, 2, 3]; + const b = [1, 2]; + const result = arrayDiff(a, b); + expect(result).toBeDefined(); + expect(result.added).toBeDefined(); + expect(result.removed).toBeDefined(); + expect(result.added).toHaveLength(0); + expect(result.removed).toHaveLength(1); + expect(result.removed).toEqual([3]); + }); + + it('should see added and removed in the same set', () => { + const a = [1, 2, 3]; + const b = [1, 2, 4]; // note diff + const result = arrayDiff(a, b); + expect(result).toBeDefined(); + expect(result.added).toBeDefined(); + expect(result.removed).toBeDefined(); + expect(result.added).toHaveLength(1); + expect(result.removed).toHaveLength(1); + expect(result.added).toEqual([4]); + expect(result.removed).toEqual([3]); + }); + }); + + describe('arrayUnion', () => { + it('should return a union', () => { + const a = [1, 2, 3]; + const b = [1, 2, 4]; // note diff + const result = arrayUnion(a, b); + expect(result).toBeDefined(); + expect(result).toHaveLength(2); + expect(result).toEqual([1, 2]); + }); + + it('should return an empty array on no matches', () => { + const a = [1, 2, 3]; + const b = [4, 5, 6]; + const result = arrayUnion(a, b); + expect(result).toBeDefined(); + expect(result).toHaveLength(0); + }); + }); + + describe('arrayMerge', () => { + it('should merge 3 arrays with deduplication', () => { + const a = [1, 2, 3]; + const b = [1, 2, 4, 5]; // note missing 3 + const c = [6, 7, 8, 9]; + const result = arrayMerge(a, b, c); + expect(result).toBeDefined(); + expect(result).toHaveLength(9); + expect(result).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9]); + }); + + it('should deduplicate a single array', () => { + // dev note: this is technically an edge case, but it is described behaviour if the + // function is only provided one function (it'll merge the array against itself) + const a = [1, 1, 2, 2, 3, 3]; + const result = arrayMerge(a); + expect(result).toBeDefined(); + expect(result).toHaveLength(3); + expect(result).toEqual([1, 2, 3]); + }); + }); + + describe('ArrayUtil', () => { + it('should maintain the pointer to the given array', () => { + const input = [1, 2, 3]; + const result = new ArrayUtil(input); + expect(result.value).toBe(input); + }); + + it('should group appropriately', () => { + const input = [['a', 1], ['b', 2], ['c', 3], ['a', 4], ['a', 5], ['b', 6]]; + const output = { + 'a': [['a', 1], ['a', 4], ['a', 5]], + 'b': [['b', 2], ['b', 6]], + 'c': [['c', 3]], + }; + const result = new ArrayUtil(input).groupBy(p => p[0]); + expect(result).toBeDefined(); + expect(result.value).toBeDefined(); + + const asObject = objectFromEntries(result.value.entries()); + expect(asObject).toMatchObject(output); + }); + }); + + describe('GroupedArray', () => { + it('should maintain the pointer to the given map', () => { + const input = new Map([ + ['a', [1, 2, 3]], + ['b', [7, 8, 9]], + ['c', [4, 5, 6]], + ]); + const result = new GroupedArray(input); + expect(result.value).toBe(input); + }); + + it('should ordering by the provided key order', () => { + const input = new Map([ + ['a', [1, 2, 3]], + ['b', [7, 8, 9]], // note counting diff + ['c', [4, 5, 6]], + ]); + const output = [4, 5, 6, 1, 2, 3, 7, 8, 9]; + const keyOrder = ['c', 'a', 'b']; // note weird order to cause the `output` to be strange + const result = new GroupedArray(input).orderBy(keyOrder); + expect(result).toBeDefined(); + expect(result.value).toBeDefined(); + expect(result.value).toEqual(output); + }); + }); +}); + diff --git a/test/utils/enums-test.ts b/test/utils/enums-test.ts new file mode 100644 index 0000000000..423b135f77 --- /dev/null +++ b/test/utils/enums-test.ts @@ -0,0 +1,67 @@ +/* +Copyright 2021 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 {getEnumValues, isEnumValue} from "../../src/utils/enums"; + +enum TestStringEnum { + First = "__first__", + Second = "__second__", +} + +enum TestNumberEnum { + FirstKey = 10, + SecondKey = 20, +} + +describe('enums', () => { + describe('getEnumValues', () => { + it('should work on string enums', () => { + const result = getEnumValues(TestStringEnum); + expect(result).toBeDefined(); + expect(result).toHaveLength(2); + expect(result).toEqual(['__first__', '__second__']); + }); + + it('should work on number enums', () => { + const result = getEnumValues(TestNumberEnum); + expect(result).toBeDefined(); + expect(result).toHaveLength(2); + expect(result).toEqual([10, 20]); + }); + }); + + describe('isEnumValue', () => { + it('should return true on values in a string enum', () => { + const result = isEnumValue(TestStringEnum, '__first__'); + expect(result).toBe(true); + }); + + it('should return false on values not in a string enum', () => { + const result = isEnumValue(TestStringEnum, 'not a value'); + expect(result).toBe(false); + }); + + it('should return true on values in a number enum', () => { + const result = isEnumValue(TestNumberEnum, 10); + expect(result).toBe(true); + }); + + it('should return false on values not in a number enum', () => { + const result = isEnumValue(TestStringEnum, 99); + expect(result).toBe(false); + }); + }); +}); diff --git a/test/utils/iterables-test.ts b/test/utils/iterables-test.ts new file mode 100644 index 0000000000..9b30b6241c --- /dev/null +++ b/test/utils/iterables-test.ts @@ -0,0 +1,77 @@ +/* +Copyright 2021 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 {iterableDiff, iterableUnion} from "../../src/utils/iterables"; + +describe('iterables', () => { + describe('iterableUnion', () => { + it('should return a union', () => { + const a = [1, 2, 3]; + const b = [1, 2, 4]; // note diff + const result = iterableUnion(a, b); + expect(result).toBeDefined(); + expect(result).toHaveLength(2); + expect(result).toEqual([1, 2]); + }); + + it('should return an empty array on no matches', () => { + const a = [1, 2, 3]; + const b = [4, 5, 6]; + const result = iterableUnion(a, b); + expect(result).toBeDefined(); + expect(result).toHaveLength(0); + }); + }); + + describe('iterableDiff', () => { + it('should see added from A->B', () => { + const a = [1, 2, 3]; + const b = [1, 2, 3, 4]; + const result = iterableDiff(a, b); + expect(result).toBeDefined(); + expect(result.added).toBeDefined(); + expect(result.removed).toBeDefined(); + expect(result.added).toHaveLength(1); + expect(result.removed).toHaveLength(0); + expect(result.added).toEqual([4]); + }); + + it('should see removed from A->B', () => { + const a = [1, 2, 3]; + const b = [1, 2]; + const result = iterableDiff(a, b); + expect(result).toBeDefined(); + expect(result.added).toBeDefined(); + expect(result.removed).toBeDefined(); + expect(result.added).toHaveLength(0); + expect(result.removed).toHaveLength(1); + expect(result.removed).toEqual([3]); + }); + + it('should see added and removed in the same set', () => { + const a = [1, 2, 3]; + const b = [1, 2, 4]; // note diff + const result = iterableDiff(a, b); + expect(result).toBeDefined(); + expect(result.added).toBeDefined(); + expect(result.removed).toBeDefined(); + expect(result.added).toHaveLength(1); + expect(result.removed).toHaveLength(1); + expect(result.added).toEqual([4]); + expect(result.removed).toEqual([3]); + }); + }); +}); diff --git a/test/utils/maps-test.ts b/test/utils/maps-test.ts new file mode 100644 index 0000000000..8764a8f2cf --- /dev/null +++ b/test/utils/maps-test.ts @@ -0,0 +1,245 @@ +/* +Copyright 2021 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 {EnhancedMap, mapDiff, mapKeyChanges} from "../../src/utils/maps"; + +describe('maps', () => { + describe('mapDiff', () => { + it('should indicate no differences when the pointers are the same', () => { + const a = new Map([[1, 1], [2, 2], [3, 3]]); + const result = mapDiff(a, a); + expect(result).toBeDefined(); + expect(result.added).toBeDefined(); + expect(result.removed).toBeDefined(); + expect(result.changed).toBeDefined(); + expect(result.added).toHaveLength(0); + expect(result.removed).toHaveLength(0); + expect(result.changed).toHaveLength(0); + }); + + it('should indicate no differences when there are none', () => { + const a = new Map([[1, 1], [2, 2], [3, 3]]); + const b = new Map([[1, 1], [2, 2], [3, 3]]); + const result = mapDiff(a, b); + expect(result).toBeDefined(); + expect(result.added).toBeDefined(); + expect(result.removed).toBeDefined(); + expect(result.changed).toBeDefined(); + expect(result.added).toHaveLength(0); + expect(result.removed).toHaveLength(0); + expect(result.changed).toHaveLength(0); + }); + + it('should indicate added properties', () => { + const a = new Map([[1, 1], [2, 2], [3, 3]]); + const b = new Map([[1, 1], [2, 2], [3, 3], [4, 4]]); + const result = mapDiff(a, b); + expect(result).toBeDefined(); + expect(result.added).toBeDefined(); + expect(result.removed).toBeDefined(); + expect(result.changed).toBeDefined(); + expect(result.added).toHaveLength(1); + expect(result.removed).toHaveLength(0); + expect(result.changed).toHaveLength(0); + expect(result.added).toEqual([4]); + }); + + it('should indicate removed properties', () => { + const a = new Map([[1, 1], [2, 2], [3, 3]]); + const b = new Map([[1, 1], [2, 2]]); + const result = mapDiff(a, b); + expect(result).toBeDefined(); + expect(result.added).toBeDefined(); + expect(result.removed).toBeDefined(); + expect(result.changed).toBeDefined(); + expect(result.added).toHaveLength(0); + expect(result.removed).toHaveLength(1); + expect(result.changed).toHaveLength(0); + expect(result.removed).toEqual([3]); + }); + + it('should indicate changed properties', () => { + const a = new Map([[1, 1], [2, 2], [3, 3]]); + const b = new Map([[1, 1], [2, 2], [3, 4]]); // note change + const result = mapDiff(a, b); + expect(result).toBeDefined(); + expect(result.added).toBeDefined(); + expect(result.removed).toBeDefined(); + expect(result.changed).toBeDefined(); + expect(result.added).toHaveLength(0); + expect(result.removed).toHaveLength(0); + expect(result.changed).toHaveLength(1); + expect(result.changed).toEqual([3]); + }); + + it('should indicate changed, added, and removed properties', () => { + const a = new Map([[1, 1], [2, 2], [3, 3]]); + const b = new Map([[1, 1], [2, 8], [4, 4]]); // note change + const result = mapDiff(a, b); + expect(result).toBeDefined(); + expect(result.added).toBeDefined(); + expect(result.removed).toBeDefined(); + expect(result.changed).toBeDefined(); + expect(result.added).toHaveLength(1); + expect(result.removed).toHaveLength(1); + expect(result.changed).toHaveLength(1); + expect(result.added).toEqual([4]); + expect(result.removed).toEqual([3]); + expect(result.changed).toEqual([2]); + }); + + it('should indicate changes for difference in pointers', () => { + const a = new Map([[1, {}]]); // {} always creates a new object + const b = new Map([[1, {}]]); + const result = mapDiff(a, b); + expect(result).toBeDefined(); + expect(result.added).toBeDefined(); + expect(result.removed).toBeDefined(); + expect(result.changed).toBeDefined(); + expect(result.added).toHaveLength(0); + expect(result.removed).toHaveLength(0); + expect(result.changed).toHaveLength(1); + expect(result.changed).toEqual([1]); + }); + }); + + describe('mapKeyChanges', () => { + it('should indicate no changes for unchanged pointers', () => { + const a = new Map([[1, 1], [2, 2], [3, 3]]); + const result = mapKeyChanges(a, a); + expect(result).toBeDefined(); + expect(result).toHaveLength(0); + }); + + it('should indicate no changes for unchanged maps with different pointers', () => { + const a = new Map([[1, 1], [2, 2], [3, 3]]); + const b = new Map([[1, 1], [2, 2], [3, 3]]); + const result = mapKeyChanges(a, b); + expect(result).toBeDefined(); + expect(result).toHaveLength(0); + }); + + it('should indicate changes for added properties', () => { + const a = new Map([[1, 1], [2, 2], [3, 3]]); + const b = new Map([[1, 1], [2, 2], [3, 3], [4, 4]]); + const result = mapKeyChanges(a, b); + expect(result).toBeDefined(); + expect(result).toHaveLength(1); + expect(result).toEqual([4]); + }); + + it('should indicate changes for removed properties', () => { + const a = new Map([[1, 1], [2, 2], [3, 3], [4, 4]]); + const b = new Map([[1, 1], [2, 2], [3, 3]]); + const result = mapKeyChanges(a, b); + expect(result).toBeDefined(); + expect(result).toHaveLength(1); + expect(result).toEqual([4]); + }); + + it('should indicate changes for changed properties', () => { + const a = new Map([[1, 1], [2, 2], [3, 3], [4, 4]]); + const b = new Map([[1, 1], [2, 2], [3, 3], [4, 55]]); + const result = mapKeyChanges(a, b); + expect(result).toBeDefined(); + expect(result).toHaveLength(1); + expect(result).toEqual([4]); + }); + + it('should indicate changes for properties with different pointers', () => { + const a = new Map([[1, {}]]); // {} always creates a new object + const b = new Map([[1, {}]]); + const result = mapKeyChanges(a, b); + expect(result).toBeDefined(); + expect(result).toHaveLength(1); + expect(result).toEqual([1]); + }); + + it('should indicate changes for changed, added, and removed properties', () => { + const a = new Map([[1, 1], [2, 2], [3, 3]]); + const b = new Map([[1, 1], [2, 8], [4, 4]]); // note change + const result = mapKeyChanges(a, b); + expect(result).toBeDefined(); + expect(result).toHaveLength(3); + expect(result).toEqual([3, 4, 2]); // order irrelevant, but the test cares + }); + }); + + describe('EnhancedMap', () => { + // Most of these tests will make sure it implements the Map class + + it('should be empty by default', () => { + const result = new EnhancedMap(); + expect(result.size).toBe(0); + }); + + it('should use the provided entries', () => { + const obj = {a: 1, b: 2}; + const result = new EnhancedMap(Object.entries(obj)); + expect(result.size).toBe(2); + expect(result.get('a')).toBe(1); + expect(result.get('b')).toBe(2); + }); + + it('should create keys if they do not exist', () => { + const key = 'a'; + const val = {}; // we'll check pointers + + const result = new EnhancedMap(); + expect(result.size).toBe(0); + + let get = result.getOrCreate(key, val); + expect(get).toBeDefined(); + expect(get).toBe(val); + expect(result.size).toBe(1); + + get = result.getOrCreate(key, 44); // specifically change `val` + expect(get).toBeDefined(); + expect(get).toBe(val); + expect(result.size).toBe(1); + + get = result.get(key); // use the base class function + expect(get).toBeDefined(); + expect(get).toBe(val); + expect(result.size).toBe(1); + }); + + it('should proxy remove to delete and return it', () => { + const val = {}; + const result = new EnhancedMap(); + result.set('a', val); + + expect(result.size).toBe(1); + + const removed = result.remove('a'); + expect(result.size).toBe(0); + expect(removed).toBeDefined(); + expect(removed).toBe(val); + }); + + it('should support removing unknown keys', () => { + const val = {}; + const result = new EnhancedMap(); + result.set('a', val); + + expect(result.size).toBe(1); + + const removed = result.remove('not-a'); + expect(result.size).toBe(1); + expect(removed).not.toBeDefined(); + }); + }); +}); diff --git a/test/utils/numbers-test.ts b/test/utils/numbers-test.ts new file mode 100644 index 0000000000..36e7d4f7e7 --- /dev/null +++ b/test/utils/numbers-test.ts @@ -0,0 +1,163 @@ +/* +Copyright 2021 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 {clamp, defaultNumber, percentageOf, percentageWithin, sum} from "../../src/utils/numbers"; + +describe('numbers', () => { + describe('defaultNumber', () => { + it('should use the default when the input is not a number', () => { + const def = 42; + + let result = defaultNumber(null, def); + expect(result).toBe(def); + + result = defaultNumber(undefined, def); + expect(result).toBe(def); + + result = defaultNumber(Number.NaN, def); + expect(result).toBe(def); + }); + + it('should use the number when it is a number', () => { + const input = 24; + const def = 42; + const result = defaultNumber(input, def); + expect(result).toBe(input); + }); + }); + + describe('clamp', () => { + it('should clamp high numbers', () => { + const input = 101; + const min = 0; + const max = 100; + const result = clamp(input, min, max); + expect(result).toBe(max); + }); + + it('should clamp low numbers', () => { + const input = -1; + const min = 0; + const max = 100; + const result = clamp(input, min, max); + expect(result).toBe(min); + }); + + it('should not clamp numbers in range', () => { + const input = 50; + const min = 0; + const max = 100; + const result = clamp(input, min, max); + expect(result).toBe(input); + }); + + it('should clamp floats', () => { + const min = -0.10; + const max = +0.10; + + let result = clamp(-1.2, min, max); + expect(result).toBe(min); + + result = clamp(1.2, min, max); + expect(result).toBe(max); + + result = clamp(0.02, min, max); + expect(result).toBe(0.02); + }); + }); + + describe('sum', () => { + it('should sum', () => { // duh + const result = sum(1, 2, 1, 4); + expect(result).toBe(8); + }); + }); + + describe('percentageWithin', () => { + it('should work within 0-100', () => { + const result = percentageWithin(0.4, 0, 100); + expect(result).toBe(40); + }); + + it('should work within 0-100 when pct > 1', () => { + const result = percentageWithin(1.4, 0, 100); + expect(result).toBe(140); + }); + + it('should work within 0-100 when pct < 0', () => { + const result = percentageWithin(-1.4, 0, 100); + expect(result).toBe(-140); + }); + + it('should work with ranges other than 0-100', () => { + const result = percentageWithin(0.4, 10, 20); + expect(result).toBe(14); + }); + + it('should work with ranges other than 0-100 when pct > 1', () => { + const result = percentageWithin(1.4, 10, 20); + expect(result).toBe(24); + }); + + it('should work with ranges other than 0-100 when pct < 0', () => { + const result = percentageWithin(-1.4, 10, 20); + expect(result).toBe(-4); + }); + + it('should work with floats', () => { + const result = percentageWithin(0.4, 10.2, 20.4); + expect(result).toBe(14.28); + }); + }); + + // These are the inverse of percentageWithin + describe('percentageOf', () => { + it('should work within 0-100', () => { + const result = percentageOf(40, 0, 100); + expect(result).toBe(0.4); + }); + + it('should work within 0-100 when val > 100', () => { + const result = percentageOf(140, 0, 100); + expect(result).toBe(1.40); + }); + + it('should work within 0-100 when val < 0', () => { + const result = percentageOf(-140, 0, 100); + expect(result).toBe(-1.40); + }); + + it('should work with ranges other than 0-100', () => { + const result = percentageOf(14, 10, 20); + expect(result).toBe(0.4); + }); + + it('should work with ranges other than 0-100 when val > 100', () => { + const result = percentageOf(24, 10, 20); + expect(result).toBe(1.4); + }); + + it('should work with ranges other than 0-100 when val < 0', () => { + const result = percentageOf(-4, 10, 20); + expect(result).toBe(-1.4); + }); + + it('should work with floats', () => { + const result = percentageOf(14.28, 10.2, 20.4); + expect(result).toBe(0.4); + }); + }); +}); diff --git a/test/utils/objects-test.ts b/test/utils/objects-test.ts new file mode 100644 index 0000000000..b7a80e6761 --- /dev/null +++ b/test/utils/objects-test.ts @@ -0,0 +1,262 @@ +/* +Copyright 2021 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 { + objectClone, + objectDiff, + objectExcluding, + objectFromEntries, + objectHasDiff, + objectKeyChanges, + objectShallowClone, + objectWithOnly, +} from "../../src/utils/objects"; + +describe('objects', () => { + describe('objectExcluding', () => { + it('should exclude the given properties', () => { + const input = {hello: "world", test: true}; + const output = {hello: "world"}; + const props = ["test", "doesnotexist"]; // we also make sure it doesn't explode on missing props + const result = objectExcluding(input, props); // any is to test the missing prop + expect(result).toBeDefined(); + expect(result).toMatchObject(output); + }); + }); + + describe('objectWithOnly', () => { + it('should exclusively use the given properties', () => { + const input = {hello: "world", test: true}; + const output = {hello: "world"}; + const props = ["hello", "doesnotexist"]; // we also make sure it doesn't explode on missing props + const result = objectWithOnly(input, props); // any is to test the missing prop + expect(result).toBeDefined(); + expect(result).toMatchObject(output); + }); + }); + + describe('objectShallowClone', () => { + it('should create a new object', () => { + const input = {test: 1}; + const result = objectShallowClone(input); + expect(result).toBeDefined(); + expect(result).not.toBe(input); + expect(result).toMatchObject(input); + }); + + it('should only clone the top level properties', () => { + const input = {a: 1, b: {c: 2}}; + const result = objectShallowClone(input); + expect(result).toBeDefined(); + expect(result).toMatchObject(input); + expect(result.b).toBe(input.b); + }); + + it('should support custom clone functions', () => { + const input = {a: 1, b: 2}; + const output = {a: 4, b: 8}; + const result = objectShallowClone(input, (k, v) => { + // XXX: inverted expectation for ease of assertion + expect(Object.keys(input)).toContain(k); + + return v * 4; + }); + expect(result).toBeDefined(); + expect(result).toMatchObject(output); + }); + }); + + describe('objectHasDiff', () => { + it('should return false for the same pointer', () => { + const a = {}; + const result = objectHasDiff(a, a); + expect(result).toBe(false); + }); + + it('should return true if keys for A > keys for B', () => { + const a = {a: 1, b: 2}; + const b = {a: 1}; + const result = objectHasDiff(a, b); + expect(result).toBe(true); + }); + + it('should return true if keys for A < keys for B', () => { + const a = {a: 1}; + const b = {a: 1, b: 2}; + const result = objectHasDiff(a, b); + expect(result).toBe(true); + }); + + it('should return false if the objects are the same but different pointers', () => { + const a = {a: 1, b: 2}; + const b = {a: 1, b: 2}; + const result = objectHasDiff(a, b); + expect(result).toBe(false); + }); + + it('should consider pointers when testing values', () => { + const a = {a: {}, b: 2}; // `{}` is shorthand for `new Object()` + const b = {a: {}, b: 2}; + const result = objectHasDiff(a, b); + expect(result).toBe(true); // even though the keys are the same, the value pointers vary + }); + }); + + describe('objectDiff', () => { + it('should return empty sets for the same object', () => { + const a = {a: 1, b: 2}; + const b = {a: 1, b: 2}; + const result = objectDiff(a, b); + expect(result).toBeDefined(); + expect(result.changed).toBeDefined(); + expect(result.added).toBeDefined(); + expect(result.removed).toBeDefined(); + expect(result.changed).toHaveLength(0); + expect(result.added).toHaveLength(0); + expect(result.removed).toHaveLength(0); + }); + + it('should return empty sets for the same object pointer', () => { + const a = {a: 1, b: 2}; + const result = objectDiff(a, a); + expect(result).toBeDefined(); + expect(result.changed).toBeDefined(); + expect(result.added).toBeDefined(); + expect(result.removed).toBeDefined(); + expect(result.changed).toHaveLength(0); + expect(result.added).toHaveLength(0); + expect(result.removed).toHaveLength(0); + }); + + it('should indicate when property changes are made', () => { + const a = {a: 1, b: 2}; + const b = {a: 11, b: 2}; + const result = objectDiff(a, b); + expect(result.changed).toBeDefined(); + expect(result.added).toBeDefined(); + expect(result.removed).toBeDefined(); + expect(result.changed).toHaveLength(1); + expect(result.added).toHaveLength(0); + expect(result.removed).toHaveLength(0); + expect(result.changed).toEqual(['a']); + }); + + it('should indicate when properties are added', () => { + const a = {a: 1, b: 2}; + const b = {a: 1, b: 2, c: 3}; + const result = objectDiff(a, b); + expect(result.changed).toBeDefined(); + expect(result.added).toBeDefined(); + expect(result.removed).toBeDefined(); + expect(result.changed).toHaveLength(0); + expect(result.added).toHaveLength(1); + expect(result.removed).toHaveLength(0); + expect(result.added).toEqual(['c']); + }); + + it('should indicate when properties are removed', () => { + const a = {a: 1, b: 2}; + const b = {a: 1}; + const result = objectDiff(a, b); + expect(result.changed).toBeDefined(); + expect(result.added).toBeDefined(); + expect(result.removed).toBeDefined(); + expect(result.changed).toHaveLength(0); + expect(result.added).toHaveLength(0); + expect(result.removed).toHaveLength(1); + expect(result.removed).toEqual(['b']); + }); + + it('should indicate when multiple aspects change', () => { + const a = {a: 1, b: 2, c: 3}; + const b: (typeof a | {d: number}) = {a: 1, b: 22, d: 4}; + const result = objectDiff(a, b); + expect(result.changed).toBeDefined(); + expect(result.added).toBeDefined(); + expect(result.removed).toBeDefined(); + expect(result.changed).toHaveLength(1); + expect(result.added).toHaveLength(1); + expect(result.removed).toHaveLength(1); + expect(result.changed).toEqual(['b']); + expect(result.removed).toEqual(['c']); + expect(result.added).toEqual(['d']); + }); + }); + + describe('objectKeyChanges', () => { + it('should return an empty set if no properties changed', () => { + const a = {a: 1, b: 2}; + const b = {a: 1, b: 2}; + const result = objectKeyChanges(a, b); + expect(result).toBeDefined(); + expect(result).toHaveLength(0); + }); + + it('should return an empty set if no properties changed for the same pointer', () => { + const a = {a: 1, b: 2}; + const result = objectKeyChanges(a, a); + expect(result).toBeDefined(); + expect(result).toHaveLength(0); + }); + + it('should return properties which were changed, added, or removed', () => { + const a = {a: 1, b: 2, c: 3}; + const b: (typeof a | {d: number}) = {a: 1, b: 22, d: 4}; + const result = objectKeyChanges(a, b); + expect(result).toBeDefined(); + expect(result).toHaveLength(3); + expect(result).toEqual(['c', 'd', 'b']); // order isn't important, but the test cares + }); + }); + + describe('objectClone', () => { + it('should deep clone an object', () => { + const a = { + hello: "world", + test: { + another: "property", + test: 42, + third: { + prop: true, + }, + }, + }; + const result = objectClone(a); + expect(result).toBeDefined(); + expect(result).not.toBe(a); + expect(result).toMatchObject(a); + expect(result.test).not.toBe(a.test); + expect(result.test.third).not.toBe(a.test.third); + }); + }); + + describe('objectFromEntries', () => { + it('should create an object from an array of entries', () => { + const output = {a: 1, b: 2, c: 3}; + const result = objectFromEntries(Object.entries(output)); + expect(result).toBeDefined(); + expect(result).toMatchObject(output); + }); + + it('should maintain pointers in values', () => { + const output = {a: {}, b: 2, c: 3}; + const result = objectFromEntries(Object.entries(output)); + expect(result).toBeDefined(); + expect(result).toMatchObject(output); + expect(result['a']).toBe(output.a); + }); + }); +}); diff --git a/test/utils/sets-test.ts b/test/utils/sets-test.ts new file mode 100644 index 0000000000..98dc218309 --- /dev/null +++ b/test/utils/sets-test.ts @@ -0,0 +1,56 @@ +/* +Copyright 2021 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 {setHasDiff} from "../../src/utils/sets"; + +describe('sets', () => { + describe('setHasDiff', () => { + it('should flag true on A length > B length', () => { + const a = new Set([1, 2, 3, 4]); + const b = new Set([1, 2, 3]); + const result = setHasDiff(a, b); + expect(result).toBe(true); + }); + + it('should flag true on A length < B length', () => { + const a = new Set([1, 2, 3]); + const b = new Set([1, 2, 3, 4]); + const result = setHasDiff(a, b); + expect(result).toBe(true); + }); + + it('should flag true on element differences', () => { + const a = new Set([1, 2, 3]); + const b = new Set([4, 5, 6]); + const result = setHasDiff(a, b); + expect(result).toBe(true); + }); + + it('should flag false if same but order different', () => { + const a = new Set([1, 2, 3]); + const b = new Set([3, 1, 2]); + const result = setHasDiff(a, b); + expect(result).toBe(false); + }); + + it('should flag false if same', () => { + const a = new Set([1, 2, 3]); + const b = new Set([1, 2, 3]); + const result = setHasDiff(a, b); + expect(result).toBe(false); + }); + }); +}); diff --git a/test/utils/test-utils.ts b/test/utils/test-utils.ts new file mode 100644 index 0000000000..af92987a3d --- /dev/null +++ b/test/utils/test-utils.ts @@ -0,0 +1,33 @@ +/* +Copyright 2021 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/client"; +import { AsyncStoreWithClient } from "../../src/stores/AsyncStoreWithClient"; + +// These methods make some use of some private methods on the AsyncStoreWithClient to simplify getting into a consistent +// ready state without needing to wire up a dispatcher and pretend to be a js-sdk client. + +export const setupAsyncStoreWithClient = async (store: AsyncStoreWithClient, client: MatrixClient) => { + // @ts-ignore + store.readyStore.useUnitTestClient(client); + // @ts-ignore + await store.onReady(); +}; + +export const resetAsyncStoreWithClient = async (store: AsyncStoreWithClient) => { + // @ts-ignore + await store.onNotReady(); +}; diff --git a/yarn.lock b/yarn.lock index 34fdbbeeeb..19c0646d32 100644 --- a/yarn.lock +++ b/yarn.lock @@ -26,6 +26,13 @@ dependencies: "@babel/highlight" "^7.10.4" +"@babel/code-frame@^7.12.13": + version "7.12.13" + resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.12.13.tgz#dcfc826beef65e75c50e21d3837d7d95798dd658" + integrity sha512-HV1Cm0Q3ZrpCR93tkWOYiuYIgLxZXZFVG2VgK+MBWjUqZTundupbfx2aXarXuw5Ko5aMcjtJgbSs4vUGBS5v6g== + dependencies: + "@babel/highlight" "^7.12.13" + "@babel/compat-data@^7.12.5", "@babel/compat-data@^7.12.7": version "7.12.7" resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.12.7.tgz#9329b4782a7d6bbd7eef57e11addf91ee3ef1e41" @@ -61,6 +68,15 @@ jsesc "^2.5.1" source-map "^0.5.0" +"@babel/generator@^7.13.16": + version "7.13.16" + resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.13.16.tgz#0befc287031a201d84cdfc173b46b320ae472d14" + integrity sha512-grBBR75UnKOcUWMp8WoDxNsWCFl//XCK6HWTrBQKTr5SV9f5g0pNOjdyzi/DTBv12S9GnYPInIXQBTky7OXEMg== + dependencies: + "@babel/types" "^7.13.16" + jsesc "^2.5.1" + source-map "^0.5.0" + "@babel/helper-annotate-as-pure@^7.10.4", "@babel/helper-annotate-as-pure@^7.12.10": version "7.12.10" resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.12.10.tgz#54ab9b000e60a93644ce17b3f37d313aaf1d115d" @@ -130,6 +146,15 @@ "@babel/template" "^7.12.7" "@babel/types" "^7.12.11" +"@babel/helper-function-name@^7.12.13": + version "7.12.13" + resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.12.13.tgz#93ad656db3c3c2232559fd7b2c3dbdcbe0eb377a" + integrity sha512-TZvmPn0UOqmvi5G4vvw0qZTpVptGkB1GL61R6lKvrSdIxGm5Pky7Q3fpKiIkQCAtRCBUwB0PaThlx9vebCDSwA== + dependencies: + "@babel/helper-get-function-arity" "^7.12.13" + "@babel/template" "^7.12.13" + "@babel/types" "^7.12.13" + "@babel/helper-get-function-arity@^7.12.10": version "7.12.10" resolved "https://registry.yarnpkg.com/@babel/helper-get-function-arity/-/helper-get-function-arity-7.12.10.tgz#b158817a3165b5faa2047825dfa61970ddcc16cf" @@ -137,6 +162,13 @@ dependencies: "@babel/types" "^7.12.10" +"@babel/helper-get-function-arity@^7.12.13": + version "7.12.13" + resolved "https://registry.yarnpkg.com/@babel/helper-get-function-arity/-/helper-get-function-arity-7.12.13.tgz#bc63451d403a3b3082b97e1d8b3fe5bd4091e583" + integrity sha512-DjEVzQNz5LICkzN0REdpD5prGoidvbdYk1BVgRUOINaWJP2t6avB27X1guXK1kXNrX0WMfsrm1A/ZBthYuIMQg== + dependencies: + "@babel/types" "^7.12.13" + "@babel/helper-hoist-variables@^7.10.4": version "7.10.4" resolved "https://registry.yarnpkg.com/@babel/helper-hoist-variables/-/helper-hoist-variables-7.10.4.tgz#d49b001d1d5a68ca5e6604dda01a6297f7c9381e" @@ -225,6 +257,13 @@ dependencies: "@babel/types" "^7.12.11" +"@babel/helper-split-export-declaration@^7.12.13": + version "7.12.13" + resolved "https://registry.yarnpkg.com/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.12.13.tgz#e9430be00baf3e88b0e13e6f9d4eaf2136372b05" + integrity sha512-tCJDltF83htUtXx5NLcaDqRmknv652ZWCHyoTETf1CXYJdPC7nohZohjUgieXhv0hTJdRf2FjDueFehdNucpzg== + dependencies: + "@babel/types" "^7.12.13" + "@babel/helper-validator-identifier@^7.10.4", "@babel/helper-validator-identifier@^7.12.11": version "7.12.11" resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.12.11.tgz#c9a1f021917dcb5ccf0d4e453e399022981fc9ed" @@ -263,11 +302,25 @@ chalk "^2.0.0" js-tokens "^4.0.0" +"@babel/highlight@^7.12.13": + version "7.13.10" + resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.13.10.tgz#a8b2a66148f5b27d666b15d81774347a731d52d1" + integrity sha512-5aPpe5XQPzflQrFwL1/QoeHkP2MsA4JCntcXHRhEsdsfPVkvPi2w7Qix4iV7t5S/oC9OodGrggd8aco1g3SZFg== + dependencies: + "@babel/helper-validator-identifier" "^7.12.11" + chalk "^2.0.0" + js-tokens "^4.0.0" + "@babel/parser@^7.1.0", "@babel/parser@^7.12.10", "@babel/parser@^7.12.11", "@babel/parser@^7.12.7", "@babel/parser@^7.7.0": version "7.12.11" resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.12.11.tgz#9ce3595bcd74bc5c466905e86c535b8b25011e79" integrity sha512-N3UxG+uuF4CMYoNj8AhnbAcJF0PiuJ9KHuy1lQmkYsxTer/MAH9UBNHsBoAX/4s6NvlDD047No8mYVGGzLL4hg== +"@babel/parser@^7.12.13", "@babel/parser@^7.13.16": + version "7.13.16" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.13.16.tgz#0f18179b0448e6939b1f3f5c4c355a3a9bcdfd37" + integrity sha512-6bAg36mCwuqLO0hbR+z7PHuqWiCeP7Dzg73OpQwsAB1Eb8HnGEz5xYBzCfbu+YjoaJsJs+qheDxVAuqbt3ILEw== + "@babel/plugin-proposal-async-generator-functions@^7.12.1": version "7.12.12" resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-async-generator-functions/-/plugin-proposal-async-generator-functions-7.12.12.tgz#04b8f24fd4532008ab4e79f788468fd5a8476566" @@ -980,6 +1033,15 @@ "@babel/parser" "^7.12.7" "@babel/types" "^7.12.7" +"@babel/template@^7.12.13": + version "7.12.13" + resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.12.13.tgz#530265be8a2589dbb37523844c5bcb55947fb327" + integrity sha512-/7xxiGA57xMo/P2GVvdEumr8ONhFOhfgq2ihK3h1e6THqzTAkHbkXgB0xI9yeTfIUoH3+oAeHhqm/I43OTbbjA== + dependencies: + "@babel/code-frame" "^7.12.13" + "@babel/parser" "^7.12.13" + "@babel/types" "^7.12.13" + "@babel/traverse@^7.1.0", "@babel/traverse@^7.10.4", "@babel/traverse@^7.12.1", "@babel/traverse@^7.12.10", "@babel/traverse@^7.12.12", "@babel/traverse@^7.12.5", "@babel/traverse@^7.7.0", "@babel/traverse@^7.7.4": version "7.12.12" resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.12.12.tgz#d0cd87892704edd8da002d674bc811ce64743376" @@ -995,6 +1057,20 @@ globals "^11.1.0" lodash "^4.17.19" +"@babel/traverse@^7.13.17": + version "7.13.17" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.13.17.tgz#c85415e0c7d50ac053d758baec98b28b2ecfeea3" + integrity sha512-BMnZn0R+X6ayqm3C3To7o1j7Q020gWdqdyP50KEoVqaCO2c/Im7sYZSmVgvefp8TTMQ+9CtwuBp0Z1CZ8V3Pvg== + dependencies: + "@babel/code-frame" "^7.12.13" + "@babel/generator" "^7.13.16" + "@babel/helper-function-name" "^7.12.13" + "@babel/helper-split-export-declaration" "^7.12.13" + "@babel/parser" "^7.13.16" + "@babel/types" "^7.13.17" + debug "^4.1.0" + globals "^11.1.0" + "@babel/types@^7.0.0", "@babel/types@^7.10.4", "@babel/types@^7.10.5", "@babel/types@^7.12.1", "@babel/types@^7.12.10", "@babel/types@^7.12.11", "@babel/types@^7.12.12", "@babel/types@^7.12.5", "@babel/types@^7.12.7", "@babel/types@^7.3.0", "@babel/types@^7.3.3", "@babel/types@^7.4.4", "@babel/types@^7.7.0": version "7.12.12" resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.12.12.tgz#4608a6ec313abbd87afa55004d373ad04a96c299" @@ -1004,6 +1080,14 @@ lodash "^4.17.19" to-fast-properties "^2.0.0" +"@babel/types@^7.12.13", "@babel/types@^7.13.16", "@babel/types@^7.13.17": + version "7.13.17" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.13.17.tgz#48010a115c9fba7588b4437dd68c9469012b38b4" + integrity sha512-RawydLgxbOPDlTLJNtoIypwdmAy//uQIzlKt2+iBiJaRlVuI6QLUxVAyWGNfOzp8Yu4L4lLIacoCyTNtpb4wiA== + dependencies: + "@babel/helper-validator-identifier" "^7.12.11" + to-fast-properties "^2.0.0" + "@bcoe/v8-coverage@^0.2.3": version "0.2.3" resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39" @@ -1510,6 +1594,11 @@ resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0" integrity sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA== +"@types/parse5@^6.0.0": + version "6.0.0" + resolved "https://registry.yarnpkg.com/@types/parse5/-/parse5-6.0.0.tgz#38590dc2c3cf5717154064e3ee9b6947ee21b299" + integrity sha512-oPwPSj4a1wu9rsXTEGIJz91ISU725t0BmSnUhb57sI+M8XEmvUop84lzuiYdq0Y5M6xLY8DBPg0C2xEQKLyvBA== + "@types/prettier@^2.0.0": version "2.1.6" resolved "https://registry.yarnpkg.com/@types/prettier/-/prettier-2.1.6.tgz#f4b1efa784e8db479cdb8b14403e2144b1e9ff03" @@ -1549,12 +1638,12 @@ "@types/prop-types" "*" csstype "^3.0.2" -"@types/sanitize-html@^1.27.0": - version "1.27.0" - resolved "https://registry.yarnpkg.com/@types/sanitize-html/-/sanitize-html-1.27.0.tgz#77702dc856f16efecc005014c1d2e45b1f2cbc56" - integrity sha512-j7Vnh3P7W4ZcoRsHNO2HpwA2m1d0c2+l39xqSQqH0+WlfcvKypgZp45eCC7NJ75ZyXPxNb2PSbIL6LtZ6E0Qbw== +"@types/sanitize-html@^2.3.1": + version "2.3.1" + resolved "https://registry.yarnpkg.com/@types/sanitize-html/-/sanitize-html-2.3.1.tgz#094d696b83b7394b016e96342bbffa6a028795ce" + integrity sha512-+UT/XRluJuCunRftwO6OzG6WOBgJ+J3sROIoSJWX+7PB2FtTJTEJLrHCcNwzCQc0r60bej3WAbaigK+VZtZCGw== dependencies: - htmlparser2 "^4.1.0" + htmlparser2 "^6.0.0" "@types/stack-utils@^1.0.1": version "1.0.1" @@ -2312,29 +2401,29 @@ chardet@^0.7.0: resolved "https://registry.yarnpkg.com/chardet/-/chardet-0.7.0.tgz#90094849f0937f2eedc2425d0d28a9e5f0cbad9e" integrity sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA== -cheerio-select-tmp@^0.1.0: - version "0.1.1" - resolved "https://registry.yarnpkg.com/cheerio-select-tmp/-/cheerio-select-tmp-0.1.1.tgz#55bbef02a4771710195ad736d5e346763ca4e646" - integrity sha512-YYs5JvbpU19VYJyj+F7oYrIE2BOll1/hRU7rEy/5+v9BzkSo3bK81iAeeQEMI92vRIxz677m72UmJUiVwwgjfQ== +cheerio-select@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/cheerio-select/-/cheerio-select-1.4.0.tgz#3a16f21e37a2ef0f211d6d1aa4eff054bb22cdc9" + integrity sha512-sobR3Yqz27L553Qa7cK6rtJlMDbiKPdNywtR95Sj/YgfpLfy0u6CGJuaBKe5YE/vTc23SCRKxWSdlon/w6I/Ew== dependencies: - css-select "^3.1.2" - css-what "^4.0.0" - domelementtype "^2.1.0" - domhandler "^4.0.0" - domutils "^2.4.4" + css-select "^4.1.2" + css-what "^5.0.0" + domelementtype "^2.2.0" + domhandler "^4.2.0" + domutils "^2.6.0" -cheerio@^1.0.0-rc.3, cheerio@^1.0.0-rc.5: - version "1.0.0-rc.5" - resolved "https://registry.yarnpkg.com/cheerio/-/cheerio-1.0.0-rc.5.tgz#88907e1828674e8f9fee375188b27dadd4f0fa2f" - integrity sha512-yoqps/VCaZgN4pfXtenwHROTp8NG6/Hlt4Jpz2FEP0ZJQ+ZUkVDd0hAPDNKhj3nakpfPt/CNs57yEtxD1bXQiw== +cheerio@^1.0.0-rc.3, cheerio@^1.0.0-rc.9: + version "1.0.0-rc.9" + resolved "https://registry.yarnpkg.com/cheerio/-/cheerio-1.0.0-rc.9.tgz#a3ae6b7ce7af80675302ff836f628e7cb786a67f" + integrity sha512-QF6XVdrLONO6DXRF5iaolY+odmhj2CLj+xzNod7INPWMi/x9X4SOylH0S/vaPpX+AUU6t04s34SQNh7DbkuCng== dependencies: - cheerio-select-tmp "^0.1.0" - dom-serializer "~1.2.0" - domhandler "^4.0.0" - entities "~2.1.0" - htmlparser2 "^6.0.0" - parse5 "^6.0.0" - parse5-htmlparser2-tree-adapter "^6.0.0" + cheerio-select "^1.4.0" + dom-serializer "^1.3.1" + domhandler "^4.2.0" + htmlparser2 "^6.1.0" + parse5 "^6.0.1" + parse5-htmlparser2-tree-adapter "^6.0.1" + tslib "^2.2.0" chokidar@^3.4.0, chokidar@^3.5.1: version "3.5.1" @@ -2616,21 +2705,21 @@ cross-spawn@^7.0.0, cross-spawn@^7.0.2: shebang-command "^2.0.0" which "^2.0.1" -css-select@^3.1.2: - version "3.1.2" - resolved "https://registry.yarnpkg.com/css-select/-/css-select-3.1.2.tgz#d52cbdc6fee379fba97fb0d3925abbd18af2d9d8" - integrity sha512-qmss1EihSuBNWNNhHjxzxSfJoFBM/lERB/Q4EnsJQQC62R2evJDW481091oAdOr9uh46/0n4nrg0It5cAnj1RA== +css-select@^4.1.2: + version "4.1.2" + resolved "https://registry.yarnpkg.com/css-select/-/css-select-4.1.2.tgz#8b52b6714ed3a80d8221ec971c543f3b12653286" + integrity sha512-nu5ye2Hg/4ISq4XqdLY2bEatAcLIdt3OYGFc9Tm9n7VSlFBcfRv0gBNksHRgSdUDQGtN3XrZ94ztW+NfzkFSUw== dependencies: boolbase "^1.0.0" - css-what "^4.0.0" - domhandler "^4.0.0" - domutils "^2.4.3" + css-what "^5.0.0" + domhandler "^4.2.0" + domutils "^2.6.0" nth-check "^2.0.0" -css-what@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/css-what/-/css-what-4.0.0.tgz#35e73761cab2eeb3d3661126b23d7aa0e8432233" - integrity sha512-teijzG7kwYfNVsUh2H/YN62xW3KK9YhXEgSlbxMlcyjPNvdKJqFx5lrwlJgoFP1ZHlB89iGDlo/JyshKeRhv5A== +css-what@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/css-what/-/css-what-5.0.0.tgz#f0bf4f8bac07582722346ab243f6a35b512cfc47" + integrity sha512-qxyKHQvgKwzwDWC/rGbT821eJalfupxYW2qbSJSAtdSTimsr/MlaGONoNLllaUPZWf8QnbcKM/kPVYUQuEKAFA== cssesc@^3.0.0: version "3.0.0" @@ -2836,9 +2925,9 @@ doctrine@^3.0.0: esutils "^2.0.2" dom-helpers@^5.0.1: - version "5.2.0" - resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-5.2.0.tgz#57fd054c5f8f34c52a3eeffdb7e7e93cd357d95b" - integrity sha512-Ru5o9+V8CpunKnz5LGgWXkmrH/20cGKwcHwS4m73zIvs54CN9epEmT/HLqFJW3kXpakAFkEdzgy1hzlJe3E4OQ== + version "5.2.1" + resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-5.2.1.tgz#d9400536b2bf8225ad98fe052e029451ac40e902" + integrity sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA== dependencies: "@babel/runtime" "^7.8.7" csstype "^3.0.2" @@ -2851,10 +2940,10 @@ dom-serializer@0: domelementtype "^2.0.1" entities "^2.0.0" -dom-serializer@^1.0.1, dom-serializer@~1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-1.2.0.tgz#3433d9136aeb3c627981daa385fc7f32d27c48f1" - integrity sha512-n6kZFH/KlCrqs/1GHMOd5i2fd/beQHuehKdWvNNffbGHTr/almdhuVvTVFb3V7fglz+nC50fFusu3lY33h12pA== +dom-serializer@^1.0.1, dom-serializer@^1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-1.3.1.tgz#d845a1565d7c041a95e5dab62184ab41e3a519be" + integrity sha512-Pv2ZluG5ife96udGgEDovOOOA5UELkltfJpnIExPrAk1LTvecolUGn6lIaoLh86d83GiB86CjzciMd9BuRB71Q== dependencies: domelementtype "^2.0.1" domhandler "^4.0.0" @@ -2865,10 +2954,10 @@ domelementtype@1, domelementtype@^1.3.1: resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-1.3.1.tgz#d048c44b37b0d10a7f2a3d5fee3f4333d790481f" integrity sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w== -domelementtype@^2.0.1, domelementtype@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-2.1.0.tgz#a851c080a6d1c3d94344aed151d99f669edf585e" - integrity sha512-LsTgx/L5VpD+Q8lmsXSHW2WpA+eBlZ9HPf3erD1IoPF00/3JKHZ3BknUVA2QGDNu69ZNmyFmCWBSO45XjYKC5w== +domelementtype@^2.0.1, domelementtype@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-2.2.0.tgz#9a0b6c2782ed6a1c7323d42267183df9bd8b1d57" + integrity sha512-DtBMo82pv1dFtUmHyr48beiuq792Sxohr+8Hm9zoxklYPfa6n0Z3Byjj2IV7bmr2IyqClnqEQhfgHJJ5QF0R5A== domexception@^2.0.1: version "2.0.1" @@ -2884,19 +2973,12 @@ domhandler@^2.3.0: dependencies: domelementtype "1" -domhandler@^3.0.0: - version "3.3.0" - resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-3.3.0.tgz#6db7ea46e4617eb15cf875df68b2b8524ce0037a" - integrity sha512-J1C5rIANUbuYK+FuFL98650rihynUOEzRLxW+90bKZRWB6A1X1Tf82GxR1qAWLyfNPRvjqfip3Q5tdYlmAa9lA== +domhandler@^4.0.0, domhandler@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-4.2.0.tgz#f9768a5f034be60a89a27c2e4d0f74eba0d8b059" + integrity sha512-zk7sgt970kzPks2Bf+dwT/PLzghLnsivb9CcxkvR8Mzr66Olr0Ofd8neSbglHJHaHa2MadfoSdNlKYAaafmWfA== dependencies: - domelementtype "^2.0.1" - -domhandler@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-4.0.0.tgz#01ea7821de996d85f69029e81fa873c21833098e" - integrity sha512-KPTbnGQ1JeEMQyO1iYXoagsI6so/C96HZiFyByU3T6iAzpXn8EGEvct6unm1ZGoed8ByO2oirxgwxBmqKF9haA== - dependencies: - domelementtype "^2.1.0" + domelementtype "^2.2.0" domutils@^1.5.1: version "1.7.0" @@ -2906,14 +2988,14 @@ domutils@^1.5.1: dom-serializer "0" domelementtype "1" -domutils@^2.0.0, domutils@^2.4.3, domutils@^2.4.4: - version "2.4.4" - resolved "https://registry.yarnpkg.com/domutils/-/domutils-2.4.4.tgz#282739c4b150d022d34699797369aad8d19bbbd3" - integrity sha512-jBC0vOsECI4OMdD0GC9mGn7NXPLb+Qt6KW1YDQzeQYRUFKmNG8lh7mO5HiELfr+lLQE7loDVI4QcAxV80HS+RA== +domutils@^2.4.4, domutils@^2.5.2, domutils@^2.6.0: + version "2.6.0" + resolved "https://registry.yarnpkg.com/domutils/-/domutils-2.6.0.tgz#2e15c04185d43fb16ae7057cb76433c6edb938b7" + integrity sha512-y0BezHuy4MDYxh6OvolXYsH+1EMGmFbwv5FKW7ovwMG6zTPWqNPq3WF9ayZssFq+UlKdffGLbOEaghNdaOm1WA== dependencies: dom-serializer "^1.0.1" - domelementtype "^2.0.1" - domhandler "^4.0.0" + domelementtype "^2.2.0" + domhandler "^4.2.0" ecc-jsbn@~0.1.1: version "0.1.2" @@ -2979,7 +3061,7 @@ entities@^1.1.1: resolved "https://registry.yarnpkg.com/entities/-/entities-1.1.2.tgz#bdfa735299664dfafd34529ed4f8522a275fea56" integrity sha512-f2LZMYl1Fzu7YSBKg+RoROelpOaNrcGmE9AZubeDfrCEia483oW4MI4VyFd5VNHIgQ/7qm1I0wUHK1eJnn2y2w== -entities@^2.0.0, entities@~2.1.0: +entities@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/entities/-/entities-2.1.0.tgz#992d3129cf7df6870b96c57858c249a120f8b8b5" integrity sha512-hCx1oky9PFrJ611mf0ifBLBRW8lUUVRlFolb5gWRfIELabBlbp9xZvrqZLZAs+NxFnbfQoeGd8wDkygjg7U85w== @@ -4137,9 +4219,9 @@ hoist-non-react-statics@^3.3.0: react-is "^16.7.0" hosted-git-info@^2.1.4: - version "2.8.8" - resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.8.tgz#7539bd4bc1e0e0a895815a2e0262420b12858488" - integrity sha512-f/wzC2QaWBs7t9IYqB4T3sR1xviIViXJRJTWBlx2Gf3g0Xi5vI7Yy4koXQ1c9OYDGHN9sBy1DQ2AB8fqZBWhUg== + version "2.8.9" + resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.9.tgz#dffc0bf9a21c02209090f2aa69429e1414daf3f9" + integrity sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw== hosted-git-info@^3.0.6: version "3.0.7" @@ -4189,16 +4271,6 @@ htmlparser2@^3.10.0: inherits "^2.0.1" readable-stream "^3.1.1" -htmlparser2@^4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-4.1.0.tgz#9a4ef161f2e4625ebf7dfbe6c0a2f52d18a59e78" - integrity sha512-4zDq1a1zhE4gQso/c5LP1OtrhYTncXNSpvJYtWJBtXAETPlMfi3IFNjGuQbYLuVY4ZR0QMqRVvo4Pdy9KLyP8Q== - dependencies: - domelementtype "^2.0.1" - domhandler "^3.0.0" - domutils "^2.0.0" - entities "^2.0.0" - htmlparser2@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-6.0.0.tgz#c2da005030390908ca4c91e5629e418e0665ac01" @@ -4209,6 +4281,16 @@ htmlparser2@^6.0.0: domutils "^2.4.4" entities "^2.0.0" +htmlparser2@^6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-6.1.0.tgz#c4d762b6c3371a05dbe65e94ae43a9f845fb8fb7" + integrity sha512-gyyPk6rgonLFEDGoeRgQNaEUvdJ4ktTmmUh/h2t7s+M8oPpIPxgNACWa+6ESR57kXstwqPiCut0V8NRpcwgU7A== + dependencies: + domelementtype "^2.0.1" + domhandler "^4.0.0" + domutils "^2.5.2" + entities "^2.0.0" + http-signature@~1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/http-signature/-/http-signature-1.2.0.tgz#9aecd925114772f3d95b65a60abb8f7c18fbace1" @@ -5496,9 +5578,9 @@ lodash.sortby@^4.7.0: integrity sha1-7dFMgk4sycHgsKG0K7UhBRakJDg= lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.2.1: - version "4.17.20" - resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.20.tgz#b44a9b6297bcb698f1c51a3545a2b3b368d59c52" - integrity sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA== + version "4.17.21" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" + integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== log-symbols@^4.0.0: version "4.0.0" @@ -5588,8 +5670,8 @@ mathml-tag-names@^2.1.3: integrity sha512-APMBEanjybaPzUrfqU0IMU5I0AswKMH7k8OTLs0vvV4KZpExkTkY87nR/zpbuTPj+gARop7aGUbl11pnDfW6xg== "matrix-js-sdk@github:matrix-org/matrix-js-sdk#develop": - version "9.10.0" - resolved "https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/4204b2170a1e04f20067b87636bb2eddf95194c4" + version "11.0.0" + resolved "https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/52a893a8116d60bb76f1b015d3161a15404b3628" dependencies: "@babel/runtime" "^7.12.5" another-json "^0.2.0" @@ -5614,10 +5696,18 @@ matrix-react-test-utils@^0.2.2: resolved "https://registry.yarnpkg.com/matrix-react-test-utils/-/matrix-react-test-utils-0.2.2.tgz#c87144d3b910c7edc544a6699d13c7c2bf02f853" integrity sha512-49+7gfV6smvBIVbeloql+37IeWMTD+fiywalwCqk8Dnz53zAFjKSltB3rmWHso1uecLtQEcPtCijfhzcLXAxTQ== -matrix-widget-api@^0.1.0-beta.13: - version "0.1.0-beta.13" - resolved "https://registry.yarnpkg.com/matrix-widget-api/-/matrix-widget-api-0.1.0-beta.13.tgz#ebddc83eaef39bbb87b621a02a35902e1a29b9ef" - integrity sha512-DJAvuX2E7gxc/a9rtJPDh17ba9xGIOAoBHcWirNTN3KGodzsrZ+Ns+M/BREFWMwGS5yEBZko5eq7uhXStEbnyQ== +"matrix-web-i18n@github:matrix-org/matrix-web-i18n": + version "1.1.2" + resolved "https://codeload.github.com/matrix-org/matrix-web-i18n/tar.gz/63f9119bc0bc304e83d4e8e22364caa7850e7671" + dependencies: + "@babel/parser" "^7.13.16" + "@babel/traverse" "^7.13.17" + walk "^2.3.14" + +matrix-widget-api@^0.1.0-beta.14: + version "0.1.0-beta.14" + resolved "https://registry.yarnpkg.com/matrix-widget-api/-/matrix-widget-api-0.1.0-beta.14.tgz#e38beed71c5ebd62c1ac1d79ef262d7150b42c70" + integrity sha512-5tC6LO1vCblKg/Hfzf5U1eHPz1nHUZIobAm3gkEKV5vpYPgRpr8KdkLiGB78VZid0tB17CVtAb4VKI8CQ3lhAQ== dependencies: "@types/events" "^3.0.0" events "^3.2.0" @@ -6210,7 +6300,12 @@ parse-json@^5.0.0: json-parse-even-better-errors "^2.3.0" lines-and-columns "^1.1.6" -parse5-htmlparser2-tree-adapter@^6.0.0: +parse-srcset@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/parse-srcset/-/parse-srcset-1.0.2.tgz#f2bd221f6cc970a938d88556abc589caaaa2bde1" + integrity sha1-8r0iH2zJcKk42IVWq8WJyqqiveE= + +parse5-htmlparser2-tree-adapter@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-6.0.1.tgz#2cdf9ad823321140370d4dbf5d3e92c7c8ddc6e6" integrity sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA== @@ -6222,7 +6317,7 @@ parse5@5.1.1: resolved "https://registry.yarnpkg.com/parse5/-/parse5-5.1.1.tgz#f68e4e5ba1852ac2cadc00f4555fff6c2abb6178" integrity sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug== -parse5@^6.0.0, parse5@^6.0.1: +parse5@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/parse5/-/parse5-6.0.1.tgz#e1a1c085c569b3dc08321184f19a39cc27f7c30b" integrity sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw== @@ -7151,17 +7246,18 @@ sane@^4.0.3: minimist "^1.1.1" walker "~1.0.5" -"sanitize-html@github:apostrophecms/sanitize-html#3c7f93f2058f696f5359e3e58d464161647226db": - version "2.0.0-rc.3" - resolved "https://codeload.github.com/apostrophecms/sanitize-html/tar.gz/3c7f93f2058f696f5359e3e58d464161647226db" +sanitize-html@^2.3.2: + version "2.3.3" + resolved "https://registry.yarnpkg.com/sanitize-html/-/sanitize-html-2.3.3.tgz#3db382c9a621cce4c46d90f10c64f1e9da9e8353" + integrity sha512-DCFXPt7Di0c6JUnlT90eIgrjs6TsJl/8HYU3KLdmrVclFN4O0heTcVbJiMa23OKVr6aR051XYtsgd8EWwEBwUA== dependencies: deepmerge "^4.2.2" escape-string-regexp "^4.0.0" - htmlparser2 "^4.1.0" + htmlparser2 "^6.0.0" is-plain-object "^5.0.0" klona "^2.0.3" + parse-srcset "^1.0.2" postcss "^8.0.2" - srcset "^3.0.0" saxes@^5.0.0: version "5.0.1" @@ -7418,11 +7514,6 @@ sprintf-js@~1.0.2: resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" integrity sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw= -srcset@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/srcset/-/srcset-3.0.0.tgz#8afd8b971362dfc129ae9c1a99b3897301ce6441" - integrity sha512-D59vF08Qzu/C4GAOXVgMTLfgryt5fyWo93FZyhEWANo0PokFz/iWdDe13mX3O5TRf6l8vMTqckAfR4zPiaH0yQ== - sshpk@^1.7.0: version "1.16.1" resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.16.1.tgz#fb661c0bef29b39db40769ee39fa70093d6f6877" @@ -7907,6 +7998,11 @@ tslib@^2.0.0, tslib@^2.0.1, tslib@^2.0.3: resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.1.0.tgz#da60860f1c2ecaa5703ab7d39bc05b6bf988b97a" integrity sha512-hcVC3wYEziELGGmEEXue7D75zbwIIVUMWAVbHItGPx0ziyXxrOMQx4rQEVEV45Ut/1IotuEvwqPopzIOkDMf0A== +tslib@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.2.0.tgz#fb2c475977e35e241311ede2693cee1ec6698f5c" + integrity sha512-gS9GVHRU+RGn5KQM2rllAlR3dU6m7AcpJKdtH8gFvQiC4Otgk98XnmMU+nZenHt/+VhnBPWwgrJsyrdcw6i23w== + tsutils@^3.17.1: version "3.19.1" resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.19.1.tgz#d8566e0c51c82f32f9c25a4d367cd62409a547a9" @@ -7978,9 +8074,9 @@ typescript@^4.1.3: integrity sha512-B3ZIOf1IKeH2ixgHhj6la6xdwR9QrLC5d1VKeCSY4tvkqhF2eqd9O7txNlS0PO3GrBAFIdr3L1ndNwteUbZLYg== ua-parser-js@^0.7.18: - version "0.7.23" - resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.23.tgz#704d67f951e13195fbcd3d78818577f5bc1d547b" - integrity sha512-m4hvMLxgGHXG3O3fQVAyyAQpZzDOvwnhOTjYz5Xmr7r/+LpkNy3vJXdVRWgd1TkAb7NGROZuSy96CrlNVjA7KA== + version "0.7.28" + resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.28.tgz#8ba04e653f35ce210239c64661685bf9121dec31" + integrity sha512-6Gurc1n//gjp9eQNXjD9O3M/sMwVtN5S8Lv9bvOYBfKfDNiIIhqiyi01vMBO45u4zkDE420w/e0se7Vs+sIg+g== unhomoglyph@^1.0.6: version "1.0.6" @@ -8144,11 +8240,6 @@ validate-npm-package-license@^3.0.1: spdx-correct "^3.0.0" spdx-expression-parse "^3.0.0" -velocity-animate@^2.0.6: - version "2.0.6" - resolved "https://registry.yarnpkg.com/velocity-animate/-/velocity-animate-2.0.6.tgz#1811ca14df7fbbef05740256f6cec0fd1b76575f" - integrity sha512-tU+/UtSo3GkIjEfk2KM4e24DvpgX0+FzfLr7XqNwm9BCvZUtbCHPq/AFutx/Mkp2bXlUS9EcX8yxu8XmzAv2Kw== - verror@1.10.0: version "1.10.0" resolved "https://registry.yarnpkg.com/verror/-/verror-1.10.0.tgz#3a105ca17053af55d6e270c1f8288682e18da400"