Compare commits

..

49 Commits

Author SHA1 Message Date
RiotRobot 26d5b1cde2 v41.0.0 2026-02-24 13:58:36 +00:00
RiotRobot aba7f8a0d4 v41.0.0-rc.0 2026-02-17 15:11:09 +00:00
RiotRobot 5495153c63 Merge remote-tracking branch 'origin/develop' into staging 2026-02-17 14:43:42 +00:00
Michael Telatynski 4f0696e2a4 Revert "Disable complement crypto tests temporarily (#5188)" (#5190)
This reverts commit 327d2fa7c8.
2026-02-17 11:13:05 +00:00
Bas Nijholt 0e659d294e fix(relations): prevent stale m.replace from overriding newer edits (#5192)
When multiple m.replace edits arrive concurrently, getLastReplacement()
may block on decryption. If an older edit's decryption completes after a
newer edit has already been applied, the older async result overwrites
the target event with stale content.

Add a monotonic update counter (replacementUpdateId) and centralise all
replacement updates through updateTargetEventReplacement(). The method
captures the counter before awaiting and discards the result if a newer
update has started in the meantime.

This race is especially pronounced in encrypted rooms with rapid
streaming-style edits, where variable decryption timing causes
out-of-order promise resolution.
2026-02-17 09:47:08 +00:00
Skye Elliot e74eb4928e Download room keys from backup prior to buliding historic room key bundles (#5171)
* chore: Update `@matrix-org/matrix-sdk-crypto-wasm` to v17.1.0

Signed-off-by: Skye Elliot <actuallyori@gmail.com>

* feat: Download keys from key backup.

Signed-off-by: Skye Elliot <actuallyori@gmail.com>

* tests: Ensure backup is downloaded before building room key bundle.

Signed-off-by: Skye Elliot <actuallyori@gmail.com>

* fix: Address review comments in history sharing tests.

* docs: Improve `getSyncResponse` and `assertInviteAndShareHistory` docs

* feat: Log `backupVersion`, `hasDecryptionKey` on download failure

* fix: Group backup data, add casts to `generate-test-data.py`.

* tests: Update `getSyncResponse` calls in history sharing integ tests

* fix: Add history visibility argument to state events test.

---------

Signed-off-by: Skye Elliot <actuallyori@gmail.com>
2026-02-13 13:31:14 +00:00
David Baker 327d2fa7c8 Disable complement crypto tests temporarily (#5188)
* Disable complement crypto tests temporarily

As per comment

* and the downstream one too

* Stub downstream test instead

* prettier
2026-02-12 14:57:34 +00:00
Aditya Cherukuru 028357f15f Fix reactive display name disambiguation (#5135)
* Fix reactive display name disambiguation

When a room member changes their display name, recalculate the disambiguation flag for all other members who share (or previously shared) that display name. This ensures that the 'disambiguate' flag is updated reactively when display name conflicts appear or are resolved.

Fixes element-hq/element-web#468

Fixes element-hq/element-web#4795

Fixes element-hq/element-web#31551

Signed-off-by: aditya-cherukuru <cherukuru.aditya01@gmail.com>

* Refactor: move disambiguation logic per review feedback

- Added updateDisambiguation() method to RoomMember for direct disambiguation recalculation

- Moved affected display name tracking to setStateEvents() instead of updateDisplayNameCache()

- Removed setMembershipEvent() hack, now calls updateDisambiguation() directly

Signed-off-by: aditya-cherukuru <cherukuru.aditya01@gmail.com>

* Exclude processed members from disambiguation loop

Signed-off-by: aditya-cherukuru <cherukuru.aditya01@gmail.com>

---------

Signed-off-by: aditya-cherukuru <cherukuru.aditya01@gmail.com>
2026-02-12 14:23:04 +00:00
Michael Telatynski 872ec6755e Add support for Matrix Spec v1.13 (#5160) 2026-02-11 16:14:02 +00:00
RiotRobot 333d6a7bd6 v40.3.0-rc.0 2026-02-11 15:06:14 +00:00
Michael Telatynski 47532de452 Switch from yarn classic to pnpm (#5184) 2026-02-11 10:35:25 +00:00
RiotRobot 87e1049dae Merge branch 'master' into develop 2026-02-10 15:17:11 +00:00
RiotRobot 4c8e38009f v40.2.0 2026-02-10 15:16:24 +00:00
Timo 6e3efef0c5 Fix empty string to room compatibility trick to only apply to m.call (#5172)
* Fix empty string to room compatibility trick to only apply to m.call

* add logging

* fix linter

* Add tests

* limit logging.
2026-02-08 11:25:34 +00:00
Olivier 'reivilibre fb590627bb Add logging on MSC4108 DELETE request (#5140)
Just noticed these requests aren't logged,
which makes debugging difficult.
This is very drive-by, done in the web editor.

Co-authored-by: R Midhun Suresh <hi@midhun.dev>
2026-02-05 10:13:44 +00:00
Michael Telatynski 24cc17c270 Merge remote-tracking branch 'origin/develop' into develop 2026-02-04 11:47:05 +00:00
Michael Telatynski 68084e8fc3 Fix vitest slow reporter crashing CI
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2026-02-04 11:46:48 +00:00
Richard van der Hoff 0c3bb1f246 Add m.invite_permission_config account data type (#5183)
For MSC4380
2026-02-04 11:00:16 +00:00
renovate[bot] 7f42b67f68 Update matrix-org (#5181)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-03 15:11:44 +00:00
renovate[bot] 9b871ac969 Update dependency eslint-plugin-jsdoc to v62.5.0 (#5179)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-03 14:39:51 +00:00
renovate[bot] 49f7972a9e Update peter-evans/repository-dispatch digest to 28959ce (#5177)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-03 14:16:42 +00:00
renovate[bot] c5ae4c8c0d Update dependency typedoc-plugin-mdn-links to v5.1.1 (#5180)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-03 14:02:42 +00:00
renovate[bot] 2423300acd Update npm non-major dependencies (#5178)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-03 14:01:03 +00:00
renovate[bot] 6cafa175b8 Update guibranco/github-status-action-v2 digest to 9bfa877 (#5176)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-03 13:55:10 +00:00
RiotRobot 40165942e3 v40.2.0-rc.0 2026-02-03 13:14:44 +00:00
Hugh Nimmo-Smith f301251ff5 Clean up the ValidatedAuthMetadata types (#5175)
We don't expect oidc-client-ts to provide the `device_authorization_endpoint` in the `OidcMetadata` because it isn't part of the OIDC spec.

As such, I think it makes sense to standardise on defining the metadata fields in `validate.ts` and clarify where they come from.
2026-02-02 17:27:33 +00:00
Hugh Nimmo-Smith 21cd5e98c1 Use stable /auth_metadata endpoint where homeserver supports v1.15 (#5174) 2026-02-02 15:25:48 +00:00
Andy Balaam db070dca57 Support additional_creators in upgradeRoom (MSC4289) (#5173)
* Support additional_creators in upgradeRoom (MSC4289)

Signed-off-by: Andy Balaam <andy.balaam@matrix.org>

* Remove unneeded undefined in type definition

Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>

---------

Signed-off-by: Andy Balaam <andy.balaam@matrix.org>
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2026-02-02 11:24:53 +00:00
Timo 8b6ff0abcb [js sdk embedded/widget] Fix race where this.syncApi.injectRoomEvents was called before the syncApi is instantiated (#5168)
* Make sure we do not call this.syncApi.injectRoomEvents before the
syncApi is instanciated

* sonarCube remove complexity (embedded.js constructor)
2026-01-29 16:22:41 +00:00
Timo f2157f28bb [MatrixRTC] Minimal change to transition from "" to "ROOM" as the callId/slotId (#5166)
* Minimal change to transition from "" to "ROOM" as the callId/slotId

* Also transition MembershipManager tests to `"ROOM"`

* fix merge
2026-01-29 15:37:34 +00:00
Timo 739b8e1f89 Remove sending of deprecated notify event (we now use (#5167)
`m.rtc.notification`
2026-01-27 15:52:41 +00:00
Timo bc57a3f829 [MatrixRTC] Do not sent the livekit_alias in sticky events (#5165)
* Do not sent the livekit_alias in sticky events

* tests
2026-01-27 15:23:14 +00:00
RiotRobot f136f6ddf7 Merge branch 'master' into develop 2026-01-27 12:39:59 +00:00
RiotRobot d428e7119a v40.1.0 2026-01-27 12:39:19 +00:00
ElementRobot 5532066178 Recalculate room name on loading members (#5158) (#5164)
Co-authored-by: David Baker <dbkr@users.noreply.github.com>
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2026-01-27 12:33:09 +00:00
R Midhun Suresh 82b51d0d46 Improve startup performance by using promise.all when processing rooms from sync (#5095)
* Use Promise.all when processing rooms in sync response

* Remove import
2026-01-27 08:56:26 +00:00
David Baker fb12a5a1d6 Recalculate room name on loading members (#5158)
* Recalculate room name on loading members

Because if it's a DM room, loading members might change the room name

* Swap other userA / userB constants

* Typo

Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>

---------

Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2026-01-26 12:25:41 +00:00
Hugh Nimmo-Smith 61ea5a7dfc Add OAuthGrantType enum for OAuth 2.0 API grant types (#5161)
* Add OAuthGrantType enum for OAuth 2.0 API grant types

* Resolve circular imports

* Fix complication error
2026-01-23 13:46:56 +00:00
Hugh Nimmo-Smith 4e032317fe Add support for stable OAuth2.0 aware feature from MSC3824 (#5159)
* Add support for stable OAuth2.0 aware feature from MSC3824

* Use stable name internally

* Mark DELEGATED_OIDC_COMPATIBILITY as

* Add tsdoc config for @alias JSDoc modifier
2026-01-23 09:50:12 +00:00
Robin dbb2ae5c07 Give RoomWidgetClient the ability to send and receive sticky events (#5142)
* Give RoomWidgetClient the ability to send and receive sticky events

* linter

* Fix existing tests

* Add tests for sticky event support in embedded clients

* Update sticky event widget capability identifiers

In matrix-widget-api 0.16.1 they are updated to use the new unstable prefix from MSC4407.

* Explicitly require matrix-widget-api ≥ 1.16.1

* remove TODO comment

* simplify type lint checks
This is needed for EW donwstream tests. Otherwise it will through:
Error: matrix-js-sdk/src/embedded.ts(417,21): error TS2345: Argument of
type 'string | number | boolean | string[]' is not assignable to
parameter of type 'number'.

---------

Co-authored-by: Timo K <toger5@hotmail.de>
2026-01-22 16:07:13 +00:00
Michael Telatynski c8032a214e Typescript fixes (#5157)
* Remove deprecated tsconfig baseUrl

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Fix imports in tests

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Fix duplicated map entry

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2026-01-21 17:28:10 +00:00
Timo 6e34ca6f2d [MatrixRTC] Fix delayId not resetting on leave (#5156)
* reset delay id to undefined after sucessfully sending it.

* add tests

* setAndEmitDelayId signature
dont allow implicit undefined
2026-01-21 13:53:55 +00:00
Andy Balaam 4a7a699623 Unit tests for OutgoingRequestsManager not repeating failed requests (#5154) 2026-01-21 12:03:09 +00:00
renovate[bot] 774776178e Update npm non-major dependencies (#5150)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-20 15:08:00 +00:00
renovate[bot] ed4078528d Update dependency typedoc to v0.28.16 (#5149)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-20 15:03:52 +00:00
renovate[bot] 7224961e9e Update actions/setup-node digest to 6044e13 (#5147)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-20 14:44:56 +00:00
renovate[bot] 35ca07e8fb Update matrix-org/sonarcloud-workflow-action digest to ea0cd9d (#5148)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-20 14:44:47 +00:00
renovate[bot] ccffb5df2d Update dependency eslint-plugin-jsdoc to v62 (#5153)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-20 14:44:04 +00:00
renovate[bot] 0d162f66f3 Update dependency matrix-widget-api to v1.16.0 (#5152)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-20 14:43:47 +00:00
73 changed files with 9858 additions and 6711 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
* @matrix-org/element-web-reviewers
/.github/workflows/** @matrix-org/element-web-team
/package.json @matrix-org/element-web-team
/yarn.lock @matrix-org/element-web-team
/pnpm-lock.yaml @matrix-org/element-web-team
/scripts/** @matrix-org/element-web-team
/src/webrtc @matrix-org/element-call-reviewers
/src/matrixrtc @matrix-org/element-call-reviewers
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
runs-on: ubuntu-24.04
steps:
- name: Notify matrix-react-sdk repo that a new SDK build is on develop so it can CI against it
uses: peter-evans/repository-dispatch@5fc4efd1a4797ddb68ffd0714a238564e4cc0e6f # v4
uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4
with:
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
repository: ${{ matrix.repo }}
@@ -21,13 +21,14 @@ jobs:
ref: staging
fetch-depth: 0
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
with:
node-version-file: package.json
cache: "yarn"
cache: "pnpm"
- name: Install Deps
run: "yarn install --frozen-lockfile"
run: "pnpm install --frozen-lockfile"
- uses: t3chguy/release-drafter@105e541c2c3d857f032bd522c0764694758fabad
id: draft-release
+5 -4
View File
@@ -33,13 +33,14 @@ jobs:
sparse-checkout: |
scripts/release
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
with:
cache: "yarn"
cache: "pnpm"
node-version-file: package.json
- name: Install Deps
run: "yarn install --frozen-lockfile"
run: "pnpm install --frozen-lockfile"
- name: Set up git
run: |
@@ -73,7 +74,7 @@ jobs:
fi
echo "Resetting $PACKAGE to develop branch..."
yarn add "github:matrix-org/$PACKAGE#develop"
pnpm add "github:matrix-org/$PACKAGE#develop"
git add -u
git commit -m "Reset $PACKAGE back to develop branch"
done <<< "$DEPENDENCIES"
+7 -6
View File
@@ -123,13 +123,14 @@ jobs:
git config --global user.email "releases@riot.im"
git config --global user.name "RiotRobot"
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
with:
cache: "yarn"
cache: "pnpm"
node-version-file: package.json
- name: Install dependencies
run: "yarn install --frozen-lockfile"
run: "pnpm install --frozen-lockfile"
- name: Handle develop dependencies
run: |
@@ -140,14 +141,14 @@ jobs:
VERSION=${dep[1]}
echo "::warning title=Develop dependency found::$DEPENDENCY will be kept at $VERSION"
yarn upgrade "$PACKAGE@$VERSION" --exact
pnpm add "$PACKAGE@$VERSION" --save-exact
git add -u
git commit -m "Keep $PACKAGE at $VERSION"
done
- name: Bump package.json version
run: |
yarn version --no-git-tag-version --new-version "${VERSION#v}"
pnpm version --no-git-tag-version "${VERSION#v}"
git add package.json
- name: Add to CHANGELOG.md
@@ -175,7 +176,7 @@ jobs:
- name: Build assets
if: steps.prepare.outputs.has-dist-script == '1'
run: DIST_VERSION="$VERSION" yarn dist
run: DIST_VERSION="$VERSION" pnpm dist
- name: Upload release assets & signatures
if: inputs.asset-path
+5 -4
View File
@@ -21,10 +21,11 @@ jobs:
with:
ref: staging
- name: 🔧 Yarn cache
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4
- name: 🔧 pnpm cache
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
with:
cache: "yarn"
cache: "pnpm"
registry-url: "https://registry.npmjs.org"
node-version-file: package.json
@@ -33,7 +34,7 @@ jobs:
run: npm install -g npm@latest
- name: 🔨 Install dependencies
run: "yarn install --frozen-lockfile"
run: "pnpm install --frozen-lockfile"
- name: 🚀 Publish to npm
id: npm-publish
+11 -9
View File
@@ -50,9 +50,10 @@ jobs:
ref: staging
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
with:
cache: "yarn"
cache: "pnpm"
node-version: "lts/*"
- name: Bump dependency
@@ -61,8 +62,8 @@ jobs:
run: |
git config --global user.email "releases@riot.im"
git config --global user.name "RiotRobot"
yarn upgrade "$DEPENDENCY" --exact
git add package.json yarn.lock
pnpm add "$DEPENDENCY" --save-exact
git add package.json pnpm-lock.yaml
git commit -am"Upgrade dependency to $DEPENDENCY"
git push origin staging
@@ -75,17 +76,18 @@ jobs:
- name: 🧮 Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: 🔧 Yarn cache
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4
- name: 🔧 pnpm cache
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
with:
cache: "yarn"
cache: "pnpm"
node-version-file: package.json
- name: 🔨 Install dependencies
run: "yarn install --frozen-lockfile"
run: "pnpm install --frozen-lockfile"
- name: 📖 Generate docs
run: yarn gendoc
run: pnpm gendoc
- name: Upload artifact
uses: actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b # v4
+3 -3
View File
@@ -27,7 +27,7 @@ jobs:
steps:
# We create the status here and then update it to success/failure in the `report` stage
# This provides an easy link to this workflow_run from the PR before Sonarcloud is done.
- uses: guibranco/github-status-action-v2@5530c593759f489bba08272e96986ffc571c1ea1
- uses: guibranco/github-status-action-v2@9bfa8773cdbdc6c185747fd43cd7faa9d7c32f09
with:
authToken: ${{ secrets.GITHUB_TOKEN }}
state: pending
@@ -75,7 +75,7 @@ jobs:
- name: "🩻 SonarCloud Scan"
id: sonarcloud
uses: matrix-org/sonarcloud-workflow-action@9f6f057c1b30500877460c66d4ae937e53d168ee
uses: matrix-org/sonarcloud-workflow-action@ea0cd9dbd5562e79816685972bc0d03c235a900c
# workflow_run fails report against the develop commit always, we don't want that for PRs
continue-on-error: ${{ github.event.workflow_run.head_branch != 'develop' }}
with:
@@ -88,7 +88,7 @@ jobs:
revision: ${{ github.event.workflow_run.head_sha }}
token: ${{ secrets.SONAR_TOKEN }}
- uses: guibranco/github-status-action-v2@5530c593759f489bba08272e96986ffc571c1ea1
- uses: guibranco/github-status-action-v2@9bfa8773cdbdc6c185747fd43cd7faa9d7c32f09
if: always()
with:
authToken: ${{ secrets.GITHUB_TOKEN }}
+34 -33
View File
@@ -16,16 +16,17 @@ jobs:
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
with:
cache: "yarn"
cache: "pnpm"
node-version-file: package.json
- name: Install Deps
run: "yarn install"
run: "pnpm install"
- name: Typecheck
run: "yarn run lint:types"
run: "pnpm run lint:types"
js_lint:
name: "ESLint"
@@ -33,16 +34,17 @@ jobs:
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
with:
cache: "yarn"
cache: "pnpm"
node-version-file: package.json
- name: Install Deps
run: "yarn install"
run: "pnpm install"
- name: Run Linter
run: "yarn run lint:js"
run: "pnpm run lint:js"
node_example_lint:
name: "Node.js example"
@@ -50,22 +52,17 @@ jobs:
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
with:
cache: "yarn"
cache: "pnpm"
node-version-file: package.json
- name: Install Deps
run: "yarn install"
run: "pnpm install"
- name: Build Types
run: "yarn build:types"
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6
with:
cache: "npm"
node-version-file: "examples/node/package.json"
# cache-dependency-path: '**/package-lock.json'
run: "pnpm build:types"
- name: Install Example Deps
run: "npm install"
@@ -85,16 +82,17 @@ jobs:
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
with:
cache: "yarn"
cache: "pnpm"
node-version-file: package.json
- name: Install Deps
run: "yarn install --frozen-lockfile"
run: "pnpm install --frozen-lockfile"
- name: Run Linter
run: "yarn lint:workflows"
run: "pnpm lint:workflows"
docs:
name: "JSDoc Checker"
@@ -102,16 +100,17 @@ jobs:
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
with:
cache: "yarn"
cache: "pnpm"
node-version-file: package.json
- name: Install Deps
run: "yarn install"
run: "pnpm install"
- name: Generate Docs
run: "yarn run gendoc --treatWarningsAsErrors --suppressCommentWarningsInDeclarationFiles"
run: "pnpm run gendoc --treatWarningsAsErrors --suppressCommentWarningsInDeclarationFiles"
- name: Upload Artifact
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
@@ -127,16 +126,17 @@ jobs:
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
with:
cache: "yarn"
cache: "pnpm"
node-version-file: package.json
- name: Install Deps
run: "yarn install --frozen-lockfile"
run: "pnpm install --frozen-lockfile"
- name: Run linter
run: "yarn run lint:knip"
run: "pnpm run lint:knip"
element-web:
name: Downstream tsc element-web
@@ -147,9 +147,10 @@ jobs:
with:
repository: element-hq/element-web
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
with:
cache: "yarn"
cache: "pnpm"
node-version: "lts/*"
- name: Install Dependencies
@@ -159,7 +160,7 @@ jobs:
JS_SDK_GITHUB_BASE_REF: ${{ github.sha }}
- name: Typecheck
run: "yarn run lint:types"
run: "pnpm run lint:types"
# Hook for branch protection to skip downstream typechecking outside of merge queues
downstream:
+6 -5
View File
@@ -24,15 +24,16 @@ jobs:
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4
- name: Setup Node
id: setupNode
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
with:
cache: "yarn"
cache: "pnpm"
node-version: ${{ matrix.node }}
- name: Install dependencies
run: "yarn install"
run: "pnpm install"
- name: Get number of CPU cores
id: cpu-cores
@@ -40,7 +41,7 @@ jobs:
- name: Run tests
run: |
yarn test \
pnpm test \
--coverage=${{ env.ENABLE_COVERAGE }} \
--maxWorkers ${{ steps.cpu-cores.outputs.count }} \
./spec/${{ matrix.specs }}
@@ -112,7 +113,7 @@ jobs:
steps:
- name: Skip SonarCloud on merge queues
if: env.ENABLE_COVERAGE == 'false'
uses: guibranco/github-status-action-v2@5530c593759f489bba08272e96986ffc571c1ea1
uses: guibranco/github-status-action-v2@9bfa8773cdbdc6c185747fd43cd7faa9d7c32f09
with:
authToken: ${{ secrets.GITHUB_TOKEN }}
state: success
+1
View File
@@ -3,6 +3,7 @@
/.npmrc
/*.log
pnpm-lock.yaml
package-lock.json
.lock-wscript
build/Release
+64
View File
@@ -1,3 +1,67 @@
Changes in [41.0.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v41.0.0) (2026-02-24)
==================================================================================================
## 🚨 BREAKING CHANGES
* Add support for Matrix Spec v1.13 ([#5160](https://github.com/matrix-org/matrix-js-sdk/pull/5160)). Contributed by @t3chguy.
## ✨ Features
* Download room keys from backup prior to buliding historic room key bundles ([#5171](https://github.com/matrix-org/matrix-js-sdk/pull/5171)). Contributed by @kaylendog.
* Add support for Matrix Spec v1.13 ([#5160](https://github.com/matrix-org/matrix-js-sdk/pull/5160)). Contributed by @t3chguy.
* Add logging on MSC4108 DELETE request ([#5140](https://github.com/matrix-org/matrix-js-sdk/pull/5140)). Contributed by @reivilibre.
* Add `m.invite_permission_config` account data type ([#5183](https://github.com/matrix-org/matrix-js-sdk/pull/5183)). Contributed by @richvdh.
## 🐛 Bug Fixes
* fix(relations): prevent stale m.replace from overriding newer edits ([#5192](https://github.com/matrix-org/matrix-js-sdk/pull/5192)). Contributed by @basnijholt.
* Fix reactive display name disambiguation ([#5135](https://github.com/matrix-org/matrix-js-sdk/pull/5135)). Contributed by @aditya-cherukuru.
* Fix empty string to room compatibility trick to only apply to m.call ([#5172](https://github.com/matrix-org/matrix-js-sdk/pull/5172)). Contributed by @toger5.
Changes in [40.2.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v40.2.0) (2026-02-10)
==================================================================================================
## 🦖 Deprecations
* [MatrixRTC] Remove sending of deprecated `notify` event (we now use `m.rtc.notification`) ([#5167](https://github.com/matrix-org/matrix-js-sdk/pull/5167)). Contributed by @toger5.
## ✨ Features
* Use stable /auth\_metadata endpoint where homeserver supports v1.15 ([#5174](https://github.com/matrix-org/matrix-js-sdk/pull/5174)). Contributed by @hughns.
* Support additional\_creators in upgradeRoom (MSC4289) ([#5173](https://github.com/matrix-org/matrix-js-sdk/pull/5173)). Contributed by @andybalaam.
* [MatrixRTC] Minimal change to transition from "" to "ROOM" as the callId/slotId ([#5166](https://github.com/matrix-org/matrix-js-sdk/pull/5166)). Contributed by @toger5.
* [MatrixRTC] Do not send the `livekit_alias` in sticky events ([#5165](https://github.com/matrix-org/matrix-js-sdk/pull/5165)). Contributed by @toger5.
* Improve startup performance by using `promise.all` when processing rooms from sync ([#5095](https://github.com/matrix-org/matrix-js-sdk/pull/5095)). Contributed by @MidhunSureshR.
* Add OAuthGrantType enum for OAuth 2.0 API grant types ([#5161](https://github.com/matrix-org/matrix-js-sdk/pull/5161)). Contributed by @hughns.
* Add support for stable OAuth2.0 aware feature from MSC3824 ([#5159](https://github.com/matrix-org/matrix-js-sdk/pull/5159)). Contributed by @hughns.
* Give RoomWidgetClient the ability to send and receive sticky events ([#5142](https://github.com/matrix-org/matrix-js-sdk/pull/5142)). Contributed by @robintown.
## 🐛 Bug Fixes
* [js sdk embedded/widget] Fix race where this.syncApi.injectRoomEvents was called before the syncApi is instantiated ([#5168](https://github.com/matrix-org/matrix-js-sdk/pull/5168)). Contributed by @toger5.
* [MatrixRTC] Fix delayId not resetting on leave ([#5156](https://github.com/matrix-org/matrix-js-sdk/pull/5156)). Contributed by @toger5.
Changes in [40.1.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v40.1.0) (2026-01-27)
==================================================================================================
## 🦖 Deprecations
* Deprecate unused `EventShieldReason` reason codes ([#5127](https://github.com/matrix-org/matrix-js-sdk/pull/5127)). Contributed by @richvdh.
## ✨ Features
* Add stable m.oauth UIA stage enum ([#5138](https://github.com/matrix-org/matrix-js-sdk/pull/5138)). Contributed by @hughns.
* Add `MatrixEvent.getKeyForwardingUser` ([#5128](https://github.com/matrix-org/matrix-js-sdk/pull/5128)). Contributed by @richvdh.
* Add types for (unstable) policy servers ([#5116](https://github.com/matrix-org/matrix-js-sdk/pull/5116)). Contributed by @turt2live.
## 🐛 Bug Fixes
* [Backport staging] Recalculate room name on loading members ([#5164](https://github.com/matrix-org/matrix-js-sdk/pull/5164)). Contributed by @RiotRobot.
* Avoid rapidly retrying failed requests ([#5146](https://github.com/matrix-org/matrix-js-sdk/pull/5146)). Contributed by @andybalaam.
* [matrixRTC] MatrixRTCSessions, add missing event reemission. ([#5144](https://github.com/matrix-org/matrix-js-sdk/pull/5144)). Contributed by @toger5.
* Use normal base64 encoding for RTC backend identities ([#5129](https://github.com/matrix-org/matrix-js-sdk/pull/5129)). Contributed by @robintown.
* export parseCallNotificationContent and isMyMembership from RTC types ([#5132](https://github.com/matrix-org/matrix-js-sdk/pull/5132)). Contributed by @Half-Shot.
Changes in [40.0.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v40.0.0) (2026-01-13)
==================================================================================================
## 🚨 BREAKING CHANGES
+8 -8
View File
@@ -41,10 +41,10 @@ endpoints from before Matrix 1.1, for example.
> Servers may require or use authenticated endpoints for media (images, files, avatars, etc). See the
> [Authenticated Media](#authenticated-media) section for information on how to enable support for this.
Using `yarn` instead of `npm` is recommended. Please see the Yarn [install guide](https://classic.yarnpkg.com/en/docs/install)
if you do not have it already.
Using `pnpm` instead of `npm` is recommended. Please see the pnpm [install
guide](https://pnpm.io/installation#using-corepack) if you do not have it already.
`yarn add matrix-js-sdk`
`pnpm add matrix-js-sdk`
```javascript
import * as sdk from "matrix-js-sdk";
@@ -310,7 +310,7 @@ This SDK uses [Typedoc](https://typedoc.org/guides/doccomments) doc comments. Yo
host the API reference from the source files like this:
```
$ yarn gendoc
$ pnpm gendoc
$ cd docs
$ python -m http.server 8005
```
@@ -453,7 +453,7 @@ want to use this SDK, skip this section._
First, you need to pull in the right build tools:
```
$ yarn install
$ pnpm install
```
## Building
@@ -461,17 +461,17 @@ First, you need to pull in the right build tools:
To build a browser version from scratch when developing:
```
$ yarn build
$ pnpm build
```
To run tests:
```
$ yarn test
$ pnpm test
```
To run linting:
```
$ yarn lint
$ pnpm lint
```
+1 -1
View File
@@ -21,4 +21,4 @@ export PATH="$rootdir/node_modules/.bin:$PATH"
# now run our checks
cd "$tmpdir"
yarn lint
pnpm lint
+22 -11
View File
@@ -1,19 +1,19 @@
{
"name": "matrix-js-sdk",
"version": "40.1.0-rc.0",
"version": "41.0.0",
"description": "Matrix Client-Server SDK for Javascript",
"engines": {
"node": ">=22.0.0"
},
"scripts": {
"prepare": "yarn build",
"prepare": "pnpm build",
"start": "echo THIS IS FOR LEGACY PURPOSES ONLY. && babel src -w -s -d lib --verbose --extensions \".ts,.js\"",
"clean": "rimraf lib",
"build": "yarn clean && yarn build:compile && yarn build:types",
"build": "pnpm clean && pnpm build:compile && pnpm build:types",
"build:types": "tsc -p tsconfig-build.json --emitDeclarationOnly",
"build:compile": "babel -d lib --verbose --extensions \".ts,.js\" src",
"gendoc": "typedoc",
"lint": "yarn lint:types && yarn lint:js && yarn lint:workflows",
"lint": "pnpm lint:types && pnpm lint:js && pnpm lint:workflows",
"lint:js": "eslint --max-warnings 0 src spec && prettier --check .",
"lint:js-fix": "prettier --log-level=warn --write . && eslint --fix src spec",
"lint:types": "tsc --noEmit",
@@ -21,7 +21,7 @@
"lint:knip": "knip",
"test": "vitest",
"test:watch": "vitest --watch",
"coverage": "yarn test --coverage"
"coverage": "pnpm test --coverage"
},
"repository": {
"type": "git",
@@ -49,14 +49,14 @@
],
"dependencies": {
"@babel/runtime": "^7.12.5",
"@matrix-org/matrix-sdk-crypto-wasm": "^17.0.0",
"@matrix-org/matrix-sdk-crypto-wasm": "^17.1.0",
"another-json": "^0.2.0",
"bs58": "^6.0.0",
"content-type": "^1.0.4",
"jwt-decode": "^4.0.0",
"loglevel": "^1.9.2",
"matrix-events-sdk": "0.0.1",
"matrix-widget-api": "^1.14.0",
"matrix-widget-api": "^1.16.1",
"oidc-client-ts": "^3.0.1",
"p-retry": "7",
"sdp-transform": "^3.0.0",
@@ -84,7 +84,7 @@
"@stylistic/eslint-plugin": "^5.0.0",
"@types/content-type": "^1.1.5",
"@types/debug": "^4.1.7",
"@types/node": "18",
"@types/node": "22",
"@types/sdp-transform": "^2.4.5",
"@typescript-eslint/eslint-plugin": "^8.0.0",
"@typescript-eslint/parser": "^8.0.0",
@@ -98,7 +98,7 @@
"eslint-config-prettier": "^10.0.0",
"eslint-import-resolver-typescript": "^4.0.0",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-jsdoc": "^61.0.0",
"eslint-plugin-jsdoc": "^62.0.0",
"eslint-plugin-matrix-org": "^3.0.0",
"eslint-plugin-n": "^14.0.0",
"eslint-plugin-tsdoc": "^0.5.0",
@@ -110,7 +110,7 @@
"knip": "^5.0.0",
"lint-staged": "^16.0.0",
"matrix-mock-request": "^2.5.0",
"prettier": "3.7.4",
"prettier": "3.8.1",
"rimraf": "^6.0.0",
"typedoc": "^0.28.1",
"typedoc-plugin-coverage": "^4.0.0",
@@ -122,5 +122,16 @@
},
"resolutions": {
"expect": "30.2.0"
}
},
"pnpm": {
"peerDependencyRules": {
"allowedVersions": {
"eslint": "8"
}
},
"allowedDeprecatedVersions": {
"eslint": "8"
}
},
"packageManager": "pnpm@10.28.2+sha512.41872f037ad22f7348e3b1debbaf7e867cfd448f2726d9cf74c08f19507c31d2c8e7a11525b983febc2df640b5438dee6023ebb1f84ed43cc2d654d2bc326264"
}
+7805
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
nodeLinker: hoisted
+138 -56
View File
@@ -22,7 +22,9 @@ import {
createClient,
DebugLogger,
EventType,
HistoryVisibility,
type IContent,
type IRoomEvent,
KnownMembership,
type MatrixClient,
MsgType,
@@ -36,6 +38,15 @@ import { flushPromises } from "../../test-utils/flushPromises.ts";
import { E2EOTKClaimResponder } from "../../test-utils/E2EOTKClaimResponder.ts";
import { escapeRegExp } from "../../../src/utils.ts";
import { EventShieldColour, EventShieldReason } from "../../../src/crypto-api";
import {
BACKUP_DECRYPTION_KEY_BASE64,
CLEAR_EVENT,
ENCRYPTED_EVENT,
SIGNED_BACKUP_DATA,
TEST_ROOM_ID,
TEST_USER_ID,
PER_ROOM_CURVE25519_KEY_BACKUP_DATA,
} from "../../test-utils/test-data";
const debug = mkDebug("matrix-js-sdk:history-sharing");
@@ -86,7 +97,7 @@ describe("History Sharing", () => {
mockSetupCrossSigningRequests();
const aliceId = "@alice:localhost";
const aliceId = TEST_USER_ID;
const bobId = "@bob:xyz";
const aliceKeyReceiver = new E2EKeyReceiver(ALICE_HOMESERVER_URL, "alice-");
@@ -117,7 +128,7 @@ describe("History Sharing", () => {
test("Room keys are successfully shared on invite", async () => {
// Alice is in an encrypted room
const syncResponse = getSyncResponse([aliceClient.getSafeUserId()], ROOM_ID);
const syncResponse = getSyncResponse([aliceClient.getSafeUserId()], HistoryVisibility.Shared, ROOM_ID);
aliceSyncResponder.sendOrQueueSyncResponse(syncResponse);
await syncPromise(aliceClient);
@@ -127,60 +138,15 @@ describe("History Sharing", () => {
const sentMessage = await msgProm;
debug(`Alice sent encrypted room event: ${JSON.stringify(sentMessage)}`);
// Now, Alice invites Bob
const uploadProm = new Promise<Uint8Array>((resolve) => {
fetchMock.postOnce(new URL("/_matrix/media/v3/upload", ALICE_HOMESERVER_URL).toString(), (callLog) => {
const body = callLog.options.body as Uint8Array;
debug(`Alice uploaded blob of length ${body.length}`);
resolve(body);
return { content_uri: "mxc://alice-server/here" };
});
});
const toDeviceMessageProm = expectSendToDeviceMessage(ALICE_HOMESERVER_URL, "m.room.encrypted");
// POST https://alice-server.com/_matrix/client/v3/rooms/!room%3Aexample.com/invite
fetchMock.postOnce(`${ALICE_HOMESERVER_URL}/_matrix/client/v3/rooms/${encodeURIComponent(ROOM_ID)}/invite`, {});
await aliceClient.invite(ROOM_ID, bobClient.getSafeUserId(), { shareEncryptedHistory: true });
const uploadedBlob = await uploadProm;
const sentToDeviceRequest = await toDeviceMessageProm;
debug(`Alice sent encrypted to-device events: ${JSON.stringify(sentToDeviceRequest)}`);
const bobToDeviceMessage = sentToDeviceRequest[bobClient.getSafeUserId()][bobClient.deviceId!];
expect(bobToDeviceMessage).toBeDefined();
// Bob receives the to-device event and the room invite
const inviteEvent = mkEventCustom({
type: "m.room.member",
sender: aliceClient.getSafeUserId(),
state_key: bobClient.getSafeUserId(),
content: { membership: KnownMembership.Invite },
});
bobSyncResponder.sendOrQueueSyncResponse({
rooms: { invite: { [ROOM_ID]: { invite_state: { events: [inviteEvent] } } } },
to_device: {
events: [
{
type: "m.room.encrypted",
sender: aliceClient.getSafeUserId(),
content: bobToDeviceMessage,
},
],
},
});
await syncPromise(bobClient);
const room = bobClient.getRoom(ROOM_ID);
expect(room).toBeTruthy();
expect(room?.getMyMembership()).toEqual(KnownMembership.Invite);
fetchMock.postOnce(`${BOB_HOMESERVER_URL}/_matrix/client/v3/join/${encodeURIComponent(ROOM_ID)}`, {
room_id: ROOM_ID,
});
fetchMock.getOnce(`begin:${BOB_HOMESERVER_URL}/_matrix/client/v1/media/download/alice-server/here`, {
body: uploadedBlob,
});
await bobClient.joinRoom(ROOM_ID, { acceptSharedHistory: true });
// Alice invites Bob, and shares the room history with them.
await assertInviteAndShareHistory(ROOM_ID);
// Bob receives, should be able to decrypt, the megolm message
const bobSyncResponse = getSyncResponse([aliceClient.getSafeUserId(), bobClient.getSafeUserId()], ROOM_ID);
const bobSyncResponse = getSyncResponse(
[aliceClient.getSafeUserId(), bobClient.getSafeUserId()],
HistoryVisibility.Shared,
ROOM_ID,
);
bobSyncResponse.rooms.join[ROOM_ID].timeline.events.push(
mkEventCustom({
type: "m.room.encrypted",
@@ -206,7 +172,7 @@ describe("History Sharing", () => {
test("Room keys are imported correctly if invite is accepted before the bundle arrives", async () => {
// Alice is in an encrypted room
const syncResponse = getSyncResponse([aliceClient.getSafeUserId()], ROOM_ID);
const syncResponse = getSyncResponse([aliceClient.getSafeUserId()], HistoryVisibility.Shared, ROOM_ID);
aliceSyncResponder.sendOrQueueSyncResponse(syncResponse);
await syncPromise(aliceClient);
@@ -257,7 +223,11 @@ describe("History Sharing", () => {
await bobClient.joinRoom(ROOM_ID, { acceptSharedHistory: true });
// Bob receives and attempts to decrypt the megolm message, but should not be able to (yet).
const bobSyncResponse = getSyncResponse([aliceClient.getSafeUserId(), bobClient.getSafeUserId()], ROOM_ID);
const bobSyncResponse = getSyncResponse(
[aliceClient.getSafeUserId(), bobClient.getSafeUserId()],
HistoryVisibility.Shared,
ROOM_ID,
);
bobSyncResponse.rooms.join[ROOM_ID].timeline.events.push(
mkEventCustom({
type: "m.room.encrypted",
@@ -304,11 +274,123 @@ describe("History Sharing", () => {
expect(encryptionInfo?.shieldReason).toEqual(EventShieldReason.AUTHENTICITY_NOT_GUARANTEED);
});
test("Room keys are downloaded from key backup before inviting", async () => {
// Set up backup, and ignore requests to send room key requests
fetchMock.get("path:/_matrix/client/v3/room_keys/version", SIGNED_BACKUP_DATA);
fetchMock.get(
`express:/_matrix/client/v3/room_keys/keys/${encodeURIComponent(TEST_ROOM_ID)}`,
PER_ROOM_CURVE25519_KEY_BACKUP_DATA,
);
await aliceClient
.getCrypto()!
.storeSessionBackupPrivateKey(
Buffer.from(BACKUP_DECRYPTION_KEY_BASE64, "base64"),
SIGNED_BACKUP_DATA.version!,
);
await aliceClient.getCrypto()!.checkKeyBackupAndEnable();
// Alice is in an encrypted room.
const syncResponse = getSyncResponse([aliceClient.getSafeUserId()], HistoryVisibility.Shared, TEST_ROOM_ID);
aliceSyncResponder.sendOrQueueSyncResponse(syncResponse);
await syncPromise(aliceClient);
// Alice invites Bob, and shares the room history with them.
await assertInviteAndShareHistory(TEST_ROOM_ID);
// Bob receives, and should be able to decrypt, the historical message
const bobSyncResponse = getSyncResponse(
[aliceClient.getSafeUserId(), bobClient.getSafeUserId()],
HistoryVisibility.Shared,
TEST_ROOM_ID,
);
bobSyncResponse.rooms.join[TEST_ROOM_ID].timeline.events.push(ENCRYPTED_EVENT as IRoomEvent);
bobSyncResponder.sendOrQueueSyncResponse(bobSyncResponse);
await syncPromise(bobClient);
const bobRoom = bobClient.getRoom(TEST_ROOM_ID);
const event = bobRoom!.getLastLiveEvent()!;
expect(event.getId()).toEqual(ENCRYPTED_EVENT.event_id);
await event.getDecryptionPromise();
expect(event.getType()).toEqual("m.room.message");
expect(event.getContent().body).toEqual(CLEAR_EVENT.content!.body);
expect(event.getKeyForwardingUser()).toEqual(aliceClient.getUserId());
const encryptionInfo = await bobClient.getCrypto()!.getEncryptionInfoForEvent(event);
expect(encryptionInfo?.shieldColour).toEqual(EventShieldColour.GREY);
expect(encryptionInfo?.shieldReason).toEqual(EventShieldReason.AUTHENTICITY_NOT_GUARANTEED);
});
afterEach(async () => {
bobClient.stopClient();
aliceClient.stopClient();
await flushPromises();
});
/**
* Helper function to automatically test that room history is shared on invite.
* The function performs the following:
*
* 1. Sets up the relevant fetchMock and to-device event listeners for Alice.
* 2. Alice invites Bob to the room.
* 3. Checks the key bundle was uploaded and that the `m.room_key_bundle`
* to-device message was sent.
* 4. Sends the invite event to Bob and ensures it is processed correctly.
* 5. Sets up the relevant fetchMock listeners for Bob.
* 5. Simulates Bob joining the room and verifies that the room history is shared.
*
* @param roomId The ID of the room where the invite and history sharing will be tested.
*/
async function assertInviteAndShareHistory(roomId: string): Promise<void> {
const uploadProm = new Promise<Uint8Array>((resolve) => {
fetchMock.postOnce(new URL("/_matrix/media/v3/upload", ALICE_HOMESERVER_URL).toString(), (callLog) => {
const body = callLog.options.body as Uint8Array;
debug(`Alice uploaded blob of length ${body.length}`);
resolve(body);
return { content_uri: "mxc://alice-server/here" };
});
});
const toDeviceMessageProm = expectSendToDeviceMessage(ALICE_HOMESERVER_URL, "m.room.encrypted");
fetchMock.postOnce(`${ALICE_HOMESERVER_URL}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/invite`, {});
await aliceClient.invite(roomId, bobClient.getSafeUserId(), { shareEncryptedHistory: true });
const uploadedBlob = await uploadProm;
const sentToDeviceRequest = await toDeviceMessageProm;
debug(`Alice sent encrypted to-device events: ${JSON.stringify(sentToDeviceRequest)}`);
const bobToDeviceMessage = sentToDeviceRequest[bobClient.getSafeUserId()][bobClient.deviceId!];
expect(bobToDeviceMessage).toBeDefined();
const inviteEvent = mkEventCustom({
type: "m.room.member",
sender: aliceClient.getSafeUserId(),
state_key: bobClient.getSafeUserId(),
content: { membership: KnownMembership.Invite },
});
bobSyncResponder.sendOrQueueSyncResponse({
rooms: { invite: { [roomId]: { invite_state: { events: [inviteEvent] } } } },
to_device: {
events: [
{
type: "m.room.encrypted",
sender: aliceClient.getSafeUserId(),
content: bobToDeviceMessage,
},
],
},
});
await syncPromise(bobClient);
const room = bobClient.getRoom(roomId);
expect(room).toBeTruthy();
expect(room?.getMyMembership()).toEqual(KnownMembership.Invite);
fetchMock.postOnce(`${BOB_HOMESERVER_URL}/_matrix/client/v3/join/${encodeURIComponent(roomId)}`, {
room_id: roomId,
});
fetchMock.getOnce(`begin:${BOB_HOMESERVER_URL}/_matrix/client/v1/media/download/alice-server/here`, {
body: uploadedBlob,
});
await bobClient.joinRoom(roomId, { acceptSharedHistory: true });
}
});
function expectSendRoomEvent(homeserverUrl: string, msgtype: string): Promise<IContent> {
+8 -2
View File
@@ -23,7 +23,13 @@ import * as testUtils from "../../test-utils/test-utils";
import { getSyncResponse, syncPromise } from "../../test-utils/test-utils";
import { TEST_ROOM_ID as ROOM_ID } from "../../test-utils/test-data";
import { logger } from "../../../src/logger";
import { createClient, PendingEventOrdering, type IStartClientOpts, type MatrixClient } from "../../../src/matrix";
import {
createClient,
HistoryVisibility,
PendingEventOrdering,
type IStartClientOpts,
type MatrixClient,
} from "../../../src/matrix";
import { E2EKeyReceiver } from "../../test-utils/E2EKeyReceiver";
import { E2EKeyResponder } from "../../test-utils/E2EKeyResponder";
import { type ISyncResponder, SyncResponder } from "../../test-utils/SyncResponder";
@@ -197,7 +203,7 @@ describe("Encrypted State Events", () => {
await startClientAndAwaitFirstSync();
// Alice shares a room with Bob
syncResponder.sendOrQueueSyncResponse(getSyncResponse(["@bob:xyz"], ROOM_ID, true));
syncResponder.sendOrQueueSyncResponse(getSyncResponse(["@bob:xyz"], HistoryVisibility.Joined, ROOM_ID, true));
await syncPromise(aliceClient);
// ... and claim one of Bob's OTKs ...
+26 -17
View File
@@ -84,9 +84,9 @@ def main() -> None:
* Do not edit by hand! This file is generated by `./generate-test-data.py`
*/
import {{ IDeviceKeys, IMegolmSessionData }} from "../../../src/@types/crypto";
import {{ IDownloadKeyResult, IEvent }} from "../../../src";
import {{ KeyBackupSession, KeyBackupInfo }} from "../../../src/crypto-api/keybackup";
import type {{ IDeviceKeys, IMegolmSessionData }} from "../../../src/@types/crypto";
import type {{ IDownloadKeyResult, IEvent }} from "../../../src";
import type {{ KeyBackupSession, KeyBackupInfo, KeyBackupRoomSessions }} from "../../../src/crypto-api/keybackup";
/* eslint-disable comma-dangle */
@@ -246,15 +246,6 @@ export const {prefix}SIGNED_CROSS_SIGNING_KEYS_DATA: Partial<IDownloadKeyResult>
/** Signed OTKs, returned by `POST /keys/claim` */
export const {prefix}ONE_TIME_KEYS = { json.dumps(otks, indent=4) };
/** base64-encoded backup decryption (private) key */
export const {prefix}BACKUP_DECRYPTION_KEY_BASE64 = "{ user_data['B64_BACKUP_DECRYPTION_KEY'] }";
/** Backup decryption key in export format */
export const {prefix}BACKUP_DECRYPTION_KEY_BASE58 = "{ backup_recovery_key }";
/** Signed backup data, suitable for return from `GET /_matrix/client/v3/room_keys/keys/{{roomId}}/{{sessionId}}` */
export const {prefix}SIGNED_BACKUP_DATA: KeyBackupInfo = { json.dumps(backup_data, indent=4) };
/** A set of megolm keys that can be imported via CryptoAPI#importRoomKeys */
export const {prefix}MEGOLM_SESSION_DATA_ARRAY: IMegolmSessionData[] = {
json.dumps(set_of_exported_room_keys, indent=4)
@@ -278,6 +269,23 @@ export const {prefix}CLEAR_EVENT: Partial<IEvent> = {json.dumps(clear_event, ind
/** The encrypted CLEAR_EVENT by MEGOLM_SESSION_DATA */
export const {prefix}ENCRYPTED_EVENT: Partial<IEvent> = {json.dumps(encrypted_event, indent=4)};
/** base64-encoded backup decryption (private) key */
export const {prefix}BACKUP_DECRYPTION_KEY_BASE64 = "{ user_data['B64_BACKUP_DECRYPTION_KEY'] }";
/** Backup decryption key in export format */
export const {prefix}BACKUP_DECRYPTION_KEY_BASE58 = "{ backup_recovery_key }";
/** Signed backup data, suitable for return from `GET /_matrix/client/v3/room_keys/keys/{{roomId}}/{{sessionId}}` */
export const {prefix}SIGNED_BACKUP_DATA: KeyBackupInfo = { json.dumps(backup_data, indent=4) };
/**
* Per-room backup data, (supposedly) suitable for return from `GET /_matrix/client/v3/room_keys/keys/{{roomId}}`.
* Contains the key from {prefix}MEGOLM_SESSION_DATA.
*/
export const {prefix}PER_ROOM_CURVE25519_KEY_BACKUP_DATA: KeyBackupRoomSessions = {{
[{prefix}MEGOLM_SESSION_DATA.session_id]: {prefix}CURVE25519_KEY_BACKUP_DATA
}};
"""
alt_master_key = user_data.get("ALT_MASTER_CROSS_SIGNING_PRIVATE_KEY_BYTES")
@@ -385,7 +393,7 @@ def sign_json(json_object: dict, private_key: ed25519.Ed25519PrivateKey) -> str:
def build_exported_megolm_key(device_curve_key: x25519.X25519PrivateKey) -> tuple[dict, ed25519.Ed25519PrivateKey]:
"""
Creates an exported megolm room key, as per https://gitlab.matrix.org/matrix-org/olm/blob/master/docs/megolm.md#session-export-format
that can be imported via importRoomKeys API.
that can be imported via importRoomKeys API, or shared via MSC4268 room history sharing.
Returns the exported key, the matching privat edKey (needed to encrypt)
"""
index = 0
@@ -409,11 +417,12 @@ def build_exported_megolm_key(device_curve_key: x25519.X25519PrivateKey) -> tupl
"session_id": encode_base64(
private_key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
),
"session_key": encode_base64(exported_key),
"session_key": encode_base64(bytes(exported_key)),
"sender_claimed_keys": {
"ed25519": encode_base64(ed25519.Ed25519PrivateKey.from_private_bytes(randbytes(32)).public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)),
},
"forwarding_curve25519_key_chain": [],
"org.matrix.msc3061.shared_history": True,
}
return megolm_export, private_key
@@ -458,7 +467,7 @@ def symetric_ratchet_step_of_megolm_key(previous: dict , megolm_private_key: ed2
"room_id": "!room:id",
"sender_key": previous["sender_key"],
"session_id": previous["session_id"],
"session_key": encode_base64(exported_key),
"session_key": encode_base64(bytes(exported_key)),
"sender_claimed_keys": previous["sender_claimed_keys"],
"forwarding_curve25519_key_chain": [],
}
@@ -609,7 +618,7 @@ def generate_encrypted_event_content(exported_key: dict, ed_key: ed25519.Ed25519
message += signature
cipher_text = encode_base64(message)
cipher_text = encode_base64(bytes(message))
encrypted_payload = {
"algorithm" : "m.megolm.v1.aes-sha2",
@@ -653,7 +662,7 @@ def export_recovery_key(key_b64: str) -> str:
export_bytes += parity_byte.to_bytes(1, 'big')
# The byte string is encoded using base58
recovery_key = base58.b58encode(export_bytes).decode('utf-8')
recovery_key = base58.b58encode(bytes(export_bytes)).decode('utf-8')
split = [recovery_key[i:i + 4] for i in range(0, len(recovery_key), 4)]
return ' '.join(split)
+73 -51
View File
@@ -3,9 +3,9 @@
* Do not edit by hand! This file is generated by `./generate-test-data.py`
*/
import { type IDeviceKeys, type IMegolmSessionData } from "../../../src/@types/crypto";
import { type IDownloadKeyResult, type IEvent } from "../../../src";
import { type KeyBackupSession, type KeyBackupInfo } from "../../../src/crypto-api/keybackup";
import type { IDeviceKeys, IMegolmSessionData } from "../../../src/@types/crypto";
import type { IDownloadKeyResult, IEvent } from "../../../src";
import type { KeyBackupSession, KeyBackupInfo, KeyBackupRoomSessions } from "../../../src/crypto-api/keybackup";
/* eslint-disable comma-dangle */
@@ -118,26 +118,6 @@ export const ONE_TIME_KEYS = {
}
};
/** base64-encoded backup decryption (private) key */
export const BACKUP_DECRYPTION_KEY_BASE64 = "dwdtCnMYpX08FsFyUbJmRd9ML4frwJkqsXf7pR25LCo=";
/** Backup decryption key in export format */
export const BACKUP_DECRYPTION_KEY_BASE58 = "EsTc LW2K PGiF wKEA 3As5 g5c4 BXwk qeeJ ZJV8 Q9fu gUMN UE4d";
/** Signed backup data, suitable for return from `GET /_matrix/client/v3/room_keys/keys/{roomId}/{sessionId}` */
export const SIGNED_BACKUP_DATA: KeyBackupInfo = {
"algorithm": "m.megolm_backup.v1.curve25519-aes-sha2",
"version": "1",
"auth_data": {
"public_key": "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo",
"signatures": {
"@alice:localhost": {
"ed25519:test_device": "KDSNeumirTsd8piI0oVfv/wzg4J4HlEc7rs5XhODFcJ/YAcUdg65ajsZG+rLI0TQOSSGjorJqcrSiSB1HRSCAA"
}
}
}
};
/** A set of megolm keys that can be imported via CryptoAPI#importRoomKeys */
export const MEGOLM_SESSION_DATA_ARRAY: IMegolmSessionData[] = [
{
@@ -149,7 +129,8 @@ export const MEGOLM_SESSION_DATA_ARRAY: IMegolmSessionData[] = [
"sender_claimed_keys": {
"ed25519": "QdgHgdpDgihgovpPzUiThXur1fbErTFh7paFvNKSgN0"
},
"forwarding_curve25519_key_chain": []
"forwarding_curve25519_key_chain": [],
"org.matrix.msc3061.shared_history": true
},
{
"algorithm": "m.megolm.v1.aes-sha2",
@@ -160,7 +141,8 @@ export const MEGOLM_SESSION_DATA_ARRAY: IMegolmSessionData[] = [
"sender_claimed_keys": {
"ed25519": "IrkbT6H+0urDf6wKDSyVC1fh1t84Vz6T62snni86Cog"
},
"forwarding_curve25519_key_chain": []
"forwarding_curve25519_key_chain": [],
"org.matrix.msc3061.shared_history": true
}
];
@@ -174,7 +156,8 @@ export const MEGOLM_SESSION_DATA: IMegolmSessionData = {
"sender_claimed_keys": {
"ed25519": "Bhbpt6hqMZlSH4sJV7xiEEEiPVeTWz4Vkujl1EMdIPI"
},
"forwarding_curve25519_key_chain": []
"forwarding_curve25519_key_chain": [],
"org.matrix.msc3061.shared_history": true
};
/** A ratcheted version of MEGOLM_SESSION_DATA */
@@ -196,7 +179,7 @@ export const CURVE25519_KEY_BACKUP_DATA: KeyBackupSession = {
"forwarded_count": 0,
"is_verified": false,
"session_data": {
"ciphertext": "r6HRk2/Im2yJe5cLP8R81aVjFWjYWPHpw7TVxphiSK1cdIDZTTK57r6MfU+0i/mTPn+/PosT74OvYwCnehy2d1BPGxhDl8AhPcBu3//Kzlq2o5CssPsw+88gRehkAsPg9Zp5G9sL9to6giltvTWTbsaQpmvv3HLmBOYSFIxvyZrOT/Ffqu325f0IEsKcyV2BdIkw8Ob9Xt+VWoe4MYEGG6y1T8W125zeFgKWI4Ow76uput64H9zZjIo+Cc+hCTO9Ea4EnosSjizCotevkNck7C/zGgfhBikiohROb6SbaZgxicSsEDZ+f7brnri9yP3iXS3PMDHHpa1+XzG2VOG/Y9OQZpkPq+pbLrCC+NWJeJPslDAK5i+RURwzjnPmaHKCRHTq86CwhFyiCDf61MGwCY3xjrmBJg44BCdxWqCx0YJvwsvVqqnl4vTieUfrwThNPsQ81aVkDHvlmrgrTt8icDa8jTJhu34jem+pbRSEM5aJikV4B+zYiLz+dH/v6UpYA2eG8ReOvwpPXp6CAcIlplRPpWbMBeLFVcPkT4KAXTp9exFpB4on4pf8OsaDomlt4qAA0rhAZmhPWPKcU/A0Tz4gyMu54OivVtw1SPj+5Iq+YDQ8jB6Po3ApzMf6fwF9x/FjevbboFB05X2Jr0NrbFqXMOUwXHMgDAGiIWX8+gkmmbaiNWqg2etjN94pobQSGZelb18XGN7kuwMk+Zwk7A",
"ciphertext": "r6HRk2/Im2yJe5cLP8R81aVjFWjYWPHpw7TVxphiSK1cdIDZTTK57r6MfU+0i/mTPn+/PosT74OvYwCnehy2d/r0NTff1SQt+1GopZkT0nq6jF5Wh/oX+8iwtYjHvTxMpN1UQoXAvRF40O+EVg+Q3efJXh1t45cMco8EWU64VerOir+k7cQ3C9FtcgQw3kmz3s3HeVY10o13X/w6+rc8n6vXqxuIxYHnFxanxX8B6TgTMZNajNfVsmJV0aC1aezim7E2gsftc+6+zW5G+rCFaEsWV/IuSOUz0+Hh0U+7hzSrz9/4qXPEVmPy1f6Ll4hhquPAlXPVDwddqlJDYj7kmvzr1g3bKVpk+TtKDbWlVQDPaJx2DEI2jGkPYjhYb7okpTFKpUny94dZmFIQqCeSGPIniaq8Y+/CanugQ1ZRVQcThuXrTewqWhXcpVvkVHT9i4ImcpBl95HzCBXuiwSUv6FKvO25fp++w555rbn2piFtilrUwnkrZPW32jFuaQcKZF4mZwcLeH7POL5UCuS4TWyaKyArp7bRzXwWuIq1wPET2nAMUmUVL7ge2+tAevk1WOIsjLgSaz/g55wO3Yma7yhXRFKcnzTjS0hUQOZ3GfTNwCM4pjzAtIPzvVd4Fp0b1emWZS5WyOYdXsceEDi3c6WtkoHWOKhPU0zBzn8hA9TdlFFqKzf2QFbN5Zgg0gprDLnLWgpc3/ieI4C7ndEQ7ZeTNMXbT/Y10APFk3qO+IGkLXJ97/qTF41EXFDhlsL0",
"ephemeral": "q+P1WdRtEiPIEtNuuGrRcueZxUbLnSKdsuTAkxewXgU",
"mac": "OibmACbORhI"
}
@@ -229,6 +212,34 @@ export const ENCRYPTED_EVENT: Partial<IEvent> = {
"origin_server_ts": 1507753886000
};
/** base64-encoded backup decryption (private) key */
export const BACKUP_DECRYPTION_KEY_BASE64 = "dwdtCnMYpX08FsFyUbJmRd9ML4frwJkqsXf7pR25LCo=";
/** Backup decryption key in export format */
export const BACKUP_DECRYPTION_KEY_BASE58 = "EsTc LW2K PGiF wKEA 3As5 g5c4 BXwk qeeJ ZJV8 Q9fu gUMN UE4d";
/** Signed backup data, suitable for return from `GET /_matrix/client/v3/room_keys/keys/{roomId}/{sessionId}` */
export const SIGNED_BACKUP_DATA: KeyBackupInfo = {
"algorithm": "m.megolm_backup.v1.curve25519-aes-sha2",
"version": "1",
"auth_data": {
"public_key": "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo",
"signatures": {
"@alice:localhost": {
"ed25519:test_device": "KDSNeumirTsd8piI0oVfv/wzg4J4HlEc7rs5XhODFcJ/YAcUdg65ajsZG+rLI0TQOSSGjorJqcrSiSB1HRSCAA"
}
}
}
};
/**
* Per-room backup data, (supposedly) suitable for return from `GET /_matrix/client/v3/room_keys/keys/{roomId}`.
* Contains the key from MEGOLM_SESSION_DATA.
*/
export const PER_ROOM_CURVE25519_KEY_BACKUP_DATA: KeyBackupRoomSessions = {
[MEGOLM_SESSION_DATA.session_id]: CURVE25519_KEY_BACKUP_DATA
};
// Bob data
export const BOB_TEST_USER_ID = "@bob:xyz";
@@ -338,26 +349,6 @@ export const BOB_ONE_TIME_KEYS = {
}
};
/** base64-encoded backup decryption (private) key */
export const BOB_BACKUP_DECRYPTION_KEY_BASE64 = "DwdtCnMYpX08FsFyUbJmRd9ML4frwJkqsXf7pR25LCo=";
/** Backup decryption key in export format */
export const BOB_BACKUP_DECRYPTION_KEY_BASE58 = "EsT5 Sd5m mEXs NQYE ibRe 3q9E 4aXW rHih 5f9J 6rU6 AfwY mASR";
/** Signed backup data, suitable for return from `GET /_matrix/client/v3/room_keys/keys/{roomId}/{sessionId}` */
export const BOB_SIGNED_BACKUP_DATA: KeyBackupInfo = {
"algorithm": "m.megolm_backup.v1.curve25519-aes-sha2",
"version": "1",
"auth_data": {
"public_key": "ZRuVWcWlDuvOwZRygccUCD4Avtnt130800I+WQNwwRY",
"signatures": {
"@bob:xyz": {
"ed25519:bob_device": "lDIMj3VC0WazE2FamGHpmbiqKf9Z4pO4qapZ5TL5BnD3c+dvb+2waOEd6pgay/pmrQ6MW4Eu2KDEpe1fnHc3BA"
}
}
}
};
/** A set of megolm keys that can be imported via CryptoAPI#importRoomKeys */
export const BOB_MEGOLM_SESSION_DATA_ARRAY: IMegolmSessionData[] = [
{
@@ -369,7 +360,8 @@ export const BOB_MEGOLM_SESSION_DATA_ARRAY: IMegolmSessionData[] = [
"sender_claimed_keys": {
"ed25519": "F4P7f1Z0RjbiZMgHk1xBCG3KC4/Ng9PmxLJ4hQ13sHA"
},
"forwarding_curve25519_key_chain": []
"forwarding_curve25519_key_chain": [],
"org.matrix.msc3061.shared_history": true
},
{
"algorithm": "m.megolm.v1.aes-sha2",
@@ -380,7 +372,8 @@ export const BOB_MEGOLM_SESSION_DATA_ARRAY: IMegolmSessionData[] = [
"sender_claimed_keys": {
"ed25519": "OsZMdC1gQ5nPr+L9tuT6xXsaFJkVPkgxP2FexHF1/QM"
},
"forwarding_curve25519_key_chain": []
"forwarding_curve25519_key_chain": [],
"org.matrix.msc3061.shared_history": true
}
];
@@ -394,7 +387,8 @@ export const BOB_MEGOLM_SESSION_DATA: IMegolmSessionData = {
"sender_claimed_keys": {
"ed25519": "zBdpQwWYyz1MkZuEUhXqcdMfUNN/B9psLFDDDTJOg64"
},
"forwarding_curve25519_key_chain": []
"forwarding_curve25519_key_chain": [],
"org.matrix.msc3061.shared_history": true
};
/** A ratcheted version of BOB_MEGOLM_SESSION_DATA */
@@ -416,7 +410,7 @@ export const BOB_CURVE25519_KEY_BACKUP_DATA: KeyBackupSession = {
"forwarded_count": 0,
"is_verified": false,
"session_data": {
"ciphertext": "d7UVOK17WEVky/8hK0h3HsTQrFMEbKbfqMcl2KtyTWcI9S5gGFWK9Git5BzVRxRggvxQ0c8PDfqL+dr3zHytAMW+71BJqIPQW910vV7SX3IcGylnoUcS3doVkJZiprXytXMP89AKcgv5Dj7mS2ZdvNGE+Atro74bzZ5yot5BrE0ZE5SjoUBPLaLMMu9HopLIV+qx01Rc3F0wmkocSPo51N0nv6wvO5Cst0FiOGHDK6r1pFlgDEJLmBkOyC4e8oMVbKTJzsSQVbJ8tJ37xuhI+T5P0ZlmiqKDqYRp8uh50w+txLEixYhEUunFgCTt1DAmiS9pLNYhLyl1ggwuQjzZe+AV6timbRxNJy18/AEcPomJw7z/pxYIiNLHRKOC13Wp8kGWx9cOgfMQ5KmBuLS8psGiLTBkfWPLOfNYqjbeqAR+OGZQoS6hUjbBYU7QuFa4FOYBHkNB2UqNsdsMb9qB/qs7QGTSb8Lok5YjW1c81BUpmIyKvuqnKma0MZskrpTYGQD2eJDABFCZwLFm+LgDyUTeSiV5xguYztLrHOk8LHKo9M8dIZgoBjeFVJxyjbcXKsVS3aQkMXKCrRlKLqhZTws/ZJwVfW9DbktZ9dT+tRZQvI7tjJofojcLX61AGJDnqUf5+2Gv1tEnmUI953gIzc8NlcFabPOsDsZEODt7MdOCTPT3w29umyhKbCsslpb64LoS/AB2QRPRCgkJS7snRA",
"ciphertext": "d7UVOK17WEVky/8hK0h3HsTQrFMEbKbfqMcl2KtyTWcI9S5gGFWK9Git5BzVRxRggvxQ0c8PDfqL+dr3zHytAA7TEpHlx8Ks23hCqXmVW710VjqK2K9xnWCyJvkHfE8x0w6AYvffDj+tRVP8C8M7t4849rD2itn0uma+YMkvjG/nANUTxG1dBf3oUOZ673vflCPoaz7s7x9ZNhYDVSVH5JTdMgNwwN42R5dqqxnGTu516tJzJh/9BWvyD9oIPWJ8X0rt1sbzEJ3PZeBXcSy8GTlZ1SgSFjeiXlwYxOZCaX2sxprk4N1oI1db6g+wCDBhbCGGucJIlTDJna/h9/C5J4drGd/fkisG3SidUmJXXCyInhs/BhwjGAtTGeQS8j7R8UnJxhMulYBHSckzj0Kas71LElPp8W8M4Jq81APA03n5UfYB+U6jbxjDgf8OJnxGQyrteq9F2+SEvS/TwHe1pE3t6EM2mDYRoYDTpU5pTNYSJkGIQMfWJKRxxuWUGs29o1twewJ6dhHgm+SlCII0M7ESoVdV54vxZCvHZnPcR0NXDzal7ils7zBKJmamHfPQBuaqNPU3KmSo+5R8ngFPaWU5LbWqYp/WxSBfNCoLZ7Jf8Io5uitjXTATR2qy2r6l/RJmk3RlfP51kliQqI2TWqRF96oaB96IGgUGSFCX/2pv0psOBGc1SjfmMB3d7gYis+2iBYVbG3xmnpeXbqvlD0Lw9TiTIPkjhJkTW1+lXyhy1xVH9ZmcFamcL7bX15Jx",
"ephemeral": "oO0VX84OUIzm2i/12zAhTWOZT5IFRH5mXaKZ8fXkCgU",
"mac": "lEfHlqfJQwU"
}
@@ -449,6 +443,34 @@ export const BOB_ENCRYPTED_EVENT: Partial<IEvent> = {
"origin_server_ts": 1507753886000
};
/** base64-encoded backup decryption (private) key */
export const BOB_BACKUP_DECRYPTION_KEY_BASE64 = "DwdtCnMYpX08FsFyUbJmRd9ML4frwJkqsXf7pR25LCo=";
/** Backup decryption key in export format */
export const BOB_BACKUP_DECRYPTION_KEY_BASE58 = "EsT5 Sd5m mEXs NQYE ibRe 3q9E 4aXW rHih 5f9J 6rU6 AfwY mASR";
/** Signed backup data, suitable for return from `GET /_matrix/client/v3/room_keys/keys/{roomId}/{sessionId}` */
export const BOB_SIGNED_BACKUP_DATA: KeyBackupInfo = {
"algorithm": "m.megolm_backup.v1.curve25519-aes-sha2",
"version": "1",
"auth_data": {
"public_key": "ZRuVWcWlDuvOwZRygccUCD4Avtnt130800I+WQNwwRY",
"signatures": {
"@bob:xyz": {
"ed25519:bob_device": "lDIMj3VC0WazE2FamGHpmbiqKf9Z4pO4qapZ5TL5BnD3c+dvb+2waOEd6pgay/pmrQ6MW4Eu2KDEpe1fnHc3BA"
}
}
}
};
/**
* Per-room backup data, (supposedly) suitable for return from `GET /_matrix/client/v3/room_keys/keys/{roomId}`.
* Contains the key from BOB_MEGOLM_SESSION_DATA.
*/
export const BOB_PER_ROOM_CURVE25519_KEY_BACKUP_DATA: KeyBackupRoomSessions = {
[BOB_MEGOLM_SESSION_DATA.session_id]: BOB_CURVE25519_KEY_BACKUP_DATA
};
/** A second set of signed cross-signing keys data, also suitable for returning from a `/keys/query` call */
export const BOB_ALT_SIGNED_CROSS_SIGNING_KEYS_DATA: Partial<IDownloadKeyResult> = {
"master_keys": {
+18 -4
View File
@@ -13,6 +13,7 @@ import {
import {
ClientEvent,
EventType,
HistoryVisibility,
type IJoinedRoom,
type IPusher,
type ISyncResponse,
@@ -57,14 +58,19 @@ export function syncPromise(client: MatrixClient, count = 1): Promise<void> {
}
/**
* Return a sync response which contains a single room (by default TEST_ROOM_ID), with the members given
* @param roomMembers
* @param roomId
* Return a sync response which contains a single room (by default `TEST_ROOM_ID`), with the members given
* and history visibility set to `shared`.
*
* @returns the sync response
* @param roomMembers - An array of user IDs representing the members of the room.
* @param roomHistoryVisibility - The history visibility setting for the room. Defaults to `shared`.
* @param roomId - The ID of the room. Defaults to `TEST_ROOM_ID`.
* @param encryptStateEvents - A boolean indicating whether state events should be encrypted. Defaults to `false`.
*
* @returns The sync response object containing the room data.
*/
export function getSyncResponse(
roomMembers: string[],
roomHistoryVisibility: HistoryVisibility = HistoryVisibility.Shared,
roomId = TEST_ROOM_ID,
encryptStateEvents = false,
): ISyncResponse {
@@ -85,6 +91,14 @@ export function getSyncResponse(
"io.element.msc4362.encrypt_state_events": encryptStateEvents,
},
}),
mkEventCustom({
sender: roomMembers[0],
type: "m.room.history_visibility",
state_key: "",
content: {
history_visibility: roomHistoryVisibility,
},
}),
],
},
timeline: {
+1 -1
View File
@@ -32,7 +32,7 @@ describe("NamespacedValue", () => {
});
it("should have a falsey unstable if needed", () => {
const ns = new NamespacedValue("stable");
const ns = new NamespacedValue("stable", null);
expect(ns.name).toBe(ns.stable);
expect(ns.altName).toBeFalsy();
expect(ns.names).toEqual([ns.stable]);
+171 -1
View File
@@ -32,7 +32,14 @@ import {
type IRoomEvent,
} from "matrix-widget-api";
import { createRoomWidgetClient, MatrixError, MsgType, UpdateDelayedEventAction } from "../../src/matrix";
import {
createRoomWidgetClient,
EventType,
type IEvent,
MatrixError,
MsgType,
UpdateDelayedEventAction,
} from "../../src/matrix";
import { MatrixClient, ClientEvent, type ITurnServer as IClientTurnServer } from "../../src/client";
import { SyncState } from "../../src/sync";
import { type ICapabilities, type RoomWidgetClient } from "../../src/embedded";
@@ -42,6 +49,7 @@ import { sleep } from "../../src/utils";
import { SlidingSync } from "../../src/sliding-sync";
import { logger } from "../../src/logger";
import { flushPromises } from "../test-utils/flushPromises";
import { RoomStickyEventsEvent, type RoomStickyEventsMap } from "../../src/models/room-sticky-events";
const testOIDCToken = {
access_token: "12345678",
@@ -49,6 +57,7 @@ const testOIDCToken = {
matrix_server_name: "homeserver.oabc",
token_type: "Bearer",
};
class MockWidgetApi extends EventEmitter {
public start = vi.fn().mockResolvedValue(undefined);
public getClientVersions = vi.fn();
@@ -167,6 +176,9 @@ describe("RoomWidgetClient", () => {
"org.matrix.rageshake_request",
{ request_id: 123 },
"!1:example.org",
undefined,
undefined,
undefined,
);
});
@@ -422,6 +434,7 @@ describe("RoomWidgetClient", () => {
"!1:example.org",
2000,
undefined,
undefined,
);
});
@@ -442,6 +455,7 @@ describe("RoomWidgetClient", () => {
"!1:example.org",
undefined,
parentDelayId,
undefined,
);
});
@@ -855,6 +869,162 @@ describe("RoomWidgetClient", () => {
});
});
describe("sticky events", () => {
describe("when supported", () => {
const doesServerSupportUnstableFeatureMock = vi.fn((feature) =>
Promise.resolve(feature === "org.matrix.msc4354"),
);
beforeAll(() => {
MatrixClient.prototype.doesServerSupportUnstableFeature = doesServerSupportUnstableFeatureMock;
});
afterAll(() => {
doesServerSupportUnstableFeatureMock.mockReset();
});
it("requests capabilities when set", async () => {
await makeClient({ sendSticky: true, receiveSticky: true });
expect(widgetApi.requestCapability).toHaveBeenCalledWith(MatrixCapabilities.MSC4407SendStickyEvent);
expect(widgetApi.requestCapability).toHaveBeenCalledWith(MatrixCapabilities.MSC4407ReceiveStickyEvent);
});
it("does not request capabilities when unset", async () => {
await makeClient({});
expect(widgetApi.requestCapability).not.toHaveBeenCalledWith(MatrixCapabilities.MSC4407SendStickyEvent);
expect(widgetApi.requestCapability).not.toHaveBeenCalledWith(
MatrixCapabilities.MSC4407ReceiveStickyEvent,
);
});
it("sends", async () => {
await makeClient({ sendEvent: [EventType.RTCMembership], sendSticky: true });
expect(widgetApi.requestCapabilityForRoomTimeline).toHaveBeenCalledWith("!1:example.org");
expect(widgetApi.requestCapabilityToSendEvent).toHaveBeenCalledWith(EventType.RTCMembership);
expect(widgetApi.requestCapability).toHaveBeenCalledWith(MatrixCapabilities.MSC4407SendStickyEvent);
await client._unstable_sendStickyEvent("!1:example.org", 2000, null, EventType.RTCMembership, {
msc4354_sticky_key: "test",
});
expect(widgetApi.sendRoomEvent).toHaveBeenCalledWith(
EventType.RTCMembership,
{ msc4354_sticky_key: "test" },
"!1:example.org",
undefined,
undefined,
2000,
);
});
it("receives (adds, updates, then removes when redacted)", async () => {
await makeClient({ receiveEvent: [EventType.RTCMembership, EventType.RoomRedaction] });
const room = client.getRoom("!1:example.org")!;
function expectStickyEvents(events: IEvent[]) {
expect([...room._unstable_getStickyEvents()].map((e) => e.getEffectiveEvent())).toEqual(events);
}
async function sendAndExpectStickyUpdate(
eventToSend: IEvent,
added: IEvent[],
updated: { current: IEvent; previous: IEvent }[],
removed: IEvent[],
) {
const emittedStickyUpdate = new Promise<
Parameters<RoomStickyEventsMap[RoomStickyEventsEvent.Update]>
>((resolve) => room.once(RoomStickyEventsEvent.Update, (...args) => resolve(args)));
widgetApi.emit(
`action:${WidgetApiToWidgetAction.SendEvent}`,
new CustomEvent(`action:${WidgetApiToWidgetAction.SendEvent}`, {
detail: { data: eventToSend },
}),
);
const [addedReceived, updatedReceived, removedReceived] = await emittedStickyUpdate;
expect(addedReceived.map((e) => e.getEffectiveEvent())).toEqual(added);
expect(
updatedReceived.map(({ current, previous }) => ({
current: current.getEffectiveEvent(),
previous: previous.getEffectiveEvent(),
})),
).toEqual(updated);
expect(removedReceived.map((e) => e.getEffectiveEvent())).toEqual(removed);
}
// First, add a new sticky event to the map. The client should emit.
const event1 = new MatrixEvent({
type: EventType.RTCMembership,
event_id: "$pduhfiidph",
room_id: "!1:example.org",
sender: "@alice:example.org",
msc4354_sticky: { duration_ms: 1200000 },
content: { msc4354_sticky_key: "test" },
}).getEffectiveEvent();
await sendAndExpectStickyUpdate(event1, [event1], [], []);
// It should remain cached in the sticky map
expectStickyEvents([event1]);
// Next, update the same key in the sticky map
const event2 = new MatrixEvent({
type: EventType.RTCMembership,
event_id: "$zshgyutptfh",
room_id: "!1:example.org",
sender: "@alice:example.org",
msc4354_sticky: { duration_ms: 1200000 },
content: { msc4354_sticky_key: "test" },
}).getEffectiveEvent();
await sendAndExpectStickyUpdate(event2, [], [{ current: event2, previous: event1 }], []);
expectStickyEvents([event2]);
// Next, redact the second event. Because it has the first as a predecessor, the map should revert to
// the first event.
const redaction1 = new MatrixEvent({
type: EventType.RoomRedaction,
event_id: "$cimoexnvz",
room_id: "!1:example.org",
sender: "@alice:example.org",
redacts: event2.event_id,
content: { redacts: event2.event_id },
}).getEffectiveEvent();
await sendAndExpectStickyUpdate(redaction1, [], [{ current: event1, previous: event2 }], []);
expectStickyEvents([event1]);
// Finally, redact the first event. Now everything should be gone from the map.
const redaction2 = new MatrixEvent({
type: EventType.RoomRedaction,
event_id: "$drgzmenlh",
room_id: "!1:example.org",
sender: "@alice:example.org",
redacts: event1.event_id,
content: { redacts: event1.event_id },
}).getEffectiveEvent();
await sendAndExpectStickyUpdate(redaction2, [], [], [event1]);
expectStickyEvents([]);
});
});
describe("when unsupported", () => {
const doesServerSupportUnstableFeatureMock = vi.fn().mockResolvedValue(false);
beforeAll(() => {
MatrixClient.prototype.doesServerSupportUnstableFeature = doesServerSupportUnstableFeatureMock;
});
afterAll(() => {
doesServerSupportUnstableFeatureMock.mockReset();
});
it("fails to send", async () => {
await makeClient({ sendEvent: [EventType.RTCMembership], sendSticky: true });
await expect(
client._unstable_sendStickyEvent("!1:example.org", 2000, null, EventType.RTCMembership, {
msc4354_sticky_key: "test",
}),
).rejects.toThrow("Server does not support");
});
});
});
describe("to-device messages", () => {
const unencryptedContentMap = new Map([
["@alice:example.org", new Map([["*", { hello: "alice!" }]])],
+3
View File
@@ -51,18 +51,21 @@ describe("SSO login URL", function () {
const urlString = client.client.getSsoLoginUrl(redirectUri, undefined, undefined, undefined);
const url = new URL(urlString);
expect(url.searchParams.has("org.matrix.msc3824.action")).toBe(false);
expect(url.searchParams.has("action")).toBe(false);
});
it("register", function () {
const urlString = client.client.getSsoLoginUrl(redirectUri, undefined, undefined, SSOAction.REGISTER);
const url = new URL(urlString);
expect(url.searchParams.get("org.matrix.msc3824.action")).toEqual("register");
expect(url.searchParams.get("action")).toEqual("register");
});
it("login", function () {
const urlString = client.client.getSsoLoginUrl(redirectUri, undefined, undefined, SSOAction.LOGIN);
const url = new URL(urlString);
expect(url.searchParams.get("org.matrix.msc3824.action")).toEqual("login");
expect(url.searchParams.get("action")).toEqual("login");
});
});
});
+22 -1
View File
@@ -88,7 +88,7 @@ import { mockOpenIdConfiguration } from "../test-utils/oidc.ts";
import { type CryptoBackend } from "../../src/common-crypto/CryptoBackend";
import { SyncResponder } from "../test-utils/SyncResponder.ts";
import { mockInitialApiRequests } from "../test-utils/mockEndpoints.ts";
import { type Transport } from "src/matrixrtc/index.ts";
import { type Transport } from "../../src/matrixrtc/index.ts";
vi.useFakeTimers();
@@ -3877,6 +3877,27 @@ describe("MatrixClient", function () {
makeClient();
});
it("should use stable prefix", async () => {
const metadata = mockOpenIdConfiguration();
client.getVersions = vi.fn().mockResolvedValue({
versions: ["v1.15"],
});
httpLookups = [
{
method: "GET",
path: `/auth_metadata`,
data: metadata,
prefix: "/_matrix/client/v1",
},
];
await expect(client.getAuthMetadata()).resolves.toEqual({
...metadata,
signingKeys: [],
});
expect(httpLookups.length).toEqual(0);
});
it("should use unstable prefix", async () => {
const metadata = mockOpenIdConfiguration();
httpLookups = [
+29 -2
View File
@@ -158,8 +158,35 @@ describe("CallMembership", () => {
expect(membership.eventId).toBe("$eventid");
});
it("returns correct slot_id", () => {
expect(membership.slotId).toBe("m.call#");
expect(membership.slotDescription).toStrictEqual({ id: "", application: "m.call" });
// slot_id is application and call_id dependent. So we create
// a membership for each possible combination
// non call application (should not alter call_id even with empty string)
const nonCallMembership = createCallMembership(makeMockEvent(), {
...membershipTemplate,
application: "m.not.a.call",
call_id: "",
});
// non "" call id should not be altered
const callMembershipCustomId = createCallMembership(makeMockEvent(), {
...membershipTemplate,
call_id: "customCallId",
});
// for membership (application = m.call and call_id = "") we expect "" -> ROOM
// for legacy events we expect the room to be added automagically
// See INFO_SLOT_ID_LEGACY_CASE comments
expect(membership.slotId).toBe("m.call#ROOM");
expect(membership.slotDescription).toStrictEqual({ id: "ROOM", application: "m.call" });
expect(nonCallMembership.slotId).toBe("m.not.a.call#");
expect(nonCallMembership.slotDescription).toStrictEqual({ id: "", application: "m.not.a.call" });
expect(callMembershipCustomId.slotId).toBe("m.call#customCallId");
expect(callMembershipCustomId.slotDescription).toStrictEqual({
id: "customCallId",
application: "m.call",
});
});
it("returns correct deviceId", () => {
expect(membership.deviceId).toBe("AAAAAAA");
+32 -68
View File
@@ -49,7 +49,7 @@ const mockFocus = { type: "mock" };
const textEncoder = new TextEncoder();
const callSession = { id: "", application: "m.call" };
const callSession = { id: "ROOM", application: "m.call" };
describe("MatrixRTCSession", () => {
let client: MatrixClient;
@@ -134,12 +134,12 @@ describe("MatrixRTCSession", () => {
);
await flushPromises();
expect(sess?.memberships.length).toEqual(1);
expect(sess?.memberships[0].slotDescription.id).toEqual("");
expect(sess?.memberships[0].slotDescription.id).toEqual("ROOM");
expect(sess?.memberships[0].scope).toEqual("m.room");
expect(sess?.memberships[0].application).toEqual("m.call");
expect(sess?.memberships[0].deviceId).toEqual("AAAAAAA");
expect(sess?.memberships[0].isExpired()).toEqual(false);
expect(sess?.slotDescription.id).toEqual("");
expect(sess?.slotDescription.id).toEqual("ROOM");
});
it("ignores memberships where application is not m.call", () => {
@@ -381,12 +381,12 @@ describe("MatrixRTCSession", () => {
});
await flushPromises();
expect(sess?.memberships.length).toEqual(1);
expect(sess?.memberships[0].slotDescription.id).toEqual("");
expect(sess?.memberships[0].slotDescription.id).toEqual("ROOM");
expect(sess?.memberships[0].scope).toEqual("m.room");
expect(sess?.memberships[0].application).toEqual("m.call");
expect(sess?.memberships[0].deviceId).toEqual("AAAAAAA");
expect(sess?.memberships[0].isExpired()).toEqual(false);
expect(sess?.slotDescription.id).toEqual("");
expect(sess?.slotDescription.id).toEqual("ROOM");
});
it("combines sticky and membership events when both exist", async () => {
// Create a room with identical member state and sticky state for the same user.
@@ -415,7 +415,7 @@ describe("MatrixRTCSession", () => {
const memberships = sess.memberships;
expect(memberships.length).toEqual(2);
expect(memberships[0].sender).toEqual(stickyUserId);
expect(memberships[0].slotDescription.id).toEqual("");
expect(memberships[0].slotDescription.id).toEqual("ROOM");
expect(memberships[0].scope).toEqual("m.room");
expect(memberships[0].application).toEqual("m.call");
expect(memberships[0].deviceId).toEqual("AAAAAAA");
@@ -424,7 +424,7 @@ describe("MatrixRTCSession", () => {
// Then state
expect(memberships[1].sender).toEqual(membershipTemplate.user_id);
expect(sess?.slotDescription.id).toEqual("");
expect(sess?.slotDescription.id).toEqual("ROOM");
});
it("handles an incoming sticky event to an existing session", async () => {
const mockRoom = makeMockRoom([membershipTemplate]);
@@ -620,9 +620,7 @@ describe("MatrixRTCSession", () => {
it("sends a notification when starting a call and emit DidSendCallNotification", async () => {
// Simulate a join, including the update to the room state
// Ensure sendEvent returns event IDs so the DidSendCallNotification payload includes them
sendEventMock
.mockResolvedValueOnce({ event_id: "legacy-evt" })
.mockResolvedValueOnce({ event_id: "new-evt" });
sendEventMock.mockResolvedValueOnce({ event_id: "new-evt" });
const didSendEventFn = vi.fn();
sess!.once(MatrixRTCSessionEvent.DidSendCallNotification, didSendEventFn);
// Create an additional listener to create a promise that resolves after the emission.
@@ -647,43 +645,25 @@ describe("MatrixRTCSession", () => {
"sender_ts": expect.any(Number),
});
// Check if deprecated notify event is also sent.
expect(client.sendEvent).toHaveBeenCalledWith(mockRoom!.roomId, EventType.CallNotify, {
"application": "m.call",
"m.mentions": { user_ids: [], room: true },
"notify_type": "ring",
"call_id": "",
});
await didSendNotification;
// And ensure we emitted the DidSendCallNotification event with both payloads
expect(didSendEventFn).toHaveBeenCalledWith(
{
"event_id": "new-evt",
"lifetime": 30000,
"m.mentions": { room: true, user_ids: [] },
"m.relates_to": {
event_id: expect.any(String),
rel_type: "m.reference",
},
"notification_type": "ring",
"sender_ts": expect.any(Number),
expect(didSendEventFn).toHaveBeenCalledWith({
"event_id": "new-evt",
"lifetime": 30000,
"m.mentions": { room: true, user_ids: [] },
"m.relates_to": {
event_id: expect.any(String),
rel_type: "m.reference",
},
{
"application": "m.call",
"call_id": "",
"event_id": "legacy-evt",
"m.mentions": { room: true, user_ids: [] },
"notify_type": "ring",
},
);
"notification_type": "ring",
"sender_ts": expect.any(Number),
});
});
it("sends a notification with a intent when starting a call and emits DidSendCallNotification", async () => {
// Simulate a join, including the update to the room state
// Ensure sendEvent returns event IDs so the DidSendCallNotification payload includes them
sendEventMock
.mockResolvedValueOnce({ event_id: "legacy-evt" })
.mockResolvedValueOnce({ event_id: "new-evt" });
sendEventMock.mockResolvedValueOnce({ event_id: "new-evt" });
const didSendEventFn = vi.fn();
sess!.once(MatrixRTCSessionEvent.DidSendCallNotification, didSendEventFn);
// Create an additional listener to create a promise that resolves after the emission.
@@ -707,7 +687,7 @@ describe("MatrixRTCSession", () => {
]);
await sess!._onRTCSessionMemberUpdate();
const ownMembershipId = sess?.memberships[0].eventId;
const ownMembershipEventId = sess?.memberships[0].eventId;
expect(sess!.getConsensusCallIntent()).toEqual("audio");
expect(client.sendEvent).toHaveBeenCalledWith(mockRoom!.roomId, EventType.RTCNotification, {
@@ -715,43 +695,27 @@ describe("MatrixRTCSession", () => {
"notification_type": "ring",
"m.call.intent": "audio",
"m.relates_to": {
event_id: ownMembershipId,
event_id: ownMembershipEventId,
rel_type: "m.reference",
},
"lifetime": 30000,
"sender_ts": expect.any(Number),
});
// Check if deprecated notify event is also sent.
expect(client.sendEvent).toHaveBeenCalledWith(mockRoom!.roomId, EventType.CallNotify, {
"application": "m.call",
"m.mentions": { user_ids: [], room: true },
"notify_type": "ring",
"call_id": "",
});
await didSendNotification;
// And ensure we emitted the DidSendCallNotification event with both payloads
expect(didSendEventFn).toHaveBeenCalledWith(
{
"event_id": "new-evt",
"lifetime": 30000,
"m.mentions": { room: true, user_ids: [] },
"m.relates_to": {
event_id: expect.any(String),
rel_type: "m.reference",
},
"notification_type": "ring",
"m.call.intent": "audio",
"sender_ts": expect.any(Number),
expect(didSendEventFn).toHaveBeenCalledWith({
"event_id": "new-evt",
"lifetime": 30000,
"m.mentions": { room: true, user_ids: [] },
"m.relates_to": {
event_id: expect.any(String),
rel_type: "m.reference",
},
{
"application": "m.call",
"call_id": "",
"event_id": "legacy-evt",
"m.mentions": { room: true, user_ids: [] },
"notify_type": "ring",
},
);
"notification_type": "ring",
"m.call.intent": "audio",
"sender_ts": expect.any(Number),
});
});
it("doesn't send a notification when joining an existing call", async () => {
+67 -4
View File
@@ -71,7 +71,7 @@ function createAsyncHandle<T>(method: MockedFunction<(...args: any[]) => any>) {
return { reject, resolve };
}
const callSession = { id: "", application: "m.call" };
const callSession = { id: "ROOM", application: "m.call" };
describe("MembershipManager", () => {
let client: MockClient;
@@ -139,6 +139,7 @@ describe("MembershipManager", () => {
"org.matrix.msc3401.call.member",
{
application: "m.call",
// This tests INFO_SLOT_ID_LEGACY_CASE because it is using callSession = { id: "ROOM", application: "m.call" }
call_id: "",
device_id: "AAAAAAA",
expires: 14400000,
@@ -147,6 +148,7 @@ describe("MembershipManager", () => {
focus_active: focusActive,
scope: "m.room",
},
// This tests INFO_SLOT_ID_LEGACY_CASE because it is using callSession = { id: "ROOM", application: "m.call" }
"_@alice:example.org_AAAAAAA_m.call",
);
restartScheduledDelayedEventHandle.resolve?.();
@@ -160,6 +162,45 @@ describe("MembershipManager", () => {
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1);
});
it("sends correct call_id and state key when using non empty string. Not using empty string -> ROOM hack. See: INFO_SLOT_ID_LEGACY_CASE", async () => {
// Spys/Mocks
const customCallSession = { id: "custom", application: "m.call" };
const restartScheduledDelayedEventHandle = createAsyncHandle<void>(
client._unstable_restartScheduledDelayedEvent,
);
// Test
const memberManager = new MembershipManager(undefined, room, client, customCallSession);
memberManager.join([focus], undefined);
// expects
await waitForMockCall(client.sendStateEvent, Promise.resolve({ event_id: "id" }));
expect(client.sendStateEvent).toHaveBeenCalledWith(
room.roomId,
"org.matrix.msc3401.call.member",
{
application: "m.call",
call_id: "custom",
device_id: "AAAAAAA",
expires: 14400000,
foci_preferred: [focus],
membershipID: "@alice:example.org:AAAAAAA",
focus_active: focusActive,
scope: "m.room",
},
"_@alice:example.org_AAAAAAA_m.callcustom",
);
restartScheduledDelayedEventHandle.resolve?.();
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledWith(
room.roomId,
{ delay: 8000 },
"org.matrix.msc3401.call.member",
{},
"_@alice:example.org_AAAAAAA_m.callcustom",
);
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1);
});
it("reschedules delayed leave event if sending state cancels it", async () => {
const memberManager = new MembershipManager(undefined, room, client, callSession);
const waitForSendState = waitForMockCall(client.sendStateEvent);
@@ -416,8 +457,9 @@ describe("MembershipManager", () => {
await manager.leave();
expect(client._unstable_sendScheduledDelayedEvent).toHaveBeenLastCalledWith("id");
expect(client.sendStateEvent).toHaveBeenCalled();
expect(manager.delayId).toBe(undefined);
});
it("send leave event when leave is called and resolving delayed leave fails", async () => {
it("send leave event when leave is called and resolving delayed leave fails unknown error", async () => {
const manager = new MembershipManager({}, room, client, callSession);
manager.join([focus]);
await vi.advanceTimersByTimeAsync(1);
@@ -431,6 +473,27 @@ describe("MembershipManager", () => {
{},
"_@alice:example.org_AAAAAAA_m.call",
);
// If there is a unknown error, we do not reset the delayId
// The delayed event might still be around and we track it.
expect(manager.delayId).not.toBe(undefined);
});
it("send leave event when leave is called and resolving delayed leave fails not found error", async () => {
const manager = new MembershipManager({}, room, client, callSession);
manager.join([focus]);
await vi.advanceTimersByTimeAsync(1);
(client._unstable_sendScheduledDelayedEvent as Mock<any>).mockRejectedValue(
new MatrixError({ errcode: "M_NOT_FOUND" }, 404),
);
await manager.leave();
// We send a normal leave event since we failed using sendScheduledDelayedEvent.
expect(client.sendStateEvent).toHaveBeenLastCalledWith(
room.roomId,
"org.matrix.msc3401.call.member",
{},
"_@alice:example.org_AAAAAAA_m.call",
);
expect(manager.delayId).toBe(undefined);
});
it("does nothing if not joined", async () => {
const manager = new MembershipManager({}, room, client, callSession);
@@ -947,8 +1010,8 @@ describe("MembershipManager", () => {
id: "@alice:example.org:AAAAAAA_m.call",
device_id: "AAAAAAA",
},
slot_id: "m.call#",
rtc_transports: [focus],
slot_id: "m.call#ROOM",
rtc_transports: [{ type: focus.type, livekit_service_url: focus.livekit_service_url }],
versions: [],
msc4354_sticky_key: "@alice:example.org:AAAAAAA_m.call",
},
+1 -1
View File
@@ -20,7 +20,7 @@ import { type Mocked, type MockedObject } from "vitest";
import { EventType, type Room, RoomEvent, type MatrixClient, type MatrixEvent } from "../../../src";
import { CallMembership, type SessionMembershipData } from "../../../src/matrixrtc";
import { secureRandomString } from "../../../src/randomstring";
import { type CallMembershipIdentityParts } from "src/matrixrtc/EncryptionManager";
import { type CallMembershipIdentityParts } from "../../../src/matrixrtc/EncryptionManager";
import { logger } from "../../../src/logger.ts";
export type MembershipData = (SessionMembershipData | {}) & { user_id: string };
+102
View File
@@ -275,6 +275,108 @@ describe("Relations", function () {
expect(badlyEditedTopic.getContent().topic).toBe("topic");
});
describe("m.replace async ordering", () => {
const userId = "@bob:example.com";
const roomId = "!room:example.com";
const targetEventId = "$target";
function makeEditEvent(eventId: string, ts: number): MatrixEvent {
return new MatrixEvent({
sender: userId,
type: "m.room.message",
event_id: eventId,
room_id: roomId,
origin_server_ts: ts,
content: {
"body": `edited ${eventId}`,
"msgtype": "m.text",
"m.new_content": {
body: `edited ${eventId}`,
msgtype: "m.text",
},
"m.relates_to": {
event_id: targetEventId,
rel_type: "m.replace",
},
},
});
}
it("should not let a slow-decrypting older edit overwrite a newer one", async () => {
const room = new Room(roomId, new TestClient(userId).client, userId);
const relations = new Relations("m.replace", "m.room.message", room);
const targetEvent = new MatrixEvent({
sender: userId,
type: "m.room.message",
event_id: targetEventId,
room_id: roomId,
origin_server_ts: 1000,
content: { body: "original", msgtype: "m.text" },
});
await relations.setTargetEvent(targetEvent);
// Create two edits: edit1 is older (ts=2000), edit2 is newer (ts=3000).
const edit1 = makeEditEvent("$edit1", 2000);
const edit2 = makeEditEvent("$edit2", 3000);
// Simulate edit1 being in the process of decryption: isBeingDecrypted()
// returns true and getDecryptionPromise() returns a deferred promise.
let resolveEdit1Decryption!: () => void;
const edit1DecryptionPromise = new Promise<void>((resolve) => {
resolveEdit1Decryption = resolve;
});
vi.spyOn(edit1, "isBeingDecrypted").mockReturnValue(true);
vi.spyOn(edit1, "getDecryptionPromise").mockReturnValue(edit1DecryptionPromise);
vi.spyOn(edit1, "shouldAttemptDecryption").mockReturnValue(false);
// edit2 is already decrypted.
vi.spyOn(edit2, "isBeingDecrypted").mockReturnValue(false);
vi.spyOn(edit2, "shouldAttemptDecryption").mockReturnValue(false);
// Add edit1 first (it will block on decryption).
const addEdit1Promise = relations.addEvent(edit1);
// While edit1 is still decrypting, add edit2 (resolves immediately).
await relations.addEvent(edit2);
// edit2 should be applied as the replacement (it's newer).
expect(targetEvent.replacingEvent()).toBe(edit2);
// Now resolve edit1's decryption — the stale result must NOT overwrite edit2.
resolveEdit1Decryption();
await addEdit1Promise;
// edit2 must still be the replacing event, not edit1.
expect(targetEvent.replacingEvent()).toBe(edit2);
});
it("should apply an edit correctly when there is no concurrency", async () => {
const room = new Room(roomId, new TestClient(userId).client, userId);
const relations = new Relations("m.replace", "m.room.message", room);
const targetEvent = new MatrixEvent({
sender: userId,
type: "m.room.message",
event_id: targetEventId,
room_id: roomId,
origin_server_ts: 1000,
content: { body: "original", msgtype: "m.text" },
});
await relations.setTargetEvent(targetEvent);
const edit = makeEditEvent("$edit1", 2000);
vi.spyOn(edit, "isBeingDecrypted").mockReturnValue(false);
vi.spyOn(edit, "shouldAttemptDecryption").mockReturnValue(false);
await relations.addEvent(edit);
expect(targetEvent.replacingEvent()).toBe(edit);
});
});
it("getSortedAnnotationsByKey should return null for non-annotation relations", async () => {
const userId = "@user:server";
const room = new Room("room123", new TestClient(userId).client, userId);
+140
View File
@@ -1308,4 +1308,144 @@ describe("RoomState", function () {
).toBeFalsy();
});
});
describe("reactive display name disambiguation", function () {
it("should disambiguate existing member when another member changes to the same name", function () {
// Create a fresh state
const testState = new RoomState(roomId);
// Alice joins with display name "Alice"
const aliceJoinEvent = utils.mkMembership({
user: userA,
mship: KnownMembership.Join,
room: roomId,
event: true,
name: "Alice",
});
// Bob joins with display name "Bob"
const bobJoinEvent = utils.mkMembership({
user: userB,
mship: KnownMembership.Join,
room: roomId,
event: true,
name: "Bob",
});
testState.setStateEvents([aliceJoinEvent, bobJoinEvent]);
// Verify no disambiguation needed initially
const aliceBefore = testState.getMember(userA);
const bobBefore = testState.getMember(userB);
expect(aliceBefore?.disambiguate).toBe(false);
expect(bobBefore?.disambiguate).toBe(false);
expect(aliceBefore?.name).toBe("Alice");
expect(bobBefore?.name).toBe("Bob");
// Bob changes display name to "Alice"
const bobRenameEvent = utils.mkMembership({
user: userB,
mship: KnownMembership.Join,
room: roomId,
event: true,
name: "Alice",
});
testState.setStateEvents([bobRenameEvent]);
// Now both should be disambiguated
const aliceAfter = testState.getMember(userA);
const bobAfter = testState.getMember(userB);
expect(aliceAfter?.disambiguate).toBe(true);
expect(bobAfter?.disambiguate).toBe(true);
expect(aliceAfter?.name).toContain(userA);
expect(bobAfter?.name).toContain(userB);
});
it("should un-disambiguate member when conflicting member changes to different name", function () {
// Create a fresh state
const testState = new RoomState(roomId);
// Both Alice and Bob join with display name "Alice"
const aliceJoinEvent = utils.mkMembership({
user: userA,
mship: KnownMembership.Join,
room: roomId,
event: true,
name: "Alice",
});
const bobJoinEvent = utils.mkMembership({
user: userB,
mship: KnownMembership.Join,
room: roomId,
event: true,
name: "Alice",
});
testState.setStateEvents([aliceJoinEvent, bobJoinEvent]);
// Verify both are disambiguated
const aliceBefore = testState.getMember(userA);
const bobBefore = testState.getMember(userB);
expect(aliceBefore?.disambiguate).toBe(true);
expect(bobBefore?.disambiguate).toBe(true);
// Bob changes display name to "Bob"
const bobRenameEvent = utils.mkMembership({
user: userB,
mship: KnownMembership.Join,
room: roomId,
event: true,
name: "Bob",
});
testState.setStateEvents([bobRenameEvent]);
// Alice should no longer be disambiguated, Bob should not be either
const aliceAfter = testState.getMember(userA);
const bobAfter = testState.getMember(userB);
expect(aliceAfter?.disambiguate).toBe(false);
expect(bobAfter?.disambiguate).toBe(false);
expect(aliceAfter?.name).toBe("Alice");
expect(bobAfter?.name).toBe("Bob");
});
it("should emit RoomState.members for affected members when disambiguation changes", function () {
// Create a fresh state
const testState = new RoomState(roomId);
// Alice joins with display name "Alice"
const aliceJoinEvent = utils.mkMembership({
user: userA,
mship: KnownMembership.Join,
room: roomId,
event: true,
name: "Alice",
});
testState.setStateEvents([aliceJoinEvent]);
// Set up listener for Members event
const membersEmitted: string[] = [];
testState.on(RoomStateEvent.Members, (_ev, _state, member) => {
membersEmitted.push(member.userId);
});
// Bob joins with display name "Alice" - should trigger disambiguation for Alice
const bobJoinEvent = utils.mkMembership({
user: userB,
mship: KnownMembership.Join,
room: roomId,
event: true,
name: "Alice",
});
testState.setStateEvents([bobJoinEvent]);
// Both Alice and Bob should have emitted Members events
expect(membersEmitted).toContain(userA);
expect(membersEmitted).toContain(userB);
});
});
});
+57
View File
@@ -0,0 +1,57 @@
/*
Copyright 2026 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { MatrixClient } from "../../src/client";
describe("Room upgrades", function () {
it("Sends an HTTP request upgrading the room", () => {
// Given a client with a fake authedRequest method
const { client, authedRequest } = createClient();
// When we upgrade the room to version 12
client.upgradeRoom("!r1", "12");
// Then we make an HTTP request to the correct endpoint, with the
// version provided in the JSON.
expect(authedRequest).toHaveBeenCalledWith("POST", "/rooms/!r1/upgrade", undefined, { new_version: "12" });
});
it("Includes additional_creators if provided", () => {
// Given a client with a fake authedRequest method
const { client, authedRequest } = createClient();
// When we upgrade the room to version 13 and supply additionalCreators
client.upgradeRoom("!r1", "13", ["@u:s.co", "@v:a.b"]);
// Then we make an HTTP request to the correct endpoint, with the
// version and additional creators provided.
expect(authedRequest).toHaveBeenCalledWith("POST", "/rooms/!r1/upgrade", undefined, {
new_version: "13",
additional_creators: ["@u:s.co", "@v:a.b"],
});
});
});
///
function createClient(): { client: MatrixClient; authedRequest: any } {
const authedRequest = vi.fn();
const client = new MatrixClient({
baseUrl: "https://my.home.server",
userId: "@u:s.co",
});
client.http.authedRequest = authedRequest;
return { client, authedRequest };
}
+22 -5
View File
@@ -2103,7 +2103,7 @@ describe("Room", function () {
}
const memberEvent = utils.mkMembership({
user: "@user_a:bar",
user: userA,
mship: KnownMembership.Join,
room: roomId,
event: true,
@@ -2114,7 +2114,7 @@ describe("Room", function () {
const client = createClientMock([memberEvent]);
const room = new Room(roomId, client as any, null!, { lazyLoadMembers: true });
await room.loadMembersIfNeeded();
const memberA = room.getMember("@user_a:bar")!;
const memberA = room.getMember(userA)!;
expect(memberA.name).toEqual("User A");
const storedMembers = client.store.storedMembers!;
expect(storedMembers.length).toEqual(1);
@@ -2123,7 +2123,7 @@ describe("Room", function () {
it("should take members from storage if available", async function () {
const memberEvent2 = utils.mkMembership({
user: "@user_a:bar",
user: userA,
mship: KnownMembership.Join,
room: roomId,
event: true,
@@ -2134,7 +2134,7 @@ describe("Room", function () {
await room.loadMembersIfNeeded();
const memberA = room.getMember("@user_a:bar")!;
const memberA = room.getMember(userA)!;
expect(memberA.name).toEqual("User A");
});
@@ -2145,9 +2145,26 @@ describe("Room", function () {
client.members.mockReturnValue({ chunk: [memberEvent] });
await room.loadMembersIfNeeded();
const memberA = room.getMember("@user_a:bar")!;
const memberA = room.getMember(userA)!;
expect(memberA.name).toEqual("User A");
});
it("should emit room name change event if loading members causes the room name to change", async function () {
const otherMemberEvent = utils.mkMembership({
user: userB,
mship: KnownMembership.Join,
room: roomId,
event: true,
name: "User B",
});
const client = createClientMock([memberEvent, otherMemberEvent]);
const room = new Room(roomId, client as any, userA, { lazyLoadMembers: true });
expect(room.name).toEqual("!foo:bar");
await room.loadMembersIfNeeded();
expect(room.name).toEqual("User B");
});
});
describe("getMyMembership", function () {
@@ -236,4 +236,105 @@ describe("OutgoingRequestsManager", () => {
expect(processor.makeOutgoingRequest).toHaveBeenCalledTimes(1);
});
});
describe("Repeated processing of outgoing requests", () => {
it("Processes any requests still in the queue after processing", async () => {
// Given that the first time we call outgoingRequests we get back
// requests 1 and 2, but the second time, we get request 3
const [request1, request2, request3] = setupOutgoingTwoRequestsThenOne();
// (And requests finish immediately)
processor.makeOutgoingRequest.mockImplementation(async () => {
return;
});
// When we ask a manager to process the requests
await manager.doProcessOutgoingRequests();
// Then all three are processed because we re-call outgoingRequests
// to check for more
await vi.waitFor(() => expect(olmMachine.outgoingRequests).toHaveBeenCalledTimes(3));
expect(processor.makeOutgoingRequest).toHaveBeenCalledTimes(3);
expect(processor.makeOutgoingRequest).toHaveBeenCalledWith(request1);
expect(processor.makeOutgoingRequest).toHaveBeenCalledWith(request2);
expect(processor.makeOutgoingRequest).toHaveBeenCalledWith(request3);
});
it("Does reprocess if any request succeeded, even if some failed", async () => {
// Given that the first time we call outgoingRequests we get back
// requests 1 and 2, but the second time, we get request 3
const [request1, request2, request3] = setupOutgoingTwoRequestsThenOne();
// And the first request fails, but subsequent ones pass
processor.makeOutgoingRequest
.mockImplementationOnce(async () => {
throw new Error("This request failed!");
})
.mockImplementation(async () => {
return;
});
// When we ask a manager to process the requests
await manager.doProcessOutgoingRequests();
// Then all three are processed because we re-call outgoingRequests
// to check for more, even though one failed
await vi.waitFor(() => expect(olmMachine.outgoingRequests).toHaveBeenCalledTimes(3));
expect(processor.makeOutgoingRequest).toHaveBeenCalledTimes(3);
expect(processor.makeOutgoingRequest).toHaveBeenCalledWith(request1);
expect(processor.makeOutgoingRequest).toHaveBeenCalledWith(request2);
expect(processor.makeOutgoingRequest).toHaveBeenCalledWith(request3);
});
it("Does not reprocess if all requests failed", async () => {
// Given that the first time we call outgoingRequests we get back
// requests 1 and 2, but the second time, we get request 3
const [request1, request2, request3] = setupOutgoingTwoRequestsThenOne();
// And the first two requests fail, but subsequent ones pass
processor.makeOutgoingRequest
.mockImplementationOnce(async () => {
throw new Error("Request 1 failed!");
})
.mockImplementationOnce(async () => {
throw new Error("Request 2 failed!");
})
.mockImplementation(async () => {
return;
});
// When we ask a manager to process the requests
await manager.doProcessOutgoingRequests();
// Then only the first two requests are processed, because since
// they both failed we stop retrying
await vi.waitFor(() => expect(olmMachine.outgoingRequests).toHaveBeenCalledTimes(1));
expect(processor.makeOutgoingRequest).toHaveBeenCalledTimes(2);
expect(processor.makeOutgoingRequest).toHaveBeenCalledWith(request1);
expect(processor.makeOutgoingRequest).toHaveBeenCalledWith(request2);
expect(processor.makeOutgoingRequest).not.toHaveBeenCalledWith(request3);
});
/// Mock calls to olmMachine.outgoingRequests. The first time it is
// called, return two requests and the second time return a third one.
//
// Returns the three returned requests.
function setupOutgoingTwoRequestsThenOne(): [OutgoingRequest, OutgoingRequest, OutgoingRequest] {
const request1 = new RustSdkCryptoJs.KeysQueryRequest("1", "{}");
const request2 = new RustSdkCryptoJs.KeysUploadRequest("2", "{}");
const request3 = new RustSdkCryptoJs.KeysUploadRequest("3", "{}");
// Given that the first time we call outgoingRequests we get back
// requests 1 and 2, but the second time, we get request 3
olmMachine.outgoingRequests
.mockImplementationOnce(async (): Promise<OutgoingRequest[]> => {
return [request1, request2];
})
.mockImplementationOnce(async (): Promise<OutgoingRequest[]> => {
return [request3];
});
return [request1, request2, request3];
}
});
});
@@ -4,7 +4,7 @@ exports[`RustCrypto > importing and exporting room keys > should import and expo
{
"algorithm": "m.megolm.v1.aes-sha2",
"forwarding_curve25519_key_chain": [],
"org.matrix.msc3061.shared_history": false,
"org.matrix.msc3061.shared_history": true,
"room_id": "!room:id",
"sender_claimed_keys": {
"ed25519": "QdgHgdpDgihgovpPzUiThXur1fbErTFh7paFvNKSgN0",
@@ -19,7 +19,7 @@ exports[`RustCrypto > importing and exporting room keys > should import and expo
{
"algorithm": "m.megolm.v1.aes-sha2",
"forwarding_curve25519_key_chain": [],
"org.matrix.msc3061.shared_history": false,
"org.matrix.msc3061.shared_history": true,
"room_id": "!room:id",
"sender_claimed_keys": {
"ed25519": "QdgHgdpDgihgovpPzUiThXur1fbErTFh7paFvNKSgN0",
+14 -14
View File
@@ -314,7 +314,7 @@ describe("Call", function () {
answer: {
sdp: DUMMY_SDP,
},
[SDPStreamMetadataKey]: {
[SDPStreamMetadataKey.name]: {
remote_stream: {
purpose: SDPStreamMetadataPurpose.Usermedia,
audio_muted: true,
@@ -420,7 +420,7 @@ describe("Call", function () {
answer: {
sdp: DUMMY_SDP,
},
[SDPStreamMetadataKey]: {},
[SDPStreamMetadataKey.name]: {},
}),
);
@@ -451,7 +451,7 @@ describe("Call", function () {
answer: {
sdp: DUMMY_SDP,
},
[SDPStreamMetadataKey]: {},
[SDPStreamMetadataKey.name]: {},
}),
);
@@ -478,7 +478,7 @@ describe("Call", function () {
answer: {
sdp: DUMMY_SDP,
},
[SDPStreamMetadataKey]: {},
[SDPStreamMetadataKey.name]: {},
}),
);
@@ -504,7 +504,7 @@ describe("Call", function () {
call.onSDPStreamMetadataChangedReceived(
makeMockEvent("@test:foo", {
[SDPStreamMetadataKey]: {
[SDPStreamMetadataKey.name]: {
remote_stream: {
purpose: SDPStreamMetadataPurpose.Screenshare,
audio_muted: true,
@@ -849,7 +849,7 @@ describe("Call", function () {
answer: {
sdp: DUMMY_SDP,
},
[SDPStreamMetadataKey]: {
[SDPStreamMetadataKey.name]: {
[STREAM_ID]: {
purpose: SDPStreamMetadataPurpose.Usermedia,
},
@@ -959,8 +959,8 @@ describe("Call", function () {
describe("sending sdp_stream_metadata_changed events", () => {
it("should send sdp_stream_metadata_changed when muting audio", async () => {
await call.setMicrophoneMuted(true);
expect(mockSendVoipEvent).toHaveBeenCalledWith(EventType.CallSDPStreamMetadataChangedPrefix, {
[SDPStreamMetadataKey]: {
expect(mockSendVoipEvent).toHaveBeenCalledWith(EventType.CallSDPStreamMetadataChanged, {
[SDPStreamMetadataKey.name]: {
mock_stream_from_media_handler: {
purpose: SDPStreamMetadataPurpose.Usermedia,
audio_muted: true,
@@ -972,8 +972,8 @@ describe("Call", function () {
it("should send sdp_stream_metadata_changed when muting video", async () => {
await call.setLocalVideoMuted(true);
expect(mockSendVoipEvent).toHaveBeenCalledWith(EventType.CallSDPStreamMetadataChangedPrefix, {
[SDPStreamMetadataKey]: {
expect(mockSendVoipEvent).toHaveBeenCalledWith(EventType.CallSDPStreamMetadataChanged, {
[SDPStreamMetadataKey.name]: {
mock_stream_from_media_handler: {
purpose: SDPStreamMetadataPurpose.Usermedia,
audio_muted: false,
@@ -1001,7 +1001,7 @@ describe("Call", function () {
);
call.onSDPStreamMetadataChangedReceived({
getContent: () => ({
[SDPStreamMetadataKey]: metadata,
[SDPStreamMetadataKey.name]: metadata,
}),
} as MatrixEvent);
return metadata;
@@ -1293,9 +1293,9 @@ describe("Call", function () {
FAKE_ROOM_ID,
EventType.CallNegotiate,
expect.objectContaining({
"version": "1",
"call_id": call.callId,
"org.matrix.msc3077.sdp_stream_metadata": expect.objectContaining({
version: "1",
call_id: call.callId,
sdp_stream_metadata: expect.objectContaining({
[SCREENSHARE_STREAM_ID]: expect.objectContaining({
purpose: SDPStreamMetadataPurpose.Screenshare,
}),
+2 -2
View File
@@ -963,7 +963,7 @@ describe("Group Call", function () {
const getMetadataEvent = (audio: boolean, video: boolean): MatrixEvent =>
({
getContent: () => ({
[SDPStreamMetadataKey]: {
[SDPStreamMetadataKey.name]: {
stream: {
purpose: SDPStreamMetadataPurpose.Usermedia,
audio_muted: audio,
@@ -1330,7 +1330,7 @@ describe("Group Call", function () {
call.getOpponentMember = () => ({ userId: call.invitee }) as RoomMember;
call.onNegotiateReceived({
getContent: () => ({
[SDPStreamMetadataKey]: {
[SDPStreamMetadataKey.name]: {
screensharing_stream: {
purpose: SDPStreamMetadataPurpose.Screenshare,
},
+10 -4
View File
@@ -48,11 +48,17 @@ export interface IPasswordFlow extends ILoginFlow {
type: "m.login.password";
}
export const DELEGATED_OIDC_COMPATIBILITY = new UnstableValue(
"delegated_oidc_compatibility",
export const OAUTH_AWARE_PREFERRED_FLOW_FIELD = new UnstableValue(
"oauth_aware_preferred",
"org.matrix.msc3824.delegated_oidc_compatibility",
);
/**
* @alias
* @deprecated use `OAUTH_AWARE_PREFERRED_FLOW_FIELD` instead.
*/
export const DELEGATED_OIDC_COMPATIBILITY = OAUTH_AWARE_PREFERRED_FLOW_FIELD;
/**
* Representation of SSO flow as per https://spec.matrix.org/v1.3/client-server-api/#client-login-via-sso
*/
@@ -60,8 +66,8 @@ export interface ISSOFlow extends ILoginFlow {
type: "m.login.sso" | "m.login.cas";
// eslint-disable-next-line camelcase
identity_providers?: IIdentityProvider[];
[DELEGATED_OIDC_COMPATIBILITY.name]?: boolean;
[DELEGATED_OIDC_COMPATIBILITY.altName]?: boolean;
[OAUTH_AWARE_PREFERRED_FLOW_FIELD.name]?: boolean;
[OAUTH_AWARE_PREFERRED_FLOW_FIELD.altName]?: boolean;
}
export enum IdentityProviderBrand {
+31 -2
View File
@@ -14,6 +14,8 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { type EitherAnd } from "matrix-events-sdk";
import { NamespacedValue, UnstableValue } from "../NamespacedValue.ts";
import {
type PolicyRuleEventContent,
@@ -50,7 +52,6 @@ import {
type MCallReplacesEvent,
type MCallSelectAnswer,
type SDPStreamMetadata,
type SDPStreamMetadataKey,
} from "../webrtc/callEventTypes.ts";
import {
type IRTCNotificationContent,
@@ -133,11 +134,13 @@ export enum EventType {
FullyRead = "m.fully_read",
Tag = "m.tag",
SpaceOrder = "org.matrix.msc3230.space_order", // MSC3230
MarkedUnread = "m.marked_unread",
// User account_data events
PushRules = "m.push_rules",
Direct = "m.direct",
IgnoredUserList = "m.ignored_user_list",
InvitePermissionConfig = "m.invite_permission_config", // MSC4380
// to_device events
RoomKey = "m.room_key",
@@ -334,7 +337,16 @@ export interface TimelineEvents {
[EventType.CallCandidates]: MCallCandidates;
[EventType.CallHangup]: MCallHangupReject;
[EventType.CallReject]: MCallHangupReject;
[EventType.CallSDPStreamMetadataChangedPrefix]: MCallBase & { [SDPStreamMetadataKey]: SDPStreamMetadata };
[EventType.CallSDPStreamMetadataChangedPrefix]: MCallBase &
EitherAnd<
{ sdp_stream_metadata: SDPStreamMetadata },
{ "org.matrix.msc3077.sdp_stream_metadata": SDPStreamMetadata }
>;
[EventType.CallSDPStreamMetadataChanged]: MCallBase &
EitherAnd<
{ sdp_stream_metadata: SDPStreamMetadata },
{ "org.matrix.msc3077.sdp_stream_metadata": SDPStreamMetadata }
>;
[EventType.CallEncryptionKeysPrefix]: EncryptionKeysEventContent;
[EventType.CallNotify]: ICallNotifyContent;
[EventType.RTCNotification]: IRTCNotificationContent;
@@ -386,6 +398,16 @@ export interface StateEvents {
[M_BEACON_INFO.name]: MBeaconInfoEventContent;
}
/**
* Mapped type from event type to content type for all specified room-specific account_data events.
*/
export interface RoomAccountDataEvents extends SecretStorageAccountDataEvents {
[EventType.FullyRead]: { event_id: string };
[EventType.Tag]: { tags: { [name: string]: { order?: number } } };
[EventType.SpaceOrder]: { order: string };
[EventType.MarkedUnread]: { unread: boolean };
}
/**
* Mapped type from event type to content type for all specified global account_data events.
*/
@@ -404,8 +426,15 @@ export interface AccountDataEvents extends SecretStorageAccountDataEvents {
// Invites-ignorer events
[POLICIES_ACCOUNT_EVENT_TYPE.name]: { [key: string]: any };
[POLICIES_ACCOUNT_EVENT_TYPE.altName]: { [key: string]: any };
[EventType.InvitePermissionConfig]: { default_action?: string };
}
/**
* Subset of AccountDataEvents, excluding events specified in https://spec.matrix.org/v1.17/client-server-api/#server-behaviour-12
*/
export type WritableAccountDataEvents = Exclude<AccountDataEvents, "m.fully_read" | "m.push_rules">;
/**
* Mapped type from event type to content type for all specified global events encrypted by secret storage.
*
-24
View File
@@ -1,24 +0,0 @@
/*
Copyright 2024 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import "oidc-client-ts";
declare module "oidc-client-ts" {
interface OidcMetadata {
// Add the missing device_authorization_endpoint field to the OidcMetadata interface
device_authorization_endpoint?: string;
}
}
+1 -1
View File
@@ -42,7 +42,7 @@ import { type IMessageRendering } from "./extensible_events.ts";
/**
* The event type for an m.topic event (in content)
*/
export const M_TOPIC = new NamespacedValue("m.topic");
export const M_TOPIC = new NamespacedValue("m.topic", null);
/**
* The event content for an m.topic event (in content)
+6 -6
View File
@@ -22,11 +22,11 @@ export class NamespacedValue<S extends string, U extends string> {
// Stable is optional, but one of the two parameters is required, hence the weird-looking types.
// Goal is to to have developers explicitly say there is no stable value (if applicable).
public constructor(stable: S, unstable: U);
public constructor(stable: S, unstable?: U);
public constructor(stable: null | undefined, unstable: U);
public constructor(stable: S, unstable: U | null);
public constructor(stable: null, unstable: U);
public constructor(
public readonly stable?: S | null,
public readonly unstable?: U,
public readonly stable: S | null,
public readonly unstable: U | null,
) {
if (!this.unstable && !this.stable) {
throw new Error("One of stable or unstable values must be supplied");
@@ -60,8 +60,8 @@ export class NamespacedValue<S extends string, U extends string> {
// this desperately wants https://github.com/microsoft/TypeScript/pull/26349 at the top level of the class
// so we can instantiate `NamespacedValue<string, _, _>` as a default type for that namespace.
public findIn<T>(obj: any): T | undefined {
let val: T | undefined = undefined;
public findIn<V>(obj: Partial<Record<NonNullable<S | U>, V>>): V | undefined {
let val: V | undefined = undefined;
if (this.name) {
val = obj?.[this.name];
}
+30 -12
View File
@@ -138,6 +138,7 @@ import {
MsgType,
PUSHER_ENABLED,
RelationType,
type RoomAccountDataEvents,
RoomCreateTypeField,
RoomType,
type StateEvents,
@@ -145,6 +146,7 @@ import {
UNSTABLE_MSC3088_ENABLED,
UNSTABLE_MSC3088_PURPOSE,
UNSTABLE_MSC3089_TREE_SUBTYPE,
type WritableAccountDataEvents,
} from "./@types/event.ts";
import {
GuestAccess,
@@ -2221,7 +2223,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @param eventType - The event type
* @param content - the contents object for the event
*/
public async setAccountData<K extends keyof AccountDataEvents>(
public async setAccountData<K extends keyof WritableAccountDataEvents>(
eventType: K,
content: AccountDataEvents[K] | Record<string, never>,
): Promise<EmptyObject> {
@@ -2273,7 +2275,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @param eventType - The event type
* @param content - the contents object for the event
*/
public setAccountDataRaw<K extends keyof AccountDataEvents>(
public setAccountDataRaw<K extends keyof WritableAccountDataEvents>(
eventType: K,
content: AccountDataEvents[K] | Record<string, never>,
): Promise<EmptyObject> {
@@ -2328,7 +2330,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
}
}
public async deleteAccountData(eventType: keyof AccountDataEvents): Promise<void> {
public async deleteAccountData(eventType: keyof WritableAccountDataEvents): Promise<void> {
const msc3391DeleteAccountDataServerSupport = this.canSupport.get(Feature.AccountDataDeletion);
// if deletion is not supported overwrite with empty content
if (msc3391DeleteAccountDataServerSupport === ServerSupport.Unsupported) {
@@ -2584,12 +2586,17 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
}
/**
* @param roomId - the ID of the room this event should be stored within
* @param eventType - event type to be set
* @param content - event content
* @returns Promise which resolves: to an empty object `{}`
* @returns Rejects: with an error response.
*/
public setRoomAccountData(roomId: string, eventType: string, content: Record<string, any>): Promise<EmptyObject> {
public setRoomAccountData<K extends keyof RoomAccountDataEvents>(
roomId: string,
eventType: K,
content: RoomAccountDataEvents[K] | Record<string, never>,
): Promise<EmptyObject> {
const path = utils.encodeUri("/user/$userId/rooms/$roomId/account_data/$type", {
$userId: this.credentials.userId!,
$roomId: roomId,
@@ -6689,6 +6696,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
const params = {
redirectUrl,
[SSO_ACTION_PARAM.stable!]: action,
[SSO_ACTION_PARAM.unstable!]: action,
};
@@ -6936,13 +6944,23 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
/**
* Upgrades a room to a new protocol version
* @param newVersion - The target version to upgrade to
* @param additionalCreators - an optional list of user IDs of users who
* should have the same permissions as the user performing the
* upgrade
* @returns Promise which resolves: Object with key 'replacement_room'
* @returns Rejects: with an error response.
*/
public upgradeRoom(roomId: string, newVersion: string): Promise<{ replacement_room: string }> {
public upgradeRoom(
roomId: string,
newVersion: string,
additionalCreators?: string[],
): Promise<{ replacement_room: string }> {
// eslint-disable-line camelcase
const path = utils.encodeUri("/rooms/$roomId/upgrade", { $roomId: roomId });
return this.http.authedRequest(Method.Post, path, undefined, { new_version: newVersion });
return this.http.authedRequest(Method.Post, path, undefined, {
new_version: newVersion,
additional_creators: additionalCreators,
});
}
/**
@@ -8826,21 +8844,21 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
}
/**
* Discover and validate delegated auth configuration
* - delegated auth issuer openid-configuration is reachable
* - delegated auth issuer openid-configuration is configured correctly for us
* Discover and validate the auth metadata for the OAuth 2.0 API.
*
* Fetches /auth_metadata falling back to legacy implementation using /auth_issuer followed by
* https://oidc-issuer.example.com/.well-known/openid-configuration and other files linked therein.
* When successful, validated metadata is returned
* When successful, validated metadata is returned.
*
* @returns validated authentication metadata and optionally signing keys
* @throws when delegated auth config is invalid or unreachable
* @experimental - part of MSC2965
*/
public async getAuthMetadata(): Promise<OidcClientConfig> {
let authMetadata: unknown | undefined;
try {
const useStable = await this.isVersionSupported("v1.15");
authMetadata = await this.http.request<unknown>(Method.Get, "/auth_metadata", undefined, undefined, {
prefix: ClientPrefix.Unstable + "/org.matrix.msc2965",
prefix: useStable ? ClientPrefix.V1 : ClientPrefix.Unstable + "/org.matrix.msc2965",
});
} catch (e) {
if (e instanceof MatrixError && e.errcode === "M_UNRECOGNIZED") {
+3 -3
View File
@@ -16,7 +16,7 @@ limitations under the License.
import { type MBeaconEventContent, type MBeaconInfoContent, type MBeaconInfoEventContent } from "./@types/beacon.ts";
import { MsgType } from "./@types/event.ts";
import { M_TEXT, REFERENCE_RELATION } from "./@types/extensible_events.ts";
import { type IMessageRendering, M_TEXT, REFERENCE_RELATION } from "./@types/extensible_events.ts";
import { isProvided } from "./extensible_events_v1/utilities.ts";
import {
M_ASSET,
@@ -29,7 +29,7 @@ import {
type MAssetContent,
type LegacyLocationEventContent,
} from "./@types/location.ts";
import { type MRoomTopicEventContent, type MTopicContent, M_TOPIC } from "./@types/topic.ts";
import { type MRoomTopicEventContent, type MTopicContent, M_TOPIC, type MTopicEvent } from "./@types/topic.ts";
import { type RoomMessageEventContent } from "./@types/events.ts";
/**
@@ -206,7 +206,7 @@ export type TopicState = {
};
export const parseTopicContent = (content: MRoomTopicEventContent): TopicState => {
const mtopicParent = M_TOPIC.findIn<MTopicContent>(content);
const mtopicParent = M_TOPIC.findIn<MTopicContent | IMessageRendering[]>(content as MTopicEvent);
const mtopic = Array.isArray(mtopicParent) ? mtopicParent : mtopicParent?.["m.text"];
// TODO remove support for the old malformed m.topic arrays after a few releases (only allow array in m.text)
// https://github.com/matrix-org/matrix-js-sdk/pull/4984#pullrequestreview-3174251065
+5
View File
@@ -51,6 +51,7 @@ function validateMediaId(mediaId: string): boolean {
* for authenticated media will *not* be checked - it is the caller's responsibility
* to do so before calling this function. Note also that `useAuthentication`
* implies `allowRedirects`. Defaults to false (unauthenticated endpoints).
* @param animated - Whether the desired thumbnail should be animated.
* @returns The complete URL to the content, may be an empty string if the provided mxc is not valid.
*/
export function getHttpUriForMxc(
@@ -62,6 +63,7 @@ export function getHttpUriForMxc(
allowDirectLinks = false,
allowRedirects?: boolean,
useAuthentication?: boolean,
animated?: boolean,
): string {
if (typeof mxc !== "string" || !mxc) {
return "";
@@ -107,6 +109,9 @@ export function getHttpUriForMxc(
if (resizeMethod) {
url.searchParams.set("method", resizeMethod);
}
if (animated !== undefined) {
url.searchParams.set("animated", String(animated));
}
if (typeof allowRedirects === "boolean") {
// We add this after, so we don't convert everything to a thumbnail request.
+110 -54
View File
@@ -38,6 +38,7 @@ import {
type SendDelayedEventRequestOpts,
type SendDelayedEventResponse,
UpdateDelayedEventAction,
isSendDelayedEventRequestOpts,
} from "./@types/requests.ts";
import { EventType, type StateEvents } from "./@types/event.ts";
import { logger } from "./logger.ts";
@@ -56,7 +57,7 @@ import { ConnectionError, MatrixError } from "./http-api/errors.ts";
import { User } from "./models/user.ts";
import { type Room } from "./models/room.ts";
import { type ToDeviceBatch, type ToDevicePayload } from "./models/ToDeviceMessage.ts";
import { MapWithDefault, recursiveMapToObject } from "./utils.ts";
import { MapWithDefault, type QueryDict, recursiveMapToObject } from "./utils.ts";
import { type EmptyObject, TypedEventEmitter, UnsupportedDelayedEventsEndpointError } from "./matrix.ts";
interface IStateEventRequest {
@@ -122,6 +123,20 @@ export interface ICapabilities {
* @defaultValue false
*/
updateDelayedEvents?: boolean;
/**
* Whether this client needs to be able to send sticky events.
* @experimental Part of MSC4354 & MSC4407
* @defaultValue false
*/
sendSticky?: boolean;
/**
* Whether this client needs to be able to receive sticky events.
* @experimental Part of MSC4354 & MSC4407
* @defaultValue false
*/
receiveSticky?: boolean;
}
export enum RoomWidgetClientEvent {
@@ -197,54 +212,7 @@ export class RoomWidgetClient extends MatrixClient {
)
: Promise.resolve();
// Request capabilities for the functionality this client needs to support
if (
capabilities.sendEvent?.length ||
capabilities.receiveEvent?.length ||
capabilities.sendMessage === true ||
(Array.isArray(capabilities.sendMessage) && capabilities.sendMessage.length) ||
capabilities.receiveMessage === true ||
(Array.isArray(capabilities.receiveMessage) && capabilities.receiveMessage.length) ||
capabilities.sendState?.length ||
capabilities.receiveState?.length
) {
widgetApi.requestCapabilityForRoomTimeline(roomId);
}
capabilities.sendEvent?.forEach((eventType) => widgetApi.requestCapabilityToSendEvent(eventType));
capabilities.receiveEvent?.forEach((eventType) => widgetApi.requestCapabilityToReceiveEvent(eventType));
if (capabilities.sendMessage === true) {
widgetApi.requestCapabilityToSendMessage();
} else if (Array.isArray(capabilities.sendMessage)) {
capabilities.sendMessage.forEach((msgType) => widgetApi.requestCapabilityToSendMessage(msgType));
}
if (capabilities.receiveMessage === true) {
widgetApi.requestCapabilityToReceiveMessage();
} else if (Array.isArray(capabilities.receiveMessage)) {
capabilities.receiveMessage.forEach((msgType) => widgetApi.requestCapabilityToReceiveMessage(msgType));
}
capabilities.sendState?.forEach(({ eventType, stateKey }) =>
widgetApi.requestCapabilityToSendState(eventType, stateKey),
);
capabilities.receiveState?.forEach(({ eventType, stateKey }) =>
widgetApi.requestCapabilityToReceiveState(eventType, stateKey),
);
capabilities.sendToDevice?.forEach((eventType) => widgetApi.requestCapabilityToSendToDevice(eventType));
capabilities.receiveToDevice?.forEach((eventType) => widgetApi.requestCapabilityToReceiveToDevice(eventType));
if (
capabilities.sendDelayedEvents &&
(capabilities.sendEvent?.length ||
capabilities.sendMessage === true ||
(Array.isArray(capabilities.sendMessage) && capabilities.sendMessage.length) ||
capabilities.sendState?.length)
) {
widgetApi.requestCapability(MatrixCapabilities.MSC4157SendDelayedEvent);
}
if (capabilities.updateDelayedEvents) {
widgetApi.requestCapability(MatrixCapabilities.MSC4157UpdateDelayedEvent);
}
if (capabilities.turnServers) {
widgetApi.requestCapability(MatrixCapabilities.MSC3846TurnServers);
}
this.requestInitialCapabilities(capabilities, roomId);
widgetApi.on(`action:${WidgetApiToWidgetAction.SendEvent}`, this.onEvent);
widgetApi.on(`action:${WidgetApiToWidgetAction.SendToDevice}`, this.onToDevice);
@@ -260,10 +228,70 @@ export class RoomWidgetClient extends MatrixClient {
if (sendContentLoaded) widgetApi.sendContentLoaded();
}
private requestInitialCapabilities(capabilities: ICapabilities, roomId: string): void {
// Request capabilities for the functionality this client needs to support
if (
capabilities.sendEvent?.length ||
capabilities.receiveEvent?.length ||
capabilities.sendMessage === true ||
(Array.isArray(capabilities.sendMessage) && capabilities.sendMessage.length) ||
capabilities.receiveMessage === true ||
(Array.isArray(capabilities.receiveMessage) && capabilities.receiveMessage.length) ||
capabilities.sendState?.length ||
capabilities.receiveState?.length
) {
this.widgetApi.requestCapabilityForRoomTimeline(roomId);
}
capabilities.sendEvent?.forEach((eventType) => this.widgetApi.requestCapabilityToSendEvent(eventType));
capabilities.receiveEvent?.forEach((eventType) => this.widgetApi.requestCapabilityToReceiveEvent(eventType));
if (capabilities.sendMessage === true) {
this.widgetApi.requestCapabilityToSendMessage();
} else if (Array.isArray(capabilities.sendMessage)) {
capabilities.sendMessage.forEach((msgType) => this.widgetApi.requestCapabilityToSendMessage(msgType));
}
if (capabilities.receiveMessage === true) {
this.widgetApi.requestCapabilityToReceiveMessage();
} else if (Array.isArray(capabilities.receiveMessage)) {
capabilities.receiveMessage.forEach((msgType) => this.widgetApi.requestCapabilityToReceiveMessage(msgType));
}
capabilities.sendState?.forEach(({ eventType, stateKey }) =>
this.widgetApi.requestCapabilityToSendState(eventType, stateKey),
);
capabilities.receiveState?.forEach(({ eventType, stateKey }) =>
this.widgetApi.requestCapabilityToReceiveState(eventType, stateKey),
);
capabilities.sendToDevice?.forEach((eventType) => this.widgetApi.requestCapabilityToSendToDevice(eventType));
capabilities.receiveToDevice?.forEach((eventType) =>
this.widgetApi.requestCapabilityToReceiveToDevice(eventType),
);
if (
capabilities.sendDelayedEvents &&
(capabilities.sendEvent?.length ||
capabilities.sendMessage === true ||
(Array.isArray(capabilities.sendMessage) && capabilities.sendMessage.length) ||
capabilities.sendState?.length)
) {
this.widgetApi.requestCapability(MatrixCapabilities.MSC4157SendDelayedEvent);
}
if (capabilities.updateDelayedEvents) {
this.widgetApi.requestCapability(MatrixCapabilities.MSC4157UpdateDelayedEvent);
}
if (capabilities.sendSticky) {
this.widgetApi.requestCapability(MatrixCapabilities.MSC4407SendStickyEvent);
}
if (capabilities.receiveSticky) {
this.widgetApi.requestCapability(MatrixCapabilities.MSC4407ReceiveStickyEvent);
}
if (capabilities.turnServers) {
this.widgetApi.requestCapability(MatrixCapabilities.MSC3846TurnServers);
}
}
public async supportUpdateState(): Promise<boolean> {
return (await this.widgetApi.getClientVersions()).includes(UnstableApiVersion.MSC2762_UPDATE_STATE);
}
private readonly syncApiResolver = Promise.withResolvers<void>();
public async startClient(opts: IStartClientOpts = {}): Promise<void> {
this.lifecycle = new AbortController();
@@ -282,6 +310,7 @@ export class RoomWidgetClient extends MatrixClient {
} else {
this.syncApi = new SyncApi(this, opts, this.buildSyncApiOptions());
}
this.syncApiResolver.resolve();
this.room = this.syncApi.createRoom(this.roomId);
this.store.storeRoom(this.room);
@@ -346,17 +375,41 @@ export class RoomWidgetClient extends MatrixClient {
throw new Error(`Unknown room: ${roomIdOrAlias}`);
}
protected async encryptAndSendEvent(room: Room, event: MatrixEvent): Promise<ISendEventResponse>;
protected async encryptAndSendEvent(
room: Room,
event: MatrixEvent,
queryDict?: QueryDict,
): Promise<ISendEventResponse>;
protected async encryptAndSendEvent(
room: Room,
event: MatrixEvent,
delayOpts: SendDelayedEventRequestOpts,
): Promise<SendDelayedEventResponse>;
queryDict?: QueryDict,
): Promise<ISendEventResponse>;
protected async encryptAndSendEvent(
room: Room,
event: MatrixEvent,
delayOpts?: SendDelayedEventRequestOpts,
delayOptsOrQuery?: SendDelayedEventRequestOpts | QueryDict,
queryDict?: QueryDict,
): Promise<ISendEventResponse | SendDelayedEventResponse> {
let queryOpts = queryDict;
let delayOpts: SendDelayedEventRequestOpts | undefined;
if (delayOptsOrQuery && isSendDelayedEventRequestOpts(delayOptsOrQuery)) {
delayOpts = delayOptsOrQuery;
} else if (!queryOpts) {
queryOpts = delayOptsOrQuery;
}
const stickyDurationMs = queryOpts?.["org.matrix.msc4354.sticky_duration_ms"];
if (stickyDurationMs !== undefined && typeof stickyDurationMs !== "number") {
throw new Error("Sticky duration must be a number when defined");
}
// This is save since we just checked that above
// We need the additional as assertion for the EW linter to be happy.
// It is not capable of implying the type based on the throw if `stickyDurationMs !== undefined && typeof stickyDurationMs !== "number"`
// above
const stickyDurationMsAsNumber: number | undefined = stickyDurationMs as number | undefined;
// We need to extend the content with the redacts parameter
// The js sdk uses event.redacts but the widget api uses event.content.redacts
// This will be converted back to event.redacts in the widget driver.
@@ -374,6 +427,7 @@ export class RoomWidgetClient extends MatrixClient {
room.roomId,
"delay" in delayOpts ? delayOpts.delay : undefined,
"parent_delay_id" in delayOpts ? delayOpts.parent_delay_id : undefined,
stickyDurationMsAsNumber,
)
.catch(timeoutToConnectionError);
return this.validateSendDelayedEventResponse(response);
@@ -386,7 +440,7 @@ export class RoomWidgetClient extends MatrixClient {
let response: ISendEventFromWidgetResponseData;
try {
response = await this.widgetApi
.sendRoomEvent(event.getType(), content, room.roomId)
.sendRoomEvent(event.getType(), content, room.roomId, undefined, undefined, stickyDurationMsAsNumber)
.catch(timeoutToConnectionError);
} catch (e) {
this.updatePendingEventStatus(room, event, EventStatus.NOT_SENT);
@@ -683,7 +737,7 @@ export class RoomWidgetClient extends MatrixClient {
// Only inject once we have update the txId
await this.updateTxId(event);
await this.syncApiResolver.promise;
if (this.syncApi instanceof SyncApi) {
if (await this.supportUpdateState()) {
await this.syncApi.injectRoomEvents(this.room!, undefined, [], [event]);
@@ -704,6 +758,7 @@ export class RoomWidgetClient extends MatrixClient {
}
this.emit(ClientEvent.Event, event);
if (event.unstableStickyInfo !== undefined) this.room!._unstable_addStickyEvents([event]);
this.setSyncState(SyncState.Syncing);
logger.info(`Received event ${event.getId()} ${event.getType()}`);
} else {
@@ -737,6 +792,7 @@ export class RoomWidgetClient extends MatrixClient {
"received update_state widget action but the widget driver did not claim to support 'org.matrix.msc2762_update_state'",
);
}
await this.syncApiResolver.promise;
for (const rawEvent of ev.detail.data.state) {
// Verify the room ID matches, since it's possible for the client to
// send us state updates from other rooms if this widget is always
+5
View File
@@ -67,6 +67,11 @@ export interface IHttpOpts {
* Optional, only called when a refreshToken is present
*/
tokenRefreshFunction?: TokenRefreshFunction;
/**
* Whether to use the HTTP Authorization header over the `access_token` query parameter
* @deprecated as of v1.11 in https://spec.matrix.org/v1.17/client-server-api/#using-access-tokens
*/
useAuthorizationHeader?: boolean; // defaults to true
/** For historical reasons, must be set to `true`. Will eventually be removed. */
+43 -2
View File
@@ -372,12 +372,53 @@ export class CallMembership {
*/
public get slotId(): string {
const { kind, data } = this.membershipData;
if (data.application === "m.call") {
switch (kind) {
case "rtc":
return data.slot_id;
case "session":
default: {
const [application, id] = [this.application, data.call_id];
// INFO_SLOT_ID_LEGACY_CASE (search for all occurances of this INFO to get the full picture)
// The spec got changed to use `"ROOM"` instead of `""` empyt string for the implicit default call.
// State events still are sent with `""` however. To find other events that should end up in the same call,
// we use the slotId.
// Since the CallMembership is the public representation of a rtc.member event, we just pretend it is a
// "ROOM" slotId/call_id.
// This makes all the remote members work with just this simple trick.
//
// We of course now need to be careful when sending legacy events (state events)
// They get a slotDescription containing "ROOM" since this is what we use starting at the time this comment
// is commited.
//
// See the Other INFO_SLOT_ID_LEGACY_CASE comments to see where we revert back to "" just before sending the event.
let compatibilityAdaptedId: string;
if (id === "") {
compatibilityAdaptedId = "ROOM";
this.logger?.info("use slotId compat hack emptyString -> ROOM");
} else {
compatibilityAdaptedId = id;
}
return slotDescriptionToId({
application,
id: compatibilityAdaptedId,
});
}
}
}
this.logger?.info("NOT using slotId compat hack emptyString -> ROOM");
// This is what the function should look like for any other application that did not
// go through a `""`=> `"ROOM"` rename
switch (kind) {
case "rtc":
return data.slot_id;
case "session":
default:
return slotDescriptionToId({ application: this.application, id: data.call_id });
default: {
const [application, id] = [this.application, data.call_id];
return slotDescriptionToId({ application, id });
}
}
}
+14 -30
View File
@@ -26,13 +26,12 @@ import { CallMembership } from "./CallMembership.ts";
import { RoomStateEvent } from "../models/room-state.ts";
import { MembershipManager, StickyEventMembershipManager } from "./MembershipManager.ts";
import { type CallMembershipIdentityParts, EncryptionManager, type IEncryptionManager } from "./EncryptionManager.ts";
import { deepCompare, logDurationSync } from "../utils.ts";
import { logDurationSync } from "../utils.ts";
import type {
Statistics,
RTCNotificationType,
Status,
IRTCNotificationContent,
ICallNotifyContent,
RTCCallIntent,
Transport,
} from "./types.ts";
@@ -81,7 +80,6 @@ export type MatrixRTCSessionEventHandlerMap = {
[MatrixRTCSessionEvent.MembershipManagerError]: (error: unknown) => void;
[MatrixRTCSessionEvent.DidSendCallNotification]: (
notificationContentNew: { event_id: string } & IRTCNotificationContent,
notificationContentLegacy: { event_id: string } & ICallNotifyContent,
) => void;
};
@@ -328,7 +326,7 @@ export class MatrixRTCSession extends TypedEventEmitter<
*/
public static async sessionMembershipsForSlot(
room: Pick<Room, "getLiveTimeline" | "roomId" | "hasMembershipState" | "_unstable_getStickyEvents">,
slotDescription: SlotDescription,
slotId: string,
// default both true this implied we combine sticky and state events for the final call state
// (prefer sticky events in case of a duplicate)
options: SessionMembershipsForSlotOpts = DEFAULT_SESSION_MEMBERSHIPS_FOR_SLOT_OPTS,
@@ -339,7 +337,7 @@ export class MatrixRTCSession extends TypedEventEmitter<
const callMemberships = await computeBackendIdentityAndVerifyMemberEvents(
room,
callMemberEvents,
slotDescription,
slotId,
logger,
);
@@ -713,20 +711,7 @@ export class MatrixRTCSession extends TypedEventEmitter<
notificationType: RTCNotificationType,
callIntent?: RTCCallIntent,
): void {
const sendLegacyNotificationEvent = async (): Promise<{
response: ISendEventResponse;
content: ICallNotifyContent;
}> => {
const content: ICallNotifyContent = {
"application": "m.call",
"m.mentions": { user_ids: [], room: true },
"notify_type": notificationType === "notification" ? "notify" : notificationType,
"call_id": this.callId!,
};
const response = await this.client.sendEvent(this.roomSubset.roomId, EventType.CallNotify, content);
return { response, content };
};
const sendNewNotificationEvent = async (): Promise<{
const sendNotificationEvent = async (): Promise<{
response: ISendEventResponse;
content: IRTCNotificationContent;
}> => {
@@ -747,12 +732,11 @@ export class MatrixRTCSession extends TypedEventEmitter<
return { response, content };
};
void Promise.all([sendLegacyNotificationEvent(), sendNewNotificationEvent()])
.then(([legacy, newNotification]) => {
void sendNotificationEvent()
.then((notification) => {
// Join event_id and origin event content
const legacyResult = { ...legacy.response, ...legacy.content };
const newResult = { ...newNotification.response, ...newNotification.content };
this.emit(MatrixRTCSessionEvent.DidSendCallNotification, newResult, legacyResult);
const newResult = { ...notification.response, ...notification.content };
this.emit(MatrixRTCSessionEvent.DidSendCallNotification, newResult);
})
.catch(([errorLegacy, errorNew]) =>
this.logger.error("Failed to send call notification", errorLegacy, errorNew),
@@ -823,7 +807,7 @@ export class MatrixRTCSession extends TypedEventEmitter<
this.memberships = await MatrixRTCSession.sessionMembershipsForSlot(
this.room,
this.slotDescription,
slotDescriptionToId(this.slotDescription),
this.calculateMembershipsOpts,
);
@@ -873,7 +857,7 @@ export class MatrixRTCSession extends TypedEventEmitter<
async function computeBackendIdentityAndVerifyMemberEvents(
room: Pick<Room, "hasMembershipState">,
callMemberEvents: MatrixEvent[],
slotDescription: SlotDescription,
slotId: string,
logger: Logger,
): Promise<CallMembership[]> {
const callMemberships: CallMembership[] = [];
@@ -897,7 +881,7 @@ async function computeBackendIdentityAndVerifyMemberEvents(
logger,
);
if (isValidMembership(membership, room, slotDescription, logger)) {
if (isValidMembership(membership, room, slotId, logger)) {
callMemberships.push(membership);
}
} catch (e) {
@@ -930,12 +914,12 @@ function quickFilterNonRelevantContents(content: IContent, logger: Logger): bool
function isValidMembership(
membership: CallMembership,
room: Pick<Room, "hasMembershipState">,
slotDescription: SlotDescription,
slotId: string,
logger: Logger,
): boolean {
if (!deepCompare(membership.slotDescription, slotDescription)) {
if (membership.slotId !== slotId) {
logger.info(
`Ignoring membership of user ${membership.userId} for a different slot: ${JSON.stringify(membership.slotDescription)}`,
`Ignoring membership of user ${membership.userId} for a different slot: user: ${JSON.stringify(membership.slotDescription)}, slotId: ${slotId})`,
);
return false;
}
+1 -1
View File
@@ -56,7 +56,7 @@ export class MatrixRTCSessionManager extends TypedEventEmitter<MatrixRTCSessionM
public constructor(
rootLogger: Logger,
private client: MatrixClient,
private readonly slotDescription: SlotDescription = { application: "m.call", id: "" }, // Default to the Matrix Call application
private readonly slotDescription: SlotDescription = { application: "m.call", id: "ROOM" }, // Default to the Matrix Call application
) {
super();
this.logger = rootLogger.getChild("[MatrixRTCSessionManager]");
+20 -4
View File
@@ -43,6 +43,7 @@ import {
type IMembershipManager,
type MembershipManagerEventHandlerMap,
} from "./IMembershipManager.ts";
import { isLivekitTransportConfig } from "./LivekitTransport.ts";
/* MembershipActionTypes:
On Join: (1)
@@ -584,7 +585,7 @@ export class MembershipManager
this.emit(MembershipManagerEvent.ProbablyLeft, this.state.probablyLeft);
}
private setAndEmitDelayId(delayId?: string): void {
private setAndEmitDelayId(delayId: string | undefined): void {
if (this.state.delayId === delayId) return;
this.state.delayId = delayId;
@@ -654,6 +655,7 @@ export class MembershipManager
._unstable_sendScheduledDelayedEvent(delayId)
.then(() => {
this.state.hasMemberStateEvent = false;
this.setAndEmitDelayId(undefined);
this.resetRateLimitCounter(MembershipActionType.SendScheduledDelayedLeaveEvent);
return { replace: [] };
@@ -772,7 +774,12 @@ export class MembershipManager
* which is not compatible with membershipID of session type member events. They have to be `${localUserId}:${localDeviceId}`
*/
private makeMembershipStateKey(localUserId: string, localDeviceId: string): string {
const stateKey = `${localUserId}_${localDeviceId}_${this.slotDescription.application}${this.slotDescription.id}`;
// INFO_SLOT_ID_LEGACY_CASE (search for all occurances of this INFO to get the full picture)
// Revert back to "" just for the state key (state keys are always legacy. we use sticky events for non legacy events)
const application = this.slotDescription.application;
const needsEmptyStringRoomFix = application === "m.call" && this.slotDescription.id === "ROOM";
const slotId = needsEmptyStringRoomFix ? "" : this.slotDescription.id;
const stateKey = `${localUserId}_${localDeviceId}_${application}${slotId}`;
if (/^org\.matrix\.msc(3757|3779)\b/.exec(this.room.getVersion())) {
return stateKey;
} else {
@@ -785,6 +792,8 @@ export class MembershipManager
*/
protected makeMyMembership(expires: number): SessionMembershipData | RtcMembershipData {
const ownMembership = this.ownMembership;
const needsEmptyStringRoomFix =
this.slotDescription.application === "m.call" && this.slotDescription.id === "ROOM";
const focusObjects =
this.rtcTransport === undefined
@@ -798,7 +807,9 @@ export class MembershipManager
};
return {
"application": this.slotDescription.application,
"call_id": this.slotDescription.id,
// INFO_SLOT_ID_LEGACY_CASE (search for all occurances of this INFO to get the full picture)
// Revert back to "" just for the sending the event.
"call_id": needsEmptyStringRoomFix ? "" : this.slotDescription.id,
"scope": "m.room",
"device_id": this.deviceId,
// DO NOT use this.memberId here since that is the state key (using application...)
@@ -1091,6 +1102,7 @@ export class StickyEventMembershipManager extends MembershipManager {
protected makeMyMembership(expires: number): SessionMembershipData | RtcMembershipData {
const ownMembership = this.ownMembership;
const livekitTransport = isLivekitTransportConfig(this.rtcTransport) ? this.rtcTransport : undefined;
const relationObject = ownMembership?.eventId
? { "m.relation": { rel_type: RelationType.Reference, event_id: ownMembership?.eventId } }
: {};
@@ -1100,7 +1112,11 @@ export class StickyEventMembershipManager extends MembershipManager {
...(this.callIntent ? { "m.call.intent": this.callIntent } : {}),
},
slot_id: slotDescriptionToId(this.slotDescription),
rtc_transports: this.rtcTransport ? [this.rtcTransport] : [],
// Make sure we do not add the alias to the transport.
// It is not needed in matrix2.0. The additional session information will be used to find the right alias on the sfu.
rtc_transports: livekitTransport
? [{ type: livekitTransport.type, livekit_service_url: livekitTransport.livekit_service_url }]
: [],
member: { device_id: this.deviceId, user_id: this.userId, id: this.memberId },
versions: [],
...relationObject,
-1
View File
@@ -38,7 +38,6 @@ export type BeaconEventHandlerMap = {
[BeaconEvent.LivenessChange]: (isLive: boolean, beacon: Beacon) => void;
[BeaconEvent.Destroy]: (beaconIdentifier: string) => void;
[BeaconEvent.LocationUpdate]: (locationState: BeaconLocationState) => void;
[BeaconEvent.Destroy]: (beaconIdentifier: string) => void;
};
export const isTimestampInDuration = (startTimestamp: number, durationMs: number, timestamp: number): boolean =>
+3 -1
View File
@@ -76,6 +76,8 @@ export interface IUnsigned {
"m.relations"?: Record<RelationType | string, any>; // No common pattern for aggregated relations
"msc4354_sticky_duration_ttl_ms"?: number;
[UNSIGNED_THREAD_ID_FIELD.name]?: string;
"membership"?: Membership;
"io.element.msc4115.membership"?: Membership;
}
export interface IThreadBundledRelationship {
@@ -786,7 +788,7 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
*/
public getMembershipAtEvent(): Membership | string | undefined {
const unsigned = this.getUnsigned();
return UNSIGNED_MEMBERSHIP_FIELD.findIn<Membership | string>(unsigned);
return UNSIGNED_MEMBERSHIP_FIELD.findIn<Membership>(unsigned);
}
/**
+38 -16
View File
@@ -53,6 +53,7 @@ export class Relations extends TypedEventEmitter<RelationsEvent, EventHandlerMap
private sortedAnnotationsByKey: [string, Set<MatrixEvent>][] = [];
private targetEvent: MatrixEvent | null = null;
private creationEmitted = false;
private replacementUpdateId = 0;
private readonly client: MatrixClient;
/**
@@ -106,9 +107,8 @@ export class Relations extends TypedEventEmitter<RelationsEvent, EventHandlerMap
if (this.relationType === RelationType.Annotation) {
this.addAnnotationToAggregation(event);
} else if (this.relationType === RelationType.Replace && this.targetEvent && !this.targetEvent.isState()) {
const lastReplacement = await this.getLastReplacement();
this.targetEvent.makeReplaced(lastReplacement!);
} else if (this.relationType === RelationType.Replace) {
await this.updateTargetEventReplacement();
}
event.on(MatrixEventEvent.BeforeRedaction, this.onBeforeRedaction);
@@ -132,9 +132,8 @@ export class Relations extends TypedEventEmitter<RelationsEvent, EventHandlerMap
if (this.relationType === RelationType.Annotation) {
this.removeAnnotationFromAggregation(event);
} else if (this.relationType === RelationType.Replace && this.targetEvent && !this.targetEvent.isState()) {
const lastReplacement = await this.getLastReplacement();
this.targetEvent.makeReplaced(lastReplacement!);
} else if (this.relationType === RelationType.Replace) {
await this.updateTargetEventReplacement();
}
this.emit(RelationsEvent.Remove, event);
@@ -243,9 +242,8 @@ export class Relations extends TypedEventEmitter<RelationsEvent, EventHandlerMap
if (this.relationType === RelationType.Annotation) {
// Remove the redacted annotation from aggregation by key
this.removeAnnotationFromAggregation(redactedEvent);
} else if (this.relationType === RelationType.Replace && this.targetEvent && !this.targetEvent.isState()) {
const lastReplacement = await this.getLastReplacement();
this.targetEvent.makeReplaced(lastReplacement!);
} else if (this.relationType === RelationType.Replace) {
await this.updateTargetEventReplacement();
}
redactedEvent.removeListener(MatrixEventEvent.BeforeRedaction, this.onBeforeRedaction);
@@ -343,18 +341,42 @@ export class Relations extends TypedEventEmitter<RelationsEvent, EventHandlerMap
}
this.targetEvent = event;
if (this.relationType === RelationType.Replace && !this.targetEvent.isState()) {
const replacement = await this.getLastReplacement();
// this is the initial update, so only call it if we already have something
// to not emit Event.replaced needlessly
if (replacement) {
this.targetEvent.makeReplaced(replacement);
}
if (this.relationType === RelationType.Replace) {
await this.updateTargetEventReplacement();
}
this.maybeEmitCreated();
}
/**
* Updates the target event with the latest replacement.
*
* Multiple replacement updates can be triggered concurrently (for example
* while edits are still being decrypted). A monotonic update counter guards
* against older async resolutions overriding newer replacement selections.
*/
private async updateTargetEventReplacement(): Promise<void> {
if (!this.targetEvent || this.targetEvent.isState()) {
return;
}
const targetEvent = this.targetEvent;
const updateId = ++this.replacementUpdateId;
const lastReplacement = await this.getLastReplacement();
// If a newer update started while we were awaiting, discard this stale result.
if (updateId !== this.replacementUpdateId || this.targetEvent !== targetEvent) {
return;
}
// Avoid emitting Event.replaced when there is no replacement and none currently set.
if (!lastReplacement && !targetEvent.replacingEvent()) {
return;
}
targetEvent.makeReplaced(lastReplacement ?? undefined);
}
private maybeEmitCreated(): void {
if (this.creationEmitted) {
return;
+36
View File
@@ -227,6 +227,42 @@ export class RoomMember extends TypedEventEmitter<RoomMemberEvent, RoomMemberEve
}
}
/**
* Recalculate the disambiguation flag for this member based on current room state.
* This should be called when another member's display name changes and may affect
* whether this member needs disambiguation.
*
* @param roomState - The current room state to use for disambiguation check
* @returns true if the member's name changed as a result of the disambiguation update
*
* @remarks
* Fires {@link RoomMemberEvent.Name}
*/
public recalculateDisambiguatedName(roomState: RoomState): boolean {
if (!this.events.member) {
return false;
}
const displayName = this.events.member.getDirectionalContent().displayname ?? "";
const newDisambiguate = shouldDisambiguate(this.userId, displayName, roomState);
if (newDisambiguate === this.disambiguate) {
return false;
}
this.disambiguate = newDisambiguate;
const oldName = this.name;
this.name = calculateDisplayName(this.userId, displayName, this.disambiguate);
if (oldName !== this.name) {
this.updateModifiedTime();
this.emit(RoomMemberEvent.Name, this.events.member, this, oldName);
return true;
}
return false;
}
/**
* Update this room member's power level event. Will fire
* "RoomMember.powerLevel" if the new power level is different
+49 -1
View File
@@ -438,6 +438,11 @@ export class RoomState extends TypedEventEmitter<EmittedEvents, EventHandlerMap>
this.updateModifiedTime();
// update the core event dict
// Track display names that change so we can recalculate disambiguation
const affectedDisplayNames = new Set<string>();
// Track userIds whose membership events we process so we don't emit duplicate events
const processedMemberUserIds = new Set<string>();
stateEvents.forEach((event) => {
if (event.getRoomId() !== this.roomId || !event.isState()) return;
@@ -448,7 +453,22 @@ export class RoomState extends TypedEventEmitter<EmittedEvents, EventHandlerMap>
const lastStateEvent = this.getStateEventMatching(event);
this.setStateEvent(event);
if (event.getType() === EventType.RoomMember) {
this.updateDisplayNameCache(event.getStateKey()!, event.getContent().displayname ?? "");
const userId = event.getStateKey()!;
processedMemberUserIds.add(userId);
const newDisplayName = event.getContent().displayname ?? "";
const oldDisplayName = this.userIdsToDisplayNames[userId];
// Track both old and new display names for disambiguation recalculation
if (oldDisplayName) {
const strippedOld = removeHiddenChars(oldDisplayName);
if (strippedOld) affectedDisplayNames.add(strippedOld);
}
if (newDisplayName) {
const strippedNew = removeHiddenChars(newDisplayName);
if (strippedNew) affectedDisplayNames.add(strippedNew);
}
this.updateDisplayNameCache(userId, newDisplayName);
this.updateThirdPartyTokenCache(event);
}
this.emit(RoomStateEvent.Events, event, this, lastStateEvent);
@@ -514,6 +534,33 @@ export class RoomState extends TypedEventEmitter<EmittedEvents, EventHandlerMap>
}
});
// Recalculate disambiguation for all members whose display names were affected.
// This ensures that when a user changes their name to match (or stop matching)
// another user, all affected users' disambiguation flags are updated correctly.
if (affectedDisplayNames.size > 0) {
// Collect all affected user IDs first to avoid duplicate processing
const affectedUserIds = new Set<string>();
for (const displayName of affectedDisplayNames) {
const userIds = this.displayNameToUserIds.get(displayName) ?? [];
userIds.forEach((id) => affectedUserIds.add(id));
}
// Process each affected member once, excluding those whose membership
// events were already processed (they already got their events emitted)
for (const userId of affectedUserIds) {
if (processedMemberUserIds.has(userId)) {
continue;
}
const member = this.members[userId];
if (member?.events.member) {
const nameChanged = member.recalculateDisambiguatedName(this);
if (nameChanged) {
this.emit(RoomStateEvent.Members, member.events.member, this, member);
}
}
}
}
this.emit(RoomStateEvent.Update, this);
}
@@ -1110,6 +1157,7 @@ export class RoomState extends TypedEventEmitter<EmittedEvents, EventHandlerMap>
private updateDisplayNameCache(userId: string, displayName: string): void {
const oldName = this.userIdsToDisplayNames[userId];
delete this.userIdsToDisplayNames[userId];
if (oldName) {
// Remove the old name from the cache.
+2
View File
@@ -1103,6 +1103,8 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
const inMemoryUpdate = this.loadMembers()
.then((result) => {
this.currentState.setOutOfBandMembers(result.memberEvents);
// recalculate the room name: it may have been based on members, so may have changed
this.recalculate();
return result.fromServer;
})
.catch((err) => {
+29 -2
View File
@@ -52,7 +52,34 @@ interface OidcRegistrationRequestBody {
application_type: "web" | "native";
}
export const DEVICE_CODE_SCOPE = "urn:ietf:params:oauth:grant-type:device_code";
/**
* The OAuth 2.0 grant types that are defined for Matrix in https://spec.matrix.org/v1.17/client-server-api/#grant-types
*/
export enum OAuthGrantType {
/**
* See https://spec.matrix.org/v1.17/client-server-api/#authorization-code-grant
*/
AuthorizationCode = "authorization_code",
/**
* https://spec.matrix.org/v1.17/client-server-api/#refresh-token-grant
*/
RefreshToken = "refresh_token",
/**
* The OAuth 2.0 Device Authorization Grant type identifier as per
* https://www.rfc-editor.org/rfc/rfc8628.html#section-7.2 from
* [MSC4341](https://github.com/matrix-org/matrix-spec-proposals/pull/4341).
*
* @experimental Note that this is UNSTABLE and may have breaking changes without notice.
*/
DeviceAuthorization = "urn:ietf:params:oauth:grant-type:device_code",
}
/**
* The name "scope" is a misnomer here as it is actually a "grant type".
*
* @deprecated use `OAuthGrantType.DeviceAuthorization` instead
*/
export const DEVICE_CODE_SCOPE: string = OAuthGrantType.DeviceAuthorization;
// Check that URIs have a common base, as per the MSC2966 definition
const urlHasCommonBase = (base: URL, urlStr?: string): boolean => {
@@ -79,7 +106,7 @@ export const registerOidcClient = async (
throw new Error(OidcError.DynamicRegistrationNotSupported);
}
const grantTypes: NonEmptyArray<string> = ["authorization_code", "refresh_token"];
const grantTypes: NonEmptyArray<string> = [OAuthGrantType.AuthorizationCode, OAuthGrantType.RefreshToken];
if (grantTypes.some((scope) => !delegatedAuthConfig.grant_types_supported.includes(scope))) {
throw new Error(OidcError.DynamicRegistrationNotSupported);
}
+14 -8
View File
@@ -19,13 +19,17 @@ import { type IdTokenClaims, type OidcMetadata, type SigninResponse } from "oidc
import { logger } from "../logger.ts";
import { OidcError } from "./error.ts";
import { OAuthGrantType } from "./index.ts";
/**
* Metadata from OIDC authority discovery
* Metadata from OAuth 2.0 client authentication API as per
* https://spec.matrix.org/v1.17/client-server-api/#get_matrixclientv1auth_metadata
* With validated properties required in type
*/
export type ValidatedAuthMetadata = Partial<OidcMetadata> &
Pick<
// These values are from [RFC8414](https://datatracker.ietf.org/doc/html/rfc8414#section-2)
// so we can reuse the OidcMetadata definitions from oidc-client-ts
OidcMetadata,
| "issuer"
| "authorization_endpoint"
@@ -35,12 +39,14 @@ export type ValidatedAuthMetadata = Partial<OidcMetadata> &
| "grant_types_supported"
| "code_challenge_methods_supported"
> & {
// MSC2965 extensions to the OIDC spec
// These values aren't part of RFC8414 so we add them here
// Account management fields from stable MSC4191:
account_management_uri?: string;
account_management_actions_supported?: string[];
// The OidcMetadata type from oidc-client-ts does not include `prompt_values_supported`
// even though it is part of the OIDC spec
// Value from [Initiating User Registration via OpenID Connect](https://openid.net/specs/openid-connect-prompt-create-1_0.html):
prompt_values_supported?: string[];
// Experimental MSC4341 value from [RFC8628](https://datatracker.ietf.org/doc/html/rfc8628#section-4):
device_authorization_endpoint?: string;
};
const isRecord = (value: unknown): value is Record<string, unknown> =>
@@ -79,9 +85,9 @@ const requiredArrayValue = (wellKnown: Record<string, unknown>, key: string, val
};
/**
* Validates issuer `.well-known/openid-configuration`
* As defined in RFC5785 https://openid.net/specs/openid-connect-discovery-1_0.html
* validates that OP is compatible with Element's OIDC flow
* Validates OAuth 2.0 auth metadata as defined by
* https://spec.matrix.org/v1.17/client-server-api/#get_matrixclientv1auth_metadata
* is compatible with Element's OAuth/OIDC flow
* @param authMetadata - json object
* @returns valid issuer config
* @throws Error - when issuer config is not found or is invalid
@@ -102,7 +108,7 @@ export const validateAuthMetadata = (authMetadata: unknown): ValidatedAuthMetada
optionalStringProperty(authMetadata, "device_authorization_endpoint"),
optionalStringArrayProperty(authMetadata, "account_management_actions_supported"),
requiredArrayValue(authMetadata, "response_types_supported", "code"),
requiredArrayValue(authMetadata, "grant_types_supported", "authorization_code"),
requiredArrayValue(authMetadata, "grant_types_supported", OAuthGrantType.AuthorizationCode),
requiredArrayValue(authMetadata, "code_challenge_methods_supported", "S256"),
optionalStringArrayProperty(authMetadata, "prompt_values_supported"),
].some((isValid) => !isValid);
+2 -2
View File
@@ -27,7 +27,7 @@ import { logger } from "../logger.ts";
import { type MSC4108SecureChannel } from "./channels/MSC4108SecureChannel.ts";
import { MatrixError } from "../http-api/index.ts";
import { sleep } from "../utils.ts";
import { DEVICE_CODE_SCOPE, type OidcClientConfig } from "../oidc/index.ts";
import { OAuthGrantType, type OidcClientConfig } from "../oidc/index.ts";
import { type CryptoApi } from "../crypto-api/index.ts";
/**
@@ -194,7 +194,7 @@ export class MSC4108SignInWithQR {
logger.error("Failed to discover OIDC metadata", e);
}
if (oidcClientConfig?.grant_types_supported.includes(DEVICE_CODE_SCOPE)) {
if (oidcClientConfig?.grant_types_supported.includes(OAuthGrantType.DeviceAuthorization)) {
await this.send<ProtocolsPayload>({
type: PayloadType.Protocols,
protocols: ["device_authorization_grant"],
@@ -262,7 +262,9 @@ export class MSC4108RendezvousSession {
if (!this.url) return;
try {
await this.fetch(this.url, { method: Method.Delete });
const method = Method.Delete;
logger.info(`=> ${method} ${this.url}`);
await this.fetch(this.url, { method });
} catch (e) {
logger.warn(e);
}
+33
View File
@@ -631,6 +631,24 @@ export class RustBackupManager extends TypedEventEmitter<RustBackupCryptoEvents,
return this.importKeyBackup(keyBackup, backupVersion, backupDecryptor, opts);
}
/**
* Download and import the keys for a given room from the current backup version.
*
* @param roomId - The room in question.
*/
public async downloadLatestRoomKeyBackup(roomId: string): Promise<void> {
const { backupVersion, decryptionKey } = await this.olmMachine.getBackupKeys();
if (!backupVersion || !decryptionKey) {
this.logger.warn(
`downloadLatestRoomKeyBackup: Could not download backup (backupVersion=${backupVersion}, hasDecryptionKey=${!!decryptionKey})`,
);
return;
}
const sessions = await this.downloadRoomKeyBackup(backupVersion, roomId);
const backupDecryptor = this.createBackupDecryptor(decryptionKey);
this.importKeyBackup({ rooms: { [roomId]: { sessions } } }, backupVersion, backupDecryptor);
}
/**
* Call `/room_keys/keys` to download the key backup (room keys) for the given backup version.
* https://spec.matrix.org/v1.12/client-server-api/#get_matrixclientv3room_keyskeys
@@ -650,6 +668,21 @@ export class RustBackupManager extends TypedEventEmitter<RustBackupCryptoEvents,
);
}
/**
* Call `/room/keys/keys/{roomId}` to download the key backup (room keys) for a given backup version and room ID.
* @param backupVersion - The version to download.
* @param roomId - The ID of the room.
* @returns The key backup response.
*/
private downloadRoomKeyBackup(backupVersion: string, roomId: string): Promise<KeyBackupRoomSessions> {
const path = encodeUri("/room_keys/keys/$roomId", {
$roomId: roomId,
});
return this.http.authedRequest<KeyBackupRoomSessions>(Method.Get, path, { version: backupVersion }, undefined, {
prefix: ClientPrefix.V3,
});
}
/**
* Import the room keys from a `/room_keys/keys` call.
* Calls `opts.progressCallback` with the progress of the import.
+11 -5
View File
@@ -1616,25 +1616,31 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
logger.info("Sharing message history");
// 1. Construct the key bundle
// 1. Download keys from backup.
if (!(await this.getOlmMachineOrThrow().hasDownloadedAllRoomKeys(new RustSdkCryptoJs.RoomId(roomId)))) {
await this.backupManager.downloadLatestRoomKeyBackup(roomId);
await this.getOlmMachineOrThrow().setHasDownloadedAllRoomKeys(new RustSdkCryptoJs.RoomId(roomId));
}
// 2. Construct the key bundle
const bundle = await this.getOlmMachineOrThrow().buildRoomKeyBundle(new RustSdkCryptoJs.RoomId(roomId));
if (!bundle) {
logger.info("No keys to share");
return;
}
// 2. Upload the encrypted bundle to the server
// 3. Upload the encrypted bundle to the server
const uploadResponse = await this.http.uploadContent(bundle.encryptedData as Uint8Array<ArrayBuffer>);
logger.info(`Uploaded encrypted key blob: ${JSON.stringify(uploadResponse)}`);
// 3. We may not share a room with the user, so get a fresh list of devices for the invited user.
// 4. We may not share a room with the user, so get a fresh list of devices for the invited user.
const req = this.getOlmMachineOrThrow().queryKeysForUsers([new RustSdkCryptoJs.UserId(userId)]);
await this.outgoingRequestProcessor.makeOutgoingRequest(req);
// 4. Establish Olm sessions with all of the recipient's devices.
// 5. Establish Olm sessions with all of the recipient's devices.
await this.keyClaimManager.ensureSessionsForUsers(logger, [new RustSdkCryptoJs.UserId(userId)]);
// 5. Send to-device messages to the recipient to share the keys.
// 6. Send to-device messages to the recipient to share the keys.
const requests = await this.getOlmMachineOrThrow().shareRoomKeyBundleData(
new RustSdkCryptoJs.UserId(userId),
new RustSdkCryptoJs.RoomId(roomId),
+285 -271
View File
@@ -26,7 +26,7 @@ limitations under the License.
import type { SyncCryptoCallbacks } from "./common-crypto/CryptoBackend.ts";
import { User } from "./models/user.ts";
import { NotificationCountType, Room, RoomEvent } from "./models/room.ts";
import { deepCopy, noUnsafeEventProps, promiseMapSeries, unsafeProp } from "./utils.ts";
import { deepCopy, noUnsafeEventProps, unsafeProp } from "./utils.ts";
import { Filter } from "./filter.ts";
import { EventTimeline } from "./models/event-timeline.ts";
import { type Logger } from "./logger.ts";
@@ -1188,312 +1188,326 @@ export class SyncApi {
this.notifEvents = [];
// Handle invites
await promiseMapSeries(inviteRooms, async (inviteObj) => {
const room = inviteObj.room;
const stateEvents = this.mapSyncEventsFormat(inviteObj.invite_state, room);
await Promise.all(
inviteRooms.map(async (inviteObj) => {
const room = inviteObj.room;
const stateEvents = this.mapSyncEventsFormat(inviteObj.invite_state, room);
await this.injectRoomEvents(room, stateEvents, undefined);
await this.injectRoomEvents(room, stateEvents, undefined);
if (inviteObj.isBrandNewRoom) {
room.recalculate();
client.store.storeRoom(room);
client.emit(ClientEvent.Room, room);
} else {
// Update room state for invite->reject->invite cycles
room.recalculate();
}
stateEvents.forEach(function (e) {
client.emit(ClientEvent.Event, e);
});
});
if (inviteObj.isBrandNewRoom) {
room.recalculate();
client.store.storeRoom(room);
client.emit(ClientEvent.Room, room);
} else {
// Update room state for invite->reject->invite cycles
room.recalculate();
}
stateEvents.forEach(function (e) {
client.emit(ClientEvent.Event, e);
});
}),
);
// Handle joins
await promiseMapSeries(joinRooms, async (joinObj) => {
const room = joinObj.room;
const stateEvents = this.mapSyncEventsFormat(joinObj.state, room);
const stateAfterEvents = this.mapSyncEventsFormat(joinObj["org.matrix.msc4222.state_after"], room);
// Prevent events from being decrypted ahead of time
// this helps large account to speed up faster
// room::decryptCriticalEvent is in charge of decrypting all the events
// required for a client to function properly
const timelineEvents = this.mapSyncEventsFormat(joinObj.timeline, room, false);
const ephemeralEvents = this.mapSyncEventsFormat(joinObj.ephemeral);
const accountDataEvents = this.mapSyncEventsFormat(joinObj.account_data);
const stickyEvents = this.mapSyncEventsFormat(joinObj.msc4354_sticky);
await Promise.all(
joinRooms.map(async (joinObj) => {
const room = joinObj.room;
const stateEvents = this.mapSyncEventsFormat(joinObj.state, room);
const stateAfterEvents = this.mapSyncEventsFormat(joinObj["org.matrix.msc4222.state_after"], room);
// Prevent events from being decrypted ahead of time
// this helps large account to speed up faster
// room::decryptCriticalEvent is in charge of decrypting all the events
// required for a client to function properly
const timelineEvents = this.mapSyncEventsFormat(joinObj.timeline, room, false);
const ephemeralEvents = this.mapSyncEventsFormat(joinObj.ephemeral);
const accountDataEvents = this.mapSyncEventsFormat(joinObj.account_data);
const stickyEvents = this.mapSyncEventsFormat(joinObj.msc4354_sticky);
// If state_after is present, this is the events that form the state at the end of the timeline block and
// regular timeline events do *not* count towards state. If it's not present, then the state is formed by
// the state events plus the timeline events. Note mapSyncEventsFormat returns an empty array if the field
// is absent so we explicitly check the field on the original object.
const eventsFormingFinalState = joinObj["org.matrix.msc4222.state_after"]
? stateAfterEvents
: stateEvents.concat(timelineEvents);
// If state_after is present, this is the events that form the state at the end of the timeline block and
// regular timeline events do *not* count towards state. If it's not present, then the state is formed by
// the state events plus the timeline events. Note mapSyncEventsFormat returns an empty array if the field
// is absent so we explicitly check the field on the original object.
const eventsFormingFinalState = joinObj["org.matrix.msc4222.state_after"]
? stateAfterEvents
: stateEvents.concat(timelineEvents);
const encrypted = this.isRoomEncrypted(room, eventsFormingFinalState);
// We store the server-provided value first so it's correct when any of the events fire.
if (joinObj.unread_notifications) {
/**
* We track unread notifications ourselves in encrypted rooms, so don't
* bother setting it here. We trust our calculations better than the
* server's for this case, and therefore will assume that our non-zero
* count is accurate.
* XXX: this is known faulty as the push rule for `.m.room.encrypted` may be disabled so server
* may issue notification counts of 0 which we wrongly trust.
* https://github.com/matrix-org/matrix-spec-proposals/pull/2654 would fix this
*
* @see import("./client").fixNotificationCountOnDecryption
*/
if (!encrypted || joinObj.unread_notifications.notification_count === 0) {
// In an encrypted room, if the room has notifications enabled then it's typical for
// the server to flag all new messages as notifying. However, some push rules calculate
// events as ignored based on their event contents (e.g. ignoring msgtype=m.notice messages)
// so we want to calculate this figure on the client in all cases.
room.setUnreadNotificationCount(
NotificationCountType.Total,
joinObj.unread_notifications.notification_count ?? 0,
);
}
if (!encrypted || room.getUnreadNotificationCount(NotificationCountType.Highlight) <= 0) {
// If the locally stored highlight count is zero, use the server provided value.
room.setUnreadNotificationCount(
NotificationCountType.Highlight,
joinObj.unread_notifications.highlight_count ?? 0,
);
}
}
const unreadThreadNotifications =
joinObj[UNREAD_THREAD_NOTIFICATIONS.name] ?? joinObj[UNREAD_THREAD_NOTIFICATIONS.altName!];
if (unreadThreadNotifications) {
// This mirrors the logic above for rooms: take the *total* notification count from
// the server for unencrypted rooms or is it's zero. Any threads not present in this
// object implicitly have zero notifications, so start by clearing the total counts
// for all such threads.
room.resetThreadUnreadNotificationCountFromSync(Object.keys(unreadThreadNotifications));
for (const [threadId, unreadNotification] of Object.entries(unreadThreadNotifications)) {
if (!encrypted || unreadNotification.notification_count === 0) {
room.setThreadUnreadNotificationCount(
threadId,
const encrypted = this.isRoomEncrypted(room, eventsFormingFinalState);
// We store the server-provided value first so it's correct when any of the events fire.
if (joinObj.unread_notifications) {
/**
* We track unread notifications ourselves in encrypted rooms, so don't
* bother setting it here. We trust our calculations better than the
* server's for this case, and therefore will assume that our non-zero
* count is accurate.
* XXX: this is known faulty as the push rule for `.m.room.encrypted` may be disabled so server
* may issue notification counts of 0 which we wrongly trust.
* https://github.com/matrix-org/matrix-spec-proposals/pull/2654 would fix this
*
* @see import("./client").fixNotificationCountOnDecryption
*/
if (!encrypted || joinObj.unread_notifications.notification_count === 0) {
// In an encrypted room, if the room has notifications enabled then it's typical for
// the server to flag all new messages as notifying. However, some push rules calculate
// events as ignored based on their event contents (e.g. ignoring msgtype=m.notice messages)
// so we want to calculate this figure on the client in all cases.
room.setUnreadNotificationCount(
NotificationCountType.Total,
unreadNotification.notification_count ?? 0,
joinObj.unread_notifications.notification_count ?? 0,
);
}
const hasNoNotifications =
room.getThreadUnreadNotificationCount(threadId, NotificationCountType.Highlight) <= 0;
if (!encrypted || (encrypted && hasNoNotifications)) {
room.setThreadUnreadNotificationCount(
threadId,
if (!encrypted || room.getUnreadNotificationCount(NotificationCountType.Highlight) <= 0) {
// If the locally stored highlight count is zero, use the server provided value.
room.setUnreadNotificationCount(
NotificationCountType.Highlight,
unreadNotification.highlight_count ?? 0,
joinObj.unread_notifications.highlight_count ?? 0,
);
}
}
} else {
room.resetThreadUnreadNotificationCountFromSync();
}
joinObj.timeline = joinObj.timeline || ({} as ITimeline);
const unreadThreadNotifications =
joinObj[UNREAD_THREAD_NOTIFICATIONS.name] ?? joinObj[UNREAD_THREAD_NOTIFICATIONS.altName!];
if (unreadThreadNotifications) {
// This mirrors the logic above for rooms: take the *total* notification count from
// the server for unencrypted rooms or is it's zero. Any threads not present in this
// object implicitly have zero notifications, so start by clearing the total counts
// for all such threads.
room.resetThreadUnreadNotificationCountFromSync(Object.keys(unreadThreadNotifications));
for (const [threadId, unreadNotification] of Object.entries(unreadThreadNotifications)) {
if (!encrypted || unreadNotification.notification_count === 0) {
room.setThreadUnreadNotificationCount(
threadId,
NotificationCountType.Total,
unreadNotification.notification_count ?? 0,
);
}
if (joinObj.isBrandNewRoom) {
// set the back-pagination token. Do this *before* adding any
// events so that clients can start back-paginating.
if (joinObj.timeline.prev_batch !== null) {
room.getLiveTimeline().setPaginationToken(joinObj.timeline.prev_batch, EventTimeline.BACKWARDS);
}
} else if (joinObj.timeline.limited) {
let limited = true;
// we've got a limited sync, so we *probably* have a gap in the
// timeline, so should reset. But we might have been peeking or
// paginating and already have some of the events, in which
// case we just want to append any subsequent events to the end
// of the existing timeline.
//
// This is particularly important in the case that we already have
// *all* of the events in the timeline - in that case, if we reset
// the timeline, we'll end up with an entirely empty timeline,
// which we'll try to paginate but not get any new events (which
// will stop us linking the empty timeline into the chain).
//
for (let i = timelineEvents.length - 1; i >= 0; i--) {
const eventId = timelineEvents[i].getId()!;
if (room.getTimelineForEvent(eventId)) {
this.syncOpts.logger.debug(`Already have event ${eventId} in limited sync - not resetting`);
limited = false;
// we might still be missing some of the events before i;
// we don't want to be adding them to the end of the
// timeline because that would put them out of order.
timelineEvents.splice(0, i);
// XXX: there's a problem here if the skipped part of the
// timeline modifies the state set in stateEvents, because
// we'll end up using the state from stateEvents rather
// than the later state from timelineEvents. We probably
// need to wind stateEvents forward over the events we're
// skipping.
break;
const hasNoNotifications =
room.getThreadUnreadNotificationCount(threadId, NotificationCountType.Highlight) <= 0;
if (!encrypted || (encrypted && hasNoNotifications)) {
room.setThreadUnreadNotificationCount(
threadId,
NotificationCountType.Highlight,
unreadNotification.highlight_count ?? 0,
);
}
}
}
if (limited) {
room.resetLiveTimeline(
joinObj.timeline.prev_batch,
this.syncOpts.canResetEntireTimeline!(room.roomId)
? null
: (syncEventData.oldSyncToken ?? null),
);
// We have to assume any gap in any timeline is
// reason to stop incrementally tracking notifications and
// reset the timeline.
client.resetNotifTimelineSet();
}
}
// process any crypto events *before* emitting the RoomStateEvent events. This
// avoids a race condition if the application tries to send a message after the
// state event is processed, but before crypto is enabled, which then causes the
// crypto layer to complain.
if (this.syncOpts.cryptoCallbacks) {
for (const e of eventsFormingFinalState) {
if (e.isState() && e.getType() === EventType.RoomEncryption && e.getStateKey() === "") {
await this.syncOpts.cryptoCallbacks.onCryptoEvent(room, e);
}
}
}
// Proactively decrypt state events: normally we decrypt on demand, but for state
// events we need them immediately, so we handle them here. Specifically, consumers
// (e.g. Element Web) expect state events to be unencrypted upon receipt.
for (const ev of timelineEvents.filter((ev) => ev.isState())) {
await this.client.decryptEventIfNeeded(ev);
}
try {
if ("org.matrix.msc4222.state_after" in joinObj) {
await this.injectRoomEvents(
room,
undefined,
stateAfterEvents,
timelineEvents,
syncEventData.fromCache,
);
} else {
await this.injectRoomEvents(room, stateEvents, undefined, timelineEvents, syncEventData.fromCache);
room.resetThreadUnreadNotificationCountFromSync();
}
} catch (e) {
this.syncOpts.logger.error(`Failed to process events on room ${room.roomId}:`, e);
}
// set summary after processing events,
// because it will trigger a name calculation
// which needs the room state to be up to date
if (joinObj.summary) {
room.setSummary(joinObj.summary);
}
joinObj.timeline = joinObj.timeline || ({} as ITimeline);
// we deliberately don't add ephemeral events to the timeline
room.addEphemeralEvents(ephemeralEvents);
if (joinObj.isBrandNewRoom) {
// set the back-pagination token. Do this *before* adding any
// events so that clients can start back-paginating.
if (joinObj.timeline.prev_batch !== null) {
room.getLiveTimeline().setPaginationToken(joinObj.timeline.prev_batch, EventTimeline.BACKWARDS);
}
} else if (joinObj.timeline.limited) {
let limited = true;
// we deliberately don't add accountData to the timeline
room.addAccountData(accountDataEvents);
// we've got a limited sync, so we *probably* have a gap in the
// timeline, so should reset. But we might have been peeking or
// paginating and already have some of the events, in which
// case we just want to append any subsequent events to the end
// of the existing timeline.
//
// This is particularly important in the case that we already have
// *all* of the events in the timeline - in that case, if we reset
// the timeline, we'll end up with an entirely empty timeline,
// which we'll try to paginate but not get any new events (which
// will stop us linking the empty timeline into the chain).
//
for (let i = timelineEvents.length - 1; i >= 0; i--) {
const eventId = timelineEvents[i].getId()!;
if (room.getTimelineForEvent(eventId)) {
this.syncOpts.logger.debug(`Already have event ${eventId} in limited sync - not resetting`);
limited = false;
// Sticky events primarily come via the `timeline` field, with the
// sticky info field marking them as sticky.
// If the sync is "gappy" (meaning it is skipping events to catch up) then
// sticky events will instead come down the sticky section.
// This ensures we collect sticky events from both places.
const stickyEventsAndStickyEventsFromTheTimeline = stickyEvents.concat(
timelineEvents.filter((e) => e.unstableStickyInfo !== undefined),
);
// Note: We calculate sticky events before emitting `.Room` as it's nice to have
// sticky events calculated and ready to go.
room._unstable_addStickyEvents(stickyEventsAndStickyEventsFromTheTimeline);
// we might still be missing some of the events before i;
// we don't want to be adding them to the end of the
// timeline because that would put them out of order.
timelineEvents.splice(0, i);
room.recalculate();
if (joinObj.isBrandNewRoom) {
client.store.storeRoom(room);
client.emit(ClientEvent.Room, room);
}
// XXX: there's a problem here if the skipped part of the
// timeline modifies the state set in stateEvents, because
// we'll end up using the state from stateEvents rather
// than the later state from timelineEvents. We probably
// need to wind stateEvents forward over the events we're
// skipping.
this.processEventsForNotifs(room, timelineEvents);
break;
}
}
const emitEvent = (e: MatrixEvent): boolean => client.emit(ClientEvent.Event, e);
// this fires a couple of times for some events. (eg state events are in the timeline and the state)
// should this get a sync section as an additional event emission param (e, syncSection))?
stateEvents.forEach(emitEvent);
timelineEvents.forEach(emitEvent);
ephemeralEvents.forEach(emitEvent);
accountDataEvents.forEach(emitEvent);
stickyEvents
.filter(
(stickyEvent) =>
// This is highly unlikey, but in the case where a sticky event
// has appeared in the timeline AND the sticky section, we only
// want to emit the event once.
!timelineEvents.some((timelineEvent) => timelineEvent.getId() === stickyEvent.getId()),
)
.forEach(emitEvent);
// Decrypt only the last message in all rooms to make sure we can generate a preview
// And decrypt all events after the recorded read receipt to ensure an accurate
// notification count
room.decryptCriticalEvents();
});
if (limited) {
room.resetLiveTimeline(
joinObj.timeline.prev_batch,
this.syncOpts.canResetEntireTimeline!(room.roomId)
? null
: (syncEventData.oldSyncToken ?? null),
);
// We have to assume any gap in any timeline is
// reason to stop incrementally tracking notifications and
// reset the timeline.
client.resetNotifTimelineSet();
}
}
// process any crypto events *before* emitting the RoomStateEvent events. This
// avoids a race condition if the application tries to send a message after the
// state event is processed, but before crypto is enabled, which then causes the
// crypto layer to complain.
if (this.syncOpts.cryptoCallbacks) {
for (const e of eventsFormingFinalState) {
if (e.isState() && e.getType() === EventType.RoomEncryption && e.getStateKey() === "") {
await this.syncOpts.cryptoCallbacks.onCryptoEvent(room, e);
}
}
}
// Proactively decrypt state events: normally we decrypt on demand, but for state
// events we need them immediately, so we handle them here. Specifically, consumers
// (e.g. Element Web) expect state events to be unencrypted upon receipt.
for (const ev of timelineEvents.filter((ev) => ev.isState())) {
await this.client.decryptEventIfNeeded(ev);
}
try {
if ("org.matrix.msc4222.state_after" in joinObj) {
await this.injectRoomEvents(
room,
undefined,
stateAfterEvents,
timelineEvents,
syncEventData.fromCache,
);
} else {
await this.injectRoomEvents(
room,
stateEvents,
undefined,
timelineEvents,
syncEventData.fromCache,
);
}
} catch (e) {
this.syncOpts.logger.error(`Failed to process events on room ${room.roomId}:`, e);
}
// set summary after processing events,
// because it will trigger a name calculation
// which needs the room state to be up to date
if (joinObj.summary) {
room.setSummary(joinObj.summary);
}
// we deliberately don't add ephemeral events to the timeline
room.addEphemeralEvents(ephemeralEvents);
// we deliberately don't add accountData to the timeline
room.addAccountData(accountDataEvents);
// Sticky events primarily come via the `timeline` field, with the
// sticky info field marking them as sticky.
// If the sync is "gappy" (meaning it is skipping events to catch up) then
// sticky events will instead come down the sticky section.
// This ensures we collect sticky events from both places.
const stickyEventsAndStickyEventsFromTheTimeline = stickyEvents.concat(
timelineEvents.filter((e) => e.unstableStickyInfo !== undefined),
);
// Note: We calculate sticky events before emitting `.Room` as it's nice to have
// sticky events calculated and ready to go.
room._unstable_addStickyEvents(stickyEventsAndStickyEventsFromTheTimeline);
room.recalculate();
if (joinObj.isBrandNewRoom) {
client.store.storeRoom(room);
client.emit(ClientEvent.Room, room);
}
this.processEventsForNotifs(room, timelineEvents);
const emitEvent = (e: MatrixEvent): boolean => client.emit(ClientEvent.Event, e);
// this fires a couple of times for some events. (eg state events are in the timeline and the state)
// should this get a sync section as an additional event emission param (e, syncSection))?
stateEvents.forEach(emitEvent);
timelineEvents.forEach(emitEvent);
ephemeralEvents.forEach(emitEvent);
accountDataEvents.forEach(emitEvent);
stickyEvents
.filter(
(stickyEvent) =>
// This is highly unlikey, but in the case where a sticky event
// has appeared in the timeline AND the sticky section, we only
// want to emit the event once.
!timelineEvents.some((timelineEvent) => timelineEvent.getId() === stickyEvent.getId()),
)
.forEach(emitEvent);
// Decrypt only the last message in all rooms to make sure we can generate a preview
// And decrypt all events after the recorded read receipt to ensure an accurate
// notification count
room.decryptCriticalEvents();
}),
);
// Handle leaves (e.g. kicked rooms)
await promiseMapSeries(leaveRooms, async (leaveObj) => {
const room = leaveObj.room;
const { timelineEvents, stateEvents, stateAfterEvents } = await this.mapAndInjectRoomEvents(leaveObj);
const accountDataEvents = this.mapSyncEventsFormat(leaveObj.account_data);
await Promise.all(
leaveRooms.map(async (leaveObj) => {
const room = leaveObj.room;
const { timelineEvents, stateEvents, stateAfterEvents } = await this.mapAndInjectRoomEvents(leaveObj);
const accountDataEvents = this.mapSyncEventsFormat(leaveObj.account_data);
room.addAccountData(accountDataEvents);
room.addAccountData(accountDataEvents);
room.recalculate();
if (leaveObj.isBrandNewRoom) {
client.store.storeRoom(room);
client.emit(ClientEvent.Room, room);
}
room.recalculate();
if (leaveObj.isBrandNewRoom) {
client.store.storeRoom(room);
client.emit(ClientEvent.Room, room);
}
this.processEventsForNotifs(room, timelineEvents);
this.processEventsForNotifs(room, timelineEvents);
stateEvents?.forEach(function (e) {
client.emit(ClientEvent.Event, e);
});
stateAfterEvents?.forEach(function (e) {
client.emit(ClientEvent.Event, e);
});
timelineEvents.forEach(function (e) {
client.emit(ClientEvent.Event, e);
});
accountDataEvents.forEach(function (e) {
client.emit(ClientEvent.Event, e);
});
});
stateEvents?.forEach(function (e) {
client.emit(ClientEvent.Event, e);
});
stateAfterEvents?.forEach(function (e) {
client.emit(ClientEvent.Event, e);
});
timelineEvents.forEach(function (e) {
client.emit(ClientEvent.Event, e);
});
accountDataEvents.forEach(function (e) {
client.emit(ClientEvent.Event, e);
});
}),
);
// Handle knocks
await promiseMapSeries(knockRooms, async (knockObj) => {
const room = knockObj.room;
const stateEvents = this.mapSyncEventsFormat(knockObj.knock_state, room);
await Promise.all(
knockRooms.map(async (knockObj) => {
const room = knockObj.room;
const stateEvents = this.mapSyncEventsFormat(knockObj.knock_state, room);
await this.injectRoomEvents(room, stateEvents, undefined);
await this.injectRoomEvents(room, stateEvents, undefined);
if (knockObj.isBrandNewRoom) {
room.recalculate();
client.store.storeRoom(room);
client.emit(ClientEvent.Room, room);
} else {
// Update room state for knock->leave->knock cycles
room.recalculate();
}
stateEvents.forEach(function (e) {
client.emit(ClientEvent.Event, e);
});
});
if (knockObj.isBrandNewRoom) {
room.recalculate();
client.store.storeRoom(room);
client.emit(ClientEvent.Room, room);
} else {
// Update room state for knock->leave->knock cycles
room.recalculate();
}
stateEvents.forEach(function (e) {
client.emit(ClientEvent.Event, e);
});
}),
);
// update the notification timeline, if appropriate.
// we only do this for live events, as otherwise we can't order them sanely
+2 -2
View File
@@ -27,7 +27,7 @@ import { type RoomMember } from "./models/room-member.ts";
import { EventType } from "./@types/event.ts";
import { type DecryptionFailureCode } from "./crypto-api/index.ts";
import { DecryptionError, type EventDecryptionResult } from "./common-crypto/CryptoBackend.ts";
import { type OidcClientConfig, type ValidatedAuthMetadata } from "./oidc/index.ts";
import { OAuthGrantType, type OidcClientConfig, type ValidatedAuthMetadata } from "./oidc/index.ts";
/**
* Create a {@link MatrixEvent}.
@@ -226,6 +226,6 @@ export const mockOpenIdConfiguration = (
device_authorization_endpoint: issuer + "device",
jwks_uri: issuer + "jwks",
response_types_supported: ["code"],
grant_types_supported: ["authorization_code", "refresh_token", ...additionalGrantTypes],
grant_types_supported: [OAuthGrantType.AuthorizationCode, OAuthGrantType.RefreshToken, ...additionalGrantTypes],
code_challenge_methods_supported: ["S256"],
});
+15 -1
View File
@@ -23,7 +23,21 @@ limitations under the License.
* versions; only that we should be able to provide a base level of functionality with a server that offers support for
* any of the listed versions.
*/
export const SUPPORTED_MATRIX_VERSIONS = ["v1.1", "v1.2", "v1.3", "v1.4", "v1.5", "v1.6", "v1.7", "v1.8", "v1.9"];
export const SUPPORTED_MATRIX_VERSIONS = [
"v1.1",
"v1.2",
"v1.3",
"v1.4",
"v1.5",
"v1.6",
"v1.7",
"v1.8",
"v1.9",
"v1.10",
"v1.11",
"v1.12",
"v1.13",
];
/**
* The oldest Matrix specification version the js-sdk supports.
+15 -13
View File
@@ -301,7 +301,7 @@ type CallEventType =
| EventType.CallCandidates
| EventType.CallHangup
| EventType.CallReject
| EventType.CallSDPStreamMetadataChangedPrefix;
| EventType.CallSDPStreamMetadataChanged;
export interface VoipEvent {
type: "toDevice" | "sendEvent";
@@ -954,7 +954,7 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
);
}
const sdpStreamMetadata = invite[SDPStreamMetadataKey];
const sdpStreamMetadata = SDPStreamMetadataKey.findIn(invite);
if (sdpStreamMetadata) {
this.updateRemoteSDPStreamMetadata(sdpStreamMetadata);
} else {
@@ -1596,8 +1596,8 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
}
public async sendMetadataUpdate(): Promise<void> {
await this.sendVoipEvent(EventType.CallSDPStreamMetadataChangedPrefix, {
[SDPStreamMetadataKey]: this.getLocalSDPStreamMetadata(),
await this.sendVoipEvent(EventType.CallSDPStreamMetadataChanged, {
[SDPStreamMetadataKey.name]: this.getLocalSDPStreamMetadata(),
});
}
@@ -1628,15 +1628,15 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
}
private async sendAnswer(): Promise<void> {
const answerContent = {
const answerContent: Omit<MCallAnswer, "version" | "call_id" | "party_id" | "conf_id"> = {
answer: {
sdp: this.peerConn!.localDescription!.sdp,
// type is now deprecated as of Matrix VoIP v1, but
// required to still be sent for backwards compat
type: this.peerConn!.localDescription!.type,
},
[SDPStreamMetadataKey]: this.getLocalSDPStreamMetadata(true),
} as MCallAnswer;
[SDPStreamMetadataKey.name]: this.getLocalSDPStreamMetadata(true),
};
answerContent.capabilities = {
"m.call.transferee": this.client.supportsCallTransfer,
@@ -1894,7 +1894,7 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
this.state = CallState.Connecting;
const sdpStreamMetadata = content[SDPStreamMetadataKey];
const sdpStreamMetadata = SDPStreamMetadataKey.findIn(content);
if (sdpStreamMetadata) {
this.updateRemoteSDPStreamMetadata(sdpStreamMetadata);
} else {
@@ -1986,7 +1986,7 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
const prevLocalOnHold = this.isLocalOnHold();
const sdpStreamMetadata = content[SDPStreamMetadataKey];
const sdpStreamMetadata = SDPStreamMetadataKey.findIn<SDPStreamMetadata>(content);
if (sdpStreamMetadata) {
this.updateRemoteSDPStreamMetadata(sdpStreamMetadata);
} else {
@@ -2019,7 +2019,7 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
this.sendVoipEvent(EventType.CallNegotiate, {
lifetime: CALL_TIMEOUT_MS,
description: this.peerConn!.localDescription?.toJSON() as RTCSessionDescription,
[SDPStreamMetadataKey]: this.getLocalSDPStreamMetadata(true),
[SDPStreamMetadataKey.name]: this.getLocalSDPStreamMetadata(true),
});
}
} catch (err) {
@@ -2048,8 +2048,10 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
public onSDPStreamMetadataChangedReceived(event: MatrixEvent): void {
const content = event.getContent<MCallSDPStreamMetadataChanged>();
const metadata = content[SDPStreamMetadataKey];
this.updateRemoteSDPStreamMetadata(metadata);
const metadata = SDPStreamMetadataKey.findIn<SDPStreamMetadata>(content);
if (metadata) {
this.updateRemoteSDPStreamMetadata(metadata);
}
}
public async onAssertedIdentityReceived(event: MatrixEvent): Promise<void> {
@@ -2156,7 +2158,7 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
"m.call.dtmf": false,
};
content[SDPStreamMetadataKey] = this.getLocalSDPStreamMetadata(true);
content[SDPStreamMetadataKey.name] = this.getLocalSDPStreamMetadata(true);
// Get rid of any candidates waiting to be sent: they'll be included in the local
// description we just got and will send in the offer.
+22 -14
View File
@@ -2,9 +2,12 @@
/* eslint-disable camelcase */
import { type CallErrorCode } from "./call.ts";
import { NamespacedValue } from "../NamespacedValue.ts";
// TODO: Change to "sdp_stream_metadata" when MSC3077 is merged
export const SDPStreamMetadataKey = "org.matrix.msc3077.sdp_stream_metadata";
export const SDPStreamMetadataKey = new NamespacedValue(
"sdp_stream_metadata",
"org.matrix.msc3077.sdp_stream_metadata",
);
export enum SDPStreamMetadataPurpose {
Usermedia = "m.usermedia",
@@ -41,10 +44,13 @@ export interface MCallBase {
dest_session_id?: string;
}
type Description = Pick<RTCSessionDescription, "type" | "sdp">;
export interface MCallAnswer extends MCallBase {
answer: RTCSessionDescription;
capabilities?: CallCapabilities;
[SDPStreamMetadataKey]: SDPStreamMetadata;
"answer": Description;
"capabilities"?: CallCapabilities;
"sdp_stream_metadata"?: SDPStreamMetadata;
"org.matrix.msc3077.sdp_stream_metadata"?: SDPStreamMetadata;
}
export interface MCallSelectAnswer extends MCallBase {
@@ -52,18 +58,20 @@ export interface MCallSelectAnswer extends MCallBase {
}
export interface MCallInviteNegotiate extends MCallBase {
offer: RTCSessionDescription;
description: RTCSessionDescription;
lifetime: number;
capabilities?: CallCapabilities;
invitee?: string;
sender_session_id?: string;
dest_session_id?: string;
[SDPStreamMetadataKey]: SDPStreamMetadata;
"offer": Description;
"description": Description;
"lifetime": number;
"capabilities"?: CallCapabilities;
"invitee"?: string;
"sender_session_id"?: string;
"dest_session_id"?: string;
"sdp_stream_metadata"?: SDPStreamMetadata;
"org.matrix.msc3077.sdp_stream_metadata"?: SDPStreamMetadata;
}
export interface MCallSDPStreamMetadataChanged extends MCallBase {
[SDPStreamMetadataKey]: SDPStreamMetadata;
"sdp_stream_metadata"?: SDPStreamMetadata;
"org.matrix.msc3077.sdp_stream_metadata"?: SDPStreamMetadata;
}
export interface MCallReplacesEvent extends MCallBase {
-1
View File
@@ -11,7 +11,6 @@
"strict": true,
"allowImportingTsExtensions": true,
"lib": ["es2024", "dom", "dom.iterable"],
"baseUrl": ".",
"types": ["vitest/globals", "node"],
"paths": {
"#types/*": ["./node_modules/vite/types/*"]
+11
View File
@@ -0,0 +1,11 @@
{
"$schema": "https://developer.microsoft.com/en-us/json-schemas/tsdoc/v0/tsdoc.schema.json",
"extends": ["typedoc/tsdoc.json"],
"noStandardTags": false,
"tagDefinitions": [
{
"tagName": "@alias",
"syntaxKind": "modifier"
}
]
}
+1 -1
View File
@@ -15,7 +15,7 @@ const slowTestReporter: Reporter = {
onTestRunEnd(testModules, unhandledErrors, reason) {
const tests = testModules
.flatMap((m) => Array.from(m.children.allTests()))
.filter((test) => test.diagnostic().slow);
.filter((test) => test.diagnostic()?.slow);
tests.sort((x, y) => x.diagnostic()?.duration! - y.diagnostic()?.duration!);
tests.reverse();
-5908
View File
File diff suppressed because it is too large Load Diff