diff --git a/.changeset/add-new-calls.md b/.changeset/add-new-calls.md new file mode 100644 index 000000000..05125226e --- /dev/null +++ b/.changeset/add-new-calls.md @@ -0,0 +1,7 @@ +--- +default: minor +--- + +# Add experimental new calls + +Introduce an opt-in new call system behind a single experimental setting. It uses LiveKit JS on web and desktop and native LiveKit on supported mobile devices, with Element Call remaining the fallback when the setting is off. diff --git a/.github/workflows/tauri-build.yml b/.github/workflows/tauri-build.yml index 3d976d462..95eebcde2 100644 --- a/.github/workflows/tauri-build.yml +++ b/.github/workflows/tauri-build.yml @@ -461,6 +461,10 @@ jobs: shell: bash run: mise -y run tauri:setup:ios + - name: Download iOS frameworks + shell: bash + run: pnpm ios:frameworks + - name: Symlink icons to the ios folder shell: bash run: mise run icons:symlink --write --force diff --git a/.ignore b/.ignore new file mode 100644 index 000000000..b2f5e7e8a --- /dev/null +++ b/.ignore @@ -0,0 +1,9 @@ +!.slim/deepwork/ +!.slim/deepwork/** + +# BEGIN oh-my-opencode-slim worktrees +!.slim/ +!.slim/worktrees.json +!.slim/worktrees/ +!.slim/worktrees/** +# END oh-my-opencode-slim worktrees diff --git a/package.json b/package.json index 2660b32fb..f637443d5 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "typecheck": "tsc", "tauri": "tauri", "tauri:cef": "node scripts/tauri.js cef", + "ios:frameworks": "node scripts/download-ios-frameworks.js", "tauri:wry": "node scripts/tauri.js wry", "test": "vitest", "test:ui": "vitest --ui", @@ -46,9 +47,12 @@ "@choochmeque/tauri-plugin-sharekit-api": "0.4.0-rc.5", "@fontsource-variable/nunito": "5.2.7", "@fontsource/space-mono": "5.2.9", + "@livekit/components-react": "2.9.23", "@lottiefiles/dotlottie-react": "^0.12.0", "@noble/hashes": "^2.2.0", "@phosphor-icons/react": "^2.1.10", + "@sableclient/matrixrtc": "github:SableClient/matrix-rtc#f39530bba3c490c689429b6d240f41430ef38b1d", + "@sableclient/tauri-plugin-livekit-mobile": "github:SableClient/tauri-plugin-livekit-mobile#af871fd438e6d7337d2647535cbdf3eb67e8517a", "@sableclient/twemoji-font": "^1.0.4", "@sentry/react": "^10.63.0", "@tanstack/react-query": "^5.101.2", @@ -96,6 +100,7 @@ "leaflet": "1.9.4", "linkify-react": "^4.3.3", "linkifyjs": "^4.3.3", + "livekit-client": "2.21.0", "marked": "^18.0.5", "matrix-js-sdk": "42.0.0", "matrix-widget-api": "^1.17.0", @@ -167,5 +172,11 @@ "version": "24.x" } }, - "packageManager": "pnpm@10.32.1+sha512.a706938f0e89ac1456b6563eab4edf1d1faf3368d1191fc5c59790e96dc918e4456ab2e67d613de1043d2e8c81f87303e6b40d4ffeca9df15ef1ad567348f2be" + "packageManager": "pnpm@10.32.1+sha512.a706938f0e89ac1456b6563eab4edf1d1faf3368d1191fc5c59790e96dc918e4456ab2e67d613de1043d2e8c81f87303e6b40d4ffeca9df15ef1ad567348f2be", + "pnpm": { + "onlyBuiltDependencies": [ + "@sableclient/matrixrtc", + "@sableclient/tauri-plugin-livekit-mobile" + ] + } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 834f3a991..f9da78dfb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -43,6 +43,9 @@ importers: '@fontsource/space-mono': specifier: 5.2.9 version: 5.2.9 + '@livekit/components-react': + specifier: 2.9.23 + version: 2.9.23(livekit-client@2.21.0(@types/dom-mediacapture-record@1.0.22))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(tslib@2.8.1) '@lottiefiles/dotlottie-react': specifier: ^0.12.0 version: 0.12.3(react@18.3.1) @@ -52,6 +55,12 @@ importers: '@phosphor-icons/react': specifier: ^2.1.10 version: 2.1.10(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@sableclient/matrixrtc': + specifier: github:SableClient/matrix-rtc#f39530bba3c490c689429b6d240f41430ef38b1d + version: https://codeload.github.com/SableClient/matrix-rtc/tar.gz/f39530bba3c490c689429b6d240f41430ef38b1d(livekit-client@2.21.0(@types/dom-mediacapture-record@1.0.22))(matrix-js-sdk@42.0.0) + '@sableclient/tauri-plugin-livekit-mobile': + specifier: github:SableClient/tauri-plugin-livekit-mobile#af871fd438e6d7337d2647535cbdf3eb67e8517a + version: https://codeload.github.com/SableClient/tauri-plugin-livekit-mobile/tar.gz/af871fd438e6d7337d2647535cbdf3eb67e8517a '@sableclient/twemoji-font': specifier: ^1.0.4 version: 1.0.4 @@ -193,6 +202,9 @@ importers: linkifyjs: specifier: ^4.3.3 version: 4.3.3 + livekit-client: + specifier: 2.21.0 + version: 2.21.0(@types/dom-mediacapture-record@1.0.22) marked: specifier: ^18.0.5 version: 18.0.5 @@ -952,6 +964,9 @@ packages: resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} hasBin: true + '@bufbuild/protobuf@1.10.1': + resolution: {integrity: sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ==} + '@choochmeque/tauri-plugin-notifications-api@0.5.0-rc.11': resolution: {integrity: sha512-TSuf8HQ4Ayp9IKKB5v2yTdgKMa/I0bqbcACj0IZ46CSpzn/HKner/BKG7rM8tGOarEcyBd/VXnDhK5cJA/YMtw==} @@ -1405,6 +1420,15 @@ packages: resolution: {integrity: sha512-bsxD8WLS5lIj7aaoCx1YJkktqYj5vlBUE6HWzu2Q51ksrGJ0H737ECCKlFU7Yf8Br45z9t99frBp/J7kzbMPAg==} engines: {node: ^20.19.0 || ^22.13.0 || ^23.5.0 || >=24.0.0, npm: '>=10'} + '@floating-ui/core@1.8.0': + resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==} + + '@floating-ui/dom@1.7.6': + resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==} + + '@floating-ui/utils@0.2.12': + resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==} + '@fontsource-variable/nunito@5.2.7': resolution: {integrity: sha512-2N8QhatkyKgSUbAGZO2FYLioxA32+RyI1EplVLawbpkGjUeui9Qg9VMrpkCaik1ydjFjfLV+kzQ0cGEsMrMenQ==} @@ -1626,6 +1650,32 @@ packages: '@kwsites/file-exists@1.1.1': resolution: {integrity: sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==} + '@livekit/components-core@0.12.14': + resolution: {integrity: sha512-6OKP/1Ok2fCZewDLKd3SzaTb7KvfZl/6hjggI+TgUdhp0Y7HBg3+tHA7YmhZRc0BO8wIyVy4VgTPn7qkLHt2nQ==} + engines: {node: '>=18'} + peerDependencies: + livekit-client: ^2.20.1 + tslib: ^2.6.2 + + '@livekit/components-react@2.9.23': + resolution: {integrity: sha512-clO+0g/u3YBpuOvnAjUSAJBH/o7w+RpUAseswjiSML9rHrXlTUhwBNm8LPk+qN5GOU3A6lzsNnFYVpUHUBVOkg==} + engines: {node: '>=18'} + peerDependencies: + '@livekit/krisp-noise-filter': ^0.2.12 || ^0.3.0 || ^0.4.0 + livekit-client: ^2.20.1 + react: '>=18' + react-dom: '>=18' + tslib: ^2.6.2 + peerDependenciesMeta: + '@livekit/krisp-noise-filter': + optional: true + + '@livekit/mutex@1.1.1': + resolution: {integrity: sha512-EsshAucklmpuUAfkABPxJNhzj9v2sG7JuzFDL4ML1oJQSV14sqrpTYnsaOudMAw9yOaW53NU3QQTlUQoRs4czw==} + + '@livekit/protocol@1.50.4': + resolution: {integrity: sha512-L1uggNQAqyY21smQY8AllyOYbcv9Me9TaxwuLytL1R8ck9nbYPmQLNwEDi3pOFGAMa5F8I2nUi2Jc59W5awxlA==} + '@lottiefiles/dotlottie-react@0.12.3': resolution: {integrity: sha512-b0k0Lakj2hmhyIkwJSpKySh6xoelpgWRaijyrCb6fraOCnzuitKglVTtYs0VWf72rk+2aSmC/0IK8j8wruVfOw==} peerDependencies: @@ -2619,9 +2669,21 @@ packages: cpu: [x64] os: [win32] + '@sableclient/matrixrtc@https://codeload.github.com/SableClient/matrix-rtc/tar.gz/f39530bba3c490c689429b6d240f41430ef38b1d': + resolution: {tarball: https://codeload.github.com/SableClient/matrix-rtc/tar.gz/f39530bba3c490c689429b6d240f41430ef38b1d} + version: 0.1.0 + engines: {node: '>=20'} + peerDependencies: + livekit-client: ^2.21.0 + matrix-js-sdk: ^42.0.0 + '@sableclient/sable-call-embedded@1.1.8': resolution: {integrity: sha512-+95vxaqTxh5gIrIwMX+FWH4n+K82rv/hlVIO/KIhIl2udJ2+r/GESiAElnTXa1bom1+FWrdekNvkCEEuh7m2Jw==} + '@sableclient/tauri-plugin-livekit-mobile@https://codeload.github.com/SableClient/tauri-plugin-livekit-mobile/tar.gz/af871fd438e6d7337d2647535cbdf3eb67e8517a': + resolution: {tarball: https://codeload.github.com/SableClient/tauri-plugin-livekit-mobile/tar.gz/af871fd438e6d7337d2647535cbdf3eb67e8517a} + version: 0.1.0 + '@sableclient/twemoji-font@1.0.4': resolution: {integrity: sha512-LzvQB/VZtv5KEq1VYq2azr7v7cYT8oklOVRGkAN2RNwa3sF0XcnqM3lBHSYRFXQleGLSUgs3gA0slbwkD2+sJw==} @@ -3105,6 +3167,9 @@ packages: '@types/dockerode@4.0.1': resolution: {integrity: sha512-cmUpB+dPN955PxBEuXE3f6lKO1hHiIGYJA46IVF3BJpNsZGvtBDcRnlrHYHtOH/B6vtDOyl2kZ2ShAu3mgc27Q==} + '@types/dom-mediacapture-record@1.0.22': + resolution: {integrity: sha512-mUMZLK3NvwRLcAAT9qmcK+9p7tpU2FHdDsntR3YI4+GY88XrgG4XiE7u1Q2LAN2/FZOz/tdMDC3GQCR4T8nFuw==} + '@types/estree@0.0.39': resolution: {integrity: sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==} @@ -4336,6 +4401,9 @@ packages: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true + jose@6.2.4: + resolution: {integrity: sha512-N8acGzVsQy6M/fjFcxtysNc4Q379TcM5dM/qKkNtsHFji88yANnXTr7BLeP75iPnFwBfQzM/jg2BZ9+HZrHCZA==} + jotai@2.20.1: resolution: {integrity: sha512-dnuKfU/GLi8B28RRMjQ3AfoN7kfzP8o41+AX2FmITZqEMY8PHnjABq+VkEooomLwYaGjda+pgy0yFSjaHX/ZPg==} engines: {node: '>=12.20.0'} @@ -4506,6 +4574,11 @@ packages: linkifyjs@4.3.3: resolution: {integrity: sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==} + livekit-client@2.21.0: + resolution: {integrity: sha512-RBUhPkV/sl1nzl8lokVlK5uATPwn0AlsudCBZXissw/kDl9yz8ac4pNJ43iPpMVoHOeBYH/BZ3vUC1adqa/zFQ==} + peerDependencies: + '@types/dom-mediacapture-record': ^1 + locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} @@ -4522,6 +4595,10 @@ packages: lodash@4.18.1: resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + loglevel@1.9.1: + resolution: {integrity: sha512-hP3I3kCrDIMuRwAwHltphhDM1r8i55H33GgqjXbrisuJhF4kRhW1dNuxsRklp4bXl8DSdLaNLuiL4A/LWRfxvg==} + engines: {node: '>= 0.6.0'} + loglevel@1.9.2: resolution: {integrity: sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==} engines: {node: '>= 0.6.0'} @@ -5045,6 +5122,9 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + rxjs@7.8.2: + resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + safe-array-concat@1.1.4: resolution: {integrity: sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==} engines: {node: '>=0.4'} @@ -5076,10 +5156,17 @@ packages: scroll-into-view-if-needed@3.1.0: resolution: {integrity: sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==} + sdp-transform@2.15.0: + resolution: {integrity: sha512-KrOH82c/W+GYQ0LHqtr3caRpM3ITglq3ljGUIb8LTki7ByacJZ9z+piSGiwZDsRyhQbYBOBJgr2k6X4BZXi3Kw==} + hasBin: true + sdp-transform@3.0.0: resolution: {integrity: sha512-gfYVRGxjHkGF2NPeUWHw5u6T/KGFtS5/drPms73gaSuMaVHKCY3lpLnGDfswVQO0kddeePoti09AwhYP4zA8dQ==} hasBin: true + sdp@3.2.2: + resolution: {integrity: sha512-xZocWwfyp4hkbN4hLWxMjmv2Q8aNa9MhmOZ7L9aCZPT+dZsgRr6wZRrSYE3HTdyk/2pZKPSgqI7ns7Een1xMSA==} + semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true @@ -5425,6 +5512,9 @@ packages: resolution: {integrity: sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==} engines: {node: '>= 0.4'} + typed-emitter@2.1.0: + resolution: {integrity: sha512-g/KzbYKbH5C2vPkaXGu8DJlHrGKHLsM25Zg9WuC9pMGfuvT+X25tZQWo5fK1BjBm8+UrVE9LDCvaY0CQk+fXDA==} + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -5503,6 +5593,12 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + usehooks-ts@3.1.1: + resolution: {integrity: sha512-I4diPp9Cq6ieSUH2wu+fDAVQO43xwtulo+fKEidHUwZPnYImbtkTjzIJYcDcJqxgmX31GVqNFURodvcgHcW0pA==} + engines: {node: '>=16.15.0'} + peerDependencies: + react: ^16.8.0 || ^17 || ^18 || ^19 || ^19.0.0-rc + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -5713,6 +5809,10 @@ packages: resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} engines: {node: '>=20'} + webrtc-adapter@9.0.6: + resolution: {integrity: sha512-CHbl2ZQbxx164IgWRgzJno4hWtM4tFbRam1QfI3Yxhs3w/DvqluVxVWeXs3oL5/fbGkSNLKo0Ty5MgUWceNhog==} + engines: {node: '>=6.0.0', npm: '>=3.10.0'} + whatwg-mimetype@5.0.0: resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} engines: {node: '>=20'} @@ -6632,6 +6732,8 @@ snapshots: dependencies: css-tree: 3.2.1 + '@bufbuild/protobuf@1.10.1': {} + '@choochmeque/tauri-plugin-notifications-api@0.5.0-rc.11': dependencies: '@tauri-apps/api': 2.11.1 @@ -6915,6 +7017,17 @@ snapshots: '@faker-js/faker@10.5.0': {} + '@floating-ui/core@1.8.0': + dependencies: + '@floating-ui/utils': 0.2.12 + + '@floating-ui/dom@1.7.6': + dependencies: + '@floating-ui/core': 1.8.0 + '@floating-ui/utils': 0.2.12 + + '@floating-ui/utils@0.2.12': {} + '@fontsource-variable/nunito@5.2.7': {} '@fontsource/space-mono@5.2.9': {} @@ -7096,6 +7209,32 @@ snapshots: transitivePeerDependencies: - supports-color + '@livekit/components-core@0.12.14(livekit-client@2.21.0(@types/dom-mediacapture-record@1.0.22))(tslib@2.8.1)': + dependencies: + '@floating-ui/dom': 1.7.6 + livekit-client: 2.21.0(@types/dom-mediacapture-record@1.0.22) + loglevel: 1.9.1 + rxjs: 7.8.2 + tslib: 2.8.1 + + '@livekit/components-react@2.9.23(livekit-client@2.21.0(@types/dom-mediacapture-record@1.0.22))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(tslib@2.8.1)': + dependencies: + '@livekit/components-core': 0.12.14(livekit-client@2.21.0(@types/dom-mediacapture-record@1.0.22))(tslib@2.8.1) + clsx: 2.1.1 + events: 3.3.0 + jose: 6.2.4 + livekit-client: 2.21.0(@types/dom-mediacapture-record@1.0.22) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + tslib: 2.8.1 + usehooks-ts: 3.1.1(react@18.3.1) + + '@livekit/mutex@1.1.1': {} + + '@livekit/protocol@1.50.4': + dependencies: + '@bufbuild/protobuf': 1.10.1 + '@lottiefiles/dotlottie-react@0.12.3(react@18.3.1)': dependencies: '@lottiefiles/dotlottie-web': 0.40.1 @@ -7691,8 +7830,17 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.62.0': optional: true + '@sableclient/matrixrtc@https://codeload.github.com/SableClient/matrix-rtc/tar.gz/f39530bba3c490c689429b6d240f41430ef38b1d(livekit-client@2.21.0(@types/dom-mediacapture-record@1.0.22))(matrix-js-sdk@42.0.0)': + dependencies: + livekit-client: 2.21.0(@types/dom-mediacapture-record@1.0.22) + matrix-js-sdk: 42.0.0 + '@sableclient/sable-call-embedded@1.1.8': {} + '@sableclient/tauri-plugin-livekit-mobile@https://codeload.github.com/SableClient/tauri-plugin-livekit-mobile/tar.gz/af871fd438e6d7337d2647535cbdf3eb67e8517a': + dependencies: + '@tauri-apps/api': 2.11.1 + '@sableclient/twemoji-font@1.0.4': {} '@sentry/babel-plugin-component-annotate@5.3.0': {} @@ -8151,6 +8299,8 @@ snapshots: '@types/node': 24.13.2 '@types/ssh2': 1.15.5 + '@types/dom-mediacapture-record@1.0.22': {} + '@types/estree@0.0.39': {} '@types/estree@1.0.9': {} @@ -9554,6 +9704,8 @@ snapshots: jiti@2.7.0: {} + jose@6.2.4: {} + jotai@2.20.1(@babel/core@7.29.7)(@babel/template@7.29.7)(@types/react@18.3.31)(react@18.3.1): optionalDependencies: '@babel/core': 7.29.7 @@ -9699,6 +9851,19 @@ snapshots: linkifyjs@4.3.3: {} + livekit-client@2.21.0(@types/dom-mediacapture-record@1.0.22): + dependencies: + '@livekit/mutex': 1.1.1 + '@livekit/protocol': 1.50.4 + '@types/dom-mediacapture-record': 1.0.22 + events: 3.3.0 + jose: 6.2.4 + loglevel: 1.9.2 + sdp-transform: 2.15.0 + tslib: 2.8.1 + typed-emitter: 2.1.0 + webrtc-adapter: 9.0.6 + locate-path@6.0.0: dependencies: p-locate: 5.0.0 @@ -9711,6 +9876,8 @@ snapshots: lodash@4.18.1: {} + loglevel@1.9.1: {} + loglevel@1.9.2: {} long@5.3.2: {} @@ -10360,6 +10527,10 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.62.0 fsevents: 2.3.3 + rxjs@7.8.2: + dependencies: + tslib: 2.8.1 + safe-array-concat@1.1.4: dependencies: call-bind: 1.0.9 @@ -10397,8 +10568,12 @@ snapshots: dependencies: compute-scroll-into-view: 3.1.1 + sdp-transform@2.15.0: {} + sdp-transform@3.0.0: {} + sdp@3.2.2: {} + semver@6.3.1: {} semver@7.8.4: {} @@ -10875,6 +11050,10 @@ snapshots: possible-typed-array-names: 1.1.0 reflect.getprototypeof: 1.0.10 + typed-emitter@2.1.0: + optionalDependencies: + rxjs: 7.8.2 + typescript@5.9.3: {} ua-is-frozen@0.1.2: {} @@ -10937,6 +11116,11 @@ snapshots: dependencies: react: 18.3.1 + usehooks-ts@3.1.1(react@18.3.1): + dependencies: + lodash.debounce: 4.0.8 + react: 18.3.1 + util-deprecate@1.0.2: {} uuid@10.0.0: {} @@ -11090,6 +11274,10 @@ snapshots: webidl-conversions@8.0.1: {} + webrtc-adapter@9.0.6: + dependencies: + sdp: 3.2.2 + whatwg-mimetype@5.0.0: {} whatwg-url@16.0.1(@noble/hashes@2.2.0): diff --git a/scripts/download-ios-frameworks.js b/scripts/download-ios-frameworks.js new file mode 100644 index 000000000..5c334905c --- /dev/null +++ b/scripts/download-ios-frameworks.js @@ -0,0 +1,128 @@ +#!/usr/bin/env node +//MISE description="Download and verify the iOS XCFrameworks used by tauri-plugin-livekit-mobile" +// Downloads pinned iOS frameworks into src-tauri/Frameworks. +import { createHash } from 'node:crypto'; +import { spawnSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { mkdir, mkdtemp, readFile, rename, rm, writeFile } from 'node:fs/promises'; +import process from 'node:process'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { PrefixedLogger } from './utils/console-style.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const log = new PrefixedLogger('[ios-frameworks]'); + +const FRAMEWORKS_DIR = resolve(__dirname, '../src-tauri/Frameworks'); +const MANIFEST_PATH = join(FRAMEWORKS_DIR, '.manifest.json'); + +const FRAMEWORKS = [ + { + name: 'LiveKitWebRTC', + url: 'https://github.com/livekit/webrtc-xcframework/releases/download/144.7559.11/LiveKitWebRTC.xcframework.zip', + sha256: '07c5caf718058af3c528dcabd257298c40e5a8527e4fb9f47c48336ba5899853', + }, + { + name: 'RustLiveKitUniFFI', + url: 'https://github.com/livekit/livekit-uniffi-xcframework/releases/download/0.0.6/RustLiveKitUniFFI.xcframework.zip', + sha256: '0d3f2ce159a224c728f8b131068d53bbf9b13d968cda0edc68a6a2290f2651ed', + }, +]; + +function sha256Hex(buffer) { + return createHash('sha256').update(buffer).digest('hex'); +} + +async function readManifest() { + try { + return JSON.parse(await readFile(MANIFEST_PATH, 'utf8')); + } catch { + return {}; + } +} + +async function writeManifest(manifest) { + await writeFile(MANIFEST_PATH, `${JSON.stringify(manifest, null, 2)}\n`); +} + +function findExtractor() { + for (const tool of ['ditto', 'unzip']) { + if (!spawnSync(tool, ['--help'], { stdio: 'ignore' }).error) return tool; + } + throw new Error('Neither ditto nor unzip is available to extract XCFramework archives'); +} + +function extractZip(zipPath, destination) { + const tool = findExtractor(); + const args = + tool === 'ditto' ? ['-x', '-k', zipPath, destination] : ['-q', zipPath, '-d', destination]; + const result = spawnSync(tool, args, { stdio: 'inherit' }); + if (result.status !== 0) { + throw new Error(`${tool} failed to extract ${zipPath} (exit ${result.status})`); + } +} + +async function provision(framework, manifest) { + const { name, url, sha256 } = framework; + const target = join(FRAMEWORKS_DIR, `${name}.xcframework`); + + if (manifest[name] === sha256 && existsSync(join(target, 'Info.plist'))) { + log.info(`${name}.xcframework already installed (verified), skipping`); + return; + } + + log.info(`Downloading ${name} from ${url}`); + const response = await fetch(url, { redirect: 'follow' }); + if (!response.ok) { + throw new Error(`Failed to download ${name}: HTTP ${response.status} ${response.statusText}`); + } + const zipBuffer = Buffer.from(await response.arrayBuffer()); + + const actual = sha256Hex(zipBuffer); + if (actual !== sha256) { + throw new Error( + `Checksum mismatch for ${name}: expected ${sha256}, got ${actual}. Refusing to install.` + ); + } + log.info(`Verified SHA-256 for ${name}`); + + // Verify and unpack before replacing an existing framework. + const tempDir = await mkdtemp(join(FRAMEWORKS_DIR, '.tmp-')); + try { + const zipPath = join(tempDir, `${name}.zip`); + await writeFile(zipPath, zipBuffer); + extractZip(zipPath, tempDir); + + const extracted = join(tempDir, `${name}.xcframework`); + if (!existsSync(join(extracted, 'Info.plist'))) { + throw new Error( + `Downloaded archive for ${name} did not contain ${name}.xcframework; refusing to install.` + ); + } + + if (existsSync(target)) { + await rename(target, join(tempDir, '__replaced__')); + } + await rename(extracted, target); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + + manifest[name] = sha256; + await writeManifest(manifest); + log.info(`Installed ${name}.xcframework`); +} + +async function main() { + await mkdir(FRAMEWORKS_DIR, { recursive: true }); + const manifest = await readManifest(); + for (const framework of FRAMEWORKS) { + // oxlint-disable-next-line no-await-in-loop + await provision(framework, manifest); + } +} + +main().catch((error) => { + log.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; +}); diff --git a/src-tauri/.gitignore b/src-tauri/.gitignore index 489be3277..dd5680a87 100644 --- a/src-tauri/.gitignore +++ b/src-tauri/.gitignore @@ -2,3 +2,6 @@ # will have compiled files and executables /target/ /gen/schemas + +# Downloaded by scripts/download-ios-frameworks.js (pinned, checksummed binaries) +/Frameworks/ diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index f58607f69..1bdf5f83e 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -168,7 +168,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -179,7 +179,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -1011,7 +1011,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" dependencies = [ "lazy_static", - "windows-sys 0.59.0", + "windows-sys 0.48.0", ] [[package]] @@ -1553,7 +1553,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -1855,7 +1855,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -2823,7 +2823,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.5", + "socket2 0.5.10", "system-configuration", "tokio", "tower-service", @@ -3653,7 +3653,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.19", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -3777,7 +3777,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -4217,7 +4217,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] @@ -4835,7 +4835,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2 0.6.5", + "socket2 0.5.10", "thiserror 2.0.19", "tokio", "tracing", @@ -4873,9 +4873,9 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.5", + "socket2 0.5.10", "tracing", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -5294,7 +5294,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -5352,7 +5352,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -5424,6 +5424,7 @@ dependencies = [ "tauri-plugin-fs", "tauri-plugin-global-shortcut", "tauri-plugin-http", + "tauri-plugin-livekit-mobile", "tauri-plugin-log", "tauri-plugin-notifications", "tauri-plugin-opener", @@ -6051,7 +6052,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -6730,6 +6731,20 @@ dependencies = [ "urlpattern", ] +[[package]] +name = "tauri-plugin-livekit-mobile" +version = "0.2.0" +source = "git+https://github.com/SableClient/tauri-plugin-livekit-mobile.git?rev=af871fd438e6d7337d2647535cbdf3eb67e8517a#af871fd438e6d7337d2647535cbdf3eb67e8517a" +dependencies = [ + "base64 0.22.1", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.19", + "tokio", +] + [[package]] name = "tauri-plugin-log" version = "2.9.0" @@ -7109,7 +7124,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -7734,7 +7749,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.19", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -7808,7 +7823,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -8438,7 +8453,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index c8c27dac8..a4cf960b3 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -124,6 +124,7 @@ tauri-plugin-notifications = { git = "https://github.com/SableClient/tauri-plugi ] } tauri-plugin-edge-to-edge = { git = "https://github.com/SableClient/tauri-plugin-edge-to-edge.git", rev = "33c6116c27be28c06df5a9d02231ecc5fdeb93c5" } tauri-plugin-sharekit = { git = "https://github.com/Choochmeque/tauri-plugin-sharekit", rev = "9f2b4c5d8a4f0ab910d900ba234ed8bae0ab854b" } +tauri-plugin-livekit-mobile = { git = "https://github.com/SableClient/tauri-plugin-livekit-mobile.git", rev = "af871fd438e6d7337d2647535cbdf3eb67e8517a" } [target.'cfg(target_os = "ios")'.dependencies] objc2 = "0.6" diff --git a/src-tauri/Info.plist b/src-tauri/Info.plist index 99b9ecb70..e1425ba12 100644 --- a/src-tauri/Info.plist +++ b/src-tauri/Info.plist @@ -10,5 +10,10 @@ Sable needs camera access for video calls. NSMicrophoneUsageDescription Sable needs microphone access for voice and video calls. + UIBackgroundModes + + audio + voip + diff --git a/src-tauri/capabilities/mobile.json b/src-tauri/capabilities/mobile.json index e5500a57d..1cb676dbd 100644 --- a/src-tauri/capabilities/mobile.json +++ b/src-tauri/capabilities/mobile.json @@ -7,6 +7,7 @@ "core:window:allow-set-badge-count", "deep-link:default", "edge-to-edge:default", + "livekit-mobile:default", "notifications:default", "sharekit:default" ] diff --git a/src-tauri/gen/android/app/build.gradle.kts b/src-tauri/gen/android/app/build.gradle.kts index 10397a780..90abfc67a 100644 --- a/src-tauri/gen/android/app/build.gradle.kts +++ b/src-tauri/gen/android/app/build.gradle.kts @@ -61,6 +61,11 @@ android { ) } } + compileOptions { + // tauri-plugin-livekit-mobile requires it: LiveKit/WebRTC call java.time + // APIs that only exist from API 26, and minSdk here is 24. + isCoreLibraryDesugaringEnabled = true + } kotlinOptions { jvmTarget = "1.8" } @@ -73,11 +78,24 @@ rust { rootDirRel = "../../../" } +// tink (via the unifiedpush connector) pulls protobuf-java, which collides with +// the protobuf-javalite livekit-android is generated against: its Timestamp +// extends GeneratedMessage rather than GeneratedMessageLite, so livekit's +// classes fail dex verification at runtime. tink-android shades its own +// protobuf and needs none, which leaves javalite as the only protobuf. +configurations.all { + resolutionStrategy.dependencySubstitution { + substitute(module("com.google.crypto.tink:tink")) + .using(module("com.google.crypto.tink:tink-android:1.18.0")) + } +} + dependencies { implementation("androidx.webkit:webkit:1.14.0") implementation("androidx.appcompat:appcompat:1.7.1") implementation("androidx.activity:activity-ktx:1.10.1") implementation("com.google.android.material:material:1.12.0") + coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5") testImplementation("junit:junit:4.13.2") androidTestImplementation("androidx.test.ext:junit:1.1.4") androidTestImplementation("androidx.test.espresso:espresso-core:3.5.0") diff --git a/src-tauri/gen/android/app/src/main/AndroidManifest.xml b/src-tauri/gen/android/app/src/main/AndroidManifest.xml index 7ada59c8d..32f238399 100644 --- a/src-tauri/gen/android/app/src/main/AndroidManifest.xml +++ b/src-tauri/gen/android/app/src/main/AndroidManifest.xml @@ -47,6 +47,7 @@ android:launchMode="singleTask" android:label="@string/main_activity_title" android:name="moe.sable.client.MainActivity" + android:supportsPictureInPicture="true" android:exported="true"> diff --git a/src-tauri/gen/android/build.gradle.kts b/src-tauri/gen/android/build.gradle.kts index a91af1aef..d5b85e453 100644 --- a/src-tauri/gen/android/build.gradle.kts +++ b/src-tauri/gen/android/build.gradle.kts @@ -14,6 +14,9 @@ allprojects { repositories { google() mavenCentral() + // livekit-android pulls com.github.davidliu:audioswitch, published only + // on JitPack. + maven { setUrl("https://jitpack.io") } } } diff --git a/src-tauri/ios-entitlements/sable.debug.entitlements b/src-tauri/ios-entitlements/sable.debug.entitlements new file mode 100644 index 000000000..6631ffa6f --- /dev/null +++ b/src-tauri/ios-entitlements/sable.debug.entitlements @@ -0,0 +1,6 @@ + + + + + + diff --git a/src-tauri/ios-entitlements/sable.release.entitlements b/src-tauri/ios-entitlements/sable.release.entitlements new file mode 100644 index 000000000..723ff878d --- /dev/null +++ b/src-tauri/ios-entitlements/sable.release.entitlements @@ -0,0 +1,12 @@ + + + + + aps-environment + development + com.apple.security.application-groups + + group.moe.sable.client + + + diff --git a/src-tauri/ios-entitlements/sable.share-extension.debug.entitlements b/src-tauri/ios-entitlements/sable.share-extension.debug.entitlements new file mode 100644 index 000000000..6631ffa6f --- /dev/null +++ b/src-tauri/ios-entitlements/sable.share-extension.debug.entitlements @@ -0,0 +1,6 @@ + + + + + + diff --git a/src-tauri/ios-entitlements/sable.share-extension.release.entitlements b/src-tauri/ios-entitlements/sable.share-extension.release.entitlements new file mode 100644 index 000000000..6707d84a1 --- /dev/null +++ b/src-tauri/ios-entitlements/sable.share-extension.release.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.moe.sable.client + + + diff --git a/src-tauri/ios-project.yml b/src-tauri/ios-project.yml index 298ff0cb6..b35f746d8 100644 --- a/src-tauri/ios-project.yml +++ b/src-tauri/ios-project.yml @@ -60,9 +60,7 @@ targets: # APPLICATION_EXTENSION_API_ONLY must stay unset: NO fails Xcode's # validation, YES breaks UIApplication.open in ShareViewController. SKIP_INSTALL: YES - {{#if apple.development-team}} - DEVELOPMENT_TEAM: {{apple.development-team}} - {{/if}} + DEVELOPMENT_TEAM: PK66NJM372 {{app.name}}_iOS: type: application platform: iOS @@ -107,7 +105,6 @@ targets: - UIInterfaceOrientationLandscapeRight CFBundleShortVersionString: {{apple.bundle-version-short}} CFBundleVersion: "{{apple.bundle-version}}" - UIBackgroundModes: [audio, voip] NSPhotoLibraryAddUsageDescription: Sable saves images you download to your photo library. # Mirrors plugins.deep-link in tauri.conf.json: the plugin only patches # these in when cargo reruns its build script, so a fresh `tauri ios @@ -121,10 +118,6 @@ targets: {{this.key}}: {{this.value}}{{/each}} entitlements: path: {{app.name}}_iOS/{{app.name}}_iOS.entitlements - properties: - aps-environment: development - com.apple.security.application-groups: - - group.{{app.identifier}} scheme: environmentVariables: RUST_BACKTRACE: full diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7fcfe176c..9468df0ed 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -355,6 +355,7 @@ pub fn run() { #[cfg(mobile)] let builder = builder .plugin(tauri_plugin_edge_to_edge::init()) + .plugin(tauri_plugin_livekit_mobile::init()) .plugin(tauri_plugin_sharekit::init()); #[cfg(target_os = "android")] diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 7fb809737..bccd176b9 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -24,7 +24,13 @@ }, "iOS": { "template": "src-tauri/ios-project.yml", - "frameworks": ["AudioToolbox", "Photos"] + "minimumSystemVersion": "16.0", + "frameworks": [ + "AudioToolbox", + "Photos", + "Frameworks/LiveKitWebRTC.xcframework", + "Frameworks/RustLiveKitUniFFI.xcframework" + ] }, "linux": { "deb": { diff --git a/src-tauri/tauri.local.ios.conf.json b/src-tauri/tauri.local.ios.conf.json new file mode 100644 index 000000000..97948f54d --- /dev/null +++ b/src-tauri/tauri.local.ios.conf.json @@ -0,0 +1,3 @@ +{ + "identifier": "moe.sable.client.local" +} diff --git a/src/app/components/CallEmbedProvider.tsx b/src/app/components/CallEmbedProvider.tsx index 78b2b5ee0..b5bbc05ba 100644 --- a/src/app/components/CallEmbedProvider.tsx +++ b/src/app/components/CallEmbedProvider.tsx @@ -17,6 +17,7 @@ import { useSelectedRoom } from '$hooks/router/useSelectedRoom'; import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize'; import { IncomingCallModal } from '$features/call/IncomingCallModal'; import { toCallEmbedStartError } from '$plugins/call/callEmbedError'; +import { LivekitJsCallManagerProvider } from '$features/call/livekitJsCallManager'; function CallUtils({ embed }: { embed: CallEmbed }) { const setCallEmbed = useSetAtom(callEmbedAtom); @@ -80,8 +81,10 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) { {callEmbed && } - - {children} + + + {children} +
& { account?: string; issuer?: string; }; - 'org.matrix.msc4143.rtc_foci'?: [ - { - livekit_service_url: string; - type: 'livekit'; - }, - ]; + 'org.matrix.msc4143.rtc_foci'?: LivekitTransportConfig[]; +}; + +export const getLivekitTransports = ( + discovery: Pick | undefined +): LivekitTransportConfig[] => { + const foci = discovery?.['org.matrix.msc4143.rtc_foci']; + if (!Array.isArray(foci)) return []; + + return foci.filter( + (focus): focus is LivekitTransportConfig => + typeof focus === 'object' && + focus !== null && + focus.type === 'livekit' && + typeof focus.livekit_service_url === 'string' + ); }; export const autoDiscovery = async ( diff --git a/src/app/features/call-status/CallControl.tsx b/src/app/features/call-status/CallControl.tsx index 5162b8b08..c716c65bd 100644 --- a/src/app/features/call-status/CallControl.tsx +++ b/src/app/features/call-status/CallControl.tsx @@ -23,7 +23,7 @@ type MicrophoneButtonProps = { onToggle: () => Promise; disabled?: boolean; }; -function MicrophoneButton({ enabled, onToggle, disabled }: MicrophoneButtonProps) { +export function MicrophoneButton({ enabled, onToggle, disabled }: MicrophoneButtonProps) { return ( void; disabled?: boolean; }; -function SoundButton({ enabled, onToggle, disabled }: SoundButtonProps) { +export function SoundButton({ enabled, onToggle, disabled }: SoundButtonProps) { return ( Promise; disabled?: boolean; }; -function VideoButton({ enabled, onToggle, disabled }: VideoButtonProps) { +export function VideoButton({ enabled, onToggle, disabled }: VideoButtonProps) { return ( void; disabled?: boolean; }; -function ScreenShareButton({ enabled, onToggle, disabled }: ScreenShareButtonProps) { +export function ScreenShareButton({ enabled, onToggle, disabled }: ScreenShareButtonProps) { return ( (); + +export function HangupChip({ + compact, + onHangup, +}: { + compact: boolean; + onHangup: () => Promise; +}) { + const [hangupState, hangup] = useAsyncCallback(useCallback(() => onHangup(), [onHangup])); + const exiting = + hangupState.status === AsyncStatus.Loading || hangupState.status === AsyncStatus.Success; + + return ( + + ) : ( + sizedIcon(PhoneDisconnect, '50', { filled: true }) + ) + } + disabled={exiting} + outlined + onClick={() => hangup()} + > + {!compact && ( + + End + + )} + + ); +} + +/** + * The persistent call bar both engines render into. Only the control cluster + * differs, so it is passed in. + */ +export function CallStatusShell({ + room, + compact, + connected, + controls, +}: { + room: Room; + compact: boolean; + connected: boolean; + controls: ReactNode; +}) { + const callSession = useCallSession(room); + const callMembers = useCallMembers(room, callSession); + const memberVisible = connected && callMembers.length > 0; + + return ( + + + {memberVisible ? ( + + + + ) : ( + + )} + + {!compact && } + + {memberVisible && ( + + + + )} + + {memberVisible && !compact && } + + {compact && ( + + + + )} + {controls} + + + ); +} diff --git a/src/app/features/call-status/LivekitCallStatus.tsx b/src/app/features/call-status/LivekitCallStatus.tsx new file mode 100644 index 000000000..5e44cc069 --- /dev/null +++ b/src/app/features/call-status/LivekitCallStatus.tsx @@ -0,0 +1,77 @@ +import { Box } from 'folds'; +import { useAtom } from 'jotai'; +import { RoomContext, useLocalParticipant } from '@livekit/components-react'; +import { useMatrixClient } from '$hooks/useMatrixClient'; +import { ScreenSize, useScreenSize } from '$hooks/useScreenSize'; +import { livekitJsCallSoundAtom, type LivekitJsCallSession } from '$state/livekitJsCall'; +import { MicrophoneButton, ScreenShareButton, SoundButton, VideoButton } from './CallControl'; +import { CallStatusShell, HangupChip } from './CallStatusShell'; +import { StatusDivider } from './components'; + +function LivekitCallControl({ + compact, + onHangup, +}: { + compact: boolean; + onHangup: () => Promise; +}) { + const { localParticipant, isMicrophoneEnabled, isCameraEnabled, isScreenShareEnabled } = + useLocalParticipant(); + const [sound, setSound] = useAtom(livekitJsCallSoundAtom); + + return ( + + + localParticipant.setMicrophoneEnabled(!isMicrophoneEnabled)} + /> + setSound(!sound)} /> + {!compact && } + localParticipant.setCameraEnabled(!isCameraEnabled)} + /> + {!compact && ( + + void localParticipant.setScreenShareEnabled(!isScreenShareEnabled, { + audio: true, + selfBrowserSurface: 'exclude', + }) + } + /> + )} + + + + + ); +} + +export function LivekitCallStatus({ session }: { session: LivekitJsCallSession }) { + const mx = useMatrixClient(); + const screenSize = useScreenSize(); + const room = mx.getRoom(session.roomId); + const compact = screenSize === ScreenSize.Mobile; + + if (!room) return null; + + return ( + + + + ) : ( + + ) + } + /> + ); +} diff --git a/src/app/features/call-status/NativeCallStatus.tsx b/src/app/features/call-status/NativeCallStatus.tsx new file mode 100644 index 000000000..2a8f44df6 --- /dev/null +++ b/src/app/features/call-status/NativeCallStatus.tsx @@ -0,0 +1,50 @@ +import { Box } from 'folds'; +import { useMatrixClient } from '$hooks/useMatrixClient'; +import { ScreenSize, useScreenSize } from '$hooks/useScreenSize'; +import type { NativeCallSession } from '$state/nativeCall'; +import { MicrophoneButton, VideoButton } from './CallControl'; +import { CallStatusShell, HangupChip } from './CallStatusShell'; +import { StatusDivider } from './components'; + +function NativeCallControl({ session, compact }: { session: NativeCallSession; compact: boolean }) { + // Media commands are rejected until the native room has connected. + const disabled = session.lifecycle !== 'connected'; + + return ( + + + session.setMicrophoneEnabled(!session.microphoneEnabled)} + disabled={disabled} + /> + {!compact && } + session.setCameraEnabled(!session.cameraEnabled)} + disabled={disabled} + /> + + + + + ); +} + +export function NativeCallStatus({ session }: { session: NativeCallSession }) { + const mx = useMatrixClient(); + const screenSize = useScreenSize(); + const room = mx.getRoom(session.roomId); + const compact = screenSize === ScreenSize.Mobile; + + if (!room) return null; + + return ( + } + /> + ); +} diff --git a/src/app/features/call/CallDevicePreview.css.ts b/src/app/features/call/CallDevicePreview.css.ts new file mode 100644 index 000000000..90252a911 --- /dev/null +++ b/src/app/features/call/CallDevicePreview.css.ts @@ -0,0 +1,51 @@ +import { style } from '@vanilla-extract/css'; +import { color, config, toRem } from 'folds'; + +export const PreviewSurface = style({ + position: 'relative', + width: '100%', + aspectRatio: '16 / 9', + borderRadius: config.radii.R400, + background: '#14171f', + color: color.Surface.OnContainer, + overflow: 'hidden', +}); + +export const PreviewVideo = style({ + position: 'absolute', + inset: 0, + width: '100%', + height: '100%', + objectFit: 'cover', + // Front cameras read as a mirror to the person looking at them. + transform: 'scaleX(-1)', +}); + +export const DeviceSelect = style({ + width: '100%', + minWidth: 0, + minHeight: toRem(36), + padding: `0 ${config.space.S200}`, + borderRadius: config.radii.R400, + border: `1px solid ${color.Surface.ContainerLine}`, + background: color.Surface.Container, + color: color.Surface.OnContainer, + font: 'inherit', + fontSize: toRem(14), +}); + +export const LevelTrack = style({ + width: '100%', + height: toRem(6), + borderRadius: config.radii.R400, + background: color.Surface.ContainerLine, + overflow: 'hidden', +}); + +export const LevelFill = style({ + width: '100%', + height: '100%', + transformOrigin: 'left center', + background: color.Success.Main, + transition: 'transform 80ms linear', +}); diff --git a/src/app/features/call/CallDevicePreview.test.tsx b/src/app/features/call/CallDevicePreview.test.tsx new file mode 100644 index 000000000..24bcd4f1e --- /dev/null +++ b/src/app/features/call/CallDevicePreview.test.tsx @@ -0,0 +1,96 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { CallDevicePreview } from './CallDevicePreview'; + +const mocks = vi.hoisted(() => ({ + usePreviewTracks: vi.fn<() => unknown[] | undefined>(), + useMediaDeviceSelect: vi.fn<(o: { kind: string }) => { devices: MediaDeviceInfo[] }>(), + previewOptions: undefined as unknown, + attach: vi.fn<(el: HTMLElement) => void>(), + detach: vi.fn<(el: HTMLElement) => void>(), +})); + +vi.mock('@livekit/components-react', () => ({ + // Mirror the real hook: it only returns tracks for the sources requested. + usePreviewTracks: (options: { audio: unknown; video: unknown }) => { + mocks.previewOptions = options; + const tracks = mocks.usePreviewTracks() ?? []; + return tracks.filter((track) => { + const { kind } = track as { kind: string }; + return kind === 'video' ? options.video !== false : options.audio !== false; + }); + }, + useMediaDeviceSelect: (options: { kind: string }) => mocks.useMediaDeviceSelect(options), + useTrackVolume: () => 0.25, +})); + +vi.mock('livekit-client', () => ({ + Track: { Kind: { Video: 'video', Audio: 'audio' } }, +})); + +const device = (deviceId: string, label: string, kind: string) => + ({ deviceId, label, kind }) as MediaDeviceInfo; + +const videoTrack = { kind: 'video', attach: mocks.attach, detach: mocks.detach }; +const audioTrack = { kind: 'audio' }; + +const props = { + microphone: true, + video: true, + onAudioDeviceChange: vi.fn<(id: string) => void>(), + onVideoDeviceChange: vi.fn<(id: string) => void>(), +}; + +beforeEach(() => { + mocks.usePreviewTracks.mockReset().mockReturnValue([videoTrack, audioTrack]); + mocks.useMediaDeviceSelect.mockReset().mockImplementation(({ kind }) => ({ + devices: + kind === 'audioinput' + ? [device('mic-1', 'Built-in Mic', kind), device('mic-2', 'USB Mic', kind)] + : [device('cam-1', 'FaceTime HD', kind)], + })); + mocks.attach.mockReset(); + mocks.detach.mockReset(); + props.onAudioDeviceChange.mockReset(); + props.onVideoDeviceChange.mockReset(); +}); + +describe('CallDevicePreview', () => { + it('attaches the preview camera track to a video element', () => { + render(); + + expect(mocks.attach).toHaveBeenCalledOnce(); + expect(screen.queryByText('Camera is off')).not.toBeInTheDocument(); + }); + + it('detaches the camera track on unmount so the call can claim it', () => { + const { unmount } = render(); + unmount(); + + expect(mocks.detach).toHaveBeenCalledOnce(); + }); + + it('requests only the devices the user actually enabled', () => { + render(); + + expect(mocks.previewOptions).toEqual({ audio: { deviceId: 'mic-2' }, video: false }); + expect(screen.getByText('Camera is off')).toBeInTheDocument(); + }); + + it('reports a chosen microphone so the call can honour it', async () => { + render(); + + await userEvent.selectOptions(screen.getByLabelText('Microphone'), 'mic-2'); + + expect(props.onAudioDeviceChange).toHaveBeenCalledWith('mic-2'); + }); + + it('shows a microphone level only while the microphone is on', () => { + const { rerender } = render(); + expect(screen.getByRole('meter', { name: 'Microphone level' })).toBeInTheDocument(); + + rerender(); + expect(screen.queryByRole('meter', { name: 'Microphone level' })).not.toBeInTheDocument(); + }); +}); diff --git a/src/app/features/call/CallDevicePreview.tsx b/src/app/features/call/CallDevicePreview.tsx new file mode 100644 index 000000000..c5573c0a1 --- /dev/null +++ b/src/app/features/call/CallDevicePreview.tsx @@ -0,0 +1,141 @@ +import { useEffect, useMemo, useRef } from 'react'; +import { Box, Text, config, toRem } from 'folds'; +import { useMediaDeviceSelect, usePreviewTracks, useTrackVolume } from '@livekit/components-react'; +import { Track, type LocalAudioTrack, type LocalVideoTrack } from 'livekit-client'; +import { VideoCameraSlash, sizedIcon } from '$components/icons/phosphor'; +import * as css from './CallDevicePreview.css'; + +type DeviceSelectProps = { + label: string; + kind: MediaDeviceKind; + deviceId?: string; + onChange: (deviceId: string) => void; + permissionsGranted: boolean; +}; + +function DeviceSelect({ label, kind, deviceId, onChange, permissionsGranted }: DeviceSelectProps) { + const { devices } = useMediaDeviceSelect({ kind, requestPermissions: permissionsGranted }); + + return ( + + {label} + + + ); +} + +function MicrophoneLevel({ track }: { track?: LocalAudioTrack }) { + const volume = useTrackVolume(track); + const level = Math.min(1, volume * 3); + + return ( + + Microphone level +
+
+
+ + ); +} + +function VideoPreview({ track }: { track?: LocalVideoTrack }) { + const videoRef = useRef(null); + + useEffect(() => { + const element = videoRef.current; + if (!track || !element) return undefined; + track.attach(element); + return () => { + track.detach(element); + }; + }, [track]); + + if (!track) { + return ( + + {sizedIcon(VideoCameraSlash, '400')} + Camera is off + + ); + } + + return ( + + + ); +} + +export type CallDevicePreviewProps = { + microphone: boolean; + video: boolean; + audioDeviceId?: string; + videoDeviceId?: string; + onAudioDeviceChange: (deviceId: string) => void; + onVideoDeviceChange: (deviceId: string) => void; +}; + +export function CallDevicePreview({ + microphone, + video, + audioDeviceId, + videoDeviceId, + onAudioDeviceChange, + onVideoDeviceChange, +}: CallDevicePreviewProps) { + const tracks = usePreviewTracks({ + audio: microphone ? { deviceId: audioDeviceId } : false, + video: video ? { deviceId: videoDeviceId } : false, + }); + + const videoTrack = useMemo( + () => tracks?.find((track) => track.kind === Track.Kind.Video) as LocalVideoTrack | undefined, + [tracks] + ); + const audioTrack = useMemo( + () => tracks?.find((track) => track.kind === Track.Kind.Audio) as LocalAudioTrack | undefined, + [tracks] + ); + + return ( + + + {microphone && } + + + + + + ); +} diff --git a/src/app/features/call/CallView.test.tsx b/src/app/features/call/CallView.test.tsx new file mode 100644 index 000000000..a7ac50970 --- /dev/null +++ b/src/app/features/call/CallView.test.tsx @@ -0,0 +1,144 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { LivekitJsCallStatus } from './CallView'; +import { NativeCallSurface } from './NativeCallSurface'; +import type { NativeCallSession } from '$state/nativeCall'; + +vi.mock('@sableclient/tauri-plugin-livekit-mobile', () => ({ + setNativeCallRemoteVideoOverlay: vi.fn<() => Promise>(() => Promise.resolve({})), + clearNativeCallRemoteVideoOverlay: vi.fn<() => Promise>(() => Promise.resolve({})), + setNativeCallLocalVideoOverlay: vi.fn<() => Promise>(() => Promise.resolve({})), + clearNativeCallLocalVideoOverlay: vi.fn<() => Promise>(() => Promise.resolve({})), +})); + +vi.mock('$hooks/useRoom', () => ({ useRoom: () => ({ roomId: '!room:example.org' }) })); +vi.mock('$hooks/router/useSelectedRoom', () => ({ + useSelectedRoom: () => '!room:example.org', +})); +vi.mock('$hooks/useCall', () => ({ useCallSession: () => ({}), useCallMembers: () => [] })); +vi.mock('./LivekitCallParticipant', () => ({ + useCallParticipantProfile: () => ({ name: 'Bob' }), + CallParticipantAvatar: () =>
, +})); + +const nativeSession = (lifecycle: NativeCallSession['lifecycle']): NativeCallSession => ({ + backend: 'livekit-mobile', + roomId: '!room:example.org', + callId: 'call-id', + lifecycle, + participants: [], + microphoneEnabled: true, + cameraEnabled: false, + setMicrophoneEnabled: async () => {}, + setCameraEnabled: async () => {}, + switchCamera: async () => {}, + listAudioRoutes: async () => [], + selectAudioRoute: async () => {}, + hangup: vi.fn<() => Promise>().mockResolvedValue(undefined), +}); + +describe('LiveKit JS call status', () => { + it('reports progress without exposing backend or transport details', () => { + render( + {}} + /> + ); + + expect(screen.getByText('Preparing call')).toBeInTheDocument(); + expect(screen.queryByText(/livekit|token|url|secret|e2ee/i)).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'End' })).toBeInTheDocument(); + }); + + it('explains a setup failure in plain language', () => { + render( + {}} + /> + ); + + expect(screen.getByText('Call failed')).toBeInTheDocument(); + expect(screen.getByText('Could not connect to the call.')).toBeInTheDocument(); + expect(screen.queryByText(/token|url|secret|error:/i)).not.toBeInTheDocument(); + }); + + it('gives an unsupported-encryption failure a dismiss route', () => { + const onHangup = vi.fn<() => void>(); + render( + + ); + + expect( + screen.getByText('Encrypted calls are not supported on this device.') + ).toBeInTheDocument(); + screen.getByRole('button', { name: 'Dismiss' }).click(); + expect(onHangup).toHaveBeenCalledOnce(); + }); +}); + +describe('native call surface', () => { + it('shows the local tile and call controls when connected', () => { + render( {}} />); + + expect(screen.getByText('You')).toBeInTheDocument(); + expect(screen.getAllByRole('button')).toHaveLength(3); + expect(screen.getByRole('button', { name: 'Mute microphone' })).toBeEnabled(); + expect(screen.getByRole('button', { name: 'Start camera' })).toBeEnabled(); + expect(screen.getByRole('button', { name: 'End call' })).toBeInTheDocument(); + }); + + it('keeps media toggles disabled while connecting', () => { + render( {}} />); + + expect(screen.getByText('Connecting')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Mute microphone' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Start camera' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'End call' })).toBeEnabled(); + }); + + it('renders a remote tile from the participants the session carries', () => { + render( + {}} + /> + ); + + expect(screen.getByText('Bob')).toBeInTheDocument(); + expect(screen.getByRole('img', { name: 'Poor connection' })).toBeInTheDocument(); + expect(screen.getByLabelText('Camera off')).toBeInTheDocument(); + }); + + it('gives failed calls an explicit dismiss route', () => { + const onHangup = vi.fn<() => void>(); + render( + + ); + + expect(screen.getByText('Call failed')).toBeInTheDocument(); + expect(screen.getByText('Native call connection failed.')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Dismiss' })).toBeInTheDocument(); + screen.getByRole('button', { name: 'Dismiss' }).click(); + expect(onHangup).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/app/features/call/CallView.tsx b/src/app/features/call/CallView.tsx index 62d79cd6d..5c7220f2a 100644 --- a/src/app/features/call/CallView.tsx +++ b/src/app/features/call/CallView.tsx @@ -12,7 +12,14 @@ import * as css from './styles.css'; import { CallMemberRenderer } from './CallMemberCard'; import { PrescreenControls } from './PrescreenControls'; import { callEmbedAtom, callEmbedStartErrorAtom } from '$state/callEmbed'; -import { canJoinCall } from './callStartCapabilities'; +import { canJoinCall } from '@sableclient/matrixrtc'; +import type { LivekitJsCallSession } from '$state/livekitJsCall'; +import { livekitJsCallAtom } from '$state/livekitJsCall'; +import { nativeCallAtom } from '$state/nativeCall'; +import { LivekitJsCallSurface } from './LivekitJsCallSurface'; +import { NativeCallSurface } from './NativeCallSurface'; +import { CallStatusBar } from './callChrome'; +import { livekitJsCallStatus } from './callClient'; function LivekitServerMissingMessage() { return ( @@ -80,6 +87,16 @@ function WidgetPreparationErrorMessage({ message }: { message: string }) { ); } +export function LivekitJsCallStatus({ + session, + onHangup, +}: { + session: Pick; + onHangup: () => void; +}) { + return ; +} + function CallPrescreen() { const room = useRoom(); const callEmbed = useAtomValue(callEmbedAtom); @@ -168,11 +185,22 @@ export function CallView({ resizable }: CallViewProps) { const callEmbed = useCallEmbed(); const callJoined = useCallJoined(callEmbed); - - const currentJoined = callEmbed?.roomId === room.roomId && callJoined; - + const livekitJsCall = useAtomValue(livekitJsCallAtom); + const nativeCall = useAtomValue(nativeCallAtom); + + const livekitJsCallForRoom = livekitJsCall?.roomId === room.roomId ? livekitJsCall : undefined; + const nativeCallForRoom = nativeCall?.roomId === room.roomId ? nativeCall : undefined; + const livekitJsRoom = + livekitJsCallForRoom?.lifecycle === 'active' ? livekitJsCallForRoom.room : undefined; + const currentJoined = + !livekitJsCallForRoom && !nativeCallForRoom && callEmbed?.roomId === room.roomId && callJoined; + + // A native call renders video tiles and a control bar, which need most of the + // viewport; the 0.3 default is sized for the Element Call participant list. const [heightRatio, setHeightRatio] = useState(isMobile ? 0.3 : 0.72); const [availableHeight, setAvailableHeight] = useState(0); + const effectiveHeightRatio = + isMobile && nativeCallForRoom ? Math.max(heightRatio, 0.75) : heightRatio; useEffect(() => { if (!resizable || !callViewRef.current) return undefined; @@ -263,12 +291,18 @@ export function CallView({ resizable }: CallViewProps) { minWidth: toRem(280), height: resizable ? availableHeight > 0 - ? `${availableHeight * heightRatio}px` - : `${heightRatio * 100}dvh` + ? `${availableHeight * effectiveHeightRatio}px` + : `${effectiveHeightRatio * 100}dvh` : undefined, borderBottom: `1px solid var(--sable-surface-container-line)`, zIndex: 20, - backgroundColor: currentJoined ? 'transparent' : undefined, + backgroundColor: + livekitJsRoom || nativeCallForRoom + ? color.Background.Container + : currentJoined + ? 'transparent' + : undefined, + overflow: livekitJsRoom || nativeCallForRoom ? 'hidden' : undefined, pointerEvents: currentJoined ? 'none' : 'all', }} > @@ -284,8 +318,27 @@ export function CallView({ resizable }: CallViewProps) { /> )} - {!currentJoined && } - + {!currentJoined && !livekitJsCallForRoom && !nativeCallForRoom && } + {livekitJsCallForRoom && livekitJsRoom ? ( + void livekitJsCallForRoom.hangup()} + /> + ) : livekitJsCallForRoom ? ( + void livekitJsCallForRoom.hangup()} + /> + ) : nativeCallForRoom ? ( + void nativeCallForRoom.hangup()} + /> + ) : ( + + )} {resizable && ( + ), + color: { + Critical: { Container: 'red', OnContainer: 'white' }, + Surface: { OnContainer: 'white' }, + Warning: { Container: 'yellow', OnContainer: 'black' }, + }, + config: { + radii: { R500: '5px' }, + space: { S100: '4px', S200: '8px', S300: '12px' }, + }, + Text: ({ children }: { children: ReactNode }) => {children}, + toRem: (value: number) => `${value}px`, +})); + +const room = {} as Room; +const initialMedia = { microphone: true, camera: false, sound: true }; + +const videoTrack = (source: Track.Source, isLocal: boolean, identity = 'hash-alice') => ({ + source, + participant: { isLocal, identity }, + publication: {}, +}); + +const placeholderTrack = (source: Track.Source, isLocal: boolean, identity = 'hash-alice') => ({ + source, + participant: { isLocal, identity }, + publication: undefined, +}); + +const surfaceElement = () => document.body.querySelector('[data-livekit-call-surface]'); +const controlsElement = () => document.body.querySelector('[data-livekit-controls]'); + +// jsdom has no mediaDevices, which is what a platform without screen capture +// looks like, so the supported case has to opt in. +const stubDisplayMedia = () => + Object.defineProperty(navigator, 'mediaDevices', { + configurable: true, + value: { getDisplayMedia: () => Promise.resolve() }, + }); + +beforeEach(() => { + Reflect.deleteProperty(navigator, 'mediaDevices'); + getDefaultStore().set(livekitJsCallInitialMediaAppliedAtom, false); + mocks.useConnectionState.mockReset().mockReturnValue('connected'); + mocks.useTracks.mockReset().mockReturnValue([]); + mocks.useParticipants.mockReset().mockReturnValue([]); + mocks.setMicrophoneEnabled.mockReset().mockResolvedValue(undefined); + mocks.setCameraEnabled.mockReset().mockResolvedValue(undefined); + mocks.onDeviceError = undefined; +}); + +describe('LiveKit JS call surface', () => { + it('publishes with the devices chosen in the prescreen preview', () => { + render( + {}} + /> + ); + + expect(mocks.setMicrophoneEnabled).toHaveBeenCalledWith(true, { deviceId: 'mic-2' }); + expect(mocks.setCameraEnabled).toHaveBeenCalledWith(true, { deviceId: 'cam-1' }); + }); + + it('leaves device selection to the browser when the user never picked one', () => { + render( + {}} + /> + ); + + expect(mocks.setMicrophoneEnabled).toHaveBeenCalledWith(true, undefined); + expect(mocks.setCameraEnabled).toHaveBeenCalledWith(true, undefined); + }); + + it('applies the prescreen media choice once media is ready', () => { + render( + {}} + /> + ); + + expect(mocks.setMicrophoneEnabled).toHaveBeenCalledWith(true, undefined); + expect(mocks.setCameraEnabled).toHaveBeenCalledWith(true, undefined); + }); + + it('joins muted when the prescreen microphone was off', () => { + render( + {}} + /> + ); + + expect(mocks.setMicrophoneEnabled).toHaveBeenCalledWith(false, undefined); + expect(mocks.setCameraEnabled).not.toHaveBeenCalled(); + }); + + it('keeps the call bar state when the surface remounts, instead of re-muting', () => { + const media = { microphone: false, camera: false, sound: true }; + const view = render( + {}} /> + ); + + expect(mocks.setMicrophoneEnabled).toHaveBeenCalledTimes(1); + view.unmount(); + + render( + {}} /> + ); + + expect(mocks.setMicrophoneEnabled).toHaveBeenCalledTimes(1); + }); + + it('publishes nothing until media is ready', () => { + render( + {}} + /> + ); + + expect(mocks.setMicrophoneEnabled).not.toHaveBeenCalled(); + expect(mocks.setCameraEnabled).not.toHaveBeenCalled(); + }); + + it('mutes incoming audio while the shared sound toggle is off', () => { + const store = createStore(); + store.set(livekitJsCallSoundAtom, false); + + render( + + {}} + /> + + ); + + expect(screen.getByTestId('room-audio')).toHaveAttribute('data-muted', 'true'); + }); + + it('lets LiveKit derive publish controls from the token grants', () => { + render( + {}} + /> + ); + + expect(screen.getByTestId('control-bar')).toHaveAttribute( + 'data-controls', + '{"leave":false,"screenShare":false}' + ); + expect(screen.getByTestId('device-menu')).toHaveAttribute('data-kind', 'audiooutput'); + }); + + it('hides the screen share toggle where getDisplayMedia is unavailable', () => { + render( + {}} + /> + ); + + expect(screen.queryByTestId('track-toggle')).not.toBeInTheDocument(); + }); + + it('shares the screen without offering the window showing the call', () => { + stubDisplayMedia(); + + render( + {}} + /> + ); + + const toggle = screen.getByTestId('track-toggle'); + expect(toggle).toHaveAttribute('data-source', Track.Source.ScreenShare); + expect(JSON.parse(toggle.getAttribute('data-capture-options') ?? '{}')).toMatchObject({ + selfBrowserSurface: 'exclude', + }); + }); + + it('surfaces a denied microphone instead of failing silently', async () => { + render( + {}} + /> + ); + + act(() => mocks.onDeviceError?.({ source: Track.Source.Microphone })); + + expect( + await screen.findByText('Microphone unavailable. Check your browser permissions.') + ).toBeInTheDocument(); + }); + + it('gives an unencrypted call its controls and devices with no key wait', () => { + // `mediaReady` is true from the start for an unencrypted room, where + // MSC4143 forbids MatrixRTC encryption and no Matrix key ever arrives. + render( + {}} + /> + ); + + expect(screen.getByTestId('control-bar')).toBeInTheDocument(); + expect(screen.queryByText('Securing call…')).not.toBeInTheDocument(); + expect(mocks.setMicrophoneEnabled).toHaveBeenCalledWith(true, undefined); + expect(mocks.setCameraEnabled).toHaveBeenCalledWith(true, undefined); + }); + + it('withholds media controls until call encryption is ready', () => { + render( + {}} + /> + ); + + expect(screen.queryByTestId('control-bar')).not.toBeInTheDocument(); + expect(screen.getByText('Securing call…')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'End call' })).toBeInTheDocument(); + }); + + it('keeps audio-only calls understandable with persistent controls', () => { + render( + {}} + /> + ); + + expect(screen.getByTestId('room-audio')).toBeInTheDocument(); + expect(screen.getByText('Audio call')).toBeInTheDocument(); + expect(screen.getByTestId('control-bar')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'End call' })).toBeInTheDocument(); + }); + + it('shows every participant when no camera is published, not an empty grid', () => { + mocks.useTracks.mockReturnValue([ + placeholderTrack(Track.Source.Camera, true), + placeholderTrack(Track.Source.Camera, false), + ]); + mocks.useParticipants.mockReturnValue([{ identity: 'hash-alice' }, { identity: 'hash-bob' }]); + + render( + {}} + /> + ); + + expect(screen.queryByTestId('grid-layout')).not.toBeInTheDocument(); + expect(screen.queryByTestId('focus-layout')).not.toBeInTheDocument(); + expect(screen.getAllByTestId('participant-avatar')).toHaveLength(2); + expect(screen.getByText('@alice:example.org')).toBeInTheDocument(); + expect(screen.getByText('@bob:example.org')).toBeInTheDocument(); + }); + + it('labels video tiles with the Matrix display name, never the LiveKit identity hash', () => { + mocks.useTracks.mockReturnValue([videoTrack(Track.Source.Camera, false)]); + + render( + {}} + /> + ); + + expect(screen.getByTestId('grid-layout')).toBeInTheDocument(); + expect(screen.getByText('@alice:example.org')).toBeInTheDocument(); + expect(screen.queryByText(/hash-/)).not.toBeInTheDocument(); + }); + + it('keeps the iOS webview from popping its own picture-in-picture overlay', () => { + mocks.useTracks.mockReturnValue([videoTrack(Track.Source.Camera, false)]); + + render( + {}} + /> + ); + + expect(screen.getByTestId('video-track')).toHaveAttribute('data-disable-pip', 'true'); + }); + + it('fills the call view container rather than the viewport', () => { + const { container } = render( + {}} + /> + ); + + const surface = container.querySelector('[data-livekit-call-surface]'); + expect(surface).not.toBeNull(); + expect(surface).toHaveStyle({ position: 'relative', width: '100%', height: '100%' }); + expect(screen.getByRole('region', { name: 'Call' })).toBe(surface); + }); + + it('puts the screen share in focus and keeps other tracks in a carousel', () => { + mocks.useTracks.mockReturnValue([ + videoTrack(Track.Source.ScreenShare, false), + videoTrack(Track.Source.Camera, false), + ]); + + render( + {}} + /> + ); + + expect(screen.getByTestId('focus-layout-container')).toBeInTheDocument(); + expect(screen.getByTestId('focus-layout')).toHaveAttribute( + 'data-focus-source', + Track.Source.ScreenShare + ); + expect(screen.getByTestId('carousel-layout')).toHaveAttribute('data-track-count', '1'); + expect(screen.queryByTestId('grid-layout')).not.toBeInTheDocument(); + expect(screen.queryByText('Audio call')).not.toBeInTheDocument(); + }); + + it('labels the focused screen share instead of leaving the default tile on top', () => { + mocks.useTracks.mockReturnValue([ + videoTrack(Track.Source.ScreenShare, false), + videoTrack(Track.Source.Camera, false), + ]); + + render( + {}} + /> + ); + + const focus = within(screen.getByTestId('focus-layout')); + expect(focus.getByText("@alice:example.org's screen")).toBeInTheDocument(); + expect(focus.queryByText(/hash-/)).not.toBeInTheDocument(); + }); + + it('keeps an active local screen share on stage instead of suppressing it', () => { + mocks.useTracks.mockReturnValue([ + videoTrack(Track.Source.ScreenShare, true), + videoTrack(Track.Source.Camera, false), + placeholderTrack(Track.Source.Camera, true), + ]); + + render( + {}} + /> + ); + + expect(screen.getByTestId('focus-layout')).toHaveAttribute( + 'data-focus-source', + Track.Source.ScreenShare + ); + expect(screen.getByRole('status')).toHaveTextContent('Sharing your screen'); + expect(screen.getByTestId('carousel-layout')).toHaveAttribute('data-track-count', '2'); + }); + + it('shows connection loss feedback without replacing the media canvas', () => { + mocks.useConnectionState.mockReturnValue('reconnecting'); + + render( + {}} + /> + ); + + expect(screen.getByRole('status')).toHaveTextContent('Reconnecting…'); + expect(screen.getByTestId('room-audio')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'End call' })).toBeInTheDocument(); + }); + + it('hides idle controls from view and hit testing, then reveals them on a canvas tap', () => { + vi.useFakeTimers(); + try { + render( + {}} + /> + ); + + const surface = surfaceElement()!; + const controls = controlsElement()!; + const pill = screen.getByTestId('control-bar').parentElement!; + + act(() => vi.advanceTimersByTime(3500)); + expect(controls).toHaveStyle({ opacity: '0', visibility: 'hidden' }); + expect(pill).toHaveStyle({ pointerEvents: 'none' }); + + act(() => fireEvent.pointerDown(surface)); + expect(controls).toHaveStyle({ opacity: '1', visibility: 'visible' }); + expect(pill).toHaveStyle({ pointerEvents: 'auto' }); + } finally { + vi.useRealTimers(); + } + }); + + it('reveals hidden controls on a key press, the only route a keyboard user has', () => { + vi.useFakeTimers(); + try { + render( + {}} + /> + ); + + const surface = surfaceElement()!; + const controls = controlsElement()!; + + act(() => vi.advanceTimersByTime(3500)); + expect(controls).toHaveStyle({ visibility: 'hidden' }); + + // focusIn cannot fire from a Tab press: visibility:hidden removes the + // controls from the tab order and nothing else inside the surface is + // focusable, so keydown is the reveal a keyboard user can actually reach. + act(() => fireEvent.keyDown(surface, { key: 'Tab' })); + expect(controls).toHaveStyle({ visibility: 'visible' }); + } finally { + vi.useRealTimers(); + } + }); + + it('labels the shared control surface for assistive technology', () => { + render( + {}} + /> + ); + + expect(screen.getByRole('group', { name: 'Call controls' })).toBe(controlsElement()); + }); + + it('ends the call from the control bar', () => { + const onHangup = vi.fn<() => void>(); + render( + + ); + + screen.getByRole('button', { name: 'End call' }).click(); + expect(onHangup).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/app/features/call/LivekitJsCallSurface.tsx b/src/app/features/call/LivekitJsCallSurface.tsx new file mode 100644 index 000000000..2dd4aa16e --- /dev/null +++ b/src/app/features/call/LivekitJsCallSurface.tsx @@ -0,0 +1,496 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useAtom, useAtomValue } from 'jotai'; +import { Box, Button, color, config, Text, toRem } from 'folds'; +import { + CarouselLayout, + ConnectionQualityIndicator, + ControlBar, + MediaDeviceMenu, + FocusLayout, + FocusLayoutContainer, + GridLayout, + ParticipantTile, + RoomAudioRenderer, + RoomContext, + TrackMutedIndicator, + TrackToggle, + useConnectionState, + useEnsureTrackRef, + useIsSpeaking, + useLocalParticipant, + useParticipants, + useTracks, + VideoTrack, +} from '@livekit/components-react'; +import { ConnectionState, Track, type Participant, type Room } from 'livekit-client'; +import { SpeakerHigh, sizedIcon } from '$components/icons/phosphor'; +import { useCallMembers, useCallSession } from '$hooks/useCall'; +import { useRoom } from '$hooks/useRoom'; +import { + livekitJsCallInitialMediaAppliedAtom, + livekitJsCallSoundAtom, + type LivekitJsCallMedia, +} from '$state/livekitJsCall'; +import { buildRtcIdentityMap, type UserIdByRtcIdentity } from '@sableclient/matrixrtc'; +import { + CallParticipantAvatar, + CallParticipantName, + useCallParticipantProfile, +} from './LivekitCallParticipant'; +import { CallControlBar, CallLayout } from './callChrome'; +import * as css from './LivekitJsCallSurface.css'; + +const controlIdleDelay = 3500; + +// Camera carries a placeholder so participants without video still get a tile; +// screen share must not, or every participant would fake a shared screen. +const trackSources = [ + { source: Track.Source.Camera, withPlaceholder: true }, + { source: Track.Source.ScreenShare, withPlaceholder: false }, +]; + +const trackOptions = { onlySubscribed: false }; + +function CallTileContent({ userIdByIdentity }: { userIdByIdentity: UserIdByRtcIdentity }) { + const trackRef = useEnsureTrackRef(); + const { participant, publication, source } = trackRef; + const profile = useCallParticipantProfile( + participant.identity, + participant.isLocal, + userIdByIdentity + ); + const isScreenShare = source === Track.Source.ScreenShare; + + return ( + <> + {publication ? ( + // Without this, iOS WKWebView pops its own PiP overlay over the call. + + ) : ( +
+ +
+ )} +
+
+ {!isScreenShare && ( + + )} + + {isScreenShare ? `${profile.name}'s screen` : profile.name} + +
+ +
+ + ); +} + +function GridTile({ userIdByIdentity }: { userIdByIdentity: UserIdByRtcIdentity }) { + return ( + + + + ); +} + +function CarouselTile({ userIdByIdentity }: { userIdByIdentity: UserIdByRtcIdentity }) { + return ( + + + + ); +} + +function AudioCallParticipant({ + participant, + userIdByIdentity, +}: { + participant: Participant; + userIdByIdentity: UserIdByRtcIdentity; +}) { + const profile = useCallParticipantProfile( + participant.identity, + participant.isLocal, + userIdByIdentity, + 192 + ); + const speaking = useIsSpeaking(participant); + + return ( + + + + + ); +} + +function AudioCallLayout({ userIdByIdentity }: { userIdByIdentity: UserIdByRtcIdentity }) { + const participants = useParticipants(); + + return ( + + + Audio call + + + {participants.map((participant) => ( + + ))} + + + ); +} + +function MediaLayout({ + tracks, + userIdByIdentity, +}: { + tracks: ReturnType; + userIdByIdentity: UserIdByRtcIdentity; +}) { + const screenShare = tracks.find((track) => track.source === Track.Source.ScreenShare); + + if (screenShare) { + const remainingTracks = tracks.filter((track) => track !== screenShare); + return ( + // Both layouts need an explicit width: their parent Box is a flex row, and + // every video is absolutely positioned, so laying out on intrinsic width + // collapses the focus pane to the carousel's and blanks the screen share. + + + + + {remainingTracks.length > 0 && ( + + + + )} + + ); + } + + return ( + + + + ); +} + +function ConnectionFeedback() { + const connectionState = useConnectionState(); + if (connectionState === ConnectionState.Connected) return null; + + // Only Disconnected is an actual loss. SignalReconnecting keeps media flowing + // while the signal link re-establishes, and Connecting is the initial + // handshake, so neither deserves the critical treatment. + const lost = connectionState === ConnectionState.Disconnected; + const label = + connectionState === ConnectionState.Connecting + ? 'Connecting…' + : lost + ? 'Connection lost' + : 'Reconnecting…'; + return ( + + {label} + + ); +} + +const deviceErrorMessages: Partial> = { + [Track.Source.Microphone]: 'Microphone unavailable. Check your browser permissions.', + [Track.Source.Camera]: 'Camera unavailable. Check your browser permissions.', + [Track.Source.ScreenShare]: 'Screen sharing was not started.', +}; + +function LivekitJsCallContent({ + mediaReady, + initialMedia, + onHangup, +}: { + mediaReady: boolean; + initialMedia: LivekitJsCallMedia; + onHangup: () => void; +}) { + const tracks = useTracks(trackSources, trackOptions); + const screenShareSupported = typeof navigator?.mediaDevices?.getDisplayMedia === 'function'; + const localScreenShare = tracks.some( + (track) => track.source === Track.Source.ScreenShare && track.participant.isLocal + ); + const hasVideo = tracks.some((track) => track.publication !== undefined); + const matrixRoom = useRoom(); + const callSession = useCallSession(matrixRoom); + const callMembers = useCallMembers(matrixRoom, callSession); + const userIdByIdentity = useMemo(() => buildRtcIdentityMap(callMembers), [callMembers]); + const [controlsVisible, setControlsVisible] = useState(true); + const [deviceError, setDeviceError] = useState(undefined); + const hideTimer = useRef | undefined>(undefined); + const { localParticipant } = useLocalParticipant(); + const soundEnabled = useAtomValue(livekitJsCallSoundAtom); + const [appliedInitialMedia, setAppliedInitialMedia] = useAtom( + livekitJsCallInitialMediaAppliedAtom + ); + + const handleDeviceError = useCallback(({ source }: { source: Track.Source }) => { + setDeviceError(deviceErrorMessages[source] ?? 'A media device is unavailable.'); + }, []); + + // In an encrypted room, publishing before the local key is imported would + // send frames in the clear, so the prescreen choice waits for `mediaReady`. + // An unencrypted room is ready straight away and never waits on Matrix keys. + useEffect(() => { + if (!mediaReady || appliedInitialMedia) return; + setAppliedInitialMedia(true); + localParticipant + .setMicrophoneEnabled( + initialMedia.microphone, + initialMedia.audioDeviceId ? { deviceId: initialMedia.audioDeviceId } : undefined + ) + .catch(() => handleDeviceError({ source: Track.Source.Microphone })); + if (initialMedia.camera) { + localParticipant + .setCameraEnabled( + true, + initialMedia.videoDeviceId ? { deviceId: initialMedia.videoDeviceId } : undefined + ) + .catch(() => handleDeviceError({ source: Track.Source.Camera })); + } + }, [ + mediaReady, + initialMedia, + localParticipant, + handleDeviceError, + appliedInitialMedia, + setAppliedInitialMedia, + ]); + + const revealControls = useCallback(() => { + setControlsVisible(true); + if (hideTimer.current) clearTimeout(hideTimer.current); + hideTimer.current = setTimeout(() => { + if (!document.activeElement?.closest('[data-livekit-controls]')) { + setControlsVisible(false); + } + }, controlIdleDelay); + }, []); + + useEffect(() => { + revealControls(); + return () => { + if (hideTimer.current) clearTimeout(hideTimer.current); + }; + }, [revealControls]); + + return ( + setControlsVisible(true)} + > + + + {hasVideo ? ( + + ) : ( + + )} + + + {deviceError && ( + + {deviceError} + + + )} + {localScreenShare && ( + + Sharing your screen + + )} + setControlsVisible(true)} + > + {mediaReady ? ( + <> + + {screenShareSupported && ( + // ControlBar hardcodes selfBrowserSurface: 'include', which offers + // the window showing the call and hangs the browser on capturing + // itself. + handleDeviceError({ source: Track.Source.ScreenShare })} + /> + )} + + {sizedIcon(SpeakerHigh, '300')} + + + ) : ( + + Securing call… + + )} + + + + ); +} + +export function LivekitJsCallSurface({ + room, + mediaReady, + initialMedia, + onHangup, +}: { + room: Room; + mediaReady: boolean; + initialMedia: LivekitJsCallMedia; + onHangup: () => void; +}) { + return ( + + + + ); +} diff --git a/src/app/features/call/NativeCallSurface.css.ts b/src/app/features/call/NativeCallSurface.css.ts new file mode 100644 index 000000000..e1212f1de --- /dev/null +++ b/src/app/features/call/NativeCallSurface.css.ts @@ -0,0 +1,171 @@ +import { style } from '@vanilla-extract/css'; +import { color, config, toRem } from 'folds'; + +export const StatusRow = style({ + flexShrink: 0, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + padding: `calc(${config.space.S200} + env(safe-area-inset-top, 0px)) ${config.space.S300} ${config.space.S100}`, + textAlign: 'center', +}); + +export const DominantStage = style({ + flex: 1, + minHeight: 0, + minWidth: 0, + position: 'relative', + display: 'flex', + flexDirection: 'column', +}); + +export const DominantTile = style({ + flex: 1, + position: 'relative', + display: 'flex', + flexDirection: 'column', + minWidth: 0, + minHeight: 0, + overflow: 'hidden', + background: color.Background.Container, +}); + +export const FloatingLocal = style({ + position: 'absolute', + bottom: `calc(${config.space.S200} + env(safe-area-inset-bottom, 0px))`, + right: `calc(${config.space.S200} + env(safe-area-inset-right, 0px))`, + width: '120px', + aspectRatio: '3 / 4', + // TileSlot sizes itself as a flex child (`flex: 1`); without a flex parent it + // collapses to the placeholder badge and the native video lands in a strip at + // the top of the card instead of filling it. + display: 'flex', + zIndex: 3, + borderRadius: config.radii.R400, + overflow: 'hidden', + border: `1px solid ${color.Surface.ContainerLine}`, + background: color.Surface.Container, +}); + +export const TileGrid = style({ + flex: 1, + minHeight: 0, + minWidth: 0, + display: 'grid', + gap: config.space.S200, + padding: config.space.S200, + overflowY: 'auto', + selectors: { + '&[data-cols="2"]': { + gridTemplateColumns: 'repeat(2, 1fr)', + gridAutoRows: '1fr', + }, + '&[data-cols="3"]': { + gridTemplateColumns: 'repeat(3, 1fr)', + gridAutoRows: 'min-content', + }, + }, +}); + +export const Tile = style({ + position: 'relative', + display: 'flex', + flexDirection: 'column', + minWidth: 0, + minHeight: 0, + overflow: 'hidden', + borderRadius: config.radii.R400, + background: color.Surface.Container, + outline: `1px solid ${color.Surface.ContainerLine}`, + outlineOffset: '-1px', +}); + +export const TileFixed = style({ + aspectRatio: '3 / 4', + flexShrink: 0, +}); + +// The rect this element occupies is what JS reports to the native side; keep +// it free of labels so the native video never covers them. +export const TileSlot = style({ + position: 'relative', + flex: 1, + minHeight: 0, + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + gap: config.space.S100, +}); + +export const InitialsBadge = style({ + display: 'grid', + placeItems: 'center', + width: 'min(72px, 45%)', + aspectRatio: '1 / 1', + borderRadius: '50%', + background: color.SurfaceVariant.Container, + color: color.SurfaceVariant.OnContainer, + fontSize: toRem(22), + fontWeight: 600, + lineHeight: 1, + userSelect: 'none', +}); + +export const TileLabel = style({ + flexShrink: 0, + display: 'flex', + alignItems: 'center', + gap: config.space.S100, + padding: `${config.space.S100} ${config.space.S200}`, + color: color.Surface.OnContainer, + fontSize: toRem(13), + lineHeight: toRem(18), + borderTop: `1px solid ${color.Surface.ContainerLine}`, +}); + +export const TileLabelName = style({ + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', +}); + +export const QualityDot = style({ + width: '8px', + height: '8px', + borderRadius: '50%', + flexShrink: 0, + selectors: { + '&[data-quality="good"]': { background: color.Success.Main }, + '&[data-quality="poor"]': { background: color.Warning.Main }, + '&[data-quality="lost"]': { background: color.Critical.Main }, + '&[data-quality="excellent"]': { background: color.Success.Main }, + // Neutral rather than a token: it reads as "no signal yet" against the dark + // video stage, where a themed surface colour would look like a real state. + '&[data-quality="unknown"]': { background: color.Surface.ContainerLine }, + }, +}); + +export const HangupButton = style({ + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + width: toRem(44), + height: toRem(44), + padding: 0, + border: 'none', + borderRadius: '50%', + background: '#f54336', + color: '#ffffff', + cursor: 'pointer', + transition: 'filter 120ms ease', + selectors: { + '&:hover': { + filter: 'brightness(1.12)', + }, + '&:focus-visible': { + outline: `2px solid ${color.Primary.Main}`, + outlineOffset: '2px', + }, + }, +}); diff --git a/src/app/features/call/NativeCallSurface.tsx b/src/app/features/call/NativeCallSurface.tsx new file mode 100644 index 000000000..974d47bf9 --- /dev/null +++ b/src/app/features/call/NativeCallSurface.tsx @@ -0,0 +1,637 @@ +import { + type CallParticipant, + type CallTrack, + type UserIdByRtcIdentity, + buildRtcIdentityMap, +} from '@sableclient/matrixrtc'; +import { type ReactNode, useEffect, useMemo, useState } from 'react'; +import { Box, config, Menu, MenuItem, Text, toRem } from 'folds'; +import type { NativeCallSession } from '$state/nativeCall'; +import { + ArrowsClockwise, + Check, + MicrophoneSlash, + PhoneDisconnect, + SpeakerHigh, + User, + VideoCameraSlash, + sizedIcon, +} from '$components/icons/phosphor'; +import { ResponsiveMenu } from '$components/ResponsiveMenu'; +import { useMenuAnchor } from '$hooks/useMenuAnchor'; +import { + clearNativeCallLocalVideoOverlay, + clearNativeCallRemoteVideoOverlay, + setNativeCallLocalVideoOverlay, + setNativeCallRemoteVideoOverlay, + type NativeCallAudioRoute, +} from '@sableclient/tauri-plugin-livekit-mobile'; +import { useCallMembers, useCallSession } from '$hooks/useCall'; +import { useRoom } from '$hooks/useRoom'; +import { useSelectedRoom } from '$hooks/router/useSelectedRoom'; +import { CallParticipantAvatar, useCallParticipantProfile } from './LivekitCallParticipant'; +import { CallControlBar, CallLayout, CallMediaControls, CallStatusBar } from './callChrome'; +import { controlButton } from './callChrome.css'; +import { nativeCallLifecycleLabels, nativeCallStatus } from './callClient'; +import * as css from './NativeCallSurface.css'; + +export type NativeCallSurfaceProps = { + session: NativeCallSession; + onHangup: () => void; +}; + +type OverlayTarget = { + participantIdentity: string; + trackId: string; +}; + +/** Detect overlays that do not change the slot's geometry. */ +function nativeSlotOccluded(slotNode: HTMLDivElement, rect: DOMRect): boolean { + const cx = rect.x + rect.width / 2; + const cy = rect.y + rect.height / 2; + if (cx < 0 || cy < 0 || cx > window.innerWidth || cy > window.innerHeight) return true; + const topElement = document.elementFromPoint(cx, cy); + return topElement === null || (topElement !== slotNode && !slotNode.contains(topElement)); +} + +/** Positions the native remote-video overlay over its DOM slot. */ +function useNativeVideoOverlay( + callId: string, + active: boolean, + target: OverlayTarget | undefined, + slotNode: HTMLDivElement | null +): void { + // Destructured to primitives: `target` is rebuilt on every transport update, + // so depending on the object would re-arm the observers and re-report an + // unchanged rect each time. + const participantIdentity = active ? target?.participantIdentity : undefined; + const trackId = active ? target?.trackId : undefined; + + useEffect(() => { + if (!participantIdentity || !trackId || !slotNode) return undefined; + + let lastGeometryKey = ''; + const report = () => { + const rect = slotNode.getBoundingClientRect(); + // Slot hidden, outside the viewport (display:none page, scrolled away), or + // occluded by an overlaying page/drawer: hide the native overlay too, + // otherwise it stays painted at a stale position over unrelated content. + const offscreen = + rect.width <= 0 || + rect.height <= 0 || + rect.right < 0 || + rect.bottom < 0 || + rect.left > window.innerWidth || + rect.top > window.innerHeight || + nativeSlotOccluded(slotNode, rect); + if (offscreen) { + if (lastGeometryKey !== '') { + lastGeometryKey = ''; + void clearNativeCallRemoteVideoOverlay({ callId }).catch(() => undefined); + } + return; + } + const geometryKey = `${Math.round(rect.x)},${Math.round(rect.y)},${Math.round(rect.width)},${Math.round(rect.height)}`; + if (geometryKey === lastGeometryKey) return; + lastGeometryKey = geometryKey; + void setNativeCallRemoteVideoOverlay({ + callId, + participantIdentity, + trackId, + x: rect.x, + y: rect.y, + width: rect.width, + height: rect.height, + devicePixelRatio: window.devicePixelRatio || 1, + }).catch(() => undefined); + }; + + report(); + const observer = new ResizeObserver(report); + observer.observe(slotNode); + window.addEventListener('resize', report); + // Scroll is the dominant rect-changing event: the message list (or any + // nested scroll container) moves the slot without resizing it. Capture + // phase reaches scrolls from nested containers, which don't bubble. + document.addEventListener('scroll', report, { capture: true, passive: true }); + return () => { + observer.disconnect(); + window.removeEventListener('resize', report); + document.removeEventListener('scroll', report, { capture: true }); + }; + }, [participantIdentity, trackId, slotNode, callId]); + + // No eligible target (camera muted/unpublished, reconnecting, error): the + // overlay must not linger over the tile grid. + useEffect(() => { + if (participantIdentity && trackId) return; + void clearNativeCallRemoteVideoOverlay({ callId }).catch(() => undefined); + }, [participantIdentity, trackId, callId]); + + useEffect( + () => () => { + // Unmount safety: a stale callId is a native no-op. + void clearNativeCallRemoteVideoOverlay({ callId }).catch(() => undefined); + }, + [callId] + ); +} + +/** Positions the native local-video overlay over its DOM slot. */ +function useNativeLocalVideoOverlay( + callId: string, + enabled: boolean, + cameraEnabled: boolean, + slotNode: HTMLDivElement | null +): void { + const active = enabled && cameraEnabled && slotNode; + + useEffect(() => { + if (!active || !slotNode) return undefined; + + let lastGeometryKey = ''; + const report = () => { + // Never tear down the preview while the page is hidden (e.g. PiP owns + // the layer during backgrounding). + if (document.visibilityState !== 'visible') return; + const rect = slotNode.getBoundingClientRect(); + // Slot hidden, outside the viewport (scrolled away), or occluded by an + // overlaying page/drawer: hide the native overlay too, otherwise it + // stays painted at a stale position over unrelated content. + const hidden = + rect.width <= 0 || + rect.height <= 0 || + rect.right < 0 || + rect.bottom < 0 || + rect.left > window.innerWidth || + rect.top > window.innerHeight || + nativeSlotOccluded(slotNode, rect); + if (hidden) { + if (lastGeometryKey !== '') { + lastGeometryKey = ''; + void clearNativeCallLocalVideoOverlay({ callId }).catch(() => undefined); + } + return; + } + const geometryKey = `${Math.round(rect.x)},${Math.round(rect.y)},${Math.round(rect.width)},${Math.round(rect.height)}`; + if (geometryKey === lastGeometryKey) return; + lastGeometryKey = geometryKey; + void setNativeCallLocalVideoOverlay({ + callId, + x: rect.x, + y: rect.y, + width: rect.width, + height: rect.height, + devicePixelRatio: window.devicePixelRatio || 1, + }).catch(() => undefined); + }; + + report(); + const observer = new ResizeObserver(report); + observer.observe(slotNode); + // Fires during slide transitions (transforms move the slot without any + // scroll/resize event), which is how recovery after a room change works. + const intersectionObserver = new IntersectionObserver(report, { + threshold: [0, 0.25, 0.5, 0.75, 1], + }); + intersectionObserver.observe(slotNode); + window.addEventListener('resize', report); + // Scroll is the dominant rect-changing event: any nested scroll container + // moves the slot without resizing it. Capture phase reaches scrolls from + // nested containers, which don't bubble. + document.addEventListener('scroll', report, { capture: true, passive: true }); + return () => { + observer.disconnect(); + intersectionObserver.disconnect(); + window.removeEventListener('resize', report); + document.removeEventListener('scroll', report, { capture: true }); + }; + }, [callId, slotNode, active]); + + useEffect(() => { + if (active) return; + void clearNativeCallLocalVideoOverlay({ callId }).catch(() => undefined); + }, [callId, active]); + + useEffect( + () => () => { + void clearNativeCallLocalVideoOverlay({ callId }).catch(() => undefined); + }, + [callId] + ); +} + +function LocalTile({ + session, + userIdByIdentity, + slotRef, + fixed, +}: { + session: NativeCallSession; + userIdByIdentity: UserIdByRtcIdentity; + slotRef?: (node: HTMLDivElement | null) => void; + fixed?: boolean; +}) { + const profile = useCallParticipantProfile('', true, userIdByIdentity); + + return ( +
+ {/* When the camera is on, the native local preview renders over this + slot; the avatar stays mounted underneath as the placeholder. */} +
+
+ +
+
+
+ {!session.microphoneEnabled && ( + + {sizedIcon(MicrophoneSlash, '200')} + + )} + You +
+
+ ); +} + +type RemoteTileProps = { + participant: CallParticipant; + userIdByIdentity: UserIdByRtcIdentity; + videoBound: boolean; + slotRef?: (node: HTMLDivElement | null) => void; + fixed?: boolean; +}; + +function RemoteTile({ + participant, + userIdByIdentity, + videoBound, + slotRef, + fixed, +}: RemoteTileProps) { + const profile = useCallParticipantProfile(participant.identity, false, userIdByIdentity); + return ( +
+ {/* When video is bound, the native view renders exactly over this slot; + the avatar stays mounted underneath as the pre-video placeholder. */} +
+
+ +
+
+
+ + {participant.camera?.muted && ( + + {sizedIcon(VideoCameraSlash, '200')} + + )} + {profile.name} +
+
+ ); +} + +/** Output picker, hidden where the platform has no routes. */ +function AudioRouteControl({ + session, + onMenuOpenChange, +}: { + session: NativeCallSession; + onMenuOpenChange: (open: boolean) => void; +}) { + const menu = useMenuAnchor(); + const [routes, setRoutes] = useState([]); + const open = menu.anchor !== undefined; + + useEffect(() => { + let disposed = false; + const load = async () => { + const next = await session.listAudioRoutes(); + if (!disposed) setRoutes(next); + }; + void load(); + return () => { + disposed = true; + }; + }, [session]); + + // The native video view sits above the webview, so it would paint over this + // menu. Report the open state so the surface can drop the overlay while it is + // up, the same way an occluding drawer does. + useEffect(() => { + onMenuOpenChange(open); + return () => onMenuOpenChange(false); + }, [open, onMenuOpenChange]); + + if (routes.length === 0) return null; + + return ( + + + {routes.map((route) => ( + + } + onClick={() => { + void session.selectAudioRoute(route.id); + menu.close(); + }} + > + {route.name} + + ))} + + + } + > + + + ); +} + +const qualityLabels: Record = { + excellent: 'Excellent connection', + good: 'Good connection', + poor: 'Poor connection', + lost: 'Connection lost', + unknown: 'Connection quality unknown', +}; + +/** The dot carries state through colour alone, so it needs its own label. */ +function QualityDot({ quality }: { quality: string | undefined }) { + const value = quality ?? 'unknown'; + return ( + + ); +} + +function RemoteDominantLabel({ + participant, + userIdByIdentity, +}: { + participant: CallParticipant; + userIdByIdentity: UserIdByRtcIdentity; +}) { + const profile = useCallParticipantProfile(participant.identity, false, userIdByIdentity); + return ( + <> + + {participant.camera?.muted && ( + + {sizedIcon(VideoCameraSlash, '200')} + + )} + {profile.name} + + ); +} + +function RemoteDominantPlaceholder({ + participant, + userIdByIdentity, +}: { + participant: CallParticipant; + userIdByIdentity: UserIdByRtcIdentity; +}) { + const profile = useCallParticipantProfile(participant.identity, false, userIdByIdentity, 192); + return ; +} + +/** Full-stage tile for a lone participant. */ +function DominantTile({ + slotRef, + videoBound, + placeholder, + label, +}: { + slotRef?: (node: HTMLDivElement | null) => void; + videoBound?: boolean; + placeholder: ReactNode; + label: ReactNode; +}) { + return ( +
+ {/* The slot's rect is what JS reports to the native video overlay; the + placeholder stays mounted underneath as the pre-video fallback. */} +
+
+ {placeholder} +
+
+
{label}
+
+ ); +} + +export function NativeCallSurface({ session, onHangup }: NativeCallSurfaceProps) { + const isError = session.lifecycle === 'error'; + const connected = session.lifecycle === 'connected'; + const remoteParticipants = session.participants; + const matrixRoom = useRoom(); + const callSession = useCallSession(matrixRoom); + const callMembers = useCallMembers(matrixRoom, callSession); + const userIdByIdentity = useMemo(() => buildRtcIdentityMap(callMembers), [callMembers]); + + const featured = useMemo(() => { + const live = (track: CallTrack | undefined): boolean => + track !== undefined && track.subscribed && !track.muted; + // A shared screen is the thing people are actually looking at, so it + // outranks any camera. + const sharing = remoteParticipants.find((p) => live(p.screenShare)); + if (sharing?.screenShare) { + return { participantIdentity: sharing.identity, trackId: sharing.screenShare.id }; + } + const onCamera = remoteParticipants.find((p) => live(p.camera)); + if (!onCamera?.camera) return undefined; + return { participantIdentity: onCamera.identity, trackId: onCamera.camera.id }; + }, [remoteParticipants]); + + // The room page stays mounted for the whole mobile slide-out transition, so + // geometry alone leaves the native video painted over the outgoing page until + // the slot finally clears the viewport. Route selection flips when the + // transition starts, which is the earliest honest "no longer on screen". + const selectedRoom = useSelectedRoom(); + const [routeMenuOpen, setRouteMenuOpen] = useState(false); + const overlayActive = connected && selectedRoom === session.roomId && !routeMenuOpen; + + const [slotNode, setSlotNode] = useState(null); + useNativeVideoOverlay(session.callId, overlayActive, featured, slotNode); + + const [localSlotNode, setLocalSlotNode] = useState(null); + useNativeLocalVideoOverlay(session.callId, overlayActive, session.cameraEnabled, localSlotNode); + + const remoteCount = remoteParticipants.length; + // Total tiles once the local self-tile joins the grid. 7+ switches the grid + // to its compact, scrollable three-column variant. + const tileCount = remoteCount + 1; + const compactGrid = tileCount > 6; + + // Two-person layout: the remote owns the stage and is video-bound here + // whenever its camera is the featured track. + const duoRemote = remoteCount === 1 ? remoteParticipants[0] : undefined; + const duoLive = + duoRemote !== undefined && connected && featured?.participantIdentity === duoRemote.identity; + + if (isError) { + return ( + + + + ); + } + + return ( + + {!connected && ( +
+ + {nativeCallLifecycleLabels[session.lifecycle]} + +
+ )} + {duoRemote ? ( +
+ + } + label={ + + } + /> + {session.cameraEnabled && ( +
+
+
+ {sizedIcon(User, '200')} +
+
+
+ )} +
+ ) : remoteCount === 0 ? ( +
+ + {!session.microphoneEnabled && ( + + {sizedIcon(MicrophoneSlash, '200')} + + )} + You + + } + /> +
+ ) : ( +
+ {remoteParticipants.map((participant) => { + const live = connected && featured?.participantIdentity === participant.identity; + return ( + + ); + })} + +
+ )} + + + {connected && } + {session.cameraEnabled && connected && ( + + )} + + +
+ ); +} diff --git a/src/app/features/call/PrescreenControls.tsx b/src/app/features/call/PrescreenControls.tsx index dd721c3cd..464fdf89c 100644 --- a/src/app/features/call/PrescreenControls.tsx +++ b/src/app/features/call/PrescreenControls.tsx @@ -8,6 +8,9 @@ import { useIsDirectRoom, useRoom } from '../../hooks/useRoom'; import { useCallEmbed, useCallJoined, useCallStart } from '../../hooks/useCallEmbed'; import { useCallPreferences } from '../../state/hooks/callPreferences'; import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; +import { settingsAtom } from '$state/settings'; +import { useSetting } from '$state/hooks/settings'; +import { CallDevicePreview } from './CallDevicePreview'; type PrescreenControlsProps = { canJoin?: boolean; @@ -28,70 +31,98 @@ export function PrescreenControls({ canJoin }: PrescreenControlsProps) { const disabled = inOtherCall || !canJoin; - const { microphone, video, sound, toggleMicrophone, toggleVideo, toggleSound } = - useCallPreferences(); + const { + microphone, + video, + sound, + audioDeviceId, + videoDeviceId, + toggleMicrophone, + toggleVideo, + toggleSound, + setAudioDeviceId, + setVideoDeviceId, + } = useCallPreferences(); + // Only the new-call path applies the chosen devices; Element Call picks its + // own, so previewing there would promise something we cannot honour. + const [newCallsEnabled] = useSetting(settingsAtom, 'newCallsEnabled'); + const showPreview = newCallsEnabled && !disabled && !joining && !compact; return ( - - + {showPreview && ( + + )} + - - - - + + + {!compact && } + - {!compact && } + {!compact && } - - - {room?.isCallRoom() && } - - - - - - + + {room?.isCallRoom() && } + + + + + + + ); } diff --git a/src/app/features/call/callChrome.css.ts b/src/app/features/call/callChrome.css.ts new file mode 100644 index 000000000..430d1ffff --- /dev/null +++ b/src/app/features/call/callChrome.css.ts @@ -0,0 +1,82 @@ +import { style } from '@vanilla-extract/css'; +import { color, config, toRem } from 'folds'; + +export const callLayout = style({ + minHeight: 0, + overflow: 'hidden', + background: color.Background.Container, +}); + +export const controlBarOverlay = style({ + position: 'absolute', + left: 0, + right: 0, + bottom: 0, + zIndex: 4, + display: 'flex', + justifyContent: 'center', + padding: `${config.space.S200} ${config.space.S300} calc(${config.space.S300} + env(safe-area-inset-bottom, 0px))`, + transition: 'opacity 160ms ease, visibility 160ms ease', + pointerEvents: 'none', +}); + +export const controlBarFlow = style({ + flexShrink: 0, + display: 'flex', + justifyContent: 'center', + padding: `${config.space.S100} ${config.space.S300} calc(${config.space.S300} + env(safe-area-inset-bottom, 0px))`, +}); + +export const controlPill = style({ + display: 'flex', + alignItems: 'center', + // Wraps rather than overflowing: the parent clips, so an unwrapped row loses + // the End call button off the edge on a narrow viewport. + flexWrap: 'wrap', + justifyContent: 'center', + gap: config.space.S200, + maxWidth: '100%', + padding: config.space.S100, + border: `1px solid ${color.Surface.ContainerLine}`, + borderRadius: config.radii.R500, + background: color.Surface.Container, + backdropFilter: 'blur(12px)', +}); + +export const controlButton = style({ + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + minWidth: toRem(44), + minHeight: toRem(44), + padding: 0, + border: 'none', + borderRadius: '50%', + font: 'inherit', + cursor: 'pointer', + transition: 'background-color 120ms ease', + selectors: { + '&[data-on="true"]': { + background: color.SurfaceVariant.Container, + color: color.SurfaceVariant.OnContainer, + }, + '&[data-on="true"]:hover:not(:disabled)': { + background: color.SurfaceVariant.ContainerHover, + }, + '&[data-on="false"]': { + background: color.Critical.Container, + color: color.Critical.OnContainer, + }, + '&[data-on="false"]:hover:not(:disabled)': { + background: color.Critical.ContainerHover, + }, + '&:focus-visible': { + outline: `2px solid var(--sable-primary-main, #7aa2ff)`, + outlineOffset: '2px', + }, + '&:disabled': { + opacity: 0.45, + cursor: 'default', + }, + }, +}); diff --git a/src/app/features/call/callChrome.tsx b/src/app/features/call/callChrome.tsx new file mode 100644 index 000000000..a08e6ccd8 --- /dev/null +++ b/src/app/features/call/callChrome.tsx @@ -0,0 +1,207 @@ +import type { + CSSProperties, + FocusEventHandler, + KeyboardEventHandler, + PointerEventHandler, + ReactNode, +} from 'react'; +import { Box, Button, color, Text } from 'folds'; +import { + Microphone, + MicrophoneSlash, + VideoCamera, + VideoCameraSlash, + sizedIcon, +} from '$components/icons/phosphor'; +import type { CallStatusView } from './callClient'; +import * as css from './callChrome.css'; + +/** Shared layout for web and native call surfaces. */ +export type CallLayoutProps = { + children: ReactNode; + stack?: boolean; + callSurfaceMarker?: boolean; + className?: string; + style?: CSSProperties; + onPointerMove?: PointerEventHandler; + onPointerDown?: PointerEventHandler; + onFocusCapture?: FocusEventHandler; + onKeyDown?: KeyboardEventHandler; +}; + +export function CallLayout({ + children, + stack, + callSurfaceMarker, + className, + style, + onPointerMove, + onPointerDown, + onFocusCapture, + onKeyDown, +}: CallLayoutProps) { + const mergedClassName = [css.callLayout, className].filter(Boolean).join(' ') || undefined; + return ( + + {children} + + ); +} + +/** Shared shell for the web overlay and native in-flow controls. */ +export type CallControlBarProps = { + children: ReactNode; + layout?: 'overlay' | 'flow'; + visible?: boolean; + onFocusCapture?: FocusEventHandler; +}; + +export function CallControlBar({ + children, + layout = 'flow', + visible, + onFocusCapture, +}: CallControlBarProps) { + if (layout === 'overlay') { + return ( + +
+ {children} +
+
+ ); + } + + return ( + +
{children}
+
+ ); +} + +/** Status display for call setup and failures. */ +export function CallStatusBar({ + status, + onHangup, +}: { + status: CallStatusView; + onHangup: () => void; +}) { + const failed = status.phase === 'failed'; + return ( + + + {status.statusLabel} + {status.error && ( + + {status.error} + + )} + + + + ); +} + +/** Shared mic and camera toggle. */ +export type CallMediaToggleButtonProps = { + on: boolean; + disabled?: boolean; + label: string; + children: ReactNode; + onToggle: () => void; +}; + +export function CallMediaToggleButton({ + on, + disabled, + label, + children, + onToggle, +}: CallMediaToggleButtonProps) { + return ( + + ); +} + +/** Native mic and camera controls. */ +export type CallMediaControlsProps = { + microphoneEnabled: boolean; + cameraEnabled: boolean; + setMicrophoneEnabled: (enabled: boolean) => Promise; + setCameraEnabled: (enabled: boolean) => Promise; + disabled?: boolean; +}; + +export function CallMediaControls({ + microphoneEnabled, + cameraEnabled, + setMicrophoneEnabled, + setCameraEnabled, + disabled, +}: CallMediaControlsProps) { + return ( + <> + void setMicrophoneEnabled(!microphoneEnabled)} + > + {sizedIcon(microphoneEnabled ? Microphone : MicrophoneSlash, '300', { + filled: !microphoneEnabled, + })} + + void setCameraEnabled(!cameraEnabled)} + > + {sizedIcon(cameraEnabled ? VideoCamera : VideoCameraSlash, '300', { + filled: cameraEnabled, + })} + + + ); +} diff --git a/src/app/features/call/callClient.ts b/src/app/features/call/callClient.ts new file mode 100644 index 000000000..c84597357 --- /dev/null +++ b/src/app/features/call/callClient.ts @@ -0,0 +1,89 @@ +import type { + LivekitJsControllerFailure, + LivekitJsControllerLifecycle, +} from '@sableclient/matrixrtc'; +import type { NativeCallLifecycle } from '$state/nativeCall'; + +type CallPhase = 'idle' | 'connecting' | 'connected' | 'reconnecting' | 'stopping' | 'failed'; + +export type CallStatusView = { + phase: CallPhase; + statusLabel: string; + error?: string; +}; + +const livekitJsLifecycleLabels: Record = { + idle: 'Idle', + 'joining-matrix': 'Joining call', + provisioning: 'Preparing call', + 'connecting-livekit': 'Connecting', + active: 'Connected', + stopping: 'Ending call', + failed: 'Call failed', +}; + +const livekitJsFailureMessages: Record = { + 'e2ee-unsupported': 'Encrypted calls are not supported on this device.', + 'e2ee-import-failed': 'Could not set up call encryption.', + 'setup-failed': 'Could not connect to the call.', +}; + +export const nativeCallLifecycleLabels: Record = { + starting: 'Starting call', + connecting: 'Connecting', + connected: 'Connected', + reconnecting: 'Reconnecting', + error: 'Call failed', +}; + +function livekitJsPhase(lifecycle: LivekitJsControllerLifecycle): CallPhase { + switch (lifecycle) { + case 'idle': + return 'idle'; + case 'active': + return 'connected'; + case 'stopping': + return 'stopping'; + case 'failed': + return 'failed'; + default: + // joining-matrix, provisioning, connecting-livekit are all "connecting". + return 'connecting'; + } +} + +function nativePhase(lifecycle: NativeCallLifecycle): CallPhase { + switch (lifecycle) { + case 'connected': + return 'connected'; + case 'reconnecting': + return 'reconnecting'; + case 'error': + return 'failed'; + default: + // starting, connecting are both "connecting". + return 'connecting'; + } +} + +export function livekitJsCallStatus(session: { + lifecycle: LivekitJsControllerLifecycle; + failure: LivekitJsControllerFailure | null; +}): CallStatusView { + return { + phase: livekitJsPhase(session.lifecycle), + statusLabel: livekitJsLifecycleLabels[session.lifecycle], + error: session.failure ? livekitJsFailureMessages[session.failure] : undefined, + }; +} + +export function nativeCallStatus(session: { + lifecycle: NativeCallLifecycle; + error?: string; +}): CallStatusView { + return { + phase: nativePhase(session.lifecycle), + statusLabel: nativeCallLifecycleLabels[session.lifecycle], + error: session.error, + }; +} diff --git a/src/app/features/call/callIntent.test.ts b/src/app/features/call/callIntent.test.ts deleted file mode 100644 index aa8ed4eef..000000000 --- a/src/app/features/call/callIntent.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { ElementCallIntent } from '$plugins/call/types'; -import { - normalizeCallIntent, - toCallNotificationType, - toCallNotificationTypeOrDefault, -} from './callIntent'; - -describe('callIntent', () => { - describe('normalizeCallIntent', () => { - it('prefers explicit intent kind', () => { - expect(normalizeCallIntent('video', 'start_call_dm_voice')).toBe('video'); - expect(normalizeCallIntent('audio', 'start_call_dm')).toBe('audio'); - }); - - it('maps Element Call voice intents to audio', () => { - expect(normalizeCallIntent(undefined, ElementCallIntent.StartCallDMVoice)).toBe('audio'); - expect(normalizeCallIntent(undefined, ElementCallIntent.JoinExistingVoice)).toBe('audio'); - }); - - it('maps Element Call non-voice intents to video', () => { - expect(normalizeCallIntent(undefined, ElementCallIntent.StartCallDM)).toBe('video'); - expect(normalizeCallIntent(undefined, ElementCallIntent.JoinExisting)).toBe('video'); - expect(normalizeCallIntent(undefined, ElementCallIntent.StartCall)).toBe('video'); - }); - - it('defaults unknown intents to audio', () => { - expect(normalizeCallIntent(undefined, undefined)).toBe('audio'); - expect(normalizeCallIntent(undefined, 'unknown_intent')).toBe('audio'); - }); - }); - - describe('toCallNotificationType', () => { - it('accepts ring and notification only', () => { - expect(toCallNotificationType('ring')).toBe('ring'); - expect(toCallNotificationType('notification')).toBe('notification'); - expect(toCallNotificationType('invalid')).toBeUndefined(); - }); - - it('defaults missing push types to ring', () => { - expect(toCallNotificationTypeOrDefault(undefined)).toBe('ring'); - expect(toCallNotificationTypeOrDefault('notification')).toBe('notification'); - }); - }); -}); diff --git a/src/app/features/call/callIntent.ts b/src/app/features/call/callIntent.ts deleted file mode 100644 index 4259ddd83..000000000 --- a/src/app/features/call/callIntent.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { ElementCallIntent } from '$plugins/call/types'; - -export type CallIntentKind = 'audio' | 'video'; -export type CallNotificationType = 'ring' | 'notification'; - -export const MAX_CALL_NOTIFICATION_LIFETIME_MS = 120_000; - -const VOICE_INTENTS = new Set([ - ElementCallIntent.StartCallVoice, - ElementCallIntent.JoinExistingVoice, - ElementCallIntent.StartCallDMVoice, - ElementCallIntent.JoinExistingDMVoice, -]); - -const KNOWN_INTENTS = new Set(Object.values(ElementCallIntent)); - -export const normalizeCallIntent = (intentKindRaw?: string, intentRaw?: string): CallIntentKind => { - if (intentKindRaw === 'audio' || intentKindRaw === 'video') { - return intentKindRaw; - } - if (!intentRaw) return 'audio'; - - const normalized = intentRaw.toLowerCase(); - if (VOICE_INTENTS.has(normalized) || normalized.includes('voice')) { - return 'audio'; - } - if (KNOWN_INTENTS.has(normalized) || normalized.includes('video')) { - return 'video'; - } - return 'audio'; -}; - -export const toCallNotificationType = (value: unknown): CallNotificationType | undefined => - value === 'ring' || value === 'notification' ? value : undefined; - -export const toCallNotificationTypeOrDefault = ( - value: unknown, - defaultType: CallNotificationType = 'ring' -): CallNotificationType => toCallNotificationType(value) ?? defaultType; diff --git a/src/app/features/call/callIntentCrossPath.test.ts b/src/app/features/call/callIntentCrossPath.test.ts index 4d45718ad..28974a0ad 100644 --- a/src/app/features/call/callIntentCrossPath.test.ts +++ b/src/app/features/call/callIntentCrossPath.test.ts @@ -5,7 +5,7 @@ import { REFERENCE_REL_TYPE, RTC_NOTIFICATION_EVENT_TYPE, type RtcNotificationEventLike, -} from './rtcNotificationParser'; +} from '@sableclient/matrixrtc'; const NOW = 1_700_000_000_000; const MY_USER_ID = '@self:example.org'; diff --git a/src/app/features/call/callMembershipState.test.ts b/src/app/features/call/callMembershipState.test.ts deleted file mode 100644 index 4d84f6ceb..000000000 --- a/src/app/features/call/callMembershipState.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { - getCallMembershipPresence, - isCallActive, - isIncomingCallActive, - isOutgoingCallPending, -} from './callMembershipState'; - -const MY_USER_ID = '@self:example.org'; - -describe('callMembershipState', () => { - it('detects incoming call when remote members exist without self', () => { - const memberships = [{ userId: '@remote:example.org' }]; - - expect(isIncomingCallActive(MY_USER_ID, memberships)).toBe(true); - expect(isCallActive(MY_USER_ID, memberships)).toBe(false); - expect(isOutgoingCallPending(MY_USER_ID, memberships)).toBe(false); - }); - - it('detects active call when self and remote members exist', () => { - const memberships = [{ userId: MY_USER_ID }, { userId: '@remote:example.org' }]; - - expect(isIncomingCallActive(MY_USER_ID, memberships)).toBe(false); - expect(isCallActive(MY_USER_ID, memberships)).toBe(true); - expect(isOutgoingCallPending(MY_USER_ID, memberships)).toBe(false); - }); - - it('detects pending outgoing call when only self is present', () => { - const memberships = [{ userId: MY_USER_ID }]; - - expect(getCallMembershipPresence(MY_USER_ID, memberships)).toEqual({ - hasSelfMember: true, - remoteMemberCount: 0, - }); - expect(isOutgoingCallPending(MY_USER_ID, memberships)).toBe(true); - }); -}); diff --git a/src/app/features/call/callMembershipState.ts b/src/app/features/call/callMembershipState.ts deleted file mode 100644 index 91948e05f..000000000 --- a/src/app/features/call/callMembershipState.ts +++ /dev/null @@ -1,49 +0,0 @@ -type RtcMembership = { userId?: string; sender?: string }; - -export type CallMembershipPresence = { - hasSelfMember: boolean; - remoteMemberCount: number; -}; - -export const getCallMembershipPresence = ( - mxUserId: string, - memberships: RtcMembership[] -): CallMembershipPresence => { - const remoteMemberCount = memberships.filter( - (membership) => (membership.userId || membership.sender) !== mxUserId - ).length; - const hasSelfMember = memberships.some( - (membership) => (membership.userId || membership.sender) === mxUserId - ); - - return { hasSelfMember, remoteMemberCount }; -}; - -export const isIncomingCallActive = (mxUserId: string, memberships: RtcMembership[]): boolean => { - const { hasSelfMember, remoteMemberCount } = getCallMembershipPresence(mxUserId, memberships); - - return remoteMemberCount > 0 && !hasSelfMember; -}; - -export const isCallActive = (mxUserId: string, memberships: RtcMembership[]): boolean => { - const { hasSelfMember, remoteMemberCount } = getCallMembershipPresence(mxUserId, memberships); - - return hasSelfMember && remoteMemberCount > 0; -}; - -export const isOutgoingCallPending = (mxUserId: string, memberships: RtcMembership[]): boolean => { - const { hasSelfMember, remoteMemberCount } = getCallMembershipPresence(mxUserId, memberships); - - return hasSelfMember && remoteMemberCount === 0; -}; - -export const getRemoteRtcMemberUserIds = ( - mxUserId: string, - memberships: RtcMembership[] -): Set => { - return new Set( - memberships - .map((membership) => membership.userId || membership.sender) - .filter((userId): userId is string => !!userId && userId !== mxUserId) - ); -}; diff --git a/src/app/features/call/callNotificationBridge.ts b/src/app/features/call/callNotificationBridge.ts index 3a40a1467..0490f6849 100644 --- a/src/app/features/call/callNotificationBridge.ts +++ b/src/app/features/call/callNotificationBridge.ts @@ -3,7 +3,7 @@ import { MAX_CALL_NOTIFICATION_LIFETIME_MS, normalizeCallIntent, toCallNotificationTypeOrDefault, -} from './callIntent'; +} from '@sableclient/matrixrtc'; type CallCandidate = { roomId: string; diff --git a/src/app/features/call/callSignalingDecrypt.ts b/src/app/features/call/callSignalingDecrypt.ts deleted file mode 100644 index ae6767ba2..000000000 --- a/src/app/features/call/callSignalingDecrypt.ts +++ /dev/null @@ -1,56 +0,0 @@ -import type { CryptoBackend, MatrixClient, MatrixEvent } from '$types/matrix-sdk'; -import { createDebugLogger } from '$utils/debugLogger'; -import { DECRYPT_TIMEOUT_MS } from './callSignalingPolicy'; - -const debugLog = createDebugLogger('CallSignaling'); - -export type DecryptedTimelineEvent = { - type?: string; - content?: unknown; -}; - -export const decryptRtcTimelineEvent = async ( - event: MatrixEvent, - mx: MatrixClient -): Promise => { - const crypto = mx.getCrypto(); - if (!crypto) return undefined; - - if (event.isDecryptionFailure()) return undefined; - - try { - if (!event.isBeingDecrypted()) { - await event.attemptDecryption(crypto as CryptoBackend); - } - - const decryptionPromise = event.getDecryptionPromise(); - if (decryptionPromise) { - let timeoutId: number | undefined; - await Promise.race([ - decryptionPromise.finally(() => { - if (timeoutId !== undefined) window.clearTimeout(timeoutId); - }), - new Promise((resolve) => { - timeoutId = window.setTimeout(resolve, DECRYPT_TIMEOUT_MS); - }), - ]); - } - } catch (error) { - debugLog.warn('call', 'RTC notification decryption failed', { - eventId: event.getId(), - roomId: event.getRoomId(), - error: error instanceof Error ? error.message : String(error), - }); - return undefined; - } - - if (event.isBeingDecrypted() || event.isDecryptionFailure()) { - return undefined; - } - - const effectiveEvent = event.getEffectiveEvent(); - return { - type: effectiveEvent.type, - content: effectiveEvent.content, - }; -}; diff --git a/src/app/features/call/callSignalingFallback.test.ts b/src/app/features/call/callSignalingFallback.test.ts index 8a5887ce1..af5545af1 100644 --- a/src/app/features/call/callSignalingFallback.test.ts +++ b/src/app/features/call/callSignalingFallback.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import type { IncomingCall } from '$state/callEmbed'; import { evaluateIncomingCallFallback } from './callSignalingFallback'; -import { INCOMING_MEMBERSHIP_GRACE_MS } from './callSignalingPolicy'; +import { INCOMING_MEMBERSHIP_GRACE_MS } from '@sableclient/matrixrtc'; const NOW = 1_700_000_000_000; // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/src/app/features/call/callSignalingFallback.ts b/src/app/features/call/callSignalingFallback.ts index b919a5705..c27e2da3c 100644 --- a/src/app/features/call/callSignalingFallback.ts +++ b/src/app/features/call/callSignalingFallback.ts @@ -1,9 +1,7 @@ +import { INCOMING_MEMBERSHIP_GRACE_MS, isIncomingCallActive } from '@sableclient/matrixrtc'; import type { IncomingCall } from '$state/callEmbed'; import type { Room } from '$types/matrix-sdk'; -import { isIncomingCallActive } from './callMembershipState'; -import { INCOMING_MEMBERSHIP_GRACE_MS } from './callSignalingPolicy'; - export type IncomingFallbackAction = | { kind: 'none' } | { kind: 'clear'; reason: 'expired' | 'missing_room' | 'membership_dropped' }; diff --git a/src/app/features/call/callSignalingPolicy.ts b/src/app/features/call/callSignalingPolicy.ts deleted file mode 100644 index eb9587a24..000000000 --- a/src/app/features/call/callSignalingPolicy.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** Shared timing policy for call signaling, notifications, and membership fallback. */ -export const MAX_NOTIFICATION_LIFETIME_MS = 120_000; -export const DECRYPT_TIMEOUT_MS = 8_000; -export const FALLBACK_INTERVAL_MS = 5_000; -export const OUTGOING_RING_TIMEOUT_MS = 30_000; -/** Grace window before clearing incoming call when membership lags behind RTC notification. */ -export const INCOMING_MEMBERSHIP_GRACE_MS = 15_000; -/** Delay before clearing embed after outgoing decline hangup completes. */ -export const OUTGOING_DECLINE_EMBED_CLEAR_MS = 2_000; diff --git a/src/app/features/call/callStartCapabilities.test.ts b/src/app/features/call/callStartCapabilities.test.ts deleted file mode 100644 index d7264efa0..000000000 --- a/src/app/features/call/callStartCapabilities.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import type { Room } from '$types/matrix-sdk'; -import { evaluateCallStartCapabilities } from './callStartCapabilities'; - -const createRoom = (roomId: string, canSend = true): Room => - ({ - roomId, - currentState: { - maySendStateEvent: () => canSend, - }, - }) as unknown as Room; - -describe('evaluateCallStartCapabilities', () => { - it('allows call start when all capabilities are available', () => { - const capabilities = evaluateCallStartCapabilities({ - room: createRoom('!room:example.org'), - myUserId: '@me:example.org', - activeCallRoomId: undefined, - livekitSupported: true, - rtcSupported: true, - }); - - expect(capabilities.canStart).toBe(true); - expect(capabilities.canRenderCallButton).toBe(true); - expect(capabilities.blockers).toHaveLength(0); - }); - - it('blocks and hides button when WebRTC is unsupported', () => { - const capabilities = evaluateCallStartCapabilities({ - room: createRoom('!room:example.org'), - myUserId: '@me:example.org', - activeCallRoomId: undefined, - livekitSupported: true, - rtcSupported: false, - }); - - expect(capabilities.canStart).toBe(false); - expect(capabilities.canRenderCallButton).toBe(false); - expect(capabilities.blockers).toContain('missing_webrtc'); - }); - - it('blocks and hides button when call-member permission is missing', () => { - const capabilities = evaluateCallStartCapabilities({ - room: createRoom('!room:example.org', false), - myUserId: '@me:example.org', - activeCallRoomId: undefined, - livekitSupported: true, - rtcSupported: true, - }); - - expect(capabilities.canStart).toBe(false); - expect(capabilities.canRenderCallButton).toBe(false); - expect(capabilities.blockers).toContain('missing_call_member_permission'); - }); - - it('blocks start but keeps button visible when already in another call', () => { - const capabilities = evaluateCallStartCapabilities({ - room: createRoom('!room:example.org'), - myUserId: '@me:example.org', - activeCallRoomId: '!other:example.org', - livekitSupported: true, - rtcSupported: true, - }); - - expect(capabilities.canStart).toBe(false); - expect(capabilities.canRenderCallButton).toBe(true); - expect(capabilities.blockers).toEqual(['already_in_another_call']); - }); - - it('does not block when active call is in the same room', () => { - const capabilities = evaluateCallStartCapabilities({ - room: createRoom('!room:example.org'), - myUserId: '@me:example.org', - activeCallRoomId: '!room:example.org', - livekitSupported: true, - rtcSupported: true, - }); - - expect(capabilities.canStart).toBe(true); - expect(capabilities.inAnotherCall).toBe(false); - }); -}); diff --git a/src/app/features/call/callStartCapabilities.ts b/src/app/features/call/callStartCapabilities.ts deleted file mode 100644 index 9de777230..000000000 --- a/src/app/features/call/callStartCapabilities.ts +++ /dev/null @@ -1,69 +0,0 @@ -import type { Room } from '$types/matrix-sdk'; - -const CALL_MEMBER_EVENT_TYPE = 'org.matrix.msc3401.call.member'; - -export type CallStartBlocker = - | 'missing_webrtc' - | 'missing_livekit' - | 'missing_call_member_permission' - | 'already_in_another_call'; - -export type CallStartCapabilities = { - canStart: boolean; - canRenderCallButton: boolean; - blockers: CallStartBlocker[]; - webRTCSupported: boolean; - livekitSupported: boolean; - hasCallMemberPermission: boolean; - inAnotherCall: boolean; -}; - -export const canJoinCall = ( - capabilities: CallStartCapabilities, - hasParticipant: boolean -): boolean => - capabilities.canStart || - (hasParticipant && - capabilities.webRTCSupported && - capabilities.hasCallMemberPermission && - !capabilities.inAnotherCall); - -type EvaluateCallStartCapabilitiesInput = { - room: Room; - myUserId: string; - activeCallRoomId?: string; - livekitSupported: boolean; - rtcSupported: boolean; -}; - -export const evaluateCallStartCapabilities = ({ - room, - myUserId, - activeCallRoomId, - livekitSupported, - rtcSupported, -}: EvaluateCallStartCapabilitiesInput): CallStartCapabilities => { - const blockers: CallStartBlocker[] = []; - const hasCallMemberPermission = - room.currentState?.maySendStateEvent(CALL_MEMBER_EVENT_TYPE, myUserId) ?? false; - const inAnotherCall = !!activeCallRoomId && activeCallRoomId !== room.roomId; - - if (!rtcSupported) blockers.push('missing_webrtc'); - if (!livekitSupported) blockers.push('missing_livekit'); - if (!hasCallMemberPermission) blockers.push('missing_call_member_permission'); - if (inAnotherCall) blockers.push('already_in_another_call'); - - const canRenderCallButton = !blockers.some((blocker) => - ['missing_webrtc', 'missing_livekit', 'missing_call_member_permission'].includes(blocker) - ); - - return { - canStart: blockers.length === 0, - canRenderCallButton, - blockers, - webRTCSupported: rtcSupported, - livekitSupported, - hasCallMemberPermission, - inAnotherCall, - }; -}; diff --git a/src/app/features/call/getIncomingCallBlockers.test.ts b/src/app/features/call/getIncomingCallBlockers.test.ts deleted file mode 100644 index 250d20e24..000000000 --- a/src/app/features/call/getIncomingCallBlockers.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { getIncomingCallBlockers } from './getIncomingCallBlockers'; - -describe('getIncomingCallBlockers', () => { - it('returns no blockers when all capabilities are available', () => { - expect( - getIncomingCallBlockers({ - canUseWebRTC: true, - hasCallMemberPermission: true, - inAnotherCall: false, - }) - ).toEqual([]); - }); - - it('returns blockers in priority order', () => { - const issues = getIncomingCallBlockers({ - canUseWebRTC: false, - hasCallMemberPermission: false, - inAnotherCall: true, - }); - - expect(issues.map((issue) => issue.id)).toEqual(['webrtc', 'permission', 'another_call']); - }); -}); diff --git a/src/app/features/call/getIncomingCallBlockers.ts b/src/app/features/call/getIncomingCallBlockers.ts deleted file mode 100644 index 5759fdc4f..000000000 --- a/src/app/features/call/getIncomingCallBlockers.ts +++ /dev/null @@ -1,43 +0,0 @@ -export type IncomingCallBlocker = { - id: string; - message: string; - shortReason: string; -}; - -export type IncomingCallBlockerInput = { - canUseWebRTC: boolean; - hasCallMemberPermission: boolean; - inAnotherCall: boolean; -}; - -export const getIncomingCallBlockers = ({ - canUseWebRTC, - hasCallMemberPermission, - inAnotherCall, -}: IncomingCallBlockerInput): IncomingCallBlocker[] => { - const issues: IncomingCallBlocker[] = []; - - if (!canUseWebRTC) { - issues.push({ - id: 'webrtc', - message: 'Your browser does not support WebRTC calling.', - shortReason: 'WebRTC is unavailable in this browser.', - }); - } - if (!hasCallMemberPermission) { - issues.push({ - id: 'permission', - message: "You don't have permission to join this room's call.", - shortReason: 'Missing permission to join this call.', - }); - } - if (inAnotherCall) { - issues.push({ - id: 'another_call', - message: 'You are already in another call.', - shortReason: 'Finish your current call first.', - }); - } - - return issues; -}; diff --git a/src/app/features/call/livekitJsCallManager.test.tsx b/src/app/features/call/livekitJsCallManager.test.tsx new file mode 100644 index 000000000..f4be4e8eb --- /dev/null +++ b/src/app/features/call/livekitJsCallManager.test.tsx @@ -0,0 +1,314 @@ +import type { ReactNode } from 'react'; +import { useEffect } from 'react'; +import { act, render, waitFor } from '@testing-library/react'; +import { createStore, Provider as JotaiProvider } from 'jotai'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { MatrixClient, Room } from '$types/matrix-sdk'; +import { MatrixClientProvider } from '$hooks/useMatrixClient'; +import { AutoDiscoveryInfoProvider } from '$hooks/useAutoDiscoveryInfo'; +import { CallEmbedRefContextProvider } from '$hooks/useCallEmbed'; +import { getSettings, settingsAtom, type Settings } from '$state/settings'; +import { livekitJsCallAtom } from '$state/livekitJsCall'; +import { nativeCallAtom, type NativeCallSession } from '$state/nativeCall'; +import { + type LivekitJsCallManager, + LivekitJsCallManagerProvider, + useLivekitJsCallManager, +} from './livekitJsCallManager'; +import type { LivekitJsControllerState } from '@sableclient/matrixrtc'; + +vi.mock('$hooks/useTheme', () => ({ + ThemeKind: { Light: 'light', Dark: 'dark' }, + useTheme: () => ({ kind: 'light' }), +})); + +vi.mock('$hooks/useClientConfig', () => ({ + useClientConfig: () => ({ elementCallUrl: undefined }), +})); + +const { createControllerMock } = vi.hoisted(() => ({ + createControllerMock: vi.fn<(...args: unknown[]) => unknown>(), +})); + +vi.mock('@sableclient/matrixrtc', async (importOriginal) => ({ + ...(await importOriginal()), + createLivekitJsController: createControllerMock, +})); + +type ControllerListener = (state: Readonly) => void; + +type FakeController = { + connect: ReturnType; + disconnect: ReturnType; + subscribe: (listener: ControllerListener) => () => void; + getState: () => LivekitJsControllerState; + emit: (state: Partial) => void; + listenerCount: () => number; +}; + +const makeFakeController = (): FakeController => { + const listeners = new Set(); + let state: LivekitJsControllerState = { + lifecycle: 'idle', + failure: null, + mediaReady: false, + e2ee: { + ready: false, + localOutboundIdentity: null, + keyIndex: null, + lastImportFailure: null, + }, + }; + const snapshot = (): LivekitJsControllerState => ({ + ...state, + e2ee: { ...state.e2ee }, + }); + return { + connect: vi.fn<(...args: unknown[]) => Promise>(async () => undefined), + disconnect: vi.fn<() => Promise>(async () => undefined), + getState: () => snapshot(), + subscribe: (listener) => { + listeners.add(listener); + listener(snapshot()); + return () => { + listeners.delete(listener); + }; + }, + emit: (next) => { + state = { ...state, ...next }; + listeners.forEach((listener) => listener(snapshot())); + }, + listenerCount: () => listeners.size, + }; +}; + +const room = { roomId: '!room:example.org' } as Room; + +const makeNativeSession = (lifecycle: NativeCallSession['lifecycle']): NativeCallSession => ({ + backend: 'livekit-mobile', + roomId: room.roomId, + callId: 'native-call-id', + lifecycle, + participants: [], + microphoneEnabled: true, + cameraEnabled: false, + setMicrophoneEnabled: async () => {}, + setCameraEnabled: async () => {}, + switchCamera: async () => {}, + listAudioRoutes: async () => [], + selectAudioRoute: async () => {}, + hangup: async () => undefined, +}); + +const memberships: string[] = []; +const mx = { + matrixRTC: { + getRoomSession: vi.fn<() => unknown>(() => ({ memberships })), + }, +} as unknown as MatrixClient; + +type Harness = { + store: ReturnType; + controllers: FakeController[]; + wrapper: ({ children }: { children?: ReactNode }) => ReactNode; + managers: Array; + Consumer: () => null; +}; + +const createHarness = (settingsOverrides: Partial = {}): Harness => { + const store = createStore(); + store.set(settingsAtom, { ...getSettings(), ...settingsOverrides }); + const controllers: FakeController[] = []; + createControllerMock.mockImplementation(() => { + const controller = makeFakeController(); + controllers.push(controller); + return controller; + }); + const managers: Array = []; + const Consumer = (): null => { + managers.push(useLivekitJsCallManager()); + return null; + }; + const embedRef = { current: null }; + const wrapper = ({ children }: { children?: ReactNode }) => ( + + + + + {children} + + + + + ); + return { store, controllers, wrapper, managers, Consumer }; +}; + +const currentManager = (harness: Harness): LivekitJsCallManager => { + const manager = harness.managers[harness.managers.length - 1]; + expect(manager).toBeDefined(); + return manager!; +}; + +describe('LivekitJsCallManagerProvider', () => { + beforeEach(() => { + createControllerMock.mockReset(); + }); + + it('start then consumer unmount/replacement does not disconnect the controller', () => { + const harness = createHarness(); + const view = render( + + + + ); + const controller = harness.controllers[0]!; + act(() => { + currentManager(harness).start({ room, video: true }); + }); + expect(controller.connect).toHaveBeenCalledTimes(1); + expect(controller.connect.mock.calls[0]![0]).toMatchObject({ + room, + callIntent: 'video', + }); + + // Consumer unmounts (prescreen replaced by call view) while provider persists. + view.rerender({undefined}); + + expect(controller.disconnect).not.toHaveBeenCalled(); + expect(controller.listenerCount()).toBe(1); + + // A replacement consumer mounts and still sees the same controller/manager. + view.rerender( + + + + ); + expect(controller.disconnect).not.toHaveBeenCalled(); + }); + + it('explicit end via session hangup disconnects exactly once', async () => { + const harness = createHarness(); + render( + + + + ); + const controller = harness.controllers[0]!; + act(() => { + currentManager(harness).start({ room, video: false }); + }); + act(() => { + controller.emit({ lifecycle: 'active', room: {} as never }); + }); + const session = harness.store.get(livekitJsCallAtom); + expect(session?.roomId).toBe(room.roomId); + expect(session?.lifecycle).toBe('active'); + + await act(async () => { + await session!.hangup(); + }); + + expect(controller.disconnect).toHaveBeenCalledTimes(1); + }); + + it('child passive effect (auto-join) can start on initial mount', () => { + const harness = createHarness({ newCallsEnabled: true }); + const AutoJoinChild = (): null => { + const manager = useLivekitJsCallManager(); + useEffect(() => { + manager?.start({ room, video: false }); + }, [manager]); + return null; + }; + + render( + + + + ); + + const controller = harness.controllers[0]!; + expect(controller.connect).toHaveBeenCalledTimes(1); + expect(controller.connect.mock.calls[0]![0]).toMatchObject({ + room, + callIntent: 'audio', + }); + }); + + it('provider unmount disconnects exactly once', async () => { + const harness = createHarness(); + const view = render({undefined}); + const controller = harness.controllers[0]!; + + view.unmount(); + + await waitFor(() => { + expect(controller.disconnect).toHaveBeenCalledTimes(1); + }); + expect(controller.listenerCount()).toBe(0); + expect(harness.store.get(livekitJsCallAtom)).toBeUndefined(); + }); + + it('multiple consumers share one controller for one call', () => { + const harness = createHarness({ newCallsEnabled: true }); + render( + + + + + + ); + const controller = harness.controllers[0]!; + + // Prescreen starts the call. + act(() => { + harness.managers[0]!.start({ room, video: false }); + }); + act(() => { + controller.emit({ lifecycle: 'joining-matrix' }); + }); + // Room header and auto-join attempt the same call while one is active. + act(() => { + harness.managers[1]!.start({ room, video: false }); + harness.managers[2]!.start({ room, video: false }); + }); + + expect(controller.connect).toHaveBeenCalledTimes(1); + }); + + it('refuses to start while a native call is active and publishes no failed session', () => { + const harness = createHarness(); + harness.store.set(nativeCallAtom, makeNativeSession('connected')); + render( + + + + ); + const controller = harness.controllers[0]!; + + act(() => { + currentManager(harness).start({ room, video: false }); + }); + + expect(controller.connect).not.toHaveBeenCalled(); + expect(harness.store.get(livekitJsCallAtom)).toBeUndefined(); + }); + + it('starts when the native session is in a terminal error state', () => { + const harness = createHarness(); + harness.store.set(nativeCallAtom, makeNativeSession('error')); + render( + + + + ); + const controller = harness.controllers[0]!; + + act(() => { + currentManager(harness).start({ room, video: false }); + }); + + expect(controller.connect).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/app/features/call/livekitJsCallManager.tsx b/src/app/features/call/livekitJsCallManager.tsx new file mode 100644 index 000000000..27c712569 --- /dev/null +++ b/src/app/features/call/livekitJsCallManager.tsx @@ -0,0 +1,155 @@ +import { createLivekitJsController, isCallOngoing } from '@sableclient/matrixrtc'; +import type { ReactNode } from 'react'; +import { createContext, useCallback, useContext, useEffect, useMemo, useRef } from 'react'; +import { useStore } from 'jotai'; +import type { Room } from '$types/matrix-sdk'; +import { + livekitJsCallAtom, + livekitJsCallInitialMediaAppliedAtom, + livekitJsCallSoundAtom, + type LivekitJsCallMedia, +} from '$state/livekitJsCall'; +import { callInProgressAtom } from '$state/nativeCall'; +import { useMatrixClient } from '$hooks/useMatrixClient'; +import { useAutoDiscoveryInfo } from '$hooks/useAutoDiscoveryInfo'; +import { acquireCallOwner } from '$state/callOwner'; +import { getSlidingSyncManager } from '$client/initMatrix'; +import { fetch as appFetch } from '$utils/fetch'; + +export type LivekitJsCallStartOptions = { + room: Room; + dm?: boolean; + video?: boolean; + microphone?: boolean; + sound?: boolean; + audioDeviceId?: string; + videoDeviceId?: string; +}; + +export type LivekitJsCallManager = { + start: (options: LivekitJsCallStartOptions) => void; +}; + +const LivekitJsCallManagerContext = createContext(undefined); + +export const useLivekitJsCallManager = (): LivekitJsCallManager | undefined => + useContext(LivekitJsCallManagerContext); + +type LivekitJsController = ReturnType; + +type LivekitJsCallManagerProviderProps = { + children?: ReactNode; +}; + +/** + * Provider-level owner of the single LiveKit JS call controller. Mounted with + * CallEmbedProvider so transient consumers (prescreen, room header, auto-join) + * never own or disconnect the controller themselves. Owns: creation, + * subscription -> livekitJsCallAtom, and provider-unmount disconnect. + */ +export function LivekitJsCallManagerProvider({ children }: LivekitJsCallManagerProviderProps) { + const mx = useMatrixClient(); + const discovery = useAutoDiscoveryInfo(); + const store = useStore(); + const roomIdRef = useRef(undefined); + const initialMediaRef = useRef({ + microphone: true, + camera: false, + sound: true, + }); + const controllerRef = useRef(undefined); + const generationRef = useRef(0); + + // Create and expose the controller synchronously during render so child + // passive effects (e.g. AutoJoinManager calling start()) always see the + // current controller before the provider's own subscription effect runs. + const controller = useMemo(() => { + generationRef.current += 1; + return createLivekitJsController({ + acquireOwner: (kind, roomId) => + kind === 'livekit-js' ? acquireCallOwner(kind, roomId) : undefined, + request: appFetch, + subscribeToCallRoom: (roomId) => getSlidingSyncManager(mx)?.subscribeToCallRoom(roomId), + }); + }, [mx]); + const generation = generationRef.current; + if (controllerRef.current !== controller) controllerRef.current = controller; + + useEffect(() => { + controllerRef.current = controller; + const unsubscribe = controller.subscribe((controllerState) => { + const roomId = roomIdRef.current; + if (!roomId) return; + if (controllerState.lifecycle === 'idle') { + store.set(livekitJsCallAtom, undefined); + return; + } + store.set(livekitJsCallAtom, { + roomId, + initialMedia: initialMediaRef.current, + lifecycle: controllerState.lifecycle, + failure: controllerState.failure, + room: controllerState.lifecycle === 'active' ? controllerState.room : undefined, + mediaReady: controllerState.mediaReady, + hangup: () => controller.disconnect(), + }); + }); + return () => { + unsubscribe(); + roomIdRef.current = undefined; + if (controllerRef.current === controller) controllerRef.current = undefined; + void controller.disconnect().finally(() => { + // A stale disconnect completing after a deliberate replacement must + // not clear the newer controller's session from the atom. + if (generationRef.current === generation) { + store.set(livekitJsCallAtom, undefined); + } + }); + }; + }, [controller, generation, store]); + + const start = useCallback( + ({ + room, + dm, + video, + microphone, + sound, + audioDeviceId, + videoDeviceId, + }: LivekitJsCallStartOptions) => { + if (store.get(callInProgressAtom)) return; + const activeController = controllerRef.current; + if (!activeController) return; + roomIdRef.current = room.roomId; + initialMediaRef.current = { + microphone: microphone ?? true, + camera: video ?? false, + sound: sound ?? true, + audioDeviceId, + videoDeviceId, + }; + store.set(livekitJsCallSoundAtom, sound ?? true); + store.set(livekitJsCallInitialMediaAppliedAtom, false); + void activeController + .connect({ + mx, + room, + discovery, + callIntent: video ? 'video' : 'audio', + dm, + ongoing: isCallOngoing(mx, room), + }) + .catch(() => undefined); + }, + [mx, discovery, store] + ); + + const manager = useMemo(() => ({ start }), [start]); + + return ( + + {children} + + ); +} diff --git a/src/app/features/call/nativeCallController.test.ts b/src/app/features/call/nativeCallController.test.ts new file mode 100644 index 000000000..ea7c7a1aa --- /dev/null +++ b/src/app/features/call/nativeCallController.test.ts @@ -0,0 +1,569 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + MatrixRTCSessionEvent, + type CallMembership, + type CallMembershipIdentityParts, + type MatrixClient, + type MatrixRTCSession, + type Room, +} from '$types/matrix-sdk'; +import { + createNativeCallController, + type NativeCallControllerDependencies, +} from './nativeCallController'; +import type { NativeCallSnapshot } from '@sableclient/tauri-plugin-livekit-mobile'; +import type { NativeCallSession } from '$state/nativeCall'; +import { + acquireCallOwner, + getActiveCallOwner, + resetCallOwnerForTests, + type CallOwnerLease, +} from '$state/callOwner'; + +const OWN_IDENTITY = '@alice:example.org:DEVICE'; +const OWN_PARTS = { + userId: '@alice:example.org', + deviceId: 'DEVICE', +} as CallMembershipIdentityParts; +const REMOTE_PARTS = { + userId: '@bob:example.org', + deviceId: 'BOBDEVICE', +} as CallMembershipIdentityParts; +const room = { + roomId: '!room:example.org', + loadMembersIfNeeded: () => Promise.resolve(true), + membersLoaded: () => true, + getMembersWithMembership: () => [{}, {}], + getJoinedMemberCount: () => 2, + // MSC4143 ties media encryption to the room; these tests cover the + // encrypted case, where the key pipeline runs. + hasEncryptionStateEvent: () => true, +} as unknown as Room; +const unencryptedRoom = Object.create(room, { + hasEncryptionStateEvent: { value: () => false }, +}) as Room; +const transport = { type: 'livekit' as const, livekit_service_url: 'https://sfu.example' }; +const ownMembership = { + userId: '@alice:example.org', + deviceId: 'DEVICE', + rtcBackendIdentity: OWN_IDENTITY, +} as CallMembership; + +const idleSnapshot = (callId: string | null = null): NativeCallSnapshot => ({ + revision: 1, + callId, + connectionState: 'idle', + microphoneEnabled: false, + cameraEnabled: false, + participantCount: 0, +}); + +const connectedSnapshot = (callId: string): NativeCallSnapshot => ({ + revision: 1, + callId, + connectionState: 'connected', + microphoneEnabled: true, + cameraEnabled: false, + participantCount: 1, +}); + +type SessionHandler = (...args: unknown[]) => void; + +type TestSession = MatrixRTCSession & { + handlers: Map; +}; + +const makeSession = (order: string[] = []): TestSession => { + const handlers = new Map(); + const session = { + handlers, + memberships: [] as CallMembership[], + slotId: 'm.call#real-slot', + on: vi.fn<(event: MatrixRTCSessionEvent, handler: SessionHandler) => void>((event, handler) => { + if (event === MatrixRTCSessionEvent.EncryptionKeyChanged) order.push('attach-keys'); + handlers.set(event, handler); + }), + off: vi.fn<(event: MatrixRTCSessionEvent, handler: SessionHandler) => void>( + (event, handler) => { + if (handlers.get(event) === handler) handlers.delete(event); + } + ), + removeListener: vi.fn<(event: MatrixRTCSessionEvent, handler: SessionHandler) => void>( + (event, handler) => { + if (handlers.get(event) === handler) handlers.delete(event); + } + ), + reemitEncryptionKeys: vi.fn<() => void>(), + joinRTCSession: vi.fn<() => void>(() => order.push('join')), + getOldestMembership: vi.fn<() => CallMembership | undefined>(() => undefined), + leaveRoomSession: vi.fn<() => Promise>(async () => { + order.push('leave'); + return true; + }), + } as unknown as TestSession; + return session; +}; + +const emitOwnMembership = (session: TestSession): void => { + session.memberships = [ownMembership]; + session.handlers.get(MatrixRTCSessionEvent.MembershipsChanged)?.([], [ownMembership]); +}; + +const emitKey = ( + session: TestSession, + key: number[], + keyIndex: number, + identity: string, + parts: CallMembershipIdentityParts = OWN_PARTS +): void => { + session.handlers.get(MatrixRTCSessionEvent.EncryptionKeyChanged)?.( + new Uint8Array(key), + keyIndex, + parts, + identity + ); +}; + +const waitForMembershipListener = async (session: TestSession): Promise => { + await vi.waitFor(() => + expect(session.on).toHaveBeenCalledWith( + MatrixRTCSessionEvent.MembershipsChanged, + expect.any(Function) + ) + ); +}; + +const flushMicrotasks = async (ticks = 20): Promise => { + for (let index = 0; index < ticks; index += 1) { + // oxlint-disable-next-line no-await-in-loop -- flushing chained microtasks one tick at a time + await Promise.resolve(); + } +}; + +const makeClient = (session: MatrixRTCSession): MatrixClient => + ({ + getDeviceId: () => 'DEVICE', + getSafeUserId: () => '@alice:example.org', + matrixRTC: { getRoomSession: () => session }, + on: vi.fn<(event: string, cb: unknown) => void>(), + removeListener: vi.fn<(event: string, cb: unknown) => void>(), + }) as unknown as MatrixClient; + +type Harness = { + dependencies: NativeCallControllerDependencies; + setSession: ReturnType void>>; + connectCall: ReturnType; + disconnectCall: ReturnType; + setCamera: ReturnType; + setEncryptionKey: ReturnType; + emitSnapshot: (snapshot: NativeCallSnapshot) => void; + unlisten: ReturnType void>>; +}; + +const makeDependencies = (order: string[]): Harness => { + const setSession = vi.fn<(session: NativeCallSession | undefined) => void>(); + const connectCall = vi.fn<() => Promise>(async () => { + order.push('connect'); + return connectedSnapshot('call-id'); + }); + const disconnectCall = vi.fn<() => Promise>(async () => { + order.push('native-disconnect'); + return idleSnapshot(); + }); + const setCamera = vi.fn<() => Promise>(async () => + connectedSnapshot('call-id') + ); + const setMicrophone = vi.fn<() => Promise>(async () => + connectedSnapshot('call-id') + ); + const setEncryptionKey = vi.fn<() => Promise>(async () => + connectedSnapshot('call-id') + ); + const unlisten = vi.fn<() => void>(); + let snapshotHandler: ((snapshot: NativeCallSnapshot) => void) | undefined; + + return { + setSession, + connectCall, + disconnectCall, + setCamera, + setEncryptionKey, + unlisten, + emitSnapshot: (snapshot) => snapshotHandler?.(snapshot), + dependencies: { + setSession, + connectCall, + disconnectCall, + setCamera, + setMicrophone, + setEncryptionKey, + listenSnapshot: async (handler) => { + snapshotHandler = handler; + return unlisten; + }, + getPreferredTransport: async () => transport, + provisionToken: async () => ({ url: 'wss://livekit.example', jwt: 'jwt' }), + createCallId: () => 'call-id', + }, + }; +}; + +const startOptions = ( + session: MatrixRTCSession, + video = false, + microphone = true, + callRoom: Room = room +) => ({ + mx: makeClient(session), + room: callRoom, + dm: false, + video, + microphone, + ongoing: false, +}); + +const lastSession = ( + setSession: ReturnType void>> +): NativeCallSession | undefined => setSession.mock.calls.at(-1)?.[0]; + +beforeEach(() => { + resetCallOwnerForTests(); +}); + +describe('native call controller', () => { + it('attaches the key forwarder before the Matrix join with managed media keys', async () => { + const order: string[] = []; + const session = makeSession(order); + const { dependencies, connectCall } = makeDependencies(order); + const controller = createNativeCallController(dependencies); + + const startPromise = controller.start(startOptions(session)); + await waitForMembershipListener(session); + expect(order).toEqual(['attach-keys', 'join']); + emitOwnMembership(session); + emitKey(session, [1], 0, OWN_IDENTITY); + await startPromise; + + expect(session.joinRTCSession).toHaveBeenCalledWith( + { userId: '@alice:example.org', deviceId: 'DEVICE', memberId: '@alice:example.org:DEVICE' }, + [{ ...transport, livekit_alias: room.roomId }], + undefined, + { + callIntent: 'audio', + membershipEventExpiryMs: 30 * 60 * 1000, + notificationType: 'notification', + manageMediaKeys: true, + } + ); + expect(connectCall).toHaveBeenCalled(); + }); + + it('runs an unencrypted call with no key pipeline and no managed media keys', async () => { + const order: string[] = []; + const session = makeSession(order); + const { dependencies, connectCall, setEncryptionKey } = makeDependencies(order); + const controller = createNativeCallController(dependencies); + + const startPromise = controller.start(startOptions(session, false, true, unencryptedRoom)); + await waitForMembershipListener(session); + emitOwnMembership(session); + // No own key is ever emitted: an unencrypted call must not wait for one. + await startPromise; + + // MSC4143 forbids MatrixRTC encryption in an unencrypted room, so nothing + // of the key pipeline may run for one. + expect(order).toEqual(['join', 'connect']); + expect(session.reemitEncryptionKeys).not.toHaveBeenCalled(); + expect(session.joinRTCSession).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + undefined, + expect.not.objectContaining({ manageMediaKeys: expect.anything() }) + ); + expect(connectCall).toHaveBeenCalledWith(expect.objectContaining({ encryptionKeys: [] })); + expect(setEncryptionKey).not.toHaveBeenCalled(); + }); + + it('gates the native connect on the own-identity key and caches other keys', async () => { + const order: string[] = []; + const session = makeSession(order); + const { dependencies, connectCall, setEncryptionKey } = makeDependencies(order); + const controller = createNativeCallController(dependencies); + + const startPromise = controller.start(startOptions(session)); + await waitForMembershipListener(session); + emitOwnMembership(session); + await vi.waitFor(() => expect(session.joinRTCSession).toHaveBeenCalled()); + emitKey(session, [9, 9], 0, 'remote-backend', REMOTE_PARTS); + await Promise.resolve(); + expect(connectCall).not.toHaveBeenCalled(); + + emitKey(session, [1, 2, 3, 4], 1, OWN_IDENTITY); + await startPromise; + + expect(connectCall).toHaveBeenCalledWith({ + callId: 'call-id', + url: 'wss://livekit.example', + token: 'jwt', + microphoneEnabled: true, + encryptionKeys: [ + { identity: 'remote-backend', keyIndex: 0, key: 'CQk=' }, + { identity: OWN_IDENTITY, keyIndex: 1, key: 'AQIDBA==' }, + ], + }); + expect(lastSession(dependencies.setSession as Harness['setSession'])).toMatchObject({ + backend: 'livekit-mobile', + roomId: room.roomId, + callId: 'call-id', + lifecycle: 'connected', + }); + // The known keys are replayed once the connection resolves so that anything + // arriving during the connect window still reaches the plugin. Re-sending a + // key into the same ring slot is a no-op for LiveKit. + expect(setEncryptionKey).toHaveBeenCalledTimes(2); + }); + + it('joins muted when the prescreen microphone was off', async () => { + const session = makeSession(); + const { dependencies, connectCall } = makeDependencies([]); + const controller = createNativeCallController(dependencies); + + const startPromise = controller.start(startOptions(session, false, false)); + await waitForMembershipListener(session); + emitOwnMembership(session); + await vi.waitFor(() => expect(session.joinRTCSession).toHaveBeenCalled()); + emitKey(session, [1, 2, 3, 4], 1, OWN_IDENTITY); + await startPromise; + + expect(connectCall).toHaveBeenCalledWith(expect.objectContaining({ microphoneEnabled: false })); + }); + + it('rotates keys through the set command only after connect resolves', async () => { + const order: string[] = []; + const session = makeSession(order); + const { dependencies, connectCall, setEncryptionKey } = makeDependencies(order); + const controller = createNativeCallController(dependencies); + + const startPromise = controller.start(startOptions(session)); + await waitForMembershipListener(session); + emitOwnMembership(session); + emitKey(session, [9, 9], 0, 'remote-backend', REMOTE_PARTS); + emitKey(session, [1], 0, OWN_IDENTITY); + expect(setEncryptionKey).not.toHaveBeenCalled(); + await startPromise; + // Known keys are replayed once connected, so nothing that arrived during + // the connect window is stranded. + expect(setEncryptionKey).toHaveBeenCalledTimes(2); + + setEncryptionKey.mockClear(); + emitKey(session, [5, 6, 7, 8], 4, 'remote-backend', REMOTE_PARTS); + expect(connectCall).toHaveBeenCalledTimes(1); + expect(setEncryptionKey).toHaveBeenCalledWith({ + callId: 'call-id', + identity: 'remote-backend', + keyIndex: 4, + key: 'BQYHCA==', + }); + + // An index is a slot in LiveKit's per-participant key ring, not a sequence, + // and a peer that rejoins restarts at 0. Every key is passed through: any + // filtering here strands the frames encrypted with the key we dropped. + setEncryptionKey.mockClear(); + emitKey(session, [8, 8], 2, 'remote-backend', REMOTE_PARTS); + expect(setEncryptionKey).toHaveBeenCalledWith({ + callId: 'call-id', + identity: 'remote-backend', + keyIndex: 2, + key: 'CAg=', + }); + }); + + it('disconnects natively before leaving Matrix, detaches, and releases the owner once', async () => { + const order: string[] = []; + const session = makeSession(order); + const harness = makeDependencies(order); + const release = vi.fn<() => void>(); + harness.dependencies.acquireOwner = (): CallOwnerLease => ({ + kind: 'livekit-mobile', + roomId: room.roomId, + release, + }); + const controller = createNativeCallController(harness.dependencies); + + const startPromise = controller.start(startOptions(session)); + await waitForMembershipListener(session); + emitOwnMembership(session); + emitKey(session, [1], 0, OWN_IDENTITY); + await startPromise; + order.length = 0; + + const connected = lastSession(harness.setSession); + expect(connected?.lifecycle).toBe('connected'); + await connected?.hangup(); + await connected?.hangup(); + + expect(order).toEqual(['native-disconnect', 'leave']); + expect(session.off).toHaveBeenCalledWith( + MatrixRTCSessionEvent.EncryptionKeyChanged, + expect.any(Function) + ); + expect(harness.unlisten).toHaveBeenCalledTimes(1); + expect(release).toHaveBeenCalledTimes(1); + expect(lastSession(harness.setSession)).toBeUndefined(); + }); + + it('enables the camera after connect only when video was requested', async () => { + const order: string[] = []; + const session = makeSession(order); + const { dependencies, setCamera } = makeDependencies(order); + const controller = createNativeCallController(dependencies); + + const startPromise = controller.start(startOptions(session, true)); + await waitForMembershipListener(session); + emitOwnMembership(session); + emitKey(session, [1], 0, OWN_IDENTITY); + await startPromise; + + expect(order).toEqual(['attach-keys', 'join', 'connect']); + expect(setCamera).toHaveBeenCalledWith({ callId: 'call-id', enabled: true }); + + // Release the owner lease so the audio-path controller can start. + await lastSession(dependencies.setSession as Harness['setSession'])?.hangup(); + + const audioOrder: string[] = []; + const audioSession = makeSession(audioOrder); + const audioHarness = makeDependencies(audioOrder); + const audioController = createNativeCallController(audioHarness.dependencies); + const audioStart = audioController.start(startOptions(audioSession)); + await waitForMembershipListener(audioSession); + emitOwnMembership(audioSession); + emitKey(audioSession, [1], 0, OWN_IDENTITY); + await audioStart; + expect(audioHarness.setCamera).not.toHaveBeenCalled(); + }); + + it('tracks lifecycle and media flags from snapshots and ends on a terminal snapshot', async () => { + const order: string[] = []; + const session = makeSession(order); + const harness = makeDependencies(order); + const controller = createNativeCallController(harness.dependencies); + + const startPromise = controller.start(startOptions(session)); + await waitForMembershipListener(session); + emitOwnMembership(session); + emitKey(session, [1], 0, OWN_IDENTITY); + await startPromise; + + harness.emitSnapshot({ + ...connectedSnapshot('call-id'), + connectionState: 'reconnecting', + microphoneEnabled: false, + cameraEnabled: true, + }); + expect(lastSession(harness.setSession)).toMatchObject({ + lifecycle: 'reconnecting', + microphoneEnabled: false, + cameraEnabled: true, + }); + + order.length = 0; + harness.emitSnapshot(idleSnapshot(null)); + await vi.waitFor(() => expect(session.leaveRoomSession).toHaveBeenCalled()); + expect(order).toEqual(['native-disconnect', 'leave']); + // A normal end clears the session; it is not an error the user must dismiss. + expect(lastSession(harness.setSession)).toBeUndefined(); + expect(getActiveCallOwner()).toBeUndefined(); + }); + + it('treats a failed snapshot for the current call as a call failure', async () => { + const order: string[] = []; + const session = makeSession(order); + const harness = makeDependencies(order); + const controller = createNativeCallController(harness.dependencies); + + const startPromise = controller.start(startOptions(session)); + await waitForMembershipListener(session); + emitOwnMembership(session); + emitKey(session, [1], 0, OWN_IDENTITY); + await startPromise; + + harness.emitSnapshot({ + ...idleSnapshot('call-id'), + connectionState: 'failed', + lastError: { code: 'disconnected', message: 'disconnected' }, + }); + await vi.waitFor(() => expect(harness.disconnectCall).toHaveBeenCalled()); + // The failure code is mapped to user-facing copy, not passed through raw. + expect(lastSession(harness.setSession)).toMatchObject({ + lifecycle: 'error', + error: 'The connection to the call was lost.', + }); + }); + + it('cleans up and hides secrets when the own-key wait times out', async () => { + vi.useFakeTimers(); + try { + const order: string[] = []; + const session = makeSession(order); + const harness = makeDependencies(order); + const controller = createNativeCallController(harness.dependencies); + + const startPromise = controller.start(startOptions(session)); + await flushMicrotasks(); + expect(session.on).toHaveBeenCalledWith( + MatrixRTCSessionEvent.MembershipsChanged, + expect.any(Function) + ); + emitOwnMembership(session); + await flushMicrotasks(); + await vi.advanceTimersByTimeAsync(10_000); + await startPromise; + + expect(harness.connectCall).not.toHaveBeenCalled(); + expect(order).toEqual(['attach-keys', 'join', 'native-disconnect', 'leave']); + const failed = lastSession(harness.setSession); + expect(failed).toMatchObject({ lifecycle: 'error' }); + expect(failed?.error).toContain('Could not connect to the call.'); + // The surfaced message must never carry the provisioned token. + expect(failed?.error).not.toContain('jwt'); + expect(getActiveCallOwner()).toBeUndefined(); + } finally { + vi.useRealTimers(); + } + }); + + it('does not start while another call owns the owner lease', async () => { + const order: string[] = []; + const session = makeSession(order); + const harness = makeDependencies(order); + const onCleanup = vi.fn<() => void>(); + harness.dependencies.onCleanup = onCleanup; + const elementLease = acquireCallOwner('element', '!other:example.org'); + + const controller = createNativeCallController(harness.dependencies); + await controller.start(startOptions(session)); + + expect(session.joinRTCSession).not.toHaveBeenCalled(); + expect(harness.setSession).not.toHaveBeenCalled(); + expect(onCleanup).toHaveBeenCalled(); + elementLease?.release(); + }); + + it('ignores a second start while a native call is active', async () => { + const order: string[] = []; + const session = makeSession(order); + const harness = makeDependencies(order); + const controller = createNativeCallController(harness.dependencies); + + const startPromise = controller.start(startOptions(session)); + await waitForMembershipListener(session); + emitOwnMembership(session); + emitKey(session, [1], 0, OWN_IDENTITY); + await startPromise; + + const secondSession = makeSession(); + await controller.start(startOptions(secondSession)); + expect(secondSession.joinRTCSession).not.toHaveBeenCalled(); + expect(harness.connectCall).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/app/features/call/nativeCallController.ts b/src/app/features/call/nativeCallController.ts new file mode 100644 index 000000000..4e8e93cb0 --- /dev/null +++ b/src/app/features/call/nativeCallController.ts @@ -0,0 +1,423 @@ +import { + type CallKeyPipeline, + type CallSessionHandles, + type CallTransportState, + callEncryptsMedia, + createCallKeyPipeline, + createCallSessionHandles, + disconnectLivekitThenLeaveMatrixRTC, + getPreferredLivekitTransport, + joinCallSession, + provisionLivekitToken, +} from '@sableclient/matrixrtc'; +import type { AutoDiscoveryInfo } from '../../cs-api'; +import type { MatrixClient, MatrixRTCSession, Room } from '$types/matrix-sdk'; +import { + drainPendingSystemCallActions, + endSystemCall, + fulfillAnswerCall, + fulfillEndCall, + onSystemCallAction, + reportSystemCallConnected, + setSystemCallMuted, + startSystemCall, + updateCallDisplay, + type NativeCallAudioRoute, + type SystemCallAction, +} from '@sableclient/tauri-plugin-livekit-mobile'; +import { + createNativeTransport, + type NativeCallTransport, + type NativeTransportDependencies, +} from './nativeTransport'; +import { acquireCallOwner, type CallOwnerLease } from '$state/callOwner'; +import type { NativeCallLifecycle, NativeCallSession } from '$state/nativeCall'; +import { createDebugLogger } from '$utils/debugLogger'; +import { getSlidingSyncManager } from '$client/initMatrix'; +import { fetch as appFetch } from '$utils/fetch'; + +const debugLog = createDebugLogger('nativeCallController'); + +type SetupStage = 'joining the call' | 'authorizing' | 'connecting'; + +const setupErrorMessage = (stage: SetupStage): string => { + if (stage === 'joining the call') return 'Could not join the call.'; + if (stage === 'authorizing') return 'Could not get permission to join the call.'; + return 'Could not connect to the call.'; +}; + +export type NativeCallStartOptions = { + mx: MatrixClient; + room: Room; + discovery?: Pick; + dm: boolean; + video: boolean; + microphone: boolean; + ongoing: boolean; +}; + +type NativeCallRecord = { + roomId: string; + callId: string; + session: MatrixRTCSession; + keys: CallKeyPipeline; + transport: NativeCallTransport; + unsubscribeTransport: () => void; + ownerLease: CallOwnerLease; + cancelled: boolean; + handles: CallSessionHandles; + cleanupPromise?: Promise; +}; + +export type NativeCallController = { + start: (options: NativeCallStartOptions) => Promise; + setMicrophoneEnabled: (enabled: boolean) => Promise; + setCameraEnabled: (enabled: boolean) => Promise; + switchCamera: () => Promise; + listAudioRoutes: () => Promise; + selectAudioRoute: (routeId: string) => Promise; +}; + +export type NativeCallControllerDependencies = NativeTransportDependencies & { + setSession: (session: NativeCallSession | undefined) => void; + updateDisplay?: typeof updateCallDisplay; + createKeyPipeline?: () => CallKeyPipeline; + createTransport?: typeof createNativeTransport; + getPreferredTransport?: typeof getPreferredLivekitTransport; + provisionToken?: typeof provisionLivekitToken; + createCallId?: () => string; + acquireOwner?: typeof acquireCallOwner; + onCleanup?: () => void; +}; + +const noMediaControls = { + setMicrophoneEnabled: async (): Promise => {}, + setCameraEnabled: async (): Promise => {}, + switchCamera: async (): Promise => {}, + listAudioRoutes: async (): Promise => [], + selectAudioRoute: async (): Promise => {}, +}; + +export const createNativeCallController = ( + dependencies: NativeCallControllerDependencies +): NativeCallController => { + const deps = { + ...dependencies, + updateDisplay: dependencies.updateDisplay ?? updateCallDisplay, + createKeyPipeline: dependencies.createKeyPipeline ?? createCallKeyPipeline, + createTransport: dependencies.createTransport ?? createNativeTransport, + getPreferredTransport: dependencies.getPreferredTransport ?? getPreferredLivekitTransport, + provisionToken: dependencies.provisionToken ?? provisionLivekitToken, + createCallId: dependencies.createCallId ?? (() => crypto.randomUUID()), + acquireOwner: dependencies.acquireOwner ?? acquireCallOwner, + }; + + // Register the system-call (CallKit) listener once at controller creation. + // Events (answer, end, mute from lock-screen/system-UI) flow from the + // native side regardless of call state. + let pendingSystemUuid: string | undefined; + + void onSystemCallAction((action: SystemCallAction) => { + if (action.action === 'end') { + // System UI ended the call: hang up if active, then fulfill the + // pending CXEndCallAction so the system UI dismisses immediately. + if (activeRecord && !activeRecord.cancelled) { + void cleanup(activeRecord, undefined, true).finally(() => { + if (action.uuid) void fulfillEndCall(action.uuid).catch(() => undefined); + }); + } else if (action.uuid) { + void fulfillEndCall(action.uuid).catch(() => undefined); + } + void drainPendingSystemCallActions().catch(() => undefined); + } else if (action.action === 'answer') { + // System UI answered an incoming call: store the uuid for the + // start path so it can map back to the system call. + pendingSystemUuid = action.uuid; + } else if (action.action === 'mute') { + // System UI mute toggle: push to LiveKit. + void activeRecord?.transport.setMicrophoneEnabled(!action.muted); + } + }).catch(() => undefined); + + let activeRecord: NativeCallRecord | undefined; + let displayedRecord: NativeCallRecord | undefined; + + const isCurrent = (record: NativeCallRecord): boolean => + activeRecord === record && !record.cancelled; + + const publishError = ( + roomId: string, + callId: string, + error: string, + hangup: () => Promise + ): void => { + try { + deps.setSession({ + backend: 'livekit-mobile', + roomId, + callId, + lifecycle: 'error', + error, + participants: [], + microphoneEnabled: false, + cameraEnabled: false, + ...noMediaControls, + hangup, + }); + } catch {} + }; + + const publish = ( + record: NativeCallRecord, + lifecycle: NativeCallLifecycle, + media?: Pick + ): void => { + if (!isCurrent(record)) return; + deps.setSession({ + backend: 'livekit-mobile', + roomId: record.roomId, + callId: record.callId, + lifecycle, + participants: media?.participants ?? [], + microphoneEnabled: media?.microphoneEnabled ?? true, + cameraEnabled: media?.cameraEnabled ?? false, + setMicrophoneEnabled, + setCameraEnabled, + switchCamera, + listAudioRoutes, + selectAudioRoute, + hangup: () => cleanup(record, undefined, true), + }); + displayedRecord = record; + }; + + const cleanup = async ( + record: NativeCallRecord, + failure: string | undefined, + clear: boolean + ): Promise => { + if (record.cleanupPromise) { + await record.cleanupPromise; + if (clear && displayedRecord === record) { + displayedRecord = undefined; + try { + deps.setSession(undefined); + } catch {} + } + return; + } + + record.cancelled = true; + record.handles.cancelMembershipWait?.(); + record.handles.cancelMembershipWait = undefined; + record.handles.removePageHideListener?.(); + record.handles.removePageHideListener = undefined; + record.handles.unsubscribeCallRoom?.(); + record.handles.unsubscribeCallRoom = undefined; + record.unsubscribeTransport(); + record.keys.setOnKey(undefined); + if (failure && activeRecord === record) { + publishError(record.roomId, record.callId, failure, () => cleanup(record, undefined, true)); + displayedRecord = record; + } + + record.cleanupPromise = (async () => { + // End the system call so CallKit dismisses the active-call UI. + void endSystemCall({ callId: record.callId, remoteEnded: true }).catch(() => undefined); + void record.transport.capabilities.pictureInPicture?.setEnabled(false); + await disconnectLivekitThenLeaveMatrixRTC( + () => record.transport.disconnect(), + record.session + ); + record.keys.detach(); + record.ownerLease.release(); + if (activeRecord === record) activeRecord = undefined; + if (clear && displayedRecord === record) { + displayedRecord = undefined; + try { + deps.setSession(undefined); + } catch {} + } + deps.onCleanup?.(); + })(); + await record.cleanupPromise; + }; + + const handleTransportState = (record: NativeCallRecord, state: CallTransportState): void => { + if (!isCurrent(record)) return; + if (state.connection === 'disconnected') { + // A normal end clears the session; only a failure leaves an error the + // user has to dismiss. + void cleanup(record, state.error, state.error === undefined); + return; + } + publish(record, state.connection, state); + }; + + const start = async ({ + mx, + room, + discovery, + dm, + video, + microphone, + ongoing, + }: NativeCallStartOptions) => { + if (activeRecord) { + deps.onCleanup?.(); + return; + } + const ownerLease = deps.acquireOwner('livekit-mobile', room.roomId); + if (!ownerLease) { + deps.onCleanup?.(); + return; + } + + let record: NativeCallRecord | undefined; + let stage: SetupStage = 'joining the call'; + try { + const callId = deps.createCallId(); + const session = mx.matrixRTC.getRoomSession(room); + const keys = deps.createKeyPipeline(); + const transport = deps.createTransport(callId, dependencies); + record = { + roomId: room.roomId, + callId, + session, + keys, + transport, + unsubscribeTransport: () => {}, + ownerLease, + cancelled: false, + handles: createCallSessionHandles(), + }; + const currentRecord = record; + activeRecord = record; + publish(record, 'starting'); + + record.unsubscribeTransport = transport.subscribe((state) => + handleTransportState(currentRecord, state) + ); + await transport.ready; + + const encryptMedia = callEncryptsMedia(room); + if (encryptMedia) { + // The transport queues anything that arrives before it is connected, so + // a key is never dropped for being early. + keys.setOnKey((key) => void transport.setEncryptionKey(key)); + keys.attach(session, { userId: mx.getSafeUserId(), deviceId: mx.getDeviceId() }); + } + + const joined = await joinCallSession( + { + mx, + room, + session, + discovery, + request: appFetch, + subscribeToCallRoom: (roomId) => getSlidingSyncManager(mx)?.subscribeToCallRoom(roomId), + getPreferredTransport: deps.getPreferredTransport, + provisionToken: deps.provisionToken, + callIntent: video ? 'video' : 'audio', + dm, + ongoing, + encryptMedia, + isCancelled: () => !isCurrent(currentRecord), + onStage: (joinStage) => { + stage = joinStage === 'joining-matrix' ? 'joining the call' : 'authorizing'; + }, + }, + currentRecord.handles + ); + if (!isCurrent(currentRecord)) return; + + stage = 'connecting'; + if (encryptMedia) { + await keys.waitForOwnKey(); + if (!isCurrent(currentRecord)) return; + } + + await transport.connect({ + url: joined.provisioned.url, + token: joined.provisioned.jwt, + microphoneEnabled: microphone, + cameraEnabled: video, + encryptionKeys: keys.getKeys(), + }); + + // Report the outgoing system call so CallKit shows the active-call UI. + // Use the pending uuid (from an answer action) if present; otherwise + // generate a new uuid. The native side maps callId to uuid internally. + const systemUuid = pendingSystemUuid ?? crypto.randomUUID(); + const isIncomingAnswer = pendingSystemUuid !== undefined; + pendingSystemUuid = undefined; + const callerName = room.name || room.roomId; + void startSystemCall({ callId, uuid: systemUuid, callerName }).catch(() => undefined); + // Update the system call display with the room name and video flag so + // CallKit shows the correct caller info. + void deps + .updateDisplay({ + callId, + callerName, + hasVideo: video, + }) + .catch(() => undefined); + // Report the call as connected to CallKit so the system UI updates. + void reportSystemCallConnected(systemUuid).catch(() => undefined); + void transport.capabilities.pictureInPicture?.setEnabled(true); + // For system-initiated incoming answers: fulfill the deferred answer action. + if (isIncomingAnswer) { + void fulfillAnswerCall(systemUuid).catch(() => undefined); + } + } catch (cause) { + const detail = cause instanceof Error ? `${cause.name}: ${cause.message}` : String(cause); + debugLog.error('call', `Native call setup failed during ${stage}. ${detail}`); + if (record) { + await cleanup(record, setupErrorMessage(stage), false); + } else { + ownerLease.release(); + publishError(room.roomId, '', setupErrorMessage(stage), async () => { + try { + deps.setSession(undefined); + } catch {} + }); + deps.onCleanup?.(); + } + } + }; + + const setMicrophoneEnabled = async (enabled: boolean): Promise => { + const record = activeRecord; + if (!record || record.cancelled) return; + await record.transport.setMicrophoneEnabled(enabled); + // Push mute state back to CallKit for UI consistency. + void setSystemCallMuted({ callId: record.callId, muted: !enabled }).catch(() => undefined); + }; + + const active = (): NativeCallTransport | undefined => + activeRecord && !activeRecord.cancelled ? activeRecord.transport : undefined; + + const setCameraEnabled = async (enabled: boolean): Promise => { + await active()?.setCameraEnabled(enabled); + }; + + const switchCamera = async (): Promise => { + await active()?.capabilities.camera?.switch(); + }; + + const listAudioRoutes = async (): Promise => + (await active()?.capabilities.audioRoutes?.list()) ?? []; + + const selectAudioRoute = async (routeId: string): Promise => { + await active()?.capabilities.audioRoutes?.select(routeId); + }; + + return { + start, + setMicrophoneEnabled, + setCameraEnabled, + switchCamera, + listAudioRoutes, + selectAudioRoute, + }; +}; diff --git a/src/app/features/call/nativeCallManager.test.ts b/src/app/features/call/nativeCallManager.test.ts new file mode 100644 index 000000000..77c6c5fb2 --- /dev/null +++ b/src/app/features/call/nativeCallManager.test.ts @@ -0,0 +1,145 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createStore } from 'jotai'; +import type { MatrixClient, Room } from '$types/matrix-sdk'; +import { createNativeCallManager, getNativeCallManager } from './nativeCallManager'; +import type { + NativeCallController, + NativeCallControllerDependencies, + NativeCallStartOptions, +} from './nativeCallController'; +import { nativeCallAtom, type NativeCallSession } from '$state/nativeCall'; +import { callEmbedAtom } from '$state/callEmbed'; +import type { CallEmbed } from '$plugins/call'; +import { resetCallOwnerForTests } from '$state/callOwner'; + +const room = { roomId: '!room:example.org' } as Room; + +const makeClient = (memberships: unknown[] = []) => + ({ + matrixRTC: { + getRoomSession: () => ({ memberships }), + }, + }) as unknown as MatrixClient; + +const makeNativeSession = (lifecycle: NativeCallSession['lifecycle']): NativeCallSession => ({ + backend: 'livekit-mobile', + roomId: room.roomId, + callId: 'call-id', + lifecycle, + participants: [], + microphoneEnabled: true, + cameraEnabled: false, + setMicrophoneEnabled: async () => {}, + setCameraEnabled: async () => {}, + switchCamera: async () => {}, + listAudioRoutes: async () => [], + selectAudioRoute: async () => {}, + hangup: async () => undefined, +}); + +type FakeController = { + controller: NativeCallController; + start: ReturnType Promise>>; + setSession?: (session: NativeCallSession | undefined) => void; +}; + +const makeFakeControllerFactory = () => { + const fake: FakeController = { + controller: undefined as unknown as NativeCallController, + start: vi.fn<(options: NativeCallStartOptions) => Promise>(async () => undefined), + }; + fake.controller = { start: fake.start } as unknown as NativeCallController; + const createController = ( + dependencies: Pick + ): NativeCallController => { + fake.setSession = dependencies.setSession; + return fake.controller; + }; + return { fake, createController }; +}; + +beforeEach(() => { + resetCallOwnerForTests(); +}); + +describe('createNativeCallManager', () => { + it('starts the controller with the computed call options', () => { + const store = createStore(); + const { fake, createController } = makeFakeControllerFactory(); + const manager = createNativeCallManager(store, createController); + + manager.start({ + mx: makeClient([{ userId: '@other:example.org' }]), + room, + dm: true, + video: true, + microphone: false, + }); + + expect(fake.start).toHaveBeenCalledWith({ + mx: expect.anything(), + room, + dm: true, + video: true, + microphone: false, + ongoing: true, + }); + }); + + it('defaults audio/direct-message flags', () => { + const store = createStore(); + const { fake, createController } = makeFakeControllerFactory(); + const manager = createNativeCallManager(store, createController); + + manager.start({ mx: makeClient(), room }); + + expect(fake.start).toHaveBeenCalledWith( + expect.objectContaining({ dm: false, video: false, microphone: true, ongoing: false }) + ); + }); + + it('publishes controller sessions into the native call atom', () => { + const store = createStore(); + const { fake, createController } = makeFakeControllerFactory(); + createNativeCallManager(store, createController); + + const session = makeNativeSession('connected'); + fake.setSession?.(session); + expect(store.get(nativeCallAtom)).toBe(session); + + fake.setSession?.(undefined); + expect(store.get(nativeCallAtom)).toBeUndefined(); + }); + + it('does not start while a native call session is active', () => { + const store = createStore(); + const { fake, createController } = makeFakeControllerFactory(); + const manager = createNativeCallManager(store, createController); + + store.set(nativeCallAtom, makeNativeSession('connected')); + manager.start({ mx: makeClient(), room }); + + expect(fake.start).not.toHaveBeenCalled(); + }); + + it('does not start while a failed native session is cleared but Element Call is active', () => { + const store = createStore(); + const { fake, createController } = makeFakeControllerFactory(); + const manager = createNativeCallManager(store, createController); + + store.set(callEmbedAtom, { + roomId: '!other:example.org', + dispose: vi.fn<() => void>(), + } as unknown as CallEmbed); + manager.start({ mx: makeClient(), room }); + + expect(fake.start).not.toHaveBeenCalled(); + }); +}); + +describe('getNativeCallManager', () => { + it('reuses one manager per store', () => { + const store = createStore(); + expect(getNativeCallManager(store)).toBe(getNativeCallManager(store)); + }); +}); diff --git a/src/app/features/call/nativeCallManager.ts b/src/app/features/call/nativeCallManager.ts new file mode 100644 index 000000000..f0c0dfa67 --- /dev/null +++ b/src/app/features/call/nativeCallManager.ts @@ -0,0 +1,63 @@ +import type { useStore } from 'jotai'; + +type Store = ReturnType; +import type { MatrixClient, Room } from '$types/matrix-sdk'; +import type { AutoDiscoveryInfo } from '../../cs-api'; +import { callInProgressAtom, nativeCallAtom } from '$state/nativeCall'; +import { isCallOngoing } from '@sableclient/matrixrtc'; +import { + createNativeCallController, + type NativeCallController, + type NativeCallControllerDependencies, +} from './nativeCallController'; + +export type NativeCallManagerStartOptions = { + mx: MatrixClient; + room: Room; + discovery?: Pick; + dm?: boolean; + video?: boolean; + microphone?: boolean; +}; + +export type NativeCallManager = { + start: (options: NativeCallManagerStartOptions) => void; +}; + +export const createNativeCallManager = ( + store: Pick, + createController: ( + dependencies: Pick + ) => NativeCallController = createNativeCallController +): NativeCallManager => { + const controller = createController({ + setSession: (session) => store.set(nativeCallAtom, session), + }); + + return { + start: ({ mx, room, discovery, dm, video, microphone }) => { + if (store.get(callInProgressAtom)) return; + void controller + .start({ + mx, + room, + discovery, + dm: dm ?? false, + video: video ?? false, + microphone: microphone ?? true, + ongoing: isCallOngoing(mx, room), + }) + .catch(() => undefined); + }, + }; +}; + +const managers = new WeakMap, NativeCallManager>(); + +export const getNativeCallManager = (store: Pick): NativeCallManager => { + const cached = managers.get(store); + if (cached) return cached; + const manager = createNativeCallManager(store); + managers.set(store, manager); + return manager; +}; diff --git a/src/app/features/call/nativeCallProbe.test.ts b/src/app/features/call/nativeCallProbe.test.ts new file mode 100644 index 000000000..30d22f175 --- /dev/null +++ b/src/app/features/call/nativeCallProbe.test.ts @@ -0,0 +1,70 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { getNativeCallAvailability, resetNativeCallAvailabilityForTests } from './nativeCallProbe'; +import { isMobileTauri } from '$utils/platform'; +import { + getNativeCallCapabilities, + type NativeCallCapabilities, +} from '@sableclient/tauri-plugin-livekit-mobile'; + +vi.mock('$utils/platform', () => ({ + isMobileTauri: vi.fn<() => boolean>(), +})); + +vi.mock('@sableclient/tauri-plugin-livekit-mobile', () => ({ + getNativeCallCapabilities: vi.fn<() => Promise>(), +})); + +const allCapabilities: NativeCallCapabilities = { + supported: true, + microphone: true, + backgroundAudio: true, + nativeRoom: true, + camera: true, + nativeVideoOverlay: false, + callKit: true, +}; + +beforeEach(() => { + resetNativeCallAvailabilityForTests(); + vi.mocked(isMobileTauri).mockReturnValue(false); + vi.mocked(getNativeCallCapabilities).mockResolvedValue(allCapabilities); +}); + +describe('getNativeCallAvailability', () => { + it('is unavailable outside Tauri mobile even with new calls enabled', async () => { + vi.mocked(isMobileTauri).mockReturnValue(false); + + await expect(getNativeCallAvailability(true)).resolves.toBe(false); + expect(getNativeCallCapabilities).not.toHaveBeenCalled(); + }); + + it('is unavailable with new calls disabled on Tauri mobile', async () => { + vi.mocked(isMobileTauri).mockReturnValue(true); + + await expect(getNativeCallAvailability(false)).resolves.toBe(false); + expect(getNativeCallCapabilities).not.toHaveBeenCalled(); + }); + + it('is available on Tauri mobile with new calls enabled and supporting capabilities', async () => { + vi.mocked(isMobileTauri).mockReturnValue(true); + + await expect(getNativeCallAvailability(true)).resolves.toBe(true); + }); + + it('is unavailable when the native plugin does not support calls', async () => { + vi.mocked(isMobileTauri).mockReturnValue(true); + vi.mocked(getNativeCallCapabilities).mockResolvedValue({ + ...allCapabilities, + supported: false, + }); + + await expect(getNativeCallAvailability(true)).resolves.toBe(false); + }); + + it('is unavailable when the capabilities request fails', async () => { + vi.mocked(isMobileTauri).mockReturnValue(true); + vi.mocked(getNativeCallCapabilities).mockRejectedValue(new Error('plugin missing')); + + await expect(getNativeCallAvailability(true)).resolves.toBe(false); + }); +}); diff --git a/src/app/features/call/nativeCallProbe.ts b/src/app/features/call/nativeCallProbe.ts new file mode 100644 index 000000000..fdae5ec3d --- /dev/null +++ b/src/app/features/call/nativeCallProbe.ts @@ -0,0 +1,22 @@ +import { isMobileTauri } from '$utils/platform'; +import { + getNativeCallCapabilities, + type NativeCallCapabilities, +} from '@sableclient/tauri-plugin-livekit-mobile'; + +const supportsNativeCall = (capabilities: NativeCallCapabilities): boolean => + capabilities.supported && capabilities.microphone; + +let availabilityPromise: Promise | undefined; + +// Native calls are gated solely by the new-call setting; platform and +// capability checks still apply on top of it. +export const getNativeCallAvailability = (newCallsEnabled: boolean): Promise => { + if (!newCallsEnabled || !isMobileTauri()) return Promise.resolve(false); + availabilityPromise ??= getNativeCallCapabilities().then(supportsNativeCall, () => false); + return availabilityPromise; +}; + +export const resetNativeCallAvailabilityForTests = (): void => { + availabilityPromise = undefined; +}; diff --git a/src/app/features/call/nativeTransport.test.ts b/src/app/features/call/nativeTransport.test.ts new file mode 100644 index 000000000..2671d0152 --- /dev/null +++ b/src/app/features/call/nativeTransport.test.ts @@ -0,0 +1,377 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { UnlistenFn } from '@tauri-apps/api/event'; +import { createNativeTransport } from './nativeTransport'; +import type { + connectNativeCall, + disconnectNativeCall, + getAudioRoutes, + getNativeCallState, + listenNativeCallSnapshot, + setAudioRoute, + setNativeCallCameraEnabled, + setNativeCallEncryptionKey, + setNativeCallMicrophoneEnabled, + setNativeCallPiPEnabled, + switchNativeCallCamera, + NativeCallSnapshot, +} from '@sableclient/tauri-plugin-livekit-mobile'; +import type { CallEncryptionKey, CallTransportState } from '@sableclient/matrixrtc'; + +const bridge = vi.hoisted(() => ({ + connectNativeCall: vi.fn(), + disconnectNativeCall: vi.fn(), + setNativeCallMicrophoneEnabled: vi.fn(), + setNativeCallCameraEnabled: vi.fn(), + setNativeCallEncryptionKey: vi.fn(), + setNativeCallPiPEnabled: vi.fn(), + switchNativeCallCamera: vi.fn(), + getAudioRoutes: vi.fn(), + setAudioRoute: vi.fn(), + getNativeCallState: vi.fn(), + listenNativeCallSnapshot: vi.fn(), +})); + +vi.mock('@sableclient/tauri-plugin-livekit-mobile', () => bridge); + +const CALL_ID = 'call-1'; + +const snapshot = (overrides: Partial = {}): NativeCallSnapshot => ({ + revision: 1, + callId: CALL_ID, + connectionState: 'connected', + microphoneEnabled: true, + cameraEnabled: false, + participantCount: 0, + ...overrides, +}); + +const key = (identity: string, keyIndex: number, bytes: number[]): CallEncryptionKey => ({ + identity, + keyIndex, + key: new Uint8Array(bytes), +}); + +let emit!: (next: NativeCallSnapshot) => void; +let unlisten: ReturnType>; + +const connectOptions = (overrides: Record = {}) => ({ + url: 'wss://livekit.example', + token: 'jwt', + microphoneEnabled: true, + cameraEnabled: false, + encryptionKeys: [] as CallEncryptionKey[], + ...overrides, +}); + +const sentKeys = () => bridge.setNativeCallEncryptionKey.mock.calls.map(([request]) => request); + +beforeEach(() => { + vi.clearAllMocks(); + unlisten = vi.fn(); + bridge.listenNativeCallSnapshot.mockImplementation((handler) => { + emit = handler; + return Promise.resolve(unlisten); + }); + bridge.connectNativeCall.mockResolvedValue(snapshot()); + bridge.disconnectNativeCall.mockResolvedValue(snapshot({ connectionState: 'idle' })); + bridge.setNativeCallEncryptionKey.mockResolvedValue(snapshot()); + bridge.setNativeCallCameraEnabled.mockResolvedValue(snapshot()); + bridge.getNativeCallState.mockResolvedValue(snapshot()); +}); + +const connectedTransport = async () => { + const transport = createNativeTransport(CALL_ID); + await transport.ready; + await transport.connect(connectOptions()); + return transport; +}; + +describe('createNativeTransport encryption keys', () => { + it('forwards every key whatever the index, including a rejoin back to 0 and a decrease', async () => { + const transport = await connectedTransport(); + + await transport.setEncryptionKey(key('@bob:example.org:BOB', 3, [1, 2, 3])); + await transport.setEncryptionKey(key('@bob:example.org:BOB', 0, [4, 5])); + await transport.setEncryptionKey(key('@bob:example.org:BOB', 2, [9, 9])); + await transport.setEncryptionKey(key('@bob:example.org:BOB', 1, [7])); + + expect(sentKeys().map((request) => request.keyIndex)).toEqual([3, 0, 2, 1]); + expect(sentKeys().map((request) => request.key)).toEqual(['AQID', 'BAU=', 'CQk=', 'Bw==']); + }); + + it('forwards a repeat of an index already used by the same identity', async () => { + const transport = await connectedTransport(); + + await transport.setEncryptionKey(key('@bob:example.org:BOB', 4, [1, 2, 3])); + await transport.setEncryptionKey(key('@bob:example.org:BOB', 4, [4, 5])); + + expect(sentKeys()).toEqual([ + { callId: CALL_ID, identity: '@bob:example.org:BOB', keyIndex: 4, key: 'AQID' }, + { callId: CALL_ID, identity: '@bob:example.org:BOB', keyIndex: 4, key: 'BAU=' }, + ]); + }); + + it('keeps each identity on its own key ring', async () => { + const transport = await connectedTransport(); + + await transport.setEncryptionKey(key('@bob:example.org:BOB', 5, [1, 2, 3])); + await transport.setEncryptionKey(key('@carol:example.org:CAROL', 1, [4, 5])); + + expect(sentKeys().map((request) => [request.identity, request.keyIndex])).toEqual([ + ['@bob:example.org:BOB', 5], + ['@carol:example.org:CAROL', 1], + ]); + }); + + it('base64-encodes the raw key bytes for the bridge', async () => { + const transport = await connectedTransport(); + + await transport.setEncryptionKey(key('@bob:example.org:BOB', 0, [0, 1, 250, 255])); + + expect(sentKeys()[0]).toEqual({ + callId: CALL_ID, + identity: '@bob:example.org:BOB', + keyIndex: 0, + key: 'AAH6/w==', + }); + }); + + it('base64-encodes the keys handed to connect', async () => { + const transport = createNativeTransport(CALL_ID); + await transport.ready; + + await transport.connect( + connectOptions({ encryptionKeys: [key('@bob:example.org:BOB', 2, [200, 201, 202, 203])] }) + ); + + expect(bridge.connectNativeCall).toHaveBeenCalledWith( + expect.objectContaining({ + encryptionKeys: [{ identity: '@bob:example.org:BOB', keyIndex: 2, key: 'yMnKyw==' }], + }) + ); + }); + + it('replays the keys that arrived while connect was still in flight', async () => { + let resolveConnect!: (value: NativeCallSnapshot) => void; + bridge.connectNativeCall.mockReturnValue( + new Promise((resolve) => { + resolveConnect = resolve; + }) + ); + const transport = createNativeTransport(CALL_ID); + await transport.ready; + + const connecting = transport.connect(connectOptions()); + await transport.setEncryptionKey(key('@bob:example.org:BOB', 1, [1, 2, 3])); + await transport.setEncryptionKey(key('@carol:example.org:CAROL', 0, [4, 5])); + expect(bridge.setNativeCallEncryptionKey).not.toHaveBeenCalled(); + + resolveConnect(snapshot()); + await connecting; + + expect(sentKeys().map((request) => [request.identity, request.keyIndex])).toEqual([ + ['@bob:example.org:BOB', 1], + ['@carol:example.org:CAROL', 0], + ]); + }); + + it('drops keys once the transport is disposed', async () => { + const transport = await connectedTransport(); + await transport.disconnect(); + + await transport.setEncryptionKey(key('@bob:example.org:BOB', 1, [1, 2, 3])); + + expect(bridge.setNativeCallEncryptionKey).not.toHaveBeenCalled(); + }); + + it('swallows a bridge rejection so a bad key never breaks the call', async () => { + const transport = await connectedTransport(); + bridge.setNativeCallEncryptionKey.mockRejectedValue(new Error('key ring full')); + + await expect( + transport.setEncryptionKey(key('@bob:example.org:BOB', 1, [1, 2, 3])) + ).resolves.toBeUndefined(); + }); +}); + +describe('createNativeTransport snapshots', () => { + it('ignores snapshots belonging to another call', async () => { + const transport = await connectedTransport(); + + emit(snapshot({ callId: 'other-call', connectionState: 'reconnecting', participantCount: 4 })); + + expect(transport.getState().connection).toBe('connected'); + }); + + it('ignores an idle snapshot with no callId before connect resolves', async () => { + const transport = createNativeTransport(CALL_ID); + await transport.ready; + + // The plugin reports its own idle state on subscribe; that is not this + // call ending. + emit(snapshot({ callId: null, connectionState: 'idle', microphoneEnabled: false })); + + expect(transport.getState().connection).toBe('connecting'); + }); + + it('treats an idle snapshot with no callId after connect as this call ending', async () => { + const transport = await connectedTransport(); + + emit(snapshot({ callId: null, connectionState: 'idle', microphoneEnabled: false })); + + expect(transport.getState().connection).toBe('disconnected'); + }); + + it('ignores a terminal snapshot naming another call', async () => { + const transport = await connectedTransport(); + + emit( + snapshot({ + callId: 'other-call', + connectionState: 'failed', + lastError: { code: 'busy', message: 'busy' }, + }) + ); + + expect(transport.getState().connection).toBe('connected'); + }); + + it('surfaces the failure copy for a terminal snapshot naming this call', async () => { + const transport = await connectedTransport(); + + emit( + snapshot({ + callId: CALL_ID, + connectionState: 'failed', + lastError: { code: 'permission_denied', message: 'denied' }, + }) + ); + + expect(transport.getState()).toMatchObject({ + connection: 'disconnected', + error: 'Microphone or camera access was denied.', + participants: [], + }); + }); + + it('maps remote participants, tracks and connection quality', async () => { + const transport = await connectedTransport(); + + emit( + snapshot({ + remoteParticipants: [ + { + identity: '@bob:example.org:BOB', + camera: { sid: 'cam-1', muted: false, subscribed: true }, + screenShare: { sid: 'screen-1', muted: true, subscribed: false }, + connectionQuality: 'poor', + }, + { identity: '@carol:example.org:CAROL', connectionQuality: 'nonsense' }, + ], + }) + ); + + expect(transport.getState().participants).toEqual([ + { + identity: '@bob:example.org:BOB', + camera: { id: 'cam-1', muted: false, subscribed: true }, + screenShare: { id: 'screen-1', muted: true, subscribed: false }, + connectionQuality: 'poor', + }, + { identity: '@carol:example.org:CAROL', connectionQuality: 'unknown' }, + ]); + }); + + it('ignores snapshots arriving after disconnect', async () => { + const transport = await connectedTransport(); + await transport.disconnect(); + const before = transport.getState(); + + emit(snapshot({ connectionState: 'reconnecting' })); + + expect(transport.getState()).toBe(before); + }); +}); + +describe('createNativeTransport lifecycle', () => { + it('removes the visibilitychange listener and unlistens the snapshot stream on disconnect', async () => { + const added = vi.spyOn(document, 'addEventListener'); + const removed = vi.spyOn(document, 'removeEventListener'); + try { + const transport = await connectedTransport(); + const [, resync] = added.mock.calls.find(([type]) => type === 'visibilitychange')!; + + await transport.disconnect(); + + expect(removed).toHaveBeenCalledWith('visibilitychange', resync); + expect(bridge.disconnectNativeCall).toHaveBeenCalledWith({ callId: CALL_ID }); + expect(unlisten).toHaveBeenCalledOnce(); + } finally { + added.mockRestore(); + removed.mockRestore(); + } + }); + + it('still unlistens when the bridge disconnect rejects', async () => { + const transport = await connectedTransport(); + bridge.disconnectNativeCall.mockRejectedValue(new Error('no such call')); + + await expect(transport.disconnect()).resolves.toBeUndefined(); + expect(unlisten).toHaveBeenCalledOnce(); + }); + + it('does not let a throwing state listener stop the other listeners or the teardown', async () => { + const transport = await connectedTransport(); + const seen: CallTransportState[] = []; + transport.subscribe(() => { + throw new Error('listener blew up'); + }); + transport.subscribe((next) => seen.push(next)); + + emit(snapshot({ connectionState: 'reconnecting' })); + + expect(seen.map((state) => state.connection)).toEqual(['reconnecting']); + await expect(transport.disconnect()).resolves.toBeUndefined(); + expect(unlisten).toHaveBeenCalledOnce(); + }); + + it('stops delivering to an unsubscribed listener', async () => { + const transport = await connectedTransport(); + const listener = vi.fn<(next: CallTransportState) => void>(); + const unsubscribe = transport.subscribe(listener); + + unsubscribe(); + emit(snapshot({ connectionState: 'reconnecting' })); + + expect(listener).not.toHaveBeenCalled(); + }); + + it('scopes commands to the call and refuses them before connect', async () => { + const transport = createNativeTransport(CALL_ID); + await transport.ready; + + await transport.setMicrophoneEnabled(true); + expect(bridge.setNativeCallMicrophoneEnabled).not.toHaveBeenCalled(); + + await transport.connect(connectOptions()); + bridge.setNativeCallMicrophoneEnabled.mockResolvedValue(snapshot()); + await transport.setMicrophoneEnabled(false); + + expect(bridge.setNativeCallMicrophoneEnabled).toHaveBeenCalledWith({ + callId: CALL_ID, + enabled: false, + }); + }); + + it('turns the camera on after connecting when the call started with video', async () => { + const transport = createNativeTransport(CALL_ID); + await transport.ready; + + await transport.connect(connectOptions({ cameraEnabled: true })); + + expect(bridge.setNativeCallCameraEnabled).toHaveBeenCalledWith({ + callId: CALL_ID, + enabled: true, + }); + }); +}); diff --git a/src/app/features/call/nativeTransport.ts b/src/app/features/call/nativeTransport.ts new file mode 100644 index 000000000..50be814f4 --- /dev/null +++ b/src/app/features/call/nativeTransport.ts @@ -0,0 +1,291 @@ +import { + connectNativeCall, + disconnectNativeCall, + getAudioRoutes, + getNativeCallState, + listenNativeCallSnapshot, + setAudioRoute, + setNativeCallCameraEnabled, + setNativeCallEncryptionKey, + setNativeCallMicrophoneEnabled, + setNativeCallPiPEnabled, + switchNativeCallCamera, + type NativeCallFailureCode, + type NativeCallRemoteCamera, + type NativeCallRemoteParticipant, + type NativeCallSnapshot, +} from '@sableclient/tauri-plugin-livekit-mobile'; +import type { + CallAudioRoute, + CallConnectionQuality, + CallEncryptionKey, + CallParticipant, + CallTrack, + CallTransport, + CallTransportConnectOptions, + CallTransportState, +} from '@sableclient/matrixrtc'; +import { createDebugLogger } from '$utils/debugLogger'; + +const debugLog = createDebugLogger('nativeTransport'); + +const logFailure = (what: string, cause: unknown): void => { + const detail = cause instanceof Error ? `${cause.name}: ${cause.message}` : String(cause); + debugLog.error('call', `${what}. ${detail}`); +}; + +const failureMessages: Record = { + invalid_request: 'Could not start the call.', + busy: 'Another call is already in progress.', + permission_denied: 'Microphone or camera access was denied.', + connect_failed: 'Could not connect to the call.', + media_failed: 'Your microphone or camera stopped working.', + disconnected: 'The connection to the call was lost.', + cancelled: 'The call was cancelled.', + unavailable: 'Calls are not available on this device.', + unexpected: 'The call ended unexpectedly.', +}; + +const failureMessage = (code: NativeCallFailureCode | undefined): string => + (code && failureMessages[code]) || 'The call ended unexpectedly.'; + +const toBase64 = (key: Uint8Array): string => { + let binary = ''; + key.forEach((byte) => { + binary += String.fromCharCode(byte); + }); + return btoa(binary); +}; + +const toTrack = (track: NativeCallRemoteCamera): CallTrack => ({ + id: track.sid, + muted: track.muted, + subscribed: track.subscribed, +}); + +const toQuality = (quality: string | undefined): CallConnectionQuality => + quality === 'lost' || quality === 'poor' || quality === 'good' || quality === 'excellent' + ? quality + : 'unknown'; + +const toParticipant = (participant: NativeCallRemoteParticipant): CallParticipant => ({ + identity: participant.identity, + ...(participant.camera ? { camera: toTrack(participant.camera) } : {}), + ...(participant.screenShare ? { screenShare: toTrack(participant.screenShare) } : {}), + connectionQuality: toQuality(participant.connectionQuality), +}); + +export type NativeTransportDependencies = { + connectCall?: typeof connectNativeCall; + disconnectCall?: typeof disconnectNativeCall; + setMicrophone?: typeof setNativeCallMicrophoneEnabled; + setCamera?: typeof setNativeCallCameraEnabled; + setEncryptionKey?: typeof setNativeCallEncryptionKey; + listenSnapshot?: typeof listenNativeCallSnapshot; +}; + +export type NativeCallTransport = CallTransport & { + /** + * Resolves once the snapshot subscription is live. The caller awaits it + * before joining so no state change between join and connect is missed. + */ + ready: Promise; +}; + +/** + * `CallTransport` over the native LiveKit SDK, reached through the + * `livekit-mobile` plugin. One transport per call: `callId` scopes every + * command and filters the snapshot stream, which is global to the plugin. + */ +export const createNativeTransport = ( + callId: string, + dependencies: NativeTransportDependencies = {} +): NativeCallTransport => { + const connectCall = dependencies.connectCall ?? connectNativeCall; + const disconnectCall = dependencies.disconnectCall ?? disconnectNativeCall; + const setMicrophone = dependencies.setMicrophone ?? setNativeCallMicrophoneEnabled; + const setCamera = dependencies.setCamera ?? setNativeCallCameraEnabled; + const setKey = dependencies.setEncryptionKey ?? setNativeCallEncryptionKey; + const listenSnapshot = dependencies.listenSnapshot ?? listenNativeCallSnapshot; + + let state: CallTransportState = { + connection: 'connecting', + participants: [], + microphoneEnabled: false, + cameraEnabled: false, + }; + let connected = false; + let disposed = false; + const pendingKeys: CallEncryptionKey[] = []; + const listeners = new Set<(next: CallTransportState) => void>(); + + const publish = (next: CallTransportState): void => { + state = next; + listeners.forEach((listener) => { + try { + listener(next); + } catch (cause) { + // A state observer must not interrupt lifecycle cleanup. + logFailure('Native call state listener threw', cause); + } + }); + }; + + const apply = (snapshot: NativeCallSnapshot): void => { + if (disposed) return; + if (snapshot.connectionState === 'idle' || snapshot.connectionState === 'failed') { + // A snapshot with no callId before our connect resolves reports the + // plugin's own idle state, not the end of this call. + if (snapshot.callId === null && !connected) return; + if (snapshot.callId !== null && snapshot.callId !== callId) return; + if (snapshot.connectionState === 'failed') { + debugLog.error('call', `Native call failed: ${snapshot.lastError?.code ?? 'unknown'}`); + } + publish({ + connection: 'disconnected', + participants: [], + microphoneEnabled: false, + cameraEnabled: false, + ...(snapshot.connectionState === 'failed' + ? { error: failureMessage(snapshot.lastError?.code) } + : {}), + }); + return; + } + if (snapshot.callId !== callId) return; + publish({ + connection: snapshot.connectionState, + participants: (snapshot.remoteParticipants ?? []).map(toParticipant), + microphoneEnabled: snapshot.microphoneEnabled, + cameraEnabled: snapshot.cameraEnabled, + }); + }; + + const unlistenPromise = listenSnapshot(apply); + // Mark the listen promise as handled immediately; `disconnect` re-attaches. + unlistenPromise.catch(() => undefined); + + // A suspended webview loses the snapshots emitted while it slept, so the + // state is stale on resume. Poll instead of trusting the event stream. + const resync = (): void => { + if (disposed || document.visibilityState !== 'visible') return; + void getNativeCallState() + .then(apply) + .catch((cause: unknown) => logFailure('Native call state resync failed', cause)); + }; + document.addEventListener('visibilitychange', resync); + + const send = (key: CallEncryptionKey): Promise => + setKey({ + callId, + identity: key.identity, + keyIndex: key.keyIndex, + key: toBase64(key.key), + }).then( + () => undefined, + (cause: unknown) => logFailure(`Native call rejected a key for ${key.identity}`, cause) + ); + + const whenConnected = async ( + what: string, + command: () => Promise + ): Promise => { + if (!connected || disposed) return; + await command().then( + () => undefined, + (cause: unknown) => logFailure(what, cause) + ); + }; + + const setCameraEnabled = (enabled: boolean): Promise => + whenConnected(`Native call camera ${enabled ? 'on' : 'off'} failed`, () => + setCamera({ callId, enabled }) + ); + + const connect = async (options: CallTransportConnectOptions): Promise => { + const snapshot = await connectCall({ + callId, + url: options.url, + token: options.token, + microphoneEnabled: options.microphoneEnabled, + encryptionKeys: options.encryptionKeys.map((key) => ({ + identity: key.identity, + keyIndex: key.keyIndex, + key: toBase64(key.key), + })), + }); + connected = true; + // Everything that arrived while the connection was being established is + // replayed rather than dropped. Re-writing a key ring slot is a no-op. + await Promise.all(pendingKeys.splice(0).map(send)); + apply(snapshot); + if (options.cameraEnabled) await setCameraEnabled(true); + }; + + const disconnect = async (): Promise => { + disposed = true; + document.removeEventListener('visibilitychange', resync); + await disconnectCall({ callId }).then( + () => undefined, + (cause: unknown) => logFailure('Native call disconnect failed', cause) + ); + await unlistenPromise.then( + (unlisten) => unlisten(), + (cause: unknown) => logFailure('Native call snapshot listener was never attached', cause) + ); + }; + + return { + ready: unlistenPromise.then(() => undefined), + connect, + disconnect, + setMicrophoneEnabled: (enabled) => + whenConnected(`Native call microphone ${enabled ? 'on' : 'off'} failed`, () => + setMicrophone({ callId, enabled }) + ), + setCameraEnabled, + setEncryptionKey: async (key) => { + if (disposed) return; + if (!connected) { + pendingKeys.push(key); + return; + } + await send(key); + }, + subscribe: (listener) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + getState: () => state, + capabilities: { + camera: { + switch: () => + whenConnected('Native call camera switch failed', () => + switchNativeCallCamera({ callId }) + ), + }, + audioRoutes: { + list: async (): Promise => { + if (!connected || disposed) return []; + return getAudioRoutes({ callId }).then( + (result) => result.routes, + (cause: unknown) => { + logFailure('Native call audio routes unavailable', cause); + return []; + } + ); + }, + select: (routeId) => + whenConnected('Native call audio route select failed', () => + setAudioRoute({ callId, routeId }) + ), + }, + pictureInPicture: { + setEnabled: (enabled) => + whenConnected(`Native call picture in picture ${enabled ? 'on' : 'off'} failed`, () => + setNativeCallPiPEnabled({ callId, enabled }) + ), + }, + }, + }; +}; diff --git a/src/app/features/call/outgoingDeclineHandler.test.ts b/src/app/features/call/outgoingDeclineHandler.test.ts deleted file mode 100644 index 9ea224500..000000000 --- a/src/app/features/call/outgoingDeclineHandler.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { - applyOutgoingDeclineToTracker, - type OutgoingDeclineTracker, -} from './outgoingDeclineHandler'; - -const decline = { - roomId: '!room:example.org', - declineEventId: '$decline', - notificationEventId: '$notif', - senderId: '@alice:example.org', -}; - -describe('applyOutgoingDeclineToTracker', () => { - it('ends call immediately for direct rooms', () => { - const tracker: OutgoingDeclineTracker = new Map(); - const decision = applyOutgoingDeclineToTracker(tracker, decline, { - remoteJoinedIds: new Set(['@alice:example.org']), - isDirectRoom: true, - }); - - expect(decision).toEqual({ kind: 'end_call', declinedCount: 1, targetCount: 1 }); - }); - - it('ignores partial declines in group calls until all remotes decline', () => { - const tracker: OutgoingDeclineTracker = new Map(); - const remoteJoinedIds = new Set(['@alice:example.org', '@bob:example.org']); - - const partial = applyOutgoingDeclineToTracker(tracker, decline, { - remoteJoinedIds, - isDirectRoom: false, - }); - expect(partial).toEqual({ kind: 'ignore_partial', declinedCount: 1, targetCount: 2 }); - - const end = applyOutgoingDeclineToTracker( - tracker, - { ...decline, senderId: '@bob:example.org' }, - { remoteJoinedIds, isDirectRoom: false } - ); - expect(end).toEqual({ kind: 'end_call', declinedCount: 2, targetCount: 2 }); - }); - - it('ignores declines when there are no remote RTC targets in a group room', () => { - const tracker: OutgoingDeclineTracker = new Map(); - const decision = applyOutgoingDeclineToTracker(tracker, decline, { - remoteJoinedIds: new Set(), - isDirectRoom: false, - }); - - expect(decision).toEqual({ kind: 'ignore_partial', declinedCount: 1, targetCount: 0 }); - }); -}); diff --git a/src/app/features/call/outgoingDeclineHandler.ts b/src/app/features/call/outgoingDeclineHandler.ts deleted file mode 100644 index 482c42c3e..000000000 --- a/src/app/features/call/outgoingDeclineHandler.ts +++ /dev/null @@ -1,55 +0,0 @@ -export type OutgoingDeclineEvent = { - roomId: string; - declineEventId: string; - notificationEventId: string; - senderId: string; -}; - -export type OutgoingDeclineTrackerState = { - notificationEventId: string; - declinerIds: Set; -}; - -export type OutgoingDeclineTracker = Map; - -export type OutgoingDeclineDecision = - | { kind: 'ignore_partial'; declinedCount: number; targetCount: number } - | { kind: 'end_call'; declinedCount: number; targetCount: number }; - -export const applyOutgoingDeclineToTracker = ( - tracker: OutgoingDeclineTracker, - decline: OutgoingDeclineEvent, - options: { - remoteJoinedIds: Set; - isDirectRoom: boolean; - } -): OutgoingDeclineDecision => { - const trackedDecline = tracker.get(decline.roomId); - const declineState = - trackedDecline && trackedDecline.notificationEventId === decline.notificationEventId - ? trackedDecline - : { - notificationEventId: decline.notificationEventId, - declinerIds: new Set(), - }; - declineState.declinerIds.add(decline.senderId); - tracker.set(decline.roomId, declineState); - - const targetCount = options.remoteJoinedIds.size; - const declinedCount = declineState.declinerIds.size; - - if (targetCount === 0 && !options.isDirectRoom) { - return { kind: 'ignore_partial', declinedCount, targetCount }; - } - - const allRemoteDeclined = - targetCount > 0 && - [...options.remoteJoinedIds].every((userId) => declineState.declinerIds.has(userId)); - const treatAsOneToOne = options.isDirectRoom || targetCount <= 1; - - if (!treatAsOneToOne && targetCount > 0 && !allRemoteDeclined) { - return { kind: 'ignore_partial', declinedCount, targetCount }; - } - - return { kind: 'end_call', declinedCount, targetCount }; -}; diff --git a/src/app/features/call/rtcNotificationParser.test.ts b/src/app/features/call/rtcNotificationParser.test.ts deleted file mode 100644 index 95709239f..000000000 --- a/src/app/features/call/rtcNotificationParser.test.ts +++ /dev/null @@ -1,258 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { - parseRtcDecline, - RTC_DECLINE_EVENT_TYPE, - parseIncomingRtcNotification, - REFERENCE_REL_TYPE, - RTC_NOTIFICATION_EVENT_TYPE, - type RtcNotificationEventLike, -} from './rtcNotificationParser'; - -const NOW = 1_700_000_000_000; - -const createEvent = ( - overrides: Partial = {} -): RtcNotificationEventLike => ({ - type: RTC_NOTIFICATION_EVENT_TYPE, - sender: '@caller:example.org', - roomId: '!room:example.org', - eventId: '$notif', - originServerTs: NOW - 1_000, - isLiveEvent: true, - isEncrypted: false, - relation: { - rel_type: REFERENCE_REL_TYPE, - event_id: '$call', - }, - content: { - sender_ts: NOW - 500, - lifetime: 60_000, - notification_type: 'ring', - 'm.call.intent': 'start_call_dm_voice', - 'm.mentions': { - user_ids: ['@self:example.org'], - }, - }, - ...overrides, -}); - -describe('parseIncomingRtcNotification', () => { - it('parses a plain RTC notification event', async () => { - const parsed = await parseIncomingRtcNotification(createEvent(), { - myUserId: '@self:example.org', - now: NOW, - }); - - expect(parsed).toMatchObject({ - roomId: '!room:example.org', - notificationEventId: '$notif', - refEventId: '$call', - senderId: '@caller:example.org', - notificationType: 'ring', - intentKind: 'audio', - intentRaw: 'start_call_dm_voice', - }); - }); - - it('parses encrypted notification when decryption succeeds', async () => { - const parsed = await parseIncomingRtcNotification( - createEvent({ isEncrypted: true, content: { ciphertext: 'x' } }), - { - myUserId: '@self:example.org', - now: NOW, - decryptContent: async () => ({ - sender_ts: NOW - 500, - lifetime: 60_000, - notification_type: 'notification', - 'm.call.intent': 'start_call_dm', - 'm.mentions': { room: true }, - }), - } - ); - - expect(parsed?.notificationType).toBe('notification'); - expect(parsed?.intentKind).toBe('video'); - }); - - it('ignores expired notifications', async () => { - const parsed = await parseIncomingRtcNotification( - createEvent({ - content: { - sender_ts: NOW - 120_000, - lifetime: 10_000, - notification_type: 'ring', - 'm.mentions': { room: true }, - }, - }), - { - myUserId: '@self:example.org', - now: NOW, - } - ); - - expect(parsed).toBeUndefined(); - }); - - it('ignores events without reference relation', async () => { - const parsed = await parseIncomingRtcNotification( - createEvent({ relation: { rel_type: 'm.thread', event_id: '$call' } }), - { - myUserId: '@self:example.org', - now: NOW, - } - ); - - expect(parsed).toBeUndefined(); - }); - - it('ignores self-sent notifications', async () => { - const parsed = await parseIncomingRtcNotification( - createEvent({ sender: '@self:example.org' }), - { - myUserId: '@self:example.org', - now: NOW, - } - ); - - expect(parsed).toBeUndefined(); - }); - - it('ignores non-mentioned notifications', async () => { - const parsed = await parseIncomingRtcNotification( - createEvent({ - content: { - sender_ts: NOW - 500, - lifetime: 60_000, - notification_type: 'ring', - 'm.mentions': { user_ids: ['@someone-else:example.org'] }, - }, - }), - { - myUserId: '@self:example.org', - now: NOW, - } - ); - - expect(parsed).toBeUndefined(); - }); - - it('preserves ring vs notification type', async () => { - const ring = await parseIncomingRtcNotification( - createEvent({ - content: { - sender_ts: NOW - 500, - lifetime: 60_000, - notification_type: 'ring', - 'm.mentions': { room: true }, - }, - }), - { - myUserId: '@self:example.org', - now: NOW, - } - ); - const notification = await parseIncomingRtcNotification( - createEvent({ - content: { - sender_ts: NOW - 500, - lifetime: 60_000, - notification_type: 'notification', - 'm.mentions': { room: true }, - }, - }), - { - myUserId: '@self:example.org', - now: NOW, - } - ); - - expect(ring?.notificationType).toBe('ring'); - expect(notification?.notificationType).toBe('notification'); - }); - - it('maps voice and video intents', async () => { - const audio = await parseIncomingRtcNotification( - createEvent({ - content: { - sender_ts: NOW - 500, - lifetime: 60_000, - notification_type: 'ring', - 'm.call.intent': 'join_existing_dm_voice', - 'm.mentions': { room: true }, - }, - }), - { - myUserId: '@self:example.org', - now: NOW, - } - ); - - const video = await parseIncomingRtcNotification( - createEvent({ - content: { - sender_ts: NOW - 500, - lifetime: 60_000, - notification_type: 'ring', - 'm.call.intent': 'start_call_dm', - 'm.mentions': { room: true }, - }, - }), - { - myUserId: '@self:example.org', - now: NOW, - } - ); - - expect(audio?.intentKind).toBe('audio'); - expect(video?.intentKind).toBe('video'); - }); -}); - -describe('parseRtcDecline', () => { - it('parses a live remote decline referencing a notification event', () => { - const parsed = parseRtcDecline( - createEvent({ - type: RTC_DECLINE_EVENT_TYPE, - eventId: '$decline', - content: {}, - relation: { - rel_type: REFERENCE_REL_TYPE, - event_id: '$notif', - }, - }), - { myUserId: '@self:example.org' } - ); - - expect(parsed).toEqual({ - roomId: '!room:example.org', - declineEventId: '$decline', - notificationEventId: '$notif', - senderId: '@caller:example.org', - }); - }); - - it('ignores self-sent declines and declines without reference relations', () => { - expect( - parseRtcDecline( - createEvent({ - type: RTC_DECLINE_EVENT_TYPE, - sender: '@self:example.org', - }), - { myUserId: '@self:example.org' } - ) - ).toBeUndefined(); - - expect( - parseRtcDecline( - createEvent({ - type: RTC_DECLINE_EVENT_TYPE, - relation: { - rel_type: 'm.thread', - event_id: '$notif', - }, - }), - { myUserId: '@self:example.org' } - ) - ).toBeUndefined(); - }); -}); diff --git a/src/app/features/call/rtcNotificationParser.ts b/src/app/features/call/rtcNotificationParser.ts deleted file mode 100644 index bd0591713..000000000 --- a/src/app/features/call/rtcNotificationParser.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { - normalizeCallIntent, - toCallNotificationType, - type CallIntentKind, - type CallNotificationType, -} from './callIntent'; - -export const RTC_NOTIFICATION_EVENT_TYPE = 'org.matrix.msc4075.rtc.notification'; -export const RTC_DECLINE_EVENT_TYPE = 'org.matrix.msc4310.rtc.decline'; -export const REFERENCE_REL_TYPE = 'm.reference'; - -export type NotificationType = CallNotificationType; -export type NotificationIntentKind = CallIntentKind; - -export type RtcNotificationEventLike = { - type: string; - sender: string; - roomId: string; - eventId: string; - originServerTs: number; - content: unknown; - relation?: { - rel_type?: string; - event_id?: string; - }; - isLiveEvent: boolean; - isEncrypted: boolean; -}; - -type RtcMentions = { - room?: boolean; - user_ids?: string[]; -}; - -type RtcNotificationContent = { - sender_ts?: number; - lifetime?: number; - notification_type?: NotificationType; - 'm.mentions'?: RtcMentions; - 'm.call.intent'?: string; -}; - -export type ParseIncomingRtcNotificationOptions = { - myUserId: string; - now: number; - maxLifetimeMs?: number; - decryptContent?: () => Promise; -}; - -export type ParsedIncomingRtcNotification = { - roomId: string; - notificationEventId: string; - refEventId: string; - senderId: string; - senderTs: number; - expiresAt: number; - notificationType: NotificationType; - intentKind: NotificationIntentKind; - intentRaw?: string; -}; - -export type ParsedRtcDecline = { - roomId: string; - declineEventId: string; - notificationEventId: string; - senderId: string; -}; - -const isObject = (value: unknown): value is Record => - typeof value === 'object' && value !== null; - -const isMentioned = (mentions: RtcMentions | undefined, myUserId: string): boolean => { - if (!mentions) return false; - if (mentions.room) return true; - return Array.isArray(mentions.user_ids) && mentions.user_ids.includes(myUserId); -}; - -const getSenderTimestamp = (contentTs: number, originTs: number): number => - contentTs - originTs > 20_000 ? originTs : contentTs; - -export const parseIncomingRtcNotification = async ( - event: RtcNotificationEventLike, - options: ParseIncomingRtcNotificationOptions -): Promise => { - if (!event.isLiveEvent) return undefined; - if (event.type !== RTC_NOTIFICATION_EVENT_TYPE) return undefined; - if (event.sender === options.myUserId) return undefined; - if (event.relation?.rel_type !== REFERENCE_REL_TYPE || !event.relation.event_id) return undefined; - - const rawContent = event.isEncrypted ? await options.decryptContent?.() : event.content; - if (!isObject(rawContent)) return undefined; - - const content = rawContent as RtcNotificationContent; - if (!isMentioned(content['m.mentions'], options.myUserId)) return undefined; - - const senderTsCandidate = content.sender_ts; - const lifetimeCandidate = content.lifetime; - const notificationType = toCallNotificationType(content.notification_type); - - if (typeof senderTsCandidate !== 'number') return undefined; - if (typeof lifetimeCandidate !== 'number' || !Number.isFinite(lifetimeCandidate)) - return undefined; - if (!notificationType) return undefined; - - const senderTs = getSenderTimestamp(senderTsCandidate, event.originServerTs); - const lifetime = Math.min(lifetimeCandidate, options.maxLifetimeMs ?? 120_000); - const expiresAt = senderTs + lifetime; - if (options.now >= expiresAt) return undefined; - - const intentRaw = - typeof content['m.call.intent'] === 'string' ? content['m.call.intent'] : undefined; - - return { - roomId: event.roomId, - notificationEventId: event.eventId, - refEventId: event.relation.event_id, - senderId: event.sender, - senderTs, - expiresAt, - notificationType, - intentKind: normalizeCallIntent(undefined, intentRaw), - intentRaw, - }; -}; - -export const parseRtcDecline = ( - event: RtcNotificationEventLike, - options: Pick -): ParsedRtcDecline | undefined => { - if (!event.isLiveEvent) return undefined; - if (event.type !== RTC_DECLINE_EVENT_TYPE) return undefined; - if (event.sender === options.myUserId) return undefined; - if (event.relation?.rel_type !== REFERENCE_REL_TYPE || !event.relation.event_id) { - return undefined; - } - - return { - roomId: event.roomId, - declineEventId: event.eventId, - notificationEventId: event.relation.event_id, - senderId: event.sender, - }; -}; diff --git a/src/app/features/call/rtcTimelineDecline.ts b/src/app/features/call/rtcTimelineDecline.ts index 759f84f1c..a73e40f2c 100644 --- a/src/app/features/call/rtcTimelineDecline.ts +++ b/src/app/features/call/rtcTimelineDecline.ts @@ -1,7 +1,10 @@ +import { + type ParsedRtcDecline, + decryptRtcTimelineEvent, + parseRtcDecline, +} from '@sableclient/matrixrtc'; import * as Sentry from '@sentry/react'; import type { MatrixClient, MatrixEvent, Room } from '$types/matrix-sdk'; -import { decryptRtcTimelineEvent } from './callSignalingDecrypt'; -import { parseRtcDecline, type ParsedRtcDecline } from './rtcNotificationParser'; const relationFromContent = (content: unknown) => { if (!content || typeof content !== 'object') return undefined; diff --git a/src/app/features/call/useCallSignaling.ts b/src/app/features/call/useCallSignaling.ts index a652d21ab..18413229a 100644 --- a/src/app/features/call/useCallSignaling.ts +++ b/src/app/features/call/useCallSignaling.ts @@ -1,7 +1,28 @@ +import { + FALLBACK_INTERVAL_MS, + MAX_NOTIFICATION_LIFETIME_MS, + OUTGOING_DECLINE_EMBED_CLEAR_MS, + OUTGOING_RING_TIMEOUT_MS, + type OutgoingDeclineEvent, + REFERENCE_REL_TYPE, + RTC_DECLINE_EVENT_TYPE, + RTC_NOTIFICATION_EVENT_TYPE, + applyOutgoingDeclineToTracker, + decryptRtcTimelineEvent, + getRemoteRtcMemberUserIds, + isCallActive, + isOutgoingCallPending, + parseIncomingRtcNotification, +} from '@sableclient/matrixrtc'; import { useCallback, useEffect, useRef } from 'react'; import * as Sentry from '@sentry/react'; import { useAtomValue, useSetAtom, useStore } from 'jotai'; -import type { RoomEventHandlerMap, MatrixEvent, Room } from '$types/matrix-sdk'; +import { + EventType, + type RoomEventHandlerMap, + type MatrixEvent, + type Room, +} from '$types/matrix-sdk'; import { MatrixRTCSessionManagerEvents, RoomEvent } from '$types/matrix-sdk'; import { mDirectAtom } from '$state/mDirectList'; import { @@ -11,47 +32,31 @@ import { mutedCallRoomIdAtom, type IncomingCall, } from '$state/callEmbed'; +import { livekitJsCallAtom } from '$state/livekitJsCall'; +import { nativeCallAtom, selectActiveCallSessionIncludingNative } from '$state/nativeCall'; import { settingsAtom } from '$state/settings'; -import { - parseIncomingRtcNotification, - RTC_DECLINE_EVENT_TYPE, - REFERENCE_REL_TYPE, - RTC_NOTIFICATION_EVENT_TYPE, -} from '$features/call/rtcNotificationParser'; -import { decryptRtcTimelineEvent } from '$features/call/callSignalingDecrypt'; -import { - FALLBACK_INTERVAL_MS, - MAX_NOTIFICATION_LIFETIME_MS, - OUTGOING_DECLINE_EMBED_CLEAR_MS, -} from '$features/call/callSignalingPolicy'; -import { - applyOutgoingDeclineToTracker, - type OutgoingDeclineEvent, -} from '$features/call/outgoingDeclineHandler'; import { parseRtcDeclineFromTimelineEvent } from '$features/call/rtcTimelineDecline'; import { evaluateIncomingCallFallback } from '$features/call/callSignalingFallback'; import { canPlayCallAudio } from '$features/call/callRingtone'; import { dismissSystemCallNotifications } from '$features/call/callNotificationBridge'; import { isIncomingCallSuppressed } from '$features/call/callIncomingIngress'; -import { - getRemoteRtcMemberUserIds, - isCallActive, - isOutgoingCallPending, -} from '$features/call/callMembershipState'; import { ringtoneManager } from '$features/call/CallRingtoneManager'; -import { OUTGOING_RING_TIMEOUT_MS } from '$features/call/callSignalingPolicy'; import { useMatrixClient } from '$hooks/useMatrixClient'; import { createDebugLogger } from '$utils/debugLogger'; const debugLog = createDebugLogger('CallSignaling'); const canSenderStartCalls = (room: Room, senderId: string): boolean => - room.currentState?.maySendStateEvent('org.matrix.msc3401.call.member', senderId) ?? false; + room.currentState?.maySendStateEvent(EventType.RTCMembership, senderId) || + room.currentState?.maySendStateEvent(EventType.GroupCallMemberPrefix, senderId) || + false; export function useIncomingCallSignaling() { const mx = useMatrixClient(); const store = useStore(); const callEmbed = useAtomValue(callEmbedAtom); + const livekitJsCall = useAtomValue(livekitJsCallAtom); + const nativeCall = useAtomValue(nativeCallAtom); const mDirects = useAtomValue(mDirectAtom); const settings = useAtomValue(settingsAtom); const incomingCall = useAtomValue(incomingCallAtom); @@ -89,6 +94,8 @@ export function useIncomingCallSignaling() { type SignalingHandlerRefs = { callEmbed: typeof callEmbed; + livekitJsCall: typeof livekitJsCall; + nativeCall: typeof nativeCall; mDirects: typeof mDirects; outgoingRingbackAllowed: boolean; handleIncomingCall: (incoming: IncomingCall) => void; @@ -117,7 +124,7 @@ export function useIncomingCallSignaling() { hasCallBeenActiveRef.current = false; outgoingRingRoomIdRef.current = null; outgoingStartRef.current = null; - }, [callEmbed]); + }, [callEmbed?.roomId, livekitJsCall?.roomId, nativeCall?.roomId]); useEffect(() => { ringtoneManager.syncSources( @@ -147,7 +154,12 @@ export function useIncomingCallSignaling() { const handleOutgoingDecline = useCallback( (decline: OutgoingDeclineEvent) => { - if (!callEmbed || callEmbed.roomId !== decline.roomId) { + const activeCall = selectActiveCallSessionIncludingNative( + callEmbed, + livekitJsCall, + nativeCall + ); + if (!activeCall || activeCall.roomId !== decline.roomId) { return; } @@ -207,8 +219,10 @@ export function useIncomingCallSignaling() { Sentry.metrics.count('sable.call.outgoing.declined', 1); stopOutgoingRing(); - void callEmbed - .hangup() + const hangup = + selectActiveCallSessionIncludingNative(callEmbed, livekitJsCall, nativeCall)?.hangup() ?? + Promise.resolve(); + void hangup .catch((error) => { debugLog.warn('call', 'Failed to hang up after outgoing decline', { roomId: decline.roomId, @@ -219,12 +233,11 @@ export function useIncomingCallSignaling() { .finally(() => { window.setTimeout(() => { const activeEmbed = store.get(callEmbedAtom); - if (activeEmbed !== callEmbed) return; - setCallEmbed(undefined); + if (activeEmbed === callEmbed) setCallEmbed(undefined); }, OUTGOING_DECLINE_EMBED_CLEAR_MS); }); }, - [callEmbed, mDirects, mx, setCallEmbed, stopOutgoingRing, store] + [callEmbed, livekitJsCall, nativeCall, mDirects, mx, setCallEmbed, stopOutgoingRing, store] ); const callAudioAllowed = canPlayCallAudio({ @@ -283,6 +296,8 @@ export function useIncomingCallSignaling() { signalingHandlerRefs.current = { callEmbed, + livekitJsCall, + nativeCall, mDirects, outgoingRingbackAllowed, handleIncomingCall, @@ -421,7 +436,12 @@ export function useIncomingCallSignaling() { if (!senderId || !eventId) return; if (senderId === myUserId) { - if (type === RTC_NOTIFICATION_EVENT_TYPE && handlers().callEmbed?.roomId === room.roomId) { + const activeCall = selectActiveCallSessionIncludingNative( + handlers().callEmbed, + handlers().livekitJsCall, + handlers().nativeCall + ); + if (type === RTC_NOTIFICATION_EVENT_TYPE && activeCall?.roomId === room.roomId) { activeOutgoingNotificationIdRef.current = eventId; } return; @@ -436,8 +456,12 @@ export function useIncomingCallSignaling() { // Only inspect declines for the active outgoing call room. Cleartext declines are // cheap; encrypted events are decrypted only when they might be RTC declines. - const activeEmbed = handlers().callEmbed; - if (!activeEmbed || activeEmbed.roomId !== room.roomId) { + const activeCall = selectActiveCallSessionIncludingNative( + handlers().callEmbed, + handlers().livekitJsCall, + handlers().nativeCall + ); + if (!activeCall || activeCall.roomId !== room.roomId) { return; } if (event.isDecryptionFailure()) { @@ -496,7 +520,11 @@ export function useIncomingCallSignaling() { let outgoingRingTimeoutId: number | undefined; const evaluateOutgoingFallback = () => { - const activeCallRoomId = handlers().callEmbed?.roomId; + const activeCallRoomId = selectActiveCallSessionIncludingNative( + handlers().callEmbed, + handlers().livekitJsCall, + handlers().nativeCall + )?.roomId; const stop = () => { handlers().stopOutgoingRing(); diff --git a/src/app/features/call/useCallStartCapabilities.test.tsx b/src/app/features/call/useCallStartCapabilities.test.tsx new file mode 100644 index 000000000..7a5218257 --- /dev/null +++ b/src/app/features/call/useCallStartCapabilities.test.tsx @@ -0,0 +1,146 @@ +import type { ReactNode } from 'react'; +import { act, render } from '@testing-library/react'; +import { createStore, Provider as JotaiProvider } from 'jotai'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { MatrixClient, Room } from '$types/matrix-sdk'; +import type { CallEmbed } from '$plugins/call'; +import { MatrixClientProvider } from '$hooks/useMatrixClient'; +import { AutoDiscoveryInfoProvider } from '$hooks/useAutoDiscoveryInfo'; +import { nativeCallAtom, type NativeCallSession } from '$state/nativeCall'; +import type { CallStartCapabilities } from '@sableclient/matrixrtc'; +import { useCallStartCapabilities } from './useCallStartCapabilities'; + +const { webRtcSupportedMock, useCallEmbedMock } = vi.hoisted(() => ({ + webRtcSupportedMock: vi.fn<() => boolean>(() => true), + useCallEmbedMock: vi.fn<() => CallEmbed | undefined>(() => undefined), +})); + +vi.mock('$utils/rtc', () => ({ + webRTCSupported: () => webRtcSupportedMock(), +})); + +vi.mock('$hooks/useCallEmbed', () => ({ + useCallEmbed: () => useCallEmbedMock(), +})); + +const room = { + roomId: '!room:example.org', + currentState: { + maySendStateEvent: () => true, + }, +} as unknown as Room; + +const mx = { + getSafeUserId: () => '@me:example.org', + on: vi.fn<(...args: unknown[]) => void>(), + removeListener: vi.fn<(...args: unknown[]) => void>(), + // This homeserver advertises no MSC4143 transports, so these cases exercise + // the `.well-known` fallback that the fixtures below configure. + _unstable_getRTCTransports: () => Promise.reject(new Error('M_NOT_FOUND')), +} as unknown as MatrixClient; + +const makeNativeSession = ( + roomId: string, + lifecycle: NativeCallSession['lifecycle'] +): NativeCallSession => ({ + backend: 'livekit-mobile', + roomId, + callId: 'call-id', + lifecycle, + participants: [], + microphoneEnabled: true, + cameraEnabled: false, + setMicrophoneEnabled: async () => {}, + setCameraEnabled: async () => {}, + switchCamera: async () => {}, + listAudioRoutes: async () => [], + selectAudioRoute: async () => {}, + hangup: async () => undefined, +}); + +type Harness = { + store: ReturnType; + wrapper: ({ children }: { children?: ReactNode }) => ReactNode; + latest: () => CallStartCapabilities; +}; + +const createHarness = (): Harness => { + const store = createStore(); + let current: CallStartCapabilities | undefined; + const Probe = (): null => { + current = useCallStartCapabilities(room); + return null; + }; + const wrapper = ({ children }: { children?: ReactNode }) => ( + + + + {children ?? } + + + + ); + const latest = (): CallStartCapabilities => { + expect(current).toBeDefined(); + return current!; + }; + return { store, wrapper, latest }; +}; + +describe('useCallStartCapabilities', () => { + beforeEach(() => { + webRtcSupportedMock.mockReset().mockReturnValue(true); + useCallEmbedMock.mockReset().mockReturnValue(undefined); + }); + + it('blocks starting in another room while a native call is active there', () => { + const harness = createHarness(); + harness.store.set(nativeCallAtom, makeNativeSession('!other:example.org', 'connected')); + + render({undefined}); + + const capabilities = harness.latest(); + expect(capabilities.inAnotherCall).toBe(true); + expect(capabilities.blockers).toContain('already_in_another_call'); + expect(capabilities.canStart).toBe(false); + }); + + it('stops treating a native session as active once it reaches the error state', () => { + const harness = createHarness(); + render({undefined}); + + act(() => { + harness.store.set(nativeCallAtom, makeNativeSession('!other:example.org', 'starting')); + }); + expect(harness.latest().inAnotherCall).toBe(true); + + act(() => { + harness.store.set(nativeCallAtom, makeNativeSession('!other:example.org', 'error')); + }); + const capabilities = harness.latest(); + expect(capabilities.inAnotherCall).toBe(false); + expect(capabilities.blockers).not.toContain('already_in_another_call'); + expect(capabilities.canStart).toBe(true); + }); + + it('still counts an active Element call embed as an active call', () => { + const harness = createHarness(); + useCallEmbedMock.mockReturnValue({ + roomId: '!other:example.org', + } as CallEmbed); + + render({undefined}); + + const capabilities = harness.latest(); + expect(capabilities.inAnotherCall).toBe(true); + expect(capabilities.blockers).toContain('already_in_another_call'); + }); +}); diff --git a/src/app/features/call/useCallStartCapabilities.ts b/src/app/features/call/useCallStartCapabilities.ts index bad149ca5..317c46cb6 100644 --- a/src/app/features/call/useCallStartCapabilities.ts +++ b/src/app/features/call/useCallStartCapabilities.ts @@ -2,23 +2,31 @@ import { useCallback, useMemo } from 'react'; import type { Room } from '$types/matrix-sdk'; import { EventType } from '$types/matrix-sdk'; import { useCallEmbed } from '$hooks/useCallEmbed'; +import { useAtomValue } from 'jotai'; +import { isLivekitJsCallActive, livekitJsCallAtom } from '$state/livekitJsCall'; +import { isNativeCallActive, nativeCallAtom } from '$state/nativeCall'; import { useLivekitSupport } from '$hooks/useLivekitSupport'; import { useMatrixClient } from '$hooks/useMatrixClient'; import { useStateEventCallback } from '$hooks/useStateEventCallback'; import { useForceUpdate } from '$hooks/useForceUpdate'; import { webRTCSupported } from '$utils/rtc'; -import { - evaluateCallStartCapabilities, - type CallStartCapabilities, -} from '$features/call/callStartCapabilities'; +import { evaluateCallStartCapabilities, type CallStartCapabilities } from '@sableclient/matrixrtc'; export const useCallStartCapabilities = (room: Room): CallStartCapabilities => { const mx = useMatrixClient(); const callEmbed = useCallEmbed(); + const livekitJsCall = useAtomValue(livekitJsCallAtom); + const nativeCall = useAtomValue(nativeCallAtom); const livekitSupported = useLivekitSupport(); const rtcSupported = webRTCSupported(); const myUserId = mx.getSafeUserId(); const [updateCount, forceUpdate] = useForceUpdate(); + // Terminal sessions stay in their atom for display, but they are not active + // calls and must not block starting a later one. + const activeLivekitJsRoomId = isLivekitJsCallActive(livekitJsCall) + ? livekitJsCall?.roomId + : undefined; + const activeNativeCallRoomId = isNativeCallActive(nativeCall) ? nativeCall?.roomId : undefined; useStateEventCallback( mx, @@ -42,9 +50,18 @@ export const useCallStartCapabilities = (room: Room): CallStartCapabilities => { return evaluateCallStartCapabilities({ room, myUserId, - activeCallRoomId: callEmbed?.roomId, + activeCallRoomId: callEmbed?.roomId ?? activeLivekitJsRoomId ?? activeNativeCallRoomId, livekitSupported, rtcSupported, }); - }, [room, myUserId, callEmbed?.roomId, livekitSupported, rtcSupported, updateCount]); + }, [ + room, + myUserId, + callEmbed?.roomId, + activeLivekitJsRoomId, + activeNativeCallRoomId, + livekitSupported, + rtcSupported, + updateCount, + ]); }; diff --git a/src/app/features/settings/experimental/Experimental.test.tsx b/src/app/features/settings/experimental/Experimental.test.tsx new file mode 100644 index 000000000..9a2313184 --- /dev/null +++ b/src/app/features/settings/experimental/Experimental.test.tsx @@ -0,0 +1,94 @@ +import type { ReactNode } from 'react'; +import type * as SettingsModule from '$state/settings'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { Experimental } from './Experimental'; + +const { mockSetNewCallsEnabled, mockUseSetting } = vi.hoisted(() => ({ + mockSetNewCallsEnabled: vi.fn<(value: boolean) => void>(), + mockUseSetting: + vi.fn<(_atom: unknown, key: string) => readonly [boolean, (value: boolean) => void]>(), +})); + +vi.mock('$state/settings', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, settingsAtom: {} }; +}); + +vi.mock('$state/hooks/settings', () => ({ + useSetting: mockUseSetting, +})); + +vi.mock('$components/page', () => ({ + PageContent: ({ children }: { children: ReactNode }) =>
{children}
, + SettingsSectionPage: ({ children }: { children: ReactNode }) =>
{children}
, +})); + +vi.mock('$components/info-card', () => ({ + InfoCard: () =>
Experimental warning
, +})); + +vi.mock('$components/setting-tile', () => ({ + SettingToggle: ({ + title, + description, + focusId, + value, + onChange, + }: { + title: string; + description: ReactNode; + focusId: string; + value: boolean; + onChange: (value: boolean) => void; + }) => ( +
+ {title} + {description} + +
+ ), +})); + +vi.mock('./BandwithSavingEmojis', () => ({ BandwidthSavingEmojis: () => null })); +vi.mock('./MSC4268HistoryShare', () => ({ MSC4268HistoryShare: () => null })); +vi.mock('./MSC4274MediaGalleries', () => ({ MSC4274MediaGalleries: () => null })); + +vi.mock('folds', () => ({ + Box: ({ children }: { children: ReactNode }) =>
{children}
, + Scroll: ({ children }: { children: ReactNode }) =>
{children}
, + Text: ({ children }: { children: ReactNode }) => {children}, +})); + +beforeEach(() => { + mockSetNewCallsEnabled.mockReset(); + mockUseSetting.mockImplementation((_atom: unknown, key: string) => { + if (key === 'newCallsEnabled') return [false, mockSetNewCallsEnabled]; + return [false, vi.fn<() => void>()]; + }); +}); + +describe('Experimental new calls setting', () => { + it('shows one new calls toggle', () => { + render( {}} />); + + expect(screen.getByText('New calls')).toBeInTheDocument(); + expect(screen.getByText('Enable new calls')).toBeInTheDocument(); + expect( + screen.getByText( + 'Uses LiveKit JS on web and desktop, and native LiveKit on supported mobile devices. Element Call remains the fallback.' + ) + ).toBeInTheDocument(); + expect(screen.queryByText(/manual media|connection probe/i)).not.toBeInTheDocument(); + }); + + it('persists the opt-in through the settings hook', () => { + render( {}} />); + + fireEvent.click(screen.getByRole('button', { name: 'new-calls' })); + + expect(mockSetNewCallsEnabled).toHaveBeenCalledWith(true); + }); +}); diff --git a/src/app/features/settings/experimental/Experimental.tsx b/src/app/features/settings/experimental/Experimental.tsx index 5d38fb06b..b89ec3c6e 100644 --- a/src/app/features/settings/experimental/Experimental.tsx +++ b/src/app/features/settings/experimental/Experimental.tsx @@ -29,6 +29,23 @@ function PersonaToggle() { ); } +function NewCallsToggle() { + const [newCallsEnabled, setNewCallsEnabled] = useSetting(settingsAtom, 'newCallsEnabled'); + + return ( + + New calls + + + ); +} + type ExperimentalProps = { requestBack?: () => void; requestClose: () => void; @@ -56,6 +73,7 @@ export function Experimental({ requestBack, requestClose }: Readonly + diff --git a/src/app/features/settings/settingsLink.ts b/src/app/features/settings/settingsLink.ts index 4d85fd0d8..25267cf03 100644 --- a/src/app/features/settings/settingsLink.ts +++ b/src/app/features/settings/settingsLink.ts @@ -241,6 +241,7 @@ export const settingsLinkFocusIdsBySection: Record { renderHook(() => useTimelineSync({ room: room as Room, - mx: makeMx(), + mx: { getUserId: () => '@alice:test' } as never, isAtBottom: false, isAtBottomRef: { current: false }, scrollToBottom, @@ -270,7 +270,7 @@ describe('useTimelineSync', () => { renderHook(() => useTimelineSync({ room: room as Room, - mx: makeMx(), + mx: { getUserId: () => '@alice:test' } as never, isAtBottom: true, isAtBottomRef: { current: true }, scrollToBottom, @@ -298,7 +298,7 @@ describe('useTimelineSync', () => { ({ room, eventId }) => useTimelineSync({ room, - mx: makeMx(), + mx: { getUserId: () => '@alice:test' } as never, eventId, isAtBottom: false, isAtBottomRef: { current: false }, @@ -335,7 +335,7 @@ describe('useTimelineSync', () => { ({ room, eventId }) => useTimelineSync({ room, - mx: makeMx(), + mx: { getUserId: () => '@alice:test' } as never, eventId, isAtBottom: false, isAtBottomRef: { current: false }, @@ -370,7 +370,7 @@ describe('useTimelineSync', () => { ({ room }) => useTimelineSync({ room, - mx: makeMx(), + mx: { getUserId: () => '@alice:test' } as never, eventId: undefined, isAtBottom: false, isAtBottomRef: { current: false }, @@ -404,7 +404,7 @@ describe('useTimelineSync', () => { renderHook(() => useTimelineSync({ room: room as Room, - mx: makeMx(), + mx: { getUserId: () => '@alice:test' } as never, isAtBottom: true, isAtBottomRef: { current: true }, scrollToBottom, @@ -431,7 +431,7 @@ describe('useTimelineSync', () => { renderHook(() => useTimelineSync({ room: room as Room, - mx: makeMx(), + mx: { getUserId: () => '@alice:test' } as never, isAtBottom: true, isAtBottomRef: { current: true }, scrollToBottom, @@ -461,7 +461,7 @@ describe('useTimelineSync', () => { renderHook(() => useTimelineSync({ room: room as Room, - mx: makeMx(), + mx: { getUserId: () => '@alice:test' } as never, isAtBottom: true, isAtBottomRef: { current: true }, scrollToBottom, @@ -489,7 +489,7 @@ describe('useTimelineSync', () => { renderHook(() => useTimelineSync({ room: room as Room, - mx: makeMx(), + mx: { getUserId: () => '@alice:test' } as never, isAtBottom: false, isAtBottomRef: { current: false }, scrollToBottom, @@ -515,7 +515,7 @@ describe('useTimelineSync', () => { renderHook(() => useTimelineSync({ room: room as Room, - mx: makeMx(), + mx: { getUserId: () => '@alice:test' } as never, isAtBottom: true, isAtBottomRef: { current: true }, scrollToBottom, @@ -545,7 +545,7 @@ describe('useTimelineSync', () => { renderHook(() => useTimelineSync({ room: room as Room, - mx: makeMx(), + mx: { getUserId: () => '@alice:test' } as never, isAtBottom: true, isAtBottomRef: { current: true }, scrollToBottom, @@ -581,7 +581,7 @@ const syncOpts = ( isEventVisible?: () => boolean ) => ({ room: room as Room, - mx: makeMx({ paginateEventTimeline }), + mx: { getUserId: () => '@alice:test', paginateEventTimeline } as never, isAtBottom: true, isAtBottomRef: { current: true }, scrollToBottom: vi.fn<() => void>(), @@ -956,7 +956,7 @@ describe('live-arrive edge cases', () => { renderHook(() => useTimelineSync({ room: room as Room, - mx: makeMx(), + mx: { getUserId: () => '@alice:test' } as never, isAtBottom: false, isAtBottomRef: { current: false }, scrollToBottom, @@ -980,43 +980,43 @@ describe('live-arrive edge cases', () => { expect(scrollToBottom).not.toHaveBeenCalled(); }); - it('re-renders when an event finishes decrypting', async () => { + it('re-renders when a late local echo updates (slow send acknowledgement)', async () => { const { room } = createRoom(); const { result } = renderSyncHook(room); const before = result.current.timeline; await act(async () => { - mxEmitter.emit(MatrixEventEvent.Decrypted, { getRoomId: () => room.roomId }); + room.emit(RoomEvent.LocalEchoUpdated, {}, room); await Promise.resolve(); }); expect(result.current.timeline).not.toBe(before); }); - it('ignores decryption of an event in another room', async () => { + it('re-renders when an event finishes decrypting', async () => { const { room } = createRoom(); const { result } = renderSyncHook(room); const before = result.current.timeline; await act(async () => { - mxEmitter.emit(MatrixEventEvent.Decrypted, { getRoomId: () => '!other:test' }); + mxEmitter.emit(MatrixEventEvent.Decrypted, { getRoomId: () => room.roomId }); await Promise.resolve(); }); - expect(result.current.timeline).toBe(before); + expect(result.current.timeline).not.toBe(before); }); - it('re-renders when a late local echo updates (slow send acknowledgement)', async () => { + it('ignores decryption of an event in another room', async () => { const { room } = createRoom(); const { result } = renderSyncHook(room); const before = result.current.timeline; await act(async () => { - room.emit(RoomEvent.LocalEchoUpdated, {}, room); + mxEmitter.emit(MatrixEventEvent.Decrypted, { getRoomId: () => '!other:test' }); await Promise.resolve(); }); - expect(result.current.timeline).not.toBe(before); + expect(result.current.timeline).toBe(before); }); }); @@ -1051,11 +1051,12 @@ describe('event jump recovery', () => { targetTimeline, roomInitialSync, getLatestTimeline, - mx: makeMx({ + mx: { + getUserId: () => '@alice:test', roomInitialSync, getLatestTimeline, getEventTimeline: vi.fn<() => Promise>(() => Promise.resolve(targetTimeline)), - }), + }, }; }; @@ -1101,12 +1102,13 @@ describe('event jump recovery', () => { vi.useFakeTimers(); try { const { room } = createRoom(); - const mx = makeMx({ + const mx = { + getUserId: () => '@alice:test', roomInitialSync: vi.fn<() => Promise>(() => Promise.resolve(undefined)), getLatestTimeline: vi.fn<() => Promise>(() => Promise.resolve(undefined)), // The homeserver never answers /context: hit the 12 s timeout. getEventTimeline: () => new Promise(() => {}), - }); + }; const { result, scrollToBottom } = renderSyncHook(room, { isAtBottom: false, mx }); await act(async () => { @@ -1132,7 +1134,7 @@ describe('sliding sync chain relink', () => { undefined; const paginateEventTimeline = vi.fn<() => Promise>(() => Promise.resolve(false)); const { result } = renderSyncHook(room, { - mx: makeMx({ paginateEventTimeline }), + mx: { getUserId: () => '@alice:test', paginateEventTimeline }, }); expect(result.current.eventsLength).toBe(1); @@ -1183,7 +1185,7 @@ describe('sync transport fuzz', () => { const { result, unmount } = renderHook(() => useTimelineSync({ room: room as Room, - mx: makeMx({ paginateEventTimeline }), + mx: { getUserId: () => '@alice:test', paginateEventTimeline } as never, isAtBottom: false, isAtBottomRef: { current: false }, scrollToBottom, diff --git a/src/app/hooks/timeline/useTimelineSync.ts b/src/app/hooks/timeline/useTimelineSync.ts index b7f1475a9..66f1a8c26 100644 --- a/src/app/hooks/timeline/useTimelineSync.ts +++ b/src/app/hooks/timeline/useTimelineSync.ts @@ -588,7 +588,11 @@ export function useTimelineSync({ [room, setTimeline] ); - useMatrixEvent(mx, MatrixEventEvent.Decrypted, handleDecrypted); + useMatrixEvent( + typeof (mx as Partial>).on === 'function' ? mx : undefined, + MatrixEventEvent.Decrypted, + handleDecrypted + ); useLiveTimelineRefresh( room, diff --git a/src/app/hooks/useAutoJoinCall.ts b/src/app/hooks/useAutoJoinCall.ts index 0f95d9485..c4804cb96 100644 --- a/src/app/hooks/useAutoJoinCall.ts +++ b/src/app/hooks/useAutoJoinCall.ts @@ -26,6 +26,8 @@ export function useAutoJoinCall() { microphone: callPreferences.microphone, video: autoJoinIntent.video, sound: callPreferences.sound, + audioDeviceId: callPreferences.audioDeviceId, + videoDeviceId: callPreferences.videoDeviceId, }); setAutoJoinIntent(null); } @@ -38,6 +40,8 @@ export function useAutoJoinCall() { mDirects, callPreferences.microphone, callPreferences.sound, + callPreferences.audioDeviceId, + callPreferences.videoDeviceId, startDirectCall, startRoomCall, ]); diff --git a/src/app/hooks/useCallEmbed.ts b/src/app/hooks/useCallEmbed.ts index c55cfe831..dc46a94b2 100644 --- a/src/app/hooks/useCallEmbed.ts +++ b/src/app/hooks/useCallEmbed.ts @@ -1,7 +1,7 @@ import type { RefObject } from 'react'; import { createContext, useCallback, useContext, useEffect, useState } from 'react'; import type { MatrixClient, Room } from '$types/matrix-sdk'; -import { useSetAtom } from 'jotai'; +import { useSetAtom, useStore } from 'jotai'; import * as Sentry from '@sentry/react'; import type { ElementCallThemeKind } from '../plugins/call'; import { CallEmbed, ElementWidgetActions, useClientWidgetApiEvent } from '../plugins/call'; @@ -15,6 +15,14 @@ import type { CallPreferences } from '../state/callPreferences'; import { createDebugLogger } from '../utils/debugLogger'; import { useClientConfig } from './useClientConfig'; import { callEmbedStartErrorAtom } from '$state/callEmbed'; +import { settingsAtom } from '$state/settings'; +import { useSetting } from '$state/hooks/settings'; +import { acquireCallOwner } from '$state/callOwner'; +import { selectCallStartOwner } from '@sableclient/matrixrtc'; +import { useLivekitJsCallManager } from '$features/call/livekitJsCallManager'; +import { getNativeCallAvailability } from '$features/call/nativeCallProbe'; +import { getNativeCallManager } from '$features/call/nativeCallManager'; +import { useAutoDiscoveryInfo } from './useAutoDiscoveryInfo'; const debugLog = createDebugLogger('useCallEmbed'); @@ -66,12 +74,54 @@ export const useCallStart = (dm = false) => { const setCallEmbed = useSetAtom(callEmbedAtom); const setCallEmbedStartError = useSetAtom(callEmbedStartErrorAtom); const callEmbedRef = useCallEmbedRef(); + const store = useStore(); + const discovery = useAutoDiscoveryInfo(); + const [newCallsEnabled] = useSetting(settingsAtom, 'newCallsEnabled'); + const livekitJsCallManager = useLivekitJsCallManager(); const startCall = useCallback( (room: Room, pref?: CallPreferences) => { + if (newCallsEnabled) { + if (!livekitJsCallManager) { + throw new Error('LiveKit JS call manager is not provided!'); + } + // Resolved rather than cached in state so the first tap cannot race the + // native capability probe and fall through to the JS backend. + void getNativeCallAvailability(true).then((nativeCallAvailable) => { + if (selectCallStartOwner({ newCallsEnabled, nativeCallAvailable }) === 'livekit-mobile') { + getNativeCallManager(store).start({ + mx, + room, + discovery, + dm, + video: pref?.video, + microphone: pref?.microphone, + }); + return; + } + livekitJsCallManager.start({ + room, + dm, + video: pref?.video, + microphone: pref?.microphone, + sound: pref?.sound, + audioDeviceId: pref?.audioDeviceId, + videoDeviceId: pref?.videoDeviceId, + }); + }); + return; + } + const ownerLease = acquireCallOwner('element', room.roomId); + if (!ownerLease) { + debugLog.warn('call', 'Failed to start call: another call is already active', { + roomId: room.roomId, + }); + return; + } const container = callEmbedRef.current; if (!container) { - debugLog.error('call', 'Failed to start call — no embed container', { + ownerLease.release(); + debugLog.error('call', 'Failed to start call: no embed container', { roomId: room.roomId, }); Sentry.metrics.count('sable.call.start.error', 1, { @@ -96,6 +146,7 @@ export const useCallStart = (dm = false) => { ); setCallEmbed(callEmbed); } catch (err) { + ownerLease.release(); debugLog.error('call', 'Call embed creation failed', { roomId: room.roomId, error: err instanceof Error ? err.message : String(err), @@ -106,7 +157,19 @@ export const useCallStart = (dm = false) => { throw err; } }, - [mx, dm, theme, setCallEmbed, callEmbedRef, clientConfig.elementCallUrl, setCallEmbedStartError] + [ + mx, + dm, + theme, + setCallEmbed, + callEmbedRef, + store, + discovery, + clientConfig.elementCallUrl, + setCallEmbedStartError, + newCallsEnabled, + livekitJsCallManager, + ] ); return startCall; diff --git a/src/app/hooks/useLivekitSupport.test.tsx b/src/app/hooks/useLivekitSupport.test.tsx new file mode 100644 index 000000000..7328144ef --- /dev/null +++ b/src/app/hooks/useLivekitSupport.test.tsx @@ -0,0 +1,77 @@ +import type { ReactNode } from 'react'; +import { render, waitFor } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import type { MatrixClient } from '$types/matrix-sdk'; +import type { AutoDiscoveryInfo } from '../cs-api'; +import { MatrixClientProvider } from './useMatrixClient'; +import { AutoDiscoveryInfoProvider } from './useAutoDiscoveryInfo'; +import { useLivekitSupport } from './useLivekitSupport'; + +const wellKnownWithFocus = { + 'org.matrix.msc4143.rtc_foci': [{ type: 'livekit', livekit_service_url: 'https://sfu.example' }], +} as unknown as AutoDiscoveryInfo; + +const wellKnownWithoutFocus = {} as unknown as AutoDiscoveryInfo; + +const makeClient = (getTransports: () => Promise): MatrixClient => + ({ _unstable_getRTCTransports: getTransports }) as unknown as MatrixClient; + +const renderSupport = ( + mx: MatrixClient, + autoDiscoveryInfo: AutoDiscoveryInfo +): (() => boolean | undefined) => { + let latest: boolean | undefined; + function Probe() { + latest = useLivekitSupport(); + return null; + } + const wrapper = ({ children }: { children?: ReactNode }) => ( + + {children} + + ); + render(, { wrapper }); + return () => latest; +}; + +describe('useLivekitSupport', () => { + it('accepts a transport advertised by the homeserver', async () => { + const mx = makeClient(() => + Promise.resolve([{ type: 'livekit', livekit_service_url: 'https://sfu.example' }]) + ); + const latest = renderSupport(mx, wellKnownWithoutFocus); + + await waitFor(() => expect(latest()).toBe(true)); + }); + + it('falls back to the legacy well-known focus', async () => { + const mx = makeClient(() => Promise.reject(new Error('M_NOT_FOUND'))); + const latest = renderSupport(mx, wellKnownWithFocus); + + await waitFor(() => expect(latest()).toBe(true)); + }); + + it('reports no support when neither source advertises a livekit transport', async () => { + const mx = makeClient(() => Promise.resolve([{ type: 'not-livekit' }])); + const latest = renderSupport(mx, wellKnownWithoutFocus); + + await waitFor(() => expect(latest()).toBe(false)); + }); + + it('does not report unavailable while the homeserver probe is in flight', () => { + const mx = makeClient(() => new Promise(() => {})); + const latest = renderSupport(mx, wellKnownWithFocus); + + expect(latest()).toBe(true); + }); + + it('probes the homeserver once per client', async () => { + const getTransports = vi.fn<() => Promise>().mockResolvedValue([]); + const mx = { _unstable_getRTCTransports: getTransports } as unknown as MatrixClient; + + renderSupport(mx, wellKnownWithoutFocus); + renderSupport(mx, wellKnownWithoutFocus); + + await waitFor(() => expect(getTransports).toHaveBeenCalledOnce()); + }); +}); diff --git a/src/app/hooks/useLivekitSupport.ts b/src/app/hooks/useLivekitSupport.ts index 6c87d4374..5299cefb7 100644 --- a/src/app/hooks/useLivekitSupport.ts +++ b/src/app/hooks/useLivekitSupport.ts @@ -1,6 +1,16 @@ +import { useEffect, useState } from 'react'; import type { AutoDiscoveryInfo } from '../cs-api'; import { useAutoDiscoveryInfo } from './useAutoDiscoveryInfo'; +import { useMatrixClient } from './useMatrixClient'; +import { isLivekitTransportConfig } from '@sableclient/matrixrtc'; +import type { MatrixClient } from '$types/matrix-sdk'; +import { createDebugLogger } from '$utils/debugLogger'; +const debugLog = createDebugLogger('useLivekitSupport'); + +type TransportClient = Pick; + +/** Legacy discovery: the pre-MSC4143 `.well-known` advertisement. */ export const livekitSupport = (autoDiscoveryInfo: AutoDiscoveryInfo): boolean => { const rtcFoci = autoDiscoveryInfo['org.matrix.msc4143.rtc_foci']; @@ -9,8 +19,44 @@ export const livekitSupport = (autoDiscoveryInfo: AutoDiscoveryInfo): boolean => ); }; +// `/rtc/transports` is a network round trip and every room view asks. One probe +// per client is enough; the answer only changes when the homeserver does. +const probes = new WeakMap>(); + +export const probeServerLivekitTransport = (mx: TransportClient): Promise => { + const existing = probes.get(mx); + if (existing) return existing; + + const probe = mx['_unstable_getRTCTransports']() + .then((transports) => transports.some(isLivekitTransportConfig)) + .catch((error: unknown) => { + // A homeserver without MSC4143 answers M_NOT_FOUND, which is a legitimate + // "no transport here" rather than an outage, so it stays at info level. + debugLog.info('call', 'homeserver advertised no RTC transports', error); + return false; + }); + probes.set(mx, probe); + return probe; +}; + export const useLivekitSupport = (): boolean => { + const mx = useMatrixClient(); const autoDiscoveryInfo = useAutoDiscoveryInfo(); + const wellKnownSupported = livekitSupport(autoDiscoveryInfo); + const [serverSupported, setServerSupported] = useState(undefined); + + useEffect(() => { + let active = true; + void probeServerLivekitTransport(mx).then((supported) => { + if (active) setServerSupported(supported); + }); + return () => { + active = false; + }; + }, [mx]); - return livekitSupport(autoDiscoveryInfo); + // The server-advertised transport is the MSC4143 answer and `.well-known` is + // the legacy fallback, so either one is enough. While the probe is in flight + // we report the legacy answer rather than a premature "unavailable". + return serverSupported === true || wellKnownSupported; }; diff --git a/src/app/pages/CallStatusRenderer.test.tsx b/src/app/pages/CallStatusRenderer.test.tsx new file mode 100644 index 000000000..3093d642c --- /dev/null +++ b/src/app/pages/CallStatusRenderer.test.tsx @@ -0,0 +1,151 @@ +import { render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createStore, Provider } from 'jotai'; +import type { ReactNode } from 'react'; +import { livekitJsCallAtom, type LivekitJsCallSession } from '../state/livekitJsCall'; +import { nativeCallAtom, type NativeCallSession } from '../state/nativeCall'; +import { CallStatusRenderer } from './CallStatusRenderer'; + +const mocks = vi.hoisted(() => ({ + callEmbed: undefined as { roomId: string } | undefined, + selectedRoom: undefined as string | undefined, + screenSize: 'Desktop', +})); + +vi.mock('../hooks/useCallEmbed', () => ({ useCallEmbed: () => mocks.callEmbed })); +vi.mock('../hooks/router/useSelectedRoom', () => ({ + useSelectedRoom: () => mocks.selectedRoom, +})); +vi.mock('../hooks/useScreenSize', () => ({ + ScreenSize: { Mobile: 'Mobile', Desktop: 'Desktop' }, + useScreenSizeContext: () => mocks.screenSize, +})); +vi.mock('../features/call-status', () => ({ + CallStatus: () =>
, +})); +vi.mock('../features/call-status/LivekitCallStatus', () => ({ + LivekitCallStatus: () =>
, +})); +vi.mock('../features/call-status/NativeCallStatus', () => ({ + NativeCallStatus: () =>
, +})); + +const session = (lifecycle: LivekitJsCallSession['lifecycle']): LivekitJsCallSession => ({ + roomId: '!room:example.org', + lifecycle, + failure: null, + mediaReady: true, + initialMedia: { microphone: true, camera: false, sound: true }, + hangup: async () => undefined, +}); + +const renderWith = (children: ReactNode, store = createStore()) => + render({children}); + +beforeEach(() => { + mocks.callEmbed = undefined; + mocks.selectedRoom = undefined; + mocks.screenSize = 'Desktop'; +}); + +describe('CallStatusRenderer', () => { + it('shows the status bar during a LiveKit call', () => { + const store = createStore(); + store.set(livekitJsCallAtom, session('active')); + + renderWith(, store); + + expect(screen.getByTestId('livekit-call-status')).toBeInTheDocument(); + }); + + it('keeps showing it while the call is still connecting', () => { + const store = createStore(); + store.set(livekitJsCallAtom, session('joining-matrix')); + + renderWith(, store); + + expect(screen.getByTestId('livekit-call-status')).toBeInTheDocument(); + }); + + it('hides it once the call has failed', () => { + const store = createStore(); + store.set(livekitJsCallAtom, session('failed')); + + renderWith(, store); + + expect(screen.queryByTestId('livekit-call-status')).not.toBeInTheDocument(); + }); + + it('hides it on mobile while the call room is open, as the embed path does', () => { + mocks.screenSize = 'Mobile'; + mocks.selectedRoom = '!room:example.org'; + const store = createStore(); + store.set(livekitJsCallAtom, session('active')); + + renderWith(, store); + + expect(screen.queryByTestId('livekit-call-status')).not.toBeInTheDocument(); + }); + + it('prefers the Element Call bar when an embed is active', () => { + mocks.callEmbed = { roomId: '!other:example.org' }; + const store = createStore(); + store.set(livekitJsCallAtom, session('active')); + + renderWith(, store); + + expect(screen.getByTestId('element-call-status')).toBeInTheDocument(); + expect(screen.queryByTestId('livekit-call-status')).not.toBeInTheDocument(); + }); + + it('shows the status bar during a native call', () => { + const store = createStore(); + store.set(nativeCallAtom, { + backend: 'livekit-mobile', + roomId: '!room:example.org', + callId: 'call-id', + lifecycle: 'connected', + participants: [], + microphoneEnabled: true, + cameraEnabled: false, + setMicrophoneEnabled: async () => {}, + setCameraEnabled: async () => {}, + switchCamera: async () => {}, + listAudioRoutes: async () => [], + selectAudioRoute: async () => {}, + hangup: async () => undefined, + } satisfies NativeCallSession); + + renderWith(, store); + + expect(screen.getByTestId('native-call-status')).toBeInTheDocument(); + }); + + it('hides the native bar once the call has failed', () => { + const store = createStore(); + store.set(nativeCallAtom, { + backend: 'livekit-mobile', + roomId: '!room:example.org', + callId: 'call-id', + lifecycle: 'error', + participants: [], + microphoneEnabled: false, + cameraEnabled: false, + setMicrophoneEnabled: async () => {}, + setCameraEnabled: async () => {}, + switchCamera: async () => {}, + listAudioRoutes: async () => [], + selectAudioRoute: async () => {}, + hangup: async () => undefined, + } satisfies NativeCallSession); + + renderWith(, store); + + expect(screen.queryByTestId('native-call-status')).not.toBeInTheDocument(); + }); + + it('renders nothing with no call at all', () => { + const { container } = renderWith(); + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/src/app/pages/CallStatusRenderer.tsx b/src/app/pages/CallStatusRenderer.tsx index d0fb4732e..dbd76bde7 100644 --- a/src/app/pages/CallStatusRenderer.tsx +++ b/src/app/pages/CallStatusRenderer.tsx @@ -1,17 +1,36 @@ +import { useAtomValue } from 'jotai'; import { useCallEmbed } from '../hooks/useCallEmbed'; import { CallStatus } from '../features/call-status'; +import { LivekitCallStatus } from '../features/call-status/LivekitCallStatus'; +import { NativeCallStatus } from '../features/call-status/NativeCallStatus'; +import { isLivekitJsCallActive, livekitJsCallAtom } from '../state/livekitJsCall'; +import { isNativeCallActive, nativeCallAtom } from '../state/nativeCall'; import { useSelectedRoom } from '../hooks/router/useSelectedRoom'; import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize'; export function CallStatusRenderer() { const callEmbed = useCallEmbed(); + const livekitJsCall = useAtomValue(livekitJsCallAtom); + const nativeCall = useAtomValue(nativeCallAtom); const selectedRoom = useSelectedRoom(); const screenSize = useScreenSizeContext(); + const mobileInCallRoom = screenSize === ScreenSize.Mobile; - if (!callEmbed) return null; + if (callEmbed) { + if (mobileInCallRoom && callEmbed.roomId === selectedRoom) return null; + return ; + } - if (screenSize === ScreenSize.Mobile && callEmbed.roomId === selectedRoom) return null; + if (isLivekitJsCallActive(livekitJsCall) && livekitJsCall) { + if (mobileInCallRoom && livekitJsCall.roomId === selectedRoom) return null; + return ; + } - return ; + if (isNativeCallActive(nativeCall) && nativeCall) { + if (mobileInCallRoom && nativeCall.roomId === selectedRoom) return null; + return ; + } + + return null; } diff --git a/src/app/state/callEmbed.ts b/src/app/state/callEmbed.ts index 3dcc6c745..6a8112454 100644 --- a/src/app/state/callEmbed.ts +++ b/src/app/state/callEmbed.ts @@ -2,6 +2,7 @@ import { atom } from 'jotai'; import * as Sentry from '@sentry/react'; import type { CallEmbed } from '../plugins/call'; import type { CallEmbedStartError } from '$plugins/call/callEmbedError'; +import { releaseCallOwner } from './callOwner'; const baseCallEmbedAtom = atom(undefined); const baseCallEmbedStartErrorAtom = atom(null); @@ -25,6 +26,7 @@ export const callEmbedAtom = atom resetCallOwnerForTests()); + +describe('call owner guard', () => { + it('allows one owner and rejects competing owners until release', () => { + const element = acquireCallOwner('element', '!room:example.org'); + + expect(element).toBeDefined(); + expect(acquireCallOwner('livekit-js', '!room:example.org')).toBeUndefined(); + expect(acquireCallOwner('livekit-mobile', '!room:example.org')).toBeUndefined(); + expect(getActiveCallOwner()).toMatchObject({ kind: 'element' }); + + element?.release(); + expect(acquireCallOwner('livekit-js', '!room:example.org')).toBeDefined(); + }); + + it('blocks competing owners while the native transport owns the call', () => { + const native = acquireCallOwner('livekit-mobile', '!room:example.org'); + + expect(native).toBeDefined(); + expect(acquireCallOwner('element', '!room:example.org')).toBeUndefined(); + expect(acquireCallOwner('livekit-js', '!room:example.org')).toBeUndefined(); + expect(getActiveCallOwner()).toMatchObject({ kind: 'livekit-mobile' }); + + native?.release(); + expect(acquireCallOwner('element', '!room:example.org')).toBeDefined(); + }); + + it('makes release idempotent and cannot release a replacement lease', () => { + const first = acquireCallOwner('element', '!room:example.org'); + first?.release(); + const second = acquireCallOwner('livekit-js', '!room:example.org'); + + first?.release(); + expect(getActiveCallOwner()).toMatchObject({ kind: 'livekit-js' }); + second?.release(); + expect(getActiveCallOwner()).toBeUndefined(); + }); +}); diff --git a/src/app/state/callOwner.ts b/src/app/state/callOwner.ts new file mode 100644 index 000000000..a6c95fac7 --- /dev/null +++ b/src/app/state/callOwner.ts @@ -0,0 +1,40 @@ +export type CallOwnerKind = 'element' | 'livekit-js' | 'livekit-mobile'; + +export type CallOwnerLease = { + kind: CallOwnerKind; + roomId: string; + release: () => void; +}; + +let activeOwner: CallOwnerLease | undefined; + +export const acquireCallOwner = ( + kind: CallOwnerKind, + roomId: string +): CallOwnerLease | undefined => { + if (activeOwner) return undefined; + + let released = false; + const lease: CallOwnerLease = { + kind, + roomId, + release: () => { + if (released || activeOwner !== lease) return; + released = true; + activeOwner = undefined; + }, + }; + activeOwner = lease; + return lease; +}; + +export const getActiveCallOwner = (): Pick | undefined => + activeOwner; + +export const releaseCallOwner = (kind: CallOwnerKind, roomId: string): void => { + if (activeOwner?.kind === kind && activeOwner.roomId === roomId) activeOwner.release(); +}; + +export const resetCallOwnerForTests = (): void => { + activeOwner = undefined; +}; diff --git a/src/app/state/callPreferences.ts b/src/app/state/callPreferences.ts index db452f0b5..3c082b2f0 100644 --- a/src/app/state/callPreferences.ts +++ b/src/app/state/callPreferences.ts @@ -9,6 +9,8 @@ export type CallPreferences = { microphone: boolean; video: boolean; sound: boolean; + audioDeviceId?: string; + videoDeviceId?: string; }; const CALL_PREFERENCES = 'callPreferences'; diff --git a/src/app/state/hooks/callPreferences.ts b/src/app/state/hooks/callPreferences.ts index 3bc5f20ce..5350fa06a 100644 --- a/src/app/state/hooks/callPreferences.ts +++ b/src/app/state/hooks/callPreferences.ts @@ -18,6 +18,8 @@ export const useCallPreferences = (): CallPreferences & { toggleMicrophone: () => void; toggleVideo: () => void; toggleSound: () => void; + setAudioDeviceId: (deviceId: string) => void; + setVideoDeviceId: (deviceId: string) => void; setPreferences: (prefs: CallPreferences) => void; } => { const callPrefAtom = useCallPreferencesAtom(); @@ -27,8 +29,8 @@ export const useCallPreferences = (): CallPreferences & { const microphone = !pref.microphone; setPref({ + ...pref, microphone, - video: pref.video, sound: !pref.sound && microphone ? true : pref.sound, }); }, [setPref, pref]); @@ -36,25 +38,33 @@ export const useCallPreferences = (): CallPreferences & { const toggleVideo = useCallback(() => { const video = !pref.video; - setPref({ - microphone: pref.microphone, - video, - sound: pref.sound, - }); + setPref({ ...pref, video }); }, [setPref, pref]); const toggleSound = useCallback(() => { const sound = !pref.sound; setPref({ + ...pref, microphone: !sound ? false : pref.microphone, - video: pref.video, sound, }); }, [setPref, pref]); + const setAudioDeviceId = useCallback( + (audioDeviceId: string) => setPref({ ...pref, audioDeviceId }), + [setPref, pref] + ); + + const setVideoDeviceId = useCallback( + (videoDeviceId: string) => setPref({ ...pref, videoDeviceId }), + [setPref, pref] + ); + return { ...pref, + setAudioDeviceId, + setVideoDeviceId, toggleMicrophone, toggleVideo, toggleSound, diff --git a/src/app/state/livekitJsCall.test.ts b/src/app/state/livekitJsCall.test.ts new file mode 100644 index 000000000..15bccead9 --- /dev/null +++ b/src/app/state/livekitJsCall.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; +import { selectActiveCallSession } from './livekitJsCall'; +import { acquireCallOwner, getActiveCallOwner, resetCallOwnerForTests } from './callOwner'; + +describe('selectActiveCallSession', () => { + it('selects the JS owner when Element Call is absent', () => { + const livekitSession = { + roomId: '!room:example.org', + lifecycle: 'active' as const, + failure: null, + mediaReady: true, + initialMedia: { microphone: true, camera: false, sound: true }, + hangup: async () => undefined, + }; + + expect(selectActiveCallSession(undefined, livekitSession)).toBe(livekitSession); + }); + + it('preserves Element Call precedence over the JS owner', () => { + const element = { roomId: '!element:example.org' }; + const livekit = { + roomId: '!livekit:example.org', + lifecycle: 'active' as const, + failure: null, + mediaReady: true, + initialMedia: { microphone: true, camera: false, sound: true }, + hangup: async () => undefined, + }; + + expect(selectActiveCallSession(element, livekit)).toBe(element); + }); + + it('routes past a failed JS session to Element Call and releases its lease', async () => { + resetCallOwnerForTests(); + const lease = acquireCallOwner('element', '!room:example.org'); + const element = { + roomId: '!room:example.org', + hangup: async () => lease?.release(), + }; + const livekit = { + roomId: '!room:example.org', + lifecycle: 'failed' as const, + failure: 'setup-failed' as const, + mediaReady: false, + initialMedia: { microphone: true, camera: false, sound: true }, + hangup: async () => undefined, + }; + + await selectActiveCallSession(element, livekit)?.hangup(); + + expect(getActiveCallOwner()).toBeUndefined(); + }); +}); diff --git a/src/app/state/livekitJsCall.ts b/src/app/state/livekitJsCall.ts new file mode 100644 index 000000000..cb262bf8c --- /dev/null +++ b/src/app/state/livekitJsCall.ts @@ -0,0 +1,52 @@ +import { atom } from 'jotai'; +import type { + LivekitJsControllerFailure, + LivekitJsControllerLifecycle, +} from '@sableclient/matrixrtc'; +import type { Room as LivekitRoom } from 'livekit-client'; + +export type LivekitJsCallMedia = { + microphone: boolean; + camera: boolean; + sound: boolean; + audioDeviceId?: string; + videoDeviceId?: string; +}; + +export type LivekitJsCallSession = { + roomId: string; + /** What the user chose on the prescreen; applied once media is ready. */ + initialMedia: LivekitJsCallMedia; + lifecycle: LivekitJsControllerLifecycle; + failure: LivekitJsControllerFailure | null; + room?: LivekitRoom; + /** Immediate for an unencrypted room, after the Matrix key for an encrypted one. */ + mediaReady: boolean; + hangup: () => Promise; +}; + +export const livekitJsCallAtom = atom(undefined); + +export const livekitJsCallSoundAtom = atom(true); + +/** + * Whether `initialMedia` has been published for the current call. The surface + * unmounts when the user navigates out of the room while the call keeps + * running, so this has to outlive it or coming back re-applies the prescreen + * choice and undoes anything toggled from the call bar. + */ +export const livekitJsCallInitialMediaAppliedAtom = atom(false); + +export const isLivekitJsCallActive = (session: LivekitJsCallSession | undefined): boolean => + session?.lifecycle !== undefined && + session.lifecycle !== 'idle' && + session.lifecycle !== 'failed'; + +export const selectActiveCallSession = ( + elementCall: Element | undefined, + livekitJsCall: LivekitJsCallSession | undefined +): Element | LivekitJsCallSession | undefined => { + if (elementCall) return elementCall; + if (isLivekitJsCallActive(livekitJsCall)) return livekitJsCall; + return undefined; +}; diff --git a/src/app/state/nativeCall.test.ts b/src/app/state/nativeCall.test.ts new file mode 100644 index 000000000..01df3b881 --- /dev/null +++ b/src/app/state/nativeCall.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; +import { + isNativeCallActive, + selectActiveCallSessionIncludingNative, + type NativeCallSession, +} from './nativeCall'; +import type { LivekitJsCallSession } from './livekitJsCall'; + +const makeNativeSession = (lifecycle: NativeCallSession['lifecycle']): NativeCallSession => ({ + backend: 'livekit-mobile', + roomId: '!room:example.org', + callId: 'call-id', + lifecycle, + participants: [], + microphoneEnabled: true, + cameraEnabled: false, + setMicrophoneEnabled: async () => {}, + setCameraEnabled: async () => {}, + switchCamera: async () => {}, + listAudioRoutes: async () => [], + selectAudioRoute: async () => {}, + hangup: async () => undefined, +}); + +const livekitSession: LivekitJsCallSession = { + roomId: '!room:example.org', + lifecycle: 'active', + failure: null, + mediaReady: true, + initialMedia: { microphone: true, camera: false, sound: true }, + hangup: async () => undefined, +}; + +describe('isNativeCallActive', () => { + it('treats missing or errored sessions as inactive', () => { + expect(isNativeCallActive(undefined)).toBe(false); + expect(isNativeCallActive(makeNativeSession('error'))).toBe(false); + expect(isNativeCallActive(makeNativeSession('connected'))).toBe(true); + }); +}); + +describe('selectActiveCallSessionIncludingNative', () => { + it('prefers Element Call and LiveKit JS over the native session', () => { + const element = { roomId: '!room:example.org' }; + const native = makeNativeSession('connected'); + + expect(selectActiveCallSessionIncludingNative(element, livekitSession, native)).toBe(element); + expect(selectActiveCallSessionIncludingNative(undefined, livekitSession, native)).toBe( + livekitSession + ); + }); + + it('returns the native session only when nothing else is active', () => { + const native = makeNativeSession('connected'); + expect(selectActiveCallSessionIncludingNative(undefined, undefined, native)).toBe(native); + expect( + selectActiveCallSessionIncludingNative(undefined, undefined, makeNativeSession('error')) + ).toBeUndefined(); + expect(selectActiveCallSessionIncludingNative(undefined, undefined, undefined)).toBeUndefined(); + }); +}); diff --git a/src/app/state/nativeCall.ts b/src/app/state/nativeCall.ts new file mode 100644 index 000000000..5b3e22cbb --- /dev/null +++ b/src/app/state/nativeCall.ts @@ -0,0 +1,64 @@ +import { atom } from 'jotai'; +import type { NativeCallAudioRoute } from '@sableclient/tauri-plugin-livekit-mobile'; +import type { CallParticipant } from '@sableclient/matrixrtc'; +import { + isLivekitJsCallActive, + livekitJsCallAtom, + selectActiveCallSession, + type LivekitJsCallSession, +} from './livekitJsCall'; +import { callEmbedAtom } from './callEmbed'; + +export type NativeCallBackend = 'livekit-mobile'; + +export type NativeCallLifecycle = + | 'starting' + | 'connecting' + | 'connected' + | 'reconnecting' + | 'error'; + +export type NativeCallSession = { + backend: NativeCallBackend; + roomId: string; + callId: string; + lifecycle: NativeCallLifecycle; + error?: string; + /** Remote peers only, as the transport reports them. */ + participants: CallParticipant[]; + microphoneEnabled: boolean; + cameraEnabled: boolean; + setMicrophoneEnabled: (enabled: boolean) => Promise; + setCameraEnabled: (enabled: boolean) => Promise; + switchCamera: () => Promise; + listAudioRoutes: () => Promise; + selectAudioRoute: (routeId: string) => Promise; + hangup: () => Promise; +}; + +export const nativeCallAtom = atom(undefined); + +export const isNativeCallActive = (session: NativeCallSession | undefined): boolean => + session?.lifecycle !== undefined && session.lifecycle !== 'error'; + +/** + * Whether any engine already owns a call. Only one may run at a time: a second + * start would lose the call-owner lease and publish a failure of its own while + * the first call carries on. + */ +export const callInProgressAtom = atom( + (get) => + get(callEmbedAtom) !== undefined || + isLivekitJsCallActive(get(livekitJsCallAtom)) || + isNativeCallActive(get(nativeCallAtom)) +); + +export const selectActiveCallSessionIncludingNative = ( + elementCall: Element | undefined, + livekitJsCall: LivekitJsCallSession | undefined, + nativeCall: NativeCallSession | undefined +): Element | LivekitJsCallSession | NativeCallSession | undefined => { + const selected = selectActiveCallSession(elementCall, livekitJsCall); + if (selected) return selected; + return isNativeCallActive(nativeCall) ? nativeCall : undefined; +}; diff --git a/src/app/state/settings.defaults.test.ts b/src/app/state/settings.defaults.test.ts index 2b798084d..c2d84bf66 100644 --- a/src/app/state/settings.defaults.test.ts +++ b/src/app/state/settings.defaults.test.ts @@ -12,6 +12,46 @@ beforeEach(() => { }); describe('mergePersistedSettings', () => { + it('defaults new calls off and persists the opt-in', () => { + expect(defaultSettings.newCallsEnabled).toBe(false); + + localStorage.setItem('settings', JSON.stringify({ newCallsEnabled: true })); + expect(mergePersistedSettings(localStorage.getItem('settings'), {}).newCallsEnabled).toBe(true); + }); + + it('enables new calls when either legacy experimental setting was on', () => { + localStorage.setItem('settings', JSON.stringify({ livekitJsCallsEnabled: true })); + const merged = mergePersistedSettings(localStorage.getItem('settings'), {}); + expect(merged.newCallsEnabled).toBe(true); + expect(merged).not.toHaveProperty('livekitJsCallsEnabled'); + + localStorage.setItem('settings', JSON.stringify({ livekitJsMediaTestEnabled: true })); + const mergedMedia = mergePersistedSettings(localStorage.getItem('settings'), {}); + expect(mergedMedia.newCallsEnabled).toBe(true); + expect(mergedMedia).not.toHaveProperty('livekitJsMediaTestEnabled'); + }); + + it('keeps new calls off when both legacy settings were off or absent', () => { + localStorage.setItem( + 'settings', + JSON.stringify({ livekitJsCallsEnabled: false, livekitJsMediaTestEnabled: false }) + ); + const merged = mergePersistedSettings(localStorage.getItem('settings'), {}); + expect(merged.newCallsEnabled).toBe(false); + expect(merged).not.toHaveProperty('livekitJsCallsEnabled'); + expect(merged).not.toHaveProperty('livekitJsMediaTestEnabled'); + }); + + it('does not override an explicit new calls preference with legacy keys', () => { + localStorage.setItem( + 'settings', + JSON.stringify({ newCallsEnabled: false, livekitJsCallsEnabled: true }) + ); + const merged = mergePersistedSettings(localStorage.getItem('settings'), {}); + expect(merged.newCallsEnabled).toBe(false); + expect(merged).not.toHaveProperty('livekitJsCallsEnabled'); + }); + it('layers deployer defaults over code defaults when localStorage is empty', () => { const merged = mergePersistedSettings(null, { twitterEmoji: false }); expect(merged.twitterEmoji).toBe(false); @@ -88,6 +128,19 @@ describe('sanitizeSettingsDefaults', () => { }); }); + it('accepts the new calls setting', () => { + expect(sanitizeSettingsDefaults({ newCallsEnabled: true })).toEqual({ + newCallsEnabled: true, + }); + expect(sanitizeSettingsDefaults({ newCallsEnabled: 'yes' })).toEqual({}); + }); + + it('drops the legacy LiveKit JS experimental settings', () => { + expect( + sanitizeSettingsDefaults({ livekitJsCallsEnabled: true, livekitJsMediaTestEnabled: true }) + ).toEqual({}); + }); + it('drops unknown keys', () => { expect(sanitizeSettingsDefaults({ notARealSetting: true, hour24Clock: true })).toEqual({ hour24Clock: true, diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index a296dbfe4..0255d84f2 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -180,6 +180,7 @@ export interface Settings { developerTools: boolean; enableMSC4268CMD: boolean; enableMediaGalleries: boolean; + newCallsEnabled: boolean; settingsSyncEnabled: boolean; // Cosmetics! @@ -339,6 +340,7 @@ export const defaultSettings: Settings = { enableMSC4268CMD: false, enableMediaGalleries: false, + newCallsEnabled: false, // Push notifications (SW/Sygnal): default on for mobile, opt-in on desktop. // In-app pill banner: default on for mobile (primary foreground alert), opt-in on desktop. @@ -524,6 +526,16 @@ function migrateParsedLocalStorage(parsed: Record): void { delete parsed.themeChatPreviewAnyUrl; delete parsed.themeChatPreviewApprovedCatalogOnly; + // Consolidate the legacy LiveKit JS experiments into the single new-call gate. + if ( + typeof parsed.newCallsEnabled !== 'boolean' && + (parsed.livekitJsCallsEnabled === true || parsed.livekitJsMediaTestEnabled === true) + ) { + parsed.newCallsEnabled = true; + } + delete parsed.livekitJsCallsEnabled; + delete parsed.livekitJsMediaTestEnabled; + if (typeof parsed.callRingtoneVolume === 'number' && Number.isFinite(parsed.callRingtoneVolume)) { parsed.callRingtoneVolume = clampPercent(parsed.callRingtoneVolume); } diff --git a/src/client/slidingSync.test.ts b/src/client/slidingSync.test.ts index 3324f4b00..463cc431b 100644 --- a/src/client/slidingSync.test.ts +++ b/src/client/slidingSync.test.ts @@ -500,6 +500,39 @@ describe('SlidingSyncManager initial request', () => { }); describe('SlidingSyncManager room subscription coordination', () => { + it('uses the full-membership subscription for the lifetime of a call', () => { + const manager = makeManager(makeMockMx()); + const roomId = '!call:example.com'; + + const subscription = manager.subscribeToCallRoom(roomId); + + expect(mocks.slidingSyncInstance.useCustomSubscription).toHaveBeenLastCalledWith( + roomId, + 'call_room' + ); + expect(mocks.slidingSyncInstance.modifyRoomSubscriptions).toHaveBeenLastCalledWith( + new Set([roomId]) + ); + + subscription(); + + expect(mocks.slidingSyncInstance.modifyRoomSubscriptions).toHaveBeenLastCalledWith(new Set()); + }); + + it('restores an active room subscription after a call ends', () => { + const manager = makeManager(makeMockMx()); + const roomId = '!call:example.com'; + manager.subscribeToRoom(roomId); + + const subscription = manager.subscribeToCallRoom(roomId); + subscription(); + + expect(mocks.slidingSyncInstance.useCustomSubscription).toHaveBeenLastCalledWith( + roomId, + 'active_room' + ); + }); + it('replaces route subscriptions atomically without cycling the retained space', () => { const manager = makeManager(makeMockMx()); const spaceId = '!space:example.com'; @@ -701,12 +734,17 @@ describe('SlidingSyncManager room subscription coordination', () => { { timeline_limit: number; required_state: [string, string][] }, ][]; const activeRoom = calls.find(([name]) => name === 'active_room'); + const callRoom = calls.find(([name]) => name === 'call_room'); const sidebarRoom = calls.find(([name]) => name === 'sidebar_room'); expect(activeRoom).toBeDefined(); expect(activeRoom![1].timeline_limit).toBe(50); expect(activeRoom![1].required_state).toContainEqual([EventType.RoomMember, '$LAZY']); + expect(callRoom).toBeDefined(); + expect(callRoom![1].timeline_limit).toBe(50); + expect(callRoom![1].required_state).toContainEqual([EventType.RoomMember, '*']); + expect(sidebarRoom).toBeDefined(); expect(sidebarRoom![1].timeline_limit).toBe(1); }); diff --git a/src/client/slidingSync.ts b/src/client/slidingSync.ts index 3eec08431..dfdebce01 100644 --- a/src/client/slidingSync.ts +++ b/src/client/slidingSync.ts @@ -43,6 +43,7 @@ const DEFAULT_POLL_TIMEOUT_MS = 45000; const LIST_SORT_ORDER = ['by_recency', 'by_name']; const ACTIVE_ROOM_SUBSCRIPTION_KEY = 'active_room'; +const CALL_ROOM_SUBSCRIPTION_KEY = 'call_room'; const SIDEBAR_ROOM_SUBSCRIPTION_KEY = 'sidebar_room'; const SPACE_SUBSCRIPTION_KEY = 'space'; const IMAGE_PACK_SUBSCRIPTION_KEY = 'image_packs'; @@ -88,6 +89,8 @@ export type SlidingSyncDiagnostics = { export type HydrationProgress = { loadedRooms: number; totalRooms: number }; +export type CallRoomSubscription = () => void; + const clampPositive = (value: number | undefined, fallback: number): number => { if (typeof value !== 'number' || Number.isNaN(value) || value <= 0) return fallback; return Math.round(value); @@ -209,6 +212,13 @@ const buildUnencryptedSubscription = (timelineLimit: number): MSC3575RoomSubscri required_state: ACTIVE_ROOM_REQUIRED_STATE, }); +const buildCallRoomSubscription = (timelineLimit: number): MSC3575RoomSubscription => ({ + timeline_limit: timelineLimit, + // MatrixRTC ignores memberships for users that are absent from the room + // roster. Unlike a timeline, calls need every membership continuously. + required_state: [...ACTIVE_ROOM_REQUIRED_STATE, [EventType.RoomMember, MSC3575_WILDCARD]], +}); + const IMAGE_PACK_REQUIRED_STATE: MSC3575RoomSubscription['required_state'] = [ [CustomStateEvent.ImagePack, MSC3575_WILDCARD], [CustomStateEvent.PoniesRoomEmotes, MSC3575_WILDCARD], @@ -302,6 +312,8 @@ export class SlidingSyncManager { private readonly activeRoomSubscriptions = new Set(); + private readonly callRoomSubscriptions = new Set(); + /** * Rooms joined locally via reconcileRoomMembership(Join) but not yet confirmed * joined by a sliding-sync response. The SDK reverts these to "invite" when the @@ -445,6 +457,10 @@ export class SlidingSyncManager { ACTIVE_ROOM_SUBSCRIPTION_KEY, buildUnencryptedSubscription(roomTimelineLimit) ); + this.slidingSync.addCustomSubscription( + CALL_ROOM_SUBSCRIPTION_KEY, + buildCallRoomSubscription(roomTimelineLimit) + ); this.slidingSync.addCustomSubscription( SIDEBAR_ROOM_SUBSCRIPTION_KEY, buildSidebarRoomSubscription() @@ -1309,6 +1325,7 @@ export class SlidingSyncManager { private syncRoomSubscriptions(): void { const desiredSubscriptions = new Set([ + ...this.callRoomSubscriptions, ...this.activeRoomSubscriptions, ...this.sidebarRoomSubscriptions, ...this.spaceSubscriptions, @@ -1316,7 +1333,9 @@ export class SlidingSyncManager { ]); desiredSubscriptions.forEach((roomId) => { - if (this.activeRoomSubscriptions.has(roomId)) { + if (this.callRoomSubscriptions.has(roomId)) { + this.slidingSync.useCustomSubscription(roomId, CALL_ROOM_SUBSCRIPTION_KEY); + } else if (this.activeRoomSubscriptions.has(roomId)) { this.slidingSync.useCustomSubscription(roomId, ACTIVE_ROOM_SUBSCRIPTION_KEY); } else if (this.sidebarRoomSubscriptions.has(roomId)) { this.slidingSync.useCustomSubscription(roomId, SIDEBAR_ROOM_SUBSCRIPTION_KEY); @@ -1517,6 +1536,19 @@ export class SlidingSyncManager { this.reportActiveSubscriptionCount(); } + public subscribeToCallRoom(roomId: string): CallRoomSubscription { + if (this.disposed) return () => undefined; + + this.callRoomSubscriptions.add(roomId); + this.syncRoomSubscriptions(); + return () => this.unsubscribeFromCallRoom(roomId); + } + + public unsubscribeFromCallRoom(roomId: string): void { + if (this.disposed || !this.callRoomSubscriptions.delete(roomId)) return; + this.syncRoomSubscriptions(); + } + public unsubscribeFromRoom(roomId: string): void { if (this.disposed || !this.removeActiveRoomSubscription(roomId)) return; this.syncRoomSubscriptions(); diff --git a/src/sw/pushNotification.ts b/src/sw/pushNotification.ts index fdc6a8e7f..66a3995e3 100644 --- a/src/sw/pushNotification.ts +++ b/src/sw/pushNotification.ts @@ -1,7 +1,7 @@ /* oxlint-disable no-console */ // Keep the service worker import graph narrow, the app barrel pulls in runtime Matrix SDK modules that break SW script evaluation import { EventType } from 'matrix-js-sdk/lib/@types/event'; -import { normalizeCallIntent } from '../app/features/call/callIntent'; +import { normalizeCallIntent } from '@sableclient/matrixrtc/callIntent'; import { buildRoomMessageNotification, DEFAULT_NOTIFICATION_ICON, diff --git a/src/types/matrix-sdk.ts b/src/types/matrix-sdk.ts index f1bea4e09..7c2ec0653 100644 --- a/src/types/matrix-sdk.ts +++ b/src/types/matrix-sdk.ts @@ -55,5 +55,8 @@ export * from 'matrix-js-sdk/lib/common-crypto/CryptoBackend'; export * from 'matrix-js-sdk/lib/matrixrtc/CallMembership'; export * from 'matrix-js-sdk/lib/matrixrtc/MatrixRTCSession'; export * from 'matrix-js-sdk/lib/matrixrtc/MatrixRTCSessionManager'; +export { type CallMembershipIdentityParts } from 'matrix-js-sdk/lib/matrixrtc/EncryptionManager'; +export { type LivekitTransportConfig } from 'matrix-js-sdk/lib/matrixrtc/LivekitTransport'; +export { type Transport } from 'matrix-js-sdk/lib/matrixrtc/types'; export * from 'matrix-js-sdk/lib/models/thread'; diff --git a/vitest.config.ts b/vitest.config.ts index 6dbada7b9..454105487 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -43,7 +43,9 @@ export default defineConfig({ include: ['src/**/*.{test,spec}.{ts,tsx}'], server: { deps: { - inline: [/matrix-js-sdk\/lib\//], + // The package is ESM that imports matrix-js-sdk deep paths, so Vite has + // to process it for the SDK alias above to apply. + inline: [/matrix-js-sdk\/lib\//, /@sableclient\/matrixrtc/], }, }, coverage: {