diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 908981c52..bf494dcda 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -8,7 +8,7 @@ Please see LICENSE in the repository root for full details. `; module.exports = { - plugins: ["matrix-org", "rxjs", "jsdoc"], + plugins: ["matrix-org", "rxjs", "jsdoc", "element-call"], extends: [ "plugin:matrix-org/react", "plugin:matrix-org/a11y", @@ -27,6 +27,7 @@ module.exports = { node: true, }, rules: { + "element-call/no-observablescope-leak": "error", "jsdoc/no-types": "error", "jsdoc/empty-tags": "error", "jsdoc/check-property-names": "error", @@ -92,6 +93,9 @@ module.exports = { "**/test-**", ], rules: { + // Tests often initialize an ObservableScope in an outer scope in + // beforeEach, which is not actually a problem + "element-call/no-observablescope-leak": "off", "jsdoc/no-types": "off", "jsdoc/empty-tags": "off", "jsdoc/check-property-names": "off", diff --git a/eslint/NoObservableScopeLeak.js b/eslint/NoObservableScopeLeak.js new file mode 100644 index 000000000..f11bc256b --- /dev/null +++ b/eslint/NoObservableScopeLeak.js @@ -0,0 +1,62 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { ESLintUtils } from "@typescript-eslint/utils"; + +// These ObservableScope methods will not generally cause resource leaks even if +// called from a callback +const safeScopeMethods = ["bind", "end"]; + +const rule = ESLintUtils.RuleCreator( + () => "https://github.com/element-hq/element-call", +)({ + name: "no-observablescope-leak", + meta: { + type: "problem", + docs: { + description: + "Require referenced ObservableScopes to be defined in the very same scope to avoid resource leaks.", + }, + messages: { + scopeLeak: + "Do not reference ObservableScopes defined in an outer scope; this may create resource leaks.", + }, + schema: [], + }, + create(context) { + return { + Identifier(node) { + const scope = context.sourceCode.getScope(node); + if ( + // Is this a reference to a variable defined in an outer ("through") scope? + scope.through.some( + ({ identifier }) => identifier.name === node.name, + ) && + // Exclude calls to "safe" ObservableScope methods + node.parent?.type === "MemberExpression" && + node.parent.object === node && + node.parent.property.type === "Identifier" && + !safeScopeMethods.includes(node.parent.property.name) + ) { + // Verify that the variable is actually of type ObservableScope + // (expensive, so we check this last) + const services = ESLintUtils.getParserServices(context); + const type = services.getTypeAtLocation(node); + if (type.symbol?.name === "ObservableScope") + // This ObservableScope method call may be causing resource leaks. + context.report({ + messageId: "scopeLeak", + loc: node.loc, + node, + }); + } + }, + }; + }, +}); + +export default rule; diff --git a/eslint/index.js b/eslint/index.js new file mode 100644 index 000000000..26bd7f3e8 --- /dev/null +++ b/eslint/index.js @@ -0,0 +1,5 @@ +module.exports = { + rules: { + "no-observablescope-leak": require("./NoObservableScopeLeak").default, + }, +}; diff --git a/eslint/package.json b/eslint/package.json new file mode 100644 index 000000000..88c222308 --- /dev/null +++ b/eslint/package.json @@ -0,0 +1,4 @@ +{ + "name": "eslint-plugin-element-call", + "version": "0.0.0" +} diff --git a/knip.ts b/knip.ts index c07f8256f..e2b38f29d 100644 --- a/knip.ts +++ b/knip.ts @@ -11,7 +11,7 @@ export default { vite: { config: ["vite.config.ts", "vite-embedded.config.ts", "vite-sdk.config.ts"], }, - entry: ["src/main.tsx", "i18next.config.ts"], + entry: ["src/main.tsx", "eslint/index.js", "i18next.config.ts"], ignoreBinaries: [ // This is deprecated, so Knip doesn't actually recognize it as a globally // installed binary. TODO We should switch to Compose v2: diff --git a/package.json b/package.json index ffb5b7ce3..a005d47e6 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.0.0", "@testing-library/user-event": "^14.5.1", + "@types/eslint": "^9.6.1", "@types/grecaptcha": "^3.0.9", "@types/jsdom": "^21.1.7", "@types/lodash-es": "^4.17.12", @@ -76,6 +77,7 @@ "@types/sdp-transform": "^2.4.5", "@typescript-eslint/eslint-plugin": "^8.31.0", "@typescript-eslint/parser": "^8.31.0", + "@typescript-eslint/utils": "^8.61.0", "@use-gesture/react": "^10.2.11", "@vector-im/compound-design-tokens": "^10.0.0", "@vector-im/compound-web": "^9.3.0", @@ -89,6 +91,7 @@ "eslint-config-google": "^0.14.0", "eslint-config-prettier": "^10.0.0", "eslint-plugin-deprecate": "^0.9.0", + "eslint-plugin-element-call": "link:eslint", "eslint-plugin-import": "^2.26.0", "eslint-plugin-jsdoc": "^61.5.0", "eslint-plugin-jsx-a11y": "^6.5.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a38cbf6ec..bf88d952b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,14 +7,14 @@ settings: overrides: '@livekit/components-core>rxjs': ^7.8.1 '@livekit/track-processors>@mediapipe/tasks-vision': ^0.10.18 - minimatch: ^10.2.3 tar: ^7.5.11 + minimatch: ^10.2.3 glob: ^10.5.0 qs: ^6.14.1 js-yaml: ^4.1.1 esbuild: ^0.28.0 - flatted: ^3.4.2 undici: ^6.24.0 + flatted: ^3.4.2 importers: @@ -95,6 +95,9 @@ importers: '@testing-library/user-event': specifier: ^14.5.1 version: 14.6.1(@testing-library/dom@10.4.1) + '@types/eslint': + specifier: ^9.6.1 + version: 9.6.1 '@types/grecaptcha': specifier: ^3.0.9 version: 3.0.9 @@ -128,6 +131,9 @@ importers: '@typescript-eslint/parser': specifier: ^8.31.0 version: 8.60.0(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/utils': + specifier: ^8.61.0 + version: 8.61.0(eslint@8.57.1)(typescript@5.9.3) '@use-gesture/react': specifier: ^10.2.11 version: 10.3.1(react@19.2.6) @@ -167,6 +173,9 @@ importers: eslint-plugin-deprecate: specifier: ^0.9.0 version: 0.9.0(eslint@8.57.1) + eslint-plugin-element-call: + specifier: link:eslint + version: link:eslint eslint-plugin-import: specifier: ^2.26.0 version: 2.32.0(@typescript-eslint/parser@8.60.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1) @@ -3322,6 +3331,9 @@ packages: '@types/dom-webcodecs@0.1.18': resolution: {integrity: sha512-vAvE8C9DGWR+tkb19xyjk1TSUlJ7RUzzp4a9Anu7mwBT+fpyePWK1UxmH14tMO5zHmrnrRIMg5NutnnDztLxgg==} + '@types/eslint@9.6.1': + resolution: {integrity: sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} @@ -3417,20 +3429,14 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/project-service@8.58.2': - resolution: {integrity: sha512-Cq6UfpZZk15+r87BkIh5rDpi38W4b+Sjnb8wQCPPDDweS/LRCFjCyViEbzHk5Ck3f2QDfgmlxqSa7S7clDtlfg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/project-service@8.60.0': resolution: {integrity: sha512-aZu74NNKJeUWqCjDddzdiKaS82dgYgV/vmf+Ui3ZdZejmgfXR/q+pRumgobnQ2cCJTgGTWp4ypiwsuofFubavg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/project-service@8.60.1': - resolution: {integrity: sha512-eXkTH2bxmXlqD1RnOPmLZ9ZM9D3VwSx04JOwBnP9RQ+yUA5a2Mu7SfW8uaV2Aon53NJzZlZYuX7tn91Izf+xaw==} + '@typescript-eslint/project-service@8.61.0': + resolution: {integrity: sha512-DV42F7MLJO6Rax7SK1yg43tcnEfGUrurSpSxKuVX+a3RCTzBlH3fuxprrOJXKCJGAaw82xXocikJ0uQaqwXgGA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' @@ -3445,36 +3451,26 @@ packages: resolution: {integrity: sha512-VXuvVvZeQCQb5Zgf4HAxc04q5j+WrNAtNh9OwCsCgpKqESMTu3tF/jhZ3xG6T4NZwWl65Bg8KuS2uEvhSfLl0w==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - '@typescript-eslint/scope-manager@8.58.2': - resolution: {integrity: sha512-SgmyvDPexWETQek+qzZnrG6844IaO02UVyOLhI4wpo82dpZJY9+6YZCKAMFzXb7qhx37mFK1QcPQ18tud+vo6Q==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/scope-manager@8.60.0': resolution: {integrity: sha512-pFzqhllJMs+jghLQWzV00ds39xLzuyqPSev5pd8f4Ir0rtKR3ZLUB4/4dhjOFighWb9larvtfJvqL+4yKDI3Xw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/scope-manager@8.60.1': - resolution: {integrity: sha512-gvI5OQoptnxQnchOirukCuQ55svJSTuD/4k5+pC267xyBtYry748R9/c3tYUzb/iE6RZfllRz2lVulLCHkTm4w==} + '@typescript-eslint/scope-manager@8.61.0': + resolution: {integrity: sha512-IWdXFHFSb6mlC3HPc7QsLDm5zYEbUla6trDEHf32D3/dnuUyXd87plScSNXSbm0/RxMvObpI17sv/EDTGrGZkA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@typescript-eslint/scope-manager@8.61.1': resolution: {integrity: sha512-L2bdIeoQS8FlKAvONAr20w6OcLXeB+qiDKbAooS9A0Ben+iSIkBef0FxqwKWYqt5sa0i4KJtxVyVmhMylKzF5w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/tsconfig-utils@8.58.2': - resolution: {integrity: sha512-3SR+RukipDvkkKp/d0jP0dyzuls3DbGmwDpVEc5wqk5f38KFThakqAAO0XMirWAE+kT00oTauTbzMFGPoAzB0A==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/tsconfig-utils@8.60.0': resolution: {integrity: sha512-BZPR3RGYlAXnly6ymAxfkVn5rCbZzQNou0rxv3GfWZ8cTQp+hhVd73khbGLAd8k1TlAPLISH337M+tAgAnaJDQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/tsconfig-utils@8.60.1': - resolution: {integrity: sha512-nh8w4qAteiKuZu3pSSzG/yGKpw0OlkrKnzFmbVRenKaD4qc+7i1GrmZaLVkr8rk4uipiPGMOW4YsM6WmKZ5CvA==} + '@typescript-eslint/tsconfig-utils@8.61.0': + resolution: {integrity: sha512-O5Amvdv9ztMpxpf+vmFULGG78IE6Qwdr3bCGvqwG4nwc9H2qXkOYJJnRbRHyMkQTjv1d03olqwwwzHLMqpFePQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' @@ -3496,10 +3492,6 @@ packages: resolution: {integrity: sha512-87NVngcbVXUahrRTqIK27gD2t5Cu1yuCXxbLcFtCzZGlfyVWWh8mLHkoxzjsB6DDNnvdL+fW8MiwPEJyGJQDgQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - '@typescript-eslint/types@8.58.2': - resolution: {integrity: sha512-9TukXyATBQf/Jq9AMQXfvurk+G5R2MwfqQGDR2GzGz28HvY/lXNKGhkY+6IOubwcquikWk5cjlgPvD2uAA7htQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/types@8.60.0': resolution: {integrity: sha512-AsE7x2XaAK+CVbeih0Fvbn+r1qHxtpLDJ3XUuFcIinT318T90yHMJC+Zgv+jUuDjQQd06HKwxnDu6sz1IcTilA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -3508,6 +3500,10 @@ packages: resolution: {integrity: sha512-4h0tY8ppCkdCzcrl2YM5M3my0xsE1Tf8om3owEu5oPWmXwkKRmk0j0LGDzYBGUcAlesEbxBhazqu/K4cu3Ug7w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/types@8.61.0': + resolution: {integrity: sha512-9QTQpZ5Iin4CdIodfbDQFSeiSJKidgYJYug1P9CC2xWgUTvlmixViqDZNciMjwLBZyJnG4tGmPl97rVAFb1AJg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/types@8.61.1': resolution: {integrity: sha512-G+CRlPqLv7Bz1IZVs03x5K59F1veqL0EJUROAdGhKsEq8qOiRiZbI+HUojPq5l0fEGOKModD9br6lObhB8zkoA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -3521,20 +3517,14 @@ packages: typescript: optional: true - '@typescript-eslint/typescript-estree@8.58.2': - resolution: {integrity: sha512-ELGuoofuhhoCvNbQjFFiobFcGgcDCEm0ThWdmO4Z0UzLqPXS3KFvnEZ+SHewwOYHjM09tkzOWXNTv9u6Gqtyuw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/typescript-estree@8.60.0': resolution: {integrity: sha512-3AcZNBGMClm6CXDyo8kYvVGT/sx29sS0oBsIb9oZI2gunA4Vm2M3YHzRLPvsUBBsl+yB5FPtltq7gGH0iTlp9g==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/typescript-estree@8.60.1': - resolution: {integrity: sha512-alpRkfG8hlVE5kdJW2GkfgDgXxold3e8e4l6EnmhRmRLbekgAPCCGDVD++sABy9FcgPFroq+uFcCSM1vR57Cew==} + '@typescript-eslint/typescript-estree@8.61.0': + resolution: {integrity: sha512-42zatd5qSvvcV1JdDBCLxYRznvP4eIHpPoZXdkPFnAmanA4FuZ5dibSnCBggY8hQnqajPpoGjXFdZ7fIJKQnlA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' @@ -3551,13 +3541,6 @@ packages: peerDependencies: eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 - '@typescript-eslint/utils@8.58.2': - resolution: {integrity: sha512-QZfjHNEzPY8+l0+fIXMvuQ2sJlplB4zgDZvA+NmvZsZv3EQwOcc1DuIU1VJUTWZ/RKouBMhDyNaBMx4sWvrzRA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/utils@8.60.0': resolution: {integrity: sha512-HtXuPfrHTyBDkameWpl+vJb1Uevu2tznAyahM1Oc4AENidCLTPiZDWIo4GfcxNdC/RcfGcadzzkqbRG87dUrQA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -3565,8 +3548,8 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/utils@8.60.1': - resolution: {integrity: sha512-h2MPBLoNtjc3qZWfY3Tl51yPorQ2McHn8pJfcMNTcIvrrZrr90Ykffit0yjrPFWQcRcUxzH20+6OcVdW4yHtUg==} + '@typescript-eslint/utils@8.61.0': + resolution: {integrity: sha512-3bzFt7ImFMW/jVYwJamDoe/dMOdFLSC6pom6rRjdh4SZJEYupyMzem8e7vKZLclLfpHjlwSAXOUxtKxGXUiLqA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 @@ -3583,16 +3566,12 @@ packages: resolution: {integrity: sha512-07ny+LHRzQXepkGg6w0mFY41fVUNBrL2Roj/++7V1txKugfjm/Ci/qSND03r2RhlJhJYMcTn9AhhSSqQp0Ysyw==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - '@typescript-eslint/visitor-keys@8.58.2': - resolution: {integrity: sha512-f1WO2Lx8a9t8DARmcWAUPJbu0G20bJlj8L4z72K00TMeJAoyLr/tHhI/pzYBLrR4dXWkcxO1cWYZEOX8DKHTqA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/visitor-keys@8.60.0': resolution: {integrity: sha512-9WI52t8ZGLVGrPMBet25yAftqY/n95+zmoUUtJBBQTKDSKUu7OsPTroT2op7U9JatkoRccL0YkWDNMFfC4Sjxg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/visitor-keys@8.60.1': - resolution: {integrity: sha512-EbGRQg4FhrmwLodl+t3JNAnXHWVr9Vp+Zl1QBZVPY4ByfkzIT8cX3K6QWODHtkIZqqJVEWvhHSx3v5PDHsaQag==} + '@typescript-eslint/visitor-keys@8.61.0': + resolution: {integrity: sha512-QVLZu3ZPQEE+HICQyAMZ2yLQhxf0meY/wx6Hx14YcTNj13JB3qHlX3lJ02L3fLGHgERRH71kvYDwiXIguT3AjQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@typescript-eslint/visitor-keys@8.61.1': @@ -5524,7 +5503,7 @@ packages: resolution: {integrity: sha512-1QEOsXO+bhyCroIe2/A5OwaxHvBm7EsSQ46DEDn8RBIfQwN5HWBpFvyWWR4QY0KHPPnnJdI99wgRiAl7Ad5qaA==} matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/8c95727b6278fe7942c20d0b9485f984dd0694b7: - resolution: {tarball: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/8c95727b6278fe7942c20d0b9485f984dd0694b7} + resolution: {gitHosted: true, tarball: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/8c95727b6278fe7942c20d0b9485f984dd0694b7} version: 41.7.0 engines: {node: '>=22.0.0'} @@ -9641,7 +9620,7 @@ snapshots: '@stylistic/eslint-plugin@3.1.0(eslint@8.57.1)(typescript@5.9.3)': dependencies: - '@typescript-eslint/utils': 8.58.2(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/utils': 8.61.0(eslint@8.57.1)(typescript@5.9.3) eslint: 8.57.1 eslint-visitor-keys: 4.2.1 espree: 10.4.0 @@ -9860,6 +9839,11 @@ snapshots: '@types/dom-webcodecs@0.1.18': {} + '@types/eslint@9.6.1': + dependencies: + '@types/estree': 1.0.9 + '@types/json-schema': 7.0.15 + '@types/estree@1.0.8': {} '@types/estree@1.0.9': {} @@ -9961,28 +9945,19 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.58.2(typescript@5.9.3)': - dependencies: - '@typescript-eslint/tsconfig-utils': 8.58.2(typescript@5.9.3) - '@typescript-eslint/types': 8.58.2 - debug: 4.4.3 - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - '@typescript-eslint/project-service@8.60.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.60.0(typescript@5.9.3) - '@typescript-eslint/types': 8.60.0 + '@typescript-eslint/tsconfig-utils': 8.61.1(typescript@5.9.3) + '@typescript-eslint/types': 8.61.1 debug: 4.4.3 typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.60.1(typescript@5.9.3)': + '@typescript-eslint/project-service@8.61.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.60.1(typescript@5.9.3) - '@typescript-eslint/types': 8.60.1 + '@typescript-eslint/tsconfig-utils': 8.61.1(typescript@5.9.3) + '@typescript-eslint/types': 8.61.1 debug: 4.4.3 typescript: 5.9.3 transitivePeerDependencies: @@ -10002,35 +9977,26 @@ snapshots: '@typescript-eslint/types': 5.62.0 '@typescript-eslint/visitor-keys': 5.62.0 - '@typescript-eslint/scope-manager@8.58.2': - dependencies: - '@typescript-eslint/types': 8.58.2 - '@typescript-eslint/visitor-keys': 8.58.2 - '@typescript-eslint/scope-manager@8.60.0': dependencies: '@typescript-eslint/types': 8.60.0 '@typescript-eslint/visitor-keys': 8.60.0 - '@typescript-eslint/scope-manager@8.60.1': + '@typescript-eslint/scope-manager@8.61.0': dependencies: - '@typescript-eslint/types': 8.60.1 - '@typescript-eslint/visitor-keys': 8.60.1 + '@typescript-eslint/types': 8.61.0 + '@typescript-eslint/visitor-keys': 8.61.0 '@typescript-eslint/scope-manager@8.61.1': dependencies: '@typescript-eslint/types': 8.61.1 '@typescript-eslint/visitor-keys': 8.61.1 - '@typescript-eslint/tsconfig-utils@8.58.2(typescript@5.9.3)': - dependencies: - typescript: 5.9.3 - '@typescript-eslint/tsconfig-utils@8.60.0(typescript@5.9.3)': dependencies: typescript: 5.9.3 - '@typescript-eslint/tsconfig-utils@8.60.1(typescript@5.9.3)': + '@typescript-eslint/tsconfig-utils@8.61.0(typescript@5.9.3)': dependencies: typescript: 5.9.3 @@ -10052,12 +10018,12 @@ snapshots: '@typescript-eslint/types@5.62.0': {} - '@typescript-eslint/types@8.58.2': {} - '@typescript-eslint/types@8.60.0': {} '@typescript-eslint/types@8.60.1': {} + '@typescript-eslint/types@8.61.0': {} + '@typescript-eslint/types@8.61.1': {} '@typescript-eslint/typescript-estree@5.62.0(typescript@5.9.3)': @@ -10074,21 +10040,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/typescript-estree@8.58.2(typescript@5.9.3)': - dependencies: - '@typescript-eslint/project-service': 8.58.2(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.58.2(typescript@5.9.3) - '@typescript-eslint/types': 8.58.2 - '@typescript-eslint/visitor-keys': 8.58.2 - debug: 4.4.3 - minimatch: 10.2.5 - semver: 7.8.1 - tinyglobby: 0.2.17 - ts-api-utils: 2.5.0(typescript@5.9.3) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - '@typescript-eslint/typescript-estree@8.60.0(typescript@5.9.3)': dependencies: '@typescript-eslint/project-service': 8.60.0(typescript@5.9.3) @@ -10104,15 +10055,15 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/typescript-estree@8.60.1(typescript@5.9.3)': + '@typescript-eslint/typescript-estree@8.61.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/project-service': 8.60.1(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.60.1(typescript@5.9.3) - '@typescript-eslint/types': 8.60.1 - '@typescript-eslint/visitor-keys': 8.60.1 + '@typescript-eslint/project-service': 8.61.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.61.0(typescript@5.9.3) + '@typescript-eslint/types': 8.61.0 + '@typescript-eslint/visitor-keys': 8.61.0 debug: 4.4.3 minimatch: 10.2.5 - semver: 7.8.1 + semver: 7.8.4 tinyglobby: 0.2.17 ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 @@ -10149,17 +10100,6 @@ snapshots: - supports-color - typescript - '@typescript-eslint/utils@8.58.2(eslint@8.57.1)(typescript@5.9.3)': - dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@8.57.1) - '@typescript-eslint/scope-manager': 8.58.2 - '@typescript-eslint/types': 8.58.2 - '@typescript-eslint/typescript-estree': 8.58.2(typescript@5.9.3) - eslint: 8.57.1 - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - '@typescript-eslint/utils@8.60.0(eslint@8.57.1)(typescript@5.9.3)': dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@8.57.1) @@ -10171,12 +10111,12 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.60.1(eslint@8.57.1)(typescript@5.9.3)': + '@typescript-eslint/utils@8.61.0(eslint@8.57.1)(typescript@5.9.3)': dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@8.57.1) - '@typescript-eslint/scope-manager': 8.60.1 - '@typescript-eslint/types': 8.60.1 - '@typescript-eslint/typescript-estree': 8.60.1(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.61.0 + '@typescript-eslint/types': 8.61.0 + '@typescript-eslint/typescript-estree': 8.61.0(typescript@5.9.3) eslint: 8.57.1 typescript: 5.9.3 transitivePeerDependencies: @@ -10198,19 +10138,14 @@ snapshots: '@typescript-eslint/types': 5.62.0 eslint-visitor-keys: 3.4.3 - '@typescript-eslint/visitor-keys@8.58.2': - dependencies: - '@typescript-eslint/types': 8.58.2 - eslint-visitor-keys: 5.0.1 - '@typescript-eslint/visitor-keys@8.60.0': dependencies: '@typescript-eslint/types': 8.60.0 eslint-visitor-keys: 5.0.1 - '@typescript-eslint/visitor-keys@8.60.1': + '@typescript-eslint/visitor-keys@8.61.0': dependencies: - '@typescript-eslint/types': 8.60.1 + '@typescript-eslint/types': 8.61.0 eslint-visitor-keys: 5.0.1 '@typescript-eslint/visitor-keys@8.61.1': @@ -11406,7 +11341,7 @@ snapshots: eslint-plugin-storybook@10.4.1(eslint@8.57.1)(storybook@10.4.1(@testing-library/dom@10.4.1)(@types/react@19.2.15)(prettier@3.8.3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(typescript@5.9.3): dependencies: - '@typescript-eslint/utils': 8.60.1(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/utils': 8.61.0(eslint@8.57.1)(typescript@5.9.3) eslint: 8.57.1 storybook: 10.4.1(@testing-library/dom@10.4.1)(@types/react@19.2.15)(prettier@3.8.3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) transitivePeerDependencies: diff --git a/sdk/main.ts b/sdk/main.ts index 286c16ea2..b94a7eb51 100644 --- a/sdk/main.ts +++ b/sdk/main.ts @@ -76,9 +76,9 @@ interface MatrixRTCSdk { stop: () => void; data$: Observable<{ rtcBackendIdentity: string; data: string }>; /** - * flattened list of members + * flattened list of remote members */ - members$: Behavior< + remoteMembers$: Behavior< { connection: Connection | null; membership: CallMembership; @@ -86,7 +86,7 @@ interface MatrixRTCSdk { }[] >; /** - * flattened local members + * flattened local member */ localMember$: Behavior<{ connection: Connection | null; @@ -338,8 +338,8 @@ export async function createMatrixRTCSdk( ), ), connected$: callViewModel.connected$, - members$: scope.behavior( - callViewModel.matrixLivekitMembers$.pipe( + remoteMembers$: scope.behavior( + callViewModel.remoteMatrixLivekitMembers$.pipe( switchMap((members) => { const listOfMemberObservables = members.map((member) => combineLatest([ diff --git a/src/state/CallViewModel/CallNotificationLifecycle.ts b/src/state/CallViewModel/CallNotificationLifecycle.ts index 2100bde3e..4d7007b9e 100644 --- a/src/state/CallViewModel/CallNotificationLifecycle.ts +++ b/src/state/CallViewModel/CallNotificationLifecycle.ts @@ -161,6 +161,10 @@ export function createCallNotificationLifecycle$({ recipient, outcome$: race(timeout$, accept$, decline$).pipe( take(1), + // Make this observable 'hot' to avoid running multiple timers. This + // is not actually a resource leak since there will be at most one + // active ring attempt at any given time. + // eslint-disable-next-line element-call/no-observablescope-leak scope.share, ), }); diff --git a/src/state/CallViewModel/CallViewModel.ts b/src/state/CallViewModel/CallViewModel.ts index 2df759168..661d30790 100644 --- a/src/state/CallViewModel/CallViewModel.ts +++ b/src/state/CallViewModel/CallViewModel.ts @@ -125,10 +125,9 @@ import { createConnectionManager$, } from "./remoteMembers/ConnectionManager.ts"; import { - createMatrixLivekitMembers$, + createRemoteMatrixLivekitMembers$, type LocalMatrixLivekitMember, type RemoteMatrixLivekitMember, - type MatrixLivekitMember, } from "./remoteMembers/MatrixLivekitMembers.ts"; import { type AutoLeaveReason, @@ -300,7 +299,7 @@ export interface CallViewModel { /** Participants sorted by livekit room so they can be used in the audio rendering */ livekitRoomItems$: Behavior; /** use the layout instead, this is just for the sdk export. */ - matrixLivekitMembers$: Behavior; + remoteMatrixLivekitMembers$: Behavior; localMatrixLivekitMember$: Behavior; /** List of participants raising their hand */ handsRaised$: Behavior>; @@ -527,13 +526,15 @@ export function createCallViewModel$( ownMembershipIdentity, }); - const matrixLivekitMembers$: Behavior> = - createMatrixLivekitMembers$({ - scope: scope, - membershipsWithTransport$: - membershipsAndTransports.membershipsWithTransport$, - connectionManager: connectionManager, - }); + const remoteMatrixLivekitMembers$: Behavior< + Epoch + > = createRemoteMatrixLivekitMembers$({ + scope: scope, + membershipsWithTransport$: + membershipsAndTransports.membershipsWithTransport$, + connectionManager: connectionManager, + localUser: { userId, deviceId }, + }); const connectOptions$ = scope.behavior( matrixRTCMode$.pipe( @@ -610,6 +611,13 @@ export function createCallViewModel$( ), ); + const matrixLivekitMembers$ = scope.behavior( + combineLatest( + [localMatrixLivekitMember$, remoteMatrixLivekitMembers$], + (local, remote) => [...(local === null ? [] : [local]), ...remote.value], + ), + ); + // ------------------------------------------------------------------------ // matrixMemberMetadataStore @@ -639,7 +647,7 @@ export function createCallViewModel$( connectionManager.connectionManagerData$.pipe(map((d) => d.value)), ); const livekitRoomItems$ = scope.behavior( - matrixLivekitMembers$.pipe( + remoteMatrixLivekitMembers$.pipe( switchMap((members) => { const a$ = combineLatest( members.value.map((member) => @@ -705,43 +713,20 @@ export function createCallViewModel$( * List of user media (camera feeds) that we want tiles for. */ const userMedia$ = scope.behavior( - combineLatest([ - localMatrixLivekitMember$, - matrixLivekitMembers$, - duplicateTiles.value$, - ]).pipe( + combineLatest([matrixLivekitMembers$, duplicateTiles.value$]).pipe( // Generate a collection of user media from the list of expected (whether // present or missing) LiveKit participants. generateItems( "CallViewModel userMedia$", - function* ([ - localMatrixLivekitMember, - matrixLivekitMembers, - duplicateTiles, - ]) { - const computeMediaId = (m: MatrixLivekitMember): string => - `${m.userId}:${m.membership$.value.deviceId}`; - - const localUserMediaId = localMatrixLivekitMember - ? computeMediaId(localMatrixLivekitMember) - : undefined; - - const localAsArray = localMatrixLivekitMember - ? [localMatrixLivekitMember] - : []; - const remoteWithoutLocal = matrixLivekitMembers.value.filter( - (m) => computeMediaId(m) !== localUserMediaId, - ); - const allMatrixLivekitMembers = [ - ...localAsArray, - ...remoteWithoutLocal, - ]; - - for (const matrixLivekitMember of allMatrixLivekitMembers) { - const { userId, participant, connection$, membership$ } = - matrixLivekitMember; - const rtcId = membership$.value.rtcBackendIdentity; // rtcBackendIdentity - const mediaId = computeMediaId(matrixLivekitMember); + function* ([members, duplicateTiles]) { + for (const { + userId, + participant, + connection$, + membership$, + } of members) { + const rtcId = membership$.value.rtcBackendIdentity; + const mediaId = `${userId}:${membership$.value.deviceId}`; for (let dup = 0; dup < 1 + duplicateTiles; dup++) { yield { keys: [dup, mediaId, userId, participant, connection$, rtcId], @@ -859,7 +844,7 @@ export function createCallViewModel$( * multiple devices. */ const participantCount$ = scope.behavior( - matrixLivekitMembers$.pipe(map((ms) => ms.value.length)), + matrixLivekitMembers$.pipe(map((ms) => ms.length)), ); const leaveSoundEffect$ = userMedia$.pipe( @@ -1760,8 +1745,8 @@ export function createCallViewModel$( setGridMode: setGridMode, layout$: layout$, localMatrixLivekitMember$, - matrixLivekitMembers$: scope.behavior( - matrixLivekitMembers$.pipe( + remoteMatrixLivekitMembers$: scope.behavior( + remoteMatrixLivekitMembers$.pipe( map((members) => members.value), tap((v) => { const listForLogs = v diff --git a/src/state/CallViewModel/remoteMembers/MatrixLivekitMembers.test.ts b/src/state/CallViewModel/remoteMembers/MatrixLivekitMembers.test.ts index 5d34f7be1..244d70ae8 100644 --- a/src/state/CallViewModel/remoteMembers/MatrixLivekitMembers.test.ts +++ b/src/state/CallViewModel/remoteMembers/MatrixLivekitMembers.test.ts @@ -15,7 +15,7 @@ import { BehaviorSubject, combineLatest, map, type Observable } from "rxjs"; import { type IConnectionManager } from "./ConnectionManager.ts"; import { type RemoteMatrixLivekitMember, - createMatrixLivekitMembers$, + createRemoteMatrixLivekitMembers$, } from "./MatrixLivekitMembers.ts"; import { Epoch, @@ -31,6 +31,7 @@ import { } from "../../../utils/test.ts"; import { type Connection } from "./Connection.ts"; import { constant } from "../../Behavior.ts"; +import { localRtcMember } from "../../../utils/test-fixtures.ts"; let testScope: ObservableScope; @@ -88,16 +89,17 @@ test("should signal participant not yet connected to livekit", async () => { mockConnectionManagerData$, ); - const matrixLivekitMember$ = createMatrixLivekitMembers$({ + const remoteMatrixLivekitMembers$ = createRemoteMatrixLivekitMembers$({ scope: testScope, membershipsWithTransport$: testScope.behavior(membershipsWithTransport$), connectionManager: { connectionManagerData$: connectionManagerData$, } as unknown as IConnectionManager, + localUser: localRtcMember, }); await flushPromises(); - expect(matrixLivekitMember$.value.value).toSatisfy( + expect(remoteMatrixLivekitMembers$.value.value).toSatisfy( (data: RemoteMatrixLivekitMember[]) => { expect(data.length).toEqual(1); expect(data[0].membership$.value).toBe(bobMembership); @@ -157,16 +159,17 @@ test("should signal participant on a connection that is publishing", async () => constant(dataWithPublisher), ); - const matrixLivekitMember$ = createMatrixLivekitMembers$({ + const remoteMatrixLivekitMembers$ = createRemoteMatrixLivekitMembers$({ scope: testScope, membershipsWithTransport$: testScope.behavior(membershipsWithTransport$), connectionManager: { connectionManagerData$: connectionManagerData$, } as unknown as IConnectionManager, + localUser: localRtcMember, }); await flushPromises(); - expect(matrixLivekitMember$.value.value).toSatisfy( + expect(remoteMatrixLivekitMembers$.value.value).toSatisfy( (data: RemoteMatrixLivekitMember[]) => { expect(data.length).toEqual(1); expect(data[0].membership$.value).toBe(bobMembership); @@ -197,15 +200,16 @@ test("should signal participant on a connection that is not publishing", async ( constant(dataWithPublisher), ); - const matrixLivekitMember$ = createMatrixLivekitMembers$({ + const remoteMatrixLivekitMembers$ = createRemoteMatrixLivekitMembers$({ scope: testScope, membershipsWithTransport$: testScope.behavior(membershipsWithTransport$), connectionManager: { connectionManagerData$: connectionManagerData$, } as unknown as IConnectionManager, + localUser: localRtcMember, }); await flushPromises(); - expect(matrixLivekitMember$.value.value).toSatisfy( + expect(remoteMatrixLivekitMembers$.value.value).toSatisfy( (data: RemoteMatrixLivekitMember[]) => { expect(data.length).toEqual(1); expect(data[0].membership$.value).toBe(bobMembership); @@ -245,15 +249,16 @@ describe("Publication edge case", () => { constant(connectionWithPublisher), ); - const matrixLivekitMembers$ = createMatrixLivekitMembers$({ + const remoteMatrixLivekitMembers$ = createRemoteMatrixLivekitMembers$({ scope: testScope, membershipsWithTransport$: testScope.behavior(membershipsWithTransport$), connectionManager: { connectionManagerData$: connectionManagerData$, } as unknown as IConnectionManager, + localUser: localRtcMember, }); await flushPromises(); - expect(matrixLivekitMembers$.value.value).toSatisfy( + expect(remoteMatrixLivekitMembers$.value.value).toSatisfy( (data: RemoteMatrixLivekitMember[]) => { expect(data.length).toEqual(2); expect(data[0].membership$.value).toBe(bobMembership); @@ -303,16 +308,17 @@ test("bob is publishing in the wrong connection", async () => { connectionsWithPublisher$, ); - const matrixLivekitMember$ = createMatrixLivekitMembers$({ + const remoteMatrixLivekitMembers$ = createRemoteMatrixLivekitMembers$({ scope: testScope, membershipsWithTransport$: testScope.behavior(membershipsWithTransport$), connectionManager: { connectionManagerData$: connectionManagerData$, } as unknown as IConnectionManager, + localUser: localRtcMember, }); await flushPromises(); - expect(matrixLivekitMember$.value.value).toSatisfy( + expect(remoteMatrixLivekitMembers$.value.value).toSatisfy( (data: RemoteMatrixLivekitMember[]) => { expect(data.length).toEqual(2); expect(data[0].membership$.value).toBe(bobMembership); diff --git a/src/state/CallViewModel/remoteMembers/MatrixLivekitMembers.ts b/src/state/CallViewModel/remoteMembers/MatrixLivekitMembers.ts index acd5b55f1..0b93a274b 100644 --- a/src/state/CallViewModel/remoteMembers/MatrixLivekitMembers.ts +++ b/src/state/CallViewModel/remoteMembers/MatrixLivekitMembers.ts @@ -62,7 +62,9 @@ interface Props { Epoch<{ membership: CallMembership; transport?: LivekitTransportConfig }[]> >; connectionManager: IConnectionManager; + localUser: { deviceId: string; userId: string }; } + /** * Combines MatrixRTC and Livekit worlds. * @@ -73,13 +75,14 @@ interface Props { * - out (via public Observable): * - `remoteMatrixLivekitMember` an observable of MatrixLivekitMember[] to track the remote members and associated livekit data. */ -export function createMatrixLivekitMembers$({ +export function createRemoteMatrixLivekitMembers$({ scope, membershipsWithTransport$, connectionManager, + localUser, }: Props): Behavior> { /** - * Stream of all the call members and their associated livekit data (if available). + * Behavior of all the remote call members and their associated livekit data (if available). */ return scope.behavior( combineLatest([ @@ -91,12 +94,19 @@ export function createMatrixLivekitMembers$({ ), map(([ms, data]) => new Epoch([ms.value, data.value] as const, ms.epoch)), generateItemsWithEpoch( - "MatrixLivekitMembers", + "RemoteMatrixLivekitMembers", // Generator function. // creates an array of `{key, data}[]` // Each change in the keys (new key) will result in a call to the factory function. function* ([membershipsWithTransport, managerData]) { for (const { membership, transport } of membershipsWithTransport) { + // Exclude the local membership + if ( + membership.userId === localUser.userId && + membership.deviceId === localUser.deviceId + ) + continue; + const participants = transport ? managerData.getParticipantsForTransport(transport) : []; diff --git a/src/state/CallViewModel/remoteMembers/integration.test.ts b/src/state/CallViewModel/remoteMembers/integration.test.ts index eb2c6ac8c..67d15a38f 100644 --- a/src/state/CallViewModel/remoteMembers/integration.test.ts +++ b/src/state/CallViewModel/remoteMembers/integration.test.ts @@ -29,13 +29,13 @@ import { import { type ProcessorState } from "../../../livekit/TrackProcessorContext.tsx"; import { areLivekitTransportsEqual, - createMatrixLivekitMembers$, + createRemoteMatrixLivekitMembers$, type RemoteMatrixLivekitMember, } from "./MatrixLivekitMembers.ts"; import { createConnectionManager$ } from "./ConnectionManager.ts"; import { membershipsAndTransports$ } from "../../SessionBehaviors.ts"; import { constant } from "../../Behavior.ts"; -import { testJWTToken } from "../../../utils/test-fixtures.ts"; +import { localRtcMember, testJWTToken } from "../../../utils/test-fixtures.ts"; // Test the integration of ConnectionManager and MatrixLivekitMerger @@ -130,14 +130,15 @@ test("bob, carl, then bob joining no tracks yet", () => { ownMembershipIdentity: ownMemberMock, }); - const matrixLivekitMembers$ = createMatrixLivekitMembers$({ + const remoteMatrixLivekitMembers$ = createRemoteMatrixLivekitMembers$({ scope: testScope, membershipsWithTransport$: membershipsAndTransports.membershipsWithTransport$, connectionManager, + localUser: localRtcMember, }); - expectObservable(matrixLivekitMembers$).toBe(vMarble, { + expectObservable(remoteMatrixLivekitMembers$).toBe(vMarble, { a: expect.toSatisfy((e: Epoch) => { const items = e.value; expect(items.length).toBe(1); diff --git a/src/state/media/MemberMediaViewModel.ts b/src/state/media/MemberMediaViewModel.ts index 969da8996..a7f4612db 100644 --- a/src/state/media/MemberMediaViewModel.ts +++ b/src/state/media/MemberMediaViewModel.ts @@ -92,6 +92,7 @@ export function createMemberMedia( }: MemberMediaInputs, ): BaseMemberMediaViewModel { const trackBehavior$ = ( + scope: ObservableScope, source: Track.Source, ): Behavior => scope.behavior( @@ -102,8 +103,8 @@ export function createMemberMedia( ), ); - const audio$ = trackBehavior$(audioSource); - const video$ = trackBehavior$(videoSource); + const audio$ = trackBehavior$(scope, audioSource); + const video$ = trackBehavior$(scope, videoSource); return { ...createBaseMedia(inputs),