From 450ddbe2b0c22362e17f540ee89aa5f8800eb089 Mon Sep 17 00:00:00 2001 From: Element Translate Bot Date: Tue, 18 Oct 2022 13:40:40 +0200 Subject: [PATCH 01/11] Translations update from Weblate (#9448) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Translated using Weblate (German) Currently translated at 100.0% (3570 of 3570 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/de/ * Translated using Weblate (German) Currently translated at 100.0% (3570 of 3570 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/de/ * Translated using Weblate (German) Currently translated at 100.0% (3570 of 3570 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/de/ * Added translation using Weblate (Luxembourgish) * Translated using Weblate (German) Currently translated at 99.9% (3568 of 3570 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/de/ * Translated using Weblate (French) Currently translated at 100.0% (3570 of 3570 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/fr/ * Translated using Weblate (Ukrainian) Currently translated at 100.0% (3570 of 3570 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/uk/ * Translated using Weblate (Russian) Currently translated at 99.0% (3536 of 3570 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/ru/ * Translated using Weblate (Chinese (Traditional)) Currently translated at 100.0% (3570 of 3570 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/zh_Hant/ * Translated using Weblate (Czech) Currently translated at 100.0% (3570 of 3570 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/cs/ * Translated using Weblate (Slovak) Currently translated at 100.0% (3570 of 3570 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/sk/ * Translated using Weblate (German) Currently translated at 100.0% (3570 of 3570 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/de/ * Translated using Weblate (German) Currently translated at 100.0% (3570 of 3570 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/de/ * Translated using Weblate (Italian) Currently translated at 100.0% (3570 of 3570 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/it/ * Translated using Weblate (Indonesian) Currently translated at 100.0% (3570 of 3570 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/id/ * Translated using Weblate (Icelandic) Currently translated at 88.2% (3150 of 3570 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/is/ * Translated using Weblate (Icelandic) Currently translated at 88.9% (3174 of 3570 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/is/ * Translated using Weblate (German) Currently translated at 100.0% (3568 of 3568 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/de/ * Translated using Weblate (Russian) Currently translated at 99.0% (3534 of 3568 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/ru/ * Translated using Weblate (Ukrainian) Currently translated at 100.0% (3568 of 3568 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/uk/ * Translated using Weblate (German) Currently translated at 100.0% (3571 of 3571 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/de/ * Translated using Weblate (Italian) Currently translated at 100.0% (3571 of 3571 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/it/ * Translated using Weblate (German) Currently translated at 100.0% (3571 of 3571 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/de/ * Translated using Weblate (Ukrainian) Currently translated at 100.0% (3571 of 3571 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/uk/ * Translated using Weblate (German) Currently translated at 100.0% (3575 of 3575 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/de/ * Translated using Weblate (Ukrainian) Currently translated at 100.0% (3575 of 3575 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/uk/ * Translated using Weblate (Icelandic) Currently translated at 89.1% (3188 of 3575 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/is/ * Translated using Weblate (Chinese (Traditional)) Currently translated at 100.0% (3575 of 3575 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/zh_Hant/ * Translated using Weblate (Slovak) Currently translated at 100.0% (3575 of 3575 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/sk/ * Translated using Weblate (Icelandic) Currently translated at 89.7% (3207 of 3575 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/is/ * Translated using Weblate (German) Currently translated at 99.9% (3579 of 3580 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/de/ * Translated using Weblate (Czech) Currently translated at 100.0% (3580 of 3580 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/cs/ * Translated using Weblate (Estonian) Currently translated at 100.0% (3580 of 3580 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/et/ * Translated using Weblate (French) Currently translated at 99.7% (3572 of 3580 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/fr/ * Translated using Weblate (French) Currently translated at 99.8% (3574 of 3580 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/fr/ * Translated using Weblate (French) Currently translated at 99.8% (3575 of 3580 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/fr/ * Translated using Weblate (German) Currently translated at 99.9% (3582 of 3583 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/de/ * Translated using Weblate (Czech) Currently translated at 100.0% (3583 of 3583 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/cs/ * Translated using Weblate (German) Currently translated at 100.0% (3583 of 3583 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/de/ * Translated using Weblate (Italian) Currently translated at 99.6% (3571 of 3583 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/it/ * Translated using Weblate (French) Currently translated at 100.0% (3580 of 3580 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/fr/ * Translated using Weblate (German) Currently translated at 100.0% (3583 of 3583 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/de/ * Translated using Weblate (Ukrainian) Currently translated at 100.0% (3583 of 3583 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/uk/ * Translated using Weblate (Italian) Currently translated at 100.0% (3583 of 3583 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/it/ * Translated using Weblate (Russian) Currently translated at 98.5% (3531 of 3583 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/ru/ * Translated using Weblate (German) Currently translated at 100.0% (3589 of 3589 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/de/ * Translated using Weblate (Ukrainian) Currently translated at 100.0% (3589 of 3589 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/uk/ * Translated using Weblate (Slovak) Currently translated at 100.0% (3590 of 3590 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/sk/ * Translated using Weblate (Ukrainian) Currently translated at 100.0% (3590 of 3590 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/uk/ * Translated using Weblate (Russian) Currently translated at 98.3% (3532 of 3590 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/ru/ * Translated using Weblate (German) Currently translated at 100.0% (3590 of 3590 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/de/ * Translated using Weblate (Portuguese (Brazil)) Currently translated at 78.9% (2833 of 3590 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/pt_BR/ * Translated using Weblate (Hebrew) Currently translated at 76.8% (2760 of 3590 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/he/ * Translated using Weblate (Russian) Currently translated at 98.4% (3533 of 3590 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/ru/ * Translated using Weblate (French) Currently translated at 100.0% (3590 of 3590 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/fr/ * Translated using Weblate (Indonesian) Currently translated at 100.0% (3590 of 3590 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/id/ * Translated using Weblate (Estonian) Currently translated at 100.0% (3590 of 3590 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/et/ * Translated using Weblate (Ukrainian) Currently translated at 100.0% (3590 of 3590 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/uk/ * Translated using Weblate (Chinese (Traditional)) Currently translated at 100.0% (3590 of 3590 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/zh_Hant/ * Translated using Weblate (Czech) Currently translated at 100.0% (3590 of 3590 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/cs/ * Translated using Weblate (Slovak) Currently translated at 100.0% (3590 of 3590 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/sk/ * Translated using Weblate (Russian) Currently translated at 98.4% (3533 of 3590 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/ru/ * Translated using Weblate (Indonesian) Currently translated at 100.0% (3590 of 3590 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/id/ * Translated using Weblate (Ukrainian) Currently translated at 100.0% (3590 of 3590 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/uk/ * Translated using Weblate (Italian) Currently translated at 100.0% (3590 of 3590 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/it/ * Translated using Weblate (Czech) Currently translated at 100.0% (3594 of 3594 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/cs/ * Translated using Weblate (Chinese (Traditional)) Currently translated at 100.0% (3590 of 3590 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/zh_Hant/ * Translated using Weblate (German) Currently translated at 100.0% (3594 of 3594 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/de/ * Translated using Weblate (Indonesian) Currently translated at 100.0% (3594 of 3594 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/id/ * Translated using Weblate (Russian) Currently translated at 98.3% (3534 of 3594 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/ru/ * Translated using Weblate (Russian) Currently translated at 98.3% (3535 of 3594 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/ru/ * Translated using Weblate (Russian) Currently translated at 98.5% (3541 of 3594 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/ru/ * Translated using Weblate (Slovak) Currently translated at 100.0% (3594 of 3594 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/sk/ * Translated using Weblate (French) Currently translated at 100.0% (3594 of 3594 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/fr/ * Translated using Weblate (Czech) Currently translated at 100.0% (3594 of 3594 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/cs/ * Translated using Weblate (Russian) Currently translated at 98.6% (3545 of 3594 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/ru/ * Translated using Weblate (Korean) Currently translated at 37.3% (1342 of 3594 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/ko/ * Translated using Weblate (Ukrainian) Currently translated at 100.0% (3594 of 3594 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/uk/ * Translated using Weblate (Chinese (Traditional)) Currently translated at 100.0% (3594 of 3594 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/zh_Hant/ * Translated using Weblate (German) Currently translated at 100.0% (3595 of 3595 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/de/ * Translated using Weblate (Russian) Currently translated at 98.6% (3546 of 3595 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/ru/ * Translated using Weblate (Indonesian) Currently translated at 100.0% (3595 of 3595 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/id/ * Translated using Weblate (French) Currently translated at 100.0% (3595 of 3595 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/fr/ * Translated using Weblate (Russian) Currently translated at 98.6% (3546 of 3595 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/ru/ * Translated using Weblate (Italian) Currently translated at 100.0% (3595 of 3595 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/it/ * Translated using Weblate (Persian) Currently translated at 69.3% (2492 of 3595 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/fa/ * Translated using Weblate (Czech) Currently translated at 100.0% (3595 of 3595 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/cs/ * Translated using Weblate (Persian) Currently translated at 69.4% (2498 of 3595 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/fa/ * Translated using Weblate (Russian) Currently translated at 98.6% (3548 of 3595 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/ru/ * Translated using Weblate (Russian) Currently translated at 98.9% (3558 of 3595 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/ru/ * Translated using Weblate (Russian) Currently translated at 99.0% (3562 of 3595 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/ru/ * Translated using Weblate (Ukrainian) Currently translated at 100.0% (3595 of 3595 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/uk/ * Translated using Weblate (Portuguese (Brazil)) Currently translated at 79.7% (2867 of 3595 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/pt_BR/ * Translated using Weblate (Portuguese (Brazil)) Currently translated at 79.7% (2867 of 3595 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/pt_BR/ * Translated using Weblate (Chinese (Traditional)) Currently translated at 100.0% (3595 of 3595 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/zh_Hant/ * Translated using Weblate (Ukrainian) Currently translated at 100.0% (3595 of 3595 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/uk/ * Translated using Weblate (Slovak) Currently translated at 100.0% (3595 of 3595 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/sk/ * Translated using Weblate (German) Currently translated at 100.0% (3597 of 3597 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/de/ * Translated using Weblate (Indonesian) Currently translated at 100.0% (3597 of 3597 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/id/ * Translated using Weblate (Czech) Currently translated at 100.0% (3597 of 3597 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/cs/ * Translated using Weblate (French) Currently translated at 100.0% (3597 of 3597 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/fr/ * Translated using Weblate (German) Currently translated at 99.9% (3598 of 3599 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/de/ * Translated using Weblate (Russian) Currently translated at 98.9% (3562 of 3599 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/ru/ * Translated using Weblate (Ukrainian) Currently translated at 100.0% (3599 of 3599 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/uk/ * Translated using Weblate (Italian) Currently translated at 100.0% (3599 of 3599 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/it/ * Translated using Weblate (Indonesian) Currently translated at 100.0% (3599 of 3599 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/id/ * Translated using Weblate (Czech) Currently translated at 100.0% (3599 of 3599 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/cs/ * Translated using Weblate (German) Currently translated at 100.0% (3599 of 3599 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/de/ * Translated using Weblate (German) Currently translated at 100.0% (3600 of 3600 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/de/ * Translated using Weblate (Russian) Currently translated at 99.0% (3564 of 3600 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/ru/ * Translated using Weblate (Indonesian) Currently translated at 100.0% (3600 of 3600 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/id/ * Translated using Weblate (Czech) Currently translated at 100.0% (3600 of 3600 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/cs/ * Translated using Weblate (German) Currently translated at 100.0% (3601 of 3601 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/de/ * Translated using Weblate (French) Currently translated at 100.0% (3601 of 3601 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/fr/ * Translated using Weblate (Hungarian) Currently translated at 99.3% (3577 of 3601 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/hu/ * Translated using Weblate (Portuguese (Brazil)) Currently translated at 80.2% (2891 of 3601 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/pt_BR/ * Translated using Weblate (Russian) Currently translated at 98.9% (3564 of 3601 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/ru/ * Translated using Weblate (Ukrainian) Currently translated at 100.0% (3601 of 3601 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/uk/ * Translated using Weblate (Indonesian) Currently translated at 100.0% (3601 of 3601 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/id/ * Translated using Weblate (Czech) Currently translated at 100.0% (3601 of 3601 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/cs/ * Translated using Weblate (Slovak) Currently translated at 100.0% (3601 of 3601 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/sk/ * Translated using Weblate (Bulgarian) Currently translated at 59.7% (2153 of 3601 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/bg/ * Translated using Weblate (Estonian) Currently translated at 100.0% (3601 of 3601 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/et/ * Translated using Weblate (German) Currently translated at 99.9% (3600 of 3601 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/de/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (3601 of 3601 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/hu/ * Translated using Weblate (Chinese (Traditional)) Currently translated at 100.0% (3601 of 3601 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/zh_Hant/ * Translated using Weblate (German) Currently translated at 100.0% (3601 of 3601 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/de/ * Translated using Weblate (Italian) Currently translated at 100.0% (3602 of 3602 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/it/ * Translated using Weblate (Russian) Currently translated at 98.9% (3564 of 3602 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/ru/ * Translated using Weblate (Indonesian) Currently translated at 100.0% (3602 of 3602 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/id/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (3602 of 3602 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/hu/ * Translated using Weblate (Russian) Currently translated at 98.9% (3564 of 3602 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/ru/ * Translated using Weblate (German) Currently translated at 100.0% (3602 of 3602 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/de/ * Translated using Weblate (Ukrainian) Currently translated at 100.0% (3602 of 3602 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/uk/ * Translated using Weblate (Spanish) Currently translated at 99.9% (3599 of 3602 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/es/ * Translated using Weblate (Slovak) Currently translated at 100.0% (3602 of 3602 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/sk/ * Translated using Weblate (Estonian) Currently translated at 100.0% (3602 of 3602 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/et/ * Translated using Weblate (Chinese (Traditional)) Currently translated at 100.0% (3602 of 3602 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/zh_Hant/ * Translated using Weblate (German) Currently translated at 100.0% (3605 of 3605 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/de/ * Translated using Weblate (Italian) Currently translated at 100.0% (3605 of 3605 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/it/ * Translated using Weblate (Czech) Currently translated at 100.0% (3605 of 3605 strings) Translation: Element Web/matrix-react-sdk Translate-URL: https://translate.element.io/projects/element-web/matrix-react-sdk/cs/ * Update ko.json Co-authored-by: Dominik Henneke Co-authored-by: Timo Gurr Co-authored-by: Vri Co-authored-by: pierrebolze Co-authored-by: Ihor Hordiichuk Co-authored-by: Weblate Co-authored-by: Nui Harime Co-authored-by: Jeff Huang Co-authored-by: waclaw66 Co-authored-by: Jozef Gaal Co-authored-by: Sirius-KiH Co-authored-by: random Co-authored-by: Linerly Co-authored-by: Sveinn í Felli Co-authored-by: Priit Jõerüüt Co-authored-by: Glandos Co-authored-by: G. Ribeiro Co-authored-by: MusiCode1 Co-authored-by: Yuriy Bulka Co-authored-by: Youngbin Han Co-authored-by: Seyed Masih Sajadi Co-authored-by: DjAntony Co-authored-by: Gustavo Costa Co-authored-by: Johannes Marbach Co-authored-by: Szimszon Co-authored-by: Slavi Pantaleev Co-authored-by: Michael Weimann Co-authored-by: fkwp Co-authored-by: iaiz Co-authored-by: Michael Telatynski <7t3chguy@gmail.com> --- src/i18n/strings/bg.json | 12 +- src/i18n/strings/cs.json | 56 ++++- src/i18n/strings/de_DE.json | 412 +++++++++++++++++++--------------- src/i18n/strings/es.json | 80 ++++++- src/i18n/strings/et.json | 46 +++- src/i18n/strings/fa.json | 9 +- src/i18n/strings/fr.json | 44 +++- src/i18n/strings/he.json | 3 +- src/i18n/strings/hu.json | 45 +++- src/i18n/strings/id.json | 200 ++++++++++------- src/i18n/strings/is.json | 106 ++++++++- src/i18n/strings/it.json | 50 ++++- src/i18n/strings/lb.json | 1 + src/i18n/strings/pt_BR.json | 83 ++++++- src/i18n/strings/ru.json | 68 ++++-- src/i18n/strings/sk.json | 48 +++- src/i18n/strings/uk.json | 52 ++++- src/i18n/strings/zh_Hant.json | 46 +++- 18 files changed, 1057 insertions(+), 304 deletions(-) create mode 100644 src/i18n/strings/lb.json diff --git a/src/i18n/strings/bg.json b/src/i18n/strings/bg.json index 65bd8ab877..aad0be9261 100644 --- a/src/i18n/strings/bg.json +++ b/src/i18n/strings/bg.json @@ -2163,5 +2163,15 @@ "You cannot place calls in this browser.": "Не можете да провеждате обаждания в този браузър.", "Calls are unsupported": "Обажданията не се поддържат", "The user you called is busy.": "Потребителят, когото потърсихте, е зает.", - "User Busy": "Потребителят е зает" + "User Busy": "Потребителят е зает", + "Some invites couldn't be sent": "Някои покани не можаха да бъдат изпратени", + "We sent the others, but the below people couldn't be invited to ": "Изпратихме останалите покани, но следните хора не можаха да бъдат поканени в ", + "Empty room (was %(oldName)s)": "Празна стая (беше %(oldName)s)", + "Inviting %(user)s and %(count)s others|one": "Канене на %(user)s и още 1 друг", + "Inviting %(user)s and %(count)s others|other": "Канене на %(user)s и %(count)s други", + "Inviting %(user1)s and %(user2)s": "Канене на %(user1)s и %(user2)s", + "%(user)s and %(count)s others|one": "%(user)s и още 1", + "%(user)s and %(count)s others|other": "%(user)s и %(count)s други", + "%(user1)s and %(user2)s": "%(user1)s и %(user2)s", + "Empty room": "Празна стая" } diff --git a/src/i18n/strings/cs.json b/src/i18n/strings/cs.json index 5516b1dd8c..9bd59626f6 100644 --- a/src/i18n/strings/cs.json +++ b/src/i18n/strings/cs.json @@ -538,7 +538,7 @@ "Upgrade this room to version %(version)s": "Aktualizace místnosti na verzi %(version)s", "Security & Privacy": "Zabezpečení a soukromí", "Encryption": "Šifrování", - "Once enabled, encryption cannot be disabled.": "Jakmile je šifrování povoleno, nelze jej zakázat.", + "Once enabled, encryption cannot be disabled.": "Po zapnutí šifrování ho není možné vypnout.", "Encrypted": "Šifrováno", "General": "Obecné", "General failure": "Nějaká chyba", @@ -1262,7 +1262,7 @@ "about a day from now": "asi za den", "%(num)s days from now": "za %(num)s dní", "Show info about bridges in room settings": "Zobrazovat v nastavení místnosti informace o propojeních", - "Never send encrypted messages to unverified sessions from this session": "Nikdy neposílat šifrované zprávy neověřených zařízením", + "Never send encrypted messages to unverified sessions from this session": "Nikdy neposílat šifrované zprávy do neověřených relací z této relace", "Never send encrypted messages to unverified sessions in this room from this session": "Nikdy v této místnosti neposílat šifrované zprávy neověřeným relacím", "Enable message search in encrypted rooms": "Povolit vyhledávání v šifrovaných místnostech", "How fast should messages be downloaded.": "Jak rychle se mají zprávy stahovat.", @@ -3582,12 +3582,58 @@ "Failed to set pusher state": "Nepodařilo se nastavit stav push oznámení", "%(selectedDeviceCount)s sessions selected": "%(selectedDeviceCount)s vybraných relací", "Receive push notifications on this session.": "Přijímat push oznámení v této relaci.", - "Toggle push notifications on this session.": "Přepnout push notifikace v této relaci.", - "Push notifications": "Push notifikace", + "Toggle push notifications on this session.": "Přepnout push oznámení v této relaci.", + "Push notifications": "Push oznámení", "Enable notifications for this device": "Povolit oznámení pro toto zařízení", "Turn off to disable notifications on all your devices and sessions": "Vypnutím zakážete oznámení na všech zařízeních a relacích", "Enable notifications for this account": "Povolit oznámení pro tento účet", "Video call ended": "Videohovor ukončen", "%(name)s started a video call": "%(name)s zahájil(a) videohovor", - "Record the client name, version, and url to recognise sessions more easily in session manager": "Zaznamenat název, verzi a url pro snadnější rozpoznání relací ve správci relací" + "Record the client name, version, and url to recognise sessions more easily in session manager": "Zaznamenat název, verzi a url pro snadnější rozpoznání relací ve správci relací", + "URL": "URL", + "Version": "Verze", + "Application": "Aplikace", + "Room info": "Informace o místnosti", + "View chat timeline": "Zobrazit časovou osu konverzace", + "Close call": "Zavřít hovor", + "Freedom": "Svoboda", + "Layout type": "Typ rozložení", + "Spotlight": "Reflektor", + "Unknown session type": "Neznámý typ relace", + "Web session": "Relace na webu", + "Mobile session": "Relace mobilního zařízení", + "Desktop session": "Relace stolního počítače", + "Fill screen": "Vyplnit obrazovku", + "Video call started": "Videohovor byl zahájen", + "Unknown room": "Neznámá místnost", + "Video call started in %(roomName)s. (not supported by this browser)": "Videohovor byl zahájen v %(roomName)s. (není podporováno tímto prohlížečem)", + "Video call started in %(roomName)s.": "Videohovor byl zahájen v %(roomName)s.", + "Operating system": "Operační systém", + "Model": "Model", + "Client": "Klient", + "Video call (%(brand)s)": "Videohovor (%(brand)s)", + "Call type": "Typ volání", + "You do not have sufficient permissions to change this.": "Ke změně nemáte dostatečná oprávnění.", + "%(brand)s is end-to-end encrypted, but is currently limited to smaller numbers of users.": "%(brand)s je koncově šifrovaný, ale v současné době je omezen na menší počet uživatelů.", + "Enable %(brand)s as an additional calling option in this room": "Povolit %(brand)s jako další možnost volání v této místnosti", + "Join %(brand)s calls": "Připojit se k %(brand)s volání", + "Start %(brand)s calls": "Zahájit %(brand)s volání", + "Sorry — this call is currently full": "Omlouváme se — tento hovor je v současné době plný", + "Our new sessions manager provides better visibility of all your sessions, and greater control over them including the ability to remotely toggle push notifications.": "Náš nový správce relací poskytuje lepší přehled o všech relacích a lepší kontrolu nad nimi, včetně možnosti vzdáleně přepínat push oznámení.", + "Have greater visibility and control over all your sessions.": "Získejte větší přehled a kontrolu nad všemi relacemi.", + "New session manager": "Nový správce relací", + "Use new session manager": "Použít nový správce relací", + "Wysiwyg composer (plain text mode coming soon) (under active development)": "Wysiwyg editor (textový režim již brzy) (v aktivním vývoji)", + "Sign out all other sessions": "Odhlásit všechny ostatní relace", + "resume voice broadcast": "obnovit hlasové vysílání", + "pause voice broadcast": "pozastavit hlasové vysílání", + "Underline": "Podtržení", + "Italic": "Kurzíva", + "Try out the rich text editor (plain text mode coming soon)": "Vyzkoušejte nový editor (textový režim již brzy)", + "You have already joined this call from another device": "K tomuto hovoru jste se již připojili z jiného zařízení", + "stop voice broadcast": "zastavit hlasové vysílání", + "Notifications silenced": "Oznámení ztlumena", + "Yes, stop broadcast": "Ano, zastavit vysílání", + "Are you sure you want to stop your live broadcast?This will end the broadcast and the full recording will be available in the room.": "Opravdu chcete ukončit živé vysílání? Tím se vysílání ukončí a v místnosti bude k dispozici celý záznam.", + "Stop live broadcasting?": "Ukončit živé vysílání?" } diff --git a/src/i18n/strings/de_DE.json b/src/i18n/strings/de_DE.json index 5116fcf8f5..5b07a37cec 100644 --- a/src/i18n/strings/de_DE.json +++ b/src/i18n/strings/de_DE.json @@ -142,7 +142,7 @@ "and %(count)s others...|one": "und ein weiterer …", "Are you sure?": "Bist du sicher?", "Attachment": "Anhang", - "Can't connect to homeserver via HTTP when an HTTPS URL is in your browser bar. Either use HTTPS or enable unsafe scripts.": "Es kann keine Verbindung zum Heimserver via HTTP aufgebaut werden, wenn die Adresszeile des Browsers eine HTTPS-URL enthält. Entweder HTTPS verwenden oder alternativ unsichere Skripte erlauben.", + "Can't connect to homeserver via HTTP when an HTTPS URL is in your browser bar. Either use HTTPS or enable unsafe scripts.": "Es kann keine Verbindung zum Heim-Server via HTTP aufgebaut werden, wenn die Adresszeile des Browsers eine HTTPS-URL enthält. Entweder HTTPS verwenden oder alternativ unsichere Skripte erlauben.", "Command error": "Fehler im Befehl", "Decrypt %(text)s": "%(text)s entschlüsseln", "Download %(text)s": "%(text)s herunterladen", @@ -159,7 +159,7 @@ "OK": "Ok", "Search": "Suchen", "Search failed": "Suche ist fehlgeschlagen", - "Server error": "Serverfehler", + "Server error": "Server-Fehler", "Server may be unavailable, overloaded, or search timed out :(": "Der Server ist entweder nicht verfügbar, überlastet oder die Suche wurde wegen Zeitüberschreitung abgebrochen :(", "Server unavailable, overloaded, or something else went wrong.": "Server ist nicht verfügbar, überlastet oder ein anderer Fehler ist aufgetreten.", "Submit": "Absenden", @@ -264,7 +264,7 @@ "Home": "Startseite", "Accept": "Annehmen", "Admin Tools": "Administrationswerkzeuge", - "Can't connect to homeserver - please check your connectivity, ensure your homeserver's SSL certificate is trusted, and that a browser extension is not blocking requests.": "Verbindung zum Heimserver fehlgeschlagen - bitte überprüfe die Internetverbindung und stelle sicher, dass dem SSL-Zertifikat deines Heimservers vertraut wird und dass Anfragen nicht durch eine Browser-Erweiterung blockiert werden.", + "Can't connect to homeserver - please check your connectivity, ensure your homeserver's SSL certificate is trusted, and that a browser extension is not blocking requests.": "Verbindung zum Heim-Server fehlgeschlagen – bitte überprüfe die Internetverbindung und stelle sicher, dass dem SSL-Zertifikat deines Heimservers vertraut wird und dass Anfragen nicht durch eine Browser-Erweiterung blockiert werden.", "Close": "Schließen", "Decline": "Ablehnen", "Failed to upload profile picture!": "Hochladen des Profilbilds fehlgeschlagen!", @@ -360,7 +360,7 @@ "%(oneUser)schanged their avatar %(count)s times|one": "%(oneUser)shat das Profilbild geändert", "Members only (since the point in time of selecting this option)": "Mitglieder", "Members only (since they were invited)": "Mitglieder (ab Einladung)", - "Members only (since they joined)": "Mitglieder (ab Beitreten)", + "Members only (since they joined)": "Mitglieder (ab Betreten)", "A text message has been sent to %(msisdn)s": "Eine Textnachricht wurde an %(msisdn)s gesendet", "%(severalUsers)srejected their invitations %(count)s times|other": "%(severalUsers)shaben ihre Einladungen %(count)s-mal abgelehnt", "%(oneUser)srejected their invitation %(count)s times|other": "%(oneUser)shat die Einladung %(count)s-mal abgelehnt", @@ -380,7 +380,7 @@ "Enable inline URL previews by default": "URL-Vorschau standardmäßig aktivieren", "Enable URL previews for this room (only affects you)": "URL-Vorschau für dich in diesem Raum", "Enable URL previews by default for participants in this room": "URL-Vorschau für Raummitglieder", - "Please note you are logging into the %(hs)s server, not matrix.org.": "Du meldest dich gerade am Server von %(hs)s an, nicht auf matrix.org.", + "Please note you are logging into the %(hs)s server, not matrix.org.": "Du meldest dich gerade auf dem Server von %(hs)s an, nicht auf matrix.org.", "URL previews are disabled by default for participants in this room.": "URL-Vorschau ist für Mitglieder des Raumes standardmäßig deaktiviert.", "URL previews are enabled by default for participants in this room.": "URL-Vorschau ist für Mitglieder des Raumes standardmäßig aktiviert.", "Restricted": "Eingeschränkt", @@ -392,7 +392,7 @@ "Idle for %(duration)s": "Abwesend seit %(duration)s", "Offline for %(duration)s": "Offline seit %(duration)s", "Unknown for %(duration)s": "Unbekannt seit %(duration)s", - "This homeserver doesn't offer any login flows which are supported by this client.": "Dieser Heimserver verfügt über keines von dieser Anwendung unterstütztes Anmeldeverfahren.", + "This homeserver doesn't offer any login flows which are supported by this client.": "Dieser Heim-Server verfügt über keines von dieser Anwendung unterstütztes Anmeldeverfahren.", "Call Failed": "Anruf fehlgeschlagen", "Send": "Senden", "collapse": "Verbergen", @@ -424,7 +424,7 @@ "What's New": "Was ist neu", "On": "An", "Changelog": "Änderungsprotokoll", - "Waiting for response from server": "Auf Antwort vom Server warten", + "Waiting for response from server": "Warte auf Antwort vom Server", "Failed to send logs: ": "Senden von Protokolldateien fehlgeschlagen: ", "This Room": "In diesem Raum", "Resend": "Erneut senden", @@ -444,7 +444,7 @@ "Developer Tools": "Entwicklungswerkzeuge", "Preparing to send logs": "Senden von Protokolldateien wird vorbereitet", "Saturday": "Samstag", - "The server may be unavailable or overloaded": "Der Server ist vermutlich nicht erreichbar oder überlastet", + "The server may be unavailable or overloaded": "Der Server ist möglicherweise nicht erreichbar oder überlastet", "Reject": "Ablehnen", "Monday": "Montag", "Remove from Directory": "Aus dem Raum-Verzeichnis entfernen", @@ -494,10 +494,10 @@ "Enable widget screenshots on supported widgets": "Bildschirmfotos für unterstützte Widgets", "Send analytics data": "Analysedaten senden", "Muted Users": "Stummgeschaltete Benutzer", - "Can't leave Server Notices room": "Du kannst den Raum für Servernotizen nicht verlassen", - "This room is used for important messages from the Homeserver, so you cannot leave it.": "Du kannst diesen Raum nicht verlassen, da dieser Raum für wichtige Nachrichten vom Heim-Server verwendet wird.", + "Can't leave Server Notices room": "Der Raum für Server-Mitteilungen kann nicht verlassen werden", + "This room is used for important messages from the Homeserver, so you cannot leave it.": "Du kannst diesen Raum nicht verlassen, da dieser Raum für wichtige Mitteilungen vom Heim-Server verwendet wird.", "Terms and Conditions": "Geschäftsbedingungen", - "To continue using the %(homeserverDomain)s homeserver you must review and agree to our terms and conditions.": "Um den %(homeserverDomain)s -Heimserver weiter zu verwenden, musst du die Geschäftsbedingungen sichten und ihnen zustimmen.", + "To continue using the %(homeserverDomain)s homeserver you must review and agree to our terms and conditions.": "Um den %(homeserverDomain)s-Heim-Server weiterzuverwenden, musst du die Nutzungsbedingungen sichten und akzeptieren.", "Review terms and conditions": "Geschäftsbedingungen anzeigen", "Share Link to User": "Link zu Benutzer teilen", "Share room": "Raum teilen", @@ -508,7 +508,7 @@ "Link to selected message": "Link zur ausgewählten Nachricht", "No Audio Outputs detected": "Keine Audioausgabe erkannt", "Audio Output": "Audioausgabe", - "In encrypted rooms, like this one, URL previews are disabled by default to ensure that your homeserver (where the previews are generated) cannot gather information about links you see in this room.": "In verschlüsselten Räumen wie diesem ist die Linkvorschau standardmäßig deaktiviert, damit dein Heimserver (der die Vorschau erzeugt) keine Informationen über Links in diesem Raum bekommt.", + "In encrypted rooms, like this one, URL previews are disabled by default to ensure that your homeserver (where the previews are generated) cannot gather information about links you see in this room.": "In verschlüsselten Räumen wie diesem ist die Linkvorschau standardmäßig deaktiviert, damit dein Heim-Server (der die Vorschau erzeugt) keine Informationen über Links in diesem Raum erhält.", "When someone puts a URL in their message, a URL preview can be shown to give more information about that link such as the title, description, and an image from the website.": "Die URL-Vorschau kann Informationen wie den Titel, die Beschreibung sowie ein Vorschaubild der Website enthalten.", "You can't send any messages until you review and agree to our terms and conditions.": "Du kannst keine Nachrichten senden bis du unsere Geschäftsbedingungen gelesen und akzeptiert hast.", "Demote yourself?": "Dein eigenes Berechtigungslevel herabsetzen?", @@ -523,11 +523,11 @@ "This homeserver has exceeded one of its resource limits.": "Dieser Heim-Server hat einen seiner Ressourcengrenzwerte überschritten.", "Upgrade Room Version": "Raumversion aktualisieren", "Create a new room with the same name, description and avatar": "Einen neuen Raum mit demselben Namen, Beschreibung und Profilbild erstellen", - "Update any local room aliases to point to the new room": "Alle lokalen Raum-Aliase aktualisieren, damit sie auf den neuen Raum zeigen", + "Update any local room aliases to point to the new room": "Alle lokalen Raumaliase aktualisieren, damit sie auf den neuen Raum zeigen", "Stop users from speaking in the old version of the room, and post a message advising users to move to the new room": "Nutzern verbieten in dem Raum mit der alten Version zu schreiben und eine Nachricht senden, die den Nutzern rät in den neuen Raum zu wechseln", "Put a link back to the old room at the start of the new room so people can see old messages": "Zu Beginn des neuen Raumes einen Link zum alten Raum setzen, damit Personen die alten Nachrichten sehen können", - "Your message wasn't sent because this homeserver has hit its Monthly Active User Limit. Please contact your service administrator to continue using the service.": "Deine Nachricht wurde nicht gesendet, weil dieser Heimserver sein Limit an monatlich aktiven Benutzern erreicht hat. Bitte kontaktiere deinen Systemadministrator um diesen Dienst weiter zu nutzen.", - "Your message wasn't sent because this homeserver has exceeded a resource limit. Please contact your service administrator to continue using the service.": "Deine Nachricht wurde nicht gesendet, weil dieser Heimserver ein Ressourcen-Limit erreicht hat. Bitte kontaktiere deinen Systemadministrator um diesen Dienst weiter zu nutzen.", + "Your message wasn't sent because this homeserver has hit its Monthly Active User Limit. Please contact your service administrator to continue using the service.": "Deine Nachricht wurde nicht gesendet, weil dieser Heim-Server sein Limit an monatlich aktiven Benutzern erreicht hat. Bitte kontaktiere deine Systemadministration, um diesen Dienst weiterzunutzen.", + "Your message wasn't sent because this homeserver has exceeded a resource limit. Please contact your service administrator to continue using the service.": "Deine Nachricht wurde nicht gesendet, weil dieser Heim-Server ein Ressourcen-Limit erreicht hat. Bitte kontaktiere deine Systemadministration, um diesen Dienst weiterzunutzen.", "Please contact your service administrator to continue using this service.": "Bitte kontaktiere deinen Systemadministrator um diesen Dienst weiter zu nutzen.", "Please contact your homeserver administrator.": "Bitte setze dich mit der Administration deines Heim-Servers in Verbindung.", "Legal": "Rechtliches", @@ -543,16 +543,16 @@ "%(senderName)s set the main address for this room to %(address)s.": "%(senderName)s hat als Hauptadresse des Raums %(address)s festgelegt.", "%(senderName)s removed the main address for this room.": "%(senderName)s hat die Hauptadresse von diesem Raum entfernt.", "Before submitting logs, you must create a GitHub issue to describe your problem.": "Bevor du Protokolldateien übermittelst, musst du auf GitHub einen \"Issue\" erstellen um dein Problem zu beschreiben.", - "%(brand)s now uses 3-5x less memory, by only loading information about other users when needed. Please wait whilst we resynchronise with the server!": "%(brand)s benutzt nun 3 - 5-mal weniger Arbeitsspeicher, indem Informationen über andere Nutzer erst bei Bedarf geladen werden. Bitte warte, während die Daten erneut mit dem Server abgeglichen werden!", + "%(brand)s now uses 3-5x less memory, by only loading information about other users when needed. Please wait whilst we resynchronise with the server!": "%(brand)s benutzt nun 3 bis 5 Mal weniger Arbeitsspeicher, indem Informationen über andere Nutzer erst bei Bedarf geladen werden. Bitte warte, während die Daten erneut mit dem Server abgeglichen werden!", "Updating %(brand)s": "Aktualisiere %(brand)s", "You've previously used %(brand)s on %(host)s with lazy loading of members enabled. In this version lazy loading is disabled. As the local cache is not compatible between these two settings, %(brand)s needs to resync your account.": "Du hast zuvor %(brand)s auf %(host)s ohne das verzögerte Laden von Mitgliedern genutzt. In dieser Version war das verzögerte Laden deaktiviert. Da die lokal zwischengespeicherten Daten zwischen diesen Einstellungen nicht kompatibel sind, muss %(brand)s dein Konto neu synchronisieren.", "If the other version of %(brand)s is still open in another tab, please close it as using %(brand)s on the same host with both lazy loading enabled and disabled simultaneously will cause issues.": "Wenn %(brand)s mit der alten Version in einem anderen Tab geöffnet ist, schließe dies bitte, da das parallele Nutzen von %(brand)s auf demselben Host mit aktivierten und deaktivierten verzögertem Laden, Probleme verursachen wird.", "Incompatible local cache": "Inkompatibler lokaler Zwischenspeicher", "Clear cache and resync": "Zwischenspeicher löschen und erneut synchronisieren", - "Please review and accept the policies of this homeserver:": "Bitte sieh dir alle Bedingungen dieses Heimservers an und akzeptiere sie:", + "Please review and accept the policies of this homeserver:": "Bitte sieh dir alle Bedingungen dieses Heim-Servers an und akzeptiere sie:", "Add some now": "Jetzt hinzufügen", "Unable to load! Check your network connectivity and try again.": "Konnte nicht geladen werden! Überprüfe die Netzwerkverbindung und versuche es erneut.", - "Delete Backup": "Sicherung löschen", + "Delete Backup": "Lösche Sicherung", "To avoid losing your chat history, you must export your room keys before logging out. You will need to go back to the newer version of %(brand)s to do this": "Um zu vermeiden, dass dein Verlauf verloren geht, musst du deine Raumschlüssel exportieren, bevor du dich abmeldest. Dazu musst du auf die neuere Version von %(brand)s zurückgehen", "Incompatible Database": "Inkompatible Datenbanken", "Continue With Encryption Disabled": "Mit deaktivierter Verschlüsselung fortfahren", @@ -593,7 +593,7 @@ "Names and surnames by themselves are easy to guess": "Namen und Familiennamen alleine sind einfach zu erraten", "Common names and surnames are easy to guess": "Häufige Namen und Familiennamen sind einfach zu erraten", "You do not have permission to invite people to this room.": "Du hast keine Berechtigung, Personen in diesen Raum einzuladen.", - "Unknown server error": "Unbekannter Serverfehler", + "Unknown server error": "Unbekannter Server-Fehler", "Short keyboard patterns are easy to guess": "Kurze Tastaturmuster sind einfach zu erraten", "Messages containing @room": "Nachrichten mit @room", "Encrypted messages in one-to-one chats": "Verschlüsselte Direktnachrichten", @@ -602,12 +602,12 @@ "Straight rows of keys are easy to guess": "Gerade Reihen von Tasten sind einfach zu erraten", "Unable to load key backup status": "Konnte Status der Schlüsselsicherung nicht laden", "Set up": "Einrichten", - "Please review and accept all of the homeserver's policies": "Bitte prüfe und akzeptiere alle Richtlinien des Heimservers", + "Please review and accept all of the homeserver's policies": "Bitte prüfe und akzeptiere alle Richtlinien des Heim-Servers", "Unable to load commit detail: %(msg)s": "Konnte Übermittlungsdetails nicht laden: %(msg)s", "Unable to load backup status": "Konnte Sicherungsstatus nicht laden", "Failed to decrypt %(failedCount)s sessions!": "Konnte %(failedCount)s Sitzungen nicht entschlüsseln!", "Invalid homeserver discovery response": "Ungültige Antwort beim Aufspüren des Heim-Servers", - "Invalid identity server discovery response": "Ungültige Antwort beim Aufspüren des Identitätsservers", + "Invalid identity server discovery response": "Ungültige Antwort beim Aufspüren des Identitäts-Servers", "General failure": "Allgemeiner Fehler", "Failed to perform homeserver discovery": "Fehler beim Aufspüren des Heim-Servers", "Set up Secure Message Recovery": "Richte Sichere Nachrichten-Wiederherstellung ein", @@ -659,7 +659,7 @@ "Room version": "Raumversion", "Room version:": "Raumversion:", "General": "Allgemein", - "Set a new account password...": "Neues Passwort festlegen …", + "Set a new account password...": "Neues Kontopasswort festlegen …", "Email addresses": "E-Mail-Adressen", "Phone numbers": "Telefonnummern", "Language and region": "Sprache und Region", @@ -675,7 +675,7 @@ "Room Addresses": "Raumadressen", "Preferences": "Optionen", "Room list": "Raumliste", - "The file '%(fileName)s' exceeds this homeserver's size limit for uploads": "Die Datei '%(fileName)s' überschreitet die maximale Uploadgröße deines Heim-Servers", + "The file '%(fileName)s' exceeds this homeserver's size limit for uploads": "Die Datei '%(fileName)s' überschreitet das Hochladelimit deines Heim-Servers", "This room has no topic.": "Dieser Raum hat kein Thema.", "%(senderDisplayName)s made the room public to whoever knows the link.": "%(senderDisplayName)s hat den Raum für jeden, der den Link kennt, öffentlich gemacht.", "%(senderDisplayName)s made the room invite only.": "%(senderDisplayName)s hat den Raum auf eingeladene Benutzer beschränkt.", @@ -796,7 +796,7 @@ "Sign in instead": "Stattdessen anmelden", "Your password has been reset.": "Dein Passwort wurde zurückgesetzt.", "Set a new password": "Erstelle ein neues Passwort", - "This homeserver does not support login using email address.": "Dieser Heimserver unterstützt eine Anmeldung über E-Mail-Adresse nicht.", + "This homeserver does not support login using email address.": "Dieser Heim-Server unterstützt die Anmeldung per E-Mail-Adresse nicht.", "Create account": "Konto anlegen", "Registration has been disabled on this homeserver.": "Registrierungen wurden auf diesem Heim-Server deaktiviert.", "Keep going...": "Fortfahren …", @@ -830,7 +830,7 @@ "Send %(eventType)s events": "%(eventType)s-Ereignisse senden", "Select the roles required to change various parts of the room": "Wähle Rollen, die benötigt werden, um einige Teile des Raumes zu ändern", "Enable encryption?": "Verschlüsselung aktivieren?", - "Once enabled, encryption for a room cannot be disabled. Messages sent in an encrypted room cannot be seen by the server, only by the participants of the room. Enabling encryption may prevent many bots and bridges from working correctly. Learn more about encryption.": "Sobald aktiviert, kann die Verschlüsselung für einen Raum nicht mehr deaktiviert werden. Nachrichten in einem verschlüsselten Raum können nur noch von Teilnehmern aber nicht mehr vom Server gelesen werden. Einige Bots und Brücken werden vielleicht nicht mehr funktionieren. Erfahre mehr über Verschlüsselung.", + "Once enabled, encryption for a room cannot be disabled. Messages sent in an encrypted room cannot be seen by the server, only by the participants of the room. Enabling encryption may prevent many bots and bridges from working correctly. Learn more about encryption.": "Sobald aktiviert, kann die Verschlüsselung für einen Raum nicht mehr deaktiviert werden. Nachrichten in einem verschlüsselten Raum können nur noch von Teilnehmern, aber nicht mehr vom Server gelesen werden. Einige Bots und Brücken werden vielleicht nicht mehr funktionieren. Erfahre mehr über Verschlüsselung.", "Error updating main address": "Fehler beim Aktualisieren der Hauptadresse", "There was an error updating the room's main address. It may not be allowed by the server or a temporary failure occurred.": "Es gab ein Problem beim Aktualisieren der Raum-Hauptadresse. Es kann sein, dass der Server dies verbietet oder ein temporäres Problem aufgetreten ist.", "Power level": "Berechtigungsstufe", @@ -849,19 +849,19 @@ "Sends the given emote coloured as a rainbow": "Zeigt Aktionen in Regenbogenfarben", "%(senderName)s revoked the invitation for %(targetDisplayName)s to join the room.": "%(senderName)s hat die Einladung für %(targetDisplayName)s zurückgezogen.", "Cannot reach homeserver": "Heim-Server nicht erreichbar", - "Ensure you have a stable internet connection, or get in touch with the server admin": "Stelle sicher, dass du eine stabile Internetverbindung hast oder wende dich an deinen Serveradministrator", + "Ensure you have a stable internet connection, or get in touch with the server admin": "Stelle sicher, dass du eine stabile Internetverbindung hast oder wende dich an deine Server-Administration", "Ask your %(brand)s admin to check your config for incorrect or duplicate entries.": "Wende dich an deinen %(brand)s-Admin um deine Konfiguration auf ungültige oder doppelte Einträge zu überprüfen.", "Unexpected error resolving identity server configuration": "Ein unerwarteter Fehler ist beim Laden der Identitäts-Server-Konfiguration aufgetreten", "Cannot reach identity server": "Identitäts-Server nicht erreichbar", - "You can register, but some features will be unavailable until the identity server is back online. If you keep seeing this warning, check your configuration or contact a server admin.": "Du kannst dich registrieren, einige Funktionen werden allerdings erst verfügbar sein, sobald der Identitäts-Server wieder in Betrieb ist. Sollte diese Warnmeldung weiterhin erscheinen, überprüfe deine Konfiguration oder kontaktiere die Serveradministration.", - "You can reset your password, but some features will be unavailable until the identity server is back online. If you keep seeing this warning, check your configuration or contact a server admin.": "Du kannst dein Passwort zurücksetzen, einige Funktionen werden allerdings erst verfügbar sein, sobald der Identitäts-Server wieder in Betrieb ist. Sollte diese Warnmeldung weiterhin erscheinen, überprüfe deine Konfiguration oder kontaktiere die Serveradministration.", - "You can log in, but some features will be unavailable until the identity server is back online. If you keep seeing this warning, check your configuration or contact a server admin.": "Du kannst dich anmelden, einige Funktionen werden allerdings erst verfügbar sein, sobald der Identitäts-Server wieder in Betrieb ist. Sollte diese Warnmeldung weiterhin erscheinen, überprüfe deine Konfiguration oder kontaktiere deine Serveradministration.", + "You can register, but some features will be unavailable until the identity server is back online. If you keep seeing this warning, check your configuration or contact a server admin.": "Du kannst dich registrieren, einige Funktionen werden allerdings erst verfügbar sein, sobald der Identitäts-Server wieder in Betrieb ist. Sollte diese Warnmeldung weiterhin erscheinen, überprüfe deine Konfiguration oder kontaktiere die Server-Administration.", + "You can reset your password, but some features will be unavailable until the identity server is back online. If you keep seeing this warning, check your configuration or contact a server admin.": "Du kannst dein Passwort zurücksetzen, einige Funktionen werden allerdings erst verfügbar sein, sobald der Identitäts-Server wieder in Betrieb ist. Sollte diese Warnmeldung weiterhin erscheinen, überprüfe deine Konfiguration oder kontaktiere die Server-Administration.", + "You can log in, but some features will be unavailable until the identity server is back online. If you keep seeing this warning, check your configuration or contact a server admin.": "Du kannst dich anmelden, einige Funktionen werden allerdings erst verfügbar sein, sobald der Identitäts-Server wieder in Betrieb ist. Sollte diese Warnmeldung weiterhin erscheinen, überprüfe deine Konfiguration oder kontaktiere deine Server-Administration.", "No homeserver URL provided": "Keine Heim-Server-URL angegeben", "Unexpected error resolving homeserver configuration": "Ein unerwarteter Fehler ist beim Laden der Heim-Server-Konfiguration aufgetreten", "The user's homeserver does not support the version of the room.": "Die Raumversion wird vom Heim-Server des Benutzers nicht unterstützt.", "Show hidden events in timeline": "Zeige versteckte Ereignisse im Verlauf", "Reset": "Zurücksetzen", - "Joining room …": "Raum betreten …", + "Joining room …": "Betrete Raum …", "Rejecting invite …": "Einladung ablehnen…", "Sign Up": "Registrieren", "Sign In": "Anmelden", @@ -893,19 +893,19 @@ "Call failed due to misconfigured server": "Anruf aufgrund eines falsch konfigurierten Servers fehlgeschlagen", "Try using turn.matrix.org": "Versuche es mit turn.matrix.org", "You do not have the required permissions to use this command.": "Du hast nicht die erforderlichen Berechtigungen, diesen Befehl zu verwenden.", - "Checking server": "Server wird überprüft", + "Checking server": "Überprüfe Server", "Identity server has no terms of service": "Der Identitäts-Server hat keine Nutzungsbedingungen", "Disconnect": "Trennen", - "Use an identity server": "Benutze einen Identitätsserver", - "Use an identity server to invite by email. Click continue to use the default identity server (%(defaultIdentityServerName)s) or manage in Settings.": "Benutze einen Identitätsserver, um andere mittels E-Mail einzuladen. Klicke auf fortfahren, um den Standardidentitätsserver (%(defaultIdentityServerName)s) zu benutzen oder ändere ihn in den Einstellungen.", + "Use an identity server": "Benutze einen Identitäts-Server", + "Use an identity server to invite by email. Click continue to use the default identity server (%(defaultIdentityServerName)s) or manage in Settings.": "Benutze einen Identitäts-Server, um andere mittels E-Mail einzuladen. Klicke auf fortfahren, um den Standard-Identitäts-Server (%(defaultIdentityServerName)s) zu benutzen oder ändere ihn in den Einstellungen.", "Terms of service not accepted or the identity server is invalid.": "Nutzungsbedingungen nicht akzeptiert oder der Identitäts-Server ist ungültig.", "Using an identity server is optional. If you choose not to use an identity server, you won't be discoverable by other users and you won't be able to invite others by email or phone.": "Die Verwendung eines Identitäts-Servers ist optional. Solltest du dich dazu entschließen, keinen Identitäts-Server zu verwenden, kannst du von anderen Nutzern nicht gefunden werden und andere nicht per E-Mail-Adresse oder Telefonnummer einladen.", - "Do not use an identity server": "Keinen Identitätsserver verwenden", - "Enter a new identity server": "Gib einen neuen Identitätsserver ein", + "Do not use an identity server": "Keinen Identitäts-Server verwenden", + "Enter a new identity server": "Gib einen neuen Identitäts-Server ein", "Clear personal data": "Persönliche Daten löschen", - "Disconnecting from your identity server will mean you won't be discoverable by other users and you won't be able to invite others by email or phone.": "Wenn du die Verbindung zu deinem Identitätsserver trennst, kannst du nicht mehr von anderen Benutzern gefunden werden und andere nicht mehr per E-Mail oder Telefonnummer einladen.", + "Disconnecting from your identity server will mean you won't be discoverable by other users and you won't be able to invite others by email or phone.": "Wenn du die Verbindung zu deinem Identitäts-Server trennst, kannst du nicht mehr von anderen Benutzern gefunden werden und andere nicht mehr per E-Mail oder Telefonnummer einladen.", "Please ask the administrator of your homeserver (%(homeserverDomain)s) to configure a TURN server in order for calls to work reliably.": "Bitte frage die Administration deines Heim-Servers (%(homeserverDomain)s) darum, einen TURN-Server einzurichten, damit Anrufe zuverlässig funktionieren.", - "Disconnect from the identity server ?": "Verbindung zum Identitätsserver trennen?", + "Disconnect from the identity server ?": "Verbindung zum Identitäts-Server trennen?", "Add Email Address": "E-Mail-Adresse hinzufügen", "Add Phone Number": "Telefonnummer hinzufügen", "Changes the avatar of the current room": "Ändert das Icon vom Raum", @@ -920,16 +920,16 @@ "Trust": "Vertrauen", "Custom (%(level)s)": "Benutzerdefiniert (%(level)s)", "Sends a message as plain text, without interpreting it as markdown": "Verschickt eine Nachricht in Rohtext, ohne sie als Markdown darzustellen", - "Use an identity server to invite by email. Manage in Settings.": "Mit einem Identitätsserver kannst du über E-Mail Einladungen zu verschicken. Verwalte ihn in den Einstellungen.", + "Use an identity server to invite by email. Manage in Settings.": "Verwende einen Identitäts-Server, um per E-Mail einladen zu können. Lege einen in den Einstellungen fest.", "%(name)s (%(userId)s)": "%(name)s (%(userId)s)", "Try out new ways to ignore people (experimental)": "Verwende neue Möglichkeiten, Menschen zu blockieren", "My Ban List": "Meine Bannliste", - "This is your list of users/servers you have blocked - don't leave the room!": "Dies ist die Liste von Benutzer und Servern, die du blockiert hast - verlasse diesen Raum nicht!", + "This is your list of users/servers you have blocked - don't leave the room!": "Dies ist die Liste von Benutzer und Servern, die du blockiert hast – verlasse diesen Raum nicht!", "Accept to continue:": "Akzeptiere , um fortzufahren:", - "Change identity server": "Identitätsserver wechseln", + "Change identity server": "Identitäts-Server wechseln", "You should remove your personal data from identity server before disconnecting. Unfortunately, identity server is currently offline or cannot be reached.": "Du solltest deine persönlichen Daten vom Identitäts-Server entfernen, bevor du die Verbindung trennst. Leider ist der Identitäts-Server derzeit außer Betrieb oder kann nicht erreicht werden.", "You should:": "Du solltest:", - "check your browser plugins for anything that might block the identity server (such as Privacy Badger)": "Überprüfe deinen Browser auf Erweiterungen, die den Identitätsserver blockieren könnten (z.B. Privacy Badger)", + "check your browser plugins for anything that might block the identity server (such as Privacy Badger)": "Überprüfe deinen Browser auf Erweiterungen, die den Identitäts-Server blockieren könnten (z. B. Privacy Badger)", "Error upgrading room": "Fehler bei Raumaktualisierung", "Double check that your server supports the room version chosen and try again.": "Überprüfe nochmal ob dein Server die ausgewählte Raumversion unterstützt und versuche es nochmal.", "%(senderName)s placed a voice call.": "%(senderName)s hat einen Sprachanruf getätigt.", @@ -968,17 +968,17 @@ "not stored": "nicht gespeichert", "Backup has a signature from unknown user with ID %(deviceId)s": "Die Sicherung hat eine Signatur von unbekanntem Nutzer mit ID %(deviceId)s", "Clear notifications": "Benachrichtigungen löschen", - "Disconnect from the identity server and connect to instead?": "Vom Identitätsserver trennen, und stattdessen eine Verbindung zu aufbauen?", - "The identity server you have chosen does not have any terms of service.": "Der von dir gewählte Identitätsserver gibt keine Nutzungsbedingungen an.", - "Disconnect identity server": "Verbindung zum Identitätsserver trennen", - "contact the administrators of identity server ": "Administration des Identitätsservers kontaktieren", + "Disconnect from the identity server and connect to instead?": "Vom Identitäts-Server trennen, und stattdessen mit verbinden?", + "The identity server you have chosen does not have any terms of service.": "Der von dir gewählte Identitäts-Server gibt keine Nutzungsbedingungen an.", + "Disconnect identity server": "Verbindung zum Identitäts-Server trennen", + "contact the administrators of identity server ": "Kontaktiere die Administration des Identitäts-Servers ", "wait and try again later": "warte und versuche es später erneut", "Disconnect anyway": "Verbindung trotzdem trennen", - "You are still sharing your personal data on the identity server .": "Du teilst deine persönlichen Daten immer noch auf dem Identitätsserver .", - "We recommend that you remove your email addresses and phone numbers from the identity server before disconnecting.": "Wir empfehlen, dass du deine E-Mail-Adressen und Telefonnummern vom Identitätsserver löschst, bevor du die Verbindung trennst.", + "You are still sharing your personal data on the identity server .": "Du teilst deine persönlichen Daten noch immer auf dem Identitäts-Server .", + "We recommend that you remove your email addresses and phone numbers from the identity server before disconnecting.": "Wir empfehlen, dass du deine E-Mail-Adressen und Telefonnummern vom Identitäts-Server löschst, bevor du die Verbindung trennst.", "You are not currently using an identity server. To discover and be discoverable by existing contacts you know, add one below.": "Zurzeit benutzt du keinen Identitäts-Server. Trage unten einen Server ein, um Kontakte zu finden und von anderen gefunden zu werden.", "Manage integrations": "Integrationen verwalten", - "Agree to the identity server (%(serverName)s) Terms of Service to allow yourself to be discoverable by email address or phone number.": "Stimme den Nutzungsbedingungen des Identitätsservers %(serverName)s zu, um dich per E-Mail-Adresse und Telefonnummer auffindbar zu machen.", + "Agree to the identity server (%(serverName)s) Terms of Service to allow yourself to be discoverable by email address or phone number.": "Stimme den Nutzungsbedingungen des Identitäts-Servers %(serverName)s zu, um per E-Mail-Adresse oder Telefonnummer auffindbar zu werden.", "Clear cache and reload": "Zwischenspeicher löschen und neu laden", "Ignored/Blocked": "Ignoriert/Blockiert", "Something went wrong. Please try again or view your console for hints.": "Etwas ist schief gelaufen. Bitte versuche es erneut oder sieh für weitere Hinweise in deiner Konsole nach.", @@ -986,7 +986,7 @@ "Error removing ignored user/server": "Fehler beim Entfernen eines blockierten Benutzers/Servers", "Error unsubscribing from list": "Fehler beim Deabonnieren der Liste", "Please try again or view your console for hints.": "Bitte versuche es erneut oder sieh für weitere Hinweise in deine Konsole.", - "Server rules": "Serverregeln", + "Server rules": "Server-Regeln", "User rules": "Nutzerregeln", "You have not ignored anyone.": "Du hast niemanden blockiert.", "You are currently ignoring:": "Du ignorierst momentan:", @@ -1066,7 +1066,7 @@ "Done": "Fertig", "Trusted": "Vertrauenswürdig", "Not trusted": "Nicht vertrauenswürdig", - "%(count)s verified sessions|one": "1 verifizierte Sitzung", + "%(count)s verified sessions|one": "Eine verifizierte Sitzung", "%(count)s sessions|one": "%(count)s Sitzung", "Messages in this room are not end-to-end encrypted.": "Nachrichten in diesem Raum sind nicht Ende-zu-Ende verschlüsselt.", "Security": "Sicherheit", @@ -1088,12 +1088,12 @@ "Match system theme": "An Systemdesign anpassen", "Unable to load session list": "Sitzungsliste kann nicht geladen werden", "This session is backing up your keys. ": "Diese Sitzung sichert deine Schlüssel. ", - "Connect this session to Key Backup": "Diese Sitzung mit der Schlüsselsicherung verbinden", + "Connect this session to Key Backup": "Verbinde diese Sitzung mit einer Schlüsselsicherung", "Backup has a signature from unknown session with ID %(deviceId)s": "Die Sicherung hat eine Signatur von einer unbekannten Sitzung mit der ID %(deviceId)s", "Backup has a valid signature from this session": "Die Sicherung hat eine gültige Signatur von dieser Sitzung", "Backup has an invalid signature from this session": "Die Sicherung hat eine ungültige Signatur von dieser Sitzung", - "Discovery options will appear once you have added an email above.": "Möglichkeiten zur Auffindbarkeit werden angezeigt, sobald du oben eine E-Mail-Adresse hinzugefügt hast.", - "Discovery options will appear once you have added a phone number above.": "Möglichkeiten zur Auffindbarkeit werden angezeigt, sobald du oben eine Telefonnummer hinzugefügt hast.", + "Discovery options will appear once you have added an email above.": "Entdeckungsoptionen werden angezeigt, sobald du eine E-Mail-Adresse hinzugefügt hast.", + "Discovery options will appear once you have added a phone number above.": "Entdeckungsoptionen werden angezeigt, sobald du eine Telefonnummer hinzugefügt hast.", "Close preview": "Vorschau schließen", "Join the discussion": "An Diskussion teilnehmen", "Remove for everyone": "Für alle entfernen", @@ -1151,16 +1151,16 @@ "Encrypted by a deleted session": "Von einer gelöschten Sitzung verschlüsselt", "The encryption used by this room isn't supported.": "Die von diesem Raum verwendete Verschlüsselung wird nicht unterstützt.", "React": "Reagieren", - "e.g. my-room": "z.B. mein-raum", - "Use an identity server to invite by email. Use the default (%(defaultIdentityServerName)s) or manage in Settings.": "Verwende einen Identitätsserver, um per E-Mail einzuladen. Nutze den Standardidentitätsserver (%(defaultIdentityServerName)s) oder konfiguriere einen in den Einstellungen.", - "Use an identity server to invite by email. Manage in Settings.": "Verwende einen Identitätsserver, um mit einer E-Mail-Adresse einzuladen. Diese können in den Einstellungen konfiguriert werden.", + "e.g. my-room": "z. B. mein-raum", + "Use an identity server to invite by email. Use the default (%(defaultIdentityServerName)s) or manage in Settings.": "Verwende einen Identitäts-Server, um per E-Mail einzuladen. Nutze den Standardidentitäts-Server (%(defaultIdentityServerName)s) oder konfiguriere einen in den Einstellungen.", + "Use an identity server to invite by email. Manage in Settings.": "Verwende einen Identitäts-Server, um per E-Mail-Adresse einladen zu können. Lege einen in den Einstellungen fest.", "Create a public room": "Öffentlichen Raum erstellen", "Show advanced": "Erweiterte Einstellungen", "Verify session": "Sitzung verifizieren", "Session key": "Sitzungsschlüssel", "Recent Conversations": "Letzte Unterhaltungen", "Report Content to Your Homeserver Administrator": "Inhalte an die Administration deines Heim-Servers melden", - "Reporting this message will send its unique 'event ID' to the administrator of your homeserver. If messages in this room are encrypted, your homeserver administrator will not be able to read the message text or view any files or images.": "Wenn du diese Nachricht meldest, wird die eindeutige Event-ID an die Administration deines Heimservers übermittelt. Wenn die Nachrichten in diesem Raum verschlüsselt sind, wird deine Heimserver-Administration nicht in der Lage sein, Nachrichten zu lesen oder Medien einzusehen.", + "Reporting this message will send its unique 'event ID' to the administrator of your homeserver. If messages in this room are encrypted, your homeserver administrator will not be able to read the message text or view any files or images.": "Wenn du diese Nachricht meldest, wird die eindeutige Ereignis-ID an die Administration deines Heim-Servers übermittelt. Wenn die Nachrichten in diesem Raum verschlüsselt sind, wird deine Heim-Server-Administration nicht in der Lage sein, Nachrichten zu lesen oder Medien einzusehen.", "Send report": "Bericht senden", "Report Content": "Inhalt melden", "%(creator)s created and configured the room.": "%(creator)s hat den Raum erstellt und konfiguriert.", @@ -1179,8 +1179,8 @@ "Could not find user in room": "Benutzer konnte nicht im Raum gefunden werden", "Click the button below to confirm adding this email address.": "Klicke unten auf den Knopf, um die hinzugefügte E-Mail-Adresse zu bestätigen.", "Confirm adding phone number": "Hinzugefügte Telefonnummer bestätigen", - "%(senderName)s changed a rule that was banning servers matching %(oldGlob)s to matching %(newGlob)s for %(reason)s": "%(senderName)s ändert eine Ausschlussregel für Server von %(oldGlob)s nach %(newGlob)s wegen %(reason)s", - "%(senderName)s updated a ban rule that was matching %(oldGlob)s to matching %(newGlob)s for %(reason)s": "%(senderName)s erneuert eine Ausschlussregel von %(oldGlob)s nach %(newGlob)s wegen %(reason)s", + "%(senderName)s changed a rule that was banning servers matching %(oldGlob)s to matching %(newGlob)s for %(reason)s": "%(senderName)s änderte eine Ausschlussregel für Server von %(oldGlob)s nach %(newGlob)s wegen %(reason)s", + "%(senderName)s updated a ban rule that was matching %(oldGlob)s to matching %(newGlob)s for %(reason)s": "%(senderName)s aktualisierte eine Ausschlussregel von %(oldGlob)s nach %(newGlob)s wegen %(reason)s", "Not Trusted": "Nicht vertraut", "Manually Verify by Text": "Verifiziere manuell mit einem Text", "Interactively verify by Emoji": "Verifiziere interaktiv mit Emojis", @@ -1218,12 +1218,12 @@ "Error adding ignored user/server": "Fehler beim Blockieren eines Nutzers/Servers", "None": "Nichts", "Ban list rules - %(roomName)s": "Verbotslistenregeln - %(roomName)s", - "Add users and servers you want to ignore here. Use asterisks to have %(brand)s match any characters. For example, @bot:* would ignore all users that have the name 'bot' on any server.": "Füge hier die Benutzer und Server hinzu, die du blockieren willst. Verwende Sternchen, damit %(brand)s mit beliebigen Zeichen übereinstimmt. Bspw. würde @bot: * alle Benutzer blockieren, die auf einem Server den Namen 'bot' haben.", + "Add users and servers you want to ignore here. Use asterisks to have %(brand)s match any characters. For example, @bot:* would ignore all users that have the name 'bot' on any server.": "Füge hier die Benutzer und Server hinzu, die du blockieren willst. Verwende Sternchen, um %(brand)s alle Zeichen abgleichen zu lassen. So würde @bot:* alle Benutzer mit dem Namen „bot“, auf jedem beliebigen Server, blockieren.", "Ignoring people is done through ban lists which contain rules for who to ban. Subscribing to a ban list means the users/servers blocked by that list will be hidden from you.": "Das Ignorieren von Personen erfolgt über Sperrlisten. Wenn eine Sperrliste abonniert wird, werden die von dieser Liste blockierten Benutzer und Server ausgeblendet.", "Personal ban list": "Persönliche Sperrliste", - "Your personal ban list holds all the users/servers you personally don't want to see messages from. After ignoring your first user/server, a new room will show up in your room list named 'My Ban List' - stay in this room to keep the ban list in effect.": "Deine persönliche Sperrliste enthält alle Benutzer/Server, von denen du persönlich keine Nachrichten sehen willst. Nachdem du den ersten Benutzer/Server blockiert hast, wird in der Raumliste \"Meine Sperrliste\" angezeigt - bleibe in diesem Raum, um die Sperrliste aufrecht zu halten.", + "Your personal ban list holds all the users/servers you personally don't want to see messages from. After ignoring your first user/server, a new room will show up in your room list named 'My Ban List' - stay in this room to keep the ban list in effect.": "Deine persönliche Sperrliste enthält alle Benutzer/Server, von denen du persönlich keine Nachrichten sehen willst. Nachdem du den ersten Benutzer/Server blockiert hast, wird in der Raumliste „Meine Sperrliste“ angezeigt – bleibe in diesem Raum, um die Sperrliste aktiv zu halten.", "Server or user ID to ignore": "Zu blockierende Server- oder Benutzer-ID", - "eg: @bot:* or example.org": "z.B. @bot:* oder example.org", + "eg: @bot:* or example.org": "z. B. @bot:* oder example.org", "Subscribed lists": "Abonnierte Listen", "Subscribing to a ban list will cause you to join it!": "Eine Verbotsliste abonnieren bedeutet ihr beizutreten!", "If this isn't what you want, please use a different tool to ignore users.": "Wenn dies nicht das ist, was du willst, verwende ein anderes Werkzeug, um Benutzer zu blockieren.", @@ -1307,7 +1307,7 @@ "This invite to %(roomName)s was sent to %(email)s which is not associated with your account": "Diese Einladung zu %(roomName)s wurde an die Adresse %(email)s gesendet, die nicht zu deinem Konto gehört", "Link this email with your account in Settings to receive invites directly in %(brand)s.": "Verbinde diese E-Mail-Adresse in den Einstellungen mit deinem Konto, um die Einladungen direkt in %(brand)s zu erhalten.", "This invite to %(roomName)s was sent to %(email)s": "Diese Einladung zu %(roomName)s wurde an %(email)s gesendet", - "Use an identity server in Settings to receive invites directly in %(brand)s.": "Verknüpfe einen Identitätsserver in den Einstellungen um die Einladungen direkt in %(brand)s zu erhalten.", + "Use an identity server in Settings to receive invites directly in %(brand)s.": "Verknüpfe einen Identitäts-Server in den Einstellungen, um die Einladungen direkt in %(brand)s zu erhalten.", "Share this email in Settings to receive invites directly in %(brand)s.": "Teile diese E-Mail-Adresse in den Einstellungen, um Einladungen direkt in %(brand)s zu erhalten.", "%(roomName)s can't be previewed. Do you want to join it?": "Vorschau von %(roomName)s kann nicht angezeigt werden. Möchtest du den Raum betreten?", "%(count)s unread messages including mentions.|other": "%(count)s ungelesene Nachrichten einschließlich Erwähnungen.", @@ -1316,21 +1316,21 @@ "%(count)s unread messages.|one": "1 ungelesene Nachricht.", "Unread messages.": "Ungelesene Nachrichten.", "This room has already been upgraded.": "Dieser Raum wurde bereits aktualisiert.", - "This room is running room version , which this homeserver has marked as unstable.": "Dieser Raum läuft mit der Raumversion , welche dieser Heimserver als instabil markiert hat.", + "This room is running room version , which this homeserver has marked as unstable.": "Dieser Raum läuft mit der Raumversion , welche dieser Heim-Server als instabil markiert hat.", "Unknown Command": "Unbekannter Befehl", "Unrecognised command: %(commandText)s": "Unbekannter Befehl: %(commandText)s", "Hint: Begin your message with // to start it with a slash.": "Hinweis: Beginne deine Nachricht mit //, um sie mit einem Schrägstrich zu beginnen.", "Send as message": "Als Nachricht senden", - "Failed to connect to integration manager": "Fehler beim Verbinden mit dem Integrationsserver", + "Failed to connect to integration manager": "Fehler beim Verbinden mit dem Integrations-Server", "Could not revoke the invite. The server may be experiencing a temporary problem or you do not have sufficient permissions to revoke the invite.": "Die Einladung konnte nicht zurückgezogen werden. Der Server hat möglicherweise ein vorübergehendes Problem oder du hast nicht ausreichende Berechtigungen, um die Einladung zurückzuziehen.", "Mark all as read": "Alle als gelesen markieren", "Local address": "Lokale Adresse", "Published Addresses": "Öffentliche Adresse", "Other published addresses:": "Andere öffentliche Adressen:", "No other published addresses yet, add one below": "Keine anderen öffentlichen Adressen vorhanden. Du kannst weiter unten eine hinzufügen", - "New published address (e.g. #alias:server)": "Neue öffentliche Adresse (z.B. #alias:server)", + "New published address (e.g. #alias:server)": "Neue öffentliche Adresse (z. B. #alias:server)", "Local Addresses": "Lokale Adressen", - "Set addresses for this room so users can find this room through your homeserver (%(localDomain)s)": "Erstelle Adressen für diesen Raum, damit andere Benutzer den Raum auf deinem Heimserver (%(localDomain)s) finden können", + "Set addresses for this room so users can find this room through your homeserver (%(localDomain)s)": "Erstelle Adressen für diesen Raum, damit andere Benutzer den Raum auf deinem Heim-Server (%(localDomain)s) finden können", "Waiting for %(displayName)s to accept…": "Warte auf die Annahme von %(displayName)s …", "Accepting…": "Annehmen…", "Start Verification": "Verifizierung starten", @@ -1358,7 +1358,7 @@ "You have ignored this user, so their message is hidden. Show anyways.": "Du blockierst diesen Benutzer, deshalb werden seine Nachrichten nicht angezeigt. Trotzdem anzeigen.", "You accepted": "Du hast angenommen", "You declined": "Du hast abgelehnt", - "You cancelled": "Du hast abgebrochen", + "You cancelled": "Du brachst ab", "Accepting …": "Annehmen …", "Declining …": "Ablehnen …", "You sent a verification request": "Du hast eine Verifizierungsanfrage gesendet", @@ -1405,13 +1405,13 @@ "Your server": "Dein Server", "Matrix": "Matrix", "Add a new server": "Einen Server hinzufügen", - "Enter the name of a new server you want to explore.": "Gib den Namen des Servers an den du erforschen möchtest.", - "Server name": "Servername", + "Enter the name of a new server you want to explore.": "Gib den Namen des Servers an, den du erkunden möchtest.", + "Server name": "Server-Name", "Close dialog": "Dialog schließen", "Please tell us what went wrong or, better, create a GitHub issue that describes the problem.": "Bitte teile uns mit, was schief lief - oder besser, beschreibe das Problem auf GitHub in einem \"Issue\".", "Reminder: Your browser is unsupported, so your experience may be unpredictable.": "Warnung: Dein Browser wird nicht unterstützt. Die Anwendung kann instabil sein.", "Notes": "Notizen", - "If there is additional context that would help in analysing the issue, such as what you were doing at the time, room IDs, user IDs, etc., please include those things here.": "Wenn du mehr Informationen hast, die uns bei Untersuchung des Problems helfen (z.B. was du gerade getan hast, Raum-IDs, Benutzer-IDs, etc.), gib sie bitte hier an.", + "If there is additional context that would help in analysing the issue, such as what you were doing at the time, room IDs, user IDs, etc., please include those things here.": "Wenn es mehr Informationen gibt, die uns bei der Auswertung des Problems würden – z. B. was du getan hast, Raum- oder Benutzer-IDs … – gib sie bitte hier an.", "Removing…": "Löschen…", "Destroy cross-signing keys?": "Cross-Signing-Schlüssel zerstören?", "Clear cross-signing keys": "Cross-Signing-Schlüssel löschen", @@ -1445,7 +1445,7 @@ "Please fill why you're reporting.": "Bitte gib an, weshalb du einen Fehler meldest.", "Upgrade private room": "Privaten Raum aktualisieren", "Upgrade public room": "Öffentlichen Raum aktualisieren", - "This usually only affects how the room is processed on the server. If you're having problems with your %(brand)s, please report a bug.": "Dies wirkt sich normalerweise nur darauf aus, wie der Raum auf dem Server verarbeitet wird. Wenn du Probleme mit deinem %(brand)s hast, melde bitte einen Bug.", + "This usually only affects how the room is processed on the server. If you're having problems with your %(brand)s, please report a bug.": "Dies beeinflusst meistens nur, wie der Raum auf dem Server verarbeitet wird. Solltest du Probleme mit %(brand)s haben, melde bitte einen Programmfehler.", "You'll upgrade this room from to .": "Du wirst diesen Raum von zu aktualisieren.", "Missing session data": "Fehlende Sitzungsdaten", "Your browser likely removed this data when running low on disk space.": "Dein Browser hat diese Daten wahrscheinlich entfernt als der Festplattenspeicher knapp wurde.", @@ -1460,13 +1460,13 @@ "Upload %(count)s other files|one": "%(count)s andere Datei hochladen", "Remember my selection for this widget": "Speichere meine Auswahl für dieses Widget", "Restoring keys from backup": "Schlüssel aus der Sicherung wiederherstellen", - "Fetching keys from server...": "Lade Schlüssel vom Server …", + "Fetching keys from server...": "Beziehe Schlüssel vom Server …", "%(completed)s of %(total)s keys restored": "%(completed)s von %(total)s Schlüsseln wiederhergestellt", "Keys restored": "Schlüssel wiederhergestellt", "Successfully restored %(sessionCount)s keys": "%(sessionCount)s Schlüssel erfolgreich wiederhergestellt", "Country Dropdown": "Landauswahl", "Resend %(unsentCount)s reaction(s)": "%(unsentCount)s Reaktion(en) erneut senden", - "Missing captcha public key in homeserver configuration. Please report this to your homeserver administrator.": "Fehlender öffentlicher Captcha-Schlüssel in der Heimserver-Konfiguration. Bitte melde dies deinem Heimserver-Administrator.", + "Missing captcha public key in homeserver configuration. Please report this to your homeserver administrator.": "Fehlender öffentlicher Captcha-Schlüssel in der Heim-Server-Konfiguration. Bitte melde dies deiner Heimserver-Administration.", "Use an email address to recover your account": "Verwende eine E-Mail-Adresse, um dein Konto wiederherzustellen", "Enter email address (required on this homeserver)": "E-Mail-Adresse eingeben (auf diesem Heim-Server erforderlich)", "Doesn't look like a valid email address": "Das sieht nicht nach einer gültigen E-Mail-Adresse aus", @@ -1479,15 +1479,15 @@ "%(brand)s failed to get the public room list.": "%(brand)s konnte die Liste der öffentlichen Räume nicht laden.", "Syncing...": "Synchronisiere …", "Signing In...": "Melde an …", - "The homeserver may be unavailable or overloaded.": "Der Heim-Server ist möglicherweise nicht verfügbar oder überlastet.", + "The homeserver may be unavailable or overloaded.": "Der Heim-Server ist möglicherweise nicht erreichbar oder überlastet.", "Jump to first unread room.": "Zum ersten ungelesenen Raum springen.", "Jump to first invite.": "Zur ersten Einladung springen.", "You have %(count)s unread notifications in a prior version of this room.|other": "Du hast %(count)s ungelesene Benachrichtigungen in einer früheren Version dieses Raums.", "Failed to get autodiscovery configuration from server": "Abrufen der Autodiscovery-Konfiguration vom Server fehlgeschlagen", "Invalid base_url for m.homeserver": "Ungültige base_url für m.homeserver", - "Homeserver URL does not appear to be a valid Matrix homeserver": "Die Heimserver-URL scheint kein gültiger Matrix-Heimserver zu sein", + "Homeserver URL does not appear to be a valid Matrix homeserver": "Die Heim-Server-URL scheint kein gültiger Matrix-Heim-Server zu sein", "Invalid base_url for m.identity_server": "Ungültige base_url für m.identity_server", - "Identity server URL does not appear to be a valid identity server": "Die Identitätsserver-URL scheint kein gültiger Identitätsserver zu sein", + "Identity server URL does not appear to be a valid identity server": "Die Identitäts-Server-URL scheint kein gültiger Identitäts-Server zu sein", "This account has been deactivated.": "Dieses Konto wurde deaktiviert.", "Continue with previous account": "Mit vorherigem Konto fortfahren", "Log in to your new account.": "Mit deinem neuen Konto anmelden.", @@ -1526,9 +1526,9 @@ "Other users can invite you to rooms using your contact details": "Andere Personen können dich mit deinen Kontaktdaten in Räume einladen", "Explore Public Rooms": "Öffentliche Räume erkunden", "If you've joined lots of rooms, this might take a while": "Du bist einer Menge Räumen beigetreten, das kann eine Weile dauern", - "%(brand)s failed to get the protocol list from the homeserver. The homeserver may be too old to support third party networks.": "%(brand)s konnte die Protokollliste nicht vom Heimserver abrufen. Der Heimserver ist möglicherweise zu alt, um Netzwerke von Drittanbietern zu unterstützen.", + "%(brand)s failed to get the protocol list from the homeserver. The homeserver may be too old to support third party networks.": "%(brand)s konnte die Protokollliste nicht vom Heim-Server abrufen. Der Heim-Server ist möglicherweise zu alt, um Netzwerke von Drittanbietern zu unterstützen.", "Your new account (%(newAccountId)s) is registered, but you're already logged into a different account (%(loggedInUserId)s).": "Dein neues Konto (%(newAccountId)s) ist registriert, aber du hast dich bereits in mit einem anderen Konto (%(loggedInUserId)s) angemeldet.", - "Failed to re-authenticate due to a homeserver problem": "Erneute Authentifizierung aufgrund eines Problems im Heim-Server fehlgeschlagen", + "Failed to re-authenticate due to a homeserver problem": "Erneute Authentifizierung aufgrund eines Problems des Heim-Servers fehlgeschlagen", "Failed to re-authenticate": "Erneute Authentifizierung fehlgeschlagen", "Command Autocomplete": "Autovervollständigung aktivieren", "Emoji Autocomplete": "Emoji-Auto-Vervollständigung", @@ -1559,7 +1559,7 @@ "%(doneRooms)s out of %(totalRooms)s": "%(doneRooms)s von %(totalRooms)s", "Unable to query secret storage status": "Status des sicheren Speichers kann nicht gelesen werden", "Without setting up Secure Message Recovery, you won't be able to restore your encrypted message history if you log out or use another session.": "Ohne eine Schlüsselsicherung kann dein verschlüsselter Nachrichtenverlauf nicht wiederhergestellt werden wenn du dich abmeldest oder eine andere Sitzung verwendest.", - "There was an error updating the room's alternative addresses. It may not be allowed by the server or a temporary failure occurred.": "Es gab einen Fehler beim Ändern des Raumaliases. Entweder erlaubt es der Server nicht oder es gab ein temporäres Problem.", + "There was an error updating the room's alternative addresses. It may not be allowed by the server or a temporary failure occurred.": "Es gab einen Fehler beim Ändern des Raumalias. Entweder erlaubt es der Server nicht oder es gab ein temporäres Problem.", "%(brand)s is securely caching encrypted messages locally for them to appear in search results:": "%(brand)s verwendet einen sicheren Zwischenspeicher für verschlüsselte Nachrichten, damit sie in den Suchergebnissen angezeigt werden:", "Message downloading sleep time(ms)": "Wartezeit zwischen dem Herunterladen von Nachrichten (ms)", "If you did this accidentally, you can setup Secure Messages on this session which will re-encrypt this session's message history with a new recovery method.": "Wenn du dies versehentlich getan hast, kannst du in dieser Sitzung \"sichere Nachrichten\" einrichten, die den Nachrichtenverlauf dieser Sitzung mit einer neuen Wiederherstellungsmethode erneut verschlüsseln.", @@ -1606,7 +1606,7 @@ "This address is already in use": "Diese Adresse wird bereits verwendet", "delete the address.": "lösche die Adresse.", "Use a different passphrase?": "Eine andere Passphrase verwenden?", - "Your server admin has disabled end-to-end encryption by default in private rooms & Direct Messages.": "Deine Serveradministration hat die Ende-zu-Ende-Verschlüsselung für private Räume und Direktnachrichten standardmäßig deaktiviert.", + "Your server admin has disabled end-to-end encryption by default in private rooms & Direct Messages.": "Deine Server-Administration hat die Ende-zu-Ende-Verschlüsselung für private Räume und Direktnachrichten standardmäßig deaktiviert.", "People": "Personen", "There was an error removing that address. It may no longer exist or a temporary error occurred.": "Beim Entfernen dieser Adresse ist ein Fehler aufgetreten. Vielleicht existiert sie nicht mehr oder es kam zu einem temporären Fehler.", "You've previously used a newer version of %(brand)s with this session. To use this version again with end to end encryption, you will need to sign out and back in again.": "Du hast für diese Sitzung zuvor eine neuere Version von %(brand)s verwendet. Um diese Version mit Ende-zu-Ende-Verschlüsselung wieder zu benutzen, musst du dich erst ab- und dann wieder anmelden.", @@ -1671,19 +1671,19 @@ "%(brand)s encountered an error during upload of:": "%(brand)s hat einen Fehler festgestellt beim hochladen von:", "Change notification settings": "Benachrichtigungseinstellungen ändern", "Your server isn't responding to some requests.": "Dein Server antwortet auf einige Anfragen nicht.", - "Server isn't responding": "Server antwortet nicht", - "Your server isn't responding to some of your requests. Below are some of the most likely reasons.": "Server reagiert nicht auf einige deiner Anfragen. Im Folgenden sind einige der wahrscheinlichsten Gründe aufgeführt.", - "The server (%(serverName)s) took too long to respond.": "Der Server (%(serverName)s) brauchte zu lange zum antworten.", + "Server isn't responding": "Server reagiert nicht", + "Your server isn't responding to some of your requests. Below are some of the most likely reasons.": "Server reagiert auf einige deiner Anfragen nicht. Folgend sind einige der wahrscheinlichsten Gründe aufgeführt.", + "The server (%(serverName)s) took too long to respond.": "Die Reaktionszeit des Servers (%(serverName)s) war zu hoch.", "Your firewall or anti-virus is blocking the request.": "Deine Firewall oder Anti-Virus-Programm blockiert die Anfrage.", "A browser extension is preventing the request.": "Eine Browser-Erweiterung verhindert die Anfrage.", - "The server is offline.": "Der Server ist offline.", + "The server is offline.": "Der Server ist außer Betrieb.", "The server has denied your request.": "Der Server hat deine Anfrage abgewiesen.", "Your area is experiencing difficulties connecting to the internet.": "Deine Region hat Schwierigkeiten, eine Verbindung zum Internet herzustellen.", "A connection error occurred while trying to contact the server.": "Beim Versuch, den Server zu kontaktieren, ist ein Verbindungsfehler aufgetreten.", "Master private key:": "Privater Hauptschlüssel:", "Set the name of a font installed on your system & %(brand)s will attempt to use it.": "Setze den Schriftnamen auf eine in deinem System installierte Schriftart und %(brand)s wird versuchen, sie zu verwenden.", "You're all caught up.": "Du bist auf dem neuesten Stand.", - "The server is not configured to indicate what the problem is (CORS).": "Der Server ist nicht so konfiguriert, dass das Problem angezeigt wird (CORS).", + "The server is not configured to indicate what the problem is (CORS).": "Der Server ist nicht dafür konfiguriert, das Problem anzuzeigen (CORS).", "Recent changes that have not yet been received": "Letzte Änderungen, die noch nicht eingegangen sind", "Set a Security Phrase": "Sicherheitsphrase setzen", "Confirm Security Phrase": "Sicherheitsphrase bestätigen", @@ -1693,7 +1693,7 @@ "Use your Security Key to continue.": "Benutze deinen Sicherheitsschlüssel um fortzufahren.", "No files visible in this room": "Keine Dateien in diesem Raum", "Attach files from chat or just drag and drop them anywhere in a room.": "Hänge Dateien aus der Unterhaltung an oder ziehe sie einfach an eine beliebige Stelle im Raum.", - "Safeguard against losing access to encrypted messages & data by backing up encryption keys on your server.": "Schütze dich vor dem Verlust des Zugriffs auf verschlüsselte Nachrichten und Daten, indem du Verschlüsselungsschlüssel auf deinem Server sicherst.", + "Safeguard against losing access to encrypted messages & data by backing up encryption keys on your server.": "Verhindere, den Zugriff auf verschlüsselte Nachrichten und Daten zu verlieren, indem du die Verschlüsselungs-Schlüssel auf deinem Server sicherst.", "Generate a Security Key": "Sicherheitsschlüssel generieren", "Enter a Security Phrase": "Sicherheitsphrase eingeben", "Use a secret phrase only you know, and optionally save a Security Key to use for backup.": "Verwende für deine Sicherung eine geheime Phrase, die nur du kennst, und speichere optional einen Sicherheitsschlüssel.", @@ -1701,19 +1701,19 @@ "You can also set up Secure Backup & manage your keys in Settings.": "Du kannst auch in den Einstellungen Sicherungen einrichten und deine Schlüssel verwalten.", "Show message previews for reactions in DMs": "Anzeigen einer Nachrichtenvorschau für Reaktionen in DMs", "Show message previews for reactions in all rooms": "Zeige eine Nachrichtenvorschau für Reaktionen in allen Räumen an", - "Uploading logs": "Protokolle werden hochgeladen", - "Downloading logs": "Protokolle werden heruntergeladen", + "Uploading logs": "Lade Protokolle hoch", + "Downloading logs": "Lade Protokolle herunter", "Explore public rooms": "Öffentliche Räume erkunden", "Explore all public rooms": "Alle öffentlichen Räume erkunden", "%(count)s results|other": "%(count)s Ergebnisse", "Preparing to download logs": "Bereite das Herunterladen der Protokolle vor", "Download logs": "Protokolle herunterladen", - "Unexpected server error trying to leave the room": "Unerwarteter Serverfehler beim Versuch den Raum zu verlassen", + "Unexpected server error trying to leave the room": "Unerwarteter Server-Fehler beim Versuch den Raum zu verlassen", "Error leaving room": "Fehler beim Verlassen des Raums", "Set up Secure Backup": "Schlüsselsicherung einrichten", "Information": "Information", - "You might enable this if the room will only be used for collaborating with internal teams on your homeserver. This cannot be changed later.": "Du solltest dies aktivieren, wenn der Raum nur für die Zusammenarbeit mit Benutzern von deinem Heimserver verwendet werden soll. Dies kann später nicht mehr geändert werden.", - "You might disable this if the room will be used for collaborating with external teams who have their own homeserver. This cannot be changed later.": "Du solltest dies deaktivieren, wenn der Raum für die Zusammenarbeit mit Benutzern von anderen Heimserver verwendet werden soll. Dies kann später nicht mehr geändert werden.", + "You might enable this if the room will only be used for collaborating with internal teams on your homeserver. This cannot be changed later.": "Du solltest dies aktivieren, wenn der Raum nur für die Zusammenarbeit mit Benutzern von deinem Heim-Server verwendet werden soll. Dies kann später nicht mehr geändert werden.", + "You might disable this if the room will be used for collaborating with external teams who have their own homeserver. This cannot be changed later.": "Du solltest dies deaktivieren, wenn der Raum für die Zusammenarbeit mit Benutzern von anderen Heim-Server verwendet werden soll. Dies kann später nicht mehr geändert werden.", "Block anyone not part of %(serverName)s from ever joining this room.": "Betreten nur für Nutzer von %(serverName)s erlauben.", "Privacy": "Privatsphäre", "Prepends ( ͡° ͜ʖ ͡°) to a plain-text message": "Stellt ( ͡° ͜ʖ ͡°) einer Klartextnachricht voran", @@ -1723,7 +1723,7 @@ "Not encrypted": "Nicht verschlüsselt", "About": "Über", "Room settings": "Raumeinstellungen", - "Take a picture": "Foto aufnehmen", + "Take a picture": "Bildschirmfoto", "Unpin": "Nicht mehr anheften", "Cross-signing is ready for use.": "Quersignaturen sind bereits in Anwendung.", "Cross-signing is not set up.": "Quersignierung wurde nicht eingerichtet.", @@ -1734,7 +1734,7 @@ "Secret storage:": "Sicherer Speicher:", "ready": "bereit", "not ready": "nicht bereit", - "Secure Backup": "Geschützte Sicherung", + "Secure Backup": "Verschlüsselte Sicherung", "Safeguard against losing access to encrypted messages & data": "Schütze dich vor dem Verlust verschlüsselter Nachrichten und Daten", "not found in storage": "nicht im Speicher gefunden", "Widgets": "Widgets", @@ -1751,7 +1751,7 @@ "Join the conference at the top of this room": "An Konferenz oberhalb des Verlaufs teilnehmen", "Join the conference from the room information card on the right": "An der Konferenz kannst du über die rechte Seitenleiste (Rauminfo) teilnehmen", "Video conference ended by %(senderName)s": "Videokonferenz von %(senderName)s beendet", - "Video conference updated by %(senderName)s": "Videokonferenz wurde %(senderName)s aktualisiert", + "Video conference updated by %(senderName)s": "Videokonferenz wurde von %(senderName)s aktualisiert", "Video conference started by %(senderName)s": "Videokonferenz von %(senderName)s gestartet", "Ignored attempt to disable encryption": "Versuch, die Verschlüsselung zu deaktivieren, wurde ignoriert", "Failed to save your profile": "Speichern des Profils fehlgeschlagen", @@ -1856,7 +1856,7 @@ "Messages in this room are end-to-end encrypted. When people join, you can verify them in their profile, just tap on their avatar.": "Nachrichten in diesem Raum sind Ende-zu-Ende-verschlüsselt. Wenn Personen ihn betreten, kannst du sie in ihrem Profil verifizieren, indem du auf ihren Avatar klickst.", "Comment": "Kommentar", "Please view existing bugs on Github first. No match? Start a new one.": "Bitte wirf einen Blick auf existierende Programmfehler auf Github. Keinen passenden gefunden? Erstelle einen neuen.", - "PRO TIP: If you start a bug, please submit debug logs to help us track down the problem.": "PRO TIPP: Wenn du einen Programmfehler meldest, füge bitte Debug-Logs hinzu um uns zu helfen das Problem zu finden.", + "PRO TIP: If you start a bug, please submit debug logs to help us track down the problem.": "PRO TIPP: Wenn du einen Programmfehler meldest, füge bitte Debug-Protokolle hinzu, um uns beim Finden des Problems zu helfen.", "Invite by email": "Via Email einladen", "Start a conversation with someone using their name, email address or username (like ).": "Beginne eine Konversation mit jemanden unter Benutzung des Namens, der Email-Adresse oder der Matrix-ID (wie ).", "Invite someone using their name, email address, username (like ) or share this room.": "Lade jemanden mittels Name, E-Mail-Adresse oder Benutzername (wie ) ein, oder teile diesen Raum.", @@ -2131,7 +2131,7 @@ "%(ssoButtons)s Or %(usernamePassword)s": "%(ssoButtons)s oder %(usernamePassword)s", "Continue with %(ssoButtons)s": "Mit %(ssoButtons)s anmelden", "New? Create account": "Neu? Erstelle ein Konto", - "There was a problem communicating with the homeserver, please try again later.": "Es gab ein Problem bei der Kommunikation mit dem Homseserver. Bitte versuche es später erneut.", + "There was a problem communicating with the homeserver, please try again later.": "Es gab ein Problem bei der Kommunikation mit dem Heim-Server. Bitte versuche es später erneut.", "New here? Create an account": "Neu hier? Erstelle ein Konto", "Got an account? Sign in": "Du hast bereits ein Konto? Melde dich an", "Use email to optionally be discoverable by existing contacts.": "Nutze optional eine E-Mail-Adresse, um von Nutzern gefunden werden zu können.", @@ -2140,7 +2140,7 @@ "Forgot password?": "Passwort vergessen?", "That phone number doesn't look quite right, please check and try again": "Diese Telefonummer sieht nicht ganz richtig aus. Bitte überprüfe deine Eingabe und versuche es erneut", "About homeservers": "Über Heim-Server", - "Learn more": "Mehr dazu", + "Learn more": "Mehr erfahren", "Use your preferred Matrix homeserver if you have one, or host your own.": "Verwende einen Matrix-Heim-Server deiner Wahl oder betreibe deinen eigenen.", "Other homeserver": "Anderer Heim-Server", "Sign into your homeserver": "Melde dich bei deinem Heim-Server an", @@ -2214,7 +2214,7 @@ "Repeat your Security Phrase...": "Wiederhole deine Sicherheitsphrase …", "Great! This Security Phrase looks strong enough.": "Großartig! Diese Sicherheitsphrase sieht stark genug aus.", "Access your secure message history and set up secure messaging by entering your Security Phrase.": "Greife auf deinen verschlüsselten Nachrichtenverlauf zu und richte die sichere Kommunikation ein, indem du deine Sicherheitsphrase eingibst.", - "We'll store an encrypted copy of your keys on our server. Secure your backup with a Security Phrase.": "Wir werden eine verschlüsselte Kopie deiner Schlüssel auf unserem Server speichern. Sichere dein Backup mit einer Sicherheitsphrase (z.B. einem langen Satz, den niemand errät).", + "We'll store an encrypted copy of your keys on our server. Secure your backup with a Security Phrase.": "Wir werden eine verschlüsselte Kopie deiner Schlüssel auf unserem Server speichern. Schütze deine Sicherung mit einer Sicherheitsphrase.", "If you've forgotten your Security Key you can ": "Wenn du deinen Sicherheitsschlüssel vergessen hast, kannst du ", "Access your secure message history and set up secure messaging by entering your Security Key.": "Greife auf deinen verschlüsselten Nachrichtenverlauf zu und richte die sichere Kommunikation ein, indem du deinen Sicherheitsschlüssel eingibst.", "If you've forgotten your Security Phrase you can use your Security Key or set up new recovery options": "Wenn du deine Sicherheitsphrase vergessen hast, kannst du deinen Sicherheitsschlüssel nutzen oder neue Wiederherstellungsoptionen einrichten", @@ -2279,7 +2279,7 @@ "Public": "Öffentlich", "Create a space": "Neuen Space erstellen", "Delete": "Löschen", - "This homeserver has been blocked by its administrator.": "Dieser Heim-Server wurde von ihrer Administration geblockt.", + "This homeserver has been blocked by its administrator.": "Dieser Heim-Server wurde von seiner Administration geblockt.", "You're already in a call with this person.": "Du bist schon in einem Anruf mit dieser Person.", "Already in call": "Schon im Anruf", "Invite people": "Personen einladen", @@ -2294,8 +2294,8 @@ "Invite to this space": "In diesen Space einladen", "Failed to invite the following users to your space: %(csvUsers)s": "Die folgenden Leute konnten nicht eingeladen werden: %(csvUsers)s", "Share %(name)s": "%(name)s teilen", - "Skip for now": "Für jetzt überspringen", - "Random": "Zufällig", + "Skip for now": "Vorerst überspringen", + "Random": "Ohne Thema", "Welcome to ": "Willkommen bei ", "Private space": "Privater Space", "Public space": "Öffentlicher Space", @@ -2308,10 +2308,10 @@ "%(count)s members|one": "%(count)s Mitglied", "Are you sure you want to leave the space '%(spaceName)s'?": "Willst du %(spaceName)s wirklich verlassen?", "Start audio stream": "Audiostream starten", - "Failed to start livestream": "Livestream kann nicht gestartet werden", + "Failed to start livestream": "Livestream konnte nicht gestartet werden", "Unable to start audio streaming.": "Audiostream kann nicht gestartet werden.", "Leave Space": "Space verlassen", - "This usually only affects how the room is processed on the server. If you're having problems with your %(brand)s, please report a bug.": "Dies beeinflusst meistens nur die Verarbeitung des Raumes am Server. Falls du Probleme mit %(brand)s hast, erstelle bitte einen Bug-Report.", + "This usually only affects how the room is processed on the server. If you're having problems with your %(brand)s, please report a bug.": "Dies beeinflusst meistens nur, wie der Raum auf dem Server verarbeitet wird. Solltest du Probleme mit %(brand)s haben, erstelle bitte einen Fehlerbericht.", "Invite someone using their name, username (like ) or share this space.": "Lade Leute mittels Anzeigename oder Benutzername (z. B. ) ein oder teile diesen Space.", "Invite someone using their name, email address, username (like ) or share this space.": "Lade Leute mittels Anzeigename, E-Mail-Adresse oder Benutzername (z. B. ) ein oder teile diesen Space.", "Invite to %(roomName)s": "In %(roomName)s einladen", @@ -2323,11 +2323,11 @@ "Invite by username": "Mit Benutzername einladen", "Make sure the right people have access. You can invite more later.": "Stelle sicher, dass die richtigen Personen Zugriff haben. Du kannst später weitere einladen.", "Invite your teammates": "Lade deine Kollegen ein", - "A private space for you and your teammates": "Ein privater Space für dich und dein Team", + "A private space for you and your teammates": "Ein privater Space für dich und deine Kollegen", "Me and my teammates": "Für mich und meine Kollegen", - "A private space to organise your rooms": "Ein privater Space zum Organisieren von Räumen", + "A private space to organise your rooms": "Ein privater Space zum Organisieren deiner Räume", "Just me": "Nur für mich", - "Who are you working with?": "Für wen soll dieser Space sein?", + "Who are you working with?": "Für wen ist dieser Space gedacht?", "Make sure the right people have access to %(name)s": "Stelle sicher, dass die richtigen Personen Zugriff auf %(name)s haben", "Go to my first room": "Zum ersten Raum springen", "It's just you at the moment, it will be even better with others.": "Momentan bist nur du hier. Mit anderen Leuten wird es noch viel besser.", @@ -2362,7 +2362,7 @@ "%(deviceId)s from %(ip)s": "%(deviceId)s von %(ip)s", "You have unverified logins": "Du hast nicht-bestätigte Anmeldungen", "Review to ensure your account is safe": "Überprüfen, um sicher zu sein, dass dein Konto sicher ist", - "Support": "Support", + "Support": "Unterstützung", "This room is suggested as a good one to join": "Dieser Raum wird empfohlen", "Verification requested": "Verifizierung angefragt", "Avatar": "Avatar", @@ -2378,12 +2378,12 @@ "Reset event store?": "Ereignisspeicher zurück setzen?", "You most likely do not want to reset your event index store": "Es ist wahrscheinlich, dass du den Ereignis-Indexspeicher nicht zurück setzen möchtest", "Reset event store": "Ereignisspeicher zurück setzen", - "You can add more later too, including already existing ones.": "Natürlich kannst du jederzeit weitere Räume hinzufügen.", - "Let's create a room for each of them.": "Wir erstellen dir für jedes Thema einen Raum.", + "You can add more later too, including already existing ones.": "Du kannst später weitere hinzufügen, auch bereits bestehende.", + "Let's create a room for each of them.": "Lass uns für jedes einen Raum erstellen.", "What are some things you want to discuss in %(spaceName)s?": "Welche Themen willst du in %(spaceName)s besprechen?", "Inviting...": "Einladen …", "Failed to create initial space rooms": "Fehler beim Initialisieren des Space", - "You are the only person here. If you leave, no one will be able to join in the future, including you.": "Du bist die einzige Person hier. Wenn du ihn jetzt verlässt, ist er für immer verloren (eine lange Zeit).", + "You are the only person here. If you leave, no one will be able to join in the future, including you.": "Du bist die einzige Person im Raum. Sobald du ihn verlässt, wird niemand mehr hineingelangen, auch du nicht.", "Edit settings relating to your space.": "Einstellungen vom Space bearbeiten.", "If you reset everything, you will restart with no trusted sessions, no trusted users, and might not be able to see past messages.": "Wenn du alles zurücksetzt, gehen alle verifizierten Anmeldungen, Benutzer und vergangenen Nachrichten verloren.", "Only do this if you have no other device to complete verification with.": "Verwende es nur, wenn du kein Gerät, mit dem du dich verifizieren, kannst bei dir hast.", @@ -2435,7 +2435,7 @@ "Search names and descriptions": "Nach Name und Beschreibung filtern", "Not all selected were added": "Nicht alle Ausgewählten konnten hinzugefügt werden", "Add reaction": "Reaktion hinzufügen", - "This is an experimental feature. For now, new users receiving an invite will have to open the invite on to actually join.": "Diese Funktion ist experimentell. Falls du eine Einladung erhältst, musst du sie momentan noch auf öffnen, um beizutreten.", + "This is an experimental feature. For now, new users receiving an invite will have to open the invite on to actually join.": "Diese Funktion ist experimentell. Falls du eine Einladung erhältst, musst du sie momentan noch auf öffnen, um den Raum zu betreten.", "You may contact me if you have any follow up questions": "Kontaktiert mich, falls ihr weitere Fragen zu meiner Rückmeldung habt", "To leave the beta, visit your settings.": "Du kannst die Beta in den Einstellungen deaktivieren.", "Your platform and username will be noted to help us use your feedback as much as we can.": "Deine Systeminformationen und dein Benutzername werden mitgeschickt, damit wir deine Rückmeldung bestmöglich nachvollziehen können.", @@ -2489,8 +2489,8 @@ "%(severalUsers)schanged the server ACLs %(count)s times|other": "%(severalUsers)s haben die Server-ACLs %(count)s-mal geändert", "Set addresses for this space so users can find this space through your homeserver (%(localDomain)s)": "Füge Adressen für diesen Space hinzu, damit andere Leute ihn über deinen Heim-Server (%(localDomain)s) finden können", "To publish an address, it needs to be set as a local address first.": "Damit du die Adresse veröffentlichen kannst, musst du sie zuerst als lokale Adresse hinzufügen.", - "Published addresses can be used by anyone on any server to join your room.": "Veröffentlichte Adressen erlauben jedem, dem Raum beizutreten.", - "Published addresses can be used by anyone on any server to join your space.": "Veröffentlichte Adressen erlauben jedem, dem Space beizutreten.", + "Published addresses can be used by anyone on any server to join your room.": "Veröffentlichte Adressen erlauben jedem, den Raum zu betreten.", + "Published addresses can be used by anyone on any server to join your space.": "Veröffentlichte Adressen erlauben jedem, den Space zu betreten.", "This space has no local addresses": "Dieser Space hat keine lokale Adresse", "Space information": "Information über den Space", "Collapse": "Verbergen", @@ -2507,7 +2507,7 @@ "Failed to update the guest access of this space": "Gastzugriff des Space konnte nicht geändert werden", "Failed to update the visibility of this space": "Sichtbarkeit des Space konnte nicht geändert werden", "Address": "Adresse", - "e.g. my-space": "z.B. Mein-Space", + "e.g. my-space": "z. B. mein-space", "Sound on": "Ton an", "Show all rooms in Home": "Alle Räume auf Startseite anzeigen", "Report to moderators prototype. In rooms that support moderation, the `report` button will let you report abuse to room moderators": "Inhalte an Mods melden. In Räumen, die Moderation unterstützen, kannst du so unerwünschte Inhalte direkt der Raummoderation melden", @@ -2551,13 +2551,13 @@ "Using this widget may share data with %(widgetDomain)s & your integration manager.": "Wenn du dieses Widget verwendest, können Daten zu %(widgetDomain)s und deinem Integrationsmanager übertragen werden.", "Identity server is": "Dein Identitäts-Server ist", "Integration managers receive configuration data, and can modify widgets, send room invites, and set power levels on your behalf.": "Integrationsassistenten erhalten Konfigurationsdaten und können Widgets modifizieren, Raumeinladungen verschicken und in deinem Namen Berechtigungslevel setzen.", - "Use an integration manager to manage bots, widgets, and sticker packs.": "Verwende einen Integrationsserver, um Bots, Widgets und Sticker-Pakete zu verwalten.", - "Use an integration manager (%(serverName)s) to manage bots, widgets, and sticker packs.": "Nutze einen Integrationsserver (%(serverName)s), um Bots, Widgets und Sticker-Pakete zu verwalten.", - "Identity server": "Identitätsserver", - "Identity server (%(server)s)": "Identitätsserver (%(server)s)", - "Could not connect to identity server": "Verbindung zum Identitätsserver konnte nicht hergestellt werden", - "Not a valid identity server (status code %(code)s)": "Ungültiger Identitätsserver (Fehlercode %(code)s)", - "Identity server URL must be HTTPS": "Der Identitätsserver muss über HTTPS erreichbar sein", + "Use an integration manager to manage bots, widgets, and sticker packs.": "Verwende einen Integrations-Server, um Bots, Widgets und Sticker-Pakete zu verwalten.", + "Use an integration manager (%(serverName)s) to manage bots, widgets, and sticker packs.": "Nutze einen Integrations-Server (%(serverName)s), um Bots, Widgets und Sticker-Pakete zu verwalten.", + "Identity server": "Identitäts-Server", + "Identity server (%(server)s)": "Identitäts-Server (%(server)s)", + "Could not connect to identity server": "Verbindung zum Identitäts-Server konnte nicht hergestellt werden", + "Not a valid identity server (status code %(code)s)": "Ungültiger Identitäts-Server (Fehlercode %(code)s)", + "Identity server URL must be HTTPS": "Identitäts-Server-URL muss mit HTTPS anfangen", "Error processing audio message": "Fehler beim Verarbeiten der Audionachricht", "Code blocks": "Quelltextblöcke", "There was an error loading your notification settings.": "Fehler beim Laden der Benachrichtigungseinstellungen.", @@ -2591,7 +2591,7 @@ "Missed call": "Verpasster Anruf", "Call declined": "Anruf abgelehnt", "Dialpad": "Telefontastatur", - "Stop the camera": "Kamera stoppen", + "Stop the camera": "Kamera beenden", "Start the camera": "Kamera starten", "You can change this at any time from room settings.": "Du kannst das jederzeit in den Raumeinstellungen ändern.", "Everyone in will be able to find and join this room.": "Mitglieder von können diesen Raum finden und betreten.", @@ -2611,7 +2611,7 @@ "Access": "Zugriff", "Decide who can join %(roomName)s.": "Entscheide, wer %(roomName)s betreten kann.", "Space members": "Spacemitglieder", - "Anyone in a space can find and join. You can select multiple spaces.": "Das Beitreten ist allen in den gewählten Spaces möglich.", + "Anyone in a space can find and join. You can select multiple spaces.": "Das Betreten ist allen in den gewählten Spaces möglich.", "Spaces with access": "Spaces mit Zugriff", "Anyone in a space can find and join. Edit which spaces can access here.": "Das Betreten ist allen in diesen Spaces möglich. Ändere, welche Spaces Zugriff haben.", "Currently, %(count)s spaces have access|other": "%(count)s Spaces haben Zugriff", @@ -2659,7 +2659,7 @@ "Spaces you know that contain this room": "Spaces, in denen du Mitglied bist und die diesen Raum enthalten", "You're removing all spaces. Access will default to invite only": "Du entfernst alle Spaces. Der Zugriff wird auf den Standard (Privat) zurückgesetzt", "People with supported clients will be able to join the room without having a registered account.": "Personen mit unterstützter Anwendung werden diesen Raum ohne registriertes Konto betreten können.", - "Anyone can find and join.": "Jeder kann den Raum finden und betreten.", + "Anyone can find and join.": "Sichtbar und zugänglich für jeden.", "Mute the microphone": "Stummschalten", "Unmute the microphone": "Stummschaltung deaktivieren", "Displaying time": "Zeitanzeige", @@ -2678,7 +2678,7 @@ "Send a sticker": "Sticker senden", "Are you sure you want to make this encrypted room public?": "Willst du diesen verschlüsselten Raum wirklich öffentlich machen?", "Unknown failure": "Unbekannter Fehler", - "Failed to update the join rules": "Fehler beim updaten der Beitrittsregeln", + "Failed to update the join rules": "Fehler beim Aktualisieren der Beitrittsregeln", "To avoid these issues, create a new encrypted room for the conversation you plan to have.": "Um dieses Problem zu vermeiden, erstelle einen neuen verschlüsselten Raum für deine Konversation.", "It's not recommended to add encryption to public rooms.Anyone can find and join public rooms, so anyone can read messages in them. You'll get none of the benefits of encryption, and you won't be able to turn it off later. Encrypting messages in a public room will make receiving and sending messages slower.": "Verschlüsselung ist für öffentliche Räume nicht empfohlen. Alle können öffentliche Räume finden und betreten und so auch Nachrichten lesen und Senden und Empfangen wird langsamer. Du hast daher von der Verschlüsselung keinen Vorteil und kannst sie später nicht mehr ausschalten.", "Are you sure you want to add encryption to this public room?": "Dieser Raum ist öffentlich. Willst du die Verschlüsselung wirklich aktivieren?", @@ -2697,7 +2697,7 @@ "%(senderName)s unpinned a message from this room. See all pinned messages.": "%(senderName)s hat eine Nachricht losgeheftet. Alle angehefteten Nachrichten anzeigen.", "%(senderName)s pinned a message to this room. See all pinned messages.": "%(senderName)s hat eine Nachricht angeheftet. Alle angehefteten Nachrichten anzeigen.", "%(senderName)s pinned a message to this room. See all pinned messages.": "%(senderName)s hat eine Nachricht angeheftet. Alle angehefteten Nachrichten anzeigen.", - "Joining space …": "Space betreten …", + "Joining space …": "Betrete Space …", "To join a space you'll need an invite.": "Um einen Space zu betreten, brauchst du eine Einladung.", "You are about to leave .": "Du bist dabei, zu verlassen.", "Leave some rooms": "Zu verlassende Räume auswählen", @@ -2721,7 +2721,7 @@ "Shows all threads from current room": "Alle Threads des Raums anzeigen", "All threads": "Alle Threads", "My threads": "Meine Threads", - "What projects are your team working on?": "An welchen Projekten arbeitet dein Team?", + "What projects are your team working on?": "Welche Projekte bearbeitet euer Team?", "Joined": "Beigetreten", "See room timeline (devtools)": "Nachrichtenverlauf anzeigen (Entwicklungswerkzeuge)", "View in room": "Im Raum anzeigen", @@ -2732,7 +2732,7 @@ "Format": "Format", "Export Chat": "Unterhaltung exportieren", "Exporting your data": "Deine Daten werden exportiert", - "Stop": "Stopp", + "Stop": "Beenden", "Are you sure you want to stop exporting your data? If you do, you'll need to start over.": "Willst du das Exportieren deiner Daten wirklich abbrechen? Falls ja, musst du komplett von neu beginnen.", "Your export was successful. Find it in your Downloads folder.": "Export erfolgreich. Du kannst in bei deinen Downloads finden.", "The export was cancelled successfully": "Exportieren abgebrochen", @@ -2744,7 +2744,7 @@ "Enter a number between %(min)s and %(max)s": "Gib eine Zahl zwischen %(min)s und %(max)s ein", "In reply to this message": "Antwort auf diese Nachricht", "Downloading": "Herunterladen", - "No answer": "Nicht beantwortet", + "No answer": "Keine Antwort", "Unban from %(roomName)s": "Von %(roomName)s entbannen", "Disinvite from %(roomName)s": "Einladung für %(roomName)s zurückziehen", "Export chat": "Unterhaltung exportieren", @@ -2777,7 +2777,7 @@ "%(date)s at %(time)s": "%(date)s um %(time)s", "Store your Security Key somewhere safe, like a password manager or a safe, as it's used to safeguard your encrypted data.": "Bewahre deinen Sicherheitsschlüssel sicher auf, etwa in einem Passwortmanager oder einem Safe, da er verwendet wird, um deine Daten zu sichern.", "Enter a security phrase only you know, as it's used to safeguard your data. To be secure, you shouldn't re-use your account password.": "Gib eine Sicherheitsphrase ein, die nur du kennst. Sie wird verwendet, um deine Daten zu sichern. Zu deiner Sicherheit solltest du dein Kontopasswort nicht wiederverwenden.", - "We'll generate a Security Key for you to store somewhere safe, like a password manager or a safe.": "Wir generieren einen Sicherheitsschlüssel für dich, den du sicher aufbewahren solltest, etwa in einem Passwortmanager oder einem Safe.", + "We'll generate a Security Key for you to store somewhere safe, like a password manager or a safe.": "Wir generieren einen Sicherheitsschlüssel für dich, den du in einem Passwort-Manager oder Safe sicher aufbewahren solltest.", "Regain access to your account and recover encryption keys stored in this session. Without them, you won't be able to read all of your secure messages in any session.": "Zugriff auf dein Konto wiederherstellen und in dieser Sitzung gespeicherte Verschlüsselungs-Schlüssel wiederherstellen. Ohne diese wirst du nicht all deine verschlüsselten Nachrichten lesen können.", "Please only proceed if you're sure you've lost all of your other devices and your security key.": "Bitte fahre nur fort, falls du dir sicher bist, dass du alle deine anderen Geräte und deinen Sicherheitsschlüssel verloren hast.", "Resetting your verification keys cannot be undone. After resetting, you won't have access to old encrypted messages, and any friends who have previously verified you will see security warnings until you re-verify with them.": "Das Zurücksetzen deiner Sicherheitsschlüssel kann nicht rückgängig gemacht werden. Nach dem Zurücksetzen wirst du alte Nachrichten nicht mehr lesen können un Freunde, die dich vorher verifiziert haben werden Sicherheitswarnungen bekommen, bis du dich erneut mit ihnen verifizierst.", @@ -2875,7 +2875,7 @@ "Get notified only with mentions and keywords as set up in your settings": "Nur bei Erwähnungen und Schlüsselwörtern benachrichtigen, die du in den Einstellungen konfigurieren kannst", "@mentions & keywords": "@Erwähnungen und Schlüsselwörter", "Get notified for every message": "Bei jeder Nachricht benachrichtigen", - "Rooms outside of a space": "Räume ohne Zugehörigkeit zu einem Space", + "Rooms outside of a space": "Räume außerhalb von Spaces", "Manage rooms in this space": "Räume in diesem Space verwalten", "Clear": "Löschen", "%(count)s votes|one": "%(count)s Stimme", @@ -2901,7 +2901,7 @@ "%(spaceName)s and %(count)s others|other": "%(spaceName)s und %(count)s andere", "Okay": "Okay", "Share anonymous data to help us identify issues. Nothing personal. No third parties. Learn More": "Teile Daten anonymisiert um uns zu helfen Probleme zu identifizieren. Nichts persönliches. Keine Dritten. Mehr dazu hier", - "You previously consented to share anonymous usage data with us. We're updating how that works.": "Sie haben vorher zugestimmt anonymisierte Nutzungsdaten mit uns zu teilen. Wir updaten wie das funktioniert.", + "You previously consented to share anonymous usage data with us. We're updating how that works.": "Sie haben zuvor zugestimmt, anonymisierte Nutzungsdaten mit uns zu teilen. Wir aktualisieren, wie das funktioniert.", "Help improve %(analyticsOwner)s": "Hilf mit, %(analyticsOwner)s zu verbessern", "That's fine": "Das ist okay", "You cannot place calls without a connection to the server.": "Sie können keine Anrufe starten ohne Verbindung zum Server.", @@ -3035,8 +3035,8 @@ "Verify other device": "Anderes Gerät verifizieren", "Edit setting": "Einstellung bearbeiten", "You can read all our terms here": "Du kannst unsere Datenschutzbedingungen hier lesen", - "Missing room name or separator e.g. (my-room:domain.org)": "Fehlender Raumname oder Doppelpunkt (z.B. dein-raum:domain.org)", - "Missing domain separator e.g. (:domain.org)": "Fehlender Doppelpunkt vor Server (z.B. :domain.org)", + "Missing room name or separator e.g. (my-room:domain.org)": "Fehlender Raumname oder Doppelpunkt (z. B. dein-raum:domain.org)", + "Missing domain separator e.g. (:domain.org)": "Fehlender Doppelpunkt vor Server (z. B. :domain.org)", "was removed %(count)s times|other": "wurde %(count)s mal entfernt", "was removed %(count)s times|one": "wurde entfernt", "were removed %(count)s times|one": "wurden entfernt", @@ -3069,10 +3069,10 @@ "Navigate to previous message to edit": "Vorherige Nachricht bearbeiten", "Navigate to next message to edit": "Nächste Nachricht bearbeiten", "Internal room ID": "Interne Raum-ID", - "Group all your rooms that aren't part of a space in one place.": "Alle Räume, die nicht Teil eines Spaces sind, gruppieren.", - "Group all your people in one place.": "Alle Direktnachrichten gruppieren.", - "Group all your favourite rooms and people in one place.": "Gruppiere all deine Unterhaltungen an einem Ort.", - "Spaces are ways to group rooms and people. Alongside the spaces you're in, you can use some pre-built ones too.": "Mit Spaces kannst du deine Chats gruppieren. Zusätzlich kannst du dir einige vorgefertigte Spaces anzeigen lassen.", + "Group all your rooms that aren't part of a space in one place.": "Gruppiere all deine Räume, die nicht Teil eines Spaces sind, an einem Ort.", + "Group all your people in one place.": "Gruppiere all deine Direktnachrichten an einem Ort.", + "Group all your favourite rooms and people in one place.": "Chats all deine favorisierten Unterhaltungen an einem Ort.", + "Spaces are ways to group rooms and people. Alongside the spaces you're in, you can use some pre-built ones too.": "Mit Spaces kannst du deine Unterhaltungen organisieren. Neben Spaces, in denen du dich befindest, kannst du dir auch dynamische anzeigen lassen.", "IRC (Experimental)": "IRC (Experimentell)", "Call": "Anruf", "Right panel stays open (defaults to room member list)": "Rechtes Panel offen lassen (Standardmäßig Liste der Mitglieder)", @@ -3084,7 +3084,7 @@ "Toggle hidden event visibility": "Sichtbarkeit versteckter Events umschalten", "If you know what you're doing, Element is open-source, be sure to check out our GitHub (https://github.com/vector-im/element-web/) and contribute!": "Falls du weißt, was du machst: Element ist Open Source! Checke unser GitHub aus (https://github.com/vector-im/element-web/) und hilf mit!", "If someone told you to copy/paste something here, there is a high likelihood you're being scammed!": "Wenn dir jemand gesagt hat, dass du hier etwas einfügen sollst, ist die Wahrscheinlichkeit sehr groß, dass du von der Person betrogen wirst!", - "Wait!": "Halt Stopp!", + "Wait!": "Warte!", "This address does not point at this room": "Diese Adresse verweist nicht auf diesen Raum", "Pick a date to jump to": "Wähle eine Datum aus", "Jump to date": "Zu Datum springen", @@ -3094,7 +3094,7 @@ "Unable to find event at that date. (%(code)s)": "An diesem Datum gab es kein Event. (%(code)s)", "Jump to date (adds /jumptodate and jump to date headers)": "Zu Datum springen ( /jumptodate bzw. Zu Datum springen im Header)", "Location": "Standort", - "Poll": "Abstimmung", + "Poll": "Umfrage", "Voice Message": "Sprachnachricht", "Hide stickers": "Sticker ausblenden", "You do not have permissions to add spaces to this space": "Du hast keine Berechtigung, um Spaces zu diesem Space hinzuzufügen", @@ -3155,7 +3155,7 @@ "Match system": "An System anpassen", "Insert a trailing colon after user mentions at the start of a message": "Doppelpunkt nach Erwähnungen einfügen", "Reply to an ongoing thread or use “%(replyInThread)s” when hovering over a message to start a new one.": "Antworte auf einen Thread oder klicke bei einer Nachricht auf „%(replyInThread)s“, um einen Thread zu starten.", - "We'll create rooms for each of them.": "Wir werden dir dafür entsprechende Räume erstellen.", + "We'll create rooms for each of them.": "Wir werden für jedes einen Raum erstellen.", "Export Cancelled": "Exportieren abgebrochen", "%(oneUser)schanged the pinned messages for the room %(count)s times|one": "%(oneUser)s hat die angehefteten Nachrichten des Raumes bearbeitet", "%(oneUser)schanged the pinned messages for the room %(count)s times|other": "%(oneUser)s hat die angehefteten Nachrichten des Raumes %(count)s-Mal bearbeitet", @@ -3163,21 +3163,21 @@ "%(severalUsers)schanged the pinned messages for the room %(count)s times|other": "%(severalUsers)s haben die angehefteten Nachrichten des Raumes %(count)s-Mal bearbeitet", "What location type do you want to share?": "Wie willst du deinen Standort teilen?", "Drop a Pin": "Standort setzen", - "My live location": "Mein Live-Standort", + "My live location": "Mein Echtzeit-Standort", "My current location": "Mein Standort", - "%(brand)s could not send your location. Please try again later.": "%(brand)s konnte deinen Standort nicht senden. Versuche es später bitte erneut.", - "We couldn't send your location": "Wir können deinen Standort nicht senden", - "This homeserver is not configured correctly to display maps, or the configured map server may be unreachable.": "Dein Homeserver unterstützt das Anzeigen von Karten nicht oder der Kartenanbieter ist nicht erreichbar.", + "%(brand)s could not send your location. Please try again later.": "%(brand)s konnte deinen Standort nicht senden. Bitte versuche es später erneut.", + "We couldn't send your location": "Wir konnten deinen Standort nicht senden", + "This homeserver is not configured correctly to display maps, or the configured map server may be unreachable.": "Dein Home-Server unterstützt das Anzeigen von Karten nicht oder der Kartenanbieter ist nicht erreichbar.", "Busy": "Beschäftigt", "If you've submitted a bug via GitHub, debug logs can help us track down the problem. ": "Wenn du uns einen Bug auf GitHub gemeldet hast, können uns Debug-Logs helfen, das Problem zu finden. ", "Toggle Link": "Linkfomatierung umschalten", "Toggle Code Block": "Quelltextblock-Formatierung umschalten", - "You are sharing your live location": "Du teilst deinen Live-Standort", - "Uncheck if you also want to remove system messages on this user (e.g. membership change, profile change…)": "Entferne das Häkchen, wenn du auch Systemnachrichten des Nutzers löschen willst (z.B. Mitglieds- und Profiländerungen)", + "You are sharing your live location": "Du teilst deinen Echtzeit-Standort", + "Uncheck if you also want to remove system messages on this user (e.g. membership change, profile change…)": "Deaktivieren, wenn du auch Systemnachrichten bzgl. des Nutzers löschen willst (z. B. Mitglieds- und Profiländerungen …)", "Preserve system messages": "Systemnachrichten behalten", "You are about to remove %(count)s messages by %(user)s. This will remove them permanently for everyone in the conversation. Do you wish to continue?|one": "Du bist gerade dabei, %(count)s Nachricht von %(user)s Benutzern zu löschen. Die Nachrichten werden für niemanden mehr sichtbar sein. Willst du fortfahren?", "You are about to remove %(count)s messages by %(user)s. This will remove them permanently for everyone in the conversation. Do you wish to continue?|other": "Du bist gerade dabei, %(count)s Nachrichten von %(user)s Benutzern zu löschen. Die Nachrichten werden für niemanden mehr sichtbar sein. Willst du fortfahren?", - "%(displayName)s's live location": "Aktueller Standort von %(displayName)s", + "%(displayName)s's live location": "Echtzeit-Standort von %(displayName)s", "Currently removing messages in %(count)s rooms|one": "Entferne Nachrichten in %(count)s Raum", "Currently removing messages in %(count)s rooms|other": "Entferne Nachrichten in %(count)s Räumen", "Stop sharing": "Nicht mehr teilen", @@ -3189,7 +3189,7 @@ "%(value)sd": "%(value)sd", "Start messages with /plain to send without markdown and /md to send with.": "Beginne Nachrichten mit /plain, um Nachrichten ohne Markdown zu schreiben und mit /md, um sie mit Markdown zu schreiben.", "Enable Markdown": "Markdown aktivieren", - "Live Location Sharing (temporary implementation: locations persist in room history)": "Standort Live teilen (Temporäre Implementation; Die Standorte bleiben in Raumverlauf bestehen)", + "Live Location Sharing (temporary implementation: locations persist in room history)": "Echtzeit-Standortfreigabe (Temporäre Implementation: Die Standorte bleiben in Raumverlauf bestehen)", "Location sharing - pin drop": "Standort teilen - Position auswählen", "Right-click message context menu": "Rechtsklick-Kontextmenü", "To leave, return to this page and use the “%(leaveTheBeta)s” button.": "Zum Verlassen, gehe auf diese Seite zurück und klicke auf „%(leaveTheBeta)s“.", @@ -3215,10 +3215,10 @@ "Failed to invite users to %(roomName)s": "Fehler beim Einladen von Benutzern in %(roomName)s", "You're trying to access a community link (%(groupId)s).
Communities are no longer supported and have been replaced by spaces.Learn more about spaces here.": "Du versuchst, einer Community beizutreten (%(groupId)s).
Diese wurden jedoch durch Spaces ersetzt.Mehr Infos über Spaces gibt es hier.", "That link is no longer supported": "Dieser Link wird leider nicht mehr unterstützt", - "Live location sharing": "Live Standort teilen", + "Live location sharing": "Echtzeit-Standortfreigabe", "Beta feature. Click to learn more.": "Betafunktion. Klicken, für mehr Informationen.", "Beta feature": "Betafunktion", - "View live location": "Live-Standort anzeigen", + "View live location": "Echtzeit-Standort anzeigen", "Ban from room": "Bannen", "Unban from room": "Entbannen", "Ban from space": "Bannen", @@ -3243,7 +3243,7 @@ "Forget this space": "Diesen Space vergessen", "You were removed by %(memberName)s": "Du wurdest von %(memberName)s entfernt", "Loading preview": "Lade Vorschau", - "Joining …": "Betreten …", + "Joining …": "Betrete …", "New video room": "Neuer Videoraum", "New room": "Neuer Raum", "Seen by %(count)s people|one": "Von %(count)s Person gesehen", @@ -3293,13 +3293,13 @@ "Client Versions": "Anwendungsversionen", "Send custom state event": "Benutzerdefiniertes Status-Event senden", "Failed to send event!": "Event konnte nicht gesendet werden!", - "Server info": "Serverinfo", + "Server info": "Server-Info", "Explore account data": "Kontodaten erkunden", "View servers in room": "Zeige Server im Raum", "Explore room state": "Raumstatus erkunden", "Hide my messages from new joiners": "Meine Nachrichten vor neuen Teilnehmern verstecken", "Your old messages will still be visible to people who received them, just like emails you sent in the past. Would you like to hide your sent messages from people who join rooms in the future?": "Deine alten Nachrichten werden weiterhin für Personen sichtbar bleiben, die sie erhalten haben, so wie es bei E-Mails der Fall ist. Möchtest du deine Nachrichten vor Personen verbergen, die Räume in der Zukunft betreten?", - "You will be removed from the identity server: your friends will no longer be able to find you with your email or phone number": "Du wirst vom Identitätsserver entfernt: Deine Freunde werden nicht mehr in der Lage sein dich über deine E-Mail-Adresse oder Telefonnummer zu finden", + "You will be removed from the identity server: your friends will no longer be able to find you with your email or phone number": "Du wirst vom Identitäts-Server entfernt: Deine Freunde werden nicht mehr in der Lage sein, dich über deine E-Mail-Adresse oder Telefonnummer zu finden", "You will leave all rooms and DMs that you are in": "Du wirst alle Unterhaltungen verlassen, in denen du dich befindest", "No one will be able to reuse your username (MXID), including you: this username will remain unavailable": "Niemand wird in der Lage sein deinen Benutzernamen (MXID) wiederzuverwenden, dich eingeschlossen: Der Benutzername wird nicht verfügbar bleiben", "You will no longer be able to log in": "Du wirst dich nicht mehr anmelden können", @@ -3311,7 +3311,7 @@ "Help us identify issues and improve %(analyticsOwner)s by sharing anonymous usage data. To understand how people use multiple devices, we'll generate a random identifier, shared by your devices.": "Hilf uns dabei Probleme zu identifizieren und %(analyticsOwner)s zu verbessern, indem du anonyme Nutzungsdaten teilst. Um zu verstehen, wie Personen mehrere Geräte verwenden, werden wir eine zufällige Kennung generieren, die zwischen deinen Geräten geteilt wird.", "You can use the custom server options to sign into other Matrix servers by specifying a different homeserver URL. This allows you to use %(brand)s with an existing Matrix account on a different homeserver.": "Du kannst in den benutzerdefinierten Server-Optionen eine andere Heim-Server-URL angeben, um dich bei anderen Matrix-Servern anzumelden. Dadurch kannst du %(brand)s mit einem auf einem anderen Heim-Server liegenden Matrix-Konto nutzen.", "%(brand)s was denied permission to fetch your location. Please allow location access in your browser settings.": "%(brand)s wurde der Zugriff auf deinen Standort verweigert. Bitte erlaube den Zugriff in den Einstellungen deines Browsers.", - "Enable live location sharing": "Standortfreigabe in Echtzeit aktivieren", + "Enable live location sharing": "Aktiviere Echtzeit-Standortfreigabe", "To view %(roomName)s, you need an invite": "Du musst eingeladen sein, um %(roomName)s zu sehen", "%(errcode)s was returned while trying to access the room or space. If you think you're seeing this message in error, please submit a bug report.": "Beim Betreten des Raums oder Spaces ist ein Fehler aufgetreten %(errcode)s. Wenn du denkst dass diese Meldung nicht korrekt ist sende bitte einen Fehlerbericht.", "Private room": "Privater Raum", @@ -3321,12 +3321,12 @@ "Previous recently visited room or space": "Vorheriger kürzlich besuchter Raum oder Space", "You have been logged out of all devices and will no longer receive push notifications. To re-enable notifications, sign in again on each device.": "Du wurdest von allen Geräten abgemeldet und erhältst keine Push-Benachrichtigungen mehr. Um Benachrichtigungen wieder zu aktivieren, melde dich auf jedem Gerät erneut an.", "Sign out all devices": "Alle Geräte abmelden", - "Resetting your password on this homeserver will cause all of your devices to be signed out. This will delete the message encryption keys stored on them, making encrypted chat history unreadable.": "Wenn du dein Passwort zurücksetzt, werden alle deine anderen Geräte abgemeldet. Wenn auf diesen Ende-zu-Ende-Schlüssel gespeichert sind, kann der Verlauf deiner verschlüsselten Unterhaltungen verloren gehen.", + "Resetting your password on this homeserver will cause all of your devices to be signed out. This will delete the message encryption keys stored on them, making encrypted chat history unreadable.": "Wenn du dein Passwort zurücksetzt, werden all deine anderen Geräte abgemeldet. Wenn auf diesen Ende-zu-Ende-Schlüssel gespeichert sind, kann der Verlauf deiner verschlüsselten Unterhaltungen verloren gehen.", "Event ID: %(eventId)s": "Event-ID: %(eventId)s", "Give feedback": "Rückmeldung geben", "Threads are a beta feature": "Threads sind eine Betafunktion", "Threads help keep your conversations on-topic and easy to track.": "Threads helfen dabei, dass deine Konversationen beim Thema und leicht nachverfolgbar bleiben.", - "Your message wasn't sent because this homeserver has been blocked by its administrator. Please contact your service administrator to continue using the service.": "Deine Nachricht wurde nicht gesendet, weil dieser Heimserver von dessen Administrator gesperrt wurde. Bitte kontaktiere deinen Dienstadministrator um den Dienst weiterzunutzen.", + "Your message wasn't sent because this homeserver has been blocked by its administrator. Please contact your service administrator to continue using the service.": "Deine Nachricht wurde nicht gesendet, weil dieser Heim-Server von dessen Administration gesperrt wurde. Bitte kontaktiere deine Dienstadministration, um den Dienst weiterzunutzen.", "Video rooms": "Videoräume", "You were disconnected from the call. (Error: %(message)s)": "Du wurdest vom Anruf getrennt. (Error: %(message)s)", "Connection lost": "Verbindung verloren", @@ -3339,7 +3339,7 @@ "%(count)s people joined|one": "%(count)s Person hat teilgenommen", "%(count)s people joined|other": "%(count)s Personen haben teilgenommen", "Enable hardware acceleration": "Aktiviere die Hardwarebeschleunigung", - "Please note: this is a labs feature using a temporary implementation. This means you will not be able to delete your location history, and advanced users will be able to see your location history even after you stop sharing your live location with this room.": "Bitte beachte: Dies ist eine experimentelle Funktion, die eine temporäre Implementierung nutzt. Das bedeutet, dass du deinen Standortverlauf nicht löschen kannst und erfahrene Nutzer ihn sehen können, selbst wenn du deinen Live-Standort nicht mehr mit diesem Raum teilst.", + "Please note: this is a labs feature using a temporary implementation. This means you will not be able to delete your location history, and advanced users will be able to see your location history even after you stop sharing your live location with this room.": "Bitte beachte: Dies ist eine experimentelle Funktion, die eine temporäre Implementierung nutzt. Das bedeutet, dass du deinen Standortverlauf nicht löschen kannst und erfahrene Nutzer ihn sehen können, selbst wenn du deinen Echtzeit-Standort nicht mehr mit diesem Raum teilst.", "Video room": "Videoraum", "Video rooms are a beta feature": "Videoräume sind eine Betafunktion", "Minimise": "Minimieren", @@ -3351,7 +3351,7 @@ "Show: Matrix rooms": "Zeige: Matrix-Räume", "Create a video room": "Videoraum erstellen", "Open room": "Raum öffnen", - "When you sign out, these keys will be deleted from this device, which means you won't be able to read encrypted messages unless you have the keys for them on your other devices, or backed them up to the server.": "Wenn du dich abmeldest werden die Schlüssel auf diesem Gerät gelöscht. Das bedeutet, dass du keine verschlüsselten Nachrichten mehr lesen kannst, außer du hast die Schlüssel auf einem anderen Gerät oder ein Backup der Schlüssel auf dem Server.", + "When you sign out, these keys will be deleted from this device, which means you won't be able to read encrypted messages unless you have the keys for them on your other devices, or backed them up to the server.": "Wenn du dich abmeldest, werden die Schlüssel auf diesem Gerät gelöscht. Das bedeutet, dass du keine verschlüsselten Nachrichten mehr lesen kannst, wenn du die Schlüssel nicht auf einem anderen Gerät oder eine Sicherung auf dem Server hast.", "Ignore user": "Nutzer ignorieren", "Show rooms": "Räume zeigen", "Search for": "Suche nach", @@ -3364,11 +3364,11 @@ "Copy invite link": "Einladungslink kopieren", "Some results may be hidden": "Einige Ergebnisse können ausgeblendet sein", "Close sidebar": "Seitenleiste schließen", - "No live locations": "Keine Live-Standorte", - "Live location error": "Live-Standort Fehler", - "Live location ended": "Live-Standort beendet", - "Loading live location...": "Lade Live-Standort …", - "Live until %(expiryTime)s": "Existiert bis %(expiryTime)s", + "No live locations": "Keine Echtzeit-Standorte", + "Live location error": "Echtzeit-Standort-Fehler", + "Live location ended": "Echtzeit-Standort beendet", + "Loading live location...": "Lade Echtzeit-Standort …", + "Live until %(expiryTime)s": "Echtzeit bis %(expiryTime)s", "Updated %(humanizedUpdateTime)s": "%(humanizedUpdateTime)s aktualisiert", "Joining the beta will reload %(brand)s.": "Die Teilnahme an der Beta wird %(brand)s neustarten.", "Leaving the beta will reload %(brand)s.": "Das Verlassen der Beta wird %(brand)s neustarten.", @@ -3381,11 +3381,11 @@ "Doesn't look like valid JSON.": "Scheint kein gültiges JSON zu sein.", "Other options": "Andere Optionen", "If you can't find the room you're looking for, ask for an invite or create a new room.": "Falls du den Raum nicht findest, frag nach einer Einladung oder erstelle einen neuen Raum.", - "An error occurred whilst sharing your live location, please try again": "Ein Fehler ist während des Teilens deines Live-Standorts aufgetreten. Bitte versuche es erneut", - "Live location enabled": "Live-Standort aktiviert", - "An error occurred whilst sharing your live location": "Ein Fehler ist während des Teilens deines Live-Standorts aufgetreten", - "An error occurred while stopping your live location": "Ein Fehler ist beim Stoppen des Live-Standorts aufgetreten", - "An error occurred while stopping your live location, please try again": "Ein Fehler ist beim Stoppen des Live-Standorts aufgetreten. Bitte versuche es erneut", + "An error occurred whilst sharing your live location, please try again": "Ein Fehler ist während des Teilens deines Echtzeit-Standorts aufgetreten, bitte versuche es erneut", + "Live location enabled": "Echtzeit-Standort aktiviert", + "An error occurred whilst sharing your live location": "Ein Fehler ist während des Teilens deines Echtzeit-Standorts aufgetreten", + "An error occurred while stopping your live location": "Ein Fehler ist während des Beendens deines Echtzeit-Standorts aufgetreten", + "An error occurred while stopping your live location, please try again": "Ein Fehler ist während des Beendens deines Echtzeit-Standorts aufgetreten, bitte versuche es erneut", "Check your email to continue": "Zum Fortfahren prüfe deine E-Mails", "Failed to set direct message tag": "Fehler beim Setzen der Nachrichtenmarkierung", "Resent!": "Verschickt!", @@ -3398,12 +3398,12 @@ "iOS": "iOS", "Android": "Android", "You can't disable this later. The room will be encrypted but the embedded call will not.": "Dies kann später nicht deaktiviert werden. Der Raum wird verschlüsselt sein, nicht aber der eingebettete Anruf.", - "You need to have the right permissions in order to share locations in this room.": "Du brauchst du richtigen Berechtigungen, um deinen Live-Standort in diesem Raum zu teilen.", + "You need to have the right permissions in order to share locations in this room.": "Du benötigst die entsprechenden Berechtigungen, um deinen Echtzeit-Standort in diesem Raum freizugeben.", "Who will you chat to the most?": "Mit wem wirst du am meisten schreiben?", "We're creating a room with %(names)s": "Wir erstellen einen Raum mit %(names)s", "Messages in this chat will be end-to-end encrypted.": "Nachrichten in dieser Unterhaltung werden Ende-zu-Ende-verschlüsselt.", "Send your first message to invite to chat": "Schreibe deine erste Nachricht, um zur Unterhaltung einzuladen", - "Your server doesn't support disabling sending read receipts.": "Dein Server unterstützt das deaktivieren von Lesebestätigungen nicht.", + "Your server doesn't support disabling sending read receipts.": "Dein Server unterstützt das Deaktivieren von Lesebestätigungen nicht.", "Send read receipts": "Sende Lesebestätigungen", "Share your activity and status with others.": "Teile anderen deine Aktivität und deinen Status mit.", "Presence": "Anwesenheit", @@ -3413,12 +3413,12 @@ "Developer command: Discards the current outbound group session and sets up new Olm sessions": "Entwicklungsbefehl: Verwirft die aktuell ausgehende Gruppensitzung und setzt eine neue Olm-Sitzung auf", "Toggle attribution": "Info ein-/ausblenden", "In spaces %(space1Name)s and %(space2Name)s.": "In den Spaces %(space1Name)s und %(space2Name)s.", - "Joining…": "Betreten …", + "Joining…": "Betrete …", "Show Labs settings": "Zeige die \"Labor\" Einstellungen", "To view, please enable video rooms in Labs first": "Zum Anzeigen, aktiviere bitte Videoräume in den Laboreinstellungen", "Use the “+” button in the room section of the left panel.": "Verwende die „+“-Schaltfläche des Räumebereichs der linken Seitenleiste.", "View all": "Alles anzeigen", - "Improve your account security by following these recommendations": "Verstärke die Sicherheit deines Benutzerkontos mit folgenden Empfehlungen", + "Improve your account security by following these recommendations": "Verbessere deine Kontosicherheit, indem du diese Empfehlungen beherzigst", "Security recommendations": "Sicherheitsempfehlungen", "Filter devices": "Geräte filtern", "Inactive for %(inactiveAgeDays)s days or longer": "Seit %(inactiveAgeDays)s oder mehr Tagen inaktiv", @@ -3429,11 +3429,11 @@ "No sessions found.": "Keine Sitzungen gefunden.", "No inactive sessions found.": "Keine inaktiven Sitzungen gefunden.", "No unverified sessions found.": "Keine unverifizierten Sitzungen gefunden.", - "No verified sessions found.": "Keine verifizierte Sitzung gefunden.", - "Consider signing out from old sessions (%(inactiveAgeDays)s days or older) you don't use anymore": "Erwäge, dich aus alten Sitzungen (%(inactiveAgeDays)s oder mehr Tage) abzumelden, die du nicht mehr benutzt", + "No verified sessions found.": "Keine verifizierten Sitzungen gefunden.", + "Consider signing out from old sessions (%(inactiveAgeDays)s days or older) you don't use anymore": "Erwäge, dich aus alten (%(inactiveAgeDays)s Tage oder mehr), nicht mehr verwendeten Sitzungen abzumelden", "Inactive sessions": "Inaktive Sitzungen", "Unverified sessions": "Nicht verifizierte Sitzungen", - "For best security, sign out from any session that you don't recognize or use anymore.": "Für die bestmögliche Sicherheit, melde dich von allen Sitzungen ab, die du nicht erkennst oder nicht mehr benutzt.", + "For best security, sign out from any session that you don't recognize or use anymore.": "Für bestmögliche Sicherheit, melde dich von allen Sitzungen ab, die du nicht erkennst oder benutzt.", "Verified sessions": "Verifizierte Sitzungen", "Unverified session": "Nicht verifizierte Sitzung", "This session is ready for secure messaging.": "Diese Sitzung ist für sichere Kommunikation bereit.", @@ -3445,7 +3445,7 @@ "Session details": "Sitzungsdetails", "IP address": "IP-Adresse", "Device": "Gerät", - "Last activity": "Neuste Aktivität", + "Last activity": "Neueste Aktivität", "Current session": "Aktuelle Sitzung", "For best security, verify your sessions and sign out from any session that you don't recognize or use anymore.": "Für bestmögliche Sicherheit verifiziere deine Sitzungen und melde dich von allen ab, die du nicht erkennst oder nutzt.", "Other sessions": "Andere Sitzungen", @@ -3485,7 +3485,7 @@ "Map feedback": "Rückmeldung zur Karte", "Online community members": "Online Community-Mitglieder", "Help": "Hilfe", - "You don't have permission to share locations": "Du hast keine Berechtigung Live-Standorte zu teilen", + "You don't have permission to share locations": "Dir fehlt die Berechtigung, Echtzeit-Standorte freigeben zu dürfen", "Un-maximise": "Maximieren rückgängig machen", "Create video room": "Videoraum erstellen", "Google Play and the Google Play logo are trademarks of Google LLC.": "Google Play und das Google Play Logo sind eingetragene Markenzeichen von Google LLC.", @@ -3525,14 +3525,14 @@ "Saved Items": "Gespeicherte Elemente", "Read receipts": "Lesebestätigungen", "Verify your sessions for enhanced secure messaging or sign out from those you don't recognize or use anymore.": "Für besonders sichere Kommunikation verifiziere deine Sitzungen oder melde dich von ihnen ab, falls du sie nicht mehr identifizieren kannst.", - "Verify or sign out from this session for best security and reliability.": "Für bestmögliche Sicherheit und Zuverlässigkeit verifiziere diese Sitzung oder melden sie ab.", + "Verify or sign out from this session for best security and reliability.": "Für bestmögliche Sicherheit und Zuverlässigkeit verifiziere diese Sitzung oder melde sie ab.", "Keep ownership and control of community discussion.\nScale to support millions, with powerful moderation and interoperability.": "Verfüge und behalte die Kontrolle über Gespräche deiner Gemeinschaft.\nSkalierbar für Millionen von Nutzenden, mit mächtigen Moderationswerkzeugen und Interoperabilität.", "Community ownership": "In gemeinschaftlicher Hand", "Join the room to participate": "Betrete den Raum, um teilzunehmen", "Show shortcut to welcome checklist above the room list": "Verknüpfung zu ersten Schritten (Willkommen) anzeigen", "Find people": "Finde Personen", "Find your people": "Finde deine Leute", - "It’s what you’re here for, so lets get to it": "Deshalb bist du hier, also lass uns beginnen", + "It’s what you’re here for, so lets get to it": "Dafür bist du hier, also dann mal los", "It's not recommended to add encryption to public rooms. Anyone can find and join public rooms, so anyone can read messages in them. You'll get none of the benefits of encryption, and you won't be able to turn it off later. Encrypting messages in a public room will make receiving and sending messages slower.": "Verschlüsselung ist für öffentliche Räume nicht empfohlen. Jeder kann öffentliche Räume finden und betreten, also kann auch jeder die Nachrichten lesen. Du wirst keine der Vorteile von Verschlüsselung erhalten und kannst sie später auch nicht mehr deaktivieren. Nachrichten in öffentlichen Räumen zu verschlüsseln, wird das empfangen und senden verlangsamen.", "Empty room (was %(oldName)s)": "Leerer Raum (war %(oldName)s)", "Inviting %(user)s and %(count)s others|other": "Lade %(user)s und %(count)s weitere Person ein", @@ -3573,7 +3573,7 @@ "Video call (Element Call)": "Videoanruf (Element Call)", "Video call (Jitsi)": "Videoanruf (Jitsi)", "New group call experience": "Neue Gruppenanruf-Erfahrung", - "Live": "Live-Übertragung", + "Live": "Live", "Receive push notifications on this session.": "Erhalte Push-Benachrichtigungen in dieser Sitzung.", "Push notifications": "Push-Benachrichtigungen", "Toggle push notifications on this session.": "(De)Aktiviere Push-Benachrichtigungen in dieser Sitzung.", @@ -3584,5 +3584,51 @@ "%(selectedDeviceCount)s sessions selected": "%(selectedDeviceCount)s Sitzungen ausgewählt", "Video call ended": "Videoanruf beendet", "%(name)s started a video call": "%(name)s hat einen Videoanruf begonnen", - "Record the client name, version, and url to recognise sessions more easily in session manager": "Anwendungsbezeichnung, -version und -adresse registrieren, damit diese Sitzung in der Sitzungsverwaltung besser erkennbar ist" + "Record the client name, version, and url to recognise sessions more easily in session manager": "Bezeichnung, Version und URL der Anwendung registrieren, damit diese Sitzung in der Sitzungsverwaltung besser erkennbar ist", + "Application": "Anwendung", + "URL": "URL", + "Version": "Version", + "Mobile session": "Mobil-Sitzung", + "Desktop session": "Desktop-Sitzung", + "Web session": "Web-Sitzung", + "Unknown session type": "Unbekannter Sitzungstyp", + "Video call started": "Videoanruf hat begonnen", + "Unknown room": "Unbekannter Raum", + "Video call started in %(roomName)s. (not supported by this browser)": "Ein Videoanruf hat in %(roomName)s begonnen. (Von diesem Browser nicht unterstützt)", + "Video call started in %(roomName)s.": "Ein Videoanruf hat in %(roomName)s begonnen.", + "Fill screen": "Bildschirm füllen", + "Freedom": "Freiraum", + "Spotlight": "Rampenlicht", + "Room info": "Raum-Info", + "View chat timeline": "Nachrichtenverlauf anzeigen", + "Close call": "Anruf schließen", + "Layout type": "Anordnungsart", + "Client": "Anwendung", + "Model": "Modell", + "Operating system": "Betriebssystem", + "Call type": "Anrufart", + "You do not have sufficient permissions to change this.": "Du hast nicht die erforderlichen Berechtigungen, um dies zu ändern.", + "Start %(brand)s calls": "Beginne %(brand)s-Anrufe", + "Video call (%(brand)s)": "Videoanruf (%(brand)s)", + "%(brand)s is end-to-end encrypted, but is currently limited to smaller numbers of users.": "%(brand)s ist Ende-zu-Ende-verschlüsselt, allerdings noch auf eine geringere Anzahl Benutzer beschränkt.", + "Enable %(brand)s as an additional calling option in this room": "Verwende %(brand)s als alternative Anrufoption in diesem Raum", + "Join %(brand)s calls": "Trete %(brand)s-Anrufen bei", + "Sorry — this call is currently full": "Entschuldigung — dieser Anruf ist aktuell besetzt", + "Wysiwyg composer (plain text mode coming soon) (under active development)": "WYSIWYG-Eingabe (demnächst mit Klartextmodus) (in aktiver Entwicklung)", + "Our new sessions manager provides better visibility of all your sessions, and greater control over them including the ability to remotely toggle push notifications.": "Unsere neue Sitzungsverwaltung bietet bessere Übersicht und Kontrolle über all deine Sitzungen, inklusive der Möglichkeit, aus der Ferne Push-Benachrichtigungen umzuschalten.", + "Have greater visibility and control over all your sessions.": "Bessere Übersicht und Kontrolle über all deine Sitzungen.", + "New session manager": "Neue Sitzungsverwaltung", + "Use new session manager": "Neue Sitzungsverwaltung nutzen", + "Sign out all other sessions": "Alle anderen Sitzungen abmelden", + "pause voice broadcast": "Sprachübertragung pausieren", + "resume voice broadcast": "Sprachübertragung fortsetzen", + "Italic": "Kursiv", + "Underline": "Unterstrichen", + "Try out the rich text editor (plain text mode coming soon)": "Probiere den Rich-Text-Editor aus (bald auch mit Plain-Text-Modus)", + "You have already joined this call from another device": "Du nimmst an diesem Anruf bereits mit einem anderen Gerät teil", + "stop voice broadcast": "Sprachübertragung beenden", + "Notifications silenced": "Benachrichtigungen stummgeschaltet", + "Are you sure you want to stop your live broadcast?This will end the broadcast and the full recording will be available in the room.": "Willst du die Sprachübertragung wirklich beenden? Damit endet auch die Aufnahme.", + "Yes, stop broadcast": "Ja, Sprachübertragung beenden", + "Stop live broadcasting?": "Sprachübertragung beenden?" } diff --git a/src/i18n/strings/es.json b/src/i18n/strings/es.json index 613e60e5ef..fa355fc59a 100644 --- a/src/i18n/strings/es.json +++ b/src/i18n/strings/es.json @@ -3549,5 +3549,83 @@ "Empty room (was %(oldName)s)": "Sala vacía (antes era %(oldName)s)", "%(user)s and %(count)s others|one": "%(user)s y 1 más", "%(user)s and %(count)s others|other": "%(user)s y %(count)s más", - "%(user1)s and %(user2)s": "%(user1)s y %(user2)s" + "%(user1)s and %(user2)s": "%(user1)s y %(user2)s", + "Spotlight": "Spotlight", + "Your server lacks native support, you must specify a proxy": "Tu servidor no es compatible, debes configurar un intermediario (proxy)", + "View chat timeline": "Ver historial del chat", + "You do not have permission to start voice calls": "No tienes permiso para iniciar llamadas de voz", + "Failed to set pusher state": "Fallo al establecer el estado push", + "Sign out of this session": "Cerrar esta sesión", + "Receive push notifications on this session.": "Recibir notificaciones push en esta sesión.", + "Please be aware that session names are also visible to people you communicate with": "Ten en cuenta que cualquiera con quien te comuniques puede ver los nombres de las sesiones", + "Sign out all other sessions": "Cerrar el resto de sesiones", + "You do not have sufficient permissions to change this.": "No tienes suficientes permisos para cambiar esto.", + "%(brand)s is end-to-end encrypted, but is currently limited to smaller numbers of users.": "%(brand)s está cifrado de extremo a extremo, pero actualmente está limitado a unos pocos participantes.", + "Enable %(brand)s as an additional calling option in this room": "Activar %(brand)s como una opción para las llamadas de esta sala", + "Enable notifications for this device": "Activar notificaciones en este dispositivo", + "Turn off to disable notifications on all your devices and sessions": "Desactiva para no recibir notificaciones en todos tus dispositivos y sesiones", + "You need to be able to kick users to do that.": "Debes poder sacar usuarios para hacer eso.", + "Video call started in %(roomName)s. (not supported by this browser)": "Videollamada empezada en %(roomName)s. (no compatible con este navegador)", + "Video call started in %(roomName)s.": "Videollamada empezada en %(roomName)s.", + "Layout type": "Tipo de disposición", + "%(downloadButton)s or %(copyButton)s": "%(downloadButton)s o %(copyButton)s", + "%(securityKey)s or %(recoveryFile)s": "%(securityKey)s o %(recoveryFile)s", + "Proxy URL": "URL de servidor proxy", + "Proxy URL (optional)": "URL de servidor proxy (opcional)", + "To disable you will need to log out and back in, use with caution!": "Para desactivarlo, tendrás que cerrar sesión y volverla a iniciar. ¡Ten cuidado!", + "Sliding Sync configuration": "Configuración de la sincronización progresiva", + "Your server lacks native support": "Tu servidor no es compatible", + "Your server has native support": "Tu servidor es compatible", + "Checking...": "Comprobando…", + "%(qrCode)s or %(appLinks)s": "%(qrCode)s o %(appLinks)s", + "Video call ended": "Videollamada terminada", + "%(name)s started a video call": "%(name)s comenzó una videollamada", + "%(qrCode)s or %(emojiCompare)s": "%(qrCode)s o %(emojiCompare)s", + "Room info": "Info. de la sala", + "Underline": "Subrayado", + "Italic": "Cursiva", + "Close call": "Terminar llamada", + "Freedom": "Libertad", + "There's no one here to call": "No hay nadie a quien llamar aquí", + "You do not have permission to start video calls": "No tienes permiso para empezar videollamadas", + "Ongoing call": "Llamada en curso", + "Video call (%(brand)s)": "Videollamada (%(brand)s)", + "Video call (Jitsi)": "Videollamada (Jitsi)", + "%(selectedDeviceCount)s sessions selected": "%(selectedDeviceCount)s sesiones seleccionadas", + "Unknown session type": "Sesión de tipo desconocido", + "Web session": "Sesión web", + "Mobile session": "Sesión móvil", + "Desktop session": "Sesión de escritorio", + "Toggle push notifications on this session.": "Activar/desactivar notificaciones push en esta sesión.", + "Push notifications": "Notificaciones push", + "Operating system": "Sistema operativo", + "Model": "Modelo", + "URL": "URL", + "Version": "Versión", + "Application": "Aplicación", + "Client": "Cliente", + "Rename session": "Renombrar sesión", + "Call type": "Tipo de llamada", + "Join %(brand)s calls": "Unirte a llamadas de %(brand)s", + "Start %(brand)s calls": "Empezar llamadas de %(brand)s", + "Voice broadcasts": "Retransmisiones de voz", + "Enable notifications for this account": "Activar notificaciones para esta cuenta", + "Fill screen": "Llenar la pantalla", + "You have already joined this call from another device": "Ya te has unido a la llamada desde otro dispositivo", + "Sorry — this call is currently full": "Lo sentimos — la llamada está llena", + "Use new session manager": "Usar el nuevo gestor de sesiones", + "New session manager": "Nuevo gestor de sesiones", + "Voice broadcast (under active development)": "Retransmisión de voz (en desarrollo)", + "New group call experience": "Nueva experiencia de llamadas grupales", + "Element Call video rooms": "Salas de vídeo Element Call", + "Sliding Sync mode (under active development, cannot be disabled)": "Modo de sincronización progresiva (en pleno desarrollo, no puede desactivarse)", + "Try out the rich text editor (plain text mode coming soon)": "Prueba el nuevo editor de texto con formato (un modo sin formato estará disponible próximamente)", + "Notifications silenced": "Notificaciones silenciadas", + "Video call started": "Videollamada iniciada", + "Unknown room": "Sala desconocida", + "Voice broadcast": "Retransmisión de voz", + "stop voice broadcast": "parar retransmisión de voz", + "resume voice broadcast": "reanudar retransmisión de voz", + "pause voice broadcast": "pausar retransmisión de voz", + "Live": "En directo" } diff --git a/src/i18n/strings/et.json b/src/i18n/strings/et.json index b2a004cb4c..f6a6e49b25 100644 --- a/src/i18n/strings/et.json +++ b/src/i18n/strings/et.json @@ -3587,5 +3587,49 @@ "Push notifications": "Tõuketeavitused", "Toggle push notifications on this session.": "Lülita tõuketeavitused selles sessioonis sisse/välja.", "Enable notifications for this device": "Võta teavitused selles seadmes kasutusele", - "Turn off to disable notifications on all your devices and sessions": "Välja lülitades keelad teavitused kõikides oma seadmetes ja sessioonides" + "Turn off to disable notifications on all your devices and sessions": "Välja lülitades keelad teavitused kõikides oma seadmetes ja sessioonides", + "Room info": "Jututoa teave", + "View chat timeline": "Vaata vestluse ajajoont", + "Close call": "Lõpeta kõne", + "Layout type": "Kujunduse tüüp", + "Spotlight": "Rambivalgus", + "Freedom": "Vabadus", + "Unknown session type": "Tundmatu sessioonitüüp", + "Web session": "Veebirakendus", + "Mobile session": "Nutirakendus", + "Desktop session": "Töölauarakendus", + "URL": "URL", + "Version": "Versioon", + "Application": "Rakendus", + "Fill screen": "Täida ekraan", + "Record the client name, version, and url to recognise sessions more easily in session manager": "Sessioonide paremaks tuvastamiseks saad nüüd sessioonihalduris salvestada klientrakenduse nime, versiooni ja aadressi", + "Video call started": "Videokõne algas", + "Unknown room": "Teadmata jututuba", + "Live": "Otseeeter", + "Video call started in %(roomName)s. (not supported by this browser)": "Videokõne algas %(roomName)s jututoas. (ei ole selles brauseris toetatud)", + "Video call started in %(roomName)s.": "Videokõne algas %(roomName)s jututoas.", + "Video call (%(brand)s)": "Videokõne (%(brand)s)", + "Operating system": "Operatsioonisüsteem", + "Model": "Mudel", + "Client": "Klient", + "Call type": "Kõne tüüp", + "You do not have sufficient permissions to change this.": "Sul pole piisavalt õigusi selle muutmiseks.", + "%(brand)s is end-to-end encrypted, but is currently limited to smaller numbers of users.": "%(brand)s kasutab läbivat krüptimist, kuid on hetkel piiratud väikese osalejate arvuga ühes kõnes.", + "Enable %(brand)s as an additional calling option in this room": "Võta kasutusele %(brand)s kui lisavõimalus kõnedeks selles jututoas", + "Join %(brand)s calls": "Liitu %(brand)s kõnedega", + "Start %(brand)s calls": "Alusta helistamist %(brand)s abil", + "Sorry — this call is currently full": "Vabandust, selles kõnes ei saa rohkem osalejaid olla", + "stop voice broadcast": "lõpeta ringhäälingukõne", + "resume voice broadcast": "jätka ringhäälingukõnet", + "pause voice broadcast": "peata ringhäälingukõne", + "Underline": "Allajoonitud tekst", + "Italic": "Kaldkiri", + "Sign out all other sessions": "Logi välja kõikidest oma muudest sessioonidest", + "You have already joined this call from another device": "Sa oled selle kõnega juba ühest teisest seadmest liitunud", + "Our new sessions manager provides better visibility of all your sessions, and greater control over them including the ability to remotely toggle push notifications.": "Uues sessioonihalduris saad parema ülevaate kõikidest oma sessioonidest ning rohkem võimalusi neid hallata, sealhulgas tõuketeavituste sisse- ja väljalülitamine.", + "Have greater visibility and control over all your sessions.": "Sellega saad parema ülevaate oma sessioonidest ja võimaluse neid mugavasti hallata.", + "New session manager": "Uus sessioonihaldur", + "Use new session manager": "Kasuta uut sessioonihaldurit", + "Try out the rich text editor (plain text mode coming soon)": "Proovi vormindatud teksti alusel töötavat tekstitoimetit (varsti lisandub ka vormindamata teksti režiim)", + "Notifications silenced": "Teavitused on summutatud" } diff --git a/src/i18n/strings/fa.json b/src/i18n/strings/fa.json index ac9b4a9f8c..c6ebc564a5 100644 --- a/src/i18n/strings/fa.json +++ b/src/i18n/strings/fa.json @@ -2502,5 +2502,12 @@ "Jump to the given date in the timeline": "پرش به تاریخ تعیین شده در جدول زمانی", "Failed to invite users to %(roomName)s": "افزودن کاربران به %(roomName)s با شکست روبرو شد", "You're trying to access a community link (%(groupId)s).
Communities are no longer supported and have been replaced by spaces.Learn more about spaces here.": "شما قصد دسترسی به لینک انجمن%(groupId)s را دارید.
انجمن ها دیگر پشتیبانی نمی شوند و با فضاها جایگزین شده اند. در مورد فضا بیشتر بدانید", - "That link is no longer supported": "لینک موردنظر دیگر پشتیبانی نمی شود" + "That link is no longer supported": "لینک موردنظر دیگر پشتیبانی نمی شود", + "Inviting %(user)s and %(count)s others|other": "دعوت کردن %(user)s و %(count)s دیگر", + "Video call started in %(roomName)s. (not supported by this browser)": "تماس ویدئویی در %(roomName)s شروع شد. (توسط این مرورگر پشتیبانی نمی‌شود.)", + "Video call started in %(roomName)s.": "تماس ویدئویی در %(roomName)s شروع شد.", + "No virtual room for this room": "اتاق مجازی برای این اتاق وجود ندارد", + "Switches to this room's virtual room, if it has one": "جابجایی به اتاق مجازی این اتاق، اگر یکی وجود داشت", + "You need to be able to kick users to do that.": "برای انجام این کار نیاز دارید که بتوانید کاربران را حذف کنید.", + "Empty room (was %(oldName)s)": "اتاق خالی (نام قبلی: %(oldName)s)" } diff --git a/src/i18n/strings/fr.json b/src/i18n/strings/fr.json index 07c651812c..f1751965e7 100644 --- a/src/i18n/strings/fr.json +++ b/src/i18n/strings/fr.json @@ -3589,5 +3589,47 @@ "Enable notifications for this account": "Activer les notifications pour ce compte", "Video call ended": "Appel vidéo terminé", "%(name)s started a video call": "%(name)s a démarré un appel vidéo", - "Record the client name, version, and url to recognise sessions more easily in session manager": "Enregistrez le nom, la version et l'URL du client afin de reconnaitre les sessions plus facilement dans le gestionnaire de sessions" + "Record the client name, version, and url to recognise sessions more easily in session manager": "Enregistrez le nom, la version et l'URL du client afin de reconnaitre les sessions plus facilement dans le gestionnaire de sessions", + "Version": "Version", + "Application": "Application", + "URL": "URL", + "Unknown session type": "Type de session inconnu", + "Web session": "session internet", + "Mobile session": "Session de téléphone portable", + "Desktop session": "Session de bureau", + "Video call started": "Appel vidéo commencé", + "Unknown room": "Salon inconnu", + "Video call started in %(roomName)s. (not supported by this browser)": "Appel vidéo commencé dans %(roomName)s. (non supporté par ce navigateur)", + "Video call started in %(roomName)s.": "Appel vidéo commencé dans %(roomName)s.", + "Close call": "Terminer l’appel", + "Layout type": "Type de mise en page", + "Spotlight": "Projecteur", + "Freedom": "Liberté", + "Fill screen": "Remplir l’écran", + "Room info": "Information du salon", + "View chat timeline": "Afficher la chronologie du chat", + "Video call (%(brand)s)": "Appel vidéo (%(brand)s)", + "Operating system": "Système d’exploitation", + "Model": "Modèle", + "Client": "Client", + "Call type": "Type d’appel", + "You do not have sufficient permissions to change this.": "Vous n’avez pas assez de permissions pour changer ceci.", + "%(brand)s is end-to-end encrypted, but is currently limited to smaller numbers of users.": "%(brand)s est chiffré de bout en bout, mais n’est actuellement utilisable qu’avec un petit nombre d’utilisateurs.", + "Enable %(brand)s as an additional calling option in this room": "Activer %(brand)s comme une option supplémentaire d’appel dans ce salon", + "Join %(brand)s calls": "Rejoindre des appels %(brand)s", + "Start %(brand)s calls": "Démarrer des appels %(brand)s", + "Sorry — this call is currently full": "Désolé — Cet appel est actuellement complet", + "Our new sessions manager provides better visibility of all your sessions, and greater control over them including the ability to remotely toggle push notifications.": "Notre nouveau gestionnaire de sessions fournit une meilleure visibilité sur toutes vos sessions, et un plus grand contrôle sur ces dernières avec la possibilité de désactiver à distance les notifications push.", + "Have greater visibility and control over all your sessions.": "Ayez une meilleur visibilité et plus de contrôle sur toutes vos sessions.", + "New session manager": "Nouveau gestionnaire de sessions", + "Use new session manager": "Utiliser le nouveau gestionnaire de session", + "Wysiwyg composer (plain text mode coming soon) (under active development)": "Compositeur Wysiwyg (le mode texte brut arrive prochainement) (en cours de développement)", + "Sign out all other sessions": "Déconnecter toutes les autres sessions", + "resume voice broadcast": "continuer la diffusion audio", + "pause voice broadcast": "mettre en pause la diffusion audio", + "Underline": "Souligné", + "Italic": "Italique", + "You have already joined this call from another device": "Vous avez déjà rejoint cet appel depuis un autre appareil", + "Try out the rich text editor (plain text mode coming soon)": "Essayer l’éditeur de texte formaté (le mode texte brut arrive bientôt)", + "stop voice broadcast": "arrêter la diffusion audio" } diff --git a/src/i18n/strings/he.json b/src/i18n/strings/he.json index ee7567b4ef..3f83f79593 100644 --- a/src/i18n/strings/he.json +++ b/src/i18n/strings/he.json @@ -2774,5 +2774,6 @@ "Start messages with /plain to send without markdown and /md to send with.": "התחילו הודעות עם /plain לשליחה ללא סימון ו-/md לשליחה.", "Get notified only with mentions and keywords as set up in your settings": "קבלו התראה רק עם אזכורים ומילות מפתח כפי שהוגדרו בהגדרות שלכם", "New keyword": "מילת מפתח חדשה", - "Keyword": "מילת מפתח" + "Keyword": "מילת מפתח", + "Empty room": "חדר ריק" } diff --git a/src/i18n/strings/hu.json b/src/i18n/strings/hu.json index d42f8136f9..bba23baba9 100644 --- a/src/i18n/strings/hu.json +++ b/src/i18n/strings/hu.json @@ -3588,5 +3588,48 @@ "Turn off to disable notifications on all your devices and sessions": "Kikapcsolva az eszközökön és munkamenetekben az értesítések tiltva lesznek", "Enable notifications for this account": "Értesítések engedélyezése ehhez a fiókhoz", "New group call experience": "Új konferenciahívás élmény", - "Live": "Élő" + "Live": "Élő", + "Join %(brand)s calls": "Csatlakozás ebbe a hívásba: %(brand)s", + "Start %(brand)s calls": "%(brand)s hívás indítása", + "Fill screen": "Képernyő kitöltése", + "You have already joined this call from another device": "Már csatlakozott ehhez a híváshoz egy másik eszközön", + "Sorry — this call is currently full": "Bocsánat — ez a hívás betelt", + "Record the client name, version, and url to recognise sessions more easily in session manager": "Kliens neve, verziója és url felvétele a munkamenet könnyebb azonosításához a munkamenet kezelőben", + "Our new sessions manager provides better visibility of all your sessions, and greater control over them including the ability to remotely toggle push notifications.": "Az új munkamenet kezelő jobb rálátást biztosít a munkamenetekre és jobb felügyeletet beleértve, hogy távolról ki-, bekapcsolhatóak a „push” értesítések.", + "Have greater visibility and control over all your sessions.": "Jobb áttekintés és felügyelet a munkamenetek felett.", + "New session manager": "Új munkamenet kezelő", + "Use new session manager": "Új munkamenet kezelő használata", + "Try out the rich text editor (plain text mode coming soon)": "Próbálja ki az új szövegbevitelt (hamarosan érkezik a sima szöveges üzemmód)", + "Video call started": "Videó hívás elindult", + "Unknown room": "Ismeretlen szoba", + "stop voice broadcast": "hang közvetítés beállítása", + "resume voice broadcast": "hang közvetítés folytatása", + "pause voice broadcast": "hang közvetítés szüneteltetése", + "Video call started in %(roomName)s. (not supported by this browser)": "Videó hívás indult itt: %(roomName)s. (ebben a böngészőben ez nem támogatott)", + "Video call started in %(roomName)s.": "Videó hívás indult itt: %(roomName)s.", + "Room info": "Szoba információ", + "Underline": "Aláhúzott", + "Italic": "Dőlt", + "View chat timeline": "Beszélgetés idővonal megjelenítése", + "Close call": "Hívás befejezése", + "Layout type": "Kinézet típusa", + "Spotlight": "Reflektor", + "Freedom": "Szabadság", + "Video call (%(brand)s)": "Videó hívás (%(brand)s)", + "Unknown session type": "Ismeretlen munkamenet típus", + "Web session": "Webes munkamenet", + "Mobile session": "Mobil munkamenet", + "Desktop session": "Asztali munkamenet", + "Operating system": "Operációs rendszer", + "Model": "Modell", + "URL": "URL", + "Version": "Verzió", + "Application": "Alkalmazás", + "Client": "Kliens", + "Sign out all other sessions": "Kijelentkezés minden más munkamenetből", + "Call type": "Hívás típusa", + "You do not have sufficient permissions to change this.": "Nincs megfelelő jogosultság a megváltoztatáshoz.", + "%(brand)s is end-to-end encrypted, but is currently limited to smaller numbers of users.": "%(brand)s végpontok között titkosított de jelenleg csak kevés számú résztvevővel működik.", + "Enable %(brand)s as an additional calling option in this room": "%(brand)s engedélyezése mint további opció hívásokhoz a szobában", + "Notifications silenced": "Értesítések elnémítva" } diff --git a/src/i18n/strings/id.json b/src/i18n/strings/id.json index 2567745ca7..934cb31071 100644 --- a/src/i18n/strings/id.json +++ b/src/i18n/strings/id.json @@ -30,7 +30,7 @@ "Favourites": "Favorit", "Import": "Impor", "Incorrect verification code": "Kode verifikasi tidak benar", - "Invalid Email Address": "Alamat Email Tidak Valid", + "Invalid Email Address": "Alamat Email Tidak Absah", "Invited": "Diundang", "Sign in with": "Masuk dengan", "Leave room": "Tinggalkan ruangan", @@ -255,7 +255,7 @@ "Unbans user with given ID": "Menhilangkan cekalan pengguna dengan ID yang dicantumkan", "Joins room with given address": "Bergabung ke ruangan dengan alamat yang dicantumkan", "Use an identity server to invite by email. Manage in Settings.": "Gunakan server identitas untuk mengundang melalui email. Kelola di Pengaturan.", - "Use an identity server to invite by email. Click continue to use the default identity server (%(defaultIdentityServerName)s) or manage in Settings.": "Gunakan server identitas untuk mengundang melalui email. Klik lanjutkan untuk menggunakan server identitas default (%(defaultIdentityServerName)s) atau kelola di Pengaturan.", + "Use an identity server to invite by email. Click continue to use the default identity server (%(defaultIdentityServerName)s) or manage in Settings.": "Gunakan server identitas untuk mengundang melalui email. Klik lanjutkan untuk menggunakan server identitas bawaan (%(defaultIdentityServerName)s) atau kelola di Pengaturan.", "Use an identity server": "Gunakan sebuah server identitias", "Invites user with given id to current room": "Mengundang pengguna dengan ID yang dicantumkan ke ruangan saat ini", "Sets the room name": "Mengatur nama ruangan", @@ -563,7 +563,7 @@ "We couldn't log you in": "Kami tidak dapat memasukkan Anda", "Trust": "Percayakan", "Only continue if you trust the owner of the server.": "Hanya lanjutkan jika Anda mempercayai pemilik server ini.", - "This action requires accessing the default identity server to validate an email address or phone number, but the server does not have any terms of service.": "Aksi ini memerlukan mengakses server identitas bawaan untuk memvalidasi sebuah alamat email atau nomor telepon, tetapi server ini tidak memiliki syarat layanan apapun.", + "This action requires accessing the default identity server to validate an email address or phone number, but the server does not have any terms of service.": "Aksi ini memerlukan mengakses server identitas bawaan untuk memvalidasi sebuah alamat email atau nomor telepon, tetapi server ini tidak memiliki syarat layanan apa pun.", "Identity server has no terms of service": "Identitas server ini tidak memiliki syarat layanan", "Unnamed Room": "Ruangan Tanpa Nama", "%(date)s at %(time)s": "%(date)s pada %(time)s", @@ -870,7 +870,7 @@ "Keep going...": "Lanjutkan...", "Email (optional)": "Email (opsional)", "Enable encryption?": "Aktifkan enkripsi?", - "Notify everyone": "Beritahu semua", + "Notify everyone": "Beri tahu semua", "Ban users": "Cekal pengguna", "Change settings": "Ubah pengaturan", "Invite users": "Undang pengguna", @@ -947,7 +947,7 @@ "Reject invitation": "Tolak undangan", "Confirm Removal": "Konfirmasi Penghapusan", "Unknown Address": "Alamat Tidak Dikenal", - "Invalid file%(extra)s": "File tidak valid%(extra)s", + "Invalid file%(extra)s": "File tidak absah%(extra)s", "not specified": "tidak ditentukan", "Start chat": "Mulai obrolan", "Join Room": "Bergabung ke Ruangan", @@ -1064,7 +1064,7 @@ "%(senderName)s updated a ban rule matching %(glob)s for %(reason)s": "%(senderName)s memperbarui sebuah peraturan pencekalan yang berisi %(glob)s untuk %(reason)s", "%(senderName)s updated the rule banning servers matching %(glob)s for %(reason)s": "%(senderName)s memperbarui peraturan pencekalan server yang berisi %(glob)s untuk %(reason)s", "%(senderName)s updated the rule banning rooms matching %(glob)s for %(reason)s": "%(senderName)s memperbarui peraturan pencekalan ruangan yang berisi %(glob)s untuk %(reason)s", - "%(senderName)s updated an invalid ban rule": "%(senderName)s memperbarui sebuah peraturan pencekalan yang tidak valid", + "%(senderName)s updated an invalid ban rule": "%(senderName)s memperbarui sebuah peraturan pencekalan yang tidak absah", "%(senderName)s removed a ban rule matching %(glob)s": "%(senderName)s menghapus sebuah peraturan pencekalan yang berisi %(glob)s", "%(senderName)s removed the rule banning servers matching %(glob)s": "%(senderName)s menghapus peraturan pencekalan server yang berisi %(glob)s", "%(senderName)s removed the rule banning rooms matching %(glob)s": "%(senderName)s menghapus peraturan pencekalan ruangan yang berisi %(glob)s", @@ -1144,7 +1144,7 @@ "Can't leave Server Notices room": "Tidak dapat meninggalkan ruangan Pemberitahuan Server", "Unexpected server error trying to leave the room": "Kesalahan server yang tidak terduga saat mencoba untuk meninggalkan ruangannya", "Authentication check failed: incorrect password?": "Pemeriksaan otentikasi gagal: kata sandi salah?", - "Not a valid %(brand)s keyfile": "Bukan keyfile %(brand)s yang valid", + "Not a valid %(brand)s keyfile": "Bukan keyfile %(brand)s yang absah", "Your browser does not support the required cryptography extensions": "Browser Anda tidak mendukung ekstensi kriptografi yang dibutuhkan", "%(name)s (%(userId)s)": "%(name)s (%(userId)s)", "%(num)s days from now": "%(num)s hari dari sekarang", @@ -1221,11 +1221,11 @@ "Disconnect from the identity server ?": "Putuskan hubungan dari server identitas ?", "Disconnect identity server": "Putuskan hubungan server identitas", "The identity server you have chosen does not have any terms of service.": "Server identitas yang Anda pilih tidak memiliki persyaratan layanan.", - "Terms of service not accepted or the identity server is invalid.": "Persyaratan layanan tidak diterima atau server identitasnya tidak valid.", + "Terms of service not accepted or the identity server is invalid.": "Persyaratan layanan tidak diterima atau server identitasnya tidak absah.", "Disconnect from the identity server and connect to instead?": "Putuskan hubungan dari server identitas dan hubungkan ke ?", "Change identity server": "Ubah server identitas", "Could not connect to identity server": "Tidak dapat menghubung ke server identitas", - "Not a valid identity server (status code %(code)s)": "Bukan server identitas yang valid (kode status %(code)s)", + "Not a valid identity server (status code %(code)s)": "Bukan server identitas yang absah (kode status %(code)s)", "Identity server URL must be HTTPS": "URL server identitas harus HTTPS", "not ready": "belum siap", "Secret storage:": "Penyimpanan rahasia:", @@ -1242,16 +1242,16 @@ "Backup version:": "Versi cadangan:", "This backup is trusted because it has been restored on this session": "Cadangan ini dipercayai karena telah dipulihkan di sesi ini", "Backup is not signed by any of your sessions": "Cadangan tidak ditandatangani oleh sesi-sesi Anda", - "Backup has an invalid signature from unverified session ": "Cadangan mempunyai tanda tangan yang tidak valid dari sesi yang tidak diverifikasi ", - "Backup has an invalid signature from verified session ": "Cadangan mempunyai tanda tangan yang tidak valid dari sesi yang terverifikasi ", - "Backup has a valid signature from unverified session ": "Cadangan mempunyai tanda tangan yang valid dari sesi yang belum diverifikasi ", - "Backup has a valid signature from verified session ": "Cadangan mempunyai tanda tangan yang valid dari sesi yang terverifikasi ", - "Backup has an invalid signature from this session": "Cadangan mempunyai tanda tangan yang tidak valid dari sesi ini", - "Backup has a valid signature from this session": "Cadangan mempunyai tanda tangan yang valid dari sesi ini", + "Backup has an invalid signature from unverified session ": "Cadangan mempunyai tanda tangan yang tidak absah dari sesi yang tidak diverifikasi ", + "Backup has an invalid signature from verified session ": "Cadangan mempunyai tanda tangan yang tidak absah dari sesi yang terverifikasi ", + "Backup has a valid signature from unverified session ": "Cadangan mempunyai tanda tangan yang absah dari sesi yang belum diverifikasi ", + "Backup has a valid signature from verified session ": "Cadangan mempunyai tanda tangan yang absah dari sesi yang terverifikasi ", + "Backup has an invalid signature from this session": "Cadangan mempunyai tanda tangan yang tidak absah dari sesi ini", + "Backup has a valid signature from this session": "Cadangan mempunyai tanda tangan yang absah dari sesi ini", "Backup has a signature from unknown session with ID %(deviceId)s": "Cadangan mempunyai tanda tangan dari sesi tidak dikenal dengan ID %(deviceId)s", "Backup has a signature from unknown user with ID %(deviceId)s": "Cadangan mempunyai tanda tangan dari seseorang tidak dikenal dengan ID %(deviceId)s", - "Backup has a invalid signature from this user": "Cadangan mempunyai tanda tangan yang tidak valid dari pengguna ini", - "Backup has a valid signature from this user": "Cadangan mempunyai tanda tangan yang valid dari pengguna ini", + "Backup has a invalid signature from this user": "Cadangan mempunyai tanda tangan yang tidak absah dari pengguna ini", + "Backup has a valid signature from this user": "Cadangan mempunyai tanda tangan yang absah dari pengguna ini", "All keys backed up": "Semua kunci telah dicadangkan", "Backing up %(sessionsRemaining)s keys...": "Mencadangkan %(sessionsRemaining)s kunci...", "Connect this session to Key Backup": "Hubungkan sesi ini ke Pencadangan Kunci", @@ -1284,7 +1284,7 @@ "Loading new room": "Memuat ruangan baru", "Upgrading room": "Meningkatkan ruangan", "This upgrade will allow members of selected spaces access to this room without an invite.": "Peningkatan ini akan mengizinkan anggota di space yang terpilih untuk dapat mengakses ruangan ini tanpa sebuah undangan.", - "This room is in some spaces you're not an admin of. In those spaces, the old room will still be shown, but people will be prompted to join the new one.": "Ruangan ini masih ada di dalam space yang Anda bukan admin di sana. Di space-space itu, ruangan yang lama masih terlihat, tetapi orang-orang akan diberitahu untuk bergabung ke ruangan yang baru.", + "This room is in some spaces you're not an admin of. In those spaces, the old room will still be shown, but people will be prompted to join the new one.": "Ruangan ini masih ada di dalam space yang Anda bukan admin di sana. Di space itu, ruangan yang lama masih terlihat, tetapi orang akan diberi tahu untuk bergabung ke ruangan yang baru.", "Space members": "Anggota space", "Anyone in a space can find and join. You can select multiple spaces.": "Siapa saja di sebuah space dapat menemukan dan bergabung. Anda dapat memilih beberapa space.", "Anyone in can find and join. You can select other spaces too.": "Siapa saja di dapat menemukan dan bergabung. Anda juga dapat memilih space yang lain.", @@ -1332,7 +1332,7 @@ "Your homeserver does not support device management.": "Homeserver Anda tidak mendukung pengelolaan perangkat.", "Session key:": "Kunci sesi:", "Session ID:": "ID Sesi:", - "Import E2E room keys": "Impor kunci enkripsi ujung-ke-ujung", + "Import E2E room keys": "Impor kunci enkripsi ujung ke ujung", "Homeserver feature support:": "Dukungan fitur homeserver:", "Self signing private key:": "Kunci privat penandatanganan diri:", "User signing private key:": "Kunci rahasia penandatanganan pengguna:", @@ -1352,7 +1352,7 @@ "Your homeserver does not support cross-signing.": "Homeserver Anda tidak mendukung penandatanganan silang.", "Passwords don't match": "Kata sandi tidak cocok", "Do you want to set an email address?": "Apakah Anda ingin menetapkan sebuah alamat email?", - "Export E2E room keys": "Ekspor kunci ruangan enkripsi ujung-ke-ujung", + "Export E2E room keys": "Ekspor kunci ruangan enkripsi ujung ke ujung", "No display name": "Tidak ada nama tampilan", "Failed to upload profile picture!": "Gagal untuk mengunggah foto profil!", "Channel: ": "Saluran: ", @@ -1403,7 +1403,7 @@ "Accept to continue:": "Terima untuk melanjutkan:", "Decline (%(counter)s)": "Tolak (%(counter)s)", "Your server isn't responding to some requests.": "Server Anda tidak menanggapi beberapa permintaan.", - "Prompt before sending invites to potentially invalid matrix IDs": "Tanyakan sebelum mengirim undangan ke ID Matrix yang mungkin tidak valid", + "Prompt before sending invites to potentially invalid matrix IDs": "Tanyakan sebelum mengirim undangan ke ID Matrix yang mungkin tidak absah", "Update %(brand)s": "Perbarui %(brand)s", "This is the start of export of . Exported by at %(exportDate)s.": "Ini adalah awalan dari ekspor . Diekspor oleh di %(exportDate)s.", "Waiting for %(displayName)s to verify…": "Menunggu %(displayName)s untuk memverifikasi…", @@ -1413,7 +1413,7 @@ "Unable to find a supported verification method.": "Tidak dapat menemukan metode verifikasi yang didukung.", "Verify this user by confirming the following number appears on their screen.": "Verifikasi pengguna ini dengan mengkonfirmasi nomor berikut yang ditampilkan.", "Verify this user by confirming the following emoji appear on their screen.": "Verifikasi pengguna ini dengan mengkonfirmasi emoji berikut yang ditampilkan.", - "Secure messages with this user are end-to-end encrypted and not able to be read by third parties.": "Pesan dengan pengguna ini terenkripsi secara ujung-ke-ujung dan tidak dapat dibaca oleh pihak ketiga.", + "Secure messages with this user are end-to-end encrypted and not able to be read by third parties.": "Pesan dengan pengguna ini terenkripsi secara ujung ke ujung dan tidak dapat dibaca oleh pihak ketiga.", "You've successfully verified this user.": "Anda berhasil memverifikasi pengguna ini.", "The other party cancelled the verification.": "Pengguna yang lain membatalkan proses verifikasi ini.", "%(name)s on hold": "%(name)s ditahan", @@ -1470,9 +1470,9 @@ "Show rooms with unread notifications first": "Tampilkan ruangan dengan notifikasi yang belum dibaca dulu", "Order rooms by name": "Urutkan ruangan oleh nama", "Enable widget screenshots on supported widgets": "Aktifkan tangkapan layar widget di widget yang didukung", - "Enable URL previews by default for participants in this room": "Aktifkan tampilan URL secara default untuk anggota di ruangan ini", - "Enable URL previews for this room (only affects you)": "Aktifkan tampilan URL secara default (hanya mempengaruhi Anda)", - "Enable inline URL previews by default": "Aktifkan tampilan URL secara default", + "Enable URL previews by default for participants in this room": "Aktifkan tampilan URL secara bawaan untuk anggota di ruangan ini", + "Enable URL previews for this room (only affects you)": "Aktifkan tampilan URL secara bawaan (hanya memengaruhi Anda)", + "Enable inline URL previews by default": "Aktifkan tampilan URL secara bawaan", "Never send encrypted messages to unverified sessions in this room from this session": "Jangan kirim pesan terenkripsi ke sesi yang belum diverifikasi di ruangan ini dari sesi ini", "Never send encrypted messages to unverified sessions from this session": "Jangan kirim pesan terenkripsi ke sesi yang belum diverifikasi dari sesi ini", "Send analytics data": "Kirim data analitik", @@ -1493,7 +1493,7 @@ "Show avatars in user and room mentions": "Tampilkan avatar di sebutan pengguna dan ruangan", "Jump to the bottom of the timeline when you send a message": "Pergi ke bawah linimasa ketika Anda mengirim pesan", "Show line numbers in code blocks": "Tampilkan nomor barisan di blok kode", - "Expand code blocks by default": "Buka blok kode secara default", + "Expand code blocks by default": "Buka blok kode secara bawaan", "Enable automatic language detection for syntax highlighting": "Aktifkan deteksi bahasa otomatis untuk penyorotan sintaks", "Autoplay videos": "Mainkan video secara otomatis", "Autoplay GIFs": "Mainkan GIF secara otomatis", @@ -1580,7 +1580,7 @@ "Use high contrast": "Gunakan kontras tinggi", "Theme added!": "Tema ditambahkan!", "Error downloading theme information.": "Terjadi kesalahan saat mengunduh informasi tema.", - "Invalid theme schema.": "Skema tema tidak valid.", + "Invalid theme schema.": "Skema tema tidak absah.", "Integration managers receive configuration data, and can modify widgets, send room invites, and set power levels on your behalf.": "Manajer integrasi menerima data pengaturan, dan dapat mengubah widget, mengirimkan undangan ruangan, dan mengatur tingkat daya dengan sepengetahuan Anda.", "Manage integrations": "Kelola integrasi", "Use an integration manager to manage bots, widgets, and sticker packs.": "Gunakan sebuah manajer integrasi untuk mengelola bot, widget, dan paket stiker.", @@ -1609,7 +1609,7 @@ "Sidebar": "Bilah Samping", "Manage your signed-in devices below. A device's name is visible to people you communicate with.": "Kelola sesi Anda di bawah. Sebuah nama sesi dapat dilihat oleh siapa saja yang Anda berkomunikasi.", "Where you're signed in": "Di mana Anda masuk", - "Your server admin has disabled end-to-end encryption by default in private rooms & Direct Messages.": "Admin server Anda telah menonaktifkan enkripsi ujung-ke-ujung secara default di ruangan privat & Pesan Langsung.", + "Your server admin has disabled end-to-end encryption by default in private rooms & Direct Messages.": "Admin server Anda telah menonaktifkan enkripsi ujung ke ujung secara bawaan di ruangan privat & Pesan Langsung.", "Message search": "Pencarian pesan", "Secure Backup": "Cadangan Aman", "Reject all %(invitedRooms)s invites": "Tolak semua %(invitedRooms)s undangan", @@ -1624,7 +1624,7 @@ "Keyboard shortcuts": "Pintasan keyboard", "Show tray icon and minimise window to it on close": "Tampilkan ikon baki dan minimalkan window ke ikonnya jika ditutup", "Always show the window menu bar": "Selalu tampilkan bilah menu window", - "Warn before quitting": "Beritahu sebelum keluar", + "Warn before quitting": "Beri tahu sebelum keluar", "Start automatically after system login": "Mulai setelah login sistem secara otomatis", "Room ID or address of ban list": "ID ruangan atau alamat daftar larangan", "If this isn't what you want, please use a different tool to ignore users.": "Jika itu bukan yang Anda ingin, mohon pakai alat yang lain untuk mengabaikan pengguna.", @@ -1639,9 +1639,9 @@ "⚠ These settings are meant for advanced users.": "⚠ Pengaturan ini hanya untuk pengguna berkelanjutan saja.", "You are currently subscribed to:": "Anda saat ini berlangganan:", "View rules": "Tampilkan aturan", - "You are not subscribed to any lists": "Anda belum berlangganan daftar apapun", + "You are not subscribed to any lists": "Anda belum berlangganan daftar apa pun", "You are currently ignoring:": "Anda saat ini mengabaikan:", - "You have not ignored anyone.": "Anda belum mengabaikan siapapun.", + "You have not ignored anyone.": "Anda belum mengabaikan siapa pun.", "User rules": "Aturan pengguna", "Server rules": "Aturan server", "Ban list rules - %(roomName)s": "Daftar aturan cekalan — %(roomName)s", @@ -1697,17 +1697,17 @@ "For extra security, verify this user by checking a one-time code on both of your devices.": "Untuk keamanan lebih, verifikasi pengguna ini dengan memeriksa kode satu kali di kedua perangkat Anda.", "Verify User": "Verifikasi Pengguna", "In encrypted rooms, your messages are secured and only you and the recipient have the unique keys to unlock them.": "Di ruangan terenkripsi, pesan Anda diamankan dan hanya Anda dan penerimanya mempunyai kunci yang unik untuk mengaksesnya.", - "Messages in this room are not end-to-end encrypted.": "Pesan di ruangan ini tidak dienkripsi secara ujung-ke-ujung.", + "Messages in this room are not end-to-end encrypted.": "Pesan di ruangan ini tidak dienkripsi secara ujung ke ujung.", "Your messages are secured and only you and the recipient have the unique keys to unlock them.": "Pesan Anda diamankan dan hanya Anda dan penerimanya mempunyai kunci yang unik untuk mengaksesnya.", - "Messages in this room are end-to-end encrypted.": "Pesan di ruangan ini terenkripsi secara ujung-ke-ujung.", + "Messages in this room are end-to-end encrypted.": "Pesan di ruangan ini terenkripsi secara ujung ke ujung.", "Start Verification": "Mulai Verifikasi", "Waiting for %(displayName)s to accept…": "Menunggu untuk %(displayName)s untuk menerima…", "When someone puts a URL in their message, a URL preview can be shown to give more information about that link such as the title, description, and an image from the website.": "Ketika seseorang menambahkan URL di pesannya, sebuah tampilan URL dapat ditampilkan untuk memberikan informasi lainnya tentang tautan itu seperti judul, deskripsi, dan sebuah gambar dari website.", - "In encrypted rooms, like this one, URL previews are disabled by default to ensure that your homeserver (where the previews are generated) cannot gather information about links you see in this room.": "Di ruangan terenkripsi, seperti ruangan ini, tampilan URL dinonaktifkan untuk memastikan homeserver Anda (di mana tampilannya dibuat) tidak mendapatkan informasi tentang tautan yang Anda lihat di ruangan ini.", - "URL previews are disabled by default for participants in this room.": "Tampilan URL dinonaktifkan secara default untuk anggota di ruangan ini.", - "URL previews are enabled by default for participants in this room.": "Tampilan URL diaktifkan secara default untuk anggota di ruangan ini.", - "You have disabled URL previews by default.": "Anda telah menonaktifkan tampilan URL secara default.", - "You have enabled URL previews by default.": "Anda telah mengaktifkan tampilan URL secara default.", + "In encrypted rooms, like this one, URL previews are disabled by default to ensure that your homeserver (where the previews are generated) cannot gather information about links you see in this room.": "Di ruangan terenkripsi, seperti ruangan ini, tampilan URL dinonaktifkan secara bawaan untuk memastikan homeserver Anda (di mana tampilannya dibuat) tidak mendapatkan informasi tentang tautan yang Anda lihat di ruangan ini.", + "URL previews are disabled by default for participants in this room.": "Tampilan URL dinonaktifkan secara bawaan untuk anggota di ruangan ini.", + "URL previews are enabled by default for participants in this room.": "Tampilan URL diaktifkan secara bawaan untuk anggota di ruangan ini.", + "You have disabled URL previews by default.": "Anda telah menonaktifkan tampilan URL secara bawaan.", + "You have enabled URL previews by default.": "Anda telah mengaktifkan tampilan URL secara bawaan.", "Publish this room to the public in %(domain)s's room directory?": "Publikasi ruangan ini ke publik di direktori ruangan %(domain)s?", "Show more": "Tampilkan lebih banyak", "Set addresses for this room so users can find this room through your homeserver (%(localDomain)s)": "Tetapkan alamat untuk ruangan ini supaya pengguna dapat menemukan space ini melalui homeserver Anda (%(localDomain)s)", @@ -1756,7 +1756,7 @@ "Forget Room": "Lupakan Ruangan", "Notification options": "Opsi notifikasi", "Mentions & Keywords": "Sebutan & Kata Kunci", - "Use default": "Gunakan default", + "Use default": "Gunakan bawaan", "Show less": "Tampilkan lebih sedikit", "Show %(count)s more|one": "Tampilkan %(count)s lagi", "Show %(count)s more|other": "Tampilkan %(count)s lagi", @@ -1813,7 +1813,7 @@ "Online for %(duration)s": "Daring selama %(duration)s", "View message": "Tampilkan pesan", "Message didn't send. Click for info.": "Pesan tidak terkirim. Klik untuk informasi.", - "End-to-end encryption isn't enabled": "Enkripsi ujung-ke-ujung belum diaktifkan", + "End-to-end encryption isn't enabled": "Enkripsi ujung ke ujung tidak diaktifkan", "Enable encryption in settings.": "Aktifkan enkripsi di pengaturan.", "Your private messages are normally encrypted, but this room isn't. Usually this is due to an unsupported device or method being used, like email invites.": "Pesan privat Anda biasanya dienkripsi, tetapi di ruangan ini tidak terenkripsi. Biasanya ini disebabkan oleh perangkat yang tidak mendukung atau metode yang sedang digunakan, seperti undangan email.", "This is the start of .": "Ini adalah awal dari .", @@ -1877,14 +1877,14 @@ "Unknown Command": "Perintah Tidak Diketahui", "Server unavailable, overloaded, or something else went wrong.": "Server tidak tersedia, terlalu penuh, atau ada sesuatu yang salah.", "Everyone in this room is verified": "Semuanya di ruangan ini telah terverifikasi", - "This room is end-to-end encrypted": "Ruangan ini dienkripsi secara ujung-ke-ujung", + "This room is end-to-end encrypted": "Ruangan ini dienkripsi secara ujung ke ujung", "Someone is using an unknown session": "Seseorang menggunakan sesi yang tidak dikenal", "You have verified this user. This user has verified all of their sessions.": "Anda telah memverifikasi pengguna ini. Pengguna ini telah memverifikasi semua sesinya.", "You have not verified this user.": "Anda belum memverifikasi pengguna ini.", "This user has not verified all of their sessions.": "Pengguna ini belum memverifikasi semua sesinya.", "A text message has been sent to +%(msisdn)s. Please enter the verification code it contains.": "Sebuah teks pesan telah dikirim ke +%(msisdn)s. Silakan masukkan kode verifikasinya.", "We've sent you an email to verify your address. Please follow the instructions there and then click the button below.": "Kami telah mengirim sebuah email untuk memverifikasi alamat Anda. Silakan ikuti instruksinya dan klik tombol di bawah.", - "This doesn't appear to be a valid email address": "Ini sepertinya bukan alamat email yang valid", + "This doesn't appear to be a valid email address": "Ini sepertinya bukan alamat email yang absah", "Unable to remove contact information": "Tidak dapat menghapus informasi kontak", "Discovery options will appear once you have added a phone number above.": "Opsi penemuan akan tersedia setelah Anda telah menambahkan sebuah nomor telepon di atas.", "Discovery options will appear once you have added an email above.": "Opsi penemuan akan tersedia setelah Anda telah menambahkan sebuah email di atas.", @@ -2002,8 +2002,8 @@ "Encryption not enabled": "Enkripsi tidak diaktifkan", "Ignored attempt to disable encryption": "Mengabaikan percobaan untuk menonaktifkan enkripsi", "Encryption enabled": "Enkripsi diaktifkan", - "Messages in this room are end-to-end encrypted. When people join, you can verify them in their profile, just tap on their avatar.": "Pesan di ruangan ini terenkripsi secara ujung-ke-ujung. Ketika orang-orang bergabung, Anda dapat memverifikasi mereka di profil mereka — klik pada avatar mereka.", - "Messages here are end-to-end encrypted. Verify %(displayName)s in their profile - tap on their avatar.": "Pesan di pesan langsung ini terenkripsi secara ujung-ke-ujung. Verifikasi %(displayName)s di profilnya — klik pada avatarnya.", + "Messages in this room are end-to-end encrypted. When people join, you can verify them in their profile, just tap on their avatar.": "Pesan di ruangan ini terenkripsi secara ujung ke ujung. Ketika orang-orang bergabung, Anda dapat memverifikasi mereka di profil mereka — klik pada avatar mereka.", + "Messages here are end-to-end encrypted. Verify %(displayName)s in their profile - tap on their avatar.": "Pesan di pesan langsung ini terenkripsi secara ujung ke ujung. Verifikasi %(displayName)s di profilnya — klik pada avatarnya.", "Some encryption parameters have been changed.": "Beberapa parameter enkripsi telah diubah.", "The call is in an unknown state!": "Panggilan ini berada di status yang tidak diketahui!", "Missed call": "Panggilan terlewat", @@ -2037,7 +2037,7 @@ "Compare unique emoji": "Bandingkan emoji unik", "Scan this unique code": "Pindai kode unik ini", "Edit devices": "Edit perangkat", - "This client does not support end-to-end encryption.": "Klien ini tidak mendukung enkripsi ujung-ke-ujung.", + "This client does not support end-to-end encryption.": "Klien ini tidak mendukung enkripsi ujung ke ujung.", "Role in ": "Peran di ", "Failed to deactivate user": "Gagal untuk menonaktifkan pengguna", "Deactivate user": "Nonaktifkan pengguna", @@ -2097,14 +2097,14 @@ "Setting ID": "ID Pengaturan", "There was an error finding this widget.": "Terjadi sebuah kesalahan menemukan widget ini.", "Active Widgets": "Widget Aktif", - "Server did not return valid authentication information.": "Server tidak memberikan informasi otentikasi yang valid.", + "Server did not return valid authentication information.": "Server tidak memberikan informasi otentikasi yang absah.", "Server did not require any authentication": "Server tidak membutuhkan otentikasi apa pun", "There was a problem communicating with the server. Please try again.": "Terjadi sebuah masalah ketika berkomunikasi dengan server. Mohon coba lagi.", "Confirm account deactivation": "Konfirmasi penonaktifan akun", "Confirm your account deactivation by using Single Sign On to prove your identity.": "Konfirmasi penonaktifan akun Anda dengan menggunakan Single Sign On untuk membuktikan identitas Anda.", "Are you sure you want to deactivate your account? This is irreversible.": "Apakah Anda yakin ingin menonaktifkan akun Anda? Ini tidak dapat dibatalkan.", "Continue With Encryption Disabled": "Lanjutkan Dengan Enkripsi Dinonaktifkan", - "You've previously used a newer version of %(brand)s with this session. To use this version again with end to end encryption, you will need to sign out and back in again.": "Anda sebelumnya menggunakan sebuah versi %(brand)s yang baru dengan sesi ini. Untuk menggunakan versi ini lagi dengan enkripsi ujung-ke-ujung, Anda harus keluar dan masuk lagi.", + "You've previously used a newer version of %(brand)s with this session. To use this version again with end to end encryption, you will need to sign out and back in again.": "Anda sebelumnya menggunakan sebuah versi %(brand)s yang baru dengan sesi ini. Untuk menggunakan versi ini lagi dengan enkripsi ujung ke ujung, Anda harus keluar dan masuk lagi.", "To avoid losing your chat history, you must export your room keys before logging out. You will need to go back to the newer version of %(brand)s to do this": "Untuk menghindari kehilangan riwayat obrolan, Anda harus mengekspor kunci ruangan Anda sebelum keluar. Anda harus kembali ke versi %(brand)s yang baru untuk melakukannya", "Want to add an existing space instead?": "Ingin menambahkan sebuah space yang sudah ada saja?", "Add a space to a space you manage.": "Tambahkan sebuah space ke space yang Anda kelola.", @@ -2125,7 +2125,7 @@ "Create a room": "Buat sebuah ruangan", "You might disable this if the room will be used for collaborating with external teams who have their own homeserver. This cannot be changed later.": "Anda mungkin menonaktifkannya jika ruangan ini akan digunakan untuk berkolabroasi dengan tim eksternal yang mempunyai homeserver sendiri. Ini tidak dapat diubah nanti.", "You might enable this if the room will only be used for collaborating with internal teams on your homeserver. This cannot be changed later.": "Anda mungkin aktifkan jika ruangan ini hanya digunakan untuk berkolabroasi dengan tim internal di homeserver Anda. Ini tidak dapat diubah nanti.", - "Enable end-to-end encryption": "Aktifkan enkripsi ujung-ke-ujung", + "Enable end-to-end encryption": "Aktifkan enkripsi ujung ke ujung", "Your server requires encryption to be enabled in private rooms.": "Server Anda memerlukan mengaktifkan enkripsi di ruangan privat.", "You can't disable this later. Bridges & most bots won't work yet.": "Anda tidak dapat menonaktifkannya nanti. Jembatan & kebanyakan bot belum dapat digunakan.", "Anyone will be able to find and join this room.": "Siapa saja dapat menemukan dan bergabung ke ruangan ini.", @@ -2147,7 +2147,7 @@ "Preparing to download logs": "Mempersiapkan untuk mengunduh catatan", "Failed to send logs: ": "Gagal untuk mengirimkan catatan: ", "Preparing to send logs": "Mempersiapkan untuk mengirimkan catatan", - "Please tell us what went wrong or, better, create a GitHub issue that describes the problem.": "Mohon beritahu kami apa saja yang salah atau, lebih baik, buat sebuah issue GitHub yang menjelaskan masalahnya.", + "Please tell us what went wrong or, better, create a GitHub issue that describes the problem.": "Mohon beri tahu kami apa saja yang salah atau, lebih baik, buat sebuah issue GitHub yang menjelaskan masalahnya.", "To leave the beta, visit your settings.": "Untuk keluar dari beta, pergi ke pengaturan Anda.", "Close dialog": "Tutup dialog", "Invite anyway and never warn me again": "Undang saja dan jangan peringatkan saya lagi", @@ -2283,7 +2283,7 @@ "Sign into your homeserver": "Masuk ke homeserver Anda", "Matrix.org is the biggest public homeserver in the world, so it's a good place for many.": "Matrix.org adalah homeserver publik terbesar di dunia, jadi itu adalah tempat yang bagus untuk banyak orang.", "Specify a homeserver": "Tentukan sebuah homeserver", - "Invalid URL": "URL tidak valid", + "Invalid URL": "URL tidak absah", "Unable to validate homeserver": "Tidak dapat memvalidasi homeserver", "Recent changes that have not yet been received": "Perubahan terbaru yang belum diterima", "The server is not configured to indicate what the problem is (CORS).": "Server tidak diatur untuk menandakan apa masalahnya (CORS).", @@ -2305,7 +2305,7 @@ "Upgrade private room": "Tingkatkan ruangan privat", "Automatically invite members from this room to the new one": "Mengundang pengguna dari ruangan ini ke yang baru secara otomatis", "Put a link back to the old room at the start of the new room so people can see old messages": "Letakkan sebuah tautan kembali ke ruangan yang lama di awal ruangan baru supaya orang-orang dapat melihat pesan-pesan lama", - "Stop users from speaking in the old version of the room, and post a message advising users to move to the new room": "Menghentikan pengguna dari berbicara di versi ruangan yang lama, dan mengirimkan sebuah pesan memberitahu pengguna untuk pindah ke ruangan yang baru", + "Stop users from speaking in the old version of the room, and post a message advising users to move to the new room": "Menghentikan pengguna dari berbicara di versi ruangan yang lama, dan mengirimkan sebuah pesan memberi tahu pengguna untuk pindah ke ruangan yang baru", "Update any local room aliases to point to the new room": "Memperbarui alias ruangan lokal apa saja untuk diarahkan ke ruangan yang baru", "Create a new room with the same name, description and avatar": "Membuat ruangan baru dengan nama, deskripsi, dan avatar yang sama", "Upgrading this room requires closing down the current instance of the room and creating a new room in its place. To give room members the best possible experience, we will:": "Meningkatkan ruangan ini membutuhkan penutupan instansi ruangan saat ini dan membuat ruangan yang baru di tempatnya. Untuk memberikan anggota ruangan pengalaman yang baik, kami akan:", @@ -2386,13 +2386,13 @@ "This account has been deactivated.": "Akun ini telah dinonaktifkan.", "Please contact your service administrator to continue using this service.": "Mohon hubungi administrator layanan Anda untuk melanjutkan menggunakan layanannya.", "This homeserver does not support login using email address.": "Homeserver ini tidak mendukung login menggunakan alamat email.", - "Identity server URL does not appear to be a valid identity server": "URL server identitas terlihat bukan sebagai server identitas yang valid", - "Invalid base_url for m.identity_server": "base_url tidak valid untuk m.identity_server", - "Invalid identity server discovery response": "Respons penemuan server identitas tidak valid", - "Homeserver URL does not appear to be a valid Matrix homeserver": "URL homeserver sepertinya bukan sebagai homeserver Matrix yang valid", - "Invalid base_url for m.homeserver": "base_url tidak valid untuk m.homeserver", + "Identity server URL does not appear to be a valid identity server": "URL server identitas terlihat bukan sebagai server identitas yang absah", + "Invalid base_url for m.identity_server": "base_url tidak absah untuk m.identity_server", + "Invalid identity server discovery response": "Respons penemuan server identitas tidak absah", + "Homeserver URL does not appear to be a valid Matrix homeserver": "URL homeserver sepertinya bukan sebagai homeserver Matrix yang absah", + "Invalid base_url for m.homeserver": "base_url tidak absah untuk m.homeserver", "Failed to get autodiscovery configuration from server": "Gagal untuk mendapatkan konfigurasi penemuan otomatis dari server", - "Invalid homeserver discovery response": "Respons penemuan homeserver tidak valid", + "Invalid homeserver discovery response": "Respons penemuan homeserver tidak absah", "Set a new password": "Tetapkan kata sandi baru", "Your password has been reset.": "Kata sandi Anda telah diatur ulang.", "I have verified my email address": "Saya telah memverifikasi alamat email saya", @@ -2400,7 +2400,7 @@ "Sign in instead": "Masuk saja", "A verification email will be sent to your inbox to confirm setting your new password.": "Sebuah email verifikasi akan dikirim ke kotak masuk Anda untuk mengkonfirmasi mengatur kata sandi Anda yang baru.", "New passwords must match each other.": "Kata sandi baru harus cocok.", - "The email address doesn't appear to be valid.": "Alamat email ini tidak terlihat valid.", + "The email address doesn't appear to be valid.": "Alamat email ini tidak terlihat absah.", "The email address linked to your account must be entered.": "Alamat email yang tertaut ke akun Anda harus dimasukkan.", "Skip verification for now": "Lewatkan verifikasi untuk sementara", "Really reset verification keys?": "Benar-benar ingin mengatur ulang kunci-kunci verifikasi?", @@ -2446,7 +2446,7 @@ "Go to my first room": "Pergi ke ruangan pertama saya", "Share %(name)s": "Bagikan %(name)s", "Search for rooms or spaces": "Cari untuk ruangan atau space", - "Pick rooms or conversations to add. This is just a space for you, no one will be informed. You can add more later.": "Pilih ruangan atau percakapan untuk ditambahkan. Ini adalah hanya sebuah space untuk Anda, tidak ada siapa saja yang diberitahu. Anda dapat menambahkan lagi nanti.", + "Pick rooms or conversations to add. This is just a space for you, no one will be informed. You can add more later.": "Pilih ruangan atau percakapan untuk ditambahkan. Ini adalah hanya sebuah space untuk Anda, tidak ada siapa pun yang diberi tahu. Anda dapat menambahkan lagi nanti.", "What do you want to organise?": "Apa saja yang Anda ingin organisirkan?", "Creating rooms...": "Membuat ruangan...", "Skip for now": "Lewat untuk sementara", @@ -2497,7 +2497,7 @@ "%(creator)s created and configured the room.": "%(creator)s membuat dan mengatur ruangan ini.", "%(creator)s created this DM.": "%(creator)s membuat pesan langsung ini.", "Verification requested": "Verifikasi diminta", - "Data from an older version of %(brand)s has been detected. This will have caused end-to-end cryptography to malfunction in the older version. End-to-end encrypted messages exchanged recently whilst using the older version may not be decryptable in this version. This may also cause messages exchanged with this version to fail. If you experience problems, log out and back in again. To retain message history, export and re-import your keys.": "Data dari %(brand)s versi lama telah terdeteksi. Ini akan menyebabkan kriptografi ujung-ke-ujung tidak berfungsi di versi yang lebih lama. Pesan terenkripsi secara ujung-ke-ujung yang dipertukarkan baru-baru ini saat menggunakan versi yang lebih lama mungkin tidak dapat didekripsi dalam versi ini. Ini juga dapat menyebabkan pesan yang dipertukarkan dengan versi ini gagal. Jika Anda mengalami masalah, keluar dan masuk kembali. Untuk menyimpan riwayat pesan, ekspor dan impor ulang kunci Anda.", + "Data from an older version of %(brand)s has been detected. This will have caused end-to-end cryptography to malfunction in the older version. End-to-end encrypted messages exchanged recently whilst using the older version may not be decryptable in this version. This may also cause messages exchanged with this version to fail. If you experience problems, log out and back in again. To retain message history, export and re-import your keys.": "Data dari %(brand)s versi lama telah terdeteksi. Ini akan menyebabkan kriptografi ujung ke ujung tidak berfungsi di versi yang lebih lama. Pesan terenkripsi secara ujung ke ujung yang dipertukarkan baru-baru ini saat menggunakan versi yang lebih lama mungkin tidak dapat didekripsi dalam versi ini. Ini juga dapat menyebabkan pesan yang dipertukarkan dengan versi ini gagal. Jika Anda mengalami masalah, keluar dan masuk kembali. Untuk menyimpan riwayat pesan, ekspor dan impor ulang kunci Anda.", "Old cryptography data detected": "Data kriptografi lama terdeteksi", "Review terms and conditions": "Lihat syarat dan ketentuan", "To continue using the %(homeserverDomain)s homeserver you must review and agree to our terms and conditions.": "Untuk melanjutkan menggunakan homeserver %(homeserverDomain)s Anda harus lihat dan terima ke syarat dan ketentuan kami.", @@ -2549,7 +2549,7 @@ "Please review and accept all of the homeserver's policies": "Mohon lihat dan terima semua kebijakan homeserver ini", "Missing captcha public key in homeserver configuration. Please report this to your homeserver administrator.": "Tidak ada kunci publik captcha di konfigurasi homeserver. Mohon melaporkannya ke administrator homeserver Anda.", "Confirm your identity by entering your account password below.": "Konfirmasi identitas Anda dengan memasukkan kata sandi akun Anda di bawah.", - "Doesn't look like a valid email address": "Kelihatannya bukan sebuah alamat email yang valid", + "Doesn't look like a valid email address": "Kelihatannya bukan sebuah alamat email yang absah", "Enter email address": "Masukkan alamat email", "Country Dropdown": "Dropdown Negara", "This homeserver would like to make sure you are not a robot.": "Homeserver ini memastikan Anda bahwa Anda bukan sebuah robot.", @@ -2581,8 +2581,8 @@ "If you've forgotten your Security Key you can ": "Jika Anda lupa Kunci Keamanan, Anda dapat ", "Access your secure message history and set up secure messaging by entering your Security Key.": "Akses riwayat pesan aman Anda dan siapkan perpesanan aman dengan memasukkan Kunci Keamanan Anda.", "Warning: You should only set up key backup from a trusted computer.": "Peringatan: Anda seharusnya menyiapkan cadangan kunci di komputer yang dipercayai.", - "Not a valid Security Key": "Bukan Kunci Keamanan yang valid", - "This looks like a valid Security Key!": "Ini sepertinya Kunci Keamanan yang valid!", + "Not a valid Security Key": "Bukan Kunci Keamanan yang absah", + "This looks like a valid Security Key!": "Ini sepertinya Kunci Keamanan yang absah!", "Enter Security Key": "Masukkan Kunci Keamanan", "If you've forgotten your Security Phrase you can use your Security Key or set up new recovery options": "Jika Anda lupa Frasa Keamanan, Anda dapat menggunakan Kunci Keamanan Anda atau siapkan opsi pemulihan baru", "Access your secure message history and set up secure messaging by entering your Security Phrase.": "Akses riwayat pesan aman Anda dan siapkan perpesanan aman dengan memasukkan Frasa Keamanan Anda.", @@ -2616,7 +2616,7 @@ "Only do this if you have no other device to complete verification with.": "Hanya lakukan ini jika Anda tidak memiliki perangkat yang lain untuk menyelesaikan verifikasi.", "Reset everything": "Atur ulang semuanya", "Forgotten or lost all recovery methods? Reset all": "Lupa atau kehilangan semua metode pemulihan? Atur ulang semuanya", - "Invalid Security Key": "Kunci Keamanan tidak valid", + "Invalid Security Key": "Kunci Keamanan tidak absah", "Wrong Security Key": "Kunci Keamanan salah", "Looks good!": "Kelihatannya bagus!", "Wrong file type": "Tipe file salah", @@ -2669,7 +2669,7 @@ "Space Autocomplete": "Penyelesaian Space Otomatis", "Room Autocomplete": "Penyelesaian Ruangan Otomatis", "Notification Autocomplete": "Penyelesaian Notifikasi Otomatis", - "Notify the whole room": "Beritahu seluruh ruangan", + "Notify the whole room": "Beri tahu seluruh ruangan", "Command Autocomplete": "Penyelesaian Perintah Otomatis", "Warning: Your personal data (including encryption keys) is still stored in this session. Clear it if you're finished using this session, or want to sign in to another account.": "Peringatan: Data personal Anda (termasuk kunci enkripsi) masih disimpan di sesi ini. Hapus jika Anda selesai menggunakan sesi ini, atau jika ingin masuk ke akun yang lain.", "Clear personal data": "Hapus data personal", @@ -2753,7 +2753,7 @@ "Search spaces": "Cari space", "Decide which spaces can access this room. If a space is selected, its members can find and join .": "Tentukan space mana yang dapat mengakses ruangan ini. Jika sebuah space dipilih, anggotanya dapat menemukan dan bergabung .", "Select spaces": "Pilih space", - "You're removing all spaces. Access will default to invite only": "Anda menghilangkan semua space. Akses secara default ke undangan saja", + "You're removing all spaces. Access will default to invite only": "Anda menghilangkan semua space. Akses secara bawaan ke undangan saja", "%(count)s rooms|one": "%(count)s ruangan", "%(count)s rooms|other": "%(count)s ruangan", "%(count)s members|one": "%(count)s anggota", @@ -2763,7 +2763,7 @@ "Manually export keys": "Ekspor kunci secara manual", "I don't want my encrypted messages": "Saya tidak ingin pesan-pesan terenkripsi saya", "Start using Key Backup": "Mulai menggunakan Cadangan Kunci", - "Encrypted messages are secured with end-to-end encryption. Only you and the recipient(s) have the keys to read these messages.": "Pesan terenkripsi diamankan dengan enkripsi ujung-ke-ujung. Hanya Anda dan penerima punya kuncinya untuk membaca pesan-pesan ini.", + "Encrypted messages are secured with end-to-end encryption. Only you and the recipient(s) have the keys to read these messages.": "Pesan terenkripsi diamankan dengan enkripsi ujung ke ujung. Hanya Anda dan penerima punya kuncinya untuk membaca pesan ini.", "Leave space": "Tinggalkan space", "Leave some rooms": "Tinggalkan beberapa ruangan", "Leave all rooms": "Tinggalkan semua ruangan", @@ -2807,7 +2807,7 @@ "Start a conversation with someone using their name or username (like ).": "Mulai sebuah obrolan dengan seseorang menggunakan namanya atau nama pengguna (seperti ).", "Recently Direct Messaged": "Pesan Langsung Kini", "Recent Conversations": "Obrolan Terkini", - "The following users might not exist or are invalid, and cannot be invited: %(csvNames)s": "Pengguna berikut ini mungkin tidak ada atau tidak valid, dan tidak dapat diundang: %(csvNames)s", + "The following users might not exist or are invalid, and cannot be invited: %(csvNames)s": "Pengguna berikut ini mungkin tidak ada atau tidak absah, dan tidak dapat diundang: %(csvNames)s", "Failed to find the following users": "Gagal untuk mencari pengguna berikut ini", "A call can only be transferred to a single user.": "Sebuah panggilan dapat dipindah ke sebuah pengguna.", "We couldn't invite those users. Please check the users you want to invite and try again.": "Kami tidak dapat mengundang penggunanya. Mohon periksa pengguna yang Anda ingin undang dan coba lagi.", @@ -2824,9 +2824,9 @@ "Incoming Verification Request": "Permintaan Verifikasi Masuk", "Waiting for partner to confirm...": "Menunggu pengguna lain untuk mengkonfirmasi...", "Verifying this device will mark it as trusted, and users who have verified with you will trust this device.": "Memverifikasi perangkat ini akan menandainya sebagai terpercaya, dan pengguna yang telah diverifikasi dengan Anda akan mempercayai perangkat ini.", - "Verify this device to mark it as trusted. Trusting this device gives you and other users extra peace of mind when using end-to-end encrypted messages.": "Verifikasi perangkat ini untuk menandainya sebagai terpercaya. Mempercayai perangkat ini akan memberikan Anda dan pengguna lain ketenangan saat menggunakan pesan-pesan terenkripsi secara ujung-ke-ujung.", + "Verify this device to mark it as trusted. Trusting this device gives you and other users extra peace of mind when using end-to-end encrypted messages.": "Verifikasi perangkat ini untuk menandainya sebagai terpercaya. Mempercayai perangkat ini akan memberikan Anda dan pengguna lain ketenangan saat menggunakan pesan terenkripsi secara ujung ke ujung.", "Verifying this user will mark their session as trusted, and also mark your session as trusted to them.": "Memverifikasi pengguna ini akan menandai sesinya sebagai terpercaya, dan juga menandai sesi Anda sebagai terpercaya kepadanya.", - "Verify this user to mark them as trusted. Trusting users gives you extra peace of mind when using end-to-end encrypted messages.": "Verifikasi pengguna ini untuk menandainya sebagai terpercaya. Mempercayai pengguna memberikan Anda ketenangan saat menggunakan pesan-pesan terenkripsi secara ujung-ke-ujung.", + "Verify this user to mark them as trusted. Trusting users gives you extra peace of mind when using end-to-end encrypted messages.": "Verifikasi pengguna ini untuk menandainya sebagai terpercaya. Mempercayai pengguna memberikan Anda ketenangan saat menggunakan pesan terenkripsi secara ujung ke ujung.", "Based on %(count)s votes|one": "Berdasarkan oleh %(count)s suara", "Based on %(count)s votes|other": "Berdasarkan oleh %(count)s suara", "%(count)s votes|one": "%(count)s suara", @@ -2907,7 +2907,7 @@ "Spaces you know that contain this space": "Space yang Anda tahu yang berisi space ini", "Chat": "Obrolan", "Clear": "Hapus", - "You may contact me if you want to follow up or to let me test out upcoming ideas": "Anda mungkin hubungi saya jika Anda ingin menindaklanjuti atau memberitahu saya untuk menguji ide-ide baru", + "You may contact me if you want to follow up or to let me test out upcoming ideas": "Anda mungkin hubungi saya jika Anda ingin menindaklanjuti atau memberi tahu saya untuk menguji ide baru", "Home options": "Opsi Beranda", "%(spaceName)s menu": "Menu %(spaceName)s", "Join public room": "Bergabung ke ruangan publik", @@ -2923,7 +2923,7 @@ "To view all keyboard shortcuts, click here.": "Untuk melihat semua shortcut keyboard, klik di sini.", "You can turn this off anytime in settings": "Anda dapat mematikannya kapan saja di pengaturan", "We don't share information with third parties": "Kami tidak membagikan informasi ini dengan pihak ketiga", - "We don't record or profile any account data": "Kami tidak merekam atau memprofil data akun apapun", + "We don't record or profile any account data": "Kami tidak merekam atau memprofil data akun apa pun", "You can read all our terms here": "Anda dapat membaca kebijakan kami di sini", "Share anonymous data to help us identify issues. Nothing personal. No third parties.": "Bagikan data anonim untuk membantu kami mengidentifikasi masalah-masalah. Tidak ada yang pribadi. Tidak ada pihak ketiga.", "Okay": "Ok", @@ -2983,7 +2983,7 @@ "Dial": "Dial", "Missing room name or separator e.g. (my-room:domain.org)": "Kurang nama ruangan atau pemisah mis. (ruangan-saya:domain.org)", "Missing domain separator e.g. (:domain.org)": "Kurang pemisah domain mis. (:domain.org)", - "This address had invalid server or is already in use": "Alamat ini memiliki server yang tidak valid atau telah digunakan", + "This address had invalid server or is already in use": "Alamat ini memiliki server yang tidak absah atau telah digunakan", "Back to thread": "Kembali ke utasan", "Room members": "Anggota ruangan", "Back to chat": "Kembali ke obrolan", @@ -3076,7 +3076,7 @@ "Spaces are ways to group rooms and people. Alongside the spaces you're in, you can use some pre-built ones too.": "Space adalah cara untuk mengelompokkan ruangan dan orang. Di sampingnya space yang Anda berada, Anda dapat menggunakan space yang sudah dibuat.", "IRC (Experimental)": "IRC (Eksperimental)", "Call": "Panggil", - "Right panel stays open (defaults to room member list)": "Panel kanan tetap terbuka (menampilkan daftar anggota ruangan secara default)", + "Right panel stays open (defaults to room member list)": "Panel kanan tetap terbuka (menampilkan daftar anggota ruangan secara bawaan)", "Toggle hidden event visibility": "Alih visibilitas peristiwa tersembunyi", "Redo edit": "Ulangi editan", "Force complete": "Selesaikan dengan paksa", @@ -3220,7 +3220,7 @@ "Capabilities": "Kemampuan", "Send custom state event": "Kirim peristiwa status kustom", "Failed to send event!": "Gagal mengirimkan pertistiwa!", - "Doesn't look like valid JSON.": "Tidak terlihat seperti JSON yang valid.", + "Doesn't look like valid JSON.": "Tidak terlihat seperti JSON yang absah.", "Send custom room account data event": "Kirim peristiwa data akun ruangan kustom", "Send custom account data event": "Kirim peristiwa data akun kustom", "Room ID: %(roomId)s": "ID ruangan: %(roomId)s", @@ -3442,7 +3442,7 @@ "You're in": "Anda dalam", "You need to have the right permissions in order to share locations in this room.": "Anda harus mempunyai izin yang diperlukan untuk membagikan lokasi di ruangan ini.", "You don't have permission to share locations": "Anda tidak memiliki izin untuk membagikan lokasi", - "Messages in this chat will be end-to-end encrypted.": "Pesan di obrolan ini akan dienkripsi secara ujung-ke-ujung.", + "Messages in this chat will be end-to-end encrypted.": "Pesan di obrolan ini akan dienkripsi secara ujung ke ujung.", "Send your first message to invite to chat": "Kirim pesan pertama Anda untuk mengundang ke obrolan", "Favourite Messages (under active development)": "Pesan Favorit (dalam pengembangan aktif)", "Saved Items": "Item yang Tersimpan", @@ -3473,7 +3473,7 @@ "Find your co-workers": "Temukan rekan kerja Anda", "Secure messaging for work": "Perpesanan aman untuk berkerja", "Start your first chat": "Mulai obrolan pertama Anda", - "With free end-to-end encrypted messaging, and unlimited voice and video calls, %(brand)s is a great way to stay in touch.": "Dengan perpesanan terenkripsi ujung-ke-ujung gratis, dan panggilan suara & video tidak terbatas, %(brand)s adalah cara yang baik untuk tetap terhubung.", + "With free end-to-end encrypted messaging, and unlimited voice and video calls, %(brand)s is a great way to stay in touch.": "Dengan perpesanan terenkripsi ujung ke ujung gratis, dan panggilan suara & video tidak terbatas, %(brand)s adalah cara yang baik untuk tetap terhubung.", "Secure messaging for friends and family": "Perpesanan aman untuk teman dan keluarga", "We’d appreciate any feedback on how you’re finding Element.": "Kami akan menghargai masukan apa pun tentang bagaimana Anda menemukan Element.", "How are you finding Element so far?": "Bagaimana Anda menemukan Element sejauh ini?", @@ -3588,5 +3588,49 @@ "Turn off to disable notifications on all your devices and sessions": "Matikan untuk menonaktifkan notifikasi pada semua perangkat dan sesi Anda", "Enable notifications for this account": "Aktifkan notifikasi untuk akun ini", "Video call ended": "Panggilan video berakhir", - "%(name)s started a video call": "%(name)s memulai sebuah panggilan video" + "%(name)s started a video call": "%(name)s memulai sebuah panggilan video", + "URL": "URL", + "Version": "Versi", + "Application": "Aplikasi", + "Record the client name, version, and url to recognise sessions more easily in session manager": "Rekam nama, versi, dan URL klien untuk dapat mengenal sesi dengan lebih muda dalam pengelola sesi", + "%(brand)s is end-to-end encrypted, but is currently limited to smaller numbers of users.": "%(brand)s terenkripsi secara ujung ke ujung, tetapi saat ini terbatas jumlah penggunanya.", + "Room info": "Informasi ruangan", + "View chat timeline": "Tampilkan linimasa obrolan", + "Close call": "Tutup panggilan", + "Layout type": "Jenis tata letak", + "Spotlight": "Sorotan", + "Freedom": "Bebas", + "Video call (%(brand)s)": "Panggilan video (%(brand)s)", + "Unknown session type": "Jenis sesi tidak diketahui", + "Web session": "Sesi web", + "Mobile session": "Sesi ponsel", + "Desktop session": "Sesi desktop", + "Operating system": "Sistem operasi", + "Model": "Model", + "Client": "Klien", + "Call type": "Jenis panggilan", + "You do not have sufficient permissions to change this.": "Anda tidak memiliki izin untuk mengubah ini.", + "Enable %(brand)s as an additional calling option in this room": "Aktifkan %(brand)s sebagai opsi panggilan tambahan di ruangan ini", + "Start %(brand)s calls": "Mulai panggilan %(brand)s", + "Join %(brand)s calls": "Bergabung panggilan %(brand)s", + "Fill screen": "Penuhi layar", + "Sorry — this call is currently full": "Maaf — panggilan ini saat ini penuh", + "Video call started": "Panggilan video dimulai", + "Unknown room": "Ruangan yang tidak diketahui", + "Video call started in %(roomName)s. (not supported by this browser)": "Panggilan video dimulai di %(roomName)s. (tidak didukung oleh peramban ini)", + "Video call started in %(roomName)s.": "Panggilan video dimulai di %(roomName)s.", + "Wysiwyg composer (plain text mode coming soon) (under active development)": "Komposer WYSIWYG (mode teks biasa akan datang) (dalam pengembangan aktif)", + "Our new sessions manager provides better visibility of all your sessions, and greater control over them including the ability to remotely toggle push notifications.": "Pengelola sesi kami yang baru memberikan pengelihatan yang lebih baik pada semua sesi Anda, dan pengendalian yang lebih baik pada semua sesi, termasuk kemampuan untuk mensaklar notifikasi dorongan.", + "Have greater visibility and control over all your sessions.": "Miliki pengelihatan dan pengendalian yang lebih baik pada semua sesi Anda.", + "New session manager": "Pengelola sesi baru", + "Use new session manager": "Gunakan pengelola sesi baru", + "Sign out all other sessions": "Keluarkan semua sesi lain", + "resume voice broadcast": "lanjutkan siaran suara", + "pause voice broadcast": "jeda siaran suara", + "Underline": "Garis Bawah", + "Italic": "Miring", + "Try out the rich text editor (plain text mode coming soon)": "Coba editor teks kaya (mode teks biasa akan datang)", + "You have already joined this call from another device": "Anda telah bergabung ke panggilan ini dari perangkat lain", + "stop voice broadcast": "hentikan siaran suara", + "Notifications silenced": "Notifikasi dibisukan" } diff --git a/src/i18n/strings/is.json b/src/i18n/strings/is.json index 897b2bd040..faf880f098 100644 --- a/src/i18n/strings/is.json +++ b/src/i18n/strings/is.json @@ -420,9 +420,9 @@ "Share Link to User": "Deila Hlekk að Notanda", "You have verified this user. This user has verified all of their sessions.": "Þú hefur sannreynt þennan notanda. Þessi notandi hefur sannreynt öll tæki þeirra.", "This user has not verified all of their sessions.": "Þessi notandi hefur ekki sannreynt öll tæki þeirra.", - "%(count)s verified sessions|one": "1 sannreynt tæki", - "%(count)s verified sessions|other": "%(count)s sannreyn tæki", - "Hide verified sessions": "Fela sannreynd tæki", + "%(count)s verified sessions|one": "1 sannreynd seta", + "%(count)s verified sessions|other": "%(count)s sannreyndar setur", + "Hide verified sessions": "Fela sannreyndar setur", "Remove recent messages": "Fjarlægja nýleg skilaboð", "Remove recent messages by %(user)s": "Fjarlægja nýleg skilaboð frá %(user)s", "Messages in this room are not end-to-end encrypted.": "Skilaboð í þessari spjallrás eru ekki enda-í-enda dulrituð.", @@ -1094,7 +1094,7 @@ "Topic: %(topic)s ": "Umfjöllunarefni: %(topic)s ", "Insert link": "Setja inn tengil", "Code block": "Kóðablokk", - "Poll": "Athuga", + "Poll": "Könnun", "Voice Message": "Talskilaboð", "Sticker": "Límmerki", "Hide stickers": "Fela límmerki", @@ -2971,7 +2971,7 @@ "Sends the given message with hearts": "Sendir skilaboðin með hjörtum", "Enable hardware acceleration": "Virkja vélbúnaðarhröðun", "Enable Markdown": "Virkja Markdown", - "Live Location Sharing (temporary implementation: locations persist in room history)": "Deiling staðsetninga í rautíma(tímabundið haldast staðsetningar í ferli spjallrása)", + "Live Location Sharing (temporary implementation: locations persist in room history)": "Deiling staðsetninga í rautíma (tímabundið haldast staðsetningar í ferli spjallrása)", "Location sharing - pin drop": "Deiling staðsetninga - festipinni", "To leave, return to this page and use the “%(leaveTheBeta)s” button.": "Til að hætta kemurðu einfaldlega aftur á þessa síðu og notar “%(leaveTheBeta)s” hnappinn.", "Use “%(replyInThread)s” when hovering over a message.": "Notaðu “%(replyInThread)s” þegar bendillinn svífur yfir skilaboðum.", @@ -3131,5 +3131,99 @@ "Inviting %(user1)s and %(user2)s": "Býð %(user1)s og %(user2)s", "%(user)s and %(count)s others|one": "%(user)s og 1 annar", "%(user)s and %(count)s others|other": "%(user)s og %(count)s til viðbótar", - "%(user1)s and %(user2)s": "%(user1)s og %(user2)s" + "%(user1)s and %(user2)s": "%(user1)s og %(user2)s", + "%(downloadButton)s or %(copyButton)s": "%(downloadButton)s eða %(copyButton)s", + "Did not receive it? Resend it": "Fékkstu hann ekki? Endursenda hann", + "Unread email icon": "Táknmynd fyrir ólesinn tölvupóst", + "No live locations": "Engar staðsetningar í rauntíma", + "Live location error": "Villa í rauntímastaðsetningu", + "Live location ended": "Staðsetningu í rauntíma lauk", + "Loading live location...": "Hleð inn rauntímastaðsetningu...", + "Interactively verify by emoji": "Sannprófa gagnvirkt með táknmyndum", + "Manually verify by text": "Sannreyna handvirkt með texta", + "%(featureName)s Beta feedback": "%(featureName)s beta umsögn", + "Show: %(instance)s rooms (%(server)s)": "Sýna: %(instance)s spjallrásir (%(server)s)", + "Who will you chat to the most?": "Við hverja muntu helst spjalla?", + "You're in": "Þú ert inni", + "Popout widget": "Sprettviðmótshluti", + "Un-maximise": "Ekki-hámarka", + "Live location sharing": "Deiling staðsetningar í rauntíma", + "View live location": "Skoða staðsetningu í rauntíma", + "%(name)s started a video call": "%(name)s hóf myndsímtal", + "To view %(roomName)s, you need an invite": "Til að skoða %(roomName)s þarftu boð", + "Ongoing call": "Símtal í gangi", + "Video call (Element Call)": "Myndsímtal (Element Call)", + "Video call (Jitsi)": "Myndsímtal (Jitsi)", + "Seen by %(count)s people|one": "Séð af %(count)s aðila", + "Seen by %(count)s people|other": "Séð af %(count)s aðilum", + "Send your first message to invite to chat": "Sendu fyrstu skilaboðin þín til að bjóða að spjalla", + "Security recommendations": "Ráðleggingar varðandi öryggi", + "%(selectedDeviceCount)s sessions selected": "%(selectedDeviceCount)s setur valdar", + "Filter devices": "Sía tæki", + "Inactive sessions": "Óvirkar setur", + "Verified sessions": "Sannreyndar setur", + "Sign out of this session": "Skrá út úr þessari setu", + "Receive push notifications on this session.": "Taka á móti ýti-tilkynningum á þessu tæki.", + "Toggle push notifications on this session.": "Víxla af/á ýti-tilkynningum á þessu tæki.", + "Download %(brand)s": "Sækja %(brand)s", + "Start a conversation with someone using their name or username (like ).": "Byrjaðu samtal með einhverjum með því að nota nafn viðkomandi eða notandanafn (eins og ).", + "Start a conversation with someone using their name, email address or username (like ).": "Byrjaðu samtal með einhverjum með því að nota nafn viðkomandi, tölvupóstfang eða notandanafn (eins og ).", + "Use an identity server to invite by email. Manage in Settings.": "Notaðu auðkennisþjón til að geta boðið með tölvupósti. Sýslaðu með þetta í stillingunum.", + "Something went wrong trying to invite the users.": "Eitthvað fór úrskeiðis við að bjóða notendunum.", + "Upgrade to %(hostSignupBrand)s": "Uppfæra í %(hostSignupBrand)s", + "Size can only be a number between %(min)s MB and %(max)s MB": "Stærð getur aðeins verið tala á milli %(min)s og %(max)s", + "The poll has ended. Top answer: %(topAnswer)s": "Könnuninni er lokið. Efsta svarið: %(topAnswer)s", + "Send custom timeline event": "Senda sérsniðinn tímalínuatburð", + "You will no longer be able to log in": "Þú munt ekki lengur geta skráð þig inn", + "You will not be able to reactivate your account": "Þú munt ekki geta endurvirkjað aðganginn þinn", + "Google Play and the Google Play logo are trademarks of Google LLC.": "Google Play og Google Play táknmerkið eru vörumerki í eigu Google LLC.", + "App Store® and the Apple logo® are trademarks of Apple Inc.": "App Store® og Apple logo® eru vörumerki í eigu Apple Inc.", + "%(severalUsers)ssent %(count)s hidden messages|one": "%(severalUsers)ssendu falin skilaboð", + "%(severalUsers)ssent %(count)s hidden messages|other": "%(severalUsers)ssendu %(count)s falin skilaboð", + "%(severalUsers)schanged the pinned messages for the room %(count)s times|one": "%(severalUsers)sbreyttu föstum skilaboðum fyrir spjallrásina", + "%(severalUsers)schanged the pinned messages for the room %(count)s times|other": "%(severalUsers)sbreyttu föstum skilaboðum fyrir spjallrásina %(count)s sinnum", + "%(oneUser)srejected their invitation %(count)s times|other": "%(oneUser)shafnaði boði sínu %(count)s sinnum", + "%(severalUsers)srejected their invitations %(count)s times|other": "%(severalUsers)shöfnuðu boðum þeirra %(count)s sinnum", + "Using this widget may share data with %(widgetDomain)s.": "Að nota þennan viðmótshluta gæti deilt gögnum með %(widgetDomain)s.", + "Any of the following data may be shared:": "Eftirfarandi gögnum gæti verið deilt:", + "You don't have permission to share locations": "Þú hefur ekki heimildir til að deila staðsetningum", + "Enable live location sharing": "Virkja deilingu rauntímastaðsetninga", + "Messages in this chat will be end-to-end encrypted.": "Skilaboð í þessu spjalli verða enda-í-enda dulrituð.", + "%(qrCode)s or %(emojiCompare)s": "%(qrCode)s eða %(emojiCompare)s", + "If you can't find the room you're looking for, ask for an invite or create a new room.": "Ef þú finnur ekki spjallrásina sem þú leitar að, skaltu biðja um boð eða útbúa nýja spjallrás.", + "Try different words or check for typos. Some results may not be visible as they're private and you need an invite to join them.": "Prófaðu önnur orð og aðgættu stafsetningu. Sumar niðurstöður gætu verið faldar þar sem þær eru einkamál og þá þarftu boð til að geta séð þær.", + "Joining the beta will reload %(brand)s.": "Ef tekið er þátt í beta-prófunum verður %(brand)s endurhlaðið.", + "Results not as expected? Please give feedback.": "Eru leitarniðurstöður ekki eins og þú áttir von á? Láttu okkur vita.", + "If you can't find the room you're looking for, ask for an invite or create a new room.": "Ef þú finnur ekki spjallrásina sem þú leitar að, skaltu biðja um boð eða útbúa nýja spjallrás.", + "If you can't see who you're looking for, send them your invite link.": "Ef þú sérð ekki þann sem þú ert að leita að, ættirðu að senda viðkomandi boðstengil.", + "Some results may be hidden for privacy": "Sumar niðurstöður gætu verið faldar þar sem þær eru einkamál", + "Add widgets, bridges & bots": "Bæta við viðmótshlutum, brúm og vélmennum", + "Edit widgets, bridges & bots": "Breyta viðmótshlutum, brúm og vélmennum", + "Close this widget to view it in this panel": "Lokaðu þessum viðmótshluta til að sjá hann á þessu spjaldi", + "Unpin this widget to view it in this panel": "Losaðu þennan viðmótshluta til að sjá hann á þessu spjaldi", + "Explore public spaces in the new search dialog": "Kannaðu opimber svæði í nýja leitarglugganum", + "Yes, the chat timeline is displayed alongside the video.": "Já, tímalína spjallsins birtist við hlið myndmerkisins.", + "Can I use text chat alongside the video call?": "Get ég notað textaspjall samhliða myndsímtali?", + "Use the “+” button in the room section of the left panel.": "Notaðu “+” hnappinn í spjallrásarhluta hliðarspjaldsins vinstra megin.", + "Video rooms are always-on VoIP channels embedded within a room in %(brand)s.": "Myndspjallrásir eru sívirkar VoIP-rásir sem ívafðar eru í spjallrásir innan %(brand)s.", + "A new way to chat over voice and video in %(brand)s.": "Ný leið til að spjalla með tali og myndmerki í %(brand)s.", + "How are you finding %(brand)s so far?": "Hvernig líst þér á %(brand)s hingað til?", + "Turn on notifications": "Kveikja á tilkynningum", + "Make sure people know it’s really you": "Láttu fólk vita að þetta sért þú", + "Set up your profile": "Settu upp notandasniðið þitt", + "Find and invite your co-workers": "Finndu og bjóddu samstarfsaðilum þínum", + "Do you want to enable threads anyway?": "Viltu samt virkja spjallþræði?", + "Partial Support for Threads": "Hlutastuðningur við þræði", + "Use new session manager (under active development)": "Ný setustýring (í virkri þróun)", + "Voice broadcast (under active development)": "Útvörpun tals (í virkri þróun)", + "Favourite Messages (under active development)": "Eftirlætisskilaboð (í virkri þróun)", + "Show HTML representation of room topics": "Birta HTML-framsetningu umfjöllunarefnis spjallrása", + "You were disconnected from the call. (Error: %(message)s)": "Þú varst aftengd/ur frá samtalinu. (Villa: %(message)s)", + "Reset bearing to north": "Frumstilla stefnu á norður", + "Toggle attribution": "Víxla tilvísun af/á", + "Unable to look up room ID from server": "Get ekki flett upp auðkenni spjallrásar á þjóninum", + "In %(spaceName)s and %(count)s other spaces.|one": "Á %(spaceName)s og %(count)s svæði til viðbótar.", + "In %(spaceName)s and %(count)s other spaces.|zero": "Á svæðinu %(spaceName)s.", + "In %(spaceName)s and %(count)s other spaces.|other": "Á %(spaceName)s og %(count)s svæðum til viðbótar.", + "In spaces %(space1Name)s and %(space2Name)s.": "Á svæðunum %(space1Name)s og %(space2Name)s." } diff --git a/src/i18n/strings/it.json b/src/i18n/strings/it.json index a5c166a246..08ea1f9234 100644 --- a/src/i18n/strings/it.json +++ b/src/i18n/strings/it.json @@ -1634,7 +1634,7 @@ "Looks good!": "Sembra giusta!", "Use custom size": "Usa dimensione personalizzata", "Hey you. You're the best!": "Ehi tu. Sei il migliore!", - "Message layout": "Layout messaggio", + "Message layout": "Disposizione del messaggio", "Modern": "Moderno", "Use a system font": "Usa un carattere di sistema", "System font name": "Nome carattere di sistema", @@ -3589,5 +3589,51 @@ "Enable notifications for this account": "Attiva le notifiche per questo account", "Video call ended": "Videochiamata terminata", "%(name)s started a video call": "%(name)s ha iniziato una videochiamata", - "Record the client name, version, and url to recognise sessions more easily in session manager": "Registra il nome, la versione e l'url del client per riconoscere le sessioni più facilmente nel gestore di sessioni" + "Record the client name, version, and url to recognise sessions more easily in session manager": "Registra il nome, la versione e l'url del client per riconoscere le sessioni più facilmente nel gestore di sessioni", + "URL": "URL", + "Version": "Versione", + "Application": "Applicazione", + "Unknown session type": "Tipo di sessione sconosciuta", + "Web session": "Sessione web", + "Mobile session": "Sessione mobile", + "Desktop session": "Sessione desktop", + "Video call started in %(roomName)s. (not supported by this browser)": "Videochiamata iniziata in %(roomName)s. (non supportata da questo browser)", + "Video call started in %(roomName)s.": "Videochiamata iniziata in %(roomName)s.", + "Room info": "Info stanza", + "View chat timeline": "Vedi linea temporale chat", + "Close call": "Chiudi chiamata", + "Layout type": "Tipo di disposizione", + "Spotlight": "Riflettore", + "Freedom": "Libertà", + "Operating system": "Sistema operativo", + "Model": "Modello", + "Client": "Client", + "Fill screen": "Riempi schermo", + "Video call started": "Videochiamata iniziata", + "Unknown room": "Stanza sconosciuta", + "Video call (%(brand)s)": "Videochiamata (%(brand)s)", + "Call type": "Tipo chiamata", + "You do not have sufficient permissions to change this.": "Non hai autorizzazioni sufficienti per cambiarlo.", + "%(brand)s is end-to-end encrypted, but is currently limited to smaller numbers of users.": "%(brand)s è crittografato end-to-end, ma attualmente è limitato ad un minore numero di utenti.", + "Enable %(brand)s as an additional calling option in this room": "Attiva %(brand)s come opzione di chiamata aggiuntiva in questa stanza", + "Join %(brand)s calls": "Entra in chiamate di %(brand)s", + "Start %(brand)s calls": "Inizia chiamate di %(brand)s", + "Sorry — this call is currently full": "Spiacenti — questa chiamata è piena", + "Wysiwyg composer (plain text mode coming soon) (under active development)": "Compositore wysiwyg (modalità a testo semplice in arrivo) (in sviluppo attivo)", + "Sign out all other sessions": "Disconnetti tutte le altre sessioni", + "Our new sessions manager provides better visibility of all your sessions, and greater control over them including the ability to remotely toggle push notifications.": "Il nostro nuovo gestore di sessioni offre una migliore visibilità e un maggiore controllo sulle tue sessioni, inclusa la possibilità di attivare/disattivare da remoto le notifiche push.", + "Have greater visibility and control over all your sessions.": "Maggiore visibilità e controllo su tutte le tue sessioni.", + "New session manager": "Nuovo gestore di sessioni", + "Use new session manager": "Usa nuovo gestore di sessioni", + "Underline": "Sottolineato", + "Italic": "Corsivo", + "Try out the rich text editor (plain text mode coming soon)": "Prova l'editor in rich text (il testo semplice è in arrivo)", + "resume voice broadcast": "riprendi broadcast voce", + "pause voice broadcast": "sospendi broadcast voce", + "You have already joined this call from another device": "Sei già in questa chiamata in un altro dispositivo", + "Notifications silenced": "Notifiche silenziose", + "stop voice broadcast": "ferma broadcast voce", + "Yes, stop broadcast": "Sì, ferma il broadcast", + "Are you sure you want to stop your live broadcast?This will end the broadcast and the full recording will be available in the room.": "Vuoi davvero fermare il tuo broadcast in diretta? Verrà terminato il broadcast e la registrazione completa sarà disponibile nella stanza.", + "Stop live broadcasting?": "Fermare il broadcast in diretta?" } diff --git a/src/i18n/strings/lb.json b/src/i18n/strings/lb.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/src/i18n/strings/lb.json @@ -0,0 +1 @@ +{} diff --git a/src/i18n/strings/pt_BR.json b/src/i18n/strings/pt_BR.json index 9690353559..6b1bc3c576 100644 --- a/src/i18n/strings/pt_BR.json +++ b/src/i18n/strings/pt_BR.json @@ -2828,5 +2828,86 @@ "Inviting %(user1)s and %(user2)s": "Convidando %(user1)s e %(user2)s", "%(user1)s and %(user2)s": "%(user1)s e %(user2)s", "%(value)sh": "%(value)sh", - "%(value)sd": "%(value)sd" + "%(value)sd": "%(value)sd", + "Live": "Ao vivo", + "%(senderName)s has ended a poll": "%(senderName)s encerrou uma enquete", + "%(senderName)s has started a poll - %(pollQuestion)s": "%(senderName)s começou uma enquete - %(pollQuestion)s", + "%(senderName)s has shared their location": "%(senderName)s compartilhou sua localização", + "%(senderName)s removed %(targetName)s": "%(senderName)s removeu %(targetName)s", + "%(senderName)s removed %(targetName)s: %(reason)s": "%(senderName)s removeu %(targetName)s: %(reason)s", + "Video call started in %(roomName)s. (not supported by this browser)": "Chamada de vídeo iniciada em %(roomName)s. (não compatível com este navegador)", + "Video call started in %(roomName)s.": "Chamada de vídeo iniciada em %(roomName)s.", + "No active call in this room": "Nenhuma chamada ativa nesta sala", + "Unable to find Matrix ID for phone number": "Não foi possível encontrar o ID Matrix pelo número de telefone", + "Unknown (user, session) pair: (%(userId)s, %(deviceId)s)": "Par desconhecido (usuário, sessão): (%(userId)s, %(deviceId)s)", + "Command failed: Unable to find room (%(roomId)s": "Falha no comando: Não foi possível encontrar sala %(roomId)s", + "Removes user with given id from this room": "Remove desta sala o usuário com o ID determinado", + "Unrecognised room address: %(roomAlias)s": "Endereço da sala não reconhecido: %(roomAlias)s", + "Failed to get room topic: Unable to find room (%(roomId)s": "Falha ao obter tópico da sala: Não foi possível encontrar a sala %(roomId)s", + "We were unable to understand the given date (%(inputDate)s). Try using the format YYYY-MM-DD.": "Não foi possível entender a data fornecida (%(inputDate)s). Tente usando o formato AAAA-MM-DD.", + "You need to be able to kick users to do that.": "Você precisa ter permissão de expulsar usuários para fazer isso.", + "Failed to invite users to %(roomName)s": "Falha ao convidar usuários para %(roomName)s", + "Inviting %(user)s and %(count)s others|one": "Convidando %(user)s e 1 outro", + "Inviting %(user)s and %(count)s others|other": "Convidando %(user)s e %(count)s outros", + "%(user)s and %(count)s others|one": "%(user)s e 1 outro", + "%(user)s and %(count)s others|other": "%(user)s e %(count)s outros", + "You were disconnected from the call. (Error: %(message)s)": "Você foi desconectado da chamada. (Erro: %(message)s)", + "Remove messages sent by me": "", + "Welcome": "Boas-vindas", + "%(count)s people joined|one": "%(count)s pessoa entrou", + "%(count)s people joined|other": "%(count)s pessoas entraram", + "Audio devices": "Dispositivos de áudio", + "Unmute microphone": "Habilitar microfone", + "Mute microphone": "Silenciar microfone", + "Video devices": "Dispositivos de vídeo", + "Make sure people know it’s really you": "Certifique-se de que as pessoas saibam que é realmente você", + "Set up your profile": "Configure seu perfil", + "Video rooms": "Salas de vídeo", + "Room members": "Membros da sala", + "Back to chat": "Voltar ao chat", + "Connection lost": "Conexão perdida", + "Failed to join": "Falha ao entrar", + "The person who invited you has already left, or their server is offline.": "A pessoa que o convidou já saiu ou o servidor dela está offline.", + "The person who invited you has already left.": "A pessoa que o convidou já saiu.", + "There was an error joining.": "Ocorreu um erro ao entrar.", + "Video": "Vídeo", + "Video call started": "Videochamada iniciada", + "Unknown room": "Sala desconhecida", + "Location not available": "Local não disponível", + "Find my location": "Encontrar minha localização", + "Exit fullscreen": "Sair da tela cheia", + "Enter fullscreen": "Entrar em tela cheia", + "User may or may not exist": "O usuário pode ou não existir", + "User is already invited to the room": "O usuário já foi convidado para a sala", + "User is already invited to the space": "O usuário já foi convidado para o espaço", + "You do not have permission to invite people to this space.": "Você não tem permissão para convidar pessoas para este espaço.", + "In %(spaceName)s and %(count)s other spaces.|one": "Em %(spaceName)s e %(count)s outro espaço.", + "In %(spaceName)s and %(count)s other spaces.|zero": "No espaço %(spaceName)s.", + "In %(spaceName)s and %(count)s other spaces.|other": "Em %(spaceName)s e %(count)s outros espaços.", + "In spaces %(space1Name)s and %(space2Name)s.": "Nos espaços %(space1Name)s e %(space2Name)s.", + "Empty room (was %(oldName)s)": "Sala vazia (era %(oldName)s)", + "Unknown session type": "Tipo de sessão desconhecido", + "Mobile session": "Sessão móvel", + "Desktop session": "Sessão desktop", + "Web session": "Sessão web", + "Sign out of this session": "Sair desta sessão", + "Operating system": "Sistema operacional", + "Model": "Modelo", + "URL": "URL", + "Version": "Versão", + "Application": "Aplicação", + "Last activity": "Última atividade", + "Client": "Cliente", + "Confirm signing out these devices|other": "Confirme a saída destes dispositivos", + "Confirm signing out these devices|one": "Confirme a saída deste dispositivo", + "Sign out all other sessions": "Sair de todas as outras sessões", + "Current session": "Sessão atual", + "Developer tools": "Ferramentas de desenvolvimento", + "Welcome to %(brand)s": "Boas-vindas ao", + "Processing event %(number)s out of %(total)s": "Processando evento %(number)s de %(total)s", + "Exported %(count)s events in %(seconds)s seconds|one": "%(count)s evento exportado em %(seconds)s segundos", + "Exported %(count)s events in %(seconds)s seconds|other": "%(count)s eventos exportados em %(seconds)s segundos", + "Creating output...": "Criando resultado...", + "Fetching events...": "Buscando eventos...", + "Starting export process...": "Iniciando processo de exportação..." } diff --git a/src/i18n/strings/ru.json b/src/i18n/strings/ru.json index 6fb881f0f8..6c7d506816 100644 --- a/src/i18n/strings/ru.json +++ b/src/i18n/strings/ru.json @@ -40,7 +40,7 @@ "Logout": "Выйти", "Low priority": "Маловажные", "Moderator": "Модератор", - "Name": "Имя", + "Name": "Название", "New passwords must match each other.": "Новые пароли должны совпадать.", "Notifications": "Уведомления", "": "<не поддерживается>", @@ -187,7 +187,7 @@ "You may need to manually permit %(brand)s to access your microphone/webcam": "Вам необходимо предоставить %(brand)s доступ к микрофону или веб-камере вручную", "Anyone": "Все", "Are you sure you want to leave the room '%(roomName)s'?": "Уверены, что хотите покинуть '%(roomName)s'?", - "%(senderDisplayName)s removed the room name.": "%(senderDisplayName)s удалил(а) имя комнаты.", + "%(senderDisplayName)s removed the room name.": "%(senderDisplayName)s удалил(а) название комнаты.", "Custom level": "Специальные права", "Email address": "Электронная почта", "Error decrypting attachment": "Ошибка расшифровки вложения", @@ -195,7 +195,7 @@ "Import": "Импорт", "Incorrect username and/or password.": "Неверное имя пользователя и/или пароль.", "Invalid file%(extra)s": "Недопустимый файл%(extra)s", - "Invited": "Приглашен", + "Invited": "Приглашены", "Jump to first unread message.": "Перейти к первому непрочитанному сообщению.", "Privileged Users": "Привилегированные пользователи", "Register": "Зарегистрироваться", @@ -613,7 +613,7 @@ "Timeline": "Лента сообщений", "Autocomplete delay (ms)": "Задержка автодополнения (мс)", "Roles & Permissions": "Роли и права", - "Security & Privacy": "Безопасность и приватность", + "Security & Privacy": "Безопасность", "Encryption": "Шифрование", "Encrypted": "Зашифровано", "Ignored users": "Игнорируемые пользователи", @@ -1087,7 +1087,7 @@ "%(count)s unread messages including mentions.|other": "%(count)s непрочитанных сообщения(-й), включая упоминания.", "Failed to deactivate user": "Не удалось деактивировать пользователя", "This client does not support end-to-end encryption.": "Этот клиент не поддерживает сквозное шифрование.", - "Messages in this room are not end-to-end encrypted.": "Сообщения в этой комнате не шифруются сквозным шифрованием.", + "Messages in this room are not end-to-end encrypted.": "Сообщения в этой комнате не защищены сквозным шифрованием.", "Please create a new issue on GitHub so that we can investigate this bug.": "Пожалуйста, создайте новую проблему/вопрос на GitHub, чтобы мы могли расследовать эту ошибку.", "Use an identity server to invite by email. Use the default (%(defaultIdentityServerName)s) or manage in Settings.": "Используйте идентификационный сервер для приглашения по электронной почте. Используйте значение по умолчанию (%(defaultIdentityServerName)s) или управляйте в Настройках.", "Use an identity server to invite by email. Manage in Settings.": "Используйте идентификационный сервер для приглашения по электронной почте. Управление в Настройки.", @@ -1352,7 +1352,7 @@ "Waiting for %(displayName)s to accept…": "Ожидание принятия от %(displayName)s…", "Accepting…": "Принятие…", "Start Verification": "Начать проверку", - "Messages in this room are end-to-end encrypted.": "Сообщения в этой комнате зашифрованы сквозным шифрованием.", + "Messages in this room are end-to-end encrypted.": "Сообщения в этой комнате защищены сквозным шифрованием.", "Verify User": "Подтвердить пользователя", "Your messages are not secure": "Ваши сообщения не защищены", "Your homeserver": "Ваш домашний сервер", @@ -1516,7 +1516,7 @@ "Favourited": "В избранном", "Room options": "Настройки комнаты", "Welcome to %(appName)s": "Добро пожаловать в %(appName)s", - "Create a Group Chat": "Создать групповой чат", + "Create a Group Chat": "Создать комнату", "All settings": "Все настройки", "Feedback": "Отзыв", "* %(senderName)s %(emote)s": "* %(senderName)s %(emote)s", @@ -1615,8 +1615,8 @@ "Wrong file type": "Неправильный тип файла", "Looks good!": "Выглядит неплохо!", "Security Phrase": "Секретная фраза", - "Security Key": "Ключ безопасности", - "Use your Security Key to continue.": "Чтобы продолжить, используйте свой ключ безопасности.", + "Security Key": "Бумажный ключ", + "Use your Security Key to continue.": "Чтобы продолжить, используйте свой бумажный ключ.", "Restoring keys from backup": "Восстановление ключей из резервной копии", "Fetching keys from server...": "Получение ключей с сервера...", "%(completed)s of %(total)s keys restored": "%(completed)s из %(total)s ключей восстановлено", @@ -2753,7 +2753,7 @@ "Resetting your verification keys cannot be undone. After resetting, you won't have access to old encrypted messages, and any friends who have previously verified you will see security warnings until you re-verify with them.": "Сброс ключей проверки нельзя отменить. После сброса вы не сможете получить доступ к старым зашифрованным сообщениям, а друзья, которые ранее проверили вас, будут видеть предупреждения о безопасности, пока вы не пройдете повторную проверку.", "Skip verification for now": "Пока пропустить проверку", "I'll verify later": "Я проверю позже", - "Verify with Security Key": "Заверить с помощью ключа безопасности", + "Verify with Security Key": "Заверить бумажным ключом", "Verify with Security Key or Phrase": "Проверка с помощью ключа безопасности или фразы", "Proceed with reset": "Выполнить сброс", "It looks like you don't have a Security Key or any other devices you can verify against. This device will not be able to access old encrypted messages. In order to verify your identity on this device, you'll need to reset your verification keys.": "Похоже, у вас нет ключа шифрования, или каких-либо других устройств, которые вы можете проверить. Это устройство не сможет получить доступ к старым зашифрованным сообщениям. Чтобы подтвердить свою личность на этом устройстве, вам потребуется сбросить ключи подтверждения.", @@ -2827,11 +2827,11 @@ "We'll generate a Security Key for you to store somewhere safe, like a password manager or a safe.": "Мы создадим ключ безопасности для вас, чтобы вы могли хранить его в надежном месте, например, в менеджере паролей или сейфе.", "Regain access to your account and recover encryption keys stored in this session. Without them, you won't be able to read all of your secure messages in any session.": "Восстановите доступ к своей учетной записи и восстановите ключи шифрования, сохраненные в этом сеансе. Без них вы не сможете прочитать все свои защищенные сообщения в любой сессии.", "Without verifying, you won't have access to all your messages and may appear as untrusted to others.": "Без проверки вы не сможете получить доступ ко всем своим сообщениям и можете показаться другим людям недоверенным.", - "Your new device is now verified. Other users will see it as trusted.": "Теперь ваше новое устройство проверено. Другие пользователи будут видеть его как доверенное.", - "Your new device is now verified. It has access to your encrypted messages, and other users will see it as trusted.": "Теперь ваше новое устройство проверено. Оно имеет доступ к вашим зашифрованным сообщениям, и другие пользователи будут воспринимать его как доверенное.", + "Your new device is now verified. Other users will see it as trusted.": "Ваша новая сессия подтверждена. Другие пользователи будут воспринимать её как заверенную.", + "Your new device is now verified. It has access to your encrypted messages, and other users will see it as trusted.": "Ваша новая сессия подтверждена. Она имеет доступ к вашим зашифрованным сообщениям, и другие пользователи будут воспринимать её как заверенную.", "Verify with another device": "Заверить с помощью другого устройства", "Someone already has that username, please try another.": "У кого-то уже есть такое имя пользователя, пожалуйста, попробуйте другое.", - "Device verified": "Устройство заверено", + "Device verified": "Сессия заверена", "Verify this device": "Заверьте эту сессию", "Unable to verify this device": "Не удалось проверить это устройство", "Show all threads": "Показать все обсуждения", @@ -3547,12 +3547,50 @@ "%(downloadButton)s or %(copyButton)s": "%(downloadButton)s или %(copyButton)s", "Sign out of this session": "Выйти из этой сессии", "Please be aware that session names are also visible to people you communicate with": "Пожалуйста, имейте в виду, что названия сессий также видны людям, с которыми вы общаетесь", - "Push notifications": "Push-уведомления", + "Push notifications": "Уведомления", "Receive push notifications on this session.": "Получать push-уведомления в этой сессии.", "Toggle push notifications on this session.": "Push-уведомления для этой сессии.", "Enable notifications for this device": "Уведомления для этой сессии", "Enable notifications for this account": "Уведомления для этой учётной записи", "Turn off to disable notifications on all your devices and sessions": "Выключите, чтобы отключить уведомления во всех своих сессиях", "Failed to set pusher state": "Не удалось установить состояние push-службы", - "%(selectedDeviceCount)s sessions selected": "Выбрано сессий: %(selectedDeviceCount)s" + "%(selectedDeviceCount)s sessions selected": "Выбрано сессий: %(selectedDeviceCount)s", + "Application": "Приложение", + "Version": "Версия", + "URL": "URL-адрес", + "Client": "Клиент", + "Room info": "О комнате", + "Wysiwyg composer (plain text mode coming soon) (under active development)": "Редактор «Что видишь, то и получишь» (скоро появится режим обычного текста) (в активной разработке)", + "New session manager": "Новый менеджер сессий", + "Operating system": "Операционная система", + "Element Call video rooms": "Видеокомнаты Element Call", + "Video call (Jitsi)": "Видеозвонок (Jitsi)", + "Unknown session type": "Неизвестный тип сессии", + "Unknown room": "Неизвестная комната", + "View chat timeline": "Посмотреть ленту сообщений", + "Model": "Модель", + "Live": "В эфире", + "Video call (%(brand)s)": "Видеозвонок (%(brand)s)", + "Voice broadcast (under active development)": "Аудиовещание (в активной разработке)", + "Use new session manager": "Использовать новый менеджер сессий", + "Sign out all other sessions": "Выйти из всех других сессий", + "Voice broadcasts": "Аудиопередачи", + "Voice broadcast": "Аудиопередача", + "Have greater visibility and control over all your sessions.": "Получите наилучшую видимость и контроль над всеми вашими сеансами.", + "New group call experience": "Новый опыт группового вызова", + "Sliding Sync mode (under active development, cannot be disabled)": "Скользящий режим синхронизации (в активной разработке, не может быть отключен)", + "Video call started": "Начался видеозвонок", + "Video call started in %(roomName)s. (not supported by this browser)": "Видеовызов начался в %(roomName)s. (не поддерживается этим браузером)", + "Video call started in %(roomName)s.": "Видеовызов начался в %(roomName)s.", + "You need to be able to kick users to do that.": "Вы должны иметь возможность пинать пользователей, чтобы сделать это.", + "Inviting %(user)s and %(count)s others|one": "Приглашающий %(user)s и 1 других", + "Inviting %(user)s and %(count)s others|other": "Приглашение %(user)s и %(count)s других", + "Inviting %(user1)s and %(user2)s": "Приглашение %(user1)s и %(user2)s", + "Fill screen": "Заполнить экран", + "Sorry — this call is currently full": "Извините — этот вызов в настоящее время заполнен", + "Record the client name, version, and url to recognise sessions more easily in session manager": "Записывать название клиента, версию и URL-адрес для более лёгкого распознавания сессий в менеджере сессий", + "Our new sessions manager provides better visibility of all your sessions, and greater control over them including the ability to remotely toggle push notifications.": "Наш новый менеджер сеансов обеспечивает лучшую видимость всех ваших сеансов и больший контроль над ними, включая возможность удаленного переключения push-уведомлений.", + "Try out the rich text editor (plain text mode coming soon)": "Попробуйте визуальный редактор текста (скоро появится обычный текстовый режим)", + "Italic": "Курсив", + "Underline": "Подчёркнутый" } diff --git a/src/i18n/strings/sk.json b/src/i18n/strings/sk.json index 155fa55bdd..0a7596d94e 100644 --- a/src/i18n/strings/sk.json +++ b/src/i18n/strings/sk.json @@ -3504,7 +3504,7 @@ "Session details": "Podrobnosti o relácii", "IP address": "IP adresa", "Device": "Zariadenie", - "For best security, verify your sessions and sign out from any session that you don't recognize or use anymore.": "V záujme čo najlepšieho zabezpečenia overte svoje relácie a odhláste sa z každej relácie, ktorú už nepoznáte alebo nepoužívate.", + "For best security, verify your sessions and sign out from any session that you don't recognize or use anymore.": "V záujme čo najlepšieho zabezpečenia, overte svoje relácie a odhláste sa z každej relácie, ktorú už nepoznáte alebo nepoužívate.", "Other sessions": "Iné relácie", "Verify or sign out from this session for best security and reliability.": "V záujme čo najvyššej bezpečnosti a spoľahlivosti túto reláciu overte alebo sa z nej odhláste.", "Unverified session": "Neoverená relácia", @@ -3588,5 +3588,49 @@ "Enable notifications for this account": "Povoliť oznámenia pre tento účet", "%(selectedDeviceCount)s sessions selected": "%(selectedDeviceCount)s vybratých relácií", "Video call ended": "Videohovor ukončený", - "%(name)s started a video call": "%(name)s začal/a videohovor" + "%(name)s started a video call": "%(name)s začal/a videohovor", + "URL": "URL", + "Version": "Verzia", + "Application": "Aplikácia", + "Record the client name, version, and url to recognise sessions more easily in session manager": "Zaznamenať názov klienta, verziu a url, aby bolo možné ľahšie rozpoznať relácie v správcovi relácií", + "Unknown session type": "Neznámy typ relácie", + "Web session": "Webová relácia", + "Mobile session": "Relácia na mobile", + "Desktop session": "Relácia stolného počítača", + "Video call started": "Videohovor bol spustený", + "Unknown room": "Neznáma miestnosť", + "Video call started in %(roomName)s. (not supported by this browser)": "Videohovor sa začal v %(roomName)s. (nie je podporované v tomto prehliadači)", + "Video call started in %(roomName)s.": "Videohovor sa začal v %(roomName)s.", + "Close call": "Zavrieť hovor", + "Room info": "Informácie o miestnosti", + "View chat timeline": "Zobraziť časovú os konverzácie", + "Layout type": "Typ rozmiestnenia", + "Spotlight": "Stredobod", + "Freedom": "Sloboda", + "Video call (%(brand)s)": "Videohovor (%(brand)s)", + "Operating system": "Operačný systém", + "Model": "Model", + "Client": "Klient", + "Call type": "Typ hovoru", + "You do not have sufficient permissions to change this.": "Nemáte dostatočné oprávnenia na to, aby ste toto mohli zmeniť.", + "%(brand)s is end-to-end encrypted, but is currently limited to smaller numbers of users.": "%(brand)s je end-to-end šifrovaný, ale v súčasnosti je obmedzený pre menší počet používateľov.", + "Enable %(brand)s as an additional calling option in this room": "Zapnúť %(brand)s ako ďalšiu možnosť volania v tejto miestnosti", + "Join %(brand)s calls": "Pripojiť sa k %(brand)s hovorom", + "Start %(brand)s calls": "Spustiť %(brand)s hovory", + "Fill screen": "Vyplniť obrazovku", + "Sorry — this call is currently full": "Prepáčte — tento hovor je momentálne obsadený", + "Our new sessions manager provides better visibility of all your sessions, and greater control over them including the ability to remotely toggle push notifications.": "Náš nový správca relácií poskytuje lepší prehľad o všetkých vašich reláciách a lepšiu kontrolu nad nimi vrátane možnosti vzdialene prepínať push oznámenia.", + "Have greater visibility and control over all your sessions.": "Majte lepší prehľad a kontrolu nad všetkými reláciami.", + "New session manager": "Nový správca relácií", + "Use new session manager": "Použiť nového správcu relácií", + "Wysiwyg composer (plain text mode coming soon) (under active development)": "Wysiwyg composer (textový režim už čoskoro) (v štádiu aktívneho vývoja)", + "Sign out all other sessions": "Odhlásenie zo všetkých ostatných relácií", + "Underline": "Podčiarknuté", + "Italic": "Kurzíva", + "You have already joined this call from another device": "K tomuto hovoru ste sa už pripojili z iného zariadenia", + "Try out the rich text editor (plain text mode coming soon)": "Vyskúšajte rozšírený textový editor (čistý textový režim sa objaví čoskoro)", + "stop voice broadcast": "zastaviť hlasové vysielanie", + "resume voice broadcast": "obnoviť hlasové vysielanie", + "pause voice broadcast": "pozastaviť hlasové vysielanie", + "Notifications silenced": "Oznámenia stlmené" } diff --git a/src/i18n/strings/uk.json b/src/i18n/strings/uk.json index 3e978a7869..04b1c056e9 100644 --- a/src/i18n/strings/uk.json +++ b/src/i18n/strings/uk.json @@ -213,7 +213,7 @@ "%(senderName)s removed the main address for this room.": "%(senderName)s вилучає основу адресу цієї кімнати.", "Someone": "Хтось", "%(senderName)s sent an invitation to %(targetDisplayName)s to join the room.": "%(senderName)s надіслав(-ла) запрошення %(targetDisplayName)s приєднатися до кімнати.", - "Default": "Типово", + "Default": "Типовий", "%(senderName)s made future room history visible to all room members, from the point they are invited.": "%(senderName)s зробив(-ла) майбутню історію кімнати видимою для всіх учасників з моменту, коли вони приєдналися.", "%(senderName)s made future room history visible to all room members, from the point they joined.": "%(senderName)s робить майбутню історію кімнати видимою для всіх учасників від часу їхнього приєднання.", "%(senderName)s made future room history visible to all room members.": "%(senderName)s зробив(-ла) майбутню історію видимою для всіх учасників кімнати.", @@ -2162,7 +2162,7 @@ "Feeling experimental? Labs are the best way to get things early, test out new features and help shape them before they actually launch. Learn more.": "Почуваєтесь допитливо? Лабораторія дає змогу отримувати нову функціональність раніше всіх, випробовувати й допомагати допрацьовувати її перед запуском. Докладніше.", "Render LaTeX maths in messages": "Форматувати LaTeX-формули в повідомленнях", "Share anonymous data to help us identify issues. Nothing personal. No third parties. Learn More": "Збір анонімних даних дає нам змогу дізнаватися про збої. Жодних особистих даних. Жодних третіх сторін. Докладніше", - "Low bandwidth mode (requires compatible homeserver)": "Заощаджувати трафік (потрібен сумісний сервер)", + "Low bandwidth mode (requires compatible homeserver)": "Режим низької пропускної здатності (потрібен сумісний домашній сервер)", "Developer": "Розробка", "Moderation": "Модерування", "Experimental": "Експериментально", @@ -3570,7 +3570,7 @@ "Voice broadcast": "Голосове мовлення", "Voice broadcast (under active development)": "Голосове мовлення (в активній розробці)", "Element Call video rooms": "Відео кімнати Element Call", - "Voice broadcasts": "Голосові передачі", + "Voice broadcasts": "Голосове мовлення", "You do not have permission to start voice calls": "У вас немає дозволу розпочинати голосові виклики", "There's no one here to call": "Тут немає кого викликати", "You do not have permission to start video calls": "У вас немає дозволу розпочинати відеовиклики", @@ -3588,5 +3588,49 @@ "Enable notifications for this account": "Увімкнути сповіщення для цього облікового запису", "Video call ended": "Відеовиклик завершено", "%(name)s started a video call": "%(name)s розпочинає відеовиклик", - "%(selectedDeviceCount)s sessions selected": "Вибрано %(selectedDeviceCount)s сеансів" + "%(selectedDeviceCount)s sessions selected": "Вибрано %(selectedDeviceCount)s сеансів", + "URL": "URL", + "Version": "Версія", + "Application": "Застосунок", + "Record the client name, version, and url to recognise sessions more easily in session manager": "Записуйте назву клієнта, версію та URL-адресу, щоб легше розпізнавати сеанси в менеджері сеансів", + "Unknown session type": "Невідомий тип сеансу", + "Web session": "Сеанс у браузері", + "Mobile session": "Сеанс на мобільному", + "Desktop session": "Сеанс на комп'ютері", + "Video call started": "Відеовиклик розпочато", + "Unknown room": "Невідома кімната", + "Video call started in %(roomName)s. (not supported by this browser)": "Відеовиклик розпочато о %(roomName)s. (не підтримується цим браузером)", + "Video call started in %(roomName)s.": "Відеовиклик розпочато о %(roomName)s.", + "Room info": "Відомості про кімнату", + "View chat timeline": "Переглянути стрічку бесіди", + "Close call": "Закрити виклик", + "Layout type": "Тип макета", + "Spotlight": "У фокусі", + "Freedom": "Свобода", + "Operating system": "Операційна система", + "Model": "Модель", + "Client": "Клієнт", + "Fill screen": "Заповнити екран", + "Video call (%(brand)s)": "Відеовиклик (%(brand)s)", + "Call type": "Тип викликів", + "You do not have sufficient permissions to change this.": "Ви не маєте достатніх повноважень, щоб змінити це.", + "%(brand)s is end-to-end encrypted, but is currently limited to smaller numbers of users.": "%(brand)s наскрізно зашифровано, але наразі обмежений меншою кількістю користувачів.", + "Enable %(brand)s as an additional calling option in this room": "Увімкнути %(brand)s додатковою опцією викликів у цій кімнаті", + "Join %(brand)s calls": "Приєднатися до %(brand)s викликів", + "Start %(brand)s calls": "Розпочати %(brand)s викликів", + "Sorry — this call is currently full": "Перепрошуємо, цей виклик заповнено", + "Wysiwyg composer (plain text mode coming soon) (under active development)": "Редактор Wysiwyg (скоро з'явиться режим звичайного тексту) (в активній розробці)", + "Our new sessions manager provides better visibility of all your sessions, and greater control over them including the ability to remotely toggle push notifications.": "Наш новий менеджер сеансів забезпечує кращу видимість всіх ваших сеансів і більший контроль над ними, зокрема можливість віддаленого перемикання push-сповіщень.", + "Have greater visibility and control over all your sessions.": "Майте кращу видимість і контроль над усіма вашими сеансами.", + "New session manager": "Новий менеджер сеансів", + "Use new session manager": "Використовувати новий менеджер сеансів", + "Sign out all other sessions": "Вийти з усіх інших сеансів", + "Underline": "Підкреслений", + "Italic": "Курсив", + "Try out the rich text editor (plain text mode coming soon)": "Спробуйте розширений текстовий редактор (незабаром з'явиться режим звичайного тексту)", + "resume voice broadcast": "поновити голосове мовлення", + "pause voice broadcast": "призупинити голосове мовлення", + "You have already joined this call from another device": "Ви вже приєдналися до цього виклику з іншого пристрою", + "stop voice broadcast": "припинити голосове мовлення", + "Notifications silenced": "Сповіщення стишено" } diff --git a/src/i18n/strings/zh_Hant.json b/src/i18n/strings/zh_Hant.json index 67a38c5d99..68647b9485 100644 --- a/src/i18n/strings/zh_Hant.json +++ b/src/i18n/strings/zh_Hant.json @@ -3588,5 +3588,49 @@ "Enable notifications for this account": "為此帳號啟用通知", "%(selectedDeviceCount)s sessions selected": "已選取 %(selectedDeviceCount)s 個工作階段", "Video call ended": "視訊通話已結束", - "%(name)s started a video call": "%(name)s 開始了視訊通話" + "%(name)s started a video call": "%(name)s 開始了視訊通話", + "URL": "URL", + "Version": "版本", + "Application": "應用程式", + "Record the client name, version, and url to recognise sessions more easily in session manager": "記錄客戶端名稱、版本與 URL,以便在工作階段管理程式更輕鬆地識別工作階段", + "Unknown session type": "未知工作階段類型", + "Web session": "網頁工作階段", + "Mobile session": "行動裝置工作階段", + "Desktop session": "桌面工作階段", + "Video call started": "視訊通話已開始", + "Unknown room": "未知的聊天室", + "Video call started in %(roomName)s. (not supported by this browser)": "視訊通話在 %(roomName)s 開始。(此瀏覽器不支援)", + "Video call started in %(roomName)s.": "視訊通話在 %(roomName)s 開始。", + "Room info": "聊天室資訊", + "View chat timeline": "檢視聊天時間軸", + "Close call": "關閉通話", + "Layout type": "佈局類型", + "Spotlight": "聚焦", + "Freedom": "自由", + "Video call (%(brand)s)": "視訊通話 (%(brand)s)", + "Operating system": "作業系統", + "Model": "模型", + "Client": "客戶端", + "Call type": "通話類型", + "You do not have sufficient permissions to change this.": "您沒有足夠的權限來變更此設定。", + "%(brand)s is end-to-end encrypted, but is currently limited to smaller numbers of users.": "%(brand)s 是端到端加密的,但目前僅限於少數使用者。", + "Enable %(brand)s as an additional calling option in this room": "啟用 %(brand)s 作為此聊天室的額外通話選項", + "Join %(brand)s calls": "加入 %(brand)s 通話", + "Start %(brand)s calls": "開始 %(brand)s 通話", + "Fill screen": "填滿螢幕", + "Sorry — this call is currently full": "抱歉 — 此通話目前已滿", + "Wysiwyg composer (plain text mode coming soon) (under active development)": "所見即所得編輯器(純文字模式即將推出)(正在積極開發中)", + "Our new sessions manager provides better visibility of all your sessions, and greater control over them including the ability to remotely toggle push notifications.": "我們的新工作階段管理程式可讓您更好地了解您的所有工作階段,並更好地控制它們,包含遠端切換推播通知的能力。", + "Have greater visibility and control over all your sessions.": "對您所有的工作階段有更大的能見度與控制。", + "New session manager": "新的工作階段管理程式", + "Use new session manager": "使用新的工作階段管理程式", + "Sign out all other sessions": "登出其他所有工作階段", + "Underline": "底線", + "Italic": "義式斜體", + "You have already joined this call from another device": "您已從另一台裝置加入了此通話", + "Try out the rich text editor (plain text mode coming soon)": "試用格式化文字編輯器(純文字模式即將推出)", + "stop voice broadcast": "停止語音廣播", + "resume voice broadcast": "恢復語音廣播", + "pause voice broadcast": "暫停語音廣播", + "Notifications silenced": "通知已靜音" } From 0ef8c808159ffb685bad8fb9fbbd9cb0eb65dd19 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Tue, 18 Oct 2022 13:39:59 +0100 Subject: [PATCH 02/11] Fix usages of useContextMenu which never pass the ref to the element (#9449) --- src/components/views/messages/MessageActionBar.tsx | 8 ++++---- src/components/views/rooms/MessageComposerButtons.tsx | 1 + 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/components/views/messages/MessageActionBar.tsx b/src/components/views/messages/MessageActionBar.tsx index c510805116..c1637b9a0c 100644 --- a/src/components/views/messages/MessageActionBar.tsx +++ b/src/components/views/messages/MessageActionBar.tsx @@ -83,7 +83,7 @@ const OptionsButton: React.FC = ({ getRelationsForEvent, }) => { const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu(); - const [onFocus, isActive, ref] = useRovingTabIndex(button); + const [onFocus, isActive] = useRovingTabIndex(button); useEffect(() => { onFocusChange(menuDisplayed); }, [onFocusChange, menuDisplayed]); @@ -123,7 +123,7 @@ const OptionsButton: React.FC = ({ onClick={onOptionsClick} onContextMenu={onOptionsClick} isExpanded={menuDisplayed} - inputRef={ref} + inputRef={button} onFocus={onFocus} tabIndex={isActive ? 0 : -1} > @@ -141,7 +141,7 @@ interface IReactButtonProps { const ReactButton: React.FC = ({ mxEvent, reactions, onFocusChange }) => { const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu(); - const [onFocus, isActive, ref] = useRovingTabIndex(button); + const [onFocus, isActive] = useRovingTabIndex(button); useEffect(() => { onFocusChange(menuDisplayed); }, [onFocusChange, menuDisplayed]); @@ -173,7 +173,7 @@ const ReactButton: React.FC = ({ mxEvent, reactions, onFocusC onClick={onClick} onContextMenu={onClick} isExpanded={menuDisplayed} - inputRef={ref} + inputRef={button} onFocus={onFocus} tabIndex={isActive ? 0 : -1} > diff --git a/src/components/views/rooms/MessageComposerButtons.tsx b/src/components/views/rooms/MessageComposerButtons.tsx index cc7ce70f44..b77bff66a8 100644 --- a/src/components/views/rooms/MessageComposerButtons.tsx +++ b/src/components/views/rooms/MessageComposerButtons.tsx @@ -179,6 +179,7 @@ const EmojiButton: React.FC = ({ addEmoji, menuPosition }) => iconClassName="mx_MessageComposer_emoji" onClick={openMenu} title={_t("Emoji")} + inputRef={button} /> { contextMenu } From 67dbb360260bea1da55fe518e50a50424d816abc Mon Sep 17 00:00:00 2001 From: kegsay Date: Tue, 18 Oct 2022 13:44:45 +0100 Subject: [PATCH 03/11] Listen for and update the notification state when they change (#9438) * Listen for and update the notification state when they change * Remove unnecessary listeners: justify each listener left remaining * Update removeListener too --- .../notifications/RoomNotificationState.ts | 24 ++++++++----------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/src/stores/notifications/RoomNotificationState.ts b/src/stores/notifications/RoomNotificationState.ts index c4c803483d..9c64b7ec42 100644 --- a/src/stores/notifications/RoomNotificationState.ts +++ b/src/stores/notifications/RoomNotificationState.ts @@ -32,16 +32,15 @@ import { ThreadsRoomNotificationState } from "./ThreadsRoomNotificationState"; export class RoomNotificationState extends NotificationState implements IDestroyable { constructor(public readonly room: Room, private readonly threadsState?: ThreadsRoomNotificationState) { super(); - this.room.on(RoomEvent.Receipt, this.handleReadReceipt); - this.room.on(RoomEvent.Timeline, this.handleRoomEventUpdate); - this.room.on(RoomEvent.Redaction, this.handleRoomEventUpdate); - this.room.on(RoomEvent.MyMembership, this.handleMembershipUpdate); - this.room.on(RoomEvent.LocalEchoUpdated, this.handleLocalEchoUpdated); + this.room.on(RoomEvent.Receipt, this.handleReadReceipt); // for unread indicators + this.room.on(RoomEvent.MyMembership, this.handleMembershipUpdate); // for redness on invites + this.room.on(RoomEvent.LocalEchoUpdated, this.handleLocalEchoUpdated); // for redness on unsent messages + this.room.on(RoomEvent.UnreadNotifications, this.handleNotificationCountUpdate); // for server-sent counts if (threadsState) { threadsState.on(NotificationStateEvents.Update, this.handleThreadsUpdate); } - MatrixClientPeg.get().on(MatrixEventEvent.Decrypted, this.onEventDecrypted); - MatrixClientPeg.get().on(ClientEvent.AccountData, this.handleAccountDataUpdate); + MatrixClientPeg.get().on(MatrixEventEvent.Decrypted, this.onEventDecrypted); // for local count calculation + MatrixClientPeg.get().on(ClientEvent.AccountData, this.handleAccountDataUpdate); // for push rules this.updateNotificationState(); } @@ -52,10 +51,9 @@ export class RoomNotificationState extends NotificationState implements IDestroy public destroy(): void { super.destroy(); this.room.removeListener(RoomEvent.Receipt, this.handleReadReceipt); - this.room.removeListener(RoomEvent.Timeline, this.handleRoomEventUpdate); - this.room.removeListener(RoomEvent.Redaction, this.handleRoomEventUpdate); this.room.removeListener(RoomEvent.MyMembership, this.handleMembershipUpdate); this.room.removeListener(RoomEvent.LocalEchoUpdated, this.handleLocalEchoUpdated); + this.room.removeListener(RoomEvent.UnreadNotifications, this.handleNotificationCountUpdate); if (this.threadsState) { this.threadsState.removeListener(NotificationStateEvents.Update, this.handleThreadsUpdate); } @@ -83,14 +81,12 @@ export class RoomNotificationState extends NotificationState implements IDestroy this.updateNotificationState(); }; - private onEventDecrypted = (event: MatrixEvent) => { - if (event.getRoomId() !== this.room.roomId) return; // ignore - not for us or notifications timeline - + private handleNotificationCountUpdate = () => { this.updateNotificationState(); }; - private handleRoomEventUpdate = (event: MatrixEvent, room: Room | null) => { - if (room?.roomId !== this.room.roomId) return; // ignore - not for us or notifications timeline + private onEventDecrypted = (event: MatrixEvent) => { + if (event.getRoomId() !== this.room.roomId) return; // ignore - not for us or notifications timeline this.updateNotificationState(); }; From b04991a9628f896e6f25ae178d3fc58b1a568c8d Mon Sep 17 00:00:00 2001 From: Kerry Date: Tue, 18 Oct 2022 15:00:01 +0200 Subject: [PATCH 04/11] Device manager - put client/browser device metadata in correct section (#9447) --- .../views/settings/devices/DeviceDetails.tsx | 2 +- src/i18n/strings/en_EN.json | 2 +- .../__snapshots__/DeviceDetails-test.tsx.snap | 24 +++++++++---------- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/components/views/settings/devices/DeviceDetails.tsx b/src/components/views/settings/devices/DeviceDetails.tsx index 4330798dca..3921ae899e 100644 --- a/src/components/views/settings/devices/DeviceDetails.tsx +++ b/src/components/views/settings/devices/DeviceDetails.tsx @@ -62,7 +62,6 @@ const DeviceDetails: React.FC = ({ id: 'session', values: [ { label: _t('Session ID'), value: device.device_id }, - { label: _t('Client'), value: device.client }, { label: _t('Last activity'), value: device.last_seen_ts && formatDate(new Date(device.last_seen_ts)), @@ -84,6 +83,7 @@ const DeviceDetails: React.FC = ({ values: [ { label: _t('Model'), value: device.deviceModel }, { label: _t('Operating system'), value: device.deviceOperatingSystem }, + { label: _t('Browser'), value: device.client }, { label: _t('IP address'), value: device.last_seen_ip }, ], }, diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index d24c763bec..3f078172b3 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1742,7 +1742,6 @@ "Rename session": "Rename session", "Please be aware that session names are also visible to people you communicate with": "Please be aware that session names are also visible to people you communicate with", "Session ID": "Session ID", - "Client": "Client", "Last activity": "Last activity", "Application": "Application", "Version": "Version", @@ -1750,6 +1749,7 @@ "Device": "Device", "Model": "Model", "Operating system": "Operating system", + "Browser": "Browser", "IP address": "IP address", "Session details": "Session details", "Toggle push notifications on this session.": "Toggle push notifications on this session.", diff --git a/test/components/views/settings/devices/__snapshots__/DeviceDetails-test.tsx.snap b/test/components/views/settings/devices/__snapshots__/DeviceDetails-test.tsx.snap index e681b65276..9f2f538658 100644 --- a/test/components/views/settings/devices/__snapshots__/DeviceDetails-test.tsx.snap +++ b/test/components/views/settings/devices/__snapshots__/DeviceDetails-test.tsx.snap @@ -181,18 +181,6 @@ exports[` renders device with metadata 1`] = ` my-device - - - Client - - - Firefox 100 - - renders device with metadata 1`] = ` Windows 95 + + + Browser + + + Firefox 100 + + Date: Tue, 18 Oct 2022 17:07:23 +0100 Subject: [PATCH 05/11] Stabilise Cypress login tests (#9446) * Attempt to stabilise login tests * More stability * Stabilise s'more * don't clear LS as we rely on it for enablements * Add small delay * Iterate * Update login.ts --- cypress/e2e/create-room/create-room.spec.ts | 4 +- cypress/e2e/editing/editing.spec.ts | 2 +- cypress/e2e/login/consent.spec.ts | 4 +- cypress/e2e/login/login.spec.ts | 22 +++++------ cypress/e2e/polls/polls.spec.ts | 4 +- .../e2e/room-directory/room-directory.spec.ts | 2 +- cypress/e2e/sliding-sync/sliding-sync.ts | 6 +-- cypress/e2e/spaces/spaces.spec.ts | 38 +++++++++---------- cypress/e2e/threads/threads.spec.ts | 22 ++++++----- cypress/e2e/timeline/timeline.spec.ts | 2 +- cypress/e2e/toasts/analytics-toast.ts | 2 +- cypress/support/login.ts | 2 +- cypress/support/settings.ts | 6 +-- src/Lifecycle.ts | 2 +- 14 files changed, 60 insertions(+), 58 deletions(-) diff --git a/cypress/e2e/create-room/create-room.spec.ts b/cypress/e2e/create-room/create-room.spec.ts index 9bf38194d9..deac0728e3 100644 --- a/cypress/e2e/create-room/create-room.spec.ts +++ b/cypress/e2e/create-room/create-room.spec.ts @@ -60,7 +60,7 @@ describe("Create Room", () => { cy.url().should("contain", "/#/room/#test-room-1:localhost"); cy.stopMeasuring("from-submit-to-room"); - cy.get(".mx_RoomHeader_nametext").contains(name); - cy.get(".mx_RoomHeader_topic").contains(topic); + cy.contains(".mx_RoomHeader_nametext", name); + cy.contains(".mx_RoomHeader_topic", topic); }); }); diff --git a/cypress/e2e/editing/editing.spec.ts b/cypress/e2e/editing/editing.spec.ts index 49e4ae79b3..f08466ab30 100644 --- a/cypress/e2e/editing/editing.spec.ts +++ b/cypress/e2e/editing/editing.spec.ts @@ -62,7 +62,7 @@ describe("Editing", () => { cy.get(".mx_BasicMessageComposer_input").type("Foo{backspace}{backspace}{backspace}{enter}"); cy.checkA11y(); }); - cy.get(".mx_RoomView_body .mx_EventTile").contains(".mx_EventTile[data-scroll-tokens]", "Message"); + cy.contains(".mx_RoomView_body .mx_EventTile[data-scroll-tokens]", "Message"); // Assert that the edit composer has gone away cy.get(".mx_EditMessageComposer").should("not.exist"); diff --git a/cypress/e2e/login/consent.spec.ts b/cypress/e2e/login/consent.spec.ts index a4cd31bd26..c6af9eab22 100644 --- a/cypress/e2e/login/consent.spec.ts +++ b/cypress/e2e/login/consent.spec.ts @@ -46,7 +46,7 @@ describe("Consent", () => { // Accept terms & conditions cy.get(".mx_QuestionDialog").within(() => { - cy.get("#mx_BaseDialog_title").contains("Terms and Conditions"); + cy.contains("#mx_BaseDialog_title", "Terms and Conditions"); cy.get(".mx_Dialog_primary").click(); }); @@ -58,7 +58,7 @@ describe("Consent", () => { cy.visit(url); cy.get('[type="submit"]').click(); - cy.get("p").contains("Danke schon"); + cy.contains("p", "Danke schon"); }); }); diff --git a/cypress/e2e/login/login.spec.ts b/cypress/e2e/login/login.spec.ts index 2ba2e33f9b..ff963dfbfe 100644 --- a/cypress/e2e/login/login.spec.ts +++ b/cypress/e2e/login/login.spec.ts @@ -21,13 +21,6 @@ import { SynapseInstance } from "../../plugins/synapsedocker"; describe("Login", () => { let synapse: SynapseInstance; - beforeEach(() => { - cy.visit("/#/login"); - cy.startSynapse("consent").then(data => { - synapse = data; - }); - }); - afterEach(() => { cy.stopSynapse(synapse); }); @@ -37,7 +30,11 @@ describe("Login", () => { const password = "p4s5W0rD"; beforeEach(() => { - cy.registerUser(synapse, username, password); + cy.startSynapse("consent").then(data => { + synapse = data; + cy.registerUser(synapse, username, password); + cy.visit("/#/login"); + }); }); it("logs in with an existing account and lands on the home screen", () => { @@ -65,14 +62,17 @@ describe("Login", () => { describe("logout", () => { beforeEach(() => { - cy.initTestUser(synapse, "Erin"); + cy.startSynapse("consent").then(data => { + synapse = data; + cy.initTestUser(synapse, "Erin"); + }); }); it("should go to login page on logout", () => { cy.get('[aria-label="User menu"]').click(); // give a change for the outstanding requests queue to settle before logging out - cy.wait(500); + cy.wait(2000); cy.get(".mx_UserMenu_contextMenu").within(() => { cy.get(".mx_UserMenu_iconSignOut").click(); @@ -94,7 +94,7 @@ describe("Login", () => { cy.get('[aria-label="User menu"]').click(); // give a change for the outstanding requests queue to settle before logging out - cy.wait(500); + cy.wait(2000); cy.get(".mx_UserMenu_contextMenu").within(() => { cy.get(".mx_UserMenu_iconSignOut").click(); diff --git a/cypress/e2e/polls/polls.spec.ts b/cypress/e2e/polls/polls.spec.ts index 470c69d8cf..50d2befb0f 100644 --- a/cypress/e2e/polls/polls.spec.ts +++ b/cypress/e2e/polls/polls.spec.ts @@ -122,7 +122,7 @@ describe("Polls", () => { createPoll(pollParams); // Wait for message to send, get its ID and save as @pollId - cy.get(".mx_RoomView_body .mx_EventTile").contains(".mx_EventTile[data-scroll-tokens]", pollParams.title) + cy.contains(".mx_RoomView_body .mx_EventTile[data-scroll-tokens]", pollParams.title) .invoke("attr", "data-scroll-tokens").as("pollId"); cy.get("@pollId").then(pollId => { @@ -190,7 +190,7 @@ describe("Polls", () => { createPoll(pollParams); // Wait for message to send, get its ID and save as @pollId - cy.get(".mx_RoomView_body .mx_EventTile").contains(".mx_EventTile[data-scroll-tokens]", pollParams.title) + cy.contains(".mx_RoomView_body .mx_EventTile[data-scroll-tokens]", pollParams.title) .invoke("attr", "data-scroll-tokens").as("pollId"); cy.get("@pollId").then(pollId => { diff --git a/cypress/e2e/room-directory/room-directory.spec.ts b/cypress/e2e/room-directory/room-directory.spec.ts index 18464e2071..f179b0988c 100644 --- a/cypress/e2e/room-directory/room-directory.spec.ts +++ b/cypress/e2e/room-directory/room-directory.spec.ts @@ -93,7 +93,7 @@ describe("Room Directory", () => { cy.get(".mx_RoomDirectory_dialogWrapper").percySnapshotElement("Room Directory - filtered no results"); cy.get('.mx_RoomDirectory_dialogWrapper [name="dirsearch"]').type("{selectAll}{backspace}test1234"); - cy.get(".mx_RoomDirectory_dialogWrapper").contains(".mx_RoomDirectory_listItem", name) + cy.contains(".mx_RoomDirectory_dialogWrapper .mx_RoomDirectory_listItem", name) .should("exist").as("resultRow"); cy.get(".mx_RoomDirectory_dialogWrapper").percySnapshotElement("Room Directory - filtered one result"); cy.get("@resultRow").find(".mx_AccessibleButton").contains("Join").click(); diff --git a/cypress/e2e/sliding-sync/sliding-sync.ts b/cypress/e2e/sliding-sync/sliding-sync.ts index cfd4fd4185..e0e7c974a7 100644 --- a/cypress/e2e/sliding-sync/sliding-sync.ts +++ b/cypress/e2e/sliding-sync/sliding-sync.ts @@ -293,7 +293,7 @@ describe("Sliding Sync", () => { ]); cy.contains(".mx_RoomTile", "Reject").click(); - cy.get(".mx_RoomView").contains(".mx_AccessibleButton", "Reject").click(); + cy.contains(".mx_RoomView .mx_AccessibleButton", "Reject").click(); // wait for the rejected room to disappear cy.get(".mx_RoomTile").should('have.length', 3); @@ -328,8 +328,8 @@ describe("Sliding Sync", () => { cy.getClient().then(cli => cli.setRoomTag(roomId, "m.favourite", { order: 0.5 })); }); - cy.get('.mx_RoomSublist[aria-label="Favourites"]').contains(".mx_RoomTile", "Favourite DM").should("exist"); - cy.get('.mx_RoomSublist[aria-label="People"]').contains(".mx_RoomTile", "Favourite DM").should("not.exist"); + cy.contains('.mx_RoomSublist[aria-label="Favourites"] .mx_RoomTile', "Favourite DM").should("exist"); + cy.contains('.mx_RoomSublist[aria-label="People"] .mx_RoomTile', "Favourite DM").should("not.exist"); }); // Regression test for a bug in SS mode, but would be useful to have in non-SS mode too. diff --git a/cypress/e2e/spaces/spaces.spec.ts b/cypress/e2e/spaces/spaces.spec.ts index e7767de942..893f48239b 100644 --- a/cypress/e2e/spaces/spaces.spec.ts +++ b/cypress/e2e/spaces/spaces.spec.ts @@ -83,26 +83,26 @@ describe("Spaces", () => { cy.get('input[label="Name"]').type("Let's have a Riot"); cy.get('input[label="Address"]').should("have.value", "lets-have-a-riot"); cy.get('textarea[label="Description"]').type("This is a space to reminisce Riot.im!"); - cy.get(".mx_AccessibleButton").contains("Create").click(); + cy.contains(".mx_AccessibleButton", "Create").click(); }); // Create the default General & Random rooms, as well as a custom "Jokes" room cy.get('input[label="Room name"][value="General"]').should("exist"); cy.get('input[label="Room name"][value="Random"]').should("exist"); cy.get('input[placeholder="Support"]').type("Jokes"); - cy.get(".mx_AccessibleButton").contains("Continue").click(); + cy.contains(".mx_AccessibleButton", "Continue").click(); // Copy matrix.to link cy.get(".mx_SpacePublicShare_shareButton").focus().realClick(); cy.getClipboardText().should("eq", "https://matrix.to/#/#lets-have-a-riot:localhost"); // Go to space home - cy.get(".mx_AccessibleButton").contains("Go to my first room").click(); + cy.contains(".mx_AccessibleButton", "Go to my first room").click(); // Assert rooms exist in the room list - cy.get(".mx_RoomList").contains(".mx_RoomTile", "General").should("exist"); - cy.get(".mx_RoomList").contains(".mx_RoomTile", "Random").should("exist"); - cy.get(".mx_RoomList").contains(".mx_RoomTile", "Jokes").should("exist"); + cy.contains(".mx_RoomList .mx_RoomTile", "General").should("exist"); + cy.contains(".mx_RoomList .mx_RoomTile", "Random").should("exist"); + cy.contains(".mx_RoomList .mx_RoomTile", "Jokes").should("exist"); }); it("should allow user to create private space", () => { @@ -113,7 +113,7 @@ describe("Spaces", () => { cy.get('input[label="Name"]').type("This is not a Riot"); cy.get('input[label="Address"]').should("not.exist"); cy.get('textarea[label="Description"]').type("This is a private space of mourning Riot.im..."); - cy.get(".mx_AccessibleButton").contains("Create").click(); + cy.contains(".mx_AccessibleButton", "Create").click(); }); cy.get(".mx_SpaceRoomView_privateScope_meAndMyTeammatesButton").click(); @@ -122,20 +122,20 @@ describe("Spaces", () => { cy.get('input[label="Room name"][value="General"]').should("exist"); cy.get('input[label="Room name"][value="Random"]').should("exist"); cy.get('input[placeholder="Support"]').type("Projects"); - cy.get(".mx_AccessibleButton").contains("Continue").click(); + cy.contains(".mx_AccessibleButton", "Continue").click(); cy.get(".mx_SpaceRoomView").should("contain", "Invite your teammates"); - cy.get(".mx_AccessibleButton").contains("Skip for now").click(); + cy.contains(".mx_AccessibleButton", "Skip for now").click(); // Assert rooms exist in the room list - cy.get(".mx_RoomList").contains(".mx_RoomTile", "General").should("exist"); - cy.get(".mx_RoomList").contains(".mx_RoomTile", "Random").should("exist"); - cy.get(".mx_RoomList").contains(".mx_RoomTile", "Projects").should("exist"); + cy.contains(".mx_RoomList .mx_RoomTile", "General").should("exist"); + cy.contains(".mx_RoomList .mx_RoomTile", "Random").should("exist"); + cy.contains(".mx_RoomList .mx_RoomTile", "Projects").should("exist"); // Assert rooms exist in the space explorer - cy.get(".mx_SpaceHierarchy_list").contains(".mx_SpaceHierarchy_roomTile", "General").should("exist"); - cy.get(".mx_SpaceHierarchy_list").contains(".mx_SpaceHierarchy_roomTile", "Random").should("exist"); - cy.get(".mx_SpaceHierarchy_list").contains(".mx_SpaceHierarchy_roomTile", "Projects").should("exist"); + cy.contains(".mx_SpaceHierarchy_list .mx_SpaceHierarchy_roomTile", "General").should("exist"); + cy.contains(".mx_SpaceHierarchy_list .mx_SpaceHierarchy_roomTile", "Random").should("exist"); + cy.contains(".mx_SpaceHierarchy_list .mx_SpaceHierarchy_roomTile", "Projects").should("exist"); }); it("should allow user to create just-me space", () => { @@ -155,10 +155,10 @@ describe("Spaces", () => { cy.get(".mx_SpaceRoomView_privateScope_justMeButton").click(); cy.get(".mx_AddExistingToSpace_entry").click(); - cy.get(".mx_AccessibleButton").contains("Add").click(); + cy.contains(".mx_AccessibleButton", "Add").click(); - cy.get(".mx_RoomList").contains(".mx_RoomTile", "Sample Room").should("exist"); - cy.get(".mx_SpaceHierarchy_list").contains(".mx_SpaceHierarchy_roomTile", "Sample Room").should("exist"); + cy.contains(".mx_RoomList .mx_RoomTile", "Sample Room").should("exist"); + cy.contains(".mx_SpaceHierarchy_list .mx_SpaceHierarchy_roomTile", "Sample Room").should("exist"); }); it("should allow user to invite another to a space", () => { @@ -186,7 +186,7 @@ describe("Spaces", () => { cy.get(".mx_InviteDialog_other").within(() => { cy.get('input[type="text"]').type(bot.getUserId()); - cy.get(".mx_AccessibleButton").contains("Invite").click(); + cy.contains(".mx_AccessibleButton", "Invite").click(); }); cy.get(".mx_InviteDialog_other").should("not.exist"); diff --git a/cypress/e2e/threads/threads.spec.ts b/cypress/e2e/threads/threads.spec.ts index 5af2d07d79..6aea5815e5 100644 --- a/cypress/e2e/threads/threads.spec.ts +++ b/cypress/e2e/threads/threads.spec.ts @@ -53,6 +53,7 @@ describe("Threads", () => { cy.window().should("have.prop", "beforeReload", true); cy.leaveBeta("Threads"); + cy.wait(1000); // after reload the property should be gone cy.window().should("not.have.prop", "beforeReload"); }); @@ -66,6 +67,7 @@ describe("Threads", () => { cy.window().should("have.prop", "beforeReload", true); cy.joinBeta("Threads"); + cy.wait(1000); // after reload the property should be gone cy.window().should("not.have.prop", "beforeReload"); }); @@ -92,7 +94,7 @@ describe("Threads", () => { cy.get(".mx_RoomView_body .mx_BasicMessageComposer_input").type("Hello Mr. Bot{enter}"); // Wait for message to send, get its ID and save as @threadId - cy.get(".mx_RoomView_body .mx_EventTile").contains(".mx_EventTile[data-scroll-tokens]", "Hello Mr. Bot") + cy.contains(".mx_RoomView_body .mx_EventTile[data-scroll-tokens]", "Hello Mr. Bot") .invoke("attr", "data-scroll-tokens").as("threadId"); // Bot starts thread @@ -116,21 +118,21 @@ describe("Threads", () => { cy.get(".mx_RoomView_body .mx_ThreadSummary .mx_ThreadSummary_content").should("contain", "Test"); // User reacts to message instead - cy.get(".mx_ThreadView .mx_EventTile").contains(".mx_EventTile_line", "Hello there") + cy.contains(".mx_ThreadView .mx_EventTile .mx_EventTile_line", "Hello there") .find('[aria-label="React"]').click({ force: true }); // Cypress has no ability to hover cy.get(".mx_EmojiPicker").within(() => { cy.get('input[type="text"]').type("wave"); - cy.get('[role="menuitem"]').contains("👋").click(); + cy.contains('[role="menuitem"]', "👋").click(); }); // User redacts their prior response - cy.get(".mx_ThreadView .mx_EventTile").contains(".mx_EventTile_line", "Test") + cy.contains(".mx_ThreadView .mx_EventTile .mx_EventTile_line", "Test") .find('[aria-label="Options"]').click({ force: true }); // Cypress has no ability to hover cy.get(".mx_IconizedContextMenu").within(() => { - cy.get('[role="menuitem"]').contains("Remove").click(); + cy.contains('[role="menuitem"]', "Remove").click(); }); cy.get(".mx_TextInputDialog").within(() => { - cy.get(".mx_Dialog_primary").contains("Remove").click(); + cy.contains(".mx_Dialog_primary", "Remove").click(); }); // User asserts summary was updated correctly @@ -171,7 +173,7 @@ describe("Threads", () => { cy.get(".mx_RoomView_body .mx_ThreadSummary .mx_ThreadSummary_content").should("contain", "Great!"); // User edits & asserts - cy.get(".mx_ThreadView .mx_EventTile_last").contains(".mx_EventTile_line", "Great!").within(() => { + cy.contains(".mx_ThreadView .mx_EventTile_last .mx_EventTile_line", "Great!").within(() => { cy.get('[aria-label="Edit"]').click({ force: true }); // Cypress has no ability to hover cy.get(".mx_BasicMessageComposer_input").type(" How about yourself?{enter}"); }); @@ -234,7 +236,7 @@ describe("Threads", () => { cy.get(".mx_RoomView_body .mx_BasicMessageComposer_input").type("Hello Mr. Bot{enter}"); // Create thread - cy.get(".mx_RoomView_body .mx_EventTile").contains(".mx_EventTile[data-scroll-tokens]", "Hello Mr. Bot") + cy.contains(".mx_RoomView_body .mx_EventTile[data-scroll-tokens]", "Hello Mr. Bot") .realHover().find(".mx_MessageActionBar_threadButton").click(); cy.get(".mx_ThreadView_timelinePanelWrapper").should("have.length", 1); @@ -256,7 +258,7 @@ describe("Threads", () => { cy.get(".mx_RoomView_body .mx_BasicMessageComposer_input").type("Hello Mr. Bot{enter}"); // Create thread - cy.get(".mx_RoomView_body .mx_EventTile").contains(".mx_EventTile[data-scroll-tokens]", "Hello Mr. Bot") + cy.contains(".mx_RoomView_body .mx_EventTile[data-scroll-tokens]", "Hello Mr. Bot") .realHover().find(".mx_MessageActionBar_threadButton").click(); cy.get(".mx_ThreadView_timelinePanelWrapper").should("have.length", 1); @@ -268,7 +270,7 @@ describe("Threads", () => { cy.get(".mx_BaseCard_close").click(); // Open existing thread - cy.get(".mx_RoomView_body .mx_EventTile").contains(".mx_EventTile[data-scroll-tokens]", "Hello Mr. Bot") + cy.contains(".mx_RoomView_body .mx_EventTile[data-scroll-tokens]", "Hello Mr. Bot") .realHover().find(".mx_MessageActionBar_threadButton").click(); cy.get(".mx_ThreadView_timelinePanelWrapper").should("have.length", 1); cy.get(".mx_BaseCard .mx_EventTile").should("contain", "Hello Mr. Bot"); diff --git a/cypress/e2e/timeline/timeline.spec.ts b/cypress/e2e/timeline/timeline.spec.ts index 6cebbfd181..68e0300ce3 100644 --- a/cypress/e2e/timeline/timeline.spec.ts +++ b/cypress/e2e/timeline/timeline.spec.ts @@ -329,7 +329,7 @@ describe("Timeline", () => { cy.getComposer().type(`${MESSAGE}{enter}`); // Reply to the message - cy.get(".mx_RoomView_body").contains(".mx_EventTile_line", "Hello world").within(() => { + cy.contains(".mx_RoomView_body .mx_EventTile_line", "Hello world").within(() => { cy.get('[aria-label="Reply"]').click({ force: true }); // Cypress has no ability to hover }); }; diff --git a/cypress/e2e/toasts/analytics-toast.ts b/cypress/e2e/toasts/analytics-toast.ts index 547e46bf68..518a544a1c 100644 --- a/cypress/e2e/toasts/analytics-toast.ts +++ b/cypress/e2e/toasts/analytics-toast.ts @@ -24,7 +24,7 @@ function assertNoToasts(): void { } function getToast(expectedTitle: string): Chainable { - return cy.get(".mx_Toast_toast").contains("h2", expectedTitle).should("exist").closest(".mx_Toast_toast"); + return cy.contains(".mx_Toast_toast h2", expectedTitle).should("exist").closest(".mx_Toast_toast"); } function acceptToast(expectedTitle: string): void { diff --git a/cypress/support/login.ts b/cypress/support/login.ts index e44be78123..6c44158941 100644 --- a/cypress/support/login.ts +++ b/cypress/support/login.ts @@ -91,7 +91,7 @@ Cypress.Commands.add("loginUser", (synapse: SynapseInstance, username: string, p Cypress.Commands.add("initTestUser", (synapse: SynapseInstance, displayName: string, prelaunchFn?: () => void): Chainable => { // XXX: work around Cypress not clearing IDB between tests cy.window({ log: false }).then(win => { - win.indexedDB.databases().then(databases => { + win.indexedDB.databases()?.then(databases => { databases.forEach(database => { win.indexedDB.deleteDatabase(database.name); }); diff --git a/cypress/support/settings.ts b/cypress/support/settings.ts index 63c91ddda0..42a78792a0 100644 --- a/cypress/support/settings.ts +++ b/cypress/support/settings.ts @@ -153,7 +153,7 @@ Cypress.Commands.add("openRoomSettings", (tab?: string): Chainable> => { return cy.get(".mx_TabbedView_tabLabels").within(() => { - cy.get(".mx_TabbedView_tabLabel").contains(tab).click(); + cy.contains(".mx_TabbedView_tabLabel", tab).click(); }); }); @@ -162,13 +162,13 @@ Cypress.Commands.add("closeDialog", (): Chainable> => { }); Cypress.Commands.add("joinBeta", (name: string): Chainable> => { - return cy.get(".mx_BetaCard_title").contains(name).closest(".mx_BetaCard").within(() => { + return cy.contains(".mx_BetaCard_title", name).closest(".mx_BetaCard").within(() => { return cy.get(".mx_BetaCard_buttons").contains("Join the beta").click(); }); }); Cypress.Commands.add("leaveBeta", (name: string): Chainable> => { - return cy.get(".mx_BetaCard_title").contains(name).closest(".mx_BetaCard").within(() => { + return cy.contains(".mx_BetaCard_title", name).closest(".mx_BetaCard").within(() => { return cy.get(".mx_BetaCard_buttons").contains("Leave the beta").click(); }); }); diff --git a/src/Lifecycle.ts b/src/Lifecycle.ts index 64d1d9b5fd..6c9c955818 100644 --- a/src/Lifecycle.ts +++ b/src/Lifecycle.ts @@ -426,7 +426,7 @@ export async function restoreFromLocalStorage(opts?: { ignoreGuest?: boolean }): const { hsUrl, isUrl, hasAccessToken, accessToken, userId, deviceId, isGuest } = await getStoredSessionVars(); if (hasAccessToken && !accessToken) { - abortLogin(); + await abortLogin(); } if (accessToken && userId && hsUrl) { From 26f3d107fd4dab2327b0704d966600cf39b64db7 Mon Sep 17 00:00:00 2001 From: Johannes Marbach Date: Tue, 18 Oct 2022 21:06:43 +0200 Subject: [PATCH 06/11] Set relations helper when creating event tile context menu (#9253) * Set relations helper when creating event tile context menu Fixes vector-im/element-web#22018 Signed-off-by: Johannes Marbach * Add e2e tests * Use idiomatic test names Signed-off-by: Johannes Marbach Co-authored-by: Travis Ralston --- cypress/e2e/polls/polls.spec.ts | 89 +++++++++++++++++++++++- src/components/views/rooms/EventTile.tsx | 1 + 2 files changed, 88 insertions(+), 2 deletions(-) diff --git a/cypress/e2e/polls/polls.spec.ts b/cypress/e2e/polls/polls.spec.ts index 50d2befb0f..f4be3962ed 100644 --- a/cypress/e2e/polls/polls.spec.ts +++ b/cypress/e2e/polls/polls.spec.ts @@ -94,7 +94,7 @@ describe("Polls", () => { cy.stopSynapse(synapse); }); - it("Open polls can be created and voted in", () => { + it("should be creatable and votable", () => { let bot: MatrixClient; cy.getBot(synapse, { displayName: "BotBob" }).then(_bot => { bot = _bot; @@ -159,7 +159,92 @@ describe("Polls", () => { }); }); - it("displays polls correctly in thread panel", () => { + it("should be editable from context menu if no votes have been cast", () => { + let bot: MatrixClient; + cy.getBot(synapse, { displayName: "BotBob" }).then(_bot => { + bot = _bot; + }); + + let roomId: string; + cy.createRoom({}).then(_roomId => { + roomId = _roomId; + cy.inviteUser(roomId, bot.getUserId()); + cy.visit('/#/room/' + roomId); + }); + + cy.openMessageComposerOptions().within(() => { + cy.get('[aria-label="Poll"]').click(); + }); + + const pollParams = { + title: 'Does the polls feature work?', + options: ['Yes', 'No', 'Maybe'], + }; + createPoll(pollParams); + + // Wait for message to send, get its ID and save as @pollId + cy.get(".mx_RoomView_body .mx_EventTile").contains(".mx_EventTile[data-scroll-tokens]", pollParams.title) + .invoke("attr", "data-scroll-tokens").as("pollId"); + + cy.get("@pollId").then(pollId => { + // Open context menu + getPollTile(pollId).rightclick(); + + // Select edit item + cy.get('.mx_ContextualMenu').within(() => { + cy.get('[aria-label="Edit"]').click(); + }); + + // Expect poll editing dialog + cy.get('.mx_PollCreateDialog'); + }); + }); + + it("should not be editable from context menu if votes have been cast", () => { + let bot: MatrixClient; + cy.getBot(synapse, { displayName: "BotBob" }).then(_bot => { + bot = _bot; + }); + + let roomId: string; + cy.createRoom({}).then(_roomId => { + roomId = _roomId; + cy.inviteUser(roomId, bot.getUserId()); + cy.visit('/#/room/' + roomId); + }); + + cy.openMessageComposerOptions().within(() => { + cy.get('[aria-label="Poll"]').click(); + }); + + const pollParams = { + title: 'Does the polls feature work?', + options: ['Yes', 'No', 'Maybe'], + }; + createPoll(pollParams); + + // Wait for message to send, get its ID and save as @pollId + cy.get(".mx_RoomView_body .mx_EventTile").contains(".mx_EventTile[data-scroll-tokens]", pollParams.title) + .invoke("attr", "data-scroll-tokens").as("pollId"); + + cy.get("@pollId").then(pollId => { + // Bot votes 'Maybe' in the poll + botVoteForOption(bot, roomId, pollId, pollParams.options[2]); + + // Open context menu + getPollTile(pollId).rightclick(); + + // Select edit item + cy.get('.mx_ContextualMenu').within(() => { + cy.get('[aria-label="Edit"]').click(); + }); + + // Expect error dialog + cy.get('.mx_ErrorDialog'); + }); + }); + + it("should be displayed correctly in thread panel", () => { let botBob: MatrixClient; let botCharlie: MatrixClient; cy.getBot(synapse, { displayName: "BotBob" }).then(_bot => { diff --git a/src/components/views/rooms/EventTile.tsx b/src/components/views/rooms/EventTile.tsx index 654cc80b67..b13eba33e4 100644 --- a/src/components/views/rooms/EventTile.tsx +++ b/src/components/views/rooms/EventTile.tsx @@ -932,6 +932,7 @@ export class UnwrappedEventTile extends React.Component { rightClick={true} reactions={this.state.reactions} link={this.state.contextMenu.link} + getRelationsForEvent={this.props.getRelationsForEvent} /> ); } From e0ab0ac5c9996ebaff0c949298e42fad1d128538 Mon Sep 17 00:00:00 2001 From: Andy Balaam Date: Wed, 19 Oct 2022 04:07:21 +0100 Subject: [PATCH 07/11] Allow pressing Enter to send messages in new composer (#9451) * Allow pressing Enter to send messages in new composer * Cypress tests for composer send behaviour --- cypress/e2e/composer/composer.spec.ts | 140 ++++++++++++++++++ package.json | 2 +- .../wysiwyg_composer/WysiwygComposer.tsx | 24 ++- .../wysiwyg_composer/WysiwygComposer-test.tsx | 82 +++++++++- yarn.lock | 87 ++++++++++- 5 files changed, 323 insertions(+), 12 deletions(-) create mode 100644 cypress/e2e/composer/composer.spec.ts diff --git a/cypress/e2e/composer/composer.spec.ts b/cypress/e2e/composer/composer.spec.ts new file mode 100644 index 0000000000..f3fc374cf0 --- /dev/null +++ b/cypress/e2e/composer/composer.spec.ts @@ -0,0 +1,140 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/// + +import { SynapseInstance } from "../../plugins/synapsedocker"; +import { SettingLevel } from "../../../src/settings/SettingLevel"; + +describe("Composer", () => { + let synapse: SynapseInstance; + + beforeEach(() => { + cy.startSynapse("default").then(data => { + synapse = data; + }); + }); + + afterEach(() => { + cy.stopSynapse(synapse); + }); + + describe("CIDER", () => { + beforeEach(() => { + cy.initTestUser(synapse, "Janet").then(() => { + cy.createRoom({ name: "Composing Room" }); + }); + cy.viewRoomByName("Composing Room"); + }); + + it("sends a message when you click send or press Enter", () => { + // Type a message + cy.get('div[contenteditable=true]').type('my message 0'); + // It has not been sent yet + cy.contains('.mx_EventTile_body', 'my message 0').should('not.exist'); + + // Click send + cy.get('div[aria-label="Send message"]').click(); + // It has been sent + cy.contains('.mx_EventTile_body', 'my message 0'); + + // Type another and press Enter afterwards + cy.get('div[contenteditable=true]').type('my message 1{enter}'); + // It was sent + cy.contains('.mx_EventTile_body', 'my message 1'); + }); + + it("can write formatted text", () => { + cy.get('div[contenteditable=true]').type('my bold{ctrl+b} message'); + cy.get('div[aria-label="Send message"]').click(); + // Note: both "bold" and "message" are bold, which is probably surprising + cy.contains('.mx_EventTile_body strong', 'bold message'); + }); + + describe("when Ctrl+Enter is required to send", () => { + beforeEach(() => { + cy.setSettingValue("MessageComposerInput.ctrlEnterToSend", null, SettingLevel.ACCOUNT, true); + }); + + it("only sends when you press Ctrl+Enter", () => { + // Type a message and press Enter + cy.get('div[contenteditable=true]').type('my message 3{enter}'); + // It has not been sent yet + cy.contains('.mx_EventTile_body', 'my message 3').should('not.exist'); + + // Press Ctrl+Enter + cy.get('div[contenteditable=true]').type('{ctrl+enter}'); + // It was sent + cy.contains('.mx_EventTile_body', 'my message 3'); + }); + }); + }); + + describe("WYSIWYG", () => { + beforeEach(() => { + cy.enableLabsFeature("feature_wysiwyg_composer"); + cy.initTestUser(synapse, "Janet").then(() => { + cy.createRoom({ name: "Composing Room" }); + }); + cy.viewRoomByName("Composing Room"); + }); + + it("sends a message when you click send or press Enter", () => { + // Type a message + cy.get('div[contenteditable=true]').type('my message 0'); + // It has not been sent yet + cy.contains('.mx_EventTile_body', 'my message 0').should('not.exist'); + + // Click send + cy.get('div[aria-label="Send message"]').click(); + // It has been sent + cy.contains('.mx_EventTile_body', 'my message 0'); + + // Type another + cy.get('div[contenteditable=true]').type('my message 1'); + // Press enter. Would be nice to just use {enter} but we can't because Cypress + // does not trigger an insertParagraph when you do that. + cy.get('div[contenteditable=true]').trigger('input', { inputType: "insertParagraph" }); + // It was sent + cy.contains('.mx_EventTile_body', 'my message 1'); + }); + + it("can write formatted text", () => { + cy.get('div[contenteditable=true]').type('my {ctrl+b}bold{ctrl+b} message'); + cy.get('div[aria-label="Send message"]').click(); + cy.contains('.mx_EventTile_body strong', 'bold'); + }); + + describe("when Ctrl+Enter is required to send", () => { + beforeEach(() => { + cy.setSettingValue("MessageComposerInput.ctrlEnterToSend", null, SettingLevel.ACCOUNT, true); + }); + + it("only sends when you press Ctrl+Enter", () => { + // Type a message and press Enter + cy.get('div[contenteditable=true]').type('my message 3'); + cy.get('div[contenteditable=true]').trigger('input', { inputType: "insertParagraph" }); + // It has not been sent yet + cy.contains('.mx_EventTile_body', 'my message 3').should('not.exist'); + + // Press Ctrl+Enter + cy.get('div[contenteditable=true]').type('{ctrl+enter}'); + // It was sent + cy.contains('.mx_EventTile_body', 'my message 3'); + }); + }); + }); +}); diff --git a/package.json b/package.json index b203cf51e9..f0ab2c266b 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,7 @@ "dependencies": { "@babel/runtime": "^7.12.5", "@matrix-org/analytics-events": "^0.2.0", - "@matrix-org/matrix-wysiwyg": "^0.2.0", + "@matrix-org/matrix-wysiwyg": "^0.3.0", "@matrix-org/react-sdk-module-api": "^0.0.3", "@sentry/browser": "^6.11.0", "@sentry/tracing": "^6.11.0", diff --git a/src/components/views/rooms/wysiwyg_composer/WysiwygComposer.tsx b/src/components/views/rooms/wysiwyg_composer/WysiwygComposer.tsx index 8701f5be77..c22e3406fa 100644 --- a/src/components/views/rooms/wysiwyg_composer/WysiwygComposer.tsx +++ b/src/components/views/rooms/wysiwyg_composer/WysiwygComposer.tsx @@ -16,7 +16,7 @@ limitations under the License. import React, { useCallback, useEffect } from 'react'; import { IEventRelation, MatrixEvent } from 'matrix-js-sdk/src/models/event'; -import { useWysiwyg } from "@matrix-org/matrix-wysiwyg"; +import { useWysiwyg, Wysiwyg, WysiwygInputEvent } from "@matrix-org/matrix-wysiwyg"; import { Editor } from './Editor'; import { FormattingButtons } from './FormattingButtons'; @@ -25,6 +25,7 @@ import { sendMessage } from './message'; import { useMatrixClientContext } from '../../../../contexts/MatrixClientContext'; import { useRoomContext } from '../../../../contexts/RoomContext'; import { useWysiwygActionHandler } from './useWysiwygActionHandler'; +import { useSettingValue } from '../../../../hooks/useSettings'; interface WysiwygProps { disabled?: boolean; @@ -41,8 +42,27 @@ export function WysiwygComposer( ) { const roomContext = useRoomContext(); const mxClient = useMatrixClientContext(); + const ctrlEnterToSend = useSettingValue("MessageComposerInput.ctrlEnterToSend"); - const { ref, isWysiwygReady, content, formattingStates, wysiwyg } = useWysiwyg(); + function inputEventProcessor(event: WysiwygInputEvent, wysiwyg: Wysiwyg): WysiwygInputEvent | null { + if (event instanceof ClipboardEvent) { + return event; + } + + if ( + (event.inputType === 'insertParagraph' && !ctrlEnterToSend) || + event.inputType === 'sendMessage' + ) { + sendMessage(content, { mxClient, roomContext, ...props }); + wysiwyg.actions.clear(); + ref.current?.focus(); + return null; + } + + return event; + } + + const { ref, isWysiwygReady, content, formattingStates, wysiwyg } = useWysiwyg({ inputEventProcessor }); useEffect(() => { if (!disabled && content !== null) { diff --git a/test/components/views/rooms/wysiwyg_composer/WysiwygComposer-test.tsx b/test/components/views/rooms/wysiwyg_composer/WysiwygComposer-test.tsx index b0aa838879..df2596809c 100644 --- a/test/components/views/rooms/wysiwyg_composer/WysiwygComposer-test.tsx +++ b/test/components/views/rooms/wysiwyg_composer/WysiwygComposer-test.tsx @@ -17,6 +17,7 @@ limitations under the License. import "@testing-library/jest-dom"; import React from "react"; import { act, render, screen, waitFor } from "@testing-library/react"; +import { InputEventProcessor, Wysiwyg, WysiwygProps } from "@matrix-org/matrix-wysiwyg"; import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext"; import RoomContext, { TimelineRenderingType } from "../../../../../src/contexts/RoomContext"; @@ -26,13 +27,31 @@ import { IRoomState } from "../../../../../src/components/structures/RoomView"; import { Layout } from "../../../../../src/settings/enums/Layout"; import { WysiwygComposer } from "../../../../../src/components/views/rooms/wysiwyg_composer/WysiwygComposer"; import { createTestClient, mkEvent, mkStubRoom } from "../../../../test-utils"; +import SettingsStore from "../../../../../src/settings/SettingsStore"; + +// Work around missing ClipboardEvent type +class MyClipbardEvent {} +window.ClipboardEvent = MyClipbardEvent as any; + +let inputEventProcessor: InputEventProcessor | null = null; // The wysiwyg fetch wasm bytes and a specific workaround is needed to make it works in a node (jest) environnement // See https://github.com/matrix-org/matrix-wysiwyg/blob/main/platforms/web/test.setup.ts jest.mock("@matrix-org/matrix-wysiwyg", () => ({ - useWysiwyg: () => { - return { ref: { current: null }, content: 'html', isWysiwygReady: true, wysiwyg: { clear: () => void 0 }, - formattingStates: { bold: 'enabled', italic: 'enabled', underline: 'enabled', strikeThrough: 'enabled' } }; + useWysiwyg: (props: WysiwygProps) => { + inputEventProcessor = props.inputEventProcessor ?? null; + return { + ref: { current: null }, + content: 'html', + isWysiwygReady: true, + wysiwyg: { clear: () => void 0 }, + formattingStates: { + bold: 'enabled', + italic: 'enabled', + underline: 'enabled', + strikeThrough: 'enabled', + }, + }; }, })); @@ -196,5 +215,62 @@ describe('WysiwygComposer', () => { // Then we don't get it because we are disabled expect(screen.getByRole('textbox')).not.toHaveFocus(); }); + + it('sends a message when Enter is pressed', async () => { + // Given a composer + customRender(() => {}, false); + + // When we tell its inputEventProcesser that the user pressed Enter + const event = new InputEvent("insertParagraph", { inputType: "insertParagraph" }); + const wysiwyg = { actions: { clear: () => {} } } as Wysiwyg; + inputEventProcessor(event, wysiwyg); + + // Then it sends a message + expect(mockClient.sendMessage).toBeCalledWith( + "myfakeroom", + null, + { + "body": "html", + "format": "org.matrix.custom.html", + "formatted_body": "html", + "msgtype": "m.text", + }, + ); + // TODO: plain text body above is wrong - will be fixed when we provide markdown for it + }); + + describe('when settings require Ctrl+Enter to send', () => { + beforeEach(() => { + jest.spyOn(SettingsStore, "getValue").mockImplementation((name: string) => { + if (name === "MessageComposerInput.ctrlEnterToSend") return true; + }); + }); + + it('does not send a message when Enter is pressed', async () => { + // Given a composer + customRender(() => {}, false); + + // When we tell its inputEventProcesser that the user pressed Enter + const event = new InputEvent("input", { inputType: "insertParagraph" }); + const wysiwyg = { actions: { clear: () => {} } } as Wysiwyg; + inputEventProcessor(event, wysiwyg); + + // Then it does not send a message + expect(mockClient.sendMessage).toBeCalledTimes(0); + }); + + it('sends a message when Ctrl+Enter is pressed', async () => { + // Given a composer + customRender(() => {}, false); + + // When we tell its inputEventProcesser that the user pressed Ctrl+Enter + const event = new InputEvent("input", { inputType: "sendMessage" }); + const wysiwyg = { actions: { clear: () => {} } } as Wysiwyg; + inputEventProcessor(event, wysiwyg); + + // Then it sends a message + expect(mockClient.sendMessage).toBeCalledTimes(1); + }); + }); }); diff --git a/yarn.lock b/yarn.lock index b54bc1ec81..add14d4c3e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1660,10 +1660,10 @@ resolved "https://registry.yarnpkg.com/@matrix-org/analytics-events/-/analytics-events-0.2.0.tgz#453925c939ecdd5ca6c797d293deb8cf0933f1b8" integrity sha512-+0/Sydm4MNOcqd8iySJmojVPB74Axba4BXlwTsiKmL5fgYqdUkwmqkO39K7Pn8i+a+8pg11oNvBPkpWs3O5Qww== -"@matrix-org/matrix-wysiwyg@^0.2.0": - version "0.2.0" - resolved "https://registry.yarnpkg.com/@matrix-org/matrix-wysiwyg/-/matrix-wysiwyg-0.2.0.tgz#651002ad67be3004698d4a89806cf344283a4ca3" - integrity sha512-m9R1NOd0ogkhrjqFNg159TMXL5dpME90G9RDrZrO106263Qtoj0TazyBaLhNjgvPkogbzbCJUULQWPFiLQfTjw== +"@matrix-org/matrix-wysiwyg@^0.3.0": + version "0.3.0" + resolved "https://registry.yarnpkg.com/@matrix-org/matrix-wysiwyg/-/matrix-wysiwyg-0.3.0.tgz#9a0b996c47fbb63fb235a0810b678158b253f721" + integrity sha512-m33qOo64VIZRqzMZ5vJ9m2gYns+sCaFFy3R5Nn9JfDnldQ1oh+ra611I9keFmO/Ls6548ZN8hUkv+49Ua3iBHA== "@matrix-org/olm@https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.8.tgz": version "3.2.8" @@ -2674,7 +2674,7 @@ ajv-keywords@^3.5.2: resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-3.5.2.tgz#31f29da5ab6e00d1c2d329acf7b5929614d5014d" integrity sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ== -ajv@^6.10.0, ajv@^6.12.4, ajv@^6.12.5: +ajv@^6.10.0, ajv@^6.12.3, ajv@^6.12.4, ajv@^6.12.5: version "6.12.6" resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4" integrity sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g== @@ -3198,6 +3198,11 @@ browser-process-hrtime@^1.0.0: resolved "https://registry.yarnpkg.com/browser-process-hrtime/-/browser-process-hrtime-1.0.0.tgz#3c9b4b7d782c8121e56f10106d84c0d0ffc94626" integrity sha512-9o5UecI3GhkpM6DrXr69PblIuWxPKk9Y0jHBRhdocZ2y7YECBFCsHm79Pr3OyR2AvjhDkabFJaDJMYRazHgsow== +browser-request@^0.3.3: + version "0.3.3" + resolved "https://registry.yarnpkg.com/browser-request/-/browser-request-0.3.3.tgz#9ece5b5aca89a29932242e18bf933def9876cc17" + integrity sha512-YyNI4qJJ+piQG6MMEuo7J3Bzaqssufx04zpEKYfSrl/1Op59HWali9zMtBpXnkmqMcOuWJPZvudrm9wISmnCbg== + browserslist@^4.20.2, browserslist@^4.21.3: version "4.21.3" resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.21.3.tgz#5df277694eb3c48bc5c4b05af3e8b7e09c5a6d1a" @@ -5280,6 +5285,19 @@ grid-index@^1.1.0: resolved "https://registry.yarnpkg.com/grid-index/-/grid-index-1.1.0.tgz#97f8221edec1026c8377b86446a7c71e79522ea7" integrity sha512-HZRwumpOGUrHyxO5bqKZL0B0GlUpwtCAzZ42sgxUPniu33R1LSFH5yrIcBCHjkctCAh3mtWKcKd9J4vDDdeVHA== +har-schema@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/har-schema/-/har-schema-2.0.0.tgz#a94c2224ebcac04782a0d9035521f24735b7ec92" + integrity sha512-Oqluz6zhGX8cyRaTQlFMPw80bSJVG2x/cFb8ZPhUILGgHka9SsokCCOQgpveePerqidZOrT14ipqfJb7ILcW5Q== + +har-validator@~5.1.3: + version "5.1.5" + resolved "https://registry.yarnpkg.com/har-validator/-/har-validator-5.1.5.tgz#1f0803b9f8cb20c0fa13822df1ecddb36bde1efd" + integrity sha512-nmT2T0lljbxdQZfspsno9hgrG3Uir6Ks5afism62poxqBM6sDnMEuPmzTq8XN0OEwqKLLdh1jQI3qyE66Nzb3w== + dependencies: + ajv "^6.12.3" + har-schema "^2.0.0" + hard-rejection@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/hard-rejection/-/hard-rejection-2.1.0.tgz#1c6eda5c1685c63942766d79bb40ae773cecd883" @@ -5440,6 +5458,15 @@ http-proxy-agent@^4.0.1: agent-base "6" debug "4" +http-signature@~1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/http-signature/-/http-signature-1.2.0.tgz#9aecd925114772f3d95b65a60abb8f7c18fbace1" + integrity sha512-CAbnr6Rz4CYQkLYUtSNXxQPUH2gK8f3iWexVlsnMeD+GjlsQ0Xsy1cOX+mN3dtxYomRy21CiOzU8Uhw6OwncEQ== + dependencies: + assert-plus "^1.0.0" + jsprim "^1.2.2" + sshpk "^1.7.0" + http-signature@~1.3.6: version "1.3.6" resolved "https://registry.yarnpkg.com/http-signature/-/http-signature-1.3.6.tgz#cb6fbfdf86d1c974f343be94e87f7fc128662cf9" @@ -6668,6 +6695,16 @@ jsonfile@^6.0.1: optionalDependencies: graceful-fs "^4.1.6" +jsprim@^1.2.2: + version "1.4.2" + resolved "https://registry.yarnpkg.com/jsprim/-/jsprim-1.4.2.tgz#712c65533a15c878ba59e9ed5f0e26d5b77c5feb" + integrity sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw== + dependencies: + assert-plus "1.0.0" + extsprintf "1.3.0" + json-schema "0.4.0" + verror "1.10.0" + jsprim@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/jsprim/-/jsprim-2.0.2.tgz#77ca23dbcd4135cd364800d22ff82c2185803d4d" @@ -7033,12 +7070,14 @@ matrix-events-sdk@^0.0.1-beta.7: dependencies: "@babel/runtime" "^7.12.5" another-json "^0.2.0" + browser-request "^0.3.3" bs58 "^5.0.0" content-type "^1.0.4" loglevel "^1.7.1" matrix-events-sdk "^0.0.1-beta.7" p-retry "4" qs "^6.9.6" + request "^2.88.2" unhomoglyph "^1.0.6" matrix-mock-request@^2.5.0: @@ -7357,6 +7396,11 @@ nwsapi@^2.2.0: resolved "https://registry.yarnpkg.com/nwsapi/-/nwsapi-2.2.1.tgz#10a9f268fbf4c461249ebcfe38e359aa36e2577c" integrity sha512-JYOWTeFoS0Z93587vRJgASD5Ut11fYl5NyihP3KrYBvMe1FRRs6RN7m20SA/16GM4P6hTnZjT+UmDOt38UeXNg== +oauth-sign@~0.9.0: + version "0.9.0" + resolved "https://registry.yarnpkg.com/oauth-sign/-/oauth-sign-0.9.0.tgz#47a7b016baa68b5fa0ecf3dee08a85c679ac6455" + integrity sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ== + object-assign@^4.1.0, object-assign@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" @@ -8259,6 +8303,32 @@ request-progress@^3.0.0: dependencies: throttleit "^1.0.0" +request@^2.88.2: + version "2.88.2" + resolved "https://registry.yarnpkg.com/request/-/request-2.88.2.tgz#d73c918731cb5a87da047e207234146f664d12b3" + integrity sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw== + dependencies: + aws-sign2 "~0.7.0" + aws4 "^1.8.0" + caseless "~0.12.0" + combined-stream "~1.0.6" + extend "~3.0.2" + forever-agent "~0.6.1" + form-data "~2.3.2" + har-validator "~5.1.3" + http-signature "~1.2.0" + is-typedarray "~1.0.0" + isstream "~0.1.2" + json-stringify-safe "~5.0.1" + mime-types "~2.1.19" + oauth-sign "~0.9.0" + performance-now "^2.1.0" + qs "~6.5.2" + safe-buffer "^5.1.2" + tough-cookie "~2.5.0" + tunnel-agent "^0.6.0" + uuid "^3.3.2" + require-directory@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" @@ -8725,7 +8795,7 @@ sprintf-js@~1.0.2: resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" integrity sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g== -sshpk@^1.14.1: +sshpk@^1.14.1, sshpk@^1.7.0: version "1.17.0" resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.17.0.tgz#578082d92d4fe612b13007496e543fa0fbcbe4c5" integrity sha512-/9HIEs1ZXGhSPE8X6Ccm7Nam1z8KcoCqPdI7ecm1N33EzAetWahvQWVqLZtaZQ+IDKX4IyA2o0gBzqIMkAagHQ== @@ -9439,6 +9509,11 @@ util-deprecate@^1.0.2, util-deprecate@~1.0.1: resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== +uuid@^3.3.2: + version "3.4.0" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee" + integrity sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A== + uuid@^8.3.2: version "8.3.2" resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" From 84f2974b570fcb5c6cc4b5d2d1228b0e55c2767a Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Wed, 19 Oct 2022 12:04:15 +0200 Subject: [PATCH 08/11] Always show voice broadcasts tile (#9444) --- .../views/messages/MessageEvent.tsx | 40 +---------- .../views/messages/MessageEvent-test.tsx | 66 ++----------------- 2 files changed, 7 insertions(+), 99 deletions(-) diff --git a/src/components/views/messages/MessageEvent.tsx b/src/components/views/messages/MessageEvent.tsx index 91807d568f..858bf0eb6c 100644 --- a/src/components/views/messages/MessageEvent.tsx +++ b/src/components/views/messages/MessageEvent.tsx @@ -43,8 +43,6 @@ import MjolnirBody from "./MjolnirBody"; import MBeaconBody from "./MBeaconBody"; import { IEventTileOps } from "../rooms/EventTile"; import { VoiceBroadcastBody, VoiceBroadcastInfoEventType, VoiceBroadcastInfoState } from '../../../voice-broadcast'; -import { Features } from '../../../settings/Settings'; -import { SettingLevel } from '../../../settings/SettingLevel'; // onMessageAllowed is handled internally interface IProps extends Omit { @@ -58,18 +56,10 @@ interface IProps extends Omit([ [MsgType.Text, TextualBody], [MsgType.Notice, TextualBody], @@ -87,7 +77,7 @@ const baseEvTypes = new Map>>([ [M_BEACON_INFO.altName, MBeaconBody], ]); -export default class MessageEvent extends React.Component implements IMediaBody, IOperableEventTile { +export default class MessageEvent extends React.Component implements IMediaBody, IOperableEventTile { private body: React.RefObject = createRef(); private mediaHelper: MediaEventHelper; private bodyTypes = new Map(baseBodyTypes.entries()); @@ -95,7 +85,6 @@ export default class MessageEvent extends React.Component impleme public static contextType = MatrixClientContext; public context!: React.ContextType; - private voiceBroadcastSettingWatcherRef: string; public constructor(props: IProps, context: React.ContextType) { super(props, context); @@ -105,29 +94,15 @@ export default class MessageEvent extends React.Component impleme } this.updateComponentMaps(); - - this.state = { - // only check voice broadcast settings for a voice broadcast event - voiceBroadcastEnabled: this.props.mxEvent.getType() === VoiceBroadcastInfoEventType - && SettingsStore.getValue(Features.VoiceBroadcast), - }; } public componentDidMount(): void { this.props.mxEvent.addListener(MatrixEventEvent.Decrypted, this.onDecrypted); - - if (this.props.mxEvent.getType() === VoiceBroadcastInfoEventType) { - this.watchVoiceBroadcastFeatureSetting(); - } } public componentWillUnmount() { this.props.mxEvent.removeListener(MatrixEventEvent.Decrypted, this.onDecrypted); this.mediaHelper?.destroy(); - - if (this.voiceBroadcastSettingWatcherRef) { - SettingsStore.unwatchSetting(this.voiceBroadcastSettingWatcherRef); - } } public componentDidUpdate(prevProps: Readonly) { @@ -171,16 +146,6 @@ export default class MessageEvent extends React.Component impleme this.forceUpdate(); }; - private watchVoiceBroadcastFeatureSetting(): void { - this.voiceBroadcastSettingWatcherRef = SettingsStore.watchSetting( - Features.VoiceBroadcast, - null, - (settingName: string, roomId: string, atLevel: SettingLevel, newValAtLevel, newValue: boolean) => { - this.setState({ voiceBroadcastEnabled: newValue }); - }, - ); - } - public render() { const content = this.props.mxEvent.getContent(); const type = this.props.mxEvent.getType(); @@ -209,8 +174,7 @@ export default class MessageEvent extends React.Component impleme } if ( - this.state.voiceBroadcastEnabled - && type === VoiceBroadcastInfoEventType + type === VoiceBroadcastInfoEventType && content?.state === VoiceBroadcastInfoState.Started ) { BodyType = VoiceBroadcastBody; diff --git a/test/components/views/messages/MessageEvent-test.tsx b/test/components/views/messages/MessageEvent-test.tsx index 82442855fc..dadddca093 100644 --- a/test/components/views/messages/MessageEvent-test.tsx +++ b/test/components/views/messages/MessageEvent-test.tsx @@ -16,11 +16,9 @@ limitations under the License. import React from "react"; import { render, RenderResult } from "@testing-library/react"; -import { mocked } from "jest-mock"; import { MatrixClient, MatrixEvent, Room } from "matrix-js-sdk/src/matrix"; -import { Features } from "../../../../src/settings/Settings"; -import SettingsStore, { CallbackFn } from "../../../../src/settings/SettingsStore"; +import SettingsStore from "../../../../src/settings/SettingsStore"; import { VoiceBroadcastInfoEventType, VoiceBroadcastInfoState } from "../../../../src/voice-broadcast"; import { mkEvent, mkRoom, stubClient } from "../../../test-utils"; import MessageEvent from "../../../../src/components/views/messages/MessageEvent"; @@ -57,8 +55,7 @@ describe("MessageEvent", () => { }); describe("when a voice broadcast start event occurs", () => { - const voiceBroadcastSettingWatcherRef = "vb ref"; - let onVoiceBroadcastSettingChanged: CallbackFn; + let result: RenderResult; beforeEach(() => { event = mkEvent({ @@ -70,64 +67,11 @@ describe("MessageEvent", () => { state: VoiceBroadcastInfoState.Started, }, }); - - mocked(SettingsStore.watchSetting).mockImplementation( - (settingName: string, roomId: string | null, callbackFn: CallbackFn) => { - if (settingName === Features.VoiceBroadcast) { - onVoiceBroadcastSettingChanged = callbackFn; - return voiceBroadcastSettingWatcherRef; - } - }, - ); + result = renderMessageEvent(); }); - describe("and the voice broadcast feature is enabled", () => { - let result: RenderResult; - - beforeEach(() => { - mocked(SettingsStore.getValue).mockImplementation((settingName: string) => { - return settingName === Features.VoiceBroadcast; - }); - result = renderMessageEvent(); - }); - - it("should render a VoiceBroadcast component", () => { - result.getByTestId("voice-broadcast-body"); - }); - - describe("and switching the voice broadcast feature off", () => { - beforeEach(() => { - onVoiceBroadcastSettingChanged(Features.VoiceBroadcast, null, null, null, false); - }); - - it("should render an UnknownBody component", () => { - const result = renderMessageEvent(); - result.getByTestId("unknown-body"); - }); - }); - - describe("and unmounted", () => { - beforeEach(() => { - result.unmount(); - }); - - it("should unregister the settings watcher", () => { - expect(SettingsStore.unwatchSetting).toHaveBeenCalled(); - }); - }); - }); - - describe("and the voice broadcast feature is disabled", () => { - beforeEach(() => { - mocked(SettingsStore.getValue).mockImplementation((settingName: string) => { - return false; - }); - }); - - it("should render an UnknownBody component", () => { - const result = renderMessageEvent(); - result.getByTestId("unknown-body"); - }); + it("should render a VoiceBroadcast component", () => { + result.getByTestId("voice-broadcast-body"); }); }); }); From e946674df3be642eb06e17117390326d3d709df6 Mon Sep 17 00:00:00 2001 From: kegsay Date: Wed, 19 Oct 2022 13:07:03 +0100 Subject: [PATCH 09/11] Store refactor: use non-global stores in components (#9293) * Add Stores and StoresContext and use it in MatrixChat and RoomView Added a new kind of class: - Add God object `Stores` which will hold refs to all known stores and the `MatrixClient`. This object is NOT a singleton. - Add `StoresContext` to hold onto a ref of `Stores` for use inside components. `StoresContext` is created via: - Create `Stores` in `MatrixChat`, assigning the `MatrixClient` when we have one set. Currently sets the RVS to `RoomViewStore.instance`. - Wrap `MatrixChat`s `render()` function in a `StoresContext.Provider` so it can be used anywhere. `StoresContext` is currently only used in `RoomView` via the following changes: - Remove the HOC, which redundantly set `mxClient` as a prop. We don't need this as `RoomView` was using the client from `this.context`. - Change the type of context accepted from `MatrixClientContext` to `StoresContext`. - Modify alllll the places where `this.context` is used to interact with the client and suffix `.client`. - Modify places where we use `RoomViewStore.instance` and replace them with `this.context.roomViewStore`. This makes `RoomView` use a non-global instance of RVS. * Linting * SDKContext and make client an optional constructor arg * Move SDKContext to /src/contexts * Inject all RVS deps * Linting * Remove reset calls; deep copy the INITIAL_STATE to avoid test pollution * DI singletons used in RoomView; DI them in RoomView-test too * Initial RoomViewStore.instance after all files are imported to avoid cyclical deps * Lazily init stores to allow for circular dependencies Rather than stores accepting a list of other stores in their constructors, which doesn't work when A needs B and B needs A, make new-style stores simply accept Stores. When a store needs another store, they access it via `Stores` which then lazily constructs that store if it needs it. This breaks the circular dependency at constructor time, without needing to introduce wiring diagrams or any complex DI framework. * Delete RoomViewStore.instance Replaced with Stores.instance.roomViewStore * Linting * Move OverridableStores to test/TestStores * Rejig how eager stores get made; don't automatically do it else tests break * Linting * Linting and review comments * Fix new code to use Stores.instance * s/Stores/SdkContextClass/g * Update docs * Remove unused imports * Update src/stores/RoomViewStore.tsx Co-authored-by: Michael Telatynski <7t3chguy@gmail.com> * Remove empty c'tor to make sonar happy Co-authored-by: Michael Telatynski <7t3chguy@gmail.com> --- src/ContentMessages.ts | 4 +- src/Notifier.ts | 4 +- src/ScalarMessaging.ts | 4 +- src/SlashCommands.tsx | 8 +- src/audio/PlaybackQueue.ts | 4 +- src/components/structures/MatrixChat.tsx | 9 +- src/components/structures/RoomView.tsx | 239 +++++++++--------- src/components/structures/SpaceHierarchy.tsx | 4 +- src/components/structures/ThreadView.tsx | 4 +- .../views/beacon/RoomCallBanner.tsx | 4 +- .../views/context_menus/RoomContextMenu.tsx | 6 +- .../dialogs/spotlight/SpotlightDialog.tsx | 4 +- src/components/views/elements/AppTile.tsx | 4 +- .../views/right_panel/TimelineCard.tsx | 15 +- src/components/views/right_panel/UserInfo.tsx | 4 +- src/components/views/rooms/RoomList.tsx | 10 +- src/components/views/rooms/RoomTile.tsx | 8 +- .../views/spaces/QuickSettingsButton.tsx | 4 +- src/components/views/voip/PipView.tsx | 10 +- src/contexts/SDKContext.ts | 127 ++++++++++ src/stores/RoomViewStore.tsx | 38 ++- src/stores/right-panel/RightPanelStore.ts | 4 +- src/stores/room-list/RoomListStore.ts | 6 +- src/stores/room-list/SlidingRoomListStore.ts | 10 +- src/stores/spaces/SpaceStore.ts | 8 +- src/stores/widgets/StopGapWidget.ts | 6 +- src/stores/widgets/StopGapWidgetDriver.ts | 8 +- src/utils/DialogOpener.ts | 6 +- src/utils/leave-behaviour.ts | 4 +- src/utils/space.tsx | 6 +- test/SlashCommands-test.tsx | 8 +- test/TestStores.ts | 44 ++++ test/components/structures/RoomView-test.tsx | 41 +-- .../views/beacon/RoomCallBanner-test.tsx | 5 +- ...ewStore-test.tsx => RoomViewStore-test.ts} | 68 +++-- .../widgets/StopGapWidgetDriver-test.ts | 4 +- 36 files changed, 467 insertions(+), 275 deletions(-) create mode 100644 src/contexts/SDKContext.ts create mode 100644 test/TestStores.ts rename test/stores/{RoomViewStore-test.tsx => RoomViewStore-test.ts} (79%) diff --git a/src/ContentMessages.ts b/src/ContentMessages.ts index d4cf3cc0ab..8135eaab0e 100644 --- a/src/ContentMessages.ts +++ b/src/ContentMessages.ts @@ -43,7 +43,6 @@ import { RoomUpload } from "./models/RoomUpload"; import SettingsStore from "./settings/SettingsStore"; import { decorateStartSendingTime, sendRoundTripMetric } from "./sendTimePerformanceMetrics"; import { TimelineRenderingType } from "./contexts/RoomContext"; -import { RoomViewStore } from "./stores/RoomViewStore"; import { addReplyToMessageContent } from "./utils/Reply"; import ErrorDialog from "./components/views/dialogs/ErrorDialog"; import UploadFailureDialog from "./components/views/dialogs/UploadFailureDialog"; @@ -51,6 +50,7 @@ import UploadConfirmDialog from "./components/views/dialogs/UploadConfirmDialog" import { createThumbnail } from "./utils/image-media"; import { attachRelation } from "./components/views/rooms/SendMessageComposer"; import { doMaybeLocalRoomAction } from "./utils/local-room"; +import { SdkContextClass } from "./contexts/SDKContext"; // scraped out of a macOS hidpi (5660ppm) screenshot png // 5669 px (x-axis) , 5669 px (y-axis) , per metre @@ -361,7 +361,7 @@ export default class ContentMessages { return; } - const replyToEvent = RoomViewStore.instance.getQuotingEvent(); + const replyToEvent = SdkContextClass.instance.roomViewStore.getQuotingEvent(); if (!this.mediaConfig) { // hot-path optimization to not flash a spinner if we don't need to const modal = Modal.createDialog(Spinner, null, 'mx_Dialog_spinner'); await this.ensureMediaConfigFetched(matrixClient); diff --git a/src/Notifier.ts b/src/Notifier.ts index dd0ebc296a..cc84acb2fa 100644 --- a/src/Notifier.ts +++ b/src/Notifier.ts @@ -41,12 +41,12 @@ import SettingsStore from "./settings/SettingsStore"; import { hideToast as hideNotificationsToast } from "./toasts/DesktopNotificationsToast"; import { SettingLevel } from "./settings/SettingLevel"; import { isPushNotifyDisabled } from "./settings/controllers/NotificationControllers"; -import { RoomViewStore } from "./stores/RoomViewStore"; import UserActivity from "./UserActivity"; import { mediaFromMxc } from "./customisations/Media"; import ErrorDialog from "./components/views/dialogs/ErrorDialog"; import LegacyCallHandler from "./LegacyCallHandler"; import VoipUserMapper from "./VoipUserMapper"; +import { SdkContextClass } from "./contexts/SDKContext"; import { localNotificationsAreSilenced } from "./utils/notifications"; import { getIncomingCallToastKey, IncomingCallToast } from "./toasts/IncomingCallToast"; import ToastStore from "./stores/ToastStore"; @@ -435,7 +435,7 @@ export const Notifier = { if (actions?.notify) { this._performCustomEventHandling(ev); - if (RoomViewStore.instance.getRoomId() === room.roomId && + if (SdkContextClass.instance.roomViewStore.getRoomId() === room.roomId && UserActivity.sharedInstance().userActiveRecently() && !Modal.hasDialogs() ) { diff --git a/src/ScalarMessaging.ts b/src/ScalarMessaging.ts index c511d291ce..72ff94d4d3 100644 --- a/src/ScalarMessaging.ts +++ b/src/ScalarMessaging.ts @@ -272,12 +272,12 @@ import { logger } from "matrix-js-sdk/src/logger"; import { MatrixClientPeg } from './MatrixClientPeg'; import dis from './dispatcher/dispatcher'; import WidgetUtils from './utils/WidgetUtils'; -import { RoomViewStore } from './stores/RoomViewStore'; import { _t } from './languageHandler'; import { IntegrationManagers } from "./integrations/IntegrationManagers"; import { WidgetType } from "./widgets/WidgetType"; import { objectClone } from "./utils/objects"; import { EffectiveMembership, getEffectiveMembership } from './utils/membership'; +import { SdkContextClass } from './contexts/SDKContext'; enum Action { CloseScalar = "close_scalar", @@ -721,7 +721,7 @@ const onMessage = function(event: MessageEvent): void { } } - if (roomId !== RoomViewStore.instance.getRoomId()) { + if (roomId !== SdkContextClass.instance.roomViewStore.getRoomId()) { sendError(event, _t('Room %(roomId)s not visible', { roomId: roomId })); return; } diff --git a/src/SlashCommands.tsx b/src/SlashCommands.tsx index bbd936ce75..624c515b15 100644 --- a/src/SlashCommands.tsx +++ b/src/SlashCommands.tsx @@ -62,7 +62,6 @@ import InfoDialog from "./components/views/dialogs/InfoDialog"; import SlashCommandHelpDialog from "./components/views/dialogs/SlashCommandHelpDialog"; import { shouldShowComponent } from "./customisations/helpers/UIComponents"; import { TimelineRenderingType } from './contexts/RoomContext'; -import { RoomViewStore } from "./stores/RoomViewStore"; import { XOR } from "./@types/common"; import { PosthogAnalytics } from "./PosthogAnalytics"; import { ViewRoomPayload } from "./dispatcher/payloads/ViewRoomPayload"; @@ -70,6 +69,7 @@ import VoipUserMapper from './VoipUserMapper'; import { htmlSerializeFromMdIfNeeded } from './editor/serialize'; import { leaveRoomBehaviour } from "./utils/leave-behaviour"; import { isLocalRoom } from './utils/localRoom/isLocalRoom'; +import { SdkContextClass } from './contexts/SDKContext'; // XXX: workaround for https://github.com/microsoft/TypeScript/issues/31816 interface HTMLInputEvent extends Event { @@ -209,7 +209,7 @@ function successSync(value: any) { const isCurrentLocalRoom = (): boolean => { const cli = MatrixClientPeg.get(); - const room = cli.getRoom(RoomViewStore.instance.getRoomId()); + const room = cli.getRoom(SdkContextClass.instance.roomViewStore.getRoomId()); return isLocalRoom(room); }; @@ -868,7 +868,7 @@ export const Commands = [ description: _td('Define the power level of a user'), isEnabled(): boolean { const cli = MatrixClientPeg.get(); - const room = cli.getRoom(RoomViewStore.instance.getRoomId()); + const room = cli.getRoom(SdkContextClass.instance.roomViewStore.getRoomId()); return room?.currentState.maySendStateEvent(EventType.RoomPowerLevels, cli.getUserId()) && !isLocalRoom(room); }, @@ -909,7 +909,7 @@ export const Commands = [ description: _td('Deops user with given id'), isEnabled(): boolean { const cli = MatrixClientPeg.get(); - const room = cli.getRoom(RoomViewStore.instance.getRoomId()); + const room = cli.getRoom(SdkContextClass.instance.roomViewStore.getRoomId()); return room?.currentState.maySendStateEvent(EventType.RoomPowerLevels, cli.getUserId()) && !isLocalRoom(room); }, diff --git a/src/audio/PlaybackQueue.ts b/src/audio/PlaybackQueue.ts index 72ed8cf169..c5a6ee64f2 100644 --- a/src/audio/PlaybackQueue.ts +++ b/src/audio/PlaybackQueue.ts @@ -25,7 +25,7 @@ import { MatrixClientPeg } from "../MatrixClientPeg"; import { arrayFastClone } from "../utils/arrays"; import { PlaybackManager } from "./PlaybackManager"; import { isVoiceMessage } from "../utils/EventUtils"; -import { RoomViewStore } from "../stores/RoomViewStore"; +import { SdkContextClass } from "../contexts/SDKContext"; /** * Audio playback queue management for a given room. This keeps track of where the user @@ -51,7 +51,7 @@ export class PlaybackQueue { constructor(private room: Room) { this.loadClocks(); - RoomViewStore.instance.addRoomListener(this.room.roomId, (isActive) => { + SdkContextClass.instance.roomViewStore.addRoomListener(this.room.roomId, (isActive) => { if (!isActive) return; // Reset the state of the playbacks before they start mounting and enqueuing updates. diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index 515355b63d..06a73ff605 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -137,6 +137,7 @@ import { TimelineRenderingType } from "../../contexts/RoomContext"; import { UseCaseSelection } from '../views/elements/UseCaseSelection'; import { ValidatedServerConfig } from '../../utils/ValidatedServerConfig'; import { isLocalRoom } from '../../utils/localRoom/isLocalRoom'; +import { SdkContextClass, SDKContext } from '../../contexts/SDKContext'; import { viewUserDeviceSettings } from '../../actions/handlers/viewUserDeviceSettings'; // legacy export @@ -238,9 +239,12 @@ export default class MatrixChat extends React.PureComponent { private readonly dispatcherRef: string; private readonly themeWatcher: ThemeWatcher; private readonly fontWatcher: FontWatcher; + private readonly stores: SdkContextClass; constructor(props: IProps) { super(props); + this.stores = SdkContextClass.instance; + this.stores.constructEagerStores(); this.state = { view: Views.LOADING, @@ -762,6 +766,7 @@ export default class MatrixChat extends React.PureComponent { Modal.createDialog(DialPadModal, {}, "mx_Dialog_dialPadWrapper"); break; case Action.OnLoggedIn: + this.stores.client = MatrixClientPeg.get(); if ( // Skip this handling for token login as that always calls onLoggedIn itself !this.tokenLogin && @@ -2087,7 +2092,9 @@ export default class MatrixChat extends React.PureComponent { } return - { view } + + { view } + ; } } diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index 6425709ea7..77245e0eb8 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -44,21 +44,18 @@ import { RoomPermalinkCreator } from '../../utils/permalinks/Permalinks'; import ResizeNotifier from '../../utils/ResizeNotifier'; import ContentMessages from '../../ContentMessages'; import Modal from '../../Modal'; -import LegacyCallHandler, { LegacyCallHandlerEvent } from '../../LegacyCallHandler'; +import { LegacyCallHandlerEvent } from '../../LegacyCallHandler'; import dis, { defaultDispatcher } from '../../dispatcher/dispatcher'; import * as Rooms from '../../Rooms'; import eventSearch, { searchPagination } from '../../Searching'; import MainSplit from './MainSplit'; import RightPanel from './RightPanel'; -import { RoomViewStore } from '../../stores/RoomViewStore'; import RoomScrollStateStore, { ScrollState } from '../../stores/RoomScrollStateStore'; import WidgetEchoStore from '../../stores/WidgetEchoStore'; import SettingsStore from "../../settings/SettingsStore"; import { Layout } from "../../settings/enums/Layout"; import AccessibleButton from "../views/elements/AccessibleButton"; -import RightPanelStore from "../../stores/right-panel/RightPanelStore"; import RoomContext, { TimelineRenderingType } from "../../contexts/RoomContext"; -import MatrixClientContext, { MatrixClientProps, withMatrixClientHOC } from "../../contexts/MatrixClientContext"; import { E2EStatus, shieldStatusForRoom } from '../../utils/ShieldUtils'; import { Action } from "../../dispatcher/actions"; import { IMatrixClientCreds } from "../../MatrixClientPeg"; @@ -76,12 +73,10 @@ import { IOOBData, IThreepidInvite } from "../../stores/ThreepidInviteStore"; import EffectsOverlay from "../views/elements/EffectsOverlay"; import { containsEmoji } from '../../effects/utils'; import { CHAT_EFFECTS } from '../../effects'; -import WidgetStore from "../../stores/WidgetStore"; import { CallView } from "../views/voip/CallView"; import { UPDATE_EVENT } from "../../stores/AsyncStore"; import Notifier from "../../Notifier"; import { showToast as showNotificationsToast } from "../../toasts/DesktopNotificationsToast"; -import { RoomNotificationStateStore } from "../../stores/notifications/RoomNotificationStateStore"; import { Container, WidgetLayoutStore } from "../../stores/widgets/WidgetLayoutStore"; import { getKeyBindingsManager } from '../../KeyBindingsManager'; import { objectHasDiff } from "../../utils/objects"; @@ -120,6 +115,7 @@ import { RoomStatusBarUnsentMessages } from './RoomStatusBarUnsentMessages'; import { LargeLoader } from './LargeLoader'; import { VoiceBroadcastInfoEventType } from '../../voice-broadcast'; import { isVideoRoom } from '../../utils/video-rooms'; +import { SDKContext } from '../../contexts/SDKContext'; import { CallStore, CallStoreEvent } from "../../stores/CallStore"; import { Call } from "../../models/Call"; @@ -133,7 +129,7 @@ if (DEBUG) { debuglog = logger.log.bind(console); } -interface IRoomProps extends MatrixClientProps { +interface IRoomProps { threepidInvite: IThreepidInvite; oobData?: IOOBData; @@ -381,13 +377,13 @@ export class RoomView extends React.Component { private messagePanel: TimelinePanel; private roomViewBody = createRef(); - static contextType = MatrixClientContext; - public context!: React.ContextType; + static contextType = SDKContext; + public context!: React.ContextType; - constructor(props: IRoomProps, context: React.ContextType) { + constructor(props: IRoomProps, context: React.ContextType) { super(props, context); - const llMembers = context.hasLazyLoadMembersEnabled(); + const llMembers = context.client.hasLazyLoadMembersEnabled(); this.state = { roomId: null, roomLoading: true, @@ -422,7 +418,7 @@ export class RoomView extends React.Component { showJoinLeaves: true, showAvatarChanges: true, showDisplaynameChanges: true, - matrixClientIsReady: context?.isInitialSyncComplete(), + matrixClientIsReady: context.client?.isInitialSyncComplete(), mainSplitContentType: MainSplitContentType.Timeline, timelineRenderingType: TimelineRenderingType.Room, liveTimeline: undefined, @@ -430,25 +426,25 @@ export class RoomView extends React.Component { }; this.dispatcherRef = dis.register(this.onAction); - context.on(ClientEvent.Room, this.onRoom); - context.on(RoomEvent.Timeline, this.onRoomTimeline); - context.on(RoomEvent.TimelineReset, this.onRoomTimelineReset); - context.on(RoomEvent.Name, this.onRoomName); - context.on(RoomStateEvent.Events, this.onRoomStateEvents); - context.on(RoomStateEvent.Update, this.onRoomStateUpdate); - context.on(RoomEvent.MyMembership, this.onMyMembership); - context.on(CryptoEvent.KeyBackupStatus, this.onKeyBackupStatus); - context.on(CryptoEvent.DeviceVerificationChanged, this.onDeviceVerificationChanged); - context.on(CryptoEvent.UserTrustStatusChanged, this.onUserVerificationChanged); - context.on(CryptoEvent.KeysChanged, this.onCrossSigningKeysChanged); - context.on(MatrixEventEvent.Decrypted, this.onEventDecrypted); + context.client.on(ClientEvent.Room, this.onRoom); + context.client.on(RoomEvent.Timeline, this.onRoomTimeline); + context.client.on(RoomEvent.TimelineReset, this.onRoomTimelineReset); + context.client.on(RoomEvent.Name, this.onRoomName); + context.client.on(RoomStateEvent.Events, this.onRoomStateEvents); + context.client.on(RoomStateEvent.Update, this.onRoomStateUpdate); + context.client.on(RoomEvent.MyMembership, this.onMyMembership); + context.client.on(CryptoEvent.KeyBackupStatus, this.onKeyBackupStatus); + context.client.on(CryptoEvent.DeviceVerificationChanged, this.onDeviceVerificationChanged); + context.client.on(CryptoEvent.UserTrustStatusChanged, this.onUserVerificationChanged); + context.client.on(CryptoEvent.KeysChanged, this.onCrossSigningKeysChanged); + context.client.on(MatrixEventEvent.Decrypted, this.onEventDecrypted); // Start listening for RoomViewStore updates - RoomViewStore.instance.on(UPDATE_EVENT, this.onRoomViewStoreUpdate); + context.roomViewStore.on(UPDATE_EVENT, this.onRoomViewStoreUpdate); - RightPanelStore.instance.on(UPDATE_EVENT, this.onRightPanelStoreUpdate); + context.rightPanelStore.on(UPDATE_EVENT, this.onRightPanelStoreUpdate); WidgetEchoStore.on(UPDATE_EVENT, this.onWidgetEchoStoreUpdate); - WidgetStore.instance.on(UPDATE_EVENT, this.onWidgetStoreUpdate); + context.widgetStore.on(UPDATE_EVENT, this.onWidgetStoreUpdate); CallStore.instance.on(CallStoreEvent.ActiveCalls, this.onActiveCalls); @@ -501,16 +497,16 @@ export class RoomView extends React.Component { action: "appsDrawer", show: true, }); - if (WidgetLayoutStore.instance.hasMaximisedWidget(this.state.room)) { + if (this.context.widgetLayoutStore.hasMaximisedWidget(this.state.room)) { // Show chat in right panel when a widget is maximised - RightPanelStore.instance.setCard({ phase: RightPanelPhases.Timeline }); + this.context.rightPanelStore.setCard({ phase: RightPanelPhases.Timeline }); } this.checkWidgets(this.state.room); }; private checkWidgets = (room: Room): void => { this.setState({ - hasPinnedWidgets: WidgetLayoutStore.instance.hasPinnedWidgets(room), + hasPinnedWidgets: this.context.widgetLayoutStore.hasPinnedWidgets(room), mainSplitContentType: this.getMainSplitContentType(room), showApps: this.shouldShowApps(room), }); @@ -518,12 +514,12 @@ export class RoomView extends React.Component { private getMainSplitContentType = (room: Room) => { if ( - (SettingsStore.getValue("feature_group_calls") && RoomViewStore.instance.isViewingCall()) + (SettingsStore.getValue("feature_group_calls") && this.context.roomViewStore.isViewingCall()) || isVideoRoom(room) ) { return MainSplitContentType.Call; } - if (WidgetLayoutStore.instance.hasMaximisedWidget(room)) { + if (this.context.widgetLayoutStore.hasMaximisedWidget(room)) { return MainSplitContentType.MaximisedWidget; } return MainSplitContentType.Timeline; @@ -534,7 +530,7 @@ export class RoomView extends React.Component { return; } - if (!initial && this.state.roomId !== RoomViewStore.instance.getRoomId()) { + if (!initial && this.state.roomId !== this.context.roomViewStore.getRoomId()) { // RoomView explicitly does not support changing what room // is being viewed: instead it should just be re-mounted when // switching rooms. Therefore, if the room ID changes, we @@ -549,45 +545,45 @@ export class RoomView extends React.Component { return; } - const roomId = RoomViewStore.instance.getRoomId(); - const room = this.context.getRoom(roomId); + const roomId = this.context.roomViewStore.getRoomId(); + const room = this.context.client.getRoom(roomId); // This convoluted type signature ensures we get IntelliSense *and* correct typing const newState: Partial & Pick = { roomId, - roomAlias: RoomViewStore.instance.getRoomAlias(), - roomLoading: RoomViewStore.instance.isRoomLoading(), - roomLoadError: RoomViewStore.instance.getRoomLoadError(), - joining: RoomViewStore.instance.isJoining(), - replyToEvent: RoomViewStore.instance.getQuotingEvent(), + roomAlias: this.context.roomViewStore.getRoomAlias(), + roomLoading: this.context.roomViewStore.isRoomLoading(), + roomLoadError: this.context.roomViewStore.getRoomLoadError(), + joining: this.context.roomViewStore.isJoining(), + replyToEvent: this.context.roomViewStore.getQuotingEvent(), // we should only peek once we have a ready client - shouldPeek: this.state.matrixClientIsReady && RoomViewStore.instance.shouldPeek(), + shouldPeek: this.state.matrixClientIsReady && this.context.roomViewStore.shouldPeek(), showReadReceipts: SettingsStore.getValue("showReadReceipts", roomId), showRedactions: SettingsStore.getValue("showRedactions", roomId), showJoinLeaves: SettingsStore.getValue("showJoinLeaves", roomId), showAvatarChanges: SettingsStore.getValue("showAvatarChanges", roomId), showDisplaynameChanges: SettingsStore.getValue("showDisplaynameChanges", roomId), - wasContextSwitch: RoomViewStore.instance.getWasContextSwitch(), + wasContextSwitch: this.context.roomViewStore.getWasContextSwitch(), mainSplitContentType: room === null ? undefined : this.getMainSplitContentType(room), initialEventId: null, // default to clearing this, will get set later in the method if needed - showRightPanel: RightPanelStore.instance.isOpenForRoom(roomId), + showRightPanel: this.context.rightPanelStore.isOpenForRoom(roomId), activeCall: CallStore.instance.getActiveCall(roomId), }; if ( this.state.mainSplitContentType !== MainSplitContentType.Timeline && newState.mainSplitContentType === MainSplitContentType.Timeline - && RightPanelStore.instance.isOpen - && RightPanelStore.instance.currentCard.phase === RightPanelPhases.Timeline - && RightPanelStore.instance.roomPhaseHistory.some(card => (card.phase === RightPanelPhases.Timeline)) + && this.context.rightPanelStore.isOpen + && this.context.rightPanelStore.currentCard.phase === RightPanelPhases.Timeline + && this.context.rightPanelStore.roomPhaseHistory.some(card => (card.phase === RightPanelPhases.Timeline)) ) { // We're returning to the main timeline, so hide the right panel timeline - RightPanelStore.instance.setCard({ phase: RightPanelPhases.RoomSummary }); - RightPanelStore.instance.togglePanel(this.state.roomId ?? null); + this.context.rightPanelStore.setCard({ phase: RightPanelPhases.RoomSummary }); + this.context.rightPanelStore.togglePanel(this.state.roomId ?? null); newState.showRightPanel = false; } - const initialEventId = RoomViewStore.instance.getInitialEventId(); + const initialEventId = this.context.roomViewStore.getInitialEventId(); if (initialEventId) { let initialEvent = room?.findEventById(initialEventId); // The event does not exist in the current sync data @@ -600,7 +596,7 @@ export class RoomView extends React.Component { // becomes available to fetch a whole thread if (!initialEvent) { initialEvent = await fetchInitialEvent( - this.context, + this.context.client, roomId, initialEventId, ); @@ -616,21 +612,21 @@ export class RoomView extends React.Component { action: Action.ShowThread, rootEvent: thread.rootEvent, initialEvent, - highlighted: RoomViewStore.instance.isInitialEventHighlighted(), - scroll_into_view: RoomViewStore.instance.initialEventScrollIntoView(), + highlighted: this.context.roomViewStore.isInitialEventHighlighted(), + scroll_into_view: this.context.roomViewStore.initialEventScrollIntoView(), }); } else { newState.initialEventId = initialEventId; - newState.isInitialEventHighlighted = RoomViewStore.instance.isInitialEventHighlighted(); - newState.initialEventScrollIntoView = RoomViewStore.instance.initialEventScrollIntoView(); + newState.isInitialEventHighlighted = this.context.roomViewStore.isInitialEventHighlighted(); + newState.initialEventScrollIntoView = this.context.roomViewStore.initialEventScrollIntoView(); if (thread && initialEvent?.isThreadRoot) { dis.dispatch({ action: Action.ShowThread, rootEvent: thread.rootEvent, initialEvent, - highlighted: RoomViewStore.instance.isInitialEventHighlighted(), - scroll_into_view: RoomViewStore.instance.initialEventScrollIntoView(), + highlighted: this.context.roomViewStore.isInitialEventHighlighted(), + scroll_into_view: this.context.roomViewStore.initialEventScrollIntoView(), }); } } @@ -657,7 +653,7 @@ export class RoomView extends React.Component { if (!initial && this.state.shouldPeek && !newState.shouldPeek) { // Stop peeking because we have joined this room now - this.context.stopPeeking(); + this.context.client.stopPeeking(); } // Temporary logging to diagnose https://github.com/vector-im/element-web/issues/4307 @@ -674,7 +670,7 @@ export class RoomView extends React.Component { // NB: This does assume that the roomID will not change for the lifetime of // the RoomView instance if (initial) { - newState.room = this.context.getRoom(newState.roomId); + newState.room = this.context.client.getRoom(newState.roomId); if (newState.room) { newState.showApps = this.shouldShowApps(newState.room); this.onRoomLoaded(newState.room); @@ -784,7 +780,7 @@ export class RoomView extends React.Component { peekLoading: true, isPeeking: true, // this will change to false if peeking fails }); - this.context.peekInRoom(roomId).then((room) => { + this.context.client.peekInRoom(roomId).then((room) => { if (this.unmounted) { return; } @@ -817,7 +813,7 @@ export class RoomView extends React.Component { }); } else if (room) { // Stop peeking because we have joined this room previously - this.context.stopPeeking(); + this.context.client.stopPeeking(); this.setState({ isPeeking: false }); } } @@ -835,7 +831,7 @@ export class RoomView extends React.Component { // Otherwise (in case the user set hideWidgetDrawer by clicking the button) follow the parameter. const isManuallyShown = hideWidgetDrawer ? hideWidgetDrawer === "false": true; - const widgets = WidgetLayoutStore.instance.getContainerWidgets(room, Container.Top); + const widgets = this.context.widgetLayoutStore.getContainerWidgets(room, Container.Top); return isManuallyShown && widgets.length > 0; } @@ -848,7 +844,7 @@ export class RoomView extends React.Component { callState: callState, }); - LegacyCallHandler.instance.on(LegacyCallHandlerEvent.CallState, this.onCallState); + this.context.legacyCallHandler.on(LegacyCallHandlerEvent.CallState, this.onCallState); window.addEventListener('beforeunload', this.onPageUnload); } @@ -885,7 +881,7 @@ export class RoomView extends React.Component { // (We could use isMounted, but facebook have deprecated that.) this.unmounted = true; - LegacyCallHandler.instance.removeListener(LegacyCallHandlerEvent.CallState, this.onCallState); + this.context.legacyCallHandler.removeListener(LegacyCallHandlerEvent.CallState, this.onCallState); // update the scroll map before we get unmounted if (this.state.roomId) { @@ -893,47 +889,47 @@ export class RoomView extends React.Component { } if (this.state.shouldPeek) { - this.context.stopPeeking(); + this.context.client.stopPeeking(); } // stop tracking room changes to format permalinks this.stopAllPermalinkCreators(); dis.unregister(this.dispatcherRef); - if (this.context) { - this.context.removeListener(ClientEvent.Room, this.onRoom); - this.context.removeListener(RoomEvent.Timeline, this.onRoomTimeline); - this.context.removeListener(RoomEvent.TimelineReset, this.onRoomTimelineReset); - this.context.removeListener(RoomEvent.Name, this.onRoomName); - this.context.removeListener(RoomStateEvent.Events, this.onRoomStateEvents); - this.context.removeListener(RoomEvent.MyMembership, this.onMyMembership); - this.context.removeListener(RoomStateEvent.Update, this.onRoomStateUpdate); - this.context.removeListener(CryptoEvent.KeyBackupStatus, this.onKeyBackupStatus); - this.context.removeListener(CryptoEvent.DeviceVerificationChanged, this.onDeviceVerificationChanged); - this.context.removeListener(CryptoEvent.UserTrustStatusChanged, this.onUserVerificationChanged); - this.context.removeListener(CryptoEvent.KeysChanged, this.onCrossSigningKeysChanged); - this.context.removeListener(MatrixEventEvent.Decrypted, this.onEventDecrypted); + if (this.context.client) { + this.context.client.removeListener(ClientEvent.Room, this.onRoom); + this.context.client.removeListener(RoomEvent.Timeline, this.onRoomTimeline); + this.context.client.removeListener(RoomEvent.TimelineReset, this.onRoomTimelineReset); + this.context.client.removeListener(RoomEvent.Name, this.onRoomName); + this.context.client.removeListener(RoomStateEvent.Events, this.onRoomStateEvents); + this.context.client.removeListener(RoomEvent.MyMembership, this.onMyMembership); + this.context.client.removeListener(RoomStateEvent.Update, this.onRoomStateUpdate); + this.context.client.removeListener(CryptoEvent.KeyBackupStatus, this.onKeyBackupStatus); + this.context.client.removeListener(CryptoEvent.DeviceVerificationChanged, this.onDeviceVerificationChanged); + this.context.client.removeListener(CryptoEvent.UserTrustStatusChanged, this.onUserVerificationChanged); + this.context.client.removeListener(CryptoEvent.KeysChanged, this.onCrossSigningKeysChanged); + this.context.client.removeListener(MatrixEventEvent.Decrypted, this.onEventDecrypted); } window.removeEventListener('beforeunload', this.onPageUnload); - RoomViewStore.instance.off(UPDATE_EVENT, this.onRoomViewStoreUpdate); + this.context.roomViewStore.off(UPDATE_EVENT, this.onRoomViewStoreUpdate); - RightPanelStore.instance.off(UPDATE_EVENT, this.onRightPanelStoreUpdate); + this.context.rightPanelStore.off(UPDATE_EVENT, this.onRightPanelStoreUpdate); WidgetEchoStore.removeListener(UPDATE_EVENT, this.onWidgetEchoStoreUpdate); - WidgetStore.instance.removeListener(UPDATE_EVENT, this.onWidgetStoreUpdate); + this.context.widgetStore.removeListener(UPDATE_EVENT, this.onWidgetStoreUpdate); this.props.resizeNotifier.off("isResizing", this.onIsResizing); if (this.state.room) { - WidgetLayoutStore.instance.off( + this.context.widgetLayoutStore.off( WidgetLayoutStore.emissionForRoom(this.state.room), this.onWidgetLayoutChange, ); } CallStore.instance.off(CallStoreEvent.ActiveCalls, this.onActiveCalls); - LegacyCallHandler.instance.off(LegacyCallHandlerEvent.CallState, this.onCallState); + this.context.legacyCallHandler.off(LegacyCallHandlerEvent.CallState, this.onCallState); // cancel any pending calls to the throttled updated this.updateRoomMembers.cancel(); @@ -944,13 +940,13 @@ export class RoomView extends React.Component { if (this.viewsLocalRoom) { // clean up if this was a local room - this.props.mxClient.store.removeRoom(this.state.room.roomId); + this.context.client.store.removeRoom(this.state.room.roomId); } } private onRightPanelStoreUpdate = () => { this.setState({ - showRightPanel: RightPanelStore.instance.isOpenForRoom(this.state.roomId), + showRightPanel: this.context.rightPanelStore.isOpenForRoom(this.state.roomId), }); }; @@ -1017,7 +1013,7 @@ export class RoomView extends React.Component { break; case 'picture_snapshot': ContentMessages.sharedInstance().sendContentListToRoom( - [payload.file], this.state.room.roomId, null, this.context); + [payload.file], this.state.room.roomId, null, this.context.client); break; case 'notifier_enabled': case Action.UploadStarted: @@ -1043,7 +1039,7 @@ export class RoomView extends React.Component { case 'MatrixActions.sync': if (!this.state.matrixClientIsReady) { this.setState({ - matrixClientIsReady: this.context?.isInitialSyncComplete(), + matrixClientIsReady: this.context.client?.isInitialSyncComplete(), }, () => { // send another "initial" RVS update to trigger peeking if needed this.onRoomViewStoreUpdate(true); @@ -1112,7 +1108,7 @@ export class RoomView extends React.Component { private onLocalRoomEvent(roomId: string) { if (roomId !== this.state.room.roomId) return; - createRoomFromLocalRoom(this.props.mxClient, this.state.room as LocalRoom); + createRoomFromLocalRoom(this.context.client, this.state.room as LocalRoom); } private onRoomTimeline = (ev: MatrixEvent, room: Room | null, toStartOfTimeline: boolean, removed, data) => { @@ -1145,7 +1141,7 @@ export class RoomView extends React.Component { this.handleEffects(ev); } - if (ev.getSender() !== this.context.credentials.userId) { + if (ev.getSender() !== this.context.client.credentials.userId) { // update unread count when scrolled up if (!this.state.searchResults && this.state.atEndOfLiveTimeline) { // no change @@ -1165,7 +1161,7 @@ export class RoomView extends React.Component { }; private handleEffects = (ev: MatrixEvent) => { - const notifState = RoomNotificationStateStore.instance.getRoomState(this.state.room); + const notifState = this.context.roomNotificationStateStore.getRoomState(this.state.room); if (!notifState.isUnread) return; CHAT_EFFECTS.forEach(effect => { @@ -1202,7 +1198,7 @@ export class RoomView extends React.Component { private onRoomLoaded = (room: Room) => { if (this.unmounted) return; // Attach a widget store listener only when we get a room - WidgetLayoutStore.instance.on(WidgetLayoutStore.emissionForRoom(room), this.onWidgetLayoutChange); + this.context.widgetLayoutStore.on(WidgetLayoutStore.emissionForRoom(room), this.onWidgetLayoutChange); this.calculatePeekRules(room); this.updatePreviewUrlVisibility(room); @@ -1214,10 +1210,10 @@ export class RoomView extends React.Component { if ( this.getMainSplitContentType(room) !== MainSplitContentType.Timeline - && RoomNotificationStateStore.instance.getRoomState(room).isUnread + && this.context.roomNotificationStateStore.getRoomState(room).isUnread ) { // Automatically open the chat panel to make unread messages easier to discover - RightPanelStore.instance.setCard({ phase: RightPanelPhases.Timeline }, true, room.roomId); + this.context.rightPanelStore.setCard({ phase: RightPanelPhases.Timeline }, true, room.roomId); } this.setState({ @@ -1244,7 +1240,7 @@ export class RoomView extends React.Component { private async loadMembersIfJoined(room: Room) { // lazy load members if enabled - if (this.context.hasLazyLoadMembersEnabled()) { + if (this.context.client.hasLazyLoadMembersEnabled()) { if (room && room.getMyMembership() === 'join') { try { await room.loadMembersIfNeeded(); @@ -1270,7 +1266,7 @@ export class RoomView extends React.Component { private updatePreviewUrlVisibility({ roomId }: Room) { // URL Previews in E2EE rooms can be a privacy leak so use a different setting which is per-room explicit - const key = this.context.isRoomEncrypted(roomId) ? 'urlPreviewsEnabled_e2ee' : 'urlPreviewsEnabled'; + const key = this.context.client.isRoomEncrypted(roomId) ? 'urlPreviewsEnabled_e2ee' : 'urlPreviewsEnabled'; this.setState({ showUrlPreview: SettingsStore.getValue(key, roomId), }); @@ -1283,7 +1279,7 @@ export class RoomView extends React.Component { // Detach the listener if the room is changing for some reason if (this.state.room) { - WidgetLayoutStore.instance.off( + this.context.widgetLayoutStore.off( WidgetLayoutStore.emissionForRoom(this.state.room), this.onWidgetLayoutChange, ); @@ -1320,15 +1316,15 @@ export class RoomView extends React.Component { }; private async updateE2EStatus(room: Room) { - if (!this.context.isRoomEncrypted(room.roomId)) return; + if (!this.context.client.isRoomEncrypted(room.roomId)) return; // If crypto is not currently enabled, we aren't tracking devices at all, // so we don't know what the answer is. Let's error on the safe side and show // a warning for this case. let e2eStatus = E2EStatus.Warning; - if (this.context.isCryptoEnabled()) { + if (this.context.client.isCryptoEnabled()) { /* At this point, the user has encryption on and cross-signing on */ - e2eStatus = await shieldStatusForRoom(this.context, room); + e2eStatus = await shieldStatusForRoom(this.context.client, room); } if (this.unmounted) return; @@ -1374,7 +1370,7 @@ export class RoomView extends React.Component { private updatePermissions(room: Room) { if (room) { - const me = this.context.getUserId(); + const me = this.context.client.getUserId(); const canReact = ( room.getMyMembership() === "join" && room.currentState.maySendEvent(EventType.Reaction, me) @@ -1442,7 +1438,7 @@ export class RoomView extends React.Component { private onJoinButtonClicked = () => { // If the user is a ROU, allow them to transition to a PWLU - if (this.context?.isGuest()) { + if (this.context.client?.isGuest()) { // Join this room once the user has registered and logged in // (If we failed to peek, we may not have a valid room object.) dis.dispatch>({ @@ -1499,13 +1495,13 @@ export class RoomView extends React.Component { }; private injectSticker(url: string, info: object, text: string, threadId: string | null) { - if (this.context.isGuest()) { + if (this.context.client.isGuest()) { dis.dispatch({ action: 'require_registration' }); return; } ContentMessages.sharedInstance() - .sendStickerContentToRoom(url, this.state.room.roomId, threadId, info, text, this.context) + .sendStickerContentToRoom(url, this.state.room.roomId, threadId, info, text, this.context.client) .then(undefined, (error) => { if (error.name === "UnknownDeviceError") { // Let the staus bar handle this @@ -1578,7 +1574,7 @@ export class RoomView extends React.Component { return b.length - a.length; }); - if (this.context.supportsExperimentalThreads()) { + if (this.context.client.supportsExperimentalThreads()) { // Process all thread roots returned in this batch of search results // XXX: This won't work for results coming from Seshat which won't include the bundled relationship for (const result of results.results) { @@ -1586,7 +1582,7 @@ export class RoomView extends React.Component { const bundledRelationship = event .getServerAggregatedRelation(THREAD_RELATION_TYPE.name); if (!bundledRelationship || event.getThread()) continue; - const room = this.context.getRoom(event.getRoomId()); + const room = this.context.client.getRoom(event.getRoomId()); const thread = room.findThreadForEvent(event); if (thread) { event.setThread(thread); @@ -1658,7 +1654,7 @@ export class RoomView extends React.Component { const mxEv = result.context.getEvent(); const roomId = mxEv.getRoomId(); - const room = this.context.getRoom(roomId); + const room = this.context.client.getRoom(roomId); if (!room) { // if we do not have the room in js-sdk stores then hide it as we cannot easily show it // As per the spec, an all rooms search can create this condition, @@ -1715,7 +1711,7 @@ export class RoomView extends React.Component { this.setState({ rejecting: true, }); - this.context.leave(this.state.roomId).then(() => { + this.context.client.leave(this.state.roomId).then(() => { dis.dispatch({ action: Action.ViewHomePage }); this.setState({ rejecting: false, @@ -1742,13 +1738,13 @@ export class RoomView extends React.Component { }); try { - const myMember = this.state.room.getMember(this.context.getUserId()); + const myMember = this.state.room.getMember(this.context.client.getUserId()); const inviteEvent = myMember.events.member; - const ignoredUsers = this.context.getIgnoredUsers(); + const ignoredUsers = this.context.client.getIgnoredUsers(); ignoredUsers.push(inviteEvent.getSender()); // de-duped internally in the js-sdk - await this.context.setIgnoredUsers(ignoredUsers); + await this.context.client.setIgnoredUsers(ignoredUsers); - await this.context.leave(this.state.roomId); + await this.context.client.leave(this.state.roomId); dis.dispatch({ action: Action.ViewHomePage }); this.setState({ rejecting: false, @@ -1911,7 +1907,7 @@ export class RoomView extends React.Component { if (!this.state.room) { return null; } - return LegacyCallHandler.instance.getCallForRoom(this.state.room.roomId); + return this.context.legacyCallHandler.getCallForRoom(this.state.room.roomId); } // this has to be a proper method rather than an unnamed function, @@ -1924,7 +1920,7 @@ export class RoomView extends React.Component { const createEvent = this.state.room.currentState.getStateEvents(EventType.RoomCreate, ""); if (!createEvent || !createEvent.getContent()['predecessor']) return null; - return this.context.getRoom(createEvent.getContent()['predecessor']['room_id']); + return this.context.client.getRoom(createEvent.getContent()['predecessor']['room_id']); } getHiddenHighlightCount() { @@ -1953,7 +1949,7 @@ export class RoomView extends React.Component { Array.from(dataTransfer.files), this.state.room?.roomId ?? this.state.roomId, null, - this.context, + this.context.client, TimelineRenderingType.Room, ); @@ -1970,7 +1966,7 @@ export class RoomView extends React.Component { } private renderLocalRoomCreateLoader(): ReactElement { - const names = this.state.room.getDefaultRoomName(this.props.mxClient.getUserId()); + const names = this.state.room.getDefaultRoomName(this.context.client.getUserId()); return { ); } else { - const myUserId = this.context.credentials.userId; + const myUserId = this.context.client.credentials.userId; const myMember = this.state.room.getMember(myUserId); const inviteEvent = myMember ? myMember.events.member : null; let inviterName = _t("Unknown"); @@ -2162,7 +2158,7 @@ export class RoomView extends React.Component { const showRoomUpgradeBar = ( roomVersionRecommendation && roomVersionRecommendation.needsUpgrade && - this.state.room.userMayUpgradeRoom(this.context.credentials.userId) + this.state.room.userMayUpgradeRoom(this.context.client.credentials.userId) ); const hiddenHighlightCount = this.getHiddenHighlightCount(); @@ -2174,7 +2170,7 @@ export class RoomView extends React.Component { searchInProgress={this.state.searchInProgress} onCancelClick={this.onCancelSearchClick} onSearch={this.onSearch} - isRoomEncrypted={this.context.isRoomEncrypted(this.state.room.roomId)} + isRoomEncrypted={this.context.client.isRoomEncrypted(this.state.room.roomId)} />; } else if (showRoomUpgradeBar) { aux = ; @@ -2236,7 +2232,7 @@ export class RoomView extends React.Component { const auxPanel = ( @@ -2397,7 +2393,7 @@ export class RoomView extends React.Component { mainSplitBody = <> @@ -2451,7 +2447,7 @@ export class RoomView extends React.Component { onAppsClick = null; onForgetClick = null; onSearchClick = null; - if (this.state.room.canInvite(this.context.credentials.userId)) { + if (this.state.room.canInvite(this.context.client.credentials.userId)) { onInviteClick = this.onInviteClick; } viewingCall = true; @@ -2493,5 +2489,4 @@ export class RoomView extends React.Component { } } -const RoomViewWithMatrixClient = withMatrixClientHOC(RoomView); -export default RoomViewWithMatrixClient; +export default RoomView; diff --git a/src/components/structures/SpaceHierarchy.tsx b/src/components/structures/SpaceHierarchy.tsx index 7336dfeb0c..00ebfdacce 100644 --- a/src/components/structures/SpaceHierarchy.tsx +++ b/src/components/structures/SpaceHierarchy.tsx @@ -60,13 +60,13 @@ import MatrixClientContext from "../../contexts/MatrixClientContext"; import { useTypedEventEmitterState } from "../../hooks/useEventEmitter"; import { IOOBData } from "../../stores/ThreepidInviteStore"; import { awaitRoomDownSync } from "../../utils/RoomUpgrade"; -import { RoomViewStore } from "../../stores/RoomViewStore"; import { ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload"; import { JoinRoomReadyPayload } from "../../dispatcher/payloads/JoinRoomReadyPayload"; import { KeyBindingAction } from "../../accessibility/KeyboardShortcuts"; import { getKeyBindingsManager } from "../../KeyBindingsManager"; import { Alignment } from "../views/elements/Tooltip"; import { getTopic } from "../../hooks/room/useTopic"; +import { SdkContextClass } from "../../contexts/SDKContext"; interface IProps { space: Room; @@ -378,7 +378,7 @@ export const joinRoom = (cli: MatrixClient, hierarchy: RoomHierarchy, roomId: st metricsTrigger: "SpaceHierarchy", }); }, err => { - RoomViewStore.instance.showJoinRoomError(err, roomId); + SdkContextClass.instance.roomViewStore.showJoinRoomError(err, roomId); }); return prom; diff --git a/src/components/structures/ThreadView.tsx b/src/components/structures/ThreadView.tsx index 042b8b3b92..a7b4ab10c8 100644 --- a/src/components/structures/ThreadView.tsx +++ b/src/components/structures/ThreadView.tsx @@ -51,10 +51,10 @@ import { KeyBindingAction } from "../../accessibility/KeyboardShortcuts"; import Measured from '../views/elements/Measured'; import PosthogTrackers from "../../PosthogTrackers"; import { ButtonEvent } from "../views/elements/AccessibleButton"; -import { RoomViewStore } from '../../stores/RoomViewStore'; import Spinner from "../views/elements/Spinner"; import { ComposerInsertPayload, ComposerType } from "../../dispatcher/payloads/ComposerInsertPayload"; import Heading from '../views/typography/Heading'; +import { SdkContextClass } from '../../contexts/SDKContext'; interface IProps { room: Room; @@ -113,7 +113,7 @@ export default class ThreadView extends React.Component { room.removeListener(ThreadEvent.New, this.onNewThread); SettingsStore.unwatchSetting(this.layoutWatcherRef); - const hasRoomChanged = RoomViewStore.instance.getRoomId() !== roomId; + const hasRoomChanged = SdkContextClass.instance.roomViewStore.getRoomId() !== roomId; if (this.props.isInitialEventHighlighted && !hasRoomChanged) { dis.dispatch({ action: Action.ViewRoom, diff --git a/src/components/views/beacon/RoomCallBanner.tsx b/src/components/views/beacon/RoomCallBanner.tsx index 736c88649f..6085fe141b 100644 --- a/src/components/views/beacon/RoomCallBanner.tsx +++ b/src/components/views/beacon/RoomCallBanner.tsx @@ -24,13 +24,13 @@ import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; import { Action } from "../../../dispatcher/actions"; import { Call, ConnectionState, ElementCall } from "../../../models/Call"; import { useCall } from "../../../hooks/useCall"; -import { RoomViewStore } from "../../../stores/RoomViewStore"; import { useEventEmitterState } from "../../../hooks/useEventEmitter"; import { OwnBeaconStore, OwnBeaconStoreEvent, } from "../../../stores/OwnBeaconStore"; import { CallDurationFromEvent } from "../voip/CallDuration"; +import { SdkContextClass } from "../../../contexts/SDKContext"; interface RoomCallBannerProps { roomId: Room["roomId"]; @@ -114,7 +114,7 @@ const RoomCallBanner: React.FC = ({ roomId }) => { } // Check if the call is already showing. No banner is needed in this case. - if (RoomViewStore.instance.isViewingCall()) { + if (SdkContextClass.instance.roomViewStore.isViewingCall()) { return null; } diff --git a/src/components/views/context_menus/RoomContextMenu.tsx b/src/components/views/context_menus/RoomContextMenu.tsx index b9923d9278..aadfd2d268 100644 --- a/src/components/views/context_menus/RoomContextMenu.tsx +++ b/src/components/views/context_menus/RoomContextMenu.tsx @@ -37,7 +37,6 @@ import Modal from "../../../Modal"; import ExportDialog from "../dialogs/ExportDialog"; import { useFeatureEnabled } from "../../../hooks/useSettings"; import { usePinnedEvents } from "../right_panel/PinnedMessagesCard"; -import { RoomViewStore } from "../../../stores/RoomViewStore"; import { RightPanelPhases } from '../../../stores/right-panel/RightPanelStorePhases'; import { ROOM_NOTIFICATIONS_TAB } from "../dialogs/RoomSettingsDialog"; import { useEventEmitterState } from "../../../hooks/useEventEmitter"; @@ -50,6 +49,7 @@ import { getKeyBindingsManager } from "../../../KeyBindingsManager"; import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts"; import SettingsStore from "../../../settings/SettingsStore"; import DevtoolsDialog from "../dialogs/DevtoolsDialog"; +import { SdkContextClass } from "../../../contexts/SDKContext"; interface IProps extends IContextMenuProps { room: Room; @@ -332,7 +332,7 @@ const RoomContextMenu = ({ room, onFinished, ...props }: IProps) => { }; const ensureViewingRoom = (ev: ButtonEvent) => { - if (RoomViewStore.instance.getRoomId() === room.roomId) return; + if (SdkContextClass.instance.roomViewStore.getRoomId() === room.roomId) return; dis.dispatch({ action: Action.ViewRoom, room_id: room.roomId, @@ -377,7 +377,7 @@ const RoomContextMenu = ({ room, onFinished, ...props }: IProps) => { ev.stopPropagation(); Modal.createDialog(DevtoolsDialog, { - roomId: RoomViewStore.instance.getRoomId(), + roomId: SdkContextClass.instance.roomViewStore.getRoomId(), }, "mx_DevtoolsDialog_wrapper"); onFinished(); }} diff --git a/src/components/views/dialogs/spotlight/SpotlightDialog.tsx b/src/components/views/dialogs/spotlight/SpotlightDialog.tsx index b04299869c..dfec2ab509 100644 --- a/src/components/views/dialogs/spotlight/SpotlightDialog.tsx +++ b/src/components/views/dialogs/spotlight/SpotlightDialog.tsx @@ -66,7 +66,7 @@ import { BreadcrumbsStore } from "../../../../stores/BreadcrumbsStore"; import { RoomNotificationState } from "../../../../stores/notifications/RoomNotificationState"; import { RoomNotificationStateStore } from "../../../../stores/notifications/RoomNotificationStateStore"; import { RecentAlgorithm } from "../../../../stores/room-list/algorithms/tag-sorting/RecentAlgorithm"; -import { RoomViewStore } from "../../../../stores/RoomViewStore"; +import { SdkContextClass } from "../../../../contexts/SDKContext"; import { getMetaSpaceName } from "../../../../stores/spaces"; import SpaceStore from "../../../../stores/spaces/SpaceStore"; import { DirectoryMember, Member, startDmOnFirstMessage } from "../../../../utils/direct-messages"; @@ -1060,7 +1060,7 @@ const SpotlightDialog: React.FC = ({ initialText = "", initialFilter = n
{ BreadcrumbsStore.instance.rooms - .filter(r => r.roomId !== RoomViewStore.instance.getRoomId()) + .filter(r => r.roomId !== SdkContextClass.instance.roomViewStore.getRoomId()) .map(room => ( { ); if (isActiveWidget) { // We just left the room that the active widget was from. - if (this.props.room && RoomViewStore.instance.getRoomId() !== this.props.room.roomId) { + if (this.props.room && SdkContextClass.instance.roomViewStore.getRoomId() !== this.props.room.roomId) { // If we are not actively looking at the room then destroy this widget entirely. this.endWidgetActions(); } else if (WidgetType.JITSI.matches(this.props.app.type)) { diff --git a/src/components/views/right_panel/TimelineCard.tsx b/src/components/views/right_panel/TimelineCard.tsx index f1eea5ad49..c88a47406a 100644 --- a/src/components/views/right_panel/TimelineCard.tsx +++ b/src/components/views/right_panel/TimelineCard.tsx @@ -33,7 +33,6 @@ import dis from '../../../dispatcher/dispatcher'; import { _t } from '../../../languageHandler'; import { ActionPayload } from '../../../dispatcher/payloads'; import { Action } from '../../../dispatcher/actions'; -import { RoomViewStore } from '../../../stores/RoomViewStore'; import ContentMessages from '../../../ContentMessages'; import UploadBar from '../../structures/UploadBar'; import SettingsStore from '../../../settings/SettingsStore'; @@ -42,6 +41,7 @@ import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; import Measured from '../elements/Measured'; import Heading from '../typography/Heading'; import { UPDATE_EVENT } from '../../../stores/AsyncStore'; +import { SdkContextClass } from '../../../contexts/SDKContext'; interface IProps { room: Room; @@ -91,7 +91,7 @@ export default class TimelineCard extends React.Component { } public componentDidMount(): void { - RoomViewStore.instance.addListener(UPDATE_EVENT, this.onRoomViewStoreUpdate); + SdkContextClass.instance.roomViewStore.addListener(UPDATE_EVENT, this.onRoomViewStoreUpdate); this.dispatcherRef = dis.register(this.onAction); this.readReceiptsSettingWatcher = SettingsStore.watchSetting("showReadReceipts", null, (...[,,, value]) => this.setState({ showReadReceipts: value as boolean }), @@ -102,7 +102,7 @@ export default class TimelineCard extends React.Component { } public componentWillUnmount(): void { - RoomViewStore.instance.removeListener(UPDATE_EVENT, this.onRoomViewStoreUpdate); + SdkContextClass.instance.roomViewStore.removeListener(UPDATE_EVENT, this.onRoomViewStoreUpdate); if (this.readReceiptsSettingWatcher) { SettingsStore.unwatchSetting(this.readReceiptsSettingWatcher); @@ -116,12 +116,9 @@ export default class TimelineCard extends React.Component { private onRoomViewStoreUpdate = async (initial?: boolean): Promise => { const newState: Pick = { - // roomLoading: RoomViewStore.instance.isRoomLoading(), - // roomLoadError: RoomViewStore.instance.getRoomLoadError(), - - initialEventId: RoomViewStore.instance.getInitialEventId(), - isInitialEventHighlighted: RoomViewStore.instance.isInitialEventHighlighted(), - replyToEvent: RoomViewStore.instance.getQuotingEvent(), + initialEventId: SdkContextClass.instance.roomViewStore.getInitialEventId(), + isInitialEventHighlighted: SdkContextClass.instance.roomViewStore.isInitialEventHighlighted(), + replyToEvent: SdkContextClass.instance.roomViewStore.getQuotingEvent(), }; this.setState(newState); diff --git a/src/components/views/right_panel/UserInfo.tsx b/src/components/views/right_panel/UserInfo.tsx index 810ae48dd7..49201d52bc 100644 --- a/src/components/views/right_panel/UserInfo.tsx +++ b/src/components/views/right_panel/UserInfo.tsx @@ -36,7 +36,6 @@ import { _t } from '../../../languageHandler'; import DMRoomMap from '../../../utils/DMRoomMap'; import AccessibleButton, { ButtonEvent } from '../elements/AccessibleButton'; import SdkConfig from '../../../SdkConfig'; -import { RoomViewStore } from "../../../stores/RoomViewStore"; import MultiInviter from "../../../utils/MultiInviter"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; import E2EIcon from "../rooms/E2EIcon"; @@ -77,6 +76,7 @@ import UserIdentifierCustomisations from '../../../customisations/UserIdentifier import PosthogTrackers from "../../../PosthogTrackers"; import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; import { DirectoryMember, startDmOnFirstMessage } from '../../../utils/direct-messages'; +import { SdkContextClass } from '../../../contexts/SDKContext'; export interface IDevice { deviceId: string; @@ -412,7 +412,7 @@ const UserOptionsSection: React.FC<{ } if (canInvite && (member?.membership ?? 'leave') === 'leave' && shouldShowComponent(UIComponent.InviteUsers)) { - const roomId = member && member.roomId ? member.roomId : RoomViewStore.instance.getRoomId(); + const roomId = member && member.roomId ? member.roomId : SdkContextClass.instance.roomViewStore.getRoomId(); const onInviteUserButton = async (ev: ButtonEvent) => { try { // We use a MultiInviter to re-use the invite logic, even though we're only inviting one user. diff --git a/src/components/views/rooms/RoomList.tsx b/src/components/views/rooms/RoomList.tsx index 13b1011088..0cd38f7b30 100644 --- a/src/components/views/rooms/RoomList.tsx +++ b/src/components/views/rooms/RoomList.tsx @@ -38,7 +38,6 @@ import { ITagMap } from "../../../stores/room-list/algorithms/models"; import { DefaultTagID, TagID } from "../../../stores/room-list/models"; import { UPDATE_EVENT } from "../../../stores/AsyncStore"; import RoomListStore, { LISTS_UPDATE_EVENT } from "../../../stores/room-list/RoomListStore"; -import { RoomViewStore } from "../../../stores/RoomViewStore"; import { isMetaSpace, ISuggestedRoom, @@ -62,6 +61,7 @@ import IconizedContextMenu, { import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import ExtraTile from "./ExtraTile"; import RoomSublist, { IAuxButtonProps } from "./RoomSublist"; +import { SdkContextClass } from "../../../contexts/SDKContext"; interface IProps { onKeyDown: (ev: React.KeyboardEvent, state: IRovingTabIndexState) => void; @@ -421,7 +421,7 @@ export default class RoomList extends React.PureComponent { public componentDidMount(): void { this.dispatcherRef = defaultDispatcher.register(this.onAction); - RoomViewStore.instance.on(UPDATE_EVENT, this.onRoomViewStoreUpdate); + SdkContextClass.instance.roomViewStore.on(UPDATE_EVENT, this.onRoomViewStoreUpdate); SpaceStore.instance.on(UPDATE_SUGGESTED_ROOMS, this.updateSuggestedRooms); RoomListStore.instance.on(LISTS_UPDATE_EVENT, this.updateLists); this.favouriteMessageWatcher = @@ -436,19 +436,19 @@ export default class RoomList extends React.PureComponent { RoomListStore.instance.off(LISTS_UPDATE_EVENT, this.updateLists); SettingsStore.unwatchSetting(this.favouriteMessageWatcher); defaultDispatcher.unregister(this.dispatcherRef); - RoomViewStore.instance.off(UPDATE_EVENT, this.onRoomViewStoreUpdate); + SdkContextClass.instance.roomViewStore.off(UPDATE_EVENT, this.onRoomViewStoreUpdate); } private onRoomViewStoreUpdate = () => { this.setState({ - currentRoomId: RoomViewStore.instance.getRoomId(), + currentRoomId: SdkContextClass.instance.roomViewStore.getRoomId(), }); }; private onAction = (payload: ActionPayload) => { if (payload.action === Action.ViewRoomDelta) { const viewRoomDeltaPayload = payload as ViewRoomDeltaPayload; - const currentRoomId = RoomViewStore.instance.getRoomId(); + const currentRoomId = SdkContextClass.instance.roomViewStore.getRoomId(); const room = this.getRoomDelta(currentRoomId, viewRoomDeltaPayload.delta, viewRoomDeltaPayload.unread); if (room) { defaultDispatcher.dispatch({ diff --git a/src/components/views/rooms/RoomTile.tsx b/src/components/views/rooms/RoomTile.tsx index 219295d23d..68f4dfe4de 100644 --- a/src/components/views/rooms/RoomTile.tsx +++ b/src/components/views/rooms/RoomTile.tsx @@ -44,10 +44,10 @@ import PosthogTrackers from "../../../PosthogTrackers"; import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts"; import { getKeyBindingsManager } from "../../../KeyBindingsManager"; -import { RoomViewStore } from "../../../stores/RoomViewStore"; import { RoomTileCallSummary } from "./RoomTileCallSummary"; import { RoomGeneralContextMenu } from "../context_menus/RoomGeneralContextMenu"; import { CallStore, CallStoreEvent } from "../../../stores/CallStore"; +import { SdkContextClass } from "../../../contexts/SDKContext"; interface IProps { room: Room; @@ -86,7 +86,7 @@ export default class RoomTile extends React.PureComponent { super(props); this.state = { - selected: RoomViewStore.instance.getRoomId() === this.props.room.roomId, + selected: SdkContextClass.instance.roomViewStore.getRoomId() === this.props.room.roomId, notificationsMenuPosition: null, generalMenuPosition: null, call: CallStore.instance.getCall(this.props.room.roomId), @@ -146,7 +146,7 @@ export default class RoomTile extends React.PureComponent { this.scrollIntoView(); } - RoomViewStore.instance.addRoomListener(this.props.room.roomId, this.onActiveRoomUpdate); + SdkContextClass.instance.roomViewStore.addRoomListener(this.props.room.roomId, this.onActiveRoomUpdate); this.dispatcherRef = defaultDispatcher.register(this.onAction); MessagePreviewStore.instance.on( MessagePreviewStore.getPreviewChangedEventName(this.props.room), @@ -163,7 +163,7 @@ export default class RoomTile extends React.PureComponent { } public componentWillUnmount() { - RoomViewStore.instance.removeRoomListener(this.props.room.roomId, this.onActiveRoomUpdate); + SdkContextClass.instance.roomViewStore.removeRoomListener(this.props.room.roomId, this.onActiveRoomUpdate); MessagePreviewStore.instance.off( MessagePreviewStore.getPreviewChangedEventName(this.props.room), this.onRoomPreviewChanged, diff --git a/src/components/views/spaces/QuickSettingsButton.tsx b/src/components/views/spaces/QuickSettingsButton.tsx index 2f4ee9315f..eb7244f994 100644 --- a/src/components/views/spaces/QuickSettingsButton.tsx +++ b/src/components/views/spaces/QuickSettingsButton.tsx @@ -36,7 +36,7 @@ import { Icon as FavoriteIcon } from '../../../../res/img/element-icons/roomlist import SettingsStore from "../../../settings/SettingsStore"; import Modal from "../../../Modal"; import DevtoolsDialog from "../dialogs/DevtoolsDialog"; -import { RoomViewStore } from "../../../stores/RoomViewStore"; +import { SdkContextClass } from "../../../contexts/SDKContext"; const QuickSettingsButton = ({ isPanelCollapsed = false }) => { const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu(); @@ -72,7 +72,7 @@ const QuickSettingsButton = ({ isPanelCollapsed = false }) => { onClick={() => { closeMenu(); Modal.createDialog(DevtoolsDialog, { - roomId: RoomViewStore.instance.getRoomId(), + roomId: SdkContextClass.instance.roomViewStore.getRoomId(), }, "mx_DevtoolsDialog_wrapper"); }} kind="danger_outline" diff --git a/src/components/views/voip/PipView.tsx b/src/components/views/voip/PipView.tsx index 0bebfe1bf3..3aaa9ac430 100644 --- a/src/components/views/voip/PipView.tsx +++ b/src/components/views/voip/PipView.tsx @@ -21,7 +21,6 @@ import classNames from 'classnames'; import { Room } from "matrix-js-sdk/src/models/room"; import LegacyCallView from "./LegacyCallView"; -import { RoomViewStore } from '../../../stores/RoomViewStore'; import LegacyCallHandler, { LegacyCallHandlerEvent } from '../../../LegacyCallHandler'; import PersistentApp from "../elements/PersistentApp"; import { MatrixClientPeg } from '../../../MatrixClientPeg'; @@ -34,6 +33,7 @@ import ActiveWidgetStore, { ActiveWidgetStoreEvent } from '../../../stores/Activ import WidgetStore, { IApp } from "../../../stores/WidgetStore"; import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; import { UPDATE_EVENT } from '../../../stores/AsyncStore'; +import { SdkContextClass } from '../../../contexts/SDKContext'; import { CallStore } from "../../../stores/CallStore"; import { VoiceBroadcastRecording, @@ -129,7 +129,7 @@ class PipView extends React.Component { constructor(props: IProps) { super(props); - const roomId = RoomViewStore.instance.getRoomId(); + const roomId = SdkContextClass.instance.roomViewStore.getRoomId(); const [primaryCall, secondaryCalls] = getPrimarySecondaryCallsForPip(roomId); @@ -147,7 +147,7 @@ class PipView extends React.Component { public componentDidMount() { LegacyCallHandler.instance.addListener(LegacyCallHandlerEvent.CallChangeRoom, this.updateCalls); LegacyCallHandler.instance.addListener(LegacyCallHandlerEvent.CallState, this.updateCalls); - RoomViewStore.instance.addListener(UPDATE_EVENT, this.onRoomViewStoreUpdate); + SdkContextClass.instance.roomViewStore.addListener(UPDATE_EVENT, this.onRoomViewStoreUpdate); MatrixClientPeg.get().on(CallEvent.RemoteHoldUnhold, this.onCallRemoteHold); const room = MatrixClientPeg.get()?.getRoom(this.state.viewedRoomId); if (room) { @@ -164,7 +164,7 @@ class PipView extends React.Component { LegacyCallHandler.instance.removeListener(LegacyCallHandlerEvent.CallState, this.updateCalls); const cli = MatrixClientPeg.get(); cli?.removeListener(CallEvent.RemoteHoldUnhold, this.onCallRemoteHold); - RoomViewStore.instance.removeListener(UPDATE_EVENT, this.onRoomViewStoreUpdate); + SdkContextClass.instance.roomViewStore.removeListener(UPDATE_EVENT, this.onRoomViewStoreUpdate); const room = cli?.getRoom(this.state.viewedRoomId); if (room) { WidgetLayoutStore.instance.off(WidgetLayoutStore.emissionForRoom(room), this.updateCalls); @@ -186,7 +186,7 @@ class PipView extends React.Component { private onMove = () => this.movePersistedElement.current?.(); private onRoomViewStoreUpdate = () => { - const newRoomId = RoomViewStore.instance.getRoomId(); + const newRoomId = SdkContextClass.instance.roomViewStore.getRoomId(); const oldRoomId = this.state.viewedRoomId; if (newRoomId === oldRoomId) return; // The WidgetLayoutStore observer always tracks the currently viewed Room, diff --git a/src/contexts/SDKContext.ts b/src/contexts/SDKContext.ts new file mode 100644 index 0000000000..61905dca92 --- /dev/null +++ b/src/contexts/SDKContext.ts @@ -0,0 +1,127 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { MatrixClient } from "matrix-js-sdk/src/matrix"; +import { createContext } from "react"; + +import defaultDispatcher from "../dispatcher/dispatcher"; +import LegacyCallHandler from "../LegacyCallHandler"; +import { PosthogAnalytics } from "../PosthogAnalytics"; +import { SlidingSyncManager } from "../SlidingSyncManager"; +import { RoomNotificationStateStore } from "../stores/notifications/RoomNotificationStateStore"; +import RightPanelStore from "../stores/right-panel/RightPanelStore"; +import { RoomViewStore } from "../stores/RoomViewStore"; +import SpaceStore, { SpaceStoreClass } from "../stores/spaces/SpaceStore"; +import { WidgetLayoutStore } from "../stores/widgets/WidgetLayoutStore"; +import WidgetStore from "../stores/WidgetStore"; + +export const SDKContext = createContext(undefined); +SDKContext.displayName = "SDKContext"; + +/** + * A class which lazily initialises stores as and when they are requested, ensuring they remain + * as singletons scoped to this object. + */ +export class SdkContextClass { + /** + * The global SdkContextClass instance. This is a temporary measure whilst so many stores remain global + * as well. Over time, these stores should accept a `SdkContextClass` instance in their constructor. + * When all stores do this, this static variable can be deleted. + */ + public static readonly instance = new SdkContextClass(); + + // Optional as we don't have a client on initial load if unregistered. This should be set + // when the MatrixClient is first acquired in the dispatcher event Action.OnLoggedIn. + // It is only safe to set this once, as updating this value will NOT notify components using + // this Context. + public client?: MatrixClient; + + // All protected fields to make it easier to derive test stores + protected _RightPanelStore?: RightPanelStore; + protected _RoomNotificationStateStore?: RoomNotificationStateStore; + protected _RoomViewStore?: RoomViewStore; + protected _WidgetLayoutStore?: WidgetLayoutStore; + protected _WidgetStore?: WidgetStore; + protected _PosthogAnalytics?: PosthogAnalytics; + protected _SlidingSyncManager?: SlidingSyncManager; + protected _SpaceStore?: SpaceStoreClass; + protected _LegacyCallHandler?: LegacyCallHandler; + + /** + * Automatically construct stores which need to be created eagerly so they can register with + * the dispatcher. + */ + public constructEagerStores() { + this._RoomViewStore = this.roomViewStore; + } + + public get legacyCallHandler(): LegacyCallHandler { + if (!this._LegacyCallHandler) { + this._LegacyCallHandler = LegacyCallHandler.instance; + } + return this._LegacyCallHandler; + } + public get rightPanelStore(): RightPanelStore { + if (!this._RightPanelStore) { + this._RightPanelStore = RightPanelStore.instance; + } + return this._RightPanelStore; + } + public get roomNotificationStateStore(): RoomNotificationStateStore { + if (!this._RoomNotificationStateStore) { + this._RoomNotificationStateStore = RoomNotificationStateStore.instance; + } + return this._RoomNotificationStateStore; + } + public get roomViewStore(): RoomViewStore { + if (!this._RoomViewStore) { + this._RoomViewStore = new RoomViewStore( + defaultDispatcher, this, + ); + } + return this._RoomViewStore; + } + public get widgetLayoutStore(): WidgetLayoutStore { + if (!this._WidgetLayoutStore) { + this._WidgetLayoutStore = WidgetLayoutStore.instance; + } + return this._WidgetLayoutStore; + } + public get widgetStore(): WidgetStore { + if (!this._WidgetStore) { + this._WidgetStore = WidgetStore.instance; + } + return this._WidgetStore; + } + public get posthogAnalytics(): PosthogAnalytics { + if (!this._PosthogAnalytics) { + this._PosthogAnalytics = PosthogAnalytics.instance; + } + return this._PosthogAnalytics; + } + public get slidingSyncManager(): SlidingSyncManager { + if (!this._SlidingSyncManager) { + this._SlidingSyncManager = SlidingSyncManager.instance; + } + return this._SlidingSyncManager; + } + public get spaceStore(): SpaceStoreClass { + if (!this._SpaceStore) { + this._SpaceStore = SpaceStore.instance; + } + return this._SpaceStore; + } +} diff --git a/src/stores/RoomViewStore.tsx b/src/stores/RoomViewStore.tsx index 0a15ce1860..b3814f7a32 100644 --- a/src/stores/RoomViewStore.tsx +++ b/src/stores/RoomViewStore.tsx @@ -17,6 +17,7 @@ limitations under the License. */ import React, { ReactNode } from "react"; +import * as utils from 'matrix-js-sdk/src/utils'; import { MatrixError } from "matrix-js-sdk/src/http-api"; import { logger } from "matrix-js-sdk/src/logger"; import { ViewRoom as ViewRoomEvent } from "@matrix-org/analytics-events/types/typescript/ViewRoom"; @@ -27,7 +28,7 @@ import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { Optional } from "matrix-events-sdk"; import EventEmitter from "events"; -import { defaultDispatcher, MatrixDispatcher } from '../dispatcher/dispatcher'; +import { MatrixDispatcher } from '../dispatcher/dispatcher'; import { MatrixClientPeg } from '../MatrixClientPeg'; import Modal from '../Modal'; import { _t } from '../languageHandler'; @@ -35,10 +36,8 @@ import { getCachedRoomIDForAlias, storeRoomAliasInCache } from '../RoomAliasCach import { Action } from "../dispatcher/actions"; import { retry } from "../utils/promise"; import { TimelineRenderingType } from "../contexts/RoomContext"; -import { PosthogAnalytics } from "../PosthogAnalytics"; import { ViewRoomPayload } from "../dispatcher/payloads/ViewRoomPayload"; import DMRoomMap from "../utils/DMRoomMap"; -import SpaceStore from "./spaces/SpaceStore"; import { isMetaSpace, MetaSpace } from "./spaces"; import { JoinRoomPayload } from "../dispatcher/payloads/JoinRoomPayload"; import { JoinRoomReadyPayload } from "../dispatcher/payloads/JoinRoomReadyPayload"; @@ -47,9 +46,9 @@ import { ViewRoomErrorPayload } from "../dispatcher/payloads/ViewRoomErrorPayloa import ErrorDialog from "../components/views/dialogs/ErrorDialog"; import { ActiveRoomChangedPayload } from "../dispatcher/payloads/ActiveRoomChangedPayload"; import SettingsStore from "../settings/SettingsStore"; -import { SlidingSyncManager } from "../SlidingSyncManager"; import { awaitRoomDownSync } from "../utils/RoomUpgrade"; import { UPDATE_EVENT } from "./AsyncStore"; +import { SdkContextClass } from "../contexts/SDKContext"; import { CallStore } from "./CallStore"; const NUM_JOIN_RETRY = 5; @@ -131,17 +130,16 @@ type Listener = (isActive: boolean) => void; * A class for storing application state for RoomView. */ export class RoomViewStore extends EventEmitter { - // Important: This cannot be a dynamic getter (lazily-constructed instance) because - // otherwise we'll miss view_room dispatches during startup, breaking relaunches of - // the app. We need to eagerly create the instance. - public static readonly instance = new RoomViewStore(defaultDispatcher); - - private state: State = INITIAL_STATE; // initialize state + // initialize state as a copy of the initial state. We need to copy else one RVS can talk to + // another RVS via INITIAL_STATE as they share the same underlying object. Mostly relevant for tests. + private state = utils.deepCopy(INITIAL_STATE); private dis: MatrixDispatcher; private dispatchToken: string; - public constructor(dis: MatrixDispatcher) { + public constructor( + dis: MatrixDispatcher, private readonly stores: SdkContextClass, + ) { super(); this.resetDispatcher(dis); } @@ -248,7 +246,7 @@ export class RoomViewStore extends EventEmitter { : numMembers > 1 ? "Two" : "One"; - PosthogAnalytics.instance.trackEvent({ + this.stores.posthogAnalytics.trackEvent({ eventName: "JoinedRoom", trigger: payload.metricsTrigger, roomSize, @@ -291,17 +289,17 @@ export class RoomViewStore extends EventEmitter { if (payload.metricsTrigger !== null && payload.room_id !== this.state.roomId) { let activeSpace: ViewRoomEvent["activeSpace"]; - if (SpaceStore.instance.activeSpace === MetaSpace.Home) { + if (this.stores.spaceStore.activeSpace === MetaSpace.Home) { activeSpace = "Home"; - } else if (isMetaSpace(SpaceStore.instance.activeSpace)) { + } else if (isMetaSpace(this.stores.spaceStore.activeSpace)) { activeSpace = "Meta"; } else { - activeSpace = SpaceStore.instance.activeSpaceRoom.getJoinRule() === JoinRule.Public + activeSpace = this.stores.spaceStore.activeSpaceRoom?.getJoinRule() === JoinRule.Public ? "Public" : "Private"; } - PosthogAnalytics.instance.trackEvent({ + this.stores.posthogAnalytics.trackEvent({ eventName: "ViewRoom", trigger: payload.metricsTrigger, viaKeyboard: payload.metricsViaKeyboard, @@ -314,7 +312,7 @@ export class RoomViewStore extends EventEmitter { if (SettingsStore.getValue("feature_sliding_sync") && this.state.roomId !== payload.room_id) { if (this.state.subscribingRoomId && this.state.subscribingRoomId !== payload.room_id) { // unsubscribe from this room, but don't await it as we don't care when this gets done. - SlidingSyncManager.instance.setRoomVisible(this.state.subscribingRoomId, false); + this.stores.slidingSyncManager.setRoomVisible(this.state.subscribingRoomId, false); } this.setState({ subscribingRoomId: payload.room_id, @@ -332,11 +330,11 @@ export class RoomViewStore extends EventEmitter { }); // set this room as the room subscription. We need to await for it as this will fetch // all room state for this room, which is required before we get the state below. - await SlidingSyncManager.instance.setRoomVisible(payload.room_id, true); + await this.stores.slidingSyncManager.setRoomVisible(payload.room_id, true); // Whilst we were subscribing another room was viewed, so stop what we're doing and // unsubscribe if (this.state.subscribingRoomId !== payload.room_id) { - SlidingSyncManager.instance.setRoomVisible(payload.room_id, false); + this.stores.slidingSyncManager.setRoomVisible(payload.room_id, false); return; } // Re-fire the payload: we won't re-process it because the prev room ID == payload room ID now @@ -599,7 +597,7 @@ export class RoomViewStore extends EventEmitter { // // Not joined // } // } else { - // if (RoomViewStore.instance.isJoining()) { + // if (this.stores.roomViewStore.isJoining()) { // // show spinner // } else { // // show join prompt diff --git a/src/stores/right-panel/RightPanelStore.ts b/src/stores/right-panel/RightPanelStore.ts index 327f82153f..9aa4c1b27c 100644 --- a/src/stores/right-panel/RightPanelStore.ts +++ b/src/stores/right-panel/RightPanelStore.ts @@ -34,7 +34,7 @@ import { import { ActionPayload } from "../../dispatcher/payloads"; import { Action } from "../../dispatcher/actions"; import { ActiveRoomChangedPayload } from "../../dispatcher/payloads/ActiveRoomChangedPayload"; -import { RoomViewStore } from "../RoomViewStore"; +import { SdkContextClass } from "../../contexts/SDKContext"; /** * A class for tracking the state of the right panel between layouts and @@ -64,7 +64,7 @@ export default class RightPanelStore extends ReadyWatchingStore { } protected async onReady(): Promise { - this.viewedRoomId = RoomViewStore.instance.getRoomId(); + this.viewedRoomId = SdkContextClass.instance.roomViewStore.getRoomId(); this.matrixClient.on(CryptoEvent.VerificationRequest, this.onVerificationRequestUpdate); this.loadCacheFromSettings(); this.emitAndUpdateSettings(); diff --git a/src/stores/room-list/RoomListStore.ts b/src/stores/room-list/RoomListStore.ts index 83c79a16a9..d6f9de79c3 100644 --- a/src/stores/room-list/RoomListStore.ts +++ b/src/stores/room-list/RoomListStore.ts @@ -27,7 +27,6 @@ import { ActionPayload } from "../../dispatcher/payloads"; import defaultDispatcher from "../../dispatcher/dispatcher"; import { readReceiptChangeIsFor } from "../../utils/read-receipts"; import { FILTER_CHANGED, IFilterCondition } from "./filters/IFilterCondition"; -import { RoomViewStore } from "../RoomViewStore"; import { Algorithm, LIST_UPDATED_EVENT } from "./algorithms/Algorithm"; import { EffectiveMembership, getEffectiveMembership } from "../../utils/membership"; import RoomListLayoutStore from "./RoomListLayoutStore"; @@ -40,6 +39,7 @@ import { IRoomTimelineActionPayload } from "../../actions/MatrixActionCreators"; import { RoomListStore as Interface, RoomListStoreEvent } from "./Interface"; import { SlidingRoomListStoreClass } from "./SlidingRoomListStore"; import { UPDATE_EVENT } from "../AsyncStore"; +import { SdkContextClass } from "../../contexts/SDKContext"; interface IState { // state is tracked in underlying classes @@ -105,7 +105,7 @@ export class RoomListStoreClass extends AsyncStoreWithClient implements this.readyStore.useUnitTestClient(forcedClient); } - RoomViewStore.instance.addListener(UPDATE_EVENT, () => this.handleRVSUpdate({})); + SdkContextClass.instance.roomViewStore.addListener(UPDATE_EVENT, () => this.handleRVSUpdate({})); this.algorithm.on(LIST_UPDATED_EVENT, this.onAlgorithmListUpdated); this.algorithm.on(FILTER_CHANGED, this.onAlgorithmFilterUpdated); this.setupWatchers(); @@ -128,7 +128,7 @@ export class RoomListStoreClass extends AsyncStoreWithClient implements private handleRVSUpdate({ trigger = true }) { if (!this.matrixClient) return; // We assume there won't be RVS updates without a client - const activeRoomId = RoomViewStore.instance.getRoomId(); + const activeRoomId = SdkContextClass.instance.roomViewStore.getRoomId(); if (!activeRoomId && this.algorithm.stickyRoom) { this.algorithm.setStickyRoom(null); } else if (activeRoomId) { diff --git a/src/stores/room-list/SlidingRoomListStore.ts b/src/stores/room-list/SlidingRoomListStore.ts index 3d532fe0c9..35550d04f1 100644 --- a/src/stores/room-list/SlidingRoomListStore.ts +++ b/src/stores/room-list/SlidingRoomListStore.ts @@ -29,8 +29,8 @@ import { SlidingSyncManager } from "../../SlidingSyncManager"; import SpaceStore from "../spaces/SpaceStore"; import { MetaSpace, SpaceKey, UPDATE_SELECTED_SPACE } from "../spaces"; import { LISTS_LOADING_EVENT } from "./RoomListStore"; -import { RoomViewStore } from "../RoomViewStore"; import { UPDATE_EVENT } from "../AsyncStore"; +import { SdkContextClass } from "../../contexts/SDKContext"; interface IState { // state is tracked in underlying classes @@ -207,7 +207,7 @@ export class SlidingRoomListStoreClass extends AsyncStoreWithClient impl // this room will not move due to it being viewed: it is sticky. This can be null to indicate // no sticky room if you aren't viewing a room. - this.stickyRoomId = RoomViewStore.instance.getRoomId(); + this.stickyRoomId = SdkContextClass.instance.roomViewStore.getRoomId(); let stickyRoomNewIndex = -1; const stickyRoomOldIndex = (tagMap[tagId] || []).findIndex((room) => { return room.roomId === this.stickyRoomId; @@ -273,7 +273,7 @@ export class SlidingRoomListStoreClass extends AsyncStoreWithClient impl private onRoomViewStoreUpdated() { // we only care about this to know when the user has clicked on a room to set the stickiness value - if (RoomViewStore.instance.getRoomId() === this.stickyRoomId) { + if (SdkContextClass.instance.roomViewStore.getRoomId() === this.stickyRoomId) { return; } @@ -303,7 +303,7 @@ export class SlidingRoomListStoreClass extends AsyncStoreWithClient impl } } // in the event we didn't call refreshOrderedLists, it helps to still remember the sticky room ID. - this.stickyRoomId = RoomViewStore.instance.getRoomId(); + this.stickyRoomId = SdkContextClass.instance.roomViewStore.getRoomId(); if (hasUpdatedAnyList) { this.emit(LISTS_UPDATE_EVENT); @@ -314,7 +314,7 @@ export class SlidingRoomListStoreClass extends AsyncStoreWithClient impl logger.info("SlidingRoomListStore.onReady"); // permanent listeners: never get destroyed. Could be an issue if we want to test this in isolation. SlidingSyncManager.instance.slidingSync.on(SlidingSyncEvent.List, this.onSlidingSyncListUpdate.bind(this)); - RoomViewStore.instance.addListener(UPDATE_EVENT, this.onRoomViewStoreUpdated.bind(this)); + SdkContextClass.instance.roomViewStore.addListener(UPDATE_EVENT, this.onRoomViewStoreUpdated.bind(this)); SpaceStore.instance.on(UPDATE_SELECTED_SPACE, this.onSelectedSpaceUpdated.bind(this)); if (SpaceStore.instance.activeSpace) { this.onSelectedSpaceUpdated(SpaceStore.instance.activeSpace, false); diff --git a/src/stores/spaces/SpaceStore.ts b/src/stores/spaces/SpaceStore.ts index ce86b6ec0f..f4802a1520 100644 --- a/src/stores/spaces/SpaceStore.ts +++ b/src/stores/spaces/SpaceStore.ts @@ -34,7 +34,6 @@ import { RoomNotificationStateStore } from "../notifications/RoomNotificationSta import { DefaultTagID } from "../room-list/models"; import { EnhancedMap, mapDiff } from "../../utils/maps"; import { setDiff, setHasDiff } from "../../utils/sets"; -import { RoomViewStore } from "../RoomViewStore"; import { Action } from "../../dispatcher/actions"; import { arrayHasDiff, arrayHasOrderChange } from "../../utils/arrays"; import { reorderLexicographically } from "../../utils/stringOrderField"; @@ -64,6 +63,7 @@ import { ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload"; import { ViewHomePagePayload } from "../../dispatcher/payloads/ViewHomePagePayload"; import { SwitchSpacePayload } from "../../dispatcher/payloads/SwitchSpacePayload"; import { AfterLeaveRoomPayload } from "../../dispatcher/payloads/AfterLeaveRoomPayload"; +import { SdkContextClass } from "../../contexts/SDKContext"; interface IState { } @@ -797,7 +797,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { this.updateNotificationStates(notificationStatesToUpdate); }; - private switchSpaceIfNeeded = (roomId = RoomViewStore.instance.getRoomId()) => { + private switchSpaceIfNeeded = (roomId = SdkContextClass.instance.roomViewStore.getRoomId()) => { if (!this.isRoomInSpace(this.activeSpace, roomId) && !this.matrixClient.getRoom(roomId)?.isSpaceRoom()) { this.switchToRelatedSpace(roomId); } @@ -848,7 +848,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { } // if the room currently being viewed was just joined then switch to its related space - if (newMembership === "join" && room.roomId === RoomViewStore.instance.getRoomId()) { + if (newMembership === "join" && room.roomId === SdkContextClass.instance.roomViewStore.getRoomId()) { this.switchSpaceIfNeeded(room.roomId); } } @@ -875,7 +875,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { this.emit(room.roomId); } - if (membership === "join" && room.roomId === RoomViewStore.instance.getRoomId()) { + if (membership === "join" && room.roomId === SdkContextClass.instance.roomViewStore.getRoomId()) { // if the user was looking at the space and then joined: select that space this.setActiveSpace(room.roomId, false); } else if (membership === "leave" && room.roomId === this.activeSpace) { diff --git a/src/stores/widgets/StopGapWidget.ts b/src/stores/widgets/StopGapWidget.ts index 91a262fdca..aa1ad2c393 100644 --- a/src/stores/widgets/StopGapWidget.ts +++ b/src/stores/widgets/StopGapWidget.ts @@ -41,7 +41,6 @@ import { ClientEvent } from "matrix-js-sdk/src/client"; import { _t } from "../../languageHandler"; import { StopGapWidgetDriver } from "./StopGapWidgetDriver"; import { WidgetMessagingStore } from "./WidgetMessagingStore"; -import { RoomViewStore } from "../RoomViewStore"; import { MatrixClientPeg } from "../../MatrixClientPeg"; import { OwnProfileStore } from "../OwnProfileStore"; import WidgetUtils from '../../utils/WidgetUtils'; @@ -65,6 +64,7 @@ import { arrayFastClone } from "../../utils/arrays"; import { ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload"; import Modal from "../../Modal"; import ErrorDialog from "../../components/views/dialogs/ErrorDialog"; +import { SdkContextClass } from "../../contexts/SDKContext"; import { VoiceBroadcastRecordingsStore } from "../../voice-broadcast"; // TODO: Destroy all of this code @@ -185,7 +185,7 @@ export class StopGapWidget extends EventEmitter { if (this.roomId) return this.roomId; - return RoomViewStore.instance.getRoomId(); + return SdkContextClass.instance.roomViewStore.getRoomId(); } public get widgetApi(): ClientWidgetApi { @@ -381,7 +381,7 @@ export class StopGapWidget extends EventEmitter { // noinspection JSIgnoredPromiseFromCall IntegrationManagers.sharedInstance().getPrimaryManager().open( - this.client.getRoom(RoomViewStore.instance.getRoomId()), + this.client.getRoom(SdkContextClass.instance.roomViewStore.getRoomId()), `type_${integType}`, integId, ); diff --git a/src/stores/widgets/StopGapWidgetDriver.ts b/src/stores/widgets/StopGapWidgetDriver.ts index 752d6d57e6..ba01a10926 100644 --- a/src/stores/widgets/StopGapWidgetDriver.ts +++ b/src/stores/widgets/StopGapWidgetDriver.ts @@ -53,9 +53,9 @@ import { CHAT_EFFECTS } from "../../effects"; import { containsEmoji } from "../../effects/utils"; import dis from "../../dispatcher/dispatcher"; import SettingsStore from "../../settings/SettingsStore"; -import { RoomViewStore } from "../RoomViewStore"; import { ElementWidgetCapabilities } from "./ElementWidgetCapabilities"; import { navigateToPermalink } from "../../utils/permalinks/navigator"; +import { SdkContextClass } from "../../contexts/SDKContext"; // TODO: Purge this from the universe @@ -210,7 +210,7 @@ export class StopGapWidgetDriver extends WidgetDriver { targetRoomId: string = null, ): Promise { const client = MatrixClientPeg.get(); - const roomId = targetRoomId || RoomViewStore.instance.getRoomId(); + const roomId = targetRoomId || SdkContextClass.instance.roomViewStore.getRoomId(); if (!client || !roomId) throw new Error("Not in a room or not attached to a client"); @@ -291,7 +291,7 @@ export class StopGapWidgetDriver extends WidgetDriver { const targetRooms = roomIds ? (roomIds.includes(Symbols.AnyRoom) ? client.getVisibleRooms() : roomIds.map(r => client.getRoom(r))) - : [client.getRoom(RoomViewStore.instance.getRoomId())]; + : [client.getRoom(SdkContextClass.instance.roomViewStore.getRoomId())]; return targetRooms.filter(r => !!r); } @@ -430,7 +430,7 @@ export class StopGapWidgetDriver extends WidgetDriver { ): Promise { const client = MatrixClientPeg.get(); const dir = direction as Direction; - roomId = roomId ?? RoomViewStore.instance.getRoomId() ?? undefined; + roomId = roomId ?? SdkContextClass.instance.roomViewStore.getRoomId() ?? undefined; if (typeof roomId !== "string") { throw new Error('Error while reading the current room'); diff --git a/src/utils/DialogOpener.ts b/src/utils/DialogOpener.ts index 0e5a3d2b11..82d16962b2 100644 --- a/src/utils/DialogOpener.ts +++ b/src/utils/DialogOpener.ts @@ -20,7 +20,6 @@ import defaultDispatcher from "../dispatcher/dispatcher"; import { ActionPayload } from "../dispatcher/payloads"; import Modal from "../Modal"; import RoomSettingsDialog from "../components/views/dialogs/RoomSettingsDialog"; -import { RoomViewStore } from "../stores/RoomViewStore"; import ForwardDialog from "../components/views/dialogs/ForwardDialog"; import { MatrixClientPeg } from "../MatrixClientPeg"; import { Action } from "../dispatcher/actions"; @@ -32,6 +31,7 @@ import AddExistingToSpaceDialog from "../components/views/dialogs/AddExistingToS import { ButtonEvent } from "../components/views/elements/AccessibleButton"; import PosthogTrackers from "../PosthogTrackers"; import { showAddExistingSubspace, showCreateNewRoom } from "./space"; +import { SdkContextClass } from "../contexts/SDKContext"; /** * Auxiliary class to listen for dialog opening over the dispatcher and @@ -58,7 +58,7 @@ export class DialogOpener { switch (payload.action) { case 'open_room_settings': Modal.createDialog(RoomSettingsDialog, { - roomId: payload.room_id || RoomViewStore.instance.getRoomId(), + roomId: payload.room_id || SdkContextClass.instance.roomViewStore.getRoomId(), initialTabId: payload.initial_tab_id, }, /*className=*/null, /*isPriority=*/false, /*isStatic=*/true); break; @@ -108,7 +108,7 @@ export class DialogOpener { onAddSubspaceClick: () => showAddExistingSubspace(space), space, onFinished: (added: boolean) => { - if (added && RoomViewStore.instance.getRoomId() === space.roomId) { + if (added && SdkContextClass.instance.roomViewStore.getRoomId() === space.roomId) { defaultDispatcher.fire(Action.UpdateSpaceHierarchy); } }, diff --git a/src/utils/leave-behaviour.ts b/src/utils/leave-behaviour.ts index a12cd70ebf..83054ce1b4 100644 --- a/src/utils/leave-behaviour.ts +++ b/src/utils/leave-behaviour.ts @@ -27,7 +27,6 @@ import { _t } from "../languageHandler"; import ErrorDialog from "../components/views/dialogs/ErrorDialog"; import { isMetaSpace } from "../stores/spaces"; import SpaceStore from "../stores/spaces/SpaceStore"; -import { RoomViewStore } from "../stores/RoomViewStore"; import dis from "../dispatcher/dispatcher"; import { ViewRoomPayload } from "../dispatcher/payloads/ViewRoomPayload"; import { Action } from "../dispatcher/actions"; @@ -35,6 +34,7 @@ import { ViewHomePagePayload } from "../dispatcher/payloads/ViewHomePagePayload" import LeaveSpaceDialog from "../components/views/dialogs/LeaveSpaceDialog"; import { AfterLeaveRoomPayload } from "../dispatcher/payloads/AfterLeaveRoomPayload"; import { bulkSpaceBehaviour } from "./space"; +import { SdkContextClass } from "../contexts/SDKContext"; export async function leaveRoomBehaviour(roomId: string, retry = true, spinner = true) { let spinnerModal: IHandle; @@ -130,7 +130,7 @@ export async function leaveRoomBehaviour(roomId: string, retry = true, spinner = if (!isMetaSpace(SpaceStore.instance.activeSpace) && SpaceStore.instance.activeSpace !== roomId && - RoomViewStore.instance.getRoomId() === roomId + SdkContextClass.instance.roomViewStore.getRoomId() === roomId ) { dis.dispatch({ action: Action.ViewRoom, diff --git a/src/utils/space.tsx b/src/utils/space.tsx index 9e05f0444b..1e30b7235a 100644 --- a/src/utils/space.tsx +++ b/src/utils/space.tsx @@ -30,7 +30,6 @@ import { showRoomInviteDialog } from "../RoomInvite"; import CreateSubspaceDialog from "../components/views/dialogs/CreateSubspaceDialog"; import AddExistingSubspaceDialog from "../components/views/dialogs/AddExistingSubspaceDialog"; import defaultDispatcher from "../dispatcher/dispatcher"; -import { RoomViewStore } from "../stores/RoomViewStore"; import { Action } from "../dispatcher/actions"; import Spinner from "../components/views/elements/Spinner"; import { shouldShowComponent } from "../customisations/helpers/UIComponents"; @@ -38,6 +37,7 @@ import { UIComponent } from "../settings/UIFeature"; import { OpenSpacePreferencesPayload, SpacePreferenceTab } from "../dispatcher/payloads/OpenSpacePreferencesPayload"; import { OpenSpaceSettingsPayload } from "../dispatcher/payloads/OpenSpaceSettingsPayload"; import { OpenAddExistingToSpaceDialogPayload } from "../dispatcher/payloads/OpenAddExistingToSpaceDialogPayload"; +import { SdkContextClass } from "../contexts/SDKContext"; export const shouldShowSpaceSettings = (space: Room) => { const userId = space.client.getUserId(); @@ -113,7 +113,7 @@ export const showAddExistingSubspace = (space: Room): void => { space, onCreateSubspaceClick: () => showCreateNewSubspace(space), onFinished: (added: boolean) => { - if (added && RoomViewStore.instance.getRoomId() === space.roomId) { + if (added && SdkContextClass.instance.roomViewStore.getRoomId() === space.roomId) { defaultDispatcher.fire(Action.UpdateSpaceHierarchy); } }, @@ -125,7 +125,7 @@ export const showCreateNewSubspace = (space: Room): void => { space, onAddExistingSpaceClick: () => showAddExistingSubspace(space), onFinished: (added: boolean) => { - if (added && RoomViewStore.instance.getRoomId() === space.roomId) { + if (added && SdkContextClass.instance.roomViewStore.getRoomId() === space.roomId) { defaultDispatcher.fire(Action.UpdateSpaceHierarchy); } }, diff --git a/test/SlashCommands-test.tsx b/test/SlashCommands-test.tsx index 39d3986270..c31d6d70c1 100644 --- a/test/SlashCommands-test.tsx +++ b/test/SlashCommands-test.tsx @@ -21,9 +21,9 @@ import { Command, Commands, getCommand } from '../src/SlashCommands'; import { createTestClient } from './test-utils'; import { MatrixClientPeg } from '../src/MatrixClientPeg'; import { LocalRoom, LOCAL_ROOM_ID_PREFIX } from '../src/models/LocalRoom'; -import { RoomViewStore } from '../src/stores/RoomViewStore'; import SettingsStore from '../src/settings/SettingsStore'; import LegacyCallHandler from '../src/LegacyCallHandler'; +import { SdkContextClass } from '../src/contexts/SDKContext'; describe('SlashCommands', () => { let client: MatrixClient; @@ -38,14 +38,14 @@ describe('SlashCommands', () => { }; const setCurrentRoom = (): void => { - mocked(RoomViewStore.instance.getRoomId).mockReturnValue(roomId); + mocked(SdkContextClass.instance.roomViewStore.getRoomId).mockReturnValue(roomId); mocked(client.getRoom).mockImplementation((rId: string): Room => { if (rId === roomId) return room; }); }; const setCurrentLocalRoon = (): void => { - mocked(RoomViewStore.instance.getRoomId).mockReturnValue(localRoomId); + mocked(SdkContextClass.instance.roomViewStore.getRoomId).mockReturnValue(localRoomId); mocked(client.getRoom).mockImplementation((rId: string): Room => { if (rId === localRoomId) return localRoom; }); @@ -60,7 +60,7 @@ describe('SlashCommands', () => { room = new Room(roomId, client, client.getUserId()); localRoom = new LocalRoom(localRoomId, client, client.getUserId()); - jest.spyOn(RoomViewStore.instance, "getRoomId"); + jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId"); }); describe('/topic', () => { diff --git a/test/TestStores.ts b/test/TestStores.ts new file mode 100644 index 0000000000..dbaa51f504 --- /dev/null +++ b/test/TestStores.ts @@ -0,0 +1,44 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { SdkContextClass } from "../src/contexts/SDKContext"; +import { PosthogAnalytics } from "../src/PosthogAnalytics"; +import { SlidingSyncManager } from "../src/SlidingSyncManager"; +import { RoomNotificationStateStore } from "../src/stores/notifications/RoomNotificationStateStore"; +import RightPanelStore from "../src/stores/right-panel/RightPanelStore"; +import { RoomViewStore } from "../src/stores/RoomViewStore"; +import { SpaceStoreClass } from "../src/stores/spaces/SpaceStore"; +import { WidgetLayoutStore } from "../src/stores/widgets/WidgetLayoutStore"; +import WidgetStore from "../src/stores/WidgetStore"; + +/** + * A class which provides the same API as Stores but adds additional unsafe setters which can + * replace individual stores. This is useful for tests which need to mock out stores. + */ +export class TestStores extends SdkContextClass { + public _RightPanelStore?: RightPanelStore; + public _RoomNotificationStateStore?: RoomNotificationStateStore; + public _RoomViewStore?: RoomViewStore; + public _WidgetLayoutStore?: WidgetLayoutStore; + public _WidgetStore?: WidgetStore; + public _PosthogAnalytics?: PosthogAnalytics; + public _SlidingSyncManager?: SlidingSyncManager; + public _SpaceStore?: SpaceStoreClass; + + constructor() { + super(); + } +} diff --git a/test/components/structures/RoomView-test.tsx b/test/components/structures/RoomView-test.tsx index dd45c7df09..a4131100c5 100644 --- a/test/components/structures/RoomView-test.tsx +++ b/test/components/structures/RoomView-test.tsx @@ -32,17 +32,16 @@ import { defaultDispatcher } from "../../../src/dispatcher/dispatcher"; import { ViewRoomPayload } from "../../../src/dispatcher/payloads/ViewRoomPayload"; import { RoomView as _RoomView } from "../../../src/components/structures/RoomView"; import ResizeNotifier from "../../../src/utils/ResizeNotifier"; -import { RoomViewStore } from "../../../src/stores/RoomViewStore"; import SettingsStore from "../../../src/settings/SettingsStore"; import { SettingLevel } from "../../../src/settings/SettingLevel"; import DMRoomMap from "../../../src/utils/DMRoomMap"; import { NotificationState } from "../../../src/stores/notifications/NotificationState"; -import RightPanelStore from "../../../src/stores/right-panel/RightPanelStore"; import { RightPanelPhases } from "../../../src/stores/right-panel/RightPanelStorePhases"; import { LocalRoom, LocalRoomState } from "../../../src/models/LocalRoom"; import { DirectoryMember } from "../../../src/utils/direct-messages"; import { createDmLocalRoom } from "../../../src/utils/dm/createDmLocalRoom"; import { UPDATE_EVENT } from "../../../src/stores/AsyncStore"; +import { SdkContextClass, SDKContext } from "../../../src/contexts/SDKContext"; const RoomView = wrapInMatrixClientContext(_RoomView); @@ -50,6 +49,7 @@ describe("RoomView", () => { let cli: MockedObject; let room: Room; let roomCount = 0; + let stores: SdkContextClass; beforeEach(async () => { mockPlatformPeg({ reload: () => {} }); @@ -64,7 +64,9 @@ describe("RoomView", () => { room.on(RoomEvent.TimelineReset, (...args) => cli.emit(RoomEvent.TimelineReset, ...args)); DMRoomMap.makeShared(); - RightPanelStore.instance.useUnitTestClient(cli); + stores = new SdkContextClass(); + stores.client = cli; + stores.rightPanelStore.useUnitTestClient(cli); }); afterEach(async () => { @@ -73,15 +75,15 @@ describe("RoomView", () => { }); const mountRoomView = async (): Promise => { - if (RoomViewStore.instance.getRoomId() !== room.roomId) { + if (stores.roomViewStore.getRoomId() !== room.roomId) { const switchedRoom = new Promise(resolve => { const subFn = () => { - if (RoomViewStore.instance.getRoomId()) { - RoomViewStore.instance.off(UPDATE_EVENT, subFn); + if (stores.roomViewStore.getRoomId()) { + stores.roomViewStore.off(UPDATE_EVENT, subFn); resolve(); } }; - RoomViewStore.instance.on(UPDATE_EVENT, subFn); + stores.roomViewStore.on(UPDATE_EVENT, subFn); }); defaultDispatcher.dispatch({ @@ -94,15 +96,16 @@ describe("RoomView", () => { } const roomView = mount( - , + + + , ); await act(() => Promise.resolve()); // Allow state to settle return roomView; @@ -162,14 +165,14 @@ describe("RoomView", () => { it("normally doesn't open the chat panel", async () => { jest.spyOn(NotificationState.prototype, "isUnread", "get").mockReturnValue(false); await mountRoomView(); - expect(RightPanelStore.instance.isOpen).toEqual(false); + expect(stores.rightPanelStore.isOpen).toEqual(false); }); it("opens the chat panel if there are unread messages", async () => { jest.spyOn(NotificationState.prototype, "isUnread", "get").mockReturnValue(true); await mountRoomView(); - expect(RightPanelStore.instance.isOpen).toEqual(true); - expect(RightPanelStore.instance.currentCard.phase).toEqual(RightPanelPhases.Timeline); + expect(stores.rightPanelStore.isOpen).toEqual(true); + expect(stores.rightPanelStore.currentCard.phase).toEqual(RightPanelPhases.Timeline); }); }); diff --git a/test/components/views/beacon/RoomCallBanner-test.tsx b/test/components/views/beacon/RoomCallBanner-test.tsx index 52d0ed27d3..722c28ff1f 100644 --- a/test/components/views/beacon/RoomCallBanner-test.tsx +++ b/test/components/views/beacon/RoomCallBanner-test.tsx @@ -42,8 +42,8 @@ import RoomCallBanner from "../../../../src/components/views/beacon/RoomCallBann import { CallStore } from "../../../../src/stores/CallStore"; import { WidgetMessagingStore } from "../../../../src/stores/widgets/WidgetMessagingStore"; import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; -import { RoomViewStore } from "../../../../src/stores/RoomViewStore"; import { ConnectionState } from "../../../../src/models/Call"; +import { SdkContextClass } from "../../../../src/contexts/SDKContext"; describe("", () => { let client: Mocked; @@ -132,7 +132,8 @@ describe("", () => { }); it("doesn't show banner if the call is shown", async () => { - jest.spyOn(RoomViewStore.instance, 'isViewingCall').mockReturnValue(true); + jest.spyOn(SdkContextClass.instance.roomViewStore, "isViewingCall"); + mocked(SdkContextClass.instance.roomViewStore.isViewingCall).mockReturnValue(true); await renderBanner(); const banner = await screen.queryByText("Video call"); expect(banner).toBeFalsy(); diff --git a/test/stores/RoomViewStore-test.tsx b/test/stores/RoomViewStore-test.ts similarity index 79% rename from test/stores/RoomViewStore-test.tsx rename to test/stores/RoomViewStore-test.ts index 3ea402438d..f6f6bf2cc7 100644 --- a/test/stores/RoomViewStore-test.tsx +++ b/test/stores/RoomViewStore-test.ts @@ -21,10 +21,21 @@ import { Action } from '../../src/dispatcher/actions'; import { getMockClientWithEventEmitter, untilDispatch, untilEmission } from '../test-utils'; import SettingsStore from '../../src/settings/SettingsStore'; import { SlidingSyncManager } from '../../src/SlidingSyncManager'; +import { PosthogAnalytics } from '../../src/PosthogAnalytics'; import { TimelineRenderingType } from '../../src/contexts/RoomContext'; import { MatrixDispatcher } from '../../src/dispatcher/dispatcher'; import { UPDATE_EVENT } from '../../src/stores/AsyncStore'; import { ActiveRoomChangedPayload } from '../../src/dispatcher/payloads/ActiveRoomChangedPayload'; +import { SpaceStoreClass } from '../../src/stores/spaces/SpaceStore'; +import { TestStores } from '../TestStores'; + +// mock out the injected classes +jest.mock('../../src/PosthogAnalytics'); +const MockPosthogAnalytics = >PosthogAnalytics; +jest.mock('../../src/SlidingSyncManager'); +const MockSlidingSyncManager = >SlidingSyncManager; +jest.mock('../../src/stores/spaces/SpaceStore'); +const MockSpaceStore = >SpaceStoreClass; jest.mock('../../src/utils/DMRoomMap', () => { const mock = { @@ -51,6 +62,9 @@ describe('RoomViewStore', function() { isGuest: jest.fn(), }); const room = new Room(roomId, mockClient, userId); + + let roomViewStore: RoomViewStore; + let slidingSyncManager: SlidingSyncManager; let dis: MatrixDispatcher; beforeEach(function() { @@ -60,10 +74,17 @@ describe('RoomViewStore', function() { mockClient.getRoom.mockReturnValue(room); mockClient.isGuest.mockReturnValue(false); - // Reset the state of the store + // Make the RVS to test dis = new MatrixDispatcher(); - RoomViewStore.instance.reset(); - RoomViewStore.instance.resetDispatcher(dis); + slidingSyncManager = new MockSlidingSyncManager(); + const stores = new TestStores(); + stores._SlidingSyncManager = slidingSyncManager; + stores._PosthogAnalytics = new MockPosthogAnalytics(); + stores._SpaceStore = new MockSpaceStore(); + roomViewStore = new RoomViewStore( + dis, stores, + ); + stores._RoomViewStore = roomViewStore; }); it('can be used to view a room by ID and join', async () => { @@ -71,14 +92,14 @@ describe('RoomViewStore', function() { dis.dispatch({ action: Action.JoinRoom }); await untilDispatch(Action.JoinRoomReady, dis); expect(mockClient.joinRoom).toHaveBeenCalledWith(roomId, { viaServers: [] }); - expect(RoomViewStore.instance.isJoining()).toBe(true); + expect(roomViewStore.isJoining()).toBe(true); }); it('can auto-join a room', async () => { dis.dispatch({ action: Action.ViewRoom, room_id: roomId, auto_join: true }); await untilDispatch(Action.JoinRoomReady, dis); expect(mockClient.joinRoom).toHaveBeenCalledWith(roomId, { viaServers: [] }); - expect(RoomViewStore.instance.isJoining()).toBe(true); + expect(roomViewStore.isJoining()).toBe(true); }); it('emits ActiveRoomChanged when the viewed room changes', async () => { @@ -97,7 +118,7 @@ describe('RoomViewStore', function() { it('invokes room activity listeners when the viewed room changes', async () => { const roomId2 = "!roomid:2"; const callback = jest.fn(); - RoomViewStore.instance.addRoomListener(roomId, callback); + roomViewStore.addRoomListener(roomId, callback); dis.dispatch({ action: Action.ViewRoom, room_id: roomId }); await untilDispatch(Action.ActiveRoomChanged, dis) as ActiveRoomChangedPayload; expect(callback).toHaveBeenCalledWith(true); @@ -116,14 +137,14 @@ describe('RoomViewStore', function() { }, dis); // roomId is set to id of the room alias - expect(RoomViewStore.instance.getRoomId()).toBe(roomId); + expect(roomViewStore.getRoomId()).toBe(roomId); // join the room dis.dispatch({ action: Action.JoinRoom }, true); await untilDispatch(Action.JoinRoomReady, dis); - expect(RoomViewStore.instance.isJoining()).toBeTruthy(); + expect(roomViewStore.isJoining()).toBeTruthy(); expect(mockClient.joinRoom).toHaveBeenCalledWith(alias, { viaServers: [] }); }); @@ -134,7 +155,7 @@ describe('RoomViewStore', function() { const payload = await untilDispatch(Action.ViewRoomError, dis); expect(payload.room_id).toBeNull(); expect(payload.room_alias).toEqual(alias); - expect(RoomViewStore.instance.getRoomAlias()).toEqual(alias); + expect(roomViewStore.getRoomAlias()).toEqual(alias); }); it('emits JoinRoomError if joining the room fails', async () => { @@ -143,8 +164,8 @@ describe('RoomViewStore', function() { dis.dispatch({ action: Action.ViewRoom, room_id: roomId }); dis.dispatch({ action: Action.JoinRoom }); await untilDispatch(Action.JoinRoomError, dis); - expect(RoomViewStore.instance.isJoining()).toBe(false); - expect(RoomViewStore.instance.getJoinError()).toEqual(joinErr); + expect(roomViewStore.isJoining()).toBe(false); + expect(roomViewStore.getJoinError()).toEqual(joinErr); }); it('remembers the event being replied to when swapping rooms', async () => { @@ -154,13 +175,13 @@ describe('RoomViewStore', function() { getRoomId: () => roomId, }; dis.dispatch({ action: 'reply_to_event', event: replyToEvent, context: TimelineRenderingType.Room }); - await untilEmission(RoomViewStore.instance, UPDATE_EVENT); - expect(RoomViewStore.instance.getQuotingEvent()).toEqual(replyToEvent); + await untilEmission(roomViewStore, UPDATE_EVENT); + expect(roomViewStore.getQuotingEvent()).toEqual(replyToEvent); // view the same room, should remember the event. // set the highlighed flag to make sure there is a state change so we get an update event dis.dispatch({ action: Action.ViewRoom, room_id: roomId, highlighted: true }); - await untilEmission(RoomViewStore.instance, UPDATE_EVENT); - expect(RoomViewStore.instance.getQuotingEvent()).toEqual(replyToEvent); + await untilEmission(roomViewStore, UPDATE_EVENT); + expect(roomViewStore.getQuotingEvent()).toEqual(replyToEvent); }); it('swaps to the replied event room if it is not the current room', async () => { @@ -172,18 +193,18 @@ describe('RoomViewStore', function() { }; dis.dispatch({ action: 'reply_to_event', event: replyToEvent, context: TimelineRenderingType.Room }); await untilDispatch(Action.ViewRoom, dis); - expect(RoomViewStore.instance.getQuotingEvent()).toEqual(replyToEvent); - expect(RoomViewStore.instance.getRoomId()).toEqual(roomId2); + expect(roomViewStore.getQuotingEvent()).toEqual(replyToEvent); + expect(roomViewStore.getRoomId()).toEqual(roomId2); }); it('removes the roomId on ViewHomePage', async () => { dis.dispatch({ action: Action.ViewRoom, room_id: roomId }); await untilDispatch(Action.ActiveRoomChanged, dis); - expect(RoomViewStore.instance.getRoomId()).toEqual(roomId); + expect(roomViewStore.getRoomId()).toEqual(roomId); dis.dispatch({ action: Action.ViewHomePage }); - await untilEmission(RoomViewStore.instance, UPDATE_EVENT); - expect(RoomViewStore.instance.getRoomId()).toBeNull(); + await untilEmission(roomViewStore, UPDATE_EVENT); + expect(roomViewStore.getRoomId()).toBeNull(); }); describe('Sliding Sync', function() { @@ -191,23 +212,22 @@ describe('RoomViewStore', function() { jest.spyOn(SettingsStore, 'getValue').mockImplementation((settingName, roomId, value) => { return settingName === "feature_sliding_sync"; // this is enabled, everything else is disabled. }); - RoomViewStore.instance.reset(); }); it("subscribes to the room", async () => { - const setRoomVisible = jest.spyOn(SlidingSyncManager.instance, "setRoomVisible").mockReturnValue( + const setRoomVisible = jest.spyOn(slidingSyncManager, "setRoomVisible").mockReturnValue( Promise.resolve(""), ); const subscribedRoomId = "!sub1:localhost"; dis.dispatch({ action: Action.ViewRoom, room_id: subscribedRoomId }); await untilDispatch(Action.ActiveRoomChanged, dis); - expect(RoomViewStore.instance.getRoomId()).toBe(subscribedRoomId); + expect(roomViewStore.getRoomId()).toBe(subscribedRoomId); expect(setRoomVisible).toHaveBeenCalledWith(subscribedRoomId, true); }); // Regression test for an in-the-wild bug where rooms would rapidly switch forever in sliding sync mode it("doesn't get stuck in a loop if you view rooms quickly", async () => { - const setRoomVisible = jest.spyOn(SlidingSyncManager.instance, "setRoomVisible").mockReturnValue( + const setRoomVisible = jest.spyOn(slidingSyncManager, "setRoomVisible").mockReturnValue( Promise.resolve(""), ); const subscribedRoomId = "!sub1:localhost"; diff --git a/test/stores/widgets/StopGapWidgetDriver-test.ts b/test/stores/widgets/StopGapWidgetDriver-test.ts index d1816bdac3..0fd2f18be7 100644 --- a/test/stores/widgets/StopGapWidgetDriver-test.ts +++ b/test/stores/widgets/StopGapWidgetDriver-test.ts @@ -20,8 +20,8 @@ import { DeviceInfo } from "matrix-js-sdk/src/crypto/deviceinfo"; import { Direction, MatrixEvent } from "matrix-js-sdk/src/matrix"; import { Widget, MatrixWidgetType, WidgetKind, WidgetDriver, ITurnServer } from "matrix-widget-api"; +import { SdkContextClass } from "../../../src/contexts/SDKContext"; import { MatrixClientPeg } from "../../../src/MatrixClientPeg"; -import { RoomViewStore } from "../../../src/stores/RoomViewStore"; import { StopGapWidgetDriver } from "../../../src/stores/widgets/StopGapWidgetDriver"; import { stubClient } from "../../test-utils"; @@ -201,7 +201,7 @@ describe("StopGapWidgetDriver", () => { beforeEach(() => { driver = mkDefaultDriver(); }); it('reads related events from the current room', async () => { - jest.spyOn(RoomViewStore.instance, 'getRoomId').mockReturnValue('!this-room-id'); + jest.spyOn(SdkContextClass.instance.roomViewStore, 'getRoomId').mockReturnValue('!this-room-id'); client.relations.mockResolvedValue({ originalEvent: new MatrixEvent(), From 3c3df11d32f439cd84d057f7ef54dbd3fd75fd53 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Wed, 19 Oct 2022 13:31:20 +0100 Subject: [PATCH 10/11] Support for login + E2EE set up with QR (#9403) * Support for login + E2EE set up with QR * Whitespace * Padding * Refactor of fetch * Whitespace * CSS whitespace * Add link to MSC3906 * Handle incorrect typing in MatrixClientPeg.get() * Use unstable class name * fix: use unstable class name * Use default fetch client instead * Update to revised function name * Refactor device manager panel and make it work with new sessions manager * Lint fix * Add missing interstitials and update wording * Linting * i18n * Lint * Use sensible sdk config name for fallback server * Improve error handling for QR code generation * Refactor feature availability logic * Hide device manager panel if no options available * Put sign in with QR behind lab setting * Reduce scope of PR to just showing code on existing device * i18n updates * Handle null features * Testing for LoginWithQRSection * Refactor to handle UIA * Imports * Reduce diff complexity * Remove unnecessary change * Remove unused styles * Support UIA * Tidy up * i18n * Remove additional unused parts of flow * Add extra instruction when showing QR code * Add getVersions to server mocks * Use proper colours for theme support * Test cases * Lint * Remove obsolete snapshot * Don't override error if already set * Remove unused var * Update src/components/views/settings/devices/LoginWithQRSection.tsx Co-authored-by: Travis Ralston * Update src/components/views/auth/LoginWithQR.tsx Co-authored-by: Travis Ralston * Update src/components/views/auth/LoginWithQR.tsx Co-authored-by: Travis Ralston * Update src/components/views/auth/LoginWithQR.tsx Co-authored-by: Travis Ralston * Update src/components/views/auth/LoginWithQR.tsx Co-authored-by: Travis Ralston * Update src/components/views/auth/LoginWithQR.tsx Co-authored-by: Travis Ralston * Update res/css/views/auth/_LoginWithQR.pcss Co-authored-by: Kerry * Use spacing variables * Remove debug * Style + docs * preventDefault * Names of tests * Fixes for js-sdk refactor * Update snapshots to match test names * Refactor labs config to make deployment simpler * i18n * Unused imports * Typo * Stateless component * Whitespace * Use context not MatrixClientPeg * Add missing context * Type updates to match js-sdk * Wrap click handlers in useCallback * Update src/components/views/settings/DevicesPanel.tsx Co-authored-by: Travis Ralston * Wait for DOM update instead of timeout * Add missing snapshot update from last commit * Remove void keyword in favour of then() clauses * test main paths in LoginWithQR Co-authored-by: Travis Ralston Co-authored-by: Kerry --- res/css/_components.pcss | 1 + res/css/views/auth/_LoginWithQR.pcss | 171 ++++++++ res/img/element-icons/back.svg | 3 + res/img/element-icons/devices.svg | 11 + res/img/element-icons/qrcode.svg | 4 + src/components/views/auth/LoginWithQR.tsx | 396 ++++++++++++++++++ .../views/dialogs/InteractiveAuthDialog.tsx | 6 +- .../views/settings/DevicesPanel.tsx | 22 +- .../settings/devices/LoginWithQRSection.tsx | 63 +++ .../views/settings/devices/useOwnDevices.ts | 7 + .../tabs/user/SecurityUserSettingsTab.tsx | 28 ++ .../settings/tabs/user/SessionManagerTab.tsx | 29 ++ src/i18n/strings/en_EN.json | 24 ++ src/settings/Settings.tsx | 10 + src/utils/UserInteractiveAuth.ts | 55 +++ .../views/settings/DevicesPanel-test.tsx | 6 +- .../settings/devices/LoginWithQR-test.tsx | 297 +++++++++++++ .../devices/LoginWithQRSection-test.tsx | 94 +++++ .../__snapshots__/LoginWithQR-test.tsx.snap | 367 ++++++++++++++++ .../LoginWithQRSection-test.tsx.snap | 45 ++ .../user/SecurityUserSettingsTab-test.tsx | 9 +- .../tabs/user/SessionManagerTab-test.tsx | 1 + test/test-utils/client.ts | 1 + 23 files changed, 1638 insertions(+), 12 deletions(-) create mode 100644 res/css/views/auth/_LoginWithQR.pcss create mode 100644 res/img/element-icons/back.svg create mode 100644 res/img/element-icons/devices.svg create mode 100644 res/img/element-icons/qrcode.svg create mode 100644 src/components/views/auth/LoginWithQR.tsx create mode 100644 src/components/views/settings/devices/LoginWithQRSection.tsx create mode 100644 src/utils/UserInteractiveAuth.ts create mode 100644 test/components/views/settings/devices/LoginWithQR-test.tsx create mode 100644 test/components/views/settings/devices/LoginWithQRSection-test.tsx create mode 100644 test/components/views/settings/devices/__snapshots__/LoginWithQR-test.tsx.snap create mode 100644 test/components/views/settings/devices/__snapshots__/LoginWithQRSection-test.tsx.snap diff --git a/res/css/_components.pcss b/res/css/_components.pcss index 00661bd56b..b2fcb0dd4f 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -96,6 +96,7 @@ @import "./views/auth/_CountryDropdown.pcss"; @import "./views/auth/_InteractiveAuthEntryComponents.pcss"; @import "./views/auth/_LanguageSelector.pcss"; +@import "./views/auth/_LoginWithQR.pcss"; @import "./views/auth/_PassphraseField.pcss"; @import "./views/auth/_Welcome.pcss"; @import "./views/avatars/_BaseAvatar.pcss"; diff --git a/res/css/views/auth/_LoginWithQR.pcss b/res/css/views/auth/_LoginWithQR.pcss new file mode 100644 index 0000000000..390cf8311d --- /dev/null +++ b/res/css/views/auth/_LoginWithQR.pcss @@ -0,0 +1,171 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_LoginWithQRSection .mx_AccessibleButton { + margin-right: $spacing-12; +} + +.mx_AuthPage .mx_LoginWithQR { + .mx_AccessibleButton { + display: block !important; + } + + .mx_AccessibleButton + .mx_AccessibleButton { + margin-top: $spacing-8; + } + + .mx_LoginWithQR_separator { + display: flex; + align-items: center; + text-align: center; + + &::before, &::after { + content: ''; + flex: 1; + border-bottom: 1px solid $quinary-content; + } + + &:not(:empty) { + &::before { + margin-right: 1em; + } + &::after { + margin-left: 1em; + } + } + } + + font-size: $font-15px; +} + +.mx_UserSettingsDialog .mx_LoginWithQR { + .mx_AccessibleButton + .mx_AccessibleButton { + margin-left: $spacing-12; + } + + font-size: $font-14px; + + h1 { + font-size: $font-24px; + margin-bottom: 0; + } + + li { + line-height: 1.8; + } + + .mx_QRCode { + padding: $spacing-12 $spacing-40; + margin: $spacing-28 0; + } + + .mx_LoginWithQR_buttons { + text-align: center; + } + + .mx_LoginWithQR_qrWrapper { + display: flex; + } +} + +.mx_LoginWithQR { + min-height: 350px; + display: flex; + flex-direction: column; + + .mx_LoginWithQR_centreTitle { + h1 { + text-align: centre; + } + } + + h1 > svg { + &.normal { + color: $secondary-content; + } + &.error { + color: $alert; + } + &.success { + color: $accent; + } + height: 1.3em; + margin-right: $spacing-8; + vertical-align: middle; + } + + .mx_LoginWithQR_confirmationDigits { + text-align: center; + margin: $spacing-48 auto; + font-weight: 600; + font-size: $font-24px; + color: $primary-content; + } + + .mx_LoginWithQR_confirmationAlert { + border: 1px solid $quaternary-content; + border-radius: $spacing-8; + padding: $spacing-8; + line-height: 1.5em; + display: flex; + + svg { + height: 30px; + } + } + + .mx_LoginWithQR_separator { + margin: 1em 0; + } + + ol { + list-style-position: inside; + padding-inline-start: 0; + + li::marker { + color: $accent; + } + } + + .mx_LoginWithQR_BackButton { + height: $spacing-12; + margin-bottom: $spacing-24; + svg { + height: 100%; + } + } + + .mx_LoginWithQR_main { + display: flex; + flex-direction: column; + flex-grow: 1; + } + + .mx_QRCode { + border: 1px solid $quinary-content; + border-radius: $spacing-8; + display: flex; + justify-content: center; + } + + .mx_LoginWithQR_spinner { + flex-grow: 1; + display: flex; + justify-content: center; + align-items: center; + height: 100%; + } +} diff --git a/res/img/element-icons/back.svg b/res/img/element-icons/back.svg new file mode 100644 index 0000000000..62aef5df27 --- /dev/null +++ b/res/img/element-icons/back.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/res/img/element-icons/devices.svg b/res/img/element-icons/devices.svg new file mode 100644 index 0000000000..6c26cfe97e --- /dev/null +++ b/res/img/element-icons/devices.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/res/img/element-icons/qrcode.svg b/res/img/element-icons/qrcode.svg new file mode 100644 index 0000000000..7787141ad5 --- /dev/null +++ b/res/img/element-icons/qrcode.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/src/components/views/auth/LoginWithQR.tsx b/src/components/views/auth/LoginWithQR.tsx new file mode 100644 index 0000000000..f95e618cc5 --- /dev/null +++ b/src/components/views/auth/LoginWithQR.tsx @@ -0,0 +1,396 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from 'react'; +import { MSC3906Rendezvous, MSC3906RendezvousPayload, RendezvousFailureReason } from 'matrix-js-sdk/src/rendezvous'; +import { MSC3886SimpleHttpRendezvousTransport } from 'matrix-js-sdk/src/rendezvous/transports'; +import { MSC3903ECDHPayload, MSC3903ECDHv1RendezvousChannel } from 'matrix-js-sdk/src/rendezvous/channels'; +import { logger } from 'matrix-js-sdk/src/logger'; +import { MatrixClient } from 'matrix-js-sdk/src/client'; + +import { _t } from "../../../languageHandler"; +import AccessibleButton from '../elements/AccessibleButton'; +import QRCode from '../elements/QRCode'; +import Spinner from '../elements/Spinner'; +import { Icon as BackButtonIcon } from "../../../../res/img/element-icons/back.svg"; +import { Icon as DevicesIcon } from "../../../../res/img/element-icons/devices.svg"; +import { Icon as WarningBadge } from "../../../../res/img/element-icons/warning-badge.svg"; +import { Icon as InfoIcon } from "../../../../res/img/element-icons/i.svg"; +import { wrapRequestWithDialog } from '../../../utils/UserInteractiveAuth'; + +/** + * The intention of this enum is to have a mode that scans a QR code instead of generating one. + */ +export enum Mode { + /** + * A QR code with be generated and shown + */ + Show = "show", +} + +enum Phase { + Loading, + ShowingQR, + Connecting, + Connected, + WaitingForDevice, + Verifying, + Error, +} + +interface IProps { + client: MatrixClient; + mode: Mode; + onFinished(...args: any): void; +} + +interface IState { + phase: Phase; + rendezvous?: MSC3906Rendezvous; + confirmationDigits?: string; + failureReason?: RendezvousFailureReason; + mediaPermissionError?: boolean; +} + +/** + * A component that allows sign in and E2EE set up with a QR code. + * + * It implements both `login.start` and `login-reciprocate` capabilities as well as both scanning and showing QR codes. + * + * This uses the unstable feature of MSC3906: https://github.com/matrix-org/matrix-spec-proposals/pull/3906 + */ +export default class LoginWithQR extends React.Component { + public constructor(props) { + super(props); + + this.state = { + phase: Phase.Loading, + }; + } + + public componentDidMount(): void { + this.updateMode(this.props.mode).then(() => {}); + } + + public componentDidUpdate(prevProps: Readonly): void { + if (prevProps.mode !== this.props.mode) { + this.updateMode(this.props.mode).then(() => {}); + } + } + + private async updateMode(mode: Mode) { + this.setState({ phase: Phase.Loading }); + if (this.state.rendezvous) { + this.state.rendezvous.onFailure = undefined; + await this.state.rendezvous.cancel(RendezvousFailureReason.UserCancelled); + this.setState({ rendezvous: undefined }); + } + if (mode === Mode.Show) { + await this.generateCode(); + } + } + + public componentWillUnmount(): void { + if (this.state.rendezvous) { + // eslint-disable-next-line react/no-direct-mutation-state + this.state.rendezvous.onFailure = undefined; + // calling cancel will call close() as well to clean up the resources + this.state.rendezvous.cancel(RendezvousFailureReason.UserCancelled).then(() => {}); + } + } + + private approveLogin = async (): Promise => { + if (!this.state.rendezvous) { + throw new Error('Rendezvous not found'); + } + this.setState({ phase: Phase.Loading }); + + try { + logger.info("Requesting login token"); + + const { login_token: loginToken } = await wrapRequestWithDialog(this.props.client.requestLoginToken, { + matrixClient: this.props.client, + title: _t("Sign in new device"), + })(); + + this.setState({ phase: Phase.WaitingForDevice }); + + const newDeviceId = await this.state.rendezvous.approveLoginOnExistingDevice(loginToken); + if (!newDeviceId) { + // user denied + return; + } + if (!this.props.client.crypto) { + // no E2EE to set up + this.props.onFinished(true); + return; + } + await this.state.rendezvous.verifyNewDeviceOnExistingDevice(); + this.props.onFinished(true); + } catch (e) { + logger.error('Error whilst approving sign in', e); + this.setState({ phase: Phase.Error, failureReason: RendezvousFailureReason.Unknown }); + } + }; + + private generateCode = async () => { + let rendezvous: MSC3906Rendezvous; + try { + const transport = new MSC3886SimpleHttpRendezvousTransport({ + onFailure: this.onFailure, + client: this.props.client, + }); + + const channel = new MSC3903ECDHv1RendezvousChannel( + transport, undefined, this.onFailure, + ); + + rendezvous = new MSC3906Rendezvous(channel, this.props.client, this.onFailure); + + await rendezvous.generateCode(); + this.setState({ + phase: Phase.ShowingQR, + rendezvous, + failureReason: undefined, + }); + } catch (e) { + logger.error('Error whilst generating QR code', e); + this.setState({ phase: Phase.Error, failureReason: RendezvousFailureReason.HomeserverLacksSupport }); + return; + } + + try { + const confirmationDigits = await rendezvous.startAfterShowingCode(); + this.setState({ phase: Phase.Connected, confirmationDigits }); + } catch (e) { + logger.error('Error whilst doing QR login', e); + // only set to error phase if it hasn't already been set by onFailure or similar + if (this.state.phase !== Phase.Error) { + this.setState({ phase: Phase.Error, failureReason: RendezvousFailureReason.Unknown }); + } + } + }; + + private onFailure = (reason: RendezvousFailureReason) => { + logger.info(`Rendezvous failed: ${reason}`); + this.setState({ phase: Phase.Error, failureReason: reason }); + }; + + public reset() { + this.setState({ + rendezvous: undefined, + confirmationDigits: undefined, + failureReason: undefined, + }); + } + + private cancelClicked = async (e: React.FormEvent) => { + e.preventDefault(); + await this.state.rendezvous?.cancel(RendezvousFailureReason.UserCancelled); + this.reset(); + this.props.onFinished(false); + }; + + private declineClicked = async (e: React.FormEvent) => { + e.preventDefault(); + await this.state.rendezvous?.declineLoginOnExistingDevice(); + this.reset(); + this.props.onFinished(false); + }; + + private tryAgainClicked = async (e: React.FormEvent) => { + e.preventDefault(); + this.reset(); + await this.updateMode(this.props.mode); + }; + + private onBackClick = async () => { + await this.state.rendezvous?.cancel(RendezvousFailureReason.UserCancelled); + + this.props.onFinished(false); + }; + + private cancelButton = () => + { _t("Cancel") } + ; + + private simpleSpinner = (description?: string): JSX.Element => { + return
+
+ + { description &&

{ description }

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

{ cancellationMessage }

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

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

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

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

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

{ titleIcon }{ title }

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

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

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

+

+
+
+
+
+
+
+

+ Waiting for device to sign in +

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

+
+ Devices connected +

+
+
+

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

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

+
+ Connection failed +

+
+
+

+ An unexpected error occurred. +

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

+ Sign in with QR code +

+
+
+

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

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

+
+ Connection failed +

+
+
+

+ An unexpected error occurred. +

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

+
+ Connection failed +

+
+
+

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

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

+

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

+ Sign in with QR code +

+
+
+
+

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

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

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

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

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

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

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

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

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

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

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

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

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

, + "hasCloseButton": true, + "title": "Can't start a new voice broadcast", + }, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, + }, + ], +} +`; diff --git a/test/voice-broadcast/utils/hasRoomLiveVoiceBroadcast-test.ts b/test/voice-broadcast/utils/hasRoomLiveVoiceBroadcast-test.ts new file mode 100644 index 0000000000..c9fbc5f09e --- /dev/null +++ b/test/voice-broadcast/utils/hasRoomLiveVoiceBroadcast-test.ts @@ -0,0 +1,144 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { MatrixClient, Room } from "matrix-js-sdk/src/matrix"; + +import { + hasRoomLiveVoiceBroadcast, + VoiceBroadcastInfoEventType, + VoiceBroadcastInfoState, +} from "../../../src/voice-broadcast"; +import { mkEvent, stubClient } from "../../test-utils"; +import { mkVoiceBroadcastInfoStateEvent } from "./test-utils"; + +describe("hasRoomLiveVoiceBroadcast", () => { + const otherUserId = "@other:example.com"; + const roomId = "!room:example.com"; + let client: MatrixClient; + let room: Room; + + const addVoiceBroadcastInfoEvent = ( + state: VoiceBroadcastInfoState, + sender: string, + ) => { + room.currentState.setStateEvents([ + mkVoiceBroadcastInfoStateEvent(room.roomId, state, sender), + ]); + }; + + const itShouldReturnTrueTrue = () => { + it("should return true/true", () => { + expect(hasRoomLiveVoiceBroadcast(room, client.getUserId())).toEqual({ + hasBroadcast: true, + startedByUser: true, + }); + }); + }; + + const itShouldReturnTrueFalse = () => { + it("should return true/false", () => { + expect(hasRoomLiveVoiceBroadcast(room, client.getUserId())).toEqual({ + hasBroadcast: true, + startedByUser: false, + }); + }); + }; + + const itShouldReturnFalseFalse = () => { + it("should return false/false", () => { + expect(hasRoomLiveVoiceBroadcast(room, client.getUserId())).toEqual({ + hasBroadcast: false, + startedByUser: false, + }); + }); + }; + + beforeAll(() => { + client = stubClient(); + }); + + beforeEach(() => { + room = new Room(roomId, client, client.getUserId()); + }); + + describe("when there is no voice broadcast info at all", () => { + itShouldReturnFalseFalse(); + }); + + describe("when the »state« prop is missing", () => { + beforeEach(() => { + room.currentState.setStateEvents([ + mkEvent({ + event: true, + room: room.roomId, + user: client.getUserId(), + type: VoiceBroadcastInfoEventType, + skey: client.getUserId(), + content: {}, + }), + ]); + }); + itShouldReturnFalseFalse(); + }); + + describe("when there is a live broadcast from the current and another user", () => { + beforeEach(() => { + addVoiceBroadcastInfoEvent(VoiceBroadcastInfoState.Started, client.getUserId()); + addVoiceBroadcastInfoEvent(VoiceBroadcastInfoState.Started, otherUserId); + }); + + itShouldReturnTrueTrue(); + }); + + describe("when there are only stopped info events", () => { + beforeEach(() => { + addVoiceBroadcastInfoEvent(VoiceBroadcastInfoState.Stopped, client.getUserId()); + addVoiceBroadcastInfoEvent(VoiceBroadcastInfoState.Stopped, otherUserId); + }); + + itShouldReturnFalseFalse(); + }); + + describe.each([ + // all there are kind of live states + VoiceBroadcastInfoState.Started, + VoiceBroadcastInfoState.Paused, + VoiceBroadcastInfoState.Running, + ])("when there is a live broadcast (%s) from the current user", (state: VoiceBroadcastInfoState) => { + beforeEach(() => { + addVoiceBroadcastInfoEvent(state, client.getUserId()); + }); + + itShouldReturnTrueTrue(); + }); + + describe("when there was a live broadcast, that has been stopped", () => { + beforeEach(() => { + addVoiceBroadcastInfoEvent(VoiceBroadcastInfoState.Running, client.getUserId()); + addVoiceBroadcastInfoEvent(VoiceBroadcastInfoState.Stopped, client.getUserId()); + }); + + itShouldReturnFalseFalse(); + }); + + describe("when there is a live broadcast from another user", () => { + beforeEach(() => { + addVoiceBroadcastInfoEvent(VoiceBroadcastInfoState.Running, otherUserId); + }); + + itShouldReturnTrueFalse(); + }); +}); diff --git a/test/voice-broadcast/utils/startNewVoiceBroadcastRecording-test.ts b/test/voice-broadcast/utils/startNewVoiceBroadcastRecording-test.ts index 570719539a..a320bca2eb 100644 --- a/test/voice-broadcast/utils/startNewVoiceBroadcastRecording-test.ts +++ b/test/voice-broadcast/utils/startNewVoiceBroadcastRecording-test.ts @@ -15,8 +15,9 @@ limitations under the License. */ import { mocked } from "jest-mock"; -import { EventType, MatrixClient, MatrixEvent, Room, RoomStateEvent } from "matrix-js-sdk/src/matrix"; +import { EventType, MatrixClient, MatrixEvent, Room } from "matrix-js-sdk/src/matrix"; +import Modal from "../../../src/Modal"; import { startNewVoiceBroadcastRecording, VoiceBroadcastInfoEventType, @@ -25,46 +26,29 @@ import { VoiceBroadcastRecording, } from "../../../src/voice-broadcast"; import { mkEvent, stubClient } from "../../test-utils"; +import { mkVoiceBroadcastInfoStateEvent } from "./test-utils"; jest.mock("../../../src/voice-broadcast/models/VoiceBroadcastRecording", () => ({ VoiceBroadcastRecording: jest.fn(), })); +jest.mock("../../../src/Modal"); + describe("startNewVoiceBroadcastRecording", () => { const roomId = "!room:example.com"; + const otherUserId = "@other:example.com"; let client: MatrixClient; let recordingsStore: VoiceBroadcastRecordingsStore; let room: Room; - let roomOnStateEventsCallbackRegistered: Promise; - let roomOnStateEventsCallbackRegisteredResolver: Function; - let roomOnStateEventsCallback: () => void; let infoEvent: MatrixEvent; let otherEvent: MatrixEvent; - let stateEvent: MatrixEvent; + let result: VoiceBroadcastRecording | null; beforeEach(() => { - roomOnStateEventsCallbackRegistered = new Promise((resolve) => { - roomOnStateEventsCallbackRegisteredResolver = resolve; - }); - - room = { - currentState: { - getStateEvents: jest.fn().mockImplementation((type, userId) => { - if (type === VoiceBroadcastInfoEventType && userId === client.getUserId()) { - return stateEvent; - } - }), - }, - on: jest.fn().mockImplementation((eventType, callback) => { - if (eventType === RoomStateEvent.Events) { - roomOnStateEventsCallback = callback; - roomOnStateEventsCallbackRegisteredResolver(); - } - }), - off: jest.fn(), - } as unknown as Room; - client = stubClient(); + room = new Room(roomId, client, client.getUserId()); + jest.spyOn(room.currentState, "maySendStateEvent"); + mocked(client.getRoom).mockImplementation((getRoomId: string) => { if (getRoomId === roomId) { return room; @@ -85,22 +69,14 @@ describe("startNewVoiceBroadcastRecording", () => { setCurrent: jest.fn(), } as unknown as VoiceBroadcastRecordingsStore; - infoEvent = mkEvent({ - event: true, - type: VoiceBroadcastInfoEventType, - content: { - device_id: client.getDeviceId(), - state: VoiceBroadcastInfoState.Started, - }, - user: client.getUserId(), - room: roomId, - }); + infoEvent = mkVoiceBroadcastInfoStateEvent(roomId, VoiceBroadcastInfoState.Started, client.getUserId()); otherEvent = mkEvent({ event: true, type: EventType.RoomMember, content: {}, user: client.getUserId(), room: roomId, + skey: "", }); mocked(VoiceBroadcastRecording).mockImplementation(( @@ -115,29 +91,96 @@ describe("startNewVoiceBroadcastRecording", () => { }); }); - it("should create a new Voice Broadcast", (done) => { - let ok = false; + afterEach(() => { + jest.clearAllMocks(); + }); - startNewVoiceBroadcastRecording(roomId, client, recordingsStore).then((recording) => { - expect(ok).toBe(true); - expect(mocked(room.off)).toHaveBeenCalledWith(RoomStateEvent.Events, roomOnStateEventsCallback); - expect(recording.infoEvent).toBe(infoEvent); - expect(recording.start).toHaveBeenCalled(); - done(); + describe("when the current user is allowed to send voice broadcast info state events", () => { + beforeEach(() => { + mocked(room.currentState.maySendStateEvent).mockReturnValue(true); }); - roomOnStateEventsCallbackRegistered.then(() => { - // no state event, yet - roomOnStateEventsCallback(); + describe("when there currently is no other broadcast", () => { + it("should create a new Voice Broadcast", async () => { + mocked(client.sendStateEvent).mockImplementation(async ( + _roomId: string, + _eventType: string, + _content: any, + _stateKey = "", + ) => { + setTimeout(() => { + // emit state events after resolving the promise + room.currentState.setStateEvents([otherEvent]); + room.currentState.setStateEvents([infoEvent]); + }, 0); + return { event_id: infoEvent.getId() }; + }); + const recording = await startNewVoiceBroadcastRecording(room, client, recordingsStore); - // other state event - stateEvent = otherEvent; - roomOnStateEventsCallback(); + expect(client.sendStateEvent).toHaveBeenCalledWith( + roomId, + VoiceBroadcastInfoEventType, + { + chunk_length: 300, + device_id: client.getDeviceId(), + state: VoiceBroadcastInfoState.Started, + }, + client.getUserId(), + ); + expect(recording.infoEvent).toBe(infoEvent); + expect(recording.start).toHaveBeenCalled(); + }); + }); - // the expected Voice Broadcast Info event - stateEvent = infoEvent; - ok = true; - roomOnStateEventsCallback(); + describe("when there already is a live broadcast of the current user", () => { + beforeEach(async () => { + room.currentState.setStateEvents([ + mkVoiceBroadcastInfoStateEvent(roomId, VoiceBroadcastInfoState.Running, client.getUserId()), + ]); + + result = await startNewVoiceBroadcastRecording(room, client, recordingsStore); + }); + + it("should not start a voice broadcast", () => { + expect(result).toBeNull(); + }); + + it("should show an info dialog", () => { + expect(Modal.createDialog).toMatchSnapshot(); + }); + }); + + describe("when there already is a live broadcast of another user", () => { + beforeEach(async () => { + room.currentState.setStateEvents([ + mkVoiceBroadcastInfoStateEvent(roomId, VoiceBroadcastInfoState.Running, otherUserId), + ]); + + result = await startNewVoiceBroadcastRecording(room, client, recordingsStore); + }); + + it("should not start a voice broadcast", () => { + expect(result).toBeNull(); + }); + + it("should show an info dialog", () => { + expect(Modal.createDialog).toMatchSnapshot(); + }); + }); + }); + + describe("when the current user is not allowed to send voice broadcast info state events", () => { + beforeEach(async () => { + mocked(room.currentState.maySendStateEvent).mockReturnValue(false); + result = await startNewVoiceBroadcastRecording(room, client, recordingsStore); + }); + + it("should not start a voice broadcast", () => { + expect(result).toBeNull(); + }); + + it("should show an info dialog", () => { + expect(Modal.createDialog).toMatchSnapshot(); }); }); }); diff --git a/test/voice-broadcast/utils/test-utils.ts b/test/voice-broadcast/utils/test-utils.ts new file mode 100644 index 0000000000..2a73877474 --- /dev/null +++ b/test/voice-broadcast/utils/test-utils.ts @@ -0,0 +1,37 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { MatrixEvent } from "matrix-js-sdk/src/matrix"; + +import { VoiceBroadcastInfoEventType, VoiceBroadcastInfoState } from "../../../src/voice-broadcast"; +import { mkEvent } from "../../test-utils"; + +export const mkVoiceBroadcastInfoStateEvent = ( + roomId: string, + state: VoiceBroadcastInfoState, + sender: string, +): MatrixEvent => { + return mkEvent({ + event: true, + room: roomId, + user: sender, + type: VoiceBroadcastInfoEventType, + skey: sender, + content: { + state, + }, + }); +};