1
0
mirror of https://github.com/matrix-org/matrix-js-sdk.git synced 2025-07-31 15:24:23 +03:00

Merge branch 'develop' into robertlong/group-call

This commit is contained in:
Robin Townsend
2022-06-21 11:21:03 -04:00
101 changed files with 7194 additions and 4969 deletions

View File

@ -52,6 +52,8 @@ module.exports = {
"@typescript-eslint/no-explicit-any": "off", "@typescript-eslint/no-explicit-any": "off",
// We'd rather not do this but we do // We'd rather not do this but we do
"@typescript-eslint/ban-ts-comment": "off", "@typescript-eslint/ban-ts-comment": "off",
// We're okay with assertion errors when we ask for them
"@typescript-eslint/no-non-null-assertion": "off",
"quotes": "off", "quotes": "off",
// We use a `logger` intermediary module // We use a `logger` intermediary module

53
.github/workflows/jsdoc.yml vendored Normal file
View File

@ -0,0 +1,53 @@
name: Release Process
on:
release:
types: [ published ]
concurrency: ${{ github.workflow }}-${{ github.ref }}
jobs:
jsdoc:
name: Publish Documentation
runs-on: ubuntu-latest
steps:
- name: 🧮 Checkout code
uses: actions/checkout@v3
- name: 🔧 Yarn cache
uses: actions/setup-node@v3
with:
cache: "yarn"
- name: 🔨 Install dependencies
run: "yarn install --pure-lockfile"
- name: 📖 Generate JSDoc
run: "yarn gendoc"
- name: 📋 Copy to temp
run: |
ls -lah
tag="${{ github.ref_name }}"
version="${tag#v}"
echo "VERSION=$version" >> $GITHUB_ENV
cp -a "./.jsdoc/matrix-js-sdk/$version" $RUNNER_TEMP
- name: 🧮 Checkout gh-pages
uses: actions/checkout@v3
with:
ref: gh-pages
- name: 🔪 Prepare
run: |
cp -a "$RUNNER_TEMP/$VERSION" .
# Add the new directory to the index if it isn't there already
if ! grep -q "Version $VERSION" index.html; then
perl -i -pe 'BEGIN {$rel=shift} $_ =~ /^<\/ul>/ && print
"<li><a href=\"${rel}/index.html\">Version ${rel}</a></li>\n"' "$VERSION" index.html
fi
- name: 🚀 Deploy
uses: peaceiris/actions-gh-pages@v3
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
keep_files: true
publish_dir: .

View File

@ -2,13 +2,26 @@ name: Notify Downstream Projects
on: on:
push: push:
branches: [ develop ] branches: [ develop ]
concurrency: ${{ github.workflow }}-${{ github.ref }}
jobs: jobs:
notify-matrix-react-sdk: notify-downstream:
# Only respect triggers from our develop branch, ignore that of forks
if: github.repository == 'matrix-org/matrix-js-sdk'
continue-on-error: true
strategy:
fail-fast: false
matrix:
include:
- repo: vector-im/element-web
event: element-web-notify
- repo: matrix-org/matrix-react-sdk
event: upstream-sdk-notify
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Notify matrix-react-sdk repo that a new SDK build is on develop so it can CI against it - 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@v1 uses: peter-evans/repository-dispatch@v1
with: with:
token: ${{ secrets.ELEMENT_BOT_TOKEN }} token: ${{ secrets.ELEMENT_BOT_TOKEN }}
repository: vector-im/element-web repository: ${{ matrix.repo }}
event-type: upstream-sdk-notify event-type: ${{ matrix.event }}

View File

@ -1,24 +1,92 @@
name: Pull Request name: Pull Request
on: on:
pull_request_target: pull_request_target:
types: [ opened, edited, labeled, unlabeled ] types: [ opened, edited, labeled, unlabeled, synchronize ]
workflow_call:
inputs:
labels:
type: string
default: "T-Defect,T-Deprecation,T-Enhancement,T-Task"
required: false
description: "No longer used, uses allchange logic now, will be removed at a later date"
secrets:
ELEMENT_BOT_TOKEN:
required: true
concurrency: ${{ github.workflow }}-${{ github.event.pull_request.head.ref }}
jobs: jobs:
changelog: changelog:
name: Preview Changelog name: Preview Changelog
if: github.event.action != 'synchronize'
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: matrix-org/allchange@main - uses: matrix-org/allchange@main
with: with:
ghToken: ${{ secrets.GITHUB_TOKEN }} ghToken: ${{ secrets.GITHUB_TOKEN }}
requireLabel: true
enforce-label: prevent-blocked:
name: Enforce Labels name: Prevent Blocked
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
pull-requests: read pull-requests: read
steps: steps:
- uses: yogevbd/enforce-label-action@2.1.0 - name: Add notice
uses: actions/github-script@v5
if: contains(github.event.pull_request.labels.*.name, 'X-Blocked')
with: with:
REQUIRED_LABELS_ANY: "T-Defect,T-Deprecation,T-Enhancement,T-Task" script: |
BANNED_LABELS: "X-Blocked" core.setFailed("Preventing merge whilst PR is marked blocked!");
BANNED_LABELS_DESCRIPTION: "Preventing merge whilst PR is marked blocked!"
community-prs:
name: Label Community PRs
runs-on: ubuntu-latest
if: github.event.action == 'opened'
steps:
- name: Check membership
uses: tspascoal/get-user-teams-membership@v1
id: teams
with:
username: ${{ github.event.pull_request.user.login }}
organization: matrix-org
team: Core Team
GITHUB_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }}
- name: Add label
if: ${{ steps.teams.outputs.isTeamMember == 'false' }}
uses: actions/github-script@v5
with:
script: |
github.rest.issues.addLabels({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
labels: ['Z-Community-PR']
});
close-if-fork-develop:
name: Forbid develop branch fork contributions
runs-on: ubuntu-latest
if: >
github.event.action == 'opened' &&
github.event.pull_request.head.ref == 'develop' &&
github.event.pull_request.head.repo.full_name != github.repository
steps:
- name: Close pull request
uses: actions/github-script@v5
with:
script: |
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: "Thanks for opening this pull request, unfortunately we do not accept contributions from the main" +
" branch of your fork, please re-open once you switch to an alternative branch for everyone's sanity." +
" See https://github.com/matrix-org/matrix-js-sdk/blob/develop/CONTRIBUTING.md",
});
github.rest.pulls.update({
pull_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
state: 'closed'
});

24
.github/workflows/sonarcloud.yml vendored Normal file
View File

@ -0,0 +1,24 @@
# Must only be called from a workflow_run in the context of the upstream repo
name: SonarCloud
on:
workflow_call:
secrets:
SONAR_TOKEN:
required: true
jobs:
sonarqube:
runs-on: ubuntu-latest
if: github.event.workflow_run.conclusion == 'success'
steps:
- name: "🩻 SonarCloud Scan"
uses: matrix-org/sonarcloud-workflow-action@v2.2
with:
repository: ${{ github.event.workflow_run.head_repository.full_name }}
is_pr: ${{ github.event.workflow_run.event == 'pull_request' }}
version_cmd: 'cat package.json | jq -r .version'
branch: ${{ github.event.workflow_run.head_branch }}
revision: ${{ github.event.workflow_run.head_sha }}
token: ${{ secrets.SONAR_TOKEN }}
coverage_run_id: ${{ github.event.workflow_run.id }}
coverage_workflow_name: tests.yml
coverage_extract_path: coverage

View File

@ -4,62 +4,12 @@ on:
workflows: [ "Tests" ] workflows: [ "Tests" ]
types: types:
- completed - completed
concurrency:
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_branch }}
cancel-in-progress: true
jobs: jobs:
sonarqube: sonarqube:
name: SonarQube name: 🩻 SonarQube
runs-on: ubuntu-latest uses: matrix-org/matrix-js-sdk/.github/workflows/sonarcloud.yml@develop
if: github.event.workflow_run.conclusion == 'success' secrets:
steps: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
- uses: actions/checkout@v3
with:
repository: ${{ github.event.workflow_run.head_repository.full_name }}
ref: ${{ github.event.workflow_run.head_branch }} # checkout commit that triggered this workflow
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
# fetch develop so that Sonar can identify new issues in PR builds
- name: Fetch develop
if: "github.event.workflow_run.head_branch != 'develop'"
run: git rev-parse HEAD && git fetch origin develop:develop && git status && git rev-parse HEAD
# There's a 'download artifact' action, but it hasn't been updated for the workflow_run action
# (https://github.com/actions/download-artifact/issues/60) so instead we get this mess:
- name: Download Coverage Report
uses: actions/github-script@v3.1.0
with:
script: |
const artifacts = await github.actions.listWorkflowRunArtifacts({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: ${{ github.event.workflow_run.id }},
});
const matchArtifact = artifacts.data.artifacts.filter((artifact) => {
return artifact.name == "coverage"
})[0];
const download = await github.actions.downloadArtifact({
owner: context.repo.owner,
repo: context.repo.repo,
artifact_id: matchArtifact.id,
archive_format: 'zip',
});
const fs = require('fs');
fs.writeFileSync('${{github.workspace}}/coverage.zip', Buffer.from(download.data));
- name: Extract Coverage Report
run: unzip -d coverage coverage.zip && rm coverage.zip
- name: Read version
id: version
uses: WyriHaximus/github-action-get-previous-tag@v1
- name: SonarCloud Scan
uses: SonarSource/sonarcloud-github-action@master
with:
args: >
-Dsonar.projectVersion=${{ steps.version.outputs.tag }}
-Dsonar.scm.revision=${{ github.event.workflow_run.head_sha }}
-Dsonar.pullrequest.key=${{ github.event.workflow_run.pull_requests[0].number }}
-Dsonar.pullrequest.branch=${{ github.event.workflow_run.pull_requests[0].head.ref }}
-Dsonar.pullrequest.base=${{ github.event.workflow_run.pull_requests[0].base.ref }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}

View File

@ -3,6 +3,9 @@ on:
pull_request: { } pull_request: { }
push: push:
branches: [ develop, master ] branches: [ develop, master ]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs: jobs:
ts_lint: ts_lint:
name: "Typescript Syntax Check" name: "Typescript Syntax Check"

View File

@ -2,7 +2,10 @@ name: Tests
on: on:
pull_request: { } pull_request: { }
push: push:
branches: [ develop, main, master ] branches: [ develop, master ]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs: jobs:
jest: jest:
name: Jest name: Jest
@ -23,7 +26,7 @@ jobs:
run: "yarn build" run: "yarn build"
- name: Run tests with coverage - name: Run tests with coverage
run: "yarn coverage --ci" run: "yarn coverage --ci --reporters github-actions"
- name: Upload Artifact - name: Upload Artifact
uses: actions/upload-artifact@v2 uses: actions/upload-artifact@v2

View File

@ -1,2 +0,0 @@
instrumentation:
compact: false

View File

@ -1,3 +1,37 @@
Changes in [18.1.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v18.1.0) (2022-06-07)
==================================================================================================
## ✨ Features
* Convert `getLocalAliases` to a stable API call ([\#2402](https://github.com/matrix-org/matrix-js-sdk/pull/2402)).
## 🐛 Bug Fixes
* Fix request, crypto, and bs58 imports ([\#2414](https://github.com/matrix-org/matrix-js-sdk/pull/2414)). Fixes #2415.
* Update relations after every decryption attempt ([\#2387](https://github.com/matrix-org/matrix-js-sdk/pull/2387)). Fixes vector-im/element-web#22258. Contributed by @weeman1337.
* Fix degraded mode for the IDBStore and test it ([\#2400](https://github.com/matrix-org/matrix-js-sdk/pull/2400)). Fixes matrix-org/element-web-rageshakes#13170.
* Don't cancel SAS verifications if `ready` is received after `start` ([\#2250](https://github.com/matrix-org/matrix-js-sdk/pull/2250)).
* Prevent overlapping sync accumulator persists ([\#2392](https://github.com/matrix-org/matrix-js-sdk/pull/2392)). Fixes vector-im/element-web#21541.
* Fix behaviour of isRelation with relation m.replace for state events ([\#2389](https://github.com/matrix-org/matrix-js-sdk/pull/2389)). Fixes vector-im/element-web#22280.
* Fixes #2384 ([\#2385](https://github.com/matrix-org/matrix-js-sdk/pull/2385)). Fixes undefined/matrix-js-sdk#2384. Contributed by @schmop.
* Ensure rooms are recalculated on re-invites ([\#2374](https://github.com/matrix-org/matrix-js-sdk/pull/2374)). Fixes vector-im/element-web#22106.
Changes in [18.0.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v18.0.0) (2022-05-24)
==================================================================================================
## 🚨 BREAKING CHANGES (to experimental methods)
* Implement changes to MSC2285 (private read receipts) ([\#2221](https://github.com/matrix-org/matrix-js-sdk/pull/2221)).
## ✨ Features
* Add support for HTML renderings of room topics ([\#2272](https://github.com/matrix-org/matrix-js-sdk/pull/2272)).
* Add stopClient parameter to MatrixClient::logout ([\#2367](https://github.com/matrix-org/matrix-js-sdk/pull/2367)).
* registration: add function to re-request email token ([\#2357](https://github.com/matrix-org/matrix-js-sdk/pull/2357)).
* Remove hacky custom status feature ([\#2350](https://github.com/matrix-org/matrix-js-sdk/pull/2350)).
## 🐛 Bug Fixes
* Remove default push rule override for MSC1930 ([\#2376](https://github.com/matrix-org/matrix-js-sdk/pull/2376)). Fixes vector-im/element-web#15439.
* Tweak thread creation & event adding to fix bugs around relations ([\#2369](https://github.com/matrix-org/matrix-js-sdk/pull/2369)). Fixes vector-im/element-web#22162 and vector-im/element-web#22180.
* Prune both clear & wire content on redaction ([\#2346](https://github.com/matrix-org/matrix-js-sdk/pull/2346)). Fixes vector-im/element-web#21929.
* MSC3786: Add a default push rule to ignore `m.room.server_acl` events ([\#2333](https://github.com/matrix-org/matrix-js-sdk/pull/2333)). Fixes vector-im/element-web#20788.
Changes in [17.2.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v17.2.0) (2022-05-10) Changes in [17.2.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v17.2.0) (2022-05-10)
================================================================================================== ==================================================================================================

View File

@ -23,7 +23,18 @@ Things that should go into your PR description:
* A changelog entry in the `Notes` section (see below) * A changelog entry in the `Notes` section (see below)
* References to any bugs fixed by the change (in GitHub's `Fixes` notation) * References to any bugs fixed by the change (in GitHub's `Fixes` notation)
* Describe the why and what is changing in the PR description so it's easy for * Describe the why and what is changing in the PR description so it's easy for
onlookers and reviewers to onboard and context switch. onlookers and reviewers to onboard and context switch. This information is
also helpful when we come back to look at this in 6 months and ask "why did
we do it like that?" we have a chance of finding out.
* Why didn't it work before? Why does it work now? What use cases does it
unlock?
* If you find yourself adding information on how the code works or why you
chose to do it the way you did, make sure this information is instead
written as comments in the code itself.
* Sometimes a PR can change considerably as it is developed. In this case,
the description should be updated to reflect the most recent state of
the PR. (It can be helpful to retain the old content under a suitable
heading, for additional context.)
* Include both **before** and **after** screenshots to easily compare and discuss * Include both **before** and **after** screenshots to easily compare and discuss
what's changing. what's changing.
* Include a step-by-step testing strategy so that a reviewer can check out the * Include a step-by-step testing strategy so that a reviewer can check out the
@ -31,11 +42,6 @@ Things that should go into your PR description:
* Add comments to the diff for the reviewer that might help them to understand * Add comments to the diff for the reviewer that might help them to understand
why the change is necessary or how they might better understand and review it. why the change is necessary or how they might better understand and review it.
Things that should *not* go into your PR description:
* Any information on how the code works or why you chose to do it the way
you did. If this isn't obvious from your code, you haven't written enough
comments.
We rely on information in pull request to populate the information that goes We rely on information in pull request to populate the information that goes
into the changelogs our users see, both for the JS SDK itself and also for some into the changelogs our users see, both for the JS SDK itself and also for some
projects based on it. This is picked up from both labels on the pull request and projects based on it. This is picked up from both labels on the pull request and
@ -129,6 +135,16 @@ When writing unit tests, please aim for a high level of test coverage
for new code - 80% or greater. If you cannot achieve that, please document for new code - 80% or greater. If you cannot achieve that, please document
why it's not possible in your PR. why it's not possible in your PR.
Some sections of code are not sensible to add coverage for, such as those
which explicitly inhibit noisy logging for tests. Which can be hidden using
an istanbul magic comment as [documented here][1]. See example:
```javascript
/* istanbul ignore if */
if (process.env.NODE_ENV !== "test") {
logger.error("Log line that is noisy enough in tests to want to skip");
}
```
Tests validate that your change works as intended and also document Tests validate that your change works as intended and also document
concisely what is being changed. Ideally, your new tests fail concisely what is being changed. Ideally, your new tests fail
prior to your change, and succeed once it has been applied. You may prior to your change, and succeed once it has been applied. You may
@ -244,14 +260,25 @@ on Git 2.17+ you can mass signoff using rebase:
git rebase --signoff origin/develop git rebase --signoff origin/develop
``` ```
Review expectations
===================
See https://github.com/vector-im/element-meta/wiki/Review-process
Merge Strategy Merge Strategy
============== ==============
The preferred method for merging pull requests is squash merging to keep the The preferred method for merging pull requests is squash merging to keep the
commit history trim, but it is up to the discretion of the team member merging commit history trim, but it is up to the discretion of the team member merging
the change. When stacking pull requests, you may wish to do the following: the change. We do not support rebase merges due to `allchange` being unable to
handle them. When merging make sure to leave the default commit title, or
at least leave the PR number at the end in brackets like by default.
When stacking pull requests, you may wish to do the following:
1. Branch from develop to your branch (branch1), push commits onto it and open a pull request 1. Branch from develop to your branch (branch1), push commits onto it and open a pull request
2. Branch from your base branch (branch1) to your work branch (branch2), push commits and open a pull request configuring the base to be branch1, saying in the description that it is based on your other PR. 2. Branch from your base branch (branch1) to your work branch (branch2), push commits and open a pull request configuring the base to be branch1, saying in the description that it is based on your other PR.
3. Merge the first PR using a merge commit otherwise your stacked PR will need a rebase. Github will automatically adjust the base branch of your other PR to be develop. 3. Merge the first PR using a merge commit otherwise your stacked PR will need a rebase. Github will automatically adjust the base branch of your other PR to be develop.
[1]: https://github.com/gotwarlost/istanbul/blob/master/ignoring-code-for-coverage.md

View File

@ -1,6 +1,6 @@
{ {
"name": "matrix-js-sdk", "name": "matrix-js-sdk",
"version": "17.2.0", "version": "18.1.0",
"description": "Matrix Client-Server SDK for Javascript", "description": "Matrix Client-Server SDK for Javascript",
"engines": { "engines": {
"node": ">=12.9.0" "node": ">=12.9.0"
@ -81,28 +81,28 @@
"@matrix-org/olm": "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.8.tgz", "@matrix-org/olm": "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.8.tgz",
"@types/bs58": "^4.0.1", "@types/bs58": "^4.0.1",
"@types/content-type": "^1.1.5", "@types/content-type": "^1.1.5",
"@types/jest": "^26.0.20", "@types/jest": "^27.0.0",
"@types/node": "12", "@types/node": "12",
"@types/request": "^2.48.5", "@types/request": "^2.48.5",
"@typescript-eslint/eslint-plugin": "^5.6.0", "@typescript-eslint/eslint-plugin": "^5.6.0",
"@typescript-eslint/parser": "^5.6.0", "@typescript-eslint/parser": "^5.6.0",
"allchange": "^1.0.6", "allchange": "^1.0.6",
"babel-jest": "^26.6.3", "babel-jest": "^28.0.0",
"babelify": "^10.0.0", "babelify": "^10.0.0",
"better-docs": "^2.4.0-beta.9", "better-docs": "^2.4.0-beta.9",
"browserify": "^17.0.0", "browserify": "^17.0.0",
"docdash": "^1.2.0", "docdash": "^1.2.0",
"eslint": "8.9.0", "eslint": "8.16.0",
"eslint-config-google": "^0.14.0", "eslint-config-google": "^0.14.0",
"eslint-plugin-import": "^2.25.4", "eslint-plugin-import": "^2.25.4",
"eslint-plugin-matrix-org": "^0.4.0", "eslint-plugin-matrix-org": "^0.5.0",
"exorcist": "^1.0.1", "exorcist": "^1.0.1",
"fake-indexeddb": "^3.1.2", "fake-indexeddb": "^3.1.2",
"jest": "^26.6.3", "jest": "^28.0.0",
"jest-localstorage-mock": "^2.4.6", "jest-localstorage-mock": "^2.4.6",
"jest-sonar-reporter": "^2.0.0", "jest-sonar-reporter": "^2.0.0",
"jsdoc": "^3.6.6", "jsdoc": "^3.6.6",
"matrix-mock-request": "^1.2.3", "matrix-mock-request": "^2.0.1",
"rimraf": "^3.0.2", "rimraf": "^3.0.2",
"terser": "^5.5.1", "terser": "^5.5.1",
"tsify": "^5.0.2", "tsify": "^5.0.2",

View File

@ -29,7 +29,7 @@ fi
npm --version > /dev/null || (echo "npm is required: please install it"; kill $$) npm --version > /dev/null || (echo "npm is required: please install it"; kill $$)
yarn --version > /dev/null || (echo "yarn is required: please install it"; kill $$) yarn --version > /dev/null || (echo "yarn is required: please install it"; kill $$)
USAGE="$0 [-xz] [-c changelog_file] vX.Y.Z" USAGE="$0 [-x] [-c changelog_file] vX.Y.Z"
help() { help() {
cat <<EOF cat <<EOF
@ -37,7 +37,6 @@ $USAGE
-c changelog_file: specify name of file containing changelog -c changelog_file: specify name of file containing changelog
-x: skip updating the changelog -x: skip updating the changelog
-z: skip generating the jsdoc
-n: skip publish to NPM -n: skip publish to NPM
EOF EOF
} }
@ -60,7 +59,6 @@ if ! git diff-files --quiet; then
fi fi
skip_changelog= skip_changelog=
skip_jsdoc=
skip_npm= skip_npm=
changelog_file="CHANGELOG.md" changelog_file="CHANGELOG.md"
expected_npm_user="matrixdotorg" expected_npm_user="matrixdotorg"
@ -76,9 +74,6 @@ while getopts hc:u:xzn f; do
x) x)
skip_changelog=1 skip_changelog=1
;; ;;
z)
skip_jsdoc=1
;;
n) n)
skip_npm=1 skip_npm=1
;; ;;
@ -326,22 +321,6 @@ if [ -z "$skip_npm" ]; then
fi fi
fi fi
if [ -z "$skip_jsdoc" ]; then
echo "generating jsdocs"
yarn gendoc
echo "copying jsdocs to gh-pages branch"
git checkout gh-pages
git pull
cp -a ".jsdoc/matrix-js-sdk/$release" .
perl -i -pe 'BEGIN {$rel=shift} $_ =~ /^<\/ul>/ && print
"<li><a href=\"${rel}/index.html\">Version ${rel}</a></li>\n"' \
$release index.html
git add "$release"
git commit --no-verify -m "Add jsdoc for $release" index.html "$release"
git push origin gh-pages
fi
# if it is a pre-release, leave it on the release branch for now. # if it is a pre-release, leave it on the release branch for now.
if [ $prerelease -eq 1 ]; then if [ $prerelease -eq 1 ]; then
git checkout "$rel_branch" git checkout "$rel_branch"

16
renovate.json Normal file
View File

@ -0,0 +1,16 @@
{
"extends": [
"config:base",
":dependencyDashboardApproval"
],
"labels": ["T-Task", "Dependencies"],
"lockFileMaintenance": { "enabled": true },
"groupName": "all",
"packageRules": [{
"matchFiles": ["package.json"],
"rangeStrategy": "update-lockfile"
}],
"platformAutomerge": true,
"automerge": true,
"automergeType": "pr"
}

View File

@ -1,238 +0,0 @@
/*
Copyright 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2018-2019 New Vector Ltd
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.
*/
// load olm before the sdk if possible
import './olm-loader';
import MockHttpBackend from 'matrix-mock-request';
import { LocalStorageCryptoStore } from '../src/crypto/store/localStorage-crypto-store';
import { logger } from '../src/logger';
import { WebStorageSessionStore } from "../src/store/session/webstorage";
import { syncPromise } from "./test-utils/test-utils";
import { createClient } from "../src/matrix";
import { MockStorageApi } from "./MockStorageApi";
/**
* Wrapper for a MockStorageApi, MockHttpBackend and MatrixClient
*
* @constructor
* @param {string} userId
* @param {string} deviceId
* @param {string} accessToken
*
* @param {WebStorage=} sessionStoreBackend a web storage object to use for the
* session store. If undefined, we will create a MockStorageApi.
* @param {object} options additional options to pass to the client
*/
export function TestClient(
userId, deviceId, accessToken, sessionStoreBackend, options,
) {
this.userId = userId;
this.deviceId = deviceId;
if (sessionStoreBackend === undefined) {
sessionStoreBackend = new MockStorageApi();
}
const sessionStore = new WebStorageSessionStore(sessionStoreBackend);
this.httpBackend = new MockHttpBackend();
options = Object.assign({
baseUrl: "http://" + userId + ".test.server",
userId: userId,
accessToken: accessToken,
deviceId: deviceId,
sessionStore: sessionStore,
request: this.httpBackend.requestFn,
}, options);
if (!options.cryptoStore) {
// expose this so the tests can get to it
this.cryptoStore = new LocalStorageCryptoStore(sessionStoreBackend);
options.cryptoStore = this.cryptoStore;
}
this.client = createClient(options);
this.deviceKeys = null;
this.oneTimeKeys = {};
this.callEventHandler = {
calls: new Map(),
};
}
TestClient.prototype.toString = function() {
return 'TestClient[' + this.userId + ']';
};
/**
* start the client, and wait for it to initialise.
*
* @return {Promise}
*/
TestClient.prototype.start = function() {
logger.log(this + ': starting');
this.httpBackend.when("GET", "/versions").respond(200, {});
this.httpBackend.when("GET", "/pushrules").respond(200, {});
this.httpBackend.when("POST", "/filter").respond(200, { filter_id: "fid" });
this.expectDeviceKeyUpload();
// we let the client do a very basic initial sync, which it needs before
// it will upload one-time keys.
this.httpBackend.when("GET", "/sync").respond(200, { next_batch: 1 });
this.client.startClient({
// set this so that we can get hold of failed events
pendingEventOrdering: 'detached',
});
return Promise.all([
this.httpBackend.flushAllExpected(),
syncPromise(this.client),
]).then(() => {
logger.log(this + ': started');
});
};
/**
* stop the client
* @return {Promise} Resolves once the mock http backend has finished all pending flushes
*/
TestClient.prototype.stop = function() {
this.client.stopClient();
return this.httpBackend.stop();
};
/**
* Set up expectations that the client will upload device keys.
*/
TestClient.prototype.expectDeviceKeyUpload = function() {
const self = this;
this.httpBackend.when("POST", "/keys/upload").respond(200, function(path, content) {
expect(content.one_time_keys).toBe(undefined);
expect(content.device_keys).toBeTruthy();
logger.log(self + ': received device keys');
// we expect this to happen before any one-time keys are uploaded.
expect(Object.keys(self.oneTimeKeys).length).toEqual(0);
self.deviceKeys = content.device_keys;
return { one_time_key_counts: { signed_curve25519: 0 } };
});
};
/**
* If one-time keys have already been uploaded, return them. Otherwise,
* set up an expectation that the keys will be uploaded, and wait for
* that to happen.
*
* @returns {Promise} for the one-time keys
*/
TestClient.prototype.awaitOneTimeKeyUpload = function() {
if (Object.keys(this.oneTimeKeys).length != 0) {
// already got one-time keys
return Promise.resolve(this.oneTimeKeys);
}
this.httpBackend.when("POST", "/keys/upload")
.respond(200, (path, content) => {
expect(content.device_keys).toBe(undefined);
expect(content.one_time_keys).toBe(undefined);
return { one_time_key_counts: {
signed_curve25519: Object.keys(this.oneTimeKeys).length,
} };
});
this.httpBackend.when("POST", "/keys/upload")
.respond(200, (path, content) => {
expect(content.device_keys).toBe(undefined);
expect(content.one_time_keys).toBeTruthy();
expect(content.one_time_keys).not.toEqual({});
logger.log('%s: received %i one-time keys', this,
Object.keys(content.one_time_keys).length);
this.oneTimeKeys = content.one_time_keys;
return { one_time_key_counts: {
signed_curve25519: Object.keys(this.oneTimeKeys).length,
} };
});
// this can take ages
return this.httpBackend.flush('/keys/upload', 2, 1000).then((flushed) => {
expect(flushed).toEqual(2);
return this.oneTimeKeys;
});
};
/**
* Set up expectations that the client will query device keys.
*
* We check that the query contains each of the users in `response`.
*
* @param {Object} response response to the query.
*/
TestClient.prototype.expectKeyQuery = function(response) {
this.httpBackend.when('POST', '/keys/query').respond(
200, (path, content) => {
Object.keys(response.device_keys).forEach((userId) => {
expect(content.device_keys[userId]).toEqual(
[],
"Expected key query for " + userId + ", got " +
Object.keys(content.device_keys),
);
});
return response;
});
};
/**
* get the uploaded curve25519 device key
*
* @return {string} base64 device key
*/
TestClient.prototype.getDeviceKey = function() {
const keyId = 'curve25519:' + this.deviceId;
return this.deviceKeys.keys[keyId];
};
/**
* get the uploaded ed25519 device key
*
* @return {string} base64 device key
*/
TestClient.prototype.getSigningKey = function() {
const keyId = 'ed25519:' + this.deviceId;
return this.deviceKeys.keys[keyId];
};
/**
* flush a single /sync request, and wait for the syncing event
*
* @returns {Promise} promise which completes once the sync has been flushed
*/
TestClient.prototype.flushSync = function() {
logger.log(`${this}: flushSync`);
return Promise.all([
this.httpBackend.flush('/sync', 1),
syncPromise(this.client),
]).then(() => {
logger.log(`${this}: flushSync completed`);
});
};
TestClient.prototype.isFallbackICEServerAllowed = function() {
return true;
};

239
spec/TestClient.ts Normal file
View File

@ -0,0 +1,239 @@
/*
Copyright 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2018-2019 New Vector Ltd
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.
*/
// load olm before the sdk if possible
import './olm-loader';
import MockHttpBackend from 'matrix-mock-request';
import { LocalStorageCryptoStore } from '../src/crypto/store/localStorage-crypto-store';
import { logger } from '../src/logger';
import { syncPromise } from "./test-utils/test-utils";
import { createClient } from "../src/matrix";
import { ICreateClientOpts, IDownloadKeyResult, MatrixClient, PendingEventOrdering } from "../src/client";
import { MockStorageApi } from "./MockStorageApi";
import { encodeUri } from "../src/utils";
import { IDeviceKeys, IOneTimeKey } from "../src/crypto/dehydration";
import { IKeyBackupSession } from "../src/crypto/keybackup";
import { IHttpOpts } from "../src/http-api";
import { IKeysUploadResponse, IUploadKeysRequest } from '../src/client';
/**
* Wrapper for a MockStorageApi, MockHttpBackend and MatrixClient
*/
export class TestClient {
public readonly httpBackend: MockHttpBackend;
public readonly client: MatrixClient;
private deviceKeys: IDeviceKeys;
private oneTimeKeys: Record<string, IOneTimeKey>;
constructor(
public readonly userId?: string,
public readonly deviceId?: string,
accessToken?: string,
sessionStoreBackend?: Storage,
options?: Partial<ICreateClientOpts>,
) {
if (sessionStoreBackend === undefined) {
sessionStoreBackend = new MockStorageApi();
}
this.httpBackend = new MockHttpBackend();
const fullOptions: ICreateClientOpts = {
baseUrl: "http://" + userId + ".test.server",
userId: userId,
accessToken: accessToken,
deviceId: deviceId,
request: this.httpBackend.requestFn as IHttpOpts["request"],
...options,
};
if (!fullOptions.cryptoStore) {
// expose this so the tests can get to it
fullOptions.cryptoStore = new LocalStorageCryptoStore(sessionStoreBackend);
}
this.client = createClient(fullOptions);
this.deviceKeys = null;
this.oneTimeKeys = {};
}
public toString(): string {
return 'TestClient[' + this.userId + ']';
}
/**
* start the client, and wait for it to initialise.
*/
public start(): Promise<void> {
logger.log(this + ': starting');
this.httpBackend.when("GET", "/versions").respond(200, {});
this.httpBackend.when("GET", "/pushrules").respond(200, {});
this.httpBackend.when("POST", "/filter").respond(200, { filter_id: "fid" });
this.expectDeviceKeyUpload();
// we let the client do a very basic initial sync, which it needs before
// it will upload one-time keys.
this.httpBackend.when("GET", "/sync").respond(200, { next_batch: 1 });
this.client.startClient({
// set this so that we can get hold of failed events
pendingEventOrdering: PendingEventOrdering.Detached,
});
return Promise.all([
this.httpBackend.flushAllExpected(),
syncPromise(this.client),
]).then(() => {
logger.log(this + ': started');
});
}
/**
* stop the client
* @return {Promise} Resolves once the mock http backend has finished all pending flushes
*/
public async stop(): Promise<void> {
this.client.stopClient();
await this.httpBackend.stop();
}
/**
* Set up expectations that the client will upload device keys.
*/
public expectDeviceKeyUpload() {
this.httpBackend.when("POST", "/keys/upload")
.respond<IKeysUploadResponse, IUploadKeysRequest>(200, (_path, content) => {
expect(content.one_time_keys).toBe(undefined);
expect(content.device_keys).toBeTruthy();
logger.log(this + ': received device keys');
// we expect this to happen before any one-time keys are uploaded.
expect(Object.keys(this.oneTimeKeys).length).toEqual(0);
this.deviceKeys = content.device_keys;
return { one_time_key_counts: { signed_curve25519: 0 } };
});
}
/**
* If one-time keys have already been uploaded, return them. Otherwise,
* set up an expectation that the keys will be uploaded, and wait for
* that to happen.
*
* @returns {Promise} for the one-time keys
*/
public awaitOneTimeKeyUpload(): Promise<Record<string, IOneTimeKey>> {
if (Object.keys(this.oneTimeKeys).length != 0) {
// already got one-time keys
return Promise.resolve(this.oneTimeKeys);
}
this.httpBackend.when("POST", "/keys/upload")
.respond<IKeysUploadResponse, IUploadKeysRequest>(200, (_path, content: IUploadKeysRequest) => {
expect(content.device_keys).toBe(undefined);
expect(content.one_time_keys).toBe(undefined);
return { one_time_key_counts: {
signed_curve25519: Object.keys(this.oneTimeKeys).length,
} };
});
this.httpBackend.when("POST", "/keys/upload")
.respond<IKeysUploadResponse, IUploadKeysRequest>(200, (_path, content: IUploadKeysRequest) => {
expect(content.device_keys).toBe(undefined);
expect(content.one_time_keys).toBeTruthy();
expect(content.one_time_keys).not.toEqual({});
logger.log('%s: received %i one-time keys', this,
Object.keys(content.one_time_keys).length);
this.oneTimeKeys = content.one_time_keys;
return { one_time_key_counts: {
signed_curve25519: Object.keys(this.oneTimeKeys).length,
} };
});
// this can take ages
return this.httpBackend.flush('/keys/upload', 2, 1000).then((flushed) => {
expect(flushed).toEqual(2);
return this.oneTimeKeys;
});
}
/**
* Set up expectations that the client will query device keys.
*
* We check that the query contains each of the users in `response`.
*
* @param {Object} response response to the query.
*/
public expectKeyQuery(response: IDownloadKeyResult) {
this.httpBackend.when('POST', '/keys/query').respond<IDownloadKeyResult>(
200, (_path, content) => {
Object.keys(response.device_keys).forEach((userId) => {
expect(content.device_keys[userId]).toEqual([]);
});
return response;
});
}
/**
* Set up expectations that the client will query key backups for a particular session
*/
public expectKeyBackupQuery(roomId: string, sessionId: string, status: number, response: IKeyBackupSession) {
this.httpBackend.when('GET', encodeUri("/room_keys/keys/$roomId/$sessionId", {
$roomId: roomId,
$sessionId: sessionId,
})).respond(status, response);
}
/**
* get the uploaded curve25519 device key
*
* @return {string} base64 device key
*/
public getDeviceKey(): string {
const keyId = 'curve25519:' + this.deviceId;
return this.deviceKeys.keys[keyId];
}
/**
* get the uploaded ed25519 device key
*
* @return {string} base64 device key
*/
public getSigningKey(): string {
const keyId = 'ed25519:' + this.deviceId;
return this.deviceKeys.keys[keyId];
}
/**
* flush a single /sync request, and wait for the syncing event
*/
public flushSync(): Promise<void> {
logger.log(`${this}: flushSync`);
return Promise.all([
this.httpBackend.flush('/sync', 1),
syncPromise(this.client),
]).then(() => {
logger.log(`${this}: flushSync completed`);
});
}
public isFallbackICEServerAllowed(): boolean {
return true;
}
}

View File

@ -136,138 +136,137 @@ describe("DeviceList management:", function() {
}); });
}); });
it("We should not get confused by out-of-order device query responses", it.skip("We should not get confused by out-of-order device query responses", () => {
() => { // https://github.com/vector-im/element-web/issues/3126
// https://github.com/vector-im/element-web/issues/3126 aliceTestClient.expectKeyQuery({ device_keys: { '@alice:localhost': {} } });
aliceTestClient.expectKeyQuery({ device_keys: { '@alice:localhost': {} } }); return aliceTestClient.start().then(() => {
return aliceTestClient.start().then(() => { aliceTestClient.httpBackend.when('GET', '/sync').respond(
aliceTestClient.httpBackend.when('GET', '/sync').respond( 200, getSyncResponse(['@bob:xyz', '@chris:abc']));
200, getSyncResponse(['@bob:xyz', '@chris:abc'])); return aliceTestClient.flushSync();
return aliceTestClient.flushSync(); }).then(() => {
}).then(() => { // to make sure the initial device queries are flushed out, we
// to make sure the initial device queries are flushed out, we // attempt to send a message.
// attempt to send a message.
aliceTestClient.httpBackend.when('POST', '/keys/query').respond( aliceTestClient.httpBackend.when('POST', '/keys/query').respond(
200, { 200, {
device_keys: { device_keys: {
'@bob:xyz': {}, '@bob:xyz': {},
'@chris:abc': {}, '@chris:abc': {},
}, },
}, },
); );
aliceTestClient.httpBackend.when('PUT', '/send/').respond( aliceTestClient.httpBackend.when('PUT', '/send/').respond(
200, { event_id: '$event1' }); 200, { event_id: '$event1' });
return Promise.all([ return Promise.all([
aliceTestClient.client.sendTextMessage(ROOM_ID, 'test'), aliceTestClient.client.sendTextMessage(ROOM_ID, 'test'),
aliceTestClient.httpBackend.flush('/keys/query', 1).then( aliceTestClient.httpBackend.flush('/keys/query', 1).then(
() => aliceTestClient.httpBackend.flush('/send/', 1), () => aliceTestClient.httpBackend.flush('/send/', 1),
), ),
aliceTestClient.client.crypto.deviceList.saveIfDirty(), aliceTestClient.client.crypto.deviceList.saveIfDirty(),
]); ]);
}).then(() => { }).then(() => {
aliceTestClient.cryptoStore.getEndToEndDeviceData(null, (data) => { aliceTestClient.client.cryptoStore.getEndToEndDeviceData(null, (data) => {
expect(data.syncToken).toEqual(1); expect(data.syncToken).toEqual(1);
}); });
// invalidate bob's and chris's device lists in separate syncs // invalidate bob's and chris's device lists in separate syncs
aliceTestClient.httpBackend.when('GET', '/sync').respond(200, { aliceTestClient.httpBackend.when('GET', '/sync').respond(200, {
next_batch: '2', next_batch: '2',
device_lists: { device_lists: {
changed: ['@bob:xyz'], changed: ['@bob:xyz'],
}, },
}); });
aliceTestClient.httpBackend.when('GET', '/sync').respond(200, { aliceTestClient.httpBackend.when('GET', '/sync').respond(200, {
next_batch: '3', next_batch: '3',
device_lists: { device_lists: {
changed: ['@chris:abc'], changed: ['@chris:abc'],
}, },
}); });
// flush both syncs // flush both syncs
return aliceTestClient.flushSync().then(() => { return aliceTestClient.flushSync().then(() => {
return aliceTestClient.flushSync(); return aliceTestClient.flushSync();
}); });
}).then(() => { }).then(() => {
// check that we don't yet have a request for chris's devices. // check that we don't yet have a request for chris's devices.
aliceTestClient.httpBackend.when('POST', '/keys/query', { aliceTestClient.httpBackend.when('POST', '/keys/query', {
device_keys: { device_keys: {
'@chris:abc': {}, '@chris:abc': {},
}, },
token: '3', token: '3',
}).respond(200, { }).respond(200, {
device_keys: { '@chris:abc': {} }, device_keys: { '@chris:abc': {} },
}); });
return aliceTestClient.httpBackend.flush('/keys/query', 1); return aliceTestClient.httpBackend.flush('/keys/query', 1);
}).then((flushed) => { }).then((flushed) => {
expect(flushed).toEqual(0); expect(flushed).toEqual(0);
return aliceTestClient.client.crypto.deviceList.saveIfDirty(); return aliceTestClient.client.crypto.deviceList.saveIfDirty();
}).then(() => { }).then(() => {
aliceTestClient.cryptoStore.getEndToEndDeviceData(null, (data) => { aliceTestClient.client.cryptoStore.getEndToEndDeviceData(null, (data) => {
const bobStat = data.trackingStatus['@bob:xyz']; const bobStat = data.trackingStatus['@bob:xyz'];
if (bobStat != 1 && bobStat != 2) { if (bobStat != 1 && bobStat != 2) {
throw new Error('Unexpected status for bob: wanted 1 or 2, got ' + throw new Error('Unexpected status for bob: wanted 1 or 2, got ' +
bobStat); bobStat);
} }
const chrisStat = data.trackingStatus['@chris:abc']; const chrisStat = data.trackingStatus['@chris:abc'];
if (chrisStat != 1 && chrisStat != 2) { if (chrisStat != 1 && chrisStat != 2) {
throw new Error( throw new Error(
'Unexpected status for chris: wanted 1 or 2, got ' + chrisStat, 'Unexpected status for chris: wanted 1 or 2, got ' + chrisStat,
); );
} }
}); });
// now add an expectation for a query for bob's devices, and let // now add an expectation for a query for bob's devices, and let
// it complete. // it complete.
aliceTestClient.httpBackend.when('POST', '/keys/query', { aliceTestClient.httpBackend.when('POST', '/keys/query', {
device_keys: { device_keys: {
'@bob:xyz': {}, '@bob:xyz': {},
}, },
token: '2', token: '2',
}).respond(200, { }).respond(200, {
device_keys: { '@bob:xyz': {} }, device_keys: { '@bob:xyz': {} },
}); });
return aliceTestClient.httpBackend.flush('/keys/query', 1); return aliceTestClient.httpBackend.flush('/keys/query', 1);
}).then((flushed) => { }).then((flushed) => {
expect(flushed).toEqual(1); expect(flushed).toEqual(1);
// wait for the client to stop processing the response // wait for the client to stop processing the response
return aliceTestClient.client.downloadKeys(['@bob:xyz']); return aliceTestClient.client.downloadKeys(['@bob:xyz']);
}).then(() => { }).then(() => {
return aliceTestClient.client.crypto.deviceList.saveIfDirty(); return aliceTestClient.client.crypto.deviceList.saveIfDirty();
}).then(() => { }).then(() => {
aliceTestClient.cryptoStore.getEndToEndDeviceData(null, (data) => { aliceTestClient.client.cryptoStore.getEndToEndDeviceData(null, (data) => {
const bobStat = data.trackingStatus['@bob:xyz']; const bobStat = data.trackingStatus['@bob:xyz'];
expect(bobStat).toEqual(3); expect(bobStat).toEqual(3);
const chrisStat = data.trackingStatus['@chris:abc']; const chrisStat = data.trackingStatus['@chris:abc'];
if (chrisStat != 1 && chrisStat != 2) { if (chrisStat != 1 && chrisStat != 2) {
throw new Error( throw new Error(
'Unexpected status for chris: wanted 1 or 2, got ' + bobStat, 'Unexpected status for chris: wanted 1 or 2, got ' + bobStat,
); );
} }
}); });
// now let the query for chris's devices complete. // now let the query for chris's devices complete.
return aliceTestClient.httpBackend.flush('/keys/query', 1); return aliceTestClient.httpBackend.flush('/keys/query', 1);
}).then((flushed) => { }).then((flushed) => {
expect(flushed).toEqual(1); expect(flushed).toEqual(1);
// wait for the client to stop processing the response // wait for the client to stop processing the response
return aliceTestClient.client.downloadKeys(['@chris:abc']); return aliceTestClient.client.downloadKeys(['@chris:abc']);
}).then(() => { }).then(() => {
return aliceTestClient.client.crypto.deviceList.saveIfDirty(); return aliceTestClient.client.crypto.deviceList.saveIfDirty();
}).then(() => { }).then(() => {
aliceTestClient.cryptoStore.getEndToEndDeviceData(null, (data) => { aliceTestClient.client.cryptoStore.getEndToEndDeviceData(null, (data) => {
const bobStat = data.trackingStatus['@bob:xyz']; const bobStat = data.trackingStatus['@bob:xyz'];
const chrisStat = data.trackingStatus['@bob:xyz']; const chrisStat = data.trackingStatus['@bob:xyz'];
expect(bobStat).toEqual(3); expect(bobStat).toEqual(3);
expect(chrisStat).toEqual(3); expect(chrisStat).toEqual(3);
expect(data.syncToken).toEqual(3); expect(data.syncToken).toEqual(3);
}); });
}); });
}).timeout(3000); });
// https://github.com/vector-im/element-web/issues/4983 // https://github.com/vector-im/element-web/issues/4983
describe("Alice should know she has stale device lists", () => { describe("Alice should know she has stale device lists", () => {
@ -288,7 +287,7 @@ describe("DeviceList management:", function() {
await aliceTestClient.httpBackend.flush('/keys/query', 1); await aliceTestClient.httpBackend.flush('/keys/query', 1);
await aliceTestClient.client.crypto.deviceList.saveIfDirty(); await aliceTestClient.client.crypto.deviceList.saveIfDirty();
aliceTestClient.cryptoStore.getEndToEndDeviceData(null, (data) => { aliceTestClient.client.cryptoStore.getEndToEndDeviceData(null, (data) => {
const bobStat = data.trackingStatus['@bob:xyz']; const bobStat = data.trackingStatus['@bob:xyz'];
expect(bobStat).toBeGreaterThan( expect(bobStat).toBeGreaterThan(
@ -324,7 +323,7 @@ describe("DeviceList management:", function() {
await aliceTestClient.flushSync(); await aliceTestClient.flushSync();
await aliceTestClient.client.crypto.deviceList.saveIfDirty(); await aliceTestClient.client.crypto.deviceList.saveIfDirty();
aliceTestClient.cryptoStore.getEndToEndDeviceData(null, (data) => { aliceTestClient.client.cryptoStore.getEndToEndDeviceData(null, (data) => {
const bobStat = data.trackingStatus['@bob:xyz']; const bobStat = data.trackingStatus['@bob:xyz'];
expect(bobStat).toEqual( expect(bobStat).toEqual(
@ -360,7 +359,7 @@ describe("DeviceList management:", function() {
await aliceTestClient.flushSync(); await aliceTestClient.flushSync();
await aliceTestClient.client.crypto.deviceList.saveIfDirty(); await aliceTestClient.client.crypto.deviceList.saveIfDirty();
aliceTestClient.cryptoStore.getEndToEndDeviceData(null, (data) => { aliceTestClient.client.cryptoStore.getEndToEndDeviceData(null, (data) => {
const bobStat = data.trackingStatus['@bob:xyz']; const bobStat = data.trackingStatus['@bob:xyz'];
expect(bobStat).toEqual( expect(bobStat).toEqual(
@ -381,7 +380,7 @@ describe("DeviceList management:", function() {
await anotherTestClient.flushSync(); await anotherTestClient.flushSync();
await anotherTestClient.client.crypto.deviceList.saveIfDirty(); await anotherTestClient.client.crypto.deviceList.saveIfDirty();
anotherTestClient.cryptoStore.getEndToEndDeviceData(null, (data) => { anotherTestClient.client.cryptoStore.getEndToEndDeviceData(null, (data) => {
const bobStat = data.trackingStatus['@bob:xyz']; const bobStat = data.trackingStatus['@bob:xyz'];
expect(bobStat).toEqual( expect(bobStat).toEqual(

View File

@ -161,7 +161,7 @@ function aliDownloadsKeys() {
return Promise.all([p1, p2]).then(() => { return Promise.all([p1, p2]).then(() => {
return aliTestClient.client.crypto.deviceList.saveIfDirty(); return aliTestClient.client.crypto.deviceList.saveIfDirty();
}).then(() => { }).then(() => {
aliTestClient.cryptoStore.getEndToEndDeviceData(null, (data) => { aliTestClient.client.cryptoStore.getEndToEndDeviceData(null, (data) => {
const devices = data.devices[bobUserId]; const devices = data.devices[bobUserId];
expect(devices[bobDeviceId].keys).toEqual(bobTestClient.deviceKeys.keys); expect(devices[bobDeviceId].keys).toEqual(bobTestClient.deviceKeys.keys);
expect(devices[bobDeviceId].verified). expect(devices[bobDeviceId].verified).

View File

@ -1,5 +1,5 @@
import * as utils from "../test-utils/test-utils"; import * as utils from "../test-utils/test-utils";
import { EventTimeline } from "../../src/matrix"; import { EventTimeline, Filter, MatrixEvent } from "../../src/matrix";
import { logger } from "../../src/logger"; import { logger } from "../../src/logger";
import { TestClient } from "../TestClient"; import { TestClient } from "../TestClient";
import { Thread, THREAD_RELATION_TYPE } from "../../src/models/thread"; import { Thread, THREAD_RELATION_TYPE } from "../../src/models/thread";
@ -70,10 +70,23 @@ const EVENTS = [
}), }),
]; ];
const THREAD_ROOT = utils.mkMessage({ const THREAD_ROOT = utils.mkEvent({
room: roomId, room: roomId,
user: userId, user: userId,
msg: "thread root", type: "m.room.message",
content: {
"body": "thread root",
"msgtype": "m.text",
},
unsigned: {
"m.relations": {
"io.element.thread": {
"latest_event": undefined,
"count": 1,
"current_user_participated": true,
},
},
},
}); });
const THREAD_REPLY = utils.mkEvent({ const THREAD_REPLY = utils.mkEvent({
@ -91,6 +104,8 @@ const THREAD_REPLY = utils.mkEvent({
}, },
}); });
THREAD_ROOT.unsigned["m.relations"]["io.element.thread"].latest_event = THREAD_REPLY;
// start the client, and wait for it to initialise // start the client, and wait for it to initialise
function startClient(httpBackend, client) { function startClient(httpBackend, client) {
httpBackend.when("GET", "/versions").respond(200, {}); httpBackend.when("GET", "/versions").respond(200, {});
@ -500,7 +515,8 @@ describe("MatrixClient event timelines", function() {
Thread.setServerSideSupport(true); Thread.setServerSideSupport(true);
client.stopClient(); // we don't need the client to be syncing at this time client.stopClient(); // we don't need the client to be syncing at this time
const room = client.getRoom(roomId); const room = client.getRoom(roomId);
const timelineSet = room.getTimelineSets()[0]; const thread = room.createThread(THREAD_ROOT.event_id, undefined, [], false);
const timelineSet = thread.timelineSet;
httpBackend.when("GET", "/rooms/!foo%3Abar/context/" + encodeURIComponent(THREAD_REPLY.event_id)) httpBackend.when("GET", "/rooms/!foo%3Abar/context/" + encodeURIComponent(THREAD_REPLY.event_id))
.respond(200, function() { .respond(200, function() {
@ -526,8 +542,7 @@ describe("MatrixClient event timelines", function() {
return { return {
original_event: THREAD_ROOT, original_event: THREAD_ROOT,
chunk: [THREAD_REPLY], chunk: [THREAD_REPLY],
next_batch: "next_batch_token0", // no next batch as this is the oldest end of the timeline
prev_batch: "prev_batch_token0",
}; };
}); });
@ -536,8 +551,189 @@ describe("MatrixClient event timelines", function() {
const timeline = await timelinePromise; const timeline = await timelinePromise;
expect(timeline.getEvents().find(e => e.getId() === THREAD_ROOT.event_id)); expect(timeline.getEvents().find(e => e.getId() === THREAD_ROOT.event_id)).toBeTruthy();
expect(timeline.getEvents().find(e => e.getId() === THREAD_REPLY.event_id)); expect(timeline.getEvents().find(e => e.getId() === THREAD_REPLY.event_id)).toBeTruthy();
});
it("should return relevant timeline from non-thread timelineSet when asking for the thread root", async () => {
client.clientOpts.experimentalThreadSupport = true;
Thread.setServerSideSupport(true);
client.stopClient(); // we don't need the client to be syncing at this time
const room = client.getRoom(roomId);
const threadRoot = new MatrixEvent(THREAD_ROOT);
const thread = room.createThread(THREAD_ROOT.event_id, threadRoot, [threadRoot], false);
const timelineSet = room.getTimelineSets()[0];
httpBackend.when("GET", "/rooms/!foo%3Abar/context/" + encodeURIComponent(THREAD_ROOT.event_id))
.respond(200, function() {
return {
start: "start_token0",
events_before: [],
event: THREAD_ROOT,
events_after: [],
end: "end_token0",
state: [],
};
});
const [timeline] = await Promise.all([
client.getEventTimeline(timelineSet, THREAD_ROOT.event_id),
httpBackend.flushAllExpected(),
]);
expect(timeline).not.toBe(thread.liveTimeline);
expect(timelineSet.getTimelines()).toContain(timeline);
expect(timeline.getEvents().find(e => e.getId() === THREAD_ROOT.event_id)).toBeTruthy();
});
it("should return undefined when event is not in the thread that the given timelineSet is representing", () => {
client.clientOpts.experimentalThreadSupport = true;
Thread.setServerSideSupport(true);
client.stopClient(); // we don't need the client to be syncing at this time
const room = client.getRoom(roomId);
const threadRoot = new MatrixEvent(THREAD_ROOT);
const thread = room.createThread(THREAD_ROOT.event_id, threadRoot, [threadRoot], false);
const timelineSet = thread.timelineSet;
httpBackend.when("GET", "/rooms/!foo%3Abar/context/" + encodeURIComponent(EVENTS[0].event_id))
.respond(200, function() {
return {
start: "start_token0",
events_before: [],
event: EVENTS[0],
events_after: [],
end: "end_token0",
state: [],
};
});
return Promise.all([
expect(client.getEventTimeline(timelineSet, EVENTS[0].event_id)).resolves.toBeUndefined(),
httpBackend.flushAllExpected(),
]);
});
it("should return undefined when event is within a thread but timelineSet is not", () => {
client.clientOpts.experimentalThreadSupport = true;
Thread.setServerSideSupport(true);
client.stopClient(); // we don't need the client to be syncing at this time
const room = client.getRoom(roomId);
const timelineSet = room.getTimelineSets()[0];
httpBackend.when("GET", "/rooms/!foo%3Abar/context/" + encodeURIComponent(THREAD_REPLY.event_id))
.respond(200, function() {
return {
start: "start_token0",
events_before: [],
event: THREAD_REPLY,
events_after: [],
end: "end_token0",
state: [],
};
});
return Promise.all([
expect(client.getEventTimeline(timelineSet, THREAD_REPLY.event_id)).resolves.toBeUndefined(),
httpBackend.flushAllExpected(),
]);
});
it("should should add lazy loading filter when requested", async () => {
client.clientOpts.lazyLoadMembers = true;
client.stopClient(); // we don't need the client to be syncing at this time
const room = client.getRoom(roomId);
const timelineSet = room.getTimelineSets()[0];
const req = httpBackend.when("GET", "/rooms/!foo%3Abar/context/" + encodeURIComponent(EVENTS[0].event_id));
req.respond(200, function() {
return {
start: "start_token0",
events_before: [],
event: EVENTS[0],
events_after: [],
end: "end_token0",
state: [],
};
});
req.check((request) => {
expect(request.opts.qs.filter).toEqual(JSON.stringify(Filter.LAZY_LOADING_MESSAGES_FILTER));
});
await Promise.all([
client.getEventTimeline(timelineSet, EVENTS[0].event_id),
httpBackend.flushAllExpected(),
]);
});
});
describe("getLatestTimeline", function() {
it("should create a new timeline for new events", function() {
const room = client.getRoom(roomId);
const timelineSet = room.getTimelineSets()[0];
const latestMessageId = 'event1:bar';
httpBackend.when("GET", "/rooms/!foo%3Abar/messages")
.respond(200, function() {
return {
chunk: [{
event_id: latestMessageId,
}],
};
});
httpBackend.when("GET", `/rooms/!foo%3Abar/context/${encodeURIComponent(latestMessageId)}`)
.respond(200, function() {
return {
start: "start_token",
events_before: [EVENTS[1], EVENTS[0]],
event: EVENTS[2],
events_after: [EVENTS[3]],
state: [
ROOM_NAME_EVENT,
USER_MEMBERSHIP_EVENT,
],
end: "end_token",
};
});
return Promise.all([
client.getLatestTimeline(timelineSet).then(function(tl) {
// Instead of this assertion logic, we could just add a spy
// for `getEventTimeline` and make sure it's called with the
// correct parameters. This doesn't feel too bad to make sure
// `getLatestTimeline` is doing the right thing though.
expect(tl.getEvents().length).toEqual(4);
for (let i = 0; i < 4; i++) {
expect(tl.getEvents()[i].event).toEqual(EVENTS[i]);
expect(tl.getEvents()[i].sender.name).toEqual(userName);
}
expect(tl.getPaginationToken(EventTimeline.BACKWARDS))
.toEqual("start_token");
expect(tl.getPaginationToken(EventTimeline.FORWARDS))
.toEqual("end_token");
}),
httpBackend.flushAllExpected(),
]);
});
it("should throw error when /messages does not return a message", () => {
const room = client.getRoom(roomId);
const timelineSet = room.getTimelineSets()[0];
httpBackend.when("GET", "/rooms/!foo%3Abar/messages")
.respond(200, () => {
return {
chunk: [
// No messages to return
],
};
});
return Promise.all([
expect(client.getLatestTimeline(timelineSet)).rejects.toThrow(),
httpBackend.flushAllExpected(),
]);
}); });
}); });

View File

@ -1,3 +1,19 @@
/*
Copyright 2022 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 * as utils from "../test-utils/test-utils"; import * as utils from "../test-utils/test-utils";
import { CRYPTO_ENABLED } from "../../src/client"; import { CRYPTO_ENABLED } from "../../src/client";
import { MatrixEvent } from "../../src/models/event"; import { MatrixEvent } from "../../src/models/event";
@ -26,7 +42,7 @@ describe("MatrixClient", function() {
}); });
describe("uploadContent", function() { describe("uploadContent", function() {
const buf = new Buffer('hello world'); const buf = Buffer.from('hello world');
it("should upload the file", function() { it("should upload the file", function() {
httpBackend.when( httpBackend.when(
"POST", "/_matrix/media/r0/upload", "POST", "/_matrix/media/r0/upload",
@ -458,6 +474,10 @@ describe("MatrixClient", function() {
return client.initCrypto(); return client.initCrypto();
}); });
afterEach(() => {
client.stopClient();
});
it("should do an HTTP request and then store the keys", function() { it("should do an HTTP request and then store the keys", function() {
const ed25519key = "7wG2lzAqbjcyEkOP7O4gU7ItYcn+chKzh5sT/5r2l78"; const ed25519key = "7wG2lzAqbjcyEkOP7O4gU7ItYcn+chKzh5sT/5r2l78";
// ed25519key = client.getDeviceEd25519Key(); // ed25519key = client.getDeviceEd25519Key();
@ -823,6 +843,171 @@ describe("MatrixClient", function() {
]); ]);
}); });
}); });
describe("getThirdpartyUser", () => {
it("should hit the expected API endpoint", async () => {
const response = [{
userid: "@Bob",
protocol: "irc",
fields: {},
}];
const prom = client.getThirdpartyUser("irc", {});
httpBackend.when("GET", "/thirdparty/user/irc").respond(200, response);
await httpBackend.flush();
expect(await prom).toStrictEqual(response);
});
});
describe("getThirdpartyLocation", () => {
it("should hit the expected API endpoint", async () => {
const response = [{
alias: "#alias",
protocol: "irc",
fields: {},
}];
const prom = client.getThirdpartyLocation("irc", {});
httpBackend.when("GET", "/thirdparty/location/irc").respond(200, response);
await httpBackend.flush();
expect(await prom).toStrictEqual(response);
});
});
describe("getPushers", () => {
it("should hit the expected API endpoint", async () => {
const response = {
pushers: [],
};
const prom = client.getPushers();
httpBackend.when("GET", "/pushers").respond(200, response);
await httpBackend.flush();
expect(await prom).toStrictEqual(response);
});
});
describe("getKeyChanges", () => {
it("should hit the expected API endpoint", async () => {
const response = {
changed: [],
left: [],
};
const prom = client.getKeyChanges("old", "new");
httpBackend.when("GET", "/keys/changes").check((req) => {
expect(req.queryParams.from).toEqual("old");
expect(req.queryParams.to).toEqual("new");
}).respond(200, response);
await httpBackend.flush();
expect(await prom).toStrictEqual(response);
});
});
describe("getDevices", () => {
it("should hit the expected API endpoint", async () => {
const response = {
devices: [],
};
const prom = client.getDevices();
httpBackend.when("GET", "/devices").respond(200, response);
await httpBackend.flush();
expect(await prom).toStrictEqual(response);
});
});
describe("getDevice", () => {
it("should hit the expected API endpoint", async () => {
const response = {
device_id: "DEADBEEF",
display_name: "NotAPhone",
last_seen_ip: "127.0.0.1",
last_seen_ts: 1,
};
const prom = client.getDevice("DEADBEEF");
httpBackend.when("GET", "/devices/DEADBEEF").respond(200, response);
await httpBackend.flush();
expect(await prom).toStrictEqual(response);
});
});
describe("getThreePids", () => {
it("should hit the expected API endpoint", async () => {
const response = {
threepids: [],
};
const prom = client.getThreePids();
httpBackend.when("GET", "/account/3pid").respond(200, response);
await httpBackend.flush();
expect(await prom).toStrictEqual(response);
});
});
describe("deleteAlias", () => {
it("should hit the expected API endpoint", async () => {
const response = {};
const prom = client.deleteAlias("#foo:bar");
httpBackend.when("DELETE", "/directory/room/" + encodeURIComponent("#foo:bar")).respond(200, response);
await httpBackend.flush();
expect(await prom).toStrictEqual(response);
});
});
describe("deleteRoomTag", () => {
it("should hit the expected API endpoint", async () => {
const response = {};
const prom = client.deleteRoomTag("!roomId:server", "u.tag");
const url = `/user/${encodeURIComponent(userId)}/rooms/${encodeURIComponent("!roomId:server")}/tags/u.tag`;
httpBackend.when("DELETE", url).respond(200, response);
await httpBackend.flush();
expect(await prom).toStrictEqual(response);
});
});
describe("getRoomTags", () => {
it("should hit the expected API endpoint", async () => {
const response = {
tags: {
"u.tag": {
order: 0.5,
},
},
};
const prom = client.getRoomTags("!roomId:server");
const url = `/user/${encodeURIComponent(userId)}/rooms/${encodeURIComponent("!roomId:server")}/tags`;
httpBackend.when("GET", url).respond(200, response);
await httpBackend.flush();
expect(await prom).toStrictEqual(response);
});
});
describe("requestRegisterEmailToken", () => {
it("should hit the expected API endpoint", async () => {
const response = {
sid: "random_sid",
submit_url: "https://foobar.matrix/_matrix/matrix",
};
httpBackend.when("GET", "/_matrix/client/versions").respond(200, {
versions: ["r0.5.0"],
});
const prom = client.requestRegisterEmailToken("bob@email", "secret", 1);
httpBackend.when("POST", "/register/email/requestToken").check(req => {
expect(req.data).toStrictEqual({
email: "bob@email",
client_secret: "secret",
send_attempt: 1,
});
}).respond(200, response);
await httpBackend.flush();
expect(await prom).toStrictEqual(response);
});
});
}); });
function withThreadId(event, newThreadId) { function withThreadId(event, newThreadId) {

View File

@ -8,7 +8,6 @@ import { MatrixError } from "../../src/http-api";
describe("MatrixClient opts", function() { describe("MatrixClient opts", function() {
const baseUrl = "http://localhost.or.something"; const baseUrl = "http://localhost.or.something";
let client = null;
let httpBackend = null; let httpBackend = null;
const userId = "@alice:localhost"; const userId = "@alice:localhost";
const userB = "@bob:localhost"; const userB = "@bob:localhost";
@ -65,6 +64,7 @@ describe("MatrixClient opts", function() {
}); });
describe("without opts.store", function() { describe("without opts.store", function() {
let client;
beforeEach(function() { beforeEach(function() {
client = new MatrixClient({ client = new MatrixClient({
request: httpBackend.requestFn, request: httpBackend.requestFn,
@ -124,6 +124,7 @@ describe("MatrixClient opts", function() {
}); });
describe("without opts.scheduler", function() { describe("without opts.scheduler", function() {
let client;
beforeEach(function() { beforeEach(function() {
client = new MatrixClient({ client = new MatrixClient({
request: httpBackend.requestFn, request: httpBackend.requestFn,
@ -135,6 +136,10 @@ describe("MatrixClient opts", function() {
}); });
}); });
afterEach(function() {
client.stopClient();
});
it("shouldn't retry sending events", function(done) { it("shouldn't retry sending events", function(done) {
httpBackend.when("PUT", "/txn1").fail(500, new MatrixError({ httpBackend.when("PUT", "/txn1").fail(500, new MatrixError({
errcode: "M_SOMETHING", errcode: "M_SOMETHING",

View File

@ -1,10 +1,26 @@
import { EventStatus, RoomEvent } from "../../src/matrix"; /*
Copyright 2022 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 { EventStatus, RoomEvent, MatrixClient } from "../../src/matrix";
import { MatrixScheduler } from "../../src/scheduler"; import { MatrixScheduler } from "../../src/scheduler";
import { Room } from "../../src/models/room"; import { Room } from "../../src/models/room";
import { TestClient } from "../TestClient"; import { TestClient } from "../TestClient";
describe("MatrixClient retrying", function() { describe("MatrixClient retrying", function() {
let client: TestClient = null; let client: MatrixClient = null;
let httpBackend: TestClient["httpBackend"] = null; let httpBackend: TestClient["httpBackend"] = null;
let scheduler; let scheduler;
const userId = "@alice:localhost"; const userId = "@alice:localhost";

View File

@ -1,5 +1,6 @@
import * as utils from "../test-utils/test-utils"; import * as utils from "../test-utils/test-utils";
import { EventStatus } from "../../src/models/event"; import { EventStatus } from "../../src/models/event";
import { RoomEvent } from "../../src";
import { TestClient } from "../TestClient"; import { TestClient } from "../TestClient";
describe("MatrixClient room timelines", function() { describe("MatrixClient room timelines", function() {
@ -579,7 +580,7 @@ describe("MatrixClient room timelines", function() {
}); });
}); });
it("should emit a 'Room.timelineReset' event", function() { it("should emit a `RoomEvent.TimelineReset` event when the sync response is `limited`", function() {
const eventData = [ const eventData = [
utils.mkMessage({ user: userId, room: roomId }), utils.mkMessage({ user: userId, room: roomId }),
]; ];
@ -608,4 +609,271 @@ describe("MatrixClient room timelines", function() {
}); });
}); });
}); });
describe('Refresh live timeline', () => {
const initialSyncEventData = [
utils.mkMessage({ user: userId, room: roomId }),
utils.mkMessage({ user: userId, room: roomId }),
utils.mkMessage({ user: userId, room: roomId }),
];
const contextUrl = `/rooms/${encodeURIComponent(roomId)}/context/` +
`${encodeURIComponent(initialSyncEventData[2].event_id)}`;
const contextResponse = {
start: "start_token",
events_before: [initialSyncEventData[1], initialSyncEventData[0]],
event: initialSyncEventData[2],
events_after: [],
state: [
USER_MEMBERSHIP_EVENT,
],
end: "end_token",
};
let room;
beforeEach(async () => {
setNextSyncData(initialSyncEventData);
// Create a room from the sync
await Promise.all([
httpBackend.flushAllExpected(),
utils.syncPromise(client, 1),
]);
// Get the room after the first sync so the room is created
room = client.getRoom(roomId);
expect(room).toBeTruthy();
});
it('should clear and refresh messages in timeline', async () => {
// `/context` request for `refreshLiveTimeline()` -> `getEventTimeline()`
// to construct a new timeline from.
httpBackend.when("GET", contextUrl)
.respond(200, function() {
// The timeline should be cleared at this point in the refresh
expect(room.timeline.length).toEqual(0);
return contextResponse;
});
// Refresh the timeline.
await Promise.all([
room.refreshLiveTimeline(),
httpBackend.flushAllExpected(),
]);
// Make sure the message are visible
const resultantEventsInTimeline = room.getUnfilteredTimelineSet().getLiveTimeline().getEvents();
const resultantEventIdsInTimeline = resultantEventsInTimeline.map((event) => event.getId());
expect(resultantEventIdsInTimeline).toEqual([
initialSyncEventData[0].event_id,
initialSyncEventData[1].event_id,
initialSyncEventData[2].event_id,
]);
});
it('Perfectly merges timelines if a sync finishes while refreshing the timeline', async () => {
// `/context` request for `refreshLiveTimeline()` ->
// `getEventTimeline()` to construct a new timeline from.
//
// We only resolve this request after we detect that the timeline
// was reset(when it goes blank) and force a sync to happen in the
// middle of all of this refresh timeline logic. We want to make
// sure the sync pagination still works as expected after messing
// the refresh timline logic messes with the pagination tokens.
httpBackend.when("GET", contextUrl)
.respond(200, () => {
// Now finally return and make the `/context` request respond
return contextResponse;
});
// Wait for the timeline to reset(when it goes blank) which means
// it's in the middle of the refrsh logic right before the
// `getEventTimeline()` -> `/context`. Then simulate a racey `/sync`
// to happen in the middle of all of this refresh timeline logic. We
// want to make sure the sync pagination still works as expected
// after messing the refresh timline logic messes with the
// pagination tokens.
//
// We define this here so the event listener is in place before we
// call `room.refreshLiveTimeline()`.
const racingSyncEventData = [
utils.mkMessage({ user: userId, room: roomId }),
];
const waitForRaceySyncAfterResetPromise = new Promise((resolve, reject) => {
let eventFired = false;
// Throw a more descriptive error if this part of the test times out.
const failTimeout = setTimeout(() => {
if (eventFired) {
reject(new Error(
'TestError: `RoomEvent.TimelineReset` fired but we timed out trying to make' +
'a `/sync` happen in time.',
));
} else {
reject(new Error(
'TestError: Timed out while waiting for `RoomEvent.TimelineReset` to fire.',
));
}
}, 4000 /* FIXME: Is there a way to reference the current timeout of this test in Jest? */);
room.on(RoomEvent.TimelineReset, async () => {
try {
eventFired = true;
// The timeline should be cleared at this point in the refresh
expect(room.getUnfilteredTimelineSet().getLiveTimeline().getEvents().length).toEqual(0);
// Then make a `/sync` happen by sending a message and seeing that it
// shows up (simulate a /sync naturally racing with us).
setNextSyncData(racingSyncEventData);
httpBackend.when("GET", "/sync").respond(200, function() {
return NEXT_SYNC_DATA;
});
await Promise.all([
httpBackend.flush("/sync", 1),
utils.syncPromise(client, 1),
]);
// Make sure the timeline has the racey sync data
const afterRaceySyncTimelineEvents = room
.getUnfilteredTimelineSet()
.getLiveTimeline()
.getEvents();
const afterRaceySyncTimelineEventIds = afterRaceySyncTimelineEvents
.map((event) => event.getId());
expect(afterRaceySyncTimelineEventIds).toEqual([
racingSyncEventData[0].event_id,
]);
clearTimeout(failTimeout);
resolve();
} catch (err) {
reject(err);
}
});
});
// Refresh the timeline. Just start the function, we will wait for
// it to finish after the racey sync.
const refreshLiveTimelinePromise = room.refreshLiveTimeline();
await waitForRaceySyncAfterResetPromise;
await Promise.all([
refreshLiveTimelinePromise,
// Then flush the remaining `/context` to left the refresh logic complete
httpBackend.flushAllExpected(),
]);
// Make sure sync pagination still works by seeing a new message show up
// after refreshing the timeline.
const afterRefreshEventData = [
utils.mkMessage({ user: userId, room: roomId }),
];
setNextSyncData(afterRefreshEventData);
httpBackend.when("GET", "/sync").respond(200, function() {
return NEXT_SYNC_DATA;
});
await Promise.all([
httpBackend.flushAllExpected(),
utils.syncPromise(client, 1),
]);
// Make sure the timeline includes the the events from the `/sync`
// that raced and beat us in the middle of everything and the
// `/sync` after the refresh. Since the `/sync` beat us to create
// the timeline, `initialSyncEventData` won't be visible unless we
// paginate backwards with `/messages`.
const resultantEventsInTimeline = room.getUnfilteredTimelineSet().getLiveTimeline().getEvents();
const resultantEventIdsInTimeline = resultantEventsInTimeline.map((event) => event.getId());
expect(resultantEventIdsInTimeline).toEqual([
racingSyncEventData[0].event_id,
afterRefreshEventData[0].event_id,
]);
});
it('Timeline recovers after `/context` request to generate new timeline fails', async () => {
// `/context` request for `refreshLiveTimeline()` -> `getEventTimeline()`
// to construct a new timeline from.
httpBackend.when("GET", contextUrl)
.respond(500, function() {
// The timeline should be cleared at this point in the refresh
expect(room.timeline.length).toEqual(0);
return {
errcode: 'TEST_FAKE_ERROR',
error: 'We purposely intercepted this /context request to make it fail ' +
'in order to test whether the refresh timeline code is resilient',
};
});
// Refresh the timeline and expect it to fail
const settledFailedRefreshPromises = await Promise.allSettled([
room.refreshLiveTimeline(),
httpBackend.flushAllExpected(),
]);
// We only expect `TEST_FAKE_ERROR` here. Anything else is
// unexpected and should fail the test.
if (settledFailedRefreshPromises[0].status === 'fulfilled') {
throw new Error('Expected the /context request to fail with a 500');
} else if (settledFailedRefreshPromises[0].reason.errcode !== 'TEST_FAKE_ERROR') {
throw settledFailedRefreshPromises[0].reason;
}
// The timeline will be empty after we refresh the timeline and fail
// to construct a new timeline.
expect(room.timeline.length).toEqual(0);
// `/messages` request for `refreshLiveTimeline()` ->
// `getLatestTimeline()` to construct a new timeline from.
httpBackend.when("GET", `/rooms/${encodeURIComponent(roomId)}/messages`)
.respond(200, function() {
return {
chunk: [{
// The latest message in the room
event_id: initialSyncEventData[2].event_id,
}],
};
});
// `/context` request for `refreshLiveTimeline()` ->
// `getLatestTimeline()` -> `getEventTimeline()` to construct a new
// timeline from.
httpBackend.when("GET", contextUrl)
.respond(200, function() {
// The timeline should be cleared at this point in the refresh
expect(room.timeline.length).toEqual(0);
return contextResponse;
});
// Refresh the timeline again but this time it should pass
await Promise.all([
room.refreshLiveTimeline(),
httpBackend.flushAllExpected(),
]);
// Make sure sync pagination still works by seeing a new message show up
// after refreshing the timeline.
const afterRefreshEventData = [
utils.mkMessage({ user: userId, room: roomId }),
];
setNextSyncData(afterRefreshEventData);
httpBackend.when("GET", "/sync").respond(200, function() {
return NEXT_SYNC_DATA;
});
await Promise.all([
httpBackend.flushAllExpected(),
utils.syncPromise(client, 1),
]);
// Make sure the message are visible
const resultantEventsInTimeline = room.getUnfilteredTimelineSet().getLiveTimeline().getEvents();
const resultantEventIdsInTimeline = resultantEventsInTimeline.map((event) => event.getId());
expect(resultantEventIdsInTimeline).toEqual([
initialSyncEventData[0].event_id,
initialSyncEventData[1].event_id,
initialSyncEventData[2].event_id,
afterRefreshEventData[0].event_id,
]);
});
});
}); });

View File

@ -1,5 +1,21 @@
import { MatrixEvent } from "../../src/models/event"; /*
import { EventTimeline } from "../../src/models/event-timeline"; Copyright 2022 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 { EventTimeline, MatrixEvent, RoomEvent, RoomStateEvent, RoomMemberEvent } from "../../src";
import { UNSTABLE_MSC2716_MARKER } from "../../src/@types/event";
import * as utils from "../test-utils/test-utils"; import * as utils from "../test-utils/test-utils";
import { TestClient } from "../TestClient"; import { TestClient } from "../TestClient";
@ -60,6 +76,112 @@ describe("MatrixClient syncing", function() {
done(); done();
}); });
}); });
it("should emit RoomEvent.MyMembership for invite->leave->invite cycles", async () => {
const roomId = "!cycles:example.org";
// First sync: an invite
const inviteSyncRoomSection = {
invite: {
[roomId]: {
invite_state: {
events: [{
type: "m.room.member",
state_key: selfUserId,
content: {
membership: "invite",
},
}],
},
},
},
};
httpBackend.when("GET", "/sync").respond(200, {
...syncData,
rooms: inviteSyncRoomSection,
});
// Second sync: a leave (reject of some kind)
httpBackend.when("POST", "/leave").respond(200, {});
httpBackend.when("GET", "/sync").respond(200, {
...syncData,
rooms: {
leave: {
[roomId]: {
account_data: { events: [] },
ephemeral: { events: [] },
state: {
events: [{
type: "m.room.member",
state_key: selfUserId,
content: {
membership: "leave",
},
prev_content: {
membership: "invite",
},
// XXX: And other fields required on an event
}],
},
timeline: {
limited: false,
events: [{
type: "m.room.member",
state_key: selfUserId,
content: {
membership: "leave",
},
prev_content: {
membership: "invite",
},
// XXX: And other fields required on an event
}],
},
},
},
},
});
// Third sync: another invite
httpBackend.when("GET", "/sync").respond(200, {
...syncData,
rooms: inviteSyncRoomSection,
});
// First fire: an initial invite
let fires = 0;
client.once(RoomEvent.MyMembership, (room, membership, oldMembership) => { // Room, string, string
fires++;
expect(room.roomId).toBe(roomId);
expect(membership).toBe("invite");
expect(oldMembership).toBeFalsy();
// Second fire: a leave
client.once(RoomEvent.MyMembership, (room, membership, oldMembership) => {
fires++;
expect(room.roomId).toBe(roomId);
expect(membership).toBe("leave");
expect(oldMembership).toBe("invite");
// Third/final fire: a second invite
client.once(RoomEvent.MyMembership, (room, membership, oldMembership) => {
fires++;
expect(room.roomId).toBe(roomId);
expect(membership).toBe("invite");
expect(oldMembership).toBe("leave");
});
});
// For maximum safety, "leave" the room after we register the handler
client.leave(roomId);
});
// noinspection ES6MissingAwait
client.startClient();
await httpBackend.flushAllExpected();
expect(fires).toBe(3);
});
}); });
describe("resolving invites to profile info", function() { describe("resolving invites to profile info", function() {
@ -177,7 +299,7 @@ describe("MatrixClient syncing", function() {
httpBackend.when("GET", "/sync").respond(200, syncData); httpBackend.when("GET", "/sync").respond(200, syncData);
let latestFiredName = null; let latestFiredName = null;
client.on("RoomMember.name", function(event, m) { client.on(RoomMemberEvent.Name, function(event, m) {
if (m.userId === userC && m.roomId === roomOne) { if (m.userId === userC && m.roomId === roomOne) {
latestFiredName = m.name; latestFiredName = m.name;
} }
@ -461,6 +583,477 @@ describe("MatrixClient syncing", function() {
xit("should update the room topic", function() { xit("should update the room topic", function() {
}); });
describe("onMarkerStateEvent", () => {
const normalMessageEvent = utils.mkMessage({
room: roomOne, user: otherUserId, msg: "hello",
});
it('new marker event *NOT* from the room creator in a subsequent syncs ' +
'should *NOT* mark the timeline as needing a refresh', async () => {
const roomCreateEvent = utils.mkEvent({
type: "m.room.create", room: roomOne, user: otherUserId,
content: {
creator: otherUserId,
room_version: '9',
},
});
const normalFirstSync = {
next_batch: "batch_token",
rooms: {
join: {},
},
};
normalFirstSync.rooms.join[roomOne] = {
timeline: {
events: [normalMessageEvent],
prev_batch: "pagTok",
},
state: {
events: [roomCreateEvent],
},
};
const nextSyncData = {
next_batch: "batch_token",
rooms: {
join: {},
},
};
nextSyncData.rooms.join[roomOne] = {
timeline: {
events: [
// In subsequent syncs, a marker event in timeline
// range should normally trigger
// `timelineNeedsRefresh=true` but this marker isn't
// being sent by the room creator so it has no
// special meaning in existing room versions.
utils.mkEvent({
type: UNSTABLE_MSC2716_MARKER.name,
room: roomOne,
// The important part we're testing is here!
// `userC` is not the room creator.
user: userC,
skey: "",
content: {
"m.insertion_id": "$abc",
},
}),
],
prev_batch: "pagTok",
},
};
// Ensure the marker is being sent by someone who is not the room creator
// because this is the main thing we're testing in this spec.
const markerEvent = nextSyncData.rooms.join[roomOne].timeline.events[0];
expect(markerEvent.sender).toBeDefined();
expect(markerEvent.sender).not.toEqual(roomCreateEvent.sender);
httpBackend.when("GET", "/sync").respond(200, normalFirstSync);
httpBackend.when("GET", "/sync").respond(200, nextSyncData);
client.startClient();
await Promise.all([
httpBackend.flushAllExpected(),
awaitSyncEvent(2),
]);
const room = client.getRoom(roomOne);
expect(room.getTimelineNeedsRefresh()).toEqual(false);
});
[{
label: 'In existing room versions (when the room creator sends the MSC2716 events)',
roomVersion: '9',
}, {
label: 'In a MSC2716 supported room version',
roomVersion: 'org.matrix.msc2716v3',
}].forEach((testMeta) => {
describe(testMeta.label, () => {
const roomCreateEvent = utils.mkEvent({
type: "m.room.create", room: roomOne, user: otherUserId,
content: {
creator: otherUserId,
room_version: testMeta.roomVersion,
},
});
const markerEventFromRoomCreator = utils.mkEvent({
type: UNSTABLE_MSC2716_MARKER.name, room: roomOne, user: otherUserId,
skey: "",
content: {
"m.insertion_id": "$abc",
},
});
const normalFirstSync = {
next_batch: "batch_token",
rooms: {
join: {},
},
};
normalFirstSync.rooms.join[roomOne] = {
timeline: {
events: [normalMessageEvent],
prev_batch: "pagTok",
},
state: {
events: [roomCreateEvent],
},
};
it('no marker event in sync response '+
'should *NOT* mark the timeline as needing a refresh (check for a sane default)', async () => {
const syncData = {
next_batch: "batch_token",
rooms: {
join: {},
},
};
syncData.rooms.join[roomOne] = {
timeline: {
events: [normalMessageEvent],
prev_batch: "pagTok",
},
state: {
events: [roomCreateEvent],
},
};
httpBackend.when("GET", "/sync").respond(200, syncData);
client.startClient();
await Promise.all([
httpBackend.flushAllExpected(),
awaitSyncEvent(),
]);
const room = client.getRoom(roomOne);
expect(room.getTimelineNeedsRefresh()).toEqual(false);
});
it('marker event already sent within timeline range when you join ' +
'should *NOT* mark the timeline as needing a refresh (timelineWasEmpty)', async () => {
const syncData = {
next_batch: "batch_token",
rooms: {
join: {},
},
};
syncData.rooms.join[roomOne] = {
timeline: {
events: [markerEventFromRoomCreator],
prev_batch: "pagTok",
},
state: {
events: [roomCreateEvent],
},
};
httpBackend.when("GET", "/sync").respond(200, syncData);
client.startClient();
await Promise.all([
httpBackend.flushAllExpected(),
awaitSyncEvent(),
]);
const room = client.getRoom(roomOne);
expect(room.getTimelineNeedsRefresh()).toEqual(false);
});
it('marker event already sent before joining (in state) ' +
'should *NOT* mark the timeline as needing a refresh (timelineWasEmpty)', async () => {
const syncData = {
next_batch: "batch_token",
rooms: {
join: {},
},
};
syncData.rooms.join[roomOne] = {
timeline: {
events: [normalMessageEvent],
prev_batch: "pagTok",
},
state: {
events: [
roomCreateEvent,
markerEventFromRoomCreator,
],
},
};
httpBackend.when("GET", "/sync").respond(200, syncData);
client.startClient();
await Promise.all([
httpBackend.flushAllExpected(),
awaitSyncEvent(),
]);
const room = client.getRoom(roomOne);
expect(room.getTimelineNeedsRefresh()).toEqual(false);
});
it('new marker event in a subsequent syncs timeline range ' +
'should mark the timeline as needing a refresh', async () => {
const nextSyncData = {
next_batch: "batch_token",
rooms: {
join: {},
},
};
nextSyncData.rooms.join[roomOne] = {
timeline: {
events: [
// In subsequent syncs, a marker event in timeline
// range should trigger `timelineNeedsRefresh=true`
markerEventFromRoomCreator,
],
prev_batch: "pagTok",
},
};
const markerEventId = nextSyncData.rooms.join[roomOne].timeline.events[0].event_id;
// Only do the first sync
httpBackend.when("GET", "/sync").respond(200, normalFirstSync);
client.startClient();
await Promise.all([
httpBackend.flushAllExpected(),
awaitSyncEvent(),
]);
// Get the room after the first sync so the room is created
const room = client.getRoom(roomOne);
let emitCount = 0;
room.on(RoomEvent.HistoryImportedWithinTimeline, function(markerEvent, room) {
expect(markerEvent.getId()).toEqual(markerEventId);
expect(room.roomId).toEqual(roomOne);
emitCount += 1;
});
// Now do a subsequent sync with the marker event
httpBackend.when("GET", "/sync").respond(200, nextSyncData);
await Promise.all([
httpBackend.flushAllExpected(),
awaitSyncEvent(),
]);
expect(room.getTimelineNeedsRefresh()).toEqual(true);
// Make sure `RoomEvent.HistoryImportedWithinTimeline` was emitted
expect(emitCount).toEqual(1);
});
// Mimic a marker event being sent far back in the scroll back but since our last sync
it('new marker event in sync state should mark the timeline as needing a refresh', async () => {
const nextSyncData = {
next_batch: "batch_token",
rooms: {
join: {},
},
};
nextSyncData.rooms.join[roomOne] = {
timeline: {
events: [
utils.mkMessage({
room: roomOne, user: otherUserId, msg: "hello again",
}),
],
prev_batch: "pagTok",
},
state: {
events: [
// In subsequent syncs, a marker event in state
// should trigger `timelineNeedsRefresh=true`
markerEventFromRoomCreator,
],
},
};
httpBackend.when("GET", "/sync").respond(200, normalFirstSync);
httpBackend.when("GET", "/sync").respond(200, nextSyncData);
client.startClient();
await Promise.all([
httpBackend.flushAllExpected(),
awaitSyncEvent(2),
]);
const room = client.getRoom(roomOne);
expect(room.getTimelineNeedsRefresh()).toEqual(true);
});
});
});
});
// Make sure the state listeners work and events are re-emitted properly from
// the client regardless if we reset and refresh the timeline.
describe('state listeners and re-registered when RoomEvent.CurrentStateUpdated is fired', () => {
const EVENTS = [
utils.mkMessage({
room: roomOne, user: userA, msg: "we",
}),
utils.mkMessage({
room: roomOne, user: userA, msg: "could",
}),
utils.mkMessage({
room: roomOne, user: userA, msg: "be",
}),
utils.mkMessage({
room: roomOne, user: userA, msg: "heroes",
}),
];
const SOME_STATE_EVENT = utils.mkEvent({
event: true,
type: 'org.matrix.test_state',
room: roomOne,
user: userA,
skey: "",
content: {
"foo": "bar",
},
});
const USER_MEMBERSHIP_EVENT = utils.mkMembership({
room: roomOne, mship: "join", user: userA,
});
// This appears to work even if we comment out
// `RoomEvent.CurrentStateUpdated` part which triggers everything to
// re-listen after the `room.currentState` reference changes. I'm
// not sure how it's getting re-emitted.
it("should be able to listen to state events even after " +
"the timeline is reset during `limited` sync response", async () => {
// Create a room from the sync
httpBackend.when("GET", "/sync").respond(200, syncData);
client.startClient();
await Promise.all([
httpBackend.flushAllExpected(),
awaitSyncEvent(),
]);
// Get the room after the first sync so the room is created
const room = client.getRoom(roomOne);
expect(room).toBeTruthy();
let stateEventEmitCount = 0;
client.on(RoomStateEvent.Update, () => {
stateEventEmitCount += 1;
});
// Cause `RoomStateEvent.Update` to be fired
room.currentState.setStateEvents([SOME_STATE_EVENT]);
// Make sure we can listen to the room state events before the reset
expect(stateEventEmitCount).toEqual(1);
// Make a `limited` sync which will cause a `room.resetLiveTimeline`
const limitedSyncData = {
next_batch: "batch_token",
rooms: {
join: {},
},
};
limitedSyncData.rooms.join[roomOne] = {
timeline: {
events: [
utils.mkMessage({
room: roomOne, user: otherUserId, msg: "world",
}),
],
// The important part, make the sync `limited`
limited: true,
prev_batch: "newerTok",
},
};
httpBackend.when("GET", "/sync").respond(200, limitedSyncData);
await Promise.all([
httpBackend.flushAllExpected(),
awaitSyncEvent(),
]);
// This got incremented again from processing the sync response
expect(stateEventEmitCount).toEqual(2);
// Cause `RoomStateEvent.Update` to be fired
room.currentState.setStateEvents([SOME_STATE_EVENT]);
// Make sure we can still listen to the room state events after the reset
expect(stateEventEmitCount).toEqual(3);
});
// Make sure it re-registers the state listeners after the
// `room.currentState` reference changes
it("should be able to listen to state events even after " +
"refreshing the timeline", async () => {
const testClientWithTimelineSupport = new TestClient(
selfUserId,
"DEVICE",
selfAccessToken,
undefined,
{ timelineSupport: true },
);
httpBackend = testClientWithTimelineSupport.httpBackend;
httpBackend.when("GET", "/versions").respond(200, {});
httpBackend.when("GET", "/pushrules").respond(200, {});
httpBackend.when("POST", "/filter").respond(200, { filter_id: "a filter id" });
client = testClientWithTimelineSupport.client;
// Create a room from the sync
httpBackend.when("GET", "/sync").respond(200, syncData);
client.startClient();
await Promise.all([
httpBackend.flushAllExpected(),
awaitSyncEvent(),
]);
// Get the room after the first sync so the room is created
const room = client.getRoom(roomOne);
expect(room).toBeTruthy();
let stateEventEmitCount = 0;
client.on(RoomStateEvent.Update, () => {
stateEventEmitCount += 1;
});
// Cause `RoomStateEvent.Update` to be fired
room.currentState.setStateEvents([SOME_STATE_EVENT]);
// Make sure we can listen to the room state events before the reset
expect(stateEventEmitCount).toEqual(1);
const eventsInRoom = syncData.rooms.join[roomOne].timeline.events;
const contextUrl = `/rooms/${encodeURIComponent(roomOne)}/context/` +
`${encodeURIComponent(eventsInRoom[0].event_id)}`;
httpBackend.when("GET", contextUrl)
.respond(200, function() {
return {
start: "start_token",
events_before: [EVENTS[1], EVENTS[0]],
event: EVENTS[2],
events_after: [EVENTS[3]],
state: [
USER_MEMBERSHIP_EVENT,
],
end: "end_token",
};
});
// Refresh the timeline. This will cause the `room.currentState`
// reference to change
await Promise.all([
room.refreshLiveTimeline(),
httpBackend.flushAllExpected(),
]);
// Cause `RoomStateEvent.Update` to be fired
room.currentState.setStateEvents([SOME_STATE_EVENT]);
// Make sure we can still listen to the room state events after the reset
expect(stateEventEmitCount).toEqual(2);
});
});
}); });
describe("timeline", function() { describe("timeline", function() {
@ -516,7 +1109,7 @@ describe("MatrixClient syncing", function() {
awaitSyncEvent(), awaitSyncEvent(),
]).then(function() { ]).then(function() {
const room = client.getRoom(roomTwo); const room = client.getRoom(roomTwo);
expect(room).toBeDefined(); expect(room).toBeTruthy();
const tok = room.getLiveTimeline() const tok = room.getLiveTimeline()
.getPaginationToken(EventTimeline.BACKWARDS); .getPaginationToken(EventTimeline.BACKWARDS);
expect(tok).toEqual("roomtwotok"); expect(tok).toEqual("roomtwotok");
@ -545,7 +1138,7 @@ describe("MatrixClient syncing", function() {
let resetCallCount = 0; let resetCallCount = 0;
// the token should be set *before* timelineReset is emitted // the token should be set *before* timelineReset is emitted
client.on("Room.timelineReset", function(room) { client.on(RoomEvent.TimelineReset, function(room) {
resetCallCount++; resetCallCount++;
const tl = room.getLiveTimeline(); const tl = room.getLiveTimeline();

View File

@ -0,0 +1,165 @@
/*
Copyright 2022 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 { Account } from "@matrix-org/olm";
import { logger } from "../../src/logger";
import { decodeRecoveryKey } from "../../src/crypto/recoverykey";
import { IKeyBackupInfo, IKeyBackupSession } from "../../src/crypto/keybackup";
import { TestClient } from "../TestClient";
import { IEvent } from "../../src";
import { MatrixEvent, MatrixEventEvent } from "../../src/models/event";
const ROOM_ID = '!ROOM:ID';
const SESSION_ID = 'o+21hSjP+mgEmcfdslPsQdvzWnkdt0Wyo00Kp++R8Kc';
const ENCRYPTED_EVENT: Partial<IEvent> = {
type: 'm.room.encrypted',
content: {
algorithm: 'm.megolm.v1.aes-sha2',
sender_key: 'SENDER_CURVE25519',
session_id: SESSION_ID,
ciphertext: 'AwgAEjD+VwXZ7PoGPRS/H4kwpAsMp/g+WPvJVtPEKE8fmM9IcT/N'
+ 'CiwPb8PehecDKP0cjm1XO88k6Bw3D17aGiBHr5iBoP7oSw8CXULXAMTkBl'
+ 'mkufRQq2+d0Giy1s4/Cg5n13jSVrSb2q7VTSv1ZHAFjUCsLSfR0gxqcQs',
},
room_id: '!ROOM:ID',
event_id: '$event1',
origin_server_ts: 1507753886000,
};
const CURVE25519_KEY_BACKUP_DATA: IKeyBackupSession = {
first_message_index: 0,
forwarded_count: 0,
is_verified: false,
session_data: {
ciphertext: '2z2M7CZ+azAiTHN1oFzZ3smAFFt+LEOYY6h3QO3XXGdw'
+ '6YpNn/gpHDO6I/rgj1zNd4FoTmzcQgvKdU8kN20u5BWRHxaHTZ'
+ 'Slne5RxE6vUdREsBgZePglBNyG0AogR/PVdcrv/v18Y6rLM5O9'
+ 'SELmwbV63uV9Kuu/misMxoqbuqEdG7uujyaEKtjlQsJ5MGPQOy'
+ 'Syw7XrnesSwF6XWRMxcPGRV0xZr3s9PI350Wve3EncjRgJ9IGF'
+ 'ru1bcptMqfXgPZkOyGvrphHoFfoK7nY3xMEHUiaTRfRIjq8HNV'
+ '4o8QY1qmWGnxNBQgOlL8MZlykjg3ULmQ3DtFfQPj/YYGS3jzxv'
+ 'C+EBjaafmsg+52CTeK3Rswu72PX450BnSZ1i3If4xWAUKvjTpe'
+ 'Ug5aDLqttOv1pITolTJDw5W/SD+b5rjEKg1CFCHGEGE9wwV3Nf'
+ 'QHVCQL+dfpd7Or0poy4dqKMAi3g0o3Tg7edIF8d5rREmxaALPy'
+ 'iie8PHD8mj/5Y0GLqrac4CD6+Mop7eUTzVovprjg',
mac: '5lxYBHQU80M',
ephemeral: '/Bn0A4UMFwJaDDvh0aEk1XZj3k1IfgCxgFY9P9a0b14',
},
};
const CURVE25519_BACKUP_INFO: IKeyBackupInfo = {
algorithm: "m.megolm_backup.v1.curve25519-aes-sha2",
version: "1",
auth_data: {
public_key: "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo",
},
};
const RECOVERY_KEY = "EsTc LW2K PGiF wKEA 3As5 g5c4 BXwk qeeJ ZJV8 Q9fu gUMN UE4d";
/**
* start an Olm session with a given recipient
*/
function createOlmSession(olmAccount: Olm.Account, recipientTestClient: TestClient): Promise<Olm.Session> {
return recipientTestClient.awaitOneTimeKeyUpload().then((keys) => {
const otkId = Object.keys(keys)[0];
const otk = keys[otkId];
const session = new global.Olm.Session();
session.create_outbound(
olmAccount, recipientTestClient.getDeviceKey(), otk.key,
);
return session;
});
}
describe("megolm key backups", function() {
if (!global.Olm) {
logger.warn('not running megolm tests: Olm not present');
return;
}
const Olm = global.Olm;
let testOlmAccount: Account;
let aliceTestClient: TestClient;
beforeAll(function() {
return Olm.init();
});
beforeEach(async function() {
aliceTestClient = new TestClient(
"@alice:localhost", "xzcvb", "akjgkrgjs",
);
testOlmAccount = new Olm.Account();
testOlmAccount.create();
await aliceTestClient.client.initCrypto();
aliceTestClient.client.crypto.backupManager.backupInfo = CURVE25519_BACKUP_INFO;
});
afterEach(function() {
return aliceTestClient.stop();
});
it("Alice checks key backups when receiving a message she can't decrypt", function() {
const syncResponse = {
next_batch: 1,
rooms: {
join: {},
},
};
syncResponse.rooms.join[ROOM_ID] = {
timeline: {
events: [ENCRYPTED_EVENT],
},
};
return aliceTestClient.start().then(() => {
return createOlmSession(testOlmAccount, aliceTestClient);
}).then(() => {
const privkey = decodeRecoveryKey(RECOVERY_KEY);
return aliceTestClient.client.crypto.storeSessionBackupPrivateKey(privkey);
}).then(() => {
aliceTestClient.httpBackend.when("GET", "/sync").respond(200, syncResponse);
aliceTestClient.expectKeyBackupQuery(
ROOM_ID,
SESSION_ID,
200,
CURVE25519_KEY_BACKUP_DATA,
);
return aliceTestClient.httpBackend.flushAllExpected();
}).then(function(): Promise<MatrixEvent> {
const room = aliceTestClient.client.getRoom(ROOM_ID);
const event = room.getLiveTimeline().getEvents()[0];
if (event.getContent()) {
return Promise.resolve(event);
}
return new Promise((resolve, reject) => {
event.once(MatrixEventEvent.Decrypted, (ev) => {
logger.log(`${Date.now()} event ${event.getId()} now decrypted`);
resolve(ev);
});
});
}).then((event) => {
expect(event.getContent()).toEqual('testytest');
});
});
});

View File

@ -1029,6 +1029,7 @@ describe("megolm", function() {
}); });
return event.attemptDecryption(testClient.client.crypto, true).then(() => { return event.attemptDecryption(testClient.client.crypto, true).then(() => {
expect(event.isKeySourceUntrusted()).toBeFalsy(); expect(event.isKeySourceUntrusted()).toBeFalsy();
testClient.stop();
}); });
}); });
}); });

View File

@ -27,6 +27,7 @@ type InfoContentProps = {
isLive?: boolean; isLive?: boolean;
assetType?: LocationAssetType; assetType?: LocationAssetType;
description?: string; description?: string;
timestamp?: number;
}; };
const DEFAULT_INFO_CONTENT_PROPS: InfoContentProps = { const DEFAULT_INFO_CONTENT_PROPS: InfoContentProps = {
timeout: 3600000, timeout: 3600000,
@ -44,7 +45,11 @@ export const makeBeaconInfoEvent = (
eventId?: string, eventId?: string,
): MatrixEvent => { ): MatrixEvent => {
const { const {
timeout, isLive, description, assetType, timeout,
isLive,
description,
assetType,
timestamp,
} = { } = {
...DEFAULT_INFO_CONTENT_PROPS, ...DEFAULT_INFO_CONTENT_PROPS,
...contentProps, ...contentProps,
@ -53,10 +58,10 @@ export const makeBeaconInfoEvent = (
type: M_BEACON_INFO.name, type: M_BEACON_INFO.name,
room_id: roomId, room_id: roomId,
state_key: sender, state_key: sender,
content: makeBeaconInfoContent(timeout, isLive, description, assetType), content: makeBeaconInfoContent(timeout, isLive, description, assetType, timestamp),
}); });
event.event.origin_server_ts = Date.now(); event.event.origin_server_ts = timestamp || Date.now();
// live beacons use the beacon_info event id // live beacons use the beacon_info event id
// set or default this // set or default this

View File

@ -6,7 +6,7 @@ import '../olm-loader';
import { logger } from '../../src/logger'; import { logger } from '../../src/logger';
import { IContent, IEvent, IUnsigned, MatrixEvent, MatrixEventEvent } from "../../src/models/event"; import { IContent, IEvent, IUnsigned, MatrixEvent, MatrixEventEvent } from "../../src/models/event";
import { ClientEvent, EventType, MatrixClient } from "../../src"; import { ClientEvent, EventType, MatrixClient, MsgType } from "../../src";
import { SyncState } from "../../src/sync"; import { SyncState } from "../../src/sync";
import { eventMapperFor } from "../../src/event-mapper"; import { eventMapperFor } from "../../src/event-mapper";
@ -74,7 +74,6 @@ interface IEventOpts {
sender?: string; sender?: string;
skey?: string; skey?: string;
content: IContent; content: IContent;
event?: boolean;
user?: string; user?: string;
unsigned?: IUnsigned; unsigned?: IUnsigned;
redacts?: string; redacts?: string;
@ -93,7 +92,9 @@ let testEventIndex = 1; // counter for events, easier for comparison of randomly
* @param {MatrixClient} client If passed along with opts.event=true will be used to set up re-emitters. * @param {MatrixClient} client If passed along with opts.event=true will be used to set up re-emitters.
* @return {Object} a JSON object representing this event. * @return {Object} a JSON object representing this event.
*/ */
export function mkEvent(opts: IEventOpts, client?: MatrixClient): object | MatrixEvent { export function mkEvent(opts: IEventOpts & { event: true }, client?: MatrixClient): MatrixEvent;
export function mkEvent(opts: IEventOpts & { event?: false }, client?: MatrixClient): object;
export function mkEvent(opts: IEventOpts & { event?: boolean }, client?: MatrixClient): object | MatrixEvent {
if (!opts.type || !opts.content) { if (!opts.type || !opts.content) {
throw new Error("Missing .type or .content =>" + JSON.stringify(opts)); throw new Error("Missing .type or .content =>" + JSON.stringify(opts));
} }
@ -143,7 +144,9 @@ interface IPresenceOpts {
* @param {Object} opts Values for the presence. * @param {Object} opts Values for the presence.
* @return {Object|MatrixEvent} The event * @return {Object|MatrixEvent} The event
*/ */
export function mkPresence(opts: IPresenceOpts): object | MatrixEvent { export function mkPresence(opts: IPresenceOpts & { event: true }): MatrixEvent;
export function mkPresence(opts: IPresenceOpts & { event?: false }): object;
export function mkPresence(opts: IPresenceOpts & { event?: boolean }): object | MatrixEvent {
const event = { const event = {
event_id: "$" + Math.random() + "-" + Math.random(), event_id: "$" + Math.random() + "-" + Math.random(),
type: "m.presence", type: "m.presence",
@ -182,7 +185,9 @@ interface IMembershipOpts {
* @param {boolean} opts.event True to make a MatrixEvent. * @param {boolean} opts.event True to make a MatrixEvent.
* @return {Object|MatrixEvent} The event * @return {Object|MatrixEvent} The event
*/ */
export function mkMembership(opts: IMembershipOpts): object | MatrixEvent { export function mkMembership(opts: IMembershipOpts & { event: true }): MatrixEvent;
export function mkMembership(opts: IMembershipOpts & { event?: false }): object;
export function mkMembership(opts: IMembershipOpts & { event?: boolean }): object | MatrixEvent {
const eventOpts: IEventOpts = { const eventOpts: IEventOpts = {
...opts, ...opts,
type: EventType.RoomMember, type: EventType.RoomMember,
@ -220,12 +225,14 @@ interface IMessageOpts {
* @param {MatrixClient} client If passed along with opts.event=true will be used to set up re-emitters. * @param {MatrixClient} client If passed along with opts.event=true will be used to set up re-emitters.
* @return {Object|MatrixEvent} The event * @return {Object|MatrixEvent} The event
*/ */
export function mkMessage(opts: IMessageOpts, client?: MatrixClient): object | MatrixEvent { export function mkMessage(opts: IMessageOpts & { event: true }, client?: MatrixClient): MatrixEvent;
export function mkMessage(opts: IMessageOpts & { event?: false }, client?: MatrixClient): object;
export function mkMessage(opts: IMessageOpts & { event?: boolean }, client?: MatrixClient): object | MatrixEvent {
const eventOpts: IEventOpts = { const eventOpts: IEventOpts = {
...opts, ...opts,
type: EventType.RoomMessage, type: EventType.RoomMessage,
content: { content: {
msgtype: "m.text", msgtype: MsgType.Text,
body: opts.msg, body: opts.msg,
}, },
}; };
@ -236,6 +243,50 @@ export function mkMessage(opts: IMessageOpts, client?: MatrixClient): object | M
return mkEvent(eventOpts, client); return mkEvent(eventOpts, client);
} }
interface IReplyMessageOpts extends IMessageOpts {
replyToMessage: MatrixEvent;
}
/**
* Create a reply message.
*
* @param {Object} opts Values for the message
* @param {string} opts.room The room ID for the event.
* @param {string} opts.user The user ID for the event.
* @param {string} opts.msg Optional. The content.body for the event.
* @param {MatrixEvent} opts.replyToMessage The replied message
* @param {boolean} opts.event True to make a MatrixEvent.
* @param {MatrixClient} client If passed along with opts.event=true will be used to set up re-emitters.
* @return {Object|MatrixEvent} The event
*/
export function mkReplyMessage(opts: IReplyMessageOpts & { event: true }, client?: MatrixClient): MatrixEvent;
export function mkReplyMessage(opts: IReplyMessageOpts & { event?: false }, client?: MatrixClient): object;
export function mkReplyMessage(
opts: IReplyMessageOpts & { event?: boolean },
client?: MatrixClient,
): object | MatrixEvent {
const eventOpts: IEventOpts = {
...opts,
type: EventType.RoomMessage,
content: {
"msgtype": MsgType.Text,
"body": opts.msg,
"m.relates_to": {
"rel_type": "m.in_reply_to",
"event_id": opts.replyToMessage.getId(),
"m.in_reply_to": {
"event_id": opts.replyToMessage.getId(),
},
},
},
};
if (!eventOpts.content.body) {
eventOpts.content.body = "Random->" + Math.random();
}
return mkEvent(eventOpts, client);
}
/** /**
* A mock implementation of webstorage * A mock implementation of webstorage
* *

View File

@ -17,7 +17,13 @@ limitations under the License.
import { REFERENCE_RELATION } from "matrix-events-sdk"; import { REFERENCE_RELATION } from "matrix-events-sdk";
import { LocationAssetType, M_ASSET, M_LOCATION, M_TIMESTAMP } from "../../src/@types/location"; import { LocationAssetType, M_ASSET, M_LOCATION, M_TIMESTAMP } from "../../src/@types/location";
import { makeBeaconContent, makeBeaconInfoContent } from "../../src/content-helpers"; import { M_TOPIC } from "../../src/@types/topic";
import {
makeBeaconContent,
makeBeaconInfoContent,
makeTopicContent,
parseTopicContent,
} from "../../src/content-helpers";
describe('Beacon content helpers', () => { describe('Beacon content helpers', () => {
describe('makeBeaconInfoContent()', () => { describe('makeBeaconInfoContent()', () => {
@ -122,3 +128,68 @@ describe('Beacon content helpers', () => {
}); });
}); });
}); });
describe('Topic content helpers', () => {
describe('makeTopicContent()', () => {
it('creates fully defined event content without html', () => {
expect(makeTopicContent("pizza")).toEqual({
topic: "pizza",
[M_TOPIC.name]: [{
body: "pizza",
mimetype: "text/plain",
}],
});
});
it('creates fully defined event content with html', () => {
expect(makeTopicContent("pizza", "<b>pizza</b>")).toEqual({
topic: "pizza",
[M_TOPIC.name]: [{
body: "pizza",
mimetype: "text/plain",
}, {
body: "<b>pizza</b>",
mimetype: "text/html",
}],
});
});
});
describe('parseTopicContent()', () => {
it('parses event content with plain text topic without mimetype', () => {
expect(parseTopicContent({
topic: "pizza",
[M_TOPIC.name]: [{
body: "pizza",
}],
})).toEqual({
text: "pizza",
});
});
it('parses event content with plain text topic', () => {
expect(parseTopicContent({
topic: "pizza",
[M_TOPIC.name]: [{
body: "pizza",
mimetype: "text/plain",
}],
})).toEqual({
text: "pizza",
});
});
it('parses event content with html topic', () => {
expect(parseTopicContent({
topic: "pizza",
[M_TOPIC.name]: [{
body: "<b>pizza</b>",
mimetype: "text/html",
}],
})).toEqual({
text: "pizza",
html: "<b>pizza</b>",
});
});
});
});

View File

@ -3,7 +3,6 @@ import '../olm-loader';
import { EventEmitter } from "events"; import { EventEmitter } from "events";
import { Crypto } from "../../src/crypto"; import { Crypto } from "../../src/crypto";
import { WebStorageSessionStore } from "../../src/store/session/webstorage";
import { MemoryCryptoStore } from "../../src/crypto/store/memory-crypto-store"; import { MemoryCryptoStore } from "../../src/crypto/store/memory-crypto-store";
import { MockStorageApi } from "../MockStorageApi"; import { MockStorageApi } from "../MockStorageApi";
import { TestClient } from "../TestClient"; import { TestClient } from "../TestClient";
@ -14,9 +13,47 @@ import { sleep } from "../../src/utils";
import { CRYPTO_ENABLED } from "../../src/client"; import { CRYPTO_ENABLED } from "../../src/client";
import { DeviceInfo } from "../../src/crypto/deviceinfo"; import { DeviceInfo } from "../../src/crypto/deviceinfo";
import { logger } from '../../src/logger'; import { logger } from '../../src/logger';
import { MemoryStore } from "../../src";
const Olm = global.Olm; const Olm = global.Olm;
function awaitEvent(emitter, event) {
return new Promise((resolve, reject) => {
emitter.once(event, (result) => {
resolve(result);
});
});
}
async function keyshareEventForEvent(client, event, index) {
const roomId = event.getRoomId();
const eventContent = event.getWireContent();
const key = await client.crypto.olmDevice.getInboundGroupSessionKey(
roomId,
eventContent.sender_key,
eventContent.session_id,
index,
);
const ksEvent = new MatrixEvent({
type: "m.forwarded_room_key",
sender: client.getUserId(),
content: {
algorithm: olmlib.MEGOLM_ALGORITHM,
room_id: roomId,
sender_key: eventContent.sender_key,
sender_claimed_ed25519_key: key.sender_claimed_ed25519_key,
session_id: eventContent.session_id,
session_key: key.key,
chain_index: key.chain_index,
forwarding_curve25519_key_chain:
key.forwarding_curve_key_chain,
},
});
// make onRoomKeyEvent think this was an encrypted event
ksEvent.senderCurve25519Key = "akey";
return ksEvent;
}
describe("Crypto", function() { describe("Crypto", function() {
if (!CRYPTO_ENABLED) { if (!CRYPTO_ENABLED) {
return; return;
@ -116,7 +153,7 @@ describe("Crypto", function() {
beforeEach(async function() { beforeEach(async function() {
const mockStorage = new MockStorageApi(); const mockStorage = new MockStorageApi();
const sessionStore = new WebStorageSessionStore(mockStorage); const clientStore = new MemoryStore({ localStorage: mockStorage });
const cryptoStore = new MemoryCryptoStore(mockStorage); const cryptoStore = new MemoryCryptoStore(mockStorage);
cryptoStore.storeEndToEndDeviceData({ cryptoStore.storeEndToEndDeviceData({
@ -143,10 +180,9 @@ describe("Crypto", function() {
crypto = new Crypto( crypto = new Crypto(
mockBaseApis, mockBaseApis,
sessionStore,
"@alice:home.server", "@alice:home.server",
"FLIBBLE", "FLIBBLE",
sessionStore, clientStore,
cryptoStore, cryptoStore,
mockRoomList, mockRoomList,
); );
@ -203,136 +239,141 @@ describe("Crypto", function() {
bobClient.stopClient(); bobClient.stopClient();
}); });
it( it("does not cancel keyshare requests if some messages are not decrypted", async function() {
"does not cancel keyshare requests if some messages are not decrypted", const encryptionCfg = {
async function() { "algorithm": "m.megolm.v1.aes-sha2",
function awaitEvent(emitter, event) { };
return new Promise((resolve, reject) => { const roomId = "!someroom";
emitter.once(event, (result) => { const aliceRoom = new Room(roomId, aliceClient, "@alice:example.com", {});
resolve(result); const bobRoom = new Room(roomId, bobClient, "@bob:example.com", {});
}); aliceClient.store.storeRoom(aliceRoom);
}); bobClient.store.storeRoom(bobRoom);
} await aliceClient.setRoomEncryption(roomId, encryptionCfg);
await bobClient.setRoomEncryption(roomId, encryptionCfg);
async function keyshareEventForEvent(event, index) { const events = [
const eventContent = event.getWireContent(); new MatrixEvent({
const key = await aliceClient.crypto.olmDevice type: "m.room.message",
.getInboundGroupSessionKey( sender: "@alice:example.com",
roomId, eventContent.sender_key, eventContent.session_id,
index,
);
const ksEvent = new MatrixEvent({
type: "m.forwarded_room_key",
sender: "@alice:example.com",
content: {
algorithm: olmlib.MEGOLM_ALGORITHM,
room_id: roomId,
sender_key: eventContent.sender_key,
sender_claimed_ed25519_key: key.sender_claimed_ed25519_key,
session_id: eventContent.session_id,
session_key: key.key,
chain_index: key.chain_index,
forwarding_curve25519_key_chain:
key.forwarding_curve_key_chain,
},
});
// make onRoomKeyEvent think this was an encrypted event
ksEvent.senderCurve25519Key = "akey";
return ksEvent;
}
const encryptionCfg = {
"algorithm": "m.megolm.v1.aes-sha2",
};
const roomId = "!someroom";
const aliceRoom = new Room(roomId, aliceClient, "@alice:example.com", {});
const bobRoom = new Room(roomId, bobClient, "@bob:example.com", {});
aliceClient.store.storeRoom(aliceRoom);
bobClient.store.storeRoom(bobRoom);
await aliceClient.setRoomEncryption(roomId, encryptionCfg);
await bobClient.setRoomEncryption(roomId, encryptionCfg);
const events = [
new MatrixEvent({
type: "m.room.message",
sender: "@alice:example.com",
room_id: roomId,
event_id: "$1",
content: {
msgtype: "m.text",
body: "1",
},
}),
new MatrixEvent({
type: "m.room.message",
sender: "@alice:example.com",
room_id: roomId,
event_id: "$2",
content: {
msgtype: "m.text",
body: "2",
},
}),
];
await Promise.all(events.map(async (event) => {
// alice encrypts each event, and then bob tries to decrypt
// them without any keys, so that they'll be in pending
await aliceClient.crypto.encryptEvent(event, aliceRoom);
event.clearEvent = undefined;
event.senderCurve25519Key = null;
event.claimedEd25519Key = null;
try {
await bobClient.crypto.decryptEvent(event);
} catch (e) {
// we expect this to fail because we don't have the
// decryption keys yet
}
}));
const bobDecryptor = bobClient.crypto.getRoomDecryptor(
roomId, olmlib.MEGOLM_ALGORITHM,
);
let eventPromise = Promise.all(events.map((ev) => {
return awaitEvent(ev, "Event.decrypted");
}));
// keyshare the session key starting at the second message, so
// the first message can't be decrypted yet, but the second one
// can
let ksEvent = await keyshareEventForEvent(events[1], 1);
await bobDecryptor.onRoomKeyEvent(ksEvent);
await eventPromise;
expect(events[0].getContent().msgtype).toBe("m.bad.encrypted");
expect(events[1].getContent().msgtype).not.toBe("m.bad.encrypted");
const cryptoStore = bobClient.cryptoStore;
const eventContent = events[0].getWireContent();
const senderKey = eventContent.sender_key;
const sessionId = eventContent.session_id;
const roomKeyRequestBody = {
algorithm: olmlib.MEGOLM_ALGORITHM,
room_id: roomId, room_id: roomId,
sender_key: senderKey, event_id: "$1",
session_id: sessionId, content: {
}; msgtype: "m.text",
// the room key request should still be there, since we haven't body: "1",
// decrypted everything },
expect(await cryptoStore.getOutgoingRoomKeyRequest(roomKeyRequestBody)) }),
.toBeDefined(); new MatrixEvent({
type: "m.room.message",
sender: "@alice:example.com",
room_id: roomId,
event_id: "$2",
content: {
msgtype: "m.text",
body: "2",
},
}),
];
await Promise.all(events.map(async (event) => {
// alice encrypts each event, and then bob tries to decrypt
// them without any keys, so that they'll be in pending
await aliceClient.crypto.encryptEvent(event, aliceRoom);
event.clearEvent = undefined;
event.senderCurve25519Key = null;
event.claimedEd25519Key = null;
try {
await bobClient.crypto.decryptEvent(event);
} catch (e) {
// we expect this to fail because we don't have the
// decryption keys yet
}
}));
// keyshare the session key starting at the first message, so const bobDecryptor = bobClient.crypto.getRoomDecryptor(
// that it can now be decrypted roomId, olmlib.MEGOLM_ALGORITHM,
eventPromise = awaitEvent(events[0], "Event.decrypted"); );
ksEvent = await keyshareEventForEvent(events[0], 0);
await bobDecryptor.onRoomKeyEvent(ksEvent); let eventPromise = Promise.all(events.map((ev) => {
await eventPromise; return awaitEvent(ev, "Event.decrypted");
expect(events[0].getContent().msgtype).not.toBe("m.bad.encrypted"); }));
await sleep(1);
// the room key request should be gone since we've now decrypted everything // keyshare the session key starting at the second message, so
expect(await cryptoStore.getOutgoingRoomKeyRequest(roomKeyRequestBody)) // the first message can't be decrypted yet, but the second one
.toBeFalsy(); // can
}, let ksEvent = await keyshareEventForEvent(aliceClient, events[1], 1);
); await bobDecryptor.onRoomKeyEvent(ksEvent);
await eventPromise;
expect(events[0].getContent().msgtype).toBe("m.bad.encrypted");
expect(events[1].getContent().msgtype).not.toBe("m.bad.encrypted");
const cryptoStore = bobClient.cryptoStore;
const eventContent = events[0].getWireContent();
const senderKey = eventContent.sender_key;
const sessionId = eventContent.session_id;
const roomKeyRequestBody = {
algorithm: olmlib.MEGOLM_ALGORITHM,
room_id: roomId,
sender_key: senderKey,
session_id: sessionId,
};
// the room key request should still be there, since we haven't
// decrypted everything
expect(await cryptoStore.getOutgoingRoomKeyRequest(roomKeyRequestBody)).toBeDefined();
// keyshare the session key starting at the first message, so
// that it can now be decrypted
eventPromise = awaitEvent(events[0], "Event.decrypted");
ksEvent = await keyshareEventForEvent(aliceClient, events[0], 0);
await bobDecryptor.onRoomKeyEvent(ksEvent);
await eventPromise;
expect(events[0].getContent().msgtype).not.toBe("m.bad.encrypted");
await sleep(1);
// the room key request should be gone since we've now decrypted everything
expect(await cryptoStore.getOutgoingRoomKeyRequest(roomKeyRequestBody)).toBeFalsy();
});
it("should error if a forwarded room key lacks a content.sender_key", async function() {
const encryptionCfg = {
"algorithm": "m.megolm.v1.aes-sha2",
};
const roomId = "!someroom";
const aliceRoom = new Room(roomId, aliceClient, "@alice:example.com", {});
const bobRoom = new Room(roomId, bobClient, "@bob:example.com", {});
aliceClient.store.storeRoom(aliceRoom);
bobClient.store.storeRoom(bobRoom);
await aliceClient.setRoomEncryption(roomId, encryptionCfg);
await bobClient.setRoomEncryption(roomId, encryptionCfg);
const event = new MatrixEvent({
type: "m.room.message",
sender: "@alice:example.com",
room_id: roomId,
event_id: "$1",
content: {
msgtype: "m.text",
body: "1",
},
});
// alice encrypts each event, and then bob tries to decrypt
// them without any keys, so that they'll be in pending
await aliceClient.crypto.encryptEvent(event, aliceRoom);
event.clearEvent = undefined;
event.senderCurve25519Key = null;
event.claimedEd25519Key = null;
try {
await bobClient.crypto.decryptEvent(event);
} catch (e) {
// we expect this to fail because we don't have the
// decryption keys yet
}
const bobDecryptor = bobClient.crypto.getRoomDecryptor(
roomId, olmlib.MEGOLM_ALGORITHM,
);
const ksEvent = await keyshareEventForEvent(aliceClient, event, 1);
ksEvent.getContent().sender_key = undefined; // test
bobClient.crypto.addInboundGroupSession = jest.fn();
await bobDecryptor.onRoomKeyEvent(ksEvent);
expect(bobClient.crypto.addInboundGroupSession).not.toHaveBeenCalled();
});
it("creates a new keyshare request if we request a keyshare", async function() { it("creates a new keyshare request if we request a keyshare", async function() {
// make sure that cancelAndResend... creates a new keyshare request // make sure that cancelAndResend... creates a new keyshare request
@ -423,6 +464,7 @@ describe("Crypto", function() {
await client.crypto.bootstrapSecretStorage({ await client.crypto.bootstrapSecretStorage({
createSecretStorageKey, createSecretStorageKey,
}); });
client.stopClient();
}); });
}); });
}); });

View File

@ -66,23 +66,23 @@ describe("CrossSigningInfo.getCrossSigningKey", function() {
}); });
it.each(types)("should throw if the callback returns falsey", it.each(types)("should throw if the callback returns falsey",
async ({ type, shouldCache }) => { async ({ type, shouldCache }) => {
const info = new CrossSigningInfo(userId, { const info = new CrossSigningInfo(userId, {
getCrossSigningKey: () => false, getCrossSigningKey: async () => false as unknown as Uint8Array,
});
await expect(info.getCrossSigningKey(type)).rejects.toThrow("falsey");
}); });
await expect(info.getCrossSigningKey(type)).rejects.toThrow("falsey");
});
it("should throw if the expected key doesn't come back", async () => { it("should throw if the expected key doesn't come back", async () => {
const info = new CrossSigningInfo(userId, { const info = new CrossSigningInfo(userId, {
getCrossSigningKey: () => masterKeyPub, getCrossSigningKey: async () => masterKeyPub as unknown as Uint8Array,
}); });
await expect(info.getCrossSigningKey("master", "")).rejects.toThrow(); await expect(info.getCrossSigningKey("master", "")).rejects.toThrow();
}); });
it("should return a key from its callback", async () => { it("should return a key from its callback", async () => {
const info = new CrossSigningInfo(userId, { const info = new CrossSigningInfo(userId, {
getCrossSigningKey: () => testKey, getCrossSigningKey: async () => testKey,
}); });
const [pubKey, pkSigning] = await info.getCrossSigningKey("master", masterKeyPub); const [pubKey, pkSigning] = await info.getCrossSigningKey("master", masterKeyPub);
expect(pubKey).toEqual(masterKeyPub); expect(pubKey).toEqual(masterKeyPub);
@ -99,7 +99,7 @@ describe("CrossSigningInfo.getCrossSigningKey", function() {
it.each(types)("should request a key from the cache callback (if set)" + it.each(types)("should request a key from the cache callback (if set)" +
" and does not call app if one is found" + " and does not call app if one is found" +
" %o", " %o",
async ({ type, shouldCache }) => { async ({ type, shouldCache }) => {
const getCrossSigningKey = jest.fn().mockImplementation(() => { const getCrossSigningKey = jest.fn().mockImplementation(() => {
if (shouldCache) { if (shouldCache) {
return Promise.reject(new Error("Regular callback called")); return Promise.reject(new Error("Regular callback called"));
@ -122,58 +122,58 @@ describe("CrossSigningInfo.getCrossSigningKey", function() {
}); });
it.each(types)("should store a key with the cache callback (if set)", it.each(types)("should store a key with the cache callback (if set)",
async ({ type, shouldCache }) => { async ({ type, shouldCache }) => {
const getCrossSigningKey = jest.fn().mockResolvedValue(testKey); const getCrossSigningKey = jest.fn().mockResolvedValue(testKey);
const storeCrossSigningKeyCache = jest.fn().mockResolvedValue(undefined); const storeCrossSigningKeyCache = jest.fn().mockResolvedValue(undefined);
const info = new CrossSigningInfo( const info = new CrossSigningInfo(
userId, userId,
{ getCrossSigningKey }, { getCrossSigningKey },
{ storeCrossSigningKeyCache }, { storeCrossSigningKeyCache },
); );
const [pubKey] = await info.getCrossSigningKey(type, masterKeyPub); const [pubKey] = await info.getCrossSigningKey(type, masterKeyPub);
expect(pubKey).toEqual(masterKeyPub); expect(pubKey).toEqual(masterKeyPub);
expect(storeCrossSigningKeyCache.mock.calls.length).toEqual(shouldCache ? 1 : 0); expect(storeCrossSigningKeyCache.mock.calls.length).toEqual(shouldCache ? 1 : 0);
if (shouldCache) { if (shouldCache) {
expect(storeCrossSigningKeyCache.mock.calls[0][0]).toBe(type); expect(storeCrossSigningKeyCache.mock.calls[0][0]).toBe(type);
expect(storeCrossSigningKeyCache.mock.calls[0][1]).toBe(testKey); expect(storeCrossSigningKeyCache.mock.calls[0][1]).toBe(testKey);
} }
}); });
it.each(types)("does not store a bad key to the cache", it.each(types)("does not store a bad key to the cache",
async ({ type, shouldCache }) => { async ({ type, shouldCache }) => {
const getCrossSigningKey = jest.fn().mockResolvedValue(badKey); const getCrossSigningKey = jest.fn().mockResolvedValue(badKey);
const storeCrossSigningKeyCache = jest.fn().mockResolvedValue(undefined); const storeCrossSigningKeyCache = jest.fn().mockResolvedValue(undefined);
const info = new CrossSigningInfo( const info = new CrossSigningInfo(
userId, userId,
{ getCrossSigningKey }, { getCrossSigningKey },
{ storeCrossSigningKeyCache }, { storeCrossSigningKeyCache },
); );
await expect(info.getCrossSigningKey(type, masterKeyPub)).rejects.toThrow(); await expect(info.getCrossSigningKey(type, masterKeyPub)).rejects.toThrow();
expect(storeCrossSigningKeyCache.mock.calls.length).toEqual(0); expect(storeCrossSigningKeyCache.mock.calls.length).toEqual(0);
}); });
it.each(types)("does not store a value to the cache if it came from the cache", it.each(types)("does not store a value to the cache if it came from the cache",
async ({ type, shouldCache }) => { async ({ type, shouldCache }) => {
const getCrossSigningKey = jest.fn().mockImplementation(() => { const getCrossSigningKey = jest.fn().mockImplementation(() => {
if (shouldCache) { if (shouldCache) {
return Promise.reject(new Error("Regular callback called")); return Promise.reject(new Error("Regular callback called"));
} else { } else {
return Promise.resolve(testKey); return Promise.resolve(testKey);
} }
});
const getCrossSigningKeyCache = jest.fn().mockResolvedValue(testKey);
const storeCrossSigningKeyCache = jest.fn().mockRejectedValue(
new Error("Tried to store a value from cache"),
);
const info = new CrossSigningInfo(
userId,
{ getCrossSigningKey },
{ getCrossSigningKeyCache, storeCrossSigningKeyCache },
);
expect(storeCrossSigningKeyCache.mock.calls.length).toBe(0);
const [pubKey] = await info.getCrossSigningKey(type, masterKeyPub);
expect(pubKey).toEqual(masterKeyPub);
}); });
const getCrossSigningKeyCache = jest.fn().mockResolvedValue(testKey);
const storeCrossSigningKeyCache = jest.fn().mockRejectedValue(
new Error("Tried to store a value from cache"),
);
const info = new CrossSigningInfo(
userId,
{ getCrossSigningKey },
{ getCrossSigningKeyCache, storeCrossSigningKeyCache },
);
expect(storeCrossSigningKeyCache.mock.calls.length).toBe(0);
const [pubKey] = await info.getCrossSigningKey(type, masterKeyPub);
expect(pubKey).toEqual(masterKeyPub);
});
it.each(types)("requests a key from the cache callback (if set) and then calls app" + it.each(types)("requests a key from the cache callback (if set) and then calls app" +
" if one is not found", async ({ type, shouldCache }) => { " if one is not found", async ({ type, shouldCache }) => {
@ -220,12 +220,14 @@ describe("CrossSigningInfo.getCrossSigningKey", function() {
*/ */
describe.each([ describe.each([
["IndexedDBCryptoStore", ["IndexedDBCryptoStore",
() => new IndexedDBCryptoStore(global.indexedDB, "tests")], () => new IndexedDBCryptoStore(global.indexedDB, "tests")],
["LocalStorageCryptoStore", ["LocalStorageCryptoStore",
() => new IndexedDBCryptoStore(undefined, "tests")], () => new IndexedDBCryptoStore(undefined, "tests")],
["MemoryCryptoStore", () => { ["MemoryCryptoStore", () => {
const store = new IndexedDBCryptoStore(undefined, "tests"); const store = new IndexedDBCryptoStore(undefined, "tests");
// @ts-ignore set private properties
store._backend = new MemoryCryptoStore(); store._backend = new MemoryCryptoStore();
// @ts-ignore
store._backendPromise = Promise.resolve(store._backend); store._backendPromise = Promise.resolve(store._backend);
return store; return store;
}], }],

View File

@ -1,7 +1,7 @@
/* /*
Copyright 2017 Vector Creations Ltd Copyright 2017 Vector Creations Ltd
Copyright 2018, 2019 New Vector Ltd Copyright 2018, 2019 New Vector Ltd
Copyright 2019 The Matrix.org Foundation C.I.C. Copyright 2019, 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -20,8 +20,10 @@ import { logger } from "../../../src/logger";
import * as utils from "../../../src/utils"; import * as utils from "../../../src/utils";
import { MemoryCryptoStore } from "../../../src/crypto/store/memory-crypto-store"; import { MemoryCryptoStore } from "../../../src/crypto/store/memory-crypto-store";
import { DeviceList } from "../../../src/crypto/DeviceList"; import { DeviceList } from "../../../src/crypto/DeviceList";
import { IDownloadKeyResult, MatrixClient } from "../../../src";
import { OlmDevice } from "../../../src/crypto/OlmDevice";
const signedDeviceList = { const signedDeviceList: IDownloadKeyResult = {
"failures": {}, "failures": {},
"device_keys": { "device_keys": {
"@test1:sw1v.org": { "@test1:sw1v.org": {
@ -45,13 +47,15 @@ const signedDeviceList = {
"m.megolm.v1.aes-sha2", "m.megolm.v1.aes-sha2",
], ],
"device_id": "HGKAWHRVJQ", "device_id": "HGKAWHRVJQ",
"unsigned": {}, "unsigned": {
"device_display_name": "",
},
}, },
}, },
}, },
}; };
const signedDeviceList2 = { const signedDeviceList2: IDownloadKeyResult = {
"failures": {}, "failures": {},
"device_keys": { "device_keys": {
"@test2:sw1v.org": { "@test2:sw1v.org": {
@ -75,7 +79,9 @@ const signedDeviceList2 = {
"m.megolm.v1.aes-sha2", "m.megolm.v1.aes-sha2",
], ],
"device_id": "QJVRHWAKGH", "device_id": "QJVRHWAKGH",
"unsigned": {}, "unsigned": {
"device_display_name": "",
},
}, },
}, },
}, },
@ -104,10 +110,10 @@ describe('DeviceList', function() {
downloadKeysForUsers: downloadSpy, downloadKeysForUsers: downloadSpy,
getUserId: () => '@test1:sw1v.org', getUserId: () => '@test1:sw1v.org',
deviceId: 'HGKAWHRVJQ', deviceId: 'HGKAWHRVJQ',
}; } as unknown as MatrixClient;
const mockOlm = { const mockOlm = {
verifySignature: function(key, message, signature) {}, verifySignature: function(key, message, signature) {},
}; } as unknown as OlmDevice;
const dl = new DeviceList(baseApis, cryptoStore, mockOlm, keyDownloadChunkSize); const dl = new DeviceList(baseApis, cryptoStore, mockOlm, keyDownloadChunkSize);
deviceLists.push(dl); deviceLists.push(dl);
return dl; return dl;
@ -118,7 +124,7 @@ describe('DeviceList', function() {
dl.startTrackingDeviceList('@test1:sw1v.org'); dl.startTrackingDeviceList('@test1:sw1v.org');
const queryDefer1 = utils.defer(); const queryDefer1 = utils.defer<IDownloadKeyResult>();
downloadSpy.mockReturnValue(queryDefer1.promise); downloadSpy.mockReturnValue(queryDefer1.promise);
const prom1 = dl.refreshOutdatedDeviceLists(); const prom1 = dl.refreshOutdatedDeviceLists();
@ -128,6 +134,7 @@ describe('DeviceList', function() {
return prom1.then(() => { return prom1.then(() => {
const storedKeys = dl.getRawStoredDevicesForUser('@test1:sw1v.org'); const storedKeys = dl.getRawStoredDevicesForUser('@test1:sw1v.org');
expect(Object.keys(storedKeys)).toEqual(['HGKAWHRVJQ']); expect(Object.keys(storedKeys)).toEqual(['HGKAWHRVJQ']);
dl.stop();
}); });
}); });
@ -137,7 +144,7 @@ describe('DeviceList', function() {
dl.startTrackingDeviceList('@test1:sw1v.org'); dl.startTrackingDeviceList('@test1:sw1v.org');
const queryDefer1 = utils.defer(); const queryDefer1 = utils.defer<IDownloadKeyResult>();
downloadSpy.mockReturnValue(queryDefer1.promise); downloadSpy.mockReturnValue(queryDefer1.promise);
const prom1 = dl.refreshOutdatedDeviceLists(); const prom1 = dl.refreshOutdatedDeviceLists();
@ -154,6 +161,7 @@ describe('DeviceList', function() {
dl.saveIfDirty().then(() => { dl.saveIfDirty().then(() => {
// the first request completes // the first request completes
queryDefer1.resolve({ queryDefer1.resolve({
failures: {},
device_keys: { device_keys: {
'@test1:sw1v.org': {}, '@test1:sw1v.org': {},
}, },
@ -165,11 +173,12 @@ describe('DeviceList', function() {
logger.log("Creating new devicelist to simulate app reload"); logger.log("Creating new devicelist to simulate app reload");
downloadSpy.mockReset(); downloadSpy.mockReset();
const dl2 = createTestDeviceList(); const dl2 = createTestDeviceList();
const queryDefer3 = utils.defer(); const queryDefer3 = utils.defer<IDownloadKeyResult>();
downloadSpy.mockReturnValue(queryDefer3.promise); downloadSpy.mockReturnValue(queryDefer3.promise);
const prom3 = dl2.refreshOutdatedDeviceLists(); const prom3 = dl2.refreshOutdatedDeviceLists();
expect(downloadSpy).toHaveBeenCalledWith(['@test1:sw1v.org'], {}); expect(downloadSpy).toHaveBeenCalledWith(['@test1:sw1v.org'], {});
dl2.stop();
queryDefer3.resolve(utils.deepCopy(signedDeviceList)); queryDefer3.resolve(utils.deepCopy(signedDeviceList));
@ -178,6 +187,7 @@ describe('DeviceList', function() {
}).then(() => { }).then(() => {
const storedKeys = dl.getRawStoredDevicesForUser('@test1:sw1v.org'); const storedKeys = dl.getRawStoredDevicesForUser('@test1:sw1v.org');
expect(Object.keys(storedKeys)).toEqual(['HGKAWHRVJQ']); expect(Object.keys(storedKeys)).toEqual(['HGKAWHRVJQ']);
dl.stop();
}); });
}); });
@ -187,9 +197,9 @@ describe('DeviceList', function() {
dl.startTrackingDeviceList('@test1:sw1v.org'); dl.startTrackingDeviceList('@test1:sw1v.org');
dl.startTrackingDeviceList('@test2:sw1v.org'); dl.startTrackingDeviceList('@test2:sw1v.org');
const queryDefer1 = utils.defer(); const queryDefer1 = utils.defer<IDownloadKeyResult>();
downloadSpy.mockReturnValueOnce(queryDefer1.promise); downloadSpy.mockReturnValueOnce(queryDefer1.promise);
const queryDefer2 = utils.defer(); const queryDefer2 = utils.defer<IDownloadKeyResult>();
downloadSpy.mockReturnValueOnce(queryDefer2.promise); downloadSpy.mockReturnValueOnce(queryDefer2.promise);
const prom1 = dl.refreshOutdatedDeviceLists(); const prom1 = dl.refreshOutdatedDeviceLists();
@ -204,6 +214,7 @@ describe('DeviceList', function() {
expect(Object.keys(storedKeys1)).toEqual(['HGKAWHRVJQ']); expect(Object.keys(storedKeys1)).toEqual(['HGKAWHRVJQ']);
const storedKeys2 = dl.getRawStoredDevicesForUser('@test2:sw1v.org'); const storedKeys2 = dl.getRawStoredDevicesForUser('@test2:sw1v.org');
expect(Object.keys(storedKeys2)).toEqual(['QJVRHWAKGH']); expect(Object.keys(storedKeys2)).toEqual(['QJVRHWAKGH']);
dl.stop();
}); });
}); });
}); });

View File

@ -257,6 +257,8 @@ describe("MegolmDecryption", function() {
}); });
describe("session reuse and key reshares", () => { describe("session reuse and key reshares", () => {
const rotationPeriodMs = 999 * 24 * 60 * 60 * 1000; // 999 days, so we don't have to deal with it
let megolmEncryption; let megolmEncryption;
let aliceDeviceInfo; let aliceDeviceInfo;
let mockRoom; let mockRoom;
@ -318,7 +320,7 @@ describe("MegolmDecryption", function() {
baseApis: mockBaseApis, baseApis: mockBaseApis,
roomId: ROOM_ID, roomId: ROOM_ID,
config: { config: {
rotation_period_ms: 9999999999999, rotation_period_ms: rotationPeriodMs,
}, },
}); });
@ -336,6 +338,31 @@ describe("MegolmDecryption", function() {
}; };
}); });
it("should use larger otkTimeout when preparing to encrypt room", async () => {
megolmEncryption.prepareToEncrypt(mockRoom);
await megolmEncryption.encryptMessage(mockRoom, "a.fake.type", {
body: "Some text",
});
expect(mockRoom.getEncryptionTargetMembers).toHaveBeenCalled();
expect(mockBaseApis.claimOneTimeKeys).toHaveBeenCalledWith(
[['@alice:home.server', 'aliceDevice']], 'signed_curve25519', 10000,
);
});
it("should generate a new session if this one needs rotation", async () => {
const session = await megolmEncryption.prepareNewSession(false);
session.creationTime -= rotationPeriodMs + 10000; // a smidge over the rotation time
// Inject expired session which needs rotation
megolmEncryption.setupPromise = Promise.resolve(session);
const prepareNewSessionSpy = jest.spyOn(megolmEncryption, "prepareNewSession");
await megolmEncryption.encryptMessage(mockRoom, "a.fake.type", {
body: "Some text",
});
expect(prepareNewSessionSpy).toHaveBeenCalledTimes(1);
});
it("re-uses sessions for sequential messages", async function() { it("re-uses sessions for sequential messages", async function() {
const ct1 = await megolmEncryption.encryptMessage(mockRoom, "a.fake.type", { const ct1 = await megolmEncryption.encryptMessage(mockRoom, "a.fake.type", {
body: "Some text", body: "Some text",
@ -603,6 +630,8 @@ describe("MegolmDecryption", function() {
}); });
await aliceClient.crypto.encryptEvent(event, aliceRoom); await aliceClient.crypto.encryptEvent(event, aliceRoom);
await sendPromise; await sendPromise;
aliceClient.stopClient();
bobClient.stopClient();
}); });
it("throws an error describing why it doesn't have a key", async function() { it("throws an error describing why it doesn't have a key", async function() {
@ -673,6 +702,8 @@ describe("MegolmDecryption", function() {
session_id: "session_id2", session_id: "session_id2",
}, },
}))).rejects.toThrow("The sender has blocked you."); }))).rejects.toThrow("The sender has blocked you.");
aliceClient.stopClient();
bobClient.stopClient();
}); });
it("throws an error describing the lack of an olm session", async function() { it("throws an error describing the lack of an olm session", async function() {
@ -756,6 +787,8 @@ describe("MegolmDecryption", function() {
}, },
origin_server_ts: now, origin_server_ts: now,
}))).rejects.toThrow("The sender was unable to establish a secure channel."); }))).rejects.toThrow("The sender was unable to establish a secure channel.");
aliceClient.stopClient();
bobClient.stopClient();
}); });
it("throws an error to indicate a wedged olm session", async function() { it("throws an error to indicate a wedged olm session", async function() {
@ -806,5 +839,7 @@ describe("MegolmDecryption", function() {
}, },
origin_server_ts: now, origin_server_ts: now,
}))).rejects.toThrow("The secure channel with the sender was corrupted."); }))).rejects.toThrow("The secure channel with the sender was corrupted.");
aliceClient.stopClient();
bobClient.stopClient();
}); });
}); });

View File

@ -21,7 +21,6 @@ import * as olmlib from "../../../src/crypto/olmlib";
import { MatrixClient } from "../../../src/client"; import { MatrixClient } from "../../../src/client";
import { MatrixEvent } from "../../../src/models/event"; import { MatrixEvent } from "../../../src/models/event";
import * as algorithms from "../../../src/crypto/algorithms"; import * as algorithms from "../../../src/crypto/algorithms";
import { WebStorageSessionStore } from "../../../src/store/session/webstorage";
import { MemoryCryptoStore } from "../../../src/crypto/store/memory-crypto-store"; import { MemoryCryptoStore } from "../../../src/crypto/store/memory-crypto-store";
import { MockStorageApi } from "../../MockStorageApi"; import { MockStorageApi } from "../../MockStorageApi";
import * as testUtils from "../../test-utils/test-utils"; import * as testUtils from "../../test-utils/test-utils";
@ -118,7 +117,7 @@ function saveCrossSigningKeys(k) {
Object.assign(keys, k); Object.assign(keys, k);
} }
function makeTestClient(sessionStore, cryptoStore) { function makeTestClient(cryptoStore) {
const scheduler = [ const scheduler = [
"getQueueForEvent", "queueEvent", "removeEventFromQueue", "getQueueForEvent", "queueEvent", "removeEventFromQueue",
"setProcessFunction", "setProcessFunction",
@ -141,7 +140,6 @@ function makeTestClient(sessionStore, cryptoStore) {
scheduler: scheduler, scheduler: scheduler,
userId: "@alice:bar", userId: "@alice:bar",
deviceId: "device", deviceId: "device",
sessionStore: sessionStore,
cryptoStore: cryptoStore, cryptoStore: cryptoStore,
cryptoCallbacks: { getCrossSigningKey, saveCrossSigningKeys }, cryptoCallbacks: { getCrossSigningKey, saveCrossSigningKeys },
}); });
@ -161,7 +159,6 @@ describe("MegolmBackup", function() {
let mockOlmLib; let mockOlmLib;
let mockCrypto; let mockCrypto;
let mockStorage; let mockStorage;
let sessionStore;
let cryptoStore; let cryptoStore;
let megolmDecryption; let megolmDecryption;
beforeEach(async function() { beforeEach(async function() {
@ -174,7 +171,6 @@ describe("MegolmBackup", function() {
mockCrypto.backupInfo = CURVE25519_BACKUP_INFO; mockCrypto.backupInfo = CURVE25519_BACKUP_INFO;
mockStorage = new MockStorageApi(); mockStorage = new MockStorageApi();
sessionStore = new WebStorageSessionStore(mockStorage);
cryptoStore = new MemoryCryptoStore(mockStorage); cryptoStore = new MemoryCryptoStore(mockStorage);
olmDevice = new OlmDevice(cryptoStore); olmDevice = new OlmDevice(cryptoStore);
@ -261,7 +257,7 @@ describe("MegolmBackup", function() {
const ibGroupSession = new Olm.InboundGroupSession(); const ibGroupSession = new Olm.InboundGroupSession();
ibGroupSession.create(groupSession.session_key()); ibGroupSession.create(groupSession.session_key());
const client = makeTestClient(sessionStore, cryptoStore); const client = makeTestClient(cryptoStore);
megolmDecryption = new MegolmDecryption({ megolmDecryption = new MegolmDecryption({
userId: '@user:id', userId: '@user:id',
@ -329,6 +325,7 @@ describe("MegolmBackup", function() {
); );
}).then(() => { }).then(() => {
expect(numCalls).toBe(1); expect(numCalls).toBe(1);
client.stopClient();
}); });
}); });
}); });
@ -339,7 +336,7 @@ describe("MegolmBackup", function() {
const ibGroupSession = new Olm.InboundGroupSession(); const ibGroupSession = new Olm.InboundGroupSession();
ibGroupSession.create(groupSession.session_key()); ibGroupSession.create(groupSession.session_key());
const client = makeTestClient(sessionStore, cryptoStore); const client = makeTestClient(cryptoStore);
megolmDecryption = new MegolmDecryption({ megolmDecryption = new MegolmDecryption({
userId: '@user:id', userId: '@user:id',
@ -411,6 +408,7 @@ describe("MegolmBackup", function() {
); );
}).then(() => { }).then(() => {
expect(numCalls).toBe(1); expect(numCalls).toBe(1);
client.stopClient();
}); });
}); });
}); });
@ -421,7 +419,7 @@ describe("MegolmBackup", function() {
const ibGroupSession = new Olm.InboundGroupSession(); const ibGroupSession = new Olm.InboundGroupSession();
ibGroupSession.create(groupSession.session_key()); ibGroupSession.create(groupSession.session_key());
const client = makeTestClient(sessionStore, cryptoStore); const client = makeTestClient(cryptoStore);
megolmDecryption = new MegolmDecryption({ megolmDecryption = new MegolmDecryption({
userId: '@user:id', userId: '@user:id',
@ -487,6 +485,7 @@ describe("MegolmBackup", function() {
}), }),
]); ]);
expect(numCalls).toBe(2); expect(numCalls).toBe(2);
client.stopClient();
}); });
it('retries when a backup fails', function() { it('retries when a backup fails', function() {
@ -517,7 +516,6 @@ describe("MegolmBackup", function() {
scheduler: scheduler, scheduler: scheduler,
userId: "@alice:bar", userId: "@alice:bar",
deviceId: "device", deviceId: "device",
sessionStore: sessionStore,
cryptoStore: cryptoStore, cryptoStore: cryptoStore,
}); });
@ -593,6 +591,7 @@ describe("MegolmBackup", function() {
); );
}).then(() => { }).then(() => {
expect(numCalls).toBe(2); expect(numCalls).toBe(2);
client.stopClient();
}); });
}); });
}); });
@ -602,7 +601,7 @@ describe("MegolmBackup", function() {
let client; let client;
beforeEach(function() { beforeEach(function() {
client = makeTestClient(sessionStore, cryptoStore); client = makeTestClient(cryptoStore);
megolmDecryption = new MegolmDecryption({ megolmDecryption = new MegolmDecryption({
userId: '@user:id', userId: '@user:id',

View File

@ -17,12 +17,16 @@ limitations under the License.
import '../../olm-loader'; import '../../olm-loader';
import anotherjson from 'another-json'; import anotherjson from 'another-json';
import { PkSigning } from '@matrix-org/olm';
import * as olmlib from "../../../src/crypto/olmlib"; import * as olmlib from "../../../src/crypto/olmlib";
import { TestClient } from '../../TestClient';
import { resetCrossSigningKeys } from "./crypto-utils";
import { MatrixError } from '../../../src/http-api'; import { MatrixError } from '../../../src/http-api';
import { logger } from '../../../src/logger'; import { logger } from '../../../src/logger';
import { ICrossSigningKey, ICreateClientOpts, ISignedKey } from '../../../src/client';
import { CryptoEvent } from '../../../src/crypto';
import { IDevice } from '../../../src/crypto/deviceinfo';
import { TestClient } from '../../TestClient';
import { resetCrossSigningKeys } from "./crypto-utils";
const PUSH_RULES_RESPONSE = { const PUSH_RULES_RESPONSE = {
method: "GET", method: "GET",
@ -47,9 +51,11 @@ function setHttpResponses(httpBackend, responses) {
}); });
} }
async function makeTestClient(userInfo, options, keys) { async function makeTestClient(
if (!keys) keys = {}; userInfo: { userId: string, deviceId: string},
options: Partial<ICreateClientOpts> = {},
keys = {},
) {
function getCrossSigningKey(type) { function getCrossSigningKey(type) {
return keys[type]; return keys[type];
} }
@ -58,7 +64,6 @@ async function makeTestClient(userInfo, options, keys) {
Object.assign(keys, k); Object.assign(keys, k);
} }
if (!options) options = {};
options.cryptoCallbacks = Object.assign( options.cryptoCallbacks = Object.assign(
{}, { getCrossSigningKey, saveCrossSigningKeys }, options.cryptoCallbacks || {}, {}, { getCrossSigningKey, saveCrossSigningKeys }, options.cryptoCallbacks || {},
); );
@ -86,20 +91,21 @@ describe("Cross Signing", function() {
const { client: alice } = await makeTestClient( const { client: alice } = await makeTestClient(
{ userId: "@alice:example.com", deviceId: "Osborne2" }, { userId: "@alice:example.com", deviceId: "Osborne2" },
); );
alice.uploadDeviceSigningKeys = jest.fn(async (auth, keys) => { alice.uploadDeviceSigningKeys = jest.fn().mockImplementation(async (auth, keys) => {
await olmlib.verifySignature( await olmlib.verifySignature(
alice.crypto.olmDevice, keys.master_key, "@alice:example.com", alice.crypto.olmDevice, keys.master_key, "@alice:example.com",
"Osborne2", alice.crypto.olmDevice.deviceEd25519Key, "Osborne2", alice.crypto.olmDevice.deviceEd25519Key,
); );
}); });
alice.uploadKeySignatures = async () => {}; alice.uploadKeySignatures = async () => ({ failures: {} });
alice.setAccountData = async () => {}; alice.setAccountData = async () => ({});
alice.getAccountDataFromServer = async () => {}; alice.getAccountDataFromServer = async <T>() => ({} as T);
// set Alice's cross-signing key // set Alice's cross-signing key
await alice.bootstrapCrossSigning({ await alice.bootstrapCrossSigning({
authUploadDeviceSigningKeys: async func => await func({}), authUploadDeviceSigningKeys: async func => { await func({}); },
}); });
expect(alice.uploadDeviceSigningKeys).toHaveBeenCalled(); expect(alice.uploadDeviceSigningKeys).toHaveBeenCalled();
alice.stopClient();
}); });
it("should abort bootstrap if device signing auth fails", async function() { it("should abort bootstrap if device signing auth fails", async function() {
@ -133,9 +139,9 @@ describe("Cross Signing", function() {
error.httpStatus == 401; error.httpStatus == 401;
throw error; throw error;
}; };
alice.uploadKeySignatures = async () => {}; alice.uploadKeySignatures = async () => ({ failures: {} });
alice.setAccountData = async () => {}; alice.setAccountData = async () => ({});
alice.getAccountDataFromServer = async () => { }; alice.getAccountDataFromServer = async <T extends {[k: string]: any}>(): Promise<T> => ({} as T);
const authUploadDeviceSigningKeys = async func => await func({}); const authUploadDeviceSigningKeys = async func => await func({});
// Try bootstrap, expecting `authUploadDeviceSigningKeys` to pass // Try bootstrap, expecting `authUploadDeviceSigningKeys` to pass
@ -151,14 +157,15 @@ describe("Cross Signing", function() {
} }
} }
expect(bootstrapDidThrow).toBeTruthy(); expect(bootstrapDidThrow).toBeTruthy();
alice.stopClient();
}); });
it("should upload a signature when a user is verified", async function() { it("should upload a signature when a user is verified", async function() {
const { client: alice } = await makeTestClient( const { client: alice } = await makeTestClient(
{ userId: "@alice:example.com", deviceId: "Osborne2" }, { userId: "@alice:example.com", deviceId: "Osborne2" },
); );
alice.uploadDeviceSigningKeys = async () => {}; alice.uploadDeviceSigningKeys = async () => ({});
alice.uploadKeySignatures = async () => {}; alice.uploadKeySignatures = async () => ({ failures: {} });
// set Alice's cross-signing key // set Alice's cross-signing key
await resetCrossSigningKeys(alice); await resetCrossSigningKeys(alice);
// Alice downloads Bob's device key // Alice downloads Bob's device key
@ -172,16 +179,20 @@ describe("Cross Signing", function() {
}, },
}, },
}, },
firstUse: false,
crossSigningVerifiedBefore: false,
}); });
// Alice verifies Bob's key // Alice verifies Bob's key
const promise = new Promise((resolve, reject) => { const promise = new Promise((resolve, reject) => {
alice.uploadKeySignatures = (...args) => { alice.uploadKeySignatures = async (...args) => {
resolve(...args); resolve(...args);
return { failures: {} };
}; };
}); });
await alice.setDeviceVerified("@bob:example.com", "bobs+master+pubkey", true); await alice.setDeviceVerified("@bob:example.com", "bobs+master+pubkey", true);
// Alice should send a signature of Bob's key to the server // Alice should send a signature of Bob's key to the server
await promise; await promise;
alice.stopClient();
}); });
it.skip("should get cross-signing keys from sync", async function() { it.skip("should get cross-signing keys from sync", async function() {
@ -203,7 +214,7 @@ describe("Cross Signing", function() {
{ {
cryptoCallbacks: { cryptoCallbacks: {
// will be called to sign our own device // will be called to sign our own device
getCrossSigningKey: type => { getCrossSigningKey: async type => {
if (type === 'master') { if (type === 'master') {
return masterKey; return masterKey;
} else { } else {
@ -215,7 +226,7 @@ describe("Cross Signing", function() {
); );
const keyChangePromise = new Promise((resolve, reject) => { const keyChangePromise = new Promise((resolve, reject) => {
alice.once("crossSigning.keysChanged", async (e) => { alice.once(CryptoEvent.KeysChanged, async (e) => {
resolve(e); resolve(e);
await alice.checkOwnCrossSigningTrust({ await alice.checkOwnCrossSigningTrust({
allowPrivateKeyRequests: true, allowPrivateKeyRequests: true,
@ -223,14 +234,14 @@ describe("Cross Signing", function() {
}); });
}); });
const uploadSigsPromise = new Promise((resolve, reject) => { const uploadSigsPromise = new Promise<void>((resolve, reject) => {
alice.uploadKeySignatures = jest.fn(async (content) => { alice.uploadKeySignatures = jest.fn().mockImplementation(async (content) => {
try { try {
await olmlib.verifySignature( await olmlib.verifySignature(
alice.crypto.olmDevice, alice.crypto.olmDevice,
content["@alice:example.com"][ content["@alice:example.com"][
"nqOvzeuGWT/sRx3h7+MHoInYj3Uk2LD/unI9kDYcHwk" "nqOvzeuGWT/sRx3h7+MHoInYj3Uk2LD/unI9kDYcHwk"
], ],
"@alice:example.com", "@alice:example.com",
"Osborne2", alice.crypto.olmDevice.deviceEd25519Key, "Osborne2", alice.crypto.olmDevice.deviceEd25519Key,
); );
@ -246,16 +257,22 @@ describe("Cross Signing", function() {
}); });
}); });
// @ts-ignore private property
const deviceInfo = alice.crypto.deviceList.devices["@alice:example.com"] const deviceInfo = alice.crypto.deviceList.devices["@alice:example.com"]
.Osborne2; .Osborne2;
const aliceDevice = { const aliceDevice = {
user_id: "@alice:example.com", user_id: "@alice:example.com",
device_id: "Osborne2", device_id: "Osborne2",
keys: deviceInfo.keys,
algorithms: deviceInfo.algorithms,
}; };
aliceDevice.keys = deviceInfo.keys;
aliceDevice.algorithms = deviceInfo.algorithms;
await alice.crypto.signObject(aliceDevice); await alice.crypto.signObject(aliceDevice);
olmlib.pkSign(aliceDevice, selfSigningKey, "@alice:example.com"); olmlib.pkSign(
aliceDevice as ISignedKey,
selfSigningKey as unknown as PkSigning,
"@alice:example.com",
'',
);
// feed sync result that includes master key, ssk, device key // feed sync result that includes master key, ssk, device key
const responses = [ const responses = [
@ -353,14 +370,15 @@ describe("Cross Signing", function() {
expect(aliceDeviceTrust.isLocallyVerified()).toBeTruthy(); expect(aliceDeviceTrust.isLocallyVerified()).toBeTruthy();
expect(aliceDeviceTrust.isTofu()).toBeTruthy(); expect(aliceDeviceTrust.isTofu()).toBeTruthy();
expect(aliceDeviceTrust.isVerified()).toBeTruthy(); expect(aliceDeviceTrust.isVerified()).toBeTruthy();
alice.stopClient();
}); });
it("should use trust chain to determine device verification", async function() { it("should use trust chain to determine device verification", async function() {
const { client: alice } = await makeTestClient( const { client: alice } = await makeTestClient(
{ userId: "@alice:example.com", deviceId: "Osborne2" }, { userId: "@alice:example.com", deviceId: "Osborne2" },
); );
alice.uploadDeviceSigningKeys = async () => {}; alice.uploadDeviceSigningKeys = async () => ({});
alice.uploadKeySignatures = async () => {}; alice.uploadKeySignatures = async () => ({ failures: {} });
// set Alice's cross-signing key // set Alice's cross-signing key
await resetCrossSigningKeys(alice); await resetCrossSigningKeys(alice);
// Alice downloads Bob's ssk and device key // Alice downloads Bob's ssk and device key
@ -370,7 +388,7 @@ describe("Cross Signing", function() {
const bobSigning = new global.Olm.PkSigning(); const bobSigning = new global.Olm.PkSigning();
const bobPrivkey = bobSigning.generate_seed(); const bobPrivkey = bobSigning.generate_seed();
const bobPubkey = bobSigning.init_with_seed(bobPrivkey); const bobPubkey = bobSigning.init_with_seed(bobPrivkey);
const bobSSK = { const bobSSK: ICrossSigningKey = {
user_id: "@bob:example.com", user_id: "@bob:example.com",
usage: ["self_signing"], usage: ["self_signing"],
keys: { keys: {
@ -394,10 +412,10 @@ describe("Cross Signing", function() {
}, },
self_signing: bobSSK, self_signing: bobSSK,
}, },
firstUse: 1, firstUse: true,
unsigned: {}, crossSigningVerifiedBefore: false,
}); });
const bobDevice = { const bobDeviceUnsigned = {
user_id: "@bob:example.com", user_id: "@bob:example.com",
device_id: "Dynabook", device_id: "Dynabook",
algorithms: ["m.olm.curve25519-aes-sha256", "m.megolm.v1.aes-sha"], algorithms: ["m.olm.curve25519-aes-sha256", "m.megolm.v1.aes-sha"],
@ -406,11 +424,16 @@ describe("Cross Signing", function() {
"ed25519:Dynabook": "someOtherPubkey", "ed25519:Dynabook": "someOtherPubkey",
}, },
}; };
const sig = bobSigning.sign(anotherjson.stringify(bobDevice)); const sig = bobSigning.sign(anotherjson.stringify(bobDeviceUnsigned));
bobDevice.signatures = { const bobDevice: IDevice = {
"@bob:example.com": { ...bobDeviceUnsigned,
["ed25519:" + bobPubkey]: sig, signatures: {
"@bob:example.com": {
["ed25519:" + bobPubkey]: sig,
},
}, },
verified: 0,
known: false,
}; };
alice.crypto.deviceList.storeDevicesForUser("@bob:example.com", { alice.crypto.deviceList.storeDevicesForUser("@bob:example.com", {
Dynabook: bobDevice, Dynabook: bobDevice,
@ -425,7 +448,7 @@ describe("Cross Signing", function() {
expect(bobDeviceTrust.isTofu()).toBeTruthy(); expect(bobDeviceTrust.isTofu()).toBeTruthy();
// Alice verifies Bob's SSK // Alice verifies Bob's SSK
alice.uploadKeySignatures = () => {}; alice.uploadKeySignatures = async () => ({ failures: {} });
await alice.setDeviceVerified("@bob:example.com", bobMasterPubkey, true); await alice.setDeviceVerified("@bob:example.com", bobMasterPubkey, true);
// Bob's device key should be trusted // Bob's device key should be trusted
@ -437,10 +460,11 @@ describe("Cross Signing", function() {
expect(bobDeviceTrust2.isCrossSigningVerified()).toBeTruthy(); expect(bobDeviceTrust2.isCrossSigningVerified()).toBeTruthy();
expect(bobDeviceTrust2.isLocallyVerified()).toBeFalsy(); expect(bobDeviceTrust2.isLocallyVerified()).toBeFalsy();
expect(bobDeviceTrust2.isTofu()).toBeTruthy(); expect(bobDeviceTrust2.isTofu()).toBeTruthy();
alice.stopClient();
}); });
it.skip("should trust signatures received from other devices", async function() { it.skip("should trust signatures received from other devices", async function() {
const aliceKeys = {}; const aliceKeys: Record<string, PkSigning> = {};
const { client: alice, httpBackend } = await makeTestClient( const { client: alice, httpBackend } = await makeTestClient(
{ userId: "@alice:example.com", deviceId: "Osborne2" }, { userId: "@alice:example.com", deviceId: "Osborne2" },
null, null,
@ -448,8 +472,8 @@ describe("Cross Signing", function() {
); );
alice.crypto.deviceList.startTrackingDeviceList("@bob:example.com"); alice.crypto.deviceList.startTrackingDeviceList("@bob:example.com");
alice.crypto.deviceList.stopTrackingAllDeviceLists = () => {}; alice.crypto.deviceList.stopTrackingAllDeviceLists = () => {};
alice.uploadDeviceSigningKeys = async () => {}; alice.uploadDeviceSigningKeys = async () => ({});
alice.uploadKeySignatures = async () => {}; alice.uploadKeySignatures = async () => ({ failures: {} });
// set Alice's cross-signing key // set Alice's cross-signing key
await resetCrossSigningKeys(alice); await resetCrossSigningKeys(alice);
@ -461,28 +485,29 @@ describe("Cross Signing", function() {
0x34, 0xf2, 0x4b, 0x64, 0x9b, 0x52, 0xf8, 0x5f, 0x34, 0xf2, 0x4b, 0x64, 0x9b, 0x52, 0xf8, 0x5f,
]); ]);
const keyChangePromise = new Promise((resolve, reject) => { const keyChangePromise = new Promise<void>((resolve, reject) => {
alice.crypto.deviceList.once("userCrossSigningUpdated", (userId) => { alice.crypto.deviceList.once(CryptoEvent.UserCrossSigningUpdated, (userId) => {
if (userId === "@bob:example.com") { if (userId === "@bob:example.com") {
resolve(); resolve();
} }
}); });
}); });
// @ts-ignore private property
const deviceInfo = alice.crypto.deviceList.devices["@alice:example.com"] const deviceInfo = alice.crypto.deviceList.devices["@alice:example.com"]
.Osborne2; .Osborne2;
const aliceDevice = { const aliceDevice = {
user_id: "@alice:example.com", user_id: "@alice:example.com",
device_id: "Osborne2", device_id: "Osborne2",
keys: deviceInfo.keys,
algorithms: deviceInfo.algorithms,
}; };
aliceDevice.keys = deviceInfo.keys;
aliceDevice.algorithms = deviceInfo.algorithms;
await alice.crypto.signObject(aliceDevice); await alice.crypto.signObject(aliceDevice);
const bobOlmAccount = new global.Olm.Account(); const bobOlmAccount = new global.Olm.Account();
bobOlmAccount.create(); bobOlmAccount.create();
const bobKeys = JSON.parse(bobOlmAccount.identity_keys()); const bobKeys = JSON.parse(bobOlmAccount.identity_keys());
const bobDevice = { const bobDeviceUnsigned = {
user_id: "@bob:example.com", user_id: "@bob:example.com",
device_id: "Dynabook", device_id: "Dynabook",
algorithms: [olmlib.OLM_ALGORITHM, olmlib.MEGOLM_ALGORITHM], algorithms: [olmlib.OLM_ALGORITHM, olmlib.MEGOLM_ALGORITHM],
@ -491,15 +516,25 @@ describe("Cross Signing", function() {
"curve25519:Dynabook": bobKeys.curve25519, "curve25519:Dynabook": bobKeys.curve25519,
}, },
}; };
const deviceStr = anotherjson.stringify(bobDevice); const deviceStr = anotherjson.stringify(bobDeviceUnsigned);
bobDevice.signatures = { const bobDevice: IDevice = {
"@bob:example.com": { ...bobDeviceUnsigned,
"ed25519:Dynabook": bobOlmAccount.sign(deviceStr), signatures: {
"@bob:example.com": {
"ed25519:Dynabook": bobOlmAccount.sign(deviceStr),
},
}, },
verified: 0,
known: false,
}; };
olmlib.pkSign(bobDevice, selfSigningKey, "@bob:example.com"); olmlib.pkSign(
bobDevice,
selfSigningKey as unknown as PkSigning,
"@bob:example.com",
'',
);
const bobMaster = { const bobMaster: ICrossSigningKey = {
user_id: "@bob:example.com", user_id: "@bob:example.com",
usage: ["master"], usage: ["master"],
keys: { keys: {
@ -507,7 +542,7 @@ describe("Cross Signing", function() {
"nqOvzeuGWT/sRx3h7+MHoInYj3Uk2LD/unI9kDYcHwk", "nqOvzeuGWT/sRx3h7+MHoInYj3Uk2LD/unI9kDYcHwk",
}, },
}; };
olmlib.pkSign(bobMaster, aliceKeys.user_signing, "@alice:example.com"); olmlib.pkSign(bobMaster, aliceKeys.user_signing, "@alice:example.com", '');
// Alice downloads Bob's keys // Alice downloads Bob's keys
// - device key // - device key
@ -600,14 +635,15 @@ describe("Cross Signing", function() {
expect(bobDeviceTrust.isCrossSigningVerified()).toBeTruthy(); expect(bobDeviceTrust.isCrossSigningVerified()).toBeTruthy();
expect(bobDeviceTrust.isLocallyVerified()).toBeFalsy(); expect(bobDeviceTrust.isLocallyVerified()).toBeFalsy();
expect(bobDeviceTrust.isTofu()).toBeTruthy(); expect(bobDeviceTrust.isTofu()).toBeTruthy();
alice.stopClient();
}); });
it("should dis-trust an unsigned device", async function() { it("should dis-trust an unsigned device", async function() {
const { client: alice } = await makeTestClient( const { client: alice } = await makeTestClient(
{ userId: "@alice:example.com", deviceId: "Osborne2" }, { userId: "@alice:example.com", deviceId: "Osborne2" },
); );
alice.uploadDeviceSigningKeys = async () => {}; alice.uploadDeviceSigningKeys = async () => ({});
alice.uploadKeySignatures = async () => {}; alice.uploadKeySignatures = async () => ({ failures: {} });
// set Alice's cross-signing key // set Alice's cross-signing key
await resetCrossSigningKeys(alice); await resetCrossSigningKeys(alice);
// Alice downloads Bob's ssk and device key // Alice downloads Bob's ssk and device key
@ -618,7 +654,7 @@ describe("Cross Signing", function() {
const bobSigning = new global.Olm.PkSigning(); const bobSigning = new global.Olm.PkSigning();
const bobPrivkey = bobSigning.generate_seed(); const bobPrivkey = bobSigning.generate_seed();
const bobPubkey = bobSigning.init_with_seed(bobPrivkey); const bobPubkey = bobSigning.init_with_seed(bobPrivkey);
const bobSSK = { const bobSSK: ICrossSigningKey = {
user_id: "@bob:example.com", user_id: "@bob:example.com",
usage: ["self_signing"], usage: ["self_signing"],
keys: { keys: {
@ -642,8 +678,8 @@ describe("Cross Signing", function() {
}, },
self_signing: bobSSK, self_signing: bobSSK,
}, },
firstUse: 1, firstUse: true,
unsigned: {}, crossSigningVerifiedBefore: false,
}); });
const bobDevice = { const bobDevice = {
user_id: "@bob:example.com", user_id: "@bob:example.com",
@ -655,7 +691,7 @@ describe("Cross Signing", function() {
}, },
}; };
alice.crypto.deviceList.storeDevicesForUser("@bob:example.com", { alice.crypto.deviceList.storeDevicesForUser("@bob:example.com", {
Dynabook: bobDevice, Dynabook: bobDevice as unknown as IDevice,
}); });
// Bob's device key should be untrusted // Bob's device key should be untrusted
const bobDeviceTrust = alice.checkDeviceTrust("@bob:example.com", "Dynabook"); const bobDeviceTrust = alice.checkDeviceTrust("@bob:example.com", "Dynabook");
@ -669,14 +705,15 @@ describe("Cross Signing", function() {
const bobDeviceTrust2 = alice.checkDeviceTrust("@bob:example.com", "Dynabook"); const bobDeviceTrust2 = alice.checkDeviceTrust("@bob:example.com", "Dynabook");
expect(bobDeviceTrust2.isVerified()).toBeFalsy(); expect(bobDeviceTrust2.isVerified()).toBeFalsy();
expect(bobDeviceTrust2.isTofu()).toBeFalsy(); expect(bobDeviceTrust2.isTofu()).toBeFalsy();
alice.stopClient();
}); });
it("should dis-trust a user when their ssk changes", async function() { it("should dis-trust a user when their ssk changes", async function() {
const { client: alice } = await makeTestClient( const { client: alice } = await makeTestClient(
{ userId: "@alice:example.com", deviceId: "Osborne2" }, { userId: "@alice:example.com", deviceId: "Osborne2" },
); );
alice.uploadDeviceSigningKeys = async () => {}; alice.uploadDeviceSigningKeys = async () => ({});
alice.uploadKeySignatures = async () => {}; alice.uploadKeySignatures = async () => ({ failures: {} });
await resetCrossSigningKeys(alice); await resetCrossSigningKeys(alice);
// Alice downloads Bob's keys // Alice downloads Bob's keys
const bobMasterSigning = new global.Olm.PkSigning(); const bobMasterSigning = new global.Olm.PkSigning();
@ -685,7 +722,7 @@ describe("Cross Signing", function() {
const bobSigning = new global.Olm.PkSigning(); const bobSigning = new global.Olm.PkSigning();
const bobPrivkey = bobSigning.generate_seed(); const bobPrivkey = bobSigning.generate_seed();
const bobPubkey = bobSigning.init_with_seed(bobPrivkey); const bobPubkey = bobSigning.init_with_seed(bobPrivkey);
const bobSSK = { const bobSSK: ICrossSigningKey = {
user_id: "@bob:example.com", user_id: "@bob:example.com",
usage: ["self_signing"], usage: ["self_signing"],
keys: { keys: {
@ -709,10 +746,10 @@ describe("Cross Signing", function() {
}, },
self_signing: bobSSK, self_signing: bobSSK,
}, },
firstUse: 1, firstUse: true,
unsigned: {}, crossSigningVerifiedBefore: false,
}); });
const bobDevice = { const bobDeviceUnsigned = {
user_id: "@bob:example.com", user_id: "@bob:example.com",
device_id: "Dynabook", device_id: "Dynabook",
algorithms: ["m.olm.curve25519-aes-sha256", "m.megolm.v1.aes-sha"], algorithms: ["m.olm.curve25519-aes-sha256", "m.megolm.v1.aes-sha"],
@ -721,16 +758,23 @@ describe("Cross Signing", function() {
"ed25519:Dynabook": "someOtherPubkey", "ed25519:Dynabook": "someOtherPubkey",
}, },
}; };
const bobDeviceString = anotherjson.stringify(bobDevice); const bobDeviceString = anotherjson.stringify(bobDeviceUnsigned);
const sig = bobSigning.sign(bobDeviceString); const sig = bobSigning.sign(bobDeviceString);
bobDevice.signatures = {}; const bobDevice: IDevice = {
bobDevice.signatures["@bob:example.com"] = {}; ...bobDeviceUnsigned,
bobDevice.signatures["@bob:example.com"]["ed25519:" + bobPubkey] = sig; verified: 0,
known: false,
signatures: {
"@bob:example.com": {
["ed25519:" + bobPubkey]: sig,
},
},
};
alice.crypto.deviceList.storeDevicesForUser("@bob:example.com", { alice.crypto.deviceList.storeDevicesForUser("@bob:example.com", {
Dynabook: bobDevice, Dynabook: bobDevice,
}); });
// Alice verifies Bob's SSK // Alice verifies Bob's SSK
alice.uploadKeySignatures = () => {}; alice.uploadKeySignatures = async () => ({ failures: {} });
await alice.setDeviceVerified("@bob:example.com", bobMasterPubkey, true); await alice.setDeviceVerified("@bob:example.com", bobMasterPubkey, true);
// Bob's device key should be trusted // Bob's device key should be trusted
@ -745,7 +789,7 @@ describe("Cross Signing", function() {
const bobSigning2 = new global.Olm.PkSigning(); const bobSigning2 = new global.Olm.PkSigning();
const bobPrivkey2 = bobSigning2.generate_seed(); const bobPrivkey2 = bobSigning2.generate_seed();
const bobPubkey2 = bobSigning2.init_with_seed(bobPrivkey2); const bobPubkey2 = bobSigning2.init_with_seed(bobPrivkey2);
const bobSSK2 = { const bobSSK2: ICrossSigningKey = {
user_id: "@bob:example.com", user_id: "@bob:example.com",
usage: ["self_signing"], usage: ["self_signing"],
keys: { keys: {
@ -769,8 +813,8 @@ describe("Cross Signing", function() {
}, },
self_signing: bobSSK2, self_signing: bobSSK2,
}, },
firstUse: 0, firstUse: false,
unsigned: {}, crossSigningVerifiedBefore: false,
}); });
// Bob's and his device should be untrusted // Bob's and his device should be untrusted
const bobTrust = alice.checkUserTrust("@bob:example.com"); const bobTrust = alice.checkUserTrust("@bob:example.com");
@ -782,7 +826,7 @@ describe("Cross Signing", function() {
expect(bobDeviceTrust2.isTofu()).toBeFalsy(); expect(bobDeviceTrust2.isTofu()).toBeFalsy();
// Alice verifies Bob's SSK // Alice verifies Bob's SSK
alice.uploadKeySignatures = () => {}; alice.uploadKeySignatures = async () => ({ failures: {} });
await alice.setDeviceVerified("@bob:example.com", bobMasterPubkey2, true); await alice.setDeviceVerified("@bob:example.com", bobMasterPubkey2, true);
// Bob should be trusted but not his device // Bob should be trusted but not his device
@ -805,6 +849,7 @@ describe("Cross Signing", function() {
const bobDeviceTrust4 = alice.checkDeviceTrust("@bob:example.com", "Dynabook"); const bobDeviceTrust4 = alice.checkDeviceTrust("@bob:example.com", "Dynabook");
expect(bobDeviceTrust4.isCrossSigningVerified()).toBeTruthy(); expect(bobDeviceTrust4.isCrossSigningVerified()).toBeTruthy();
alice.stopClient();
}); });
it("should offer to upgrade device verifications to cross-signing", async function() { it("should offer to upgrade device verifications to cross-signing", async function() {
@ -814,20 +859,21 @@ describe("Cross Signing", function() {
{ userId: "@alice:example.com", deviceId: "Osborne2" }, { userId: "@alice:example.com", deviceId: "Osborne2" },
{ {
cryptoCallbacks: { cryptoCallbacks: {
shouldUpgradeDeviceVerifications: (verifs) => { shouldUpgradeDeviceVerifications: async (verifs) => {
expect(verifs.users["@bob:example.com"]).toBeDefined(); expect(verifs.users["@bob:example.com"]).toBeDefined();
upgradeResolveFunc(); upgradeResolveFunc();
return ["@bob:example.com"]; return ["@bob:example.com"];
}, },
}, },
}, },
); );
const { client: bob } = await makeTestClient( const { client: bob } = await makeTestClient(
{ userId: "@bob:example.com", deviceId: "Dynabook" }, { userId: "@bob:example.com", deviceId: "Dynabook" },
); );
bob.uploadDeviceSigningKeys = async () => {}; bob.uploadDeviceSigningKeys = async () => ({});
bob.uploadKeySignatures = async () => {}; bob.uploadKeySignatures = async () => ({ failures: {} });
// set Bob's cross-signing key // set Bob's cross-signing key
await resetCrossSigningKeys(bob); await resetCrossSigningKeys(bob);
alice.crypto.deviceList.storeDevicesForUser("@bob:example.com", { alice.crypto.deviceList.storeDevicesForUser("@bob:example.com", {
@ -846,8 +892,8 @@ describe("Cross Signing", function() {
bob.crypto.crossSigningInfo.toStorage(), bob.crypto.crossSigningInfo.toStorage(),
); );
alice.uploadDeviceSigningKeys = async () => {}; alice.uploadDeviceSigningKeys = async () => ({});
alice.uploadKeySignatures = async () => {}; alice.uploadKeySignatures = async () => ({ failures: {} });
// when alice sets up cross-signing, she should notice that bob's // when alice sets up cross-signing, she should notice that bob's
// cross-signing key is signed by his Dynabook, which alice has // cross-signing key is signed by his Dynabook, which alice has
// verified, and ask if the device verification should be upgraded to a // verified, and ask if the device verification should be upgraded to a
@ -873,15 +919,17 @@ describe("Cross Signing", function() {
upgradePromise = new Promise((resolve) => { upgradePromise = new Promise((resolve) => {
upgradeResolveFunc = resolve; upgradeResolveFunc = resolve;
}); });
alice.crypto.deviceList.emit("userCrossSigningUpdated", "@bob:example.com"); alice.crypto.deviceList.emit(CryptoEvent.UserCrossSigningUpdated, "@bob:example.com");
await new Promise((resolve) => { await new Promise((resolve) => {
alice.crypto.on("userTrustStatusChanged", resolve); alice.crypto.on(CryptoEvent.UserTrustStatusChanged, resolve);
}); });
await upgradePromise; await upgradePromise;
const bobTrust3 = alice.checkUserTrust("@bob:example.com"); const bobTrust3 = alice.checkUserTrust("@bob:example.com");
expect(bobTrust3.isCrossSigningVerified()).toBeTruthy(); expect(bobTrust3.isCrossSigningVerified()).toBeTruthy();
expect(bobTrust3.isTofu()).toBeTruthy(); expect(bobTrust3.isTofu()).toBeTruthy();
alice.stopClient();
bob.stopClient();
}); });
it( it(
@ -890,8 +938,8 @@ describe("Cross Signing", function() {
const { client: alice } = await makeTestClient( const { client: alice } = await makeTestClient(
{ userId: "@alice:example.com", deviceId: "Osborne2" }, { userId: "@alice:example.com", deviceId: "Osborne2" },
); );
alice.uploadDeviceSigningKeys = async () => {}; alice.uploadDeviceSigningKeys = async () => ({});
alice.uploadKeySignatures = async () => {}; alice.uploadKeySignatures = async () => ({ failures: {} });
// Generate Alice's SSK etc // Generate Alice's SSK etc
const aliceMasterSigning = new global.Olm.PkSigning(); const aliceMasterSigning = new global.Olm.PkSigning();
@ -900,7 +948,7 @@ describe("Cross Signing", function() {
const aliceSigning = new global.Olm.PkSigning(); const aliceSigning = new global.Olm.PkSigning();
const alicePrivkey = aliceSigning.generate_seed(); const alicePrivkey = aliceSigning.generate_seed();
const alicePubkey = aliceSigning.init_with_seed(alicePrivkey); const alicePubkey = aliceSigning.init_with_seed(alicePrivkey);
const aliceSSK = { const aliceSSK: ICrossSigningKey = {
user_id: "@alice:example.com", user_id: "@alice:example.com",
usage: ["self_signing"], usage: ["self_signing"],
keys: { keys: {
@ -926,34 +974,42 @@ describe("Cross Signing", function() {
}, },
self_signing: aliceSSK, self_signing: aliceSSK,
}, },
firstUse: 1, firstUse: true,
unsigned: {}, crossSigningVerifiedBefore: false,
}); });
// Alice has a second device that's cross-signed // Alice has a second device that's cross-signed
const aliceCrossSignedDevice = { const aliceDeviceId = 'Dynabook';
const aliceUnsignedDevice = {
user_id: "@alice:example.com", user_id: "@alice:example.com",
device_id: "Dynabook", device_id: aliceDeviceId,
algorithms: ["m.olm.curve25519-aes-sha256", "m.megolm.v1.aes-sha"], algorithms: ["m.olm.curve25519-aes-sha256", "m.megolm.v1.aes-sha"],
keys: { keys: {
"curve25519:Dynabook": "somePubkey", "curve25519:Dynabook": "somePubkey",
"ed25519:Dynabook": "someOtherPubkey", "ed25519:Dynabook": "someOtherPubkey",
}, },
}; };
const sig = aliceSigning.sign(anotherjson.stringify(aliceCrossSignedDevice)); const sig = aliceSigning.sign(anotherjson.stringify(aliceUnsignedDevice));
aliceCrossSignedDevice.signatures = { const aliceCrossSignedDevice: IDevice = {
"@alice:example.com": { ...aliceUnsignedDevice,
["ed25519:" + alicePubkey]: sig, verified: 0,
}, known: false,
}; signatures: {
"@alice:example.com": {
["ed25519:" + alicePubkey]: sig,
},
} };
alice.crypto.deviceList.storeDevicesForUser("@alice:example.com", { alice.crypto.deviceList.storeDevicesForUser("@alice:example.com", {
Dynabook: aliceCrossSignedDevice, [aliceDeviceId]: aliceCrossSignedDevice,
}); });
// We don't trust the cross-signing keys yet... // We don't trust the cross-signing keys yet...
expect(alice.checkDeviceTrust(aliceCrossSignedDevice.device_id).isCrossSigningVerified()).toBeFalsy(); expect(
alice.checkDeviceTrust("@alice:example.com", aliceDeviceId).isCrossSigningVerified(),
).toBeFalsy();
// ... but we do acknowledge that the device is signed by them // ... but we do acknowledge that the device is signed by them
expect(alice.checkIfOwnDeviceCrossSigned(aliceCrossSignedDevice.device_id)).toBeTruthy(); expect(alice.checkIfOwnDeviceCrossSigned(aliceDeviceId)).toBeTruthy();
alice.stopClient();
}, },
); );
@ -961,8 +1017,8 @@ describe("Cross Signing", function() {
const { client: alice } = await makeTestClient( const { client: alice } = await makeTestClient(
{ userId: "@alice:example.com", deviceId: "Osborne2" }, { userId: "@alice:example.com", deviceId: "Osborne2" },
); );
alice.uploadDeviceSigningKeys = async () => {}; alice.uploadDeviceSigningKeys = async () => ({});
alice.uploadKeySignatures = async () => {}; alice.uploadKeySignatures = async () => ({ failures: {} });
// Generate Alice's SSK etc // Generate Alice's SSK etc
const aliceMasterSigning = new global.Olm.PkSigning(); const aliceMasterSigning = new global.Olm.PkSigning();
@ -971,7 +1027,7 @@ describe("Cross Signing", function() {
const aliceSigning = new global.Olm.PkSigning(); const aliceSigning = new global.Olm.PkSigning();
const alicePrivkey = aliceSigning.generate_seed(); const alicePrivkey = aliceSigning.generate_seed();
const alicePubkey = aliceSigning.init_with_seed(alicePrivkey); const alicePubkey = aliceSigning.init_with_seed(alicePrivkey);
const aliceSSK = { const aliceSSK: ICrossSigningKey = {
user_id: "@alice:example.com", user_id: "@alice:example.com",
usage: ["self_signing"], usage: ["self_signing"],
keys: { keys: {
@ -997,14 +1053,14 @@ describe("Cross Signing", function() {
}, },
self_signing: aliceSSK, self_signing: aliceSSK,
}, },
firstUse: 1, firstUse: true,
unsigned: {}, crossSigningVerifiedBefore: false,
}); });
// Alice has a second device that's also not cross-signed const deviceId = "Dynabook";
const aliceNotCrossSignedDevice = { const aliceNotCrossSignedDevice: IDevice = {
user_id: "@alice:example.com", verified: 0,
device_id: "Dynabook", known: false,
algorithms: ["m.olm.curve25519-aes-sha256", "m.megolm.v1.aes-sha"], algorithms: ["m.olm.curve25519-aes-sha256", "m.megolm.v1.aes-sha"],
keys: { keys: {
"curve25519:Dynabook": "somePubkey", "curve25519:Dynabook": "somePubkey",
@ -1012,9 +1068,10 @@ describe("Cross Signing", function() {
}, },
}; };
alice.crypto.deviceList.storeDevicesForUser("@alice:example.com", { alice.crypto.deviceList.storeDevicesForUser("@alice:example.com", {
Dynabook: aliceNotCrossSignedDevice, [deviceId]: aliceNotCrossSignedDevice,
}); });
expect(alice.checkIfOwnDeviceCrossSigned(aliceNotCrossSignedDevice.device_id)).toBeFalsy(); expect(alice.checkIfOwnDeviceCrossSigned(deviceId)).toBeFalsy();
alice.stopClient();
}); });
}); });

View File

@ -43,13 +43,15 @@ const requests = [
describe.each([ describe.each([
["IndexedDBCryptoStore", ["IndexedDBCryptoStore",
() => new IndexedDBCryptoStore(global.indexedDB, "tests")], () => new IndexedDBCryptoStore(global.indexedDB, "tests")],
["LocalStorageCryptoStore", ["LocalStorageCryptoStore",
() => new IndexedDBCryptoStore(undefined, "tests")], () => new IndexedDBCryptoStore(undefined, "tests")],
["MemoryCryptoStore", () => { ["MemoryCryptoStore", () => {
const store = new IndexedDBCryptoStore(undefined, "tests"); const store = new IndexedDBCryptoStore(undefined, "tests");
store._backend = new MemoryCryptoStore(); // @ts-ignore set private properties
store._backendPromise = Promise.resolve(store._backend); store.backend = new MemoryCryptoStore();
// @ts-ignore
store.backendPromise = Promise.resolve(store.backend);
return store; return store;
}], }],
])("Outgoing room key requests [%s]", function(name, dbFactory) { ])("Outgoing room key requests [%s]", function(name, dbFactory) {
@ -64,22 +66,22 @@ describe.each([
}); });
it("getAllOutgoingRoomKeyRequestsByState retrieves all entries in a given state", it("getAllOutgoingRoomKeyRequestsByState retrieves all entries in a given state",
async () => { async () => {
const r = await const r = await
store.getAllOutgoingRoomKeyRequestsByState(RoomKeyRequestState.Sent); store.getAllOutgoingRoomKeyRequestsByState(RoomKeyRequestState.Sent);
expect(r).toHaveLength(2); expect(r).toHaveLength(2);
requests.filter((e) => e.state === RoomKeyRequestState.Sent).forEach((e) => { requests.filter((e) => e.state === RoomKeyRequestState.Sent).forEach((e) => {
expect(r).toContainEqual(e); expect(r).toContainEqual(e);
});
}); });
});
test("getOutgoingRoomKeyRequestByState retrieves any entry in a given state", test("getOutgoingRoomKeyRequestByState retrieves any entry in a given state",
async () => { async () => {
const r = const r =
await store.getOutgoingRoomKeyRequestByState([RoomKeyRequestState.Sent]); await store.getOutgoingRoomKeyRequestByState([RoomKeyRequestState.Sent]);
expect(r).not.toBeNull(); expect(r).not.toBeNull();
expect(r).not.toBeUndefined(); expect(r).not.toBeUndefined();
expect(r.state).toEqual(RoomKeyRequestState.Sent); expect(r.state).toEqual(RoomKeyRequestState.Sent);
expect(requests).toContainEqual(r); expect(requests).toContainEqual(r);
}); });
}); });

View File

@ -1,5 +1,5 @@
/* /*
Copyright 2019 The Matrix.org Foundation C.I.C. Copyright 2019, 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -24,15 +24,18 @@ import { encryptAES } from "../../../src/crypto/aes";
import { resetCrossSigningKeys, createSecretStorageKey } from "./crypto-utils"; import { resetCrossSigningKeys, createSecretStorageKey } from "./crypto-utils";
import { logger } from '../../../src/logger'; import { logger } from '../../../src/logger';
import * as utils from "../../../src/utils"; import * as utils from "../../../src/utils";
import { ICreateClientOpts } from '../../../src/client';
import { ISecretStorageKeyInfo } from '../../../src/crypto/api';
try { try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const crypto = require('crypto'); const crypto = require('crypto');
utils.setCrypto(crypto); utils.setCrypto(crypto);
} catch (err) { } catch (err) {
logger.log('nodejs was compiled without crypto support'); logger.log('nodejs was compiled without crypto support');
} }
async function makeTestClient(userInfo, options) { async function makeTestClient(userInfo: { userId: string, deviceId: string}, options: Partial<ICreateClientOpts> = {}) {
const client = (new TestClient( const client = (new TestClient(
userInfo.userId, userInfo.deviceId, undefined, undefined, options, userInfo.userId, userInfo.deviceId, undefined, undefined, options,
)).client; )).client;
@ -46,7 +49,7 @@ async function makeTestClient(userInfo, options) {
await client.initCrypto(); await client.initCrypto();
// No need to download keys for these tests // No need to download keys for these tests
client.crypto.downloadKeys = async function() {}; jest.spyOn(client.crypto, 'downloadKeys').mockResolvedValue({});
return client; return client;
} }
@ -54,7 +57,7 @@ async function makeTestClient(userInfo, options) {
// Wrapper around pkSign to return a signed object. pkSign returns the // Wrapper around pkSign to return a signed object. pkSign returns the
// signature, rather than the signed object. // signature, rather than the signed object.
function sign(obj, key, userId) { function sign(obj, key, userId) {
olmlib.pkSign(obj, key, userId); olmlib.pkSign(obj, key, userId, '');
return obj; return obj;
} }
@ -84,7 +87,7 @@ describe("Secrets", function() {
}, },
}; };
const getKey = jest.fn(e => { const getKey = jest.fn().mockImplementation(async e => {
expect(Object.keys(e.keys)).toEqual(["abc"]); expect(Object.keys(e.keys)).toEqual(["abc"]);
return ['abc', key]; return ['abc', key];
}); });
@ -93,7 +96,7 @@ describe("Secrets", function() {
{ userId: "@alice:example.com", deviceId: "Osborne2" }, { userId: "@alice:example.com", deviceId: "Osborne2" },
{ {
cryptoCallbacks: { cryptoCallbacks: {
getCrossSigningKey: t => signingKey, getCrossSigningKey: async t => signingKey,
getSecretStorageKey: getKey, getSecretStorageKey: getKey,
}, },
}, },
@ -104,17 +107,19 @@ describe("Secrets", function() {
const secretStorage = alice.crypto.secretStorage; const secretStorage = alice.crypto.secretStorage;
alice.setAccountData = async function(eventType, contents, callback) { jest.spyOn(alice, 'setAccountData').mockImplementation(
alice.store.storeAccountDataEvents([ async function(eventType, contents, callback) {
new MatrixEvent({ alice.store.storeAccountDataEvents([
type: eventType, new MatrixEvent({
content: contents, type: eventType,
}), content: contents,
]); }),
if (callback) { ]);
callback(); if (callback) {
} callback(undefined, undefined);
}; }
return {};
});
const keyAccountData = { const keyAccountData = {
algorithm: SECRET_STORAGE_ALGORITHM_V1_AES, algorithm: SECRET_STORAGE_ALGORITHM_V1_AES,
@ -136,6 +141,7 @@ describe("Secrets", function() {
expect(await secretStorage.get("foo")).toBe("bar"); expect(await secretStorage.get("foo")).toBe("bar");
expect(getKey).toHaveBeenCalled(); expect(getKey).toHaveBeenCalled();
alice.stopClient();
}); });
it("should throw if given a key that doesn't exist", async function() { it("should throw if given a key that doesn't exist", async function() {
@ -150,6 +156,7 @@ describe("Secrets", function() {
expect(true).toBeFalsy(); expect(true).toBeFalsy();
} catch (e) { } catch (e) {
} }
alice.stopClient();
}); });
it("should refuse to encrypt with zero keys", async function() { it("should refuse to encrypt with zero keys", async function() {
@ -162,12 +169,13 @@ describe("Secrets", function() {
expect(true).toBeFalsy(); expect(true).toBeFalsy();
} catch (e) { } catch (e) {
} }
alice.stopClient();
}); });
it("should encrypt with default key if keys is null", async function() { it("should encrypt with default key if keys is null", async function() {
const key = new Uint8Array(16); const key = new Uint8Array(16);
for (let i = 0; i < 16; i++) key[i] = i; for (let i = 0; i < 16; i++) key[i] = i;
const getKey = jest.fn(e => { const getKey = jest.fn().mockImplementation(async e => {
expect(Object.keys(e.keys)).toEqual([newKeyId]); expect(Object.keys(e.keys)).toEqual([newKeyId]);
return [newKeyId, key]; return [newKeyId, key];
}); });
@ -190,11 +198,12 @@ describe("Secrets", function() {
content: contents, content: contents,
}), }),
]); ]);
return {};
}; };
resetCrossSigningKeys(alice); resetCrossSigningKeys(alice);
const { keyId: newKeyId } = await alice.addSecretStorageKey( const { keyId: newKeyId } = await alice.addSecretStorageKey(
SECRET_STORAGE_ALGORITHM_V1_AES, SECRET_STORAGE_ALGORITHM_V1_AES, { pubkey: undefined, key: undefined },
); );
// we don't await on this because it waits for the event to come down the sync // we don't await on this because it waits for the event to come down the sync
// which won't happen in the test setup // which won't happen in the test setup
@ -203,6 +212,7 @@ describe("Secrets", function() {
const accountData = alice.getAccountData('foo'); const accountData = alice.getAccountData('foo');
expect(accountData.getContent().encrypted).toBeTruthy(); expect(accountData.getContent().encrypted).toBeTruthy();
alice.stopClient();
}); });
it("should refuse to encrypt if no keys given and no default key", async function() { it("should refuse to encrypt if no keys given and no default key", async function() {
@ -215,10 +225,11 @@ describe("Secrets", function() {
expect(true).toBeFalsy(); expect(true).toBeFalsy();
} catch (e) { } catch (e) {
} }
alice.stopClient();
}); });
it("should request secrets from other clients", async function() { it("should request secrets from other clients", async function() {
const [osborne2, vax] = await makeTestClients( const [[osborne2, vax], clearTestClientTimeouts] = await makeTestClients(
[ [
{ userId: "@alice:example.com", deviceId: "Osborne2" }, { userId: "@alice:example.com", deviceId: "Osborne2" },
{ userId: "@alice:example.com", deviceId: "VAX" }, { userId: "@alice:example.com", deviceId: "VAX" },
@ -273,6 +284,9 @@ describe("Secrets", function() {
const secret = await request.promise; const secret = await request.promise;
expect(secret).toBe("bar"); expect(secret).toBe("bar");
osborne2.stop();
vax.stop();
clearTestClientTimeouts();
}); });
describe("bootstrap", function() { describe("bootstrap", function() {
@ -298,7 +312,7 @@ describe("Secrets", function() {
it("bootstraps when no storage or cross-signing keys locally", async function() { it("bootstraps when no storage or cross-signing keys locally", async function() {
const key = new Uint8Array(16); const key = new Uint8Array(16);
for (let i = 0; i < 16; i++) key[i] = i; for (let i = 0; i < 16; i++) key[i] = i;
const getKey = jest.fn(e => { const getKey = jest.fn().mockImplementation(async e => {
return [Object.keys(e.keys)[0], key]; return [Object.keys(e.keys)[0], key];
}); });
@ -313,8 +327,8 @@ describe("Secrets", function() {
}, },
}, },
); );
bob.uploadDeviceSigningKeys = async () => {}; bob.uploadDeviceSigningKeys = async () => ({});
bob.uploadKeySignatures = async () => {}; bob.uploadKeySignatures = jest.fn().mockResolvedValue(undefined);
bob.setAccountData = async function(eventType, contents, callback) { bob.setAccountData = async function(eventType, contents, callback) {
const event = new MatrixEvent({ const event = new MatrixEvent({
type: eventType, type: eventType,
@ -324,10 +338,11 @@ describe("Secrets", function() {
event, event,
]); ]);
this.emit("accountData", event); this.emit("accountData", event);
return {};
}; };
await bob.bootstrapCrossSigning({ await bob.bootstrapCrossSigning({
authUploadDeviceSigningKeys: async func => await func({}), authUploadDeviceSigningKeys: async func => { await func({}); },
}); });
await bob.bootstrapSecretStorage({ await bob.bootstrapSecretStorage({
createSecretStorageKey, createSecretStorageKey,
@ -340,6 +355,7 @@ describe("Secrets", function() {
expect(await crossSigning.isStoredInSecretStorage(secretStorage)) expect(await crossSigning.isStoredInSecretStorage(secretStorage))
.toBeTruthy(); .toBeTruthy();
expect(await secretStorage.hasKey()).toBeTruthy(); expect(await secretStorage.hasKey()).toBeTruthy();
bob.stopClient();
}); });
it("bootstraps when cross-signing keys in secret storage", async function() { it("bootstraps when cross-signing keys in secret storage", async function() {
@ -406,10 +422,11 @@ describe("Secrets", function() {
expect(await crossSigning.isStoredInSecretStorage(secretStorage)) expect(await crossSigning.isStoredInSecretStorage(secretStorage))
.toBeTruthy(); .toBeTruthy();
expect(await secretStorage.hasKey()).toBeTruthy(); expect(await secretStorage.hasKey()).toBeTruthy();
bob.stopClient();
}); });
it("adds passphrase checking if it's lacking", async function() { it("adds passphrase checking if it's lacking", async function() {
let crossSigningKeys = { let crossSigningKeys: Record<string, Uint8Array> = {
master: XSK, master: XSK,
user_signing: USK, user_signing: USK,
self_signing: SSK, self_signing: SSK,
@ -421,9 +438,9 @@ describe("Secrets", function() {
{ userId: "@alice:example.com", deviceId: "Osborne2" }, { userId: "@alice:example.com", deviceId: "Osborne2" },
{ {
cryptoCallbacks: { cryptoCallbacks: {
getCrossSigningKey: t => crossSigningKeys[t], getCrossSigningKey: async t => crossSigningKeys[t],
saveCrossSigningKeys: k => crossSigningKeys = k, saveCrossSigningKeys: k => crossSigningKeys = k,
getSecretStorageKey: ({ keys }, name) => { getSecretStorageKey: async ({ keys }, name) => {
for (const keyId of Object.keys(keys)) { for (const keyId of Object.keys(keys)) {
if (secretStorageKeys[keyId]) { if (secretStorageKeys[keyId]) {
return [keyId, secretStorageKeys[keyId]]; return [keyId, secretStorageKeys[keyId]];
@ -479,6 +496,8 @@ describe("Secrets", function() {
}), }),
]); ]);
alice.crypto.deviceList.storeCrossSigningForUser("@alice:example.com", { alice.crypto.deviceList.storeCrossSigningForUser("@alice:example.com", {
firstUse: false,
crossSigningVerifiedBefore: false,
keys: { keys: {
master: { master: {
user_id: "@alice:example.com", user_id: "@alice:example.com",
@ -519,14 +538,15 @@ describe("Secrets", function() {
}); });
alice.store.storeAccountDataEvents([event]); alice.store.storeAccountDataEvents([event]);
this.emit("accountData", event); this.emit("accountData", event);
return {};
}; };
await alice.bootstrapSecretStorage(); await alice.bootstrapSecretStorage({});
expect(alice.getAccountData("m.secret_storage.default_key").getContent()) expect(alice.getAccountData("m.secret_storage.default_key").getContent())
.toEqual({ key: "key_id" }); .toEqual({ key: "key_id" });
const keyInfo = alice.getAccountData("m.secret_storage.key.key_id") const keyInfo = alice.getAccountData("m.secret_storage.key.key_id")
.getContent(); .getContent() as ISecretStorageKeyInfo;
expect(keyInfo.algorithm) expect(keyInfo.algorithm)
.toEqual("m.secret_storage.v1.aes-hmac-sha2"); .toEqual("m.secret_storage.v1.aes-hmac-sha2");
expect(keyInfo.passphrase).toEqual({ expect(keyInfo.passphrase).toEqual({
@ -538,9 +558,10 @@ describe("Secrets", function() {
expect(keyInfo).toHaveProperty("mac"); expect(keyInfo).toHaveProperty("mac");
expect(alice.checkSecretStorageKey(secretStorageKeys.key_id, keyInfo)) expect(alice.checkSecretStorageKey(secretStorageKeys.key_id, keyInfo))
.toBeTruthy(); .toBeTruthy();
alice.stopClient();
}); });
it("fixes backup keys in the wrong format", async function() { it("fixes backup keys in the wrong format", async function() {
let crossSigningKeys = { let crossSigningKeys: Record<string, Uint8Array> = {
master: XSK, master: XSK,
user_signing: USK, user_signing: USK,
self_signing: SSK, self_signing: SSK,
@ -552,9 +573,9 @@ describe("Secrets", function() {
{ userId: "@alice:example.com", deviceId: "Osborne2" }, { userId: "@alice:example.com", deviceId: "Osborne2" },
{ {
cryptoCallbacks: { cryptoCallbacks: {
getCrossSigningKey: t => crossSigningKeys[t], getCrossSigningKey: async t => crossSigningKeys[t],
saveCrossSigningKeys: k => crossSigningKeys = k, saveCrossSigningKeys: k => crossSigningKeys = k,
getSecretStorageKey: ({ keys }, name) => { getSecretStorageKey: async ({ keys }, name) => {
for (const keyId of Object.keys(keys)) { for (const keyId of Object.keys(keys)) {
if (secretStorageKeys[keyId]) { if (secretStorageKeys[keyId]) {
return [keyId, secretStorageKeys[keyId]]; return [keyId, secretStorageKeys[keyId]];
@ -619,6 +640,8 @@ describe("Secrets", function() {
}), }),
]); ]);
alice.crypto.deviceList.storeCrossSigningForUser("@alice:example.com", { alice.crypto.deviceList.storeCrossSigningForUser("@alice:example.com", {
firstUse: false,
crossSigningVerifiedBefore: false,
keys: { keys: {
master: { master: {
user_id: "@alice:example.com", user_id: "@alice:example.com",
@ -659,15 +682,17 @@ describe("Secrets", function() {
}); });
alice.store.storeAccountDataEvents([event]); alice.store.storeAccountDataEvents([event]);
this.emit("accountData", event); this.emit("accountData", event);
return {};
}; };
await alice.bootstrapSecretStorage(); await alice.bootstrapSecretStorage({});
const backupKey = alice.getAccountData("m.megolm_backup.v1") const backupKey = alice.getAccountData("m.megolm_backup.v1")
.getContent(); .getContent();
expect(backupKey.encrypted).toHaveProperty("key_id"); expect(backupKey.encrypted).toHaveProperty("key_id");
expect(await alice.getSecret("m.megolm_backup.v1")) expect(await alice.getSecret("m.megolm_backup.v1"))
.toEqual("ey0GB1kB6jhOWgwiBUMIWg=="); .toEqual("ey0GB1kB6jhOWgwiBUMIWg==");
alice.stopClient();
}); });
}); });
}); });

View File

@ -40,7 +40,7 @@ describe("verification request integration tests with crypto layer", function()
}); });
it("should request and accept a verification", async function() { it("should request and accept a verification", async function() {
const [alice, bob] = await makeTestClients( const [[alice, bob], clearTestClientTimeouts] = await makeTestClients(
[ [
{ userId: "@alice:example.com", deviceId: "Osborne2" }, { userId: "@alice:example.com", deviceId: "Osborne2" },
{ userId: "@bob:example.com", deviceId: "Dynabook" }, { userId: "@bob:example.com", deviceId: "Dynabook" },
@ -78,5 +78,9 @@ describe("verification request integration tests with crypto layer", function()
// XXX: Private function access (but it's a test, so we're okay) // XXX: Private function access (but it's a test, so we're okay)
aliceVerifier.endTimer(); aliceVerifier.endTimer();
alice.stop();
bob.stop();
clearTestClientTimeouts();
}); });
}); });

View File

@ -75,9 +75,10 @@ describe("SAS verification", function() {
let bobSasEvent; let bobSasEvent;
let aliceVerifier; let aliceVerifier;
let bobPromise; let bobPromise;
let clearTestClientTimeouts;
beforeEach(async () => { beforeEach(async () => {
[alice, bob] = await makeTestClients( [[alice, bob], clearTestClientTimeouts] = await makeTestClients(
[ [
{ userId: "@alice:example.com", deviceId: "Osborne2" }, { userId: "@alice:example.com", deviceId: "Osborne2" },
{ userId: "@bob:example.com", deviceId: "Dynabook" }, { userId: "@bob:example.com", deviceId: "Dynabook" },
@ -178,6 +179,8 @@ describe("SAS verification", function() {
alice.stop(), alice.stop(),
bob.stop(), bob.stop(),
]); ]);
clearTestClientTimeouts();
}); });
it("should verify a key", async () => { it("should verify a key", async () => {
@ -334,7 +337,7 @@ describe("SAS verification", function() {
}); });
it("should send a cancellation message on error", async function() { it("should send a cancellation message on error", async function() {
const [alice, bob] = await makeTestClients( const [[alice, bob], clearTestClientTimeouts] = await makeTestClients(
[ [
{ userId: "@alice:example.com", deviceId: "Osborne2" }, { userId: "@alice:example.com", deviceId: "Osborne2" },
{ userId: "@bob:example.com", deviceId: "Dynabook" }, { userId: "@bob:example.com", deviceId: "Dynabook" },
@ -377,6 +380,10 @@ describe("SAS verification", function() {
.not.toHaveBeenCalled(); .not.toHaveBeenCalled();
expect(bob.client.setDeviceVerified) expect(bob.client.setDeviceVerified)
.not.toHaveBeenCalled(); .not.toHaveBeenCalled();
alice.stop();
bob.stop();
clearTestClientTimeouts();
}); });
describe("verification in DM", function() { describe("verification in DM", function() {
@ -386,9 +393,10 @@ describe("SAS verification", function() {
let bobSasEvent; let bobSasEvent;
let aliceVerifier; let aliceVerifier;
let bobPromise; let bobPromise;
let clearTestClientTimeouts;
beforeEach(async function() { beforeEach(async function() {
[alice, bob] = await makeTestClients( [[alice, bob], clearTestClientTimeouts] = await makeTestClients(
[ [
{ userId: "@alice:example.com", deviceId: "Osborne2" }, { userId: "@alice:example.com", deviceId: "Osborne2" },
{ userId: "@bob:example.com", deviceId: "Dynabook" }, { userId: "@bob:example.com", deviceId: "Dynabook" },
@ -488,6 +496,8 @@ describe("SAS verification", function() {
alice.stop(), alice.stop(),
bob.stop(), bob.stop(),
]); ]);
clearTestClientTimeouts();
}); });
it("should verify a key", async function() { it("should verify a key", async function() {

View File

@ -23,6 +23,7 @@ import { logger } from '../../../../src/logger';
export async function makeTestClients(userInfos, options) { export async function makeTestClients(userInfos, options) {
const clients = []; const clients = [];
const timeouts = [];
const clientMap = {}; const clientMap = {};
const sendToDevice = function(type, map) { const sendToDevice = function(type, map) {
// logger.log(this.getUserId(), "sends", type, map); // logger.log(this.getUserId(), "sends", type, map);
@ -66,7 +67,7 @@ export async function makeTestClients(userInfos, options) {
}, },
})); }));
setImmediate(() => { const timeout = setTimeout(() => {
for (const tc of clients) { for (const tc of clients) {
if (tc.client === this) { // eslint-disable-line @babel/no-invalid-this if (tc.client === this) { // eslint-disable-line @babel/no-invalid-this
logger.log("sending remote echo!!"); logger.log("sending remote echo!!");
@ -77,6 +78,8 @@ export async function makeTestClients(userInfos, options) {
} }
}); });
timeouts.push(timeout);
return Promise.resolve({ event_id: eventId }); return Promise.resolve({ event_id: eventId });
}; };
@ -103,7 +106,11 @@ export async function makeTestClients(userInfos, options) {
await Promise.all(clients.map((testClient) => testClient.client.initCrypto())); await Promise.all(clients.map((testClient) => testClient.client.initCrypto()));
return clients; const destroy = () => {
timeouts.forEach((t) => clearTimeout(t));
};
return [clients, destroy];
} }
export function setupWebcrypto() { export function setupWebcrypto() {

View File

@ -44,6 +44,10 @@ describe("eventMapperFor", function() {
rooms = []; rooms = [];
}); });
afterEach(() => {
client.stopClient();
});
it("should de-duplicate MatrixEvent instances by means of findEventById on the room object", async () => { it("should de-duplicate MatrixEvent instances by means of findEventById on the room object", async () => {
const roomId = "!room:example.org"; const roomId = "!room:example.org";
const room = new Room(roomId, client, userId); const room = new Room(roomId, client, userId);

View File

@ -0,0 +1,294 @@
/*
Copyright 2022 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 * as utils from "../test-utils/test-utils";
import {
EventTimeline,
EventTimelineSet,
EventType,
MatrixClient,
MatrixEvent,
MatrixEventEvent,
Room,
DuplicateStrategy,
} from '../../src';
import { Thread } from "../../src/models/thread";
import { ReEmitter } from "../../src/ReEmitter";
describe('EventTimelineSet', () => {
const roomId = '!foo:bar';
const userA = "@alice:bar";
let room: Room;
let eventTimeline: EventTimeline;
let eventTimelineSet: EventTimelineSet;
let client: MatrixClient;
let messageEvent: MatrixEvent;
let replyEvent: MatrixEvent;
const itShouldReturnTheRelatedEvents = () => {
it('should return the related events', () => {
eventTimelineSet.relations.aggregateChildEvent(messageEvent);
const relations = eventTimelineSet.relations.getChildEventsForEvent(
messageEvent.getId(),
"m.in_reply_to",
EventType.RoomMessage,
);
expect(relations).toBeDefined();
expect(relations.getRelations().length).toBe(1);
expect(relations.getRelations()[0].getId()).toBe(replyEvent.getId());
});
};
beforeEach(() => {
client = utils.mock(MatrixClient, 'MatrixClient');
client.reEmitter = utils.mock(ReEmitter, 'ReEmitter');
room = new Room(roomId, client, userA);
eventTimelineSet = new EventTimelineSet(room);
eventTimeline = new EventTimeline(eventTimelineSet);
messageEvent = utils.mkMessage({
room: roomId,
user: userA,
msg: 'Hi!',
event: true,
});
replyEvent = utils.mkReplyMessage({
room: roomId,
user: userA,
msg: 'Hoo!',
event: true,
replyToMessage: messageEvent,
});
});
describe('addLiveEvent', () => {
it("Adds event to the live timeline in the timeline set", () => {
const liveTimeline = eventTimelineSet.getLiveTimeline();
expect(liveTimeline.getEvents().length).toStrictEqual(0);
eventTimelineSet.addLiveEvent(messageEvent);
expect(liveTimeline.getEvents().length).toStrictEqual(1);
});
it("should replace a timeline event if dupe strategy is 'replace'", () => {
const liveTimeline = eventTimelineSet.getLiveTimeline();
expect(liveTimeline.getEvents().length).toStrictEqual(0);
eventTimelineSet.addLiveEvent(messageEvent, {
duplicateStrategy: DuplicateStrategy.Replace,
});
expect(liveTimeline.getEvents().length).toStrictEqual(1);
// make a duplicate
const duplicateMessageEvent = utils.mkMessage({
room: roomId, user: userA, msg: "dupe", event: true,
});
duplicateMessageEvent.event.event_id = messageEvent.getId();
// Adding the duplicate event should replace the `messageEvent`
// because it has the same `event_id` and duplicate strategy is
// replace.
eventTimelineSet.addLiveEvent(duplicateMessageEvent, {
duplicateStrategy: DuplicateStrategy.Replace,
});
const eventsInLiveTimeline = liveTimeline.getEvents();
expect(eventsInLiveTimeline.length).toStrictEqual(1);
expect(eventsInLiveTimeline[0]).toStrictEqual(duplicateMessageEvent);
});
it("Make sure legacy overload passing options directly as parameters still works", () => {
expect(() => eventTimelineSet.addLiveEvent(messageEvent, DuplicateStrategy.Replace, false)).not.toThrow();
expect(() => eventTimelineSet.addLiveEvent(messageEvent, DuplicateStrategy.Ignore, true)).not.toThrow();
});
});
describe('addEventToTimeline', () => {
it("Adds event to timeline", () => {
const liveTimeline = eventTimelineSet.getLiveTimeline();
expect(liveTimeline.getEvents().length).toStrictEqual(0);
eventTimelineSet.addEventToTimeline(messageEvent, liveTimeline, {
toStartOfTimeline: true,
});
expect(liveTimeline.getEvents().length).toStrictEqual(1);
});
it("Make sure legacy overload passing options directly as parameters still works", () => {
const liveTimeline = eventTimelineSet.getLiveTimeline();
expect(() => {
eventTimelineSet.addEventToTimeline(
messageEvent,
liveTimeline,
true,
);
}).not.toThrow();
expect(() => {
eventTimelineSet.addEventToTimeline(
messageEvent,
liveTimeline,
true,
false,
);
}).not.toThrow();
});
});
describe('aggregateRelations', () => {
describe('with unencrypted events', () => {
beforeEach(() => {
eventTimelineSet.addEventsToTimeline(
[
messageEvent,
replyEvent,
],
true,
eventTimeline,
'foo',
);
});
itShouldReturnTheRelatedEvents();
});
describe('with events to be decrypted', () => {
let messageEventShouldAttemptDecryptionSpy: jest.SpyInstance;
let messageEventIsDecryptionFailureSpy: jest.SpyInstance;
let replyEventShouldAttemptDecryptionSpy: jest.SpyInstance;
let replyEventIsDecryptionFailureSpy: jest.SpyInstance;
beforeEach(() => {
messageEventShouldAttemptDecryptionSpy = jest.spyOn(messageEvent, 'shouldAttemptDecryption');
messageEventShouldAttemptDecryptionSpy.mockReturnValue(true);
messageEventIsDecryptionFailureSpy = jest.spyOn(messageEvent, 'isDecryptionFailure');
replyEventShouldAttemptDecryptionSpy = jest.spyOn(replyEvent, 'shouldAttemptDecryption');
replyEventShouldAttemptDecryptionSpy.mockReturnValue(true);
replyEventIsDecryptionFailureSpy = jest.spyOn(messageEvent, 'isDecryptionFailure');
eventTimelineSet.addEventsToTimeline(
[
messageEvent,
replyEvent,
],
true,
eventTimeline,
'foo',
);
});
it('should not return the related events', () => {
eventTimelineSet.relations.aggregateChildEvent(messageEvent);
const relations = eventTimelineSet.relations.getChildEventsForEvent(
messageEvent.getId(),
"m.in_reply_to",
EventType.RoomMessage,
);
expect(relations).toBeUndefined();
});
describe('after decryption', () => {
beforeEach(() => {
// simulate decryption failure once
messageEventIsDecryptionFailureSpy.mockReturnValue(true);
replyEventIsDecryptionFailureSpy.mockReturnValue(true);
messageEvent.emit(MatrixEventEvent.Decrypted, messageEvent);
replyEvent.emit(MatrixEventEvent.Decrypted, replyEvent);
// simulate decryption
messageEventIsDecryptionFailureSpy.mockReturnValue(false);
replyEventIsDecryptionFailureSpy.mockReturnValue(false);
messageEventShouldAttemptDecryptionSpy.mockReturnValue(false);
replyEventShouldAttemptDecryptionSpy.mockReturnValue(false);
messageEvent.emit(MatrixEventEvent.Decrypted, messageEvent);
replyEvent.emit(MatrixEventEvent.Decrypted, replyEvent);
});
itShouldReturnTheRelatedEvents();
});
});
});
describe("canContain", () => {
const mkThreadResponse = (root: MatrixEvent) => utils.mkEvent({
event: true,
type: EventType.RoomMessage,
user: userA,
room: roomId,
content: {
"body": "Thread response :: " + Math.random(),
"m.relates_to": {
"event_id": root.getId(),
"m.in_reply_to": {
"event_id": root.getId(),
},
"rel_type": "m.thread",
},
},
}, room.client);
let thread: Thread;
beforeEach(() => {
(client.supportsExperimentalThreads as jest.Mock).mockReturnValue(true);
thread = new Thread("!thread_id:server", messageEvent, { room, client });
});
it("should throw if timeline set has no room", () => {
const eventTimelineSet = new EventTimelineSet(undefined, {}, client);
expect(() => eventTimelineSet.canContain(messageEvent)).toThrowError();
});
it("should return false if timeline set is for thread but event is not threaded", () => {
const eventTimelineSet = new EventTimelineSet(room, {}, client, thread);
expect(eventTimelineSet.canContain(replyEvent)).toBeFalsy();
});
it("should return false if timeline set it for thread but event it for a different thread", () => {
const eventTimelineSet = new EventTimelineSet(room, {}, client, thread);
const event = mkThreadResponse(replyEvent);
expect(eventTimelineSet.canContain(event)).toBeFalsy();
});
it("should return false if timeline set is not for a thread but event is a thread response", () => {
const eventTimelineSet = new EventTimelineSet(room, {}, client);
const event = mkThreadResponse(replyEvent);
expect(eventTimelineSet.canContain(event)).toBeFalsy();
});
it("should return true if the timeline set is not for a thread and the event is a thread root", () => {
const eventTimelineSet = new EventTimelineSet(room, {}, client);
expect(eventTimelineSet.canContain(messageEvent)).toBeTruthy();
});
it("should return true if the timeline set is for a thread and the event is its thread root", () => {
const thread = new Thread(messageEvent.getId(), messageEvent, { room, client });
const eventTimelineSet = new EventTimelineSet(room, {}, client, thread);
messageEvent.setThread(thread);
expect(eventTimelineSet.canContain(messageEvent)).toBeTruthy();
});
it("should return true if the timeline set is for a thread and the event is a response to it", () => {
const thread = new Thread(messageEvent.getId(), messageEvent, { room, client });
const eventTimelineSet = new EventTimelineSet(room, {}, client, thread);
messageEvent.setThread(thread);
const event = mkThreadResponse(messageEvent);
expect(eventTimelineSet.canContain(event)).toBeTruthy();
});
});
});

View File

@ -50,9 +50,11 @@ describe("EventTimeline", function() {
timeline.initialiseState(events); timeline.initialiseState(events);
expect(timeline.startState.setStateEvents).toHaveBeenCalledWith( expect(timeline.startState.setStateEvents).toHaveBeenCalledWith(
events, events,
{ timelineWasEmpty: undefined },
); );
expect(timeline.endState.setStateEvents).toHaveBeenCalledWith( expect(timeline.endState.setStateEvents).toHaveBeenCalledWith(
events, events,
{ timelineWasEmpty: undefined },
); );
}); });
@ -73,7 +75,7 @@ describe("EventTimeline", function() {
expect(function() { expect(function() {
timeline.initialiseState(state); timeline.initialiseState(state);
}).not.toThrow(); }).not.toThrow();
timeline.addEvent(event, false); timeline.addEvent(event, { toStartOfTimeline: false });
expect(function() { expect(function() {
timeline.initialiseState(state); timeline.initialiseState(state);
}).toThrow(); }).toThrow();
@ -149,9 +151,9 @@ describe("EventTimeline", function() {
]; ];
it("should be able to add events to the end", function() { it("should be able to add events to the end", function() {
timeline.addEvent(events[0], false); timeline.addEvent(events[0], { toStartOfTimeline: false });
const initialIndex = timeline.getBaseIndex(); const initialIndex = timeline.getBaseIndex();
timeline.addEvent(events[1], false); timeline.addEvent(events[1], { toStartOfTimeline: false });
expect(timeline.getBaseIndex()).toEqual(initialIndex); expect(timeline.getBaseIndex()).toEqual(initialIndex);
expect(timeline.getEvents().length).toEqual(2); expect(timeline.getEvents().length).toEqual(2);
expect(timeline.getEvents()[0]).toEqual(events[0]); expect(timeline.getEvents()[0]).toEqual(events[0]);
@ -159,9 +161,9 @@ describe("EventTimeline", function() {
}); });
it("should be able to add events to the start", function() { it("should be able to add events to the start", function() {
timeline.addEvent(events[0], true); timeline.addEvent(events[0], { toStartOfTimeline: true });
const initialIndex = timeline.getBaseIndex(); const initialIndex = timeline.getBaseIndex();
timeline.addEvent(events[1], true); timeline.addEvent(events[1], { toStartOfTimeline: true });
expect(timeline.getBaseIndex()).toEqual(initialIndex + 1); expect(timeline.getBaseIndex()).toEqual(initialIndex + 1);
expect(timeline.getEvents().length).toEqual(2); expect(timeline.getEvents().length).toEqual(2);
expect(timeline.getEvents()[0]).toEqual(events[1]); expect(timeline.getEvents()[0]).toEqual(events[1]);
@ -203,9 +205,9 @@ describe("EventTimeline", function() {
content: { name: "Old Room Name" }, content: { name: "Old Room Name" },
}); });
timeline.addEvent(newEv, false); timeline.addEvent(newEv, { toStartOfTimeline: false });
expect(newEv.sender).toEqual(sentinel); expect(newEv.sender).toEqual(sentinel);
timeline.addEvent(oldEv, true); timeline.addEvent(oldEv, { toStartOfTimeline: true });
expect(oldEv.sender).toEqual(oldSentinel); expect(oldEv.sender).toEqual(oldSentinel);
}); });
@ -242,9 +244,9 @@ describe("EventTimeline", function() {
const oldEv = utils.mkMembership({ const oldEv = utils.mkMembership({
room: roomId, mship: "ban", user: userB, skey: userA, event: true, room: roomId, mship: "ban", user: userB, skey: userA, event: true,
}); });
timeline.addEvent(newEv, false); timeline.addEvent(newEv, { toStartOfTimeline: false });
expect(newEv.target).toEqual(sentinel); expect(newEv.target).toEqual(sentinel);
timeline.addEvent(oldEv, true); timeline.addEvent(oldEv, { toStartOfTimeline: true });
expect(oldEv.target).toEqual(oldSentinel); expect(oldEv.target).toEqual(oldSentinel);
}); });
@ -262,13 +264,13 @@ describe("EventTimeline", function() {
}), }),
]; ];
timeline.addEvent(events[0], false); timeline.addEvent(events[0], { toStartOfTimeline: false });
timeline.addEvent(events[1], false); timeline.addEvent(events[1], { toStartOfTimeline: false });
expect(timeline.getState(EventTimeline.FORWARDS).setStateEvents). expect(timeline.getState(EventTimeline.FORWARDS).setStateEvents).
toHaveBeenCalledWith([events[0]]); toHaveBeenCalledWith([events[0]], { timelineWasEmpty: undefined });
expect(timeline.getState(EventTimeline.FORWARDS).setStateEvents). expect(timeline.getState(EventTimeline.FORWARDS).setStateEvents).
toHaveBeenCalledWith([events[1]]); toHaveBeenCalledWith([events[1]], { timelineWasEmpty: undefined });
expect(events[0].forwardLooking).toBe(true); expect(events[0].forwardLooking).toBe(true);
expect(events[1].forwardLooking).toBe(true); expect(events[1].forwardLooking).toBe(true);
@ -291,13 +293,13 @@ describe("EventTimeline", function() {
}), }),
]; ];
timeline.addEvent(events[0], true); timeline.addEvent(events[0], { toStartOfTimeline: true });
timeline.addEvent(events[1], true); timeline.addEvent(events[1], { toStartOfTimeline: true });
expect(timeline.getState(EventTimeline.BACKWARDS).setStateEvents). expect(timeline.getState(EventTimeline.BACKWARDS).setStateEvents).
toHaveBeenCalledWith([events[0]]); toHaveBeenCalledWith([events[0]], { timelineWasEmpty: undefined });
expect(timeline.getState(EventTimeline.BACKWARDS).setStateEvents). expect(timeline.getState(EventTimeline.BACKWARDS).setStateEvents).
toHaveBeenCalledWith([events[1]]); toHaveBeenCalledWith([events[1]], { timelineWasEmpty: undefined });
expect(events[0].forwardLooking).toBe(false); expect(events[0].forwardLooking).toBe(false);
expect(events[1].forwardLooking).toBe(false); expect(events[1].forwardLooking).toBe(false);
@ -305,6 +307,11 @@ describe("EventTimeline", function() {
expect(timeline.getState(EventTimeline.FORWARDS).setStateEvents). expect(timeline.getState(EventTimeline.FORWARDS).setStateEvents).
not.toHaveBeenCalled(); not.toHaveBeenCalled();
}); });
it("Make sure legacy overload passing options directly as parameters still works", () => {
expect(() => timeline.addEvent(events[0], { toStartOfTimeline: true })).not.toThrow();
expect(() => timeline.addEvent(events[0], { stateContext: new RoomState() })).not.toThrow();
});
}); });
describe("removeEvent", function() { describe("removeEvent", function() {
@ -324,8 +331,8 @@ describe("EventTimeline", function() {
]; ];
it("should remove events", function() { it("should remove events", function() {
timeline.addEvent(events[0], false); timeline.addEvent(events[0], { toStartOfTimeline: false });
timeline.addEvent(events[1], false); timeline.addEvent(events[1], { toStartOfTimeline: false });
expect(timeline.getEvents().length).toEqual(2); expect(timeline.getEvents().length).toEqual(2);
let ev = timeline.removeEvent(events[0].getId()); let ev = timeline.removeEvent(events[0].getId());
@ -338,9 +345,9 @@ describe("EventTimeline", function() {
}); });
it("should update baseIndex", function() { it("should update baseIndex", function() {
timeline.addEvent(events[0], false); timeline.addEvent(events[0], { toStartOfTimeline: false });
timeline.addEvent(events[1], true); timeline.addEvent(events[1], { toStartOfTimeline: true });
timeline.addEvent(events[2], false); timeline.addEvent(events[2], { toStartOfTimeline: false });
expect(timeline.getEvents().length).toEqual(3); expect(timeline.getEvents().length).toEqual(3);
expect(timeline.getBaseIndex()).toEqual(1); expect(timeline.getBaseIndex()).toEqual(1);
@ -358,11 +365,11 @@ describe("EventTimeline", function() {
// further addEvent(ev, false) calls made the index increase. // further addEvent(ev, false) calls made the index increase.
it("should not make baseIndex assplode when removing the last event", it("should not make baseIndex assplode when removing the last event",
function() { function() {
timeline.addEvent(events[0], true); timeline.addEvent(events[0], { toStartOfTimeline: true });
timeline.removeEvent(events[0].getId()); timeline.removeEvent(events[0].getId());
const initialIndex = timeline.getBaseIndex(); const initialIndex = timeline.getBaseIndex();
timeline.addEvent(events[1], false); timeline.addEvent(events[1], { toStartOfTimeline: false });
timeline.addEvent(events[2], false); timeline.addEvent(events[2], { toStartOfTimeline: false });
expect(timeline.getBaseIndex()).toEqual(initialIndex); expect(timeline.getBaseIndex()).toEqual(initialIndex);
expect(timeline.getEvents().length).toEqual(2); expect(timeline.getEvents().length).toEqual(2);
}); });

View File

@ -1,7 +1,4 @@
import { import { RelationType } from "../../src";
MatrixEvent,
RelationType,
} from "../../src";
import { FilterComponent } from "../../src/filter-component"; import { FilterComponent } from "../../src/filter-component";
import { mkEvent } from '../test-utils/test-utils'; import { mkEvent } from '../test-utils/test-utils';
@ -14,7 +11,7 @@ describe("Filter Component", function() {
content: { }, content: { },
room: 'roomId', room: 'roomId',
event: true, event: true,
}) as MatrixEvent; });
const checkResult = filter.check(event); const checkResult = filter.check(event);
@ -28,7 +25,7 @@ describe("Filter Component", function() {
content: { }, content: { },
room: 'roomId', room: 'roomId',
event: true, event: true,
}) as MatrixEvent; });
const checkResult = filter.check(event); const checkResult = filter.check(event);
@ -55,7 +52,7 @@ describe("Filter Component", function() {
}, },
}, },
}, },
}) as MatrixEvent; });
expect(filter.check(threadRootNotParticipated)).toBe(false); expect(filter.check(threadRootNotParticipated)).toBe(false);
}); });
@ -80,7 +77,7 @@ describe("Filter Component", function() {
user: '@someone-else:server.org', user: '@someone-else:server.org',
room: 'roomId', room: 'roomId',
event: true, event: true,
}) as MatrixEvent; });
expect(filter.check(threadRootParticipated)).toBe(true); expect(filter.check(threadRootParticipated)).toBe(true);
}); });
@ -100,7 +97,7 @@ describe("Filter Component", function() {
[RelationType.Reference]: {}, [RelationType.Reference]: {},
}, },
}, },
}) as MatrixEvent; });
expect(filter.check(referenceRelationEvent)).toBe(false); expect(filter.check(referenceRelationEvent)).toBe(false);
}); });
@ -123,7 +120,7 @@ describe("Filter Component", function() {
}, },
room: 'roomId', room: 'roomId',
event: true, event: true,
}) as MatrixEvent; });
const eventWithMultipleRelations = mkEvent({ const eventWithMultipleRelations = mkEvent({
"type": "m.room.message", "type": "m.room.message",
@ -148,7 +145,7 @@ describe("Filter Component", function() {
}, },
"room": 'roomId', "room": 'roomId',
"event": true, "event": true,
}) as MatrixEvent; });
const noMatchEvent = mkEvent({ const noMatchEvent = mkEvent({
"type": "m.room.message", "type": "m.room.message",
@ -160,7 +157,7 @@ describe("Filter Component", function() {
}, },
"room": 'roomId', "room": 'roomId',
"event": true, "event": true,
}) as MatrixEvent; });
expect(filter.check(threadRootEvent)).toBe(true); expect(filter.check(threadRootEvent)).toBe(true);
expect(filter.check(eventWithMultipleRelations)).toBe(true); expect(filter.check(eventWithMultipleRelations)).toBe(true);

View File

@ -18,6 +18,8 @@ limitations under the License.
import { logger } from "../../src/logger"; import { logger } from "../../src/logger";
import { InteractiveAuth } from "../../src/interactive-auth"; import { InteractiveAuth } from "../../src/interactive-auth";
import { MatrixError } from "../../src/http-api"; import { MatrixError } from "../../src/http-api";
import { sleep } from "../../src/utils";
import { randomString } from "../../src/randomstring";
// Trivial client object to test interactive auth // Trivial client object to test interactive auth
// (we do not need TestClient here) // (we do not need TestClient here)
@ -172,4 +174,107 @@ describe("InteractiveAuth", function() {
expect(error.message).toBe('No appropriate authentication flow found'); expect(error.message).toBe('No appropriate authentication flow found');
}); });
}); });
describe("requestEmailToken", () => {
it("increases auth attempts", async () => {
const doRequest = jest.fn();
const stateUpdated = jest.fn();
const requestEmailToken = jest.fn();
requestEmailToken.mockImplementation(async () => ({ sid: "" }));
const ia = new InteractiveAuth({
matrixClient: new FakeClient(),
doRequest, stateUpdated, requestEmailToken,
});
await ia.requestEmailToken();
expect(requestEmailToken).toHaveBeenLastCalledWith(undefined, ia.getClientSecret(), 1, undefined);
requestEmailToken.mockClear();
await ia.requestEmailToken();
expect(requestEmailToken).toHaveBeenLastCalledWith(undefined, ia.getClientSecret(), 2, undefined);
requestEmailToken.mockClear();
await ia.requestEmailToken();
expect(requestEmailToken).toHaveBeenLastCalledWith(undefined, ia.getClientSecret(), 3, undefined);
requestEmailToken.mockClear();
await ia.requestEmailToken();
expect(requestEmailToken).toHaveBeenLastCalledWith(undefined, ia.getClientSecret(), 4, undefined);
requestEmailToken.mockClear();
await ia.requestEmailToken();
expect(requestEmailToken).toHaveBeenLastCalledWith(undefined, ia.getClientSecret(), 5, undefined);
});
it("increases auth attempts", async () => {
const doRequest = jest.fn();
const stateUpdated = jest.fn();
const requestEmailToken = jest.fn();
requestEmailToken.mockImplementation(async () => ({ sid: "" }));
const ia = new InteractiveAuth({
matrixClient: new FakeClient(),
doRequest, stateUpdated, requestEmailToken,
});
await ia.requestEmailToken();
expect(requestEmailToken).toHaveBeenLastCalledWith(undefined, ia.getClientSecret(), 1, undefined);
requestEmailToken.mockClear();
await ia.requestEmailToken();
expect(requestEmailToken).toHaveBeenLastCalledWith(undefined, ia.getClientSecret(), 2, undefined);
requestEmailToken.mockClear();
await ia.requestEmailToken();
expect(requestEmailToken).toHaveBeenLastCalledWith(undefined, ia.getClientSecret(), 3, undefined);
requestEmailToken.mockClear();
await ia.requestEmailToken();
expect(requestEmailToken).toHaveBeenLastCalledWith(undefined, ia.getClientSecret(), 4, undefined);
requestEmailToken.mockClear();
await ia.requestEmailToken();
expect(requestEmailToken).toHaveBeenLastCalledWith(undefined, ia.getClientSecret(), 5, undefined);
});
it("passes errors through", async () => {
const doRequest = jest.fn();
const stateUpdated = jest.fn();
const requestEmailToken = jest.fn();
requestEmailToken.mockImplementation(async () => {
throw new Error("unspecific network error");
});
const ia = new InteractiveAuth({
matrixClient: new FakeClient(),
doRequest, stateUpdated, requestEmailToken,
});
expect(async () => await ia.requestEmailToken()).rejects.toThrowError("unspecific network error");
});
it("only starts one request at a time", async () => {
const doRequest = jest.fn();
const stateUpdated = jest.fn();
const requestEmailToken = jest.fn();
requestEmailToken.mockImplementation(() => sleep(500, { sid: "" }));
const ia = new InteractiveAuth({
matrixClient: new FakeClient(),
doRequest, stateUpdated, requestEmailToken,
});
await Promise.all([ia.requestEmailToken(), ia.requestEmailToken(), ia.requestEmailToken()]);
expect(requestEmailToken).toHaveBeenCalledTimes(1);
});
it("stores result in email sid", async () => {
const doRequest = jest.fn();
const stateUpdated = jest.fn();
const requestEmailToken = jest.fn();
const sid = randomString(24);
requestEmailToken.mockImplementation(() => sleep(500, { sid }));
const ia = new InteractiveAuth({
matrixClient: new FakeClient(),
doRequest, stateUpdated, requestEmailToken,
});
await ia.requestEmailToken();
expect(ia.getEmailSid()).toEqual(sid);
});
});
}); });

View File

@ -33,7 +33,7 @@ import { ReceiptType } from "../../src/@types/read_receipts";
import * as testUtils from "../test-utils/test-utils"; import * as testUtils from "../test-utils/test-utils";
import { makeBeaconInfoContent } from "../../src/content-helpers"; import { makeBeaconInfoContent } from "../../src/content-helpers";
import { M_BEACON_INFO } from "../../src/@types/beacon"; import { M_BEACON_INFO } from "../../src/@types/beacon";
import { Room } from "../../src"; import { ContentHelpers, Room } from "../../src";
import { makeBeaconEvent } from "../test-utils/beacon"; import { makeBeaconEvent } from "../test-utils/beacon";
jest.useFakeTimers(); jest.useFakeTimers();
@ -87,7 +87,7 @@ describe("MatrixClient", function() {
// } // }
// items are popped off when processed and block if no items left. // items are popped off when processed and block if no items left.
]; ];
let acceptKeepalives; let acceptKeepalives: boolean;
let pendingLookup = null; let pendingLookup = null;
function httpReq(cb, method, path, qp, data, prefix) { function httpReq(cb, method, path, qp, data, prefix) {
if (path === KEEP_ALIVE_PATH && acceptKeepalives) { if (path === KEEP_ALIVE_PATH && acceptKeepalives) {
@ -118,6 +118,7 @@ describe("MatrixClient", function() {
method: method, method: method,
path: path, path: path,
}; };
pendingLookup.promise.abort = () => {}; // to make it a valid IAbortablePromise
return pendingLookup.promise; return pendingLookup.promise;
} }
if (next.path === path && next.method === method) { if (next.path === path && next.method === method) {
@ -126,7 +127,7 @@ describe("MatrixClient", function() {
(next.error ? "BAD" : "GOOD") + " response", (next.error ? "BAD" : "GOOD") + " response",
); );
if (next.expectBody) { if (next.expectBody) {
expect(next.expectBody).toEqual(data); expect(data).toEqual(next.expectBody);
} }
if (next.expectQueryParams) { if (next.expectQueryParams) {
Object.keys(next.expectQueryParams).forEach(function(k) { Object.keys(next.expectQueryParams).forEach(function(k) {
@ -150,6 +151,10 @@ describe("MatrixClient", function() {
} }
return Promise.resolve(next.data); return Promise.resolve(next.data);
} }
// Jest doesn't let us have custom expectation errors, so if you're seeing this then
// you forgot to handle at least 1 pending request. Check your tests to ensure your
// number of expectations lines up with your number of requests made, and that those
// requests match your expectations.
expect(true).toBe(false); expect(true).toBe(false);
return new Promise(() => {}); return new Promise(() => {});
} }
@ -205,6 +210,7 @@ describe("MatrixClient", function() {
client.http.authedRequest.mockImplementation(function() { client.http.authedRequest.mockImplementation(function() {
return new Promise(() => {}); return new Promise(() => {});
}); });
client.stopClient();
}); });
it("should create (unstable) file trees", async () => { it("should create (unstable) file trees", async () => {
@ -725,18 +731,16 @@ describe("MatrixClient", function() {
}); });
describe("guest rooms", function() { describe("guest rooms", function() {
it("should only do /sync calls (without filter/pushrules)", function(done) { it("should only do /sync calls (without filter/pushrules)", async function() {
httpLookups = []; // no /pushrules or /filterw httpLookups = []; // no /pushrules or /filter
httpLookups.push({ httpLookups.push({
method: "GET", method: "GET",
path: "/sync", path: "/sync",
data: SYNC_DATA, data: SYNC_DATA,
thenCall: function() {
done();
},
}); });
client.setGuest(true); client.setGuest(true);
client.startClient(); await client.startClient();
expect(httpLookups.length).toBe(0);
}); });
xit("should be able to peek into a room using peekInRoom", function(done) { xit("should be able to peek into a room using peekInRoom", function(done) {
@ -773,7 +777,7 @@ describe("MatrixClient", function() {
expectBody: content, expectBody: content,
}]; }];
await client.sendEvent(roomId, EventType.RoomMessage, content, txnId); await client.sendEvent(roomId, EventType.RoomMessage, { ...content }, txnId);
}); });
it("overload with null threadId works", async () => { it("overload with null threadId works", async () => {
@ -786,20 +790,99 @@ describe("MatrixClient", function() {
expectBody: content, expectBody: content,
}]; }];
await client.sendEvent(roomId, null, EventType.RoomMessage, content, txnId); await client.sendEvent(roomId, null, EventType.RoomMessage, { ...content }, txnId);
}); });
it("overload with threadId works", async () => { it("overload with threadId works", async () => {
const eventId = "$eventId:example.org"; const eventId = "$eventId:example.org";
const txnId = client.makeTxnId(); const txnId = client.makeTxnId();
const threadId = "$threadId:server";
httpLookups = [{ httpLookups = [{
method: "PUT", method: "PUT",
path: `/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${txnId}`, path: `/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${txnId}`,
data: { event_id: eventId }, data: { event_id: eventId },
expectBody: content, expectBody: {
...content,
"m.relates_to": {
"event_id": threadId,
"is_falling_back": true,
"rel_type": "m.thread",
},
},
}]; }];
await client.sendEvent(roomId, "$threadId:server", EventType.RoomMessage, content, txnId); await client.sendEvent(roomId, threadId, EventType.RoomMessage, { ...content }, txnId);
});
it("should add thread relation if threadId is passed and the relation is missing", async () => {
const eventId = "$eventId:example.org";
const threadId = "$threadId:server";
const txnId = client.makeTxnId();
const room = new Room(roomId, client, userId);
store.getRoom.mockReturnValue(room);
const rootEvent = new MatrixEvent({ event_id: threadId });
room.createThread(threadId, rootEvent, [rootEvent], false);
httpLookups = [{
method: "PUT",
path: `/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${txnId}`,
data: { event_id: eventId },
expectBody: {
...content,
"m.relates_to": {
"m.in_reply_to": {
event_id: threadId,
},
"event_id": threadId,
"is_falling_back": true,
"rel_type": "m.thread",
},
},
}];
await client.sendEvent(roomId, threadId, EventType.RoomMessage, { ...content }, txnId);
});
it("should add thread relation if threadId is passed and the relation is missing with reply", async () => {
const eventId = "$eventId:example.org";
const threadId = "$threadId:server";
const txnId = client.makeTxnId();
const content = {
body,
"m.relates_to": {
"m.in_reply_to": {
event_id: "$other:event",
},
},
};
const room = new Room(roomId, client, userId);
store.getRoom.mockReturnValue(room);
const rootEvent = new MatrixEvent({ event_id: threadId });
room.createThread(threadId, rootEvent, [rootEvent], false);
httpLookups = [{
method: "PUT",
path: `/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${txnId}`,
data: { event_id: eventId },
expectBody: {
...content,
"m.relates_to": {
"m.in_reply_to": {
event_id: "$other:event",
},
"event_id": threadId,
"is_falling_back": false,
"rel_type": "m.thread",
},
},
}];
await client.sendEvent(roomId, threadId, EventType.RoomMessage, { ...content }, txnId);
}); });
}); });
@ -926,6 +1009,7 @@ describe("MatrixClient", function() {
}; };
client.crypto = { // mock crypto client.crypto = { // mock crypto
encryptEvent: (event, room) => new Promise(() => {}), encryptEvent: (event, room) => new Promise(() => {}),
stop: jest.fn(),
}; };
}); });
@ -1104,6 +1188,41 @@ describe("MatrixClient", function() {
}); });
}); });
describe("setRoomTopic", () => {
const roomId = "!foofoofoofoofoofoo:matrix.org";
const createSendStateEventMock = (topic: string, htmlTopic?: string) => {
return jest.fn()
.mockImplementation((roomId: string, eventType: string, content: any, stateKey: string) => {
expect(roomId).toEqual(roomId);
expect(eventType).toEqual(EventType.RoomTopic);
expect(content).toMatchObject(ContentHelpers.makeTopicContent(topic, htmlTopic));
expect(stateKey).toBeUndefined();
return Promise.resolve();
});
};
it("is called with plain text topic and sends state event", async () => {
const sendStateEvent = createSendStateEventMock("pizza");
client.sendStateEvent = sendStateEvent;
await client.setRoomTopic(roomId, "pizza");
expect(sendStateEvent).toHaveBeenCalledTimes(1);
});
it("is called with plain text topic and callback and sends state event", async () => {
const sendStateEvent = createSendStateEventMock("pizza");
client.sendStateEvent = sendStateEvent;
await client.setRoomTopic(roomId, "pizza", () => {});
expect(sendStateEvent).toHaveBeenCalledTimes(1);
});
it("is called with plain text and HTML topic and sends state event", async () => {
const sendStateEvent = createSendStateEventMock("pizza", "<b>pizza</b>");
client.sendStateEvent = sendStateEvent;
await client.setRoomTopic(roomId, "pizza", "<b>pizza</b>");
expect(sendStateEvent).toHaveBeenCalledTimes(1);
});
});
describe("setPassword", () => { describe("setPassword", () => {
const auth = { session: 'abcdef', type: 'foo' }; const auth = { session: 'abcdef', type: 'foo' };
const newPassword = 'newpassword'; const newPassword = 'newpassword';
@ -1156,4 +1275,26 @@ describe("MatrixClient", function() {
passwordTest({ auth, new_password: newPassword, logout_devices: false }, callback); passwordTest({ auth, new_password: newPassword, logout_devices: false }, callback);
}); });
}); });
describe("getLocalAliases", () => {
it("should call the right endpoint", async () => {
const response = {
aliases: ["#woop:example.org", "#another:example.org"],
};
client.http.authedRequest.mockClear().mockResolvedValue(response);
const roomId = "!whatever:example.org";
const result = await client.getLocalAliases(roomId);
// Current version of the endpoint we support is v3
const [callback, method, path, queryParams, data, opts] = client.http.authedRequest.mock.calls[0];
expect(callback).toBeFalsy();
expect(data).toBeFalsy();
expect(method).toBe('GET');
expect(path).toEqual(`/rooms/${encodeURIComponent(roomId)}/aliases`);
expect(opts).toMatchObject({ prefix: "/_matrix/client/v3" });
expect(queryParams).toBeFalsy();
expect(result!.aliases).toEqual(response.aliases);
});
});
}); });

View File

@ -14,6 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { MatrixEvent } from "../../../src";
import { import {
isTimestampInDuration, isTimestampInDuration,
Beacon, Beacon,
@ -65,33 +66,36 @@ describe('Beacon', () => {
// beacon_info events // beacon_info events
// created 'an hour ago' // created 'an hour ago'
// without timeout of 3 hours // without timeout of 3 hours
let liveBeaconEvent; let liveBeaconEvent: MatrixEvent;
let notLiveBeaconEvent; let notLiveBeaconEvent: MatrixEvent;
let user2BeaconEvent; let user2BeaconEvent: MatrixEvent;
const advanceDateAndTime = (ms: number) => { const advanceDateAndTime = (ms: number) => {
// bc liveness check uses Date.now we have to advance this mock // bc liveness check uses Date.now we have to advance this mock
jest.spyOn(global.Date, 'now').mockReturnValue(now + ms); jest.spyOn(global.Date, 'now').mockReturnValue(Date.now() + ms);
// then advance time for the interval by the same amount // then advance time for the interval by the same amount
jest.advanceTimersByTime(ms); jest.advanceTimersByTime(ms);
}; };
beforeEach(() => { beforeEach(() => {
// go back in time to create the beacon
jest.spyOn(global.Date, 'now').mockReturnValue(now - HOUR_MS);
liveBeaconEvent = makeBeaconInfoEvent( liveBeaconEvent = makeBeaconInfoEvent(
userId, userId,
roomId, roomId,
{ {
timeout: HOUR_MS * 3, timeout: HOUR_MS * 3,
isLive: true, isLive: true,
timestamp: now - HOUR_MS,
}, },
'$live123', '$live123',
); );
notLiveBeaconEvent = makeBeaconInfoEvent( notLiveBeaconEvent = makeBeaconInfoEvent(
userId, userId,
roomId, roomId,
{ timeout: HOUR_MS * 3, isLive: false }, {
timeout: HOUR_MS * 3,
isLive: false,
timestamp: now - HOUR_MS,
},
'$dead123', '$dead123',
); );
user2BeaconEvent = makeBeaconInfoEvent( user2BeaconEvent = makeBeaconInfoEvent(
@ -100,11 +104,12 @@ describe('Beacon', () => {
{ {
timeout: HOUR_MS * 3, timeout: HOUR_MS * 3,
isLive: true, isLive: true,
timestamp: now - HOUR_MS,
}, },
'$user2live123', '$user2live123',
); );
// back to now // back to 'now'
jest.spyOn(global.Date, 'now').mockReturnValue(now); jest.spyOn(global.Date, 'now').mockReturnValue(now);
}); });
@ -131,17 +136,81 @@ describe('Beacon', () => {
}); });
it('returns false when beacon is expired', () => { it('returns false when beacon is expired', () => {
// time travel to beacon creation + 3 hours const expiredBeaconEvent = makeBeaconInfoEvent(
jest.spyOn(global.Date, 'now').mockReturnValue(now - 3 * HOUR_MS); userId2,
const beacon = new Beacon(liveBeaconEvent); roomId,
{
timeout: HOUR_MS,
isLive: true,
timestamp: now - HOUR_MS * 2,
},
'$user2live123',
);
const beacon = new Beacon(expiredBeaconEvent);
expect(beacon.isLive).toEqual(false); expect(beacon.isLive).toEqual(false);
}); });
it('returns false when beacon timestamp is in future', () => { it('returns false when beacon timestamp is in future by an hour', () => {
// time travel to before beacon events timestamp const beaconStartsInHour = makeBeaconInfoEvent(
// event was created now - 1 hour userId2,
jest.spyOn(global.Date, 'now').mockReturnValue(now - HOUR_MS - HOUR_MS); roomId,
const beacon = new Beacon(liveBeaconEvent); {
timeout: HOUR_MS,
isLive: true,
timestamp: now + HOUR_MS,
},
'$user2live123',
);
const beacon = new Beacon(beaconStartsInHour);
expect(beacon.isLive).toEqual(false);
});
it('returns true when beacon timestamp is one minute in the future', () => {
const beaconStartsInOneMin = makeBeaconInfoEvent(
userId2,
roomId,
{
timeout: HOUR_MS,
isLive: true,
timestamp: now + 60000,
},
'$user2live123',
);
const beacon = new Beacon(beaconStartsInOneMin);
expect(beacon.isLive).toEqual(true);
});
it('returns true when beacon timestamp is one minute before expiry', () => {
// this test case is to check the start time leniency doesn't affect
// strict expiry time checks
const expiresInOneMin = makeBeaconInfoEvent(
userId2,
roomId,
{
timeout: HOUR_MS,
isLive: true,
timestamp: now - HOUR_MS + 60000,
},
'$user2live123',
);
const beacon = new Beacon(expiresInOneMin);
expect(beacon.isLive).toEqual(true);
});
it('returns false when beacon timestamp is one minute after expiry', () => {
// this test case is to check the start time leniency doesn't affect
// strict expiry time checks
const expiredOneMinAgo = makeBeaconInfoEvent(
userId2,
roomId,
{
timeout: HOUR_MS,
isLive: true,
timestamp: now - HOUR_MS - 60000,
},
'$user2live123',
);
const beacon = new Beacon(expiredOneMinAgo);
expect(beacon.isLive).toEqual(false); expect(beacon.isLive).toEqual(false);
}); });
@ -224,13 +293,47 @@ describe('Beacon', () => {
beacon.monitorLiveness(); beacon.monitorLiveness();
// @ts-ignore // @ts-ignore
expect(beacon.livenessWatchInterval).toBeFalsy(); expect(beacon.livenessWatchTimeout).toBeFalsy();
advanceDateAndTime(HOUR_MS * 2 + 1); advanceDateAndTime(HOUR_MS * 2 + 1);
// no emit // no emit
expect(emitSpy).not.toHaveBeenCalled(); expect(emitSpy).not.toHaveBeenCalled();
}); });
it('checks liveness of beacon at expected start time', () => {
const futureBeaconEvent = makeBeaconInfoEvent(
userId,
roomId,
{
timeout: HOUR_MS * 3,
isLive: true,
// start timestamp hour in future
timestamp: now + HOUR_MS,
},
'$live123',
);
const beacon = new Beacon(futureBeaconEvent);
expect(beacon.isLive).toBeFalsy();
const emitSpy = jest.spyOn(beacon, 'emit');
beacon.monitorLiveness();
// advance to the start timestamp of the beacon
advanceDateAndTime(HOUR_MS + 1);
// beacon is in live period now
expect(emitSpy).toHaveBeenCalledTimes(1);
expect(emitSpy).toHaveBeenCalledWith(BeaconEvent.LivenessChange, true, beacon);
// check the expiry monitor is still setup ok
// advance to the expiry
advanceDateAndTime(HOUR_MS * 3 + 100);
expect(emitSpy).toHaveBeenCalledTimes(2);
expect(emitSpy).toHaveBeenCalledWith(BeaconEvent.LivenessChange, false, beacon);
});
it('checks liveness of beacon at expected expiry time', () => { it('checks liveness of beacon at expected expiry time', () => {
// live beacon was created an hour ago // live beacon was created an hour ago
// and has a 3hr duration // and has a 3hr duration
@ -253,12 +356,12 @@ describe('Beacon', () => {
beacon.monitorLiveness(); beacon.monitorLiveness();
// @ts-ignore // @ts-ignore
const oldMonitor = beacon.livenessWatchInterval; const oldMonitor = beacon.livenessWatchTimeout;
beacon.monitorLiveness(); beacon.monitorLiveness();
// @ts-ignore // @ts-ignore
expect(beacon.livenessWatchInterval).not.toEqual(oldMonitor); expect(beacon.livenessWatchTimeout).not.toEqual(oldMonitor);
}); });
it('destroy kills liveness monitor and emits', () => { it('destroy kills liveness monitor and emits', () => {
@ -309,6 +412,57 @@ describe('Beacon', () => {
expect(emitSpy).not.toHaveBeenCalled(); expect(emitSpy).not.toHaveBeenCalled();
}); });
describe('when beacon is live with a start timestamp is in the future', () => {
it('ignores locations before the beacon start timestamp', () => {
const startTimestamp = now + 60000;
const beacon = new Beacon(makeBeaconInfoEvent(
userId,
roomId,
{ isLive: true, timeout: 60000, timestamp: startTimestamp },
));
const emitSpy = jest.spyOn(beacon, 'emit');
beacon.addLocations([
// beacon has now + 60000 live period
makeBeaconEvent(
userId,
{
beaconInfoId: beacon.beaconInfoId,
// now < location timestamp < beacon timestamp
timestamp: now + 10,
},
),
]);
expect(beacon.latestLocationState).toBeFalsy();
expect(emitSpy).not.toHaveBeenCalled();
});
it('sets latest location when location timestamp is after startTimestamp', () => {
const startTimestamp = now + 60000;
const beacon = new Beacon(makeBeaconInfoEvent(
userId,
roomId,
{ isLive: true, timeout: 600000, timestamp: startTimestamp },
));
const emitSpy = jest.spyOn(beacon, 'emit');
beacon.addLocations([
// beacon has now + 600000 live period
makeBeaconEvent(
userId,
{
beaconInfoId: beacon.beaconInfoId,
// now < beacon timestamp < location timestamp
timestamp: startTimestamp + 10,
},
),
]);
expect(beacon.latestLocationState).toBeTruthy();
expect(emitSpy).toHaveBeenCalled();
});
});
it('sets latest location state to most recent location', () => { it('sets latest location state to most recent location', () => {
const beacon = new Beacon(makeBeaconInfoEvent(userId, roomId, { isLive: true, timeout: 60000 })); const beacon = new Beacon(makeBeaconInfoEvent(userId, roomId, { isLive: true, timeout: 60000 }));
const emitSpy = jest.spyOn(beacon, 'emit'); const emitSpy = jest.spyOn(beacon, 'emit');
@ -338,6 +492,7 @@ describe('Beacon', () => {
// the newest valid location // the newest valid location
expect(beacon.latestLocationState).toEqual(expectedLatestLocation); expect(beacon.latestLocationState).toEqual(expectedLatestLocation);
expect(beacon.latestLocationEvent).toEqual(locations[1]);
expect(emitSpy).toHaveBeenCalledWith(BeaconEvent.LocationUpdate, expectedLatestLocation); expect(emitSpy).toHaveBeenCalledWith(BeaconEvent.LocationUpdate, expectedLatestLocation);
}); });
@ -356,6 +511,7 @@ describe('Beacon', () => {
expect(beacon.latestLocationState).toEqual(expect.objectContaining({ expect(beacon.latestLocationState).toEqual(expect.objectContaining({
uri: 'geo:bar', uri: 'geo:bar',
})); }));
expect(beacon.latestLocationEvent).toEqual(newerLocation);
const emitSpy = jest.spyOn(beacon, 'emit').mockClear(); const emitSpy = jest.spyOn(beacon, 'emit').mockClear();

View File

@ -0,0 +1,28 @@
/*
Copyright 2022 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 { Thread } from "../../../src/models/thread";
describe('Thread', () => {
describe("constructor", () => {
it("should explode for element-web#22141 logging", () => {
// Logging/debugging for https://github.com/vector-im/element-web/issues/22141
expect(() => {
new Thread("$event", undefined, {} as any); // deliberate cast to test error case
}).toThrow("element-web#22141: A thread requires a room in order to function");
});
});
});

View File

@ -302,6 +302,7 @@ describe('NotificationService', function() {
type: EventType.RoomServerAcl, type: EventType.RoomServerAcl,
room: testRoomId, room: testRoomId,
user: "@alfred:localhost", user: "@alfred:localhost",
skey: "",
event: true, event: true,
content: {}, content: {},
}); });

View File

@ -96,19 +96,14 @@ describe("Relations", function() {
}, },
}); });
// Stub the room
const room = new Room("room123", null, null);
// Add the target event first, then the relation event // Add the target event first, then the relation event
{ {
const room = new Room("room123", null, null);
const relationsCreated = new Promise(resolve => { const relationsCreated = new Promise(resolve => {
targetEvent.once(MatrixEventEvent.RelationsCreated, resolve); targetEvent.once(MatrixEventEvent.RelationsCreated, resolve);
}); });
const timelineSet = new EventTimelineSet(room, { const timelineSet = new EventTimelineSet(room);
unstableClientRelationAggregation: true,
});
timelineSet.addLiveEvent(targetEvent); timelineSet.addLiveEvent(targetEvent);
timelineSet.addLiveEvent(relationEvent); timelineSet.addLiveEvent(relationEvent);
@ -117,13 +112,12 @@ describe("Relations", function() {
// Add the relation event first, then the target event // Add the relation event first, then the target event
{ {
const room = new Room("room123", null, null);
const relationsCreated = new Promise(resolve => { const relationsCreated = new Promise(resolve => {
targetEvent.once(MatrixEventEvent.RelationsCreated, resolve); targetEvent.once(MatrixEventEvent.RelationsCreated, resolve);
}); });
const timelineSet = new EventTimelineSet(room, { const timelineSet = new EventTimelineSet(room);
unstableClientRelationAggregation: true,
});
timelineSet.addLiveEvent(relationEvent); timelineSet.addLiveEvent(relationEvent);
timelineSet.addLiveEvent(targetEvent); timelineSet.addLiveEvent(targetEvent);
@ -131,6 +125,14 @@ describe("Relations", function() {
} }
}); });
it("should re-use Relations between all timeline sets in a room", async () => {
const room = new Room("room123", null, null);
const timelineSet1 = new EventTimelineSet(room);
const timelineSet2 = new EventTimelineSet(room);
expect(room.relations).toBe(timelineSet1.relations);
expect(room.relations).toBe(timelineSet2.relations);
});
it("should ignore m.replace for state events", async () => { it("should ignore m.replace for state events", async () => {
const userId = "@bob:example.com"; const userId = "@bob:example.com";
const room = new Room("room123", null, userId); const room = new Room("room123", null, userId);
@ -168,6 +170,8 @@ describe("Relations", function() {
await relations.setTargetEvent(originalTopic); await relations.setTargetEvent(originalTopic);
expect(originalTopic.replacingEvent()).toBe(null); expect(originalTopic.replacingEvent()).toBe(null);
expect(originalTopic.getContent().topic).toBe("orig"); expect(originalTopic.getContent().topic).toBe("orig");
expect(badlyEditedTopic.isRelation()).toBe(false);
expect(badlyEditedTopic.isRelation("m.replace")).toBe(false);
await relations.addEvent(badlyEditedTopic); await relations.addEvent(badlyEditedTopic);
expect(originalTopic.replacingEvent()).toBe(null); expect(originalTopic.replacingEvent()).toBe(null);

View File

@ -3,7 +3,7 @@ import { makeBeaconEvent, makeBeaconInfoEvent } from "../test-utils/beacon";
import { filterEmitCallsByEventType } from "../test-utils/emitter"; import { filterEmitCallsByEventType } from "../test-utils/emitter";
import { RoomState, RoomStateEvent } from "../../src/models/room-state"; import { RoomState, RoomStateEvent } from "../../src/models/room-state";
import { BeaconEvent, getBeaconInfoIdentifier } from "../../src/models/beacon"; import { BeaconEvent, getBeaconInfoIdentifier } from "../../src/models/beacon";
import { EventType, RelationType } from "../../src/@types/event"; import { EventType, RelationType, UNSTABLE_MSC2716_MARKER } from "../../src/@types/event";
import { import {
MatrixEvent, MatrixEvent,
MatrixEventEvent, MatrixEventEvent,
@ -258,6 +258,29 @@ describe("RoomState", function() {
); );
}); });
it("should emit `RoomStateEvent.Marker` for each marker event", function() {
const events = [
utils.mkEvent({
event: true,
type: UNSTABLE_MSC2716_MARKER.name,
room: roomId,
user: userA,
skey: "",
content: {
"m.insertion_id": "$abc",
},
}),
];
let emitCount = 0;
state.on("RoomState.Marker", function(markerEvent, markerFoundOptions) {
expect(markerEvent).toEqual(events[emitCount]);
expect(markerFoundOptions).toEqual({ timelineWasEmpty: true });
emitCount += 1;
});
state.setStateEvents(events, { timelineWasEmpty: true });
expect(emitCount).toEqual(1);
});
describe('beacon events', () => { describe('beacon events', () => {
it('adds new beacon info events to state and emits', () => { it('adds new beacon info events to state and emits', () => {
const beaconEvent = makeBeaconInfoEvent(userA, roomId); const beaconEvent = makeBeaconInfoEvent(userA, roomId);

View File

@ -52,7 +52,7 @@ describe("Room", function() {
event: true, event: true,
user: userA, user: userA,
room: roomId, room: roomId,
}, room.client) as MatrixEvent; }, room.client);
const mkReply = (target: MatrixEvent) => utils.mkEvent({ const mkReply = (target: MatrixEvent) => utils.mkEvent({
event: true, event: true,
@ -67,7 +67,7 @@ describe("Room", function() {
}, },
}, },
}, },
}, room.client) as MatrixEvent; }, room.client);
const mkEdit = (target: MatrixEvent, salt = Math.random()) => utils.mkEvent({ const mkEdit = (target: MatrixEvent, salt = Math.random()) => utils.mkEvent({
event: true, event: true,
@ -84,7 +84,7 @@ describe("Room", function() {
event_id: target.getId(), event_id: target.getId(),
}, },
}, },
}, room.client) as MatrixEvent; }, room.client);
const mkThreadResponse = (root: MatrixEvent) => utils.mkEvent({ const mkThreadResponse = (root: MatrixEvent) => utils.mkEvent({
event: true, event: true,
@ -101,7 +101,7 @@ describe("Room", function() {
"rel_type": "m.thread", "rel_type": "m.thread",
}, },
}, },
}, room.client) as MatrixEvent; }, room.client);
const mkReaction = (target: MatrixEvent) => utils.mkEvent({ const mkReaction = (target: MatrixEvent) => utils.mkEvent({
event: true, event: true,
@ -115,7 +115,7 @@ describe("Room", function() {
"key": Math.random().toString(), "key": Math.random().toString(),
}, },
}, },
}, room.client) as MatrixEvent; }, room.client);
const mkRedaction = (target: MatrixEvent) => utils.mkEvent({ const mkRedaction = (target: MatrixEvent) => utils.mkEvent({
event: true, event: true,
@ -124,7 +124,7 @@ describe("Room", function() {
room: roomId, room: roomId,
redacts: target.getId(), redacts: target.getId(),
content: {}, content: {},
}, room.client) as MatrixEvent; }, room.client);
beforeEach(function() { beforeEach(function() {
room = new Room(roomId, new TestClient(userA, "device").client, userA); room = new Room(roomId, new TestClient(userA, "device").client, userA);
@ -133,6 +133,27 @@ describe("Room", function() {
room.currentState = room.getLiveTimeline().endState = utils.mock(RoomState, "currentState"); room.currentState = room.getLiveTimeline().endState = utils.mock(RoomState, "currentState");
}); });
describe('getCreator', () => {
it("should return the creator from m.room.create", function() {
room.currentState.getStateEvents.mockImplementation(function(type, key) {
if (type === EventType.RoomCreate && key === "") {
return utils.mkEvent({
event: true,
type: EventType.RoomCreate,
skey: "",
room: roomId,
user: userA,
content: {
creator: userA,
},
});
}
});
const roomCreator = room.getCreator();
expect(roomCreator).toStrictEqual(userA);
});
});
describe("getAvatarUrl", function() { describe("getAvatarUrl", function() {
const hsUrl = "https://my.home.server"; const hsUrl = "https://my.home.server";
@ -189,29 +210,24 @@ describe("Room", function() {
const events: MatrixEvent[] = [ const events: MatrixEvent[] = [
utils.mkMessage({ utils.mkMessage({
room: roomId, user: userA, msg: "changing room name", event: true, room: roomId, user: userA, msg: "changing room name", event: true,
}) as MatrixEvent, }),
utils.mkEvent({ utils.mkEvent({
type: EventType.RoomName, room: roomId, user: userA, event: true, type: EventType.RoomName, room: roomId, user: userA, event: true,
content: { name: "New Room Name" }, content: { name: "New Room Name" },
}) as MatrixEvent, }),
]; ];
it("should call RoomState.setTypingEvent on m.typing events", function() { it("Make sure legacy overload passing options directly as parameters still works", () => {
const typing = utils.mkEvent({ expect(() => room.addLiveEvents(events, DuplicateStrategy.Replace, false)).not.toThrow();
room: roomId, expect(() => room.addLiveEvents(events, DuplicateStrategy.Ignore, true)).not.toThrow();
type: EventType.Typing, expect(() => room.addLiveEvents(events, "shouldfailbecauseinvalidduplicatestrategy", false)).toThrow();
event: true,
content: {
user_ids: [userA],
},
});
room.addEphemeralEvents([typing]);
expect(room.currentState.setTypingEvent).toHaveBeenCalledWith(typing);
}); });
it("should throw if duplicateStrategy isn't 'replace' or 'ignore'", function() { it("should throw if duplicateStrategy isn't 'replace' or 'ignore'", function() {
expect(function() { expect(function() {
room.addLiveEvents(events, "foo"); room.addLiveEvents(events, {
duplicateStrategy: "foo",
});
}).toThrow(); }).toThrow();
}); });
@ -219,11 +235,13 @@ describe("Room", function() {
// make a duplicate // make a duplicate
const dupe = utils.mkMessage({ const dupe = utils.mkMessage({
room: roomId, user: userA, msg: "dupe", event: true, room: roomId, user: userA, msg: "dupe", event: true,
}) as MatrixEvent; });
dupe.event.event_id = events[0].getId(); dupe.event.event_id = events[0].getId();
room.addLiveEvents(events); room.addLiveEvents(events);
expect(room.timeline[0]).toEqual(events[0]); expect(room.timeline[0]).toEqual(events[0]);
room.addLiveEvents([dupe], DuplicateStrategy.Replace); room.addLiveEvents([dupe], {
duplicateStrategy: DuplicateStrategy.Replace,
});
expect(room.timeline[0]).toEqual(dupe); expect(room.timeline[0]).toEqual(dupe);
}); });
@ -231,11 +249,13 @@ describe("Room", function() {
// make a duplicate // make a duplicate
const dupe = utils.mkMessage({ const dupe = utils.mkMessage({
room: roomId, user: userA, msg: "dupe", event: true, room: roomId, user: userA, msg: "dupe", event: true,
}) as MatrixEvent; });
dupe.event.event_id = events[0].getId(); dupe.event.event_id = events[0].getId();
room.addLiveEvents(events); room.addLiveEvents(events);
expect(room.timeline[0]).toEqual(events[0]); expect(room.timeline[0]).toEqual(events[0]);
room.addLiveEvents([dupe], "ignore"); room.addLiveEvents([dupe], {
duplicateStrategy: "ignore",
});
expect(room.timeline[0]).toEqual(events[0]); expect(room.timeline[0]).toEqual(events[0]);
}); });
@ -257,20 +277,22 @@ describe("Room", function() {
const events: MatrixEvent[] = [ const events: MatrixEvent[] = [
utils.mkMembership({ utils.mkMembership({
room: roomId, mship: "invite", user: userB, skey: userA, event: true, room: roomId, mship: "invite", user: userB, skey: userA, event: true,
}) as MatrixEvent, }),
utils.mkEvent({ utils.mkEvent({
type: EventType.RoomName, room: roomId, user: userB, event: true, type: EventType.RoomName, room: roomId, user: userB, event: true,
content: { content: {
name: "New room", name: "New room",
}, },
}) as MatrixEvent, }),
]; ];
room.addLiveEvents(events); room.addLiveEvents(events);
expect(room.currentState.setStateEvents).toHaveBeenCalledWith( expect(room.currentState.setStateEvents).toHaveBeenCalledWith(
[events[0]], [events[0]],
{ timelineWasEmpty: undefined },
); );
expect(room.currentState.setStateEvents).toHaveBeenCalledWith( expect(room.currentState.setStateEvents).toHaveBeenCalledWith(
[events[1]], [events[1]],
{ timelineWasEmpty: undefined },
); );
expect(events[0].forwardLooking).toBe(true); expect(events[0].forwardLooking).toBe(true);
expect(events[1].forwardLooking).toBe(true); expect(events[1].forwardLooking).toBe(true);
@ -296,13 +318,13 @@ describe("Room", function() {
it("should emit Room.localEchoUpdated when a local echo is updated", function() { it("should emit Room.localEchoUpdated when a local echo is updated", function() {
const localEvent = utils.mkMessage({ const localEvent = utils.mkMessage({
room: roomId, user: userA, event: true, room: roomId, user: userA, event: true,
}) as MatrixEvent; });
localEvent.status = EventStatus.SENDING; localEvent.status = EventStatus.SENDING;
const localEventId = localEvent.getId(); const localEventId = localEvent.getId();
const remoteEvent = utils.mkMessage({ const remoteEvent = utils.mkMessage({
room: roomId, user: userA, event: true, room: roomId, user: userA, event: true,
}) as MatrixEvent; });
remoteEvent.event.unsigned = { transaction_id: "TXN_ID" }; remoteEvent.event.unsigned = { transaction_id: "TXN_ID" };
const remoteEventId = remoteEvent.getId(); const remoteEventId = remoteEvent.getId();
@ -341,6 +363,21 @@ describe("Room", function() {
}); });
}); });
describe('addEphemeralEvents', () => {
it("should call RoomState.setTypingEvent on m.typing events", function() {
const typing = utils.mkEvent({
room: roomId,
type: EventType.Typing,
event: true,
content: {
user_ids: [userA],
},
});
room.addEphemeralEvents([typing]);
expect(room.currentState.setTypingEvent).toHaveBeenCalledWith(typing);
});
});
describe("addEventsToTimeline", function() { describe("addEventsToTimeline", function() {
const events = [ const events = [
utils.mkMessage({ utils.mkMessage({
@ -408,11 +445,11 @@ describe("Room", function() {
const newEv = utils.mkEvent({ const newEv = utils.mkEvent({
type: EventType.RoomName, room: roomId, user: userA, event: true, type: EventType.RoomName, room: roomId, user: userA, event: true,
content: { name: "New Room Name" }, content: { name: "New Room Name" },
}) as MatrixEvent; });
const oldEv = utils.mkEvent({ const oldEv = utils.mkEvent({
type: EventType.RoomName, room: roomId, user: userA, event: true, type: EventType.RoomName, room: roomId, user: userA, event: true,
content: { name: "Old Room Name" }, content: { name: "Old Room Name" },
}) as MatrixEvent; });
room.addLiveEvents([newEv]); room.addLiveEvents([newEv]);
expect(newEv.sender).toEqual(sentinel); expect(newEv.sender).toEqual(sentinel);
room.addEventsToTimeline([oldEv], true, room.getLiveTimeline()); room.addEventsToTimeline([oldEv], true, room.getLiveTimeline());
@ -445,10 +482,10 @@ describe("Room", function() {
const newEv = utils.mkMembership({ const newEv = utils.mkMembership({
room: roomId, mship: "invite", user: userB, skey: userA, event: true, room: roomId, mship: "invite", user: userB, skey: userA, event: true,
}) as MatrixEvent; });
const oldEv = utils.mkMembership({ const oldEv = utils.mkMembership({
room: roomId, mship: "ban", user: userB, skey: userA, event: true, room: roomId, mship: "ban", user: userB, skey: userA, event: true,
}) as MatrixEvent; });
room.addLiveEvents([newEv]); room.addLiveEvents([newEv]);
expect(newEv.target).toEqual(sentinel); expect(newEv.target).toEqual(sentinel);
room.addEventsToTimeline([oldEv], true, room.getLiveTimeline()); room.addEventsToTimeline([oldEv], true, room.getLiveTimeline());
@ -460,21 +497,23 @@ describe("Room", function() {
const events: MatrixEvent[] = [ const events: MatrixEvent[] = [
utils.mkMembership({ utils.mkMembership({
room: roomId, mship: "invite", user: userB, skey: userA, event: true, room: roomId, mship: "invite", user: userB, skey: userA, event: true,
}) as MatrixEvent, }),
utils.mkEvent({ utils.mkEvent({
type: EventType.RoomName, room: roomId, user: userB, event: true, type: EventType.RoomName, room: roomId, user: userB, event: true,
content: { content: {
name: "New room", name: "New room",
}, },
}) as MatrixEvent, }),
]; ];
room.addEventsToTimeline(events, true, room.getLiveTimeline()); room.addEventsToTimeline(events, true, room.getLiveTimeline());
expect(room.oldState.setStateEvents).toHaveBeenCalledWith( expect(room.oldState.setStateEvents).toHaveBeenCalledWith(
[events[0]], [events[0]],
{ timelineWasEmpty: undefined },
); );
expect(room.oldState.setStateEvents).toHaveBeenCalledWith( expect(room.oldState.setStateEvents).toHaveBeenCalledWith(
[events[1]], [events[1]],
{ timelineWasEmpty: undefined },
); );
expect(events[0].forwardLooking).toBe(false); expect(events[0].forwardLooking).toBe(false);
expect(events[1].forwardLooking).toBe(false); expect(events[1].forwardLooking).toBe(false);
@ -520,6 +559,23 @@ describe("Room", function() {
it("should reset the legacy timeline fields", function() { it("should reset the legacy timeline fields", function() {
room.addLiveEvents([events[0], events[1]]); room.addLiveEvents([events[0], events[1]]);
expect(room.timeline.length).toEqual(2); expect(room.timeline.length).toEqual(2);
const oldStateBeforeRunningReset = room.oldState;
let oldStateUpdateEmitCount = 0;
room.on(RoomEvent.OldStateUpdated, function(room, previousOldState, oldState) {
expect(previousOldState).toBe(oldStateBeforeRunningReset);
expect(oldState).toBe(room.oldState);
oldStateUpdateEmitCount += 1;
});
const currentStateBeforeRunningReset = room.currentState;
let currentStateUpdateEmitCount = 0;
room.on(RoomEvent.CurrentStateUpdated, function(room, previousCurrentState, currentState) {
expect(previousCurrentState).toBe(currentStateBeforeRunningReset);
expect(currentState).toBe(room.currentState);
currentStateUpdateEmitCount += 1;
});
room.resetLiveTimeline('sometoken', 'someothertoken'); room.resetLiveTimeline('sometoken', 'someothertoken');
room.addLiveEvents([events[2]]); room.addLiveEvents([events[2]]);
@ -529,6 +585,10 @@ describe("Room", function() {
newLiveTimeline.getState(EventTimeline.BACKWARDS)); newLiveTimeline.getState(EventTimeline.BACKWARDS));
expect(room.currentState).toEqual( expect(room.currentState).toEqual(
newLiveTimeline.getState(EventTimeline.FORWARDS)); newLiveTimeline.getState(EventTimeline.FORWARDS));
// Make sure `RoomEvent.OldStateUpdated` was emitted
expect(oldStateUpdateEmitCount).toEqual(1);
// Make sure `RoomEvent.OldStateUpdated` was emitted if necessary
expect(currentStateUpdateEmitCount).toEqual(timelineSupport ? 1 : 0);
}); });
it("should emit Room.timelineReset event and set the correct " + it("should emit Room.timelineReset event and set the correct " +
@ -571,13 +631,13 @@ describe("Room", function() {
const events: MatrixEvent[] = [ const events: MatrixEvent[] = [
utils.mkMessage({ utils.mkMessage({
room: roomId, user: userA, msg: "1111", event: true, room: roomId, user: userA, msg: "1111", event: true,
}) as MatrixEvent, }),
utils.mkMessage({ utils.mkMessage({
room: roomId, user: userA, msg: "2222", event: true, room: roomId, user: userA, msg: "2222", event: true,
}) as MatrixEvent, }),
utils.mkMessage({ utils.mkMessage({
room: roomId, user: userA, msg: "3333", event: true, room: roomId, user: userA, msg: "3333", event: true,
}) as MatrixEvent, }),
]; ];
it("should handle events in the same timeline", function() { it("should handle events in the same timeline", function() {
@ -718,26 +778,26 @@ describe("Room", function() {
type: EventType.RoomJoinRules, room: roomId, user: userA, content: { type: EventType.RoomJoinRules, room: roomId, user: userA, content: {
join_rule: rule, join_rule: rule,
}, event: true, }, event: true,
}) as MatrixEvent]); })]);
}; };
const setAltAliases = function(aliases: string[]) { const setAltAliases = function(aliases: string[]) {
room.addLiveEvents([utils.mkEvent({ room.addLiveEvents([utils.mkEvent({
type: EventType.RoomCanonicalAlias, room: roomId, skey: "", content: { type: EventType.RoomCanonicalAlias, room: roomId, skey: "", content: {
alt_aliases: aliases, alt_aliases: aliases,
}, event: true, }, event: true,
}) as MatrixEvent]); })]);
}; };
const setAlias = function(alias: string) { const setAlias = function(alias: string) {
room.addLiveEvents([utils.mkEvent({ room.addLiveEvents([utils.mkEvent({
type: EventType.RoomCanonicalAlias, room: roomId, skey: "", content: { alias }, event: true, type: EventType.RoomCanonicalAlias, room: roomId, skey: "", content: { alias }, event: true,
}) as MatrixEvent]); })]);
}; };
const setRoomName = function(name: string) { const setRoomName = function(name: string) {
room.addLiveEvents([utils.mkEvent({ room.addLiveEvents([utils.mkEvent({
type: EventType.RoomName, room: roomId, user: userA, content: { type: EventType.RoomName, room: roomId, user: userA, content: {
name: name, name: name,
}, event: true, }, event: true,
}) as MatrixEvent]); })]);
}; };
const addMember = function(userId: string, state = "join", opts: any = {}) { const addMember = function(userId: string, state = "join", opts: any = {}) {
opts.room = roomId; opts.room = roomId;
@ -745,7 +805,7 @@ describe("Room", function() {
opts.user = opts.user || userId; opts.user = opts.user || userId;
opts.skey = userId; opts.skey = userId;
opts.event = true; opts.event = true;
const event = utils.mkMembership(opts) as MatrixEvent; const event = utils.mkMembership(opts);
room.addLiveEvents([event]); room.addLiveEvents([event]);
return event; return event;
}; };
@ -1053,7 +1113,7 @@ describe("Room", function() {
const eventToAck = utils.mkMessage({ const eventToAck = utils.mkMessage({
room: roomId, user: userA, msg: "PLEASE ACKNOWLEDGE MY EXISTENCE", room: roomId, user: userA, msg: "PLEASE ACKNOWLEDGE MY EXISTENCE",
event: true, event: true,
}) as MatrixEvent; });
function mkReceipt(roomId: string, records) { function mkReceipt(roomId: string, records) {
const content = {}; const content = {};
@ -1119,7 +1179,7 @@ describe("Room", function() {
const nextEventToAck = utils.mkMessage({ const nextEventToAck = utils.mkMessage({
room: roomId, user: userA, msg: "I AM HERE YOU KNOW", room: roomId, user: userA, msg: "I AM HERE YOU KNOW",
event: true, event: true,
}) as MatrixEvent; });
const ts = 13787898424; const ts = 13787898424;
room.addReceipt(mkReceipt(roomId, [ room.addReceipt(mkReceipt(roomId, [
mkRecord(eventToAck.getId(), "m.read", userB, ts), mkRecord(eventToAck.getId(), "m.read", userB, ts),
@ -1154,11 +1214,11 @@ describe("Room", function() {
const eventTwo = utils.mkMessage({ const eventTwo = utils.mkMessage({
room: roomId, user: userA, msg: "2222", room: roomId, user: userA, msg: "2222",
event: true, event: true,
}) as MatrixEvent; });
const eventThree = utils.mkMessage({ const eventThree = utils.mkMessage({
room: roomId, user: userA, msg: "3333", room: roomId, user: userA, msg: "3333",
event: true, event: true,
}) as MatrixEvent; });
const ts = 13787898424; const ts = 13787898424;
room.addReceipt(mkReceipt(roomId, [ room.addReceipt(mkReceipt(roomId, [
mkRecord(eventToAck.getId(), "m.read", userB, ts), mkRecord(eventToAck.getId(), "m.read", userB, ts),
@ -1206,15 +1266,15 @@ describe("Room", function() {
utils.mkMessage({ utils.mkMessage({
room: roomId, user: userA, msg: "1111", room: roomId, user: userA, msg: "1111",
event: true, event: true,
}) as MatrixEvent, }),
utils.mkMessage({ utils.mkMessage({
room: roomId, user: userA, msg: "2222", room: roomId, user: userA, msg: "2222",
event: true, event: true,
}) as MatrixEvent, }),
utils.mkMessage({ utils.mkMessage({
room: roomId, user: userA, msg: "3333", room: roomId, user: userA, msg: "3333",
event: true, event: true,
}) as MatrixEvent, }),
]; ];
room.addLiveEvents(events); room.addLiveEvents(events);
@ -1244,15 +1304,15 @@ describe("Room", function() {
utils.mkMessage({ utils.mkMessage({
room: roomId, user: userA, msg: "1111", room: roomId, user: userA, msg: "1111",
event: true, event: true,
}) as MatrixEvent, }),
utils.mkMessage({ utils.mkMessage({
room: roomId, user: userA, msg: "2222", room: roomId, user: userA, msg: "2222",
event: true, event: true,
}) as MatrixEvent, }),
utils.mkMessage({ utils.mkMessage({
room: roomId, user: userA, msg: "3333", room: roomId, user: userA, msg: "3333",
event: true, event: true,
}) as MatrixEvent, }),
]; ];
room.addLiveEvents(events); room.addLiveEvents(events);
@ -1344,14 +1404,14 @@ describe("Room", function() {
}); });
const eventA = utils.mkMessage({ const eventA = utils.mkMessage({
room: roomId, user: userA, msg: "remote 1", event: true, room: roomId, user: userA, msg: "remote 1", event: true,
}) as MatrixEvent; });
const eventB = utils.mkMessage({ const eventB = utils.mkMessage({
room: roomId, user: userA, msg: "local 1", event: true, room: roomId, user: userA, msg: "local 1", event: true,
}) as MatrixEvent; });
eventB.status = EventStatus.SENDING; eventB.status = EventStatus.SENDING;
const eventC = utils.mkMessage({ const eventC = utils.mkMessage({
room: roomId, user: userA, msg: "remote 2", event: true, room: roomId, user: userA, msg: "remote 2", event: true,
}) as MatrixEvent; });
room.addLiveEvents([eventA]); room.addLiveEvents([eventA]);
room.addPendingEvent(eventB, "TXN1"); room.addPendingEvent(eventB, "TXN1");
room.addLiveEvents([eventC]); room.addLiveEvents([eventC]);
@ -1370,14 +1430,14 @@ describe("Room", function() {
}); });
const eventA = utils.mkMessage({ const eventA = utils.mkMessage({
room: roomId, user: userA, msg: "remote 1", event: true, room: roomId, user: userA, msg: "remote 1", event: true,
}) as MatrixEvent; });
const eventB = utils.mkMessage({ const eventB = utils.mkMessage({
room: roomId, user: userA, msg: "local 1", event: true, room: roomId, user: userA, msg: "local 1", event: true,
}) as MatrixEvent; });
eventB.status = EventStatus.SENDING; eventB.status = EventStatus.SENDING;
const eventC = utils.mkMessage({ const eventC = utils.mkMessage({
room: roomId, user: userA, msg: "remote 2", event: true, room: roomId, user: userA, msg: "remote 2", event: true,
}) as MatrixEvent; });
room.addLiveEvents([eventA]); room.addLiveEvents([eventA]);
room.addPendingEvent(eventB, "TXN1"); room.addPendingEvent(eventB, "TXN1");
room.addLiveEvents([eventC]); room.addLiveEvents([eventC]);
@ -1397,7 +1457,7 @@ describe("Room", function() {
}); });
const eventA = utils.mkMessage({ const eventA = utils.mkMessage({
room: roomId, user: userA, event: true, room: roomId, user: userA, event: true,
}) as MatrixEvent; });
eventA.status = EventStatus.SENDING; eventA.status = EventStatus.SENDING;
const eventId = eventA.getId(); const eventId = eventA.getId();
@ -1430,7 +1490,7 @@ describe("Room", function() {
const room = new Room(roomId, null, userA); const room = new Room(roomId, null, userA);
const eventA = utils.mkMessage({ const eventA = utils.mkMessage({
room: roomId, user: userA, event: true, room: roomId, user: userA, event: true,
}) as MatrixEvent; });
eventA.status = EventStatus.SENDING; eventA.status = EventStatus.SENDING;
const eventId = eventA.getId(); const eventId = eventA.getId();
@ -1495,6 +1555,8 @@ describe("Room", function() {
return Promise.resolve(); return Promise.resolve();
}, },
getSyncToken: () => "sync_token", getSyncToken: () => "sync_token",
getPendingEvents: jest.fn().mockResolvedValue([]),
setPendingEvents: jest.fn().mockResolvedValue(undefined),
}, },
}; };
} }
@ -1505,7 +1567,7 @@ describe("Room", function() {
room: roomId, room: roomId,
event: true, event: true,
name: "User A", name: "User A",
}) as MatrixEvent; });
it("should load members from server on first call", async function() { it("should load members from server on first call", async function() {
const client = createClientMock([memberEvent]); const client = createClientMock([memberEvent]);
@ -1525,7 +1587,7 @@ describe("Room", function() {
room: roomId, room: roomId,
event: true, event: true,
name: "Ms A", name: "Ms A",
}) as MatrixEvent; });
const client = createClientMock([memberEvent2], [memberEvent]); const client = createClientMock([memberEvent2], [memberEvent]);
const room = new Room(roomId, client as any, null, { lazyLoadMembers: true }); const room = new Room(roomId, client as any, null, { lazyLoadMembers: true });
@ -1596,7 +1658,7 @@ describe("Room", function() {
mship: "join", mship: "join",
room: roomId, room: roomId,
event: true, event: true,
}) as MatrixEvent]); })]);
expect(room.guessDMUserId()).toEqual(userB); expect(room.guessDMUserId()).toEqual(userB);
}); });
it("should return self if only member present", function() { it("should return self if only member present", function() {
@ -1629,11 +1691,11 @@ describe("Room", function() {
utils.mkMembership({ utils.mkMembership({
user: userA, mship: "join", user: userA, mship: "join",
room: roomId, event: true, name: "User A", room: roomId, event: true, name: "User A",
}) as MatrixEvent, }),
utils.mkMembership({ utils.mkMembership({
user: userB, mship: "join", user: userB, mship: "join",
room: roomId, event: true, name: "User B", room: roomId, event: true, name: "User B",
}) as MatrixEvent, }),
]); ]);
expect(room.getDefaultRoomName(userA)).toEqual("User B"); expect(room.getDefaultRoomName(userA)).toEqual("User B");
}); });
@ -1644,11 +1706,11 @@ describe("Room", function() {
utils.mkMembership({ utils.mkMembership({
user: userA, mship: "join", user: userA, mship: "join",
room: roomId, event: true, name: "User A", room: roomId, event: true, name: "User A",
}) as MatrixEvent, }),
utils.mkMembership({ utils.mkMembership({
user: userB, mship: "ban", user: userB, mship: "ban",
room: roomId, event: true, name: "User B", room: roomId, event: true, name: "User B",
}) as MatrixEvent, }),
]); ]);
expect(room.getDefaultRoomName(userA)).toEqual("Empty room (was User B)"); expect(room.getDefaultRoomName(userA)).toEqual("Empty room (was User B)");
}); });
@ -1659,11 +1721,11 @@ describe("Room", function() {
utils.mkMembership({ utils.mkMembership({
user: userA, mship: "join", user: userA, mship: "join",
room: roomId, event: true, name: "User A", room: roomId, event: true, name: "User A",
}) as MatrixEvent, }),
utils.mkMembership({ utils.mkMembership({
user: userB, mship: "invite", user: userB, mship: "invite",
room: roomId, event: true, name: "User B", room: roomId, event: true, name: "User B",
}) as MatrixEvent, }),
]); ]);
expect(room.getDefaultRoomName(userA)).toEqual("User B"); expect(room.getDefaultRoomName(userA)).toEqual("User B");
}); });
@ -1674,11 +1736,11 @@ describe("Room", function() {
utils.mkMembership({ utils.mkMembership({
user: userA, mship: "join", user: userA, mship: "join",
room: roomId, event: true, name: "User A", room: roomId, event: true, name: "User A",
}) as MatrixEvent, }),
utils.mkMembership({ utils.mkMembership({
user: userB, mship: "leave", user: userB, mship: "leave",
room: roomId, event: true, name: "User B", room: roomId, event: true, name: "User B",
}) as MatrixEvent, }),
]); ]);
expect(room.getDefaultRoomName(userA)).toEqual("Empty room (was User B)"); expect(room.getDefaultRoomName(userA)).toEqual("Empty room (was User B)");
}); });
@ -1689,15 +1751,15 @@ describe("Room", function() {
utils.mkMembership({ utils.mkMembership({
user: userA, mship: "join", user: userA, mship: "join",
room: roomId, event: true, name: "User A", room: roomId, event: true, name: "User A",
}) as MatrixEvent, }),
utils.mkMembership({ utils.mkMembership({
user: userB, mship: "join", user: userB, mship: "join",
room: roomId, event: true, name: "User B", room: roomId, event: true, name: "User B",
}) as MatrixEvent, }),
utils.mkMembership({ utils.mkMembership({
user: userC, mship: "join", user: userC, mship: "join",
room: roomId, event: true, name: "User C", room: roomId, event: true, name: "User C",
}) as MatrixEvent, }),
]); ]);
expect(room.getDefaultRoomName(userA)).toEqual("User B and User C"); expect(room.getDefaultRoomName(userA)).toEqual("User B and User C");
}); });
@ -1708,19 +1770,19 @@ describe("Room", function() {
utils.mkMembership({ utils.mkMembership({
user: userA, mship: "join", user: userA, mship: "join",
room: roomId, event: true, name: "User A", room: roomId, event: true, name: "User A",
}) as MatrixEvent, }),
utils.mkMembership({ utils.mkMembership({
user: userB, mship: "join", user: userB, mship: "join",
room: roomId, event: true, name: "User B", room: roomId, event: true, name: "User B",
}) as MatrixEvent, }),
utils.mkMembership({ utils.mkMembership({
user: userC, mship: "join", user: userC, mship: "join",
room: roomId, event: true, name: "User C", room: roomId, event: true, name: "User C",
}) as MatrixEvent, }),
utils.mkMembership({ utils.mkMembership({
user: userD, mship: "join", user: userD, mship: "join",
room: roomId, event: true, name: "User D", room: roomId, event: true, name: "User D",
}) as MatrixEvent, }),
]); ]);
expect(room.getDefaultRoomName(userA)).toEqual("User B and 2 others"); expect(room.getDefaultRoomName(userA)).toEqual("User B and 2 others");
}); });
@ -1733,18 +1795,18 @@ describe("Room", function() {
utils.mkMembership({ utils.mkMembership({
user: userA, mship: "join", user: userA, mship: "join",
room: roomId, event: true, name: "User A", room: roomId, event: true, name: "User A",
}) as MatrixEvent, }),
utils.mkMembership({ utils.mkMembership({
user: userB, mship: "join", user: userB, mship: "join",
room: roomId, event: true, name: "User B", room: roomId, event: true, name: "User B",
}) as MatrixEvent, }),
utils.mkEvent({ utils.mkEvent({
type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, skey: "", type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, skey: "",
room: roomId, event: true, room: roomId, event: true,
content: { content: {
service_members: [], service_members: [],
}, },
}) as MatrixEvent, }),
]); ]);
expect(room.getDefaultRoomName(userA)).toEqual("User B"); expect(room.getDefaultRoomName(userA)).toEqual("User B");
}); });
@ -1755,11 +1817,11 @@ describe("Room", function() {
utils.mkMembership({ utils.mkMembership({
user: userA, mship: "join", user: userA, mship: "join",
room: roomId, event: true, name: "User A", room: roomId, event: true, name: "User A",
}) as MatrixEvent, }),
utils.mkMembership({ utils.mkMembership({
user: userB, mship: "join", user: userB, mship: "join",
room: roomId, event: true, name: "User B", room: roomId, event: true, name: "User B",
}) as MatrixEvent, }),
utils.mkEvent({ utils.mkEvent({
type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name,
skey: "", skey: "",
@ -1768,7 +1830,7 @@ describe("Room", function() {
content: { content: {
service_members: 1, service_members: 1,
}, },
}) as MatrixEvent, }),
]); ]);
expect(room.getDefaultRoomName(userA)).toEqual("User B"); expect(room.getDefaultRoomName(userA)).toEqual("User B");
}); });
@ -1779,18 +1841,18 @@ describe("Room", function() {
utils.mkMembership({ utils.mkMembership({
user: userA, mship: "join", user: userA, mship: "join",
room: roomId, event: true, name: "User A", room: roomId, event: true, name: "User A",
}) as MatrixEvent, }),
utils.mkMembership({ utils.mkMembership({
user: userB, mship: "join", user: userB, mship: "join",
room: roomId, event: true, name: "User B", room: roomId, event: true, name: "User B",
}) as MatrixEvent, }),
utils.mkEvent({ utils.mkEvent({
type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, skey: "", type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, skey: "",
room: roomId, event: true, room: roomId, event: true,
content: { content: {
service_members: userB, service_members: userB,
}, },
}) as MatrixEvent, }),
]); ]);
expect(room.getDefaultRoomName(userA)).toEqual("User B"); expect(room.getDefaultRoomName(userA)).toEqual("User B");
}); });
@ -1801,18 +1863,18 @@ describe("Room", function() {
utils.mkMembership({ utils.mkMembership({
user: userA, mship: "join", user: userA, mship: "join",
room: roomId, event: true, name: "User A", room: roomId, event: true, name: "User A",
}) as MatrixEvent, }),
utils.mkMembership({ utils.mkMembership({
user: userB, mship: "join", user: userB, mship: "join",
room: roomId, event: true, name: "User B", room: roomId, event: true, name: "User B",
}) as MatrixEvent, }),
utils.mkEvent({ utils.mkEvent({
type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, skey: "", type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, skey: "",
room: roomId, event: true, room: roomId, event: true,
content: { content: {
service_members: [userB], service_members: [userB],
}, },
}) as MatrixEvent, }),
]); ]);
expect(room.getDefaultRoomName(userA)).toEqual("Empty room"); expect(room.getDefaultRoomName(userA)).toEqual("Empty room");
}); });
@ -1823,22 +1885,22 @@ describe("Room", function() {
utils.mkMembership({ utils.mkMembership({
user: userA, mship: "join", user: userA, mship: "join",
room: roomId, event: true, name: "User A", room: roomId, event: true, name: "User A",
}) as MatrixEvent, }),
utils.mkMembership({ utils.mkMembership({
user: userB, mship: "join", user: userB, mship: "join",
room: roomId, event: true, name: "User B", room: roomId, event: true, name: "User B",
}) as MatrixEvent, }),
utils.mkMembership({ utils.mkMembership({
user: userC, mship: "join", user: userC, mship: "join",
room: roomId, event: true, name: "User C", room: roomId, event: true, name: "User C",
}) as MatrixEvent, }),
utils.mkEvent({ utils.mkEvent({
type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, skey: "", type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, skey: "",
room: roomId, event: true, user: userA, room: roomId, event: true, user: userA,
content: { content: {
service_members: [userC], service_members: [userC],
}, },
}) as MatrixEvent, }),
]); ]);
expect(room.getDefaultRoomName(userA)).toEqual("User B"); expect(room.getDefaultRoomName(userA)).toEqual("User B");
}); });
@ -1849,22 +1911,22 @@ describe("Room", function() {
utils.mkMembership({ utils.mkMembership({
user: userA, mship: "join", user: userA, mship: "join",
room: roomId, event: true, name: "User A", room: roomId, event: true, name: "User A",
}) as MatrixEvent, }),
utils.mkMembership({ utils.mkMembership({
user: userB, mship: "join", user: userB, mship: "join",
room: roomId, event: true, name: "User B", room: roomId, event: true, name: "User B",
}) as MatrixEvent, }),
utils.mkMembership({ utils.mkMembership({
user: userC, mship: "join", user: userC, mship: "join",
room: roomId, event: true, name: "User C", room: roomId, event: true, name: "User C",
}) as MatrixEvent, }),
utils.mkEvent({ utils.mkEvent({
type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, skey: "", type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, skey: "",
room: roomId, event: true, user: userA, room: roomId, event: true, user: userA,
content: { content: {
service_members: [userB, userC], service_members: [userB, userC],
}, },
}) as MatrixEvent, }),
]); ]);
expect(room.getDefaultRoomName(userA)).toEqual("Empty room"); expect(room.getDefaultRoomName(userA)).toEqual("Empty room");
}); });
@ -1875,18 +1937,18 @@ describe("Room", function() {
utils.mkMembership({ utils.mkMembership({
user: userA, mship: "join", user: userA, mship: "join",
room: roomId, event: true, name: "User A", room: roomId, event: true, name: "User A",
}) as MatrixEvent, }),
utils.mkMembership({ utils.mkMembership({
user: userB, mship: "join", user: userB, mship: "join",
room: roomId, event: true, name: "User B", room: roomId, event: true, name: "User B",
}) as MatrixEvent, }),
utils.mkEvent({ utils.mkEvent({
type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, skey: "", type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, skey: "",
room: roomId, event: true, user: userA, room: roomId, event: true, user: userA,
content: { content: {
service_members: [userC], service_members: [userC],
}, },
}) as MatrixEvent, }),
]); ]);
expect(room.getDefaultRoomName(userA)).toEqual("User B"); expect(room.getDefaultRoomName(userA)).toEqual("User B");
}); });
@ -1937,6 +1999,15 @@ describe("Room", function() {
expect(() => room.createThread(rootEvent.getId(), rootEvent, [])).not.toThrow(); expect(() => room.createThread(rootEvent.getId(), rootEvent, [])).not.toThrow();
}); });
it("creating thread from edited event should not conflate old versions of the event", () => {
const message = mkMessage();
const edit = mkEdit(message);
message.makeReplaced(edit);
const thread = room.createThread("$000", message, [], true);
expect(thread).toHaveLength(0);
});
it("Edits update the lastReply event", async () => { it("Edits update the lastReply event", async () => {
room.client.supportsExperimentalThreads = () => true; room.client.supportsExperimentalThreads = () => true;
@ -2036,17 +2107,15 @@ describe("Room", function() {
}, },
}); });
let prom = emitPromise(room, ThreadEvent.New); const prom = emitPromise(room, ThreadEvent.New);
room.addLiveEvents([threadRoot, threadResponse1, threadResponse2, threadResponse2Reaction]); room.addLiveEvents([threadRoot, threadResponse1, threadResponse2, threadResponse2Reaction]);
const thread = await prom; const thread = await prom;
expect(thread).toHaveLength(2); expect(thread).toHaveLength(2);
expect(thread.replyToEvent.getId()).toBe(threadResponse2.getId()); expect(thread.replyToEvent.getId()).toBe(threadResponse2.getId());
prom = emitPromise(thread, ThreadEvent.Update);
const threadResponse2ReactionRedaction = mkRedaction(threadResponse2Reaction); const threadResponse2ReactionRedaction = mkRedaction(threadResponse2Reaction);
room.addLiveEvents([threadResponse2ReactionRedaction]); room.addLiveEvents([threadResponse2ReactionRedaction]);
await prom;
expect(thread).toHaveLength(2); expect(thread).toHaveLength(2);
expect(thread.replyToEvent.getId()).toBe(threadResponse2.getId()); expect(thread.replyToEvent.getId()).toBe(threadResponse2.getId());
}); });
@ -2267,7 +2336,7 @@ describe("Room", function() {
const thread = threadRoot.getThread(); const thread = threadRoot.getThread();
expect(thread.rootEvent).toBe(threadRoot); expect(thread.rootEvent).toBe(threadRoot);
const rootRelations = thread.timelineSet.getRelationsForEvent( const rootRelations = thread.timelineSet.relations.getChildEventsForEvent(
threadRoot.getId(), threadRoot.getId(),
RelationType.Annotation, RelationType.Annotation,
EventType.Reaction, EventType.Reaction,
@ -2277,7 +2346,7 @@ describe("Room", function() {
expect(rootRelations[0][1].size).toEqual(1); expect(rootRelations[0][1].size).toEqual(1);
expect(rootRelations[0][1].has(rootReaction)).toBeTruthy(); expect(rootRelations[0][1].has(rootReaction)).toBeTruthy();
const responseRelations = thread.timelineSet.getRelationsForEvent( const responseRelations = thread.timelineSet.relations.getChildEventsForEvent(
threadResponse.getId(), threadResponse.getId(),
RelationType.Annotation, RelationType.Annotation,
EventType.Reaction, EventType.Reaction,

View File

@ -0,0 +1,114 @@
/*
Copyright 2022 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 'fake-indexeddb/auto';
import 'jest-localstorage-mock';
import { IndexedDBStore, IStateEventWithRoomId, MemoryStore } from "../../../src";
import { emitPromise } from "../../test-utils/test-utils";
import { LocalIndexedDBStoreBackend } from "../../../src/store/indexeddb-local-backend";
describe("IndexedDBStore", () => {
afterEach(() => {
jest.clearAllMocks();
});
const roomId = "!room:id";
it("should degrade to MemoryStore on IDB errors", async () => {
const store = new IndexedDBStore({
indexedDB: indexedDB,
dbName: "database",
localStorage,
});
await store.startup();
const member1: IStateEventWithRoomId = {
room_id: roomId,
event_id: "!ev1:id",
sender: "@user1:id",
state_key: "@user1:id",
type: "m.room.member",
origin_server_ts: 123,
content: {},
};
const member2: IStateEventWithRoomId = {
room_id: roomId,
event_id: "!ev2:id",
sender: "@user2:id",
state_key: "@user2:id",
type: "m.room.member",
origin_server_ts: 123,
content: {},
};
expect(await store.getOutOfBandMembers(roomId)).toBe(null);
await store.setOutOfBandMembers(roomId, [member1]);
expect(await store.getOutOfBandMembers(roomId)).toHaveLength(1);
// Simulate a broken IDB
(store.backend as LocalIndexedDBStoreBackend)["db"].transaction = (): IDBTransaction => {
const err = new Error("Failed to execute 'transaction' on 'IDBDatabase': " +
"The database connection is closing.");
err.name = "InvalidStateError";
throw err;
};
expect(await store.getOutOfBandMembers(roomId)).toHaveLength(1);
await Promise.all([
emitPromise(store["emitter"], "degraded"),
store.setOutOfBandMembers(roomId, [member1, member2]),
]);
expect(await store.getOutOfBandMembers(roomId)).toHaveLength(2);
});
it("should use MemoryStore methods for pending events if no localStorage", async () => {
jest.spyOn(MemoryStore.prototype, "setPendingEvents");
jest.spyOn(MemoryStore.prototype, "getPendingEvents");
const store = new IndexedDBStore({
indexedDB: indexedDB,
dbName: "database",
localStorage: undefined,
});
const events = [{ type: "test" }];
await store.setPendingEvents(roomId, events);
expect(MemoryStore.prototype.setPendingEvents).toHaveBeenCalledWith(roomId, events);
await expect(store.getPendingEvents(roomId)).resolves.toEqual(events);
expect(MemoryStore.prototype.getPendingEvents).toHaveBeenCalledWith(roomId);
});
it("should persist pending events to localStorage if available", async () => {
jest.spyOn(MemoryStore.prototype, "setPendingEvents");
jest.spyOn(MemoryStore.prototype, "getPendingEvents");
const store = new IndexedDBStore({
indexedDB: indexedDB,
dbName: "database",
localStorage,
});
await expect(store.getPendingEvents(roomId)).resolves.toEqual([]);
const events = [{ type: "test" }];
await store.setPendingEvents(roomId, events);
expect(MemoryStore.prototype.setPendingEvents).not.toHaveBeenCalled();
await expect(store.getPendingEvents(roomId)).resolves.toEqual(events);
expect(MemoryStore.prototype.getPendingEvents).not.toHaveBeenCalled();
expect(localStorage.getItem("mx_pending_events_" + roomId)).toBe(JSON.stringify(events));
await store.setPendingEvents(roomId, []);
expect(localStorage.getItem("mx_pending_events_" + roomId)).toBeNull();
});
});

View File

@ -35,13 +35,14 @@ function createTimeline(numEvents, baseIndex) {
return timeline; return timeline;
} }
function addEventsToTimeline(timeline, numEvents, atStart) { function addEventsToTimeline(timeline, numEvents, toStartOfTimeline) {
for (let i = 0; i < numEvents; i++) { for (let i = 0; i < numEvents; i++) {
timeline.addEvent( timeline.addEvent(
utils.mkMessage({ utils.mkMessage({
room: ROOM_ID, user: USER_ID, room: ROOM_ID, user: USER_ID,
event: true, event: true,
}), atStart, }),
{ toStartOfTimeline },
); );
} }
} }

View File

@ -15,7 +15,7 @@ limitations under the License.
*/ */
import { TestClient } from '../../TestClient'; import { TestClient } from '../../TestClient';
import { MatrixCall, CallErrorCode, CallEvent } from '../../../src/webrtc/call'; import { MatrixCall, CallErrorCode, CallEvent, supportsMatrixCall } from '../../../src/webrtc/call';
import { SDPStreamMetadataKey, SDPStreamMetadataPurpose } from '../../../src/webrtc/callEventTypes'; import { SDPStreamMetadataKey, SDPStreamMetadataPurpose } from '../../../src/webrtc/callEventTypes';
import { RoomMember } from "../../../src"; import { RoomMember } from "../../../src";
@ -525,4 +525,40 @@ describe('Call', function() {
return sender?.track?.kind === "video"; return sender?.track?.kind === "video";
}).track.id).toBe("video_track"); }).track.id).toBe("video_track");
}); });
describe("supportsMatrixCall", () => {
it("should return true when the environment is right", () => {
expect(supportsMatrixCall()).toBe(true);
});
it("should return false if window or document are undefined", () => {
global.window = undefined;
expect(supportsMatrixCall()).toBe(false);
global.window = prevWindow;
global.document = undefined;
expect(supportsMatrixCall()).toBe(false);
});
it("should return false if RTCPeerConnection throws", () => {
// @ts-ignore - writing to window as we are simulating browser edge-cases
global.window = {};
Object.defineProperty(global.window, "RTCPeerConnection", {
get: () => {
throw Error("Secure mode, naaah!");
},
});
expect(supportsMatrixCall()).toBe(false);
});
it("should return false if RTCPeerConnection & RTCSessionDescription " +
"& RTCIceCandidate & mediaDevices are unavailable",
() => {
global.window.RTCPeerConnection = undefined;
global.window.RTCSessionDescription = undefined;
global.window.RTCIceCandidate = undefined;
// @ts-ignore - writing to a read-only property as we are simulating faulty browsers
global.navigator.mediaDevices = undefined;
expect(supportsMatrixCall()).toBe(false);
});
});
}); });

View File

@ -1,22 +1,52 @@
/*
Copyright 2022 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 { TestClient } from '../../TestClient'; import { TestClient } from '../../TestClient';
import { CallEventHandler } from '../../../src/webrtc/callEventHandler'; import {
import { MatrixEvent } from '../../../src/models/event'; ClientEvent,
import { EventType } from '../../../src/@types/event'; EventTimeline,
EventTimelineSet,
EventType,
IRoomTimelineData,
MatrixEvent,
Room,
RoomEvent,
} from "../../../src";
import { MatrixClient } from "../../../src/client";
import { CallEventHandler, CallEventHandlerEvent } from "../../../src/webrtc/callEventHandler";
import { GroupCallEventHandler } from "../../../src/webrtc/groupCallEventHandler";
import { SyncState } from "../../../src/sync";
describe('CallEventHandler', function() { describe("CallEventHandler", () => {
let client; let client: MatrixClient;
beforeEach(() => {
beforeEach(function() { client = new TestClient("@alice:foo", "somedevice", "token", undefined, {}).client;
client = new TestClient("@alice:foo", "somedevice", "token", undefined, {}); client.callEventHandler = new CallEventHandler(client);
client.callEventHandler.start();
client.groupCallEventHandler = new GroupCallEventHandler(client);
client.groupCallEventHandler.start();
}); });
afterEach(function() { afterEach(() => {
client.stop(); client.callEventHandler.stop();
client.groupCallEventHandler.stop();
}); });
it('should enforce inbound toDevice message ordering', async function() { it("should enforce inbound toDevice message ordering", async () => {
const callEventHandler = new CallEventHandler(client); const callEventHandler = client.callEventHandler;
const event1 = new MatrixEvent({ const event1 = new MatrixEvent({
type: EventType.CallInvite, type: EventType.CallInvite,
content: { content: {
@ -80,4 +110,34 @@ describe('CallEventHandler', function() {
expect(callEventHandler.nextSeqByCall.get("123")).toBe(5); expect(callEventHandler.nextSeqByCall.get("123")).toBe(5);
expect(callEventHandler.toDeviceEventBuffers.get("123").length).toBe(0); expect(callEventHandler.toDeviceEventBuffers.get("123").length).toBe(0);
}); });
it("should ignore a call if invite & hangup come within a single sync", () => {
const room = new Room("!room:id", client, "@user:id");
const timelineData: IRoomTimelineData = { timeline: new EventTimeline(new EventTimelineSet(room, {})) };
// Fire off call invite then hangup within a single sync
const callInvite = new MatrixEvent({
type: EventType.CallInvite,
content: {
call_id: "123",
},
});
client.emit(RoomEvent.Timeline, callInvite, room, false, false, timelineData);
const callHangup = new MatrixEvent({
type: EventType.CallHangup,
content: {
call_id: "123",
},
});
client.emit(RoomEvent.Timeline, callHangup, room, false, false, timelineData);
const incomingCallEmitted = jest.fn();
client.on(CallEventHandlerEvent.Incoming, incomingCallEmitted);
client.getSyncState = jest.fn().mockReturnValue(SyncState.Syncing);
client.emit(ClientEvent.Sync, SyncState.Syncing);
expect(incomingCallEmitted).not.toHaveBeenCalled();
});
}); });

View File

@ -155,6 +155,14 @@ export const UNSTABLE_MSC3089_LEAF = new UnstableValue("m.leaf", "org.matrix.msc
*/ */
export const UNSTABLE_MSC3089_BRANCH = new UnstableValue("m.branch", "org.matrix.msc3089.branch"); export const UNSTABLE_MSC3089_BRANCH = new UnstableValue("m.branch", "org.matrix.msc3089.branch");
/**
* Marker event type to point back at imported historical content in a room. See
* [MSC2716](https://github.com/matrix-org/matrix-spec-proposals/pull/2716).
* Note that this reference is UNSTABLE and subject to breaking changes,
* including its eventual removal.
*/
export const UNSTABLE_MSC2716_MARKER = new UnstableValue("m.room.marker", "org.matrix.msc2716.marker");
/** /**
* Functional members type for declaring a purpose of room members (e.g. helpful bots). * Functional members type for declaring a purpose of room members (e.g. helpful bots).
* Note that this reference is UNSTABLE and subject to breaking changes, including its * Note that this reference is UNSTABLE and subject to breaking changes, including its

View File

@ -17,9 +17,12 @@ limitations under the License.
import { Callback } from "../client"; import { Callback } from "../client";
import { IContent, IEvent } from "../models/event"; import { IContent, IEvent } from "../models/event";
import { Preset, Visibility } from "./partials"; import { Preset, Visibility } from "./partials";
import { SearchKey } from "./search"; import { IEventWithRoomId, SearchKey } from "./search";
import { IRoomEventFilter } from "../filter"; import { IRoomEventFilter } from "../filter";
import { Direction } from "../models/event-timeline"; import { Direction } from "../models/event-timeline";
import { PushRuleAction } from "./PushRules";
import { IRoomEvent } from "../sync-accumulator";
import { RoomType } from "./event";
// allow camelcase as these are things that go onto the wire // allow camelcase as these are things that go onto the wire
/* eslint-disable camelcase */ /* eslint-disable camelcase */
@ -109,7 +112,8 @@ export interface IRoomDirectoryOptions {
limit?: number; limit?: number;
since?: string; since?: string;
filter?: { filter?: {
generic_search_term: string; generic_search_term?: string;
"org.matrix.msc3827.room_types"?: Array<RoomType | null>;
}; };
include_all_networks?: boolean; include_all_networks?: boolean;
third_party_instance_id?: string; third_party_instance_id?: string;
@ -155,4 +159,50 @@ export interface IRelationsResponse {
prev_batch?: string; prev_batch?: string;
} }
export interface IContextResponse {
end: string;
start: string;
state: IEventWithRoomId[];
events_before: IEventWithRoomId[];
events_after: IEventWithRoomId[];
event: IEventWithRoomId;
}
export interface IEventsResponse {
chunk: IEventWithRoomId[];
end: string;
start: string;
}
export interface INotification {
actions: PushRuleAction[];
event: IRoomEvent;
profile_tag?: string;
read: boolean;
room_id: string;
ts: number;
}
export interface INotificationsResponse {
next_token: string;
notifications: INotification[];
}
export interface IFilterResponse {
filter_id: string;
}
export interface ITagsResponse {
tags: {
[tagId: string]: {
order: number;
};
};
}
export interface IStatusResponse extends IPresenceOpts {
currently_active?: boolean;
last_active_ago?: number;
}
/* eslint-enable camelcase */ /* eslint-enable camelcase */

62
src/@types/topic.ts Normal file
View File

@ -0,0 +1,62 @@
/*
Copyright 2022 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 { EitherAnd, IMessageRendering } from "matrix-events-sdk";
import { UnstableValue } from "../NamespacedValue";
/**
* Extensible topic event type based on MSC3765
* https://github.com/matrix-org/matrix-spec-proposals/pull/3765
*/
/**
* Eg
* {
* "type": "m.room.topic,
* "state_key": "",
* "content": {
* "topic": "All about **pizza**",
* "m.topic": [{
* "body": "All about **pizza**",
* "mimetype": "text/plain",
* }, {
* "body": "All about <b>pizza</b>",
* "mimetype": "text/html",
* }],
* }
* }
*/
/**
* The event type for an m.topic event (in content)
*/
export const M_TOPIC = new UnstableValue("m.topic", "org.matrix.msc3765.topic");
/**
* The event content for an m.topic event (in content)
*/
export type MTopicContent = IMessageRendering[];
/**
* The event definition for an m.topic event (in content)
*/
export type MTopicEvent = EitherAnd<{ [M_TOPIC.name]: MTopicContent }, { [M_TOPIC.altName]: MTopicContent }>;
/**
* The event content for an m.room.topic event
*/
export type MRoomTopicEventContent = { topic: string } & MTopicEvent;

View File

@ -17,8 +17,6 @@ limitations under the License.
/** @module auto-discovery */ /** @module auto-discovery */
import { URL as NodeURL } from "url";
import { IClientWellKnown, IWellKnownConfig } from "./client"; import { IClientWellKnown, IWellKnownConfig } from "./client";
import { logger } from './logger'; import { logger } from './logger';
@ -372,16 +370,11 @@ export class AutoDiscovery {
if (!url) return false; if (!url) return false;
try { try {
// We have to try and parse the URL using the NodeJS URL
// library if we're on NodeJS and use the browser's URL
// library when we're in a browser. To accomplish this, we
// try the NodeJS version first and fall back to the browser.
let parsed = null; let parsed = null;
try { try {
if (NodeURL) parsed = new NodeURL(url);
else parsed = new URL(url);
} catch (e) {
parsed = new URL(url); parsed = new URL(url);
} catch (e) {
logger.error("Could not parse url", e);
} }
if (!parsed || !parsed.hostname) return false; if (!parsed || !parsed.hostname) return false;

View File

@ -32,7 +32,7 @@ import {
MatrixEventHandlerMap, MatrixEventHandlerMap,
} from "./models/event"; } from "./models/event";
import { StubStore } from "./store/stub"; import { StubStore } from "./store/stub";
import { CallEvent, CallEventHandlerMap, createNewMatrixCall, MatrixCall } from "./webrtc/call"; import { CallEvent, CallEventHandlerMap, createNewMatrixCall, MatrixCall, supportsMatrixCall } from "./webrtc/call";
import { Filter, IFilterDefinition } from "./filter"; import { Filter, IFilterDefinition } from "./filter";
import { CallEventHandlerEvent, CallEventHandler, CallEventHandlerEventHandlerMap } from './webrtc/callEventHandler'; import { CallEventHandlerEvent, CallEventHandler, CallEventHandlerEventHandlerMap } from './webrtc/callEventHandler';
import { GroupCallEventHandlerEvent, GroupCallEventHandlerEventHandlerMap } from './webrtc/groupCallEventHandler'; import { GroupCallEventHandlerEvent, GroupCallEventHandlerEventHandlerMap } from './webrtc/groupCallEventHandler';
@ -62,6 +62,7 @@ import {
PREFIX_R0, PREFIX_R0,
PREFIX_UNSTABLE, PREFIX_UNSTABLE,
PREFIX_V1, PREFIX_V1,
PREFIX_V3,
retryNetworkOperation, retryNetworkOperation,
UploadContentResponseType, UploadContentResponseType,
} from "./http-api"; } from "./http-api";
@ -114,6 +115,13 @@ import {
RoomMemberEventHandlerMap, RoomMemberEventHandlerMap,
RoomStateEvent, RoomStateEvent,
RoomStateEventHandlerMap, RoomStateEventHandlerMap,
INotificationsResponse,
IFilterResponse,
ITagsResponse,
IStatusResponse,
IPushRule,
PushRuleActionName,
IAuthDict,
} from "./matrix"; } from "./matrix";
import { import {
CrossSigningKey, CrossSigningKey,
@ -133,6 +141,7 @@ import { Room } from "./models/room";
import { import {
IAddThreePidOnlyBody, IAddThreePidOnlyBody,
IBindThreePidBody, IBindThreePidBody,
IContextResponse,
ICreateRoomOpts, ICreateRoomOpts,
IEventSearchOpts, IEventSearchOpts,
IGuestAccessOpts, IGuestAccessOpts,
@ -160,7 +169,6 @@ import {
import { IAbortablePromise, IdServerUnbindResult, IImageInfo, Preset, Visibility } from "./@types/partials"; import { IAbortablePromise, IdServerUnbindResult, IImageInfo, Preset, Visibility } from "./@types/partials";
import { EventMapper, eventMapperFor, MapperOpts } from "./event-mapper"; import { EventMapper, eventMapperFor, MapperOpts } from "./event-mapper";
import { randomString } from "./randomstring"; import { randomString } from "./randomstring";
import { WebStorageSessionStore } from "./store/session/webstorage";
import { BackupManager, IKeyBackup, IKeyBackupCheck, IPreparedKeyBackupVersion, TrustInfo } from "./crypto/backup"; import { BackupManager, IKeyBackup, IKeyBackupCheck, IPreparedKeyBackupVersion, TrustInfo } from "./crypto/backup";
import { DEFAULT_TREE_POWER_LEVELS_TEMPLATE, MSC3089TreeSpace } from "./models/MSC3089TreeSpace"; import { DEFAULT_TREE_POWER_LEVELS_TEMPLATE, MSC3089TreeSpace } from "./models/MSC3089TreeSpace";
import { ISignatures } from "./@types/signed"; import { ISignatures } from "./@types/signed";
@ -194,7 +202,6 @@ import { Thread, THREAD_RELATION_TYPE } from "./models/thread";
import { MBeaconInfoEventContent, M_BEACON_INFO } from "./@types/beacon"; import { MBeaconInfoEventContent, M_BEACON_INFO } from "./@types/beacon";
export type Store = IStore; export type Store = IStore;
export type SessionStore = WebStorageSessionStore;
export type Callback<T = any> = (err: Error | any | null, data?: T) => void; export type Callback<T = any> = (err: Error | any | null, data?: T) => void;
export type ResetTimelineCallback = (roomId: string) => boolean; export type ResetTimelineCallback = (roomId: string) => boolean;
@ -314,21 +321,6 @@ export interface ICreateClientOpts {
*/ */
pickleKey?: string; pickleKey?: string;
/**
* A store to be used for end-to-end crypto session data. Most data has been
* migrated out of here to `cryptoStore` instead. If not specified,
* end-to-end crypto will be disabled. The `createClient` helper
* _will not_ create this store at the moment.
*/
sessionStore?: SessionStore;
/**
* Set to true to enable client-side aggregation of event relations
* via `EventTimelineSet#getRelationsForEvent`.
* This feature is currently unstable and the API may change without notice.
*/
unstableClientRelationAggregation?: boolean;
verificationMethods?: Array<VerificationMethod>; verificationMethods?: Array<VerificationMethod>;
/** /**
@ -589,13 +581,9 @@ export interface IRequestMsisdnTokenResponse extends IRequestTokenResponse {
intl_fmt: string; intl_fmt: string;
} }
interface IUploadKeysRequest { export interface IUploadKeysRequest {
device_keys?: Required<IDeviceKeys>; device_keys?: Required<IDeviceKeys>;
one_time_keys?: { one_time_keys?: Record<string, IOneTimeKey>;
[userId: string]: {
[deviceId: string]: number;
};
};
"org.matrix.msc2732.fallback_keys"?: Record<string, IOneTimeKey>; "org.matrix.msc2732.fallback_keys"?: Record<string, IOneTimeKey>;
} }
@ -633,6 +621,19 @@ interface IJoinedMembersResponse {
}; };
} }
export interface IRegisterRequestParams {
auth?: IAuthData;
username?: string;
password?: string;
refresh_token?: boolean;
guest_access_token?: string;
x_show_msisdn?: boolean;
bind_msisdn?: boolean;
bind_email?: boolean;
inhibit_login?: boolean;
initial_device_display_name?: string;
}
export interface IPublicRoomsChunkRoom { export interface IPublicRoomsChunkRoom {
room_id: string; room_id: string;
name?: string; name?: string;
@ -802,6 +803,7 @@ type RoomEvents = RoomEvent.Name
| RoomEvent.Receipt | RoomEvent.Receipt
| RoomEvent.Tags | RoomEvent.Tags
| RoomEvent.LocalEchoUpdated | RoomEvent.LocalEchoUpdated
| RoomEvent.HistoryImportedWithinTimeline
| RoomEvent.AccountData | RoomEvent.AccountData
| RoomEvent.MyMembership | RoomEvent.MyMembership
| RoomEvent.Timeline | RoomEvent.Timeline
@ -811,6 +813,7 @@ type RoomStateEvents = RoomStateEvent.Events
| RoomStateEvent.Members | RoomStateEvent.Members
| RoomStateEvent.NewMember | RoomStateEvent.NewMember
| RoomStateEvent.Update | RoomStateEvent.Update
| RoomStateEvent.Marker
; ;
type CryptoEvents = CryptoEvent.KeySignatureUploadFailure type CryptoEvents = CryptoEvent.KeySignatureUploadFailure
@ -897,9 +900,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
public clientRunning = false; public clientRunning = false;
public timelineSupport = false; public timelineSupport = false;
public urlPreviewCache: { [key: string]: Promise<IPreviewUrlResponse> } = {}; public urlPreviewCache: { [key: string]: Promise<IPreviewUrlResponse> } = {};
public unstableClientRelationAggregation = false;
public identityServer: IIdentityServerProvider; public identityServer: IIdentityServerProvider;
public sessionStore: SessionStore; // XXX: Intended private, used in code.
public http: MatrixHttpApi; // XXX: Intended private, used in code. public http: MatrixHttpApi; // XXX: Intended private, used in code.
public crypto: Crypto; // XXX: Intended private, used in code. public crypto: Crypto; // XXX: Intended private, used in code.
public cryptoCallbacks: ICryptoCallbacks; // XXX: Intended private, used in code. public cryptoCallbacks: ICryptoCallbacks; // XXX: Intended private, used in code.
@ -1022,10 +1023,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
}); });
} }
// try constructing a MatrixCall to see if we are running in an environment if (supportsMatrixCall()) {
// which has WebRTC. If we are, listen for and handle m.call.* events.
const call = createNewMatrixCall(this, undefined, undefined);
if (call) {
this.callEventHandler = new CallEventHandler(this); this.callEventHandler = new CallEventHandler(this);
this.groupCallEventHandler = new GroupCallEventHandler(this); this.groupCallEventHandler = new GroupCallEventHandler(this);
this.canSupportVoip = true; this.canSupportVoip = true;
@ -1036,10 +1034,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
} }
this.timelineSupport = Boolean(opts.timelineSupport); this.timelineSupport = Boolean(opts.timelineSupport);
this.unstableClientRelationAggregation = !!opts.unstableClientRelationAggregation;
this.cryptoStore = opts.cryptoStore; this.cryptoStore = opts.cryptoStore;
this.sessionStore = opts.sessionStore;
this.verificationMethods = opts.verificationMethods; this.verificationMethods = opts.verificationMethods;
this.cryptoCallbacks = opts.cryptoCallbacks || {}; this.cryptoCallbacks = opts.cryptoCallbacks || {};
@ -1219,6 +1215,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* clean shutdown. * clean shutdown.
*/ */
public stopClient() { public stopClient() {
this.crypto?.stop(); // crypto might have been initialised even if the client wasn't fully started
if (!this.clientRunning) return; // already stopped
logger.log('stopping MatrixClient'); logger.log('stopping MatrixClient');
this.clientRunning = false; this.clientRunning = false;
@ -1226,7 +1226,6 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
this.syncApi?.stop(); this.syncApi?.stop();
this.syncApi = null; this.syncApi = null;
this.crypto?.stop();
this.peekSync?.stopPeeking(); this.peekSync?.stopPeeking();
this.callEventHandler?.stop(); this.callEventHandler?.stop();
@ -1715,10 +1714,6 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
return; return;
} }
if (!this.sessionStore) {
// this is temporary, the sessionstore is supposed to be going away
throw new Error(`Cannot enable encryption: no sessionStore provided`);
}
if (!this.cryptoStore) { if (!this.cryptoStore) {
// the cryptostore is provided by sdk.createClient, so this shouldn't happen // the cryptostore is provided by sdk.createClient, so this shouldn't happen
throw new Error(`Cannot enable encryption: no cryptoStore provided`); throw new Error(`Cannot enable encryption: no cryptoStore provided`);
@ -1747,8 +1742,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
const crypto = new Crypto( const crypto = new Crypto(
this, this,
this.sessionStore, userId,
userId, this.deviceId, this.deviceId,
this.store, this.store,
this.cryptoStore, this.cryptoStore,
this.roomList, this.roomList,
@ -2460,18 +2455,15 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* The Secure Secret Storage API is currently UNSTABLE and may change without notice. * The Secure Secret Storage API is currently UNSTABLE and may change without notice.
* *
* @param {string} name the name of the secret * @param {string} name the name of the secret
* @param {boolean} checkKey check if the secret is encrypted by a trusted
* key
*
* @return {object?} map of key name to key info the secret is encrypted * @return {object?} map of key name to key info the secret is encrypted
* with, or null if it is not present or not encrypted with a trusted * with, or null if it is not present or not encrypted with a trusted
* key * key
*/ */
public isSecretStored(name: string, checkKey: boolean): Promise<Record<string, ISecretStorageKeyInfo> | null> { public isSecretStored(name: string): Promise<Record<string, ISecretStorageKeyInfo> | null> {
if (!this.crypto) { if (!this.crypto) {
throw new Error("End-to-end encryption disabled"); throw new Error("End-to-end encryption disabled");
} }
return this.crypto.isSecretStored(name, checkKey); return this.crypto.isSecretStored(name);
} }
/** /**
@ -2800,7 +2792,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* trusted key * trusted key
*/ */
public isKeyBackupKeyStored(): Promise<Record<string, ISecretStorageKeyInfo> | null> { public isKeyBackupKeyStored(): Promise<Record<string, ISecretStorageKeyInfo> | null> {
return Promise.resolve(this.isSecretStored("m.megolm_backup.v1", false /* checkKey */)); return Promise.resolve(this.isSecretStored("m.megolm_backup.v1"));
} }
/** /**
@ -3625,28 +3617,45 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
/** /**
* @param {string} roomId * @param {string} roomId
* @param {string} topic * @param {string} topic
* @param {string} htmlTopic Optional.
* @param {module:client.callback} callback Optional. * @param {module:client.callback} callback Optional.
* @return {Promise} Resolves: TODO * @return {Promise} Resolves: TODO
* @return {module:http-api.MatrixError} Rejects: with an error response. * @return {module:http-api.MatrixError} Rejects: with an error response.
*/ */
public setRoomTopic(roomId: string, topic: string, callback?: Callback): Promise<ISendEventResponse> { public setRoomTopic(
return this.sendStateEvent(roomId, EventType.RoomTopic, { topic: topic }, undefined, callback); roomId: string,
topic: string,
htmlTopic?: string,
): Promise<ISendEventResponse>;
public setRoomTopic(
roomId: string,
topic: string,
callback?: Callback,
): Promise<ISendEventResponse>;
public setRoomTopic(
roomId: string,
topic: string,
htmlTopicOrCallback?: string | Callback,
): Promise<ISendEventResponse> {
const isCallback = typeof htmlTopicOrCallback === 'function';
const htmlTopic = isCallback ? undefined : htmlTopicOrCallback;
const callback = isCallback ? htmlTopicOrCallback : undefined;
const content = ContentHelpers.makeTopicContent(topic, htmlTopic);
return this.sendStateEvent(roomId, EventType.RoomTopic, content, undefined, callback);
} }
/** /**
* @param {string} roomId * @param {string} roomId
* @param {module:client.callback} callback Optional. * @param {module:client.callback} callback Optional.
* @return {Promise} Resolves: TODO * @return {Promise} Resolves: to an object keyed by tagId with objects containing a numeric order field.
* @return {module:http-api.MatrixError} Rejects: with an error response. * @return {module:http-api.MatrixError} Rejects: with an error response.
*/ */
public getRoomTags(roomId: string, callback?: Callback): Promise<unknown> { // TODO: Types public getRoomTags(roomId: string, callback?: Callback): Promise<ITagsResponse> {
const path = utils.encodeUri("/user/$userId/rooms/$roomId/tags/", { const path = utils.encodeUri("/user/$userId/rooms/$roomId/tags", {
$userId: this.credentials.userId, $userId: this.credentials.userId,
$roomId: roomId, $roomId: roomId,
}); });
return this.http.authedRequest( return this.http.authedRequest(callback, Method.Get, path);
callback, Method.Get, path, undefined,
);
} }
/** /**
@ -3670,7 +3679,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @param {string} roomId * @param {string} roomId
* @param {string} tagName name of room tag to be removed * @param {string} tagName name of room tag to be removed
* @param {module:client.callback} callback Optional. * @param {module:client.callback} callback Optional.
* @return {Promise} Resolves: TODO * @return {Promise} Resolves: void
* @return {module:http-api.MatrixError} Rejects: with an error response. * @return {module:http-api.MatrixError} Rejects: with an error response.
*/ */
public deleteRoomTag(roomId: string, tagName: string, callback?: Callback): Promise<void> { public deleteRoomTag(roomId: string, tagName: string, callback?: Callback): Promise<void> {
@ -3679,7 +3688,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
$roomId: roomId, $roomId: roomId,
$tag: tagName, $tag: tagName,
}); });
return this.http.authedRequest(callback, Method.Delete, path, undefined, undefined); return this.http.authedRequest(callback, Method.Delete, path);
} }
/** /**
@ -3810,17 +3819,20 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
// If we expect that an event is part of a thread but is missing the relation // If we expect that an event is part of a thread but is missing the relation
// we need to add it manually, as well as the reply fallback // we need to add it manually, as well as the reply fallback
if (threadId && !content["m.relates_to"]?.rel_type) { if (threadId && !content["m.relates_to"]?.rel_type) {
const isReply = !!content["m.relates_to"]?.["m.in_reply_to"];
content["m.relates_to"] = { content["m.relates_to"] = {
...content["m.relates_to"], ...content["m.relates_to"],
"rel_type": THREAD_RELATION_TYPE.name, "rel_type": THREAD_RELATION_TYPE.name,
"event_id": threadId, "event_id": threadId,
// Set is_falling_back to true unless this is actually intended to be a reply
"is_falling_back": !isReply,
}; };
const thread = this.getRoom(roomId)?.getThread(threadId); const thread = this.getRoom(roomId)?.getThread(threadId);
if (thread) { if (thread && !isReply) {
content["m.relates_to"]["m.in_reply_to"] = { content["m.relates_to"]["m.in_reply_to"] = {
"event_id": thread.lastReply((ev: MatrixEvent) => { "event_id": thread.lastReply((ev: MatrixEvent) => {
return ev.isRelation(THREAD_RELATION_TYPE.name) && !ev.status; return ev.isRelation(THREAD_RELATION_TYPE.name) && !ev.status;
})?.getId(), })?.getId() ?? threadId,
}; };
} }
} }
@ -4069,7 +4081,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
$txnId: txnId, $txnId: txnId,
}; };
let path; let path: string;
if (event.isState()) { if (event.isState()) {
let pathTemplate = "/rooms/$roomId/state/$eventType"; let pathTemplate = "/rooms/$roomId/state/$eventType";
@ -4608,7 +4620,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @param {Event} event The event that has been read. * @param {Event} event The event that has been read.
* @param {ReceiptType} receiptType other than ReceiptType.Read are experimental! Optional. * @param {ReceiptType} receiptType other than ReceiptType.Read are experimental! Optional.
* @param {module:client.callback} callback Optional. * @param {module:client.callback} callback Optional.
* @return {Promise} Resolves: to an empty object * @return {Promise} Resolves: to an empty object {}
* @return {module:http-api.MatrixError} Rejects: with an error response. * @return {module:http-api.MatrixError} Rejects: with an error response.
*/ */
public async sendReadReceipt(event: MatrixEvent, receiptType = ReceiptType.Read, callback?: Callback): Promise<{}> { public async sendReadReceipt(event: MatrixEvent, receiptType = ReceiptType.Read, callback?: Callback): Promise<{}> {
@ -5031,7 +5043,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
membership: string, membership: string,
reason?: string, reason?: string,
callback?: Callback, callback?: Callback,
): Promise<{}> { ): Promise<{}> { // API returns an empty object
if (utils.isFunction(reason)) { if (utils.isFunction(reason)) {
callback = reason as any as Callback; // legacy callback = reason as any as Callback; // legacy
reason = undefined; reason = undefined;
@ -5172,12 +5184,12 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @return {Promise} Resolves: The presence state for this user. * @return {Promise} Resolves: The presence state for this user.
* @return {module:http-api.MatrixError} Rejects: with an error response. * @return {module:http-api.MatrixError} Rejects: with an error response.
*/ */
public getPresence(userId: string, callback?: Callback): Promise<unknown> { // TODO: Types public getPresence(userId: string, callback?: Callback): Promise<IStatusResponse> {
const path = utils.encodeUri("/presence/$userId/status", { const path = utils.encodeUri("/presence/$userId/status", {
$userId: userId, $userId: userId,
}); });
return this.http.authedRequest(callback, Method.Get, path, undefined, undefined); return this.http.authedRequest(callback, Method.Get, path);
} }
/** /**
@ -5189,7 +5201,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* when there is no connection). * when there is no connection).
* *
* @param {Room} room The room to get older messages in. * @param {Room} room The room to get older messages in.
* @param {Integer} limit Optional. The maximum number of previous events to * @param {number} limit Optional. The maximum number of previous events to
* pull in. Default: 30. * pull in. Default: 30.
* @param {module:client.callback} callback Optional. * @param {module:client.callback} callback Optional.
* @return {Promise} Resolves: Room. If you are at the beginning * @return {Promise} Resolves: Room. If you are at the beginning
@ -5289,14 +5301,15 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* <p>If the EventTimelineSet object already has the given event in its store, the * <p>If the EventTimelineSet object already has the given event in its store, the
* corresponding timeline will be returned. Otherwise, a /context request is * corresponding timeline will be returned. Otherwise, a /context request is
* made, and used to construct an EventTimeline. * made, and used to construct an EventTimeline.
* If the event does not belong to this EventTimelineSet then undefined will be returned.
* *
* @param {EventTimelineSet} timelineSet The timelineSet to look for the event in * @param {EventTimelineSet} timelineSet The timelineSet to look for the event in, must be bound to a room
* @param {string} eventId The ID of the event to look for * @param {string} eventId The ID of the event to look for
* *
* @return {Promise} Resolves: * @return {Promise} Resolves:
* {@link module:models/event-timeline~EventTimeline} including the given event * {@link module:models/event-timeline~EventTimeline} including the given event
*/ */
public async getEventTimeline(timelineSet: EventTimelineSet, eventId: string): Promise<EventTimeline> { public async getEventTimeline(timelineSet: EventTimelineSet, eventId: string): Promise<EventTimeline | undefined> {
// don't allow any timeline support unless it's been enabled. // don't allow any timeline support unless it's been enabled.
if (!this.timelineSupport) { if (!this.timelineSupport) {
throw new Error("timeline support is disabled. Set the 'timelineSupport'" + throw new Error("timeline support is disabled. Set the 'timelineSupport'" +
@ -5320,7 +5333,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
} }
// TODO: we should implement a backoff (as per scrollback()) to deal more nicely with HTTP errors. // TODO: we should implement a backoff (as per scrollback()) to deal more nicely with HTTP errors.
const res = await this.http.authedRequest<any>(undefined, Method.Get, path, params); // TODO types const res = await this.http.authedRequest<IContextResponse>(undefined, Method.Get, path, params);
if (!res.event) { if (!res.event) {
throw new Error("'event' not in '/context' result - homeserver too old?"); throw new Error("'event' not in '/context' result - homeserver too old?");
} }
@ -5333,45 +5346,44 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
const mapper = this.getEventMapper(); const mapper = this.getEventMapper();
const event = mapper(res.event); const event = mapper(res.event);
const events = [ const events = [
// we start with the last event, since that's the point at which we have known state. // Order events from most recent to oldest (reverse-chronological).
// We start with the last event, since that's the point at which we have known state.
// events_after is already backwards; events_before is forwards. // events_after is already backwards; events_before is forwards.
...res.events_after.reverse().map(mapper), ...res.events_after.reverse().map(mapper),
event, event,
...res.events_before.map(mapper), ...res.events_before.map(mapper),
]; ];
// Where the event is a thread reply (not a root) and running in MSC-enabled mode the Thread timeline only if (this.supportsExperimentalThreads()) {
// functions contiguously, so we have to jump through some hoops to get our target event in it. if (!timelineSet.canContain(event)) {
// XXX: workaround for https://github.com/vector-im/element-meta/issues/150 return undefined;
if (Thread.hasServerSideSupport &&
this.supportsExperimentalThreads() &&
event.isRelation(THREAD_RELATION_TYPE.name)
) {
const [, threadedEvents] = timelineSet.room.partitionThreadedEvents(events);
let thread = timelineSet.room.getThread(event.threadRootId);
if (!thread) {
thread = timelineSet.room.createThread(event.threadRootId, undefined, threadedEvents, true);
} }
const opts: IRelationsRequestOpts = { // Where the event is a thread reply (not a root) and running in MSC-enabled mode the Thread timeline only
direction: Direction.Backward, // functions contiguously, so we have to jump through some hoops to get our target event in it.
limit: 50, // XXX: workaround for https://github.com/vector-im/element-meta/issues/150
}; if (Thread.hasServerSideSupport && timelineSet.thread) {
const thread = timelineSet.thread;
const opts: IRelationsRequestOpts = {
direction: Direction.Backward,
limit: 50,
};
await thread.fetchInitialEvents(); await thread.fetchInitialEvents();
let nextBatch = thread.liveTimeline.getPaginationToken(Direction.Backward); let nextBatch = thread.liveTimeline.getPaginationToken(Direction.Backward);
// Fetch events until we find the one we were asked for, or we run out of pages // Fetch events until we find the one we were asked for, or we run out of pages
while (!thread.findEventById(eventId)) { while (!thread.findEventById(eventId)) {
if (nextBatch) { if (nextBatch) {
opts.from = nextBatch; opts.from = nextBatch;
}
({ nextBatch } = await thread.fetchEvents(opts));
if (!nextBatch) break;
} }
({ nextBatch } = await thread.fetchEvents(opts)); return thread.liveTimeline;
if (!nextBatch) break;
} }
return thread.liveTimeline;
} }
// Here we handle non-thread timelines only, but still process any thread events to populate thread summaries. // Here we handle non-thread timelines only, but still process any thread events to populate thread summaries.
@ -5398,6 +5410,45 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
?? timeline; ?? timeline;
} }
/**
* Get an EventTimeline for the latest events in the room. This will just
* call `/messages` to get the latest message in the room, then use
* `client.getEventTimeline(...)` to construct a new timeline from it.
*
* @param {EventTimelineSet} timelineSet The timelineSet to find or add the timeline to
*
* @return {Promise} Resolves:
* {@link module:models/event-timeline~EventTimeline} timeline with the latest events in the room
*/
public async getLatestTimeline(timelineSet: EventTimelineSet): Promise<EventTimeline> {
// don't allow any timeline support unless it's been enabled.
if (!this.timelineSupport) {
throw new Error("timeline support is disabled. Set the 'timelineSupport'" +
" parameter to true when creating MatrixClient to enable it.");
}
const messagesPath = utils.encodeUri(
"/rooms/$roomId/messages", {
$roomId: timelineSet.room.roomId,
},
);
const params: Record<string, string | string[]> = {
dir: 'b',
};
if (this.clientOpts.lazyLoadMembers) {
params.filter = JSON.stringify(Filter.LAZY_LOADING_MESSAGES_FILTER);
}
const res = await this.http.authedRequest<IMessagesResponse>(undefined, Method.Get, messagesPath, params);
const event = res.chunk?.[0];
if (!event) {
throw new Error("No message returned from /messages when trying to construct getLatestTimeline");
}
return this.getEventTimeline(timelineSet, event.event_id);
}
/** /**
* Makes a request to /messages with the appropriate lazy loading filter set. * Makes a request to /messages with the appropriate lazy loading filter set.
* XXX: if we do get rid of scrollback (as it's not used at the moment), * XXX: if we do get rid of scrollback (as it's not used at the moment),
@ -5499,8 +5550,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
params.from = token; params.from = token;
} }
promise = this.http.authedRequest<any>( // TODO types promise = this.http.authedRequest<INotificationsResponse>(
undefined, Method.Get, path, params, undefined, undefined, Method.Get, path, params,
).then(async (res) => { ).then(async (res) => {
const token = res.next_token; const token = res.next_token;
const matrixEvents = []; const matrixEvents = [];
@ -5880,11 +5931,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
const idServerUrl = new URL(this.idBaseUrl); const idServerUrl = new URL(this.idBaseUrl);
postParams.id_server = idServerUrl.host; postParams.id_server = idServerUrl.host;
if ( if (this.identityServer?.getAccessToken && await this.doesServerAcceptIdentityAccessToken()) {
this.identityServer &&
this.identityServer.getAccessToken &&
await this.doesServerAcceptIdentityAccessToken()
) {
const identityAccessToken = await this.identityServer.getAccessToken(); const identityAccessToken = await this.identityServer.getAccessToken();
if (identityAccessToken) { if (identityAccessToken) {
postParams.id_access_token = identityAccessToken; postParams.id_access_token = identityAccessToken;
@ -5901,7 +5948,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @param {string} roomId the id of the room. * @param {string} roomId the id of the room.
* @return {object} the rule or undefined. * @return {object} the rule or undefined.
*/ */
public getRoomPushRule(scope: string, roomId: string): any { // TODO: Types public getRoomPushRule(scope: string, roomId: string): IPushRule | undefined {
// There can be only room-kind push rule per room // There can be only room-kind push rule per room
// and its id is the room id. // and its id is the room id.
if (this.pushRules) { if (this.pushRules) {
@ -5928,15 +5975,13 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @return {module:http-api.MatrixError} Rejects: with an error response. * @return {module:http-api.MatrixError} Rejects: with an error response.
*/ */
public setRoomMutePushRule(scope: string, roomId: string, mute: boolean): Promise<void> | void { public setRoomMutePushRule(scope: string, roomId: string, mute: boolean): Promise<void> | void {
let promise: Promise<void>; let promise: Promise<unknown>;
let hasDontNotifyRule = false; let hasDontNotifyRule = false;
// Get the existing room-kind push rule if any // Get the existing room-kind push rule if any
const roomPushRule = this.getRoomPushRule(scope, roomId); const roomPushRule = this.getRoomPushRule(scope, roomId);
if (roomPushRule) { if (roomPushRule?.actions.includes(PushRuleActionName.DontNotify)) {
if (0 <= roomPushRule.actions.indexOf("dont_notify")) { hasDontNotifyRule = true;
hasDontNotifyRule = true;
}
} }
if (!mute) { if (!mute) {
@ -5947,24 +5992,23 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
} else { } else {
if (!roomPushRule) { if (!roomPushRule) {
promise = this.addPushRule(scope, PushRuleKind.RoomSpecific, roomId, { promise = this.addPushRule(scope, PushRuleKind.RoomSpecific, roomId, {
actions: ["dont_notify"], actions: [PushRuleActionName.DontNotify],
}); });
} else if (!hasDontNotifyRule) { } else if (!hasDontNotifyRule) {
// Remove the existing one before setting the mute push rule // Remove the existing one before setting the mute push rule
// This is a workaround to SYN-590 (Push rule update fails) // This is a workaround to SYN-590 (Push rule update fails)
const deferred = utils.defer(); const deferred = utils.defer();
this.deletePushRule(scope, PushRuleKind.RoomSpecific, roomPushRule.rule_id) this.deletePushRule(scope, PushRuleKind.RoomSpecific, roomPushRule.rule_id).then(() => {
.then(() => { this.addPushRule(scope, PushRuleKind.RoomSpecific, roomId, {
this.addPushRule(scope, PushRuleKind.RoomSpecific, roomId, { actions: [PushRuleActionName.DontNotify],
actions: ["dont_notify"], }).then(() => {
}).then(() => { deferred.resolve();
deferred.resolve();
}).catch((err) => {
deferred.reject(err);
});
}).catch((err) => { }).catch((err) => {
deferred.reject(err); deferred.reject(err);
}); });
}).catch((err) => {
deferred.reject(err);
});
promise = deferred.promise; promise = deferred.promise;
} }
@ -6176,15 +6220,13 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
const path = utils.encodeUri("/user/$userId/filter", { const path = utils.encodeUri("/user/$userId/filter", {
$userId: this.credentials.userId, $userId: this.credentials.userId,
}); });
// TODO types return this.http.authedRequest<IFilterResponse>(undefined, Method.Post, path, undefined, content)
return this.http.authedRequest<any>(undefined, Method.Post, path, undefined, content).then((response) => { .then((response) => {
// persist the filter // persist the filter
const filter = Filter.fromJson( const filter = Filter.fromJson(this.credentials.userId, response.filter_id, content);
this.credentials.userId, response.filter_id, content, this.store.storeFilter(filter);
); return filter;
this.store.storeFilter(filter); });
return filter;
});
} }
/** /**
@ -6209,9 +6251,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
$filterId: filterId, $filterId: filterId,
}); });
return this.http.authedRequest<IFilterDefinition>( return this.http.authedRequest<IFilterDefinition>(undefined, Method.Get, path).then((response) => {
undefined, Method.Get, path, undefined, undefined,
).then((response) => {
// persist the filter // persist the filter
const filter = Filter.fromJson(userId, filterId, response); const filter = Filter.fromJson(userId, filterId, response);
this.store.storeFilter(filter); this.store.storeFilter(filter);
@ -6607,8 +6647,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
const versions = response["versions"]; const versions = response["versions"];
const unstableFeatures = response["unstable_features"]; const unstableFeatures = response["unstable_features"];
return (versions && versions.includes("r0.6.0")) return versions?.includes("r0.6.0") || unstableFeatures?.["m.separate_add_and_bind"];
|| (unstableFeatures && unstableFeatures["m.separate_add_and_bind"]);
} }
/** /**
@ -6910,7 +6949,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
guestAccessToken?: string, guestAccessToken?: string,
inhibitLogin?: boolean, inhibitLogin?: boolean,
callback?: Callback, callback?: Callback,
): Promise<any> { // TODO: Types (many) ): Promise<IAuthData> {
// backwards compat // backwards compat
if (bindThreepids === true) { if (bindThreepids === true) {
bindThreepids = { email: true }; bindThreepids = { email: true };
@ -6926,7 +6965,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
auth.session = sessionId; auth.session = sessionId;
} }
const params: any = { const params: IRegisterRequestParams = {
auth: auth, auth: auth,
refresh_token: true, // always ask for a refresh token - does nothing if unsupported refresh_token: true, // always ask for a refresh token - does nothing if unsupported
}; };
@ -6997,8 +7036,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @return {Promise} Resolves: to the /register response * @return {Promise} Resolves: to the /register response
* @return {module:http-api.MatrixError} Rejects: with an error response. * @return {module:http-api.MatrixError} Rejects: with an error response.
*/ */
public registerRequest(data: any, kind?: string, callback?: Callback): Promise<any> { // TODO: Types public registerRequest(data: IRegisterRequestParams, kind?: string, callback?: Callback): Promise<IAuthData> {
const params: any = {}; const params: { kind?: string } = {};
if (kind) { if (kind) {
params.kind = kind; params.kind = kind;
} }
@ -7142,9 +7181,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* it is up to the caller to either reset or destroy the MatrixClient after * it is up to the caller to either reset or destroy the MatrixClient after
* this method succeeds. * this method succeeds.
* @param {module:client.callback} callback Optional. * @param {module:client.callback} callback Optional.
* @return {Promise} Resolves: On success, the empty object * @param {boolean} stopClient whether to stop the client before calling /logout to prevent invalid token errors.
* @return {Promise} Resolves: On success, the empty object {}
*/ */
public async logout(callback?: Callback): Promise<{}> { public async logout(callback?: Callback, stopClient = false): Promise<{}> {
if (this.crypto?.backupManager?.getKeyBackupEnabled()) { if (this.crypto?.backupManager?.getKeyBackupEnabled()) {
try { try {
while (await this.crypto.backupManager.backupPendingKeys(200) > 0); while (await this.crypto.backupManager.backupPendingKeys(200) > 0);
@ -7155,6 +7195,11 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
); );
} }
} }
if (stopClient) {
this.stopClient();
}
return this.http.authedRequest( return this.http.authedRequest(
callback, Method.Post, '/logout', callback, Method.Post, '/logout',
); );
@ -7561,31 +7606,31 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
} }
/** /**
* Delete an alias to room ID mapping. This alias must be on your local server * Delete an alias to room ID mapping. This alias must be on your local server,
* and you must have sufficient access to do this operation. * and you must have sufficient access to do this operation.
* @param {string} alias The room alias to delete. * @param {string} alias The room alias to delete.
* @param {module:client.callback} callback Optional. * @param {module:client.callback} callback Optional.
* @return {Promise} Resolves: an empty object. * @return {Promise} Resolves: an empty object {}.
* @return {module:http-api.MatrixError} Rejects: with an error response. * @return {module:http-api.MatrixError} Rejects: with an error response.
*/ */
public deleteAlias(alias: string, callback?: Callback): Promise<{}> { public deleteAlias(alias: string, callback?: Callback): Promise<{}> {
const path = utils.encodeUri("/directory/room/$alias", { const path = utils.encodeUri("/directory/room/$alias", {
$alias: alias, $alias: alias,
}); });
return this.http.authedRequest(callback, Method.Delete, path, undefined, undefined); return this.http.authedRequest(callback, Method.Delete, path);
} }
/** /**
* @param {string} roomId * Gets the local aliases for the room. Note: this includes all local aliases, unlike the
* @param {module:client.callback} callback Optional. * curated list from the m.room.canonical_alias state event.
* @param {string} roomId The room ID to get local aliases for.
* @return {Promise} Resolves: an object with an `aliases` property, containing an array of local aliases * @return {Promise} Resolves: an object with an `aliases` property, containing an array of local aliases
* @return {module:http-api.MatrixError} Rejects: with an error response. * @return {module:http-api.MatrixError} Rejects: with an error response.
*/ */
public unstableGetLocalAliases(roomId: string, callback?: Callback): Promise<{ aliases: string[] }> { public getLocalAliases(roomId: string): Promise<{ aliases: string[] }> {
const path = utils.encodeUri("/rooms/$roomId/aliases", const path = utils.encodeUri("/rooms/$roomId/aliases", { $roomId: roomId });
{ $roomId: roomId }); const prefix = PREFIX_V3;
const prefix = PREFIX_UNSTABLE + "/org.matrix.msc2432"; return this.http.authedRequest(undefined, Method.Get, path, null, null, { prefix });
return this.http.authedRequest(callback, Method.Get, path, null, null, { prefix });
} }
/** /**
@ -7640,7 +7685,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* in the public directory, or "private" to make * in the public directory, or "private" to make
* it invisible. * it invisible.
* @param {module:client.callback} callback Optional. * @param {module:client.callback} callback Optional.
* @return {Promise} Resolves: result object * @return {Promise} Resolves: to an empty object {}
* @return {module:http-api.MatrixError} Rejects: with an error response. * @return {module:http-api.MatrixError} Rejects: with an error response.
*/ */
public setRoomDirectoryVisibility(roomId: string, visibility: Visibility, callback?: Callback): Promise<{}> { public setRoomDirectoryVisibility(roomId: string, visibility: Visibility, callback?: Callback): Promise<{}> {
@ -7799,8 +7844,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @return {module:http-api.MatrixError} Rejects: with an error response. * @return {module:http-api.MatrixError} Rejects: with an error response.
*/ */
public getThreePids(callback?: Callback): Promise<{ threepids: IThreepid[] }> { public getThreePids(callback?: Callback): Promise<{ threepids: IThreepid[] }> {
const path = "/account/3pid"; return this.http.authedRequest(callback, Method.Get, "/account/3pid");
return this.http.authedRequest(callback, Method.Get, path, undefined, undefined);
} }
/** /**
@ -7836,7 +7880,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* *
* @param {Object} data A object with 3PID validation data from having called * @param {Object} data A object with 3PID validation data from having called
* `account/3pid/<medium>/requestToken` on the homeserver. * `account/3pid/<medium>/requestToken` on the homeserver.
* @return {Promise} Resolves: on success * @return {Promise} Resolves: to an empty object {}
* @return {module:http-api.MatrixError} Rejects: with an error response. * @return {module:http-api.MatrixError} Rejects: with an error response.
*/ */
public async addThreePidOnly(data: IAddThreePidOnlyBody): Promise<{}> { public async addThreePidOnly(data: IAddThreePidOnlyBody): Promise<{}> {
@ -7856,7 +7900,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @param {Object} data A object with 3PID validation data from having called * @param {Object} data A object with 3PID validation data from having called
* `validate/<medium>/requestToken` on the identity server. It should also * `validate/<medium>/requestToken` on the identity server. It should also
* contain `id_server` and `id_access_token` fields as well. * contain `id_server` and `id_access_token` fields as well.
* @return {Promise} Resolves: on success * @return {Promise} Resolves: to an empty object {}
* @return {module:http-api.MatrixError} Rejects: with an error response. * @return {module:http-api.MatrixError} Rejects: with an error response.
*/ */
public async bindThreePid(data: IBindThreePidBody): Promise<{}> { public async bindThreePid(data: IBindThreePidBody): Promise<{}> {
@ -7917,7 +7961,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @param {string} newPassword The new desired password. * @param {string} newPassword The new desired password.
* @param {boolean} logoutDevices Should all sessions be logged out after the password change. Defaults to true. * @param {boolean} logoutDevices Should all sessions be logged out after the password change. Defaults to true.
* @param {module:client.callback} callback Optional. * @param {module:client.callback} callback Optional.
* @return {Promise} Resolves: TODO * @return {Promise} Resolves: to an empty object {}
* @return {module:http-api.MatrixError} Rejects: with an error response. * @return {module:http-api.MatrixError} Rejects: with an error response.
*/ */
public setPassword( public setPassword(
@ -7964,7 +8008,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @return {module:http-api.MatrixError} Rejects: with an error response. * @return {module:http-api.MatrixError} Rejects: with an error response.
*/ */
public getDevices(): Promise<{ devices: IMyDevice[] }> { public getDevices(): Promise<{ devices: IMyDevice[] }> {
return this.http.authedRequest(undefined, Method.Get, "/devices", undefined, undefined); return this.http.authedRequest(undefined, Method.Get, "/devices");
} }
/** /**
@ -7977,7 +8021,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
const path = utils.encodeUri("/devices/$device_id", { const path = utils.encodeUri("/devices/$device_id", {
$device_id: deviceId, $device_id: deviceId,
}); });
return this.http.authedRequest(undefined, Method.Get, path, undefined, undefined); return this.http.authedRequest(undefined, Method.Get, path);
} }
/** /**
@ -7985,7 +8029,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* *
* @param {string} deviceId device to update * @param {string} deviceId device to update
* @param {Object} body body of request * @param {Object} body body of request
* @return {Promise} Resolves: result object * @return {Promise} Resolves: to an empty object {}
* @return {module:http-api.MatrixError} Rejects: with an error response. * @return {module:http-api.MatrixError} Rejects: with an error response.
*/ */
// eslint-disable-next-line camelcase // eslint-disable-next-line camelcase
@ -8005,7 +8049,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @return {Promise} Resolves: result object * @return {Promise} Resolves: result object
* @return {module:http-api.MatrixError} Rejects: with an error response. * @return {module:http-api.MatrixError} Rejects: with an error response.
*/ */
public deleteDevice(deviceId: string, auth?: any): Promise<any> { // TODO: Types public deleteDevice(deviceId: string, auth?: IAuthDict): Promise<IAuthData | {}> {
const path = utils.encodeUri("/devices/$device_id", { const path = utils.encodeUri("/devices/$device_id", {
$device_id: deviceId, $device_id: deviceId,
}); });
@ -8027,7 +8071,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @return {Promise} Resolves: result object * @return {Promise} Resolves: result object
* @return {module:http-api.MatrixError} Rejects: with an error response. * @return {module:http-api.MatrixError} Rejects: with an error response.
*/ */
public deleteMultipleDevices(devices: string[], auth?: any): Promise<any> { // TODO: Types public deleteMultipleDevices(devices: string[], auth?: IAuthDict): Promise<IAuthData | {}> {
const body: any = { devices }; const body: any = { devices };
if (auth) { if (auth) {
@ -8046,8 +8090,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @return {module:http-api.MatrixError} Rejects: with an error response. * @return {module:http-api.MatrixError} Rejects: with an error response.
*/ */
public getPushers(callback?: Callback): Promise<{ pushers: IPusher[] }> { public getPushers(callback?: Callback): Promise<{ pushers: IPusher[] }> {
const path = "/pushers"; return this.http.authedRequest(callback, Method.Get, "/pushers");
return this.http.authedRequest(callback, Method.Get, path, undefined, undefined);
} }
/** /**
@ -8088,9 +8131,9 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
scope: string, scope: string,
kind: PushRuleKind, kind: PushRuleKind,
ruleId: Exclude<string, RuleId>, ruleId: Exclude<string, RuleId>,
body: any, body: Pick<IPushRule, "actions" | "conditions" | "pattern">,
callback?: Callback, callback?: Callback,
): Promise<any> { // TODO: Types ): Promise<{}> {
// NB. Scope not uri encoded because devices need the '/' // NB. Scope not uri encoded because devices need the '/'
const path = utils.encodeUri("/pushrules/" + scope + "/$kind/$ruleId", { const path = utils.encodeUri("/pushrules/" + scope + "/$kind/$ruleId", {
$kind: kind, $kind: kind,
@ -8112,7 +8155,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
kind: PushRuleKind, kind: PushRuleKind,
ruleId: Exclude<string, RuleId>, ruleId: Exclude<string, RuleId>,
callback?: Callback, callback?: Callback,
): Promise<any> { // TODO: Types ): Promise<{}> {
// NB. Scope not uri encoded because devices need the '/' // NB. Scope not uri encoded because devices need the '/'
const path = utils.encodeUri("/pushrules/" + scope + "/$kind/$ruleId", { const path = utils.encodeUri("/pushrules/" + scope + "/$kind/$ruleId", {
$kind: kind, $kind: kind,
@ -8128,7 +8171,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @param {string} ruleId * @param {string} ruleId
* @param {boolean} enabled * @param {boolean} enabled
* @param {module:client.callback} callback Optional. * @param {module:client.callback} callback Optional.
* @return {Promise} Resolves: result object * @return {Promise} Resolves: to an empty object {}
* @return {module:http-api.MatrixError} Rejects: with an error response. * @return {module:http-api.MatrixError} Rejects: with an error response.
*/ */
public setPushRuleEnabled( public setPushRuleEnabled(
@ -8154,7 +8197,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @param {string} ruleId * @param {string} ruleId
* @param {array} actions * @param {array} actions
* @param {module:client.callback} callback Optional. * @param {module:client.callback} callback Optional.
* @return {Promise} Resolves: result object * @return {Promise} Resolves: to an empty object {}
* @return {module:http-api.MatrixError} Rejects: with an error response. * @return {module:http-api.MatrixError} Rejects: with an error response.
*/ */
public setPushRuleActions( public setPushRuleActions(
@ -8311,11 +8354,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
to: newToken, to: newToken,
}; };
const path = "/keys/changes"; return this.http.authedRequest(undefined, Method.Get, "/keys/changes", qps);
return this.http.authedRequest(undefined, Method.Get, path, qps, undefined);
} }
public uploadDeviceSigningKeys(auth?: IAuthData, keys?: CrossSigningKeys): Promise<{}> { public uploadDeviceSigningKeys(auth?: IAuthData, keys?: CrossSigningKeys): Promise<{}> { // API returns empty object
const data = Object.assign({}, keys); const data = Object.assign({}, keys);
if (auth) Object.assign(data, { auth }); if (auth) Object.assign(data, { auth });
return this.http.authedRequest( return this.http.authedRequest(
@ -8725,7 +8767,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* content to send. Map from user_id to device_id to content object. * content to send. Map from user_id to device_id to content object.
* @param {string=} txnId transaction id. One will be made up if not * @param {string=} txnId transaction id. One will be made up if not
* supplied. * supplied.
* @return {Promise} Resolves to the result object * @return {Promise} Resolves: to an empty object {}
*/ */
public sendToDevice( public sendToDevice(
eventType: string, eventType: string,
@ -8757,7 +8799,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
*/ */
public getThirdpartyProtocols(): Promise<{ [protocol: string]: IProtocol }> { public getThirdpartyProtocols(): Promise<{ [protocol: string]: IProtocol }> {
return this.http.authedRequest<Record<string, IProtocol>>( return this.http.authedRequest<Record<string, IProtocol>>(
undefined, Method.Get, "/thirdparty/protocols", undefined, undefined, undefined, Method.Get, "/thirdparty/protocols",
).then((response) => { ).then((response) => {
// sanity check // sanity check
if (!response || typeof (response) !== 'object') { if (!response || typeof (response) !== 'object') {
@ -8783,7 +8825,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
$protocol: protocol, $protocol: protocol,
}); });
return this.http.authedRequest(undefined, Method.Get, path, params, undefined); return this.http.authedRequest(undefined, Method.Get, path, params);
} }
/** /**
@ -8799,7 +8841,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
$protocol: protocol, $protocol: protocol,
}); });
return this.http.authedRequest(undefined, Method.Get, path, params, undefined); return this.http.authedRequest(undefined, Method.Get, path, params);
} }
public getTerms(serviceType: SERVICE_TYPES, baseUrl: string): Promise<any> { // TODO: Types public getTerms(serviceType: SERVICE_TYPES, baseUrl: string): Promise<any> { // TODO: Types

View File

@ -16,7 +16,7 @@ limitations under the License.
/** @module ContentHelpers */ /** @module ContentHelpers */
import { REFERENCE_RELATION } from "matrix-events-sdk"; import { isProvided, REFERENCE_RELATION } from "matrix-events-sdk";
import { MBeaconEventContent, MBeaconInfoContent, MBeaconInfoEventContent } from "./@types/beacon"; import { MBeaconEventContent, MBeaconInfoContent, MBeaconInfoEventContent } from "./@types/beacon";
import { MsgType } from "./@types/event"; import { MsgType } from "./@types/event";
@ -32,6 +32,7 @@ import {
MAssetContent, MAssetContent,
LegacyLocationEventContent, LegacyLocationEventContent,
} from "./@types/location"; } from "./@types/location";
import { MRoomTopicEventContent, MTopicContent, M_TOPIC } from "./@types/topic";
/** /**
* Generates the content for a HTML Message event * Generates the content for a HTML Message event
@ -138,10 +139,10 @@ export const getTextForLocationEvent = (
/** /**
* Generates the content for a Location event * Generates the content for a Location event
* @param uri a geo:// uri for the location * @param uri a geo:// uri for the location
* @param ts the timestamp when the location was correct (milliseconds since * @param timestamp the timestamp when the location was correct (milliseconds since
* the UNIX epoch) * the UNIX epoch)
* @param description the (optional) label for this location on the map * @param description the (optional) label for this location on the map
* @param asset_type the (optional) asset type of this location e.g. "m.self" * @param assetType the (optional) asset type of this location e.g. "m.self"
* @param text optional. A text for the location * @param text optional. A text for the location
*/ */
export const makeLocationContent = ( export const makeLocationContent = (
@ -190,6 +191,34 @@ export const parseLocationEvent = (wireEventContent: LocationEventWireContent):
return makeLocationContent(fallbackText, geoUri, timestamp, description, assetType); return makeLocationContent(fallbackText, geoUri, timestamp, description, assetType);
}; };
/**
* Topic event helpers
*/
export type MakeTopicContent = (
topic: string,
htmlTopic?: string,
) => MRoomTopicEventContent;
export const makeTopicContent: MakeTopicContent = (topic, htmlTopic) => {
const renderings = [{ body: topic, mimetype: "text/plain" }];
if (isProvided(htmlTopic)) {
renderings.push({ body: htmlTopic, mimetype: "text/html" });
}
return { topic, [M_TOPIC.name]: renderings };
};
export type TopicState = {
text: string;
html?: string;
};
export const parseTopicContent = (content: MRoomTopicEventContent): TopicState => {
const mtopic = M_TOPIC.findIn<MTopicContent>(content);
const text = mtopic?.find(r => !isProvided(r.mimetype) || r.mimetype === "text/plain")?.body ?? content.topic;
const html = mtopic?.find(r => r.mimetype === "text/html")?.body;
return { text, html };
};
/** /**
* Beacon event helpers * Beacon event helpers
*/ */

View File

@ -171,7 +171,7 @@ export class CrossSigningInfo {
*/ */
public async isStoredInSecretStorage(secretStorage: SecretStorage): Promise<Record<string, object>> { public async isStoredInSecretStorage(secretStorage: SecretStorage): Promise<Record<string, object>> {
// check what SSSS keys have encrypted the master key (if any) // check what SSSS keys have encrypted the master key (if any)
const stored = await secretStorage.isStored("m.cross_signing.master", false) || {}; const stored = await secretStorage.isStored("m.cross_signing.master") || {};
// then check which of those SSSS keys have also encrypted the SSK and USK // then check which of those SSSS keys have also encrypted the SSK and USK
function intersect(s: Record<string, ISecretStorageKeyInfo>) { function intersect(s: Record<string, ISecretStorageKeyInfo>) {
for (const k of Object.keys(stored)) { for (const k of Object.keys(stored)) {
@ -181,7 +181,7 @@ export class CrossSigningInfo {
} }
} }
for (const type of ["self_signing", "user_signing"]) { for (const type of ["self_signing", "user_signing"]) {
intersect(await secretStorage.isStored(`m.cross_signing.${type}`, false) || {}); intersect(await secretStorage.isStored(`m.cross_signing.${type}`) || {});
} }
return Object.keys(stored).length ? stored : null; return Object.keys(stored).length ? stored : null;
} }

View File

@ -942,7 +942,7 @@ async function updateStoredDeviceKeysForUser(
async function storeDeviceKeys( async function storeDeviceKeys(
olmDevice: OlmDevice, olmDevice: OlmDevice,
userStore: Record<string, DeviceInfo>, userStore: Record<string, DeviceInfo>,
deviceResult: any, // TODO types deviceResult: IDownloadKeyResult["device_keys"]["user_id"]["device_id"],
): Promise<boolean> { ): Promise<boolean> {
if (!deviceResult.keys) { if (!deviceResult.keys) {
// no keys? // no keys?

View File

@ -92,7 +92,7 @@ export interface InboundGroupSessionData {
sharedHistory?: boolean; sharedHistory?: boolean;
} }
interface IDecryptedGroupMessage { export interface IDecryptedGroupMessage {
result: string; result: string;
keysClaimed: Record<string, string>; keysClaimed: Record<string, string>;
senderKey: string; senderKey: string;
@ -100,6 +100,11 @@ interface IDecryptedGroupMessage {
untrusted: boolean; untrusted: boolean;
} }
export interface IInboundSession {
payload: string;
session_id: string;
}
export interface IExportedDevice { export interface IExportedDevice {
pickleKey: string; pickleKey: string;
pickledAccount: string; pickledAccount: string;
@ -620,7 +625,7 @@ export class OlmDevice {
theirDeviceIdentityKey: string, theirDeviceIdentityKey: string,
messageType: number, messageType: number,
ciphertext: string, ciphertext: string,
): Promise<{ payload: string, session_id: string }> { // eslint-disable-line camelcase ): Promise<IInboundSession> {
if (messageType !== 0) { if (messageType !== 0) {
throw new Error("Need messageType == 0 to create inbound session"); throw new Error("Need messageType == 0 to create inbound session");
} }

View File

@ -339,13 +339,12 @@ export class SecretStorage {
* Check if a secret is stored on the server. * Check if a secret is stored on the server.
* *
* @param {string} name the name of the secret * @param {string} name the name of the secret
* @param {boolean} checkKey check if the secret is encrypted by a trusted key
* *
* @return {object?} map of key name to key info the secret is encrypted * @return {object?} map of key name to key info the secret is encrypted
* with, or null if it is not present or not encrypted with a trusted * with, or null if it is not present or not encrypted with a trusted
* key * key
*/ */
public async isStored(name: string, checkKey = true): Promise<Record<string, ISecretStorageKeyInfo> | null> { public async isStored(name: string): Promise<Record<string, ISecretStorageKeyInfo> | null> {
// check if secret exists // check if secret exists
const secretInfo = await this.accountDataAdapter.getAccountDataFromServer<ISecretInfo>(name); const secretInfo = await this.accountDataAdapter.getAccountDataFromServer<ISecretInfo>(name);
if (!secretInfo?.encrypted) return null; if (!secretInfo?.encrypted) return null;

View File

@ -179,7 +179,7 @@ export abstract class DecryptionAlgorithm {
* *
* @param {module:models/event.MatrixEvent} params event key event * @param {module:models/event.MatrixEvent} params event key event
*/ */
public onRoomKeyEvent(params: MatrixEvent): void { public async onRoomKeyEvent(params: MatrixEvent): Promise<void> {
// ignore by default // ignore by default
} }

View File

@ -30,13 +30,13 @@ import {
registerAlgorithm, registerAlgorithm,
UnknownDeviceError, UnknownDeviceError,
} from "./base"; } from "./base";
import { WITHHELD_MESSAGES } from '../OlmDevice'; import { IDecryptedGroupMessage, WITHHELD_MESSAGES } from '../OlmDevice';
import { Room } from '../../models/room'; import { Room } from '../../models/room';
import { DeviceInfo } from "../deviceinfo"; import { DeviceInfo } from "../deviceinfo";
import { IOlmSessionResult } from "../olmlib"; import { IOlmSessionResult } from "../olmlib";
import { DeviceInfoMap } from "../DeviceList"; import { DeviceInfoMap } from "../DeviceList";
import { MatrixEvent } from "../.."; import { MatrixEvent } from "../..";
import { IEventDecryptionResult, IMegolmSessionData, IncomingRoomKeyRequest } from "../index"; import { IEncryptedContent, IEventDecryptionResult, IMegolmSessionData, IncomingRoomKeyRequest } from "../index";
// determine whether the key can be shared with invitees // determine whether the key can be shared with invitees
export function isRoomSharedHistory(room: Room): boolean { export function isRoomSharedHistory(room: Room): boolean {
@ -100,12 +100,6 @@ interface IPayload extends Partial<IMessage> {
algorithm?: string; algorithm?: string;
sender_key?: string; sender_key?: string;
} }
interface IEncryptedContent {
algorithm: string;
sender_key: string;
ciphertext: Record<string, string>;
}
/* eslint-enable camelcase */ /* eslint-enable camelcase */
interface SharedWithData { interface SharedWithData {
@ -213,6 +207,8 @@ class OutboundSessionInfo {
} }
} }
} }
return false;
} }
} }
@ -231,7 +227,7 @@ class MegolmEncryption extends EncryptionAlgorithm {
// are using, and which devices we have shared the keys with. It resolves // are using, and which devices we have shared the keys with. It resolves
// with an OutboundSessionInfo (or undefined, for the first message in the // with an OutboundSessionInfo (or undefined, for the first message in the
// room). // room).
private setupPromise = Promise.resolve<OutboundSessionInfo>(undefined); private setupPromise = Promise.resolve<OutboundSessionInfo | null>(null);
// Map of outbound sessions by sessions ID. Used if we need a particular // Map of outbound sessions by sessions ID. Used if we need a particular
// session (the session we're currently using to send is always obtained // session (the session we're currently using to send is always obtained
@ -240,8 +236,8 @@ class MegolmEncryption extends EncryptionAlgorithm {
private readonly sessionRotationPeriodMsgs: number; private readonly sessionRotationPeriodMsgs: number;
private readonly sessionRotationPeriodMs: number; private readonly sessionRotationPeriodMs: number;
private encryptionPreparation: Promise<void>; private encryptionPreparation?: {
private encryptionPreparationMetadata: { promise: Promise<void>;
startTime: number; startTime: number;
}; };
@ -270,193 +266,209 @@ class MegolmEncryption extends EncryptionAlgorithm {
blocked: IBlockedMap, blocked: IBlockedMap,
singleOlmCreationPhase = false, singleOlmCreationPhase = false,
): Promise<OutboundSessionInfo> { ): Promise<OutboundSessionInfo> {
let session: OutboundSessionInfo;
// takes the previous OutboundSessionInfo, and considers whether to create // takes the previous OutboundSessionInfo, and considers whether to create
// a new one. Also shares the key with any (new) devices in the room. // a new one. Also shares the key with any (new) devices in the room.
// Updates `session` to hold the final OutboundSessionInfo. //
// Returns the successful session whether keyshare succeeds or not.
// //
// returns a promise which resolves once the keyshare is successful. // returns a promise which resolves once the keyshare is successful.
const prepareSession = async (oldSession: OutboundSessionInfo) => { const setup = async (oldSession: OutboundSessionInfo | null): Promise<OutboundSessionInfo> => {
session = oldSession;
const sharedHistory = isRoomSharedHistory(room); const sharedHistory = isRoomSharedHistory(room);
// history visibility changed const session = await this.prepareSession(devicesInRoom, sharedHistory, oldSession);
if (session && sharedHistory !== session.sharedHistory) {
session = null; try {
await this.shareSession(devicesInRoom, sharedHistory, singleOlmCreationPhase, blocked, session);
} catch (e) {
logger.error(`Failed to ensure outbound session in ${this.roomId}`, e);
} }
// need to make a brand new session? return session;
if (session && session.needsRotation(this.sessionRotationPeriodMsgs,
this.sessionRotationPeriodMs)
) {
logger.log("Starting new megolm session because we need to rotate.");
session = null;
}
// determine if we have shared with anyone we shouldn't have
if (session && session.sharedWithTooManyDevices(devicesInRoom)) {
session = null;
}
if (!session) {
logger.log(`Starting new megolm session for room ${this.roomId}`);
session = await this.prepareNewSession(sharedHistory);
logger.log(`Started new megolm session ${session.sessionId} ` +
`for room ${this.roomId}`);
this.outboundSessions[session.sessionId] = session;
}
// now check if we need to share with any devices
const shareMap: Record<string, DeviceInfo[]> = {};
for (const [userId, userDevices] of Object.entries(devicesInRoom)) {
for (const [deviceId, deviceInfo] of Object.entries(userDevices)) {
const key = deviceInfo.getIdentityKey();
if (key == this.olmDevice.deviceCurve25519Key) {
// don't bother sending to ourself
continue;
}
if (
!session.sharedWithDevices[userId] ||
session.sharedWithDevices[userId][deviceId] === undefined
) {
shareMap[userId] = shareMap[userId] || [];
shareMap[userId].push(deviceInfo);
}
}
}
const key = this.olmDevice.getOutboundGroupSessionKey(session.sessionId);
const payload: IPayload = {
type: "m.room_key",
content: {
"algorithm": olmlib.MEGOLM_ALGORITHM,
"room_id": this.roomId,
"session_id": session.sessionId,
"session_key": key.key,
"chain_index": key.chain_index,
"org.matrix.msc3061.shared_history": sharedHistory,
},
};
const [devicesWithoutSession, olmSessions] = await olmlib.getExistingOlmSessions(
this.olmDevice, this.baseApis, shareMap,
);
await Promise.all([
(async () => {
// share keys with devices that we already have a session for
logger.debug(`Sharing keys with existing Olm sessions in ${this.roomId}`, olmSessions);
await this.shareKeyWithOlmSessions(session, key, payload, olmSessions);
logger.debug(`Shared keys with existing Olm sessions in ${this.roomId}`);
})(),
(async () => {
logger.debug(
`Sharing keys (start phase 1) with new Olm sessions in ${this.roomId}`,
devicesWithoutSession,
);
const errorDevices: IOlmDevice[] = [];
// meanwhile, establish olm sessions for devices that we don't
// already have a session for, and share keys with them. If
// we're doing two phases of olm session creation, use a
// shorter timeout when fetching one-time keys for the first
// phase.
const start = Date.now();
const failedServers: string[] = [];
await this.shareKeyWithDevices(
session, key, payload, devicesWithoutSession, errorDevices,
singleOlmCreationPhase ? 10000 : 2000, failedServers,
);
logger.debug(`Shared keys (end phase 1) with new Olm sessions in ${this.roomId}`);
if (!singleOlmCreationPhase && (Date.now() - start < 10000)) {
// perform the second phase of olm session creation if requested,
// and if the first phase didn't take too long
(async () => {
// Retry sending keys to devices that we were unable to establish
// an olm session for. This time, we use a longer timeout, but we
// do this in the background and don't block anything else while we
// do this. We only need to retry users from servers that didn't
// respond the first time.
const retryDevices: Record<string, DeviceInfo[]> = {};
const failedServerMap = new Set;
for (const server of failedServers) {
failedServerMap.add(server);
}
const failedDevices = [];
for (const { userId, deviceInfo } of errorDevices) {
const userHS = userId.slice(userId.indexOf(":") + 1);
if (failedServerMap.has(userHS)) {
retryDevices[userId] = retryDevices[userId] || [];
retryDevices[userId].push(deviceInfo);
} else {
// if we aren't going to retry, then handle it
// as a failed device
failedDevices.push({ userId, deviceInfo });
}
}
logger.debug(`Sharing keys (start phase 2) with new Olm sessions in ${this.roomId}`);
await this.shareKeyWithDevices(
session, key, payload, retryDevices, failedDevices, 30000,
);
logger.debug(`Shared keys (end phase 2) with new Olm sessions in ${this.roomId}`);
await this.notifyFailedOlmDevices(session, key, failedDevices);
})();
} else {
await this.notifyFailedOlmDevices(session, key, errorDevices);
}
logger.debug(`Shared keys (all phases done) with new Olm sessions in ${this.roomId}`);
})(),
(async () => {
logger.debug(`There are ${Object.entries(blocked).length} blocked devices in ${this.roomId}`,
Object.entries(blocked));
// also, notify newly blocked devices that they're blocked
logger.debug(`Notifying newly blocked devices in ${this.roomId}`);
const blockedMap: Record<string, Record<string, { device: IBlockedDevice }>> = {};
let blockedCount = 0;
for (const [userId, userBlockedDevices] of Object.entries(blocked)) {
for (const [deviceId, device] of Object.entries(userBlockedDevices)) {
if (
!session.blockedDevicesNotified[userId] ||
session.blockedDevicesNotified[userId][deviceId] === undefined
) {
blockedMap[userId] = blockedMap[userId] || {};
blockedMap[userId][deviceId] = { device };
blockedCount++;
}
}
}
await this.notifyBlockedDevices(session, blockedMap);
logger.debug(`Notified ${blockedCount} newly blocked devices in ${this.roomId}`, blockedMap);
})(),
]);
}; };
// helper which returns the session prepared by prepareSession
function returnSession() {
return session;
}
// first wait for the previous share to complete // first wait for the previous share to complete
const prom = this.setupPromise.then(prepareSession); const prom = this.setupPromise.then(setup);
// Ensure any failures are logged for debugging // Ensure any failures are logged for debugging
prom.catch(e => { prom.catch(e => {
logger.error(`Failed to ensure outbound session in ${this.roomId}`, e); logger.error(`Failed to setup outbound session in ${this.roomId}`, e);
}); });
// setupPromise resolves to `session` whether or not the share succeeds // setupPromise resolves to `session` whether or not the share succeeds
this.setupPromise = prom.then(returnSession, returnSession); this.setupPromise = prom;
// but we return a promise which only resolves if the share was successful. // but we return a promise which only resolves if the share was successful.
return prom.then(returnSession); return prom;
}
private async prepareSession(
devicesInRoom: DeviceInfoMap,
sharedHistory: boolean,
session: OutboundSessionInfo | null,
): Promise<OutboundSessionInfo> {
// history visibility changed
if (session && sharedHistory !== session.sharedHistory) {
session = null;
}
// need to make a brand new session?
if (session?.needsRotation(this.sessionRotationPeriodMsgs, this.sessionRotationPeriodMs)) {
logger.log("Starting new megolm session because we need to rotate.");
session = null;
}
// determine if we have shared with anyone we shouldn't have
if (session?.sharedWithTooManyDevices(devicesInRoom)) {
session = null;
}
if (!session) {
logger.log(`Starting new megolm session for room ${this.roomId}`);
session = await this.prepareNewSession(sharedHistory);
logger.log(`Started new megolm session ${session.sessionId} ` +
`for room ${this.roomId}`);
this.outboundSessions[session.sessionId] = session;
}
return session;
}
private async shareSession(
devicesInRoom: DeviceInfoMap,
sharedHistory: boolean,
singleOlmCreationPhase: boolean,
blocked: IBlockedMap,
session: OutboundSessionInfo,
) {
// now check if we need to share with any devices
const shareMap: Record<string, DeviceInfo[]> = {};
for (const [userId, userDevices] of Object.entries(devicesInRoom)) {
for (const [deviceId, deviceInfo] of Object.entries(userDevices)) {
const key = deviceInfo.getIdentityKey();
if (key == this.olmDevice.deviceCurve25519Key) {
// don't bother sending to ourself
continue;
}
if (
!session.sharedWithDevices[userId] ||
session.sharedWithDevices[userId][deviceId] === undefined
) {
shareMap[userId] = shareMap[userId] || [];
shareMap[userId].push(deviceInfo);
}
}
}
const key = this.olmDevice.getOutboundGroupSessionKey(session.sessionId);
const payload: IPayload = {
type: "m.room_key",
content: {
"algorithm": olmlib.MEGOLM_ALGORITHM,
"room_id": this.roomId,
"session_id": session.sessionId,
"session_key": key.key,
"chain_index": key.chain_index,
"org.matrix.msc3061.shared_history": sharedHistory,
},
};
const [devicesWithoutSession, olmSessions] = await olmlib.getExistingOlmSessions(
this.olmDevice, this.baseApis, shareMap,
);
await Promise.all([
(async () => {
// share keys with devices that we already have a session for
logger.debug(`Sharing keys with existing Olm sessions in ${this.roomId}`, olmSessions);
await this.shareKeyWithOlmSessions(session, key, payload, olmSessions);
logger.debug(`Shared keys with existing Olm sessions in ${this.roomId}`);
})(),
(async () => {
logger.debug(
`Sharing keys (start phase 1) with new Olm sessions in ${this.roomId}`,
devicesWithoutSession,
);
const errorDevices: IOlmDevice[] = [];
// meanwhile, establish olm sessions for devices that we don't
// already have a session for, and share keys with them. If
// we're doing two phases of olm session creation, use a
// shorter timeout when fetching one-time keys for the first
// phase.
const start = Date.now();
const failedServers: string[] = [];
await this.shareKeyWithDevices(
session, key, payload, devicesWithoutSession, errorDevices,
singleOlmCreationPhase ? 10000 : 2000, failedServers,
);
logger.debug(`Shared keys (end phase 1) with new Olm sessions in ${this.roomId}`);
if (!singleOlmCreationPhase && (Date.now() - start < 10000)) {
// perform the second phase of olm session creation if requested,
// and if the first phase didn't take too long
(async () => {
// Retry sending keys to devices that we were unable to establish
// an olm session for. This time, we use a longer timeout, but we
// do this in the background and don't block anything else while we
// do this. We only need to retry users from servers that didn't
// respond the first time.
const retryDevices: Record<string, DeviceInfo[]> = {};
const failedServerMap = new Set;
for (const server of failedServers) {
failedServerMap.add(server);
}
const failedDevices: IOlmDevice[] = [];
for (const { userId, deviceInfo } of errorDevices) {
const userHS = userId.slice(userId.indexOf(":") + 1);
if (failedServerMap.has(userHS)) {
retryDevices[userId] = retryDevices[userId] || [];
retryDevices[userId].push(deviceInfo);
} else {
// if we aren't going to retry, then handle it
// as a failed device
failedDevices.push({ userId, deviceInfo });
}
}
logger.debug(`Sharing keys (start phase 2) with new Olm sessions in ${this.roomId}`);
await this.shareKeyWithDevices(
session, key, payload, retryDevices, failedDevices, 30000,
);
logger.debug(`Shared keys (end phase 2) with new Olm sessions in ${this.roomId}`);
await this.notifyFailedOlmDevices(session, key, failedDevices);
})();
} else {
await this.notifyFailedOlmDevices(session, key, errorDevices);
}
logger.debug(`Shared keys (all phases done) with new Olm sessions in ${this.roomId}`);
})(),
(async () => {
logger.debug(`There are ${Object.entries(blocked).length} blocked devices in ${this.roomId}`,
Object.entries(blocked));
// also, notify newly blocked devices that they're blocked
logger.debug(`Notifying newly blocked devices in ${this.roomId}`);
const blockedMap: Record<string, Record<string, { device: IBlockedDevice }>> = {};
let blockedCount = 0;
for (const [userId, userBlockedDevices] of Object.entries(blocked)) {
for (const [deviceId, device] of Object.entries(userBlockedDevices)) {
if (
!session.blockedDevicesNotified[userId] ||
session.blockedDevicesNotified[userId][deviceId] === undefined
) {
blockedMap[userId] = blockedMap[userId] || {};
blockedMap[userId][deviceId] = { device };
blockedCount++;
}
}
}
await this.notifyBlockedDevices(session, blockedMap);
logger.debug(`Notified ${blockedCount} newly blocked devices in ${this.roomId}`, blockedMap);
})(),
]);
} }
/** /**
@ -594,15 +606,14 @@ class MegolmEncryption extends EncryptionAlgorithm {
return this.crypto.encryptAndSendToDevices( return this.crypto.encryptAndSendToDevices(
userDeviceMap, userDeviceMap,
payload, payload,
).then((result) => { ).then(({ contentMap, deviceInfoByUserIdAndDeviceId }) => {
const { contentMap, deviceInfoByDeviceId } = result;
// store that we successfully uploaded the keys of the current slice // store that we successfully uploaded the keys of the current slice
for (const userId of Object.keys(contentMap)) { for (const userId of Object.keys(contentMap)) {
for (const deviceId of Object.keys(contentMap[userId])) { for (const deviceId of Object.keys(contentMap[userId])) {
session.markSharedWithDevice( session.markSharedWithDevice(
userId, userId,
deviceId, deviceId,
deviceInfoByDeviceId.get(deviceId).getIdentityKey(), deviceInfoByUserIdAndDeviceId.get(userId).get(deviceId).getIdentityKey(),
chainIndex, chainIndex,
); );
} }
@ -798,7 +809,7 @@ class MegolmEncryption extends EncryptionAlgorithm {
logger.debug(`Ensuring Olm sessions for devices in ${this.roomId}`); logger.debug(`Ensuring Olm sessions for devices in ${this.roomId}`);
const devicemap = await olmlib.ensureOlmSessionsForDevices( const devicemap = await olmlib.ensureOlmSessionsForDevices(
this.olmDevice, this.baseApis, devicesByUser, false, otkTimeout, failedServers, this.olmDevice, this.baseApis, devicesByUser, false, otkTimeout, failedServers,
logger.withPrefix(`[${this.roomId}]`), logger.withPrefix?.(`[${this.roomId}]`),
); );
logger.debug(`Ensured Olm sessions for devices in ${this.roomId}`); logger.debug(`Ensured Olm sessions for devices in ${this.roomId}`);
@ -938,11 +949,11 @@ class MegolmEncryption extends EncryptionAlgorithm {
* @param {module:models/room} room the room the event is in * @param {module:models/room} room the room the event is in
*/ */
public prepareToEncrypt(room: Room): void { public prepareToEncrypt(room: Room): void {
if (this.encryptionPreparation) { if (this.encryptionPreparation != null) {
// We're already preparing something, so don't do anything else. // We're already preparing something, so don't do anything else.
// FIXME: check if we need to restart // FIXME: check if we need to restart
// (https://github.com/matrix-org/matrix-js-sdk/issues/1255) // (https://github.com/matrix-org/matrix-js-sdk/issues/1255)
const elapsedTime = Date.now() - this.encryptionPreparationMetadata.startTime; const elapsedTime = Date.now() - this.encryptionPreparation.startTime;
logger.debug( logger.debug(
`Already started preparing to encrypt for ${this.roomId} ` + `Already started preparing to encrypt for ${this.roomId} ` +
`${elapsedTime} ms ago, skipping`, `${elapsedTime} ms ago, skipping`,
@ -952,32 +963,31 @@ class MegolmEncryption extends EncryptionAlgorithm {
logger.debug(`Preparing to encrypt events for ${this.roomId}`); logger.debug(`Preparing to encrypt events for ${this.roomId}`);
this.encryptionPreparationMetadata = { this.encryptionPreparation = {
startTime: Date.now(), startTime: Date.now(),
}; promise: (async () => {
this.encryptionPreparation = (async () => { try {
try { logger.debug(`Getting devices in ${this.roomId}`);
logger.debug(`Getting devices in ${this.roomId}`); const [devicesInRoom, blocked] = await this.getDevicesInRoom(room);
const [devicesInRoom, blocked] = await this.getDevicesInRoom(room);
if (this.crypto.getGlobalErrorOnUnknownDevices()) { if (this.crypto.getGlobalErrorOnUnknownDevices()) {
// Drop unknown devices for now. When the message gets sent, we'll // Drop unknown devices for now. When the message gets sent, we'll
// throw an error, but we'll still be prepared to send to the known // throw an error, but we'll still be prepared to send to the known
// devices. // devices.
this.removeUnknownDevices(devicesInRoom); this.removeUnknownDevices(devicesInRoom);
}
logger.debug(`Ensuring outbound session in ${this.roomId}`);
await this.ensureOutboundSession(room, devicesInRoom, blocked, true);
logger.debug(`Ready to encrypt events for ${this.roomId}`);
} catch (e) {
logger.error(`Failed to prepare to encrypt events for ${this.roomId}`, e);
} finally {
delete this.encryptionPreparation;
} }
})(),
logger.debug(`Ensuring outbound session in ${this.roomId}`); };
await this.ensureOutboundSession(room, devicesInRoom, blocked, true);
logger.debug(`Ready to encrypt events for ${this.roomId}`);
} catch (e) {
logger.error(`Failed to prepare to encrypt events for ${this.roomId}`, e);
} finally {
delete this.encryptionPreparationMetadata;
delete this.encryptionPreparation;
}
})();
} }
/** /**
@ -992,12 +1002,12 @@ class MegolmEncryption extends EncryptionAlgorithm {
public async encryptMessage(room: Room, eventType: string, content: object): Promise<object> { public async encryptMessage(room: Room, eventType: string, content: object): Promise<object> {
logger.log(`Starting to encrypt event for ${this.roomId}`); logger.log(`Starting to encrypt event for ${this.roomId}`);
if (this.encryptionPreparation) { if (this.encryptionPreparation != null) {
// If we started sending keys, wait for it to be done. // If we started sending keys, wait for it to be done.
// FIXME: check if we need to cancel // FIXME: check if we need to cancel
// (https://github.com/matrix-org/matrix-js-sdk/issues/1255) // (https://github.com/matrix-org/matrix-js-sdk/issues/1255)
try { try {
await this.encryptionPreparation; await this.encryptionPreparation.promise;
} catch (e) { } catch (e) {
// ignore any errors -- if the preparation failed, we'll just // ignore any errors -- if the preparation failed, we'll just
// restart everything here // restart everything here
@ -1212,7 +1222,7 @@ class MegolmDecryption extends DecryptionAlgorithm {
// (fixes https://github.com/vector-im/element-web/issues/5001) // (fixes https://github.com/vector-im/element-web/issues/5001)
this.addEventToPendingList(event); this.addEventToPendingList(event);
let res; let res: IDecryptedGroupMessage;
try { try {
res = await this.olmDevice.decryptGroupMessage( res = await this.olmDevice.decryptGroupMessage(
event.getRoomId(), content.sender_key, content.session_id, content.ciphertext, event.getRoomId(), content.sender_key, content.session_id, content.ciphertext,
@ -1242,7 +1252,9 @@ class MegolmDecryption extends DecryptionAlgorithm {
if (res === null) { if (res === null) {
// We've got a message for a session we don't have. // We've got a message for a session we don't have.
// // try and get the missing key from the backup first
this.crypto.backupManager.queryKeyBackupRateLimited(event.getRoomId(), content.session_id).catch(() => {});
// (XXX: We might actually have received this key since we started // (XXX: We might actually have received this key since we started
// decrypting, in which case we'll have scheduled a retry, and this // decrypting, in which case we'll have scheduled a retry, and this
// request will be redundant. We could probably check to see if the // request will be redundant. We could probably check to see if the
@ -1335,7 +1347,7 @@ class MegolmDecryption extends DecryptionAlgorithm {
if (!senderPendingEvents.has(sessionId)) { if (!senderPendingEvents.has(sessionId)) {
senderPendingEvents.set(sessionId, new Set()); senderPendingEvents.set(sessionId, new Set());
} }
senderPendingEvents.get(sessionId).add(event); senderPendingEvents.get(sessionId)?.add(event);
} }
/** /**
@ -1369,17 +1381,17 @@ class MegolmDecryption extends DecryptionAlgorithm {
* *
* @param {module:models/event.MatrixEvent} event key event * @param {module:models/event.MatrixEvent} event key event
*/ */
public onRoomKeyEvent(event: MatrixEvent): Promise<void> { public async onRoomKeyEvent(event: MatrixEvent): Promise<void> {
const content = event.getContent(); const content = event.getContent<Partial<IMessage["content"]>>();
const sessionId = content.session_id;
let senderKey = event.getSenderKey(); let senderKey = event.getSenderKey();
let forwardingKeyChain = []; let forwardingKeyChain: string[] = [];
let exportFormat = false; let exportFormat = false;
let keysClaimed; let keysClaimed: ReturnType<MatrixEvent["getKeysClaimed"]>;
if (!content.room_id || if (!content.room_id ||
!sessionId || !content.session_key ||
!content.session_key !content.session_id ||
!content.algorithm
) { ) {
logger.error("key event is missing fields"); logger.error("key event is missing fields");
return; return;
@ -1392,20 +1404,18 @@ class MegolmDecryption extends DecryptionAlgorithm {
if (event.getType() == "m.forwarded_room_key") { if (event.getType() == "m.forwarded_room_key") {
exportFormat = true; exportFormat = true;
forwardingKeyChain = content.forwarding_curve25519_key_chain; forwardingKeyChain = Array.isArray(content.forwarding_curve25519_key_chain) ?
if (!Array.isArray(forwardingKeyChain)) { content.forwarding_curve25519_key_chain : [];
forwardingKeyChain = [];
}
// copy content before we modify it // copy content before we modify it
forwardingKeyChain = forwardingKeyChain.slice(); forwardingKeyChain = forwardingKeyChain.slice();
forwardingKeyChain.push(senderKey); forwardingKeyChain.push(senderKey);
senderKey = content.sender_key; if (!content.sender_key) {
if (!senderKey) {
logger.error("forwarded_room_key event is missing sender_key field"); logger.error("forwarded_room_key event is missing sender_key field");
return; return;
} }
senderKey = content.sender_key;
const ed25519Key = content.sender_claimed_ed25519_key; const ed25519Key = content.sender_claimed_ed25519_key;
if (!ed25519Key) { if (!ed25519Key) {
@ -1426,34 +1436,39 @@ class MegolmDecryption extends DecryptionAlgorithm {
if (content["org.matrix.msc3061.shared_history"]) { if (content["org.matrix.msc3061.shared_history"]) {
extraSessionData.sharedHistory = true; extraSessionData.sharedHistory = true;
} }
return this.olmDevice.addInboundGroupSession(
content.room_id, senderKey, forwardingKeyChain, sessionId, try {
content.session_key, keysClaimed, await this.olmDevice.addInboundGroupSession(
exportFormat, extraSessionData, content.room_id,
).then(() => { senderKey,
forwardingKeyChain,
content.session_id,
content.session_key,
keysClaimed,
exportFormat,
extraSessionData,
);
// have another go at decrypting events sent with this session. // have another go at decrypting events sent with this session.
this.retryDecryption(senderKey, sessionId) if (await this.retryDecryption(senderKey, content.session_id)) {
.then((success) => { // cancel any outstanding room key requests for this session.
// cancel any outstanding room key requests for this session. // Only do this if we managed to decrypt every message in the
// Only do this if we managed to decrypt every message in the // session, because if we didn't, we leave the other key
// session, because if we didn't, we leave the other key // requests in the hopes that someone sends us a key that
// requests in the hopes that someone sends us a key that // includes an earlier index.
// includes an earlier index. this.crypto.cancelRoomKeyRequest({
if (success) { algorithm: content.algorithm,
this.crypto.cancelRoomKeyRequest({ room_id: content.room_id,
algorithm: content.algorithm, session_id: content.session_id,
room_id: content.room_id, sender_key: senderKey,
session_id: content.session_id,
sender_key: senderKey,
});
}
}); });
}).then(() => { }
// don't wait for the keys to be backed up for the server // don't wait for the keys to be backed up for the server
this.crypto.backupManager.backupGroupSession(senderKey, content.session_id); await this.crypto.backupManager.backupGroupSession(senderKey, content.session_id);
}).catch((e) => { } catch (e) {
logger.error(`Error handling m.room_key_event: ${e}`); logger.error(`Error handling m.room_key_event: ${e}`);
}); }
} }
/** /**
@ -1651,7 +1666,10 @@ class MegolmDecryption extends DecryptionAlgorithm {
* @param {boolean} [opts.untrusted] whether the key should be considered as untrusted * @param {boolean} [opts.untrusted] whether the key should be considered as untrusted
* @param {string} [opts.source] where the key came from * @param {string} [opts.source] where the key came from
*/ */
public importRoomKey(session: IMegolmSessionData, opts: any = {}): Promise<void> { public importRoomKey(
session: IMegolmSessionData,
opts: { untrusted?: boolean, source?: string } = {},
): Promise<void> {
const extraSessionData: any = {}; const extraSessionData: any = {};
if (opts.untrusted || session.untrusted) { if (opts.untrusted || session.untrusted) {
extraSessionData.untrusted = true; extraSessionData.untrusted = true;

View File

@ -32,6 +32,7 @@ import {
import { Room } from '../../models/room'; import { Room } from '../../models/room';
import { MatrixEvent } from "../.."; import { MatrixEvent } from "../..";
import { IEventDecryptionResult } from "../index"; import { IEventDecryptionResult } from "../index";
import { IInboundSession } from "../OlmDevice";
const DeviceVerification = DeviceInfo.DeviceVerification; const DeviceVerification = DeviceInfo.DeviceVerification;
@ -51,7 +52,7 @@ interface IMessage {
*/ */
class OlmEncryption extends EncryptionAlgorithm { class OlmEncryption extends EncryptionAlgorithm {
private sessionPrepared = false; private sessionPrepared = false;
private prepPromise: Promise<void> = null; private prepPromise: Promise<void> | null = null;
/** /**
* @private * @private
@ -116,11 +117,11 @@ class OlmEncryption extends EncryptionAlgorithm {
ciphertext: {}, ciphertext: {},
}; };
const promises = []; const promises: Promise<void>[] = [];
for (let i = 0; i < users.length; ++i) { for (let i = 0; i < users.length; ++i) {
const userId = users[i]; const userId = users[i];
const devices = this.crypto.getStoredDevicesForUser(userId); const devices = this.crypto.getStoredDevicesForUser(userId) || [];
for (let j = 0; j < devices.length; ++j) { for (let j = 0; j < devices.length; ++j) {
const deviceInfo = devices[j]; const deviceInfo = devices[j];
@ -239,7 +240,7 @@ class OlmDecryption extends DecryptionAlgorithm {
throw new DecryptionError( throw new DecryptionError(
"OLM_BAD_ROOM", "OLM_BAD_ROOM",
"Message intended for room " + payload.room_id, { "Message intended for room " + payload.room_id, {
reported_room: event.getRoomId(), reported_room: event.getRoomId() || "ROOM_ID_UNDEFINED",
}, },
); );
} }
@ -331,7 +332,7 @@ class OlmDecryption extends DecryptionAlgorithm {
// prekey message which doesn't match any existing sessions: make a new // prekey message which doesn't match any existing sessions: make a new
// session. // session.
let res; let res: IInboundSession;
try { try {
res = await this.olmDevice.createInboundSession( res = await this.olmDevice.createInboundSession(
theirDeviceIdentityKey, message.type, message.body, theirDeviceIdentityKey, message.type, message.body,

View File

@ -35,6 +35,7 @@ import { UnstableValue } from "../NamespacedValue";
import { CryptoEvent, IMegolmSessionData } from "./index"; import { CryptoEvent, IMegolmSessionData } from "./index";
const KEY_BACKUP_KEYS_PER_REQUEST = 200; const KEY_BACKUP_KEYS_PER_REQUEST = 200;
const KEY_BACKUP_CHECK_RATE_LIMIT = 5000; // ms
type AuthData = IKeyBackupInfo["auth_data"]; type AuthData = IKeyBackupInfo["auth_data"];
@ -111,6 +112,8 @@ export class BackupManager {
public backupInfo: IKeyBackupInfo | undefined; // The info dict from /room_keys/version public backupInfo: IKeyBackupInfo | undefined; // The info dict from /room_keys/version
public checkedForBackup: boolean; // Have we checked the server for a backup we can use? public checkedForBackup: boolean; // Have we checked the server for a backup we can use?
private sendingBackups: boolean; // Are we currently sending backups? private sendingBackups: boolean; // Are we currently sending backups?
private sessionLastCheckAttemptedTime: Record<string, number> = {}; // When did we last try to check the server for a given session id?
constructor(private readonly baseApis: MatrixClient, public readonly getKey: GetKey) { constructor(private readonly baseApis: MatrixClient, public readonly getKey: GetKey) {
this.checkedForBackup = false; this.checkedForBackup = false;
this.sendingBackups = false; this.sendingBackups = false;
@ -282,6 +285,26 @@ export class BackupManager {
return this.checkAndStart(); return this.checkAndStart();
} }
/**
* Attempts to retrieve a session from a key backup, if enough time
* has elapsed since the last check for this session id.
*/
public async queryKeyBackupRateLimited(
targetRoomId: string | undefined,
targetSessionId: string | undefined,
): Promise<void> {
if (!this.backupInfo) { return; }
const now = new Date().getTime();
if (
!this.sessionLastCheckAttemptedTime[targetSessionId]
|| now - this.sessionLastCheckAttemptedTime[targetSessionId] > KEY_BACKUP_CHECK_RATE_LIMIT
) {
this.sessionLastCheckAttemptedTime[targetSessionId] = now;
await this.baseApis.restoreKeyBackupWithCache(targetRoomId, targetSessionId, this.backupInfo, {});
}
}
/** /**
* Check if the given backup info is trusted. * Check if the given backup info is trusted.
* *
@ -428,7 +451,7 @@ export class BackupManager {
// requests from different clients hitting the server all at // requests from different clients hitting the server all at
// the same time when a new key is sent // the same time when a new key is sent
const delay = Math.random() * maxDelay; const delay = Math.random() * maxDelay;
await sleep(delay, undefined); await sleep(delay);
let numFailures = 0; // number of consecutive failures let numFailures = 0; // number of consecutive failures
for (;;) { for (;;) {
if (!this.algorithm) { if (!this.algorithm) {
@ -462,7 +485,7 @@ export class BackupManager {
} }
if (numFailures) { if (numFailures) {
// exponential backoff if we have failures // exponential backoff if we have failures
await sleep(1000 * Math.pow(2, Math.min(numFailures - 1, 4)), undefined); await sleep(1000 * Math.pow(2, Math.min(numFailures - 1, 4)));
} }
} }
} finally { } finally {
@ -474,8 +497,8 @@ export class BackupManager {
* Take some e2e keys waiting to be backed up and send them * Take some e2e keys waiting to be backed up and send them
* to the backup. * to the backup.
* *
* @param {integer} limit Maximum number of keys to back up * @param {number} limit Maximum number of keys to back up
* @returns {integer} Number of sessions backed up * @returns {number} Number of sessions backed up
*/ */
public async backupPendingKeys(limit: number): Promise<number> { public async backupPendingKeys(limit: number): Promise<number> {
const sessions = await this.baseApis.crypto.cryptoStore.getSessionsNeedingBackup(limit); const sessions = await this.baseApis.crypto.cryptoStore.getSessionsNeedingBackup(limit);

View File

@ -59,7 +59,7 @@ import { keyFromPassphrase } from './key_passphrase';
import { decodeRecoveryKey, encodeRecoveryKey } from './recoverykey'; import { decodeRecoveryKey, encodeRecoveryKey } from './recoverykey';
import { VerificationRequest } from "./verification/request/VerificationRequest"; import { VerificationRequest } from "./verification/request/VerificationRequest";
import { InRoomChannel, InRoomRequests } from "./verification/request/InRoomChannel"; import { InRoomChannel, InRoomRequests } from "./verification/request/InRoomChannel";
import { ToDeviceChannel, ToDeviceRequests } from "./verification/request/ToDeviceChannel"; import { ToDeviceChannel, ToDeviceRequests, Request } from "./verification/request/ToDeviceChannel";
import { IllegalMethod } from "./verification/IllegalMethod"; import { IllegalMethod } from "./verification/IllegalMethod";
import { KeySignatureUploadError } from "../errors"; import { KeySignatureUploadError } from "../errors";
import { calculateKeyCheck, decryptAES, encryptAES } from './aes'; import { calculateKeyCheck, decryptAES, encryptAES } from './aes';
@ -76,7 +76,6 @@ import {
ISignedKey, ISignedKey,
IUploadKeySignaturesResponse, IUploadKeySignaturesResponse,
MatrixClient, MatrixClient,
SessionStore,
} from "../client"; } from "../client";
import type { IRoomEncryption, RoomList } from "./RoomList"; import type { IRoomEncryption, RoomList } from "./RoomList";
import { IKeyBackupInfo } from "./keybackup"; import { IKeyBackupInfo } from "./keybackup";
@ -122,7 +121,7 @@ interface IInitOpts {
export interface IBootstrapCrossSigningOpts { export interface IBootstrapCrossSigningOpts {
setupNewCrossSigning?: boolean; setupNewCrossSigning?: boolean;
authUploadDeviceSigningKeys?(makeRequest: (authData: any) => {}): Promise<void>; authUploadDeviceSigningKeys?(makeRequest: (authData: any) => Promise<{}>): Promise<void>;
} }
/* eslint-disable camelcase */ /* eslint-disable camelcase */
@ -203,6 +202,19 @@ export interface IRequestsMap {
setRequestByChannel(channel: IVerificationChannel, request: VerificationRequest): void; setRequestByChannel(channel: IVerificationChannel, request: VerificationRequest): void;
} }
/* eslint-disable camelcase */
export interface IEncryptedContent {
algorithm: string;
sender_key: string;
ciphertext: Record<string, string>;
}
/* eslint-enable camelcase */
interface IEncryptAndSendToDevicesResult {
contentMap: Record<string, Record<string, IEncryptedContent>>;
deviceInfoByUserIdAndDeviceId: Map<string, Map<string, DeviceInfo>>;
}
export enum CryptoEvent { export enum CryptoEvent {
DeviceVerificationChanged = "deviceVerificationChanged", DeviceVerificationChanged = "deviceVerificationChanged",
UserTrustStatusChanged = "userTrustStatusChanged", UserTrustStatusChanged = "userTrustStatusChanged",
@ -324,9 +336,6 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
* *
* @param {MatrixClient} baseApis base matrix api interface * @param {MatrixClient} baseApis base matrix api interface
* *
* @param {module:store/session/webstorage~WebStorageSessionStore} sessionStore
* Store to be used for end-to-end crypto session data
*
* @param {string} userId The user ID for the local user * @param {string} userId The user ID for the local user
* *
* @param {string} deviceId The identifier for this device. * @param {string} deviceId The identifier for this device.
@ -344,7 +353,6 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
*/ */
constructor( constructor(
public readonly baseApis: MatrixClient, public readonly baseApis: MatrixClient,
public readonly sessionStore: SessionStore,
public readonly userId: string, public readonly userId: string,
private readonly deviceId: string, private readonly deviceId: string,
private readonly clientStore: IStore, private readonly clientStore: IStore,
@ -1077,11 +1085,8 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
return this.secretStorage.get(name); return this.secretStorage.get(name);
} }
public isSecretStored( public isSecretStored(name: string): Promise<Record<string, ISecretStorageKeyInfo> | null> {
name: string, return this.secretStorage.isStored(name);
checkKey?: boolean,
): Promise<Record<string, ISecretStorageKeyInfo> | null> {
return this.secretStorage.isStored(name, checkKey);
} }
public requestSecret(name: string, devices: string[]): ISecretRequest { public requestSecret(name: string, devices: string[]): ISecretRequest {
@ -1729,13 +1734,6 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
logger.info(`Finished device verification upgrade for ${userId}`); logger.info(`Finished device verification upgrade for ${userId}`);
} }
public async setTrustedBackupPubKey(trustedPubKey: string): Promise<void> {
// This should be redundant post cross-signing is a thing, so just
// plonk it in localStorage for now.
this.sessionStore.setLocalTrustedBackupPubKey(trustedPubKey);
await this.backupManager.checkKeyBackup();
}
/** /**
*/ */
public enableLazyLoading(): void { public enableLazyLoading(): void {
@ -2322,8 +2320,8 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
userId: string, userId: string,
deviceId: string, deviceId: string,
transactionId: string = null, transactionId: string = null,
): any { // TODO types ): VerificationBase<any, any> {
let request; let request: Request;
if (transactionId) { if (transactionId) {
request = this.toDeviceVerificationRequests.getRequestBySenderAndTxnId(userId, transactionId); request = this.toDeviceVerificationRequests.getRequestBySenderAndTxnId(userId, transactionId);
if (!request) { if (!request) {
@ -2594,7 +2592,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
// because it first stores in memory. We should await the promise only // because it first stores in memory. We should await the promise only
// after all the in-memory state (roomEncryptors and _roomList) has been updated // after all the in-memory state (roomEncryptors and _roomList) has been updated
// to avoid races when calling this method multiple times. Hence keep a hold of the promise. // to avoid races when calling this method multiple times. Hence keep a hold of the promise.
let storeConfigPromise = null; let storeConfigPromise: Promise<void> = null;
if (!existingConfig) { if (!existingConfig) {
storeConfigPromise = this.roomList.setRoomEncryption(roomId, config); storeConfigPromise = this.roomList.setRoomEncryption(roomId, config);
} }
@ -3131,36 +3129,40 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
public encryptAndSendToDevices( public encryptAndSendToDevices(
userDeviceInfoArr: IOlmDevice<DeviceInfo>[], userDeviceInfoArr: IOlmDevice<DeviceInfo>[],
payload: object, payload: object,
): Promise<{contentMap, deviceInfoByDeviceId}> { ): Promise<IEncryptAndSendToDevicesResult> {
const contentMap = {}; const contentMap: Record<string, Record<string, IEncryptedContent>> = {};
const deviceInfoByDeviceId = new Map<string, DeviceInfo>(); const deviceInfoByUserIdAndDeviceId = new Map<string, Map<string, DeviceInfo>>();
const promises = []; const promises: Promise<unknown>[] = [];
for (let i = 0; i < userDeviceInfoArr.length; i++) { for (const { userId, deviceInfo } of userDeviceInfoArr) {
const encryptedContent = { const deviceId = deviceInfo.deviceId;
const encryptedContent: IEncryptedContent = {
algorithm: olmlib.OLM_ALGORITHM, algorithm: olmlib.OLM_ALGORITHM,
sender_key: this.olmDevice.deviceCurve25519Key, sender_key: this.olmDevice.deviceCurve25519Key,
ciphertext: {}, ciphertext: {},
}; };
const val = userDeviceInfoArr[i];
const userId = val.userId; // Assign to temp value to make type-checking happy
const deviceInfo = val.deviceInfo; let userIdDeviceInfo = deviceInfoByUserIdAndDeviceId.get(userId);
const deviceId = deviceInfo.deviceId;
deviceInfoByDeviceId.set(deviceId, deviceInfo); if (userIdDeviceInfo === undefined) {
userIdDeviceInfo = new Map<string, DeviceInfo>();
deviceInfoByUserIdAndDeviceId.set(userId, userIdDeviceInfo);
}
// We hold by reference, this updates deviceInfoByUserIdAndDeviceId[userId]
userIdDeviceInfo.set(deviceId, deviceInfo);
if (!contentMap[userId]) { if (!contentMap[userId]) {
contentMap[userId] = {}; contentMap[userId] = {};
} }
contentMap[userId][deviceId] = encryptedContent; contentMap[userId][deviceId] = encryptedContent;
const devicesByUser = {};
devicesByUser[userId] = [deviceInfo];
promises.push( promises.push(
olmlib.ensureOlmSessionsForDevices( olmlib.ensureOlmSessionsForDevices(
this.olmDevice, this.olmDevice,
this.baseApis, this.baseApis,
devicesByUser, { [userId]: [deviceInfo] },
).then(() => ).then(() =>
olmlib.encryptMessageForDevice( olmlib.encryptMessageForDevice(
encryptedContent.ciphertext, encryptedContent.ciphertext,
@ -3183,16 +3185,13 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
for (const userId of Object.keys(contentMap)) { for (const userId of Object.keys(contentMap)) {
for (const deviceId of Object.keys(contentMap[userId])) { for (const deviceId of Object.keys(contentMap[userId])) {
if (Object.keys(contentMap[userId][deviceId].ciphertext).length === 0) { if (Object.keys(contentMap[userId][deviceId].ciphertext).length === 0) {
logger.log( logger.log(`No ciphertext for device ${userId}:${deviceId}: pruning`);
"No ciphertext for device " +
userId + ":" + deviceId + ": pruning",
);
delete contentMap[userId][deviceId]; delete contentMap[userId][deviceId];
} }
} }
// No devices left for that user? Strip that too. // No devices left for that user? Strip that too.
if (Object.keys(contentMap[userId]).length === 0) { if (Object.keys(contentMap[userId]).length === 0) {
logger.log("Pruned all devices for user " + userId); logger.log(`Pruned all devices for user ${userId}`);
delete contentMap[userId]; delete contentMap[userId];
} }
} }
@ -3204,7 +3203,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
} }
return this.baseApis.sendToDevice("m.room.encrypted", contentMap).then( return this.baseApis.sendToDevice("m.room.encrypted", contentMap).then(
(response) => ({ contentMap, deviceInfoByDeviceId }), (response) => ({ contentMap, deviceInfoByUserIdAndDeviceId }),
).catch(error => { ).catch(error => {
logger.error("sendToDevice failed", error); logger.error("sendToDevice failed", error);
throw error; throw error;
@ -3402,7 +3401,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
event.on(MatrixEventEvent.Status, statusListener); event.on(MatrixEventEvent.Status, statusListener);
}); });
} catch (err) { } catch (err) {
logger.error("error while waiting for the verification event to be sent: " + err.message); logger.error("error while waiting for the verification event to be sent: ", err);
return; return;
} finally { } finally {
event.removeListener(MatrixEventEvent.LocalEventIdReplaced, eventIdListener); event.removeListener(MatrixEventEvent.LocalEventIdReplaced, eventIdListener);
@ -3426,7 +3425,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
try { try {
await request.channel.handleEvent(event, request, isLiveEvent); await request.channel.handleEvent(event, request, isLiveEvent);
} catch (err) { } catch (err) {
logger.error("error while handling verification event: " + err.message); logger.error("error while handling verification event", err);
} }
const shouldEmit = isNewRequest && const shouldEmit = isNewRequest &&
!request.initiatedByMe && !request.initiatedByMe &&

View File

@ -15,17 +15,19 @@ limitations under the License.
*/ */
import { ISigned } from "../@types/signed"; import { ISigned } from "../@types/signed";
import { IEncryptedPayload } from "./aes";
export interface Curve25519SessionData {
ciphertext: string;
ephemeral: string;
mac: string;
}
export interface IKeyBackupSession { export interface IKeyBackupSession {
first_message_index: number; // eslint-disable-line camelcase first_message_index: number; // eslint-disable-line camelcase
forwarded_count: number; // eslint-disable-line camelcase forwarded_count: number; // eslint-disable-line camelcase
is_verified: boolean; // eslint-disable-line camelcase is_verified: boolean; // eslint-disable-line camelcase
session_data: { // eslint-disable-line camelcase session_data: Curve25519SessionData | IEncryptedPayload; // eslint-disable-line camelcase
ciphertext: string;
ephemeral: string;
mac: string;
iv: string;
};
} }
export interface IKeyBackupRoomSessions { export interface IKeyBackupRoomSessions {

View File

@ -76,7 +76,7 @@ export interface IOlmSessionResult {
export async function encryptMessageForDevice( export async function encryptMessageForDevice(
resultsObject: Record<string, string>, resultsObject: Record<string, string>,
ourUserId: string, ourUserId: string,
ourDeviceId: string, ourDeviceId: string | undefined,
olmDevice: OlmDevice, olmDevice: OlmDevice,
recipientUserId: string, recipientUserId: string,
recipientDevice: DeviceInfo, recipientDevice: DeviceInfo,
@ -323,7 +323,7 @@ export async function ensureOlmSessionsForDevices(
} }
const oneTimeKeyAlgorithm = "signed_curve25519"; const oneTimeKeyAlgorithm = "signed_curve25519";
let res; let res: IClaimOTKsResult;
let taskDetail = `one-time keys for ${devicesWithoutSession.length} devices`; let taskDetail = `one-time keys for ${devicesWithoutSession.length} devices`;
try { try {
log.debug(`Claiming ${taskDetail}`); log.debug(`Claiming ${taskDetail}`);

View File

@ -21,7 +21,7 @@ import * as bs58 from 'bs58';
const OLM_RECOVERY_KEY_PREFIX = [0x8B, 0x01]; const OLM_RECOVERY_KEY_PREFIX = [0x8B, 0x01];
export function encodeRecoveryKey(key: ArrayLike<number>): string { export function encodeRecoveryKey(key: ArrayLike<number>): string {
const buf = new Buffer(OLM_RECOVERY_KEY_PREFIX.length + key.length + 1); const buf = Buffer.alloc(OLM_RECOVERY_KEY_PREFIX.length + key.length + 1);
buf.set(OLM_RECOVERY_KEY_PREFIX, 0); buf.set(OLM_RECOVERY_KEY_PREFIX, 0);
buf.set(key, OLM_RECOVERY_KEY_PREFIX.length); buf.set(key, OLM_RECOVERY_KEY_PREFIX.length);

View File

@ -157,7 +157,7 @@ export class IndexedDBCryptoStore implements CryptoStore {
} }
}).then(backend => { }).then(backend => {
this.backend = backend; this.backend = backend;
return backend as CryptoStore; return backend;
}); });
return this.backendPromise; return this.backendPromise;

View File

@ -841,11 +841,11 @@ export class VerificationRequest<
} }
const isUnexpectedRequest = type === REQUEST_TYPE && this.phase !== PHASE_UNSENT; const isUnexpectedRequest = type === REQUEST_TYPE && this.phase !== PHASE_UNSENT;
const isUnexpectedReady = type === READY_TYPE && this.phase !== PHASE_REQUESTED; const isUnexpectedReady = type === READY_TYPE && this.phase !== PHASE_REQUESTED && this.phase !== PHASE_STARTED;
// only if phase has passed from PHASE_UNSENT should we cancel, because events // only if phase has passed from PHASE_UNSENT should we cancel, because events
// are allowed to come in in any order (at least with InRoomChannel). So we only know // are allowed to come in in any order (at least with InRoomChannel). So we only know
// we're dealing with a valid request we should participate in once we've moved to PHASE_REQUESTED // we're dealing with a valid request we should participate in once we've moved to PHASE_REQUESTED.
// before that, we could be looking at somebody elses verification request and we just // Before that, we could be looking at somebody else's verification request and we just
// happen to be in the room // happen to be in the room
if (this.phase !== PHASE_UNSENT && (isUnexpectedRequest || isUnexpectedReady)) { if (this.phase !== PHASE_UNSENT && (isUnexpectedRequest || isUnexpectedReady)) {
logger.warn(`Cancelling, unexpected ${type} verification ` + logger.warn(`Cancelling, unexpected ${type} verification ` +

View File

@ -48,10 +48,15 @@ TODO:
export const PREFIX_R0 = "/_matrix/client/r0"; export const PREFIX_R0 = "/_matrix/client/r0";
/** /**
* A constant representing the URI path for release v1 of the Client-Server HTTP API. * A constant representing the URI path for the legacy release v1 of the Client-Server HTTP API.
*/ */
export const PREFIX_V1 = "/_matrix/client/v1"; export const PREFIX_V1 = "/_matrix/client/v1";
/**
* A constant representing the URI path for Client-Server API endpoints versioned at v3.
*/
export const PREFIX_V3 = "/_matrix/client/v3";
/** /**
* A constant representing the URI path for as-yet unspecified Client-Server HTTP APIs. * A constant representing the URI path for as-yet unspecified Client-Server HTTP APIs.
*/ */
@ -403,7 +408,7 @@ export class MatrixHttpApi {
resp = bodyParser(resp); resp = bodyParser(resp);
} }
} catch (err) { } catch (err) {
err.http_status = xhr.status; err.httpStatus = xhr.status;
cb(err); cb(err);
return; return;
} }
@ -1055,7 +1060,7 @@ interface IErrorJson extends Partial<IUsageLimit> {
* @prop {string} name Same as MatrixError.errcode but with a default unknown string. * @prop {string} name Same as MatrixError.errcode but with a default unknown string.
* @prop {string} message The Matrix 'error' value, e.g. "Missing token." * @prop {string} message The Matrix 'error' value, e.g. "Missing token."
* @prop {Object} data The raw Matrix error JSON used to construct this object. * @prop {Object} data The raw Matrix error JSON used to construct this object.
* @prop {integer} httpStatus The numeric HTTP status code given * @prop {number} httpStatus The numeric HTTP status code given
*/ */
export class MatrixError extends Error { export class MatrixError extends Error {
public readonly errcode: string; public readonly errcode: string;

View File

@ -44,11 +44,19 @@ export interface IStageStatus {
export interface IAuthData { export interface IAuthData {
session?: string; session?: string;
type?: string;
completed?: string[]; completed?: string[];
flows?: IFlow[]; flows?: IFlow[];
available_flows?: IFlow[];
stages?: string[];
required_stages?: AuthType[];
params?: Record<string, Record<string, any>>; params?: Record<string, Record<string, any>>;
data?: Record<string, string>;
errcode?: string; errcode?: string;
error?: string; error?: string;
user_id?: string;
device_id?: string;
access_token?: string;
} }
export enum AuthType { export enum AuthType {
@ -203,6 +211,8 @@ export class InteractiveAuth {
private chosenFlow: IFlow = null; private chosenFlow: IFlow = null;
private currentStage: string = null; private currentStage: string = null;
private emailAttempt = 1;
// if we are currently trying to submit an auth dict (which includes polling) // if we are currently trying to submit an auth dict (which includes polling)
// the promise the will resolve/reject when it completes // the promise the will resolve/reject when it completes
private submitPromise: Promise<void> = null; private submitPromise: Promise<void> = null;
@ -408,6 +418,34 @@ export class InteractiveAuth {
this.emailSid = sid; this.emailSid = sid;
} }
/**
* Requests a new email token and sets the email sid for the validation session
*/
public requestEmailToken = async () => {
if (!this.requestingEmailToken) {
logger.trace("Requesting email token. Attempt: " + this.emailAttempt);
// If we've picked a flow with email auth, we send the email
// now because we want the request to fail as soon as possible
// if the email address is not valid (ie. already taken or not
// registered, depending on what the operation is).
this.requestingEmailToken = true;
try {
const requestTokenResult = await this.requestEmailTokenCallback(
this.inputs.emailAddress,
this.clientSecret,
this.emailAttempt++,
this.data.session,
);
this.emailSid = requestTokenResult.sid;
logger.trace("Email token request succeeded");
} finally {
this.requestingEmailToken = false;
}
} else {
logger.warn("Could not request email token: Already requesting");
}
};
/** /**
* Fire off a request, and either resolve the promise, or call * Fire off a request, and either resolve the promise, or call
* startAuthStage. * startAuthStage.
@ -458,24 +496,9 @@ export class InteractiveAuth {
return; return;
} }
if ( if (!this.emailSid && this.chosenFlow.stages.includes(AuthType.Email)) {
!this.emailSid &&
!this.requestingEmailToken &&
this.chosenFlow.stages.includes(AuthType.Email)
) {
// If we've picked a flow with email auth, we send the email
// now because we want the request to fail as soon as possible
// if the email address is not valid (ie. already taken or not
// registered, depending on what the operation is).
this.requestingEmailToken = true;
try { try {
const requestTokenResult = await this.requestEmailTokenCallback( await this.requestEmailToken();
this.inputs.emailAddress,
this.clientSecret,
1, // TODO: Multiple send attempts?
this.data.session,
);
this.emailSid = requestTokenResult.sid;
// NB. promise is not resolved here - at some point, doRequest // NB. promise is not resolved here - at some point, doRequest
// will be called again and if the user has jumped through all // will be called again and if the user has jumped through all
// the hoops correctly, auth will be complete and the request // the hoops correctly, auth will be complete and the request
@ -491,8 +514,6 @@ export class InteractiveAuth {
// send the email, for whatever reason. // send the email, for whatever reason.
this.attemptAuthDeferred.reject(e); this.attemptAuthDeferred.reject(e);
this.attemptAuthDeferred = null; this.attemptAuthDeferred = null;
} finally {
this.requestingEmailToken = false;
} }
} }
} }

View File

@ -41,7 +41,6 @@ export * from "./interactive-auth";
export * from "./service-types"; export * from "./service-types";
export * from "./store/memory"; export * from "./store/memory";
export * from "./store/indexeddb"; export * from "./store/indexeddb";
export * from "./store/session/webstorage";
export * from "./crypto/store/memory-crypto-store"; export * from "./crypto/store/memory-crypto-store";
export * from "./crypto/store/indexeddb-crypto-store"; export * from "./crypto/store/indexeddb-crypto-store";
export * from "./content-repo"; export * from "./content-repo";

View File

@ -54,8 +54,8 @@ export class Beacon extends TypedEventEmitter<Exclude<BeaconEvent, BeaconEvent.N
public readonly roomId: string; public readonly roomId: string;
private _beaconInfo: BeaconInfoState; private _beaconInfo: BeaconInfoState;
private _isLive: boolean; private _isLive: boolean;
private livenessWatchInterval: ReturnType<typeof setInterval>; private livenessWatchTimeout: ReturnType<typeof setTimeout>;
private _latestLocationState: BeaconLocationState | undefined; private _latestLocationEvent: MatrixEvent | undefined;
constructor( constructor(
private rootEvent: MatrixEvent, private rootEvent: MatrixEvent,
@ -90,7 +90,11 @@ export class Beacon extends TypedEventEmitter<Exclude<BeaconEvent, BeaconEvent.N
} }
public get latestLocationState(): BeaconLocationState | undefined { public get latestLocationState(): BeaconLocationState | undefined {
return this._latestLocationState; return this._latestLocationEvent && parseBeaconContent(this._latestLocationEvent.getContent());
}
public get latestLocationEvent(): MatrixEvent | undefined {
return this._latestLocationEvent;
} }
public update(beaconInfoEvent: MatrixEvent): void { public update(beaconInfoEvent: MatrixEvent): void {
@ -109,8 +113,8 @@ export class Beacon extends TypedEventEmitter<Exclude<BeaconEvent, BeaconEvent.N
} }
public destroy(): void { public destroy(): void {
if (this.livenessWatchInterval) { if (this.livenessWatchTimeout) {
clearInterval(this.livenessWatchInterval); clearTimeout(this.livenessWatchTimeout);
} }
this._isLive = false; this._isLive = false;
@ -122,19 +126,26 @@ export class Beacon extends TypedEventEmitter<Exclude<BeaconEvent, BeaconEvent.N
* Emits BeaconEvent.LivenessChange when beacon expires * Emits BeaconEvent.LivenessChange when beacon expires
*/ */
public monitorLiveness(): void { public monitorLiveness(): void {
if (this.livenessWatchInterval) { if (this.livenessWatchTimeout) {
clearInterval(this.livenessWatchInterval); clearTimeout(this.livenessWatchTimeout);
} }
this.checkLiveness(); this.checkLiveness();
if (this.isLive) { if (this.isLive) {
const expiryInMs = (this._beaconInfo?.timestamp + this._beaconInfo?.timeout) - Date.now(); const expiryInMs = (this._beaconInfo?.timestamp + this._beaconInfo?.timeout) - Date.now();
if (expiryInMs > 1) { if (expiryInMs > 1) {
this.livenessWatchInterval = setInterval( this.livenessWatchTimeout = setTimeout(
() => { this.monitorLiveness(); }, () => { this.monitorLiveness(); },
expiryInMs, expiryInMs,
); );
} }
} else if (this._beaconInfo?.timestamp > Date.now()) {
// beacon start timestamp is in the future
// check liveness again then
this.livenessWatchTimeout = setTimeout(
() => { this.monitorLiveness(); },
this.beaconInfo?.timestamp - Date.now(),
);
} }
} }
@ -161,13 +172,13 @@ export class Beacon extends TypedEventEmitter<Exclude<BeaconEvent, BeaconEvent.N
const latestLocationEvent = validLocationEvents.sort(sortEventsByLatestContentTimestamp)?.[0]; const latestLocationEvent = validLocationEvents.sort(sortEventsByLatestContentTimestamp)?.[0];
if (latestLocationEvent) { if (latestLocationEvent) {
this._latestLocationState = parseBeaconContent(latestLocationEvent.getContent()); this._latestLocationEvent = latestLocationEvent;
this.emit(BeaconEvent.LocationUpdate, this.latestLocationState); this.emit(BeaconEvent.LocationUpdate, this.latestLocationState);
} }
} }
private clearLatestLocation = () => { private clearLatestLocation = () => {
this._latestLocationState = undefined; this._latestLocationEvent = undefined;
this.emit(BeaconEvent.LocationUpdate, this.latestLocationState); this.emit(BeaconEvent.LocationUpdate, this.latestLocationState);
}; };
@ -178,8 +189,16 @@ export class Beacon extends TypedEventEmitter<Exclude<BeaconEvent, BeaconEvent.N
private checkLiveness(): void { private checkLiveness(): void {
const prevLiveness = this.isLive; const prevLiveness = this.isLive;
// element web sets a beacon's start timestamp to the senders local current time
// when Alice's system clock deviates slightly from Bob's a beacon Alice intended to be live
// may have a start timestamp in the future from Bob's POV
// handle this by adding 6min of leniency to the start timestamp when it is in the future
const startTimestamp = this._beaconInfo?.timestamp > Date.now() ?
this._beaconInfo?.timestamp - 360000 /* 6min */ :
this._beaconInfo?.timestamp;
this._isLive = this._beaconInfo?.live && this._isLive = this._beaconInfo?.live &&
isTimestampInDuration(this._beaconInfo?.timestamp, this._beaconInfo?.timeout, Date.now()); isTimestampInDuration(startTimestamp, this._beaconInfo?.timeout, Date.now());
if (prevLiveness !== this.isLive) { if (prevLiveness !== this.isLive) {
this.emit(BeaconEvent.LivenessChange, this.isLive, this); this.emit(BeaconEvent.LivenessChange, this.isLive, this);

View File

@ -18,15 +18,16 @@ limitations under the License.
* @module models/event-timeline-set * @module models/event-timeline-set
*/ */
import { EventTimeline } from "./event-timeline"; import { EventTimeline, IAddEventOptions } from "./event-timeline";
import { EventStatus, MatrixEvent, MatrixEventEvent } from "./event"; import { MatrixEvent } from "./event";
import { logger } from '../logger'; import { logger } from '../logger';
import { Relations } from './relations';
import { Room, RoomEvent } from "./room"; import { Room, RoomEvent } from "./room";
import { Filter } from "../filter"; import { Filter } from "../filter";
import { EventType, RelationType } from "../@types/event";
import { RoomState } from "./room-state"; import { RoomState } from "./room-state";
import { TypedEventEmitter } from "./typed-event-emitter"; import { TypedEventEmitter } from "./typed-event-emitter";
import { RelationsContainer } from "./relations-container";
import { MatrixClient } from "../client";
import { Thread } from "./thread";
const DEBUG = true; const DEBUG = true;
@ -41,7 +42,6 @@ if (DEBUG) {
interface IOpts { interface IOpts {
timelineSupport?: boolean; timelineSupport?: boolean;
filter?: Filter; filter?: Filter;
unstableClientRelationAggregation?: boolean;
pendingEvents?: boolean; pendingEvents?: boolean;
} }
@ -55,6 +55,23 @@ export interface IRoomTimelineData {
liveEvent?: boolean; liveEvent?: boolean;
} }
export interface IAddEventToTimelineOptions
extends Pick<IAddEventOptions, 'toStartOfTimeline' | 'roomState' | 'timelineWasEmpty'> {
/** Whether the sync response came from cache */
fromCache?: boolean;
}
export interface IAddLiveEventOptions
extends Pick<IAddEventToTimelineOptions, 'fromCache' | 'roomState' | 'timelineWasEmpty'> {
/** Applies to events in the timeline only. If this is 'replace' then if a
* duplicate is encountered, the event passed to this function will replace
* the existing event in the timeline. If this is not specified, or is
* 'ignore', then the event passed to this function will be ignored
* entirely, preserving the existing event in the timeline. Events are
* identical based on their event ID <b>only</b>. */
duplicateStrategy?: DuplicateStrategy;
}
type EmittedEvents = RoomEvent.Timeline | RoomEvent.TimelineReset; type EmittedEvents = RoomEvent.Timeline | RoomEvent.TimelineReset;
export type EventTimelineSetHandlerMap = { export type EventTimelineSetHandlerMap = {
@ -64,14 +81,13 @@ export type EventTimelineSetHandlerMap = {
}; };
export class EventTimelineSet extends TypedEventEmitter<EmittedEvents, EventTimelineSetHandlerMap> { export class EventTimelineSet extends TypedEventEmitter<EmittedEvents, EventTimelineSetHandlerMap> {
public readonly relations?: RelationsContainer;
private readonly timelineSupport: boolean; private readonly timelineSupport: boolean;
private unstableClientRelationAggregation: boolean; private readonly displayPendingEvents: boolean;
private displayPendingEvents: boolean;
private liveTimeline: EventTimeline; private liveTimeline: EventTimeline;
private timelines: EventTimeline[]; private timelines: EventTimeline[];
private _eventIdToTimeline: Record<string, EventTimeline>; private _eventIdToTimeline: Record<string, EventTimeline>;
private filter?: Filter; private filter?: Filter;
private relations: Record<string, Record<string, Record<RelationType, Relations>>>;
/** /**
* Construct a set of EventTimeline objects, typically on behalf of a given * Construct a set of EventTimeline objects, typically on behalf of a given
@ -95,7 +111,7 @@ export class EventTimelineSet extends TypedEventEmitter<EmittedEvents, EventTime
* map from event_id to timeline and index. * map from event_id to timeline and index.
* *
* @constructor * @constructor
* @param {?Room} room * @param {Room=} room
* Room for this timelineSet. May be null for non-room cases, such as the * Room for this timelineSet. May be null for non-room cases, such as the
* notification timeline. * notification timeline.
* @param {Object} opts Options inherited from Room. * @param {Object} opts Options inherited from Room.
@ -104,17 +120,20 @@ export class EventTimelineSet extends TypedEventEmitter<EmittedEvents, EventTime
* Set to true to enable improved timeline support. * Set to true to enable improved timeline support.
* @param {Object} [opts.filter = null] * @param {Object} [opts.filter = null]
* The filter object, if any, for this timelineSet. * The filter object, if any, for this timelineSet.
* @param {boolean} [opts.unstableClientRelationAggregation = false] * @param {MatrixClient=} client the Matrix client which owns this EventTimelineSet,
* Optional. Set to true to enable client-side aggregation of event relations * can be omitted if room is specified.
* via `getRelationsForEvent`. * @param {Thread=} thread the thread to which this timeline set relates.
* This feature is currently unstable and the API may change without notice.
*/ */
constructor(public readonly room: Room, opts: IOpts) { constructor(
public readonly room: Room | undefined,
opts: IOpts = {},
client?: MatrixClient,
public readonly thread?: Thread,
) {
super(); super();
this.timelineSupport = Boolean(opts.timelineSupport); this.timelineSupport = Boolean(opts.timelineSupport);
this.liveTimeline = new EventTimeline(this); this.liveTimeline = new EventTimeline(this);
this.unstableClientRelationAggregation = !!opts.unstableClientRelationAggregation;
this.displayPendingEvents = opts.pendingEvents !== false; this.displayPendingEvents = opts.pendingEvents !== false;
// just a list - *not* ordered. // just a list - *not* ordered.
@ -123,11 +142,7 @@ export class EventTimelineSet extends TypedEventEmitter<EmittedEvents, EventTime
this.filter = opts.filter; this.filter = opts.filter;
if (this.unstableClientRelationAggregation) { this.relations = this.room?.relations ?? new RelationsContainer(room?.client ?? client);
// A tree of objects to access a set of relations for an event, as in:
// this.relations[relatesToEventId][relationType][relationEventType]
this.relations = {};
}
} }
/** /**
@ -180,6 +195,15 @@ export class EventTimelineSet extends TypedEventEmitter<EmittedEvents, EventTime
return this.liveTimeline; return this.liveTimeline;
} }
/**
* Set the live timeline for this room.
*
* @return {module:models/event-timeline~EventTimeline} live timeline
*/
public setLiveTimeline(timeline: EventTimeline): void {
this.liveTimeline = timeline;
}
/** /**
* Return the timeline (if any) this event is in. * Return the timeline (if any) this event is in.
* @param {String} eventId the eventId being sought * @param {String} eventId the eventId being sought
@ -430,7 +454,9 @@ export class EventTimelineSet extends TypedEventEmitter<EmittedEvents, EventTime
if (!existingTimeline) { if (!existingTimeline) {
// we don't know about this event yet. Just add it to the timeline. // we don't know about this event yet. Just add it to the timeline.
this.addEventToTimeline(event, timeline, toStartOfTimeline); this.addEventToTimeline(event, timeline, {
toStartOfTimeline,
});
lastEventWasNew = true; lastEventWasNew = true;
didUpdate = true; didUpdate = true;
continue; continue;
@ -522,16 +548,52 @@ export class EventTimelineSet extends TypedEventEmitter<EmittedEvents, EventTime
* Add an event to the end of this live timeline. * Add an event to the end of this live timeline.
* *
* @param {MatrixEvent} event Event to be added * @param {MatrixEvent} event Event to be added
* @param {string?} duplicateStrategy 'ignore' or 'replace' * @param {IAddLiveEventOptions} options addLiveEvent options
* @param {boolean} fromCache whether the sync response came from cache
* @param roomState the state events to reconcile metadata from
*/ */
public addLiveEvent( public addLiveEvent(
event: MatrixEvent, event: MatrixEvent,
duplicateStrategy: DuplicateStrategy = DuplicateStrategy.Ignore, {
duplicateStrategy,
fromCache,
roomState,
timelineWasEmpty,
}: IAddLiveEventOptions,
): void;
/**
* @deprecated In favor of the overload with `IAddLiveEventOptions`
*/
public addLiveEvent(
event: MatrixEvent,
duplicateStrategy?: DuplicateStrategy,
fromCache?: boolean,
roomState?: RoomState,
): void;
public addLiveEvent(
event: MatrixEvent,
duplicateStrategyOrOpts?: DuplicateStrategy | IAddLiveEventOptions,
fromCache = false, fromCache = false,
roomState?: RoomState, roomState?: RoomState,
): void { ): void {
let duplicateStrategy = duplicateStrategyOrOpts as DuplicateStrategy || DuplicateStrategy.Ignore;
let timelineWasEmpty: boolean;
if (typeof (duplicateStrategyOrOpts) === 'object') {
({
duplicateStrategy = DuplicateStrategy.Ignore,
fromCache = false,
roomState,
timelineWasEmpty,
} = duplicateStrategyOrOpts);
} else if (duplicateStrategyOrOpts !== undefined) {
// Deprecation warning
// FIXME: Remove after 2023-06-01 (technical debt)
logger.warn(
'Overload deprecated: ' +
'`EventTimelineSet.addLiveEvent(event, duplicateStrategy?, fromCache?, roomState?)` ' +
'is deprecated in favor of the overload with ' +
'`EventTimelineSet.addLiveEvent(event, IAddLiveEventOptions)`',
);
}
if (this.filter) { if (this.filter) {
const events = this.filter.filterRoomTimeline([event]); const events = this.filter.filterRoomTimeline([event]);
if (!events.length) { if (!events.length) {
@ -542,8 +604,7 @@ export class EventTimelineSet extends TypedEventEmitter<EmittedEvents, EventTime
const timeline = this._eventIdToTimeline[event.getId()]; const timeline = this._eventIdToTimeline[event.getId()];
if (timeline) { if (timeline) {
if (duplicateStrategy === DuplicateStrategy.Replace) { if (duplicateStrategy === DuplicateStrategy.Replace) {
debuglog("EventTimelineSet.addLiveEvent: replacing duplicate event " + debuglog("EventTimelineSet.addLiveEvent: replacing duplicate event " + event.getId());
event.getId());
const tlEvents = timeline.getEvents(); const tlEvents = timeline.getEvents();
for (let j = 0; j < tlEvents.length; j++) { for (let j = 0; j < tlEvents.length; j++) {
if (tlEvents[j].getId() === event.getId()) { if (tlEvents[j].getId() === event.getId()) {
@ -563,13 +624,17 @@ export class EventTimelineSet extends TypedEventEmitter<EmittedEvents, EventTime
} }
} }
} else { } else {
debuglog("EventTimelineSet.addLiveEvent: ignoring duplicate event " + debuglog("EventTimelineSet.addLiveEvent: ignoring duplicate event " + event.getId());
event.getId());
} }
return; return;
} }
this.addEventToTimeline(event, this.liveTimeline, false, fromCache, roomState); this.addEventToTimeline(event, this.liveTimeline, {
toStartOfTimeline: false,
fromCache,
roomState,
timelineWasEmpty,
});
} }
/** /**
@ -580,24 +645,62 @@ export class EventTimelineSet extends TypedEventEmitter<EmittedEvents, EventTime
* *
* @param {MatrixEvent} event * @param {MatrixEvent} event
* @param {EventTimeline} timeline * @param {EventTimeline} timeline
* @param {boolean} toStartOfTimeline * @param {IAddEventToTimelineOptions} options addEventToTimeline options
* @param {boolean} fromCache whether the sync response came from cache
* *
* @fires module:client~MatrixClient#event:"Room.timeline" * @fires module:client~MatrixClient#event:"Room.timeline"
*/ */
public addEventToTimeline(
event: MatrixEvent,
timeline: EventTimeline,
{
toStartOfTimeline,
fromCache,
roomState,
timelineWasEmpty,
}: IAddEventToTimelineOptions,
): void;
/**
* @deprecated In favor of the overload with `IAddEventToTimelineOptions`
*/
public addEventToTimeline( public addEventToTimeline(
event: MatrixEvent, event: MatrixEvent,
timeline: EventTimeline, timeline: EventTimeline,
toStartOfTimeline: boolean, toStartOfTimeline: boolean,
fromCache?: boolean,
roomState?: RoomState,
): void;
public addEventToTimeline(
event: MatrixEvent,
timeline: EventTimeline,
toStartOfTimelineOrOpts: boolean | IAddEventToTimelineOptions,
fromCache = false, fromCache = false,
roomState?: RoomState, roomState?: RoomState,
) { ): void {
let toStartOfTimeline = !!toStartOfTimelineOrOpts;
let timelineWasEmpty: boolean;
if (typeof (toStartOfTimelineOrOpts) === 'object') {
({ toStartOfTimeline, fromCache = false, roomState, timelineWasEmpty } = toStartOfTimelineOrOpts);
} else if (toStartOfTimelineOrOpts !== undefined) {
// Deprecation warning
// FIXME: Remove after 2023-06-01 (technical debt)
logger.warn(
'Overload deprecated: ' +
'`EventTimelineSet.addEventToTimeline(event, timeline, toStartOfTimeline, fromCache?, roomState?)` ' +
'is deprecated in favor of the overload with ' +
'`EventTimelineSet.addEventToTimeline(event, timeline, IAddEventToTimelineOptions)`',
);
}
const eventId = event.getId(); const eventId = event.getId();
timeline.addEvent(event, toStartOfTimeline, roomState); timeline.addEvent(event, {
toStartOfTimeline,
roomState,
timelineWasEmpty,
});
this._eventIdToTimeline[eventId] = timeline; this._eventIdToTimeline[eventId] = timeline;
this.setRelationsTarget(event); this.relations.aggregateParentEvent(event);
this.aggregateRelations(event); this.relations.aggregateChildEvent(event, this);
const data: IRoomTimelineData = { const data: IRoomTimelineData = {
timeline: timeline, timeline: timeline,
@ -629,10 +732,14 @@ export class EventTimelineSet extends TypedEventEmitter<EmittedEvents, EventTime
} else { } else {
if (this.filter) { if (this.filter) {
if (this.filter.filterRoomTimeline([localEvent]).length) { if (this.filter.filterRoomTimeline([localEvent]).length) {
this.addEventToTimeline(localEvent, this.liveTimeline, false); this.addEventToTimeline(localEvent, this.liveTimeline, {
toStartOfTimeline: false,
});
} }
} else { } else {
this.addEventToTimeline(localEvent, this.liveTimeline, false); this.addEventToTimeline(localEvent, this.liveTimeline, {
toStartOfTimeline: false,
});
} }
} }
} }
@ -693,8 +800,8 @@ export class EventTimelineSet extends TypedEventEmitter<EmittedEvents, EventTime
if (timeline1 === timeline2) { if (timeline1 === timeline2) {
// both events are in the same timeline - figure out their // both events are in the same timeline - figure out their
// relative indices // relative indices
let idx1; let idx1: number;
let idx2; let idx2: number;
const events = timeline1.getEvents(); const events = timeline1.getEvents();
for (let idx = 0; idx < events.length && for (let idx = 0; idx < events.length &&
(idx1 === undefined || idx2 === undefined); idx++) { (idx1 === undefined || idx2 === undefined); idx++) {
@ -737,133 +844,28 @@ export class EventTimelineSet extends TypedEventEmitter<EmittedEvents, EventTime
} }
/** /**
* Get a collection of relations to a given event in this timeline set. * Determine whether a given event can sanely be added to this event timeline set,
* for timeline sets relating to a thread, only return true for events in the same
* thread timeline, for timeline sets not relating to a thread only return true
* for events which should be shown in the main room timeline.
* Requires the `room` property to have been set at EventTimelineSet construction time.
* *
* @param {String} eventId * @param event {MatrixEvent} the event to check whether it belongs to this timeline set.
* The ID of the event that you'd like to access relation events for. * @throws {Error} if `room` was not set when constructing this timeline set.
* For example, with annotations, this would be the ID of the event being annotated. * @return {boolean} whether the event belongs to this timeline set.
* @param {String} relationType
* The type of relation involved, such as "m.annotation", "m.reference", "m.replace", etc.
* @param {String} eventType
* The relation event's type, such as "m.reaction", etc.
* @throws If <code>eventId</code>, <code>relationType</code> or <code>eventType</code>
* are not valid.
*
* @returns {?Relations}
* A container for relation events or undefined if there are no relation events for
* the relationType.
*/ */
public getRelationsForEvent( public canContain(event: MatrixEvent): boolean {
eventId: string, if (!this.room) {
relationType: RelationType | string, throw new Error("Cannot call `EventTimelineSet::canContain without a `room` set. " +
eventType: EventType | string, "Set the room when creating the EventTimelineSet to call this method.");
): Relations | undefined {
if (!this.unstableClientRelationAggregation) {
throw new Error("Client-side relation aggregation is disabled");
} }
if (!eventId || !relationType || !eventType) { const { threadId, shouldLiveInRoom } = this.room.eventShouldLiveIn(event);
throw new Error("Invalid arguments for `getRelationsForEvent`");
if (this.thread) {
return this.thread.id === threadId;
} }
return shouldLiveInRoom;
// debuglog("Getting relations for: ", eventId, relationType, eventType);
const relationsForEvent = this.relations[eventId] || {};
const relationsWithRelType = relationsForEvent[relationType] || {};
return relationsWithRelType[eventType];
}
public getAllRelationsEventForEvent(eventId: string): MatrixEvent[] {
const relationsForEvent = this.relations?.[eventId] || {};
const events = [];
for (const relationsRecord of Object.values(relationsForEvent)) {
for (const relations of Object.values(relationsRecord)) {
events.push(...relations.getRelations());
}
}
return events;
}
/**
* Set an event as the target event if any Relations exist for it already
*
* @param {MatrixEvent} event
* The event to check as relation target.
*/
public setRelationsTarget(event: MatrixEvent): void {
if (!this.unstableClientRelationAggregation) {
return;
}
const relationsForEvent = this.relations[event.getId()];
if (!relationsForEvent) {
return;
}
for (const relationsWithRelType of Object.values(relationsForEvent)) {
for (const relationsWithEventType of Object.values(relationsWithRelType)) {
relationsWithEventType.setTargetEvent(event);
}
}
}
/**
* Add relation events to the relevant relation collection.
*
* @param {MatrixEvent} event
* The new relation event to be aggregated.
*/
public aggregateRelations(event: MatrixEvent): void {
if (!this.unstableClientRelationAggregation) {
return;
}
if (event.isRedacted() || event.status === EventStatus.CANCELLED) {
return;
}
// If the event is currently encrypted, wait until it has been decrypted.
if (event.isBeingDecrypted() || event.shouldAttemptDecryption()) {
event.once(MatrixEventEvent.Decrypted, () => {
this.aggregateRelations(event);
});
return;
}
const relation = event.getRelation();
if (!relation) {
return;
}
const relatesToEventId = relation.event_id;
const relationType = relation.rel_type;
const eventType = event.getType();
// debuglog("Aggregating relation: ", event.getId(), eventType, relation);
let relationsForEvent: Record<string, Partial<Record<string, Relations>>> = this.relations[relatesToEventId];
if (!relationsForEvent) {
relationsForEvent = this.relations[relatesToEventId] = {};
}
let relationsWithRelType = relationsForEvent[relationType];
if (!relationsWithRelType) {
relationsWithRelType = relationsForEvent[relationType] = {};
}
let relationsWithEventType = relationsWithRelType[eventType];
if (!relationsWithEventType) {
relationsWithEventType = relationsWithRelType[eventType] = new Relations(
relationType,
eventType,
this.room,
);
const relatesToEvent = this.findEventById(relatesToEventId) || this.room.getPendingEvent(relatesToEventId);
if (relatesToEvent) {
relationsWithEventType.setTargetEvent(relatesToEvent);
}
}
relationsWithEventType.addEvent(event);
} }
} }

View File

@ -18,12 +18,30 @@ limitations under the License.
* @module models/event-timeline * @module models/event-timeline
*/ */
import { RoomState } from "./room-state"; import { logger } from '../logger';
import { RoomState, IMarkerFoundOptions } from "./room-state";
import { EventTimelineSet } from "./event-timeline-set"; import { EventTimelineSet } from "./event-timeline-set";
import { MatrixEvent } from "./event"; import { MatrixEvent } from "./event";
import { Filter } from "../filter"; import { Filter } from "../filter";
import { EventType } from "../@types/event"; import { EventType } from "../@types/event";
export interface IInitialiseStateOptions extends Pick<IMarkerFoundOptions, 'timelineWasEmpty'> {
// This is a separate interface without any extra stuff currently added on
// top of `IMarkerFoundOptions` just because it feels like they have
// different concerns. One shouldn't necessarily look to add to
// `IMarkerFoundOptions` just because they want to add an extra option to
// `initialiseState`.
}
export interface IAddEventOptions extends Pick<IMarkerFoundOptions, 'timelineWasEmpty'> {
/** Whether to insert the new event at the start of the timeline where the
* oldest events are (timeline is in chronological order, oldest to most
* recent) */
toStartOfTimeline: boolean;
/** The state events to reconcile metadata from */
roomState?: RoomState;
}
export enum Direction { export enum Direction {
Backward = "b", Backward = "b",
Forward = "f", Forward = "f",
@ -131,7 +149,7 @@ export class EventTimeline {
* state with. * state with.
* @throws {Error} if an attempt is made to call this after addEvent is called. * @throws {Error} if an attempt is made to call this after addEvent is called.
*/ */
public initialiseState(stateEvents: MatrixEvent[]): void { public initialiseState(stateEvents: MatrixEvent[], { timelineWasEmpty }: IInitialiseStateOptions = {}): void {
if (this.events.length > 0) { if (this.events.length > 0) {
throw new Error("Cannot initialise state after events are added"); throw new Error("Cannot initialise state after events are added");
} }
@ -152,8 +170,12 @@ export class EventTimeline {
Object.freeze(e); Object.freeze(e);
} }
this.startState.setStateEvents(stateEvents); this.startState.setStateEvents(stateEvents, {
this.endState.setStateEvents(stateEvents); timelineWasEmpty,
});
this.endState.setStateEvents(stateEvents, {
timelineWasEmpty,
});
} }
/** /**
@ -345,24 +367,60 @@ export class EventTimeline {
* Add a new event to the timeline, and update the state * Add a new event to the timeline, and update the state
* *
* @param {MatrixEvent} event new event * @param {MatrixEvent} event new event
* @param {boolean} atStart true to insert new event at the start * @param {IAddEventOptions} options addEvent options
*/ */
public addEvent(event: MatrixEvent, atStart: boolean, stateContext?: RoomState): void { public addEvent(
if (!stateContext) { event: MatrixEvent,
stateContext = atStart ? this.startState : this.endState; {
toStartOfTimeline,
roomState,
timelineWasEmpty,
}: IAddEventOptions,
): void;
/**
* @deprecated In favor of the overload with `IAddEventOptions`
*/
public addEvent(
event: MatrixEvent,
toStartOfTimeline: boolean,
roomState?: RoomState
): void;
public addEvent(
event: MatrixEvent,
toStartOfTimelineOrOpts: boolean | IAddEventOptions,
roomState?: RoomState,
): void {
let toStartOfTimeline = !!toStartOfTimelineOrOpts;
let timelineWasEmpty: boolean;
if (typeof (toStartOfTimelineOrOpts) === 'object') {
({ toStartOfTimeline, roomState, timelineWasEmpty } = toStartOfTimelineOrOpts);
} else if (toStartOfTimelineOrOpts !== undefined) {
// Deprecation warning
// FIXME: Remove after 2023-06-01 (technical debt)
logger.warn(
'Overload deprecated: ' +
'`EventTimeline.addEvent(event, toStartOfTimeline, roomState?)` ' +
'is deprecated in favor of the overload with `EventTimeline.addEvent(event, IAddEventOptions)`',
);
}
if (!roomState) {
roomState = toStartOfTimeline ? this.startState : this.endState;
} }
const timelineSet = this.getTimelineSet(); const timelineSet = this.getTimelineSet();
if (timelineSet.room) { if (timelineSet.room) {
EventTimeline.setEventMetadata(event, stateContext, atStart); EventTimeline.setEventMetadata(event, roomState, toStartOfTimeline);
// modify state but only on unfiltered timelineSets // modify state but only on unfiltered timelineSets
if ( if (
event.isState() && event.isState() &&
timelineSet.room.getUnfilteredTimelineSet() === timelineSet timelineSet.room.getUnfilteredTimelineSet() === timelineSet
) { ) {
stateContext.setStateEvents([event]); roomState.setStateEvents([event], {
timelineWasEmpty,
});
// it is possible that the act of setting the state event means we // it is possible that the act of setting the state event means we
// can set more metadata (specifically sender/target props), so try // can set more metadata (specifically sender/target props), so try
// it again if the prop wasn't previously set. It may also mean that // it again if the prop wasn't previously set. It may also mean that
@ -373,22 +431,22 @@ export class EventTimeline {
// back in time, else we'll set the .sender value for BEFORE the given // back in time, else we'll set the .sender value for BEFORE the given
// member event, whereas we want to set the .sender value for the ACTUAL // member event, whereas we want to set the .sender value for the ACTUAL
// member event itself. // member event itself.
if (!event.sender || (event.getType() === "m.room.member" && !atStart)) { if (!event.sender || (event.getType() === "m.room.member" && !toStartOfTimeline)) {
EventTimeline.setEventMetadata(event, stateContext, atStart); EventTimeline.setEventMetadata(event, roomState, toStartOfTimeline);
} }
} }
} }
let insertIndex; let insertIndex: number;
if (atStart) { if (toStartOfTimeline) {
insertIndex = 0; insertIndex = 0;
} else { } else {
insertIndex = this.events.length; insertIndex = this.events.length;
} }
this.events.splice(insertIndex, 0, event); // insert element this.events.splice(insertIndex, 0, event); // insert element
if (atStart) { if (toStartOfTimeline) {
this.baseIndex++; this.baseIndex++;
} }
} }

View File

@ -514,13 +514,6 @@ export class MatrixEvent extends TypedEventEmitter<EmittedEvents, MatrixEventHan
} }
} }
/**
* @experimental
*/
public get isThreadRelation(): boolean {
return !!this.threadRootId && this.threadId !== this.getId();
}
/** /**
* @experimental * @experimental
*/ */
@ -544,7 +537,7 @@ export class MatrixEvent extends TypedEventEmitter<EmittedEvents, MatrixEventHan
return mRelatesTo?.['m.in_reply_to']?.event_id; return mRelatesTo?.['m.in_reply_to']?.event_id;
} }
public get relationEventId(): string { public get relationEventId(): string | undefined {
return this.getWireContent() return this.getWireContent()
?.["m.relates_to"] ?.["m.relates_to"]
?.event_id; ?.event_id;
@ -830,18 +823,13 @@ export class MatrixEvent extends TypedEventEmitter<EmittedEvents, MatrixEventHan
// //
if (this.retryDecryption) { if (this.retryDecryption) {
// decryption error, but we have a retry queued. // decryption error, but we have a retry queued.
logger.log( logger.log(`Got error decrypting event (id=${this.getId()}: ${e.detailedString}), but retrying`, e);
`Got error decrypting event (id=${this.getId()}: ` +
`${e}), but retrying`,
);
continue; continue;
} }
// decryption error, no retries queued. Warn about the error and // decryption error, no retries queued. Warn about the error and
// set it to m.bad.encrypted. // set it to m.bad.encrypted.
logger.warn( logger.warn(`Got error decrypting event (id=${this.getId()}: ${e.detailedString})`, e);
`Error decrypting event (id=${this.getId()}): ${e.detailedString}`,
);
res = this.badEncryptedMessage(e.message); res = this.badEncryptedMessage(e.message);
} }
@ -1043,7 +1031,7 @@ export class MatrixEvent extends TypedEventEmitter<EmittedEvents, MatrixEventHan
* caused a change in the actual visibility of this event, either by making it * caused a change in the actual visibility of this event, either by making it
* visible (if it was hidden), by making it hidden (if it was visible) or by * visible (if it was hidden), by making it hidden (if it was visible) or by
* changing the reason (if it was hidden). * changing the reason (if it was hidden).
* @param visibilityEvent event holding a hide/unhide payload, or nothing * @param visibilityChange event holding a hide/unhide payload, or nothing
* if the event is being reset to its original visibility (presumably * if the event is being reset to its original visibility (presumably
* by a visibility event being redacted). * by a visibility event being redacted).
*/ */
@ -1065,9 +1053,7 @@ export class MatrixEvent extends TypedEventEmitter<EmittedEvents, MatrixEventHan
reason: reason, reason: reason,
}); });
} }
if (change) { this.emit(MatrixEventEvent.VisibilityChange, this, visible);
this.emit(MatrixEventEvent.VisibilityChange, this, visible);
}
} }
} }
@ -1292,7 +1278,7 @@ export class MatrixEvent extends TypedEventEmitter<EmittedEvents, MatrixEventHan
/** /**
* Get whether the event is a relation event, and of a given type if * Get whether the event is a relation event, and of a given type if
* `relType` is passed in. * `relType` is passed in. State events cannot be relation events
* *
* @param {string?} relType if given, checks that the relation is of the * @param {string?} relType if given, checks that the relation is of the
* given type * given type
@ -1302,8 +1288,11 @@ export class MatrixEvent extends TypedEventEmitter<EmittedEvents, MatrixEventHan
// Relation info is lifted out of the encrypted content when sent to // Relation info is lifted out of the encrypted content when sent to
// encrypted rooms, so we have to check `getWireContent` for this. // encrypted rooms, so we have to check `getWireContent` for this.
const relation = this.getWireContent()?.["m.relates_to"]; const relation = this.getWireContent()?.["m.relates_to"];
return relation && relation.rel_type && relation.event_id && if (this.isState() && relation?.rel_type === RelationType.Replace) {
((relType && relation.rel_type === relType) || !relType); // State events cannot be m.replace relations
return false;
}
return relation?.rel_type && relation.event_id && (relType ? relation.rel_type === relType : true);
} }
/** /**

View File

@ -0,0 +1,155 @@
/*
Copyright 2022 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 { Relations } from "./relations";
import { EventType, RelationType } from "../@types/event";
import { EventStatus, MatrixEvent, MatrixEventEvent } from "./event";
import { EventTimelineSet } from "./event-timeline-set";
import { MatrixClient } from "../client";
import { Room } from "./room";
export class RelationsContainer {
// A tree of objects to access a set of related children for an event, as in:
// this.relations[parentEventId][relationType][relationEventType]
private relations: {
[parentEventId: string]: {
[relationType: RelationType | string]: {
[eventType: EventType | string]: Relations;
};
};
} = {};
constructor(private readonly client: MatrixClient, private readonly room?: Room) {
}
/**
* Get a collection of child events to a given event in this timeline set.
*
* @param {String} eventId
* The ID of the event that you'd like to access child events for.
* For example, with annotations, this would be the ID of the event being annotated.
* @param {String} relationType
* The type of relationship involved, such as "m.annotation", "m.reference", "m.replace", etc.
* @param {String} eventType
* The relation event's type, such as "m.reaction", etc.
* @throws If <code>eventId</code>, <code>relationType</code> or <code>eventType</code>
* are not valid.
*
* @returns {?Relations}
* A container for relation events or undefined if there are no relation events for
* the relationType.
*/
public getChildEventsForEvent(
eventId: string,
relationType: RelationType | string,
eventType: EventType | string,
): Relations | undefined {
return this.relations[eventId]?.[relationType]?.[eventType];
}
public getAllChildEventsForEvent(parentEventId: string): MatrixEvent[] {
const relationsForEvent = this.relations[parentEventId] ?? {};
const events: MatrixEvent[] = [];
for (const relationsRecord of Object.values(relationsForEvent)) {
for (const relations of Object.values(relationsRecord)) {
events.push(...relations.getRelations());
}
}
return events;
}
/**
* Set an event as the target event if any Relations exist for it already.
* Child events can point to other child events as their parent, so this method may be
* called for events which are also logically child events.
*
* @param {MatrixEvent} event The event to check as relation target.
*/
public aggregateParentEvent(event: MatrixEvent): void {
const relationsForEvent = this.relations[event.getId()];
if (!relationsForEvent) return;
for (const relationsWithRelType of Object.values(relationsForEvent)) {
for (const relationsWithEventType of Object.values(relationsWithRelType)) {
relationsWithEventType.setTargetEvent(event);
}
}
}
/**
* Add relation events to the relevant relation collection.
*
* @param {MatrixEvent} event The new child event to be aggregated.
* @param {EventTimelineSet} timelineSet The event timeline set within which to search for the related event if any.
*/
public aggregateChildEvent(event: MatrixEvent, timelineSet?: EventTimelineSet): void {
if (event.isRedacted() || event.status === EventStatus.CANCELLED) {
return;
}
const relation = event.getRelation();
if (!relation) return;
const onEventDecrypted = () => {
if (event.isDecryptionFailure()) {
// This could for example happen if the encryption keys are not yet available.
// The event may still be decrypted later. Register the listener again.
event.once(MatrixEventEvent.Decrypted, onEventDecrypted);
return;
}
this.aggregateChildEvent(event, timelineSet);
};
// If the event is currently encrypted, wait until it has been decrypted.
if (event.isBeingDecrypted() || event.shouldAttemptDecryption()) {
event.once(MatrixEventEvent.Decrypted, onEventDecrypted);
return;
}
const { event_id: relatesToEventId, rel_type: relationType } = relation;
const eventType = event.getType();
let relationsForEvent = this.relations[relatesToEventId];
if (!relationsForEvent) {
relationsForEvent = this.relations[relatesToEventId] = {};
}
let relationsWithRelType = relationsForEvent[relationType];
if (!relationsWithRelType) {
relationsWithRelType = relationsForEvent[relationType] = {};
}
let relationsWithEventType = relationsWithRelType[eventType];
if (!relationsWithEventType) {
relationsWithEventType = relationsWithRelType[eventType] = new Relations(
relationType,
eventType,
this.client,
);
const room = this.room ?? timelineSet?.room;
const relatesToEvent = timelineSet?.findEventById(relatesToEventId)
?? room?.findEventById(relatesToEventId)
?? room?.getPendingEvent(relatesToEventId);
if (relatesToEvent) {
relationsWithEventType.setTargetEvent(relatesToEvent);
}
}
relationsWithEventType.addEvent(event);
}
}

View File

@ -15,10 +15,11 @@ limitations under the License.
*/ */
import { EventStatus, IAggregatedRelation, MatrixEvent, MatrixEventEvent } from './event'; import { EventStatus, IAggregatedRelation, MatrixEvent, MatrixEventEvent } from './event';
import { Room } from './room';
import { logger } from '../logger'; import { logger } from '../logger';
import { RelationType } from "../@types/event"; import { RelationType } from "../@types/event";
import { TypedEventEmitter } from "./typed-event-emitter"; import { TypedEventEmitter } from "./typed-event-emitter";
import { MatrixClient } from "../client";
import { Room } from "./room";
export enum RelationsEvent { export enum RelationsEvent {
Add = "Relations.add", Add = "Relations.add",
@ -48,6 +49,7 @@ export class Relations extends TypedEventEmitter<RelationsEvent, EventHandlerMap
private sortedAnnotationsByKey: [string, Set<MatrixEvent>][] = []; private sortedAnnotationsByKey: [string, Set<MatrixEvent>][] = [];
private targetEvent: MatrixEvent = null; private targetEvent: MatrixEvent = null;
private creationEmitted = false; private creationEmitted = false;
private readonly client: MatrixClient;
/** /**
* @param {RelationType} relationType * @param {RelationType} relationType
@ -55,16 +57,16 @@ export class Relations extends TypedEventEmitter<RelationsEvent, EventHandlerMap
* "m.replace", etc. * "m.replace", etc.
* @param {String} eventType * @param {String} eventType
* The relation event's type, such as "m.reaction", etc. * The relation event's type, such as "m.reaction", etc.
* @param {?Room} room * @param {MatrixClient|Room} client
* Room for this container. May be null for non-room cases, such as the * The client which created this instance. For backwards compatibility also accepts a Room.
* notification timeline.
*/ */
constructor( constructor(
public readonly relationType: RelationType | string, public readonly relationType: RelationType | string,
public readonly eventType: string, public readonly eventType: string,
private readonly room: Room, client: MatrixClient | Room,
) { ) {
super(); super();
this.client = client instanceof Room ? client.client : client;
} }
/** /**
@ -103,7 +105,7 @@ export class Relations extends TypedEventEmitter<RelationsEvent, EventHandlerMap
if (this.relationType === RelationType.Annotation) { if (this.relationType === RelationType.Annotation) {
this.addAnnotationToAggregation(event); this.addAnnotationToAggregation(event);
} else if (this.relationType === RelationType.Replace && this.targetEvent) { } else if (this.relationType === RelationType.Replace && this.targetEvent && !this.targetEvent.isState()) {
const lastReplacement = await this.getLastReplacement(); const lastReplacement = await this.getLastReplacement();
this.targetEvent.makeReplaced(lastReplacement); this.targetEvent.makeReplaced(lastReplacement);
} }
@ -144,7 +146,7 @@ export class Relations extends TypedEventEmitter<RelationsEvent, EventHandlerMap
if (this.relationType === RelationType.Annotation) { if (this.relationType === RelationType.Annotation) {
this.removeAnnotationFromAggregation(event); this.removeAnnotationFromAggregation(event);
} else if (this.relationType === RelationType.Replace && this.targetEvent) { } else if (this.relationType === RelationType.Replace && this.targetEvent && !this.targetEvent.isState()) {
const lastReplacement = await this.getLastReplacement(); const lastReplacement = await this.getLastReplacement();
this.targetEvent.makeReplaced(lastReplacement); this.targetEvent.makeReplaced(lastReplacement);
} }
@ -261,7 +263,7 @@ export class Relations extends TypedEventEmitter<RelationsEvent, EventHandlerMap
if (this.relationType === RelationType.Annotation) { if (this.relationType === RelationType.Annotation) {
// Remove the redacted annotation from aggregation by key // Remove the redacted annotation from aggregation by key
this.removeAnnotationFromAggregation(redactedEvent); this.removeAnnotationFromAggregation(redactedEvent);
} else if (this.relationType === RelationType.Replace && this.targetEvent) { } else if (this.relationType === RelationType.Replace && this.targetEvent && !this.targetEvent.isState()) {
const lastReplacement = await this.getLastReplacement(); const lastReplacement = await this.getLastReplacement();
this.targetEvent.makeReplaced(lastReplacement); this.targetEvent.makeReplaced(lastReplacement);
} }
@ -347,7 +349,7 @@ export class Relations extends TypedEventEmitter<RelationsEvent, EventHandlerMap
}, null); }, null);
if (lastReplacement?.shouldAttemptDecryption()) { if (lastReplacement?.shouldAttemptDecryption()) {
await lastReplacement.attemptDecryption(this.room.client.crypto); await lastReplacement.attemptDecryption(this.client.crypto);
} else if (lastReplacement?.isBeingDecrypted()) { } else if (lastReplacement?.isBeingDecrypted()) {
await lastReplacement.getDecryptionPromise(); await lastReplacement.getDecryptionPromise();
} }
@ -364,7 +366,7 @@ export class Relations extends TypedEventEmitter<RelationsEvent, EventHandlerMap
} }
this.targetEvent = event; this.targetEvent = event;
if (this.relationType === RelationType.Replace) { if (this.relationType === RelationType.Replace && !this.targetEvent.isState()) {
const replacement = await this.getLastReplacement(); const replacement = await this.getLastReplacement();
// this is the initial update, so only call it if we already have something // this is the initial update, so only call it if we already have something
// to not emit Event.replaced needlessly // to not emit Event.replaced needlessly

View File

@ -21,7 +21,7 @@ limitations under the License.
import { RoomMember } from "./room-member"; import { RoomMember } from "./room-member";
import { logger } from '../logger'; import { logger } from '../logger';
import * as utils from "../utils"; import * as utils from "../utils";
import { EventType } from "../@types/event"; import { EventType, UNSTABLE_MSC2716_MARKER } from "../@types/event";
import { MatrixEvent, MatrixEventEvent } from "./event"; import { MatrixEvent, MatrixEventEvent } from "./event";
import { MatrixClient } from "../client"; import { MatrixClient } from "../client";
import { GuestAccess, HistoryVisibility, IJoinRuleEventContent, JoinRule } from "../@types/partials"; import { GuestAccess, HistoryVisibility, IJoinRuleEventContent, JoinRule } from "../@types/partials";
@ -30,6 +30,22 @@ import { Beacon, BeaconEvent, BeaconEventHandlerMap, getBeaconInfoIdentifier, Be
import { TypedReEmitter } from "../ReEmitter"; import { TypedReEmitter } from "../ReEmitter";
import { M_BEACON, M_BEACON_INFO } from "../@types/beacon"; import { M_BEACON, M_BEACON_INFO } from "../@types/beacon";
export interface IMarkerFoundOptions {
/** Whether the timeline was empty before the marker event arrived in the
* room. This could be happen in a variety of cases:
* 1. From the initial sync
* 2. It's the first state we're seeing after joining the room
* 3. Or whether it's coming from `syncFromCache`
*
* A marker event refers to `UNSTABLE_MSC2716_MARKER` and indicates that
* history was imported somewhere back in time. It specifically points to an
* MSC2716 insertion event where the history was imported at. Marker events
* are sent as state events so they are easily discoverable by clients and
* homeservers and don't get lost in timeline gaps.
*/
timelineWasEmpty?: boolean;
}
// possible statuses for out-of-band member loading // possible statuses for out-of-band member loading
enum OobStatus { enum OobStatus {
NotStarted, NotStarted,
@ -43,6 +59,7 @@ export enum RoomStateEvent {
NewMember = "RoomState.newMember", NewMember = "RoomState.newMember",
Update = "RoomState.update", // signals batches of updates without specificity Update = "RoomState.update", // signals batches of updates without specificity
BeaconLiveness = "RoomState.BeaconLiveness", BeaconLiveness = "RoomState.BeaconLiveness",
Marker = "RoomState.Marker",
} }
export type RoomStateEventHandlerMap = { export type RoomStateEventHandlerMap = {
@ -51,6 +68,7 @@ export type RoomStateEventHandlerMap = {
[RoomStateEvent.NewMember]: (event: MatrixEvent, state: RoomState, member: RoomMember) => void; [RoomStateEvent.NewMember]: (event: MatrixEvent, state: RoomState, member: RoomMember) => void;
[RoomStateEvent.Update]: (state: RoomState) => void; [RoomStateEvent.Update]: (state: RoomState) => void;
[RoomStateEvent.BeaconLiveness]: (state: RoomState, hasLiveBeacons: boolean) => void; [RoomStateEvent.BeaconLiveness]: (state: RoomState, hasLiveBeacons: boolean) => void;
[RoomStateEvent.Marker]: (event: MatrixEvent, setStateOptions: IMarkerFoundOptions) => void;
[BeaconEvent.New]: (event: MatrixEvent, beacon: Beacon) => void; [BeaconEvent.New]: (event: MatrixEvent, beacon: Beacon) => void;
}; };
@ -314,16 +332,19 @@ export class RoomState extends TypedEventEmitter<EmittedEvents, EventHandlerMap>
} }
/** /**
* Add an array of one or more state MatrixEvents, overwriting * Add an array of one or more state MatrixEvents, overwriting any existing
* any existing state with the same {type, stateKey} tuple. Will fire * state with the same {type, stateKey} tuple. Will fire "RoomState.events"
* "RoomState.events" for every event added. May fire "RoomState.members" * for every event added. May fire "RoomState.members" if there are
* if there are <code>m.room.member</code> events. * <code>m.room.member</code> events. May fire "RoomStateEvent.Marker" if there are
* <code>UNSTABLE_MSC2716_MARKER</code> events.
* @param {MatrixEvent[]} stateEvents a list of state events for this room. * @param {MatrixEvent[]} stateEvents a list of state events for this room.
* @param {IMarkerFoundOptions} markerFoundOptions
* @fires module:client~MatrixClient#event:"RoomState.members" * @fires module:client~MatrixClient#event:"RoomState.members"
* @fires module:client~MatrixClient#event:"RoomState.newMember" * @fires module:client~MatrixClient#event:"RoomState.newMember"
* @fires module:client~MatrixClient#event:"RoomState.events" * @fires module:client~MatrixClient#event:"RoomState.events"
* @fires module:client~MatrixClient#event:"RoomStateEvent.Marker"
*/ */
public setStateEvents(stateEvents: MatrixEvent[]) { public setStateEvents(stateEvents: MatrixEvent[], markerFoundOptions?: IMarkerFoundOptions) {
this.updateModifiedTime(); this.updateModifiedTime();
// update the core event dict // update the core event dict
@ -403,6 +424,8 @@ export class RoomState extends TypedEventEmitter<EmittedEvents, EventHandlerMap>
// assume all our sentinels are now out-of-date // assume all our sentinels are now out-of-date
this.sentinels = {}; this.sentinels = {};
} else if (UNSTABLE_MSC2716_MARKER.matches(event.getType())) {
this.emit(RoomStateEvent.Marker, event, markerFoundOptions);
} }
}); });

View File

@ -18,7 +18,7 @@ limitations under the License.
* @module models/room * @module models/room
*/ */
import { EventTimelineSet, DuplicateStrategy } from "./event-timeline-set"; import { EventTimelineSet, DuplicateStrategy, IAddLiveEventOptions } from "./event-timeline-set";
import { Direction, EventTimeline } from "./event-timeline"; import { Direction, EventTimeline } from "./event-timeline";
import { getHttpUriForMxc } from "../content-repo"; import { getHttpUriForMxc } from "../content-repo";
import * as utils from "../utils"; import * as utils from "../utils";
@ -49,6 +49,7 @@ import {
import { TypedEventEmitter } from "./typed-event-emitter"; import { TypedEventEmitter } from "./typed-event-emitter";
import { ReceiptType } from "../@types/read_receipts"; import { ReceiptType } from "../@types/read_receipts";
import { IStateEventWithRoomId } from "../@types/search"; import { IStateEventWithRoomId } from "../@types/search";
import { RelationsContainer } from "./relations-container";
// These constants are used as sane defaults when the homeserver doesn't support // These constants are used as sane defaults when the homeserver doesn't support
// the m.room_versions capability. In practice, KNOWN_SAFE_ROOM_VERSION should be // the m.room_versions capability. In practice, KNOWN_SAFE_ROOM_VERSION should be
@ -80,7 +81,6 @@ interface IOpts {
storageToken?: string; storageToken?: string;
pendingEventOrdering?: PendingEventOrdering; pendingEventOrdering?: PendingEventOrdering;
timelineSupport?: boolean; timelineSupport?: boolean;
unstableClientRelationAggregation?: boolean;
lazyLoadMembers?: boolean; lazyLoadMembers?: boolean;
} }
@ -165,6 +165,10 @@ export enum RoomEvent {
LocalEchoUpdated = "Room.localEchoUpdated", LocalEchoUpdated = "Room.localEchoUpdated",
Timeline = "Room.timeline", Timeline = "Room.timeline",
TimelineReset = "Room.timelineReset", TimelineReset = "Room.timelineReset",
TimelineRefresh = "Room.TimelineRefresh",
OldStateUpdated = "Room.OldStateUpdated",
CurrentStateUpdated = "Room.CurrentStateUpdated",
HistoryImportedWithinTimeline = "Room.historyImportedWithinTimeline",
} }
type EmittedEvents = RoomEvent type EmittedEvents = RoomEvent
@ -173,6 +177,10 @@ type EmittedEvents = RoomEvent
| ThreadEvent.NewReply | ThreadEvent.NewReply
| RoomEvent.Timeline | RoomEvent.Timeline
| RoomEvent.TimelineReset | RoomEvent.TimelineReset
| RoomEvent.TimelineRefresh
| RoomEvent.HistoryImportedWithinTimeline
| RoomEvent.OldStateUpdated
| RoomEvent.CurrentStateUpdated
| MatrixEventEvent.BeforeRedaction; | MatrixEventEvent.BeforeRedaction;
export type RoomEventHandlerMap = { export type RoomEventHandlerMap = {
@ -189,6 +197,13 @@ export type RoomEventHandlerMap = {
oldEventId?: string, oldEventId?: string,
oldStatus?: EventStatus, oldStatus?: EventStatus,
) => void; ) => void;
[RoomEvent.OldStateUpdated]: (room: Room, previousRoomState: RoomState, roomState: RoomState) => void;
[RoomEvent.CurrentStateUpdated]: (room: Room, previousRoomState: RoomState, roomState: RoomState) => void;
[RoomEvent.HistoryImportedWithinTimeline]: (
markerEvent: MatrixEvent,
room: Room,
) => void;
[RoomEvent.TimelineRefresh]: (room: Room, eventTimelineSet: EventTimelineSet) => void;
[ThreadEvent.New]: (thread: Thread, toStartOfTimeline: boolean) => void; [ThreadEvent.New]: (thread: Thread, toStartOfTimeline: boolean) => void;
} & ThreadHandlerMap & MatrixEventHandlerMap; } & ThreadHandlerMap & MatrixEventHandlerMap;
@ -206,6 +221,7 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
public readonly threadsTimelineSets: EventTimelineSet[] = []; public readonly threadsTimelineSets: EventTimelineSet[] = [];
// any filtered timeline sets we're maintaining for this room // any filtered timeline sets we're maintaining for this room
private readonly filteredTimelineSets: Record<string, EventTimelineSet> = {}; // filter_id: timelineSet private readonly filteredTimelineSets: Record<string, EventTimelineSet> = {}; // filter_id: timelineSet
private timelineNeedsRefresh = false;
private readonly pendingEventList?: MatrixEvent[]; private readonly pendingEventList?: MatrixEvent[];
// read by megolm via getter; boolean value - null indicates "use global value" // read by megolm via getter; boolean value - null indicates "use global value"
private blacklistUnverifiedDevices: boolean = null; private blacklistUnverifiedDevices: boolean = null;
@ -261,6 +277,7 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
* prefer getLiveTimeline().getState(EventTimeline.FORWARDS). * prefer getLiveTimeline().getState(EventTimeline.FORWARDS).
*/ */
public currentState: RoomState; public currentState: RoomState;
public readonly relations = new RelationsContainer(this.client, this);
/** /**
* @experimental * @experimental
@ -322,10 +339,6 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
* "chronological". * "chronological".
* @param {boolean} [opts.timelineSupport = false] Set to true to enable improved * @param {boolean} [opts.timelineSupport = false] Set to true to enable improved
* timeline support. * timeline support.
* @param {boolean} [opts.unstableClientRelationAggregation = false]
* Optional. Set to true to enable client-side aggregation of event relations
* via `EventTimelineSet#getRelationsForEvent`.
* This feature is currently unstable and the API may change without notice.
*/ */
constructor( constructor(
public readonly roomId: string, public readonly roomId: string,
@ -355,18 +368,16 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
if (this.opts.pendingEventOrdering === PendingEventOrdering.Detached) { if (this.opts.pendingEventOrdering === PendingEventOrdering.Detached) {
this.pendingEventList = []; this.pendingEventList = [];
const serializedPendingEventList = client.sessionStore.store.getItem(pendingEventsKey(this.roomId)); this.client.store.getPendingEvents(this.roomId).then(events => {
if (serializedPendingEventList) { events.forEach(async (serializedEvent: Partial<IEvent>) => {
JSON.parse(serializedPendingEventList) const event = new MatrixEvent(serializedEvent);
.forEach(async (serializedEvent: Partial<IEvent>) => { if (event.getType() === EventType.RoomMessageEncrypted) {
const event = new MatrixEvent(serializedEvent); await event.attemptDecryption(this.client.crypto);
if (event.getType() === EventType.RoomMessageEncrypted) { }
await event.attemptDecryption(this.client.crypto); event.setStatus(EventStatus.NOT_SENT);
} this.addPendingEvent(event, event.getTxnId());
event.setStatus(EventStatus.NOT_SENT); });
this.addPendingEvent(event, event.getTxnId()); });
});
}
} }
// awaited by getEncryptionTargetMembers while room members are loading // awaited by getEncryptionTargetMembers while room members are loading
@ -441,6 +452,15 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
return Promise.allSettled(decryptionPromises) as unknown as Promise<void>; return Promise.allSettled(decryptionPromises) as unknown as Promise<void>;
} }
/**
* Gets the creator of the room
* @returns {string} The creator of the room, or null if it could not be determined
*/
public getCreator(): string | null {
const createEvent = this.currentState.getStateEvents(EventType.RoomCreate, "");
return createEvent?.getContent()['creator'] ?? null;
}
/** /**
* Gets the version of the room * Gets the version of the room
* @returns {string} The version of the room, or null if it could not be determined * @returns {string} The version of the room, or null if it could not be determined
@ -897,6 +917,108 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
}); });
} }
/**
* Empty out the current live timeline and re-request it. This is used when
* historical messages are imported into the room via MSC2716 `/batch_send
* because the client may already have that section of the timeline loaded.
* We need to force the client to throw away their current timeline so that
* when they back paginate over the area again with the historical messages
* in between, it grabs the newly imported messages. We can listen for
* `UNSTABLE_MSC2716_MARKER`, in order to tell when historical messages are ready
* to be discovered in the room and the timeline needs a refresh. The SDK
* emits a `RoomEvent.HistoryImportedWithinTimeline` event when we detect a
* valid marker and can check the needs refresh status via
* `room.getTimelineNeedsRefresh()`.
*/
public async refreshLiveTimeline(): Promise<void> {
const liveTimelineBefore = this.getLiveTimeline();
const forwardPaginationToken = liveTimelineBefore.getPaginationToken(EventTimeline.FORWARDS);
const backwardPaginationToken = liveTimelineBefore.getPaginationToken(EventTimeline.BACKWARDS);
const eventsBefore = liveTimelineBefore.getEvents();
const mostRecentEventInTimeline = eventsBefore[eventsBefore.length - 1];
logger.log(
`[refreshLiveTimeline for ${this.roomId}] at ` +
`mostRecentEventInTimeline=${mostRecentEventInTimeline && mostRecentEventInTimeline.getId()} ` +
`liveTimelineBefore=${liveTimelineBefore.toString()} ` +
`forwardPaginationToken=${forwardPaginationToken} ` +
`backwardPaginationToken=${backwardPaginationToken}`,
);
// Get the main TimelineSet
const timelineSet = this.getUnfilteredTimelineSet();
let newTimeline: EventTimeline;
// If there isn't any event in the timeline, let's go fetch the latest
// event and construct a timeline from it.
//
// This should only really happen if the user ran into an error
// with refreshing the timeline before which left them in a blank
// timeline from `resetLiveTimeline`.
if (!mostRecentEventInTimeline) {
newTimeline = await this.client.getLatestTimeline(timelineSet);
} else {
// Empty out all of `this.timelineSets`. But we also need to keep the
// same `timelineSet` references around so the React code updates
// properly and doesn't ignore the room events we emit because it checks
// that the `timelineSet` references are the same. We need the
// `timelineSet` empty so that the `client.getEventTimeline(...)` call
// later, will call `/context` and create a new timeline instead of
// returning the same one.
this.resetLiveTimeline(null, null);
// Make the UI timeline show the new blank live timeline we just
// reset so that if the network fails below it's showing the
// accurate state of what we're working with instead of the
// disconnected one in the TimelineWindow which is just hanging
// around by reference.
this.emit(RoomEvent.TimelineRefresh, this, timelineSet);
// Use `client.getEventTimeline(...)` to construct a new timeline from a
// `/context` response state and events for the most recent event before
// we reset everything. The `timelineSet` we pass in needs to be empty
// in order for this function to call `/context` and generate a new
// timeline.
newTimeline = await this.client.getEventTimeline(timelineSet, mostRecentEventInTimeline.getId());
}
// If a racing `/sync` beat us to creating a new timeline, use that
// instead because it's the latest in the room and any new messages in
// the scrollback will include the history.
const liveTimeline = timelineSet.getLiveTimeline();
if (!liveTimeline || (
liveTimeline.getPaginationToken(Direction.Forward) === null &&
liveTimeline.getPaginationToken(Direction.Backward) === null &&
liveTimeline.getEvents().length === 0
)) {
logger.log(`[refreshLiveTimeline for ${this.roomId}] using our new live timeline`);
// Set the pagination token back to the live sync token (`null`) instead
// of using the `/context` historical token (ex. `t12-13_0_0_0_0_0_0_0_0`)
// so that it matches the next response from `/sync` and we can properly
// continue the timeline.
newTimeline.setPaginationToken(forwardPaginationToken, EventTimeline.FORWARDS);
// Set our new fresh timeline as the live timeline to continue syncing
// forwards and back paginating from.
timelineSet.setLiveTimeline(newTimeline);
// Fixup `this.oldstate` so that `scrollback` has the pagination tokens
// available
this.fixUpLegacyTimelineFields();
} else {
logger.log(
`[refreshLiveTimeline for ${this.roomId}] \`/sync\` or some other request beat us to creating a new ` +
`live timeline after we reset it. We'll use that instead since any events in the scrollback from ` +
`this timeline will include the history.`,
);
}
// The timeline has now been refreshed ✅
this.setTimelineNeedsRefresh(false);
// Emit an event which clients can react to and re-load the timeline
// from the SDK
this.emit(RoomEvent.TimelineRefresh, this, timelineSet);
}
/** /**
* Reset the live timeline of all timelineSets, and start new ones. * Reset the live timeline of all timelineSets, and start new ones.
* *
@ -924,6 +1046,9 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
* @private * @private
*/ */
private fixUpLegacyTimelineFields(): void { private fixUpLegacyTimelineFields(): void {
const previousOldState = this.oldState;
const previousCurrentState = this.currentState;
// maintain this.timeline as a reference to the live timeline, // maintain this.timeline as a reference to the live timeline,
// and this.oldState and this.currentState as references to the // and this.oldState and this.currentState as references to the
// state at the start and end of that timeline. These are more // state at the start and end of that timeline. These are more
@ -933,6 +1058,17 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
.getState(EventTimeline.BACKWARDS); .getState(EventTimeline.BACKWARDS);
this.currentState = this.getLiveTimeline() this.currentState = this.getLiveTimeline()
.getState(EventTimeline.FORWARDS); .getState(EventTimeline.FORWARDS);
// Let people know to register new listeners for the new state
// references. The reference won't necessarily change every time so only
// emit when we see a change.
if (previousOldState !== this.oldState) {
this.emit(RoomEvent.OldStateUpdated, this, previousOldState, this.oldState);
}
if (previousCurrentState !== this.currentState) {
this.emit(RoomEvent.CurrentStateUpdated, this, previousCurrentState, this.currentState);
}
} }
/** /**
@ -1000,6 +1136,24 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
return this.getUnfilteredTimelineSet().addTimeline(); return this.getUnfilteredTimelineSet().addTimeline();
} }
/**
* Whether the timeline needs to be refreshed in order to pull in new
* historical messages that were imported.
* @param {Boolean} value The value to set
*/
public setTimelineNeedsRefresh(value: boolean): void {
this.timelineNeedsRefresh = value;
}
/**
* Whether the timeline needs to be refreshed in order to pull in new
* historical messages that were imported.
* @return {Boolean} .
*/
public getTimelineNeedsRefresh(): boolean {
return this.timelineNeedsRefresh;
}
/** /**
* Get an event which is stored in our unfiltered timeline set, or in a thread * Get an event which is stored in our unfiltered timeline set, or in a thread
* *
@ -1454,7 +1608,9 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
return event.getSender() === this.client.getUserId(); return event.getSender() === this.client.getUserId();
}); });
if (filterType !== ThreadFilterType.My || currentUserParticipated) { if (filterType !== ThreadFilterType.My || currentUserParticipated) {
timelineSet.getLiveTimeline().addEvent(thread.rootEvent, false); timelineSet.getLiveTimeline().addEvent(thread.rootEvent, {
toStartOfTimeline: false,
});
} }
}); });
} }
@ -1501,22 +1657,20 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
let latestMyThreadsRootEvent: MatrixEvent; let latestMyThreadsRootEvent: MatrixEvent;
const roomState = this.getLiveTimeline().getState(EventTimeline.FORWARDS); const roomState = this.getLiveTimeline().getState(EventTimeline.FORWARDS);
for (const rootEvent of threadRoots) { for (const rootEvent of threadRoots) {
this.threadsTimelineSets[0].addLiveEvent( this.threadsTimelineSets[0].addLiveEvent(rootEvent, {
rootEvent, duplicateStrategy: DuplicateStrategy.Ignore,
DuplicateStrategy.Ignore, fromCache: false,
false,
roomState, roomState,
); });
const threadRelationship = rootEvent const threadRelationship = rootEvent
.getServerAggregatedRelation<IThreadBundledRelationship>(RelationType.Thread); .getServerAggregatedRelation<IThreadBundledRelationship>(RelationType.Thread);
if (threadRelationship.current_user_participated) { if (threadRelationship.current_user_participated) {
this.threadsTimelineSets[1].addLiveEvent( this.threadsTimelineSets[1].addLiveEvent(rootEvent, {
rootEvent, duplicateStrategy: DuplicateStrategy.Ignore,
DuplicateStrategy.Ignore, fromCache: false,
false,
roomState, roomState,
); });
latestMyThreadsRootEvent = rootEvent; latestMyThreadsRootEvent = rootEvent;
} }
@ -1578,7 +1732,7 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
} }
// A thread relation is always only shown in a thread // A thread relation is always only shown in a thread
if (event.isThreadRelation) { if (event.isRelation(THREAD_RELATION_TYPE.name)) {
return { return {
shouldLiveInRoom: false, shouldLiveInRoom: false,
shouldLiveInThread: true, shouldLiveInThread: true,
@ -1657,10 +1811,11 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
toStartOfTimeline: boolean, toStartOfTimeline: boolean,
): Thread { ): Thread {
if (rootEvent) { if (rootEvent) {
const tl = this.getTimelineForEvent(rootEvent.getId()); const relatedEvents = this.relations.getAllChildEventsForEvent(rootEvent.getId());
const relatedEvents = tl?.getTimelineSet().getAllRelationsEventForEvent(rootEvent.getId()); if (relatedEvents?.length) {
if (relatedEvents) { // Include all relations of the root event, given it'll be visible in both timelines,
events = events.concat(relatedEvents); // except `m.replace` as that will already be applied atop the event using `MatrixEvent::makeReplaced`
events = events.concat(relatedEvents.filter(e => !e.isRelation(RelationType.Replace)));
} }
} }
@ -1776,15 +1931,20 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
* "Room.timeline". * "Room.timeline".
* *
* @param {MatrixEvent} event Event to be added * @param {MatrixEvent} event Event to be added
* @param {string?} duplicateStrategy 'ignore' or 'replace' * @param {IAddLiveEventOptions} options addLiveEvent options
* @param {boolean} fromCache whether the sync response came from cache
* @fires module:client~MatrixClient#event:"Room.timeline" * @fires module:client~MatrixClient#event:"Room.timeline"
* @private * @private
*/ */
private addLiveEvent(event: MatrixEvent, duplicateStrategy: DuplicateStrategy, fromCache = false): void { private addLiveEvent(event: MatrixEvent, addLiveEventOptions: IAddLiveEventOptions): void {
const { duplicateStrategy, timelineWasEmpty, fromCache } = addLiveEventOptions;
// add to our timeline sets // add to our timeline sets
for (let i = 0; i < this.timelineSets.length; i++) { for (let i = 0; i < this.timelineSets.length; i++) {
this.timelineSets[i].addLiveEvent(event, duplicateStrategy, fromCache); this.timelineSets[i].addLiveEvent(event, {
duplicateStrategy,
fromCache,
timelineWasEmpty,
});
} }
// synthesize and inject implicit read receipts // synthesize and inject implicit read receipts
@ -1870,11 +2030,15 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
if (timelineSet.getFilter()) { if (timelineSet.getFilter()) {
if (timelineSet.getFilter().filterRoomTimeline([event]).length) { if (timelineSet.getFilter().filterRoomTimeline([event]).length) {
timelineSet.addEventToTimeline(event, timelineSet.addEventToTimeline(event,
timelineSet.getLiveTimeline(), false); timelineSet.getLiveTimeline(), {
toStartOfTimeline: false,
});
} }
} else { } else {
timelineSet.addEventToTimeline(event, timelineSet.addEventToTimeline(event,
timelineSet.getLiveTimeline(), false); timelineSet.getLiveTimeline(), {
toStartOfTimeline: false,
});
} }
} }
} }
@ -1909,15 +2073,7 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
return isEventEncrypted || !isRoomEncrypted; return isEventEncrypted || !isRoomEncrypted;
}); });
const { store } = this.client.sessionStore; this.client.store.setPendingEvents(this.roomId, pendingEvents);
if (this.pendingEventList.length > 0) {
store.setItem(
pendingEventsKey(this.roomId),
JSON.stringify(pendingEvents),
);
} else {
store.removeItem(pendingEventsKey(this.roomId));
}
} }
} }
@ -1932,24 +2088,7 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
* @param {module:models/event.MatrixEvent} event the relation event that needs to be aggregated. * @param {module:models/event.MatrixEvent} event the relation event that needs to be aggregated.
*/ */
private aggregateNonLiveRelation(event: MatrixEvent): void { private aggregateNonLiveRelation(event: MatrixEvent): void {
const { shouldLiveInRoom, threadId } = this.eventShouldLiveIn(event); this.relations.aggregateChildEvent(event);
const thread = this.getThread(threadId);
thread?.timelineSet.aggregateRelations(event);
if (shouldLiveInRoom) {
// TODO: We should consider whether this means it would be a better
// design to lift the relations handling up to the room instead.
for (let i = 0; i < this.timelineSets.length; i++) {
const timelineSet = this.timelineSets[i];
if (timelineSet.getFilter()) {
if (timelineSet.getFilter().filterRoomTimeline([event]).length) {
timelineSet.aggregateRelations(event);
}
} else {
timelineSet.aggregateRelations(event);
}
}
}
} }
public getEventForTxnId(txnId: string): MatrixEvent { public getEventForTxnId(txnId: string): MatrixEvent {
@ -2111,18 +2250,38 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
* they will go to the end of the timeline. * they will go to the end of the timeline.
* *
* @param {MatrixEvent[]} events A list of events to add. * @param {MatrixEvent[]} events A list of events to add.
* * @param {IAddLiveEventOptions} options addLiveEvent options
* @param {string} duplicateStrategy Optional. Applies to events in the
* timeline only. If this is 'replace' then if a duplicate is encountered, the
* event passed to this function will replace the existing event in the
* timeline. If this is not specified, or is 'ignore', then the event passed to
* this function will be ignored entirely, preserving the existing event in the
* timeline. Events are identical based on their event ID <b>only</b>.
*
* @param {boolean} fromCache whether the sync response came from cache
* @throws If <code>duplicateStrategy</code> is not falsey, 'replace' or 'ignore'. * @throws If <code>duplicateStrategy</code> is not falsey, 'replace' or 'ignore'.
*/ */
public addLiveEvents(events: MatrixEvent[], duplicateStrategy?: DuplicateStrategy, fromCache = false): void { public addLiveEvents(events: MatrixEvent[], addLiveEventOptions?: IAddLiveEventOptions): void;
/**
* @deprecated In favor of the overload with `IAddLiveEventOptions`
*/
public addLiveEvents(events: MatrixEvent[], duplicateStrategy?: DuplicateStrategy, fromCache?: boolean): void;
public addLiveEvents(
events: MatrixEvent[],
duplicateStrategyOrOpts?: DuplicateStrategy | IAddLiveEventOptions,
fromCache = false,
): void {
let duplicateStrategy = duplicateStrategyOrOpts as DuplicateStrategy;
let timelineWasEmpty: boolean;
if (typeof (duplicateStrategyOrOpts) === 'object') {
({
duplicateStrategy,
fromCache = false,
/* roomState, (not used here) */
timelineWasEmpty,
} = duplicateStrategyOrOpts);
} else if (duplicateStrategyOrOpts !== undefined) {
// Deprecation warning
// FIXME: Remove after 2023-06-01 (technical debt)
logger.warn(
'Overload deprecated: ' +
'`Room.addLiveEvents(events, duplicateStrategy?, fromCache?)` ' +
'is deprecated in favor of the overload with `Room.addLiveEvents(events, IAddLiveEventOptions)`',
);
}
if (duplicateStrategy && ["replace", "ignore"].indexOf(duplicateStrategy) === -1) { if (duplicateStrategy && ["replace", "ignore"].indexOf(duplicateStrategy) === -1) {
throw new Error("duplicateStrategy MUST be either 'replace' or 'ignore'"); throw new Error("duplicateStrategy MUST be either 'replace' or 'ignore'");
} }
@ -2160,7 +2319,11 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
eventsByThread[threadId]?.push(event); eventsByThread[threadId]?.push(event);
if (shouldLiveInRoom) { if (shouldLiveInRoom) {
this.addLiveEvent(event, duplicateStrategy, fromCache); this.addLiveEvent(event, {
duplicateStrategy,
fromCache,
timelineWasEmpty,
});
} }
} }
@ -2211,7 +2374,7 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
private findThreadRoots(events: MatrixEvent[]): Set<string> { private findThreadRoots(events: MatrixEvent[]): Set<string> {
const threadRoots = new Set<string>(); const threadRoots = new Set<string>();
for (const event of events) { for (const event of events) {
if (event.isThreadRelation) { if (event.isRelation(THREAD_RELATION_TYPE.name)) {
threadRoots.add(event.relationEventId); threadRoots.add(event.relationEventId);
} }
} }
@ -2939,14 +3102,6 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
} }
} }
/**
* @param {string} roomId ID of the current room
* @returns {string} Storage key to retrieve pending events
*/
function pendingEventsKey(roomId: string): string {
return `mx_pending_events_${roomId}`;
}
// a map from current event status to a list of allowed next statuses // a map from current event status to a list of allowed next statuses
const ALLOWED_TRANSITIONS: Record<EventStatus, EventStatus[]> = { const ALLOWED_TRANSITIONS: Record<EventStatus, EventStatus[]> = {
[EventStatus.ENCRYPTING]: [ [EventStatus.ENCRYPTING]: [

View File

@ -14,6 +14,8 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { Optional } from "matrix-events-sdk";
import { MatrixClient, MatrixEventEvent, RelationType, RoomEvent } from "../matrix"; import { MatrixClient, MatrixEventEvent, RelationType, RoomEvent } from "../matrix";
import { TypedReEmitter } from "../ReEmitter"; import { TypedReEmitter } from "../ReEmitter";
import { IRelationsRequestOpts } from "../@types/requests"; import { IRelationsRequestOpts } from "../@types/requests";
@ -79,13 +81,18 @@ export class Thread extends TypedEventEmitter<EmittedEvents, EventHandlerMap> {
) { ) {
super(); super();
if (!opts?.room) {
// Logging/debugging for https://github.com/vector-im/element-web/issues/22141
// Hope is that we end up with a more obvious stack trace.
throw new Error("element-web#22141: A thread requires a room in order to function");
}
this.room = opts.room; this.room = opts.room;
this.client = opts.client; this.client = opts.client;
this.timelineSet = new EventTimelineSet(this.room, { this.timelineSet = new EventTimelineSet(this.room, {
unstableClientRelationAggregation: true,
timelineSupport: true, timelineSupport: true,
pendingEvents: true, pendingEvents: true,
}); }, this.client, this);
this.reEmitter = new TypedReEmitter(this); this.reEmitter = new TypedReEmitter(this);
this.reEmitter.reEmit(this.timelineSet, [ this.reEmitter.reEmit(this.timelineSet, [
@ -160,6 +167,7 @@ export class Thread extends TypedEventEmitter<EmittedEvents, EventHandlerMap> {
private onEcho = (event: MatrixEvent) => { private onEcho = (event: MatrixEvent) => {
if (event.threadRootId !== this.id) return; // ignore echoes for other timelines if (event.threadRootId !== this.id) return; // ignore echoes for other timelines
if (this.lastEvent === event) return; if (this.lastEvent === event) return;
if (!event.isRelation(THREAD_RELATION_TYPE.name)) return;
// There is a risk that the `localTimestamp` approximation will not be accurate // There is a risk that the `localTimestamp` approximation will not be accurate
// when threads are used over federation. That could result in the reply // when threads are used over federation. That could result in the reply
@ -193,9 +201,11 @@ export class Thread extends TypedEventEmitter<EmittedEvents, EventHandlerMap> {
this.timelineSet.addEventToTimeline( this.timelineSet.addEventToTimeline(
event, event,
this.liveTimeline, this.liveTimeline,
toStartOfTimeline, {
false, toStartOfTimeline,
this.roomState, fromCache: false,
roomState: this.roomState,
},
); );
} }
} }
@ -221,12 +231,6 @@ export class Thread extends TypedEventEmitter<EmittedEvents, EventHandlerMap> {
this._currentUserParticipated = true; this._currentUserParticipated = true;
} }
// Add all annotations and replace relations to the timeline so that the relations are processed accordingly
if ([RelationType.Annotation, RelationType.Replace].includes(event.getRelation()?.rel_type as RelationType)) {
this.addEventToTimeline(event, toStartOfTimeline);
return;
}
// Add all incoming events to the thread's timeline set when there's no server support // Add all incoming events to the thread's timeline set when there's no server support
if (!Thread.hasServerSideSupport) { if (!Thread.hasServerSideSupport) {
// all the relevant membership info to hydrate events with a sender // all the relevant membership info to hydrate events with a sender
@ -242,6 +246,11 @@ export class Thread extends TypedEventEmitter<EmittedEvents, EventHandlerMap> {
) { ) {
this.fetchEditsWhereNeeded(event); this.fetchEditsWhereNeeded(event);
this.addEventToTimeline(event, false); this.addEventToTimeline(event, false);
} else if (event.isRelation(RelationType.Annotation) || event.isRelation(RelationType.Replace)) {
// Apply annotations and replace relations to the relations of the timeline only
this.timelineSet.relations.aggregateParentEvent(event);
this.timelineSet.relations.aggregateChildEvent(event, this.timelineSet);
return;
} }
// If no thread support exists we want to count all thread relation // If no thread support exists we want to count all thread relation
@ -284,6 +293,7 @@ export class Thread extends TypedEventEmitter<EmittedEvents, EventHandlerMap> {
// XXX: Workaround for https://github.com/matrix-org/matrix-spec-proposals/pull/2676/files#r827240084 // XXX: Workaround for https://github.com/matrix-org/matrix-spec-proposals/pull/2676/files#r827240084
private async fetchEditsWhereNeeded(...events: MatrixEvent[]): Promise<unknown> { private async fetchEditsWhereNeeded(...events: MatrixEvent[]): Promise<unknown> {
return Promise.all(events.filter(e => e.isEncrypted()).map((event: MatrixEvent) => { return Promise.all(events.filter(e => e.isEncrypted()).map((event: MatrixEvent) => {
if (event.isRelation()) return; // skip - relations don't get edits
return this.client.relations(this.roomId, event.getId(), RelationType.Replace, event.getType(), { return this.client.relations(this.roomId, event.getId(), RelationType.Replace, event.getType(), {
limit: 1, limit: 1,
}).then(relations => { }).then(relations => {
@ -320,15 +330,16 @@ export class Thread extends TypedEventEmitter<EmittedEvents, EventHandlerMap> {
} }
/** /**
* Return last reply to the thread * Return last reply to the thread, if known.
*/ */
public lastReply(matches: (ev: MatrixEvent) => boolean = () => true): MatrixEvent { public lastReply(matches: (ev: MatrixEvent) => boolean = () => true): Optional<MatrixEvent> {
for (let i = this.events.length - 1; i >= 0; i--) { for (let i = this.events.length - 1; i >= 0; i--) {
const event = this.events[i]; const event = this.events[i];
if (matches(event)) { if (matches(event)) {
return event; return event;
} }
} }
return null;
} }
public get roomId(): string { public get roomId(): string {
@ -345,9 +356,9 @@ export class Thread extends TypedEventEmitter<EmittedEvents, EventHandlerMap> {
} }
/** /**
* A getter for the last event added to the thread * A getter for the last event added to the thread, if known.
*/ */
public get replyToEvent(): MatrixEvent { public get replyToEvent(): Optional<MatrixEvent> {
return this.lastEvent ?? this.lastReply(); return this.lastEvent ?? this.lastReply();
} }

View File

@ -56,31 +56,6 @@ const RULEKINDS_IN_ORDER = [
// 2. We often want to start using push rules ahead of the server supporting them, // 2. We often want to start using push rules ahead of the server supporting them,
// and so we can put them here. // and so we can put them here.
const DEFAULT_OVERRIDE_RULES: IPushRule[] = [ const DEFAULT_OVERRIDE_RULES: IPushRule[] = [
{
// For homeservers which don't support MSC1930 yet
rule_id: ".m.rule.tombstone",
default: true,
enabled: true,
conditions: [
{
kind: ConditionKind.EventMatch,
key: "type",
pattern: "m.room.tombstone",
},
{
kind: ConditionKind.EventMatch,
key: "state_key",
pattern: "",
},
],
actions: [
PushRuleActionName.Notify,
{
set_tweak: TweakName.Highlight,
value: true,
},
],
},
{ {
// For homeservers which don't support MSC2153 yet // For homeservers which don't support MSC2153 yet
rule_id: ".m.rule.reaction", rule_id: ".m.rule.reaction",
@ -108,10 +83,13 @@ const DEFAULT_OVERRIDE_RULES: IPushRule[] = [
key: "type", key: "type",
pattern: EventType.RoomServerAcl, pattern: EventType.RoomServerAcl,
}, },
{
kind: ConditionKind.EventMatch,
key: "state_key",
pattern: "",
},
], ],
actions: [ actions: [],
PushRuleActionName.DontNotify,
],
}, },
]; ];

View File

@ -17,7 +17,7 @@ limitations under the License.
import { EventType } from "../@types/event"; import { EventType } from "../@types/event";
import { Room } from "../models/room"; import { Room } from "../models/room";
import { User } from "../models/user"; import { User } from "../models/user";
import { MatrixEvent } from "../models/event"; import { IEvent, MatrixEvent } from "../models/event";
import { Filter } from "../filter"; import { Filter } from "../filter";
import { RoomSummary } from "../models/room-summary"; import { RoomSummary } from "../models/room-summary";
import { IMinimalEvent, IRooms, ISyncResponse } from "../sync-accumulator"; import { IMinimalEvent, IRooms, ISyncResponse } from "../sync-accumulator";
@ -218,4 +218,8 @@ export interface IStore {
getClientOptions(): Promise<IStartClientOpts>; getClientOptions(): Promise<IStartClientOpts>;
storeClientOptions(options: IStartClientOpts): Promise<void>; storeClientOptions(options: IStartClientOpts): Promise<void>;
getPendingEvents(roomId: string): Promise<Partial<IEvent>[]>;
setPendingEvents(roomId: string, events: Partial<IEvent>[]): Promise<void>;
} }

View File

@ -127,6 +127,8 @@ export class LocalIndexedDBStoreBackend implements IIndexedDBBackend {
private db: IDBDatabase = null; private db: IDBDatabase = null;
private disconnected = true; private disconnected = true;
private _isNewlyCreated = false; private _isNewlyCreated = false;
private isPersisting = false;
private pendingUserPresenceData: UserTuple[] = [];
/** /**
* Does the actual reading from and writing to the indexeddb * Does the actual reading from and writing to the indexeddb
@ -266,7 +268,7 @@ export class LocalIndexedDBStoreBackend implements IIndexedDBBackend {
reject(err); reject(err);
}; };
}).then((events) => { }).then((events) => {
logger.log(`LL: got ${events && events.length} membershipEvents from storage for room ${roomId} ...`); logger.log(`LL: got ${events?.length} membershipEvents from storage for room ${roomId} ...`);
return events; return events;
}); });
} }
@ -401,11 +403,24 @@ export class LocalIndexedDBStoreBackend implements IIndexedDBBackend {
public async syncToDatabase(userTuples: UserTuple[]): Promise<void> { public async syncToDatabase(userTuples: UserTuple[]): Promise<void> {
const syncData = this.syncAccumulator.getJSON(true); const syncData = this.syncAccumulator.getJSON(true);
await Promise.all([ if (this.isPersisting) {
this.persistUserPresenceEvents(userTuples), logger.warn("Skipping syncToDatabase() as persist already in flight");
this.persistAccountData(syncData.accountData), this.pendingUserPresenceData.push(...userTuples);
this.persistSyncData(syncData.nextBatch, syncData.roomsData), return;
]); } else {
userTuples.unshift(...this.pendingUserPresenceData);
this.isPersisting = true;
}
try {
await Promise.all([
this.persistUserPresenceEvents(userTuples),
this.persistAccountData(syncData.accountData),
this.persistSyncData(syncData.nextBatch, syncData.roomsData),
]);
} finally {
this.isPersisting = false;
}
} }
/** /**
@ -427,7 +442,9 @@ export class LocalIndexedDBStoreBackend implements IIndexedDBBackend {
nextBatch, nextBatch,
roomsData, roomsData,
}); // put == UPSERT }); // put == UPSERT
return txnAsPromise(txn).then(); return txnAsPromise(txn).then(() => {
logger.log("Persisted sync data up to", nextBatch);
});
}); });
} }

View File

@ -122,8 +122,7 @@ export class IndexedDBStoreWorker {
result: ret, result: ret,
}); });
}, (err) => { }, (err) => {
logger.error("Error running command: " + msg.command); logger.error("Error running command: " + msg.command, err);
logger.error(err);
this.postMessage.call(null, { this.postMessage.call(null, {
command: 'cmd_fail', command: 'cmd_fail',
seq: msg.seq, seq: msg.seq,

View File

@ -243,7 +243,7 @@ export class IndexedDBStore extends MemoryStore {
* @returns {event[]} the events, potentially an empty array if OOB loading didn't yield any new members * @returns {event[]} the events, potentially an empty array if OOB loading didn't yield any new members
* @returns {null} in case the members for this room haven't been stored yet * @returns {null} in case the members for this room haven't been stored yet
*/ */
public getOutOfBandMembers = this.degradable((roomId: string): Promise<IStateEventWithRoomId[]> => { public getOutOfBandMembers = this.degradable((roomId: string): Promise<IStateEventWithRoomId[] | null> => {
return this.backend.getOutOfBandMembers(roomId); return this.backend.getOutOfBandMembers(roomId);
}, "getOutOfBandMembers"); }, "getOutOfBandMembers");
@ -297,7 +297,7 @@ export class IndexedDBStore extends MemoryStore {
return async (...args) => { return async (...args) => {
try { try {
return func.call(this, ...args); return await func.call(this, ...args);
} catch (e) { } catch (e) {
logger.error("IndexedDBStore failure, degrading to MemoryStore", e); logger.error("IndexedDBStore failure, degrading to MemoryStore", e);
this.emitter.emit("degraded", e); this.emitter.emit("degraded", e);
@ -320,11 +320,45 @@ export class IndexedDBStore extends MemoryStore {
// `IndexedDBStore` also maintain the state that `MemoryStore` uses (many are // `IndexedDBStore` also maintain the state that `MemoryStore` uses (many are
// not overridden at all). // not overridden at all).
if (fallbackFn) { if (fallbackFn) {
return fallbackFn(...args); return fallbackFn.call(this, ...args);
} }
} }
}; };
} }
// XXX: ideally these would be stored in indexeddb as part of the room but,
// we don't store rooms as such and instead accumulate entire sync responses atm.
public async getPendingEvents(roomId: string): Promise<Partial<IEvent>[]> {
if (!this.localStorage) return super.getPendingEvents(roomId);
const serialized = this.localStorage.getItem(pendingEventsKey(roomId));
if (serialized) {
try {
return JSON.parse(serialized);
} catch (e) {
logger.error("Could not parse persisted pending events", e);
}
}
return [];
}
public async setPendingEvents(roomId: string, events: Partial<IEvent>[]): Promise<void> {
if (!this.localStorage) return super.setPendingEvents(roomId, events);
if (events.length > 0) {
this.localStorage.setItem(pendingEventsKey(roomId), JSON.stringify(events));
} else {
this.localStorage.removeItem(pendingEventsKey(roomId));
}
}
}
/**
* @param {string} roomId ID of the current room
* @returns {string} Storage key to retrieve pending events
*/
function pendingEventsKey(roomId: string): string {
return `mx_pending_events_${roomId}`;
} }
type DegradableFn<A extends Array<any>, T> = (...args: A) => Promise<T>; type DegradableFn<A extends Array<any>, T> = (...args: A) => Promise<T>;

View File

@ -22,7 +22,7 @@ limitations under the License.
import { EventType } from "../@types/event"; import { EventType } from "../@types/event";
import { Room } from "../models/room"; import { Room } from "../models/room";
import { User } from "../models/user"; import { User } from "../models/user";
import { MatrixEvent } from "../models/event"; import { IEvent, MatrixEvent } from "../models/event";
import { RoomState, RoomStateEvent } from "../models/room-state"; import { RoomState, RoomStateEvent } from "../models/room-state";
import { RoomMember } from "../models/room-member"; import { RoomMember } from "../models/room-member";
import { Filter } from "../filter"; import { Filter } from "../filter";
@ -48,7 +48,7 @@ export interface IOpts {
* Construct a new in-memory data store for the Matrix Client. * Construct a new in-memory data store for the Matrix Client.
* @constructor * @constructor
* @param {Object=} opts Config options * @param {Object=} opts Config options
* @param {LocalStorage} opts.localStorage The local storage instance to persist * @param {Storage} opts.localStorage The local storage instance to persist
* some forms of data such as tokens. Rooms will NOT be stored. * some forms of data such as tokens. Rooms will NOT be stored.
*/ */
export class MemoryStore implements IStore { export class MemoryStore implements IStore {
@ -60,8 +60,9 @@ export class MemoryStore implements IStore {
// } // }
private filters: Record<string, Record<string, Filter>> = {}; private filters: Record<string, Record<string, Filter>> = {};
public accountData: Record<string, MatrixEvent> = {}; // type : content public accountData: Record<string, MatrixEvent> = {}; // type : content
private readonly localStorage: Storage; protected readonly localStorage: Storage;
private oobMembers: Record<string, IStateEventWithRoomId[]> = {}; // roomId: [member events] private oobMembers: Record<string, IStateEventWithRoomId[]> = {}; // roomId: [member events]
private pendingEvents: { [roomId: string]: Partial<IEvent>[] } = {};
private clientOptions = {}; private clientOptions = {};
constructor(opts: IOpts = {}) { constructor(opts: IOpts = {}) {
@ -199,7 +200,7 @@ export class MemoryStore implements IStore {
/** /**
* Retrieve scrollback for this room. * Retrieve scrollback for this room.
* @param {Room} room The matrix room * @param {Room} room The matrix room
* @param {integer} limit The max number of old events to retrieve. * @param {number} limit The max number of old events to retrieve.
* @return {Array<Object>} An array of objects which will be at most 'limit' * @return {Array<Object>} An array of objects which will be at most 'limit'
* length and at least 0. The objects are the raw event JSON. * length and at least 0. The objects are the raw event JSON.
*/ */
@ -420,4 +421,12 @@ export class MemoryStore implements IStore {
this.clientOptions = Object.assign({}, options); this.clientOptions = Object.assign({}, options);
return Promise.resolve(); return Promise.resolve();
} }
public async getPendingEvents(roomId: string): Promise<Partial<IEvent>[]> {
return this.pendingEvents[roomId] ?? [];
}
public async setPendingEvents(roomId: string, events: Partial<IEvent>[]): Promise<void> {
this.pendingEvents[roomId] = events;
}
} }

View File

@ -1,263 +0,0 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 New Vector Ltd
Copyright 2018 New Vector Ltd
Copyright 2019 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.
*/
/**
* @module store/session/webstorage
*/
import * as utils from "../../utils";
import { logger } from '../../logger';
const DEBUG = false; // set true to enable console logging.
const E2E_PREFIX = "session.e2e.";
/**
* Construct a web storage session store, capable of storing account keys,
* session keys and access tokens.
* @constructor
* @param {WebStorage} webStore A web storage implementation, e.g.
* 'window.localStorage' or 'window.sessionStorage' or a custom implementation.
* @throws if the supplied 'store' does not meet the Storage interface of the
* WebStorage API.
*/
export function WebStorageSessionStore(webStore) {
this.store = webStore;
if (!utils.isFunction(webStore.getItem) ||
!utils.isFunction(webStore.setItem) ||
!utils.isFunction(webStore.removeItem) ||
!utils.isFunction(webStore.key) ||
typeof(webStore.length) !== 'number'
) {
throw new Error(
"Supplied webStore does not meet the WebStorage API interface",
);
}
}
WebStorageSessionStore.prototype = {
/**
* Remove the stored end to end account for the logged-in user.
*/
removeEndToEndAccount: function() {
this.store.removeItem(KEY_END_TO_END_ACCOUNT);
},
/**
* Load the end to end account for the logged-in user.
* Note that the end-to-end account is now stored in the
* crypto store rather than here: this remains here so
* old sessions can be migrated out of the session store.
* @return {?string} Base64 encoded account.
*/
getEndToEndAccount: function() {
return this.store.getItem(KEY_END_TO_END_ACCOUNT);
},
/**
* Retrieves the known devices for all users.
* @return {object} A map from user ID to map of device ID to keys for the device.
*/
getAllEndToEndDevices: function() {
const prefix = keyEndToEndDevicesForUser('');
const devices = {};
for (let i = 0; i < this.store.length; ++i) {
const key = this.store.key(i);
const userId = key.slice(prefix.length);
if (key.startsWith(prefix)) devices[userId] = getJsonItem(this.store, key);
}
return devices;
},
getEndToEndDeviceTrackingStatus: function() {
return getJsonItem(this.store, KEY_END_TO_END_DEVICE_LIST_TRACKING_STATUS);
},
/**
* Get the sync token corresponding to the device list.
*
* @return {String?} token
*/
getEndToEndDeviceSyncToken: function() {
return getJsonItem(this.store, KEY_END_TO_END_DEVICE_SYNC_TOKEN);
},
/**
* Removes all end to end device data from the store
*/
removeEndToEndDeviceData: function() {
removeByPrefix(this.store, keyEndToEndDevicesForUser(''));
removeByPrefix(this.store, KEY_END_TO_END_DEVICE_LIST_TRACKING_STATUS);
removeByPrefix(this.store, KEY_END_TO_END_DEVICE_SYNC_TOKEN);
},
/**
* Retrieve the end-to-end sessions between the logged-in user and another
* device.
* @param {string} deviceKey The public key of the other device.
* @return {object} A map from sessionId to Base64 end-to-end session.
*/
getEndToEndSessions: function(deviceKey) {
return getJsonItem(this.store, keyEndToEndSessions(deviceKey));
},
/**
* Retrieve all end-to-end sessions between the logged-in user and other
* devices.
* @return {object} A map of {deviceKey -> {sessionId -> session pickle}}
*/
getAllEndToEndSessions: function() {
const deviceKeys = getKeysWithPrefix(this.store, keyEndToEndSessions(''));
const results = {};
for (const k of deviceKeys) {
const unprefixedKey = k.slice(keyEndToEndSessions('').length);
results[unprefixedKey] = getJsonItem(this.store, k);
}
return results;
},
/**
* Remove all end-to-end sessions from the store
* This is used after migrating sessions awat from the sessions store.
*/
removeAllEndToEndSessions: function() {
removeByPrefix(this.store, keyEndToEndSessions(''));
},
/**
* Retrieve a list of all known inbound group sessions
*
* @return {{senderKey: string, sessionId: string}}
*/
getAllEndToEndInboundGroupSessionKeys: function() {
const prefix = E2E_PREFIX + 'inboundgroupsessions/';
const result = [];
for (let i = 0; i < this.store.length; i++) {
const key = this.store.key(i);
if (!key.startsWith(prefix)) {
continue;
}
// we can't use split, as the components we are trying to split out
// might themselves contain '/' characters. We rely on the
// senderKey being a (32-byte) curve25519 key, base64-encoded
// (hence 43 characters long).
result.push({
senderKey: key.slice(prefix.length, prefix.length + 43),
sessionId: key.slice(prefix.length + 44),
});
}
return result;
},
getEndToEndInboundGroupSession: function(senderKey, sessionId) {
const key = keyEndToEndInboundGroupSession(senderKey, sessionId);
return this.store.getItem(key);
},
removeAllEndToEndInboundGroupSessions: function() {
removeByPrefix(this.store, E2E_PREFIX + 'inboundgroupsessions/');
},
/**
* Get the end-to-end state for all rooms
* @return {object} roomId -> object with the end-to-end info for the room.
*/
getAllEndToEndRooms: function() {
const roomKeys = getKeysWithPrefix(this.store, keyEndToEndRoom(''));
const results = {};
for (const k of roomKeys) {
const unprefixedKey = k.slice(keyEndToEndRoom('').length);
results[unprefixedKey] = getJsonItem(this.store, k);
}
return results;
},
removeAllEndToEndRooms: function() {
removeByPrefix(this.store, keyEndToEndRoom(''));
},
setLocalTrustedBackupPubKey: function(pubkey) {
this.store.setItem(KEY_END_TO_END_TRUSTED_BACKUP_PUBKEY, pubkey);
},
// XXX: This store is deprecated really, but added this as a temporary
// thing until cross-signing lands.
getLocalTrustedBackupPubKey: function() {
return this.store.getItem(KEY_END_TO_END_TRUSTED_BACKUP_PUBKEY);
},
};
const KEY_END_TO_END_ACCOUNT = E2E_PREFIX + "account";
const KEY_END_TO_END_DEVICE_SYNC_TOKEN = E2E_PREFIX + "device_sync_token";
const KEY_END_TO_END_DEVICE_LIST_TRACKING_STATUS = E2E_PREFIX + "device_tracking";
const KEY_END_TO_END_TRUSTED_BACKUP_PUBKEY = E2E_PREFIX + "trusted_backup_pubkey";
function keyEndToEndDevicesForUser(userId) {
return E2E_PREFIX + "devices/" + userId;
}
function keyEndToEndSessions(deviceKey) {
return E2E_PREFIX + "sessions/" + deviceKey;
}
function keyEndToEndInboundGroupSession(senderKey, sessionId) {
return E2E_PREFIX + "inboundgroupsessions/" + senderKey + "/" + sessionId;
}
function keyEndToEndRoom(roomId) {
return E2E_PREFIX + "rooms/" + roomId;
}
function getJsonItem(store, key) {
try {
// if the key is absent, store.getItem() returns null, and
// JSON.parse(null) === null, so this returns null.
return JSON.parse(store.getItem(key));
} catch (e) {
debuglog("Failed to get key %s: %s", key, e);
debuglog(e.stack);
}
return null;
}
function getKeysWithPrefix(store, prefix) {
const results = [];
for (let i = 0; i < store.length; ++i) {
const key = store.key(i);
if (key.startsWith(prefix)) results.push(key);
}
return results;
}
function removeByPrefix(store, prefix) {
const toRemove = [];
for (let i = 0; i < store.length; ++i) {
const key = store.key(i);
if (key.startsWith(prefix)) toRemove.push(key);
}
for (const key of toRemove) {
store.removeItem(key);
}
}
function debuglog(...args) {
if (DEBUG) {
logger.log(...args);
}
}

View File

@ -22,7 +22,7 @@ limitations under the License.
import { EventType } from "../@types/event"; import { EventType } from "../@types/event";
import { Room } from "../models/room"; import { Room } from "../models/room";
import { User } from "../models/user"; import { User } from "../models/user";
import { MatrixEvent } from "../models/event"; import { IEvent, MatrixEvent } from "../models/event";
import { Filter } from "../filter"; import { Filter } from "../filter";
import { ISavedSync, IStore } from "./index"; import { ISavedSync, IStore } from "./index";
import { RoomSummary } from "../models/room-summary"; import { RoomSummary } from "../models/room-summary";
@ -262,4 +262,12 @@ export class StubStore implements IStore {
public storeClientOptions(options: object): Promise<void> { public storeClientOptions(options: object): Promise<void> {
return Promise.resolve(); return Promise.resolve();
} }
public async getPendingEvents(roomId: string): Promise<Partial<IEvent>[]> {
return [];
}
public setPendingEvents(roomId: string, events: Partial<IEvent>[]): Promise<void> {
return Promise.resolve();
}
} }

View File

@ -50,7 +50,6 @@ export interface IRoomEvent extends IMinimalEvent {
event_id: string; event_id: string;
sender: string; sender: string;
origin_server_ts: number; origin_server_ts: number;
unsigned?: IUnsigned;
/** @deprecated - legacy field */ /** @deprecated - legacy field */
age?: number; age?: number;
} }

View File

@ -51,9 +51,11 @@ import { MatrixError, Method } from "./http-api";
import { ISavedSync } from "./store"; import { ISavedSync } from "./store";
import { EventType } from "./@types/event"; import { EventType } from "./@types/event";
import { IPushRules } from "./@types/PushRules"; import { IPushRules } from "./@types/PushRules";
import { RoomStateEvent } from "./models/room-state"; import { RoomState, RoomStateEvent, IMarkerFoundOptions } from "./models/room-state";
import { RoomMemberEvent } from "./models/room-member"; import { RoomMemberEvent } from "./models/room-member";
import { BeaconEvent } from "./models/beacon"; import { BeaconEvent } from "./models/beacon";
import { IEventsResponse } from "./@types/requests";
import { IAbortablePromise } from "./@types/partials";
const DEBUG = true; const DEBUG = true;
@ -69,14 +71,32 @@ const BUFFER_PERIOD_MS = 80 * 1000;
const FAILED_SYNC_ERROR_THRESHOLD = 3; const FAILED_SYNC_ERROR_THRESHOLD = 3;
export enum SyncState { export enum SyncState {
/** Emitted after we try to sync more than `FAILED_SYNC_ERROR_THRESHOLD`
* times and are still failing. Or when we enounter a hard error like the
* token being invalid. */
Error = "ERROR", Error = "ERROR",
/** Emitted after the first sync events are ready (this could even be sync
* events from the cache) */
Prepared = "PREPARED", Prepared = "PREPARED",
/** Emitted when the sync loop is no longer running */
Stopped = "STOPPED", Stopped = "STOPPED",
/** Emitted after each sync request happens */
Syncing = "SYNCING", Syncing = "SYNCING",
/** Emitted after a connectivity error and we're ready to start syncing again */
Catchup = "CATCHUP", Catchup = "CATCHUP",
/** Emitted for each time we try reconnecting. Will switch to `Error` after
* we reach the `FAILED_SYNC_ERROR_THRESHOLD`
*/
Reconnecting = "RECONNECTING", Reconnecting = "RECONNECTING",
} }
// Room versions where "insertion", "batch", and "marker" events are controlled
// by power-levels. MSC2716 is supported in existing room versions but they
// should only have special meaning when the room creator sends them.
const MSC2716_ROOM_VERSIONS = [
'org.matrix.msc2716v3',
];
function getFilterName(userId: string, suffix?: string): string { function getFilterName(userId: string, suffix?: string): string {
// scope this on the user ID because people may login on many accounts // scope this on the user ID because people may login on many accounts
// and they all need to be stored! // and they all need to be stored!
@ -120,11 +140,6 @@ interface ISyncParams {
_cacheBuster?: string | number; // not part of the API itself _cacheBuster?: string | number; // not part of the API itself
} }
// http-api mangles an abort method onto its promises
interface IRequestPromise<T> extends Promise<T> {
abort(): void;
}
type WrappedRoom<T> = T & { type WrappedRoom<T> = T & {
room: Room; room: Room;
isBrandNewRoom: boolean; isBrandNewRoom: boolean;
@ -147,7 +162,7 @@ type WrappedRoom<T> = T & {
*/ */
export class SyncApi { export class SyncApi {
private _peekRoom: Room = null; private _peekRoom: Room = null;
private currentSyncRequest: IRequestPromise<ISyncResponse> = null; private currentSyncRequest: IAbortablePromise<ISyncResponse> = null;
private syncState: SyncState = null; private syncState: SyncState = null;
private syncStateData: ISyncStateData = null; // additional data (eg. error object for failed sync) private syncStateData: ISyncStateData = null; // additional data (eg. error object for failed sync)
private catchingUp = false; private catchingUp = false;
@ -187,13 +202,11 @@ export class SyncApi {
const client = this.client; const client = this.client;
const { const {
timelineSupport, timelineSupport,
unstableClientRelationAggregation,
} = client; } = client;
const room = new Room(roomId, client, client.getUserId(), { const room = new Room(roomId, client, client.getUserId(), {
lazyLoadMembers: this.opts.lazyLoadMembers, lazyLoadMembers: this.opts.lazyLoadMembers,
pendingEventOrdering: this.opts.pendingEventOrdering, pendingEventOrdering: this.opts.pendingEventOrdering,
timelineSupport, timelineSupport,
unstableClientRelationAggregation,
}); });
client.reEmitter.reEmit(room, [ client.reEmitter.reEmit(room, [
RoomEvent.Name, RoomEvent.Name,
@ -208,6 +221,15 @@ export class SyncApi {
RoomEvent.TimelineReset, RoomEvent.TimelineReset,
]); ]);
this.registerStateListeners(room); this.registerStateListeners(room);
// Register listeners again after the state reference changes
room.on(RoomEvent.CurrentStateUpdated, (targetRoom, previousCurrentState) => {
if (targetRoom !== room) {
return;
}
this.deregisterStateListeners(previousCurrentState);
this.registerStateListeners(room);
});
return room; return room;
} }
@ -240,17 +262,89 @@ export class SyncApi {
RoomMemberEvent.Membership, RoomMemberEvent.Membership,
]); ]);
}); });
room.currentState.on(RoomStateEvent.Marker, (markerEvent, markerFoundOptions) => {
this.onMarkerStateEvent(room, markerEvent, markerFoundOptions);
});
} }
/** /**
* @param {Room} room * @param {RoomState} roomState The roomState to clear listeners from
* @private * @private
*/ */
private deregisterStateListeners(room: Room): void { private deregisterStateListeners(roomState: RoomState): void {
// could do with a better way of achieving this. // could do with a better way of achieving this.
room.currentState.removeAllListeners(RoomStateEvent.Events); roomState.removeAllListeners(RoomStateEvent.Events);
room.currentState.removeAllListeners(RoomStateEvent.Members); roomState.removeAllListeners(RoomStateEvent.Members);
room.currentState.removeAllListeners(RoomStateEvent.NewMember); roomState.removeAllListeners(RoomStateEvent.NewMember);
roomState.removeAllListeners(RoomStateEvent.Marker);
}
/** When we see the marker state change in the room, we know there is some
* new historical messages imported by MSC2716 `/batch_send` somewhere in
* the room and we need to throw away the timeline to make sure the
* historical messages are shown when we paginate `/messages` again.
* @param {Room} room The room where the marker event was sent
* @param {MatrixEvent} markerEvent The new marker event
* @param {ISetStateOptions} setStateOptions When `timelineWasEmpty` is set
* as `true`, the given marker event will be ignored
*/
private onMarkerStateEvent(
room: Room,
markerEvent: MatrixEvent,
{ timelineWasEmpty }: IMarkerFoundOptions = {},
): void {
// We don't need to refresh the timeline if it was empty before the
// marker arrived. This could be happen in a variety of cases:
// 1. From the initial sync
// 2. If it's from the first state we're seeing after joining the room
// 3. Or whether it's coming from `syncFromCache`
if (timelineWasEmpty) {
logger.debug(
`MarkerState: Ignoring markerEventId=${markerEvent.getId()} in roomId=${room.roomId} ` +
`because the timeline was empty before the marker arrived which means there is nothing to refresh.`,
);
return;
}
const isValidMsc2716Event =
// Check whether the room version directly supports MSC2716, in
// which case, "marker" events are already auth'ed by
// power_levels
MSC2716_ROOM_VERSIONS.includes(room.getVersion()) ||
// MSC2716 is also supported in all existing room versions but
// special meaning should only be given to "insertion", "batch",
// and "marker" events when they come from the room creator
markerEvent.getSender() === room.getCreator();
// It would be nice if we could also specifically tell whether the
// historical messages actually affected the locally cached client
// timeline or not. The problem is we can't see the prev_events of
// the base insertion event that the marker was pointing to because
// prev_events aren't available in the client API's. In most cases,
// the history won't be in people's locally cached timelines in the
// client, so we don't need to bother everyone about refreshing
// their timeline. This works for a v1 though and there are use
// cases like initially bootstrapping your bridged room where people
// are likely to encounter the historical messages affecting their
// current timeline (think someone signing up for Beeper and
// importing their Whatsapp history).
if (isValidMsc2716Event) {
// Saw new marker event, let's let the clients know they should
// refresh the timeline.
logger.debug(
`MarkerState: Timeline needs to be refreshed because ` +
`a new markerEventId=${markerEvent.getId()} was sent in roomId=${room.roomId}`,
);
room.setTimelineNeedsRefresh(true);
room.emit(RoomEvent.HistoryImportedWithinTimeline, markerEvent, room);
} else {
logger.debug(
`MarkerState: Ignoring markerEventId=${markerEvent.getId()} in roomId=${room.roomId} because ` +
`MSC2716 is not supported in the room version or for any room version, the marker wasn't sent ` +
`by the room creator.`,
);
}
} }
/** /**
@ -274,7 +368,7 @@ export class SyncApi {
getFilterName(client.credentials.userId, "LEFT_ROOMS"), filter, getFilterName(client.credentials.userId, "LEFT_ROOMS"), filter,
).then(function(filterId) { ).then(function(filterId) {
qps.filter = filterId; qps.filter = filterId;
return client.http.authedRequest<any>( // TODO types return client.http.authedRequest<ISyncResponse>(
undefined, Method.Get, "/sync", qps as any, undefined, localTimeoutMs, undefined, Method.Get, "/sync", qps as any, undefined, localTimeoutMs,
); );
}).then(async (data) => { }).then(async (data) => {
@ -409,8 +503,7 @@ export class SyncApi {
} }
// FIXME: gut wrenching; hard-coded timeout values // FIXME: gut wrenching; hard-coded timeout values
// TODO types this.client.http.authedRequest<IEventsResponse>(undefined, Method.Get, "/events", {
this.client.http.authedRequest<any>(undefined, Method.Get, "/events", {
room_id: peekRoom.roomId, room_id: peekRoom.roomId,
timeout: String(30 * 1000), timeout: String(30 * 1000),
from: token, from: token,
@ -702,9 +795,7 @@ export class SyncApi {
global.window.removeEventListener("online", this.onOnline, false); global.window.removeEventListener("online", this.onOnline, false);
} }
this.running = false; this.running = false;
if (this.currentSyncRequest) { this.currentSyncRequest?.abort();
this.currentSyncRequest.abort();
}
if (this.keepAliveTimer) { if (this.keepAliveTimer) {
clearTimeout(this.keepAliveTimer); clearTimeout(this.keepAliveTimer);
this.keepAliveTimer = null; this.keepAliveTimer = null;
@ -872,9 +963,9 @@ export class SyncApi {
this.doSync(syncOptions); this.doSync(syncOptions);
} }
private doSyncRequest(syncOptions: ISyncOptions, syncToken: string): IRequestPromise<ISyncResponse> { private doSyncRequest(syncOptions: ISyncOptions, syncToken: string): IAbortablePromise<ISyncResponse> {
const qps = this.getSyncParams(syncOptions, syncToken); const qps = this.getSyncParams(syncOptions, syncToken);
return this.client.http.authedRequest( // TODO types return this.client.http.authedRequest<ISyncResponse>(
undefined, Method.Get, "/sync", qps as any, undefined, undefined, Method.Get, "/sync", qps as any, undefined,
qps.timeout + BUFFER_PERIOD_MS, qps.timeout + BUFFER_PERIOD_MS,
); );
@ -1166,6 +1257,9 @@ export class SyncApi {
room.recalculate(); room.recalculate();
client.store.storeRoom(room); client.store.storeRoom(room);
client.emit(ClientEvent.Room, room); client.emit(ClientEvent.Room, room);
} else {
// Update room state for invite->reject->invite cycles
room.recalculate();
} }
stateEvents.forEach(function(e) { stateEvents.forEach(function(e) {
client.emit(ClientEvent.Event, e); client.emit(ClientEvent.Event, e);
@ -1251,7 +1345,6 @@ export class SyncApi {
} }
if (limited) { if (limited) {
this.deregisterStateListeners(room);
room.resetLiveTimeline( room.resetLiveTimeline(
joinObj.timeline.prev_batch, joinObj.timeline.prev_batch,
this.opts.canResetEntireTimeline(room.roomId) ? this.opts.canResetEntireTimeline(room.roomId) ?
@ -1262,8 +1355,6 @@ export class SyncApi {
// reason to stop incrementally tracking notifications and // reason to stop incrementally tracking notifications and
// reset the timeline. // reset the timeline.
client.resetNotifTimelineSet(); client.resetNotifTimelineSet();
this.registerStateListeners(room);
} }
} }
@ -1587,7 +1678,9 @@ export class SyncApi {
for (const ev of stateEventList) { for (const ev of stateEventList) {
this.client.getPushActionsForEvent(ev); this.client.getPushActionsForEvent(ev);
} }
liveTimeline.initialiseState(stateEventList); liveTimeline.initialiseState(stateEventList, {
timelineWasEmpty,
});
} }
this.resolveInvites(room); this.resolveInvites(room);
@ -1625,7 +1718,10 @@ export class SyncApi {
// if the timeline has any state events in it. // if the timeline has any state events in it.
// This also needs to be done before running push rules on the events as they need // This also needs to be done before running push rules on the events as they need
// to be decorated with sender etc. // to be decorated with sender etc.
room.addLiveEvents(timelineEventList || [], null, fromCache); room.addLiveEvents(timelineEventList || [], {
fromCache,
timelineWasEmpty,
});
this.client.processBeaconEvents(room, timelineEventList); this.client.processBeaconEvents(room, timelineEventList);
} }

View File

@ -2509,6 +2509,37 @@ export function setTracksEnabled(tracks: Array<MediaStreamTrack>, enabled: boole
} }
} }
export function supportsMatrixCall(): boolean {
// typeof prevents Node from erroring on an undefined reference
if (typeof(window) === 'undefined' || typeof(document) === 'undefined') {
// NB. We don't log here as apps try to create a call object as a test for
// whether calls are supported, so we shouldn't fill the logs up.
return false;
}
// Firefox throws on so little as accessing the RTCPeerConnection when operating in a secure mode.
// There's some information at https://bugzilla.mozilla.org/show_bug.cgi?id=1542616 though the concern
// is that the browser throwing a SecurityError will brick the client creation process.
try {
const supported = Boolean(
window.RTCPeerConnection || window.RTCSessionDescription ||
window.RTCIceCandidate || navigator.mediaDevices,
);
if (!supported) {
/* istanbul ignore if */ // Adds a lot of noise to test runs, so disable logging there.
if (process.env.NODE_ENV !== "test") {
logger.error("WebRTC is not supported in this browser / environment");
}
return false;
}
} catch (e) {
logger.error("Exception thrown when trying to access WebRTC", e);
return false;
}
return true;
}
/** /**
* DEPRECATED * DEPRECATED
* Use client.createCall() * Use client.createCall()
@ -2522,34 +2553,8 @@ export function setTracksEnabled(tracks: Array<MediaStreamTrack>, enabled: boole
* since it's only possible to set this option on outbound calls. * since it's only possible to set this option on outbound calls.
* @return {MatrixCall} the call or null if the browser doesn't support calling. * @return {MatrixCall} the call or null if the browser doesn't support calling.
*/ */
export function createNewMatrixCall(client: any, roomId: string, options?: CallOpts): MatrixCall { export function createNewMatrixCall(client: any, roomId: string, options?: CallOpts): MatrixCall | null {
// typeof prevents Node from erroring on an undefined reference if (!supportsMatrixCall()) return null;
if (typeof(window) === 'undefined' || typeof(document) === 'undefined') {
// NB. We don't log here as apps try to create a call object as a test for
// whether calls are supported, so we shouldn't fill the logs up.
return null;
}
// Firefox throws on so little as accessing the RTCPeerConnection when operating in
// a secure mode. There's some information at https://bugzilla.mozilla.org/show_bug.cgi?id=1542616
// though the concern is that the browser throwing a SecurityError will brick the
// client creation process.
try {
const supported = Boolean(
window.RTCPeerConnection || window.RTCSessionDescription ||
window.RTCIceCandidate || navigator.mediaDevices,
);
if (!supported) {
// Adds a lot of noise to test runs, so disable logging there.
if (process.env.NODE_ENV !== "test") {
logger.error("WebRTC is not supported in this browser / environment");
}
return null;
}
} catch (e) {
logger.error("Exception thrown when trying to access WebRTC", e);
return null;
}
const optionsForceTURN = options ? options.forceTURN : false; const optionsForceTURN = options ? options.forceTURN : false;

View File

@ -94,7 +94,7 @@ export class CallEventHandler {
return eventType.startsWith("m.call.") || eventType.startsWith("org.matrix.call."); return eventType.startsWith("m.call.") || eventType.startsWith("org.matrix.call.");
}); });
const ignoreCallIds = new Set<String>(); const ignoreCallIds = new Set<string>();
// inspect the buffer and mark all calls which have been answered // inspect the buffer and mark all calls which have been answered
// or hung up before passing them to the call event handler. // or hung up before passing them to the call event handler.
@ -297,7 +297,7 @@ export class CallEventHandler {
} }
// Were we trying to call that user (room)? // Were we trying to call that user (room)?
let existingCall; let existingCall: MatrixCall;
for (const thisCall of this.calls.values()) { for (const thisCall of this.calls.values()) {
const isCalling = [CallState.WaitLocalMedia, CallState.CreateOffer, CallState.InviteSent].includes( const isCalling = [CallState.WaitLocalMedia, CallState.CreateOffer, CallState.InviteSent].includes(
thisCall.state, thisCall.state,
@ -384,7 +384,7 @@ export class CallEventHandler {
// The following events need a call and a peer connection // The following events need a call and a peer connection
if (!call || !call.hasPeerConnection) { if (!call || !call.hasPeerConnection) {
logger.warn("Discarding an event, we don't have a call/peerConn", type); logger.info(`Discarding possible call event ${event.getId()} as we don't have a call/peerConn`, type);
return; return;
} }
// Ignore remote echo // Ignore remote echo

Some files were not shown because too many files have changed in this diff Show More