From 7da9bca08c5c50ae41478296837ab688658510ef Mon Sep 17 00:00:00 2001 From: Timo K Date: Fri, 13 Mar 2026 07:59:49 +0100 Subject: [PATCH 1/6] update qs, js-yaml, glob for security patches --- package.json | 5 +- yarn.lock | 164 ++++++++++++++------------------------------------- 2 files changed, 49 insertions(+), 120 deletions(-) diff --git a/package.json b/package.json index 04a48dc2..cc8a36eb 100644 --- a/package.json +++ b/package.json @@ -140,7 +140,10 @@ "@livekit/components-core/rxjs": "^7.8.1", "@livekit/track-processors/@mediapipe/tasks-vision": "^0.10.18", "minimatch": "^10.2.3", - "tar": "^7.5.11" + "tar": "^7.5.11", + "glob": "^10.5.0", + "qs": "^6.14.1", + "js-yaml": "^4.1.1" }, "packageManager": "yarn@4.7.0" } diff --git a/yarn.lock b/yarn.lock index a3f6b921..12e1b857 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2901,20 +2901,13 @@ __metadata: languageName: node linkType: hard -"@eslint-community/regexpp@npm:^4.12.2": +"@eslint-community/regexpp@npm:^4.12.2, @eslint-community/regexpp@npm:^4.6.1": version: 4.12.2 resolution: "@eslint-community/regexpp@npm:4.12.2" checksum: 10c0/fddcbc66851b308478d04e302a4d771d6917a0b3740dc351513c0da9ca2eab8a1adf99f5e0aa7ab8b13fa0df005c81adeee7e63a92f3effd7d367a163b721c2d languageName: node linkType: hard -"@eslint-community/regexpp@npm:^4.6.1": - version: 4.11.1 - resolution: "@eslint-community/regexpp@npm:4.11.1" - checksum: 10c0/fbcc1cb65ef5ed5b92faa8dc542e035269065e7ebcc0b39c81a4fe98ad35cfff20b3c8df048641de15a7757e07d69f85e2579c1a5055f993413ba18c055654f8 - languageName: node - linkType: hard - "@eslint/eslintrc@npm:^2.1.4": version: 2.1.4 resolution: "@eslint/eslintrc@npm:2.1.4" @@ -6218,9 +6211,9 @@ __metadata: linkType: hard "@ungap/structured-clone@npm:^1.2.0": - version: 1.2.0 - resolution: "@ungap/structured-clone@npm:1.2.0" - checksum: 10c0/8209c937cb39119f44eb63cf90c0b73e7c754209a6411c707be08e50e29ee81356dca1a848a405c8bdeebfe2f5e4f831ad310ae1689eeef65e7445c090c6657d + version: 1.3.0 + resolution: "@ungap/structured-clone@npm:1.3.0" + checksum: 10c0/0fc3097c2540ada1fc340ee56d58d96b5b536a2a0dab6e3ec17d4bfc8c4c86db345f61a375a8185f9da96f01c69678f836a2b57eeaa9e4b8eeafd26428e57b0a languageName: node linkType: hard @@ -6437,7 +6430,7 @@ __metadata: languageName: node linkType: hard -"acorn@npm:^8.16.0": +"acorn@npm:^8.16.0, acorn@npm:^8.9.0": version: 8.16.0 resolution: "acorn@npm:8.16.0" bin: @@ -6446,15 +6439,6 @@ __metadata: languageName: node linkType: hard -"acorn@npm:^8.9.0": - version: 8.12.1 - resolution: "acorn@npm:8.12.1" - bin: - acorn: bin/acorn - checksum: 10c0/51fb26cd678f914e13287e886da2d7021f8c2bc0ccc95e03d3e0447ee278dd3b40b9c57dc222acd5881adcf26f3edc40901a4953403232129e3876793cd17386 - languageName: node - linkType: hard - "agent-base@npm:6": version: 6.0.2 resolution: "agent-base@npm:6.0.2" @@ -6481,14 +6465,14 @@ __metadata: linkType: hard "ajv@npm:^6.12.4": - version: 6.12.6 - resolution: "ajv@npm:6.12.6" + version: 6.14.0 + resolution: "ajv@npm:6.14.0" dependencies: fast-deep-equal: "npm:^3.1.1" fast-json-stable-stringify: "npm:^2.0.0" json-schema-traverse: "npm:^0.4.1" uri-js: "npm:^4.2.2" - checksum: 10c0/41e23642cbe545889245b9d2a45854ebba51cda6c778ebced9649420d9205f2efb39cb43dbc41e358409223b1ea43303ae4839db682c848b891e4811da1a5a71 + checksum: 10c0/a2bc39b0555dc9802c899f86990eb8eed6e366cddbf65be43d5aa7e4f3c4e1a199d5460fd7ca4fb3d864000dbbc049253b72faa83b3b30e641ca52cb29a68c22 languageName: node linkType: hard @@ -7749,18 +7733,7 @@ __metadata: languageName: node linkType: hard -"cross-spawn@npm:^7.0.2": - version: 7.0.3 - resolution: "cross-spawn@npm:7.0.3" - dependencies: - path-key: "npm:^3.1.0" - shebang-command: "npm:^2.0.0" - which: "npm:^2.0.1" - checksum: 10c0/5738c312387081c98d69c98e105b6327b069197f864a60593245d64c8089c8a0a744e16349281210d56835bb9274130d825a78b2ad6853ca13cfbeffc0c31750 - languageName: node - linkType: hard - -"cross-spawn@npm:^7.0.6": +"cross-spawn@npm:^7.0.2, cross-spawn@npm:^7.0.6": version: 7.0.6 resolution: "cross-spawn@npm:7.0.6" dependencies: @@ -9308,16 +9281,7 @@ __metadata: languageName: node linkType: hard -"esquery@npm:^1.4.2, esquery@npm:^1.6.0": - version: 1.6.0 - resolution: "esquery@npm:1.6.0" - dependencies: - estraverse: "npm:^5.1.0" - checksum: 10c0/cb9065ec605f9da7a76ca6dadb0619dfb611e37a81e318732977d90fab50a256b95fee2d925fba7c2f3f0523aa16f91587246693bc09bc34d5a59575fe6e93d2 - languageName: node - linkType: hard - -"esquery@npm:^1.7.0": +"esquery@npm:^1.4.2, esquery@npm:^1.7.0": version: 1.7.0 resolution: "esquery@npm:1.7.0" dependencies: @@ -9326,6 +9290,15 @@ __metadata: languageName: node linkType: hard +"esquery@npm:^1.6.0": + version: 1.6.0 + resolution: "esquery@npm:1.6.0" + dependencies: + estraverse: "npm:^5.1.0" + checksum: 10c0/cb9065ec605f9da7a76ca6dadb0619dfb611e37a81e318732977d90fab50a256b95fee2d925fba7c2f3f0523aa16f91587246693bc09bc34d5a59575fe6e93d2 + languageName: node + linkType: hard + "esrecurse@npm:^4.3.0": version: 4.3.0 resolution: "esrecurse@npm:4.3.0" @@ -9700,13 +9673,6 @@ __metadata: languageName: node linkType: hard -"fs.realpath@npm:^1.0.0": - version: 1.0.0 - resolution: "fs.realpath@npm:1.0.0" - checksum: 10c0/444cf1291d997165dfd4c0d58b69f0e4782bfd9149fd72faa4fe299e68e0e93d6db941660b37dd29153bf7186672ececa3b50b7e7249477b03fdf850f287c948 - languageName: node - linkType: hard - "fsevents@npm:2.3.2": version: 2.3.2 resolution: "fsevents@npm:2.3.2" @@ -9899,9 +9865,9 @@ __metadata: languageName: node linkType: hard -"glob@npm:^10.2.2, glob@npm:^10.3.10, glob@npm:^10.3.7": - version: 10.4.5 - resolution: "glob@npm:10.4.5" +"glob@npm:^10.5.0": + version: 10.5.0 + resolution: "glob@npm:10.5.0" dependencies: foreground-child: "npm:^3.1.0" jackspeak: "npm:^3.1.2" @@ -9911,33 +9877,7 @@ __metadata: path-scurry: "npm:^1.11.1" bin: glob: dist/esm/bin.mjs - checksum: 10c0/19a9759ea77b8e3ca0a43c2f07ecddc2ad46216b786bb8f993c445aee80d345925a21e5280c7b7c6c59e860a0154b84e4b2b60321fea92cd3c56b4a7489f160e - languageName: node - linkType: hard - -"glob@npm:^7.1.3, glob@npm:~7.2.0": - version: 7.2.3 - resolution: "glob@npm:7.2.3" - dependencies: - fs.realpath: "npm:^1.0.0" - inflight: "npm:^1.0.4" - inherits: "npm:2" - minimatch: "npm:^3.1.1" - once: "npm:^1.3.0" - path-is-absolute: "npm:^1.0.0" - checksum: 10c0/65676153e2b0c9095100fe7f25a778bf45608eeb32c6048cf307f579649bcc30353277b3b898a3792602c65764e5baa4f643714dfbdfd64ea271d210c7a425fe - languageName: node - linkType: hard - -"glob@npm:^9.3.2": - version: 9.3.5 - resolution: "glob@npm:9.3.5" - dependencies: - fs.realpath: "npm:^1.0.0" - minimatch: "npm:^8.0.2" - minipass: "npm:^4.2.4" - path-scurry: "npm:^1.6.1" - checksum: 10c0/2f6c2b9ee019ee21dc258ae97a88719614591e4c979cb4580b1b9df6f0f778a3cb38b4bdaf18dfa584637ea10f89a3c5f2533a5e449cf8741514ad18b0951f2e + checksum: 10c0/100705eddbde6323e7b35e1d1ac28bcb58322095bd8e63a7d0bef1a2cdafe0d0f7922a981b2b48369a4f8c1b077be5c171804534c3509dfe950dde15fbe6d828 languageName: node linkType: hard @@ -10384,7 +10324,17 @@ __metadata: languageName: node linkType: hard -"import-fresh@npm:^3.2.1, import-fresh@npm:^3.3.0": +"import-fresh@npm:^3.2.1": + version: 3.3.1 + resolution: "import-fresh@npm:3.3.1" + dependencies: + parent-module: "npm:^1.0.0" + resolve-from: "npm:^4.0.0" + checksum: 10c0/bf8cc494872fef783249709385ae883b447e3eb09db0ebd15dcead7d9afe7224dad7bd7591c6b73b0b19b3c0f9640eb8ee884f01cfaf2887ab995b0b36a0cbec + languageName: node + linkType: hard + +"import-fresh@npm:^3.3.0": version: 3.3.0 resolution: "import-fresh@npm:3.3.0" dependencies: @@ -10408,17 +10358,7 @@ __metadata: languageName: node linkType: hard -"inflight@npm:^1.0.4": - version: 1.0.6 - resolution: "inflight@npm:1.0.6" - dependencies: - once: "npm:^1.3.0" - wrappy: "npm:1" - checksum: 10c0/7faca22584600a9dc5b9fca2cd5feb7135ac8c935449837b315676b4c90aa4f391ec4f42240178244b5a34e8bede1948627fda392ca3191522fc46b34e985ab2 - languageName: node - linkType: hard - -"inherits@npm:2, inherits@npm:^2.0.1, inherits@npm:^2.0.3, inherits@npm:^2.0.4, inherits@npm:~2.0.3, inherits@npm:~2.0.4": +"inherits@npm:^2.0.1, inherits@npm:^2.0.3, inherits@npm:^2.0.4, inherits@npm:~2.0.3, inherits@npm:~2.0.4": version: 2.0.4 resolution: "inherits@npm:2.0.4" checksum: 10c0/4e531f648b29039fb7426fb94075e6545faa1eb9fe83c29f0b6d9e7263aceb4289d2d4557db0d428188eeb449cc7c5e77b0a0b2c4e248ff2a65933a0dee49ef2 @@ -10948,14 +10888,14 @@ __metadata: languageName: node linkType: hard -"js-yaml@npm:^4.1.0": - version: 4.1.0 - resolution: "js-yaml@npm:4.1.0" +"js-yaml@npm:^4.1.1": + version: 4.1.1 + resolution: "js-yaml@npm:4.1.1" dependencies: argparse: "npm:^2.0.1" bin: js-yaml: bin/js-yaml.js - checksum: 10c0/184a24b4eaacfce40ad9074c64fd42ac83cf74d8c8cd137718d456ced75051229e5061b8633c3366b8aada17945a7a356b337828c19da92b51ae62126575018f + checksum: 10c0/561c7d7088c40a9bb53cc75becbfb1df6ae49b34b5e6e5a81744b14ae8667ec564ad2527709d1a6e7d5e5fa6d483aa0f373a50ad98d42fde368ec4a190d4fae7 languageName: node linkType: hard @@ -11606,13 +11546,6 @@ __metadata: languageName: node linkType: hard -"minipass@npm:^4.2.4": - version: 4.2.8 - resolution: "minipass@npm:4.2.8" - checksum: 10c0/4ea76b030d97079f4429d6e8a8affd90baf1b6a1898977c8ccce4701c5a2ba2792e033abc6709373f25c2c4d4d95440d9d5e9464b46b7b76ca44d2ce26d939ce - languageName: node - linkType: hard - "minipass@npm:^5.0.0 || ^6.0.2 || ^7.0.0, minipass@npm:^7.0.2, minipass@npm:^7.0.3, minipass@npm:^7.0.4, minipass@npm:^7.1.2": version: 7.1.2 resolution: "minipass@npm:7.1.2" @@ -11990,7 +11923,7 @@ __metadata: languageName: node linkType: hard -"once@npm:^1.3.0, once@npm:^1.4.0": +"once@npm:^1.4.0": version: 1.4.0 resolution: "once@npm:1.4.0" dependencies: @@ -12292,13 +12225,6 @@ __metadata: languageName: node linkType: hard -"path-is-absolute@npm:^1.0.0": - version: 1.0.1 - resolution: "path-is-absolute@npm:1.0.1" - checksum: 10c0/127da03c82172a2a50099cddbf02510c1791fc2cc5f7713ddb613a56838db1e8168b121a920079d052e0936c23005562059756d653b7c544c53185efe53be078 - languageName: node - linkType: hard - "path-key@npm:^3.1.0": version: 3.1.1 resolution: "path-key@npm:3.1.1" @@ -12320,7 +12246,7 @@ __metadata: languageName: node linkType: hard -"path-scurry@npm:^1.11.1, path-scurry@npm:^1.6.1": +"path-scurry@npm:^1.11.1": version: 1.11.1 resolution: "path-scurry@npm:1.11.1" dependencies: @@ -13005,12 +12931,12 @@ __metadata: languageName: node linkType: hard -"qs@npm:^6.12.3": - version: 6.14.0 - resolution: "qs@npm:6.14.0" +"qs@npm:^6.14.1": + version: 6.15.0 + resolution: "qs@npm:6.15.0" dependencies: side-channel: "npm:^1.1.0" - checksum: 10c0/8ea5d91bf34f440598ee389d4a7d95820e3b837d3fd9f433871f7924801becaa0cd3b3b4628d49a7784d06a8aea9bc4554d2b6d8d584e2d221dc06238a42909c + checksum: 10c0/ff341078a78a991d8a48b4524d52949211447b4b1ad907f489cac0770cbc346a28e47304455c0320e5fb000f8762d64b03331e3b71865f663bf351bcba8cdb4b languageName: node linkType: hard From bf8bf80417483d22dc0a9bce19e9affe332e3fcd Mon Sep 17 00:00:00 2001 From: Timo K Date: Fri, 13 Mar 2026 08:01:08 +0100 Subject: [PATCH 2/6] Revert "update qs, js-yaml, glob for security patches" This reverts commit 7da9bca08c5c50ae41478296837ab688658510ef. --- package.json | 5 +- yarn.lock | 164 +++++++++++++++++++++++++++++++++++++-------------- 2 files changed, 120 insertions(+), 49 deletions(-) diff --git a/package.json b/package.json index cc8a36eb..04a48dc2 100644 --- a/package.json +++ b/package.json @@ -140,10 +140,7 @@ "@livekit/components-core/rxjs": "^7.8.1", "@livekit/track-processors/@mediapipe/tasks-vision": "^0.10.18", "minimatch": "^10.2.3", - "tar": "^7.5.11", - "glob": "^10.5.0", - "qs": "^6.14.1", - "js-yaml": "^4.1.1" + "tar": "^7.5.11" }, "packageManager": "yarn@4.7.0" } diff --git a/yarn.lock b/yarn.lock index 12e1b857..a3f6b921 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2901,13 +2901,20 @@ __metadata: languageName: node linkType: hard -"@eslint-community/regexpp@npm:^4.12.2, @eslint-community/regexpp@npm:^4.6.1": +"@eslint-community/regexpp@npm:^4.12.2": version: 4.12.2 resolution: "@eslint-community/regexpp@npm:4.12.2" checksum: 10c0/fddcbc66851b308478d04e302a4d771d6917a0b3740dc351513c0da9ca2eab8a1adf99f5e0aa7ab8b13fa0df005c81adeee7e63a92f3effd7d367a163b721c2d languageName: node linkType: hard +"@eslint-community/regexpp@npm:^4.6.1": + version: 4.11.1 + resolution: "@eslint-community/regexpp@npm:4.11.1" + checksum: 10c0/fbcc1cb65ef5ed5b92faa8dc542e035269065e7ebcc0b39c81a4fe98ad35cfff20b3c8df048641de15a7757e07d69f85e2579c1a5055f993413ba18c055654f8 + languageName: node + linkType: hard + "@eslint/eslintrc@npm:^2.1.4": version: 2.1.4 resolution: "@eslint/eslintrc@npm:2.1.4" @@ -6211,9 +6218,9 @@ __metadata: linkType: hard "@ungap/structured-clone@npm:^1.2.0": - version: 1.3.0 - resolution: "@ungap/structured-clone@npm:1.3.0" - checksum: 10c0/0fc3097c2540ada1fc340ee56d58d96b5b536a2a0dab6e3ec17d4bfc8c4c86db345f61a375a8185f9da96f01c69678f836a2b57eeaa9e4b8eeafd26428e57b0a + version: 1.2.0 + resolution: "@ungap/structured-clone@npm:1.2.0" + checksum: 10c0/8209c937cb39119f44eb63cf90c0b73e7c754209a6411c707be08e50e29ee81356dca1a848a405c8bdeebfe2f5e4f831ad310ae1689eeef65e7445c090c6657d languageName: node linkType: hard @@ -6430,7 +6437,7 @@ __metadata: languageName: node linkType: hard -"acorn@npm:^8.16.0, acorn@npm:^8.9.0": +"acorn@npm:^8.16.0": version: 8.16.0 resolution: "acorn@npm:8.16.0" bin: @@ -6439,6 +6446,15 @@ __metadata: languageName: node linkType: hard +"acorn@npm:^8.9.0": + version: 8.12.1 + resolution: "acorn@npm:8.12.1" + bin: + acorn: bin/acorn + checksum: 10c0/51fb26cd678f914e13287e886da2d7021f8c2bc0ccc95e03d3e0447ee278dd3b40b9c57dc222acd5881adcf26f3edc40901a4953403232129e3876793cd17386 + languageName: node + linkType: hard + "agent-base@npm:6": version: 6.0.2 resolution: "agent-base@npm:6.0.2" @@ -6465,14 +6481,14 @@ __metadata: linkType: hard "ajv@npm:^6.12.4": - version: 6.14.0 - resolution: "ajv@npm:6.14.0" + version: 6.12.6 + resolution: "ajv@npm:6.12.6" dependencies: fast-deep-equal: "npm:^3.1.1" fast-json-stable-stringify: "npm:^2.0.0" json-schema-traverse: "npm:^0.4.1" uri-js: "npm:^4.2.2" - checksum: 10c0/a2bc39b0555dc9802c899f86990eb8eed6e366cddbf65be43d5aa7e4f3c4e1a199d5460fd7ca4fb3d864000dbbc049253b72faa83b3b30e641ca52cb29a68c22 + checksum: 10c0/41e23642cbe545889245b9d2a45854ebba51cda6c778ebced9649420d9205f2efb39cb43dbc41e358409223b1ea43303ae4839db682c848b891e4811da1a5a71 languageName: node linkType: hard @@ -7733,7 +7749,18 @@ __metadata: languageName: node linkType: hard -"cross-spawn@npm:^7.0.2, cross-spawn@npm:^7.0.6": +"cross-spawn@npm:^7.0.2": + version: 7.0.3 + resolution: "cross-spawn@npm:7.0.3" + dependencies: + path-key: "npm:^3.1.0" + shebang-command: "npm:^2.0.0" + which: "npm:^2.0.1" + checksum: 10c0/5738c312387081c98d69c98e105b6327b069197f864a60593245d64c8089c8a0a744e16349281210d56835bb9274130d825a78b2ad6853ca13cfbeffc0c31750 + languageName: node + linkType: hard + +"cross-spawn@npm:^7.0.6": version: 7.0.6 resolution: "cross-spawn@npm:7.0.6" dependencies: @@ -9281,16 +9308,7 @@ __metadata: languageName: node linkType: hard -"esquery@npm:^1.4.2, esquery@npm:^1.7.0": - version: 1.7.0 - resolution: "esquery@npm:1.7.0" - dependencies: - estraverse: "npm:^5.1.0" - checksum: 10c0/77d5173db450b66f3bc685d11af4c90cffeedb340f34a39af96d43509a335ce39c894fd79233df32d38f5e4e219fa0f7076f6ec90bae8320170ba082c0db4793 - languageName: node - linkType: hard - -"esquery@npm:^1.6.0": +"esquery@npm:^1.4.2, esquery@npm:^1.6.0": version: 1.6.0 resolution: "esquery@npm:1.6.0" dependencies: @@ -9299,6 +9317,15 @@ __metadata: languageName: node linkType: hard +"esquery@npm:^1.7.0": + version: 1.7.0 + resolution: "esquery@npm:1.7.0" + dependencies: + estraverse: "npm:^5.1.0" + checksum: 10c0/77d5173db450b66f3bc685d11af4c90cffeedb340f34a39af96d43509a335ce39c894fd79233df32d38f5e4e219fa0f7076f6ec90bae8320170ba082c0db4793 + languageName: node + linkType: hard + "esrecurse@npm:^4.3.0": version: 4.3.0 resolution: "esrecurse@npm:4.3.0" @@ -9673,6 +9700,13 @@ __metadata: languageName: node linkType: hard +"fs.realpath@npm:^1.0.0": + version: 1.0.0 + resolution: "fs.realpath@npm:1.0.0" + checksum: 10c0/444cf1291d997165dfd4c0d58b69f0e4782bfd9149fd72faa4fe299e68e0e93d6db941660b37dd29153bf7186672ececa3b50b7e7249477b03fdf850f287c948 + languageName: node + linkType: hard + "fsevents@npm:2.3.2": version: 2.3.2 resolution: "fsevents@npm:2.3.2" @@ -9865,9 +9899,9 @@ __metadata: languageName: node linkType: hard -"glob@npm:^10.5.0": - version: 10.5.0 - resolution: "glob@npm:10.5.0" +"glob@npm:^10.2.2, glob@npm:^10.3.10, glob@npm:^10.3.7": + version: 10.4.5 + resolution: "glob@npm:10.4.5" dependencies: foreground-child: "npm:^3.1.0" jackspeak: "npm:^3.1.2" @@ -9877,7 +9911,33 @@ __metadata: path-scurry: "npm:^1.11.1" bin: glob: dist/esm/bin.mjs - checksum: 10c0/100705eddbde6323e7b35e1d1ac28bcb58322095bd8e63a7d0bef1a2cdafe0d0f7922a981b2b48369a4f8c1b077be5c171804534c3509dfe950dde15fbe6d828 + checksum: 10c0/19a9759ea77b8e3ca0a43c2f07ecddc2ad46216b786bb8f993c445aee80d345925a21e5280c7b7c6c59e860a0154b84e4b2b60321fea92cd3c56b4a7489f160e + languageName: node + linkType: hard + +"glob@npm:^7.1.3, glob@npm:~7.2.0": + version: 7.2.3 + resolution: "glob@npm:7.2.3" + dependencies: + fs.realpath: "npm:^1.0.0" + inflight: "npm:^1.0.4" + inherits: "npm:2" + minimatch: "npm:^3.1.1" + once: "npm:^1.3.0" + path-is-absolute: "npm:^1.0.0" + checksum: 10c0/65676153e2b0c9095100fe7f25a778bf45608eeb32c6048cf307f579649bcc30353277b3b898a3792602c65764e5baa4f643714dfbdfd64ea271d210c7a425fe + languageName: node + linkType: hard + +"glob@npm:^9.3.2": + version: 9.3.5 + resolution: "glob@npm:9.3.5" + dependencies: + fs.realpath: "npm:^1.0.0" + minimatch: "npm:^8.0.2" + minipass: "npm:^4.2.4" + path-scurry: "npm:^1.6.1" + checksum: 10c0/2f6c2b9ee019ee21dc258ae97a88719614591e4c979cb4580b1b9df6f0f778a3cb38b4bdaf18dfa584637ea10f89a3c5f2533a5e449cf8741514ad18b0951f2e languageName: node linkType: hard @@ -10324,17 +10384,7 @@ __metadata: languageName: node linkType: hard -"import-fresh@npm:^3.2.1": - version: 3.3.1 - resolution: "import-fresh@npm:3.3.1" - dependencies: - parent-module: "npm:^1.0.0" - resolve-from: "npm:^4.0.0" - checksum: 10c0/bf8cc494872fef783249709385ae883b447e3eb09db0ebd15dcead7d9afe7224dad7bd7591c6b73b0b19b3c0f9640eb8ee884f01cfaf2887ab995b0b36a0cbec - languageName: node - linkType: hard - -"import-fresh@npm:^3.3.0": +"import-fresh@npm:^3.2.1, import-fresh@npm:^3.3.0": version: 3.3.0 resolution: "import-fresh@npm:3.3.0" dependencies: @@ -10358,7 +10408,17 @@ __metadata: languageName: node linkType: hard -"inherits@npm:^2.0.1, inherits@npm:^2.0.3, inherits@npm:^2.0.4, inherits@npm:~2.0.3, inherits@npm:~2.0.4": +"inflight@npm:^1.0.4": + version: 1.0.6 + resolution: "inflight@npm:1.0.6" + dependencies: + once: "npm:^1.3.0" + wrappy: "npm:1" + checksum: 10c0/7faca22584600a9dc5b9fca2cd5feb7135ac8c935449837b315676b4c90aa4f391ec4f42240178244b5a34e8bede1948627fda392ca3191522fc46b34e985ab2 + languageName: node + linkType: hard + +"inherits@npm:2, inherits@npm:^2.0.1, inherits@npm:^2.0.3, inherits@npm:^2.0.4, inherits@npm:~2.0.3, inherits@npm:~2.0.4": version: 2.0.4 resolution: "inherits@npm:2.0.4" checksum: 10c0/4e531f648b29039fb7426fb94075e6545faa1eb9fe83c29f0b6d9e7263aceb4289d2d4557db0d428188eeb449cc7c5e77b0a0b2c4e248ff2a65933a0dee49ef2 @@ -10888,14 +10948,14 @@ __metadata: languageName: node linkType: hard -"js-yaml@npm:^4.1.1": - version: 4.1.1 - resolution: "js-yaml@npm:4.1.1" +"js-yaml@npm:^4.1.0": + version: 4.1.0 + resolution: "js-yaml@npm:4.1.0" dependencies: argparse: "npm:^2.0.1" bin: js-yaml: bin/js-yaml.js - checksum: 10c0/561c7d7088c40a9bb53cc75becbfb1df6ae49b34b5e6e5a81744b14ae8667ec564ad2527709d1a6e7d5e5fa6d483aa0f373a50ad98d42fde368ec4a190d4fae7 + checksum: 10c0/184a24b4eaacfce40ad9074c64fd42ac83cf74d8c8cd137718d456ced75051229e5061b8633c3366b8aada17945a7a356b337828c19da92b51ae62126575018f languageName: node linkType: hard @@ -11546,6 +11606,13 @@ __metadata: languageName: node linkType: hard +"minipass@npm:^4.2.4": + version: 4.2.8 + resolution: "minipass@npm:4.2.8" + checksum: 10c0/4ea76b030d97079f4429d6e8a8affd90baf1b6a1898977c8ccce4701c5a2ba2792e033abc6709373f25c2c4d4d95440d9d5e9464b46b7b76ca44d2ce26d939ce + languageName: node + linkType: hard + "minipass@npm:^5.0.0 || ^6.0.2 || ^7.0.0, minipass@npm:^7.0.2, minipass@npm:^7.0.3, minipass@npm:^7.0.4, minipass@npm:^7.1.2": version: 7.1.2 resolution: "minipass@npm:7.1.2" @@ -11923,7 +11990,7 @@ __metadata: languageName: node linkType: hard -"once@npm:^1.4.0": +"once@npm:^1.3.0, once@npm:^1.4.0": version: 1.4.0 resolution: "once@npm:1.4.0" dependencies: @@ -12225,6 +12292,13 @@ __metadata: languageName: node linkType: hard +"path-is-absolute@npm:^1.0.0": + version: 1.0.1 + resolution: "path-is-absolute@npm:1.0.1" + checksum: 10c0/127da03c82172a2a50099cddbf02510c1791fc2cc5f7713ddb613a56838db1e8168b121a920079d052e0936c23005562059756d653b7c544c53185efe53be078 + languageName: node + linkType: hard + "path-key@npm:^3.1.0": version: 3.1.1 resolution: "path-key@npm:3.1.1" @@ -12246,7 +12320,7 @@ __metadata: languageName: node linkType: hard -"path-scurry@npm:^1.11.1": +"path-scurry@npm:^1.11.1, path-scurry@npm:^1.6.1": version: 1.11.1 resolution: "path-scurry@npm:1.11.1" dependencies: @@ -12931,12 +13005,12 @@ __metadata: languageName: node linkType: hard -"qs@npm:^6.14.1": - version: 6.15.0 - resolution: "qs@npm:6.15.0" +"qs@npm:^6.12.3": + version: 6.14.0 + resolution: "qs@npm:6.14.0" dependencies: side-channel: "npm:^1.1.0" - checksum: 10c0/ff341078a78a991d8a48b4524d52949211447b4b1ad907f489cac0770cbc346a28e47304455c0320e5fb000f8762d64b03331e3b71865f663bf351bcba8cdb4b + checksum: 10c0/8ea5d91bf34f440598ee389d4a7d95820e3b837d3fd9f433871f7924801becaa0cd3b3b4628d49a7784d06a8aea9bc4554d2b6d8d584e2d221dc06238a42909c languageName: node linkType: hard From 78240c2ec802ef3f7d2ee9f07ab41f9e8c517bf2 Mon Sep 17 00:00:00 2001 From: Timo K Date: Fri, 13 Mar 2026 07:59:49 +0100 Subject: [PATCH 3/6] update qs, js-yaml, glob for security patches --- package.json | 5 +- yarn.lock | 164 ++++++++++++++------------------------------------- 2 files changed, 49 insertions(+), 120 deletions(-) diff --git a/package.json b/package.json index 04a48dc2..cc8a36eb 100644 --- a/package.json +++ b/package.json @@ -140,7 +140,10 @@ "@livekit/components-core/rxjs": "^7.8.1", "@livekit/track-processors/@mediapipe/tasks-vision": "^0.10.18", "minimatch": "^10.2.3", - "tar": "^7.5.11" + "tar": "^7.5.11", + "glob": "^10.5.0", + "qs": "^6.14.1", + "js-yaml": "^4.1.1" }, "packageManager": "yarn@4.7.0" } diff --git a/yarn.lock b/yarn.lock index a3f6b921..12e1b857 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2901,20 +2901,13 @@ __metadata: languageName: node linkType: hard -"@eslint-community/regexpp@npm:^4.12.2": +"@eslint-community/regexpp@npm:^4.12.2, @eslint-community/regexpp@npm:^4.6.1": version: 4.12.2 resolution: "@eslint-community/regexpp@npm:4.12.2" checksum: 10c0/fddcbc66851b308478d04e302a4d771d6917a0b3740dc351513c0da9ca2eab8a1adf99f5e0aa7ab8b13fa0df005c81adeee7e63a92f3effd7d367a163b721c2d languageName: node linkType: hard -"@eslint-community/regexpp@npm:^4.6.1": - version: 4.11.1 - resolution: "@eslint-community/regexpp@npm:4.11.1" - checksum: 10c0/fbcc1cb65ef5ed5b92faa8dc542e035269065e7ebcc0b39c81a4fe98ad35cfff20b3c8df048641de15a7757e07d69f85e2579c1a5055f993413ba18c055654f8 - languageName: node - linkType: hard - "@eslint/eslintrc@npm:^2.1.4": version: 2.1.4 resolution: "@eslint/eslintrc@npm:2.1.4" @@ -6218,9 +6211,9 @@ __metadata: linkType: hard "@ungap/structured-clone@npm:^1.2.0": - version: 1.2.0 - resolution: "@ungap/structured-clone@npm:1.2.0" - checksum: 10c0/8209c937cb39119f44eb63cf90c0b73e7c754209a6411c707be08e50e29ee81356dca1a848a405c8bdeebfe2f5e4f831ad310ae1689eeef65e7445c090c6657d + version: 1.3.0 + resolution: "@ungap/structured-clone@npm:1.3.0" + checksum: 10c0/0fc3097c2540ada1fc340ee56d58d96b5b536a2a0dab6e3ec17d4bfc8c4c86db345f61a375a8185f9da96f01c69678f836a2b57eeaa9e4b8eeafd26428e57b0a languageName: node linkType: hard @@ -6437,7 +6430,7 @@ __metadata: languageName: node linkType: hard -"acorn@npm:^8.16.0": +"acorn@npm:^8.16.0, acorn@npm:^8.9.0": version: 8.16.0 resolution: "acorn@npm:8.16.0" bin: @@ -6446,15 +6439,6 @@ __metadata: languageName: node linkType: hard -"acorn@npm:^8.9.0": - version: 8.12.1 - resolution: "acorn@npm:8.12.1" - bin: - acorn: bin/acorn - checksum: 10c0/51fb26cd678f914e13287e886da2d7021f8c2bc0ccc95e03d3e0447ee278dd3b40b9c57dc222acd5881adcf26f3edc40901a4953403232129e3876793cd17386 - languageName: node - linkType: hard - "agent-base@npm:6": version: 6.0.2 resolution: "agent-base@npm:6.0.2" @@ -6481,14 +6465,14 @@ __metadata: linkType: hard "ajv@npm:^6.12.4": - version: 6.12.6 - resolution: "ajv@npm:6.12.6" + version: 6.14.0 + resolution: "ajv@npm:6.14.0" dependencies: fast-deep-equal: "npm:^3.1.1" fast-json-stable-stringify: "npm:^2.0.0" json-schema-traverse: "npm:^0.4.1" uri-js: "npm:^4.2.2" - checksum: 10c0/41e23642cbe545889245b9d2a45854ebba51cda6c778ebced9649420d9205f2efb39cb43dbc41e358409223b1ea43303ae4839db682c848b891e4811da1a5a71 + checksum: 10c0/a2bc39b0555dc9802c899f86990eb8eed6e366cddbf65be43d5aa7e4f3c4e1a199d5460fd7ca4fb3d864000dbbc049253b72faa83b3b30e641ca52cb29a68c22 languageName: node linkType: hard @@ -7749,18 +7733,7 @@ __metadata: languageName: node linkType: hard -"cross-spawn@npm:^7.0.2": - version: 7.0.3 - resolution: "cross-spawn@npm:7.0.3" - dependencies: - path-key: "npm:^3.1.0" - shebang-command: "npm:^2.0.0" - which: "npm:^2.0.1" - checksum: 10c0/5738c312387081c98d69c98e105b6327b069197f864a60593245d64c8089c8a0a744e16349281210d56835bb9274130d825a78b2ad6853ca13cfbeffc0c31750 - languageName: node - linkType: hard - -"cross-spawn@npm:^7.0.6": +"cross-spawn@npm:^7.0.2, cross-spawn@npm:^7.0.6": version: 7.0.6 resolution: "cross-spawn@npm:7.0.6" dependencies: @@ -9308,16 +9281,7 @@ __metadata: languageName: node linkType: hard -"esquery@npm:^1.4.2, esquery@npm:^1.6.0": - version: 1.6.0 - resolution: "esquery@npm:1.6.0" - dependencies: - estraverse: "npm:^5.1.0" - checksum: 10c0/cb9065ec605f9da7a76ca6dadb0619dfb611e37a81e318732977d90fab50a256b95fee2d925fba7c2f3f0523aa16f91587246693bc09bc34d5a59575fe6e93d2 - languageName: node - linkType: hard - -"esquery@npm:^1.7.0": +"esquery@npm:^1.4.2, esquery@npm:^1.7.0": version: 1.7.0 resolution: "esquery@npm:1.7.0" dependencies: @@ -9326,6 +9290,15 @@ __metadata: languageName: node linkType: hard +"esquery@npm:^1.6.0": + version: 1.6.0 + resolution: "esquery@npm:1.6.0" + dependencies: + estraverse: "npm:^5.1.0" + checksum: 10c0/cb9065ec605f9da7a76ca6dadb0619dfb611e37a81e318732977d90fab50a256b95fee2d925fba7c2f3f0523aa16f91587246693bc09bc34d5a59575fe6e93d2 + languageName: node + linkType: hard + "esrecurse@npm:^4.3.0": version: 4.3.0 resolution: "esrecurse@npm:4.3.0" @@ -9700,13 +9673,6 @@ __metadata: languageName: node linkType: hard -"fs.realpath@npm:^1.0.0": - version: 1.0.0 - resolution: "fs.realpath@npm:1.0.0" - checksum: 10c0/444cf1291d997165dfd4c0d58b69f0e4782bfd9149fd72faa4fe299e68e0e93d6db941660b37dd29153bf7186672ececa3b50b7e7249477b03fdf850f287c948 - languageName: node - linkType: hard - "fsevents@npm:2.3.2": version: 2.3.2 resolution: "fsevents@npm:2.3.2" @@ -9899,9 +9865,9 @@ __metadata: languageName: node linkType: hard -"glob@npm:^10.2.2, glob@npm:^10.3.10, glob@npm:^10.3.7": - version: 10.4.5 - resolution: "glob@npm:10.4.5" +"glob@npm:^10.5.0": + version: 10.5.0 + resolution: "glob@npm:10.5.0" dependencies: foreground-child: "npm:^3.1.0" jackspeak: "npm:^3.1.2" @@ -9911,33 +9877,7 @@ __metadata: path-scurry: "npm:^1.11.1" bin: glob: dist/esm/bin.mjs - checksum: 10c0/19a9759ea77b8e3ca0a43c2f07ecddc2ad46216b786bb8f993c445aee80d345925a21e5280c7b7c6c59e860a0154b84e4b2b60321fea92cd3c56b4a7489f160e - languageName: node - linkType: hard - -"glob@npm:^7.1.3, glob@npm:~7.2.0": - version: 7.2.3 - resolution: "glob@npm:7.2.3" - dependencies: - fs.realpath: "npm:^1.0.0" - inflight: "npm:^1.0.4" - inherits: "npm:2" - minimatch: "npm:^3.1.1" - once: "npm:^1.3.0" - path-is-absolute: "npm:^1.0.0" - checksum: 10c0/65676153e2b0c9095100fe7f25a778bf45608eeb32c6048cf307f579649bcc30353277b3b898a3792602c65764e5baa4f643714dfbdfd64ea271d210c7a425fe - languageName: node - linkType: hard - -"glob@npm:^9.3.2": - version: 9.3.5 - resolution: "glob@npm:9.3.5" - dependencies: - fs.realpath: "npm:^1.0.0" - minimatch: "npm:^8.0.2" - minipass: "npm:^4.2.4" - path-scurry: "npm:^1.6.1" - checksum: 10c0/2f6c2b9ee019ee21dc258ae97a88719614591e4c979cb4580b1b9df6f0f778a3cb38b4bdaf18dfa584637ea10f89a3c5f2533a5e449cf8741514ad18b0951f2e + checksum: 10c0/100705eddbde6323e7b35e1d1ac28bcb58322095bd8e63a7d0bef1a2cdafe0d0f7922a981b2b48369a4f8c1b077be5c171804534c3509dfe950dde15fbe6d828 languageName: node linkType: hard @@ -10384,7 +10324,17 @@ __metadata: languageName: node linkType: hard -"import-fresh@npm:^3.2.1, import-fresh@npm:^3.3.0": +"import-fresh@npm:^3.2.1": + version: 3.3.1 + resolution: "import-fresh@npm:3.3.1" + dependencies: + parent-module: "npm:^1.0.0" + resolve-from: "npm:^4.0.0" + checksum: 10c0/bf8cc494872fef783249709385ae883b447e3eb09db0ebd15dcead7d9afe7224dad7bd7591c6b73b0b19b3c0f9640eb8ee884f01cfaf2887ab995b0b36a0cbec + languageName: node + linkType: hard + +"import-fresh@npm:^3.3.0": version: 3.3.0 resolution: "import-fresh@npm:3.3.0" dependencies: @@ -10408,17 +10358,7 @@ __metadata: languageName: node linkType: hard -"inflight@npm:^1.0.4": - version: 1.0.6 - resolution: "inflight@npm:1.0.6" - dependencies: - once: "npm:^1.3.0" - wrappy: "npm:1" - checksum: 10c0/7faca22584600a9dc5b9fca2cd5feb7135ac8c935449837b315676b4c90aa4f391ec4f42240178244b5a34e8bede1948627fda392ca3191522fc46b34e985ab2 - languageName: node - linkType: hard - -"inherits@npm:2, inherits@npm:^2.0.1, inherits@npm:^2.0.3, inherits@npm:^2.0.4, inherits@npm:~2.0.3, inherits@npm:~2.0.4": +"inherits@npm:^2.0.1, inherits@npm:^2.0.3, inherits@npm:^2.0.4, inherits@npm:~2.0.3, inherits@npm:~2.0.4": version: 2.0.4 resolution: "inherits@npm:2.0.4" checksum: 10c0/4e531f648b29039fb7426fb94075e6545faa1eb9fe83c29f0b6d9e7263aceb4289d2d4557db0d428188eeb449cc7c5e77b0a0b2c4e248ff2a65933a0dee49ef2 @@ -10948,14 +10888,14 @@ __metadata: languageName: node linkType: hard -"js-yaml@npm:^4.1.0": - version: 4.1.0 - resolution: "js-yaml@npm:4.1.0" +"js-yaml@npm:^4.1.1": + version: 4.1.1 + resolution: "js-yaml@npm:4.1.1" dependencies: argparse: "npm:^2.0.1" bin: js-yaml: bin/js-yaml.js - checksum: 10c0/184a24b4eaacfce40ad9074c64fd42ac83cf74d8c8cd137718d456ced75051229e5061b8633c3366b8aada17945a7a356b337828c19da92b51ae62126575018f + checksum: 10c0/561c7d7088c40a9bb53cc75becbfb1df6ae49b34b5e6e5a81744b14ae8667ec564ad2527709d1a6e7d5e5fa6d483aa0f373a50ad98d42fde368ec4a190d4fae7 languageName: node linkType: hard @@ -11606,13 +11546,6 @@ __metadata: languageName: node linkType: hard -"minipass@npm:^4.2.4": - version: 4.2.8 - resolution: "minipass@npm:4.2.8" - checksum: 10c0/4ea76b030d97079f4429d6e8a8affd90baf1b6a1898977c8ccce4701c5a2ba2792e033abc6709373f25c2c4d4d95440d9d5e9464b46b7b76ca44d2ce26d939ce - languageName: node - linkType: hard - "minipass@npm:^5.0.0 || ^6.0.2 || ^7.0.0, minipass@npm:^7.0.2, minipass@npm:^7.0.3, minipass@npm:^7.0.4, minipass@npm:^7.1.2": version: 7.1.2 resolution: "minipass@npm:7.1.2" @@ -11990,7 +11923,7 @@ __metadata: languageName: node linkType: hard -"once@npm:^1.3.0, once@npm:^1.4.0": +"once@npm:^1.4.0": version: 1.4.0 resolution: "once@npm:1.4.0" dependencies: @@ -12292,13 +12225,6 @@ __metadata: languageName: node linkType: hard -"path-is-absolute@npm:^1.0.0": - version: 1.0.1 - resolution: "path-is-absolute@npm:1.0.1" - checksum: 10c0/127da03c82172a2a50099cddbf02510c1791fc2cc5f7713ddb613a56838db1e8168b121a920079d052e0936c23005562059756d653b7c544c53185efe53be078 - languageName: node - linkType: hard - "path-key@npm:^3.1.0": version: 3.1.1 resolution: "path-key@npm:3.1.1" @@ -12320,7 +12246,7 @@ __metadata: languageName: node linkType: hard -"path-scurry@npm:^1.11.1, path-scurry@npm:^1.6.1": +"path-scurry@npm:^1.11.1": version: 1.11.1 resolution: "path-scurry@npm:1.11.1" dependencies: @@ -13005,12 +12931,12 @@ __metadata: languageName: node linkType: hard -"qs@npm:^6.12.3": - version: 6.14.0 - resolution: "qs@npm:6.14.0" +"qs@npm:^6.14.1": + version: 6.15.0 + resolution: "qs@npm:6.15.0" dependencies: side-channel: "npm:^1.1.0" - checksum: 10c0/8ea5d91bf34f440598ee389d4a7d95820e3b837d3fd9f433871f7924801becaa0cd3b3b4628d49a7784d06a8aea9bc4554d2b6d8d584e2d221dc06238a42909c + checksum: 10c0/ff341078a78a991d8a48b4524d52949211447b4b1ad907f489cac0770cbc346a28e47304455c0320e5fb000f8762d64b03331e3b71865f663bf351bcba8cdb4b languageName: node linkType: hard From 6d14f1d06ff007ce8a98f8e4e467fbb650da3796 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sat, 14 Mar 2026 14:48:44 +0000 Subject: [PATCH 4/6] Update GitHub Actions (#3804) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/build-and-publish-docker.yaml | 2 +- .github/workflows/zizmor.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build-and-publish-docker.yaml b/.github/workflows/build-and-publish-docker.yaml index 6447c094..3b18b133 100644 --- a/.github/workflows/build-and-publish-docker.yaml +++ b/.github/workflows/build-and-publish-docker.yaml @@ -44,7 +44,7 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Connect to Tailscale - uses: tailscale/github-action@53acf823325fe9ca47f4cdaa951f90b4b0de5bb9 # v4 + uses: tailscale/github-action@306e68a486fd2350f2bfc3b19fcd143891a4a2d8 # v4 if: github.event_name != 'pull_request' with: oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }} diff --git a/.github/workflows/zizmor.yml b/.github/workflows/zizmor.yml index 612adbd1..d3b6e969 100644 --- a/.github/workflows/zizmor.yml +++ b/.github/workflows/zizmor.yml @@ -20,4 +20,4 @@ jobs: persist-credentials: false - name: Run zizmor 🌈 - uses: zizmorcore/zizmor-action@0dce2577a4760a2749d8cfb7a84b7d5585ebcb7d # v0.5.0 + uses: zizmorcore/zizmor-action@71321a20a9ded102f6e9ce5718a2fcec2c4f70d8 # v0.5.2 From 9dfade68eed85e8a6df6c4ca1664b897fbdf1de7 Mon Sep 17 00:00:00 2001 From: Robin Date: Mon, 16 Mar 2026 13:12:49 +0100 Subject: [PATCH 5/6] New ringing UI This implements the new ringing UI by showing a placeholder tile for the participant being dialed, rather than an overlay. --- locales/en/app.json | 2 + playwright/widget/voice-call-dm.spec.ts | 21 +- src/grid/OneOnOneLayout.tsx | 8 +- src/room/InCallView.tsx | 76 ++--- src/room/WaitingForJoin.module.css | 61 ---- .../CallNotificationLifecycle.ts | 11 +- src/state/CallViewModel/CallViewModel.test.ts | 174 +++++------ src/state/CallViewModel/CallViewModel.ts | 274 ++++++++++-------- .../CallViewModel/CallViewModelTestUtils.ts | 22 +- .../remoteMembers/MatrixMemberMetadata.ts | 25 -- src/state/OneOnOneLayout.ts | 8 +- src/state/TileStore.ts | 19 +- src/state/TileViewModel.ts | 7 +- src/state/layout-types.ts | 10 +- src/state/media/MediaViewModel.ts | 6 +- src/state/media/MemberMediaViewModel.ts | 13 +- src/state/media/RingingMediaViewModel.ts | 51 ++++ src/state/media/ScreenShareViewModel.ts | 4 +- src/state/media/UserMediaViewModel.ts | 4 +- ...iaItem.ts => WrappedUserMediaViewModel.ts} | 2 - src/tile/GridTile.test.tsx | 89 ++++-- src/tile/GridTile.tsx | 70 ++++- src/tile/MediaView.module.css | 28 +- src/tile/MediaView.test.tsx | 2 - src/tile/MediaView.tsx | 27 +- src/tile/SpotlightTile.test.tsx | 46 ++- src/tile/SpotlightTile.tsx | 121 ++++++-- 27 files changed, 703 insertions(+), 478 deletions(-) delete mode 100644 src/room/WaitingForJoin.module.css create mode 100644 src/state/media/RingingMediaViewModel.ts rename src/state/media/{MediaItem.ts => WrappedUserMediaViewModel.ts} (98%) diff --git a/locales/en/app.json b/locales/en/app.json index 9b1a5675..f5749cf7 100644 --- a/locales/en/app.json +++ b/locales/en/app.json @@ -249,6 +249,8 @@ "version": "{{productName}} version: {{version}}", "video_tile": { "always_show": "Always show", + "call_ended": "Call ended", + "calling": "Calling…", "camera_starting": "Video loading...", "collapse": "Collapse", "expand": "Expand", diff --git a/playwright/widget/voice-call-dm.spec.ts b/playwright/widget/voice-call-dm.spec.ts index 6a8473cf..cc0b4e53 100644 --- a/playwright/widget/voice-call-dm.spec.ts +++ b/playwright/widget/voice-call-dm.spec.ts @@ -34,9 +34,12 @@ widgetTest( .locator('iframe[title="Element Call"]') .contentFrame(); - // We should show a ringing overlay, let's check for that + // We should show a ringing tile, let's check for that await expect( - brooksFrame.getByText(`Waiting for ${whistler.displayName} to join…`), + brooksFrame + .getByTestId("videoTile") + .filter({ has: brooksFrame.getByText(whistler.displayName) }) + .filter({ has: brooksFrame.getByText("Calling…") }), ).toBeVisible(); await expect(whistler.page.getByText("Incoming voice call")).toBeVisible(); @@ -125,9 +128,12 @@ widgetTest( .locator('iframe[title="Element Call"]') .contentFrame(); - // We should show a ringing overlay, let's check for that + // We should show a ringing tile, let's check for that await expect( - brooksFrame.getByText(`Waiting for ${whistler.displayName} to join…`), + brooksFrame + .getByTestId("videoTile") + .filter({ has: brooksFrame.getByText(whistler.displayName) }) + .filter({ has: brooksFrame.getByText("Calling…") }), ).toBeVisible(); await expect(whistler.page.getByText("Incoming video call")).toBeVisible(); @@ -216,9 +222,12 @@ widgetTest( .locator('iframe[title="Element Call"]') .contentFrame(); - // We should show a ringing overlay, let's check for that + // We should show a ringing tile, let's check for that await expect( - brooksFrame.getByText(`Waiting for ${whistler.displayName} to join…`), + brooksFrame + .getByTestId("videoTile") + .filter({ has: brooksFrame.getByText(whistler.displayName) }) + .filter({ has: brooksFrame.getByText("Calling…") }), ).toBeVisible(); await expect(whistler.page.getByText("Incoming video call")).toBeVisible(); diff --git a/src/grid/OneOnOneLayout.tsx b/src/grid/OneOnOneLayout.tsx index 6c5ae69f..fd9c0a65 100644 --- a/src/grid/OneOnOneLayout.tsx +++ b/src/grid/OneOnOneLayout.tsx @@ -51,15 +51,15 @@ export const makeOneOnOneLayout: CallLayout = ({ return (
= ({ const { showControls } = useUrlParams(); const muteAllAudio = useBehavior(muteAllAudio$); - // Call pickup state and display names are needed for waiting overlay/sounds - const callPickupState = useBehavior(vm.callPickupState$); // Preload a waiting and decline sounds const pickupPhaseSoundCache = useInitial(async () => { @@ -239,6 +236,7 @@ export const InCallView: FC = ({ latencyHint: "interactive", muted: muteAllAudio, }); + const latestPickupPhaseAudio = useLatest(pickupPhaseAudio); const audioEnabled = useBehavior(muteStates.audio.enabled$); const videoEnabled = useBehavior(muteStates.video.enabled$); @@ -257,6 +255,7 @@ export const InCallView: FC = ({ () => void toggleRaisedHand(), ); + const ringing = useBehavior(vm.ringing$); const audioParticipants = useBehavior(vm.livekitRoomItems$); const participantCount = useBehavior(vm.participantCount$); const reconnecting = useBehavior(vm.reconnecting$); @@ -271,7 +270,6 @@ export const InCallView: FC = ({ const audioOutputSwitcher = useBehavior(vm.audioOutputSwitcher$); const sharingScreen = useBehavior(vm.sharingScreen$); - const ringOverlay = useBehavior(vm.ringOverlay$); const fatalCallError = useBehavior(vm.fatalError$); // Stop the rendering and throw for the error boundary if (fatalCallError) { @@ -279,58 +277,21 @@ export const InCallView: FC = ({ throw fatalCallError; } - // We need to set the proper timings on the animation based upon the sound length. - const ringDuration = pickupPhaseAudio?.soundDuration["waiting"] ?? 1; - useEffect((): (() => void) => { - // The CSS animation includes the delay, so we must double the length of the sound. - window.document.body.style.setProperty( - "--call-ring-duration-s", - `${ringDuration * 2}s`, - ); - window.document.body.style.setProperty( - "--call-ring-delay-s", - `${ringDuration}s`, - ); - // Remove properties when we unload. - return () => { - window.document.body.style.removeProperty("--call-ring-duration-s"); - window.document.body.style.removeProperty("--call-ring-delay-s"); - }; - }, [pickupPhaseAudio?.soundDuration, ringDuration]); - - // When waiting for pickup, loop a waiting sound + // While ringing, loop the ringtone useEffect((): void | (() => void) => { - if (callPickupState !== "ringing" || !pickupPhaseAudio) return; - const endSound = pickupPhaseAudio.playSoundLooping("waiting", ringDuration); - return () => { - void endSound().catch((e) => { - logger.error("Failed to stop ringing sound", e); - }); - }; - }, [callPickupState, pickupPhaseAudio, ringDuration]); - - // Waiting UI overlay - const waitingOverlay: JSX.Element | null = useMemo(() => { - return ringOverlay ? ( -
-
-
- -
- - {ringOverlay.text} - -
-
- ) : null; - }, [ringOverlay]); + const audio = latestPickupPhaseAudio.current; + if (ringing && audio) { + const endSound = audio.playSoundLooping( + "waiting", + audio.soundDuration["waiting"] ?? 1, + ); + return () => { + void endSound().catch((e) => { + logger.error("Failed to stop ringing sound", e); + }); + }; + } + }, [ringing, latestPickupPhaseAudio]); const onViewClick = useCallback( (e: ReactMouseEvent) => { @@ -764,7 +725,6 @@ export const InCallView: FC = ({ {reconnectingToast} {earpieceOverlay} - {waitingOverlay} {footer} {layout.type !== "pip" && ( <> diff --git a/src/room/WaitingForJoin.module.css b/src/room/WaitingForJoin.module.css deleted file mode 100644 index a598e482..00000000 --- a/src/room/WaitingForJoin.module.css +++ /dev/null @@ -1,61 +0,0 @@ -.overlay { - position: absolute; - inset: 0; - display: flex; - align-items: center; - justify-content: center; - pointer-events: none; -} - -.content { - display: flex; - flex-direction: column; - align-items: center; - gap: 16px; -} - -.pulse { - position: relative; - height: 90px; -} - -.pulse::before { - content: ""; - position: absolute; - inset: -12px; - border-radius: 9999px; - border: 12px solid rgba(255, 255, 255, 0.6); - animation: pulse var(--call-ring-duration-s) ease-out infinite; - animation-delay: 1s; - opacity: 0; -} - -.text { - color: var(--cpd-color-text-on-solid-primary); -} - -@keyframes pulse { - 0% { - transform: scale(0.95); - opacity: 0.7; - transform: scale(0); - opacity: 1; - } - 35% { - transform: scale(1.15); - opacity: 0.15; - } - 50% { - transform: scale(1.2); - opacity: 0; - } - 50.01% { - transform: scale(0); - } - 85% { - transform: scale(0); - } - 100% { - transform: scale(0); - } -} diff --git a/src/state/CallViewModel/CallNotificationLifecycle.ts b/src/state/CallViewModel/CallNotificationLifecycle.ts index 44ce2e43..3e06108f 100644 --- a/src/state/CallViewModel/CallNotificationLifecycle.ts +++ b/src/state/CallViewModel/CallNotificationLifecycle.ts @@ -89,7 +89,6 @@ export interface Props { * `callPickupState$` The current call pickup state of the call. * - "unknown": The client has not yet sent the notification event. We don't know if it will because it first needs to send its own membership. * Then we can conclude if we were the first one to join or not. - * This may also be set if we are disconnected. * - "ringing": The call is ringing on other devices in this room (This client should give audiovisual feedback that this is happening). * - "timeout": No-one picked up in the defined time this call should be ringing on others devices. * The call failed. If desired this can be used as a trigger to exit the call. @@ -131,15 +130,9 @@ export function createCallNotificationLifecycle$({ ) as Behavior>; /** - * Whenever the RTC session tells us that it intends to ring the remote - * participant's devices, this emits an Observable tracking the current state of - * that ringing process. + * The state of the current ringing attempt, if the RTC session is indeed + * ringing the remote participant's devices. Otherwise `null`. */ - // This is a behavior since we need to store the latest state for when we subscribe to this after `didSendCallNotification$` - // has already emitted but we still need the latest observable with a timeout timer that only gets created on after receiving `notificationEvent`. - // A behavior will emit the latest observable with the running timer to new subscribers. - // see also: callPickupState$ and in particular the line: `return this.ring$.pipe(mergeAll());` here we otherwise might get an EMPTY observable if - // `ring$` would not be a behavior. const remoteRingState$: Behavior<"ringing" | "timeout" | "decline" | null> = scope.behavior( sentCallNotification$.pipe( diff --git a/src/state/CallViewModel/CallViewModel.test.ts b/src/state/CallViewModel/CallViewModel.test.ts index 5ee679b0..aca3ee7b 100644 --- a/src/state/CallViewModel/CallViewModel.test.ts +++ b/src/state/CallViewModel/CallViewModel.test.ts @@ -46,9 +46,11 @@ import { } from "../../utils/test.ts"; import { E2eeType } from "../../e2ee/e2eeType.ts"; import { + alice, aliceId, aliceParticipant, aliceRtcMember, + aliceUserId, bobId, bobRtcMember, local, @@ -140,8 +142,8 @@ export interface SpotlightExpandedLayoutSummary { export interface OneOnOneLayoutSummary { type: "one-on-one"; - local: string; - remote: string; + spotlight: string; + pip: string; } export interface PipLayoutSummary { @@ -194,11 +196,11 @@ function summarizeLayout$(l$: Observable): Observable { ); case "one-on-one": return combineLatest( - [l.local.media$, l.remote.media$], - (local, remote) => ({ + [l.spotlight.media$, l.pip.media$], + (spotlight, pip) => ({ type: l.type, - local: local.id, - remote: remote.id, + spotlight: spotlight.id, + pip: pip.id, }), ); case "pip": @@ -537,8 +539,8 @@ describe.each([ b: { // In a larger window, expect the normal one-on-one layout type: "one-on-one", - local: `${localId}:0`, - remote: `${aliceId}:0`, + pip: `${localId}:0`, + spotlight: `${aliceId}:0`, }, c: { // In a PiP-sized window, we of course expect a PiP layout @@ -840,8 +842,8 @@ describe.each([ }, b: { type: "one-on-one", - local: `${localId}:0`, - remote: `${aliceId}:0`, + pip: `${localId}:0`, + spotlight: `${aliceId}:0`, }, c: { type: "grid", @@ -883,8 +885,8 @@ describe.each([ }, b: { type: "one-on-one", - local: `${localId}:0`, - remote: `${aliceId}:0`, + pip: `${localId}:0`, + spotlight: `${aliceId}:0`, }, c: { type: "grid", @@ -893,8 +895,8 @@ describe.each([ }, d: { type: "one-on-one", - local: `${localId}:0`, - remote: `${daveId}:0`, + pip: `${localId}:0`, + spotlight: `${daveId}:0`, }, }, ); @@ -1087,83 +1089,81 @@ describe.each([ }); }); - describe("waitForCallPickup$", () => { - it.skip("regression test: does stop ringing in case livekitConnectionState$ emits after didSendCallNotification$ has already emitted", () => { - withTestScheduler(({ schedule, expectObservable, behavior }) => { - withCallViewModel( - { - livekitConnectionState$: behavior("d 9ms c", { - d: ConnectionState.Disconnected, - c: ConnectionState.Connected, - }), - }, - (vm, rtcSession) => { - // Fire a call notification IMMEDIATELY (its important for this test, that this happens before the livekitConnectionState$ emits) - schedule("n", { - n: () => { - rtcSession.emit( - MatrixRTCSessionEvent.DidSendCallNotification, - mockRingEvent("$notif1", 30), - ); - }, - }); + test("recipient has placeholder tile while ringing or timed out", () => { + withTestScheduler(({ schedule, expectObservable }) => { + withCallViewModel( + { + roomMembers: [alice, local], // Simulate a DM + }, + (vm, rtcSession) => { + // Fire a ringing notification + schedule("n", { + n: () => { + rtcSession.emit( + MatrixRTCSessionEvent.DidSendCallNotification, + mockRingEvent("$notif1", 30), + ); + }, + }); - expectObservable(vm.callPickupState$).toBe("a 9ms b 19ms c", { - a: "unknown", - b: "ringing", - c: "timeout", - }); - }, - { - waitForCallPickup: true, - encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, - }, - ); - }); + // Should ring for 30ms and then time out + expectObservable(vm.ringing$).toBe("(ny) 26ms n", yesNo); + // Layout should show placeholder media for the participant we're + // ringing the entire time (even once timed out) + expectObservable(summarizeLayout$(vm.layout$)).toBe("a", { + a: { + type: "one-on-one", + spotlight: `${localId}:0`, + pip: `ringing:${aliceUserId}`, + }, + }); + }, + { waitForCallPickup: true }, + ); }); + }); - it.skip("ringing -> unknown if we get disconnected", () => { - withTestScheduler(({ behavior, schedule, expectObservable }) => { - const connectionState$ = new BehaviorSubject(ConnectionState.Connected); - // Someone joins at 20ms (both LiveKit participant and MatrixRTC member) - withCallViewModel( - { - remoteParticipants$: behavior("a 19ms b", { - a: [], - b: [aliceParticipant], - }), - rtcMembers$: behavior("a 19ms b", { - a: [localRtcMember], - b: [localRtcMember, aliceRtcMember], - }), - livekitConnectionState$: connectionState$, - }, - (vm, rtcSession) => { - // Notify at 5ms so we enter ringing, then get disconnected 5ms later - schedule(" 5ms r 5ms d", { - r: () => { - rtcSession.emit( - MatrixRTCSessionEvent.DidSendCallNotification, - mockRingEvent("$notif2", 100), - ); - }, - d: () => { - connectionState$.next(ConnectionState.Disconnected); - }, - }); + test("recipient's placeholder tile is replaced by their real tile once they answer", () => { + withTestScheduler(({ behavior, schedule, expectObservable }) => { + withCallViewModel( + { + // Alice answers after 20ms + rtcMembers$: behavior("a 20ms b", { + a: [localRtcMember], + b: [localRtcMember, aliceRtcMember], + }), + roomMembers: [alice, local], // Simulate a DM + }, + (vm, rtcSession) => { + // Fire a ringing notification + schedule("n", { + n: () => { + rtcSession.emit( + MatrixRTCSessionEvent.DidSendCallNotification, + mockRingEvent("$notif1", 30), + ); + }, + }); - expectObservable(vm.callPickupState$).toBe("a 4ms b 5ms c", { - a: "unknown", - b: "ringing", - c: "unknown", - }); - }, - { - waitForCallPickup: true, - encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, - }, - ); - }); + // Should ring until Alice joins + expectObservable(vm.ringing$).toBe("(ny) 17ms n", yesNo); + // Layout should show placeholder media for the participant we're + // ringing the entire time + expectObservable(summarizeLayout$(vm.layout$)).toBe("a 20ms b", { + a: { + type: "one-on-one", + spotlight: `${localId}:0`, + pip: `ringing:${aliceUserId}`, + }, + b: { + type: "one-on-one", + spotlight: `${aliceId}:0`, + pip: `${localId}:0`, + }, + }); + }, + { waitForCallPickup: true }, + ); }); }); diff --git a/src/state/CallViewModel/CallViewModel.ts b/src/state/CallViewModel/CallViewModel.ts index 18e49d0a..8b4d19fb 100644 --- a/src/state/CallViewModel/CallViewModel.ts +++ b/src/state/CallViewModel/CallViewModel.ts @@ -128,7 +128,6 @@ import { createSentCallNotification$, } from "./CallNotificationLifecycle.ts"; import { - createDMMember$, createMatrixMemberMetadata$, createRoomMembers$, } from "./remoteMembers/MatrixMemberMetadata.ts"; @@ -137,12 +136,17 @@ import { type Connection } from "./remoteMembers/Connection.ts"; import { createLayoutModeSwitch } from "./LayoutSwitch.ts"; import { createWrappedUserMedia, - type MediaItem, type WrappedUserMediaViewModel, -} from "../media/MediaItem.ts"; +} from "../media/WrappedUserMediaViewModel.ts"; import { type ScreenShareViewModel } from "../media/ScreenShareViewModel.ts"; import { type UserMediaViewModel } from "../media/UserMediaViewModel.ts"; import { type MediaViewModel } from "../media/MediaViewModel.ts"; +import { type LocalUserMediaViewModel } from "../media/LocalUserMediaViewModel.ts"; +import { type RemoteUserMediaViewModel } from "../media/RemoteUserMediaViewModel.ts"; +import { + createRingingMedia, + type RingingMediaViewModel, +} from "../media/RingingMediaViewModel.ts"; const logger = rootLogger.getChild("[CallViewModel]"); //TODO @@ -210,11 +214,10 @@ export type LivekitRoomItem = { export interface CallViewModel { // lifecycle autoLeave$: Observable; - // TODO if we are in "unknown" state we need a loading rendering (or empty screen) - // Otherwise it looks like we already connected and only than the ringing starts which is weird. - callPickupState$: Behavior< - "unknown" | "ringing" | "timeout" | "decline" | "success" | null - >; + /** + * Whether we are ringing a call recipient. + */ + ringing$: Behavior; /** Observable that emits when the user should leave the call (hangup pressed, widget action, error). * THIS DOES NOT LEAVE THE CALL YET. The only way to leave the call (send the hangup event) is * - by ending the scope @@ -289,13 +292,6 @@ export interface CallViewModel { /** List of reactions. Keys are: membership.membershipId (currently predefined as: `${membershipEvent.userId}:${membershipEvent.deviceId}`)*/ reactions$: Behavior>; - ringOverlay$: Behavior; // sounds and events joinSoundEffect$: Observable; leaveSoundEffect$: Observable; @@ -611,40 +607,6 @@ export function createCallViewModel$( matrixRoomMembers$, ); - const dmMember$ = createDMMember$(scope, matrixRoomMembers$, matrixRoom); - const noUserToCallInRoom$ = scope.behavior( - matrixRoomMembers$.pipe( - map( - (roomMembersMap) => - roomMembersMap.size === 1 && roomMembersMap.get(userId) !== undefined, - ), - ), - ); - - const ringOverlay$ = scope.behavior( - combineLatest([noUserToCallInRoom$, dmMember$, callPickupState$]).pipe( - map(([noUserToCallInRoom, dmMember, callPickupState]) => { - // No overlay if not in ringing state - if (callPickupState !== "ringing" || noUserToCallInRoom) return null; - - const name = dmMember ? dmMember.rawDisplayName : matrixRoom.name; - const id = dmMember ? dmMember.userId : matrixRoom.roomId; - const text = dmMember - ? `Waiting for ${name} to join…` - : "Waiting for other participants…"; - const avatarMxc = dmMember - ? (dmMember.getMxcAvatarUrl?.() ?? undefined) - : (matrixRoom.getMxcAvatarUrl() ?? undefined); - return { - name: name ?? id, - idForAvatar: id, - text, - avatarMxc, - }; - }), - ), - ); - const allConnections$ = scope.behavior( connectionManager.connectionManagerData$.pipe(map((d) => d.value)), ); @@ -720,7 +682,7 @@ export function createCallViewModel$( matrixLivekitMembers$, duplicateTiles.value$, ]).pipe( - // Generate a collection of MediaItems from the list of expected (whether + // Generate a collection of user media from the list of expected (whether // present or missing) LiveKit participants. generateItems( "CallViewModel userMedia$", @@ -793,32 +755,67 @@ export function createCallViewModel$( ), ); + const ringingMedia$ = scope.behavior( + combineLatest([userMedia$, matrixRoomMembers$, callPickupState$]).pipe( + generateItems( + "CallViewModel ringingMedia$", + function* ([userMedia, roomMembers, callPickupState]) { + if ( + callPickupState === "ringing" || + callPickupState === "timeout" || + callPickupState === "decline" + ) { + for (const member of roomMembers.values()) { + if (!userMedia.some((vm) => vm.userId === member.userId)) + yield { + keys: [member.userId], + data: callPickupState, + }; + } + } + }, + (scope, pickupState$, userId) => + createRingingMedia({ + id: `ringing:${userId}`, + userId, + displayName$: scope.behavior( + matrixRoomMembers$.pipe( + map((members) => members.get(userId)?.rawDisplayName || userId), + ), + ), + mxcAvatarUrl$: + matrixMemberMetadataStore.createAvatarUrlBehavior$(userId), + pickupState$, + muteStates, + }), + ), + distinctUntilChanged(shallowEquals), + tap((ringingMedia) => { + if (ringingMedia.length > 1) + // Warn that UI may do something unexpected in this case + logger.warn( + `Ringing more than one participant is not supported (ringing ${ringingMedia.map((vm) => vm.userId).join(", ")})`, + ); + }), + ), + ); + /** - * List of all media items (user media and screen share media) that we want - * tiles for. + * All screen share media that we want to display. */ - const mediaItems$ = scope.behavior( + const screenShares$ = scope.behavior( userMedia$.pipe( switchMap((userMedia) => userMedia.length === 0 ? of([]) : combineLatest( userMedia.map((m) => m.screenShares$), - (...screenShares) => [...userMedia, ...screenShares.flat(1)], + (...screenShares) => screenShares.flat(1), ), ), ), ); - /** - * List of MediaItems that we want to display, that are of type ScreenShare - */ - const screenShares$ = scope.behavior( - mediaItems$.pipe( - map((mediaItems) => mediaItems.filter((m) => m.type === "screen share")), - ), - ); - const joinSoundEffect$ = userMedia$.pipe( pairwise(), filter( @@ -931,40 +928,20 @@ export function createCallViewModel$( ), ); - const spotlight$ = scope.behavior( - screenShares$.pipe( - switchMap((screenShares) => { - if (screenShares.length > 0) return of(screenShares); - - return spotlightSpeaker$.pipe( - map((speaker) => (speaker ? [speaker] : [])), + /** + * Local user media suitable for displaying in a PiP (undefined if not found + * or if user prefers to not see themselves). + */ + const localUserMediaForPip$ = scope.behavior< + LocalUserMediaViewModel | undefined + >( + userMedia$.pipe( + switchMap((userMedia) => { + const localUserMedia = userMedia.find( + (m): m is WrappedUserMediaViewModel & LocalUserMediaViewModel => + m.type === "user" && m.local, ); - }), - distinctUntilChanged(shallowEquals), - ), - ); - - const pip$ = scope.behavior( - combineLatest([ - // TODO This also needs epoch logic to dedupe the screenshares and mediaItems emits - screenShares$, - spotlightSpeaker$, - mediaItems$, - ]).pipe( - switchMap(([screenShares, spotlight, mediaItems]) => { - if (screenShares.length > 0) { - return spotlightSpeaker$; - } - if (!spotlight || spotlight.local) { - return of(undefined); - } - - const localUserMedia = mediaItems.find( - (m) => m.type === "user" && m.local, - ); - if (!localUserMedia) { - return of(undefined); - } + if (!localUserMedia) return of(undefined); return localUserMedia.alwaysShow$.pipe( map((alwaysShow) => (alwaysShow ? localUserMedia : undefined)), ); @@ -972,6 +949,39 @@ export function createCallViewModel$( ), ); + const spotlightAndPip$ = scope.behavior<{ + spotlight: MediaViewModel[]; + pip$: Behavior; + }>( + ringingMedia$.pipe( + switchMap((ringingMedia) => { + if (ringingMedia.length > 0) + return of({ spotlight: ringingMedia, pip$: localUserMediaForPip$ }); + + return screenShares$.pipe( + switchMap((screenShares) => { + if (screenShares.length > 0) + return of({ spotlight: screenShares, pip$: spotlightSpeaker$ }); + + return spotlightSpeaker$.pipe( + map((speaker) => ({ + spotlight: speaker ? [speaker] : [], + pip$: localUserMediaForPip$, + })), + ); + }), + ); + }), + ), + ); + + const spotlight$ = scope.behavior( + spotlightAndPip$.pipe( + map(({ spotlight }) => spotlight), + distinctUntilChanged(shallowEquals), + ), + ); + const hasRemoteScreenShares$ = scope.behavior( spotlight$.pipe( map((spotlight) => @@ -1054,24 +1064,61 @@ export function createCallViewModel$( })); const spotlightExpandedLayoutMedia$: Observable = - combineLatest([spotlight$, pip$], (spotlight, pip) => ({ - type: "spotlight-expanded", - spotlight, - pip: pip ?? undefined, - })); + spotlightAndPip$.pipe( + switchMap(({ spotlight, pip$ }) => + pip$.pipe( + map((pip) => ({ + type: "spotlight-expanded" as const, + spotlight, + pip: pip ?? undefined, + })), + ), + ), + ); const oneOnOneLayoutMedia$: Observable = - mediaItems$.pipe( - map((mediaItems) => { - if (mediaItems.length !== 2) return null; - const local = mediaItems.find((vm) => vm.type === "user" && vm.local); - const remote = mediaItems.find((vm) => vm.type === "user" && !vm.local); - // There might not be a remote tile if there are screen shares, or if - // only the local user is in the call and they're using the duplicate - // tiles option - if (!remote || !local) return null; + userMedia$.pipe( + switchMap((userMedia) => { + if (userMedia.length <= 2) { + const local = userMedia.find( + (vm): vm is WrappedUserMediaViewModel & LocalUserMediaViewModel => + vm.type === "user" && vm.local, + ); - return { type: "one-on-one", local, remote }; + if (local !== undefined) { + const remote = userMedia.find( + ( + vm, + ): vm is WrappedUserMediaViewModel & RemoteUserMediaViewModel => + vm.type === "user" && !vm.local, + ); + + if (remote !== undefined) + return of({ + type: "one-on-one" as const, + spotlight: remote, + pip: local, + }); + + // If there's no other user media in the call (could still happen in + // this branch due to the duplicate tiles option), we could possibly + // show ringing media instead + if (userMedia.length === 1) + return ringingMedia$.pipe( + map((ringingMedia) => { + return ringingMedia.length === 1 + ? { + type: "one-on-one" as const, + spotlight: local, + pip: ringingMedia[0], + } + : null; + }), + ); + } + } + + return of(null); }), ); @@ -1482,8 +1529,9 @@ export function createCallViewModel$( return { autoLeave$: autoLeave$, - callPickupState$: callPickupState$, - ringOverlay$: ringOverlay$, + ringing$: scope.behavior( + callPickupState$.pipe(map((state) => state === "ringing")), + ), leave$: leave$, hangup: (): void => userHangup$.next(), join: localMembership.requestJoinAndPublish, diff --git a/src/state/CallViewModel/CallViewModelTestUtils.ts b/src/state/CallViewModel/CallViewModelTestUtils.ts index b6f53275..b6bf8a9a 100644 --- a/src/state/CallViewModel/CallViewModelTestUtils.ts +++ b/src/state/CallViewModel/CallViewModelTestUtils.ts @@ -17,7 +17,7 @@ import { import { SyncState } from "matrix-js-sdk/lib/sync"; import { BehaviorSubject, type Observable, map, of } from "rxjs"; import { onTestFinished, vi } from "vitest"; -import { ClientEvent, type MatrixClient } from "matrix-js-sdk"; +import { ClientEvent, type RoomMember, type MatrixClient } from "matrix-js-sdk"; import EventEmitter from "events"; import * as ComponentsCore from "@livekit/components-core"; @@ -63,15 +63,10 @@ const carol = local; const dave = mockMatrixRoomMember(daveRTLRtcMember, { rawDisplayName: "Dave" }); -const roomMembers = new Map( - [alice, aliceDoppelganger, bob, bobZeroWidthSpace, carol, dave, daveRTL].map( - (p) => [p.userId, p], - ), -); - export interface CallViewModelInputs { remoteParticipants$: Behavior; rtcMembers$: Behavior[]>; + roomMembers: RoomMember[]; livekitConnectionState$: Behavior; speaking: Map>; mediaDevices: MediaDevices; @@ -86,6 +81,15 @@ export function withCallViewModel(mode: MatrixRTCMode) { { remoteParticipants$ = constant([]), rtcMembers$ = constant([localRtcMember]), + roomMembers = [ + alice, + aliceDoppelganger, + bob, + bobZeroWidthSpace, + carol, + dave, + daveRTL, + ], livekitConnectionState$: connectionState$ = constant( ConnectionState.Connected, ), @@ -128,8 +132,8 @@ export function withCallViewModel(mode: MatrixRTCMode) { return syncState; } })() as Partial as MatrixClient, - getMembers: () => Array.from(roomMembers.values()), - getMembersWithMembership: () => Array.from(roomMembers.values()), + getMembers: () => roomMembers, + getMembersWithMembership: () => roomMembers, }); const rtcSession = new MockRTCSession(room, []).withMemberships( rtcMembers$, diff --git a/src/state/CallViewModel/remoteMembers/MatrixMemberMetadata.ts b/src/state/CallViewModel/remoteMembers/MatrixMemberMetadata.ts index c1a7a499..d9be2d35 100644 --- a/src/state/CallViewModel/remoteMembers/MatrixMemberMetadata.ts +++ b/src/state/CallViewModel/remoteMembers/MatrixMemberMetadata.ts @@ -54,31 +54,6 @@ export function createRoomMembers$( ); } -/** - * creates the member that this DM is with in case it is a DM (two members) otherwise null - */ -export function createDMMember$( - scope: ObservableScope, - roomMembers$: Behavior, - matrixRoom: MatrixRoom, -): Behavior | null> { - // We cannot use the normal direct check from matrix since we do not have access to the account data. - // use primitive member count === 2 check instead. - return scope.behavior( - roomMembers$.pipe( - map((membersMap) => { - // primitive appraoch do to no access to account data. - const isDM = membersMap.size === 2; - if (!isDM) return null; - return matrixRoom.getMember(matrixRoom.guessDMUserId()); - }), - ), - ); -} - /** * Displayname for each member of the call. This will disambiguate * any displayname that clashes with another member. Only members diff --git a/src/state/OneOnOneLayout.ts b/src/state/OneOnOneLayout.ts index 10268945..27fa4439 100644 --- a/src/state/OneOnOneLayout.ts +++ b/src/state/OneOnOneLayout.ts @@ -16,14 +16,14 @@ export function oneOnOneLayout( prevTiles: TileStore, ): [OneOnOneLayout, TileStore] { const update = prevTiles.from(2); - update.registerGridTile(media.local); - update.registerGridTile(media.remote); + update.registerGridTile(media.pip); + update.registerGridTile(media.spotlight); const tiles = update.build(); return [ { type: media.type, - local: tiles.gridTilesByMedia.get(media.local)!, - remote: tiles.gridTilesByMedia.get(media.remote)!, + spotlight: tiles.gridTilesByMedia.get(media.spotlight)!, + pip: tiles.gridTilesByMedia.get(media.pip)!, }, tiles, ]; diff --git a/src/state/TileStore.ts b/src/state/TileStore.ts index a954eb4e..300e6bd2 100644 --- a/src/state/TileStore.ts +++ b/src/state/TileStore.ts @@ -13,6 +13,7 @@ import { fillGaps } from "../utils/iter"; import { debugTileLayout } from "../settings/settings"; import { type MediaViewModel } from "./media/MediaViewModel"; import { type UserMediaViewModel } from "./media/UserMediaViewModel"; +import { type RingingMediaViewModel } from "./media/RingingMediaViewModel"; function debugEntries(entries: GridTileData[]): string[] { return entries.map((e) => e.media.displayName$.value); @@ -48,8 +49,10 @@ class SpotlightTileData { } class GridTileData { - private readonly media$: BehaviorSubject; - public get media(): UserMediaViewModel { + private readonly media$: BehaviorSubject< + UserMediaViewModel | RingingMediaViewModel + >; + public get media(): UserMediaViewModel | RingingMediaViewModel { return this.media$.value; } public set media(value: UserMediaViewModel) { @@ -58,7 +61,7 @@ class GridTileData { public readonly vm: GridTileViewModel; - public constructor(media: UserMediaViewModel) { + public constructor(media: UserMediaViewModel | RingingMediaViewModel) { this.media$ = new BehaviorSubject(media); this.vm = new GridTileViewModel(this.media$); } @@ -178,7 +181,9 @@ export class TileStoreBuilder { * Sets up a grid tile for the given media. If this is never called for some * media, then that media will have no grid tile. */ - public registerGridTile(media: UserMediaViewModel): void { + public registerGridTile( + media: UserMediaViewModel | RingingMediaViewModel, + ): void { if (DEBUG_ENABLED) logger.debug( `[TileStore, ${this.generation}] register grid tile: ${media.displayName$.value}`, @@ -187,7 +192,11 @@ export class TileStoreBuilder { if (this.spotlight !== null) { // We actually *don't* want spotlight speakers to appear in both the // spotlight and the grid, so they're filtered out here - if (!media.local && this.spotlight.media.includes(media)) return; + if ( + !(media.type === "user" && media.local) && + this.spotlight.media.includes(media) + ) + return; // When the spotlight speaker changes, we would see one grid tile appear // and another grid tile disappear. This would be an undesirable layout // shift, so instead what we do is take the speaker's grid tile and swap diff --git a/src/state/TileViewModel.ts b/src/state/TileViewModel.ts index 8b13c685..eeec0c88 100644 --- a/src/state/TileViewModel.ts +++ b/src/state/TileViewModel.ts @@ -7,6 +7,7 @@ Please see LICENSE in the repository root for full details. import { type Behavior } from "./Behavior"; import { type MediaViewModel } from "./media/MediaViewModel"; +import { type RingingMediaViewModel } from "./media/RingingMediaViewModel"; import { type UserMediaViewModel } from "./media/UserMediaViewModel"; let nextId = 0; @@ -17,7 +18,11 @@ function createId(): string { export class GridTileViewModel { public readonly id = createId(); - public constructor(public readonly media$: Behavior) {} + public constructor( + public readonly media$: Behavior< + UserMediaViewModel | RingingMediaViewModel + >, + ) {} } export class SpotlightTileViewModel { diff --git a/src/state/layout-types.ts b/src/state/layout-types.ts index 33796f66..2e779057 100644 --- a/src/state/layout-types.ts +++ b/src/state/layout-types.ts @@ -7,7 +7,7 @@ Please see LICENSE in the repository root for full details. import { type LocalUserMediaViewModel } from "./media/LocalUserMediaViewModel.ts"; import { type MediaViewModel } from "./media/MediaViewModel.ts"; -import { type RemoteUserMediaViewModel } from "./media/RemoteUserMediaViewModel.ts"; +import { type RingingMediaViewModel } from "./media/RingingMediaViewModel.ts"; import { type UserMediaViewModel } from "./media/UserMediaViewModel.ts"; import { type GridTileViewModel, @@ -40,8 +40,8 @@ export interface SpotlightExpandedLayoutMedia { export interface OneOnOneLayoutMedia { type: "one-on-one"; - local: LocalUserMediaViewModel; - remote: RemoteUserMediaViewModel; + spotlight: UserMediaViewModel; + pip: LocalUserMediaViewModel | RingingMediaViewModel; } export interface PipLayoutMedia { @@ -86,8 +86,8 @@ export interface SpotlightExpandedLayout { export interface OneOnOneLayout { type: "one-on-one"; - local: GridTileViewModel; - remote: GridTileViewModel; + spotlight: GridTileViewModel; + pip: GridTileViewModel; } export interface PipLayout { diff --git a/src/state/media/MediaViewModel.ts b/src/state/media/MediaViewModel.ts index bdc4875b..9a253d81 100644 --- a/src/state/media/MediaViewModel.ts +++ b/src/state/media/MediaViewModel.ts @@ -7,13 +7,17 @@ Please see LICENSE in the repository root for full details. */ import { type Behavior } from "../Behavior"; +import { type RingingMediaViewModel } from "./RingingMediaViewModel"; import { type ScreenShareViewModel } from "./ScreenShareViewModel"; import { type UserMediaViewModel } from "./UserMediaViewModel"; /** * A participant's media. */ -export type MediaViewModel = UserMediaViewModel | ScreenShareViewModel; +export type MediaViewModel = + | UserMediaViewModel + | ScreenShareViewModel + | RingingMediaViewModel; /** * Properties which are common to all MediaViewModels. diff --git a/src/state/media/MemberMediaViewModel.ts b/src/state/media/MemberMediaViewModel.ts index e7f57b59..969da899 100644 --- a/src/state/media/MemberMediaViewModel.ts +++ b/src/state/media/MemberMediaViewModel.ts @@ -38,6 +38,8 @@ import { type ObservableScope } from "../ObservableScope"; import { observeTrackReference$ } from "../observeTrackReference"; import { E2eeType } from "../../e2ee/e2eeType"; import { observeInboundRtpStreamStats$ } from "./observeRtpStreamStats"; +import { type UserMediaViewModel } from "./UserMediaViewModel"; +import { type ScreenShareViewModel } from "./ScreenShareViewModel"; // TODO: Encryption status is kinda broken and thus unused right now. Remove? export enum EncryptionStatus { @@ -49,9 +51,9 @@ export enum EncryptionStatus { } /** - * Media belonging to an active member of the RTC session. + * Properties common to all MemberMediaViewModels. */ -export interface MemberMediaViewModel extends BaseMediaViewModel { +export interface BaseMemberMediaViewModel extends BaseMediaViewModel { /** * The LiveKit video track for this media. */ @@ -88,7 +90,7 @@ export function createMemberMedia( encryptionSystem, ...inputs }: MemberMediaInputs, -): MemberMediaViewModel { +): BaseMemberMediaViewModel { const trackBehavior$ = ( source: Track.Source, ): Behavior => @@ -270,3 +272,8 @@ function observeRemoteTrackReceivingOkay$( startWith(undefined), ); } + +/** + * Media belonging to an active member of the call. + */ +export type MemberMediaViewModel = UserMediaViewModel | ScreenShareViewModel; diff --git a/src/state/media/RingingMediaViewModel.ts b/src/state/media/RingingMediaViewModel.ts new file mode 100644 index 00000000..23291723 --- /dev/null +++ b/src/state/media/RingingMediaViewModel.ts @@ -0,0 +1,51 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { type Behavior } from "../Behavior"; +import { type MuteStates } from "../MuteStates"; +import { + type BaseMediaInputs, + type BaseMediaViewModel, + createBaseMedia, +} from "./MediaViewModel"; + +/** + * Media representing a user who is not yet part of the call — one that we are + * *ringing*. + */ +export interface RingingMediaViewModel extends BaseMediaViewModel { + type: "ringing"; + pickupState$: Behavior<"ringing" | "timeout" | "decline">; + /** + * Whether this media would be expected to have video, were it not simply a + * placeholder. + */ + videoEnabled$: Behavior; +} + +export interface RingingMediaInputs extends BaseMediaInputs { + pickupState$: Behavior<"ringing" | "timeout" | "decline">; + /** + * The local user's own mute states. + */ + muteStates: MuteStates; +} + +export function createRingingMedia({ + pickupState$, + muteStates, + ...inputs +}: RingingMediaInputs): RingingMediaViewModel { + return { + ...createBaseMedia(inputs), + type: "ringing", + pickupState$, + // If our own video is enabled, then this is a video call and we would + // expect remote media to have video as well + videoEnabled$: muteStates.video.enabled$, + }; +} diff --git a/src/state/media/ScreenShareViewModel.ts b/src/state/media/ScreenShareViewModel.ts index 36cd9440..8336f0a6 100644 --- a/src/state/media/ScreenShareViewModel.ts +++ b/src/state/media/ScreenShareViewModel.ts @@ -13,7 +13,7 @@ import { type LocalScreenShareViewModel } from "./LocalScreenShareViewModel"; import { createMemberMedia, type MemberMediaInputs, - type MemberMediaViewModel, + type BaseMemberMediaViewModel, } from "./MemberMediaViewModel"; import { type RemoteScreenShareViewModel } from "./RemoteScreenShareViewModel"; @@ -27,7 +27,7 @@ export type ScreenShareViewModel = /** * Properties which are common to all ScreenShareViewModels. */ -export interface BaseScreenShareViewModel extends MemberMediaViewModel { +export interface BaseScreenShareViewModel extends BaseMemberMediaViewModel { type: "screen share"; } diff --git a/src/state/media/UserMediaViewModel.ts b/src/state/media/UserMediaViewModel.ts index 16af7f26..a20c489e 100644 --- a/src/state/media/UserMediaViewModel.ts +++ b/src/state/media/UserMediaViewModel.ts @@ -27,7 +27,7 @@ import { type LocalUserMediaViewModel } from "./LocalUserMediaViewModel"; import { createMemberMedia, type MemberMediaInputs, - type MemberMediaViewModel, + type BaseMemberMediaViewModel, } from "./MemberMediaViewModel"; import { type RemoteUserMediaViewModel } from "./RemoteUserMediaViewModel"; import { type ObservableScope } from "../ObservableScope"; @@ -42,7 +42,7 @@ export type UserMediaViewModel = | LocalUserMediaViewModel | RemoteUserMediaViewModel; -export interface BaseUserMediaViewModel extends MemberMediaViewModel { +export interface BaseUserMediaViewModel extends BaseMemberMediaViewModel { type: "user"; speaking$: Behavior; audioEnabled$: Behavior; diff --git a/src/state/media/MediaItem.ts b/src/state/media/WrappedUserMediaViewModel.ts similarity index 98% rename from src/state/media/MediaItem.ts rename to src/state/media/WrappedUserMediaViewModel.ts index 6cd80045..e9575d0c 100644 --- a/src/state/media/MediaItem.ts +++ b/src/state/media/WrappedUserMediaViewModel.ts @@ -194,5 +194,3 @@ export function createWrappedUserMedia( ), }; } - -export type MediaItem = WrappedUserMediaViewModel | ScreenShareViewModel; diff --git a/src/tile/GridTile.test.tsx b/src/tile/GridTile.test.tsx index 02f09a17..501f440c 100644 --- a/src/tile/GridTile.test.tsx +++ b/src/tile/GridTile.test.tsx @@ -7,9 +7,10 @@ Please see LICENSE in the repository root for full details. import { type RemoteTrackPublication } from "livekit-client"; import { test, expect } from "vitest"; -import { render, screen } from "@testing-library/react"; +import { act, render, screen } from "@testing-library/react"; import { axe } from "vitest-axe"; import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc"; +import { BehaviorSubject } from "rxjs"; import { GridTile } from "./GridTile"; import { @@ -21,6 +22,11 @@ import { GridTileViewModel } from "../state/TileViewModel"; import { ReactionsSenderProvider } from "../reactions/useReactionsSender"; import type { CallViewModel } from "../state/CallViewModel/CallViewModel"; import { constant } from "../state/Behavior"; +import { + createRingingMedia, + type RingingMediaViewModel, +} from "../state/media/RingingMediaViewModel"; +import { type MuteStates } from "../state/MuteStates"; global.IntersectionObserver = class MockIntersectionObserver { public observe(): void {} @@ -28,6 +34,27 @@ global.IntersectionObserver = class MockIntersectionObserver { public disconnect(): void {} } as unknown as typeof IntersectionObserver; +const fakeRtcSession = { + on: () => {}, + off: () => {}, + room: { + on: () => {}, + off: () => {}, + client: { + getUserId: () => null, + getDeviceId: () => null, + on: () => {}, + off: () => {}, + }, + }, + memberships: [], +} as unknown as MatrixRTCSession; + +const callVm = { + reactions$: constant({}), + handsRaised$: constant({}), +} as Partial as CallViewModel; + test("GridTile is accessible", async () => { const vm = mockRemoteMedia( mockRtcMembership("@alice:example.org", "AAAA"), @@ -42,34 +69,15 @@ test("GridTile is accessible", async () => { }), ); - const fakeRtcSession = { - on: () => {}, - off: () => {}, - room: { - on: () => {}, - off: () => {}, - client: { - getUserId: () => null, - getDeviceId: () => null, - on: () => {}, - off: () => {}, - }, - }, - memberships: [], - } as unknown as MatrixRTCSession; - const cVm = { - reactions$: constant({}), - handsRaised$: constant({}), - } as Partial as CallViewModel; const { container } = render( - + {}} targetWidth={300} targetHeight={200} showSpeakingIndicators - focusable={true} + focusable /> , ); @@ -77,3 +85,40 @@ test("GridTile is accessible", async () => { // Name should be visible screen.getByText("Alice"); }); + +test("GridTile displays ringing media", async () => { + const pickupState$ = new BehaviorSubject< + RingingMediaViewModel["pickupState$"]["value"] + >("ringing"); + const vm = createRingingMedia({ + pickupState$, + muteStates: { + video: { enabled$: constant(false) }, + } as unknown as MuteStates, + id: "test", + userId: "@alice:example.org", + displayName$: constant("Alice"), + mxcAvatarUrl$: constant(undefined), + }); + + const { container } = render( + + {}} + targetWidth={300} + targetHeight={200} + showSpeakingIndicators + focusable + /> + , + ); + expect(await axe(container)).toHaveNoViolations(); + // Name and status should be visible + screen.getByText("Alice"); + screen.getByText("Calling…"); + + // Alice declines the call + act(() => pickupState$.next("decline")); + screen.getByText("Call ended"); +}); diff --git a/src/tile/GridTile.tsx b/src/tile/GridTile.tsx index c8052a65..13cf677f 100644 --- a/src/tile/GridTile.tsx +++ b/src/tile/GridTile.tsx @@ -29,6 +29,9 @@ import { UserProfileIcon, VolumeOffSolidIcon, SwitchCameraSolidIcon, + VideoCallSolidIcon, + VoiceCallSolidIcon, + EndCallIcon, } from "@vector-im/compound-design-tokens/assets/web/icons"; import { ContextMenu, @@ -49,6 +52,7 @@ import { useBehavior } from "../useBehavior"; import { type LocalUserMediaViewModel } from "../state/media/LocalUserMediaViewModel"; import { type RemoteUserMediaViewModel } from "../state/media/RemoteUserMediaViewModel"; import { type UserMediaViewModel } from "../state/media/UserMediaViewModel"; +import { type RingingMediaViewModel } from "../state/media/RingingMediaViewModel"; interface TileProps { ref?: Ref; @@ -56,21 +60,56 @@ interface TileProps { style?: ComponentProps["style"]; targetWidth: number; targetHeight: number; - focusUrl: string | undefined; displayName: string; mxcAvatarUrl: string | undefined; - showSpeakingIndicators: boolean; focusable: boolean; } +interface RingingMediaTileProps extends TileProps { + vm: RingingMediaViewModel; +} + +const RingingMediaTile: FC = ({ + vm, + className, + ...props +}) => { + const { t } = useTranslation(); + const pickupState = useBehavior(vm.pickupState$); + const videoEnabled = useBehavior(vm.videoEnabled$); + + return ( + + ); +}; + interface UserMediaTileProps extends TileProps { vm: UserMediaViewModel; + showSpeakingIndicators: boolean; mirror: boolean; playbackMuted: boolean; waitingForMedia?: boolean; primaryButton?: ReactNode; menuStart?: ReactNode; menuEnd?: ReactNode; + focusUrl: string | undefined; } const UserMediaTile: FC = ({ @@ -95,7 +134,6 @@ const UserMediaTile: FC = ({ const { t } = useTranslation(); const video = useBehavior(vm.video$); const unencryptedWarning = useBehavior(vm.unencryptedWarning$); - const encryptionStatus = useBehavior(vm.encryptionStatus$); const audioStreamStats = useObservableEagerState< RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined >(vm.audioStreamStats$); @@ -153,7 +191,6 @@ const UserMediaTile: FC = ({ video={video} userId={vm.userId} unencryptedWarning={unencryptedWarning} - encryptionStatus={encryptionStatus} videoEnabled={videoEnabled} videoFit={videoFit} className={classNames(className, styles.tile, { @@ -218,6 +255,7 @@ UserMediaTile.displayName = "UserMediaTile"; interface LocalUserMediaTileProps extends TileProps { vm: LocalUserMediaViewModel; + showSpeakingIndicators: boolean; onOpenProfile: (() => void) | null; } @@ -232,6 +270,7 @@ const LocalUserMediaTile: FC = ({ const mirror = useBehavior(vm.mirror$); const alwaysShow = useBehavior(vm.alwaysShow$); const switchCamera = useBehavior(vm.switchCamera$); + const focusUrl = useBehavior(vm.focusUrl$); const latestAlwaysShow = useLatest(alwaysShow); const onSelectAlwaysShow = useCallback( @@ -278,6 +317,7 @@ const LocalUserMediaTile: FC = ({ ) } focusable={focusable} + focusUrl={focusUrl} {...props} /> ); @@ -287,6 +327,7 @@ LocalUserMediaTile.displayName = "LocalUserMediaTile"; interface RemoteUserMediaTileProps extends TileProps { vm: RemoteUserMediaViewModel; + showSpeakingIndicators: boolean; } const RemoteUserMediaTile: FC = ({ @@ -298,6 +339,8 @@ const RemoteUserMediaTile: FC = ({ const waitingForMedia = useBehavior(vm.waitingForMedia$); const playbackMuted = useBehavior(vm.playbackMuted$); const playbackVolume = useBehavior(vm.playbackVolume$); + const focusUrl = useBehavior(vm.focusUrl$); + const onSelectMute = useCallback( (e: Event) => { e.preventDefault(); @@ -338,6 +381,7 @@ const RemoteUserMediaTile: FC = ({ } + focusUrl={focusUrl} {...props} /> ); @@ -360,23 +404,33 @@ interface GridTileProps { export const GridTile: FC = ({ ref: theirRef, vm, + showSpeakingIndicators, onOpenProfile, ...props }) => { const ourRef = useRef(null); const ref = useMergedRefs(ourRef, theirRef); const media = useBehavior(vm.media$); - const focusUrl = useBehavior(media.focusUrl$); const displayName = useBehavior(media.displayName$); const mxcAvatarUrl = useBehavior(media.mxcAvatarUrl$); - if (media.local) { + if (media.type === "ringing") { + return ( + + ); + } else if (media.local) { return ( = ({ 0) { @@ -71,14 +76,15 @@ unconditionally select the container so we can use cqmin units */ .fg { position: absolute; - inset: var( + --fg-inset: var( --media-view-fg-inset, calc(var(--media-view-border-radius) - var(--cpd-space-3x)) ); + inset: var(--fg-inset); display: grid; grid-template-columns: 30px 1fr 30px; grid-template-rows: 1fr auto; - grid-template-areas: "reactions status ." "nameTag nameTag button"; + grid-template-areas: "status status reactions" "nameTag nameTag button"; gap: var(--cpd-space-1x); place-items: start; } @@ -102,21 +108,19 @@ unconditionally select the container so we can use cqmin units */ .status { grid-area: status; - justify-self: center; - align-self: start; - padding: var(--cpd-space-2x); - padding-block: var(--cpd-space-2x); color: var(--cpd-color-text-primary); - background-color: var(--cpd-color-bg-canvas-default); display: flex; + flex-wrap: none; align-items: center; - border-radius: var(--cpd-radius-pill-effect); + gap: 3px; user-select: none; overflow: hidden; - box-shadow: var(--small-drop-shadow); - box-sizing: border-box; - max-inline-size: 100%; - text-align: center; + margin-block-start: calc(var(--cpd-space-3x) - var(--fg-inset)); + margin-inline-start: calc(var(--cpd-space-4x) - var(--fg-inset)); +} + +.status svg { + color: var(--cpd-color-icon-tertiary); } .reactions { diff --git a/src/tile/MediaView.test.tsx b/src/tile/MediaView.test.tsx index a509d3a5..6ef5eb7e 100644 --- a/src/tile/MediaView.test.tsx +++ b/src/tile/MediaView.test.tsx @@ -18,7 +18,6 @@ import { TrackInfo } from "@livekit/protocol"; import { type ComponentProps } from "react"; import { MediaView } from "./MediaView"; -import { EncryptionStatus } from "../state/media/MemberMediaViewModel"; import { mockLocalParticipant } from "../utils/test"; describe("MediaView", () => { @@ -41,7 +40,6 @@ describe("MediaView", () => { videoFit: "contain", targetWidth: 300, targetHeight: 200, - encryptionStatus: EncryptionStatus.Connecting, mirror: false, unencryptedWarning: false, video: trackReference, diff --git a/src/tile/MediaView.tsx b/src/tile/MediaView.tsx index f912c069..eb6cc6b4 100644 --- a/src/tile/MediaView.tsx +++ b/src/tile/MediaView.tsx @@ -7,7 +7,13 @@ Please see LICENSE in the repository root for full details. import { type TrackReferenceOrPlaceholder } from "@livekit/components-core"; import { animated } from "@react-spring/web"; -import { type FC, type ComponentProps, type ReactNode } from "react"; +import { + type FC, + type ComponentProps, + type ReactNode, + type ComponentType, + type SVGAttributes, +} from "react"; import { useTranslation } from "react-i18next"; import classNames from "classnames"; import { VideoTrack } from "@livekit/components-react"; @@ -16,7 +22,6 @@ import { ErrorSolidIcon } from "@vector-im/compound-design-tokens/assets/web/ico import styles from "./MediaView.module.css"; import { Avatar } from "../Avatar"; -import { type EncryptionStatus } from "../state/media/MemberMediaViewModel"; import { RaisedHandIndicator } from "../reactions/RaisedHandIndicator"; import { showConnectionStats as showConnectionStatsSetting, @@ -38,7 +43,7 @@ interface Props extends ComponentProps { userId: string; videoEnabled: boolean; unencryptedWarning: boolean; - encryptionStatus: EncryptionStatus; + status?: { text: string; Icon: ComponentType> }; nameTagLeadingIcon?: ReactNode; displayName: string; mxcAvatarUrl: string | undefined; @@ -72,7 +77,7 @@ export const MediaView: FC = ({ mxcAvatarUrl, focusable, primaryButton, - encryptionStatus, + status, raisedHandTime, currentReaction, raisedHandOnClick, @@ -106,7 +111,11 @@ export const MediaView: FC = ({ name={displayName} size={avatarSize} src={mxcAvatarUrl} - className={styles.avatar} + className={classNames(styles.avatar, { + // When the avatar is overlaid with a status, make it translucent + // for readability + [styles.translucent]: status, + })} style={{ display: video && videoEnabled ? "none" : "initial" }} /> {video?.publication !== undefined && ( @@ -152,6 +161,14 @@ export const MediaView: FC = ({ /> )} + {status && ( +
+ + + {status.text} + +
+ )} {/* TODO: Bring this back once encryption status is less broken */} {/*encryptionStatus !== EncryptionStatus.Okay && (
diff --git a/src/tile/SpotlightTile.test.tsx b/src/tile/SpotlightTile.test.tsx index aac81b9c..533c3b2f 100644 --- a/src/tile/SpotlightTile.test.tsx +++ b/src/tile/SpotlightTile.test.tsx @@ -6,10 +6,11 @@ Please see LICENSE in the repository root for full details. */ import { test, expect, vi } from "vitest"; -import { isInaccessible, render, screen } from "@testing-library/react"; +import { act, isInaccessible, render, screen } from "@testing-library/react"; import { axe } from "vitest-axe"; import userEvent from "@testing-library/user-event"; import { TooltipProvider } from "@vector-im/compound-web"; +import { BehaviorSubject } from "rxjs"; import { SpotlightTile } from "./SpotlightTile"; import { @@ -23,6 +24,11 @@ import { } from "../utils/test"; import { SpotlightTileViewModel } from "../state/TileViewModel"; import { constant } from "../state/Behavior"; +import { + createRingingMedia, + type RingingMediaViewModel, +} from "../state/media/RingingMediaViewModel"; +import { type MuteStates } from "../state/MuteStates"; global.IntersectionObserver = class MockIntersectionObserver { public observe(): void {} @@ -140,3 +146,41 @@ test("Screen share volume UI is hidden when screen share has no audio", async () screen.queryByRole("button", { name: /volume/i }), ).not.toBeInTheDocument(); }); + +test("SpotlightTile displays ringing media", async () => { + const pickupState$ = new BehaviorSubject< + RingingMediaViewModel["pickupState$"]["value"] + >("ringing"); + const vm = createRingingMedia({ + pickupState$, + muteStates: { + video: { enabled$: constant(false) }, + } as unknown as MuteStates, + id: "test", + userId: "@alice:example.org", + displayName$: constant("Alice"), + mxcAvatarUrl$: constant(undefined), + }); + + const toggleExpanded = vi.fn(); + const { container } = render( + , + ); + + expect(await axe(container)).toHaveNoViolations(); + // Alice should be in the spotlight with the right status + screen.getByText("Alice"); + screen.getByText("Calling…"); + + // Now we time out ringing to Alice + act(() => pickupState$.next("timeout")); + screen.getByText("Call ended"); +}); diff --git a/src/tile/SpotlightTile.tsx b/src/tile/SpotlightTile.tsx index aa66d6b6..c5faba40 100644 --- a/src/tile/SpotlightTile.tsx +++ b/src/tile/SpotlightTile.tsx @@ -24,6 +24,9 @@ import { VolumeOnIcon, VolumeOffSolidIcon, VolumeOnSolidIcon, + VideoCallSolidIcon, + VoiceCallSolidIcon, + EndCallIcon, } from "@vector-im/compound-design-tokens/assets/web/icons"; import { animated } from "@react-spring/web"; import { type Observable, map } from "rxjs"; @@ -43,7 +46,7 @@ import { useReactiveState } from "../useReactiveState"; import { useLatest } from "../useLatest"; import { type SpotlightTileViewModel } from "../state/TileViewModel"; import { useBehavior } from "../useBehavior"; -import { type EncryptionStatus } from "../state/media/MemberMediaViewModel"; +import { type MemberMediaViewModel } from "../state/media/MemberMediaViewModel"; import { type LocalUserMediaViewModel } from "../state/media/LocalUserMediaViewModel"; import { type RemoteUserMediaViewModel } from "../state/media/RemoteUserMediaViewModel"; import { type UserMediaViewModel } from "../state/media/UserMediaViewModel"; @@ -52,6 +55,7 @@ import { type RemoteScreenShareViewModel } from "../state/media/RemoteScreenShar import { type MediaViewModel } from "../state/media/MediaViewModel"; import { Slider } from "../Slider"; import { platform } from "../Platform"; +import { type RingingMediaViewModel } from "../state/media/RingingMediaViewModel"; interface SpotlightItemBaseProps { ref?: Ref; @@ -59,18 +63,20 @@ interface SpotlightItemBaseProps { "data-id": string; targetWidth: number; targetHeight: number; - video: TrackReferenceOrPlaceholder | undefined; userId: string; - unencryptedWarning: boolean; - encryptionStatus: EncryptionStatus; - focusUrl: string | undefined; displayName: string; mxcAvatarUrl: string | undefined; focusable: boolean; "aria-hidden"?: boolean; } -interface SpotlightUserMediaItemBaseProps extends SpotlightItemBaseProps { +interface SpotlightMemberMediaItemBaseProps extends SpotlightItemBaseProps { + video: TrackReferenceOrPlaceholder | undefined; + unencryptedWarning: boolean; + focusUrl: string | undefined; +} + +interface SpotlightUserMediaItemBaseProps extends SpotlightMemberMediaItemBaseProps { videoFit: "contain" | "cover"; videoEnabled: boolean; } @@ -103,21 +109,32 @@ const SpotlightRemoteUserMediaItem: FC = ({ ); }; -interface SpotlightUserMediaItemProps extends SpotlightItemBaseProps { +interface SpotlightUserMediaItemProps extends SpotlightMemberMediaItemBaseProps { vm: UserMediaViewModel; } const SpotlightUserMediaItem: FC = ({ vm, + targetWidth, + targetHeight, ...props }) => { const videoFit = useBehavior(vm.videoFit$); const videoEnabled = useBehavior(vm.videoEnabled$); + // Whenever target bounds change, inform the viewModel + useEffect(() => { + if (targetWidth > 0 && targetHeight > 0) { + vm.setTargetDimensions(targetWidth, targetHeight); + } + }, [targetWidth, targetHeight, vm]); + const baseProps: SpotlightUserMediaItemBaseProps & RefAttributes = { videoFit, videoEnabled, + targetWidth, + targetHeight, ...props, }; @@ -130,7 +147,7 @@ const SpotlightUserMediaItem: FC = ({ SpotlightUserMediaItem.displayName = "SpotlightUserMediaItem"; -interface SpotlightScreenShareItemProps extends SpotlightItemBaseProps { +interface SpotlightScreenShareItemProps extends SpotlightMemberMediaItemBaseProps { vm: ScreenShareViewModel; videoEnabled: boolean; } @@ -142,7 +159,7 @@ const SpotlightScreenShareItem: FC = ({ return ; }; -interface SpotlightRemoteScreenShareItemProps extends SpotlightItemBaseProps { +interface SpotlightRemoteScreenShareItemProps extends SpotlightMemberMediaItemBaseProps { vm: RemoteScreenShareViewModel; } @@ -155,6 +172,67 @@ const SpotlightRemoteScreenShareItem: FC< ); }; +interface SpotlightMemberMediaItemProps extends SpotlightItemBaseProps { + vm: MemberMediaViewModel; +} + +const SpotlightMemberMediaItem: FC = ({ + vm, + ...props +}) => { + const video = useBehavior(vm.video$); + const unencryptedWarning = useBehavior(vm.unencryptedWarning$); + const focusUrl = useBehavior(vm.focusUrl$); + + const baseProps: SpotlightMemberMediaItemBaseProps & + RefAttributes = { + video: video ?? undefined, + unencryptedWarning, + focusUrl, + ...props, + }; + + if (vm.type === "user") + return ; + return vm.local ? ( + + ) : ( + + ); +}; + +interface SpotlightRingingMediaItemProps extends SpotlightItemBaseProps { + vm: RingingMediaViewModel; +} + +const SpotlightRingingMediaItem: FC = ({ + vm, + ...props +}) => { + const { t } = useTranslation(); + const pickupState = useBehavior(vm.pickupState$); + const videoEnabled = useBehavior(vm.videoEnabled$); + + return ( + + ); +}; + interface SpotlightItemProps { ref?: Ref; vm: MediaViewModel; @@ -187,22 +265,9 @@ const SpotlightItem: FC = ({ }) => { const ourRef = useRef(null); - // Whenever target bounds change, inform the viewModel - useEffect(() => { - if (targetWidth > 0 && targetHeight > 0) { - if (vm.type != "screen share") { - vm.setTargetDimensions(targetWidth, targetHeight); - } - } - }, [targetWidth, targetHeight, vm]); - const ref = useMergedRefs(ourRef, theirRef); - const focusUrl = useBehavior(vm.focusUrl$); const displayName = useBehavior(vm.displayName$); const mxcAvatarUrl = useBehavior(vm.mxcAvatarUrl$); - const video = useBehavior(vm.video$); - const unencryptedWarning = useBehavior(vm.unencryptedWarning$); - const encryptionStatus = useBehavior(vm.encryptionStatus$); // Hook this item up to the intersection observer useEffect(() => { @@ -225,23 +290,17 @@ const SpotlightItem: FC = ({ className: classNames(styles.item, { [styles.snap]: snap }), targetWidth, targetHeight, - video: video ?? undefined, userId: vm.userId, - unencryptedWarning, - focusUrl, displayName, mxcAvatarUrl, focusable, - encryptionStatus, "aria-hidden": ariaHidden, }; - if (vm.type === "user") - return ; - return vm.local ? ( - + return vm.type === "ringing" ? ( + ) : ( - + ); }; From fa844446b6a745731ceb20178314bf8ba0b0ca41 Mon Sep 17 00:00:00 2001 From: Robin Date: Wed, 18 Mar 2026 11:29:55 +0100 Subject: [PATCH 6/6] Invert the colors of the camera and microphone buttons So that they use primary color tokens when unmuted, and secondary color tokens when muted. This makes them work like the screen sharing button. --- src/button/Button.tsx | 28 +++++++++---------- src/room/InCallView.tsx | 4 +-- src/room/LobbyView.tsx | 4 +-- .../__snapshots__/InCallView.test.tsx.snap | 4 +-- 4 files changed, 20 insertions(+), 20 deletions(-) diff --git a/src/button/Button.tsx b/src/button/Button.tsx index 00d803f1..1aff9fa3 100644 --- a/src/button/Button.tsx +++ b/src/button/Button.tsx @@ -22,16 +22,16 @@ import { import styles from "./Button.module.css"; interface MicButtonProps extends ComponentPropsWithoutRef<"button"> { - muted: boolean; + enabled: boolean; size?: "sm" | "lg"; } -export const MicButton: FC = ({ muted, ...props }) => { +export const MicButton: FC = ({ enabled, ...props }) => { const { t } = useTranslation(); - const Icon = muted ? MicOffSolidIcon : MicOnSolidIcon; - const label = muted - ? t("unmute_microphone_button_label") - : t("mute_microphone_button_label"); + const Icon = enabled ? MicOnSolidIcon : MicOffSolidIcon; + const label = enabled + ? t("mute_microphone_button_label") + : t("unmute_microphone_button_label"); return ( @@ -39,7 +39,7 @@ export const MicButton: FC = ({ muted, ...props }) => { iconOnly aria-label={label} Icon={Icon} - kind={muted ? "primary" : "secondary"} + kind={enabled ? "primary" : "secondary"} {...props} /> @@ -47,16 +47,16 @@ export const MicButton: FC = ({ muted, ...props }) => { }; interface VideoButtonProps extends ComponentPropsWithoutRef<"button"> { - muted: boolean; + enabled: boolean; size?: "sm" | "lg"; } -export const VideoButton: FC = ({ muted, ...props }) => { +export const VideoButton: FC = ({ enabled, ...props }) => { const { t } = useTranslation(); - const Icon = muted ? VideoCallOffSolidIcon : VideoCallSolidIcon; - const label = muted - ? t("start_video_button_label") - : t("stop_video_button_label"); + const Icon = enabled ? VideoCallSolidIcon : VideoCallOffSolidIcon; + const label = enabled + ? t("stop_video_button_label") + : t("start_video_button_label"); return ( @@ -64,7 +64,7 @@ export const VideoButton: FC = ({ muted, ...props }) => { iconOnly aria-label={label} Icon={Icon} - kind={muted ? "primary" : "secondary"} + kind={enabled ? "primary" : "secondary"} {...props} /> diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index f1a872a0..ff221329 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -645,7 +645,7 @@ export const InCallView: FC = ({ = ({ = ({ {recentsButtonInFooter && recentsButton}
diff --git a/src/room/__snapshots__/InCallView.test.tsx.snap b/src/room/__snapshots__/InCallView.test.tsx.snap index e4707c5c..1188d0cc 100644 --- a/src/room/__snapshots__/InCallView.test.tsx.snap +++ b/src/room/__snapshots__/InCallView.test.tsx.snap @@ -289,7 +289,7 @@ exports[`InCallView > rendering > renders 1`] = ` aria-label="Unmute microphone" aria-labelledby="_r_8_" class="_button_13vu4_8 _has-icon_13vu4_60 _icon-only_13vu4_53" - data-kind="primary" + data-kind="secondary" data-size="lg" data-testid="incall_mute" role="button" @@ -313,7 +313,7 @@ exports[`InCallView > rendering > renders 1`] = ` aria-label="Start video" aria-labelledby="_r_d_" class="_button_13vu4_8 _has-icon_13vu4_60 _icon-only_13vu4_53" - data-kind="primary" + data-kind="secondary" data-size="lg" data-testid="incall_videomute" role="button"