Compare commits

...

102 Commits

Author SHA1 Message Date
Michael Kaye
beeb418496 Merge pull request #1143 from vector-im/michaelkaye/testy_on_main
Run tests for codecoverage after push to main
2023-06-27 08:57:09 +01:00
Michael Kaye
0dc362a5dc primary branch is now livekit, not main 2023-06-26 19:28:11 +01:00
Robin
2981a9ddd8 Merge pull request #1138 from robintown/update-js-sdk
Update matrix-js-sdk
2023-06-26 09:53:09 -04:00
Michael Kaye
1971c18034 Run tests, for codecoverage after push to main
This allows us to generate a regular baseline to compare upcoming push requests against.
2023-06-26 10:53:29 +01:00
Robin Townsend
eb8f6ef902 Update matrix-js-sdk 2023-06-23 15:31:31 -04:00
Robin
d2e2d3e768 Merge pull request #1137 from robintown/call-backend-full-mesh
Note the call backend in rageshake and analytics data
2023-06-23 15:13:10 -04:00
Robin Townsend
4eadfed9af Note the call backend in rageshake and analytics data 2023-06-23 15:00:15 -04:00
Michael Kaye
e446039d1f Merge pull request #1117 from vector-im/michaelk/report_coverage
Push code coverage percentages to codecov.io.
2023-06-23 12:52:41 +01:00
Michael Kaye
f64df3dcf1 Fix typo in github action config. 2023-06-22 09:18:17 +01:00
Robin
612449066d Merge pull request #1128 from robintown/fix-livekit-deployment
Use the right config for the livekit-experiment deployment
2023-06-20 16:21:55 +00:00
Robin Townsend
18bcc9ee37 Use the right config for the livekit-experiment deployment 2023-06-20 12:19:38 -04:00
Robin
c34fcfedda Merge pull request #1127 from robintown/livekit-experiment-cd
Add persistent CD for the livekit-experiment branch
2023-06-20 16:06:10 +00:00
Robin Townsend
11f8ec03bc Add persistent CD for the livekit-experiment branch
This is basically just a copy of the main branch CD - untested but is supposed to deploy to element-call-livekit.netlify.app
2023-06-20 11:46:10 -04:00
Robin
50718e47ca Merge pull request #1124 from robintown/grid-interactions
Improved large grid interactions
2023-06-20 03:55:00 +00:00
Timo
2ffe000bf5 Connection lost banner (#1101)
* connection lost banner
if there is no connection to the home server

Signed-off-by: Timo K <toger5@hotmail.de>
2023-06-19 15:36:03 +02:00
Robin Townsend
cd7ab00d80 Don't try to promote the same speaker multiple times 2023-06-18 11:45:01 -04:00
Robin Townsend
ddeb36db47 Promote speakers to the first page of the grid 2023-06-18 11:35:13 -04:00
Robin Townsend
4e5a75074a Fix tiles not collapsing toward their center 2023-06-18 01:01:24 -04:00
Robin Townsend
391ba5196c Make screenshares appear near the presenter's tile and be larger 2023-06-18 00:47:37 -04:00
Robin Townsend
3e56d0a656 Make it possible again to drag a tile into the top left corner 2023-06-18 00:28:08 -04:00
Robin Townsend
afbcea7b66 Allow the grid to resize with the window width 2023-06-17 22:31:07 -04:00
Robin Townsend
4f582c6ad7 Don't change tile size when dragging 2023-06-17 22:31:07 -04:00
Robin Townsend
8b8d6fd0e0 Push large tiles upwards back into the grid 2023-06-17 22:31:01 -04:00
Robin
cabad628b4 Merge pull request #1121 from robintown/grid-performance
Improve the performance of dragging tiles in the large grid
2023-06-16 12:55:15 -04:00
Robin Townsend
f4f454f58e Improve the performance of dragging tiles in the large grid
By only updating the one spring of the tile that's being interacted with
2023-06-16 10:20:24 -04:00
Michael Kaye
97a6313e03 Push code coverage percentages to codecov.io. 2023-06-15 15:23:10 +01:00
Robin
5510719fb2 Merge pull request #1111 from RiotTranslateBot/weblate-element-call-element-call
Translations update from Weblate
2023-06-14 11:42:45 -04:00
Robin Townsend
7cae785351 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (144 of 144 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/uk/
2023-06-14 15:34:43 +00:00
Ihor Hordiichuk
69526b67eb Translated using Weblate (Ukrainian)
Currently translated at 100.0% (144 of 144 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/uk/
2023-06-14 14:01:48 +00:00
Robin
bde13e0fab Merge pull request #1104 from robintown/decouple-grid
Decouple video grid from video tile components
2023-06-13 09:56:47 -04:00
Robin Townsend
1207ecc9d7 Decouple video grid from video tile components
This is an attempt to address the feedback in https://github.com/vector-im/element-call/pull/1099#discussion_r1226863404 that the video grid and video tile components have become too tightly coupled. After this change, the only requirements that the video grid makes of its child components are:

- They accept ref, style, and item props
- They attach the ref and styles to a react-spring animated element

Note: I removed the video grid Storybook file, because I'm not aware of anyone using Storybook for development of Element Call beyond Robert, and it would take some effort to fix to work with these changes.
2023-06-12 18:21:45 -04:00
Robin
8c21dbaade Don't require textual feedback (#1097)
We want to encourage scoring as much as possible for the purpose of our KPIs, even if it means we don't always get detailed textual feedback.
2023-06-12 12:52:29 +02:00
Robin
825cb75cb7 Merge pull request #1098 from robintown/fix-new-grid
Fix tiles not animating in the new grid layout
2023-06-10 17:09:21 -04:00
Robin Townsend
554da08628 Fix tiles not animating in the new grid layout
The new grid layout has been broken ever since upgrading react-spring, because it was apparently relying on a buggy behavior of react-spring that started transitions automatically even in imperative mode. react-spring 9.5.1 fixed that behavior, which means we now need to manually start the animations.
2023-06-09 13:52:21 -04:00
Robin
f070ab7f67 Merge pull request #1095 from RiotTranslateBot/weblate-element-call-element-call
Translations update from Weblate
2023-06-08 10:26:15 -04:00
Robin
1bfbb80f6d Merge pull request #1094 from robintown/widget-join-delay
Don't prematurely claim that we've joined the call in widget mode
2023-06-08 10:24:32 -04:00
Robin
d3d21fa86c Merge pull request #1093 from robintown/widget-feedback-hotfix
Don't show the quality survey if the app is a widget
2023-06-08 10:19:49 -04:00
raspin0
515ee72945 Translated using Weblate (Polish)
Currently translated at 100.0% (144 of 144 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/pl/
2023-06-08 08:35:14 +00:00
Glandos
6a6b62216d Translated using Weblate (French)
Currently translated at 100.0% (144 of 144 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/fr/
2023-06-08 08:35:14 +00:00
Linerly
95b0a6a1ae Translated using Weblate (Indonesian)
Currently translated at 99.3% (143 of 144 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/id/
2023-06-08 05:25:27 +00:00
Vri
28ffd591b7 Translated using Weblate (German)
Currently translated at 100.0% (144 of 144 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/de/
2023-06-08 05:25:26 +00:00
Jeff Huang
5c17988e5b Translated using Weblate (Chinese (Traditional))
Currently translated at 100.0% (144 of 144 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/zh_Hant/
2023-06-08 03:01:53 +00:00
Jozef Gaal
9696b21b1b Translated using Weblate (Slovak)
Currently translated at 100.0% (144 of 144 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/sk/
2023-06-07 21:09:18 +00:00
Priit Jõerüüt
6deeb76124 Translated using Weblate (Estonian)
Currently translated at 100.0% (144 of 144 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/et/
2023-06-07 21:09:18 +00:00
Weblate
1da3d5e2c6 Update translation files
Updated by "Cleanup translation files" hook in Weblate.

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/
2023-06-07 19:47:26 +00:00
Someone
b71a118db0 Translated using Weblate (Vietnamese)
Currently translated at 74.8% (104 of 139 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/vi/
2023-06-07 19:47:26 +00:00
raspin0
d0962d77e1 Translated using Weblate (Polish)
Currently translated at 100.0% (139 of 139 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/pl/
2023-06-07 19:47:26 +00:00
waclaw66
170e18af1c Translated using Weblate (Czech)
Currently translated at 94.2% (131 of 139 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/cs/
2023-06-07 19:47:26 +00:00
Robin Townsend
e4a3dbd7f7 Don't prematurely claim that we've joined the call in widget mode
In GroupCallView we do 'await enter()' when responding to a widget API join request, but it turns out enter wasn't actually returning a promise until now. The consequence of this was that in Element Web, when you click the join button you get shown a blank screen for a moment. This fixes that half-second moment of the UI being broken, allowing Element Web to show the intermediate 'joining' state.
2023-06-07 14:33:41 -04:00
Robin Townsend
47a4c5aa88 Don't show the quality survey if the app is a widget
As explained by the comment, we don't yet have designs that account for this combo.
2023-06-07 14:19:53 -04:00
Timo
efc25fd4ec hotfix Quality survey button interaction (#1091)
Signed-off-by: Timo K <toger5@hotmail.de>
2023-06-07 17:12:24 +02:00
Enrico Schwendig
8f8dd5f803 Display active tracks in OTel metrics (#1085)
* Add track, feed and transceiver spans under call span
2023-06-07 16:40:47 +02:00
Timo
2a6981c58d Add quality survey at the end of the call (#1084)
Signed-off-by: Timo K <toger5@hotmail.de>
Co-authored-by: Robin <robin@robin.town>
2023-06-07 16:22:44 +02:00
Timo
eff8847586 add splitbrain params to MediaReceived event (#1089)
Signed-off-by: Timo K <toger5@hotmail.de>
2023-06-07 15:59:42 +02:00
Robin
166b9fede5 Merge pull request #1086 from robintown/ice-fallback
Add a URL parameter for allowing fallback ICE servers
2023-06-07 09:29:34 -04:00
Robin
5dd7c3ad8d Merge pull request #1088 from RiotTranslateBot/weblate-element-call-element-call
Translations update from Weblate
2023-06-07 09:23:28 -04:00
Someone
cc2402e61c Translated using Weblate (Vietnamese)
Currently translated at 25.8% (36 of 139 strings)

Translation: Element Call/element-call
Translate-URL: http://translate.element.io/projects/element-call/element-call/vi/
2023-06-06 06:35:08 +00:00
Linerly
66a582dd5f Translated using Weblate (Indonesian)
Currently translated at 100.0% (139 of 139 strings)

Translation: Element Call/element-call
Translate-URL: http://translate.element.io/projects/element-call/element-call/id/
2023-06-06 06:35:08 +00:00
Enrico Schwendig
f0a6f5919e move webrtc etc. events from groupCall to matrix.call span (#1080)
* add new linked span for connection stats

* move stats span under call span and add user attribute

* Update matrix-js-sdk
2023-06-06 08:28:53 +02:00
Robin Townsend
5ef0486eff Add a URL parameter for allowing fallback ICE servers 2023-06-05 15:52:05 -04:00
Robin
b1a5417b63 Merge pull request #1083 from RiotTranslateBot/weblate-element-call-element-call
Translations update from Weblate
2023-06-05 14:36:38 -04:00
Someone
e129e90dd8 Added translation using Weblate (Vietnamese) 2023-06-05 04:16:05 +00:00
Weblate
5af7c9e7c7 Update translation files
Updated by "Cleanup translation files" hook in Weblate.

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/
2023-06-04 13:07:31 +00:00
Jozef Gaal
9f924aef64 Translated using Weblate (Slovak)
Currently translated at 100.0% (139 of 139 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/sk/
2023-06-04 13:07:30 +00:00
phardyle
a0da11ea78 Translated using Weblate (Chinese (Simplified))
Currently translated at 90.6% (126 of 139 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/zh_Hans/
2023-06-04 13:07:30 +00:00
Ihor Hordiichuk
48cf604bd1 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (139 of 139 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/uk/
2023-06-04 13:07:30 +00:00
Vri
00c44fb38a Translated using Weblate (German)
Currently translated at 100.0% (139 of 139 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/de/
2023-06-04 13:07:30 +00:00
Robin
062802b3e9 Merge pull request #1082 from RiotTranslateBot/weblate-element-call-element-call
Translations update from Weblate
2023-06-02 10:51:46 -04:00
Jeff Huang
ce3d315d50 Translated using Weblate (Chinese (Traditional))
Currently translated at 100.0% (139 of 139 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/zh_Hant/
2023-06-02 14:46:17 +00:00
Robin Townsend
0e50679db5 Translated using Weblate (Chinese (Simplified))
Currently translated at 89.9% (125 of 139 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/zh_Hans/
2023-06-02 14:46:17 +00:00
Priit Jõerüüt
3db081440e Translated using Weblate (Estonian)
Currently translated at 100.0% (139 of 139 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/et/
2023-06-02 14:46:17 +00:00
Glandos
a9b2ca01a7 Translated using Weblate (French)
Currently translated at 100.0% (139 of 139 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/fr/
2023-06-02 14:46:17 +00:00
Vri
af4f27cbbf Translated using Weblate (German)
Currently translated at 96.4% (134 of 139 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/de/
2023-06-02 14:46:16 +00:00
Weblate
3f8848981d Update translation files
Updated by "Cleanup translation files" hook in Weblate.

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/
2023-06-01 23:07:30 +00:00
joemama
aa519f3c67 Translated using Weblate (Chinese (Simplified))
Currently translated at 95.7% (134 of 140 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/zh_Hans/
2023-06-01 23:07:30 +00:00
Avery
99f06b0322 Translated using Weblate (Spanish)
Currently translated at 100.0% (140 of 140 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/es/
2023-06-01 23:07:30 +00:00
Robin
e67290550c Merge pull request #1037 from vector-im/SimonBrandner/feat/settings
Settings improvements
2023-05-25 22:28:26 -04:00
Robin Townsend
3c118f0cf7 Add a comment 2023-05-22 15:44:39 -04:00
Robin Townsend
9c2f4be17c Bring back the rageshake request modal 2023-05-22 15:30:29 -04:00
Robin Townsend
dc8d0fd81b Update strings 2023-05-22 15:12:41 -04:00
Robin Townsend
ae40dea7ec Make the profile form autosave 2023-05-22 15:12:41 -04:00
Robin Townsend
85380c8142 Make width of profile tab conform to designs 2023-05-22 15:12:41 -04:00
Robin Townsend
6560d9eb1a Make remove avatar button target area larger 2023-05-22 15:12:41 -04:00
Robin Townsend
69099772e0 Make settings button icon size match designs 2023-05-22 15:12:41 -04:00
Robin Townsend
cf1a7f2e21 Match settings modal to design nuances better 2023-05-22 15:12:41 -04:00
Robin Townsend
eeb1f4baaf Merge branch 'main' into SimonBrandner/feat/settings 2023-05-22 12:49:57 -04:00
Robin
568c989ff7 Merge pull request #1069 from robintown/hide-rageshake-request
Hide the rageshake request modal when the window is too small
2023-05-22 09:21:30 -04:00
Michael Kaye
8451296f3a Merge pull request #1052 from vector-im/michaelk/test_hanging_up_call
Add ability to explicitly hang up the call
2023-05-18 18:32:38 +01:00
Robin
606358c51b Merge pull request #1070 from RiotTranslateBot/weblate-element-call-element-call
Translations update from Weblate
2023-05-17 23:33:38 -04:00
raspin0
fd0956bbc5 Translated using Weblate (Polish)
Currently translated at 100.0% (140 of 140 strings)

Translation: Element Call/element-call
Translate-URL: https://translate.element.io/projects/element-call/element-call/pl/
2023-05-17 22:34:03 +00:00
Robin Townsend
c01e363639 Hide the rageshake request modal when the window is too small 2023-05-17 13:59:15 -04:00
Enrico Schwendig
0114db7d2d update matrix-js-sdk (#1067) 2023-05-17 18:00:37 +02:00
Robin
e93dfb54d2 Merge pull request #1065 from robintown/resist-fingerprinting
Make Element Call work in Firefox's resist fingerprinting mode
2023-05-17 10:32:00 -04:00
Robin Townsend
f1ee3604de Make Element Call work in Firefox's resist fingerprinting mode
This one is gonna take some explaining:

When in resist fingerprinting mode, Firefox exhibits some funny behavior: when we ask for the the list of media devices, it gives us fake device IDs. But when the js-sdk requests a stream for any of those devices, Firefox associates the stream with the real device ID.

Now, in order to get the names of devices included in their metadata when you query the device list, you need to be holding a stream. For this reason, useMediaHandler was set up to reload the device list whenever matrix-js-sdk got a new local stream. But because of the inconsistency in device IDs, it would enter an infinite cycle telling matrix-js-sdk to request a stream for the fake device ID, but with matrix-js-sdk always responding with the real device ID.

I already wasn't happy with useMediaHandler's use of @ts-ignore comments to inspect private js-sdk fields, and in the meantime we've come up with a simpler function for requesting device names, so I decided to refactor useMediaHandler to use it instead. Importantly, it doesn't break in resist fingerprinting mode.

This created a new UX issue though: now, when on the lobby screen, useMediaHandler would request microphone access so it could get device names, followed immediately by a *second* pop-up for the lobby screen to request camera access. That's 1 pop-up too many, so I changed useMediaHandler to only request device names when a component is mounted that actually wants to show them. Currently, the settings modal is the only such component, and users normally only open it *after* granting full audio/video access, so this solution works out quite nicely.
2023-05-15 23:13:18 -04:00
Michael Kaye
d270756443 Useful to be able to hang up rather than close the window. 2023-05-12 16:25:24 +01:00
Šimon Brandner
6cad89b20c Add success message
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2023-05-05 19:36:23 +02:00
Šimon Brandner
4c1168aaf7 Feedback copy
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2023-05-05 19:29:11 +02:00
Šimon Brandner
57e79862a5 User ID -> Username
Co-authored-by: Robin <robin@robin.town>
2023-05-05 19:18:34 +02:00
Šimon Brandner
93a47e7009 Fix casing
Co-authored-by: Robin <robin@robin.town>
2023-05-05 19:17:59 +02:00
Šimon Brandner
d4f0300c82 Match designs
Co-authored-by: Robin <robin@robin.town>
2023-05-05 19:17:49 +02:00
Šimon Brandner
f11e1fac6b i18n
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2023-05-05 12:04:48 +02:00
Šimon Brandner
0269753f59 Settings improvements
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2023-05-05 11:44:35 +02:00
90 changed files with 3035 additions and 1634 deletions

88
.github/workflows/netlify-livekit.yaml vendored Normal file
View File

@@ -0,0 +1,88 @@
name: Netlify LiveKit Experiment
on:
workflow_run:
workflows: ["Build"]
types:
- completed
branches:
- "livekit-experiment"
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
deployments: write
# Important: the 'branches' filter above will match the 'livekit-experiment' branch on forks,
# so we need to check the head repo too in order to not run on PRs from forks
# We check the branch name again too just for completeness
# (Is there a nicer way to see if a PR is from a fork?)
if: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_repository.full_name == 'vector-im/element-call' && github.event.workflow_run.head_branch == 'livekit-experiment'
steps:
- name: Create Deployment
uses: bobheadxi/deployments@v1
id: deployment
with:
step: start
token: ${{ secrets.GITHUB_TOKEN }}
env: livekit-experiment-branch-cd
ref: ${{ github.event.workflow_run.head_sha }}
- name: "Download artifact"
uses: actions/github-script@v3.1.0
with:
script: |
const artifacts = await github.actions.listWorkflowRunArtifacts({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: ${{ github.event.workflow_run.id }},
});
const matchArtifact = artifacts.data.artifacts.filter((artifact) => {
return artifact.name == "build"
})[0];
const download = await github.actions.downloadArtifact({
owner: context.repo.owner,
repo: context.repo.repo,
artifact_id: matchArtifact.id,
archive_format: 'zip',
});
const fs = require('fs');
fs.writeFileSync('${{github.workspace}}/build.zip', Buffer.from(download.data));
- name: Extract Artifacts
run: unzip -d dist build.zip && rm build.zip
- name: Add redirects file
# We fetch from github directly as we don't bother checking out the repo
run: curl -s https://raw.githubusercontent.com/vector-im/element-call/livekit-experiment/config/netlify_redirects > dist/_redirects
- name: Add config file
run: curl -s https://raw.githubusercontent.com/vector-im/element-call/livekit-experiment/config/element_io_preview.json > dist/config.json
- name: Deploy to Netlify
id: netlify
uses: nwtgck/actions-netlify@v1.2.3
with:
publish-dir: dist
deploy-message: "Deploy from GitHub Actions"
production-branch: livekit-experiment
production-deploy: true
# These don't work because we're in workflow_run
enable-pull-request-comment: false
enable-commit-comment: false
github-deployment-environment: livekit-experiment
env:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: e3b9fa82-c040-4db6-b4bf-42b524d57423
timeout-minutes: 1
- name: Update deployment status
uses: bobheadxi/deployments@v1
if: always()
with:
step: finish
override: false
token: ${{ secrets.GITHUB_TOKEN }}
status: ${{ job.status }}
env: ${{ steps.deployment.outputs.env }}
deployment_id: ${{ steps.deployment.outputs.deployment_id }}
env_url: ${{ steps.netlify.outputs.deploy-url }}

View File

@@ -1,6 +1,8 @@
name: Run jest tests
on:
pull_request: {}
push:
branches: [livekit]
jobs:
jest:
name: Run jest tests
@@ -16,3 +18,7 @@ jobs:
run: "yarn install"
- name: Jest
run: "yarn run test"
- name: Upload to codecov
uses: codecov/codecov-action@v3
with:
flags: unittests

View File

@@ -53,7 +53,7 @@
"i18next-browser-languagedetector": "^6.1.8",
"i18next-http-backend": "^1.4.4",
"lodash": "^4.17.21",
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#fcbc195fbe4170251b87f03a69c8dc5bfccfd5ac",
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#f884c78579c336a03bc20ff8f4e92c46582822b6",
"matrix-widget-api": "^1.3.1",
"mermaid": "^8.13.8",
"normalize.css": "^8.0.1",
@@ -118,6 +118,11 @@
"\\.(css|less|svg)+$": "identity-obj-proxy",
"^\\./IndexedDBWorker\\?worker$": "<rootDir>/test/mocks/workerMock.ts",
"^\\./olm$": "<rootDir>/test/mocks/olmMock.ts"
}
},
"collectCoverage": true,
"coverageReporters": [
"text",
"cobertura"
]
}
}

View File

@@ -23,7 +23,6 @@
"Create account": "Създай акаунт",
"Debug log": "Debug логове",
"Debug log request": "Заявка за debug логове",
"Description (optional)": "Описание (незадължително)",
"Details": "Детайли",
"Developer": "Разработчик",
"Display name": "Име/псевдоним",
@@ -34,7 +33,6 @@
"Full screen": "Цял екран",
"Go": "Напред",
"Grid layout menu": "Меню \"решетков изглед\"",
"Having trouble? Help us fix it.": "Имате проблем? Помогнете да го поправим.",
"Home": "Начало",
"Include debug logs": "Включи debug логове",
"Incompatible versions": "Несъвместими версии",
@@ -55,7 +53,6 @@
"Microphone permissions needed to join the call.": "Необходими са разрешения за микрофона за да можете да се присъедините в разговора.",
"Microphone {{n}}": "Микрофон {{n}}",
"More": "Още",
"More menu": "Мено \"още\"",
"Mute microphone": "Заглуши микрофона",
"No": "Не",
"Not now, return to home screen": "Не сега, върни се на началния екран",
@@ -76,8 +73,6 @@
"Release to stop": "Отпуснете за да спрете",
"Remove": "Премахни",
"Return to home screen": "Връщане на началния екран",
"Save": "Запази",
"Saving…": "Запазване…",
"Select an option": "Изберете опция",
"Send debug logs": "Изпратете debug логове",
"Sending…": "Изпращане…",
@@ -92,7 +87,6 @@
"Spotlight": "Прожектор",
"Stop sharing screen": "Спри споделянето на екрана",
"Submit feedback": "Изпрати обратна връзка",
"Submitting feedback…": "Изпращане на обратна връзка…",
"Take me Home": "Отиди в Начало",
"Talk over speaker": "Говорете заедно с говорителя",
"Talking…": "Говорене…",
@@ -103,7 +97,6 @@
"Turn off camera": "Изключи камерата",
"Turn on camera": "Включи камерата",
"Unmute microphone": "Включи микрофона",
"User ID": "Потребителски идентификатор",
"User menu": "Потребителско меню",
"Username": "Потребителско име",
"Version: {{version}}": "Версия: {{version}}",
@@ -120,7 +113,6 @@
"Your recent calls": "Скорошните ви разговори",
"{{count}} people connected|one": "{{count}} човек се свърза",
"{{count}} people connected|other": "{{count}} човека се звързаха",
"{{displayName}}, your call is now ended": "{{displayName}}, разговорът ви приключи",
"{{names}}, {{name}}": "{{names}}, {{name}}",
"{{name}} is presenting": "{{name}} презентира",
"{{name}} is talking…": "{{name}} говори…",

View File

@@ -26,14 +26,12 @@
"Version: {{version}}": "Verze: {{version}}",
"Username": "Uživatelské jméno",
"User menu": "Uživatelské menu",
"User ID": "ID uživatele",
"Unmute microphone": "Zapnout mikrofon",
"Turn on camera": "Zapnout kameru",
"Turn off camera": "Vypnout kameru",
"This call already exists, would you like to join?": "Tento hovor již existuje, chcete se připojit?",
"Thanks! We'll get right on it.": "Děkujeme! Hned se na to vrhneme.",
"Take me Home": "Domovská obrazovka",
"Submitting feedback…": "Odesílání zpětné vazby…",
"Submit feedback": "Dát feedback",
"Stop sharing screen": "Zastavit sdílení obrazovek",
"Speaker {{n}}": "Reproduktor {{n}}",
@@ -48,8 +46,6 @@
"Sending debug logs…": "Posílání ladícího záznamu…",
"Send debug logs": "Poslat ladící záznam",
"Select an option": "Vyberte možnost",
"Saving…": "Ukládání…",
"Save": "Uložit",
"Return to home screen": "Vrátit se na domácí obrazovku",
"Remove": "Odstranit",
"Registering…": "Registrování…",
@@ -66,7 +62,7 @@
"Microphone permissions needed to join the call.": "Přístup k mikrofonu je nutný pro připojení se k hovoru.",
"Microphone {{n}}": "Mikrofon {{n}}",
"Microphone": "Mikrofon",
"Login to your account": "Přihlásit se ke svůmu účtu",
"Login to your account": "Přihlásit se ke svému účtu",
"Login": "Přihlášení",
"Logging in…": "Přihlašování se…",
"Local volume": "Lokální hlasitost",
@@ -82,7 +78,6 @@
"{{name}} is talking…": "{{name}} mluví…",
"{{name}} is presenting": "{{name}} prezentuje",
"{{name}} (Connecting...)": "{{name}} (Připojení...)",
"{{displayName}}, your call is now ended": "{{displayName}}, váš hovor je nyní ukončen",
"{{count}} people connected|other": "{{count}} lidí připojeno",
"{{count}} people connected|one": "{{count}} lidí připojeno",
"This will make a speaker's audio seem as if it is coming from where their tile is positioned on screen. (Experimental feature: this may impact the stability of audio.)": "Tato možnost způsobí, že zvuk účastníků hovoru se bude tvářit jako by přicházel z místa, kde jsou umístěni na obrazovce.(Experimentální možnost: může způsobit nestabilitu audia.)",
@@ -102,11 +97,9 @@
"Press and hold spacebar to talk over {{name}}": "Zmáčkněte a držte mezerník, abyste mluvili přes {{name}}",
"Other users are trying to join this call from incompatible versions. These users should ensure that they have refreshed their browsers:<1>{userLis}</1>": "Ostatní uživatelé se pokoušejí připojit k tomuto hovoru s nekompatibilních verzí. Tito uživatelé by se měli ujistit, že stránku načetli znovu:<1>{userLis}</1>",
"Not registered yet? <2>Create an account</2>": "Nejste registrovaní? <2>Vytvořit účet</2>",
"More menu": "Další možnosti",
"Join existing call?": "Připojit se k existujícimu hovoru?",
"Include debug logs": "Zahrnout ladící záznamy",
"Home": "Domov",
"Having trouble? Help us fix it.": "Máte problémy? Pomozte nám je spravit.",
"Grid layout menu": "Menu rozložení",
"Go": "Pokračovat",
"Full screen": "Zvětšit na celou obrazovku",
@@ -118,7 +111,6 @@
"Display name": "Zobrazované jméno",
"Developer": "Vývojář",
"Details": "Detaily",
"Description (optional)": "Popis (nepovinný)",
"Debug log request": "Žádost o protokoly ladění",
"Debug log": "Protokoly ladění",
"Create account": "Vytvořit účet",

View File

@@ -23,7 +23,6 @@
"Create account": "Konto erstellen",
"Debug log": "Debug-Protokoll",
"Debug log request": "Debug-Log Anfrage",
"Description (optional)": "Beschreibung (optional)",
"Details": "Details",
"Developer": "Entwickler",
"Display name": "Anzeigename",
@@ -33,7 +32,6 @@
"Full screen": "Vollbild",
"Go": "Los gehts",
"Grid layout menu": "Grid-Layout-Menü",
"Having trouble? Help us fix it.": "Du hast ein Problem? Hilf uns, es zu beheben.",
"Home": "Startseite",
"Include debug logs": "Debug-Protokolle einschließen",
"Incompatible versions": "Inkompatible Versionen",
@@ -54,7 +52,6 @@
"Microphone permissions needed to join the call.": "Mikrofon-Berechtigung ist erforderlich, um dem Anruf beizutreten.",
"Microphone {{n}}": "Mikrofon {{n}}",
"More": "Mehr",
"More menu": "Weiteres Menü",
"Mute microphone": "Mikrofon stummschalten",
"No": "Nein",
"Not now, return to home screen": "Nicht jetzt, zurück zum Startbildschirm",
@@ -75,8 +72,6 @@
"Release to stop": "Loslassen zum Stoppen",
"Remove": "Entfernen",
"Return to home screen": "Zurück zum Startbildschirm",
"Save": "Speichern",
"Saving…": "Speichere …",
"Select an option": "Wähle eine Option",
"Send debug logs": "Debug-Logs senden",
"Sending…": "Senden …",
@@ -91,7 +86,6 @@
"Spotlight": "Rampenlicht",
"Stop sharing screen": "Beenden der Bildschirmfreigabe",
"Submit feedback": "Rückmeldung geben",
"Submitting feedback…": "Sende Rückmeldung …",
"Take me Home": "Zurück zur Startseite",
"Talk over speaker": "Aktiven Sprecher verdrängen und sprechen",
"Talking…": "Sprechen …",
@@ -102,7 +96,6 @@
"Turn off camera": "Kamera ausschalten",
"Turn on camera": "Kamera einschalten",
"Unmute microphone": "Mikrofon aktivieren",
"User ID": "Benutzer-ID",
"User menu": "Benutzermenü",
"Username": "Benutzername",
"Version: {{version}}": "Version: {{version}}",
@@ -118,7 +111,6 @@
"Your recent calls": "Deine letzten Anrufe",
"{{count}} people connected|one": "{{count}} Person verbunden",
"{{count}} people connected|other": "{{count}} Personen verbunden",
"{{displayName}}, your call is now ended": "{{displayName}}, dein Anruf wurde beendet",
"{{names}}, {{name}}": "{{names}}, {{name}}",
"{{name}} is presenting": "{{name}} präsentiert",
"{{name}} is talking…": "{{name}} spricht …",
@@ -138,5 +130,17 @@
"Expose developer settings in the settings window.": "Zeige die Entwicklereinstellungen im Einstellungsfenster.",
"Developer Settings": "Entwicklereinstellungen",
"By participating in this beta, you consent to the collection of anonymous data, which we use to improve the product. You can find more information about which data we track in our <2>Privacy Policy</2> and our <5>Cookie Policy</5>.": "Mit der Teilnahme an der Beta akzeptierst du die Sammlung von anonymen Daten, die wir zur Verbesserung des Produkts verwenden. Weitere Informationen zu den von uns erhobenen Daten findest du in unserer <2>Datenschutzerklärung</2> und unseren <5>Cookie-Richtlinien</5>.",
"<0></0><1></1>You may withdraw consent by unchecking this box. If you are currently in a call, this setting will take effect at the end of the call.": "<0></0><1></1>Du kannst deine Zustimmung durch Abwählen dieses Kästchens zurückziehen. Falls du dich aktuell in einem Anruf befindest, wird diese Einstellung nach dem Ende des Anrufs wirksam."
"<0></0><1></1>You may withdraw consent by unchecking this box. If you are currently in a call, this setting will take effect at the end of the call.": "<0></0><1></1>Du kannst deine Zustimmung durch Abwählen dieses Kästchens zurückziehen. Falls du dich aktuell in einem Anruf befindest, wird diese Einstellung nach dem Ende des Anrufs wirksam.",
"Feedback": "Rückmeldung",
"If you are experiencing issues or simply would like to provide some feedback, please send us a short description below.": "Falls du auf Probleme stößt oder einfach nur eine Rückmeldung geben möchtest, sende uns bitte eine kurze Beschreibung.",
"Your feedback": "Deine Rückmeldung",
"Thanks, we received your feedback!": "Danke, wir haben deine Rückmeldung erhalten!",
"Submitting…": "Sende …",
"Submit": "Absenden",
"{{count}} stars|other": "{{count}} Sterne",
"{{displayName}}, your call has ended.": "{{displayName}}, dein Anruf wurde beendet.",
"<0>We'd love to hear your feedback so we can improve your experience.</0>": "<0>Wir würden uns freuen, deine Rückmeldung zu hören, um deine Erfahrung verbessern zu können.</0>",
"How did it go?": "Wie ist es gelaufen?",
"{{count}} stars|one": "{{count}} Stern",
"<0>Thanks for your feedback!</0>": "<0>Danke für deine Rückmeldung!</0>"
}

View File

@@ -9,7 +9,6 @@
"Share screen": "Κοινή χρήση οθόνης",
"Sending…": "Αποστολή…",
"Select an option": "Επιλέξτε μια επιλογή",
"Saving…": "Αποθήκευση…",
"Remove": "Αφαίρεση",
"Registering…": "Εγγραφή…",
"Press and hold to talk": "Πατήστε παρατεταμένα για να μιλήσετε",
@@ -52,7 +51,6 @@
"Spatial audio": "Χωρικός ήχος",
"Sign out": "Αποσύνδεση",
"Settings": "Ρυθμίσεις",
"Save": "Αποθήκευση",
"Return to home screen": "Επιστροφή στην αρχική οθόνη",
"Register": "Εγγραφή",
"Profile": "Προφίλ",
@@ -62,7 +60,6 @@
"Not now, return to home screen": "Όχι τώρα, επιστροφή στην αρχική οθόνη",
"No": "Όχι",
"Mute microphone": "Σίγαση μικροφώνου",
"More menu": "Μενού περισσότερα",
"More": "Περισσότερα",
"Microphone permissions needed to join the call.": "Απαιτούνται δικαιώματα μικροφώνου για συμμετοχή στην κλήση.",
"Microphone {{n}}": "Μικρόφωνο {{n}}",
@@ -77,7 +74,6 @@
"Full screen": "Πλήρη οθόνη",
"Exit full screen": "Έξοδος από πλήρη οθόνη",
"Details": "Λεπτομέρειες",
"Description (optional)": "Περιγραφή (προαιρετική)",
"Create account": "Δημιουργία λογαριασμού",
"Copy and share this call link": "Αντιγράψτε και μοιραστείτε αυτόν τον σύνδεσμο κλήσης",
"Copy": "Αντιγραφή",
@@ -94,7 +90,6 @@
"{{name}} is presenting": "{{name}} παρουσιάζει",
"{{name}} (Waiting for video...)": "{{name}} (Αναμονή για βίντεο...)",
"{{name}} (Connecting...)": "{{name}} (Συνδέεται...)",
"{{displayName}}, your call is now ended": "{{displayName}}, η κλήση σας τερματίστηκε",
"{{count}} people connected|other": "{{count}} άτομα συνδεδεμένα",
"{{count}} people connected|one": "{{count}} άτομο συνδεδεμένο"
}

View File

@@ -1,7 +1,9 @@
{
"{{count}} people connected|one": "{{count}} person connected",
"{{count}} people connected|other": "{{count}} people connected",
"{{displayName}}, your call is now ended": "{{displayName}}, your call is now ended",
"{{count}} stars|one": "{{count}} star",
"{{count}} stars|other": "{{count}} stars",
"{{displayName}}, your call has ended.": "{{displayName}}, your call has ended.",
"{{name}} (Connecting...)": "{{name}} (Connecting...)",
"{{name}} (Waiting for video...)": "{{name}} (Waiting for video...)",
"{{name}} is presenting": "{{name}} is presenting",
@@ -14,6 +16,8 @@
"<0>Join call now</0><1>Or</1><2>Copy call link and join later</2>": "<0>Join call now</0><1>Or</1><2>Copy call link and join later</2>",
"<0>Oops, something's gone wrong.</0>": "<0>Oops, something's gone wrong.</0>",
"<0>Submitting debug logs will help us track down the problem.</0>": "<0>Submitting debug logs will help us track down the problem.</0>",
"<0>Thanks for your feedback!</0>": "<0>Thanks for your feedback!</0>",
"<0>We'd love to hear your feedback so we can improve your experience.</0>": "<0>We'd love to hear your feedback so we can improve your experience.</0>",
"<0>Why not finish by setting up a password to keep your account?</0><1>You'll be able to keep your name and set an avatar for use on future calls</1>": "<0>Why not finish by setting up a password to keep your account?</0><1>You'll be able to keep your name and set an avatar for use on future calls</1>",
"Accept camera/microphone permissions to join the call.": "Accept camera/microphone permissions to join the call.",
"Accept microphone permissions to join the call.": "Accept microphone permissions to join the call.",
@@ -32,13 +36,13 @@
"Close": "Close",
"Confirm password": "Confirm password",
"Connection lost": "Connection lost",
"Connectivity to the server has been lost.": "Connectivity to the server has been lost.",
"Copied!": "Copied!",
"Copy": "Copy",
"Copy and share this call link": "Copy and share this call link",
"Create account": "Create account",
"Debug log": "Debug log",
"Debug log request": "Debug log request",
"Description (optional)": "Description (optional)",
"Details": "Details",
"Developer": "Developer",
"Developer Settings": "Developer Settings",
@@ -47,13 +51,15 @@
"Element Call Home": "Element Call Home",
"Exit full screen": "Exit full screen",
"Expose developer settings in the settings window.": "Expose developer settings in the settings window.",
"Feedback": "Feedback",
"Fetching group call timed out.": "Fetching group call timed out.",
"Freedom": "Freedom",
"Full screen": "Full screen",
"Go": "Go",
"Grid layout menu": "Grid layout menu",
"Having trouble? Help us fix it.": "Having trouble? Help us fix it.",
"Home": "Home",
"How did it go?": "How did it go?",
"If you are experiencing issues or simply would like to provide some feedback, please send us a short description below.": "If you are experiencing issues or simply would like to provide some feedback, please send us a short description below.",
"Include debug logs": "Include debug logs",
"Incompatible versions": "Incompatible versions",
"Incompatible versions!": "Incompatible versions!",
@@ -73,7 +79,6 @@
"Microphone {{n}}": "Microphone {{n}}",
"Microphone permissions needed to join the call.": "Microphone permissions needed to join the call.",
"More": "More",
"More menu": "More menu",
"Mute microphone": "Mute microphone",
"No": "No",
"Not now, return to home screen": "Not now, return to home screen",
@@ -94,8 +99,6 @@
"Release to stop": "Release to stop",
"Remove": "Remove",
"Return to home screen": "Return to home screen",
"Save": "Save",
"Saving…": "Saving…",
"Select an option": "Select an option",
"Send debug logs": "Send debug logs",
"Sending debug logs…": "Sending debug logs…",
@@ -110,11 +113,13 @@
"Speaker {{n}}": "Speaker {{n}}",
"Spotlight": "Spotlight",
"Stop sharing screen": "Stop sharing screen",
"Submit": "Submit",
"Submit feedback": "Submit feedback",
"Submitting feedback…": "Submitting feedback…",
"Submitting…": "Submitting…",
"Take me Home": "Take me Home",
"Talk over speaker": "Talk over speaker",
"Talking…": "Talking…",
"Thanks, we received your feedback!": "Thanks, we received your feedback!",
"Thanks! We'll get right on it.": "Thanks! We'll get right on it.",
"This call already exists, would you like to join?": "This call already exists, would you like to join?",
"This feature is only supported on Firefox.": "This feature is only supported on Firefox.",
@@ -124,7 +129,6 @@
"Turn on camera": "Turn on camera",
"Unmute microphone": "Unmute microphone",
"Use the upcoming grid system": "Use the upcoming grid system",
"User ID": "User ID",
"User menu": "User menu",
"Username": "Username",
"Version: {{version}}": "Version: {{version}}",
@@ -138,5 +142,6 @@
"WebRTC is not supported or is being blocked in this browser.": "WebRTC is not supported or is being blocked in this browser.",
"Yes, join call": "Yes, join call",
"You can't talk at the same time": "You can't talk at the same time",
"Your feedback": "Your feedback",
"Your recent calls": "Your recent calls"
}

View File

@@ -23,7 +23,6 @@
"Version: {{version}}": "Versión: {{version}}",
"Username": "Nombre de usuario",
"User menu": "Menú de usuario",
"User ID": "ID de usuario",
"Unmute microphone": "Desilenciar el micrófono",
"Turn on camera": "Encender la cámara",
"Turn off camera": "Apagar la cámara",
@@ -33,7 +32,6 @@
"Talking…": "Hablando…",
"Talk over speaker": "Hablar por encima",
"Take me Home": "Volver al inicio",
"Submitting feedback…": "Enviando comentarios…",
"Submit feedback": "Enviar comentarios",
"Stop sharing screen": "Dejar de compartir pantalla",
"Spotlight": "Foco",
@@ -49,8 +47,6 @@
"Sending debug logs…": "Enviando registros de depuración…",
"Send debug logs": "Enviar registros de depuración",
"Select an option": "Selecciona una opción",
"Saving…": "Guardando…",
"Save": "Guardar",
"Return to home screen": "Volver a la pantalla de inicio",
"Remove": "Eliminar",
"Release to stop": "Suelta para parar",
@@ -68,7 +64,6 @@
"Not now, return to home screen": "Ahora no, volver a la pantalla de inicio",
"No": "No",
"Mute microphone": "Silenciar micrófono",
"More menu": "Menú Más",
"More": "Más",
"Microphone permissions needed to join the call.": "Se necesitan permisos del micrófono para unirse a la llamada.",
"Microphone {{n}}": "Micrófono {{n}}",
@@ -88,7 +83,6 @@
"Incompatible versions": "Versiones incompatibles",
"Include debug logs": "Incluir registros de depuración",
"Home": "Inicio",
"Having trouble? Help us fix it.": "¿Tienes problemas? Ayúdanos a resolverlos.",
"Grid layout menu": "Menú de distribución de cuadrícula",
"Go": "Comenzar",
"Full screen": "Pantalla completa",
@@ -99,7 +93,6 @@
"Display name": "Nombre a mostrar",
"Developer": "Desarrollador",
"Details": "Detalles",
"Description (optional)": "Descripción (opcional)",
"Debug log request": "Petición de registros de depuración",
"Debug log": "Registro de depuración",
"Create account": "Crear cuenta",
@@ -125,7 +118,6 @@
"{{name}} is talking…": "{{name}} está hablando…",
"{{name}} is presenting": "{{name}} está presentando",
"{{name}} (Connecting...)": "{{name}} (Conectando...)",
"{{displayName}}, your call is now ended": "{{displayName}}, tu llamada ha finalizado",
"{{count}} people connected|other": "{{count}} personas conectadas",
"{{count}} people connected|one": "{{count}} persona conectada",
"Element Call Home": "Inicio de Element Call",
@@ -135,5 +127,8 @@
"<0>Submitting debug logs will help us track down the problem.</0>": "<0>Subir los registros de depuración nos ayudará a encontrar el problema.</0>",
"<0>Oops, something's gone wrong.</0>": "<0>Ups, algo ha salido mal.</0>",
"Expose developer settings in the settings window.": "Muestra los ajustes de desarrollador en la ventana de ajustes.",
"Developer Settings": "Ajustes de desarrollador"
"Developer Settings": "Ajustes de desarrollador",
"Use the upcoming grid system": "Utilizar el próximo sistema de cuadrícula",
"By participating in this beta, you consent to the collection of anonymous data, which we use to improve the product. You can find more information about which data we track in our <2>Privacy Policy</2> and our <5>Cookie Policy</5>.": "Al participar en esta beta, consientes a la recogida de datos anónimos, los cuales usaremos para mejorar el producto. Puedes encontrar más información sobre que datos recogemos en nuestra <2>Política de privacidad</2> y en nuestra <5>Política sobre Cookies</5>.",
"<0></0><1></1>You may withdraw consent by unchecking this box. If you are currently in a call, this setting will take effect at the end of the call.": "<0></0><1></1>Puedes retirar tu consentimiento desmarcando esta casilla. Si estás en una llamada, este ajuste se aplicará al final de esta."
}

View File

@@ -10,7 +10,6 @@
"{{name}} is talking…": "{{nimi}} räägib…",
"{{name}} is presenting": "{{nimi}} esitab",
"{{name}} (Connecting...)": "{{nimi}} (ühendamisel...)",
"{{displayName}}, your call is now ended": "{{displayName}}, sinu kõne on nüüd lõppenud",
"{{count}} people connected|other": "{{count}} osalejat liitunud",
"{{count}} people connected|one": "{{count}} osaleja liitunud",
"Invite people": "Kutsu inimesi",
@@ -20,7 +19,6 @@
"Incompatible versions": "Ühildumatud versioonid",
"Include debug logs": "Lisa veatuvastuslogid",
"Home": "Avavaatesse",
"Having trouble? Help us fix it.": "Kas on probleeme? Aita meil asja parandada.",
"Grid layout menu": "Ruudustikvaate menüü",
"Go": "Jätka",
"Full screen": "Täisekraan",
@@ -31,7 +29,6 @@
"Display name": "Kuvatav nimi",
"Developer": "Arendaja",
"Details": "Täpsemalt",
"Description (optional)": "Kirjeldus (valikuline)",
"Debug log request": "Veaotsingulogi päring",
"Debug log": "Veaotsingulogi",
"Create account": "Loo konto",
@@ -60,7 +57,6 @@
"Mute microphone": "Summuta mikrofon",
"Your recent calls": "Hiljutised kõned",
"You can't talk at the same time": "Üheaegselt ei saa rääkida",
"More menu": "Rohkem valikuid",
"More": "Rohkem",
"Microphone permissions needed to join the call.": "Kõnega liitumiseks on vaja lubada mikrofoni kasutamine.",
"Microphone {{n}}": "Mikrofon {{n}}",
@@ -74,10 +70,8 @@
"Join existing call?": "Liitu juba käimasoleva kõnega?",
"Join call now": "Kõnega liitumine",
"Join call": "Kõnega liitumine",
"User ID": "Kasutajatunnus",
"Turn on camera": "Lülita kaamera sisse",
"Turn off camera": "Lülita kaamera välja",
"Submitting feedback…": "Tagasiside saatmine…",
"Take me Home": "Mine avalehele",
"Submit feedback": "Jaga tagasisidet",
"Stop sharing screen": "Lõpeta ekraani jagamine",
@@ -94,8 +88,6 @@
"Sending debug logs…": "Veaotsingulogide saatmine…",
"Send debug logs": "Saada veaotsingulogid",
"Select an option": "Vali oma eelistus",
"Saving…": "Salvestamine…",
"Save": "Salvesta",
"Return to home screen": "Tagasi avalehele",
"Remove": "Eemalda",
"Release to stop": "Peatamiseks vabasta klahv",
@@ -138,5 +130,17 @@
"Expose developer settings in the settings window.": "Näita seadistuste aknas arendajale vajalikke seadeid.",
"Developer Settings": "Arendaja seadistused",
"By participating in this beta, you consent to the collection of anonymous data, which we use to improve the product. You can find more information about which data we track in our <2>Privacy Policy</2> and our <5>Cookie Policy</5>.": "Nõustudes selle beetaversiooni kasutamisega sa nõustud ka toote arendamiseks kasutatavate anonüümsete andmete kogumisega. Täpsemat teavet kogutavate andmete kohta leiad meie <2>Privaatsuspoliitikast</2> ja meie <5>Küpsiste kasutamise reeglitest</5>.",
"<0></0><1></1>You may withdraw consent by unchecking this box. If you are currently in a call, this setting will take effect at the end of the call.": "<0></0><1></1>Sa võid selle valiku eelmaldamisega alati oma nõusoleku tagasi võtta. Kui sul parasjagu on kõne pooleli, siis seadistuste muudatus jõustub pärast kõne lõppu."
"<0></0><1></1>You may withdraw consent by unchecking this box. If you are currently in a call, this setting will take effect at the end of the call.": "<0></0><1></1>Sa võid selle valiku eelmaldamisega alati oma nõusoleku tagasi võtta. Kui sul parasjagu on kõne pooleli, siis seadistuste muudatus jõustub pärast kõne lõppu.",
"Your feedback": "Sinu tagasiside",
"Thanks, we received your feedback!": "Tänud, me oleme sinu tagasiside kätte saanud!",
"Submitting…": "Saadan…",
"Submit": "Saada",
"If you are experiencing issues or simply would like to provide some feedback, please send us a short description below.": "Kui selle rakenduse kasutamisel tekib sul probleeme või lihtsalt soovid oma arvamust avaldada, siis palun täida alljärgnev lühike kirjeldus.",
"Feedback": "Tagasiside",
"{{count}} stars|one": "{{count}} tärn",
"{{count}} stars|other": "{{count}} tärni",
"How did it go?": "Kuidas sujus?",
"{{displayName}}, your call has ended.": "{{displayName}}, sinu kõne on lõppenud.",
"<0>Thanks for your feedback!</0>": "<0>Täname Sind tagasiside eest!</0>",
"<0>We'd love to hear your feedback so we can improve your experience.</0>": "<0>Meie rakenduse paremaks muutmiseks me hea meelega ootame Sinu arvamusi.</0>"
}

View File

@@ -3,7 +3,6 @@
"Video call": "تماس تصویری",
"Video": "ویدیو",
"Username": "نام کاربری",
"User ID": "آی دی کاربر",
"Turn on camera": "روشن کردن دوربین",
"Turn off camera": "خاموش کردن دوربین",
"Take me Home": "مرا به خانه ببر",
@@ -11,7 +10,6 @@
"Sign out": "خروج",
"Sign in": "ورود",
"Settings": "تنظیمات",
"Save": "ذخیره",
"Profile": "پروفایل",
"Password": "رمز عبور",
"No": "خیر",
@@ -36,7 +34,6 @@
"Display name": "نام نمایشی",
"Developer": "توسعه دهنده",
"Details": "جزئیات",
"Description (optional)": "توضیحات (اختیاری)",
"Debug log request": "درخواست لاگ عیب‌یابی",
"Debug log": "لاگ عیب‌یابی",
"Create account": "ساخت حساب کاربری",
@@ -65,7 +62,6 @@
"{{roomName}} - Walkie-talkie call": "{{roomName}} - تماس واکی-تاکی",
"{{name}} is talking…": "{{name}} در حال صحبت است…",
"{{name}} is presenting": "{{name}} حاضر است",
"{{displayName}}, your call is now ended": "{{displayName}} تماس شما پایان یافت",
"{{count}} people connected|other": "{{count}} نفر متصل هستند",
"{{count}} people connected|one": "{{count}} فرد متصل هستند",
"Local volume": "حجم داخلی",
@@ -80,7 +76,6 @@
"Sending debug logs…": "در حال ارسال باگ‌های عیب‌یابی…",
"Send debug logs": "ارسال لاگ‌های عیب‌یابی",
"Select an option": "یک گزینه را انتخاب کنید",
"Saving…": "در حال ذخیره…",
"Return to home screen": "برگشت به صفحه اصلی",
"Remove": "حذف",
"Release to stop": "برای توقف رها کنید",
@@ -97,12 +92,10 @@
"Other users are trying to join this call from incompatible versions. These users should ensure that they have refreshed their browsers:<1>{userLis}</1>": "کاربران دیگر تلاش می‌کنند با ورژن‌های ناسازگار به مکالمه بپیوندند. این کاربران باید از بروزرسانی مرورگرشان اطمینان داشته باشند:<1>{userLis}</1>",
"Not registered yet? <2>Create an account</2>": "هنوز ثبت‌نام نکرده‌اید؟ <2>ساخت حساب کاربری</2>",
"Not now, return to home screen": "الان نه، به صفحه اصلی برگردید",
"More menu": "تنظیمات بیشتر",
"Microphone permissions needed to join the call.": "برای پیوستن به مکالمه دسترسی به میکروفون نیاز است.",
"Microphone {{n}}": "میکروفون {{n}}",
"Logging in…": "ورود…",
"Include debug logs": "شامل لاگ‌های عیب‌یابی",
"Having trouble? Help us fix it.": "با مشکلی رو به رو شدید؟ به ما کمک کنید رفعش کنیم.",
"Grid layout menu": "منوی طرح‌بندی شبکه‌ای",
"Fetching group call timed out.": "زمان اتصال به مکالمه گروهی تمام شد.",
"You can't talk at the same time": "نمی توانید هم‌زمان صحبت کنید",
@@ -122,7 +115,6 @@
"Thanks! We'll get right on it.": "با تشکر! ما به درستی آن را انجام خواهیم داد.",
"Talking…": "در حال صحبت کردن…",
"Talk over speaker": "روی بلندگو صحبت کنید",
"Submitting feedback…": "در حال ارسال بازخورد…",
"Submit feedback": "بازخورد ارائه دهید",
"Stop sharing screen": "توقف اشتراک‌گذاری صفحه نمایش",
"Spatial audio": "صدای جهت‌دار",

View File

@@ -22,7 +22,6 @@
"Create account": "Créer un compte",
"Debug log": "Journal de débogage",
"Debug log request": "Demande dun journal de débogage",
"Description (optional)": "Description (facultatif)",
"Details": "Informations",
"Developer": "Développeur",
"Display name": "Nom daffichage",
@@ -32,7 +31,6 @@
"Full screen": "Plein écran",
"Go": "Commencer",
"Grid layout menu": "Menu en grille",
"Having trouble? Help us fix it.": "Un problème ? Aidez nous à le résoudre.",
"Home": "Accueil",
"Include debug logs": "Inclure les journaux de débogage",
"Incompatible versions": "Versions incompatibles",
@@ -52,7 +50,6 @@
"Microphone permissions needed to join the call.": "Accès au microphone requis pour rejoindre lappel.",
"Microphone {{n}}": "Microphone {{n}}",
"More": "Plus",
"More menu": "Menu plus",
"Mute microphone": "Couper le micro",
"No": "Non",
"Not now, return to home screen": "Pas maintenant, retourner à laccueil",
@@ -73,8 +70,6 @@
"Release to stop": "Relâcher pour arrêter",
"Remove": "Supprimer",
"Return to home screen": "Retour à laccueil",
"Save": "Enregistrer",
"Saving…": "Enregistrement…",
"Select an option": "Sélectionnez une option",
"Send debug logs": "Envoyer les journaux de débogage",
"Sending…": "Envoi…",
@@ -87,7 +82,6 @@
"Spotlight": "Premier plan",
"Stop sharing screen": "Arrêter le partage décran",
"Submit feedback": "Envoyer des retours",
"Submitting feedback…": "Envoi des retours…",
"Take me Home": "Retouner à laccueil",
"Talk over speaker": "Parler par dessus lintervenant",
"Thanks! We'll get right on it.": "Merci ! Nous allons nous y attaquer.",
@@ -97,7 +91,6 @@
"{{roomName}} - Walkie-talkie call": "{{roomName}} — Appel talkie-walkie",
"{{name}} is talking…": "{{name}} est en train de parler…",
"{{names}}, {{name}}": "{{names}}, {{name}}",
"{{displayName}}, your call is now ended": "{{displayName}}, votre appel est désormais terminé",
"{{count}} people connected|other": "{{count}} personnes connectées",
"{{count}} people connected|one": "{{count}} personne connectée",
"Your recent calls": "Appels récents",
@@ -114,7 +107,6 @@
"Version: {{version}}": "Version : {{version}}",
"Username": "Nom dutilisateur",
"User menu": "Menu utilisateur",
"User ID": "Identifiant utilisateur",
"Unmute microphone": "Allumer le micro",
"Turn on camera": "Allumer la caméra",
"Turn off camera": "Couper la caméra",
@@ -138,5 +130,17 @@
"Expose developer settings in the settings window.": "Affiche les paramètres développeurs dans la fenêtre des paramètres.",
"Developer Settings": "Paramètres développeurs",
"By participating in this beta, you consent to the collection of anonymous data, which we use to improve the product. You can find more information about which data we track in our <2>Privacy Policy</2> and our <5>Cookie Policy</5>.": "En participant à cette beta, vous consentez à la collecte de données anonymes, qui seront utilisées pour améliorer le produit. Vous trouverez plus dinformations sur les données collectées dans notre <2>Politique de vie privée</2> et notre <5>Politique de cookies</5>.",
"<0></0><1></1>You may withdraw consent by unchecking this box. If you are currently in a call, this setting will take effect at the end of the call.": "<0></0><1></1>Vous pouvez retirer votre consentement en décochant cette case. Si vous êtes actuellement en communication, ce paramètre prendra effet à la fin de lappel."
"<0></0><1></1>You may withdraw consent by unchecking this box. If you are currently in a call, this setting will take effect at the end of the call.": "<0></0><1></1>Vous pouvez retirer votre consentement en décochant cette case. Si vous êtes actuellement en communication, ce paramètre prendra effet à la fin de lappel.",
"Your feedback": "Votre commentaire",
"Thanks, we received your feedback!": "Merci, nous avons reçu vos commentaires !",
"Submitting…": "Envoi…",
"Submit": "Envoyer",
"If you are experiencing issues or simply would like to provide some feedback, please send us a short description below.": "Si vous rencontrez des problèmes, ou vous voulez simplement faire un commentaire, veuillez nous envoyer une courte description ci-dessous.",
"Feedback": "Commentaires",
"{{count}} stars|other": "{{count}} favoris",
"<0>We'd love to hear your feedback so we can improve your experience.</0>": "<0>Nous aimerions avoir vos commentaires afin que nous puissions améliorer votre expérience.</0>",
"{{count}} stars|one": "{{count}} favori",
"{{displayName}}, your call has ended.": "{{displayName}}, votre appel est terminé.",
"<0>Thanks for your feedback!</0>": "<0>Merci pour votre commentaire !</0>",
"How did it go?": "Comment cela sest-il passé ?"
}

View File

@@ -23,7 +23,6 @@
"Create account": "Buat akun",
"Debug log": "Catatan pengawakutuan",
"Debug log request": "Permintaan catatan pengawakutuan",
"Description (optional)": "Deskripsi (opsional)",
"Details": "Detail",
"Developer": "Pengembang",
"Display name": "Nama tampilan",
@@ -34,7 +33,6 @@
"Full screen": "Layar penuh",
"Go": "Bergabung",
"Grid layout menu": "Menu tata letak kisi",
"Having trouble? Help us fix it.": "Mengalami masalah? Bantu kami memperbaikinya.",
"Home": "Beranda",
"Include debug logs": "Termasuk catatan pengawakutuan",
"Incompatible versions": "Versi tidak kompatibel",
@@ -55,7 +53,6 @@
"Microphone permissions needed to join the call.": "Izin mikrofon dibutuhkan untuk bergabung ke panggilan ini.",
"Microphone {{n}}": "Mikrofon {{n}}",
"More": "Lainnya",
"More menu": "Menu lainnya",
"Mute microphone": "Bisukan mikrofon",
"No": "Tidak",
"Not now, return to home screen": "Tidak sekarang, kembali ke layar beranda",
@@ -76,8 +73,6 @@
"Release to stop": "Lepaskan untuk berhenti",
"Remove": "Hapus",
"Return to home screen": "Kembali ke layar beranda",
"Save": "Simpan",
"Saving…": "Menyimpan…",
"Select an option": "Pilih sebuah opsi",
"Send debug logs": "Kirim catatan pengawakutuan",
"Sending…": "Mengirimkan…",
@@ -92,7 +87,6 @@
"Spotlight": "Sorotan",
"Stop sharing screen": "Berhenti membagikan layar",
"Submit feedback": "Kirim masukan",
"Submitting feedback…": "Mengirimkan masukan…",
"Take me Home": "Bawa saya ke Beranda",
"Talk over speaker": "Bicara pada pembicara",
"Talking…": "Berbicara…",
@@ -103,7 +97,6 @@
"Turn off camera": "Matikan kamera",
"Turn on camera": "Nyalakan kamera",
"Unmute microphone": "Suarakan mikrofon",
"User ID": "ID pengguna",
"User menu": "Menu pengguna",
"Username": "Nama pengguna",
"Version: {{version}}": "Versi: {{version}}",
@@ -120,7 +113,6 @@
"Your recent calls": "Panggilan Anda terkini",
"{{count}} people connected|one": "{{count}} orang terhubung",
"{{count}} people connected|other": "{{count}} orang terhubung",
"{{displayName}}, your call is now ended": "{{displayName}}, panggilan Anda sekarang telah berakhir",
"{{names}}, {{name}}": "{{names}}, {{name}}",
"{{name}} is presenting": "{{name}} sedang mempresentasi",
"{{name}} is talking…": "{{name}} sedang berbicara…",
@@ -138,5 +130,16 @@
"Expose developer settings in the settings window.": "Ekspos pengaturan pengembang dalam jendela pengaturan.",
"Developer Settings": "Pengaturan Pengembang",
"By participating in this beta, you consent to the collection of anonymous data, which we use to improve the product. You can find more information about which data we track in our <2>Privacy Policy</2> and our <5>Cookie Policy</5>.": "Dengan bergabung dalam beta ini, Anda mengizinkan kami untuk mengumpulkan data anonim, yang kami gunakan untuk meningkatkan produk ini. Anda dapat mempelajari lebih lanjut tentang data apa yang kami lacak dalam <2>Kebijakan Privasi</2> dan <5>Kebijakan Kuki</5> kami.",
"<0></0><1></1>You may withdraw consent by unchecking this box. If you are currently in a call, this setting will take effect at the end of the call.": "<0></0><1></1>Anda dapat mengurungkan kembali izin dengan mencentang kotak ini. Jika Anda saat ini dalam panggilan, pengaturan ini akan diterapkan di akhir panggilan."
"<0></0><1></1>You may withdraw consent by unchecking this box. If you are currently in a call, this setting will take effect at the end of the call.": "<0></0><1></1>Anda dapat mengurungkan kembali izin dengan mencentang kotak ini. Jika Anda saat ini dalam panggilan, pengaturan ini akan diterapkan di akhir panggilan.",
"Feedback": "Masukan",
"If you are experiencing issues or simply would like to provide some feedback, please send us a short description below.": "Jika Anda mengalami masalah atau hanya ingin memberikan masukan, silakan kirimkan kami deskripsi pendek di bawah.",
"Submit": "Kirim",
"Submitting…": "Mengirim",
"Thanks, we received your feedback!": "Terima kasih, kami telah menerima masukan Anda!",
"Your feedback": "Masukan Anda",
"{{displayName}}, your call has ended.": "{{displayName}}, panggilan Anda telah berakhir.",
"<0>Thanks for your feedback!</0>": "<0>Terima kasih atas masukan Anda!</0>",
"How did it go?": "Bagaimana rasanya?",
"{{count}} stars|one": "{{count}} bintang",
"<0>We'd love to hear your feedback so we can improve your experience.</0>": "<0>Kami ingin mendengar masukan Anda supaya kami bisa meningkatkan pengalaman Anda.</0>"
}

View File

@@ -24,10 +24,8 @@
"Copied!": "コピーしました!",
"Copy and share this call link": "通話リンクをコピーし共有",
"Copy": "コピー",
"Description (optional)": "概要(任意)",
"Debug log": "デバッグログ",
"Create account": "アカウントを作成",
"Having trouble? Help us fix it.": "問題が起きましたか?修正にご協力ください。",
"Go": "続行",
"Fetching group call timed out.": "グループ通話の取得がタイムアウトしました。",
"Element Call Home": "Element Call ホーム",
@@ -57,11 +55,9 @@
"Version: {{version}}": "バージョン:{{version}}",
"Username": "ユーザー名",
"User menu": "ユーザーメニュー",
"User ID": "ユーザーID",
"Unmute microphone": "マイクのミュートを解除",
"Turn on camera": "カメラをつける",
"Turn off camera": "カメラを切る",
"Submitting feedback…": "フィードバックを送信しています…",
"Submit feedback": "フィードバックを送信",
"Stop sharing screen": "画面共有を停止",
"Spotlight": "スポットライト",
@@ -72,8 +68,6 @@
"Settings": "設定",
"Sending…": "送信しています…",
"Sending debug logs…": "デバッグログを送信しています…",
"Saving…": "保存しています…",
"Save": "保存",
"Return to home screen": "ホーム画面に戻る",
"Registering…": "登録しています…",
"Register": "登録",
@@ -100,7 +94,6 @@
"WebRTC is not supported or is being blocked in this browser.": "お使いのブラウザでWebRTCがサポートされていないか、またはブロックされています。",
"Login to your account": "アカウントにログイン",
"Freedom": "自由",
"{{displayName}}, your call is now ended": "{{displayName}}、通話が終了しました",
"Talking…": "話しています…",
"Remove": "削除",
"No": "いいえ",

View File

@@ -3,7 +3,6 @@
"<0>Create an account</0> Or <2>Access as a guest</2>": "",
"{{count}} people connected|one": "{{count}}명 연결됨",
"{{count}} people connected|other": "{{count}}명 연결됨",
"{{displayName}}, your call is now ended": "{{displayName}}님, 전화가 종료되었습니다",
"{{names}}, {{name}}": "{{names}}님, {{name}}님",
"{{name}} is presenting": "{{name}}님이 발표 중",
"{{name}} is talking…": "{{name}}님이 말하는 중…",

View File

@@ -1,8 +1,7 @@
{
"More menu": "Menu \"więcej\"",
"Login": "Zaloguj się",
"Go": "Kontynuuj",
"By clicking \"Go\", you agree to our <2>Terms and conditions</2>": "Klikając \"Kontynuuj\", wyrażasz zgodę na nasze <2>Warunki</2>",
"Go": "Przejdź",
"By clicking \"Go\", you agree to our <2>Terms and conditions</2>": "Klikając \"Kontynuuj\", wyrażasz zgodę na nasze <2>Zasady i warunki</2>",
"{{count}} people connected|other": "{{count}} osób połączonych",
"Your recent calls": "Twoje ostatnie połączenia",
"You can't talk at the same time": "Nie możesz mówić w tym samym czasie",
@@ -18,7 +17,6 @@
"Version: {{version}}": "Wersja: {{version}}",
"Username": "Nazwa użytkownika",
"User menu": "Menu użytkownika",
"User ID": "ID użytkownika",
"Unmute microphone": "Wyłącz wyciszenie mikrofonu",
"Turn on camera": "Włącz kamerę",
"Turn off camera": "Wyłącz kamerę",
@@ -26,8 +24,7 @@
"This call already exists, would you like to join?": "Te połączenie już istnieje, czy chcesz do niego dołączyć?",
"Thanks! We'll get right on it.": "Dziękujemy! Zaraz się tym zajmiemy.",
"Talking…": "Mówienie…",
"Take me Home": "Zabierz mnie do ekranu startowego",
"Submitting feedback…": "Przesyłanie opinii…",
"Take me Home": "Zabierz mnie do strony głównej",
"Submit feedback": "Prześlij opinię",
"Stop sharing screen": "Zatrzymaj udostępnianie ekranu",
"Spotlight": "Centrum uwagi",
@@ -43,12 +40,10 @@
"Sending debug logs…": "Wysyłanie dzienników debugowania…",
"Send debug logs": "Wyślij dzienniki debugowania",
"Select an option": "Wybierz opcję",
"Saving…": "Zapisywanie…",
"Save": "Zapisz",
"Return to home screen": "Powróć do ekranu domowego",
"Return to home screen": "Powróć do strony głównej",
"Remove": "Usuń",
"Release to stop": "Puść przycisk, aby przestać",
"Release spacebar key to stop": "Puść spację, aby przestać",
"Release to stop": "Puść przycisk, aby zatrzymać",
"Release spacebar key to stop": "Puść spację, aby zatrzymać",
"Registering…": "Rejestrowanie…",
"Register": "Zarejestruj",
"Recaptcha not loaded": "Recaptcha nie została załadowana",
@@ -58,7 +53,7 @@
"Press and hold to talk": "Przytrzymaj, aby mówić",
"Press and hold spacebar to talk over {{name}}": "Przytrzymaj spację, aby mówić wraz z {{name}}",
"Press and hold spacebar to talk": "Przytrzymaj spację, aby mówić",
"Passwords must match": "Hasła muszą być identyczne",
"Passwords must match": "Hasła muszą pasować",
"Password": "Hasło",
"Other users are trying to join this call from incompatible versions. These users should ensure that they have refreshed their browsers:<1>{userLis}</1>": "Inni użytkownicy próbują dołączyć do tego połączenia przy użyciu niekompatybilnych wersji. Powinni oni upewnić się, że odświeżyli stronę w swoich przeglądarkach:<1>{userLis}</1>",
"Not registered yet? <2>Create an account</2>": "Nie masz konta? <2>Utwórz je</2>",
@@ -71,7 +66,7 @@
"Microphone": "Mikrofon",
"Login to your account": "Zaloguj się do swojego konta",
"Logging in…": "Logowanie…",
"Local volume": "Lokalna głośność",
"Local volume": "Głośność lokalna",
"Loading…": "Ładowanie…",
"Leave": "Opuść",
"Join existing call?": "Dołączyć do istniejącego połączenia?",
@@ -84,17 +79,15 @@
"Incompatible versions": "Niekompatybilne wersje",
"Include debug logs": "Dołącz dzienniki debugowania",
"Home": "Strona domowa",
"Having trouble? Help us fix it.": "Masz problem? Pomóż nam go naprawić.",
"Grid layout menu": "Menu układu siatki",
"Full screen": "Pełen ekran",
"Full screen": "Pełny ekran",
"Freedom": "Wolność",
"Fetching group call timed out.": "Przekroczono limit czasu na uzyskanie połączenia grupowego.",
"Exit full screen": "Zamknij pełny ekran",
"Exit full screen": "Opuść pełny ekran",
"Download debug logs": "Pobierz dzienniki debugowania",
"Display name": "Wyświetlana nazwa",
"Developer": "Deweloper",
"Display name": "Nazwa wyświetlana",
"Developer": "Programista",
"Details": "Szczegóły",
"Description (optional)": "Opis (opcjonalny)",
"Debug log request": "Prośba o dzienniki debugowania",
"Debug log": "Dzienniki debugowania",
"Create account": "Utwórz konto",
@@ -104,39 +97,50 @@
"Confirm password": "Potwierdź hasło",
"Close": "Zamknij",
"Change layout": "Zmień układ",
"Camera/microphone permissions needed to join the call.": "Aby dołączyć do tego połączenia, potrzebne są uprawnienia do kamery/mikrofonu.",
"Camera/microphone permissions needed to join the call.": "Wymagane są uprawnienia do kamery/mikrofonu, aby dołączyć do rozmowy.",
"Camera {{n}}": "Kamera {{n}}",
"Camera": "Kamera",
"Call type menu": "Menu rodzaju połączenia",
"Call type menu": "Menu typu połączenia",
"Call link copied": "Skopiowano link do połączenia",
"By clicking \"Join call now\", you agree to our <2>Terms and conditions</2>": "Klikając \"Dołącz do rozmowy\", wyrażasz zgodę na nasze <2>Warunki</2>",
"By clicking \"Join call now\", you agree to our <2>Terms and conditions</2>": "Klikając \"Dołącz do rozmowy\", wyrażasz zgodę na nasze <2>Zasady i warunki</2>",
"Avatar": "Awatar",
"Audio": "Dźwięk",
"Another user on this call is having an issue. In order to better diagnose these issues we'd like to collect a debug log.": "Inny użytkownik w tym połączeniu napotkał problem. Aby lepiej zdiagnozować tę usterkę, chcielibyśmy zebrać dzienniki debugowania.",
"Accept microphone permissions to join the call.": "Przyznaj uprawnienia do mikrofonu aby dołączyć do połączenia.",
"Accept camera/microphone permissions to join the call.": "Przyznaj uprawnienia do kamery/mikrofonu aby dołączyć do połączenia.",
"Accept microphone permissions to join the call.": "Akceptuj uprawnienia mikrofonu, aby dołączyć do połączenia.",
"Accept camera/microphone permissions to join the call.": "Akceptuj uprawnienia kamery/mikrofonu, aby dołączyć do połączenia.",
"<0>Why not finish by setting up a password to keep your account?</0><1>You'll be able to keep your name and set an avatar for use on future calls</1>": "<0>Może zechcesz ustawić hasło, aby zachować swoje konto?</0><1>Będziesz w stanie utrzymać swoją nazwę i ustawić awatar do wyświetlania podczas połączeń w przyszłości</1>",
"<0>Create an account</0> Or <2>Access as a guest</2>": "<0>Utwórz konto</0> Albo <2>Dołącz jako gość</2>",
"<0>Already have an account?</0><1><0>Log in</0> Or <2>Access as a guest</2></1>": "<0>Masz już konto?</0><1><0>Zaloguj się</0> Albo <2>Dołącz jako gość</2></1>",
"<0>Create an account</0> Or <2>Access as a guest</2>": "<0>Utwórz konto</0> lub <2>Dołącz jako gość</2>",
"<0>Already have an account?</0><1><0>Log in</0> Or <2>Access as a guest</2></1>": "<0>Masz już konto?</0><1><0>Zaloguj się</0> lub <2>Dołącz jako gość</2></1>",
"{{roomName}} - Walkie-talkie call": "{{roomName}} - połączenie walkie-talkie",
"{{names}}, {{name}}": "{{names}}, {{name}}",
"{{name}} is talking…": "{{name}} mówi…",
"{{name}} is presenting": "{{name}} prezentuje",
"{{displayName}}, your call is now ended": "{{displayName}}, twoje połączenie zostało zakończone",
"{{count}} people connected|one": "{{count}} osoba połączona",
"This feature is only supported on Firefox.": "Ta funkcjonalność jest dostępna tylko w Firefox.",
"Copy": "Kopiuj",
"<0>Submitting debug logs will help us track down the problem.</0>": "<0>Wysłanie logów debuggowania pomoże nam ustalić przyczynę problemu.</0>",
"<0>Submitting debug logs will help us track down the problem.</0>": "<0>Wysłanie dzienników debuggowania pomoże nam ustalić przyczynę problemu.</0>",
"<0>Oops, something's gone wrong.</0>": "<0>Ojej, coś poszło nie tak.</0>",
"<0>Join call now</0><1>Or</1><2>Copy call link and join later</2>": "<0>Dołącz do rozmowy już teraz</0><1>Or</1><2>Skopiuj link do rozmowy i dołącz później</2>",
"<0>Join call now</0><1>Or</1><2>Copy call link and join later</2>": "<0>Dołącz do rozmowy już teraz</0><1>lub</1><2>Skopiuj link do rozmowy i dołącz później</2>",
"{{name}} (Waiting for video...)": "{{name}} (Oczekiwanie na wideo...)",
"{{name}} (Connecting...)": "{{name}} (Łączenie...)",
"Expose developer settings in the settings window.": "Wyświetlaj opcje programisty w oknie ustawień.",
"Expose developer settings in the settings window.": "Wyświetl opcje programisty w oknie ustawień.",
"Element Call Home": "Strona główna Element Call",
"Developer Settings": "Opcje programisty",
"Talk over speaker": "Rozmowa przez głośnik",
"Use the upcoming grid system": "Użyj nadchodzącego systemu siatek",
"This site is protected by ReCAPTCHA and the Google <2>Privacy Policy</2> and <6>Terms of Service</6> apply.<9></9>By clicking \"Register\", you agree to our <12>Terms and conditions</12>": "Ta strona jest chroniona przez ReCAPTCHA, więc obowiązują na niej <2>Polityka prywatności</2> i <6>Warunki świadczenia usług</6> Google.<9></9>Klikając \"Zarejestruj się\", zgadzasz się na nasze <12>Warunki świadczenia usług</12>",
"<0></0><1></1>You may withdraw consent by unchecking this box. If you are currently in a call, this setting will take effect at the end of the call.": "<0></0><1></1>Możesz wycofać swoją zgodę poprzez odznaczenie tego pola. Jeśli już jesteś w trakcie rozmowy, opcja zostanie zastosowana po jej zakończeniu.",
"By participating in this beta, you consent to the collection of anonymous data, which we use to improve the product. You can find more information about which data we track in our <2>Privacy Policy</2> and our <5>Cookie Policy</5>.": "Uczestnicząc w tej becie, upoważniasz nas do zbierania anonimowych danych, które wykorzystamy do ulepszenia produktu. Dowiedz się więcej na temat danych, które zbieramy w naszej <2>Polityce prywatności</2> i <5>Polityce ciasteczek</5>."
"By participating in this beta, you consent to the collection of anonymous data, which we use to improve the product. You can find more information about which data we track in our <2>Privacy Policy</2> and our <5>Cookie Policy</5>.": "Uczestnicząc w tej becie, upoważniasz nas do zbierania anonimowych danych, które wykorzystamy do ulepszenia produktu. Dowiedz się więcej na temat danych, które zbieramy w naszej <2>Polityce prywatności</2> i <5>Polityce ciasteczek</5>.",
"If you are experiencing issues or simply would like to provide some feedback, please send us a short description below.": "Jeśli posiadasz problemy lub chciałbyś zgłosić swoją opinię, wyślij nam krótki opis.",
"Thanks, we received your feedback!": "Dziękujemy, otrzymaliśmy Twoją opinię!",
"Feedback": "Opinia użytkownika",
"Submitting…": "Wysyłanie…",
"Submit": "Wyślij",
"Your feedback": "Twoje opinie",
"{{count}} stars|other": "{{count}} gwiazdki",
"{{count}} stars|one": "{{count}} gwiazdka",
"{{displayName}}, your call has ended.": "{{displayName}}, Twoje połączenie zostało zakończone.",
"<0>Thanks for your feedback!</0>": "<0>Dziękujemy za Twoją opinię!</0>",
"<0>We'd love to hear your feedback so we can improve your experience.</0>": "<0>Z przyjemnością wysłuchamy Twojej opinii, aby poprawić Twoje doświadczenia.</0>",
"How did it go?": "Jak poszło?"
}

View File

@@ -1,6 +1,5 @@
{
"Register": "Зарегистрироваться",
"Saving…": "Сохранение…",
"Registering…": "Регистрация…",
"Logging in…": "Вход…",
"{{names}}, {{name}}": "{{names}}, {{name}}",
@@ -10,7 +9,6 @@
"This call already exists, would you like to join?": "Этот звонок уже существует, хотите присоединиться?",
"Thanks! We'll get right on it.": "Спасибо! Мы учтём ваш отзыв.",
"Talking…": "Говорите…",
"Submitting feedback…": "Отправка отзыва…",
"Submit feedback": "Отправить отзыв",
"Sending debug logs…": "Отправка журнала отладки…",
"Select an option": "Выберите вариант",
@@ -38,7 +36,6 @@
"Version: {{version}}": "Версия: {{version}}",
"Username": "Имя пользователя",
"User menu": "Меню пользователя",
"User ID": "ID пользователя",
"Unmute microphone": "Включить микрофон",
"Turn on camera": "Включить камеру",
"Turn off camera": "Отключить камеру",
@@ -57,7 +54,6 @@
"Sending…": "Отправка…",
"Local volume": "Местная громкость",
"Call type menu": "Меню \"Тип звонка\"",
"More menu": "Полное меню",
"{{roomName}} - Walkie-talkie call": "{{roomName}} - Звонок-рация",
"Include debug logs": "Приложить журнал отладки",
"Download debug logs": "Скачать журнал отладки",
@@ -65,7 +61,6 @@
"Debug log": "Журнал отладки",
"Another user on this call is having an issue. In order to better diagnose these issues we'd like to collect a debug log.": "У одного из участников звонка есть неполадки. Чтобы лучше диагностировать похожие проблемы, нам нужен журнал отладки.",
"Send debug logs": "Отправить журнал отладки",
"Save": "Сохранить",
"Return to home screen": "Вернуться в Начало",
"Remove": "Удалить",
"Recaptcha not loaded": "Невозможно начать проверку",
@@ -96,7 +91,6 @@
"Incompatible versions!": "Несовместимые версии!",
"Incompatible versions": "Несовместимые версии",
"Home": "Начало",
"Having trouble? Help us fix it.": "Есть проблема? Помогите нам её устранить.",
"Go": "Далее",
"Full screen": "Полноэкранный режим",
"Freedom": "Свобода",
@@ -105,7 +99,6 @@
"Display name": "Видимое имя",
"Developer": "Разработчику",
"Details": "Подробности",
"Description (optional)": "Описание (необязательно)",
"Create account": "Создать аккаунт",
"Copy and share this call link": "Скопируйте и поделитесь этой ссылкой на звонок",
"Copied!": "Скопировано!",
@@ -123,7 +116,6 @@
"Accept camera/microphone permissions to join the call.": "Для присоединения к звонку разрешите доступ к камере/микрофону.",
"{{name}} is talking…": "{{name}} говорит…",
"{{name}} is presenting": "{{name}} показывает",
"{{displayName}}, your call is now ended": "{{displayName}}, ваш звонок завершён",
"{{count}} people connected|other": "{{count}} подключилось",
"{{count}} people connected|one": "{{count}} подключился",
"Element Call Home": "Главная Element Call",

View File

@@ -12,7 +12,6 @@
"Talking…": "Rozprávanie…",
"Talk over speaker": "Hovor cez reproduktor",
"Take me Home": "Zober ma domov",
"Submitting feedback…": "Odosielanie spätnej väzby…",
"Submit feedback": "Odoslať spätnú väzbu",
"Stop sharing screen": "Zastaviť zdieľanie obrazovky",
"Show call inspector": "Zobraziť inšpektora hovorov",
@@ -21,8 +20,6 @@
"Sending debug logs…": "Odosielanie záznamov o ladení…",
"Send debug logs": "Odoslať záznamy o ladení",
"Select an option": "Vyberte možnosť",
"Saving…": "Ukladanie…",
"Save": "Uložiť",
"Return to home screen": "Návrat na domovskú obrazovku",
"Remove": "Odstrániť",
"Release spacebar key to stop": "Pustite medzerník pre ukončenie",
@@ -43,7 +40,6 @@
"Not now, return to home screen": "Teraz nie, vrátiť sa na domovskú obrazovku",
"No": "Nie",
"Mute microphone": "Stlmiť mikrofón",
"More menu": "Ponuka viac",
"More": "Viac",
"Microphone permissions needed to join the call.": "Povolenie mikrofónu je potrebné na pripojenie k hovoru.",
"Microphone {{n}}": "Mikrofón {{n}}",
@@ -62,7 +58,6 @@
"Incompatible versions!": "Nekompatibilné verzie!",
"Incompatible versions": "Nekompatibilné verzie",
"Home": "Domov",
"Having trouble? Help us fix it.": "Máte problém? Pomôžte nám ho opraviť.",
"Grid layout menu": "Ponuka rozloženia mriežky",
"Go": "Prejsť",
"Full screen": "Zobrazenie na celú obrazovku",
@@ -80,7 +75,6 @@
"Version: {{version}}": "Verzia: {{version}}",
"Username": "Meno používateľa",
"User menu": "Používateľské menu",
"User ID": "ID používateľa",
"Unmute microphone": "Zrušiť stlmenie mikrofónu",
"Turn on camera": "Zapnúť kameru",
"Turn off camera": "Vypnúť kameru",
@@ -95,7 +89,6 @@
"Display name": "Zobrazované meno",
"Developer": "Vývojár",
"Details": "Podrobnosti",
"Description (optional)": "Popis (voliteľné)",
"Debug log request": "Žiadosť o záznam ladenia",
"Debug log": "Záznam o ladení",
"Create account": "Vytvoriť účet",
@@ -128,7 +121,6 @@
"{{name}} is presenting": "{{name}} prezentuje",
"{{name}} (Waiting for video...)": "{{name}} (Čaká sa na video...)",
"{{name}} (Connecting...)": "{{name}} (Pripájanie...)",
"{{displayName}}, your call is now ended": "{{displayName}}, váš hovor je teraz ukončený",
"{{count}} people connected|other": "{{count}} osôb pripojených",
"{{count}} people connected|one": "{{count}} osoba pripojená",
"This feature is only supported on Firefox.": "Táto funkcia je podporovaná len v prehliadači Firefox.",
@@ -138,5 +130,17 @@
"Expose developer settings in the settings window.": "Zobraziť nastavenia pre vývojárov v okne nastavení.",
"Developer Settings": "Nastavenia pre vývojárov",
"By participating in this beta, you consent to the collection of anonymous data, which we use to improve the product. You can find more information about which data we track in our <2>Privacy Policy</2> and our <5>Cookie Policy</5>.": "Účasťou v tejto beta verzii súhlasíte so zhromažďovaním anonymných údajov, ktoré použijeme na zlepšenie produktu. Viac informácií o tom, ktoré údaje sledujeme, nájdete v našich <2>Zásadách ochrany osobných údajov</2> a <5>Zásadách používania súborov cookie</5>.",
"<0></0><1></1>You may withdraw consent by unchecking this box. If you are currently in a call, this setting will take effect at the end of the call.": "<0></0><1></1>Súhlas môžete odvolať zrušením označenia tohto políčka. Ak práve prebieha hovor, toto nastavenie nadobudne platnosť po skončení hovoru."
"<0></0><1></1>You may withdraw consent by unchecking this box. If you are currently in a call, this setting will take effect at the end of the call.": "<0></0><1></1>Súhlas môžete odvolať zrušením označenia tohto políčka. Ak práve prebieha hovor, toto nastavenie nadobudne platnosť po skončení hovoru.",
"Your feedback": "Vaša spätná väzba",
"Thanks, we received your feedback!": "Ďakujeme, dostali sme vašu spätnú väzbu!",
"Submitting…": "Odosielanie…",
"Submit": "Odoslať",
"If you are experiencing issues or simply would like to provide some feedback, please send us a short description below.": "Ak máte problémy alebo jednoducho chcete poskytnúť spätnú väzbu, pošlite nám krátky popis nižšie.",
"Feedback": "Spätná väzba",
"{{count}} stars|one": "{{count}} hviezdička",
"How did it go?": "Ako to išlo?",
"{{count}} stars|other": "{{count}} hviezdičiek",
"{{displayName}}, your call has ended.": "{{displayName}}, váš hovor skončil.",
"<0>Thanks for your feedback!</0>": "<0> Ďakujeme za vašu spätnú väzbu!</0>",
"<0>We'd love to hear your feedback so we can improve your experience.</0>": "<0> Radi si vypočujeme vašu spätnú väzbu, aby sme mohli zlepšiť vaše skúsenosti.</0>"
}

View File

@@ -21,7 +21,6 @@
"Create account": "Hesap aç",
"Debug log": "Hata ayıklama kütüğü",
"Debug log request": "Hata ayıklama kütük istemi",
"Description (optional)": "Tanım (isteğe bağlı)",
"Details": "Ayrıntı",
"Developer": "Geliştirici",
"Display name": "Ekran adı",
@@ -32,7 +31,6 @@
"Full screen": "Tam ekran",
"Go": "Git",
"Grid layout menu": "Izgara plan menü",
"Having trouble? Help us fix it.": "Sorun mu var? Çözmemize yardım edin.",
"Home": "Ev",
"Include debug logs": "Hata ayıklama kütüğünü dahil et",
"Incompatible versions": "Uyumsuz sürümler",
@@ -52,7 +50,6 @@
"Microphone permissions needed to join the call.": "Aramaya katılmak için mikrofon erişim izni gerek.",
"Microphone {{n}}": "{{n}}. mikrofon",
"More": "Daha",
"More menu": "Daha fazla",
"Mute microphone": "Mikrofonu kapat",
"No": "Hayır",
"Not now, return to home screen": "Şimdi değil, ev ekranına dön",
@@ -70,8 +67,6 @@
"Release to stop": "Kesmek için bırakın",
"Remove": ıkar",
"Return to home screen": "Ev ekranına geri dön",
"Save": "Kaydet",
"Saving…": "Kaydediliyor…",
"Select an option": "Bir seçenek seç",
"Send debug logs": "Hata ayıklama kütüğünü gönder",
"Sending…": "Gönderiliyor…",
@@ -83,14 +78,12 @@
"Spatial audio": "Uzamsal ses",
"Stop sharing screen": "Ekran paylaşmayı terk et",
"Submit feedback": "Geri bildirim ver",
"Submitting feedback…": "Geri bildirimler gönderiliyor…",
"Take me Home": "Ev ekranına gir",
"Talking…": "Konuşuyor…",
"Thanks! We'll get right on it.": "Sağol! Bununla ilgileneceğiz.",
"This call already exists, would you like to join?": "Bu arama zaten var, katılmak ister misiniz?",
"{{count}} people connected|one": "{{count}} kişi bağlı",
"{{count}} people connected|other": "{{count}} kişi bağlı",
"{{displayName}}, your call is now ended": "Aramanız bitti, {{displayName]}!",
"{{names}}, {{name}}": "{{names}}, {{name}}",
"{{name}} is presenting": "{{name}} sunuyor",
"{{name}} is talking…": "{{name}} konuşuyor…",

View File

@@ -14,7 +14,6 @@
"Version: {{version}}": "Версія: {{version}}",
"Username": "Ім'я користувача",
"User menu": "Меню користувача",
"User ID": "ID користувача",
"Unmute microphone": "Увімкнути мікрофон",
"Turn on camera": "Увімкнути камеру",
"Turn off camera": "Вимкнути камеру",
@@ -25,7 +24,6 @@
"Talking…": "Говоріть…",
"Talk over speaker": "Говорити через динамік",
"Take me Home": "Перейти до Домівки",
"Submitting feedback…": "Надсилання відгуку…",
"Submit feedback": "Надіслати відгук",
"Stop sharing screen": "Припинити показ екрана",
"Spotlight": "У центрі уваги",
@@ -41,8 +39,6 @@
"Sending debug logs…": "Надсилання журналу налагодження…",
"Send debug logs": "Надіслати журнал налагодження",
"Select an option": "Вибрати опцію",
"Saving…": "Збереження…",
"Save": "Зберегти",
"Return to home screen": "Повернутися на екран домівки",
"Remove": "Вилучити",
"Release to stop": "Відпустіть, щоб закінчити",
@@ -63,7 +59,6 @@
"Not now, return to home screen": "Не зараз, повернутися на екран домівки",
"No": "Ні",
"Mute microphone": "Заглушити мікрофон",
"More menu": "Усе меню",
"More": "Докладніше",
"Microphone permissions needed to join the call.": "Для участі у виклику необхідний дозвіл на користування мікрофоном.",
"Microphone {{n}}": "Мікрофон {{n}}",
@@ -83,7 +78,6 @@
"Incompatible versions": "Несумісні версії",
"Include debug logs": "Долучити журнали налагодження",
"Home": "Домівка",
"Having trouble? Help us fix it.": "Проблеми? Допоможіть нам це виправити.",
"Grid layout menu": "Меню у вигляді сітки",
"Go": "Далі",
"Full screen": "Повноекранний режим",
@@ -94,7 +88,6 @@
"Display name": "Показуване ім'я",
"Developer": "Розробнику",
"Details": "Подробиці",
"Description (optional)": "Опис (необов'язково)",
"Debug log request": "Запит журналу налагодження",
"Debug log": "Журнал налагодження",
"Create account": "Створити обліковий запис",
@@ -123,7 +116,6 @@
"{{names}}, {{name}}": "{{names}}, {{name}}",
"{{name}} is talking…": "{{name}} балакає…",
"{{name}} is presenting": "{{name}} показує",
"{{displayName}}, your call is now ended": "{{displayName}}, ваш виклик завершено",
"{{count}} people connected|other": "{{count}} під'єдналися",
"{{count}} people connected|one": "{{count}} під'єднується",
"<0>Join call now</0><1>Or</1><2>Copy call link and join later</2>": "<0>Приєднатися до виклику зараз</0><1>Or</1><2>Скопіювати посилання на виклик і приєднатися пізніше</2>",
@@ -138,5 +130,17 @@
"Expose developer settings in the settings window.": "Відкрийте налаштування розробника у вікні налаштувань.",
"Developer Settings": "Налаштування розробника",
"By participating in this beta, you consent to the collection of anonymous data, which we use to improve the product. You can find more information about which data we track in our <2>Privacy Policy</2> and our <5>Cookie Policy</5>.": "Користуючись дочасним доступом, ви даєте згоду на збір анонімних даних, які ми використовуємо для вдосконалення продукту. Ви можете знайти більше інформації про те, які дані ми відстежуємо в нашій <2>Політиці Приватності</2> і нашій <5>Політиці про куки</5>.",
"<0></0><1></1>You may withdraw consent by unchecking this box. If you are currently in a call, this setting will take effect at the end of the call.": "<0></0><1></1>Ви можете відкликати згоду, прибравши цей прапорець. Якщо ви зараз розмовляєте, це налаштування застосується після завершення виклику."
"<0></0><1></1>You may withdraw consent by unchecking this box. If you are currently in a call, this setting will take effect at the end of the call.": "<0></0><1></1>Ви можете відкликати згоду, прибравши цей прапорець. Якщо ви зараз розмовляєте, це налаштування застосується після завершення виклику.",
"Your feedback": "Ваш відгук",
"Thanks, we received your feedback!": "Дякуємо, ми отримали ваш відгук!",
"Submitting…": "Надсилання…",
"Submit": "Надіслати",
"If you are experiencing issues or simply would like to provide some feedback, please send us a short description below.": "Якщо у вас виникли проблеми або ви просто хочете залишити відгук, надішліть нам короткий опис нижче.",
"Feedback": "Відгук",
"<0>Thanks for your feedback!</0>": "<0>Дякуємо за ваш відгук!</0>",
"{{count}} stars|one": "{{count}} зірка",
"{{count}} stars|other": "{{count}} зірок",
"{{displayName}}, your call has ended.": "{{displayName}}, ваш виклик завершено.",
"<0>We'd love to hear your feedback so we can improve your experience.</0>": "<0>Ми будемо раді почути ваші відгуки, щоб поліпшити роботу застосунку.</0>",
"How did it go?": "Вам усе сподобалось?"
}

105
public/locales/vi/app.json Normal file
View File

@@ -0,0 +1,105 @@
{
"Login": "Đăng nhập",
"{{count}} people connected|other": "{{count}} người đã kết nối",
"{{name}} (Waiting for video...)": "{{name}} (Đang đợi truyền hình...)",
"Join call": "Tham gia cuộc gọi",
"Mute microphone": "Tắt micrô",
"Password": "Mật khẩu",
"Settings": "Cài đặt",
"Sending…": "Đang gửi…",
"Sign in": "Đăng nhập",
"Submit": "Gửi",
"Video call name": "Tên cuộc gọi truyền hình",
"Video call": "Gọi truyền hình",
"Video": "Truyền hình",
"Username": "Tên người dùng",
"Yes, join call": "Vâng, tham gia cuộc gọi",
"Your feedback": "Phản hồi của bạn",
"{{count}} people connected|one": "{{count}} người đã kết nối",
"{{name}} (Connecting...)": "{{name}} (Đang kết nối...)",
"Your recent calls": "Cuộc gọi gần đây",
"You can't talk at the same time": "Bạn không thể nói cùng thời điểm",
"WebRTC is not supported or is being blocked in this browser.": "WebRTC không được hỗ trợ hay bị chặn trong trình duyệt này.",
"Waiting for network": "Đang đợi kết nối mạng",
"Waiting for other participants…": "Đang đợi những người khác…",
"Version: {{version}}": "Phiên bản: {{version}}",
"Turn on camera": "Bật máy quay",
"Turn off camera": "Tắt máy quay",
"Submit feedback": "Gửi phản hồi",
"Stop sharing screen": "Ngừng chia sẻ màn hình",
"Speaker": "Loa",
"Sign out": "Đăng xuất",
"Share screen": "Chia sẻ màn hình",
"No": "Không",
"Invite people": "Mời mọi người",
"Join call now": "Tham gia cuộc gọi",
"Create account": "Tạo tài khoản",
"{{name}} is presenting": "{{name}} đang thuyết trình",
"{{name}} is talking…": "{{name}} đang nói…",
"{{names}}, {{name}}": "{{names}}, {{name}}",
"{{roomName}} - Walkie-talkie call": "{{roomName}} - Cuộc gọi thoại",
"<0>Create an account</0> Or <2>Access as a guest</2>": "<0>Tạo tài khoản</0> Hay <2>Tham gia dưới tên khác</2>",
"<0>Join call now</0><1>Or</1><2>Copy call link and join later</2>": "<0>Tham gia cuộc gọi</0><1>hay</1><2>Sao chép liên kết cuộc gọi và tham gia sau</2>",
"Accept camera/microphone permissions to join the call.": "Chấp nhận quyền sử dụng máy quay/micrô để tham gia cuộc gọi.",
"<0>Submitting debug logs will help us track down the problem.</0>": "<0>Gửi nhật ký gỡ lỗi sẽ giúp chúng tôi theo dõi vấn đề.</0>",
"Avatar": "Ảnh đại diện",
"Audio": "Âm thanh",
"Camera/microphone permissions needed to join the call.": "Cần quyền máy quay/micrô để tham gia cuộc gọi.",
"Camera": "Máy quay",
"Camera {{n}}": "Máy quay {{n}}",
"Call link copied": "Đã sao chép liên kết cuộc gọi",
"Copied!": "Đã sao chép!",
"Connection lost": "Mất kết nối",
"Confirm password": "Xác nhận mật khẩu",
"Close": "Đóng",
"Change layout": "Thay đổi bố cục",
"Debug log": "Nhật ký gỡ lỗi",
"Copy": "Sao chép",
"Copy and share this call link": "Sao chép và chia sẻ liên kết cuộc gọi này",
"Display name": "Tên hiển thị",
"Developer Settings": "Cài đặt phát triển",
"Developer": "Nhà phát triển",
"Details": "Chi tiết",
"Download debug logs": "Tải xuống nhật ký gỡ lỗi",
"Feedback": "Phản hồi",
"Full screen": "Toàn màn hình",
"Incompatible versions!": "Phiên bản không tương thích!",
"Incompatible versions": "Phiên bản không tương thích",
"Include debug logs": "Kèm theo nhật ký gỡ lỗi",
"Invite": "Mời",
"Join existing call?": "Tham gia cuộc gọi?",
"Leave": "Rời",
"Loading…": "Đang tải…",
"Logging in…": "Đang đăng nhập…",
"Login to your account": "Đăng nhập vào tài khoản của bạn",
"Microphone": "Micrô",
"Microphone {{n}}": "Micrô {{n}}",
"Not registered yet? <2>Create an account</2>": "Chưa đăng ký? <2>Tạo tài khoản</2>",
"Passwords must match": "Mật khẩu phải khớp",
"Press and hold spacebar to talk": "Nhấn và giữ phím space để nói",
"Press and hold to talk": "Nhấn và giữ để nói",
"Press and hold to talk over {{name}}": "Nhấn và giữ để nói qua {{name}}",
"Register": "Đăng ký",
"Release spacebar key to stop": "Nhả phím space để ngừng",
"Spotlight": "Tiêu điểm",
"Submitting…": "Đang gửi…",
"Thanks, we received your feedback!": "Cảm ơn, chúng tôi đã nhận được phản hồi!",
"Talking…": "Đang nói…",
"Walkie-talkie call": "Cuộc gọi thoại",
"Walkie-talkie call name": "Tên cuộc gọi thoại",
"<0>Already have an account?</0><1><0>Log in</0> Or <2>Access as a guest</2></1>": "<0>Đã có tài khoản?</0><1><0>Đăng nhập</0> Hay <2>Tham gia dưới tên Khách</2></1>",
"Exit full screen": "Rời chế độ toàn màn hình",
"Profile": "Hồ sơ",
"Registering…": "Đang đăng ký…",
"Unmute microphone": "Bật micrô",
"This call already exists, would you like to join?": "Cuộc gọi đã tồn tại, bạn có muốn tham gia không?",
"This feature is only supported on Firefox.": "Tính năng này chỉ được hỗ trợ trên Firefox.",
"Speaker {{n}}": "Loa {{n}}",
"Recaptcha not loaded": "Chưa tải được Recaptcha",
"Debug log request": "Yêu cầu nhật ký gỡ lỗi",
"By clicking \"Join call now\", you agree to our <2>Terms and conditions</2>": "Khi nhấn vào \"Tham gia cuộc gọi\", bạn đồng ý với <2>Điều khoản và điều kiện</2> của chúng tôi",
"Another user on this call is having an issue. In order to better diagnose these issues we'd like to collect a debug log.": "Một người dùng khác trong cuộc gọi đang gặp vấn đề. Để có thể chẩn đoán tốt hơn chúng tôi muốn thu thập nhật ký gỡ lỗi.",
"Accept microphone permissions to join the call.": "Chấp nhận quyền sử dụng micrô để tham gia cuộc gọi.",
"<0>Why not finish by setting up a password to keep your account?</0><1>You'll be able to keep your name and set an avatar for use on future calls</1>": "<0>Tại sao lại không hoàn thiện bằng cách đặt mật khẩu để giữ tài khoản của bạn?</0><1>Bạn sẽ có thể giữ tên và đặt ảnh đại diện cho những cuộc gọi tiếp theo.</1>",
"Press and hold spacebar to talk over {{name}}": "Nhấn và giữ phím space để nói trên {{name}}"
}

View File

@@ -13,7 +13,6 @@
"Version: {{version}}": "版本:{{version}}",
"Username": "用户名",
"User menu": "用户菜单",
"User ID": "用户ID",
"Unmute microphone": "取消麦克风静音",
"Turn on camera": "开启摄像头",
"Turn off camera": "关闭摄像头",
@@ -24,7 +23,6 @@
"Talking…": "正在发言……",
"Talk over speaker": "通过扬声器发言",
"Take me Home": "返回主页",
"Submitting feedback…": "正在提交反馈……",
"Submit feedback": "提交反馈",
"Stop sharing screen": "停止屏幕共享",
"Spotlight": "聚焦模式",
@@ -47,7 +45,6 @@
"{{name}} is presenting": "{{name}}正在展示",
"{{name}} (Waiting for video...)": "{{name}}(等待视频……)",
"{{name}} (Connecting...)": "{{name}} (正在连接……)",
"{{displayName}}, your call is now ended": "{{displayName}},您的通话已结束",
"{{count}} people connected|other": "{{count}}人已连接",
"{{count}} people connected|one": "{{count}}人已连接",
"Inspector": "检查器",
@@ -58,8 +55,6 @@
"Sending debug logs…": "正在发送调试日志……",
"Send debug logs": "发送调试日志",
"Select an option": "选择一个选项",
"Saving…": "正在保存……",
"Save": "保存",
"Return to home screen": "返回主页",
"Remove": "移除",
"Release to stop": "松开后停止",
@@ -80,7 +75,6 @@
"Not now, return to home screen": "暂不,先返回主页",
"No": "否",
"Mute microphone": "麦克风静音",
"More menu": "更多",
"More": "更多",
"Microphone permissions needed to join the call.": "加入通话需要麦克风权限。",
"Microphone {{n}}": "麦克风 {{n}}",
@@ -100,7 +94,6 @@
"Incompatible versions": "不兼容版本",
"Include debug logs": "包含调试日志",
"Home": "主页",
"Having trouble? Help us fix it.": "遇到麻烦?帮助我们解决问题。",
"Grid layout menu": "网格布局菜单",
"Go": "开始",
"Full screen": "全屏",
@@ -112,7 +105,6 @@
"Display name": "显示名称",
"Developer": "开发者",
"Details": "详情",
"Description (optional)": "描述(可选)",
"Debug log request": "调试日志请求",
"Debug log": "调试日志",
"Create account": "创建账户",
@@ -130,5 +122,7 @@
"Call type menu": "通话类型菜单",
"Call link copied": "链接已复制",
"By clicking \"Join call now\", you agree to our <2>Terms and conditions</2>": "点击“现在加入”则表示同意我们的<2>条款与条件<2>",
"Avatar": "头像"
"Avatar": "头像",
"<0>Oops, something's gone wrong.</0>": "<0>哎哟,出问题了。</0>",
"<0></0><1></1>You may withdraw consent by unchecking this box. If you are currently in a call, this setting will take effect at the end of the call.": ""
}

View File

@@ -8,7 +8,6 @@
"{{name}} is presenting": "{{name}} 已上線",
"{{name}} (Waiting for video...)": "{{name}} (等候視訊中...)",
"{{name}} (Connecting...)": "{{name}} (連結中...)",
"{{displayName}}, your call is now ended": "{{displayName}},您的通話已結束",
"{{count}} people connected|other": "{{count}} 人已連結",
"{{count}} people connected|one": "{{count}} 人已連結",
"Use the upcoming grid system": "使用即將推出的網格系統",
@@ -30,7 +29,6 @@
"Version: {{version}}": "版本: {{version}}",
"Username": "使用者名稱",
"User menu": "使用者選單",
"User ID": "使用者 ID",
"Unmute microphone": "取消麥克風靜音",
"Turn on camera": "開啟相機",
"Turn off camera": "關閉相機",
@@ -42,7 +40,6 @@
"Talking…": "對話中…",
"Talk over speaker": "以擴音對話",
"Take me Home": "帶我回主畫面",
"Submitting feedback…": "遞交回饋…",
"Submit feedback": "遞交回覆",
"Stop sharing screen": "停止分享螢幕畫面",
"Spotlight": "聚焦",
@@ -58,8 +55,6 @@
"Sending debug logs…": "傳送除錯記錄檔中…",
"Send debug logs": "傳送除錯紀錄",
"Select an option": "選擇一個選項",
"Saving…": "儲存中…",
"Save": "儲存",
"Return to home screen": "回到首頁",
"Remove": "移除",
"Release to stop": "放開以停止",
@@ -80,7 +75,6 @@
"Not now, return to home screen": "現在不行,回到首頁",
"No": "否",
"Mute microphone": "麥克風靜音",
"More menu": "更多選單",
"More": "更多",
"Microphone permissions needed to join the call.": "加入通話前需要取得麥克風的權限。",
"Microphone {{n}}": "麥克風 {{n}}",
@@ -101,7 +95,6 @@
"Incompatible versions": "不相容版本",
"Include debug logs": "包含除錯紀錄",
"Home": "首頁",
"Having trouble? Help us fix it.": "遇到問題嗎?請讓我們協助您。",
"Grid layout menu": "格框式清單",
"Go": "前往",
"Full screen": "全螢幕",
@@ -113,7 +106,6 @@
"Display name": "顯示名稱",
"Developer": "開發者",
"Details": "詳細說明",
"Description (optional)": "描述(選擇性)",
"Debug log request": "請求偵錯報告",
"Debug log": "除錯紀錄",
"Create account": "建立帳號",
@@ -138,5 +130,17 @@
"Accept camera/microphone permissions to join the call.": "請授權使用您的相機/麥克風以加入對話。",
"<0>Why not finish by setting up a password to keep your account?</0><1>You'll be able to keep your name and set an avatar for use on future calls</1>": "<0>何不設定密碼以保留此帳號?</0><1>您可以保留暱稱並設定頭像,以便未來通話時使用</1>",
"By participating in this beta, you consent to the collection of anonymous data, which we use to improve the product. You can find more information about which data we track in our <2>Privacy Policy</2> and our <5>Cookie Policy</5>.": "參與此測試版即表示您同意蒐集匿名資料,我們使用這些資料來改進產品。您可以在我們的<2>隱私政策</2>與我們的 <5>Cookie 政策</5> 中找到關於我們追蹤哪些資料的更多資訊。",
"<0></0><1></1>You may withdraw consent by unchecking this box. If you are currently in a call, this setting will take effect at the end of the call.": "<0></0><1></1>您可以透過取消核取此方塊來撤回同意。若您目前正在通話中,此設定將在通話結束時生效。"
"<0></0><1></1>You may withdraw consent by unchecking this box. If you are currently in a call, this setting will take effect at the end of the call.": "<0></0><1></1>您可以透過取消核取此方塊來撤回同意。若您目前正在通話中,此設定將在通話結束時生效。",
"Your feedback": "您的回饋",
"Thanks, we received your feedback!": "感謝,我們已經收到您的回饋了!",
"Submitting…": "正在遞交……",
"Submit": "遞交",
"If you are experiencing issues or simply would like to provide some feedback, please send us a short description below.": "若您遇到問題或只是想提供一些回饋,請在下方傳送簡短說明給我們。",
"Feedback": "回饋",
"{{count}} stars|other": "{{count}} 個星星",
"<0>Thanks for your feedback!</0>": "<0>感謝您的回饋!</0>",
"<0>We'd love to hear your feedback so we can improve your experience.</0>": "<0>我們想要聽到您的回饋,如此我們才能改善您的體驗。</0>",
"{{count}} stars|one": "{{count}} 個星星",
"{{displayName}}, your call has ended.": "{{displayName}},您的通話已結束。",
"How did it go?": "進展如何?"
}

View File

@@ -1,5 +1,5 @@
/*
Copyright 2021 New Vector Ltd
Copyright 2021 - 2023 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -29,7 +29,9 @@ import { usePageFocusStyle } from "./usePageFocusStyle";
import { SequenceDiagramViewerPage } from "./SequenceDiagramViewerPage";
import { InspectorContextProvider } from "./room/GroupCallInspector";
import { CrashView, LoadingView } from "./FullScreenView";
import { DisconnectedBanner } from "./DisconnectedBanner";
import { Initializer } from "./initializer";
import { MediaHandlerProvider } from "./settings/useMediaHandler";
const SentryRoute = Sentry.withSentryRouting(Route);
@@ -55,32 +57,35 @@ export default function App({ history }: AppProps) {
{loaded ? (
<Suspense fallback={null}>
<ClientProvider>
<InspectorContextProvider>
<Sentry.ErrorBoundary fallback={errorPage}>
<OverlayProvider>
<Switch>
<SentryRoute exact path="/">
<HomePage />
</SentryRoute>
<SentryRoute exact path="/login">
<LoginPage />
</SentryRoute>
<SentryRoute exact path="/register">
<RegisterPage />
</SentryRoute>
<SentryRoute path="/room/:roomId?">
<RoomPage />
</SentryRoute>
<SentryRoute path="/inspector">
<SequenceDiagramViewerPage />
</SentryRoute>
<SentryRoute path="*">
<RoomRedirect />
</SentryRoute>
</Switch>
</OverlayProvider>
</Sentry.ErrorBoundary>
</InspectorContextProvider>
<MediaHandlerProvider>
<InspectorContextProvider>
<Sentry.ErrorBoundary fallback={errorPage}>
<OverlayProvider>
<DisconnectedBanner />
<Switch>
<SentryRoute exact path="/">
<HomePage />
</SentryRoute>
<SentryRoute exact path="/login">
<LoginPage />
</SentryRoute>
<SentryRoute exact path="/register">
<RegisterPage />
</SentryRoute>
<SentryRoute path="/room/:roomId?">
<RoomPage />
</SentryRoute>
<SentryRoute path="/inspector">
<SequenceDiagramViewerPage />
</SentryRoute>
<SentryRoute path="*">
<RoomRedirect />
</SentryRoute>
</Switch>
</OverlayProvider>
</Sentry.ErrorBoundary>
</InspectorContextProvider>
</MediaHandlerProvider>
</ClientProvider>
</Suspense>
) : (

View File

@@ -25,9 +25,10 @@ import React, {
useRef,
} from "react";
import { useHistory } from "react-router-dom";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { ClientEvent, MatrixClient } from "matrix-js-sdk/src/client";
import { logger } from "matrix-js-sdk/src/logger";
import { useTranslation } from "react-i18next";
import { ISyncStateData, SyncState } from "matrix-js-sdk/src/sync";
import { ErrorView } from "./FullScreenView";
import {
@@ -70,6 +71,8 @@ const loadSession = (): Session => {
const saveSession = (session: Session) =>
localStorage.setItem("matrix-auth-store", JSON.stringify(session));
const clearSession = () => localStorage.removeItem("matrix-auth-store");
const isDisconnected = (syncState, syncData) =>
syncState === "ERROR" && syncData?.error?.name === "ConnectionError";
interface ClientState {
loading: boolean;
@@ -81,6 +84,7 @@ interface ClientState {
logout: () => void;
setClient: (client: MatrixClient, session: Session) => void;
error?: Error;
disconnected: boolean;
}
const ClientContext = createContext<ClientState>(null);
@@ -98,7 +102,15 @@ export const ClientProvider: FC<Props> = ({ children }) => {
const history = useHistory();
const initializing = useRef(false);
const [
{ loading, isAuthenticated, isPasswordlessUser, client, userName, error },
{
loading,
isAuthenticated,
isPasswordlessUser,
client,
userName,
error,
disconnected,
},
setState,
] = useState<ClientProviderState>({
loading: true,
@@ -107,8 +119,18 @@ export const ClientProvider: FC<Props> = ({ children }) => {
client: undefined,
userName: null,
error: undefined,
disconnected: false,
});
const onSync = (state: SyncState, _old: SyncState, data: ISyncStateData) => {
setState((currentState) => {
const disconnected = isDisconnected(state, data);
return disconnected === currentState.disconnected
? currentState
: { ...currentState, disconnected };
});
};
useEffect(() => {
// In case the component is mounted, unmounted, and remounted quickly (as
// React does in strict mode), we need to make sure not to doubly initialize
@@ -183,9 +205,10 @@ export const ClientProvider: FC<Props> = ({ children }) => {
}
}
};
let clientWithListener: MatrixClient;
init()
.then(({ client, isPasswordlessUser }) => {
clientWithListener = client;
setState({
client,
loading: false,
@@ -193,7 +216,12 @@ export const ClientProvider: FC<Props> = ({ children }) => {
isPasswordlessUser,
userName: client?.getUserIdLocalpart(),
error: undefined,
disconnected: isDisconnected(
client?.getSyncState,
client?.getSyncStateData
),
});
clientWithListener?.on(ClientEvent.Sync, onSync);
})
.catch((err) => {
logger.error(err);
@@ -204,9 +232,13 @@ export const ClientProvider: FC<Props> = ({ children }) => {
isPasswordlessUser: false,
userName: null,
error: undefined,
disconnected: false,
});
})
.finally(() => (initializing.current = false));
return () => {
clientWithListener?.removeListener(ClientEvent.Sync, onSync);
};
}, []);
const changePassword = useCallback(
@@ -235,6 +267,7 @@ export const ClientProvider: FC<Props> = ({ children }) => {
isPasswordlessUser: false,
userName: client.getUserIdLocalpart(),
error: undefined,
disconnected: false,
});
},
[client]
@@ -256,6 +289,10 @@ export const ClientProvider: FC<Props> = ({ children }) => {
isPasswordlessUser: session.passwordlessUser,
userName: newClient.getUserIdLocalpart(),
error: undefined,
disconnected: isDisconnected(
newClient.getSyncState(),
newClient.getSyncStateData()
),
});
} else {
clearSession();
@@ -267,6 +304,7 @@ export const ClientProvider: FC<Props> = ({ children }) => {
isPasswordlessUser: false,
userName: null,
error: undefined,
disconnected: false,
});
}
},
@@ -284,6 +322,7 @@ export const ClientProvider: FC<Props> = ({ children }) => {
isPasswordlessUser: true,
userName: "",
error: undefined,
disconnected: false,
});
history.push("/");
PosthogAnalytics.instance.setRegistrationType(RegistrationType.Guest);
@@ -326,6 +365,7 @@ export const ClientProvider: FC<Props> = ({ children }) => {
userName,
setClient,
error: undefined,
disconnected,
}),
[
loading,
@@ -336,6 +376,7 @@ export const ClientProvider: FC<Props> = ({ children }) => {
logout,
userName,
setClient,
disconnected,
]
);

View File

@@ -0,0 +1,27 @@
/*
Copyright 2023 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
.banner {
position: absolute;
padding: 29px;
background-color: var(--quaternary-content);
vertical-align: middle;
font-size: var(--font-size-body);
text-align: center;
z-index: 1;
top: 76px;
width: calc(100% - 58px);
}

View File

@@ -0,0 +1,47 @@
/*
Copyright 2023 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import classNames from "classnames";
import React, { HTMLAttributes, ReactNode } from "react";
import { useTranslation } from "react-i18next";
import styles from "./DisconnectedBanner.module.css";
import { useClient } from "./ClientContext";
interface DisconnectedBannerProps extends HTMLAttributes<HTMLElement> {
children?: ReactNode;
className?: string;
}
export function DisconnectedBanner({
children,
className,
...rest
}: DisconnectedBannerProps) {
const { t } = useTranslation();
const { disconnected } = useClient();
return (
<>
{disconnected && (
<div className={classNames(styles.banner, className)} {...rest}>
{children}
{t("Connectivity to the server has been lost.")}
</div>
)}
</>
);
}

View File

@@ -40,7 +40,7 @@ limitations under the License.
.modalHeader {
display: flex;
justify-content: space-between;
padding: 34px 34px 0 34px;
padding: 34px 32px 0 32px;
}
.modalHeader h3 {
@@ -72,7 +72,7 @@ limitations under the License.
.modalHeader {
display: flex;
justify-content: space-between;
padding: 24px 24px 0 24px;
padding: 32px 20px 0 20px;
}
.modal.mobileFullScreen {

View File

@@ -79,6 +79,11 @@ export interface UrlParams {
* The Posthog analytics ID. It is only available if the user has given consent for sharing telemetry in element web.
*/
analyticsID: string | null;
/**
* Whether the app is allowed to use fallback STUN servers for ICE in case the
* user's homeserver doesn't provide any.
*/
allowIceFallback: boolean;
}
/**
@@ -135,6 +140,7 @@ export const getUrlParams = (
fonts: getAllParams("font"),
fontScale: Number.isNaN(fontScale) ? null : fontScale,
analyticsID: getParam("analyticsID"),
allowIceFallback: hasParam("allowIceFallback"),
};
};

View File

@@ -1,5 +1,5 @@
/*
Copyright 2022 New Vector Ltd
Copyright 2022 - 2023 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -25,6 +25,7 @@ import { Menu } from "./Menu";
import { TooltipTrigger } from "./Tooltip";
import { Avatar, Size } from "./Avatar";
import { ReactComponent as UserIcon } from "./icons/User.svg";
import { ReactComponent as SettingsIcon } from "./icons/Settings.svg";
import { ReactComponent as LoginIcon } from "./icons/Login.svg";
import { ReactComponent as LogoutIcon } from "./icons/Logout.svg";
import { Body } from "./typography/Typography";
@@ -60,6 +61,11 @@ export function UserMenu({
label: displayName,
dataTestid: "usermenu_user",
});
arr.push({
key: "settings",
icon: SettingsIcon,
label: t("Settings"),
});
if (isPasswordlessUser && !preventNavigation) {
arr.push({

View File

@@ -1,5 +1,5 @@
/*
Copyright 2022 New Vector Ltd
Copyright 2022 - 2023 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -14,13 +14,13 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useCallback } from "react";
import React, { useCallback, useState } from "react";
import { useHistory, useLocation } from "react-router-dom";
import { useClient } from "./ClientContext";
import { useProfile } from "./profile/useProfile";
import { useModalTriggerState } from "./Modal";
import { ProfileModal } from "./profile/ProfileModal";
import { SettingsModal } from "./settings/SettingsModal";
import { UserMenu } from "./UserMenu";
interface Props {
@@ -35,10 +35,17 @@ export function UserMenuContainer({ preventNavigation = false }: Props) {
const { displayName, avatarUrl } = useProfile(client);
const { modalState, modalProps } = useModalTriggerState();
const [defaultSettingsTab, setDefaultSettingsTab] = useState<string>();
const onAction = useCallback(
(value: string) => {
async (value: string) => {
switch (value) {
case "user":
setDefaultSettingsTab("profile");
modalState.open();
break;
case "settings":
setDefaultSettingsTab("audio");
modalState.open();
break;
case "logout":
@@ -64,7 +71,13 @@ export function UserMenuContainer({ preventNavigation = false }: Props) {
displayName || (userName ? userName.replace("@", "") : undefined)
}
/>
{modalState.isOpen && <ProfileModal client={client} {...modalProps} />}
{modalState.isOpen && (
<SettingsModal
client={client}
defaultTab={defaultSettingsTab}
{...modalProps}
/>
)}
</>
);
}

View File

@@ -29,6 +29,7 @@ import {
MuteCameraTracker,
MuteMicrophoneTracker,
UndecryptableToDeviceEventTracker,
QualitySurveyEventTracker,
} from "./PosthogEvents";
import { Config } from "../config/Config";
import { getUrlParams } from "../UrlParams";
@@ -69,6 +70,7 @@ export enum RegistrationType {
interface PlatformProperties {
appVersion: string;
matrixBackend: "embedded" | "jssdk";
callBackend: "livekit" | "full-mesh";
}
interface PosthogSettings {
@@ -190,6 +192,7 @@ export class PosthogAnalytics {
return {
appVersion,
matrixBackend: widget ? "embedded" : "jssdk",
callBackend: "full-mesh",
};
}
@@ -431,4 +434,5 @@ export class PosthogAnalytics {
public eventMuteMicrophone = new MuteMicrophoneTracker();
public eventMuteCamera = new MuteCameraTracker();
public eventUndecryptableToDevice = new UndecryptableToDeviceEventTracker();
public eventQualitySurvey = new QualitySurveyEventTracker();
}

View File

@@ -163,3 +163,21 @@ export class UndecryptableToDeviceEventTracker {
});
}
}
interface QualitySurveyEvent {
eventName: "QualitySurvey";
callId: string;
feedbackText: string;
stars: number;
}
export class QualitySurveyEventTracker {
track(callId: string, feedbackText: string, stars: number) {
PosthogAnalytics.instance.trackEvent<QualitySurveyEvent>({
eventName: "QualitySurvey",
callId,
feedbackText,
stars,
});
}
}

View File

@@ -126,6 +126,11 @@ export class PosthogSpanProcessor implements SpanProcessor {
const maxPacketLoss = `${attributes["matrix.stats.summary.maxPacketLoss"]}`;
const peerConnections = `${attributes["matrix.stats.summary.peerConnections"]}`;
const percentageConcealedAudio = `${attributes["matrix.stats.summary.percentageConcealedAudio"]}`;
const opponentUsersInCall = `${attributes["matrix.stats.summary.opponentUsersInCall"]}`;
const opponentDevicesInCall = `${attributes["matrix.stats.summary.opponentDevicesInCall"]}`;
const diffDevicesToPeerConnections = `${attributes["matrix.stats.summary.diffDevicesToPeerConnections"]}`;
const ratioPeerConnectionToDevices = `${attributes["matrix.stats.summary.ratioPeerConnectionToDevices"]}`;
PosthogAnalytics.instance.trackEvent(
{
eventName: "MediaReceived",
@@ -137,6 +142,10 @@ export class PosthogSpanProcessor implements SpanProcessor {
maxPacketLoss: maxPacketLoss,
peerConnections: peerConnections,
percentageConcealedAudio: percentageConcealedAudio,
opponentUsersInCall: opponentUsersInCall,
opponentDevicesInCall: opponentDevicesInCall,
diffDevicesToPeerConnections: diffDevicesToPeerConnections,
ratioPeerConnectionToDevices: ratioPeerConnectionToDevices,
},
// Send instantly because the window might be closing
{ send_instantly: true }

38
src/array-utils.ts Normal file
View File

@@ -0,0 +1,38 @@
/*
Copyright 2023 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
/**
* Gets the index of the last element in the array to satsify the given
* predicate.
*/
// TODO: remove this once TypeScript recognizes the existence of
// Array.prototype.findLastIndex
export function findLastIndex<T>(
array: T[],
predicate: (item: T) => boolean
): number | null {
for (let i = array.length - 1; i >= 0; i--) {
if (predicate(array[i])) return i;
}
return null;
}
/**
* Counts the number of elements in an array that satsify the given predicate.
*/
export const count = <T>(array: T[], predicate: (item: T) => boolean): number =>
array.reduce((acc, item) => (predicate(item) ? acc + 1 : acc), 0);

View File

@@ -39,10 +39,10 @@ limitations under the License.
.secondaryHangup,
.button,
.copyButton {
padding: 7px 15px;
padding: 8px 20px;
border-radius: 8px;
font-size: var(--font-size-body);
font-weight: 700;
font-weight: 600;
}
.button {

View File

@@ -1,5 +1,5 @@
/*
Copyright 2022 New Vector Ltd
Copyright 2022 - 2023 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -238,7 +238,7 @@ export function SettingsButton({
return (
<TooltipTrigger tooltip={tooltip}>
<Button variant="toolbar" {...rest}>
<SettingsIcon />
<SettingsIcon width={20} height={20} />
</Button>
</TooltipTrigger>
);
@@ -246,9 +246,11 @@ export function SettingsButton({
export function InviteButton({
className,
variant = "toolbar",
...rest
}: {
className?: string;
variant?: string;
// TODO: add all props for <Button>
[index: string]: unknown;
}) {
@@ -257,7 +259,7 @@ export function InviteButton({
return (
<TooltipTrigger tooltip={tooltip}>
<Button variant="toolbar" {...rest}>
<Button variant={variant} {...rest}>
<AddUserIcon />
</Button>
</TooltipTrigger>

View File

@@ -0,0 +1,3 @@
<svg width="28" height="26" viewBox="0 0 28 26" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14 21.0267L22.24 26.0001L20.0533 16.6267L27.3333 10.3201L17.7466 9.50675L14 0.666748L10.2533 9.50675L0.666626 10.3201L7.94663 16.6267L5.75996 26.0001L14 21.0267Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 290 B

View File

@@ -0,0 +1,4 @@
<svg width="28" height="26" viewBox="0 0 28 26" fill="none" xmlns="http://www.w3.org/2000/svg">
<path id="Vector" d="M14 7.50675L15.2933 10.5601L15.92 12.0401L17.52 12.1734L20.8133 12.4534L18.3066 14.6267L17.0933 15.6801L17.4533 17.2534L18.2 20.4667L15.3733 18.7601L14 17.9067L12.6266 18.7334L9.79996 20.4401L10.5466 17.2267L10.9066 15.6534L9.69329 14.6001L7.18663 12.4267L10.48 12.1467L12.08 12.0134L12.7066 10.5334L14 7.50675M14 0.666748L10.2533 9.50675L0.666626 10.3201L7.94663 16.6267L5.75996 26.0001L14 21.0267L22.24 26.0001L20.0533 16.6267L27.3333 10.3201L17.7466 9.50675L14 0.666748Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 620 B

View File

@@ -180,10 +180,16 @@ h2 {
/* Subtitle */
h3 {
font-weight: 400;
font-weight: 600;
font-size: var(--font-size-subtitle);
}
/* Body Semi Bold */
h4 {
font-weight: 600;
font-size: var(--font-size-body);
}
h1,
h2,
h3 {

View File

@@ -45,6 +45,12 @@ class DependencyLoadStates {
export class Initializer {
private static internalInstance: Initializer;
private isInitialized = false;
public static isInitialized(): boolean {
return Initializer.internalInstance?.isInitialized;
}
public static initBeforeReact() {
// this maybe also needs to return a promise in the future,
// if we have to do async inits before showing the loading screen
@@ -223,6 +229,7 @@ export class Initializer {
if (this.loadStates.allDepsAreLoaded()) {
// resolve if there is no dependency that is not loaded
resolve();
this.isInitialized = true;
}
}
private initPromise: Promise<void> | null;

View File

@@ -54,4 +54,6 @@ limitations under the License.
.removeButton {
color: var(--accent);
font-size: var(--font-size-caption);
padding: 6px 0;
}

View File

@@ -0,0 +1,23 @@
/*
Copyright 2023 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
.feedback textarea {
height: 75px;
border-radius: 8px;
}
.feedback {
border-radius: 8px;
}

View File

@@ -72,6 +72,7 @@ interface InputFieldProps {
autoCorrect?: string;
autoCapitalize?: string;
value?: string;
defaultValue?: string;
placeholder?: string;
defaultChecked?: boolean;
onChange?: (event: ChangeEvent) => void;

View File

@@ -22,8 +22,6 @@ limitations under the License.
}
.label {
font-weight: 600;
font-size: var(--font-size-subtitle);
margin-top: 0;
margin-bottom: 12px;
}

View File

@@ -0,0 +1,41 @@
/*
Copyright 2023 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
.starIcon {
cursor: pointer;
}
.starRating {
display: flex;
justify-content: center;
flex: 1;
}
.inputContainer {
display: inline-block;
}
.hideElement {
border: 0;
clip-path: content-box;
height: 0px;
width: 0px;
margin: -1px;
overflow: hidden;
padding: 0;
width: 1px;
display: inline-block;
}

View File

@@ -0,0 +1,85 @@
/*
Copyright 2023 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import styles from "./StarRatingInput.module.css";
import { ReactComponent as StarSelected } from "../icons/StarSelected.svg";
import { ReactComponent as StarUnselected } from "../icons/StarUnselected.svg";
interface Props {
starCount: number;
onChange: (stars: number) => void;
required?: boolean;
}
export function StarRatingInput({
starCount,
onChange,
required,
}: Props): JSX.Element {
const [rating, setRating] = useState(0);
const [hover, setHover] = useState(0);
const { t } = useTranslation();
return (
<div className={styles.starRating}>
{[...Array(starCount)].map((_star, index) => {
index += 1;
return (
<div
className={styles.inputContainer}
onMouseEnter={() => setHover(index)}
onMouseLeave={() => setHover(rating)}
key={index}
>
<input
className={styles.hideElement}
type="radio"
id={"starInput" + String(index)}
value={String(index) + "Star"}
name="star rating"
onChange={(_ev) => {
setRating(index);
onChange(index);
}}
required
/>
<label
className={styles.hideElement}
id={"starInvisibleLabel" + String(index)}
htmlFor={"starInput" + String(index)}
>
{t("{{count}} stars", {
count: index,
})}
</label>
<label
className={styles.starIcon}
id={"starIcon" + String(index)}
htmlFor={"starInput" + String(index)}
>
{index <= (hover || rating) ? (
<StarSelected />
) : (
<StarUnselected />
)}
</label>
</div>
);
})}
</div>
);
}

View File

@@ -61,11 +61,11 @@ function waitForSync(client: MatrixClient) {
data: ISyncStateData
) => {
if (state === "PREPARED") {
client.removeListener(ClientEvent.Sync, onSync);
resolve();
client.removeListener(ClientEvent.Sync, onSync);
} else if (state === "ERROR") {
reject(data?.error);
client.removeListener(ClientEvent.Sync, onSync);
reject(data?.error);
}
};
client.on(ClientEvent.Sync, onSync);

View File

@@ -47,7 +47,7 @@ export async function findDeviceByName(
*
* @return The available media devices
*/
export async function getDevices(): Promise<MediaDeviceInfo[]> {
export async function getNamedDevices(): Promise<MediaDeviceInfo[]> {
// First get the devices without their labels, to learn what kinds of streams
// we can request
let devices: MediaDeviceInfo[];

View File

@@ -17,13 +17,33 @@ limitations under the License.
import { Span } from "@opentelemetry/api";
import { MatrixCall } from "matrix-js-sdk";
import { CallEvent } from "matrix-js-sdk/src/webrtc/call";
import {
TransceiverStats,
CallFeedStats,
} from "matrix-js-sdk/src/webrtc/stats/statsReport";
import { ObjectFlattener } from "./ObjectFlattener";
import { ElementCallOpenTelemetry } from "./otel";
import { OTelCallAbstractMediaStreamSpan } from "./OTelCallAbstractMediaStreamSpan";
import { OTelCallTransceiverMediaStreamSpan } from "./OTelCallTransceiverMediaStreamSpan";
import { OTelCallFeedMediaStreamSpan } from "./OTelCallFeedMediaStreamSpan";
type StreamId = string;
type MID = string;
/**
* Tracks an individual call within a group call, either to a full-mesh peer or a focus
*/
export class OTelCall {
private readonly trackFeedSpan = new Map<
StreamId,
OTelCallAbstractMediaStreamSpan
>();
private readonly trackTransceiverSpan = new Map<
MID,
OTelCallAbstractMediaStreamSpan
>();
constructor(
public userId: string,
public deviceId: string,
@@ -116,4 +136,62 @@ export class OTelCall {
this.span.addEvent("matrix.call.iceCandidateError", flatObject);
};
public onCallFeedStats(callFeeds: CallFeedStats[]): void {
let prvFeeds: StreamId[] = [...this.trackFeedSpan.keys()];
callFeeds.forEach((feed) => {
if (!this.trackFeedSpan.has(feed.stream)) {
this.trackFeedSpan.set(
feed.stream,
new OTelCallFeedMediaStreamSpan(
ElementCallOpenTelemetry.instance,
this.span,
feed
)
);
}
this.trackFeedSpan.get(feed.stream)?.update(feed);
prvFeeds = prvFeeds.filter((prvStreamId) => prvStreamId !== feed.stream);
});
prvFeeds.forEach((prvStreamId) => {
this.trackFeedSpan.get(prvStreamId)?.end();
this.trackFeedSpan.delete(prvStreamId);
});
}
public onTransceiverStats(transceiverStats: TransceiverStats[]): void {
let prvTransSpan: MID[] = [...this.trackTransceiverSpan.keys()];
transceiverStats.forEach((transStats) => {
if (!this.trackTransceiverSpan.has(transStats.mid)) {
this.trackTransceiverSpan.set(
transStats.mid,
new OTelCallTransceiverMediaStreamSpan(
ElementCallOpenTelemetry.instance,
this.span,
transStats
)
);
}
this.trackTransceiverSpan.get(transStats.mid)?.update(transStats);
prvTransSpan = prvTransSpan.filter(
(prvStreamId) => prvStreamId !== transStats.mid
);
});
prvTransSpan.forEach((prvMID) => {
this.trackTransceiverSpan.get(prvMID)?.end();
this.trackTransceiverSpan.delete(prvMID);
});
}
public end(): void {
this.trackFeedSpan.forEach((feedSpan) => feedSpan.end());
this.trackTransceiverSpan.forEach((transceiverSpan) =>
transceiverSpan.end()
);
this.span.end();
}
}

View File

@@ -0,0 +1,62 @@
import opentelemetry, { Span } from "@opentelemetry/api";
import { TrackStats } from "matrix-js-sdk/src/webrtc/stats/statsReport";
import { ElementCallOpenTelemetry } from "./otel";
import { OTelCallMediaStreamTrackSpan } from "./OTelCallMediaStreamTrackSpan";
type TrackId = string;
export abstract class OTelCallAbstractMediaStreamSpan {
protected readonly trackSpans = new Map<
TrackId,
OTelCallMediaStreamTrackSpan
>();
public readonly span;
public constructor(
readonly oTel: ElementCallOpenTelemetry,
readonly callSpan: Span,
protected readonly type: string
) {
const ctx = opentelemetry.trace.setSpan(
opentelemetry.context.active(),
callSpan
);
const options = {
links: [
{
context: callSpan.spanContext(),
},
],
};
this.span = oTel.tracer.startSpan(this.type, options, ctx);
}
protected upsertTrackSpans(tracks: TrackStats[]) {
let prvTracks: TrackId[] = [...this.trackSpans.keys()];
tracks.forEach((t) => {
if (!this.trackSpans.has(t.id)) {
this.trackSpans.set(
t.id,
new OTelCallMediaStreamTrackSpan(this.oTel, this.span, t)
);
}
this.trackSpans.get(t.id)?.update(t);
prvTracks = prvTracks.filter((prvTrackId) => prvTrackId !== t.id);
});
prvTracks.forEach((prvTrackId) => {
this.trackSpans.get(prvTrackId)?.end();
this.trackSpans.delete(prvTrackId);
});
}
public abstract update(data: Object): void;
public end(): void {
this.trackSpans.forEach((tSpan) => {
tSpan.end();
});
this.span.end();
}
}

View File

@@ -0,0 +1,57 @@
import { Span } from "@opentelemetry/api";
import {
CallFeedStats,
TrackStats,
} from "matrix-js-sdk/src/webrtc/stats/statsReport";
import { ElementCallOpenTelemetry } from "./otel";
import { OTelCallAbstractMediaStreamSpan } from "./OTelCallAbstractMediaStreamSpan";
export class OTelCallFeedMediaStreamSpan extends OTelCallAbstractMediaStreamSpan {
private readonly prev: { isAudioMuted: boolean; isVideoMuted: boolean };
constructor(
readonly oTel: ElementCallOpenTelemetry,
readonly callSpan: Span,
callFeed: CallFeedStats
) {
const postFix =
callFeed.type === "local" && callFeed.prefix === "from-call-feed"
? "(clone)"
: "";
super(oTel, callSpan, `matrix.call.feed.${callFeed.type}${postFix}`);
this.span.setAttribute("feed.streamId", callFeed.stream);
this.span.setAttribute("feed.type", callFeed.type);
this.span.setAttribute("feed.readFrom", callFeed.prefix);
this.span.setAttribute("feed.purpose", callFeed.purpose);
this.prev = {
isAudioMuted: callFeed.isAudioMuted,
isVideoMuted: callFeed.isVideoMuted,
};
this.span.addEvent("matrix.call.feed.initState", this.prev);
}
public update(callFeed: CallFeedStats): void {
if (this.prev.isAudioMuted !== callFeed.isAudioMuted) {
this.span.addEvent("matrix.call.feed.audioMuted", {
isAudioMuted: callFeed.isAudioMuted,
});
this.prev.isAudioMuted = callFeed.isAudioMuted;
}
if (this.prev.isVideoMuted !== callFeed.isVideoMuted) {
this.span.addEvent("matrix.call.feed.isVideoMuted", {
isVideoMuted: callFeed.isVideoMuted,
});
this.prev.isVideoMuted = callFeed.isVideoMuted;
}
const trackStats: TrackStats[] = [];
if (callFeed.video) {
trackStats.push(callFeed.video);
}
if (callFeed.audio) {
trackStats.push(callFeed.audio);
}
this.upsertTrackSpans(trackStats);
}
}

View File

@@ -0,0 +1,62 @@
import { TrackStats } from "matrix-js-sdk/src/webrtc/stats/statsReport";
import opentelemetry, { Span } from "@opentelemetry/api";
import { ElementCallOpenTelemetry } from "./otel";
export class OTelCallMediaStreamTrackSpan {
private readonly span: Span;
private prev: TrackStats;
public constructor(
readonly oTel: ElementCallOpenTelemetry,
readonly streamSpan: Span,
data: TrackStats
) {
const ctx = opentelemetry.trace.setSpan(
opentelemetry.context.active(),
streamSpan
);
const options = {
links: [
{
context: streamSpan.spanContext(),
},
],
};
const type = `matrix.call.track.${data.label}.${data.kind}`;
this.span = oTel.tracer.startSpan(type, options, ctx);
this.span.setAttribute("track.trackId", data.id);
this.span.setAttribute("track.kind", data.kind);
this.span.setAttribute("track.constrainDeviceId", data.constrainDeviceId);
this.span.setAttribute("track.settingDeviceId", data.settingDeviceId);
this.span.setAttribute("track.label", data.label);
this.span.addEvent("matrix.call.track.initState", {
readyState: data.readyState,
muted: data.muted,
enabled: data.enabled,
});
this.prev = data;
}
public update(data: TrackStats): void {
if (this.prev.muted !== data.muted) {
this.span.addEvent("matrix.call.track.muted", { muted: data.muted });
}
if (this.prev.enabled !== data.enabled) {
this.span.addEvent("matrix.call.track.enabled", {
enabled: data.enabled,
});
}
if (this.prev.readyState !== data.readyState) {
this.span.addEvent("matrix.call.track.readyState", {
readyState: data.readyState,
});
}
this.prev = data;
}
public end(): void {
this.span.end();
}
}

View File

@@ -0,0 +1,54 @@
import { Span } from "@opentelemetry/api";
import {
TrackStats,
TransceiverStats,
} from "matrix-js-sdk/src/webrtc/stats/statsReport";
import { ElementCallOpenTelemetry } from "./otel";
import { OTelCallAbstractMediaStreamSpan } from "./OTelCallAbstractMediaStreamSpan";
export class OTelCallTransceiverMediaStreamSpan extends OTelCallAbstractMediaStreamSpan {
private readonly prev: {
direction: string;
currentDirection: string;
};
constructor(
readonly oTel: ElementCallOpenTelemetry,
readonly callSpan: Span,
stats: TransceiverStats
) {
super(oTel, callSpan, `matrix.call.transceiver.${stats.mid}`);
this.span.setAttribute("transceiver.mid", stats.mid);
this.prev = {
direction: stats.direction,
currentDirection: stats.currentDirection,
};
this.span.addEvent("matrix.call.transceiver.initState", this.prev);
}
public update(stats: TransceiverStats): void {
if (this.prev.currentDirection !== stats.currentDirection) {
this.span.addEvent("matrix.call.transceiver.currentDirection", {
currentDirection: stats.currentDirection,
});
this.prev.currentDirection = stats.currentDirection;
}
if (this.prev.direction !== stats.direction) {
this.span.addEvent("matrix.call.transceiver.direction", {
direction: stats.direction,
});
this.prev.direction = stats.direction;
}
const trackStats: TrackStats[] = [];
if (stats.sender) {
trackStats.push(stats.sender);
}
if (stats.receiver) {
trackStats.push(stats.receiver);
}
this.upsertTrackSpans(trackStats);
}
}

View File

@@ -38,6 +38,7 @@ import {
ConnectionStatsReport,
ByteSentStatsReport,
SummaryStatsReport,
CallFeedReport,
} from "matrix-js-sdk/src/webrtc/stats/statsReport";
import { setSpan } from "@opentelemetry/api/build/esm/trace/context-utils";
@@ -174,7 +175,7 @@ export class OTelGroupCallMembership {
userCalls.get(callTrackingInfo.deviceId).callId !==
callTrackingInfo.call.callId
) {
callTrackingInfo.span.end();
callTrackingInfo.end();
this.callsByCallId.delete(callTrackingInfo.call.callId);
}
}
@@ -330,25 +331,102 @@ export class OTelGroupCallMembership {
});
}
public onCallFeedStatsReport(report: GroupCallStatsReport<CallFeedReport>) {
if (!ElementCallOpenTelemetry.instance) return;
let call: OTelCall | undefined;
const callId = report.report?.callId;
if (callId) {
call = this.callsByCallId.get(callId);
}
if (!call) {
this.callMembershipSpan?.addEvent(
OTelStatsReportType.CallFeedReport + "_unknown_callId",
{
"call.callId": callId,
"call.opponentMemberId": report.report?.opponentMemberId
? report.report?.opponentMemberId
: "unknown",
}
);
logger.error(
`Received ${OTelStatsReportType.CallFeedReport} with unknown call ID: ${callId}`
);
return;
} else {
call.onCallFeedStats(report.report.callFeeds);
call.onTransceiverStats(report.report.transceiver);
}
}
public onConnectionStatsReport(
statsReport: GroupCallStatsReport<ConnectionStatsReport>
) {
if (!ElementCallOpenTelemetry.instance) return;
const type = OTelStatsReportType.ConnectionReport;
const data =
ObjectFlattener.flattenConnectionStatsReportObject(statsReport);
this.buildStatsEventSpan({ type, data });
this.buildCallStatsSpan(
OTelStatsReportType.ConnectionReport,
statsReport.report
);
}
public onByteSentStatsReport(
statsReport: GroupCallStatsReport<ByteSentStatsReport>
) {
if (!ElementCallOpenTelemetry.instance) return;
this.buildCallStatsSpan(
OTelStatsReportType.ByteSentReport,
statsReport.report
);
}
const type = OTelStatsReportType.ByteSentReport;
const data = ObjectFlattener.flattenByteSentStatsReportObject(statsReport);
this.buildStatsEventSpan({ type, data });
public buildCallStatsSpan(
type: OTelStatsReportType,
report: ByteSentStatsReport | ConnectionStatsReport
): void {
if (!ElementCallOpenTelemetry.instance) return;
let call: OTelCall | undefined;
const callId = report?.callId;
if (callId) {
call = this.callsByCallId.get(callId);
}
if (!call) {
this.callMembershipSpan?.addEvent(type + "_unknown_callid", {
"call.callId": callId,
"call.opponentMemberId": report.opponentMemberId
? report.opponentMemberId
: "unknown",
});
logger.error(`Received ${type} with unknown call ID: ${callId}`);
return;
}
const data = ObjectFlattener.flattenReportObject(type, report);
const ctx = opentelemetry.trace.setSpan(
opentelemetry.context.active(),
call.span
);
const options = {
links: [
{
context: call.span.spanContext(),
},
],
};
const span = ElementCallOpenTelemetry.instance.tracer.startSpan(
type,
options,
ctx
);
span.setAttribute("matrix.callId", callId);
span.setAttribute(
"matrix.opponentMemberId",
report.opponentMemberId ? report.opponentMemberId : "unknown"
);
span.addEvent("matrix.call.connection_stats_event", data);
span.end();
}
public onSummaryStatsReport(
@@ -381,45 +459,6 @@ export class OTelGroupCallMembership {
span.end();
}
}
private buildStatsEventSpan(event: OTelStatsReportEvent): void {
// @ TODO: fix this - Because on multiple calls we receive multiple stats report spans.
// This could be break if stats arrived in same time from different call objects.
if (this.statsReportSpan.span === undefined && this.callMembershipSpan) {
const ctx = setSpan(
opentelemetry.context.active(),
this.callMembershipSpan
);
this.statsReportSpan.span =
ElementCallOpenTelemetry.instance?.tracer.startSpan(
"matrix.groupCallMembership.statsReport",
undefined,
ctx
);
if (this.statsReportSpan.span === undefined) {
return;
}
this.statsReportSpan.span.setAttribute(
"matrix.confId",
this.groupCall.groupCallId
);
this.statsReportSpan.span.setAttribute("matrix.userId", this.myUserId);
this.statsReportSpan.span.setAttribute(
"matrix.displayName",
this.myMember ? this.myMember.name : "unknown-name"
);
this.statsReportSpan.span.addEvent(event.type, event.data);
this.statsReportSpan.stats.push(event);
} else if (
this.statsReportSpan.span !== undefined &&
this.callMembershipSpan
) {
this.statsReportSpan.span.addEvent(event.type, event.data);
this.statsReportSpan.span.end();
this.statsReportSpan = { span: undefined, stats: [] };
}
}
}
interface OTelStatsReportEvent {
@@ -428,7 +467,8 @@ interface OTelStatsReportEvent {
}
enum OTelStatsReportType {
ConnectionReport = "matrix.stats.connection",
ByteSentReport = "matrix.stats.byteSent",
ConnectionReport = "matrix.call.stats.connection",
ByteSentReport = "matrix.call.stats.byteSent",
SummaryReport = "matrix.stats.summary",
CallFeedReport = "matrix.stats.call_feed",
}

View File

@@ -23,16 +23,12 @@ import {
} from "matrix-js-sdk/src/webrtc/stats/statsReport";
export class ObjectFlattener {
public static flattenConnectionStatsReportObject(
statsReport: GroupCallStatsReport<ConnectionStatsReport>
public static flattenReportObject(
prefix: string,
report: ConnectionStatsReport | ByteSentStatsReport
): Attributes {
const flatObject = {};
ObjectFlattener.flattenObjectRecursive(
statsReport.report,
flatObject,
"matrix.stats.conn.",
0
);
ObjectFlattener.flattenObjectRecursive(report, flatObject, `${prefix}.`, 0);
return flatObject;
}

View File

@@ -1,146 +0,0 @@
/*
Copyright 2022 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { ChangeEvent, useCallback, useEffect, useState } from "react";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { useTranslation } from "react-i18next";
import { Button } from "../button";
import { useProfile } from "./useProfile";
import { FieldRow, InputField, ErrorMessage } from "../input/Input";
import { Modal, ModalContent } from "../Modal";
import { AvatarInputField } from "../input/AvatarInputField";
import styles from "./ProfileModal.module.css";
interface Props {
client: MatrixClient;
onClose: () => void;
[rest: string]: unknown;
}
export function ProfileModal({ client, ...rest }: Props) {
const { onClose } = rest;
const { t } = useTranslation();
const {
success,
error,
loading,
displayName: initialDisplayName,
avatarUrl,
saveProfile,
} = useProfile(client);
const [displayName, setDisplayName] = useState(initialDisplayName || "");
const [removeAvatar, setRemoveAvatar] = useState(false);
const onRemoveAvatar = useCallback(() => {
setRemoveAvatar(true);
}, []);
const onChangeDisplayName = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
setDisplayName(e.target.value);
},
[setDisplayName]
);
const onSubmit = useCallback(
(e) => {
e.preventDefault();
const data = new FormData(e.target);
const displayNameDataEntry = data.get("displayName");
const avatar: File | string = data.get("avatar");
const avatarSize =
typeof avatar == "string" ? avatar.length : avatar.size;
const displayName =
typeof displayNameDataEntry == "string"
? displayNameDataEntry
: displayNameDataEntry.name;
saveProfile({
displayName,
avatar: avatar && avatarSize > 0 ? avatar : undefined,
removeAvatar: removeAvatar && (!avatar || avatarSize === 0),
});
},
[saveProfile, removeAvatar]
);
useEffect(() => {
if (success) {
onClose();
}
}, [success, onClose]);
return (
<Modal title={t("Profile")} isDismissable {...rest}>
<ModalContent>
<form onSubmit={onSubmit}>
<FieldRow className={styles.avatarFieldRow}>
<AvatarInputField
id="avatar"
name="avatar"
label={t("Avatar")}
avatarUrl={avatarUrl}
displayName={displayName}
onRemoveAvatar={onRemoveAvatar}
/>
</FieldRow>
<FieldRow>
<InputField
id="userId"
name="userId"
label={t("User ID")}
type="text"
disabled
value={client.getUserId()}
/>
</FieldRow>
<FieldRow>
<InputField
id="displayName"
name="displayName"
label={t("Display name")}
type="text"
required
autoComplete="off"
placeholder={t("Display name")}
value={displayName}
onChange={onChangeDisplayName}
data-testid="profile_displayname"
/>
</FieldRow>
{error && (
<FieldRow>
<ErrorMessage error={error} />
</FieldRow>
)}
<FieldRow rightAlign>
<Button type="button" variant="secondary" onPress={onClose}>
Cancel
</Button>
<Button
type="submit"
disabled={loading}
data-testid="profile_submit"
>
{loading ? t("Saving…") : t("Save")}
</Button>
</FieldRow>
</form>
</ModalContent>
</Modal>
);
}

View File

@@ -17,20 +17,31 @@ limitations under the License.
.headline {
text-align: center;
margin-bottom: 60px;
white-space: pre;
}
.callEndedContent {
text-align: center;
max-width: 360px;
max-width: 450px;
}
.callEndedContent p {
font-size: var(--font-size-subtitle);
}
.callEndedContent h3 {
margin-bottom: 32px;
}
.callEndedButton {
margin-top: 54px;
margin-left: 30px;
margin-right: 30px !important;
}
.submitButton {
width: 100%;
margin-top: 54px;
margin-left: 30px;
margin-right: 30px !important;
}
.container {

View File

@@ -14,19 +14,129 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import React, { FormEventHandler, useCallback, useState } from "react";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { Trans, useTranslation } from "react-i18next";
import { useHistory } from "react-router-dom";
import styles from "./CallEndedView.module.css";
import { LinkButton } from "../button";
import feedbackStyle from "../input/FeedbackInput.module.css";
import { Button, LinkButton } from "../button";
import { useProfile } from "../profile/useProfile";
import { Subtitle, Body, Link, Headline } from "../typography/Typography";
import { Body, Link, Headline } from "../typography/Typography";
import { Header, HeaderLogo, LeftNav, RightNav } from "../Header";
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
import { FieldRow, InputField } from "../input/Input";
import { StarRatingInput } from "../input/StarRatingInput";
export function CallEndedView({ client }: { client: MatrixClient }) {
export function CallEndedView({
client,
isPasswordlessUser,
endedCallId,
}: {
client: MatrixClient;
isPasswordlessUser: boolean;
endedCallId: string;
}) {
const { t } = useTranslation();
const history = useHistory();
const { displayName } = useProfile(client);
const [surveySubmitted, setSurverySubmitted] = useState(false);
const [starRating, setStarRating] = useState(0);
const [submitting, setSubmitting] = useState(false);
const [submitDone, setSubmitDone] = useState(false);
const submitSurvery: FormEventHandler<HTMLFormElement> = useCallback(
(e) => {
e.preventDefault();
const data = new FormData(e.target as HTMLFormElement);
const feedbackText = data.get("feedbackText") as string;
PosthogAnalytics.instance.eventQualitySurvey.track(
endedCallId,
feedbackText,
starRating
);
setSubmitting(true);
setTimeout(() => {
setSubmitDone(true);
setTimeout(() => {
if (isPasswordlessUser) {
// setting this renders the callEndedView with the invitation to create an account
setSurverySubmitted(true);
} else {
// if the user already has an account immediately go back to the home screen
history.push("/");
}
}, 1000);
}, 1000);
},
[endedCallId, history, isPasswordlessUser, starRating]
);
const createAccountDialog = isPasswordlessUser && (
<div className={styles.callEndedContent}>
<Trans>
<p>Why not finish by setting up a password to keep your account?</p>
<p>
You'll be able to keep your name and set an avatar for use on future
calls
</p>
</Trans>
<LinkButton
className={styles.callEndedButton}
size="lg"
variant="default"
to="/register"
>
{t("Create account")}
</LinkButton>
</div>
);
const qualitySurveyDialog = (
<div className={styles.callEndedContent}>
<Trans>
<p>
We'd love to hear your feedback so we can improve your experience.
</p>
</Trans>
<form onSubmit={submitSurvery}>
<FieldRow>
<StarRatingInput starCount={5} onChange={setStarRating} required />
</FieldRow>
<FieldRow>
<InputField
className={feedbackStyle.feedback}
id="feedbackText"
name="feedbackText"
label={t("Your feedback")}
placeholder={t("Your feedback")}
type="textarea"
/>
</FieldRow>{" "}
<FieldRow>
{submitDone ? (
<Trans>
<p>Thanks for your feedback!</p>
</Trans>
) : (
<Button
type="submit"
className={styles.submitButton}
size="lg"
variant="default"
data-testid="home_go"
>
{submitting ? t("Submitting…") : t("Submit")}
</Button>
)}
</FieldRow>
</form>
</div>
);
return (
<>
@@ -39,27 +149,19 @@ export function CallEndedView({ client }: { client: MatrixClient }) {
<div className={styles.container}>
<main className={styles.main}>
<Headline className={styles.headline}>
{t("{{displayName}}, your call is now ended", { displayName })}
{surveySubmitted
? t("{{displayName}}, your call has ended.", {
displayName,
})
: t("{{displayName}}, your call has ended.", {
displayName,
}) +
"\n" +
t("How did it go?")}
</Headline>
<div className={styles.callEndedContent}>
<Trans>
<Subtitle>
Why not finish by setting up a password to keep your account?
</Subtitle>
<Subtitle>
You'll be able to keep your name and set an avatar for use on
future calls
</Subtitle>
</Trans>
<LinkButton
className={styles.callEndedButton}
size="lg"
variant="default"
to="/register"
>
{t("Create account")}
</LinkButton>
</div>
{!surveySubmitted && PosthogAnalytics.instance.isEnabled()
? qualitySurveyDialog
: createAccountDialog}
</main>
<Body className={styles.footer}>
<Link color="primary" to="/">

View File

@@ -34,7 +34,7 @@ import { useSentryGroupCallHandler } from "./useSentryGroupCallHandler";
import { useLocationNavigation } from "../useLocationNavigation";
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
import { useMediaHandler } from "../settings/useMediaHandler";
import { findDeviceByName, getDevices } from "../media-utils";
import { findDeviceByName, getNamedDevices } from "../media-utils";
declare global {
interface Window {
@@ -102,7 +102,7 @@ export function GroupCallView({
// Get the available devices so we can match the selected device
// to its ID. This involves getting a media stream (see docs on
// the function) so we only do it once and re-use the result.
const devices = await getDevices();
const devices = await getNamedDevices();
const { audioInput, videoInput } = ev.detail
.data as unknown as JoinCallData;
@@ -203,7 +203,11 @@ export function GroupCallView({
widget.api.transport.send(ElementWidgetActions.HangupCall, {});
}
if (!isPasswordlessUser && !isEmbedded) {
if (
!isPasswordlessUser &&
!isEmbedded &&
!PosthogAnalytics.instance.isEnabled()
) {
history.push("/");
}
}, [groupCall, leave, isPasswordlessUser, isEmbedded, history]);
@@ -268,8 +272,23 @@ export function GroupCallView({
);
}
} else if (left) {
if (isPasswordlessUser) {
return <CallEndedView client={client} />;
// The call ended view is shown for two reasons: prompting guests to create
// an account, and prompting users that have opted into analytics to provide
// feedback. We don't show a feedback prompt to widget users however (at
// least for now), because we don't yet have designs that would allow widget
// users to dismiss the feedback prompt and close the call window without
// submitting anything.
if (
isPasswordlessUser ||
(PosthogAnalytics.instance.isEnabled() && !isEmbedded)
) {
return (
<CallEndedView
endedCallId={groupCall.groupCallId}
client={client}
isPasswordlessUser={isPasswordlessUser}
/>
);
} else {
// If the user is a regular user, we'll have sent them back to the homepage,
// so just sit here & do nothing: otherwise we would (briefly) mount the

View File

@@ -82,17 +82,21 @@ limitations under the License.
bottom: 0;
}
.avatar {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
/* To make avatars scale smoothly with their tiles during animations, we
override the styles set on the element */
--avatarSize: calc(min(var(--tileWidth), var(--tileHeight)) / 2);
width: var(--avatarSize) !important;
height: var(--avatarSize) !important;
border-radius: 10000px !important;
/* CSS makes us put a condition here, even though all we want to do is
unconditionally select the container so we can use cqmin units */
@container videoTile (width > 0) {
.avatar {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
/* To make avatars scale smoothly with their tiles during animations, we
override the styles set on the element */
--avatarSize: 50cqmin; /* Half of the smallest dimension of the tile */
width: var(--avatarSize) !important;
height: var(--avatarSize) !important;
border-radius: 10000px !important;
}
}
@media (min-height: 300px) {

View File

@@ -1,5 +1,5 @@
/*
Copyright 2022 New Vector Ltd
Copyright 2022 - 2023 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -24,6 +24,7 @@ import { GroupCall } from "matrix-js-sdk/src/webrtc/groupCall";
import { CallFeed } from "matrix-js-sdk/src/webrtc/callFeed";
import classNames from "classnames";
import { useTranslation } from "react-i18next";
import { OverlayTriggerState } from "@react-stately/overlays";
import { JoinRule } from "matrix-js-sdk/src/@types/partials";
import type { IWidgetApiRequest } from "matrix-widget-api";
@@ -33,6 +34,8 @@ import {
MicButton,
VideoButton,
ScreenshareButton,
SettingsButton,
InviteButton,
} from "../button";
import {
Header,
@@ -41,19 +44,10 @@ import {
RoomHeaderInfo,
VersionMismatchWarning,
} from "../Header";
import {
VideoGrid,
useVideoGridLayout,
ChildrenProperties,
} from "../video-grid/VideoGrid";
import { VideoTileContainer } from "../video-grid/VideoTileContainer";
import { VideoGrid, useVideoGridLayout } from "../video-grid/VideoGrid";
import { GroupCallInspector } from "./GroupCallInspector";
import { OverflowMenu } from "./OverflowMenu";
import { GridLayoutMenu } from "./GridLayoutMenu";
import { Avatar } from "../Avatar";
import { UserMenuContainer } from "../UserMenuContainer";
import { useRageshakeRequestModal } from "../settings/submit-rageshake";
import { RageshakeRequestModal } from "./RageshakeRequestModal";
import { useMediaHandler } from "../settings/useMediaHandler";
import {
useNewGrid,
@@ -74,6 +68,11 @@ import { AudioSink } from "../video-grid/AudioSink";
import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts";
import { NewVideoGrid } from "../video-grid/NewVideoGrid";
import { OTelGroupCallMembership } from "../otel/OTelGroupCallMembership";
import { SettingsModal } from "../settings/SettingsModal";
import { InviteModal } from "./InviteModal";
import { useRageshakeRequestModal } from "../settings/submit-rageshake";
import { RageshakeRequestModal } from "./RageshakeRequestModal";
import { VideoTile } from "../video-grid/VideoTile";
const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
// There is currently a bug in Safari our our code with cloning and sending MediaStreams
@@ -128,7 +127,6 @@ export function InCallView({
}: Props) {
const { t } = useTranslation();
usePreventScroll();
const joinRule = useJoinRule(groupCall.room);
const containerRef1 = useRef<HTMLDivElement | null>(null);
const [containerRef2, bounds] = useMeasure({ polyfill: ResizeObserver });
@@ -151,11 +149,10 @@ export function InCallView({
const [audioContext, audioDestination] = useAudioContext();
const [showInspector] = useShowInspector();
const { modalState: feedbackModalState, modalProps: feedbackModalProps } =
useModalTriggerState();
const { hideScreensharing } = useUrlParams();
const joinRule = useJoinRule(groupCall.room);
useCallViewKeyboardShortcuts(
containerRef1,
toggleMicrophoneMuted,
@@ -219,6 +216,8 @@ export function InCallView({
focused: screenshareFeeds.length === 0 && callFeed === activeSpeaker,
isLocal: member.userId === localUserId && deviceId === localDeviceId,
presenter,
isSpeaker: callFeed === activeSpeaker,
largeBaseSize: false,
connectionState,
});
}
@@ -231,6 +230,7 @@ export function InCallView({
// Add the screenshares too
for (const screenshareFeed of screenshareFeeds) {
const member = screenshareFeed.getMember()!;
const deviceId = screenshareFeed.deviceId!;
const connectionState = participants
.get(member)
?.get(screenshareFeed.deviceId!)?.connectionState;
@@ -245,6 +245,9 @@ export function InCallView({
focused: true,
isLocal: screenshareFeed.isLocal(),
presenter: false,
isSpeaker: screenshareFeed === activeSpeaker,
largeBaseSize: true,
placeNear: `${member.userId} ${deviceId}`,
connectionState,
});
}
@@ -302,7 +305,7 @@ export function InCallView({
}
if (maximisedParticipant) {
return (
<VideoTileContainer
<VideoTile
targetHeight={bounds.height}
targetWidth={bounds.width}
key={maximisedParticipant.id}
@@ -310,10 +313,10 @@ export function InCallView({
getAvatar={renderAvatar}
audioContext={audioContext}
audioDestination={audioDestination}
disableSpeakingIndicator={true}
maximised={Boolean(maximisedParticipant)}
fullscreen={maximisedParticipant === fullscreenParticipant}
onFullscreen={toggleFullscreen}
showSpeakingIndicator={false}
/>
);
}
@@ -324,17 +327,16 @@ export function InCallView({
layout={layout}
disableAnimations={prefersReducedMotion || isSafari}
>
{({ item, ...rest }: ChildrenProperties) => (
<VideoTileContainer
item={item}
{(props) => (
<VideoTile
getAvatar={renderAvatar}
audioContext={audioContext}
audioDestination={audioDestination}
disableSpeakingIndicator={items.length < 3}
maximised={false}
fullscreen={false}
onFullscreen={toggleFullscreen}
{...rest}
showSpeakingIndicator={items.length > 2}
{...props}
/>
)}
</Grid>
@@ -346,6 +348,36 @@ export function InCallView({
modalProps: rageshakeRequestModalProps,
} = useRageshakeRequestModal(groupCall.room.roomId);
const {
modalState: settingsModalState,
modalProps: settingsModalProps,
}: {
modalState: OverlayTriggerState;
modalProps: {
isOpen: boolean;
onClose: () => void;
};
} = useModalTriggerState();
const openSettings = useCallback(() => {
settingsModalState.open();
}, [settingsModalState]);
const {
modalState: inviteModalState,
modalProps: inviteModalProps,
}: {
modalState: OverlayTriggerState;
modalProps: {
isOpen: boolean;
onClose: () => void;
};
} = useModalTriggerState();
const openInvite = useCallback(() => {
inviteModalState.open();
}, [inviteModalState]);
const containerClasses = classNames(styles.inRoom, {
[styles.maximised]: maximisedParticipant,
});
@@ -405,21 +437,13 @@ export function InCallView({
);
}
if (!maximisedParticipant) {
buttons.push(
<OverflowMenu
key="4"
inCall
roomIdOrAlias={roomIdOrAlias}
groupCall={groupCall}
showInvite={joinRule === JoinRule.Public}
feedbackModalState={feedbackModalState}
feedbackModalProps={feedbackModalProps}
/>
);
buttons.push(<SettingsButton key="4" onPress={openSettings} />);
}
}
buttons.push(<HangupButton key="6" onPress={onLeave} />);
buttons.push(
<HangupButton key="6" onPress={onLeave} data-testid="incall_leave" />
);
footer = <div className={styles.footer}>{buttons}</div>;
}
@@ -437,7 +461,9 @@ export function InCallView({
</LeftNav>
<RightNav>
<GridLayoutMenu layout={layout} setLayout={setLayout} />
<UserMenuContainer preventNavigation />
{joinRule === JoinRule.Public && (
<InviteButton variant="icon" onClick={openInvite} />
)}
</RightNav>
</Header>
)}
@@ -451,12 +477,22 @@ export function InCallView({
otelGroupCallMembership={otelGroupCallMembership}
show={showInspector}
/>
{rageshakeRequestModalState.isOpen && (
{rageshakeRequestModalState.isOpen && !noControls && (
<RageshakeRequestModal
{...rageshakeRequestModalProps}
roomIdOrAlias={roomIdOrAlias}
/>
)}
{settingsModalState.isOpen && (
<SettingsModal
client={client}
roomId={groupCall.room.roomId}
{...settingsModalProps}
/>
)}
{inviteModalState.isOpen && (
<InviteModal roomIdOrAlias={roomIdOrAlias} {...inviteModalProps} />
)}
</div>
);
}

View File

@@ -1,143 +0,0 @@
/*
Copyright 2022 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useCallback } from "react";
import { Item } from "@react-stately/collections";
import { GroupCall } from "matrix-js-sdk/src/webrtc/groupCall";
import { OverlayTriggerState } from "@react-stately/overlays";
import { useTranslation } from "react-i18next";
import { Button } from "../button";
import { Menu } from "../Menu";
import { PopoverMenuTrigger } from "../popover/PopoverMenu";
import { ReactComponent as SettingsIcon } from "../icons/Settings.svg";
import { ReactComponent as AddUserIcon } from "../icons/AddUser.svg";
import { ReactComponent as OverflowIcon } from "../icons/Overflow.svg";
import { ReactComponent as FeedbackIcon } from "../icons/Feedback.svg";
import { useModalTriggerState } from "../Modal";
import { SettingsModal } from "../settings/SettingsModal";
import { InviteModal } from "./InviteModal";
import { TooltipTrigger } from "../Tooltip";
import { FeedbackModal } from "./FeedbackModal";
import { Config } from "../config/Config";
interface Props {
roomIdOrAlias: string;
inCall: boolean;
groupCall: GroupCall;
showInvite: boolean;
feedbackModalState: OverlayTriggerState;
feedbackModalProps: {
isOpen: boolean;
onClose: () => void;
};
}
export function OverflowMenu({
roomIdOrAlias,
inCall,
groupCall,
showInvite,
feedbackModalState,
feedbackModalProps,
}: Props) {
const { t } = useTranslation();
const {
modalState: inviteModalState,
modalProps: inviteModalProps,
}: {
modalState: OverlayTriggerState;
modalProps: {
isOpen: boolean;
onClose: () => void;
};
} = useModalTriggerState();
const {
modalState: settingsModalState,
modalProps: settingsModalProps,
}: {
modalState: OverlayTriggerState;
modalProps: {
isOpen: boolean;
onClose: () => void;
};
} = useModalTriggerState();
// TODO: On closing modal, focus should be restored to the trigger button
// https://github.com/adobe/react-spectrum/issues/2444
const onAction = useCallback(
(key) => {
switch (key) {
case "invite":
inviteModalState.open();
break;
case "settings":
settingsModalState.open();
break;
case "feedback":
feedbackModalState.open();
break;
}
},
[feedbackModalState, inviteModalState, settingsModalState]
);
const tooltip = useCallback(() => t("More"), [t]);
return (
<>
<PopoverMenuTrigger disableOnState>
<TooltipTrigger tooltip={tooltip} placement="top">
<Button variant="toolbar" data-testid="call_more">
<OverflowIcon />
</Button>
</TooltipTrigger>
{(props: JSX.IntrinsicAttributes) => (
<Menu {...props} label={t("More menu")} onAction={onAction}>
{showInvite && (
<Item key="invite" textValue={t("Invite people")}>
<AddUserIcon />
<span data-testid="call_moreInvite">{t("Invite people")}</span>
</Item>
)}
<Item key="settings" textValue={t("Settings")}>
<SettingsIcon />
<span>{t("Settings")}</span>
</Item>
{Config.get().rageshake?.submit_url && (
<Item key="feedback" textValue={t("Submit feedback")}>
<FeedbackIcon />
<span>{t("Submit feedback")}</span>
</Item>
)}
</Menu>
)}
</PopoverMenuTrigger>
{settingsModalState.isOpen && <SettingsModal {...settingsModalProps} />}
{inviteModalState.isOpen && (
<InviteModal roomIdOrAlias={roomIdOrAlias} {...inviteModalProps} />
)}
{feedbackModalState.isOpen && (
<FeedbackModal
{...feedbackModalProps}
roomId={groupCall?.room.roomId}
inCall={inCall}
/>
)}
</>
);
}

View File

@@ -1,5 +1,5 @@
/*
Copyright 2022 New Vector Ltd
Copyright 2022 - 2023 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -27,7 +27,7 @@ import { useTranslation } from "react-i18next";
import { useDelayedState } from "../useDelayedState";
import { useModalTriggerState } from "../Modal";
import { InviteModal } from "./InviteModal";
import { HangupButton, InviteButton } from "../button";
import { HangupButton, InviteButton, SettingsButton } from "../button";
import { Header, LeftNav, RightNav, RoomSetupHeaderInfo } from "../Header";
import styles from "./PTTCallView.module.css";
import { Facepile } from "../Facepile";
@@ -41,10 +41,10 @@ import { ReactComponent as AudioIcon } from "../icons/Audio.svg";
import { usePTTSounds } from "../sound/usePttSounds";
import { PTTClips } from "../sound/PTTClips";
import { GroupCallInspector } from "./GroupCallInspector";
import { OverflowMenu } from "./OverflowMenu";
import { Size } from "../Avatar";
import { ParticipantInfo } from "./useGroupCall";
import { OTelGroupCallMembership } from "../otel/OTelGroupCallMembership";
import { SettingsModal } from "../settings/SettingsModal";
function getPromptText(
networkWaiting: boolean,
@@ -126,8 +126,9 @@ export const PTTCallView: React.FC<Props> = ({
const { t } = useTranslation();
const { modalState: inviteModalState, modalProps: inviteModalProps } =
useModalTriggerState();
const { modalState: feedbackModalState, modalProps: feedbackModalProps } =
const { modalState: settingsModalState, modalProps: settingsModalProps } =
useModalTriggerState();
const [containerRef, bounds] = useMeasure({ polyfill: ResizeObserver });
const facepileSize = bounds.width < 800 ? Size.SM : Size.MD;
const showControls = bounds.height > 500;
@@ -232,14 +233,7 @@ export const PTTCallView: React.FC<Props> = ({
/>
</div>
<div className={styles.footer}>
<OverflowMenu
inCall
roomIdOrAlias={roomIdOrAlias}
groupCall={groupCall}
showInvite={false}
feedbackModalState={feedbackModalState}
feedbackModalProps={feedbackModalProps}
/>
<SettingsButton onPress={() => settingsModalState.open()} />
{!isEmbedded && <HangupButton onPress={onLeave} />}
<InviteButton onPress={() => inviteModalState.open()} />
</div>
@@ -265,7 +259,7 @@ export const PTTCallView: React.FC<Props> = ({
<div className={styles.talkingInfo} />
))}
<PTTButton
enabled={!feedbackModalState.isOpen}
enabled={!inviteModalState.isOpen && !settingsModalState.isOpen}
showTalkOverError={showTalkOverError}
activeSpeakerUserId={activeSpeakerUserId}
activeSpeakerDisplayName={activeSpeakerDisplayName}
@@ -312,6 +306,13 @@ export const PTTCallView: React.FC<Props> = ({
</div>
</div>
{settingsModalState.isOpen && (
<SettingsModal
client={client}
roomId={groupCall.room.roomId}
{...settingsModalProps}
/>
)}
{inviteModalState.isOpen && showControls && (
<InviteModal roomIdOrAlias={roomIdOrAlias} {...inviteModalProps} />
)}

View File

@@ -1,5 +1,5 @@
/*
Copyright 2021-2022 New Vector Ltd
Copyright 2021-2023 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -24,7 +24,6 @@ import { RoomAuthView } from "./RoomAuthView";
import { GroupCallLoader } from "./GroupCallLoader";
import { GroupCallView } from "./GroupCallView";
import { useUrlParams } from "../UrlParams";
import { MediaHandlerProvider } from "../settings/useMediaHandler";
import { useRegisterPasswordlessUser } from "../auth/useRegisterPasswordlessUser";
import { translatedError } from "../TranslatedError";
import { useOptInAnalytics } from "../settings/useSetting";
@@ -101,15 +100,13 @@ export const RoomPage: FC = () => {
}
return (
<MediaHandlerProvider client={client}>
<GroupCallLoader
client={client}
roomIdOrAlias={roomIdOrAlias}
viaServers={viaServers}
createPtt={isPtt}
>
{groupCallView}
</GroupCallLoader>
</MediaHandlerProvider>
<GroupCallLoader
client={client}
roomIdOrAlias={roomIdOrAlias}
viaServers={viaServers}
createPtt={isPtt}
>
{groupCallView}
</GroupCallLoader>
);
};

View File

@@ -1,5 +1,5 @@
/*
Copyright 2022 New Vector Ltd
Copyright 2022 - 2023 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -14,21 +14,22 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import React, { useCallback } from "react";
import useMeasure from "react-use-measure";
import { ResizeObserver } from "@juggle/resize-observer";
import { GroupCallState } from "matrix-js-sdk/src/webrtc/groupCall";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { useTranslation } from "react-i18next";
import { OverlayTriggerState } from "@react-stately/overlays";
import { MicButton, VideoButton } from "../button";
import { MicButton, SettingsButton, VideoButton } from "../button";
import { useMediaStream } from "../video-grid/useMediaStream";
import { OverflowMenu } from "./OverflowMenu";
import { Avatar } from "../Avatar";
import { useProfile } from "../profile/useProfile";
import styles from "./VideoPreview.module.css";
import { Body } from "../typography/Typography";
import { useModalTriggerState } from "../Modal";
import { SettingsModal } from "../settings/SettingsModal";
interface Props {
client: MatrixClient;
@@ -59,8 +60,20 @@ export function VideoPreview({
const [previewRef, previewBounds] = useMeasure({ polyfill: ResizeObserver });
const avatarSize = (previewBounds.height - 66) / 2;
const { modalState: feedbackModalState, modalProps: feedbackModalProps } =
useModalTriggerState();
const {
modalState: settingsModalState,
modalProps: settingsModalProps,
}: {
modalState: OverlayTriggerState;
modalProps: {
isOpen: boolean;
onClose: () => void;
};
} = useModalTriggerState();
const openSettings = useCallback(() => {
settingsModalState.open();
}, [settingsModalState]);
return (
<div className={styles.preview} ref={previewRef}>
@@ -101,17 +114,13 @@ export function VideoPreview({
muted={localVideoMuted}
onPress={toggleLocalVideoMuted}
/>
<OverflowMenu
roomIdOrAlias={roomIdOrAlias}
feedbackModalState={feedbackModalState}
feedbackModalProps={feedbackModalProps}
inCall={false}
groupCall={undefined}
showInvite={false}
/>
<SettingsButton onPress={openSettings} />
</div>
</>
)}
{settingsModalState.isOpen && (
<SettingsModal client={client} {...settingsModalProps} />
)}
</div>
);
}

View File

@@ -34,6 +34,7 @@ import {
ByteSentStatsReport,
ConnectionStatsReport,
SummaryStatsReport,
CallFeedReport,
} from "matrix-js-sdk/src/webrtc/stats/statsReport";
import { usePageUnload } from "./usePageUnload";
@@ -64,7 +65,7 @@ export interface UseGroupCallReturnType {
localVideoMuted: boolean;
error: TranslatedError | null;
initLocalCallFeed: () => void;
enter: () => void;
enter: () => Promise<void>;
leave: () => void;
toggleLocalVideoMuted: () => void;
toggleMicrophoneMuted: () => void;
@@ -363,6 +364,12 @@ export function useGroupCall(
groupCallOTelMembership?.onSummaryStatsReport(report);
}
function onCallFeedStatsReport(
report: GroupCallStatsReport<CallFeedReport>
): void {
groupCallOTelMembership?.onCallFeedStatsReport(report);
}
groupCall.on(GroupCallEvent.GroupCallStateChanged, onGroupCallStateChanged);
groupCall.on(GroupCallEvent.UserMediaFeedsChanged, onUserMediaFeedsChanged);
groupCall.on(
@@ -387,6 +394,11 @@ export function useGroupCall(
onByteSentStatsReport
);
groupCall.on(GroupCallStatsReportEvent.SummaryStats, onSummaryStatsReport);
groupCall.on(
GroupCallStatsReportEvent.CallFeedStats,
onCallFeedStatsReport
);
groupCall.room.currentState.on(
RoomStateEvent.Update,
checkForParallelCalls
@@ -450,6 +462,10 @@ export function useGroupCall(
GroupCallStatsReportEvent.SummaryStats,
onSummaryStatsReport
);
groupCall.removeListener(
GroupCallStatsReportEvent.CallFeedStats,
onCallFeedStatsReport
);
groupCall.room.currentState.off(
RoomStateEvent.Update,
checkForParallelCalls
@@ -467,7 +483,7 @@ export function useGroupCall(
[groupCall]
);
const enter = useCallback(() => {
const enter = useCallback(async () => {
if (
groupCall.state !== GroupCallState.LocalCallFeedUninitialized &&
groupCall.state !== GroupCallState.LocalCallFeedInitialized
@@ -482,7 +498,7 @@ export function useGroupCall(
// have started tracking by the time calls start getting created.
groupCallOTelMembership?.onJoinCall();
groupCall.enter().catch((error) => {
await groupCall.enter().catch((error) => {
console.error(error);
updateState({ error });
});

View File

@@ -1,5 +1,5 @@
/*
Copyright 2022 New Vector Ltd
Copyright 2022 - 2023 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -14,28 +14,22 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useCallback, useEffect } from "react";
import React, { useCallback } from "react";
import { randomString } from "matrix-js-sdk/src/randomstring";
import { useTranslation } from "react-i18next";
import { Modal, ModalContent } from "../Modal";
import { Button } from "../button";
import { FieldRow, InputField, ErrorMessage } from "../input/Input";
import {
useSubmitRageshake,
useRageshakeRequest,
} from "../settings/submit-rageshake";
import { useSubmitRageshake, useRageshakeRequest } from "./submit-rageshake";
import { Body } from "../typography/Typography";
import styles from "../input/SelectInput.module.css";
import feedbackStyles from "../input/FeedbackInput.module.css";
interface Props {
inCall: boolean;
roomId: string;
onClose?: () => void;
// TODO: add all props for for <Modal>
[index: string]: unknown;
roomId?: string;
}
export function FeedbackModal({ inCall, roomId, onClose, ...rest }: Props) {
export function FeedbackSettingsTab({ roomId }: Props) {
const { t } = useTranslation();
const { submitRageshake, sending, sent, error } = useSubmitRageshake();
const sendRageshakeRequest = useRageshakeRequest();
@@ -57,37 +51,36 @@ export function FeedbackModal({ inCall, roomId, onClose, ...rest }: Props) {
roomId,
});
if (inCall && sendLogs) {
if (roomId && sendLogs) {
sendRageshakeRequest(roomId, rageshakeRequestId);
}
},
[inCall, submitRageshake, roomId, sendRageshakeRequest]
[submitRageshake, roomId, sendRageshakeRequest]
);
useEffect(() => {
if (sent) {
onClose();
}
}, [sent, onClose]);
return (
<Modal
title={t("Submit feedback")}
isDismissable
onClose={onClose}
{...rest}
>
<ModalContent>
<Body>{t("Having trouble? Help us fix it.")}</Body>
<form onSubmit={onSubmitFeedback}>
<FieldRow>
<InputField
id="description"
name="description"
label={t("Description (optional)")}
type="textarea"
/>
</FieldRow>
<div>
<h4 className={styles.label}>{t("Submit feedback")}</h4>
<Body>
{t(
"If you are experiencing issues or simply would like to provide some feedback, please send us a short description below."
)}
</Body>
<form onSubmit={onSubmitFeedback}>
<FieldRow>
<InputField
className={feedbackStyles.feedback}
id="description"
name="description"
label={t("Your feedback")}
placeholder={t("Your feedback")}
type="textarea"
disabled={sending || sent}
/>
</FieldRow>
{sent ? (
<Body> {t("Thanks, we received your feedback!")}</Body>
) : (
<FieldRow>
<InputField
id="sendLogs"
@@ -96,19 +89,17 @@ export function FeedbackModal({ inCall, roomId, onClose, ...rest }: Props) {
type="checkbox"
defaultChecked
/>
</FieldRow>
{error && (
<FieldRow>
<ErrorMessage error={error} />
</FieldRow>
)}
<FieldRow>
{error && (
<FieldRow>
<ErrorMessage error={error} />
</FieldRow>
)}
<Button type="submit" disabled={sending}>
{sending ? t("Submitting feedback…") : t("Submit feedback")}
{sending ? t("Submitting…") : t("Submit")}
</Button>
</FieldRow>
</form>
</ModalContent>
</Modal>
)}
</form>
</div>
);
}

View File

@@ -1,5 +1,5 @@
/*
Copyright 2022 New Vector Ltd
Copyright 2022 - 2023 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -14,6 +14,12 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
.content {
width: 100%;
max-width: 350px;
align-self: center;
}
.avatarFieldRow {
justify-content: center;
}

View File

@@ -0,0 +1,113 @@
/*
Copyright 2022 - 2023 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useCallback, useEffect, useRef } from "react";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { useTranslation } from "react-i18next";
import { useProfile } from "../profile/useProfile";
import { FieldRow, InputField, ErrorMessage } from "../input/Input";
import { AvatarInputField } from "../input/AvatarInputField";
import styles from "./ProfileSettingsTab.module.css";
interface Props {
client: MatrixClient;
}
export function ProfileSettingsTab({ client }: Props) {
const { t } = useTranslation();
const { error, displayName, avatarUrl, saveProfile } = useProfile(client);
const formRef = useRef<HTMLFormElement | null>(null);
const formChanged = useRef(false);
const onFormChange = useCallback(() => {
formChanged.current = true;
}, []);
const removeAvatar = useRef(false);
const onRemoveAvatar = useCallback(() => {
removeAvatar.current = true;
formChanged.current = true;
}, []);
useEffect(() => {
const form = formRef.current!;
// Auto-save when the user dismisses this component
return () => {
if (formChanged.current) {
const data = new FormData(form);
const displayNameDataEntry = data.get("displayName");
const avatar = data.get("avatar");
const avatarSize =
typeof avatar == "string" ? avatar.length : avatar?.size ?? 0;
const displayName =
typeof displayNameDataEntry == "string"
? displayNameDataEntry
: displayNameDataEntry?.name ?? null;
saveProfile({
displayName,
avatar: avatar && avatarSize > 0 ? avatar : undefined,
removeAvatar: removeAvatar.current && (!avatar || avatarSize === 0),
});
}
};
}, [saveProfile]);
return (
<form onChange={onFormChange} ref={formRef} className={styles.content}>
<FieldRow className={styles.avatarFieldRow}>
<AvatarInputField
id="avatar"
name="avatar"
label={t("Avatar")}
avatarUrl={avatarUrl}
displayName={displayName}
onRemoveAvatar={onRemoveAvatar}
/>
</FieldRow>
<FieldRow>
<InputField
id="userId"
name="userId"
label={t("Username")}
type="text"
disabled
value={client.getUserId()!}
/>
</FieldRow>
<FieldRow>
<InputField
id="displayName"
name="displayName"
label={t("Display name")}
type="text"
required
autoComplete="off"
placeholder={t("Display name")}
defaultValue={displayName}
data-testid="profile_displayname"
/>
</FieldRow>
{error && (
<FieldRow>
<ErrorMessage error={error} />
</FieldRow>
)}
</form>
);
}

View File

@@ -19,6 +19,10 @@ limitations under the License.
height: 480px;
}
.settingsModal p {
color: var(--secondary-content);
}
.tabContainer {
padding: 27px 20px;
}
@@ -26,12 +30,3 @@ limitations under the License.
.fieldRowText {
margin-bottom: 0;
}
/*
This style guarantees a fixed width of the tab bar in the settings window.
The "Developer" item in the tab bar can be toggled.
Without a defined width activating the developer tab makes the tab container jump to the right.
*/
.tabLabel {
min-width: 80px;
}

View File

@@ -1,5 +1,5 @@
/*
Copyright 2022 New Vector Ltd
Copyright 2022 - 2023 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -14,9 +14,10 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import React, { useCallback, useState } from "react";
import { Item } from "@react-stately/collections";
import { Trans, useTranslation } from "react-i18next";
import { MatrixClient } from "matrix-js-sdk";
import { Modal } from "../Modal";
import styles from "./SettingsModal.module.css";
@@ -25,6 +26,8 @@ import { ReactComponent as AudioIcon } from "../icons/Audio.svg";
import { ReactComponent as VideoIcon } from "../icons/Video.svg";
import { ReactComponent as DeveloperIcon } from "../icons/Developer.svg";
import { ReactComponent as OverflowIcon } from "../icons/Overflow.svg";
import { ReactComponent as UserIcon } from "../icons/User.svg";
import { ReactComponent as FeedbackIcon } from "../icons/Feedback.svg";
import { SelectInput } from "../input/SelectInput";
import { useMediaHandler } from "./useMediaHandler";
import {
@@ -39,9 +42,14 @@ import { Button } from "../button";
import { useDownloadDebugLog } from "./submit-rageshake";
import { Body, Caption } from "../typography/Typography";
import { AnalyticsNotice } from "../analytics/AnalyticsNotice";
import { ProfileSettingsTab } from "./ProfileSettingsTab";
import { FeedbackSettingsTab } from "./FeedbackSettingsTab";
interface Props {
isOpen: boolean;
client: MatrixClient;
roomId?: string;
defaultTab?: string;
onClose: () => void;
}
@@ -57,7 +65,9 @@ export const SettingsModal = (props: Props) => {
audioOutput,
audioOutputs,
setAudioOutput,
useDeviceNames,
} = useMediaHandler();
useDeviceNames();
const [spatialAudio, setSpatialAudio] = useSpatialAudio();
const [showInspector, setShowInspector] = useShowInspector();
@@ -68,6 +78,15 @@ export const SettingsModal = (props: Props) => {
const downloadDebugLog = useDownloadDebugLog();
const [selectedTab, setSelectedTab] = useState<string | undefined>();
const onSelectedTabChanged = useCallback(
(tab) => {
setSelectedTab(tab);
},
[setSelectedTab]
);
const optInDescription = (
<Caption>
<Trans>
@@ -87,8 +106,13 @@ export const SettingsModal = (props: Props) => {
className={styles.settingsModal}
{...props}
>
<TabContainer className={styles.tabContainer}>
<TabContainer
onSelectionChange={onSelectedTabChanged}
selectedKey={selectedTab ?? props.defaultTab ?? "audio"}
className={styles.tabContainer}
>
<TabItem
key="audio"
title={
<>
<AudioIcon width={16} height={16} />
@@ -145,6 +169,7 @@ export const SettingsModal = (props: Props) => {
</FieldRow>
</TabItem>
<TabItem
key="video"
title={
<>
<VideoIcon width={16} height={16} />
@@ -167,6 +192,29 @@ export const SettingsModal = (props: Props) => {
</SelectInput>
</TabItem>
<TabItem
key="profile"
title={
<>
<UserIcon width={15} height={15} />
<span>{t("Profile")}</span>
</>
}
>
<ProfileSettingsTab client={props.client} />
</TabItem>
<TabItem
key="feedback"
title={
<>
<FeedbackIcon width={16} height={16} />
<span>{t("Feedback")}</span>
</>
}
>
<FeedbackSettingsTab roomId={props.roomId} />
</TabItem>
<TabItem
key="more"
title={
<>
<OverflowIcon width={16} height={16} />
@@ -174,18 +222,10 @@ export const SettingsModal = (props: Props) => {
</>
}
>
<h4>Analytics</h4>
<FieldRow>
<InputField
id="optInAnalytics"
type="checkbox"
checked={optInAnalytics}
description={optInDescription}
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
setOptInAnalytics(event.target.checked)
}
/>
</FieldRow>
<h4>Developer</h4>
<p>
Version: {(import.meta.env.VITE_APP_VERSION as string) || "dev"}
</p>
<FieldRow>
<InputField
id="developerSettingsTab"
@@ -200,9 +240,22 @@ export const SettingsModal = (props: Props) => {
}
/>
</FieldRow>
<h4>Analytics</h4>
<FieldRow>
<InputField
id="optInAnalytics"
type="checkbox"
checked={optInAnalytics}
description={optInDescription}
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
setOptInAnalytics(event.target.checked)
}
/>
</FieldRow>
</TabItem>
{developerSettingsTab && (
<TabItem
key="developer"
title={
<>
<DeveloperIcon width={16} height={16} />

View File

@@ -101,6 +101,7 @@ export function useSubmitRageshake(): {
body.append("user_agent", userAgent);
body.append("installed_pwa", "false");
body.append("touch_input", touchInput);
body.append("call_backend", "full-mesh");
if (client) {
const userId = client.getUserId();

View File

@@ -1,5 +1,5 @@
/*
Copyright 2022 New Vector Ltd
Copyright 2022 - 2023 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -14,25 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
/* eslint-disable @typescript-eslint/ban-ts-comment */
/*
Copyright 2022 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { MatrixClient } from "matrix-js-sdk/src/client";
import { MediaHandlerEvent } from "matrix-js-sdk/src/webrtc/mediaHandler";
import React, {
useState,
useEffect,
@@ -41,18 +23,27 @@ import React, {
useContext,
createContext,
ReactNode,
useRef,
} from "react";
import { useClient } from "../ClientContext";
import { getNamedDevices } from "../media-utils";
export interface MediaHandlerContextInterface {
audioInput: string;
audioInput: string | undefined;
audioInputs: MediaDeviceInfo[];
setAudioInput: (deviceId: string) => void;
videoInput: string;
videoInput: string | undefined;
videoInputs: MediaDeviceInfo[];
setVideoInput: (deviceId: string) => void;
audioOutput: string;
audioOutput: string | undefined;
audioOutputs: MediaDeviceInfo[];
setAudioOutput: (deviceId: string) => void;
/**
* A hook which requests for devices to be named. This requires media
* permissions.
*/
useDeviceNames: () => void;
}
const MediaHandlerContext =
@@ -63,6 +54,7 @@ interface MediaPreferences {
videoInput?: string;
audioOutput?: string;
}
function getMediaPreferences(): MediaPreferences {
const mediaPreferences = localStorage.getItem("matrix-media-preferences");
@@ -70,10 +62,10 @@ function getMediaPreferences(): MediaPreferences {
try {
return JSON.parse(mediaPreferences);
} catch (e) {
return undefined;
return {};
}
} else {
return undefined;
return {};
}
}
@@ -88,11 +80,13 @@ function updateMediaPreferences(newPreferences: MediaPreferences): void {
})
);
}
interface Props {
client: MatrixClient;
children: ReactNode;
}
export function MediaHandlerProvider({ client, children }: Props): JSX.Element {
export function MediaHandlerProvider({ children }: Props): JSX.Element {
const { client } = useClient();
const [
{
audioInput,
@@ -103,118 +97,109 @@ export function MediaHandlerProvider({ client, children }: Props): JSX.Element {
audioOutputs,
},
setState,
] = useState(() => {
const mediaPreferences = getMediaPreferences();
const mediaHandler = client.getMediaHandler();
] = useState(() => ({
audioInput: undefined as string | undefined,
videoInput: undefined as string | undefined,
audioOutput: undefined as string | undefined,
audioInputs: [] as MediaDeviceInfo[],
videoInputs: [] as MediaDeviceInfo[],
audioOutputs: [] as MediaDeviceInfo[],
}));
mediaHandler.restoreMediaSettings(
mediaPreferences?.audioInput,
mediaPreferences?.videoInput
);
// A ref counting the number of components currently mounted that want
// to know device names
const numComponentsWantingNames = useRef(0);
return {
// @ts-ignore, ignore that audioInput is a private members of mediaHandler
audioInput: mediaHandler.audioInput,
// @ts-ignore, ignore that videoInput is a private members of mediaHandler
videoInput: mediaHandler.videoInput,
audioOutput: undefined,
audioInputs: [],
videoInputs: [],
audioOutputs: [],
};
});
const updateDevices = useCallback(
async (client: MatrixClient, initial: boolean) => {
// Only request device names if components actually want them, because it
// could trigger an extra permission pop-up
const devices = await (numComponentsWantingNames.current > 0
? getNamedDevices()
: navigator.mediaDevices.enumerateDevices());
const mediaPreferences = getMediaPreferences();
const audioInputs = devices.filter((d) => d.kind === "audioinput");
const videoInputs = devices.filter((d) => d.kind === "videoinput");
const audioOutputs = devices.filter((d) => d.kind === "audiooutput");
const audioInput = (
mediaPreferences.audioInput === undefined
? audioInputs.at(0)
: audioInputs.find(
(d) => d.deviceId === mediaPreferences.audioInput
) ?? audioInputs.at(0)
)?.deviceId;
const videoInput = (
mediaPreferences.videoInput === undefined
? videoInputs.at(0)
: videoInputs.find(
(d) => d.deviceId === mediaPreferences.videoInput
) ?? videoInputs.at(0)
)?.deviceId;
const audioOutput =
mediaPreferences.audioOutput === undefined
? undefined
: audioOutputs.find(
(d) => d.deviceId === mediaPreferences.audioOutput
)?.deviceId;
updateMediaPreferences({ audioInput, videoInput, audioOutput });
setState({
audioInput,
videoInput,
audioOutput,
audioInputs,
videoInputs,
audioOutputs,
});
if (
initial ||
audioInput !== mediaPreferences.audioInput ||
videoInput !== mediaPreferences.videoInput
) {
client.getMediaHandler().setMediaInputs(audioInput, videoInput);
}
},
[setState]
);
const useDeviceNames = useCallback(() => {
// This is a little weird from React's perspective as it looks like a
// dynamic hook, but it works
// eslint-disable-next-line react-hooks/rules-of-hooks
useEffect(() => {
if (client) {
numComponentsWantingNames.current++;
if (numComponentsWantingNames.current === 1)
updateDevices(client, false);
return () => void numComponentsWantingNames.current--;
}
}, []);
}, [client, updateDevices]);
useEffect(() => {
const mediaHandler = client.getMediaHandler();
if (client) {
updateDevices(client, true);
const onDeviceChange = () => updateDevices(client, false);
navigator.mediaDevices.addEventListener("devicechange", onDeviceChange);
function updateDevices(): void {
navigator.mediaDevices.enumerateDevices().then((devices) => {
const mediaPreferences = getMediaPreferences();
const audioInputs = devices.filter(
(device) => device.kind === "audioinput"
return () => {
navigator.mediaDevices.removeEventListener(
"devicechange",
onDeviceChange
);
const audioConnected = audioInputs.some(
// @ts-ignore
(device) => device.deviceId === mediaHandler.audioInput
);
// @ts-ignore
let audioInput = mediaHandler.audioInput;
if (!audioConnected && audioInputs.length > 0) {
audioInput = audioInputs[0].deviceId;
}
const videoInputs = devices.filter(
(device) => device.kind === "videoinput"
);
const videoConnected = videoInputs.some(
// @ts-ignore
(device) => device.deviceId === mediaHandler.videoInput
);
// @ts-ignore
let videoInput = mediaHandler.videoInput;
if (!videoConnected && videoInputs.length > 0) {
videoInput = videoInputs[0].deviceId;
}
const audioOutputs = devices.filter(
(device) => device.kind === "audiooutput"
);
let audioOutput = undefined;
if (
mediaPreferences &&
audioOutputs.some(
(device) => device.deviceId === mediaPreferences.audioOutput
)
) {
audioOutput = mediaPreferences.audioOutput;
}
if (
// @ts-ignore
(mediaHandler.videoInput && mediaHandler.videoInput !== videoInput) ||
// @ts-ignore
mediaHandler.audioInput !== audioInput
) {
mediaHandler.setMediaInputs(audioInput, videoInput);
}
updateMediaPreferences({ audioInput, videoInput, audioOutput });
setState({
audioInput,
videoInput,
audioOutput,
audioInputs,
videoInputs,
audioOutputs,
});
});
client.getMediaHandler().stopAllStreams();
};
}
updateDevices();
mediaHandler.on(MediaHandlerEvent.LocalStreamsChanged, updateDevices);
navigator.mediaDevices.addEventListener("devicechange", updateDevices);
return () => {
mediaHandler.removeListener(
MediaHandlerEvent.LocalStreamsChanged,
updateDevices
);
navigator.mediaDevices.removeEventListener("devicechange", updateDevices);
mediaHandler.stopAllStreams();
};
}, [client]);
}, [client, updateDevices]);
const setAudioInput: (deviceId: string) => void = useCallback(
(deviceId: string) => {
updateMediaPreferences({ audioInput: deviceId });
setState((prevState) => ({ ...prevState, audioInput: deviceId }));
client.getMediaHandler().setAudioInput(deviceId);
client?.getMediaHandler().setAudioInput(deviceId);
},
[client]
);
@@ -245,6 +230,7 @@ export function MediaHandlerProvider({ client, children }: Props): JSX.Element {
audioOutput,
audioOutputs,
setAudioOutput,
useDeviceNames,
}),
[
audioInput,
@@ -256,6 +242,7 @@ export function MediaHandlerProvider({ client, children }: Props): JSX.Element {
audioOutput,
audioOutputs,
setAudioOutput,
useDeviceNames,
]
);

View File

@@ -25,12 +25,14 @@ limitations under the License.
list-style: none;
padding: 0;
margin: 0 auto 24px auto;
gap: 16px;
overflow: scroll;
max-width: 100%;
}
.tab {
max-width: 190px;
min-width: fit-content;
height: 32px;
box-sizing: border-box;
border-radius: 8px;
background-color: transparent;
display: flex;
@@ -38,6 +40,7 @@ limitations under the License.
padding: 0 8px;
border: none;
cursor: pointer;
font-size: var(--font-size-body);
}
.tab > * {
@@ -78,17 +81,18 @@ limitations under the License.
@media (min-width: 800px) {
.tab {
width: 200px;
padding: 0 16px;
}
.tab > * {
margin: 0 16px 0 0;
margin: 0 12px 0 0;
}
.tabContainer {
width: 100%;
flex-direction: row;
padding: 27px 20px;
padding: 20px 18px;
box-sizing: border-box;
overflow: hidden;
}
@@ -96,6 +100,7 @@ limitations under the License.
.tabList {
flex-direction: column;
margin-bottom: 0;
gap: 0;
}
.tabPanel {

View File

@@ -21,14 +21,14 @@ import { MutableRefObject, RefCallback, useCallback } from "react";
* same DOM node.
*/
export const useMergedRefs = <T>(
...refs: (MutableRefObject<T | null> | RefCallback<T | null>)[]
...refs: (MutableRefObject<T | null> | RefCallback<T | null> | null)[]
): RefCallback<T | null> =>
useCallback(
(value) =>
refs.forEach((ref) => {
if (typeof ref === "function") {
ref(value);
} else {
} else if (ref !== null) {
ref.current = value;
}
}),

View File

@@ -18,7 +18,7 @@ limitations under the License.
contain: strict;
position: relative;
flex-grow: 1;
padding: 0 20px;
padding: 0 20px var(--footerHeight);
overflow-y: auto;
overflow-x: hidden;
}
@@ -28,7 +28,6 @@ limitations under the License.
display: grid;
grid-auto-rows: 163px;
gap: 8px;
padding-bottom: var(--footerHeight);
}
.slot {
@@ -37,7 +36,7 @@ limitations under the License.
@media (min-width: 800px) {
.grid {
padding: 0 22px;
padding: 0 22px var(--footerHeight);
}
.slotGrid {

View File

@@ -43,8 +43,12 @@ import {
fillGaps,
forEachCellInArea,
cycleTileSize,
appendItems,
addItems,
tryMoveTile,
resize,
promoteSpeakers,
} from "./model";
import { TileWrapper } from "./TileWrapper";
interface GridState extends Grid {
/**
@@ -80,15 +84,21 @@ const useGridState = (
}),
};
// Step 2: Backfill gaps left behind by removed tiles
const grid2 = fillGaps(grid1);
// Step 2: Resize the grid if necessary and backfill gaps left behind by
// removed tiles
// Resizing already takes care of backfilling gaps
const grid2 =
columns !== grid1.columns ? resize(grid1, columns!) : fillGaps(grid1);
// Step 3: Add new tiles to the end of the grid
const existingItemIds = new Set(
grid2.cells.filter((c) => c !== undefined).map((c) => c!.item.id)
);
const newItems = items.filter((i) => !existingItemIds.has(i.id));
const grid3 = appendItems(newItems, grid2);
const grid3 = addItems(newItems, grid2);
// Step 4: Promote speakers to the top
promoteSpeakers(grid3);
return { ...grid3, generation: prevGrid.generation + 1 };
},
@@ -203,14 +213,10 @@ export const NewVideoGrid: FC<Props> = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [slotGrid, slotGridGeneration, gridBounds]);
const [columns] = useReactiveState<number | null>(
// Since grid resizing isn't implemented yet, pick a column count on mount
// and stick to it
(prevColumns) =>
prevColumns !== undefined && prevColumns !== null
? prevColumns
: // The grid bounds might not be known yet
gridBounds.width === 0
const columns = useMemo(
() =>
// The grid bounds might not be known yet
gridBounds.width === 0
? null
: Math.max(2, Math.floor(gridBounds.width * 0.0045)),
[gridBounds]
@@ -256,7 +262,7 @@ export const NewVideoGrid: FC<Props> = ({
enter: { opacity: 1, scale: 1, immediate: disableAnimations },
update: ({ item, x, y, width, height }: Tile) =>
item.id === dragState.current?.tileId
? {}
? null
: {
x,
y,
@@ -266,65 +272,81 @@ export const NewVideoGrid: FC<Props> = ({
},
leave: { opacity: 0, scale: 0, immediate: disableAnimations },
config: { mass: 0.7, tension: 252, friction: 25 },
}),
[tiles, disableAnimations]
})
// react-spring's types are bugged and can't infer the spring type
) as unknown as [TransitionFn<Tile, TileSpring>, SpringRef<TileSpring>];
// Because we're using react-spring in imperative mode, we're responsible for
// firing animations manually whenever the tiles array updates
useEffect(() => {
springRef.start();
}, [tiles, springRef]);
const animateDraggedTile = (endOfGesture: boolean) => {
const { tileId, tileX, tileY, cursorX, cursorY } = dragState.current!;
const tile = tiles.find((t) => t.item.id === tileId)!;
const originIndex = grid!.cells.findIndex((c) => c?.item.id === tileId);
const originCell = grid!.cells[originIndex]!;
springRef.start((_i, controller) => {
if ((controller.item as Tile).item.id === tileId) {
if (endOfGesture) {
return {
scale: 1,
zIndex: 1,
shadow: 1,
x: tile.x,
y: tile.y,
width: tile.width,
height: tile.height,
immediate: disableAnimations || ((key) => key === "zIndex"),
// Allow the tile's position to settle before pushing its
// z-index back down
delay: (key) => (key === "zIndex" ? 500 : 0),
};
} else {
return {
scale: 1.1,
zIndex: 2,
shadow: 15,
x: tileX,
y: tileY,
immediate:
disableAnimations ||
((key) => key === "zIndex" || key === "x" || key === "y"),
};
}
} else {
return {};
}
});
springRef.current
.find((c) => (c.item as Tile).item.id === tileId)
?.start(
endOfGesture
? {
scale: 1,
zIndex: 1,
shadow: 1,
x: tile.x,
y: tile.y,
width: tile.width,
height: tile.height,
immediate: disableAnimations || ((key) => key === "zIndex"),
// Allow the tile's position to settle before pushing its
// z-index back down
delay: (key) => (key === "zIndex" ? 500 : 0),
}
: {
scale: 1.1,
zIndex: 2,
shadow: 15,
x: tileX,
y: tileY,
immediate:
disableAnimations ||
((key) => key === "zIndex" || key === "x" || key === "y"),
}
);
const overTile = tiles.find(
(t) =>
cursorX >= t.x &&
cursorX < t.x + t.width &&
cursorY >= t.y &&
cursorY < t.y + t.height
const columns = grid!.columns;
const rows = row(grid!.cells.length - 1, grid!) + 1;
const cursorColumn = Math.floor(
(cursorX / slotGrid!.clientWidth) * columns
);
if (overTile !== undefined && overTile.item.id !== tileId) {
setGrid((g) => ({
...g!,
cells: g!.cells.map((c) => {
if (c?.item === overTile.item) return { ...c, item: tile.item };
if (c?.item === tile.item) return { ...c, item: overTile.item };
return c;
}),
}));
}
const cursorRow = Math.floor((cursorY / slotGrid!.clientHeight) * rows);
const cursorColumnOnTile = Math.floor(
((cursorX - tileX) / tile.width) * originCell.columns
);
const cursorRowOnTile = Math.floor(
((cursorY - tileY) / tile.height) * originCell.rows
);
const dest =
Math.max(
0,
Math.min(
columns - originCell.columns,
cursorColumn - cursorColumnOnTile
)
) +
grid!.columns *
Math.max(
0,
Math.min(rows - originCell.rows, cursorRow - cursorRowOnTile)
);
if (dest !== originIndex) setGrid((g) => tryMoveTile(g, originIndex, dest));
};
// Callback for useDrag. We could call useDrag here, but the default
@@ -447,16 +469,19 @@ export const NewVideoGrid: FC<Props> = ({
>
{slots}
</div>
{tileTransitions((style, tile) =>
children({
...style,
key: tile.item.id,
targetWidth: tile.width,
targetHeight: tile.height,
item: tile.item,
onDragRef: onTileDragRef,
})
)}
{tileTransitions((spring, tile) => (
<TileWrapper
key={tile.item.id}
id={tile.item.id}
onDragRef={onTileDragRef}
targetWidth={tile.width}
targetHeight={tile.height}
item={tile.item}
{...spring}
>
{children}
</TileWrapper>
))}
</div>
);
};

View File

@@ -26,7 +26,10 @@ export interface TileDescriptor {
member: RoomMember;
focused: boolean;
presenter: boolean;
isSpeaker: boolean;
callFeed?: CallFeed;
isLocal?: boolean;
largeBaseSize: boolean;
placeNear?: string;
connectionState: ConnectionState;
}

View File

@@ -0,0 +1,101 @@
/*
Copyright 2023 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { FC, memo, ReactNode, RefObject, useRef } from "react";
import { EventTypes, Handler, useDrag } from "@use-gesture/react";
import { SpringValue, to } from "@react-spring/web";
import { TileDescriptor } from "./TileDescriptor";
import { ChildrenProperties } from "./VideoGrid";
interface Props {
id: string;
onDragRef: RefObject<
(
tileId: string,
state: Parameters<Handler<"drag", EventTypes["drag"]>>[0]
) => void
>;
targetWidth: number;
targetHeight: number;
item: TileDescriptor;
opacity: SpringValue<number>;
scale: SpringValue<number>;
shadow: SpringValue<number>;
shadowSpread: SpringValue<number>;
zIndex: SpringValue<number>;
x: SpringValue<number>;
y: SpringValue<number>;
width: SpringValue<number>;
height: SpringValue<number>;
children: (props: ChildrenProperties) => ReactNode;
}
/**
* A wrapper around a tile in a video grid. This component exists to decouple
* child components from the grid.
*/
export const TileWrapper: FC<Props> = memo(
({
id,
onDragRef,
targetWidth,
targetHeight,
item,
opacity,
scale,
shadow,
shadowSpread,
zIndex,
x,
y,
width,
height,
children,
}) => {
const ref = useRef<HTMLElement | null>(null);
useDrag((state) => onDragRef?.current!(id, state), {
target: ref,
filterTaps: true,
preventScroll: true,
});
return (
<>
{children({
ref,
style: {
opacity,
scale,
zIndex,
x,
y,
width,
height,
boxShadow: to(
[shadow, shadowSpread],
(s, ss) => `rgba(0, 0, 0, 0.5) 0px ${s}px ${2 * s}px ${ss}px`
),
},
targetWidth,
targetHeight,
item,
})}
</>
);
}
);

View File

@@ -1,97 +0,0 @@
/*
Copyright 2022 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useState } from "react";
import { useMemo } from "react";
import { RoomMember } from "matrix-js-sdk";
import { VideoGrid, useVideoGridLayout } from "./VideoGrid";
import { VideoTile } from "./VideoTile";
import { Button } from "../button";
import { ConnectionState } from "../room/useGroupCall";
import { TileDescriptor } from "./TileDescriptor";
export default {
title: "VideoGrid",
parameters: {
layout: "fullscreen",
},
};
export const ParticipantsTest = () => {
const { layout, setLayout } = useVideoGridLayout(false);
const [participantCount, setParticipantCount] = useState(1);
const items: TileDescriptor[] = useMemo(
() =>
new Array(participantCount).fill(undefined).map((_, i) => ({
id: (i + 1).toString(),
member: new RoomMember("!fake:room.id", `@user${i}:fake.dummy`),
focused: false,
presenter: false,
connectionState: ConnectionState.Connected,
})),
[participantCount]
);
return (
<>
<div style={{ display: "flex", width: "100vw", height: "32px" }}>
<Button
onPress={() =>
setLayout(layout === "freedom" ? "spotlight" : "freedom")
}
>
Toggle Layout
</Button>
{participantCount < 12 && (
<Button onPress={() => setParticipantCount((count) => count + 1)}>
Add Participant
</Button>
)}
{participantCount > 0 && (
<Button onPress={() => setParticipantCount((count) => count - 1)}>
Remove Participant
</Button>
)}
</div>
<div
style={{
display: "flex",
width: "100vw",
height: "calc(100vh - 32px)",
}}
>
<VideoGrid layout={layout} items={items}>
{({ item, ...rest }) => (
<VideoTile
key={item.id}
name={`User ${item.id}`}
disableSpeakingIndicator={items.length < 3}
connectionState={ConnectionState.Connected}
{...rest}
/>
)}
</VideoGrid>
</div>
</>
);
};
ParticipantsTest.args = {
layout: "freedom",
participantCount: 1,
};

View File

@@ -1,5 +1,5 @@
/*
Copyright 2022 New Vector Ltd
Copyright 2022-2023 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -14,21 +14,34 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { Key, useCallback, useEffect, useRef, useState } from "react";
import { FullGestureState, useDrag, useGesture } from "@use-gesture/react";
import React, {
ComponentProps,
Key,
Ref,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import {
EventTypes,
FullGestureState,
Handler,
useGesture,
} from "@use-gesture/react";
import {
animated,
SpringRef,
SpringValue,
SpringValues,
useSprings,
} from "@react-spring/web";
import useMeasure from "react-use-measure";
import { ResizeObserver as JuggleResizeObserver } from "@juggle/resize-observer";
import { ReactDOMAttributes } from "@use-gesture/react/dist/declarations/src/types";
import styles from "./VideoGrid.module.css";
import { Layout } from "../room/GridLayoutMenu";
import { TileDescriptor } from "./TileDescriptor";
import { TileWrapper } from "./TileWrapper";
interface TilePosition {
x: number;
@@ -39,7 +52,7 @@ interface TilePosition {
}
interface Tile {
key: Key;
key: string;
order: number;
item: TileDescriptor;
remove: boolean;
@@ -717,20 +730,18 @@ interface DragTileData {
y: number;
}
export interface ChildrenProperties extends ReactDOMAttributes {
key: Key;
export interface ChildrenProperties {
ref: Ref<HTMLElement>;
style: ComponentProps<typeof animated.div>["style"];
/**
* The width this tile will have once its animations have settled.
*/
targetWidth: number;
/**
* The height this tile will have once its animations have settled.
*/
targetHeight: number;
item: TileDescriptor;
opacity: SpringValue<number>;
scale: SpringValue<number>;
shadow: SpringValue<number>;
zIndex: SpringValue<number>;
x: SpringValue<number>;
y: SpringValue<number>;
width: SpringValue<number>;
height: SpringValue<number>;
[index: string]: unknown;
}
export interface VideoGridProps {
@@ -1063,117 +1074,132 @@ export function VideoGrid({
[tiles, layout, gridBounds.width, gridBounds.height, pipXRatio, pipYRatio]
);
const bindTile = useDrag(
({ args: [key], active, xy, movement, tap, last, event }) => {
event.preventDefault();
// Callback for useDrag. We could call useDrag here, but the default
// pattern of spreading {...bind()} across the children to bind the gesture
// ends up breaking memoization and ruining this component's performance.
// Instead, we pass this callback to each tile via a ref, to let them bind the
// gesture using the much more sensible ref-based method.
const onTileDrag = (
tileId: string,
{
active,
xy,
movement,
tap,
last,
event,
}: Parameters<Handler<"drag", EventTypes["drag"]>>[0]
) => {
event.preventDefault();
if (tap) {
onTap(key);
return;
}
if (tap) {
onTap(tileId);
return;
}
if (layout !== "freedom") return;
if (layout !== "freedom") return;
const dragTileIndex = tiles.findIndex((tile) => tile.key === key);
const dragTile = tiles[dragTileIndex];
const dragTilePosition = tilePositions[dragTile.order];
const dragTileIndex = tiles.findIndex((tile) => tile.key === tileId);
const dragTile = tiles[dragTileIndex];
const dragTilePosition = tilePositions[dragTile.order];
const cursorPosition = [xy[0] - gridBounds.left, xy[1] - gridBounds.top];
const cursorPosition = [xy[0] - gridBounds.left, xy[1] - gridBounds.top];
let newTiles = tiles;
let newTiles = tiles;
if (tiles.length === 2 && !tiles.some((t) => t.presenter || t.focused)) {
// We're in 1:1 mode, so only the local tile should be draggable
if (!dragTile.item.isLocal) return;
if (tiles.length === 2 && !tiles.some((t) => t.presenter || t.focused)) {
// We're in 1:1 mode, so only the local tile should be draggable
if (!dragTile.item.isLocal) return;
// Position should only update on the very last event, to avoid
// compounding the offset on every drag event
if (last) {
const remotePosition = tilePositions[1];
// Position should only update on the very last event, to avoid
// compounding the offset on every drag event
if (last) {
const remotePosition = tilePositions[1];
const pipGap = getPipGap(
gridBounds.width / gridBounds.height,
gridBounds.width
);
const pipMinX = remotePosition.x + pipGap;
const pipMinY = remotePosition.y + pipGap;
const pipMaxX =
remotePosition.x +
remotePosition.width -
dragTilePosition.width -
pipGap;
const pipMaxY =
remotePosition.y +
remotePosition.height -
dragTilePosition.height -
pipGap;
const newPipXRatio =
(dragTilePosition.x + movement[0] - pipMinX) / (pipMaxX - pipMinX);
const newPipYRatio =
(dragTilePosition.y + movement[1] - pipMinY) / (pipMaxY - pipMinY);
setPipXRatio(Math.max(0, Math.min(1, newPipXRatio)));
setPipYRatio(Math.max(0, Math.min(1, newPipYRatio)));
}
} else {
const hoverTile = tiles.find(
(tile) =>
tile.key !== key &&
isInside(cursorPosition, tilePositions[tile.order])
const pipGap = getPipGap(
gridBounds.width / gridBounds.height,
gridBounds.width
);
const pipMinX = remotePosition.x + pipGap;
const pipMinY = remotePosition.y + pipGap;
const pipMaxX =
remotePosition.x +
remotePosition.width -
dragTilePosition.width -
pipGap;
const pipMaxY =
remotePosition.y +
remotePosition.height -
dragTilePosition.height -
pipGap;
if (hoverTile) {
// Shift the tiles into their new order
newTiles = newTiles.map((tile) => {
let order = tile.order;
if (order < dragTile.order) {
if (order >= hoverTile.order) order++;
} else if (order > dragTile.order) {
if (order <= hoverTile.order) order--;
} else {
order = hoverTile.order;
}
const newPipXRatio =
(dragTilePosition.x + movement[0] - pipMinX) / (pipMaxX - pipMinX);
const newPipYRatio =
(dragTilePosition.y + movement[1] - pipMinY) / (pipMaxY - pipMinY);
let focused;
if (tile === hoverTile) {
focused = dragTile.focused;
} else if (tile === dragTile) {
focused = hoverTile.focused;
} else {
focused = tile.focused;
}
return { ...tile, order, focused };
});
reorderTiles(newTiles, layout);
setTileState((state) => ({ ...state, tiles: newTiles }));
}
setPipXRatio(Math.max(0, Math.min(1, newPipXRatio)));
setPipYRatio(Math.max(0, Math.min(1, newPipYRatio)));
}
} else {
const hoverTile = tiles.find(
(tile) =>
tile.key !== tileId &&
isInside(cursorPosition, tilePositions[tile.order])
);
if (active) {
if (!draggingTileRef.current) {
draggingTileRef.current = {
key: dragTile.key,
offsetX: dragTilePosition.x,
offsetY: dragTilePosition.y,
x: movement[0],
y: movement[1],
};
} else {
draggingTileRef.current.x = movement[0];
draggingTileRef.current.y = movement[1];
}
if (hoverTile) {
// Shift the tiles into their new order
newTiles = newTiles.map((tile) => {
let order = tile.order;
if (order < dragTile.order) {
if (order >= hoverTile.order) order++;
} else if (order > dragTile.order) {
if (order <= hoverTile.order) order--;
} else {
order = hoverTile.order;
}
let focused;
if (tile === hoverTile) {
focused = dragTile.focused;
} else if (tile === dragTile) {
focused = hoverTile.focused;
} else {
focused = tile.focused;
}
return { ...tile, order, focused };
});
reorderTiles(newTiles, layout);
setTileState((state) => ({ ...state, tiles: newTiles }));
}
}
if (active) {
if (!draggingTileRef.current) {
draggingTileRef.current = {
key: dragTile.key,
offsetX: dragTilePosition.x,
offsetY: dragTilePosition.y,
x: movement[0],
y: movement[1],
};
} else {
draggingTileRef.current = null;
draggingTileRef.current.x = movement[0];
draggingTileRef.current.y = movement[1];
}
} else {
draggingTileRef.current = null;
}
api.start(animate(newTiles));
},
{ filterTaps: true, pointer: { buttons: [1] } }
);
api.start(animate(newTiles));
};
const onTileDragRef = useRef(onTileDrag);
onTileDragRef.current = onTileDrag;
const onGridGesture = useCallback(
(
@@ -1220,18 +1246,23 @@ export function VideoGrid({
return (
<div className={styles.videoGrid} ref={gridRef} {...bindGrid()}>
{springs.map((style, i) => {
{springs.map((spring, i) => {
const tile = tiles[i];
const tilePosition = tilePositions[tile.order];
return children({
...bindTile(tile.key),
...style,
key: tile.item.id,
targetWidth: tilePosition.width,
targetHeight: tilePosition.height,
item: tile.item,
});
return (
<TileWrapper
key={tile.key}
id={tile.key}
onDragRef={onTileDragRef}
targetWidth={tilePosition.width}
targetHeight={tilePosition.height}
item={tile.item}
{...spring}
>
{children}
</TileWrapper>
);
})}
</div>
);

View File

@@ -18,12 +18,10 @@ limitations under the License.
position: absolute;
contain: strict;
top: 0;
width: var(--tileWidth);
height: var(--tileHeight);
container-name: videoTile;
container-type: size;
--tileRadius: 8px;
border-radius: var(--tileRadius);
box-shadow: rgba(0, 0, 0, 0.5) 0px var(--tileShadow)
calc(2 * var(--tileShadow)) var(--tileShadowSpread);
overflow: hidden;
cursor: pointer;

View File

@@ -1,5 +1,5 @@
/*
Copyright 2022 New Vector Ltd
Copyright 2022-2023 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -14,86 +14,105 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { ForwardedRef, forwardRef } from "react";
import { animated, SpringValue } from "@react-spring/web";
import React, { ComponentProps, forwardRef, useCallback } from "react";
import { animated } from "@react-spring/web";
import classNames from "classnames";
import { useTranslation } from "react-i18next";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { SDPStreamMetadataPurpose } from "matrix-js-sdk/src/webrtc/callEventTypes";
import styles from "./VideoTile.module.css";
import { ReactComponent as MicIcon } from "../icons/Mic.svg";
import { ReactComponent as MicMutedIcon } from "../icons/MicMuted.svg";
import { AudioButton, FullscreenButton } from "../button/Button";
import { ConnectionState } from "../room/useGroupCall";
import { TileDescriptor } from "./TileDescriptor";
import { VideoTileSettingsModal } from "./VideoTileSettingsModal";
import { useCallFeed } from "./useCallFeed";
import { useSpatialMediaStream } from "./useMediaStream";
import { useRoomMemberName } from "./useRoomMemberName";
import { useModalTriggerState } from "../Modal";
import { useMergedRefs } from "../useMergedRefs";
interface Props {
name: string;
connectionState: ConnectionState;
speaking?: boolean;
audioMuted?: boolean;
videoMuted?: boolean;
screenshare?: boolean;
avatar?: JSX.Element;
mediaRef?: React.RefObject<MediaElement>;
onOptionsPress?: () => void;
localVolume?: number;
hasAudio?: boolean;
maximised?: boolean;
fullscreen?: boolean;
onFullscreen?: () => void;
item: TileDescriptor;
maximised: boolean;
fullscreen: boolean;
onFullscreen: (participant: TileDescriptor) => void;
className?: string;
showOptions?: boolean;
isLocal?: boolean;
disableSpeakingIndicator?: boolean;
opacity?: SpringValue<number>;
scale?: SpringValue<number>;
shadow?: SpringValue<number>;
shadowSpread?: SpringValue<number>;
zIndex?: SpringValue<number>;
x?: SpringValue<number>;
y?: SpringValue<number>;
width?: SpringValue<number>;
height?: SpringValue<number>;
showSpeakingIndicator: boolean;
style?: ComponentProps<typeof animated.div>["style"];
targetWidth: number;
targetHeight: number;
getAvatar: (
roomMember: RoomMember,
width: number,
height: number
) => JSX.Element;
audioContext: AudioContext;
audioDestination: AudioNode;
}
export const VideoTile = forwardRef<HTMLElement, Props>(
(
{
name,
connectionState,
speaking,
audioMuted,
videoMuted,
screenshare,
avatar,
mediaRef,
onOptionsPress,
localVolume,
hasAudio,
item,
maximised,
fullscreen,
onFullscreen,
className,
showOptions,
isLocal,
// TODO: disableSpeakingIndicator is not used atm.
disableSpeakingIndicator,
opacity,
scale,
shadow,
shadowSpread,
zIndex,
x,
y,
width,
height,
...rest
showSpeakingIndicator,
style,
targetWidth,
targetHeight,
getAvatar,
audioContext,
audioDestination,
},
ref
tileRef1
) => {
const { t } = useTranslation();
const {
isLocal,
audioMuted,
videoMuted,
localVolume,
hasAudio,
speaking,
stream,
purpose,
} = useCallFeed(item.callFeed);
const screenshare = purpose === SDPStreamMetadataPurpose.Screenshare;
const { rawDisplayName: name } = useRoomMemberName(item.member);
const [tileRef2, mediaRef] = useSpatialMediaStream(
stream ?? null,
audioContext,
audioDestination,
localVolume,
// The feed is muted if it's local audio (because we don't want our own audio,
// but it's a hook and we can't call it conditionally so we're stuck with it)
// or if there's a maximised feed in which case we always render audio via audio
// elements because we wire it up at the video tile container level and only one
// video tile container is displayed.
isLocal || maximised
);
const tileRef = useMergedRefs(tileRef1, tileRef2);
const {
modalState: videoTileSettingsModalState,
modalProps: videoTileSettingsModalProps,
} = useModalTriggerState();
const onOptionsPress = videoTileSettingsModalState.open;
const onFullscreenCallback = useCallback(() => {
onFullscreen(item);
}, [onFullscreen, item]);
const toolbarButtons: JSX.Element[] = [];
if (connectionState == ConnectionState.Connected && !isLocal) {
if (item.connectionState == ConnectionState.Connected && !isLocal) {
if (hasAudio) {
toolbarButtons.push(
<AudioButton
@@ -111,14 +130,14 @@ export const VideoTile = forwardRef<HTMLElement, Props>(
key="fullscreen"
className={styles.button}
fullscreen={fullscreen}
onPress={onFullscreen}
onPress={onFullscreenCallback}
/>
);
}
}
let caption: string;
switch (connectionState) {
switch (item.connectionState) {
case ConnectionState.EstablishingCall:
caption = t("{{name}} (Connecting...)", { name });
break;
@@ -131,68 +150,65 @@ export const VideoTile = forwardRef<HTMLElement, Props>(
break;
}
// Firefox doesn't respect the disablePictureInPicture attribute
// https://bugzilla.mozilla.org/show_bug.cgi?id=1611831
return (
<animated.div
className={classNames(styles.videoTile, className, {
[styles.isLocal]: isLocal,
[styles.speaking]: speaking,
[styles.muted]: audioMuted,
[styles.screenshare]: screenshare,
[styles.maximised]: maximised,
})}
style={{
opacity,
scale,
zIndex,
x,
y,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore React does in fact support assigning custom properties,
// but React's types say no
"--tileWidth": width?.to((w) => `${w}px`),
"--tileHeight": height?.to((h) => `${h}px`),
"--tileShadow": shadow?.to((s) => `${s}px`),
"--tileShadowSpread": shadowSpread?.to((s) => `${s}px`),
}}
ref={ref as ForwardedRef<HTMLDivElement>}
data-testid="videoTile"
{...rest}
>
{toolbarButtons.length > 0 && !maximised && (
<div className={classNames(styles.toolbar)}>{toolbarButtons}</div>
)}
{videoMuted && (
<>
<div className={styles.videoMutedOverlay} />
{avatar}
</>
)}
{!maximised &&
(screenshare ? (
<div className={styles.presenterLabel}>
<span>{t("{{name}} is presenting", { name })}</span>
</div>
) : (
<div className={classNames(styles.infoBubble, styles.memberName)}>
{
/* If the user is speaking, it's safe to say they're unmuted.
<>
<animated.div
className={classNames(styles.videoTile, className, {
[styles.isLocal]: isLocal,
[styles.speaking]: speaking && showSpeakingIndicator,
[styles.muted]: audioMuted,
[styles.screenshare]: screenshare,
[styles.maximised]: maximised,
})}
style={style}
ref={tileRef}
data-testid="videoTile"
>
{toolbarButtons.length > 0 && !maximised && (
<div className={classNames(styles.toolbar)}>{toolbarButtons}</div>
)}
{videoMuted && (
<>
<div className={styles.videoMutedOverlay} />
{getAvatar(item.member, targetWidth, targetHeight)}
</>
)}
{!maximised &&
(screenshare ? (
<div className={styles.presenterLabel}>
<span>{t("{{name}} is presenting", { name })}</span>
</div>
) : (
<div className={classNames(styles.infoBubble, styles.memberName)}>
{
/* If the user is speaking, it's safe to say they're unmuted.
Mute state is currently sent over to-device messages, which
aren't quite real-time, so this is an important kludge to make
sure no one appears muted when they've clearly begun talking. */
speaking || !audioMuted ? <MicIcon /> : <MicMutedIcon />
}
<span data-testid="videoTile_caption" title={caption}>
{caption}
</span>
</div>
))}
<video
data-testid="videoTile_video"
ref={mediaRef}
playsInline
disablePictureInPicture
/>
</animated.div>
speaking || !audioMuted ? <MicIcon /> : <MicMutedIcon />
}
<span data-testid="videoTile_caption" title={caption}>
{caption}
</span>
</div>
))}
<video
data-testid="videoTile_video"
ref={mediaRef}
playsInline
disablePictureInPicture
/>
</animated.div>
{videoTileSettingsModalState.isOpen && !maximised && item.callFeed && (
<VideoTileSettingsModal
{...videoTileSettingsModalProps}
feed={item.callFeed}
/>
)}
</>
);
}
);

View File

@@ -1,157 +0,0 @@
/*
Copyright 2022 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { SDPStreamMetadataPurpose } from "matrix-js-sdk/src/webrtc/callEventTypes";
import React, { FC, memo, RefObject } from "react";
import { useCallback } from "react";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { SpringValue } from "@react-spring/web";
import { EventTypes, Handler, useDrag } from "@use-gesture/react";
import { useCallFeed } from "./useCallFeed";
import { useSpatialMediaStream } from "./useMediaStream";
import { useRoomMemberName } from "./useRoomMemberName";
import { VideoTile } from "./VideoTile";
import { VideoTileSettingsModal } from "./VideoTileSettingsModal";
import { useModalTriggerState } from "../Modal";
import { TileDescriptor } from "./TileDescriptor";
interface Props {
item: TileDescriptor;
targetWidth: number;
targetHeight: number;
getAvatar: (
roomMember: RoomMember,
width: number,
height: number
) => JSX.Element;
audioContext: AudioContext;
audioDestination: AudioNode;
disableSpeakingIndicator: boolean;
maximised: boolean;
fullscreen: boolean;
onFullscreen: (item: TileDescriptor) => void;
opacity?: SpringValue<number>;
scale?: SpringValue<number>;
shadow?: SpringValue<number>;
shadowSpread?: SpringValue<number>;
zIndex?: SpringValue<number>;
x?: SpringValue<number>;
y?: SpringValue<number>;
width?: SpringValue<number>;
height?: SpringValue<number>;
onDragRef?: RefObject<
(
tileId: string,
state: Parameters<Handler<"drag", EventTypes["drag"]>>[0]
) => void
>;
}
export const VideoTileContainer: FC<Props> = memo(
({
item,
targetWidth,
targetHeight,
getAvatar,
audioContext,
audioDestination,
disableSpeakingIndicator,
maximised,
fullscreen,
onFullscreen,
onDragRef,
...rest
}) => {
const {
isLocal,
audioMuted,
videoMuted,
localVolume,
hasAudio,
speaking,
stream,
purpose,
} = useCallFeed(item.callFeed);
const { rawDisplayName } = useRoomMemberName(item.member);
const [tileRef, mediaRef] = useSpatialMediaStream(
stream ?? null,
audioContext,
audioDestination,
localVolume,
// The feed is muted if it's local audio (because we don't want our own audio,
// but it's a hook and we can't call it conditionally so we're stuck with it)
// or if there's a maximised feed in which case we always render audio via audio
// elements because we wire it up at the video tile container level and only one
// video tile container is displayed.
isLocal || maximised
);
useDrag((state) => onDragRef?.current!(item.id, state), {
target: tileRef,
filterTaps: true,
preventScroll: true,
});
const {
modalState: videoTileSettingsModalState,
modalProps: videoTileSettingsModalProps,
} = useModalTriggerState();
const onOptionsPress = () => {
videoTileSettingsModalState.open();
};
const onFullscreenCallback = useCallback(() => {
onFullscreen(item);
}, [onFullscreen, item]);
// Firefox doesn't respect the disablePictureInPicture attribute
// https://bugzilla.mozilla.org/show_bug.cgi?id=1611831
return (
<>
<VideoTile
isLocal={isLocal}
speaking={speaking && !disableSpeakingIndicator}
audioMuted={audioMuted}
videoMuted={videoMuted}
screenshare={purpose === SDPStreamMetadataPurpose.Screenshare}
name={rawDisplayName}
connectionState={item.connectionState}
ref={tileRef}
mediaRef={mediaRef}
avatar={
getAvatar && getAvatar(item.member, targetWidth, targetHeight)
}
onOptionsPress={onOptionsPress}
localVolume={localVolume}
hasAudio={hasAudio}
maximised={maximised}
fullscreen={fullscreen}
onFullscreen={onFullscreenCallback}
{...rest}
/>
{videoTileSettingsModalState.isOpen && !maximised && item.callFeed && (
<VideoTileSettingsModal
{...videoTileSettingsModalProps}
feed={item.callFeed}
/>
)}
</>
);
}
);

View File

@@ -17,6 +17,7 @@ limitations under the License.
import TinyQueue from "tinyqueue";
import { TileDescriptor } from "./TileDescriptor";
import { count, findLastIndex } from "../array-utils";
/**
* A 1×1 cell in a grid which belongs to a tile.
@@ -105,17 +106,6 @@ export function getPaths(dest: number, g: Grid): (number | null)[] {
return edges as (number | null)[];
}
function findLastIndex<T>(
array: T[],
predicate: (item: T) => boolean
): number | null {
for (let i = array.length - 1; i >= 0; i--) {
if (predicate(array[i])) return i;
}
return null;
}
const findLast1By1Index = (g: Grid): number | null =>
findLastIndex(g.cells, (c) => c?.rows === 1 && c?.columns === 1);
@@ -185,6 +175,8 @@ const areaEnd = (
g: Grid
): number => start + columns - 1 + g.columns * (rows - 1);
const cloneGrid = (g: Grid): Grid => ({ ...g, cells: [...g.cells] });
/**
* Gets the index of the next gap in the grid that should be backfilled by 1×1
* tiles.
@@ -209,11 +201,150 @@ function getNextGap(g: Grid): number | null {
return null;
}
/**
* Gets the index of the origin of the tile to which the given cell belongs.
*/
function getOrigin(g: Grid, index: number): number {
const initialColumn = column(index, g);
for (
let i = index;
i >= 0;
i = column(i, g) === 0 ? i - g.columns + initialColumn : i - 1
) {
const cell = g.cells[i];
if (
cell !== undefined &&
cell.origin &&
inArea(index, i, areaEnd(i, cell.columns, cell.rows, g), g)
)
return i;
}
throw new Error("Tile is broken");
}
/**
* Moves the tile at index "from" over to index "to", displacing other tiles
* along the way.
* Precondition: the destination area must consist of only 1×1 tiles.
*/
function moveTile(g: Grid, from: number, to: number) {
const tile = g.cells[from]!;
const fromEnd = areaEnd(from, tile.columns, tile.rows, g);
const toEnd = areaEnd(to, tile.columns, tile.rows, g);
const displacedTiles: Cell[] = [];
forEachCellInArea(to, toEnd, g, (c, i) => {
if (c !== undefined && !inArea(i, from, fromEnd, g)) displacedTiles.push(c);
});
const movingCells: Cell[] = [];
forEachCellInArea(from, fromEnd, g, (c, i) => {
movingCells.push(c!);
g.cells[i] = undefined;
});
forEachCellInArea(
to,
toEnd,
g,
(_c, i) => (g.cells[i] = movingCells.shift())
);
forEachCellInArea(
from,
fromEnd,
g,
(_c, i) => (g.cells[i] ??= displacedTiles.shift())
);
}
/**
* Moves the tile at index "from" over to index "to", if there is space.
*/
export function tryMoveTile(g: Grid, from: number, to: number): Grid {
const tile = g.cells[from]!;
if (
to >= 0 &&
to < g.cells.length &&
column(to, g) <= g.columns - tile.columns
) {
const fromEnd = areaEnd(from, tile.columns, tile.rows, g);
const toEnd = areaEnd(to, tile.columns, tile.rows, g);
// The contents of a given cell are 'displaceable' if it's empty, holds a
// 1×1 tile, or is part of the original tile we're trying to reposition
const displaceable = (c: Cell | undefined, i: number): boolean =>
c === undefined ||
(c.columns === 1 && c.rows === 1) ||
inArea(i, from, fromEnd, g);
if (allCellsInArea(to, toEnd, g, displaceable)) {
// The target space is free; move
const gClone = cloneGrid(g);
moveTile(gClone, from, to);
return gClone;
}
}
// The target space isn't free; don't move
return g;
}
/**
* Attempts to push a tile upwards by one row, displacing 1×1 tiles and shifting
* enlarged tiles around when necessary.
* @returns Whether the tile was actually pushed
*/
function pushTileUp(g: Grid, from: number): boolean {
const tile = g.cells[from]!;
// TODO: pushing large tiles sideways might be more successful in some
// situations
const cellsAboveAreDisplacable =
from - g.columns >= 0 &&
allCellsInArea(
from - g.columns,
from - g.columns + tile.columns - 1,
g,
(c, i) =>
c === undefined ||
(c.columns === 1 && c.rows === 1) ||
pushTileUp(g, getOrigin(g, i))
);
if (cellsAboveAreDisplacable) {
moveTile(g, from, from - g.columns);
return true;
} else {
return false;
}
}
/**
* Backfill any gaps in the grid.
*/
export function fillGaps(g: Grid): Grid {
const result: Grid = { ...g, cells: [...g.cells] };
const result = cloneGrid(g);
// This will hopefully be the size of the grid after we're done here, assuming
// that we can pack the large tiles tightly enough
const idealLength = count(result.cells, (c) => c !== undefined);
// Step 1: Take any large tiles hanging off the bottom of the grid, and push
// them upwards
for (let i = result.cells.length - 1; i >= idealLength; i--) {
const cell = result.cells[i];
if (cell !== undefined && (cell.columns > 1 || cell.rows > 1)) {
const originIndex =
i - (cell.columns - 1) - result.columns * (cell.rows - 1);
// If it's not possible to pack the large tiles any tighter, give up
if (!pushTileUp(result, originIndex)) break;
}
}
// Step 2: Fill all 1×1 gaps
let gap = getNextGap(result);
if (gap !== null) {
@@ -263,9 +394,6 @@ export function fillGaps(g: Grid): Grid {
} while (gap !== null);
}
// TODO: If there are any large tiles on the last row, shuffle them back
// upwards into a full row
// Shrink the array to remove trailing gaps
const finalLength =
(findLastIndex(result.cells, (c) => c !== undefined) ?? -1) + 1;
@@ -275,21 +403,104 @@ export function fillGaps(g: Grid): Grid {
return result;
}
export function appendItems(items: TileDescriptor[], g: Grid): Grid {
return {
...g,
cells: [
...g.cells,
...items.map((i) => ({
item: i,
origin: true,
columns: 1,
rows: 1,
})),
],
function createRows(g: Grid, count: number, atRow: number): Grid {
const result = {
columns: g.columns,
cells: new Array(g.cells.length + g.columns * count),
};
const offsetAfterNewRows = g.columns * count;
// Copy tiles from the original grid to the new one, with the new rows
// inserted at the target location
g.cells.forEach((c, from) => {
if (c?.origin) {
const offset = row(from, g) >= atRow ? offsetAfterNewRows : 0;
forEachCellInArea(
from,
areaEnd(from, c.columns, c.rows, g),
g,
(c, i) => {
result.cells[i + offset] = c;
}
);
}
});
return result;
}
/**
* Adds a set of new items into the grid.
*/
export function addItems(items: TileDescriptor[], g: Grid): Grid {
let result = cloneGrid(g);
for (const item of items) {
const cell = {
item,
origin: true,
columns: 1,
rows: 1,
};
let placeAt: number;
let hasGaps: boolean;
if (item.placeNear === undefined) {
// This item has no special placement requests, so let's put it
// uneventfully at the end of the grid
placeAt = result.cells.length;
hasGaps = false;
} else {
// This item wants to be placed near another; let's put it on a row
// directly below the related tile
const placeNear = result.cells.findIndex(
(c) => c?.item.id === item.placeNear
);
if (placeNear === -1) {
// Can't find the related tile, so let's give up and place it at the end
placeAt = result.cells.length;
hasGaps = false;
} else {
const placeNearCell = result.cells[placeNear]!;
const placeNearEnd = areaEnd(
placeNear,
placeNearCell.columns,
placeNearCell.rows,
result
);
result = createRows(result, 1, row(placeNearEnd, result) + 1);
placeAt =
placeNear +
Math.floor(placeNearCell.columns / 2) +
result.columns * placeNearCell.rows;
hasGaps = true;
}
}
result.cells[placeAt] = cell;
if (item.largeBaseSize) {
// Cycle the tile size once to set up the tile with its larger base size
// This also fills any gaps in the grid, hence no extra call to fillGaps
result = cycleTileSize(item.id, result);
} else if (hasGaps) {
result = fillGaps(result);
}
}
return result;
}
const largeTileDimensions = (g: Grid): [number, number] => [
Math.min(3, Math.max(2, g.columns - 1)),
2,
];
const extraLargeTileDimensions = (g: Grid): [number, number] =>
g.columns > 3 ? [4, 3] : [g.columns, 2];
/**
* Changes the size of a tile, rearranging the grid to make space.
* @param tileId The ID of the tile to modify.
@@ -299,15 +510,19 @@ export function appendItems(items: TileDescriptor[], g: Grid): Grid {
export function cycleTileSize(tileId: string, g: Grid): Grid {
const from = g.cells.findIndex((c) => c?.item.id === tileId);
if (from === -1) return g; // Tile removed, no change
const fromWidth = g.cells[from]!.columns;
const fromHeight = g.cells[from]!.rows;
const fromCell = g.cells[from]!;
const fromWidth = fromCell.columns;
const fromHeight = fromCell.rows;
const fromEnd = areaEnd(from, fromWidth, fromHeight, g);
// The target dimensions, which toggle between 1×1 and larger than 1×1
const [baseDimensions, enlargedDimensions] = fromCell.item.largeBaseSize
? [largeTileDimensions(g), extraLargeTileDimensions(g)]
: [[1, 1], largeTileDimensions(g)];
// The target dimensions, which toggle between the base and enlarged sizes
const [toWidth, toHeight] =
fromWidth === 1 && fromHeight === 1
? [Math.min(3, Math.max(2, g.columns - 1)), 2]
: [1, 1];
fromWidth === baseDimensions[0] && fromHeight === baseDimensions[1]
? enlargedDimensions
: baseDimensions;
// If we're expanding the tile, we want to create enough new rows at the
// tile's target position such that every new unit of grid area created during
@@ -319,12 +534,6 @@ export function cycleTileSize(tileId: string, g: Grid): Grid {
Math.ceil((toWidth * toHeight - fromWidth * fromHeight) / g.columns)
);
// This is the grid with the new rows added
const gappyGrid: Grid = {
...g,
cells: new Array(g.cells.length + newRows * g.columns),
};
// The next task is to scan for a spot to place the modified tile. Since we
// might be creating new rows at the target position, this spot can be shorter
// than the target height.
@@ -334,8 +543,8 @@ export function cycleTileSize(tileId: string, g: Grid): Grid {
// To make the tile appear to expand outwards from its center, we're actually
// scanning for locations to put the *center* of the tile. These numbers are
// the offsets between the tile's origin and its center.
const scanColumnOffset = Math.floor((toWidth - 1) / 2);
const scanRowOffset = Math.floor((toHeight - 1) / 2);
const scanColumnOffset = Math.floor((toWidth - fromWidth) / 2);
const scanRowOffset = Math.floor((toHeight - fromHeight) / 2);
const nextScanLocations = new Set<number>([from]);
const rows = row(g.cells.length - 1, g) + 1;
@@ -379,17 +588,19 @@ export function cycleTileSize(tileId: string, g: Grid): Grid {
const toRow = row(to, g);
// Copy tiles from the original grid to the new one, with the new rows
// inserted at the target location
g.cells.forEach((c, src) => {
if (c?.origin && c.item.id !== tileId) {
const offset =
row(src, g) > toRow + candidateHeight - 1 ? g.columns * newRows : 0;
forEachCellInArea(src, areaEnd(src, c.columns, c.rows, g), g, (c, i) => {
gappyGrid.cells[i + offset] = c;
});
}
});
// This is the grid with the new rows added
const gappyGrid = createRows(g, newRows, toRow + candidateHeight);
// Remove the original tile
const fromInGappyGrid =
from + (row(from, g) >= toRow + candidateHeight ? g.columns * newRows : 0);
const fromEndInGappyGrid = fromInGappyGrid - from + fromEnd;
forEachCellInArea(
fromInGappyGrid,
fromEndInGappyGrid,
gappyGrid,
(_c, i) => (gappyGrid.cells[i] = undefined)
);
// Place the tile in its target position, making a note of the tiles being
// overwritten
@@ -414,3 +625,79 @@ export function cycleTileSize(tileId: string, g: Grid): Grid {
// Fill any gaps that remain
return fillGaps(gappyGrid);
}
/**
* Resizes the grid to a new column width.
*/
export function resize(g: Grid, columns: number): Grid {
const result: Grid = { columns, cells: [] };
const [largeColumns, largeRows] = largeTileDimensions(result);
// Copy each tile from the old grid to the resized one in the same order
// The next index in the result grid to copy a tile to
let next = 0;
for (const cell of g.cells) {
if (cell?.origin) {
const [nextColumns, nextRows] =
cell.columns > 1 || cell.rows > 1 ? [largeColumns, largeRows] : [1, 1];
// If there isn't enough space left on this row, jump to the next row
if (columns - column(next, result) < nextColumns)
next = columns * (Math.floor(next / columns) + 1);
const nextEnd = areaEnd(next, nextColumns, nextRows, result);
// Expand the cells array as necessary
if (result.cells.length <= nextEnd)
result.cells.push(...new Array(nextEnd + 1 - result.cells.length));
// Copy the tile into place
forEachCellInArea(next, nextEnd, result, (_c, i) => {
result.cells[i] = {
item: cell.item,
origin: i === next,
columns: nextColumns,
rows: nextRows,
};
});
next = nextEnd + 1;
}
}
return fillGaps(result);
}
/**
* Promotes speakers to the first page of the grid.
*/
export function promoteSpeakers(g: Grid) {
// This is all a bit of a hack right now, because we don't know if the designs
// will stick with this approach in the long run
// We assume that 4 rows are probably about 1 page
const firstPageEnd = g.columns * 4;
for (let from = firstPageEnd; from < g.cells.length; from++) {
const fromCell = g.cells[from];
// Don't bother trying to promote enlarged tiles
if (
fromCell?.item.isSpeaker &&
fromCell.columns === 1 &&
fromCell.rows === 1
) {
// Promote this tile by making 10 attempts to place it on the first page
for (let j = 0; j < 10; j++) {
const to = Math.floor(Math.random() * firstPageEnd);
const toCell = g.cells[to];
if (
toCell === undefined ||
(toCell.columns === 1 && toCell.rows === 1)
) {
moveTile(g, from, to);
break;
}
}
}
}
}

View File

@@ -101,7 +101,14 @@ export const widget: WidgetHelpers | null = (() => {
// We need to do this now rather than later because it has capabilities to
// request, and is responsible for starting the transport (should it be?)
const { roomId, userId, deviceId, baseUrl, e2eEnabled } = getUrlParams();
const {
roomId,
userId,
deviceId,
baseUrl,
e2eEnabled,
allowIceFallback,
} = getUrlParams();
if (!roomId) throw new Error("Room ID must be supplied");
if (!userId) throw new Error("User ID must be supplied");
if (!deviceId) throw new Error("Device ID must be supplied");
@@ -148,6 +155,7 @@ export const widget: WidgetHelpers | null = (() => {
deviceId,
timelineSupport: true,
useE2eForGroupCall: e2eEnabled,
fallbackICEServerAllowed: allowIceFallback,
}
);
const clientPromise = client.startClient().then(() => client);

View File

@@ -1,6 +1,7 @@
import { GroupCallStatsReport } from "matrix-js-sdk/src/webrtc/groupCall";
import {
AudioConcealment,
ByteSentStatsReport,
ConnectionStatsReport,
} from "matrix-js-sdk/src/webrtc/stats/statsReport";
import { ObjectFlattener } from "../../src/otel/ObjectFlattener";
@@ -28,6 +29,8 @@ describe("ObjectFlattener", () => {
const statsReport: GroupCallStatsReport<ConnectionStatsReport> = {
report: {
callId: "callId",
opponentMemberId: "opponentMemberId",
bandwidth: { upload: 426, download: 0 },
bitrate: {
upload: 426,
@@ -116,21 +119,25 @@ describe("ObjectFlattener", () => {
ObjectFlattener.flattenObjectRecursive(
statsReport.report.resolution,
flatObject,
"matrix.stats.conn.resolution.",
"matrix.call.stats.connection.resolution.",
0
);
expect(flatObject).toEqual({
"matrix.stats.conn.resolution.local.LOCAL_AUDIO_TRACK_ID.height": -1,
"matrix.stats.conn.resolution.local.LOCAL_AUDIO_TRACK_ID.width": -1,
"matrix.call.stats.connection.resolution.local.LOCAL_AUDIO_TRACK_ID.height":
-1,
"matrix.call.stats.connection.resolution.local.LOCAL_AUDIO_TRACK_ID.width":
-1,
"matrix.stats.conn.resolution.local.LOCAL_VIDEO_TRACK_ID.height": 460,
"matrix.stats.conn.resolution.local.LOCAL_VIDEO_TRACK_ID.width": 780,
"matrix.call.stats.connection.resolution.local.LOCAL_VIDEO_TRACK_ID.height": 460,
"matrix.call.stats.connection.resolution.local.LOCAL_VIDEO_TRACK_ID.width": 780,
"matrix.stats.conn.resolution.remote.REMOTE_AUDIO_TRACK_ID.height": -1,
"matrix.stats.conn.resolution.remote.REMOTE_AUDIO_TRACK_ID.width": -1,
"matrix.call.stats.connection.resolution.remote.REMOTE_AUDIO_TRACK_ID.height":
-1,
"matrix.call.stats.connection.resolution.remote.REMOTE_AUDIO_TRACK_ID.width":
-1,
"matrix.stats.conn.resolution.remote.REMOTE_VIDEO_TRACK_ID.height": 960,
"matrix.stats.conn.resolution.remote.REMOTE_VIDEO_TRACK_ID.width": 1080,
"matrix.call.stats.connection.resolution.remote.REMOTE_VIDEO_TRACK_ID.height": 960,
"matrix.call.stats.connection.resolution.remote.REMOTE_VIDEO_TRACK_ID.width": 1080,
});
});
it("should flatter an Array object", () => {
@@ -138,106 +145,128 @@ describe("ObjectFlattener", () => {
ObjectFlattener.flattenObjectRecursive(
statsReport.report.transport,
flatObject,
"matrix.stats.conn.transport.",
"matrix.call.stats.connection.transport.",
0
);
expect(flatObject).toEqual({
"matrix.stats.conn.transport.0.ip": "ff11::5fa:abcd:999c:c5c5:50000",
"matrix.stats.conn.transport.0.type": "udp",
"matrix.stats.conn.transport.0.localIp":
"matrix.call.stats.connection.transport.0.ip":
"ff11::5fa:abcd:999c:c5c5:50000",
"matrix.call.stats.connection.transport.0.type": "udp",
"matrix.call.stats.connection.transport.0.localIp":
"2aaa:9999:2aaa:999:8888:2aaa:2aaa:7777:50000",
"matrix.stats.conn.transport.0.isFocus": true,
"matrix.stats.conn.transport.0.localCandidateType": "host",
"matrix.stats.conn.transport.0.remoteCandidateType": "host",
"matrix.stats.conn.transport.0.networkType": "ethernet",
"matrix.stats.conn.transport.0.rtt": "NaN",
"matrix.stats.conn.transport.1.ip": "10.10.10.2:22222",
"matrix.stats.conn.transport.1.type": "tcp",
"matrix.stats.conn.transport.1.localIp": "10.10.10.100:33333",
"matrix.stats.conn.transport.1.isFocus": true,
"matrix.stats.conn.transport.1.localCandidateType": "srfx",
"matrix.stats.conn.transport.1.remoteCandidateType": "srfx",
"matrix.stats.conn.transport.1.networkType": "ethernet",
"matrix.stats.conn.transport.1.rtt": "null",
"matrix.call.stats.connection.transport.0.isFocus": true,
"matrix.call.stats.connection.transport.0.localCandidateType": "host",
"matrix.call.stats.connection.transport.0.remoteCandidateType": "host",
"matrix.call.stats.connection.transport.0.networkType": "ethernet",
"matrix.call.stats.connection.transport.0.rtt": "NaN",
"matrix.call.stats.connection.transport.1.ip": "10.10.10.2:22222",
"matrix.call.stats.connection.transport.1.type": "tcp",
"matrix.call.stats.connection.transport.1.localIp":
"10.10.10.100:33333",
"matrix.call.stats.connection.transport.1.isFocus": true,
"matrix.call.stats.connection.transport.1.localCandidateType": "srfx",
"matrix.call.stats.connection.transport.1.remoteCandidateType": "srfx",
"matrix.call.stats.connection.transport.1.networkType": "ethernet",
"matrix.call.stats.connection.transport.1.rtt": "null",
});
});
});
describe("on flattenConnectionStatsReportObject", () => {
describe("on flattenReportObject Connection Stats", () => {
it("should flatten a Report to otel Attributes Object", () => {
expect(
ObjectFlattener.flattenConnectionStatsReportObject(statsReport)
ObjectFlattener.flattenReportObject(
"matrix.call.stats.connection",
statsReport.report
)
).toEqual({
"matrix.stats.conn.bandwidth.download": 0,
"matrix.stats.conn.bandwidth.upload": 426,
"matrix.stats.conn.bitrate.audio.download": 0,
"matrix.stats.conn.bitrate.audio.upload": 124,
"matrix.stats.conn.bitrate.download": 0,
"matrix.stats.conn.bitrate.upload": 426,
"matrix.stats.conn.bitrate.video.download": 0,
"matrix.stats.conn.bitrate.video.upload": 302,
"matrix.stats.conn.codec.local.LOCAL_AUDIO_TRACK_ID": "opus",
"matrix.stats.conn.codec.local.LOCAL_VIDEO_TRACK_ID": "v8",
"matrix.stats.conn.codec.remote.REMOTE_AUDIO_TRACK_ID": "opus",
"matrix.stats.conn.codec.remote.REMOTE_VIDEO_TRACK_ID": "v9",
"matrix.stats.conn.framerate.local.LOCAL_AUDIO_TRACK_ID": 0,
"matrix.stats.conn.framerate.local.LOCAL_VIDEO_TRACK_ID": 30,
"matrix.stats.conn.framerate.remote.REMOTE_AUDIO_TRACK_ID": 0,
"matrix.stats.conn.framerate.remote.REMOTE_VIDEO_TRACK_ID": 60,
"matrix.stats.conn.jitter.REMOTE_AUDIO_TRACK_ID": 2,
"matrix.stats.conn.jitter.REMOTE_VIDEO_TRACK_ID": 50,
"matrix.stats.conn.packetLoss.download": 0,
"matrix.stats.conn.packetLoss.total": 0,
"matrix.stats.conn.packetLoss.upload": 0,
"matrix.stats.conn.resolution.local.LOCAL_AUDIO_TRACK_ID.height": -1,
"matrix.stats.conn.resolution.local.LOCAL_AUDIO_TRACK_ID.width": -1,
"matrix.stats.conn.resolution.local.LOCAL_VIDEO_TRACK_ID.height": 460,
"matrix.stats.conn.resolution.local.LOCAL_VIDEO_TRACK_ID.width": 780,
"matrix.stats.conn.resolution.remote.REMOTE_AUDIO_TRACK_ID.height": -1,
"matrix.stats.conn.resolution.remote.REMOTE_AUDIO_TRACK_ID.width": -1,
"matrix.stats.conn.resolution.remote.REMOTE_VIDEO_TRACK_ID.height": 960,
"matrix.stats.conn.resolution.remote.REMOTE_VIDEO_TRACK_ID.width": 1080,
"matrix.stats.conn.transport.0.ip": "ff11::5fa:abcd:999c:c5c5:50000",
"matrix.stats.conn.transport.0.type": "udp",
"matrix.stats.conn.transport.0.localIp":
"matrix.call.stats.connection.callId": "callId",
"matrix.call.stats.connection.opponentMemberId": "opponentMemberId",
"matrix.call.stats.connection.bandwidth.download": 0,
"matrix.call.stats.connection.bandwidth.upload": 426,
"matrix.call.stats.connection.bitrate.audio.download": 0,
"matrix.call.stats.connection.bitrate.audio.upload": 124,
"matrix.call.stats.connection.bitrate.download": 0,
"matrix.call.stats.connection.bitrate.upload": 426,
"matrix.call.stats.connection.bitrate.video.download": 0,
"matrix.call.stats.connection.bitrate.video.upload": 302,
"matrix.call.stats.connection.codec.local.LOCAL_AUDIO_TRACK_ID": "opus",
"matrix.call.stats.connection.codec.local.LOCAL_VIDEO_TRACK_ID": "v8",
"matrix.call.stats.connection.codec.remote.REMOTE_AUDIO_TRACK_ID":
"opus",
"matrix.call.stats.connection.codec.remote.REMOTE_VIDEO_TRACK_ID": "v9",
"matrix.call.stats.connection.framerate.local.LOCAL_AUDIO_TRACK_ID": 0,
"matrix.call.stats.connection.framerate.local.LOCAL_VIDEO_TRACK_ID": 30,
"matrix.call.stats.connection.framerate.remote.REMOTE_AUDIO_TRACK_ID": 0,
"matrix.call.stats.connection.framerate.remote.REMOTE_VIDEO_TRACK_ID": 60,
"matrix.call.stats.connection.jitter.REMOTE_AUDIO_TRACK_ID": 2,
"matrix.call.stats.connection.jitter.REMOTE_VIDEO_TRACK_ID": 50,
"matrix.call.stats.connection.packetLoss.download": 0,
"matrix.call.stats.connection.packetLoss.total": 0,
"matrix.call.stats.connection.packetLoss.upload": 0,
"matrix.call.stats.connection.resolution.local.LOCAL_AUDIO_TRACK_ID.height":
-1,
"matrix.call.stats.connection.resolution.local.LOCAL_AUDIO_TRACK_ID.width":
-1,
"matrix.call.stats.connection.resolution.local.LOCAL_VIDEO_TRACK_ID.height": 460,
"matrix.call.stats.connection.resolution.local.LOCAL_VIDEO_TRACK_ID.width": 780,
"matrix.call.stats.connection.resolution.remote.REMOTE_AUDIO_TRACK_ID.height":
-1,
"matrix.call.stats.connection.resolution.remote.REMOTE_AUDIO_TRACK_ID.width":
-1,
"matrix.call.stats.connection.resolution.remote.REMOTE_VIDEO_TRACK_ID.height": 960,
"matrix.call.stats.connection.resolution.remote.REMOTE_VIDEO_TRACK_ID.width": 1080,
"matrix.call.stats.connection.transport.0.ip":
"ff11::5fa:abcd:999c:c5c5:50000",
"matrix.call.stats.connection.transport.0.type": "udp",
"matrix.call.stats.connection.transport.0.localIp":
"2aaa:9999:2aaa:999:8888:2aaa:2aaa:7777:50000",
"matrix.stats.conn.transport.0.isFocus": true,
"matrix.stats.conn.transport.0.localCandidateType": "host",
"matrix.stats.conn.transport.0.remoteCandidateType": "host",
"matrix.stats.conn.transport.0.networkType": "ethernet",
"matrix.stats.conn.transport.0.rtt": "NaN",
"matrix.stats.conn.transport.1.ip": "10.10.10.2:22222",
"matrix.stats.conn.transport.1.type": "tcp",
"matrix.stats.conn.transport.1.localIp": "10.10.10.100:33333",
"matrix.stats.conn.transport.1.isFocus": true,
"matrix.stats.conn.transport.1.localCandidateType": "srfx",
"matrix.stats.conn.transport.1.remoteCandidateType": "srfx",
"matrix.stats.conn.transport.1.networkType": "ethernet",
"matrix.stats.conn.transport.1.rtt": "null",
"matrix.stats.conn.audioConcealment.REMOTE_AUDIO_TRACK_ID.concealedAudio": 0,
"matrix.stats.conn.audioConcealment.REMOTE_AUDIO_TRACK_ID.totalAudioDuration": 0,
"matrix.stats.conn.audioConcealment.REMOTE_VIDEO_TRACK_ID.concealedAudio": 0,
"matrix.stats.conn.audioConcealment.REMOTE_VIDEO_TRACK_ID.totalAudioDuration": 0,
"matrix.stats.conn.totalAudioConcealment.concealedAudio": 0,
"matrix.stats.conn.totalAudioConcealment.totalAudioDuration": 0,
"matrix.call.stats.connection.transport.0.isFocus": true,
"matrix.call.stats.connection.transport.0.localCandidateType": "host",
"matrix.call.stats.connection.transport.0.remoteCandidateType": "host",
"matrix.call.stats.connection.transport.0.networkType": "ethernet",
"matrix.call.stats.connection.transport.0.rtt": "NaN",
"matrix.call.stats.connection.transport.1.ip": "10.10.10.2:22222",
"matrix.call.stats.connection.transport.1.type": "tcp",
"matrix.call.stats.connection.transport.1.localIp":
"10.10.10.100:33333",
"matrix.call.stats.connection.transport.1.isFocus": true,
"matrix.call.stats.connection.transport.1.localCandidateType": "srfx",
"matrix.call.stats.connection.transport.1.remoteCandidateType": "srfx",
"matrix.call.stats.connection.transport.1.networkType": "ethernet",
"matrix.call.stats.connection.transport.1.rtt": "null",
"matrix.call.stats.connection.audioConcealment.REMOTE_AUDIO_TRACK_ID.concealedAudio": 0,
"matrix.call.stats.connection.audioConcealment.REMOTE_AUDIO_TRACK_ID.totalAudioDuration": 0,
"matrix.call.stats.connection.audioConcealment.REMOTE_VIDEO_TRACK_ID.concealedAudio": 0,
"matrix.call.stats.connection.audioConcealment.REMOTE_VIDEO_TRACK_ID.totalAudioDuration": 0,
"matrix.call.stats.connection.totalAudioConcealment.concealedAudio": 0,
"matrix.call.stats.connection.totalAudioConcealment.totalAudioDuration": 0,
});
});
});
describe("on flattenByteSendStatsReportObject", () => {
const byteSent = {
report: new Map([
["4aa92608-04c6-428e-8312-93e17602a959", 132093],
["a08e4237-ee30-4015-a932-b676aec894b1", 913448],
]),
};
const byteSentStatsReport = new Map<
string,
number
>() as ByteSentStatsReport;
byteSentStatsReport.callId = "callId";
byteSentStatsReport.opponentMemberId = "opponentMemberId";
byteSentStatsReport.set("4aa92608-04c6-428e-8312-93e17602a959", 132093);
byteSentStatsReport.set("a08e4237-ee30-4015-a932-b676aec894b1", 913448);
it("should flatten a Report to otel Attributes Object", () => {
expect(
ObjectFlattener.flattenByteSentStatsReportObject(byteSent)
ObjectFlattener.flattenReportObject(
"matrix.call.stats.bytesSend",
byteSentStatsReport
)
).toEqual({
"matrix.stats.bytesSent.4aa92608-04c6-428e-8312-93e17602a959": 132093,
"matrix.stats.bytesSent.a08e4237-ee30-4015-a932-b676aec894b1": 913448,
"matrix.call.stats.bytesSend.4aa92608-04c6-428e-8312-93e17602a959": 132093,
"matrix.call.stats.bytesSend.a08e4237-ee30-4015-a932-b676aec894b1": 913448,
});
expect(byteSentStatsReport.callId).toEqual("callId");
expect(byteSentStatsReport.opponentMemberId).toEqual("opponentMemberId");
});
});
});

View File

@@ -15,13 +15,15 @@ limitations under the License.
*/
import {
appendItems,
addItems,
column,
cycleTileSize,
fillGaps,
forEachCellInArea,
Grid,
resize,
row,
tryMoveTile,
} from "../../src/video-grid/model";
import { TileDescriptor } from "../../src/video-grid/TileDescriptor";
@@ -31,7 +33,7 @@ import { TileDescriptor } from "../../src/video-grid/TileDescriptor";
function mkGrid(spec: string): Grid {
const secondNewline = spec.indexOf("\n", 1);
const columns = secondNewline === -1 ? spec.length : secondNewline - 1;
const cells = spec.match(/[a-z ]/g) ?? [];
const cells = spec.match(/[a-z ]/g) ?? ([] as string[]);
const areas = new Set(cells);
areas.delete(" "); // Space represents an empty cell, not an area
const grid: Grid = { columns, cells: new Array(cells.length) };
@@ -169,6 +171,50 @@ dddd
iegh`
);
testFillGaps(
"keeps a large tile from hanging off the bottom",
`
abcd
efgh
ii
ii`,
`
abcd
iigh
iief`
);
testFillGaps(
"pushes a chain of large tiles upwards",
`
abcd
e fg
hh
hh
ii
ii`,
`
hhcd
hhfg
aiib
eii`
);
testFillGaps(
"gives up on pushing large tiles upwards when not possible",
`
aabb
aabb
cc
cc`,
`
aabb
aabb
cc
cc`
);
function testCycleTileSize(
title: string,
tileId: string,
@@ -227,9 +273,9 @@ dbbe
fghi
jk`,
`
abhc
djge
fik`
akbc
djhe
fig`
);
testCycleTileSize(
@@ -267,17 +313,160 @@ dde
ddf`
);
test("appendItems appends 1×1 tiles", () => {
const grid1 = `
function testAddItems(
title: string,
items: TileDescriptor[],
input: string,
output: string
): void {
test(`addItems ${title}`, () => {
expect(showGrid(addItems(items, mkGrid(input)))).toBe(output);
});
}
testAddItems(
"appends 1×1 tiles",
["e", "f"].map((i) => ({ id: i } as unknown as TileDescriptor)),
`
aab
aac
d`;
const grid2 = `
d`,
`
aab
aac
def`;
const newItems = ["e", "f"].map(
(i) => ({ id: i } as unknown as TileDescriptor)
);
expect(showGrid(appendItems(newItems, mkGrid(grid1)))).toBe(grid2);
});
def`
);
testAddItems(
"places one tile near another on request",
[{ id: "g", placeNear: "b" } as unknown as TileDescriptor],
`
abc
def`,
`
abc
gfe
d`
);
testAddItems(
"places items with a large base size",
[{ id: "g", largeBaseSize: true } as unknown as TileDescriptor],
`
abc
def`,
`
abc
ggf
gge
d`
);
function testTryMoveTile(
title: string,
from: number,
to: number,
input: string,
output: string
): void {
test(`tryMoveTile ${title}`, () => {
expect(showGrid(tryMoveTile(mkGrid(input), from, to))).toBe(output);
});
}
testTryMoveTile(
"refuses to move a tile too far to the left",
1,
-1,
`
abc`,
`
abc`
);
testTryMoveTile(
"refuses to move a tile too far to the right",
1,
3,
`
abc`,
`
abc`
);
testTryMoveTile(
"moves a large tile to an unoccupied space",
3,
1,
`
a b
ccd
cce`,
`
acc
bcc
d e`
);
testTryMoveTile(
"refuses to move a large tile to an occupied space",
3,
1,
`
abb
ccd
cce`,
`
abb
ccd
cce`
);
function testResize(
title: string,
columns: number,
input: string,
output: string
): void {
test(`resize ${title}`, () => {
expect(showGrid(resize(mkGrid(input), columns))).toBe(output);
});
}
testResize(
"contracts the grid",
2,
`
abbb
cbbb
ddde
dddf
gh`,
`
af
bb
bb
ch
dd
dd
eg`
);
testResize(
"expands the grid",
4,
`
af
bb
bb
ch
dd
dd
eg`,
`
bbbc
bbbf
addd
hddd
ge`
);

View File

@@ -1828,10 +1828,10 @@
resolved "https://registry.yarnpkg.com/@juggle/resize-observer/-/resize-observer-3.3.1.tgz#b50a781709c81e10701004214340f25475a171a0"
integrity sha512-zMM9Ds+SawiUkakS7y94Ymqx+S0ORzpG3frZirN3l+UlXUmSUR7hF4wxCVqW+ei94JzV5kt0uXBcoOEAuiydrw==
"@matrix-org/matrix-sdk-crypto-js@^0.1.0-alpha.8":
version "0.1.0-alpha.8"
resolved "https://registry.yarnpkg.com/@matrix-org/matrix-sdk-crypto-js/-/matrix-sdk-crypto-js-0.1.0-alpha.8.tgz#18dd8e7fb56602d2999d8a502b49e902a2bb3782"
integrity sha512-hdmbbGXKrN6JNo3wdBaR5Zs3lXlzllT3U43ViNTlabB3nKkOZQnEAN/Isv+4EQSgz1+8897veI9Q8sqlQX22oA==
"@matrix-org/matrix-sdk-crypto-js@^0.1.0-alpha.10":
version "0.1.0-alpha.11"
resolved "https://registry.yarnpkg.com/@matrix-org/matrix-sdk-crypto-js/-/matrix-sdk-crypto-js-0.1.0-alpha.11.tgz#24d705318c3159ef7dbe43bca464ac2bdd11e45d"
integrity sha512-HD3rskPkqrUUSaKzGLg97k/bN+OZrkcX7ODB/pNBs/jqq+/A0wDKqsszJotzFwsQcDPpWn78BmMyvBo4tLxKjw==
"@matrix-org/olm@https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.14.tgz":
version "3.2.14"
@@ -10557,12 +10557,12 @@ matrix-events-sdk@0.0.1:
resolved "https://registry.yarnpkg.com/matrix-events-sdk/-/matrix-events-sdk-0.0.1.tgz#c8c38911e2cb29023b0bbac8d6f32e0de2c957dd"
integrity sha512-1QEOsXO+bhyCroIe2/A5OwaxHvBm7EsSQ46DEDn8RBIfQwN5HWBpFvyWWR4QY0KHPPnnJdI99wgRiAl7Ad5qaA==
"matrix-js-sdk@github:matrix-org/matrix-js-sdk#fcbc195fbe4170251b87f03a69c8dc5bfccfd5ac":
version "25.1.0"
resolved "https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/fcbc195fbe4170251b87f03a69c8dc5bfccfd5ac"
"matrix-js-sdk@github:matrix-org/matrix-js-sdk#f884c78579c336a03bc20ff8f4e92c46582822b6":
version "26.1.0"
resolved "https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/f884c78579c336a03bc20ff8f4e92c46582822b6"
dependencies:
"@babel/runtime" "^7.12.5"
"@matrix-org/matrix-sdk-crypto-js" "^0.1.0-alpha.8"
"@matrix-org/matrix-sdk-crypto-js" "^0.1.0-alpha.10"
another-json "^0.2.0"
bs58 "^5.0.0"
content-type "^1.0.4"