diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 16036541f8..ebde627fde 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -2,7 +2,7 @@ ## Checklist -- [ ] Tests written for new code (and old code if feasible). -- [ ] New or updated `public`/`exported` symbols have accurate [TSDoc](https://tsdoc.org/) documentation. -- [ ] Linter and other CI checks pass. -- [ ] I have licensed the changes to Element by completing the [Contributor License Agreement (CLA)](https://cla-assistant.io/element-hq/element-web) +- [ ] Tests written for new code (and old code if feasible). +- [ ] New or updated `public`/`exported` symbols have accurate [TSDoc](https://tsdoc.org/) documentation. +- [ ] Linter and other CI checks pass. +- [ ] I have licensed the changes to Element by completing the [Contributor License Agreement (CLA)](https://cla-assistant.io/element-hq/element-web) diff --git a/.github/workflows/dockerhub.yaml b/.github/workflows/dockerhub.yaml index 7911cf794a..8dae6cf5ab 100644 --- a/.github/workflows/dockerhub.yaml +++ b/.github/workflows/dockerhub.yaml @@ -39,7 +39,7 @@ jobs: - name: Docker meta id: meta - uses: docker/metadata-action@8e5442c4ef9f78752691e2d8f8d19755c6f78e81 # v5 + uses: docker/metadata-action@369eb591f429131d6889c46b94e711f089e6ca96 # v5 with: images: | vectorim/element-web @@ -51,7 +51,7 @@ jobs: - name: Build and push id: build-and-push - uses: docker/build-push-action@4f58ea79222b3b9dc2c8bbdd6debcef730109a75 # v6 + uses: docker/build-push-action@48aba3b46d1b1fec4febb7c5d0c644b249a11355 # v6 with: context: . push: true diff --git a/.github/workflows/pull_request.yaml b/.github/workflows/pull_request.yaml index 2f97ccbbb4..d48fed1792 100644 --- a/.github/workflows/pull_request.yaml +++ b/.github/workflows/pull_request.yaml @@ -9,6 +9,6 @@ jobs: action: uses: matrix-org/matrix-js-sdk/.github/workflows/pull_request.yaml@develop permissions: - pull-requests: read + pull-requests: write secrets: ELEMENT_BOT_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }} diff --git a/.github/workflows/release_prepare.yml b/.github/workflows/release_prepare.yml index b655bb4206..031221041a 100644 --- a/.github/workflows/release_prepare.yml +++ b/.github/workflows/release_prepare.yml @@ -19,8 +19,23 @@ on: default: true permissions: {} # Uses ELEMENT_BOT_TOKEN instead jobs: + checks: + name: Sanity checks + strategy: + matrix: + repo: + - matrix-org/matrix-js-sdk + - element-hq/element-web + - element-hq/element-desktop + uses: matrix-org/matrix-js-sdk/.github/workflows/release-checks.yml@develop + secrets: + ELEMENT_BOT_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }} + with: + repository: ${{ matrix.repo }} + prepare: runs-on: ubuntu-24.04 + needs: checks env: # The order is specified bottom-up to avoid any races for allchange REPOS: matrix-js-sdk element-web element-desktop diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 14fd5ffd64..0c531f89b4 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -104,7 +104,7 @@ jobs: - name: Skip SonarCloud in merge queue if: github.event_name == 'merge_group' || inputs.disable_coverage == 'true' - uses: guibranco/github-status-action-v2@1f26a0237cd1a57626fbb5a0eb2494c9b8797d07 + uses: guibranco/github-status-action-v2@66088c44e212a906c32a047529a213d81809ec1c with: authToken: ${{ secrets.GITHUB_TOKEN }} state: success diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 49741b073c..fa887929fb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -20,26 +20,26 @@ Definitely don't use the GitHub default of "Update file.ts". As for your PR description, it should include these things: -- References to any bugs fixed by the change (in GitHub's `Fixes` notation) -- Describe the why and what is changing in the PR description so it's easy for - onlookers and reviewers to onboard and context switch. This information is - also helpful when we come back to look at this in 6 months and ask "why did - we do it like that?" we have a chance of finding out. - - Why didn't it work before? Why does it work now? What use cases does it - unlock? - - If you find yourself adding information on how the code works or why you - chose to do it the way you did, make sure this information is instead - written as comments in the code itself. - - Sometimes a PR can change considerably as it is developed. In this case, - the description should be updated to reflect the most recent state of - the PR. (It can be helpful to retain the old content under a suitable - heading, for additional context.) -- Include both **before** and **after** screenshots to easily compare and discuss - what's changing. -- Include a step-by-step testing strategy so that a reviewer can check out the - code locally and easily get to the point of testing your change. -- Add comments to the diff for the reviewer that might help them to understand - why the change is necessary or how they might better understand and review it. +- References to any bugs fixed by the change (in GitHub's `Fixes` notation) +- Describe the why and what is changing in the PR description so it's easy for + onlookers and reviewers to onboard and context switch. This information is + also helpful when we come back to look at this in 6 months and ask "why did + we do it like that?" we have a chance of finding out. + - Why didn't it work before? Why does it work now? What use cases does it + unlock? + - If you find yourself adding information on how the code works or why you + chose to do it the way you did, make sure this information is instead + written as comments in the code itself. + - Sometimes a PR can change considerably as it is developed. In this case, + the description should be updated to reflect the most recent state of + the PR. (It can be helpful to retain the old content under a suitable + heading, for additional context.) +- Include both **before** and **after** screenshots to easily compare and discuss + what's changing. +- Include a step-by-step testing strategy so that a reviewer can check out the + code locally and easily get to the point of testing your change. +- Add comments to the diff for the reviewer that might help them to understand + why the change is necessary or how they might better understand and review it. ### Changelogs @@ -79,8 +79,8 @@ element-web notes: Fix a bug where the 'Herd' button only worked on Tuesdays This example is for Element Web. You can specify: -- element-web -- element-desktop +- element-web +- element-desktop If your PR introduces a breaking change, use the `Notes` section in the same way, additionally adding the `X-Breaking-Change` label (see below). There's no need @@ -96,10 +96,10 @@ Notes: Remove legacy `Camelopard` class. `Giraffe` should be used instead. Other metadata can be added using labels. -- `X-Breaking-Change`: A breaking change - adding this label will mean the change causes a _major_ version bump. -- `T-Enhancement`: A new feature - adding this label will mean the change causes a _minor_ version bump. -- `T-Defect`: A bug fix (in either code or docs). -- `T-Task`: No user-facing changes, eg. code comments, CI fixes, refactors or tests. Won't have a changelog entry unless you specify one. +- `X-Breaking-Change`: A breaking change - adding this label will mean the change causes a _major_ version bump. +- `T-Enhancement`: A new feature - adding this label will mean the change causes a _minor_ version bump. +- `T-Defect`: A bug fix (in either code or docs). +- `T-Task`: No user-facing changes, eg. code comments, CI fixes, refactors or tests. Won't have a changelog entry unless you specify one. If you don't have permission to add labels, your PR reviewer(s) can work with you to add them: ask in the PR description or comments. diff --git a/README.md b/README.md index fa4ac89ff9..87e451c9ff 100644 --- a/README.md +++ b/README.md @@ -16,28 +16,28 @@ JS SDK](https://github.com/matrix-org/matrix-js-sdk). Element has several tiers of support for different environments: -- Supported - - Definition: - - Issues **actively triaged**, regressions **block** the release - - Last 2 major versions of Chrome, Firefox, and Edge on desktop OSes - - Last 2 versions of Safari - - Latest release of official Element Desktop app on desktop OSes - - Desktop OSes means macOS, Windows, and Linux versions for desktop devices - that are actively supported by the OS vendor and receive security updates -- Best effort - - Definition: - - Issues **accepted**, regressions **do not block** the release - - The wider Element Products(including Element Call and the Enterprise Server Suite) do still not officially support these browsers. - - The element web project and its contributors should keep the client functioning and gracefully degrade where other sibling features (E.g. Element Call) may not function. - - Last major release of Firefox ESR and Chrome/Edge Extended Stable -- Community Supported - - Definition: - - Issues **accepted**, regressions **do not block** the release - - Community contributions are welcome to support these issues - - Mobile web for current stable version of Chrome, Firefox, and Safari on Android, iOS, and iPadOS -- Not supported - - Definition: Issues only affecting unsupported environments are **closed** - - Everything else +- Supported + - Definition: + - Issues **actively triaged**, regressions **block** the release + - Last 2 major versions of Chrome, Firefox, and Edge on desktop OSes + - Last 2 versions of Safari + - Latest release of official Element Desktop app on desktop OSes + - Desktop OSes means macOS, Windows, and Linux versions for desktop devices + that are actively supported by the OS vendor and receive security updates +- Best effort + - Definition: + - Issues **accepted**, regressions **do not block** the release + - The wider Element Products(including Element Call and the Enterprise Server Suite) do still not officially support these browsers. + - The element web project and its contributors should keep the client functioning and gracefully degrade where other sibling features (E.g. Element Call) may not function. + - Last major release of Firefox ESR and Chrome/Edge Extended Stable +- Community Supported + - Definition: + - Issues **accepted**, regressions **do not block** the release + - Community contributions are welcome to support these issues + - Mobile web for current stable version of Chrome, Firefox, and Safari on Android, iOS, and iPadOS +- Not supported + - Definition: Issues only affecting unsupported environments are **closed** + - Everything else The period of support for these tiers should last until the releases specified above, plus 1 app release cycle(2 weeks). In the case of Firefox ESR this is extended further to allow it land in Debian Stable. @@ -74,16 +74,16 @@ situation, but it's still not good practice to do it in the first place. See Unless you have special requirements, you will want to add the following to your web server configuration when hosting Element Web: -- The `X-Frame-Options: SAMEORIGIN` header, to prevent Element Web from being - framed and protect from [clickjacking][owasp-clickjacking]. -- The `frame-ancestors 'self'` directive to your `Content-Security-Policy` - header, as the modern replacement for `X-Frame-Options` (though both should be - included since not all browsers support it yet, see - [this][owasp-clickjacking-csp]). -- The `X-Content-Type-Options: nosniff` header, to [disable MIME - sniffing][mime-sniffing]. -- The `X-XSS-Protection: 1; mode=block;` header, for basic XSS protection in - legacy browsers. +- The `X-Frame-Options: SAMEORIGIN` header, to prevent Element Web from being + framed and protect from [clickjacking][owasp-clickjacking]. +- The `frame-ancestors 'self'` directive to your `Content-Security-Policy` + header, as the modern replacement for `X-Frame-Options` (though both should be + included since not all browsers support it yet, see + [this][owasp-clickjacking-csp]). +- The `X-Content-Type-Options: nosniff` header, to [disable MIME + sniffing][mime-sniffing]. +- The `X-XSS-Protection: 1; mode=block;` header, for basic XSS protection in + legacy browsers. [mime-sniffing]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types#mime_sniffing [owasp-clickjacking-csp]: https://cheatsheetseries.owasp.org/cheatsheets/Clickjacking_Defense_Cheat_Sheet.html#content-security-policy-frame-ancestors-examples diff --git a/code_style.md b/code_style.md index e5f7485cec..9aa6836442 100644 --- a/code_style.md +++ b/code_style.md @@ -3,9 +3,9 @@ This code style applies to projects which the element-web team directly maintains or is reasonably adjacent to. As of writing, these are: -- element-desktop -- element-web -- matrix-js-sdk +- element-desktop +- element-web +- matrix-js-sdk Other projects might extend this code style for increased strictness. For example, matrix-events-sdk has stricter code organization to reduce the maintenance burden. These projects will declare their code diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index 23229bf00a..57b017bc1c 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -1,55 +1,55 @@ # Summary -- [Introduction](../README.md) +- [Introduction](../README.md) # Usage -- [Betas](betas.md) -- [Labs](labs.md) +- [Betas](betas.md) +- [Labs](labs.md) # Setup -- [Install](install.md) -- [Config](config.md) -- [Custom home page](custom-home.md) -- [Kubernetes](kubernetes.md) -- [Jitsi](jitsi.md) -- [Encryption](e2ee.md) +- [Install](install.md) +- [Config](config.md) +- [Custom home page](custom-home.md) +- [Kubernetes](kubernetes.md) +- [Jitsi](jitsi.md) +- [Encryption](e2ee.md) # Build -- [Customisations](customisations.md) -- [Modules](modules.md) -- [Native Node modules](native-node-modules.md) +- [Customisations](customisations.md) +- [Modules](modules.md) +- [Native Node modules](native-node-modules.md) # Contribution -- [Choosing an issue](choosing-an-issue.md) -- [Translation](translating.md) -- [Netlify builds](pr-previews.md) -- [Code review](review.md) +- [Choosing an issue](choosing-an-issue.md) +- [Translation](translating.md) +- [Netlify builds](pr-previews.md) +- [Code review](review.md) # Development -- [App load order](app-load.md) -- [Translation](translating-dev.md) -- [Theming](theming.md) -- [Playwright end to end tests](playwright.md) -- [Memory profiling](memory-profiles-and-leaks.md) -- [Jitsi](jitsi-dev.md) -- [Feature flags](feature-flags.md) -- [OIDC and delegated authentication](oidc.md) -- [Release Process](release.md) +- [App load order](app-load.md) +- [Translation](translating-dev.md) +- [Theming](theming.md) +- [Playwright end to end tests](playwright.md) +- [Memory profiling](memory-profiles-and-leaks.md) +- [Jitsi](jitsi-dev.md) +- [Feature flags](feature-flags.md) +- [OIDC and delegated authentication](oidc.md) +- [Release Process](release.md) # Deep dive -- [Skinning](skinning.md) -- [Cider editor](ciderEditor.md) -- [Iconography](icons.md) -- [Jitsi](jitsi.md) -- [Local echo](local-echo-dev.md) -- [Media](media-handling.md) -- [Room List Store](room-list-store.md) -- [Scrolling](scrolling.md) -- [Usercontent](usercontent.md) -- [Widget layouts](widget-layouts.md) +- [Skinning](skinning.md) +- [Cider editor](ciderEditor.md) +- [Iconography](icons.md) +- [Jitsi](jitsi.md) +- [Local echo](local-echo-dev.md) +- [Media](media-handling.md) +- [Room List Store](room-list-store.md) +- [Scrolling](scrolling.md) +- [Usercontent](usercontent.md) +- [Widget layouts](widget-layouts.md) diff --git a/docs/app-load.md b/docs/app-load.md index 849e95cb8d..7f72b3fea4 100644 --- a/docs/app-load.md +++ b/docs/app-load.md @@ -61,18 +61,18 @@ flowchart TD Key: -- Parallelogram: async/await task -- Box: sync task -- Diamond: conditional branch -- Circle: user interaction -- Blue arrow: async task is allowed to settle but allowed to fail -- Red arrow: async task success is asserted +- Parallelogram: async/await task +- Box: sync task +- Diamond: conditional branch +- Circle: user interaction +- Blue arrow: async task is allowed to settle but allowed to fail +- Red arrow: async task success is asserted Notes: -- A task begins when all its dependencies (arrows going into it) are fulfilled. -- The success of setting up rageshake is never asserted, element-web has a fallback path for running without IDB (and thus rageshake). -- Everything is awaited to be settled before the Modernizr check, to allow it to make use of things like i18n if they are successful. +- A task begins when all its dependencies (arrows going into it) are fulfilled. +- The success of setting up rageshake is never asserted, element-web has a fallback path for running without IDB (and thus rageshake). +- Everything is awaited to be settled before the Modernizr check, to allow it to make use of things like i18n if they are successful. Underlying dependencies: diff --git a/docs/choosing-an-issue.md b/docs/choosing-an-issue.md index 9d008782a1..ca17979367 100644 --- a/docs/choosing-an-issue.md +++ b/docs/choosing-an-issue.md @@ -32,19 +32,19 @@ someone to add something. When you're looking through the list, here are some things that might make an issue a **GOOD** choice: -- It is a problem or feature you care about. -- It concerns a type of code you know a little about. -- You think you can understand what's needed. -- It already has approval from Element Web's designers (look for comments from - members of the - [Product](https://github.com/orgs/element-hq/teams/product/members) or - [Design](https://github.com/orgs/element-hq/teams/design/members) teams). +- It is a problem or feature you care about. +- It concerns a type of code you know a little about. +- You think you can understand what's needed. +- It already has approval from Element Web's designers (look for comments from + members of the + [Product](https://github.com/orgs/element-hq/teams/product/members) or + [Design](https://github.com/orgs/element-hq/teams/design/members) teams). Here are some things that might make it a **BAD** choice: -- You don't understand it (maybe add a comment asking a clarifying question). -- It sounds difficult, or is part of a larger change you don't know about. -- **It is tagged with `X-Needs-Design` or `X-Needs-Product`.** +- You don't understand it (maybe add a comment asking a clarifying question). +- It sounds difficult, or is part of a larger change you don't know about. +- **It is tagged with `X-Needs-Design` or `X-Needs-Product`.** **Element Web's Design and Product teams tend to be very busy**, so if you make changes that require approval from one of those teams, you will probably have diff --git a/docs/config.md b/docs/config.md index cc40179740..8ca4ba4eb8 100644 --- a/docs/config.md +++ b/docs/config.md @@ -455,7 +455,7 @@ If you would like to use Scalar, the integration manager maintained by Element, For widgets in general (from an integration manager or not) there is also: -- `default_widget_container_height` +- `default_widget_container_height` This controls the height that the top widget panel initially appears as and is the height in pixels, default 280. @@ -551,38 +551,38 @@ preferences. Currently, the following UI feature flags are supported: -- `UIFeature.urlPreviews` - Whether URL previews are enabled across the entire application. -- `UIFeature.feedback` - Whether prompts to supply feedback are shown. -- `UIFeature.voip` - Whether or not VoIP is shown readily to the user. When disabled, - Jitsi widgets will still work though they cannot easily be added. -- `UIFeature.widgets` - Whether or not widgets will be shown. -- `UIFeature.advancedSettings` - Whether or not sections titled "advanced" in room and - user settings are shown to the user. -- `UIFeature.shareQrCode` - Whether or not the QR code on the share room/event dialog - is shown. -- `UIFeature.shareSocial` - Whether or not the social icons on the share room/event dialog - are shown. -- `UIFeature.identityServer` - Whether or not functionality requiring an identity server - is shown. When disabled, the user will not be able to interact with the identity - server (sharing email addresses, 3PID invites, etc). -- `UIFeature.thirdPartyId` - Whether or not UI relating to third party identifiers (3PIDs) - is shown. Typically this is considered "contact information" on the homeserver, and is - not directly related to the identity server. -- `UIFeature.registration` - Whether or not the registration page is accessible. Typically - useful if accounts are managed externally. -- `UIFeature.passwordReset` - Whether or not the password reset page is accessible. Typically - useful if accounts are managed externally. -- `UIFeature.deactivate` - Whether or not the deactivate account button is accessible. Typically - useful if accounts are managed externally. -- `UIFeature.advancedEncryption` - Whether or not advanced encryption options are shown to the - user. -- `UIFeature.roomHistorySettings` - Whether or not the room history settings are shown to the user. - This should only be used if the room history visibility options are managed by the server. -- `UIFeature.TimelineEnableRelativeDates` - Display relative date separators (eg: 'Today', 'Yesterday') in the - timeline for recent messages. When false day dates will be used. -- `UIFeature.BulkUnverifiedSessionsReminder` - Display popup reminders to verify or remove unverified sessions. Defaults - to true. -- `UIFeature.locationSharing` - Whether or not location sharing menus will be shown. +- `UIFeature.urlPreviews` - Whether URL previews are enabled across the entire application. +- `UIFeature.feedback` - Whether prompts to supply feedback are shown. +- `UIFeature.voip` - Whether or not VoIP is shown readily to the user. When disabled, + Jitsi widgets will still work though they cannot easily be added. +- `UIFeature.widgets` - Whether or not widgets will be shown. +- `UIFeature.advancedSettings` - Whether or not sections titled "advanced" in room and + user settings are shown to the user. +- `UIFeature.shareQrCode` - Whether or not the QR code on the share room/event dialog + is shown. +- `UIFeature.shareSocial` - Whether or not the social icons on the share room/event dialog + are shown. +- `UIFeature.identityServer` - Whether or not functionality requiring an identity server + is shown. When disabled, the user will not be able to interact with the identity + server (sharing email addresses, 3PID invites, etc). +- `UIFeature.thirdPartyId` - Whether or not UI relating to third party identifiers (3PIDs) + is shown. Typically this is considered "contact information" on the homeserver, and is + not directly related to the identity server. +- `UIFeature.registration` - Whether or not the registration page is accessible. Typically + useful if accounts are managed externally. +- `UIFeature.passwordReset` - Whether or not the password reset page is accessible. Typically + useful if accounts are managed externally. +- `UIFeature.deactivate` - Whether or not the deactivate account button is accessible. Typically + useful if accounts are managed externally. +- `UIFeature.advancedEncryption` - Whether or not advanced encryption options are shown to the + user. +- `UIFeature.roomHistorySettings` - Whether or not the room history settings are shown to the user. + This should only be used if the room history visibility options are managed by the server. +- `UIFeature.TimelineEnableRelativeDates` - Display relative date separators (eg: 'Today', 'Yesterday') in the + timeline for recent messages. When false day dates will be used. +- `UIFeature.BulkUnverifiedSessionsReminder` - Display popup reminders to verify or remove unverified sessions. Defaults + to true. +- `UIFeature.locationSharing` - Whether or not location sharing menus will be shown. ## Undocumented / developer options @@ -592,4 +592,3 @@ The following are undocumented or intended for developer use only. 2. `sync_timeline_limit` 3. `dangerously_allow_unsafe_and_insecure_passwords` 4. `latex_maths_delims`: An optional setting to override the default delimiters used for maths parsing. See https://github.com/matrix-org/matrix-react-sdk/pull/5939 for details. Only used when `feature_latex_maths` is enabled. -5. `voice_broadcast.chunk_length`: Target chunk length in seconds for the Voice Broadcast feature currently under development. diff --git a/docs/customisations.md b/docs/customisations.md index a6f72ab1ab..42cb8c7c5c 100644 --- a/docs/customisations.md +++ b/docs/customisations.md @@ -50,9 +50,9 @@ that properties/state machines won't change. UI for some actions can be hidden via the ComponentVisibility customisation: -- inviting users to rooms and spaces, -- creating rooms, -- creating spaces, +- inviting users to rooms and spaces, +- creating rooms, +- creating spaces, To customise visibility create a customisation module from [ComponentVisibility](https://github.com/element-hq/element-web/blob/master/src/customisations/ComponentVisibility.ts) following the instructions above. diff --git a/docs/e2ee.md b/docs/e2ee.md index 1229f55f38..835c38a1d5 100644 --- a/docs/e2ee.md +++ b/docs/e2ee.md @@ -31,9 +31,9 @@ Set the following on your homeserver's When `force_disable` is true: -- all rooms will be created with encryption disabled, and it will not be possible to enable - encryption from room settings. -- any `io.element.e2ee.default` value will be disregarded. +- all rooms will be created with encryption disabled, and it will not be possible to enable + encryption from room settings. +- any `io.element.e2ee.default` value will be disregarded. Note: If the server is configured to forcibly enable encryption for some or all rooms, this behaviour will be overridden. diff --git a/docs/feature-flags.md b/docs/feature-flags.md index 46e5f1243e..54d54e3b1b 100644 --- a/docs/feature-flags.md +++ b/docs/feature-flags.md @@ -5,10 +5,10 @@ flexibility and control over when and where those features are enabled. For example, flags make the following things possible: -- Extended testing of a feature via labs on develop -- Enabling features when ready instead of the first moment the code is released -- Testing a feature with a specific set of users (by enabling only on a specific - Element instance) +- Extended testing of a feature via labs on develop +- Enabling features when ready instead of the first moment the code is released +- Testing a feature with a specific set of users (by enabling only on a specific + Element instance) The size of the feature controlled by a feature flag may vary widely: it could be a large project like reactions or a smaller change to an existing algorithm. diff --git a/docs/features/composer.md b/docs/features/composer.md index 408c78a8d9..1af4c9c894 100644 --- a/docs/features/composer.md +++ b/docs/features/composer.md @@ -2,37 +2,37 @@ ## Auto Complete -- Hitting tab tries to auto-complete the word before the caret as a room member - - If no matching name is found, a visual bell is shown -- @ + a letter opens auto complete for members starting with the given letter - - When inserting a user pill at the start in the composer, a colon and space is appended to the pill - - When inserting a user pill anywhere else in composer, only a space is appended to the pill -- # + a letter opens auto complete for rooms starting with the given letter -- : open auto complete for emoji -- Pressing arrow-up/arrow-down while the autocomplete is open navigates between auto complete options -- Pressing tab while the autocomplete is open goes to the next autocomplete option, - wrapping around at the end after reverting to the typed text first. +- Hitting tab tries to auto-complete the word before the caret as a room member + - If no matching name is found, a visual bell is shown +- @ + a letter opens auto complete for members starting with the given letter + - When inserting a user pill at the start in the composer, a colon and space is appended to the pill + - When inserting a user pill anywhere else in composer, only a space is appended to the pill +- # + a letter opens auto complete for rooms starting with the given letter +- : open auto complete for emoji +- Pressing arrow-up/arrow-down while the autocomplete is open navigates between auto complete options +- Pressing tab while the autocomplete is open goes to the next autocomplete option, + wrapping around at the end after reverting to the typed text first. ## Formatting -- When selecting text, a formatting bar appears above the selection. -- The formatting bar allows to format the selected test as: - bold, italic, strikethrough, a block quote, and a code block (inline if no linebreak is selected). -- Formatting is applied as markdown syntax. -- Hitting ctrl/cmd+B also marks the selected text as bold -- Hitting ctrl/cmd+I also marks the selected text as italic -- Hitting ctrl/cmd+> also marks the selected text as a blockquote +- When selecting text, a formatting bar appears above the selection. +- The formatting bar allows to format the selected test as: + bold, italic, strikethrough, a block quote, and a code block (inline if no linebreak is selected). +- Formatting is applied as markdown syntax. +- Hitting ctrl/cmd+B also marks the selected text as bold +- Hitting ctrl/cmd+I also marks the selected text as italic +- Hitting ctrl/cmd+> also marks the selected text as a blockquote ## Misc -- When hitting the arrow-up button while having the caret at the start in the composer, - the last message sent by the syncing user is edited. -- Clicking a display name on an event in the timeline inserts a user pill into the composer -- Emoticons (like :-), >:-), :-/, ...) are replaced by emojis while typing if the relevant setting is enabled -- Typing in the composer sends typing notifications in the room -- Pressing ctrl/mod+z and ctrl/mod+y undoes/redoes modifications -- Pressing shift+enter inserts a line break -- Pressing enter sends the message. -- Choosing "Quote" in the context menu of an event inserts a quote of the event body in the composer. -- Choosing "Reply" in the context menu of an event shows a preview above the composer to reply to. -- Pressing alt+arrow up/arrow down navigates in previously sent messages, putting them in the composer. +- When hitting the arrow-up button while having the caret at the start in the composer, + the last message sent by the syncing user is edited. +- Clicking a display name on an event in the timeline inserts a user pill into the composer +- Emoticons (like :-), >:-), :-/, ...) are replaced by emojis while typing if the relevant setting is enabled +- Typing in the composer sends typing notifications in the room +- Pressing ctrl/mod+z and ctrl/mod+y undoes/redoes modifications +- Pressing shift+enter inserts a line break +- Pressing enter sends the message. +- Choosing "Quote" in the context menu of an event inserts a quote of the event body in the composer. +- Choosing "Reply" in the context menu of an event shows a preview above the composer to reply to. +- Pressing alt+arrow up/arrow down navigates in previously sent messages, putting them in the composer. diff --git a/docs/icons.md b/docs/icons.md index b0582356ce..449663e24a 100644 --- a/docs/icons.md +++ b/docs/icons.md @@ -8,9 +8,9 @@ Icons have `role="presentation"` and `aria-hidden` automatically applied. These SVG file recommendations: -- Colours should not be defined absolutely. Use `currentColor` instead. -- SVG files should be taken from the design compound as they are. Some icons contain special padding. - This means that there should be icons for each size, e.g. warning-16px and warning-32px. +- Colours should not be defined absolutely. Use `currentColor` instead. +- SVG files should be taken from the design compound as they are. Some icons contain special padding. + This means that there should be icons for each size, e.g. warning-16px and warning-32px. Example usage: diff --git a/docs/jitsi.md b/docs/jitsi.md index 48d1a7bf3e..20e64db379 100644 --- a/docs/jitsi.md +++ b/docs/jitsi.md @@ -81,27 +81,27 @@ which takes several parameters: _Query string_: -- `widgetId`: The ID of the widget. This is needed for communication back to the - react-sdk. -- `parentUrl`: The URL of the parent window. This is also needed for - communication back to the react-sdk. +- `widgetId`: The ID of the widget. This is needed for communication back to the + react-sdk. +- `parentUrl`: The URL of the parent window. This is also needed for + communication back to the react-sdk. _Hash/fragment (formatted as a query string)_: -- `conferenceDomain`: The domain to connect Jitsi Meet to. -- `conferenceId`: The room or conference ID to connect Jitsi Meet to. -- `isAudioOnly`: Boolean for whether this is a voice-only conference. May not - be present, should default to `false`. -- `startWithAudioMuted`: Boolean for whether the calls start with audio - muted. May not be present. -- `startWithVideoMuted`: Boolean for whether the calls start with video - muted. May not be present. -- `displayName`: The display name of the user viewing the widget. May not - be present or could be null. -- `avatarUrl`: The HTTP(S) URL for the avatar of the user viewing the widget. May - not be present or could be null. -- `userId`: The MXID of the user viewing the widget. May not be present or could - be null. +- `conferenceDomain`: The domain to connect Jitsi Meet to. +- `conferenceId`: The room or conference ID to connect Jitsi Meet to. +- `isAudioOnly`: Boolean for whether this is a voice-only conference. May not + be present, should default to `false`. +- `startWithAudioMuted`: Boolean for whether the calls start with audio + muted. May not be present. +- `startWithVideoMuted`: Boolean for whether the calls start with video + muted. May not be present. +- `displayName`: The display name of the user viewing the widget. May not + be present or could be null. +- `avatarUrl`: The HTTP(S) URL for the avatar of the user viewing the widget. May + not be present or could be null. +- `userId`: The MXID of the user viewing the widget. May not be present or could + be null. The react-sdk will assume that `jitsi.html` is at the path of wherever it is currently being served. For example, `https://develop.element.io/jitsi.html` or `vector://webapp/jitsi.html`. diff --git a/docs/playwright.md b/docs/playwright.md index 7eae8e783d..1454c4868f 100644 --- a/docs/playwright.md +++ b/docs/playwright.md @@ -2,10 +2,10 @@ ## Contents -- How to run the tests -- How the tests work -- How to write great Playwright tests -- Visual testing +- How to run the tests +- How the tests work +- How to write great Playwright tests +- Visual testing ## Running the Tests @@ -123,15 +123,15 @@ When a Synapse instance is started, it's given a config generated from one of th templates in `playwright/plugins/homeserver/synapse/templates`. There are a couple of special files in these templates: -- `homeserver.yaml`: - Template substitution happens in this file. Template variables are: - - `REGISTRATION_SECRET`: The secret used to register users via the REST API. - - `MACAROON_SECRET_KEY`: Generated each time for security - - `FORM_SECRET`: Generated each time for security - - `PUBLIC_BASEURL`: The localhost url + port combination the synapse is accessible at -- `localhost.signing.key`: A signing key is auto-generated and saved to this file. - Config templates should not contain a signing key and instead assume that one will exist - in this file. +- `homeserver.yaml`: + Template substitution happens in this file. Template variables are: + - `REGISTRATION_SECRET`: The secret used to register users via the REST API. + - `MACAROON_SECRET_KEY`: Generated each time for security + - `FORM_SECRET`: Generated each time for security + - `PUBLIC_BASEURL`: The localhost url + port combination the synapse is accessible at +- `localhost.signing.key`: A signing key is auto-generated and saved to this file. + Config templates should not contain a signing key and instead assume that one will exist + in this file. All other files in the template are copied recursively to `/data/`, so the file `foo.html` in a template can be referenced in the config as `/data/foo.html`. diff --git a/docs/release.md b/docs/release.md index 5074039374..b2c797b66b 100644 --- a/docs/release.md +++ b/docs/release.md @@ -82,28 +82,28 @@ This label will automagically convert to `X-Release-Blocker` at the conclusion o This release process revolves around our main repositories: -- [Element Desktop](https://github.com/element-hq/element-desktop/) -- [Element Web](https://github.com/element-hq/element-web/) -- [Matrix JS SDK](https://github.com/matrix-org/matrix-js-sdk/) +- [Element Desktop](https://github.com/element-hq/element-desktop/) +- [Element Web](https://github.com/element-hq/element-web/) +- [Matrix JS SDK](https://github.com/matrix-org/matrix-js-sdk/) We own other repositories, but they have more ad-hoc releases and are not part of the bi-weekly cycle: -- https://github.com/matrix-org/matrix-web-i18n/ -- https://github.com/matrix-org/matrix-react-sdk-module-api +- https://github.com/matrix-org/matrix-web-i18n/ +- https://github.com/matrix-org/matrix-react-sdk-module-api

Prerequisites

-- You must be part of the 2 Releasers GitHub groups: - - - - -- You will need access to the **VPN** ([docs](https://gitlab.matrix.org/new-vector/internal/-/wikis/SRE/Tailscale)) to be able to follow the instructions under Deploy below. -- You will need the ability to **SSH** in to the production machines to be able to follow the instructions under Deploy below. Ensure that your SSH key has a non-empty passphrase, and you registered your SSH key with Ops. Log a ticket at https://github.com/matrix-org/matrix-ansible-private and ask for: - - Two-factor authentication to be set up on your SSH key. (This is needed to get access to production). - - SSH access to `horme` (staging.element.io and app.element.io) - - Permission to sudo on horme as the user `element` -- You need "**jumphost**" configuration in your local `~/.ssh/config`. This should have been set up as part of your onboarding. +- You must be part of the 2 Releasers GitHub groups: + - + - +- You will need access to the **VPN** ([docs](https://gitlab.matrix.org/new-vector/internal/-/wikis/SRE/Tailscale)) to be able to follow the instructions under Deploy below. +- You will need the ability to **SSH** in to the production machines to be able to follow the instructions under Deploy below. Ensure that your SSH key has a non-empty passphrase, and you registered your SSH key with Ops. Log a ticket at https://github.com/matrix-org/matrix-ansible-private and ask for: + - Two-factor authentication to be set up on your SSH key. (This is needed to get access to production). + - SSH access to `horme` (staging.element.io and app.element.io) + - Permission to sudo on horme as the user `element` +- You need "**jumphost**" configuration in your local `~/.ssh/config`. This should have been set up as part of your onboarding.
@@ -177,7 +177,7 @@ For security, you may wish to merge the security advisory private fork or apply It is worth noting that at the end of the Final/Hotfix/Security release `staging` is merged to `master` which is merged back into `develop` - this means that any commit which goes to `staging` will eventually make its way back to the default branch. -- [ ] The staging branch is prepared +- [ ] The staging branch is prepared # Releasing @@ -192,21 +192,21 @@ switched back to the version of the dependency from the master branch to not lea ### Matrix JS SDK -- [ ] Check the draft release which has been generated by [the automation](https://github.com/matrix-org/matrix-js-sdk/actions/workflows/release-drafter.yml) -- [ ] Make any changes to the release notes in the draft release as are necessary - **Do not click publish, only save draft** -- [ ] Kick off a release using [the automation](https://github.com/matrix-org/matrix-js-sdk/actions/workflows/release.yml) - making sure to select the right type of release. For anything other than an RC: choose final. You should not need to ever switch off either of the Publishing options. +- [ ] Check the draft release which has been generated by [the automation](https://github.com/matrix-org/matrix-js-sdk/actions/workflows/release-drafter.yml) +- [ ] Make any changes to the release notes in the draft release as are necessary - **Do not click publish, only save draft** +- [ ] Kick off a release using [the automation](https://github.com/matrix-org/matrix-js-sdk/actions/workflows/release.yml) - making sure to select the right type of release. For anything other than an RC: choose final. You should not need to ever switch off either of the Publishing options. ### Element Web -- [ ] Check the draft release which has been generated by [the automation](https://github.com/element-hq/element-web/actions/workflows/release-drafter.yml) -- [ ] Make any changes to the release notes in the draft release as are necessary - **Do not click publish, only save draft** -- [ ] Kick off a release using [the automation](https://github.com/element-hq/element-web/actions/workflows/release.yml) - making sure to select the right type of release. For anything other than an RC: choose final. You should not need to ever switch off either of the Publishing options. +- [ ] Check the draft release which has been generated by [the automation](https://github.com/element-hq/element-web/actions/workflows/release-drafter.yml) +- [ ] Make any changes to the release notes in the draft release as are necessary - **Do not click publish, only save draft** +- [ ] Kick off a release using [the automation](https://github.com/element-hq/element-web/actions/workflows/release.yml) - making sure to select the right type of release. For anything other than an RC: choose final. You should not need to ever switch off either of the Publishing options. ### Element Desktop -- [ ] Check the draft release which has been generated by [the automation](https://github.com/element-hq/element-desktop/actions/workflows/release-drafter.yml) -- [ ] Make any changes to the release notes in the draft release as are necessary - **Do not click publish, only save draft** -- [ ] Kick off a release using [the automation](https://github.com/element-hq/element-desktop/actions/workflows/release.yml) - making sure to select the right type of release. For anything other than an RC: choose final. You should not need to ever switch off either of the Publishing options. +- [ ] Check the draft release which has been generated by [the automation](https://github.com/element-hq/element-desktop/actions/workflows/release-drafter.yml) +- [ ] Make any changes to the release notes in the draft release as are necessary - **Do not click publish, only save draft** +- [ ] Kick off a release using [the automation](https://github.com/element-hq/element-desktop/actions/workflows/release.yml) - making sure to select the right type of release. For anything other than an RC: choose final. You should not need to ever switch off either of the Publishing options. # Deploying @@ -214,23 +214,23 @@ We ship the SDKs to npm, this happens as part of the release process. We ship Element Web to dockerhub, `*.element.io`, and packages.element.io. We ship Element Desktop to packages.element.io. -- [ ] Check that element-web has shipped to dockerhub -- [ ] Deploy staging.element.io. [See docs.](https://handbook.element.io/books/element-web-team/page/deploying-appstagingelementio) -- [ ] Test staging.element.io +- [ ] Check that element-web has shipped to dockerhub +- [ ] Deploy staging.element.io. [See docs.](https://handbook.element.io/books/element-web-team/page/deploying-appstagingelementio) +- [ ] Test staging.element.io For final releases additionally do these steps: -- [ ] Deploy app.element.io. [See docs.](https://handbook.element.io/books/element-web-team/page/deploying-appstagingelementio) -- [ ] Test app.element.io -- [ ] Ensure Element Web package has shipped to packages.element.io -- [ ] Ensure Element Desktop packages have shipped to packages.element.io +- [ ] Deploy app.element.io. [See docs.](https://handbook.element.io/books/element-web-team/page/deploying-appstagingelementio) +- [ ] Test app.element.io +- [ ] Ensure Element Web package has shipped to packages.element.io +- [ ] Ensure Element Desktop packages have shipped to packages.element.io # Housekeeping We have some manual housekeeping to do in order to prepare for the next release. -- [ ] Update topics using [the automation](https://github.com/element-hq/element-web/actions/workflows/update-topics.yaml). It will autodetect the current latest version. Don't forget the date you supply should be e.g. September 5th (including the "th") for the script to work. -- [ ] Announce the release in [#element-web-announcements:matrix.org](https://matrix.to/#/#element-web-announcements:matrix.org) +- [ ] Update topics using [the automation](https://github.com/element-hq/element-web/actions/workflows/update-topics.yaml). It will autodetect the current latest version. Don't forget the date you supply should be e.g. September 5th (including the "th") for the script to work. +- [ ] Announce the release in [#element-web-announcements:matrix.org](https://matrix.to/#/#element-web-announcements:matrix.org)
(show) @@ -246,15 +246,15 @@ With wording like: For the first RC of a given release cycle do these steps: -- [ ] Go to the [matrix-js-sdk Renovate dashboard](https://github.com/matrix-org/matrix-js-sdk/issues/2406) and click the checkbox to create/update its PRs. +- [ ] Go to the [matrix-js-sdk Renovate dashboard](https://github.com/matrix-org/matrix-js-sdk/issues/2406) and click the checkbox to create/update its PRs. -- [ ] Go to the [element-web Renovate dashboard](https://github.com/element-hq/element-web/issues/22941) and click the checkbox to create/update its PRs. +- [ ] Go to the [element-web Renovate dashboard](https://github.com/element-hq/element-web/issues/22941) and click the checkbox to create/update its PRs. -- [ ] Go to the [element-desktop Renovate dashboard](https://github.com/element-hq/element-desktop/issues/465) and click the checkbox to create/update its PRs. +- [ ] Go to the [element-desktop Renovate dashboard](https://github.com/element-hq/element-desktop/issues/465) and click the checkbox to create/update its PRs. -- [ ] Later, check back and merge the PRs that succeeded to build. The ones that failed will get picked up by the [maintainer](https://docs.google.com/document/d/1V5VINWXATMpz9UBw4IKmVVB8aw3CxM0Jt7igtHnDfSk/edit#). +- [ ] Later, check back and merge the PRs that succeeded to build. The ones that failed will get picked up by the [maintainer](https://docs.google.com/document/d/1V5VINWXATMpz9UBw4IKmVVB8aw3CxM0Jt7igtHnDfSk/edit#). For final releases additionally do these steps: -- [ ] Archive done column on the [team board](https://github.com/orgs/element-hq/projects/67/views/34) _Note: this should be automated_ -- [ ] Add entry to the [milestones diary](https://docs.google.com/document/d/1cpRFJdfNCo2Ps6jqzQmatzbYEToSrQpyBug0aP_iwZE/edit#heading=h.6y55fw4t283z). The document says only to add significant releases, but we add all of them just in case. +- [ ] Archive done column on the [team board](https://github.com/orgs/element-hq/projects/67/views/34) _Note: this should be automated_ +- [ ] Add entry to the [milestones diary](https://docs.google.com/document/d/1cpRFJdfNCo2Ps6jqzQmatzbYEToSrQpyBug0aP_iwZE/edit#heading=h.6y55fw4t283z). The document says only to add significant releases, but we add all of them just in case. diff --git a/docs/review.md b/docs/review.md index 8f8dc5f09b..c565db5297 100644 --- a/docs/review.md +++ b/docs/review.md @@ -10,53 +10,53 @@ When reviewing code, here are some things we look for and also things we avoid: ### We review for -- Correctness -- Performance -- Accessibility -- Security -- Quality via automated and manual testing -- Comments and documentation where needed -- Sharing knowledge of different areas among the team -- Ensuring it's something we're comfortable maintaining for the long term -- Progress indicators and local echo where appropriate with network activity +- Correctness +- Performance +- Accessibility +- Security +- Quality via automated and manual testing +- Comments and documentation where needed +- Sharing knowledge of different areas among the team +- Ensuring it's something we're comfortable maintaining for the long term +- Progress indicators and local echo where appropriate with network activity ### We should avoid -- Style nits that are already handled by the linter -- Dramatically increasing scope +- Style nits that are already handled by the linter +- Dramatically increasing scope ### Good practices -- Use empathetic language - - See also [Mindful Communication in Code - Reviews](https://kickstarter.engineering/a-guide-to-mindful-communication-in-code-reviews-48aab5282e5e) - and [How to Do Code Reviews Like a Human](https://mtlynch.io/human-code-reviews-1/) -- Authors should prefer smaller commits for easier reviewing and bisection -- Reviewers should be explicit about required versus optional changes - - Reviews are conversations and the PR author should feel comfortable - discussing and pushing back on changes before making them -- Reviewers are encouraged to ask for tests where they believe it is reasonable -- Core team should lead by example through their tone and language -- Take the time to thank and point out good code changes -- Using softer language like "please" and "what do you think?" goes a long way - towards making others feel like colleagues working towards a common goal +- Use empathetic language + - See also [Mindful Communication in Code + Reviews](https://kickstarter.engineering/a-guide-to-mindful-communication-in-code-reviews-48aab5282e5e) + and [How to Do Code Reviews Like a Human](https://mtlynch.io/human-code-reviews-1/) +- Authors should prefer smaller commits for easier reviewing and bisection +- Reviewers should be explicit about required versus optional changes + - Reviews are conversations and the PR author should feel comfortable + discussing and pushing back on changes before making them +- Reviewers are encouraged to ask for tests where they believe it is reasonable +- Core team should lead by example through their tone and language +- Take the time to thank and point out good code changes +- Using softer language like "please" and "what do you think?" goes a long way + towards making others feel like colleagues working towards a common goal ### Workflow -- Authors should request review from the element-web team by default (if someone on - the team is clearly the expert in an area, a direct review request to them may - be more appropriate) -- Reviewers should remove the team review request and request review from - themselves when starting a review to avoid double review -- If there are multiple related PRs authors should reference each of the PRs in - the others before requesting review. Reviewers might start reviewing from - different places and could miss other required PRs. -- Avoid force pushing to a PR after the first round of review -- Use the GitHub default of merge commits when landing (avoid alternate options - like squash or rebase) -- PR author merges after review (assuming they have write access) -- Assign issues only when in progress to indicate to others what can be picked - up +- Authors should request review from the element-web team by default (if someone on + the team is clearly the expert in an area, a direct review request to them may + be more appropriate) +- Reviewers should remove the team review request and request review from + themselves when starting a review to avoid double review +- If there are multiple related PRs authors should reference each of the PRs in + the others before requesting review. Reviewers might start reviewing from + different places and could miss other required PRs. +- Avoid force pushing to a PR after the first round of review +- Use the GitHub default of merge commits when landing (avoid alternate options + like squash or rebase) +- PR author merges after review (assuming they have write access) +- Assign issues only when in progress to indicate to others what can be picked + up ## Code Quality @@ -64,10 +64,10 @@ In the past, we have occasionally written different kinds of tests for Element and the SDKs, but it hasn't been a consistent focus. Going forward, we'd like to change that. -- For new features, code reviewers will expect some form of automated testing to - be included by default -- For bug fixes, regression tests are of course great to have, but we don't want - to block fixes on this, so we won't require them at this time +- For new features, code reviewers will expect some form of automated testing to + be included by default +- For bug fixes, regression tests are of course great to have, but we don't want + to block fixes on this, so we won't require them at this time The above policy is not a strict rule, but instead it's meant to be a conversation between the author and reviewer. As an author, try to think about @@ -104,10 +104,10 @@ perspective. In more detail, our usual process for changes that affect the UI or alter user functionality is: -- For changes that will go live when merged, always flag Design and Product - teams as appropriate -- For changes guarded by a feature flag, Design and Product review is not - required (though may still be useful) since we can continue tweaking +- For changes that will go live when merged, always flag Design and Product + teams as appropriate +- For changes guarded by a feature flag, Design and Product review is not + required (though may still be useful) since we can continue tweaking As it can be difficult to review design work from looking at just the changed files in a PR, a [preview site](./pr-previews.md) that includes your changes diff --git a/docs/room-list-store.md b/docs/room-list-store.md index b87bf5f7bd..4e131ee309 100644 --- a/docs/room-list-store.md +++ b/docs/room-list-store.md @@ -6,11 +6,11 @@ It's so complicated it needs its own README. Legend: -- Orange = External event. -- Purple = Deterministic flow. -- Green = Algorithm definition. -- Red = Exit condition/point. -- Blue = Process definition. +- Orange = External event. +- Purple = Deterministic flow. +- Green = Algorithm definition. +- Red = Exit condition/point. +- Blue = Process definition. ## Algorithms involved @@ -68,14 +68,14 @@ simply get the manual sorting algorithm applied to them with no further involvem algorithm. There are 4 categories: Red, Grey, Bold, and Idle. Each has their own definition based off relative (perceived) importance to the user: -- **Red**: The room has unread mentions waiting for the user. -- **Grey**: The room has unread notifications waiting for the user. Notifications are simply unread - messages which cause a push notification or badge count. Typically, this is the default as rooms get - set to 'All Messages'. -- **Bold**: The room has unread messages waiting for the user. Essentially this is a grey room without - a badge/notification count (or 'Mentions Only'/'Muted'). -- **Idle**: No useful (see definition of useful above) activity has occurred in the room since the user - last read it. +- **Red**: The room has unread mentions waiting for the user. +- **Grey**: The room has unread notifications waiting for the user. Notifications are simply unread + messages which cause a push notification or badge count. Typically, this is the default as rooms get + set to 'All Messages'. +- **Bold**: The room has unread messages waiting for the user. Essentially this is a grey room without + a badge/notification count (or 'Mentions Only'/'Muted'). +- **Idle**: No useful (see definition of useful above) activity has occurred in the room since the user + last read it. Conveniently, each tag gets ordered by those categories as presented: red rooms appear above grey, grey above bold, etc. diff --git a/docs/settings.md b/docs/settings.md index 3f0636d380..e555cd7c1e 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -10,13 +10,13 @@ of dealing with the different levels and exposes easy to use getters and setters Granular Settings rely on a series of known levels in order to use the correct value for the scenario. These levels, in order of priority, are: -- `device` - The current user's device -- `room-device` - The current user's device, but only when in a specific room -- `room-account` - The current user's account, but only when in a specific room -- `account` - The current user's account -- `room` - A specific room (setting for all members of the room) -- `config` - Values are defined by the `setting_defaults` key (usually) in `config.json` -- `default` - The hardcoded default for the settings +- `device` - The current user's device +- `room-device` - The current user's device, but only when in a specific room +- `room-account` - The current user's account, but only when in a specific room +- `account` - The current user's account +- `room` - A specific room (setting for all members of the room) +- `config` - Values are defined by the `setting_defaults` key (usually) in `config.json` +- `default` - The hardcoded default for the settings Individual settings may control which levels are appropriate for them as part of the defaults. This is often to ensure that room administrators cannot force account-only settings upon participants. diff --git a/docs/translating-dev.md b/docs/translating-dev.md index e2a8e2c82a..fd1ac23294 100644 --- a/docs/translating-dev.md +++ b/docs/translating-dev.md @@ -2,9 +2,9 @@ ## Requirements -- A working [Development Setup](../README.md#setting-up-a-dev-environment) -- Latest LTS version of Node.js installed -- Be able to understand English +- A working [Development Setup](../README.md#setting-up-a-dev-environment) +- Latest LTS version of Node.js installed +- Be able to understand English ## Translating strings vs. marking strings for translation @@ -65,17 +65,17 @@ There you can also require all translations to be redone if the meaning of the s 1. Add it to the array in `_t` for example `_t(TKEY, {variable: this.variable})` 1. Add the variable inside the string. The syntax for variables is `%(variable)s`. Please note the _s_ at the end. The name of the variable has to match the previous used name. -- You can use the special `count` variable to choose between multiple versions of the same string, in order to get the correct pluralization. E.g. `_t('You have %(count)s new messages', { count: 2 })` would show 'You have 2 new messages', while `_t('You have %(count)s new messages', { count: 1 })` would show 'You have one new message' (assuming a singular version of the string has been added to the translation file. See above). Passing in `count` is much preferred over having an if-statement choose the correct string to use, because some languages have much more complicated plural rules than english (e.g. they might need a completely different form if there are three things rather than two). -- If you want to translate text that includes e.g. hyperlinks or other HTML you have to also use tag substitution, e.g. `_t('Click here!', {}, { 'a': (sub) => {sub} })`. If you don't do the tag substitution you will end up showing literally '' rather than making a hyperlink. -- You can also use React components with normal variable substitution if you want to insert HTML markup, e.g. `_t('Your email address is %(emailAddress)s', { emailAddress: {userEmailAddress} })`. +- You can use the special `count` variable to choose between multiple versions of the same string, in order to get the correct pluralization. E.g. `_t('You have %(count)s new messages', { count: 2 })` would show 'You have 2 new messages', while `_t('You have %(count)s new messages', { count: 1 })` would show 'You have one new message' (assuming a singular version of the string has been added to the translation file. See above). Passing in `count` is much preferred over having an if-statement choose the correct string to use, because some languages have much more complicated plural rules than english (e.g. they might need a completely different form if there are three things rather than two). +- If you want to translate text that includes e.g. hyperlinks or other HTML you have to also use tag substitution, e.g. `_t('Click here!', {}, { 'a': (sub) => {sub} })`. If you don't do the tag substitution you will end up showing literally '' rather than making a hyperlink. +- You can also use React components with normal variable substitution if you want to insert HTML markup, e.g. `_t('Your email address is %(emailAddress)s', { emailAddress: {userEmailAddress} })`. ## Things to know/Style Guides -- Do not use `_t()` inside `getDefaultProps`: the translations aren't loaded when `getDefaultProps` is called, leading to missing translations. Use `_td()` to indicate that `_t()` will be called on the string later. -- If using translated strings as constants, translated strings can't be in constants loaded at class-load time since the translations won't be loaded. Mark the strings using `_td()` instead and perform the actual translation later. -- If a string is presented in the UI with punctuation like a full stop, include this in the translation strings, since punctuation varies between languages too. -- Avoid "translation in parts", i.e. concatenating translated strings or using translated strings in variable substitutions. Context is important for translations, and translating partial strings this way is simply not always possible. -- Concatenating strings often also introduces an implicit assumption about word order (e.g. that the subject of the sentence comes first), which is incorrect for many languages. -- Translation 'smell test': If you have a string that does not begin with a capital letter (is not the start of a sentence) or it ends with e.g. ':' or a preposition (e.g. 'to') you should recheck that you are not trying to translate a partial sentence. -- If you have multiple strings, that are almost identical, except some part (e.g. a word or two) it is still better to translate the full sentence multiple times. It may seem like inefficient repetition, but unlike programming where you try to minimize repetition, translation is much faster if you have many, full, clear, sentences to work with, rather than fewer, but incomplete sentence fragments. -- Don't forget curly braces when you assign an expression to JSX attributes in the render method) +- Do not use `_t()` inside `getDefaultProps`: the translations aren't loaded when `getDefaultProps` is called, leading to missing translations. Use `_td()` to indicate that `_t()` will be called on the string later. +- If using translated strings as constants, translated strings can't be in constants loaded at class-load time since the translations won't be loaded. Mark the strings using `_td()` instead and perform the actual translation later. +- If a string is presented in the UI with punctuation like a full stop, include this in the translation strings, since punctuation varies between languages too. +- Avoid "translation in parts", i.e. concatenating translated strings or using translated strings in variable substitutions. Context is important for translations, and translating partial strings this way is simply not always possible. +- Concatenating strings often also introduces an implicit assumption about word order (e.g. that the subject of the sentence comes first), which is incorrect for many languages. +- Translation 'smell test': If you have a string that does not begin with a capital letter (is not the start of a sentence) or it ends with e.g. ':' or a preposition (e.g. 'to') you should recheck that you are not trying to translate a partial sentence. +- If you have multiple strings, that are almost identical, except some part (e.g. a word or two) it is still better to translate the full sentence multiple times. It may seem like inefficient repetition, but unlike programming where you try to minimize repetition, translation is much faster if you have many, full, clear, sentences to work with, rather than fewer, but incomplete sentence fragments. +- Don't forget curly braces when you assign an expression to JSX attributes in the render method) diff --git a/docs/translating.md b/docs/translating.md index 657b8cebbc..2b82453f93 100644 --- a/docs/translating.md +++ b/docs/translating.md @@ -2,9 +2,9 @@ ## Requirements -- Web Browser -- Be able to understand English -- Be able to understand the language you want to translate Element into +- Web Browser +- Be able to understand English +- Be able to understand the language you want to translate Element into ## Join #element-translations:matrix.org diff --git a/package.json b/package.json index 6e81ae1d81..6a4b055192 100644 --- a/package.json +++ b/package.json @@ -73,7 +73,7 @@ "resolutions": { "oidc-client-ts": "3.1.0", "jwt-decode": "4.0.0", - "caniuse-lite": "1.0.30001679", + "caniuse-lite": "1.0.30001684", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0", "wrap-ansi": "npm:wrap-ansi@^7.0.0" }, @@ -114,10 +114,10 @@ "jsrsasign": "^11.0.0", "jszip": "^3.7.0", "katex": "^0.16.0", - "linkify-element": "4.1.3", - "linkify-react": "4.1.3", - "linkify-string": "4.1.3", - "linkifyjs": "4.1.3", + "linkify-element": "4.1.4", + "linkify-react": "4.1.4", + "linkify-string": "4.1.4", + "linkifyjs": "4.1.4", "lodash": "^4.17.21", "maplibre-gl": "^4.0.0", "matrix-encrypt-attachment": "^1.0.3", @@ -268,11 +268,12 @@ "postcss-preset-env": "^10.0.0", "postcss-scss": "^4.0.4", "postcss-simple-vars": "^7.0.1", - "prettier": "3.3.3", + "prettier": "3.4.1", "process": "^0.11.10", "raw-loader": "^4.0.2", "rimraf": "^6.0.0", "semver": "^7.5.2", + "source-map-loader": "^5.0.0", "stylelint": "^16.1.0", "stylelint-config-standard": "^36.0.0", "stylelint-scss": "^6.0.0", diff --git a/playwright/Dockerfile b/playwright/Dockerfile index 9d478ff231..2b30c416f7 100644 --- a/playwright/Dockerfile +++ b/playwright/Dockerfile @@ -1,4 +1,4 @@ -FROM mcr.microsoft.com/playwright:v1.48.2-jammy +FROM mcr.microsoft.com/playwright:v1.49.0-jammy WORKDIR /work diff --git a/playwright/e2e/crypto/decryption-failure-messages.spec.ts b/playwright/e2e/crypto/decryption-failure-messages.spec.ts index ce7ca34d8e..b2a1209a70 100644 --- a/playwright/e2e/crypto/decryption-failure-messages.spec.ts +++ b/playwright/e2e/crypto/decryption-failure-messages.spec.ts @@ -67,6 +67,9 @@ test.describe("Cryptography", function () { await page.locator(".mx_AuthPage").getByRole("button", { name: "I'll verify later" }).click(); await app.viewRoomByName("Test room"); + // In this case, the call to cryptoApi.isEncryptionEnabledInRoom is taking a long time to resolve + await page.waitForTimeout(1000); + // There should be two historical events in the timeline const tiles = await page.locator(".mx_EventTile").all(); expect(tiles.length).toBeGreaterThanOrEqual(2); diff --git a/playwright/e2e/crypto/event-shields.spec.ts b/playwright/e2e/crypto/event-shields.spec.ts index b5d3790aaa..c6382f1d72 100644 --- a/playwright/e2e/crypto/event-shields.spec.ts +++ b/playwright/e2e/crypto/event-shields.spec.ts @@ -16,6 +16,7 @@ import { logOutOfElement, verify, } from "./utils"; +import { bootstrapCrossSigningForClient } from "../../pages/client.ts"; test.describe("Cryptography", function () { test.use({ @@ -307,5 +308,30 @@ test.describe("Cryptography", function () { const penultimate = page.locator(".mx_EventTile").filter({ hasText: "test encrypted from verified" }); await expect(penultimate.locator(".mx_EventTile_e2eIcon")).not.toBeVisible(); }); + + test("should show correct shields on events sent by users with changed identity", async ({ + page, + app, + bot: bob, + homeserver, + }) => { + // Verify Bob + await verify(app, bob); + + // Bob logs in a new device and resets cross-signing + const bobSecondDevice = await createSecondBotDevice(page, homeserver, bob); + await bootstrapCrossSigningForClient(await bobSecondDevice.prepareClient(), bob.credentials, true); + + /* should show an error for a message from a previously verified device */ + await bobSecondDevice.sendMessage(testRoomId, "test encrypted from user that was previously verified"); + const last = page.locator(".mx_EventTile_last"); + await expect(last).toContainText("test encrypted from user that was previously verified"); + const lastE2eIcon = last.locator(".mx_EventTile_e2eIcon"); + await expect(lastE2eIcon).toHaveClass(/mx_EventTile_e2eIcon_warning/); + await lastE2eIcon.focus(); + await expect(await app.getTooltipForElement(lastE2eIcon)).toContainText( + "Sender's verified identity has changed", + ); + }); }); }); diff --git a/playwright/e2e/read-receipts/readme.md b/playwright/e2e/read-receipts/readme.md index 4e4dce297f..33bcfeb93d 100644 --- a/playwright/e2e/read-receipts/readme.md +++ b/playwright/e2e/read-receipts/readme.md @@ -2,19 +2,19 @@ Tips for writing these tests: -- Break up your tests into the smallest test case possible. The purpose of - these tests is to understand hard-to-find bugs, so small tests are necessary. - We know that Playwright recommends combining tests together for performance, but - that will frustrate our goals here. (We will need to find a different way to - reduce CI time.) +- Break up your tests into the smallest test case possible. The purpose of + these tests is to understand hard-to-find bugs, so small tests are necessary. + We know that Playwright recommends combining tests together for performance, but + that will frustrate our goals here. (We will need to find a different way to + reduce CI time.) -- Try to assert something after every action, to make sure it has completed. - E.g.: - markAsRead(room2); - assertRead(room2); - You should especially follow this rule if you are jumping to a different - room or similar straight afterward. +- Try to assert something after every action, to make sure it has completed. + E.g.: + markAsRead(room2); + assertRead(room2); + You should especially follow this rule if you are jumping to a different + room or similar straight afterward. -- Use assertStillRead() if you are asserting something is read when it was - also read before. This waits a little while to make sure you're not getting a - false positive. +- Use assertStillRead() if you are asserting something is read when it was + also read before. This waits a little while to make sure you're not getting a + false positive. diff --git a/playwright/e2e/share-dialog/share-dialog.spec.ts b/playwright/e2e/share-dialog/share-dialog.spec.ts new file mode 100644 index 0000000000..2999b74ca0 --- /dev/null +++ b/playwright/e2e/share-dialog/share-dialog.spec.ts @@ -0,0 +1,67 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only + * Please see LICENSE files in the repository root for full details. + */ + +import { test, expect } from "../../element-web-test"; + +test.describe("Share dialog", () => { + test.use({ + displayName: "Alice", + room: async ({ app, user, bot }, use) => { + const roomId = await app.client.createRoom({ name: "Alice room" }); + await use({ roomId }); + }, + }); + + test("should share a room", async ({ page, app, room }) => { + await app.viewRoomById(room.roomId); + await app.toggleRoomInfoPanel(); + await page.getByRole("menuitem", { name: "Copy link" }).click(); + + const dialog = page.getByRole("dialog", { name: "Share room" }); + await expect(dialog.getByText(`https://matrix.to/#/${room.roomId}`)).toBeVisible(); + expect(dialog).toMatchScreenshot("share-dialog-room.png", { + // QRCode and url changes at every run + mask: [page.locator(".mx_QRCode"), page.locator(".mx_ShareDialog_top > span")], + }); + }); + + test("should share a room member", async ({ page, app, room, user }) => { + await app.viewRoomById(room.roomId); + await app.client.sendMessage(room.roomId, { body: "hello", msgtype: "m.text" }); + + const rightPanel = await app.toggleRoomInfoPanel(); + await rightPanel.getByRole("menuitem", { name: "People" }).click(); + await rightPanel.getByRole("button", { name: `${user.userId} (power 100)` }).click(); + await rightPanel.getByRole("button", { name: "Share profile" }).click(); + + const dialog = page.getByRole("dialog", { name: "Share User" }); + await expect(dialog.getByText(`https://matrix.to/#/${user.userId}`)).toBeVisible(); + expect(dialog).toMatchScreenshot("share-dialog-user.png", { + // QRCode changes at every run + mask: [page.locator(".mx_QRCode")], + }); + }); + + test("should share an event", async ({ page, app, room }) => { + await app.viewRoomById(room.roomId); + await app.client.sendMessage(room.roomId, { body: "hello", msgtype: "m.text" }); + + const timelineMessage = page.locator(".mx_MTextBody", { hasText: "hello" }); + await timelineMessage.hover(); + await page.getByRole("button", { name: "Options", exact: true }).click(); + await page.getByRole("menuitem", { name: "Share" }).click(); + + const dialog = page.getByRole("dialog", { name: "Share Room Message" }); + await expect(dialog.getByRole("checkbox", { name: "Link to selected message" })).toBeChecked(); + expect(dialog).toMatchScreenshot("share-dialog-event.png", { + // QRCode and url changes at every run + mask: [page.locator(".mx_QRCode"), page.locator(".mx_ShareDialog_top > span")], + }); + await dialog.getByRole("checkbox", { name: "Link to selected message" }).click(); + await expect(dialog.getByRole("checkbox", { name: "Link to selected message" })).not.toBeChecked(); + }); +}); diff --git a/playwright/plugins/homeserver/synapse/index.ts b/playwright/plugins/homeserver/synapse/index.ts index f11d94a703..2b386afb53 100644 --- a/playwright/plugins/homeserver/synapse/index.ts +++ b/playwright/plugins/homeserver/synapse/index.ts @@ -20,7 +20,7 @@ import { randB64Bytes } from "../../utils/rand"; // Docker tag to use for synapse docker image. // We target a specific digest as every now and then a Synapse update will break our CI. // This digest is updated by the playwright-image-updates.yaml workflow periodically. -const DOCKER_TAG = "develop@sha256:e163b15bf4905e4067dece856cca00e6ac8d1d655f4f1307978eee256b3ea775"; +const DOCKER_TAG = "develop@sha256:892793d00b70e9a92ceb929263fe734408ce7f50cb4436c65f07407048a6d4e7"; async function cfgDirFromTemplate(opts: StartHomeserverOpts): Promise> { const templateDir = path.join(__dirname, "templates", opts.template); diff --git a/playwright/plugins/oauth_server/README.md b/playwright/plugins/oauth_server/README.md index 541756384f..5260704a66 100644 --- a/playwright/plugins/oauth_server/README.md +++ b/playwright/plugins/oauth_server/README.md @@ -4,16 +4,16 @@ A very simple OAuth identity provider server. The following endpoints are exposed: -- `/oauth/auth.html`: An OAuth2 [authorization endpoint](https://openid.net/specs/openid-connect-core-1_0.html#AuthorizationEndpoint). - In a proper OAuth2 system, this would prompt the user to log in; we just give a big "Submit" button (and an - auth code that can be changed if we want the next step to fail). It redirects back to the calling application - with a "code". +- `/oauth/auth.html`: An OAuth2 [authorization endpoint](https://openid.net/specs/openid-connect-core-1_0.html#AuthorizationEndpoint). + In a proper OAuth2 system, this would prompt the user to log in; we just give a big "Submit" button (and an + auth code that can be changed if we want the next step to fail). It redirects back to the calling application + with a "code". -- `/oauth/token`: An OAuth2 [token endpoint](https://openid.net/specs/openid-connect-core-1_0.html#TokenEndpoint). - Receives the code issued by "auth.html" and, if it is valid, exchanges it for an OAuth2 access token. +- `/oauth/token`: An OAuth2 [token endpoint](https://openid.net/specs/openid-connect-core-1_0.html#TokenEndpoint). + Receives the code issued by "auth.html" and, if it is valid, exchanges it for an OAuth2 access token. -- `/oauth/userinfo`: An OAuth2 [userinfo endpoint](https://openid.net/specs/openid-connect-core-1_0.html#UserInfo). - Returns details about the owner of the offered access token. +- `/oauth/userinfo`: An OAuth2 [userinfo endpoint](https://openid.net/specs/openid-connect-core-1_0.html#UserInfo). + Returns details about the owner of the offered access token. To start the server, do: diff --git a/playwright/snapshots/share-dialog/share-dialog.spec.ts/share-dialog-event-linux.png b/playwright/snapshots/share-dialog/share-dialog.spec.ts/share-dialog-event-linux.png new file mode 100644 index 0000000000..2f703cfc8a Binary files /dev/null and b/playwright/snapshots/share-dialog/share-dialog.spec.ts/share-dialog-event-linux.png differ diff --git a/playwright/snapshots/share-dialog/share-dialog.spec.ts/share-dialog-room-linux.png b/playwright/snapshots/share-dialog/share-dialog.spec.ts/share-dialog-room-linux.png new file mode 100644 index 0000000000..725e095f6f Binary files /dev/null and b/playwright/snapshots/share-dialog/share-dialog.spec.ts/share-dialog-room-linux.png differ diff --git a/playwright/snapshots/share-dialog/share-dialog.spec.ts/share-dialog-user-linux.png b/playwright/snapshots/share-dialog/share-dialog.spec.ts/share-dialog-user-linux.png new file mode 100644 index 0000000000..98167cc7fb Binary files /dev/null and b/playwright/snapshots/share-dialog/share-dialog.spec.ts/share-dialog-user-linux.png differ diff --git a/res/css/_common.pcss b/res/css/_common.pcss index 15ba02b6b8..74328af39b 100644 --- a/res/css/_common.pcss +++ b/res/css/_common.pcss @@ -596,7 +596,7 @@ legend { .mx_Dialog button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not( .mx_UserProfileSettings button - ):not(.mx_ThemeChoicePanel_CustomTheme button):not(.mx_UnpinAllDialog button), + ):not(.mx_ThemeChoicePanel_CustomTheme button):not(.mx_UnpinAllDialog button):not(.mx_ShareDialog button), .mx_Dialog input[type="submit"], .mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton), .mx_Dialog_buttons input[type="submit"] { @@ -616,14 +616,16 @@ legend { .mx_Dialog button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not( .mx_UserProfileSettings button - ):not(.mx_ThemeChoicePanel_CustomTheme button):not(.mx_UnpinAllDialog button):last-child { + ):not(.mx_ThemeChoicePanel_CustomTheme button):not(.mx_UnpinAllDialog button):not( + .mx_ShareDialog button + ):last-child { margin-right: 0px; } .mx_Dialog button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not( .mx_UserProfileSettings button - ):not(.mx_ThemeChoicePanel_CustomTheme button):not(.mx_UnpinAllDialog button):focus, + ):not(.mx_ThemeChoicePanel_CustomTheme button):not(.mx_UnpinAllDialog button):not(.mx_ShareDialog button):focus, .mx_Dialog input[type="submit"]:focus, .mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):focus, .mx_Dialog_buttons input[type="submit"]:focus { @@ -635,7 +637,7 @@ legend { .mx_Dialog_buttons button.mx_Dialog_primary:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):not( .mx_UserProfileSettings button - ):not(.mx_ThemeChoicePanel_CustomTheme button):not(.mx_UnpinAllDialog button), + ):not(.mx_ThemeChoicePanel_CustomTheme button):not(.mx_UnpinAllDialog button):not(.mx_ShareDialog button), .mx_Dialog_buttons input[type="submit"].mx_Dialog_primary { color: var(--cpd-color-text-on-solid-primary); background-color: var(--cpd-color-bg-action-primary-rest); @@ -648,7 +650,7 @@ legend { .mx_Dialog_buttons button.danger:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):not(.mx_UserProfileSettings button):not( .mx_ThemeChoicePanel_CustomTheme button - ):not(.mx_UnpinAllDialog button), + ):not(.mx_UnpinAllDialog button):not(.mx_ShareDialog button), .mx_Dialog_buttons input[type="submit"].danger { background-color: var(--cpd-color-bg-critical-primary); border: solid 1px var(--cpd-color-bg-critical-primary); @@ -664,7 +666,7 @@ legend { .mx_Dialog button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not( .mx_UserProfileSettings button - ):not(.mx_ThemeChoicePanel_CustomTheme button):not(.mx_UnpinAllDialog button):disabled, + ):not(.mx_ThemeChoicePanel_CustomTheme button):not(.mx_UnpinAllDialog button):not(.mx_ShareDialog button):disabled, .mx_Dialog input[type="submit"]:disabled, .mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):disabled, .mx_Dialog_buttons input[type="submit"]:disabled { diff --git a/res/css/_components.pcss b/res/css/_components.pcss index 0fcdf6dee6..e9a53cd43c 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -393,9 +393,3 @@ @import "./views/voip/_LegacyCallViewHeader.pcss"; @import "./views/voip/_LegacyCallViewSidebar.pcss"; @import "./views/voip/_VideoFeed.pcss"; -@import "./voice-broadcast/atoms/_LiveBadge.pcss"; -@import "./voice-broadcast/atoms/_VoiceBroadcastControl.pcss"; -@import "./voice-broadcast/atoms/_VoiceBroadcastHeader.pcss"; -@import "./voice-broadcast/atoms/_VoiceBroadcastRecordingConnectionError.pcss"; -@import "./voice-broadcast/atoms/_VoiceBroadcastRoomSubtitle.pcss"; -@import "./voice-broadcast/molecules/_VoiceBroadcastBody.pcss"; diff --git a/res/css/structures/_UserMenu.pcss b/res/css/structures/_UserMenu.pcss index 741a4e90dc..d24a6e4ac7 100644 --- a/res/css/structures/_UserMenu.pcss +++ b/res/css/structures/_UserMenu.pcss @@ -22,20 +22,6 @@ Please see LICENSE files in the repository root for full details. pointer-events: none; /* makes the avatar non-draggable */ } } - - .mx_UserMenu_userAvatarLive { - align-items: center; - background-color: $alert; - border-radius: 6px; - color: $live-badge-color; - display: flex; - height: 12px; - justify-content: center; - left: 25px; - position: absolute; - top: 20px; - width: 12px; - } } .mx_UserMenu_contextMenuButton { diff --git a/res/css/views/dialogs/_ShareDialog.pcss b/res/css/views/dialogs/_ShareDialog.pcss index 086222af31..cfede43aae 100644 --- a/res/css/views/dialogs/_ShareDialog.pcss +++ b/res/css/views/dialogs/_ShareDialog.pcss @@ -5,50 +5,73 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only Please see LICENSE files in the repository root for full details. */ -.mx_ShareDialog hr { - margin-top: 25px; - margin-bottom: 25px; - border-color: $light-fg-color; -} +.mx_ShareDialog { + /* Value from figma design */ + width: 416px; -.mx_ShareDialog .mx_ShareDialog_content { - margin: 10px 0; + .mx_Dialog_header { + text-align: center; + margin-bottom: var(--cpd-space-6x); + /* Override dialog header padding to able to center it */ + padding-inline-end: 0; + } - .mx_CopyableText { - width: unset; /* full width */ + .mx_ShareDialog_content { + display: flex; + flex-direction: column; + gap: var(--cpd-space-6x); + align-items: center; - > a { - text-decoration: none; - flex-shrink: 1; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + .mx_ShareDialog_top { + display: flex; + flex-direction: column; + gap: var(--cpd-space-4x); + align-items: center; + width: 100%; + + span { + text-align: center; + font: var(--cpd-font-body-sm-semibold); + color: var(--cpd-color-text-secondary); + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + width: 100%; + } + } + + label { + display: inline-flex; + gap: var(--cpd-space-3x); + justify-content: center; + align-items: center; + font: var(--cpd-font-body-md-medium); + } + + button { + width: 100%; + } + + .mx_ShareDialog_social { + display: flex; + gap: var(--cpd-space-3x); + justify-content: center; + + a { + width: 48px; + height: 48px; + border-radius: 99px; + box-sizing: border-box; + border: 1px solid var(--cpd-color-border-interactive-secondary); + display: flex; + justify-content: center; + align-items: center; + + img { + width: 24px; + height: 24px; + } + } } } } - -.mx_ShareDialog_split { - display: flex; - flex-wrap: wrap; -} - -.mx_ShareDialog_qrcode_container { - float: left; - height: 256px; - width: 256px; - margin-right: 64px; -} - -.mx_ShareDialog_qrcode_container + .mx_ShareDialog_social_container { - width: 299px; -} - -.mx_ShareDialog_social_container { - display: inline-block; -} - -.mx_ShareDialog_social_icon { - display: inline-grid; - margin-right: 10px; - margin-bottom: 10px; -} diff --git a/res/css/views/rooms/_MessageComposer.pcss b/res/css/views/rooms/_MessageComposer.pcss index 3f11e9fa6c..73ac15c9c9 100644 --- a/res/css/views/rooms/_MessageComposer.pcss +++ b/res/css/views/rooms/_MessageComposer.pcss @@ -256,10 +256,6 @@ Please see LICENSE files in the repository root for full details. mask-image: url("@vector-im/compound-design-tokens/icons/mic-on-solid.svg"); } -.mx_MessageComposer_voiceBroadcast::before { - mask-image: url("$(res)/img/element-icons/live.svg"); -} - .mx_MessageComposer_plain_text::before { mask-image: url("$(res)/img/element-icons/room/composer/plain_text.svg"); } diff --git a/res/css/voice-broadcast/atoms/_LiveBadge.pcss b/res/css/voice-broadcast/atoms/_LiveBadge.pcss deleted file mode 100644 index 7d5f23819b..0000000000 --- a/res/css/voice-broadcast/atoms/_LiveBadge.pcss +++ /dev/null @@ -1,23 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only -Please see LICENSE files in the repository root for full details. -*/ - -.mx_LiveBadge { - align-items: center; - background-color: $alert; - border-radius: 2px; - color: $live-badge-color; - display: inline-flex; - font-size: $font-12px; - font-weight: var(--cpd-font-weight-semibold); - gap: $spacing-4; - padding: 2px 4px; -} - -.mx_LiveBadge--grey { - background-color: $quaternary-content; -} diff --git a/res/css/voice-broadcast/atoms/_VoiceBroadcastControl.pcss b/res/css/voice-broadcast/atoms/_VoiceBroadcastControl.pcss deleted file mode 100644 index 5bd7bfe098..0000000000 --- a/res/css/voice-broadcast/atoms/_VoiceBroadcastControl.pcss +++ /dev/null @@ -1,28 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only -Please see LICENSE files in the repository root for full details. -*/ - -.mx_VoiceBroadcastControl { - align-items: center; - background-color: $background; - border-radius: 50%; - color: $secondary-content; - display: flex; - flex: 0 0 32px; - height: 32px; - justify-content: center; - width: 32px; -} - -.mx_VoiceBroadcastControl-recording { - color: $alert; -} - -.mx_VoiceBroadcastControl-play .mx_Icon { - left: 1px; - position: relative; -} diff --git a/res/css/voice-broadcast/atoms/_VoiceBroadcastHeader.pcss b/res/css/voice-broadcast/atoms/_VoiceBroadcastHeader.pcss deleted file mode 100644 index c5e21233b7..0000000000 --- a/res/css/voice-broadcast/atoms/_VoiceBroadcastHeader.pcss +++ /dev/null @@ -1,60 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only -Please see LICENSE files in the repository root for full details. -*/ - -.mx_VoiceBroadcastHeader { - align-items: flex-start; - display: flex; - gap: $spacing-8; - line-height: 20px; - margin-bottom: $spacing-16; - min-width: 0; -} - -.mx_VoiceBroadcastHeader_content { - flex-grow: 1; - min-width: 0; -} - -.mx_VoiceBroadcastHeader_room_wrapper { - align-items: center; - display: flex; - gap: 4px; - justify-content: flex-start; -} - -.mx_VoiceBroadcastHeader_room { - font-size: $font-12px; - font-weight: var(--cpd-font-weight-semibold); - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.mx_VoiceBroadcastHeader_line { - align-items: center; - color: $secondary-content; - font-size: $font-12px; - display: flex; - gap: $spacing-4; - - .mx_Spinner { - flex: 0 0 14px; - padding: 1px; - } - - span { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } -} - -.mx_VoiceBroadcastHeader_mic--clickable { - cursor: pointer; -} diff --git a/res/css/voice-broadcast/atoms/_VoiceBroadcastRecordingConnectionError.pcss b/res/css/voice-broadcast/atoms/_VoiceBroadcastRecordingConnectionError.pcss deleted file mode 100644 index f21c0bb733..0000000000 --- a/res/css/voice-broadcast/atoms/_VoiceBroadcastRecordingConnectionError.pcss +++ /dev/null @@ -1,18 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2023 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only -Please see LICENSE files in the repository root for full details. -*/ - -.mx_VoiceBroadcastRecordingConnectionError { - align-items: center; - color: $alert; - display: flex; - gap: $spacing-12; - - svg path { - fill: $alert; - } -} diff --git a/res/css/voice-broadcast/atoms/_VoiceBroadcastRoomSubtitle.pcss b/res/css/voice-broadcast/atoms/_VoiceBroadcastRoomSubtitle.pcss deleted file mode 100644 index e0748e7626..0000000000 --- a/res/css/voice-broadcast/atoms/_VoiceBroadcastRoomSubtitle.pcss +++ /dev/null @@ -1,14 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only -Please see LICENSE files in the repository root for full details. -*/ - -.mx_RoomTile .mx_RoomTile_titleContainer .mx_RoomTile_subtitle.mx_RoomTile_subtitle--voice-broadcast { - align-items: center; - color: $alert; - display: flex; - gap: $spacing-4; -} diff --git a/res/css/voice-broadcast/molecules/_VoiceBroadcastBody.pcss b/res/css/voice-broadcast/molecules/_VoiceBroadcastBody.pcss deleted file mode 100644 index 45ed0e98f9..0000000000 --- a/res/css/voice-broadcast/molecules/_VoiceBroadcastBody.pcss +++ /dev/null @@ -1,75 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only -Please see LICENSE files in the repository root for full details. -*/ - -.mx_VoiceBroadcastBody { - background-color: $quinary-content; - border-radius: 8px; - color: $secondary-content; - display: inline-block; - font-size: $font-12px; - padding: $spacing-12; - width: 271px; - - .mx_Clock { - line-height: 1; - } -} - -.mx_VoiceBroadcastBody--pip { - background-color: $system; - box-shadow: 0 2px 8px 0 #0000004a; -} - -.mx_VoiceBroadcastBody--small { - display: flex; - gap: $spacing-8; - width: 192px; - - .mx_VoiceBroadcastHeader { - margin-bottom: 0; - } - - .mx_VoiceBroadcastControl { - align-self: center; - } - - .mx_LiveBadge { - margin-top: 4px; - } -} - -.mx_VoiceBroadcastBody_divider { - background-color: $quinary-content; - border: 0; - height: 1px; - margin: $spacing-12 0; -} - -.mx_VoiceBroadcastBody_controls { - align-items: center; - display: flex; - gap: $spacing-32; - justify-content: center; - margin-bottom: $spacing-8; -} - -.mx_VoiceBroadcastBody_timerow { - display: flex; - justify-content: space-between; -} - -.mx_AccessibleButton.mx_VoiceBroadcastBody_blockButton { - display: flex; - gap: $spacing-8; -} - -.mx_VoiceBroadcastBody__small-close { - right: 8px; - position: absolute; - top: 8px; -} diff --git a/res/themes/dark/css/_dark.pcss b/res/themes/dark/css/_dark.pcss index 8b0673f692..2d3ea2e4f4 100644 --- a/res/themes/dark/css/_dark.pcss +++ b/res/themes/dark/css/_dark.pcss @@ -240,11 +240,6 @@ $location-live-secondary-color: #deddfd; } /* ******************** */ -/* Voice Broadcast */ -/* ******************** */ -$live-badge-color: #ffffff; -/* ******************** */ - /* One-off colors */ /* ******************** */ $progressbar-bg-color: var(--cpd-color-gray-200); diff --git a/res/themes/legacy-dark/css/_legacy-dark.pcss b/res/themes/legacy-dark/css/_legacy-dark.pcss index 45bb1870f1..ea5228b6c7 100644 --- a/res/themes/legacy-dark/css/_legacy-dark.pcss +++ b/res/themes/legacy-dark/css/_legacy-dark.pcss @@ -226,11 +226,6 @@ $location-live-color: #5c56f5; $location-live-secondary-color: #deddfd; /* ******************** */ -/* Voice Broadcast */ -/* ******************** */ -$live-badge-color: #ffffff; -/* ******************** */ - body { color-scheme: dark; } diff --git a/res/themes/legacy-light/css/_legacy-light.pcss b/res/themes/legacy-light/css/_legacy-light.pcss index 76e0eec588..32ca7d3d1a 100644 --- a/res/themes/legacy-light/css/_legacy-light.pcss +++ b/res/themes/legacy-light/css/_legacy-light.pcss @@ -325,11 +325,6 @@ $location-live-color: #5c56f5; $location-live-secondary-color: #deddfd; /* ******************** */ -/* Voice Broadcast */ -/* ******************** */ -$live-badge-color: #ffffff; -/* ******************** */ - body { color-scheme: light; } diff --git a/res/themes/light/css/_light.pcss b/res/themes/light/css/_light.pcss index 5f278c6f16..1a1705a9c1 100644 --- a/res/themes/light/css/_light.pcss +++ b/res/themes/light/css/_light.pcss @@ -10,8 +10,8 @@ /* Noto Color Emoji contains digits, in fixed-width, therefore causing digits in flowed text to stand out. TODO: Consider putting all emoji fonts to the end rather than the front. */ -$font-family: "Inter", var(--emoji-font-family), "Apple Color Emoji", "Segoe UI Emoji", "Arial", "Helvetica", sans-serif, - "Noto Color Emoji"; +$font-family: "Inter", var(--emoji-font-family), "Apple Color Emoji", "Segoe UI Emoji", "Arial", "Helvetica", + sans-serif, "Noto Color Emoji"; $monospace-font-family: "Inconsolata", var(--emoji-font-family), "Apple Color Emoji", "Segoe UI Emoji", "Courier", monospace, "Noto Color Emoji"; @@ -355,11 +355,6 @@ $location-live-color: var(--cpd-color-purple-900); $location-live-secondary-color: var(--cpd-color-purple-600); /* ******************** */ -/* Voice Broadcast */ -/* ******************** */ -$live-badge-color: var(--cpd-color-icon-on-solid-primary); -/* ******************** */ - body { color-scheme: light; } diff --git a/scripts/copy-i18n.py b/scripts/copy-i18n.py deleted file mode 100755 index 07b1271239..0000000000 --- a/scripts/copy-i18n.py +++ /dev/null @@ -1,47 +0,0 @@ -#!/usr/bin/env python - -import json -import sys -import os - -if len(sys.argv) < 3: - print "Usage: %s " % (sys.argv[0],) - print "eg. %s pt_BR.json pt.json" % (sys.argv[0],) - print - print "Adds any translations to that exist in but not " - sys.exit(1) - -srcpath = sys.argv[1] -dstpath = sys.argv[2] -tmppath = dstpath + ".tmp" - -with open(srcpath) as f: - src = json.load(f) - -with open(dstpath) as f: - dst = json.load(f) - -toAdd = {} -for k,v in src.iteritems(): - if k not in dst: - print "Adding %s" % (k,) - toAdd[k] = v - -# don't just json.dumps as we'll probably re-order all the keys (and they're -# not in any given order so we can't just sort_keys). Append them to the end. -with open(dstpath) as ifp: - with open(tmppath, 'w') as ofp: - for line in ifp: - strippedline = line.strip() - if strippedline in ('{', '}'): - ofp.write(line) - elif strippedline.endswith(','): - ofp.write(line) - else: - ofp.write(' '+strippedline+',') - toAddStr = json.dumps(toAdd, indent=4, separators=(',', ': '), ensure_ascii=False, encoding="utf8").strip("{}\n") - ofp.write("\n") - ofp.write(toAddStr.encode('utf8')) - ofp.write("\n") - -os.rename(tmppath, dstpath) diff --git a/scripts/fetch-develop.deps.sh b/scripts/fetch-develop.deps.sh deleted file mode 100755 index 5814b43ff7..0000000000 --- a/scripts/fetch-develop.deps.sh +++ /dev/null @@ -1,84 +0,0 @@ -#!/usr/bin/env bash - -# Fetches the js-sdk dependency for development or testing purposes -# If there exists a branch of that dependency with the same name as -# the branch the current checkout is on, use that branch. Otherwise, -# use develop. - -set -x - -GIT_CLONE_ARGS=("$@") -[ -z "$defbranch" ] && defbranch="develop" - -# clone a specific branch of a github repo -function clone() { - org=$1 - repo=$2 - branch=$3 - - # Chop 'origin' off the start as jenkins ends up using - # branches on the origin, but this doesn't work if we - # specify the branch when cloning. - branch=${branch#origin/} - - if [ -n "$branch" ] - then - echo "Trying to use $org/$repo#$branch" - # Disable auth prompts: https://serverfault.com/a/665959 - GIT_TERMINAL_PROMPT=0 git clone https://github.com/$org/$repo.git $repo --branch $branch \ - "${GIT_CLONE_ARGS[@]}" - return $? - fi - return 1 -} - -function dodep() { - deforg=$1 - defrepo=$2 - rm -rf $defrepo - - # Try the PR author's branch in case it exists on the deps as well. - # Try the target branch of the push or PR. - # Use the default branch as the last resort. - if [[ "$BUILDKITE" == true ]]; then - # If BUILDKITE_BRANCH is set, it will contain either: - # * "branch" when the author's branch and target branch are in the same repo - # * "author:branch" when the author's branch is in their fork - # We can split on `:` into an array to check. - BUILDKITE_BRANCH_ARRAY=(${BUILDKITE_BRANCH//:/ }) - if [[ "${#BUILDKITE_BRANCH_ARRAY[@]}" == "2" ]]; then - prAuthor=${BUILDKITE_BRANCH_ARRAY[0]} - prBranch=${BUILDKITE_BRANCH_ARRAY[1]} - else - prAuthor=$deforg - prBranch=$BUILDKITE_BRANCH - fi - clone $prAuthor $defrepo $prBranch || - clone $deforg $defrepo $BUILDKITE_PULL_REQUEST_BASE_BRANCH || - clone $deforg $defrepo $defbranch || - return $? - else - clone $deforg $defrepo $ghprbSourceBranch || - clone $deforg $defrepo $GIT_BRANCH || - clone $deforg $defrepo `git rev-parse --abbrev-ref HEAD` || - clone $deforg $defrepo $defbranch || - return $? - fi - - echo "$defrepo set to branch "`git -C "$defrepo" rev-parse --abbrev-ref HEAD` -} - -############################## - -echo 'Setting up matrix-js-sdk' - -dodep matrix-org matrix-js-sdk - -pushd matrix-js-sdk -yarn link -yarn install --frozen-lockfile -popd - -yarn link matrix-js-sdk - -############################## diff --git a/scripts/genflags.sh b/scripts/genflags.sh deleted file mode 100755 index aa882a99b4..0000000000 --- a/scripts/genflags.sh +++ /dev/null @@ -1,64 +0,0 @@ -# Copyright 2017-2024 New Vector Ltd. - -# SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only -# Please see LICENSE in the repository root for full details. - - -# genflags.sh - Generates pngs for use with CountryDropdown.js -# -# Dependencies: -# - imagemagick --with-rsvg (because default imagemagick SVG -# renderer does not produce accurate results) -# -# on macOS, this is most easily done with: -# brew install imagemagick --with-librsvg -# -# This will clone the googlei18n flag repo before converting -# all phonenumber.js-supported country flags (as SVGs) into -# PNGs that can be used by CountryDropdown.js. - -set -e - -# Allow CTRL+C to terminate the script -trap "echo Exited!; exit;" SIGINT SIGTERM - -# git clone the google repo to get flag SVGs -git clone git@github.com:googlei18n/region-flags -for f in region-flags/svg/*.svg; do - # Skip state flags - if [[ $f =~ [A-Z]{2}-[A-Z]{2,3}.svg ]] ; then - echo "Skipping state flag "$f - continue - fi - - # Skip countries not included in phonenumber.js - if [[ $f =~ (AC|CP|DG|EA|EU|IC|TA|UM|UN|XK).svg ]] ; then - echo "Skipping non-phonenumber supported flag "$f - continue - fi - - # Run imagemagick convert - # -background none : transparent background - # -resize 50x30 : resize the flag to have a height of 15px (2x) - # By default, aspect ratio is respected so the width will - # be correct and not necessarily 25px. - # -filter Lanczos : use sharper resampling to avoid muddiness - # -gravity Center : keep the image central when adding an -extent - # -border 1 : add a 1px border around the flag - # -bordercolor : set the border colour - # -extent 54x54 : surround the image with padding so that it - # has the dimensions 27x27px (2x). - convert $f -background none -filter Lanczos -resize 50x30 \ - -gravity Center -border 1 -bordercolor \#e0e0e0 \ - -extent 54x54 $f.png - - # $f.png will be region-flags/svg/XX.svg.png at this point - - # Extract filename from path $f - newname=${f##*/} - # Replace .svg with .png - newname=${newname%.svg}.png - # Move the file to flags directory - mv $f.png ../res/flags/$newname - echo "Generated res/flags/"$newname -done diff --git a/src/@types/matrix-js-sdk.d.ts b/src/@types/matrix-js-sdk.d.ts index 73366f2fee..41ccfcbb3b 100644 --- a/src/@types/matrix-js-sdk.d.ts +++ b/src/@types/matrix-js-sdk.d.ts @@ -10,7 +10,6 @@ import type { IWidget } from "matrix-widget-api"; import type { BLURHASH_FIELD } from "../utils/image-media"; import type { JitsiCallMemberEventType, JitsiCallMemberContent } from "../call-types"; import type { ILayoutStateEvent, WIDGET_LAYOUT_EVENT_TYPE } from "../stores/widgets/types"; -import type { VoiceBroadcastInfoEventContent, VoiceBroadcastInfoEventType } from "../voice-broadcast/types"; import type { EncryptedFile } from "matrix-js-sdk/src/types"; // Extend Matrix JS SDK types via Typescript declaration merging to support unspecced event fields and types @@ -37,9 +36,6 @@ declare module "matrix-js-sdk/src/types" { "im.vector.modular.widgets": IWidget | {}; [WIDGET_LAYOUT_EVENT_TYPE]: ILayoutStateEvent; - // Unstable voice broadcast state events - [VoiceBroadcastInfoEventType]: VoiceBroadcastInfoEventContent; - // Element custom state events "im.vector.web.settings": Record; "org.matrix.room.preview_urls": { disable: boolean }; @@ -78,7 +74,5 @@ declare module "matrix-js-sdk/src/types" { waveform?: number[]; }; "org.matrix.msc3245.voice"?: {}; - - "io.element.voice_broadcast_chunk"?: { sequence: number }; } } diff --git a/src/IConfigOptions.ts b/src/IConfigOptions.ts index 72bee5d0ab..5dd500402d 100644 --- a/src/IConfigOptions.ts +++ b/src/IConfigOptions.ts @@ -175,13 +175,6 @@ export interface IConfigOptions { sync_timeline_limit?: number; dangerously_allow_unsafe_and_insecure_passwords?: boolean; // developer option - voice_broadcast?: { - // length per voice chunk in seconds - chunk_length?: number; - // max voice broadcast length in seconds - max_length?: number; - }; - user_notice?: { title: string; description: string; diff --git a/src/LegacyCallHandler.tsx b/src/LegacyCallHandler.tsx index a06480e9cd..b804ca0084 100644 --- a/src/LegacyCallHandler.tsx +++ b/src/LegacyCallHandler.tsx @@ -55,8 +55,6 @@ import { OpenInviteDialogPayload } from "./dispatcher/payloads/OpenInviteDialogP import { findDMForUser } from "./utils/dm/findDMForUser"; import { getJoinedNonFunctionalMembers } from "./utils/room/getJoinedNonFunctionalMembers"; import { localNotificationsAreSilenced } from "./utils/notifications"; -import { SdkContextClass } from "./contexts/SDKContext"; -import { showCantStartACallDialog } from "./voice-broadcast/utils/showCantStartACallDialog"; import { isNotNull } from "./Typeguards"; import { BackgroundAudio } from "./audio/BackgroundAudio"; import { Jitsi } from "./widgets/Jitsi.ts"; @@ -859,15 +857,6 @@ export default class LegacyCallHandler extends EventEmitter { return; } - // Pause current broadcast, if any - SdkContextClass.instance.voiceBroadcastPlaybacksStore.getCurrent()?.pause(); - - if (SdkContextClass.instance.voiceBroadcastRecordingsStore.getCurrent()) { - // Do not start a call, if recording a broadcast - showCantStartACallDialog(); - return; - } - // We might be using managed hybrid widgets if (isManagedHybridWidgetEnabled(room)) { await addManagedHybridWidget(room); diff --git a/src/MatrixClientPeg.ts b/src/MatrixClientPeg.ts index 9804ab5d82..ce87953118 100644 --- a/src/MatrixClientPeg.ts +++ b/src/MatrixClientPeg.ts @@ -35,13 +35,11 @@ import IdentityAuthClient from "./IdentityAuthClient"; import { crossSigningCallbacks } from "./SecurityManager"; import { SlidingSyncManager } from "./SlidingSyncManager"; import { _t, UserFriendlyError } from "./languageHandler"; -import { SettingLevel } from "./settings/SettingLevel"; import MatrixClientBackedController from "./settings/controllers/MatrixClientBackedController"; import ErrorDialog from "./components/views/dialogs/ErrorDialog"; import PlatformPeg from "./PlatformPeg"; import { formatList } from "./utils/FormattingUtils"; import SdkConfig from "./SdkConfig"; -import { Features } from "./settings/Settings"; import { setDeviceIsolationMode } from "./settings/controllers/DeviceIsolationModeController.ts"; export interface IMatrixClientCreds { @@ -333,11 +331,6 @@ class MatrixClientPegClass implements IMatrixClientPeg { logger.error("Warning! Not using an encryption key for rust crypto store."); } - // Record the fact that we used the Rust crypto stack with this client. This just guards against people - // rolling back to versions of EW that did not default to Rust crypto (which would lead to an error, since - // we cannot migrate from Rust to Legacy crypto). - await SettingsStore.setValue(Features.RustCrypto, null, SettingLevel.DEVICE, true); - await this.matrixClient.initRustCrypto({ storageKey: rustCryptoStoreKey, storagePassword: rustCryptoStorePassword, diff --git a/src/Notifier.ts b/src/Notifier.ts index 961d2171a8..45e6a1195d 100644 --- a/src/Notifier.ts +++ b/src/Notifier.ts @@ -49,8 +49,6 @@ import { SdkContextClass } from "./contexts/SDKContext"; import { localNotificationsAreSilenced, createLocalNotificationSettingsIfNeeded } from "./utils/notifications"; import { getIncomingCallToastKey, IncomingCallToast } from "./toasts/IncomingCallToast"; import ToastStore from "./stores/ToastStore"; -import { VoiceBroadcastChunkEventType, VoiceBroadcastInfoEventType } from "./voice-broadcast"; -import { getSenderName } from "./utils/event/getSenderName"; import { stripPlainReply } from "./utils/Reply"; import { BackgroundAudio } from "./audio/BackgroundAudio"; @@ -81,17 +79,6 @@ const msgTypeHandlers: Record string | null> = { return TextForEvent.textForLocationEvent(event)(); }, [MsgType.Audio]: (event: MatrixEvent): string | null => { - if (event.getContent()?.[VoiceBroadcastChunkEventType]) { - if (event.getContent()?.[VoiceBroadcastChunkEventType]?.sequence === 1) { - // Show a notification for the first broadcast chunk. - // At this point a user received something to listen to. - return _t("notifier|io.element.voice_broadcast_chunk", { senderName: getSenderName(event) }); - } - - // Mute other broadcast chunks - return null; - } - return TextForEvent.textForEvent(event, MatrixClientPeg.safeGet()); }, }; @@ -460,8 +447,6 @@ class NotifierClass extends TypedEventEmitter = { logo: require("../res/img/element-desktop-logo.svg").default, url: "https://element.io/get-started", }, - voice_broadcast: { - chunk_length: 2 * 60, // two minutes - max_length: 4 * 60 * 60, // four hours - }, feedback: { existing_issues_url: diff --git a/src/TextForEvent.tsx b/src/TextForEvent.tsx index 1ffae62aea..49d8b739b7 100644 --- a/src/TextForEvent.tsx +++ b/src/TextForEvent.tsx @@ -36,7 +36,6 @@ import AccessibleButton from "./components/views/elements/AccessibleButton"; import RightPanelStore from "./stores/right-panel/RightPanelStore"; import { highlightEvent, isLocationEvent } from "./utils/EventUtils"; import { ElementCall } from "./models/Call"; -import { textForVoiceBroadcastStoppedEvent, VoiceBroadcastInfoEventType } from "./voice-broadcast"; import { getSenderName } from "./utils/event/getSenderName"; import PosthogTrackers from "./PosthogTrackers.ts"; @@ -906,7 +905,6 @@ const stateHandlers: IHandlers = { // TODO: Enable support for m.widget event type (https://github.com/vector-im/element-web/issues/13111) "im.vector.modular.widgets": textForWidgetEvent, [WIDGET_LAYOUT_EVENT_TYPE]: textForWidgetLayoutEvent, - [VoiceBroadcastInfoEventType]: textForVoiceBroadcastStoppedEvent, }; // Add all the Mjolnir stuff to the renderer diff --git a/src/components/structures/EmbeddedPage.tsx b/src/components/structures/EmbeddedPage.tsx index 5c7e81caf5..c471565d91 100644 --- a/src/components/structures/EmbeddedPage.tsx +++ b/src/components/structures/EmbeddedPage.tsx @@ -36,7 +36,7 @@ interface IState { export default class EmbeddedPage extends React.PureComponent { public static contextType = MatrixClientContext; - public declare context: React.ContextType; + declare public context: React.ContextType; private unmounted = false; private dispatcherRef?: string; diff --git a/src/components/structures/FilePanel.tsx b/src/components/structures/FilePanel.tsx index 74a91d8cbc..4c580cb9fe 100644 --- a/src/components/structures/FilePanel.tsx +++ b/src/components/structures/FilePanel.tsx @@ -34,6 +34,7 @@ import { Layout } from "../../settings/enums/Layout"; import RoomContext, { TimelineRenderingType } from "../../contexts/RoomContext"; import Measured from "../views/elements/Measured"; import EmptyState from "../views/right_panel/EmptyState"; +import { ScopedRoomContextProvider } from "../../contexts/ScopedRoomContext.tsx"; interface IProps { roomId: string; @@ -51,7 +52,7 @@ interface IState { */ class FilePanel extends React.Component { public static contextType = RoomContext; - public declare context: React.ContextType; + declare public context: React.ContextType; // This is used to track if a decrypted event was a live event and should be // added to the timeline. @@ -104,7 +105,11 @@ class FilePanel extends React.Component { } if (!this.state.timelineSet.eventIdToTimeline(ev.getId()!)) { - this.state.timelineSet.addEventToTimeline(ev, timeline, false); + this.state.timelineSet.addEventToTimeline(ev, timeline, { + fromCache: false, + addToState: false, + toStartOfTimeline: false, + }); } } @@ -269,12 +274,10 @@ class FilePanel extends React.Component { if (this.state.timelineSet) { return ( - { layout={Layout.Group} /> - + ); } else { return ( - + { > - + ); } } diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index 9f9e225352..548dbff983 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -119,7 +119,6 @@ import { ValidatedServerConfig } from "../../utils/ValidatedServerConfig"; import { isLocalRoom } from "../../utils/localRoom/isLocalRoom"; import { SDKContext, SdkContextClass } from "../../contexts/SDKContext"; import { viewUserDeviceSettings } from "../../actions/handlers/viewUserDeviceSettings"; -import { cleanUpBroadcasts, VoiceBroadcastResumer } from "../../voice-broadcast"; import GenericToast from "../views/toasts/GenericToast"; import RovingSpotlightDialog from "../views/dialogs/spotlight/SpotlightDialog"; import { findDMForUser } from "../../utils/dm/findDMForUser"; @@ -227,7 +226,6 @@ export default class MatrixChat extends React.PureComponent { private focusNext: FocusNextType; private subTitleStatus: string; private prevWindowWidth: number; - private voiceBroadcastResumer?: VoiceBroadcastResumer; private readonly loggedInView = createRef(); private dispatcherRef?: string; @@ -501,7 +499,6 @@ export default class MatrixChat extends React.PureComponent { window.removeEventListener("resize", this.onWindowResized); this.stores.accountPasswordStore.clearPassword(); - this.voiceBroadcastResumer?.destroy(); } private onWindowResized = (): void => { @@ -651,10 +648,9 @@ export default class MatrixChat extends React.PureComponent { break; case "logout": LegacyCallHandler.instance.hangupAllCalls(); - Promise.all([ - ...[...CallStore.instance.connectedCalls].map((call) => call.disconnect()), - cleanUpBroadcasts(this.stores), - ]).finally(() => Lifecycle.logout(this.stores.oidcClientStore)); + Promise.all([...[...CallStore.instance.connectedCalls].map((call) => call.disconnect())]).finally(() => + Lifecycle.logout(this.stores.oidcClientStore), + ); break; case "require_registration": startAnyRegistrationFlow(payload as any); @@ -1679,8 +1675,6 @@ export default class MatrixChat extends React.PureComponent { }); } }); - - this.voiceBroadcastResumer = new VoiceBroadcastResumer(cli); } /** diff --git a/src/components/structures/MessagePanel.tsx b/src/components/structures/MessagePanel.tsx index b26de2e645..d2133f4f13 100644 --- a/src/components/structures/MessagePanel.tsx +++ b/src/components/structures/MessagePanel.tsx @@ -196,7 +196,7 @@ interface IReadReceiptForUser { */ export default class MessagePanel extends React.Component { public static contextType = RoomContext; - public declare context: React.ContextType; + declare public context: React.ContextType; public static defaultProps = { disableGrouping: false, diff --git a/src/components/structures/NotificationPanel.tsx b/src/components/structures/NotificationPanel.tsx index edec675b14..236da25409 100644 --- a/src/components/structures/NotificationPanel.tsx +++ b/src/components/structures/NotificationPanel.tsx @@ -19,6 +19,7 @@ import { Layout } from "../../settings/enums/Layout"; import RoomContext, { TimelineRenderingType } from "../../contexts/RoomContext"; import Measured from "../views/elements/Measured"; import EmptyState from "../views/right_panel/EmptyState"; +import { ScopedRoomContextProvider } from "../../contexts/ScopedRoomContext.tsx"; interface IProps { onClose(): void; @@ -33,7 +34,7 @@ interface IState { */ export default class NotificationPanel extends React.PureComponent { public static contextType = RoomContext; - public declare context: React.ContextType; + declare public context: React.ContextType; private card = React.createRef(); @@ -79,12 +80,10 @@ export default class NotificationPanel extends React.PureComponent } {content} - + ); } } diff --git a/src/components/structures/PipContainer.tsx b/src/components/structures/PipContainer.tsx index 731e720b12..c9fabfe0c9 100644 --- a/src/components/structures/PipContainer.tsx +++ b/src/components/structures/PipContainer.tsx @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only Please see LICENSE files in the repository root for full details. */ -import React, { MutableRefObject, ReactNode, useContext, useRef } from "react"; +import React, { MutableRefObject, ReactNode, useRef } from "react"; import { CallEvent, CallState, MatrixCall } from "matrix-js-sdk/src/webrtc/call"; import { logger } from "matrix-js-sdk/src/logger"; import { Optional } from "matrix-events-sdk"; @@ -21,19 +21,7 @@ import { WidgetLayoutStore } from "../../stores/widgets/WidgetLayoutStore"; import ActiveWidgetStore, { ActiveWidgetStoreEvent } from "../../stores/ActiveWidgetStore"; import { ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload"; import { UPDATE_EVENT } from "../../stores/AsyncStore"; -import { SDKContext, SdkContextClass } from "../../contexts/SDKContext"; -import { - useCurrentVoiceBroadcastPreRecording, - useCurrentVoiceBroadcastRecording, - VoiceBroadcastPlayback, - VoiceBroadcastPlaybackBody, - VoiceBroadcastPreRecording, - VoiceBroadcastPreRecordingPip, - VoiceBroadcastRecording, - VoiceBroadcastRecordingPip, - VoiceBroadcastSmallPlaybackBody, -} from "../../voice-broadcast"; -import { useCurrentVoiceBroadcastPlayback } from "../../voice-broadcast/hooks/useCurrentVoiceBroadcastPlayback"; +import { SdkContextClass } from "../../contexts/SDKContext"; import { WidgetPip } from "../views/pips/WidgetPip"; const SHOW_CALL_IN_STATES = [ @@ -46,9 +34,6 @@ const SHOW_CALL_IN_STATES = [ ]; interface IProps { - voiceBroadcastRecording: Optional; - voiceBroadcastPreRecording: Optional; - voiceBroadcastPlayback: Optional; movePersistedElement: MutableRefObject<(() => void) | undefined>; } @@ -245,52 +230,9 @@ class PipContainerInner extends React.Component { this.setState({ showWidgetInPip, persistentWidgetId, persistentRoomId }); } - private createVoiceBroadcastPlaybackPipContent(voiceBroadcastPlayback: VoiceBroadcastPlayback): CreatePipChildren { - const content = - this.state.viewedRoomId === voiceBroadcastPlayback.infoEvent.getRoomId() ? ( - - ) : ( - - ); - - return ({ onStartMoving }) => ( -
- {content} -
- ); - } - - private createVoiceBroadcastPreRecordingPipContent( - voiceBroadcastPreRecording: VoiceBroadcastPreRecording, - ): CreatePipChildren { - return ({ onStartMoving }) => ( -
- -
- ); - } - - private createVoiceBroadcastRecordingPipContent( - voiceBroadcastRecording: VoiceBroadcastRecording, - ): CreatePipChildren { - return ({ onStartMoving }) => ( -
- -
- ); - } - public render(): ReactNode { const pipMode = true; - let pipContent: Array = []; - - if (this.props.voiceBroadcastRecording) { - pipContent = [this.createVoiceBroadcastRecordingPipContent(this.props.voiceBroadcastRecording)]; - } else if (this.props.voiceBroadcastPreRecording) { - pipContent = [this.createVoiceBroadcastPreRecordingPipContent(this.props.voiceBroadcastPreRecording)]; - } else if (this.props.voiceBroadcastPlayback) { - pipContent = [this.createVoiceBroadcastPlaybackPipContent(this.props.voiceBroadcastPlayback)]; - } + const pipContent: Array = []; if (this.state.primaryCall) { // get a ref to call inside the current scope @@ -338,24 +280,7 @@ class PipContainerInner extends React.Component { } export const PipContainer: React.FC = () => { - const sdkContext = useContext(SDKContext); - const voiceBroadcastPreRecordingStore = sdkContext.voiceBroadcastPreRecordingStore; - const { currentVoiceBroadcastPreRecording } = useCurrentVoiceBroadcastPreRecording(voiceBroadcastPreRecordingStore); - - const voiceBroadcastRecordingsStore = sdkContext.voiceBroadcastRecordingsStore; - const { currentVoiceBroadcastRecording } = useCurrentVoiceBroadcastRecording(voiceBroadcastRecordingsStore); - - const voiceBroadcastPlaybacksStore = sdkContext.voiceBroadcastPlaybacksStore; - const { currentVoiceBroadcastPlayback } = useCurrentVoiceBroadcastPlayback(voiceBroadcastPlaybacksStore); - const movePersistedElement = useRef<() => void>(); - return ( - - ); + return ; }; diff --git a/src/components/structures/RightPanel.tsx b/src/components/structures/RightPanel.tsx index 9a9f29f82e..a1f2016243 100644 --- a/src/components/structures/RightPanel.tsx +++ b/src/components/structures/RightPanel.tsx @@ -63,7 +63,7 @@ interface IState { export default class RightPanel extends React.Component { public static contextType = MatrixClientContext; - public declare context: React.ContextType; + declare public context: React.ContextType; public constructor(props: Props, context: React.ContextType) { super(props, context); @@ -109,10 +109,10 @@ export default class RightPanel extends React.Component { } // redraw the badge on the membership list - if (this.state.phase === RightPanelPhases.RoomMemberList) { + if (this.state.phase === RightPanelPhases.MemberList) { this.delayedUpdate(); } else if ( - this.state.phase === RightPanelPhases.RoomMemberInfo && + this.state.phase === RightPanelPhases.MemberInfo && member.userId === this.state.cardState?.member?.userId ) { // refresh the member info (e.g. new power level) @@ -157,7 +157,7 @@ export default class RightPanel extends React.Component { const phase = this.props.overwriteCard?.phase ?? this.state.phase; const cardState = this.props.overwriteCard?.state ?? this.state.cardState; switch (phase) { - case RightPanelPhases.RoomMemberList: + case RightPanelPhases.MemberList: if (!!roomId) { card = ( { ); } break; - case RightPanelPhases.SpaceMemberList: - if (!!cardState?.spaceId || !!roomId) { - card = ( - - ); - } - break; - case RightPanelPhases.RoomMemberInfo: - case RightPanelPhases.SpaceMemberInfo: + case RightPanelPhases.MemberInfo: case RightPanelPhases.EncryptionPanel: { if (!!cardState?.member) { const roomMember = cardState.member instanceof RoomMember ? cardState.member : undefined; @@ -203,8 +189,7 @@ export default class RightPanel extends React.Component { } break; } - case RightPanelPhases.Room3pidMemberInfo: - case RightPanelPhases.Space3pidMemberInfo: + case RightPanelPhases.ThreePidMemberInfo: if (!!cardState?.memberInfoEvent) { card = ( diff --git a/src/components/structures/RoomSearchView.tsx b/src/components/structures/RoomSearchView.tsx index de2d9d2198..82146bcc5e 100644 --- a/src/components/structures/RoomSearchView.tsx +++ b/src/components/structures/RoomSearchView.tsx @@ -26,7 +26,7 @@ import ErrorDialog from "../views/dialogs/ErrorDialog"; import ResizeNotifier from "../../utils/ResizeNotifier"; import MatrixClientContext from "../../contexts/MatrixClientContext"; import { RoomPermalinkCreator } from "../../utils/permalinks/Permalinks"; -import RoomContext from "../../contexts/RoomContext"; +import { useScopedRoomContext } from "../../contexts/ScopedRoomContext.tsx"; const DEBUG = false; let debuglog = function (msg: string): void {}; @@ -53,7 +53,7 @@ interface Props { export const RoomSearchView = forwardRef( ({ term, scope, promise, abortController, resizeNotifier, className, onUpdate, inProgress }: Props, ref) => { const client = useContext(MatrixClientContext); - const roomContext = useContext(RoomContext); + const roomContext = useScopedRoomContext("showHiddenEvents"); const [highlights, setHighlights] = useState(null); const [results, setResults] = useState(null); const aborted = useRef(false); diff --git a/src/components/structures/RoomStatusBar.tsx b/src/components/structures/RoomStatusBar.tsx index 76f3b0c229..3bd69148ae 100644 --- a/src/components/structures/RoomStatusBar.tsx +++ b/src/components/structures/RoomStatusBar.tsx @@ -89,7 +89,7 @@ interface IState { export default class RoomStatusBar extends React.PureComponent { private unmounted = false; public static contextType = MatrixClientContext; - public declare context: React.ContextType; + declare public context: React.ContextType; public constructor(props: IProps, context: React.ContextType) { super(props, context); diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index 470b73de7c..891e6b97f4 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -9,7 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only Please see LICENSE files in the repository root for full details. */ -import React, { ChangeEvent, ComponentProps, createRef, ReactElement, ReactNode, RefObject, useContext } from "react"; +import React, { ChangeEvent, ComponentProps, createRef, ReactElement, ReactNode, RefObject, JSX } from "react"; import classNames from "classnames"; import { IRecommendedVersion, @@ -29,6 +29,7 @@ import { MatrixError, ISearchResults, THREAD_RELATION_TYPE, + MatrixClient, } from "matrix-js-sdk/src/matrix"; import { KnownMembership } from "matrix-js-sdk/src/types"; import { logger } from "matrix-js-sdk/src/logger"; @@ -54,7 +55,7 @@ import WidgetEchoStore from "../../stores/WidgetEchoStore"; import SettingsStore from "../../settings/SettingsStore"; import { Layout } from "../../settings/enums/Layout"; import AccessibleButton, { ButtonEvent } from "../views/elements/AccessibleButton"; -import RoomContext, { TimelineRenderingType, MainSplitContentType } from "../../contexts/RoomContext"; +import { TimelineRenderingType, MainSplitContentType } from "../../contexts/RoomContext"; import { E2EStatus, shieldStatusForRoom } from "../../utils/ShieldUtils"; import { Action } from "../../dispatcher/actions"; import { IMatrixClientCreds } from "../../MatrixClientPeg"; @@ -126,6 +127,7 @@ import RightPanelStore from "../../stores/right-panel/RightPanelStore"; import { onView3pidInvite } from "../../stores/right-panel/action-handlers"; import RoomSearchAuxPanel from "../views/rooms/RoomSearchAuxPanel"; import { PinnedMessageBanner } from "../views/rooms/PinnedMessageBanner"; +import { ScopedRoomContextProvider, useScopedRoomContext } from "../../contexts/ScopedRoomContext"; const DEBUG = false; const PREVENT_MULTIPLE_JITSI_WITHIN = 30_000; @@ -233,6 +235,11 @@ export interface IRoomState { liveTimeline?: EventTimeline; narrow: boolean; msc3946ProcessDynamicPredecessor: boolean; + /** + * Whether the room is encrypted or not. + * If null, we are still determining the encryption status. + */ + isRoomEncrypted: boolean | null; canAskToJoin: boolean; promptAskToJoin: boolean; @@ -246,6 +253,7 @@ interface LocalRoomViewProps { permalinkCreator: RoomPermalinkCreator; roomView: RefObject; onFileDrop: (dataTransfer: DataTransfer) => Promise; + mainSplitContentType: MainSplitContentType; } /** @@ -255,7 +263,7 @@ interface LocalRoomViewProps { * @returns {ReactElement} */ function LocalRoomView(props: LocalRoomViewProps): ReactElement { - const context = useContext(RoomContext); + const context = useScopedRoomContext("room"); const room = context.room as LocalRoom; const encryptionEvent = props.localRoom.currentState.getStateEvents(EventType.RoomEncryption)[0]; let encryptionTile: ReactNode; @@ -323,6 +331,7 @@ interface ILocalRoomCreateLoaderProps { localRoom: LocalRoom; names: string; resizeNotifier: ResizeNotifier; + mainSplitContentType: MainSplitContentType; } /** @@ -363,7 +372,7 @@ export class RoomView extends React.Component { private roomViewBody = createRef(); public static contextType = SDKContext; - public declare context: React.ContextType; + declare public context: React.ContextType; public constructor(props: IRoomProps, context: React.ContextType) { super(props, context); @@ -417,6 +426,7 @@ export class RoomView extends React.Component { canAskToJoin: this.askToJoinEnabled, promptAskToJoin: false, viewRoomOpts: { buttons: [] }, + isRoomEncrypted: null, }; } @@ -655,6 +665,7 @@ export class RoomView extends React.Component { // the RoomView instance if (initial) { newState.room = this.context.client!.getRoom(newState.roomId) || undefined; + newState.isRoomEncrypted = null; if (newState.room) { newState.showApps = this.shouldShowApps(newState.room); this.onRoomLoaded(newState.room); @@ -697,6 +708,14 @@ export class RoomView extends React.Component { if (initial) { this.setupRoom(newState.room, newState.roomId, !!newState.joining, !!newState.shouldPeek); } + + // We don't block the initial setup but we want to make it early to not block the timeline rendering + const isRoomEncrypted = await this.getIsRoomEncrypted(newState.roomId); + this.setState({ + isRoomEncrypted, + ...(isRoomEncrypted && + newState.roomId && { e2eStatus: RoomView.e2eStatusCache.get(newState.roomId) ?? E2EStatus.Warning }), + }); }; private onConnectedCalls = (): void => { @@ -1214,18 +1233,18 @@ export class RoomView extends React.Component { if (payload.member) { if (payload.push) { RightPanelStore.instance.pushCard({ - phase: RightPanelPhases.RoomMemberInfo, + phase: RightPanelPhases.MemberInfo, state: { member: payload.member }, }); } else { RightPanelStore.instance.setCards([ { phase: RightPanelPhases.RoomSummary }, - { phase: RightPanelPhases.RoomMemberList }, - { phase: RightPanelPhases.RoomMemberInfo, state: { member: payload.member } }, + { phase: RightPanelPhases.MemberList }, + { phase: RightPanelPhases.MemberInfo, state: { member: payload.member } }, ]); } } else { - RightPanelStore.instance.showOrHidePhase(RightPanelPhases.RoomMemberList); + RightPanelStore.instance.showOrHidePhase(RightPanelPhases.MemberList); } break; case Action.View3pidInvite: @@ -1342,13 +1361,12 @@ export class RoomView extends React.Component { this.context.widgetLayoutStore.on(WidgetLayoutStore.emissionForRoom(room), this.onWidgetLayoutChange); this.calculatePeekRules(room); - this.updatePreviewUrlVisibility(room); this.loadMembersIfJoined(room); this.calculateRecommendedVersion(room); - this.updateE2EStatus(room); this.updatePermissions(room); this.checkWidgets(room); this.loadVirtualRoom(room); + this.updateRoomEncrypted(room); if ( this.getMainSplitContentType(room) !== MainSplitContentType.Timeline && @@ -1377,6 +1395,13 @@ export class RoomView extends React.Component { return room?.currentState.getStateEvents(EventType.RoomTombstone, "") ?? undefined; } + private async getIsRoomEncrypted(roomId = this.state.roomId): Promise { + const crypto = this.context.client?.getCrypto(); + if (!crypto || !roomId) return false; + + return await crypto.isEncryptionEnabledInRoom(roomId); + } + private async calculateRecommendedVersion(room: Room): Promise { const upgradeRecommendation = await room.getRecommendedVersion(); if (this.unmounted) return; @@ -1409,12 +1434,15 @@ export class RoomView extends React.Component { }); } - private updatePreviewUrlVisibility({ roomId }: Room): void { - // URL Previews in E2EE rooms can be a privacy leak so use a different setting which is per-room explicit - const key = this.context.client?.isRoomEncrypted(roomId) ? "urlPreviewsEnabled_e2ee" : "urlPreviewsEnabled"; - this.setState({ - showUrlPreview: SettingsStore.getValue(key, roomId), - }); + private updatePreviewUrlVisibility(room: Room): void { + this.setState(({ isRoomEncrypted }) => ({ + showUrlPreview: this.getPreviewUrlVisibility(room, isRoomEncrypted), + })); + } + + private getPreviewUrlVisibility({ roomId }: Room, isRoomEncrypted: boolean | null): boolean { + const key = isRoomEncrypted ? "urlPreviewsEnabled_e2ee" : "urlPreviewsEnabled"; + return SettingsStore.getValue(key, roomId); } private onRoom = (room: Room): void => { @@ -1456,22 +1484,20 @@ export class RoomView extends React.Component { }; private async updateE2EStatus(room: Room): Promise { - 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 = RoomView.e2eStatusCache.get(room.roomId) ?? E2EStatus.Warning; - // set the state immediately then update, so we don't scare the user into thinking the room is unencrypted + if (!this.context.client || !this.state.isRoomEncrypted) return; + const e2eStatus = await this.cacheAndGetE2EStatus(room, this.context.client); + if (this.unmounted) return; this.setState({ e2eStatus }); + } - if (this.context.client.getCrypto()) { - /* At this point, the user has encryption on and cross-signing on */ - e2eStatus = await shieldStatusForRoom(this.context.client, room); - RoomView.e2eStatusCache.set(room.roomId, e2eStatus); - if (this.unmounted) return; - this.setState({ e2eStatus }); - } + private async cacheAndGetE2EStatus(room: Room, client: MatrixClient): Promise { + let e2eStatus = RoomView.e2eStatusCache.get(room.roomId); + // set the state immediately then update, so we don't scare the user into thinking the room is unencrypted + if (e2eStatus) this.setState({ e2eStatus }); + + e2eStatus = await shieldStatusForRoom(client, room); + RoomView.e2eStatusCache.set(room.roomId, e2eStatus); + return e2eStatus; } private onUrlPreviewsEnabledChange = (): void => { @@ -1480,20 +1506,36 @@ export class RoomView extends React.Component { } }; - private onRoomStateEvents = (ev: MatrixEvent, state: RoomState): void => { + private onRoomStateEvents = async (ev: MatrixEvent, state: RoomState): Promise => { // ignore if we don't have a room yet - if (!this.state.room || this.state.room.roomId !== state.roomId) return; + if (!this.state.room || this.state.room.roomId !== state.roomId || !this.context.client) return; switch (ev.getType()) { case EventType.RoomTombstone: this.setState({ tombstone: this.getRoomTombstone() }); break; - + case EventType.RoomEncryption: { + await this.updateRoomEncrypted(); + break; + } default: this.updatePermissions(this.state.room); } }; + private async updateRoomEncrypted(room = this.state.room): Promise { + if (!room || !this.context.client) return; + + const isRoomEncrypted = await this.getIsRoomEncrypted(room.roomId); + const newE2EStatus = isRoomEncrypted ? await this.cacheAndGetE2EStatus(room, this.context.client) : null; + + this.setState({ + isRoomEncrypted, + showUrlPreview: this.getPreviewUrlVisibility(room, isRoomEncrypted), + ...(newE2EStatus && { e2eStatus: newE2EStatus }), + }); + } + private onRoomStateUpdate = (state: RoomState): void => { // ignore members in other rooms if (state.roomId !== this.state.room?.roomId) { @@ -1959,35 +2001,41 @@ export class RoomView extends React.Component { if (!this.state.room || !this.context?.client) return null; const names = this.state.room.getDefaultRoomName(this.context.client.getSafeUserId()); return ( - - - + + + ); } private renderLocalRoomView(localRoom: LocalRoom): ReactNode { return ( - + - + ); } private renderWaitingForThirdPartyRoomView(inviteEvent: MatrixEvent): ReactNode { return ( - + - + ); } @@ -2027,6 +2075,8 @@ export class RoomView extends React.Component { public render(): ReactNode { if (!this.context.client) return null; + const { isRoomEncrypted } = this.state; + const isRoomEncryptionLoading = isRoomEncrypted === null; if (this.state.room instanceof LocalRoom) { if (this.state.room.state === LocalRoomState.CREATING) { @@ -2242,14 +2292,16 @@ export class RoomView extends React.Component { let aux: JSX.Element | undefined; let previewBar; if (this.state.timelineRenderingType === TimelineRenderingType.Search) { - aux = ( - - ); + if (!isRoomEncryptionLoading) { + aux = ( + + ); + } } else if (showRoomUpgradeBar) { aux = ; } else if (myMembership !== KnownMembership.Join) { @@ -2325,8 +2377,10 @@ export class RoomView extends React.Component { let messageComposer; const showComposer = + !isRoomEncryptionLoading && // joined and not showing search results - myMembership === KnownMembership.Join && !this.state.search; + myMembership === KnownMembership.Join && + !this.state.search; if (showComposer) { messageComposer = ( { highlightedEventId = this.state.initialEventId; } - const messagePanel = ( -
+ {SOCIALS.map((social) => ( + + {social.name} + + ))} +
+ ); +} + +/** + * Get the title, url and checkbox label for the dialog based on the target. + * @param target + * @param linkToSpecificEvent + * @param permalinkCreator + */ +function useTargetValues( + target: ShareDialogProps["target"], + linkToSpecificEvent: boolean, + permalinkCreator?: RoomPermalinkCreator, +): { title: string; url: string; checkboxLabel?: string } { + return useMemo(() => { + if (target instanceof URL) return { title: _t("share|title_link"), url: target.toString() }; + if (target instanceof User || target instanceof RoomMember) + return { + title: _t("share|title_user"), + url: makeUserPermalink(target.userId), + }; + + if (target instanceof Room) { + const title = _t("share|title_room"); + const newPermalinkCreator = new RoomPermalinkCreator(target); + newPermalinkCreator.load(); + + const events = target.getLiveTimeline().getEvents(); + return { + title, + url: linkToSpecificEvent + ? newPermalinkCreator.forEvent(events[events.length - 1].getId()!) + : newPermalinkCreator.forShareableRoom(), + ...(events.length > 0 && { checkboxLabel: _t("share|permalink_most_recent") }), + }; } - this.state = { - // MatrixEvent defaults to share linkSpecificEvent - linkSpecificEvent: this.props.target instanceof MatrixEvent, - permalinkCreator, + // MatrixEvent is remaining and should have a permalinkCreator + const url = linkToSpecificEvent + ? permalinkCreator!.forEvent(target.getId()!) + : permalinkCreator!.forShareableRoom(); + return { + title: _t("share|title_message"), + url, + checkboxLabel: _t("share|permalink_message"), }; - } - - public static onLinkClick(e: React.MouseEvent): void { - e.preventDefault(); - selectText(e.currentTarget); - } - - private onLinkSpecificEventCheckboxClick = (): void => { - this.setState({ - linkSpecificEvent: !this.state.linkSpecificEvent, - }); - }; - - private getUrl(): string { - if (this.props.target instanceof URL) { - return this.props.target.toString(); - } else if (this.props.target instanceof Room) { - if (this.state.linkSpecificEvent) { - const events = this.props.target.getLiveTimeline().getEvents(); - return this.state.permalinkCreator!.forEvent(events[events.length - 1].getId()!); - } else { - return this.state.permalinkCreator!.forShareableRoom(); - } - } else if (this.props.target instanceof User || this.props.target instanceof RoomMember) { - return makeUserPermalink(this.props.target.userId); - } else if (this.state.linkSpecificEvent) { - return this.props.permalinkCreator!.forEvent(this.props.target.getId()!); - } else { - return this.props.permalinkCreator!.forShareableRoom(); - } - } - - public render(): React.ReactNode { - let title: string | undefined; - let checkbox: JSX.Element | undefined; - - if (this.props.target instanceof URL) { - title = this.props.customTitle ?? _t("share|title_link"); - } else if (this.props.target instanceof Room) { - title = this.props.customTitle ?? _t("share|title_room"); - - const events = this.props.target.getLiveTimeline().getEvents(); - if (events.length > 0) { - checkbox = ( -
- - {_t("share|permalink_most_recent")} - -
- ); - } - } else if (this.props.target instanceof User || this.props.target instanceof RoomMember) { - title = this.props.customTitle ?? _t("share|title_user"); - } else if (this.props.target instanceof MatrixEvent) { - title = this.props.customTitle ?? _t("share|title_message"); - checkbox = ( -
- - {_t("share|permalink_message")} - -
- ); - } - - const matrixToUrl = this.getUrl(); - const encodedUrl = encodeURIComponent(matrixToUrl); - - const showQrCode = SettingsStore.getValue(UIFeature.ShareQRCode); - const showSocials = SettingsStore.getValue(UIFeature.ShareSocial); - - let qrSocialSection; - if (showQrCode || showSocials) { - qrSocialSection = ( - <> -
-
- {showQrCode && ( -
- -
- )} - {showSocials && ( -
- {socials.map((social) => ( - - {social.name} - - ))} -
- )} -
- - ); - } - - return ( - - {this.props.subtitle &&

{this.props.subtitle}

} -
- matrixToUrl}> - - {matrixToUrl} - - - {checkbox} - {qrSocialSection} -
-
- ); - } + }, [target, linkToSpecificEvent, permalinkCreator]); } diff --git a/src/components/views/elements/AppTile.tsx b/src/components/views/elements/AppTile.tsx index dae452fd5d..56754f14a6 100644 --- a/src/components/views/elements/AppTile.tsx +++ b/src/components/views/elements/AppTile.tsx @@ -116,7 +116,7 @@ interface IState { export default class AppTile extends React.Component { public static contextType = MatrixClientContext; - public declare context: ContextType; + declare public context: ContextType; public static defaultProps: Partial = { waitForIframeLoad: true, diff --git a/src/components/views/elements/EventListSummary.tsx b/src/components/views/elements/EventListSummary.tsx index 7562f992c1..776908375a 100644 --- a/src/components/views/elements/EventListSummary.tsx +++ b/src/components/views/elements/EventListSummary.tsx @@ -73,7 +73,7 @@ export default class EventListSummary extends React.Component< IProps & Required> > { public static contextType = RoomContext; - public declare context: React.ContextType; + declare public context: React.ContextType; public static defaultProps = { summaryLength: 1, diff --git a/src/components/views/elements/MiniAvatarUploader.tsx b/src/components/views/elements/MiniAvatarUploader.tsx index cf5a239814..452b206bef 100644 --- a/src/components/views/elements/MiniAvatarUploader.tsx +++ b/src/components/views/elements/MiniAvatarUploader.tsx @@ -12,12 +12,12 @@ import React, { useContext, useRef, useState, MouseEvent, ReactNode } from "reac import { Tooltip } from "@vector-im/compound-web"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; -import RoomContext from "../../../contexts/RoomContext"; import { useTimeout } from "../../../hooks/useTimeout"; import { chromeFileInputFix } from "../../../utils/BrowserWorkarounds"; import AccessibleButton from "./AccessibleButton"; import Spinner from "./Spinner"; import { getFileChanged } from "../settings/AvatarSetting.tsx"; +import { useScopedRoomContext } from "../../../contexts/ScopedRoomContext.tsx"; export const AVATAR_SIZE = "52px"; @@ -56,7 +56,7 @@ const MiniAvatarUploader: React.FC = ({ const label = hasAvatar || busy ? hasAvatarLabel : noAvatarLabel; - const { room } = useContext(RoomContext); + const { room } = useScopedRoomContext("room"); const canSetAvatar = isUserAvatar || room?.currentState?.maySendStateEvent(EventType.RoomAvatar, cli.getSafeUserId()); if (!canSetAvatar) return {children}; diff --git a/src/components/views/elements/PersistentApp.tsx b/src/components/views/elements/PersistentApp.tsx index 6fbf0d31bc..5f720dc85e 100644 --- a/src/components/views/elements/PersistentApp.tsx +++ b/src/components/views/elements/PersistentApp.tsx @@ -25,7 +25,7 @@ interface IProps { export default class PersistentApp extends React.Component { public static contextType = MatrixClientContext; - public declare context: ContextType; + declare public context: ContextType; private room: Room; public constructor(props: IProps, context: ContextType) { diff --git a/src/components/views/elements/ReplyChain.tsx b/src/components/views/elements/ReplyChain.tsx index 71846d6065..8e10ca3af9 100644 --- a/src/components/views/elements/ReplyChain.tsx +++ b/src/components/views/elements/ReplyChain.tsx @@ -65,7 +65,7 @@ interface IState { // be low as each event being loaded (after the first) is triggered by an explicit user action. export default class ReplyChain extends React.Component { public static contextType = RoomContext; - public declare context: React.ContextType; + declare public context: React.ContextType; private unmounted = false; private room: Room; diff --git a/src/components/views/elements/RoomAliasField.tsx b/src/components/views/elements/RoomAliasField.tsx index f092f9d25f..faa0ccf1a6 100644 --- a/src/components/views/elements/RoomAliasField.tsx +++ b/src/components/views/elements/RoomAliasField.tsx @@ -33,7 +33,7 @@ interface IState { // Controlled form component wrapping Field for inputting a room alias scoped to a given domain export default class RoomAliasField extends React.PureComponent { public static contextType = MatrixClientContext; - public declare context: React.ContextType; + declare public context: React.ContextType; private fieldRef = createRef(); diff --git a/src/components/views/emojipicker/ReactionPicker.tsx b/src/components/views/emojipicker/ReactionPicker.tsx index b62df99e25..bd16634490 100644 --- a/src/components/views/emojipicker/ReactionPicker.tsx +++ b/src/components/views/emojipicker/ReactionPicker.tsx @@ -29,7 +29,7 @@ interface IState { class ReactionPicker extends React.Component { public static contextType = RoomContext; - public declare context: React.ContextType; + declare public context: React.ContextType; public constructor(props: IProps, context: React.ContextType) { super(props, context); diff --git a/src/components/views/emojipicker/Search.tsx b/src/components/views/emojipicker/Search.tsx index 87397b6d4b..bce045cb8c 100644 --- a/src/components/views/emojipicker/Search.tsx +++ b/src/components/views/emojipicker/Search.tsx @@ -23,7 +23,7 @@ interface IProps { class Search extends React.PureComponent { public static contextType = RovingTabIndexContext; - public declare context: React.ContextType; + declare public context: React.ContextType; private inputRef = React.createRef(); diff --git a/src/components/views/location/LocationPicker.tsx b/src/components/views/location/LocationPicker.tsx index c45521830d..e812f1c6bd 100644 --- a/src/components/views/location/LocationPicker.tsx +++ b/src/components/views/location/LocationPicker.tsx @@ -42,7 +42,7 @@ const isSharingOwnLocation = (shareType: LocationShareType): boolean => class LocationPicker extends React.Component { public static contextType = MatrixClientContext; - public declare context: React.ContextType; + declare public context: React.ContextType; private map?: maplibregl.Map; private geolocate?: maplibregl.GeolocateControl; private marker?: maplibregl.Marker; diff --git a/src/components/views/messages/EditHistoryMessage.tsx b/src/components/views/messages/EditHistoryMessage.tsx index 8316d0835b..fb6f04c08f 100644 --- a/src/components/views/messages/EditHistoryMessage.tsx +++ b/src/components/views/messages/EditHistoryMessage.tsx @@ -45,7 +45,7 @@ interface IState { export default class EditHistoryMessage extends React.PureComponent { public static contextType = MatrixClientContext; - public declare context: React.ContextType; + declare public context: React.ContextType; private content = createRef(); private pills = new ReactRootManager(); diff --git a/src/components/views/messages/MAudioBody.tsx b/src/components/views/messages/MAudioBody.tsx index 326b1c38c8..bf0cc9ee54 100644 --- a/src/components/views/messages/MAudioBody.tsx +++ b/src/components/views/messages/MAudioBody.tsx @@ -30,7 +30,7 @@ interface IState { export default class MAudioBody extends React.PureComponent { public static contextType = RoomContext; - public declare context: React.ContextType; + declare public context: React.ContextType; public state: IState = {}; diff --git a/src/components/views/messages/MFileBody.tsx b/src/components/views/messages/MFileBody.tsx index fde3ea0184..1235b73b4b 100644 --- a/src/components/views/messages/MFileBody.tsx +++ b/src/components/views/messages/MFileBody.tsx @@ -102,7 +102,7 @@ interface IState { export default class MFileBody extends React.Component { public static contextType = RoomContext; - public declare context: React.ContextType; + declare public context: React.ContextType; public state: IState = {}; diff --git a/src/components/views/messages/MImageBody.tsx b/src/components/views/messages/MImageBody.tsx index 7d45e71a4b..c3aeee1a54 100644 --- a/src/components/views/messages/MImageBody.tsx +++ b/src/components/views/messages/MImageBody.tsx @@ -51,13 +51,14 @@ interface IState { naturalHeight: number; }; hover: boolean; + focus: boolean; showImage: boolean; placeholder: Placeholder; } export default class MImageBody extends React.Component { public static contextType = RoomContext; - public declare context: React.ContextType; + declare public context: React.ContextType; private unmounted = false; private image = createRef(); @@ -71,6 +72,7 @@ export default class MImageBody extends React.Component { imgError: false, imgLoaded: false, hover: false, + focus: false, showImage: SettingsStore.getValue("showImages"), placeholder: Placeholder.NoImage, }; @@ -120,30 +122,29 @@ export default class MImageBody extends React.Component { } }; - protected onImageEnter = (e: React.MouseEvent): void => { - this.setState({ hover: true }); - - if ( + private get shouldAutoplay(): boolean { + return !( !this.state.contentUrl || !this.state.showImage || !this.state.isAnimated || SettingsStore.getValue("autoplayGifs") - ) { - return; - } - const imgElement = e.currentTarget; - imgElement.src = this.state.contentUrl; + ); + } + + protected onImageEnter = (): void => { + this.setState({ hover: true }); }; - protected onImageLeave = (e: React.MouseEvent): void => { + protected onImageLeave = (): void => { this.setState({ hover: false }); + }; - const url = this.state.thumbUrl ?? this.state.contentUrl; - if (!url || !this.state.showImage || !this.state.isAnimated || SettingsStore.getValue("autoplayGifs")) { - return; - } - const imgElement = e.currentTarget; - imgElement.src = url; + private onFocus = (): void => { + this.setState({ focus: true }); + }; + + private onBlur = (): void => { + this.setState({ focus: false }); }; private reconnectedListener = createReconnectedListener((): void => { @@ -470,14 +471,20 @@ export default class MImageBody extends React.Component { let showPlaceholder = Boolean(placeholder); + const hoverOrFocus = this.state.hover || this.state.focus; if (thumbUrl && !this.state.imgError) { + let url = thumbUrl; + if (hoverOrFocus && this.shouldAutoplay) { + url = this.state.contentUrl!; + } + // Restrict the width of the thumbnail here, otherwise it will fill the container // which has the same width as the timeline // mx_MImageBody_thumbnail resizes img to exactly container size img = ( {content.body} { showPlaceholder = false; // because we're hiding the image, so don't show the placeholder. } - if (this.state.isAnimated && !SettingsStore.getValue("autoplayGifs") && !this.state.hover) { + if (this.state.isAnimated && !SettingsStore.getValue("autoplayGifs") && !hoverOrFocus) { // XXX: Arguably we may want a different label when the animated image is WEBP and not GIF gifLabel =

GIF

; } let banner: ReactNode | undefined; - if (this.state.showImage && this.state.hover) { + if (this.state.showImage && hoverOrFocus) { banner = this.getBanner(content); } @@ -568,7 +575,13 @@ export default class MImageBody extends React.Component { protected wrapImage(contentUrl: string | null | undefined, children: JSX.Element): ReactNode { if (contentUrl) { return ( - + {children} ); @@ -657,17 +670,14 @@ export default class MImageBody extends React.Component { } interface PlaceholderIProps { - hover?: boolean; maxWidth?: number; } export class HiddenImagePlaceholder extends React.PureComponent { public render(): React.ReactNode { const maxWidth = this.props.maxWidth ? this.props.maxWidth + "px" : null; - let className = "mx_HiddenImagePlaceholder"; - if (this.props.hover) className += " mx_HiddenImagePlaceholder_hover"; return ( -
+
{_t("timeline|m.image|show_image")} diff --git a/src/components/views/messages/MLocationBody.tsx b/src/components/views/messages/MLocationBody.tsx index b226476fa8..7735e64b03 100644 --- a/src/components/views/messages/MLocationBody.tsx +++ b/src/components/views/messages/MLocationBody.tsx @@ -30,7 +30,7 @@ interface IState { export default class MLocationBody extends React.Component { public static contextType = MatrixClientContext; - public declare context: React.ContextType; + declare public context: React.ContextType; private unmounted = false; private mapId: string; diff --git a/src/components/views/messages/MPollBody.tsx b/src/components/views/messages/MPollBody.tsx index 9e173e5f4a..ba3962779f 100644 --- a/src/components/views/messages/MPollBody.tsx +++ b/src/components/views/messages/MPollBody.tsx @@ -139,7 +139,7 @@ export function launchPollEditor(mxEvent: MatrixEvent, getRelationsForEvent?: Ge export default class MPollBody extends React.Component { public static contextType = MatrixClientContext; - public declare context: React.ContextType; + declare public context: React.ContextType; private seenEventIds: string[] = []; // Events we have already seen public constructor(props: IBodyProps, context: React.ContextType) { diff --git a/src/components/views/messages/MVideoBody.tsx b/src/components/views/messages/MVideoBody.tsx index 4036b9ddec..822d2c3f59 100644 --- a/src/components/views/messages/MVideoBody.tsx +++ b/src/components/views/messages/MVideoBody.tsx @@ -34,7 +34,7 @@ interface IState { export default class MVideoBody extends React.PureComponent { public static contextType = RoomContext; - public declare context: React.ContextType; + declare public context: React.ContextType; private videoRef = React.createRef(); private sizeWatcher?: string; diff --git a/src/components/views/messages/MessageActionBar.tsx b/src/components/views/messages/MessageActionBar.tsx index fdd0200429..9d21b8fa45 100644 --- a/src/components/views/messages/MessageActionBar.tsx +++ b/src/components/views/messages/MessageActionBar.tsx @@ -58,7 +58,6 @@ import { ALTERNATE_KEY_NAME } from "../../../accessibility/KeyboardShortcuts"; import { Action } from "../../../dispatcher/actions"; import { ShowThreadPayload } from "../../../dispatcher/payloads/ShowThreadPayload"; import { GetRelationsForEvent, IEventTileType } from "../rooms/EventTile"; -import { VoiceBroadcastInfoEventType } from "../../../voice-broadcast/types"; import { ButtonEvent } from "../elements/AccessibleButton"; import PinningUtils from "../../../utils/PinningUtils"; import PosthogTrackers from "../../../PosthogTrackers.ts"; @@ -262,7 +261,7 @@ interface IMessageActionBarProps { export default class MessageActionBar extends React.PureComponent { public static contextType = RoomContext; - public declare context: React.ContextType; + declare public context: React.ContextType; public componentDidMount(): void { if (this.props.mxEvent.status && this.props.mxEvent.status !== EventStatus.SENT) { @@ -354,8 +353,7 @@ export default class MessageActionBar extends React.PureComponent { @@ -85,7 +84,7 @@ export default class MessageEvent extends React.Component implements IMe private evTypes = new Map>(baseEvTypes.entries()); public static contextType = MatrixClientContext; - public declare context: React.ContextType; + declare public context: React.ContextType; public constructor(props: IProps, context: React.ContextType) { super(props, context); @@ -276,10 +275,6 @@ export default class MessageEvent extends React.Component implements IMe if (M_LOCATION.matches(type) || (type === EventType.RoomMessage && msgtype === MsgType.Location)) { BodyType = MLocationBody; } - - if (type === VoiceBroadcastInfoEventType && content?.state === VoiceBroadcastInfoState.Started) { - BodyType = VoiceBroadcastBody; - } } if (SettingsStore.getValue("feature_mjolnir")) { diff --git a/src/components/views/messages/ReactionsRow.tsx b/src/components/views/messages/ReactionsRow.tsx index eba9499606..605e6a7dfe 100644 --- a/src/components/views/messages/ReactionsRow.tsx +++ b/src/components/views/messages/ReactionsRow.tsx @@ -75,7 +75,7 @@ interface IState { export default class ReactionsRow extends React.PureComponent { public static contextType = RoomContext; - public declare context: React.ContextType; + declare public context: React.ContextType; public constructor(props: IProps, context: React.ContextType) { super(props, context); diff --git a/src/components/views/messages/ReactionsRowButton.tsx b/src/components/views/messages/ReactionsRowButton.tsx index 4a1d8d67fe..709edeffd8 100644 --- a/src/components/views/messages/ReactionsRowButton.tsx +++ b/src/components/views/messages/ReactionsRowButton.tsx @@ -38,7 +38,7 @@ export interface IProps { export default class ReactionsRowButton extends React.PureComponent { public static contextType = MatrixClientContext; - public declare context: React.ContextType; + declare public context: React.ContextType; public onClick = (): void => { const { mxEvent, myReactionEvent, content } = this.props; diff --git a/src/components/views/messages/ReactionsRowButtonTooltip.tsx b/src/components/views/messages/ReactionsRowButtonTooltip.tsx index 9790356762..5f407e2e20 100644 --- a/src/components/views/messages/ReactionsRowButtonTooltip.tsx +++ b/src/components/views/messages/ReactionsRowButtonTooltip.tsx @@ -28,7 +28,7 @@ interface IProps { export default class ReactionsRowButtonTooltip extends React.PureComponent> { public static contextType = MatrixClientContext; - public declare context: React.ContextType; + declare public context: React.ContextType; public render(): React.ReactNode { const { content, reactionEvents, mxEvent, children } = this.props; diff --git a/src/components/views/messages/RoomPredecessorTile.tsx b/src/components/views/messages/RoomPredecessorTile.tsx index 2e8633febd..afc8142234 100644 --- a/src/components/views/messages/RoomPredecessorTile.tsx +++ b/src/components/views/messages/RoomPredecessorTile.tsx @@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only Please see LICENSE files in the repository root for full details. */ -import React, { useCallback, useContext } from "react"; +import React, { useCallback } from "react"; import { logger } from "matrix-js-sdk/src/logger"; import { MatrixEvent, Room, RoomState } from "matrix-js-sdk/src/matrix"; @@ -18,10 +18,10 @@ import { _t } from "../../../languageHandler"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; import EventTileBubble from "./EventTileBubble"; import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; -import RoomContext from "../../../contexts/RoomContext"; import { useRoomState } from "../../../hooks/useRoomState"; import SettingsStore from "../../../settings/SettingsStore"; import MatrixToPermalinkConstructor from "../../../utils/permalinks/MatrixToPermalinkConstructor"; +import { useScopedRoomContext } from "../../../contexts/ScopedRoomContext.tsx"; interface IProps { /** The m.room.create MatrixEvent that this tile represents */ @@ -40,7 +40,7 @@ export const RoomPredecessorTile: React.FC = ({ mxEvent, timestamp }) => // the information inside mxEvent. This allows us the flexibility later to // use a different predecessor (e.g. through MSC3946) and still display it // in the timeline location of the create event. - const roomContext = useContext(RoomContext); + const roomContext = useScopedRoomContext("room"); const predecessor = useRoomState( roomContext.room, useCallback( diff --git a/src/components/views/messages/TextualBody.tsx b/src/components/views/messages/TextualBody.tsx index 0c05236176..ae99754cba 100644 --- a/src/components/views/messages/TextualBody.tsx +++ b/src/components/views/messages/TextualBody.tsx @@ -52,10 +52,8 @@ export default class TextualBody extends React.Component { private tooltips = new ReactRootManager(); private reactRoots = new ReactRootManager(); - private ref = createRef(); - public static contextType = RoomContext; - public declare context: React.ContextType; + declare public context: React.ContextType; public state = { links: [], @@ -86,7 +84,7 @@ export default class TextualBody extends React.Component { if (this.props.mxEvent.getContent().format === "org.matrix.custom.html") { // Handle expansion and add buttons - const pres = this.ref.current?.getElementsByTagName("pre"); + const pres = [...content.getElementsByTagName("pre")]; if (pres && pres.length > 0) { for (let i = 0; i < pres.length; i++) { // If there already is a div wrapping the codeblock we want to skip this. @@ -115,13 +113,14 @@ export default class TextualBody extends React.Component { root.className = "mx_EventTile_pre_container"; // Insert containing div in place of
 block
-        pre.parentNode?.replaceChild(root, pre);
+        pre.replaceWith(root);
 
         this.reactRoots.render(
             
                 {pre}
             ,
             root,
+            pre,
         );
     }
 
@@ -196,10 +195,9 @@ export default class TextualBody extends React.Component {
                     
                 );
 
-                this.reactRoots.render(spoiler, spoilerContainer);
-
-                node.parentNode?.replaceChild(spoilerContainer, node);
+                this.reactRoots.render(spoiler, spoilerContainer, node);
 
+                node.replaceWith(spoilerContainer);
                 node = spoilerContainer;
             }
 
@@ -479,12 +477,7 @@ export default class TextualBody extends React.Component {
 
         if (isEmote) {
             return (
-                
+
{mxEvent.sender ? mxEvent.sender.name : mxEvent.getSender()} @@ -497,7 +490,7 @@ export default class TextualBody extends React.Component { } if (isNotice) { return ( -
+
{body} {widgets}
@@ -505,14 +498,14 @@ export default class TextualBody extends React.Component { } if (isCaption) { return ( -
+
{body} {widgets}
); } return ( -
+
{body} {widgets}
diff --git a/src/components/views/messages/TextualEvent.tsx b/src/components/views/messages/TextualEvent.tsx index 1c1ba26d08..1c54963f76 100644 --- a/src/components/views/messages/TextualEvent.tsx +++ b/src/components/views/messages/TextualEvent.tsx @@ -19,7 +19,7 @@ interface IProps { export default class TextualEvent extends React.Component { public static contextType = RoomContext; - public declare context: React.ContextType; + declare public context: React.ContextType; public render(): React.ReactNode { const text = TextForEvent.textForEvent( diff --git a/src/components/views/right_panel/PinnedMessagesCard.tsx b/src/components/views/right_panel/PinnedMessagesCard.tsx index af7106f9c5..d6161e9434 100644 --- a/src/components/views/right_panel/PinnedMessagesCard.tsx +++ b/src/components/views/right_panel/PinnedMessagesCard.tsx @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only Please see LICENSE files in the repository root for full details. */ -import React, { useCallback, useEffect, JSX } from "react"; +import React, { useCallback, useEffect, JSX, useContext } from "react"; import { Room, MatrixEvent } from "matrix-js-sdk/src/matrix"; import { Button, Separator } from "@vector-im/compound-web"; import classNames from "classnames"; @@ -18,7 +18,7 @@ import Spinner from "../elements/Spinner"; import { useMatrixClientContext } from "../../../contexts/MatrixClientContext"; import { PinnedEventTile } from "../rooms/PinnedEventTile"; import { useRoomState } from "../../../hooks/useRoomState"; -import RoomContext, { TimelineRenderingType, useRoomContext } from "../../../contexts/RoomContext"; +import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext"; import { ReadPinsEventId } from "./types"; import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks"; import { filterBoolean } from "../../../utils/arrays"; @@ -27,6 +27,7 @@ import { UnpinAllDialog } from "../dialogs/UnpinAllDialog"; import EmptyState from "./EmptyState"; import { usePinnedEvents, useReadPinnedEvents, useSortedFetchedPinnedEvents } from "../../../hooks/usePinnedEvents"; import PinningUtils from "../../../utils/PinningUtils.ts"; +import { ScopedRoomContextProvider } from "../../../contexts/ScopedRoomContext.tsx"; /** * List the pinned messages in a room inside a Card. @@ -48,7 +49,7 @@ interface PinnedMessagesCardProps { export function PinnedMessagesCard({ room, onClose, permalinkCreator }: PinnedMessagesCardProps): JSX.Element { const cli = useMatrixClientContext(); - const roomContext = useRoomContext(); + const roomContext = useContext(RoomContext); const pinnedEventIds = usePinnedEvents(room); const readPinnedEvents = useReadPinnedEvents(room); const pinnedEvents = useSortedFetchedPinnedEvents(room, pinnedEventIds); @@ -89,14 +90,9 @@ export function PinnedMessagesCard({ room, onClose, permalinkCreator }: PinnedMe className="mx_PinnedMessagesCard" onClose={onClose} > - + {content} - + ); } diff --git a/src/components/views/right_panel/RoomSummaryCard.tsx b/src/components/views/right_panel/RoomSummaryCard.tsx index 664977bbe2..c8dd0b9738 100644 --- a/src/components/views/right_panel/RoomSummaryCard.tsx +++ b/src/components/views/right_panel/RoomSummaryCard.tsx @@ -47,11 +47,11 @@ import RoomAvatar from "../avatars/RoomAvatar"; import defaultDispatcher from "../../../dispatcher/dispatcher"; import { RightPanelPhases } from "../../../stores/right-panel/RightPanelStorePhases"; import Modal from "../../../Modal"; -import ShareDialog from "../dialogs/ShareDialog"; +import { ShareDialog } from "../dialogs/ShareDialog"; import { useEventEmitterState } from "../../../hooks/useEventEmitter"; import { E2EStatus } from "../../../utils/ShieldUtils"; import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks"; -import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext"; +import { TimelineRenderingType } from "../../../contexts/RoomContext"; import RoomName from "../elements/RoomName"; import ExportDialog from "../dialogs/ExportDialog"; import RightPanelStore from "../../../stores/right-panel/RightPanelStore"; @@ -76,6 +76,7 @@ import { useTransition } from "../../../hooks/useTransition"; import { isVideoRoom as calcIsVideoRoom } from "../../../utils/video-rooms"; import { usePinnedEvents } from "../../../hooks/usePinnedEvents"; import { ReleaseAnnouncement } from "../../structures/ReleaseAnnouncement.tsx"; +import { useScopedRoomContext } from "../../../contexts/ScopedRoomContext.tsx"; interface IProps { room: Room; @@ -86,7 +87,7 @@ interface IProps { } const onRoomMembersClick = (): void => { - RightPanelStore.instance.pushCard({ phase: RightPanelPhases.RoomMemberList }, true); + RightPanelStore.instance.pushCard({ phase: RightPanelPhases.MemberList }, true); }; const onRoomThreadsClick = (): void => { @@ -232,7 +233,7 @@ const RoomSummaryCard: React.FC = ({ }; const isRoomEncrypted = useIsEncrypted(cli, room); - const roomContext = useContext(RoomContext); + const roomContext = useScopedRoomContext("e2eStatus", "timelineRenderingType"); const e2eStatus = roomContext.e2eStatus; const isVideoRoom = calcIsVideoRoom(room); diff --git a/src/components/views/right_panel/TimelineCard.tsx b/src/components/views/right_panel/TimelineCard.tsx index e0988eeaa5..f62319f3cd 100644 --- a/src/components/views/right_panel/TimelineCard.tsx +++ b/src/components/views/right_panel/TimelineCard.tsx @@ -38,6 +38,7 @@ import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; import Measured from "../elements/Measured"; import { UPDATE_EVENT } from "../../../stores/AsyncStore"; import { SdkContextClass } from "../../../contexts/SDKContext"; +import { ScopedRoomContextProvider } from "../../../contexts/ScopedRoomContext.tsx"; interface IProps { room: Room; @@ -68,7 +69,7 @@ interface IState { export default class TimelineCard extends React.Component { public static contextType = RoomContext; - public declare context: React.ContextType; + declare public context: React.ContextType; private dispatcherRef?: string; private layoutWatcherRef?: string; @@ -199,13 +200,11 @@ export default class TimelineCard extends React.Component { const showComposer = myMembership === KnownMembership.Join; return ( - { /> )} - + ); } } diff --git a/src/components/views/right_panel/UserInfo.tsx b/src/components/views/right_panel/UserInfo.tsx index d07b3566e2..591e2327ae 100644 --- a/src/components/views/right_panel/UserInfo.tsx +++ b/src/components/views/right_panel/UserInfo.tsx @@ -63,7 +63,7 @@ import PowerSelector from "../elements/PowerSelector"; import MemberAvatar from "../avatars/MemberAvatar"; import PresenceLabel from "../rooms/PresenceLabel"; import BulkRedactDialog from "../dialogs/BulkRedactDialog"; -import ShareDialog from "../dialogs/ShareDialog"; +import { ShareDialog } from "../dialogs/ShareDialog"; import ErrorDialog from "../dialogs/ErrorDialog"; import QuestionDialog from "../dialogs/QuestionDialog"; import ConfirmUserActionDialog from "../dialogs/ConfirmUserActionDialog"; @@ -1739,13 +1739,13 @@ export const UserInfoHeader: React.FC<{ interface IProps { user: Member; room?: Room; - phase: RightPanelPhases.RoomMemberInfo | RightPanelPhases.SpaceMemberInfo | RightPanelPhases.EncryptionPanel; + phase: RightPanelPhases.MemberInfo | RightPanelPhases.EncryptionPanel; onClose(): void; verificationRequest?: VerificationRequest; verificationRequestPromise?: Promise; } -const UserInfo: React.FC = ({ user, room, onClose, phase = RightPanelPhases.RoomMemberInfo, ...props }) => { +const UserInfo: React.FC = ({ user, room, onClose, phase = RightPanelPhases.MemberInfo, ...props }) => { const cli = useContext(MatrixClientContext); // fetch latest room member if we have a room, so we don't show historical information, falling back to user @@ -1767,8 +1767,6 @@ const UserInfo: React.FC = ({ user, room, onClose, phase = RightPanelPha // We have no previousPhase for when viewing a UserInfo without a Room at this time if (room && phase === RightPanelPhases.EncryptionPanel) { cardState = { member }; - } else if (room?.isSpaceRoom()) { - cardState = { spaceId: room.roomId }; } const onEncryptionPanelClose = (): void => { @@ -1777,8 +1775,7 @@ const UserInfo: React.FC = ({ user, room, onClose, phase = RightPanelPha let content: JSX.Element | undefined; switch (phase) { - case RightPanelPhases.RoomMemberInfo: - case RightPanelPhases.SpaceMemberInfo: + case RightPanelPhases.MemberInfo: content = ( = ({ user, room, onClose, phase = RightPanelPha closeLabel={closeLabel} cardState={cardState} onBack={(ev: ButtonEvent) => { - if (RightPanelStore.instance.previousCard.phase === RightPanelPhases.RoomMemberList) { + if (RightPanelStore.instance.previousCard.phase === RightPanelPhases.MemberList) { PosthogTrackers.trackInteraction("WebRightPanelRoomUserInfoBackButton", ev); } }} diff --git a/src/components/views/room_settings/AliasSettings.tsx b/src/components/views/room_settings/AliasSettings.tsx index 3c1a745530..0bb29b7f89 100644 --- a/src/components/views/room_settings/AliasSettings.tsx +++ b/src/components/views/room_settings/AliasSettings.tsx @@ -94,7 +94,7 @@ interface IState { export default class AliasSettings extends React.Component { public static contextType = MatrixClientContext; - public declare context: ContextType; + declare public context: ContextType; public static defaultProps = { canSetAliases: false, diff --git a/src/components/views/rooms/Autocomplete.tsx b/src/components/views/rooms/Autocomplete.tsx index 423b5c6272..3ffd6648ea 100644 --- a/src/components/views/rooms/Autocomplete.tsx +++ b/src/components/views/rooms/Autocomplete.tsx @@ -49,7 +49,7 @@ export default class Autocomplete extends React.PureComponent { private completionRefs: Record> = {}; public static contextType = RoomContext; - public declare context: React.ContextType; + declare public context: React.ContextType; public constructor(props: IProps, context: React.ContextType) { super(props, context); diff --git a/src/components/views/rooms/EditMessageComposer.tsx b/src/components/views/rooms/EditMessageComposer.tsx index d62a451b8b..f4b5c3698e 100644 --- a/src/components/views/rooms/EditMessageComposer.tsx +++ b/src/components/views/rooms/EditMessageComposer.tsx @@ -43,25 +43,6 @@ import { attachMentions, attachRelation } from "./SendMessageComposer"; import { filterBoolean } from "../../../utils/arrays"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; -function getHtmlReplyFallback(mxEvent: MatrixEvent): string { - const html = mxEvent.getContent().formatted_body; - if (!html) { - return ""; - } - const rootNode = new DOMParser().parseFromString(html, "text/html").body; - const mxReply = rootNode.querySelector("mx-reply"); - return (mxReply && mxReply.outerHTML) || ""; -} - -function getTextReplyFallback(mxEvent: MatrixEvent): string { - const body: string = mxEvent.getContent().body; - const lines = body.split("\n").map((l) => l.trim()); - if (lines.length > 2 && lines[0].startsWith("> ") && lines[1].length === 0) { - return `${lines[0]}\n\n`; - } - return ""; -} - // exported for tests export function createEditContent( model: EditorModel, @@ -72,15 +53,6 @@ export function createEditContent( if (isEmote) { model = stripEmoteCommand(model); } - const isReply = !!editedEvent.replyEventId; - let plainPrefix = ""; - let htmlPrefix = ""; - - if (isReply) { - plainPrefix = getTextReplyFallback(editedEvent); - htmlPrefix = getHtmlReplyFallback(editedEvent); - } - const body = textSerialize(model); const newContent: RoomMessageEventContent = { @@ -89,19 +61,18 @@ export function createEditContent( }; const contentBody: RoomMessageTextEventContent & Omit, "m.relates_to"> = { "msgtype": newContent.msgtype, - "body": `${plainPrefix} * ${body}`, + "body": `* ${body}`, "m.new_content": newContent, }; const formattedBody = htmlSerializeIfNeeded(model, { - forceHTML: isReply, useMarkdown: SettingsStore.getValue("MessageComposerInput.useMarkdown"), }); if (formattedBody) { newContent.format = "org.matrix.custom.html"; newContent.formatted_body = formattedBody; contentBody.format = newContent.format; - contentBody.formatted_body = `${htmlPrefix} * ${formattedBody}`; + contentBody.formatted_body = `* ${formattedBody}`; } // Build the mentions properties for both the content and new_content. @@ -121,7 +92,7 @@ interface IState { class EditMessageComposer extends React.Component { public static contextType = RoomContext; - public declare context: React.ContextType; + declare public context: React.ContextType; private readonly editorRef = createRef(); private dispatcherRef?: string; diff --git a/src/components/views/rooms/EventTile.tsx b/src/components/views/rooms/EventTile.tsx index 8b07368859..8c755f00bd 100644 --- a/src/components/views/rooms/EventTile.tsx +++ b/src/components/views/rooms/EventTile.tsx @@ -296,7 +296,7 @@ export class UnwrappedEventTile extends React.Component }; public static contextType = RoomContext; - public declare context: React.ContextType; + declare public context: React.ContextType; private unmounted = false; @@ -758,8 +758,13 @@ export class UnwrappedEventTile extends React.Component shieldReasonMessage = _t("encryption|event_shield_reason_mismatched_sender_key"); break; - default: - shieldReasonMessage = _t("error|unknown"); + case EventShieldReason.SENT_IN_CLEAR: + shieldReasonMessage = _t("common|unencrypted"); + break; + + case EventShieldReason.VERIFICATION_VIOLATION: + shieldReasonMessage = _t("timeline|decryption_failure|sender_identity_previously_verified"); + break; } if (this.state.shieldColour === EventShieldColour.GREY) { @@ -770,7 +775,7 @@ export class UnwrappedEventTile extends React.Component } } - if (MatrixClientPeg.safeGet().isRoomEncrypted(ev.getRoomId()!)) { + if (this.context.isRoomEncrypted) { // else if room is encrypted // and event is being encrypted or is not_sent (Unknown Devices/Network Error) if (ev.status === EventStatus.ENCRYPTING) { diff --git a/src/components/views/rooms/HistoryTile.tsx b/src/components/views/rooms/HistoryTile.tsx index c52ab044a7..3aa74b8b0c 100644 --- a/src/components/views/rooms/HistoryTile.tsx +++ b/src/components/views/rooms/HistoryTile.tsx @@ -6,15 +6,15 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only Please see LICENSE files in the repository root for full details. */ -import React, { useContext } from "react"; +import React from "react"; import { EventTimeline } from "matrix-js-sdk/src/matrix"; import EventTileBubble from "../messages/EventTileBubble"; -import RoomContext from "../../../contexts/RoomContext"; import { _t } from "../../../languageHandler"; +import { useScopedRoomContext } from "../../../contexts/ScopedRoomContext.tsx"; const HistoryTile: React.FC = () => { - const { room } = useContext(RoomContext); + const { room } = useScopedRoomContext("room"); const oldState = room?.getLiveTimeline().getState(EventTimeline.BACKWARDS); const historyState = oldState?.getStateEvents("m.room.history_visibility")[0]?.getContent().history_visibility; diff --git a/src/components/views/rooms/MemberList.tsx b/src/components/views/rooms/MemberList.tsx index e503ce2363..5587b56bf8 100644 --- a/src/components/views/rooms/MemberList.tsx +++ b/src/components/views/rooms/MemberList.tsx @@ -75,7 +75,7 @@ export default class MemberList extends React.Component { private unmounted = false; public static contextType = SDKContext; - public declare context: React.ContextType; + declare public context: React.ContextType; private tiles: Map = new Map(); public constructor(props: IProps, context: React.ContextType) { diff --git a/src/components/views/rooms/MessageComposer.tsx b/src/components/views/rooms/MessageComposer.tsx index 27189000d1..f5716d728b 100644 --- a/src/components/views/rooms/MessageComposer.tsx +++ b/src/components/views/rooms/MessageComposer.tsx @@ -48,14 +48,9 @@ import MessageComposerButtons from "./MessageComposerButtons"; import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton"; import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; import { isLocalRoom } from "../../../utils/localRoom/isLocalRoom"; -import { Features } from "../../../settings/Settings"; import { VoiceMessageRecording } from "../../../audio/VoiceMessageRecording"; import { SendWysiwygComposer, sendMessage, getConversionFunctions } from "./wysiwyg_composer/"; import { MatrixClientProps, withMatrixClientHOC } from "../../../contexts/MatrixClientContext"; -import { setUpVoiceBroadcastPreRecording } from "../../../voice-broadcast/utils/setUpVoiceBroadcastPreRecording"; -import { SdkContextClass } from "../../../contexts/SDKContext"; -import { VoiceBroadcastInfoState } from "../../../voice-broadcast"; -import { createCantStartVoiceMessageBroadcastDialog } from "../dialogs/CantStartVoiceMessageBroadcastDialog"; import { UIFeature } from "../../../settings/UIFeature"; import { formatTimeLeft } from "../../../DateUtils"; import RoomReplacedSvg from "../../../../res/img/room_replaced.svg"; @@ -101,7 +96,6 @@ interface IState { isStickerPickerOpen: boolean; showStickersButton: boolean; showPollsButton: boolean; - showVoiceBroadcastButton: boolean; isWysiwygLabEnabled: boolean; isRichTextEnabled: boolean; initialComposerContent: string; @@ -123,11 +117,10 @@ export class MessageComposer extends React.Component { private _voiceRecording: Optional; public static contextType = RoomContext; - public declare context: React.ContextType; + declare public context: React.ContextType; public static defaultProps = { compact: false, - showVoiceBroadcastButton: false, isRichTextEnabled: true, }; @@ -155,7 +148,6 @@ export class MessageComposer extends React.Component { isStickerPickerOpen: false, showStickersButton: SettingsStore.getValue("MessageComposerInput.showStickersButton"), showPollsButton: SettingsStore.getValue("MessageComposerInput.showPollsButton"), - showVoiceBroadcastButton: SettingsStore.getValue(Features.VoiceBroadcast), isWysiwygLabEnabled: isWysiwygLabEnabled, isRichTextEnabled: isRichTextEnabled, initialComposerContent: initialComposerContent, @@ -250,7 +242,6 @@ export class MessageComposer extends React.Component { SettingsStore.monitorSetting("MessageComposerInput.showStickersButton", null); SettingsStore.monitorSetting("MessageComposerInput.showPollsButton", null); - SettingsStore.monitorSetting(Features.VoiceBroadcast, null); SettingsStore.monitorSetting("feature_wysiwyg_composer", null); this.dispatcherRef = dis.register(this.onAction); @@ -301,12 +292,6 @@ export class MessageComposer extends React.Component { } break; } - case Features.VoiceBroadcast: { - if (this.state.showVoiceBroadcastButton !== settingUpdatedPayload.newValue) { - this.setState({ showVoiceBroadcastButton: !!settingUpdatedPayload.newValue }); - } - break; - } case "feature_wysiwyg_composer": { if (this.state.isWysiwygLabEnabled !== settingUpdatedPayload.newValue) { this.setState({ isWysiwygLabEnabled: Boolean(settingUpdatedPayload.newValue) }); @@ -533,13 +518,7 @@ export class MessageComposer extends React.Component { } private onRecordStartEndClick = (): void => { - const currentBroadcastRecording = SdkContextClass.instance.voiceBroadcastRecordingsStore.getCurrent(); - - if (currentBroadcastRecording && currentBroadcastRecording.getState() !== VoiceBroadcastInfoState.Stopped) { - createCantStartVoiceMessageBroadcastDialog(); - } else { - this.voiceRecordingButton.current?.onRecordStartEndClick(); - } + this.voiceRecordingButton.current?.onRecordStartEndClick(); if (this.context.narrow) { this.toggleButtonMenu(); @@ -698,17 +677,6 @@ export class MessageComposer extends React.Component { isRichTextEnabled={this.state.isRichTextEnabled} onComposerModeClick={this.onRichTextToggle} toggleButtonMenu={this.toggleButtonMenu} - showVoiceBroadcastButton={this.state.showVoiceBroadcastButton} - onStartVoiceBroadcastClick={() => { - setUpVoiceBroadcastPreRecording( - this.props.room, - MatrixClientPeg.safeGet(), - SdkContextClass.instance.voiceBroadcastPlaybacksStore, - SdkContextClass.instance.voiceBroadcastRecordingsStore, - SdkContextClass.instance.voiceBroadcastPreRecordingStore, - ); - this.toggleButtonMenu(); - }} /> )} {showSendButton && ( diff --git a/src/components/views/rooms/MessageComposerButtons.tsx b/src/components/views/rooms/MessageComposerButtons.tsx index 003c2afed9..19b86834dd 100644 --- a/src/components/views/rooms/MessageComposerButtons.tsx +++ b/src/components/views/rooms/MessageComposerButtons.tsx @@ -21,7 +21,6 @@ import PollCreateDialog from "../elements/PollCreateDialog"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; import ContentMessages from "../../../ContentMessages"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; -import RoomContext from "../../../contexts/RoomContext"; import { useDispatcher } from "../../../hooks/useDispatcher"; import { chromeFileInputFix } from "../../../utils/BrowserWorkarounds"; import IconizedContextMenu, { IconizedContextMenuOptionList } from "../context_menus/IconizedContextMenu"; @@ -29,6 +28,7 @@ import { EmojiButton } from "./EmojiButton"; import { filterBoolean } from "../../../utils/arrays"; import { useSettingValue } from "../../../hooks/useSettings"; import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton"; +import { useScopedRoomContext } from "../../../contexts/ScopedRoomContext.tsx"; interface IProps { addEmoji: (emoji: string) => boolean; @@ -43,8 +43,6 @@ interface IProps { showPollsButton: boolean; showStickersButton: boolean; toggleButtonMenu: () => void; - showVoiceBroadcastButton: boolean; - onStartVoiceBroadcastClick: () => void; isRichTextEnabled: boolean; onComposerModeClick: () => void; } @@ -54,7 +52,7 @@ export const OverflowMenuContext = createContext(null const MessageComposerButtons: React.FC = (props: IProps) => { const matrixClient = useContext(MatrixClientContext); - const { room, narrow } = useContext(RoomContext); + const { room, narrow } = useScopedRoomContext("room", "narrow"); const isWysiwygLabEnabled = useSettingValue("feature_wysiwyg_composer"); @@ -80,7 +78,6 @@ const MessageComposerButtons: React.FC = (props: IProps) => { uploadButton(), // props passed via UploadButtonContext showStickersButton(props), voiceRecordingButton(props, narrow), - startVoiceBroadcastButton(props), props.showPollsButton ? pollButton(room, props.relation) : null, showLocationButton(props, room, matrixClient), ]; @@ -100,7 +97,6 @@ const MessageComposerButtons: React.FC = (props: IProps) => { moreButtons = [ showStickersButton(props), voiceRecordingButton(props, narrow), - startVoiceBroadcastButton(props), props.showPollsButton ? pollButton(room, props.relation) : null, showLocationButton(props, room, matrixClient), ]; @@ -168,7 +164,7 @@ interface IUploadButtonProps { // We put the file input outside the UploadButton component so that it doesn't get killed when the context menu closes. const UploadButtonContextProvider: React.FC = ({ roomId, relation, children }) => { const cli = useContext(MatrixClientContext); - const roomContext = useContext(RoomContext); + const roomContext = useScopedRoomContext("timelineRenderingType"); const uploadInput = useRef(null); const onUploadClick = (): void => { @@ -254,18 +250,6 @@ function showStickersButton(props: IProps): ReactElement | null { ) : null; } -const startVoiceBroadcastButton: React.FC = (props: IProps): ReactElement | null => { - return props.showVoiceBroadcastButton ? ( - - ) : null; -}; - function voiceRecordingButton(props: IProps, narrow: boolean): ReactElement | null { // XXX: recording UI does not work well in narrow mode, so hide for now return narrow ? null : ( @@ -290,7 +274,7 @@ interface IPollButtonProps { class PollButton extends React.PureComponent { public static contextType = OverflowMenuContext; - public declare context: React.ContextType; + declare public context: React.ContextType; private onCreateClick = (): void => { this.context?.(); // close overflow menu diff --git a/src/components/views/rooms/MessageComposerFormatBar.tsx b/src/components/views/rooms/MessageComposerFormatBar.tsx index 34798cc608..0ab359d9dd 100644 --- a/src/components/views/rooms/MessageComposerFormatBar.tsx +++ b/src/components/views/rooms/MessageComposerFormatBar.tsx @@ -33,6 +33,12 @@ interface IState { export default class MessageComposerFormatBar extends React.PureComponent { private readonly formatBarRef = createRef(); + /** + * The height of the format bar in pixels. + * Height 32px + 2px border + * @private + */ + private readonly BAR_HEIGHT = 34; public constructor(props: IProps) { super(props); @@ -96,7 +102,7 @@ export default class MessageComposerFormatBar extends React.PureComponent { const cli = useContext(MatrixClientContext); - const { room, roomId } = useContext(RoomContext); + const { room, roomId } = useScopedRoomContext("room", "roomId"); if (!room || !roomId) { throw new Error("Unable to create a NewRoomIntro without room and roomId"); diff --git a/src/components/views/rooms/ReplyPreview.tsx b/src/components/views/rooms/ReplyPreview.tsx index c820154b2b..7851f7914d 100644 --- a/src/components/views/rooms/ReplyPreview.tsx +++ b/src/components/views/rooms/ReplyPreview.tsx @@ -31,7 +31,7 @@ interface IProps { export default class ReplyPreview extends React.Component { public static contextType = RoomContext; - public declare context: React.ContextType; + declare public context: React.ContextType; public render(): JSX.Element | null { if (!this.props.replyToEvent) return null; diff --git a/src/components/views/rooms/RoomHeader.tsx b/src/components/views/rooms/RoomHeader.tsx index c2642ea733..d133587fc9 100644 --- a/src/components/views/rooms/RoomHeader.tsx +++ b/src/components/views/rooms/RoomHeader.tsx @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only Please see LICENSE files in the repository root for full details. */ -import React, { useCallback, useContext, useMemo, useState } from "react"; +import React, { useCallback, useMemo, useState } from "react"; import { Body as BodyText, Button, IconButton, Menu, MenuItem, Tooltip } from "@vector-im/compound-web"; import VideoCallIcon from "@vector-im/compound-design-tokens/assets/web/icons/video-call-solid"; import VoiceCallIcon from "@vector-im/compound-design-tokens/assets/web/icons/voice-call"; @@ -48,10 +48,10 @@ import { CallGuestLinkButton } from "./RoomHeader/CallGuestLinkButton"; import { ButtonEvent } from "../elements/AccessibleButton"; import WithPresenceIndicator, { useDmMember } from "../avatars/WithPresenceIndicator"; import { IOOBData } from "../../../stores/ThreepidInviteStore"; -import RoomContext from "../../../contexts/RoomContext"; import { MainSplitContentType } from "../../structures/RoomView"; import defaultDispatcher from "../../../dispatcher/dispatcher.ts"; import { RoomSettingsTab } from "../dialogs/RoomSettingsDialog.tsx"; +import { useScopedRoomContext } from "../../../contexts/ScopedRoomContext.tsx"; export default function RoomHeader({ room, @@ -229,7 +229,7 @@ export default function RoomHeader({ voiceCallButton = undefined; } - const roomContext = useContext(RoomContext); + const roomContext = useScopedRoomContext("mainSplitContentType"); const isVideoRoom = calcIsVideoRoom(room); const showChatButton = isVideoRoom || @@ -392,7 +392,7 @@ export default function RoomHeader({ viewUserOnClick={false} tooltipLabel={_t("room|header_face_pile_tooltip")} onClick={(e: ButtonEvent) => { - RightPanelStore.instance.showOrHidePhase(RightPanelPhases.RoomMemberList); + RightPanelStore.instance.showOrHidePhase(RightPanelPhases.MemberList); e.stopPropagation(); }} aria-label={_t("common|n_members", { count: memberCount })} diff --git a/src/components/views/rooms/RoomHeader/CallGuestLinkButton.tsx b/src/components/views/rooms/RoomHeader/CallGuestLinkButton.tsx index ae8e7be16b..8c000bdf3b 100644 --- a/src/components/views/rooms/RoomHeader/CallGuestLinkButton.tsx +++ b/src/components/views/rooms/RoomHeader/CallGuestLinkButton.tsx @@ -12,7 +12,7 @@ import { logger } from "matrix-js-sdk/src/logger"; import { EventType, JoinRule, Room } from "matrix-js-sdk/src/matrix"; import Modal from "../../../../Modal"; -import ShareDialog from "../../dialogs/ShareDialog"; +import { ShareDialog } from "../../dialogs/ShareDialog"; import { _t } from "../../../../languageHandler"; import SettingsStore from "../../../../settings/SettingsStore"; import { calculateRoomVia } from "../../../../utils/permalinks/Permalinks"; diff --git a/src/components/views/rooms/RoomInfoLine.tsx b/src/components/views/rooms/RoomInfoLine.tsx index 710ef61758..1487bfe15b 100644 --- a/src/components/views/rooms/RoomInfoLine.tsx +++ b/src/components/views/rooms/RoomInfoLine.tsx @@ -64,7 +64,7 @@ const RoomInfoLine: FC = ({ room }) => { // summary is not still loading const viewMembers = (): void => RightPanelStore.instance.setCard({ - phase: room.isSpaceRoom() ? RightPanelPhases.SpaceMemberList : RightPanelPhases.RoomMemberList, + phase: RightPanelPhases.MemberList, }); members = ( diff --git a/src/components/views/rooms/RoomList.tsx b/src/components/views/rooms/RoomList.tsx index 853bebc4fe..f3bde66af9 100644 --- a/src/components/views/rooms/RoomList.tsx +++ b/src/components/views/rooms/RoomList.tsx @@ -424,7 +424,7 @@ export default class RoomList extends React.PureComponent { private treeRef = createRef(); public static contextType = MatrixClientContext; - public declare context: React.ContextType; + declare public context: React.ContextType; public constructor(props: IProps, context: React.ContextType) { super(props, context); diff --git a/src/components/views/rooms/RoomTile.tsx b/src/components/views/rooms/RoomTile.tsx index 8351c176ff..7953c5068d 100644 --- a/src/components/views/rooms/RoomTile.tsx +++ b/src/components/views/rooms/RoomTile.tsx @@ -39,7 +39,6 @@ import { getKeyBindingsManager } from "../../../KeyBindingsManager"; import { RoomGeneralContextMenu } from "../context_menus/RoomGeneralContextMenu"; import { CallStore, CallStoreEvent } from "../../../stores/CallStore"; import { SdkContextClass } from "../../../contexts/SDKContext"; -import { useHasRoomLiveVoiceBroadcast } from "../../../voice-broadcast"; import { RoomTileSubtitle } from "./RoomTileSubtitle"; import { shouldShowComponent } from "../../../customisations/helpers/UIComponents"; import { UIComponent } from "../../../settings/UIFeature"; @@ -53,10 +52,6 @@ interface Props { tag: TagID; } -interface ClassProps extends Props { - hasLiveVoiceBroadcast: boolean; -} - type PartialDOMRect = Pick; interface State { @@ -77,13 +72,13 @@ export const contextMenuBelow = (elementRect: PartialDOMRect): MenuProps => { return { left, top, chevronFace }; }; -export class RoomTile extends React.PureComponent { +class RoomTile extends React.PureComponent { private dispatcherRef?: string; private roomTileRef = createRef(); private notificationState: NotificationState; private roomProps: RoomEchoChamber; - public constructor(props: ClassProps) { + public constructor(props: Props) { super(props); this.state = { @@ -370,15 +365,10 @@ export class RoomTile extends React.PureComponent { /** * RoomTile has a subtile if one of the following applies: * - there is a call - * - there is a live voice broadcast * - message previews are enabled and there is a previewable message */ private get shouldRenderSubtitle(): boolean { - return ( - !!this.state.call || - this.props.hasLiveVoiceBroadcast || - (this.props.showMessagePreview && !!this.state.messagePreview) - ); + return !!this.state.call || (this.props.showMessagePreview && !!this.state.messagePreview); } public render(): React.ReactElement { @@ -409,7 +399,6 @@ export class RoomTile extends React.PureComponent { const subtitle = this.shouldRenderSubtitle ? ( { } } -const RoomTileHOC: React.FC = (props: Props) => { - const hasLiveVoiceBroadcast = useHasRoomLiveVoiceBroadcast(props.room); - return ; -}; - -export default RoomTileHOC; +export default RoomTile; diff --git a/src/components/views/rooms/RoomTileSubtitle.tsx b/src/components/views/rooms/RoomTileSubtitle.tsx index ea4a96d259..479b9c4f71 100644 --- a/src/components/views/rooms/RoomTileSubtitle.tsx +++ b/src/components/views/rooms/RoomTileSubtitle.tsx @@ -13,11 +13,9 @@ import { ThreadsIcon } from "@vector-im/compound-design-tokens/assets/web/icons" import { MessagePreview } from "../../../stores/room-list/MessagePreviewStore"; import { Call } from "../../../models/Call"; import { RoomTileCallSummary } from "./RoomTileCallSummary"; -import { VoiceBroadcastRoomSubtitle } from "../../../voice-broadcast"; interface Props { call: Call | null; - hasLiveVoiceBroadcast: boolean; messagePreview: MessagePreview | null; roomId: string; showMessagePreview: boolean; @@ -25,13 +23,7 @@ interface Props { const messagePreviewId = (roomId: string): string => `mx_RoomTile_messagePreview_${roomId}`; -export const RoomTileSubtitle: React.FC = ({ - call, - hasLiveVoiceBroadcast, - messagePreview, - roomId, - showMessagePreview, -}) => { +export const RoomTileSubtitle: React.FC = ({ call, messagePreview, roomId, showMessagePreview }) => { if (call) { return (
@@ -40,10 +32,6 @@ export const RoomTileSubtitle: React.FC = ({ ); } - if (hasLiveVoiceBroadcast) { - return ; - } - if (showMessagePreview && messagePreview) { const className = classNames("mx_RoomTile_subtitle", { "mx_RoomTile_subtitle--thread-reply": messagePreview.isThreadReply, diff --git a/src/components/views/rooms/RoomUpgradeWarningBar.tsx b/src/components/views/rooms/RoomUpgradeWarningBar.tsx index 66519fa766..e92be96cb2 100644 --- a/src/components/views/rooms/RoomUpgradeWarningBar.tsx +++ b/src/components/views/rooms/RoomUpgradeWarningBar.tsx @@ -25,7 +25,7 @@ interface IState { export default class RoomUpgradeWarningBar extends React.PureComponent { public static contextType = MatrixClientContext; - public declare context: React.ContextType; + declare public context: React.ContextType; public constructor(props: IProps, context: React.ContextType) { super(props, context); diff --git a/src/components/views/rooms/SearchResultTile.tsx b/src/components/views/rooms/SearchResultTile.tsx index 5ebbaffdd9..94f5e6da9d 100644 --- a/src/components/views/rooms/SearchResultTile.tsx +++ b/src/components/views/rooms/SearchResultTile.tsx @@ -36,7 +36,7 @@ interface IProps { export default class SearchResultTile extends React.Component { public static contextType = RoomContext; - public declare context: React.ContextType; + declare public context: React.ContextType; // A map of private callEventGroupers = new Map(); diff --git a/src/components/views/rooms/SendMessageComposer.tsx b/src/components/views/rooms/SendMessageComposer.tsx index 252957c2c7..b3767cbd2a 100644 --- a/src/components/views/rooms/SendMessageComposer.tsx +++ b/src/components/views/rooms/SendMessageComposer.tsx @@ -193,7 +193,6 @@ export function createMessageContent( body: body, }; const formattedBody = htmlSerializeIfNeeded(model, { - forceHTML: !!replyToEvent, useMarkdown: SettingsStore.getValue("MessageComposerInput.useMarkdown"), }); if (formattedBody) { @@ -241,7 +240,7 @@ interface ISendMessageComposerProps extends MatrixClientProps { export class SendMessageComposer extends React.Component { public static contextType = RoomContext; - public declare context: React.ContextType; + declare public context: React.ContextType; private readonly prepareToEncrypt?: DebouncedFunc<() => void>; private readonly editorRef = createRef(); diff --git a/src/components/views/rooms/ThreadSummary.tsx b/src/components/views/rooms/ThreadSummary.tsx index 4a3032d641..ac23331f66 100644 --- a/src/components/views/rooms/ThreadSummary.tsx +++ b/src/components/views/rooms/ThreadSummary.tsx @@ -16,7 +16,6 @@ import { CardContext } from "../right_panel/context"; import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton"; import PosthogTrackers from "../../../PosthogTrackers"; import { useTypedEventEmitterState } from "../../../hooks/useEventEmitter"; -import RoomContext from "../../../contexts/RoomContext"; import MemberAvatar from "../avatars/MemberAvatar"; import { Action } from "../../../dispatcher/actions"; import { ShowThreadPayload } from "../../../dispatcher/payloads/ShowThreadPayload"; @@ -24,6 +23,7 @@ import defaultDispatcher from "../../../dispatcher/dispatcher"; import { useUnreadNotifications } from "../../../hooks/useUnreadNotifications"; import { notificationLevelToIndicator } from "../../../utils/notifications"; import { EventPreviewTile, useEventPreview } from "./EventPreview.tsx"; +import { useScopedRoomContext } from "../../../contexts/ScopedRoomContext.tsx"; interface IProps { mxEvent: MatrixEvent; @@ -31,7 +31,7 @@ interface IProps { } const ThreadSummary: React.FC = ({ mxEvent, thread, ...props }) => { - const roomContext = useContext(RoomContext); + const roomContext = useScopedRoomContext("narrow"); const cardContext = useContext(CardContext); const count = useTypedEventEmitterState(thread, ThreadEvent.Update, () => thread.length); const { level } = useUnreadNotifications(thread.room, thread.id); diff --git a/src/components/views/rooms/VoiceRecordComposerTile.tsx b/src/components/views/rooms/VoiceRecordComposerTile.tsx index a6d4a2fc27..a8335a9902 100644 --- a/src/components/views/rooms/VoiceRecordComposerTile.tsx +++ b/src/components/views/rooms/VoiceRecordComposerTile.tsx @@ -53,7 +53,7 @@ interface IState { */ export default class VoiceRecordComposerTile extends React.PureComponent { public static contextType = RoomContext; - public declare context: React.ContextType; + declare public context: React.ContextType; private voiceRecordingId: string; public constructor(props: IProps, context: React.ContextType) { diff --git a/src/components/views/rooms/wysiwyg_composer/components/Emoji.tsx b/src/components/views/rooms/wysiwyg_composer/components/Emoji.tsx index b7a6d65e23..9ab3d210ab 100644 --- a/src/components/views/rooms/wysiwyg_composer/components/Emoji.tsx +++ b/src/components/views/rooms/wysiwyg_composer/components/Emoji.tsx @@ -13,14 +13,14 @@ import { EmojiButton } from "../../EmojiButton"; import dis from "../../../../../dispatcher/dispatcher"; import { ComposerInsertPayload } from "../../../../../dispatcher/payloads/ComposerInsertPayload"; import { Action } from "../../../../../dispatcher/actions"; -import { useRoomContext } from "../../../../../contexts/RoomContext"; +import { useScopedRoomContext } from "../../../../../contexts/ScopedRoomContext.tsx"; interface EmojiProps { menuPosition: MenuProps; } export function Emoji({ menuPosition }: EmojiProps): JSX.Element { - const roomContext = useRoomContext(); + const roomContext = useScopedRoomContext("timelineRenderingType"); return ( , ): JSX.Element | null => { - const { room } = useRoomContext(); + const { room } = useScopedRoomContext("room"); const client = useMatrixClientContext(); function handleConfirm(completion: ICompletion): void { diff --git a/src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer.tsx b/src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer.tsx index 5b6361a58e..f1e42ce091 100644 --- a/src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer.tsx +++ b/src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer.tsx @@ -19,12 +19,12 @@ import { Editor } from "./Editor"; import { useInputEventProcessor } from "../hooks/useInputEventProcessor"; import { useSetCursorPosition } from "../hooks/useSetCursorPosition"; import { useIsFocused } from "../hooks/useIsFocused"; -import { useRoomContext } from "../../../../../contexts/RoomContext"; import defaultDispatcher from "../../../../../dispatcher/dispatcher"; import { Action } from "../../../../../dispatcher/actions"; import { parsePermalink } from "../../../../../utils/permalinks/Permalinks"; import { isNotNull } from "../../../../../Typeguards"; import { useSettingValue } from "../../../../../hooks/useSettings"; +import { useScopedRoomContext } from "../../../../../contexts/ScopedRoomContext.tsx"; interface WysiwygComposerProps { disabled?: boolean; @@ -56,7 +56,7 @@ export const WysiwygComposer = memo(function WysiwygComposer({ children, eventRelation, }: WysiwygComposerProps) { - const { room } = useRoomContext(); + const { room } = useScopedRoomContext("room"); const autocompleteRef = useRef(null); const inputEventProcessor = useInputEventProcessor(onSend, autocompleteRef, initialContent, eventRelation); diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/useEditing.ts b/src/components/views/rooms/wysiwyg_composer/hooks/useEditing.ts index 5d1c3b867e..20f394e8a3 100644 --- a/src/components/views/rooms/wysiwyg_composer/hooks/useEditing.ts +++ b/src/components/views/rooms/wysiwyg_composer/hooks/useEditing.ts @@ -10,10 +10,10 @@ import { ISendEventResponse } from "matrix-js-sdk/src/matrix"; import { useCallback, useState } from "react"; import { useMatrixClientContext } from "../../../../../contexts/MatrixClientContext"; -import { useRoomContext } from "../../../../../contexts/RoomContext"; import EditorStateTransfer from "../../../../../utils/EditorStateTransfer"; import { endEditing } from "../utils/editing"; import { editMessage } from "../utils/message"; +import { useScopedRoomContext } from "../../../../../contexts/ScopedRoomContext.tsx"; export function useEditing( editorStateTransfer: EditorStateTransfer, @@ -24,7 +24,7 @@ export function useEditing( editMessage(): Promise; endEditing(): void; } { - const roomContext = useRoomContext(); + const roomContext = useScopedRoomContext("timelineRenderingType"); const mxClient = useMatrixClientContext(); const [isSaveDisabled, setIsSaveDisabled] = useState(true); diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/useInitialContent.ts b/src/components/views/rooms/wysiwyg_composer/hooks/useInitialContent.ts index 2e0ddd3ccd..3a3799496b 100644 --- a/src/components/views/rooms/wysiwyg_composer/hooks/useInitialContent.ts +++ b/src/components/views/rooms/wysiwyg_composer/hooks/useInitialContent.ts @@ -10,11 +10,11 @@ import { MatrixClient, Room } from "matrix-js-sdk/src/matrix"; import { useMemo } from "react"; import { useMatrixClientContext } from "../../../../../contexts/MatrixClientContext"; -import { useRoomContext } from "../../../../../contexts/RoomContext"; import { parseEvent } from "../../../../../editor/deserialize"; import { CommandPartCreator, Part } from "../../../../../editor/parts"; import SettingsStore from "../../../../../settings/SettingsStore"; import EditorStateTransfer from "../../../../../utils/EditorStateTransfer"; +import { useScopedRoomContext } from "../../../../../contexts/ScopedRoomContext.tsx"; function getFormattedContent(editorStateTransfer: EditorStateTransfer): string { return ( @@ -60,12 +60,12 @@ export function parseEditorStateTransfer( } export function useInitialContent(editorStateTransfer: EditorStateTransfer): string | undefined { - const roomContext = useRoomContext(); + const { room } = useScopedRoomContext("room"); const mxClient = useMatrixClientContext(); return useMemo(() => { - if (editorStateTransfer && roomContext.room && mxClient) { - return parseEditorStateTransfer(editorStateTransfer, roomContext.room, mxClient); + if (editorStateTransfer && room && mxClient) { + return parseEditorStateTransfer(editorStateTransfer, room, mxClient); } - }, [editorStateTransfer, roomContext, mxClient]); + }, [editorStateTransfer, room, mxClient]); } diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/useInputEventProcessor.ts b/src/components/views/rooms/wysiwyg_composer/hooks/useInputEventProcessor.ts index 8eac63eb36..cab3bdefb8 100644 --- a/src/components/views/rooms/wysiwyg_composer/hooks/useInputEventProcessor.ts +++ b/src/components/views/rooms/wysiwyg_composer/hooks/useInputEventProcessor.ts @@ -16,7 +16,6 @@ import { KeyBindingAction } from "../../../../../accessibility/KeyboardShortcuts import { findEditableEvent } from "../../../../../utils/EventUtils"; import dis from "../../../../../dispatcher/dispatcher"; import { Action } from "../../../../../dispatcher/actions"; -import { useRoomContext } from "../../../../../contexts/RoomContext"; import { IRoomState } from "../../../../structures/RoomView"; import { ComposerContextState, useComposerContext } from "../ComposerContext"; import EditorStateTransfer from "../../../../../utils/EditorStateTransfer"; @@ -26,6 +25,7 @@ import { getEventsFromEditorStateTransfer, getEventsFromRoom } from "../utils/ev import { endEditing } from "../utils/editing"; import Autocomplete from "../../Autocomplete"; import { handleClipboardEvent, handleEventWithAutocomplete, isEventToHandleAsClipboardEvent } from "./utils"; +import { useScopedRoomContext } from "../../../../../contexts/ScopedRoomContext.tsx"; export function useInputEventProcessor( onSend: () => void, @@ -33,7 +33,7 @@ export function useInputEventProcessor( initialContent?: string, eventRelation?: IEventRelation, ): (event: WysiwygEvent, composer: Wysiwyg, editor: HTMLElement) => WysiwygEvent | null { - const roomContext = useRoomContext(); + const roomContext = useScopedRoomContext("liveTimeline", "room", "replyToEvent", "timelineRenderingType"); const composerContext = useComposerContext(); const mxClient = useMatrixClientContext(); const isCtrlEnterToSend = useSettingValue("MessageComposerInput.ctrlEnterToSend"); @@ -94,7 +94,7 @@ function handleKeyboardEvent( initialContent: string | undefined, composer: Wysiwyg, editor: HTMLElement, - roomContext: IRoomState, + roomContext: Pick, composerContext: ComposerContextState, mxClient: MatrixClient | undefined, autocompleteRef: React.RefObject, @@ -175,7 +175,7 @@ function dispatchEditEvent( isForward: boolean, editorStateTransfer: EditorStateTransfer | undefined, composerContext: ComposerContextState, - roomContext: IRoomState, + roomContext: Pick, mxClient: MatrixClient, ): boolean { const foundEvents = editorStateTransfer diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/usePlainTextListeners.ts b/src/components/views/rooms/wysiwyg_composer/hooks/usePlainTextListeners.ts index 742d24fe34..1dc23cc274 100644 --- a/src/components/views/rooms/wysiwyg_composer/hooks/usePlainTextListeners.ts +++ b/src/components/views/rooms/wysiwyg_composer/hooks/usePlainTextListeners.ts @@ -16,8 +16,8 @@ import Autocomplete from "../../Autocomplete"; import { handleClipboardEvent, handleEventWithAutocomplete, isEventToHandleAsClipboardEvent } from "./utils"; import { useSuggestion } from "./useSuggestion"; import { isNotNull, isNotUndefined } from "../../../../../Typeguards"; -import { useRoomContext } from "../../../../../contexts/RoomContext"; import { useMatrixClientContext } from "../../../../../contexts/MatrixClientContext"; +import { useScopedRoomContext } from "../../../../../contexts/ScopedRoomContext.tsx"; function isDivElement(target: EventTarget): target is HTMLDivElement { return target instanceof HTMLDivElement; @@ -63,7 +63,7 @@ export function usePlainTextListeners( onSelect: (event: SyntheticEvent) => void; suggestion: MappedSuggestion | null; } { - const roomContext = useRoomContext(); + const roomContext = useScopedRoomContext("room", "timelineRenderingType", "replyToEvent"); const mxClient = useMatrixClientContext(); const ref = useRef(null); diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/useWysiwygEditActionHandler.ts b/src/components/views/rooms/wysiwyg_composer/hooks/useWysiwygEditActionHandler.ts index e1e34623c8..eb76d77af5 100644 --- a/src/components/views/rooms/wysiwyg_composer/hooks/useWysiwygEditActionHandler.ts +++ b/src/components/views/rooms/wysiwyg_composer/hooks/useWysiwygEditActionHandler.ts @@ -11,20 +11,21 @@ import { RefObject, useCallback, useRef } from "react"; import defaultDispatcher from "../../../../../dispatcher/dispatcher"; import { Action } from "../../../../../dispatcher/actions"; import { ActionPayload } from "../../../../../dispatcher/payloads"; -import { TimelineRenderingType, useRoomContext } from "../../../../../contexts/RoomContext"; +import { TimelineRenderingType } from "../../../../../contexts/RoomContext"; import { useDispatcher } from "../../../../../hooks/useDispatcher"; import { focusComposer } from "./utils"; import { ComposerType } from "../../../../../dispatcher/payloads/ComposerInsertPayload"; import { ComposerFunctions } from "../types"; import { setSelection } from "../utils/selection"; import { useComposerContext } from "../ComposerContext"; +import { useScopedRoomContext } from "../../../../../contexts/ScopedRoomContext.tsx"; export function useWysiwygEditActionHandler( disabled: boolean, composerElement: RefObject, composerFunctions: ComposerFunctions, ): void { - const roomContext = useRoomContext(); + const roomContext = useScopedRoomContext("timelineRenderingType"); const composerContext = useComposerContext(); const timeoutId = useRef(null); diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/useWysiwygSendActionHandler.ts b/src/components/views/rooms/wysiwyg_composer/hooks/useWysiwygSendActionHandler.ts index 16e1e608ec..d11f3498fd 100644 --- a/src/components/views/rooms/wysiwyg_composer/hooks/useWysiwygSendActionHandler.ts +++ b/src/components/views/rooms/wysiwyg_composer/hooks/useWysiwygSendActionHandler.ts @@ -11,20 +11,21 @@ import { MutableRefObject, useCallback, useRef } from "react"; import defaultDispatcher from "../../../../../dispatcher/dispatcher"; import { Action } from "../../../../../dispatcher/actions"; import { ActionPayload } from "../../../../../dispatcher/payloads"; -import { TimelineRenderingType, useRoomContext } from "../../../../../contexts/RoomContext"; +import { TimelineRenderingType } from "../../../../../contexts/RoomContext"; import { useDispatcher } from "../../../../../hooks/useDispatcher"; import { focusComposer } from "./utils"; import { ComposerFunctions } from "../types"; import { ComposerType } from "../../../../../dispatcher/payloads/ComposerInsertPayload"; import { useComposerContext } from "../ComposerContext"; import { setSelection } from "../utils/selection"; +import { useScopedRoomContext } from "../../../../../contexts/ScopedRoomContext.tsx"; export function useWysiwygSendActionHandler( disabled: boolean, composerElement: MutableRefObject, composerFunctions: ComposerFunctions, ): void { - const roomContext = useRoomContext(); + const roomContext = useScopedRoomContext("timelineRenderingType"); const composerContext = useComposerContext(); const timeoutId = useRef(null); diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/utils.ts b/src/components/views/rooms/wysiwyg_composer/hooks/utils.ts index 39317ea88c..3345c9f474 100644 --- a/src/components/views/rooms/wysiwyg_composer/hooks/utils.ts +++ b/src/components/views/rooms/wysiwyg_composer/hooks/utils.ts @@ -22,7 +22,7 @@ import { isNotNull } from "../../../../../Typeguards"; export function focusComposer( composerElement: MutableRefObject, renderingType: TimelineRenderingType, - roomContext: IRoomState, + roomContext: Pick, timeoutId: MutableRefObject, ): void { if (renderingType === roomContext.timelineRenderingType) { @@ -123,7 +123,7 @@ export function handleEventWithAutocomplete( export function handleClipboardEvent( event: ClipboardEvent | InputEvent, data: DataTransfer | null, - roomContext: IRoomState, + roomContext: Pick, mxClient: MatrixClient, eventRelation?: IEventRelation, ): boolean { diff --git a/src/components/views/rooms/wysiwyg_composer/utils/createMessageContent.ts b/src/components/views/rooms/wysiwyg_composer/utils/createMessageContent.ts index 7f42ed2327..58d09b3d12 100644 --- a/src/components/views/rooms/wysiwyg_composer/utils/createMessageContent.ts +++ b/src/components/views/rooms/wysiwyg_composer/utils/createMessageContent.ts @@ -27,28 +27,6 @@ function attachRelation(content: IContent, relation?: IEventRelation): void { } } -function getHtmlReplyFallback(mxEvent: MatrixEvent): string { - const html = mxEvent.getContent().formatted_body; - if (!html) { - return ""; - } - const rootNode = new DOMParser().parseFromString(html, "text/html").body; - const mxReply = rootNode.querySelector("mx-reply"); - return (mxReply && mxReply.outerHTML) || ""; -} - -function getTextReplyFallback(mxEvent: MatrixEvent): string { - const body = mxEvent.getContent().body; - if (typeof body !== "string") { - return ""; - } - const lines = body.split("\n").map((l) => l.trim()); - if (lines.length > 2 && lines[0].startsWith("> ") && lines[1].length === 0) { - return `${lines[0]}\n\n`; - } - return ""; -} - interface CreateMessageContentParams { relation?: IEventRelation; replyToEvent?: MatrixEvent; @@ -63,8 +41,6 @@ export async function createMessageContent( { relation, replyToEvent, editedEvent }: CreateMessageContentParams, ): Promise { const isEditing = isMatrixEvent(editedEvent); - const isReply = isEditing ? Boolean(editedEvent.replyEventId) : isMatrixEvent(replyToEvent); - const isReplyAndEditing = isEditing && isReply; const isEmote = message.startsWith(EMOTE_PREFIX); if (isEmote) { @@ -82,12 +58,10 @@ export async function createMessageContent( // if we're editing rich text, the message content is pure html // BUT if we're not, the message content will be plain text where we need to convert the mentions const body = isHTML ? await richToPlain(message, false) : convertPlainTextToBody(message); - const bodyPrefix = (isReplyAndEditing && getTextReplyFallback(editedEvent)) || ""; - const formattedBodyPrefix = (isReplyAndEditing && getHtmlReplyFallback(editedEvent)) || ""; const content = { msgtype: isEmote ? MsgType.Emote : MsgType.Text, - body: isEditing ? `${bodyPrefix} * ${body}` : body, + body: isEditing ? `* ${body}` : body, } as RoomMessageTextEventContent & ReplacementEvent; // TODO markdown support @@ -97,7 +71,7 @@ export async function createMessageContent( if (formattedBody) { content.format = "org.matrix.custom.html"; - content.formatted_body = isEditing ? `${formattedBodyPrefix} * ${formattedBody}` : formattedBody; + content.formatted_body = isEditing ? `* ${formattedBody}` : formattedBody; } if (isEditing) { diff --git a/src/components/views/rooms/wysiwyg_composer/utils/editing.ts b/src/components/views/rooms/wysiwyg_composer/utils/editing.ts index 58a9e24492..462763b8f4 100644 --- a/src/components/views/rooms/wysiwyg_composer/utils/editing.ts +++ b/src/components/views/rooms/wysiwyg_composer/utils/editing.ts @@ -13,7 +13,7 @@ import dis from "../../../../../dispatcher/dispatcher"; import { Action } from "../../../../../dispatcher/actions"; import EditorStateTransfer from "../../../../../utils/EditorStateTransfer"; -export function endEditing(roomContext: IRoomState): void { +export function endEditing(roomContext: Pick): void { // todo local storage // localStorage.removeItem(this.editorRoomKey); // localStorage.removeItem(this.editorStateKey); diff --git a/src/components/views/rooms/wysiwyg_composer/utils/event.ts b/src/components/views/rooms/wysiwyg_composer/utils/event.ts index 5fd37b3665..45c6b1cac3 100644 --- a/src/components/views/rooms/wysiwyg_composer/utils/event.ts +++ b/src/components/views/rooms/wysiwyg_composer/utils/event.ts @@ -15,7 +15,7 @@ import { ComposerContextState } from "../ComposerContext"; // From EditMessageComposer private get events(): MatrixEvent[] export function getEventsFromEditorStateTransfer( editorStateTransfer: EditorStateTransfer, - roomContext: IRoomState, + roomContext: Pick, mxClient: MatrixClient, ): MatrixEvent[] | undefined { const liveTimelineEvents = roomContext.liveTimeline?.getEvents(); @@ -41,7 +41,7 @@ export function getEventsFromEditorStateTransfer( // From SendMessageComposer private onKeyDown = (event: KeyboardEvent): void export function getEventsFromRoom( composerContext: ComposerContextState, - roomContext: IRoomState, + roomContext: Pick, ): MatrixEvent[] | undefined { const isReplyingToThread = composerContext.eventRelation?.key === THREAD_RELATION_TYPE.name; return roomContext.liveTimeline diff --git a/src/components/views/rooms/wysiwyg_composer/utils/message.ts b/src/components/views/rooms/wysiwyg_composer/utils/message.ts index 44e10e3cc5..b7fca8ecb4 100644 --- a/src/components/views/rooms/wysiwyg_composer/utils/message.ts +++ b/src/components/views/rooms/wysiwyg_composer/utils/message.ts @@ -39,7 +39,7 @@ export interface SendMessageParams { mxClient: MatrixClient; relation?: IEventRelation; replyToEvent?: MatrixEvent; - roomContext: IRoomState; + roomContext: Pick; } export async function sendMessage( @@ -177,7 +177,7 @@ export async function sendMessage( interface EditMessageParams { mxClient: MatrixClient; - roomContext: IRoomState; + roomContext: Pick; editorStateTransfer: EditorStateTransfer; } diff --git a/src/components/views/settings/CryptographyPanel.tsx b/src/components/views/settings/CryptographyPanel.tsx index b418c0b05d..fbd696f243 100644 --- a/src/components/views/settings/CryptographyPanel.tsx +++ b/src/components/views/settings/CryptographyPanel.tsx @@ -32,7 +32,7 @@ interface IState { export default class CryptographyPanel extends React.Component { public static contextType = MatrixClientContext; - public declare context: React.ContextType; + declare public context: React.ContextType; public constructor(props: IProps, context: React.ContextType) { super(props); diff --git a/src/components/views/settings/devices/LoginWithQRSection.tsx b/src/components/views/settings/devices/LoginWithQRSection.tsx index a164ff894b..033aa8e32a 100644 --- a/src/components/views/settings/devices/LoginWithQRSection.tsx +++ b/src/components/views/settings/devices/LoginWithQRSection.tsx @@ -7,13 +7,7 @@ Please see LICENSE files in the repository root for full details. */ import React from "react"; -import { - IServerVersions, - IClientWellKnown, - OidcClientConfig, - MatrixClient, - DEVICE_CODE_SCOPE, -} from "matrix-js-sdk/src/matrix"; +import { IServerVersions, OidcClientConfig, MatrixClient, DEVICE_CODE_SCOPE } from "matrix-js-sdk/src/matrix"; import QrCodeIcon from "@vector-im/compound-design-tokens/assets/web/icons/qr-code"; import { Text } from "@vector-im/compound-web"; @@ -25,7 +19,6 @@ import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext interface IProps { onShowQr: () => void; versions?: IServerVersions; - wellKnown?: IClientWellKnown; oidcClientConfig?: OidcClientConfig; isCrossSigningReady?: boolean; } @@ -35,10 +28,8 @@ export function shouldShowQr( isCrossSigningReady: boolean, oidcClientConfig?: OidcClientConfig, versions?: IServerVersions, - wellKnown?: IClientWellKnown, ): boolean { - const msc4108Supported = - !!versions?.unstable_features?.["org.matrix.msc4108"] || !!wellKnown?.["io.element.rendezvous"]?.server; + const msc4108Supported = !!versions?.unstable_features?.["org.matrix.msc4108"]; const deviceAuthorizationGrantSupported = oidcClientConfig?.metadata?.grant_types_supported.includes(DEVICE_CODE_SCOPE); @@ -51,15 +42,9 @@ export function shouldShowQr( ); } -const LoginWithQRSection: React.FC = ({ - onShowQr, - versions, - wellKnown, - oidcClientConfig, - isCrossSigningReady, -}) => { +const LoginWithQRSection: React.FC = ({ onShowQr, versions, oidcClientConfig, isCrossSigningReady }) => { const cli = useMatrixClientContext(); - const offerShowQr = shouldShowQr(cli, !!isCrossSigningReady, oidcClientConfig, versions, wellKnown); + const offerShowQr = shouldShowQr(cli, !!isCrossSigningReady, oidcClientConfig, versions); return ( diff --git a/src/components/views/settings/tabs/room/BridgeSettingsTab.tsx b/src/components/views/settings/tabs/room/BridgeSettingsTab.tsx index d29d82853a..0da257607e 100644 --- a/src/components/views/settings/tabs/room/BridgeSettingsTab.tsx +++ b/src/components/views/settings/tabs/room/BridgeSettingsTab.tsx @@ -28,7 +28,7 @@ interface IProps { export default class BridgeSettingsTab extends React.Component { public static contextType = MatrixClientContext; - public declare context: React.ContextType; + declare public context: React.ContextType; private renderBridgeCard(event: MatrixEvent, room: Room | null): ReactNode { const content = event.getContent(); diff --git a/src/components/views/settings/tabs/room/GeneralRoomSettingsTab.tsx b/src/components/views/settings/tabs/room/GeneralRoomSettingsTab.tsx index 048fe5df9d..31c361de1b 100644 --- a/src/components/views/settings/tabs/room/GeneralRoomSettingsTab.tsx +++ b/src/components/views/settings/tabs/room/GeneralRoomSettingsTab.tsx @@ -33,7 +33,7 @@ interface IState { export default class GeneralRoomSettingsTab extends React.Component { public static contextType = MatrixClientContext; - public declare context: ContextType; + declare public context: ContextType; public constructor(props: IProps, context: ContextType) { super(props, context); diff --git a/src/components/views/settings/tabs/room/NotificationSettingsTab.tsx b/src/components/views/settings/tabs/room/NotificationSettingsTab.tsx index f668b1ff07..9aabf1edb0 100644 --- a/src/components/views/settings/tabs/room/NotificationSettingsTab.tsx +++ b/src/components/views/settings/tabs/room/NotificationSettingsTab.tsx @@ -42,7 +42,7 @@ export default class NotificationsSettingsTab extends React.Component(); public static contextType = MatrixClientContext; - public declare context: React.ContextType; + declare public context: React.ContextType; public constructor(props: IProps, context: React.ContextType) { super(props, context); diff --git a/src/components/views/settings/tabs/room/RolesRoomSettingsTab.tsx b/src/components/views/settings/tabs/room/RolesRoomSettingsTab.tsx index 8261bfd3eb..baf4b41253 100644 --- a/src/components/views/settings/tabs/room/RolesRoomSettingsTab.tsx +++ b/src/components/views/settings/tabs/room/RolesRoomSettingsTab.tsx @@ -19,7 +19,6 @@ import ErrorDialog from "../../../dialogs/ErrorDialog"; import PowerSelector from "../../../elements/PowerSelector"; import SettingsFieldset from "../../SettingsFieldset"; import SettingsStore from "../../../../../settings/SettingsStore"; -import { VoiceBroadcastInfoEventType } from "../../../../../voice-broadcast"; import { ElementCall } from "../../../../../models/Call"; import SdkConfig, { DEFAULTS } from "../../../../../SdkConfig"; import { AddPrivilegedUsers } from "../../AddPrivilegedUsers"; @@ -62,7 +61,6 @@ const plEventsToShow: Record = { // TODO: Enable support for m.widget event type (https://github.com/vector-im/element-web/issues/13111) "im.vector.modular.widgets": { isState: true, hideForSpace: true }, - [VoiceBroadcastInfoEventType]: { isState: true, hideForSpace: true }, }; // parse a string as an integer; if the input is undefined, or cannot be parsed @@ -81,7 +79,7 @@ interface IBannedUserProps { export class BannedUser extends React.Component { public static contextType = MatrixClientContext; - public declare context: React.ContextType; + declare public context: React.ContextType; private onUnbanClick = (): void => { this.context.unban(this.props.member.roomId, this.props.member.userId).catch((err) => { @@ -134,7 +132,7 @@ interface RolesRoomSettingsTabState { export default class RolesRoomSettingsTab extends React.Component { public static contextType = MatrixClientContext; - public declare context: React.ContextType; + declare public context: React.ContextType; public constructor(props: IProps) { super(props); @@ -289,7 +287,6 @@ export default class RolesRoomSettingsTab extends React.Component { public static contextType = MatrixClientContext; - public declare context: React.ContextType; + declare public context: React.ContextType; public constructor(props: IProps, context: React.ContextType) { super(props, context); diff --git a/src/components/views/settings/tabs/user/HelpUserSettingsTab.tsx b/src/components/views/settings/tabs/user/HelpUserSettingsTab.tsx index 7866131a01..f19343be20 100644 --- a/src/components/views/settings/tabs/user/HelpUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/HelpUserSettingsTab.tsx @@ -32,7 +32,7 @@ interface IState { export default class HelpUserSettingsTab extends React.Component { public static contextType = MatrixClientContext; - public declare context: React.ContextType; + declare public context: React.ContextType; public constructor(props: IProps, context: React.ContextType) { super(props, context); diff --git a/src/components/views/settings/tabs/user/SessionManagerTab.tsx b/src/components/views/settings/tabs/user/SessionManagerTab.tsx index 2e16f45762..5e9445bb99 100644 --- a/src/components/views/settings/tabs/user/SessionManagerTab.tsx +++ b/src/components/views/settings/tabs/user/SessionManagerTab.tsx @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only Please see LICENSE files in the repository root for full details. */ -import React, { lazy, Suspense, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; +import React, { lazy, Suspense, useCallback, useContext, useEffect, useRef, useState } from "react"; import { discoverAndValidateOIDCIssuerWellKnown, MatrixClient } from "matrix-js-sdk/src/matrix"; import { logger } from "matrix-js-sdk/src/logger"; import { defer } from "matrix-js-sdk/src/utils"; @@ -184,7 +184,6 @@ const SessionManagerTab: React.FC<{ const userId = matrixClient?.getUserId(); const currentUserMember = (userId && matrixClient?.getUser(userId)) || undefined; const clientVersions = useAsyncMemo(() => matrixClient.getVersions(), [matrixClient]); - const wellKnown = useMemo(() => matrixClient?.getClientWellKnown(), [matrixClient]); const oidcClientConfig = useAsyncMemo(async () => { try { const authIssuer = await matrixClient?.getAuthIssuer(); @@ -305,7 +304,6 @@ const SessionManagerTab: React.FC<{ diff --git a/src/components/views/settings/tabs/user/VoiceUserSettingsTab.tsx b/src/components/views/settings/tabs/user/VoiceUserSettingsTab.tsx index 36d336faa3..9711159a10 100644 --- a/src/components/views/settings/tabs/user/VoiceUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/VoiceUserSettingsTab.tsx @@ -51,7 +51,7 @@ const mapDeviceKindToHandlerValue = (deviceKind: MediaDeviceKindEnum): string | export default class VoiceUserSettingsTab extends React.Component<{}, IState> { public static contextType = MatrixClientContext; - public declare context: React.ContextType; + declare public context: React.ContextType; public constructor(props: {}, context: React.ContextType) { super(props, context); diff --git a/src/components/views/toasts/VerificationRequestToast.tsx b/src/components/views/toasts/VerificationRequestToast.tsx index 54932a12ed..7d31aa6764 100644 --- a/src/components/views/toasts/VerificationRequestToast.tsx +++ b/src/components/views/toasts/VerificationRequestToast.tsx @@ -117,7 +117,7 @@ export default class VerificationRequestToast extends React.PureComponent; + +export enum NotificationStateEvents { + Update = "update", +} + +type EventHandlerMap> = { + [NotificationStateEvents.Update]: (keys: Array) => void; +}; + +class EfficientContext> extends TypedEventEmitter< + NotificationStateEvents, + EventHandlerMap +> { + public constructor(public state: C) { + super(); + } + + public setState(state: C): void { + const changedKeys = objectKeyChanges(this.state ?? ({} as C), state); + this.state = state; + this.emit(NotificationStateEvents.Update, changedKeys); + } +} + +const ScopedRoomContext = createContext | undefined>(undefined); + +// Uses react memo and leverages splatting the value to ensure that the context is only updated when the state changes (shallow compare) +export const ScopedRoomContextProvider = memo( + ({ children, ...state }: { children: ReactNode } & ContextValue): JSX.Element => { + const contextRef = useRef(new EfficientContext(state)); + useEffect(() => { + contextRef.current.setState(state); + }, [state]); + + // Includes the legacy RoomContext provider for backwards compatibility with class components + return ( + + {children} + + ); + }, +); + +type ScopedRoomContext> = { [key in K[number]]: ContextValue[key] }; + +export function useScopedRoomContext>(...keys: K): ScopedRoomContext { + const context = useContext(ScopedRoomContext); + const [state, setState] = useState>(context?.state ?? ({} as ScopedRoomContext)); + + useTypedEventEmitter(context, NotificationStateEvents.Update, (updatedKeys: K): void => { + if (context?.state && updatedKeys.some((updatedKey) => keys.includes(updatedKey))) { + setState(context.state); + } + }); + + return state; +} diff --git a/src/events/EventTileFactory.tsx b/src/events/EventTileFactory.tsx index ff771b0467..ec1651872a 100644 --- a/src/events/EventTileFactory.tsx +++ b/src/events/EventTileFactory.tsx @@ -41,13 +41,7 @@ import { getMessageModerationState, MessageModerationState } from "../utils/Even import HiddenBody from "../components/views/messages/HiddenBody"; import ViewSourceEvent from "../components/views/messages/ViewSourceEvent"; import { shouldDisplayAsBeaconTile } from "../utils/beacon/timeline"; -import { shouldDisplayAsVoiceBroadcastTile } from "../voice-broadcast/utils/shouldDisplayAsVoiceBroadcastTile"; import { ElementCall } from "../models/Call"; -import { - isRelatedToVoiceBroadcast, - shouldDisplayAsVoiceBroadcastStoppedText, - VoiceBroadcastChunkEventType, -} from "../voice-broadcast"; // Subset of EventTile's IProps plus some mixins export interface EventTileTypeProps @@ -223,12 +217,6 @@ export function pickFactory( return MessageEventFactory; } - if (shouldDisplayAsVoiceBroadcastTile(mxEvent)) { - return MessageEventFactory; - } else if (shouldDisplayAsVoiceBroadcastStoppedText(mxEvent)) { - return TextualEventFactory; - } - if (SINGULAR_STATE_EVENTS.has(evType) && mxEvent.getStateKey() !== "") { return noEventFactoryFactory(); // improper event type to render } @@ -249,16 +237,6 @@ export function pickFactory( return noEventFactoryFactory(); } - if (mxEvent.getContent()[VoiceBroadcastChunkEventType]) { - // hide voice broadcast chunks - return noEventFactoryFactory(); - } - - if (!showHiddenEvents && mxEvent.isDecryptionFailure() && isRelatedToVoiceBroadcast(mxEvent, cli)) { - // hide utd events related to a broadcast - return noEventFactoryFactory(); - } - return EVENT_TILE_TYPES.get(evType) ?? noEventFactoryFactory(); } diff --git a/src/events/forward/getForwardableEvent.ts b/src/events/forward/getForwardableEvent.ts index 000a50f4ee..2d37ebf6e9 100644 --- a/src/events/forward/getForwardableEvent.ts +++ b/src/events/forward/getForwardableEvent.ts @@ -9,7 +9,6 @@ Please see LICENSE files in the repository root for full details. import { M_POLL_END, M_POLL_START, M_BEACON_INFO, MatrixEvent, MatrixClient } from "matrix-js-sdk/src/matrix"; import { getShareableLocationEventForBeacon } from "../../utils/beacon/getShareableLocation"; -import { VoiceBroadcastInfoEventType } from "../../voice-broadcast/types"; /** * Get forwardable event for a given event @@ -20,8 +19,6 @@ export const getForwardableEvent = (event: MatrixEvent, cli: MatrixClient): Matr return null; } - if (event.getType() === VoiceBroadcastInfoEventType) return null; - // Live location beacons should forward their latest location as a static pin location // If the beacon is not live, or doesn't have a location forwarding is not allowed if (M_BEACON_INFO.matches(event.getType())) { diff --git a/src/hooks/room/useRoomMemberProfile.ts b/src/hooks/room/useRoomMemberProfile.ts index 57f72a722e..b8bb44c50d 100644 --- a/src/hooks/room/useRoomMemberProfile.ts +++ b/src/hooks/room/useRoomMemberProfile.ts @@ -7,10 +7,11 @@ Please see LICENSE files in the repository root for full details. */ import { RoomMember } from "matrix-js-sdk/src/matrix"; -import { useContext, useMemo } from "react"; +import { useMemo } from "react"; -import RoomContext, { TimelineRenderingType } from "../../contexts/RoomContext"; +import { TimelineRenderingType } from "../../contexts/RoomContext"; import { useSettingValue } from "../useSettings"; +import { useScopedRoomContext } from "../../contexts/ScopedRoomContext.tsx"; export function useRoomMemberProfile({ userId = "", @@ -21,7 +22,7 @@ export function useRoomMemberProfile({ member?: RoomMember | null; forceHistorical?: boolean; }): RoomMember | undefined | null { - const context = useContext(RoomContext); + const context = useScopedRoomContext("room", "timelineRenderingType"); const useOnlyCurrentProfiles = useSettingValue("useOnlyCurrentProfiles"); const member = useMemo(() => { diff --git a/src/hooks/useAudioDeviceSelection.ts b/src/hooks/useAudioDeviceSelection.ts deleted file mode 100644 index 504eb10ea6..0000000000 --- a/src/hooks/useAudioDeviceSelection.ts +++ /dev/null @@ -1,76 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only -Please see LICENSE files in the repository root for full details. -*/ - -import { useRef, useState } from "react"; - -import { _t } from "../languageHandler"; -import MediaDeviceHandler, { MediaDeviceKindEnum } from "../MediaDeviceHandler"; -import { requestMediaPermissions } from "../utils/media/requestMediaPermissions"; - -interface State { - devices: MediaDeviceInfo[]; - device: MediaDeviceInfo | null; -} - -export const useAudioDeviceSelection = ( - onDeviceChanged?: (device: MediaDeviceInfo) => void, -): { - currentDevice: MediaDeviceInfo | null; - currentDeviceLabel: string; - devices: MediaDeviceInfo[]; - setDevice(device: MediaDeviceInfo): void; -} => { - const shouldRequestPermissionsRef = useRef(true); - const [state, setState] = useState({ - devices: [], - device: null, - }); - - if (shouldRequestPermissionsRef.current) { - shouldRequestPermissionsRef.current = false; - requestMediaPermissions(false).then((stream: MediaStream | undefined) => { - MediaDeviceHandler.getDevices().then((devices) => { - if (!devices) return; - const { audioinput } = devices; - MediaDeviceHandler.getDefaultDevice(audioinput); - const deviceFromSettings = MediaDeviceHandler.getAudioInput(); - const device = - audioinput.find((d) => { - return d.deviceId === deviceFromSettings; - }) || audioinput[0]; - setState({ - ...state, - devices: audioinput, - device, - }); - stream?.getTracks().forEach((t) => t.stop()); - }); - }); - } - - const setDevice = (device: MediaDeviceInfo): void => { - const shouldNotify = device.deviceId !== state.device?.deviceId; - MediaDeviceHandler.instance.setDevice(device.deviceId, MediaDeviceKindEnum.AudioInput); - - setState({ - ...state, - device, - }); - - if (shouldNotify) { - onDeviceChanged?.(device); - } - }; - - return { - currentDevice: state.device, - currentDeviceLabel: state.device?.label || _t("voip|default_device"), - devices: state.devices, - setDevice, - }; -}; diff --git a/src/i18n/strings/de_DE.json b/src/i18n/strings/de_DE.json index abe4566f8c..65cb1d8660 100644 --- a/src/i18n/strings/de_DE.json +++ b/src/i18n/strings/de_DE.json @@ -2,6 +2,7 @@ "a11y": { "emoji_picker": "Emoji-Auswahl", "jump_first_invite": "Zur ersten Einladung springen.", + "message_composer": "Nachrichteneingabe-Feld", "n_unread_messages": { "other": "%(count)s ungelesene Nachrichten.", "one": "1 ungelesene Nachricht." @@ -10,11 +11,13 @@ "other": "%(count)s ungelesene Nachrichten einschließlich Erwähnungen.", "one": "1 ungelesene Erwähnung." }, + "recent_rooms": "Kürzlich besuchte Chatrooms", "room_name": "Raum %(name)s", + "room_status_bar": "Statusleiste des Chatrooms", + "seek_bar_label": "Audio-Suchleiste", "unread_messages": "Ungelesene Nachrichten.", "user_menu": "Benutzermenü" }, - "a11y_jump_first_unread_room": "Zum ersten ungelesenen Raum springen.", "action": { "accept": "Annehmen", "add": "Hinzufügen", @@ -106,6 +109,7 @@ "save": "Speichern", "search": "Suchen", "send_report": "Bericht senden", + "set_avatar": "Profilbild festlegen", "share": "Teilen", "show": "Zeigen", "show_advanced": "Erweiterte Einstellungen", @@ -123,12 +127,13 @@ "trust": "Vertrauen", "try_again": "Erneut versuchen", "unban": "Verbannung aufheben", - "unignore": "Nicht mehr blockieren", + "unignore": "Freigeben", "unpin": "Nicht mehr anheften", "unsubscribe": "Deabonnieren", "update": "Aktualisieren", "upgrade": "Aktualisieren", "upload": "Hochladen", + "upload_file": "Datei hochladen", "verify": "Verifizieren", "view": "Ansicht", "view_all": "Alles anzeigen", @@ -143,7 +148,7 @@ "accept_button": "Das ist okay", "bullet_1": "Wir erfassen und analysieren keine Kontodaten", "bullet_2": "Wir teilen keine Informationen mit Dritten", - "consent_migration": "Sie haben zuvor zugestimmt, anonymisierte Nutzungsdaten mit uns zu teilen. Wir aktualisieren, wie das funktioniert.", + "consent_migration": "Du hast zugestimmt, anonymisierte Nutzungsdaten mit uns zu teilen. Wir aktualisieren die Funktionsweise.", "disable_prompt": "Du kannst dies jederzeit in den Einstellungen deaktivieren", "enable_prompt": "Hilf mit, %(analyticsOwner)s zu verbessern", "learn_more": "Teile Daten anonymisiert um uns zu helfen Probleme zu identifizieren. Nichts persönliches. Keine Dritten. Mehr dazu hier", @@ -204,11 +209,11 @@ "failed_soft_logout_auth": "Erneute Authentifizierung fehlgeschlagen", "failed_soft_logout_homeserver": "Erneute Authentifizierung aufgrund eines Problems des Heim-Servers fehlgeschlagen", "forgot_password_email_invalid": "E-Mail-Adresse scheint ungültig zu sein.", - "forgot_password_email_required": "Es muss die mit dem Benutzerkonto verbundene E-Mail-Adresse eingegeben werden.", + "forgot_password_email_required": "Es muss die mit dem Konto verbundene E-Mail-Adresse eingegeben werden.", "forgot_password_prompt": "Passwort vergessen?", "forgot_password_send_email": "E-Mail senden", "identifier_label": "Anmelden mit", - "incorrect_credentials": "Inkorrekter Nutzername und/oder Passwort.", + "incorrect_credentials": "Benutzername und/oder Passwort falsch.", "incorrect_credentials_detail": "Bitte beachte, dass du dich gerade auf %(hs)s anmeldest, nicht matrix.org.", "incorrect_password": "Ungültiges Passwort", "log_in_new_account": "Mit deinem neuen Konto anmelden.", @@ -223,6 +228,7 @@ }, "misconfigured_body": "Wende dich an deinen %(brand)s-Admin um deine Konfiguration auf ungültige oder doppelte Einträge zu überprüfen.", "misconfigured_title": "Dein %(brand)s ist falsch konfiguriert", + "mobile_create_account_title": "Du bist dabei, auf %(hsName)s ein Konto anzulegen", "msisdn_field_description": "Andere Personen können dich mit deinen Kontaktdaten in Räume einladen", "msisdn_field_label": "Telefon", "msisdn_field_number_invalid": "Diese Telefonummer sieht nicht ganz richtig aus. Bitte überprüfe deine Eingabe und versuche es erneut", @@ -241,12 +247,36 @@ "phone_label": "Telefon", "phone_optional_label": "Telefon (optional)", "qr_code_login": { + "check_code_explainer": "Hierdurch wird überprüft, ob die Verbindung zu Ihrem anderen Gerät sicher ist.", + "check_code_heading": "Gib die Nummer ein, die am anderen Gerät angezeigt wird", + "check_code_input_label": "zweistelliger Code", + "check_code_mismatch": "Die Zahlen stimmen nicht überein", "completing_setup": "Schließe Anmeldung deines neuen Gerätes ab", + "error_etag_missing": "Ein unerwarteter Fehler ist aufgetreten. Dies kann an einer Browsererweiterung, einem Proxyserver oder einer fehlerhaften Serverkonfiguration liegen.", + "error_expired": "Die Anmeldung ist abgelaufen. Bitte versuchen Sie es erneut.", + "error_expired_title": "Die Anmeldung wurde nicht rechtzeitig abgeschlossen", + "error_insecure_channel_detected": "Eine sichere Verbindung zum neuen Gerät konnte nicht hergestellt werden. Ihre vorhandenen Geräte sind weiterhin sicher und Sie müssen sich keine Sorgen um sie machen.", + "error_insecure_channel_detected_instructions": "Was jetzt?", + "error_insecure_channel_detected_instructions_1": "Versuchen Sie erneut, sich mit einem QR-Code auf dem anderen Gerät anzumelden, falls dies ein Netzwerkproblem war", + "error_insecure_channel_detected_instructions_2": "Falls das gleiche Problem auftritt, probiere es mit einem anderen WLAN-Netzwerk oder verwende deine Mobilen Daten.", + "error_insecure_channel_detected_instructions_3": "Falls das nicht funktioniert melde dich manuell an.", + "error_insecure_channel_detected_title": "Verbindung ist nicht sicher", + "error_other_device_already_signed_in": "Sie brauchen nichts weiter zu tun.", + "error_other_device_already_signed_in_title": "Ihr anderes Gerät ist bereits angemeldet", "error_rate_limited": "Zu viele Versuche in zu kurzer Zeit. Warte ein wenig, bevor du es erneut versuchst.", - "error_unexpected": "Ein unerwarteter Fehler ist aufgetreten.", - "scan_code_instruction": "Lese den folgenden QR-Code mit deinem nicht angemeldeten Gerät ein.", - "scan_qr_code": "QR-Code einlesen", - "select_qr_code": "Wähle „%(scanQRCode)s“", + "error_unsupported_protocol": "Dieses Gerät unterstützt die Anmeldung auf einem anderen Gerät mit einem QR-Code nicht.", + "error_user_cancelled": "Der Anmeldevorgang wurde am anderen Gerät abgebrochen.", + "error_user_cancelled_title": "Anmeldungsanfrage abgebrochen", + "error_user_declined": "Du oder der Kontoanbieter haben die Anmeldeanfrage abgelehnt.", + "error_user_declined_title": "Anmeldung abgelehnt", + "point_the_camera": "Scanne den hier angezeigten QR-Code", + "scan_code_instruction": "Scanne den QR-Code mit einem weiteren Gerät.", + "scan_qr_code": "Anmeldung mit QR-Code", + "security_code": "Sicherheitscode", + "security_code_prompt": "Wenn Sie dazu aufgefordert werden, geben Sie den folgenden Code auf Ihrem anderen Gerät ein.", + "select_qr_code": "Wähle \"%(scanQRCode)s\"", + "unsupported_explainer": "Dein Kontoanbieter unterstützt keine Anmeldung bei einem neuen Gerät per QR-Code.", + "unsupported_heading": "QR-Code nicht unterstützt", "waiting_for_device": "Warte auf Anmeldung des Gerätes" }, "register_action": "Konto erstellen", @@ -258,8 +288,6 @@ "registration_disabled": "Registrierungen wurden auf diesem Heim-Server deaktiviert.", "registration_msisdn_field_required_invalid": "Telefonnummer eingeben (auf diesem Heim-Server erforderlich)", "registration_successful": "Registrierung erfolgreich", - "registration_username_in_use": "Jemand anderes nutzt diesen Benutzernamen schon. Probier einen anderen oder wenn du es bist, melde dich unten an.", - "registration_username_unable_check": "Es kann nicht überprüft werden, ob der Nutzername bereits vergeben ist. Bitte versuche es später erneut.", "registration_username_validation": "Verwende nur Kleinbuchstaben, Zahlen, Bindestriche und Unterstriche", "reset_password": { "confirm_new_password": "Neues Passwort bestätigen", @@ -335,6 +363,8 @@ "email_resend_prompt": "Nicht angekommen? Erneut senden", "email_resent": "Verschickt!", "fallback_button": "Authentifizierung beginnen", + "mas_cross_signing_reset_cta": "Gehe zu deinem Konto", + "mas_cross_signing_reset_description": "Lasse deine Identität durch deinen Kontoanbieter zurücksetzen. Kehre dann zurück und klicke auf \"Erneut versuchen\".", "msisdn": "Eine Textnachricht wurde an %(msisdn)s gesendet", "msisdn_token_incorrect": "Token fehlerhaft", "msisdn_token_prompt": "Bitte gib den darin enthaltenen Code ein:", @@ -355,7 +385,6 @@ "unsupported_auth_email": "Dieser Heim-Server unterstützt die Anmeldung per E-Mail-Adresse nicht.", "unsupported_auth_msisdn": "Dieser Server unterstützt keine Authentifizierung per Telefonnummer.", "username_field_required_invalid": "Benutzername eingeben", - "username_in_use": "Dieser Benutzername wird bereits genutzt, bitte versuche es mit einem anderen.", "verify_email_explainer": "Wir müssen wissen, dass du es auch wirklich bist, bevor wir dein Passwort zurücksetzen. Klicke auf den Link in der E-Mail, die wir gerade an %(email)s gesendet haben", "verify_email_heading": "Verifiziere deine E-Mail, um fortzufahren" }, @@ -365,7 +394,7 @@ "collecting_information": "App-Versionsinformationen werden abgerufen", "collecting_logs": "Protokolle werden abgerufen", "create_new_issue": "Bitte erstelle ein neues Issue auf GitHub damit wir diesen Fehler untersuchen können.", - "description": "Fehlerberichte enthalten Nutzungsdaten wie Nutzernamen von dir und anderen Personen, Raum-IDs deiner beigetretenen Räume sowie mit welchen Elementen der Oberfläche du kürzlich interagiert hast. Sie enthalten keine Nachrichten.", + "description": "Fehlerberichte enthalten Applikationsnutzungsdaten wie Ihren Benutzernamen and Ihre Pseudonyme oder jene Ihrer Chatpartner, die IDs und Namen von Chaträumen, in denen Sie Mitglied sind und Elementen der Benutzeroberfläche mit denen Sie kürzlich interagiert haben. Fehlerberichte enthalten keine Nachrichten.", "download_logs": "Protokolle herunterladen", "downloading_logs": "Lade Protokolle herunter", "error_empty": "Bitte teile uns mit, was schief lief - oder besser, beschreibe das Problem auf GitHub in einem \"Issue\".", @@ -425,6 +454,7 @@ "beta": "Beta", "camera": "Kamera", "cameras": "Kameras", + "cancel": "Abbrechen", "capabilities": "Funktionen", "copied": "Kopiert!", "credits": "Danksagungen", @@ -460,11 +490,13 @@ "legal": "Rechtliches", "light": "Hell", "loading": "Lade …", + "lobby": "Lobby", "location": "Standort", "low_priority": "Niedrige Priorität", "matrix": "Matrix", "message": "Nachricht", "message_layout": "Nachrichtenlayout", + "message_timestamp_invalid": "Ungültiger Zeitstempel", "microphone": "Mikrofon", "model": "Modell", "modern": "Modern", @@ -488,7 +520,7 @@ "orphan_rooms": "Andere Räume", "password": "Passwort", "people": "Personen", - "preferences": "Einstellungen", + "preferences": "Präferenzen", "presence": "Anwesenheit", "preview_message": "Hey du. Du bist großartig!", "privacy": "Privatsphäre", @@ -506,6 +538,8 @@ "room": "Raum", "room_name": "Raumname", "rooms": "Räume", + "save": "Speichern", + "saved": "Gespeichert", "saving": "Speichere …", "secure_backup": "Verschlüsselte Sicherung", "security": "Sicherheit", @@ -516,7 +550,7 @@ "show_more": "Mehr zeigen", "someone": "Jemand", "space": "Raum", - "spaces": "Räume", + "spaces": "Spaces", "sticker": "Sticker", "stickerpack": "Sticker-Paket", "success": "Erfolg", @@ -534,6 +568,7 @@ "unnamed_room": "Unbenannter Raum", "unnamed_space": "Unbenannter Space", "unverified": "Nicht verifiziert", + "updating": "Aktualisieren...", "user": "Benutzer", "user_avatar": "Profilbild", "username": "Benutzername", @@ -661,7 +696,7 @@ "private_personal_heading": "Für wen ist dieser Space gedacht?", "private_space": "Für mich und meine Kollegen", "private_space_description": "Ein privater Space für dich und deine Kollegen", - "public_description": "Öffne den Space für alle - am besten für Communities", + "public_description": "Öffne den Space für alle - am besten für Communitys", "public_heading": "Dein öffentlicher Space", "search_public_button": "Öffentliche Spaces suchen", "setup_rooms_community_description": "Lass uns für jedes einen Raum erstellen.", @@ -687,6 +722,7 @@ "twemoji": "Die Twemoji-Emojis sind © Twitter, Inc und weitere Mitwirkende und wird unter den Bedingungen von CC-BY 4.0 verwendet.", "twemoji_colr": "Die Schriftart twemoji-colr ist © Mozilla Foundation und wird unter den Bedingungen von Apache 2.0 verwendet." }, + "desktop_default_device_name": "%(brand)s Desktop: %(platformName)s", "devtools": { "active_widgets": "Aktive Widgets", "category_other": "Sonstiges", @@ -732,6 +768,7 @@ "room_notifications_type": "Typ: ", "room_status": "Raumstatus", "room_unread_status_count": { + "one": "Ungelesen Status des ChatRooms: %(status)s, Anzahl: %(count)s", "other": "Ungelesen-Status im Raum: %(status)s, Anzahl: %(count)s" }, "save_setting_values": "Einstellungswerte speichern", @@ -761,7 +798,7 @@ "toolbox": "Werkzeugkasten", "use_at_own_risk": "Diese Benutzeroberfläche prüft nicht auf richtige Datentypen. Benutzung auf eigene Gefahr.", "user_read_up_to": "Der Benutzer hat gelesen bis: ", - "user_read_up_to_ignore_synthetic": "Der Benutzer hat gelesen bis (ignoreSynthetic): ", + "user_read_up_to_ignore_synthetic": "Der Benutzer hat bis (ignoreSynthetic) gelesen: ", "user_read_up_to_private": "Benutzer las bis (m.read.private): ", "user_read_up_to_private_ignore_synthetic": "Benutzer las bis (m.read.private;ignoreSynthetic): ", "value": "Wert", @@ -860,6 +897,8 @@ "warning": "Wenn du die neue Wiederherstellungsmethode nicht festgelegt hast, versucht ein Angreifer möglicherweise, auf dein Konto zuzugreifen. Ändere dein Kontopasswort und lege sofort eine neue Wiederherstellungsmethode in den Einstellungen fest." }, "not_supported": "", + "pinned_identity_changed": "Die Identität von %(displayName)s (%(userId)s)) hat sich geändert. Mehr erfahren", + "pinned_identity_changed_no_displayname": "Die Identität von %(userId)s hat sich geändert. Mehr erfahren", "recovery_method_removed": { "description_1": "In dieser Sitzung wurde festgestellt, dass deine Sicherheitsphrase und dein Schlüssel für sichere Nachrichten entfernt wurden.", "description_2": "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.", @@ -929,7 +968,8 @@ "qr_reciprocate_same_shield_device": "Fast geschafft! Zeigen beide Geräte das selbe Wappen an?", "qr_reciprocate_same_shield_user": "Fast geschafft! Wird bei %(displayName)s das gleiche Schild angezeigt?", "request_toast_accept": "Sitzung verifizieren", - "request_toast_decline_counter": "Ignorieren (%(counter)s)", + "request_toast_accept_user": "Benutzer verifizieren", + "request_toast_decline_counter": "Blockiert (%(counter)s)", "request_toast_detail": "%(deviceId)s von %(ip)s", "reset_proceed_prompt": "Mit Zurücksetzen fortfahren", "sas_caption_self": "Verifiziere dieses Gerät, indem du überprüfst, dass die folgende Zahl auf dem Bildschirm erscheint.", @@ -954,7 +994,6 @@ "unverified_sessions_toast_description": "Überprüfe sie, um ein sicheres Konto gewährleisten zu können", "unverified_sessions_toast_reject": "Später", "unverified_sessions_toast_title": "Du hast nicht verifizierte Sitzungen", - "verification_description": "Verifiziere diese Anmeldung, um auf verschlüsselte Nachrichten zuzugreifen und dich anderen gegenüber zu identifizieren.", "verification_dialog_title_device": "Anderes Gerät verifizieren", "verification_dialog_title_user": "Verifizierungsanfrage", "verification_skip_warning": "Ohne dich zu verifizieren wirst du keinen Zugriff auf alle deine Nachrichten haben und könntest für andere als nicht vertrauenswürdig erscheinen.", @@ -1020,6 +1059,10 @@ "error_app_open_in_another_tab": "Wechsle zu einem anderen Tab um mit %(brand)s zu verbinden. Dieser Tab kann jetzt geschlossen werden.", "error_app_open_in_another_tab_title": "%(brand)s läuft bereits in einem anderen Tab.", "error_app_opened_in_another_window": "%(brand)s läuft bereit in einem anderen Fenster. Klicke \"%(label)s um %(brand)s hier zu nutzen und beende das andere Fenster.", + "error_database_closed_description": { + "for_desktop": "Deine Festplatte scheint voll zu sein. Mache Speicherplatz frei und lade erneut.", + "for_web": "Diese Meldung wird erwartet, wenn Sie die Browserdaten gelöscht haben. %(brand)s ist möglicherweise auch in einem anderen Tab geöffnet oder Ihre Festplatte ist voll. Bitte machen Sie etwas Speicherplatz frei und laden Sie die Seite neu." + }, "error_database_closed_title": "%(brand)s funktioniert nicht mehr", "error_dialog": { "copy_room_link_failed": { @@ -1060,7 +1103,15 @@ "you": "Du reagiertest mit %(reaction)s auf %(message)s" }, "m.sticker": "%(senderName)s: %(stickerName)s", - "m.text": "%(senderName)s: %(message)s" + "m.text": "%(senderName)s: %(message)s", + "prefix": { + "audio": "Audio", + "file": "Datei", + "image": "Bild", + "poll": "Umfrage", + "video": "Video" + }, + "preview": "%(prefix)s%(preview)s" }, "export_chat": { "cancelled": "Exportieren abgebrochen", @@ -1183,7 +1234,17 @@ "other": "In %(spaceName)s und %(count)s weiteren Spaces." }, "incompatible_browser": { - "title": "Nicht unterstützter Browser" + "continue": "Trotzdem fortfahren", + "description": "%(brand)s verwendet einige Browser-Funktionen, die von deinem aktuellen Browser nicht unterstützt werden. %(detail)s", + "learn_more": "Mehr erfahren", + "linux": "Linux", + "macos": "Mac", + "supported_browsers": "Verwenden Sie Chrome, Firefox, Edge oder Safari, um das beste Erlebnis zu erzielen.", + "title": "Nicht unterstützter Browser", + "use_desktop_heading": "Verwende stattdessen %(brand)s Desktop", + "use_mobile_heading": "Stattdessen %(brand)s am Smartphone benutzen", + "use_mobile_heading_after_desktop": "Oder verwende die mobile App", + "windows": "Windows (%(bits)s-bit)" }, "info_tooltip_title": "Information", "integration_manager": { @@ -1307,12 +1368,14 @@ "navigate_next_message_edit": "Nächste Nachricht bearbeiten", "navigate_prev_history": "Vorheriger kürzlich besuchter Raum oder Space", "navigate_prev_message_edit": "Vorherige Nachricht bearbeiten", + "next_landmark": "Zur nächsten Landmark springen", "next_room": "Nächste Unterhaltung", "next_unread_room": "Nächste ungelesene Nachricht", "number": "[Nummer]", "open_user_settings": "Benutzereinstellungen öffnen", "page_down": "Bild runter", "page_up": "Bild hoch", + "prev_landmark": "Zur vorherigen Landmark springen", "prev_room": "Vorherige Unterhaltung", "prev_unread_room": "Vorherige ungelesene Nachricht", "room_list_collapse_section": "Raumliste einklappen", @@ -1357,8 +1420,11 @@ "dynamic_room_predecessors": "Veränderbare Raumvorgänger", "dynamic_room_predecessors_description": "MSC3946 aktivieren (zur Verknüpfung von Raumarchiven nach der Raumerstellung)", "element_call_video_rooms": "Element Call-Videoräume", + "exclude_insecure_devices": "Unsichere Geräte ausschließen beim senden/empfangen von Nachrichten", + "exclude_insecure_devices_description": "Bei Aktivierung dieses Modus werden verschlüsselte Nachrichten nicht mehr mit unverifizierten Geräten geteilt und Nachrichten von unverifizierten Geräten werden als Fehler angezeigt. Beachte, dass wenn du den Modus aktivierst, eine Kommunikation mit Benutzern, die keine verifizierten Geräte haben, nicht möglich ist.", "experimental_description": "Experimentierfreudig? Probiere unsere neuesten, sich in Entwicklung befindlichen Ideen aus. Diese Funktionen sind nicht final; Sie könnten instabil sein, sich verändern oder sogar ganz entfernt werden. Erfahre mehr.", "experimental_section": "Frühe Vorschauen", + "extended_profiles_msc_support": "Erfordert die Unterstützung von MSC4133 durch den Server", "feature_disable_call_per_sender_encryption": "Verschlüsselung per-sender für Element Anruf abschalten", "feature_wysiwyg_composer_description": "Verwende Textverarbeitung (Rich-Text) statt Markdown im Eingabefeld.", "group_calls": "Neue Gruppenanruf-Erfahrung", @@ -1369,9 +1435,10 @@ "group_moderation": "Moderation", "group_profile": "Profil", "group_rooms": "Räume", - "group_spaces": "Räume", + "group_spaces": "Spaces", "group_themes": "Themen", "group_threads": "Themen", + "group_ui": "Benutzeroberfläche", "group_voip": "Anrufe", "group_widgets": "Widgets", "hidebold": "Benachrichtigungspunkt ausblenden (nur Zähler zeigen)", @@ -1400,7 +1467,6 @@ "sliding_sync": "Sliding-Sync-Modus", "sliding_sync_description": "In aktiver Entwicklung, kann nicht deaktiviert werden.", "sliding_sync_disabled_notice": "Zum Deaktivieren, melde dich ab und erneut an", - "sliding_sync_server_no_support": "Dein Server unterstützt dies nicht nativ", "under_active_development": "In aktiver Entwicklung.", "unrealiable_e2e": "Nicht zuverlässig in verschlüsselten Räumen", "video_rooms": "Videoräume", @@ -1518,6 +1584,7 @@ }, "member_list_back_action_label": "Raummitglieder", "message_edit_dialog_title": "Nachrichtenänderungen", + "migrating_crypto": "Bleib dran. Wir aktualisieren%(brand)s, um die Verschlüsselung schneller und zuverlässiger zu machen.", "mobile_guide": { "toast_accept": "App verwenden", "toast_description": "%(brand)s ist in mobilen Browsern experimentell. Für eine bessere Erfahrung nutze unsere App.", @@ -1543,8 +1610,10 @@ "keyword": "Schlüsselwort", "keyword_new": "Neues Schlüsselwort", "level_activity": "Aktivität", + "level_highlight": "Hervorhebung", "level_muted": "Stumm", "level_none": "Nichts", + "level_notification": "Benachrichtigung", "level_unsent": "Nicht gesendet", "mark_all_read": "Alle als gelesen markieren", "mentions_and_keywords": "@Erwähnungen und Schlüsselwörter", @@ -1572,8 +1641,8 @@ "download_brand_desktop": "%(brand)s Desktop herunterladen", "download_f_droid": "In F-Droid erhältlich", "download_google_play": "In Google Play erhältlich", - "enable_notifications": "Benachrichtigungen einschalten", - "enable_notifications_action": "Benachrichtigungen aktivieren", + "enable_notifications": "Desktopbenachrichtigungen einschalten", + "enable_notifications_action": "Einstellungen öffnen", "enable_notifications_description": "Verpasse keine Antworten oder wichtigen Nachrichten", "explore_rooms": "Öffentliche Räume erkunden", "find_community_members": "Finde deine Community-Mitglieder und lade sie ein", @@ -1711,7 +1780,7 @@ "disagree": "Ablehnen", "error_create_room_moderation_bot": "Erstellen des Raums mit Moderations-Bot nicht möglich", "hide_messages_from_user": "Prüfe, ob du alle aktuellen und zukünftigen Nachrichten dieses Nutzers verstecken willst.", - "ignore_user": "Nutzer ignorieren", + "ignore_user": "Benutzer blockieren", "illegal_content": "Illegale Inhalte", "missing_reason": "Bitte gib an, weshalb du einen Fehler meldest.", "nature": "Bitte wähle eine Kategorie aus und beschreibe, was die Nachricht missbräuchlich macht.", @@ -1752,14 +1821,32 @@ "restore_failed_error": "Konnte Schlüsselsicherung nicht wiederherstellen" }, "right_panel": { - "add_integrations": "Widgets, Brücken und Bots hinzufügen", + "add_integrations": "Erweiterungen hinzufügen", + "add_topic": "Thema hinzufügen", + "extensions_button": "Erweiterungen", + "extensions_empty_description": "Wählen Sie \"%(addIntegrations)s\" um Erweiterungen zu suchen und diesem Raum hinzuzufügen", "files_button": "Dateien", "pinned_messages": { + "empty_title": "Hefte wichtige Nachrichten an, damit sie leicht gefunden werden können.", + "header": { + "one": "1 angeheftete Nachricht", + "other": "%(count)s angeheftete Nachrichten" + }, "limits": { "other": "Du kannst nur %(count)s Widgets anheften" - } + }, + "menu": "Menü öffnen", + "release_announcement": { + "close": "Ok", + "description": "Alle angehefteten Nachrichten findest du hier. Bewege den Mauszeiger über eine beliebige Nachricht und wählen anheften, um sie hinzuzufügen." + }, + "reply_thread": "Auf Nachricht im Thread antworten", + "unpin_all": { + "button": "Alle Nachrichten lösen", + "title": "Alle Nachrichten lösen?" + }, + "view": "Im Nachrichtenverlauf ansehen" }, - "pinned_messages_button": "Angeheftet", "poll": { "active_heading": "Aktive Umfragen", "empty_active": "In diesem Raum gibt es keine aktiven Umfragen", @@ -1780,11 +1867,11 @@ }, "load_more": "Weitere Umfragen laden", "loading": "Lade Umfragen", - "past_heading": "Vergangene Umfragen", + "past_heading": "Abgeschlossene Umfragen", "view_in_timeline": "Umfrage im Verlauf anzeigen", "view_poll": "Umfrage ansehen" }, - "polls_button": "Umfrageverlauf", + "polls_button": "Umfragen", "room_summary_card": { "title": "Raum-Info" }, @@ -1813,6 +1900,7 @@ "forget": "Raum vergessen", "low_priority": "Niedrige Priorität", "mark_read": "Als gelesen markieren", + "mark_unread": "Als ungelesen markieren", "notifications_default": "Standardeinstellung verwenden", "notifications_mute": "Raum stumm stellen", "title": "Raumoptionen", @@ -1861,6 +1949,8 @@ }, "room_is_public": "Dieser Raum ist öffentlich" }, + "header_avatar_open_settings_label": "Raumeinstellungen öffnen", + "header_face_pile_tooltip": "Personen", "header_untrusted_label": "Nicht vertrauenswürdig", "inaccessible": "Dieser Raum oder Space ist im Moment nicht zugänglich.", "inaccessible_name": "Auf %(roomName)s kann momentan nicht zugegriffen werden.", @@ -1884,10 +1974,10 @@ "you_created": "Du hast diesen Raum erstellt." }, "invite_email_mismatch_suggestion": "Teile diese E-Mail-Adresse in den Einstellungen, um Einladungen direkt in %(brand)s zu erhalten.", - "invite_reject_ignore": "Ablehnen und Nutzer blockieren", + "invite_reject_ignore": "Ablehnen und Benutzer blockieren", "invite_sent_to_email": "Einladung an %(email)s gesendet", "invite_sent_to_email_room": "Diese Einladung zu %(roomName)s wurde an %(email)s gesendet", - "invite_subtitle": " hat dich eingeladen", + "invite_subtitle": "Eingeladen von ", "invite_this_room": "In diesen Raum einladen", "invite_title": "Möchtest du %(roomName)s betreten?", "inviter_unknown": "Unbekannt", @@ -1930,11 +2020,24 @@ "not_found_title": "Dieser Raum oder Space existiert nicht.", "not_found_title_name": "%(roomName)s existiert nicht.", "peek_join_prompt": "Du erkundest den Raum %(roomName)s. Willst du ihn betreten?", + "pinned_message_badge": "Fixierte Nachrichten", + "pinned_message_banner": { + "button_close_list": "Liste schließen", + "button_view_all": "Alle anzeigen", + "description": "Dieser Raum hat fixierte Nachrichten. Klicke zum Anzeigen.", + "go_to_message": "Fixierte Nachrichten im Nachrichtenverlauf anzeigen.", + "title": "%(index)s of %(length)s Angeheftete Nachrichten" + }, "read_topic": "Klicke, um das Thema zu lesen", "rejecting": "Lehne Einladung ab …", "rejoin_button": "Erneut betreten", "search": { "all_rooms_button": "Alle Räume durchsuchen", + "placeholder": "Nachrichten durchsuchen...", + "summary": { + "one": "1 Ergebnis für \"\" gefunden", + "other": "%(count)s Ergebnisse für \"\" gefunden" + }, "this_room_button": "Diesen Raum durchsuchen" }, "status_bar": { @@ -2070,6 +2173,8 @@ "error_deleting_alias_description": "Beim Entfernen dieser Adresse ist ein Fehler aufgetreten. Vielleicht existiert sie nicht mehr oder es kam zu einem temporären Fehler.", "error_deleting_alias_description_forbidden": "Du hast nicht die Berechtigung, die Adresse zu löschen.", "error_deleting_alias_title": "Fehler beim Löschen der Adresse", + "error_publishing": "Raum konnte nicht auf öffentlich gestellt werden", + "error_publishing_detail": "Bei der Freigabe des Raumes hat es einen Fehler gegeben", "error_save_space_settings": "Spaceeinstellungen konnten nicht gespeichert werden.", "error_updating_alias_description": "Es gab einen Fehler beim Ändern des Raumalias. Entweder erlaubt es der Server nicht oder es gab ein temporäres Problem.", "error_updating_canonical_alias_description": "Es gab ein Problem beim Aktualisieren der Raum-Hauptadresse. Es kann sein, dass der Server dies verbietet oder ein temporäres Problem aufgetreten ist.", @@ -2140,7 +2245,7 @@ "m.room.history_visibility": "Sichtbarkeit des Verlaufs ändern", "m.room.name": "Raumname ändern", "m.room.name_space": "Name des Space ändern", - "m.room.pinned_events": "Angeheftete Ereignisse verwalten", + "m.room.pinned_events": "Angeheftete Nachrichten verwalten", "m.room.power_levels": "Berechtigungen ändern", "m.room.redaction": "Vom mir gesendete Nachrichten löschen", "m.room.server_acl": "Server-ACLs bearbeiten", @@ -2297,25 +2402,37 @@ "brand_version": "Version von %(brand)s:", "clear_cache_reload": "Zwischenspeicher löschen und neu laden", "crypto_version": "Krypto-Version:", + "dialog_title": "Einstellungen: Hilfe & Info", "help_link": "Um Hilfe zur Benutzung von %(brand)s zu erhalten, klicke hier.", "homeserver": "Heim-Server ist %(homeserverUrl)s", "identity_server": "Identitäts-Server ist %(identityServerUrl)s", - "title": "Hilfe und Info", + "title": "Hilfe & Info", "versions": "Versionen" } }, "settings": { + "account": { + "dialog_title": "Einstellunge: Konto", + "title": "Konto" + }, "all_rooms_home": "Alle Räume auf Startseite anzeigen", "all_rooms_home_description": "Alle Räume, denen du beigetreten bist, werden auf der Startseite erscheinen.", "always_show_message_timestamps": "Nachrichtenzeitstempel immer anzeigen", "appearance": { + "compact_layout": "Kompakten Text und Nachrichten anzeigen", + "compact_layout_description": "Um diese Funktion nutzen zu können, muss das moderne Nachrichtenlayout ausgewählt sein.", "custom_font": "Systemschriftart verwenden", "custom_font_description": "Setze den Schriftnamen auf eine in deinem System installierte Schriftart und %(brand)s wird versuchen, sie zu verwenden.", "custom_font_name": "Systemschriftart", "custom_font_size": "Andere Schriftgröße verwenden", - "custom_theme_error_downloading": "Fehler beim herunterladen des Themas.", + "custom_theme_add": "Benutzerdefiniertes Design hinzufügen", + "custom_theme_downloading": "Benutzerdefiniertes Design wird heruntergeladen...", + "custom_theme_help": "Gib die URL des einzustellenden benutzerdefinierten Designs ein.", "custom_theme_invalid": "Ungültiges Designschema.", + "dialog_title": "Einstellungen: Erscheinungsbild", "font_size": "Schriftgröße", + "font_size_default": "%(fontSize)s (Standard)", + "high_contrast": "Hochkontrast", "image_size_default": "Standard", "image_size_large": "Groß", "layout_bubbles": "Nachrichtenblasen", @@ -2330,6 +2447,9 @@ "code_block_expand_default": "Quelltextblöcke standardmäßig erweitern", "code_block_line_numbers": "Zeilennummern in Quelltextblöcken", "disable_historical_profile": "Aktuelle Profilbilder und Anzeigenamen im Verlauf anzeigen", + "discovery": { + "title": "Wie man Sie findet" + }, "emoji_autocomplete": "Emoji-Vorschläge während Eingabe", "enable_markdown": "Markdown aktivieren", "enable_markdown_description": "Beginne Nachrichten mit /plain, um sie ohne Markdown zu senden.", @@ -2345,6 +2465,13 @@ "add_msisdn_dialog_title": "Telefonnummer hinzufügen", "add_msisdn_instructions": "Gib den per SMS an +%(msisdn)s gesendeten Bestätigungscode ein.", "add_msisdn_misconfigured": "Das MSISDN-Verknüpfungsverfahren ist falsch konfiguriert", + "allow_spellcheck": "Rechtschreibprüfung zulassen", + "application_language_reload_hint": "Nach Änderung der Sprache wird die App neu gestartet", + "avatar_remove_progress": "Bild wird entfernt...", + "avatar_save_progress": "Bild wird hochgeladen...", + "avatar_upload_error_text": "Das Dateiformat wird nicht unterstützt oder das Bild ist größer als %(size)s.", + "avatar_upload_error_text_generic": "Das Dateiformat wird möglicherweise nicht unterstützt.", + "avatar_upload_error_title": "Profilbild konnte nicht hochgeladen werden", "confirm_adding_email_body": "Klicke unten auf den Knopf, um die hinzugefügte E-Mail-Adresse zu bestätigen.", "confirm_adding_email_title": "Hinzugefügte E-Mail-Addresse bestätigen", "deactivate_confirm_body": "Willst du dein Konto wirklich deaktivieren? Du kannst dies nicht rückgängig machen.", @@ -2352,7 +2479,6 @@ "deactivate_confirm_content": "Bestätige, dass du dein Konto deaktivieren möchtest. Wenn du fortfährst, tritt folgendes ein:", "deactivate_confirm_content_1": "Du wirst dein Konto nicht reaktivieren können", "deactivate_confirm_content_2": "Du wirst dich nicht mehr anmelden können", - "deactivate_confirm_content_3": "Niemand wird in der Lage sein deinen Benutzernamen (MXID) wiederzuverwenden, dich eingeschlossen: Der Benutzername wird nicht verfügbar bleiben", "deactivate_confirm_content_4": "Du wirst alle Unterhaltungen verlassen, in denen du dich befindest", "deactivate_confirm_content_5": "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", "deactivate_confirm_content_6": "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?", @@ -2360,10 +2486,12 @@ "deactivate_confirm_erase_label": "Meine Nachrichten vor neuen Teilnehmern verstecken", "deactivate_section": "Benutzerkonto deaktivieren", "deactivate_warning": "Die Deaktivierung deines Kontos ist unwiderruflich — sei vorsichtig!", - "discovery_email_empty": "Entdeckungsoptionen werden angezeigt, sobald du eine E-Mail-Adresse hinzugefügt hast.", + "discovery_email_empty": "Optionen zum Entdecken anderer Benutzer werden angezeigt, sobald Sie Ihre E-Mail Adresse zu Ihrem Konto hinzugefügt haben,", "discovery_email_verification_instructions": "Verifiziere den Link in deinem Posteingang", - "discovery_msisdn_empty": "Entdeckungsoptionen werden angezeigt, sobald du eine Telefonnummer hinzugefügt hast.", + "discovery_msisdn_empty": "Optionen zum Entdecken anderer Benutzer werden angezeigt, sobald Sie Ihre Telefonnummer zu Ihrem Konto hinzugefügt haben", "discovery_needs_terms": "Stimme den Nutzungsbedingungen des Identitäts-Servers %(serverName)s zu, um per E-Mail-Adresse oder Telefonnummer auffindbar zu werden.", + "display_name": "Anzeigename", + "display_name_error": "Anzeigename konnte nicht gesetzt werden", "email_address_in_use": "Diese E-Mail-Adresse wird bereits verwendet", "email_address_label": "E-Mail-Adresse", "email_not_verified": "Deine E-Mail-Adresse wurde noch nicht verifiziert", @@ -2388,18 +2516,23 @@ "error_share_msisdn_discovery": "Teilen der Telefonnummer nicht möglich", "identity_server_no_token": "Kein Identitäts-Zugangs-Token gefunden", "identity_server_not_set": "Kein Identitäts-Server festgelegt", - "language_section": "Sprache und Region", + "language_section": "Sprache", "msisdn_in_use": "Diese Telefonnummer wird bereits verwendet", "msisdn_label": "Telefonnummer", "msisdn_verification_field_label": "Bestätigungscode", "msisdn_verification_instructions": "Gib den Bestätigungscode ein, den du empfangen hast.", "msisdns_heading": "Telefonnummern", "oidc_manage_button": "Konto verwalten", - "password_change_section": "Setze neues Kontopasswort …", + "password_change_section": "Passwort des Nutzerkontos ändern...", "password_change_success": "Dein Passwort wurde erfolgreich geändert.", + "personal_info": "Persönliche Daten", + "profile_subtitle": "So sehen dich andere in der App.", + "profile_subtitle_oidc": "Dein Konto wird separat durch einen Identitätsanbieter verwaltet. Einige persönliche Daten können hier nicht geändert werden.", "remove_email_prompt": "%(email)s entfernen?", "remove_msisdn_prompt": "%(phone)s entfernen?", - "spell_check_locale_placeholder": "Wähle ein Gebietsschema" + "spell_check_locale_placeholder": "Wähle ein Gebietsschema", + "unable_to_load_emails": "E-Mail Adresse konnte nicht geladen werden", + "username": "Benutzername" }, "image_thumbnails": "Vorschauen für Bilder", "inline_url_previews_default": "URL-Vorschau standardmäßig aktivieren", @@ -2455,12 +2588,17 @@ "phrase_strong_enough": "Super! Diese Passphrase wirkt stark genug" }, "keyboard": { + "dialog_title": "Einstellungen: Tastatur", "title": "Tastatur" }, + "labs_mjolnir": { + "dialog_title": "Einstellungen: Blockierte Benutzer" + }, "notifications": { "default_setting_description": "Diese Einstellung wird standardmäßig für all deine Räume übernommen.", "default_setting_section": "Ich möchte benachrichtigt werden für (Standardeinstellung)", "desktop_notification_message_preview": "Nachrichtenvorschau in der Desktopbenachrichtigung anzeigen", + "dialog_title": "Einstellungen: Benachrichtigungen", "email_description": "E-Mail-Zusammenfassung für verpasste Benachrichtigungen erhalten", "email_section": "E-Mail-Zusammenfassung", "email_select": "Wähle, an welche E-Mail-Adresse die Zusammenfassungen gesendet werden. Verwalte deine E-Mail-Adressen unter .", @@ -2519,12 +2657,15 @@ "code_blocks_heading": "Quelltextblöcke", "compact_modern": "Modernes kompaktes Layout verwenden", "composer_heading": "Nachrichteneingabe", + "default_timezone": "Browser-Standard (%(timezone)s )", + "dialog_title": "Einstellungen: Präferenzen", "enable_hardware_acceleration": "Aktiviere die Hardwarebeschleunigung", "enable_tray_icon": "Fenster beim Schließen in die Symbolleiste minimieren", "keyboard_heading": "Tastenkombinationen", "keyboard_view_shortcuts_button": "Um alle Tastenkombinationen anzuzeigen, klicke hier.", "media_heading": "Mediendateien", "presence_description": "Teile anderen deine Aktivität und deinen Status mit.", + "publish_timezone": "Zeitzone auf öffentlichem Profil anzeigen lassen", "rm_lifetime": "Gültigkeitsdauer der Gelesen-Markierung (ms)", "rm_lifetime_offscreen": "Gültigkeitsdauer der Gelesen-Markierung außerhalb des Bildschirms (ms)", "room_directory_heading": "Raumverzeichnis", @@ -2533,7 +2674,8 @@ "show_checklist_shortcuts": "Verknüpfung zu ersten Schritten (Willkommen) anzeigen", "show_polls_button": "Zeige Pol button", "surround_text": "Sonderzeichen automatisch vor und hinter Textauswahl setzen", - "time_heading": "Zeitanzeige" + "time_heading": "Zeitanzeige", + "user_timezone": "Zeitzone festlegen" }, "prompt_invite": "Warnen, bevor du Einladungen zu ungültigen Matrix-IDs sendest", "replace_plain_emoji": "Klartext-Emoji automatisch ersetzen", @@ -2564,14 +2706,16 @@ "cross_signing_self_signing_private_key": "Selbst signierter privater Schlüssel:", "cross_signing_user_signing_private_key": "Privater Benutzerschlüssel:", "cryptography_section": "Verschlüsselung", + "dehydrated_device_description": "Die Offline-Gerätefunktion ermöglicht es Ihnen, verschlüsselte Nachrichten zu empfangen, auch wenn Sie an keinem Gerät angemeldet sind", + "dehydrated_device_enabled": "Offline-Gerät aktiviert", "delete_backup": "Lösche Sicherung", "delete_backup_confirm_description": "Bist du sicher? Du wirst alle deine verschlüsselten Nachrichten verlieren, wenn deine Schlüssel nicht gut gesichert sind.", + "dialog_title": "Einstellungen Sicherheit & Datenschutz", "e2ee_default_disabled_warning": "Deine Server-Administration hat die Ende-zu-Ende-Verschlüsselung für private Räume und Direktnachrichten standardmäßig deaktiviert.", "enable_message_search": "Nachrichtensuche in verschlüsselten Räumen aktivieren", "encryption_section": "Verschlüsselung", "error_loading_key_backup_status": "Konnte Status der Schlüsselsicherung nicht laden", "export_megolm_keys": "E2E-Raumschlüssel exportieren", - "ignore_users_empty": "Du ignorierst keine Benutzer.", "ignore_users_section": "Blockierte Benutzer", "import_megolm_keys": "E2E-Raumschlüssel importieren", "key_backup_active": "Diese Sitzung sichert deine Schlüssel.", @@ -2682,9 +2826,9 @@ "security_recommendations_description": "Verbessere deine Kontosicherheit, indem du diese Empfehlungen beherzigst.", "session_id": "Sitzungs-ID", "show_details": "Details anzeigen", - "sign_in_with_qr": "Mit QR-Code anmelden", + "sign_in_with_qr": "Neues Gerät verknüpfen", "sign_in_with_qr_button": "QR-Code anzeigen", - "sign_in_with_qr_description": "Du kannst dieses Gerät verwenden, um ein neues Gerät per QR-Code anzumelden. Dazu musst du den auf diesem Gerät angezeigten QR-Code mit deinem nicht angemeldeten Gerät einlesen.", + "sign_in_with_qr_unsupported": "Nicht unterstützt von deinem Kontoanbieter", "sign_out": "Von dieser Sitzung abmelden", "sign_out_all_other_sessions": "Von allen anderen Sitzungen abmelden (%(otherSessionsCount)s)", "sign_out_confirm_description": { @@ -2724,7 +2868,9 @@ "show_redaction_placeholder": "Platzhalter für gelöschte Nachrichten", "show_stickers_button": "Sticker-Schaltfläche", "show_typing_notifications": "Tippbenachrichtigungen anzeigen", + "showbold": "Alle Aktivitäten in Raumliste anzeigen (Punkt oder Anzahl ungelesener Nachrichten)", "sidebar": { + "dialog_title": "Einstellungen: Seitenleiste", "metaspaces_favourites_description": "Gruppiere all deine favorisierten Unterhaltungen an einem Ort.", "metaspaces_home_all_rooms": "Alle Räume anzeigen", "metaspaces_home_all_rooms_description": "Alle Räume auf der Startseite anzeigen, auch wenn sie Teil eines Space sind.", @@ -2733,10 +2879,12 @@ "metaspaces_orphans_description": "Gruppiere all deine Räume, die nicht Teil eines Spaces sind, an einem Ort.", "metaspaces_people_description": "Gruppiere all deine Direktnachrichten an einem Ort.", "metaspaces_subsection": "Anzuzeigende Spaces", - "spaces_explainer": "Räume sind Möglichkeiten, Personen zu gruppieren. Neben den Räumen, in denen du dich befindest, kannst du auch einige vorgefertigte verwenden.", + "metaspaces_video_rooms": "Videoräume und -konferenzen", + "metaspaces_video_rooms_description": "Gruppiere alle privaten Videoräume und -konferenzen.", "title": "Seitenleiste" }, "start_automatically": "Nach Systemstart automatisch starten", + "tac_only_notifications": "Benachrichtigungen nur im Thread Aktivitätszentrum anzeigen", "use_12_hour_format": "Uhrzeiten im 12-Stundenformat (z. B. 2:30 p. m.)", "use_command_enter_send_message": "Benutze Betriebssystemtaste + Eingabe um eine Nachricht zu senden", "use_command_f_search": "Nutze Command + F um den Verlauf zu durchsuchen", @@ -2750,6 +2898,7 @@ "audio_output_empty": "Keine Audioausgabe erkannt", "auto_gain_control": "Automatische Lautstärkeregelung", "connection_section": "Verbindung", + "dialog_title": "Einstellungen: Anrufe", "echo_cancellation": "Echounterdrückung", "enable_fallback_ice_server": "Ersatz-Anrufassistenz-Server erlauben (%(server)s)", "enable_fallback_ice_server_description": "Dieser wird nur verwendet, sollte dein Heim-Server keinen bieten. Deine IP-Adresse würde während eines Anrufs geteilt werden.", @@ -2771,6 +2920,9 @@ "link_title": "Link zum Raum", "permalink_message": "Link zur ausgewählten Nachricht", "permalink_most_recent": "Link zur aktuellsten Nachricht", + "share_call": "Konferenzeinladungslink", + "share_call_subtitle": "Link für externe Benutzer, um dem Anruf ohne Matrixkonto beizutreten:", + "title_link": "Link teilen", "title_message": "Raumnachricht teilen", "title_room": "Raum teilen", "title_user": "Teile Benutzer" @@ -2803,7 +2955,7 @@ "help_dialog_title": "Befehl Hilfe", "holdcall": "Den aktuellen Anruf halten", "html": "Sendet eine Nachricht als HTML, ohne sie als Markdown darzustellen", - "ignore": "Nutzer blockieren und dessen Nachrichten ausblenden", + "ignore": "Benutzer blockieren und dessen Nachrichten ausblenden", "ignore_dialog_description": "%(userId)s ist jetzt blockiert", "ignore_dialog_title": "Benutzer blockiert", "invite": "Lädt den Benutzer mit der angegebenen ID in den aktuellen Raum ein", @@ -2846,7 +2998,7 @@ "unban": "Entbannt den Benutzer mit der angegebenen ID", "unflip": "Stellt ┬──┬ ノ( ゜-゜ノ) einer Klartextnachricht voran", "unholdcall": "Beendet das Halten des Anrufs", - "unignore": "Benutzer nicht mehr ignorieren und neue Nachrichten wieder anzeigen", + "unignore": "Benutzer freigeben und ihre neuen Nachrichten wieder anzeigen", "unignore_dialog_description": "%(userId)s wird nicht mehr blockiert", "unignore_dialog_title": "Benutzer nicht mehr blockiert", "unknown_command": "Unbekannter Befehl", @@ -2971,7 +3123,7 @@ "heading_with_query": "Nutze \"%(query)s\" zum Suchen", "heading_without_query": "Suche nach", "join_button_text": "%(roomAddress)s betreten", - "keyboard_scroll_hint": "Benutze zum scrollen", + "keyboard_scroll_hint": "Benutze zum Scrollen", "message_search_section_title": "Andere Suchen", "other_rooms_in_space": "Andere Räume in %(spaceName)s", "public_rooms_label": "Öffentliche Räume", @@ -3019,12 +3171,22 @@ "one": "%(count)s Antwort", "other": "%(count)s Antworten" }, + "empty_description": "Verwende \"%(replyInThread)s\" beim Hovern über eine Nachricht.", + "empty_title": "Threads helfen dir, deine Unterhaltungen zu einem Thema leichter zu verfolgen.", "error_start_thread_existing_relation": "Du kannst keinen Thread in einem Thread starten", + "mark_all_read": "Alle als gelesen markieren", "my_threads": "Meine Threads", "my_threads_description": "Zeigt alle Threads, an denen du teilgenommen hast", "open_thread": "Thread anzeigen", "show_thread_filter": "Zeige:" }, + "threads_activity_centre": { + "header": "Thread-Aktivität", + "no_rooms_with_threads_notifs": "Sie haben noch keine Räume mit Thread-Benachrichtigungen.", + "no_rooms_with_unread_threads": "Sie haben noch keine Räume mit ungelesenen Threads.", + "release_announcement_description": "Die Thread-Benachrichtigungen wurden verschoben. Sie finden sie ab sofort hier.", + "release_announcement_header": "Thread Aktivitätszentrum" + }, "time": { "about_day_ago": "vor etwa einem Tag", "about_hour_ago": "vor etwa einer Stunde", @@ -3066,9 +3228,19 @@ }, "creation_summary_dm": "%(creator)s hat diese Direktnachricht erstellt.", "creation_summary_room": "%(creator)s hat den Raum erstellt und konfiguriert.", + "decryption_failure": { + "historical_event_no_key_backup": "Der historische Nachrichtenverlauf ist auf diesem Gerät nicht verfügbar.", + "historical_event_unverified_device": "Du musst dieses Gerät verifizieren, um auf den Nachrichtenverlauf zugreifen zu können", + "historical_event_user_not_joined": "Du hast keinen Zugriff auf diese Nachricht", + "sender_identity_previously_verified": "Die verifizierte Identität des Absenders hat sich geändert", + "sender_unsigned_device": "Von einem unsicheren Gerät verschickt.", + "unable_to_decrypt": "Entschlüsselung der Nachricht nicht möglich" + }, "disambiguated_profile": "%(displayName)s (%(matrixId)s)", "download_action_decrypting": "Entschlüsseln", "download_action_downloading": "Herunterladen", + "download_failed": "Herunterladen fehlgeschlagen", + "download_failed_description": "Beim Herunterladen dieser Datei ist ein Fehler aufgetreten", "e2e_state": "Status der Ende zu Ende Verschlüssellung", "edits": { "tooltip_label": "Am %(date)s geändert. Klicke, um Änderungen anzuzeigen.", @@ -3127,7 +3299,7 @@ }, "m.file": { "error_decrypting": "Fehler beim Entschlüsseln des Anhangs", - "error_invalid": "Ungültige Datei%(extra)s" + "error_invalid": "Ungültige Datei" }, "m.image": { "error": "Kann Bild aufgrund eines Fehlers nicht anzeigen", @@ -3242,12 +3414,12 @@ "set": "%(senderDisplayName)s hat den Raumnamen geändert zu %(roomName)s." }, "m.room.pinned_events": { - "changed": "%(senderName)s hat die angehefteten Nachrichten für diesen Raum geändert.", - "changed_link": "%(senderName)s hat die angehefteten Nachrichten geändert.", - "pinned": "%(senderName)s hat eine Nachricht angeheftet. Alle angehefteten Nachrichten anzeigen.", - "pinned_link": "%(senderName)s hat eine Nachricht angeheftet. Alle angehefteten Nachrichten anzeigen.", - "unpinned": "%(senderName)s hat eine Nachricht losgelöst. Alle angepinnten Nachrichten anzeigen.", - "unpinned_link": "%(senderName)s hat eine Nachricht losgeheftet. Alle angehefteten Nachrichten anzeigen." + "changed": "%(senderName)s hat die fixierte Nachrichten für diesen Chatroom geändert.", + "changed_link": "%(senderName)s hat die fixierten Nachrichten geändert.", + "pinned": "%(senderName)s hat eine Nachricht fixiert. Alle fixierten Nachrichten anzeigen.", + "pinned_link": "%(senderName)s hat eine Nachricht fixiert. Alle fixierten Nachrichten anzeigen.", + "unpinned": "%(senderName)s hat eine Nachricht losgelöst. Alle fixierten Nachrichten anzeigen.", + "unpinned_link": "%(senderName)s hat eine Nachricht gelöst. Alle fixierten Nachrichten anzeigen." }, "m.room.power_levels": { "changed": "%(senderName)s hat das Berechtigungslevel von %(powerLevelDiffText)s geändert.", @@ -3314,7 +3486,8 @@ "reactions": { "add_reaction_prompt": "Reaktion hinzufügen", "custom_reaction_fallback_label": "Benutzerdefinierte Reaktion", - "label": "%(reactors)s hat mit %(content)s reagiert" + "label": "%(reactors)s hat mit %(content)s reagiert", + "tooltip_caption": "hat reagiert mit %(shortName)s" }, "read_receipt_title": { "one": "Von %(count)s Person gesehen", @@ -3499,6 +3672,9 @@ "truncated_list_n_more": { "other": "Und %(count)s weitere …" }, + "unsupported_browser": { + "description": "Wenn Sie fortfahren, funktionieren einige Funktionen möglicherweise nicht mehr und es besteht das Risiko, dass Sie in Zukunft Daten verlieren. Aktualisieren Sie Ihren Browser, um die Nutzung von %(brand)s fortzusetzen." + }, "unsupported_server_description": "Dieser Server nutzt eine ältere Matrix-Version. Aktualisiere auf Matrix %(version)s, um %(brand)s fehlerfrei nutzen zu können.", "unsupported_server_title": "Dein Server wird nicht unterstützt", "update": { @@ -3516,6 +3692,10 @@ "toast_title": "Aktualisiere %(brand)s", "unavailable": "Nicht verfügbar" }, + "update_room_access_modal": { + "no_change": "Ich möchte die Zugriffsebene nicht ändern.", + "title": "Ändern Sie die Zugriffsebene des Raums" + }, "upload_failed_generic": "Die Datei „%(fileName)s“ konnte nicht hochgeladen werden.", "upload_failed_size": "Die Datei „%(fileName)s“ überschreitet das Hochladelimit deines Heim-Servers", "upload_failed_title": "Hochladen fehlgeschlagen", @@ -3525,6 +3705,7 @@ "error_files_too_large": "Die Datei ist zu groß, um hochgeladen zu werden. Die maximale Dateigröße ist %(limit)s.", "error_some_files_too_large": "Einige Dateien sind zu groß, um hochgeladen zu werden. Die maximale Dateigröße ist %(limit)s.", "error_title": "Fehler beim Hochladen", + "not_image": "Die von dir ausgewählte Datei ist keine gültige Bilddatei.", "title": "Dateien hochladen", "title_progress": "Dateien hochladen (%(current)s von %(total)s)", "upload_all_button": "Alle hochladen", @@ -3551,6 +3732,7 @@ "deactivate_confirm_action": "Konto deaktivieren", "deactivate_confirm_description": "Beim Deaktivieren wirst du abgemeldet und ein erneutes Anmelden verhindert. Zusätzlich wirst du aus allen Räumen entfernt. Diese Aktion kann nicht rückgängig gemacht werden. Bist du sicher, dass du dieses Konto deaktivieren willst?", "deactivate_confirm_title": "Konto deaktivieren?", + "dehydrated_device_enabled": "Offline-Gerät aktiviert", "demote_button": "Zurückstufen", "demote_self_confirm_description_space": "Das Entfernen von Rechten kann nicht rückgängig gemacht werden. Falls sie dir niemand anderer zurückgeben kann, kannst du sie nie wieder erhalten.", "demote_self_confirm_room": "Du wirst nicht in der Lage sein, die Änderung zurückzusetzen, da du dich degradierst. Wenn du der letze Nutzer mit Berechtigungen bist, wird es unmöglich sein die Privilegien zurückzubekommen.", @@ -3567,6 +3749,7 @@ "error_revoke_3pid_invite_title": "Einladung konnte nicht zurückgezogen werden", "hide_sessions": "Sitzungen ausblenden", "hide_verified_sessions": "Verifizierte Sitzungen ausblenden", + "ignore_button": "Blockieren", "ignore_confirm_description": "Alle Nachrichten und Einladungen der Person werden verborgen. Bist du sicher, dass du sie ignorieren möchtest?", "ignore_confirm_title": "%(user)s ignorieren", "invited_by": "%(sender)s eingeladen", @@ -3594,23 +3777,26 @@ "no_recent_messages_description": "Versuche nach oben zu scrollen, um zu sehen ob sich dort frühere Nachrichten befinden.", "no_recent_messages_title": "Keine neuen Nachrichten von %(user)s gefunden" }, - "redact_button": "Kürzlich gesendete Nachrichten entfernen", + "redact_button": "Nachrichten entfernen", "revoke_invite": "Einladung zurückziehen", "room_encrypted": "Nachrichten in diesem Raum sind Ende-zu-Ende verschlüsselt.", "room_encrypted_detail": "Diese Nachricht ist verschlüsselt. Nur Sie und der Empfänger haben den Schlüssel, um die Nachricht zu entschlüsseln.", "room_unencrypted": "Nachrichten in diesem Raum sind nicht Ende-zu-Ende verschlüsselt.", "room_unencrypted_detail": "Nachrichten in verschlüsselten Räumen können nur von dir und vom Empfänger gelesen werden.", - "share_button": "Link zu Benutzer teilen", + "send_message": "Nachricht senden", + "share_button": "Profil teilen", "unban_button_room": "Entbannen", "unban_button_space": "Entbannen", "unban_room_confirm_title": "Von %(roomName)s entbannen", "unban_space_everything": "Überall wo ich die Rechte dazu habe, entbannen", "unban_space_specific": "In ausgewählten Räumen und Spaces entbannen", "unban_space_warning": "Die Person wird keinen Zutritt zu Bereichen haben, in denen du nicht administrierst.", + "unignore_button": "Nicht mehr ignorieren", "verify_button": "Nutzer verifizieren", "verify_explainer": "Für zusätzliche Sicherheit, verifiziere diesen Nutzer, durch Vergleichen eines Einmal-Codes auf euren beiden Geräten." }, "user_menu": { + "link_new_device": "Neues Gerät verknüpfen", "settings": "Alle Einstellungen", "switch_theme_dark": "Zum dunklen Thema wechseln", "switch_theme_light": "Zum hellen Thema wechseln" @@ -3667,9 +3853,9 @@ "camera_enabled": "Deine Kamera ist noch aktiv", "cannot_call_yourself_description": "Du kannst keinen Anruf mit dir selbst starten.", "change_input_device": "Eingabegerät wechseln", + "close_lobby": "Lobby schließen", "connecting": "Verbinden", "connection_lost": "Verbindung zum Server unterbrochen", - "connection_lost_description": "Sie können keine Anrufe starten ohne Verbindung zum Server.", "consulting": "%(transferTarget)s wird angefragt. Übertragung zu %(transferee)s", "default_device": "Standardgerät", "dial": "Wählen", @@ -3680,17 +3866,26 @@ "disabled_no_perms_start_video_call": "Dir fehlt die Berechtigung, um Videoanrufe zu beginnen", "disabled_no_perms_start_voice_call": "Dir fehlt die Berechtigung, um Audioanrufe zu beginnen", "disabled_ongoing_call": "laufender Anruf", + "element_call": "Element Anruf", "enable_camera": "Kamera aktivieren", "enable_microphone": "Mikrofon aktivieren", "expand": "Zurück zum Anruf", "failed_call_live_broadcast_description": "Du kannst keinen Anruf beginnen, da du im Moment eine Sprachübertragung aufzeichnest. Bitte beende deine Sprachübertragung, um ein Gespräch zu beginnen.", "failed_call_live_broadcast_title": "Kann keinen Anruf beginnen", + "get_call_link": "Anruflink teilen", "hangup": "Auflegen", "hide_sidebar_button": "Seitenleiste verbergen", "input_devices": "Eingabegeräte", + "jitsi_call": "Jitsi-Konferenz", "join_button_tooltip_call_full": "Entschuldigung — dieser Anruf ist aktuell besetzt", "join_button_tooltip_connecting": "Verbinden", + "legacy_call": "Legacy-Anruf", "maximise": "Bildschirm füllen", + "maximise_call": "Anruf maximieren", + "metaspace_video_rooms": { + "conference_room_section": "Konferenzen" + }, + "minimise_call": "Anruf minimieren", "misconfigured_server": "Anruf aufgrund eines falsch konfigurierten Servers fehlgeschlagen", "misconfigured_server_description": "Bitte frage die Administration deines Heim-Servers (%(homeserverDomain)s) darum, einen TURN-Server einzurichten, damit Anrufe zuverlässig funktionieren.", "misconfigured_server_fallback": "Alternativ kannst du versuchen, den öffentlichen Server unter zu verwenden. Dieser wird nicht so zuverlässig sein und deine IP-Adresse wird mit ihm geteilt. Du kannst dies auch in den Einstellungen konfigurieren.", @@ -3732,12 +3927,13 @@ "unknown_person": "unbekannte Person", "unsilence": "Ton an", "unsupported": "Anrufe werden nicht unterstützt", - "unsupported_browser": "Sie können in diesem Browser keien Anrufe durchführen.", + "unsupported_browser": "Du kannst in diesem Browser keine Anrufe tätigen.", "user_busy": "Person beschäftigt", "user_busy_description": "Die angerufene Person ist momentan beschäftigt.", "user_is_presenting": "%(sharerName)s präsentiert", "video_call": "Videoanruf", "video_call_started": "Videoanruf hat begonnen", + "video_call_using": "Videoanruf mit:", "voice_call": "Sprachanruf", "you_are_presenting": "Du präsentierst" }, @@ -3846,7 +4042,7 @@ "title": "Erlaube diesem Widget deine Identität zu überprüfen" }, "popout": "Widget in eigenem Fenster öffnen", - "set_room_layout": "Dein Raumlayout für alle setzen", + "set_room_layout": "Layout für alle festlegen", "shared_data_avatar": "Deine Profilbild-URL", "shared_data_device_id": "Deine Geräte-ID", "shared_data_lang": "Deine Sprache", @@ -3872,7 +4068,6 @@ "l33t": "Vorhersagbare Ersetzungen wie „@“ anstelle von „a“ helfen nicht besonders", "longerKeyboardPattern": "Nutze ein längeres Tastaturmuster mit mehr Abwechslung", "noNeed": "Kein Bedarf an Symbolen, Zahlen oder Großbuchstaben", - "pwned": "Wenn Sie dieses Passwort woanders verwenden, sollten Sie es ändern.", "recentYears": "Vermeide die letzten Jahre", "repeated": "Vermeide wiederholte Worte und Zeichen", "reverseWords": "Umgedrehte Worte sind nicht schwerer zu erraten", diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 66428300a0..e601a7ecd5 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1002,7 +1002,7 @@ "unverified_sessions_toast_description": "Review to ensure your account is safe", "unverified_sessions_toast_reject": "Later", "unverified_sessions_toast_title": "You have unverified sessions", - "verification_description": "Verify your identity to access encrypted messages and prove your identity to others.", + "verification_description": "Verify your identity to access encrypted messages and prove your identity to others. If you also use a mobile device, please open the app there before you proceed.", "verification_dialog_title_device": "Verify other device", "verification_dialog_title_user": "Verification Request", "verification_skip_warning": "Without verifying, you won't have access to all your messages and may appear as untrusted to others.", @@ -1087,10 +1087,6 @@ }, "error_user_not_logged_in": "User is not logged in", "event_preview": { - "io.element.voice_broadcast_info": { - "user": "%(senderName)s ended a voice broadcast", - "you": "You ended a voice broadcast" - }, "m.call.answer": { "dm": "Call in progress", "user": "%(senderName)s joined the call", @@ -1491,8 +1487,6 @@ "video_rooms_faq2_answer": "Yes, the chat timeline is displayed alongside the video.", "video_rooms_faq2_question": "Can I use text chat alongside the video call?", "video_rooms_feedbackSubheading": "Thank you for trying the beta, please go into as much detail as you can so we can improve it.", - "voice_broadcast": "Voice broadcast", - "voice_broadcast_force_small_chunks": "Force 15s voice broadcast chunk length", "wysiwyg_composer": "Rich text editor" }, "labs_mjolnir": { @@ -1638,7 +1632,6 @@ "mute_description": "You won't get any notifications" }, "notifier": { - "io.element.voice_broadcast_chunk": "%(senderName)s started a voice broadcast", "m.key.verification.request": "%(name)s is requesting verification" }, "onboarding": { @@ -2253,7 +2246,6 @@ "error_unbanning": "Failed to unban", "events_default": "Send messages", "invite": "Invite users", - "io.element.voice_broadcast_info": "Voice broadcasts", "kick": "Remove users", "m.call": "Start %(brand)s calls", "m.call.member": "Join %(brand)s calls", @@ -2952,7 +2944,7 @@ "warning": "WARNING: " }, "share": { - "link_title": "Link to room", + "link_copied": "Link copied", "permalink_message": "Link to selected message", "permalink_most_recent": "Link to most recent message", "share_call": "Conference invite link", @@ -3287,10 +3279,6 @@ "error_rendering_message": "Can't load this message", "historical_messages_unavailable": "You can't see earlier messages", "in_room_name": " in %(room)s", - "io.element.voice_broadcast_info": { - "user": "%(senderName)s ended a voice broadcast", - "you": "You ended a voice broadcast" - }, "io.element.widgets.layout": "%(senderName)s has updated the room layout", "late_event_separator": "Originally sent %(dateTime)s", "load_error": { @@ -3840,38 +3828,6 @@ "switch_theme_dark": "Switch to dark mode", "switch_theme_light": "Switch to light mode" }, - "voice_broadcast": { - "30s_backward": "30s backward", - "30s_forward": "30s forward", - "action": "Voice broadcast", - "buffering": "Buffering…", - "confirm_listen_affirm": "Yes, end my recording", - "confirm_listen_description": "If you start listening to this live broadcast, your current live broadcast recording will be ended.", - "confirm_listen_title": "Listen to live broadcast?", - "confirm_stop_affirm": "Yes, stop broadcast", - "confirm_stop_description": "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.", - "confirm_stop_title": "Stop live broadcasting?", - "connection_error": "Connection error - Recording paused", - "failed_already_recording_description": "You are already recording a voice broadcast. Please end your current voice broadcast to start a new one.", - "failed_already_recording_title": "Can't start a new voice broadcast", - "failed_decrypt": "Unable to decrypt voice broadcast", - "failed_generic": "Unable to play this voice broadcast", - "failed_insufficient_permission_description": "You don't have the required permissions to start a voice broadcast in this room. Contact a room administrator to upgrade your permissions.", - "failed_insufficient_permission_title": "Can't start a new voice broadcast", - "failed_no_connection_description": "Unfortunately we're unable to start a recording right now. Please try again later.", - "failed_no_connection_title": "Connection error", - "failed_others_already_recording_description": "Someone else is already recording a voice broadcast. Wait for their voice broadcast to end to start a new one.", - "failed_others_already_recording_title": "Can't start a new voice broadcast", - "go_live": "Go live", - "live": "Live", - "pause": "pause voice broadcast", - "play": "play voice broadcast", - "resume": "resume voice broadcast" - }, - "voice_message": { - "cant_start_broadcast_description": "You can't start a voice message as you are currently recording a live broadcast. Please end your live broadcast in order to start recording a voice message.", - "cant_start_broadcast_title": "Can't start voice message" - }, "voip": { "already_in_call": "Already in call", "already_in_call_person": "You're already in a call with this person.", @@ -3891,7 +3847,6 @@ "camera_disabled": "Your camera is turned off", "camera_enabled": "Your camera is still enabled", "cannot_call_yourself_description": "You cannot place a call with yourself.", - "change_input_device": "Change input device", "close_lobby": "Close lobby", "connecting": "Connecting", "connection_lost": "Connectivity to the server has been lost", @@ -3910,8 +3865,6 @@ "enable_camera": "Turn on camera", "enable_microphone": "Unmute microphone", "expand": "Return to call", - "failed_call_live_broadcast_description": "You can’t start a call as you are currently recording a live broadcast. Please end your live broadcast in order to start a call.", - "failed_call_live_broadcast_title": "Can’t start a call", "get_call_link": "Share call link", "hangup": "Hangup", "hide_sidebar_button": "Hide sidebar", diff --git a/src/i18n/strings/fr.json b/src/i18n/strings/fr.json index 7d209a8a45..0462e74766 100644 --- a/src/i18n/strings/fr.json +++ b/src/i18n/strings/fr.json @@ -229,6 +229,7 @@ }, "misconfigured_body": "Demandez à votre administrateur %(brand)s de vérifier que votre configuration ne contient pas d’entrées incorrectes ou en double.", "misconfigured_title": "Votre %(brand)s est mal configuré", + "mobile_create_account_title": "Vous êtes sur le point de créer un compte sur %(hsName)s", "msisdn_field_description": "D’autres utilisateurs peuvent vous inviter à des salons grâce à vos informations de contact", "msisdn_field_label": "Numéro de téléphone", "msisdn_field_number_invalid": "Ce numéro de téléphone ne semble pas correct, merci de vérifier et réessayer", @@ -279,6 +280,8 @@ "security_code": "Code de sécurité", "security_code_prompt": "Si vous y êtes invité, saisissez le code ci-dessous sur votre autre appareil.", "select_qr_code": "Sélectionnez « %(scanQRCode)s »", + "unsupported_explainer": "Votre fournisseur de compte ne prend pas en charge la connexion à un nouvel appareil à l’aide d’un code QR.", + "unsupported_heading": "Le code QR n'est pas pris en charge", "waiting_for_device": "En attente de connexion de l’appareil" }, "register_action": "Créer un compte", @@ -367,6 +370,8 @@ "email_resend_prompt": "Vous ne l’avez pas reçu ? Le renvoyer", "email_resent": "Ré-envoyé !", "fallback_button": "Commencer l’authentification", + "mas_cross_signing_reset_cta": "Accédez à votre compte", + "mas_cross_signing_reset_description": "Réinitialisez votre identité par l’intermédiaire de votre fournisseur de compte, puis revenez et cliquez sur « Réessayer ».", "msisdn": "Un message a été envoyé à %(msisdn)s", "msisdn_token_incorrect": "Jeton incorrect", "msisdn_token_prompt": "Merci de saisir le code qu’il contient :", @@ -1227,6 +1232,14 @@ "other": "Dans %(spaceName)s et %(count)s autres espaces." }, "incompatible_browser": { + "continue": "Continuez quand même", + "description": "%(brand)s utilise certaines fonctionnalités du navigateur qui ne sont pas disponibles dans votre navigateur actuel. %(detail)s", + "detail_can_continue": "Si vous continuez, certaines fonctionnalités pourraient cesser de fonctionner et vous risquez de perdre des données à l'avenir.", + "detail_no_continue": "Essayez de mettre à jour ce navigateur si vous n'utilisez pas la dernière version, puis réessayez.", + "learn_more": "En savoir plus", + "linux": "Linux", + "macos": "MAC", + "supported_browsers": "Pour une expérience optimale, utilisez Chrome, Firefox, Edge, ou Safari.", "title": "Navigateur non pris en charge" }, "info_tooltip_title": "Informations", @@ -1351,12 +1364,14 @@ "navigate_next_message_edit": "Aller vers le prochain message à modifier", "navigate_prev_history": "Salon ou espace précédemment visité", "navigate_prev_message_edit": "Allez vers le précédent message à modifier", + "next_landmark": "Aller au prochain point de repère", "next_room": "Prochain salon ou conversation privée", "next_unread_room": "Prochain salon ou conversation privée non lu", "number": "[numéro]", "open_user_settings": "Ouvrir les paramètres de l'utilisateur", "page_down": "Page Bas", "page_up": "Page Haut", + "prev_landmark": "Aller au point de repère précédent", "prev_room": "Précédent salon ou conversation privée", "prev_unread_room": "Précédent salon ou conversation privée non lu", "room_list_collapse_section": "Réduire la section de la liste des salons", @@ -1401,8 +1416,11 @@ "dynamic_room_predecessors": "Prédécesseurs de salon dynamique", "dynamic_room_predecessors_description": "Active MSC3946 (pour prendre en charge les archives de salon après création)", "element_call_video_rooms": "Salons vidéo Element Call", + "exclude_insecure_devices": "Exclure les appareils non sécurisés lors de l'envoi/de la réception de messages", + "exclude_insecure_devices_description": "Lorsque ce mode est activé, les messages chiffrés ne seront pas partagés avec des appareils non vérifiés et les messages provenant d'appareils non vérifiés seront affichés comme une erreur. Notez que si vous activez ce mode, il se peut que vous ne puissiez pas communiquer avec les utilisateurs qui n'ont pas vérifié leurs appareils.", "experimental_description": "Envie d’expériences ? Essayez nos dernières idées en développement. Ces fonctionnalités ne sont pas terminées ; elles peuvent changer, être instables, ou être complètement abandonnées. En savoir plus.", "experimental_section": "Avant-premières", + "extended_profiles_msc_support": "Nécessite que votre serveur prenne en charge MSC4133", "feature_disable_call_per_sender_encryption": "Désactiver le chiffrement de chaque expéditeur pour Element Call", "feature_wysiwyg_composer_description": "Utilise le texte formaté au lieu de Markdown dans le compositeur de message.", "group_calls": "Nouvelle expérience d’appel de groupe", @@ -1805,8 +1823,12 @@ "right_panel": { "add_integrations": "Ajouter des extensions", "add_topic": "Ajouter un sujet", + "extensions_button": "Extensions", + "extensions_empty_description": "Sélectionnez « %(addIntegrations)s » pour explorer et ajouter des extensions à ce salon", + "extensions_empty_title": "Augmentez votre productivité avec plus d’outils, de widgets et de bots", "files_button": "Fichiers", "pinned_messages": { + "empty_description": "Sélectionnez un message et choisissez « %(pinAction)s » pour l'inclure ici.", "empty_title": "Épingler des messages importants afin qu'ils puissent être facilement découverts", "header": { "one": "1 Message épinglé", @@ -1817,11 +1839,17 @@ }, "menu": "Ouvrir le menu", "release_announcement": { + "close": "Ok", + "description": "Retrouvez tous les messages épinglés ici. Survolez n'importe quel message et sélectionnez « Épingler » pour l'ajouter.", "title": "Tous les nouveaux messages épinglés" }, + "reply_thread": "Répondre à un un message de fil de discussion", "unpin_all": { - "button": "Désépingler tous les messages" - } + "button": "Désépingler tous les messages", + "content": "Assurez-vous que vous voulez vraiment supprimer tous les messages épinglés. Cette action ne peut pas être annulée.", + "title": "Désépingler tous les messages ?" + }, + "view": "Voir dans la discussion" }, "pinned_messages_button": "Messages épinglés", "poll": { @@ -1926,6 +1954,7 @@ }, "room_is_public": "Ce salon est public" }, + "header_avatar_open_settings_label": "Ouvrir les paramètres du salon", "header_face_pile_tooltip": "Personnes", "header_untrusted_label": "Non fiable", "inaccessible": "Ce salon ou cet espace n’est pas accessible en ce moment.", @@ -1996,8 +2025,13 @@ "not_found_title": "Ce salon ou cet espace n’existe pas.", "not_found_title_name": "%(roomName)s n’existe pas.", "peek_join_prompt": "Ceci est un aperçu de %(roomName)s. Voulez-vous rejoindre le salon ?", + "pinned_message_badge": "Message épinglé", "pinned_message_banner": { - "description": "Ce salon contient des messages épinglés. Cliquez pour les consulter." + "button_close_list": "Fermer la liste", + "button_view_all": "Voir tout", + "description": "Ce salon contient des messages épinglés. Cliquez pour les consulter.", + "go_to_message": "Afficher le message épinglé dans la discussion.", + "title": "%(index)s de %(length)s messages épinglés" }, "read_topic": "Cliquer pour lire le sujet", "rejecting": "Rejet de l’invitation…", @@ -2005,6 +2039,10 @@ "search": { "all_rooms_button": "Rechercher dans tous les salons", "placeholder": "Rechercher des messages…", + "summary": { + "one": "1 résultat trouvé pour «  »", + "other": "%(count)srésultats trouvés pour «  »" + }, "this_room_button": "Rechercher dans ce salon" }, "status_bar": { @@ -2140,6 +2178,8 @@ "error_deleting_alias_description": "Une erreur est survenue lors de la suppression de cette adresse. Elle n’existe peut-être plus ou une erreur temporaire est survenue.", "error_deleting_alias_description_forbidden": "Vous n’avez pas la permission de supprimer cette adresse.", "error_deleting_alias_title": "Erreur lors de la suppression de l’adresse", + "error_publishing": "Impossible de publier le salon", + "error_publishing_detail": "Une erreur s'est produite lors de la publication du salon", "error_save_space_settings": "Échec de l’enregistrement des paramètres.", "error_updating_alias_description": "Une erreur est survenue lors de la mise à jour des adresses alternatives du salon. Ce n’est peut-être pas permis par le serveur ou une défaillance temporaire est survenue.", "error_updating_canonical_alias_description": "Une erreur est survenue lors de la mise à jour de l’adresse principale de salon. Ce n’est peut-être pas autorisé par le serveur ou une erreur temporaire est survenue.", @@ -2376,16 +2416,25 @@ } }, "settings": { + "account": { + "dialog_title": "Paramètres : Compte", + "title": "Compte" + }, "all_rooms_home": "Afficher tous les salons dans Accueil", "all_rooms_home_description": "Tous les salons dans lesquels vous vous trouvez apparaîtront sur l’Accueil.", "always_show_message_timestamps": "Toujours afficher l’heure des messages", "appearance": { "bundled_emoji_font": "Utilise la police d’émoji interne", + "compact_layout": "Afficher le texte et les messages compacts", + "compact_layout_description": "La mise en page moderne doit être sélectionnée pour utiliser cette fonctionnalité.", "custom_font": "Utiliser une police du système", "custom_font_description": "Définissez le nom d’une police de caractères installée sur votre système et %(brand)s essaiera de l’utiliser.", "custom_font_name": "Nom de la police du système", "custom_font_size": "Utiliser une taille personnalisée", + "custom_theme_add": "Ajouter un thème personnalisé", + "custom_theme_downloading": "Téléchargement du thème personnalisé…", "custom_theme_error_downloading": "Erreur lors du téléchargement du thème", + "custom_theme_help": "Entrez l'URL du thème personnalisé que vous souhaitez appliquer.", "custom_theme_invalid": "Schéma du thème invalide.", "dialog_title": "Paramètres : Apparence", "font_size": "Taille de la police", @@ -2405,6 +2454,9 @@ "code_block_expand_default": "Développer les blocs de code par défaut", "code_block_line_numbers": "Afficher les numéros de ligne dans les blocs de code", "disable_historical_profile": "Afficher l’image de profil et le nom actuels des utilisateurs dans l’historique des messages", + "discovery": { + "title": "Comment vous trouver" + }, "emoji_autocomplete": "Activer la suggestion d’émojis lors de la saisie", "enable_markdown": "Activer Markdown", "enable_markdown_description": "Commencez les messages avec /plain pour les envoyer sans markdown.", @@ -2420,10 +2472,14 @@ "add_msisdn_dialog_title": "Ajouter un numéro de téléphone", "add_msisdn_instructions": "Un SMS a été envoyé à +%(msisdn)s. Saisissez le code de vérification qu’il contient.", "add_msisdn_misconfigured": "L’ajout / liaison avec le flux MSISDN est mal configuré", + "allow_spellcheck": "Autoriser la vérification orthographique", + "application_language": "Langue de l'application", "application_language_reload_hint": "L’application se rechargera après avoir sélectionné une autre langue", "avatar_remove_progress": "Suppression de l'image...", "avatar_save_progress": "Chargement de l'image...", + "avatar_upload_error_text": "Le format de fichier n'est pas pris en charge ou l'image est plus grande que%(size)s.", "avatar_upload_error_text_generic": "Le format de fichier n'est peut-être pas pris en charge.", + "avatar_upload_error_title": "L'image de l'avatar n'a pas pu être téléchargée", "confirm_adding_email_body": "Cliquez sur le bouton ci-dessous pour confirmer l’ajout de l’adresse e-mail.", "confirm_adding_email_title": "Confirmer l’ajout de l’adresse e-mail", "deactivate_confirm_body": "Voulez-vous vraiment désactiver votre compte ? Ceci est irréversible.", @@ -2443,6 +2499,7 @@ "discovery_email_verification_instructions": "Vérifiez le lien dans votre boîte de réception", "discovery_msisdn_empty": "Les options de découverte apparaîtront quand vous aurez ajouté un numéro de téléphone ci-dessus.", "discovery_needs_terms": "Acceptez les conditions de service du serveur d’identité (%(serverName)s) pour vous permettre d’être découvrable par votre adresse e-mail ou votre numéro de téléphone.", + "discovery_needs_terms_title": "Laissez les gens vous trouver", "display_name": "Nom d'affichage", "display_name_error": "Impossible de définir le nom d'affichage", "email_address_in_use": "Cette adresse e-mail est déjà utilisée", @@ -2479,10 +2536,13 @@ "password_change_section": "Définir un nouveau mot de passe de compte…", "password_change_success": "Votre mot de passe a été mis à jour.", "personal_info": "Informations personnelles", + "profile_subtitle": "Voici comment vous apparaissez aux autres utilisateurs de l'application.", "profile_subtitle_oidc": "Votre compte est géré séparément par un fournisseur d'identité et certaines de vos informations personnelles ne peuvent donc pas être modifiées ici.", "remove_email_prompt": "Supprimer %(email)s ?", "remove_msisdn_prompt": "Supprimer %(phone)s ?", "spell_check_locale_placeholder": "Choisir une langue", + "unable_to_load_emails": "Impossible de charger les adresses e-mail", + "unable_to_load_msisdns": "Impossible de charger les numéros de téléphone", "username": "Nom d’utilisateur" }, "image_thumbnails": "Afficher les aperçus/vignettes pour les images", @@ -2611,6 +2671,7 @@ "code_blocks_heading": "Blocs de code", "compact_modern": "Utiliser une mise en page « moderne » plus compacte", "composer_heading": "Compositeur", + "default_timezone": "Navigateur par défaut (%(timezone)s)", "dialog_title": "Paramètres : Préférences", "enable_hardware_acceleration": "Activer l’accélération matérielle", "enable_tray_icon": "Afficher l’icône dans la barre d’état et minimiser la fenêtre lors de la fermeture", @@ -2618,6 +2679,7 @@ "keyboard_view_shortcuts_button": "Pour voir tous les raccourcis claviers, cliquez ici.", "media_heading": "Images, GIF et vidéos", "presence_description": "Partager votre activité et votre statut avec les autres.", + "publish_timezone": "Publier le fuseau horaire sur le profil public", "rm_lifetime": "Durée de vie du repère de lecture (ms)", "rm_lifetime_offscreen": "Durée de vie du repère de lecture en dehors de l’écran (ms)", "room_directory_heading": "Répertoire des salons", @@ -2626,7 +2688,8 @@ "show_checklist_shortcuts": "Afficher le raccourci vers la liste de vérification de bienvenue au-dessus de la liste des salons", "show_polls_button": "Afficher le bouton des sondages", "surround_text": "Entourer le texte sélectionné lors de la saisie de certains caractères", - "time_heading": "Affichage de l’heure" + "time_heading": "Affichage de l’heure", + "user_timezone": "Définir le fuseau horaire" }, "prompt_invite": "Demander avant d’envoyer des invitations à des identifiants matrix potentiellement non valides", "replace_plain_emoji": "Remplacer automatiquement le texte par des émojis", @@ -2782,6 +2845,7 @@ "sign_in_with_qr": "Associer un nouvel appareil", "sign_in_with_qr_button": "Afficher le QR code", "sign_in_with_qr_description": "Utilisez un code QR pour vous connecter à un autre appareil et configurer votre messagerie sécurisée.", + "sign_in_with_qr_unsupported": "Non pris en charge par votre fournisseur de compte", "sign_out": "Se déconnecter de cette session", "sign_out_all_other_sessions": "Déconnecter toutes les autres sessions (%(otherSessionsCount)s)", "sign_out_confirm_description": { @@ -3188,6 +3252,8 @@ "historical_event_no_key_backup": "L'historique des messages n'est pas disponible sur cet appareil", "historical_event_unverified_device": "Vous devez vérifier cet appareil pour accéder à l'historique des messages", "historical_event_user_not_joined": "Vous n'avez pas accès à ce message", + "sender_identity_previously_verified": "L'identité vérifiée de l'expéditeur a changé", + "sender_unsigned_device": "Envoyé depuis un appareil non sécurisé.", "unable_to_decrypt": "Impossible de déchiffrer le message" }, "disambiguated_profile": "%(displayName)s (%(matrixId)s)", @@ -3195,6 +3261,7 @@ "download_action_downloading": "Téléchargement en cours", "download_failed": "Échec du téléchargement", "download_failed_description": "Une erreur s'est produite lors du téléchargement de ce fichier", + "e2e_state": "État du chiffrement de bout en bout", "edits": { "tooltip_label": "Modifié le %(date)s. Cliquer pour voir les modifications.", "tooltip_sub": "Cliquez pour voir les modifications", @@ -3439,7 +3506,8 @@ "reactions": { "add_reaction_prompt": "Ajouter une réaction", "custom_reaction_fallback_label": "Réaction personnalisée", - "label": "%(reactors)s ont réagi avec %(content)s" + "label": "%(reactors)s ont réagi avec %(content)s", + "tooltip_caption": "a réagi avec %(shortName)s" }, "read_receipt_title": { "one": "Vu par %(count)s personne", @@ -3624,6 +3692,10 @@ "truncated_list_n_more": { "other": "Et %(count)s autres…" }, + "unsupported_browser": { + "description": "Si vous continuez, certaines fonctionnalités risquent de cesser de fonctionner et vous risquez de perdre des données à l'avenir. Mettez à jour votre navigateur pour continuer à utiliser%(brand)s .", + "title": "%(brand)sne prend pas en charge ce navigateur" + }, "unsupported_server_description": "Ce serveur utilise une ancienne version de Matrix. Mettez-le à jour vers Matrix %(version)s pour utiliser %(brand)s sans erreurs.", "unsupported_server_title": "Votre serveur n’est pas pris en charge", "update": { @@ -3682,6 +3754,7 @@ "deactivate_confirm_action": "Désactiver l’utilisateur", "deactivate_confirm_description": "Désactiver cet utilisateur le déconnectera et l’empêchera de se reconnecter. De plus, il quittera tous les salons qu’il a rejoints. Cette action ne peut pas être annulée. Voulez-vous vraiment désactiver cet utilisateur ?", "deactivate_confirm_title": "Désactiver l’utilisateur ?", + "dehydrated_device_enabled": "Appareil hors ligne activé", "demote_button": "Rétrograder", "demote_self_confirm_description_space": "Vous ne pourrez pas annuler ce changement puisque vous vous rétrogradez. Si vous êtes le dernier utilisateur a privilèges de cet espace, il deviendra impossible d’en reprendre contrôle.", "demote_self_confirm_room": "Vous ne pourrez pas annuler cette modification car vous vous rétrogradez. Si vous êtes le dernier utilisateur privilégié de ce salon, il sera impossible de récupérer les privilèges.", @@ -3698,6 +3771,7 @@ "error_revoke_3pid_invite_title": "Échec de la révocation de l’invitation", "hide_sessions": "Masquer les sessions", "hide_verified_sessions": "Masquer les sessions vérifiées", + "ignore_button": "Ignorer", "ignore_confirm_description": "Tous les messages et invitations de cette utilisateur seront cachés. Êtes-vous sûr de vouloir les ignorer ?", "ignore_confirm_title": "Ignorer %(user)s", "invited_by": "Invité par %(sender)s", @@ -3731,6 +3805,7 @@ "room_encrypted_detail": "Vos messages sont sécurisés et seuls vous et le destinataire avez les clés uniques pour les déchiffrer.", "room_unencrypted": "Les messages dans ce salon ne sont pas chiffrés de bout en bout.", "room_unencrypted_detail": "Dans les salons chiffrés, vos messages sont sécurisés et seuls vous et le destinataire avez les clés uniques pour les déchiffrer.", + "send_message": "Envoyer un message", "share_button": "Partager le profil", "unban_button_room": "Révoquer le bannissement du salon", "unban_button_space": "Révoquer le bannissement de l’espace", @@ -3738,6 +3813,7 @@ "unban_space_everything": "Annuler le bannissement de partout où j’ai le droit de le faire", "unban_space_specific": "Annuler le bannissement de certains endroits où j’ai le droit de le faire", "unban_space_warning": "Ils ne pourront plus accéder aux endroits dans lesquels vous n’êtes pas administrateur.", + "unignore_button": "Ne plus ignorer", "verify_button": "Vérifier l’utilisateur", "verify_explainer": "Pour une sécurité supplémentaire, vérifiez cet utilisateur en comparant un code à usage unique sur vos deux appareils." }, @@ -3826,6 +3902,7 @@ "jitsi_call": "Conférence Jitsi", "join_button_tooltip_call_full": "Désolé — Cet appel est actuellement complet", "join_button_tooltip_connecting": "Connexion", + "legacy_call": "Appel vidéo", "maximise": "Remplir l’écran", "maximise_call": "Plein écran", "metaspace_video_rooms": { diff --git a/src/indexing/EventIndex.ts b/src/indexing/EventIndex.ts index fc1be4eba5..da2eee995c 100644 --- a/src/indexing/EventIndex.ts +++ b/src/indexing/EventIndex.ts @@ -819,7 +819,11 @@ export default class EventIndex extends EventEmitter { // Add the events to the timeline of the file panel. matrixEvents.forEach((e) => { if (!timelineSet.eventIdToTimeline(e.getId()!)) { - timelineSet.addEventToTimeline(e, timeline, direction == EventTimeline.BACKWARDS); + timelineSet.addEventToTimeline(e, timeline, { + toStartOfTimeline: direction == EventTimeline.BACKWARDS, + fromCache: false, + addToState: false, + }); } }); diff --git a/src/rageshake/rageshake.ts b/src/rageshake/rageshake.ts index 763df51d95..c68fa8503c 100644 --- a/src/rageshake/rageshake.ts +++ b/src/rageshake/rageshake.ts @@ -97,6 +97,7 @@ export class ConsoleLogger { // run. // Example line: // 2017-01-18T11:23:53.214Z W Failed to set badge count + // eslint-disable-next-line @typescript-eslint/no-base-to-string let line = `${ts} ${level} ${args.join(" ")}\n`; // Do some cleanup line = line.replace(/token=[a-zA-Z0-9-]+/gm, "token=xxxxx"); diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index 1c27f03e88..6cd5b15a51 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -85,18 +85,9 @@ export enum LabGroup { } export enum Features { - VoiceBroadcast = "feature_voice_broadcast", - VoiceBroadcastForceSmallChunks = "feature_voice_broadcast_force_small_chunks", NotificationSettings2 = "feature_notification_settings2", OidcNativeFlow = "feature_oidc_native_flow", ReleaseAnnouncement = "feature_release_announcement", - - /** If true, use the Rust crypto implementation. - * - * This is no longer read, but we continue to populate it on all devices, to guard against people rolling back to - * old versions of EW that do not use rust crypto by default. - */ - RustCrypto = "feature_rust_crypto", } export const labGroupNames: Record = { @@ -447,19 +438,6 @@ export const SETTINGS: { [setting: string]: ISetting } = { shouldWarn: true, default: false, }, - [Features.VoiceBroadcast]: { - isFeature: true, - labsGroup: LabGroup.Messaging, - supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS_WITH_CONFIG_PRIORITISED, - supportedLevelsAreOrdered: true, - displayName: _td("labs|voice_broadcast"), - default: false, - }, - [Features.VoiceBroadcastForceSmallChunks]: { - supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS, - displayName: _td("labs|voice_broadcast_force_small_chunks"), - default: false, - }, [Features.OidcNativeFlow]: { isFeature: true, labsGroup: LabGroup.Developer, @@ -469,10 +447,6 @@ export const SETTINGS: { [setting: string]: ISetting } = { description: _td("labs|oidc_native_flow_description"), default: false, }, - [Features.RustCrypto]: { - supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS, - default: true, - }, /** * @deprecated in favor of {@link fontSizeDelta} */ diff --git a/src/stores/RoomViewStore.tsx b/src/stores/RoomViewStore.tsx index 53e25736f0..66644c06a1 100644 --- a/src/stores/RoomViewStore.tsx +++ b/src/stores/RoomViewStore.tsx @@ -42,15 +42,6 @@ import { UPDATE_EVENT } from "./AsyncStore"; import { SdkContextClass } from "../contexts/SDKContext"; import { CallStore } from "./CallStore"; import { ThreadPayload } from "../dispatcher/payloads/ThreadPayload"; -import { - doClearCurrentVoiceBroadcastPlaybackIfStopped, - doMaybeSetCurrentVoiceBroadcastPlayback, - VoiceBroadcastRecording, - VoiceBroadcastRecordingsStoreEvent, -} from "../voice-broadcast"; -import { IRoomStateEventsActionPayload } from "../actions/MatrixActionCreators"; -import { showCantStartACallDialog } from "../voice-broadcast/utils/showCantStartACallDialog"; -import { pauseNonLiveBroadcastFromOtherRoom } from "../voice-broadcast/utils/pauseNonLiveBroadcastFromOtherRoom"; import { ActionPayload } from "../dispatcher/payloads"; import { CancelAskToJoinPayload } from "../dispatcher/payloads/CancelAskToJoinPayload"; import { SubmitAskToJoinPayload } from "../dispatcher/payloads/SubmitAskToJoinPayload"; @@ -164,10 +155,6 @@ export class RoomViewStore extends EventEmitter { ) { super(); this.resetDispatcher(dis); - this.stores.voiceBroadcastRecordingsStore.addListener( - VoiceBroadcastRecordingsStoreEvent.CurrentChanged, - this.onCurrentBroadcastRecordingChanged, - ); } public addRoomListener(roomId: string, fn: Listener): void { @@ -182,16 +169,6 @@ export class RoomViewStore extends EventEmitter { this.emit(roomId, isActive); } - private onCurrentBroadcastRecordingChanged = (recording: VoiceBroadcastRecording | null): void => { - if (recording === null) { - const room = this.stores.client?.getRoom(this.state.roomId || undefined); - - if (room) { - this.doMaybeSetCurrentVoiceBroadcastPlayback(room); - } - } - }; - private setState(newState: Partial): void { // If values haven't changed, there's nothing to do. // This only tries a shallow comparison, so unchanged objects will slip @@ -207,16 +184,6 @@ export class RoomViewStore extends EventEmitter { return; } - if (newState.viewingCall) { - // Pause current broadcast, if any - this.stores.voiceBroadcastPlaybacksStore.getCurrent()?.pause(); - - if (this.stores.voiceBroadcastRecordingsStore.getCurrent()) { - showCantStartACallDialog(); - newState.viewingCall = false; - } - } - const lastRoomId = this.state.roomId; this.state = Object.assign(this.state, newState); if (lastRoomId !== this.state.roomId) { @@ -235,29 +202,6 @@ export class RoomViewStore extends EventEmitter { this.emit(UPDATE_EVENT); } - private doMaybeSetCurrentVoiceBroadcastPlayback(room: Room): void { - if (!this.stores.client) return; - doMaybeSetCurrentVoiceBroadcastPlayback( - room, - this.stores.client, - this.stores.voiceBroadcastPlaybacksStore, - this.stores.voiceBroadcastRecordingsStore, - ); - } - - private onRoomStateEvents(event: MatrixEvent): void { - const roomId = event.getRoomId?.(); - - // no room or not current room - if (!roomId || roomId !== this.state.roomId) return; - - const room = this.stores.client?.getRoom(roomId); - - if (room) { - this.doMaybeSetCurrentVoiceBroadcastPlayback(room); - } - } - private onDispatch(payload: ActionPayload): void { // eslint-disable-line @typescript-eslint/naming-convention switch (payload.action) { @@ -283,10 +227,6 @@ export class RoomViewStore extends EventEmitter { wasContextSwitch: false, viewingCall: false, }); - doClearCurrentVoiceBroadcastPlaybackIfStopped(this.stores.voiceBroadcastPlaybacksStore); - break; - case "MatrixActions.RoomState.events": - this.onRoomStateEvents((payload as IRoomStateEventsActionPayload).event); break; case Action.ViewRoomError: this.viewRoomError(payload as ViewRoomErrorPayload); @@ -489,9 +429,6 @@ export class RoomViewStore extends EventEmitter { } if (room) { - pauseNonLiveBroadcastFromOtherRoom(room, this.stores.voiceBroadcastPlaybacksStore); - this.doMaybeSetCurrentVoiceBroadcastPlayback(room); - await setMarkedUnreadState(room, MatrixClientPeg.safeGet(), false); } } else if (payload.room_alias) { diff --git a/src/stores/right-panel/RightPanelStore.ts b/src/stores/right-panel/RightPanelStore.ts index 9da06580dc..99b2d7fe50 100644 --- a/src/stores/right-panel/RightPanelStore.ts +++ b/src/stores/right-panel/RightPanelStore.ts @@ -304,15 +304,13 @@ export default class RightPanelStore extends ReadyWatchingStore { logger.warn("removed card from right panel because of missing threadHeadEvent in card state"); } return !!card.state?.threadHeadEvent; - case RightPanelPhases.RoomMemberInfo: - case RightPanelPhases.SpaceMemberInfo: + case RightPanelPhases.MemberInfo: case RightPanelPhases.EncryptionPanel: if (!card.state?.member) { logger.warn("removed card from right panel because of missing member in card state"); } return !!card.state?.member; - case RightPanelPhases.Room3pidMemberInfo: - case RightPanelPhases.Space3pidMemberInfo: + case RightPanelPhases.ThreePidMemberInfo: if (!card.state?.memberInfoEvent) { logger.warn("removed card from right panel because of missing memberInfoEvent in card state"); } @@ -327,7 +325,7 @@ export default class RightPanelStore extends ReadyWatchingStore { } private getVerificationRedirect(card: IRightPanelCard): IRightPanelCard | null { - if (card.phase === RightPanelPhases.RoomMemberInfo && card.state) { + if (card.phase === RightPanelPhases.MemberInfo && card.state) { // RightPanelPhases.RoomMemberInfo -> needs to be changed to RightPanelPhases.EncryptionPanel if there is a pending verification request const { member } = card.state; const pendingRequest = member @@ -385,8 +383,7 @@ export default class RightPanelStore extends ReadyWatchingStore { if (panel?.history) { panel.history = panel.history.filter( (card: IRightPanelCard) => - card.phase != RightPanelPhases.RoomMemberInfo && - card.phase != RightPanelPhases.Room3pidMemberInfo, + card.phase != RightPanelPhases.MemberInfo && card.phase != RightPanelPhases.ThreePidMemberInfo, ); } } diff --git a/src/stores/right-panel/RightPanelStoreIPanelState.ts b/src/stores/right-panel/RightPanelStoreIPanelState.ts index afb7442563..0d205abd2f 100644 --- a/src/stores/right-panel/RightPanelStoreIPanelState.ts +++ b/src/stores/right-panel/RightPanelStoreIPanelState.ts @@ -16,7 +16,6 @@ export interface IRightPanelCardState { verificationRequest?: VerificationRequest; verificationRequestPromise?: Promise; widgetId?: string; - spaceId?: string; // Room3pidMemberInfo, Space3pidMemberInfo, memberInfoEvent?: MatrixEvent; // threads @@ -32,7 +31,6 @@ export interface IRightPanelCardStateStored { memberId?: string; // we do not store the things associated with verification widgetId?: string; - spaceId?: string; // 3pidMemberInfo memberInfoEventId?: string; // threads @@ -80,7 +78,6 @@ export function convertCardToStore(panelState: IRightPanelCard): IRightPanelCard const state = panelState.state ?? {}; const stateStored: IRightPanelCardStateStored = { widgetId: state.widgetId, - spaceId: state.spaceId, isInitialEventHighlighted: state.isInitialEventHighlighted, initialEventScrollIntoView: state.initialEventScrollIntoView, threadHeadEventId: !!state?.threadHeadEvent?.getId() ? state.threadHeadEvent.getId() : undefined, @@ -97,7 +94,6 @@ function convertStoreToCard(panelStateStore: IRightPanelCardStored, room: Room): const stateStored = panelStateStore.state ?? {}; const state: IRightPanelCardState = { widgetId: stateStored.widgetId, - spaceId: stateStored.spaceId, isInitialEventHighlighted: stateStored.isInitialEventHighlighted, initialEventScrollIntoView: stateStored.initialEventScrollIntoView, threadHeadEvent: !!stateStored?.threadHeadEventId diff --git a/src/stores/right-panel/RightPanelStorePhases.ts b/src/stores/right-panel/RightPanelStorePhases.ts index 60b9e50baf..9e7a5697bf 100644 --- a/src/stores/right-panel/RightPanelStorePhases.ts +++ b/src/stores/right-panel/RightPanelStorePhases.ts @@ -10,11 +10,14 @@ import { _t } from "../../languageHandler"; // These are in their own file because of circular imports being a problem. export enum RightPanelPhases { + // Room & Space stuff + MemberList = "MemberList", + MemberInfo = "MemberInfo", + ThreePidMemberInfo = "ThreePidMemberInfo", + // Room stuff - RoomMemberList = "RoomMemberList", FilePanel = "FilePanel", NotificationPanel = "NotificationPanel", - RoomMemberInfo = "RoomMemberInfo", EncryptionPanel = "EncryptionPanel", RoomSummary = "RoomSummary", Widget = "Widget", @@ -22,13 +25,6 @@ export enum RightPanelPhases { Timeline = "Timeline", Extensions = "Extensions", - Room3pidMemberInfo = "Room3pidMemberInfo", - - // Space stuff - SpaceMemberList = "SpaceMemberList", - SpaceMemberInfo = "SpaceMemberInfo", - Space3pidMemberInfo = "Space3pidMemberInfo", - // Thread stuff ThreadView = "ThreadView", ThreadPanel = "ThreadPanel", @@ -42,7 +38,7 @@ export function backLabelForPhase(phase: RightPanelPhases | null): string | null return _t("chat_card_back_action_label"); case RightPanelPhases.RoomSummary: return _t("room_summary_card_back_action_label"); - case RightPanelPhases.RoomMemberList: + case RightPanelPhases.MemberList: return _t("member_list_back_action_label"); case RightPanelPhases.ThreadView: return _t("thread_view_back_action_label"); diff --git a/src/stores/right-panel/action-handlers/View3pidInvite.ts b/src/stores/right-panel/action-handlers/View3pidInvite.ts index e2aa191acb..0f6661819f 100644 --- a/src/stores/right-panel/action-handlers/View3pidInvite.ts +++ b/src/stores/right-panel/action-handlers/View3pidInvite.ts @@ -20,10 +20,10 @@ import { RightPanelPhases } from "../RightPanelStorePhases"; export const onView3pidInvite = (payload: ActionPayload, rightPanelStore: RightPanelStore): void => { if (payload.event) { rightPanelStore.pushCard({ - phase: RightPanelPhases.Room3pidMemberInfo, + phase: RightPanelPhases.ThreePidMemberInfo, state: { memberInfoEvent: payload.event }, }); } else { - rightPanelStore.showOrHidePhase(RightPanelPhases.RoomMemberList); + rightPanelStore.showOrHidePhase(RightPanelPhases.MemberList); } }; diff --git a/src/stores/room-list/MessagePreviewStore.ts b/src/stores/room-list/MessagePreviewStore.ts index e0e06ec980..2577b2ba23 100644 --- a/src/stores/room-list/MessagePreviewStore.ts +++ b/src/stores/room-list/MessagePreviewStore.ts @@ -22,8 +22,6 @@ import { StickerEventPreview } from "./previews/StickerEventPreview"; import { ReactionEventPreview } from "./previews/ReactionEventPreview"; import { UPDATE_EVENT } from "../AsyncStore"; import { IPreview } from "./previews/IPreview"; -import { VoiceBroadcastInfoEventType } from "../../voice-broadcast"; -import { VoiceBroadcastPreview } from "./previews/VoiceBroadcastPreview"; import shouldHideEvent from "../../shouldHideEvent"; // Emitted event for when a room's preview has changed. First argument will the room for which @@ -69,10 +67,6 @@ const PREVIEWS: Record< isState: false, previewer: new PollStartEventPreview(), }, - [VoiceBroadcastInfoEventType]: { - isState: true, - previewer: new VoiceBroadcastPreview(), - }, }; // The maximum number of events we're willing to look back on to get a preview. diff --git a/src/stores/room-list/previews/MessageEventPreview.ts b/src/stores/room-list/previews/MessageEventPreview.ts index 2873320cf3..20631f1425 100644 --- a/src/stores/room-list/previews/MessageEventPreview.ts +++ b/src/stores/room-list/previews/MessageEventPreview.ts @@ -14,15 +14,11 @@ import { _t, sanitizeForTranslation } from "../../../languageHandler"; import { getSenderName, isSelf, shouldPrefixMessagesIn } from "./utils"; import { getHtmlText } from "../../../HtmlUtils"; import { stripHTMLReply, stripPlainReply } from "../../../utils/Reply"; -import { VoiceBroadcastChunkEventType } from "../../../voice-broadcast/types"; export class MessageEventPreview implements IPreview { public getTextFor(event: MatrixEvent, tagId?: TagID, isThread?: boolean): string | null { let eventContent = event.getContent(); - // no preview for broadcast chunks - if (eventContent[VoiceBroadcastChunkEventType]) return null; - if (event.isRelation(RelationType.Replace)) { // It's an edit, generate the preview on the new text eventContent = event.getContent()["m.new_content"]; diff --git a/src/stores/room-list/previews/PollStartEventPreview.ts b/src/stores/room-list/previews/PollStartEventPreview.ts index bb005f4a94..7548cf12f7 100644 --- a/src/stores/room-list/previews/PollStartEventPreview.ts +++ b/src/stores/room-list/previews/PollStartEventPreview.ts @@ -18,7 +18,7 @@ import MatrixClientContext from "../../../contexts/MatrixClientContext"; export class PollStartEventPreview implements IPreview { public static contextType = MatrixClientContext; - public declare context: React.ContextType; + declare public context: React.ContextType; public getTextFor(event: MatrixEvent, tagId?: TagID, isThread?: boolean): string | null { let eventContent = event.getContent(); diff --git a/src/stores/room-list/previews/VoiceBroadcastPreview.ts b/src/stores/room-list/previews/VoiceBroadcastPreview.ts deleted file mode 100644 index 94116692a6..0000000000 --- a/src/stores/room-list/previews/VoiceBroadcastPreview.ts +++ /dev/null @@ -1,23 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only -Please see LICENSE files in the repository root for full details. -*/ - -import { MatrixEvent } from "matrix-js-sdk/src/matrix"; - -import { VoiceBroadcastInfoState } from "../../../voice-broadcast/types"; -import { textForVoiceBroadcastStoppedEventWithoutLink } from "../../../voice-broadcast/utils/textForVoiceBroadcastStoppedEventWithoutLink"; -import { IPreview } from "./IPreview"; - -export class VoiceBroadcastPreview implements IPreview { - public getTextFor(event: MatrixEvent, tagId?: string, isThread?: boolean): string | null { - if (!event.isRedacted() && event.getContent()?.state === VoiceBroadcastInfoState.Stopped) { - return textForVoiceBroadcastStoppedEventWithoutLink(event); - } - - return null; - } -} diff --git a/src/stores/widgets/StopGapWidget.ts b/src/stores/widgets/StopGapWidget.ts index 8362f1048a..0472b1664b 100644 --- a/src/stores/widgets/StopGapWidget.ts +++ b/src/stores/widgets/StopGapWidget.ts @@ -284,10 +284,6 @@ export class StopGapWidget extends EventEmitter { }); this.messaging.on("capabilitiesNotified", () => this.emit("capabilitiesNotified")); this.messaging.on(`action:${WidgetApiFromWidgetAction.OpenModalWidget}`, this.onOpenModal); - this.messaging.on(`action:${ElementWidgetActions.JoinCall}`, () => { - // pause voice broadcast recording when any widget sends a "join" - SdkContextClass.instance.voiceBroadcastRecordingsStore.getCurrent()?.pause(); - }); // Always attach a handler for ViewRoom, but permission check it internally this.messaging.on(`action:${ElementWidgetActions.ViewRoom}`, (ev: CustomEvent) => { diff --git a/src/utils/EventRenderingUtils.ts b/src/utils/EventRenderingUtils.ts index 099bf768d8..ed8d4af101 100644 --- a/src/utils/EventRenderingUtils.ts +++ b/src/utils/EventRenderingUtils.ts @@ -21,7 +21,6 @@ import SettingsStore from "../settings/SettingsStore"; import { haveRendererForEvent, JitsiEventFactory, JSONEventFactory, pickFactory } from "../events/EventTileFactory"; import { getMessageModerationState, isLocationEvent, MessageModerationState } from "./EventUtils"; import { ElementCall } from "../models/Call"; -import { VoiceBroadcastInfoEventType, VoiceBroadcastInfoState } from "../voice-broadcast"; const calcIsInfoMessage = ( eventType: EventType | string, @@ -38,8 +37,7 @@ const calcIsInfoMessage = ( eventType !== EventType.RoomCreate && !M_POLL_START.matches(eventType) && !M_POLL_END.matches(eventType) && - !M_BEACON_INFO.matches(eventType) && - !(eventType === VoiceBroadcastInfoEventType && content?.state === VoiceBroadcastInfoState.Started) + !M_BEACON_INFO.matches(eventType) ); }; @@ -91,8 +89,7 @@ export function getEventDisplayInfo( (eventType === EventType.RoomMessage && msgtype === MsgType.Emote) || M_POLL_START.matches(eventType) || M_BEACON_INFO.matches(eventType) || - isLocationEvent(mxEvent) || - eventType === VoiceBroadcastInfoEventType; + isLocationEvent(mxEvent); // If we're showing hidden events in the timeline, we should use the // source tile when there's no regular tile for an event and also for diff --git a/src/utils/EventUtils.ts b/src/utils/EventUtils.ts index 7c5b80697b..d57cefa1b5 100644 --- a/src/utils/EventUtils.ts +++ b/src/utils/EventUtils.ts @@ -30,7 +30,6 @@ import { TimelineRenderingType } from "../contexts/RoomContext"; import { launchPollEditor } from "../components/views/messages/MPollBody"; import { Action } from "../dispatcher/actions"; import { ViewRoomPayload } from "../dispatcher/payloads/ViewRoomPayload"; -import { VoiceBroadcastInfoEventType, VoiceBroadcastInfoState } from "../voice-broadcast/types"; /** * Returns whether an event should allow actions like reply, reactions, edit, etc. @@ -56,9 +55,7 @@ export function isContentActionable(mxEvent: MatrixEvent): boolean { mxEvent.getType() === "m.sticker" || M_POLL_START.matches(mxEvent.getType()) || M_POLL_END.matches(mxEvent.getType()) || - M_BEACON_INFO.matches(mxEvent.getType()) || - (mxEvent.getType() === VoiceBroadcastInfoEventType && - mxEvent.getContent()?.state === VoiceBroadcastInfoState.Started) + M_BEACON_INFO.matches(mxEvent.getType()) ) { return true; } diff --git a/src/utils/dm/createDmLocalRoom.ts b/src/utils/dm/createDmLocalRoom.ts index 6d6cf0712b..0a3d312368 100644 --- a/src/utils/dm/createDmLocalRoom.ts +++ b/src/utils/dm/createDmLocalRoom.ts @@ -109,7 +109,7 @@ export async function createDmLocalRoom(client: MatrixClient, targets: Member[]) localRoom.targets = targets; localRoom.updateMyMembership(KnownMembership.Join); - localRoom.addLiveEvents(events); + localRoom.addLiveEvents(events, { addToState: true }); localRoom.currentState.setStateEvents(events); localRoom.name = localRoom.getDefaultRoomName(client.getUserId()!); client.store.storeRoom(localRoom); diff --git a/src/utils/react.tsx b/src/utils/react.tsx index 164d704d91..b78f574fa9 100644 --- a/src/utils/react.tsx +++ b/src/utils/react.tsx @@ -15,23 +15,38 @@ import { createRoot, Root } from "react-dom/client"; export class ReactRootManager { private roots: Root[] = []; private rootElements: Element[] = []; + private revertElements: Array = []; public get elements(): Element[] { return this.rootElements; } - public render(children: ReactNode, element: Element): void { - const root = createRoot(element); + /** + * Render a React component into a new root based on the given root element + * @param children the React component to render + * @param rootElement the root element to render the component into + * @param revertElement the element to replace the root element with when unmounting + */ + public render(children: ReactNode, rootElement: Element, revertElement?: Element): void { + const root = createRoot(rootElement); this.roots.push(root); - this.rootElements.push(element); + this.rootElements.push(rootElement); + this.revertElements.push(revertElement ?? null); root.render(children); } + /** + * Unmount all roots and revert the elements they were rendered into + */ public unmount(): void { while (this.roots.length) { const root = this.roots.pop()!; - this.rootElements.pop(); + const rootElement = this.rootElements.pop(); + const revertElement = this.revertElements.pop(); root.unmount(); + if (revertElement) { + rootElement?.replaceWith(revertElement); + } } } } diff --git a/src/vector/index.ts b/src/vector/index.ts index 60f0868eeb..42b69af70e 100644 --- a/src/vector/index.ts +++ b/src/vector/index.ts @@ -67,6 +67,10 @@ function checkBrowserFeatures(): boolean { // although this would start to make (more) assumptions about how rust-crypto loads its wasm. window.Modernizr.addTest("wasm", () => typeof WebAssembly === "object" && typeof WebAssembly.Module === "function"); + // Check that the session is in a secure context otherwise most Crypto & WebRTC APIs will be unavailable + // https://developer.mozilla.org/en-US/docs/Web/API/Window/isSecureContext + window.Modernizr.addTest("securecontext", () => window.isSecureContext); + const featureList = Object.keys(window.Modernizr) as Array; let featureComplete = true; diff --git a/src/verification.ts b/src/verification.ts index e446186f80..9c14a64c51 100644 --- a/src/verification.ts +++ b/src/verification.ts @@ -81,7 +81,7 @@ function setRightPanel(state: IRightPanelCardState): void { } else { RightPanelStore.instance.setCards([ { phase: RightPanelPhases.RoomSummary }, - { phase: RightPanelPhases.RoomMemberInfo, state: { member: state.member } }, + { phase: RightPanelPhases.MemberInfo, state: { member: state.member } }, { phase: RightPanelPhases.EncryptionPanel, state }, ]); } diff --git a/src/voice-broadcast/audio/VoiceBroadcastRecorder.ts b/src/voice-broadcast/audio/VoiceBroadcastRecorder.ts deleted file mode 100644 index 8a6e17a1a5..0000000000 --- a/src/voice-broadcast/audio/VoiceBroadcastRecorder.ts +++ /dev/null @@ -1,181 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only -Please see LICENSE files in the repository root for full details. -*/ - -import { isEqual } from "lodash"; -import { Optional } from "matrix-events-sdk"; -import { logger } from "matrix-js-sdk/src/logger"; -import { TypedEventEmitter } from "matrix-js-sdk/src/matrix"; - -import { getChunkLength } from ".."; -import { IRecordingUpdate, VoiceRecording } from "../../audio/VoiceRecording"; -import { concat } from "../../utils/arrays"; -import { IDestroyable } from "../../utils/IDestroyable"; -import { Singleflight } from "../../utils/Singleflight"; - -export enum VoiceBroadcastRecorderEvent { - ChunkRecorded = "chunk_recorded", - CurrentChunkLengthUpdated = "current_chunk_length_updated", -} - -interface EventMap { - [VoiceBroadcastRecorderEvent.ChunkRecorded]: (chunk: ChunkRecordedPayload) => void; - [VoiceBroadcastRecorderEvent.CurrentChunkLengthUpdated]: (length: number) => void; -} - -export interface ChunkRecordedPayload { - buffer: Uint8Array; - length: number; -} - -// char sequence of "OpusHead" -const OpusHead = [79, 112, 117, 115, 72, 101, 97, 100]; - -// char sequence of "OpusTags" -const OpusTags = [79, 112, 117, 115, 84, 97, 103, 115]; - -/** - * This class provides the function to seamlessly record fixed length chunks. - * Subscribe with on(VoiceBroadcastRecordingEvents.ChunkRecorded, (payload: ChunkRecordedPayload) => {}) - * to retrieve chunks while recording. - */ -export class VoiceBroadcastRecorder - extends TypedEventEmitter - implements IDestroyable -{ - private opusHead?: Uint8Array; - private opusTags?: Uint8Array; - private chunkBuffer = new Uint8Array(0); - // position of the previous chunk in seconds - private previousChunkEndTimePosition = 0; - // current chunk length in seconds - private currentChunkLength = 0; - - public constructor( - private voiceRecording: VoiceRecording, - public readonly targetChunkLength: number, - ) { - super(); - this.voiceRecording.onDataAvailable = this.onDataAvailable; - } - - public async start(): Promise { - await this.voiceRecording.start(); - this.voiceRecording.liveData.onUpdate((data: IRecordingUpdate) => { - this.setCurrentChunkLength(data.timeSeconds - this.previousChunkEndTimePosition); - }); - } - - /** - * Stops the recording and returns the remaining chunk (if any). - */ - public async stop(): Promise> { - try { - await this.voiceRecording.stop(); - } catch { - // Ignore if the recording raises any error. - } - - // forget about that call, so that we can stop it again later - Singleflight.forgetAllFor(this.voiceRecording); - const chunk = this.extractChunk(); - this.currentChunkLength = 0; - this.previousChunkEndTimePosition = 0; - return chunk; - } - - public get contentType(): string { - return this.voiceRecording.contentType; - } - - private setCurrentChunkLength(currentChunkLength: number): void { - if (this.currentChunkLength === currentChunkLength) return; - - this.currentChunkLength = currentChunkLength; - this.emit(VoiceBroadcastRecorderEvent.CurrentChunkLengthUpdated, currentChunkLength); - } - - public getCurrentChunkLength(): number { - return this.currentChunkLength; - } - - private onDataAvailable = (data: ArrayBuffer): void => { - const dataArray = new Uint8Array(data); - - // extract the part, that contains the header type info - const headerType = Array.from(dataArray.slice(28, 36)); - - if (isEqual(OpusHead, headerType)) { - // data seems to be an "OpusHead" header - this.opusHead = dataArray; - return; - } - - if (isEqual(OpusTags, headerType)) { - // data seems to be an "OpusTags" header - this.opusTags = dataArray; - return; - } - - this.setCurrentChunkLength(this.voiceRecording.recorderSeconds! - this.previousChunkEndTimePosition); - this.handleData(dataArray); - }; - - private handleData(data: Uint8Array): void { - this.chunkBuffer = concat(this.chunkBuffer, data); - this.emitChunkIfTargetLengthReached(); - } - - private emitChunkIfTargetLengthReached(): void { - if (this.getCurrentChunkLength() >= this.targetChunkLength) { - this.emitAndResetChunk(); - } - } - - /** - * Extracts the current chunk and resets the buffer. - */ - private extractChunk(): Optional { - if (this.chunkBuffer.length === 0) { - return null; - } - - if (!this.opusHead || !this.opusTags) { - logger.warn("Broadcast chunk cannot be extracted. OpusHead or OpusTags is missing."); - return null; - } - - const currentRecorderTime = this.voiceRecording.recorderSeconds!; - const payload: ChunkRecordedPayload = { - buffer: concat(this.opusHead!, this.opusTags!, this.chunkBuffer), - length: this.getCurrentChunkLength(), - }; - this.chunkBuffer = new Uint8Array(0); - this.setCurrentChunkLength(0); - this.previousChunkEndTimePosition = currentRecorderTime; - return payload; - } - - private emitAndResetChunk(): void { - if (this.chunkBuffer.length === 0) { - return; - } - - this.emit(VoiceBroadcastRecorderEvent.ChunkRecorded, this.extractChunk()!); - } - - public destroy(): void { - this.removeAllListeners(); - this.voiceRecording.destroy(); - } -} - -export const createVoiceBroadcastRecorder = (): VoiceBroadcastRecorder => { - const voiceRecording = new VoiceRecording(); - voiceRecording.disableMaxLength(); - return new VoiceBroadcastRecorder(voiceRecording, getChunkLength()); -}; diff --git a/src/voice-broadcast/components/VoiceBroadcastBody.tsx b/src/voice-broadcast/components/VoiceBroadcastBody.tsx deleted file mode 100644 index 916ee9f907..0000000000 --- a/src/voice-broadcast/components/VoiceBroadcastBody.tsx +++ /dev/null @@ -1,58 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only -Please see LICENSE files in the repository root for full details. -*/ - -import React, { useContext, useEffect, useState } from "react"; -import { MatrixEvent, RelationType } from "matrix-js-sdk/src/matrix"; - -import { - VoiceBroadcastRecordingBody, - shouldDisplayAsVoiceBroadcastRecordingTile, - VoiceBroadcastInfoEventType, - VoiceBroadcastPlaybackBody, - VoiceBroadcastInfoState, -} from ".."; -import { IBodyProps } from "../../components/views/messages/IBodyProps"; -import { RelationsHelper, RelationsHelperEvent } from "../../events/RelationsHelper"; -import { SDKContext } from "../../contexts/SDKContext"; -import { useMatrixClientContext } from "../../contexts/MatrixClientContext"; - -export const VoiceBroadcastBody: React.FC = ({ mxEvent }) => { - const sdkContext = useContext(SDKContext); - const client = useMatrixClientContext(); - const [infoState, setInfoState] = useState(mxEvent.getContent()?.state || VoiceBroadcastInfoState.Stopped); - - useEffect(() => { - const onInfoEvent = (event: MatrixEvent): void => { - if (event.getContent()?.state === VoiceBroadcastInfoState.Stopped) { - // only a stopped event can change the tile state - setInfoState(VoiceBroadcastInfoState.Stopped); - } - }; - - const relationsHelper = new RelationsHelper( - mxEvent, - RelationType.Reference, - VoiceBroadcastInfoEventType, - client, - ); - relationsHelper.on(RelationsHelperEvent.Add, onInfoEvent); - relationsHelper.emitCurrent(); - - return () => { - relationsHelper.destroy(); - }; - }); - - if (shouldDisplayAsVoiceBroadcastRecordingTile(infoState, client, mxEvent)) { - const recording = sdkContext.voiceBroadcastRecordingsStore.getByInfoEvent(mxEvent, client); - return ; - } - - const playback = sdkContext.voiceBroadcastPlaybacksStore.getByInfoEvent(mxEvent, client); - return ; -}; diff --git a/src/voice-broadcast/components/atoms/LiveBadge.tsx b/src/voice-broadcast/components/atoms/LiveBadge.tsx deleted file mode 100644 index 2591fee435..0000000000 --- a/src/voice-broadcast/components/atoms/LiveBadge.tsx +++ /dev/null @@ -1,30 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only -Please see LICENSE files in the repository root for full details. -*/ - -import classNames from "classnames"; -import React from "react"; - -import { Icon as LiveIcon } from "../../../../res/img/compound/live-16px.svg"; -import { _t } from "../../../languageHandler"; - -interface Props { - grey?: boolean; -} - -export const LiveBadge: React.FC = ({ grey = false }) => { - const liveBadgeClasses = classNames("mx_LiveBadge", { - "mx_LiveBadge--grey": grey, - }); - - return ( -
- - {_t("voice_broadcast|live")} -
- ); -}; diff --git a/src/voice-broadcast/components/atoms/SeekButton.tsx b/src/voice-broadcast/components/atoms/SeekButton.tsx deleted file mode 100644 index 5ee0826488..0000000000 --- a/src/voice-broadcast/components/atoms/SeekButton.tsx +++ /dev/null @@ -1,25 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only -Please see LICENSE files in the repository root for full details. -*/ - -import React from "react"; - -import AccessibleButton from "../../../components/views/elements/AccessibleButton"; - -interface Props { - icon: React.FC>; - label: string; - onClick: () => void; -} - -export const SeekButton: React.FC = ({ onClick, icon: Icon, label }) => { - return ( - - - - ); -}; diff --git a/src/voice-broadcast/components/atoms/VoiceBroadcastControl.tsx b/src/voice-broadcast/components/atoms/VoiceBroadcastControl.tsx deleted file mode 100644 index 177b8fd732..0000000000 --- a/src/voice-broadcast/components/atoms/VoiceBroadcastControl.tsx +++ /dev/null @@ -1,31 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only -Please see LICENSE files in the repository root for full details. -*/ - -import classNames from "classnames"; -import React, { ReactElement } from "react"; - -import AccessibleButton from "../../../components/views/elements/AccessibleButton"; - -interface Props { - className?: string; - icon: ReactElement; - label: string; - onClick: () => void; -} - -export const VoiceBroadcastControl: React.FC = ({ className = "", icon, label, onClick }) => { - return ( - - {icon} - - ); -}; diff --git a/src/voice-broadcast/components/atoms/VoiceBroadcastError.tsx b/src/voice-broadcast/components/atoms/VoiceBroadcastError.tsx deleted file mode 100644 index d326853f4e..0000000000 --- a/src/voice-broadcast/components/atoms/VoiceBroadcastError.tsx +++ /dev/null @@ -1,23 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2023 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only -Please see LICENSE files in the repository root for full details. -*/ - -import React from "react"; -import { WarningIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; - -interface Props { - message: string; -} - -export const VoiceBroadcastError: React.FC = ({ message }) => { - return ( -
- - {message} -
- ); -}; diff --git a/src/voice-broadcast/components/atoms/VoiceBroadcastHeader.tsx b/src/voice-broadcast/components/atoms/VoiceBroadcastHeader.tsx deleted file mode 100644 index 52c0251c5e..0000000000 --- a/src/voice-broadcast/components/atoms/VoiceBroadcastHeader.tsx +++ /dev/null @@ -1,139 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only -Please see LICENSE files in the repository root for full details. -*/ - -import React from "react"; -import { Room } from "matrix-js-sdk/src/matrix"; -import classNames from "classnames"; -import CloseIcon from "@vector-im/compound-design-tokens/assets/web/icons/close"; -import MicrophoneIcon from "@vector-im/compound-design-tokens/assets/web/icons/mic-on-solid"; - -import { LiveBadge, VoiceBroadcastLiveness } from "../.."; -import { Icon as LiveIcon } from "../../../../res/img/compound/live-16px.svg"; -import { Icon as TimerIcon } from "../../../../res/img/compound/timer-16px.svg"; -import { _t } from "../../../languageHandler"; -import RoomAvatar from "../../../components/views/avatars/RoomAvatar"; -import AccessibleButton, { ButtonEvent } from "../../../components/views/elements/AccessibleButton"; -import Clock from "../../../components/views/audio_messages/Clock"; -import { formatTimeLeft } from "../../../DateUtils"; -import Spinner from "../../../components/views/elements/Spinner"; -import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; -import { Action } from "../../../dispatcher/actions"; -import dis from "../../../dispatcher/dispatcher"; - -interface VoiceBroadcastHeaderProps { - linkToRoom?: boolean; - live?: VoiceBroadcastLiveness; - liveBadgePosition?: "middle" | "right"; - onCloseClick?: () => void; - onMicrophoneLineClick?: ((e: ButtonEvent) => void | Promise) | null; - room: Room; - microphoneLabel?: string; - showBroadcast?: boolean; - showBuffering?: boolean; - bufferingPosition?: "line" | "title"; - timeLeft?: number; - showClose?: boolean; -} - -export const VoiceBroadcastHeader: React.FC = ({ - linkToRoom = false, - live = "not-live", - liveBadgePosition = "right", - onCloseClick = (): void => {}, - onMicrophoneLineClick = null, - room, - microphoneLabel, - showBroadcast = false, - showBuffering = false, - bufferingPosition = "line", - showClose = false, - timeLeft, -}) => { - const broadcast = showBroadcast && ( -
- - {_t("voice_broadcast|action")} -
- ); - - const liveBadge = live !== "not-live" && ; - - const closeButton = showClose && ( - - - - ); - - const timeLeftLine = timeLeft && ( -
- - -
- ); - - const bufferingLine = showBuffering && bufferingPosition === "line" && ( -
- - {_t("voice_broadcast|buffering")} -
- ); - - const microphoneLineClasses = classNames({ - mx_VoiceBroadcastHeader_line: true, - ["mx_VoiceBroadcastHeader_mic--clickable"]: onMicrophoneLineClick, - }); - - const microphoneLine = microphoneLabel && ( - - - {microphoneLabel} - - ); - - const onRoomAvatarOrNameClick = (): void => { - dis.dispatch({ - action: Action.ViewRoom, - room_id: room.roomId, - metricsTrigger: undefined, // other - }); - }; - - let roomAvatar = ; - let roomName = ( -
-
{room.name}
- {showBuffering && bufferingPosition === "title" && } -
- ); - - if (linkToRoom) { - roomAvatar = {roomAvatar}; - - roomName = {roomName}; - } - - return ( -
- {roomAvatar} -
- {roomName} - {microphoneLine} - {timeLeftLine} - {broadcast} - {bufferingLine} - {liveBadgePosition === "middle" && liveBadge} -
- {liveBadgePosition === "right" && liveBadge} - {closeButton} -
- ); -}; diff --git a/src/voice-broadcast/components/atoms/VoiceBroadcastPlaybackControl.tsx b/src/voice-broadcast/components/atoms/VoiceBroadcastPlaybackControl.tsx deleted file mode 100644 index 08531b8afd..0000000000 --- a/src/voice-broadcast/components/atoms/VoiceBroadcastPlaybackControl.tsx +++ /dev/null @@ -1,51 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022, 2023 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only -Please see LICENSE files in the repository root for full details. -*/ - -import React, { ReactElement } from "react"; -import PauseIcon from "@vector-im/compound-design-tokens/assets/web/icons/pause-solid"; -import PlayIcon from "@vector-im/compound-design-tokens/assets/web/icons/play-solid"; - -import { _t } from "../../../languageHandler"; -import { VoiceBroadcastControl, VoiceBroadcastPlaybackState } from "../.."; - -interface Props { - onClick: () => void; - state: VoiceBroadcastPlaybackState; -} - -export const VoiceBroadcastPlaybackControl: React.FC = ({ onClick, state }) => { - let controlIcon: ReactElement | null = null; - let controlLabel: string | null = null; - let className = ""; - - switch (state) { - case VoiceBroadcastPlaybackState.Stopped: - controlIcon = ; - className = "mx_VoiceBroadcastControl-play"; - controlLabel = _t("voice_broadcast|play"); - break; - case VoiceBroadcastPlaybackState.Paused: - controlIcon = ; - className = "mx_VoiceBroadcastControl-play"; - controlLabel = _t("voice_broadcast|resume"); - break; - case VoiceBroadcastPlaybackState.Buffering: - case VoiceBroadcastPlaybackState.Playing: - controlIcon = ; - controlLabel = _t("voice_broadcast|pause"); - break; - } - - if (controlIcon && controlLabel) { - return ( - - ); - } - - return null; -}; diff --git a/src/voice-broadcast/components/atoms/VoiceBroadcastRecordingConnectionError.tsx b/src/voice-broadcast/components/atoms/VoiceBroadcastRecordingConnectionError.tsx deleted file mode 100644 index 250d71f2f3..0000000000 --- a/src/voice-broadcast/components/atoms/VoiceBroadcastRecordingConnectionError.tsx +++ /dev/null @@ -1,21 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2023 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only -Please see LICENSE files in the repository root for full details. -*/ - -import React from "react"; -import { WarningIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; - -import { _t } from "../../../languageHandler"; - -export const VoiceBroadcastRecordingConnectionError: React.FC = () => { - return ( -
- - {_t("voice_broadcast|connection_error")} -
- ); -}; diff --git a/src/voice-broadcast/components/atoms/VoiceBroadcastRoomSubtitle.tsx b/src/voice-broadcast/components/atoms/VoiceBroadcastRoomSubtitle.tsx deleted file mode 100644 index 20b7379789..0000000000 --- a/src/voice-broadcast/components/atoms/VoiceBroadcastRoomSubtitle.tsx +++ /dev/null @@ -1,21 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only -Please see LICENSE files in the repository root for full details. -*/ - -import React from "react"; - -import { Icon as LiveIcon } from "../../../../res/img/compound/live-16px.svg"; -import { _t } from "../../../languageHandler"; - -export const VoiceBroadcastRoomSubtitle: React.FC = () => { - return ( -
- - {_t("voice_broadcast|live")} -
- ); -}; diff --git a/src/voice-broadcast/components/molecules/ConfirmListenBroadcastStopCurrent.tsx b/src/voice-broadcast/components/molecules/ConfirmListenBroadcastStopCurrent.tsx deleted file mode 100644 index 3dadfeba60..0000000000 --- a/src/voice-broadcast/components/molecules/ConfirmListenBroadcastStopCurrent.tsx +++ /dev/null @@ -1,38 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only -Please see LICENSE files in the repository root for full details. -*/ - -import React from "react"; - -import BaseDialog from "../../../components/views/dialogs/BaseDialog"; -import DialogButtons from "../../../components/views/elements/DialogButtons"; -import { _t } from "../../../languageHandler"; -import Modal from "../../../Modal"; - -interface Props { - onFinished: (confirmed?: boolean) => void; -} - -export const ConfirmListenBroadcastStopCurrentDialog: React.FC = ({ onFinished }) => { - return ( - -

{_t("voice_broadcast|confirm_listen_description")}

- onFinished(true)} - primaryButton={_t("voice_broadcast|confirm_listen_affirm")} - cancelButton={_t("action|no")} - onCancel={() => onFinished(false)} - /> -
- ); -}; - -export const showConfirmListenBroadcastStopCurrentDialog = async (): Promise => { - const { finished } = Modal.createDialog(ConfirmListenBroadcastStopCurrentDialog); - const [confirmed] = await finished; - return !!confirmed; -}; diff --git a/src/voice-broadcast/components/molecules/VoiceBroadcastPlaybackBody.tsx b/src/voice-broadcast/components/molecules/VoiceBroadcastPlaybackBody.tsx deleted file mode 100644 index 913b144960..0000000000 --- a/src/voice-broadcast/components/molecules/VoiceBroadcastPlaybackBody.tsx +++ /dev/null @@ -1,102 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only -Please see LICENSE files in the repository root for full details. -*/ - -import React, { ReactElement } from "react"; -import classNames from "classnames"; - -import { - VoiceBroadcastError, - VoiceBroadcastHeader, - VoiceBroadcastPlayback, - VoiceBroadcastPlaybackControl, - VoiceBroadcastPlaybackState, -} from "../.."; -import { useVoiceBroadcastPlayback } from "../../hooks/useVoiceBroadcastPlayback"; -import { Icon as Back30sIcon } from "../../../../res/img/compound/back-30s-24px.svg"; -import { Icon as Forward30sIcon } from "../../../../res/img/compound/forward-30s-24px.svg"; -import { _t } from "../../../languageHandler"; -import Clock from "../../../components/views/audio_messages/Clock"; -import SeekBar from "../../../components/views/audio_messages/SeekBar"; -import { SeekButton } from "../atoms/SeekButton"; - -const SEEK_TIME = 30; - -interface VoiceBroadcastPlaybackBodyProps { - pip?: boolean; - playback: VoiceBroadcastPlayback; -} - -export const VoiceBroadcastPlaybackBody: React.FC = ({ pip = false, playback }) => { - const { times, liveness, playbackState, room, sender, toggle } = useVoiceBroadcastPlayback(playback); - - let seekBackwardButton: ReactElement | null = null; - let seekForwardButton: ReactElement | null = null; - - if (playbackState !== VoiceBroadcastPlaybackState.Stopped) { - const onSeekBackwardButtonClick = (): void => { - playback.skipTo(Math.max(0, times.position - SEEK_TIME)); - }; - - seekBackwardButton = ( - - ); - - const onSeekForwardButtonClick = (): void => { - playback.skipTo(Math.min(times.duration, times.position + SEEK_TIME)); - }; - - seekForwardButton = ( - - ); - } - - const classes = classNames({ - mx_VoiceBroadcastBody: true, - ["mx_VoiceBroadcastBody--pip"]: pip, - }); - - const content = - playbackState === VoiceBroadcastPlaybackState.Error ? ( - - ) : ( - <> -
- {seekBackwardButton} - - {seekForwardButton} -
- -
- - -
- - ); - - return ( -
- - {content} -
- ); -}; diff --git a/src/voice-broadcast/components/molecules/VoiceBroadcastPreRecordingPip.tsx b/src/voice-broadcast/components/molecules/VoiceBroadcastPreRecordingPip.tsx deleted file mode 100644 index ac742e0fd8..0000000000 --- a/src/voice-broadcast/components/molecules/VoiceBroadcastPreRecordingPip.tsx +++ /dev/null @@ -1,82 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only -Please see LICENSE files in the repository root for full details. -*/ - -import React, { useRef, useState } from "react"; - -import { VoiceBroadcastHeader } from "../.."; -import AccessibleButton from "../../../components/views/elements/AccessibleButton"; -import { VoiceBroadcastPreRecording } from "../../models/VoiceBroadcastPreRecording"; -import { Icon as LiveIcon } from "../../../../res/img/compound/live-16px.svg"; -import { _t } from "../../../languageHandler"; -import { useAudioDeviceSelection } from "../../../hooks/useAudioDeviceSelection"; -import { DevicesContextMenu } from "../../../components/views/audio_messages/DevicesContextMenu"; - -interface Props { - voiceBroadcastPreRecording: VoiceBroadcastPreRecording; -} - -interface State { - showDeviceSelect: boolean; - disableStartButton: boolean; -} - -export const VoiceBroadcastPreRecordingPip: React.FC = ({ voiceBroadcastPreRecording }) => { - const pipRef = useRef(null); - const { currentDevice, currentDeviceLabel, devices, setDevice } = useAudioDeviceSelection(); - const [state, setState] = useState({ - showDeviceSelect: false, - disableStartButton: false, - }); - - const onDeviceSelect = (device: MediaDeviceInfo): void => { - setState((state) => ({ - ...state, - showDeviceSelect: false, - })); - setDevice(device); - }; - - const onStartBroadcastClick = (): void => { - setState((state) => ({ - ...state, - disableStartButton: true, - })); - - voiceBroadcastPreRecording.start(); - }; - - return ( -
- setState({ ...state, showDeviceSelect: true })} - room={voiceBroadcastPreRecording.room} - microphoneLabel={currentDeviceLabel} - showClose={true} - /> - - - {_t("voice_broadcast|go_live")} - - {state.showDeviceSelect && ( - - )} -
- ); -}; diff --git a/src/voice-broadcast/components/molecules/VoiceBroadcastRecordingBody.tsx b/src/voice-broadcast/components/molecules/VoiceBroadcastRecordingBody.tsx deleted file mode 100644 index 15547792db..0000000000 --- a/src/voice-broadcast/components/molecules/VoiceBroadcastRecordingBody.tsx +++ /dev/null @@ -1,31 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only -Please see LICENSE files in the repository root for full details. -*/ - -import React from "react"; - -import { - useVoiceBroadcastRecording, - VoiceBroadcastHeader, - VoiceBroadcastRecording, - VoiceBroadcastRecordingConnectionError, -} from "../.."; - -interface VoiceBroadcastRecordingBodyProps { - recording: VoiceBroadcastRecording; -} - -export const VoiceBroadcastRecordingBody: React.FC = ({ recording }) => { - const { live, room, sender, recordingState } = useVoiceBroadcastRecording(recording); - - return ( -
- - {recordingState === "connection_error" && } -
- ); -}; diff --git a/src/voice-broadcast/components/molecules/VoiceBroadcastRecordingPip.tsx b/src/voice-broadcast/components/molecules/VoiceBroadcastRecordingPip.tsx deleted file mode 100644 index d04132b220..0000000000 --- a/src/voice-broadcast/components/molecules/VoiceBroadcastRecordingPip.tsx +++ /dev/null @@ -1,116 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only -Please see LICENSE files in the repository root for full details. -*/ - -import React, { useRef, useState } from "react"; -import PauseIcon from "@vector-im/compound-design-tokens/assets/web/icons/pause-solid"; -import MicrophoneIcon from "@vector-im/compound-design-tokens/assets/web/icons/mic-on-solid"; - -import { - VoiceBroadcastControl, - VoiceBroadcastInfoState, - VoiceBroadcastRecording, - VoiceBroadcastRecordingConnectionError, - VoiceBroadcastRecordingState, -} from "../.."; -import { useVoiceBroadcastRecording } from "../../hooks/useVoiceBroadcastRecording"; -import { VoiceBroadcastHeader } from "../atoms/VoiceBroadcastHeader"; -import { Icon as StopIcon } from "../../../../res/img/compound/stop-16.svg"; -import { Icon as RecordIcon } from "../../../../res/img/compound/record-10px.svg"; -import { _t } from "../../../languageHandler"; -import { useAudioDeviceSelection } from "../../../hooks/useAudioDeviceSelection"; -import { DevicesContextMenu } from "../../../components/views/audio_messages/DevicesContextMenu"; -import AccessibleButton from "../../../components/views/elements/AccessibleButton"; - -interface VoiceBroadcastRecordingPipProps { - recording: VoiceBroadcastRecording; -} - -export const VoiceBroadcastRecordingPip: React.FC = ({ recording }) => { - const pipRef = useRef(null); - const { live, timeLeft, recordingState, room, stopRecording, toggleRecording } = - useVoiceBroadcastRecording(recording); - const { currentDevice, devices, setDevice } = useAudioDeviceSelection(); - - const onDeviceSelect = async (device: MediaDeviceInfo): Promise => { - setShowDeviceSelect(false); - - if (currentDevice?.deviceId === device.deviceId) { - // device unchanged - return; - } - - setDevice(device); - - if ( - ( - [VoiceBroadcastInfoState.Paused, VoiceBroadcastInfoState.Stopped] as VoiceBroadcastRecordingState[] - ).includes(recordingState) - ) { - // Nothing to do in these cases. Resume will use the selected device. - return; - } - - // pause and resume to switch the input device - await recording.pause(); - await recording.resume(); - }; - - const [showDeviceSelect, setShowDeviceSelect] = useState(false); - - const toggleControl = - recordingState === VoiceBroadcastInfoState.Paused ? ( - } - label={_t("voice_broadcast|resume")} - /> - ) : ( - } - label={_t("voice_broadcast|pause")} - /> - ); - - const controls = - recordingState === "connection_error" ? ( - - ) : ( -
- {toggleControl} - setShowDeviceSelect(true)} - title={_t("voip|change_input_device")} - > - - - } - label="Stop Recording" - onClick={stopRecording} - /> -
- ); - - return ( -
- -
- {controls} - {showDeviceSelect && ( - - )} -
- ); -}; diff --git a/src/voice-broadcast/components/molecules/VoiceBroadcastSmallPlaybackBody.tsx b/src/voice-broadcast/components/molecules/VoiceBroadcastSmallPlaybackBody.tsx deleted file mode 100644 index a791ac75d7..0000000000 --- a/src/voice-broadcast/components/molecules/VoiceBroadcastSmallPlaybackBody.tsx +++ /dev/null @@ -1,44 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only -Please see LICENSE files in the repository root for full details. -*/ - -import React from "react"; -import CloseIcon from "@vector-im/compound-design-tokens/assets/web/icons/close"; - -import { - VoiceBroadcastHeader, - VoiceBroadcastPlayback, - VoiceBroadcastPlaybackControl, - VoiceBroadcastPlaybackState, -} from "../.."; -import AccessibleButton from "../../../components/views/elements/AccessibleButton"; -import { useVoiceBroadcastPlayback } from "../../hooks/useVoiceBroadcastPlayback"; - -interface VoiceBroadcastSmallPlaybackBodyProps { - playback: VoiceBroadcastPlayback; -} - -export const VoiceBroadcastSmallPlaybackBody: React.FC = ({ playback }) => { - const { liveness, playbackState, room, sender, toggle } = useVoiceBroadcastPlayback(playback); - return ( -
- - - playback.stop()}> - - -
- ); -}; diff --git a/src/voice-broadcast/hooks/useCurrentVoiceBroadcastPlayback.ts b/src/voice-broadcast/hooks/useCurrentVoiceBroadcastPlayback.ts deleted file mode 100644 index 3ff4081a9f..0000000000 --- a/src/voice-broadcast/hooks/useCurrentVoiceBroadcastPlayback.ts +++ /dev/null @@ -1,32 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only -Please see LICENSE files in the repository root for full details. -*/ - -import { useTypedEventEmitterState } from "../../hooks/useEventEmitter"; -import { VoiceBroadcastPlayback } from "../models/VoiceBroadcastPlayback"; -import { - VoiceBroadcastPlaybacksStore, - VoiceBroadcastPlaybacksStoreEvent, -} from "../stores/VoiceBroadcastPlaybacksStore"; - -export const useCurrentVoiceBroadcastPlayback = ( - voiceBroadcastPlaybackStore: VoiceBroadcastPlaybacksStore, -): { - currentVoiceBroadcastPlayback: VoiceBroadcastPlayback | null; -} => { - const currentVoiceBroadcastPlayback = useTypedEventEmitterState( - voiceBroadcastPlaybackStore, - VoiceBroadcastPlaybacksStoreEvent.CurrentChanged, - (playback?: VoiceBroadcastPlayback) => { - return playback ?? voiceBroadcastPlaybackStore.getCurrent(); - }, - ); - - return { - currentVoiceBroadcastPlayback, - }; -}; diff --git a/src/voice-broadcast/hooks/useCurrentVoiceBroadcastPreRecording.ts b/src/voice-broadcast/hooks/useCurrentVoiceBroadcastPreRecording.ts deleted file mode 100644 index bb14e38640..0000000000 --- a/src/voice-broadcast/hooks/useCurrentVoiceBroadcastPreRecording.ts +++ /dev/null @@ -1,29 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only -Please see LICENSE files in the repository root for full details. -*/ - -import { useTypedEventEmitterState } from "../../hooks/useEventEmitter"; -import { VoiceBroadcastPreRecordingStore } from "../stores/VoiceBroadcastPreRecordingStore"; -import { VoiceBroadcastPreRecording } from "../models/VoiceBroadcastPreRecording"; - -export const useCurrentVoiceBroadcastPreRecording = ( - voiceBroadcastPreRecordingStore: VoiceBroadcastPreRecordingStore, -): { - currentVoiceBroadcastPreRecording: VoiceBroadcastPreRecording | null; -} => { - const currentVoiceBroadcastPreRecording = useTypedEventEmitterState( - voiceBroadcastPreRecordingStore, - "changed", - (preRecording?: VoiceBroadcastPreRecording) => { - return preRecording ?? voiceBroadcastPreRecordingStore.getCurrent(); - }, - ); - - return { - currentVoiceBroadcastPreRecording, - }; -}; diff --git a/src/voice-broadcast/hooks/useCurrentVoiceBroadcastRecording.ts b/src/voice-broadcast/hooks/useCurrentVoiceBroadcastRecording.ts deleted file mode 100644 index 1d4abe3f10..0000000000 --- a/src/voice-broadcast/hooks/useCurrentVoiceBroadcastRecording.ts +++ /dev/null @@ -1,28 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only -Please see LICENSE files in the repository root for full details. -*/ - -import { VoiceBroadcastRecording, VoiceBroadcastRecordingsStore, VoiceBroadcastRecordingsStoreEvent } from ".."; -import { useTypedEventEmitterState } from "../../hooks/useEventEmitter"; - -export const useCurrentVoiceBroadcastRecording = ( - voiceBroadcastRecordingsStore: VoiceBroadcastRecordingsStore, -): { - currentVoiceBroadcastRecording: VoiceBroadcastRecording | null; -} => { - const currentVoiceBroadcastRecording = useTypedEventEmitterState( - voiceBroadcastRecordingsStore, - VoiceBroadcastRecordingsStoreEvent.CurrentChanged, - (recording?: VoiceBroadcastRecording) => { - return recording ?? voiceBroadcastRecordingsStore.getCurrent(); - }, - ); - - return { - currentVoiceBroadcastRecording, - }; -}; diff --git a/src/voice-broadcast/hooks/useHasRoomLiveVoiceBroadcast.ts b/src/voice-broadcast/hooks/useHasRoomLiveVoiceBroadcast.ts deleted file mode 100644 index a298f4dc83..0000000000 --- a/src/voice-broadcast/hooks/useHasRoomLiveVoiceBroadcast.ts +++ /dev/null @@ -1,39 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only -Please see LICENSE files in the repository root for full details. -*/ - -import { useContext, useEffect, useMemo, useState } from "react"; -import { Room, RoomStateEvent } from "matrix-js-sdk/src/matrix"; - -import { hasRoomLiveVoiceBroadcast } from "../utils/hasRoomLiveVoiceBroadcast"; -import { useTypedEventEmitter } from "../../hooks/useEventEmitter"; -import { SDKContext } from "../../contexts/SDKContext"; - -export const useHasRoomLiveVoiceBroadcast = (room: Room): boolean => { - const sdkContext = useContext(SDKContext); - const [hasLiveVoiceBroadcast, setHasLiveVoiceBroadcast] = useState(false); - - const update = useMemo(() => { - return sdkContext?.client - ? () => { - hasRoomLiveVoiceBroadcast(sdkContext.client!, room).then( - ({ hasBroadcast }) => { - setHasLiveVoiceBroadcast(hasBroadcast); - }, - () => {}, // no update on error - ); - } - : () => {}; // noop without client - }, [room, sdkContext, setHasLiveVoiceBroadcast]); - - useEffect(() => { - update(); - }, [update]); - - useTypedEventEmitter(room.currentState, RoomStateEvent.Update, () => update()); - return hasLiveVoiceBroadcast; -}; diff --git a/src/voice-broadcast/hooks/useVoiceBroadcastPlayback.ts b/src/voice-broadcast/hooks/useVoiceBroadcastPlayback.ts deleted file mode 100644 index eb50b0de08..0000000000 --- a/src/voice-broadcast/hooks/useVoiceBroadcastPlayback.ts +++ /dev/null @@ -1,90 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022, 2023 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only -Please see LICENSE files in the repository root for full details. -*/ - -import { Room, RoomMember } from "matrix-js-sdk/src/matrix"; - -import { useTypedEventEmitterState } from "../../hooks/useEventEmitter"; -import { MatrixClientPeg } from "../../MatrixClientPeg"; -import { - VoiceBroadcastLiveness, - VoiceBroadcastPlayback, - VoiceBroadcastPlaybackEvent, - VoiceBroadcastPlaybackState, - VoiceBroadcastPlaybackTimes, -} from ".."; - -export const useVoiceBroadcastPlayback = ( - playback: VoiceBroadcastPlayback, -): { - times: { - duration: number; - position: number; - timeLeft: number; - }; - sender: RoomMember | null; - liveness: VoiceBroadcastLiveness; - playbackState: VoiceBroadcastPlaybackState; - toggle(): void; - room: Room; -} => { - const client = MatrixClientPeg.safeGet(); - const room = client.getRoom(playback.infoEvent.getRoomId()); - - if (!room) { - throw new Error(`Voice Broadcast room not found (event ${playback.infoEvent.getId()})`); - } - - const sender = playback.infoEvent.sender; - - if (!sender) { - throw new Error(`Voice Broadcast sender not found (event ${playback.infoEvent.getId()})`); - } - - const playbackToggle = (): void => { - playback.toggle(); - }; - - const playbackState = useTypedEventEmitterState( - playback, - VoiceBroadcastPlaybackEvent.StateChanged, - (state?: VoiceBroadcastPlaybackState) => { - return state ?? playback.getState(); - }, - ); - - const times = useTypedEventEmitterState( - playback, - VoiceBroadcastPlaybackEvent.TimesChanged, - (t?: VoiceBroadcastPlaybackTimes) => { - return ( - t ?? { - duration: playback.durationSeconds, - position: playback.timeSeconds, - timeLeft: playback.timeLeftSeconds, - } - ); - }, - ); - - const liveness = useTypedEventEmitterState( - playback, - VoiceBroadcastPlaybackEvent.LivenessChanged, - (l?: VoiceBroadcastLiveness) => { - return l ?? playback.getLiveness(); - }, - ); - - return { - times, - liveness: liveness, - playbackState, - room: room, - sender, - toggle: playbackToggle, - }; -}; diff --git a/src/voice-broadcast/hooks/useVoiceBroadcastRecording.tsx b/src/voice-broadcast/hooks/useVoiceBroadcastRecording.tsx deleted file mode 100644 index fa3c635bc9..0000000000 --- a/src/voice-broadcast/hooks/useVoiceBroadcastRecording.tsx +++ /dev/null @@ -1,96 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022, 2023 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only -Please see LICENSE files in the repository root for full details. -*/ - -import { Room, RoomMember } from "matrix-js-sdk/src/matrix"; -import React from "react"; - -import { - VoiceBroadcastInfoState, - VoiceBroadcastRecording, - VoiceBroadcastRecordingEvent, - VoiceBroadcastRecordingState, -} from ".."; -import QuestionDialog from "../../components/views/dialogs/QuestionDialog"; -import { useTypedEventEmitterState } from "../../hooks/useEventEmitter"; -import { _t } from "../../languageHandler"; -import { MatrixClientPeg } from "../../MatrixClientPeg"; -import Modal from "../../Modal"; - -const showStopBroadcastingDialog = async (): Promise => { - const { finished } = Modal.createDialog(QuestionDialog, { - title: _t("voice_broadcast|confirm_stop_title"), - description:

{_t("voice_broadcast|confirm_stop_description")}

, - button: _t("voice_broadcast|confirm_stop_affirm"), - }); - const [confirmed] = await finished; - return !!confirmed; -}; - -export const useVoiceBroadcastRecording = ( - recording: VoiceBroadcastRecording, -): { - live: boolean; - timeLeft: number; - recordingState: VoiceBroadcastRecordingState; - room: Room; - sender: RoomMember | null; - stopRecording(): void; - toggleRecording(): void; -} => { - const client = MatrixClientPeg.safeGet(); - const roomId = recording.infoEvent.getRoomId(); - const room = client.getRoom(roomId); - - if (!room) { - throw new Error("Unable to find voice broadcast room with Id: " + roomId); - } - - const sender = recording.infoEvent.sender; - - if (!sender) { - throw new Error(`Voice Broadcast sender not found (event ${recording.infoEvent.getId()})`); - } - - const stopRecording = async (): Promise => { - const confirmed = await showStopBroadcastingDialog(); - - if (confirmed) { - await recording.stop(); - } - }; - - const recordingState = useTypedEventEmitterState( - recording, - VoiceBroadcastRecordingEvent.StateChanged, - (state?: VoiceBroadcastRecordingState) => { - return state ?? recording.getState(); - }, - ); - - const timeLeft = useTypedEventEmitterState( - recording, - VoiceBroadcastRecordingEvent.TimeLeftChanged, - (t?: number) => { - return t ?? recording.getTimeLeft(); - }, - ); - - const live = ( - [VoiceBroadcastInfoState.Started, VoiceBroadcastInfoState.Resumed] as VoiceBroadcastRecordingState[] - ).includes(recordingState); - - return { - live, - timeLeft, - recordingState, - room, - sender, - stopRecording, - toggleRecording: recording.toggle, - }; -}; diff --git a/src/voice-broadcast/index.ts b/src/voice-broadcast/index.ts deleted file mode 100644 index 712c25fdc2..0000000000 --- a/src/voice-broadcast/index.ts +++ /dev/null @@ -1,57 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only -Please see LICENSE files in the repository root for full details. -*/ - -/** - * Voice Broadcast module - * {@link https://github.com/vector-im/element-meta/discussions/632} - */ - -export * from "./types"; -export * from "./models/VoiceBroadcastPlayback"; -export * from "./models/VoiceBroadcastPreRecording"; -export * from "./models/VoiceBroadcastRecording"; -export * from "./audio/VoiceBroadcastRecorder"; -export * from "./components/VoiceBroadcastBody"; -export * from "./components/atoms/LiveBadge"; -export * from "./components/atoms/VoiceBroadcastControl"; -export * from "./components/atoms/VoiceBroadcastError"; -export * from "./components/atoms/VoiceBroadcastHeader"; -export * from "./components/atoms/VoiceBroadcastPlaybackControl"; -export * from "./components/atoms/VoiceBroadcastRecordingConnectionError"; -export * from "./components/atoms/VoiceBroadcastRoomSubtitle"; -export * from "./components/molecules/ConfirmListenBroadcastStopCurrent"; -export * from "./components/molecules/VoiceBroadcastPlaybackBody"; -export * from "./components/molecules/VoiceBroadcastSmallPlaybackBody"; -export * from "./components/molecules/VoiceBroadcastPreRecordingPip"; -export * from "./components/molecules/VoiceBroadcastRecordingBody"; -export * from "./components/molecules/VoiceBroadcastRecordingPip"; -export * from "./hooks/useCurrentVoiceBroadcastPreRecording"; -export * from "./hooks/useCurrentVoiceBroadcastRecording"; -export * from "./hooks/useHasRoomLiveVoiceBroadcast"; -export * from "./hooks/useVoiceBroadcastRecording"; -export * from "./stores/VoiceBroadcastPlaybacksStore"; -export * from "./stores/VoiceBroadcastPreRecordingStore"; -export * from "./stores/VoiceBroadcastRecordingsStore"; -export * from "./utils/checkVoiceBroadcastPreConditions"; -export * from "./utils/cleanUpBroadcasts"; -export * from "./utils/doClearCurrentVoiceBroadcastPlaybackIfStopped"; -export * from "./utils/doMaybeSetCurrentVoiceBroadcastPlayback"; -export * from "./utils/getChunkLength"; -export * from "./utils/getMaxBroadcastLength"; -export * from "./utils/hasRoomLiveVoiceBroadcast"; -export * from "./utils/isRelatedToVoiceBroadcast"; -export * from "./utils/isVoiceBroadcastStartedEvent"; -export * from "./utils/findRoomLiveVoiceBroadcastFromUserAndDevice"; -export * from "./utils/retrieveStartedInfoEvent"; -export * from "./utils/shouldDisplayAsVoiceBroadcastRecordingTile"; -export * from "./utils/shouldDisplayAsVoiceBroadcastTile"; -export * from "./utils/shouldDisplayAsVoiceBroadcastStoppedText"; -export * from "./utils/startNewVoiceBroadcastRecording"; -export * from "./utils/textForVoiceBroadcastStoppedEvent"; -export * from "./utils/textForVoiceBroadcastStoppedEventWithoutLink"; -export * from "./utils/VoiceBroadcastResumer"; diff --git a/src/voice-broadcast/models/VoiceBroadcastPlayback.ts b/src/voice-broadcast/models/VoiceBroadcastPlayback.ts deleted file mode 100644 index ce6215312f..0000000000 --- a/src/voice-broadcast/models/VoiceBroadcastPlayback.ts +++ /dev/null @@ -1,651 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only -Please see LICENSE files in the repository root for full details. -*/ - -import { - EventType, - MatrixClient, - MatrixEvent, - MatrixEventEvent, - MsgType, - RelationType, - TypedEventEmitter, -} from "matrix-js-sdk/src/matrix"; -import { SimpleObservable } from "matrix-widget-api"; -import { logger } from "matrix-js-sdk/src/logger"; -import { defer, IDeferred } from "matrix-js-sdk/src/utils"; - -import { Playback, PlaybackInterface, PlaybackState } from "../../audio/Playback"; -import { PlaybackManager } from "../../audio/PlaybackManager"; -import { UPDATE_EVENT } from "../../stores/AsyncStore"; -import { MediaEventHelper } from "../../utils/MediaEventHelper"; -import { IDestroyable } from "../../utils/IDestroyable"; -import { - VoiceBroadcastLiveness, - VoiceBroadcastInfoEventType, - VoiceBroadcastInfoState, - VoiceBroadcastInfoEventContent, - VoiceBroadcastRecordingsStore, - showConfirmListenBroadcastStopCurrentDialog, -} from ".."; -import { RelationsHelper, RelationsHelperEvent } from "../../events/RelationsHelper"; -import { VoiceBroadcastChunkEvents } from "../utils/VoiceBroadcastChunkEvents"; -import { determineVoiceBroadcastLiveness } from "../utils/determineVoiceBroadcastLiveness"; -import { _t } from "../../languageHandler"; - -export enum VoiceBroadcastPlaybackState { - Paused = "pause", - Playing = "playing", - Stopped = "stopped", - Buffering = "buffering", - Error = "error", -} - -export enum VoiceBroadcastPlaybackEvent { - TimesChanged = "times_changed", - LivenessChanged = "liveness_changed", - StateChanged = "state_changed", - InfoStateChanged = "info_state_changed", -} - -export type VoiceBroadcastPlaybackTimes = { - duration: number; - position: number; - timeLeft: number; -}; - -interface EventMap { - [VoiceBroadcastPlaybackEvent.TimesChanged]: (times: VoiceBroadcastPlaybackTimes) => void; - [VoiceBroadcastPlaybackEvent.LivenessChanged]: (liveness: VoiceBroadcastLiveness) => void; - [VoiceBroadcastPlaybackEvent.StateChanged]: ( - state: VoiceBroadcastPlaybackState, - playback: VoiceBroadcastPlayback, - ) => void; - [VoiceBroadcastPlaybackEvent.InfoStateChanged]: (state: VoiceBroadcastInfoState) => void; -} - -export class VoiceBroadcastPlayback - extends TypedEventEmitter - implements IDestroyable, PlaybackInterface -{ - private state = VoiceBroadcastPlaybackState.Stopped; - private chunkEvents = new VoiceBroadcastChunkEvents(); - /** @var Map: event Id → undecryptable event */ - private utdChunkEvents: Map = new Map(); - private playbacks = new Map(); - private currentlyPlaying: MatrixEvent | null = null; - /** @var total duration of all chunks in milliseconds */ - private duration = 0; - /** @var current playback position in milliseconds */ - private position = 0; - public readonly liveData = new SimpleObservable(); - private liveness: VoiceBroadcastLiveness = "not-live"; - - // set via addInfoEvent() in constructor - private infoState!: VoiceBroadcastInfoState; - private lastInfoEvent!: MatrixEvent; - - // set via setUpRelationsHelper() in constructor - private chunkRelationHelper!: RelationsHelper; - private infoRelationHelper!: RelationsHelper; - - private skipToNext?: number; - private skipToDeferred?: IDeferred; - - public constructor( - public readonly infoEvent: MatrixEvent, - private client: MatrixClient, - private recordings: VoiceBroadcastRecordingsStore, - ) { - super(); - this.addInfoEvent(this.infoEvent); - this.infoEvent.on(MatrixEventEvent.BeforeRedaction, this.onBeforeRedaction); - this.setUpRelationsHelper(); - } - - private async setUpRelationsHelper(): Promise { - this.infoRelationHelper = new RelationsHelper( - this.infoEvent, - RelationType.Reference, - VoiceBroadcastInfoEventType, - this.client, - ); - this.infoRelationHelper.getCurrent().forEach(this.addInfoEvent); - - if (this.infoState !== VoiceBroadcastInfoState.Stopped) { - // Only required if not stopped. Stopped is the final state. - this.infoRelationHelper.on(RelationsHelperEvent.Add, this.addInfoEvent); - - try { - await this.infoRelationHelper.emitFetchCurrent(); - } catch (err) { - logger.warn("error fetching server side relation for voice broadcast info", err); - // fall back to local events - this.infoRelationHelper.emitCurrent(); - } - } - - this.chunkRelationHelper = new RelationsHelper( - this.infoEvent, - RelationType.Reference, - EventType.RoomMessage, - this.client, - ); - this.chunkRelationHelper.on(RelationsHelperEvent.Add, this.addChunkEvent); - - try { - // TODO Michael W: only fetch events if needed, blocked by PSF-1708 - await this.chunkRelationHelper.emitFetchCurrent(); - } catch (err) { - logger.warn("error fetching server side relation for voice broadcast chunks", err); - // fall back to local events - this.chunkRelationHelper.emitCurrent(); - } - } - - private addChunkEvent = async (event: MatrixEvent): Promise => { - if (!event.getId() && !event.getTxnId()) { - // skip events without id and txn id - return false; - } - - if (event.isDecryptionFailure()) { - this.onChunkEventDecryptionFailure(event); - return false; - } - - if (event.getContent()?.msgtype !== MsgType.Audio) { - // skip non-audio event - return false; - } - - this.chunkEvents.addEvent(event); - this.setDuration(this.chunkEvents.getLength()); - - if (this.getState() === VoiceBroadcastPlaybackState.Buffering) { - await this.startOrPlayNext(); - } - - return true; - }; - - private onChunkEventDecryptionFailure = (event: MatrixEvent): void => { - const eventId = event.getId(); - - if (!eventId) { - // This should not happen, as the existence of the Id is checked before the call. - // Log anyway and return. - logger.warn("Broadcast chunk decryption failure for event without Id", { - broadcast: this.infoEvent.getId(), - }); - return; - } - - if (!this.utdChunkEvents.has(eventId)) { - event.once(MatrixEventEvent.Decrypted, this.onChunkEventDecrypted); - } - - this.utdChunkEvents.set(eventId, event); - this.setError(); - }; - - private onChunkEventDecrypted = async (event: MatrixEvent): Promise => { - const eventId = event.getId(); - - if (!eventId) { - // This should not happen, as the existence of the Id is checked before the call. - // Log anyway and return. - logger.warn("Broadcast chunk decrypted for event without Id", { broadcast: this.infoEvent.getId() }); - return; - } - - this.utdChunkEvents.delete(eventId); - await this.addChunkEvent(event); - - if (this.utdChunkEvents.size === 0) { - // no more UTD events, recover from error to paused - this.setState(VoiceBroadcastPlaybackState.Paused); - } - }; - - private startOrPlayNext = async (): Promise => { - if (this.currentlyPlaying) { - return this.playNext(); - } - - return await this.start(); - }; - - private addInfoEvent = (event: MatrixEvent): void => { - if (this.lastInfoEvent && this.lastInfoEvent.getTs() >= event.getTs()) { - // Only handle newer events - return; - } - - const state = event.getContent()?.state; - - if (!Object.values(VoiceBroadcastInfoState).includes(state)) { - // Do not handle unknown voice broadcast states - return; - } - - this.lastInfoEvent = event; - this.setInfoState(state); - }; - - private onBeforeRedaction = (): void => { - if (this.getState() !== VoiceBroadcastPlaybackState.Stopped) { - this.stop(); - // destroy cleans up everything - this.destroy(); - } - }; - - private async tryLoadPlayback(chunkEvent: MatrixEvent): Promise { - try { - return await this.loadPlayback(chunkEvent); - } catch (err: any) { - logger.warn("Unable to load broadcast playback", { - message: err.message, - broadcastId: this.infoEvent.getId(), - chunkId: chunkEvent.getId(), - }); - this.setError(); - } - } - - private async loadPlayback(chunkEvent: MatrixEvent): Promise { - const eventId = chunkEvent.getId(); - - if (!eventId) { - throw new Error("Broadcast chunk event without Id occurred"); - } - - const helper = new MediaEventHelper(chunkEvent); - const blob = await helper.sourceBlob.value; - const buffer = await blob.arrayBuffer(); - const playback = PlaybackManager.instance.createPlaybackInstance(buffer); - await playback.prepare(); - playback.clockInfo.populatePlaceholdersFrom(chunkEvent); - this.playbacks.set(eventId, playback); - playback.on(UPDATE_EVENT, (state) => this.onPlaybackStateChange(chunkEvent, state)); - playback.clockInfo.liveData.onUpdate(([position]) => { - this.onPlaybackPositionUpdate(chunkEvent, position); - }); - } - - private unloadPlayback(event: MatrixEvent): void { - const playback = this.playbacks.get(event.getId()!); - if (!playback) return; - - playback.destroy(); - this.playbacks.delete(event.getId()!); - } - - private onPlaybackPositionUpdate = (event: MatrixEvent, position: number): void => { - if (event !== this.currentlyPlaying) return; - - const newPosition = this.chunkEvents.getLengthTo(event) + position * 1000; // observable sends seconds - - // do not jump backwards - this can happen when transiting from one to another chunk - if (newPosition < this.position) return; - - this.setPosition(newPosition); - }; - - private setDuration(duration: number): void { - if (this.duration === duration) return; - - this.duration = duration; - this.emitTimesChanged(); - this.liveData.update([this.timeSeconds, this.durationSeconds]); - } - - private setPosition(position: number): void { - if (this.position === position) return; - - this.position = position; - this.emitTimesChanged(); - this.liveData.update([this.timeSeconds, this.durationSeconds]); - } - - private emitTimesChanged(): void { - this.emit(VoiceBroadcastPlaybackEvent.TimesChanged, { - duration: this.durationSeconds, - position: this.timeSeconds, - timeLeft: this.timeLeftSeconds, - }); - } - - private onPlaybackStateChange = async (event: MatrixEvent, newState: PlaybackState): Promise => { - if (event !== this.currentlyPlaying) return; - if (newState !== PlaybackState.Stopped) return; - - await this.playNext(); - this.unloadPlayback(event); - }; - - private async playNext(): Promise { - if (!this.currentlyPlaying) return; - - const next = this.chunkEvents.getNext(this.currentlyPlaying); - - if (next) { - return this.playEvent(next); - } - - if ( - this.getInfoState() === VoiceBroadcastInfoState.Stopped && - this.chunkEvents.getSequenceForEvent(this.currentlyPlaying) === this.lastChunkSequence - ) { - this.stop(); - } else { - // No more chunks available, although the broadcast is not finished → enter buffering state. - this.setState(VoiceBroadcastPlaybackState.Buffering); - } - } - - /** - * @returns {number} The last chunk sequence from the latest info event. - * Falls back to the length of received chunks if the info event does not provide the number. - */ - private get lastChunkSequence(): number { - return ( - this.lastInfoEvent.getContent()?.last_chunk_sequence || - this.chunkEvents.getNumberOfEvents() - ); - } - - private async playEvent(event: MatrixEvent): Promise { - this.setState(VoiceBroadcastPlaybackState.Playing); - this.currentlyPlaying = event; - const playback = await this.tryGetOrLoadPlaybackForEvent(event); - playback?.play(); - } - - private async tryGetOrLoadPlaybackForEvent(event: MatrixEvent): Promise { - try { - return await this.getOrLoadPlaybackForEvent(event); - } catch (err: any) { - logger.warn("Unable to load broadcast playback", { - message: err.message, - broadcastId: this.infoEvent.getId(), - chunkId: event.getId(), - }); - this.setError(); - } - } - - private async getOrLoadPlaybackForEvent(event: MatrixEvent): Promise { - const eventId = event.getId(); - - if (!eventId) { - throw new Error("Broadcast chunk event without Id occurred"); - } - - if (!this.playbacks.has(eventId)) { - // set to buffering while loading the chunk data - const currentState = this.getState(); - this.setState(VoiceBroadcastPlaybackState.Buffering); - await this.loadPlayback(event); - this.setState(currentState); - } - - const playback = this.playbacks.get(eventId); - - if (!playback) { - throw new Error(`Unable to find playback for event ${event.getId()}`); - } - - // try to load the playback for the next event for a smooth(er) playback - const nextEvent = this.chunkEvents.getNext(event); - if (nextEvent) this.tryLoadPlayback(nextEvent); - - return playback; - } - - private getCurrentPlayback(): Playback | undefined { - if (!this.currentlyPlaying) return; - return this.playbacks.get(this.currentlyPlaying.getId()!); - } - - public getLiveness(): VoiceBroadcastLiveness { - return this.liveness; - } - - private setLiveness(liveness: VoiceBroadcastLiveness): void { - if (this.liveness === liveness) return; - - this.liveness = liveness; - this.emit(VoiceBroadcastPlaybackEvent.LivenessChanged, liveness); - } - - public get currentState(): PlaybackState { - return PlaybackState.Playing; - } - - public get timeSeconds(): number { - return this.position / 1000; - } - - public get durationSeconds(): number { - return this.duration / 1000; - } - - public get timeLeftSeconds(): number { - // Sometimes the meta data and the audio files are a little bit out of sync. - // Be sure it never returns a negative value. - return Math.max(0, Math.round(this.durationSeconds) - this.timeSeconds); - } - - public async skipTo(timeSeconds: number): Promise { - this.skipToNext = timeSeconds; - - if (this.skipToDeferred) { - // Skip to position is already in progress. Return the promise for that. - return this.skipToDeferred.promise; - } - - this.skipToDeferred = defer(); - - while (this.skipToNext !== undefined) { - // Skip to position until skipToNext is undefined. - // skipToNext can be set if skipTo is called while already skipping. - const skipToNext = this.skipToNext; - this.skipToNext = undefined; - await this.doSkipTo(skipToNext); - } - - this.skipToDeferred.resolve(); - this.skipToDeferred = undefined; - } - - private async doSkipTo(timeSeconds: number): Promise { - const time = timeSeconds * 1000; - const event = this.chunkEvents.findByTime(time); - - if (!event) { - logger.warn("voice broadcast chunk event to skip to not found"); - return; - } - - const currentPlayback = this.getCurrentPlayback(); - const skipToPlayback = await this.tryGetOrLoadPlaybackForEvent(event); - const currentPlaybackEvent = this.currentlyPlaying; - - if (!skipToPlayback) { - logger.warn("voice broadcast chunk to skip to not found", event); - return; - } - - this.currentlyPlaying = event; - - if (currentPlayback && currentPlaybackEvent && currentPlayback !== skipToPlayback) { - // only stop and unload the playback here without triggering other effects, e.g. play next - currentPlayback.off(UPDATE_EVENT, this.onPlaybackStateChange); - await currentPlayback.stop(); - currentPlayback.on(UPDATE_EVENT, this.onPlaybackStateChange); - this.unloadPlayback(currentPlaybackEvent); - } - - const offsetInChunk = time - this.chunkEvents.getLengthTo(event); - await skipToPlayback.skipTo(offsetInChunk / 1000); - - if (this.state === VoiceBroadcastPlaybackState.Playing && !skipToPlayback.isPlaying) { - await skipToPlayback.play(); - } - - this.setPosition(time); - } - - public async start(): Promise { - if (this.state === VoiceBroadcastPlaybackState.Playing) return; - - const currentRecording = this.recordings.getCurrent(); - - if (currentRecording && currentRecording.getState() !== VoiceBroadcastInfoState.Stopped) { - const shouldStopRecording = await showConfirmListenBroadcastStopCurrentDialog(); - - if (!shouldStopRecording) { - // keep recording - return; - } - - await this.recordings.getCurrent()?.stop(); - } - - const chunkEvents = this.chunkEvents.getEvents(); - - const toPlay = - this.getInfoState() === VoiceBroadcastInfoState.Stopped - ? chunkEvents[0] // start at the beginning for an ended voice broadcast - : chunkEvents[chunkEvents.length - 1]; // start at the current chunk for an ongoing voice broadcast - - if (toPlay) { - return this.playEvent(toPlay); - } - - this.setState(VoiceBroadcastPlaybackState.Buffering); - } - - public stop(): void { - // error is a final state - if (this.getState() === VoiceBroadcastPlaybackState.Error) return; - - this.setState(VoiceBroadcastPlaybackState.Stopped); - this.getCurrentPlayback()?.stop(); - this.currentlyPlaying = null; - this.setPosition(0); - } - - public pause(): void { - // error is a final state - if (this.getState() === VoiceBroadcastPlaybackState.Error) return; - - // stopped voice broadcasts cannot be paused - if (this.getState() === VoiceBroadcastPlaybackState.Stopped) return; - - this.setState(VoiceBroadcastPlaybackState.Paused); - this.getCurrentPlayback()?.pause(); - } - - public resume(): void { - // error is a final state - if (this.getState() === VoiceBroadcastPlaybackState.Error) return; - - if (!this.currentlyPlaying) { - // no playback to resume, start from the beginning - this.start(); - return; - } - - this.setState(VoiceBroadcastPlaybackState.Playing); - this.getCurrentPlayback()?.play(); - } - - /** - * Toggles the playback: - * stopped → playing - * playing → paused - * paused → playing - */ - public async toggle(): Promise { - // error is a final state - if (this.getState() === VoiceBroadcastPlaybackState.Error) return; - - if (this.state === VoiceBroadcastPlaybackState.Stopped) { - await this.start(); - return; - } - - if (this.state === VoiceBroadcastPlaybackState.Paused) { - this.resume(); - return; - } - - this.pause(); - } - - public getState(): VoiceBroadcastPlaybackState { - return this.state; - } - - private setState(state: VoiceBroadcastPlaybackState): void { - if (this.state === state) { - return; - } - - this.state = state; - this.emit(VoiceBroadcastPlaybackEvent.StateChanged, state, this); - } - - /** - * Set error state. Stop current playback, if any. - */ - private setError(): void { - this.setState(VoiceBroadcastPlaybackState.Error); - this.getCurrentPlayback()?.stop(); - this.currentlyPlaying = null; - this.setPosition(0); - } - - public getInfoState(): VoiceBroadcastInfoState { - return this.infoState; - } - - private setInfoState(state: VoiceBroadcastInfoState): void { - if (this.infoState === state) { - return; - } - - this.infoState = state; - this.emit(VoiceBroadcastPlaybackEvent.InfoStateChanged, state); - this.setLiveness(determineVoiceBroadcastLiveness(this.infoState)); - } - - public get errorMessage(): string { - if (this.getState() !== VoiceBroadcastPlaybackState.Error) return ""; - if (this.utdChunkEvents.size) return _t("voice_broadcast|failed_decrypt"); - return _t("voice_broadcast|failed_generic"); - } - - public destroy(): void { - for (const [, utdEvent] of this.utdChunkEvents) { - utdEvent.off(MatrixEventEvent.Decrypted, this.onChunkEventDecrypted); - } - - this.utdChunkEvents.clear(); - - this.chunkRelationHelper.destroy(); - this.infoRelationHelper.destroy(); - this.removeAllListeners(); - - this.chunkEvents = new VoiceBroadcastChunkEvents(); - this.playbacks.forEach((p) => p.destroy()); - this.playbacks = new Map(); - } -} diff --git a/src/voice-broadcast/models/VoiceBroadcastPreRecording.ts b/src/voice-broadcast/models/VoiceBroadcastPreRecording.ts deleted file mode 100644 index 0cf47c6f21..0000000000 --- a/src/voice-broadcast/models/VoiceBroadcastPreRecording.ts +++ /dev/null @@ -1,48 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only -Please see LICENSE files in the repository root for full details. -*/ - -import { MatrixClient, Room, RoomMember, TypedEventEmitter } from "matrix-js-sdk/src/matrix"; - -import { IDestroyable } from "../../utils/IDestroyable"; -import { VoiceBroadcastPlaybacksStore } from "../stores/VoiceBroadcastPlaybacksStore"; -import { VoiceBroadcastRecordingsStore } from "../stores/VoiceBroadcastRecordingsStore"; -import { startNewVoiceBroadcastRecording } from "../utils/startNewVoiceBroadcastRecording"; - -type VoiceBroadcastPreRecordingEvent = "dismiss"; - -interface EventMap { - dismiss: (voiceBroadcastPreRecording: VoiceBroadcastPreRecording) => void; -} - -export class VoiceBroadcastPreRecording - extends TypedEventEmitter - implements IDestroyable -{ - public constructor( - public room: Room, - public sender: RoomMember, - private client: MatrixClient, - private playbacksStore: VoiceBroadcastPlaybacksStore, - private recordingsStore: VoiceBroadcastRecordingsStore, - ) { - super(); - } - - public start = async (): Promise => { - await startNewVoiceBroadcastRecording(this.room, this.client, this.playbacksStore, this.recordingsStore); - this.emit("dismiss", this); - }; - - public cancel = (): void => { - this.emit("dismiss", this); - }; - - public destroy(): void { - this.removeAllListeners(); - } -} diff --git a/src/voice-broadcast/models/VoiceBroadcastRecording.ts b/src/voice-broadcast/models/VoiceBroadcastRecording.ts deleted file mode 100644 index ebf8ee697f..0000000000 --- a/src/voice-broadcast/models/VoiceBroadcastRecording.ts +++ /dev/null @@ -1,441 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only -Please see LICENSE files in the repository root for full details. -*/ - -import { logger } from "matrix-js-sdk/src/logger"; -import { - ClientEvent, - ClientEventHandlerMap, - EventType, - MatrixClient, - MatrixEvent, - MatrixEventEvent, - MsgType, - RelationType, - TypedEventEmitter, -} from "matrix-js-sdk/src/matrix"; -import { AudioContent, EncryptedFile } from "matrix-js-sdk/src/types"; - -import { - ChunkRecordedPayload, - createVoiceBroadcastRecorder, - getMaxBroadcastLength, - VoiceBroadcastInfoEventContent, - VoiceBroadcastInfoEventType, - VoiceBroadcastInfoState, - VoiceBroadcastRecorder, - VoiceBroadcastRecorderEvent, -} from ".."; -import { uploadFile } from "../../ContentMessages"; -import { createVoiceMessageContent } from "../../utils/createVoiceMessageContent"; -import { IDestroyable } from "../../utils/IDestroyable"; -import dis from "../../dispatcher/dispatcher"; -import { ActionPayload } from "../../dispatcher/payloads"; -import { VoiceBroadcastChunkEvents } from "../utils/VoiceBroadcastChunkEvents"; -import { RelationsHelper, RelationsHelperEvent } from "../../events/RelationsHelper"; -import { createReconnectedListener } from "../../utils/connection"; -import { localNotificationsAreSilenced } from "../../utils/notifications"; -import { BackgroundAudio } from "../../audio/BackgroundAudio"; - -export enum VoiceBroadcastRecordingEvent { - StateChanged = "liveness_changed", - TimeLeftChanged = "time_left_changed", -} - -export type VoiceBroadcastRecordingState = VoiceBroadcastInfoState | "connection_error"; - -interface EventMap { - [VoiceBroadcastRecordingEvent.StateChanged]: (state: VoiceBroadcastRecordingState) => void; - [VoiceBroadcastRecordingEvent.TimeLeftChanged]: (timeLeft: number) => void; -} - -export class VoiceBroadcastRecording - extends TypedEventEmitter - implements IDestroyable -{ - private state: VoiceBroadcastRecordingState; - private recorder: VoiceBroadcastRecorder | null = null; - private dispatcherRef: string; - private chunkEvents = new VoiceBroadcastChunkEvents(); - private chunkRelationHelper: RelationsHelper; - private maxLength: number; - private timeLeft: number; - private toRetry: Array<() => Promise> = []; - private reconnectedListener: ClientEventHandlerMap[ClientEvent.Sync]; - private roomId: string; - private infoEventId: string; - private backgroundAudio = new BackgroundAudio(); - - /** - * Broadcast chunks have a sequence number to bring them in the correct order and to know if a message is missing. - * This variable holds the last sequence number. - * Starts with 0 because there is no chunk at the beginning of a broadcast. - * Will be incremented when a chunk message is created. - */ - private sequence = 0; - - public constructor( - public readonly infoEvent: MatrixEvent, - private client: MatrixClient, - initialState?: VoiceBroadcastInfoState, - ) { - super(); - this.maxLength = getMaxBroadcastLength(); - this.timeLeft = this.maxLength; - this.infoEventId = this.determineEventIdFromInfoEvent(); - this.roomId = this.determineRoomIdFromInfoEvent(); - - if (initialState) { - this.state = initialState; - } else { - this.state = this.determineInitialStateFromInfoEvent(); - } - - // TODO Michael W: listen for state updates - - this.infoEvent.on(MatrixEventEvent.BeforeRedaction, this.onBeforeRedaction); - this.dispatcherRef = dis.register(this.onAction); - this.chunkRelationHelper = this.initialiseChunkEventRelation(); - this.reconnectedListener = createReconnectedListener(this.onReconnect); - this.client.on(ClientEvent.Sync, this.reconnectedListener); - } - - private initialiseChunkEventRelation(): RelationsHelper { - const relationsHelper = new RelationsHelper( - this.infoEvent, - RelationType.Reference, - EventType.RoomMessage, - this.client, - ); - relationsHelper.on(RelationsHelperEvent.Add, this.onChunkEvent); - - relationsHelper.emitFetchCurrent().catch((err) => { - logger.warn("error fetching server side relation for voice broadcast chunks", err); - // fall back to local events - relationsHelper.emitCurrent(); - }); - - return relationsHelper; - } - - private onChunkEvent = (event: MatrixEvent): void => { - if ( - (!event.getId() && !event.getTxnId()) || - event.getContent()?.msgtype !== MsgType.Audio // don't add non-audio event - ) { - return; - } - - this.chunkEvents.addEvent(event); - }; - - private determineEventIdFromInfoEvent(): string { - const infoEventId = this.infoEvent.getId(); - - if (!infoEventId) { - throw new Error("Cannot create broadcast for info event without Id."); - } - - return infoEventId; - } - - private determineRoomIdFromInfoEvent(): string { - const roomId = this.infoEvent.getRoomId(); - - if (!roomId) { - throw new Error(`Cannot create broadcast for unknown room (info event ${this.infoEventId})`); - } - - return roomId; - } - - /** - * Determines the initial broadcast state. - * Checks all related events. If one has the "stopped" state → stopped, else started. - */ - private determineInitialStateFromInfoEvent(): VoiceBroadcastRecordingState { - const room = this.client.getRoom(this.roomId); - const relations = room - ?.getUnfilteredTimelineSet() - ?.relations?.getChildEventsForEvent(this.infoEventId, RelationType.Reference, VoiceBroadcastInfoEventType); - const relatedEvents = relations?.getRelations(); - return !relatedEvents?.find((event: MatrixEvent) => { - return event.getContent()?.state === VoiceBroadcastInfoState.Stopped; - }) - ? VoiceBroadcastInfoState.Started - : VoiceBroadcastInfoState.Stopped; - } - - public getTimeLeft(): number { - return this.timeLeft; - } - - /** - * Retries failed actions on reconnect. - */ - private onReconnect = async (): Promise => { - // Do nothing if not in connection_error state. - if (this.state !== "connection_error") return; - - // Copy the array, so that it is possible to remove elements from it while iterating over the original. - const toRetryCopy = [...this.toRetry]; - - for (const retryFn of this.toRetry) { - try { - await retryFn(); - // Successfully retried. Remove from array copy. - toRetryCopy.splice(toRetryCopy.indexOf(retryFn), 1); - } catch { - // The current retry callback failed. Stop the loop. - break; - } - } - - this.toRetry = toRetryCopy; - - if (this.toRetry.length === 0) { - // Everything has been successfully retried. Recover from error state to paused. - await this.pause(); - } - }; - - private async setTimeLeft(timeLeft: number): Promise { - if (timeLeft <= 0) { - // time is up - stop the recording - return await this.stop(); - } - - // do never increase time left; no action if equals - if (timeLeft >= this.timeLeft) return; - - this.timeLeft = timeLeft; - this.emit(VoiceBroadcastRecordingEvent.TimeLeftChanged, timeLeft); - } - - public async start(): Promise { - return this.getRecorder().start(); - } - - public async stop(): Promise { - if (this.state === VoiceBroadcastInfoState.Stopped) return; - - this.setState(VoiceBroadcastInfoState.Stopped); - await this.stopRecorder(); - await this.sendInfoStateEvent(VoiceBroadcastInfoState.Stopped); - } - - public async pause(): Promise { - // stopped or already paused recordings cannot be paused - if ( - ( - [VoiceBroadcastInfoState.Stopped, VoiceBroadcastInfoState.Paused] as VoiceBroadcastRecordingState[] - ).includes(this.state) - ) - return; - - this.setState(VoiceBroadcastInfoState.Paused); - await this.stopRecorder(); - await this.sendInfoStateEvent(VoiceBroadcastInfoState.Paused); - } - - public async resume(): Promise { - if (this.state !== VoiceBroadcastInfoState.Paused) return; - - this.setState(VoiceBroadcastInfoState.Resumed); - await this.getRecorder().start(); - await this.sendInfoStateEvent(VoiceBroadcastInfoState.Resumed); - } - - public toggle = async (): Promise => { - if (this.getState() === VoiceBroadcastInfoState.Paused) return this.resume(); - - if ( - ( - [VoiceBroadcastInfoState.Started, VoiceBroadcastInfoState.Resumed] as VoiceBroadcastRecordingState[] - ).includes(this.getState()) - ) { - return this.pause(); - } - }; - - public getState(): VoiceBroadcastRecordingState { - return this.state; - } - - private getRecorder(): VoiceBroadcastRecorder { - if (!this.recorder) { - this.recorder = createVoiceBroadcastRecorder(); - this.recorder.on(VoiceBroadcastRecorderEvent.ChunkRecorded, this.onChunkRecorded); - this.recorder.on(VoiceBroadcastRecorderEvent.CurrentChunkLengthUpdated, this.onCurrentChunkLengthUpdated); - } - - return this.recorder; - } - - public async destroy(): Promise { - if (this.recorder) { - this.recorder.stop(); - this.recorder.destroy(); - } - - this.infoEvent.off(MatrixEventEvent.BeforeRedaction, this.onBeforeRedaction); - this.removeAllListeners(); - dis.unregister(this.dispatcherRef); - this.chunkEvents = new VoiceBroadcastChunkEvents(); - this.chunkRelationHelper.destroy(); - this.client.off(ClientEvent.Sync, this.reconnectedListener); - } - - private onBeforeRedaction = (): void => { - if (this.getState() !== VoiceBroadcastInfoState.Stopped) { - this.setState(VoiceBroadcastInfoState.Stopped); - // destroy cleans up everything - this.destroy(); - } - }; - - private onAction = (payload: ActionPayload): void => { - if (payload.action !== "call_state") return; - - // pause on any call action - this.pause(); - }; - - private setState(state: VoiceBroadcastRecordingState): void { - this.state = state; - this.emit(VoiceBroadcastRecordingEvent.StateChanged, this.state); - } - - private onCurrentChunkLengthUpdated = (currentChunkLength: number): void => { - this.setTimeLeft(this.maxLength - this.chunkEvents.getLengthSeconds() - currentChunkLength); - }; - - private onChunkRecorded = async (chunk: ChunkRecordedPayload): Promise => { - const uploadAndSendFn = async (): Promise => { - const { url, file } = await this.uploadFile(chunk); - await this.sendVoiceMessage(chunk, url, file); - }; - - await this.callWithRetry(uploadAndSendFn); - }; - - /** - * This function is called on connection errors. - * It sets the connection error state and stops the recorder. - */ - private async onConnectionError(): Promise { - this.playConnectionErrorAudioNotification().catch(() => { - // Error logged in playConnectionErrorAudioNotification(). - }); - await this.stopRecorder(false); - this.setState("connection_error"); - } - - private async playConnectionErrorAudioNotification(): Promise { - if (localNotificationsAreSilenced(this.client)) { - return; - } - - await this.backgroundAudio.pickFormatAndPlay("./media/error", ["mp3", "ogg"]); - } - - private async uploadFile(chunk: ChunkRecordedPayload): ReturnType { - return uploadFile( - this.client, - this.roomId, - new Blob([chunk.buffer], { - type: this.getRecorder().contentType, - }), - ); - } - - private async sendVoiceMessage(chunk: ChunkRecordedPayload, url?: string, file?: EncryptedFile): Promise { - /** - * Increment the last sequence number and use it for this message. - * Done outside of the sendMessageFn to get a scoped value. - * Also see {@link VoiceBroadcastRecording.sequence}. - */ - const sequence = ++this.sequence; - - const sendMessageFn = async (): Promise => { - const content = createVoiceMessageContent( - url, - this.getRecorder().contentType, - Math.round(chunk.length * 1000), - chunk.buffer.length, - file, - ); - content["m.relates_to"] = { - rel_type: RelationType.Reference, - event_id: this.infoEventId, - }; - (content)["io.element.voice_broadcast_chunk"] = { - sequence, - }; - - await this.client.sendMessage(this.roomId, content); - }; - - await this.callWithRetry(sendMessageFn); - } - - /** - * Sends an info state event with given state. - * On error stores a resend function and setState(state) in {@link toRetry} and - * sets the broadcast state to connection_error. - */ - private async sendInfoStateEvent(state: VoiceBroadcastInfoState): Promise { - const sendEventFn = async (): Promise => { - await this.client.sendStateEvent( - this.roomId, - VoiceBroadcastInfoEventType, - { - device_id: this.client.getDeviceId(), - state, - last_chunk_sequence: this.sequence, - ["m.relates_to"]: { - rel_type: RelationType.Reference, - event_id: this.infoEventId, - }, - } as VoiceBroadcastInfoEventContent, - this.client.getSafeUserId(), - ); - }; - - await this.callWithRetry(sendEventFn); - } - - /** - * Calls the function. - * On failure adds it to the retry list and triggers connection error. - * {@link toRetry} - * {@link onConnectionError} - */ - private async callWithRetry(retryAbleFn: () => Promise): Promise { - try { - await retryAbleFn(); - } catch { - this.toRetry.push(retryAbleFn); - this.onConnectionError(); - } - } - - private async stopRecorder(emit = true): Promise { - if (!this.recorder) { - return; - } - - try { - const lastChunk = await this.recorder.stop(); - if (lastChunk && emit) { - await this.onChunkRecorded(lastChunk); - } - } catch (err) { - logger.warn("error stopping voice broadcast recorder", err); - } - } -} diff --git a/src/voice-broadcast/stores/VoiceBroadcastPlaybacksStore.ts b/src/voice-broadcast/stores/VoiceBroadcastPlaybacksStore.ts deleted file mode 100644 index 69b8c21d90..0000000000 --- a/src/voice-broadcast/stores/VoiceBroadcastPlaybacksStore.ts +++ /dev/null @@ -1,113 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only -Please see LICENSE files in the repository root for full details. -*/ - -import { MatrixClient, MatrixEvent, TypedEventEmitter } from "matrix-js-sdk/src/matrix"; - -import { - VoiceBroadcastPlayback, - VoiceBroadcastPlaybackEvent, - VoiceBroadcastPlaybackState, - VoiceBroadcastRecordingsStore, -} from ".."; -import { IDestroyable } from "../../utils/IDestroyable"; - -export enum VoiceBroadcastPlaybacksStoreEvent { - CurrentChanged = "current_changed", -} - -interface EventMap { - [VoiceBroadcastPlaybacksStoreEvent.CurrentChanged]: (recording: VoiceBroadcastPlayback | null) => void; -} - -/** - * This store manages VoiceBroadcastPlaybacks: - * - access the currently playing voice broadcast - * - ensures that only once broadcast is playing at a time - */ -export class VoiceBroadcastPlaybacksStore - extends TypedEventEmitter - implements IDestroyable -{ - private current: VoiceBroadcastPlayback | null = null; - - /** Playbacks indexed by their info event id. */ - private playbacks = new Map(); - - public constructor(private recordings: VoiceBroadcastRecordingsStore) { - super(); - } - - public setCurrent(current: VoiceBroadcastPlayback): void { - if (this.current === current) return; - - this.current = current; - this.addPlayback(current); - this.emit(VoiceBroadcastPlaybacksStoreEvent.CurrentChanged, current); - } - - public clearCurrent(): void { - if (this.current === null) return; - - this.current = null; - this.emit(VoiceBroadcastPlaybacksStoreEvent.CurrentChanged, null); - } - - public getCurrent(): VoiceBroadcastPlayback | null { - return this.current; - } - - public getByInfoEvent(infoEvent: MatrixEvent, client: MatrixClient): VoiceBroadcastPlayback { - const infoEventId = infoEvent.getId()!; - - if (!this.playbacks.has(infoEventId)) { - this.addPlayback(new VoiceBroadcastPlayback(infoEvent, client, this.recordings)); - } - - return this.playbacks.get(infoEventId)!; - } - - private addPlayback(playback: VoiceBroadcastPlayback): void { - const infoEventId = playback.infoEvent.getId()!; - - if (this.playbacks.has(infoEventId)) return; - - this.playbacks.set(infoEventId, playback); - playback.on(VoiceBroadcastPlaybackEvent.StateChanged, this.onPlaybackStateChanged); - } - - private onPlaybackStateChanged = (state: VoiceBroadcastPlaybackState, playback: VoiceBroadcastPlayback): void => { - switch (state) { - case VoiceBroadcastPlaybackState.Buffering: - case VoiceBroadcastPlaybackState.Playing: - this.pauseExcept(playback); - this.setCurrent(playback); - break; - case VoiceBroadcastPlaybackState.Stopped: - this.clearCurrent(); - break; - } - }; - - private pauseExcept(playbackNotToPause: VoiceBroadcastPlayback): void { - for (const playback of this.playbacks.values()) { - if (playback !== playbackNotToPause) { - playback.pause(); - } - } - } - - public destroy(): void { - this.removeAllListeners(); - - for (const playback of this.playbacks.values()) { - playback.off(VoiceBroadcastPlaybackEvent.StateChanged, this.onPlaybackStateChanged); - } - - this.playbacks = new Map(); - } -} diff --git a/src/voice-broadcast/stores/VoiceBroadcastPreRecordingStore.ts b/src/voice-broadcast/stores/VoiceBroadcastPreRecordingStore.ts deleted file mode 100644 index 3552930687..0000000000 --- a/src/voice-broadcast/stores/VoiceBroadcastPreRecordingStore.ts +++ /dev/null @@ -1,63 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only -Please see LICENSE files in the repository root for full details. -*/ - -import { TypedEventEmitter } from "matrix-js-sdk/src/matrix"; - -import { VoiceBroadcastPreRecording } from ".."; -import { IDestroyable } from "../../utils/IDestroyable"; - -export type VoiceBroadcastPreRecordingEvent = "changed"; - -interface EventMap { - changed: (preRecording: VoiceBroadcastPreRecording | null) => void; -} - -export class VoiceBroadcastPreRecordingStore - extends TypedEventEmitter - implements IDestroyable -{ - private current: VoiceBroadcastPreRecording | null = null; - - public setCurrent(current: VoiceBroadcastPreRecording): void { - if (this.current === current) return; - - if (this.current) { - this.current.off("dismiss", this.onCancel); - } - - this.current = current; - current.on("dismiss", this.onCancel); - this.emit("changed", current); - } - - public clearCurrent(): void { - if (this.current === null) return; - - this.current.off("dismiss", this.onCancel); - this.current = null; - this.emit("changed", null); - } - - public getCurrent(): VoiceBroadcastPreRecording | null { - return this.current; - } - - public destroy(): void { - this.removeAllListeners(); - - if (this.current) { - this.current.off("dismiss", this.onCancel); - } - } - - private onCancel = (voiceBroadcastPreRecording: VoiceBroadcastPreRecording): void => { - if (this.current === voiceBroadcastPreRecording) { - this.clearCurrent(); - } - }; -} diff --git a/src/voice-broadcast/stores/VoiceBroadcastRecordingsStore.ts b/src/voice-broadcast/stores/VoiceBroadcastRecordingsStore.ts deleted file mode 100644 index ff0f67b910..0000000000 --- a/src/voice-broadcast/stores/VoiceBroadcastRecordingsStore.ts +++ /dev/null @@ -1,89 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022, 2023 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only -Please see LICENSE files in the repository root for full details. -*/ - -import { MatrixClient, MatrixEvent, TypedEventEmitter } from "matrix-js-sdk/src/matrix"; - -import { - VoiceBroadcastInfoState, - VoiceBroadcastRecording, - VoiceBroadcastRecordingEvent, - VoiceBroadcastRecordingState, -} from ".."; - -export enum VoiceBroadcastRecordingsStoreEvent { - CurrentChanged = "current_changed", -} - -interface EventMap { - [VoiceBroadcastRecordingsStoreEvent.CurrentChanged]: (recording: VoiceBroadcastRecording | null) => void; -} - -/** - * This store provides access to the current and specific Voice Broadcast recordings. - */ -export class VoiceBroadcastRecordingsStore extends TypedEventEmitter { - private current: VoiceBroadcastRecording | null = null; - private recordings = new Map(); - - public constructor() { - super(); - } - - public setCurrent(current: VoiceBroadcastRecording): void { - if (this.current === current) return; - - const infoEventId = current.infoEvent.getId(); - - if (!infoEventId) { - throw new Error("Got broadcast info event without Id"); - } - - if (this.current) { - this.current.off(VoiceBroadcastRecordingEvent.StateChanged, this.onCurrentStateChanged); - } - - this.current = current; - this.current.on(VoiceBroadcastRecordingEvent.StateChanged, this.onCurrentStateChanged); - this.recordings.set(infoEventId, current); - this.emit(VoiceBroadcastRecordingsStoreEvent.CurrentChanged, current); - } - - public getCurrent(): VoiceBroadcastRecording | null { - return this.current; - } - - public hasCurrent(): boolean { - return this.current !== null; - } - - public clearCurrent(): void { - if (!this.current) return; - - this.current.off(VoiceBroadcastRecordingEvent.StateChanged, this.onCurrentStateChanged); - this.current = null; - this.emit(VoiceBroadcastRecordingsStoreEvent.CurrentChanged, null); - } - - public getByInfoEvent(infoEvent: MatrixEvent, client: MatrixClient): VoiceBroadcastRecording { - const infoEventId = infoEvent.getId(); - - if (!infoEventId) { - throw new Error("Got broadcast info event without Id"); - } - - const recording = this.recordings.get(infoEventId) || new VoiceBroadcastRecording(infoEvent, client); - this.recordings.set(infoEventId, recording); - return recording; - } - - private onCurrentStateChanged = (state: VoiceBroadcastRecordingState): void => { - if (state === VoiceBroadcastInfoState.Stopped) { - this.clearCurrent(); - } - }; -} diff --git a/src/voice-broadcast/types.ts b/src/voice-broadcast/types.ts deleted file mode 100644 index 8191a0be16..0000000000 --- a/src/voice-broadcast/types.ts +++ /dev/null @@ -1,32 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only -Please see LICENSE files in the repository root for full details. -*/ - -import { RelationType } from "matrix-js-sdk/src/matrix"; - -export const VoiceBroadcastInfoEventType = "io.element.voice_broadcast_info"; -export const VoiceBroadcastChunkEventType = "io.element.voice_broadcast_chunk"; - -export type VoiceBroadcastLiveness = "live" | "not-live" | "grey"; - -export enum VoiceBroadcastInfoState { - Started = "started", - Paused = "paused", - Resumed = "resumed", - Stopped = "stopped", -} - -export interface VoiceBroadcastInfoEventContent { - device_id: string; - state: VoiceBroadcastInfoState; - chunk_length?: number; - last_chunk_sequence?: number; - ["m.relates_to"]?: { - rel_type: RelationType; - event_id: string; - }; -} diff --git a/src/voice-broadcast/utils/VoiceBroadcastChunkEvents.ts b/src/voice-broadcast/utils/VoiceBroadcastChunkEvents.ts deleted file mode 100644 index 039749cf8d..0000000000 --- a/src/voice-broadcast/utils/VoiceBroadcastChunkEvents.ts +++ /dev/null @@ -1,147 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only -Please see LICENSE files in the repository root for full details. -*/ - -import { MatrixEvent } from "matrix-js-sdk/src/matrix"; - -import { VoiceBroadcastChunkEventType } from ".."; - -/** - * Voice broadcast chunk collection. - * Orders chunks by sequence (if available) or timestamp. - */ -export class VoiceBroadcastChunkEvents { - private events: MatrixEvent[] = []; - - public getEvents(): MatrixEvent[] { - return [...this.events]; - } - - public getNext(event: MatrixEvent): MatrixEvent | undefined { - return this.events[this.events.indexOf(event) + 1]; - } - - public addEvent(event: MatrixEvent): void { - if (this.addOrReplaceEvent(event)) { - this.sort(); - } - } - - public addEvents(events: MatrixEvent[]): void { - const atLeastOneNew = events.reduce((newSoFar: boolean, event: MatrixEvent): boolean => { - return this.addOrReplaceEvent(event) || newSoFar; - }, false); - - if (atLeastOneNew) { - this.sort(); - } - } - - public includes(event: MatrixEvent): boolean { - return !!this.events.find((e) => this.equalByTxnIdOrId(event, e)); - } - - /** - * @returns {number} Length in milliseconds - */ - public getLength(): number { - return this.events.reduce((length: number, event: MatrixEvent) => { - return length + this.calculateChunkLength(event); - }, 0); - } - - public getLengthSeconds(): number { - return this.getLength() / 1000; - } - - /** - * Returns the accumulated length to (excl.) a chunk event. - */ - public getLengthTo(event: MatrixEvent): number { - let length = 0; - - for (let i = 0; i < this.events.indexOf(event); i++) { - length += this.calculateChunkLength(this.events[i]); - } - - return length; - } - - public findByTime(time: number): MatrixEvent | null { - let lengthSoFar = 0; - - for (let i = 0; i < this.events.length; i++) { - lengthSoFar += this.calculateChunkLength(this.events[i]); - - if (lengthSoFar >= time) { - return this.events[i]; - } - } - - return null; - } - - public isLast(event: MatrixEvent): boolean { - return this.events.indexOf(event) >= this.events.length - 1; - } - - public getSequenceForEvent(event: MatrixEvent): number | null { - const sequence = parseInt(event.getContent()?.[VoiceBroadcastChunkEventType]?.sequence, 10); - if (!isNaN(sequence)) return sequence; - - if (this.events.includes(event)) return this.events.indexOf(event) + 1; - - return null; - } - - public getNumberOfEvents(): number { - return this.events.length; - } - - private calculateChunkLength(event: MatrixEvent): number { - return event.getContent()?.["org.matrix.msc1767.audio"]?.duration || event.getContent()?.info?.duration || 0; - } - - private addOrReplaceEvent = (event: MatrixEvent): boolean => { - this.events = this.events.filter((e) => !this.equalByTxnIdOrId(event, e)); - this.events.push(event); - return true; - }; - - private equalByTxnIdOrId(eventA: MatrixEvent, eventB: MatrixEvent): boolean { - return ( - (eventA.getTxnId() && eventB.getTxnId() && eventA.getTxnId() === eventB.getTxnId()) || - eventA.getId() === eventB.getId() - ); - } - - /** - * Sort by sequence, if available for all events. - * Else fall back to timestamp. - */ - private sort(): void { - const compareFn = this.allHaveSequence() ? this.compareBySequence : this.compareByTimestamp; - this.events.sort(compareFn); - } - - private compareBySequence = (a: MatrixEvent, b: MatrixEvent): number => { - const aSequence = a.getContent()?.[VoiceBroadcastChunkEventType]?.sequence || 0; - const bSequence = b.getContent()?.[VoiceBroadcastChunkEventType]?.sequence || 0; - return aSequence - bSequence; - }; - - private compareByTimestamp = (a: MatrixEvent, b: MatrixEvent): number => { - return a.getTs() - b.getTs(); - }; - - private allHaveSequence(): boolean { - return !this.events.some((event: MatrixEvent) => { - const sequence = event.getContent()?.[VoiceBroadcastChunkEventType]?.sequence; - return parseInt(sequence, 10) !== sequence; - }); - } -} diff --git a/src/voice-broadcast/utils/VoiceBroadcastResumer.ts b/src/voice-broadcast/utils/VoiceBroadcastResumer.ts deleted file mode 100644 index 963b6ef3a6..0000000000 --- a/src/voice-broadcast/utils/VoiceBroadcastResumer.ts +++ /dev/null @@ -1,90 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only -Please see LICENSE files in the repository root for full details. -*/ - -import { ClientEvent, MatrixClient, MatrixEvent, RelationType, Room, SyncState } from "matrix-js-sdk/src/matrix"; - -import { VoiceBroadcastInfoEventContent, VoiceBroadcastInfoEventType, VoiceBroadcastInfoState } from ".."; -import { IDestroyable } from "../../utils/IDestroyable"; -import { findRoomLiveVoiceBroadcastFromUserAndDevice } from "./findRoomLiveVoiceBroadcastFromUserAndDevice"; - -/** - * Handles voice broadcasts on app resume (after logging in, reload, crash…). - */ -export class VoiceBroadcastResumer implements IDestroyable { - public constructor(private client: MatrixClient) { - if (client.isInitialSyncComplete()) { - this.resume(); - } else { - // wait for initial sync - client.on(ClientEvent.Sync, this.onClientSync); - } - } - - private onClientSync = (): void => { - if (this.client.getSyncState() === SyncState.Syncing) { - this.client.off(ClientEvent.Sync, this.onClientSync); - this.resume(); - } - }; - - private resume(): void { - const userId = this.client.getUserId(); - const deviceId = this.client.getDeviceId(); - - if (!userId || !deviceId) { - // Resuming a voice broadcast only makes sense if there is a user. - return; - } - - this.client.getRooms().forEach((room: Room) => { - const infoEvent = findRoomLiveVoiceBroadcastFromUserAndDevice(room, userId, deviceId); - - if (infoEvent) { - // Found a live broadcast event from current device; stop it. - // Stopping it is a temporary solution (see PSF-1669). - this.sendStopVoiceBroadcastStateEvent(infoEvent); - return false; - } - }); - } - - private sendStopVoiceBroadcastStateEvent(infoEvent: MatrixEvent): void { - const userId = this.client.getUserId(); - const deviceId = this.client.getDeviceId(); - const roomId = infoEvent.getRoomId(); - - if (!userId || !deviceId || !roomId) { - // We can only send a state event if we know all the IDs. - return; - } - - const content: VoiceBroadcastInfoEventContent = { - device_id: deviceId, - state: VoiceBroadcastInfoState.Stopped, - }; - - // all events should reference the started event - const referencedEventId = - infoEvent.getContent()?.state === VoiceBroadcastInfoState.Started - ? infoEvent.getId() - : infoEvent.getContent()?.["m.relates_to"]?.event_id; - - if (referencedEventId) { - content["m.relates_to"] = { - rel_type: RelationType.Reference, - event_id: referencedEventId, - }; - } - - this.client.sendStateEvent(roomId, VoiceBroadcastInfoEventType, content, userId); - } - - public destroy(): void { - this.client.off(ClientEvent.Sync, this.onClientSync); - } -} diff --git a/src/voice-broadcast/utils/checkVoiceBroadcastPreConditions.tsx b/src/voice-broadcast/utils/checkVoiceBroadcastPreConditions.tsx deleted file mode 100644 index ae96bc0b14..0000000000 --- a/src/voice-broadcast/utils/checkVoiceBroadcastPreConditions.tsx +++ /dev/null @@ -1,86 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only -Please see LICENSE files in the repository root for full details. -*/ - -import React from "react"; -import { MatrixClient, Room, SyncState } from "matrix-js-sdk/src/matrix"; - -import { hasRoomLiveVoiceBroadcast, VoiceBroadcastInfoEventType, VoiceBroadcastRecordingsStore } from ".."; -import InfoDialog from "../../components/views/dialogs/InfoDialog"; -import { _t } from "../../languageHandler"; -import Modal from "../../Modal"; - -const showAlreadyRecordingDialog = (): void => { - Modal.createDialog(InfoDialog, { - title: _t("voice_broadcast|failed_already_recording_title"), - description:

{_t("voice_broadcast|failed_already_recording_description")}

, - hasCloseButton: true, - }); -}; - -const showInsufficientPermissionsDialog = (): void => { - Modal.createDialog(InfoDialog, { - title: _t("voice_broadcast|failed_insufficient_permission_title"), - description:

{_t("voice_broadcast|failed_insufficient_permission_description")}

, - hasCloseButton: true, - }); -}; - -const showOthersAlreadyRecordingDialog = (): void => { - Modal.createDialog(InfoDialog, { - title: _t("voice_broadcast|failed_others_already_recording_title"), - description:

{_t("voice_broadcast|failed_others_already_recording_description")}

, - hasCloseButton: true, - }); -}; - -const showNoConnectionDialog = (): void => { - Modal.createDialog(InfoDialog, { - title: _t("voice_broadcast|failed_no_connection_title"), - description:

{_t("voice_broadcast|failed_no_connection_description")}

, - hasCloseButton: true, - }); -}; - -export const checkVoiceBroadcastPreConditions = async ( - room: Room, - client: MatrixClient, - recordingsStore: VoiceBroadcastRecordingsStore, -): Promise => { - if (recordingsStore.getCurrent()) { - showAlreadyRecordingDialog(); - return false; - } - - const currentUserId = client.getUserId(); - - if (!currentUserId) return false; - - if (!room.currentState.maySendStateEvent(VoiceBroadcastInfoEventType, currentUserId)) { - showInsufficientPermissionsDialog(); - return false; - } - - if (client.getSyncState() === SyncState.Error) { - showNoConnectionDialog(); - return false; - } - - const { hasBroadcast, startedByUser } = await hasRoomLiveVoiceBroadcast(client, room, currentUserId); - - if (hasBroadcast && startedByUser) { - showAlreadyRecordingDialog(); - return false; - } - - if (hasBroadcast) { - showOthersAlreadyRecordingDialog(); - return false; - } - - return true; -}; diff --git a/src/voice-broadcast/utils/cleanUpBroadcasts.ts b/src/voice-broadcast/utils/cleanUpBroadcasts.ts deleted file mode 100644 index 50133274b0..0000000000 --- a/src/voice-broadcast/utils/cleanUpBroadcasts.ts +++ /dev/null @@ -1,20 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2023 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only -Please see LICENSE files in the repository root for full details. -*/ - -import { SdkContextClass } from "../../contexts/SDKContext"; - -export const cleanUpBroadcasts = async (stores: SdkContextClass): Promise => { - stores.voiceBroadcastPlaybacksStore.getCurrent()?.stop(); - stores.voiceBroadcastPlaybacksStore.clearCurrent(); - - await stores.voiceBroadcastRecordingsStore.getCurrent()?.stop(); - stores.voiceBroadcastRecordingsStore.clearCurrent(); - - stores.voiceBroadcastPreRecordingStore.getCurrent()?.cancel(); - stores.voiceBroadcastPreRecordingStore.clearCurrent(); -}; diff --git a/src/voice-broadcast/utils/determineVoiceBroadcastLiveness.ts b/src/voice-broadcast/utils/determineVoiceBroadcastLiveness.ts deleted file mode 100644 index 8d9660c572..0000000000 --- a/src/voice-broadcast/utils/determineVoiceBroadcastLiveness.ts +++ /dev/null @@ -1,20 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only -Please see LICENSE files in the repository root for full details. -*/ - -import { VoiceBroadcastInfoState, VoiceBroadcastLiveness } from ".."; - -const stateLivenessMap: Map = new Map([ - ["started", "live"], - ["resumed", "live"], - ["paused", "grey"], - ["stopped", "not-live"], -] as Array<[VoiceBroadcastInfoState, VoiceBroadcastLiveness]>); - -export const determineVoiceBroadcastLiveness = (infoState: VoiceBroadcastInfoState): VoiceBroadcastLiveness => { - return stateLivenessMap.get(infoState) ?? "not-live"; -}; diff --git a/src/voice-broadcast/utils/doClearCurrentVoiceBroadcastPlaybackIfStopped.ts b/src/voice-broadcast/utils/doClearCurrentVoiceBroadcastPlaybackIfStopped.ts deleted file mode 100644 index ef0e1e7aed..0000000000 --- a/src/voice-broadcast/utils/doClearCurrentVoiceBroadcastPlaybackIfStopped.ts +++ /dev/null @@ -1,18 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only -Please see LICENSE files in the repository root for full details. -*/ - -import { VoiceBroadcastPlaybacksStore, VoiceBroadcastPlaybackState } from ".."; - -export const doClearCurrentVoiceBroadcastPlaybackIfStopped = ( - voiceBroadcastPlaybacksStore: VoiceBroadcastPlaybacksStore, -): void => { - if (voiceBroadcastPlaybacksStore.getCurrent()?.getState() === VoiceBroadcastPlaybackState.Stopped) { - // clear current if stopped - return; - } -}; diff --git a/src/voice-broadcast/utils/doMaybeSetCurrentVoiceBroadcastPlayback.ts b/src/voice-broadcast/utils/doMaybeSetCurrentVoiceBroadcastPlayback.ts deleted file mode 100644 index 2ec4ab185d..0000000000 --- a/src/voice-broadcast/utils/doMaybeSetCurrentVoiceBroadcastPlayback.ts +++ /dev/null @@ -1,56 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only -Please see LICENSE files in the repository root for full details. -*/ - -import { MatrixClient, Room } from "matrix-js-sdk/src/matrix"; - -import { - hasRoomLiveVoiceBroadcast, - VoiceBroadcastPlaybacksStore, - VoiceBroadcastPlaybackState, - VoiceBroadcastRecordingsStore, -} from ".."; - -/** - * When a live voice broadcast is in the room and - * another voice broadcast is not currently being listened to or recorded - * the live broadcast in the room is set as the current broadcast to listen to. - * When there is no live broadcast in the room: clear current broadcast. - * - * @param {Room} room The room to check for a live voice broadcast - * @param {MatrixClient} client - * @param {VoiceBroadcastPlaybacksStore} voiceBroadcastPlaybacksStore - * @param {VoiceBroadcastRecordingsStore} voiceBroadcastRecordingsStore - */ -export const doMaybeSetCurrentVoiceBroadcastPlayback = async ( - room: Room, - client: MatrixClient, - voiceBroadcastPlaybacksStore: VoiceBroadcastPlaybacksStore, - voiceBroadcastRecordingsStore: VoiceBroadcastRecordingsStore, -): Promise => { - // do not disturb the current recording - if (voiceBroadcastRecordingsStore.hasCurrent()) return; - - const currentPlayback = voiceBroadcastPlaybacksStore.getCurrent(); - - if (currentPlayback && currentPlayback.getState() !== VoiceBroadcastPlaybackState.Stopped) { - // do not disturb the current playback - return; - } - - const { infoEvent } = await hasRoomLiveVoiceBroadcast(client, room); - - if (infoEvent) { - // live broadcast in the room + no recording + not listening yet: set the current broadcast - const voiceBroadcastPlayback = voiceBroadcastPlaybacksStore.getByInfoEvent(infoEvent, client); - voiceBroadcastPlaybacksStore.setCurrent(voiceBroadcastPlayback); - return; - } - - // no broadcast; not listening: clear current - voiceBroadcastPlaybacksStore.clearCurrent(); -}; diff --git a/src/voice-broadcast/utils/findRoomLiveVoiceBroadcastFromUserAndDevice.ts b/src/voice-broadcast/utils/findRoomLiveVoiceBroadcastFromUserAndDevice.ts deleted file mode 100644 index fbd02d44fb..0000000000 --- a/src/voice-broadcast/utils/findRoomLiveVoiceBroadcastFromUserAndDevice.ts +++ /dev/null @@ -1,29 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only -Please see LICENSE files in the repository root for full details. -*/ - -import { MatrixEvent, Room } from "matrix-js-sdk/src/matrix"; - -import { VoiceBroadcastInfoEventType, VoiceBroadcastInfoState } from ".."; - -export const findRoomLiveVoiceBroadcastFromUserAndDevice = ( - room: Room, - userId: string, - deviceId: string, -): MatrixEvent | null => { - const stateEvent = room.currentState.getStateEvents(VoiceBroadcastInfoEventType, userId); - - // no broadcast from that user - if (!stateEvent) return null; - - const content = stateEvent.getContent() || {}; - - // stopped broadcast - if (content.state === VoiceBroadcastInfoState.Stopped) return null; - - return content.device_id === deviceId ? stateEvent : null; -}; diff --git a/src/voice-broadcast/utils/getChunkLength.ts b/src/voice-broadcast/utils/getChunkLength.ts deleted file mode 100644 index b3fe2f557d..0000000000 --- a/src/voice-broadcast/utils/getChunkLength.ts +++ /dev/null @@ -1,23 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only -Please see LICENSE files in the repository root for full details. -*/ - -import SdkConfig, { DEFAULTS } from "../../SdkConfig"; -import { Features } from "../../settings/Settings"; -import SettingsStore from "../../settings/SettingsStore"; - -/** - * Returns the target chunk length for voice broadcasts: - * - If {@see Features.VoiceBroadcastForceSmallChunks} is enabled uses 15s chunk length - * - Otherwise to get the value from the voice_broadcast.chunk_length config - * - If that fails from DEFAULTS - * - If that fails fall back to 120 (two minutes) - */ -export const getChunkLength = (): number => { - if (SettingsStore.getValue(Features.VoiceBroadcastForceSmallChunks)) return 15; - return SdkConfig.get("voice_broadcast")?.chunk_length || DEFAULTS.voice_broadcast?.chunk_length || 120; -}; diff --git a/src/voice-broadcast/utils/getMaxBroadcastLength.ts b/src/voice-broadcast/utils/getMaxBroadcastLength.ts deleted file mode 100644 index e5df83ef05..0000000000 --- a/src/voice-broadcast/utils/getMaxBroadcastLength.ts +++ /dev/null @@ -1,19 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only -Please see LICENSE files in the repository root for full details. -*/ - -import SdkConfig, { DEFAULTS } from "../../SdkConfig"; - -/** - * Returns the max length for voice broadcasts: - * - Tries to get the value from the voice_broadcast.max_length config - * - If that fails from DEFAULTS - * - If that fails fall back to four hours - */ -export const getMaxBroadcastLength = (): number => { - return SdkConfig.get("voice_broadcast")?.max_length || DEFAULTS.voice_broadcast?.max_length || 4 * 60 * 60; -}; diff --git a/src/voice-broadcast/utils/hasRoomLiveVoiceBroadcast.ts b/src/voice-broadcast/utils/hasRoomLiveVoiceBroadcast.ts deleted file mode 100644 index 939eb1a0c2..0000000000 --- a/src/voice-broadcast/utils/hasRoomLiveVoiceBroadcast.ts +++ /dev/null @@ -1,57 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only -Please see LICENSE files in the repository root for full details. -*/ - -import { MatrixClient, MatrixEvent, Room } from "matrix-js-sdk/src/matrix"; - -import { retrieveStartedInfoEvent, VoiceBroadcastInfoEventType, VoiceBroadcastInfoState } from ".."; -import { asyncEvery } from "../../utils/arrays"; - -interface Result { - // whether there is a live broadcast in the room - hasBroadcast: boolean; - // info event of any live broadcast in the room - infoEvent: MatrixEvent | null; - // whether the broadcast was started by the user - startedByUser: boolean; -} - -export const hasRoomLiveVoiceBroadcast = async (client: MatrixClient, room: Room, userId?: string): Promise => { - let hasBroadcast = false; - let startedByUser = false; - let infoEvent: MatrixEvent | null = null; - - const stateEvents = room.currentState.getStateEvents(VoiceBroadcastInfoEventType); - await asyncEvery(stateEvents, async (event: MatrixEvent) => { - const state = event.getContent()?.state; - - if (state && state !== VoiceBroadcastInfoState.Stopped) { - const startEvent = await retrieveStartedInfoEvent(event, client); - - // skip if started voice broadcast event is redacted - if (startEvent?.isRedacted()) return true; - - hasBroadcast = true; - infoEvent = startEvent; - - // state key = sender's MXID - if (event.getStateKey() === userId) { - startedByUser = true; - // break here, because more than true / true is not possible - return false; - } - } - - return true; - }); - - return { - hasBroadcast, - infoEvent, - startedByUser, - }; -}; diff --git a/src/voice-broadcast/utils/isRelatedToVoiceBroadcast.ts b/src/voice-broadcast/utils/isRelatedToVoiceBroadcast.ts deleted file mode 100644 index eca8f890e0..0000000000 --- a/src/voice-broadcast/utils/isRelatedToVoiceBroadcast.ts +++ /dev/null @@ -1,21 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only -Please see LICENSE files in the repository root for full details. -*/ - -import { MatrixClient, MatrixEvent, RelationType } from "matrix-js-sdk/src/matrix"; - -import { VoiceBroadcastInfoEventType } from "../types"; - -export const isRelatedToVoiceBroadcast = (event: MatrixEvent, client: MatrixClient): boolean => { - const relation = event.getRelation(); - - return ( - relation?.rel_type === RelationType.Reference && - !!relation.event_id && - client.getRoom(event.getRoomId())?.findEventById(relation.event_id)?.getType() === VoiceBroadcastInfoEventType - ); -}; diff --git a/src/voice-broadcast/utils/isVoiceBroadcastStartedEvent.ts b/src/voice-broadcast/utils/isVoiceBroadcastStartedEvent.ts deleted file mode 100644 index fffe45850e..0000000000 --- a/src/voice-broadcast/utils/isVoiceBroadcastStartedEvent.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only -Please see LICENSE files in the repository root for full details. -*/ - -import { MatrixEvent } from "matrix-js-sdk/src/matrix"; - -import { VoiceBroadcastInfoEventType, VoiceBroadcastInfoState } from "../types"; - -export const isVoiceBroadcastStartedEvent = (event: MatrixEvent): boolean => { - return ( - event.getType() === VoiceBroadcastInfoEventType && event.getContent()?.state === VoiceBroadcastInfoState.Started - ); -}; diff --git a/src/voice-broadcast/utils/pauseNonLiveBroadcastFromOtherRoom.ts b/src/voice-broadcast/utils/pauseNonLiveBroadcastFromOtherRoom.ts deleted file mode 100644 index e854ba9bac..0000000000 --- a/src/voice-broadcast/utils/pauseNonLiveBroadcastFromOtherRoom.ts +++ /dev/null @@ -1,29 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only -Please see LICENSE files in the repository root for full details. -*/ - -import { Room } from "matrix-js-sdk/src/matrix"; - -import { VoiceBroadcastPlaybacksStore } from ".."; - -export const pauseNonLiveBroadcastFromOtherRoom = ( - room: Room, - voiceBroadcastPlaybacksStore: VoiceBroadcastPlaybacksStore, -): void => { - const playingBroadcast = voiceBroadcastPlaybacksStore.getCurrent(); - - if ( - !playingBroadcast || - playingBroadcast?.getLiveness() === "live" || - playingBroadcast?.infoEvent.getRoomId() === room.roomId - ) { - return; - } - - voiceBroadcastPlaybacksStore.clearCurrent(); - playingBroadcast.pause(); -}; diff --git a/src/voice-broadcast/utils/retrieveStartedInfoEvent.ts b/src/voice-broadcast/utils/retrieveStartedInfoEvent.ts deleted file mode 100644 index cc5be144c9..0000000000 --- a/src/voice-broadcast/utils/retrieveStartedInfoEvent.ts +++ /dev/null @@ -1,37 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only -Please see LICENSE files in the repository root for full details. -*/ - -import { MatrixClient, MatrixEvent } from "matrix-js-sdk/src/matrix"; - -import { VoiceBroadcastInfoState } from ".."; - -export const retrieveStartedInfoEvent = async ( - event: MatrixEvent, - client: MatrixClient, -): Promise => { - // started event passed as argument - if (event.getContent()?.state === VoiceBroadcastInfoState.Started) return event; - - const relatedEventId = event.getRelation()?.event_id; - - // no related event - if (!relatedEventId) return null; - - const roomId = event.getRoomId() || ""; - const relatedEventFromRoom = client.getRoom(roomId)?.findEventById(relatedEventId); - - // event found - if (relatedEventFromRoom) return relatedEventFromRoom; - - try { - const relatedEventData = await client.fetchRoomEvent(roomId, relatedEventId); - return new MatrixEvent(relatedEventData); - } catch {} - - return null; -}; diff --git a/src/voice-broadcast/utils/setUpVoiceBroadcastPreRecording.ts b/src/voice-broadcast/utils/setUpVoiceBroadcastPreRecording.ts deleted file mode 100644 index c50607c58f..0000000000 --- a/src/voice-broadcast/utils/setUpVoiceBroadcastPreRecording.ts +++ /dev/null @@ -1,43 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only -Please see LICENSE files in the repository root for full details. -*/ - -import { MatrixClient, Room } from "matrix-js-sdk/src/matrix"; - -import { - checkVoiceBroadcastPreConditions, - VoiceBroadcastPlaybacksStore, - VoiceBroadcastPreRecording, - VoiceBroadcastPreRecordingStore, - VoiceBroadcastRecordingsStore, -} from ".."; - -export const setUpVoiceBroadcastPreRecording = async ( - room: Room, - client: MatrixClient, - playbacksStore: VoiceBroadcastPlaybacksStore, - recordingsStore: VoiceBroadcastRecordingsStore, - preRecordingStore: VoiceBroadcastPreRecordingStore, -): Promise => { - if (!(await checkVoiceBroadcastPreConditions(room, client, recordingsStore))) { - return null; - } - - const userId = client.getUserId(); - if (!userId) return null; - - const sender = room.getMember(userId); - if (!sender) return null; - - // pause and clear current playback (if any) - playbacksStore.getCurrent()?.pause(); - playbacksStore.clearCurrent(); - - const preRecording = new VoiceBroadcastPreRecording(room, sender, client, playbacksStore, recordingsStore); - preRecordingStore.setCurrent(preRecording); - return preRecording; -}; diff --git a/src/voice-broadcast/utils/shouldDisplayAsVoiceBroadcastRecordingTile.ts b/src/voice-broadcast/utils/shouldDisplayAsVoiceBroadcastRecordingTile.ts deleted file mode 100644 index d729d9e1ca..0000000000 --- a/src/voice-broadcast/utils/shouldDisplayAsVoiceBroadcastRecordingTile.ts +++ /dev/null @@ -1,25 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only -Please see LICENSE files in the repository root for full details. -*/ - -import { MatrixClient, MatrixEvent } from "matrix-js-sdk/src/matrix"; - -import { VoiceBroadcastInfoEventContent, VoiceBroadcastInfoState } from ".."; - -export const shouldDisplayAsVoiceBroadcastRecordingTile = ( - state: VoiceBroadcastInfoState, - client: MatrixClient, - event: MatrixEvent, -): boolean => { - const userId = client.getUserId(); - return ( - !!userId && - userId === event.getSender() && - client.getDeviceId() === event.getContent()?.device_id && - state !== VoiceBroadcastInfoState.Stopped - ); -}; diff --git a/src/voice-broadcast/utils/shouldDisplayAsVoiceBroadcastStoppedText.ts b/src/voice-broadcast/utils/shouldDisplayAsVoiceBroadcastStoppedText.ts deleted file mode 100644 index 2179aff3b7..0000000000 --- a/src/voice-broadcast/utils/shouldDisplayAsVoiceBroadcastStoppedText.ts +++ /dev/null @@ -1,16 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only -Please see LICENSE files in the repository root for full details. -*/ - -import { MatrixEvent } from "matrix-js-sdk/src/matrix"; - -import { VoiceBroadcastInfoEventType, VoiceBroadcastInfoState } from ".."; - -export const shouldDisplayAsVoiceBroadcastStoppedText = (event: MatrixEvent): boolean => - event.getType() === VoiceBroadcastInfoEventType && - event.getContent()?.state === VoiceBroadcastInfoState.Stopped && - !event.isRedacted(); diff --git a/src/voice-broadcast/utils/shouldDisplayAsVoiceBroadcastTile.ts b/src/voice-broadcast/utils/shouldDisplayAsVoiceBroadcastTile.ts deleted file mode 100644 index 9a51b33c9a..0000000000 --- a/src/voice-broadcast/utils/shouldDisplayAsVoiceBroadcastTile.ts +++ /dev/null @@ -1,15 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only -Please see LICENSE files in the repository root for full details. -*/ - -import { MatrixEvent } from "matrix-js-sdk/src/matrix"; - -import { VoiceBroadcastInfoEventType, VoiceBroadcastInfoState } from ".."; - -export const shouldDisplayAsVoiceBroadcastTile = (event: MatrixEvent): boolean => - event.getType?.() === VoiceBroadcastInfoEventType && - (event.getContent?.()?.state === VoiceBroadcastInfoState.Started || event.isRedacted()); diff --git a/src/voice-broadcast/utils/showCantStartACallDialog.tsx b/src/voice-broadcast/utils/showCantStartACallDialog.tsx deleted file mode 100644 index eeeb86ee07..0000000000 --- a/src/voice-broadcast/utils/showCantStartACallDialog.tsx +++ /dev/null @@ -1,21 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only -Please see LICENSE files in the repository root for full details. -*/ - -import React from "react"; - -import InfoDialog from "../../components/views/dialogs/InfoDialog"; -import { _t } from "../../languageHandler"; -import Modal from "../../Modal"; - -export const showCantStartACallDialog = (): void => { - Modal.createDialog(InfoDialog, { - title: _t("voip|failed_call_live_broadcast_title"), - description:

{_t("voip|failed_call_live_broadcast_description")}

, - hasCloseButton: true, - }); -}; diff --git a/src/voice-broadcast/utils/startNewVoiceBroadcastRecording.ts b/src/voice-broadcast/utils/startNewVoiceBroadcastRecording.ts deleted file mode 100644 index f0c5a91932..0000000000 --- a/src/voice-broadcast/utils/startNewVoiceBroadcastRecording.ts +++ /dev/null @@ -1,92 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only -Please see LICENSE files in the repository root for full details. -*/ - -import { ISendEventResponse, MatrixClient, Room, RoomStateEvent } from "matrix-js-sdk/src/matrix"; -import { defer } from "matrix-js-sdk/src/utils"; - -import { - VoiceBroadcastInfoEventContent, - VoiceBroadcastInfoEventType, - VoiceBroadcastInfoState, - VoiceBroadcastRecordingsStore, - VoiceBroadcastRecording, - getChunkLength, - VoiceBroadcastPlaybacksStore, -} from ".."; -import { checkVoiceBroadcastPreConditions } from "./checkVoiceBroadcastPreConditions"; - -const startBroadcast = async ( - room: Room, - client: MatrixClient, - recordingsStore: VoiceBroadcastRecordingsStore, -): Promise => { - const { promise, resolve, reject } = defer(); - - const userId = client.getUserId(); - - if (!userId) { - reject("unable to start voice broadcast if current user is unknown"); - return promise; - } - - let result: ISendEventResponse | null = null; - - const onRoomStateEvents = (): void => { - if (!result) return; - - const voiceBroadcastEvent = room.currentState.getStateEvents(VoiceBroadcastInfoEventType, userId); - - if (voiceBroadcastEvent?.getId() === result.event_id) { - room.off(RoomStateEvent.Events, onRoomStateEvents); - const recording = new VoiceBroadcastRecording(voiceBroadcastEvent, client); - recordingsStore.setCurrent(recording); - recording.start(); - resolve(recording); - } - }; - - room.on(RoomStateEvent.Events, onRoomStateEvents); - - // XXX Michael W: refactor to live event - result = await client.sendStateEvent( - room.roomId, - VoiceBroadcastInfoEventType, - { - device_id: client.getDeviceId(), - state: VoiceBroadcastInfoState.Started, - chunk_length: getChunkLength(), - } as VoiceBroadcastInfoEventContent, - userId, - ); - - return promise; -}; - -/** - * Starts a new Voice Broadcast Recording, if - * - the user has the permissions to do so in the room - * - the user is not already recording a voice broadcast - * - 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, - playbacksStore: VoiceBroadcastPlaybacksStore, - recordingsStore: VoiceBroadcastRecordingsStore, -): Promise => { - if (!(await checkVoiceBroadcastPreConditions(room, client, recordingsStore))) { - return null; - } - - // pause and clear current playback (if any) - playbacksStore.getCurrent()?.pause(); - playbacksStore.clearCurrent(); - - return startBroadcast(room, client, recordingsStore); -}; diff --git a/src/voice-broadcast/utils/textForVoiceBroadcastStoppedEvent.tsx b/src/voice-broadcast/utils/textForVoiceBroadcastStoppedEvent.tsx deleted file mode 100644 index bc2aa412a5..0000000000 --- a/src/voice-broadcast/utils/textForVoiceBroadcastStoppedEvent.tsx +++ /dev/null @@ -1,41 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only -Please see LICENSE files in the repository root for full details. -*/ - -import React, { ReactNode } from "react"; -import { MatrixClient, MatrixEvent } from "matrix-js-sdk/src/matrix"; - -import { MatrixClientPeg } from "../../MatrixClientPeg"; -import AccessibleButton from "../../components/views/elements/AccessibleButton"; -import { highlightEvent } from "../../utils/EventUtils"; -import { _t } from "../../languageHandler"; -import { getSenderName } from "../../utils/event/getSenderName"; - -export const textForVoiceBroadcastStoppedEvent = (event: MatrixEvent, client: MatrixClient): (() => ReactNode) => { - return (): ReactNode => { - const ownUserId = MatrixClientPeg.get()?.getUserId(); - const startEventId = event.getRelation()?.event_id; - const roomId = event.getRoomId(); - - const templateTags = { - a: (text: string) => - startEventId && roomId ? ( - highlightEvent(roomId, startEventId)}> - {text} - - ) : ( - text - ), - }; - - if (ownUserId && ownUserId === event.getSender()) { - return _t("timeline|io.element.voice_broadcast_info|you", {}, templateTags); - } - - return _t("timeline|io.element.voice_broadcast_info|user", { senderName: getSenderName(event) }, templateTags); - }; -}; diff --git a/src/voice-broadcast/utils/textForVoiceBroadcastStoppedEventWithoutLink.ts b/src/voice-broadcast/utils/textForVoiceBroadcastStoppedEventWithoutLink.ts deleted file mode 100644 index 13d7f47c48..0000000000 --- a/src/voice-broadcast/utils/textForVoiceBroadcastStoppedEventWithoutLink.ts +++ /dev/null @@ -1,23 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only -Please see LICENSE files in the repository root for full details. -*/ - -import { MatrixEvent } from "matrix-js-sdk/src/matrix"; - -import { _t } from "../../languageHandler"; -import { MatrixClientPeg } from "../../MatrixClientPeg"; -import { getSenderName } from "../../utils/event/getSenderName"; - -export const textForVoiceBroadcastStoppedEventWithoutLink = (event: MatrixEvent): string => { - const ownUserId = MatrixClientPeg.get()?.getUserId(); - - if (ownUserId && ownUserId === event.getSender()) { - return _t("event_preview|io.element.voice_broadcast_info|you", {}); - } - - return _t("event_preview|io.element.voice_broadcast_info|user", { senderName: getSenderName(event) }); -}; diff --git a/test/test-utils/call.ts b/test/test-utils/call.ts index 0262e5537f..df87fcaa55 100644 --- a/test/test-utils/call.ts +++ b/test/test-utils/call.ts @@ -44,17 +44,20 @@ export class MockedCall extends Call { } public static create(room: Room, id: string) { - room.addLiveEvents([ - mkEvent({ - event: true, - type: this.EVENT_TYPE, - room: room.roomId, - user: "@alice:example.org", - content: { "m.type": "m.video", "m.intent": "m.prompt" }, - skey: id, - ts: Date.now(), - }), - ]); + room.addLiveEvents( + [ + mkEvent({ + event: true, + type: this.EVENT_TYPE, + room: room.roomId, + user: "@alice:example.org", + content: { "m.type": "m.video", "m.intent": "m.prompt" }, + skey: id, + ts: Date.now(), + }), + ], + { addToState: true }, + ); // @ts-ignore deliberately calling a private method // Let CallStore know that a call might now exist CallStore.instance.updateRoom(room); @@ -81,17 +84,20 @@ export class MockedCall extends Call { public destroy() { // Terminate the call for good measure - this.room.addLiveEvents([ - mkEvent({ - event: true, - type: MockedCall.EVENT_TYPE, - room: this.room.roomId, - user: "@alice:example.org", - content: { ...this.event.getContent(), "m.terminated": "Call ended" }, - skey: this.widget.id, - ts: Date.now(), - }), - ]); + this.room.addLiveEvents( + [ + mkEvent({ + event: true, + type: MockedCall.EVENT_TYPE, + room: this.room.roomId, + user: "@alice:example.org", + content: { ...this.event.getContent(), "m.terminated": "Call ended" }, + skey: this.widget.id, + ts: Date.now(), + }), + ], + { addToState: true }, + ); super.destroy(); } diff --git a/test/test-utils/room.ts b/test/test-utils/room.ts index d618932726..bfd1f9004c 100644 --- a/test/test-utils/room.ts +++ b/test/test-utils/room.ts @@ -85,7 +85,7 @@ export function getRoomContext(room: Room, override: Partial): IRoom canAskToJoin: false, promptAskToJoin: false, viewRoomOpts: { buttons: [] }, - + isRoomEncrypted: false, ...override, }; } diff --git a/test/test-utils/test-utils.ts b/test/test-utils/test-utils.ts index 9af2ffa340..f9aee512a3 100644 --- a/test/test-utils/test-utils.ts +++ b/test/test-utils/test-utils.ts @@ -116,7 +116,7 @@ export function createTestClient(): MatrixClient { getCrypto: jest.fn().mockReturnValue({ getOwnDeviceKeys: jest.fn(), - getUserDeviceInfo: jest.fn(), + getUserDeviceInfo: jest.fn().mockResolvedValue(new Map()), getUserVerificationStatus: jest.fn(), getDeviceVerificationStatus: jest.fn(), resetKeyBackup: jest.fn(), @@ -135,6 +135,7 @@ export function createTestClient(): MatrixClient { loadSessionBackupPrivateKeyFromSecretStorage: jest.fn(), storeSessionBackupPrivateKey: jest.fn(), getKeyBackupInfo: jest.fn().mockResolvedValue(null), + getEncryptionInfoForEvent: jest.fn().mockResolvedValue(null), }), getPushActionsForEvent: jest.fn(), diff --git a/test/test-utils/threads.ts b/test/test-utils/threads.ts index d2459653e5..83313b1b8d 100644 --- a/test/test-utils/threads.ts +++ b/test/test-utils/threads.ts @@ -157,6 +157,6 @@ export const populateThread = async ({ // that it is already loaded, and send the events again to the room // so they are added to the thread timeline. ret.thread.initialEventsFetched = true; - await room.addLiveEvents(ret.events); + await room.addLiveEvents(ret.events, { addToState: false }); return ret; }; diff --git a/test/unit-tests/LegacyCallHandler-test.ts b/test/unit-tests/LegacyCallHandler-test.ts index c3e64dcf94..476d89a1f0 100644 --- a/test/unit-tests/LegacyCallHandler-test.ts +++ b/test/unit-tests/LegacyCallHandler-test.ts @@ -39,10 +39,6 @@ import { Action } from "../../src/dispatcher/actions"; import { getFunctionalMembers } from "../../src/utils/room/getFunctionalMembers"; import SettingsStore from "../../src/settings/SettingsStore"; import { UIFeature } from "../../src/settings/UIFeature"; -import { VoiceBroadcastInfoState, VoiceBroadcastPlayback, VoiceBroadcastRecording } from "../../src/voice-broadcast"; -import { mkVoiceBroadcastInfoStateEvent } from "./voice-broadcast/utils/test-utils"; -import { SdkContextClass } from "../../src/contexts/SDKContext"; -import Modal from "../../src/Modal"; import { createAudioContext } from "../../src/audio/compat"; import * as ManagedHybrid from "../../src/widgets/ManagedHybrid"; @@ -403,53 +399,6 @@ describe("LegacyCallHandler", () => { await callHandler.placeCall(NATIVE_ROOM_ALICE, CallType.Voice); expect(spy).toHaveBeenCalledWith(MatrixClientPeg.safeGet().getRoom(NATIVE_ROOM_ALICE)); }); - - describe("when listening to a voice broadcast", () => { - let voiceBroadcastPlayback: VoiceBroadcastPlayback; - - beforeEach(() => { - voiceBroadcastPlayback = new VoiceBroadcastPlayback( - mkVoiceBroadcastInfoStateEvent( - "!room:example.com", - VoiceBroadcastInfoState.Started, - MatrixClientPeg.safeGet().getSafeUserId(), - "d42", - ), - MatrixClientPeg.safeGet(), - SdkContextClass.instance.voiceBroadcastRecordingsStore, - ); - SdkContextClass.instance.voiceBroadcastPlaybacksStore.setCurrent(voiceBroadcastPlayback); - jest.spyOn(voiceBroadcastPlayback, "pause").mockImplementation(); - }); - - it("and placing a call should pause the broadcast", async () => { - callHandler.placeCall(NATIVE_ROOM_ALICE, CallType.Voice); - await untilCallHandlerEvent(callHandler, LegacyCallHandlerEvent.CallState); - - expect(voiceBroadcastPlayback.pause).toHaveBeenCalled(); - }); - }); - - describe("when recording a voice broadcast", () => { - beforeEach(() => { - SdkContextClass.instance.voiceBroadcastRecordingsStore.setCurrent( - new VoiceBroadcastRecording( - mkVoiceBroadcastInfoStateEvent( - "!room:example.com", - VoiceBroadcastInfoState.Started, - MatrixClientPeg.safeGet().getSafeUserId(), - "d42", - ), - MatrixClientPeg.safeGet(), - ), - ); - }); - - it("and placing a call should show the info dialog", async () => { - callHandler.placeCall(NATIVE_ROOM_ALICE, CallType.Voice); - expect(Modal.createDialog).toMatchSnapshot(); - }); - }); }); describe("LegacyCallHandler without third party protocols", () => { @@ -528,9 +477,6 @@ describe("LegacyCallHandler without third party protocols", () => { audioElement.id = "remoteAudio"; document.body.appendChild(audioElement); - SdkContextClass.instance.voiceBroadcastPlaybacksStore.clearCurrent(); - SdkContextClass.instance.voiceBroadcastRecordingsStore.clearCurrent(); - fetchMock.get( "/media/ring.mp3", { body: new Blob(["1", "2", "3", "4"], { type: "audio/mpeg" }) }, diff --git a/test/unit-tests/MatrixClientPeg-test.ts b/test/unit-tests/MatrixClientPeg-test.ts index 5a19b568c0..4653340574 100644 --- a/test/unit-tests/MatrixClientPeg-test.ts +++ b/test/unit-tests/MatrixClientPeg-test.ts @@ -11,8 +11,6 @@ import fetchMockJest from "fetch-mock-jest"; import { advanceDateAndTime, stubClient } from "../test-utils"; import { IMatrixClientPeg, MatrixClientPeg as peg } from "../../src/MatrixClientPeg"; -import SettingsStore from "../../src/settings/SettingsStore"; -import { SettingLevel } from "../../src/settings/SettingLevel"; jest.useFakeTimers(); @@ -81,27 +79,18 @@ describe("MatrixClientPeg", () => { }); it("should initialise the rust crypto library by default", async () => { - const mockSetValue = jest.spyOn(SettingsStore, "setValue").mockResolvedValue(undefined); - const mockInitRustCrypto = jest.spyOn(testPeg.safeGet(), "initRustCrypto").mockResolvedValue(undefined); const cryptoStoreKey = new Uint8Array([1, 2, 3, 4]); await testPeg.start({ rustCryptoStoreKey: cryptoStoreKey }); expect(mockInitRustCrypto).toHaveBeenCalledWith({ storageKey: cryptoStoreKey }); - - // we should have stashed the setting in the settings store - expect(mockSetValue).toHaveBeenCalledWith("feature_rust_crypto", null, SettingLevel.DEVICE, true); }); it("Should migrate existing login", async () => { - const mockSetValue = jest.spyOn(SettingsStore, "setValue").mockResolvedValue(undefined); const mockInitRustCrypto = jest.spyOn(testPeg.safeGet(), "initRustCrypto").mockResolvedValue(undefined); await testPeg.start(); expect(mockInitRustCrypto).toHaveBeenCalledTimes(1); - - // we should have stashed the setting in the settings store - expect(mockSetValue).toHaveBeenCalledWith("feature_rust_crypto", null, SettingLevel.DEVICE, true); }); }); }); diff --git a/test/unit-tests/Notifier-test.ts b/test/unit-tests/Notifier-test.ts index 2fe7fdec0b..f94f50724d 100644 --- a/test/unit-tests/Notifier-test.ts +++ b/test/unit-tests/Notifier-test.ts @@ -43,8 +43,6 @@ import { mkThread } from "../test-utils/threads"; import dis from "../../src/dispatcher/dispatcher"; import { ThreadPayload } from "../../src/dispatcher/payloads/ThreadPayload"; import { Action } from "../../src/dispatcher/actions"; -import { VoiceBroadcastChunkEventType, VoiceBroadcastInfoState } from "../../src/voice-broadcast"; -import { mkVoiceBroadcastInfoStateEvent } from "./voice-broadcast/utils/test-utils"; import { addReplyToMessageContent } from "../../src/utils/Reply"; jest.mock("../../src/utils/notifications", () => ({ @@ -85,16 +83,13 @@ describe("Notifier", () => { }); }; - const mkAudioEvent = (broadcastChunkContent?: object): MatrixEvent => { - const chunkContent = broadcastChunkContent ? { [VoiceBroadcastChunkEventType]: broadcastChunkContent } : {}; - + const mkAudioEvent = (): MatrixEvent => { return mkEvent({ event: true, type: EventType.RoomMessage, user: "@user:example.com", room: "!room:example.com", content: { - ...chunkContent, msgtype: MsgType.Audio, body: "test audio message", }, @@ -320,24 +315,6 @@ describe("Notifier", () => { ); }); - it("should display the expected notification for a broadcast chunk with sequence = 1", () => { - const audioEvent = mkAudioEvent({ sequence: 1 }); - Notifier.displayPopupNotification(audioEvent, testRoom); - expect(MockPlatform.displayNotification).toHaveBeenCalledWith( - "@user:example.com (!room1:server)", - "@user:example.com started a voice broadcast", - "data:image/png;base64,00", - testRoom, - audioEvent, - ); - }); - - it("should display the expected notification for a broadcast chunk with sequence = 2", () => { - const audioEvent = mkAudioEvent({ sequence: 2 }); - Notifier.displayPopupNotification(audioEvent, testRoom); - expect(MockPlatform.displayNotification).not.toHaveBeenCalled(); - }); - it("should strip reply fallback", () => { const event = mkMessage({ msg: "Test", @@ -581,24 +558,6 @@ describe("Notifier", () => { Notifier.evaluateEvent(mkAudioEvent()); expect(Notifier.displayPopupNotification).toHaveBeenCalledTimes(1); }); - - it("should not show a notification for broadcast info events in any case", () => { - // Let client decide to show a notification - mockClient.getPushActionsForEvent.mockReturnValue({ - notify: true, - tweaks: {}, - }); - - const broadcastStartedEvent = mkVoiceBroadcastInfoStateEvent( - "!other:example.org", - VoiceBroadcastInfoState.Started, - "@user:example.com", - "ABC123", - ); - - Notifier.evaluateEvent(broadcastStartedEvent); - expect(Notifier.displayPopupNotification).not.toHaveBeenCalled(); - }); }); describe("setPromptHidden", () => { @@ -624,8 +583,7 @@ describe("Notifier", () => { content: { body: "this is a thread root" }, }), testRoom.threadsTimelineSets[0]!.getLiveTimeline(), - false, - false, + { toStartOfTimeline: false, fromCache: false, addToState: true }, ); expect(fn).not.toHaveBeenCalled(); diff --git a/test/unit-tests/RoomNotifs-test.ts b/test/unit-tests/RoomNotifs-test.ts index 51416ab7fd..65089eba94 100644 --- a/test/unit-tests/RoomNotifs-test.ts +++ b/test/unit-tests/RoomNotifs-test.ts @@ -147,7 +147,7 @@ describe("RoomNotifs test", () => { const itShouldCountPredecessorHighlightWhenThereIsAPredecessorInTheCreateEvent = (): void => { it("and there is a predecessor in the create event, it should count predecessor highlight", () => { - room.addLiveEvents([mkCreateEvent(OLD_ROOM_ID)]); + room.addLiveEvents([mkCreateEvent(OLD_ROOM_ID)], { addToState: true }); expect(getUnreadNotificationCount(room, NotificationCountType.Total, false)).toBe(8); expect(getUnreadNotificationCount(room, NotificationCountType.Highlight, false)).toBe(7); @@ -157,7 +157,7 @@ describe("RoomNotifs test", () => { const itShouldCountPredecessorHighlightWhenThereIsAPredecessorEvent = (): void => { it("and there is a predecessor event, it should count predecessor highlight", () => { client.getVisibleRooms(); - room.addLiveEvents([mkCreateEvent(OLD_ROOM_ID)]); + room.addLiveEvents([mkCreateEvent(OLD_ROOM_ID)], { addToState: true }); upsertRoomStateEvents(room, [mkPredecessorEvent(OLD_ROOM_ID)]); expect(getUnreadNotificationCount(room, NotificationCountType.Total, false)).toBe(8); @@ -185,7 +185,7 @@ describe("RoomNotifs test", () => { itShouldCountPredecessorHighlightWhenThereIsAPredecessorEvent(); it("and there is only a predecessor event, it should not count predecessor highlight", () => { - room.addLiveEvents([mkCreateEvent()]); + room.addLiveEvents([mkCreateEvent()], { addToState: true }); upsertRoomStateEvents(room, [mkPredecessorEvent(OLD_ROOM_ID)]); expect(getUnreadNotificationCount(room, NotificationCountType.Total, false)).toBe(2); @@ -204,7 +204,7 @@ describe("RoomNotifs test", () => { itShouldCountPredecessorHighlightWhenThereIsAPredecessorEvent(); it("and there is only a predecessor event, it should count predecessor highlight", () => { - room.addLiveEvents([mkCreateEvent()]); + room.addLiveEvents([mkCreateEvent()], { addToState: true }); upsertRoomStateEvents(room, [mkPredecessorEvent(OLD_ROOM_ID)]); expect(getUnreadNotificationCount(room, NotificationCountType.Total, false)).toBe(8); @@ -212,7 +212,7 @@ describe("RoomNotifs test", () => { }); it("and there is an unknown room in the predecessor event, it should not count predecessor highlight", () => { - room.addLiveEvents([mkCreateEvent()]); + room.addLiveEvents([mkCreateEvent()], { addToState: true }); upsertRoomStateEvents(room, [mkPredecessorEvent("!unknon:example.com")]); expect(getUnreadNotificationCount(room, NotificationCountType.Total, false)).toBe(2); diff --git a/test/unit-tests/SdkConfig-test.ts b/test/unit-tests/SdkConfig-test.ts index 4204a698fa..19d0eec9c3 100644 --- a/test/unit-tests/SdkConfig-test.ts +++ b/test/unit-tests/SdkConfig-test.ts @@ -18,10 +18,6 @@ describe("SdkConfig", () => { describe("with custom values", () => { beforeEach(() => { SdkConfig.put({ - voice_broadcast: { - chunk_length: 42, - max_length: 1337, - }, feedback: { existing_issues_url: "https://existing", } as any, @@ -30,8 +26,6 @@ describe("SdkConfig", () => { it("should return the custom config", () => { const customConfig = JSON.parse(JSON.stringify(DEFAULTS)); - customConfig.voice_broadcast.chunk_length = 42; - customConfig.voice_broadcast.max_length = 1337; customConfig.feedback.existing_issues_url = "https://existing"; expect(SdkConfig.get()).toEqual(customConfig); }); diff --git a/test/unit-tests/SupportedBrowser-test.ts b/test/unit-tests/SupportedBrowser-test.ts index ccf75e0dab..a116ab1f9f 100644 --- a/test/unit-tests/SupportedBrowser-test.ts +++ b/test/unit-tests/SupportedBrowser-test.ts @@ -66,10 +66,10 @@ describe("SupportedBrowser", () => { "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15", // Firefox 131 on macOS Sonoma "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:131.0) Gecko/20100101 Firefox/131.0", - // Edge 129 on Windows - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36 Edg/129.0.2792.79", - // Edge 129 on macOS - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36 Edg/129.0.2792.79", + // Edge 131 on Windows + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.2903.70", + // Edge 131 on macOS + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.2903.70", // Firefox 131 on Windows "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:131.0) Gecko/20100101 Firefox/131.0", // Firefox 131 on Linux diff --git a/test/unit-tests/TestSdkContext.ts b/test/unit-tests/TestSdkContext.ts index d4bda4f889..f60b083bef 100644 --- a/test/unit-tests/TestSdkContext.ts +++ b/test/unit-tests/TestSdkContext.ts @@ -16,29 +16,21 @@ import { SpaceStoreClass } from "../../src/stores/spaces/SpaceStore"; import { WidgetLayoutStore } from "../../src/stores/widgets/WidgetLayoutStore"; import { WidgetPermissionStore } from "../../src/stores/widgets/WidgetPermissionStore"; import WidgetStore from "../../src/stores/WidgetStore"; -import { - VoiceBroadcastPlaybacksStore, - VoiceBroadcastPreRecordingStore, - VoiceBroadcastRecordingsStore, -} from "../../src/voice-broadcast"; /** * A class which provides the same API as SdkContextClass but adds additional unsafe setters which can * replace individual stores. This is useful for tests which need to mock out stores. */ export class TestSdkContext extends SdkContextClass { - public declare _RightPanelStore?: RightPanelStore; - public declare _RoomNotificationStateStore?: RoomNotificationStateStore; - public declare _RoomViewStore?: RoomViewStore; - public declare _WidgetPermissionStore?: WidgetPermissionStore; - public declare _WidgetLayoutStore?: WidgetLayoutStore; - public declare _WidgetStore?: WidgetStore; - public declare _PosthogAnalytics?: PosthogAnalytics; - public declare _SlidingSyncManager?: SlidingSyncManager; - public declare _SpaceStore?: SpaceStoreClass; - public declare _VoiceBroadcastRecordingsStore?: VoiceBroadcastRecordingsStore; - public declare _VoiceBroadcastPreRecordingStore?: VoiceBroadcastPreRecordingStore; - public declare _VoiceBroadcastPlaybacksStore?: VoiceBroadcastPlaybacksStore; + declare public _RightPanelStore?: RightPanelStore; + declare public _RoomNotificationStateStore?: RoomNotificationStateStore; + declare public _RoomViewStore?: RoomViewStore; + declare public _WidgetPermissionStore?: WidgetPermissionStore; + declare public _WidgetLayoutStore?: WidgetLayoutStore; + declare public _WidgetStore?: WidgetStore; + declare public _PosthogAnalytics?: PosthogAnalytics; + declare public _SlidingSyncManager?: SlidingSyncManager; + declare public _SpaceStore?: SpaceStoreClass; constructor() { super(); diff --git a/test/unit-tests/Unread-test.ts b/test/unit-tests/Unread-test.ts index 8719da06ef..15d3dab8f5 100644 --- a/test/unit-tests/Unread-test.ts +++ b/test/unit-tests/Unread-test.ts @@ -138,7 +138,7 @@ describe("Unread", () => { room: roomId, content: {}, }); - room.addLiveEvents([event]); + room.addLiveEvents([event], { addToState: true }); // Don't care about the code path of hidden events. mocked(haveRendererForEvent).mockClear().mockReturnValue(true); @@ -157,7 +157,7 @@ describe("Unread", () => { content: {}, }); // Only for timeline events. - room.addLiveEvents([event]); + room.addLiveEvents([event], { addToState: true }); expect(doesRoomHaveUnreadMessages(room, false)).toBe(false); }); @@ -201,7 +201,7 @@ describe("Unread", () => { content: {}, }); // Only for timeline events. - room.addLiveEvents([event2]); + room.addLiveEvents([event2], { addToState: true }); expect(doesRoomHaveUnreadMessages(room, false)).toBe(true); }); @@ -403,7 +403,7 @@ describe("Unread", () => { redactedEvent.makeRedacted(redactedEvent, room); console.log("Event Id", redactedEvent.getId()); // Only for timeline events. - room.addLiveEvents([redactedEvent]); + room.addLiveEvents([redactedEvent], { addToState: true }); expect(doesRoomHaveUnreadMessages(room, true)).toBe(true); expect(logger.warn).toHaveBeenCalledWith( @@ -448,7 +448,7 @@ describe("Unread", () => { room: roomId, content: {}, }); - room.addLiveEvents([event]); + room.addLiveEvents([event], { addToState: true }); }); it("an unthreaded receipt for the event makes the room read", () => { @@ -502,7 +502,7 @@ describe("Unread", () => { ts: 100, currentUserId: myId, }); - room.addLiveEvents(events); + room.addLiveEvents(events, { addToState: true }); threadEvent = events[1]; }); @@ -555,7 +555,7 @@ describe("Unread", () => { room: roomId, content: {}, }); - room.addLiveEvents([event]); + room.addLiveEvents([event], { addToState: true }); // It still returns false expect(doesRoomHaveUnreadThreads(room)).toBe(false); diff --git a/test/unit-tests/__snapshots__/LegacyCallHandler-test.ts.snap b/test/unit-tests/__snapshots__/LegacyCallHandler-test.ts.snap deleted file mode 100644 index aaf4d78758..0000000000 --- a/test/unit-tests/__snapshots__/LegacyCallHandler-test.ts.snap +++ /dev/null @@ -1,24 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`LegacyCallHandler when recording a voice broadcast and placing a call should show the info dialog 1`] = ` -[MockFunction] { - "calls": [ - [ - [Function], - { - "description":

- You can’t start a call as you are currently recording a live broadcast. Please end your live broadcast in order to start a call. -

, - "hasCloseButton": true, - "title": "Can’t start a call", - }, - ], - ], - "results": [ - { - "type": "return", - "value": undefined, - }, - ], -} -`; diff --git a/test/unit-tests/components/structures/FilePanel-test.tsx b/test/unit-tests/components/structures/FilePanel-test.tsx index 1dce220682..25bdd99676 100644 --- a/test/unit-tests/components/structures/FilePanel-test.tsx +++ b/test/unit-tests/components/structures/FilePanel-test.tsx @@ -7,13 +7,13 @@ Please see LICENSE files in the repository root for full details. */ import React from "react"; -import { EventTimelineSet, PendingEventOrdering, Room } from "matrix-js-sdk/src/matrix"; +import { EventTimelineSet, PendingEventOrdering, Room, RoomEvent } from "matrix-js-sdk/src/matrix"; import { screen, render, waitFor } from "jest-matrix-react"; import { mocked } from "jest-mock"; import FilePanel from "../../../../src/components/structures/FilePanel"; import ResizeNotifier from "../../../../src/utils/ResizeNotifier"; -import { stubClient } from "../../../test-utils"; +import { mkEvent, stubClient } from "../../../test-utils"; import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; jest.mock("matrix-js-sdk/src/matrix", () => ({ @@ -47,4 +47,43 @@ describe("FilePanel", () => { }); expect(asFragment()).toMatchSnapshot(); }); + + describe("addEncryptedLiveEvent", () => { + it("should add file msgtype event to filtered timelineSet", async () => { + const cli = MatrixClientPeg.safeGet(); + const room = new Room("!room:server", cli, cli.getSafeUserId(), { + pendingEventOrdering: PendingEventOrdering.Detached, + }); + cli.reEmitter.reEmit(room, [RoomEvent.Timeline]); + const timelineSet = new EventTimelineSet(room); + room.getOrCreateFilteredTimelineSet = jest.fn().mockReturnValue(timelineSet); + mocked(cli.getRoom).mockReturnValue(room); + + let filePanel: FilePanel | null; + render( + (filePanel = ref)} + />, + ); + await screen.findByText("No files visible in this room"); + + const event = mkEvent({ + type: "m.room.message", + user: cli.getSafeUserId(), + room: room.roomId, + content: { + body: "hello", + url: "mxc://matrix.org/1234", + msgtype: "m.file", + }, + event: true, + }); + filePanel!.addEncryptedLiveEvent(event); + + expect(timelineSet.getLiveTimeline().getEvents()).toContain(event); + }); + }); }); diff --git a/test/unit-tests/components/structures/MatrixChat-test.tsx b/test/unit-tests/components/structures/MatrixChat-test.tsx index 28bf99fa97..fd17ccf583 100644 --- a/test/unit-tests/components/structures/MatrixChat-test.tsx +++ b/test/unit-tests/components/structures/MatrixChat-test.tsx @@ -44,7 +44,6 @@ import { } from "../../../test-utils"; import * as leaveRoomUtils from "../../../../src/utils/leave-behaviour"; import { OidcClientError } from "../../../../src/utils/oidc/error"; -import * as voiceBroadcastUtils from "../../../../src/voice-broadcast/utils/cleanUpBroadcasts"; import LegacyCallHandler from "../../../../src/LegacyCallHandler"; import { CallStore } from "../../../../src/stores/CallStore"; import { Call } from "../../../../src/models/Call"; @@ -811,7 +810,6 @@ describe("", () => { jest.spyOn(LegacyCallHandler.instance, "hangupAllCalls") .mockClear() .mockImplementation(() => {}); - jest.spyOn(voiceBroadcastUtils, "cleanUpBroadcasts").mockImplementation(async () => {}); jest.spyOn(PosthogAnalytics.instance, "logout").mockImplementation(() => {}); jest.spyOn(EventIndexPeg, "deleteEventIndex").mockImplementation(async () => {}); @@ -831,22 +829,12 @@ describe("", () => { jest.spyOn(logger, "warn").mockClear(); }); - afterAll(() => { - jest.spyOn(voiceBroadcastUtils, "cleanUpBroadcasts").mockRestore(); - }); - it("should hangup all legacy calls", async () => { await getComponentAndWaitForReady(); await dispatchLogoutAndWait(); expect(LegacyCallHandler.instance.hangupAllCalls).toHaveBeenCalled(); }); - it("should cleanup broadcasts", async () => { - await getComponentAndWaitForReady(); - await dispatchLogoutAndWait(); - expect(voiceBroadcastUtils.cleanUpBroadcasts).toHaveBeenCalled(); - }); - it("should disconnect all calls", async () => { await getComponentAndWaitForReady(); await dispatchLogoutAndWait(); diff --git a/test/unit-tests/components/structures/MessagePanel-test.tsx b/test/unit-tests/components/structures/MessagePanel-test.tsx index cf44716ba9..dbb83da312 100644 --- a/test/unit-tests/components/structures/MessagePanel-test.tsx +++ b/test/unit-tests/components/structures/MessagePanel-test.tsx @@ -30,6 +30,7 @@ import { import ResizeNotifier from "../../../../src/utils/ResizeNotifier"; import { IRoomState } from "../../../../src/components/structures/RoomView"; import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; +import { ScopedRoomContextProvider } from "../../../../src/contexts/ScopedRoomContext.tsx"; jest.mock("../../../../src/utils/beacon", () => ({ useBeacon: jest.fn(), @@ -91,9 +92,9 @@ describe("MessagePanel", function () { const getComponent = (props = {}, roomContext: Partial = {}) => ( - + - + ); diff --git a/test/unit-tests/components/structures/PipContainer-test.tsx b/test/unit-tests/components/structures/PipContainer-test.tsx index 446727c74e..f573b0a0cd 100644 --- a/test/unit-tests/components/structures/PipContainer-test.tsx +++ b/test/unit-tests/components/structures/PipContainer-test.tsx @@ -10,7 +10,7 @@ import React from "react"; import { mocked, Mocked } from "jest-mock"; import { screen, render, act, cleanup } from "jest-matrix-react"; import userEvent from "@testing-library/user-event"; -import { MatrixClient, PendingEventOrdering, Room, MatrixEvent, RoomStateEvent } from "matrix-js-sdk/src/matrix"; +import { MatrixClient, PendingEventOrdering, Room, RoomStateEvent } from "matrix-js-sdk/src/matrix"; import { Widget, ClientWidgetApi } from "matrix-widget-api"; import { UserEvent } from "@testing-library/user-event/dist/types/setup/setup"; @@ -26,7 +26,6 @@ import { wrapInSdkContext, mkRoomCreateEvent, mockPlatformPeg, - flushPromises, useMockMediaDevices, } from "../../../test-utils"; import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; @@ -39,17 +38,7 @@ import defaultDispatcher from "../../../../src/dispatcher/dispatcher"; import { Action } from "../../../../src/dispatcher/actions"; import { ViewRoomPayload } from "../../../../src/dispatcher/payloads/ViewRoomPayload"; import { TestSdkContext } from "../../TestSdkContext"; -import { - VoiceBroadcastInfoState, - VoiceBroadcastPlaybacksStore, - VoiceBroadcastPreRecording, - VoiceBroadcastPreRecordingStore, - VoiceBroadcastRecording, - VoiceBroadcastRecordingsStore, -} from "../../../../src/voice-broadcast"; -import { mkVoiceBroadcastInfoStateEvent } from "../../voice-broadcast/utils/test-utils"; import { RoomViewStore } from "../../../../src/stores/RoomViewStore"; -import { IRoomStateEventsActionPayload } from "../../../../src/actions/MatrixActionCreators"; import { Container, WidgetLayoutStore } from "../../../../src/stores/widgets/WidgetLayoutStore"; import WidgetStore from "../../../../src/stores/WidgetStore"; import { WidgetType } from "../../../../src/widgets/WidgetType"; @@ -76,13 +65,6 @@ describe("PipContainer", () => { let room: Room; let room2: Room; let alice: RoomMember; - let voiceBroadcastRecordingsStore: VoiceBroadcastRecordingsStore; - let voiceBroadcastPreRecordingStore: VoiceBroadcastPreRecordingStore; - let voiceBroadcastPlaybacksStore: VoiceBroadcastPlaybacksStore; - - const actFlushPromises = async () => { - await flushPromises(); - }; beforeEach(async () => { useMockMediaDevices(); @@ -125,13 +107,7 @@ describe("PipContainer", () => { sdkContext = new TestSdkContext(); // @ts-ignore PipContainer uses SDKContext in the constructor SdkContextClass.instance = sdkContext; - voiceBroadcastRecordingsStore = new VoiceBroadcastRecordingsStore(); - voiceBroadcastPreRecordingStore = new VoiceBroadcastPreRecordingStore(); - voiceBroadcastPlaybacksStore = new VoiceBroadcastPlaybacksStore(voiceBroadcastRecordingsStore); sdkContext.client = client; - sdkContext._VoiceBroadcastRecordingsStore = voiceBroadcastRecordingsStore; - sdkContext._VoiceBroadcastPreRecordingStore = voiceBroadcastPreRecordingStore; - sdkContext._VoiceBroadcastPlaybacksStore = voiceBroadcastPlaybacksStore; }); afterEach(async () => { @@ -190,51 +166,10 @@ describe("PipContainer", () => { ActiveWidgetStore.instance.destroyPersistentWidget("1", room.roomId); }; - const makeVoiceBroadcastInfoStateEvent = (): MatrixEvent => { - return mkVoiceBroadcastInfoStateEvent( - room.roomId, - VoiceBroadcastInfoState.Started, - alice.userId, - client.getDeviceId() || "", - ); - }; - - const setUpVoiceBroadcastRecording = () => { - const infoEvent = makeVoiceBroadcastInfoStateEvent(); - const voiceBroadcastRecording = new VoiceBroadcastRecording(infoEvent, client); - voiceBroadcastRecordingsStore.setCurrent(voiceBroadcastRecording); - }; - - const setUpVoiceBroadcastPreRecording = () => { - const voiceBroadcastPreRecording = new VoiceBroadcastPreRecording( - room, - alice, - client, - voiceBroadcastPlaybacksStore, - voiceBroadcastRecordingsStore, - ); - voiceBroadcastPreRecordingStore.setCurrent(voiceBroadcastPreRecording); - }; - const setUpRoomViewStore = () => { sdkContext._RoomViewStore = new RoomViewStore(defaultDispatcher, sdkContext); }; - const mkVoiceBroadcast = (room: Room): MatrixEvent => { - const infoEvent = makeVoiceBroadcastInfoStateEvent(); - room.currentState.setStateEvents([infoEvent]); - defaultDispatcher.dispatch( - { - action: "MatrixActions.RoomState.events", - event: infoEvent, - state: room.currentState, - lastStateEvent: null, - }, - true, - ); - return infoEvent; - }; - it("hides if there's no content", () => { renderPip(); expect(screen.queryByRole("complementary")).toBeNull(); @@ -339,138 +274,4 @@ describe("PipContainer", () => { WidgetStore.instance.removeVirtualWidget("1", room.roomId); }); - - describe("when there is a voice broadcast recording and pre-recording", () => { - beforeEach(async () => { - setUpVoiceBroadcastPreRecording(); - setUpVoiceBroadcastRecording(); - renderPip(); - await actFlushPromises(); - }); - - it("should render the voice broadcast recording PiP", () => { - // check for the „Live“ badge to be present - expect(screen.queryByText("Live")).toBeInTheDocument(); - }); - - it("and a call it should show both, the call and the recording", async () => { - await withCall(async () => { - // Broadcast: Check for the „Live“ badge to be present - expect(screen.queryByText("Live")).toBeInTheDocument(); - // Call: Check for the „Leave“ button to be present - screen.getByRole("button", { name: "Leave" }); - }); - }); - }); - - describe("when there is a voice broadcast playback and pre-recording", () => { - beforeEach(async () => { - mkVoiceBroadcast(room); - setUpVoiceBroadcastPreRecording(); - renderPip(); - await actFlushPromises(); - }); - - it("should render the voice broadcast pre-recording PiP", () => { - // check for the „Go live“ button - expect(screen.queryByText("Go live")).toBeInTheDocument(); - }); - }); - - describe("when there is a voice broadcast pre-recording", () => { - beforeEach(async () => { - setUpVoiceBroadcastPreRecording(); - renderPip(); - await actFlushPromises(); - }); - - it("should render the voice broadcast pre-recording PiP", () => { - // check for the „Go live“ button - expect(screen.queryByText("Go live")).toBeInTheDocument(); - }); - }); - - describe("when listening to a voice broadcast in a room and then switching to another room", () => { - beforeEach(async () => { - setUpRoomViewStore(); - viewRoom(room.roomId); - mkVoiceBroadcast(room); - await actFlushPromises(); - - expect(voiceBroadcastPlaybacksStore.getCurrent()).toBeTruthy(); - - await voiceBroadcastPlaybacksStore.getCurrent()?.start(); - viewRoom(room2.roomId); - renderPip(); - }); - - it("should render the small voice broadcast playback PiP", () => { - // check for the „pause voice broadcast“ button - expect(screen.getByLabelText("pause voice broadcast")).toBeInTheDocument(); - // check for the absence of the „30s forward“ button - expect(screen.queryByLabelText("30s forward")).not.toBeInTheDocument(); - }); - }); - - describe("when viewing a room with a live voice broadcast", () => { - let startEvent!: MatrixEvent; - - beforeEach(async () => { - setUpRoomViewStore(); - viewRoom(room.roomId); - startEvent = mkVoiceBroadcast(room); - renderPip(); - await actFlushPromises(); - }); - - it("should render the voice broadcast playback pip", () => { - // check for the „resume voice broadcast“ button - expect(screen.queryByLabelText("play voice broadcast")).toBeInTheDocument(); - }); - - describe("and the broadcast stops", () => { - beforeEach(async () => { - const stopEvent = mkVoiceBroadcastInfoStateEvent( - room.roomId, - VoiceBroadcastInfoState.Stopped, - alice.userId, - client.getDeviceId() || "", - startEvent, - ); - - await act(async () => { - room.currentState.setStateEvents([stopEvent]); - defaultDispatcher.dispatch( - { - action: "MatrixActions.RoomState.events", - event: stopEvent, - state: room.currentState, - lastStateEvent: stopEvent, - }, - true, - ); - await flushPromises(); - }); - }); - - it("should not render the voice broadcast playback pip", () => { - // check for the „resume voice broadcast“ button - expect(screen.queryByLabelText("play voice broadcast")).not.toBeInTheDocument(); - }); - }); - - describe("and leaving the room", () => { - beforeEach(async () => { - await act(async () => { - viewRoom(room2.roomId); - await flushPromises(); - }); - }); - - it("should not render the voice broadcast playback pip", () => { - // check for the „resume voice broadcast“ button - expect(screen.queryByLabelText("play voice broadcast")).not.toBeInTheDocument(); - }); - }); - }); }); diff --git a/test/unit-tests/components/structures/RightPanel-test.tsx b/test/unit-tests/components/structures/RightPanel-test.tsx index e569369db5..ad29791ee9 100644 --- a/test/unit-tests/components/structures/RightPanel-test.tsx +++ b/test/unit-tests/components/structures/RightPanel-test.tsx @@ -91,7 +91,7 @@ describe("RightPanel", () => { if (name !== "RightPanel.phases") return realGetValue(name, roomId); if (roomId === "r1") { return { - history: [{ phase: RightPanelPhases.RoomMemberList }], + history: [{ phase: RightPanelPhases.MemberList }], isOpen: true, }; } @@ -123,7 +123,7 @@ describe("RightPanel", () => { await rpsUpdated; await waitFor(() => expect(screen.queryByTestId("spinner")).not.toBeInTheDocument()); - // room one will be in the RoomMemberList phase - confirm this is rendered + // room one will be in the MemberList phase - confirm this is rendered expect(container.getElementsByClassName("mx_MemberList")).toHaveLength(1); // wait for RPS room 2 updates to fire, then rerender diff --git a/test/unit-tests/components/structures/RoomView-test.tsx b/test/unit-tests/components/structures/RoomView-test.tsx index b6fbd2e850..385204c01b 100644 --- a/test/unit-tests/components/structures/RoomView-test.tsx +++ b/test/unit-tests/components/structures/RoomView-test.tsx @@ -10,18 +10,19 @@ import React, { createRef, RefObject } from "react"; import { mocked, MockedObject } from "jest-mock"; import { ClientEvent, + EventTimeline, + EventType, + IEvent, + JoinRule, MatrixClient, + MatrixError, + MatrixEvent, Room, RoomEvent, - EventType, - JoinRule, - MatrixError, RoomStateEvent, - MatrixEvent, SearchResult, - IEvent, } from "matrix-js-sdk/src/matrix"; -import { CryptoApi, UserVerificationStatus } from "matrix-js-sdk/src/crypto-api"; +import { CryptoApi, UserVerificationStatus, CryptoEvent } from "matrix-js-sdk/src/crypto-api"; import { KnownMembership } from "matrix-js-sdk/src/types"; import { fireEvent, @@ -34,6 +35,7 @@ import { cleanup, } from "jest-matrix-react"; import userEvent from "@testing-library/user-event"; +import { defer } from "matrix-js-sdk/src/utils"; import { stubClient, @@ -73,6 +75,7 @@ import { ViewRoomErrorPayload } from "../../../../src/dispatcher/payloads/ViewRo import { SearchScope } from "../../../../src/Searching"; import { MEGOLM_ENCRYPTION_ALGORITHM } from "../../../../src/utils/crypto"; import MatrixClientContext from "../../../../src/contexts/MatrixClientContext"; +import { ViewUserPayload } from "../../../../src/dispatcher/payloads/ViewUserPayload.ts"; describe("RoomView", () => { let cli: MockedObject; @@ -87,8 +90,7 @@ describe("RoomView", () => { beforeEach(() => { mockPlatformPeg({ reload: () => {} }); - stubClient(); - cli = mocked(MatrixClientPeg.safeGet()); + cli = mocked(stubClient()); room = new Room(`!${roomCount++}:example.org`, cli, "@alice:example.org"); jest.spyOn(room, "findPredecessor"); @@ -201,6 +203,21 @@ describe("RoomView", () => { return ref.current!; }; + it("should show member list right panel phase on Action.ViewUser without `payload.member`", async () => { + const spy = jest.spyOn(stores.rightPanelStore, "showOrHidePhase"); + await renderRoomView(false); + + defaultDispatcher.dispatch( + { + action: Action.ViewUser, + member: undefined, + }, + true, + ); + + expect(spy).toHaveBeenCalledWith(RightPanelPhases.MemberList); + }); + it("when there is no room predecessor, getHiddenHighlightCount should return 0", async () => { const instance = await getRoomViewInstance(); expect(instance.getHiddenHighlightCount()).toBe(0); @@ -247,8 +264,9 @@ describe("RoomView", () => { it("updates url preview visibility on encryption state change", async () => { room.getMyMembership = jest.fn().mockReturnValue(KnownMembership.Join); + jest.spyOn(cli, "getCrypto").mockReturnValue(crypto); // we should be starting unencrypted - expect(cli.isRoomEncrypted(room.roomId)).toEqual(false); + expect(await cli.getCrypto()?.isEncryptionEnabledInRoom(room.roomId)).toEqual(false); const roomViewInstance = await getRoomViewInstance(); @@ -263,23 +281,38 @@ describe("RoomView", () => { expect(roomViewInstance.state.showUrlPreview).toBe(true); // now enable encryption - cli.isRoomEncrypted.mockReturnValue(true); + jest.spyOn(cli.getCrypto()!, "isEncryptionEnabledInRoom").mockResolvedValue(true); // and fake an encryption event into the room to prompt it to re-check - await act(() => - room.addLiveEvents([ - new MatrixEvent({ - type: "m.room.encryption", - sender: cli.getUserId()!, - content: {}, - event_id: "someid", - room_id: room.roomId, - }), - ]), - ); + act(() => { + const encryptionEvent = new MatrixEvent({ + type: EventType.RoomEncryption, + sender: cli.getUserId()!, + content: {}, + event_id: "someid", + room_id: room.roomId, + }); + const roomState = room.getLiveTimeline().getState(EventTimeline.FORWARDS)!; + cli.emit(RoomStateEvent.Events, encryptionEvent, roomState, null); + }); // URL previews should now be disabled - expect(roomViewInstance.state.showUrlPreview).toBe(false); + await waitFor(() => expect(roomViewInstance.state.showUrlPreview).toBe(false)); + }); + + it("should not display the timeline when the room encryption is loading", async () => { + jest.spyOn(room, "getMyMembership").mockReturnValue(KnownMembership.Join); + jest.spyOn(cli, "getCrypto").mockReturnValue(crypto); + const deferred = defer(); + jest.spyOn(cli.getCrypto()!, "isEncryptionEnabledInRoom").mockImplementation(() => deferred.promise); + + const { asFragment, container } = await mountRoomView(); + expect(container.querySelector(".mx_RoomView_messagePanel")).toBeNull(); + expect(asFragment()).toMatchSnapshot(); + + deferred.resolve(true); + await waitFor(() => expect(container.querySelector(".mx_RoomView_messagePanel")).not.toBeNull()); + expect(asFragment()).toMatchSnapshot(); }); it("updates live timeline when a timeline reset happens", async () => { @@ -290,6 +323,32 @@ describe("RoomView", () => { expect(roomViewInstance.state.liveTimeline).not.toEqual(oldTimeline); }); + it("should update when the e2e status when the user verification changed", async () => { + room.currentState.setStateEvents([ + mkRoomMemberJoinEvent(cli.getSafeUserId(), room.roomId), + mkRoomMemberJoinEvent("user@example.com", room.roomId), + ]); + room.getMyMembership = jest.fn().mockReturnValue(KnownMembership.Join); + // Not all the calls to cli.isRoomEncrypted are migrated, so we need to mock both. + mocked(cli.isRoomEncrypted).mockReturnValue(true); + jest.spyOn(cli, "getCrypto").mockReturnValue(crypto); + jest.spyOn(cli.getCrypto()!, "isEncryptionEnabledInRoom").mockResolvedValue(true); + jest.spyOn(cli.getCrypto()!, "getUserVerificationStatus").mockResolvedValue( + new UserVerificationStatus(false, false, false), + ); + jest.spyOn(cli.getCrypto()!, "getUserDeviceInfo").mockResolvedValue( + new Map([["user@example.com", new Map()]]), + ); + + const { container } = await renderRoomView(); + await waitFor(() => expect(container.querySelector(".mx_E2EIcon_normal")).toBeInTheDocument()); + + const verificationStatus = new UserVerificationStatus(true, true, false); + jest.spyOn(cli.getCrypto()!, "getUserVerificationStatus").mockResolvedValue(verificationStatus); + cli.emit(CryptoEvent.UserTrustStatusChanged, cli.getSafeUserId(), verificationStatus); + await waitFor(() => expect(container.querySelector(".mx_E2EIcon_verified")).toBeInTheDocument()); + }); + describe("with virtual rooms", () => { it("checks for a virtual room on initial load", async () => { const { container } = await renderRoomView(); @@ -427,7 +486,8 @@ describe("RoomView", () => { ]); jest.spyOn(DMRoomMap.shared(), "getUserIdForRoomId").mockReturnValue(cli.getSafeUserId()); jest.spyOn(DMRoomMap.shared(), "getRoomIds").mockReturnValue(new Set([room.roomId])); - mocked(cli).isRoomEncrypted.mockReturnValue(true); + jest.spyOn(cli, "getCrypto").mockReturnValue(crypto); + jest.spyOn(cli.getCrypto()!, "isEncryptionEnabledInRoom").mockResolvedValue(true); await renderRoomView(); }); @@ -653,7 +713,7 @@ describe("RoomView", () => { skey: id, ts, }); - room.addLiveEvents([widgetEvent]); + room.addLiveEvents([widgetEvent], { addToState: false }); room.currentState.setStateEvents([widgetEvent]); cli.emit(RoomStateEvent.Events, widgetEvent, room.currentState, null); await flushPromises(); diff --git a/test/unit-tests/components/structures/SpaceRoomView-test.tsx b/test/unit-tests/components/structures/SpaceRoomView-test.tsx new file mode 100644 index 0000000000..fb24603283 --- /dev/null +++ b/test/unit-tests/components/structures/SpaceRoomView-test.tsx @@ -0,0 +1,117 @@ +/* +Copyright 2024 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only +Please see LICENSE files in the repository root for full details. +*/ + +import React from "react"; +import { mocked, MockedObject } from "jest-mock"; +import { MatrixClient, MatrixEvent, Room } from "matrix-js-sdk/src/matrix"; +import { render, cleanup, screen, fireEvent } from "jest-matrix-react"; + +import { stubClient, mockPlatformPeg, unmockPlatformPeg, withClientContextRenderOptions } from "../../../test-utils"; +import { RightPanelPhases } from "../../../../src/stores/right-panel/RightPanelStorePhases"; +import SpaceRoomView from "../../../../src/components/structures/SpaceRoomView.tsx"; +import ResizeNotifier from "../../../../src/utils/ResizeNotifier.ts"; +import { RoomPermalinkCreator } from "../../../../src/utils/permalinks/Permalinks.ts"; +import RightPanelStore from "../../../../src/stores/right-panel/RightPanelStore.ts"; +import DMRoomMap from "../../../../src/utils/DMRoomMap.ts"; + +describe("SpaceRoomView", () => { + let cli: MockedObject; + let space: Room; + + beforeEach(() => { + mockPlatformPeg({ reload: () => {} }); + cli = mocked(stubClient()); + + space = new Room(`!space:example.org`, cli, cli.getSafeUserId()); + space.currentState.setStateEvents([ + new MatrixEvent({ + type: "m.room.create", + room_id: space.roomId, + sender: cli.getSafeUserId(), + state_key: "", + content: { + creator: cli.getSafeUserId(), + type: "m.space", + }, + }), + new MatrixEvent({ + type: "m.room.member", + room_id: space.roomId, + sender: cli.getSafeUserId(), + state_key: cli.getSafeUserId(), + content: { + membership: "join", + }, + }), + new MatrixEvent({ + type: "m.room.member", + room_id: space.roomId, + sender: "@userA:server", + state_key: "@userA:server", + content: { + membership: "join", + }, + }), + new MatrixEvent({ + type: "m.room.member", + room_id: space.roomId, + sender: "@userB:server", + state_key: "@userB:server", + content: { + membership: "join", + }, + }), + new MatrixEvent({ + type: "m.room.member", + room_id: space.roomId, + sender: "@userC:server", + state_key: "@userC:server", + content: { + membership: "join", + }, + }), + ]); + space.updateMyMembership("join"); + + DMRoomMap.makeShared(cli); + }); + + afterEach(() => { + unmockPlatformPeg(); + jest.clearAllMocks(); + cleanup(); + }); + + const renderSpaceRoomView = async (): Promise> => { + const resizeNotifier = new ResizeNotifier(); + const permalinkCreator = new RoomPermalinkCreator(space); + + const spaceRoomView = render( + , + withClientContextRenderOptions(cli), + ); + return spaceRoomView; + }; + + describe("SpaceLanding", () => { + it("should show member list right panel phase on members click on landing", async () => { + const spy = jest.spyOn(RightPanelStore.instance, "setCard"); + const { container } = await renderSpaceRoomView(); + + await expect(screen.findByText("Welcome to")).resolves.toBeVisible(); + fireEvent.click(container.querySelector(".mx_FacePile")!); + + expect(spy).toHaveBeenCalledWith({ phase: RightPanelPhases.MemberList }); + }); + }); +}); diff --git a/test/unit-tests/components/structures/ThreadPanel-test.tsx b/test/unit-tests/components/structures/ThreadPanel-test.tsx index c19127de25..20fc708103 100644 --- a/test/unit-tests/components/structures/ThreadPanel-test.tsx +++ b/test/unit-tests/components/structures/ThreadPanel-test.tsx @@ -20,7 +20,6 @@ import { import ThreadPanel, { ThreadFilterType, ThreadPanelHeader } from "../../../../src/components/structures/ThreadPanel"; import MatrixClientContext from "../../../../src/contexts/MatrixClientContext"; -import RoomContext from "../../../../src/contexts/RoomContext"; import { _t } from "../../../../src/languageHandler"; import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; import { RoomPermalinkCreator } from "../../../../src/utils/permalinks/Permalinks"; @@ -28,6 +27,7 @@ import ResizeNotifier from "../../../../src/utils/ResizeNotifier"; import { createTestClient, getRoomContext, mkRoom, mockPlatformPeg, stubClient } from "../../../test-utils"; import { mkThread } from "../../../test-utils/threads"; import { IRoomState } from "../../../../src/components/structures/RoomView"; +import { ScopedRoomContextProvider } from "../../../../src/contexts/ScopedRoomContext.tsx"; jest.mock("../../../../src/utils/Feedback"); @@ -81,11 +81,11 @@ describe("ThreadPanel", () => { room: mockRoom, } as unknown as IRoomState; const { container } = render( - + undefined} /> - , + , ); fireEvent.click(getByRole(container, "button", { name: "Mark all as read" })); await waitFor(() => @@ -114,8 +114,8 @@ describe("ThreadPanel", () => { const TestThreadPanel = () => ( - @@ -125,7 +125,7 @@ describe("ThreadPanel", () => { resizeNotifier={new ResizeNotifier()} permalinkCreator={new RoomPermalinkCreator(room)} /> - + ); @@ -209,11 +209,11 @@ describe("ThreadPanel", () => { return event ? Promise.resolve(event) : Promise.reject(); }); const [allThreads, myThreads] = room.threadsTimelineSets; - allThreads!.addLiveEvent(otherThread.rootEvent); - allThreads!.addLiveEvent(mixedThread.rootEvent); - allThreads!.addLiveEvent(ownThread.rootEvent); - myThreads!.addLiveEvent(mixedThread.rootEvent); - myThreads!.addLiveEvent(ownThread.rootEvent); + allThreads!.addLiveEvent(otherThread.rootEvent, { addToState: true }); + allThreads!.addLiveEvent(mixedThread.rootEvent, { addToState: true }); + allThreads!.addLiveEvent(ownThread.rootEvent, { addToState: true }); + myThreads!.addLiveEvent(mixedThread.rootEvent, { addToState: true }); + myThreads!.addLiveEvent(ownThread.rootEvent, { addToState: true }); const renderResult = render(); await waitFor(() => expect(renderResult.container.querySelector(".mx_AutoHideScrollbar")).toBeFalsy()); @@ -258,7 +258,7 @@ describe("ThreadPanel", () => { return event ? Promise.resolve(event) : Promise.reject(); }); const [allThreads] = room.threadsTimelineSets; - allThreads!.addLiveEvent(otherThread.rootEvent); + allThreads!.addLiveEvent(otherThread.rootEvent, { addToState: true }); const renderResult = render(); await waitFor(() => expect(renderResult.container.querySelector(".mx_AutoHideScrollbar")).toBeFalsy()); diff --git a/test/unit-tests/components/structures/ThreadView-test.tsx b/test/unit-tests/components/structures/ThreadView-test.tsx index 697fd25181..ee4afff525 100644 --- a/test/unit-tests/components/structures/ThreadView-test.tsx +++ b/test/unit-tests/components/structures/ThreadView-test.tsx @@ -23,7 +23,6 @@ import React, { useState } from "react"; import ThreadView from "../../../../src/components/structures/ThreadView"; import MatrixClientContext from "../../../../src/contexts/MatrixClientContext"; -import RoomContext from "../../../../src/contexts/RoomContext"; import { SdkContextClass } from "../../../../src/contexts/SDKContext"; import { Action } from "../../../../src/dispatcher/actions"; import dispatcher from "../../../../src/dispatcher/dispatcher"; @@ -34,6 +33,7 @@ import { mockPlatformPeg } from "../../../test-utils/platform"; import { getRoomContext } from "../../../test-utils/room"; import { mkMessage, stubClient } from "../../../test-utils/test-utils"; import { mkThread } from "../../../test-utils/threads"; +import { ScopedRoomContextProvider } from "../../../../src/contexts/ScopedRoomContext.tsx"; describe("ThreadView", () => { const ROOM_ID = "!roomId:example.org"; @@ -51,8 +51,8 @@ describe("ThreadView", () => { return ( - @@ -63,7 +63,7 @@ describe("ThreadView", () => { initialEvent={initialEvent} resizeNotifier={new ResizeNotifier()} /> - + , ); diff --git a/test/unit-tests/components/structures/TimelinePanel-test.tsx b/test/unit-tests/components/structures/TimelinePanel-test.tsx index 2f85843000..442ed1c1d2 100644 --- a/test/unit-tests/components/structures/TimelinePanel-test.tsx +++ b/test/unit-tests/components/structures/TimelinePanel-test.tsx @@ -66,7 +66,7 @@ const mkTimeline = (room: Room, events: MatrixEvent[]): [EventTimeline, EventTim getPendingEvents: () => [] as MatrixEvent[], } as unknown as EventTimelineSet; const timeline = new EventTimeline(timelineSet); - events.forEach((event) => timeline.addEvent(event, { toStartOfTimeline: false })); + events.forEach((event) => timeline.addEvent(event, { toStartOfTimeline: false, addToState: true })); return [timeline, timelineSet]; }; @@ -150,9 +150,11 @@ const setupPagination = ( mocked(client).paginateEventTimeline.mockImplementation(async (tl, { backwards }) => { if (tl === timeline) { if (backwards) { - forEachRight(previousPage ?? [], (event) => tl.addEvent(event, { toStartOfTimeline: true })); + forEachRight(previousPage ?? [], (event) => + tl.addEvent(event, { toStartOfTimeline: true, addToState: true }), + ); } else { - (nextPage ?? []).forEach((event) => tl.addEvent(event, { toStartOfTimeline: false })); + (nextPage ?? []).forEach((event) => tl.addEvent(event, { toStartOfTimeline: false, addToState: true })); } // Prevent any further pagination attempts in this direction tl.setPaginationToken(null, backwards ? EventTimeline.BACKWARDS : EventTimeline.FORWARDS); @@ -256,7 +258,7 @@ describe("TimelinePanel", () => { describe("and reading the timeline", () => { beforeEach(async () => { await renderTimelinePanel(); - timelineSet.addLiveEvent(ev1, {}); + timelineSet.addLiveEvent(ev1, { addToState: true }); await flushPromises(); // @ts-ignore await timelinePanel.sendReadReceipts(); @@ -284,11 +286,11 @@ describe("TimelinePanel", () => { }); it("and forgetting the read markers, should send the stored marker again", async () => { - timelineSet.addLiveEvent(ev2, {}); + timelineSet.addLiveEvent(ev2, { addToState: true }); // Add the event to the room as well as the timeline, so we can find it when we // call findEventById in getEventReadUpTo. This is odd because in our test // setup, timelineSet is not actually the timelineSet of the room. - await room.addLiveEvents([ev2], {}); + await room.addLiveEvents([ev2], { addToState: true }); room.addEphemeralEvents([newReceipt(ev2.getId()!, userId, 222, 200)]); await timelinePanel!.forgetReadMarker(); expect(client.setRoomReadMarkers).toHaveBeenCalledWith(roomId, ev2.getId()); @@ -314,7 +316,7 @@ describe("TimelinePanel", () => { it("should send a fully read marker and a private receipt", async () => { await renderTimelinePanel(); - act(() => timelineSet.addLiveEvent(ev1, {})); + act(() => timelineSet.addLiveEvent(ev1, { addToState: true })); await flushPromises(); // @ts-ignore @@ -361,7 +363,7 @@ describe("TimelinePanel", () => { it("should send receipts but no fully_read when reading the thread timeline", async () => { await renderTimelinePanel(); - act(() => timelineSet.addLiveEvent(threadEv1, {})); + act(() => timelineSet.addLiveEvent(threadEv1, { addToState: true })); await flushPromises(); // @ts-ignore @@ -871,7 +873,9 @@ describe("TimelinePanel", () => { // @ts-ignore thread.fetchEditsWhereNeeded = () => Promise.resolve(); await thread.addEvent(reply1, false, true); - await allThreads.getLiveTimeline().addEvent(thread.rootEvent!, { toStartOfTimeline: true }); + await allThreads + .getLiveTimeline() + .addEvent(thread.rootEvent!, { toStartOfTimeline: true, addToState: true }); const replyToEvent = jest.spyOn(thread, "replyToEvent", "get"); const dom = render( @@ -907,7 +911,9 @@ describe("TimelinePanel", () => { // @ts-ignore realThread.fetchEditsWhereNeeded = () => Promise.resolve(); await realThread.addEvent(reply1, true); - await allThreads.getLiveTimeline().addEvent(realThread.rootEvent!, { toStartOfTimeline: true }); + await allThreads + .getLiveTimeline() + .addEvent(realThread.rootEvent!, { toStartOfTimeline: true, addToState: true }); const replyToEvent = jest.spyOn(realThread, "replyToEvent", "get"); // @ts-ignore @@ -968,7 +974,9 @@ describe("TimelinePanel", () => { events.push(rootEvent); - events.forEach((event) => timelineSet.getLiveTimeline().addEvent(event, { toStartOfTimeline: true })); + events.forEach((event) => + timelineSet.getLiveTimeline().addEvent(event, { toStartOfTimeline: true, addToState: true }), + ); const roomMembership = mkMembership({ mship: KnownMembership.Join, @@ -988,7 +996,10 @@ describe("TimelinePanel", () => { jest.spyOn(roomState, "getMember").mockReturnValue(member); jest.spyOn(timelineSet.getLiveTimeline(), "getState").mockReturnValue(roomState); - timelineSet.addEventToTimeline(roomMembership, timelineSet.getLiveTimeline(), { toStartOfTimeline: false }); + timelineSet.addEventToTimeline(roomMembership, timelineSet.getLiveTimeline(), { + toStartOfTimeline: false, + addToState: true, + }); for (const event of events) { jest.spyOn(event, "isDecryptionFailure").mockReturnValue(true); diff --git a/test/unit-tests/components/structures/UserMenu-test.tsx b/test/unit-tests/components/structures/UserMenu-test.tsx index ac76aba2ad..907bf664b7 100644 --- a/test/unit-tests/components/structures/UserMenu-test.tsx +++ b/test/unit-tests/components/structures/UserMenu-test.tsx @@ -7,20 +7,14 @@ Please see LICENSE files in the repository root for full details. */ import React from "react"; -import { act, render, RenderResult, screen, waitFor } from "jest-matrix-react"; -import { DEVICE_CODE_SCOPE, MatrixClient, MatrixEvent, Room } from "matrix-js-sdk/src/matrix"; +import { render, screen, waitFor } from "jest-matrix-react"; +import { DEVICE_CODE_SCOPE, MatrixClient, Room } from "matrix-js-sdk/src/matrix"; import { CryptoApi } from "matrix-js-sdk/src/crypto-api"; import { mocked } from "jest-mock"; import fetchMock from "fetch-mock-jest"; import UnwrappedUserMenu from "../../../../src/components/structures/UserMenu"; import { stubClient, wrapInSdkContext } from "../../../test-utils"; -import { - VoiceBroadcastInfoState, - VoiceBroadcastRecording, - VoiceBroadcastRecordingsStore, -} from "../../../../src/voice-broadcast"; -import { mkVoiceBroadcastInfoStateEvent } from "../../voice-broadcast/utils/test-utils"; import { TestSdkContext } from "../../TestSdkContext"; import defaultDispatcher from "../../../../src/dispatcher/dispatcher"; import LogoutDialog from "../../../../src/components/views/dialogs/LogoutDialog"; @@ -34,71 +28,12 @@ import { UserTab } from "../../../../src/components/views/dialogs/UserTab"; describe("", () => { let client: MatrixClient; - let renderResult: RenderResult; let sdkContext: TestSdkContext; beforeEach(() => { sdkContext = new TestSdkContext(); }); - describe(" when video broadcast", () => { - let voiceBroadcastInfoEvent: MatrixEvent; - let voiceBroadcastRecording: VoiceBroadcastRecording; - let voiceBroadcastRecordingsStore: VoiceBroadcastRecordingsStore; - - beforeAll(() => { - client = stubClient(); - voiceBroadcastInfoEvent = mkVoiceBroadcastInfoStateEvent( - "!room:example.com", - VoiceBroadcastInfoState.Started, - client.getUserId() || "", - client.getDeviceId() || "", - ); - }); - - beforeEach(() => { - voiceBroadcastRecordingsStore = new VoiceBroadcastRecordingsStore(); - sdkContext._VoiceBroadcastRecordingsStore = voiceBroadcastRecordingsStore; - - voiceBroadcastRecording = new VoiceBroadcastRecording(voiceBroadcastInfoEvent, client); - }); - - describe("when rendered", () => { - beforeEach(() => { - const UserMenu = wrapInSdkContext(UnwrappedUserMenu, sdkContext); - renderResult = render(); - }); - - it("should render as expected", () => { - expect(renderResult.container).toMatchSnapshot(); - }); - - describe("and a live voice broadcast starts", () => { - beforeEach(() => { - act(() => { - voiceBroadcastRecordingsStore.setCurrent(voiceBroadcastRecording); - }); - }); - - it("should render the live voice broadcast avatar addon", () => { - expect(renderResult.queryByTestId("user-menu-live-vb")).toBeInTheDocument(); - }); - - describe("and the broadcast ends", () => { - beforeEach(() => { - act(() => { - voiceBroadcastRecordingsStore.clearCurrent(); - }); - }); - - it("should not render the live voice broadcast avatar addon", () => { - expect(renderResult.queryByTestId("user-menu-live-vb")).not.toBeInTheDocument(); - }); - }); - }); - }); - }); - describe(" logout", () => { beforeEach(() => { client = stubClient(); @@ -106,7 +41,7 @@ describe("", () => { it("should logout directly if no crypto", async () => { const UserMenu = wrapInSdkContext(UnwrappedUserMenu, sdkContext); - renderResult = render(); + render(); mocked(client.getRooms).mockReturnValue([ { @@ -128,7 +63,7 @@ describe("", () => { it("should logout directly if no encrypted rooms", async () => { const UserMenu = wrapInSdkContext(UnwrappedUserMenu, sdkContext); - renderResult = render(); + render(); mocked(client.getRooms).mockReturnValue([ { @@ -152,7 +87,7 @@ describe("", () => { it("should show dialog if some encrypted rooms", async () => { const UserMenu = wrapInSdkContext(UnwrappedUserMenu, sdkContext); - renderResult = render(); + render(); mocked(client.getRooms).mockReturnValue([ { diff --git a/test/unit-tests/components/structures/__snapshots__/RoomView-test.tsx.snap b/test/unit-tests/components/structures/__snapshots__/RoomView-test.tsx.snap index 626f7f2b9b..1e0ed2248b 100644 --- a/test/unit-tests/components/structures/__snapshots__/RoomView-test.tsx.snap +++ b/test/unit-tests/components/structures/__snapshots__/RoomView-test.tsx.snap @@ -62,7 +62,7 @@ exports[`RoomView for a local room in state CREATING should match the snapshot 1 style="--cpd-icon-button-size: 100%;" >
`; +exports[`RoomView should not display the timeline when the room encryption is loading 1`] = ` + +
+