mirror of
https://github.com/vector-im/element-call.git
synced 2026-08-23 20:59:21 +00:00
Compare commits
68 Commits
af/bump-wi
...
hs/remove-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eeec10e298 | ||
|
|
380fa67f57 | ||
|
|
8830bd74cb | ||
|
|
3ab9bb72e2 | ||
|
|
8891a9a70e | ||
|
|
7d4d1445d0 | ||
|
|
826d0ee40d | ||
|
|
fbc2cd3e97 | ||
|
|
9dbc43d692 | ||
|
|
b3ceb5300c | ||
|
|
ffbc48fe86 | ||
|
|
4655da3c35 | ||
|
|
585998cb87 | ||
|
|
8ec2f9bb31 | ||
|
|
2ee468ba44 | ||
|
|
17eb435a7a | ||
|
|
1678ca71c1 | ||
|
|
90fe8e0529 | ||
|
|
dfece88d66 | ||
|
|
64749d0b7a | ||
|
|
79cd3047e9 | ||
|
|
9078c35e06 | ||
|
|
2833d9a816 | ||
|
|
8598be1d92 | ||
|
|
02d2de3831 | ||
|
|
6d1aa0087b | ||
|
|
8b4b9f4494 | ||
|
|
a551cde47b | ||
|
|
01b9d7a70e | ||
|
|
f56d4a32a3 | ||
|
|
eed1b98cf9 | ||
|
|
f504a6ab28 | ||
|
|
319c9aa7da | ||
|
|
c1208ac7b6 | ||
|
|
d77e9e8f08 | ||
|
|
2e172aa3be | ||
|
|
9650ca9687 | ||
|
|
f7e4b79502 | ||
|
|
20840d901e | ||
|
|
4c1c818ba9 | ||
|
|
f022eb09a2 | ||
|
|
50934a53cd | ||
|
|
99d5886e32 | ||
|
|
137a53dbee | ||
|
|
6e5c468780 | ||
|
|
1478dbe6a1 | ||
|
|
85d8e036d5 | ||
|
|
84e172a2ba | ||
|
|
7fddad02e0 | ||
|
|
3b3f7762a7 | ||
|
|
2297675b06 | ||
|
|
8f4b932c3c | ||
|
|
88782a0332 | ||
|
|
9eae1f4145 | ||
|
|
3d096426be | ||
|
|
83fa9661cf | ||
|
|
6c24763196 | ||
|
|
c4c266536b | ||
|
|
3535cbe2e6 | ||
|
|
dac89b97ae | ||
|
|
854e0ab7ae | ||
|
|
20976e1ee3 | ||
|
|
f12e6601fe | ||
|
|
4b2d8403d7 | ||
|
|
03f0fffa57 | ||
|
|
6486ffe777 | ||
|
|
3dad62f9e3 | ||
|
|
e37f36937e |
@@ -37,6 +37,7 @@ module.exports = {
|
||||
"@typescript-eslint/promise-function-async": "error",
|
||||
"@typescript-eslint/require-await": "error",
|
||||
"@typescript-eslint/await-thenable": "error",
|
||||
"rxjs/no-exposed-subjects": "error",
|
||||
},
|
||||
settings: {
|
||||
react: {
|
||||
|
||||
1
.github/workflows/build.yaml
vendored
1
.github/workflows/build.yaml
vendored
@@ -23,3 +23,4 @@ jobs:
|
||||
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
||||
SENTRY_URL: ${{ secrets.SENTRY_URL }}
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
3
.github/workflows/element-call.yaml
vendored
3
.github/workflows/element-call.yaml
vendored
@@ -14,6 +14,8 @@ on:
|
||||
required: true
|
||||
SENTRY_AUTH_TOKEN:
|
||||
required: true
|
||||
CODECOV_TOKEN:
|
||||
required: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -36,6 +38,7 @@ jobs:
|
||||
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
||||
SENTRY_URL: ${{ secrets.SENTRY_URL }}
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
VITE_APP_VERSION: ${{ inputs.vite_app_version }}
|
||||
NODE_OPTIONS: "--max-old-space-size=4096"
|
||||
- name: Upload Artifact
|
||||
|
||||
4
.github/workflows/translations-download.yaml
vendored
4
.github/workflows/translations-download.yaml
vendored
@@ -24,7 +24,7 @@ jobs:
|
||||
run: "yarn install --frozen-lockfile"
|
||||
|
||||
- name: Prune i18n
|
||||
run: "rm -R public/locales"
|
||||
run: "rm -R locales"
|
||||
|
||||
- name: Download translation files
|
||||
uses: localazy/download@0a79880fb66150601e3b43606fab69c88123c087 # v1.1.0
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
groups: "-p includeSourceLang:true"
|
||||
|
||||
- name: Fix the owner of the downloaded files
|
||||
run: "sudo chown runner:docker -R public/locales"
|
||||
run: "sudo chown runner:docker -R locales"
|
||||
|
||||
- name: Prettier
|
||||
run: yarn prettier:format
|
||||
|
||||
@@ -2,9 +2,3 @@ FROM nginxinc/nginx-unprivileged:alpine
|
||||
|
||||
COPY ./dist /app
|
||||
COPY config/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
USER root
|
||||
|
||||
RUN rm -rf /usr/share/nginx/html
|
||||
|
||||
USER 101
|
||||
|
||||
@@ -106,7 +106,7 @@ rc_message:
|
||||
|
||||
MSC3266 allows to request a room summary of rooms you are not joined. The
|
||||
summary contains the room join rules. We need that to decide if the user gets
|
||||
prompted with the option to knock ("ask to join"), a cannot join error or the
|
||||
prompted with the option to knock ("Request to join call"), a cannot join error or the
|
||||
join view.
|
||||
|
||||
Element Call requires a Livekit SFU alongside a [Livekit JWT
|
||||
@@ -213,7 +213,7 @@ To add a new translation key you can do these steps:
|
||||
|
||||
1. Add the new key entry to the code where the new key is used: `t("some_new_key")`
|
||||
1. Run `yarn i18n` to extract the new key and update the translation files. This
|
||||
will add a skeleton entry to the `public/locales/en-GB/app.json` file:
|
||||
will add a skeleton entry to the `locales/en-GB/app.json` file:
|
||||
```jsonc
|
||||
{
|
||||
...
|
||||
@@ -221,7 +221,7 @@ To add a new translation key you can do these steps:
|
||||
...
|
||||
}
|
||||
```
|
||||
1. Update the skeleton entry in the `public/locales/en-GB/app.json` file with
|
||||
1. Update the skeleton entry in the `locales/en-GB/app.json` file with
|
||||
the English translation:
|
||||
|
||||
```jsonc
|
||||
|
||||
@@ -3,23 +3,17 @@ server {
|
||||
server_name localhost;
|
||||
|
||||
root /app;
|
||||
gzip_static on;
|
||||
gzip_vary on;
|
||||
|
||||
location / {
|
||||
# disable cache entriely by default (apart from Etag which is accurate enough)
|
||||
add_header Cache-Control 'private no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0';
|
||||
if_modified_since off;
|
||||
expires off;
|
||||
# also turn off last-modified since they are just the timestamps of the file in the docker image
|
||||
# and may or may not bear any resemblance to when the resource changed
|
||||
add_header Last-Modified "";
|
||||
|
||||
try_files $uri $uri/ /index.html;
|
||||
add_header Cache-Control "public, max-age=30, stale-while-revalidate=30";
|
||||
}
|
||||
|
||||
# assets can be cached because they have hashed filenames
|
||||
location /assets {
|
||||
expires 1w;
|
||||
add_header Cache-Control "public, no-transform";
|
||||
add_header Cache-Control "public, immutable, max-age=31536000";
|
||||
}
|
||||
|
||||
location /apple-app-site-association {
|
||||
|
||||
@@ -22,7 +22,7 @@ export default {
|
||||
],
|
||||
},
|
||||
locales: ["en-GB"],
|
||||
output: "public/locales/$LOCALE/$NAMESPACE.json",
|
||||
output: "locales/$LOCALE/$NAMESPACE.json",
|
||||
input: ["src/**/*.{ts,tsx}"],
|
||||
sort: true,
|
||||
};
|
||||
|
||||
@@ -7,13 +7,13 @@
|
||||
"features": ["plural_postfix_us", "filter_untranslated"],
|
||||
"files": [
|
||||
{
|
||||
"pattern": "public/locales/en-GB/*.json",
|
||||
"pattern": "locales/en-GB/*.json",
|
||||
"lang": "inherited"
|
||||
},
|
||||
{
|
||||
"group": "existing",
|
||||
"pattern": "public/locales/*/*.json",
|
||||
"excludes": ["public/locales/en-GB/*.json"],
|
||||
"pattern": "locales/*/*.json",
|
||||
"excludes": ["locales/en-GB/*.json"],
|
||||
"lang": "${autodetectLang}"
|
||||
}
|
||||
]
|
||||
@@ -22,7 +22,7 @@
|
||||
"download": {
|
||||
"files": [
|
||||
{
|
||||
"output": "public/locales/${langLsrDash}/${file}"
|
||||
"output": "locales/${langLsrDash}/${file}"
|
||||
}
|
||||
],
|
||||
"includeSourceLang": "${includeSourceLang|false}",
|
||||
|
||||
@@ -4,19 +4,18 @@
|
||||
},
|
||||
"action": {
|
||||
"close": "Schließen",
|
||||
"close_search": "Suche beenden",
|
||||
"copy_link": "Link kopieren",
|
||||
"edit": "Bearbeiten",
|
||||
"go": "Los geht’s",
|
||||
"invite": "Einladen",
|
||||
"lower_hand": "Handmeldung zurücknehmen",
|
||||
"no": "Nein",
|
||||
"open_search": "Suchen",
|
||||
"pick_reaction": "Reaktion auswählen",
|
||||
"raise_hand": "Handmeldung",
|
||||
"raise_hand_or_send_reaction": "Handmeldung oder Reaktion senden",
|
||||
"register": "Registrieren",
|
||||
"remove": "Entfernen",
|
||||
"show_less": "Weniger anzeigen",
|
||||
"show_more": "Mehr anzeigen",
|
||||
"sign_in": "Anmelden",
|
||||
"sign_out": "Abmelden",
|
||||
"submit": "Absenden",
|
||||
@@ -62,7 +61,7 @@
|
||||
"preferences": "Einstellungen",
|
||||
"profile": "Profil",
|
||||
"reaction": "Reaktion",
|
||||
"search": "Suche",
|
||||
"reactions": "Reaktionen",
|
||||
"settings": "Einstellungen",
|
||||
"something_went_wrong": "Etwas ist schief gelaufen",
|
||||
"unencrypted": "Nicht verschlüsselt",
|
||||
@@ -81,8 +80,8 @@
|
||||
"call_ended_heading": "Anruf beendet",
|
||||
"failed_heading": "Beitreten fehlgeschlagen",
|
||||
"failed_text": "Anruf nicht gefunden oder Beitritt nicht erlaubt",
|
||||
"knock_reject_body": "Die Mitglieder des Raums haben Deine Beitrittsanfrage abgelehnt.",
|
||||
"knock_reject_heading": "Beitritt nicht erlaubt",
|
||||
"knock_reject_body": "Die Teilnahmeanfrage wurde abgelehnt.",
|
||||
"knock_reject_heading": "Zugriff verweigert",
|
||||
"reason": "Grund"
|
||||
},
|
||||
"hangup_button_label": "Anruf beenden",
|
||||
@@ -100,11 +99,11 @@
|
||||
"layout_grid_label": "Raster",
|
||||
"layout_spotlight_label": "Fokus",
|
||||
"lobby": {
|
||||
"ask_to_join": "Beitritt anfragen",
|
||||
"ask_to_join": "Teilnahmeanfrage senden",
|
||||
"join_as_guest": "Als Gast beitreten",
|
||||
"join_button": "Anruf beitreten",
|
||||
"leave_button": "Zurück zu kürzlichen Anrufen",
|
||||
"waiting_for_invite": "Anfrage gesendet"
|
||||
"waiting_for_invite": "Anfrage gesendet! Warten auf Teilnahmefreigabe …"
|
||||
},
|
||||
"log_in": "Anmelden",
|
||||
"logging_in": "Anmelden …",
|
||||
@@ -115,7 +114,7 @@
|
||||
"matrix_id": "Matrix-ID: {{id}}",
|
||||
"microphone_off": "Mikrofon aus",
|
||||
"microphone_on": "Mikrofon an",
|
||||
"mute_microphone_button_label": "Mikrofon deaktivieren",
|
||||
"mute_microphone_button_label": "Mikrofon stumm schalten",
|
||||
"participant_count_one": "{{count, number}}",
|
||||
"participant_count_other": "{{count, number}}",
|
||||
"qr_code": "QR-Code",
|
||||
@@ -128,7 +127,6 @@
|
||||
"rageshake_sending": "Senden …",
|
||||
"rageshake_sending_logs": "Sende Debug-Protokolle …",
|
||||
"rageshake_sent": "Danke!",
|
||||
"reaction_search": "Reaktionen suchen...",
|
||||
"recaptcha_caption": "Diese Seite wird durch reCAPTCHA geschützt und es gelten Googles <2>Datenschutzerklärung</2> und <6>Nutzungsbedingungen</6>. <9></9>Mit einem Klick auf „Registrieren“ akzeptierst du unseren <2>Endbenutzer-Lizenzvertrag (EULA)</2>",
|
||||
"recaptcha_dismissed": "Recaptcha abgelehnt",
|
||||
"recaptcha_not_loaded": "Recaptcha nicht geladen",
|
||||
@@ -163,8 +161,8 @@
|
||||
"preferences_tab": {
|
||||
"reactions_play_sound_description": "Einen Soundeffekt abspielen, wenn jemand eine Reaktion sendet",
|
||||
"reactions_play_sound_label": "Reaktionstöne abspielen",
|
||||
"reactions_show_description": "Reaktionen anzeigen",
|
||||
"reactions_show_label": "Zeige eine Animation, wenn jemand eine Reaktion sendet.",
|
||||
"reactions_show_description": "Zeige eine Animation, wenn jemand eine Reaktion sendet.",
|
||||
"reactions_show_label": "Reaktionen anzeigen",
|
||||
"reactions_title": "Reaktionen"
|
||||
},
|
||||
"preferences_tab_body": "Hier können zusätzliche Optionen für individuelle Anforderungen eingestellt werden",
|
||||
@@ -184,14 +182,15 @@
|
||||
"unauthenticated_view_body": "Noch nicht registriert? <2>Konto erstellen</2>",
|
||||
"unauthenticated_view_eula_caption": "Mit einem Klick auf „Los geht’s“ akzeptierst du unseren <2>Endbenutzer-Lizenzvertrag (EULA)</2>",
|
||||
"unauthenticated_view_login_button": "Melde dich mit deinem Konto an",
|
||||
"unmute_microphone_button_label": "Mikrofon aktivieren",
|
||||
"unmute_microphone_button_label": "Mikrofon einschalten",
|
||||
"version": "{{productName}} Version: {{version}}",
|
||||
"video_tile": {
|
||||
"always_show": "Immer anzeigen",
|
||||
"change_fit_contain": "An Fenster anpassen",
|
||||
"collapse": "Minimieren",
|
||||
"expand": "Erweitern",
|
||||
"mute_for_me": "Für mich stummschalten",
|
||||
"mute_for_me": "Für mich stumm schalten",
|
||||
"muted_for_me": "Für mich stumm geschaltet",
|
||||
"volume": "Lautstärke"
|
||||
}
|
||||
}
|
||||
@@ -4,19 +4,18 @@
|
||||
},
|
||||
"action": {
|
||||
"close": "Close",
|
||||
"close_search": "Close search",
|
||||
"copy_link": "Copy link",
|
||||
"edit": "Edit",
|
||||
"go": "Go",
|
||||
"invite": "Invite",
|
||||
"lower_hand": "Lower hand",
|
||||
"no": "No",
|
||||
"open_search": "Open search",
|
||||
"pick_reaction": "Pick reaction",
|
||||
"raise_hand": "Raise hand",
|
||||
"raise_hand_or_send_reaction": "Raise hand or send reaction",
|
||||
"register": "Register",
|
||||
"remove": "Remove",
|
||||
"show_less": "Show less",
|
||||
"show_more": "Show more",
|
||||
"sign_in": "Sign in",
|
||||
"sign_out": "Sign out",
|
||||
"submit": "Submit",
|
||||
@@ -62,7 +61,7 @@
|
||||
"preferences": "Preferences",
|
||||
"profile": "Profile",
|
||||
"reaction": "Reaction",
|
||||
"search": "Search",
|
||||
"reactions": "Reactions",
|
||||
"settings": "Settings",
|
||||
"something_went_wrong": "Something went wrong",
|
||||
"unencrypted": "Not encrypted",
|
||||
@@ -81,8 +80,8 @@
|
||||
"call_ended_heading": "Call ended",
|
||||
"failed_heading": "Failed to join",
|
||||
"failed_text": "Call not found or is not accessible.",
|
||||
"knock_reject_body": "The room members declined your request to join.",
|
||||
"knock_reject_heading": "Not allowed to join",
|
||||
"knock_reject_body": "Your request to join was declined.",
|
||||
"knock_reject_heading": "Access denied",
|
||||
"reason": "Reason"
|
||||
},
|
||||
"hangup_button_label": "End call",
|
||||
@@ -100,11 +99,11 @@
|
||||
"layout_grid_label": "Grid",
|
||||
"layout_spotlight_label": "Spotlight",
|
||||
"lobby": {
|
||||
"ask_to_join": "Ask to join call",
|
||||
"ask_to_join": "Request to join call",
|
||||
"join_as_guest": "Join as guest",
|
||||
"join_button": "Join call",
|
||||
"leave_button": "Back to recents",
|
||||
"waiting_for_invite": "Request sent"
|
||||
"waiting_for_invite": "Request sent! Waiting for permission to join…"
|
||||
},
|
||||
"log_in": "Log In",
|
||||
"logging_in": "Logging in…",
|
||||
@@ -128,7 +127,6 @@
|
||||
"rageshake_sending": "Sending…",
|
||||
"rageshake_sending_logs": "Sending debug logs…",
|
||||
"rageshake_sent": "Thanks!",
|
||||
"reaction_search": "Search reactions…",
|
||||
"recaptcha_caption": "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>End User Licensing Agreement (EULA)</12>",
|
||||
"recaptcha_dismissed": "Recaptcha dismissed",
|
||||
"recaptcha_not_loaded": "Recaptcha not loaded",
|
||||
@@ -163,8 +161,8 @@
|
||||
"preferences_tab": {
|
||||
"reactions_play_sound_description": "Play a sound effect when anyone sends a reaction into a call.",
|
||||
"reactions_play_sound_label": "Play reaction sounds",
|
||||
"reactions_show_description": "Show reactions",
|
||||
"reactions_show_label": "Show an animation when anyone sends a reaction.",
|
||||
"reactions_show_description": "Show an animation when anyone sends a reaction.",
|
||||
"reactions_show_label": "Show reactions",
|
||||
"reactions_title": "Reactions"
|
||||
},
|
||||
"preferences_tab_body": "Here you can configure extra options for an improved experience",
|
||||
@@ -192,6 +190,7 @@
|
||||
"collapse": "Collapse",
|
||||
"expand": "Expand",
|
||||
"mute_for_me": "Mute for me",
|
||||
"muted_for_me": "Muted for me",
|
||||
"volume": "Volume"
|
||||
}
|
||||
}
|
||||
16
package.json
16
package.json
@@ -24,6 +24,9 @@
|
||||
"@babel/preset-env": "^7.22.20",
|
||||
"@babel/preset-react": "^7.22.15",
|
||||
"@babel/preset-typescript": "^7.23.0",
|
||||
"@codecov/vite-plugin": "^1.3.0",
|
||||
"@fontsource/inconsolata": "^5.1.0",
|
||||
"@fontsource/inter": "^5.1.0",
|
||||
"@formatjs/intl-durationformat": "^0.6.1",
|
||||
"@formatjs/intl-segmenter": "^11.7.3",
|
||||
"@livekit/components-core": "^0.11.0",
|
||||
@@ -48,8 +51,9 @@
|
||||
"@types/content-type": "^1.1.5",
|
||||
"@types/grecaptcha": "^3.0.9",
|
||||
"@types/jsdom": "^21.1.7",
|
||||
"@types/lodash": "^4.14.199",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/node": "^20.0.0",
|
||||
"@types/pako": "^2.0.3",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
@@ -80,15 +84,14 @@
|
||||
"history": "^4.0.0",
|
||||
"i18next": "^23.0.0",
|
||||
"i18next-browser-languagedetector": "^8.0.0",
|
||||
"i18next-http-backend": "^2.0.0",
|
||||
"i18next-parser": "^9.0.0",
|
||||
"jsdom": "^25.0.0",
|
||||
"knip": "^5.27.2",
|
||||
"livekit-client": "^2.5.7",
|
||||
"lodash": "^4.17.21",
|
||||
"lodash-es": "^4.17.21",
|
||||
"loglevel": "^1.9.1",
|
||||
"matrix-js-sdk": "matrix-org/matrix-js-sdk#fcb69b16ad8d170c67ea844f83543d467bbd7707",
|
||||
"matrix-widget-api": "^1.8.2",
|
||||
"matrix-js-sdk": "matrix-org/matrix-js-sdk#80582a734684b532585b52cde63ef34dc7d774fa",
|
||||
"matrix-widget-api": "^1.10.0",
|
||||
"normalize.css": "^8.0.1",
|
||||
"observable-hooks": "^4.2.3",
|
||||
"pako": "^2.0.4",
|
||||
@@ -108,8 +111,9 @@
|
||||
"typescript": "^5.1.6",
|
||||
"typescript-eslint-language-service": "^5.0.5",
|
||||
"unique-names-generator": "^4.6.0",
|
||||
"vaul": "^0.9.0",
|
||||
"vaul": "^1.0.0",
|
||||
"vite": "^5.0.0",
|
||||
"vite-plugin-compression2": "^1.3.1",
|
||||
"vite-plugin-html-template": "^1.1.0",
|
||||
"vite-plugin-svgr": "^4.0.0",
|
||||
"vitest": "^2.0.0",
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -3,6 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="favicon.png" />
|
||||
<link rel="preload" href="/config.json" as="fetch" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": ["config:base"],
|
||||
"extends": ["config:recommended"],
|
||||
"packageRules": [
|
||||
{
|
||||
"groupName": "all non-major dependencies",
|
||||
@@ -26,8 +26,8 @@
|
||||
},
|
||||
{
|
||||
"groupName": "Compound",
|
||||
"matchPackagePrefixes": ["@vector-im/compound-"],
|
||||
"schedule": "before 5am on Tuesday and Friday"
|
||||
"schedule": "before 5am on Tuesday and Friday",
|
||||
"matchPackageNames": ["@vector-im/compound-{/,}**"]
|
||||
},
|
||||
{
|
||||
"groupName": "LiveKit client",
|
||||
@@ -35,7 +35,7 @@
|
||||
},
|
||||
{
|
||||
"groupName": "LiveKit components",
|
||||
"matchPackagePrefixes": ["@livekit/components-"]
|
||||
"matchPackageNames": ["@livekit/components-{/,}**"]
|
||||
},
|
||||
{
|
||||
"groupName": "Vaul",
|
||||
|
||||
6
src/@types/global.d.ts
vendored
6
src/@types/global.d.ts
vendored
@@ -6,6 +6,7 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import "matrix-js-sdk/src/@types/global";
|
||||
import type { DurationFormat as PolyfillDurationFormat } from "@formatjs/intl-durationformat";
|
||||
import { Controls } from "../controls";
|
||||
|
||||
declare global {
|
||||
@@ -23,4 +24,9 @@ declare global {
|
||||
// Safari only supports this prefixed, so tell the type system about it
|
||||
webkitRequestFullscreen: () => void;
|
||||
}
|
||||
|
||||
namespace Intl {
|
||||
// Add DurationFormat as part of the Intl namespace because we polyfill it
|
||||
const DurationFormat: typeof PolyfillDurationFormat;
|
||||
}
|
||||
}
|
||||
|
||||
2
src/@types/i18next.d.ts
vendored
2
src/@types/i18next.d.ts
vendored
@@ -7,7 +7,7 @@ Please see LICENSE in the repository root for full details.
|
||||
|
||||
import "i18next";
|
||||
// import all namespaces (for the default language, only)
|
||||
import app from "../../public/locales/en-GB/app.json";
|
||||
import app from "../../locales/en-GB/app.json";
|
||||
|
||||
declare module "i18next" {
|
||||
interface CustomTypeOptions {
|
||||
|
||||
@@ -16,19 +16,13 @@ import {
|
||||
useMemo,
|
||||
} from "react";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import {
|
||||
ClientEvent,
|
||||
ICreateClientOpts,
|
||||
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 { MatrixError } from "matrix-js-sdk/src/matrix";
|
||||
import { WidgetApi } from "matrix-widget-api";
|
||||
import { ClientEvent, type MatrixClient } from "matrix-js-sdk/src/client";
|
||||
|
||||
import type { WidgetApi } from "matrix-widget-api";
|
||||
import { ErrorView } from "./FullScreenView";
|
||||
import { fallbackICEServerAllowed, initClient } from "./utils/matrix";
|
||||
import { widget } from "./widget";
|
||||
import {
|
||||
PosthogAnalytics,
|
||||
@@ -36,7 +30,6 @@ import {
|
||||
} from "./analytics/PosthogAnalytics";
|
||||
import { translatedError } from "./TranslatedError";
|
||||
import { useEventTarget } from "./useEvents";
|
||||
import { Config } from "./config/Config";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
@@ -359,7 +352,7 @@ export const ClientProvider: FC<Props> = ({ children }) => {
|
||||
);
|
||||
};
|
||||
|
||||
type InitResult = {
|
||||
export type InitResult = {
|
||||
widgetApi: WidgetApi | null;
|
||||
client: MatrixClient;
|
||||
passwordlessUser: boolean;
|
||||
@@ -376,50 +369,8 @@ async function loadClient(): Promise<InitResult | null> {
|
||||
passwordlessUser: false,
|
||||
};
|
||||
} else {
|
||||
// We're running as a standalone application
|
||||
try {
|
||||
const session = loadSession();
|
||||
if (!session) {
|
||||
logger.log("No session stored; continuing without a client");
|
||||
return null;
|
||||
}
|
||||
|
||||
logger.log("Using a standalone client");
|
||||
|
||||
/* eslint-disable camelcase */
|
||||
const { user_id, device_id, access_token, passwordlessUser } = session;
|
||||
const initClientParams: ICreateClientOpts = {
|
||||
baseUrl: Config.defaultHomeserverUrl()!,
|
||||
accessToken: access_token,
|
||||
userId: user_id,
|
||||
deviceId: device_id,
|
||||
fallbackICEServerAllowed: fallbackICEServerAllowed,
|
||||
livekitServiceURL: Config.get().livekit?.livekit_service_url,
|
||||
};
|
||||
|
||||
try {
|
||||
const client = await initClient(initClientParams, true);
|
||||
return {
|
||||
widgetApi: null,
|
||||
client,
|
||||
passwordlessUser,
|
||||
};
|
||||
} catch (err) {
|
||||
if (err instanceof MatrixError && err.errcode === "M_UNKNOWN_TOKEN") {
|
||||
// We can't use this session anymore, so let's log it out
|
||||
logger.log(
|
||||
"The session from local store is invalid; continuing without a client",
|
||||
);
|
||||
clearSession();
|
||||
// returning null = "no client` pls register" (undefined = "loading" which is the current value when reaching this line)
|
||||
return null;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
} catch (err) {
|
||||
clearSession();
|
||||
throw err;
|
||||
}
|
||||
const { initSPA } = await import("./utils/spa");
|
||||
return initSPA(loadSession, clearSession);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
.modal {
|
||||
--inset-inline: 520px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
@@ -35,7 +36,7 @@ Please see LICENSE in the repository root for full details.
|
||||
.drawer {
|
||||
background: var(--cpd-color-bg-canvas-default);
|
||||
inset-block-end: 0;
|
||||
inset-inline: max(0px, calc((100% - 520px) / 2));
|
||||
inset-inline: max(0px, calc((100% - var(--inset-inline)) / 2));
|
||||
max-block-size: 90%;
|
||||
border-start-start-radius: var(--border-radius);
|
||||
border-start-end-radius: var(--border-radius);
|
||||
|
||||
@@ -42,8 +42,9 @@ Please see LICENSE in the repository root for full details.
|
||||
}
|
||||
|
||||
.overlay.animate {
|
||||
--overlay-top: 50%;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
top: var(--overlay-top);
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ Please see LICENSE in the repository root for full details.
|
||||
import { FC, useCallback } from "react";
|
||||
import { Root, Track, Range, Thumb } from "@radix-ui/react-slider";
|
||||
import classNames from "classnames";
|
||||
import { Tooltip } from "@vector-im/compound-web";
|
||||
|
||||
import styles from "./Slider.module.css";
|
||||
|
||||
@@ -66,7 +67,10 @@ export const Slider: FC<Props> = ({
|
||||
<Track className={styles.track}>
|
||||
<Range className={styles.highlight} />
|
||||
</Track>
|
||||
<Thumb className={styles.handle} aria-label={label} />
|
||||
{/* Note: This is expected not to be visible on mobile.*/}
|
||||
<Tooltip placement="top" label={Math.round(value * 100).toString() + "%"}>
|
||||
<Thumb className={styles.handle} aria-label={label} />
|
||||
</Tooltip>
|
||||
</Root>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -39,13 +39,16 @@ exports[`the modal renders as a drawer in mobile viewports 1`] = `
|
||||
aria-labelledby="radix-:ra:"
|
||||
class="overlay modal drawer"
|
||||
data-state="open"
|
||||
data-vaul-animate="true"
|
||||
data-vaul-custom-container="false"
|
||||
data-vaul-delayed-snap-points="false"
|
||||
data-vaul-drawer=""
|
||||
data-vaul-drawer-direction="bottom"
|
||||
data-vaul-snap-points="false"
|
||||
id="radix-:r9:"
|
||||
role="dialog"
|
||||
style="pointer-events: auto;"
|
||||
tabindex="-1"
|
||||
vaul-drawer=""
|
||||
vaul-drawer-direction="bottom"
|
||||
vaul-drawer-visible="true"
|
||||
>
|
||||
<div
|
||||
class="content"
|
||||
|
||||
@@ -3,78 +3,99 @@
|
||||
}
|
||||
|
||||
.reactionPopupMenu {
|
||||
--reaction-button-padding: 10px;
|
||||
--reaction-button-fontsize: 20px;
|
||||
--reaction-button-gap: var(--cpd-separator-spacing);
|
||||
display: flex;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.reactionPopupMenuModal {
|
||||
width: fit-content !important;
|
||||
top: 82vh !important;
|
||||
@media (max-width: 420px) {
|
||||
.reactionPopupMenu {
|
||||
--reaction-button-padding: 8px;
|
||||
--reaction-button-fontsize: 16px;
|
||||
--reaction-button-gap: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.reactionPopupMenuModal > div > div {
|
||||
padding-inline: var(--cpd-space-6x) !important;
|
||||
padding-block: var(--cpd-space-6x) var(--cpd-space-8x) !important;
|
||||
div.reactionPopupMenuRoot.reactionPopupMenuModal {
|
||||
--overlay-top: 82vh;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.reactionPopupMenu menu {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--cpd-separator-spacing);
|
||||
div.reactionPopupMenuRoot {
|
||||
/* Center the drawer */
|
||||
--inset-inline: 30em;
|
||||
}
|
||||
|
||||
.reactionPopupMenuRoot > div {
|
||||
width: fit-content;
|
||||
max-width: 100vw;
|
||||
}
|
||||
|
||||
div.reactionPopupMenuRoot.reactionPopupMenuModal > div > div {
|
||||
padding-inline: var(--cpd-space-6x);
|
||||
padding-block: var(--cpd-space-6x);
|
||||
}
|
||||
|
||||
.reactionPopupMenu section {
|
||||
height: fit-content;
|
||||
margin-top: auto;
|
||||
margin-bottom: auto;
|
||||
flex: 1;
|
||||
max-width: fit-content;
|
||||
}
|
||||
|
||||
.reactionPopupMenuItem {
|
||||
list-style: none;
|
||||
.reactionPopupMenu section.reactionsMenuSection {
|
||||
margin: auto 0;
|
||||
flex: auto;
|
||||
}
|
||||
|
||||
.reactionsMenu {
|
||||
min-height: 3em;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
flex-grow: 1;
|
||||
gap: var(--reaction-button-gap);
|
||||
/* Height of 3 rows plus padding. */
|
||||
max-height: calc(
|
||||
((var(--reaction-button-fontsize) + var(--cpd-separator-spacing)) * 2) * 3
|
||||
);
|
||||
max-width: calc(
|
||||
((var(--reaction-button-fontsize) + var(--cpd-separator-spacing)) * 2) * 5
|
||||
);
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
list-style: none;
|
||||
flex-wrap: wrap;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
flex-direction: row;
|
||||
justify-content: start;
|
||||
align-items: auto;
|
||||
align-content: start;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.reactionsMenu > * {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.reactionButton {
|
||||
padding: 1em;
|
||||
font-size: 1.6em;
|
||||
width: 1.4em;
|
||||
height: 1.4em;
|
||||
padding: var(--reaction-button-padding);
|
||||
border-radius: var(--cpd-radius-pill-effect);
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.reactionButton {
|
||||
padding: 1em;
|
||||
font-size: 1em;
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
min-block-size: unset;
|
||||
}
|
||||
font-size: var(--reaction-button-fontsize);
|
||||
min-block-size: unset;
|
||||
border: none;
|
||||
aspect-ratio: 1 / 1;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.verticalSeperator {
|
||||
background-color: var(--cpd-color-gray-800);
|
||||
width: 1px;
|
||||
width: 2px;
|
||||
height: auto;
|
||||
margin-left: var(--cpd-separator-spacing);
|
||||
margin-right: var(--cpd-separator-spacing);
|
||||
}
|
||||
|
||||
.searchForm {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: var(--cpd-separator-spacing);
|
||||
margin-bottom: var(--cpd-space-3x);
|
||||
}
|
||||
|
||||
.searchForm > label {
|
||||
flex: auto;
|
||||
}
|
||||
|
||||
.alert {
|
||||
margin-bottom: var(--cpd-space-3x);
|
||||
animation: grow-in 200ms;
|
||||
|
||||
@@ -5,10 +5,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { fireEvent, render } from "@testing-library/react";
|
||||
import { act } from "react";
|
||||
import { render } from "@testing-library/react";
|
||||
import { expect, test } from "vitest";
|
||||
import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc";
|
||||
import { TooltipProvider } from "@vector-im/compound-web";
|
||||
import { userEvent } from "@testing-library/user-event";
|
||||
import { ReactNode } from "react";
|
||||
@@ -30,18 +28,13 @@ const membership: Record<string, string> = {
|
||||
|
||||
function TestComponent({
|
||||
rtcSession,
|
||||
room,
|
||||
}: {
|
||||
rtcSession: MockRTCSession;
|
||||
room: MockRoom;
|
||||
}): ReactNode {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<TestReactionsWrapper rtcSession={rtcSession}>
|
||||
<ReactionToggleButton
|
||||
rtcSession={rtcSession as unknown as MatrixRTCSession}
|
||||
client={room.client}
|
||||
/>
|
||||
<ReactionToggleButton userId={memberUserIdAlice} />
|
||||
</TestReactionsWrapper>
|
||||
</TooltipProvider>
|
||||
);
|
||||
@@ -52,9 +45,9 @@ test("Can open menu", async () => {
|
||||
const room = new MockRoom(memberUserIdAlice);
|
||||
const rtcSession = new MockRTCSession(room, membership);
|
||||
const { getByLabelText, container } = render(
|
||||
<TestComponent rtcSession={rtcSession} room={room} />,
|
||||
<TestComponent rtcSession={rtcSession} />,
|
||||
);
|
||||
await user.click(getByLabelText("action.raise_hand_or_send_reaction"));
|
||||
await user.click(getByLabelText("common.reactions"));
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
@@ -63,9 +56,9 @@ test("Can raise hand", async () => {
|
||||
const room = new MockRoom(memberUserIdAlice);
|
||||
const rtcSession = new MockRTCSession(room, membership);
|
||||
const { getByLabelText, container } = render(
|
||||
<TestComponent rtcSession={rtcSession} room={room} />,
|
||||
<TestComponent rtcSession={rtcSession} />,
|
||||
);
|
||||
await user.click(getByLabelText("action.raise_hand_or_send_reaction"));
|
||||
await user.click(getByLabelText("common.reactions"));
|
||||
await user.click(getByLabelText("action.raise_hand"));
|
||||
expect(room.testSentEvents).toEqual([
|
||||
[
|
||||
@@ -88,10 +81,10 @@ test("Can lower hand", async () => {
|
||||
const room = new MockRoom(memberUserIdAlice);
|
||||
const rtcSession = new MockRTCSession(room, membership);
|
||||
const { getByLabelText, container } = render(
|
||||
<TestComponent rtcSession={rtcSession} room={room} />,
|
||||
<TestComponent rtcSession={rtcSession} />,
|
||||
);
|
||||
const reactionEvent = room.testSendHandRaise(memberEventAlice, membership);
|
||||
await user.click(getByLabelText("action.raise_hand_or_send_reaction"));
|
||||
await user.click(getByLabelText("common.reactions"));
|
||||
await user.click(getByLabelText("action.lower_hand"));
|
||||
expect(room.testRedactedEvents).toEqual([[undefined, reactionEvent]]);
|
||||
expect(container).toMatchSnapshot();
|
||||
@@ -102,9 +95,9 @@ test("Can react with emoji", async () => {
|
||||
const room = new MockRoom(memberUserIdAlice);
|
||||
const rtcSession = new MockRTCSession(room, membership);
|
||||
const { getByLabelText, getByText } = render(
|
||||
<TestComponent rtcSession={rtcSession} room={room} />,
|
||||
<TestComponent rtcSession={rtcSession} />,
|
||||
);
|
||||
await user.click(getByLabelText("action.raise_hand_or_send_reaction"));
|
||||
await user.click(getByLabelText("common.reactions"));
|
||||
await user.click(getByText("🐶"));
|
||||
expect(room.testSentEvents).toEqual([
|
||||
[
|
||||
@@ -122,17 +115,15 @@ test("Can react with emoji", async () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("Can search for and send emoji", async () => {
|
||||
test("Can fully expand emoji picker", async () => {
|
||||
const user = userEvent.setup();
|
||||
const room = new MockRoom(memberUserIdAlice);
|
||||
const rtcSession = new MockRTCSession(room, membership);
|
||||
const { getByText, container, getByLabelText } = render(
|
||||
<TestComponent rtcSession={rtcSession} room={room} />,
|
||||
<TestComponent rtcSession={rtcSession} />,
|
||||
);
|
||||
await user.click(getByLabelText("action.raise_hand_or_send_reaction"));
|
||||
await user.click(getByLabelText("action.open_search"));
|
||||
// Search should autofocus.
|
||||
await user.keyboard("crickets");
|
||||
await user.click(getByLabelText("common.reactions"));
|
||||
await user.click(getByLabelText("action.show_more"));
|
||||
expect(container).toMatchSnapshot();
|
||||
await user.click(getByText("🦗"));
|
||||
|
||||
@@ -152,63 +143,15 @@ test("Can search for and send emoji", async () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("Can search for and send emoji with the keyboard", async () => {
|
||||
const user = userEvent.setup();
|
||||
const room = new MockRoom(memberUserIdAlice);
|
||||
const rtcSession = new MockRTCSession(room, membership);
|
||||
const { getByLabelText, getByPlaceholderText, container } = render(
|
||||
<TestComponent rtcSession={rtcSession} room={room} />,
|
||||
);
|
||||
await user.click(getByLabelText("action.raise_hand_or_send_reaction"));
|
||||
await user.click(getByLabelText("action.open_search"));
|
||||
const searchField = getByPlaceholderText("reaction_search");
|
||||
// Search should autofocus.
|
||||
await user.keyboard("crickets");
|
||||
expect(container).toMatchSnapshot();
|
||||
act(() => {
|
||||
fireEvent.keyDown(searchField, { key: "Enter" });
|
||||
});
|
||||
expect(room.testSentEvents).toEqual([
|
||||
[
|
||||
undefined,
|
||||
ElementCallReactionEventType,
|
||||
{
|
||||
"m.relates_to": {
|
||||
event_id: memberEventAlice,
|
||||
rel_type: "m.reference",
|
||||
},
|
||||
name: "crickets",
|
||||
emoji: "🦗",
|
||||
},
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
test("Can close search", async () => {
|
||||
test("Can close reaction dialog", async () => {
|
||||
const user = userEvent.setup();
|
||||
const room = new MockRoom(memberUserIdAlice);
|
||||
const rtcSession = new MockRTCSession(room, membership);
|
||||
const { getByLabelText, container } = render(
|
||||
<TestComponent rtcSession={rtcSession} room={room} />,
|
||||
<TestComponent rtcSession={rtcSession} />,
|
||||
);
|
||||
await user.click(getByLabelText("action.raise_hand_or_send_reaction"));
|
||||
await user.click(getByLabelText("action.open_search"));
|
||||
await user.click(getByLabelText("action.close_search"));
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test("Can close search with the escape key", async () => {
|
||||
const user = userEvent.setup();
|
||||
const room = new MockRoom(memberUserIdAlice);
|
||||
const rtcSession = new MockRTCSession(room, membership);
|
||||
const { getByLabelText, container, getByPlaceholderText } = render(
|
||||
<TestComponent rtcSession={rtcSession} room={room} />,
|
||||
);
|
||||
await user.click(getByLabelText("action.raise_hand_or_send_reaction"));
|
||||
await user.click(getByLabelText("action.open_search"));
|
||||
const searchField = getByPlaceholderText("reaction_search");
|
||||
act(() => {
|
||||
fireEvent.keyDown(searchField, { key: "Escape" });
|
||||
});
|
||||
await user.click(getByLabelText("common.reactions"));
|
||||
await user.click(getByLabelText("action.show_more"));
|
||||
await user.click(getByLabelText("action.show_less"));
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
@@ -5,24 +5,16 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { Button as CpdButton, Tooltip, Alert } from "@vector-im/compound-web";
|
||||
import {
|
||||
Button as CpdButton,
|
||||
Tooltip,
|
||||
Search,
|
||||
Form,
|
||||
Alert,
|
||||
} from "@vector-im/compound-web";
|
||||
import {
|
||||
SearchIcon,
|
||||
CloseIcon,
|
||||
RaisedHandSolidIcon,
|
||||
ReactionIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronUpIcon,
|
||||
ReactionSolidIcon,
|
||||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import {
|
||||
ChangeEventHandler,
|
||||
ComponentPropsWithoutRef,
|
||||
FC,
|
||||
KeyboardEventHandler,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
@@ -31,19 +23,11 @@ import {
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { EventType, RelationType } from "matrix-js-sdk/src/matrix";
|
||||
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||
import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
|
||||
import classNames from "classnames";
|
||||
|
||||
import { useReactions } from "../useReactions";
|
||||
import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships";
|
||||
import styles from "./ReactionToggleButton.module.css";
|
||||
import {
|
||||
ReactionOption,
|
||||
ReactionSet,
|
||||
ElementCallReactionEventType,
|
||||
} from "../reactions";
|
||||
import { ReactionOption, ReactionSet, ReactionsRowSize } from "../reactions";
|
||||
import { Modal } from "../Modal";
|
||||
|
||||
interface InnerButtonProps extends ComponentPropsWithoutRef<"button"> {
|
||||
@@ -55,15 +39,14 @@ const InnerButton: FC<InnerButtonProps> = ({ raised, open, ...props }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Tooltip label={t("action.raise_hand_or_send_reaction")}>
|
||||
<Tooltip label={t("common.reactions")}>
|
||||
<CpdButton
|
||||
className={classNames(raised && styles.raisedButton)}
|
||||
aria-expanded={open}
|
||||
aria-haspopup
|
||||
aria-label={t("action.raise_hand_or_send_reaction")}
|
||||
kind={raised || open ? "primary" : "secondary"}
|
||||
iconOnly
|
||||
Icon={raised ? RaisedHandSolidIcon : ReactionIcon}
|
||||
Icon={raised ? RaisedHandSolidIcon : ReactionSolidIcon}
|
||||
{...props}
|
||||
/>
|
||||
</Tooltip>
|
||||
@@ -84,43 +67,11 @@ export function ReactionPopupMenu({
|
||||
canReact: boolean;
|
||||
}): ReactNode {
|
||||
const { t } = useTranslation();
|
||||
const [searchText, setSearchText] = useState("");
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const onSearch = useCallback<ChangeEventHandler<HTMLInputElement>>((ev) => {
|
||||
ev.preventDefault();
|
||||
setSearchText(ev.target.value.trim().toLocaleLowerCase());
|
||||
}, []);
|
||||
const [isFullyExpanded, setExpanded] = useState(false);
|
||||
|
||||
const filteredReactionSet = useMemo(
|
||||
() =>
|
||||
ReactionSet.filter(
|
||||
(reaction) =>
|
||||
!isSearching ||
|
||||
(!!searchText &&
|
||||
(reaction.name.startsWith(searchText) ||
|
||||
reaction.alias?.some((a) => a.startsWith(searchText)))),
|
||||
).slice(0, 6),
|
||||
[searchText, isSearching],
|
||||
);
|
||||
|
||||
const onSearchKeyDown = useCallback<KeyboardEventHandler<never>>(
|
||||
(ev) => {
|
||||
if (ev.key === "Enter") {
|
||||
ev.preventDefault();
|
||||
if (!canReact) {
|
||||
return;
|
||||
}
|
||||
if (filteredReactionSet.length !== 1) {
|
||||
return;
|
||||
}
|
||||
sendReaction(filteredReactionSet[0]);
|
||||
setIsSearching(false);
|
||||
} else if (ev.key === "Escape") {
|
||||
ev.preventDefault();
|
||||
setIsSearching(false);
|
||||
}
|
||||
},
|
||||
[sendReaction, filteredReactionSet, canReact, setIsSearching],
|
||||
() => (isFullyExpanded ? ReactionSet : ReactionSet.slice(0, 5)),
|
||||
[isFullyExpanded],
|
||||
);
|
||||
const label = isHandRaised ? t("action.lower_hand") : t("action.raise_hand");
|
||||
return (
|
||||
@@ -136,9 +87,10 @@ export function ReactionPopupMenu({
|
||||
)}
|
||||
<div className={styles.reactionPopupMenu}>
|
||||
<section className={styles.handRaiseSection}>
|
||||
<Tooltip label={label}>
|
||||
<Tooltip label={label} caption="H">
|
||||
<CpdButton
|
||||
kind={isHandRaised ? "primary" : "secondary"}
|
||||
aria-keyshortcuts="H"
|
||||
aria-pressed={isHandRaised}
|
||||
aria-label={label}
|
||||
onClick={() => toggleRaisedHand()}
|
||||
@@ -148,41 +100,33 @@ export function ReactionPopupMenu({
|
||||
</Tooltip>
|
||||
</section>
|
||||
<div className={styles.verticalSeperator} />
|
||||
<section>
|
||||
{isSearching ? (
|
||||
<>
|
||||
<Form.Root className={styles.searchForm}>
|
||||
<Search
|
||||
required
|
||||
value={searchText}
|
||||
name="reactionSearch"
|
||||
placeholder={t("reaction_search")}
|
||||
onChange={onSearch}
|
||||
onKeyDown={onSearchKeyDown}
|
||||
// This is a reasonable use of autofocus, we are focusing when
|
||||
// the search button is clicked (which matches the Element Web reaction picker)
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus
|
||||
autoFocus
|
||||
/>
|
||||
<CpdButton
|
||||
Icon={CloseIcon}
|
||||
aria-label={t("action.close_search")}
|
||||
size="sm"
|
||||
kind="destructive"
|
||||
onClick={() => setIsSearching(false)}
|
||||
/>
|
||||
</Form.Root>
|
||||
</>
|
||||
) : null}
|
||||
<menu className={styles.reactionsMenu}>
|
||||
{filteredReactionSet.map((reaction) => (
|
||||
<li className={styles.reactionPopupMenuItem} key={reaction.name}>
|
||||
<Tooltip label={reaction.name}>
|
||||
<section className={styles.reactionsMenuSection}>
|
||||
<menu
|
||||
className={classNames(
|
||||
isFullyExpanded && styles.reactionsMenuExpanded,
|
||||
styles.reactionsMenu,
|
||||
)}
|
||||
>
|
||||
{filteredReactionSet.map((reaction, index) => (
|
||||
<li key={reaction.name}>
|
||||
<Tooltip
|
||||
label={reaction.name}
|
||||
caption={
|
||||
index < ReactionsRowSize
|
||||
? (index + 1).toString()
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<CpdButton
|
||||
kind="secondary"
|
||||
className={styles.reactionButton}
|
||||
disabled={!canReact}
|
||||
onClick={() => sendReaction(reaction)}
|
||||
aria-keyshortcuts={
|
||||
index < ReactionsRowSize
|
||||
? (index + 1).toString()
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{reaction.emoji}
|
||||
</CpdButton>
|
||||
@@ -191,73 +135,56 @@ export function ReactionPopupMenu({
|
||||
))}
|
||||
</menu>
|
||||
</section>
|
||||
{!isSearching ? (
|
||||
<section style={{ marginLeft: "var(--cpd-separator-spacing)" }}>
|
||||
<li key="search" className={styles.reactionPopupMenuItem}>
|
||||
<Tooltip label={t("common.search")}>
|
||||
<CpdButton
|
||||
iconOnly
|
||||
aria-label={t("action.open_search")}
|
||||
Icon={SearchIcon}
|
||||
kind="tertiary"
|
||||
onClick={() => setIsSearching(true)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</li>
|
||||
</section>
|
||||
) : null}
|
||||
<section style={{ marginLeft: "var(--cpd-separator-spacing)" }}>
|
||||
<Tooltip
|
||||
label={
|
||||
isFullyExpanded ? t("action.show_less") : t("action.show_more")
|
||||
}
|
||||
>
|
||||
<CpdButton
|
||||
iconOnly
|
||||
aria-label={
|
||||
isFullyExpanded ? t("action.show_less") : t("action.show_more")
|
||||
}
|
||||
Icon={isFullyExpanded ? ChevronUpIcon : ChevronDownIcon}
|
||||
kind="tertiary"
|
||||
onClick={() => setExpanded(!isFullyExpanded)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</section>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface ReactionToggleButtonProps extends ComponentPropsWithoutRef<"button"> {
|
||||
rtcSession: MatrixRTCSession;
|
||||
client: MatrixClient;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export function ReactionToggleButton({
|
||||
client,
|
||||
rtcSession,
|
||||
userId,
|
||||
...props
|
||||
}: ReactionToggleButtonProps): ReactNode {
|
||||
const { t } = useTranslation();
|
||||
const { raisedHands, lowerHand, reactions } = useReactions();
|
||||
const { raisedHands, toggleRaisedHand, sendReaction, reactions } =
|
||||
useReactions();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const userId = client.getUserId()!;
|
||||
const isHandRaised = !!raisedHands[userId];
|
||||
const memberships = useMatrixRTCSessionMemberships(rtcSession);
|
||||
const [showReactionsMenu, setShowReactionsMenu] = useState(false);
|
||||
const [errorText, setErrorText] = useState<string>();
|
||||
|
||||
const isHandRaised = !!raisedHands[userId];
|
||||
const canReact = !reactions[userId];
|
||||
|
||||
useEffect(() => {
|
||||
// Clear whenever the reactions menu state changes.
|
||||
setErrorText(undefined);
|
||||
}, [showReactionsMenu]);
|
||||
|
||||
const canReact = !reactions[userId];
|
||||
|
||||
const sendRelation = useCallback(
|
||||
async (reaction: ReactionOption) => {
|
||||
try {
|
||||
const myMembership = memberships.find((m) => m.sender === userId);
|
||||
if (!myMembership?.eventId) {
|
||||
throw new Error("Cannot find own membership event");
|
||||
}
|
||||
const parentEventId = myMembership.eventId;
|
||||
setBusy(true);
|
||||
await client.sendEvent(
|
||||
rtcSession.room.roomId,
|
||||
ElementCallReactionEventType,
|
||||
{
|
||||
"m.relates_to": {
|
||||
rel_type: RelationType.Reference,
|
||||
event_id: parentEventId,
|
||||
},
|
||||
emoji: reaction.emoji,
|
||||
name: reaction.name,
|
||||
},
|
||||
);
|
||||
await sendReaction(reaction);
|
||||
setErrorText(undefined);
|
||||
setShowReactionsMenu(false);
|
||||
} catch (ex) {
|
||||
@@ -267,59 +194,25 @@ export function ReactionToggleButton({
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[memberships, client, userId, rtcSession],
|
||||
[sendReaction],
|
||||
);
|
||||
|
||||
const toggleRaisedHand = useCallback(() => {
|
||||
const raiseHand = async (): Promise<void> => {
|
||||
if (isHandRaised) {
|
||||
try {
|
||||
setBusy(true);
|
||||
await lowerHand();
|
||||
setShowReactionsMenu(false);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const myMembership = memberships.find((m) => m.sender === userId);
|
||||
if (!myMembership?.eventId) {
|
||||
throw new Error("Cannot find own membership event");
|
||||
}
|
||||
const parentEventId = myMembership.eventId;
|
||||
setBusy(true);
|
||||
const reaction = await client.sendEvent(
|
||||
rtcSession.room.roomId,
|
||||
EventType.Reaction,
|
||||
{
|
||||
"m.relates_to": {
|
||||
rel_type: RelationType.Annotation,
|
||||
event_id: parentEventId,
|
||||
key: "🖐️",
|
||||
},
|
||||
},
|
||||
);
|
||||
logger.debug("Sent raise hand event", reaction.event_id);
|
||||
setErrorText(undefined);
|
||||
setShowReactionsMenu(false);
|
||||
} catch (ex) {
|
||||
setErrorText(ex instanceof Error ? ex.message : "Unknown error");
|
||||
logger.error("Failed to raise hand", ex);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
const wrappedToggleRaisedHand = useCallback(() => {
|
||||
const toggleHand = async (): Promise<void> => {
|
||||
try {
|
||||
setBusy(true);
|
||||
await toggleRaisedHand();
|
||||
setShowReactionsMenu(false);
|
||||
} catch (ex) {
|
||||
setErrorText(ex instanceof Error ? ex.message : "Unknown error");
|
||||
logger.error("Failed to raise/lower hand", ex);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
void raiseHand();
|
||||
}, [
|
||||
client,
|
||||
isHandRaised,
|
||||
memberships,
|
||||
lowerHand,
|
||||
rtcSession.room.roomId,
|
||||
userId,
|
||||
]);
|
||||
void toggleHand();
|
||||
}, [toggleRaisedHand]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -335,14 +228,15 @@ export function ReactionToggleButton({
|
||||
title={t("action.pick_reaction")}
|
||||
hideHeader
|
||||
classNameModal={styles.reactionPopupMenuModal}
|
||||
className={styles.reactionPopupMenuRoot}
|
||||
onDismiss={() => setShowReactionsMenu(false)}
|
||||
>
|
||||
<ReactionPopupMenu
|
||||
errorText={errorText}
|
||||
isHandRaised={isHandRaised}
|
||||
canReact={canReact}
|
||||
canReact={!busy && canReact}
|
||||
sendReaction={(reaction) => void sendRelation(reaction)}
|
||||
toggleRaisedHand={toggleRaisedHand}
|
||||
toggleRaisedHand={wrappedToggleRaisedHand}
|
||||
/>
|
||||
</Modal>
|
||||
</>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`Can close search 1`] = `
|
||||
exports[`Can close reaction dialog 1`] = `
|
||||
<div
|
||||
aria-hidden="true"
|
||||
data-aria-hidden="true"
|
||||
@@ -9,8 +9,7 @@ exports[`Can close search 1`] = `
|
||||
aria-disabled="false"
|
||||
aria-expanded="true"
|
||||
aria-haspopup="true"
|
||||
aria-label="action.raise_hand_or_send_reaction"
|
||||
aria-labelledby=":rec:"
|
||||
aria-labelledby=":r9l:"
|
||||
class="_button_i91xf_17 _has-icon_i91xf_66 _icon-only_i91xf_59"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
@@ -26,26 +25,27 @@ exports[`Can close search 1`] = `
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M15.536 15.536a1 1 0 0 0-1.415-1.415 2.987 2.987 0 0 1-2.12.879 2.988 2.988 0 0 1-2.122-.879 1 1 0 1 0-1.414 1.415A4.987 4.987 0 0 0 12 17c1.38 0 2.632-.56 3.536-1.464ZM10 10.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0Zm5.5 1.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z"
|
||||
/>
|
||||
<path
|
||||
d="M22 12c0 5.523-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2s10 4.477 10 10Zm-2 0a8 8 0 1 0-16 0 8 8 0 0 0 16 0Z"
|
||||
clip-rule="evenodd"
|
||||
d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10Zm3.536-6.464a1 1 0 0 0-1.415-1.415A2.988 2.988 0 0 1 12 15a2.988 2.988 0 0 1-2.121-.879 1 1 0 1 0-1.414 1.415A4.987 4.987 0 0 0 12 17c1.38 0 2.632-.56 3.536-1.464ZM10 10.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0Zm5.5 1.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z"
|
||||
fill-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`Can close search with the escape key 1`] = `
|
||||
<div>
|
||||
exports[`Can fully expand emoji picker 1`] = `
|
||||
<div
|
||||
aria-hidden="true"
|
||||
data-aria-hidden="true"
|
||||
>
|
||||
<button
|
||||
aria-disabled="false"
|
||||
aria-expanded="false"
|
||||
aria-expanded="true"
|
||||
aria-haspopup="true"
|
||||
aria-label="action.raise_hand_or_send_reaction"
|
||||
aria-labelledby=":rhh:"
|
||||
aria-labelledby=":r6c:"
|
||||
class="_button_i91xf_17 _has-icon_i91xf_66 _icon-only_i91xf_59"
|
||||
data-kind="secondary"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@@ -59,10 +59,9 @@ exports[`Can close search with the escape key 1`] = `
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M15.536 15.536a1 1 0 0 0-1.415-1.415 2.987 2.987 0 0 1-2.12.879 2.988 2.988 0 0 1-2.122-.879 1 1 0 1 0-1.414 1.415A4.987 4.987 0 0 0 12 17c1.38 0 2.632-.56 3.536-1.464ZM10 10.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0Zm5.5 1.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z"
|
||||
/>
|
||||
<path
|
||||
d="M22 12c0 5.523-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2s10 4.477 10 10Zm-2 0a8 8 0 1 0-16 0 8 8 0 0 0 16 0Z"
|
||||
clip-rule="evenodd"
|
||||
d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10Zm3.536-6.464a1 1 0 0 0-1.415-1.415A2.988 2.988 0 0 1 12 15a2.988 2.988 0 0 1-2.121-.879 1 1 0 1 0-1.414 1.415A4.987 4.987 0 0 0 12 17c1.38 0 2.632-.56 3.536-1.464ZM10 10.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0Zm5.5 1.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z"
|
||||
fill-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
@@ -75,8 +74,7 @@ exports[`Can lower hand 1`] = `
|
||||
aria-disabled="false"
|
||||
aria-expanded="false"
|
||||
aria-haspopup="true"
|
||||
aria-label="action.raise_hand_or_send_reaction"
|
||||
aria-labelledby=":r3i:"
|
||||
aria-labelledby=":r36:"
|
||||
class="_button_i91xf_17 raisedButton _has-icon_i91xf_66 _icon-only_i91xf_59"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
@@ -108,7 +106,6 @@ exports[`Can open menu 1`] = `
|
||||
aria-disabled="false"
|
||||
aria-expanded="true"
|
||||
aria-haspopup="true"
|
||||
aria-label="action.raise_hand_or_send_reaction"
|
||||
aria-labelledby=":r0:"
|
||||
class="_button_i91xf_17 _has-icon_i91xf_66 _icon-only_i91xf_59"
|
||||
data-kind="primary"
|
||||
@@ -125,10 +122,9 @@ exports[`Can open menu 1`] = `
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M15.536 15.536a1 1 0 0 0-1.415-1.415 2.987 2.987 0 0 1-2.12.879 2.988 2.988 0 0 1-2.122-.879 1 1 0 1 0-1.414 1.415A4.987 4.987 0 0 0 12 17c1.38 0 2.632-.56 3.536-1.464ZM10 10.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0Zm5.5 1.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z"
|
||||
/>
|
||||
<path
|
||||
d="M22 12c0 5.523-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2s10 4.477 10 10Zm-2 0a8 8 0 1 0-16 0 8 8 0 0 0 16 0Z"
|
||||
clip-rule="evenodd"
|
||||
d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10Zm3.536-6.464a1 1 0 0 0-1.415-1.415A2.988 2.988 0 0 1 12 15a2.988 2.988 0 0 1-2.121-.879 1 1 0 1 0-1.414 1.415A4.987 4.987 0 0 0 12 17c1.38 0 2.632-.56 3.536-1.464ZM10 10.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0Zm5.5 1.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z"
|
||||
fill-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
@@ -141,8 +137,7 @@ exports[`Can raise hand 1`] = `
|
||||
aria-disabled="false"
|
||||
aria-expanded="false"
|
||||
aria-haspopup="true"
|
||||
aria-label="action.raise_hand_or_send_reaction"
|
||||
aria-labelledby=":r1p:"
|
||||
aria-labelledby=":r1j:"
|
||||
class="_button_i91xf_17 _has-icon_i91xf_66 _icon-only_i91xf_59"
|
||||
data-kind="secondary"
|
||||
data-size="lg"
|
||||
@@ -158,82 +153,9 @@ exports[`Can raise hand 1`] = `
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M15.536 15.536a1 1 0 0 0-1.415-1.415 2.987 2.987 0 0 1-2.12.879 2.988 2.988 0 0 1-2.122-.879 1 1 0 1 0-1.414 1.415A4.987 4.987 0 0 0 12 17c1.38 0 2.632-.56 3.536-1.464ZM10 10.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0Zm5.5 1.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z"
|
||||
/>
|
||||
<path
|
||||
d="M22 12c0 5.523-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2s10 4.477 10 10Zm-2 0a8 8 0 1 0-16 0 8 8 0 0 0 16 0Z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`Can search for and send emoji 1`] = `
|
||||
<div
|
||||
aria-hidden="true"
|
||||
data-aria-hidden="true"
|
||||
>
|
||||
<button
|
||||
aria-disabled="false"
|
||||
aria-expanded="true"
|
||||
aria-haspopup="true"
|
||||
aria-label="action.raise_hand_or_send_reaction"
|
||||
aria-labelledby=":r74:"
|
||||
class="_button_i91xf_17 _has-icon_i91xf_66 _icon-only_i91xf_59"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
fill="currentColor"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M15.536 15.536a1 1 0 0 0-1.415-1.415 2.987 2.987 0 0 1-2.12.879 2.988 2.988 0 0 1-2.122-.879 1 1 0 1 0-1.414 1.415A4.987 4.987 0 0 0 12 17c1.38 0 2.632-.56 3.536-1.464ZM10 10.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0Zm5.5 1.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z"
|
||||
/>
|
||||
<path
|
||||
d="M22 12c0 5.523-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2s10 4.477 10 10Zm-2 0a8 8 0 1 0-16 0 8 8 0 0 0 16 0Z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`Can search for and send emoji with the keyboard 1`] = `
|
||||
<div
|
||||
aria-hidden="true"
|
||||
data-aria-hidden="true"
|
||||
>
|
||||
<button
|
||||
aria-disabled="false"
|
||||
aria-expanded="true"
|
||||
aria-haspopup="true"
|
||||
aria-label="action.raise_hand_or_send_reaction"
|
||||
aria-labelledby=":ra3:"
|
||||
class="_button_i91xf_17 _has-icon_i91xf_66 _icon-only_i91xf_59"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
fill="currentColor"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M15.536 15.536a1 1 0 0 0-1.415-1.415 2.987 2.987 0 0 1-2.12.879 2.988 2.988 0 0 1-2.122-.879 1 1 0 1 0-1.414 1.415A4.987 4.987 0 0 0 12 17c1.38 0 2.632-.56 3.536-1.464ZM10 10.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0Zm5.5 1.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z"
|
||||
/>
|
||||
<path
|
||||
d="M22 12c0 5.523-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2s10 4.477 10 10Zm-2 0a8 8 0 1 0-16 0 8 8 0 0 0 16 0Z"
|
||||
clip-rule="evenodd"
|
||||
d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10Zm3.536-6.464a1 1 0 0 0-1.415-1.415A2.988 2.988 0 0 1 12 15a2.988 2.988 0 0 1-2.121-.879 1 1 0 1 0-1.414 1.415A4.987 4.987 0 0 0 12 17c1.38 0 2.632-.56 3.536-1.464ZM10 10.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0Zm5.5 1.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z"
|
||||
fill-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { merge } from "lodash";
|
||||
import { merge } from "lodash-es";
|
||||
|
||||
import { getUrlParams } from "../UrlParams";
|
||||
import {
|
||||
@@ -28,11 +28,11 @@ export class Config {
|
||||
const internalInstance = new Config();
|
||||
Config.internalInstance = internalInstance;
|
||||
|
||||
Config.internalInstance.initPromise = downloadConfig(
|
||||
"../config.json",
|
||||
).then((config) => {
|
||||
internalInstance.config = merge({}, DEFAULT_CONFIG, config);
|
||||
});
|
||||
Config.internalInstance.initPromise = downloadConfig("/config.json").then(
|
||||
(config) => {
|
||||
internalInstance.config = merge({}, DEFAULT_CONFIG, config);
|
||||
},
|
||||
);
|
||||
}
|
||||
return Config.internalInstance.initPromise;
|
||||
}
|
||||
@@ -74,11 +74,7 @@ async function downloadConfig(
|
||||
configJsonFilename: string,
|
||||
): Promise<ConfigOptions> {
|
||||
const url = new URL(configJsonFilename, window.location.href);
|
||||
url.searchParams.set("cachebuster", Date.now().toString());
|
||||
const res = await fetch(url, {
|
||||
cache: "no-cache",
|
||||
method: "GET",
|
||||
});
|
||||
const res = await fetch(url);
|
||||
|
||||
if (!res.ok || res.status === 404 || res.status === 0) {
|
||||
// Lack of a config isn't an error, we should just use the defaults.
|
||||
|
||||
105
src/index.css
105
src/index.css
@@ -5,14 +5,15 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
/* Inter unexpectedly contains various codepoints which collide with emoji, even
|
||||
when variation-16 is applied to request the emoji variant. From eyeballing
|
||||
the emoji picker, these are: 20e3, 23cf, 24c2, 25a0-25c1, 2665, 2764, 2b06, 2b1c.
|
||||
Therefore we define a unicode-range to load which excludes the glyphs
|
||||
(to avoid having to maintain a fork of Inter). */
|
||||
|
||||
@layer normalize, compound-legacy, compound;
|
||||
|
||||
@import url("@fontsource/inter/400.css");
|
||||
@import url("@fontsource/inter/500.css");
|
||||
@import url("@fontsource/inter/600.css");
|
||||
@import url("@fontsource/inter/700.css");
|
||||
@import url("@fontsource/inconsolata/400.css");
|
||||
@import url("@fontsource/inconsolata/700.css");
|
||||
|
||||
@import url("normalize.css/normalize.css") layer(normalize);
|
||||
@import url("@vector-im/compound-design-tokens/assets/web/css/compound-design-tokens.css")
|
||||
layer(compound);
|
||||
@@ -52,94 +53,6 @@ layer(compound);
|
||||
--stopgap-background-85: rgba(16, 19, 23, 0.85);
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
unicode-range: var(--inter-unicode-range);
|
||||
src:
|
||||
url("/fonts/Inter/Inter-Regular.woff2") format("woff2"),
|
||||
url("/fonts/Inter/Inter-Regular.woff") format("woff");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
unicode-range: var(--inter-unicode-range);
|
||||
src:
|
||||
url("/fonts/Inter/Inter-Italic.woff2") format("woff2"),
|
||||
url("/fonts/Inter/Inter-Italic.woff") format("woff");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
unicode-range: var(--inter-unicode-range);
|
||||
src:
|
||||
url("/fonts/Inter/Inter-Medium.woff2") format("woff2"),
|
||||
url("/fonts/Inter/Inter-Medium.woff") format("woff");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
font-style: italic;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
unicode-range: var(--inter-unicode-range);
|
||||
src:
|
||||
url("/fonts/Inter/Inter-MediumItalic.woff2") format("woff2"),
|
||||
url("/fonts/Inter/Inter-MediumItalic.woff") format("woff");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
unicode-range: var(--inter-unicode-range);
|
||||
src:
|
||||
url("/fonts/Inter/Inter-SemiBold.woff2") format("woff2"),
|
||||
url("/fonts/Inter/Inter-SemiBold.woff") format("woff");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
font-style: italic;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
unicode-range: var(--inter-unicode-range);
|
||||
src:
|
||||
url("/fonts/Inter/Inter-SemiBoldItalic.woff2") format("woff2"),
|
||||
url("/fonts/Inter/Inter-SemiBoldItalic.woff") format("woff");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
unicode-range: var(--inter-unicode-range);
|
||||
src:
|
||||
url("/fonts/Inter/Inter-Bold.woff2") format("woff2"),
|
||||
url("/fonts/Inter/Inter-Bold.woff") format("woff");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
font-style: italic;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
unicode-range: var(--inter-unicode-range);
|
||||
src:
|
||||
url("/fonts/Inter/Inter-BoldItalic.woff2") format("woff2"),
|
||||
url("/fonts/Inter/Inter-BoldItalic.woff") format("woff");
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--cpd-color-bg-canvas-default);
|
||||
background-size: calc(max(1440px, 100vw)) calc(max(800px, 100vh));
|
||||
@@ -185,10 +98,6 @@ body[data-platform="ios"] {
|
||||
--cpd-font-family-sans: -apple-system, BlinkMacSystemFont, "Inter", sans-serif;
|
||||
}
|
||||
|
||||
body[data-platform="desktop"] {
|
||||
--cpd-font-family-sans: "Inter", sans-serif;
|
||||
}
|
||||
|
||||
@layer compound-legacy {
|
||||
h1,
|
||||
h2,
|
||||
|
||||
@@ -9,9 +9,9 @@ import { expect, test } from "vitest";
|
||||
|
||||
import { Initializer } from "../src/initializer";
|
||||
|
||||
test("initBeforeReact sets font family from URL param", () => {
|
||||
test("initBeforeReact sets font family from URL param", async () => {
|
||||
window.location.hash = "#?font=DejaVu Sans";
|
||||
Initializer.initBeforeReact();
|
||||
await Initializer.initBeforeReact();
|
||||
expect(
|
||||
getComputedStyle(document.documentElement).getPropertyValue(
|
||||
"--font-family",
|
||||
@@ -19,9 +19,9 @@ test("initBeforeReact sets font family from URL param", () => {
|
||||
).toBe('"DejaVu Sans"');
|
||||
});
|
||||
|
||||
test("initBeforeReact sets font scale from URL param", () => {
|
||||
test("initBeforeReact sets font scale from URL param", async () => {
|
||||
window.location.hash = "#?fontScale=1.2";
|
||||
Initializer.initBeforeReact();
|
||||
await Initializer.initBeforeReact();
|
||||
expect(
|
||||
getComputedStyle(document.documentElement).getPropertyValue("--font-scale"),
|
||||
).toBe("1.2");
|
||||
|
||||
@@ -5,18 +5,85 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import i18n from "i18next";
|
||||
import i18n, {
|
||||
type BackendModule,
|
||||
type ReadCallback,
|
||||
type ResourceKey,
|
||||
} from "i18next";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
import LanguageDetector from "i18next-browser-languagedetector";
|
||||
import Backend from "i18next-http-backend";
|
||||
import * as Sentry from "@sentry/react";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { shouldPolyfill as shouldPolyfillSegmenter } from "@formatjs/intl-segmenter/should-polyfill";
|
||||
import { shouldPolyfill as shouldPolyfillDurationFormat } from "@formatjs/intl-durationformat/should-polyfill";
|
||||
|
||||
import { getUrlParams } from "./UrlParams";
|
||||
import { Config } from "./config/Config";
|
||||
import { ElementCallOpenTelemetry } from "./otel/otel";
|
||||
import { platform } from "./Platform";
|
||||
|
||||
// This generates a map of locale names to their URL (based on import.meta.url), which looks like this:
|
||||
// {
|
||||
// "../locales/en-GB/app.json": "/whatever/assets/root/locales/en-aabbcc.json",
|
||||
// ...
|
||||
// }
|
||||
const locales = import.meta.glob<string>("../locales/*/*.json", {
|
||||
query: "?url",
|
||||
import: "default",
|
||||
eager: true,
|
||||
});
|
||||
|
||||
const getLocaleUrl = (
|
||||
language: string,
|
||||
namespace: string,
|
||||
): string | undefined => locales[`../locales/${language}/${namespace}.json`];
|
||||
|
||||
const supportedLngs = [
|
||||
...new Set(
|
||||
Object.keys(locales).map((url) => {
|
||||
// The URLs are of the form ../locales/en-GB/app.json
|
||||
// This extracts the language code from the URL
|
||||
const lang = url.match(/\/([^/]+)\/[^/]+\.json$/)?.[1];
|
||||
if (!lang) {
|
||||
throw new Error(`Could not parse locale URL ${url}`);
|
||||
}
|
||||
return lang;
|
||||
}),
|
||||
),
|
||||
];
|
||||
|
||||
// A backend that fetches the locale files from the URLs generated by the glob above
|
||||
const Backend = {
|
||||
type: "backend",
|
||||
init(): void {},
|
||||
read(language: string, namespace: string, callback: ReadCallback): void {
|
||||
(async (): Promise<ResourceKey> => {
|
||||
const url = getLocaleUrl(language, namespace);
|
||||
if (!url) {
|
||||
throw new Error(
|
||||
`Namespace ${namespace} for locale ${language} not found`,
|
||||
);
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
credentials: "omit",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw Error(`Failed to fetch ${url}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
})().then(
|
||||
(data) => callback(null, data),
|
||||
(error) => callback(error, null),
|
||||
);
|
||||
},
|
||||
} satisfies BackendModule;
|
||||
|
||||
enum LoadState {
|
||||
None,
|
||||
Loading,
|
||||
@@ -41,10 +108,17 @@ export class Initializer {
|
||||
return Initializer.internalInstance?.isInitialized;
|
||||
}
|
||||
|
||||
public static initBeforeReact(): void {
|
||||
// this maybe also needs to return a promise in the future,
|
||||
// if we have to do async inits before showing the loading screen
|
||||
// but this should be avoided if possible
|
||||
public static async initBeforeReact(): Promise<void> {
|
||||
const polyfills: Promise<unknown>[] = [];
|
||||
if (shouldPolyfillSegmenter()) {
|
||||
polyfills.push(import("@formatjs/intl-segmenter/polyfill-force"));
|
||||
}
|
||||
|
||||
if (shouldPolyfillDurationFormat()) {
|
||||
polyfills.push(import("@formatjs/intl-durationformat/polyfill-force"));
|
||||
}
|
||||
|
||||
await Promise.all(polyfills);
|
||||
|
||||
//i18n
|
||||
const languageDetector = new LanguageDetector();
|
||||
@@ -54,7 +128,7 @@ export class Initializer {
|
||||
lookup: () => getUrlParams().lang ?? undefined,
|
||||
});
|
||||
|
||||
i18n
|
||||
await i18n
|
||||
.use(Backend)
|
||||
.use(languageDetector)
|
||||
.use(initReactI18next)
|
||||
@@ -65,6 +139,7 @@ export class Initializer {
|
||||
nsSeparator: false,
|
||||
pluralSeparator: "_",
|
||||
contextSeparator: "|",
|
||||
supportedLngs,
|
||||
interpolation: {
|
||||
escapeValue: false, // React has built-in XSS protections
|
||||
},
|
||||
@@ -74,9 +149,6 @@ export class Initializer {
|
||||
order: ["urlFragment", "navigator"],
|
||||
caches: [],
|
||||
},
|
||||
})
|
||||
.catch((e) => {
|
||||
logger.error("Failed to initialize i18n", e);
|
||||
});
|
||||
|
||||
// Custom Themeing
|
||||
|
||||
23
src/main.tsx
23
src/main.tsx
@@ -20,8 +20,6 @@ import {
|
||||
setLogExtension as setLKLogExtension,
|
||||
setLogLevel as setLKLogLevel,
|
||||
} from "livekit-client";
|
||||
import "@formatjs/intl-segmenter/polyfill";
|
||||
import "@formatjs/intl-durationformat/polyfill";
|
||||
|
||||
import { App } from "./App";
|
||||
import { init as initRageshake } from "./settings/rageshake";
|
||||
@@ -57,12 +55,17 @@ if (fatalError !== null) {
|
||||
throw fatalError; // Stop the app early
|
||||
}
|
||||
|
||||
Initializer.initBeforeReact();
|
||||
Initializer.initBeforeReact()
|
||||
.then(() => {
|
||||
const history = createBrowserHistory();
|
||||
|
||||
const history = createBrowserHistory();
|
||||
|
||||
root.render(
|
||||
<StrictMode>
|
||||
<App history={history} />
|
||||
</StrictMode>,
|
||||
);
|
||||
root.render(
|
||||
<StrictMode>
|
||||
<App history={history} />
|
||||
</StrictMode>,
|
||||
);
|
||||
})
|
||||
.catch((e) => {
|
||||
logger.error("Failed to initialize app", e);
|
||||
root.render(e.message);
|
||||
});
|
||||
|
||||
@@ -11,19 +11,12 @@ import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState,
|
||||
useMemo,
|
||||
} from "react";
|
||||
import { DurationFormat } from "@formatjs/intl-durationformat";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { ReactionIndicator } from "./ReactionIndicator";
|
||||
|
||||
const durationFormatter = new DurationFormat(undefined, {
|
||||
minutesDisplay: "always",
|
||||
secondsDisplay: "always",
|
||||
hoursDisplay: "auto",
|
||||
style: "digital",
|
||||
});
|
||||
|
||||
export function RaisedHandIndicator({
|
||||
raisedHandTime,
|
||||
miniature,
|
||||
@@ -38,6 +31,17 @@ export function RaisedHandIndicator({
|
||||
const { t } = useTranslation();
|
||||
const [raisedHandDuration, setRaisedHandDuration] = useState("");
|
||||
|
||||
const durationFormatter = useMemo(
|
||||
() =>
|
||||
new Intl.DurationFormat(undefined, {
|
||||
minutesDisplay: "always",
|
||||
secondsDisplay: "always",
|
||||
hoursDisplay: "auto",
|
||||
style: "digital",
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const clickCallback = useCallback<MouseEventHandler<HTMLButtonElement>>(
|
||||
(event) => {
|
||||
if (!onClick) {
|
||||
@@ -69,7 +73,7 @@ export function RaisedHandIndicator({
|
||||
calculateTime();
|
||||
const to = setInterval(calculateTime, 1000);
|
||||
return (): void => clearInterval(to);
|
||||
}, [setRaisedHandDuration, raisedHandTime, showTimer]);
|
||||
}, [setRaisedHandDuration, raisedHandTime, showTimer, durationFormatter]);
|
||||
|
||||
if (!raisedHandTime) {
|
||||
return;
|
||||
|
||||
@@ -1,19 +1,25 @@
|
||||
.reactionIndicatorWidget {
|
||||
display: flex;
|
||||
background-color: var(--cpd-color-bg-subtle-primary);
|
||||
/* background-color: var(--cpd-color-bg-subtle-primary); */
|
||||
border-radius: var(--cpd-radius-pill-effect);
|
||||
color: var(--cpd-color-icon-secondary);
|
||||
box-shadow: 0 0 var(--cpd-space-2x) #00000040;
|
||||
background: "ffffff40";
|
||||
backdrop-filter: blur(10px);
|
||||
outline: var(--cpd-border-width-1) solid var(--cpd-color-alpha-gray-400);
|
||||
outline-offset: calc(-1 * var(--cpd-border-width-1));
|
||||
}
|
||||
|
||||
.reactionIndicatorWidget > p {
|
||||
padding: none;
|
||||
margin-top: auto;
|
||||
margin-bottom: auto;
|
||||
width: 4em;
|
||||
width: 3em;
|
||||
}
|
||||
|
||||
.reactionIndicatorWidgetLarge > p {
|
||||
padding: var(--cpd-space-2x);
|
||||
padding-right: var(--cpd-space-4x);
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.reactionLarge {
|
||||
@@ -25,13 +31,13 @@
|
||||
.reaction {
|
||||
margin: var(--cpd-space-1x);
|
||||
color: var(--cpd-color-icon-secondary);
|
||||
background-color: var(--cpd-color-icon-secondary);
|
||||
/* background-color: var(--cpd-color-icon-secondary); */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: var(--cpd-radius-pill-effect);
|
||||
user-select: none;
|
||||
overflow: hidden;
|
||||
box-shadow: var(--small-drop-shadow);
|
||||
/* box-shadow: var(--small-drop-shadow); */
|
||||
box-sizing: border-box;
|
||||
max-inline-size: 100%;
|
||||
max-width: fit-content;
|
||||
|
||||
@@ -73,7 +73,9 @@ export const GenericReaction: ReactionOption = {
|
||||
},
|
||||
};
|
||||
|
||||
// The first 6 reactions are always visible.
|
||||
export const ReactionsRowSize = 5;
|
||||
|
||||
// The first {ReactionsRowSize} reactions are always visible.
|
||||
export const ReactionSet: ReactionOption[] = [
|
||||
{
|
||||
emoji: "👍",
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
/*
|
||||
Copyright 2022-2024 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MatrixError } from "matrix-js-sdk/src/matrix";
|
||||
import { Heading, Text } from "@vector-im/compound-web";
|
||||
|
||||
import { Link } from "../button/Link";
|
||||
import {
|
||||
useLoadGroupCall,
|
||||
GroupCallStatus,
|
||||
CallTerminatedMessage,
|
||||
} from "./useLoadGroupCall";
|
||||
import { ErrorView, FullScreenView } from "../FullScreenView";
|
||||
|
||||
interface Props {
|
||||
client: MatrixClient;
|
||||
roomIdOrAlias: string;
|
||||
viaServers: string[];
|
||||
children: (groupCallState: GroupCallStatus) => JSX.Element;
|
||||
}
|
||||
|
||||
export function GroupCallLoader({
|
||||
client,
|
||||
roomIdOrAlias,
|
||||
viaServers,
|
||||
children,
|
||||
}: Props): JSX.Element {
|
||||
const { t } = useTranslation();
|
||||
const groupCallState = useLoadGroupCall(client, roomIdOrAlias, viaServers);
|
||||
|
||||
switch (groupCallState.kind) {
|
||||
case "loaded":
|
||||
case "waitForInvite":
|
||||
case "canKnock":
|
||||
return children(groupCallState);
|
||||
case "loading":
|
||||
return (
|
||||
<FullScreenView>
|
||||
<h1>{t("common.loading")}</h1>
|
||||
</FullScreenView>
|
||||
);
|
||||
case "failed":
|
||||
if ((groupCallState.error as MatrixError).errcode === "M_NOT_FOUND") {
|
||||
return (
|
||||
<FullScreenView>
|
||||
<Heading>{t("group_call_loader.failed_heading")}</Heading>
|
||||
<Text>{t("group_call_loader.failed_text")}</Text>
|
||||
{/* XXX: A 'create it for me' button would be the obvious UX here. Two screens already have
|
||||
dupes of this flow, let's make a common component and put it here. */}
|
||||
<Link to="/">{t("common.home")}</Link>
|
||||
</FullScreenView>
|
||||
);
|
||||
} else if (groupCallState.error instanceof CallTerminatedMessage) {
|
||||
return (
|
||||
<FullScreenView>
|
||||
<Heading>{groupCallState.error.message}</Heading>
|
||||
<Text>{groupCallState.error.messageBody}</Text>
|
||||
{groupCallState.error.reason && (
|
||||
<>
|
||||
{t("group_call_loader.reason")}:
|
||||
<Text size="sm">"{groupCallState.error.reason}"</Text>
|
||||
</>
|
||||
)}
|
||||
<Link to="/">{t("common.home")}</Link>
|
||||
</FullScreenView>
|
||||
);
|
||||
} else {
|
||||
return <ErrorView error={groupCallState.error} />;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -177,29 +177,37 @@ export const GroupCallView: FC<Props> = ({
|
||||
}
|
||||
};
|
||||
|
||||
if (widget && preload && skipLobby) {
|
||||
// In preload mode without lobby we wait for a join action before entering
|
||||
const onJoin = (ev: CustomEvent<IWidgetApiRequest>): void => {
|
||||
(async (): Promise<void> => {
|
||||
await defaultDeviceSetup(ev.detail.data as unknown as JoinCallData);
|
||||
await enterRTCSession(rtcSession, perParticipantE2EE);
|
||||
widget!.api.transport.reply(ev.detail, {});
|
||||
})().catch((e) => {
|
||||
logger.error("Error joining RTC session", e);
|
||||
});
|
||||
};
|
||||
widget.lazyActions.on(ElementWidgetActions.JoinCall, onJoin);
|
||||
return (): void => {
|
||||
widget!.lazyActions.off(ElementWidgetActions.JoinCall, onJoin);
|
||||
};
|
||||
} else if (widget && !preload && skipLobby) {
|
||||
// No lobby and no preload: we enter the rtc session right away
|
||||
(async (): Promise<void> => {
|
||||
await defaultDeviceSetup({ audioInput: null, videoInput: null });
|
||||
await enterRTCSession(rtcSession, perParticipantE2EE);
|
||||
})().catch((e) => {
|
||||
logger.error("Error joining RTC session", e);
|
||||
});
|
||||
if (skipLobby) {
|
||||
if (widget) {
|
||||
if (preload) {
|
||||
// In preload mode without lobby we wait for a join action before entering
|
||||
const onJoin = (ev: CustomEvent<IWidgetApiRequest>): void => {
|
||||
(async (): Promise<void> => {
|
||||
await defaultDeviceSetup(
|
||||
ev.detail.data as unknown as JoinCallData,
|
||||
);
|
||||
await enterRTCSession(rtcSession, perParticipantE2EE);
|
||||
widget!.api.transport.reply(ev.detail, {});
|
||||
})().catch((e) => {
|
||||
logger.error("Error joining RTC session", e);
|
||||
});
|
||||
};
|
||||
widget.lazyActions.on(ElementWidgetActions.JoinCall, onJoin);
|
||||
return (): void => {
|
||||
widget!.lazyActions.off(ElementWidgetActions.JoinCall, onJoin);
|
||||
};
|
||||
} else {
|
||||
// No lobby and no preload: we enter the rtc session right away
|
||||
(async (): Promise<void> => {
|
||||
await defaultDeviceSetup({ audioInput: null, videoInput: null });
|
||||
await enterRTCSession(rtcSession, perParticipantE2EE);
|
||||
})().catch((e) => {
|
||||
logger.error("Error joining RTC session", e);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
void enterRTCSession(rtcSession, perParticipantE2EE);
|
||||
}
|
||||
}
|
||||
}, [rtcSession, preload, skipLobby, perParticipantE2EE]);
|
||||
|
||||
|
||||
@@ -183,7 +183,8 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
onShareClick,
|
||||
}) => {
|
||||
const [soundEffectVolume] = useSetting(soundEffectVolumeSetting);
|
||||
const { supportsReactions, raisedHands } = useReactions();
|
||||
const { supportsReactions, raisedHands, sendReaction, toggleRaisedHand } =
|
||||
useReactions();
|
||||
const raisedHandCount = useMemo(
|
||||
() => Object.keys(raisedHands).length,
|
||||
[raisedHands],
|
||||
@@ -227,6 +228,8 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
toggleMicrophone,
|
||||
toggleCamera,
|
||||
(muted) => muteStates.audio.setEnabled?.(!muted),
|
||||
(reaction) => void sendReaction(reaction),
|
||||
() => void toggleRaisedHand(),
|
||||
);
|
||||
|
||||
const windowMode = useObservableEagerState(vm.windowMode);
|
||||
@@ -572,8 +575,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
<ReactionToggleButton
|
||||
key="raise_hand"
|
||||
className={styles.raiseHand}
|
||||
client={client}
|
||||
rtcSession={rtcSession}
|
||||
userId={client.getUserId()!}
|
||||
onTouchEnd={onControlsTouchEnd}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -5,15 +5,16 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { FC, useEffect, useState, useCallback, ReactNode } from "react";
|
||||
import { FC, useEffect, useState, ReactNode, useRef } from "react";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CheckIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import { MatrixError } from "matrix-js-sdk/src/http-api";
|
||||
import { Heading, Text } from "@vector-im/compound-web";
|
||||
|
||||
import { useClientLegacy } from "../ClientContext";
|
||||
import { ErrorView, LoadingView } from "../FullScreenView";
|
||||
import { ErrorView, FullScreenView, LoadingView } from "../FullScreenView";
|
||||
import { RoomAuthView } from "./RoomAuthView";
|
||||
import { GroupCallLoader } from "./GroupCallLoader";
|
||||
import { GroupCallView } from "./GroupCallView";
|
||||
import { useRoomIdentifier, useUrlParams } from "../UrlParams";
|
||||
import { useRegisterPasswordlessUser } from "../auth/useRegisterPasswordlessUser";
|
||||
@@ -21,13 +22,14 @@ import { HomePage } from "../home/HomePage";
|
||||
import { platform } from "../Platform";
|
||||
import { AppSelectionModal } from "./AppSelectionModal";
|
||||
import { widget } from "../widget";
|
||||
import { GroupCallStatus } from "./useLoadGroupCall";
|
||||
import { CallTerminatedMessage, useLoadGroupCall } from "./useLoadGroupCall";
|
||||
import { LobbyView } from "./LobbyView";
|
||||
import { E2eeType } from "../e2ee/e2eeType";
|
||||
import { useProfile } from "../profile/useProfile";
|
||||
import { useMuteStates } from "./MuteStates";
|
||||
import { useOptInAnalytics } from "../settings/settings";
|
||||
import { Config } from "../config/Config";
|
||||
import { Link } from "../button/Link";
|
||||
|
||||
export const RoomPage: FC = () => {
|
||||
const {
|
||||
@@ -53,6 +55,7 @@ export const RoomPage: FC = () => {
|
||||
useClientLegacy();
|
||||
const { avatarUrl, displayName: userDisplayName } = useProfile(client);
|
||||
|
||||
const groupCallState = useLoadGroupCall(client, roomIdOrAlias, viaServers);
|
||||
const muteStates = useMuteStates();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -82,82 +85,112 @@ export const RoomPage: FC = () => {
|
||||
if (optInAnalytics === null && setOptInAnalytics) setOptInAnalytics(true);
|
||||
}, [optInAnalytics, setOptInAnalytics]);
|
||||
|
||||
const groupCallView = useCallback(
|
||||
(groupCallState: GroupCallStatus): JSX.Element => {
|
||||
switch (groupCallState.kind) {
|
||||
case "loaded":
|
||||
return (
|
||||
<GroupCallView
|
||||
client={client!}
|
||||
rtcSession={groupCallState.rtcSession}
|
||||
isPasswordlessUser={passwordlessUser}
|
||||
confineToRoom={confineToRoom}
|
||||
preload={preload}
|
||||
skipLobby={skipLobby}
|
||||
hideHeader={hideHeader}
|
||||
muteStates={muteStates}
|
||||
/>
|
||||
const wasInWaitForInviteState = useRef<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (groupCallState.kind === "loaded" && wasInWaitForInviteState.current) {
|
||||
logger.log("Play join sound 'Not yet implemented'");
|
||||
}
|
||||
}, [groupCallState.kind]);
|
||||
|
||||
const groupCallView = (): JSX.Element => {
|
||||
switch (groupCallState.kind) {
|
||||
case "loaded":
|
||||
return (
|
||||
<GroupCallView
|
||||
client={client!}
|
||||
rtcSession={groupCallState.rtcSession}
|
||||
isPasswordlessUser={passwordlessUser}
|
||||
confineToRoom={confineToRoom}
|
||||
preload={preload}
|
||||
skipLobby={skipLobby || wasInWaitForInviteState.current}
|
||||
hideHeader={hideHeader}
|
||||
muteStates={muteStates}
|
||||
/>
|
||||
);
|
||||
case "waitForInvite":
|
||||
case "canKnock": {
|
||||
wasInWaitForInviteState.current =
|
||||
wasInWaitForInviteState.current ||
|
||||
groupCallState.kind === "waitForInvite";
|
||||
const knock =
|
||||
groupCallState.kind === "canKnock" ? groupCallState.knock : null;
|
||||
const label: string | JSX.Element =
|
||||
groupCallState.kind === "canKnock" ? (
|
||||
t("lobby.ask_to_join")
|
||||
) : (
|
||||
<>
|
||||
{t("lobby.waiting_for_invite")}
|
||||
<CheckIcon />
|
||||
</>
|
||||
);
|
||||
case "waitForInvite":
|
||||
case "canKnock": {
|
||||
const knock =
|
||||
groupCallState.kind === "canKnock" ? groupCallState.knock : null;
|
||||
const label: string | JSX.Element =
|
||||
groupCallState.kind === "canKnock" ? (
|
||||
t("lobby.ask_to_join")
|
||||
) : (
|
||||
<>
|
||||
{t("lobby.waiting_for_invite")}
|
||||
<CheckIcon />
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<LobbyView
|
||||
client={client!}
|
||||
matrixInfo={{
|
||||
userId: client!.getUserId() ?? "",
|
||||
displayName: userDisplayName ?? "",
|
||||
avatarUrl: avatarUrl ?? "",
|
||||
roomAlias: null,
|
||||
roomId: groupCallState.roomSummary.room_id,
|
||||
roomName: groupCallState.roomSummary.name ?? "",
|
||||
roomAvatar: groupCallState.roomSummary.avatar_url ?? null,
|
||||
e2eeSystem: {
|
||||
kind: groupCallState.roomSummary[
|
||||
"im.nheko.summary.encryption"
|
||||
]
|
||||
? E2eeType.PER_PARTICIPANT
|
||||
: E2eeType.NONE,
|
||||
},
|
||||
}}
|
||||
onEnter={(): void => knock?.()}
|
||||
enterLabel={label}
|
||||
waitingForInvite={groupCallState.kind === "waitForInvite"}
|
||||
confineToRoom={confineToRoom}
|
||||
hideHeader={hideHeader}
|
||||
participantCount={null}
|
||||
muteStates={muteStates}
|
||||
onShareClick={null}
|
||||
/>
|
||||
);
|
||||
}
|
||||
default:
|
||||
return <> </>;
|
||||
return (
|
||||
<LobbyView
|
||||
client={client!}
|
||||
matrixInfo={{
|
||||
userId: client!.getUserId() ?? "",
|
||||
displayName: userDisplayName ?? "",
|
||||
avatarUrl: avatarUrl ?? "",
|
||||
roomAlias: null,
|
||||
roomId: groupCallState.roomSummary.room_id,
|
||||
roomName: groupCallState.roomSummary.name ?? "",
|
||||
roomAvatar: groupCallState.roomSummary.avatar_url ?? null,
|
||||
e2eeSystem: {
|
||||
kind: groupCallState.roomSummary["im.nheko.summary.encryption"]
|
||||
? E2eeType.PER_PARTICIPANT
|
||||
: E2eeType.NONE,
|
||||
},
|
||||
}}
|
||||
onEnter={(): void => knock?.()}
|
||||
enterLabel={label}
|
||||
waitingForInvite={groupCallState.kind === "waitForInvite"}
|
||||
confineToRoom={confineToRoom}
|
||||
hideHeader={hideHeader}
|
||||
participantCount={null}
|
||||
muteStates={muteStates}
|
||||
onShareClick={null}
|
||||
/>
|
||||
);
|
||||
}
|
||||
},
|
||||
[
|
||||
client,
|
||||
passwordlessUser,
|
||||
confineToRoom,
|
||||
preload,
|
||||
skipLobby,
|
||||
hideHeader,
|
||||
muteStates,
|
||||
t,
|
||||
userDisplayName,
|
||||
avatarUrl,
|
||||
],
|
||||
);
|
||||
case "loading":
|
||||
return (
|
||||
<FullScreenView>
|
||||
<h1>{t("common.loading")}</h1>
|
||||
</FullScreenView>
|
||||
);
|
||||
case "failed":
|
||||
wasInWaitForInviteState.current = false;
|
||||
if ((groupCallState.error as MatrixError).errcode === "M_NOT_FOUND") {
|
||||
return (
|
||||
<FullScreenView>
|
||||
<Heading>{t("group_call_loader.failed_heading")}</Heading>
|
||||
<Text>{t("group_call_loader.failed_text")}</Text>
|
||||
{/* XXX: A 'create it for me' button would be the obvious UX here. Two screens already have
|
||||
dupes of this flow, let's make a common component and put it here. */}
|
||||
<Link to="/">{t("common.home")}</Link>
|
||||
</FullScreenView>
|
||||
);
|
||||
} else if (groupCallState.error instanceof CallTerminatedMessage) {
|
||||
return (
|
||||
<FullScreenView>
|
||||
<Heading>{groupCallState.error.message}</Heading>
|
||||
<Text>{groupCallState.error.messageBody}</Text>
|
||||
{groupCallState.error.reason && (
|
||||
<>
|
||||
{t("group_call_loader.reason")}:
|
||||
<Text size="sm">"{groupCallState.error.reason}"</Text>
|
||||
</>
|
||||
)}
|
||||
<Link to="/">{t("common.home")}</Link>
|
||||
</FullScreenView>
|
||||
);
|
||||
} else {
|
||||
return <ErrorView error={groupCallState.error} />;
|
||||
}
|
||||
default:
|
||||
return <> </>;
|
||||
}
|
||||
};
|
||||
|
||||
let content: ReactNode;
|
||||
if (loading || isRegistering) {
|
||||
@@ -170,15 +203,7 @@ export const RoomPage: FC = () => {
|
||||
// TODO: This doesn't belong here, the app routes need to be reworked
|
||||
content = <HomePage />;
|
||||
} else {
|
||||
content = (
|
||||
<GroupCallLoader
|
||||
client={client}
|
||||
roomIdOrAlias={roomIdOrAlias}
|
||||
viaServers={viaServers}
|
||||
>
|
||||
{groupCallView}
|
||||
</GroupCallLoader>
|
||||
);
|
||||
content = groupCallView();
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -117,8 +117,8 @@ export class CallTerminatedMessage extends Error {
|
||||
}
|
||||
|
||||
export const useLoadGroupCall = (
|
||||
client: MatrixClient,
|
||||
roomIdOrAlias: string,
|
||||
client: MatrixClient | undefined,
|
||||
roomIdOrAlias: string | null,
|
||||
viaServers: string[],
|
||||
): GroupCallStatus => {
|
||||
const [state, setState] = useState<GroupCallStatus>({ kind: "loading" });
|
||||
@@ -159,6 +159,9 @@ export const useLoadGroupCall = (
|
||||
?.getContent().reason;
|
||||
|
||||
useEffect(() => {
|
||||
if (!client || !roomIdOrAlias) {
|
||||
return;
|
||||
}
|
||||
const getRoomByAlias = async (alias: string): Promise<Room> => {
|
||||
// We lowercase the localpart when we create the room, so we must lowercase
|
||||
// it here too (we just do the whole alias). We can't do the same to room IDs
|
||||
|
||||
@@ -8,7 +8,8 @@ Please see LICENSE in the repository root for full details.
|
||||
.content {
|
||||
width: 100%;
|
||||
max-width: 350px;
|
||||
align-self: center;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.avatarFieldRow {
|
||||
|
||||
@@ -28,7 +28,7 @@ Please see LICENSE in the repository root for full details.
|
||||
// purge on startup to prevent logs from accumulating.
|
||||
|
||||
import EventEmitter from "events";
|
||||
import { throttle } from "lodash";
|
||||
import { throttle } from "lodash-es";
|
||||
import { Logger, logger } from "matrix-js-sdk/src/logger";
|
||||
import { randomString } from "matrix-js-sdk/src/randomstring";
|
||||
import loglevel, { LoggingMethod } from "loglevel";
|
||||
|
||||
@@ -102,7 +102,7 @@ export const playReactionsSound = new Setting<boolean>(
|
||||
|
||||
export const soundEffectVolumeSetting = new Setting<number>(
|
||||
"sound-effect-volume",
|
||||
1,
|
||||
0.5,
|
||||
);
|
||||
|
||||
export const alwaysShowSelf = new Setting<boolean>("always-show-self", true);
|
||||
|
||||
@@ -6,9 +6,6 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { ComponentProps, useCallback, useEffect, useState } from "react";
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
import pako from "pako";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import {
|
||||
ClientEvent,
|
||||
@@ -23,11 +20,14 @@ import { Config } from "../config/Config";
|
||||
import { ElementCallOpenTelemetry } from "../otel/otel";
|
||||
import { RageshakeRequestModal } from "../room/RageshakeRequestModal";
|
||||
|
||||
const gzip = (text: string): Blob => {
|
||||
const gzip = async (text: string): Promise<Blob> => {
|
||||
// pako is relatively large (200KB), so we only import it when needed
|
||||
const { gzip: pakoGzip } = await import("pako");
|
||||
|
||||
// encode as UTF-8
|
||||
const buf = new TextEncoder().encode(text);
|
||||
// compress
|
||||
return new Blob([pako.gzip(buf)]);
|
||||
return new Blob([pakoGzip(buf)]);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -253,12 +253,14 @@ export function useSubmitRageshake(): {
|
||||
const logs = await getLogsForReport();
|
||||
|
||||
for (const entry of logs) {
|
||||
body.append("compressed-log", gzip(entry.lines), entry.id);
|
||||
body.append("compressed-log", await gzip(entry.lines), entry.id);
|
||||
}
|
||||
|
||||
body.append(
|
||||
"file",
|
||||
gzip(ElementCallOpenTelemetry.instance.rageshakeProcessor!.dump()),
|
||||
await gzip(
|
||||
ElementCallOpenTelemetry.instance.rageshakeProcessor!.dump(),
|
||||
),
|
||||
"traces.json.gz",
|
||||
);
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -23,7 +23,7 @@ import {
|
||||
RemoteParticipant,
|
||||
} from "livekit-client";
|
||||
import * as ComponentsCore from "@livekit/components-core";
|
||||
import { isEqual } from "lodash";
|
||||
import { isEqual } from "lodash-es";
|
||||
|
||||
import { CallViewModel, Layout } from "./CallViewModel";
|
||||
import {
|
||||
@@ -231,30 +231,33 @@ function withCallViewModel(
|
||||
test("participants are retained during a focus switch", () => {
|
||||
withTestScheduler(({ cold, expectObservable }) => {
|
||||
// Participants disappear on frame 2 and come back on frame 3
|
||||
const participantMarbles = "a-ba";
|
||||
const participantInputMarbles = "a-ba";
|
||||
// Start switching focus on frame 1 and reconnect on frame 3
|
||||
const connectionMarbles = " cs-c";
|
||||
const connectionInputMarbles = " cs-c";
|
||||
// The visible participants should remain the same throughout the switch
|
||||
const layoutMarbles = " a";
|
||||
const expectedLayoutMarbles = " a";
|
||||
|
||||
withCallViewModel(
|
||||
cold(participantMarbles, {
|
||||
cold(participantInputMarbles, {
|
||||
a: [aliceParticipant, bobParticipant],
|
||||
b: [],
|
||||
}),
|
||||
cold(connectionMarbles, {
|
||||
cold(connectionInputMarbles, {
|
||||
c: ConnectionState.Connected,
|
||||
s: ECAddonConnectionState.ECSwitchingFocus,
|
||||
}),
|
||||
new Map(),
|
||||
(vm) => {
|
||||
expectObservable(summarizeLayout(vm.layout)).toBe(layoutMarbles, {
|
||||
a: {
|
||||
type: "grid",
|
||||
spotlight: undefined,
|
||||
grid: ["local:0", `${aliceId}:0`, `${bobId}:0`],
|
||||
expectObservable(summarizeLayout(vm.layout)).toBe(
|
||||
expectedLayoutMarbles,
|
||||
{
|
||||
a: {
|
||||
type: "grid",
|
||||
spotlight: undefined,
|
||||
grid: ["local:0", `${aliceId}:0`, `${bobId}:0`],
|
||||
},
|
||||
},
|
||||
});
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -264,17 +267,17 @@ test("screen sharing activates spotlight layout", () => {
|
||||
withTestScheduler(({ cold, schedule, expectObservable }) => {
|
||||
// Start with no screen shares, then have Alice and Bob share their screens,
|
||||
// then return to no screen shares, then have just Alice share for a bit
|
||||
const participantMarbles = " abcda-ba";
|
||||
const participantInputMarbles = " abcda-ba";
|
||||
// While there are no screen shares, switch to spotlight manually, and then
|
||||
// switch back to grid at the end
|
||||
const modeMarbles = " -----s--g";
|
||||
const modeInputMarbles = " -----s--g";
|
||||
// We should automatically enter spotlight for the first round of screen
|
||||
// sharing, then return to grid, then manually go into spotlight, and
|
||||
// remain in spotlight until we manually go back to grid
|
||||
const layoutMarbles = " abcdaefeg";
|
||||
const showSpeakingMarbles = "y----nyny";
|
||||
const expectedLayoutMarbles = " abcdaefeg";
|
||||
const expectedShowSpeakingMarbles = "y----nyny";
|
||||
withCallViewModel(
|
||||
cold(participantMarbles, {
|
||||
cold(participantInputMarbles, {
|
||||
a: [aliceParticipant, bobParticipant],
|
||||
b: [aliceSharingScreen, bobParticipant],
|
||||
c: [aliceSharingScreen, bobSharingScreen],
|
||||
@@ -283,52 +286,61 @@ test("screen sharing activates spotlight layout", () => {
|
||||
of(ConnectionState.Connected),
|
||||
new Map(),
|
||||
(vm) => {
|
||||
schedule(modeMarbles, {
|
||||
schedule(modeInputMarbles, {
|
||||
s: () => vm.setGridMode("spotlight"),
|
||||
g: () => vm.setGridMode("grid"),
|
||||
});
|
||||
|
||||
expectObservable(summarizeLayout(vm.layout)).toBe(layoutMarbles, {
|
||||
a: {
|
||||
type: "grid",
|
||||
spotlight: undefined,
|
||||
grid: ["local:0", `${aliceId}:0`, `${bobId}:0`],
|
||||
expectObservable(summarizeLayout(vm.layout)).toBe(
|
||||
expectedLayoutMarbles,
|
||||
{
|
||||
a: {
|
||||
type: "grid",
|
||||
spotlight: undefined,
|
||||
grid: ["local:0", `${aliceId}:0`, `${bobId}:0`],
|
||||
},
|
||||
b: {
|
||||
type: "spotlight-landscape",
|
||||
spotlight: [`${aliceId}:0:screen-share`],
|
||||
grid: ["local:0", `${aliceId}:0`, `${bobId}:0`],
|
||||
},
|
||||
c: {
|
||||
type: "spotlight-landscape",
|
||||
spotlight: [
|
||||
`${aliceId}:0:screen-share`,
|
||||
`${bobId}:0:screen-share`,
|
||||
],
|
||||
grid: ["local:0", `${aliceId}:0`, `${bobId}:0`],
|
||||
},
|
||||
d: {
|
||||
type: "spotlight-landscape",
|
||||
spotlight: [`${bobId}:0:screen-share`],
|
||||
grid: ["local:0", `${aliceId}:0`, `${bobId}:0`],
|
||||
},
|
||||
e: {
|
||||
type: "spotlight-landscape",
|
||||
spotlight: [`${aliceId}:0`],
|
||||
grid: ["local:0", `${bobId}:0`],
|
||||
},
|
||||
f: {
|
||||
type: "spotlight-landscape",
|
||||
spotlight: [`${aliceId}:0:screen-share`],
|
||||
grid: ["local:0", `${bobId}:0`, `${aliceId}:0`],
|
||||
},
|
||||
g: {
|
||||
type: "grid",
|
||||
spotlight: undefined,
|
||||
grid: ["local:0", `${bobId}:0`, `${aliceId}:0`],
|
||||
},
|
||||
},
|
||||
b: {
|
||||
type: "spotlight-landscape",
|
||||
spotlight: [`${aliceId}:0:screen-share`],
|
||||
grid: ["local:0", `${aliceId}:0`, `${bobId}:0`],
|
||||
);
|
||||
expectObservable(vm.showSpeakingIndicators).toBe(
|
||||
expectedShowSpeakingMarbles,
|
||||
{
|
||||
y: true,
|
||||
n: false,
|
||||
},
|
||||
c: {
|
||||
type: "spotlight-landscape",
|
||||
spotlight: [`${aliceId}:0:screen-share`, `${bobId}:0:screen-share`],
|
||||
grid: ["local:0", `${aliceId}:0`, `${bobId}:0`],
|
||||
},
|
||||
d: {
|
||||
type: "spotlight-landscape",
|
||||
spotlight: [`${bobId}:0:screen-share`],
|
||||
grid: ["local:0", `${aliceId}:0`, `${bobId}:0`],
|
||||
},
|
||||
e: {
|
||||
type: "spotlight-landscape",
|
||||
spotlight: [`${aliceId}:0`],
|
||||
grid: ["local:0", `${bobId}:0`],
|
||||
},
|
||||
f: {
|
||||
type: "spotlight-landscape",
|
||||
spotlight: [`${aliceId}:0:screen-share`],
|
||||
grid: ["local:0", `${bobId}:0`, `${aliceId}:0`],
|
||||
},
|
||||
g: {
|
||||
type: "grid",
|
||||
spotlight: undefined,
|
||||
grid: ["local:0", `${bobId}:0`, `${aliceId}:0`],
|
||||
},
|
||||
});
|
||||
expectObservable(vm.showSpeakingIndicators).toBe(showSpeakingMarbles, {
|
||||
y: true,
|
||||
n: false,
|
||||
});
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -336,27 +348,27 @@ test("screen sharing activates spotlight layout", () => {
|
||||
|
||||
test("participants stay in the same order unless to appear/disappear", () => {
|
||||
withTestScheduler(({ cold, schedule, expectObservable }) => {
|
||||
const modeMarbles = "a";
|
||||
const modeInputMarbles = " a";
|
||||
// First Bob speaks, then Dave, then Alice
|
||||
const aSpeakingMarbles = "n- 1998ms - 1999ms y";
|
||||
const bSpeakingMarbles = "ny 1998ms n 1999ms ";
|
||||
const dSpeakingMarbles = "n- 1998ms y 1999ms n";
|
||||
const aSpeakingInputMarbles = "n- 1998ms - 1999ms y";
|
||||
const bSpeakingInputMarbles = "ny 1998ms n 1999ms ";
|
||||
const dSpeakingInputMarbles = "n- 1998ms y 1999ms n";
|
||||
// Nothing should change when Bob speaks, because Bob is already on screen.
|
||||
// When Dave speaks he should switch with Alice because she's the one who
|
||||
// hasn't spoken at all. Then when Alice speaks, she should return to her
|
||||
// place at the top.
|
||||
const layoutMarbles = " a 1999ms b 1999ms a 57999ms c 1999ms a";
|
||||
const expectedLayoutMarbles = "a 1999ms b 1999ms a 57999ms c 1999ms a";
|
||||
|
||||
withCallViewModel(
|
||||
of([aliceParticipant, bobParticipant, daveParticipant]),
|
||||
of(ConnectionState.Connected),
|
||||
new Map([
|
||||
[aliceParticipant, cold(aSpeakingMarbles, { y: true, n: false })],
|
||||
[bobParticipant, cold(bSpeakingMarbles, { y: true, n: false })],
|
||||
[daveParticipant, cold(dSpeakingMarbles, { y: true, n: false })],
|
||||
[aliceParticipant, cold(aSpeakingInputMarbles, { y: true, n: false })],
|
||||
[bobParticipant, cold(bSpeakingInputMarbles, { y: true, n: false })],
|
||||
[daveParticipant, cold(dSpeakingInputMarbles, { y: true, n: false })],
|
||||
]),
|
||||
(vm) => {
|
||||
schedule(modeMarbles, {
|
||||
schedule(modeInputMarbles, {
|
||||
a: () => {
|
||||
// We imagine that only three tiles (the first three) will be visible
|
||||
// on screen at a time
|
||||
@@ -369,23 +381,26 @@ test("participants stay in the same order unless to appear/disappear", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expectObservable(summarizeLayout(vm.layout)).toBe(layoutMarbles, {
|
||||
a: {
|
||||
type: "grid",
|
||||
spotlight: undefined,
|
||||
grid: ["local:0", `${aliceId}:0`, `${bobId}:0`, `${daveId}:0`],
|
||||
expectObservable(summarizeLayout(vm.layout)).toBe(
|
||||
expectedLayoutMarbles,
|
||||
{
|
||||
a: {
|
||||
type: "grid",
|
||||
spotlight: undefined,
|
||||
grid: ["local:0", `${aliceId}:0`, `${bobId}:0`, `${daveId}:0`],
|
||||
},
|
||||
b: {
|
||||
type: "grid",
|
||||
spotlight: undefined,
|
||||
grid: ["local:0", `${daveId}:0`, `${bobId}:0`, `${aliceId}:0`],
|
||||
},
|
||||
c: {
|
||||
type: "grid",
|
||||
spotlight: undefined,
|
||||
grid: ["local:0", `${aliceId}:0`, `${daveId}:0`, `${bobId}:0`],
|
||||
},
|
||||
},
|
||||
b: {
|
||||
type: "grid",
|
||||
spotlight: undefined,
|
||||
grid: ["local:0", `${daveId}:0`, `${bobId}:0`, `${aliceId}:0`],
|
||||
},
|
||||
c: {
|
||||
type: "grid",
|
||||
spotlight: undefined,
|
||||
grid: ["local:0", `${aliceId}:0`, `${daveId}:0`, `${bobId}:0`],
|
||||
},
|
||||
});
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -394,50 +409,53 @@ test("participants stay in the same order unless to appear/disappear", () => {
|
||||
test("spotlight speakers swap places", () => {
|
||||
withTestScheduler(({ cold, schedule, expectObservable }) => {
|
||||
// Go immediately into spotlight mode for the test
|
||||
const modeMarbles = " s";
|
||||
const modeInputMarbles = " s";
|
||||
// First Bob speaks, then Dave, then Alice
|
||||
const aSpeakingMarbles = "n--y";
|
||||
const bSpeakingMarbles = "nyn";
|
||||
const dSpeakingMarbles = "n-yn";
|
||||
const aSpeakingInputMarbles = "n--y";
|
||||
const bSpeakingInputMarbles = "nyn";
|
||||
const dSpeakingInputMarbles = "n-yn";
|
||||
// Alice should start in the spotlight, then Bob, then Dave, then Alice
|
||||
// again. However, the positions of Dave and Bob in the grid should be
|
||||
// reversed by the end because they've been swapped in and out of the
|
||||
// spotlight.
|
||||
const layoutMarbles = " abcd";
|
||||
const expectedLayoutMarbles = "abcd";
|
||||
|
||||
withCallViewModel(
|
||||
of([aliceParticipant, bobParticipant, daveParticipant]),
|
||||
of(ConnectionState.Connected),
|
||||
new Map([
|
||||
[aliceParticipant, cold(aSpeakingMarbles, { y: true, n: false })],
|
||||
[bobParticipant, cold(bSpeakingMarbles, { y: true, n: false })],
|
||||
[daveParticipant, cold(dSpeakingMarbles, { y: true, n: false })],
|
||||
[aliceParticipant, cold(aSpeakingInputMarbles, { y: true, n: false })],
|
||||
[bobParticipant, cold(bSpeakingInputMarbles, { y: true, n: false })],
|
||||
[daveParticipant, cold(dSpeakingInputMarbles, { y: true, n: false })],
|
||||
]),
|
||||
(vm) => {
|
||||
schedule(modeMarbles, { s: () => vm.setGridMode("spotlight") });
|
||||
schedule(modeInputMarbles, { s: () => vm.setGridMode("spotlight") });
|
||||
|
||||
expectObservable(summarizeLayout(vm.layout)).toBe(layoutMarbles, {
|
||||
a: {
|
||||
type: "spotlight-landscape",
|
||||
spotlight: [`${aliceId}:0`],
|
||||
grid: ["local:0", `${bobId}:0`, `${daveId}:0`],
|
||||
expectObservable(summarizeLayout(vm.layout)).toBe(
|
||||
expectedLayoutMarbles,
|
||||
{
|
||||
a: {
|
||||
type: "spotlight-landscape",
|
||||
spotlight: [`${aliceId}:0`],
|
||||
grid: ["local:0", `${bobId}:0`, `${daveId}:0`],
|
||||
},
|
||||
b: {
|
||||
type: "spotlight-landscape",
|
||||
spotlight: [`${bobId}:0`],
|
||||
grid: ["local:0", `${aliceId}:0`, `${daveId}:0`],
|
||||
},
|
||||
c: {
|
||||
type: "spotlight-landscape",
|
||||
spotlight: [`${daveId}:0`],
|
||||
grid: ["local:0", `${aliceId}:0`, `${bobId}:0`],
|
||||
},
|
||||
d: {
|
||||
type: "spotlight-landscape",
|
||||
spotlight: [`${aliceId}:0`],
|
||||
grid: ["local:0", `${daveId}:0`, `${bobId}:0`],
|
||||
},
|
||||
},
|
||||
b: {
|
||||
type: "spotlight-landscape",
|
||||
spotlight: [`${bobId}:0`],
|
||||
grid: ["local:0", `${aliceId}:0`, `${daveId}:0`],
|
||||
},
|
||||
c: {
|
||||
type: "spotlight-landscape",
|
||||
spotlight: [`${daveId}:0`],
|
||||
grid: ["local:0", `${aliceId}:0`, `${bobId}:0`],
|
||||
},
|
||||
d: {
|
||||
type: "spotlight-landscape",
|
||||
spotlight: [`${aliceId}:0`],
|
||||
grid: ["local:0", `${daveId}:0`, `${bobId}:0`],
|
||||
},
|
||||
});
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -446,31 +464,34 @@ test("spotlight speakers swap places", () => {
|
||||
test("layout enters picture-in-picture mode when requested", () => {
|
||||
withTestScheduler(({ schedule, expectObservable }) => {
|
||||
// Enable then disable picture-in-picture
|
||||
const pipControlMarbles = "-ed";
|
||||
const pipControlInputMarbles = "-ed";
|
||||
// Should go into picture-in-picture layout then back to grid
|
||||
const layoutMarbles = " aba";
|
||||
const expectedLayoutMarbles = " aba";
|
||||
|
||||
withCallViewModel(
|
||||
of([aliceParticipant, bobParticipant]),
|
||||
of(ConnectionState.Connected),
|
||||
new Map(),
|
||||
(vm) => {
|
||||
schedule(pipControlMarbles, {
|
||||
schedule(pipControlInputMarbles, {
|
||||
e: () => window.controls.enablePip(),
|
||||
d: () => window.controls.disablePip(),
|
||||
});
|
||||
|
||||
expectObservable(summarizeLayout(vm.layout)).toBe(layoutMarbles, {
|
||||
a: {
|
||||
type: "grid",
|
||||
spotlight: undefined,
|
||||
grid: ["local:0", `${aliceId}:0`, `${bobId}:0`],
|
||||
expectObservable(summarizeLayout(vm.layout)).toBe(
|
||||
expectedLayoutMarbles,
|
||||
{
|
||||
a: {
|
||||
type: "grid",
|
||||
spotlight: undefined,
|
||||
grid: ["local:0", `${aliceId}:0`, `${bobId}:0`],
|
||||
},
|
||||
b: {
|
||||
type: "pip",
|
||||
spotlight: [`${aliceId}:0`],
|
||||
},
|
||||
},
|
||||
b: {
|
||||
type: "pip",
|
||||
spotlight: [`${aliceId}:0`],
|
||||
},
|
||||
});
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -480,23 +501,23 @@ test("spotlight remembers whether it's expanded", () => {
|
||||
withTestScheduler(({ schedule, expectObservable }) => {
|
||||
// Start in spotlight mode, then switch to grid and back to spotlight a
|
||||
// couple times
|
||||
const modeMarbles = " s-gs-gs";
|
||||
const modeInputMarbles = " s-gs-gs";
|
||||
// Expand and collapse the spotlight
|
||||
const expandMarbles = "-a--a";
|
||||
const expandInputMarbles = " -a--a";
|
||||
// Spotlight should stay expanded during the first mode switch, and stay
|
||||
// collapsed during the second mode switch
|
||||
const layoutMarbles = "abcbada";
|
||||
const expectedLayoutMarbles = "abcbada";
|
||||
|
||||
withCallViewModel(
|
||||
of([aliceParticipant, bobParticipant]),
|
||||
of(ConnectionState.Connected),
|
||||
new Map(),
|
||||
(vm) => {
|
||||
schedule(modeMarbles, {
|
||||
schedule(modeInputMarbles, {
|
||||
s: () => vm.setGridMode("spotlight"),
|
||||
g: () => vm.setGridMode("grid"),
|
||||
});
|
||||
schedule(expandMarbles, {
|
||||
schedule(expandInputMarbles, {
|
||||
a: () => {
|
||||
let toggle: () => void;
|
||||
vm.toggleSpotlightExpanded.subscribe((val) => (toggle = val!));
|
||||
@@ -504,28 +525,31 @@ test("spotlight remembers whether it's expanded", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expectObservable(summarizeLayout(vm.layout)).toBe(layoutMarbles, {
|
||||
a: {
|
||||
type: "spotlight-landscape",
|
||||
spotlight: [`${aliceId}:0`],
|
||||
grid: ["local:0", `${bobId}:0`],
|
||||
expectObservable(summarizeLayout(vm.layout)).toBe(
|
||||
expectedLayoutMarbles,
|
||||
{
|
||||
a: {
|
||||
type: "spotlight-landscape",
|
||||
spotlight: [`${aliceId}:0`],
|
||||
grid: ["local:0", `${bobId}:0`],
|
||||
},
|
||||
b: {
|
||||
type: "spotlight-expanded",
|
||||
spotlight: [`${aliceId}:0`],
|
||||
pip: "local:0",
|
||||
},
|
||||
c: {
|
||||
type: "grid",
|
||||
spotlight: undefined,
|
||||
grid: ["local:0", `${aliceId}:0`, `${bobId}:0`],
|
||||
},
|
||||
d: {
|
||||
type: "grid",
|
||||
spotlight: undefined,
|
||||
grid: ["local:0", `${bobId}:0`, `${aliceId}:0`],
|
||||
},
|
||||
},
|
||||
b: {
|
||||
type: "spotlight-expanded",
|
||||
spotlight: [`${aliceId}:0`],
|
||||
pip: "local:0",
|
||||
},
|
||||
c: {
|
||||
type: "grid",
|
||||
spotlight: undefined,
|
||||
grid: ["local:0", `${aliceId}:0`, `${bobId}:0`],
|
||||
},
|
||||
d: {
|
||||
type: "grid",
|
||||
spotlight: undefined,
|
||||
grid: ["local:0", `${bobId}:0`, `${aliceId}:0`],
|
||||
},
|
||||
});
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -260,7 +260,6 @@ class UserMedia {
|
||||
),
|
||||
),
|
||||
startWith(false),
|
||||
distinctUntilChanged(),
|
||||
// Make this Observable hot so that the timers don't reset when you
|
||||
// resubscribe
|
||||
this.scope.state(),
|
||||
@@ -672,7 +671,7 @@ export class CallViewModel extends ViewModel {
|
||||
this.gridModeUserSelection.next(value);
|
||||
}
|
||||
|
||||
private readonly gridLayout: Observable<LayoutMedia> = combineLatest(
|
||||
private readonly gridLayoutMedia: Observable<GridLayoutMedia> = combineLatest(
|
||||
[this.grid, this.spotlight],
|
||||
(grid, spotlight) => ({
|
||||
type: "grid",
|
||||
@@ -683,28 +682,28 @@ export class CallViewModel extends ViewModel {
|
||||
}),
|
||||
);
|
||||
|
||||
private readonly spotlightLandscapeLayout: Observable<LayoutMedia> =
|
||||
private readonly spotlightLandscapeLayoutMedia: Observable<SpotlightLandscapeLayoutMedia> =
|
||||
combineLatest([this.grid, this.spotlight], (grid, spotlight) => ({
|
||||
type: "spotlight-landscape",
|
||||
spotlight,
|
||||
grid,
|
||||
}));
|
||||
|
||||
private readonly spotlightPortraitLayout: Observable<LayoutMedia> =
|
||||
private readonly spotlightPortraitLayoutMedia: Observable<SpotlightPortraitLayoutMedia> =
|
||||
combineLatest([this.grid, this.spotlight], (grid, spotlight) => ({
|
||||
type: "spotlight-portrait",
|
||||
spotlight,
|
||||
grid,
|
||||
}));
|
||||
|
||||
private readonly spotlightExpandedLayout: Observable<LayoutMedia> =
|
||||
private readonly spotlightExpandedLayoutMedia: Observable<SpotlightExpandedLayoutMedia> =
|
||||
combineLatest([this.spotlight, this.pip], (spotlight, pip) => ({
|
||||
type: "spotlight-expanded",
|
||||
spotlight,
|
||||
pip: pip ?? undefined,
|
||||
}));
|
||||
|
||||
private readonly oneOnOneLayout: Observable<LayoutMedia | null> =
|
||||
private readonly oneOnOneLayoutMedia: Observable<OneOnOneLayoutMedia | null> =
|
||||
this.mediaItems.pipe(
|
||||
map((mediaItems) => {
|
||||
if (mediaItems.length !== 2) return null;
|
||||
@@ -722,9 +721,8 @@ export class CallViewModel extends ViewModel {
|
||||
}),
|
||||
);
|
||||
|
||||
private readonly pipLayout: Observable<LayoutMedia> = this.spotlight.pipe(
|
||||
map((spotlight) => ({ type: "pip", spotlight })),
|
||||
);
|
||||
private readonly pipLayoutMedia: Observable<LayoutMedia> =
|
||||
this.spotlight.pipe(map((spotlight) => ({ type: "pip", spotlight })));
|
||||
|
||||
/**
|
||||
* The media to be used to produce a layout.
|
||||
@@ -737,24 +735,24 @@ export class CallViewModel extends ViewModel {
|
||||
switchMap((gridMode) => {
|
||||
switch (gridMode) {
|
||||
case "grid":
|
||||
return this.oneOnOneLayout.pipe(
|
||||
return this.oneOnOneLayoutMedia.pipe(
|
||||
switchMap((oneOnOne) =>
|
||||
oneOnOne === null ? this.gridLayout : of(oneOnOne),
|
||||
oneOnOne === null ? this.gridLayoutMedia : of(oneOnOne),
|
||||
),
|
||||
);
|
||||
case "spotlight":
|
||||
return this.spotlightExpanded.pipe(
|
||||
switchMap((expanded) =>
|
||||
expanded
|
||||
? this.spotlightExpandedLayout
|
||||
: this.spotlightLandscapeLayout,
|
||||
? this.spotlightExpandedLayoutMedia
|
||||
: this.spotlightLandscapeLayoutMedia,
|
||||
),
|
||||
);
|
||||
}
|
||||
}),
|
||||
);
|
||||
case "narrow":
|
||||
return this.oneOnOneLayout.pipe(
|
||||
return this.oneOnOneLayoutMedia.pipe(
|
||||
switchMap((oneOnOne) =>
|
||||
oneOnOne === null
|
||||
? combineLatest(
|
||||
@@ -762,12 +760,12 @@ export class CallViewModel extends ViewModel {
|
||||
(grid, spotlight) =>
|
||||
grid.length > smallMobileCallThreshold ||
|
||||
spotlight.some((vm) => vm instanceof ScreenShareViewModel)
|
||||
? this.spotlightPortraitLayout
|
||||
: this.gridLayout,
|
||||
? this.spotlightPortraitLayoutMedia
|
||||
: this.gridLayoutMedia,
|
||||
).pipe(switchAll())
|
||||
: // The expanded spotlight layout makes for a better one-on-one
|
||||
// experience in narrow windows
|
||||
this.spotlightExpandedLayout,
|
||||
this.spotlightExpandedLayoutMedia,
|
||||
),
|
||||
);
|
||||
case "flat":
|
||||
@@ -777,14 +775,14 @@ export class CallViewModel extends ViewModel {
|
||||
case "grid":
|
||||
// Yes, grid mode actually gets you a "spotlight" layout in
|
||||
// this window mode.
|
||||
return this.spotlightLandscapeLayout;
|
||||
return this.spotlightLandscapeLayoutMedia;
|
||||
case "spotlight":
|
||||
return this.spotlightExpandedLayout;
|
||||
return this.spotlightExpandedLayoutMedia;
|
||||
}
|
||||
}),
|
||||
);
|
||||
case "pip":
|
||||
return this.pipLayout;
|
||||
return this.pipLayoutMedia;
|
||||
}
|
||||
}),
|
||||
this.scope.state(),
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
VisibilityOnIcon,
|
||||
UserProfileIcon,
|
||||
ExpandIcon,
|
||||
VolumeOffSolidIcon,
|
||||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import {
|
||||
ContextMenu,
|
||||
@@ -62,6 +63,7 @@ interface TileProps {
|
||||
interface UserMediaTileProps extends TileProps {
|
||||
vm: UserMediaViewModel;
|
||||
mirror: boolean;
|
||||
locallyMuted: boolean;
|
||||
menuStart?: ReactNode;
|
||||
menuEnd?: ReactNode;
|
||||
}
|
||||
@@ -71,6 +73,7 @@ const UserMediaTile = forwardRef<HTMLDivElement, UserMediaTileProps>(
|
||||
{
|
||||
vm,
|
||||
showSpeakingIndicators,
|
||||
locallyMuted,
|
||||
menuStart,
|
||||
menuEnd,
|
||||
className,
|
||||
@@ -94,9 +97,18 @@ const UserMediaTile = forwardRef<HTMLDivElement, UserMediaTileProps>(
|
||||
},
|
||||
[vm],
|
||||
);
|
||||
const { raisedHands, lowerHand, reactions } = useReactions();
|
||||
const { raisedHands, toggleRaisedHand, reactions } = useReactions();
|
||||
|
||||
const MicIcon = audioEnabled ? MicOnSolidIcon : MicOffSolidIcon;
|
||||
const AudioIcon = locallyMuted
|
||||
? VolumeOffSolidIcon
|
||||
: audioEnabled
|
||||
? MicOnSolidIcon
|
||||
: MicOffSolidIcon;
|
||||
const audioIconLabel = locallyMuted
|
||||
? t("video_tile.muted_for_me")
|
||||
: audioEnabled
|
||||
? t("microphone_on")
|
||||
: t("microphone_off");
|
||||
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const menu = (
|
||||
@@ -115,8 +127,9 @@ const UserMediaTile = forwardRef<HTMLDivElement, UserMediaTileProps>(
|
||||
const handRaised: Date | undefined = raisedHands[vm.member?.userId ?? ""];
|
||||
const currentReaction: ReactionOption | undefined =
|
||||
reactions[vm.member?.userId ?? ""];
|
||||
const raisedHandOnClick =
|
||||
vm.local && handRaised ? (): void => void lowerHand() : undefined;
|
||||
const raisedHandOnClick = vm.local
|
||||
? (): void => void toggleRaisedHand()
|
||||
: undefined;
|
||||
|
||||
const showSpeaking = showSpeakingIndicators && speaking;
|
||||
|
||||
@@ -134,11 +147,11 @@ const UserMediaTile = forwardRef<HTMLDivElement, UserMediaTileProps>(
|
||||
[styles.handRaised]: !showSpeaking && !!handRaised,
|
||||
})}
|
||||
nameTagLeadingIcon={
|
||||
<MicIcon
|
||||
<AudioIcon
|
||||
width={20}
|
||||
height={20}
|
||||
aria-label={audioEnabled ? t("microphone_on") : t("microphone_off")}
|
||||
data-muted={!audioEnabled}
|
||||
aria-label={audioIconLabel}
|
||||
data-muted={locallyMuted || !audioEnabled}
|
||||
className={styles.muteIcon}
|
||||
/>
|
||||
}
|
||||
@@ -199,6 +212,7 @@ const LocalUserMediaTile = forwardRef<HTMLDivElement, LocalUserMediaTileProps>(
|
||||
<UserMediaTile
|
||||
ref={ref}
|
||||
vm={vm}
|
||||
locallyMuted={false}
|
||||
mirror={mirror}
|
||||
menuStart={
|
||||
<ToggleMenuItem
|
||||
@@ -255,6 +269,7 @@ const RemoteUserMediaTile = forwardRef<
|
||||
<UserMediaTile
|
||||
ref={ref}
|
||||
vm={vm}
|
||||
locallyMuted={locallyMuted}
|
||||
mirror={false}
|
||||
menuStart={
|
||||
<>
|
||||
|
||||
@@ -102,7 +102,7 @@ export const MediaView = forwardRef<HTMLDivElement, Props>(
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.fg}>
|
||||
<div style={{ display: "flex" }}>
|
||||
<div style={{ display: "flex", gap: "var(--cpd-space-1x)" }}>
|
||||
<RaisedHandIndicator
|
||||
raisedHandTime={raisedHandTime}
|
||||
miniature={avatarSize < 96}
|
||||
|
||||
@@ -12,19 +12,24 @@ import { Button } from "@vector-im/compound-web";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
import { useCallViewKeyboardShortcuts } from "../src/useCallViewKeyboardShortcuts";
|
||||
import { ReactionOption, ReactionSet, ReactionsRowSize } from "./reactions";
|
||||
|
||||
// Test Explanation:
|
||||
// - The main objective is to test `useCallViewKeyboardShortcuts`.
|
||||
// The TestComponent just wraps a button around that hook.
|
||||
|
||||
interface TestComponentProps {
|
||||
setMicrophoneMuted: (muted: boolean) => void;
|
||||
setMicrophoneMuted?: (muted: boolean) => void;
|
||||
onButtonClick?: () => void;
|
||||
sendReaction?: () => void;
|
||||
toggleHandRaised?: () => void;
|
||||
}
|
||||
|
||||
const TestComponent: FC<TestComponentProps> = ({
|
||||
setMicrophoneMuted,
|
||||
setMicrophoneMuted = (): void => {},
|
||||
onButtonClick = (): void => {},
|
||||
sendReaction = (reaction: ReactionOption): void => {},
|
||||
toggleHandRaised = (): void => {},
|
||||
}) => {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
useCallViewKeyboardShortcuts(
|
||||
@@ -32,6 +37,8 @@ const TestComponent: FC<TestComponentProps> = ({
|
||||
() => {},
|
||||
() => {},
|
||||
setMicrophoneMuted,
|
||||
sendReaction,
|
||||
toggleHandRaised,
|
||||
);
|
||||
return (
|
||||
<div ref={ref}>
|
||||
@@ -74,6 +81,28 @@ test("spacebar prioritizes pressing a button", async () => {
|
||||
expect(onClick).toBeCalled();
|
||||
});
|
||||
|
||||
test("reactions can be sent via keyboard presses", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
const sendReaction = vi.fn();
|
||||
render(<TestComponent sendReaction={sendReaction} />);
|
||||
|
||||
for (let index = 1; index <= ReactionsRowSize; index++) {
|
||||
await user.keyboard(index.toString());
|
||||
expect(sendReaction).toHaveBeenNthCalledWith(index, ReactionSet[index - 1]);
|
||||
}
|
||||
});
|
||||
|
||||
test("raised hand can be sent via keyboard presses", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
const toggleHandRaised = vi.fn();
|
||||
render(<TestComponent toggleHandRaised={toggleHandRaised} />);
|
||||
await user.keyboard("h");
|
||||
|
||||
expect(toggleHandRaised).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test("unmuting happens in place of the default action", async () => {
|
||||
const user = userEvent.setup();
|
||||
const defaultPrevented = vi.fn();
|
||||
|
||||
@@ -8,6 +8,7 @@ Please see LICENSE in the repository root for full details.
|
||||
import { RefObject, useCallback, useMemo, useRef } from "react";
|
||||
|
||||
import { useEventTarget } from "./useEvents";
|
||||
import { ReactionOption, ReactionSet, ReactionsRowSize } from "./reactions";
|
||||
|
||||
/**
|
||||
* Determines whether focus is in the same part of the tree as the given
|
||||
@@ -18,11 +19,17 @@ const mayReceiveKeyEvents = (e: HTMLElement): boolean => {
|
||||
return focusedElement !== null && focusedElement.contains(e);
|
||||
};
|
||||
|
||||
const KeyToReactionMap: Record<string, ReactionOption> = Object.fromEntries(
|
||||
ReactionSet.slice(0, ReactionsRowSize).map((r, i) => [(i + 1).toString(), r]),
|
||||
);
|
||||
|
||||
export function useCallViewKeyboardShortcuts(
|
||||
focusElement: RefObject<HTMLElement | null>,
|
||||
toggleMicrophoneMuted: () => void,
|
||||
toggleLocalVideoMuted: () => void,
|
||||
setMicrophoneMuted: (muted: boolean) => void,
|
||||
sendReaction: (reaction: ReactionOption) => void,
|
||||
toggleHandRaised: () => void,
|
||||
): void {
|
||||
const spacebarHeld = useRef(false);
|
||||
|
||||
@@ -49,6 +56,12 @@ export function useCallViewKeyboardShortcuts(
|
||||
spacebarHeld.current = true;
|
||||
setMicrophoneMuted(false);
|
||||
}
|
||||
} else if (event.key === "h") {
|
||||
event.preventDefault();
|
||||
toggleHandRaised();
|
||||
} else if (KeyToReactionMap[event.key]) {
|
||||
event.preventDefault();
|
||||
sendReaction(KeyToReactionMap[event.key]);
|
||||
}
|
||||
},
|
||||
[
|
||||
@@ -56,6 +69,8 @@ export function useCallViewKeyboardShortcuts(
|
||||
toggleLocalVideoMuted,
|
||||
toggleMicrophoneMuted,
|
||||
setMicrophoneMuted,
|
||||
sendReaction,
|
||||
toggleHandRaised,
|
||||
],
|
||||
),
|
||||
// Because this is set on the window, to prevent shortcuts from activating
|
||||
|
||||
@@ -40,7 +40,8 @@ interface ReactionsContextType {
|
||||
raisedHands: Record<string, Date>;
|
||||
supportsReactions: boolean;
|
||||
reactions: Record<string, ReactionOption>;
|
||||
lowerHand: () => Promise<void>;
|
||||
toggleRaisedHand: () => Promise<void>;
|
||||
sendReaction: (reaction: ReactionOption) => Promise<void>;
|
||||
}
|
||||
|
||||
const ReactionsContext = createContext<ReactionsContextType | undefined>(
|
||||
@@ -104,7 +105,6 @@ export const ReactionsProvider = ({
|
||||
),
|
||||
[raisedHands],
|
||||
);
|
||||
|
||||
const addRaisedHand = useCallback((userId: string, info: RaisedHandInfo) => {
|
||||
setRaisedHands((prevRaisedHands) => ({
|
||||
...prevRaisedHands,
|
||||
@@ -181,6 +181,11 @@ export const ReactionsProvider = ({
|
||||
const latestMemberships = useLatest(memberships);
|
||||
const latestRaisedHands = useLatest(raisedHands);
|
||||
|
||||
const myMembership = useMemo(
|
||||
() => memberships.find((m) => m.sender === myUserId)?.eventId,
|
||||
[memberships, myUserId],
|
||||
);
|
||||
|
||||
// This effect handles any *live* reaction/redactions in the room.
|
||||
useEffect(() => {
|
||||
const reactionTimeouts = new Set<number>();
|
||||
@@ -195,11 +200,12 @@ export const ReactionsProvider = ({
|
||||
// Skip any event without a sender or event ID.
|
||||
if (!sender || !reactionEventId) return;
|
||||
|
||||
room.client
|
||||
.decryptEventIfNeeded(event)
|
||||
.catch((e) => logger.warn(`Failed to decrypt ${event.getId()}`, e));
|
||||
if (event.isBeingDecrypted() || event.isDecryptionFailure()) return;
|
||||
|
||||
if (event.getType() === ElementCallReactionEventType) {
|
||||
room.client
|
||||
.decryptEventIfNeeded(event)
|
||||
.catch((e) => logger.warn(`Failed to decrypt ${event.getId()}`, e));
|
||||
if (event.isBeingDecrypted() || event.isDecryptionFailure()) return;
|
||||
const content: ECallReactionEventContent = event.getContent();
|
||||
|
||||
const membershipEventId = content?.["m.relates_to"]?.event_id;
|
||||
@@ -321,22 +327,67 @@ export const ReactionsProvider = ({
|
||||
latestRaisedHands,
|
||||
]);
|
||||
|
||||
const lowerHand = useCallback(async () => {
|
||||
if (!myUserId || !raisedHands[myUserId]) {
|
||||
const toggleRaisedHand = useCallback(async () => {
|
||||
if (!myUserId) {
|
||||
return;
|
||||
}
|
||||
const myReactionId = raisedHands[myUserId].reactionEventId;
|
||||
const myReactionId = raisedHands[myUserId]?.reactionEventId;
|
||||
|
||||
if (!myReactionId) {
|
||||
logger.warn(`Hand raised but no reaction event to redact!`);
|
||||
return;
|
||||
try {
|
||||
if (!myMembership) {
|
||||
throw new Error("Cannot find own membership event");
|
||||
}
|
||||
const reaction = await room.client.sendEvent(
|
||||
rtcSession.room.roomId,
|
||||
EventType.Reaction,
|
||||
{
|
||||
"m.relates_to": {
|
||||
rel_type: RelationType.Annotation,
|
||||
event_id: myMembership,
|
||||
key: "🖐️",
|
||||
},
|
||||
},
|
||||
);
|
||||
logger.debug("Sent raise hand event", reaction.event_id);
|
||||
} catch (ex) {
|
||||
logger.error("Failed to send raised hand", ex);
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
await room.client.redactEvent(rtcSession.room.roomId, myReactionId);
|
||||
logger.debug("Redacted raise hand event");
|
||||
} catch (ex) {
|
||||
logger.error("Failed to redact reaction event", myReactionId, ex);
|
||||
throw ex;
|
||||
}
|
||||
}
|
||||
try {
|
||||
await room.client.redactEvent(rtcSession.room.roomId, myReactionId);
|
||||
logger.debug("Redacted raise hand event");
|
||||
} catch (ex) {
|
||||
logger.error("Failed to redact reaction event", myReactionId, ex);
|
||||
}
|
||||
}, [myUserId, raisedHands, rtcSession, room]);
|
||||
}, [myMembership, myUserId, raisedHands, rtcSession, room]);
|
||||
|
||||
const sendReaction = useCallback(
|
||||
async (reaction: ReactionOption) => {
|
||||
if (!myUserId || reactions[myUserId]) {
|
||||
// We're still reacting
|
||||
return;
|
||||
}
|
||||
if (!myMembership) {
|
||||
throw new Error("Cannot find own membership event");
|
||||
}
|
||||
await room.client.sendEvent(
|
||||
rtcSession.room.roomId,
|
||||
ElementCallReactionEventType,
|
||||
{
|
||||
"m.relates_to": {
|
||||
rel_type: RelationType.Reference,
|
||||
event_id: myMembership,
|
||||
},
|
||||
emoji: reaction.emoji,
|
||||
name: reaction.name,
|
||||
},
|
||||
);
|
||||
},
|
||||
[myMembership, reactions, room, myUserId, rtcSession],
|
||||
);
|
||||
|
||||
return (
|
||||
<ReactionsContext.Provider
|
||||
@@ -344,7 +395,8 @@ export const ReactionsProvider = ({
|
||||
raisedHands: resultRaisedHands,
|
||||
supportsReactions,
|
||||
reactions,
|
||||
lowerHand,
|
||||
toggleRaisedHand,
|
||||
sendReaction,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
64
src/utils/spa.ts
Normal file
64
src/utils/spa.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { ICreateClientOpts } from "matrix-js-sdk/src/client";
|
||||
import { MatrixError } from "matrix-js-sdk/src/http-api";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
import { Config } from "../config/Config";
|
||||
import { fallbackICEServerAllowed, initClient } from "./matrix";
|
||||
import type { InitResult, Session } from "../ClientContext";
|
||||
|
||||
export async function initSPA(
|
||||
loadSession: () => Session | undefined,
|
||||
clearSession: () => void,
|
||||
): Promise<InitResult | null> {
|
||||
// We're running as a standalone application
|
||||
try {
|
||||
const session = loadSession();
|
||||
if (!session) {
|
||||
logger.log("No session stored; continuing without a client");
|
||||
return null;
|
||||
}
|
||||
|
||||
logger.log("Using a standalone client");
|
||||
|
||||
/* eslint-disable camelcase */
|
||||
const { user_id, device_id, access_token, passwordlessUser } = session;
|
||||
const initClientParams: ICreateClientOpts = {
|
||||
baseUrl: Config.defaultHomeserverUrl()!,
|
||||
accessToken: access_token,
|
||||
userId: user_id,
|
||||
deviceId: device_id,
|
||||
fallbackICEServerAllowed,
|
||||
livekitServiceURL: Config.get().livekit?.livekit_service_url,
|
||||
};
|
||||
|
||||
try {
|
||||
const client = await initClient(initClientParams, true);
|
||||
return {
|
||||
widgetApi: null,
|
||||
client,
|
||||
passwordlessUser,
|
||||
};
|
||||
} catch (err) {
|
||||
if (err instanceof MatrixError && err.errcode === "M_UNKNOWN_TOKEN") {
|
||||
// We can't use this session anymore, so let's log it out
|
||||
logger.log(
|
||||
"The session from local store is invalid; continuing without a client",
|
||||
);
|
||||
clearSession();
|
||||
// returning null = "no client` pls register" (undefined = "loading" which is the current value when reaching this line)
|
||||
return null;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
} catch (err) {
|
||||
clearSession();
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ Copyright 2023, 2024 New Vector Ltd.
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
import { map, Observable, of } from "rxjs";
|
||||
import { map, Observable, of, SchedulerLike } from "rxjs";
|
||||
import { RunHelpers, TestScheduler } from "rxjs/testing";
|
||||
import { expect, vi } from "vitest";
|
||||
import { RoomMember, Room as MatrixRoom } from "matrix-js-sdk/src/matrix";
|
||||
@@ -39,15 +39,23 @@ export interface OurRunHelpers extends RunHelpers {
|
||||
schedule: (marbles: string, actions: Record<string, () => void>) => void;
|
||||
}
|
||||
|
||||
interface TestRunnerGlobal {
|
||||
rxjsTestScheduler?: SchedulerLike;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run Observables with a scheduler that virtualizes time, for testing purposes.
|
||||
*/
|
||||
export function withTestScheduler(
|
||||
continuation: (helpers: OurRunHelpers) => void,
|
||||
): void {
|
||||
new TestScheduler((actual, expected) => {
|
||||
const scheduler = new TestScheduler((actual, expected) => {
|
||||
expect(actual).deep.equals(expected);
|
||||
}).run((helpers) =>
|
||||
});
|
||||
// we set the test scheduler as a global so that you can watch it in a debugger
|
||||
// and get the frame number. e.g. `rxjsTestScheduler?.now()`
|
||||
(global as unknown as TestRunnerGlobal).rxjsTestScheduler = scheduler;
|
||||
scheduler.run((helpers) =>
|
||||
continuation({
|
||||
...helpers,
|
||||
schedule(marbles, actions) {
|
||||
|
||||
@@ -6,6 +6,8 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import "global-jsdom/register";
|
||||
import "@formatjs/intl-durationformat/polyfill";
|
||||
import "@formatjs/intl-segmenter/polyfill";
|
||||
import i18n from "i18next";
|
||||
import posthog from "posthog-js";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
@@ -14,6 +16,7 @@ import { cleanup } from "@testing-library/react";
|
||||
import "vitest-axe/extend-expect";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
import EN_GB from "../locales/en-GB/app.json";
|
||||
import { Config } from "./config/Config";
|
||||
|
||||
// Bare-minimum i18n config
|
||||
@@ -22,6 +25,13 @@ i18n
|
||||
.init({
|
||||
lng: "en-GB",
|
||||
fallbackLng: "en-GB",
|
||||
supportedLngs: ["en-GB"],
|
||||
// We embed the translations, so that it never needs to fetch
|
||||
resources: {
|
||||
"en-GB": {
|
||||
app: EN_GB,
|
||||
},
|
||||
},
|
||||
interpolation: {
|
||||
escapeValue: false, // React has built-in XSS protections
|
||||
},
|
||||
|
||||
@@ -6,8 +6,10 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { defineConfig, loadEnv } from "vite";
|
||||
import { compression } from "vite-plugin-compression2";
|
||||
import svgrPlugin from "vite-plugin-svgr";
|
||||
import htmlTemplate from "vite-plugin-html-template";
|
||||
import { codecovVitePlugin } from "@codecov/vite-plugin";
|
||||
import { sentryVitePlugin } from "@sentry/vite-plugin";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import basicSsl from "@vitejs/plugin-basic-ssl";
|
||||
@@ -31,6 +33,16 @@ export default defineConfig(({ mode }) => {
|
||||
title: env.VITE_PRODUCT_NAME || "Element Call",
|
||||
},
|
||||
}),
|
||||
|
||||
codecovVitePlugin({
|
||||
enableBundleAnalysis: process.env.CODECOV_TOKEN !== undefined,
|
||||
bundleName: "element-call",
|
||||
uploadToken: process.env.CODECOV_TOKEN,
|
||||
}),
|
||||
|
||||
compression({
|
||||
exclude: [/config.json/],
|
||||
}),
|
||||
];
|
||||
|
||||
if (
|
||||
@@ -53,6 +65,25 @@ export default defineConfig(({ mode }) => {
|
||||
},
|
||||
build: {
|
||||
sourcemap: true,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
assetFileNames: ({ originalFileNames }) => {
|
||||
if (originalFileNames) {
|
||||
for (const name of originalFileNames) {
|
||||
// Custom asset name for locales to include the locale code in the filename
|
||||
const match = name.match(/locales\/([^/]+)\/(.+)\.json$/);
|
||||
if (match) {
|
||||
const [, locale, filename] = match;
|
||||
return `assets/${locale}-${filename}-[hash].json`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Default naming fallback
|
||||
return "assets/[name]-[hash][extname]";
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins,
|
||||
resolve: {
|
||||
|
||||
Reference in New Issue
Block a user