diff --git a/CHANGELOG.md b/CHANGELOG.md index 089bfa73e0..b2e1f1af0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,356 @@ +Changes in [2.9.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v2.9.0) (2020-07-03) +=================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v2.9.0-rc.1...v2.9.0) + + * Upgrade to JS SDK 7.1.0 + * Remove duplicate compact settings, handle device level updates + [\#4889](https://github.com/matrix-org/matrix-react-sdk/pull/4889) + +Changes in [2.9.0-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v2.9.0-rc.1) (2020-07-01) +============================================================================================================= +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v2.8.1...v2.9.0-rc.1) + + * Upgrade to JS SDK 7.1.0-rc.1 + * Update from Weblate + [\#4869](https://github.com/matrix-org/matrix-react-sdk/pull/4869) + * Fix a number of proliferation issues in the new room list + [\#4828](https://github.com/matrix-org/matrix-react-sdk/pull/4828) + * Fix jumping to read marker for events without tiles + [\#4860](https://github.com/matrix-org/matrix-react-sdk/pull/4860) + * De-duplicate rooms from the room autocomplete provider + [\#4859](https://github.com/matrix-org/matrix-react-sdk/pull/4859) + * Add file upload button to recovery key input + [\#4847](https://github.com/matrix-org/matrix-react-sdk/pull/4847) + * Implement new design on security setup & login + [\#4831](https://github.com/matrix-org/matrix-react-sdk/pull/4831) + * Fix /join slash command via servers including room id as a via + [\#4856](https://github.com/matrix-org/matrix-react-sdk/pull/4856) + * Add Generic Expiring Toast and timing hooks + [\#4855](https://github.com/matrix-org/matrix-react-sdk/pull/4855) + * Fix Room Custom Sounds regression and make ProgressBar relevant again + [\#4846](https://github.com/matrix-org/matrix-react-sdk/pull/4846) + * Including start_sso and start_cas in redirect loop prevention + [\#4854](https://github.com/matrix-org/matrix-react-sdk/pull/4854) + * Clean up TODO comments for new room list + [\#4850](https://github.com/matrix-org/matrix-react-sdk/pull/4850) + * Show timestamp of redaction on hover + [\#4622](https://github.com/matrix-org/matrix-react-sdk/pull/4622) + * Remove the DM button from new room tiles + [\#4849](https://github.com/matrix-org/matrix-react-sdk/pull/4849) + * Hide room list show less button if it would do nothing + [\#4848](https://github.com/matrix-org/matrix-react-sdk/pull/4848) + * Improve message preview copy in new room list + [\#4823](https://github.com/matrix-org/matrix-react-sdk/pull/4823) + * Allow the tag panel to be disabled in the new room list + [\#4844](https://github.com/matrix-org/matrix-react-sdk/pull/4844) + * Make the whole user row clickable in the new room list + [\#4843](https://github.com/matrix-org/matrix-react-sdk/pull/4843) + * Add a new spinner design behind a labs flag + [\#4842](https://github.com/matrix-org/matrix-react-sdk/pull/4842) + * ts-ignore because something is made of fail + [\#4845](https://github.com/matrix-org/matrix-react-sdk/pull/4845) + * Fix Welcome.html CAS and SSO URLs not working + [\#4838](https://github.com/matrix-org/matrix-react-sdk/pull/4838) + * More small tweaks in preparation for Notifications rework + [\#4835](https://github.com/matrix-org/matrix-react-sdk/pull/4835) + * Iterate on the new room list resize handle + [\#4840](https://github.com/matrix-org/matrix-react-sdk/pull/4840) + * Update sublists for new hover states + [\#4837](https://github.com/matrix-org/matrix-react-sdk/pull/4837) + * Tweak parts of the new room list design + [\#4839](https://github.com/matrix-org/matrix-react-sdk/pull/4839) + * Implement new resize handle for dogfooding + [\#4836](https://github.com/matrix-org/matrix-react-sdk/pull/4836) + * Hide app badge count for hidden upgraded rooms (non-highlight) + [\#4834](https://github.com/matrix-org/matrix-react-sdk/pull/4834) + * Move compact modern layout checkbox to 'advanced' + [\#4822](https://github.com/matrix-org/matrix-react-sdk/pull/4822) + * Allow the user to resize the new sublists to 1 tile + [\#4825](https://github.com/matrix-org/matrix-react-sdk/pull/4825) + * Make LoggedInView a real component because it uses shouldComponentUpdate + [\#4832](https://github.com/matrix-org/matrix-react-sdk/pull/4832) + * Small tweaks in preparation for Notifications rework + [\#4829](https://github.com/matrix-org/matrix-react-sdk/pull/4829) + * Remove extraneous debug from the new left panel + [\#4826](https://github.com/matrix-org/matrix-react-sdk/pull/4826) + * Fix icons in the new user menu not showing up + [\#4824](https://github.com/matrix-org/matrix-react-sdk/pull/4824) + * Fix sticky room disappearing/jumping in search results + [\#4817](https://github.com/matrix-org/matrix-react-sdk/pull/4817) + * Show cross-signing / secret storage reset button in more cases + [\#4821](https://github.com/matrix-org/matrix-react-sdk/pull/4821) + * Use theme-capable icons in the user menu + [\#4819](https://github.com/matrix-org/matrix-react-sdk/pull/4819) + * Font support in custom themes + [\#4814](https://github.com/matrix-org/matrix-react-sdk/pull/4814) + * Decrease margin between new sublists + [\#4816](https://github.com/matrix-org/matrix-react-sdk/pull/4816) + * Update profile information in User Menu and truncate where needed + [\#4818](https://github.com/matrix-org/matrix-react-sdk/pull/4818) + * Fix MessageActionBar in irc layout + [\#4802](https://github.com/matrix-org/matrix-react-sdk/pull/4802) + * Mark messages with a black shield if the megolm session isn't trusted + [\#4797](https://github.com/matrix-org/matrix-react-sdk/pull/4797) + * Custom font selection + [\#4761](https://github.com/matrix-org/matrix-react-sdk/pull/4761) + * Use the correct timeline reference for message previews + [\#4812](https://github.com/matrix-org/matrix-react-sdk/pull/4812) + * Fix read receipt handling in the new room list + [\#4811](https://github.com/matrix-org/matrix-react-sdk/pull/4811) + * Improve unread/badge states in new room list (mk II) + [\#4805](https://github.com/matrix-org/matrix-react-sdk/pull/4805) + * Only fire setting changes for changed settings + [\#4803](https://github.com/matrix-org/matrix-react-sdk/pull/4803) + * Trigger room-specific watchers whenever a higher level change happens + [\#4804](https://github.com/matrix-org/matrix-react-sdk/pull/4804) + * Have the theme switcher set the device-level theme to match settings + [\#4810](https://github.com/matrix-org/matrix-react-sdk/pull/4810) + * Fix layout of minimized view for new room list + [\#4808](https://github.com/matrix-org/matrix-react-sdk/pull/4808) + * Fix sticky headers over/under extending themselves in the new room list + [\#4809](https://github.com/matrix-org/matrix-react-sdk/pull/4809) + * Update read receipt remainder for internal font size change + [\#4806](https://github.com/matrix-org/matrix-react-sdk/pull/4806) + * Fix some appearance tab crash and implement style nits + [\#4801](https://github.com/matrix-org/matrix-react-sdk/pull/4801) + * Add message preview for font slider + [\#4770](https://github.com/matrix-org/matrix-react-sdk/pull/4770) + * Add layout options to the appearance tab + [\#4773](https://github.com/matrix-org/matrix-react-sdk/pull/4773) + * Update from Weblate + [\#4800](https://github.com/matrix-org/matrix-react-sdk/pull/4800) + * Support accounts with cross signing but no SSSS + [\#4717](https://github.com/matrix-org/matrix-react-sdk/pull/4717) + * Look for existing verification requests after login + [\#4762](https://github.com/matrix-org/matrix-react-sdk/pull/4762) + * Add a checkpoint to index newly encrypted rooms. + [\#4611](https://github.com/matrix-org/matrix-react-sdk/pull/4611) + * Add support to paginate search results when using Seshat. + [\#4705](https://github.com/matrix-org/matrix-react-sdk/pull/4705) + * User versions in the event index. + [\#4788](https://github.com/matrix-org/matrix-react-sdk/pull/4788) + * Fix crash when filtering new room list too fast + [\#4796](https://github.com/matrix-org/matrix-react-sdk/pull/4796) + * hide search results from unknown rooms + [\#4795](https://github.com/matrix-org/matrix-react-sdk/pull/4795) + * Mark the new room list as ready for general testing + [\#4794](https://github.com/matrix-org/matrix-react-sdk/pull/4794) + * Extend QueryMatcher's sorting heuristic + [\#4784](https://github.com/matrix-org/matrix-react-sdk/pull/4784) + * Lint ts semicolons (aka. The great semicolon migration) + [\#4791](https://github.com/matrix-org/matrix-react-sdk/pull/4791) + * Revert "Use recovery keys over passphrases" + [\#4790](https://github.com/matrix-org/matrix-react-sdk/pull/4790) + * Clear `top` when not sticking headers to the top + [\#4783](https://github.com/matrix-org/matrix-react-sdk/pull/4783) + * Don't show a 'show less' button when it's impossible to collapse + [\#4785](https://github.com/matrix-org/matrix-react-sdk/pull/4785) + * Fix show less/more button occluding the list automatically + [\#4786](https://github.com/matrix-org/matrix-react-sdk/pull/4786) + * Improve room switching in the new room list + [\#4787](https://github.com/matrix-org/matrix-react-sdk/pull/4787) + * Remove labs option to cache 'passphrase' + [\#4789](https://github.com/matrix-org/matrix-react-sdk/pull/4789) + * Remove escape backslashes in non-Markdown messages + [\#4694](https://github.com/matrix-org/matrix-react-sdk/pull/4694) + +Changes in [2.8.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v2.8.1) (2020-06-29) +=================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v2.8.0...v2.8.1) + + * Support accounts with cross signing but no SSSS + [\#4852](https://github.com/matrix-org/matrix-react-sdk/pull/4852) + +Changes in [2.8.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v2.8.0) (2020-06-23) +=================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v2.8.0-rc.1...v2.8.0) + + * Upgrade to JS SDK 7.0.0 + * Update read receipt remainder for internal font size change + [\#4807](https://github.com/matrix-org/matrix-react-sdk/pull/4807) + * Revert "Use recovery keys over passphrases" + [\#4793](https://github.com/matrix-org/matrix-react-sdk/pull/4793) + +Changes in [2.8.0-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v2.8.0-rc.1) (2020-06-17) +============================================================================================================= +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v2.7.2...v2.8.0-rc.1) + + * Upgrade to JS SDK 7.0.0-rc.1 + * Fix Styled Checkbox and Radio Button disabled state + [\#4778](https://github.com/matrix-org/matrix-react-sdk/pull/4778) + * clean up and fix the isMasterRuleEnabled logic + [\#4782](https://github.com/matrix-org/matrix-react-sdk/pull/4782) + * Fix case-sensitivity of /me to match rest of slash commands + [\#4763](https://github.com/matrix-org/matrix-react-sdk/pull/4763) + * Add a 'show less' button to the new room list + [\#4765](https://github.com/matrix-org/matrix-react-sdk/pull/4765) + * Update from Weblate + [\#4781](https://github.com/matrix-org/matrix-react-sdk/pull/4781) + * Sticky and collapsing headers for new room list + [\#4758](https://github.com/matrix-org/matrix-react-sdk/pull/4758) + * Make the room list labs setting reload on change + [\#4780](https://github.com/matrix-org/matrix-react-sdk/pull/4780) + * Handle/hide old rooms in the room list + [\#4767](https://github.com/matrix-org/matrix-react-sdk/pull/4767) + * Add some media queries to improve UI on mobile (#3991) + [\#4656](https://github.com/matrix-org/matrix-react-sdk/pull/4656) + * Match fuzzy filtering a bit more reliably in the new room list + [\#4769](https://github.com/matrix-org/matrix-react-sdk/pull/4769) + * Improve Field ts definitions some more + [\#4777](https://github.com/matrix-org/matrix-react-sdk/pull/4777) + * Fix alignment of checkboxes in new room list's context menu + [\#4776](https://github.com/matrix-org/matrix-react-sdk/pull/4776) + * Fix Field ts def, fix LocalEchoWrapper and NotificationsEnabledController + [\#4775](https://github.com/matrix-org/matrix-react-sdk/pull/4775) + * Add presence indicators and globes to new room list + [\#4774](https://github.com/matrix-org/matrix-react-sdk/pull/4774) + * Include the sticky room when filtering in the new room list + [\#4772](https://github.com/matrix-org/matrix-react-sdk/pull/4772) + * Add a home button to the new room list menu when available + [\#4771](https://github.com/matrix-org/matrix-react-sdk/pull/4771) + * use group layout for search results + [\#4764](https://github.com/matrix-org/matrix-react-sdk/pull/4764) + * Fix m.id.phone spec compliance + [\#4757](https://github.com/matrix-org/matrix-react-sdk/pull/4757) + * User Info default power levels for ban/kick/redact to 50 as per spec + [\#4759](https://github.com/matrix-org/matrix-react-sdk/pull/4759) + * Match new room list's text search to old room list + [\#4768](https://github.com/matrix-org/matrix-react-sdk/pull/4768) + * Fix ordering of recent rooms in the new room list + [\#4766](https://github.com/matrix-org/matrix-react-sdk/pull/4766) + * Change theme selector to use new styled radio buttons + [\#4731](https://github.com/matrix-org/matrix-react-sdk/pull/4731) + * Use recovery keys over passphrases + [\#4686](https://github.com/matrix-org/matrix-react-sdk/pull/4686) + * Update from Weblate + [\#4760](https://github.com/matrix-org/matrix-react-sdk/pull/4760) + * Initial dark theme support for new room list + [\#4756](https://github.com/matrix-org/matrix-react-sdk/pull/4756) + * Support per-list options and algorithms on the new room list + [\#4754](https://github.com/matrix-org/matrix-react-sdk/pull/4754) + * Send read marker updates immediately after moving visually + [\#4755](https://github.com/matrix-org/matrix-react-sdk/pull/4755) + * Add a minimized view to the new room list + [\#4753](https://github.com/matrix-org/matrix-react-sdk/pull/4753) + * Fix e2e icon alignment in irc-layout + [\#4752](https://github.com/matrix-org/matrix-react-sdk/pull/4752) + * Add some resource leak protection to new room list badges + [\#4750](https://github.com/matrix-org/matrix-react-sdk/pull/4750) + * Fix read-receipt alignment + [\#4747](https://github.com/matrix-org/matrix-react-sdk/pull/4747) + * Show message previews on the new room list tiles + [\#4751](https://github.com/matrix-org/matrix-react-sdk/pull/4751) + * Fix various layout concerns with the new room list + [\#4749](https://github.com/matrix-org/matrix-react-sdk/pull/4749) + * Prioritize text on the clipboard over file + [\#4748](https://github.com/matrix-org/matrix-react-sdk/pull/4748) + * Move Settings flag to ts + [\#4729](https://github.com/matrix-org/matrix-react-sdk/pull/4729) + * Add a context menu to rooms in the new room list + [\#4743](https://github.com/matrix-org/matrix-react-sdk/pull/4743) + * Add hover states and basic context menu to new room list + [\#4742](https://github.com/matrix-org/matrix-react-sdk/pull/4742) + * Update resize handle for new designs in new room list + [\#4741](https://github.com/matrix-org/matrix-react-sdk/pull/4741) + * Improve general stability in the new room list + [\#4740](https://github.com/matrix-org/matrix-react-sdk/pull/4740) + * Reimplement breadcrumbs for new room list + [\#4735](https://github.com/matrix-org/matrix-react-sdk/pull/4735) + * Add styled radio buttons + [\#4744](https://github.com/matrix-org/matrix-react-sdk/pull/4744) + * Hide checkbox tick on dark backgrounds + [\#4730](https://github.com/matrix-org/matrix-react-sdk/pull/4730) + * Make checkboxes a11y friendly + [\#4746](https://github.com/matrix-org/matrix-react-sdk/pull/4746) + * EventIndex: Store and restore the encryption info for encrypted events. + [\#4738](https://github.com/matrix-org/matrix-react-sdk/pull/4738) + * Use IDestroyable instead of IDisposable + [\#4739](https://github.com/matrix-org/matrix-react-sdk/pull/4739) + * Add/improve badge counts in new room list + [\#4734](https://github.com/matrix-org/matrix-react-sdk/pull/4734) + * Convert FormattingUtils to TypeScript and add badge utility function + [\#4732](https://github.com/matrix-org/matrix-react-sdk/pull/4732) + * Add filtering and exploring to the new room list + [\#4736](https://github.com/matrix-org/matrix-react-sdk/pull/4736) + * Support prioritized room list filters + [\#4737](https://github.com/matrix-org/matrix-react-sdk/pull/4737) + * Clean up font scaling appearance + [\#4733](https://github.com/matrix-org/matrix-react-sdk/pull/4733) + * Add user menu to new room list + [\#4722](https://github.com/matrix-org/matrix-react-sdk/pull/4722) + * New room list basic styling and layout + [\#4711](https://github.com/matrix-org/matrix-react-sdk/pull/4711) + * Fix read receipt overlap + [\#4727](https://github.com/matrix-org/matrix-react-sdk/pull/4727) + * Load correct default font size + [\#4726](https://github.com/matrix-org/matrix-react-sdk/pull/4726) + * send state of lowBandwidth in rageshakes + [\#4724](https://github.com/matrix-org/matrix-react-sdk/pull/4724) + * Change internal font size from from 15 to 10 + [\#4725](https://github.com/matrix-org/matrix-react-sdk/pull/4725) + * Upgrade deps + [\#4723](https://github.com/matrix-org/matrix-react-sdk/pull/4723) + * Ensure active Jitsi conference is closed on widget pop-out + [\#4444](https://github.com/matrix-org/matrix-react-sdk/pull/4444) + * Introduce sticky rooms to the new room list + [\#4720](https://github.com/matrix-org/matrix-react-sdk/pull/4720) + * Handle remaining cases for room updates in new room list + [\#4721](https://github.com/matrix-org/matrix-react-sdk/pull/4721) + * Allow searching the emoji picker using other emoji + [\#4719](https://github.com/matrix-org/matrix-react-sdk/pull/4719) + * New room list scrolling and resizing + [\#4697](https://github.com/matrix-org/matrix-react-sdk/pull/4697) + * Don't show FormatBar if composer is empty + [\#4696](https://github.com/matrix-org/matrix-react-sdk/pull/4696) + * Split the left panel into new and old for new room list designs + [\#4687](https://github.com/matrix-org/matrix-react-sdk/pull/4687) + * Fix compact layout regression + [\#4712](https://github.com/matrix-org/matrix-react-sdk/pull/4712) + * fix emoji in safari + [\#4710](https://github.com/matrix-org/matrix-react-sdk/pull/4710) + * Fix not being able to dismiss new login toasts + [\#4709](https://github.com/matrix-org/matrix-react-sdk/pull/4709) + * Fix exceptions from Tooltip + [\#4708](https://github.com/matrix-org/matrix-react-sdk/pull/4708) + * Stop removing variation selector from quick reactions + [\#4707](https://github.com/matrix-org/matrix-react-sdk/pull/4707) + * Tidy up continuation algorithm and make it work for hidden profile changes + [\#4704](https://github.com/matrix-org/matrix-react-sdk/pull/4704) + * Profile settings should never show a disambiguated display name + [\#4699](https://github.com/matrix-org/matrix-react-sdk/pull/4699) + * Prevent (double) 4S bootstrap from RestoreKeyBackupDialog + [\#4701](https://github.com/matrix-org/matrix-react-sdk/pull/4701) + * Stop checkbox styling bleeding through room address selector + [\#4691](https://github.com/matrix-org/matrix-react-sdk/pull/4691) + * Center HeaderButtons + [\#4695](https://github.com/matrix-org/matrix-react-sdk/pull/4695) + * Add .well-known option to control default e2ee behaviour + [\#4605](https://github.com/matrix-org/matrix-react-sdk/pull/4605) + * Add max-width to right and left panels + [\#4692](https://github.com/matrix-org/matrix-react-sdk/pull/4692) + * Fix login loop where the sso flow returns to `#/login` + [\#4685](https://github.com/matrix-org/matrix-react-sdk/pull/4685) + * Don't clear MAU toasts when a successful sync comes in + [\#4690](https://github.com/matrix-org/matrix-react-sdk/pull/4690) + * Add initial filtering support to new room list + [\#4681](https://github.com/matrix-org/matrix-react-sdk/pull/4681) + * Bubble up a decline-to-render of verification events to outside wrapper + [\#4664](https://github.com/matrix-org/matrix-react-sdk/pull/4664) + * upgrade to twemoji 13.0.0 + [\#4672](https://github.com/matrix-org/matrix-react-sdk/pull/4672) + * Apply FocusLock to ImageView to capture Escape handling + [\#4666](https://github.com/matrix-org/matrix-react-sdk/pull/4666) + * Fix the 'complete security' screen + [\#4689](https://github.com/matrix-org/matrix-react-sdk/pull/4689) + * add null-guard for Autocomplete containerRef + [\#4688](https://github.com/matrix-org/matrix-react-sdk/pull/4688) + * Remove legacy codepaths for Unknown Device Error (UDE/UDD) handling + [\#4660](https://github.com/matrix-org/matrix-react-sdk/pull/4660) + * Remove feature_cross_signing + [\#4655](https://github.com/matrix-org/matrix-react-sdk/pull/4655) + * Autocomplete: use scrollIntoView for auto-scroll to fix it + [\#4670](https://github.com/matrix-org/matrix-react-sdk/pull/4670) + Changes in [2.7.2](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v2.7.2) (2020-06-16) =================================================================================================== [Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v2.7.1...v2.7.2) diff --git a/package.json b/package.json index 5f9b7dde1f..41ba3f47c1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-react-sdk", - "version": "2.7.2", + "version": "2.9.0", "description": "SDK for matrix.org using React", "author": "matrix.org", "repository": { diff --git a/res/css/_common.scss b/res/css/_common.scss index e83c6aaeda..6e70618142 100644 --- a/res/css/_common.scss +++ b/res/css/_common.scss @@ -319,7 +319,7 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus { } .mx_Dialog_titleImage { - vertical-align: middle; + vertical-align: sub; width: 25px; height: 25px; margin-left: -2px; @@ -428,6 +428,10 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus { border-radius: 8px; padding: 0px; box-shadow: none; + + /* Don't show scroll-bars on spinner dialogs */ + overflow-x: hidden; + overflow-y: hidden; } // TODO: Review mx_GeneralButton usage to see if it can use a different class @@ -584,27 +588,16 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus { // A context menu that largely fits the | [icon] [label] | format. .mx_IconizedContextMenu { - // Put 20px of padding around the whole menu. We do this instead of a - // simple `padding: 20px` rule so the horizontal rules added by the - // optionLists is rendered correctly (full width). - > * { - padding-left: 20px; - padding-right: 20px; - - &:first-child { - padding-top: 20px; - } - - &:last-child { - padding-bottom: 20px; - } - } + min-width: 146px; .mx_IconizedContextMenu_optionList { + & > * { + padding-left: 20px; + padding-right: 20px; + } + // the notFirst class is for cases where the optionList might be under a header of sorts. &:nth-child(n + 2), .mx_IconizedContextMenu_optionList_notFirst { - margin-top: 20px; - // This is a bit of a hack when we could just use a simple border-top property, // however we have a (kinda) good reason for doing it this way: we need opacity. // To get the right color, we need an opacity modifier which means we have to work @@ -627,72 +620,76 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus { } } - ul { - list-style: none; - margin: 0; - padding: 0; + // round the top corners of the top button for the hover effect to be bounded + &:first-child .mx_AccessibleButton:first-child { + border-radius: 4px 4px 0 0; // radius matches .mx_ContextualMenu + } - li { - margin: 0; - padding: 20px 0 0; + // round the bottom corners of the bottom button for the hover effect to be bounded + &:last-child .mx_AccessibleButton:last-child { + border-radius: 0 0 4px 4px; // radius matches .mx_ContextualMenu + } - .mx_AccessibleButton { - text-decoration: none; - color: $primary-fg-color; - font-size: $font-15px; - line-height: $font-24px; + .mx_AccessibleButton { + // pad the inside of the button so that the hover background is padded too + padding-top: 12px; + padding-bottom: 12px; + text-decoration: none; + color: $primary-fg-color; + font-size: $font-15px; + line-height: $font-24px; - // Create a flexbox to more easily define the list items - display: flex; - align-items: center; + // Create a flexbox to more easily define the list items + display: flex; + align-items: center; - img, .mx_IconizedContextMenu_icon { // icons - width: 16px; - min-width: 16px; - max-width: 16px; - } + &:hover { + background-color: $menu-selected-color; + } - span:last-child { // labels - padding-left: 14px; - width: 100%; - flex: 1; + img, .mx_IconizedContextMenu_icon { // icons + width: 16px; + min-width: 16px; + max-width: 16px; + } - // Ellipsize any text overflow - text-overflow: ellipsis; - overflow: hidden; - white-space: nowrap; - } - } + span.mx_IconizedContextMenu_label { // labels + padding-left: 14px; + width: 100%; + flex: 1; + + // Ellipsize any text overflow + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; } } } &.mx_IconizedContextMenu_compact { - > * { - padding-left: 11px; - padding-right: 16px; - - &:first-child { - padding-top: 13px; - } - - &:last-child { - padding-bottom: 13px; - } - } - - .mx_IconizedContextMenu_optionList { - &:nth-child(n + 2), .mx_IconizedContextMenu_optionList_notFirst { - margin-top: 10px; - - li:first-child { - padding-top: 10px; - } - } - - li:first-child { - padding-top: 0; - } + .mx_IconizedContextMenu_optionList > * { + padding: 8px 16px 8px 11px; } } } + +@define-mixin ProgressBarColour $colour { + color: $colour; + &::-moz-progress-bar { + background-color: $colour; + } + &::-webkit-progress-value { + background-color: $colour; + } +} + +@define-mixin ProgressBarBorderRadius $radius { + border-radius: $radius; + &::-moz-progress-bar { + border-radius: $radius; + } + &::-webkit-progress-bar, + &::-webkit-progress-value { + border-radius: $radius; + } +} diff --git a/res/css/_components.scss b/res/css/_components.scss index 66eb98ea9d..8288cf34f6 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -30,7 +30,7 @@ @import "./structures/_ToastContainer.scss"; @import "./structures/_TopLeftMenuButton.scss"; @import "./structures/_UploadBar.scss"; -@import "./structures/_UserMenuButton.scss"; +@import "./structures/_UserMenu.scss"; @import "./structures/_ViewSource.scss"; @import "./structures/auth/_CompleteSecurity.scss"; @import "./structures/auth/_Login.scss"; @@ -49,6 +49,7 @@ @import "./views/auth/_ServerTypeSelector.scss"; @import "./views/auth/_Welcome.scss"; @import "./views/avatars/_BaseAvatar.scss"; +@import "./views/avatars/_DecoratedRoomAvatar.scss"; @import "./views/avatars/_MemberStatusMessageAvatar.scss"; @import "./views/context_menus/_MessageContextMenu.scss"; @import "./views/context_menus/_RoomTileContextMenu.scss"; diff --git a/res/css/structures/_LeftPanel2.scss b/res/css/structures/_LeftPanel2.scss index 5cdefa0324..bdaada0d15 100644 --- a/res/css/structures/_LeftPanel2.scss +++ b/res/css/structures/_LeftPanel2.scss @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -// TODO: Rename to mx_LeftPanel during replacement of old component +// TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14231 $tagPanelWidth: 70px; // only applies in this file, used for calculations @@ -38,6 +38,12 @@ $tagPanelWidth: 70px; // only applies in this file, used for calculations // TagPanel handles its own CSS } + &:not(.mx_LeftPanel2_hasTagPanel) { + .mx_LeftPanel2_roomListContainer { + width: 100%; + } + } + // Note: The 'room list' in this context is actually everything that isn't the tag // panel, such as the menu options, breadcrumbs, filtering, etc .mx_LeftPanel2_roomListContainer { @@ -48,13 +54,13 @@ $tagPanelWidth: 70px; // only applies in this file, used for calculations flex-direction: column; .mx_LeftPanel2_userHeader { - padding: 14px 12px 20px; // 14px top, 12px sides, 20px bottom + padding: 12px 12px 20px; // 12px top, 12px sides, 20px bottom // Create another flexbox column for the rows to stack within display: flex; flex-direction: column; - // There's 2 rows when breadcrumbs are present: the top bit and the breadcrumbs + // This is basically just breadcrumbs. The row above that is handled by the UserMenu .mx_LeftPanel2_headerRow { // Create yet another flexbox, this time within the row, to ensure items stay // aligned correctly. This is also a row-based flexbox. @@ -62,25 +68,10 @@ $tagPanelWidth: 70px; // only applies in this file, used for calculations align-items: center; } - .mx_LeftPanel2_userAvatarContainer { - position: relative; // to make default avatars work - margin-right: 8px; - } - - .mx_LeftPanel2_userName { - font-weight: 600; - font-size: $font-15px; - line-height: $font-20px; - flex: 1; - } - - .mx_LeftPanel2_headerButtons { - // No special styles: the rest of the layout happens to make it work. - } - .mx_LeftPanel2_breadcrumbsContainer { width: 100%; - overflow: hidden; + overflow-y: hidden; + overflow-x: scroll; margin-top: 8px; } } @@ -143,21 +134,16 @@ $tagPanelWidth: 70px; // only applies in this file, used for calculations min-width: unset; // We have to forcefully set the width to override the resizer's style attribute. - width: calc(68px + $tagPanelWidth) !important; + &.mx_LeftPanel2_hasTagPanel { + width: calc(68px + $tagPanelWidth) !important; + } + &:not(.mx_LeftPanel2_hasTagPanel) { + width: 68px !important; + } .mx_LeftPanel2_roomListContainer { width: 68px; - .mx_LeftPanel2_userHeader { - .mx_LeftPanel2_headerRow { - justify-content: center; - } - - .mx_LeftPanel2_userAvatarContainer { - margin-right: 0; - } - } - .mx_LeftPanel2_filterContainer { // Organize the flexbox into a centered column layout flex-direction: column; diff --git a/res/css/structures/_UserMenu.scss b/res/css/structures/_UserMenu.scss new file mode 100644 index 0000000000..c958b9eacd --- /dev/null +++ b/res/css/structures/_UserMenu.scss @@ -0,0 +1,196 @@ +/* +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. +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_UserMenu { + .mx_UserMenu_headerButtons { + width: 16px; + height: 16px; + position: relative; + display: block; + + &::before { + content: ''; + width: 16px; + height: 16px; + position: absolute; + top: 0; + left: 0; + mask-position: center; + mask-size: contain; + mask-repeat: no-repeat; + background: $primary-fg-color; + mask-image: url('$(res)/img/feather-customised/more-horizontal.svg'); + } + } + + .mx_UserMenu_row { + // Create a row-based flexbox to ensure items stay aligned correctly. + display: flex; + align-items: center; + + .mx_UserMenu_userAvatarContainer { + position: relative; // to make default avatars work + margin-right: 8px; + height: 32px; // to remove the unknown 4px gap the browser puts below it + + .mx_UserMenu_userAvatar { + border-radius: 32px; // should match avatar size + } + } + + .mx_UserMenu_userName { + font-weight: 600; + font-size: $font-15px; + line-height: $font-20px; + flex: 1; + + // Ellipsize any text overflow + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + } + + .mx_UserMenu_headerButtons { + // No special styles: the rest of the layout happens to make it work. + } + } + + &.mx_UserMenu_minimized { + .mx_UserMenu_userHeader { + .mx_UserMenu_row { + justify-content: center; + } + + .mx_UserMenu_userAvatarContainer { + margin-right: 0; + } + } + } +} + +.mx_UserMenu_contextMenu { + width: 247px; + + .mx_UserMenu_contextMenu_redRow { + .mx_AccessibleButton { + padding-top: 16px; + padding-bottom: 16px; + color: $warning-color !important; // !important to override styles from context menu + } + + .mx_IconizedContextMenu_icon::before { + background-color: $warning-color; + } + } + + .mx_UserMenu_contextMenu_header { + padding: 20px; + + // Create a flexbox to organize the header a bit easier + display: flex; + align-items: center; + + .mx_UserMenu_contextMenu_name { + // Create another flexbox of columns to handle large user IDs + display: flex; + flex-direction: column; + width: calc(100% - 40px); // 40px = 32px theme button + 8px margin to theme button + + * { + // Automatically grow all subelements to fit the container + flex: 1; + width: 100%; + + // Ellipsize any text overflow + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + } + + .mx_UserMenu_contextMenu_displayName { + font-weight: bold; + font-size: $font-15px; + line-height: $font-20px; + } + + .mx_UserMenu_contextMenu_userId { + font-size: $font-15px; + line-height: $font-24px; + } + } + + .mx_UserMenu_contextMenu_themeButton { + min-width: 32px; + max-width: 32px; + width: 32px; + height: 32px; + margin-left: 8px; + border-radius: 32px; + background-color: $theme-button-bg-color; + cursor: pointer; + + // to make alignment easier, create flexbox for the image + display: flex; + align-items: center; + justify-content: center; + } + } + + .mx_IconizedContextMenu_icon { + width: 16px; + height: 16px; + display: block; + + &::before { + content: ''; + width: 16px; + height: 16px; + display: block; + mask-position: center; + mask-size: contain; + mask-repeat: no-repeat; + background: $primary-fg-color; + } + } + + .mx_UserMenu_iconHome::before { + mask-image: url('$(res)/img/feather-customised/home.svg'); + } + + .mx_UserMenu_iconBell::before { + mask-image: url('$(res)/img/feather-customised/notifications.svg'); + } + + .mx_UserMenu_iconLock::before { + mask-image: url('$(res)/img/feather-customised/lock.svg'); + } + + .mx_UserMenu_iconSettings::before { + mask-image: url('$(res)/img/feather-customised/settings.svg'); + } + + .mx_UserMenu_iconArchive::before { + mask-image: url('$(res)/img/feather-customised/archive.svg'); + } + + .mx_UserMenu_iconMessage::before { + mask-image: url('$(res)/img/feather-customised/message-circle.svg'); + } + + .mx_UserMenu_iconSignOut::before { + mask-image: url('$(res)/img/feather-customised/sign-out.svg'); + } +} diff --git a/res/css/structures/_UserMenuButton.scss b/res/css/structures/_UserMenuButton.scss deleted file mode 100644 index 1fbbbb5fd8..0000000000 --- a/res/css/structures/_UserMenuButton.scss +++ /dev/null @@ -1,82 +0,0 @@ -/* -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. -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_UserMenuButton { - // No special styles on the button itself -} - -.mx_UserMenuButton_contextMenu { - width: 247px; - - .mx_UserMenuButton_contextMenu_header { - // Create a flexbox to organize the header a bit easier - display: flex; - align-items: center; - - &:nth-child(n + 1) { - // The first header will have appropriate padding, subsequent ones need a margin. - margin-top: 10px; - } - - .mx_UserMenuButton_contextMenu_name { - // Create another flexbox of columns to handle large user IDs - display: flex; - flex-direction: column; - - // fit the container - flex: 1; - width: calc(100% - 40px); // 40px = 32px theme button + 8px margin to theme button - - * { - // Automatically grow all subelements to fit the container - flex: 1; - width: 100%; - - // Ellipsize any text overflow - text-overflow: ellipsis; - overflow: hidden; - white-space: nowrap; - } - - .mx_UserMenuButton_contextMenu_displayName { - font-weight: bold; - font-size: $font-15px; - line-height: $font-20px; - } - - .mx_UserMenuButton_contextMenu_userId { - font-size: $font-15px; - line-height: $font-24px; - } - } - - .mx_UserMenuButton_contextMenu_themeButton { - min-width: 32px; - max-width: 32px; - width: 32px; - height: 32px; - margin-left: 8px; - border-radius: 32px; - background-color: $theme-button-bg-color; - cursor: pointer; - - // to make alignment easier, create flexbox for the image - display: flex; - align-items: center; - justify-content: center; - } - } -} diff --git a/res/css/views/auth/_PassphraseField.scss b/res/css/views/auth/_PassphraseField.scss index d1b8c47d00..bf8e7f4438 100644 --- a/res/css/views/auth/_PassphraseField.scss +++ b/res/css/views/auth/_PassphraseField.scss @@ -18,16 +18,6 @@ $PassphraseStrengthHigh: $accent-color; $PassphraseStrengthMedium: $username-variant5-color; $PassphraseStrengthLow: $notice-primary-color; -@define-mixin ProgressBarColour $colour { - color: $colour; - &::-moz-progress-bar { - background-color: $colour; - } - &::-webkit-progress-value { - background-color: $colour; - } -} - progress.mx_PassphraseField_progress { appearance: none; width: 100%; @@ -36,15 +26,7 @@ progress.mx_PassphraseField_progress { position: absolute; top: -12px; - border-radius: 2px; - &::-moz-progress-bar { - border-radius: 2px; - } - &::-webkit-progress-bar, - &::-webkit-progress-value { - border-radius: 2px; - } - + @mixin ProgressBarBorderRadius "2px"; @mixin ProgressBarColour $PassphraseStrengthLow; &[value="2"], &[value="3"] { @mixin ProgressBarColour $PassphraseStrengthMedium; diff --git a/res/css/views/avatars/_DecoratedRoomAvatar.scss b/res/css/views/avatars/_DecoratedRoomAvatar.scss new file mode 100644 index 0000000000..b500d44a43 --- /dev/null +++ b/res/css/views/avatars/_DecoratedRoomAvatar.scss @@ -0,0 +1,34 @@ +/* +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. +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. +*/ + +// XXX: We shouldn't be using TemporaryTile anywhere - delete it. +.mx_DecoratedRoomAvatar, .mx_TemporaryTile { + position: relative; + + .mx_RoomTileIcon { + position: absolute; + bottom: 0; + right: 0; + } + + .mx_NotificationBadge { + position: absolute; + top: 0; + right: 0; + height: 18px; + width: 18px; + } +} diff --git a/res/css/views/dialogs/secretstorage/_AccessSecretStorageDialog.scss b/res/css/views/dialogs/secretstorage/_AccessSecretStorageDialog.scss index db11e91bdb..63d0ca555d 100644 --- a/res/css/views/dialogs/secretstorage/_AccessSecretStorageDialog.scss +++ b/res/css/views/dialogs/secretstorage/_AccessSecretStorageDialog.scss @@ -15,20 +15,79 @@ See the License for the specific language governing permissions and limitations under the License. */ +.mx_AccessSecretStorageDialog_titleWithIcon::before { + content: ''; + display: inline-block; + width: 24px; + height: 24px; + margin-right: 8px; + position: relative; + top: 5px; + background-color: $primary-fg-color; +} + +.mx_AccessSecretStorageDialog_secureBackupTitle::before { + mask-image: url('$(res)/img/feather-customised/secure-backup.svg'); +} + +.mx_AccessSecretStorageDialog_securePhraseTitle::before { + mask-image: url('$(res)/img/feather-customised/secure-phrase.svg'); +} + .mx_AccessSecretStorageDialog_keyStatus { height: 30px; } -.mx_AccessSecretStorageDialog_primaryContainer { - /* FIXME: plinth colour in new theme(s). background-color: $accent-color; */ - padding: 20px; -} - -.mx_AccessSecretStorageDialog_passPhraseInput, -.mx_AccessSecretStorageDialog_recoveryKeyInput { +.mx_AccessSecretStorageDialog_passPhraseInput { width: 300px; border: 1px solid $accent-color; border-radius: 5px; padding: 10px; } +.mx_AccessSecretStorageDialog_recoveryKeyEntry { + display: flex; + align-items: center; +} + +.mx_AccessSecretStorageDialog_recoveryKeyEntry_textInput { + flex-grow: 1; +} + +.mx_AccessSecretStorageDialog_recoveryKeyEntry_entryControlSeparatorText { + margin: 16px; +} + +.mx_AccessSecretStorageDialog_recoveryKeyFeedback { + &::before { + content: ""; + display: inline-block; + vertical-align: bottom; + width: 20px; + height: 20px; + mask-repeat: no-repeat; + mask-position: center; + mask-size: 20px; + margin-right: 5px; + } +} + +.mx_AccessSecretStorageDialog_recoveryKeyFeedback_valid { + color: $input-valid-border-color; + &::before { + mask-image: url('$(res)/img/feather-customised/check.svg'); + background-color: $input-valid-border-color; + } +} + +.mx_AccessSecretStorageDialog_recoveryKeyFeedback_invalid { + color: $input-invalid-border-color; + &::before { + mask-image: url('$(res)/img/feather-customised/x.svg'); + background-color: $input-invalid-border-color; + } +} + +.mx_AccessSecretStorageDialog_recoveryKeyEntry_fileInput { + display: none; +} diff --git a/res/css/views/dialogs/secretstorage/_CreateSecretStorageDialog.scss b/res/css/views/dialogs/secretstorage/_CreateSecretStorageDialog.scss index 63e5a3de09..d30803b1f0 100644 --- a/res/css/views/dialogs/secretstorage/_CreateSecretStorageDialog.scss +++ b/res/css/views/dialogs/secretstorage/_CreateSecretStorageDialog.scss @@ -48,6 +48,29 @@ limitations under the License. margin-bottom: 1em; } +.mx_CreateSecretStorageDialog_titleWithIcon::before { + content: ''; + display: inline-block; + width: 24px; + height: 24px; + margin-right: 8px; + position: relative; + top: 5px; + background-color: $primary-fg-color; +} + +.mx_CreateSecretStorageDialog_secureBackupTitle::before { + mask-image: url('$(res)/img/feather-customised/secure-backup.svg'); +} + +.mx_CreateSecretStorageDialog_securePhraseTitle::before { + mask-image: url('$(res)/img/feather-customised/secure-phrase.svg'); +} + +.mx_CreateSecretStorageDialog_centeredTitle, .mx_CreateSecretStorageDialog_centeredBody { + text-align: center; +} + .mx_CreateSecretStorageDialog_primaryContainer { /* FIXME: plinth colour in new theme(s). background-color: $accent-color; */ padding-top: 20px; @@ -59,6 +82,36 @@ limitations under the License. display: block; } +.mx_CreateSecretStorageDialog_primaryContainer .mx_RadioButton { + margin-bottom: 16px; + padding: 11px; +} + +.mx_CreateSecretStorageDialog_optionTitle { + color: $dialog-title-fg-color; + font-weight: 600; + font-size: $font-18px; + padding-bottom: 10px; +} + +.mx_CreateSecretStorageDialog_optionIcon { + display: inline-block; + width: 24px; + height: 24px; + margin-right: 8px; + position: relative; + top: 5px; + background-color: $primary-fg-color; +} + +.mx_CreateSecretStorageDialog_optionIcon_securePhrase { + mask-image: url('$(res)/img/feather-customised/secure-phrase.svg'); +} + +.mx_CreateSecretStorageDialog_optionIcon_secureBackup { + mask-image: url('$(res)/img/feather-customised/secure-backup.svg'); +} + .mx_CreateSecretStorageDialog_passPhraseContainer { display: flex; align-items: flex-start; @@ -73,33 +126,42 @@ limitations under the License. margin-left: 20px; } -.mx_CreateSecretStorageDialog_recoveryKeyHeader { - margin-bottom: 1em; -} - .mx_CreateSecretStorageDialog_recoveryKeyContainer { - display: flex; + width: 380px; + margin-left: auto; + margin-right: auto; } .mx_CreateSecretStorageDialog_recoveryKey { - width: 262px; + font-weight: bold; + text-align: center; padding: 20px; color: $info-plinth-fg-color; background-color: $info-plinth-bg-color; - margin-right: 12px; + border-radius: 6px; + word-spacing: 1em; + margin-bottom: 20px; } .mx_CreateSecretStorageDialog_recoveryKeyButtons { - flex: 1; display: flex; + justify-content: space-between; align-items: center; } .mx_CreateSecretStorageDialog_recoveryKeyButtons .mx_AccessibleButton { - margin-right: 10px; -} - -.mx_CreateSecretStorageDialog_recoveryKeyButtons button { - flex: 1; + width: 160px; + padding-left: 0px; + padding-right: 0px; white-space: nowrap; } + +.mx_CreateSecretStorageDialog_continueSpinner { + margin-top: 33px; + text-align: right; +} + +.mx_CreateSecretStorageDialog_continueSpinner img { + width: 20px; + height: 20px; +} diff --git a/res/css/views/elements/_InlineSpinner.scss b/res/css/views/elements/_InlineSpinner.scss index 612b6209c6..6b91e45923 100644 --- a/res/css/views/elements/_InlineSpinner.scss +++ b/res/css/views/elements/_InlineSpinner.scss @@ -18,7 +18,7 @@ limitations under the License. display: inline; } -.mx_InlineSpinner img { +.mx_InlineSpinner_spin img { margin: 0px 6px; vertical-align: -3px; } diff --git a/res/css/views/elements/_ProgressBar.scss b/res/css/views/elements/_ProgressBar.scss index a3fee232d0..e49d85af04 100644 --- a/res/css/views/elements/_ProgressBar.scss +++ b/res/css/views/elements/_ProgressBar.scss @@ -1,5 +1,5 @@ /* -Copyright 2015, 2016 OpenMarket Ltd +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. @@ -14,12 +14,26 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_ProgressBar { - height: 5px; - border: 1px solid $progressbar-color; -} +progress.mx_ProgressBar { + height: 4px; + width: 60px; + border-radius: 10px; + overflow: hidden; + appearance: none; + border: 0; -.mx_ProgressBar_fill { - height: 100%; - background-color: $progressbar-color; + @mixin ProgressBarBorderRadius "10px"; + @mixin ProgressBarColour $accent-color; + ::-webkit-progress-value { + transition: width 1s; + } + ::-moz-progress-bar { + transition: padding-bottom 1s; + padding-bottom: var(--value); + transform-origin: 0 0; + transform: rotate(-90deg) translateX(-15px); + padding-left: 15px; + + height: 0; + } } diff --git a/res/css/views/elements/_Spinner.scss b/res/css/views/elements/_Spinner.scss index 01b4f23c2c..6966a60e52 100644 --- a/res/css/views/elements/_Spinner.scss +++ b/res/css/views/elements/_Spinner.scss @@ -23,6 +23,16 @@ limitations under the License. flex: 1; } +.mx_Spinner_spin img { + animation: spin 1s linear infinite; +} + +@keyframes spin { + 100% { + transform: rotate(360deg); + } +} + .mx_MatrixChat_middlePanel .mx_Spinner { height: auto; } diff --git a/res/css/views/elements/_StyledCheckbox.scss b/res/css/views/elements/_StyledCheckbox.scss index aab448605c..60f1bf0277 100644 --- a/res/css/views/elements/_StyledCheckbox.scss +++ b/res/css/views/elements/_StyledCheckbox.scss @@ -77,8 +77,8 @@ limitations under the License. } &:checked:disabled + label > .mx_Checkbox_background { - background-color: $muted-fg-color; - border-color: rgba($muted-fg-color, 0.5); + background-color: $accent-color; + border-color: $accent-color; } } } diff --git a/res/css/views/elements/_StyledRadioButton.scss b/res/css/views/elements/_StyledRadioButton.scss index c2edb359dc..ffa1337ebb 100644 --- a/res/css/views/elements/_StyledRadioButton.scss +++ b/res/css/views/elements/_StyledRadioButton.scss @@ -25,13 +25,14 @@ limitations under the License. position: relative; display: flex; - align-items: center; + align-items: baseline; flex-grow: 1; - > span { + > .mx_RadioButton_content { flex-grow: 1; display: flex; + flex-direction: column; margin-left: 8px; margin-right: 8px; @@ -105,3 +106,12 @@ limitations under the License. } } } + +.mx_RadioButton_outlined { + border: 1px solid $input-darker-bg-color; + border-radius: 8px; +} + +.mx_RadioButton_checked { + border-color: $accent-color; +} diff --git a/res/css/views/elements/_Tooltip.scss b/res/css/views/elements/_Tooltip.scss index 73ac9b3558..d67928bf83 100644 --- a/res/css/views/elements/_Tooltip.scss +++ b/res/css/views/elements/_Tooltip.scss @@ -55,7 +55,7 @@ limitations under the License. border-radius: 4px; box-shadow: 4px 4px 12px 0 $menu-box-shadow-color; background-color: $menu-bg-color; - z-index: 4000; // Higher than dialogs so tooltips can be used in dialogs + z-index: 6000; // Higher than context menu so tooltips can be used everywhere padding: 10px; pointer-events: none; line-height: $font-14px; diff --git a/res/css/views/rooms/_EventTile.scss b/res/css/views/rooms/_EventTile.scss index 2b204955d8..c168699b70 100644 --- a/res/css/views/rooms/_EventTile.scss +++ b/res/css/views/rooms/_EventTile.scss @@ -354,6 +354,11 @@ limitations under the License. opacity: 1; } +.mx_EventTile_e2eIcon_unauthenticated { + background-image: url('$(res)/img/e2e/normal.svg'); + opacity: 1; +} + .mx_EventTile_e2eIcon_hidden { display: none; } diff --git a/res/css/views/rooms/_IRCLayout.scss b/res/css/views/rooms/_IRCLayout.scss index 814a614007..94753f9473 100644 --- a/res/css/views/rooms/_IRCLayout.scss +++ b/res/css/views/rooms/_IRCLayout.scss @@ -121,11 +121,6 @@ $irc-line-height: $font-18px; } } - .mx_EventTile_line .mx_MessageActionBar, - .mx_EventTile_line .mx_ReplyThread_wrapper { - display: block; - } - .mx_EventTile_reply { order: 4; } diff --git a/res/css/views/rooms/_RoomBreadcrumbs2.scss b/res/css/views/rooms/_RoomBreadcrumbs2.scss index ac5a9fc34e..6e5a5fbb16 100644 --- a/res/css/views/rooms/_RoomBreadcrumbs2.scss +++ b/res/css/views/rooms/_RoomBreadcrumbs2.scss @@ -14,6 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ +// TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14231 + .mx_RoomBreadcrumbs2 { width: 100%; @@ -49,3 +51,18 @@ limitations under the License. height: 32px; } } + +.mx_RoomBreadcrumbs2_Tooltip { + margin-left: -42px; + margin-top: -42px; + + &.mx_Tooltip { + background-color: $tagpanel-bg-color; + color: $accent-fg-color; + border: 0; + + .mx_Tooltip_chevron { + display: none; + } + } +} diff --git a/res/css/views/rooms/_RoomSublist2.scss b/res/css/views/rooms/_RoomSublist2.scss index 66615fb6a8..0e76152f86 100644 --- a/res/css/views/rooms/_RoomSublist2.scss +++ b/res/css/views/rooms/_RoomSublist2.scss @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -// TODO: Rename to mx_RoomSublist during replacement of old component +// TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14231 .mx_RoomSublist2 { // The sublist is a column of rows, essentially @@ -22,10 +22,12 @@ limitations under the License. flex-direction: column; margin-left: 8px; - margin-top: 12px; - margin-bottom: 12px; width: 100%; + &:first-child { + margin-top: 12px; // so we're not up against the search/filter + } + .mx_RoomSublist2_headerContainer { // Create a flexbox to make alignment easy display: flex; @@ -83,23 +85,30 @@ limitations under the License. // *************************** .mx_RoomSublist2_badgeContainer { - opacity: 0.8; - width: 16px; - margin-right: 5px; // aligns with the room tile's badge - // Create another flexbox row because it's super easy to position the badge this way. display: flex; align-items: center; justify-content: center; + + // Apply the width and margin to the badge so the container doesn't occupy dead space + .mx_NotificationBadge { + // Do not set a width so the badges get properly sized + margin-left: 8px; // same as menu+aux buttons + } + } + + &:not(.mx_RoomSublist2_headerContainer_withAux) { + .mx_NotificationBadge { + margin-right: 4px; // just to push it over a bit, aligning it with the other elements + } } - // Both of these buttons are hidden by default until the list is hovered .mx_RoomSublist2_auxButton, .mx_RoomSublist2_menuButton { - width: 0; - margin: 0; - visibility: hidden; + margin-left: 8px; // should be the same as the notification badge position: relative; + width: 24px; + height: 24px; border-radius: 32px; &::before { @@ -116,6 +125,13 @@ limitations under the License. } } + // Hide the menu button by default + .mx_RoomSublist2_menuButton { + visibility: hidden; + width: 0; + margin: 0; + } + .mx_RoomSublist2_auxButton::before { mask-image: url('$(res)/img/feather-customised/plus.svg'); } @@ -128,9 +144,9 @@ limitations under the License. flex: 1; max-width: calc(100% - 16px); // 16px is the badge width text-transform: uppercase; - opacity: 0.5; line-height: $font-16px; font-size: $font-12px; + font-weight: 600; // Ellipsize any text overflow text-overflow: ellipsis; @@ -140,11 +156,9 @@ limitations under the License. .mx_RoomSublist2_collapseBtn { display: inline-block; position: relative; - - // Default hidden - visibility: hidden; - width: 0; - height: 0; + width: 12px; + height: 12px; + margin-right: 8px; &::before { content: ''; @@ -156,7 +170,7 @@ limitations under the License. mask-position: center; mask-size: contain; mask-repeat: no-repeat; - background: $primary-fg-color; + background-color: $primary-fg-color; mask-image: url('$(res)/img/feather-customised/chevron-down.svg'); } @@ -224,6 +238,16 @@ limitations under the License. .mx_RoomSublist2_showLessButtonChevron { mask-image: url('$(res)/img/feather-customised/chevron-up.svg'); } + + &.mx_RoomSublist2_isCutting::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 4px; + box-shadow: 0px -2px 3px rgba(46, 47, 50, 0.08); + } } // Class name comes from the ResizableBox component @@ -231,69 +255,35 @@ limitations under the License. // so that selector is below and one level higher. .react-resizable-handle { cursor: ns-resize; - border-radius: 2px; + border-radius: 3px; + + // Update RESIZE_HANDLE_HEIGHT if this changes + height: 4px; // This is positioned directly below the 'show more' button. position: absolute; bottom: 0; - left: 0; - right: 0; - // This is to visually align the bar in the list. Should be 12px from - // either side of the list. We define this after the positioning to - // trick the browser. - margin-left: 4px; - margin-right: 4px; + // Together, these make the bar 64px wide + left: calc(50% - 32px); + right: calc(50% - 32px); + } + + &:hover, &.mx_RoomSublist2_hasMenuOpen { + .react-resizable-handle { + opacity: 0.8; + background-color: $primary-fg-color; + } } } - // The aforementioned selector for the hover state. - &:hover, &.mx_RoomSublist2_hasMenuOpen { - .react-resizable-handle { - opacity: 0.2; - - // Update the render() function for RoomSublist2 if this changes - border: 2px solid $primary-fg-color; - } - - &:not(.mx_RoomSublist2_minimized) > .mx_RoomSublist2_headerContainer { - // If the header doesn't have an aux button we still need to hide the badge for - // the menu button. - .mx_RoomSublist2_badgeContainer { - // Completely hide the badge - width: 0; - margin: 0; - visibility: hidden; - } - - &:not(.mx_RoomSublist2_headerContainer_withAux) { - // The menu button will be the rightmost button, so make it correctly aligned. - .mx_RoomSublist2_menuButton { - margin-right: 1px; // line it up with the badges on the room tiles - } - } - - // Both of these buttons have circled backgrounds and are visible at this point, - // so make them so. - .mx_RoomSublist2_auxButton, - .mx_RoomSublist2_menuButton { - width: 24px; - height: 24px; - margin-left: 16px; - visibility: visible; - background-color: $roomlist2-button-bg-color; - } - } - - .mx_RoomSublist2_headerContainer { - .mx_RoomSublist2_headerText { - .mx_RoomSublist2_collapseBtn { - visibility: visible; - width: 12px; - height: 12px; - margin-right: 4px; - } - } + &.mx_RoomSublist2_hasMenuOpen, + &:not(.mx_RoomSublist2_minimized) > .mx_RoomSublist2_headerContainer:focus-within, + &:not(.mx_RoomSublist2_minimized) > .mx_RoomSublist2_headerContainer:hover { + .mx_RoomSublist2_menuButton { + visibility: visible; + width: 24px; + margin-left: 8px; } } @@ -304,18 +294,18 @@ limitations under the License. position: relative; .mx_RoomSublist2_badgeContainer { - order: 1; + order: 0; align-self: flex-end; margin-right: 0; } - .mx_RoomSublist2_headerText { - order: 2; + .mx_RoomSublist2_stickable { + order: 1; max-width: 100%; } .mx_RoomSublist2_auxButton { - order: 4; + order: 2; visibility: visible; width: 32px !important; // !important to override hover styles height: 32px !important; // !important to override hover styles @@ -342,7 +332,12 @@ limitations under the License. } } - &:hover, &.mx_RoomSublist2_hasMenuOpen { + .mx_RoomSublist2_menuButton { + height: 16px; + } + + &.mx_RoomSublist2_hasMenuOpen, + & > .mx_RoomSublist2_headerContainer:hover { .mx_RoomSublist2_menuButton { visibility: visible; position: absolute; @@ -363,7 +358,7 @@ limitations under the License. } } - .mx_RoomSublist2_headerContainer:not(.mx_RoomSublist2_headerContainer_withAux) { + &.mx_RoomSublist2_headerContainer:not(.mx_RoomSublist2_headerContainer_withAux) { .mx_RoomSublist2_menuButton { bottom: 8px; // align to the middle of name, 40px less than the `bottom` above. } @@ -372,27 +367,6 @@ limitations under the License. } } -// We have a hover style on the room list with no specific list hovered, so account for that -.mx_RoomList2:hover .mx_RoomSublist2:not(.mx_RoomSublist2_minimized), -.mx_RoomSublist2_hasMenuOpen:not(.mx_RoomSublist2_minimized) { - .mx_RoomSublist2_headerContainer_withAux { - .mx_RoomSublist2_badgeContainer { - // Completely hide the badge - width: 0; - margin: 0; - visibility: hidden; - } - - .mx_RoomSublist2_auxButton { - // Show the aux button, but not the list button - width: 24px; - height: 24px; - margin-right: 1px; // line it up with the badges on the room tiles - visibility: visible; - } - } -} - .mx_RoomSublist2_contextMenu { padding: 20px 16px; width: 250px; @@ -402,6 +376,7 @@ limitations under the License. margin-bottom: 16px; margin-right: 16px; // additional 16px border: 1px solid $roomsublist2-divider-color; + opacity: 0.1; } .mx_RoomSublist2_contextMenu_title { @@ -415,3 +390,7 @@ limitations under the License. margin-top: 8px; } } + +.mx_RoomSublist2_addRoomTooltip { + margin-top: -3px; +} diff --git a/res/css/views/rooms/_RoomTile2.scss b/res/css/views/rooms/_RoomTile2.scss index 001499fea5..7b606ab947 100644 --- a/res/css/views/rooms/_RoomTile2.scss +++ b/res/css/views/rooms/_RoomTile2.scss @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -// TODO: Rename to mx_RoomTile during replacement of old component +// TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14231 // Note: the room tile expects to be in a flexbox column container .mx_RoomTile2 { @@ -23,27 +23,23 @@ limitations under the License. // The tile is also a flexbox row itself display: flex; - flex-wrap: wrap; - &.mx_RoomTile2_selected, &:hover, &.mx_RoomTile2_hasMenuOpen { + &.mx_RoomTile2_selected, + &:hover, + &:focus-within, + &.mx_RoomTile2_hasMenuOpen { background-color: $roomtile2-selected-bg-color; border-radius: 32px; } - .mx_RoomTile2_avatarContainer { + .mx_DecoratedRoomAvatar, .mx_RoomTile2_avatarContainer { margin-right: 8px; - position: relative; - - .mx_RoomTileIcon { - position: absolute; - bottom: 0; - right: 0; - } } .mx_RoomTile2_nameContainer { flex-grow: 1; - max-width: calc(100% - 58px); // 32px avatar, 18px badge area, 8px margin on avatar + min-width: 0; // allow flex to shrink it + margin-right: 8px; // spacing to buttons/badges // Create a new column layout flexbox for the name parts display: flex; @@ -67,7 +63,7 @@ limitations under the License. } .mx_RoomTile2_name.mx_RoomTile2_nameHasUnreadEvents { - font-weight: 600; + font-weight: 700; } .mx_RoomTile2_messagePreview { @@ -81,30 +77,44 @@ limitations under the License. } } - .mx_RoomTile2_badgeContainer { - width: 18px; - height: 32px; - - // Create another flexbox row because it's super easy to position the badge at - // the end this way. - display: flex; - align-items: center; - justify-content: center; + .mx_RoomTile2_menuButton { + margin-left: 4px; // spacing between buttons } - // The menu button is hidden by default - // TODO: [Notifications] Use mx_RoomTile2_notificationsButton, similar to the following approach: - // https://github.com/matrix-org/matrix-react-sdk/blob/2180a56074f3698fc0241c309a72ba6cad802d1c/res/css/views/rooms/_RoomSublist2.scss#L48-L76 - // You'll need to do the same down below on the &:hover selector for the tile. - // ... also remove this 4 line TODO comment. + .mx_RoomTile2_badgeContainer { + height: 16px; + // don't set width so that it takes no space when there is no badge to show + margin: auto 0; // vertically align + position: relative; // fixes badge alignment in some scenarios + + // Create a flexbox to make aligning dot badges easier + display: flex; + align-items: center; + + .mx_NotificationBadge { + margin-right: 2px; // centering + } + + .mx_NotificationBadge_dot { + // make the smaller dot occupy the same width for centering + margin-left: 5px; + margin-right: 7px; + } + } + + // The context menu buttons are hidden by default .mx_RoomTile2_menuButton, .mx_RoomTile2_notificationsButton { - width: 0; - height: 0; - visibility: hidden; + width: 20px; + min-width: 20px; // yay flex + height: 20px; + margin: auto 0; position: relative; + display: none; &::before { + top: 2px; + left: 2px; content: ''; width: 16px; height: 16px; @@ -116,25 +126,29 @@ limitations under the License. } } + // If the room has an overriden notification setting then we always show the notifications menu button + .mx_RoomTile2_notificationsButton.mx_RoomTile2_notificationsButton_show { + display: block; + } + .mx_RoomTile2_menuButton::before { - top: 8px; - left: -1px; // this is off-center to align it with the badges mask-image: url('$(res)/img/feather-customised/more-horizontal.svg'); } &:not(.mx_RoomTile2_minimized) { - &:hover, &.mx_RoomTile2_hasMenuOpen { + &:hover, + &:focus-within, + &.mx_RoomTile2_hasMenuOpen { // Hide the badge container on hover because it'll be a menu button .mx_RoomTile2_badgeContainer { width: 0; height: 0; - visibility: hidden; + display: none; } + .mx_RoomTile2_notificationsButton, .mx_RoomTile2_menuButton { - width: 18px; - height: 32px; - visibility: visible; + display: block; } } } @@ -144,19 +158,29 @@ limitations under the License. align-items: center; position: relative; - .mx_RoomTile2_avatarContainer { + .mx_DecoratedRoomAvatar, .mx_RoomTile2_avatarContainer { margin-right: 0; } - - .mx_RoomTile2_badgeContainer { - position: absolute; - top: 0; - right: 0; - height: 18px; - } } } +// We use these both in context menus and the room tiles +.mx_RoomTile2_iconBell::before { + mask-image: url('$(res)/img/feather-customised/bell.svg'); +} +.mx_RoomTile2_iconBellDot::before { + mask-image: url('$(res)/img/feather-customised/bell-notification.custom.svg'); +} +.mx_RoomTile2_iconBellCrossed::before { + mask-image: url('$(res)/img/feather-customised/bell-crossed.svg'); +} +.mx_RoomTile2_iconBellMentions::before { + mask-image: url('$(res)/img/feather-customised/bell-mentions.custom.svg'); +} +.mx_RoomTile2_iconCheck::before { + mask-image: url('$(res)/img/feather-customised/check.svg'); +} + .mx_RoomTile2_contextMenu { .mx_RoomTile2_contextMenu_redRow { .mx_AccessibleButton { @@ -168,6 +192,16 @@ limitations under the License. } } + .mx_RoomTile2_contextMenu_activeRow { + &.mx_AccessibleButton, .mx_AccessibleButton { + color: $accent-color !important; // !important to override styles from context menu + } + + .mx_IconizedContextMenu_icon::before { + background-color: $accent-color; + } + } + .mx_IconizedContextMenu_icon { position: relative; width: 16px; @@ -193,10 +227,6 @@ limitations under the License. mask-image: url('$(res)/img/feather-customised/arrow-down.svg'); } - .mx_RoomTile2_iconUser::before { - mask-image: url('$(res)/img/feather-customised/user.svg'); - } - .mx_RoomTile2_iconSettings::before { mask-image: url('$(res)/img/feather-customised/settings.svg'); } diff --git a/res/css/views/settings/tabs/user/_AppearanceUserSettingsTab.scss b/res/css/views/settings/tabs/user/_AppearanceUserSettingsTab.scss index 044b5e2240..94983a60bf 100644 --- a/res/css/views/settings/tabs/user/_AppearanceUserSettingsTab.scss +++ b/res/css/views/settings/tabs/user/_AppearanceUserSettingsTab.scss @@ -16,11 +16,14 @@ limitations under the License. .mx_AppearanceUserSettingsTab_fontSlider, .mx_AppearanceUserSettingsTab_fontSlider_preview, -.mx_AppearanceUserSettingsTab_Layout, -.mx_AppearanceUserSettingsTab .mx_Field { +.mx_AppearanceUserSettingsTab_Layout { @mixin mx_Settings_fullWidthField; } +.mx_AppearanceUserSettingsTab .mx_Field { + width: 256px; +} + .mx_AppearanceUserSettingsTab_fontScaling { color: $primary-fg-color; } @@ -30,7 +33,7 @@ limitations under the License. flex-direction: row; align-items: center; padding: 15px; - background: $font-slider-bg-color; + background: rgba($appearance-tab-border-color, 0.2); border-radius: 10px; font-size: 10px; margin-top: 24px; @@ -38,7 +41,7 @@ limitations under the License. } .mx_AppearanceUserSettingsTab_fontSlider_preview { - border: 1px solid $input-darker-bg-color; + border: 1px solid $appearance-tab-border-color; border-radius: 10px; padding: 0 16px 9px 16px; pointer-events: none; @@ -56,12 +59,14 @@ limitations under the License. font-size: 15px; padding-right: 20px; padding-left: 5px; + font-weight: 500; } .mx_AppearanceUserSettingsTab_fontSlider_largeText { font-size: 18px; padding-left: 20px; padding-right: 5px; + font-weight: 500; } .mx_AppearanceUserSettingsTab { @@ -115,7 +120,8 @@ limitations under the License. } &.mx_ThemeSelector_dark { - background-color: #181b21; + // 5% lightened version of 181b21 + background-color: #25282e; color: #f3f8fd; > input > div { @@ -163,10 +169,11 @@ limitations under the License. width: 300px; - border: 1px solid $input-darker-bg-color; + border: 1px solid $appearance-tab-border-color; border-radius: 10px; - .mx_EventTile_msgOption { + .mx_EventTile_msgOption, + .mx_MessageActionBar { display: none; } @@ -175,6 +182,7 @@ limitations under the License. display: flex; align-items: center; padding: 10px; + pointer-events: none; } .mx_RadioButton { @@ -185,10 +193,14 @@ limitations under the License. .mx_EventTile_content { margin-right: 0; } + + &.mx_AppearanceUserSettingsTab_Layout_RadioButton_selected { + border-color: $accent-color; + } } .mx_RadioButton { - border-top: 1px solid $input-darker-bg-color; + border-top: 1px solid $appearance-tab-border-color; > input + div { border-color: rgba($muted-fg-color, 0.2); @@ -199,3 +211,20 @@ limitations under the License. background-color: rgba($accent-color, 0.08); } } + +.mx_AppearanceUserSettingsTab_Advanced { + color: $primary-fg-color; + + > * { + margin-bottom: 16px; + } + + .mx_AppearanceUserSettingsTab_AdvancedToggle { + color: $accent-color; + cursor: pointer; + } + + .mx_AppearanceUserSettingsTab_systemFont { + margin-left: calc($font-16px + 10px); + } +} diff --git a/res/img/feather-customised/bell-crossed.svg b/res/img/feather-customised/bell-crossed.svg new file mode 100644 index 0000000000..3ca24662b9 --- /dev/null +++ b/res/img/feather-customised/bell-crossed.svg @@ -0,0 +1,4 @@ + + + + diff --git a/res/img/feather-customised/bell-mentions.custom.svg b/res/img/feather-customised/bell-mentions.custom.svg new file mode 100644 index 0000000000..fcc02f337f --- /dev/null +++ b/res/img/feather-customised/bell-mentions.custom.svg @@ -0,0 +1,3 @@ + + + diff --git a/res/img/feather-customised/bell-notification.custom.svg b/res/img/feather-customised/bell-notification.custom.svg new file mode 100644 index 0000000000..7bfd551f97 --- /dev/null +++ b/res/img/feather-customised/bell-notification.custom.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/res/img/feather-customised/bell.svg b/res/img/feather-customised/bell.svg new file mode 100644 index 0000000000..b6bc5ec502 --- /dev/null +++ b/res/img/feather-customised/bell.svg @@ -0,0 +1,3 @@ + + + diff --git a/res/img/feather-customised/secure-backup.svg b/res/img/feather-customised/secure-backup.svg new file mode 100644 index 0000000000..c06f93c1fe --- /dev/null +++ b/res/img/feather-customised/secure-backup.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/res/img/feather-customised/secure-phrase.svg b/res/img/feather-customised/secure-phrase.svg new file mode 100644 index 0000000000..eb13d3f048 --- /dev/null +++ b/res/img/feather-customised/secure-phrase.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/res/img/spinner.svg b/res/img/spinner.svg new file mode 100644 index 0000000000..a18140c7e2 --- /dev/null +++ b/res/img/spinner.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/res/themes/dark/css/_dark.scss b/res/themes/dark/css/_dark.scss index 50f3c08782..1546e7a400 100644 --- a/res/themes/dark/css/_dark.scss +++ b/res/themes/dark/css/_dark.scss @@ -113,7 +113,7 @@ $theme-button-bg-color: #e3e8f0; $roomlist2-button-bg-color: #1A1D23; // Buttons include the filter box, explore button, and sublist buttons $roomlist2-bg-color: $header-panel-bg-color; -$roomsublist2-divider-color: #e9eaeb; +$roomsublist2-divider-color: $primary-fg-color; $roomtile2-preview-color: #9e9e9e; $roomtile2-default-badge-bg-color: #61708b; @@ -198,8 +198,8 @@ $breadcrumb-placeholder-bg-color: #272c35; $user-tile-hover-bg-color: $header-panel-bg-color; -// FontSlider colors -$font-slider-bg-color: $room-highlight-color; +// Appearance tab colors +$appearance-tab-border-color: $room-highlight-color; // ***** Mixins! ***** diff --git a/res/themes/light-custom/css/_custom.scss b/res/themes/light-custom/css/_custom.scss index 6206496150..e7912e3cb0 100644 --- a/res/themes/light-custom/css/_custom.scss +++ b/res/themes/light-custom/css/_custom.scss @@ -14,6 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ +$font-family: var(--font-family, $font-family); +$monospace-font-family: var(--font-family-monospace, $monospace-font-family); // // --accent-color $accent-color: var(--accent-color); diff --git a/res/themes/light/css/_light.scss b/res/themes/light/css/_light.scss index 355cc1301c..c4b4262642 100644 --- a/res/themes/light/css/_light.scss +++ b/res/themes/light/css/_light.scss @@ -180,7 +180,7 @@ $theme-button-bg-color: #e3e8f0; $roomlist2-button-bg-color: #fff; // Buttons include the filter box, explore button, and sublist buttons $roomlist2-bg-color: $header-panel-bg-color; -$roomsublist2-divider-color: #e9eaeb; +$roomsublist2-divider-color: $primary-fg-color; $roomtile2-preview-color: #9e9e9e; $roomtile2-default-badge-bg-color: #61708b; @@ -327,7 +327,7 @@ $breadcrumb-placeholder-bg-color: #e8eef5; $user-tile-hover-bg-color: $header-panel-bg-color; // FontSlider colors -$font-slider-bg-color: rgba($input-darker-bg-color, 0.2); +$appearance-tab-border-color: $input-darker-bg-color; // ***** Mixins! ***** diff --git a/src/BasePlatform.ts b/src/BasePlatform.ts index d54dc7dd23..1d11495e61 100644 --- a/src/BasePlatform.ts +++ b/src/BasePlatform.ts @@ -25,8 +25,8 @@ import {CheckUpdatesPayload} from "./dispatcher/payloads/CheckUpdatesPayload"; import {Action} from "./dispatcher/actions"; import {hideToast as hideUpdateToast} from "./toasts/UpdateToast"; -export const HOMESERVER_URL_KEY = "mx_hs_url"; -export const ID_SERVER_URL_KEY = "mx_is_url"; +export const SSO_HOMESERVER_URL_KEY = "mx_sso_hs_url"; +export const SSO_ID_SERVER_URL_KEY = "mx_sso_is_url"; export enum UpdateCheckStatus { Checking = "CHECKING", @@ -221,7 +221,7 @@ export default abstract class BasePlatform { setLanguage(preferredLangs: string[]) {} - getSSOCallbackUrl(fragmentAfterLogin: string): URL { + protected getSSOCallbackUrl(fragmentAfterLogin: string): URL { const url = new URL(window.location.href); url.hash = fragmentAfterLogin || ""; return url; @@ -235,9 +235,9 @@ export default abstract class BasePlatform { */ startSingleSignOn(mxClient: MatrixClient, loginType: "sso" | "cas", fragmentAfterLogin: string) { // persist hs url and is url for when the user is returned to the app with the login token - localStorage.setItem(HOMESERVER_URL_KEY, mxClient.getHomeserverUrl()); + localStorage.setItem(SSO_HOMESERVER_URL_KEY, mxClient.getHomeserverUrl()); if (mxClient.getIdentityServerUrl()) { - localStorage.setItem(ID_SERVER_URL_KEY, mxClient.getIdentityServerUrl()); + localStorage.setItem(SSO_ID_SERVER_URL_KEY, mxClient.getIdentityServerUrl()); } const callbackUrl = this.getSSOCallbackUrl(fragmentAfterLogin); window.location.href = mxClient.getSsoLoginUrl(callbackUrl.toString(), loginType); // redirect to SSO diff --git a/src/FromWidgetPostMessageApi.js b/src/FromWidgetPostMessageApi.js index 102afa6bf1..1b4aa19ebf 100644 --- a/src/FromWidgetPostMessageApi.js +++ b/src/FromWidgetPostMessageApi.js @@ -25,6 +25,7 @@ import RoomViewStore from "./stores/RoomViewStore"; import {IntegrationManagers} from "./integrations/IntegrationManagers"; import SettingsStore from "./settings/SettingsStore"; import {Capability} from "./widgets/WidgetApi"; +import {objectClone} from "./utils/objects"; const WIDGET_API_VERSION = '0.0.2'; // Current API version const SUPPORTED_WIDGET_API_VERSIONS = [ @@ -247,7 +248,7 @@ export default class FromWidgetPostMessageApi { * @param {Object} res Response data */ sendResponse(event, res) { - const data = JSON.parse(JSON.stringify(event.data)); + const data = objectClone(event.data); data.response = res; event.source.postMessage(data, event.origin); } @@ -260,7 +261,7 @@ export default class FromWidgetPostMessageApi { */ sendError(event, msg, nestedError) { console.error('Action:' + event.data.action + ' failed with message: ' + msg); - const data = JSON.parse(JSON.stringify(event.data)); + const data = objectClone(event.data); data.response = { error: { message: msg, diff --git a/src/Lifecycle.js b/src/Lifecycle.js index 96cefaf593..9ae4ae7e03 100644 --- a/src/Lifecycle.js +++ b/src/Lifecycle.js @@ -41,7 +41,10 @@ import {IntegrationManagers} from "./integrations/IntegrationManagers"; import {Mjolnir} from "./mjolnir/Mjolnir"; import DeviceListener from "./DeviceListener"; import {Jitsi} from "./widgets/Jitsi"; -import {HOMESERVER_URL_KEY, ID_SERVER_URL_KEY} from "./BasePlatform"; +import {SSO_HOMESERVER_URL_KEY, SSO_ID_SERVER_URL_KEY} from "./BasePlatform"; + +const HOMESERVER_URL_KEY = "mx_hs_url"; +const ID_SERVER_URL_KEY = "mx_is_url"; /** * Called at startup, to attempt to build a logged-in Matrix session. It tries @@ -164,8 +167,8 @@ export function attemptTokenLogin(queryParams, defaultDeviceDisplayName) { return Promise.resolve(false); } - const homeserver = localStorage.getItem(HOMESERVER_URL_KEY); - const identityServer = localStorage.getItem(ID_SERVER_URL_KEY); + const homeserver = localStorage.getItem(SSO_HOMESERVER_URL_KEY); + const identityServer = localStorage.getItem(SSO_ID_SERVER_URL_KEY); if (!homeserver) { console.warn("Cannot log in with token: can't determine HS URL to use"); return Promise.resolve(false); diff --git a/src/Notifier.js b/src/Notifier.js index cd328ba565..b6690959d2 100644 --- a/src/Notifier.js +++ b/src/Notifier.js @@ -122,7 +122,7 @@ const Notifier = { } }, - getSoundForRoom: async function(roomId) { + getSoundForRoom: function(roomId) { // We do no caching here because the SDK caches setting // and the browser will cache the sound. const content = SettingsStore.getValue("notificationSound", roomId); @@ -151,7 +151,7 @@ const Notifier = { }, _playAudioNotification: async function(ev, room) { - const sound = await this.getSoundForRoom(room.roomId); + const sound = this.getSoundForRoom(room.roomId); console.log(`Got sound ${sound && sound.name || "default"} for ${room.roomId}`); try { diff --git a/src/RoomNotifs.js b/src/RoomNotifs.js index c67acaf314..4614bef378 100644 --- a/src/RoomNotifs.js +++ b/src/RoomNotifs.js @@ -56,10 +56,11 @@ export function countRoomsWithNotif(rooms) { } export function aggregateNotificationCount(rooms) { - return rooms.reduce((result, room, index) => { + return rooms.reduce((result, room) => { const roomNotifState = getRoomNotifsState(room.roomId); const highlight = room.getUnreadNotificationCount('highlight') > 0; - const notificationCount = room.getUnreadNotificationCount(); + // use helper method to include highlights in the previous version of the room + const notificationCount = getUnreadNotificationCount(room); const notifBadges = notificationCount > 0 && shouldShowNotifBadge(roomNotifState); const mentionBadges = highlight && shouldShowMentionBadge(roomNotifState); diff --git a/src/ScalarMessaging.js b/src/ScalarMessaging.js index 315c2d86f4..b33aa57359 100644 --- a/src/ScalarMessaging.js +++ b/src/ScalarMessaging.js @@ -244,16 +244,17 @@ import RoomViewStore from './stores/RoomViewStore'; import { _t } from './languageHandler'; import {IntegrationManagers} from "./integrations/IntegrationManagers"; import {WidgetType} from "./widgets/WidgetType"; +import {objectClone} from "./utils/objects"; function sendResponse(event, res) { - const data = JSON.parse(JSON.stringify(event.data)); + const data = objectClone(event.data); data.response = res; event.source.postMessage(data, event.origin); } function sendError(event, msg, nestedError) { console.error("Action:" + event.data.action + " failed with message: " + msg); - const data = JSON.parse(JSON.stringify(event.data)); + const data = objectClone(event.data); data.response = { error: { message: msg, diff --git a/src/SlashCommands.tsx b/src/SlashCommands.tsx index 7ebdc4ee3b..f667c47b3c 100644 --- a/src/SlashCommands.tsx +++ b/src/SlashCommands.tsx @@ -495,8 +495,7 @@ export const Commands = [ }); return success(); } else if (params[0][0] === '!') { - const roomId = params[0]; - const viaServers = params.splice(0); + const [roomId, ...viaServers] = params; dis.dispatch({ action: 'view_room', diff --git a/src/TextForEvent.js b/src/TextForEvent.js index 09cfb67de7..3607d7a676 100644 --- a/src/TextForEvent.js +++ b/src/TextForEvent.js @@ -265,22 +265,13 @@ function textForServerACLEvent(ev) { return text + changes.join(" "); } -function textForMessageEvent(ev, skipUserPrefix) { +function textForMessageEvent(ev) { const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); let message = senderDisplayName + ': ' + ev.getContent().body; - if (skipUserPrefix) { - message = ev.getContent().body; - if (ev.getContent().msgtype === "m.emote") { - message = senderDisplayName + " " + message; - } else if (ev.getContent().msgtype === "m.image") { - message = _t('sent an image.'); - } - } else { - if (ev.getContent().msgtype === "m.emote") { - message = "* " + senderDisplayName + " " + message; - } else if (ev.getContent().msgtype === "m.image") { - message = _t('%(senderDisplayName)s sent an image.', {senderDisplayName}); - } + if (ev.getContent().msgtype === "m.emote") { + message = "* " + senderDisplayName + " " + message; + } else if (ev.getContent().msgtype === "m.image") { + message = _t('%(senderDisplayName)s sent an image.', {senderDisplayName}); } return message; } @@ -621,8 +612,8 @@ for (const evType of ALL_RULE_TYPES) { stateHandlers[evType] = textForMjolnirEvent; } -export function textForEvent(ev, skipUserPrefix) { +export function textForEvent(ev) { const handler = (ev.isState() ? stateHandlers : handlers)[ev.getType()]; - if (handler) return handler(ev, skipUserPrefix); + if (handler) return handler(ev); return ''; } diff --git a/src/async-components/views/dialogs/secretstorage/CreateSecretStorageDialog.js b/src/async-components/views/dialogs/secretstorage/CreateSecretStorageDialog.js index d7b79c2cfa..4cef817a38 100644 --- a/src/async-components/views/dialogs/secretstorage/CreateSecretStorageDialog.js +++ b/src/async-components/views/dialogs/secretstorage/CreateSecretStorageDialog.js @@ -26,20 +26,27 @@ import { promptForBackupPassphrase } from '../../../../CrossSigningManager'; import {copyNode} from "../../../../utils/strings"; import {SSOAuthEntry} from "../../../../components/views/auth/InteractiveAuthEntryComponents"; import PassphraseField from "../../../../components/views/auth/PassphraseField"; +import StyledRadioButton from '../../../../components/views/elements/StyledRadioButton'; +import AccessibleButton from "../../../../components/views/elements/AccessibleButton"; +import DialogButtons from "../../../../components/views/elements/DialogButtons"; +import InlineSpinner from "../../../../components/views/elements/InlineSpinner"; const PHASE_LOADING = 0; const PHASE_LOADERROR = 1; -const PHASE_MIGRATE = 2; -const PHASE_PASSPHRASE = 3; -const PHASE_PASSPHRASE_CONFIRM = 4; -const PHASE_SHOWKEY = 5; -const PHASE_KEEPITSAFE = 6; -const PHASE_STORING = 7; -const PHASE_DONE = 8; -const PHASE_CONFIRM_SKIP = 9; +const PHASE_CHOOSE_KEY_PASSPHRASE = 2; +const PHASE_MIGRATE = 3; +const PHASE_PASSPHRASE = 4; +const PHASE_PASSPHRASE_CONFIRM = 5; +const PHASE_SHOWKEY = 6; +const PHASE_STORING = 8; +const PHASE_CONFIRM_SKIP = 10; const PASSWORD_MIN_SCORE = 4; // So secure, many characters, much complex, wow, etc, etc. +// these end up as strings from being values in the radio buttons, so just use strings +const CREATE_STORAGE_OPTION_KEY = 'key'; +const CREATE_STORAGE_OPTION_PASSPHRASE = 'passphrase'; + /* * Walks the user through the process of creating a passphrase to guard Secure * Secret Storage in account data. @@ -70,6 +77,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent { passPhraseConfirm: '', copied: false, downloaded: false, + setPassphrase: false, backupInfo: null, backupSigStatus: null, // does the server offer a UI auth flow with just m.login.password @@ -77,8 +85,8 @@ export default class CreateSecretStorageDialog extends React.PureComponent { canUploadKeysWithPasswordOnly: null, accountPassword: props.accountPassword || "", accountPasswordCorrect: null, - // status of the key backup toggle switch - useKeyBackup: true, + + passPhraseKeySelected: CREATE_STORAGE_OPTION_KEY, }; this._passphraseField = createRef(); @@ -110,7 +118,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent { ); const { force } = this.props; - const phase = (backupInfo && !force) ? PHASE_MIGRATE : PHASE_PASSPHRASE; + const phase = (backupInfo && !force) ? PHASE_MIGRATE : PHASE_CHOOSE_KEY_PASSPHRASE; this.setState({ phase, @@ -152,14 +160,33 @@ export default class CreateSecretStorageDialog extends React.PureComponent { if (this.state.phase === PHASE_MIGRATE) this._fetchBackupInfo(); } + _onKeyPassphraseChange = e => { + this.setState({ + passPhraseKeySelected: e.target.value, + }); + } + _collectRecoveryKeyNode = (n) => { this._recoveryKeyNode = n; } - _onUseKeyBackupChange = (enabled) => { - this.setState({ - useKeyBackup: enabled, - }); + _onChooseKeyPassphraseFormSubmit = async () => { + if (this.state.passPhraseKeySelected === CREATE_STORAGE_OPTION_KEY) { + this._recoveryKey = + await MatrixClientPeg.get().createRecoveryKeyFromPassphrase(); + this.setState({ + copied: false, + downloaded: false, + setPassphrase: false, + phase: PHASE_SHOWKEY, + }); + } else { + this.setState({ + copied: false, + downloaded: false, + phase: PHASE_PASSPHRASE, + }); + } } _onMigrateFormSubmit = (e) => { @@ -176,7 +203,6 @@ export default class CreateSecretStorageDialog extends React.PureComponent { if (successful) { this.setState({ copied: true, - phase: PHASE_KEEPITSAFE, }); } } @@ -189,7 +215,6 @@ export default class CreateSecretStorageDialog extends React.PureComponent { this.setState({ downloaded: true, - phase: PHASE_KEEPITSAFE, }); } @@ -259,22 +284,15 @@ export default class CreateSecretStorageDialog extends React.PureComponent { await cli.bootstrapSecretStorage({ authUploadDeviceSigningKeys: this._doBootstrapUIAuth, createSecretStorageKey: async () => this._recoveryKey, - setupNewKeyBackup: this.state.useKeyBackup, + setupNewKeyBackup: true, setupNewSecretStorage: true, }); - if (!this.state.useKeyBackup && this.state.backupInfo) { - // If the user is resetting their cross-signing keys and doesn't want - // key backup (but had it enabled before), delete the key backup as it's - // no longer valid. - console.log("Deleting invalid key backup (secrets have been reset; key backup not requested)"); - await cli.deleteKeyBackupVersion(this.state.backupInfo.version); - } } else { await cli.bootstrapSecretStorage({ authUploadDeviceSigningKeys: this._doBootstrapUIAuth, createSecretStorageKey: async () => this._recoveryKey, keyBackupInfo: this.state.backupInfo, - setupNewKeyBackup: !this.state.backupInfo && this.state.useKeyBackup, + setupNewKeyBackup: !this.state.backupInfo, getKeyBackupPassphrase: () => { // We may already have the backup key if we earlier went // through the restore backup path, so pass it along @@ -286,9 +304,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent { }, }); } - this.setState({ - phase: PHASE_DONE, - }); + this.props.onFinished(true); } catch (e) { if (this.state.canUploadKeysWithPasswordOnly && e.httpStatus === 401 && e.data.flows) { this.setState({ @@ -342,22 +358,16 @@ export default class CreateSecretStorageDialog extends React.PureComponent { this._fetchBackupInfo(); } - _onSkipSetupClick = () => { + _onShowKeyContinueClick = () => { + this._bootstrapSecretStorage(); + } + + _onCancelClick = () => { this.setState({phase: PHASE_CONFIRM_SKIP}); } - _onSetUpClick = () => { - this.setState({phase: PHASE_PASSPHRASE}); - } - - _onSkipPassPhraseClick = async () => { - this._recoveryKey = - await MatrixClientPeg.get().createRecoveryKeyFromPassphrase(); - this.setState({ - copied: false, - downloaded: false, - phase: PHASE_SHOWKEY, - }); + _onGoBackClick = () => { + this.setState({phase: PHASE_CHOOSE_KEY_PASSPHRASE}); } _onPassPhraseNextClick = async (e) => { @@ -384,6 +394,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent { this.setState({ copied: false, downloaded: false, + setPassphrase: true, phase: PHASE_SHOWKEY, }); } @@ -397,12 +408,6 @@ export default class CreateSecretStorageDialog extends React.PureComponent { }); } - _onKeepItSafeBackClick = () => { - this.setState({ - phase: PHASE_SHOWKEY, - }); - } - _onPassPhraseValidate = (result) => { this.setState({ passPhraseValid: result.valid, @@ -427,13 +432,55 @@ export default class CreateSecretStorageDialog extends React.PureComponent { }); } + _renderPhaseChooseKeyPassphrase() { + return
+

{_t( + "Safeguard against losing access to encrypted messages & data by " + + "backing up encryption keys on your server.", + )}

+
+ +
+ + {_t("Generate a Security Key")} +
+
{_t("We’ll generate a Security Key for you to store somewhere safe, like a password manager or a safe.")}
+
+ +
+ + {_t("Enter a Security Phrase")} +
+
{_t("Use a secret phrase only you know, and optionally save a Security Key to use for backup.")}
+
+
+ + ; + } + _renderPhaseMigrate() { // TODO: This is a temporary screen so people who have the labs flag turned on and // click the button are aware they're making a change to their account. // Once we're confident enough in this (and it's supported enough) we can do // it automatically. // https://github.com/vector-im/riot-web/issues/11696 - const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); const Field = sdk.getComponent('views.elements.Field'); let authPrompt; @@ -446,7 +493,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent { label={_t("Password")} value={this.state.accountPassword} onChange={this._onAccountPasswordChange} - flagInvalid={this.state.accountPasswordCorrect === false} + forceValidity={this.state.accountPasswordCorrect === false ? false : null} autoFocus={true} /> ; @@ -474,7 +521,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent { hasCancel={false} primaryDisabled={this.state.canUploadKeysWithPasswordOnly && !this.state.accountPassword} > -
@@ -482,14 +529,10 @@ export default class CreateSecretStorageDialog extends React.PureComponent { } _renderPhasePassPhrase() { - const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); - const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); - const LabelledToggleSwitch = sdk.getComponent('views.elements.LabelledToggleSwitch'); - return

{_t( - "Set a recovery passphrase to secure encrypted information and recover it if you log out. " + - "This should be different to 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.", )}

@@ -508,11 +551,6 @@ export default class CreateSecretStorageDialog extends React.PureComponent { />
- - + >{_t("Cancel")} - -
- {_t("Advanced")} - - {_t("Set up with a recovery key")} - -
; } _renderPhasePassPhraseConfirm() { - const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); const Field = sdk.getComponent('views.elements.Field'); let matchText; @@ -566,7 +596,6 @@ export default class CreateSecretStorageDialog extends React.PureComponent { ; } - const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); return

{_t( "Enter your recovery passphrase a second time to confirm it.", @@ -592,7 +621,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent { disabled={this.state.passPhrase !== this.state.passPhraseConfirm} > @@ -600,66 +629,48 @@ export default class CreateSecretStorageDialog extends React.PureComponent { } _renderPhaseShowKey() { - const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); + let continueButton; + if (this.state.phase === PHASE_SHOWKEY) { + continueButton = ; + } else { + continueButton =

+ +
; + } return

{_t( - "Your recovery key is a safety net - you can use it to restore " + - "access to your encrypted messages if you forget your recovery passphrase.", - )}

-

{_t( - "Keep a copy of it somewhere secure, like a password manager or even a safe.", + "Store your Security Key somewhere safe, like a password manager or a safe, " + + "as it’s used to safeguard your encrypted data.", )}

-
- {_t("Your recovery key")} -
{this._recoveryKey.encodedPrivateKey}
+ + {_t("Download")} + + {_t("or")} - {_t("Copy")} - - - {_t("Download")} + {this.state.copied ? _t("Copied!") : _t("Copy")}
-
; - } - - _renderPhaseKeepItSafe() { - let introText; - if (this.state.copied) { - introText = _t( - "Your recovery key has been copied to your clipboard, paste it to:", - {}, {b: s => {s}}, - ); - } else if (this.state.downloaded) { - introText = _t( - "Your recovery key is in your Downloads folder.", - {}, {b: s => {s}}, - ); - } - const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); - return
- {introText} -
    -
  • {_t("Print it and store it somewhere safe", {}, {b: s => {s}})}
  • -
  • {_t("Save it on a USB key or backup drive", {}, {b: s => {s}})}
  • -
  • {_t("Copy it to your personal cloud storage", {}, {b: s => {s}})}
  • -
- - - + {continueButton}
; } @@ -671,7 +682,6 @@ export default class CreateSecretStorageDialog extends React.PureComponent { } _renderPhaseLoadError() { - const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); return

{_t("Unable to query secret storage status")}

@@ -684,53 +694,39 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
; } - _renderPhaseDone() { - const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); + _renderPhaseSkipConfirm() { return

{_t( - "You can now verify your other devices, " + - "and other users to keep your chats safe.", + "If you cancel now, you may lose encrypted messages & data if you lose access to your logins.", + )}

+

{_t( + "You can also set up Secure Backup & manage your keys in Settings.", )}

- -
; - } - - _renderPhaseSkipConfirm() { - const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); - return
- {_t( - "Without completing security on this session, it won’t have " + - "access to encrypted messages.", - )} - +
; } _titleForPhase(phase) { switch (phase) { + case PHASE_CHOOSE_KEY_PASSPHRASE: + return _t('Set up Secure backup'); case PHASE_MIGRATE: return _t('Upgrade your encryption'); case PHASE_PASSPHRASE: - return _t('Set up encryption'); + return _t('Set a Security Phrase'); case PHASE_PASSPHRASE_CONFIRM: - return _t('Confirm recovery passphrase'); + return _t('Confirm Security Phrase'); case PHASE_CONFIRM_SKIP: return _t('Are you sure?'); case PHASE_SHOWKEY: - case PHASE_KEEPITSAFE: - return _t('Make a copy of your recovery key'); + return _t('Save your Security Key'); case PHASE_STORING: return _t('Setting up keys'); - case PHASE_DONE: - return _t("You're done!"); default: return ''; } @@ -741,7 +737,6 @@ export default class CreateSecretStorageDialog extends React.PureComponent { let content; if (this.state.error) { - const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); content =

{_t("Unable to set up secret storage")}

@@ -760,6 +755,9 @@ export default class CreateSecretStorageDialog extends React.PureComponent { case PHASE_LOADERROR: content = this._renderPhaseLoadError(); break; + case PHASE_CHOOSE_KEY_PASSPHRASE: + content = this._renderPhaseChooseKeyPassphrase(); + break; case PHASE_MIGRATE: content = this._renderPhaseMigrate(); break; @@ -772,31 +770,40 @@ export default class CreateSecretStorageDialog extends React.PureComponent { case PHASE_SHOWKEY: content = this._renderPhaseShowKey(); break; - case PHASE_KEEPITSAFE: - content = this._renderPhaseKeepItSafe(); - break; case PHASE_STORING: content = this._renderBusyPhase(); break; - case PHASE_DONE: - content = this._renderPhaseDone(); - break; case PHASE_CONFIRM_SKIP: content = this._renderPhaseSkipConfirm(); break; } } - let headerImage; - if (this._titleForPhase(this.state.phase)) { - headerImage = require("../../../../../res/img/e2e/normal.svg"); + let titleClass = null; + switch (this.state.phase) { + case PHASE_PASSPHRASE: + case PHASE_PASSPHRASE_CONFIRM: + titleClass = [ + 'mx_CreateSecretStorageDialog_titleWithIcon', + 'mx_CreateSecretStorageDialog_securePhraseTitle', + ]; + break; + case PHASE_SHOWKEY: + titleClass = [ + 'mx_CreateSecretStorageDialog_titleWithIcon', + 'mx_CreateSecretStorageDialog_secureBackupTitle', + ]; + break; + case PHASE_CHOOSE_KEY_PASSPHRASE: + titleClass = 'mx_CreateSecretStorageDialog_centeredTitle'; + break; } return ( diff --git a/src/autocomplete/RoomProvider.tsx b/src/autocomplete/RoomProvider.tsx index 0d8aac4218..f14fa3bbfa 100644 --- a/src/autocomplete/RoomProvider.tsx +++ b/src/autocomplete/RoomProvider.tsx @@ -25,9 +25,9 @@ import {MatrixClientPeg} from '../MatrixClientPeg'; import QueryMatcher from './QueryMatcher'; import {PillCompletion} from './Components'; import * as sdk from '../index'; -import _sortBy from 'lodash/sortBy'; import {makeRoomPermalink} from "../utils/permalinks/Permalinks"; import {ICompletion, ISelectionRange} from "./Autocompleter"; +import { uniqBy, sortBy } from 'lodash'; const ROOM_REGEX = /\B#\S*/g; @@ -91,10 +91,11 @@ export default class RoomProvider extends AutocompleteProvider { this.matcher.setObjects(matcherObjects); const matchedString = command[0]; completions = this.matcher.match(matchedString); - completions = _sortBy(completions, [ + completions = sortBy(completions, [ (c) => score(matchedString, c.displayedAlias), (c) => c.displayedAlias.length, ]); + completions = uniqBy(completions, (match) => match.room); completions = completions.map((room) => { return { completion: room.displayedAlias, diff --git a/src/components/structures/ContextMenu.js b/src/components/structures/ContextMenu.js index 98b0867ccc..e43b0d1431 100644 --- a/src/components/structures/ContextMenu.js +++ b/src/components/structures/ContextMenu.js @@ -116,6 +116,7 @@ export class ContextMenu extends React.Component { this.props.onFinished(); e.preventDefault(); + e.stopPropagation(); const x = e.clientX; const y = e.clientY; @@ -133,6 +134,19 @@ export class ContextMenu extends React.Component { } }; + onContextMenuPreventBubbling = (e) => { + // stop propagation so that any context menu handlers don't leak out of this context menu + // but do not inhibit the default browser menu + e.stopPropagation(); + }; + + // Prevent clicks on the background from going through to the component which opened the menu. + _onFinished = (ev: InputEvent) => { + ev.stopPropagation(); + ev.preventDefault(); + if (this.props.onFinished) this.props.onFinished(); + }; + _onMoveFocus = (element, up) => { let descending = false; // are we currently descending or ascending through the DOM tree? @@ -319,12 +333,12 @@ export class ContextMenu extends React.Component { let background; if (hasBackground) { background = ( -
+
); } return ( -
+
{ chevron } { props.children } @@ -340,10 +354,18 @@ export class ContextMenu extends React.Component { } // Semantic component for representing the AccessibleButton which launches a -export const ContextMenuButton = ({ label, isExpanded, children, ...props }) => { +export const ContextMenuButton = ({ label, isExpanded, children, onClick, onContextMenu, ...props }) => { const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); return ( - + { children } ); diff --git a/src/components/structures/LeftPanel2.tsx b/src/components/structures/LeftPanel2.tsx index b5da44caef..23a9e74646 100644 --- a/src/components/structures/LeftPanel2.tsx +++ b/src/components/structures/LeftPanel2.tsx @@ -15,21 +15,26 @@ limitations under the License. */ import * as React from "react"; +import { createRef } from "react"; import TagPanel from "./TagPanel"; import classNames from "classnames"; import dis from "../../dispatcher/dispatcher"; import { _t } from "../../languageHandler"; -import SearchBox from "./SearchBox"; import RoomList2 from "../views/rooms/RoomList2"; import { Action } from "../../dispatcher/actions"; -import { MatrixClientPeg } from "../../MatrixClientPeg"; -import BaseAvatar from '../views/avatars/BaseAvatar'; -import UserMenuButton from "./UserMenuButton"; +import UserMenu from "./UserMenu"; import RoomSearch from "./RoomSearch"; import AccessibleButton from "../views/elements/AccessibleButton"; import RoomBreadcrumbs2 from "../views/rooms/RoomBreadcrumbs2"; import { BreadcrumbsStore } from "../../stores/BreadcrumbsStore"; import { UPDATE_EVENT } from "../../stores/AsyncStore"; +import ResizeNotifier from "../../utils/ResizeNotifier"; +import SettingsStore from "../../settings/SettingsStore"; +import RoomListStore, { LISTS_UPDATE_EVENT } from "../../stores/room-list/RoomListStore2"; +import {Key} from "../../Keyboard"; + +// TODO: Remove banner on launch: https://github.com/vector-im/riot-web/issues/14231 +// TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14231 /******************************************************************* * CAUTION * @@ -41,20 +46,21 @@ import { UPDATE_EVENT } from "../../stores/AsyncStore"; interface IProps { isMinimized: boolean; + resizeNotifier: ResizeNotifier; } interface IState { - searchFilter: string; // TODO: Move search into room list? + searchFilter: string; showBreadcrumbs: boolean; + showTagPanel: boolean; } export default class LeftPanel2 extends React.Component { - // TODO: Properly support TagPanel - // TODO: Properly support searching/filtering - // TODO: Properly support breadcrumbs - // TODO: a11y - // TODO: actually make this useful in general (match design proposals) - // TODO: Fadable support (is this still needed?) + private listContainerRef: React.RefObject = createRef(); + private tagPanelWatcherRef: string; + private focusedElement = null; + + // TODO: a11y: https://github.com/vector-im/riot-web/issues/14180 constructor(props: IProps) { super(props); @@ -62,13 +68,25 @@ export default class LeftPanel2 extends React.Component { this.state = { searchFilter: "", showBreadcrumbs: BreadcrumbsStore.instance.visible, + showTagPanel: SettingsStore.getValue('TagPanel.enableTagPanel'), }; BreadcrumbsStore.instance.on(UPDATE_EVENT, this.onBreadcrumbsUpdate); + RoomListStore.instance.on(LISTS_UPDATE_EVENT, this.onBreadcrumbsUpdate); + this.tagPanelWatcherRef = SettingsStore.watchSetting("TagPanel.enableTagPanel", null, () => { + this.setState({showTagPanel: SettingsStore.getValue("TagPanel.enableTagPanel")}); + }); + + // We watch the middle panel because we don't actually get resized, the middle panel does. + // We listen to the noisy channel to avoid choppy reaction times. + this.props.resizeNotifier.on("middlePanelResizedNoisy", this.onResize); } public componentWillUnmount() { + SettingsStore.unwatchSetting(this.tagPanelWatcherRef); BreadcrumbsStore.instance.off(UPDATE_EVENT, this.onBreadcrumbsUpdate); + RoomListStore.instance.off(LISTS_UPDATE_EVENT, this.onBreadcrumbsUpdate); + this.props.resizeNotifier.off("middlePanelResizedNoisy", this.onResize); } private onSearch = (term: string): void => { @@ -86,9 +104,7 @@ export default class LeftPanel2 extends React.Component { } }; - // TODO: Apply this on resize, init, etc for reliability - private onScroll = (ev: React.MouseEvent) => { - const list = ev.target as HTMLDivElement; + private handleStickyHeaders(list: HTMLDivElement) { const rlRect = list.getBoundingClientRect(); const bottom = rlRect.bottom; const top = rlRect.top; @@ -123,75 +139,111 @@ export default class LeftPanel2 extends React.Component { header.style.top = `unset`; } } + } + + // TODO: Improve header reliability: https://github.com/vector-im/riot-web/issues/14232 + private onScroll = (ev: React.MouseEvent) => { + const list = ev.target as HTMLDivElement; + this.handleStickyHeaders(list); + }; + + private onResize = () => { + if (!this.listContainerRef.current) return; // ignore: no headers to sticky + this.handleStickyHeaders(this.listContainerRef.current); + }; + + private onFocus = (ev: React.FocusEvent) => { + this.focusedElement = ev.target; + }; + + private onBlur = () => { + this.focusedElement = null; + }; + + private onKeyDown = (ev: React.KeyboardEvent) => { + if (!this.focusedElement) return; + + switch (ev.key) { + case Key.ARROW_UP: + case Key.ARROW_DOWN: + ev.stopPropagation(); + ev.preventDefault(); + this.onMoveFocus(ev.key === Key.ARROW_UP); + break; + } + }; + + private onMoveFocus = (up: boolean) => { + let element = this.focusedElement; + + let descending = false; // are we currently descending or ascending through the DOM tree? + let classes: DOMTokenList; + + do { + const child = up ? element.lastElementChild : element.firstElementChild; + const sibling = up ? element.previousElementSibling : element.nextElementSibling; + + if (descending) { + if (child) { + element = child; + } else if (sibling) { + element = sibling; + } else { + descending = false; + element = element.parentElement; + } + } else { + if (sibling) { + element = sibling; + descending = true; + } else { + element = element.parentElement; + } + } + + if (element) { + classes = element.classList; + } + } while (element && !( + classes.contains("mx_RoomTile2") || + classes.contains("mx_RoomSublist2_headerText") || + classes.contains("mx_RoomSearch_input"))); + + if (element) { + element.focus(); + this.focusedElement = element; + } }; private renderHeader(): React.ReactNode { - // TODO: Update when profile info changes - // TODO: Presence - // TODO: Breadcrumbs toggle - // TODO: Menu button - const avatarSize = 32; - // TODO: Don't do this profile lookup in render() - const client = MatrixClientPeg.get(); - let displayName = client.getUserId(); - let avatarUrl: string = null; - const myUser = client.getUser(client.getUserId()); - if (myUser) { - displayName = myUser.rawDisplayName; - avatarUrl = myUser.avatarUrl; - } - let breadcrumbs; if (this.state.showBreadcrumbs) { breadcrumbs = ( -
+
{this.props.isMinimized ? null : }
); } - let name = {displayName}; - let buttons = ( - - - - ); - if (this.props.isMinimized) { - name = null; - buttons = null; - } - return (
-
- - - - {name} - {buttons} -
+ {breadcrumbs}
); } private renderSearchExplore(): React.ReactNode { - // TODO: Collapsed support - return ( -
- +
+ @@ -200,37 +252,49 @@ export default class LeftPanel2 extends React.Component { } public render(): React.ReactNode { - const tagPanel = ( + const tagPanel = !this.state.showTagPanel ? null : (
); - // TODO: Improve props for RoomList2 const roomList = {/*TODO*/}} + onKeyDown={this.onKeyDown} resizeNotifier={null} collapsed={false} searchFilter={this.state.searchFilter} - onFocus={() => {/*TODO*/}} - onBlur={() => {/*TODO*/}} + onFocus={this.onFocus} + onBlur={this.onBlur} isMinimized={this.props.isMinimized} />; - // TODO: Conference handling / calls + // TODO: Conference handling / calls: https://github.com/vector-im/riot-web/issues/14177 const containerClasses = classNames({ "mx_LeftPanel2": true, + "mx_LeftPanel2_hasTagPanel": !!tagPanel, "mx_LeftPanel2_minimized": this.props.isMinimized, }); + const roomListClasses = classNames( + "mx_LeftPanel2_actualRoomListContainer", + "mx_AutoHideScrollbar", + ); + return (
{tagPanel} diff --git a/src/components/structures/LoggedInView.tsx b/src/components/structures/LoggedInView.tsx index f37f77b31b..9fbc98dee3 100644 --- a/src/components/structures/LoggedInView.tsx +++ b/src/components/structures/LoggedInView.tsx @@ -123,7 +123,7 @@ interface IState { * * Components mounted below us can access the matrix client via the react context. */ -class LoggedInView extends React.PureComponent { +class LoggedInView extends React.Component { static displayName = 'LoggedInView'; static propTypes = { @@ -146,6 +146,7 @@ class LoggedInView extends React.PureComponent { protected readonly _resizeContainer: React.RefObject; protected readonly _sessionStore: sessionStore; protected readonly _sessionStoreToken: { remove: () => void }; + protected readonly _compactLayoutWatcherRef: string; protected resizer: Resizer; constructor(props, context) { @@ -177,6 +178,10 @@ class LoggedInView extends React.PureComponent { this._matrixClient.on("sync", this.onSync); this._matrixClient.on("RoomState.events", this.onRoomStateEvents); + this._compactLayoutWatcherRef = SettingsStore.watchSetting( + "useCompactLayout", null, this.onCompactLayoutChanged, + ); + fixupColorFonts(); this._roomView = React.createRef(); @@ -194,6 +199,7 @@ class LoggedInView extends React.PureComponent { this._matrixClient.removeListener("accountData", this.onAccountData); this._matrixClient.removeListener("sync", this.onSync); this._matrixClient.removeListener("RoomState.events", this.onRoomStateEvents); + SettingsStore.unwatchSetting(this._compactLayoutWatcherRef); if (this._sessionStoreToken) { this._sessionStoreToken.remove(); } @@ -263,16 +269,17 @@ class LoggedInView extends React.PureComponent { } onAccountData = (event) => { - if (event.getType() === "im.vector.web.settings") { - this.setState({ - useCompactLayout: event.getContent().useCompactLayout, - }); - } if (event.getType() === "m.ignored_user_list") { dis.dispatch({action: "ignore_state_changed"}); } }; + onCompactLayoutChanged = (setting, roomId, level, valueAtLevel, newValue) => { + this.setState({ + useCompactLayout: valueAtLevel, + }); + }; + onSync = (syncState, oldSyncState, data) => { const oldErrCode = ( this.state.syncErrorData && @@ -677,7 +684,10 @@ class LoggedInView extends React.PureComponent { if (SettingsStore.isFeatureEnabled("feature_new_room_list")) { // TODO: Supply props like collapsed and disabled to LeftPanel2 leftPanel = ( - + ); } diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index fa6cd8a4d8..315c648e15 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -18,10 +18,11 @@ limitations under the License. */ import React, { createRef } from 'react'; +// @ts-ignore - XXX: no idea why this import fails +import * as Matrix from "matrix-js-sdk"; import { InvalidStoreError } from "matrix-js-sdk/src/errors"; import { RoomMember } from "matrix-js-sdk/src/models/room-member"; import { MatrixEvent } from "matrix-js-sdk/src/models/event"; -import { isCryptoAvailable } from 'matrix-js-sdk/src/crypto'; // focus-visible is a Polyfill for the :focus-visible CSS pseudo-attribute used by _AccessibleButton.scss import 'focus-visible'; // what-input helps improve keyboard accessibility @@ -1612,6 +1613,19 @@ export default class MatrixChat extends React.PureComponent { }); } else if (screen === 'directory') { dis.fire(Action.ViewRoomDirectory); + } else if (screen === "start_sso" || screen === "start_cas") { + // TODO if logged in, skip SSO + let cli = MatrixClientPeg.get(); + if (!cli) { + const {hsUrl, isUrl} = this.props.serverConfig; + cli = Matrix.createClient({ + baseUrl: hsUrl, + idBaseUrl: isUrl, + }); + } + + const type = screen === "start_sso" ? "sso" : "cas"; + PlatformPeg.get().startSingleSignOn(cli, type, this.getFragmentAfterLogin()); } else if (screen === 'groups') { dis.dispatch({ action: 'view_my_groups', @@ -1828,7 +1842,9 @@ export default class MatrixChat extends React.PureComponent { } updateStatusIndicator(state: string, prevState: string) { - const notifCount = countRoomsWithNotif(MatrixClientPeg.get().getRooms()).count; + // only count visible rooms to not torment the user with notification counts in rooms they can't see + // it will include highlights from the previous version of the room internally + const notifCount = countRoomsWithNotif(MatrixClientPeg.get().getVisibleRooms()).count; if (PlatformPeg.get()) { PlatformPeg.get().setErrorStatus(state === 'ERROR'); @@ -1913,17 +1929,20 @@ export default class MatrixChat extends React.PureComponent { this.onLoggedIn(); }; - render() { - // console.log(`Rendering MatrixChat with view ${this.state.view}`); - + getFragmentAfterLogin() { let fragmentAfterLogin = ""; - if (this.props.initialScreenAfterLogin && + const initialScreenAfterLogin = this.props.initialScreenAfterLogin; + if (initialScreenAfterLogin && // XXX: workaround for https://github.com/vector-im/riot-web/issues/11643 causing a login-loop - !["welcome", "login", "register"].includes(this.props.initialScreenAfterLogin.screen) + !["welcome", "login", "register", "start_sso", "start_cas"].includes(initialScreenAfterLogin.screen) ) { - fragmentAfterLogin = `/${this.props.initialScreenAfterLogin.screen}`; + fragmentAfterLogin = `/${initialScreenAfterLogin.screen}`; } + return fragmentAfterLogin; + } + render() { + const fragmentAfterLogin = this.getFragmentAfterLogin(); let view; if (this.state.view === Views.LOADING) { @@ -2002,7 +2021,7 @@ export default class MatrixChat extends React.PureComponent { } } else if (this.state.view === Views.WELCOME) { const Welcome = sdk.getComponent('auth.Welcome'); - view = ; + view = ; } else if (this.state.view === Views.REGISTER) { const Registration = sdk.getComponent('structures.auth.Registration'); view = ( diff --git a/src/components/structures/MessagePanel.js b/src/components/structures/MessagePanel.js index d11fee6360..7567786af3 100644 --- a/src/components/structures/MessagePanel.js +++ b/src/components/structures/MessagePanel.js @@ -388,8 +388,11 @@ export default class MessagePanel extends React.Component { } return ( -
  • +
  • { hr }
  • ); diff --git a/src/components/structures/RoomSearch.tsx b/src/components/structures/RoomSearch.tsx index 345cf83d31..7ed2acf276 100644 --- a/src/components/structures/RoomSearch.tsx +++ b/src/components/structures/RoomSearch.tsx @@ -25,6 +25,8 @@ import { Key } from "../../Keyboard"; import AccessibleButton from "../views/elements/AccessibleButton"; import { Action } from "../../dispatcher/actions"; +// TODO: Remove banner on launch: https://github.com/vector-im/riot-web/issues/14231 + /******************************************************************* * CAUTION * ******************************************************************* @@ -36,6 +38,7 @@ import { Action } from "../../dispatcher/actions"; interface IProps { onQueryUpdate: (newQuery: string) => void; isMinimized: boolean; + onVerticalArrow(ev: React.KeyboardEvent); } interface IState { @@ -109,6 +112,8 @@ export default class RoomSearch extends React.PureComponent { if (ev.key === Key.ESCAPE) { this.clearInput(); defaultDispatcher.fire(Action.FocusComposer); + } else if (ev.key === Key.ARROW_UP || ev.key === Key.ARROW_DOWN) { + this.props.onVerticalArrow(ev); } }; diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js index 19f1cccebd..519c4c1f8e 100644 --- a/src/components/structures/RoomView.js +++ b/src/components/structures/RoomView.js @@ -1819,6 +1819,7 @@ export default createReactClass({ ); const showRoomRecoveryReminder = ( + this.context.isCryptoEnabled() && SettingsStore.getValue("showRoomRecoveryReminder") && this.context.isRoomEncrypted(this.state.room.roomId) && this.context.getKeyBackupEnabled() === false diff --git a/src/components/structures/UserMenu.tsx b/src/components/structures/UserMenu.tsx new file mode 100644 index 0000000000..5955a046a4 --- /dev/null +++ b/src/components/structures/UserMenu.tsx @@ -0,0 +1,356 @@ +/* +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. +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 React from "react"; +import { MatrixClientPeg } from "../../MatrixClientPeg"; +import defaultDispatcher from "../../dispatcher/dispatcher"; +import { ActionPayload } from "../../dispatcher/payloads"; +import { Action } from "../../dispatcher/actions"; +import { createRef } from "react"; +import { _t } from "../../languageHandler"; +import {ContextMenu, ContextMenuButton, MenuItem} from "./ContextMenu"; +import {USER_NOTIFICATIONS_TAB, USER_SECURITY_TAB} from "../views/dialogs/UserSettingsDialog"; +import { OpenToTabPayload } from "../../dispatcher/payloads/OpenToTabPayload"; +import RedesignFeedbackDialog from "../views/dialogs/RedesignFeedbackDialog"; +import Modal from "../../Modal"; +import LogoutDialog from "../views/dialogs/LogoutDialog"; +import SettingsStore, {SettingLevel} from "../../settings/SettingsStore"; +import {getCustomTheme} from "../../theme"; +import {getHostingLink} from "../../utils/HostingLink"; +import {ButtonEvent} from "../views/elements/AccessibleButton"; +import SdkConfig from "../../SdkConfig"; +import {getHomePageUrl} from "../../utils/pages"; +import { OwnProfileStore } from "../../stores/OwnProfileStore"; +import { UPDATE_EVENT } from "../../stores/AsyncStore"; +import BaseAvatar from '../views/avatars/BaseAvatar'; +import classNames from "classnames"; +import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton"; + +interface IProps { + isMinimized: boolean; +} + +type PartialDOMRect = Pick; + +interface IState { + contextMenuPosition: PartialDOMRect; + isDarkTheme: boolean; +} + +interface IMenuButtonProps { + iconClassName: string; + label: string; + onClick(ev: ButtonEvent); +} + +const MenuButton: React.FC = ({iconClassName, label, onClick}) => { + return + + {label} + ; +}; + +export default class UserMenu extends React.Component { + private dispatcherRef: string; + private themeWatcherRef: string; + private buttonRef: React.RefObject = createRef(); + + constructor(props: IProps) { + super(props); + + this.state = { + contextMenuPosition: null, + isDarkTheme: this.isUserOnDarkTheme(), + }; + + OwnProfileStore.instance.on(UPDATE_EVENT, this.onProfileUpdate); + } + + private get hasHomePage(): boolean { + return !!getHomePageUrl(SdkConfig.get()); + } + + public componentDidMount() { + this.dispatcherRef = defaultDispatcher.register(this.onAction); + this.themeWatcherRef = SettingsStore.watchSetting("theme", null, this.onThemeChanged); + } + + public componentWillUnmount() { + if (this.themeWatcherRef) SettingsStore.unwatchSetting(this.themeWatcherRef); + if (this.dispatcherRef) defaultDispatcher.unregister(this.dispatcherRef); + OwnProfileStore.instance.off(UPDATE_EVENT, this.onProfileUpdate); + } + + private isUserOnDarkTheme(): boolean { + const theme = SettingsStore.getValue("theme"); + if (theme.startsWith("custom-")) { + return getCustomTheme(theme.substring("custom-".length)).is_dark; + } + return theme === "dark"; + } + + private onProfileUpdate = async () => { + // the store triggered an update, so force a layout update. We don't + // have any state to store here for that to magically happen. + this.forceUpdate(); + }; + + private onThemeChanged = () => { + this.setState({isDarkTheme: this.isUserOnDarkTheme()}); + }; + + private onAction = (ev: ActionPayload) => { + if (ev.action !== Action.ToggleUserMenu) return; // not interested + + if (this.state.contextMenuPosition) { + this.setState({contextMenuPosition: null}); + } else { + if (this.buttonRef.current) this.buttonRef.current.click(); + } + }; + + private onOpenMenuClick = (ev: InputEvent) => { + ev.preventDefault(); + ev.stopPropagation(); + const target = ev.target as HTMLButtonElement; + this.setState({contextMenuPosition: target.getBoundingClientRect()}); + }; + + private onContextMenu = (ev: React.MouseEvent) => { + ev.preventDefault(); + ev.stopPropagation(); + this.setState({ + contextMenuPosition: { + left: ev.clientX, + top: ev.clientY, + width: 20, + height: 0, + }, + }); + }; + + private onCloseMenu = () => { + this.setState({contextMenuPosition: null}); + }; + + private onSwitchThemeClick = (ev: React.MouseEvent) => { + ev.preventDefault(); + ev.stopPropagation(); + + // Disable system theme matching if the user hits this button + SettingsStore.setValue("use_system_theme", null, SettingLevel.DEVICE, false); + + const newTheme = this.state.isDarkTheme ? "light" : "dark"; + SettingsStore.setValue("theme", null, SettingLevel.DEVICE, newTheme); // set at same level as Appearance tab + }; + + private onSettingsOpen = (ev: ButtonEvent, tabId: string) => { + ev.preventDefault(); + ev.stopPropagation(); + + const payload: OpenToTabPayload = {action: Action.ViewUserSettings, initialTabId: tabId}; + defaultDispatcher.dispatch(payload); + this.setState({contextMenuPosition: null}); // also close the menu + }; + + private onShowArchived = (ev: ButtonEvent) => { + ev.preventDefault(); + ev.stopPropagation(); + + // TODO: Archived room view: https://github.com/vector-im/riot-web/issues/14038 + console.log("TODO: Show archived rooms"); + }; + + private onProvideFeedback = (ev: ButtonEvent) => { + ev.preventDefault(); + ev.stopPropagation(); + + Modal.createTrackedDialog('Report bugs & give feedback', '', RedesignFeedbackDialog); + this.setState({contextMenuPosition: null}); // also close the menu + }; + + private onSignOutClick = (ev: ButtonEvent) => { + ev.preventDefault(); + ev.stopPropagation(); + + Modal.createTrackedDialog('Logout from LeftPanel', '', LogoutDialog); + this.setState({contextMenuPosition: null}); // also close the menu + }; + + private onHomeClick = (ev: ButtonEvent) => { + ev.preventDefault(); + ev.stopPropagation(); + + defaultDispatcher.dispatch({action: 'view_home_page'}); + }; + + private renderContextMenu = (): React.ReactNode => { + if (!this.state.contextMenuPosition) return null; + + let hostingLink; + const signupLink = getHostingLink("user-context-menu"); + if (signupLink) { + hostingLink = ( +
    + {_t( + "Upgrade to your own domain", {}, + { + a: sub => ( + {sub} + ), + }, + )} +
    + ); + } + + let homeButton = null; + if (this.hasHomePage) { + homeButton = ( + + ); + } + + return ( + +
    +
    +
    + + {OwnProfileStore.instance.displayName} + + + {MatrixClientPeg.get().getUserId()} + +
    + + {_t("Switch + +
    + {hostingLink} +
    + {homeButton} + this.onSettingsOpen(e, USER_NOTIFICATIONS_TAB)} + /> + this.onSettingsOpen(e, USER_SECURITY_TAB)} + /> + this.onSettingsOpen(e, null)} + /> + + +
    +
    + +
    +
    +
    + ); + }; + + public render() { + const avatarSize = 32; // should match border-radius of the avatar + + let name = {OwnProfileStore.instance.displayName}; + let buttons = ( + + {/* masked image in CSS */} + + ); + if (this.props.isMinimized) { + name = null; + buttons = null; + } + + const classes = classNames({ + 'mx_UserMenu': true, + 'mx_UserMenu_minimized': this.props.isMinimized, + }); + + return ( + + +
    + + + + {name} + {buttons} +
    + {this.renderContextMenu()} +
    +
    + ); + } +} diff --git a/src/components/structures/UserMenuButton.tsx b/src/components/structures/UserMenuButton.tsx deleted file mode 100644 index 6607fffdd1..0000000000 --- a/src/components/structures/UserMenuButton.tsx +++ /dev/null @@ -1,296 +0,0 @@ -/* -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. -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 React from "react"; -import {User} from "matrix-js-sdk/src/models/user"; -import { MatrixClientPeg } from "../../MatrixClientPeg"; -import defaultDispatcher from "../../dispatcher/dispatcher"; -import { ActionPayload } from "../../dispatcher/payloads"; -import { Action } from "../../dispatcher/actions"; -import { createRef } from "react"; -import { _t } from "../../languageHandler"; -import {ContextMenu, ContextMenuButton} from "./ContextMenu"; -import {USER_NOTIFICATIONS_TAB, USER_SECURITY_TAB} from "../views/dialogs/UserSettingsDialog"; -import { OpenToTabPayload } from "../../dispatcher/payloads/OpenToTabPayload"; -import RedesignFeedbackDialog from "../views/dialogs/RedesignFeedbackDialog"; -import Modal from "../../Modal"; -import LogoutDialog from "../views/dialogs/LogoutDialog"; -import SettingsStore, {SettingLevel} from "../../settings/SettingsStore"; -import {getCustomTheme} from "../../theme"; -import {getHostingLink} from "../../utils/HostingLink"; -import AccessibleButton, {ButtonEvent} from "../views/elements/AccessibleButton"; -import SdkConfig from "../../SdkConfig"; -import {getHomePageUrl} from "../../utils/pages"; - -interface IProps { -} - -interface IState { - user: User; - menuDisplayed: boolean; - isDarkTheme: boolean; -} - -export default class UserMenuButton extends React.Component { - private dispatcherRef: string; - private themeWatcherRef: string; - private buttonRef: React.RefObject = createRef(); - - constructor(props: IProps) { - super(props); - - this.state = { - menuDisplayed: false, - user: MatrixClientPeg.get().getUser(MatrixClientPeg.get().getUserId()), - isDarkTheme: this.isUserOnDarkTheme(), - }; - } - - private get displayName(): string { - if (MatrixClientPeg.get().isGuest()) { - return _t("Guest"); - } else if (this.state.user) { - return this.state.user.displayName; - } else { - return MatrixClientPeg.get().getUserId(); - } - } - - private get hasHomePage(): boolean { - return !!getHomePageUrl(SdkConfig.get()); - } - - public componentDidMount() { - this.dispatcherRef = defaultDispatcher.register(this.onAction); - this.themeWatcherRef = SettingsStore.watchSetting("theme", null, this.onThemeChanged); - } - - public componentWillUnmount() { - if (this.themeWatcherRef) SettingsStore.unwatchSetting(this.themeWatcherRef); - if (this.dispatcherRef) defaultDispatcher.unregister(this.dispatcherRef); - } - - private isUserOnDarkTheme(): boolean { - const theme = SettingsStore.getValue("theme"); - if (theme.startsWith("custom-")) { - return getCustomTheme(theme.substring("custom-".length)).is_dark; - } - return theme === "dark"; - } - - private onThemeChanged = () => { - this.setState({isDarkTheme: this.isUserOnDarkTheme()}); - }; - - private onAction = (ev: ActionPayload) => { - if (ev.action !== Action.ToggleUserMenu) return; // not interested - - // For accessibility - if (this.buttonRef.current) this.buttonRef.current.click(); - }; - - private onOpenMenuClick = (ev: InputEvent) => { - ev.preventDefault(); - ev.stopPropagation(); - this.setState({menuDisplayed: true}); - }; - - private onCloseMenu = () => { - this.setState({menuDisplayed: false}); - }; - - private onSwitchThemeClick = () => { - // Disable system theme matching if the user hits this button - SettingsStore.setValue("use_system_theme", null, SettingLevel.DEVICE, false); - - const newTheme = this.state.isDarkTheme ? "light" : "dark"; - SettingsStore.setValue("theme", null, SettingLevel.ACCOUNT, newTheme); - }; - - private onSettingsOpen = (ev: ButtonEvent, tabId: string) => { - ev.preventDefault(); - ev.stopPropagation(); - - const payload: OpenToTabPayload = {action: Action.ViewUserSettings, initialTabId: tabId}; - defaultDispatcher.dispatch(payload); - this.setState({menuDisplayed: false}); // also close the menu - }; - - private onShowArchived = (ev: ButtonEvent) => { - ev.preventDefault(); - ev.stopPropagation(); - - // TODO: Archived room view (deferred) - console.log("TODO: Show archived rooms"); - }; - - private onProvideFeedback = (ev: ButtonEvent) => { - ev.preventDefault(); - ev.stopPropagation(); - - Modal.createTrackedDialog('Report bugs & give feedback', '', RedesignFeedbackDialog); - this.setState({menuDisplayed: false}); // also close the menu - }; - - private onSignOutClick = (ev: ButtonEvent) => { - ev.preventDefault(); - ev.stopPropagation(); - - Modal.createTrackedDialog('Logout from LeftPanel', '', LogoutDialog); - this.setState({menuDisplayed: false}); // also close the menu - }; - - private onHomeClick = (ev: ButtonEvent) => { - ev.preventDefault(); - ev.stopPropagation(); - - defaultDispatcher.dispatch({action: 'view_home_page'}); - }; - - public render() { - let contextMenu; - if (this.state.menuDisplayed) { - let hostingLink; - const signupLink = getHostingLink("user-context-menu"); - if (signupLink) { - hostingLink = ( -
    - {_t( - "Upgrade to your own domain", {}, - { - a: sub => ( - {sub} - ), - }, - )} -
    - ); - } - - let homeButton = null; - if (this.hasHomePage) { - homeButton = ( -
  • - - - {_t("Home")} - -
  • - ); - } - - const elementRect = this.buttonRef.current.getBoundingClientRect(); - contextMenu = ( - -
    -
    -
    - - {this.displayName} - - - {MatrixClientPeg.get().getUserId()} - -
    -
    - {_t("Switch -
    -
    - {hostingLink} -
    -
      - {homeButton} -
    • - this.onSettingsOpen(e, USER_NOTIFICATIONS_TAB)}> - - {_t("Notification settings")} - -
    • -
    • - this.onSettingsOpen(e, USER_SECURITY_TAB)}> - - {_t("Security & privacy")} - -
    • -
    • - this.onSettingsOpen(e, null)}> - - {_t("All settings")} - -
    • -
    • - - - {_t("Archived rooms")} - -
    • -
    • - - - {_t("Feedback")} - -
    • -
    -
    -
    -
      -
    • - - - {_t("Sign out")} - -
    • -
    -
    -
    -
    - ); - } - - return ( - - - ... - - {contextMenu} - - ); - } -} diff --git a/src/components/structures/auth/SoftLogout.js b/src/components/structures/auth/SoftLogout.js index a2824b63a3..6577386fae 100644 --- a/src/components/structures/auth/SoftLogout.js +++ b/src/components/structures/auth/SoftLogout.js @@ -25,7 +25,7 @@ import {MatrixClientPeg} from "../../../MatrixClientPeg"; import {sendLoginRequest} from "../../../Login"; import AuthPage from "../../views/auth/AuthPage"; import SSOButton from "../../views/elements/SSOButton"; -import {HOMESERVER_URL_KEY, ID_SERVER_URL_KEY} from "../../../BasePlatform"; +import {SSO_HOMESERVER_URL_KEY, SSO_ID_SERVER_URL_KEY} from "../../../BasePlatform"; const LOGIN_VIEW = { LOADING: 1, @@ -158,8 +158,8 @@ export default class SoftLogout extends React.Component { async trySsoLogin() { this.setState({busy: true}); - const hsUrl = localStorage.getItem(HOMESERVER_URL_KEY); - const isUrl = localStorage.getItem(ID_SERVER_URL_KEY) || MatrixClientPeg.get().getIdentityServerUrl(); + const hsUrl = localStorage.getItem(SSO_HOMESERVER_URL_KEY); + const isUrl = localStorage.getItem(SSO_ID_SERVER_URL_KEY) || MatrixClientPeg.get().getIdentityServerUrl(); const loginType = "m.login.token"; const loginParams = { token: this.props.realQueryParams['loginToken'], diff --git a/src/components/views/auth/Welcome.js b/src/components/views/auth/Welcome.js index 91ba368f70..5a30a02490 100644 --- a/src/components/views/auth/Welcome.js +++ b/src/components/views/auth/Welcome.js @@ -18,9 +18,7 @@ import React from 'react'; import * as sdk from '../../../index'; import SdkConfig from '../../../SdkConfig'; import AuthPage from "./AuthPage"; -import * as Matrix from "matrix-js-sdk"; import {_td} from "../../../languageHandler"; -import PlatformPeg from "../../../PlatformPeg"; // translatable strings for Welcome pages _td("Sign in with SSO"); @@ -39,15 +37,6 @@ export default class Welcome extends React.PureComponent { pageUrl = 'welcome.html'; } - const {hsUrl, isUrl} = this.props.serverConfig; - const tmpClient = Matrix.createClient({ - baseUrl: hsUrl, - idBaseUrl: isUrl, - }); - const plaf = PlatformPeg.get(); - const callbackUrl = plaf.getSSOCallbackUrl(tmpClient.getHomeserverUrl(), tmpClient.getIdentityServerUrl(), - this.props.fragmentAfterLogin); - return (
    @@ -55,8 +44,8 @@ export default class Welcome extends React.PureComponent { className="mx_WelcomePage" url={pageUrl} replaceMap={{ - "$riot:ssoUrl": tmpClient.getSsoLoginUrl(callbackUrl.toString(), "sso"), - "$riot:casUrl": tmpClient.getSsoLoginUrl(callbackUrl.toString(), "cas"), + "$riot:ssoUrl": "#/start_sso", + "$riot:casUrl": "#/start_cas", }} /> diff --git a/src/components/views/avatars/DecoratedRoomAvatar.tsx b/src/components/views/avatars/DecoratedRoomAvatar.tsx new file mode 100644 index 0000000000..e0ad3202b8 --- /dev/null +++ b/src/components/views/avatars/DecoratedRoomAvatar.tsx @@ -0,0 +1,65 @@ +/* +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. +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 { Room } from "matrix-js-sdk/src/models/room"; + +import { TagID } from '../../../stores/room-list/models'; +import RoomAvatar from "./RoomAvatar"; +import RoomTileIcon from "../rooms/RoomTileIcon"; +import NotificationBadge from '../rooms/NotificationBadge'; +import { INotificationState } from "../../../stores/notifications/INotificationState"; +import { TagSpecificNotificationState } from "../../../stores/notifications/TagSpecificNotificationState"; + +interface IProps { + room: Room; + avatarSize: number; + tag: TagID; + displayBadge?: boolean; + forceCount?: boolean; +} + +interface IState { + notificationState?: INotificationState; +} + +export default class DecoratedRoomAvatar extends React.PureComponent { + + constructor(props: IProps) { + super(props); + + this.state = { + notificationState: new TagSpecificNotificationState(this.props.room, this.props.tag), + }; + } + + public render(): React.ReactNode { + let badge: React.ReactNode; + if (this.props.displayBadge) { + badge = ; + } + + return
    + + + {badge} +
    ; + } +} diff --git a/src/components/views/dialogs/BaseDialog.js b/src/components/views/dialogs/BaseDialog.js index e59b6bbaf5..353298032c 100644 --- a/src/components/views/dialogs/BaseDialog.js +++ b/src/components/views/dialogs/BaseDialog.js @@ -75,8 +75,12 @@ export default createReactClass({ // If provided, this is used to add a aria-describedby attribute contentId: PropTypes.string, - // optional additional class for the title element - titleClass: PropTypes.string, + // optional additional class for the title element (basically anything that can be passed to classnames) + titleClass: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.object, + PropTypes.arrayOf(PropTypes.string), + ]), }, getDefaultProps: function() { diff --git a/src/components/views/dialogs/secretstorage/AccessSecretStorageDialog.js b/src/components/views/dialogs/secretstorage/AccessSecretStorageDialog.js index e2ceadfbb9..5c01a6907f 100644 --- a/src/components/views/dialogs/secretstorage/AccessSecretStorageDialog.js +++ b/src/components/views/dialogs/secretstorage/AccessSecretStorageDialog.js @@ -15,13 +15,24 @@ See the License for the specific language governing permissions and limitations under the License. */ +import { debounce } from 'lodash'; +import classNames from 'classnames'; import React from 'react'; import PropTypes from "prop-types"; import * as sdk from '../../../../index'; import {MatrixClientPeg} from '../../../../MatrixClientPeg'; +import Field from '../../elements/Field'; +import AccessibleButton from '../../elements/AccessibleButton'; import { _t } from '../../../../languageHandler'; -import { accessSecretStorage } from '../../../../CrossSigningManager'; + +// 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 +// maybe that's a thing people would do? +const KEY_FILE_MAX_SIZE = 128; + +// Don't shout at the user that their key is invalid every time they type a key: wait a short time +const VALIDATION_THROTTLE_MS = 200; /* * Access Secure Secret Storage by requesting the user's passphrase. @@ -36,9 +47,14 @@ export default class AccessSecretStorageDialog extends React.PureComponent { constructor(props) { super(props); + + this._fileUpload = React.createRef(); + this.state = { recoveryKey: "", - recoveryKeyValid: false, + recoveryKeyValid: null, + recoveryKeyCorrect: null, + recoveryKeyFileError: null, forceRecoveryKey: false, passPhrase: '', keyMatches: null, @@ -55,18 +71,89 @@ export default class AccessSecretStorageDialog extends React.PureComponent { }); } - _onResetRecoveryClick = () => { - // Re-enter the access flow, but resetting storage this time around. - this.props.onFinished(false); - accessSecretStorage(() => {}, /* forceReset = */ true); + _validateRecoveryKeyOnChange = debounce(() => { + this._validateRecoveryKey(); + }, VALIDATION_THROTTLE_MS); + + async _validateRecoveryKey() { + if (this.state.recoveryKey === '') { + this.setState({ + recoveryKeyValid: null, + recoveryKeyCorrect: null, + }); + return; + } + + try { + const cli = MatrixClientPeg.get(); + const decodedKey = cli.keyBackupKeyFromRecoveryKey(this.state.recoveryKey); + const correct = await cli.checkSecretStorageKey( + decodedKey, this.props.keyInfo, + ); + this.setState({ + recoveryKeyValid: true, + recoveryKeyCorrect: correct, + }); + } catch (e) { + this.setState({ + recoveryKeyValid: false, + recoveryKeyCorrect: false, + }); + } } _onRecoveryKeyChange = (e) => { this.setState({ recoveryKey: e.target.value, - recoveryKeyValid: MatrixClientPeg.get().isValidRecoveryKey(e.target.value), - keyMatches: null, + recoveryKeyFileError: null, }); + + // also clear the file upload control so that the user can upload the same file + // the did before (otherwise the onchange wouldn't fire) + if (this._fileUpload.current) this._fileUpload.current.value = null; + + // We don't use Field's validation here because a) we want it in a separate place rather + // than in a tooltip and b) we want it to display feedback based on the uploaded file + // as well as the text box. Ideally we would refactor Field's validation logic so we could + // re-use some of it. + this._validateRecoveryKeyOnChange(); + } + + _onRecoveryKeyFileChange = async e => { + if (e.target.files.length === 0) return; + + const f = e.target.files[0]; + + if (f.size > KEY_FILE_MAX_SIZE) { + this.setState({ + recoveryKeyFileError: true, + recoveryKeyCorrect: false, + recoveryKeyValid: false, + }); + } else { + const contents = await f.text(); + // test it's within the base58 alphabet. We could be more strict here, eg. require the + // right number of characters, but it's really just to make sure that what we're reading is + // text because we'll put it in the text field. + if (/^[123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz\s]+$/.test(contents)) { + this.setState({ + recoveryKeyFileError: null, + recoveryKey: contents.trim(), + }); + this._validateRecoveryKey(); + } else { + this.setState({ + recoveryKeyFileError: true, + recoveryKeyCorrect: false, + recoveryKeyValid: false, + recoveryKey: '', + }); + } + } + } + + _onRecoveryKeyFileUploadClick = () => { + this._fileUpload.current.click(); } _onPassPhraseNext = async (e) => { @@ -106,6 +193,20 @@ export default class AccessSecretStorageDialog extends React.PureComponent { }); } + getKeyValidationText() { + if (this.state.recoveryKeyFileError) { + return _t("Wrong file type"); + } else if (this.state.recoveryKeyCorrect) { + return _t("Looks good!"); + } else if (this.state.recoveryKeyValid) { + return _t("Wrong Recovery Key"); + } else if (this.state.recoveryKeyValid === null) { + return ''; + } else { + return _t("Invalid Recovery Key"); + } + } + render() { const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); @@ -118,10 +219,12 @@ export default class AccessSecretStorageDialog extends React.PureComponent { let content; let title; + let titleClass; if (hasPassphrase && !this.state.forceRecoveryKey) { const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); - title = _t("Enter recovery passphrase"); + title = _t("Security Phrase"); + titleClass = ['mx_AccessSecretStorageDialog_titleWithIcon mx_AccessSecretStorageDialog_securePhraseTitle']; let keyStatus; if (this.state.keyMatches === false) { @@ -137,12 +240,15 @@ export default class AccessSecretStorageDialog extends React.PureComponent { content =

    {_t( - "Warning: You should only do this on a trusted computer.", {}, - { b: sub => {sub} }, - )}

    -

    {_t( - "Access your secure message history and your cross-signing " + - "identity for verifying other sessions by entering your recovery passphrase.", + "Enter your Security Phrase or to continue.", {}, + { + button: s => + {s} + , + }, )}

    @@ -153,10 +259,11 @@ export default class AccessSecretStorageDialog extends React.PureComponent { value={this.state.passPhrase} autoFocus={true} autoComplete="new-password" + placeholder={_t("Security Phrase")} /> {keyStatus} - {_t( - "If you've forgotten your recovery passphrase you can "+ - "use your recovery key or " + - "set up new recovery options." - , {}, { - button1: s => - {s} - , - button2: s => - {s} - , - })}
    ; } else { - title = _t("Enter recovery key"); + title = _t("Security Key"); + titleClass = ['mx_AccessSecretStorageDialog_titleWithIcon mx_AccessSecretStorageDialog_secureBackupTitle']; const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); - const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); - let keyStatus; - if (this.state.recoveryKey.length === 0) { - keyStatus =
    ; - } else if (this.state.keyMatches === false) { - keyStatus =
    - {"\uD83D\uDC4E "}{_t( - "Unable to access secret storage. " + - "Please verify that you entered the correct recovery key.", - )} -
    ; - } else if (this.state.recoveryKeyValid) { - keyStatus =
    - {"\uD83D\uDC4D "}{_t("This looks like a valid recovery key!")} -
    ; - } else { - keyStatus =
    - {"\uD83D\uDC4E "}{_t("Not a valid recovery key")} -
    ; - } + const feedbackClasses = classNames({ + 'mx_AccessSecretStorageDialog_recoveryKeyFeedback': true, + 'mx_AccessSecretStorageDialog_recoveryKeyFeedback_valid': this.state.recoveryKeyCorrect === true, + 'mx_AccessSecretStorageDialog_recoveryKeyFeedback_invalid': this.state.recoveryKeyCorrect === false, + }); + const recoveryKeyFeedback =
    + {this.getKeyValidationText()} +
    ; content =
    -

    {_t( - "Warning: You should only do this on a trusted computer.", {}, - { b: sub => {sub} }, - )}

    -

    {_t( - "Access your secure message history and your cross-signing " + - "identity for verifying other sessions by entering your recovery key.", - )}

    +

    {_t("Use your Security Key to continue.")}

    -
    - - {keyStatus} + +
    +
    + +
    + + {_t("or")} + +
    + + + {_t("Upload")} + +
    +
    + {recoveryKeyFeedback} - {_t( - "If you've forgotten your recovery key you can "+ - "." - , {}, { - button: s => - {s} - , - })}
    ; } @@ -252,6 +333,7 @@ export default class AccessSecretStorageDialog extends React.PureComponent {
    {content} diff --git a/src/components/views/elements/AccessibleButton.tsx b/src/components/views/elements/AccessibleButton.tsx index 01a27d9522..040147bb16 100644 --- a/src/components/views/elements/AccessibleButton.tsx +++ b/src/components/views/elements/AccessibleButton.tsx @@ -27,7 +27,7 @@ export type ButtonEvent = React.MouseEvent | React.KeyboardEvent { +export interface IProps extends React.InputHTMLAttributes { inputRef?: React.Ref; element?: string; // The kind of button, similar to how Bootstrap works. diff --git a/src/components/views/elements/AccessibleTooltipButton.js b/src/components/views/elements/AccessibleTooltipButton.tsx similarity index 70% rename from src/components/views/elements/AccessibleTooltipButton.js rename to src/components/views/elements/AccessibleTooltipButton.tsx index 6c84c6ab7e..f4d63136e1 100644 --- a/src/components/views/elements/AccessibleTooltipButton.js +++ b/src/components/views/elements/AccessibleTooltipButton.tsx @@ -16,21 +16,28 @@ limitations under the License. */ import React from 'react'; -import PropTypes from 'prop-types'; +import classnames from 'classnames'; import AccessibleButton from "./AccessibleButton"; -import * as sdk from "../../../index"; +import {IProps} from "./AccessibleButton"; +import Tooltip from './Tooltip'; -export default class AccessibleTooltipButton extends React.PureComponent { - static propTypes = { - ...AccessibleButton.propTypes, - // The tooltip to render on hover - title: PropTypes.string.isRequired, - }; +interface ITooltipProps extends IProps { + title: string; + tooltipClassName?: string; +} - state = { - hover: false, - }; +interface IState { + hover: boolean; +} + +export default class AccessibleTooltipButton extends React.PureComponent { + constructor(props: ITooltipProps) { + super(props); + this.state = { + hover: false, + }; + } onMouseOver = () => { this.setState({ @@ -45,14 +52,15 @@ export default class AccessibleTooltipButton extends React.PureComponent { }; render() { - const Tooltip = sdk.getComponent("elements.Tooltip"); - const AccessibleButton = sdk.getComponent("elements.AccessibleButton"); - const {title, children, ...props} = this.props; + const tooltipClassName = classnames( + "mx_AccessibleTooltipButton_tooltip", + this.props.tooltipClassName, + ); const tip = this.state.hover ? :
    ; return ( diff --git a/src/components/views/elements/AppTile.js b/src/components/views/elements/AppTile.js index 9129b8fe48..60cd1a2eba 100644 --- a/src/components/views/elements/AppTile.js +++ b/src/components/views/elements/AppTile.js @@ -29,7 +29,7 @@ import { _t } from '../../../languageHandler'; import * as sdk from '../../../index'; import AppPermission from './AppPermission'; import AppWarning from './AppWarning'; -import MessageSpinner from './MessageSpinner'; +import Spinner from './Spinner'; import WidgetUtils from '../../../utils/WidgetUtils'; import dis from '../../../dispatcher/dispatcher'; import ActiveWidgetStore from '../../../stores/ActiveWidgetStore'; @@ -740,7 +740,7 @@ export default class AppTile extends React.Component { if (this.props.show) { const loadingElement = (
    - +
    ); if (!this.state.hasPermissionToLoad) { diff --git a/src/components/views/elements/EventTilePreview.tsx b/src/components/views/elements/EventTilePreview.tsx index fa600196e5..7d8b774955 100644 --- a/src/components/views/elements/EventTilePreview.tsx +++ b/src/components/views/elements/EventTilePreview.tsx @@ -108,7 +108,7 @@ export default class EventTilePreview extends React.Component { }, }; - + return event; } public render() { diff --git a/src/components/views/elements/Field.tsx b/src/components/views/elements/Field.tsx index fbee431d6e..9a889a0351 100644 --- a/src/components/views/elements/Field.tsx +++ b/src/components/views/elements/Field.tsx @@ -50,10 +50,12 @@ interface IProps { // to the user. onValidate?: (input: IFieldState) => Promise; // If specified, overrides the value returned by onValidate. - flagInvalid?: boolean; + forceValidity?: boolean; // If specified, contents will appear as a tooltip on the element and // validation feedback tooltips will be suppressed. tooltipContent?: React.ReactNode; + // If specified the tooltip will be shown regardless of feedback + forceTooltipVisible?: boolean; // If specified alongside tooltipContent, the class name to apply to the // tooltip itself. tooltipClassName?: string; @@ -201,7 +203,7 @@ export default class Field extends React.PureComponent { public render() { const { element, prefixComponent, postfixComponent, className, onValidate, children, - tooltipContent, flagInvalid, tooltipClassName, list, ...inputProps} = this.props; + tooltipContent, forceValidity, tooltipClassName, list, ...inputProps} = this.props; // Set some defaults for the element const ref = input => this.input = input; @@ -226,15 +228,15 @@ export default class Field extends React.PureComponent { postfixContainer = {postfixComponent}; } - const hasValidationFlag = flagInvalid !== null && flagInvalid !== undefined; + const hasValidationFlag = forceValidity !== null && forceValidity !== undefined; const fieldClasses = classNames("mx_Field", `mx_Field_${this.props.element}`, className, { // If we have a prefix element, leave the label always at the top left and // don't animate it, as it looks a bit clunky and would add complexity to do // properly. mx_Field_labelAlwaysTopLeft: prefixComponent, - mx_Field_valid: onValidate && this.state.valid === true, + mx_Field_valid: hasValidationFlag ? forceValidity : onValidate && this.state.valid === true, mx_Field_invalid: hasValidationFlag - ? flagInvalid + ? !forceValidity : onValidate && this.state.valid === false, }); @@ -242,10 +244,9 @@ export default class Field extends React.PureComponent { const Tooltip = sdk.getComponent("elements.Tooltip"); let fieldTooltip; if (tooltipContent || this.state.feedback) { - const addlClassName = tooltipClassName ? tooltipClassName : ''; fieldTooltip = ; } diff --git a/src/components/views/elements/InlineSpinner.js b/src/components/views/elements/InlineSpinner.js index ad70471d89..ad88868790 100644 --- a/src/components/views/elements/InlineSpinner.js +++ b/src/components/views/elements/InlineSpinner.js @@ -16,6 +16,8 @@ limitations under the License. import React from "react"; import createReactClass from 'create-react-class'; +import {_t} from "../../../languageHandler"; +import SettingsStore from "../../../settings/SettingsStore"; export default createReactClass({ displayName: 'InlineSpinner', @@ -25,9 +27,25 @@ export default createReactClass({ const h = this.props.h || 16; const imgClass = this.props.imgClassName || ""; + let divClass; + let imageSource; + if (SettingsStore.isFeatureEnabled('feature_new_spinner')) { + divClass = "mx_InlineSpinner mx_Spinner_spin"; + imageSource = require("../../../../res/img/spinner.svg"); + } else { + divClass = "mx_InlineSpinner"; + imageSource = require("../../../../res/img/spinner.gif"); + } + return ( -
    - +
    +
    ); }, diff --git a/src/components/views/elements/MessageSpinner.js b/src/components/views/elements/MessageSpinner.js deleted file mode 100644 index 1775fdd4d7..0000000000 --- a/src/components/views/elements/MessageSpinner.js +++ /dev/null @@ -1,35 +0,0 @@ -/* -Copyright 2017 Vector Creations 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. -*/ - -import React from 'react'; -import createReactClass from 'create-react-class'; - -export default createReactClass({ - displayName: 'MessageSpinner', - - render: function() { - const w = this.props.w || 32; - const h = this.props.h || 32; - const imgClass = this.props.imgClassName || ""; - const msg = this.props.msg || "Loading..."; - return ( -
    -
    { msg }
      - -
    - ); - }, -}); diff --git a/src/components/views/elements/ProgressBar.js b/src/components/views/elements/ProgressBar.js deleted file mode 100644 index 045731ba38..0000000000 --- a/src/components/views/elements/ProgressBar.js +++ /dev/null @@ -1,39 +0,0 @@ -/* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2019 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 PropTypes from 'prop-types'; -import createReactClass from 'create-react-class'; - -export default createReactClass({ - displayName: 'ProgressBar', - propTypes: { - value: PropTypes.number, - max: PropTypes.number, - }, - - render: function() { - // Would use an HTML5 progress tag but if that doesn't animate if you - // use the HTML attributes rather than styles - const progressStyle = { - width: ((this.props.value / this.props.max) * 100)+"%", - }; - return ( -
    - ); - }, -}); diff --git a/src/components/views/elements/ProgressBar.tsx b/src/components/views/elements/ProgressBar.tsx new file mode 100644 index 0000000000..90832e5006 --- /dev/null +++ b/src/components/views/elements/ProgressBar.tsx @@ -0,0 +1,28 @@ +/* +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. +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"; + +interface IProps { + value: number; + max: number; +} + +const ProgressBar: React.FC = ({value, max}) => { + return ; +}; + +export default ProgressBar; diff --git a/src/components/views/elements/SettingsFlag.tsx b/src/components/views/elements/SettingsFlag.tsx index 9bdd04d803..4f41db51e2 100644 --- a/src/components/views/elements/SettingsFlag.tsx +++ b/src/components/views/elements/SettingsFlag.tsx @@ -30,6 +30,7 @@ interface IProps { isExplicit?: boolean; // XXX: once design replaces all toggles make this the default useCheckbox?: boolean; + disabled?: boolean; onChange?(checked: boolean): void; } @@ -78,14 +79,23 @@ export default class SettingsFlag extends React.Component { else label = _t(label); if (this.props.useCheckbox) { - return + return {label} ; } else { return (
    {label} - +
    ); } diff --git a/src/components/views/elements/Spinner.js b/src/components/views/elements/Spinner.js index b1fe97d5d2..08ba0cf921 100644 --- a/src/components/views/elements/Spinner.js +++ b/src/components/views/elements/Spinner.js @@ -16,19 +16,39 @@ limitations under the License. */ import React from "react"; -import createReactClass from 'create-react-class'; +import PropTypes from "prop-types"; +import {_t} from "../../../languageHandler"; +import SettingsStore from "../../../settings/SettingsStore"; -export default createReactClass({ - displayName: 'Spinner', +const Spinner = ({w = 32, h = 32, imgClassName, message}) => { + let divClass; + let imageSource; + if (SettingsStore.isFeatureEnabled('feature_new_spinner')) { + divClass = "mx_Spinner mx_Spinner_spin"; + imageSource = require("../../../../res/img/spinner.svg"); + } else { + divClass = "mx_Spinner"; + imageSource = require("../../../../res/img/spinner.gif"); + } - render: function() { - const w = this.props.w || 32; - const h = this.props.h || 32; - const imgClass = this.props.imgClassName || ""; - return ( -
    - -
    - ); - }, -}); + return ( +
    + { message &&
    { message}
     
    } + +
    + ); +}; +Spinner.propTypes = { + w: PropTypes.number, + h: PropTypes.number, + imgClassName: PropTypes.string, + message: PropTypes.node, +}; + +export default Spinner; diff --git a/src/components/views/elements/StyledRadioButton.tsx b/src/components/views/elements/StyledRadioButton.tsx index d7ae4d5af8..2efd084861 100644 --- a/src/components/views/elements/StyledRadioButton.tsx +++ b/src/components/views/elements/StyledRadioButton.tsx @@ -18,6 +18,7 @@ import React from 'react'; import classnames from 'classnames'; interface IProps extends React.InputHTMLAttributes { + outlined?: boolean; } interface IState { @@ -29,7 +30,7 @@ export default class StyledRadioButton extends React.PureComponent {/* Used to render the radio button circle */} -
    - {children} +
    +
    {children}
    ; } diff --git a/src/components/views/elements/StyledRadioGroup.tsx b/src/components/views/elements/StyledRadioGroup.tsx new file mode 100644 index 0000000000..ea8f65d12b --- /dev/null +++ b/src/components/views/elements/StyledRadioGroup.tsx @@ -0,0 +1,62 @@ +/* +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. +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 StyledRadioButton from "./StyledRadioButton"; + +interface IDefinition { + value: T; + className?: string; + disabled?: boolean; + label: React.ReactChild; + description?: React.ReactChild; +} + +interface IProps { + name: string; + className?: string; + definitions: IDefinition[]; + value?: T; // if not provided no options will be selected + outlined?: boolean; + onChange(newValue: T); +} + +function StyledRadioGroup({name, definitions, value, className, outlined, onChange}: IProps) { + const _onChange = e => { + onChange(e.target.value); + }; + + return + {definitions.map(d => + + {d.label} + + {d.description} + )} + ; +} + +export default StyledRadioGroup; diff --git a/src/components/views/messages/MAudioBody.js b/src/components/views/messages/MAudioBody.js index a642936fec..37f85a108f 100644 --- a/src/components/views/messages/MAudioBody.js +++ b/src/components/views/messages/MAudioBody.js @@ -22,6 +22,7 @@ import MFileBody from './MFileBody'; import {MatrixClientPeg} from '../../../MatrixClientPeg'; import { decryptFile } from '../../../utils/DecryptFile'; import { _t } from '../../../languageHandler'; +import InlineSpinner from '../elements/InlineSpinner'; export default class MAudioBody extends React.Component { constructor(props) { @@ -94,7 +95,7 @@ export default class MAudioBody extends React.Component { // Not sure how tall the audio player is so not sure how tall it should actually be. return ( - {content.body} + ); } diff --git a/src/components/views/messages/MImageBody.js b/src/components/views/messages/MImageBody.js index ad238a728e..c92ae475bf 100644 --- a/src/components/views/messages/MImageBody.js +++ b/src/components/views/messages/MImageBody.js @@ -26,6 +26,7 @@ import { decryptFile } from '../../../utils/DecryptFile'; import { _t } from '../../../languageHandler'; import SettingsStore from "../../../settings/SettingsStore"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; +import InlineSpinner from '../elements/InlineSpinner'; export default class MImageBody extends React.Component { static propTypes = { @@ -365,12 +366,7 @@ export default class MImageBody extends React.Component { // e2e image hasn't been decrypted yet if (content.file !== undefined && this.state.decryptedUrl === null) { - placeholder = {content.body}; + placeholder = ; } else if (!this.state.imgLoaded) { // Deliberately, getSpinner is left unimplemented here, MStickerBody overides placeholder = this.getPlaceholder(); diff --git a/src/components/views/messages/MVideoBody.js b/src/components/views/messages/MVideoBody.js index 03f345e042..fdc04deffc 100644 --- a/src/components/views/messages/MVideoBody.js +++ b/src/components/views/messages/MVideoBody.js @@ -23,6 +23,7 @@ import {MatrixClientPeg} from '../../../MatrixClientPeg'; import { decryptFile } from '../../../utils/DecryptFile'; import { _t } from '../../../languageHandler'; import SettingsStore from "../../../settings/SettingsStore"; +import InlineSpinner from '../elements/InlineSpinner'; export default createReactClass({ displayName: 'MVideoBody', @@ -147,7 +148,7 @@ export default createReactClass({ return (
    - {content.body} +
    ); diff --git a/src/components/views/messages/RedactedBody.tsx b/src/components/views/messages/RedactedBody.tsx index 5dada64b52..5f80460d03 100644 --- a/src/components/views/messages/RedactedBody.tsx +++ b/src/components/views/messages/RedactedBody.tsx @@ -19,6 +19,8 @@ import {MatrixClient} from "matrix-js-sdk/src/client"; import {MatrixEvent} from "matrix-js-sdk/src/models/event"; import { _t } from "../../../languageHandler"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; +import {formatFullDate} from "../../../DateUtils"; +import SettingsStore from "../../../settings/SettingsStore"; interface IProps { mxEvent: MatrixEvent; @@ -36,8 +38,12 @@ const RedactedBody = React.forwardRef(({mxEvent}, ref) => { text = _t("Message deleted by %(name)s", { name: sender ? sender.name : redactedBecauseUserId }); } + const showTwelveHour = SettingsStore.getValue("showTwelveHourTimestamps"); + const fullDate = formatFullDate(new Date(unsigned.redacted_because.origin_server_ts), showTwelveHour); + const titleText = _t("Message deleted on %(date)s", { date: fullDate }); + return ( - + { text } ); diff --git a/src/components/views/rooms/E2EIcon.js b/src/components/views/rooms/E2EIcon.js index bf65c7fb7c..254e28dffa 100644 --- a/src/components/views/rooms/E2EIcon.js +++ b/src/components/views/rooms/E2EIcon.js @@ -28,6 +28,7 @@ export const E2E_STATE = { WARNING: "warning", UNKNOWN: "unknown", NORMAL: "normal", + UNAUTHENTICATED: "unauthenticated", }; const crossSigningUserTitles = { diff --git a/src/components/views/rooms/EventTile.js b/src/components/views/rooms/EventTile.js index 7508cf3372..88c4ed2e7d 100644 --- a/src/components/views/rooms/EventTile.js +++ b/src/components/views/rooms/EventTile.js @@ -313,35 +313,52 @@ export default createReactClass({ return; } - // If we directly trust the device, short-circuit here - const verified = await this.context.isEventSenderVerified(mxEvent); - if (verified) { + const encryptionInfo = this.context.getEventEncryptionInfo(mxEvent); + const senderId = mxEvent.getSender(); + const userTrust = this.context.checkUserTrust(senderId); + + if (encryptionInfo.mismatchedSender) { + // something definitely wrong is going on here this.setState({ - verified: E2E_STATE.VERIFIED, - }, () => { - // Decryption may have caused a change in size - this.props.onHeightChanged(); - }); + verified: E2E_STATE.WARNING, + }, this.props.onHeightChanged); // Decryption may have caused a change in size return; } - if (!this.context.checkUserTrust(mxEvent.getSender()).isCrossSigningVerified()) { + if (!userTrust.isCrossSigningVerified()) { + // user is not verified, so default to everything is normal this.setState({ verified: E2E_STATE.NORMAL, - }, this.props.onHeightChanged); + }, this.props.onHeightChanged); // Decryption may have caused a change in size return; } - const eventSenderTrust = await this.context.checkEventSenderTrust(mxEvent); + const eventSenderTrust = this.context.checkDeviceTrust( + senderId, encryptionInfo.sender.deviceId, + ); if (!eventSenderTrust) { this.setState({ verified: E2E_STATE.UNKNOWN, - }, this.props.onHeightChanged); // Decryption may have cause a change in size + }, this.props.onHeightChanged); // Decryption may have caused a change in size + return; + } + + if (!eventSenderTrust.isVerified()) { + this.setState({ + verified: E2E_STATE.WARNING, + }, this.props.onHeightChanged); // Decryption may have caused a change in size + return; + } + + if (!encryptionInfo.authenticated) { + this.setState({ + verified: E2E_STATE.UNAUTHENTICATED, + }, this.props.onHeightChanged); // Decryption may have caused a change in size return; } this.setState({ - verified: eventSenderTrust.isVerified() ? E2E_STATE.VERIFIED : E2E_STATE.WARNING, + verified: E2E_STATE.VERIFIED, }, this.props.onHeightChanged); // Decryption may have caused a change in size }, @@ -526,6 +543,8 @@ export default createReactClass({ return; // no icon if we've not even cross-signed the user } else if (this.state.verified === E2E_STATE.VERIFIED) { return; // no icon for verified + } else if (this.state.verified === E2E_STATE.UNAUTHENTICATED) { + return (); } else if (this.state.verified === E2E_STATE.UNKNOWN) { return (); } else { @@ -976,6 +995,12 @@ function E2ePadlockUnknown(props) { ); } +function E2ePadlockUnauthenticated(props) { + return ( + + ); +} + class E2ePadlock extends React.Component { static propTypes = { icon: PropTypes.string.isRequired, diff --git a/src/components/views/rooms/NotificationBadge.tsx b/src/components/views/rooms/NotificationBadge.tsx index b742f8e8e7..829b05fbfc 100644 --- a/src/components/views/rooms/NotificationBadge.tsx +++ b/src/components/views/rooms/NotificationBadge.tsx @@ -17,54 +17,64 @@ limitations under the License. import React from "react"; import classNames from "classnames"; import { formatMinimalBadgeCount } from "../../../utils/FormattingUtils"; -import { Room } from "matrix-js-sdk/src/models/room"; -import { RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex"; -import AccessibleButton from "../../views/elements/AccessibleButton"; -import RoomAvatar from "../../views/avatars/RoomAvatar"; -import dis from '../../../dispatcher/dispatcher'; -import { Key } from "../../../Keyboard"; -import * as RoomNotifs from '../../../RoomNotifs'; -import { EffectiveMembership, getEffectiveMembership } from "../../../stores/room-list/membership"; -import * as Unread from '../../../Unread'; -import { MatrixClientPeg } from "../../../MatrixClientPeg"; -import { MatrixEvent } from "matrix-js-sdk/src/models/event"; -import ActiveRoomObserver from "../../../ActiveRoomObserver"; -import { EventEmitter } from "events"; -import { arrayDiff } from "../../../utils/arrays"; -import { IDestroyable } from "../../../utils/IDestroyable"; - -export const NOTIFICATION_STATE_UPDATE = "update"; - -export enum NotificationColor { - // Inverted (None -> Red) because we do integer comparisons on this - None, // nothing special - Bold, // no badge, show as unread - Grey, // unread notified messages - Red, // unread pings -} - -export interface INotificationState extends EventEmitter { - symbol?: string; - count: number; - color: NotificationColor; -} +import SettingsStore from "../../../settings/SettingsStore"; +import { DefaultTagID, TagID } from "../../../stores/room-list/models"; +import { readReceiptChangeIsFor } from "../../../utils/read-receipts"; +import AccessibleButton from "../elements/AccessibleButton"; +import { XOR } from "../../../@types/common"; +import { INotificationState, NOTIFICATION_STATE_UPDATE } from "../../../stores/notifications/INotificationState"; +import { NotificationColor } from "../../../stores/notifications/NotificationColor"; interface IProps { notification: INotificationState; /** - * If true, the badge will conditionally display a badge without count for the user. + * If true, the badge will show a count if at all possible. This is typically + * used to override the user's preference for things like room sublists. */ - allowNoCount: boolean; + forceCount: boolean; + + /** + * The room ID, if any, the badge represents. + */ + roomId?: string; +} + +interface IClickableProps extends IProps, React.InputHTMLAttributes { + /** + * If specified will return an AccessibleButton instead of a div. + */ + onClick?(ev: React.MouseEvent); } interface IState { + showCounts: boolean; // whether or not to show counts. Independent of props.forceCount } -export default class NotificationBadge extends React.PureComponent { +export default class NotificationBadge extends React.PureComponent, IState> { + private countWatcherRef: string; + constructor(props: IProps) { super(props); this.props.notification.on(NOTIFICATION_STATE_UPDATE, this.onNotificationUpdate); + + this.state = { + showCounts: SettingsStore.getValue("Notifications.alwaysShowBadgeCounts", this.roomId), + }; + + this.countWatcherRef = SettingsStore.watchSetting( + "Notifications.alwaysShowBadgeCounts", this.roomId, + this.countPreferenceChanged, + ); + } + + private get roomId(): string { + // We should convert this to null for safety with the SettingsStore + return this.props.roomId || null; + } + + public componentWillUnmount() { + SettingsStore.unwatchSetting(this.countWatcherRef); } public componentDidUpdate(prevProps: Readonly) { @@ -75,30 +85,53 @@ export default class NotificationBadge extends React.PureComponent { + this.setState({showCounts: SettingsStore.getValue("Notifications.alwaysShowBadgeCounts", this.roomId)}); + }; + private onNotificationUpdate = () => { this.forceUpdate(); // notification state changed - update }; public render(): React.ReactElement { + const {notification, forceCount, roomId, onClick, ...props} = this.props; + // Don't show a badge if we don't need to - if (this.props.notification.color <= NotificationColor.Bold) return null; + if (notification.color <= NotificationColor.None) return null; - const hasNotif = this.props.notification.color >= NotificationColor.Red; - const hasCount = this.props.notification.color >= NotificationColor.Grey; - const isEmptyBadge = this.props.allowNoCount && !localStorage.getItem("mx_rl_rt_badgeCount"); + // TODO: Update these booleans for FTUE Notifications: https://github.com/vector-im/riot-web/issues/14261 + // As of writing, that is "if red, show count always" and "optionally show counts instead of dots". + // See git diff for what that boolean state looks like. + // XXX: We ignore this.state.showCounts (the setting which controls counts vs dots). + const hasNotif = notification.color >= NotificationColor.Red; + const hasCount = notification.color >= NotificationColor.Grey; + const hasAnySymbol = notification.symbol || notification.count > 0; + let isEmptyBadge = !hasAnySymbol || !hasCount; + if (forceCount) { + isEmptyBadge = false; + if (!hasCount) return null; // Can't render a badge + } - let symbol = this.props.notification.symbol || formatMinimalBadgeCount(this.props.notification.count); + let symbol = notification.symbol || formatMinimalBadgeCount(notification.count); if (isEmptyBadge) symbol = ""; const classes = classNames({ 'mx_NotificationBadge': true, - 'mx_NotificationBadge_visible': hasCount, + 'mx_NotificationBadge_visible': isEmptyBadge ? true : hasCount, 'mx_NotificationBadge_highlighted': hasNotif, 'mx_NotificationBadge_dot': isEmptyBadge, 'mx_NotificationBadge_2char': symbol.length > 0 && symbol.length < 3, 'mx_NotificationBadge_3char': symbol.length > 2, }); + if (onClick) { + return ( + + {symbol} + + ); + } + return (
    {symbol} @@ -106,189 +139,3 @@ export default class NotificationBadge extends React.PureComponent { - const roomId = event.getRoomId(); - - if (roomId !== this.room.roomId) return; // ignore - not for us - this.updateNotificationState(); - }; - - private updateNotificationState() { - const before = {count: this.count, symbol: this.symbol, color: this.color}; - - if (this.roomIsInvite) { - this._color = NotificationColor.Red; - this._symbol = "!"; - this._count = 1; // not used, technically - } else { - const redNotifs = RoomNotifs.getUnreadNotificationCount(this.room, 'highlight'); - const greyNotifs = RoomNotifs.getUnreadNotificationCount(this.room, 'total'); - - // For a 'true count' we pick the grey notifications first because they include the - // red notifications. If we don't have a grey count for some reason we use the red - // count. If that count is broken for some reason, assume zero. This avoids us showing - // a badge for 'NaN' (which formats as 'NaNB' for NaN Billion). - const trueCount = greyNotifs ? greyNotifs : (redNotifs ? redNotifs : 0); - - // Note: we only set the symbol if we have an actual count. We don't want to show - // zero on badges. - - if (redNotifs > 0) { - this._color = NotificationColor.Red; - this._count = trueCount; - this._symbol = null; // symbol calculated by component - } else if (greyNotifs > 0) { - this._color = NotificationColor.Grey; - this._count = trueCount; - this._symbol = null; // symbol calculated by component - } else { - // We don't have any notified messages, but we might have unread messages. Let's - // find out. - const hasUnread = Unread.doesRoomHaveUnreadMessages(this.room); - if (hasUnread) { - this._color = NotificationColor.Bold; - } else { - this._color = NotificationColor.None; - } - - // no symbol or count for this state - this._count = 0; - this._symbol = null; - } - } - - // finally, publish an update if needed - const after = {count: this.count, symbol: this.symbol, color: this.color}; - if (JSON.stringify(before) !== JSON.stringify(after)) { - this.emit(NOTIFICATION_STATE_UPDATE); - } - } -} - -export class ListNotificationState extends EventEmitter implements IDestroyable { - private _count: number; - private _color: NotificationColor; - private rooms: Room[] = []; - private states: { [roomId: string]: RoomNotificationState } = {}; - - constructor(private byTileCount = false) { - super(); - } - - public get symbol(): string { - return null; // This notification state doesn't support symbols - } - - public get count(): number { - return this._count; - } - - public get color(): NotificationColor { - return this._color; - } - - public setRooms(rooms: Room[]) { - // If we're only concerned about the tile count, don't bother setting up listeners. - if (this.byTileCount) { - this.rooms = rooms; - this.calculateTotalState(); - return; - } - - const oldRooms = this.rooms; - const diff = arrayDiff(oldRooms, rooms); - this.rooms = rooms; - for (const oldRoom of diff.removed) { - const state = this.states[oldRoom.roomId]; - if (!state) continue; // We likely just didn't have a badge (race condition) - delete this.states[oldRoom.roomId]; - state.off(NOTIFICATION_STATE_UPDATE, this.onRoomNotificationStateUpdate); - state.destroy(); - } - for (const newRoom of diff.added) { - const state = new RoomNotificationState(newRoom); - state.on(NOTIFICATION_STATE_UPDATE, this.onRoomNotificationStateUpdate); - if (this.states[newRoom.roomId]) { - // "Should never happen" disclaimer. - console.warn("Overwriting notification state for room:", newRoom.roomId); - this.states[newRoom.roomId].destroy(); - } - this.states[newRoom.roomId] = state; - } - - this.calculateTotalState(); - } - - public destroy() { - for (const state of Object.values(this.states)) { - state.destroy(); - } - this.states = {}; - } - - private onRoomNotificationStateUpdate = () => { - this.calculateTotalState(); - }; - - private calculateTotalState() { - const before = {count: this.count, symbol: this.symbol, color: this.color}; - - if (this.byTileCount) { - this._color = NotificationColor.Red; - this._count = this.rooms.length; - } else { - this._count = 0; - this._color = NotificationColor.None; - for (const state of Object.values(this.states)) { - this._count += state.count; - this._color = Math.max(this.color, state.color); - } - } - - // finally, publish an update if needed - const after = {count: this.count, symbol: this.symbol, color: this.color}; - if (JSON.stringify(before) !== JSON.stringify(after)) { - this.emit(NOTIFICATION_STATE_UPDATE); - } - } -} diff --git a/src/components/views/rooms/RoomBreadcrumbs2.tsx b/src/components/views/rooms/RoomBreadcrumbs2.tsx index 69cca39d9f..687f4dd73e 100644 --- a/src/components/views/rooms/RoomBreadcrumbs2.tsx +++ b/src/components/views/rooms/RoomBreadcrumbs2.tsx @@ -17,13 +17,19 @@ limitations under the License. import React from "react"; import { BreadcrumbsStore } from "../../../stores/BreadcrumbsStore"; import AccessibleButton from "../elements/AccessibleButton"; -import RoomAvatar from "../avatars/RoomAvatar"; +import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar"; import { _t } from "../../../languageHandler"; import { Room } from "matrix-js-sdk/src/models/room"; import defaultDispatcher from "../../../dispatcher/dispatcher"; import Analytics from "../../../Analytics"; import { UPDATE_EVENT } from "../../../stores/AsyncStore"; -import { CSSTransition, TransitionGroup } from "react-transition-group"; +import { CSSTransition } from "react-transition-group"; +import RoomListStore from "../../../stores/room-list/RoomListStore2"; +import { DefaultTagID } from "../../../stores/room-list/models"; +import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; + +// TODO: Remove banner on launch: https://github.com/vector-im/riot-web/issues/14231 +// TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14231 /******************************************************************* * CAUTION * @@ -86,17 +92,29 @@ export default class RoomBreadcrumbs2 extends React.PureComponent { + const roomTags = RoomListStore.instance.getTagsForRoom(r); + const roomTag = roomTags.includes(DefaultTagID.DM) ? DefaultTagID.DM : roomTags[0]; return ( - this.viewRoom(r, i)} aria-label={_t("Room %(name)s", {name: r.name})} + title={r.name} + tooltipClassName={"mx_RoomBreadcrumbs2_Tooltip"} > - - + + ); }); diff --git a/src/components/views/rooms/RoomList2.tsx b/src/components/views/rooms/RoomList2.tsx index 4fdce360c1..b0bb70c9a0 100644 --- a/src/components/views/rooms/RoomList2.tsx +++ b/src/components/views/rooms/RoomList2.tsx @@ -25,10 +25,19 @@ import { ITagMap } from "../../../stores/room-list/algorithms/models"; import { DefaultTagID, TagID } from "../../../stores/room-list/models"; import { Dispatcher } from "flux"; import dis from "../../../dispatcher/dispatcher"; +import defaultDispatcher from "../../../dispatcher/dispatcher"; import RoomSublist2 from "./RoomSublist2"; import { ActionPayload } from "../../../dispatcher/payloads"; import { NameFilterCondition } from "../../../stores/room-list/filters/NameFilterCondition"; import { ListLayout } from "../../../stores/room-list/ListLayout"; +import { MatrixClientPeg } from "../../../MatrixClientPeg"; +import GroupAvatar from "../avatars/GroupAvatar"; +import TemporaryTile from "./TemporaryTile"; +import { StaticNotificationState } from "../../../stores/notifications/StaticNotificationState"; +import { NotificationColor } from "../../../stores/notifications/NotificationColor"; + +// TODO: Remove banner on launch: https://github.com/vector-im/riot-web/issues/14231 +// TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14231 /******************************************************************* * CAUTION * @@ -120,6 +129,8 @@ const TAG_AESTHETICS: { isInvite: false, defaultHidden: false, }, + + // TODO: Replace with archived view: https://github.com/vector-im/riot-web/issues/14038 [DefaultTagID.Archived]: { sectionLabel: _td("Historical"), isInvite: false, @@ -155,16 +166,57 @@ export default class RoomList2 extends React.Component { } public componentDidMount(): void { - RoomListStore.instance.on(LISTS_UPDATE_EVENT, (store: RoomListStore2) => { - const newLists = store.orderedLists; - console.log("new lists", newLists); + RoomListStore.instance.on(LISTS_UPDATE_EVENT, this.updateLists); + this.updateLists(); // trigger the first update + } - const layoutMap = new Map(); - for (const tagId of Object.keys(newLists)) { - layoutMap.set(tagId, new ListLayout(tagId)); - } + public componentWillUnmount() { + RoomListStore.instance.off(LISTS_UPDATE_EVENT, this.updateLists); + } - this.setState({sublists: newLists, layouts: layoutMap}); + private updateLists = () => { + const newLists = RoomListStore.instance.orderedLists; + console.log("new lists", newLists); + + const layoutMap = new Map(); + for (const tagId of Object.keys(newLists)) { + layoutMap.set(tagId, new ListLayout(tagId)); + } + + this.setState({sublists: newLists, layouts: layoutMap}); + }; + + private renderCommunityInvites(): React.ReactElement[] { + // TODO: Put community invites in a more sensible place (not in the room list) + return MatrixClientPeg.get().getGroups().filter(g => { + if (g.myMembership !== 'invite') return false; + return !this.searchFilter || this.searchFilter.matches(g.name); + }).map(g => { + const avatar = ( + + ); + const openGroup = () => { + defaultDispatcher.dispatch({ + action: 'view_group', + group_id: g.groupId, + }); + }; + return ( + + ); }); } @@ -174,11 +226,11 @@ export default class RoomList2 extends React.Component { for (const orderedTagId of TAG_ORDER) { if (COMMUNITY_TAGS_BEFORE_TAG === orderedTagId) { // Populate community invites if we have the chance - // TODO + // TODO: Community invites: https://github.com/vector-im/riot-web/issues/14179 } if (CUSTOM_TAGS_BEFORE_TAG === orderedTagId) { // Populate custom tags if needed - // TODO + // TODO: Custom tags: https://github.com/vector-im/riot-web/issues/14091 } const orderedRooms = this.state.sublists[orderedTagId] || []; @@ -190,9 +242,11 @@ export default class RoomList2 extends React.Component { if (!aesthetics) throw new Error(`Tag ${orderedTagId} does not have aesthetics`); const onAddRoomFn = aesthetics.onAddRoom ? () => aesthetics.onAddRoom(dis) : null; + const extraTiles = orderedTagId === DefaultTagID.Invite ? this.renderCommunityInvites() : null; components.push( { isInvite={aesthetics.isInvite} layout={this.state.layouts.get(orderedTagId)} isMinimized={this.props.isMinimized} + extraBadTilesThatShouldntExist={extraTiles} /> ); } diff --git a/src/components/views/rooms/RoomSublist2.tsx b/src/components/views/rooms/RoomSublist2.tsx index 08a41570e3..21e7c581f0 100644 --- a/src/components/views/rooms/RoomSublist2.tsx +++ b/src/components/views/rooms/RoomSublist2.tsx @@ -26,12 +26,21 @@ import AccessibleButton from "../../views/elements/AccessibleButton"; import RoomTile2 from "./RoomTile2"; import { ResizableBox, ResizeCallbackData } from "react-resizable"; import { ListLayout } from "../../../stores/room-list/ListLayout"; -import NotificationBadge, { ListNotificationState } from "./NotificationBadge"; import { ContextMenu, ContextMenuButton } from "../../structures/ContextMenu"; import StyledCheckbox from "../elements/StyledCheckbox"; import StyledRadioButton from "../elements/StyledRadioButton"; import RoomListStore from "../../../stores/room-list/RoomListStore2"; import { ListAlgorithm, SortAlgorithm } from "../../../stores/room-list/algorithms/models"; +import { DefaultTagID, TagID } from "../../../stores/room-list/models"; +import dis from "../../../dispatcher/dispatcher"; +import NotificationBadge from "./NotificationBadge"; +import { ListNotificationState } from "../../../stores/notifications/ListNotificationState"; +import Tooltip from "../elements/Tooltip"; +import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; +import { Key } from "../../../Keyboard"; + +// TODO: Remove banner on launch: https://github.com/vector-im/riot-web/issues/14231 +// TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14231 /******************************************************************* * CAUTION * @@ -56,37 +65,46 @@ interface IProps { isInvite: boolean; layout: ListLayout; isMinimized: boolean; + tagId: TagID; - // TODO: Collapsed state - // TODO: Group invites - // TODO: Calls - // TODO: forceExpand? - // TODO: Header clicking - // TODO: Spinner support for historical + // TODO: Don't use this. It's for community invites, and community invites shouldn't be here. + // You should feel bad if you use this. + extraBadTilesThatShouldntExist?: React.ReactElement[]; + + // TODO: Account for https://github.com/vector-im/riot-web/issues/14179 } +type PartialDOMRect = Pick; + interface IState { notificationState: ListNotificationState; - menuDisplayed: boolean; + contextMenuPosition: PartialDOMRect; + isResizing: boolean; } export default class RoomSublist2 extends React.Component { - private headerButton = createRef(); - private menuButtonRef: React.RefObject = createRef(); + private headerButton = createRef(); + private sublistRef = createRef(); constructor(props: IProps) { super(props); this.state = { - notificationState: new ListNotificationState(this.props.isInvite), - menuDisplayed: false, + notificationState: new ListNotificationState(this.props.isInvite, this.props.tagId), + contextMenuPosition: null, + isResizing: false, }; this.state.notificationState.setRooms(this.props.rooms); } private get numTiles(): number { - // TODO: Account for group invites - return (this.props.rooms || []).length; + return (this.props.rooms || []).length + (this.props.extraBadTilesThatShouldntExist || []).length; + } + + private get numVisibleTiles(): number { + if (!this.props.layout) return 0; + const nVisible = Math.floor(this.props.layout.visibleTiles); + return Math.min(nVisible, this.numTiles); } public componentDidUpdate() { @@ -105,38 +123,59 @@ export default class RoomSublist2 extends React.Component { private onResize = (e: React.MouseEvent, data: ResizeCallbackData) => { const direction = e.movementY < 0 ? -1 : +1; const tileDiff = this.props.layout.pixelsToTiles(Math.abs(e.movementY)) * direction; - this.props.layout.visibleTiles += tileDiff; + this.props.layout.setVisibleTilesWithin(tileDiff, this.numTiles); this.forceUpdate(); // because the layout doesn't trigger a re-render }; + private onResizeStart = () => { + this.setState({isResizing: true}); + }; + + private onResizeStop = () => { + this.setState({isResizing: false}); + }; + private onShowAllClick = () => { this.props.layout.visibleTiles = this.props.layout.tilesWithPadding(this.numTiles, MAX_PADDING_HEIGHT); this.forceUpdate(); // because the layout doesn't trigger a re-render }; private onShowLessClick = () => { - this.props.layout.visibleTiles = this.props.layout.minVisibleTiles; + this.props.layout.visibleTiles = this.props.layout.defaultVisibleTiles; this.forceUpdate(); // because the layout doesn't trigger a re-render }; private onOpenMenuClick = (ev: InputEvent) => { ev.preventDefault(); ev.stopPropagation(); - this.setState({menuDisplayed: true}); + const target = ev.target as HTMLButtonElement; + this.setState({contextMenuPosition: target.getBoundingClientRect()}); + }; + + private onContextMenu = (ev: React.MouseEvent) => { + ev.preventDefault(); + ev.stopPropagation(); + this.setState({ + contextMenuPosition: { + left: ev.clientX, + top: ev.clientY, + height: 0, + }, + }); }; private onCloseMenu = () => { - this.setState({menuDisplayed: false}); + this.setState({contextMenuPosition: null}); }; private onUnreadFirstChanged = async () => { - const isUnreadFirst = RoomListStore.instance.getListOrder(this.props.layout.tagId) === ListAlgorithm.Importance; + const isUnreadFirst = RoomListStore.instance.getListOrder(this.props.tagId) === ListAlgorithm.Importance; const newAlgorithm = isUnreadFirst ? ListAlgorithm.Natural : ListAlgorithm.Importance; - await RoomListStore.instance.setListOrder(this.props.layout.tagId, newAlgorithm); + await RoomListStore.instance.setListOrder(this.props.tagId, newAlgorithm); }; private onTagSortChanged = async (sort: SortAlgorithm) => { - await RoomListStore.instance.setTagSorting(this.props.layout.tagId, sort); + await RoomListStore.instance.setTagSorting(this.props.tagId, sort); }; private onMessagePreviewChanged = () => { @@ -144,6 +183,30 @@ export default class RoomSublist2 extends React.Component { this.forceUpdate(); // because the layout doesn't trigger a re-render }; + private onBadgeClick = (ev: React.MouseEvent) => { + ev.preventDefault(); + ev.stopPropagation(); + + let room; + if (this.props.tagId === DefaultTagID.Invite) { + // switch to first room as that'll be the top of the list for the user + room = this.props.rooms && this.props.rooms[0]; + } else { + // find the first room with a count of the same colour as the badge count + room = this.props.rooms.find((r: Room) => { + const notifState = this.state.notificationState.getForRoom(r); + return notifState.count > 0 && notifState.color === this.state.notificationState.color; + }); + } + + if (room) { + dis.dispatch({ + action: 'view_room', + room_id: room.roomId, + }); + } + }; + private onHeaderClick = (ev: React.MouseEvent) => { let target = ev.target as HTMLDivElement; if (!target.classList.contains('mx_RoomSublist2_headerText')) { @@ -158,44 +221,108 @@ export default class RoomSublist2 extends React.Component { sublist.scrollIntoView({behavior: 'smooth'}); } else { // on screen - toggle collapse - this.props.layout.isCollapsed = !this.props.layout.isCollapsed; - this.forceUpdate(); // because the layout doesn't trigger an update + this.toggleCollapsed(); } }; - private renderTiles(): React.ReactElement[] { - if (this.props.layout && this.props.layout.isCollapsed) return []; // don't waste time on rendering + private toggleCollapsed = () => { + this.props.layout.isCollapsed = !this.props.layout.isCollapsed; + this.forceUpdate(); // because the layout doesn't trigger an update + }; + + private onHeaderKeyDown = (ev: React.KeyboardEvent) => { + const isCollapsed = this.props.layout && this.props.layout.isCollapsed; + switch (ev.key) { + case Key.ARROW_LEFT: + ev.stopPropagation(); + if (!isCollapsed) { + // On ARROW_LEFT collapse the room sublist if it isn't already + this.toggleCollapsed(); + } + break; + case Key.ARROW_RIGHT: { + ev.stopPropagation(); + if (isCollapsed) { + // On ARROW_RIGHT expand the room sublist if it isn't already + this.toggleCollapsed(); + } else if (this.sublistRef.current) { + // otherwise focus the first room + const element = this.sublistRef.current.querySelector(".mx_RoomTile2") as HTMLDivElement; + if (element) { + element.focus(); + } + } + break; + } + } + }; + + private onKeyDown = (ev: React.KeyboardEvent) => { + switch (ev.key) { + // On ARROW_LEFT go to the sublist header + case Key.ARROW_LEFT: + ev.stopPropagation(); + this.headerButton.current.focus(); + break; + // Consume ARROW_RIGHT so it doesn't cause focus to get sent to composer + case Key.ARROW_RIGHT: + ev.stopPropagation(); + } + }; + + private renderVisibleTiles(): React.ReactElement[] { + if (this.props.layout && this.props.layout.isCollapsed) { + // don't waste time on rendering + return []; + } const tiles: React.ReactElement[] = []; + if (this.props.extraBadTilesThatShouldntExist) { + tiles.push(...this.props.extraBadTilesThatShouldntExist); + } + if (this.props.rooms) { - for (const room of this.props.rooms) { + const visibleRooms = this.props.rooms.slice(0, this.numVisibleTiles); + for (const room of visibleRooms) { tiles.push( ); } } + // We only have to do this because of the extra tiles. We do it conditionally + // to avoid spending cycles on slicing. It's generally fine to do this though + // as users are unlikely to have more than a handful of tiles when the extra + // tiles are used. + if (tiles.length > this.numVisibleTiles) { + return tiles.slice(0, this.numVisibleTiles); + } + return tiles; } private renderMenu(): React.ReactElement { + // TODO: Get a proper invite context menu, or take invites out of the room list. + if (this.props.tagId === DefaultTagID.Invite) { + return null; + } + let contextMenu = null; - if (this.state.menuDisplayed) { - const elementRect = this.menuButtonRef.current.getBoundingClientRect(); - const isAlphabetical = RoomListStore.instance.getTagSorting(this.props.layout.tagId) === SortAlgorithm.Alphabetic; - const isUnreadFirst = RoomListStore.instance.getListOrder(this.props.layout.tagId) === ListAlgorithm.Importance; + if (this.state.contextMenuPosition) { + const isAlphabetical = RoomListStore.instance.getTagSorting(this.props.tagId) === SortAlgorithm.Alphabetic; + const isUnreadFirst = RoomListStore.instance.getListOrder(this.props.tagId) === ListAlgorithm.Importance; contextMenu = (
    @@ -204,14 +331,14 @@ export default class RoomSublist2 extends React.Component { this.onTagSortChanged(SortAlgorithm.Recent)} checked={!isAlphabetical} - name={`mx_${this.props.layout.tagId}_sortBy`} + name={`mx_${this.props.tagId}_sortBy`} > {_t("Activity")} this.onTagSortChanged(SortAlgorithm.Alphabetic)} checked={isAlphabetical} - name={`mx_${this.props.layout.tagId}_sortBy`} + name={`mx_${this.props.tagId}_sortBy`} > {_t("A-Z")} @@ -246,9 +373,8 @@ export default class RoomSublist2 extends React.Component { {contextMenu} @@ -256,27 +382,30 @@ export default class RoomSublist2 extends React.Component { } private renderHeader(): React.ReactElement { - // TODO: Title on collapsed - // TODO: Incoming call box - return ( - + {({onFocus, isActive, ref}) => { - // TODO: Use onFocus const tabIndex = isActive ? 0 : -1; - // TODO: Collapsed state - - const badge = ; + const badge = ( + + ); let addRoomButton = null; if (!!this.props.onAddRoom) { addRoomButton = ( - ); } @@ -291,27 +420,40 @@ export default class RoomSublist2 extends React.Component { 'mx_RoomSublist2_headerContainer_withAux': !!addRoomButton, }); - // TODO: a11y (see old component) + const badgeContainer = ( +
    + {badge} +
    + ); + + // TODO: a11y (see old component): https://github.com/vector-im/riot-web/issues/14180 + // Note: the addRoomButton conditionally gets moved around + // the DOM depending on whether or not the list is minimized. + // If we're minimized, we want it below the header so it + // doesn't become sticky. + // The same applies to the notification badge. return ( -
    -
    +
    +
    {this.props.label} {this.renderMenu()} - {addRoomButton} -
    - {badge} -
    + {this.props.isMinimized ? null : badgeContainer} + {this.props.isMinimized ? null : addRoomButton}
    + {this.props.isMinimized ? badgeContainer : null} + {this.props.isMinimized ? addRoomButton : null}
    ); }} @@ -320,36 +462,33 @@ export default class RoomSublist2 extends React.Component { } public render(): React.ReactElement { - // TODO: Proper rendering - // TODO: Error boundary + // TODO: Error boundary: https://github.com/vector-im/riot-web/issues/14185 - const tiles = this.renderTiles(); + const visibleTiles = this.renderVisibleTiles(); const classes = classNames({ - // TODO: Proper collapse support 'mx_RoomSublist2': true, - 'mx_RoomSublist2_collapsed': false, // len && isCollapsed - 'mx_RoomSublist2_hasMenuOpen': this.state.menuDisplayed, + 'mx_RoomSublist2_hasMenuOpen': !!this.state.contextMenuPosition, 'mx_RoomSublist2_minimized': this.props.isMinimized, }); let content = null; - if (tiles.length > 0) { + if (visibleTiles.length > 0) { const layout = this.props.layout; // to shorten calls - // TODO: Lazy list rendering - // TODO: Whatever scrolling magic needs to happen here - - const nVisible = Math.floor(layout.visibleTiles); - const visibleTiles = tiles.slice(0, nVisible); + const maxTilesFactored = layout.tilesWithResizerBoxFactor(this.numTiles); + const showMoreBtnClasses = classNames({ + 'mx_RoomSublist2_showNButton': true, + 'mx_RoomSublist2_isCutting': this.state.isResizing && layout.visibleTiles < maxTilesFactored, + }); // If we're hiding rooms, show a 'show more' button to the user. This button // floats above the resize handle, if we have one present. If the user has all // tiles visible, it becomes 'show less'. let showNButton = null; - if (tiles.length > nVisible) { + if (this.numTiles > visibleTiles.length) { // we have a cutoff condition - add the button to show all - const numMissing = tiles.length - visibleTiles.length; + const numMissing = this.numTiles - visibleTiles.length; let showMoreText = ( {_t("Show %(count)s more", {count: numMissing})} @@ -357,14 +496,14 @@ export default class RoomSublist2 extends React.Component { ); if (this.props.isMinimized) showMoreText = null; showNButton = ( -
    +
    {/* set by CSS masking */} {showMoreText}
    ); - } else if (tiles.length <= nVisible && tiles.length > this.props.layout.minVisibleTiles) { + } else if (this.numTiles <= visibleTiles.length && this.numTiles > this.props.layout.defaultVisibleTiles) { // we have all tiles visible - add a button to show less let showLessText = ( @@ -373,7 +512,7 @@ export default class RoomSublist2 extends React.Component { ); if (this.props.isMinimized) showLessText = null; showNButton = ( -
    +
    {/* set by CSS masking */} @@ -384,7 +523,7 @@ export default class RoomSublist2 extends React.Component { // Figure out if we need a handle let handles = ['s']; - if (layout.visibleTiles >= tiles.length && tiles.length <= layout.minVisibleTiles) { + if (layout.visibleTiles >= this.numTiles && this.numTiles <= layout.minVisibleTiles) { handles = []; // no handles, we're at a minimum } @@ -403,9 +542,9 @@ export default class RoomSublist2 extends React.Component { if (showNButton) padding += SHOW_N_BUTTON_HEIGHT; padding += RESIZE_HANDLE_HEIGHT; // always append the handle height - const relativeTiles = layout.tilesWithPadding(tiles.length, padding); + const relativeTiles = layout.tilesWithPadding(this.numTiles, padding); const minTilesPx = layout.calculateTilesToPixelsMin(relativeTiles, layout.minVisibleTiles, padding); - const maxTilesPx = layout.tilesToPixelsWithPadding(tiles.length, padding); + const maxTilesPx = layout.tilesToPixelsWithPadding(this.numTiles, padding); const tilesWithoutPadding = Math.min(relativeTiles, layout.visibleTiles); const tilesPx = layout.calculateTilesToPixelsMin(relativeTiles, tilesWithoutPadding, padding); @@ -419,6 +558,8 @@ export default class RoomSublist2 extends React.Component { resizeHandles={handles} onResize={this.onResize} className="mx_RoomSublist2_resizeBox" + onResizeStart={this.onResizeStart} + onResizeStop={this.onResizeStop} > {visibleTiles} {showNButton} @@ -426,12 +567,13 @@ export default class RoomSublist2 extends React.Component { ); } - // TODO: onKeyDown support return (
    {this.renderHeader()} {content} diff --git a/src/components/views/rooms/RoomTile2.tsx b/src/components/views/rooms/RoomTile2.tsx index 9f4870d437..8a9712b5a4 100644 --- a/src/components/views/rooms/RoomTile2.tsx +++ b/src/components/views/rooms/RoomTile2.tsx @@ -17,21 +17,29 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { createRef } from "react"; +import React from "react"; import { Room } from "matrix-js-sdk/src/models/room"; import classNames from "classnames"; import { RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex"; import AccessibleButton, { ButtonEvent } from "../../views/elements/AccessibleButton"; -import RoomAvatar from "../../views/avatars/RoomAvatar"; import dis from '../../../dispatcher/dispatcher'; import { Key } from "../../../Keyboard"; import ActiveRoomObserver from "../../../ActiveRoomObserver"; -import NotificationBadge, { INotificationState, NotificationColor, RoomNotificationState } from "./NotificationBadge"; import { _t } from "../../../languageHandler"; -import { ContextMenu, ContextMenuButton } from "../../structures/ContextMenu"; +import { ContextMenu, ContextMenuButton, MenuItemRadio } from "../../structures/ContextMenu"; import { DefaultTagID, TagID } from "../../../stores/room-list/models"; -import { MessagePreviewStore } from "../../../stores/MessagePreviewStore"; -import RoomTileIcon from "./RoomTileIcon"; +import { MessagePreviewStore } from "../../../stores/room-list/MessagePreviewStore"; +import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar"; +import { getRoomNotifsState, ALL_MESSAGES, ALL_MESSAGES_LOUD, MENTIONS_ONLY, MUTE } from "../../../RoomNotifs"; +import { MatrixClientPeg } from "../../../MatrixClientPeg"; +import { setRoomNotifsState } from "../../../RoomNotifs"; +import { TagSpecificNotificationState } from "../../../stores/notifications/TagSpecificNotificationState"; +import { INotificationState } from "../../../stores/notifications/INotificationState"; +import NotificationBadge from "./NotificationBadge"; +import { NotificationColor } from "../../../stores/notifications/NotificationColor"; + +// TODO: Remove banner on launch: https://github.com/vector-im/riot-web/issues/14231 +// TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14231 /******************************************************************* * CAUTION * @@ -47,46 +55,74 @@ interface IProps { isMinimized: boolean; tag: TagID; - // TODO: Allow falsifying counts (for invites and stuff) - // TODO: Transparency? Was this ever used? - // TODO: Incoming call boxes? + // TODO: Incoming call boxes: https://github.com/vector-im/riot-web/issues/14177 } +type PartialDOMRect = Pick; + interface IState { hover: boolean; notificationState: INotificationState; selected: boolean; - generalMenuDisplayed: boolean; + notificationsMenuPosition: PartialDOMRect; + generalMenuPosition: PartialDOMRect; } -export default class RoomTile2 extends React.Component { - private roomTileRef: React.RefObject = createRef(); - private generalMenuButtonRef: React.RefObject = createRef(); +const contextMenuBelow = (elementRect: PartialDOMRect) => { + // align the context menu's icons with the icon which opened the context menu + const left = elementRect.left + window.pageXOffset - 9; + const top = elementRect.bottom + window.pageYOffset + 17; + const chevronFace = "none"; + return {left, top, chevronFace}; +}; - // TODO: Custom status - // TODO: Lock icon - // TODO: Presence indicator - // TODO: e2e shields - // TODO: Handle changes to room aesthetics (name, join rules, etc) - // TODO: scrollIntoView? - // TODO: hover, badge, etc - // TODO: isSelected for hover effects - // TODO: Context menu - // TODO: a11y +interface INotifOptionProps { + active: boolean; + iconClassName: string; + label: string; + onClick(ev: ButtonEvent); +} + +const NotifOption: React.FC = ({active, onClick, iconClassName, label}) => { + const classes = classNames({ + mx_RoomTile2_contextMenu_activeRow: active, + }); + + let activeIcon; + if (active) { + activeIcon = ; + } + + return ( + + + { label } + { activeIcon } + + ); +}; + +export default class RoomTile2 extends React.Component { + // TODO: a11y: https://github.com/vector-im/riot-web/issues/14180 constructor(props: IProps) { super(props); this.state = { hover: false, - notificationState: new RoomNotificationState(this.props.room), + notificationState: new TagSpecificNotificationState(this.props.room, this.props.tag), selected: ActiveRoomObserver.activeRoomId === this.props.room.roomId, - generalMenuDisplayed: false, + notificationsMenuPosition: null, + generalMenuPosition: null, }; ActiveRoomObserver.addListener(this.props.room.roomId, this.onActiveRoomUpdate); } + private get showContextMenu(): boolean { + return !this.props.isMinimized && this.props.tag !== DefaultTagID.Invite; + } + public componentWillUnmount() { if (this.props.room) { ActiveRoomObserver.removeListener(this.props.room.roomId, this.onActiveRoomUpdate); @@ -102,9 +138,11 @@ export default class RoomTile2 extends React.Component { }; private onTileClick = (ev: React.KeyboardEvent) => { + ev.preventDefault(); + ev.stopPropagation(); dis.dispatch({ action: 'view_room', - // TODO: Support show_room_tile in new room list + // TODO: Support show_room_tile in new room list: https://github.com/vector-im/riot-web/issues/14233 show_room_tile: true, // make sure the room is visible in the list room_id: this.props.room.roomId, clear_search: (ev && (ev.key === Key.ENTER || ev.key === Key.SPACE)), @@ -115,27 +153,48 @@ export default class RoomTile2 extends React.Component { this.setState({selected: isActive}); }; + private onNotificationsMenuOpenClick = (ev: InputEvent) => { + ev.preventDefault(); + ev.stopPropagation(); + const target = ev.target as HTMLButtonElement; + this.setState({notificationsMenuPosition: target.getBoundingClientRect()}); + }; + + private onCloseNotificationsMenu = () => { + this.setState({notificationsMenuPosition: null}); + }; + private onGeneralMenuOpenClick = (ev: InputEvent) => { ev.preventDefault(); ev.stopPropagation(); - this.setState({generalMenuDisplayed: true}); + const target = ev.target as HTMLButtonElement; + this.setState({generalMenuPosition: target.getBoundingClientRect()}); }; - private onCloseGeneralMenu = (ev: InputEvent) => { + private onContextMenu = (ev: React.MouseEvent) => { + // If we don't have a context menu to show, ignore the action. + if (!this.showContextMenu) return; + ev.preventDefault(); ev.stopPropagation(); - this.setState({generalMenuDisplayed: false}); + this.setState({ + generalMenuPosition: { + left: ev.clientX, + bottom: ev.clientY, + }, + }); + }; + + private onCloseGeneralMenu = () => { + this.setState({generalMenuPosition: null}); }; private onTagRoom = (ev: ButtonEvent, tagId: TagID) => { ev.preventDefault(); ev.stopPropagation(); - if (tagId === DefaultTagID.DM) { - // TODO: DM Flagging - } else { - // TODO: XOR favourites and low priority - } + // TODO: Support tagging: https://github.com/vector-im/riot-web/issues/14211 + // TODO: XOR favourites and low priority: https://github.com/vector-im/riot-web/issues/14210 }; private onLeaveRoomClick = (ev: ButtonEvent) => { @@ -146,7 +205,7 @@ export default class RoomTile2 extends React.Component { action: 'leave_room', room_id: this.props.room.roomId, }); - this.setState({generalMenuDisplayed: false}); // hide the menu + this.setState({generalMenuPosition: null}); // hide the menu }; private onOpenRoomSettings = (ev: ButtonEvent) => { @@ -157,64 +216,126 @@ export default class RoomTile2 extends React.Component { action: 'open_room_settings', room_id: this.props.room.roomId, }); - this.setState({generalMenuDisplayed: false}); // hide the menu + this.setState({generalMenuPosition: null}); // hide the menu }; - private renderGeneralMenu(): React.ReactElement { - if (this.props.isMinimized) return null; // no menu when minimized + private async saveNotifState(ev: ButtonEvent, newState: ALL_MESSAGES_LOUD | ALL_MESSAGES | MENTIONS_ONLY | MUTE) { + ev.preventDefault(); + ev.stopPropagation(); + if (MatrixClientPeg.get().isGuest()) return; + + try { + // TODO add local echo - https://github.com/vector-im/riot-web/issues/14280 + await setRoomNotifsState(this.props.room.roomId, newState); + } catch (error) { + // TODO: some form of error notification to the user to inform them that their state change failed. + // https://github.com/vector-im/riot-web/issues/14281 + console.error(error); + } + + this.setState({notificationsMenuPosition: null}); // Close the context menu + } + + private onClickAllNotifs = ev => this.saveNotifState(ev, ALL_MESSAGES); + private onClickAlertMe = ev => this.saveNotifState(ev, ALL_MESSAGES_LOUD); + private onClickMentions = ev => this.saveNotifState(ev, MENTIONS_ONLY); + private onClickMute = ev => this.saveNotifState(ev, MUTE); + + private renderNotificationsMenu(isActive: boolean): React.ReactElement { + if (MatrixClientPeg.get().isGuest() || !this.showContextMenu) { + // the menu makes no sense in these cases so do not show one + return null; + } + + const state = getRoomNotifsState(this.props.room.roomId); let contextMenu = null; - if (this.state.generalMenuDisplayed) { - // The context menu appears within the list, so use the room tile as a reference point - const elementRect = this.roomTileRef.current.getBoundingClientRect(); + if (this.state.notificationsMenuPosition) { contextMenu = ( - -
    + +
    -
      -
    • - this.onTagRoom(e, DefaultTagID.Favourite)}> - - {_t("Favourite")} - -
    • -
    • - this.onTagRoom(e, DefaultTagID.LowPriority)}> - - {_t("Low Priority")} - -
    • -
    • - this.onTagRoom(e, DefaultTagID.DM)}> - - {_t("Direct Chat")} - -
    • -
    • - - - {_t("Settings")} - -
    • -
    + + + +
    +
    +
    + ); + } + + const classes = classNames("mx_RoomTile2_notificationsButton", { + // Show bell icon for the default case too. + mx_RoomTile2_iconBell: state === ALL_MESSAGES, + mx_RoomTile2_iconBellDot: state === ALL_MESSAGES_LOUD, + mx_RoomTile2_iconBellMentions: state === MENTIONS_ONLY, + mx_RoomTile2_iconBellCrossed: state === MUTE, + + // Only show the icon by default if the room is overridden to muted. + // TODO: [FTUE Notifications] Probably need to detect global mute state + mx_RoomTile2_notificationsButton_show: state === MUTE, + }); + + return ( + + + {contextMenu} + + ); + } + + private renderGeneralMenu(): React.ReactElement { + if (!this.showContextMenu) return null; // no menu to show + + // TODO: We could do with a proper invite context menu, unlike what showContextMenu suggests + + let contextMenu = null; + if (this.state.generalMenuPosition) { + contextMenu = ( + +
    -
      -
    • - - - {_t("Leave Room")} - -
    • -
    + this.onTagRoom(e, DefaultTagID.Favourite)}> + + {_t("Favourite")} + + + + {_t("Settings")} + +
    +
    + + + {_t("Leave Room")} +
    @@ -226,9 +347,8 @@ export default class RoomTile2 extends React.Component { {contextMenu} @@ -236,32 +356,45 @@ export default class RoomTile2 extends React.Component { } public render(): React.ReactElement { - // TODO: Collapsed state - // TODO: Invites - // TODO: a11y proper - // TODO: Render more than bare minimum + // TODO: Invites: https://github.com/vector-im/riot-web/issues/14198 + // TODO: a11y proper: https://github.com/vector-im/riot-web/issues/14180 const classes = classNames({ 'mx_RoomTile2': true, 'mx_RoomTile2_selected': this.state.selected, - 'mx_RoomTile2_hasMenuOpen': this.state.generalMenuDisplayed, + 'mx_RoomTile2_hasMenuOpen': !!(this.state.generalMenuPosition || this.state.notificationsMenuPosition), 'mx_RoomTile2_minimized': this.props.isMinimized, }); - const badge = ; + const roomAvatar = ; + + let badge: React.ReactNode; + if (!this.props.isMinimized) { + badge = ( +
    + +
    + ); + } // TODO: the original RoomTile uses state for the room name. Do we need to? let name = this.props.room.name; if (typeof name !== 'string') name = ''; name = name.replace(":", ":\u200b"); // add a zero-width space to allow linewrapping after the colon - // TODO: Support collapsed state properly - // TODO: Tooltip? - let messagePreview = null; if (this.props.showMessagePreview && !this.props.isMinimized) { // The preview store heavily caches this info, so should be safe to hammer. - const text = MessagePreviewStore.instance.getPreviewForRoom(this.props.room); + const text = MessagePreviewStore.instance.getPreviewForRoom(this.props.room, this.props.tag); // Only show the preview if there is one to show. if (text) { @@ -289,10 +422,9 @@ export default class RoomTile2 extends React.Component { ); if (this.props.isMinimized) nameContainer = null; - const avatarSize = 32; return ( - + {({onFocus, isActive, ref}) => { onMouseLeave={this.onTileMouseLeave} onClick={this.onTileClick} role="treeitem" + onContextMenu={this.onContextMenu} > -
    - - -
    + {roomAvatar} {nameContainer} -
    - {badge} -
    + {badge} + {this.renderNotificationsMenu(isActive)} {this.renderGeneralMenu()}
    } diff --git a/src/components/views/rooms/TemporaryTile.tsx b/src/components/views/rooms/TemporaryTile.tsx new file mode 100644 index 0000000000..b6c165ecda --- /dev/null +++ b/src/components/views/rooms/TemporaryTile.tsx @@ -0,0 +1,116 @@ +/* +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. +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 { RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex"; +import AccessibleButton from "../../views/elements/AccessibleButton"; +import { INotificationState } from "../../../stores/notifications/INotificationState"; +import NotificationBadge from "./NotificationBadge"; +import { NotificationColor } from "../../../stores/notifications/NotificationColor"; + +interface IProps { + isMinimized: boolean; + isSelected: boolean; + displayName: string; + avatar: React.ReactElement; + notificationState: INotificationState; + onClick: () => void; +} + +interface IState { + hover: boolean; +} + +export default class TemporaryTile extends React.Component { + constructor(props: IProps) { + super(props); + + this.state = { + hover: false, + }; + } + + private onTileMouseEnter = () => { + this.setState({hover: true}); + }; + + private onTileMouseLeave = () => { + this.setState({hover: false}); + }; + + public render(): React.ReactElement { + // XXX: We copy classes because it's easier + const classes = classNames({ + 'mx_RoomTile2': true, + 'mx_TemporaryTile': true, + 'mx_RoomTile2_selected': this.props.isSelected, + 'mx_RoomTile2_minimized': this.props.isMinimized, + }); + + const badge = ( + + ); + + let name = this.props.displayName; + if (typeof name !== 'string') name = ''; + name = name.replace(":", ":\u200b"); // add a zero-width space to allow linewrapping after the colon + + const nameClasses = classNames({ + "mx_RoomTile2_name": true, + "mx_RoomTile2_nameHasUnreadEvents": this.props.notificationState.color >= NotificationColor.Bold, + }); + + let nameContainer = ( +
    +
    + {name} +
    +
    + ); + if (this.props.isMinimized) nameContainer = null; + + return ( + + + {({onFocus, isActive, ref}) => + +
    + {this.props.avatar} +
    + {nameContainer} +
    + {badge} +
    +
    + } +
    +
    + ); + } +} diff --git a/src/components/views/settings/CrossSigningPanel.js b/src/components/views/settings/CrossSigningPanel.js index 7eb239cbca..aa512d4365 100644 --- a/src/components/views/settings/CrossSigningPanel.js +++ b/src/components/views/settings/CrossSigningPanel.js @@ -154,13 +154,6 @@ export default class CrossSigningPanel extends React.PureComponent { errorSection =
    {error.toString()}
    ; } - // Whether the various keys exist on your account (but not necessarily - // on this device). - const enabledForAccount = ( - crossSigningPrivateKeysInStorage && - secretStorageKeyInAccount - ); - let summarisedStatus; if (homeserverSupportsCrossSigning === undefined) { const InlineSpinner = sdk.getComponent('views.elements.InlineSpinner'); @@ -184,8 +177,19 @@ export default class CrossSigningPanel extends React.PureComponent { )}

    ; } + const keysExistAnywhere = ( + secretStorageKeyInAccount || + crossSigningPrivateKeysInStorage || + crossSigningPublicKeysOnDevice + ); + const keysExistEverywhere = ( + secretStorageKeyInAccount && + crossSigningPrivateKeysInStorage && + crossSigningPublicKeysOnDevice + ); + let resetButton; - if (enabledForAccount) { + if (keysExistAnywhere) { resetButton = (
    @@ -197,10 +201,7 @@ export default class CrossSigningPanel extends React.PureComponent { // TODO: determine how better to expose this to users in addition to prompts at login/toast let bootstrapButton; - if ( - (!enabledForAccount || !crossSigningPublicKeysOnDevice) && - homeserverSupportsCrossSigning - ) { + if (!keysExistEverywhere && homeserverSupportsCrossSigning) { bootstrapButton = (
    diff --git a/src/components/views/settings/SetIdServer.js b/src/components/views/settings/SetIdServer.js index 23e72e2352..e05fe4f1c3 100644 --- a/src/components/views/settings/SetIdServer.js +++ b/src/components/views/settings/SetIdServer.js @@ -413,7 +413,7 @@ export default class SetIdServer extends React.Component { tooltipContent={this._getTooltip()} tooltipClassName="mx_SetIdServer_tooltip" disabled={this.state.busy} - flagInvalid={!!this.state.error} + forceValidity={this.state.error ? false : null} /> { - if (!soundData) { - return; - } - this.setState({currentSound: soundData.name || soundData.url}); - }); - - this._soundUpload = createRef(); + const soundData = Notifier.getSoundForRoom(this.props.roomId); + if (!soundData) { + return; + } + this.setState({currentSound: soundData.name || soundData.url}); } async _triggerUploader(e) { diff --git a/src/components/views/settings/tabs/user/AppearanceUserSettingsTab.tsx b/src/components/views/settings/tabs/user/AppearanceUserSettingsTab.tsx index 46723ec7cd..9bed2fb039 100644 --- a/src/components/views/settings/tabs/user/AppearanceUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/AppearanceUserSettingsTab.tsx @@ -21,7 +21,6 @@ import {_t} from "../../../../../languageHandler"; import SettingsStore, {SettingLevel} from "../../../../../settings/SettingsStore"; import { enumerateThemes } from "../../../../../theme"; import ThemeWatcher from "../../../../../settings/watchers/ThemeWatcher"; -import Field from "../../../elements/Field"; import Slider from "../../../elements/Slider"; import AccessibleButton from "../../../elements/AccessibleButton"; import dis from "../../../../../dispatcher/dispatcher"; @@ -32,7 +31,10 @@ import { IValidationResult, IFieldState } from '../../../elements/Validation'; import StyledRadioButton from '../../../elements/StyledRadioButton'; import StyledCheckbox from '../../../elements/StyledCheckbox'; import SettingsFlag from '../../../elements/SettingsFlag'; +import Field from '../../../elements/Field'; import EventTilePreview from '../../../elements/EventTilePreview'; +import StyledRadioGroup from "../../../elements/StyledRadioGroup"; +import classNames from 'classnames'; interface IProps { } @@ -55,6 +57,9 @@ interface IState extends IThemeState { customThemeUrl: string; customThemeMessage: CustomThemeMessage; useCustomFontSize: boolean; + useSystemFont: boolean; + systemFont: string; + showAdvanced: boolean; useIRCLayout: boolean; } @@ -73,6 +78,9 @@ export default class AppearanceUserSettingsTab extends React.Component): void => { - const newTheme = e.target.value; + private onThemeChange = (newTheme: string): void => { if (this.state.theme === newTheme) return; // doing getValue in the .catch will still return the value we failed to set, @@ -271,22 +278,21 @@ export default class AppearanceUserSettingsTab extends React.Component {_t("Theme")} {systemThemeSection} -
    - {orderedThemes.map(theme => { - return - {theme.name} - ; - })} +
    + ({ + value: t.id, + label: t.name, + disabled: this.state.useSystemTheme, + className: "mx_ThemeSelector_" + t.id, + }))} + onChange={this.onThemeChange} + value={this.state.useSystemTheme ? undefined : this.state.theme} + outlined + />
    {customThemeForm} -
    ); } @@ -338,8 +344,10 @@ export default class AppearanceUserSettingsTab extends React.Component {_t("Message layout")} -
    -
    +
    +
    -
    +
    ; }; + private renderAdvancedSection() { + const toggle =
    this.setState({showAdvanced: !this.state.showAdvanced})} + > + {this.state.showAdvanced ? "Hide advanced" : "Show advanced"} +
    ; + + let advanced: React.ReactNode; + + if (this.state.showAdvanced) { + advanced = <> + + this.setState({useSystemFont: checked})} + /> + { + this.setState({ + systemFont: value.target.value, + }); + + SettingsStore.setValue("systemFont", null, SettingLevel.DEVICE, value.target.value); + }} + tooltipContent="Set the name of a font installed on your system & Riot will attempt to use it." + forceTooltipVisible={true} + disabled={!this.state.useSystemFont} + value={this.state.systemFont} + /> + ; + } + return
    + {toggle} + {advanced} +
    ; + } + render() { return (
    @@ -384,6 +441,7 @@ export default class AppearanceUserSettingsTab extends React.Component ); } diff --git a/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.js b/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.js index bdb2a9ffc4..40b622cf37 100644 --- a/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.js +++ b/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.js @@ -23,6 +23,7 @@ import SettingsStore from "../../../../../settings/SettingsStore"; import Field from "../../../elements/Field"; import * as sdk from "../../../../.."; import PlatformPeg from "../../../../../PlatformPeg"; +import {RoomListStoreTempProxy} from "../../../../../stores/room-list/RoomListStoreTempProxy"; export default class PreferencesUserSettingsTab extends React.Component { static ROOM_LIST_SETTINGS = [ @@ -31,6 +32,19 @@ export default class PreferencesUserSettingsTab extends React.Component { 'breadcrumbs', ]; + // TODO: Remove temp structures: https://github.com/vector-im/riot-web/issues/14231 + static ROOM_LIST_2_SETTINGS = [ + 'breadcrumbs', + ]; + + // TODO: Remove temp structures: https://github.com/vector-im/riot-web/issues/14231 + static eligibleRoomListSettings = () => { + if (RoomListStoreTempProxy.isUsingNewStore()) { + return PreferencesUserSettingsTab.ROOM_LIST_2_SETTINGS; + } + return PreferencesUserSettingsTab.ROOM_LIST_SETTINGS; + }; + static COMPOSER_SETTINGS = [ 'MessageComposerInput.autoReplaceEmoji', 'MessageComposerInput.suggestEmoji', @@ -175,7 +189,7 @@ export default class PreferencesUserSettingsTab extends React.Component {
    {_t("Room list")} - {this._renderGroup(PreferencesUserSettingsTab.ROOM_LIST_SETTINGS)} + {this._renderGroup(PreferencesUserSettingsTab.eligibleRoomListSettings())}
    diff --git a/src/components/views/terms/InlineTermsAgreement.js b/src/components/views/terms/InlineTermsAgreement.js index bccd686cd3..55719fe57f 100644 --- a/src/components/views/terms/InlineTermsAgreement.js +++ b/src/components/views/terms/InlineTermsAgreement.js @@ -18,6 +18,7 @@ import React from "react"; import PropTypes from "prop-types"; import {_t, pickBestLanguage} from "../../../languageHandler"; import * as sdk from "../../.."; +import {objectClone} from "../../../utils/objects"; export default class InlineTermsAgreement extends React.Component { static propTypes = { @@ -56,7 +57,7 @@ export default class InlineTermsAgreement extends React.Component { } _togglePolicy = (index) => { - const policies = JSON.parse(JSON.stringify(this.state.policies)); // deep & cheap clone + const policies = objectClone(this.state.policies); policies[index].checked = !policies[index].checked; this.setState({policies}); }; diff --git a/src/components/views/toasts/GenericExpiringToast.tsx b/src/components/views/toasts/GenericExpiringToast.tsx new file mode 100644 index 0000000000..83f43208c4 --- /dev/null +++ b/src/components/views/toasts/GenericExpiringToast.tsx @@ -0,0 +1,53 @@ +/* +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. +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 ToastStore from "../../../stores/ToastStore"; +import GenericToast, { IProps as IGenericToastProps } from "./GenericToast"; +import {useExpiringCounter} from "../../../hooks/useTimeout"; + +interface IProps extends IGenericToastProps { + toastKey: string; + numSeconds: number; + dismissLabel: string; + onDismiss?(); +} + +const SECOND = 1000; + +const GenericExpiringToast: React.FC = ({description, acceptLabel, dismissLabel, onAccept, onDismiss, toastKey, numSeconds}) => { + const onReject = () => { + if (onDismiss) onDismiss(); + ToastStore.sharedInstance().dismissToast(toastKey); + }; + const counter = useExpiringCounter(onReject, SECOND, numSeconds); + + let rejectLabel = dismissLabel; + if (counter > 0) { + rejectLabel += ` (${counter})`; + } + + return ; +}; + +export default GenericExpiringToast; diff --git a/src/components/views/toasts/GenericToast.tsx b/src/components/views/toasts/GenericToast.tsx index ea12641948..9f8885ba47 100644 --- a/src/components/views/toasts/GenericToast.tsx +++ b/src/components/views/toasts/GenericToast.tsx @@ -19,7 +19,7 @@ import React, {ReactChild} from "react"; import FormButton from "../elements/FormButton"; import {XOR} from "../../../@types/common"; -interface IProps { +export interface IProps { description: ReactChild; acceptLabel: string; diff --git a/src/dispatcher/actions.ts b/src/dispatcher/actions.ts index 5f7ca1293c..379a0a4451 100644 --- a/src/dispatcher/actions.ts +++ b/src/dispatcher/actions.ts @@ -69,4 +69,14 @@ export enum Action { * Opens the user menu (previously known as the top left menu). No additional payload information required. */ ToggleUserMenu = "toggle_user_menu", + + /** + * Sets the apps root font size. Should be used with UpdateFontSizePayload + */ + UpdateFontSize = "update_font_size", + + /** + * Sets a system font. Should be used with UpdateSystemFontPayload + */ + UpdateSystemFont = "update_system_font", } diff --git a/src/dispatcher/payloads/UpdateFontSizePayload.ts b/src/dispatcher/payloads/UpdateFontSizePayload.ts new file mode 100644 index 0000000000..6577acd594 --- /dev/null +++ b/src/dispatcher/payloads/UpdateFontSizePayload.ts @@ -0,0 +1,27 @@ +/* +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. +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 { ActionPayload } from "../payloads"; +import { Action } from "../actions"; + +export interface UpdateFontSizePayload extends ActionPayload { + action: Action.UpdateFontSize; + + /** + * The font size to set the root to + */ + size: number; +} diff --git a/src/dispatcher/payloads/UpdateSystemFontPayload.ts b/src/dispatcher/payloads/UpdateSystemFontPayload.ts new file mode 100644 index 0000000000..aa59db5aa9 --- /dev/null +++ b/src/dispatcher/payloads/UpdateSystemFontPayload.ts @@ -0,0 +1,32 @@ +/* +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. +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 { ActionPayload } from "../payloads"; +import { Action } from "../actions"; + +export interface UpdateSystemFontPayload extends ActionPayload { + action: Action.UpdateSystemFont; + + /** + * Specify whether to use a system font or the stylesheet font + */ + useSystemFont: boolean; + + /** + * The system font to use + */ + font: string; +} diff --git a/src/hooks/useAccountData.ts b/src/hooks/useAccountData.ts new file mode 100644 index 0000000000..dd0d53f0d3 --- /dev/null +++ b/src/hooks/useAccountData.ts @@ -0,0 +1,50 @@ +/* +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. +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 {useCallback, useState} from "react"; +import {MatrixClient} from "matrix-js-sdk/src/client"; +import {MatrixEvent} from "matrix-js-sdk/src/models/event"; +import {Room} from "matrix-js-sdk/src/models/room"; + +import {useEventEmitter} from "./useEventEmitter"; + +const tryGetContent = (ev?: MatrixEvent) => ev ? ev.getContent() : undefined; + +// Hook to simplify listening to Matrix account data +export const useAccountData = (cli: MatrixClient, eventType: string) => { + const [value, setValue] = useState(() => tryGetContent(cli.getAccountData(eventType))); + + const handler = useCallback((event) => { + if (event.getType() !== eventType) return; + setValue(event.getContent()); + }, [cli, eventType]); + useEventEmitter(cli, "accountData", handler); + + return value || {} as T; +}; + +// Hook to simplify listening to Matrix room account data +export const useRoomAccountData = (room: Room, eventType: string) => { + const [value, setValue] = useState(() => tryGetContent(room.getAccountData(eventType))); + + const handler = useCallback((event) => { + if (event.getType() !== eventType) return; + setValue(event.getContent()); + }, [room, eventType]); + useEventEmitter(room, "Room.accountData", handler); + + return value || {} as T; +}; diff --git a/src/hooks/useTimeout.ts b/src/hooks/useTimeout.ts new file mode 100644 index 0000000000..911b7bc75d --- /dev/null +++ b/src/hooks/useTimeout.ts @@ -0,0 +1,67 @@ +/* +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. +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 {useEffect, useRef, useState} from "react"; + +type Handler = () => void; + +// Hook to simplify timeouts in functional components +export const useTimeout = (handler: Handler, timeoutMs: number) => { + // Create a ref that stores handler + const savedHandler = useRef(); + + // Update ref.current value if handler changes. + useEffect(() => { + savedHandler.current = handler; + }, [handler]); + + // Set up timer + useEffect(() => { + const timeoutID = setTimeout(() => { + savedHandler.current(); + }, timeoutMs); + return () => clearTimeout(timeoutID); + }, [timeoutMs]); +}; + +// Hook to simplify intervals in functional components +export const useInterval = (handler: Handler, intervalMs: number) => { + // Create a ref that stores handler + const savedHandler = useRef(); + + // Update ref.current value if handler changes. + useEffect(() => { + savedHandler.current = handler; + }, [handler]); + + // Set up timer + useEffect(() => { + const intervalID = setInterval(() => { + savedHandler.current(); + }, intervalMs); + return () => clearInterval(intervalID); + }, [intervalMs]); +}; + +// Hook to simplify a variable counting down to 0, handler called when it reached 0 +export const useExpiringCounter = (handler: Handler, intervalMs: number, initialCount: number) => { + const [count, setCount] = useState(initialCount); + useInterval(() => setCount(c => c - 1), intervalMs); + if (count === 0) { + handler(); + } + return count; +}; diff --git a/src/i18n/strings/bg.json b/src/i18n/strings/bg.json index 89edf90c8b..1b67bb7ced 100644 --- a/src/i18n/strings/bg.json +++ b/src/i18n/strings/bg.json @@ -2325,7 +2325,7 @@ "Verify this login": "Потвърди тази сесия", "Session verified": "Сесията беше потвърдена", "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.": "Промяната на паролата ще нулира всички ключове за шифроване от-край-до-край по всички ваши сесии, правейки шифрованата история на чата нечетима. Настройте резервно копие на ключовете или експортирайте ключовете на стаите от друга сесия преди да промените паролата си.", - "Your server admin has disabled end-to-end encryption by default in private rooms & Direct Messages.": "Администраторът на сървъра е изключиш шифроване от край-до-край по подразбиране за лични стаи и за директни съобщения.", + "Your server admin has disabled end-to-end encryption by default in private rooms & Direct Messages.": "Администраторът на сървъра е изключил шифроване от край-до-край по подразбиране за лични стаи и за директни съобщения.", "Emoji picker": "Избор на емоджи", "People": "Хора", "Show %(n)s more": "Покажи още %(n)s", @@ -2446,5 +2446,27 @@ "A-Z": "Азбучен ред", "Unread rooms": "Непрочетени стаи", "Show %(count)s more|other": "Покажи още %(count)s", - "Show %(count)s more|one": "Покажи още %(count)s" + "Show %(count)s more|one": "Покажи още %(count)s", + "Light": "Светла", + "Dark": "Тъмна", + "Use the improved room list (will refresh to apply changes)": "Използвай подобрения списък със стаи (ще презареди за да се приложи промяната)", + "Enable IRC layout option in the appearance tab": "Включи опцията за IRC изглед в раздел Изглед", + "Use custom size": "Използвай собствен размер", + "Use a system font": "Използвай системния шрифт", + "System font name": "Име на системния шрифт", + "Hey you. You're the best!": "Хей, ти. Върхът си!", + "Message layout": "Изглед на съобщенията", + "Compact": "Компактен", + "Modern": "Модерен", + "Customise your appearance": "Настройте изгледа", + "Appearance Settings only affect this Riot session.": "Настройките на изгледа влияят само на тази Riot сесия.", + "The authenticity of this encrypted message can't be guaranteed on this device.": "Автентичността на това шифровано съобщение не може да бъде гарантирана на това устройство.", + "Always show first": "Винаги показвай първо", + "Show": "Покажи", + "Message preview": "Преглед на съобщението", + "List options": "Опции на списъка", + "Leave Room": "Напусни стаята", + "Room options": "Настройки на стаята", + "Use Recovery Key or Passphrase": "Използвай ключ за възстановяване или парола", + "Use Recovery Key": "Използвай ключ за възстановяване" } diff --git a/src/i18n/strings/de_DE.json b/src/i18n/strings/de_DE.json index 143e3534c3..4b305cd5b6 100644 --- a/src/i18n/strings/de_DE.json +++ b/src/i18n/strings/de_DE.json @@ -2428,8 +2428,8 @@ "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.", "You've previously used a newer version of Riot with this session. To use this version again with end to end encryption, you will need to sign out and back in again.": "Du hast für diese Sitzung zuvor eine neuere Version von Riot verwendet. Um diese Version mit Ende-zu-Ende-Verschlüsselung wieder zu benutzen, musst du dich erst ab- und dann wieder anmelden.", "Delete the room address %(alias)s and remove %(name)s from the directory?": "Soll die Raum-Adresse %(alias)s gelöscht und %(name)s aus dem Raum-Verzeichnis entfernt werden?", - "Switch to light mode": "Zum Light Mode wechseln", - "Switch to dark mode": "Zum Dark Mode wechseln", + "Switch to light mode": "Zum hellen Thema wechseln", + "Switch to dark mode": "Zum dunklen Thema wechseln", "Switch theme": "Design ändern", "Security & privacy": "Sicherheit & Datenschutz", "All settings": "Alle Einstellungen", @@ -2462,5 +2462,79 @@ "Enter your Recovery Key to continue.": "Gib deinen Wiederherstellungsschlüssel ein um fortzufahren.", "Create a Recovery Key": "Erzeuge einen Wiederherstellungsschlüssel", "Upgrade your Recovery Key": "Aktualisiere deinen Wiederherstellungsschlüssel", - "Store your Recovery Key": "Speichere deinen Wiederherstellungsschlüssel" + "Store your Recovery Key": "Speichere deinen Wiederherstellungsschlüssel", + "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!", + "Message layout": "Nachrichtenlayout", + "Compact": "Kompakt", + "Modern": "Modern", + "Enable IRC layout option in the appearance tab": "Option für IRC Layout in den Erscheinungsbild-Einstellungen aktivieren", + "Use a system font": "Verwende die System-Schriftart", + "System font name": "System-Schriftart", + "Customise your appearance": "Verändere das Erscheinungsbild", + "Appearance Settings only affect this Riot session.": "Einstellungen zum Erscheinungsbild wirken sich nur auf diese Riot 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", + "Call in progress": "Laufendes Gespräch", + "You left the call": "Du hast den Anruf verlassen", + "%(senderName)s left the call": "%(senderName)s hat den Anruf verlassen", + "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", + "%(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", + "You made the chat encrypted": "Du hast den Raum verschlüsselt", + "%(senderName)s made the chat encrypted": "%(senderName)s hat den Raum verschlüsselt", + "You made history visible to new members": "Du hast die bisherige Kommunikation für neue Teilnehmern sichtbar gemacht", + "%(senderName)s made history visible to new members": "%(senderName)s hat die bisherige Kommunikation für neue Teilnehmern sichtbar gemacht", + "You made history visible to anyone": "Du hast die bisherige Kommunikation für alle sichtbar gemacht", + "%(senderName)s made history visible to anyone": "%(senderName)s hat die bisherige Kommunikation für alle sichtbar gemacht", + "You made history visible to future members": "Du hast die bisherige Kommunikation für zukünftige Teilnehmer sichtbar gemacht", + "%(senderName)s made history visible to future members": "%(senderName)s hat die bisherige Kommunikation für zukünftige Teilnehmer sichtbar gemacht", + "You were invited": "Du wurdest eingeladen", + "%(targetName)s was invited": "%(targetName)s wurde eingeladen", + "You left": "Du hast den Raum verlassen", + "%(targetName)s left": "%(targetName)s hat den Raum verlassen", + "You were kicked (%(reason)s)": "Du wurdest herausgeworfen (%(reason)s)", + "%(targetName)s was kicked (%(reason)s)": "%(targetName)s wurde herausgeworfen (%(reason)s)", + "You were kicked": "Du wurdest herausgeworfen", + "%(targetName)s was kicked": "%(targetName)s wurde herausgeworfen", + "You rejected the invite": "Du hast die Einladung abgelehnt", + "%(targetName)s rejected the invite": "%(targetName)s hat die Einladung abgelehnt", + "You were uninvited": "Deine Einladung wurde zurückgezogen", + "%(targetName)s was uninvited": "Die Einladung für %(targetName)s wurde zurückgezogen", + "You were banned (%(reason)s)": "Du wurdest verbannt (%(reason)s)", + "%(targetName)s was banned (%(reason)s)": "%(targetName)s wurde verbannt (%(reason)s)", + "You were banned": "Du wurdest verbannt", + "%(targetName)s was banned": "%(targetName)s wurde verbannt", + "You joined": "Du bist beigetreten", + "%(targetName)s joined": "%(targetName)s ist beigetreten", + "You changed your name": "Du hast deinen Namen geändert", + "%(targetName)s changed their name": "%(targetName)s hat den Namen geändert", + "You changed your avatar": "Du hast deinen Avatar geändert", + "%(targetName)s changed their avatar": "%(targetName)s hat den Avatar geändert", + "%(senderName)s %(emote)s": "%(senderName)s %(emote)s", + "%(senderName)s: %(message)s": "%(senderName)s: %(message)s", + "You changed the room name": "Du hast den Raumnamen geändert", + "%(senderName)s changed the room name": "%(senderName)s hat den Raumnamen geändert", + "%(senderName)s: %(reaction)s": "%(senderName)s: %(reaction)s", + "%(senderName)s: %(stickerName)s": "%(senderName)s: %(stickerName)s", + "You uninvited %(targetName)s": "Du hast die Einladung für %(targetName)s zurückgezogen", + "%(senderName)s uninvited %(targetName)s": "%(senderName)s hat die Einladung für %(targetName)s zurückgezogen", + "You invited %(targetName)s": "Du hast %(targetName)s eingeladen", + "%(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": "Verwende ein kompakteres 'modernes' Layout", + "Message deleted on %(date)s": "Nachricht am %(date)s gelöscht", + "Wrong file type": "Falscher Dateityp", + "Wrong Recovery Key": "Falscher Wiederherstellungsschlüssel", + "Invalid Recovery Key": "Ungültiger Wiederherstellungsschlüssel" } diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 646f43af33..b23264a297 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -247,7 +247,6 @@ "%(senderDisplayName)s enabled flair for %(groups)s in this room.": "%(senderDisplayName)s enabled flair for %(groups)s in this room.", "%(senderDisplayName)s disabled flair for %(groups)s in this room.": "%(senderDisplayName)s disabled flair for %(groups)s in this room.", "%(senderDisplayName)s enabled flair for %(newGroups)s and disabled flair for %(oldGroups)s in this room.": "%(senderDisplayName)s enabled flair for %(newGroups)s and disabled flair for %(oldGroups)s in this room.", - "sent an image.": "sent an image.", "%(senderDisplayName)s sent an image.": "%(senderDisplayName)s sent an image.", "%(senderName)s set the main address for this room to %(address)s.": "%(senderName)s set the main address for this room to %(address)s.", "%(senderName)s removed the main address for this room.": "%(senderName)s removed the main address for this room.", @@ -421,11 +420,66 @@ "Restart": "Restart", "Upgrade your Riot": "Upgrade your Riot", "A new version of Riot is available!": "A new version of Riot is available!", - "You: %(message)s": "You: %(message)s", + "Guest": "Guest", "There was an error joining the room": "There was an error joining the room", "Sorry, your homeserver is too old to participate in this room.": "Sorry, your homeserver is too old to participate in this room.", "Please contact your homeserver administrator.": "Please contact your homeserver administrator.", "Failed to join room": "Failed to join room", + "You joined the call": "You joined the call", + "%(senderName)s joined the call": "%(senderName)s joined the call", + "Call in progress": "Call in progress", + "You left the call": "You left the call", + "%(senderName)s left the call": "%(senderName)s left the call", + "Call ended": "Call ended", + "You started a call": "You started a call", + "%(senderName)s started a call": "%(senderName)s started a call", + "Waiting for answer": "Waiting for answer", + "%(senderName)s is calling": "%(senderName)s is calling", + "You created the room": "You created the room", + "%(senderName)s created the room": "%(senderName)s created the room", + "You made the chat encrypted": "You made the chat encrypted", + "%(senderName)s made the chat encrypted": "%(senderName)s made the chat encrypted", + "You made history visible to new members": "You made history visible to new members", + "%(senderName)s made history visible to new members": "%(senderName)s made history visible to new members", + "You made history visible to anyone": "You made history visible to anyone", + "%(senderName)s made history visible to anyone": "%(senderName)s made history visible to anyone", + "You made history visible to future members": "You made history visible to future members", + "%(senderName)s made history visible to future members": "%(senderName)s made history visible to future members", + "You were invited": "You were invited", + "%(targetName)s was invited": "%(targetName)s was invited", + "You left": "You left", + "%(targetName)s left": "%(targetName)s left", + "You were kicked (%(reason)s)": "You were kicked (%(reason)s)", + "%(targetName)s was kicked (%(reason)s)": "%(targetName)s was kicked (%(reason)s)", + "You were kicked": "You were kicked", + "%(targetName)s was kicked": "%(targetName)s was kicked", + "You rejected the invite": "You rejected the invite", + "%(targetName)s rejected the invite": "%(targetName)s rejected the invite", + "You were uninvited": "You were uninvited", + "%(targetName)s was uninvited": "%(targetName)s was uninvited", + "You were banned (%(reason)s)": "You were banned (%(reason)s)", + "%(targetName)s was banned (%(reason)s)": "%(targetName)s was banned (%(reason)s)", + "You were banned": "You were banned", + "%(targetName)s was banned": "%(targetName)s was banned", + "You joined": "You joined", + "%(targetName)s joined": "%(targetName)s joined", + "You changed your name": "You changed your name", + "%(targetName)s changed their name": "%(targetName)s changed their name", + "You changed your avatar": "You changed your avatar", + "%(targetName)s changed their avatar": "%(targetName)s changed their avatar", + "%(senderName)s %(emote)s": "%(senderName)s %(emote)s", + "%(senderName)s: %(message)s": "%(senderName)s: %(message)s", + "You changed the room name": "You changed the room name", + "%(senderName)s changed the room name": "%(senderName)s changed the room name", + "%(senderName)s: %(reaction)s": "%(senderName)s: %(reaction)s", + "%(senderName)s: %(stickerName)s": "%(senderName)s: %(stickerName)s", + "You uninvited %(targetName)s": "You uninvited %(targetName)s", + "%(senderName)s uninvited %(targetName)s": "%(senderName)s uninvited %(targetName)s", + "You invited %(targetName)s": "You invited %(targetName)s", + "%(senderName)s invited %(targetName)s": "%(senderName)s invited %(targetName)s", + "You changed the room topic": "You changed the room topic", + "%(senderName)s changed the room topic": "%(senderName)s changed the room topic", + "New spinner design": "New spinner design", "Font scaling": "Font scaling", "Message Pinning": "Message Pinning", "Custom user status messages": "Custom user status messages", @@ -440,7 +494,7 @@ "Font size": "Font size", "Use custom size": "Use custom size", "Enable Emoji suggestions while typing": "Enable Emoji suggestions while typing", - "Use compact timeline layout": "Use compact timeline layout", + "Use a more compact ‘Modern’ layout": "Use a more compact ‘Modern’ layout", "Show a placeholder for removed messages": "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 avatar changes", @@ -460,6 +514,8 @@ "Mirror local video feed": "Mirror local video feed", "Enable Community Filter Panel": "Enable Community Filter Panel", "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", "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", @@ -1028,6 +1084,7 @@ "Encrypted by an unverified session": "Encrypted by an unverified session", "Unencrypted": "Unencrypted", "Encrypted by a deleted session": "Encrypted by a deleted session", + "The authenticity of this encrypted message can't be guaranteed on this device.": "The authenticity of this encrypted message can't be guaranteed on this device.", "Please select the destination room for this message": "Please select the destination room for this message", "Invite only": "Invite only", "Scroll to most recent messages": "Scroll to most recent messages", @@ -1161,9 +1218,11 @@ "%(count)s unread messages.|one": "1 unread message.", "Unread mentions.": "Unread mentions.", "Unread messages.": "Unread messages.", + "Use default": "Use default", + "All messages": "All messages", + "Mentions & Keywords": "Mentions & Keywords", + "Notification options": "Notification options", "Favourite": "Favourite", - "Low Priority": "Low Priority", - "Direct Chat": "Direct Chat", "Leave Room": "Leave Room", "Room options": "Room options", "Add a topic": "Add a topic", @@ -1371,6 +1430,7 @@ "reacted with %(shortName)s": "reacted with %(shortName)s", "Message deleted": "Message deleted", "Message deleted by %(name)s": "Message deleted by %(name)s", + "Message deleted on %(date)s": "Message deleted on %(date)s", "%(senderDisplayName)s changed the avatar for %(roomName)s": "%(senderDisplayName)s changed the avatar for %(roomName)s", "%(senderDisplayName)s removed the room avatar.": "%(senderDisplayName)s removed the room avatar.", "%(senderDisplayName)s changed the room avatar to ": "%(senderDisplayName)s changed the room avatar to ", @@ -1782,17 +1842,16 @@ "Remember my selection for this widget": "Remember my selection for this widget", "Allow": "Allow", "Deny": "Deny", - "Enter recovery passphrase": "Enter recovery passphrase", + "Wrong file type": "Wrong file type", + "Looks good!": "Looks good!", + "Wrong Recovery Key": "Wrong Recovery Key", + "Invalid Recovery Key": "Invalid Recovery Key", + "Security Phrase": "Security Phrase", "Unable to access secret storage. Please verify that you entered the correct recovery passphrase.": "Unable to access secret storage. Please verify that you entered the correct recovery passphrase.", - "Warning: You should only do this on a trusted computer.": "Warning: You should only do this on a trusted computer.", - "Access your secure message history and your cross-signing identity for verifying other sessions by entering your recovery passphrase.": "Access your secure message history and your cross-signing identity for verifying other sessions by entering your recovery passphrase.", - "If you've forgotten your recovery passphrase you can use your recovery key or set up new recovery options.": "If you've forgotten your recovery passphrase you can use your recovery key or set up new recovery options.", - "Enter recovery key": "Enter recovery key", - "Unable to access secret storage. Please verify that you entered the correct recovery key.": "Unable to access secret storage. Please verify that you entered the correct recovery key.", - "This looks like a valid recovery key!": "This looks like a valid recovery key!", - "Not a valid recovery key": "Not a valid recovery key", - "Access your secure message history and your cross-signing identity for verifying other sessions by entering your recovery key.": "Access your secure message history and your cross-signing identity for verifying other sessions by entering your recovery key.", - "If you've forgotten your recovery key you can .": "If you've forgotten your recovery key you can .", + "Enter your Security Phrase or to continue.": "Enter your Security Phrase or to continue.", + "Security Key": "Security Key", + "Use your Security Key to continue.": "Use your Security Key to continue.", + "Go Back": "Go Back", "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", @@ -1806,9 +1865,13 @@ "Keys restored": "Keys restored", "Failed to decrypt %(failedCount)s sessions!": "Failed to decrypt %(failedCount)s sessions!", "Successfully restored %(sessionCount)s keys": "Successfully restored %(sessionCount)s keys", + "Enter recovery passphrase": "Enter recovery passphrase", "Warning: you should only set up key backup from a trusted computer.": "Warning: you should only set up key backup from a trusted computer.", "Access your secure message history and set up secure messaging by entering your recovery passphrase.": "Access your secure message history and set up secure messaging by entering your recovery passphrase.", "If you've forgotten your recovery passphrase you can use your recovery key or set up new recovery options": "If you've forgotten your recovery passphrase you can use your recovery key or set up new recovery options", + "Enter recovery key": "Enter recovery key", + "This looks like a valid recovery key!": "This looks like a valid recovery key!", + "Not a valid recovery key": "Not a valid recovery key", "Warning: You should only set up key backup from a trusted computer.": "Warning: You should only set up key backup from a trusted computer.", "Access your secure message history and set up secure messaging by entering your recovery key.": "Access your secure message history and set up secure messaging by entering your recovery key.", "If you've forgotten your recovery key you can ": "If you've forgotten your recovery key you can ", @@ -1837,10 +1900,11 @@ "Failed to forget room %(errCode)s": "Failed to forget room %(errCode)s", "Notification settings": "Notification settings", "All messages (noisy)": "All messages (noisy)", - "All messages": "All messages", "Mentions only": "Mentions only", "Leave": "Leave", "Forget": "Forget", + "Low Priority": "Low Priority", + "Direct Chat": "Direct Chat", "Clear status": "Clear status", "Update status": "Update status", "Set status": "Set status", @@ -2056,7 +2120,6 @@ "Tried to load a specific point in this room's timeline, but you do not have permission to view the message in question.": "Tried to load a specific point in this room's timeline, but you do not have permission to view the message in question.", "Tried to load a specific point in this room's timeline, but was unable to find it.": "Tried to load a specific point in this room's timeline, but was unable to find it.", "Failed to load timeline position": "Failed to load timeline position", - "Guest": "Guest", "Your profile": "Your profile", "Uploading %(filename)s and %(count)s others|other": "Uploading %(filename)s and %(count)s others", "Uploading %(filename)s and %(count)s others|zero": "Uploading %(filename)s", @@ -2132,7 +2195,6 @@ "Your new session is now verified. It has access to your encrypted messages, and other users will see it as trusted.": "Your new session is now verified. It has access to your encrypted messages, and other users will see it as trusted.", "Your new session is now verified. Other users will see it as trusted.": "Your new session is now verified. Other users will see it as trusted.", "Without completing security on this session, it won’t have access to encrypted messages.": "Without completing security on this session, it won’t have access to encrypted messages.", - "Go Back": "Go Back", "Failed to re-authenticate due to a homeserver problem": "Failed to re-authenticate due to a homeserver problem", "Incorrect password": "Incorrect password", "Failed to re-authenticate": "Failed to re-authenticate", @@ -2171,48 +2233,56 @@ "Import": "Import", "Confirm encryption setup": "Confirm encryption setup", "Click the button below to confirm setting up encryption.": "Click the button below to confirm setting up encryption.", + "Safeguard against losing access to encrypted messages & data by backing up encryption keys on your server.": "Safeguard against losing access to encrypted messages & data by backing up encryption keys on your server.", + "Generate a Security Key": "Generate a Security Key", + "We’ll generate a Security Key for you to store somewhere safe, like a password manager or a safe.": "We’ll generate a Security Key for you to store somewhere safe, like a password manager or a safe.", + "Enter a Security Phrase": "Enter a Security Phrase", + "Use a secret phrase only you know, and optionally save a Security Key to use for backup.": "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 your key backup to upgrade your encryption", "Restore": "Restore", "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.", - "Set a recovery passphrase to secure encrypted information and recover it if you log out. This should be different to your account password:": "Set a recovery passphrase to secure encrypted information and recover it if you log out. This should be different to 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 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": "Enter a recovery passphrase", "Great! This recovery passphrase looks strong enough.": "Great! This recovery passphrase looks strong enough.", - "Back up encrypted message keys": "Back up encrypted message keys", - "Set up with a recovery key": "Set up with a recovery key", "That matches!": "That matches!", "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.", "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.", + "Download": "Download", + "Copy": "Copy", + "Unable to query secret storage status": "Unable to query secret storage status", + "Retry": "Retry", + "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.", + "You can also set up Secure Backup & manage your keys in Settings.": "You can also set up Secure Backup & manage your keys in Settings.", + "Set up Secure backup": "Set up Secure backup", + "Upgrade your encryption": "Upgrade your encryption", + "Set a Security Phrase": "Set a Security Phrase", + "Confirm Security Phrase": "Confirm Security Phrase", + "Save your Security Key": "Save your Security Key", + "Unable to set up secret storage": "Unable to set up secret storage", + "We'll store an encrypted copy of your keys on our server. Secure your backup with a recovery passphrase.": "We'll store an encrypted copy of your keys on our server. Secure your backup with a recovery passphrase.", + "For maximum security, this should be different from your account password.": "For maximum security, this should be different from your account password.", + "Set up with a recovery key": "Set up with a recovery key", + "Please enter your recovery passphrase a second time to confirm.": "Please enter your recovery passphrase a second time to confirm.", + "Repeat your recovery passphrase...": "Repeat your recovery passphrase...", "Your recovery key is a safety net - you can use it to restore access to your encrypted messages if you forget your recovery passphrase.": "Your recovery key is a safety net - you can use it to restore access to your encrypted messages if you forget your recovery passphrase.", "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 recovery key": "Your recovery key", - "Copy": "Copy", - "Download": "Download", "Your recovery key has been copied to your clipboard, paste it to:": "Your recovery key has been copied to your clipboard, paste it to:", "Your recovery key is in your Downloads folder.": "Your recovery key is in your Downloads folder.", "Print it and store it somewhere safe": "Print it and store it somewhere safe", "Save it on a USB key or backup drive": "Save it on a USB key or backup drive", "Copy it to your personal cloud storage": "Copy it to your personal cloud storage", - "Unable to query secret storage status": "Unable to query secret storage status", - "Retry": "Retry", - "You can now verify your other devices, and other users to keep your chats safe.": "You can now verify your other devices, and other users to keep your chats safe.", - "Upgrade your encryption": "Upgrade your encryption", - "Confirm recovery passphrase": "Confirm recovery passphrase", - "Make a copy of your recovery key": "Make a copy of your recovery key", - "You're done!": "You're done!", - "Unable to set up secret storage": "Unable to set up secret storage", - "We'll store an encrypted copy of your keys on our server. Secure your backup with a recovery passphrase.": "We'll store an encrypted copy of your keys on our server. Secure your backup with a recovery passphrase.", - "For maximum security, this should be different from your account password.": "For maximum security, this should be different from your account password.", - "Please enter your recovery passphrase a second time to confirm.": "Please enter your recovery passphrase a second time to confirm.", - "Repeat your recovery passphrase...": "Repeat your recovery passphrase...", "Your keys are being backed up (the first backup could take a few minutes).": "Your keys are being backed up (the first backup could take a few minutes).", "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.": "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.", "Set up Secure Message Recovery": "Set up Secure Message Recovery", "Secure your backup with a recovery passphrase": "Secure your backup with a recovery passphrase", + "Make a copy of your recovery key": "Make a copy of your recovery key", "Starting backup...": "Starting backup...", "Success!": "Success!", "Create key backup": "Create key backup", diff --git a/src/i18n/strings/es.json b/src/i18n/strings/es.json index 8004e5cd88..33443bd5bd 100644 --- a/src/i18n/strings/es.json +++ b/src/i18n/strings/es.json @@ -1179,7 +1179,7 @@ "Use a longer keyboard pattern with more turns": "Usa un patrón de tecleo largo con más vueltas", "Enable Community Filter Panel": "Habilitar el Panel de Filtro de Comunidad", "Verify this user by confirming the following emoji appear on their screen.": "Verifica este usuario confirmando que los siguientes emojis aparecen en su pantalla.", - "Your Riot is misconfigured": "Tu Riot está mal configurado", + "Your Riot is misconfigured": "Tu Riot tiene un error de configuración", "Whether or not you're logged in (we don't record your username)": "Hayas o no iniciado sesión (no guardamos tu nombre de usuario)", "Whether or not you're using the 'breadcrumbs' feature (avatars above the room list)": "Uses o no los 'breadcrumbs' (iconos sobre la lista de salas)", "A conference call could not be started because the integrations server is not available": "No se pudo iniciar la conferencia porque el servidor de integraciones no está disponible", @@ -1556,7 +1556,7 @@ "Sign In or Create Account": "Iniciar sesión o Crear una cuenta", "Use your account or create a new one to continue.": "Usa tu cuenta existente o crea una nueva para continuar.", "Create Account": "Crear cuenta", - "Sign In": "Registrarse", + "Sign In": "Iniciar sesión", "Sends a message as html, without interpreting it as markdown": "Envía un mensaje como html, sin interpretarlo en markdown", "Failed to set topic": "No se ha podido establecer el tema", "Command failed": "El comando falló", @@ -2191,5 +2191,44 @@ "This homeserver does not support login using email address.": "Este servidor doméstico no admite iniciar sesión con una dirección de correo electrónico.", "This account has been deactivated.": "Esta cuenta ha sido desactivada.", "Room name or address": "Nombre o dirección de la sala", - "Address (optional)": "Dirección (opcional)" + "Address (optional)": "Dirección (opcional)", + "Help us improve Riot": "Ayúdanos a mejorar Riot", + "Send anonymous usage data which helps us improve Riot. This will use a cookie.": "Enviar información anónima de uso nos ayudaría bastante a mejorar Riot. Esto cuenta como utilizar una cookie.", + "I want to help": "Quiero ayudar", + "Ok": "Ok", + "Set password": "Establecer contraseña", + "To return to your account in future you need to set a password": "Para poder regresar a tu cuenta en un futuro necesitas establecer una contraseña", + "Restart": "Reiniciar", + "Upgrade your Riot": "Actualiza tu Riot", + "A new version of Riot is available!": "¡Una nueva versión de Riot se encuentra disponible!", + "You joined the call": "Te has unido a la llamada", + "%(senderName)s joined the call": "%(senderName)s se ha unido a la llamada", + "Call in progress": "Llamada en progreso", + "You left the call": "Has abandonado la llamada", + "%(senderName)s left the call": "%(senderName)s dejo la llamada", + "Call ended": "La llamada ha finalizado", + "You started a call": "Has iniciado una llamada", + "%(senderName)s started a call": "%(senderName)s inicio una llamada", + "Waiting for answer": "Esperado por una respuesta", + "%(senderName)s is calling": "%(senderName)s está llamando", + "%(senderName)s created the room": "%(senderName)s creo la sala", + "You were invited": "Has sido invitado", + "%(targetName)s was invited": "%(targetName)s ha sido invitado", + "%(targetName)s left": "%(targetName)s se ha ido", + "You were kicked (%(reason)s)": "Has sido expulsado por %(reason)s", + "You rejected the invite": "Has rechazado la invitación", + "%(targetName)s rejected the invite": "%(targetName)s rechazo la invitación", + "You were banned (%(reason)s)": "Has sido baneado por %(reason)s", + "%(targetName)s was banned (%(reason)s)": "%(targetName)s fue baneado por %(reason)s", + "You were banned": "Has sido baneado", + "%(targetName)s was banned": "%(targetName)s fue baneado", + "You joined": "Te has unido", + "%(targetName)s joined": "%(targetName)s se ha unido", + "You changed your name": "Has cambiado tu nombre", + "%(targetName)s changed their name": "%(targetName)s cambio su nombre", + "You changed your avatar": "Ha cambiado su avatar", + "%(targetName)s changed their avatar": "%(targetName)s ha cambiado su avatar", + "You changed the room name": "Has cambiado el nombre de la sala", + "%(senderName)s changed the room name": "%(senderName)s cambio el nombre de la sala", + "You invited %(targetName)s": "Has invitado a %(targetName)s" } diff --git a/src/i18n/strings/et.json b/src/i18n/strings/et.json index 70eec98ec5..7afcbcaf58 100644 --- a/src/i18n/strings/et.json +++ b/src/i18n/strings/et.json @@ -1592,5 +1592,257 @@ "We encountered an error trying to restore your previous session.": "Meil tekkis eelmise sessiooni taastamisel viga.", "If you have previously used a more recent version of Riot, your session may be incompatible with this version. Close this window and return to the more recent version.": "Kui sa varem oled kasutanud uuemat Riot'i versiooni, siis sinu pragune sessioon ei pruugi olla sellega ühilduv. Sulge see aken ja jätka selle uuema versiooni kasutamist.", "Clearing your browser's storage may fix the problem, but will sign you out and cause any encrypted chat history to become unreadable.": "Brauseri andmeruumi tühjendamine võib selle vea lahendada, kui samas logid sa ka välja ning kogu krüptitud vestlusajalugu muutub loetamatuks.", - "Verification Pending": "Verifikatsioon on ootel" + "Verification Pending": "Verifikatsioon on ootel", + "Back": "Tagasi", + "Send Custom Event": "Saada kohandatud sündmus", + "You must specify an event type!": "Sa pead määratlema sündmuse tüübi!", + "Event sent!": "Sündmus on saadetud!", + "Failed to send custom event.": "Kohandatud sündmuse saatmine ei õnnestunud.", + "Event Type": "Sündmuse tüüp", + "State Key": "Oleku võti", + "Event Content": "Sündmuse sisu", + "Send Account Data": "Saada kasutajakonto andmed", + "View Servers in Room": "Näita jututoas kasutatavaid servereid", + "Verification Requests": "Verifitseerimistaotlused", + "Toolbox": "Töövahendid", + "Developer Tools": "Arendusvahendid", + "An error has occurred.": "Tekkis viga.", + "Verify this user to mark them as trusted. Trusting users gives you extra peace of mind when using end-to-end encrypted messages.": "Selle kasutaja usaldamiseks peaksid ta verifitseerima. Kui sa pruugid läbivalt krüptitud sõnumeid, siis kasutajate verifitseerimine tagab sulle täiendava meelerahu.", + "Verifying this user will mark their session as trusted, and also mark your session as trusted to them.": "Selle kasutaja verifitseerimisel märgitakse tema sessioon usaldusväärseks ning samuti märgitakse sinu sessioon tema jaoks usaldusväärseks.", + "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.": "Selle seadme usaldamiseks peaksid ta verifitseerima. Kui sa pruugid läbivalt krüptitud sõnumeid, siis selle seadme usaldamine tagab sulle ja teistele kasutajatele täiendava meelerahu.", + "Verifying this device will mark it as trusted, and users who have verified with you will trust this device.": "Selle seadme verifitseerimisel märgitakse ta usaldusväärseks ning kõik kasutajad, kes sinuga on verifitseerimise läbi teinud, loevad ka selle seadme usaldusväärseks.", + "Waiting for partner to confirm...": "Ootan teise osapoole kinnitust...", + "Incoming Verification Request": "Saabuv verifitseerimispalve", + "Integrations are disabled": "Lõimingud ei ole kasutusel", + "Enable 'Manage Integrations' in Settings to do this.": "Selle tegevuse jaoks määra seadetes \"Halda lõiminguid\" kasutuselevõetuks.", + "Integrations not allowed": "Lõimingute kasutamine ei ole lubatud", + "Your Riot doesn't allow you to use an Integration Manager to do this. Please contact an admin.": "Sinu Riot ei võimalda selle tegevuse jaoks kasutada Lõimingute haldurit. Palun küsi lisateavet administraatorilt.", + "Failed to invite the following users to chat: %(csvUsers)s": "Järgnevate kasutajate vestlema kutsumine ei õnnestunud: %(csvUsers)s", + "We couldn't create your DM. Please check the users you want to invite and try again.": "Otsevestluse loomine ei õnnestunud. Palun kontrolli, et kasutajanimed oleks õiged ja proovi uuesti.", + "a new master key signature": "uus üldvõtme allkiri", + "a new cross-signing key signature": "uus risttunnustamise võtme allkiri", + "a device cross-signing signature": "seadme risttunnustamise allkiri", + "a key signature": "võtme allkiri", + "Riot encountered an error during upload of:": "Riot'is tekkis viga järgneva üleslaadimisel:", + "Cancelled signature upload": "Allkirja üleslaadimine on tühistatud", + "Unable to upload": "Üleslaadimine ei õnnestu", + "Signature upload success": "Allkirja üleslaadimine õnnestus", + "Signature upload failed": "Allkirja üleslaadimine ei õnnestunud", + "Address (optional)": "Aadress (valikuline)", + "Reject invitation": "Lükka kutse tagasi", + "Are you sure you want to reject the invitation?": "Kas sa oled kindel, et soovid lükata kutse tagasi?", + "Failed to set Direct Message status of room": "Jututoa otsevestluse oleku seadmine ei õnnestunud", + "Failed to forget room %(errCode)s": "Jututoa unustamine ei õnnestunud %(errCode)s", + "This homeserver would like to make sure you are not a robot.": "See server soovib kindlaks teha, et sa ei ole robot.", + "Country Dropdown": "Riikide valik", + "You can use the custom server options to sign into other Matrix servers by specifying a different homeserver URL. This allows you to use this app with an existing Matrix account on a different homeserver.": "Sa võid kasutada serveri kohandatud valikuid selleks, et määrates teise aadressi logida sisse teise Matrix'i serverisse. See võimaldab sul kasutada seda rakendust teises koduserveris hallatava olemasoleva Matrix'i kontoga.", + "Confirm your identity by entering your account password below.": "Tuvasta oma isik sisestades salasõna alljärgnevalt.", + "Please review and accept all of the homeserver's policies": "Palun vaata üle kõik koduserveri kasutustingimused ja nõustu nendega", + "Please review and accept the policies of this homeserver:": "Palun vaata üle selle koduserveri kasutustingimused ja nõustu nendega:", + "An email has been sent to %(emailAddress)s": "Saatsime e-kirja %(emailAddress)s aadressile", + "Please check your email to continue registration.": "Registreerimise jätkamiseks, palun vaata oma e-kirjad.", + "A text message has been sent to %(msisdn)s": "Saatsime tekstisõnumi telefoninumbrile %(msisdn)s", + "Please enter the code it contains:": "Palun sisesta seal kuvatud kood:", + "Code": "Kood", + "Submit": "Saada", + "Start authentication": "Alusta autentimist", + "Unable to validate homeserver/identity server": "Ei õnnestu valideerida koduserverit/isikutuvastusserverit", + "Your Modular server": "Sinu Modular-server", + "Enter the location of your Modular homeserver. It may use your own domain name or be a subdomain of modular.im.": "Sisesta oma Modular'i koduserveri aadress. See võib kasutada nii sinu oma domeeni, kui olla modular.im alamdomeen.", + "Sign in with SSO": "Logi sisse kasutades SSO'd ehk ühekordset autentimist", + "Failed to reject invitation": "Kutse tagasi lükkamine ei õnnestunud", + "This room is not public. You will not be able to rejoin without an invite.": "See ei ole avalik jututuba. Ilma kutseta sa ei saa uuesti liituda.", + "Failed to leave room": "Jututoast lahkumine ei õnnestunud", + "Can't leave Server Notices room": "Serveriteadete jututoast ei saa lahkuda", + "This room is used for important messages from the Homeserver, so you cannot leave it.": "Seda jututuba kasutatakse sinu koduserveri oluliste teadete jaoks ja seega sa ei saa sealt lahkuda.", + "Signed Out": "Välja logitud", + "A username can only contain lower case letters, numbers and '=_-./'": "Kasutajanimes võivad olla vaid väiketähed, numbrid ja need viis tähemärki =_-./", + "Username not available": "Selline kasutajanimi ei ole saadaval", + "Username invalid: %(errMessage)s": "Vigane kasutajanimi: %(errMessage)s", + "An error occurred: %(error_string)s": "Tekkis viga: %(error_string)s", + "Checking...": "Kontrollin...", + "Username available": "Kasutajanimi on saadaval", + "To get started, please pick a username!": "Alustamiseks palun vali kasutajanimi!", + "This will be your account name on the homeserver, or you can pick a different server.": "See saab olema sinu kasutajanimi koduserveris, aga sa võid valida ka mõne teise serveri.", + "If you already have a Matrix account you can log in instead.": "Kui sul juba on olemas Matrix'i konto, siis võid lihtsalt sisse logida.", + "You have successfully set a password!": "Salasõna loomine õnnestus!", + "You have successfully set a password and an email address!": "Salasõna loomine ja e-posti aadressi salvestamine õnnestus!", + "You can now return to your account after signing out, and sign in on other devices.": "Nüüd sa saad peale väljalogimist pöörduda tagasi oma konto juurde või logida sisse muudest seadmetest.", + "Remember, you can always set an email address in user settings if you change your mind.": "Jäta meelde, et sa saad alati hiljem määrata kasutajaseadetest oma e-posti aadressi.", + "Use bots, bridges, widgets and sticker packs": "Kasuta roboteid, võrgusildu, vidinaid või kleepsupakke", + "Upload all": "Lae kõik üles", + "This file is too large to upload. The file size limit is %(limit)s but this file is %(sizeOfThisFile)s.": "See fail on üleslaadimiseks liiga suur. Üleslaetavate failide mahupiir on %(limit)s, kuid selle faili suurus on %(sizeOfThisFile)s.", + "Appearance": "Välimus", + "Enter recovery passphrase": "Sisesta taastamise paroolifraas", + "For security, this session has been signed out. Please sign in again.": "Turvalisusega seotud põhjustel on see sessioon välja logitud. Palun logi uuesti sisse.", + "Review terms and conditions": "Vaata üle kasutustingimused", + "Old cryptography data detected": "Tuvastasin andmed, mille puhul on kasutatud vanemat tüüpi krüptimist", + "Self-verification request": "Päring enda verifitseerimiseks", + "Switch to light mode": "Kasuta heledat teemat", + "Switch to dark mode": "Kasuta tumedat teemat", + "Switch theme": "Vaheta teemat", + "Security & privacy": "Turvalisus ja privaatsus", + "All settings": "Kõik seadistused", + "Archived rooms": "Arhiveeritud jututoad", + "Feedback": "Tagasiside", + "Account settings": "Kasutajakonto seadistused", + "Use Single Sign On to continue": "Jätkamiseks kasuta ühekordset sisselogimist", + "Confirm adding this email address by using Single Sign On to prove your identity.": "Kinnita selle e-posti aadressi lisamine kasutades ühekordset sisselogimist oma isiku tuvastamiseks.", + "Single Sign On": "SSO Ühekordne sisselogimine", + "Confirm adding email": "Kinnita e-posti aadressi lisamine", + "Click the button below to confirm adding this email address.": "Klõpsi järgnevat nuppu e-posti aadressi lisamise kinnitamiseks.", + "Confirm adding this phone number by using Single Sign On to prove your identity.": "Kinnita selle telefoninumbri lisamine kasutades ühekordset sisselogimist oma isiku tuvastamiseks.", + "Confirm adding phone number": "Kinnita telefoninumbri lisamine", + "Click the button below to confirm adding this phone number.": "Klõpsi järgnevat nuppu telefoninumbri lisamise kinnitamiseks.", + "Add Phone Number": "Lisa telefoninumber", + "Default": "Tavakasutaja", + "Restricted": "Piiratud õigustega kasutaja", + "Moderator": "Moderaator", + "Admin": "Peakasutaja", + "Custom (%(level)s)": "Kohandatud õigused (%(level)s)", + "Failed to invite": "Kutse saatmine ei õnnestunud", + "Operation failed": "Toiming ei õnnestunud", + "Failed to invite users to the room:": "Kasutajate kutsumine jututuppa ei õnnestunud:", + "Failed to invite the following users to the %(roomName)s room:": "Järgnevate kasutajate kutsumine %(roomName)s jututuppa ei õnnestunud:", + "You need to be logged in.": "Sa peaksid olema sisse loginud.", + "You need to be able to invite users to do that.": "Selle tegevuse jaoks peaks sul olema õigus teistele kasutajatele kutse saatmiseks.", + "Unable to create widget.": "Vidina loomine ei õnnestunud.", + "Missing roomId.": "Jututoa tunnus ehk roomId on puudu.", + "Failed to send request.": "Päringu saatmine ei õnnestunud.", + "This room is not recognised.": "Seda jututuba ei õnnestu ära tunda.", + "Power level must be positive integer.": "Õiguste tase peab olema positiivne täisarv.", + "You are not in this room.": "Sa ei asu selles jututoas.", + "You do not have permission to do that in this room.": "Sinul pole selle toimingu jaoks selles jututoas õigusi.", + "Missing room_id in request": "Päringus puudub jututoa tunnus ehk room_id", + "Room %(roomId)s not visible": "Jututuba %(roomId)s ei ole nähtav", + "Missing user_id in request": "Päringus puudub kasutaja tunnus ehk user_id", + "Messages": "Sõnumid", + "Actions": "Tegevused", + "Other": "Muud", + "Usage": "Kasutus", + "Prepends ¯\\_(ツ)_/¯ to a plain-text message": "Lisa ¯\\_(ツ)_/¯ smaili vormindamata teksti algusesse", + "Sends a message as plain text, without interpreting it as markdown": "Saadab sõnumi vormindamata tekstina ega tõlgenda seda markdown-vormindusena", + "Sends a message as html, without interpreting it as markdown": "Saadab sõnumi html'ina ega tõlgenda seda markdown-vormindusena", + "/ddg is not a command": "/ddg ei ole käsk", + "You joined the call": "Sina liitusid kõnega", + "%(senderName)s joined the call": "%(senderName)s liitus kõnega", + "Call in progress": "Kõne on pooleli", + "You left the call": "Sa lahkusid kõnest", + "%(senderName)s left the call": "%(senderName)s lahkus kõnest", + "Call ended": "Kõne lõppes", + "You started a call": "Sa alustasid kõnet", + "%(senderName)s started a call": "%(senderName)s alustas kõnet", + "Waiting for answer": "Ootan kõnele vastamist", + "%(senderName)s is calling": "%(senderName)s helistab", + "You created the room": "Sa lõid jututoa", + "%(senderName)s created the room": "%(senderName)s lõi jututoa", + "You made the chat encrypted": "Sina võtsid vestlusel kasutuse krüptimise", + "%(senderName)s made the chat encrypted": "%(senderName)s võttis vestlusel kasutuse krüptimise", + "You made history visible to new members": "Sina tegid jututoa ajaloo loetavaks uuetele liikmetele", + "%(senderName)s made history visible to new members": "%(senderName)s tegi jututoa ajaloo loetavaks uuetele liikmetele", + "You made history visible to anyone": "Sina tegi jututoa ajaloo loetavaks kõikidele", + "%(senderName)s made history visible to anyone": "%(senderName)s tegi jututoa ajaloo loetavaks kõikidele", + "You made history visible to future members": "Sina tegid jututoa ajaloo loetavaks tulevastele liikmetele", + "%(senderName)s made history visible to future members": "%(senderName)s tegi jututoa ajaloo loetavaks tulevastele liikmetele", + "You were invited": "Sina said kutse", + "%(targetName)s was invited": "%(targetName)s sai kutse", + "You left": "Sina lahkusid", + "%(targetName)s left": "%(targetName)s lahkus", + "You were kicked (%(reason)s)": "Sind müksati jututoast välja (%(reason)s)", + "%(targetName)s was kicked (%(reason)s)": "%(targetName)s müksati jututoast välja (%(reason)s)", + "You were kicked": "Sind müksati jututoast välja", + "%(targetName)s was kicked": "%(targetName)s müksati jututoast välja", + "You rejected the invite": "Sa lükkasid kutse tagasi", + "%(targetName)s rejected the invite": "%(targetName)s lükkas kutse tagasi", + "You were uninvited": "Sinult võeti kutse tagasi", + "%(targetName)s was uninvited": "%(targetName)s'lt võeti kutse tagasi", + "You joined": "Sina liitusid", + "%(targetName)s joined": "%(targetName)s liitus", + "You changed your name": "Sa muutsid oma nime", + "%(targetName)s changed their name": "%(targetName)s muutis oma nime", + "You changed your avatar": "Sa muutsid oma tunnuspilti", + "%(targetName)s changed their avatar": "%(targetName)s muutis oma tunnuspilti", + "%(senderName)s %(emote)s": "%(senderName)s %(emote)s", + "%(senderName)s: %(message)s": "%(senderName)s: %(message)s", + "You changed the room name": "Sina muutsid jututoa nime", + "%(senderName)s changed the room name": "%(senderName)s muutis jututoa nime", + "%(senderName)s: %(reaction)s": "%(senderName)s: %(reaction)s", + "%(senderName)s: %(stickerName)s": "%(senderName)s: %(stickerName)s", + "You uninvited %(targetName)s": "Sina võtsid tagasi kutse kasutajalt %(targetName)s", + "%(senderName)s uninvited %(targetName)s": "%(senderName)s võttis kutse tagasi kasutajalt %(targetName)s", + "You invited %(targetName)s": "Sina kutsusid kasutajat %(targetName)s", + "%(senderName)s invited %(targetName)s": "%(senderName)s kutsus kasutajat %(targetName)s", + "You changed the room topic": "Sina muutsid jututoa teemat", + "%(senderName)s changed the room topic": "%(senderName)s muutis jututoa teemat", + "Use the improved room list (will refresh to apply changes)": "Kasuta parandaatud jututubade loendit (muudatuse jõustamine eeldab andmete uuesti laadimist)", + "Enable IRC layout option in the appearance tab": "Näita välimuse seadistustes IRC-tüüpi paigutuse valikut", + "Use custom size": "Kasuta kohandatud suurust", + "Use a more compact ‘Modern’ layout": "Kasuta veel kompaktsemat \"moodsat\" paigutust", + "Cross-signing public keys:": "Avalikud võtmed risttunnustamise jaoks:", + "in memory": "on mälus", + "not found": "pole leitavad", + "Delete %(count)s sessions|other": "Kustuta %(count)s sessiooni", + "Delete %(count)s sessions|one": "Kustuta %(count)s sessioon", + "ID": "Kasutaja ID", + "Public Name": "Avalik nimi", + "Last seen": "Viimati nähtud", + "Manage": "Halda", + "Enable": "Võta kasutusele", + "Error saving email notification preferences": "E-posti teel saadetavate teavituste eelistuste salvestamisel tekkis viga", + "An error occurred whilst saving your email notification preferences.": "E-posti teel saadetavate teavituste eelistuste salvestamisel tekkis viga.", + "Keywords": "Märksõnad", + "Enter keywords separated by a comma:": "Sisesta märksõnad ja kasuta eraldajaks koma:", + "Failed to change settings": "Seadistuste muutmine ei õnnestunud", + "Can't update user notification settings": "Kasutaja teavistuste eelistusi ei õnnestunud uuendada", + "Failed to update keywords": "Märksõnade uuendamine ei õnnestunud", + "Messages containing keywords": "Sõnumid, mis sisaldavad märksõnu", + "Notify me for anything else": "Teavita mind kõigest muust", + "Enable notifications for this account": "Võta sellel kasutajakontol kasutusele teavitused", + "Clear notifications": "Eemalda kõik teavitused", + "All notifications are currently disabled for all targets.": "Kõik teavituste liigid on välja lülitatud.", + "Add an email address to configure email notifications": "E-posti teel saadetavate teavituste seadistamiseks lisa e-posti aadress", + "Enable email notifications": "Võta kasutusele e-posti teel saadetavad teavitused", + "Notifications on the following keywords follow rules which can’t be displayed here:": "Alljärgnevate märksõnadega seotud teavitused järgivad reegleid, misa siin ei saa kuvada:", + "Disinvite this user from community?": "Kas võtame sellelt kasutajalt tagasi kutse kogukonnaga liitumiseks?", + "Failed to withdraw invitation": "Kutse tühistamine ei õnnestunud", + "%(role)s in %(roomName)s": "%(role)s jututoas %(roomName)s", + "Failed to change power level": "Õiguste muutmine ei õnnestunud", + "You will not be able to undo this change as you are promoting the user to have the same power level as yourself.": "Sa ei saa seda muudatust hiljem tagasi pöörata, sest annad teisele kasutajale samad õigused, mis sinul on.", + "Deactivate user?": "Kas blokeerime kasutaja?", + "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?": "Kasutaja blokeerimisel logitakse ta automaatselt välja ning ei lubata enam sisse logida. Lisaks lahkub ta kõikidest jututubadest, mille liige ta parasjagu on. Seda tegevust ei saa tagasi pöörata. Kas sa oled ikka kindel, et soovid selle kasutaja blokeerida?", + "Deactivate user": "Blokeeri kasutaja", + "Failed to deactivate user": "Kasutaja blokeerimine ei õnnestunud", + "This client does not support end-to-end encryption.": "See klient ei toeta läbivat krüptimist.", + "Security": "Turvalisus", + "Using this widget may share data with %(widgetDomain)s & your Integration Manager.": "Selle vidina kasutamisel võidakse jagada andmeid saitidega %(widgetDomain)s ning sinu vidinahalduriga.", + "Using this widget may share data with %(widgetDomain)s.": "Selle vidina kasutamisel võidakse jagada andmeid saitidega %(widgetDomain)s.", + "Widgets do not use message encryption.": "Erinevalt sõnumitest vidinad ei kasuta krüptimist.", + "Widget added by": "Vidina lisaja", + "This widget may use cookies.": "See vidin võib kasutada küpsiseid.", + "Delete Widget": "Kustuta vidin", + "Deleting a widget removes it for all users in this room. Are you sure you want to delete this widget?": "Vidina kustutamisel eemaldatakse ta kõikide selle jututoa kasutajate jaoks. Kas sa kindlasti soovid seda vidinat eemaldada?", + "Delete widget": "Kustuta vidin", + "Minimize apps": "Vähenda rakendused", + "Maximize apps": "Suurenda rakendused", + "Popout widget": "Ava rakendus eraldi aknas", + "More options": "Täiendavad seadistused", + "Language Dropdown": "Keelevalik", + "Manage Integrations": "Halda lõiminguid", + "%(nameList)s %(transitionList)s": "%(nameList)s %(transitionList)s", + "%(severalUsers)sjoined %(count)s times|other": "%(severalUsers)s liitusid %(count)s korda", + "%(severalUsers)sjoined %(count)s times|one": "%(severalUsers)s liitusid", + "%(oneUser)sjoined %(count)s times|other": "%(oneUser)s liitus %(count)s korda", + "%(oneUser)sjoined %(count)s times|one": "%(oneUser)s liitus", + "%(severalUsers)sleft %(count)s times|other": "%(severalUsers)s lahkusid %(count)s korda", + "%(severalUsers)sleft %(count)s times|one": "%(severalUsers)s lahkusid", + "%(oneUser)sleft %(count)s times|other": "%(oneUser)s lahkus %(count)s korda", + "%(oneUser)sleft %(count)s times|one": "%(oneUser)s lahkus", + "%(severalUsers)sjoined and left %(count)s times|other": "%(severalUsers)s liitusid ja lahkusid %(count)s korda", + "%(severalUsers)sjoined and left %(count)s times|one": "%(severalUsers)s liitusid ja lahkusid", + "%(oneUser)sjoined and left %(count)s times|other": "%(oneUser)s liitus ja lahkus %(count)s korda", + "%(oneUser)sjoined and left %(count)s times|one": "%(oneUser)s liitus ja lahkus", + "%(severalUsers)sleft and rejoined %(count)s times|other": "%(severalUsers)s lahkusid ja liitusid uuesti %(count)s korda", + "%(severalUsers)sleft and rejoined %(count)s times|one": "%(severalUsers)s lahkusid ja liitusid uuesti", + "%(oneUser)sleft and rejoined %(count)s times|other": "%(oneUser)s lahkus ja liitus uuesti %(count)s korda", + "%(oneUser)sleft and rejoined %(count)s times|one": "%(oneUser)s lahkus ja liitus uuesti" } diff --git a/src/i18n/strings/fi.json b/src/i18n/strings/fi.json index 1157daa260..b10366bbc9 100644 --- a/src/i18n/strings/fi.json +++ b/src/i18n/strings/fi.json @@ -2367,5 +2367,55 @@ "Use Recovery Key or Passphrase": "Käytä palautusavainta tai salalausetta", "Use Recovery Key": "Käytä palautusavainta", "Create a Recovery Key": "Luo palautusavain", - "Upgrade your Recovery Key": "Päivitä palautusavaimesi" + "Upgrade your Recovery Key": "Päivitä palautusavaimesi", + "You joined the call": "Liityit puheluun", + "%(senderName)s joined the call": "%(senderName)s liittyi puheluun", + "Call in progress": "Puhelu käynnissä", + "You left the call": "Poistuit puhelusta", + "%(senderName)s left the call": "%(senderName)s poistui puhelusta", + "Call ended": "Puhelu päättyi", + "You started a call": "Aloitit puhelun", + "%(senderName)s started a call": "%(senderName)s aloitti puhelun", + "Waiting for answer": "Odotetaan vastausta", + "%(senderName)s is calling": "%(senderName)s soittaa", + "You created the room": "Loit huoneen", + "%(senderName)s created the room": "%(senderName)s loi huoneen", + "You made the chat encrypted": "Otit salauksen käyttöön keskustelussa", + "%(senderName)s made the chat encrypted": "%(senderName)s otti salauksen käyttöön keskustelussa", + "You made history visible to new members": "Teit historiasta näkyvän uusille jäsenille", + "%(senderName)s made history visible to new members": "%(senderName)s teki historiasta näkyvän uusille jäsenille", + "You made history visible to anyone": "Teit historiasta näkyvän kaikille", + "%(senderName)s made history visible to anyone": "%(senderName)s teki historiasta näkyvän kaikille", + "You made history visible to future members": "Teit historiasta näkyvän tuleville jäsenille", + "%(senderName)s made history visible to future members": "%(senderName)s teki historiasta näkyvän tuleville jäsenille", + "You were invited": "Sinut kutsuttiin", + "%(targetName)s was invited": "%(targetName)s kutsuttiin", + "You left": "Poistuit", + "%(targetName)s left": "%(targetName)s poistui", + "You rejected the invite": "Hylkäsit kutsun", + "%(targetName)s rejected the invite": "%(targetName)s hylkäsi kutsun", + "You were banned (%(reason)s)": "Sait porttikiellon (%(reason)s)", + "%(targetName)s was banned (%(reason)s)": "%(targetName)s sai porttikiellon (%(reason)s)", + "You were banned": "Sait porttikiellon", + "%(targetName)s was banned": "%(targetName)s sai porttikiellon", + "You joined": "Liityit", + "%(targetName)s joined": "%(targetName)s liittyi", + "You changed your name": "Vaihdoit nimeäsi", + "%(targetName)s changed their name": "%(targetName)s vaihtoi nimeään", + "%(senderName)s: %(message)s": "%(senderName)s: %(message)s", + "You changed the room name": "Vaihdoit huoneen nimeä", + "%(senderName)s changed the room name": "%(senderName)s vaihtoi huoneen nimeä", + "%(senderName)s: %(reaction)s": "%(senderName)s: %(reaction)s", + "%(senderName)s: %(stickerName)s": "%(senderName)s: %(stickerName)s", + "You invited %(targetName)s": "Kutsuit käyttäjän %(targetName)s", + "%(senderName)s invited %(targetName)s": "%(senderName)s kutsui käyttäjän %(targetName)s", + "You changed the room topic": "Vaihdoit huoneen aiheen", + "%(senderName)s changed the room topic": "%(senderName)s vaihtoi huoneen aiheen", + "Use custom size": "Käytä mukautettua kokoa", + "Use a more compact ‘Modern’ layout": "Käytä tiiviimpää 'modernia' asettelua", + "Use a system font": "Käytä järjestelmän fonttia", + "System font name": "Järjestelmän fontin nimi", + "Message layout": "Viestiasettelu", + "Compact": "Tiivis", + "Modern": "Moderni" } diff --git a/src/i18n/strings/fr.json b/src/i18n/strings/fr.json index 1651164a69..3beb6ea836 100644 --- a/src/i18n/strings/fr.json +++ b/src/i18n/strings/fr.json @@ -2528,5 +2528,91 @@ "Upgrade your Recovery Key": "Mettre à jour votre clé de récupération", "Store your Recovery Key": "Stocker votre clé de récupération", "Use the improved room list (in development - will refresh to apply changes)": "Utiliser la liste de salons améliorée (en développement − actualisera pour appliquer les changements)", - "Use the improved room list (will refresh to apply changes)": "Utiliser la liste de salons améliorée (actualisera pour appliquer les changements)" + "Use the improved room list (will refresh to apply changes)": "Utiliser la liste de salons améliorée (actualisera pour appliquer les changements)", + "Enable IRC layout option in the appearance tab": "Activer l’option de mise en page IRC dans l’onglet d’apparence", + "Use custom size": "Utiliser une taille personnalisée", + "Hey you. You're the best!": "Eh vous. Vous êtes les meilleurs !", + "Message layout": "Mise en page des messages", + "Compact": "Compacte", + "Modern": "Moderne", + "Use a system font": "Utiliser une police du système", + "System font name": "Nom de la police du système", + "The authenticity of this encrypted message can't be guaranteed on this device.": "L’authenticité de ce message chiffré ne peut pas être garantie sur cet appareil.", + "Use a more compact ‘Modern’ layout": "Utiliser une mise en page « moderne » plus compacte", + "You joined the call": "Vous avez rejoint l’appel", + "%(senderName)s joined the call": "%(senderName)s a rejoint l’appel", + "Call in progress": "Appel en cours", + "You left the call": "Vous avez quitté l’appel", + "%(senderName)s left the call": "%(senderName)s a quitté l’appel", + "Call ended": "Appel terminé", + "You started a call": "Vous avez démarré un appel", + "%(senderName)s started a call": "%(senderName)s a démarré un appel", + "Waiting for answer": "En attente d’une réponse", + "%(senderName)s is calling": "%(senderName)s appelle", + "You created the room": "Vous avez créé le salon", + "%(senderName)s created the room": "%(senderName)s a créé le salon", + "You made the chat encrypted": "Vous avez activé le chiffrement de la discussion", + "%(senderName)s made the chat encrypted": "%(senderName)s a activé le chiffrement de la discussion", + "You made history visible to new members": "Vous avez rendu l’historique visible aux nouveaux membres", + "%(senderName)s made history visible to new members": "%(senderName)s a rendu l’historique visible aux nouveaux membres", + "You made history visible to anyone": "Vous avez rendu l’historique visible à tout le monde", + "%(senderName)s made history visible to anyone": "%(senderName)s a rendu l’historique visible à tout le monde", + "You made history visible to future members": "Vous avez rendu l’historique visible aux futurs membres", + "%(senderName)s made history visible to future members": "%(senderName)s a rendu l’historique visible aux futurs membres", + "You were invited": "Vous avez été invité", + "%(targetName)s was invited": "%(targetName)s a été invité·e", + "You left": "Vous êtes parti·e", + "%(targetName)s left": "%(targetName)s est parti·e", + "You were kicked (%(reason)s)": "Vous avez été expulsé·e (%(reason)s)", + "%(targetName)s was kicked (%(reason)s)": "%(targetName)s a été expulsé·e (%(reason)s)", + "You were kicked": "Vous avez été expulsé·e", + "%(targetName)s was kicked": "%(targetName)s a été expulsé·e", + "You rejected the invite": "Vous avez rejeté l’invitation", + "%(targetName)s rejected the invite": "%(targetName)s a rejeté l’invitation", + "You were uninvited": "Votre invitation a été révoquée", + "%(targetName)s was uninvited": "L’invitation de %(targetName)s a été révoquée", + "You were banned (%(reason)s)": "Vous avez été banni·e (%(reason)s)", + "%(targetName)s was banned (%(reason)s)": "%(targetName)s a été banni·e (%(reason)s)", + "You were banned": "Vous avez été banni·e", + "%(targetName)s was banned": "%(targetName)s a été banni·e", + "You joined": "Vous avez rejoint le salon", + "%(targetName)s joined": "%(targetName)s a rejoint le salon", + "You changed your name": "Vous avez changé votre nom", + "%(targetName)s changed their name": "%(targetName)s a changé son nom", + "You changed your avatar": "Vous avez changé votre avatar", + "%(targetName)s changed their avatar": "%(targetName)s a changé son avatar", + "%(senderName)s %(emote)s": "%(senderName)s %(emote)s", + "%(senderName)s: %(message)s": "%(senderName)s : %(message)s", + "You changed the room name": "Vous avez changé le nom du salon", + "%(senderName)s changed the room name": "%(senderName)s a changé le nom du salon", + "%(senderName)s: %(reaction)s": "%(senderName)s : %(reaction)s", + "%(senderName)s: %(stickerName)s": "%(senderName)s : %(stickerName)s", + "You uninvited %(targetName)s": "Vous avez révoqué l’invitation de %(targetName)s", + "%(senderName)s uninvited %(targetName)s": "%(senderName)s a révoqué l’invitation de %(targetName)s", + "You invited %(targetName)s": "Vous avez invité %(targetName)s", + "%(senderName)s invited %(targetName)s": "%(senderName)s a invité %(targetName)s", + "You changed the room topic": "Vous avez changé le sujet du salon", + "%(senderName)s changed the room topic": "%(senderName)s a changé le sujet du salon", + "New spinner design": "Nouveau design du spinner", + "Message deleted on %(date)s": "Message supprimé le %(date)s", + "Wrong file type": "Mauvais type de fichier", + "Wrong Recovery Key": "Mauvaise clé de récupération", + "Invalid Recovery Key": "Clé de récupération non valide", + "Security Phrase": "Phrase de sécurité", + "Enter your Security Phrase or to continue.": "Saisissez votre phrase de sécurité ou pour continuer.", + "Security Key": "Clé de sécurité", + "Use your Security Key to continue.": "Utilisez votre clé de sécurité pour continuer.", + "Safeguard against losing access to encrypted messages & data by backing up encryption keys on your server.": "Protection afin d’éviter de perdre l’accès aux messages et données chiffrés en sauvegardant les clés de chiffrement sur votre serveur.", + "Generate a Security Key": "Générer une clé de sécurité", + "We’ll generate a Security Key for you to store somewhere safe, like a password manager or a safe.": "Nous génèrerons une clé de sécurité que vous devrez stocker dans un endroit sûr, comme un gestionnaire de mots de passe ou un coffre.", + "Enter a Security Phrase": "Saisir une phrase de sécurité", + "Use a secret phrase only you know, and optionally save a Security Key to use for backup.": "Utilisez une phrase secrète que vous êtes seul·e à connaître et enregistrez éventuellement une clé de sécurité à utiliser pour la sauvegarde.", + "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.": "Saisissez une phrase de sécurité que vous seul·e connaissez, car elle est utilisée pour protéger vos données. Pour plus de sécurité, vous ne devriez pas réutiliser le mot de passe de votre compte.", + "Store your Security Key somewhere safe, like a password manager or a safe, as it’s used to safeguard your encrypted data.": "Stockez votre clé de sécurité dans un endroit sûr, comme un gestionnaire de mots de passe ou un coffre, car elle est utilisée pour protéger vos données chiffrées.", + "If you cancel now, you may lose encrypted messages & data if you lose access to your logins.": "Si vous annulez maintenant, vous pourriez perdre vos messages et données chiffrés si vous perdez l’accès à vos identifiants.", + "You can also set up Secure Backup & manage your keys in Settings.": "Vous pouvez aussi configurer la sauvegarde sécurisée et gérer vos clés depuis les paramètres.", + "Set up Secure backup": "Configurer la sauvegarde sécurisée", + "Set a Security Phrase": "Définir une phrase de sécurité", + "Confirm Security Phrase": "Confirmer la phrase de sécurité", + "Save your Security Key": "Sauvegarder votre clé de sécurité" } diff --git a/src/i18n/strings/gl.json b/src/i18n/strings/gl.json index 9bd477f696..00ef29f9fd 100644 --- a/src/i18n/strings/gl.json +++ b/src/i18n/strings/gl.json @@ -1,14 +1,14 @@ { - "This email address is already in use": "Xa se está a usar este correo", + "This email address is already in use": "Xa se está a usar este email", "This phone number is already in use": "Xa se está a usar este teléfono", "Failed to verify email address: make sure you clicked the link in the email": "Fallo na verificación do enderezo de correo: asegúrese de ter picado na ligazón do correo", - "The remote side failed to pick up": "O interlocutor non respondeu", + "The remote side failed to pick up": "O correspondente non respondeu", "Unable to capture screen": "Non se puido capturar a pantalla", "Existing Call": "Rexistro de chamadas", - "You are already in a call.": "Xa está nunha chamada.", + "You are already in a call.": "Xa estás nunha chamada.", "VoIP is unsupported": "Sen soporte para VoIP", - "You cannot place VoIP calls in this browser.": "Non pode establecer chamadas VoIP neste navegador.", - "You cannot place a call with yourself.": "Non pode facer unha chamada a si mesmo.", + "You cannot place VoIP calls in this browser.": "Non poden establecer chamadas VoIP neste navegador.", + "You cannot place a call with yourself.": "Non podes facer unha chamada a ti mesma.", "Warning!": "Aviso!", "Call Failed": "Fallou a chamada", "Review Devices": "Revisar dispositivos", @@ -51,9 +51,9 @@ "Add rooms to the community": "Engadir salas á comunidade", "Room name or alias": "Nome da sala ou alcume", "Add to community": "Engadir á comunidade", - "Failed to invite the following users to %(groupId)s:": "Fallo ao convidar os seguintes usuarios a %(groupId)s:", - "Failed to invite users to community": "Houbo un fallo convidando usuarios á comunidade", - "Failed to invite users to %(groupId)s": "Houbo un fallo convidando usuarios a %(groupId)s", + "Failed to invite the following users to %(groupId)s:": "Fallo ao convidar ás seguintes usuarias a %(groupId)s:", + "Failed to invite users to community": "Houbo un fallo convidando usuarias á comunidade", + "Failed to invite users to %(groupId)s": "Houbo un fallo convidando usuarias a %(groupId)s", "Failed to add the following rooms to %(groupId)s:": "Fallo ao engadir as seguintes salas a %(groupId)s:", "Riot does not have permission to send you notifications - please check your browser settings": "Riot non ten permiso para enviarlle notificacións: comprobe os axustes do navegador", "Riot was not given permission to send notifications - please try again": "Riot non ten permiso para enviar notificacións: inténteo de novo", @@ -67,7 +67,7 @@ "Start a chat": "Iniciar unha conversa", "Operation failed": "Fallou a operación", "Failed to invite": "Fallou o convite", - "Failed to invite the following users to the %(roomName)s room:": "Houbo un fallo convidando os seguintes usuarios á sala %(roomName)s:", + "Failed to invite the following users to the %(roomName)s room:": "Houbo un fallo convidando as seguintes usuarias á sala %(roomName)s:", "You need to be logged in.": "Precisa estar conectada.", "You need to be able to invite users to do that.": "Precisa autorización para convidar a outros usuarias para poder facer iso.", "Unable to create widget.": "Non se puido crear o trebello.", @@ -85,7 +85,7 @@ "Unrecognised room alias:": "Alcumes de sala non recoñecidos:", "Ignored user": "Usuaria ignorada", "You are now ignoring %(userId)s": "Agora está a ignorar %(userId)s", - "Unignored user": "Usuarios non ignorados", + "Unignored user": "Usuarias non ignoradas", "You are no longer ignoring %(userId)s": "Xa non está a ignorar a %(userId)s", "Verified key": "Chave verificada", "Reason": "Razón", @@ -147,7 +147,7 @@ "Enable automatic language detection for syntax highlighting": "Activar a detección automática de idioma para o resalte da sintaxe", "Automatically replace plain text Emoji": "Substituír automaticamente Emoji en texto plano", "Enable inline URL previews by default": "Activar por defecto as vistas previas en liña de URL", - "Enable URL previews for this room (only affects you)": "Activar avista previa de URL nesta sala (só lle afecta a vostede)", + "Enable URL previews for this room (only affects you)": "Activar avista previa de URL nesta sala (só che afesta a ti)", "Enable URL previews by default for participants in this room": "Activar a vista previa de URL por defecto para as participantes nesta sala", "Room Colour": "Cor da sala", "Active call (%(roomName)s)": "Chamada activa (%(roomName)s)", @@ -200,15 +200,15 @@ "device id: ": "id dispositivo: ", "Disinvite": "Retirar convite", "Kick": "Expulsar", - "Disinvite this user?": "Retirar convite a este usuario?", - "Kick this user?": "Expulsar este usuario?", + "Disinvite this user?": "Retirar convite a esta usuaria?", + "Kick this user?": "Expulsar esta usuaria?", "Failed to kick": "Fallo ao expulsar", "Unban": "Non bloquear", "Ban": "Bloquear", - "Unban this user?": "Non bloquear este usuario?", - "Ban this user?": "Bloquear a este usuario?", - "Failed to ban user": "Fallo ao bloquear usuario", - "Failed to mute user": "Fallo ao acalar usuario", + "Unban this user?": "¿Non bloquear esta usuaria?", + "Ban this user?": "¿Bloquear a esta usuaria?", + "Failed to ban user": "Fallo ao bloquear usuaria", + "Failed to mute user": "Fallo ó silenciar usuaria", "Failed to toggle moderator status": "Fallo ao mudar a estado de moderador", "Failed to change power level": "Fallo ao cambiar o nivel de permisos", "Are you sure?": "Está segura?", @@ -288,9 +288,9 @@ "Banned by %(displayName)s": "Non aceptado por %(displayName)s", "unknown error code": "código de fallo descoñecido", "Failed to forget room %(errCode)s": "Fallo ao esquecer sala %(errCode)s", - "Privileged Users": "Usuarios con privilexios", - "No users have specific privileges in this room": "Non hai usuarios con privilexios específicos nesta sala", - "Banned users": "Usuarios excluídos", + "Privileged Users": "Usuarias con privilexios", + "No users have specific privileges in this room": "Non hai usuarias con privilexios específicos nesta sala", + "Banned users": "Usuarias excluídas", "This room is not accessible by remote Matrix servers": "Esta sala non é accesible por servidores Matrix remotos", "Leave room": "Deixar a sala", "Favourite": "Favorita", @@ -378,9 +378,9 @@ "You're not currently a member of any communities.": "Ate o momento non é membro de ningunha comunidade.", "Unknown Address": "Enderezo descoñecido", "Allow": "Permitir", - "Delete Widget": "Eliminar trebello", - "Deleting a widget removes it for all users in this room. Are you sure you want to delete this widget?": "Quitando un trebello elimínao para todas os usuarios desta sala. Está seguro de querer eliminar este trebello?", - "Delete widget": "Eliminar trebello", + "Delete Widget": "Eliminar widget", + "Deleting a widget removes it for all users in this room. Are you sure you want to delete this widget?": "Quitando un trebello elimínalo para todas as usuarias desta sala. ¿tes certeza de querer eliminar este widget?", + "Delete widget": "Eliminar widget", "Minimize apps": "Minimizar apps", "Edit": "Editar", "Create new room": "Crear unha nova sala", @@ -459,7 +459,7 @@ "Try using one of the following valid address types: %(validTypesList)s.": "Intentar utilizar algún dos seguintes tipos de enderezo válidos: %(validTypesList)s.", "You have entered an invalid address.": "Introduciu un enderezo non válido.", "Confirm Removal": "Confirme a retirada", - "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.": "Está certa de que quere quitar (eliminar) este evento? Saiba que si elimina un nome de sala ou cambia o asunto, podería desfacer o cambio.", + "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.": "Estás certa de que queres quitar (eliminar) este evento? Debes saber que se eliminas un nome de sala ou cambias o asunto, poderías desfacer o cambio.", "Community IDs may only contain characters a-z, 0-9, or '=_-./'": "Os ID de comunidade só poden conter caracteres a-z, 0-9, or '=_-./'", "Community IDs cannot be empty.": "O ID de comunidade non pode quedar baldeiro.", "Something went wrong whilst creating your community": "Algo fallou mentres se creaba a súa comunidade", @@ -481,7 +481,7 @@ "Ignore request": "Ignorar petición", "Encryption key request": "Petición de chave de cifrado", "Unable to restore session": "Non se puido restaurar a sesión", - "If you have previously used a more recent version of Riot, your session may be incompatible with this version. Close this window and return to the more recent version.": "Si anteriormente utilizou unha versión máis recente de Riot, a súa sesión podería non ser compatible con esta versión. Peche esta ventá e volva a versión máis recente.", + "If you have previously used a more recent version of Riot, your session may be incompatible with this version. Close this window and return to the more recent version.": "Se anteriormente utilizaches unha versión máis recente de Riot, a túa sesión podería non ser compatible con esta versión. Pecha esta ventá e volve á versión máis recente.", "Invalid Email Address": "Enderezo de correo non válido", "This doesn't appear to be a valid email address": "Este non semella ser un enderezo de correo válido", "Verification Pending": "Verificación pendente", @@ -513,9 +513,9 @@ "Add a Room": "Engadir unha sala", "Failed to remove the room from the summary of %(groupId)s": "Algo fallou ao quitar a sala do resumo de %(groupId)s", "The room '%(roomName)s' could not be removed from the summary.": "A sala '%(roomName)s' non se puido eliminar do resumo.", - "Add users to the community summary": "Engadir usuarios ao resumo da comunidade", + "Add users to the community summary": "Engadir usuarias ó resumo da comunidade", "Who would you like to add to this summary?": "A quen desexa engadir a este resumo?", - "Failed to add the following users to the summary of %(groupId)s:": "Algo fallou ao engadir aos seguintes usuarios ao resumo de %(groupId)s:", + "Failed to add the following users to the summary of %(groupId)s:": "Algo fallou ó engadir ás seguintes usuarias ó resumo de %(groupId)s:", "Add a User": "Engadir unha usuaria", "Failed to remove a user from the summary of %(groupId)s": "Algo fallou ao eliminar a usuaria do resumo de %(groupId)s", "The user '%(displayName)s' could not be removed from the summary.": "A usuaria '%(displayName)s' non se puido eliminar do resumo.", @@ -530,9 +530,9 @@ "These rooms are displayed to community members on the community page. Community members can join the rooms by clicking on them.": "Estas salas móstranse aos membros da comunidade na páxina da comunidade. Os participantes da comunidade poden unirse ás salas premendo nelas.", "Add rooms to this community": "Engadir salas a esta comunidade", "Featured Rooms:": "Salas destacadas:", - "Featured Users:": "Usuarios destacados:", + "Featured Users:": "Usuarias destacadas:", "%(inviter)s has invited you to join this community": "%(inviter)s convidoute a entrar nesta comunidade", - "You are an administrator of this community": "Vostede administra esta comunidade", + "You are an administrator of this community": "Administras esta comunidade", "You are a member of this community": "É membro desta comunidade", "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!": "A súa comunidade non ten unha descrición longa, ou unha páxina HTML que lle mostrar aos seus participantes.
    Pulse aquí para abrir os axustes e publicar unha!", "Long Description (HTML)": "Descrición longa (HTML)", @@ -592,7 +592,7 @@ "Import E2E room keys": "Importar chaves E2E da sala", "Cryptography": "Criptografía", "Analytics": "Analytics", - "Riot collects anonymous analytics to allow us to improve the application.": "Riot recolle información analítica anónima para permitirnos mellorar o aplicativo.", + "Riot collects anonymous analytics to allow us to improve the application.": "Riot recolle información analítica anónima para permitirnos mellorar a aplicación.", "Privacy is important to us, so we don't collect any personal or identifiable data for our analytics.": "A intimidade impórtanos, así que non recollemos información personal ou identificable nos datos dos nosos análises.", "Learn more about how we use analytics.": "Saber máis sobre como utilizamos analytics.", "Labs": "Labs", @@ -651,7 +651,7 @@ "Emoji": "Emoji", "Notify the whole room": "Notificar a toda a sala", "Room Notification": "Notificación da sala", - "Users": "Usuarios", + "Users": "Usuarias", "unknown device": "dispositivo descoñecido", "NOT verified": "Non validado", "verified": "validado", @@ -676,12 +676,12 @@ "Confirm passphrase": "Confirme a frase de paso", "Export": "Exportar", "Import room keys": "Importar chaves de sala", - "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.": "Este proceso permítelle importar chaves de cifrado que vostede exportou de outro cliente Matrix. Así poderá descifrar calquera mensaxe que o outro cliente puidese cifrar.", + "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.": "Este proceso permíteche importar chaves de cifrado que exportaches doutro cliente Matrix. Así poderás descifrar calquera mensaxe que o outro cliente puidese cifrar.", "The export file will be protected with a passphrase. You should enter the passphrase here, to decrypt the file.": "O ficheiro exportado estará protexido con unha frase de paso. Debe introducir aquí esa frase de paso para descifrar o ficheiro.", "File to import": "Ficheiro a importar", "Import": "Importar", "The information being sent to us to help make Riot.im better includes:": "A información enviada a Riot.im para axudarnos a mellorar inclúe:", - "Where this page includes identifiable information, such as a room, user or group ID, that data is removed before being sent to the server.": "Se esta páxina inclúe información identificable como ID de grupo, usuario ou sala, estes datos son eliminados antes de ser enviados ao servidor.", + "Where this page includes identifiable information, such as a room, user or group ID, that data is removed before being sent to the server.": "Se esta páxina inclúe información identificable como ID de grupo, usuaria ou sala, estes datos son eliminados antes de ser enviados ó servidor.", "The platform you're on": "A plataforma na que está", "The version of Riot.im": "A versión de Riot.im", "Your language of choice": "A súa preferencia de idioma", @@ -703,12 +703,12 @@ "Display your community flair in rooms configured to show it.": "Mostrar a popularidade da túa comunidade nas salas configuradas para que a mostren.", "Did you know: you can use communities to filter your Riot.im experience!": "Sabías que podes usar as comunidades para filtrar a túa experiencia en Riot.im!", "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.": "Para establecer un filtro, arrastra un avatar da comunidade sobre o panel de filtros na parte esquerda da pantalla. Podes premer nun avatar no panel de filtrado en calquera momento para ver só salas e xente asociada a esa comunidade.", - "Deops user with given id": "Degradar o usuario con esa ID", + "Deops user with given id": "Degradar á usuaria con ese ID", "Seen by %(displayName)s (%(userName)s) at %(dateTime)s": "Visto por %(displayName)s(%(userName)s en %(dateTime)s", "Code": "Código", "Unable to join community": "Non te puideches unir a comunidade", "Unable to leave community": "Non se puido deixar a comunidade", - "Changes made to your community name and avatar might not be seen by other users for up to 30 minutes.": "Os cambios realizados a súa comunidade name e avatar poida que non os vexan outros usuarios ate dentro de 30 minutos.", + "Changes made to your community name and avatar might not be seen by other users for up to 30 minutes.": "Os cambios realizados á túa comunidade nome e avatar poida que non os vexan outras usuarias ate dentro de 30 minutos.", "Join this community": "Únete a esta comunidade", "Leave this community": "Deixar esta comunidade", "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.": "Se enviaches un informe de fallo a través de GitHub, os informes poden axudarnos a examinar o problema. Os informes de fallo conteñen datos do uso da aplicación incluíndo o teu nome de usuaria, os IDs ou alcumes das salas e grupos que visitaches e os nomes de usuaria de outras persoas. Non conteñen mensaxes.", @@ -778,7 +778,7 @@ "Riot uses many advanced browser features, some of which are not available or experimental in your current browser.": "Riot utiliza características avanzadas do navegador, algunhas das cales non están dispoñibles ou son experimentais no seu navegador actual.", "Developer Tools": "Ferramentas para desenvolver", "Preparing to send logs": "Preparándose para enviar informe", - "Remember, you can always set an email address in user settings if you change your mind.": "Lembre que sempre poderá poñer un enderezo de correo nos axustes de usuario se cambiase de idea.", + "Remember, you can always set an email address in user settings if you change your mind.": "Lembra que sempre poderás poñer un enderezo de email nos axustes de usuaria se cambiases de idea.", "Explore Account Data": "Ollar datos da conta", "All messages (noisy)": "Todas as mensaxes (alto)", "Saturday": "Sábado", @@ -819,7 +819,7 @@ "Back": "Atrás", "Reply": "Resposta", "Show message in desktop notification": "Mostrar mensaxe nas notificacións de escritorio", - "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.": "Os informes de depuración conteñen datos de utilización do aplicativo como o seu nome de usuario, os IDs ou alcumes de salas e grupos que vostede visitou e os nomes de usuarios doutras usuarias. Non conteñen mensaxes.", + "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.": "Os informes de depuración conteñen datos de utilización da aplicación como o teu nome de usuaria, os IDs ou alias de salas e grupos que visitachese os nomes de usuaria doutras usuarias. Non conteñen mensaxes.", "Unhide Preview": "Desagochar a vista previa", "Unable to join network": "Non se puido conectar ca rede", "You might have configured them in a client other than Riot. You cannot tune them in Riot but they still apply": "Pode que os configurases nun cliente diferente de Riot. Non podes establecelos desde Riot pero aínda así aplicaranse", @@ -845,7 +845,7 @@ "View Source": "Ver fonte", "Event Content": "Contido do evento", "Thank you!": "Grazas!", - "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!": "Co seu navegador actual a aparencia e uso do aplicativo poderían estar totalmente falseadas, e algunhas características poderían non funcionar. Se quere pode continuar, pero debe ser consciente de que poden haber fallos!", + "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!": "Co teu navegador actual a aparencia e uso da aplicación poderían estar totalmente falseadas, e algunhas características poderían non funcionar. Se queres podes continuar, pero debes ser consciente de que pode haber fallos!", "Checking for an update...": "Comprobando as actualizacións...", "There are advanced notifications which are not shown here": "Existen notificacións avanzadas que non se mostran aquí", "Every page you use in the app": "Cada páxina que use na aplicación", @@ -867,18 +867,18 @@ "Enable widget screenshots on supported widgets": "Activar as capturas de trebellos para aqueles que as permiten", "Share Link to User": "Compartir a ligazón coa usuaria", "Share room": "Compartir sala", - "Muted Users": "Usuarios silenciados", + "Muted Users": "Usuarias silenciadas", "Please help improve Riot.im by sending anonymous usage data. This will use a cookie (please see our Cookie Policy).": "Axuda a mellorar Riot.im enviando os datos anónimos de uso. Usaremos unha cookie (le aquí a nosa Política de Cookies).", "Please help improve Riot.im by sending anonymous usage data. This will use a cookie.": "Axuda a mellorar Riot.im enviando datos anónimos de uso. Esto usará unha cookie.", "Yes, I want to help!": "Si, quero axudar!", - "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.": "Iso fará que a súa deixe de ter uso de xeito permanente. Non poderá acceder e ninguén vai a poder volver a rexistrar esa mesma ID de usuario. Suporá que saía de todas as salas de conversas nas que estaba e eliminará os detalles da súa conta do servidores de identificación.Isto non se poderá desfacer", + "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.": "Iso fará que a túa deixe de ter uso de xeito permanente. Non poderás acceder e ninguén vai a poder volver a rexistrar esa mesma ID de usuaria. Suporá que sairás de todalas salas de conversas nas que estabas e eliminarás os detalles da túa conta do servidores de identidade. Esta acción non ten volta", "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.": "Desactivando a súa conta non supón que por defecto esquezamos as súas mensaxes enviadas. Se quere que nos esquezamos das súas mensaxes, prema na caixa de embaixo.", "To continue, please enter your password:": "Para continuar introduza o seu contrasinal:", - "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.": "A visibilidade das mensaxes en Matrix é parecida ás dos correos electrónicos. Que esquezamos as súas mensaxes significa que as súas mensaxes non se van a compartir con ningún novo membro ou usuario que non estea rexistrado. Mais aqueles usuarios que xa tiveron acceso a estas mensaxes si que seguirán tendo acceso as súas propias copias desas mensaxes.", + "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.": "A visibilidade das mensaxes en Matrix é parecida á dos correos electrónicos. Que esquezamos as túas mensaxes significa que as mensaxes non se van a compartir con ningún novo membro ou usuaria que non estea rexistrada. Mais aqueles usuarias que xa tiveron acceso a estas mensaxes si que seguirán tendo acceso as súas propias copias desas mensaxes.", "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)": "Esquezan todas as mensaxes que eu enviara no momento en que elimine a miña conta. (Aviso: iso suporá que os seguintes participantes só verán unha versión incompleta das conversas.)", "Share Room": "Compartir sala", "Link to most recent message": "Ligazón ás mensaxes máis recentes", - "Share User": "Compartir usuario", + "Share User": "Compartir usuaria", "Share Community": "Compartir comunidade", "Share Room Message": "Compartir unha mensaxe da sala", "Link to selected message": "Ligazón á mensaxe escollida", @@ -893,10 +893,10 @@ "Audio Output": "Saída de audio", "Call in Progress": "Chamada en progreso", "A call is already in progress!": "Xa hai unha chamada en progreso!", - "Permission Required": "Precísase de permisos", - "You do not have permission to start a conference call in this room": "Non ten permisos para comezar unha chamada de conferencia nesta sala", + "Permission Required": "Precísanse permisos", + "You do not have permission to start a conference call in this room": "Non tes permisos para comezar unha chamada de conferencia nesta sala", "This event could not be displayed": "Non se puido amosar este evento", - "Demote yourself?": "Baixarse a si mesmo de rango?", + "Demote yourself?": "Baixarse a ti mesma de rango?", "Demote": "Baixar de rango", "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.": "Nas salas cifradas, como é esta, está desactivado por defecto a previsualización das URL co fin de asegurarse de que o servidor local (que é onde se gardan as previsualizacións) non poida recoller información sobre das ligazóns que se ven nesta sala.", "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.": "Cando alguén pon unha URL na mensaxe, esta previsualízarase para que así se coñezan xa cousas delas como o título, a descrición ou as imaxes que inclúe ese sitio web.", @@ -963,7 +963,7 @@ "Sign in and regain access to your account.": "Conéctate e recupera o acceso a túa conta.", "You cannot sign in to your account. Please contact your homeserver admin for more information.": "Non podes conectar a conta. Contacta coa administración do teu servidor para máis información.", "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.": "Aviso: os teus datos personais (incluíndo chaves de cifrado) aínda están gardadas nesta sesión. Pechaa se remataches de usar esta sesión, ou se quere conectar con outra conta.", - "Unable to load! Check your network connectivity and try again.": "Non cargou! Comproba a conexión a rede e volta a intentalo.", + "Unable to load! Check your network connectivity and try again.": "Non cargou! Comproba a conexión á rede e volta a intentalo.", "There are unknown sessions in this room: if you proceed without verifying them, it will be possible for someone to eavesdrop on your call.": "Hai sesións descoñecidas nesta sala: se continúas sen verificalas será posible para alguén fisgar na túa chamada.", "Review Sessions": "Revisar Sesións", "Call failed due to misconfigured server": "Fallou a chamada porque o servidor está mal configurado", @@ -1962,5 +1962,489 @@ "View Servers in Room": "Ver Servidores na Sala", "Verification Requests": "Solicitudes de Verificación", "Verify this user to mark them as trusted. Trusting users gives you extra peace of mind when using end-to-end encrypted messages.": "Verifica esta usuaria para marcala como confiable. Ao confiar nas usuarias proporcionache tranquilidade extra cando usas cifrado de extremo-a-extremo.", - "Verifying this user will mark their session as trusted, and also mark your session as trusted to them.": "Ao verificar esta usuaria marcarás a súa sesión como confiable, e tamén marcará a túa sesión como confiable para elas." + "Verifying this user will mark their session as trusted, and also mark your session as trusted to them.": "Ao verificar esta usuaria marcarás a súa sesión como confiable, e tamén marcará a túa sesión como confiable para elas.", + "Enable IRC layout option in the appearance tab": "Activar opción de disposición IRC na pestana de aparencia", + "Use custom size": "Usar tamaño personalizado", + "Use a system font": "Usar tipo de letra do sistema", + "System font name": "Nome da letra do sistema", + "Hey you. You're the best!": "Ei ti. Es grande!", + "Message layout": "Disposición da mensaxe", + "Compact": "Compacta", + "Modern": "Moderna", + "Power level": "Poderío", + "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.": "Verifica este dispositivo para marcalo como confiable. Confiando neste dispositivo permite que ti e outras usuarias estedes máis tranquilas ao utilizar mensaxes cifradas.", + "Verifying this device will mark it as trusted, and users who have verified with you will trust this device.": "Ao verificar este dispositivo marcaralo como confiable, e as usuarias que confiaron en ti tamén confiarán nel.", + "Waiting for partner to confirm...": "Agardando a que o compañeiro confirme...", + "Incoming Verification Request": "Solicitude entrante de verificación", + "Integrations are disabled": "As Integracións están desactivadas", + "Enable 'Manage Integrations' in Settings to do this.": "Activa 'Xestionar Integracións' nos Axustes para facer esto.", + "Integrations not allowed": "Non se permiten Integracións", + "Your Riot doesn't allow you to use an Integration Manager to do this. Please contact an admin.": "O teu Riot non permite que uses o Xestor de Integracións, contacta coa administración.", + "Confirm to continue": "Confirma para continuar", + "Click the button below to confirm your identity.": "Preme no botón inferior para confirmar a túa identidade.", + "Failed to invite the following users to chat: %(csvUsers)s": "Fallo ao convidar as seguintes usuarias a conversa: %(csvUsers)s", + "We couldn't create your DM. Please check the users you want to invite and try again.": "Non puidemos crear o teu MD. Comproba as usuarias que queres convidar e inténtao outra vez.", + "Something went wrong trying to invite the users.": "Algo fallou ao convidar as usuarias.", + "We couldn't invite those users. Please check the users you want to invite and try again.": "Non puidemos invitar esas usuarias. Comprobas que son correctas e intenta convidalas outra vez.", + "Failed to find the following users": "Non atopamos as seguintes usuarias", + "The following users might not exist or are invalid, and cannot be invited: %(csvNames)s": "As seguintes usuarias poderían non existir ou non son válidas, e non se poden convidar: %(csvNames)s", + "Recent Conversations": "Conversas recentes", + "Suggestions": "Suxestións", + "Recently Direct Messaged": "Mensaxes Directas recentes", + "Start a conversation with someone using their name, username (like ) or email address.": "Inicia a conversa con alguén usando o seu nome, nome de usuaria (como ) ou enderezo de email.", + "Go": "Ir", + "Invite someone using their name, username (like ), email address or share this room.": "Convida alguén usando o seu nome, nome de usuaria (como ), enderezo de email ou comparte esta sala.", + "a new master key signature": "unha nova firma con chave mestra", + "a new cross-signing key signature": "unha nova firma con chave de sinatura-cruzada", + "a device cross-signing signature": "unha sinatura sinatura-cruzada de dispositivo", + "a key signature": "unha chave de sinatura", + "Riot encountered an error during upload of:": "Riot atopou un fallo ao subir:", + "Upload completed": "Subida completa", + "Cancelled signature upload": "Cancelada a subida da sinatura", + "Unable to upload": "Non foi posible a subida", + "The authenticity of this encrypted message can't be guaranteed on this device.": "A autenticidade desta mensaxe cifrada non está garantida neste dispositivo.", + "Signature upload success": "Subeuse correctamente a sinatura", + "Signature upload failed": "Fallou a subida da sinatura", + "You've previously used Riot 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, Riot needs to resync your account.": "Anteriormente utilizaches Riot en %(host)s con carga preguiceira de membros. Nesta versión a carga preguiceira está desactivada. Como a caché local non é compatible entre as dúas configuracións, Riot precisa voltar a sincronizar a conta.", + "If the other version of Riot is still open in another tab, please close it as using Riot on the same host with both lazy loading enabled and disabled simultaneously will cause issues.": "Se a outra versión de Riot aínda está aberta noutra lapela, péchaa por favor, pois podería haber fallos ao estar as dúas sesións traballando simultáneamente.", + "Incompatible local cache": "Caché local incompatible", + "Clear cache and resync": "Baleirar caché e sincronizar", + "Riot now uses 3-5x less memory, by only loading information about other users when needed. Please wait whilst we resynchronise with the server!": "Riot utiliza agora entre 3 e 5 veces menos memoria, cargando só información sobre as usuarias cando é preciso. Agarda mentras se sincroniza co servidor!", + "Updating Riot": "Actualizando Riot", + "I don't want my encrypted messages": "Non quero as miñas mensaxes cifradas", + "Manually export keys": "Exportar manualmente as chaves", + "You'll lose access to your encrypted messages": "Perderás o acceso as túas mensaxes cifradas", + "Confirm by comparing the following with the User Settings in your other session:": "Corfirma comparando o seguinte cos Axustes de Usuaria na outra sesión:", + "Confirm this user's session by comparing the following with their User Settings:": "Confirma a sesión desta usuaria comparando o seguinte cos seus Axustes de Usuaria:", + "Session name": "Nome da sesión", + "Session key": "Chave da sesión", + "If they don't match, the security of your communication may be compromised.": "Se non concordan, a seguridade da comunicación podería estar comprometida.", + "Verify session": "Verificar sesión", + "Your homeserver doesn't seem to support this feature.": "O servidor non semella soportar esta característica.", + "Guest": "Convidada", + "Message edits": "Edicións da mensaxe", + "Your account is not secure": "A túa conta non é segura", + "Your password": "O teu contrasinal", + "This session, or the other session": "Esta sesión, ou a outra sesión", + "The internet connection either session is using": "A conexión a internet que está a usar cada sesión", + "We recommend you change your password and recovery key in Settings immediately": "Recomendámosche cambiar inmediatamente o contrasinal e chave de recuperación nos Axustes", + "New session": "Nova sesión", + "Use this session to verify your new one, granting it access to encrypted messages:": "Usa esta seseión para verificar a nova, dándolle acceso ás mensaxes cifradas:", + "This wasn't me": "Non fun eu", + "If you run into any bugs or have feedback you'd like to share, please let us know on GitHub.": "Se atopas fallos ou queres compartir a túa experiencia, compárteos con nós en GitHub.", + "Report bugs & give feedback": "Informe de fallos & opinión", + "Please fill why you're reporting.": "Escribe a razón do informe.", + "Report Content to Your Homeserver Administrator": "Denuncia sobre contido á Administración do teu servidor", + "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.": "Ao denunciar esta mensaxe vasnos enviar o seu 'event ID' único á administración do servidor. Se as mensaxes da sala están cifradas, a administración do servidor non poderá ler o texto da mensaxe ou ver imaxes ou ficheiros.", + "Send report": "Enviar denuncia", + "Failed to upgrade room": "Fallou a actualización da sala", + "The room upgrade could not be completed": "A actualización da sala non se completou", + "Upgrade this room to version %(version)s": "Actualiza esta sala á versión %(version)s", + "Upgrade Room Version": "Actualiza a Versión da Sala", + "Upgrading this room requires closing down the current instance of the room and creating a new room in its place. To give room members the best possible experience, we will:": "Para actualizar a sala debes pechar a instancia actual da sala e crear unha nova sala no seu lugar. Para proporcionar a mellor experiencia de usuaria, imos:", + "Create a new room with the same name, description and avatar": "Crear unha nova sala co mesmo nome, descrición e avatar", + "Update any local room aliases to point to the new room": "Actualizar calquera alias local da sala para que apunte á nova sala", + "Stop users from speaking in the old version of the room, and post a message advising users to move to the new room": "Evitar que as usuarias conversen na sala antiga e publicar unha mensaxe avisando ás usuarias para que veñan á nova sala", + "Put a link back to the old room at the start of the new room so people can see old messages": "Poñer unha ligazón na nova sala cara a antiga para que as persoas poidan ver as mensaxes antigas", + "Automatically invite users": "Convidar automáticamente ás usuarias", + "Upgrade private room": "Actualizar sala privada", + "Upgrade public room": "Actualizar sala pública", + "Upgrading a room is an advanced action and is usually recommended when a room is unstable due to bugs, missing features or security vulnerabilities.": "A actualización da sala é unha acción avanzada e recomendada cando unha sala se volta inestable debido aos fallos, características obsoletas e vulnerabilidades da seguridade.", + "This usually only affects how the room is processed on the server. If you're having problems with your Riot, please report a bug.": "Esto normalmente só afecta ao xeito en que a sala se procesa no servidor. Se tes problemas con Riot, informa do problema.", + "You'll upgrade this room from to .": "Vas actualizar a sala da versión á .", + "A username can only contain lower case letters, numbers and '=_-./'": "Un nome de usuaria só pode ter minúsculas, números e '=_-./'", + "Checking...": "Comprobando...", + "Missing session data": "Faltan datos da sesión", + "Some session data, including encrypted message keys, is missing. Sign out and sign in to fix this, restoring keys from backup.": "Faltan algúns datos da sesión, incluíndo chaves de mensaxes cifradas. Desconecta e volve a conectar para arranxalo, restaurando as chaves desde a copia.", + "Your browser likely removed this data when running low on disk space.": "O navegador probablemente eliminou estos datos ao quedar con pouco espazo de disco.", + "Integration Manager": "Xestor de Integracións", + "Find others by phone or email": "Atopa a outras por teléfono ou email", + "Be found by phone or email": "Permite ser atopada polo email ou teléfono", + "Use bots, bridges, widgets and sticker packs": "Usa bots, pontes, widgets e paquetes de adhesivos", + "Terms of Service": "Termos do Servizo", + "To continue you need to accept the terms of this service.": "Para continuar tes que aceptar os termos deste servizo.", + "Service": "Servizo", + "Summary": "Resumo", + "Document": "Documento", + "Next": "Seguinte", + "Upload files (%(current)s of %(total)s)": "Subir ficheiros (%(current)s de %(total)s)", + "Upload files": "Subir ficheiros", + "Upload all": "Subir todo", + "This file is too large to upload. The file size limit is %(limit)s but this file is %(sizeOfThisFile)s.": "Este ficheiro é demasiado grande para subilo. O límite é %(limit)s mais o ficheiro é %(sizeOfThisFile)s.", + "These files are too large to upload. The file size limit is %(limit)s.": "Estes ficheiros son demasiado grandes para subilos. O límite é %(limit)s.", + "Some files are too large to be uploaded. The file size limit is %(limit)s.": "Algúns ficheiros son demasiado grandes para subilos. O límite é %(limit)s.", + "Upload %(count)s other files|other": "Subir outros %(count)s ficheiros", + "Upload %(count)s other files|one": "Subir %(count)s ficheiro máis", + "Cancel All": "Cancelar todo", + "Upload Error": "Fallo ao subir", + "Verify other session": "Verificar outra sesión", + "Verification Request": "Solicitude de Verificación", + "A widget would like to verify your identity": "Un widget quere verificar a túa indentidade", + "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.": "Un widget localizado en %(widgetUrl)s quere verificar a túa identidade. Se o permites, o widget poderá verificar o teu ID de usuaria, pero non realizar accións por ti.", + "Remember my selection for this widget": "Lembrar a miña decisión para este widget", + "Deny": "Denegar", + "Enter recovery passphrase": "Escribe a frase de paso de recuperación", + "Unable to access secret storage. Please verify that you entered the correct recovery passphrase.": "Non se pode acceder ao almacenaxe segredo. Verifica que escribiches a frase de paso correta.", + "Warning: You should only do this on a trusted computer.": "Aviso: Só deberías facer esto nunha computadora de confianza.", + "Access your secure message history and your cross-signing identity for verifying other sessions by entering your recovery passphrase.": "Accede ó teu historial seguro de mensaxes e á túa identidade de sinatura-cruzada para verificar outras sesión escribindo a frase de paso de recuperación.", + "If you've forgotten your recovery passphrase you can use your recovery key or set up new recovery options.": "Se esqueceches a túa frase de paso de recuperación podes usar a chave de recuperación ou establecer novas opcións de recuperación.", + "Enter recovery key": "Escribe a chave de recuperación", + "Unable to access secret storage. Please verify that you entered the correct recovery key.": "Non se accedeu ó almacenaxe segredo. Verifica que escribiches a chave de recuperación correcta.", + "This looks like a valid recovery key!": "Semella unha chave de recuperación válida!", + "Not a valid recovery key": "Non é unha chave de recuperación válida", + "Access your secure message history and your cross-signing identity for verifying other sessions by entering your recovery key.": "Accede ó teu historial de mensaxes seguras e á identidade de sinatura-cruzada para verificar outras sesión escribindo a achave de recuperación.", + "If you've forgotten your recovery key you can .": "Se esqueceches a chave de recuperación podes .", + "Restoring keys from backup": "Restablecendo chaves desde a copia", + "Fetching keys from server...": "Obtendo chaves desde o servidor...", + "%(completed)s of %(total)s keys restored": "%(completed)s de %(total)s chaves restablecidas", + "Unable to load backup status": "Non cargou o estado da copia", + "Recovery key mismatch": "A chave de recuperación non concorda", + "Backup could not be decrypted with this recovery key: please verify that you entered the correct recovery key.": "A copia non se puido descifrar con esta chave de recuperación: comproba que introduciches a chave de recuperación correcta.", + "Incorrect recovery passphrase": "Frase da paso de recuperación incorrecta", + "Backup could not be decrypted with this recovery passphrase: please verify that you entered the correct recovery passphrase.": "A copia non se descifrou con esta frase de paso: comproba que escribiches a frase de paso correcta.", + "Unable to restore backup": "Non se restableceu a copia", + "No backup found!": "Non se atopou copia!", + "Keys restored": "Chaves restablecidas", + "Failed to decrypt %(failedCount)s sessions!": "Fallo ao descifrar %(failedCount)s sesións!", + "Successfully restored %(sessionCount)s keys": "Restablecidas correctamente %(sessionCount)s chaves", + "Warning: you should only set up key backup from a trusted computer.": "Aviso: só deberías realizar a copia de apoio desde un ordenador de confianza.", + "Access your secure message history and set up secure messaging by entering your recovery passphrase.": "Accede ó historial de mensaxes seguras escribindo a frase de paso de recuperación.", + "If you've forgotten your recovery passphrase you can use your recovery key or set up new recovery options": "Se esqueceches a frase de paso de recuperación pode usar a chave de recuperación ou establecer novas opcións de recuperación", + "Warning: You should only set up key backup from a trusted computer.": "Aviso: só deberías configurar a copia das chaves desde un ordenador de confianza.", + "Access your secure message history and set up secure messaging by entering your recovery key.": "Accede ó teu historial de mensaxes seguras e configura a comunicación segura escribindo a chave de recuperación.", + "If you've forgotten your recovery key you can ": "Se esqueceches a chave de recuperación podes ", + "Address (optional)": "Enderezo (optativo)", + "Resend edit": "Editar reenvío", + "Resend %(unsentCount)s reaction(s)": "Reenviar %(unsentCount)s reacción(s)", + "Resend removal": "Reenviar retirada", + "Share Permalink": "Comparte ligazón permanente", + "Report Content": "Denunciar contido", + "Notification settings": "Axustes de notificacións", + "Clear status": "Baleirar estado", + "Update status": "Actualizar estado", + "Set status": "Establecer estado", + "Set a new status...": "Establecer novo estado...", + "Hide": "Agochar", + "Reload": "Recargar", + "Take picture": "Tomar foto", + "Remove for everyone": "Eliminar para todas", + "Remove for me": "Eliminar para min", + "User Status": "Estado da usuaria", + "This homeserver would like to make sure you are not a robot.": "Este servidor quere asegurarse de que non es un robot.", + "Country Dropdown": "Despregable de países", + "You can use the custom server options to sign into other Matrix servers by specifying a different homeserver URL. This allows you to use this app with an existing Matrix account on a different homeserver.": "Podes usar as opcións dun servidor personalizado para conectarte a outros servidores Matrix indicando o URL do servidor. Así poderás usar esta app cunha conta Matrix dun servidor diferente.", + "Confirm your identity by entering your account password below.": "Confirma a túa identidade escribindo o contrasinal da conta embaixo.", + "Missing captcha public key in homeserver configuration. Please report this to your homeserver administrator.": "Falta a chave pública do captcha na configuración do servidor. Informa desto á administración do teu servidor.", + "Please review and accept all of the homeserver's policies": "Revisa e acepta todas as cláusulas do servidor", + "Please review and accept the policies of this homeserver:": "Revisa e acepta as cláusulas deste servidor:", + "Unable to validate homeserver/identity server": "Non se puido validar o servidor/servidor de identidade", + "Your Modular server": "O teu servidor Modular", + "Enter the location of your Modular homeserver. It may use your own domain name or be a subdomain of modular.im.": "Escribe a localización do teu servidor Modular. Podería utilizar o teu propio nome de dominio ou ser un subdominio de modular.im.", + "Server Name": "Nome do Servidor", + "Enter password": "Escribe contrasinal", + "Nice, strong password!": "Ben, bo contrasinal!", + "Password is allowed, but unsafe": "O contrasinal é admisible, pero inseguro", + "Keep going...": "Segue intentándoo...", + "The username field must not be blank.": "O campo de nome de usuaria non pode estar baleiro.", + "Username": "Nome de usuaria", + "Not sure of your password? Set a new one": "¿Non estás segura do contrasinal? Crea un novo", + "No identity server is configured so you cannot add an email address in order to reset your password in the future.": "Non hai un servidor de identidade configurado polo que non poderás engadir enderezos de email para poder restablecer o contrasinal no futuro.", + "Use an email address to recover your account": "Usa un enderezo de email para recuperar a túa conta", + "Enter email address (required on this homeserver)": "Escribe o enderzo de email (requerido neste servidor)", + "Doesn't look like a valid email address": "Non semella un enderezo válido", + "Passwords don't match": "Non concordan os contrasinais", + "Other users can invite you to rooms using your contact details": "Outras usuarias poden convidarte ás salas usando os teus detalles de contacto", + "Enter phone number (required on this homeserver)": "Escribe un número de teléfono (requerido neste servidor)", + "Doesn't look like a valid phone number": "Non semella un número de teléfono válido", + "Use lowercase letters, numbers, dashes and underscores only": "Usa só minúsculas, números, trazos e trazos baixos", + "Enter username": "Escribe nome de usuaria", + "Email (optional)": "Email (optativo)", + "Phone (optional)": "Teléfono (optativo)", + "Create your Matrix account on %(serverName)s": "Crea a conta Matrix en %(serverName)s", + "Create your Matrix account on ": "Crea a túa conta Matrix en ", + "Set an email for account recovery. Use email or phone to optionally be discoverable by existing contacts.": "Establece un email para recuperación da conta. Usa un email ou teléfono de xeito optativo para que poidan atoparte os contactos.", + "Set an email for account recovery. Use email to optionally be discoverable by existing contacts.": "Establece un email para recuperación da conta. Optativamente usa un email para que poidan atoparte os contactos existentes.", + "Enter your custom homeserver URL What does this mean?": "Escribe o URL do servidor personalizado ¿Qué significa esto?", + "Homeserver URL": "URL do servidor", + "Enter your custom identity server URL What does this mean?": "Escribe o URL do servidor de identidade personalizado ¿Que significa esto?", + "Identity Server URL": "URL do servidor de identidade", + "Other servers": "Outros servidores", + "Free": "Gratuíto", + "Premium": "Premium", + "Premium hosting for organisations Learn more": "Hospedaxe Premium para organizacións Saber máis", + "Find other public servers or use a custom server": "Atopa outros servidores públicos ou usa un servidor personalizado", + "Couldn't load page": "Non se puido cargar a páxina", + "You are an administrator of this community. You will not be able to rejoin without an invite from another administrator.": "Administras esta comunidade. Non poderás voltar a unirte sen un convite doutra persoa administradora.", + "Want more than a community? Get your own server": "¿Queres algo máis que unha comunidade? Monta o teu propio servidor", + "This homeserver does not support communities": "Este servidor non soporta comunidades", + "Welcome to %(appName)s": "Benvida a %(appName)s", + "Liberate your communication": "Libera as túas comunicacións", + "Send a Direct Message": "Envía unha Mensaxe Directa", + "Create a Group Chat": "Crear unha Conversa en Grupo", + "Self-verification request": "Solicitude de auto-verificación", + "Riot failed to get the protocol list from the homeserver. The homeserver may be too old to support third party networks.": "Riot non puido obter a lista de protocolos desde o servidor. O servidor podería ser moi antigo para soportar redes de terceiros.", + "Riot failed to get the public room list.": "Riot non puido obter a lista de salas públicas.", + "The homeserver may be unavailable or overloaded.": "O servidor podería non estar dispoñible ou con sobrecarga.", + "delete the address.": "eliminar o enderezo.", + "Preview": "Vista previa", + "View": "Vista", + "Find a room…": "Atopa unha sala…", + "Find a room… (e.g. %(exampleRoom)s)": "Atopa unha sala... (ex. %(exampleRoom)s)", + "Jump to first unread room.": "Vaite a primeira sala non lida.", + "Jump to first invite.": "Vai ó primeiro convite.", + "You have %(count)s unread notifications in a prior version of this room.|other": "Tes %(count)s notificacións non lidas nunha versión previa desta sala.", + "You have %(count)s unread notifications in a prior version of this room.|one": "Tes %(count)s notificacións non lidas nunha versión previa desta sala.", + "Your profile": "Perfil", + "Switch to light mode": "Cambiar a decorado claro", + "Switch to dark mode": "Cambiar a decorado escuro", + "Switch theme": "Cambiar decorado", + "Security & privacy": "Seguridade & privacidade", + "All settings": "Todos os axustes", + "Archived rooms": "Salas arquivadas", + "Feedback": "Comenta", + "Account settings": "Axustes da conta", + "Could not load user profile": "Non se cargou o perfil da usuaria", + "Verify this login": "Verifcar esta conexión", + "Session verified": "Sesión verificada", + "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.": "Ao cambiar o contrasinal vas restablecer todas as chaves de cifrado das túas sesións, impedindo ler o historial de conversa. Configura a Copia de Apoio das Chaves ou exporta as chaves da sala desde outra sesión antes de restablecer o contrasinal.", + "Your Matrix account on %(serverName)s": "A túa conta Matrix en %(serverName)s", + "Your Matrix account on ": "A túa conta Matrix en ", + "No identity server is configured: add one in server settings to reset your password.": "Non hai un Servidor de Identidade configurado: engade un nos axustes para restablecer o contrasinal.", + "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.": "Desconectaches todas as sesións e non recibirás notificacións push. Para reactivalas, conéctate outra vez nos dispositivos.", + "Set a new password": "Novo contrasinal", + "Invalid homeserver discovery response": "Resposta de descubrimento do servidor non válida", + "Failed to get autodiscovery configuration from server": "Fallo ó obter a configuración de autodescubrimento desde o servidor", + "Invalid base_url for m.homeserver": "base_url non válido para m.homeserver", + "Homeserver URL does not appear to be a valid Matrix homeserver": "O URL do servidor non semella ser un servidor Matrix válido", + "Invalid identity server discovery response": "Resposta de descubrimento de identidade do servidor non válida", + "Invalid base_url for m.identity_server": "base_url para m.identity_server non válida", + "Identity server URL does not appear to be a valid identity server": "O URL do servidor de identidade non semella ser un servidor de identidade válido", + "This account has been deactivated.": "Esta conta foi desactivada.", + "Failed to perform homeserver discovery": "Fallo ao intentar o descubrimento do servidor", + "Syncing...": "Sincronizando...", + "Signing In...": "Conectando con...", + "If you've joined lots of rooms, this might take a while": "Se te uniches a moitas salas, esto podería levarnos un anaco", + "Create account": "Crea unha conta", + "Use a more compact ‘Modern’ layout": "Usa o deseño compacto 'Moderno'", + "Unable to query for supported registration methods.": "Non se puido consultar os métodos de rexistro soportados.", + "Registration has been disabled on this homeserver.": "O rexistro está desactivado neste servidor.", + "Your new account (%(newAccountId)s) is registered, but you're already logged into a different account (%(loggedInUserId)s).": "A tú conta (%(newAccountId)s) foi rexistrada, pero estás conectada usando outra conta (%(loggedInUserId)s).", + "Continue with previous account": "Continúa coa conta anterior", + "Log in to your new account.": "Conecta usando a conta nova.", + "You can now close this window or log in to your new account.": "Podes pechar esta ventá ou conectar usando a conta nova.", + "Registration Successful": "Rexistro correcto", + "Create your account": "Crea a túa conta", + "Use Recovery Key or Passphrase": "Usa a Chave de recuperación ou Frase de paso", + "Use Recovery Key": "Usa chave de recuperación", + "Confirm your identity by verifying this login from one of your other sessions, granting it access to encrypted messages.": "Confirma a túa identidade verificando esta conexión desde unha das outras sesións, permitindo así acceder ás mensaxes cifradas.", + "This requires the latest Riot on your other devices:": "Require a última versión de Riot nos outros dispositivos:", + "or another cross-signing capable Matrix client": "ou outro cliente Matrix que permita a sinatura-cruzada", + "Your new session is now verified. It has access to your encrypted messages, and other users will see it as trusted.": "A nova sesión foi verificada. Tes acceso ás mensaxes cifradas, e outras persoas verante como confiable.", + "Your new session is now verified. Other users will see it as trusted.": "A nova sesión foi verificada. Outras persoas verante como confiable.", + "Without completing security on this session, it won’t have access to encrypted messages.": "Sen non garantes a seguridade para esta sesión non poderá acceder a mensaxes cifradas.", + "Go Back": "Atrás", + "Failed to re-authenticate due to a homeserver problem": "Fallo ó reautenticar debido a un problema no servidor", + "Failed to re-authenticate": "Fallo na reautenticación", + "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.": "Recupera o acceso á túa conta e recupera as chaves de cifrado gardadas nesta sesión. Sen elas, non poderás ler as mensaxes seguras en calquera das sesións.", + "Forgotten your password?": "¿Esqueceches o contrasinal?", + "You're signed out": "Estás desconectada", + "Clear personal data": "Baleirar datos personais", + "Command Autocomplete": "Autocompletado de comandos", + "Community Autocomplete": "Autocompletado de comunidade", + "DuckDuckGo Results": "Resultados DuckDuckGo", + "Emoji Autocomplete": "Autocompletado emoticonas", + "Notification Autocomplete": "Autocompletado de notificacións", + "Room Autocomplete": "Autocompletado de Salas", + "User Autocomplete": "Autocompletados de Usuaria", + "Confirm encryption setup": "Confirma os axustes de cifrado", + "Click the button below to confirm setting up encryption.": "Preme no botón inferior para confirmar os axustes do cifrado.", + "Enter your account password to confirm the upgrade:": "Escribe o contrasinal para confirmar a actualización:", + "Restore your key backup to upgrade your encryption": "Restablece a copia das chaves para actualizar o cifrado", + "Restore": "Restablecer", + "You'll need to authenticate with the server to confirm the upgrade.": "Debes autenticarte no servidor para confirmar a actualización.", + "Upgrade this session to allow it to verify other sessions, granting them access to encrypted messages and marking them as trusted for other users.": "Actualiza esta sesión para permitirlle que verifique as outras sesións, outorgándolles acceso ás mensaxes cifradas e marcándoas como confiables para outras usuarias.", + "Set a recovery passphrase to secure encrypted information and recover it if you log out. This should be different to your account password:": "Establece unha frase de paso de recuperación para asegurar a información cifrada e recuperala se te desconectas. Esta frase debería ser diferente ó contrasinal da conta:", + "Enter a recovery passphrase": "Escribe a frase de paso de recuperación", + "Great! This recovery passphrase looks strong enough.": "Ben! Esta frase de paso de recuperación semella ser forte.", + "Back up encrypted message keys": "Fai copia das chaves das mensaxes cifradas", + "Set up with a recovery key": "Configura cunha chave de recuperación", + "That matches!": "Concorda!", + "Use a different passphrase?": "¿Usar unha frase de paso diferente?", + "That doesn't match.": "Non concorda.", + "Go back to set it again.": "Vai atrás e volve a escribila.", + "Enter your recovery passphrase a second time to confirm it.": "Escribe a frase de paso de recuperación por segunda vez para confirmala.", + "Confirm your recovery passphrase": "Confirma a frase de paso de recuperación", + "Your recovery key is a safety net - you can use it to restore access to your encrypted messages if you forget your recovery passphrase.": "A chave de recuperación é unha rede de seguridade - podes usala para recuperar o acceso ás mensaxes cifradas se esqueces a frase de paso de recuperación.", + "Keep a copy of it somewhere secure, like a password manager or even a safe.": "Garda unha copia nun lugar seguro, como un xestor de contrasinais ou nun lugar aínda máis seguro.", + "Your recovery key": "A chave de recuperación", + "Copy": "Copiar", + "Download": "Descargar", + "Your recovery key has been copied to your clipboard, paste it to:": "A chave de recuperación foi copiada no portapapeis, pégaa en:", + "Your recovery key is in your Downloads folder.": "A chave de recuperación está no teu cartafol de Descargas.", + "Print it and store it somewhere safe": "Imprímea e gárdaa nun lugar seguro", + "Save it on a USB key or backup drive": "Gárdaa nunha memoria USB ou disco duro", + "Copy it to your personal cloud storage": "Copiaa no almacenaxe personal na nube", + "Unable to query secret storage status": "Non se obtivo o estado do almacenaxe segredo", + "Retry": "Reintentar", + "You can now verify your other devices, and other users to keep your chats safe.": "Xa podes verificar os teus outros dispositivos e a outras usuarias para manter conversas seguras.", + "Upgrade your encryption": "Mellora o teu cifrado", + "Confirm recovery passphrase": "Confirma a frase de paso de recuperación", + "Make a copy of your recovery key": "Fai unha copia da túa chave de recuperación", + "You're done!": "Feito!", + "Unable to set up secret storage": "Non se configurou un almacenaxe segredo", + "We'll store an encrypted copy of your keys on our server. Secure your backup with a recovery passphrase.": "Imos gardar unha copia cifrada das túas chaves no noso servidor. Asegura a copia cunha frase de paso de recuperación.", + "For maximum security, this should be different from your account password.": "Para máxima seguridade, esta debería ser diferente ó contrasinal da túa conta.", + "Please enter your recovery passphrase a second time to confirm.": "Escribe a frase de paso de recuperación outra vez para confirmala.", + "Repeat your recovery passphrase...": "Repite a frase de paso de recuperación...", + "Your keys are being backed up (the first backup could take a few minutes).": "As chaves estanse a copiar (a primeira copia podería tardar un anaco).", + "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.": "Se non configuras Recuperación de Mensaxes Seguras, non poderás restablecer o historial de mensaxes cifradas se te desconectas ou usas outra sesión.", + "Set up Secure Message Recovery": "Cofigurar Recuperación de Mensaxes Seguras", + "Secure your backup with a recovery passphrase": "Asegura a túa copia cunha frase de paso de recuperación", + "Starting backup...": "Iniciando a copia...", + "Success!": "Feito!", + "Create key backup": "Crear copia da chave", + "Unable to create key backup": "Non se creou a copia da chave", + "Without setting up Secure Message Recovery, you'll lose your secure message history when you log out.": "Se non configuras a Recuperación de Mensaxes Seguras, perderás o acceso ó historial de mensaxes seguras cando te desconectes.", + "If you don't want to set this up now, you can later in Settings.": "Se non queres configurar esto agora, pódelo facer posteriormente nos Axustes.", + "Don't ask again": "Non preguntar outra vez", + "New Recovery Method": "Novo Método de Recuperación", + "A new recovery passphrase and key for Secure Messages have been detected.": "Detectouse un novo método de chave e frase de paso de recuperación para Mensaxes Seguras.", + "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.": "Se non configuras o novo método de recuperación, un atacante podería intentar o acceso á túa conta. Cambia inmediatamente o contrasinal da conta e configura un novo método de recuperación nos Axustes.", + "This session is encrypting history using the new recovery method.": "Esta sesión está cifrando o historial usando o novo método de recuperación.", + "Go to Settings": "Ir a Axustes", + "Set up Secure Messages": "Configurar Mensaxes Seguras", + "Recovery Method Removed": "Método de Recuperación eliminado", + "This session has detected that your recovery passphrase and key for Secure Messages have been removed.": "Esta sesión detectou que a túa frase de paso de recuperación e chave para Mensaxes Seguras foron eliminadas.", + "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.": "Se fixeches esto sen querer, podes configurar Mensaxes Seguras nesta sesión e volverá a cifrar as mensaxes da sesión cun novo método de recuperación.", + "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.": "Se non eliminaches o método de recuperación, un atacante podería estar a intentar acceder á túa conta. Cambia inmediatamente o contrasinal da conta e establece un novo método de recuperación nos Axustes.", + "If disabled, messages from encrypted rooms won't appear in search results.": "Se está desactivado, as mensaxes das salas cifradas non aparecerán nos resultados das buscas.", + "Disable": "Desactivar", + "Not currently indexing messages for any room.": "Non se están indexando as mensaxes de ningunha sala.", + "Currently indexing: %(currentRoom)s": "Indexando actualmente: %(currentRoom)s", + "Riot is securely caching encrypted messages locally for them to appear in search results:": "Riot está gardando de xeito seguro na caché local mensaxes cifradas para que aparezan nos resultados das buscas:", + "Space used:": "Espazo utilizado:", + "Indexed messages:": "Mensaxes indexadas:", + "Indexed rooms:": "Salas indexadas:", + "%(doneRooms)s out of %(totalRooms)s": "%(doneRooms)s de %(totalRooms)s", + "New spinner design": "Novo deseño da roda", + "Message downloading sleep time(ms)": "Tempo de espera da mensaxe de descarga(ms)", + "Navigation": "Navegación", + "Calls": "Chamadas", + "Room List": "Lista de Salas", + "Autocomplete": "Autocompletado", + "Alt": "Alt", + "Alt Gr": "Alt Gr", + "Shift": "Maiús.", + "Super": "Super", + "Ctrl": "Ctrl", + "Toggle Bold": "Activa Resaltar", + "Toggle Italics": "Activa Cursiva", + "Toggle Quote": "Activa Citación", + "New line": "Nova liña", + "Navigate recent messages to edit": "Mira nas mensaxes recentes para editar", + "Jump to start/end of the composer": "Vai ó inicio/fin no editor", + "Navigate composer history": "Vai ó historial do editor", + "Cancel replying to a message": "Cancelar a resposta a mensaxe", + "Toggle microphone mute": "Acalar micrófono", + "Toggle video on/off": "Activar vídeo on/off", + "Scroll up/down in the timeline": "Desprazarse arriba/abaixo na cronoloxía", + "Dismiss read marker and jump to bottom": "Ignorar marcador de lectura e ir ó final", + "Jump to oldest unread message": "Ir á mensaxe máis antiga non lida", + "Upload a file": "Subir ficheiro", + "Jump to room search": "Ir a busca na sala", + "Navigate up/down in the room list": "Ir arriba/abaixo na lista de salas", + "Select room from the room list": "Escoller sala da lista de salas", + "Collapse room list section": "Contraer a sección de lista de salas", + "Expand room list section": "Expandir a sección da lista de salas", + "Previous/next unread room or DM": "Anterior/seguinte para salas non lidas ou MD", + "Previous/next room or DM": "Anterior/seguinte para sala ou MD", + "Toggle the top left menu": "Activar o menú superior esquerdo", + "Close dialog or context menu": "Pechar o diálogo ou menú contextual", + "Activate selected button": "Activar o botón seleccionado", + "Toggle right panel": "Activar panel dereito", + "Toggle this dialog": "Activar este diálogo", + "Move autocomplete selection up/down": "Mover selección autocompletado arriba/abaixo", + "Cancel autocomplete": "Cancelar autocompletado", + "Page Up": "Páxina superior", + "Page Down": "Páxina inferior", + "Esc": "Esc", + "Enter": "Intro", + "Space": "Espazo", + "End": "Fin", + "You joined the call": "Unícheste á chamada", + "%(senderName)s joined the call": "%(senderName)s uniuse á chamada", + "Call in progress": "Chamada en curso", + "You left the call": "Deixáchela chamada", + "%(senderName)s left the call": "%(senderName)s deixou a chamada", + "Call ended": "Chamada rematada", + "You started a call": "Iniciaches unha chamada", + "%(senderName)s started a call": "%(senderName)s iniciou unha chamada", + "Waiting for answer": "Agardando resposta", + "%(senderName)s is calling": "%(senderName)s está chamando", + "You created the room": "Creaches a sala", + "%(senderName)s created the room": "%(senderName)s creou a sala", + "You made the chat encrypted": "Cifraches a conversa", + "%(senderName)s made the chat encrypted": "%(senderName)s cifrou a conversa", + "You made history visible to new members": "Fixeches visible o historial para novos membros", + "%(senderName)s made history visible to new members": "%(senderName)s fixo o historial visible para novos membros", + "You made history visible to anyone": "Fixeches que o historial sexa visible para todas", + "%(senderName)s made history visible to anyone": "%(senderName)s fixo o historial visible para todas", + "You made history visible to future members": "Fixeches o historial visible para membros futuros", + "%(senderName)s made history visible to future members": "%(senderName)s fixo o historial visible para futuros membros", + "You were invited": "Foches convidada", + "%(targetName)s was invited": "%(targetName)s foi convidada", + "You left": "Saíches", + "%(targetName)s left": "%(targetName)s saíu", + "You were kicked (%(reason)s)": "Expulsáronte (%(reason)s)", + "%(targetName)s was kicked (%(reason)s)": "%(targetName)s foi expulsada (%(reason)s)", + "You were kicked": "Foches expulsada", + "%(targetName)s was kicked": "%(targetName)s foi expulsada", + "You rejected the invite": "Rexeitaches o convite", + "%(targetName)s rejected the invite": "%(targetName)s rexeitou o convite", + "You were uninvited": "Retiraronche o convite", + "%(targetName)s was uninvited": "Retirouse o convite para %(targetName)s", + "You were banned (%(reason)s)": "Foches bloqueada (%(reason)s)", + "%(targetName)s was banned (%(reason)s)": "%(targetName)s foi bloqueada (%(reason)s)", + "You were banned": "Foches bloqueada", + "%(targetName)s was banned": "%(targetName)s foi bloqueada", + "You joined": "Unícheste", + "%(targetName)s joined": "%(targetName)s uneuse", + "You changed your name": "Cambiaches o nome", + "%(targetName)s changed their name": "%(targetName)s cambiou o seu nome", + "You changed your avatar": "Cambiáchelo avatar", + "%(targetName)s changed their avatar": "%(targetName)s cambiou o seu avatar", + "%(senderName)s %(emote)s": "%(senderName)s %(emote)s", + "%(senderName)s: %(message)s": "%(senderName)s: %(message)s", + "You changed the room name": "Cambiaches o nome da sala", + "%(senderName)s changed the room name": "%(senderName)s cambiou o nome da sala", + "%(senderName)s: %(reaction)s": "%(senderName)s: %(reaction)s", + "%(senderName)s: %(stickerName)s": "%(senderName)s: %(stickerName)s", + "You uninvited %(targetName)s": "Retiraches o convite para %(targetName)s", + "%(senderName)s uninvited %(targetName)s": "%(senderName)s retiroulle o convite a %(targetName)s", + "You invited %(targetName)s": "Convidaches a %(targetName)s", + "%(senderName)s invited %(targetName)s": "%(senderName)s convidou a %(targetName)s", + "You changed the room topic": "Cambiaches o tema da sala", + "%(senderName)s changed the room topic": "%(senderName)s cambiou o asunto da sala", + "Message deleted on %(date)s": "Mensaxe eliminada o %(date)s", + "Wrong file type": "Tipo de ficheiro erróneo", + "Looks good!": "Pinta ben!", + "Wrong Recovery Key": "Chave de recuperación errónea", + "Invalid Recovery Key": "Chave de recuperación non válida", + "Security Phrase": "Frase de seguridade", + "Enter your Security Phrase or to continue.": "Escribe a túa Frase de Seguridade ou para continuar.", + "Security Key": "Chave de Seguridade", + "Use your Security Key to continue.": "Usa a túa Chave de Seguridade para continuar.", + "Safeguard against losing access to encrypted messages & data by backing up encryption keys on your server.": "Protección contra a perda do acceso ás mensaxes cifradas e datos facendo unha copia de apoio das chaves no servidor.", + "Generate a Security Key": "Crear unha Chave de Seguridade", + "We’ll generate a Security Key for you to store somewhere safe, like a password manager or a safe.": "Crearemos unha Chave de Seguridade para que a gardes nalgún lugar seguro, como un xestor de contrasinais ou caixa de seguridade.", + "Enter a Security Phrase": "Escribe unha Frase de Seguridade", + "Use a secret phrase only you know, and optionally save a Security Key to use for backup.": "Usa unha frase segreda que só ti coñezas, e de xeito optativo unha Chave de Seguridade para usar como apoio.", + "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.": "Escribe unha frase de seguridade que só ti coñezas, será utilizada para protexer os teus datos. Para maior seguridade, non deberías reutilizar o contrasinal da conta.", + "Store your Security Key somewhere safe, like a password manager or a safe, as it’s used to safeguard your encrypted data.": "Garda a Chave de Seguridade nalgún lugar seguro, como un xestor de contrasinais ou caixa de seguridade, será utiizada para protexer os teus datos cifrados.", + "If you cancel now, you may lose encrypted messages & data if you lose access to your logins.": "Se cancelas agora, poderías perder mensaxes e datos cifrados se perdes o acceso ós datos de conexión.", + "You can also set up Secure Backup & manage your keys in Settings.": "Podes configurar a Copia de apoio Segura e xestionar as chaves en Axustes.", + "Set up Secure backup": "Configurar Copia de apoio Segura", + "Set a Security Phrase": "Establece a Frase de Seguridade", + "Confirm Security Phrase": "Confirma a Frase de Seguridade", + "Save your Security Key": "Garda a Chave de Seguridade" } diff --git a/src/i18n/strings/hu.json b/src/i18n/strings/hu.json index ea830bc38e..378f71fb49 100644 --- a/src/i18n/strings/hu.json +++ b/src/i18n/strings/hu.json @@ -2516,5 +2516,91 @@ "Upgrade your Recovery Key": "A Visszaállítási kulcs fejlesztése", "Store your Recovery Key": "Visszaállítási kulcs tárolása", "Use the improved room list (in development - will refresh to apply changes)": "Használd a fejlesztett szoba listát (fejlesztés alatt - a változások a frissítés után aktiválódnak)", - "Use the improved room list (will refresh to apply changes)": "Használd a fejlesztett szoba listát (a változások életbe lépéséhez újra fog tölteni)" + "Use the improved room list (will refresh to apply changes)": "Használd a fejlesztett szoba listát (a változások életbe lépéséhez újra fog tölteni)", + "Enable IRC layout option in the appearance tab": "IRC kinézet lehetőségének megjelenítése a kinézet fülön", + "Use custom size": "Egyedi méret használata", + "Use a system font": "Rendszer betűtípus használata", + "System font name": "Rendszer betűtípus neve", + "Hey you. You're the best!": "Hé te! Te vagy a legjobb!", + "Message layout": "Üzenet kinézete", + "Compact": "Egyszerű", + "Modern": "Modern", + "The authenticity of this encrypted message can't be guaranteed on this device.": "A titkosított üzenetek valódiságát ezen az eszközön nem lehet garantálni.", + "You joined the call": "Csatlakoztál a hívásba", + "%(senderName)s joined the call": "%(senderName)s csatlakozott a híváshoz", + "Call in progress": "Hívás folyamatban van", + "You left the call": "Kiléptél a hívásból", + "%(senderName)s left the call": "%(senderName)s kilépett a hívásból", + "Call ended": "Hívás befejeződött", + "You started a call": "Hívást kezdeményeztél", + "%(senderName)s started a call": "%(senderName)s hívást kezdeményezett", + "Waiting for answer": "Válaszra várakozás", + "%(senderName)s is calling": "%(senderName)s hív", + "You created the room": "Létrehoztál egy szobát", + "%(senderName)s created the room": "%(senderName)s létrehozott egy szobát", + "You made the chat encrypted": "A beszélgetést titkosítottá tetted", + "%(senderName)s made the chat encrypted": "%(senderName)s titkosítottá tette a beszélgetést", + "You made history visible to new members": "A régi beszélgetéseket láthatóvá tetted az új tagok számára", + "%(senderName)s made history visible to new members": "%(senderName)s a régi beszélgetéseket láthatóvá tette az új tagok számára", + "You made history visible to anyone": "A régi beszélgetéseket láthatóvá tette mindenki számára", + "%(senderName)s made history visible to anyone": "%(senderName)s a régi beszélgetéseket láthatóvá tette mindenki számára", + "You made history visible to future members": "A régi beszélgetéseket láthatóvá tetted a leendő tagok számára", + "%(senderName)s made history visible to future members": "%(senderName)s a régi beszélgetéseket láthatóvá tette a leendő tagok számára", + "You were invited": "Meghívtak", + "%(targetName)s was invited": "Meghívták őt: %(targetName)s", + "You left": "Távoztál", + "%(targetName)s left": "%(targetName)s távozott", + "You were kicked (%(reason)s)": "Kirúgtak (%(reason)s)", + "%(targetName)s was kicked (%(reason)s)": "Kirúgták őt: %(targetName)s (%(reason)s)", + "You were kicked": "Kirúgtak", + "%(targetName)s was kicked": "Kirúgták őt: %(targetName)s", + "You rejected the invite": "A meghívót elutasítottad", + "%(targetName)s rejected the invite": "%(targetName)s elutasította a meghívót", + "You were uninvited": "A meghívódat visszavonták", + "%(targetName)s was uninvited": "A meghívóját visszavonták neki: %(targetName)s", + "You were banned (%(reason)s)": "Kitiltottak (%(reason)s)", + "%(targetName)s was banned (%(reason)s)": "Kitiltották őt: %(targetName)s (%(reason)s)", + "You were banned": "Kitiltottak", + "%(targetName)s was banned": "Kitiltották őt: %(targetName)s", + "You joined": "Beléptél", + "%(targetName)s joined": "%(targetName)s belépett", + "You changed your name": "A neved megváltoztattad", + "%(targetName)s changed their name": "%(targetName)s megváltoztatta a nevét", + "You changed your avatar": "A profilképedet megváltoztattad", + "%(targetName)s changed their avatar": "%(targetName)s megváltoztatta a profilképét", + "%(senderName)s %(emote)s": "%(senderName)s %(emote)s", + "%(senderName)s: %(message)s": "%(senderName)s: %(message)s", + "You changed the room name": "A szoba nevét megváltoztattad", + "%(senderName)s changed the room name": "%(senderName)s megváltoztatta a szoba nevét", + "%(senderName)s: %(reaction)s": "%(senderName)s: %(reaction)s", + "%(senderName)s: %(stickerName)s": "%(senderName)s: %(stickerName)s", + "You uninvited %(targetName)s": "A meghívóját visszavontad neki: %(targetName)s", + "%(senderName)s uninvited %(targetName)s": "%(senderName)s visszavonta a meghívóját neki: %(targetName)s", + "You invited %(targetName)s": "Meghívtad őt: %(targetName)s", + "%(senderName)s invited %(targetName)s": "%(senderName)s meghívta őt: %(targetName)s", + "You changed the room topic": "A szoba témáját megváltoztattad", + "%(senderName)s changed the room topic": "%(senderName)s megváltoztatta a szoba témáját", + "New spinner design": "Új várakozási animáció", + "Use a more compact ‘Modern’ layout": "Egyszerűbb 'Modern' kinézet használata", + "Message deleted on %(date)s": "Az üzenetet ekkor törölték: %(date)s", + "Wrong file type": "A fájl típus hibás", + "Wrong Recovery Key": "A Visszaállítási Kulcs hibás", + "Invalid Recovery Key": "A Visszaállítási Kulcs hibás", + "Security Phrase": "Biztonsági jelmondat", + "Enter your Security Phrase or to continue.": "Add meg a Biztonsági jelmondatot vagy a folytatáshoz.", + "Security Key": "Biztonsági Kulcs", + "Use your Security Key to continue.": "Használd a Biztonsági Kulcsot a folytatáshoz.", + "Safeguard against losing access to encrypted messages & data by backing up encryption keys on your server.": "A titkosított üzenetekhez és adatokhoz való hozzáférés elvesztése esetén használható biztonsági tartalék a titkosított kulcsok a szerveredre való elmentésével.", + "Generate a Security Key": "Biztonsági Kulcs elkészítése", + "We’ll generate a Security Key for you to store somewhere safe, like a password manager or a safe.": "A Biztonsági Kulcsodat elkészítjük neked amit tárolj valamilyen biztonságos helyen mint pl. a jelszókezelő vagy széf.", + "Enter a Security Phrase": "Biztonsági Jelmondat megadása", + "Use a secret phrase only you know, and optionally save a Security Key to use for backup.": "Olyan biztonsági jelmondatot használj amit csak te ismersz és esetleg mentsd el a Biztonsági Kulcsot vésztartaléknak.", + "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.": "Olyan biztonsági jelmondatot adj meg amit csak te ismersz, mert ez fogja az adataidat őrizni. Hogy biztonságos legyen ne használd a fiókod jelszavát.", + "Store your Security Key somewhere safe, like a password manager or a safe, as it’s used to safeguard your encrypted data.": "A Biztonsági Kulcsot tárold biztonságos helyen, mint pl. a jelszókezelő vagy széf, mivel ez tartja biztonságban a titkosított adataidat.", + "If you cancel now, you may lose encrypted messages & data if you lose access to your logins.": "Ha most megszakítod, akkor a munkameneteidhez való hozzáférés elvesztésével elveszítheted a titkosított üzeneteidet és adataidat.", + "You can also set up Secure Backup & manage your keys in Settings.": "A Biztonsági mentést és a kulcsok kezelését beállíthatod a Beállításokban.", + "Set up Secure backup": "Biztonsági mentés beállítása", + "Set a Security Phrase": "Biztonsági Jelmondat beállítása", + "Confirm Security Phrase": "Biztonsági Jelmondat megerősítése", + "Save your Security Key": "Ments el a Biztonsági Kulcsodat" } diff --git a/src/i18n/strings/it.json b/src/i18n/strings/it.json index 6f213de237..69b879ae19 100644 --- a/src/i18n/strings/it.json +++ b/src/i18n/strings/it.json @@ -2522,5 +2522,72 @@ "Create a Recovery Key": "Crea una chiave di recupero", "Upgrade your Recovery Key": "Aggiorna la chiave di recupero", "Store your Recovery Key": "Salva la chiave di recupero", - "Use the improved room list (will refresh to apply changes)": "Usa l'elenco stanze migliorato (verrà ricaricato per applicare le modifiche)" + "Use the improved room list (will refresh to apply changes)": "Usa l'elenco stanze migliorato (verrà ricaricato per applicare le modifiche)", + "Enable IRC layout option in the appearance tab": "Attiva l'opzione per il layout IRC nella scheda dell'aspetto", + "Use custom size": "Usa dimensione personalizzata", + "Hey you. You're the best!": "Ehi tu. Sei il migliore!", + "Message layout": "Layout messaggio", + "Compact": "Compatto", + "Modern": "Moderno", + "Use a system font": "Usa un carattere di sistema", + "System font name": "Nome carattere di sistema", + "The authenticity of this encrypted message can't be guaranteed on this device.": "L'autenticità di questo messaggio cifrato non può essere garantita su questo dispositivo.", + "You joined the call": "Ti sei unito alla chiamata", + "%(senderName)s joined the call": "%(senderName)s si è unito alla chiamata", + "Call in progress": "Chiamata in corso", + "You left the call": "Hai abbandonato la chiamata", + "%(senderName)s left the call": "%(senderName)s ha abbandonato la chiamata", + "Call ended": "Chiamata terminata", + "You started a call": "Hai iniziato una chiamata", + "%(senderName)s started a call": "%(senderName)s ha iniziato una chiamata", + "Waiting for answer": "In attesa di risposta", + "%(senderName)s is calling": "%(senderName)s sta chiamando", + "You created the room": "Hai creato la stanza", + "%(senderName)s created the room": "%(senderName)s ha creato la stanza", + "You made the chat encrypted": "Hai reso la chat crittografata", + "%(senderName)s made the chat encrypted": "%(senderName)s ha reso la chat crittografata", + "You made history visible to new members": "Hai reso visibile la cronologia ai nuovi membri", + "%(senderName)s made history visible to new members": "%(senderName)s ha reso visibile la cronologia ai nuovi membri", + "You made history visible to anyone": "Hai reso visibile la cronologia a chiunque", + "%(senderName)s made history visible to anyone": "%(senderName)s ha reso visibile la cronologia a chiunque", + "You made history visible to future members": "Hai reso visibile la cronologia ai membri futuri", + "%(senderName)s made history visible to future members": "%(senderName)s ha reso visibile la cronologia ai membri futuri", + "You were invited": "Sei stato invitato", + "%(targetName)s was invited": "%(targetName)s è stato invitato", + "You left": "Sei uscito", + "%(targetName)s left": "%(targetName)s è uscito", + "You were kicked (%(reason)s)": "Sei stato buttato fuori (%(reason)s)", + "%(targetName)s was kicked (%(reason)s)": "%(targetName)s è stato buttato fuori (%(reason)s)", + "You were kicked": "Sei stato buttato fuori", + "%(targetName)s was kicked": "%(targetName)s è stato buttato fuori", + "You rejected the invite": "Hai rifiutato l'invito", + "%(targetName)s rejected the invite": "%(targetName)s ha rifiutato l'invito", + "You were uninvited": "Ti è stato revocato l'invito", + "%(targetName)s was uninvited": "È stato revocato l'invito a %(targetName)s", + "You were banned (%(reason)s)": "Sei stato bandito (%(reason)s)", + "%(targetName)s was banned (%(reason)s)": "%(targetName)s è stato bandito (%(reason)s)", + "You were banned": "Sei stato bandito", + "%(targetName)s was banned": "%(targetName)s è stato bandito", + "You joined": "Ti sei unito", + "%(targetName)s joined": "%(targetName)s si è unito", + "You changed your name": "Hai cambiato il tuo nome", + "%(targetName)s changed their name": "%(targetName)s ha cambiato il suo nome", + "You changed your avatar": "Hai cambiato il tuo avatar", + "%(targetName)s changed their avatar": "%(targetName)s ha cambiato il suo avatar", + "%(senderName)s %(emote)s": "%(senderName)s %(emote)s", + "%(senderName)s: %(message)s": "%(senderName)s: %(message)s", + "You changed the room name": "Hai cambiato il nome della stanza", + "%(senderName)s changed the room name": "%(senderName)s ha cambiato il nome della stanza", + "%(senderName)s: %(reaction)s": "%(senderName)s: %(reaction)s", + "%(senderName)s: %(stickerName)s": "%(senderName)s: %(stickerName)s", + "You uninvited %(targetName)s": "Hai revocato l'invito a %(targetName)s", + "%(senderName)s uninvited %(targetName)s": "%(senderName)s ha revocato l'invito a %(targetName)s", + "You invited %(targetName)s": "Hai invitato %(targetName)s", + "%(senderName)s invited %(targetName)s": "%(senderName)s ha invitato %(targetName)s", + "You changed the room topic": "Hai cambiato l'argomento della stanza", + "%(senderName)s changed the room topic": "%(senderName)s ha cambiato l'argomento della stanza", + "New spinner design": "Nuovo design dello spinner", + "Use a more compact ‘Modern’ layout": "Usa un layout più compatto e moderno", + "Always show first": "Mostra sempre per prime", + "Message deleted on %(date)s": "Messaggio eliminato il %(date)s" } diff --git a/src/i18n/strings/kab.json b/src/i18n/strings/kab.json index 8d11469afc..b7cf066a21 100644 --- a/src/i18n/strings/kab.json +++ b/src/i18n/strings/kab.json @@ -2,12 +2,12 @@ "Confirm": "Sentem", "Analytics": "Tiselḍin", "Error": "Tuccḍa", - "Dismiss": "Agwi", + "Dismiss": "Agi", "OK": "IH", "Permission Required": "Tasiregt tlaq", "Continue": "Kemmel", - "Cancel": "Semmet", - "Sun": "Ace", + "Cancel": "Sefsex", + "Sun": "Iṭij", "Mon": "Ari", "Tue": "Ara", "Wed": "Aha", @@ -80,13 +80,13 @@ "Bicycle": "Azlalam", "Ball": "Balles", "Anchor": "Tamdeyt", - "Headphones": "Casque", + "Headphones": "Wennez", "Folder": "Akaram", "Upload": "Sali", "Remove": "Sfeḍ", "Show less": "Sken-d drus", "Show more": "Sken-d ugar", - "Warning!": "Ɣur-k!", + "Warning!": "Ɣur-k·m!", "Current password": "Awal uffir amiran", "Password": "Awal uffir", "New Password": "Awal uffir amaynut", @@ -100,11 +100,11 @@ "Keywords": "Awalen tisura", "Clear notifications": "Sfeḍ ilɣuyen", "Off": "Insa", - "Display Name": "Mefffer isem", + "Display Name": "Sken isem", "Save": "Sekles", - "Disconnect": "Yeffeɣ", + "Disconnect": "Ffeɣ seg tuqqna", "Go back": "Uɣal ɣer deffir", - "Change": "Changer", + "Change": "Beddel", "Theme": "Asentel", "Success": "Yedda", "Profile": "Amaɣnu", @@ -117,17 +117,17 @@ "Keyboard Shortcuts": "Inegzumen n unasiw", "Versions": "Ileqman", "None": "Ula yiwen", - "Unsubscribe": "Se désabonner", + "Unsubscribe": "Sefsex ajerred", "Ignore": "Ttu", "Subscribe": "Jerred", - "Preferences": "Tiwelhiwin", - "Composer": "Editeur", + "Preferences": "Ismenyifen", + "Composer": "Imsuddes", "Timeline": "Amazray", "Microphone": "Asawaḍ", "Camera": "Takamiṛatt", - "Sounds": "Imeslan", - "Reset": "Ales awennez", - "Browse": "Snirem", + "Sounds": "Imesla", + "Reset": "Wennez", + "Browse": "Inig", "Default role": "Tamlilt tamzwert", "Permissions": "Tisirag", "Anyone": "Yal yiwen", @@ -136,38 +136,38 @@ "Share": "Bḍu", "Verification code": "Tangalt n usenqed", "Add": "Rnu", - "Email Address": "Tansa imayl", + "Email Address": "Tansa n yimayl", "Phone Number": "Uṭṭun n tiliɣri", - "Upload file": "Azen afaylu", + "Upload file": "Sali-d afaylu", "Bold": "Azuran", - "Strikethrough": "Jerreḍ", - "Quote": "Citation", - "Loading...": "Yessalay-ed…", - "Idle": "Idle", - "Unknown": "D arussin", + "Strikethrough": "Derrer", + "Quote": "Tanebdurt", + "Loading...": "La d-yettali…", + "Idle": "Arurmid", + "Unknown": "Arussin", "Settings": "Iɣewwaren", "Search": "Nadi", "Favourites": "Ismenyifen", "People": "Imdanen", "Sign Up": "Jerred", - "Reject": "Aggi", + "Reject": "Agi", "Not now": "Mačči tura", - "Sort by": "Smizzwer s", + "Sort by": "Semyizwer s", "Activity": "Armud", "A-Z": "A-Z", "Show": "Sken", - "Options": "Tinefrunin", + "Options": "Tixtiṛiyin", "Server error": "Tuccḍa n uqeddac", - "Members": "Imedrawen", + "Members": "Imettekkiyen", "Files": "Ifuyla", - "Trusted": "De confiance", - "Invite": "Nced…", - "Unmute": "Susem", - "Mute": "Kkes imesli", - "Are you sure?": "Tebɣiḍ ?", + "Trusted": "Yettwattkal", + "Invite": "Nced", + "Unmute": "Rmed imesli", + "Mute": "Sens imesli", + "Are you sure?": "Tebɣiḍ s tidet?", "Security": "Taɣellist", "Yes": "Ih", - "Verified": "Verified", + "Verified": "Yettwasenqed", "Got it": "Awi-t", "Sunday": "Acer", "Monday": "Arim", @@ -178,48 +178,48 @@ "Saturday": "Sed", "Today": "Ass-a", "Yesterday": "Iḍelli", - "View Source": "Sken aɣbalu", + "View Source": "Wali aɣbalu", "Reply": "Err", "Edit": "Ẓreg", - "Attachment": "Attachement", - "Error decrypting attachment": "Tuccḍa deg uzmak n ufaylu yeddan", + "Attachment": "Taceqquft yeddan", + "Error decrypting attachment": "Tuccḍa deg uwgelhen n tceqquft yeddan", "Show all": "Sken akk", - "Message deleted": "Izen yettwakkes", - "Copied!": "Yettusukken!", - "edited": "yeẓreg", - "Food & Drink": "Učči aked tissit", + "Message deleted": "Izen yettwakksen", + "Copied!": "Yettwanɣel!", + "edited": "yettwaẓreg", + "Food & Drink": "Učči d tissit", "Objects": "Tiɣawsiwin", "Symbols": "Izamulen", "Flags": "Anayen", "Categories": "Taggayin", - "More options": "Ugar n textirin", - "Join": "Semlil", + "More options": "Ugar n textiṛiyin", + "Join": "Rnu", "No results": "Ulac igmad", - "collapse": "sneḍfes", - "Rotate counter-clockwise": "Zzi di tnila tanemgalt n tsegnatin n temrilt", - "Rotate clockwise": "Zzi di tnila n tsegnatin n temrilt", + "collapse": "fneẓ", + "Rotate counter-clockwise": "Zzi mgal tanila n tessegnatin n temrilt", + "Rotate clockwise": "Zzi almend n tnila n tsegnatin n temrilt", "Add User": "Rnu aseqdac", "Server name": "Isem n uqeddac", - "email address": "tansa imayl", - "Close dialog": "Mdel tanaka n usdiwen", - "Notes": "Tamawt", - "Unavailable": "Ulac-it", - "Changelog": "Aɣmis n ibeddilen", + "email address": "tansa n yimayl", + "Close dialog": "Mdel adiwenni", + "Notes": "Tamawin", + "Unavailable": "Ulac", + "Changelog": "Aɣmis n yisnifal", "Removing…": "Tukksa…", - "Confirm Removal": "Serggeg tukksa", + "Confirm Removal": "Sentem tukksa", "Example": "Amedya", "example": "amedya", "Create": "Snulfu-d", "Name": "Isem", - "Sign out": "Ffeɣ", - "Back": "Retour", + "Sign out": "Ffeɣ seg tuqqna", + "Back": "Uɣal ɣer deffir", "Send": "Azen", "Suggestions": "Isumar", "Go": "Ddu", - "Session name": "Nom de session", + "Session name": "Isem n tɣimit", "Send report": "Azen aneqqis", - "Refresh": "Sismeḍ", - "Email address": "Tansa email", + "Refresh": "Smiren", + "Email address": "Tansa n yimayl", "Skip": "Zgel", "Username not available": "Ulac isem n useqdac", "Checking...": "Asenqed...", @@ -227,21 +227,21 @@ "Terms of Service": "Tiwtilin n useqdec", "Service": "Ameẓlu", "Summary": "Agzul", - "Document": "isemli", - "Next": "Ar zdat", - "Upload files": "Azen ifuyla", - "Appearance": "Udem", + "Document": "Isemli", + "Next": "Γer sdat", + "Upload files": "Sali-d ifuyla", + "Appearance": "Arwes", "Allow": "Sireg", - "Deny": "Agwi", - "Custom": "Personnalisé", - "Source URL": "URL aγbalu", - "Notification settings": "Iɣewwaṛen n yilɣa", + "Deny": "Agi", + "Custom": "Sagen", + "Source URL": "URL aɣbalu", + "Notification settings": "Iɣewwaren n yilɣa", "Leave": "Ffeɣ", - "Set status": "Sbadu addad", + "Set status": "Sbadu addaden", "Hide": "Ffer", "Home": "Agejdan", "Sign in": "Qqen", - "Help": "Tallelt", + "Help": "Tallalt", "Reload": "Smiren", "powered by Matrix": "s lmendad n Matrix", "Custom Server Options": "Iɣewwaren n uqeddac udmawan", @@ -250,40 +250,40 @@ "Email": "Imayl", "Username": "Isem n useqdac", "Phone": "Tiliɣri", - "Passwords don't match": "Awal uffiren ur menṭaḍen ara", + "Passwords don't match": "Awalen uffiren ur mṣadan ara", "Email (optional)": "Imayl (Afrayan)", "Register": "Jerred", "Free": "Ilelli", - "Failed to upload image": "Ur yezmir ad yessali tugna", - "Description": "Aseglem", + "Failed to upload image": "Tegguma ad d-tali tugna", + "Description": "Aglam", "Explore": "Snirem", "Filter": "Imsizdeg", "Explore rooms": "Snirem tixxamin", - "Unknown error": "Erreur inconnue", - "Logout": "Tufɣa", - "Preview": "Timeẓriwt", - "View": "Ɣeṛ", - "Guest": "Inebgi", - "Your profile": "Amaɣnu-ik", - "Feedback": "Tikti", - "Your password has been reset.": "Awal n uɛeddi inek yules awennez.", - "Syncing...": "Amtawi", + "Unknown error": "Tuccḍa tarussint", + "Logout": "Tuffɣa", + "Preview": "Taskant", + "View": "Sken", + "Guest": "Anerzaf", + "Your profile": "Amaɣnu-ik/im", + "Feedback": "Takti", + "Your password has been reset.": "Awal uffir-inek/inem yettuwennez.", + "Syncing...": "Amtawi...", "Create account": "Rnu amiḍan", - "Create your account": "Rnu amiḍan-ik", - "Go Back": "Précédent", + "Create your account": "Rnu amiḍan-ik/im", + "Go Back": "Uɣal ɣer deffir", "Commands": "Tiludna", "Users": "Iseqdacen", "Export": "Sifeḍ", "Import": "Kter", "Restore": "Err-d", - "Copy": "Nγel", - "Download": "Sider", - "Retry": "Ɛreḍ tikkelt nniḍen", + "Copy": "Nɣel", + "Download": "Sader", + "Retry": "Ɛreḍ tikkelt-nniḍen", "Starting backup...": "Asenker n uḥraz...", "Success!": "Akka d rrbeḥ !", - "Disable": "Désactiver", + "Disable": "Sens", "Navigation": "Tunigin", - "Calls": "Appels", + "Calls": "isawalen", "Alt": "Alt", "Shift": "Shift", "New line": "Izirig amaynut", @@ -346,5 +346,120 @@ "Cancel replying to a message": "Sefsex tiririt ɣef yizen", "Toggle microphone mute": "Rmed/sens tanusi n usawaḍ", "Toggle video on/off": "Rmed/sens tavidyut", - "Scroll up/down in the timeline": "Drurem gar afellay/addday n tesnakudt" + "Scroll up/down in the timeline": "Drurem gar afellay/addday n tesnakudt", + "Updating Riot": "Leqqem Riot", + "I don't want my encrypted messages": "Ur bɣiɣ ara izan-inu iwgelhanen", + "Manually export keys": "Sifeḍ s ufus tisura", + "Session ID": "Asulay n tqimit", + "Session key": "Tasarut n tɣimit", + "Please check your email and click on the link it contains. Once this is done, click continue.": "Ma ulac aɣilif, senqed imayl-ik/im syen sit ɣef useɣwen i yellan. Akken ara yemmed waya, sit ad tkemmleḍ.", + "This will allow you to reset your password and receive notifications.": "Ayagi ad ak(akem)-yeǧǧ ad twennzeḍ awal-ik/im uffir yerna ad d-tremseḍ ilɣa.", + "A username can only contain lower case letters, numbers and '=_-./'": "Isem n useqdac yezmer kan ad yegber isekkilen imeẓyanen, izwilen neɣ '=_-./'", + "Username invalid: %(errMessage)s": "Isem n useqdac d armeɣtu", + "An error occurred: %(error_string)s": "Tella-d tuccḍa: %(error_string)s", + "To get started, please pick a username!": "I wakken ad tebduḍ, ttxil-k/m fren isem n useqdac!", + "This will be your account name on the homeserver, or you can pick a different server.": "Wagi ad yili d isem-ik/im deg usebter agejdan, neɣ tzemreḍ ad tferneḍ aqeddac-nniḍen.", + "If you already have a Matrix account you can log in instead.": "Ma yella tesεiḍ yakan amiḍan di Matrix, tzemreḍ ad tkecmeḍ deg umḍq-nni.", + "Call Failed": "Ur iddi ara usiwel", + "Call Timeout": "Akud n uṛaǧu n usiwel", + "Try using turn.matrix.org": "Ɛreḍ aseqdec n turn.matrix.org", + "Unable to capture screen": "Tuṭṭfa n ugdil ulamek", + "Existing Call": "Asiwel amiran", + "You are already in a call.": "Aql-ak(qkem)-id yakan tessawaleḍ.", + "VoIP is unsupported": "VoIP ur tettusefrak ara", + "You cannot place VoIP calls in this browser.": "Ur tezmireḍ ara ad tesεeddiḍ asiwel VoIP deg yiminig-a.", + "You cannot place a call with yourself.": "Ur tezmireḍ ara a temsawaleḍ d yiman-ik.", + "Call in Progress": "Asiwel iteddu", + "A call is currently being placed!": "Yella usiwel ila iteddu!", + "A call is already in progress!": "Yella usiwel ila iteddun akka tura!", + "Replying With Files": "Tiririt s yifuyla", + "The file '%(fileName)s' failed to upload.": "Yegguma ad d-yali '%(fileName)s' ufaylu.", + "Upload Failed": "Asali ur yeddi ara", + "Enter passphrase": "Sekcem tafyirt tuffirt", + "Setting up keys": "Asebded n tsura", + "%(weekDayName)s %(time)s": "%(weekDayName)s %(time)s", + "%(weekDayName)s, %(monthName)s %(day)s %(time)s": "%(weekDayName)s, %(monthName)s %(day)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 %(fullYear)s %(time)s": "%(weekDayName)s, %(monthName)s %(day)s %(fullYear)s %(time)s", + "Who would you like to add to this community?": "Anwa i tebɣiḍ ad t-ternuḍ ɣer temɣiwent-a?", + "Name or Matrix ID": "Isem neɣ asulay n Matrix", + "Invite to Community": "Ɛreḍ-d ɣer temɣiwent", + "Room name or address": "Isem neɣ tansa n texxamt", + "Add to community": "Rnu ɣer temɣiwent", + "Unnamed Room": "Taxxamt war isem", + "The server does not support the room version specified.": "Aqeddac ur issefrek ara lqem n texxamt yettwafernen.", + "If you cancel now, you won't complete verifying the other user.": "Ma yella teffɣeḍ tura, asenqed n yiseqdacen-nniḍen ur ittemmed ara.", + "Cancel entering passphrase?": "Sefsex tafyirt tuffirt n uεeddi?", + "Warning: any person you add to a community will be publicly visible to anyone who knows the community ID": "Γur-k: yal amdan ara ternuḍ ɣer temɣiwent ad d-iban s wudem azayaz i yal yiwen yessnen asulay n temɣiwent", + "Invite new community members": "Nced-d imttekkiyen imaynuten ɣer temɣiwent", + "Which rooms would you like to add to this community?": "Anti tixxamin i tebɣiḍ adternuḍ i temɣiwent-a?", + "Add rooms to the community": "Rnu tixxamin ɣer temɣiwent", + "Failed to invite the following users to %(groupId)s:": "Ancad n yiseqdacen i d-iteddun %(groupId)s ur yeddi ara:", + "Failed to invite users to community": "Ancad n yiseqdacen ɣer temɣiwent ur yeddi ara", + "Failed to invite users to %(groupId)s": "Ancad n yiseqdacen ɣer %(groupId)s ur yedi ara", + "Failed to add the following rooms to %(groupId)s:": "Timerna n texxamin i d-iteddun ɣer %(groupId)s ur yedi ara:", + "Identity server has no terms of service": "Timagit n uqeddac ulac ɣer-sen iferdisen n umeẓlu", + "%(name)s is requesting verification": "%(name)s yesra asenqed", + "Riot does not have permission to send you notifications - please check your browser settings": "Riot ulac ɣer-s tisirag i tuzna n yilɣa - ttxil-k/m senqed iɣewwaren n yiminig-ik/im", + "Riot was not given permission to send notifications - please try again": "Riot ur d-yefk ara tisirag i tuzna n yilɣa - ttxil-k/m εreḍ tikkelt-nniḍen", + "Unable to enable Notifications": "Sens irmad n yilɣa", + "This email address was not found": "Tansa-a n yimayl ulac-it", + "Your email address does not appear to be associated with a Matrix ID on this Homeserver.": "Tansa-ik/im n yimayl ur d-tban ara akken ad tettwacudd d usulay Matrix deg usebter-a agejdan.", + "Sign In or Create Account": "Kcem ɣer neɣ rnu amiḍan", + "Use your account or create a new one to continue.": "Seqdec amiḍan-ik/im neɣ snulfu-d yiwen akken ad tkemmleḍ.", + "Custom (%(level)s)": "Sagen (%(level)s)", + "Failed to invite": "Ulamek i d-tnecdeḍ", + "You need to be able to invite users to do that.": "Tesriḍ ad tizmireḍ ad d-tnecdeḍ iseqdacen ad gen ayagi.", + "Failed to send request.": "Tuzna n usuter ur teddi ara.", + "This room is not recognised.": "Taxxamt-a ur tṣeggem ara.", + "You are not in this room.": "Ulac-ik/ikem deg texxamt-a.", + "You do not have permission to do that in this room.": "Ur tesεiḍ ara tasiregt ad tgeḍ ayagi deg texxamt-a.", + "Missing room_id in request": "Ixuṣṣ taxxamt_asulay deg usuter", + "Room %(roomId)s not visible": "Taxxamt %(roomId)s ur d-tban ara", + "Missing user_id in request": "Ixuṣṣ useqdac_asulay deg usuter", + "Command error": "Tuccḍa n tladna", + "/ddg is not a command": "/ddg mačči d taladna", + "Upgrades a room to a new version": "Leqqem taxxamt ɣer lqem amaynut", + "Error upgrading room": "Tuccḍa deg uleqqem n texxamt", + "Changes your avatar in this current room only": "Snifel avatar-ik/im deg texxamat-agi kan tamirant", + "Changes your avatar in all rooms": "Snifel avatar/ik/om deg yixxamin", + "Use an identity server": "Seqdec timagit n uqeddac", + "Joins room with given address": "Kcem ɣer texxamt s tansa i d-yettunefken", + "Leave room": "Ffeɣ seg texxamt", + "Ignores a user, hiding their messages from you": "Anef iuseqdac, ffer iznan-ines sɣur-k", + "Ignored user": "Aseqdac yettunfen", + "You are now ignoring %(userId)s": "Aql-ak tura tunfeḍ i %(userId)s", + "Command failed": "Taladna ur teddi ara", + "Could not find user in room": "Ur yettwaf ara useqdac deg texxamt", + "(no answer)": "(ulac tiririt)", + "New login. Was this you?": "Anekcam amaynut. D kečč/kemm?", + "Verify the new login accessing your account: %(name)s": "Senqed anekcam amaynut i ikecmen ɣer umiḍan-ik/im: %(name)s", + "What's new?": "D acu-t umaynut?", + "Upgrade your Riot": "Leqqem Riot inek/inem", + "A new version of Riot is available!": "Lqem amaynut n Riot yella!", + "You: %(message)s": "Kečč/kemm: %(message)s", + "There was an error joining the room": "Tella-d tuccḍa deg unekcum ɣer texxamt", + "Sorry, your homeserver is too old to participate in this room.": "Suref-aɣ, asebter-ik/im agejdan d aqbur aṭas akken ad yettekki deg texxamt-a.", + "Please contact your homeserver administrator.": "Ttxil-k/m nermes anedbal-ik/im n usebter agejdan.", + "Failed to join room": "Anekcum ɣer texxamt ur yeddi ara", + "Custom user status messages": "Sagen addaden n yiznan n useqdac", + "Support adding custom themes": "Tallalt n tmerna n yisental udmawanen", + "Use custom size": "Seqdec teɣzi tudmawant", + "Show avatar changes": "Sken isnifal n avatar", + "Always show encryption icons": "Sken yal tikkelt tignitin tiwgelhanen", + "Send typing notifications": "Azen ilɣa yettuszemlen", + "Show typing notifications": "Azen ilɣa yettuszemlen", + "Room Colour": "Initen n texxamt", + "Show developer tools": "Sken ifecka n uneflay", + "Whether or not you're using the Richtext mode of the Rich Text Editor": "Ama tseqdaceḍ askar-inek.inem n umaẓrag n uḍris anesbaɣur neɣ xaṭi", + "Whether you're using Riot on a device where touch is the primary input mechanism": "Γas ma tseqdaceḍ Riot inek.inem deg yibenk anida asami d ametwi agejdan n unekcum", + "Whether or not you're using the 'breadcrumbs' feature (avatars above the room list)": "Γas ma tseqdaceḍ tamahilt 'breadcrumbs' neɣ xaṭi(avatar nnig tebdert n texxamt)", + "Whether you're using Riot as an installed Progressive Web App": "Γas ma tseqdaceḍ Riot d asnas web n usfari i ibedden", + "The information being sent to us to help make Riot better includes:": "Talɣut i aɣ-d-yettwaznen ɣef tallalt n usnerni n Riot deg-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.": "Ma yili asebter-a degs talɣut tummilt, am texxamt neɣ aseqdac neɣ asulay n ugraw, isefka-a ad ttwakksen send ad ttwaznen i uqeddac.", + "Unable to load! Check your network connectivity and try again.": "Yegguma ad d-yali! Senqed tuqqna-inek.inem ɣer uzeṭṭa syen tεerḍeḍ tikkelt-nniḍen.", + "Failure to create room": "Timerna n texxamt ur teddi ara", + "If you cancel now, you won't complete verifying your other session.": "Ma yella teffɣeḍ tura, ur tessawaḍeḍ ara ad tesneqdeḍ akk tiɣimiyin-inek.inem.", + "If you cancel now, you won't complete your operation.": "Ma yella teffɣeḍ tura, tamhelt-ik.im ur tettemmed ara.", + "Show these rooms to non-members on the community page and room list?": "Sken tixxamin-a i wid ur nettekka ara deg usebter n temɣiwent d tebdert n texxamt?" } diff --git a/src/i18n/strings/ru.json b/src/i18n/strings/ru.json index cd5a320062..41ee56d5e7 100644 --- a/src/i18n/strings/ru.json +++ b/src/i18n/strings/ru.json @@ -2146,5 +2146,15 @@ "Messages in this room are end-to-end encrypted. Learn more & verify this user in their user profile.": "Сообщения в этой комнате зашифрованы сквозным шифрованием. Посмотрите подробности и подтвердите пользователя в его профиле.", "Send a Direct Message": "Отправить сообщение", "Light": "Светлая", - "Dark": "Темная" + "Dark": "Темная", + "Recent Conversations": "Недавние Диалоги", + "Suggestions": "Предложения", + "a key signature": "отпечаток ключа", + "Upload completed": "Отправка успешно завершена", + "Cancelled signature upload": "Отправка отпечатка отменена", + "Unable to upload": "Невозможно отправить", + "Signature upload success": "Отпечаток успешно отправлен", + "Signature upload failed": "Сбой отправки отпечатка", + "Room name or address": "Имя или адрес комнаты", + "Unrecognised room address:": "Не удалось найти адрес комнаты:" } diff --git a/src/i18n/strings/sq.json b/src/i18n/strings/sq.json index 1f8f2ba48c..ed5deada2d 100644 --- a/src/i18n/strings/sq.json +++ b/src/i18n/strings/sq.json @@ -2517,5 +2517,91 @@ "Create a Recovery Key to store encryption keys & secrets with your account data. If you lose access to this login you’ll need it to unlock your data.": "Krijoni një Kyç Rimarrjesh që të depozitoni kyçe & të fshehta fshehtëzimi me të dhënat e llogarisë tuaj. Nëse humbni këto kredenciale, do t’ju duhet të shkyçni të dhënat tuaja.", "Create a Recovery Key": "Krijoni një Kyç Rimarrjesh", "Upgrade your Recovery Key": "Përmirësoni Kyçin tuaj të Rimarrjeve", - "Store your Recovery Key": "Depozitoni Kyçin tuaj të Rimarrjeve" + "Store your Recovery Key": "Depozitoni Kyçin tuaj të Rimarrjeve", + "Use the improved room list (will refresh to apply changes)": "Përdor listën e përmirësuar të dhomave (do të rifreskohet, që të aplikohen ndryshimet)", + "Enable IRC layout option in the appearance tab": "Aktivizoni te skeda e dukjes mundësinë për skemë IRC", + "Use custom size": "Përdor madhësi vetjake", + "Hey you. You're the best!": "Hej, ju. S’u ka kush shokun!", + "Message layout": "Skemë mesazhesh", + "Compact": "Kompakte", + "Modern": "Moderne", + "Use a system font": "Përdor një palë shkronja sistemi", + "System font name": "Emër shkronjash sistemi", + "You joined the call": "U bëtë pjesë e thirrjes", + "%(senderName)s joined the call": "%(senderName)s u bë pjesë e thirrjes", + "Call in progress": "Thirrje në ecuri e sipër", + "You left the call": "E braktisët thirrjen", + "%(senderName)s left the call": "%(senderName)s e braktisi thirrjen", + "Call ended": "Thirrja përfundoi", + "You started a call": "Filluat një thirrje", + "%(senderName)s started a call": "%(senderName)s filluat një thirrje", + "Waiting for answer": "Po pritet për përgjigje", + "%(senderName)s is calling": "%(senderName)s po thërret", + "You created the room": "Krijuat dhomën", + "%(senderName)s created the room": "%(senderName)s krijoi dhomën", + "You made the chat encrypted": "E bëtë të fshehtëzuar fjalosjen", + "%(senderName)s made the chat encrypted": "%(senderName)s e bëri të fshehtëzuar fjalosjen", + "You made history visible to new members": "E bëtë historikun të dukshëm për anëtarë të rinj", + "%(senderName)s made history visible to new members": "%(senderName)s e bëri historikun të dukshëm për anëtarë të rinj", + "You made history visible to anyone": "E bëtë historikun të dukshëm për këdo", + "%(senderName)s made history visible to anyone": "%(senderName)s e bëri historikun të dukshëm për këdo", + "You made history visible to future members": "E bëtë historikun të dukshëm për anëtarë të ardhshëm", + "%(senderName)s made history visible to future members": "%(senderName)s e bëri historikun të dukshëm për anëtarë të ardhshëm", + "You were invited": "U ftuat", + "%(targetName)s was invited": "%(targetName)s u ftua", + "You left": "Dolët", + "%(targetName)s left": "%(targetName)s doli", + "You were kicked (%(reason)s)": "U përzutë (%(reason)s)", + "%(targetName)s was kicked (%(reason)s)": "%(targetName)s u përzu (%(reason)s)", + "You were kicked": "U përzutë", + "%(targetName)s was kicked": "%(targetName)s u përzu", + "You rejected the invite": "S’pranuat ftesën", + "%(targetName)s rejected the invite": "%(targetName)s s’pranoi ftesën", + "You were uninvited": "Ju shfuqizuan ftesën", + "%(targetName)s was uninvited": "%(targetName)s i shfuqizuan ftesën", + "You were banned (%(reason)s)": "U dëbuat (%(reason)s)", + "%(targetName)s was banned (%(reason)s)": "%(targetName)s u dëbua (%(reason)s)", + "You were banned": "U dëbuat", + "%(targetName)s was banned": "%(targetName)s u dëbua", + "You joined": "U bëtë pjesë", + "%(targetName)s joined": "%(targetName)s u bë pjesë", + "You changed your name": "Ndryshuat emrin", + "%(targetName)s changed their name": "%(targetName)s ndryshoi emrin e vet", + "You changed your avatar": "Ndryshuat avatarin tuaj", + "%(targetName)s changed their avatar": "%(targetName)s ndryshoi avatarin e vet", + "%(senderName)s %(emote)s": "%(senderName)s %(emote)s", + "%(senderName)s: %(message)s": "%(senderName)s: %(message)s", + "You changed the room name": "Ndryshuat emrin e dhomës", + "%(senderName)s changed the room name": "%(senderName)s ndryshoi emrin e dhomës", + "%(senderName)s: %(reaction)s": "%(senderName)s: %(reaction)s", + "%(senderName)s: %(stickerName)s": "%(senderName)s: %(stickerName)s", + "You uninvited %(targetName)s": "Shfuqizuat ftesën për %(targetName)s", + "%(senderName)s uninvited %(targetName)s": "%(senderName)s shfuqizoi ftesën për %(targetName)s", + "You invited %(targetName)s": "Ftuat %(targetName)s", + "%(senderName)s invited %(targetName)s": "%(senderName)s ftoi %(targetName)s", + "You changed the room topic": "Ndryshuat temën e dhomës", + "%(senderName)s changed the room topic": "%(senderName)s ndryshoi temën e dhomës", + "Use a more compact ‘Modern’ layout": "Përdorni një skemë ‘Modern’ më kompakte", + "The authenticity of this encrypted message can't be guaranteed on this device.": "Mirëfilltësia e këtij mesazhi të fshehtëzuar s’mund të garantohet në këtë pajisje.", + "Message deleted on %(date)s": "Mesazh i fshirë më %(date)s", + "Wrong file type": "Lloj i gabuar kartele", + "Wrong Recovery Key": "Kyç Rimarrjesh i Gabuar", + "Invalid Recovery Key": "Kyç Rimarrjesh i Pavlefshëm", + "Security Phrase": "Frazë Sigurie", + "Enter your Security Phrase or to continue.": "Që të vazhdohet, jepni Frazën tuaj të Sigurisë ose .", + "Security Key": "Kyç Sigurie", + "Use your Security Key to continue.": "Që të vazhdohet përdorni Kyçin tuaj të Sigurisë.", + "Safeguard against losing access to encrypted messages & data by backing up encryption keys on your server.": "Mbrohuni kundër humbjes së hyrjes në mesazhe & të dhëna të fshehtëzuara duke kopjeruajtur kyçe fshehtëzimi në shërbyesin tuaj.", + "Generate a Security Key": "Prodhoni një Kyç Sigurie", + "We’ll generate a Security Key for you to store somewhere safe, like a password manager or a safe.": "Do të prodhojmë për ju një Kyç Sigurie që ta depozitoni diku të parrezik, bie fjala në një përgjegjës fjalëkalimesh ose në një kasafortë.", + "Enter a Security Phrase": "Jepni një Frazë Sigurie", + "Use a secret phrase only you know, and optionally save a Security Key to use for backup.": "Jepni një frazë të fshehtë që e dini vetëm ju, dhe, në daçi, ruani një Kyç Sigurie për ta përdorur për kopjeruajtje.", + "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.": "Jepni një frazë sigurie që e dini vetëm ju, ngaqë përdoret për të mbrojtur të dhënat tuaja. Që të jeni të sigurt, s’duhet të ripërdorni fjalëkalimin e llogarisë tuaj.", + "Store your Security Key somewhere safe, like a password manager or a safe, as it’s used to safeguard your encrypted data.": "Depozitojeni Kyçin tuaj të Sigurisë diku të parrezik, bie fjala në një përgjegjës fjalëkalimesh ose në një kasafortë, ngaqë përdoret për të mbrojtur të dhënat tuaja të fshehtëzuara.", + "If you cancel now, you may lose encrypted messages & data if you lose access to your logins.": "Nëse e anuloni tani, mund të humbni mesazhe & të dhëna të fshehtëzuara, nëse humbni hyrjen te kredencialet tuaja të hyrjeve.", + "You can also set up Secure Backup & manage your keys in Settings.": "Mundeni edhe të ujdisni Kopjeruajtje të Sigurt & administroni kyçet tuaj që nga Rregullimet.", + "Set up Secure backup": "Ujdisni kopjeruajtje të Sigurt", + "Set a Security Phrase": "Caktoni një Frazë Sigurie", + "Confirm Security Phrase": "Ripohoni Frazë Sigurie", + "Save your Security Key": "Ruani Kyçin tuaj të Sigurisë" } diff --git a/src/i18n/strings/uk.json b/src/i18n/strings/uk.json index fb6657d6c1..207ed0a8e6 100644 --- a/src/i18n/strings/uk.json +++ b/src/i18n/strings/uk.json @@ -600,8 +600,8 @@ "Language and region": "Мова та регіон", "Account management": "Керування обліківкою", "Deactivating your account is a permanent action - be careful!": "Деактивація вашої обліківки є безповоротною дією — будьте обережні!", - "Deactivate Account": "Деактивувати обліківку", - "Deactivate account": "Деактивувати обліківку", + "Deactivate Account": "Знедіяти обліківку", + "Deactivate account": "Знедіяти обліківку", "Legal": "Правова інформація", "Credits": "Подяки", "For help with using Riot, click here.": "Якщо необхідна допомога у користуванні Riot'ом, клацніть тут.", @@ -624,7 +624,7 @@ "Link this email with your account in Settings to receive invites directly in Riot.": "Зв'яжіть цю е-пошту з вашою обліківкою у Налаштуваннях щоб отримувати сповіщення прямо у Riot.", "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 Riot.": "Використовувати сервер ідентифікації у Налаштуваннях щоб отримувати запрошення прямо у Riot.", - "Are you sure you want to deactivate your account? This is irreversible.": "Ви впевнені у тому, що бажаєте деактивувати вашу обліківку? Це є безповоротним.", + "Are you sure you want to deactivate your account? This is irreversible.": "Ви впевнені у тому, що бажаєте знедіяти вашу обліківку? Це є безповоротним.", "Confirm account deactivation": "Підтвердьте деактивацію обліківки", "To continue, please enter your password:": "Щоб продовжити, введіть, будь ласка, ваш пароль:", "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. Це призведе до виходу вашої обліківки з усіх кімнат та до видалення деталей вашої обліківки з вашого серверу ідентифікації. Ця дія є безповоротною.", @@ -660,5 +660,12 @@ "Room": "Кімната", "Failed to reject invite": "Не вдалось відхилити запрошення", "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": "У вас одне непрочитане сповіщення у попередній версії цієї кімнати." + "You have %(count)s unread notifications in a prior version of this room.|one": "У вас одне непрочитане сповіщення у попередній версії цієї кімнати.", + "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?": "Знедіювання цього користувача виведе їх з системи і унеможливить вхід у майбутньому. До того ж, вони залишать усі кімнати, в яких перебувають. Ця дія є безповоротною. Ви впевнені, що хочете знедіяти цього користувача?", + "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.": "Знедіювання вашої обліківки типово не призводить до забуття надісланих вами повідомлень. Якщо ви бажаєте щоб ми забули ваші повідомлення, поставте прапорець внизу.", + "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)": "Забудьте, будь ласка, усі надіслані мною повідомлення після знедіювання моєї обліківки. (Попередження: після цього майбутні користувачі бачитимуть неповні бесіди)" } diff --git a/src/i18n/strings/zh_Hant.json b/src/i18n/strings/zh_Hant.json index c89c049ae5..0400771bfb 100644 --- a/src/i18n/strings/zh_Hant.json +++ b/src/i18n/strings/zh_Hant.json @@ -2527,5 +2527,91 @@ "Upgrade your Recovery Key": "升級您的復原金鑰", "Store your Recovery Key": "儲存您的復原金鑰", "Use the improved room list (in development - will refresh to apply changes)": "使用改進的聊天室清單(開發中 ── 將會重新整理以套用變更)", - "Use the improved room list (will refresh to apply changes)": "使用改進的聊天室清單(將會重新整理以套用變更)" + "Use the improved room list (will refresh to apply changes)": "使用改進的聊天室清單(將會重新整理以套用變更)", + "Enable IRC layout option in the appearance tab": "在外觀分頁中啟用 IRC 佈局選項", + "Use custom size": "使用自訂大小", + "Hey you. You're the best!": "你是最棒的!", + "Message layout": "訊息佈局", + "Compact": "簡潔", + "Modern": "現代", + "Use a system font": "使用系統字型", + "System font name": "系統字型名稱", + "The authenticity of this encrypted message can't be guaranteed on this device.": "無法在此裝置上保證加密訊息的真實性。", + "You joined the call": "您加入了通話", + "%(senderName)s joined the call": "%(senderName)s 加入了通話", + "Call in progress": "通話進行中", + "You left the call": "您離開了通話", + "%(senderName)s left the call": "%(senderName)s 離開了通話", + "Call ended": "通話結束", + "You started a call": "您開始了通話", + "%(senderName)s started a call": "%(senderName)s 開始了通話", + "Waiting for answer": "正在等待回應", + "%(senderName)s is calling": "%(senderName)s 正在通話", + "You created the room": "您建立了聊天室", + "%(senderName)s created the room": "%(senderName)s 建立了聊天室", + "You made the chat encrypted": "您讓聊天加密", + "%(senderName)s made the chat encrypted": "%(senderName)s 讓聊天加密", + "You made history visible to new members": "您讓歷史紀錄對新成員可見", + "%(senderName)s made history visible to new members": "%(senderName)s 讓歷史紀錄對新成員可見", + "You made history visible to anyone": "您讓歷史紀錄對所有人可見", + "%(senderName)s made history visible to anyone": "%(senderName)s 讓歷史紀錄對所有人可見", + "You made history visible to future members": "您讓歷史紀錄對未來成員可見", + "%(senderName)s made history visible to future members": "%(senderName)s 讓歷史紀錄對未來成員可見", + "You were invited": "您被邀請", + "%(targetName)s was invited": "%(targetName)s 被邀請", + "You left": "您離開", + "%(targetName)s left": "%(targetName)s 離開", + "You were kicked (%(reason)s)": "您被踢除(%(reason)s)", + "%(targetName)s was kicked (%(reason)s)": "%(targetName)s 被踢除(%(reason)s)", + "You were kicked": "您被踢除", + "%(targetName)s was kicked": "%(targetName)s 被踢除", + "You rejected the invite": "您回絕了邀請", + "%(targetName)s rejected the invite": "%(targetName)s 回絕了邀請", + "You were uninvited": "您被取消邀請", + "%(targetName)s was uninvited": "%(targetName)s 被取消邀請", + "You were banned (%(reason)s)": "您被封鎖(%(reason)s)", + "%(targetName)s was banned (%(reason)s)": "%(targetName)s 被封鎖(%(reason)s)", + "You were banned": "您被封鎖", + "%(targetName)s was banned": "%(targetName)s 被封鎖", + "You joined": "您加入", + "%(targetName)s joined": "%(targetName)s 加入", + "You changed your name": "您變更了您的名稱", + "%(targetName)s changed their name": "%(targetName)s 變更了他們的名稱", + "You changed your avatar": "您變更了您的大頭貼", + "%(targetName)s changed their avatar": "%(targetName)s 變更了他們的大頭貼", + "%(senderName)s %(emote)s": "%(senderName)s %(emote)s", + "%(senderName)s: %(message)s": "%(senderName)s: %(message)s", + "You changed the room name": "您變更了聊天室名稱", + "%(senderName)s changed the room name": "%(senderName)s 變更了聊天室名稱", + "%(senderName)s: %(reaction)s": "%(senderName)s: %(reaction)s", + "%(senderName)s: %(stickerName)s": "%(senderName)s: %(stickerName)s", + "You uninvited %(targetName)s": "您取消邀請了 %(targetName)s", + "%(senderName)s uninvited %(targetName)s": "%(senderName)s 取消邀請了 %(targetName)s", + "You invited %(targetName)s": "您邀請了 %(targetName)s", + "%(senderName)s invited %(targetName)s": "%(senderName)s 邀請了 %(targetName)s", + "You changed the room topic": "您變更了聊天室主題", + "%(senderName)s changed the room topic": "%(senderName)s 變更了聊天室主題", + "New spinner design": "新的微調器設計", + "Use a more compact ‘Modern’ layout": "使用更簡潔的「現代」佈局", + "Message deleted on %(date)s": "訊息刪除於 %(date)s", + "Wrong file type": "錯誤的檔案類型", + "Wrong Recovery Key": "錯誤的復原金鑰", + "Invalid Recovery Key": "無效的復原金鑰", + "Security Phrase": "安全密語", + "Enter your Security Phrase or to continue.": "輸入您的安全密語或以繼續。", + "Security Key": "安全金鑰", + "Use your Security Key to continue.": "使用您的安全金鑰以繼續。", + "Safeguard against losing access to encrypted messages & data by backing up encryption keys on your server.": "透過備份您伺服器上的加密金鑰來防止失去對您已加密的訊息與資料的存取權。", + "Generate a Security Key": "生成加密金鑰", + "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 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.": "輸入僅有您知道的安全短語,其用於保護您的資料。安全起見,請勿重複使用您的帳號密碼。", + "Store your Security Key somewhere safe, like a password manager or a safe, as it’s used to safeguard your encrypted data.": "將您的安全金鑰存放在某個安全的地方,如密碼管理員或保險櫃,其用於保護您的加密資料。", + "If you cancel now, you may lose encrypted messages & data if you lose access to your logins.": "如果您現在取消,在您失去對您的登入的存取權時可能會遺失已加密的訊息與資料。", + "You can also set up Secure Backup & manage your keys in Settings.": "您也可以在設定中設定安全備份並管理您的金鑰。", + "Set up Secure backup": "設定安全備份", + "Set a Security Phrase": "設定安全密語", + "Confirm Security Phrase": "確認安全密語", + "Save your Security Key": "儲存您的安全金鑰" } diff --git a/src/notifications/ContentRules.js b/src/notifications/ContentRules.ts similarity index 69% rename from src/notifications/ContentRules.js rename to src/notifications/ContentRules.ts index 8c285220c7..a3ec017e37 100644 --- a/src/notifications/ContentRules.js +++ b/src/notifications/ContentRules.ts @@ -1,6 +1,6 @@ /* Copyright 2016 OpenMarket Ltd -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2019, 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. @@ -15,9 +15,17 @@ See the License for the specific language governing permissions and limitations under the License. */ -'use strict'; +import {PushRuleVectorState, State} from "./PushRuleVectorState"; +import {IExtendedPushRule, IPushRuleSet, IRuleSets} from "./types"; -import {PushRuleVectorState} from "./PushRuleVectorState"; +export interface IContentRules { + vectorState: State; + rules: IExtendedPushRule[]; + externalRules: IExtendedPushRule[]; +} + +export const SCOPE = "global"; +export const KIND = "content"; export class ContentRules { /** @@ -31,7 +39,7 @@ export class ContentRules { * externalRules: a list of other keyword rules, with states other than * vectorState */ - static parseContentRules(rulesets) { + static parseContentRules(rulesets: IRuleSets): IContentRules { // first categorise the keyword rules in terms of their actions const contentRules = this._categoriseContentRules(rulesets); @@ -51,59 +59,72 @@ export class ContentRules { if (contentRules.loud.length) { return { - vectorState: PushRuleVectorState.LOUD, + vectorState: State.Loud, rules: contentRules.loud, - externalRules: [].concat(contentRules.loud_but_disabled, contentRules.on, contentRules.on_but_disabled, contentRules.other), + externalRules: [ + ...contentRules.loud_but_disabled, + ...contentRules.on, + ...contentRules.on_but_disabled, + ...contentRules.other, + ], }; } else if (contentRules.loud_but_disabled.length) { return { - vectorState: PushRuleVectorState.OFF, + vectorState: State.Off, rules: contentRules.loud_but_disabled, - externalRules: [].concat(contentRules.on, contentRules.on_but_disabled, contentRules.other), + externalRules: [...contentRules.on, ...contentRules.on_but_disabled, ...contentRules.other], }; } else if (contentRules.on.length) { return { - vectorState: PushRuleVectorState.ON, + vectorState: State.On, rules: contentRules.on, - externalRules: [].concat(contentRules.on_but_disabled, contentRules.other), + externalRules: [...contentRules.on_but_disabled, ...contentRules.other], }; } else if (contentRules.on_but_disabled.length) { return { - vectorState: PushRuleVectorState.OFF, + vectorState: State.Off, rules: contentRules.on_but_disabled, externalRules: contentRules.other, }; } else { return { - vectorState: PushRuleVectorState.ON, + vectorState: State.On, rules: [], externalRules: contentRules.other, }; } } - static _categoriseContentRules(rulesets) { - const contentRules = {on: [], on_but_disabled: [], loud: [], loud_but_disabled: [], other: []}; + static _categoriseContentRules(rulesets: IRuleSets) { + const contentRules: Record<"on"|"on_but_disabled"|"loud"|"loud_but_disabled"|"other", IExtendedPushRule[]> = { + on: [], + on_but_disabled: [], + loud: [], + loud_but_disabled: [], + other: [], + }; + for (const kind in rulesets.global) { for (let i = 0; i < Object.keys(rulesets.global[kind]).length; ++i) { const r = rulesets.global[kind][i]; // check it's not a default rule - if (r.rule_id[0] === '.' || kind !== 'content') { + if (r.rule_id[0] === '.' || kind !== "content") { continue; } - r.kind = kind; // is this needed? not sure + // this is needed as we are flattening an object of arrays into a single array + r.kind = kind; switch (PushRuleVectorState.contentRuleVectorStateKind(r)) { - case PushRuleVectorState.ON: + case State.On: if (r.enabled) { contentRules.on.push(r); } else { contentRules.on_but_disabled.push(r); } break; - case PushRuleVectorState.LOUD: + case State.Loud: if (r.enabled) { contentRules.loud.push(r); } else { diff --git a/src/notifications/NotificationUtils.js b/src/notifications/NotificationUtils.ts similarity index 80% rename from src/notifications/NotificationUtils.js rename to src/notifications/NotificationUtils.ts index bf393da060..e3b7f66447 100644 --- a/src/notifications/NotificationUtils.js +++ b/src/notifications/NotificationUtils.ts @@ -1,6 +1,6 @@ /* Copyright 2016 OpenMarket Ltd -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2019, 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. @@ -15,7 +15,13 @@ See the License for the specific language governing permissions and limitations under the License. */ -'use strict'; +import {Action, Actions} from "./types"; + +interface IEncodedActions { + notify: boolean; + sound?: string; + highlight?: boolean; +} export class NotificationUtils { // Encodes a dictionary of { @@ -24,12 +30,12 @@ export class NotificationUtils { // "highlight: true/false, // } // to a list of push actions. - static encodeActions(action) { + static encodeActions(action: IEncodedActions) { const notify = action.notify; const sound = action.sound; const highlight = action.highlight; if (notify) { - const actions = ["notify"]; + const actions: Action[] = [Actions.Notify]; if (sound) { actions.push({"set_tweak": "sound", "value": sound}); } @@ -40,7 +46,7 @@ export class NotificationUtils { } return actions; } else { - return ["dont_notify"]; + return [Actions.DontNotify]; } } @@ -50,18 +56,18 @@ export class NotificationUtils { // "highlight: true/false, // } // If the actions couldn't be decoded then returns null. - static decodeActions(actions) { + static decodeActions(actions: Action[]): IEncodedActions { let notify = false; let sound = null; let highlight = false; for (let i = 0; i < actions.length; ++i) { const action = actions[i]; - if (action === "notify") { + if (action === Actions.Notify) { notify = true; - } else if (action === "dont_notify") { + } else if (action === Actions.DontNotify) { notify = false; - } else if (typeof action === 'object') { + } else if (typeof action === "object") { if (action.set_tweak === "sound") { sound = action.value; } else if (action.set_tweak === "highlight") { @@ -81,7 +87,7 @@ export class NotificationUtils { highlight = true; } - const result = {notify: notify, highlight: highlight}; + const result: IEncodedActions = { notify, highlight }; if (sound !== null) { result.sound = sound; } diff --git a/src/notifications/PushRuleVectorState.js b/src/notifications/PushRuleVectorState.ts similarity index 69% rename from src/notifications/PushRuleVectorState.js rename to src/notifications/PushRuleVectorState.ts index 263226ce1c..d33426cfc4 100644 --- a/src/notifications/PushRuleVectorState.js +++ b/src/notifications/PushRuleVectorState.ts @@ -1,6 +1,6 @@ /* Copyright 2016 OpenMarket Ltd -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2019, 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. @@ -15,43 +15,42 @@ See the License for the specific language governing permissions and limitations under the License. */ -'use strict'; - import {StandardActions} from "./StandardActions"; import {NotificationUtils} from "./NotificationUtils"; +import {IPushRule} from "./types"; + +export enum State { + /** The push rule is disabled */ + Off = "off", + /** The user will receive push notification for this rule */ + On = "on", + /** The user will receive push notification for this rule with sound and + highlight if this is legitimate */ + Loud = "loud", +} export class PushRuleVectorState { - // Backwards compatibility (things should probably be using .states instead) - static OFF = "off"; - static ON = "on"; - static LOUD = "loud"; + // Backwards compatibility (things should probably be using the enum above instead) + static OFF = State.Off; + static ON = State.On; + static LOUD = State.Loud; /** * Enum for state of a push rule as defined by the Vector UI. * @readonly * @enum {string} */ - static states = { - /** The push rule is disabled */ - OFF: PushRuleVectorState.OFF, - - /** The user will receive push notification for this rule */ - ON: PushRuleVectorState.ON, - - /** The user will receive push notification for this rule with sound and - highlight if this is legitimate */ - LOUD: PushRuleVectorState.LOUD, - }; + static states = State; /** * Convert a PushRuleVectorState to a list of actions * * @return [object] list of push-rule actions */ - static actionsFor(pushRuleVectorState) { - if (pushRuleVectorState === PushRuleVectorState.ON) { + static actionsFor(pushRuleVectorState: State) { + if (pushRuleVectorState === State.On) { return StandardActions.ACTION_NOTIFY; - } else if (pushRuleVectorState === PushRuleVectorState.LOUD) { + } else if (pushRuleVectorState === State.Loud) { return StandardActions.ACTION_HIGHLIGHT_DEFAULT_SOUND; } } @@ -63,7 +62,7 @@ export class PushRuleVectorState { * category or in PushRuleVectorState.LOUD, regardless of its enabled * state. Returns null if it does not match these categories. */ - static contentRuleVectorStateKind(rule) { + static contentRuleVectorStateKind(rule: IPushRule): State { const decoded = NotificationUtils.decodeActions(rule.actions); if (!decoded) { @@ -81,10 +80,10 @@ export class PushRuleVectorState { let stateKind = null; switch (tweaks) { case 0: - stateKind = PushRuleVectorState.ON; + stateKind = State.On; break; case 2: - stateKind = PushRuleVectorState.LOUD; + stateKind = State.Loud; break; } return stateKind; diff --git a/src/notifications/StandardActions.js b/src/notifications/StandardActions.ts similarity index 98% rename from src/notifications/StandardActions.js rename to src/notifications/StandardActions.ts index b54cea332a..c17010af9a 100644 --- a/src/notifications/StandardActions.js +++ b/src/notifications/StandardActions.ts @@ -15,8 +15,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -'use strict'; - import {NotificationUtils} from "./NotificationUtils"; const encodeActions = NotificationUtils.encodeActions; diff --git a/src/notifications/types.ts b/src/notifications/types.ts new file mode 100644 index 0000000000..9622193740 --- /dev/null +++ b/src/notifications/types.ts @@ -0,0 +1,111 @@ +/* +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. +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 NotificationSetting { + AllMessages = "all_messages", // .m.rule.message = notify + DirectMessagesMentionsKeywords = "dm_mentions_keywords", // .m.rule.message = mark_unread. This is the new default. + MentionsKeywordsOnly = "mentions_keywords", // .m.rule.message = mark_unread; .m.rule.room_one_to_one = mark_unread + Never = "never", // .m.rule.master = enabled (dont_notify) +} + +export interface ISoundTweak { + set_tweak: "sound"; + value: string; +} +export interface IHighlightTweak { + set_tweak: "highlight"; + value?: boolean; +} + +export type Tweak = ISoundTweak | IHighlightTweak; + +export enum Actions { + Notify = "notify", + DontNotify = "dont_notify", // no-op + Coalesce = "coalesce", // unused + MarkUnread = "mark_unread", // new +} + +export type Action = Actions | Tweak; + +// Push rule kinds in descending priority order +export enum Kind { + Override = "override", + ContentSpecific = "content", + RoomSpecific = "room", + SenderSpecific = "sender", + Underride = "underride", +} + +export interface IEventMatchCondition { + kind: "event_match"; + key: string; + pattern: string; +} + +export interface IContainsDisplayNameCondition { + kind: "contains_display_name"; +} + +export interface IRoomMemberCountCondition { + kind: "room_member_count"; + is: string; +} + +export interface ISenderNotificationPermissionCondition { + kind: "sender_notification_permission"; + key: string; +} + +export type Condition = + IEventMatchCondition | + IContainsDisplayNameCondition | + IRoomMemberCountCondition | + ISenderNotificationPermissionCondition; + +export enum RuleIds { + MasterRule = ".m.rule.master", // The master rule (all notifications disabling) + MessageRule = ".m.rule.message", + EncryptedMessageRule = ".m.rule.encrypted", + RoomOneToOneRule = ".m.rule.room_one_to_one", + EncryptedRoomOneToOneRule = ".m.rule.room_one_to_one", +} + +export interface IPushRule { + enabled: boolean; + rule_id: RuleIds | string; + actions: Action[]; + default: boolean; + conditions?: Condition[]; // only applicable to `underride` and `override` rules + pattern?: string; // only applicable to `content` rules +} + +// push rule extended with kind, used by ContentRules and js-sdk's pushprocessor +export interface IExtendedPushRule extends IPushRule { + kind: Kind; +} + +export interface IPushRuleSet { + override: IPushRule[]; + content: IPushRule[]; + room: IPushRule[]; + sender: IPushRule[]; + underride: IPushRule[]; +} + +export interface IRuleSets { + global: IPushRuleSet; +} diff --git a/src/settings/Settings.js b/src/settings/Settings.js index ca8647e067..58d9ed4f31 100644 --- a/src/settings/Settings.js +++ b/src/settings/Settings.js @@ -30,6 +30,8 @@ import PushToMatrixClientController from './controllers/PushToMatrixClientContro import ReloadOnChangeController from "./controllers/ReloadOnChangeController"; import {RIGHT_PANEL_PHASES} from "../stores/RightPanelStorePhases"; import FontSizeController from './controllers/FontSizeController'; +import SystemFontController from './controllers/SystemFontController'; +import UseSystemFontController from './controllers/UseSystemFontController'; // These are just a bunch of helper arrays to avoid copy/pasting a bunch of times const LEVELS_ROOM_SETTINGS = ['device', 'room-device', 'room-account', 'account', 'config']; @@ -95,6 +97,12 @@ export const SETTINGS = { // // not use this for new settings. // invertedSettingName: "my-negative-setting", // }, + "feature_new_spinner": { + isFeature: true, + displayName: _td("New spinner design"), + supportedLevels: LEVELS_FEATURE, + default: false, + }, "feature_font_scaling": { isFeature: true, displayName: _td("Font scaling"), @@ -142,7 +150,7 @@ export const SETTINGS = { isFeature: true, displayName: _td("Use the improved room list (will refresh to apply changes)"), supportedLevels: LEVELS_FEATURE, - default: false, + default: true, controller: new ReloadOnChangeController(), }, "feature_custom_themes": { @@ -188,9 +196,14 @@ export const SETTINGS = { default: true, invertedSettingName: 'MessageComposerInput.dontSuggestEmoji', }, + // TODO: Wire up appropriately to UI (FTUE notifications) + "Notifications.alwaysShowBadgeCounts": { + supportedLevels: LEVELS_ROOM_OR_ACCOUNT, + default: false, + }, "useCompactLayout": { - supportedLevels: LEVELS_ACCOUNT_SETTINGS, - displayName: _td('Use compact timeline layout'), + supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS, + displayName: _td('Use a more compact ‘Modern’ layout'), default: false, }, "showRedactions": { @@ -314,6 +327,18 @@ export const SETTINGS = { default: true, displayName: _td("Match system theme"), }, + "useSystemFont": { + supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS, + default: false, + displayName: _td("Use a system font"), + controller: new UseSystemFontController(), + }, + "systemFont": { + supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS, + default: "", + displayName: _td("System font name"), + controller: new SystemFontController(), + }, "webRtcAllowPeerToPeer": { supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS_WITH_CONFIG, displayName: _td('Allow Peer-to-Peer for 1:1 calls'), @@ -453,11 +478,13 @@ export const SETTINGS = { deny: [], }, }, + // TODO: Remove setting: https://github.com/vector-im/riot-web/issues/14231 "RoomList.orderAlphabetically": { supportedLevels: LEVELS_ACCOUNT_SETTINGS, displayName: _td("Order rooms by name"), default: false, }, + // TODO: Remove setting: https://github.com/vector-im/riot-web/issues/14231 "RoomList.orderByImportance": { supportedLevels: LEVELS_ACCOUNT_SETTINGS, displayName: _td("Show rooms with unread notifications first"), diff --git a/src/settings/WatchManager.js b/src/settings/WatchManager.js index 472b13966f..3f54ca929e 100644 --- a/src/settings/WatchManager.js +++ b/src/settings/WatchManager.js @@ -51,8 +51,17 @@ export class WatchManager { const roomWatchers = this._watchers[settingName]; const callbacks = []; - if (inRoomId !== null && roomWatchers[inRoomId]) callbacks.push(...roomWatchers[inRoomId]); - if (roomWatchers[null]) callbacks.push(...roomWatchers[null]); + if (inRoomId !== null && roomWatchers[inRoomId]) { + callbacks.push(...roomWatchers[inRoomId]); + } + + if (!inRoomId) { + // Fire updates to all the individual room watchers too, as they probably + // care about the change higher up. + callbacks.push(...Object.values(roomWatchers).reduce((r, a) => [...r, ...a], [])); + } else if (roomWatchers[null]) { + callbacks.push(...roomWatchers[null]); + } for (const callback of callbacks) { callback(inRoomId, atLevel, newValueAtLevel); diff --git a/src/settings/controllers/FontSizeController.js b/src/settings/controllers/FontSizeController.ts similarity index 80% rename from src/settings/controllers/FontSizeController.js rename to src/settings/controllers/FontSizeController.ts index 3ef01ab99b..6440fd32fe 100644 --- a/src/settings/controllers/FontSizeController.js +++ b/src/settings/controllers/FontSizeController.ts @@ -16,6 +16,8 @@ limitations under the License. import SettingController from "./SettingController"; import dis from "../../dispatcher/dispatcher"; +import { UpdateFontSizePayload } from "../../dispatcher/payloads/UpdateFontSizePayload"; +import { Action } from "../../dispatcher/actions"; export default class FontSizeController extends SettingController { constructor() { @@ -24,8 +26,8 @@ export default class FontSizeController extends SettingController { onChange(level, roomId, newValue) { // Dispatch font size change so that everything open responds to the change. - dis.dispatch({ - action: "update-font-size", + dis.dispatch({ + action: Action.UpdateFontSize, size: newValue, }); } diff --git a/src/settings/controllers/SystemFontController.ts b/src/settings/controllers/SystemFontController.ts new file mode 100644 index 0000000000..4f591efc17 --- /dev/null +++ b/src/settings/controllers/SystemFontController.ts @@ -0,0 +1,36 @@ +/* +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. +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 SettingController from "./SettingController"; +import SettingsStore from "../SettingsStore"; +import dis from "../../dispatcher/dispatcher"; +import { UpdateSystemFontPayload } from "../../dispatcher/payloads/UpdateSystemFontPayload"; +import { Action } from "../../dispatcher/actions"; + +export default class SystemFontController extends SettingController { + constructor() { + super(); + } + + onChange(level, roomId, newValue) { + // Dispatch font size change so that everything open responds to the change. + dis.dispatch({ + action: Action.UpdateSystemFont, + useSystemFont: SettingsStore.getValue("useSystemFont"), + font: newValue, + }); + } +} diff --git a/src/settings/controllers/UseSystemFontController.ts b/src/settings/controllers/UseSystemFontController.ts new file mode 100644 index 0000000000..d598b25962 --- /dev/null +++ b/src/settings/controllers/UseSystemFontController.ts @@ -0,0 +1,36 @@ +/* +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. +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 SettingController from "./SettingController"; +import SettingsStore from "../SettingsStore"; +import dis from "../../dispatcher/dispatcher"; +import { UpdateSystemFontPayload } from "../../dispatcher/payloads/UpdateSystemFontPayload"; +import { Action } from "../../dispatcher/actions"; + +export default class UseSystemFontController extends SettingController { + constructor() { + super(); + } + + onChange(level, roomId, newValue) { + // Dispatch font size change so that everything open responds to the change. + dis.dispatch({ + action: Action.UpdateSystemFont, + useSystemFont: newValue, + font: SettingsStore.getValue("systemFont"), + }); + } +} diff --git a/src/settings/handlers/AccountSettingsHandler.js b/src/settings/handlers/AccountSettingsHandler.js index fea2e92c62..732ce6c550 100644 --- a/src/settings/handlers/AccountSettingsHandler.js +++ b/src/settings/handlers/AccountSettingsHandler.js @@ -18,6 +18,7 @@ limitations under the License. import {MatrixClientPeg} from '../../MatrixClientPeg'; import MatrixClientBackedSettingsHandler from "./MatrixClientBackedSettingsHandler"; import {SettingLevel} from "../SettingsStore"; +import {objectClone, objectKeyChanges} from "../../utils/objects"; const BREADCRUMBS_LEGACY_EVENT_TYPE = "im.vector.riot.breadcrumb_rooms"; const BREADCRUMBS_EVENT_TYPE = "im.vector.setting.breadcrumbs"; @@ -45,7 +46,7 @@ export default class AccountSettingsHandler extends MatrixClientBackedSettingsHa newClient.on("accountData", this._onAccountData); } - _onAccountData(event) { + _onAccountData(event, prevEvent) { if (event.getType() === "org.matrix.preview_urls") { let val = event.getContent()['disable']; if (typeof(val) !== "boolean") { @@ -56,8 +57,10 @@ export default class AccountSettingsHandler extends MatrixClientBackedSettingsHa this._watchers.notifyUpdate("urlPreviewsEnabled", null, SettingLevel.ACCOUNT, val); } else if (event.getType() === "im.vector.web.settings") { - // We can't really discern what changed, so trigger updates for everything - for (const settingName of Object.keys(event.getContent())) { + // Figure out what changed and fire those updates + const prevContent = prevEvent ? prevEvent.getContent() : {}; + const changedSettings = objectKeyChanges(prevContent, event.getContent()); + for (const settingName of changedSettings) { const val = event.getContent()[settingName]; this._watchers.notifyUpdate(settingName, null, SettingLevel.ACCOUNT, val); } @@ -159,7 +162,7 @@ export default class AccountSettingsHandler extends MatrixClientBackedSettingsHa const event = cli.getAccountData(eventType); if (!event || !event.getContent()) return null; - return event.getContent(); + return objectClone(event.getContent()); // clone to prevent mutation } _notifyBreadcrumbsUpdate(event) { diff --git a/src/settings/handlers/MatrixClientBackedSettingsHandler.js b/src/settings/handlers/MatrixClientBackedSettingsHandler.js index effe7ae9a7..63725b4dff 100644 --- a/src/settings/handlers/MatrixClientBackedSettingsHandler.js +++ b/src/settings/handlers/MatrixClientBackedSettingsHandler.js @@ -42,6 +42,10 @@ export default class MatrixClientBackedSettingsHandler extends SettingsHandler { MatrixClientBackedSettingsHandler._instances.push(this); } + get client() { + return MatrixClientBackedSettingsHandler._matrixClient; + } + initMatrixClient() { console.warn("initMatrixClient not overridden"); } diff --git a/src/settings/handlers/RoomAccountSettingsHandler.js b/src/settings/handlers/RoomAccountSettingsHandler.js index 1e9d3f7bed..b2af81779b 100644 --- a/src/settings/handlers/RoomAccountSettingsHandler.js +++ b/src/settings/handlers/RoomAccountSettingsHandler.js @@ -18,6 +18,7 @@ limitations under the License. import {MatrixClientPeg} from '../../MatrixClientPeg'; import MatrixClientBackedSettingsHandler from "./MatrixClientBackedSettingsHandler"; import {SettingLevel} from "../SettingsStore"; +import {objectClone, objectKeyChanges} from "../../utils/objects"; const ALLOWED_WIDGETS_EVENT_TYPE = "im.vector.setting.allowed_widgets"; @@ -40,7 +41,7 @@ export default class RoomAccountSettingsHandler extends MatrixClientBackedSettin newClient.on("Room.accountData", this._onAccountData); } - _onAccountData(event, room) { + _onAccountData(event, room, prevEvent) { const roomId = room.roomId; if (event.getType() === "org.matrix.room.preview_urls") { @@ -55,8 +56,10 @@ export default class RoomAccountSettingsHandler extends MatrixClientBackedSettin } else if (event.getType() === "org.matrix.room.color_scheme") { this._watchers.notifyUpdate("roomColor", roomId, SettingLevel.ROOM_ACCOUNT, event.getContent()); } else if (event.getType() === "im.vector.web.settings") { - // We can't really discern what changed, so trigger updates for everything - for (const settingName of Object.keys(event.getContent())) { + // Figure out what changed and fire those updates + const prevContent = prevEvent ? prevEvent.getContent() : {}; + const changedSettings = objectKeyChanges(prevContent, event.getContent()); + for (const settingName of changedSettings) { const val = event.getContent()[settingName]; this._watchers.notifyUpdate(settingName, roomId, SettingLevel.ROOM_ACCOUNT, val); } @@ -134,6 +137,6 @@ export default class RoomAccountSettingsHandler extends MatrixClientBackedSettin const event = room.getAccountData(eventType); if (!event || !event.getContent()) return null; - return event.getContent(); + return objectClone(event.getContent()); // clone to prevent mutation } } diff --git a/src/settings/handlers/RoomSettingsHandler.js b/src/settings/handlers/RoomSettingsHandler.js index 6407818450..d8e775742c 100644 --- a/src/settings/handlers/RoomSettingsHandler.js +++ b/src/settings/handlers/RoomSettingsHandler.js @@ -18,6 +18,7 @@ limitations under the License. import {MatrixClientPeg} from '../../MatrixClientPeg'; import MatrixClientBackedSettingsHandler from "./MatrixClientBackedSettingsHandler"; import {SettingLevel} from "../SettingsStore"; +import {objectClone, objectKeyChanges} from "../../utils/objects"; /** * Gets and sets settings at the "room" level. @@ -38,8 +39,15 @@ export default class RoomSettingsHandler extends MatrixClientBackedSettingsHandl newClient.on("RoomState.events", this._onEvent); } - _onEvent(event) { + _onEvent(event, state, prevEvent) { const roomId = event.getRoomId(); + const room = this.client.getRoom(roomId); + + // Note: the tests often fire setting updates that don't have rooms in the store, so + // we fail softly here. We shouldn't assume that the state being fired is current + // state, but we also don't need to explode just because we didn't find a room. + if (!room) console.warn(`Unknown room caused setting update: ${roomId}`); + if (room && state !== room.currentState) return; // ignore state updates which are not current if (event.getType() === "org.matrix.room.preview_urls") { let val = event.getContent()['disable']; @@ -51,8 +59,10 @@ export default class RoomSettingsHandler extends MatrixClientBackedSettingsHandl this._watchers.notifyUpdate("urlPreviewsEnabled", roomId, SettingLevel.ROOM, val); } else if (event.getType() === "im.vector.web.settings") { - // We can't really discern what changed, so trigger updates for everything - for (const settingName of Object.keys(event.getContent())) { + // Figure out what changed and fire those updates + const prevContent = prevEvent ? prevEvent.getContent() : {}; + const changedSettings = objectKeyChanges(prevContent, event.getContent()); + for (const settingName of changedSettings) { this._watchers.notifyUpdate(settingName, roomId, SettingLevel.ROOM, event.getContent()[settingName]); } } @@ -107,6 +117,6 @@ export default class RoomSettingsHandler extends MatrixClientBackedSettingsHandl const event = room.currentState.getStateEvents(eventType, ""); if (!event || !event.getContent()) return null; - return event.getContent(); + return objectClone(event.getContent()); // clone to prevent mutation } } diff --git a/src/settings/watchers/FontWatcher.ts b/src/settings/watchers/FontWatcher.ts index 5527284cd0..9af5156704 100644 --- a/src/settings/watchers/FontWatcher.ts +++ b/src/settings/watchers/FontWatcher.ts @@ -18,6 +18,8 @@ import dis from '../../dispatcher/dispatcher'; import SettingsStore, {SettingLevel} from '../SettingsStore'; import IWatcher from "./Watcher"; import { toPx } from '../../utils/units'; +import { Action } from '../../dispatcher/actions'; +import { UpdateSystemFontPayload } from '../../dispatcher/payloads/UpdateSystemFontPayload'; export class FontWatcher implements IWatcher { public static readonly MIN_SIZE = 8; @@ -33,6 +35,10 @@ export class FontWatcher implements IWatcher { public start() { this.setRootFontSize(SettingsStore.getValue("baseFontSize")); + this.setSystemFont({ + useSystemFont: SettingsStore.getValue("useSystemFont"), + font: SettingsStore.getValue("systemFont"), + }); this.dispatcherRef = dis.register(this.onAction); } @@ -41,8 +47,10 @@ export class FontWatcher implements IWatcher { } private onAction = (payload) => { - if (payload.action === 'update-font-size') { + if (payload.action === Action.UpdateFontSize) { this.setRootFontSize(payload.size); + } else if (payload.action === Action.UpdateSystemFont) { + this.setSystemFont(payload); } }; @@ -54,4 +62,8 @@ export class FontWatcher implements IWatcher { } (document.querySelector(":root")).style.fontSize = toPx(fontSize); }; + + private setSystemFont = ({useSystemFont, font}) => { + document.body.style.fontFamily = useSystemFont ? font : ""; + }; } diff --git a/src/stores/BreadcrumbsStore.ts b/src/stores/BreadcrumbsStore.ts index 332fa7fe2e..c78f15c3b4 100644 --- a/src/stores/BreadcrumbsStore.ts +++ b/src/stores/BreadcrumbsStore.ts @@ -51,13 +51,13 @@ export class BreadcrumbsStore extends AsyncStoreWithClient { } public get visible(): boolean { - return this.state.enabled; + return this.state.enabled && this.matrixClient.getVisibleRooms().length >= 20; } protected async onAction(payload: ActionPayload) { if (!this.matrixClient) return; - // TODO: Remove when new room list is made the default + // TODO: Remove when new room list is made the default: https://github.com/vector-im/riot-web/issues/14231 if (!RoomListStoreTempProxy.isUsingNewStore()) return; if (payload.action === 'setting_updated') { @@ -80,7 +80,7 @@ export class BreadcrumbsStore extends AsyncStoreWithClient { } protected async onReady() { - // TODO: Remove when new room list is made the default + // TODO: Remove when new room list is made the default: https://github.com/vector-im/riot-web/issues/14231 if (!RoomListStoreTempProxy.isUsingNewStore()) return; await this.updateRooms(); @@ -91,7 +91,7 @@ export class BreadcrumbsStore extends AsyncStoreWithClient { } protected async onNotReady() { - // TODO: Remove when new room list is made the default + // TODO: Remove when new room list is made the default: https://github.com/vector-im/riot-web/issues/14231 if (!RoomListStoreTempProxy.isUsingNewStore()) return; this.matrixClient.removeListener("Room.myMembership", this.onMyMembership); diff --git a/src/stores/MessagePreviewStore.ts b/src/stores/MessagePreviewStore.ts deleted file mode 100644 index 64d65a72f3..0000000000 --- a/src/stores/MessagePreviewStore.ts +++ /dev/null @@ -1,135 +0,0 @@ -/* -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. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import { Room } from "matrix-js-sdk/src/models/room"; -import { ActionPayload } from "../dispatcher/payloads"; -import { AsyncStoreWithClient } from "./AsyncStoreWithClient"; -import defaultDispatcher from "../dispatcher/dispatcher"; -import { RoomListStoreTempProxy } from "./room-list/RoomListStoreTempProxy"; -import { textForEvent } from "../TextForEvent"; -import { MatrixEvent } from "matrix-js-sdk/src/models/event"; -import { _t } from "../languageHandler"; - -const PREVIEWABLE_EVENTS = [ - // This is the same list from RiotX - {type: "m.room.message", isState: false}, - {type: "m.room.name", isState: true}, - {type: "m.room.topic", isState: true}, - {type: "m.room.member", isState: true}, - {type: "m.room.history_visibility", isState: true}, - {type: "m.call.invite", isState: false}, - {type: "m.call.hangup", isState: false}, - {type: "m.call.answer", isState: false}, - {type: "m.room.encrypted", isState: false}, - {type: "m.room.encryption", isState: true}, - {type: "m.room.third_party_invite", isState: true}, - {type: "m.sticker", isState: false}, - {type: "m.room.create", isState: true}, -]; - -// The maximum number of events we're willing to look back on to get a preview. -const MAX_EVENTS_BACKWARDS = 50; - -interface IState { - [roomId: string]: string | null; // null indicates the preview is empty -} - -export class MessagePreviewStore extends AsyncStoreWithClient { - private static internalInstance = new MessagePreviewStore(); - - private constructor() { - super(defaultDispatcher, {}); - } - - public static get instance(): MessagePreviewStore { - return MessagePreviewStore.internalInstance; - } - - /** - * Gets the pre-translated preview for a given room - * @param room The room to get the preview for. - * @returns The preview, or null if none present. - */ - public getPreviewForRoom(room: Room): string { - if (!room) return null; // invalid room, just return nothing - - // It's faster to do a lookup this way than it is to use Object.keys().includes() - // We only want to generate a preview if there's one actually missing and not explicitly - // set as 'none'. - const val = this.state[room.roomId]; - if (val !== null && typeof(val) !== "string") { - this.generatePreview(room); - } - - return this.state[room.roomId]; - } - - private generatePreview(room: Room) { - const timeline = room.getLiveTimeline(); - if (!timeline) return; // usually only happens in tests - const events = timeline.getEvents(); - - for (let i = events.length - 1; i >= 0; i--) { - if (i === events.length - MAX_EVENTS_BACKWARDS) return; // limit reached - - const event = events[i]; - const preview = this.generatePreviewForEvent(event); - if (preview.isPreviewable) { - // noinspection JSIgnoredPromiseFromCall - the AsyncStore handles concurrent calls - this.updateState({[room.roomId]: preview.preview}); - return; // break - we found some text - } - } - - // if we didn't find anything, subscribe ourselves to an update - // noinspection JSIgnoredPromiseFromCall - the AsyncStore handles concurrent calls - this.updateState({[room.roomId]: null}); - } - - protected async onAction(payload: ActionPayload) { - if (!this.matrixClient) return; - - // TODO: Remove when new room list is made the default - if (!RoomListStoreTempProxy.isUsingNewStore()) return; - - if (payload.action === 'MatrixActions.Room.timeline' || payload.action === 'MatrixActions.Event.decrypted') { - const event = payload.event; // TODO: Type out the dispatcher - if (!Object.keys(this.state).includes(event.getRoomId())) return; // not important - - const preview = this.generatePreviewForEvent(event); - if (preview.isPreviewable) { - await this.updateState({[event.getRoomId()]: preview.preview}); - return; // break - we found some text - } - } - } - - private generatePreviewForEvent(event: MatrixEvent): { isPreviewable: boolean, preview: string } { - if (PREVIEWABLE_EVENTS.some(p => p.type === event.getType() && p.isState === event.isState())) { - const isSelf = event.getSender() === this.matrixClient.getUserId(); - let text = textForEvent(event, /*skipUserPrefix=*/isSelf); - if (!text || text.trim().length === 0) text = null; // force null if useless to us - if (text && isSelf) { - // XXX: i18n doesn't really work here if the language doesn't support prefixing. - // We'd ideally somehow route the `You:` bit to the textForEvent call, however - // threading that through is non-trivial. - text = _t("You: %(message)s", {message: text}); - } - return {isPreviewable: true, preview: text}; - } - return {isPreviewable: false, preview: null}; - } -} diff --git a/src/stores/OwnProfileStore.ts b/src/stores/OwnProfileStore.ts new file mode 100644 index 0000000000..45d8829e30 --- /dev/null +++ b/src/stores/OwnProfileStore.ts @@ -0,0 +1,122 @@ +/* +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. +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 { ActionPayload } from "../dispatcher/payloads"; +import { AsyncStoreWithClient } from "./AsyncStoreWithClient"; +import defaultDispatcher from "../dispatcher/dispatcher"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { User } from "matrix-js-sdk/src/models/user"; +import { throttle } from "lodash"; +import { MatrixClientPeg } from "../MatrixClientPeg"; +import { _t } from "../languageHandler"; + +interface IState { + displayName?: string; + avatarUrl?: string; +} + +export class OwnProfileStore extends AsyncStoreWithClient { + private static internalInstance = new OwnProfileStore(); + + private monitoredUser: User; + + private constructor() { + super(defaultDispatcher, {}); + } + + public static get instance(): OwnProfileStore { + return OwnProfileStore.internalInstance; + } + + /** + * Gets the display name for the user, or null if not present. + */ + public get displayName(): string { + if (!this.matrixClient) return this.state.displayName || null; + + if (this.matrixClient.isGuest()) { + return _t("Guest"); + } else if (this.state.displayName) { + return this.state.displayName; + } else { + return this.matrixClient.getUserId(); + } + } + + /** + * Gets the MXC URI of the user's avatar, or null if not present. + */ + public get avatarMxc(): string { + return this.state.avatarUrl || null; + } + + /** + * Gets the user's avatar as an HTTP URL of the given size. If the user's + * avatar is not present, this returns null. + * @param size The size of the avatar + * @returns The HTTP URL of the user's avatar + */ + public getHttpAvatarUrl(size: number): string { + if (!this.avatarMxc) return null; + return this.matrixClient.mxcUrlToHttp(this.avatarMxc, size, size); + } + + protected async onNotReady() { + if (this.monitoredUser) { + this.monitoredUser.removeListener("User.displayName", this.onProfileUpdate); + this.monitoredUser.removeListener("User.avatarUrl", this.onProfileUpdate); + } + if (this.matrixClient) { + this.matrixClient.removeListener("RoomState.events", this.onStateEvents); + } + await this.reset({}); + } + + protected async onReady() { + const myUserId = this.matrixClient.getUserId(); + this.monitoredUser = this.matrixClient.getUser(myUserId); + if (this.monitoredUser) { + this.monitoredUser.on("User.displayName", this.onProfileUpdate); + this.monitoredUser.on("User.avatarUrl", this.onProfileUpdate); + } + + // We also have to listen for membership events for ourselves as the above User events + // are fired only with presence, which matrix.org (and many others) has disabled. + this.matrixClient.on("RoomState.events", this.onStateEvents); + + await this.onProfileUpdate(); // trigger an initial update + } + + protected async onAction(payload: ActionPayload) { + // we don't actually do anything here + } + + private onProfileUpdate = async () => { + // We specifically do not use the User object we stored for profile info as it + // could easily be wrong (such as per-room instead of global profile). + const profileInfo = await this.matrixClient.getProfileInfo(this.matrixClient.getUserId()); + await this.updateState({displayName: profileInfo.displayname, avatarUrl: profileInfo.avatar_url}); + }; + + // TSLint wants this to be a member, but we don't want that. + // tslint:disable-next-line + private onStateEvents = throttle(async (ev: MatrixEvent) => { + const myUserId = MatrixClientPeg.get().getUserId(); + if (ev.getType() === 'm.room.member' && ev.getSender() === myUserId && ev.getStateKey() === myUserId) { + await this.onProfileUpdate(); + } + }, 200, {trailing: true, leading: true}); +} diff --git a/src/stores/ToastStore.ts b/src/stores/ToastStore.ts index 55c48c3937..7063ba541a 100644 --- a/src/stores/ToastStore.ts +++ b/src/stores/ToastStore.ts @@ -24,7 +24,7 @@ export interface IToast; + props?: Omit, "toastKey">; // toastKey is injected by ToastContainer } /** diff --git a/src/stores/notifications/INotificationState.ts b/src/stores/notifications/INotificationState.ts new file mode 100644 index 0000000000..65bd7b7957 --- /dev/null +++ b/src/stores/notifications/INotificationState.ts @@ -0,0 +1,26 @@ +/* +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. +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 { NotificationColor } from "./NotificationColor"; + +export const NOTIFICATION_STATE_UPDATE = "update"; + +export interface INotificationState extends EventEmitter { + symbol?: string; + count: number; + color: NotificationColor; +} diff --git a/src/stores/notifications/ListNotificationState.ts b/src/stores/notifications/ListNotificationState.ts new file mode 100644 index 0000000000..5773693b47 --- /dev/null +++ b/src/stores/notifications/ListNotificationState.ts @@ -0,0 +1,120 @@ +/* +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. +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 { INotificationState, NOTIFICATION_STATE_UPDATE } from "./INotificationState"; +import { NotificationColor } from "./NotificationColor"; +import { IDestroyable } from "../../utils/IDestroyable"; +import { TagID } from "../room-list/models"; +import { Room } from "matrix-js-sdk/src/models/room"; +import { arrayDiff } from "../../utils/arrays"; +import { RoomNotificationState } from "./RoomNotificationState"; +import { TagSpecificNotificationState } from "./TagSpecificNotificationState"; + +export class ListNotificationState extends EventEmitter implements IDestroyable, INotificationState { + private _count: number; + private _color: NotificationColor; + private rooms: Room[] = []; + private states: { [roomId: string]: RoomNotificationState } = {}; + + constructor(private byTileCount = false, private tagId: TagID) { + super(); + } + + public get symbol(): string { + return null; // This notification state doesn't support symbols + } + + public get count(): number { + return this._count; + } + + public get color(): NotificationColor { + return this._color; + } + + public setRooms(rooms: Room[]) { + // If we're only concerned about the tile count, don't bother setting up listeners. + if (this.byTileCount) { + this.rooms = rooms; + this.calculateTotalState(); + return; + } + + const oldRooms = this.rooms; + const diff = arrayDiff(oldRooms, rooms); + this.rooms = rooms; + for (const oldRoom of diff.removed) { + const state = this.states[oldRoom.roomId]; + if (!state) continue; // We likely just didn't have a badge (race condition) + delete this.states[oldRoom.roomId]; + state.off(NOTIFICATION_STATE_UPDATE, this.onRoomNotificationStateUpdate); + state.destroy(); + } + for (const newRoom of diff.added) { + const state = new TagSpecificNotificationState(newRoom, this.tagId); + state.on(NOTIFICATION_STATE_UPDATE, this.onRoomNotificationStateUpdate); + if (this.states[newRoom.roomId]) { + // "Should never happen" disclaimer. + console.warn("Overwriting notification state for room:", newRoom.roomId); + this.states[newRoom.roomId].destroy(); + } + this.states[newRoom.roomId] = state; + } + + this.calculateTotalState(); + } + + public getForRoom(room: Room) { + const state = this.states[room.roomId]; + if (!state) throw new Error("Unknown room for notification state"); + return state; + } + + public destroy() { + for (const state of Object.values(this.states)) { + state.destroy(); + } + this.states = {}; + } + + private onRoomNotificationStateUpdate = () => { + this.calculateTotalState(); + }; + + private calculateTotalState() { + const before = {count: this.count, symbol: this.symbol, color: this.color}; + + if (this.byTileCount) { + this._color = NotificationColor.Red; + this._count = this.rooms.length; + } else { + this._count = 0; + this._color = NotificationColor.None; + for (const state of Object.values(this.states)) { + this._count += state.count; + this._color = Math.max(this.color, state.color); + } + } + + // finally, publish an update if needed + const after = {count: this.count, symbol: this.symbol, color: this.color}; + if (JSON.stringify(before) !== JSON.stringify(after)) { + this.emit(NOTIFICATION_STATE_UPDATE); + } + } +} + diff --git a/src/stores/notifications/NotificationColor.ts b/src/stores/notifications/NotificationColor.ts new file mode 100644 index 0000000000..aa2384b3df --- /dev/null +++ b/src/stores/notifications/NotificationColor.ts @@ -0,0 +1,24 @@ +/* +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. +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 NotificationColor { + // Inverted (None -> Red) because we do integer comparisons on this + None, // nothing special + // TODO: Remove bold with notifications: https://github.com/vector-im/riot-web/issues/14227 + Bold, // no badge, show as unread + Grey, // unread notified messages + Red, // unread pings +} diff --git a/src/stores/notifications/RoomNotificationState.ts b/src/stores/notifications/RoomNotificationState.ts new file mode 100644 index 0000000000..f9b19fcbcb --- /dev/null +++ b/src/stores/notifications/RoomNotificationState.ts @@ -0,0 +1,144 @@ +/* +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. +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 { INotificationState, NOTIFICATION_STATE_UPDATE } from "./INotificationState"; +import { NotificationColor } from "./NotificationColor"; +import { IDestroyable } from "../../utils/IDestroyable"; +import { MatrixClientPeg } from "../../MatrixClientPeg"; +import { EffectiveMembership, getEffectiveMembership } from "../room-list/membership"; +import { readReceiptChangeIsFor } from "../../utils/read-receipts"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { Room } from "matrix-js-sdk/src/models/room"; +import * as RoomNotifs from '../../RoomNotifs'; +import * as Unread from '../../Unread'; + +export class RoomNotificationState extends EventEmitter implements IDestroyable, INotificationState { + private _symbol: string; + private _count: number; + private _color: NotificationColor; + + constructor(private room: Room) { + super(); + this.room.on("Room.receipt", this.handleReadReceipt); + this.room.on("Room.timeline", this.handleRoomEventUpdate); + this.room.on("Room.redaction", this.handleRoomEventUpdate); + MatrixClientPeg.get().on("Event.decrypted", this.handleRoomEventUpdate); + MatrixClientPeg.get().on("accountData", this.handleAccountDataUpdate); + this.updateNotificationState(); + } + + public get symbol(): string { + return this._symbol; + } + + public get count(): number { + return this._count; + } + + public get color(): NotificationColor { + return this._color; + } + + private get roomIsInvite(): boolean { + return getEffectiveMembership(this.room.getMyMembership()) === EffectiveMembership.Invite; + } + + public destroy(): void { + this.room.removeListener("Room.receipt", this.handleReadReceipt); + this.room.removeListener("Room.timeline", this.handleRoomEventUpdate); + this.room.removeListener("Room.redaction", this.handleRoomEventUpdate); + if (MatrixClientPeg.get()) { + MatrixClientPeg.get().removeListener("Event.decrypted", this.handleRoomEventUpdate); + MatrixClientPeg.get().removeListener("accountData", this.handleAccountDataUpdate); + } + } + + private handleReadReceipt = (event: MatrixEvent, room: Room) => { + if (!readReceiptChangeIsFor(event, MatrixClientPeg.get())) return; // not our own - ignore + if (room.roomId !== this.room.roomId) return; // not for us - ignore + this.updateNotificationState(); + }; + + private handleRoomEventUpdate = (event: MatrixEvent) => { + const roomId = event.getRoomId(); + + if (roomId !== this.room.roomId) return; // ignore - not for us + this.updateNotificationState(); + }; + + private handleAccountDataUpdate = (ev: MatrixEvent) => { + if (ev.getType() === "m.push_rules") { + this.updateNotificationState(); + } + }; + + private updateNotificationState() { + const before = {count: this.count, symbol: this.symbol, color: this.color}; + + if (RoomNotifs.getRoomNotifsState(this.room.roomId) === RoomNotifs.MUTE) { + // When muted we suppress all notification states, even if we have context on them. + this._color = NotificationColor.None; + this._symbol = null; + this._count = 0; + } else if (this.roomIsInvite) { + this._color = NotificationColor.Red; + this._symbol = "!"; + this._count = 1; // not used, technically + } else { + const redNotifs = RoomNotifs.getUnreadNotificationCount(this.room, 'highlight'); + const greyNotifs = RoomNotifs.getUnreadNotificationCount(this.room, 'total'); + + // For a 'true count' we pick the grey notifications first because they include the + // red notifications. If we don't have a grey count for some reason we use the red + // count. If that count is broken for some reason, assume zero. This avoids us showing + // a badge for 'NaN' (which formats as 'NaNB' for NaN Billion). + const trueCount = greyNotifs ? greyNotifs : (redNotifs ? redNotifs : 0); + + // Note: we only set the symbol if we have an actual count. We don't want to show + // zero on badges. + + if (redNotifs > 0) { + this._color = NotificationColor.Red; + this._count = trueCount; + this._symbol = null; // symbol calculated by component + } else if (greyNotifs > 0) { + this._color = NotificationColor.Grey; + this._count = trueCount; + this._symbol = null; // symbol calculated by component + } else { + // We don't have any notified messages, but we might have unread messages. Let's + // find out. + const hasUnread = Unread.doesRoomHaveUnreadMessages(this.room); + if (hasUnread) { + this._color = NotificationColor.Bold; + } else { + this._color = NotificationColor.None; + } + + // no symbol or count for this state + this._count = 0; + this._symbol = null; + } + } + + // finally, publish an update if needed + const after = {count: this.count, symbol: this.symbol, color: this.color}; + if (JSON.stringify(before) !== JSON.stringify(after)) { + this.emit(NOTIFICATION_STATE_UPDATE); + } + } +} diff --git a/src/stores/notifications/StaticNotificationState.ts b/src/stores/notifications/StaticNotificationState.ts new file mode 100644 index 0000000000..51902688fe --- /dev/null +++ b/src/stores/notifications/StaticNotificationState.ts @@ -0,0 +1,33 @@ +/* +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. +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 { INotificationState } from "./INotificationState"; +import { NotificationColor } from "./NotificationColor"; + +export class StaticNotificationState extends EventEmitter implements INotificationState { + constructor(public symbol: string, public count: number, public color: NotificationColor) { + super(); + } + + public static forCount(count: number, color: NotificationColor): StaticNotificationState { + return new StaticNotificationState(null, count, color); + } + + public static forSymbol(symbol: string, color: NotificationColor): StaticNotificationState { + return new StaticNotificationState(symbol, 0, color); + } +} diff --git a/src/stores/notifications/TagSpecificNotificationState.ts b/src/stores/notifications/TagSpecificNotificationState.ts new file mode 100644 index 0000000000..b443f4633b --- /dev/null +++ b/src/stores/notifications/TagSpecificNotificationState.ts @@ -0,0 +1,46 @@ +/* +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. +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 { NotificationColor } from "./NotificationColor"; +import { Room } from "matrix-js-sdk/src/models/room"; +import { TagID } from "../room-list/models"; +import { RoomNotificationState } from "./RoomNotificationState"; + +export class TagSpecificNotificationState extends RoomNotificationState { + private static TAG_TO_COLOR: { + // @ts-ignore - TS wants this to be a string key, but we know better + [tagId: TagID]: NotificationColor, + } = { + // TODO: Update for FTUE Notifications: https://github.com/vector-im/riot-web/issues/14261 + //[DefaultTagID.DM]: NotificationColor.Red, + }; + + private readonly colorWhenNotIdle?: NotificationColor; + + constructor(room: Room, tagId: TagID) { + super(room); + + const specificColor = TagSpecificNotificationState.TAG_TO_COLOR[tagId]; + if (specificColor) this.colorWhenNotIdle = specificColor; + } + + public get color(): NotificationColor { + if (!this.colorWhenNotIdle) return super.color; + + if (super.color !== NotificationColor.None) return this.colorWhenNotIdle; + return super.color; + } +} diff --git a/src/stores/room-list/ListLayout.ts b/src/stores/room-list/ListLayout.ts index ebc7b95854..efb0c4bdfb 100644 --- a/src/stores/room-list/ListLayout.ts +++ b/src/stores/room-list/ListLayout.ts @@ -18,6 +18,10 @@ import { TagID } from "./models"; const TILE_HEIGHT_PX = 44; +// this comes from the CSS where the show more button is +// mathematically this percent of a tile when floating. +const RESIZER_BOX_FACTOR = 0.78; + interface ISerializedListLayout { numTiles: number; showPreviews: boolean; @@ -67,6 +71,7 @@ export class ListLayout { } public get visibleTiles(): number { + if (this._n === 0) return this.defaultVisibleTiles; return Math.max(this._n, this.minVisibleTiles); } @@ -76,9 +81,20 @@ export class ListLayout { } public get minVisibleTiles(): number { - // the .65 comes from the CSS where the show more button is - // mathematically 65% of a tile when floating. - return 4.65; + return 1 + RESIZER_BOX_FACTOR; + } + + public get defaultVisibleTiles(): number { + // 10 is what "feels right", and mostly subject to design's opinion. + return 10 + RESIZER_BOX_FACTOR; + } + + public setVisibleTilesWithin(diff: number, maxPossible: number) { + if (this.visibleTiles > maxPossible) { + this.visibleTiles = maxPossible + diff; + } else { + this.visibleTiles += diff; + } } public calculateTilesToPixelsMin(maxTiles: number, n: number, possiblePadding: number): number { @@ -92,6 +108,10 @@ export class ListLayout { return this.tilesToPixels(Math.min(maxTiles, n)) + padding; } + public tilesWithResizerBoxFactor(n: number): number { + return n + RESIZER_BOX_FACTOR; + } + public tilesWithPadding(n: number, paddingPx: number): number { return this.pixelsToTiles(this.tilesToPixelsWithPadding(n, paddingPx)); } @@ -108,6 +128,10 @@ export class ListLayout { return px / this.tileHeight; } + public reset() { + localStorage.removeItem(this.key); + } + private save() { localStorage.setItem(this.key, JSON.stringify(this.serialize())); } diff --git a/src/stores/room-list/MessagePreviewStore.ts b/src/stores/room-list/MessagePreviewStore.ts new file mode 100644 index 0000000000..01ddde2e17 --- /dev/null +++ b/src/stores/room-list/MessagePreviewStore.ts @@ -0,0 +1,204 @@ +/* +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. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { Room } from "matrix-js-sdk/src/models/room"; +import { ActionPayload } from "../../dispatcher/payloads"; +import { AsyncStoreWithClient } from "../AsyncStoreWithClient"; +import defaultDispatcher from "../../dispatcher/dispatcher"; +import { RoomListStoreTempProxy } from "./RoomListStoreTempProxy"; +import { MessageEventPreview } from "./previews/MessageEventPreview"; +import { NameEventPreview } from "./previews/NameEventPreview"; +import { TagID } from "./models"; +import { isNullOrUndefined } from "matrix-js-sdk/src/utils"; +import { TopicEventPreview } from "./previews/TopicEventPreview"; +import { MembershipEventPreview } from "./previews/MembershipEventPreview"; +import { HistoryVisibilityEventPreview } from "./previews/HistoryVisibilityEventPreview"; +import { CallInviteEventPreview } from "./previews/CallInviteEventPreview"; +import { CallAnswerEventPreview } from "./previews/CallAnswerEventPreview"; +import { CallHangupEvent } from "./previews/CallHangupEvent"; +import { EncryptionEventPreview } from "./previews/EncryptionEventPreview"; +import { ThirdPartyInviteEventPreview } from "./previews/ThirdPartyInviteEventPreview"; +import { StickerEventPreview } from "./previews/StickerEventPreview"; +import { ReactionEventPreview } from "./previews/ReactionEventPreview"; +import { CreationEventPreview } from "./previews/CreationEventPreview"; + +const PREVIEWS = { + 'm.room.message': { + isState: false, + previewer: new MessageEventPreview(), + }, + 'm.room.name': { + isState: true, + previewer: new NameEventPreview(), + }, + 'm.room.topic': { + isState: true, + previewer: new TopicEventPreview(), + }, + 'm.room.member': { + isState: true, + previewer: new MembershipEventPreview(), + }, + 'm.room.history_visibility': { + isState: true, + previewer: new HistoryVisibilityEventPreview(), + }, + 'm.call.invite': { + isState: false, + previewer: new CallInviteEventPreview(), + }, + 'm.call.answer': { + isState: false, + previewer: new CallAnswerEventPreview(), + }, + 'm.call.hangup': { + isState: false, + previewer: new CallHangupEvent(), + }, + 'm.room.encryption': { + isState: true, + previewer: new EncryptionEventPreview(), + }, + 'm.room.third_party_invite': { + isState: true, + previewer: new ThirdPartyInviteEventPreview(), + }, + 'm.sticker': { + isState: false, + previewer: new StickerEventPreview(), + }, + 'm.reaction': { + isState: false, + previewer: new ReactionEventPreview(), + }, + 'm.room.create': { + isState: true, + previewer: new CreationEventPreview(), + }, +}; + +// The maximum number of events we're willing to look back on to get a preview. +const MAX_EVENTS_BACKWARDS = 50; + +// type merging ftw +type TAG_ANY = "im.vector.any"; +const TAG_ANY: TAG_ANY = "im.vector.any"; + +interface IState { + [roomId: string]: Map; // null indicates the preview is empty / irrelevant +} + +export class MessagePreviewStore extends AsyncStoreWithClient { + private static internalInstance = new MessagePreviewStore(); + + private constructor() { + super(defaultDispatcher, {}); + } + + public static get instance(): MessagePreviewStore { + return MessagePreviewStore.internalInstance; + } + + /** + * Gets the pre-translated preview for a given room + * @param room The room to get the preview for. + * @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 { + if (!room) return null; // invalid room, just return nothing + + const val = this.state[room.roomId]; + if (!val) this.generatePreview(room, inTagId); + + const previews = this.state[room.roomId]; + if (!previews) return null; + + if (!previews.has(inTagId)) { + return previews.get(TAG_ANY); + } + return previews.get(inTagId); + } + + private generatePreview(room: Room, tagId?: TagID) { + const events = room.timeline; + if (!events) return; // should only happen in tests + + let map = this.state[room.roomId]; + if (!map) { + map = new Map(); + + // We set the state later with the map, so no need to send an update now + } + + // Set the tags so we know what to generate + if (!map.has(TAG_ANY)) map.set(TAG_ANY, null); + if (tagId && !map.has(tagId)) map.set(tagId, null); + + let changed = false; + for (let i = events.length - 1; i >= 0; i--) { + if (i === events.length - MAX_EVENTS_BACKWARDS) return; // limit reached + + const event = events[i]; + const previewDef = PREVIEWS[event.getType()]; + if (!previewDef) continue; + if (previewDef.isState && isNullOrUndefined(event.getStateKey())) continue; + + const anyPreview = previewDef.previewer.getTextFor(event, null); + if (!anyPreview) continue; // not previewable for some reason + + changed = changed || anyPreview !== map.get(TAG_ANY); + map.set(TAG_ANY, anyPreview); + + const tagsToGenerate = Array.from(map.keys()).filter(t => t !== TAG_ANY); // we did the any tag above + for (const genTagId of tagsToGenerate) { + const realTagId: TagID = genTagId === TAG_ANY ? null : genTagId; + const preview = previewDef.previewer.getTextFor(event, realTagId); + if (preview === anyPreview) { + changed = changed || anyPreview !== map.get(genTagId); + map.delete(genTagId); + } else { + changed = changed || preview !== map.get(genTagId); + map.set(genTagId, preview); + } + } + + if (changed) { + // Update state for good measure - causes emit for update + // noinspection JSIgnoredPromiseFromCall - the AsyncStore handles concurrent calls + this.updateState({[room.roomId]: map}); + } + return; // we're done + } + + // At this point, we didn't generate a preview so clear it + // noinspection JSIgnoredPromiseFromCall - the AsyncStore handles concurrent calls + this.updateState({[room.roomId]: null}); + } + + protected async onAction(payload: ActionPayload) { + if (!this.matrixClient) return; + + // TODO: Remove when new room list is made the default: https://github.com/vector-im/riot-web/issues/14231 + if (!RoomListStoreTempProxy.isUsingNewStore()) return; + + if (payload.action === 'MatrixActions.Room.timeline' || payload.action === 'MatrixActions.Event.decrypted') { + const event = payload.event; // TODO: Type out the dispatcher + if (!Object.keys(this.state).includes(event.getRoomId())) return; // not important + this.generatePreview(this.matrixClient.getRoom(event.getRoomId()), TAG_ANY); + } + } +} diff --git a/src/stores/room-list/RoomListStore2.ts b/src/stores/room-list/RoomListStore2.ts index 9684e338f8..e5205f6051 100644 --- a/src/stores/room-list/RoomListStore2.ts +++ b/src/stores/room-list/RoomListStore2.ts @@ -17,7 +17,7 @@ limitations under the License. import { MatrixClient } from "matrix-js-sdk/src/client"; import SettingsStore from "../../settings/SettingsStore"; -import { OrderedDefaultTagIDs, RoomUpdateCause, TagID } from "./models"; +import { DefaultTagID, OrderedDefaultTagIDs, RoomUpdateCause, TagID } from "./models"; import TagOrderStore from "../TagOrderStore"; import { AsyncStore } from "../AsyncStore"; import { Room } from "matrix-js-sdk/src/models/room"; @@ -29,6 +29,8 @@ import { IFilterCondition } from "./filters/IFilterCondition"; import { TagWatcher } from "./TagWatcher"; import RoomViewStore from "../RoomViewStore"; import { Algorithm, LIST_UPDATED_EVENT } from "./algorithms/Algorithm"; +import { EffectiveMembership, getEffectiveMembership } from "./membership"; +import { ListLayout } from "./ListLayout"; interface IState { tagsEnabled?: boolean; @@ -49,8 +51,6 @@ export class RoomListStore2 extends AsyncStore { private tagWatcher = new TagWatcher(this); private readonly watchedSettings = [ - 'RoomList.orderAlphabetically', - 'RoomList.orderByImportance', 'feature_custom_tags', ]; @@ -72,7 +72,7 @@ export class RoomListStore2 extends AsyncStore { return this._matrixClient; } - // TODO: Remove enabled flag when the old RoomListStore goes away + // TODO: Remove enabled flag with the old RoomListStore: https://github.com/vector-im/riot-web/issues/14231 private checkEnabled() { this.enabled = SettingsStore.isFeatureEnabled("feature_new_room_list"); if (this.enabled) { @@ -89,7 +89,7 @@ export class RoomListStore2 extends AsyncStore { } private onRVSUpdate = () => { - if (!this.enabled) return; // TODO: Remove enabled flag when RoomListStore2 takes over + if (!this.enabled) return; // TODO: Remove with https://github.com/vector-im/riot-web/issues/14231 if (!this.matrixClient) return; // We assume there won't be RVS updates without a client const activeRoomId = RoomViewStore.getRoomId(); @@ -97,8 +97,11 @@ export class RoomListStore2 extends AsyncStore { this.algorithm.stickyRoom = null; } else if (activeRoomId) { const activeRoom = this.matrixClient.getRoom(activeRoomId); - if (!activeRoom) throw new Error(`${activeRoomId} is current in RVS but missing from client`); - if (activeRoom !== this.algorithm.stickyRoom) { + if (!activeRoom) { + console.warn(`${activeRoomId} is current in RVS but missing from client - clearing sticky room`); + this.algorithm.stickyRoom = null; + } else if (activeRoom !== this.algorithm.stickyRoom) { + // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035 console.log(`Changing sticky room to ${activeRoomId}`); this.algorithm.stickyRoom = activeRoom; } @@ -112,7 +115,7 @@ export class RoomListStore2 extends AsyncStore { return; } - // TODO: Remove this once the RoomListStore becomes default + // TODO: Remove with https://github.com/vector-im/riot-web/issues/14231 this.checkEnabled(); if (!this.enabled) return; @@ -158,17 +161,19 @@ export class RoomListStore2 extends AsyncStore { // First see if the receipt event is for our own user. If it was, trigger // a room update (we probably read the room on a different device). if (readReceiptChangeIsFor(payload.event, this.matrixClient)) { - console.log(`[RoomListDebug] Got own read receipt in ${payload.event.roomId}`); - const room = this.matrixClient.getRoom(payload.event.roomId); + const room = payload.room; if (!room) { - console.warn(`Own read receipt was in unknown room ${payload.event.roomId}`); + console.warn(`Own read receipt was in unknown room ${room.roomId}`); return; } + // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035 + console.log(`[RoomListDebug] Got own read receipt in ${room.roomId}`); await this.handleRoomUpdate(room, RoomUpdateCause.ReadReceipt); return; } } else if (payload.action === 'MatrixActions.Room.tags') { const roomPayload = (payload); // TODO: Type out the dispatcher types + // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035 console.log(`[RoomListDebug] Got tag change in ${roomPayload.room.roomId}`); await this.handleRoomUpdate(roomPayload.room, RoomUpdateCause.PossibleTagChange); } else if (payload.action === 'MatrixActions.Room.timeline') { @@ -180,12 +185,18 @@ export class RoomListStore2 extends AsyncStore { const roomId = eventPayload.event.getRoomId(); const room = this.matrixClient.getRoom(roomId); const tryUpdate = async (updatedRoom: Room) => { - console.log(`[RoomListDebug] Live timeline event ${eventPayload.event.getId()} in ${updatedRoom.roomId}`); + // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035 + console.log(`[RoomListDebug] Live timeline event ${eventPayload.event.getId()}` + + ` in ${updatedRoom.roomId}`); if (eventPayload.event.getType() === 'm.room.tombstone' && eventPayload.event.getStateKey() === '') { - console.log(`[RoomListDebug] Got tombstone event - regenerating room list`); - // TODO: We could probably be smarter about this - await this.regenerateAllLists(); - return; // don't pass the update down - we will have already handled it in the regen + // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035 + console.log(`[RoomListDebug] Got tombstone event - trying to remove now-dead room`); + const newRoom = this.matrixClient.getRoom(eventPayload.event.getContent()['replacement_room']); + if (newRoom) { + // If we have the new room, then the new room check will have seen the predecessor + // and did the required updates, so do nothing here. + return; + } } await this.handleRoomUpdate(updatedRoom, RoomUpdateCause.Timeline); }; @@ -208,13 +219,15 @@ export class RoomListStore2 extends AsyncStore { console.warn(`Event ${eventPayload.event.getId()} was decrypted in an unknown room ${roomId}`); return; } + // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035 console.log(`[RoomListDebug] Decrypted timeline event ${eventPayload.event.getId()} in ${roomId}`); - // TODO: Check that e2e rooms are calculated correctly on initial load. + // TODO: Verify that e2e rooms are handled on init: https://github.com/vector-im/riot-web/issues/14238 // It seems like when viewing the room the timeline is decrypted, rather than at startup. This could // cause inaccuracies with the list ordering. We may have to decrypt the last N messages of every room :( await this.handleRoomUpdate(room, RoomUpdateCause.Timeline); } else if (payload.action === 'MatrixActions.accountData' && payload.event_type === 'm.direct') { const eventPayload = (payload); // TODO: Type out the dispatcher types + // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035 console.log(`[RoomListDebug] Received updated DM map`); const dmMap = eventPayload.event.getContent(); for (const userId of Object.keys(dmMap)) { @@ -235,16 +248,49 @@ export class RoomListStore2 extends AsyncStore { } } else if (payload.action === 'MatrixActions.Room.myMembership') { const membershipPayload = (payload); // TODO: Type out the dispatcher types - if (membershipPayload.oldMembership !== "join" && membershipPayload.membership === "join") { + const oldMembership = getEffectiveMembership(membershipPayload.oldMembership); + const newMembership = getEffectiveMembership(membershipPayload.membership); + if (oldMembership !== EffectiveMembership.Join && newMembership === EffectiveMembership.Join) { + // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035 console.log(`[RoomListDebug] Handling new room ${membershipPayload.room.roomId}`); - await this.algorithm.handleRoomUpdate(membershipPayload.room, RoomUpdateCause.NewRoom); + + // If we're joining an upgraded room, we'll want to make sure we don't proliferate + // the dead room in the list. + const createEvent = membershipPayload.room.currentState.getStateEvents("m.room.create", ""); + if (createEvent && createEvent.getContent()['predecessor']) { + console.log(`[RoomListDebug] Room has a predecessor`); + const prevRoom = this.matrixClient.getRoom(createEvent.getContent()['predecessor']['room_id']); + if (prevRoom) { + const isSticky = this.algorithm.stickyRoom === prevRoom; + if (isSticky) { + console.log(`[RoomListDebug] Clearing sticky room due to room upgrade`); + await this.algorithm.setStickyRoomAsync(null); + } + + // Note: we hit the algorithm instead of our handleRoomUpdate() function to + // avoid redundant updates. + console.log(`[RoomListDebug] Removing previous room from room list`); + await this.algorithm.handleRoomUpdate(prevRoom, RoomUpdateCause.RoomRemoved); + } + } + + console.log(`[RoomListDebug] Adding new room to room list`); + await this.handleRoomUpdate(membershipPayload.room, RoomUpdateCause.NewRoom); + return; + } + + if (oldMembership !== EffectiveMembership.Invite && newMembership === EffectiveMembership.Invite) { + // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035 + console.log(`[RoomListDebug] Handling invite to ${membershipPayload.room.roomId}`); + await this.handleRoomUpdate(membershipPayload.room, RoomUpdateCause.NewRoom); return; } // If it's not a join, it's transitioning into a different list (possibly historical) - if (membershipPayload.oldMembership !== membershipPayload.membership) { + if (oldMembership !== newMembership) { + // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035 console.log(`[RoomListDebug] Handling membership change in ${membershipPayload.room.roomId}`); - await this.algorithm.handleRoomUpdate(membershipPayload.room, RoomUpdateCause.PossibleTagChange); + await this.handleRoomUpdate(membershipPayload.room, RoomUpdateCause.PossibleTagChange); return; } } @@ -253,6 +299,7 @@ export class RoomListStore2 extends AsyncStore { private async handleRoomUpdate(room: Room, cause: RoomUpdateCause): Promise { const shouldUpdate = await this.algorithm.handleRoomUpdate(room, cause); if (shouldUpdate) { + // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035 console.log(`[DEBUG] Room "${room.name}" (${room.roomId}) triggered by ${cause} requires list update`); this.emit(LISTS_UPDATE_EVENT, this); } @@ -260,6 +307,7 @@ export class RoomListStore2 extends AsyncStore { public async setTagSorting(tagId: TagID, sort: SortAlgorithm) { await this.algorithm.setTagSorting(tagId, sort); + // TODO: Per-account? https://github.com/vector-im/riot-web/issues/14114 localStorage.setItem(`mx_tagSort_${tagId}`, sort); } @@ -269,11 +317,13 @@ export class RoomListStore2 extends AsyncStore { // noinspection JSMethodCanBeStatic private getStoredTagSorting(tagId: TagID): SortAlgorithm { + // TODO: Per-account? https://github.com/vector-im/riot-web/issues/14114 return localStorage.getItem(`mx_tagSort_${tagId}`); } public async setListOrder(tagId: TagID, order: ListAlgorithm) { await this.algorithm.setListOrdering(tagId, order); + // TODO: Per-account? https://github.com/vector-im/riot-web/issues/14114 localStorage.setItem(`mx_listOrder_${tagId}`, order); } @@ -283,15 +333,13 @@ export class RoomListStore2 extends AsyncStore { // noinspection JSMethodCanBeStatic private getStoredListOrder(tagId: TagID): ListAlgorithm { + // TODO: Per-account? https://github.com/vector-im/riot-web/issues/14114 return localStorage.getItem(`mx_listOrder_${tagId}`); } private async updateAlgorithmInstances() { - const orderByImportance = SettingsStore.getValue("RoomList.orderByImportance"); - const orderAlphabetically = SettingsStore.getValue("RoomList.orderAlphabetically"); - - const defaultSort = orderAlphabetically ? SortAlgorithm.Alphabetic : SortAlgorithm.Recent; - const defaultOrder = orderByImportance ? ListAlgorithm.Importance : ListAlgorithm.Natural; + const defaultSort = SortAlgorithm.Alphabetic; + const defaultOrder = ListAlgorithm.Natural; for (const tag of Object.keys(this.orderedLists)) { const definedSort = this.getTagSorting(tag); @@ -319,6 +367,7 @@ export class RoomListStore2 extends AsyncStore { } private onAlgorithmListUpdated = () => { + // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035 console.log("Underlying algorithm has triggered a list update - refiring"); this.emit(LISTS_UPDATE_EVENT, this); }; @@ -334,8 +383,10 @@ export class RoomListStore2 extends AsyncStore { } if (this.state.tagsEnabled) { - // TODO: Find a more reliable way to get tags (this doesn't work) + // TODO: Fix custom tags: https://github.com/vector-im/riot-web/issues/14091 const roomTags = TagOrderStore.getOrderedTags() || []; + + // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035 console.log("rtags", roomTags); } @@ -347,7 +398,17 @@ export class RoomListStore2 extends AsyncStore { this.emit(LISTS_UPDATE_EVENT, this); } + // Note: this primarily exists for debugging, and isn't really intended to be used by anything. + public async resetLayouts() { + console.warn("Resetting layouts for room list"); + for (const tagId of Object.keys(this.orderedLists)) { + new ListLayout(tagId).reset(); + } + await this.regenerateAllLists(); + } + public addFilter(filter: IFilterCondition): void { + // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035 console.log("Adding filter condition:", filter); this.filterConditions.push(filter); if (this.algorithm) { @@ -356,6 +417,7 @@ export class RoomListStore2 extends AsyncStore { } public removeFilter(filter: IFilterCondition): void { + // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035 console.log("Removing filter condition:", filter); const idx = this.filterConditions.indexOf(filter); if (idx >= 0) { @@ -366,6 +428,19 @@ export class RoomListStore2 extends AsyncStore { } } } + + /** + * Gets the tags for a room identified by the store. The returned set + * should never be empty, and will contain DefaultTagID.Untagged if + * the store is not aware of any tags. + * @param room The room to get the tags for. + * @returns The tags for the room. + */ + public getTagsForRoom(room: Room): TagID[] { + const algorithmTags = this.algorithm.getTagsForRoom(room); + if (!algorithmTags) return [DefaultTagID.Untagged]; + return algorithmTags; + } } export default class RoomListStore { diff --git a/src/stores/room-list/RoomListStoreTempProxy.ts b/src/stores/room-list/RoomListStoreTempProxy.ts index 0a173d53a9..86aff178ee 100644 --- a/src/stores/room-list/RoomListStoreTempProxy.ts +++ b/src/stores/room-list/RoomListStoreTempProxy.ts @@ -24,7 +24,7 @@ import { ITagMap } from "./algorithms/models"; * Temporary RoomListStore proxy. Should be replaced with RoomListStore2 when * it is available to everyone. * - * TODO: Remove this when RoomListStore gets fully replaced. + * TODO: Delete this: https://github.com/vector-im/riot-web/issues/14231 */ export class RoomListStoreTempProxy { public static isUsingNewStore(): boolean { diff --git a/src/stores/room-list/TagWatcher.ts b/src/stores/room-list/TagWatcher.ts index 22302b695d..56b6437524 100644 --- a/src/stores/room-list/TagWatcher.ts +++ b/src/stores/room-list/TagWatcher.ts @@ -23,7 +23,7 @@ import { arrayDiff, arrayHasDiff } from "../../utils/arrays"; * Watches for changes in tags/groups to manage filters on the provided RoomListStore */ export class TagWatcher { - // TODO: Support custom tags, somehow (deferred to later work - need support elsewhere) + // TODO: Support custom tags, somehow: https://github.com/vector-im/riot-web/issues/14091 private filters = new Map(); constructor(private store: RoomListStore2) { @@ -44,7 +44,7 @@ export class TagWatcher { const newFilters = new Map(); - // TODO: Support custom tags properly + // TODO: Support custom tags, somehow: https://github.com/vector-im/riot-web/issues/14091 const filterableTags = newTags.filter(t => t.startsWith("+")); for (const tag of filterableTags) { @@ -61,6 +61,7 @@ export class TagWatcher { const diff = arrayDiff(lastTags, newTags); for (const tag of diff.added) { // TODO: Remove this check when custom tags are supported (as we shouldn't be losing filters) + // Ref https://github.com/vector-im/riot-web/issues/14091 const filter = newFilters.get(tag); if (!filter) continue; diff --git a/src/stores/room-list/algorithms/Algorithm.ts b/src/stores/room-list/algorithms/Algorithm.ts index 052c58bb83..36abf86975 100644 --- a/src/stores/room-list/algorithms/Algorithm.ts +++ b/src/stores/room-list/algorithms/Algorithm.ts @@ -30,11 +30,11 @@ import { SortAlgorithm } from "./models"; import { FILTER_CHANGED, FilterPriority, IFilterCondition } from "../filters/IFilterCondition"; -import { EffectiveMembership, splitRoomsByMembership } from "../membership"; +import { EffectiveMembership, getEffectiveMembership, splitRoomsByMembership } from "../membership"; import { OrderingAlgorithm } from "./list-ordering/OrderingAlgorithm"; import { getListAlgorithmInstance } from "./list-ordering"; -// TODO: Add locking support to avoid concurrent writes? +// TODO: Add locking support to avoid concurrent writes? https://github.com/vector-im/riot-web/issues/14235 /** * Fired when the Algorithm has determined a list has been updated. @@ -99,6 +99,14 @@ export class Algorithm extends EventEmitter { return this._cachedRooms; } + /** + * Awaitable version of the sticky room setter. + * @param val The new room to sticky. + */ + public async setStickyRoomAsync(val: Room) { + await this.updateStickyRoom(val); + } + public getTagSorting(tagId: TagID): SortAlgorithm { return this.sortAlgorithms[tagId]; } @@ -160,28 +168,34 @@ export class Algorithm extends EventEmitter { // It's possible to have no selected room. In that case, clear the sticky room if (!val) { if (this._stickyRoom) { + const stickyRoom = this._stickyRoom.room; + this._stickyRoom = null; // clear before we go to update the algorithm + // Lie to the algorithm and re-add the room to the algorithm - await this.handleRoomUpdate(this._stickyRoom.room, RoomUpdateCause.NewRoom); + await this.handleRoomUpdate(stickyRoom, RoomUpdateCause.NewRoom); + return; } - this._stickyRoom = null; return; } // When we do have a room though, we expect to be able to find it const tag = this.roomIdsToTags[val.roomId][0]; if (!tag) throw new Error(`${val.roomId} does not belong to a tag and cannot be sticky`); - let position = this.cachedRooms[tag].indexOf(val); + + // We specifically do NOT use the ordered rooms set as it contains the sticky room, which + // means we'll be off by 1 when the user is switching rooms. This leads to visual jumping + // when the user is moving south in the list (not north, because of math). + let position = this.getOrderedRoomsWithoutSticky()[tag].indexOf(val); if (position < 0) throw new Error(`${val.roomId} does not appear to be known and cannot be sticky`); // 🐉 Here be dragons. // Before we can go through with lying to the underlying algorithm about a room - // we need to ensure that when we do we're ready for the innevitable sticky room + // we need to ensure that when we do we're ready for the inevitable sticky room // update we'll receive. To prepare for that, we first remove the sticky room and // recalculate the state ourselves so that when the underlying algorithm calls for // the same thing it no-ops. After we're done calling the algorithm, we'll issue // a new update for ourselves. const lastStickyRoom = this._stickyRoom; - console.log(`Last sticky room:`, lastStickyRoom); this._stickyRoom = null; this.recalculateStickyRoom(); @@ -208,6 +222,12 @@ export class Algorithm extends EventEmitter { position: position, tag: tag, }; + + // We update the filtered rooms just in case, as otherwise users will end up visiting + // a room while filtering and it'll disappear. We don't update the filter earlier in + // this function simply because we don't have to. + this.recalculateFilteredRoomsForTag(tag); + if (lastStickyRoom && lastStickyRoom.tag !== tag) this.recalculateFilteredRoomsForTag(lastStickyRoom.tag); this.recalculateStickyRoom(); // Finally, trigger an update @@ -231,9 +251,7 @@ export class Algorithm extends EventEmitter { // We optimize our lookups by trying to reduce sample size as much as possible // to the rooms we know will be deduped by the Set. const rooms = this.cachedRooms[tagId].map(r => r); // cheap clone - if (this._stickyRoom && this._stickyRoom.tag === tagId && this._stickyRoom.room) { - rooms.push(this._stickyRoom.room); - } + this.tryInsertStickyRoomToFilterSet(rooms, tagId); let remainingRooms = rooms.map(r => r); let allowedRoomsInThisTag = []; let lastFilterPriority = orderedFilters[0].relativePriority; @@ -254,6 +272,8 @@ export class Algorithm extends EventEmitter { } } newMap[tagId] = allowedRoomsInThisTag; + + // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035 console.log(`[DEBUG] ${newMap[tagId].length}/${rooms.length} rooms filtered into ${tagId}`); } @@ -263,6 +283,7 @@ export class Algorithm extends EventEmitter { this.emit(LIST_UPDATED_EVENT); } + // TODO: Remove or use. protected addPossiblyFilteredRoomsToTag(tagId: TagID, added: Room[]): void { const filters = this.allowedByFilter.keys(); for (const room of added) { @@ -279,16 +300,32 @@ export class Algorithm extends EventEmitter { } protected recalculateFilteredRoomsForTag(tagId: TagID): void { + if (!this.hasFilters) return; // don't bother doing work if there's nothing to do + console.log(`Recalculating filtered rooms for ${tagId}`); delete this.filteredRooms[tagId]; - const rooms = this.cachedRooms[tagId]; + const rooms = this.cachedRooms[tagId].map(r => r); // cheap clone + this.tryInsertStickyRoomToFilterSet(rooms, tagId); const filteredRooms = rooms.filter(r => this.allowedRoomsByFilters.has(r)); if (filteredRooms.length > 0) { this.filteredRooms[tagId] = filteredRooms; } + + // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035 console.log(`[DEBUG] ${filteredRooms.length}/${rooms.length} rooms filtered into ${tagId}`); } + protected tryInsertStickyRoomToFilterSet(rooms: Room[], tagId: TagID) { + if (!this._stickyRoom || !this._stickyRoom.room || this._stickyRoom.tag !== tagId) return; + + const position = this._stickyRoom.position; + if (position >= rooms.length) { + rooms.push(this._stickyRoom.room); + } else { + rooms.splice(position, 0, this._stickyRoom.room); + } + } + /** * Recalculate the sticky room position. If this is being called in relation to * a specific tag being updated, it should be given to this function to optimize @@ -314,6 +351,7 @@ export class Algorithm extends EventEmitter { } if (!this._cachedStickyRooms || !updatedTag) { + // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035 console.log(`Generating clone of cached rooms for sticky room handling`); const stickiedTagMap: ITagMap = {}; for (const tagId of Object.keys(this.cachedRooms)) { @@ -325,6 +363,7 @@ export class Algorithm extends EventEmitter { if (updatedTag) { // Update the tag indicated by the caller, if possible. This is mostly to ensure // our cache is up to date. + // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035 console.log(`Replacing cached sticky rooms for ${updatedTag}`); this._cachedStickyRooms[updatedTag] = this.cachedRooms[updatedTag].map(r => r); // shallow clone } @@ -334,6 +373,7 @@ export class Algorithm extends EventEmitter { // we might have updated from the cache is also our sticky room. const sticky = this._stickyRoom; if (!updatedTag || updatedTag === sticky.tag) { + // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035 console.log(`Inserting sticky room ${sticky.room.roomId} at position ${sticky.position} in ${sticky.tag}`); this._cachedStickyRooms[sticky.tag].splice(sticky.position, 0, sticky.room); } @@ -377,6 +417,20 @@ export class Algorithm extends EventEmitter { return this.filteredRooms; } + /** + * This returns the same as getOrderedRooms(), but without the sticky room + * map as it causes issues for sticky room handling (see sticky room handling + * for more information). + * @returns {ITagMap} The cached list of rooms, ordered, + * for each tag. May be empty, but never null/undefined. + */ + private getOrderedRoomsWithoutSticky(): ITagMap { + if (!this.hasFilters) { + return this.cachedRooms; + } + return this.filteredRooms; + } + /** * Seeds the Algorithm with a set of rooms. The algorithm will discard all * previously known information and instead use these rooms instead. @@ -387,6 +441,13 @@ export class Algorithm extends EventEmitter { if (isNullOrUndefined(rooms)) throw new Error(`Array of rooms cannot be null`); if (!this.sortAlgorithms) throw new Error(`Cannot set known rooms without a tag sorting map`); + console.warn("Resetting known rooms, initiating regeneration"); + + // Before we go any further we need to clear (but remember) the sticky room to + // avoid accidentally duplicating it in the list. + const oldStickyRoom = this._stickyRoom; + await this.updateStickyRoom(null); + this.rooms = rooms; const newTags: ITagMap = {}; @@ -405,30 +466,27 @@ export class Algorithm extends EventEmitter { // Split out the easy rooms first (leave and invite) const memberships = splitRoomsByMembership(rooms); for (const room of memberships[EffectiveMembership.Invite]) { + // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035 console.log(`[DEBUG] "${room.name}" (${room.roomId}) is an Invite`); newTags[DefaultTagID.Invite].push(room); } for (const room of memberships[EffectiveMembership.Leave]) { + // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035 console.log(`[DEBUG] "${room.name}" (${room.roomId}) is Historical`); newTags[DefaultTagID.Archived].push(room); } // Now process all the joined rooms. This is a bit more complicated for (const room of memberships[EffectiveMembership.Join]) { - let tags = Object.keys(room.tags || {}); - - if (tags.length === 0) { - // Check to see if it's a DM if it isn't anything else - if (DMRoomMap.shared().getUserIdForRoomId(room.roomId)) { - tags = [DefaultTagID.DM]; - } - } + const tags = this.getTagsOfJoinedRoom(room); let inTag = false; if (tags.length > 0) { for (const tag of tags) { + // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035 console.log(`[DEBUG] "${room.name}" (${room.roomId}) is tagged as ${tag}`); if (!isNullOrUndefined(newTags[tag])) { + // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035 console.log(`[DEBUG] "${room.name}" (${room.roomId}) is tagged with VALID tag ${tag}`); newTags[tag].push(room); inTag = true; @@ -437,8 +495,10 @@ export class Algorithm extends EventEmitter { } if (!inTag) { - // TODO: Determine if DM and push there instead + // TODO: Determine if DM and push there instead: https://github.com/vector-im/riot-web/issues/14236 newTags[DefaultTagID.Untagged].push(room); + + // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035 console.log(`[DEBUG] "${room.name}" (${room.roomId}) is Untagged`); } } @@ -447,6 +507,54 @@ export class Algorithm extends EventEmitter { this.cachedRooms = newTags; 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 + // we also have to update the position of it. + if (oldStickyRoom && oldStickyRoom.room) { + await this.updateStickyRoom(oldStickyRoom.room); + if (this._stickyRoom && this._stickyRoom.room) { // just in case the update doesn't go according to plan + if (this._stickyRoom.tag !== oldStickyRoom.tag) { + // We put the sticky room at the top of the list to treat it as an obvious tag change. + this._stickyRoom.position = 0; + this.recalculateStickyRoom(this._stickyRoom.tag); + } + } + } + } + + public getTagsForRoom(room: Room): TagID[] { + // XXX: This duplicates a lot of logic from setKnownRooms above, but has a slightly + // different use case and therefore different performance curve + + const tags: TagID[] = []; + + const membership = getEffectiveMembership(room.getMyMembership()); + if (membership === EffectiveMembership.Invite) { + tags.push(DefaultTagID.Invite); + } else if (membership === EffectiveMembership.Leave) { + tags.push(DefaultTagID.Archived); + } else { + tags.push(...this.getTagsOfJoinedRoom(room)); + } + + if (!tags.length) tags.push(DefaultTagID.Untagged); + + return tags; + } + + private getTagsOfJoinedRoom(room: Room): TagID[] { + let tags = Object.keys(room.tags || {}); + + if (tags.length === 0) { + // Check to see if it's a DM if it isn't anything else + if (DMRoomMap.shared().getUserIdForRoomId(room.roomId)) { + tags = [DefaultTagID.DM]; + } + } + + return tags; } /** @@ -501,9 +609,17 @@ export class Algorithm extends EventEmitter { public async handleRoomUpdate(room: Room, cause: RoomUpdateCause): Promise { if (!this.algorithms) throw new Error("Not ready: no algorithms to determine tags from"); + if (cause === RoomUpdateCause.NewRoom) { + const roomTags = this.roomIdsToTags[room.roomId]; + if (roomTags && roomTags.length > 0) { + console.warn(`${room.roomId} is reportedly new but is already known - assuming TagChange instead`); + cause = RoomUpdateCause.PossibleTagChange; + } + } + if (cause === RoomUpdateCause.PossibleTagChange) { - // TODO: Be smarter and splice rather than regen the planet. - // TODO: No-op if no change. + // TODO: Be smarter and splice rather than regen the planet. https://github.com/vector-im/riot-web/issues/14035 + // TODO: No-op if no change. https://github.com/vector-im/riot-web/issues/14035 await this.setKnownRooms(this.rooms); return true; } @@ -513,11 +629,25 @@ export class Algorithm extends EventEmitter { // as the sticky room relies on this. if (cause !== RoomUpdateCause.NewRoom && cause !== RoomUpdateCause.RoomRemoved) { if (this.stickyRoom === room) { + // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035 console.warn(`[RoomListDebug] Received ${cause} update for sticky room ${room.roomId} - ignoring`); return false; } } + if (cause === RoomUpdateCause.NewRoom && !this.roomIdsToTags[room.roomId]) { + console.log(`[RoomListDebug] Updating tags for new room ${room.roomId} (${room.name})`); + + // Get the tags for the room and populate the cache + const roomTags = this.getTagsForRoom(room).filter(t => !isNullOrUndefined(this.cachedRooms[t])); + + // "This should never happen" condition - we specify DefaultTagID.Untagged in getTagsForRoom(), + // which means we should *always* have a tag to go off of. + if (!roomTags.length) throw new Error(`Tags cannot be determined for ${room.roomId}`); + + this.roomIdsToTags[room.roomId] = roomTags; + } + let tags = this.roomIdsToTags[room.roomId]; if (!tags) { console.warn(`No tags known for "${room.name}" (${room.roomId})`); diff --git a/src/stores/room-list/algorithms/list-ordering/ImportanceAlgorithm.ts b/src/stores/room-list/algorithms/list-ordering/ImportanceAlgorithm.ts index 15fa00c302..e95f92f985 100644 --- a/src/stores/room-list/algorithms/list-ordering/ImportanceAlgorithm.ts +++ b/src/stores/room-list/algorithms/list-ordering/ImportanceAlgorithm.ts @@ -87,6 +87,8 @@ export class ImportanceAlgorithm extends OrderingAlgorithm { public constructor(tagId: TagID, initialSortingAlgorithm: SortAlgorithm) { super(tagId, initialSortingAlgorithm); + + // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035 console.log(`[RoomListDebug] Constructed an ImportanceAlgorithm for ${tagId}`); } @@ -177,45 +179,51 @@ export class ImportanceAlgorithm extends OrderingAlgorithm { } public async handleRoomUpdate(room: Room, cause: RoomUpdateCause): Promise { - if (cause === RoomUpdateCause.NewRoom || cause === RoomUpdateCause.RoomRemoved) { - return this.handleSplice(room, cause); + try { + await this.updateLock.acquireAsync(); + + if (cause === RoomUpdateCause.NewRoom || cause === RoomUpdateCause.RoomRemoved) { + return this.handleSplice(room, cause); + } + + if (cause !== RoomUpdateCause.Timeline && cause !== RoomUpdateCause.ReadReceipt) { + throw new Error(`Unsupported update cause: ${cause}`); + } + + const category = this.getRoomCategory(room); + if (this.sortingAlgorithm === SortAlgorithm.Manual) { + return; // Nothing to do here. + } + + const roomIdx = this.getRoomIndex(room); + if (roomIdx === -1) { + throw new Error(`Room ${room.roomId} has no index in ${this.tagId}`); + } + + // Try to avoid doing array operations if we don't have to: only move rooms within + // the categories if we're jumping categories + const oldCategory = this.getCategoryFromIndices(roomIdx, this.indices); + if (oldCategory !== category) { + // Move the room and update the indices + this.moveRoomIndexes(1, oldCategory, category, this.indices); + this.cachedOrderedRooms.splice(roomIdx, 1); // splice out the old index (fixed position) + this.cachedOrderedRooms.splice(this.indices[category], 0, room); // splice in the new room (pre-adjusted) + // Note: if moveRoomIndexes() is called after the splice then the insert operation + // will happen in the wrong place. Because we would have already adjusted the index + // for the category, we don't need to determine how the room is moving in the list. + // If we instead tried to insert before updating the indices, we'd have to determine + // whether the room was moving later (towards IDLE) or earlier (towards RED) from its + // current position, as it'll affect the category's start index after we remove the + // room from the array. + } + + // Sort the category now that we've dumped the room in + await this.sortCategory(category); + + return true; // change made + } finally { + await this.updateLock.release(); } - - if (cause !== RoomUpdateCause.Timeline && cause !== RoomUpdateCause.ReadReceipt) { - throw new Error(`Unsupported update cause: ${cause}`); - } - - const category = this.getRoomCategory(room); - if (this.sortingAlgorithm === SortAlgorithm.Manual) { - return; // Nothing to do here. - } - - const roomIdx = this.getRoomIndex(room); - if (roomIdx === -1) { - throw new Error(`Room ${room.roomId} has no index in ${this.tagId}`); - } - - // Try to avoid doing array operations if we don't have to: only move rooms within - // the categories if we're jumping categories - const oldCategory = this.getCategoryFromIndices(roomIdx, this.indices); - if (oldCategory !== category) { - // Move the room and update the indices - this.moveRoomIndexes(1, oldCategory, category, this.indices); - this.cachedOrderedRooms.splice(roomIdx, 1); // splice out the old index (fixed position) - this.cachedOrderedRooms.splice(this.indices[category], 0, room); // splice in the new room (pre-adjusted) - // Note: if moveRoomIndexes() is called after the splice then the insert operation - // will happen in the wrong place. Because we would have already adjusted the index - // for the category, we don't need to determine how the room is moving in the list. - // If we instead tried to insert before updating the indices, we'd have to determine - // whether the room was moving later (towards IDLE) or earlier (towards RED) from its - // current position, as it'll affect the category's start index after we remove the - // room from the array. - } - - // Sort the category now that we've dumped the room in - await this.sortCategory(category); - - return true; // change made } private async sortCategory(category: Category) { @@ -292,7 +300,7 @@ export class ImportanceAlgorithm extends OrderingAlgorithm { // "should never happen" disclaimer goes here console.warn(`!! Room list index corruption: ${lastCat} (i:${indices[lastCat]}) is greater than ${thisCat} (i:${indices[thisCat]}) - category indices are likely desynced from reality`); - // TODO: Regenerate index when this happens + // TODO: Regenerate index when this happens: https://github.com/vector-im/riot-web/issues/14234 } } } diff --git a/src/stores/room-list/algorithms/list-ordering/NaturalAlgorithm.ts b/src/stores/room-list/algorithms/list-ordering/NaturalAlgorithm.ts index 96a3f58d2c..f74329cb4d 100644 --- a/src/stores/room-list/algorithms/list-ordering/NaturalAlgorithm.ts +++ b/src/stores/room-list/algorithms/list-ordering/NaturalAlgorithm.ts @@ -28,6 +28,8 @@ export class NaturalAlgorithm extends OrderingAlgorithm { public constructor(tagId: TagID, initialSortingAlgorithm: SortAlgorithm) { super(tagId, initialSortingAlgorithm); + + // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035 console.log(`[RoomListDebug] Constructed a NaturalAlgorithm for ${tagId}`); } @@ -36,23 +38,29 @@ export class NaturalAlgorithm extends OrderingAlgorithm { } public async handleRoomUpdate(room, cause): Promise { - const isSplice = cause === RoomUpdateCause.NewRoom || cause === RoomUpdateCause.RoomRemoved; - const isInPlace = cause === RoomUpdateCause.Timeline || cause === RoomUpdateCause.ReadReceipt; - if (!isSplice && !isInPlace) { - throw new Error(`Unsupported update cause: ${cause}`); + try { + await this.updateLock.acquireAsync(); + + const isSplice = cause === RoomUpdateCause.NewRoom || cause === RoomUpdateCause.RoomRemoved; + const isInPlace = cause === RoomUpdateCause.Timeline || cause === RoomUpdateCause.ReadReceipt; + if (!isSplice && !isInPlace) { + throw new Error(`Unsupported update cause: ${cause}`); + } + + if (cause === RoomUpdateCause.NewRoom) { + this.cachedOrderedRooms.push(room); + } else if (cause === RoomUpdateCause.RoomRemoved) { + const idx = this.cachedOrderedRooms.indexOf(room); + if (idx >= 0) this.cachedOrderedRooms.splice(idx, 1); + } + + // TODO: Optimize this to avoid useless operations: https://github.com/vector-im/riot-web/issues/14035 + // For example, we can skip updates to alphabetic (sometimes) and manually ordered tags + this.cachedOrderedRooms = await sortRoomsWithAlgorithm(this.cachedOrderedRooms, this.tagId, this.sortingAlgorithm); + + return true; + } finally { + await this.updateLock.release(); } - - if (cause === RoomUpdateCause.NewRoom) { - this.cachedOrderedRooms.push(room); - } else if (cause === RoomUpdateCause.RoomRemoved) { - const idx = this.cachedOrderedRooms.indexOf(room); - if (idx >= 0) this.cachedOrderedRooms.splice(idx, 1); - } - - // TODO: Optimize this to avoid useless operations - // For example, we can skip updates to alphabetic (sometimes) and manually ordered tags - this.cachedOrderedRooms = await sortRoomsWithAlgorithm(this.cachedOrderedRooms, this.tagId, this.sortingAlgorithm); - - return true; } } diff --git a/src/stores/room-list/algorithms/list-ordering/OrderingAlgorithm.ts b/src/stores/room-list/algorithms/list-ordering/OrderingAlgorithm.ts index f581e30630..4ab7650367 100644 --- a/src/stores/room-list/algorithms/list-ordering/OrderingAlgorithm.ts +++ b/src/stores/room-list/algorithms/list-ordering/OrderingAlgorithm.ts @@ -17,6 +17,7 @@ limitations under the License. import { Room } from "matrix-js-sdk/src/models/room"; import { RoomUpdateCause, TagID } from "../../models"; import { SortAlgorithm } from "../models"; +import AwaitLock from "await-lock"; /** * Represents a list ordering algorithm. Subclasses should populate the @@ -25,6 +26,7 @@ import { SortAlgorithm } from "../models"; export abstract class OrderingAlgorithm { protected cachedOrderedRooms: Room[]; protected sortingAlgorithm: SortAlgorithm; + protected readonly updateLock = new AwaitLock(); protected constructor(protected tagId: TagID, initialSortingAlgorithm: SortAlgorithm) { // noinspection JSIgnoredPromiseFromCall diff --git a/src/stores/room-list/algorithms/tag-sorting/RecentAlgorithm.ts b/src/stores/room-list/algorithms/tag-sorting/RecentAlgorithm.ts index 4e4df6c9d6..a122ee3ae6 100644 --- a/src/stores/room-list/algorithms/tag-sorting/RecentAlgorithm.ts +++ b/src/stores/room-list/algorithms/tag-sorting/RecentAlgorithm.ts @@ -32,6 +32,7 @@ export class RecentAlgorithm implements IAlgorithm { // of the rooms to each other. // TODO: We could probably improve the sorting algorithm here by finding changes. + // See https://github.com/vector-im/riot-web/issues/14035 // 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. diff --git a/src/stores/room-list/filters/CommunityFilterCondition.ts b/src/stores/room-list/filters/CommunityFilterCondition.ts index b7ff8e686d..9f7d8daaa3 100644 --- a/src/stores/room-list/filters/CommunityFilterCondition.ts +++ b/src/stores/room-list/filters/CommunityFilterCondition.ts @@ -52,6 +52,7 @@ export class CommunityFilterCondition extends EventEmitter implements IFilterCon const beforeRoomIds = this.roomIds; this.roomIds = (await GroupStore.getGroupRooms(this.community.groupId)).map(r => r.roomId); if (arrayHasDiff(beforeRoomIds, this.roomIds)) { + // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035 console.log("Updating filter for group: ", this.community.groupId); this.emit(FILTER_CHANGED); } diff --git a/src/stores/room-list/filters/NameFilterCondition.ts b/src/stores/room-list/filters/NameFilterCondition.ts index 4ac5b68596..12f147990d 100644 --- a/src/stores/room-list/filters/NameFilterCondition.ts +++ b/src/stores/room-list/filters/NameFilterCondition.ts @@ -41,6 +41,7 @@ export class NameFilterCondition extends EventEmitter implements IFilterConditio public set search(val: string) { this._search = val; + // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035 console.log("Updating filter for room name search:", this._search); this.emit(FILTER_CHANGED); } @@ -57,13 +58,17 @@ export class NameFilterCondition extends EventEmitter implements IFilterConditio } } - if (!room.name) return false; // should realisitically not happen: the js-sdk always calculates a name + if (!room.name) return false; // should realistically not happen: the js-sdk always calculates a name + return this.matches(room.name); + } + + 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(lcFilter).toLowerCase(); - const noSecretsName = removeHiddenChars(room.name.toLowerCase()).toLowerCase(); + const noSecretsFilter = removeHiddenChars(this.search.toLowerCase()).toLowerCase(); + const noSecretsName = removeHiddenChars(val.toLowerCase()).toLowerCase(); return noSecretsName.includes(noSecretsFilter); } } diff --git a/src/stores/room-list/membership.ts b/src/stores/room-list/membership.ts index 3cb4bf146c..9f1c5b7b41 100644 --- a/src/stores/room-list/membership.ts +++ b/src/stores/room-list/membership.ts @@ -63,7 +63,7 @@ export function getEffectiveMembership(membership: string): EffectiveMembership if (membership === 'invite') { return EffectiveMembership.Invite; } else if (membership === 'join') { - // TODO: Do the same for knock? Update docs as needed in the enum. + // TODO: Include knocks? Update docs as needed in the enum. https://github.com/vector-im/riot-web/issues/14237 return EffectiveMembership.Join; } else { // Probably a leave, kick, or ban diff --git a/src/stores/room-list/previews/CallAnswerEventPreview.ts b/src/stores/room-list/previews/CallAnswerEventPreview.ts new file mode 100644 index 0000000000..b7207307e2 --- /dev/null +++ b/src/stores/room-list/previews/CallAnswerEventPreview.ts @@ -0,0 +1,35 @@ +/* +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. +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 { IPreview } from "./IPreview"; +import { TagID } from "../models"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { getSenderName, isSelf, shouldPrefixMessagesIn } from "./utils"; +import { _t } from "../../../languageHandler"; + +export class CallAnswerEventPreview implements IPreview { + public getTextFor(event: MatrixEvent, tagId?: TagID): string { + if (shouldPrefixMessagesIn(event.getRoomId(), tagId)) { + if (isSelf(event)) { + return _t("You joined the call"); + } else { + return _t("%(senderName)s joined the call", {senderName: getSenderName(event)}); + } + } else { + return _t("Call in progress"); + } + } +} diff --git a/src/stores/room-list/previews/CallHangupEvent.ts b/src/stores/room-list/previews/CallHangupEvent.ts new file mode 100644 index 0000000000..adc7d1aac8 --- /dev/null +++ b/src/stores/room-list/previews/CallHangupEvent.ts @@ -0,0 +1,35 @@ +/* +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. +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 { IPreview } from "./IPreview"; +import { TagID } from "../models"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { getSenderName, isSelf, shouldPrefixMessagesIn } from "./utils"; +import { _t } from "../../../languageHandler"; + +export class CallHangupEvent implements IPreview { + public getTextFor(event: MatrixEvent, tagId?: TagID): string { + if (shouldPrefixMessagesIn(event.getRoomId(), tagId)) { + if (isSelf(event)) { + return _t("You left the call"); + } else { + return _t("%(senderName)s left the call", {senderName: getSenderName(event)}); + } + } else { + return _t("Call ended"); + } + } +} diff --git a/src/stores/room-list/previews/CallInviteEventPreview.ts b/src/stores/room-list/previews/CallInviteEventPreview.ts new file mode 100644 index 0000000000..47486e3701 --- /dev/null +++ b/src/stores/room-list/previews/CallInviteEventPreview.ts @@ -0,0 +1,39 @@ +/* +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. +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 { IPreview } from "./IPreview"; +import { TagID } from "../models"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { getSenderName, isSelf, shouldPrefixMessagesIn } from "./utils"; +import { _t } from "../../../languageHandler"; + +export class CallInviteEventPreview implements IPreview { + public getTextFor(event: MatrixEvent, tagId?: TagID): string { + if (shouldPrefixMessagesIn(event.getRoomId(), tagId)) { + if (isSelf(event)) { + return _t("You started a call"); + } else { + return _t("%(senderName)s started a call", {senderName: getSenderName(event)}); + } + } else { + if (isSelf(event)) { + return _t("Waiting for answer"); + } else { + return _t("%(senderName)s is calling", {senderName: getSenderName(event)}); + } + } + } +} diff --git a/src/stores/room-list/previews/CreationEventPreview.ts b/src/stores/room-list/previews/CreationEventPreview.ts new file mode 100644 index 0000000000..62bb5fe53a --- /dev/null +++ b/src/stores/room-list/previews/CreationEventPreview.ts @@ -0,0 +1,31 @@ +/* +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. +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 { IPreview } from "./IPreview"; +import { TagID } from "../models"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { getSenderName, isSelf } from "./utils"; +import { _t } from "../../../languageHandler"; + +export class CreationEventPreview implements IPreview { + public getTextFor(event: MatrixEvent, tagId?: TagID): string { + if (isSelf(event)) { + return _t("You created the room"); + } else { + return _t("%(senderName)s created the room", {senderName: getSenderName(event)}); + } + } +} diff --git a/src/stores/room-list/previews/EncryptionEventPreview.ts b/src/stores/room-list/previews/EncryptionEventPreview.ts new file mode 100644 index 0000000000..d00fd7e7f9 --- /dev/null +++ b/src/stores/room-list/previews/EncryptionEventPreview.ts @@ -0,0 +1,31 @@ +/* +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. +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 { IPreview } from "./IPreview"; +import { TagID } from "../models"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { getSenderName, isSelf } from "./utils"; +import { _t } from "../../../languageHandler"; + +export class EncryptionEventPreview implements IPreview { + public getTextFor(event: MatrixEvent, tagId?: TagID): string { + if (isSelf(event)) { + return _t("You made the chat encrypted"); + } else { + return _t("%(senderName)s made the chat encrypted", {senderName: getSenderName(event)}); + } + } +} diff --git a/src/stores/room-list/previews/HistoryVisibilityEventPreview.ts b/src/stores/room-list/previews/HistoryVisibilityEventPreview.ts new file mode 100644 index 0000000000..ac77a181f8 --- /dev/null +++ b/src/stores/room-list/previews/HistoryVisibilityEventPreview.ts @@ -0,0 +1,42 @@ +/* +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. +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 { IPreview } from "./IPreview"; +import { TagID } from "../models"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { getSenderName, isSelf } from "./utils"; +import { _t } from "../../../languageHandler"; + +export class HistoryVisibilityEventPreview implements IPreview { + public getTextFor(event: MatrixEvent, tagId?: TagID): string { + const visibility = event.getContent()['history_visibility']; + const isUs = isSelf(event); + + if (visibility === 'invited' || visibility === 'joined') { + return isUs + ? _t("You made history visible to new members") + : _t("%(senderName)s made history visible to new members", {senderName: getSenderName(event)}); + } else if (visibility === 'world_readable') { + return isUs + ? _t("You made history visible to anyone") + : _t("%(senderName)s made history visible to anyone", {senderName: getSenderName(event)}); + } else { // shared, default + return isUs + ? _t("You made history visible to future members") + : _t("%(senderName)s made history visible to future members", {senderName: getSenderName(event)}); + } + } +} diff --git a/src/stores/room-list/previews/IPreview.ts b/src/stores/room-list/previews/IPreview.ts new file mode 100644 index 0000000000..9beb92bfbf --- /dev/null +++ b/src/stores/room-list/previews/IPreview.ts @@ -0,0 +1,31 @@ +/* +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. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { TagID } from "../models"; + +/** + * Represents an event preview. + */ +export interface IPreview { + /** + * Gets the text which represents the event as a preview. + * @param event The event to preview. + * @param tagId Optional. The tag where the room the event was sent in resides. + * @returns The preview. + */ + getTextFor(event: MatrixEvent, tagId?: TagID): string; +} diff --git a/src/stores/room-list/previews/MembershipEventPreview.ts b/src/stores/room-list/previews/MembershipEventPreview.ts new file mode 100644 index 0000000000..44339aab5f --- /dev/null +++ b/src/stores/room-list/previews/MembershipEventPreview.ts @@ -0,0 +1,90 @@ +/* +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. +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 { IPreview } from "./IPreview"; +import { TagID } from "../models"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { getTargetName, isSelfTarget } from "./utils"; +import { _t } from "../../../languageHandler"; + +export class MembershipEventPreview implements IPreview { + public getTextFor(event: MatrixEvent, tagId?: TagID): string { + const newMembership = event.getContent()['membership']; + const oldMembership = event.getPrevContent()['membership']; + const reason = event.getContent()['reason']; + const isUs = isSelfTarget(event); + + if (newMembership === 'invite') { + return isUs + ? _t("You were invited") + : _t("%(targetName)s was invited", {targetName: getTargetName(event)}); + } else if (newMembership === 'leave' && oldMembership !== 'invite') { + if (event.getSender() === event.getStateKey()) { + return isUs + ? _t("You left") + : _t("%(targetName)s left", {targetName: getTargetName(event)}); + } else { + if (reason) { + return isUs + ? _t("You were kicked (%(reason)s)", {reason}) + : _t("%(targetName)s was kicked (%(reason)s)", {targetName: getTargetName(event), reason}); + } else { + return isUs + ? _t("You were kicked") + : _t("%(targetName)s was kicked", {targetName: getTargetName(event)}); + } + } + } else if (newMembership === 'leave' && oldMembership === 'invite') { + if (event.getSender() === event.getStateKey()) { + return isUs + ? _t("You rejected the invite") + : _t("%(targetName)s rejected the invite", {targetName: getTargetName(event)}); + } else { + return isUs + ? _t("You were uninvited") + : _t("%(targetName)s was uninvited", {targetName: getTargetName(event)}); + } + } else if (newMembership === 'ban') { + if (reason) { + return isUs + ? _t("You were banned (%(reason)s)", {reason}) + : _t("%(targetName)s was banned (%(reason)s)", {targetName: getTargetName(event), reason}); + } else { + return isUs + ? _t("You were banned") + : _t("%(targetName)s was banned", {targetName: getTargetName(event)}); + } + } else if (newMembership === 'join' && oldMembership !== 'join') { + return isUs + ? _t("You joined") + : _t("%(targetName)s joined", {targetName: getTargetName(event)}); + } else { + const isDisplayNameChange = event.getContent()['displayname'] !== event.getPrevContent()['displayname']; + const isAvatarChange = event.getContent()['avatar_url'] !== event.getPrevContent()['avatar_url']; + if (isDisplayNameChange) { + return isUs + ? _t("You changed your name") + : _t("%(targetName)s changed their name", {targetName: getTargetName(event)}); + } else if (isAvatarChange) { + return isUs + ? _t("You changed your avatar") + : _t("%(targetName)s changed their avatar", {targetName: getTargetName(event)}); + } else { + return null; // no change + } + } + } +} diff --git a/src/stores/room-list/previews/MessageEventPreview.ts b/src/stores/room-list/previews/MessageEventPreview.ts new file mode 100644 index 0000000000..86ec4c539b --- /dev/null +++ b/src/stores/room-list/previews/MessageEventPreview.ts @@ -0,0 +1,57 @@ +/* +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. +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 { IPreview } from "./IPreview"; +import { TagID } from "../models"; +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"; + +export class MessageEventPreview implements IPreview { + public getTextFor(event: MatrixEvent, tagId?: TagID): string { + let eventContent = event.getContent(); + + if (event.isRelation("m.replace")) { + // It's an edit, generate the preview on the new text + eventContent = event.getContent()['m.new_content']; + } + + if (!eventContent || !eventContent['body']) return null; // invalid for our purposes + + let body = (eventContent['body'] || '').trim(); + const msgtype = eventContent['msgtype']; + if (!body || !msgtype) return null; // invalid event, no preview + + // XXX: Newer relations have a getRelation() function which is not compatible with replies. + const mRelatesTo = event.getWireContent()['m.relates_to']; + if (mRelatesTo && mRelatesTo['m.in_reply_to']) { + // If this is a reply, get the real reply and use that + body = (ReplyThread.stripPlainReply(body) || '').trim(); + if (!body) return null; // invalid event, no preview + } + + if (msgtype === 'm.emote') { + return _t("%(senderName)s %(emote)s", {senderName: getSenderName(event), emote: body}); + } + + if (isSelf(event) || !shouldPrefixMessagesIn(event.getRoomId(), tagId)) { + return body; + } else { + return _t("%(senderName)s: %(message)s", {senderName: getSenderName(event), message: body}); + } + } +} diff --git a/src/stores/room-list/previews/NameEventPreview.ts b/src/stores/room-list/previews/NameEventPreview.ts new file mode 100644 index 0000000000..4197abacfb --- /dev/null +++ b/src/stores/room-list/previews/NameEventPreview.ts @@ -0,0 +1,31 @@ +/* +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. +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 { IPreview } from "./IPreview"; +import { TagID } from "../models"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { getSenderName, isSelf } from "./utils"; +import { _t } from "../../../languageHandler"; + +export class NameEventPreview implements IPreview { + public getTextFor(event: MatrixEvent, tagId?: TagID): string { + if (isSelf(event)) { + return _t("You changed the room name"); + } else { + return _t("%(senderName)s changed the room name", {senderName: getSenderName(event)}); + } + } +} diff --git a/src/stores/room-list/previews/ReactionEventPreview.ts b/src/stores/room-list/previews/ReactionEventPreview.ts new file mode 100644 index 0000000000..07fac107ca --- /dev/null +++ b/src/stores/room-list/previews/ReactionEventPreview.ts @@ -0,0 +1,37 @@ +/* +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. +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 { IPreview } from "./IPreview"; +import { TagID } from "../models"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { getSenderName, isSelf, shouldPrefixMessagesIn } from "./utils"; +import { _t } from "../../../languageHandler"; + +export class ReactionEventPreview implements IPreview { + public getTextFor(event: MatrixEvent, tagId?: TagID): string { + const relation = event.getRelation(); + if (!relation) return null; // invalid reaction (probably redacted) + + const reaction = relation.key; + if (!reaction) return null; // invalid reaction (unknown format) + + if (isSelf(event) || !shouldPrefixMessagesIn(event.getRoomId(), tagId)) { + return reaction; + } else { + return _t("%(senderName)s: %(reaction)s", {senderName: getSenderName(event), reaction}); + } + } +} diff --git a/src/stores/room-list/previews/StickerEventPreview.ts b/src/stores/room-list/previews/StickerEventPreview.ts new file mode 100644 index 0000000000..f8263a4a45 --- /dev/null +++ b/src/stores/room-list/previews/StickerEventPreview.ts @@ -0,0 +1,34 @@ +/* +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. +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 { IPreview } from "./IPreview"; +import { TagID } from "../models"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { getSenderName, isSelf, shouldPrefixMessagesIn } from "./utils"; +import { _t } from "../../../languageHandler"; + +export class StickerEventPreview implements IPreview { + public getTextFor(event: MatrixEvent, tagId?: TagID): string { + const stickerName = event.getContent()['body']; + if (!stickerName) return null; + + if (isSelf(event) || !shouldPrefixMessagesIn(event.getRoomId(), tagId)) { + return stickerName; + } else { + return _t("%(senderName)s: %(stickerName)s", {senderName: getSenderName(event), stickerName}); + } + } +} diff --git a/src/stores/room-list/previews/ThirdPartyInviteEventPreview.ts b/src/stores/room-list/previews/ThirdPartyInviteEventPreview.ts new file mode 100644 index 0000000000..b22cd9fac9 --- /dev/null +++ b/src/stores/room-list/previews/ThirdPartyInviteEventPreview.ts @@ -0,0 +1,42 @@ +/* +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. +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 { IPreview } from "./IPreview"; +import { TagID } from "../models"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { getSenderName, isSelf } from "./utils"; +import { _t } from "../../../languageHandler"; +import { isValid3pidInvite } from "../../../RoomInvite"; + +export class ThirdPartyInviteEventPreview implements IPreview { + public getTextFor(event: MatrixEvent, tagId?: TagID): string { + if (!isValid3pidInvite(event)) { + const targetName = event.getPrevContent().display_name || _t("Someone"); + if (isSelf(event)) { + return _t("You uninvited %(targetName)s", {targetName}); + } else { + return _t("%(senderName)s uninvited %(targetName)s", {senderName: getSenderName(event), targetName}); + } + } else { + const targetName = event.getContent().display_name; + if (isSelf(event)) { + return _t("You invited %(targetName)s", {targetName}); + } else { + return _t("%(senderName)s invited %(targetName)s", {senderName: getSenderName(event), targetName}); + } + } + } +} diff --git a/src/stores/room-list/previews/TopicEventPreview.ts b/src/stores/room-list/previews/TopicEventPreview.ts new file mode 100644 index 0000000000..9b499aae8f --- /dev/null +++ b/src/stores/room-list/previews/TopicEventPreview.ts @@ -0,0 +1,31 @@ +/* +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. +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 { IPreview } from "./IPreview"; +import { TagID } from "../models"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { getSenderName, isSelf } from "./utils"; +import { _t } from "../../../languageHandler"; + +export class TopicEventPreview implements IPreview { + public getTextFor(event: MatrixEvent, tagId?: TagID): string { + if (isSelf(event)) { + return _t("You changed the room topic"); + } else { + return _t("%(senderName)s changed the room topic", {senderName: getSenderName(event)}); + } + } +} diff --git a/src/stores/room-list/previews/utils.ts b/src/stores/room-list/previews/utils.ts new file mode 100644 index 0000000000..ebbecd7bbd --- /dev/null +++ b/src/stores/room-list/previews/utils.ts @@ -0,0 +1,49 @@ +/* +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. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { MatrixClientPeg } from "../../../MatrixClientPeg"; +import { DefaultTagID, TagID } from "../models"; + +export function isSelf(event: MatrixEvent): boolean { + const selfUserId = MatrixClientPeg.get().getUserId(); + if (event.getType() === 'm.room.member') { + return event.getStateKey() === selfUserId; + } + return event.getSender() === selfUserId; +} + +export function isSelfTarget(event: MatrixEvent): boolean { + const selfUserId = MatrixClientPeg.get().getUserId(); + return event.getStateKey() === selfUserId; +} + +export function shouldPrefixMessagesIn(roomId: string, tagId: TagID): boolean { + if (tagId !== DefaultTagID.DM) return true; + + // We don't prefix anything in 1:1s + const room = MatrixClientPeg.get().getRoom(roomId); + if (!room) return true; + return room.currentState.getJoinedMemberCount() !== 2; +} + +export function getSenderName(event: MatrixEvent): string { + return event.sender ? event.sender.name : event.getSender(); +} + +export function getTargetName(event: MatrixEvent): string { + return event.target ? event.target.name : event.getStateKey(); +} diff --git a/src/theme.js b/src/theme.js index 22cfd8b076..c79e466933 100644 --- a/src/theme.js +++ b/src/theme.js @@ -35,11 +35,67 @@ export function enumerateThemes() { return Object.assign({}, customThemeNames, BUILTIN_THEMES); } +function clearCustomTheme() { + // remove all css variables, we assume these are there because of the custom theme + const inlineStyleProps = Object.values(document.body.style); + for (const prop of inlineStyleProps) { + if (prop.startsWith("--")) { + document.body.style.removeProperty(prop); + } + } + const customFontFaceStyle = document.querySelector("head > style[title='custom-theme-font-faces']"); + if (customFontFaceStyle) { + customFontFaceStyle.remove(); + } +} + +const allowedFontFaceProps = [ + "font-display", + "font-family", + "font-stretch", + "font-style", + "font-weight", + "font-variant", + "font-feature-settings", + "font-variation-settings", + "src", + "unicode-range", +]; + +function generateCustomFontFaceCSS(faces) { + return faces.map(face => { + const src = face.src && face.src.map(srcElement => { + let format; + if (srcElement.format) { + format = `format("${srcElement.format}")`; + } + if (srcElement.url) { + return `url("${srcElement.url}") ${format}`; + } else if (srcElement.local) { + return `local("${srcElement.local}") ${format}`; + } + return ""; + }).join(", "); + const props = Object.keys(face).filter(prop => allowedFontFaceProps.includes(prop)); + const body = props.map(prop => { + let value; + if (prop === "src") { + value = src; + } else if (prop === "font-family") { + value = `"${face[prop]}"`; + } else { + value = face[prop]; + } + return `${prop}: ${value}`; + }).join(";"); + return `@font-face {${body}}`; + }).join("\n"); +} function setCustomThemeVars(customTheme) { const {style} = document.body; - function setCSSVariable(name, hexColor, doPct = true) { + function setCSSColorVariable(name, hexColor, doPct = true) { style.setProperty(`--${name}`, hexColor); if (doPct) { // uses #rrggbbaa to define the color with alpha values at 0%, 15% and 50% @@ -53,13 +109,30 @@ function setCustomThemeVars(customTheme) { for (const [name, value] of Object.entries(customTheme.colors)) { if (Array.isArray(value)) { for (let i = 0; i < value.length; i += 1) { - setCSSVariable(`${name}_${i}`, value[i], false); + setCSSColorVariable(`${name}_${i}`, value[i], false); } } else { - setCSSVariable(name, value); + setCSSColorVariable(name, value); } } } + if (customTheme.fonts) { + const {fonts} = customTheme; + if (fonts.faces) { + const css = generateCustomFontFaceCSS(fonts.faces); + const style = document.createElement("style"); + style.setAttribute("title", "custom-theme-font-faces"); + style.setAttribute("type", "text/css"); + style.appendChild(document.createTextNode(css)); + document.head.appendChild(style); + } + if (fonts.general) { + style.setProperty("--font-family", fonts.general); + } + if (fonts.monospace) { + style.setProperty("--font-family-monospace", fonts.monospace); + } + } } export function getCustomTheme(themeName) { @@ -88,6 +161,7 @@ export async function setTheme(theme) { const themeWatcher = new ThemeWatcher(); theme = themeWatcher.getEffectiveTheme(); } + clearCustomTheme(); let stylesheetName = theme; if (theme.startsWith("custom-")) { const customTheme = getCustomTheme(theme.substr(7)); @@ -136,7 +210,7 @@ export async function setTheme(theme) { if (a == styleElements[stylesheetName]) return; a.disabled = true; }); - const bodyStyles = global.getComputedStyle(document.getElementsByTagName("body")[0]); + const bodyStyles = global.getComputedStyle(document.body); if (bodyStyles.backgroundColor) { document.querySelector('meta[name="theme-color"]').content = bodyStyles.backgroundColor; } diff --git a/src/toasts/AnalyticsToast.tsx b/src/toasts/AnalyticsToast.tsx index 7cd59222dd..b186a65d9d 100644 --- a/src/toasts/AnalyticsToast.tsx +++ b/src/toasts/AnalyticsToast.tsx @@ -24,14 +24,12 @@ import GenericToast from "../components/views/toasts/GenericToast"; import ToastStore from "../stores/ToastStore"; const onAccept = () => { - console.log("DEBUG onAccept AnalyticsToast"); dis.dispatch({ action: 'accept_cookies', }); }; const onReject = () => { - console.log("DEBUG onReject AnalyticsToast"); dis.dispatch({ action: "reject_cookies", }); diff --git a/src/utils/ResizeNotifier.js b/src/utils/ResizeNotifier.js index d65bc4bd07..f726a43e08 100644 --- a/src/utils/ResizeNotifier.js +++ b/src/utils/ResizeNotifier.js @@ -15,9 +15,13 @@ limitations under the License. */ /** - * Fires when the middle panel has been resized. + * Fires when the middle panel has been resized (throttled). * @event module:utils~ResizeNotifier#"middlePanelResized" */ +/** + * Fires when the middle panel has been resized by a pixel. + * @event module:utils~ResizeNotifier#"middlePanelResizedNoisy" + */ import { EventEmitter } from "events"; import { throttle } from "lodash"; @@ -29,15 +33,24 @@ export default class ResizeNotifier extends EventEmitter { this._throttledMiddlePanel = throttle(() => this.emit("middlePanelResized"), 200); } + _noisyMiddlePanel() { + this.emit("middlePanelResizedNoisy"); + } + + _updateMiddlePanel() { + this._throttledMiddlePanel(); + this._noisyMiddlePanel(); + } + // can be called in quick succession notifyLeftHandleResized() { // don't emit event for own region - this._throttledMiddlePanel(); + this._updateMiddlePanel(); } // can be called in quick succession notifyRightHandleResized() { - this._throttledMiddlePanel(); + this._updateMiddlePanel(); } // can be called in quick succession @@ -48,7 +61,7 @@ export default class ResizeNotifier extends EventEmitter { // taller than the available space this.emit("leftPanelResized"); - this._throttledMiddlePanel(); + this._updateMiddlePanel(); } } diff --git a/src/utils/WidgetUtils.js b/src/utils/WidgetUtils.js index b48ec481ba..f7f4be202b 100644 --- a/src/utils/WidgetUtils.js +++ b/src/utils/WidgetUtils.js @@ -31,6 +31,7 @@ import {IntegrationManagers} from "../integrations/IntegrationManagers"; import {Capability} from "../widgets/WidgetApi"; import {Room} from "matrix-js-sdk/src/models/room"; import {WidgetType} from "../widgets/WidgetType"; +import {objectClone} from "./objects"; export default class WidgetUtils { /* Returns true if user is able to send state events to modify widgets in this room @@ -222,7 +223,7 @@ export default class WidgetUtils { const client = MatrixClientPeg.get(); // Get the current widgets and clone them before we modify them, otherwise // we'll modify the content of the old event. - const userWidgets = JSON.parse(JSON.stringify(WidgetUtils.getUserWidgets())); + const userWidgets = objectClone(WidgetUtils.getUserWidgets()); // Delete existing widget with ID try { diff --git a/src/utils/arrays.ts b/src/utils/arrays.ts index fea376afcd..8175d89464 100644 --- a/src/utils/arrays.ts +++ b/src/utils/arrays.ts @@ -46,6 +46,28 @@ export function arrayDiff(a: T[], b: T[]): { added: T[], removed: T[] } { }; } +/** + * Returns the union of two arrays. + * @param a The first array. Must be defined. + * @param b The second array. Must be defined. + * @returns The union of the arrays. + */ +export function arrayUnion(a: T[], b: T[]): T[] { + return a.filter(i => b.includes(i)); +} + +/** + * Merges arrays, deduping contents using a Set. + * @param a The arrays to merge. + * @returns The merged array. + */ +export function arrayMerge(...a: T[][]): T[] { + return Array.from(a.reduce((c, v) => { + v.forEach(i => c.add(i)); + return c; + }, new Set())); +} + /** * Helper functions to perform LINQ-like queries on arrays. */ diff --git a/src/utils/objects.ts b/src/utils/objects.ts new file mode 100644 index 0000000000..14fa928ce2 --- /dev/null +++ b/src/utils/objects.ts @@ -0,0 +1,60 @@ +/* +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. +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, arrayMerge, arrayUnion } from "./arrays"; + +/** + * Determines the keys added, changed, and removed between two objects. + * For changes, simple triple equal comparisons are done, not in-depth + * tree checking. + * @param a The first object. Must be defined. + * @param b The second object. Must be defined. + * @returns The difference between the keys of each object. + */ +export function objectDiff(a: any, b: any): { changed: string[], added: string[], removed: string[] } { + const aKeys = Object.keys(a); + const bKeys = Object.keys(b); + const keyDiff = arrayDiff(aKeys, bKeys); + const possibleChanges = arrayUnion(aKeys, bKeys); + const changes = possibleChanges.filter(k => a[k] !== b[k]); + + return {changed: changes, added: keyDiff.added, removed: keyDiff.removed}; +} + +/** + * Gets all the key changes (added, removed, or value difference) between + * two objects. Triple equals is used to compare values, not in-depth tree + * checking. + * @param a The first object. Must be defined. + * @param b The second object. Must be defined. + * @returns The keys which have been added, removed, or changed between the + * two objects. + */ +export function objectKeyChanges(a: any, b: any): string[] { + const diff = objectDiff(a, b); + return arrayMerge(diff.removed, diff.added, diff.changed); +} + +/** + * Clones an object by running it through JSON parsing. Note that this + * will destroy any complicated object types which do not translate to + * JSON. + * @param obj The object to clone. + * @returns The cloned object + */ +export function objectClone(obj: any): any { + return JSON.parse(JSON.stringify(obj)); +} diff --git a/src/utils/units.ts b/src/utils/units.ts index 54dd6b0523..03775f4c21 100644 --- a/src/utils/units.ts +++ b/src/utils/units.ts @@ -19,7 +19,7 @@ limitations under the License. // converts a pixel value to rem. export function toRem(pixelValue: number): string { - return pixelValue / 15 + "rem"; + return pixelValue / 10 + "rem"; } export function toPx(pixelValue: number): string { diff --git a/src/widgets/WidgetApi.ts b/src/widgets/WidgetApi.ts index 795c6648ef..d5f2f2258e 100644 --- a/src/widgets/WidgetApi.ts +++ b/src/widgets/WidgetApi.ts @@ -19,6 +19,7 @@ limitations under the License. import { randomString } from "matrix-js-sdk/src/randomstring"; import { EventEmitter } from "events"; +import { objectClone } from "../utils/objects"; export enum Capability { Screenshot = "m.capability.screenshot", @@ -140,7 +141,7 @@ export class WidgetApi extends EventEmitter { private replyToRequest(payload: ToWidgetRequest, reply: any) { if (!window.parent) return; - const request = JSON.parse(JSON.stringify(payload)); + const request = objectClone(payload); request.response = reply; window.parent.postMessage(request, this.origin); diff --git a/test/components/views/dialogs/AccessSecretStorageDialog-test.js b/test/components/views/dialogs/AccessSecretStorageDialog-test.js index c754a4b607..5a8dcbf763 100644 --- a/test/components/views/dialogs/AccessSecretStorageDialog-test.js +++ b/test/components/views/dialogs/AccessSecretStorageDialog-test.js @@ -40,19 +40,20 @@ describe("AccessSecretStorageDialog", function() { testInstance.getInstance()._onRecoveryKeyNext(e); }); - it("Considers a valid key to be valid", function() { + it("Considers a valid key to be valid", async function() { const testInstance = TestRenderer.create( true} />, ); - const v = "asfd"; + const v = "asdf"; const e = { target: { value: v } }; stubClient(); - MatrixClientPeg.get().isValidRecoveryKey = function(k) { - return k == v; - }; + MatrixClientPeg.get().keyBackupKeyFromRecoveryKey = () => 'a raw key'; + MatrixClientPeg.get().checkSecretStorageKey = () => true; testInstance.getInstance()._onRecoveryKeyChange(e); + // force a validation now because it debounces + await testInstance.getInstance()._validateRecoveryKey(); const { recoveryKeyValid } = testInstance.getInstance().state; expect(recoveryKeyValid).toBe(true); }); @@ -65,17 +66,21 @@ describe("AccessSecretStorageDialog", function() { ); const e = { target: { value: "a" } }; stubClient(); - MatrixClientPeg.get().isValidRecoveryKey = () => true; + MatrixClientPeg.get().keyBackupKeyFromRecoveryKey = () => { + throw new Error("that's no key"); + }; testInstance.getInstance()._onRecoveryKeyChange(e); - await testInstance.getInstance()._onRecoveryKeyNext({ preventDefault: () => {} }); - const { keyMatches } = testInstance.getInstance().state; - expect(keyMatches).toBe(false); + // force a validation now because it debounces + await testInstance.getInstance()._validateRecoveryKey(); + + const { recoveryKeyValid, recoveryKeyCorrect } = testInstance.getInstance().state; + expect(recoveryKeyValid).toBe(false); + expect(recoveryKeyCorrect).toBe(false); const notification = testInstance.root.findByProps({ - className: "mx_AccessSecretStorageDialog_keyStatus", + className: "mx_AccessSecretStorageDialog_recoveryKeyFeedback " + + "mx_AccessSecretStorageDialog_recoveryKeyFeedback_invalid", }); - expect(notification.props.children).toEqual( - ["\uD83D\uDC4E ", "Unable to access secret storage. Please verify that you " + - "entered the correct recovery key."]); + expect(notification.props.children).toEqual("Invalid Recovery Key"); done(); }); diff --git a/test/end-to-end-tests/src/usecases/signup.js b/test/end-to-end-tests/src/usecases/signup.js index aa9f6b7efa..fd41ef1a71 100644 --- a/test/end-to-end-tests/src/usecases/signup.js +++ b/test/end-to-end-tests/src/usecases/signup.js @@ -79,20 +79,8 @@ module.exports = async function signup(session, username, password, homeserver) const acceptButton = await session.query('.mx_InteractiveAuthEntryComponents_termsSubmit'); await acceptButton.click(); - //plow through cross-signing setup by entering arbitrary details - //TODO: It's probably important for the tests to know the passphrase - const xsigningPassphrase = 'a7eaXcjpa9!Yl7#V^h$B^%dovHUVX'; // https://xkcd.com/221/ - let passphraseField = await session.query('.mx_CreateSecretStorageDialog_passPhraseField input'); - await session.replaceInputText(passphraseField, xsigningPassphrase); - await session.delay(1000); // give it a second to analyze our passphrase for security - let xsignContButton = await session.query('.mx_CreateSecretStorageDialog .mx_Dialog_buttons .mx_Dialog_primary'); - await xsignContButton.click(); - - //repeat passphrase entry - passphraseField = await session.query('.mx_CreateSecretStorageDialog_passPhraseField input'); - await session.replaceInputText(passphraseField, xsigningPassphrase); - await session.delay(1000); // give it a second to analyze our passphrase for security - xsignContButton = await session.query('.mx_CreateSecretStorageDialog .mx_Dialog_buttons .mx_Dialog_primary'); + // Continue with the default (generate a security key) + const xsignContButton = await session.query('.mx_CreateSecretStorageDialog .mx_Dialog_buttons .mx_Dialog_primary'); await xsignContButton.click(); //ignore the recovery key @@ -101,13 +89,11 @@ module.exports = async function signup(session, username, password, homeserver) await copyButton.click(); //acknowledge that we copied the recovery key to a safe place - const copyContinueButton = await session.query('.mx_CreateSecretStorageDialog .mx_Dialog_primary'); + const copyContinueButton = await session.query( + '.mx_CreateSecretStorageDialog .mx_Dialog_buttons .mx_Dialog_primary', + ); await copyContinueButton.click(); - //acknowledge that we're done cross-signing setup and our keys are safe - const doneOkButton = await session.query('.mx_CreateSecretStorageDialog .mx_Dialog_primary'); - await doneOkButton.click(); - //wait for registration to finish so the hash gets set //onhashchange better? diff --git a/yarn.lock b/yarn.lock index d2d53692b5..98b42a0b29 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5821,8 +5821,8 @@ mathml-tag-names@^2.0.1: integrity sha512-APMBEanjybaPzUrfqU0IMU5I0AswKMH7k8OTLs0vvV4KZpExkTkY87nR/zpbuTPj+gARop7aGUbl11pnDfW6xg== "matrix-js-sdk@github:matrix-org/matrix-js-sdk#develop": - version "6.2.2" - resolved "https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/1c194e81637fb07fe6ad67cda33be0d5d4c10115" + version "7.1.0" + resolved "https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/2a688bdac828dc62916437d83c72cef1e525d5f9" dependencies: "@babel/runtime" "^7.8.3" another-json "^0.2.0"