You've already forked matrix-js-sdk
mirror of
https://github.com/matrix-org/matrix-js-sdk.git
synced 2026-01-03 23:22:30 +03:00
Merge branch 'develop' into robertlong/group-call
This commit is contained in:
@@ -52,6 +52,8 @@ module.exports = {
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
// We'd rather not do this but we do
|
||||
"@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",
|
||||
// We use a `logger` intermediary module
|
||||
|
||||
53
.github/workflows/jsdoc.yml
vendored
Normal file
53
.github/workflows/jsdoc.yml
vendored
Normal 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: .
|
||||
19
.github/workflows/notify-downstream.yaml
vendored
19
.github/workflows/notify-downstream.yaml
vendored
@@ -2,13 +2,26 @@ name: Notify Downstream Projects
|
||||
on:
|
||||
push:
|
||||
branches: [ develop ]
|
||||
concurrency: ${{ github.workflow }}-${{ github.ref }}
|
||||
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
|
||||
steps:
|
||||
- name: Notify matrix-react-sdk repo that a new SDK build is on develop so it can CI against it
|
||||
uses: peter-evans/repository-dispatch@v1
|
||||
with:
|
||||
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||
repository: vector-im/element-web
|
||||
event-type: upstream-sdk-notify
|
||||
repository: ${{ matrix.repo }}
|
||||
event-type: ${{ matrix.event }}
|
||||
|
||||
82
.github/workflows/pull_request.yaml
vendored
82
.github/workflows/pull_request.yaml
vendored
@@ -1,24 +1,92 @@
|
||||
name: Pull Request
|
||||
on:
|
||||
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:
|
||||
changelog:
|
||||
name: Preview Changelog
|
||||
if: github.event.action != 'synchronize'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: matrix-org/allchange@main
|
||||
with:
|
||||
ghToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
requireLabel: true
|
||||
|
||||
enforce-label:
|
||||
name: Enforce Labels
|
||||
prevent-blocked:
|
||||
name: Prevent Blocked
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: read
|
||||
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:
|
||||
REQUIRED_LABELS_ANY: "T-Defect,T-Deprecation,T-Enhancement,T-Task"
|
||||
BANNED_LABELS: "X-Blocked"
|
||||
BANNED_LABELS_DESCRIPTION: "Preventing merge whilst PR is marked blocked!"
|
||||
script: |
|
||||
core.setFailed("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
24
.github/workflows/sonarcloud.yml
vendored
Normal 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
|
||||
64
.github/workflows/sonarqube.yml
vendored
64
.github/workflows/sonarqube.yml
vendored
@@ -4,62 +4,12 @@ on:
|
||||
workflows: [ "Tests" ]
|
||||
types:
|
||||
- completed
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_branch }}
|
||||
cancel-in-progress: true
|
||||
jobs:
|
||||
sonarqube:
|
||||
name: SonarQube
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event.workflow_run.conclusion == 'success'
|
||||
steps:
|
||||
- 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 }}
|
||||
name: 🩻 SonarQube
|
||||
uses: matrix-org/matrix-js-sdk/.github/workflows/sonarcloud.yml@develop
|
||||
secrets:
|
||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||
|
||||
3
.github/workflows/static_analysis.yml
vendored
3
.github/workflows/static_analysis.yml
vendored
@@ -3,6 +3,9 @@ on:
|
||||
pull_request: { }
|
||||
push:
|
||||
branches: [ develop, master ]
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
jobs:
|
||||
ts_lint:
|
||||
name: "Typescript Syntax Check"
|
||||
|
||||
7
.github/workflows/tests.yml
vendored
7
.github/workflows/tests.yml
vendored
@@ -2,7 +2,10 @@ name: Tests
|
||||
on:
|
||||
pull_request: { }
|
||||
push:
|
||||
branches: [ develop, main, master ]
|
||||
branches: [ develop, master ]
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
jobs:
|
||||
jest:
|
||||
name: Jest
|
||||
@@ -23,7 +26,7 @@ jobs:
|
||||
run: "yarn build"
|
||||
|
||||
- name: Run tests with coverage
|
||||
run: "yarn coverage --ci"
|
||||
run: "yarn coverage --ci --reporters github-actions"
|
||||
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@v2
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
instrumentation:
|
||||
compact: false
|
||||
34
CHANGELOG.md
34
CHANGELOG.md
@@ -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)
|
||||
==================================================================================================
|
||||
|
||||
|
||||
@@ -23,7 +23,18 @@ Things that should go into your PR description:
|
||||
* A changelog entry in the `Notes` section (see below)
|
||||
* 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
|
||||
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
|
||||
what's changing.
|
||||
* 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
|
||||
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
|
||||
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
|
||||
@@ -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
|
||||
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
|
||||
concisely what is being changed. Ideally, your new tests fail
|
||||
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
|
||||
```
|
||||
|
||||
Review expectations
|
||||
===================
|
||||
|
||||
See https://github.com/vector-im/element-meta/wiki/Review-process
|
||||
|
||||
|
||||
Merge Strategy
|
||||
==============
|
||||
|
||||
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
|
||||
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
|
||||
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.
|
||||
|
||||
|
||||
[1]: https://github.com/gotwarlost/istanbul/blob/master/ignoring-code-for-coverage.md
|
||||
|
||||
14
package.json
14
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "matrix-js-sdk",
|
||||
"version": "17.2.0",
|
||||
"version": "18.1.0",
|
||||
"description": "Matrix Client-Server SDK for Javascript",
|
||||
"engines": {
|
||||
"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",
|
||||
"@types/bs58": "^4.0.1",
|
||||
"@types/content-type": "^1.1.5",
|
||||
"@types/jest": "^26.0.20",
|
||||
"@types/jest": "^27.0.0",
|
||||
"@types/node": "12",
|
||||
"@types/request": "^2.48.5",
|
||||
"@typescript-eslint/eslint-plugin": "^5.6.0",
|
||||
"@typescript-eslint/parser": "^5.6.0",
|
||||
"allchange": "^1.0.6",
|
||||
"babel-jest": "^26.6.3",
|
||||
"babel-jest": "^28.0.0",
|
||||
"babelify": "^10.0.0",
|
||||
"better-docs": "^2.4.0-beta.9",
|
||||
"browserify": "^17.0.0",
|
||||
"docdash": "^1.2.0",
|
||||
"eslint": "8.9.0",
|
||||
"eslint": "8.16.0",
|
||||
"eslint-config-google": "^0.14.0",
|
||||
"eslint-plugin-import": "^2.25.4",
|
||||
"eslint-plugin-matrix-org": "^0.4.0",
|
||||
"eslint-plugin-matrix-org": "^0.5.0",
|
||||
"exorcist": "^1.0.1",
|
||||
"fake-indexeddb": "^3.1.2",
|
||||
"jest": "^26.6.3",
|
||||
"jest": "^28.0.0",
|
||||
"jest-localstorage-mock": "^2.4.6",
|
||||
"jest-sonar-reporter": "^2.0.0",
|
||||
"jsdoc": "^3.6.6",
|
||||
"matrix-mock-request": "^1.2.3",
|
||||
"matrix-mock-request": "^2.0.1",
|
||||
"rimraf": "^3.0.2",
|
||||
"terser": "^5.5.1",
|
||||
"tsify": "^5.0.2",
|
||||
|
||||
23
release.sh
23
release.sh
@@ -29,7 +29,7 @@ fi
|
||||
npm --version > /dev/null || (echo "npm 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() {
|
||||
cat <<EOF
|
||||
@@ -37,7 +37,6 @@ $USAGE
|
||||
|
||||
-c changelog_file: specify name of file containing changelog
|
||||
-x: skip updating the changelog
|
||||
-z: skip generating the jsdoc
|
||||
-n: skip publish to NPM
|
||||
EOF
|
||||
}
|
||||
@@ -60,7 +59,6 @@ if ! git diff-files --quiet; then
|
||||
fi
|
||||
|
||||
skip_changelog=
|
||||
skip_jsdoc=
|
||||
skip_npm=
|
||||
changelog_file="CHANGELOG.md"
|
||||
expected_npm_user="matrixdotorg"
|
||||
@@ -76,9 +74,6 @@ while getopts hc:u:xzn f; do
|
||||
x)
|
||||
skip_changelog=1
|
||||
;;
|
||||
z)
|
||||
skip_jsdoc=1
|
||||
;;
|
||||
n)
|
||||
skip_npm=1
|
||||
;;
|
||||
@@ -326,22 +321,6 @@ if [ -z "$skip_npm" ]; then
|
||||
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 [ $prerelease -eq 1 ]; then
|
||||
git checkout "$rel_branch"
|
||||
|
||||
16
renovate.json
Normal file
16
renovate.json
Normal 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"
|
||||
}
|
||||
@@ -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
239
spec/TestClient.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -136,138 +136,137 @@ describe("DeviceList management:", function() {
|
||||
});
|
||||
});
|
||||
|
||||
it("We should not get confused by out-of-order device query responses",
|
||||
() => {
|
||||
// https://github.com/vector-im/element-web/issues/3126
|
||||
aliceTestClient.expectKeyQuery({ device_keys: { '@alice:localhost': {} } });
|
||||
return aliceTestClient.start().then(() => {
|
||||
aliceTestClient.httpBackend.when('GET', '/sync').respond(
|
||||
200, getSyncResponse(['@bob:xyz', '@chris:abc']));
|
||||
return aliceTestClient.flushSync();
|
||||
}).then(() => {
|
||||
// to make sure the initial device queries are flushed out, we
|
||||
// attempt to send a message.
|
||||
it.skip("We should not get confused by out-of-order device query responses", () => {
|
||||
// https://github.com/vector-im/element-web/issues/3126
|
||||
aliceTestClient.expectKeyQuery({ device_keys: { '@alice:localhost': {} } });
|
||||
return aliceTestClient.start().then(() => {
|
||||
aliceTestClient.httpBackend.when('GET', '/sync').respond(
|
||||
200, getSyncResponse(['@bob:xyz', '@chris:abc']));
|
||||
return aliceTestClient.flushSync();
|
||||
}).then(() => {
|
||||
// to make sure the initial device queries are flushed out, we
|
||||
// attempt to send a message.
|
||||
|
||||
aliceTestClient.httpBackend.when('POST', '/keys/query').respond(
|
||||
200, {
|
||||
device_keys: {
|
||||
'@bob:xyz': {},
|
||||
'@chris:abc': {},
|
||||
},
|
||||
},
|
||||
);
|
||||
aliceTestClient.httpBackend.when('POST', '/keys/query').respond(
|
||||
200, {
|
||||
device_keys: {
|
||||
'@bob:xyz': {},
|
||||
'@chris:abc': {},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
aliceTestClient.httpBackend.when('PUT', '/send/').respond(
|
||||
200, { event_id: '$event1' });
|
||||
aliceTestClient.httpBackend.when('PUT', '/send/').respond(
|
||||
200, { event_id: '$event1' });
|
||||
|
||||
return Promise.all([
|
||||
aliceTestClient.client.sendTextMessage(ROOM_ID, 'test'),
|
||||
aliceTestClient.httpBackend.flush('/keys/query', 1).then(
|
||||
() => aliceTestClient.httpBackend.flush('/send/', 1),
|
||||
),
|
||||
aliceTestClient.client.crypto.deviceList.saveIfDirty(),
|
||||
]);
|
||||
}).then(() => {
|
||||
aliceTestClient.cryptoStore.getEndToEndDeviceData(null, (data) => {
|
||||
expect(data.syncToken).toEqual(1);
|
||||
});
|
||||
return Promise.all([
|
||||
aliceTestClient.client.sendTextMessage(ROOM_ID, 'test'),
|
||||
aliceTestClient.httpBackend.flush('/keys/query', 1).then(
|
||||
() => aliceTestClient.httpBackend.flush('/send/', 1),
|
||||
),
|
||||
aliceTestClient.client.crypto.deviceList.saveIfDirty(),
|
||||
]);
|
||||
}).then(() => {
|
||||
aliceTestClient.client.cryptoStore.getEndToEndDeviceData(null, (data) => {
|
||||
expect(data.syncToken).toEqual(1);
|
||||
});
|
||||
|
||||
// invalidate bob's and chris's device lists in separate syncs
|
||||
aliceTestClient.httpBackend.when('GET', '/sync').respond(200, {
|
||||
next_batch: '2',
|
||||
device_lists: {
|
||||
changed: ['@bob:xyz'],
|
||||
},
|
||||
});
|
||||
aliceTestClient.httpBackend.when('GET', '/sync').respond(200, {
|
||||
next_batch: '3',
|
||||
device_lists: {
|
||||
changed: ['@chris:abc'],
|
||||
},
|
||||
});
|
||||
// flush both syncs
|
||||
return aliceTestClient.flushSync().then(() => {
|
||||
return aliceTestClient.flushSync();
|
||||
});
|
||||
}).then(() => {
|
||||
// check that we don't yet have a request for chris's devices.
|
||||
aliceTestClient.httpBackend.when('POST', '/keys/query', {
|
||||
device_keys: {
|
||||
'@chris:abc': {},
|
||||
},
|
||||
token: '3',
|
||||
}).respond(200, {
|
||||
device_keys: { '@chris:abc': {} },
|
||||
});
|
||||
return aliceTestClient.httpBackend.flush('/keys/query', 1);
|
||||
}).then((flushed) => {
|
||||
expect(flushed).toEqual(0);
|
||||
return aliceTestClient.client.crypto.deviceList.saveIfDirty();
|
||||
}).then(() => {
|
||||
aliceTestClient.cryptoStore.getEndToEndDeviceData(null, (data) => {
|
||||
const bobStat = data.trackingStatus['@bob:xyz'];
|
||||
if (bobStat != 1 && bobStat != 2) {
|
||||
throw new Error('Unexpected status for bob: wanted 1 or 2, got ' +
|
||||
bobStat);
|
||||
}
|
||||
const chrisStat = data.trackingStatus['@chris:abc'];
|
||||
if (chrisStat != 1 && chrisStat != 2) {
|
||||
throw new Error(
|
||||
'Unexpected status for chris: wanted 1 or 2, got ' + chrisStat,
|
||||
);
|
||||
}
|
||||
});
|
||||
// invalidate bob's and chris's device lists in separate syncs
|
||||
aliceTestClient.httpBackend.when('GET', '/sync').respond(200, {
|
||||
next_batch: '2',
|
||||
device_lists: {
|
||||
changed: ['@bob:xyz'],
|
||||
},
|
||||
});
|
||||
aliceTestClient.httpBackend.when('GET', '/sync').respond(200, {
|
||||
next_batch: '3',
|
||||
device_lists: {
|
||||
changed: ['@chris:abc'],
|
||||
},
|
||||
});
|
||||
// flush both syncs
|
||||
return aliceTestClient.flushSync().then(() => {
|
||||
return aliceTestClient.flushSync();
|
||||
});
|
||||
}).then(() => {
|
||||
// check that we don't yet have a request for chris's devices.
|
||||
aliceTestClient.httpBackend.when('POST', '/keys/query', {
|
||||
device_keys: {
|
||||
'@chris:abc': {},
|
||||
},
|
||||
token: '3',
|
||||
}).respond(200, {
|
||||
device_keys: { '@chris:abc': {} },
|
||||
});
|
||||
return aliceTestClient.httpBackend.flush('/keys/query', 1);
|
||||
}).then((flushed) => {
|
||||
expect(flushed).toEqual(0);
|
||||
return aliceTestClient.client.crypto.deviceList.saveIfDirty();
|
||||
}).then(() => {
|
||||
aliceTestClient.client.cryptoStore.getEndToEndDeviceData(null, (data) => {
|
||||
const bobStat = data.trackingStatus['@bob:xyz'];
|
||||
if (bobStat != 1 && bobStat != 2) {
|
||||
throw new Error('Unexpected status for bob: wanted 1 or 2, got ' +
|
||||
bobStat);
|
||||
}
|
||||
const chrisStat = data.trackingStatus['@chris:abc'];
|
||||
if (chrisStat != 1 && chrisStat != 2) {
|
||||
throw new Error(
|
||||
'Unexpected status for chris: wanted 1 or 2, got ' + chrisStat,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// now add an expectation for a query for bob's devices, and let
|
||||
// it complete.
|
||||
aliceTestClient.httpBackend.when('POST', '/keys/query', {
|
||||
device_keys: {
|
||||
'@bob:xyz': {},
|
||||
},
|
||||
token: '2',
|
||||
}).respond(200, {
|
||||
device_keys: { '@bob:xyz': {} },
|
||||
});
|
||||
return aliceTestClient.httpBackend.flush('/keys/query', 1);
|
||||
}).then((flushed) => {
|
||||
expect(flushed).toEqual(1);
|
||||
// now add an expectation for a query for bob's devices, and let
|
||||
// it complete.
|
||||
aliceTestClient.httpBackend.when('POST', '/keys/query', {
|
||||
device_keys: {
|
||||
'@bob:xyz': {},
|
||||
},
|
||||
token: '2',
|
||||
}).respond(200, {
|
||||
device_keys: { '@bob:xyz': {} },
|
||||
});
|
||||
return aliceTestClient.httpBackend.flush('/keys/query', 1);
|
||||
}).then((flushed) => {
|
||||
expect(flushed).toEqual(1);
|
||||
|
||||
// wait for the client to stop processing the response
|
||||
return aliceTestClient.client.downloadKeys(['@bob:xyz']);
|
||||
}).then(() => {
|
||||
return aliceTestClient.client.crypto.deviceList.saveIfDirty();
|
||||
}).then(() => {
|
||||
aliceTestClient.cryptoStore.getEndToEndDeviceData(null, (data) => {
|
||||
const bobStat = data.trackingStatus['@bob:xyz'];
|
||||
expect(bobStat).toEqual(3);
|
||||
const chrisStat = data.trackingStatus['@chris:abc'];
|
||||
if (chrisStat != 1 && chrisStat != 2) {
|
||||
throw new Error(
|
||||
'Unexpected status for chris: wanted 1 or 2, got ' + bobStat,
|
||||
);
|
||||
}
|
||||
});
|
||||
// wait for the client to stop processing the response
|
||||
return aliceTestClient.client.downloadKeys(['@bob:xyz']);
|
||||
}).then(() => {
|
||||
return aliceTestClient.client.crypto.deviceList.saveIfDirty();
|
||||
}).then(() => {
|
||||
aliceTestClient.client.cryptoStore.getEndToEndDeviceData(null, (data) => {
|
||||
const bobStat = data.trackingStatus['@bob:xyz'];
|
||||
expect(bobStat).toEqual(3);
|
||||
const chrisStat = data.trackingStatus['@chris:abc'];
|
||||
if (chrisStat != 1 && chrisStat != 2) {
|
||||
throw new Error(
|
||||
'Unexpected status for chris: wanted 1 or 2, got ' + bobStat,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// now let the query for chris's devices complete.
|
||||
return aliceTestClient.httpBackend.flush('/keys/query', 1);
|
||||
}).then((flushed) => {
|
||||
expect(flushed).toEqual(1);
|
||||
// now let the query for chris's devices complete.
|
||||
return aliceTestClient.httpBackend.flush('/keys/query', 1);
|
||||
}).then((flushed) => {
|
||||
expect(flushed).toEqual(1);
|
||||
|
||||
// wait for the client to stop processing the response
|
||||
return aliceTestClient.client.downloadKeys(['@chris:abc']);
|
||||
}).then(() => {
|
||||
return aliceTestClient.client.crypto.deviceList.saveIfDirty();
|
||||
}).then(() => {
|
||||
aliceTestClient.cryptoStore.getEndToEndDeviceData(null, (data) => {
|
||||
const bobStat = data.trackingStatus['@bob:xyz'];
|
||||
const chrisStat = data.trackingStatus['@bob:xyz'];
|
||||
// wait for the client to stop processing the response
|
||||
return aliceTestClient.client.downloadKeys(['@chris:abc']);
|
||||
}).then(() => {
|
||||
return aliceTestClient.client.crypto.deviceList.saveIfDirty();
|
||||
}).then(() => {
|
||||
aliceTestClient.client.cryptoStore.getEndToEndDeviceData(null, (data) => {
|
||||
const bobStat = data.trackingStatus['@bob:xyz'];
|
||||
const chrisStat = data.trackingStatus['@bob:xyz'];
|
||||
|
||||
expect(bobStat).toEqual(3);
|
||||
expect(chrisStat).toEqual(3);
|
||||
expect(data.syncToken).toEqual(3);
|
||||
});
|
||||
});
|
||||
}).timeout(3000);
|
||||
expect(bobStat).toEqual(3);
|
||||
expect(chrisStat).toEqual(3);
|
||||
expect(data.syncToken).toEqual(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// https://github.com/vector-im/element-web/issues/4983
|
||||
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.client.crypto.deviceList.saveIfDirty();
|
||||
|
||||
aliceTestClient.cryptoStore.getEndToEndDeviceData(null, (data) => {
|
||||
aliceTestClient.client.cryptoStore.getEndToEndDeviceData(null, (data) => {
|
||||
const bobStat = data.trackingStatus['@bob:xyz'];
|
||||
|
||||
expect(bobStat).toBeGreaterThan(
|
||||
@@ -324,7 +323,7 @@ describe("DeviceList management:", function() {
|
||||
await aliceTestClient.flushSync();
|
||||
await aliceTestClient.client.crypto.deviceList.saveIfDirty();
|
||||
|
||||
aliceTestClient.cryptoStore.getEndToEndDeviceData(null, (data) => {
|
||||
aliceTestClient.client.cryptoStore.getEndToEndDeviceData(null, (data) => {
|
||||
const bobStat = data.trackingStatus['@bob:xyz'];
|
||||
|
||||
expect(bobStat).toEqual(
|
||||
@@ -360,7 +359,7 @@ describe("DeviceList management:", function() {
|
||||
await aliceTestClient.flushSync();
|
||||
await aliceTestClient.client.crypto.deviceList.saveIfDirty();
|
||||
|
||||
aliceTestClient.cryptoStore.getEndToEndDeviceData(null, (data) => {
|
||||
aliceTestClient.client.cryptoStore.getEndToEndDeviceData(null, (data) => {
|
||||
const bobStat = data.trackingStatus['@bob:xyz'];
|
||||
|
||||
expect(bobStat).toEqual(
|
||||
@@ -381,7 +380,7 @@ describe("DeviceList management:", function() {
|
||||
await anotherTestClient.flushSync();
|
||||
await anotherTestClient.client.crypto.deviceList.saveIfDirty();
|
||||
|
||||
anotherTestClient.cryptoStore.getEndToEndDeviceData(null, (data) => {
|
||||
anotherTestClient.client.cryptoStore.getEndToEndDeviceData(null, (data) => {
|
||||
const bobStat = data.trackingStatus['@bob:xyz'];
|
||||
|
||||
expect(bobStat).toEqual(
|
||||
@@ -161,7 +161,7 @@ function aliDownloadsKeys() {
|
||||
return Promise.all([p1, p2]).then(() => {
|
||||
return aliTestClient.client.crypto.deviceList.saveIfDirty();
|
||||
}).then(() => {
|
||||
aliTestClient.cryptoStore.getEndToEndDeviceData(null, (data) => {
|
||||
aliTestClient.client.cryptoStore.getEndToEndDeviceData(null, (data) => {
|
||||
const devices = data.devices[bobUserId];
|
||||
expect(devices[bobDeviceId].keys).toEqual(bobTestClient.deviceKeys.keys);
|
||||
expect(devices[bobDeviceId].verified).
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { TestClient } from "../TestClient";
|
||||
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,
|
||||
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({
|
||||
@@ -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
|
||||
function startClient(httpBackend, client) {
|
||||
httpBackend.when("GET", "/versions").respond(200, {});
|
||||
@@ -500,7 +515,8 @@ describe("MatrixClient event timelines", function() {
|
||||
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];
|
||||
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))
|
||||
.respond(200, function() {
|
||||
@@ -526,8 +542,7 @@ describe("MatrixClient event timelines", function() {
|
||||
return {
|
||||
original_event: THREAD_ROOT,
|
||||
chunk: [THREAD_REPLY],
|
||||
next_batch: "next_batch_token0",
|
||||
prev_batch: "prev_batch_token0",
|
||||
// no next batch as this is the oldest end of the timeline
|
||||
};
|
||||
});
|
||||
|
||||
@@ -536,8 +551,189 @@ describe("MatrixClient event timelines", function() {
|
||||
|
||||
const timeline = await timelinePromise;
|
||||
|
||||
expect(timeline.getEvents().find(e => e.getId() === THREAD_ROOT.event_id));
|
||||
expect(timeline.getEvents().find(e => e.getId() === THREAD_REPLY.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)).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(),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 { CRYPTO_ENABLED } from "../../src/client";
|
||||
import { MatrixEvent } from "../../src/models/event";
|
||||
@@ -26,7 +42,7 @@ describe("MatrixClient", function() {
|
||||
});
|
||||
|
||||
describe("uploadContent", function() {
|
||||
const buf = new Buffer('hello world');
|
||||
const buf = Buffer.from('hello world');
|
||||
it("should upload the file", function() {
|
||||
httpBackend.when(
|
||||
"POST", "/_matrix/media/r0/upload",
|
||||
@@ -458,6 +474,10 @@ describe("MatrixClient", function() {
|
||||
return client.initCrypto();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
client.stopClient();
|
||||
});
|
||||
|
||||
it("should do an HTTP request and then store the keys", function() {
|
||||
const ed25519key = "7wG2lzAqbjcyEkOP7O4gU7ItYcn+chKzh5sT/5r2l78";
|
||||
// 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) {
|
||||
|
||||
@@ -8,7 +8,6 @@ import { MatrixError } from "../../src/http-api";
|
||||
|
||||
describe("MatrixClient opts", function() {
|
||||
const baseUrl = "http://localhost.or.something";
|
||||
let client = null;
|
||||
let httpBackend = null;
|
||||
const userId = "@alice:localhost";
|
||||
const userB = "@bob:localhost";
|
||||
@@ -65,6 +64,7 @@ describe("MatrixClient opts", function() {
|
||||
});
|
||||
|
||||
describe("without opts.store", function() {
|
||||
let client;
|
||||
beforeEach(function() {
|
||||
client = new MatrixClient({
|
||||
request: httpBackend.requestFn,
|
||||
@@ -124,6 +124,7 @@ describe("MatrixClient opts", function() {
|
||||
});
|
||||
|
||||
describe("without opts.scheduler", function() {
|
||||
let client;
|
||||
beforeEach(function() {
|
||||
client = new MatrixClient({
|
||||
request: httpBackend.requestFn,
|
||||
@@ -135,6 +136,10 @@ describe("MatrixClient opts", function() {
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(function() {
|
||||
client.stopClient();
|
||||
});
|
||||
|
||||
it("shouldn't retry sending events", function(done) {
|
||||
httpBackend.when("PUT", "/txn1").fail(500, new MatrixError({
|
||||
errcode: "M_SOMETHING",
|
||||
|
||||
@@ -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 { Room } from "../../src/models/room";
|
||||
import { TestClient } from "../TestClient";
|
||||
|
||||
describe("MatrixClient retrying", function() {
|
||||
let client: TestClient = null;
|
||||
let client: MatrixClient = null;
|
||||
let httpBackend: TestClient["httpBackend"] = null;
|
||||
let scheduler;
|
||||
const userId = "@alice:localhost";
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as utils from "../test-utils/test-utils";
|
||||
import { EventStatus } from "../../src/models/event";
|
||||
import { RoomEvent } from "../../src";
|
||||
import { TestClient } from "../TestClient";
|
||||
|
||||
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 = [
|
||||
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,
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 { TestClient } from "../TestClient";
|
||||
|
||||
@@ -60,6 +76,112 @@ describe("MatrixClient syncing", function() {
|
||||
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() {
|
||||
@@ -177,7 +299,7 @@ describe("MatrixClient syncing", function() {
|
||||
httpBackend.when("GET", "/sync").respond(200, syncData);
|
||||
|
||||
let latestFiredName = null;
|
||||
client.on("RoomMember.name", function(event, m) {
|
||||
client.on(RoomMemberEvent.Name, function(event, m) {
|
||||
if (m.userId === userC && m.roomId === roomOne) {
|
||||
latestFiredName = m.name;
|
||||
}
|
||||
@@ -461,6 +583,477 @@ describe("MatrixClient syncing", 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() {
|
||||
@@ -516,7 +1109,7 @@ describe("MatrixClient syncing", function() {
|
||||
awaitSyncEvent(),
|
||||
]).then(function() {
|
||||
const room = client.getRoom(roomTwo);
|
||||
expect(room).toBeDefined();
|
||||
expect(room).toBeTruthy();
|
||||
const tok = room.getLiveTimeline()
|
||||
.getPaginationToken(EventTimeline.BACKWARDS);
|
||||
expect(tok).toEqual("roomtwotok");
|
||||
@@ -545,7 +1138,7 @@ describe("MatrixClient syncing", function() {
|
||||
|
||||
let resetCallCount = 0;
|
||||
// the token should be set *before* timelineReset is emitted
|
||||
client.on("Room.timelineReset", function(room) {
|
||||
client.on(RoomEvent.TimelineReset, function(room) {
|
||||
resetCallCount++;
|
||||
|
||||
const tl = room.getLiveTimeline();
|
||||
|
||||
165
spec/integ/megolm-backup.spec.ts
Normal file
165
spec/integ/megolm-backup.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1029,6 +1029,7 @@ describe("megolm", function() {
|
||||
});
|
||||
return event.attemptDecryption(testClient.client.crypto, true).then(() => {
|
||||
expect(event.isKeySourceUntrusted()).toBeFalsy();
|
||||
testClient.stop();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,6 +27,7 @@ type InfoContentProps = {
|
||||
isLive?: boolean;
|
||||
assetType?: LocationAssetType;
|
||||
description?: string;
|
||||
timestamp?: number;
|
||||
};
|
||||
const DEFAULT_INFO_CONTENT_PROPS: InfoContentProps = {
|
||||
timeout: 3600000,
|
||||
@@ -44,7 +45,11 @@ export const makeBeaconInfoEvent = (
|
||||
eventId?: string,
|
||||
): MatrixEvent => {
|
||||
const {
|
||||
timeout, isLive, description, assetType,
|
||||
timeout,
|
||||
isLive,
|
||||
description,
|
||||
assetType,
|
||||
timestamp,
|
||||
} = {
|
||||
...DEFAULT_INFO_CONTENT_PROPS,
|
||||
...contentProps,
|
||||
@@ -53,10 +58,10 @@ export const makeBeaconInfoEvent = (
|
||||
type: M_BEACON_INFO.name,
|
||||
room_id: roomId,
|
||||
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
|
||||
// set or default this
|
||||
|
||||
@@ -6,7 +6,7 @@ import '../olm-loader';
|
||||
|
||||
import { logger } from '../../src/logger';
|
||||
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 { eventMapperFor } from "../../src/event-mapper";
|
||||
|
||||
@@ -74,7 +74,6 @@ interface IEventOpts {
|
||||
sender?: string;
|
||||
skey?: string;
|
||||
content: IContent;
|
||||
event?: boolean;
|
||||
user?: string;
|
||||
unsigned?: IUnsigned;
|
||||
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.
|
||||
* @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) {
|
||||
throw new Error("Missing .type or .content =>" + JSON.stringify(opts));
|
||||
}
|
||||
@@ -143,7 +144,9 @@ interface IPresenceOpts {
|
||||
* @param {Object} opts Values for the presence.
|
||||
* @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 = {
|
||||
event_id: "$" + Math.random() + "-" + Math.random(),
|
||||
type: "m.presence",
|
||||
@@ -182,7 +185,9 @@ interface IMembershipOpts {
|
||||
* @param {boolean} opts.event True to make a MatrixEvent.
|
||||
* @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 = {
|
||||
...opts,
|
||||
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.
|
||||
* @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 = {
|
||||
...opts,
|
||||
type: EventType.RoomMessage,
|
||||
content: {
|
||||
msgtype: "m.text",
|
||||
msgtype: MsgType.Text,
|
||||
body: opts.msg,
|
||||
},
|
||||
};
|
||||
@@ -236,6 +243,50 @@ export function mkMessage(opts: IMessageOpts, client?: MatrixClient): object | M
|
||||
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
|
||||
*
|
||||
|
||||
@@ -17,7 +17,13 @@ limitations under the License.
|
||||
import { REFERENCE_RELATION } from "matrix-events-sdk";
|
||||
|
||||
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('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>",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,6 @@ import '../olm-loader';
|
||||
import { EventEmitter } from "events";
|
||||
|
||||
import { Crypto } from "../../src/crypto";
|
||||
import { WebStorageSessionStore } from "../../src/store/session/webstorage";
|
||||
import { MemoryCryptoStore } from "../../src/crypto/store/memory-crypto-store";
|
||||
import { MockStorageApi } from "../MockStorageApi";
|
||||
import { TestClient } from "../TestClient";
|
||||
@@ -14,9 +13,47 @@ import { sleep } from "../../src/utils";
|
||||
import { CRYPTO_ENABLED } from "../../src/client";
|
||||
import { DeviceInfo } from "../../src/crypto/deviceinfo";
|
||||
import { logger } from '../../src/logger';
|
||||
import { MemoryStore } from "../../src";
|
||||
|
||||
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() {
|
||||
if (!CRYPTO_ENABLED) {
|
||||
return;
|
||||
@@ -116,7 +153,7 @@ describe("Crypto", function() {
|
||||
|
||||
beforeEach(async function() {
|
||||
const mockStorage = new MockStorageApi();
|
||||
const sessionStore = new WebStorageSessionStore(mockStorage);
|
||||
const clientStore = new MemoryStore({ localStorage: mockStorage });
|
||||
const cryptoStore = new MemoryCryptoStore(mockStorage);
|
||||
|
||||
cryptoStore.storeEndToEndDeviceData({
|
||||
@@ -143,10 +180,9 @@ describe("Crypto", function() {
|
||||
|
||||
crypto = new Crypto(
|
||||
mockBaseApis,
|
||||
sessionStore,
|
||||
"@alice:home.server",
|
||||
"FLIBBLE",
|
||||
sessionStore,
|
||||
clientStore,
|
||||
cryptoStore,
|
||||
mockRoomList,
|
||||
);
|
||||
@@ -203,136 +239,141 @@ describe("Crypto", function() {
|
||||
bobClient.stopClient();
|
||||
});
|
||||
|
||||
it(
|
||||
"does not cancel keyshare requests if some messages are not decrypted",
|
||||
async function() {
|
||||
function awaitEvent(emitter, event) {
|
||||
return new Promise((resolve, reject) => {
|
||||
emitter.once(event, (result) => {
|
||||
resolve(result);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function keyshareEventForEvent(event, index) {
|
||||
const eventContent = event.getWireContent();
|
||||
const key = await aliceClient.crypto.olmDevice
|
||||
.getInboundGroupSessionKey(
|
||||
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,
|
||||
it("does not cancel keyshare requests if some messages are not decrypted", 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 events = [
|
||||
new MatrixEvent({
|
||||
type: "m.room.message",
|
||||
sender: "@alice:example.com",
|
||||
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();
|
||||
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
|
||||
}
|
||||
}));
|
||||
|
||||
// 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(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();
|
||||
},
|
||||
);
|
||||
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(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() {
|
||||
// make sure that cancelAndResend... creates a new keyshare request
|
||||
@@ -423,6 +464,7 @@ describe("Crypto", function() {
|
||||
await client.crypto.bootstrapSecretStorage({
|
||||
createSecretStorageKey,
|
||||
});
|
||||
client.stopClient();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -66,23 +66,23 @@ describe("CrossSigningInfo.getCrossSigningKey", function() {
|
||||
});
|
||||
|
||||
it.each(types)("should throw if the callback returns falsey",
|
||||
async ({ type, shouldCache }) => {
|
||||
const info = new CrossSigningInfo(userId, {
|
||||
getCrossSigningKey: () => false,
|
||||
async ({ type, shouldCache }) => {
|
||||
const info = new CrossSigningInfo(userId, {
|
||||
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 () => {
|
||||
const info = new CrossSigningInfo(userId, {
|
||||
getCrossSigningKey: () => masterKeyPub,
|
||||
getCrossSigningKey: async () => masterKeyPub as unknown as Uint8Array,
|
||||
});
|
||||
await expect(info.getCrossSigningKey("master", "")).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("should return a key from its callback", async () => {
|
||||
const info = new CrossSigningInfo(userId, {
|
||||
getCrossSigningKey: () => testKey,
|
||||
getCrossSigningKey: async () => testKey,
|
||||
});
|
||||
const [pubKey, pkSigning] = await info.getCrossSigningKey("master", 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)" +
|
||||
" and does not call app if one is found" +
|
||||
" %o",
|
||||
async ({ type, shouldCache }) => {
|
||||
async ({ type, shouldCache }) => {
|
||||
const getCrossSigningKey = jest.fn().mockImplementation(() => {
|
||||
if (shouldCache) {
|
||||
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)",
|
||||
async ({ type, shouldCache }) => {
|
||||
const getCrossSigningKey = jest.fn().mockResolvedValue(testKey);
|
||||
const storeCrossSigningKeyCache = jest.fn().mockResolvedValue(undefined);
|
||||
const info = new CrossSigningInfo(
|
||||
userId,
|
||||
{ getCrossSigningKey },
|
||||
{ storeCrossSigningKeyCache },
|
||||
);
|
||||
const [pubKey] = await info.getCrossSigningKey(type, masterKeyPub);
|
||||
expect(pubKey).toEqual(masterKeyPub);
|
||||
expect(storeCrossSigningKeyCache.mock.calls.length).toEqual(shouldCache ? 1 : 0);
|
||||
if (shouldCache) {
|
||||
expect(storeCrossSigningKeyCache.mock.calls[0][0]).toBe(type);
|
||||
expect(storeCrossSigningKeyCache.mock.calls[0][1]).toBe(testKey);
|
||||
}
|
||||
});
|
||||
async ({ type, shouldCache }) => {
|
||||
const getCrossSigningKey = jest.fn().mockResolvedValue(testKey);
|
||||
const storeCrossSigningKeyCache = jest.fn().mockResolvedValue(undefined);
|
||||
const info = new CrossSigningInfo(
|
||||
userId,
|
||||
{ getCrossSigningKey },
|
||||
{ storeCrossSigningKeyCache },
|
||||
);
|
||||
const [pubKey] = await info.getCrossSigningKey(type, masterKeyPub);
|
||||
expect(pubKey).toEqual(masterKeyPub);
|
||||
expect(storeCrossSigningKeyCache.mock.calls.length).toEqual(shouldCache ? 1 : 0);
|
||||
if (shouldCache) {
|
||||
expect(storeCrossSigningKeyCache.mock.calls[0][0]).toBe(type);
|
||||
expect(storeCrossSigningKeyCache.mock.calls[0][1]).toBe(testKey);
|
||||
}
|
||||
});
|
||||
|
||||
it.each(types)("does not store a bad key to the cache",
|
||||
async ({ type, shouldCache }) => {
|
||||
const getCrossSigningKey = jest.fn().mockResolvedValue(badKey);
|
||||
const storeCrossSigningKeyCache = jest.fn().mockResolvedValue(undefined);
|
||||
const info = new CrossSigningInfo(
|
||||
userId,
|
||||
{ getCrossSigningKey },
|
||||
{ storeCrossSigningKeyCache },
|
||||
);
|
||||
await expect(info.getCrossSigningKey(type, masterKeyPub)).rejects.toThrow();
|
||||
expect(storeCrossSigningKeyCache.mock.calls.length).toEqual(0);
|
||||
});
|
||||
async ({ type, shouldCache }) => {
|
||||
const getCrossSigningKey = jest.fn().mockResolvedValue(badKey);
|
||||
const storeCrossSigningKeyCache = jest.fn().mockResolvedValue(undefined);
|
||||
const info = new CrossSigningInfo(
|
||||
userId,
|
||||
{ getCrossSigningKey },
|
||||
{ storeCrossSigningKeyCache },
|
||||
);
|
||||
await expect(info.getCrossSigningKey(type, masterKeyPub)).rejects.toThrow();
|
||||
expect(storeCrossSigningKeyCache.mock.calls.length).toEqual(0);
|
||||
});
|
||||
|
||||
it.each(types)("does not store a value to the cache if it came from the cache",
|
||||
async ({ type, shouldCache }) => {
|
||||
const getCrossSigningKey = jest.fn().mockImplementation(() => {
|
||||
if (shouldCache) {
|
||||
return Promise.reject(new Error("Regular callback called"));
|
||||
} else {
|
||||
return Promise.resolve(testKey);
|
||||
}
|
||||
const getCrossSigningKey = jest.fn().mockImplementation(() => {
|
||||
if (shouldCache) {
|
||||
return Promise.reject(new Error("Regular callback called"));
|
||||
} else {
|
||||
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" +
|
||||
" if one is not found", async ({ type, shouldCache }) => {
|
||||
@@ -220,12 +220,14 @@ describe("CrossSigningInfo.getCrossSigningKey", function() {
|
||||
*/
|
||||
describe.each([
|
||||
["IndexedDBCryptoStore",
|
||||
() => new IndexedDBCryptoStore(global.indexedDB, "tests")],
|
||||
() => new IndexedDBCryptoStore(global.indexedDB, "tests")],
|
||||
["LocalStorageCryptoStore",
|
||||
() => new IndexedDBCryptoStore(undefined, "tests")],
|
||||
() => new IndexedDBCryptoStore(undefined, "tests")],
|
||||
["MemoryCryptoStore", () => {
|
||||
const store = new IndexedDBCryptoStore(undefined, "tests");
|
||||
// @ts-ignore set private properties
|
||||
store._backend = new MemoryCryptoStore();
|
||||
// @ts-ignore
|
||||
store._backendPromise = Promise.resolve(store._backend);
|
||||
return store;
|
||||
}],
|
||||
@@ -1,7 +1,7 @@
|
||||
/*
|
||||
Copyright 2017 Vector Creations 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");
|
||||
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 { MemoryCryptoStore } from "../../../src/crypto/store/memory-crypto-store";
|
||||
import { DeviceList } from "../../../src/crypto/DeviceList";
|
||||
import { IDownloadKeyResult, MatrixClient } from "../../../src";
|
||||
import { OlmDevice } from "../../../src/crypto/OlmDevice";
|
||||
|
||||
const signedDeviceList = {
|
||||
const signedDeviceList: IDownloadKeyResult = {
|
||||
"failures": {},
|
||||
"device_keys": {
|
||||
"@test1:sw1v.org": {
|
||||
@@ -45,13 +47,15 @@ const signedDeviceList = {
|
||||
"m.megolm.v1.aes-sha2",
|
||||
],
|
||||
"device_id": "HGKAWHRVJQ",
|
||||
"unsigned": {},
|
||||
"unsigned": {
|
||||
"device_display_name": "",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const signedDeviceList2 = {
|
||||
const signedDeviceList2: IDownloadKeyResult = {
|
||||
"failures": {},
|
||||
"device_keys": {
|
||||
"@test2:sw1v.org": {
|
||||
@@ -75,7 +79,9 @@ const signedDeviceList2 = {
|
||||
"m.megolm.v1.aes-sha2",
|
||||
],
|
||||
"device_id": "QJVRHWAKGH",
|
||||
"unsigned": {},
|
||||
"unsigned": {
|
||||
"device_display_name": "",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -104,10 +110,10 @@ describe('DeviceList', function() {
|
||||
downloadKeysForUsers: downloadSpy,
|
||||
getUserId: () => '@test1:sw1v.org',
|
||||
deviceId: 'HGKAWHRVJQ',
|
||||
};
|
||||
} as unknown as MatrixClient;
|
||||
const mockOlm = {
|
||||
verifySignature: function(key, message, signature) {},
|
||||
};
|
||||
} as unknown as OlmDevice;
|
||||
const dl = new DeviceList(baseApis, cryptoStore, mockOlm, keyDownloadChunkSize);
|
||||
deviceLists.push(dl);
|
||||
return dl;
|
||||
@@ -118,7 +124,7 @@ describe('DeviceList', function() {
|
||||
|
||||
dl.startTrackingDeviceList('@test1:sw1v.org');
|
||||
|
||||
const queryDefer1 = utils.defer();
|
||||
const queryDefer1 = utils.defer<IDownloadKeyResult>();
|
||||
downloadSpy.mockReturnValue(queryDefer1.promise);
|
||||
|
||||
const prom1 = dl.refreshOutdatedDeviceLists();
|
||||
@@ -128,6 +134,7 @@ describe('DeviceList', function() {
|
||||
return prom1.then(() => {
|
||||
const storedKeys = dl.getRawStoredDevicesForUser('@test1:sw1v.org');
|
||||
expect(Object.keys(storedKeys)).toEqual(['HGKAWHRVJQ']);
|
||||
dl.stop();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -137,7 +144,7 @@ describe('DeviceList', function() {
|
||||
|
||||
dl.startTrackingDeviceList('@test1:sw1v.org');
|
||||
|
||||
const queryDefer1 = utils.defer();
|
||||
const queryDefer1 = utils.defer<IDownloadKeyResult>();
|
||||
downloadSpy.mockReturnValue(queryDefer1.promise);
|
||||
|
||||
const prom1 = dl.refreshOutdatedDeviceLists();
|
||||
@@ -154,6 +161,7 @@ describe('DeviceList', function() {
|
||||
dl.saveIfDirty().then(() => {
|
||||
// the first request completes
|
||||
queryDefer1.resolve({
|
||||
failures: {},
|
||||
device_keys: {
|
||||
'@test1:sw1v.org': {},
|
||||
},
|
||||
@@ -165,11 +173,12 @@ describe('DeviceList', function() {
|
||||
logger.log("Creating new devicelist to simulate app reload");
|
||||
downloadSpy.mockReset();
|
||||
const dl2 = createTestDeviceList();
|
||||
const queryDefer3 = utils.defer();
|
||||
const queryDefer3 = utils.defer<IDownloadKeyResult>();
|
||||
downloadSpy.mockReturnValue(queryDefer3.promise);
|
||||
|
||||
const prom3 = dl2.refreshOutdatedDeviceLists();
|
||||
expect(downloadSpy).toHaveBeenCalledWith(['@test1:sw1v.org'], {});
|
||||
dl2.stop();
|
||||
|
||||
queryDefer3.resolve(utils.deepCopy(signedDeviceList));
|
||||
|
||||
@@ -178,6 +187,7 @@ describe('DeviceList', function() {
|
||||
}).then(() => {
|
||||
const storedKeys = dl.getRawStoredDevicesForUser('@test1:sw1v.org');
|
||||
expect(Object.keys(storedKeys)).toEqual(['HGKAWHRVJQ']);
|
||||
dl.stop();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -187,9 +197,9 @@ describe('DeviceList', function() {
|
||||
dl.startTrackingDeviceList('@test1:sw1v.org');
|
||||
dl.startTrackingDeviceList('@test2:sw1v.org');
|
||||
|
||||
const queryDefer1 = utils.defer();
|
||||
const queryDefer1 = utils.defer<IDownloadKeyResult>();
|
||||
downloadSpy.mockReturnValueOnce(queryDefer1.promise);
|
||||
const queryDefer2 = utils.defer();
|
||||
const queryDefer2 = utils.defer<IDownloadKeyResult>();
|
||||
downloadSpy.mockReturnValueOnce(queryDefer2.promise);
|
||||
|
||||
const prom1 = dl.refreshOutdatedDeviceLists();
|
||||
@@ -204,6 +214,7 @@ describe('DeviceList', function() {
|
||||
expect(Object.keys(storedKeys1)).toEqual(['HGKAWHRVJQ']);
|
||||
const storedKeys2 = dl.getRawStoredDevicesForUser('@test2:sw1v.org');
|
||||
expect(Object.keys(storedKeys2)).toEqual(['QJVRHWAKGH']);
|
||||
dl.stop();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -257,6 +257,8 @@ describe("MegolmDecryption", function() {
|
||||
});
|
||||
|
||||
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 aliceDeviceInfo;
|
||||
let mockRoom;
|
||||
@@ -318,7 +320,7 @@ describe("MegolmDecryption", function() {
|
||||
baseApis: mockBaseApis,
|
||||
roomId: ROOM_ID,
|
||||
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() {
|
||||
const ct1 = await megolmEncryption.encryptMessage(mockRoom, "a.fake.type", {
|
||||
body: "Some text",
|
||||
@@ -603,6 +630,8 @@ describe("MegolmDecryption", function() {
|
||||
});
|
||||
await aliceClient.crypto.encryptEvent(event, aliceRoom);
|
||||
await sendPromise;
|
||||
aliceClient.stopClient();
|
||||
bobClient.stopClient();
|
||||
});
|
||||
|
||||
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",
|
||||
},
|
||||
}))).rejects.toThrow("The sender has blocked you.");
|
||||
aliceClient.stopClient();
|
||||
bobClient.stopClient();
|
||||
});
|
||||
|
||||
it("throws an error describing the lack of an olm session", async function() {
|
||||
@@ -756,6 +787,8 @@ describe("MegolmDecryption", function() {
|
||||
},
|
||||
origin_server_ts: now,
|
||||
}))).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() {
|
||||
@@ -806,5 +839,7 @@ describe("MegolmDecryption", function() {
|
||||
},
|
||||
origin_server_ts: now,
|
||||
}))).rejects.toThrow("The secure channel with the sender was corrupted.");
|
||||
aliceClient.stopClient();
|
||||
bobClient.stopClient();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,7 +21,6 @@ import * as olmlib from "../../../src/crypto/olmlib";
|
||||
import { MatrixClient } from "../../../src/client";
|
||||
import { MatrixEvent } from "../../../src/models/event";
|
||||
import * as algorithms from "../../../src/crypto/algorithms";
|
||||
import { WebStorageSessionStore } from "../../../src/store/session/webstorage";
|
||||
import { MemoryCryptoStore } from "../../../src/crypto/store/memory-crypto-store";
|
||||
import { MockStorageApi } from "../../MockStorageApi";
|
||||
import * as testUtils from "../../test-utils/test-utils";
|
||||
@@ -118,7 +117,7 @@ function saveCrossSigningKeys(k) {
|
||||
Object.assign(keys, k);
|
||||
}
|
||||
|
||||
function makeTestClient(sessionStore, cryptoStore) {
|
||||
function makeTestClient(cryptoStore) {
|
||||
const scheduler = [
|
||||
"getQueueForEvent", "queueEvent", "removeEventFromQueue",
|
||||
"setProcessFunction",
|
||||
@@ -141,7 +140,6 @@ function makeTestClient(sessionStore, cryptoStore) {
|
||||
scheduler: scheduler,
|
||||
userId: "@alice:bar",
|
||||
deviceId: "device",
|
||||
sessionStore: sessionStore,
|
||||
cryptoStore: cryptoStore,
|
||||
cryptoCallbacks: { getCrossSigningKey, saveCrossSigningKeys },
|
||||
});
|
||||
@@ -161,7 +159,6 @@ describe("MegolmBackup", function() {
|
||||
let mockOlmLib;
|
||||
let mockCrypto;
|
||||
let mockStorage;
|
||||
let sessionStore;
|
||||
let cryptoStore;
|
||||
let megolmDecryption;
|
||||
beforeEach(async function() {
|
||||
@@ -174,7 +171,6 @@ describe("MegolmBackup", function() {
|
||||
mockCrypto.backupInfo = CURVE25519_BACKUP_INFO;
|
||||
|
||||
mockStorage = new MockStorageApi();
|
||||
sessionStore = new WebStorageSessionStore(mockStorage);
|
||||
cryptoStore = new MemoryCryptoStore(mockStorage);
|
||||
|
||||
olmDevice = new OlmDevice(cryptoStore);
|
||||
@@ -261,7 +257,7 @@ describe("MegolmBackup", function() {
|
||||
const ibGroupSession = new Olm.InboundGroupSession();
|
||||
ibGroupSession.create(groupSession.session_key());
|
||||
|
||||
const client = makeTestClient(sessionStore, cryptoStore);
|
||||
const client = makeTestClient(cryptoStore);
|
||||
|
||||
megolmDecryption = new MegolmDecryption({
|
||||
userId: '@user:id',
|
||||
@@ -329,6 +325,7 @@ describe("MegolmBackup", function() {
|
||||
);
|
||||
}).then(() => {
|
||||
expect(numCalls).toBe(1);
|
||||
client.stopClient();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -339,7 +336,7 @@ describe("MegolmBackup", function() {
|
||||
const ibGroupSession = new Olm.InboundGroupSession();
|
||||
ibGroupSession.create(groupSession.session_key());
|
||||
|
||||
const client = makeTestClient(sessionStore, cryptoStore);
|
||||
const client = makeTestClient(cryptoStore);
|
||||
|
||||
megolmDecryption = new MegolmDecryption({
|
||||
userId: '@user:id',
|
||||
@@ -411,6 +408,7 @@ describe("MegolmBackup", function() {
|
||||
);
|
||||
}).then(() => {
|
||||
expect(numCalls).toBe(1);
|
||||
client.stopClient();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -421,7 +419,7 @@ describe("MegolmBackup", function() {
|
||||
const ibGroupSession = new Olm.InboundGroupSession();
|
||||
ibGroupSession.create(groupSession.session_key());
|
||||
|
||||
const client = makeTestClient(sessionStore, cryptoStore);
|
||||
const client = makeTestClient(cryptoStore);
|
||||
|
||||
megolmDecryption = new MegolmDecryption({
|
||||
userId: '@user:id',
|
||||
@@ -487,6 +485,7 @@ describe("MegolmBackup", function() {
|
||||
}),
|
||||
]);
|
||||
expect(numCalls).toBe(2);
|
||||
client.stopClient();
|
||||
});
|
||||
|
||||
it('retries when a backup fails', function() {
|
||||
@@ -517,7 +516,6 @@ describe("MegolmBackup", function() {
|
||||
scheduler: scheduler,
|
||||
userId: "@alice:bar",
|
||||
deviceId: "device",
|
||||
sessionStore: sessionStore,
|
||||
cryptoStore: cryptoStore,
|
||||
});
|
||||
|
||||
@@ -593,6 +591,7 @@ describe("MegolmBackup", function() {
|
||||
);
|
||||
}).then(() => {
|
||||
expect(numCalls).toBe(2);
|
||||
client.stopClient();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -602,7 +601,7 @@ describe("MegolmBackup", function() {
|
||||
let client;
|
||||
|
||||
beforeEach(function() {
|
||||
client = makeTestClient(sessionStore, cryptoStore);
|
||||
client = makeTestClient(cryptoStore);
|
||||
|
||||
megolmDecryption = new MegolmDecryption({
|
||||
userId: '@user:id',
|
||||
|
||||
@@ -17,12 +17,16 @@ limitations under the License.
|
||||
|
||||
import '../../olm-loader';
|
||||
import anotherjson from 'another-json';
|
||||
import { PkSigning } from '@matrix-org/olm';
|
||||
|
||||
import * as olmlib from "../../../src/crypto/olmlib";
|
||||
import { TestClient } from '../../TestClient';
|
||||
import { resetCrossSigningKeys } from "./crypto-utils";
|
||||
import { MatrixError } from '../../../src/http-api';
|
||||
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 = {
|
||||
method: "GET",
|
||||
@@ -47,9 +51,11 @@ function setHttpResponses(httpBackend, responses) {
|
||||
});
|
||||
}
|
||||
|
||||
async function makeTestClient(userInfo, options, keys) {
|
||||
if (!keys) keys = {};
|
||||
|
||||
async function makeTestClient(
|
||||
userInfo: { userId: string, deviceId: string},
|
||||
options: Partial<ICreateClientOpts> = {},
|
||||
keys = {},
|
||||
) {
|
||||
function getCrossSigningKey(type) {
|
||||
return keys[type];
|
||||
}
|
||||
@@ -58,7 +64,6 @@ async function makeTestClient(userInfo, options, keys) {
|
||||
Object.assign(keys, k);
|
||||
}
|
||||
|
||||
if (!options) options = {};
|
||||
options.cryptoCallbacks = Object.assign(
|
||||
{}, { getCrossSigningKey, saveCrossSigningKeys }, options.cryptoCallbacks || {},
|
||||
);
|
||||
@@ -86,20 +91,21 @@ describe("Cross Signing", function() {
|
||||
const { client: alice } = await makeTestClient(
|
||||
{ userId: "@alice:example.com", deviceId: "Osborne2" },
|
||||
);
|
||||
alice.uploadDeviceSigningKeys = jest.fn(async (auth, keys) => {
|
||||
alice.uploadDeviceSigningKeys = jest.fn().mockImplementation(async (auth, keys) => {
|
||||
await olmlib.verifySignature(
|
||||
alice.crypto.olmDevice, keys.master_key, "@alice:example.com",
|
||||
"Osborne2", alice.crypto.olmDevice.deviceEd25519Key,
|
||||
);
|
||||
});
|
||||
alice.uploadKeySignatures = async () => {};
|
||||
alice.setAccountData = async () => {};
|
||||
alice.getAccountDataFromServer = async () => {};
|
||||
alice.uploadKeySignatures = async () => ({ failures: {} });
|
||||
alice.setAccountData = async () => ({});
|
||||
alice.getAccountDataFromServer = async <T>() => ({} as T);
|
||||
// set Alice's cross-signing key
|
||||
await alice.bootstrapCrossSigning({
|
||||
authUploadDeviceSigningKeys: async func => await func({}),
|
||||
authUploadDeviceSigningKeys: async func => { await func({}); },
|
||||
});
|
||||
expect(alice.uploadDeviceSigningKeys).toHaveBeenCalled();
|
||||
alice.stopClient();
|
||||
});
|
||||
|
||||
it("should abort bootstrap if device signing auth fails", async function() {
|
||||
@@ -133,9 +139,9 @@ describe("Cross Signing", function() {
|
||||
error.httpStatus == 401;
|
||||
throw error;
|
||||
};
|
||||
alice.uploadKeySignatures = async () => {};
|
||||
alice.setAccountData = async () => {};
|
||||
alice.getAccountDataFromServer = async () => { };
|
||||
alice.uploadKeySignatures = async () => ({ failures: {} });
|
||||
alice.setAccountData = async () => ({});
|
||||
alice.getAccountDataFromServer = async <T extends {[k: string]: any}>(): Promise<T> => ({} as T);
|
||||
const authUploadDeviceSigningKeys = async func => await func({});
|
||||
|
||||
// Try bootstrap, expecting `authUploadDeviceSigningKeys` to pass
|
||||
@@ -151,14 +157,15 @@ describe("Cross Signing", function() {
|
||||
}
|
||||
}
|
||||
expect(bootstrapDidThrow).toBeTruthy();
|
||||
alice.stopClient();
|
||||
});
|
||||
|
||||
it("should upload a signature when a user is verified", async function() {
|
||||
const { client: alice } = await makeTestClient(
|
||||
{ userId: "@alice:example.com", deviceId: "Osborne2" },
|
||||
);
|
||||
alice.uploadDeviceSigningKeys = async () => {};
|
||||
alice.uploadKeySignatures = async () => {};
|
||||
alice.uploadDeviceSigningKeys = async () => ({});
|
||||
alice.uploadKeySignatures = async () => ({ failures: {} });
|
||||
// set Alice's cross-signing key
|
||||
await resetCrossSigningKeys(alice);
|
||||
// Alice downloads Bob's device key
|
||||
@@ -172,16 +179,20 @@ describe("Cross Signing", function() {
|
||||
},
|
||||
},
|
||||
},
|
||||
firstUse: false,
|
||||
crossSigningVerifiedBefore: false,
|
||||
});
|
||||
// Alice verifies Bob's key
|
||||
const promise = new Promise((resolve, reject) => {
|
||||
alice.uploadKeySignatures = (...args) => {
|
||||
alice.uploadKeySignatures = async (...args) => {
|
||||
resolve(...args);
|
||||
return { failures: {} };
|
||||
};
|
||||
});
|
||||
await alice.setDeviceVerified("@bob:example.com", "bobs+master+pubkey", true);
|
||||
// Alice should send a signature of Bob's key to the server
|
||||
await promise;
|
||||
alice.stopClient();
|
||||
});
|
||||
|
||||
it.skip("should get cross-signing keys from sync", async function() {
|
||||
@@ -203,7 +214,7 @@ describe("Cross Signing", function() {
|
||||
{
|
||||
cryptoCallbacks: {
|
||||
// will be called to sign our own device
|
||||
getCrossSigningKey: type => {
|
||||
getCrossSigningKey: async type => {
|
||||
if (type === 'master') {
|
||||
return masterKey;
|
||||
} else {
|
||||
@@ -215,7 +226,7 @@ describe("Cross Signing", function() {
|
||||
);
|
||||
|
||||
const keyChangePromise = new Promise((resolve, reject) => {
|
||||
alice.once("crossSigning.keysChanged", async (e) => {
|
||||
alice.once(CryptoEvent.KeysChanged, async (e) => {
|
||||
resolve(e);
|
||||
await alice.checkOwnCrossSigningTrust({
|
||||
allowPrivateKeyRequests: true,
|
||||
@@ -223,14 +234,14 @@ describe("Cross Signing", function() {
|
||||
});
|
||||
});
|
||||
|
||||
const uploadSigsPromise = new Promise((resolve, reject) => {
|
||||
alice.uploadKeySignatures = jest.fn(async (content) => {
|
||||
const uploadSigsPromise = new Promise<void>((resolve, reject) => {
|
||||
alice.uploadKeySignatures = jest.fn().mockImplementation(async (content) => {
|
||||
try {
|
||||
await olmlib.verifySignature(
|
||||
alice.crypto.olmDevice,
|
||||
content["@alice:example.com"][
|
||||
"nqOvzeuGWT/sRx3h7+MHoInYj3Uk2LD/unI9kDYcHwk"
|
||||
],
|
||||
],
|
||||
"@alice:example.com",
|
||||
"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"]
|
||||
.Osborne2;
|
||||
const aliceDevice = {
|
||||
user_id: "@alice:example.com",
|
||||
device_id: "Osborne2",
|
||||
keys: deviceInfo.keys,
|
||||
algorithms: deviceInfo.algorithms,
|
||||
};
|
||||
aliceDevice.keys = deviceInfo.keys;
|
||||
aliceDevice.algorithms = deviceInfo.algorithms;
|
||||
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
|
||||
const responses = [
|
||||
@@ -353,14 +370,15 @@ describe("Cross Signing", function() {
|
||||
expect(aliceDeviceTrust.isLocallyVerified()).toBeTruthy();
|
||||
expect(aliceDeviceTrust.isTofu()).toBeTruthy();
|
||||
expect(aliceDeviceTrust.isVerified()).toBeTruthy();
|
||||
alice.stopClient();
|
||||
});
|
||||
|
||||
it("should use trust chain to determine device verification", async function() {
|
||||
const { client: alice } = await makeTestClient(
|
||||
{ userId: "@alice:example.com", deviceId: "Osborne2" },
|
||||
);
|
||||
alice.uploadDeviceSigningKeys = async () => {};
|
||||
alice.uploadKeySignatures = async () => {};
|
||||
alice.uploadDeviceSigningKeys = async () => ({});
|
||||
alice.uploadKeySignatures = async () => ({ failures: {} });
|
||||
// set Alice's cross-signing key
|
||||
await resetCrossSigningKeys(alice);
|
||||
// Alice downloads Bob's ssk and device key
|
||||
@@ -370,7 +388,7 @@ describe("Cross Signing", function() {
|
||||
const bobSigning = new global.Olm.PkSigning();
|
||||
const bobPrivkey = bobSigning.generate_seed();
|
||||
const bobPubkey = bobSigning.init_with_seed(bobPrivkey);
|
||||
const bobSSK = {
|
||||
const bobSSK: ICrossSigningKey = {
|
||||
user_id: "@bob:example.com",
|
||||
usage: ["self_signing"],
|
||||
keys: {
|
||||
@@ -394,10 +412,10 @@ describe("Cross Signing", function() {
|
||||
},
|
||||
self_signing: bobSSK,
|
||||
},
|
||||
firstUse: 1,
|
||||
unsigned: {},
|
||||
firstUse: true,
|
||||
crossSigningVerifiedBefore: false,
|
||||
});
|
||||
const bobDevice = {
|
||||
const bobDeviceUnsigned = {
|
||||
user_id: "@bob:example.com",
|
||||
device_id: "Dynabook",
|
||||
algorithms: ["m.olm.curve25519-aes-sha256", "m.megolm.v1.aes-sha"],
|
||||
@@ -406,11 +424,16 @@ describe("Cross Signing", function() {
|
||||
"ed25519:Dynabook": "someOtherPubkey",
|
||||
},
|
||||
};
|
||||
const sig = bobSigning.sign(anotherjson.stringify(bobDevice));
|
||||
bobDevice.signatures = {
|
||||
"@bob:example.com": {
|
||||
["ed25519:" + bobPubkey]: sig,
|
||||
const sig = bobSigning.sign(anotherjson.stringify(bobDeviceUnsigned));
|
||||
const bobDevice: IDevice = {
|
||||
...bobDeviceUnsigned,
|
||||
signatures: {
|
||||
"@bob:example.com": {
|
||||
["ed25519:" + bobPubkey]: sig,
|
||||
},
|
||||
},
|
||||
verified: 0,
|
||||
known: false,
|
||||
};
|
||||
alice.crypto.deviceList.storeDevicesForUser("@bob:example.com", {
|
||||
Dynabook: bobDevice,
|
||||
@@ -425,7 +448,7 @@ describe("Cross Signing", function() {
|
||||
expect(bobDeviceTrust.isTofu()).toBeTruthy();
|
||||
|
||||
// Alice verifies Bob's SSK
|
||||
alice.uploadKeySignatures = () => {};
|
||||
alice.uploadKeySignatures = async () => ({ failures: {} });
|
||||
await alice.setDeviceVerified("@bob:example.com", bobMasterPubkey, true);
|
||||
|
||||
// Bob's device key should be trusted
|
||||
@@ -437,10 +460,11 @@ describe("Cross Signing", function() {
|
||||
expect(bobDeviceTrust2.isCrossSigningVerified()).toBeTruthy();
|
||||
expect(bobDeviceTrust2.isLocallyVerified()).toBeFalsy();
|
||||
expect(bobDeviceTrust2.isTofu()).toBeTruthy();
|
||||
alice.stopClient();
|
||||
});
|
||||
|
||||
it.skip("should trust signatures received from other devices", async function() {
|
||||
const aliceKeys = {};
|
||||
const aliceKeys: Record<string, PkSigning> = {};
|
||||
const { client: alice, httpBackend } = await makeTestClient(
|
||||
{ userId: "@alice:example.com", deviceId: "Osborne2" },
|
||||
null,
|
||||
@@ -448,8 +472,8 @@ describe("Cross Signing", function() {
|
||||
);
|
||||
alice.crypto.deviceList.startTrackingDeviceList("@bob:example.com");
|
||||
alice.crypto.deviceList.stopTrackingAllDeviceLists = () => {};
|
||||
alice.uploadDeviceSigningKeys = async () => {};
|
||||
alice.uploadKeySignatures = async () => {};
|
||||
alice.uploadDeviceSigningKeys = async () => ({});
|
||||
alice.uploadKeySignatures = async () => ({ failures: {} });
|
||||
|
||||
// set Alice's cross-signing key
|
||||
await resetCrossSigningKeys(alice);
|
||||
@@ -461,28 +485,29 @@ describe("Cross Signing", function() {
|
||||
0x34, 0xf2, 0x4b, 0x64, 0x9b, 0x52, 0xf8, 0x5f,
|
||||
]);
|
||||
|
||||
const keyChangePromise = new Promise((resolve, reject) => {
|
||||
alice.crypto.deviceList.once("userCrossSigningUpdated", (userId) => {
|
||||
const keyChangePromise = new Promise<void>((resolve, reject) => {
|
||||
alice.crypto.deviceList.once(CryptoEvent.UserCrossSigningUpdated, (userId) => {
|
||||
if (userId === "@bob:example.com") {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// @ts-ignore private property
|
||||
const deviceInfo = alice.crypto.deviceList.devices["@alice:example.com"]
|
||||
.Osborne2;
|
||||
const aliceDevice = {
|
||||
user_id: "@alice:example.com",
|
||||
device_id: "Osborne2",
|
||||
keys: deviceInfo.keys,
|
||||
algorithms: deviceInfo.algorithms,
|
||||
};
|
||||
aliceDevice.keys = deviceInfo.keys;
|
||||
aliceDevice.algorithms = deviceInfo.algorithms;
|
||||
await alice.crypto.signObject(aliceDevice);
|
||||
|
||||
const bobOlmAccount = new global.Olm.Account();
|
||||
bobOlmAccount.create();
|
||||
const bobKeys = JSON.parse(bobOlmAccount.identity_keys());
|
||||
const bobDevice = {
|
||||
const bobDeviceUnsigned = {
|
||||
user_id: "@bob:example.com",
|
||||
device_id: "Dynabook",
|
||||
algorithms: [olmlib.OLM_ALGORITHM, olmlib.MEGOLM_ALGORITHM],
|
||||
@@ -491,15 +516,25 @@ describe("Cross Signing", function() {
|
||||
"curve25519:Dynabook": bobKeys.curve25519,
|
||||
},
|
||||
};
|
||||
const deviceStr = anotherjson.stringify(bobDevice);
|
||||
bobDevice.signatures = {
|
||||
"@bob:example.com": {
|
||||
"ed25519:Dynabook": bobOlmAccount.sign(deviceStr),
|
||||
const deviceStr = anotherjson.stringify(bobDeviceUnsigned);
|
||||
const bobDevice: IDevice = {
|
||||
...bobDeviceUnsigned,
|
||||
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",
|
||||
usage: ["master"],
|
||||
keys: {
|
||||
@@ -507,7 +542,7 @@ describe("Cross Signing", function() {
|
||||
"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
|
||||
// - device key
|
||||
@@ -600,14 +635,15 @@ describe("Cross Signing", function() {
|
||||
expect(bobDeviceTrust.isCrossSigningVerified()).toBeTruthy();
|
||||
expect(bobDeviceTrust.isLocallyVerified()).toBeFalsy();
|
||||
expect(bobDeviceTrust.isTofu()).toBeTruthy();
|
||||
alice.stopClient();
|
||||
});
|
||||
|
||||
it("should dis-trust an unsigned device", async function() {
|
||||
const { client: alice } = await makeTestClient(
|
||||
{ userId: "@alice:example.com", deviceId: "Osborne2" },
|
||||
);
|
||||
alice.uploadDeviceSigningKeys = async () => {};
|
||||
alice.uploadKeySignatures = async () => {};
|
||||
alice.uploadDeviceSigningKeys = async () => ({});
|
||||
alice.uploadKeySignatures = async () => ({ failures: {} });
|
||||
// set Alice's cross-signing key
|
||||
await resetCrossSigningKeys(alice);
|
||||
// Alice downloads Bob's ssk and device key
|
||||
@@ -618,7 +654,7 @@ describe("Cross Signing", function() {
|
||||
const bobSigning = new global.Olm.PkSigning();
|
||||
const bobPrivkey = bobSigning.generate_seed();
|
||||
const bobPubkey = bobSigning.init_with_seed(bobPrivkey);
|
||||
const bobSSK = {
|
||||
const bobSSK: ICrossSigningKey = {
|
||||
user_id: "@bob:example.com",
|
||||
usage: ["self_signing"],
|
||||
keys: {
|
||||
@@ -642,8 +678,8 @@ describe("Cross Signing", function() {
|
||||
},
|
||||
self_signing: bobSSK,
|
||||
},
|
||||
firstUse: 1,
|
||||
unsigned: {},
|
||||
firstUse: true,
|
||||
crossSigningVerifiedBefore: false,
|
||||
});
|
||||
const bobDevice = {
|
||||
user_id: "@bob:example.com",
|
||||
@@ -655,7 +691,7 @@ describe("Cross Signing", function() {
|
||||
},
|
||||
};
|
||||
alice.crypto.deviceList.storeDevicesForUser("@bob:example.com", {
|
||||
Dynabook: bobDevice,
|
||||
Dynabook: bobDevice as unknown as IDevice,
|
||||
});
|
||||
// Bob's device key should be untrusted
|
||||
const bobDeviceTrust = alice.checkDeviceTrust("@bob:example.com", "Dynabook");
|
||||
@@ -669,14 +705,15 @@ describe("Cross Signing", function() {
|
||||
const bobDeviceTrust2 = alice.checkDeviceTrust("@bob:example.com", "Dynabook");
|
||||
expect(bobDeviceTrust2.isVerified()).toBeFalsy();
|
||||
expect(bobDeviceTrust2.isTofu()).toBeFalsy();
|
||||
alice.stopClient();
|
||||
});
|
||||
|
||||
it("should dis-trust a user when their ssk changes", async function() {
|
||||
const { client: alice } = await makeTestClient(
|
||||
{ userId: "@alice:example.com", deviceId: "Osborne2" },
|
||||
);
|
||||
alice.uploadDeviceSigningKeys = async () => {};
|
||||
alice.uploadKeySignatures = async () => {};
|
||||
alice.uploadDeviceSigningKeys = async () => ({});
|
||||
alice.uploadKeySignatures = async () => ({ failures: {} });
|
||||
await resetCrossSigningKeys(alice);
|
||||
// Alice downloads Bob's keys
|
||||
const bobMasterSigning = new global.Olm.PkSigning();
|
||||
@@ -685,7 +722,7 @@ describe("Cross Signing", function() {
|
||||
const bobSigning = new global.Olm.PkSigning();
|
||||
const bobPrivkey = bobSigning.generate_seed();
|
||||
const bobPubkey = bobSigning.init_with_seed(bobPrivkey);
|
||||
const bobSSK = {
|
||||
const bobSSK: ICrossSigningKey = {
|
||||
user_id: "@bob:example.com",
|
||||
usage: ["self_signing"],
|
||||
keys: {
|
||||
@@ -709,10 +746,10 @@ describe("Cross Signing", function() {
|
||||
},
|
||||
self_signing: bobSSK,
|
||||
},
|
||||
firstUse: 1,
|
||||
unsigned: {},
|
||||
firstUse: true,
|
||||
crossSigningVerifiedBefore: false,
|
||||
});
|
||||
const bobDevice = {
|
||||
const bobDeviceUnsigned = {
|
||||
user_id: "@bob:example.com",
|
||||
device_id: "Dynabook",
|
||||
algorithms: ["m.olm.curve25519-aes-sha256", "m.megolm.v1.aes-sha"],
|
||||
@@ -721,16 +758,23 @@ describe("Cross Signing", function() {
|
||||
"ed25519:Dynabook": "someOtherPubkey",
|
||||
},
|
||||
};
|
||||
const bobDeviceString = anotherjson.stringify(bobDevice);
|
||||
const bobDeviceString = anotherjson.stringify(bobDeviceUnsigned);
|
||||
const sig = bobSigning.sign(bobDeviceString);
|
||||
bobDevice.signatures = {};
|
||||
bobDevice.signatures["@bob:example.com"] = {};
|
||||
bobDevice.signatures["@bob:example.com"]["ed25519:" + bobPubkey] = sig;
|
||||
const bobDevice: IDevice = {
|
||||
...bobDeviceUnsigned,
|
||||
verified: 0,
|
||||
known: false,
|
||||
signatures: {
|
||||
"@bob:example.com": {
|
||||
["ed25519:" + bobPubkey]: sig,
|
||||
},
|
||||
},
|
||||
};
|
||||
alice.crypto.deviceList.storeDevicesForUser("@bob:example.com", {
|
||||
Dynabook: bobDevice,
|
||||
});
|
||||
// Alice verifies Bob's SSK
|
||||
alice.uploadKeySignatures = () => {};
|
||||
alice.uploadKeySignatures = async () => ({ failures: {} });
|
||||
await alice.setDeviceVerified("@bob:example.com", bobMasterPubkey, true);
|
||||
|
||||
// Bob's device key should be trusted
|
||||
@@ -745,7 +789,7 @@ describe("Cross Signing", function() {
|
||||
const bobSigning2 = new global.Olm.PkSigning();
|
||||
const bobPrivkey2 = bobSigning2.generate_seed();
|
||||
const bobPubkey2 = bobSigning2.init_with_seed(bobPrivkey2);
|
||||
const bobSSK2 = {
|
||||
const bobSSK2: ICrossSigningKey = {
|
||||
user_id: "@bob:example.com",
|
||||
usage: ["self_signing"],
|
||||
keys: {
|
||||
@@ -769,8 +813,8 @@ describe("Cross Signing", function() {
|
||||
},
|
||||
self_signing: bobSSK2,
|
||||
},
|
||||
firstUse: 0,
|
||||
unsigned: {},
|
||||
firstUse: false,
|
||||
crossSigningVerifiedBefore: false,
|
||||
});
|
||||
// Bob's and his device should be untrusted
|
||||
const bobTrust = alice.checkUserTrust("@bob:example.com");
|
||||
@@ -782,7 +826,7 @@ describe("Cross Signing", function() {
|
||||
expect(bobDeviceTrust2.isTofu()).toBeFalsy();
|
||||
|
||||
// Alice verifies Bob's SSK
|
||||
alice.uploadKeySignatures = () => {};
|
||||
alice.uploadKeySignatures = async () => ({ failures: {} });
|
||||
await alice.setDeviceVerified("@bob:example.com", bobMasterPubkey2, true);
|
||||
|
||||
// Bob should be trusted but not his device
|
||||
@@ -805,6 +849,7 @@ describe("Cross Signing", function() {
|
||||
|
||||
const bobDeviceTrust4 = alice.checkDeviceTrust("@bob:example.com", "Dynabook");
|
||||
expect(bobDeviceTrust4.isCrossSigningVerified()).toBeTruthy();
|
||||
alice.stopClient();
|
||||
});
|
||||
|
||||
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" },
|
||||
{
|
||||
cryptoCallbacks: {
|
||||
shouldUpgradeDeviceVerifications: (verifs) => {
|
||||
shouldUpgradeDeviceVerifications: async (verifs) => {
|
||||
expect(verifs.users["@bob:example.com"]).toBeDefined();
|
||||
upgradeResolveFunc();
|
||||
return ["@bob:example.com"];
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
);
|
||||
const { client: bob } = await makeTestClient(
|
||||
{ userId: "@bob:example.com", deviceId: "Dynabook" },
|
||||
);
|
||||
|
||||
bob.uploadDeviceSigningKeys = async () => {};
|
||||
bob.uploadKeySignatures = async () => {};
|
||||
bob.uploadDeviceSigningKeys = async () => ({});
|
||||
bob.uploadKeySignatures = async () => ({ failures: {} });
|
||||
// set Bob's cross-signing key
|
||||
await resetCrossSigningKeys(bob);
|
||||
alice.crypto.deviceList.storeDevicesForUser("@bob:example.com", {
|
||||
@@ -846,8 +892,8 @@ describe("Cross Signing", function() {
|
||||
bob.crypto.crossSigningInfo.toStorage(),
|
||||
);
|
||||
|
||||
alice.uploadDeviceSigningKeys = async () => {};
|
||||
alice.uploadKeySignatures = async () => {};
|
||||
alice.uploadDeviceSigningKeys = async () => ({});
|
||||
alice.uploadKeySignatures = async () => ({ failures: {} });
|
||||
// when alice sets up cross-signing, she should notice that bob's
|
||||
// cross-signing key is signed by his Dynabook, which alice has
|
||||
// verified, and ask if the device verification should be upgraded to a
|
||||
@@ -873,15 +919,17 @@ describe("Cross Signing", function() {
|
||||
upgradePromise = new Promise((resolve) => {
|
||||
upgradeResolveFunc = resolve;
|
||||
});
|
||||
alice.crypto.deviceList.emit("userCrossSigningUpdated", "@bob:example.com");
|
||||
alice.crypto.deviceList.emit(CryptoEvent.UserCrossSigningUpdated, "@bob:example.com");
|
||||
await new Promise((resolve) => {
|
||||
alice.crypto.on("userTrustStatusChanged", resolve);
|
||||
alice.crypto.on(CryptoEvent.UserTrustStatusChanged, resolve);
|
||||
});
|
||||
await upgradePromise;
|
||||
|
||||
const bobTrust3 = alice.checkUserTrust("@bob:example.com");
|
||||
expect(bobTrust3.isCrossSigningVerified()).toBeTruthy();
|
||||
expect(bobTrust3.isTofu()).toBeTruthy();
|
||||
alice.stopClient();
|
||||
bob.stopClient();
|
||||
});
|
||||
|
||||
it(
|
||||
@@ -890,8 +938,8 @@ describe("Cross Signing", function() {
|
||||
const { client: alice } = await makeTestClient(
|
||||
{ userId: "@alice:example.com", deviceId: "Osborne2" },
|
||||
);
|
||||
alice.uploadDeviceSigningKeys = async () => {};
|
||||
alice.uploadKeySignatures = async () => {};
|
||||
alice.uploadDeviceSigningKeys = async () => ({});
|
||||
alice.uploadKeySignatures = async () => ({ failures: {} });
|
||||
|
||||
// Generate Alice's SSK etc
|
||||
const aliceMasterSigning = new global.Olm.PkSigning();
|
||||
@@ -900,7 +948,7 @@ describe("Cross Signing", function() {
|
||||
const aliceSigning = new global.Olm.PkSigning();
|
||||
const alicePrivkey = aliceSigning.generate_seed();
|
||||
const alicePubkey = aliceSigning.init_with_seed(alicePrivkey);
|
||||
const aliceSSK = {
|
||||
const aliceSSK: ICrossSigningKey = {
|
||||
user_id: "@alice:example.com",
|
||||
usage: ["self_signing"],
|
||||
keys: {
|
||||
@@ -926,34 +974,42 @@ describe("Cross Signing", function() {
|
||||
},
|
||||
self_signing: aliceSSK,
|
||||
},
|
||||
firstUse: 1,
|
||||
unsigned: {},
|
||||
firstUse: true,
|
||||
crossSigningVerifiedBefore: false,
|
||||
});
|
||||
|
||||
// Alice has a second device that's cross-signed
|
||||
const aliceCrossSignedDevice = {
|
||||
const aliceDeviceId = 'Dynabook';
|
||||
const aliceUnsignedDevice = {
|
||||
user_id: "@alice:example.com",
|
||||
device_id: "Dynabook",
|
||||
device_id: aliceDeviceId,
|
||||
algorithms: ["m.olm.curve25519-aes-sha256", "m.megolm.v1.aes-sha"],
|
||||
keys: {
|
||||
"curve25519:Dynabook": "somePubkey",
|
||||
"ed25519:Dynabook": "someOtherPubkey",
|
||||
},
|
||||
};
|
||||
const sig = aliceSigning.sign(anotherjson.stringify(aliceCrossSignedDevice));
|
||||
aliceCrossSignedDevice.signatures = {
|
||||
"@alice:example.com": {
|
||||
["ed25519:" + alicePubkey]: sig,
|
||||
},
|
||||
};
|
||||
const sig = aliceSigning.sign(anotherjson.stringify(aliceUnsignedDevice));
|
||||
const aliceCrossSignedDevice: IDevice = {
|
||||
...aliceUnsignedDevice,
|
||||
verified: 0,
|
||||
known: false,
|
||||
signatures: {
|
||||
"@alice:example.com": {
|
||||
["ed25519:" + alicePubkey]: sig,
|
||||
},
|
||||
} };
|
||||
alice.crypto.deviceList.storeDevicesForUser("@alice:example.com", {
|
||||
Dynabook: aliceCrossSignedDevice,
|
||||
[aliceDeviceId]: aliceCrossSignedDevice,
|
||||
});
|
||||
|
||||
// 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
|
||||
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(
|
||||
{ userId: "@alice:example.com", deviceId: "Osborne2" },
|
||||
);
|
||||
alice.uploadDeviceSigningKeys = async () => {};
|
||||
alice.uploadKeySignatures = async () => {};
|
||||
alice.uploadDeviceSigningKeys = async () => ({});
|
||||
alice.uploadKeySignatures = async () => ({ failures: {} });
|
||||
|
||||
// Generate Alice's SSK etc
|
||||
const aliceMasterSigning = new global.Olm.PkSigning();
|
||||
@@ -971,7 +1027,7 @@ describe("Cross Signing", function() {
|
||||
const aliceSigning = new global.Olm.PkSigning();
|
||||
const alicePrivkey = aliceSigning.generate_seed();
|
||||
const alicePubkey = aliceSigning.init_with_seed(alicePrivkey);
|
||||
const aliceSSK = {
|
||||
const aliceSSK: ICrossSigningKey = {
|
||||
user_id: "@alice:example.com",
|
||||
usage: ["self_signing"],
|
||||
keys: {
|
||||
@@ -997,14 +1053,14 @@ describe("Cross Signing", function() {
|
||||
},
|
||||
self_signing: aliceSSK,
|
||||
},
|
||||
firstUse: 1,
|
||||
unsigned: {},
|
||||
firstUse: true,
|
||||
crossSigningVerifiedBefore: false,
|
||||
});
|
||||
|
||||
// Alice has a second device that's also not cross-signed
|
||||
const aliceNotCrossSignedDevice = {
|
||||
user_id: "@alice:example.com",
|
||||
device_id: "Dynabook",
|
||||
const deviceId = "Dynabook";
|
||||
const aliceNotCrossSignedDevice: IDevice = {
|
||||
verified: 0,
|
||||
known: false,
|
||||
algorithms: ["m.olm.curve25519-aes-sha256", "m.megolm.v1.aes-sha"],
|
||||
keys: {
|
||||
"curve25519:Dynabook": "somePubkey",
|
||||
@@ -1012,9 +1068,10 @@ describe("Cross Signing", function() {
|
||||
},
|
||||
};
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -43,13 +43,15 @@ const requests = [
|
||||
|
||||
describe.each([
|
||||
["IndexedDBCryptoStore",
|
||||
() => new IndexedDBCryptoStore(global.indexedDB, "tests")],
|
||||
() => new IndexedDBCryptoStore(global.indexedDB, "tests")],
|
||||
["LocalStorageCryptoStore",
|
||||
() => new IndexedDBCryptoStore(undefined, "tests")],
|
||||
() => new IndexedDBCryptoStore(undefined, "tests")],
|
||||
["MemoryCryptoStore", () => {
|
||||
const store = new IndexedDBCryptoStore(undefined, "tests");
|
||||
store._backend = new MemoryCryptoStore();
|
||||
store._backendPromise = Promise.resolve(store._backend);
|
||||
// @ts-ignore set private properties
|
||||
store.backend = new MemoryCryptoStore();
|
||||
// @ts-ignore
|
||||
store.backendPromise = Promise.resolve(store.backend);
|
||||
return store;
|
||||
}],
|
||||
])("Outgoing room key requests [%s]", function(name, dbFactory) {
|
||||
@@ -64,22 +66,22 @@ describe.each([
|
||||
});
|
||||
|
||||
it("getAllOutgoingRoomKeyRequestsByState retrieves all entries in a given state",
|
||||
async () => {
|
||||
const r = await
|
||||
async () => {
|
||||
const r = await
|
||||
store.getAllOutgoingRoomKeyRequestsByState(RoomKeyRequestState.Sent);
|
||||
expect(r).toHaveLength(2);
|
||||
requests.filter((e) => e.state === RoomKeyRequestState.Sent).forEach((e) => {
|
||||
expect(r).toContainEqual(e);
|
||||
expect(r).toHaveLength(2);
|
||||
requests.filter((e) => e.state === RoomKeyRequestState.Sent).forEach((e) => {
|
||||
expect(r).toContainEqual(e);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("getOutgoingRoomKeyRequestByState retrieves any entry in a given state",
|
||||
async () => {
|
||||
const r =
|
||||
async () => {
|
||||
const r =
|
||||
await store.getOutgoingRoomKeyRequestByState([RoomKeyRequestState.Sent]);
|
||||
expect(r).not.toBeNull();
|
||||
expect(r).not.toBeUndefined();
|
||||
expect(r.state).toEqual(RoomKeyRequestState.Sent);
|
||||
expect(requests).toContainEqual(r);
|
||||
});
|
||||
expect(r).not.toBeNull();
|
||||
expect(r).not.toBeUndefined();
|
||||
expect(r.state).toEqual(RoomKeyRequestState.Sent);
|
||||
expect(requests).toContainEqual(r);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
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 { logger } from '../../../src/logger';
|
||||
import * as utils from "../../../src/utils";
|
||||
import { ICreateClientOpts } from '../../../src/client';
|
||||
import { ISecretStorageKeyInfo } from '../../../src/crypto/api';
|
||||
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const crypto = require('crypto');
|
||||
utils.setCrypto(crypto);
|
||||
} catch (err) {
|
||||
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(
|
||||
userInfo.userId, userInfo.deviceId, undefined, undefined, options,
|
||||
)).client;
|
||||
@@ -46,7 +49,7 @@ async function makeTestClient(userInfo, options) {
|
||||
await client.initCrypto();
|
||||
|
||||
// No need to download keys for these tests
|
||||
client.crypto.downloadKeys = async function() {};
|
||||
jest.spyOn(client.crypto, 'downloadKeys').mockResolvedValue({});
|
||||
|
||||
return client;
|
||||
}
|
||||
@@ -54,7 +57,7 @@ async function makeTestClient(userInfo, options) {
|
||||
// Wrapper around pkSign to return a signed object. pkSign returns the
|
||||
// signature, rather than the signed object.
|
||||
function sign(obj, key, userId) {
|
||||
olmlib.pkSign(obj, key, userId);
|
||||
olmlib.pkSign(obj, key, userId, '');
|
||||
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"]);
|
||||
return ['abc', key];
|
||||
});
|
||||
@@ -93,7 +96,7 @@ describe("Secrets", function() {
|
||||
{ userId: "@alice:example.com", deviceId: "Osborne2" },
|
||||
{
|
||||
cryptoCallbacks: {
|
||||
getCrossSigningKey: t => signingKey,
|
||||
getCrossSigningKey: async t => signingKey,
|
||||
getSecretStorageKey: getKey,
|
||||
},
|
||||
},
|
||||
@@ -104,17 +107,19 @@ describe("Secrets", function() {
|
||||
|
||||
const secretStorage = alice.crypto.secretStorage;
|
||||
|
||||
alice.setAccountData = async function(eventType, contents, callback) {
|
||||
alice.store.storeAccountDataEvents([
|
||||
new MatrixEvent({
|
||||
type: eventType,
|
||||
content: contents,
|
||||
}),
|
||||
]);
|
||||
if (callback) {
|
||||
callback();
|
||||
}
|
||||
};
|
||||
jest.spyOn(alice, 'setAccountData').mockImplementation(
|
||||
async function(eventType, contents, callback) {
|
||||
alice.store.storeAccountDataEvents([
|
||||
new MatrixEvent({
|
||||
type: eventType,
|
||||
content: contents,
|
||||
}),
|
||||
]);
|
||||
if (callback) {
|
||||
callback(undefined, undefined);
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
const keyAccountData = {
|
||||
algorithm: SECRET_STORAGE_ALGORITHM_V1_AES,
|
||||
@@ -136,6 +141,7 @@ describe("Secrets", function() {
|
||||
expect(await secretStorage.get("foo")).toBe("bar");
|
||||
|
||||
expect(getKey).toHaveBeenCalled();
|
||||
alice.stopClient();
|
||||
});
|
||||
|
||||
it("should throw if given a key that doesn't exist", async function() {
|
||||
@@ -150,6 +156,7 @@ describe("Secrets", function() {
|
||||
expect(true).toBeFalsy();
|
||||
} catch (e) {
|
||||
}
|
||||
alice.stopClient();
|
||||
});
|
||||
|
||||
it("should refuse to encrypt with zero keys", async function() {
|
||||
@@ -162,12 +169,13 @@ describe("Secrets", function() {
|
||||
expect(true).toBeFalsy();
|
||||
} catch (e) {
|
||||
}
|
||||
alice.stopClient();
|
||||
});
|
||||
|
||||
it("should encrypt with default key if keys is null", async function() {
|
||||
const key = new Uint8Array(16);
|
||||
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]);
|
||||
return [newKeyId, key];
|
||||
});
|
||||
@@ -190,11 +198,12 @@ describe("Secrets", function() {
|
||||
content: contents,
|
||||
}),
|
||||
]);
|
||||
return {};
|
||||
};
|
||||
resetCrossSigningKeys(alice);
|
||||
|
||||
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
|
||||
// which won't happen in the test setup
|
||||
@@ -203,6 +212,7 @@ describe("Secrets", function() {
|
||||
|
||||
const accountData = alice.getAccountData('foo');
|
||||
expect(accountData.getContent().encrypted).toBeTruthy();
|
||||
alice.stopClient();
|
||||
});
|
||||
|
||||
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();
|
||||
} catch (e) {
|
||||
}
|
||||
alice.stopClient();
|
||||
});
|
||||
|
||||
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: "VAX" },
|
||||
@@ -273,6 +284,9 @@ describe("Secrets", function() {
|
||||
const secret = await request.promise;
|
||||
|
||||
expect(secret).toBe("bar");
|
||||
osborne2.stop();
|
||||
vax.stop();
|
||||
clearTestClientTimeouts();
|
||||
});
|
||||
|
||||
describe("bootstrap", function() {
|
||||
@@ -298,7 +312,7 @@ describe("Secrets", function() {
|
||||
it("bootstraps when no storage or cross-signing keys locally", async function() {
|
||||
const key = new Uint8Array(16);
|
||||
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];
|
||||
});
|
||||
|
||||
@@ -313,8 +327,8 @@ describe("Secrets", function() {
|
||||
},
|
||||
},
|
||||
);
|
||||
bob.uploadDeviceSigningKeys = async () => {};
|
||||
bob.uploadKeySignatures = async () => {};
|
||||
bob.uploadDeviceSigningKeys = async () => ({});
|
||||
bob.uploadKeySignatures = jest.fn().mockResolvedValue(undefined);
|
||||
bob.setAccountData = async function(eventType, contents, callback) {
|
||||
const event = new MatrixEvent({
|
||||
type: eventType,
|
||||
@@ -324,10 +338,11 @@ describe("Secrets", function() {
|
||||
event,
|
||||
]);
|
||||
this.emit("accountData", event);
|
||||
return {};
|
||||
};
|
||||
|
||||
await bob.bootstrapCrossSigning({
|
||||
authUploadDeviceSigningKeys: async func => await func({}),
|
||||
authUploadDeviceSigningKeys: async func => { await func({}); },
|
||||
});
|
||||
await bob.bootstrapSecretStorage({
|
||||
createSecretStorageKey,
|
||||
@@ -340,6 +355,7 @@ describe("Secrets", function() {
|
||||
expect(await crossSigning.isStoredInSecretStorage(secretStorage))
|
||||
.toBeTruthy();
|
||||
expect(await secretStorage.hasKey()).toBeTruthy();
|
||||
bob.stopClient();
|
||||
});
|
||||
|
||||
it("bootstraps when cross-signing keys in secret storage", async function() {
|
||||
@@ -406,10 +422,11 @@ describe("Secrets", function() {
|
||||
expect(await crossSigning.isStoredInSecretStorage(secretStorage))
|
||||
.toBeTruthy();
|
||||
expect(await secretStorage.hasKey()).toBeTruthy();
|
||||
bob.stopClient();
|
||||
});
|
||||
|
||||
it("adds passphrase checking if it's lacking", async function() {
|
||||
let crossSigningKeys = {
|
||||
let crossSigningKeys: Record<string, Uint8Array> = {
|
||||
master: XSK,
|
||||
user_signing: USK,
|
||||
self_signing: SSK,
|
||||
@@ -421,9 +438,9 @@ describe("Secrets", function() {
|
||||
{ userId: "@alice:example.com", deviceId: "Osborne2" },
|
||||
{
|
||||
cryptoCallbacks: {
|
||||
getCrossSigningKey: t => crossSigningKeys[t],
|
||||
getCrossSigningKey: async t => crossSigningKeys[t],
|
||||
saveCrossSigningKeys: k => crossSigningKeys = k,
|
||||
getSecretStorageKey: ({ keys }, name) => {
|
||||
getSecretStorageKey: async ({ keys }, name) => {
|
||||
for (const keyId of Object.keys(keys)) {
|
||||
if (secretStorageKeys[keyId]) {
|
||||
return [keyId, secretStorageKeys[keyId]];
|
||||
@@ -479,6 +496,8 @@ describe("Secrets", function() {
|
||||
}),
|
||||
]);
|
||||
alice.crypto.deviceList.storeCrossSigningForUser("@alice:example.com", {
|
||||
firstUse: false,
|
||||
crossSigningVerifiedBefore: false,
|
||||
keys: {
|
||||
master: {
|
||||
user_id: "@alice:example.com",
|
||||
@@ -519,14 +538,15 @@ describe("Secrets", function() {
|
||||
});
|
||||
alice.store.storeAccountDataEvents([event]);
|
||||
this.emit("accountData", event);
|
||||
return {};
|
||||
};
|
||||
|
||||
await alice.bootstrapSecretStorage();
|
||||
await alice.bootstrapSecretStorage({});
|
||||
|
||||
expect(alice.getAccountData("m.secret_storage.default_key").getContent())
|
||||
.toEqual({ key: "key_id" });
|
||||
const keyInfo = alice.getAccountData("m.secret_storage.key.key_id")
|
||||
.getContent();
|
||||
.getContent() as ISecretStorageKeyInfo;
|
||||
expect(keyInfo.algorithm)
|
||||
.toEqual("m.secret_storage.v1.aes-hmac-sha2");
|
||||
expect(keyInfo.passphrase).toEqual({
|
||||
@@ -538,9 +558,10 @@ describe("Secrets", function() {
|
||||
expect(keyInfo).toHaveProperty("mac");
|
||||
expect(alice.checkSecretStorageKey(secretStorageKeys.key_id, keyInfo))
|
||||
.toBeTruthy();
|
||||
alice.stopClient();
|
||||
});
|
||||
it("fixes backup keys in the wrong format", async function() {
|
||||
let crossSigningKeys = {
|
||||
let crossSigningKeys: Record<string, Uint8Array> = {
|
||||
master: XSK,
|
||||
user_signing: USK,
|
||||
self_signing: SSK,
|
||||
@@ -552,9 +573,9 @@ describe("Secrets", function() {
|
||||
{ userId: "@alice:example.com", deviceId: "Osborne2" },
|
||||
{
|
||||
cryptoCallbacks: {
|
||||
getCrossSigningKey: t => crossSigningKeys[t],
|
||||
getCrossSigningKey: async t => crossSigningKeys[t],
|
||||
saveCrossSigningKeys: k => crossSigningKeys = k,
|
||||
getSecretStorageKey: ({ keys }, name) => {
|
||||
getSecretStorageKey: async ({ keys }, name) => {
|
||||
for (const keyId of Object.keys(keys)) {
|
||||
if (secretStorageKeys[keyId]) {
|
||||
return [keyId, secretStorageKeys[keyId]];
|
||||
@@ -619,6 +640,8 @@ describe("Secrets", function() {
|
||||
}),
|
||||
]);
|
||||
alice.crypto.deviceList.storeCrossSigningForUser("@alice:example.com", {
|
||||
firstUse: false,
|
||||
crossSigningVerifiedBefore: false,
|
||||
keys: {
|
||||
master: {
|
||||
user_id: "@alice:example.com",
|
||||
@@ -659,15 +682,17 @@ describe("Secrets", function() {
|
||||
});
|
||||
alice.store.storeAccountDataEvents([event]);
|
||||
this.emit("accountData", event);
|
||||
return {};
|
||||
};
|
||||
|
||||
await alice.bootstrapSecretStorage();
|
||||
await alice.bootstrapSecretStorage({});
|
||||
|
||||
const backupKey = alice.getAccountData("m.megolm_backup.v1")
|
||||
.getContent();
|
||||
expect(backupKey.encrypted).toHaveProperty("key_id");
|
||||
expect(await alice.getSecret("m.megolm_backup.v1"))
|
||||
.toEqual("ey0GB1kB6jhOWgwiBUMIWg==");
|
||||
alice.stopClient();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -40,7 +40,7 @@ describe("verification request integration tests with crypto layer", 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: "@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)
|
||||
aliceVerifier.endTimer();
|
||||
|
||||
alice.stop();
|
||||
bob.stop();
|
||||
clearTestClientTimeouts();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -75,9 +75,10 @@ describe("SAS verification", function() {
|
||||
let bobSasEvent;
|
||||
let aliceVerifier;
|
||||
let bobPromise;
|
||||
let clearTestClientTimeouts;
|
||||
|
||||
beforeEach(async () => {
|
||||
[alice, bob] = await makeTestClients(
|
||||
[[alice, bob], clearTestClientTimeouts] = await makeTestClients(
|
||||
[
|
||||
{ userId: "@alice:example.com", deviceId: "Osborne2" },
|
||||
{ userId: "@bob:example.com", deviceId: "Dynabook" },
|
||||
@@ -178,6 +179,8 @@ describe("SAS verification", function() {
|
||||
alice.stop(),
|
||||
bob.stop(),
|
||||
]);
|
||||
|
||||
clearTestClientTimeouts();
|
||||
});
|
||||
|
||||
it("should verify a key", async () => {
|
||||
@@ -334,7 +337,7 @@ describe("SAS verification", 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: "@bob:example.com", deviceId: "Dynabook" },
|
||||
@@ -377,6 +380,10 @@ describe("SAS verification", function() {
|
||||
.not.toHaveBeenCalled();
|
||||
expect(bob.client.setDeviceVerified)
|
||||
.not.toHaveBeenCalled();
|
||||
|
||||
alice.stop();
|
||||
bob.stop();
|
||||
clearTestClientTimeouts();
|
||||
});
|
||||
|
||||
describe("verification in DM", function() {
|
||||
@@ -386,9 +393,10 @@ describe("SAS verification", function() {
|
||||
let bobSasEvent;
|
||||
let aliceVerifier;
|
||||
let bobPromise;
|
||||
let clearTestClientTimeouts;
|
||||
|
||||
beforeEach(async function() {
|
||||
[alice, bob] = await makeTestClients(
|
||||
[[alice, bob], clearTestClientTimeouts] = await makeTestClients(
|
||||
[
|
||||
{ userId: "@alice:example.com", deviceId: "Osborne2" },
|
||||
{ userId: "@bob:example.com", deviceId: "Dynabook" },
|
||||
@@ -488,6 +496,8 @@ describe("SAS verification", function() {
|
||||
alice.stop(),
|
||||
bob.stop(),
|
||||
]);
|
||||
|
||||
clearTestClientTimeouts();
|
||||
});
|
||||
|
||||
it("should verify a key", async function() {
|
||||
|
||||
@@ -23,6 +23,7 @@ import { logger } from '../../../../src/logger';
|
||||
|
||||
export async function makeTestClients(userInfos, options) {
|
||||
const clients = [];
|
||||
const timeouts = [];
|
||||
const clientMap = {};
|
||||
const sendToDevice = function(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) {
|
||||
if (tc.client === this) { // eslint-disable-line @babel/no-invalid-this
|
||||
logger.log("sending remote echo!!");
|
||||
@@ -77,6 +78,8 @@ export async function makeTestClients(userInfos, options) {
|
||||
}
|
||||
});
|
||||
|
||||
timeouts.push(timeout);
|
||||
|
||||
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()));
|
||||
|
||||
return clients;
|
||||
const destroy = () => {
|
||||
timeouts.forEach((t) => clearTimeout(t));
|
||||
};
|
||||
|
||||
return [clients, destroy];
|
||||
}
|
||||
|
||||
export function setupWebcrypto() {
|
||||
|
||||
@@ -44,6 +44,10 @@ describe("eventMapperFor", function() {
|
||||
rooms = [];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
client.stopClient();
|
||||
});
|
||||
|
||||
it("should de-duplicate MatrixEvent instances by means of findEventById on the room object", async () => {
|
||||
const roomId = "!room:example.org";
|
||||
const room = new Room(roomId, client, userId);
|
||||
|
||||
294
spec/unit/event-timeline-set.spec.ts
Normal file
294
spec/unit/event-timeline-set.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -50,9 +50,11 @@ describe("EventTimeline", function() {
|
||||
timeline.initialiseState(events);
|
||||
expect(timeline.startState.setStateEvents).toHaveBeenCalledWith(
|
||||
events,
|
||||
{ timelineWasEmpty: undefined },
|
||||
);
|
||||
expect(timeline.endState.setStateEvents).toHaveBeenCalledWith(
|
||||
events,
|
||||
{ timelineWasEmpty: undefined },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -73,7 +75,7 @@ describe("EventTimeline", function() {
|
||||
expect(function() {
|
||||
timeline.initialiseState(state);
|
||||
}).not.toThrow();
|
||||
timeline.addEvent(event, false);
|
||||
timeline.addEvent(event, { toStartOfTimeline: false });
|
||||
expect(function() {
|
||||
timeline.initialiseState(state);
|
||||
}).toThrow();
|
||||
@@ -149,9 +151,9 @@ describe("EventTimeline", 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();
|
||||
timeline.addEvent(events[1], false);
|
||||
timeline.addEvent(events[1], { toStartOfTimeline: false });
|
||||
expect(timeline.getBaseIndex()).toEqual(initialIndex);
|
||||
expect(timeline.getEvents().length).toEqual(2);
|
||||
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() {
|
||||
timeline.addEvent(events[0], true);
|
||||
timeline.addEvent(events[0], { toStartOfTimeline: true });
|
||||
const initialIndex = timeline.getBaseIndex();
|
||||
timeline.addEvent(events[1], true);
|
||||
timeline.addEvent(events[1], { toStartOfTimeline: true });
|
||||
expect(timeline.getBaseIndex()).toEqual(initialIndex + 1);
|
||||
expect(timeline.getEvents().length).toEqual(2);
|
||||
expect(timeline.getEvents()[0]).toEqual(events[1]);
|
||||
@@ -203,9 +205,9 @@ describe("EventTimeline", function() {
|
||||
content: { name: "Old Room Name" },
|
||||
});
|
||||
|
||||
timeline.addEvent(newEv, false);
|
||||
timeline.addEvent(newEv, { toStartOfTimeline: false });
|
||||
expect(newEv.sender).toEqual(sentinel);
|
||||
timeline.addEvent(oldEv, true);
|
||||
timeline.addEvent(oldEv, { toStartOfTimeline: true });
|
||||
expect(oldEv.sender).toEqual(oldSentinel);
|
||||
});
|
||||
|
||||
@@ -242,9 +244,9 @@ describe("EventTimeline", function() {
|
||||
const oldEv = utils.mkMembership({
|
||||
room: roomId, mship: "ban", user: userB, skey: userA, event: true,
|
||||
});
|
||||
timeline.addEvent(newEv, false);
|
||||
timeline.addEvent(newEv, { toStartOfTimeline: false });
|
||||
expect(newEv.target).toEqual(sentinel);
|
||||
timeline.addEvent(oldEv, true);
|
||||
timeline.addEvent(oldEv, { toStartOfTimeline: true });
|
||||
expect(oldEv.target).toEqual(oldSentinel);
|
||||
});
|
||||
|
||||
@@ -262,13 +264,13 @@ describe("EventTimeline", function() {
|
||||
}),
|
||||
];
|
||||
|
||||
timeline.addEvent(events[0], false);
|
||||
timeline.addEvent(events[1], false);
|
||||
timeline.addEvent(events[0], { toStartOfTimeline: false });
|
||||
timeline.addEvent(events[1], { toStartOfTimeline: false });
|
||||
|
||||
expect(timeline.getState(EventTimeline.FORWARDS).setStateEvents).
|
||||
toHaveBeenCalledWith([events[0]]);
|
||||
toHaveBeenCalledWith([events[0]], { timelineWasEmpty: undefined });
|
||||
expect(timeline.getState(EventTimeline.FORWARDS).setStateEvents).
|
||||
toHaveBeenCalledWith([events[1]]);
|
||||
toHaveBeenCalledWith([events[1]], { timelineWasEmpty: undefined });
|
||||
|
||||
expect(events[0].forwardLooking).toBe(true);
|
||||
expect(events[1].forwardLooking).toBe(true);
|
||||
@@ -291,13 +293,13 @@ describe("EventTimeline", function() {
|
||||
}),
|
||||
];
|
||||
|
||||
timeline.addEvent(events[0], true);
|
||||
timeline.addEvent(events[1], true);
|
||||
timeline.addEvent(events[0], { toStartOfTimeline: true });
|
||||
timeline.addEvent(events[1], { toStartOfTimeline: true });
|
||||
|
||||
expect(timeline.getState(EventTimeline.BACKWARDS).setStateEvents).
|
||||
toHaveBeenCalledWith([events[0]]);
|
||||
toHaveBeenCalledWith([events[0]], { timelineWasEmpty: undefined });
|
||||
expect(timeline.getState(EventTimeline.BACKWARDS).setStateEvents).
|
||||
toHaveBeenCalledWith([events[1]]);
|
||||
toHaveBeenCalledWith([events[1]], { timelineWasEmpty: undefined });
|
||||
|
||||
expect(events[0].forwardLooking).toBe(false);
|
||||
expect(events[1].forwardLooking).toBe(false);
|
||||
@@ -305,6 +307,11 @@ describe("EventTimeline", function() {
|
||||
expect(timeline.getState(EventTimeline.FORWARDS).setStateEvents).
|
||||
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() {
|
||||
@@ -324,8 +331,8 @@ describe("EventTimeline", function() {
|
||||
];
|
||||
|
||||
it("should remove events", function() {
|
||||
timeline.addEvent(events[0], false);
|
||||
timeline.addEvent(events[1], false);
|
||||
timeline.addEvent(events[0], { toStartOfTimeline: false });
|
||||
timeline.addEvent(events[1], { toStartOfTimeline: false });
|
||||
expect(timeline.getEvents().length).toEqual(2);
|
||||
|
||||
let ev = timeline.removeEvent(events[0].getId());
|
||||
@@ -338,9 +345,9 @@ describe("EventTimeline", function() {
|
||||
});
|
||||
|
||||
it("should update baseIndex", function() {
|
||||
timeline.addEvent(events[0], false);
|
||||
timeline.addEvent(events[1], true);
|
||||
timeline.addEvent(events[2], false);
|
||||
timeline.addEvent(events[0], { toStartOfTimeline: false });
|
||||
timeline.addEvent(events[1], { toStartOfTimeline: true });
|
||||
timeline.addEvent(events[2], { toStartOfTimeline: false });
|
||||
expect(timeline.getEvents().length).toEqual(3);
|
||||
expect(timeline.getBaseIndex()).toEqual(1);
|
||||
|
||||
@@ -358,11 +365,11 @@ describe("EventTimeline", function() {
|
||||
// further addEvent(ev, false) calls made the index increase.
|
||||
it("should not make baseIndex assplode when removing the last event",
|
||||
function() {
|
||||
timeline.addEvent(events[0], true);
|
||||
timeline.addEvent(events[0], { toStartOfTimeline: true });
|
||||
timeline.removeEvent(events[0].getId());
|
||||
const initialIndex = timeline.getBaseIndex();
|
||||
timeline.addEvent(events[1], false);
|
||||
timeline.addEvent(events[2], false);
|
||||
timeline.addEvent(events[1], { toStartOfTimeline: false });
|
||||
timeline.addEvent(events[2], { toStartOfTimeline: false });
|
||||
expect(timeline.getBaseIndex()).toEqual(initialIndex);
|
||||
expect(timeline.getEvents().length).toEqual(2);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import {
|
||||
MatrixEvent,
|
||||
RelationType,
|
||||
} from "../../src";
|
||||
import { RelationType } from "../../src";
|
||||
import { FilterComponent } from "../../src/filter-component";
|
||||
import { mkEvent } from '../test-utils/test-utils';
|
||||
|
||||
@@ -14,7 +11,7 @@ describe("Filter Component", function() {
|
||||
content: { },
|
||||
room: 'roomId',
|
||||
event: true,
|
||||
}) as MatrixEvent;
|
||||
});
|
||||
|
||||
const checkResult = filter.check(event);
|
||||
|
||||
@@ -28,7 +25,7 @@ describe("Filter Component", function() {
|
||||
content: { },
|
||||
room: 'roomId',
|
||||
event: true,
|
||||
}) as MatrixEvent;
|
||||
});
|
||||
|
||||
const checkResult = filter.check(event);
|
||||
|
||||
@@ -55,7 +52,7 @@ describe("Filter Component", function() {
|
||||
},
|
||||
},
|
||||
},
|
||||
}) as MatrixEvent;
|
||||
});
|
||||
|
||||
expect(filter.check(threadRootNotParticipated)).toBe(false);
|
||||
});
|
||||
@@ -80,7 +77,7 @@ describe("Filter Component", function() {
|
||||
user: '@someone-else:server.org',
|
||||
room: 'roomId',
|
||||
event: true,
|
||||
}) as MatrixEvent;
|
||||
});
|
||||
|
||||
expect(filter.check(threadRootParticipated)).toBe(true);
|
||||
});
|
||||
@@ -100,7 +97,7 @@ describe("Filter Component", function() {
|
||||
[RelationType.Reference]: {},
|
||||
},
|
||||
},
|
||||
}) as MatrixEvent;
|
||||
});
|
||||
|
||||
expect(filter.check(referenceRelationEvent)).toBe(false);
|
||||
});
|
||||
@@ -123,7 +120,7 @@ describe("Filter Component", function() {
|
||||
},
|
||||
room: 'roomId',
|
||||
event: true,
|
||||
}) as MatrixEvent;
|
||||
});
|
||||
|
||||
const eventWithMultipleRelations = mkEvent({
|
||||
"type": "m.room.message",
|
||||
@@ -148,7 +145,7 @@ describe("Filter Component", function() {
|
||||
},
|
||||
"room": 'roomId',
|
||||
"event": true,
|
||||
}) as MatrixEvent;
|
||||
});
|
||||
|
||||
const noMatchEvent = mkEvent({
|
||||
"type": "m.room.message",
|
||||
@@ -160,7 +157,7 @@ describe("Filter Component", function() {
|
||||
},
|
||||
"room": 'roomId',
|
||||
"event": true,
|
||||
}) as MatrixEvent;
|
||||
});
|
||||
|
||||
expect(filter.check(threadRootEvent)).toBe(true);
|
||||
expect(filter.check(eventWithMultipleRelations)).toBe(true);
|
||||
|
||||
@@ -18,6 +18,8 @@ limitations under the License.
|
||||
import { logger } from "../../src/logger";
|
||||
import { InteractiveAuth } from "../../src/interactive-auth";
|
||||
import { MatrixError } from "../../src/http-api";
|
||||
import { sleep } from "../../src/utils";
|
||||
import { randomString } from "../../src/randomstring";
|
||||
|
||||
// Trivial client object to test interactive auth
|
||||
// (we do not need TestClient here)
|
||||
@@ -172,4 +174,107 @@ describe("InteractiveAuth", function() {
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,7 +33,7 @@ import { ReceiptType } from "../../src/@types/read_receipts";
|
||||
import * as testUtils from "../test-utils/test-utils";
|
||||
import { makeBeaconInfoContent } from "../../src/content-helpers";
|
||||
import { M_BEACON_INFO } from "../../src/@types/beacon";
|
||||
import { Room } from "../../src";
|
||||
import { ContentHelpers, Room } from "../../src";
|
||||
import { makeBeaconEvent } from "../test-utils/beacon";
|
||||
|
||||
jest.useFakeTimers();
|
||||
@@ -87,7 +87,7 @@ describe("MatrixClient", function() {
|
||||
// }
|
||||
// items are popped off when processed and block if no items left.
|
||||
];
|
||||
let acceptKeepalives;
|
||||
let acceptKeepalives: boolean;
|
||||
let pendingLookup = null;
|
||||
function httpReq(cb, method, path, qp, data, prefix) {
|
||||
if (path === KEEP_ALIVE_PATH && acceptKeepalives) {
|
||||
@@ -118,6 +118,7 @@ describe("MatrixClient", function() {
|
||||
method: method,
|
||||
path: path,
|
||||
};
|
||||
pendingLookup.promise.abort = () => {}; // to make it a valid IAbortablePromise
|
||||
return pendingLookup.promise;
|
||||
}
|
||||
if (next.path === path && next.method === method) {
|
||||
@@ -126,7 +127,7 @@ describe("MatrixClient", function() {
|
||||
(next.error ? "BAD" : "GOOD") + " response",
|
||||
);
|
||||
if (next.expectBody) {
|
||||
expect(next.expectBody).toEqual(data);
|
||||
expect(data).toEqual(next.expectBody);
|
||||
}
|
||||
if (next.expectQueryParams) {
|
||||
Object.keys(next.expectQueryParams).forEach(function(k) {
|
||||
@@ -150,6 +151,10 @@ describe("MatrixClient", function() {
|
||||
}
|
||||
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);
|
||||
return new Promise(() => {});
|
||||
}
|
||||
@@ -205,6 +210,7 @@ describe("MatrixClient", function() {
|
||||
client.http.authedRequest.mockImplementation(function() {
|
||||
return new Promise(() => {});
|
||||
});
|
||||
client.stopClient();
|
||||
});
|
||||
|
||||
it("should create (unstable) file trees", async () => {
|
||||
@@ -725,18 +731,16 @@ describe("MatrixClient", function() {
|
||||
});
|
||||
|
||||
describe("guest rooms", function() {
|
||||
it("should only do /sync calls (without filter/pushrules)", function(done) {
|
||||
httpLookups = []; // no /pushrules or /filterw
|
||||
it("should only do /sync calls (without filter/pushrules)", async function() {
|
||||
httpLookups = []; // no /pushrules or /filter
|
||||
httpLookups.push({
|
||||
method: "GET",
|
||||
path: "/sync",
|
||||
data: SYNC_DATA,
|
||||
thenCall: function() {
|
||||
done();
|
||||
},
|
||||
});
|
||||
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) {
|
||||
@@ -773,7 +777,7 @@ describe("MatrixClient", function() {
|
||||
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 () => {
|
||||
@@ -786,20 +790,99 @@ describe("MatrixClient", function() {
|
||||
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 () => {
|
||||
const eventId = "$eventId:example.org";
|
||||
const txnId = client.makeTxnId();
|
||||
const threadId = "$threadId:server";
|
||||
httpLookups = [{
|
||||
method: "PUT",
|
||||
path: `/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${txnId}`,
|
||||
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
|
||||
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", () => {
|
||||
const auth = { session: 'abcdef', type: 'foo' };
|
||||
const newPassword = 'newpassword';
|
||||
@@ -1156,4 +1275,26 @@ describe("MatrixClient", function() {
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { MatrixEvent } from "../../../src";
|
||||
import {
|
||||
isTimestampInDuration,
|
||||
Beacon,
|
||||
@@ -65,33 +66,36 @@ describe('Beacon', () => {
|
||||
// beacon_info events
|
||||
// created 'an hour ago'
|
||||
// without timeout of 3 hours
|
||||
let liveBeaconEvent;
|
||||
let notLiveBeaconEvent;
|
||||
let user2BeaconEvent;
|
||||
let liveBeaconEvent: MatrixEvent;
|
||||
let notLiveBeaconEvent: MatrixEvent;
|
||||
let user2BeaconEvent: MatrixEvent;
|
||||
|
||||
const advanceDateAndTime = (ms: number) => {
|
||||
// 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
|
||||
jest.advanceTimersByTime(ms);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
// go back in time to create the beacon
|
||||
jest.spyOn(global.Date, 'now').mockReturnValue(now - HOUR_MS);
|
||||
liveBeaconEvent = makeBeaconInfoEvent(
|
||||
userId,
|
||||
roomId,
|
||||
{
|
||||
timeout: HOUR_MS * 3,
|
||||
isLive: true,
|
||||
timestamp: now - HOUR_MS,
|
||||
},
|
||||
'$live123',
|
||||
);
|
||||
notLiveBeaconEvent = makeBeaconInfoEvent(
|
||||
userId,
|
||||
roomId,
|
||||
{ timeout: HOUR_MS * 3, isLive: false },
|
||||
{
|
||||
timeout: HOUR_MS * 3,
|
||||
isLive: false,
|
||||
timestamp: now - HOUR_MS,
|
||||
},
|
||||
'$dead123',
|
||||
);
|
||||
user2BeaconEvent = makeBeaconInfoEvent(
|
||||
@@ -100,11 +104,12 @@ describe('Beacon', () => {
|
||||
{
|
||||
timeout: HOUR_MS * 3,
|
||||
isLive: true,
|
||||
timestamp: now - HOUR_MS,
|
||||
},
|
||||
'$user2live123',
|
||||
);
|
||||
|
||||
// back to now
|
||||
// back to 'now'
|
||||
jest.spyOn(global.Date, 'now').mockReturnValue(now);
|
||||
});
|
||||
|
||||
@@ -131,17 +136,81 @@ describe('Beacon', () => {
|
||||
});
|
||||
|
||||
it('returns false when beacon is expired', () => {
|
||||
// time travel to beacon creation + 3 hours
|
||||
jest.spyOn(global.Date, 'now').mockReturnValue(now - 3 * HOUR_MS);
|
||||
const beacon = new Beacon(liveBeaconEvent);
|
||||
const expiredBeaconEvent = makeBeaconInfoEvent(
|
||||
userId2,
|
||||
roomId,
|
||||
{
|
||||
timeout: HOUR_MS,
|
||||
isLive: true,
|
||||
timestamp: now - HOUR_MS * 2,
|
||||
},
|
||||
'$user2live123',
|
||||
);
|
||||
const beacon = new Beacon(expiredBeaconEvent);
|
||||
expect(beacon.isLive).toEqual(false);
|
||||
});
|
||||
|
||||
it('returns false when beacon timestamp is in future', () => {
|
||||
// time travel to before beacon events timestamp
|
||||
// event was created now - 1 hour
|
||||
jest.spyOn(global.Date, 'now').mockReturnValue(now - HOUR_MS - HOUR_MS);
|
||||
const beacon = new Beacon(liveBeaconEvent);
|
||||
it('returns false when beacon timestamp is in future by an hour', () => {
|
||||
const beaconStartsInHour = makeBeaconInfoEvent(
|
||||
userId2,
|
||||
roomId,
|
||||
{
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -224,13 +293,47 @@ describe('Beacon', () => {
|
||||
beacon.monitorLiveness();
|
||||
|
||||
// @ts-ignore
|
||||
expect(beacon.livenessWatchInterval).toBeFalsy();
|
||||
expect(beacon.livenessWatchTimeout).toBeFalsy();
|
||||
advanceDateAndTime(HOUR_MS * 2 + 1);
|
||||
|
||||
// no emit
|
||||
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', () => {
|
||||
// live beacon was created an hour ago
|
||||
// and has a 3hr duration
|
||||
@@ -253,12 +356,12 @@ describe('Beacon', () => {
|
||||
|
||||
beacon.monitorLiveness();
|
||||
// @ts-ignore
|
||||
const oldMonitor = beacon.livenessWatchInterval;
|
||||
const oldMonitor = beacon.livenessWatchTimeout;
|
||||
|
||||
beacon.monitorLiveness();
|
||||
|
||||
// @ts-ignore
|
||||
expect(beacon.livenessWatchInterval).not.toEqual(oldMonitor);
|
||||
expect(beacon.livenessWatchTimeout).not.toEqual(oldMonitor);
|
||||
});
|
||||
|
||||
it('destroy kills liveness monitor and emits', () => {
|
||||
@@ -309,6 +412,57 @@ describe('Beacon', () => {
|
||||
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', () => {
|
||||
const beacon = new Beacon(makeBeaconInfoEvent(userId, roomId, { isLive: true, timeout: 60000 }));
|
||||
const emitSpy = jest.spyOn(beacon, 'emit');
|
||||
@@ -338,6 +492,7 @@ describe('Beacon', () => {
|
||||
|
||||
// the newest valid location
|
||||
expect(beacon.latestLocationState).toEqual(expectedLatestLocation);
|
||||
expect(beacon.latestLocationEvent).toEqual(locations[1]);
|
||||
expect(emitSpy).toHaveBeenCalledWith(BeaconEvent.LocationUpdate, expectedLatestLocation);
|
||||
});
|
||||
|
||||
@@ -356,6 +511,7 @@ describe('Beacon', () => {
|
||||
expect(beacon.latestLocationState).toEqual(expect.objectContaining({
|
||||
uri: 'geo:bar',
|
||||
}));
|
||||
expect(beacon.latestLocationEvent).toEqual(newerLocation);
|
||||
|
||||
const emitSpy = jest.spyOn(beacon, 'emit').mockClear();
|
||||
|
||||
|
||||
28
spec/unit/models/thread.spec.ts
Normal file
28
spec/unit/models/thread.spec.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -302,6 +302,7 @@ describe('NotificationService', function() {
|
||||
type: EventType.RoomServerAcl,
|
||||
room: testRoomId,
|
||||
user: "@alfred:localhost",
|
||||
skey: "",
|
||||
event: true,
|
||||
content: {},
|
||||
});
|
||||
|
||||
@@ -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
|
||||
{
|
||||
const room = new Room("room123", null, null);
|
||||
const relationsCreated = new Promise(resolve => {
|
||||
targetEvent.once(MatrixEventEvent.RelationsCreated, resolve);
|
||||
});
|
||||
|
||||
const timelineSet = new EventTimelineSet(room, {
|
||||
unstableClientRelationAggregation: true,
|
||||
});
|
||||
const timelineSet = new EventTimelineSet(room);
|
||||
timelineSet.addLiveEvent(targetEvent);
|
||||
timelineSet.addLiveEvent(relationEvent);
|
||||
|
||||
@@ -117,13 +112,12 @@ describe("Relations", function() {
|
||||
|
||||
// Add the relation event first, then the target event
|
||||
{
|
||||
const room = new Room("room123", null, null);
|
||||
const relationsCreated = new Promise(resolve => {
|
||||
targetEvent.once(MatrixEventEvent.RelationsCreated, resolve);
|
||||
});
|
||||
|
||||
const timelineSet = new EventTimelineSet(room, {
|
||||
unstableClientRelationAggregation: true,
|
||||
});
|
||||
const timelineSet = new EventTimelineSet(room);
|
||||
timelineSet.addLiveEvent(relationEvent);
|
||||
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 () => {
|
||||
const userId = "@bob:example.com";
|
||||
const room = new Room("room123", null, userId);
|
||||
@@ -168,6 +170,8 @@ describe("Relations", function() {
|
||||
await relations.setTargetEvent(originalTopic);
|
||||
expect(originalTopic.replacingEvent()).toBe(null);
|
||||
expect(originalTopic.getContent().topic).toBe("orig");
|
||||
expect(badlyEditedTopic.isRelation()).toBe(false);
|
||||
expect(badlyEditedTopic.isRelation("m.replace")).toBe(false);
|
||||
|
||||
await relations.addEvent(badlyEditedTopic);
|
||||
expect(originalTopic.replacingEvent()).toBe(null);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { makeBeaconEvent, makeBeaconInfoEvent } from "../test-utils/beacon";
|
||||
import { filterEmitCallsByEventType } from "../test-utils/emitter";
|
||||
import { RoomState, RoomStateEvent } from "../../src/models/room-state";
|
||||
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 {
|
||||
MatrixEvent,
|
||||
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', () => {
|
||||
it('adds new beacon info events to state and emits', () => {
|
||||
const beaconEvent = makeBeaconInfoEvent(userA, roomId);
|
||||
|
||||
@@ -52,7 +52,7 @@ describe("Room", function() {
|
||||
event: true,
|
||||
user: userA,
|
||||
room: roomId,
|
||||
}, room.client) as MatrixEvent;
|
||||
}, room.client);
|
||||
|
||||
const mkReply = (target: MatrixEvent) => utils.mkEvent({
|
||||
event: true,
|
||||
@@ -67,7 +67,7 @@ describe("Room", function() {
|
||||
},
|
||||
},
|
||||
},
|
||||
}, room.client) as MatrixEvent;
|
||||
}, room.client);
|
||||
|
||||
const mkEdit = (target: MatrixEvent, salt = Math.random()) => utils.mkEvent({
|
||||
event: true,
|
||||
@@ -84,7 +84,7 @@ describe("Room", function() {
|
||||
event_id: target.getId(),
|
||||
},
|
||||
},
|
||||
}, room.client) as MatrixEvent;
|
||||
}, room.client);
|
||||
|
||||
const mkThreadResponse = (root: MatrixEvent) => utils.mkEvent({
|
||||
event: true,
|
||||
@@ -101,7 +101,7 @@ describe("Room", function() {
|
||||
"rel_type": "m.thread",
|
||||
},
|
||||
},
|
||||
}, room.client) as MatrixEvent;
|
||||
}, room.client);
|
||||
|
||||
const mkReaction = (target: MatrixEvent) => utils.mkEvent({
|
||||
event: true,
|
||||
@@ -115,7 +115,7 @@ describe("Room", function() {
|
||||
"key": Math.random().toString(),
|
||||
},
|
||||
},
|
||||
}, room.client) as MatrixEvent;
|
||||
}, room.client);
|
||||
|
||||
const mkRedaction = (target: MatrixEvent) => utils.mkEvent({
|
||||
event: true,
|
||||
@@ -124,7 +124,7 @@ describe("Room", function() {
|
||||
room: roomId,
|
||||
redacts: target.getId(),
|
||||
content: {},
|
||||
}, room.client) as MatrixEvent;
|
||||
}, room.client);
|
||||
|
||||
beforeEach(function() {
|
||||
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");
|
||||
});
|
||||
|
||||
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() {
|
||||
const hsUrl = "https://my.home.server";
|
||||
|
||||
@@ -189,29 +210,24 @@ describe("Room", function() {
|
||||
const events: MatrixEvent[] = [
|
||||
utils.mkMessage({
|
||||
room: roomId, user: userA, msg: "changing room name", event: true,
|
||||
}) as MatrixEvent,
|
||||
}),
|
||||
utils.mkEvent({
|
||||
type: EventType.RoomName, room: roomId, user: userA, event: true,
|
||||
content: { name: "New Room Name" },
|
||||
}) as MatrixEvent,
|
||||
}),
|
||||
];
|
||||
|
||||
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);
|
||||
it("Make sure legacy overload passing options directly as parameters still works", () => {
|
||||
expect(() => room.addLiveEvents(events, DuplicateStrategy.Replace, false)).not.toThrow();
|
||||
expect(() => room.addLiveEvents(events, DuplicateStrategy.Ignore, true)).not.toThrow();
|
||||
expect(() => room.addLiveEvents(events, "shouldfailbecauseinvalidduplicatestrategy", false)).toThrow();
|
||||
});
|
||||
|
||||
it("should throw if duplicateStrategy isn't 'replace' or 'ignore'", function() {
|
||||
expect(function() {
|
||||
room.addLiveEvents(events, "foo");
|
||||
room.addLiveEvents(events, {
|
||||
duplicateStrategy: "foo",
|
||||
});
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
@@ -219,11 +235,13 @@ describe("Room", function() {
|
||||
// make a duplicate
|
||||
const dupe = utils.mkMessage({
|
||||
room: roomId, user: userA, msg: "dupe", event: true,
|
||||
}) as MatrixEvent;
|
||||
});
|
||||
dupe.event.event_id = events[0].getId();
|
||||
room.addLiveEvents(events);
|
||||
expect(room.timeline[0]).toEqual(events[0]);
|
||||
room.addLiveEvents([dupe], DuplicateStrategy.Replace);
|
||||
room.addLiveEvents([dupe], {
|
||||
duplicateStrategy: DuplicateStrategy.Replace,
|
||||
});
|
||||
expect(room.timeline[0]).toEqual(dupe);
|
||||
});
|
||||
|
||||
@@ -231,11 +249,13 @@ describe("Room", function() {
|
||||
// make a duplicate
|
||||
const dupe = utils.mkMessage({
|
||||
room: roomId, user: userA, msg: "dupe", event: true,
|
||||
}) as MatrixEvent;
|
||||
});
|
||||
dupe.event.event_id = events[0].getId();
|
||||
room.addLiveEvents(events);
|
||||
expect(room.timeline[0]).toEqual(events[0]);
|
||||
room.addLiveEvents([dupe], "ignore");
|
||||
room.addLiveEvents([dupe], {
|
||||
duplicateStrategy: "ignore",
|
||||
});
|
||||
expect(room.timeline[0]).toEqual(events[0]);
|
||||
});
|
||||
|
||||
@@ -257,20 +277,22 @@ describe("Room", function() {
|
||||
const events: MatrixEvent[] = [
|
||||
utils.mkMembership({
|
||||
room: roomId, mship: "invite", user: userB, skey: userA, event: true,
|
||||
}) as MatrixEvent,
|
||||
}),
|
||||
utils.mkEvent({
|
||||
type: EventType.RoomName, room: roomId, user: userB, event: true,
|
||||
content: {
|
||||
name: "New room",
|
||||
},
|
||||
}) as MatrixEvent,
|
||||
}),
|
||||
];
|
||||
room.addLiveEvents(events);
|
||||
expect(room.currentState.setStateEvents).toHaveBeenCalledWith(
|
||||
[events[0]],
|
||||
{ timelineWasEmpty: undefined },
|
||||
);
|
||||
expect(room.currentState.setStateEvents).toHaveBeenCalledWith(
|
||||
[events[1]],
|
||||
{ timelineWasEmpty: undefined },
|
||||
);
|
||||
expect(events[0].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() {
|
||||
const localEvent = utils.mkMessage({
|
||||
room: roomId, user: userA, event: true,
|
||||
}) as MatrixEvent;
|
||||
});
|
||||
localEvent.status = EventStatus.SENDING;
|
||||
const localEventId = localEvent.getId();
|
||||
|
||||
const remoteEvent = utils.mkMessage({
|
||||
room: roomId, user: userA, event: true,
|
||||
}) as MatrixEvent;
|
||||
});
|
||||
remoteEvent.event.unsigned = { transaction_id: "TXN_ID" };
|
||||
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() {
|
||||
const events = [
|
||||
utils.mkMessage({
|
||||
@@ -408,11 +445,11 @@ describe("Room", function() {
|
||||
const newEv = utils.mkEvent({
|
||||
type: EventType.RoomName, room: roomId, user: userA, event: true,
|
||||
content: { name: "New Room Name" },
|
||||
}) as MatrixEvent;
|
||||
});
|
||||
const oldEv = utils.mkEvent({
|
||||
type: EventType.RoomName, room: roomId, user: userA, event: true,
|
||||
content: { name: "Old Room Name" },
|
||||
}) as MatrixEvent;
|
||||
});
|
||||
room.addLiveEvents([newEv]);
|
||||
expect(newEv.sender).toEqual(sentinel);
|
||||
room.addEventsToTimeline([oldEv], true, room.getLiveTimeline());
|
||||
@@ -445,10 +482,10 @@ describe("Room", function() {
|
||||
|
||||
const newEv = utils.mkMembership({
|
||||
room: roomId, mship: "invite", user: userB, skey: userA, event: true,
|
||||
}) as MatrixEvent;
|
||||
});
|
||||
const oldEv = utils.mkMembership({
|
||||
room: roomId, mship: "ban", user: userB, skey: userA, event: true,
|
||||
}) as MatrixEvent;
|
||||
});
|
||||
room.addLiveEvents([newEv]);
|
||||
expect(newEv.target).toEqual(sentinel);
|
||||
room.addEventsToTimeline([oldEv], true, room.getLiveTimeline());
|
||||
@@ -460,21 +497,23 @@ describe("Room", function() {
|
||||
const events: MatrixEvent[] = [
|
||||
utils.mkMembership({
|
||||
room: roomId, mship: "invite", user: userB, skey: userA, event: true,
|
||||
}) as MatrixEvent,
|
||||
}),
|
||||
utils.mkEvent({
|
||||
type: EventType.RoomName, room: roomId, user: userB, event: true,
|
||||
content: {
|
||||
name: "New room",
|
||||
},
|
||||
}) as MatrixEvent,
|
||||
}),
|
||||
];
|
||||
|
||||
room.addEventsToTimeline(events, true, room.getLiveTimeline());
|
||||
expect(room.oldState.setStateEvents).toHaveBeenCalledWith(
|
||||
[events[0]],
|
||||
{ timelineWasEmpty: undefined },
|
||||
);
|
||||
expect(room.oldState.setStateEvents).toHaveBeenCalledWith(
|
||||
[events[1]],
|
||||
{ timelineWasEmpty: undefined },
|
||||
);
|
||||
expect(events[0].forwardLooking).toBe(false);
|
||||
expect(events[1].forwardLooking).toBe(false);
|
||||
@@ -520,6 +559,23 @@ describe("Room", function() {
|
||||
it("should reset the legacy timeline fields", function() {
|
||||
room.addLiveEvents([events[0], events[1]]);
|
||||
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.addLiveEvents([events[2]]);
|
||||
@@ -529,6 +585,10 @@ describe("Room", function() {
|
||||
newLiveTimeline.getState(EventTimeline.BACKWARDS));
|
||||
expect(room.currentState).toEqual(
|
||||
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 " +
|
||||
@@ -571,13 +631,13 @@ describe("Room", function() {
|
||||
const events: MatrixEvent[] = [
|
||||
utils.mkMessage({
|
||||
room: roomId, user: userA, msg: "1111", event: true,
|
||||
}) as MatrixEvent,
|
||||
}),
|
||||
utils.mkMessage({
|
||||
room: roomId, user: userA, msg: "2222", event: true,
|
||||
}) as MatrixEvent,
|
||||
}),
|
||||
utils.mkMessage({
|
||||
room: roomId, user: userA, msg: "3333", event: true,
|
||||
}) as MatrixEvent,
|
||||
}),
|
||||
];
|
||||
|
||||
it("should handle events in the same timeline", function() {
|
||||
@@ -718,26 +778,26 @@ describe("Room", function() {
|
||||
type: EventType.RoomJoinRules, room: roomId, user: userA, content: {
|
||||
join_rule: rule,
|
||||
}, event: true,
|
||||
}) as MatrixEvent]);
|
||||
})]);
|
||||
};
|
||||
const setAltAliases = function(aliases: string[]) {
|
||||
room.addLiveEvents([utils.mkEvent({
|
||||
type: EventType.RoomCanonicalAlias, room: roomId, skey: "", content: {
|
||||
alt_aliases: aliases,
|
||||
}, event: true,
|
||||
}) as MatrixEvent]);
|
||||
})]);
|
||||
};
|
||||
const setAlias = function(alias: string) {
|
||||
room.addLiveEvents([utils.mkEvent({
|
||||
type: EventType.RoomCanonicalAlias, room: roomId, skey: "", content: { alias }, event: true,
|
||||
}) as MatrixEvent]);
|
||||
})]);
|
||||
};
|
||||
const setRoomName = function(name: string) {
|
||||
room.addLiveEvents([utils.mkEvent({
|
||||
type: EventType.RoomName, room: roomId, user: userA, content: {
|
||||
name: name,
|
||||
}, event: true,
|
||||
}) as MatrixEvent]);
|
||||
})]);
|
||||
};
|
||||
const addMember = function(userId: string, state = "join", opts: any = {}) {
|
||||
opts.room = roomId;
|
||||
@@ -745,7 +805,7 @@ describe("Room", function() {
|
||||
opts.user = opts.user || userId;
|
||||
opts.skey = userId;
|
||||
opts.event = true;
|
||||
const event = utils.mkMembership(opts) as MatrixEvent;
|
||||
const event = utils.mkMembership(opts);
|
||||
room.addLiveEvents([event]);
|
||||
return event;
|
||||
};
|
||||
@@ -1053,7 +1113,7 @@ describe("Room", function() {
|
||||
const eventToAck = utils.mkMessage({
|
||||
room: roomId, user: userA, msg: "PLEASE ACKNOWLEDGE MY EXISTENCE",
|
||||
event: true,
|
||||
}) as MatrixEvent;
|
||||
});
|
||||
|
||||
function mkReceipt(roomId: string, records) {
|
||||
const content = {};
|
||||
@@ -1119,7 +1179,7 @@ describe("Room", function() {
|
||||
const nextEventToAck = utils.mkMessage({
|
||||
room: roomId, user: userA, msg: "I AM HERE YOU KNOW",
|
||||
event: true,
|
||||
}) as MatrixEvent;
|
||||
});
|
||||
const ts = 13787898424;
|
||||
room.addReceipt(mkReceipt(roomId, [
|
||||
mkRecord(eventToAck.getId(), "m.read", userB, ts),
|
||||
@@ -1154,11 +1214,11 @@ describe("Room", function() {
|
||||
const eventTwo = utils.mkMessage({
|
||||
room: roomId, user: userA, msg: "2222",
|
||||
event: true,
|
||||
}) as MatrixEvent;
|
||||
});
|
||||
const eventThree = utils.mkMessage({
|
||||
room: roomId, user: userA, msg: "3333",
|
||||
event: true,
|
||||
}) as MatrixEvent;
|
||||
});
|
||||
const ts = 13787898424;
|
||||
room.addReceipt(mkReceipt(roomId, [
|
||||
mkRecord(eventToAck.getId(), "m.read", userB, ts),
|
||||
@@ -1206,15 +1266,15 @@ describe("Room", function() {
|
||||
utils.mkMessage({
|
||||
room: roomId, user: userA, msg: "1111",
|
||||
event: true,
|
||||
}) as MatrixEvent,
|
||||
}),
|
||||
utils.mkMessage({
|
||||
room: roomId, user: userA, msg: "2222",
|
||||
event: true,
|
||||
}) as MatrixEvent,
|
||||
}),
|
||||
utils.mkMessage({
|
||||
room: roomId, user: userA, msg: "3333",
|
||||
event: true,
|
||||
}) as MatrixEvent,
|
||||
}),
|
||||
];
|
||||
|
||||
room.addLiveEvents(events);
|
||||
@@ -1244,15 +1304,15 @@ describe("Room", function() {
|
||||
utils.mkMessage({
|
||||
room: roomId, user: userA, msg: "1111",
|
||||
event: true,
|
||||
}) as MatrixEvent,
|
||||
}),
|
||||
utils.mkMessage({
|
||||
room: roomId, user: userA, msg: "2222",
|
||||
event: true,
|
||||
}) as MatrixEvent,
|
||||
}),
|
||||
utils.mkMessage({
|
||||
room: roomId, user: userA, msg: "3333",
|
||||
event: true,
|
||||
}) as MatrixEvent,
|
||||
}),
|
||||
];
|
||||
|
||||
room.addLiveEvents(events);
|
||||
@@ -1344,14 +1404,14 @@ describe("Room", function() {
|
||||
});
|
||||
const eventA = utils.mkMessage({
|
||||
room: roomId, user: userA, msg: "remote 1", event: true,
|
||||
}) as MatrixEvent;
|
||||
});
|
||||
const eventB = utils.mkMessage({
|
||||
room: roomId, user: userA, msg: "local 1", event: true,
|
||||
}) as MatrixEvent;
|
||||
});
|
||||
eventB.status = EventStatus.SENDING;
|
||||
const eventC = utils.mkMessage({
|
||||
room: roomId, user: userA, msg: "remote 2", event: true,
|
||||
}) as MatrixEvent;
|
||||
});
|
||||
room.addLiveEvents([eventA]);
|
||||
room.addPendingEvent(eventB, "TXN1");
|
||||
room.addLiveEvents([eventC]);
|
||||
@@ -1370,14 +1430,14 @@ describe("Room", function() {
|
||||
});
|
||||
const eventA = utils.mkMessage({
|
||||
room: roomId, user: userA, msg: "remote 1", event: true,
|
||||
}) as MatrixEvent;
|
||||
});
|
||||
const eventB = utils.mkMessage({
|
||||
room: roomId, user: userA, msg: "local 1", event: true,
|
||||
}) as MatrixEvent;
|
||||
});
|
||||
eventB.status = EventStatus.SENDING;
|
||||
const eventC = utils.mkMessage({
|
||||
room: roomId, user: userA, msg: "remote 2", event: true,
|
||||
}) as MatrixEvent;
|
||||
});
|
||||
room.addLiveEvents([eventA]);
|
||||
room.addPendingEvent(eventB, "TXN1");
|
||||
room.addLiveEvents([eventC]);
|
||||
@@ -1397,7 +1457,7 @@ describe("Room", function() {
|
||||
});
|
||||
const eventA = utils.mkMessage({
|
||||
room: roomId, user: userA, event: true,
|
||||
}) as MatrixEvent;
|
||||
});
|
||||
eventA.status = EventStatus.SENDING;
|
||||
const eventId = eventA.getId();
|
||||
|
||||
@@ -1430,7 +1490,7 @@ describe("Room", function() {
|
||||
const room = new Room(roomId, null, userA);
|
||||
const eventA = utils.mkMessage({
|
||||
room: roomId, user: userA, event: true,
|
||||
}) as MatrixEvent;
|
||||
});
|
||||
eventA.status = EventStatus.SENDING;
|
||||
const eventId = eventA.getId();
|
||||
|
||||
@@ -1495,6 +1555,8 @@ describe("Room", function() {
|
||||
return Promise.resolve();
|
||||
},
|
||||
getSyncToken: () => "sync_token",
|
||||
getPendingEvents: jest.fn().mockResolvedValue([]),
|
||||
setPendingEvents: jest.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1505,7 +1567,7 @@ describe("Room", function() {
|
||||
room: roomId,
|
||||
event: true,
|
||||
name: "User A",
|
||||
}) as MatrixEvent;
|
||||
});
|
||||
|
||||
it("should load members from server on first call", async function() {
|
||||
const client = createClientMock([memberEvent]);
|
||||
@@ -1525,7 +1587,7 @@ describe("Room", function() {
|
||||
room: roomId,
|
||||
event: true,
|
||||
name: "Ms A",
|
||||
}) as MatrixEvent;
|
||||
});
|
||||
const client = createClientMock([memberEvent2], [memberEvent]);
|
||||
const room = new Room(roomId, client as any, null, { lazyLoadMembers: true });
|
||||
|
||||
@@ -1596,7 +1658,7 @@ describe("Room", function() {
|
||||
mship: "join",
|
||||
room: roomId,
|
||||
event: true,
|
||||
}) as MatrixEvent]);
|
||||
})]);
|
||||
expect(room.guessDMUserId()).toEqual(userB);
|
||||
});
|
||||
it("should return self if only member present", function() {
|
||||
@@ -1629,11 +1691,11 @@ describe("Room", function() {
|
||||
utils.mkMembership({
|
||||
user: userA, mship: "join",
|
||||
room: roomId, event: true, name: "User A",
|
||||
}) as MatrixEvent,
|
||||
}),
|
||||
utils.mkMembership({
|
||||
user: userB, mship: "join",
|
||||
room: roomId, event: true, name: "User B",
|
||||
}) as MatrixEvent,
|
||||
}),
|
||||
]);
|
||||
expect(room.getDefaultRoomName(userA)).toEqual("User B");
|
||||
});
|
||||
@@ -1644,11 +1706,11 @@ describe("Room", function() {
|
||||
utils.mkMembership({
|
||||
user: userA, mship: "join",
|
||||
room: roomId, event: true, name: "User A",
|
||||
}) as MatrixEvent,
|
||||
}),
|
||||
utils.mkMembership({
|
||||
user: userB, mship: "ban",
|
||||
room: roomId, event: true, name: "User B",
|
||||
}) as MatrixEvent,
|
||||
}),
|
||||
]);
|
||||
expect(room.getDefaultRoomName(userA)).toEqual("Empty room (was User B)");
|
||||
});
|
||||
@@ -1659,11 +1721,11 @@ describe("Room", function() {
|
||||
utils.mkMembership({
|
||||
user: userA, mship: "join",
|
||||
room: roomId, event: true, name: "User A",
|
||||
}) as MatrixEvent,
|
||||
}),
|
||||
utils.mkMembership({
|
||||
user: userB, mship: "invite",
|
||||
room: roomId, event: true, name: "User B",
|
||||
}) as MatrixEvent,
|
||||
}),
|
||||
]);
|
||||
expect(room.getDefaultRoomName(userA)).toEqual("User B");
|
||||
});
|
||||
@@ -1674,11 +1736,11 @@ describe("Room", function() {
|
||||
utils.mkMembership({
|
||||
user: userA, mship: "join",
|
||||
room: roomId, event: true, name: "User A",
|
||||
}) as MatrixEvent,
|
||||
}),
|
||||
utils.mkMembership({
|
||||
user: userB, mship: "leave",
|
||||
room: roomId, event: true, name: "User B",
|
||||
}) as MatrixEvent,
|
||||
}),
|
||||
]);
|
||||
expect(room.getDefaultRoomName(userA)).toEqual("Empty room (was User B)");
|
||||
});
|
||||
@@ -1689,15 +1751,15 @@ describe("Room", function() {
|
||||
utils.mkMembership({
|
||||
user: userA, mship: "join",
|
||||
room: roomId, event: true, name: "User A",
|
||||
}) as MatrixEvent,
|
||||
}),
|
||||
utils.mkMembership({
|
||||
user: userB, mship: "join",
|
||||
room: roomId, event: true, name: "User B",
|
||||
}) as MatrixEvent,
|
||||
}),
|
||||
utils.mkMembership({
|
||||
user: userC, mship: "join",
|
||||
room: roomId, event: true, name: "User C",
|
||||
}) as MatrixEvent,
|
||||
}),
|
||||
]);
|
||||
expect(room.getDefaultRoomName(userA)).toEqual("User B and User C");
|
||||
});
|
||||
@@ -1708,19 +1770,19 @@ describe("Room", function() {
|
||||
utils.mkMembership({
|
||||
user: userA, mship: "join",
|
||||
room: roomId, event: true, name: "User A",
|
||||
}) as MatrixEvent,
|
||||
}),
|
||||
utils.mkMembership({
|
||||
user: userB, mship: "join",
|
||||
room: roomId, event: true, name: "User B",
|
||||
}) as MatrixEvent,
|
||||
}),
|
||||
utils.mkMembership({
|
||||
user: userC, mship: "join",
|
||||
room: roomId, event: true, name: "User C",
|
||||
}) as MatrixEvent,
|
||||
}),
|
||||
utils.mkMembership({
|
||||
user: userD, mship: "join",
|
||||
room: roomId, event: true, name: "User D",
|
||||
}) as MatrixEvent,
|
||||
}),
|
||||
]);
|
||||
expect(room.getDefaultRoomName(userA)).toEqual("User B and 2 others");
|
||||
});
|
||||
@@ -1733,18 +1795,18 @@ describe("Room", function() {
|
||||
utils.mkMembership({
|
||||
user: userA, mship: "join",
|
||||
room: roomId, event: true, name: "User A",
|
||||
}) as MatrixEvent,
|
||||
}),
|
||||
utils.mkMembership({
|
||||
user: userB, mship: "join",
|
||||
room: roomId, event: true, name: "User B",
|
||||
}) as MatrixEvent,
|
||||
}),
|
||||
utils.mkEvent({
|
||||
type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, skey: "",
|
||||
room: roomId, event: true,
|
||||
content: {
|
||||
service_members: [],
|
||||
},
|
||||
}) as MatrixEvent,
|
||||
}),
|
||||
]);
|
||||
expect(room.getDefaultRoomName(userA)).toEqual("User B");
|
||||
});
|
||||
@@ -1755,11 +1817,11 @@ describe("Room", function() {
|
||||
utils.mkMembership({
|
||||
user: userA, mship: "join",
|
||||
room: roomId, event: true, name: "User A",
|
||||
}) as MatrixEvent,
|
||||
}),
|
||||
utils.mkMembership({
|
||||
user: userB, mship: "join",
|
||||
room: roomId, event: true, name: "User B",
|
||||
}) as MatrixEvent,
|
||||
}),
|
||||
utils.mkEvent({
|
||||
type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name,
|
||||
skey: "",
|
||||
@@ -1768,7 +1830,7 @@ describe("Room", function() {
|
||||
content: {
|
||||
service_members: 1,
|
||||
},
|
||||
}) as MatrixEvent,
|
||||
}),
|
||||
]);
|
||||
expect(room.getDefaultRoomName(userA)).toEqual("User B");
|
||||
});
|
||||
@@ -1779,18 +1841,18 @@ describe("Room", function() {
|
||||
utils.mkMembership({
|
||||
user: userA, mship: "join",
|
||||
room: roomId, event: true, name: "User A",
|
||||
}) as MatrixEvent,
|
||||
}),
|
||||
utils.mkMembership({
|
||||
user: userB, mship: "join",
|
||||
room: roomId, event: true, name: "User B",
|
||||
}) as MatrixEvent,
|
||||
}),
|
||||
utils.mkEvent({
|
||||
type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, skey: "",
|
||||
room: roomId, event: true,
|
||||
content: {
|
||||
service_members: userB,
|
||||
},
|
||||
}) as MatrixEvent,
|
||||
}),
|
||||
]);
|
||||
expect(room.getDefaultRoomName(userA)).toEqual("User B");
|
||||
});
|
||||
@@ -1801,18 +1863,18 @@ describe("Room", function() {
|
||||
utils.mkMembership({
|
||||
user: userA, mship: "join",
|
||||
room: roomId, event: true, name: "User A",
|
||||
}) as MatrixEvent,
|
||||
}),
|
||||
utils.mkMembership({
|
||||
user: userB, mship: "join",
|
||||
room: roomId, event: true, name: "User B",
|
||||
}) as MatrixEvent,
|
||||
}),
|
||||
utils.mkEvent({
|
||||
type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, skey: "",
|
||||
room: roomId, event: true,
|
||||
content: {
|
||||
service_members: [userB],
|
||||
},
|
||||
}) as MatrixEvent,
|
||||
}),
|
||||
]);
|
||||
expect(room.getDefaultRoomName(userA)).toEqual("Empty room");
|
||||
});
|
||||
@@ -1823,22 +1885,22 @@ describe("Room", function() {
|
||||
utils.mkMembership({
|
||||
user: userA, mship: "join",
|
||||
room: roomId, event: true, name: "User A",
|
||||
}) as MatrixEvent,
|
||||
}),
|
||||
utils.mkMembership({
|
||||
user: userB, mship: "join",
|
||||
room: roomId, event: true, name: "User B",
|
||||
}) as MatrixEvent,
|
||||
}),
|
||||
utils.mkMembership({
|
||||
user: userC, mship: "join",
|
||||
room: roomId, event: true, name: "User C",
|
||||
}) as MatrixEvent,
|
||||
}),
|
||||
utils.mkEvent({
|
||||
type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, skey: "",
|
||||
room: roomId, event: true, user: userA,
|
||||
content: {
|
||||
service_members: [userC],
|
||||
},
|
||||
}) as MatrixEvent,
|
||||
}),
|
||||
]);
|
||||
expect(room.getDefaultRoomName(userA)).toEqual("User B");
|
||||
});
|
||||
@@ -1849,22 +1911,22 @@ describe("Room", function() {
|
||||
utils.mkMembership({
|
||||
user: userA, mship: "join",
|
||||
room: roomId, event: true, name: "User A",
|
||||
}) as MatrixEvent,
|
||||
}),
|
||||
utils.mkMembership({
|
||||
user: userB, mship: "join",
|
||||
room: roomId, event: true, name: "User B",
|
||||
}) as MatrixEvent,
|
||||
}),
|
||||
utils.mkMembership({
|
||||
user: userC, mship: "join",
|
||||
room: roomId, event: true, name: "User C",
|
||||
}) as MatrixEvent,
|
||||
}),
|
||||
utils.mkEvent({
|
||||
type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, skey: "",
|
||||
room: roomId, event: true, user: userA,
|
||||
content: {
|
||||
service_members: [userB, userC],
|
||||
},
|
||||
}) as MatrixEvent,
|
||||
}),
|
||||
]);
|
||||
expect(room.getDefaultRoomName(userA)).toEqual("Empty room");
|
||||
});
|
||||
@@ -1875,18 +1937,18 @@ describe("Room", function() {
|
||||
utils.mkMembership({
|
||||
user: userA, mship: "join",
|
||||
room: roomId, event: true, name: "User A",
|
||||
}) as MatrixEvent,
|
||||
}),
|
||||
utils.mkMembership({
|
||||
user: userB, mship: "join",
|
||||
room: roomId, event: true, name: "User B",
|
||||
}) as MatrixEvent,
|
||||
}),
|
||||
utils.mkEvent({
|
||||
type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, skey: "",
|
||||
room: roomId, event: true, user: userA,
|
||||
content: {
|
||||
service_members: [userC],
|
||||
},
|
||||
}) as MatrixEvent,
|
||||
}),
|
||||
]);
|
||||
expect(room.getDefaultRoomName(userA)).toEqual("User B");
|
||||
});
|
||||
@@ -1937,6 +1999,15 @@ describe("Room", function() {
|
||||
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 () => {
|
||||
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]);
|
||||
const thread = await prom;
|
||||
|
||||
expect(thread).toHaveLength(2);
|
||||
expect(thread.replyToEvent.getId()).toBe(threadResponse2.getId());
|
||||
|
||||
prom = emitPromise(thread, ThreadEvent.Update);
|
||||
const threadResponse2ReactionRedaction = mkRedaction(threadResponse2Reaction);
|
||||
room.addLiveEvents([threadResponse2ReactionRedaction]);
|
||||
await prom;
|
||||
expect(thread).toHaveLength(2);
|
||||
expect(thread.replyToEvent.getId()).toBe(threadResponse2.getId());
|
||||
});
|
||||
@@ -2267,7 +2336,7 @@ describe("Room", function() {
|
||||
const thread = threadRoot.getThread();
|
||||
expect(thread.rootEvent).toBe(threadRoot);
|
||||
|
||||
const rootRelations = thread.timelineSet.getRelationsForEvent(
|
||||
const rootRelations = thread.timelineSet.relations.getChildEventsForEvent(
|
||||
threadRoot.getId(),
|
||||
RelationType.Annotation,
|
||||
EventType.Reaction,
|
||||
@@ -2277,7 +2346,7 @@ describe("Room", function() {
|
||||
expect(rootRelations[0][1].size).toEqual(1);
|
||||
expect(rootRelations[0][1].has(rootReaction)).toBeTruthy();
|
||||
|
||||
const responseRelations = thread.timelineSet.getRelationsForEvent(
|
||||
const responseRelations = thread.timelineSet.relations.getChildEventsForEvent(
|
||||
threadResponse.getId(),
|
||||
RelationType.Annotation,
|
||||
EventType.Reaction,
|
||||
|
||||
114
spec/unit/stores/indexeddb.spec.ts
Normal file
114
spec/unit/stores/indexeddb.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -35,13 +35,14 @@ function createTimeline(numEvents, baseIndex) {
|
||||
return timeline;
|
||||
}
|
||||
|
||||
function addEventsToTimeline(timeline, numEvents, atStart) {
|
||||
function addEventsToTimeline(timeline, numEvents, toStartOfTimeline) {
|
||||
for (let i = 0; i < numEvents; i++) {
|
||||
timeline.addEvent(
|
||||
utils.mkMessage({
|
||||
room: ROOM_ID, user: USER_ID,
|
||||
event: true,
|
||||
}), atStart,
|
||||
}),
|
||||
{ toStartOfTimeline },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
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 { RoomMember } from "../../../src";
|
||||
|
||||
@@ -525,4 +525,40 @@ describe('Call', function() {
|
||||
return sender?.track?.kind === "video";
|
||||
}).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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 { CallEventHandler } from '../../../src/webrtc/callEventHandler';
|
||||
import { MatrixEvent } from '../../../src/models/event';
|
||||
import { EventType } from '../../../src/@types/event';
|
||||
import {
|
||||
ClientEvent,
|
||||
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() {
|
||||
let client;
|
||||
|
||||
beforeEach(function() {
|
||||
client = new TestClient("@alice:foo", "somedevice", "token", undefined, {});
|
||||
describe("CallEventHandler", () => {
|
||||
let client: MatrixClient;
|
||||
beforeEach(() => {
|
||||
client = new TestClient("@alice:foo", "somedevice", "token", undefined, {}).client;
|
||||
client.callEventHandler = new CallEventHandler(client);
|
||||
client.callEventHandler.start();
|
||||
client.groupCallEventHandler = new GroupCallEventHandler(client);
|
||||
client.groupCallEventHandler.start();
|
||||
});
|
||||
|
||||
afterEach(function() {
|
||||
client.stop();
|
||||
afterEach(() => {
|
||||
client.callEventHandler.stop();
|
||||
client.groupCallEventHandler.stop();
|
||||
});
|
||||
|
||||
it('should enforce inbound toDevice message ordering', async function() {
|
||||
const callEventHandler = new CallEventHandler(client);
|
||||
|
||||
it("should enforce inbound toDevice message ordering", async () => {
|
||||
const callEventHandler = client.callEventHandler;
|
||||
const event1 = new MatrixEvent({
|
||||
type: EventType.CallInvite,
|
||||
content: {
|
||||
@@ -80,4 +110,34 @@ describe('CallEventHandler', function() {
|
||||
expect(callEventHandler.nextSeqByCall.get("123")).toBe(5);
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* Note that this reference is UNSTABLE and subject to breaking changes, including its
|
||||
|
||||
@@ -17,9 +17,12 @@ limitations under the License.
|
||||
import { Callback } from "../client";
|
||||
import { IContent, IEvent } from "../models/event";
|
||||
import { Preset, Visibility } from "./partials";
|
||||
import { SearchKey } from "./search";
|
||||
import { IEventWithRoomId, SearchKey } from "./search";
|
||||
import { IRoomEventFilter } from "../filter";
|
||||
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
|
||||
/* eslint-disable camelcase */
|
||||
@@ -109,7 +112,8 @@ export interface IRoomDirectoryOptions {
|
||||
limit?: number;
|
||||
since?: string;
|
||||
filter?: {
|
||||
generic_search_term: string;
|
||||
generic_search_term?: string;
|
||||
"org.matrix.msc3827.room_types"?: Array<RoomType | null>;
|
||||
};
|
||||
include_all_networks?: boolean;
|
||||
third_party_instance_id?: string;
|
||||
@@ -155,4 +159,50 @@ export interface IRelationsResponse {
|
||||
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 */
|
||||
|
||||
62
src/@types/topic.ts
Normal file
62
src/@types/topic.ts
Normal 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;
|
||||
@@ -17,8 +17,6 @@ limitations under the License.
|
||||
|
||||
/** @module auto-discovery */
|
||||
|
||||
import { URL as NodeURL } from "url";
|
||||
|
||||
import { IClientWellKnown, IWellKnownConfig } from "./client";
|
||||
import { logger } from './logger';
|
||||
|
||||
@@ -372,16 +370,11 @@ export class AutoDiscovery {
|
||||
if (!url) return false;
|
||||
|
||||
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;
|
||||
try {
|
||||
if (NodeURL) parsed = new NodeURL(url);
|
||||
else parsed = new URL(url);
|
||||
} catch (e) {
|
||||
parsed = new URL(url);
|
||||
} catch (e) {
|
||||
logger.error("Could not parse url", e);
|
||||
}
|
||||
|
||||
if (!parsed || !parsed.hostname) return false;
|
||||
|
||||
382
src/client.ts
382
src/client.ts
@@ -32,7 +32,7 @@ import {
|
||||
MatrixEventHandlerMap,
|
||||
} from "./models/event";
|
||||
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 { CallEventHandlerEvent, CallEventHandler, CallEventHandlerEventHandlerMap } from './webrtc/callEventHandler';
|
||||
import { GroupCallEventHandlerEvent, GroupCallEventHandlerEventHandlerMap } from './webrtc/groupCallEventHandler';
|
||||
@@ -62,6 +62,7 @@ import {
|
||||
PREFIX_R0,
|
||||
PREFIX_UNSTABLE,
|
||||
PREFIX_V1,
|
||||
PREFIX_V3,
|
||||
retryNetworkOperation,
|
||||
UploadContentResponseType,
|
||||
} from "./http-api";
|
||||
@@ -114,6 +115,13 @@ import {
|
||||
RoomMemberEventHandlerMap,
|
||||
RoomStateEvent,
|
||||
RoomStateEventHandlerMap,
|
||||
INotificationsResponse,
|
||||
IFilterResponse,
|
||||
ITagsResponse,
|
||||
IStatusResponse,
|
||||
IPushRule,
|
||||
PushRuleActionName,
|
||||
IAuthDict,
|
||||
} from "./matrix";
|
||||
import {
|
||||
CrossSigningKey,
|
||||
@@ -133,6 +141,7 @@ import { Room } from "./models/room";
|
||||
import {
|
||||
IAddThreePidOnlyBody,
|
||||
IBindThreePidBody,
|
||||
IContextResponse,
|
||||
ICreateRoomOpts,
|
||||
IEventSearchOpts,
|
||||
IGuestAccessOpts,
|
||||
@@ -160,7 +169,6 @@ import {
|
||||
import { IAbortablePromise, IdServerUnbindResult, IImageInfo, Preset, Visibility } from "./@types/partials";
|
||||
import { EventMapper, eventMapperFor, MapperOpts } from "./event-mapper";
|
||||
import { randomString } from "./randomstring";
|
||||
import { WebStorageSessionStore } from "./store/session/webstorage";
|
||||
import { BackupManager, IKeyBackup, IKeyBackupCheck, IPreparedKeyBackupVersion, TrustInfo } from "./crypto/backup";
|
||||
import { DEFAULT_TREE_POWER_LEVELS_TEMPLATE, MSC3089TreeSpace } from "./models/MSC3089TreeSpace";
|
||||
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";
|
||||
|
||||
export type Store = IStore;
|
||||
export type SessionStore = WebStorageSessionStore;
|
||||
|
||||
export type Callback<T = any> = (err: Error | any | null, data?: T) => void;
|
||||
export type ResetTimelineCallback = (roomId: string) => boolean;
|
||||
@@ -314,21 +321,6 @@ export interface ICreateClientOpts {
|
||||
*/
|
||||
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>;
|
||||
|
||||
/**
|
||||
@@ -589,13 +581,9 @@ export interface IRequestMsisdnTokenResponse extends IRequestTokenResponse {
|
||||
intl_fmt: string;
|
||||
}
|
||||
|
||||
interface IUploadKeysRequest {
|
||||
export interface IUploadKeysRequest {
|
||||
device_keys?: Required<IDeviceKeys>;
|
||||
one_time_keys?: {
|
||||
[userId: string]: {
|
||||
[deviceId: string]: number;
|
||||
};
|
||||
};
|
||||
one_time_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 {
|
||||
room_id: string;
|
||||
name?: string;
|
||||
@@ -802,6 +803,7 @@ type RoomEvents = RoomEvent.Name
|
||||
| RoomEvent.Receipt
|
||||
| RoomEvent.Tags
|
||||
| RoomEvent.LocalEchoUpdated
|
||||
| RoomEvent.HistoryImportedWithinTimeline
|
||||
| RoomEvent.AccountData
|
||||
| RoomEvent.MyMembership
|
||||
| RoomEvent.Timeline
|
||||
@@ -811,6 +813,7 @@ type RoomStateEvents = RoomStateEvent.Events
|
||||
| RoomStateEvent.Members
|
||||
| RoomStateEvent.NewMember
|
||||
| RoomStateEvent.Update
|
||||
| RoomStateEvent.Marker
|
||||
;
|
||||
|
||||
type CryptoEvents = CryptoEvent.KeySignatureUploadFailure
|
||||
@@ -897,9 +900,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
public clientRunning = false;
|
||||
public timelineSupport = false;
|
||||
public urlPreviewCache: { [key: string]: Promise<IPreviewUrlResponse> } = {};
|
||||
public unstableClientRelationAggregation = false;
|
||||
public identityServer: IIdentityServerProvider;
|
||||
public sessionStore: SessionStore; // 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 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
|
||||
// which has WebRTC. If we are, listen for and handle m.call.* events.
|
||||
const call = createNewMatrixCall(this, undefined, undefined);
|
||||
if (call) {
|
||||
if (supportsMatrixCall()) {
|
||||
this.callEventHandler = new CallEventHandler(this);
|
||||
this.groupCallEventHandler = new GroupCallEventHandler(this);
|
||||
this.canSupportVoip = true;
|
||||
@@ -1036,10 +1034,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
}
|
||||
|
||||
this.timelineSupport = Boolean(opts.timelineSupport);
|
||||
this.unstableClientRelationAggregation = !!opts.unstableClientRelationAggregation;
|
||||
|
||||
this.cryptoStore = opts.cryptoStore;
|
||||
this.sessionStore = opts.sessionStore;
|
||||
this.verificationMethods = opts.verificationMethods;
|
||||
this.cryptoCallbacks = opts.cryptoCallbacks || {};
|
||||
|
||||
@@ -1219,6 +1215,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* clean shutdown.
|
||||
*/
|
||||
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');
|
||||
|
||||
this.clientRunning = false;
|
||||
@@ -1226,7 +1226,6 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
this.syncApi?.stop();
|
||||
this.syncApi = null;
|
||||
|
||||
this.crypto?.stop();
|
||||
this.peekSync?.stopPeeking();
|
||||
|
||||
this.callEventHandler?.stop();
|
||||
@@ -1715,10 +1714,6 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
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) {
|
||||
// the cryptostore is provided by sdk.createClient, so this shouldn't happen
|
||||
throw new Error(`Cannot enable encryption: no cryptoStore provided`);
|
||||
@@ -1747,8 +1742,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
|
||||
const crypto = new Crypto(
|
||||
this,
|
||||
this.sessionStore,
|
||||
userId, this.deviceId,
|
||||
userId,
|
||||
this.deviceId,
|
||||
this.store,
|
||||
this.cryptoStore,
|
||||
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.
|
||||
*
|
||||
* @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
|
||||
* with, or null if it is not present or not encrypted with a trusted
|
||||
* key
|
||||
*/
|
||||
public isSecretStored(name: string, checkKey: boolean): Promise<Record<string, ISecretStorageKeyInfo> | null> {
|
||||
public isSecretStored(name: string): Promise<Record<string, ISecretStorageKeyInfo> | null> {
|
||||
if (!this.crypto) {
|
||||
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
|
||||
*/
|
||||
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} topic
|
||||
* @param {string} htmlTopic Optional.
|
||||
* @param {module:client.callback} callback Optional.
|
||||
* @return {Promise} Resolves: TODO
|
||||
* @return {module:http-api.MatrixError} Rejects: with an error response.
|
||||
*/
|
||||
public setRoomTopic(roomId: string, topic: string, callback?: Callback): Promise<ISendEventResponse> {
|
||||
return this.sendStateEvent(roomId, EventType.RoomTopic, { topic: topic }, undefined, callback);
|
||||
public setRoomTopic(
|
||||
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 {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.
|
||||
*/
|
||||
public getRoomTags(roomId: string, callback?: Callback): Promise<unknown> { // TODO: Types
|
||||
const path = utils.encodeUri("/user/$userId/rooms/$roomId/tags/", {
|
||||
public getRoomTags(roomId: string, callback?: Callback): Promise<ITagsResponse> {
|
||||
const path = utils.encodeUri("/user/$userId/rooms/$roomId/tags", {
|
||||
$userId: this.credentials.userId,
|
||||
$roomId: roomId,
|
||||
});
|
||||
return this.http.authedRequest(
|
||||
callback, Method.Get, path, undefined,
|
||||
);
|
||||
return this.http.authedRequest(callback, Method.Get, path);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -3670,7 +3679,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* @param {string} roomId
|
||||
* @param {string} tagName name of room tag to be removed
|
||||
* @param {module:client.callback} callback Optional.
|
||||
* @return {Promise} Resolves: TODO
|
||||
* @return {Promise} Resolves: void
|
||||
* @return {module:http-api.MatrixError} Rejects: with an error response.
|
||||
*/
|
||||
public deleteRoomTag(roomId: string, tagName: string, callback?: Callback): Promise<void> {
|
||||
@@ -3679,7 +3688,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
$roomId: roomId,
|
||||
$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
|
||||
// we need to add it manually, as well as the reply fallback
|
||||
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"],
|
||||
"rel_type": THREAD_RELATION_TYPE.name,
|
||||
"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);
|
||||
if (thread) {
|
||||
if (thread && !isReply) {
|
||||
content["m.relates_to"]["m.in_reply_to"] = {
|
||||
"event_id": thread.lastReply((ev: MatrixEvent) => {
|
||||
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,
|
||||
};
|
||||
|
||||
let path;
|
||||
let path: string;
|
||||
|
||||
if (event.isState()) {
|
||||
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 {ReceiptType} receiptType other than ReceiptType.Read are experimental! 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.
|
||||
*/
|
||||
public async sendReadReceipt(event: MatrixEvent, receiptType = ReceiptType.Read, callback?: Callback): Promise<{}> {
|
||||
@@ -5031,7 +5043,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
membership: string,
|
||||
reason?: string,
|
||||
callback?: Callback,
|
||||
): Promise<{}> {
|
||||
): Promise<{}> { // API returns an empty object
|
||||
if (utils.isFunction(reason)) {
|
||||
callback = reason as any as Callback; // legacy
|
||||
reason = undefined;
|
||||
@@ -5172,12 +5184,12 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* @return {Promise} Resolves: The presence state for this user.
|
||||
* @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", {
|
||||
$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).
|
||||
*
|
||||
* @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.
|
||||
* @param {module:client.callback} callback Optional.
|
||||
* @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
|
||||
* corresponding timeline will be returned. Otherwise, a /context request is
|
||||
* 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
|
||||
*
|
||||
* @return {Promise} Resolves:
|
||||
* {@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.
|
||||
if (!this.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.
|
||||
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) {
|
||||
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 event = mapper(res.event);
|
||||
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.
|
||||
...res.events_after.reverse().map(mapper),
|
||||
event,
|
||||
...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
|
||||
// functions contiguously, so we have to jump through some hoops to get our target event in it.
|
||||
// XXX: workaround for https://github.com/vector-im/element-meta/issues/150
|
||||
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);
|
||||
if (this.supportsExperimentalThreads()) {
|
||||
if (!timelineSet.canContain(event)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const opts: IRelationsRequestOpts = {
|
||||
direction: Direction.Backward,
|
||||
limit: 50,
|
||||
};
|
||||
// Where the event is a thread reply (not a root) and running in MSC-enabled mode the Thread timeline only
|
||||
// functions contiguously, so we have to jump through some hoops to get our target event in it.
|
||||
// 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();
|
||||
let nextBatch = thread.liveTimeline.getPaginationToken(Direction.Backward);
|
||||
await thread.fetchInitialEvents();
|
||||
let nextBatch = thread.liveTimeline.getPaginationToken(Direction.Backward);
|
||||
|
||||
// Fetch events until we find the one we were asked for, or we run out of pages
|
||||
while (!thread.findEventById(eventId)) {
|
||||
if (nextBatch) {
|
||||
opts.from = nextBatch;
|
||||
// Fetch events until we find the one we were asked for, or we run out of pages
|
||||
while (!thread.findEventById(eventId)) {
|
||||
if (nextBatch) {
|
||||
opts.from = nextBatch;
|
||||
}
|
||||
|
||||
({ nextBatch } = await thread.fetchEvents(opts));
|
||||
if (!nextBatch) break;
|
||||
}
|
||||
|
||||
({ nextBatch } = await thread.fetchEvents(opts));
|
||||
if (!nextBatch) break;
|
||||
return thread.liveTimeline;
|
||||
}
|
||||
|
||||
return thread.liveTimeline;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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;
|
||||
}
|
||||
|
||||
promise = this.http.authedRequest<any>( // TODO types
|
||||
undefined, Method.Get, path, params, undefined,
|
||||
promise = this.http.authedRequest<INotificationsResponse>(
|
||||
undefined, Method.Get, path, params,
|
||||
).then(async (res) => {
|
||||
const token = res.next_token;
|
||||
const matrixEvents = [];
|
||||
@@ -5880,11 +5931,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
const idServerUrl = new URL(this.idBaseUrl);
|
||||
postParams.id_server = idServerUrl.host;
|
||||
|
||||
if (
|
||||
this.identityServer &&
|
||||
this.identityServer.getAccessToken &&
|
||||
await this.doesServerAcceptIdentityAccessToken()
|
||||
) {
|
||||
if (this.identityServer?.getAccessToken && await this.doesServerAcceptIdentityAccessToken()) {
|
||||
const identityAccessToken = await this.identityServer.getAccessToken();
|
||||
if (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.
|
||||
* @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
|
||||
// and its id is the room id.
|
||||
if (this.pushRules) {
|
||||
@@ -5928,15 +5975,13 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* @return {module:http-api.MatrixError} Rejects: with an error response.
|
||||
*/
|
||||
public setRoomMutePushRule(scope: string, roomId: string, mute: boolean): Promise<void> | void {
|
||||
let promise: Promise<void>;
|
||||
let promise: Promise<unknown>;
|
||||
let hasDontNotifyRule = false;
|
||||
|
||||
// Get the existing room-kind push rule if any
|
||||
const roomPushRule = this.getRoomPushRule(scope, roomId);
|
||||
if (roomPushRule) {
|
||||
if (0 <= roomPushRule.actions.indexOf("dont_notify")) {
|
||||
hasDontNotifyRule = true;
|
||||
}
|
||||
if (roomPushRule?.actions.includes(PushRuleActionName.DontNotify)) {
|
||||
hasDontNotifyRule = true;
|
||||
}
|
||||
|
||||
if (!mute) {
|
||||
@@ -5947,24 +5992,23 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
} else {
|
||||
if (!roomPushRule) {
|
||||
promise = this.addPushRule(scope, PushRuleKind.RoomSpecific, roomId, {
|
||||
actions: ["dont_notify"],
|
||||
actions: [PushRuleActionName.DontNotify],
|
||||
});
|
||||
} else if (!hasDontNotifyRule) {
|
||||
// Remove the existing one before setting the mute push rule
|
||||
// This is a workaround to SYN-590 (Push rule update fails)
|
||||
const deferred = utils.defer();
|
||||
this.deletePushRule(scope, PushRuleKind.RoomSpecific, roomPushRule.rule_id)
|
||||
.then(() => {
|
||||
this.addPushRule(scope, PushRuleKind.RoomSpecific, roomId, {
|
||||
actions: ["dont_notify"],
|
||||
}).then(() => {
|
||||
deferred.resolve();
|
||||
}).catch((err) => {
|
||||
deferred.reject(err);
|
||||
});
|
||||
this.deletePushRule(scope, PushRuleKind.RoomSpecific, roomPushRule.rule_id).then(() => {
|
||||
this.addPushRule(scope, PushRuleKind.RoomSpecific, roomId, {
|
||||
actions: [PushRuleActionName.DontNotify],
|
||||
}).then(() => {
|
||||
deferred.resolve();
|
||||
}).catch((err) => {
|
||||
deferred.reject(err);
|
||||
});
|
||||
}).catch((err) => {
|
||||
deferred.reject(err);
|
||||
});
|
||||
|
||||
promise = deferred.promise;
|
||||
}
|
||||
@@ -6176,15 +6220,13 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
const path = utils.encodeUri("/user/$userId/filter", {
|
||||
$userId: this.credentials.userId,
|
||||
});
|
||||
// TODO types
|
||||
return this.http.authedRequest<any>(undefined, Method.Post, path, undefined, content).then((response) => {
|
||||
// persist the filter
|
||||
const filter = Filter.fromJson(
|
||||
this.credentials.userId, response.filter_id, content,
|
||||
);
|
||||
this.store.storeFilter(filter);
|
||||
return filter;
|
||||
});
|
||||
return this.http.authedRequest<IFilterResponse>(undefined, Method.Post, path, undefined, content)
|
||||
.then((response) => {
|
||||
// persist the filter
|
||||
const filter = Filter.fromJson(this.credentials.userId, response.filter_id, content);
|
||||
this.store.storeFilter(filter);
|
||||
return filter;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -6209,9 +6251,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
$filterId: filterId,
|
||||
});
|
||||
|
||||
return this.http.authedRequest<IFilterDefinition>(
|
||||
undefined, Method.Get, path, undefined, undefined,
|
||||
).then((response) => {
|
||||
return this.http.authedRequest<IFilterDefinition>(undefined, Method.Get, path).then((response) => {
|
||||
// persist the filter
|
||||
const filter = Filter.fromJson(userId, filterId, response);
|
||||
this.store.storeFilter(filter);
|
||||
@@ -6607,8 +6647,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
const versions = response["versions"];
|
||||
const unstableFeatures = response["unstable_features"];
|
||||
|
||||
return (versions && versions.includes("r0.6.0"))
|
||||
|| (unstableFeatures && unstableFeatures["m.separate_add_and_bind"]);
|
||||
return versions?.includes("r0.6.0") || unstableFeatures?.["m.separate_add_and_bind"];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -6910,7 +6949,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
guestAccessToken?: string,
|
||||
inhibitLogin?: boolean,
|
||||
callback?: Callback,
|
||||
): Promise<any> { // TODO: Types (many)
|
||||
): Promise<IAuthData> {
|
||||
// backwards compat
|
||||
if (bindThreepids === true) {
|
||||
bindThreepids = { email: true };
|
||||
@@ -6926,7 +6965,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
auth.session = sessionId;
|
||||
}
|
||||
|
||||
const params: any = {
|
||||
const params: IRegisterRequestParams = {
|
||||
auth: auth,
|
||||
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 {module:http-api.MatrixError} Rejects: with an error response.
|
||||
*/
|
||||
public registerRequest(data: any, kind?: string, callback?: Callback): Promise<any> { // TODO: Types
|
||||
const params: any = {};
|
||||
public registerRequest(data: IRegisterRequestParams, kind?: string, callback?: Callback): Promise<IAuthData> {
|
||||
const params: { kind?: string } = {};
|
||||
if (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
|
||||
* this method succeeds.
|
||||
* @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()) {
|
||||
try {
|
||||
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(
|
||||
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.
|
||||
* @param {string} alias The room alias to delete.
|
||||
* @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.
|
||||
*/
|
||||
public deleteAlias(alias: string, callback?: Callback): Promise<{}> {
|
||||
const path = utils.encodeUri("/directory/room/$alias", {
|
||||
$alias: alias,
|
||||
});
|
||||
return this.http.authedRequest(callback, Method.Delete, path, undefined, undefined);
|
||||
return this.http.authedRequest(callback, Method.Delete, path);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} roomId
|
||||
* @param {module:client.callback} callback Optional.
|
||||
* Gets the local aliases for the room. Note: this includes all local aliases, unlike the
|
||||
* 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 {module:http-api.MatrixError} Rejects: with an error response.
|
||||
*/
|
||||
public unstableGetLocalAliases(roomId: string, callback?: Callback): Promise<{ aliases: string[] }> {
|
||||
const path = utils.encodeUri("/rooms/$roomId/aliases",
|
||||
{ $roomId: roomId });
|
||||
const prefix = PREFIX_UNSTABLE + "/org.matrix.msc2432";
|
||||
return this.http.authedRequest(callback, Method.Get, path, null, null, { prefix });
|
||||
public getLocalAliases(roomId: string): Promise<{ aliases: string[] }> {
|
||||
const path = utils.encodeUri("/rooms/$roomId/aliases", { $roomId: roomId });
|
||||
const prefix = PREFIX_V3;
|
||||
return this.http.authedRequest(undefined, 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
|
||||
* it invisible.
|
||||
* @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.
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
public getThreePids(callback?: Callback): Promise<{ threepids: IThreepid[] }> {
|
||||
const path = "/account/3pid";
|
||||
return this.http.authedRequest(callback, Method.Get, path, undefined, undefined);
|
||||
return this.http.authedRequest(callback, Method.Get, "/account/3pid");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -7836,7 +7880,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
*
|
||||
* @param {Object} data A object with 3PID validation data from having called
|
||||
* `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.
|
||||
*/
|
||||
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
|
||||
* `validate/<medium>/requestToken` on the identity server. It should also
|
||||
* 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.
|
||||
*/
|
||||
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 {boolean} logoutDevices Should all sessions be logged out after the password change. Defaults to true.
|
||||
* @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.
|
||||
*/
|
||||
public setPassword(
|
||||
@@ -7964,7 +8008,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* @return {module:http-api.MatrixError} Rejects: with an error response.
|
||||
*/
|
||||
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", {
|
||||
$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 {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.
|
||||
*/
|
||||
// eslint-disable-next-line camelcase
|
||||
@@ -8005,7 +8049,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* @return {Promise} Resolves: result object
|
||||
* @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", {
|
||||
$device_id: deviceId,
|
||||
});
|
||||
@@ -8027,7 +8071,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* @return {Promise} Resolves: result object
|
||||
* @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 };
|
||||
|
||||
if (auth) {
|
||||
@@ -8046,8 +8090,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* @return {module:http-api.MatrixError} Rejects: with an error response.
|
||||
*/
|
||||
public getPushers(callback?: Callback): Promise<{ pushers: IPusher[] }> {
|
||||
const path = "/pushers";
|
||||
return this.http.authedRequest(callback, Method.Get, path, undefined, undefined);
|
||||
return this.http.authedRequest(callback, Method.Get, "/pushers");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -8088,9 +8131,9 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
scope: string,
|
||||
kind: PushRuleKind,
|
||||
ruleId: Exclude<string, RuleId>,
|
||||
body: any,
|
||||
body: Pick<IPushRule, "actions" | "conditions" | "pattern">,
|
||||
callback?: Callback,
|
||||
): Promise<any> { // TODO: Types
|
||||
): Promise<{}> {
|
||||
// NB. Scope not uri encoded because devices need the '/'
|
||||
const path = utils.encodeUri("/pushrules/" + scope + "/$kind/$ruleId", {
|
||||
$kind: kind,
|
||||
@@ -8112,7 +8155,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
kind: PushRuleKind,
|
||||
ruleId: Exclude<string, RuleId>,
|
||||
callback?: Callback,
|
||||
): Promise<any> { // TODO: Types
|
||||
): Promise<{}> {
|
||||
// NB. Scope not uri encoded because devices need the '/'
|
||||
const path = utils.encodeUri("/pushrules/" + scope + "/$kind/$ruleId", {
|
||||
$kind: kind,
|
||||
@@ -8128,7 +8171,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* @param {string} ruleId
|
||||
* @param {boolean} enabled
|
||||
* @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.
|
||||
*/
|
||||
public setPushRuleEnabled(
|
||||
@@ -8154,7 +8197,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* @param {string} ruleId
|
||||
* @param {array} actions
|
||||
* @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.
|
||||
*/
|
||||
public setPushRuleActions(
|
||||
@@ -8311,11 +8354,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
to: newToken,
|
||||
};
|
||||
|
||||
const path = "/keys/changes";
|
||||
return this.http.authedRequest(undefined, Method.Get, path, qps, undefined);
|
||||
return this.http.authedRequest(undefined, Method.Get, "/keys/changes", qps);
|
||||
}
|
||||
|
||||
public uploadDeviceSigningKeys(auth?: IAuthData, keys?: CrossSigningKeys): Promise<{}> {
|
||||
public uploadDeviceSigningKeys(auth?: IAuthData, keys?: CrossSigningKeys): Promise<{}> { // API returns empty object
|
||||
const data = Object.assign({}, keys);
|
||||
if (auth) Object.assign(data, { auth });
|
||||
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.
|
||||
* @param {string=} txnId transaction id. One will be made up if not
|
||||
* supplied.
|
||||
* @return {Promise} Resolves to the result object
|
||||
* @return {Promise} Resolves: to an empty object {}
|
||||
*/
|
||||
public sendToDevice(
|
||||
eventType: string,
|
||||
@@ -8757,7 +8799,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
*/
|
||||
public getThirdpartyProtocols(): Promise<{ [protocol: string]: IProtocol }> {
|
||||
return this.http.authedRequest<Record<string, IProtocol>>(
|
||||
undefined, Method.Get, "/thirdparty/protocols", undefined, undefined,
|
||||
undefined, Method.Get, "/thirdparty/protocols",
|
||||
).then((response) => {
|
||||
// sanity check
|
||||
if (!response || typeof (response) !== 'object') {
|
||||
@@ -8783,7 +8825,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
$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,
|
||||
});
|
||||
|
||||
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
|
||||
|
||||
@@ -16,7 +16,7 @@ limitations under the License.
|
||||
|
||||
/** @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 { MsgType } from "./@types/event";
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
MAssetContent,
|
||||
LegacyLocationEventContent,
|
||||
} from "./@types/location";
|
||||
import { MRoomTopicEventContent, MTopicContent, M_TOPIC } from "./@types/topic";
|
||||
|
||||
/**
|
||||
* Generates the content for a HTML Message event
|
||||
@@ -138,10 +139,10 @@ export const getTextForLocationEvent = (
|
||||
/**
|
||||
* Generates the content for a Location event
|
||||
* @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)
|
||||
* @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
|
||||
*/
|
||||
export const makeLocationContent = (
|
||||
@@ -190,6 +191,34 @@ export const parseLocationEvent = (wireEventContent: LocationEventWireContent):
|
||||
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
|
||||
*/
|
||||
|
||||
@@ -171,7 +171,7 @@ export class CrossSigningInfo {
|
||||
*/
|
||||
public async isStoredInSecretStorage(secretStorage: SecretStorage): Promise<Record<string, object>> {
|
||||
// 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
|
||||
function intersect(s: Record<string, ISecretStorageKeyInfo>) {
|
||||
for (const k of Object.keys(stored)) {
|
||||
@@ -181,7 +181,7 @@ export class CrossSigningInfo {
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -942,7 +942,7 @@ async function updateStoredDeviceKeysForUser(
|
||||
async function storeDeviceKeys(
|
||||
olmDevice: OlmDevice,
|
||||
userStore: Record<string, DeviceInfo>,
|
||||
deviceResult: any, // TODO types
|
||||
deviceResult: IDownloadKeyResult["device_keys"]["user_id"]["device_id"],
|
||||
): Promise<boolean> {
|
||||
if (!deviceResult.keys) {
|
||||
// no keys?
|
||||
|
||||
@@ -92,7 +92,7 @@ export interface InboundGroupSessionData {
|
||||
sharedHistory?: boolean;
|
||||
}
|
||||
|
||||
interface IDecryptedGroupMessage {
|
||||
export interface IDecryptedGroupMessage {
|
||||
result: string;
|
||||
keysClaimed: Record<string, string>;
|
||||
senderKey: string;
|
||||
@@ -100,6 +100,11 @@ interface IDecryptedGroupMessage {
|
||||
untrusted: boolean;
|
||||
}
|
||||
|
||||
export interface IInboundSession {
|
||||
payload: string;
|
||||
session_id: string;
|
||||
}
|
||||
|
||||
export interface IExportedDevice {
|
||||
pickleKey: string;
|
||||
pickledAccount: string;
|
||||
@@ -620,7 +625,7 @@ export class OlmDevice {
|
||||
theirDeviceIdentityKey: string,
|
||||
messageType: number,
|
||||
ciphertext: string,
|
||||
): Promise<{ payload: string, session_id: string }> { // eslint-disable-line camelcase
|
||||
): Promise<IInboundSession> {
|
||||
if (messageType !== 0) {
|
||||
throw new Error("Need messageType == 0 to create inbound session");
|
||||
}
|
||||
|
||||
@@ -339,13 +339,12 @@ export class SecretStorage {
|
||||
* Check if a secret is stored on the server.
|
||||
*
|
||||
* @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
|
||||
* with, or null if it is not present or not encrypted with a trusted
|
||||
* 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
|
||||
const secretInfo = await this.accountDataAdapter.getAccountDataFromServer<ISecretInfo>(name);
|
||||
if (!secretInfo?.encrypted) return null;
|
||||
|
||||
@@ -179,7 +179,7 @@ export abstract class DecryptionAlgorithm {
|
||||
*
|
||||
* @param {module:models/event.MatrixEvent} params event key event
|
||||
*/
|
||||
public onRoomKeyEvent(params: MatrixEvent): void {
|
||||
public async onRoomKeyEvent(params: MatrixEvent): Promise<void> {
|
||||
// ignore by default
|
||||
}
|
||||
|
||||
|
||||
@@ -30,13 +30,13 @@ import {
|
||||
registerAlgorithm,
|
||||
UnknownDeviceError,
|
||||
} from "./base";
|
||||
import { WITHHELD_MESSAGES } from '../OlmDevice';
|
||||
import { IDecryptedGroupMessage, WITHHELD_MESSAGES } from '../OlmDevice';
|
||||
import { Room } from '../../models/room';
|
||||
import { DeviceInfo } from "../deviceinfo";
|
||||
import { IOlmSessionResult } from "../olmlib";
|
||||
import { DeviceInfoMap } from "../DeviceList";
|
||||
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
|
||||
export function isRoomSharedHistory(room: Room): boolean {
|
||||
@@ -100,12 +100,6 @@ interface IPayload extends Partial<IMessage> {
|
||||
algorithm?: string;
|
||||
sender_key?: string;
|
||||
}
|
||||
|
||||
interface IEncryptedContent {
|
||||
algorithm: string;
|
||||
sender_key: string;
|
||||
ciphertext: Record<string, string>;
|
||||
}
|
||||
/* eslint-enable camelcase */
|
||||
|
||||
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
|
||||
// with an OutboundSessionInfo (or undefined, for the first message in the
|
||||
// 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
|
||||
// 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 sessionRotationPeriodMs: number;
|
||||
private encryptionPreparation: Promise<void>;
|
||||
private encryptionPreparationMetadata: {
|
||||
private encryptionPreparation?: {
|
||||
promise: Promise<void>;
|
||||
startTime: number;
|
||||
};
|
||||
|
||||
@@ -270,193 +266,209 @@ class MegolmEncryption extends EncryptionAlgorithm {
|
||||
blocked: IBlockedMap,
|
||||
singleOlmCreationPhase = false,
|
||||
): Promise<OutboundSessionInfo> {
|
||||
let session: OutboundSessionInfo;
|
||||
|
||||
// takes the previous OutboundSessionInfo, and considers whether to create
|
||||
// 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.
|
||||
const prepareSession = async (oldSession: OutboundSessionInfo) => {
|
||||
session = oldSession;
|
||||
|
||||
const setup = async (oldSession: OutboundSessionInfo | null): Promise<OutboundSessionInfo> => {
|
||||
const sharedHistory = isRoomSharedHistory(room);
|
||||
|
||||
// history visibility changed
|
||||
if (session && sharedHistory !== session.sharedHistory) {
|
||||
session = null;
|
||||
const session = await this.prepareSession(devicesInRoom, sharedHistory, oldSession);
|
||||
|
||||
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?
|
||||
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);
|
||||
})(),
|
||||
]);
|
||||
return session;
|
||||
};
|
||||
|
||||
// helper which returns the session prepared by prepareSession
|
||||
function returnSession() {
|
||||
return session;
|
||||
}
|
||||
|
||||
// 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
|
||||
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
|
||||
this.setupPromise = prom.then(returnSession, returnSession);
|
||||
this.setupPromise = prom;
|
||||
|
||||
// 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(
|
||||
userDeviceMap,
|
||||
payload,
|
||||
).then((result) => {
|
||||
const { contentMap, deviceInfoByDeviceId } = result;
|
||||
).then(({ contentMap, deviceInfoByUserIdAndDeviceId }) => {
|
||||
// store that we successfully uploaded the keys of the current slice
|
||||
for (const userId of Object.keys(contentMap)) {
|
||||
for (const deviceId of Object.keys(contentMap[userId])) {
|
||||
session.markSharedWithDevice(
|
||||
userId,
|
||||
deviceId,
|
||||
deviceInfoByDeviceId.get(deviceId).getIdentityKey(),
|
||||
deviceInfoByUserIdAndDeviceId.get(userId).get(deviceId).getIdentityKey(),
|
||||
chainIndex,
|
||||
);
|
||||
}
|
||||
@@ -798,7 +809,7 @@ class MegolmEncryption extends EncryptionAlgorithm {
|
||||
logger.debug(`Ensuring Olm sessions for devices in ${this.roomId}`);
|
||||
const devicemap = await olmlib.ensureOlmSessionsForDevices(
|
||||
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}`);
|
||||
|
||||
@@ -938,11 +949,11 @@ class MegolmEncryption extends EncryptionAlgorithm {
|
||||
* @param {module:models/room} room the room the event is in
|
||||
*/
|
||||
public prepareToEncrypt(room: Room): void {
|
||||
if (this.encryptionPreparation) {
|
||||
if (this.encryptionPreparation != null) {
|
||||
// We're already preparing something, so don't do anything else.
|
||||
// FIXME: check if we need to restart
|
||||
// (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(
|
||||
`Already started preparing to encrypt for ${this.roomId} ` +
|
||||
`${elapsedTime} ms ago, skipping`,
|
||||
@@ -952,32 +963,31 @@ class MegolmEncryption extends EncryptionAlgorithm {
|
||||
|
||||
logger.debug(`Preparing to encrypt events for ${this.roomId}`);
|
||||
|
||||
this.encryptionPreparationMetadata = {
|
||||
this.encryptionPreparation = {
|
||||
startTime: Date.now(),
|
||||
};
|
||||
this.encryptionPreparation = (async () => {
|
||||
try {
|
||||
logger.debug(`Getting devices in ${this.roomId}`);
|
||||
const [devicesInRoom, blocked] = await this.getDevicesInRoom(room);
|
||||
promise: (async () => {
|
||||
try {
|
||||
logger.debug(`Getting devices in ${this.roomId}`);
|
||||
const [devicesInRoom, blocked] = await this.getDevicesInRoom(room);
|
||||
|
||||
if (this.crypto.getGlobalErrorOnUnknownDevices()) {
|
||||
// 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
|
||||
// devices.
|
||||
this.removeUnknownDevices(devicesInRoom);
|
||||
if (this.crypto.getGlobalErrorOnUnknownDevices()) {
|
||||
// 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
|
||||
// devices.
|
||||
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> {
|
||||
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.
|
||||
// FIXME: check if we need to cancel
|
||||
// (https://github.com/matrix-org/matrix-js-sdk/issues/1255)
|
||||
try {
|
||||
await this.encryptionPreparation;
|
||||
await this.encryptionPreparation.promise;
|
||||
} catch (e) {
|
||||
// ignore any errors -- if the preparation failed, we'll just
|
||||
// restart everything here
|
||||
@@ -1212,7 +1222,7 @@ class MegolmDecryption extends DecryptionAlgorithm {
|
||||
// (fixes https://github.com/vector-im/element-web/issues/5001)
|
||||
this.addEventToPendingList(event);
|
||||
|
||||
let res;
|
||||
let res: IDecryptedGroupMessage;
|
||||
try {
|
||||
res = await this.olmDevice.decryptGroupMessage(
|
||||
event.getRoomId(), content.sender_key, content.session_id, content.ciphertext,
|
||||
@@ -1242,7 +1252,9 @@ class MegolmDecryption extends DecryptionAlgorithm {
|
||||
|
||||
if (res === null) {
|
||||
// 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
|
||||
// decrypting, in which case we'll have scheduled a retry, and this
|
||||
// request will be redundant. We could probably check to see if the
|
||||
@@ -1335,7 +1347,7 @@ class MegolmDecryption extends DecryptionAlgorithm {
|
||||
if (!senderPendingEvents.has(sessionId)) {
|
||||
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
|
||||
*/
|
||||
public onRoomKeyEvent(event: MatrixEvent): Promise<void> {
|
||||
const content = event.getContent();
|
||||
const sessionId = content.session_id;
|
||||
public async onRoomKeyEvent(event: MatrixEvent): Promise<void> {
|
||||
const content = event.getContent<Partial<IMessage["content"]>>();
|
||||
let senderKey = event.getSenderKey();
|
||||
let forwardingKeyChain = [];
|
||||
let forwardingKeyChain: string[] = [];
|
||||
let exportFormat = false;
|
||||
let keysClaimed;
|
||||
let keysClaimed: ReturnType<MatrixEvent["getKeysClaimed"]>;
|
||||
|
||||
if (!content.room_id ||
|
||||
!sessionId ||
|
||||
!content.session_key
|
||||
!content.session_key ||
|
||||
!content.session_id ||
|
||||
!content.algorithm
|
||||
) {
|
||||
logger.error("key event is missing fields");
|
||||
return;
|
||||
@@ -1392,20 +1404,18 @@ class MegolmDecryption extends DecryptionAlgorithm {
|
||||
|
||||
if (event.getType() == "m.forwarded_room_key") {
|
||||
exportFormat = true;
|
||||
forwardingKeyChain = content.forwarding_curve25519_key_chain;
|
||||
if (!Array.isArray(forwardingKeyChain)) {
|
||||
forwardingKeyChain = [];
|
||||
}
|
||||
forwardingKeyChain = Array.isArray(content.forwarding_curve25519_key_chain) ?
|
||||
content.forwarding_curve25519_key_chain : [];
|
||||
|
||||
// copy content before we modify it
|
||||
forwardingKeyChain = forwardingKeyChain.slice();
|
||||
forwardingKeyChain.push(senderKey);
|
||||
|
||||
senderKey = content.sender_key;
|
||||
if (!senderKey) {
|
||||
if (!content.sender_key) {
|
||||
logger.error("forwarded_room_key event is missing sender_key field");
|
||||
return;
|
||||
}
|
||||
senderKey = content.sender_key;
|
||||
|
||||
const ed25519Key = content.sender_claimed_ed25519_key;
|
||||
if (!ed25519Key) {
|
||||
@@ -1426,34 +1436,39 @@ class MegolmDecryption extends DecryptionAlgorithm {
|
||||
if (content["org.matrix.msc3061.shared_history"]) {
|
||||
extraSessionData.sharedHistory = true;
|
||||
}
|
||||
return this.olmDevice.addInboundGroupSession(
|
||||
content.room_id, senderKey, forwardingKeyChain, sessionId,
|
||||
content.session_key, keysClaimed,
|
||||
exportFormat, extraSessionData,
|
||||
).then(() => {
|
||||
|
||||
try {
|
||||
await this.olmDevice.addInboundGroupSession(
|
||||
content.room_id,
|
||||
senderKey,
|
||||
forwardingKeyChain,
|
||||
content.session_id,
|
||||
content.session_key,
|
||||
keysClaimed,
|
||||
exportFormat,
|
||||
extraSessionData,
|
||||
);
|
||||
|
||||
// have another go at decrypting events sent with this session.
|
||||
this.retryDecryption(senderKey, sessionId)
|
||||
.then((success) => {
|
||||
// cancel any outstanding room key requests for this session.
|
||||
// Only do this if we managed to decrypt every message in the
|
||||
// session, because if we didn't, we leave the other key
|
||||
// requests in the hopes that someone sends us a key that
|
||||
// includes an earlier index.
|
||||
if (success) {
|
||||
this.crypto.cancelRoomKeyRequest({
|
||||
algorithm: content.algorithm,
|
||||
room_id: content.room_id,
|
||||
session_id: content.session_id,
|
||||
sender_key: senderKey,
|
||||
});
|
||||
}
|
||||
if (await this.retryDecryption(senderKey, content.session_id)) {
|
||||
// cancel any outstanding room key requests for this session.
|
||||
// Only do this if we managed to decrypt every message in the
|
||||
// session, because if we didn't, we leave the other key
|
||||
// requests in the hopes that someone sends us a key that
|
||||
// includes an earlier index.
|
||||
this.crypto.cancelRoomKeyRequest({
|
||||
algorithm: content.algorithm,
|
||||
room_id: content.room_id,
|
||||
session_id: content.session_id,
|
||||
sender_key: senderKey,
|
||||
});
|
||||
}).then(() => {
|
||||
}
|
||||
|
||||
// don't wait for the keys to be backed up for the server
|
||||
this.crypto.backupManager.backupGroupSession(senderKey, content.session_id);
|
||||
}).catch((e) => {
|
||||
await this.crypto.backupManager.backupGroupSession(senderKey, content.session_id);
|
||||
} catch (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 {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 = {};
|
||||
if (opts.untrusted || session.untrusted) {
|
||||
extraSessionData.untrusted = true;
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
import { Room } from '../../models/room';
|
||||
import { MatrixEvent } from "../..";
|
||||
import { IEventDecryptionResult } from "../index";
|
||||
import { IInboundSession } from "../OlmDevice";
|
||||
|
||||
const DeviceVerification = DeviceInfo.DeviceVerification;
|
||||
|
||||
@@ -51,7 +52,7 @@ interface IMessage {
|
||||
*/
|
||||
class OlmEncryption extends EncryptionAlgorithm {
|
||||
private sessionPrepared = false;
|
||||
private prepPromise: Promise<void> = null;
|
||||
private prepPromise: Promise<void> | null = null;
|
||||
|
||||
/**
|
||||
* @private
|
||||
@@ -116,11 +117,11 @@ class OlmEncryption extends EncryptionAlgorithm {
|
||||
ciphertext: {},
|
||||
};
|
||||
|
||||
const promises = [];
|
||||
const promises: Promise<void>[] = [];
|
||||
|
||||
for (let i = 0; i < users.length; ++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) {
|
||||
const deviceInfo = devices[j];
|
||||
@@ -239,7 +240,7 @@ class OlmDecryption extends DecryptionAlgorithm {
|
||||
throw new DecryptionError(
|
||||
"OLM_BAD_ROOM",
|
||||
"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
|
||||
// session.
|
||||
|
||||
let res;
|
||||
let res: IInboundSession;
|
||||
try {
|
||||
res = await this.olmDevice.createInboundSession(
|
||||
theirDeviceIdentityKey, message.type, message.body,
|
||||
|
||||
@@ -35,6 +35,7 @@ import { UnstableValue } from "../NamespacedValue";
|
||||
import { CryptoEvent, IMegolmSessionData } from "./index";
|
||||
|
||||
const KEY_BACKUP_KEYS_PER_REQUEST = 200;
|
||||
const KEY_BACKUP_CHECK_RATE_LIMIT = 5000; // ms
|
||||
|
||||
type AuthData = IKeyBackupInfo["auth_data"];
|
||||
|
||||
@@ -111,6 +112,8 @@ export class BackupManager {
|
||||
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?
|
||||
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) {
|
||||
this.checkedForBackup = false;
|
||||
this.sendingBackups = false;
|
||||
@@ -282,6 +285,26 @@ export class BackupManager {
|
||||
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.
|
||||
*
|
||||
@@ -428,7 +451,7 @@ export class BackupManager {
|
||||
// requests from different clients hitting the server all at
|
||||
// the same time when a new key is sent
|
||||
const delay = Math.random() * maxDelay;
|
||||
await sleep(delay, undefined);
|
||||
await sleep(delay);
|
||||
let numFailures = 0; // number of consecutive failures
|
||||
for (;;) {
|
||||
if (!this.algorithm) {
|
||||
@@ -462,7 +485,7 @@ export class BackupManager {
|
||||
}
|
||||
if (numFailures) {
|
||||
// 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 {
|
||||
@@ -474,8 +497,8 @@ export class BackupManager {
|
||||
* Take some e2e keys waiting to be backed up and send them
|
||||
* to the backup.
|
||||
*
|
||||
* @param {integer} limit Maximum number of keys to back up
|
||||
* @returns {integer} Number of sessions backed up
|
||||
* @param {number} limit Maximum number of keys to back up
|
||||
* @returns {number} Number of sessions backed up
|
||||
*/
|
||||
public async backupPendingKeys(limit: number): Promise<number> {
|
||||
const sessions = await this.baseApis.crypto.cryptoStore.getSessionsNeedingBackup(limit);
|
||||
|
||||
@@ -59,7 +59,7 @@ import { keyFromPassphrase } from './key_passphrase';
|
||||
import { decodeRecoveryKey, encodeRecoveryKey } from './recoverykey';
|
||||
import { VerificationRequest } from "./verification/request/VerificationRequest";
|
||||
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 { KeySignatureUploadError } from "../errors";
|
||||
import { calculateKeyCheck, decryptAES, encryptAES } from './aes';
|
||||
@@ -76,7 +76,6 @@ import {
|
||||
ISignedKey,
|
||||
IUploadKeySignaturesResponse,
|
||||
MatrixClient,
|
||||
SessionStore,
|
||||
} from "../client";
|
||||
import type { IRoomEncryption, RoomList } from "./RoomList";
|
||||
import { IKeyBackupInfo } from "./keybackup";
|
||||
@@ -122,7 +121,7 @@ interface IInitOpts {
|
||||
|
||||
export interface IBootstrapCrossSigningOpts {
|
||||
setupNewCrossSigning?: boolean;
|
||||
authUploadDeviceSigningKeys?(makeRequest: (authData: any) => {}): Promise<void>;
|
||||
authUploadDeviceSigningKeys?(makeRequest: (authData: any) => Promise<{}>): Promise<void>;
|
||||
}
|
||||
|
||||
/* eslint-disable camelcase */
|
||||
@@ -203,6 +202,19 @@ export interface IRequestsMap {
|
||||
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 {
|
||||
DeviceVerificationChanged = "deviceVerificationChanged",
|
||||
UserTrustStatusChanged = "userTrustStatusChanged",
|
||||
@@ -324,9 +336,6 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
*
|
||||
* @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} deviceId The identifier for this device.
|
||||
@@ -344,7 +353,6 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
*/
|
||||
constructor(
|
||||
public readonly baseApis: MatrixClient,
|
||||
public readonly sessionStore: SessionStore,
|
||||
public readonly userId: string,
|
||||
private readonly deviceId: string,
|
||||
private readonly clientStore: IStore,
|
||||
@@ -1077,11 +1085,8 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
return this.secretStorage.get(name);
|
||||
}
|
||||
|
||||
public isSecretStored(
|
||||
name: string,
|
||||
checkKey?: boolean,
|
||||
): Promise<Record<string, ISecretStorageKeyInfo> | null> {
|
||||
return this.secretStorage.isStored(name, checkKey);
|
||||
public isSecretStored(name: string): Promise<Record<string, ISecretStorageKeyInfo> | null> {
|
||||
return this.secretStorage.isStored(name);
|
||||
}
|
||||
|
||||
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}`);
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -2322,8 +2320,8 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
userId: string,
|
||||
deviceId: string,
|
||||
transactionId: string = null,
|
||||
): any { // TODO types
|
||||
let request;
|
||||
): VerificationBase<any, any> {
|
||||
let request: Request;
|
||||
if (transactionId) {
|
||||
request = this.toDeviceVerificationRequests.getRequestBySenderAndTxnId(userId, transactionId);
|
||||
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
|
||||
// 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.
|
||||
let storeConfigPromise = null;
|
||||
let storeConfigPromise: Promise<void> = null;
|
||||
if (!existingConfig) {
|
||||
storeConfigPromise = this.roomList.setRoomEncryption(roomId, config);
|
||||
}
|
||||
@@ -3131,36 +3129,40 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
public encryptAndSendToDevices(
|
||||
userDeviceInfoArr: IOlmDevice<DeviceInfo>[],
|
||||
payload: object,
|
||||
): Promise<{contentMap, deviceInfoByDeviceId}> {
|
||||
const contentMap = {};
|
||||
const deviceInfoByDeviceId = new Map<string, DeviceInfo>();
|
||||
): Promise<IEncryptAndSendToDevicesResult> {
|
||||
const contentMap: Record<string, Record<string, IEncryptedContent>> = {};
|
||||
const deviceInfoByUserIdAndDeviceId = new Map<string, Map<string, DeviceInfo>>();
|
||||
|
||||
const promises = [];
|
||||
for (let i = 0; i < userDeviceInfoArr.length; i++) {
|
||||
const encryptedContent = {
|
||||
const promises: Promise<unknown>[] = [];
|
||||
for (const { userId, deviceInfo } of userDeviceInfoArr) {
|
||||
const deviceId = deviceInfo.deviceId;
|
||||
const encryptedContent: IEncryptedContent = {
|
||||
algorithm: olmlib.OLM_ALGORITHM,
|
||||
sender_key: this.olmDevice.deviceCurve25519Key,
|
||||
ciphertext: {},
|
||||
};
|
||||
const val = userDeviceInfoArr[i];
|
||||
const userId = val.userId;
|
||||
const deviceInfo = val.deviceInfo;
|
||||
const deviceId = deviceInfo.deviceId;
|
||||
deviceInfoByDeviceId.set(deviceId, deviceInfo);
|
||||
|
||||
// Assign to temp value to make type-checking happy
|
||||
let userIdDeviceInfo = deviceInfoByUserIdAndDeviceId.get(userId);
|
||||
|
||||
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]) {
|
||||
contentMap[userId] = {};
|
||||
}
|
||||
contentMap[userId][deviceId] = encryptedContent;
|
||||
|
||||
const devicesByUser = {};
|
||||
devicesByUser[userId] = [deviceInfo];
|
||||
|
||||
promises.push(
|
||||
olmlib.ensureOlmSessionsForDevices(
|
||||
this.olmDevice,
|
||||
this.baseApis,
|
||||
devicesByUser,
|
||||
{ [userId]: [deviceInfo] },
|
||||
).then(() =>
|
||||
olmlib.encryptMessageForDevice(
|
||||
encryptedContent.ciphertext,
|
||||
@@ -3183,16 +3185,13 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
for (const userId of Object.keys(contentMap)) {
|
||||
for (const deviceId of Object.keys(contentMap[userId])) {
|
||||
if (Object.keys(contentMap[userId][deviceId].ciphertext).length === 0) {
|
||||
logger.log(
|
||||
"No ciphertext for device " +
|
||||
userId + ":" + deviceId + ": pruning",
|
||||
);
|
||||
logger.log(`No ciphertext for device ${userId}:${deviceId}: pruning`);
|
||||
delete contentMap[userId][deviceId];
|
||||
}
|
||||
}
|
||||
// No devices left for that user? Strip that too.
|
||||
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];
|
||||
}
|
||||
}
|
||||
@@ -3204,7 +3203,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
}
|
||||
|
||||
return this.baseApis.sendToDevice("m.room.encrypted", contentMap).then(
|
||||
(response) => ({ contentMap, deviceInfoByDeviceId }),
|
||||
(response) => ({ contentMap, deviceInfoByUserIdAndDeviceId }),
|
||||
).catch(error => {
|
||||
logger.error("sendToDevice failed", error);
|
||||
throw error;
|
||||
@@ -3402,7 +3401,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
event.on(MatrixEventEvent.Status, statusListener);
|
||||
});
|
||||
} 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;
|
||||
} finally {
|
||||
event.removeListener(MatrixEventEvent.LocalEventIdReplaced, eventIdListener);
|
||||
@@ -3426,7 +3425,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
try {
|
||||
await request.channel.handleEvent(event, request, isLiveEvent);
|
||||
} catch (err) {
|
||||
logger.error("error while handling verification event: " + err.message);
|
||||
logger.error("error while handling verification event", err);
|
||||
}
|
||||
const shouldEmit = isNewRequest &&
|
||||
!request.initiatedByMe &&
|
||||
|
||||
@@ -15,17 +15,19 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import { ISigned } from "../@types/signed";
|
||||
import { IEncryptedPayload } from "./aes";
|
||||
|
||||
export interface Curve25519SessionData {
|
||||
ciphertext: string;
|
||||
ephemeral: string;
|
||||
mac: string;
|
||||
}
|
||||
|
||||
export interface IKeyBackupSession {
|
||||
first_message_index: number; // eslint-disable-line camelcase
|
||||
forwarded_count: number; // eslint-disable-line camelcase
|
||||
is_verified: boolean; // eslint-disable-line camelcase
|
||||
session_data: { // eslint-disable-line camelcase
|
||||
ciphertext: string;
|
||||
ephemeral: string;
|
||||
mac: string;
|
||||
iv: string;
|
||||
};
|
||||
session_data: Curve25519SessionData | IEncryptedPayload; // eslint-disable-line camelcase
|
||||
}
|
||||
|
||||
export interface IKeyBackupRoomSessions {
|
||||
|
||||
@@ -76,7 +76,7 @@ export interface IOlmSessionResult {
|
||||
export async function encryptMessageForDevice(
|
||||
resultsObject: Record<string, string>,
|
||||
ourUserId: string,
|
||||
ourDeviceId: string,
|
||||
ourDeviceId: string | undefined,
|
||||
olmDevice: OlmDevice,
|
||||
recipientUserId: string,
|
||||
recipientDevice: DeviceInfo,
|
||||
@@ -323,7 +323,7 @@ export async function ensureOlmSessionsForDevices(
|
||||
}
|
||||
|
||||
const oneTimeKeyAlgorithm = "signed_curve25519";
|
||||
let res;
|
||||
let res: IClaimOTKsResult;
|
||||
let taskDetail = `one-time keys for ${devicesWithoutSession.length} devices`;
|
||||
try {
|
||||
log.debug(`Claiming ${taskDetail}`);
|
||||
|
||||
@@ -21,7 +21,7 @@ import * as bs58 from 'bs58';
|
||||
const OLM_RECOVERY_KEY_PREFIX = [0x8B, 0x01];
|
||||
|
||||
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(key, OLM_RECOVERY_KEY_PREFIX.length);
|
||||
|
||||
|
||||
@@ -157,7 +157,7 @@ export class IndexedDBCryptoStore implements CryptoStore {
|
||||
}
|
||||
}).then(backend => {
|
||||
this.backend = backend;
|
||||
return backend as CryptoStore;
|
||||
return backend;
|
||||
});
|
||||
|
||||
return this.backendPromise;
|
||||
|
||||
@@ -841,11 +841,11 @@ export class VerificationRequest<
|
||||
}
|
||||
|
||||
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
|
||||
// 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
|
||||
// before that, we could be looking at somebody elses verification request and we just
|
||||
// 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 else's verification request and we just
|
||||
// happen to be in the room
|
||||
if (this.phase !== PHASE_UNSENT && (isUnexpectedRequest || isUnexpectedReady)) {
|
||||
logger.warn(`Cancelling, unexpected ${type} verification ` +
|
||||
|
||||
@@ -48,10 +48,15 @@ TODO:
|
||||
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";
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
@@ -403,7 +408,7 @@ export class MatrixHttpApi {
|
||||
resp = bodyParser(resp);
|
||||
}
|
||||
} catch (err) {
|
||||
err.http_status = xhr.status;
|
||||
err.httpStatus = xhr.status;
|
||||
cb(err);
|
||||
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} message The Matrix 'error' value, e.g. "Missing token."
|
||||
* @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 {
|
||||
public readonly errcode: string;
|
||||
|
||||
@@ -44,11 +44,19 @@ export interface IStageStatus {
|
||||
|
||||
export interface IAuthData {
|
||||
session?: string;
|
||||
type?: string;
|
||||
completed?: string[];
|
||||
flows?: IFlow[];
|
||||
available_flows?: IFlow[];
|
||||
stages?: string[];
|
||||
required_stages?: AuthType[];
|
||||
params?: Record<string, Record<string, any>>;
|
||||
data?: Record<string, string>;
|
||||
errcode?: string;
|
||||
error?: string;
|
||||
user_id?: string;
|
||||
device_id?: string;
|
||||
access_token?: string;
|
||||
}
|
||||
|
||||
export enum AuthType {
|
||||
@@ -203,6 +211,8 @@ export class InteractiveAuth {
|
||||
private chosenFlow: IFlow = null;
|
||||
private currentStage: string = null;
|
||||
|
||||
private emailAttempt = 1;
|
||||
|
||||
// if we are currently trying to submit an auth dict (which includes polling)
|
||||
// the promise the will resolve/reject when it completes
|
||||
private submitPromise: Promise<void> = null;
|
||||
@@ -408,6 +418,34 @@ export class InteractiveAuth {
|
||||
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
|
||||
* startAuthStage.
|
||||
@@ -458,24 +496,9 @@ export class InteractiveAuth {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!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;
|
||||
if (!this.emailSid && this.chosenFlow.stages.includes(AuthType.Email)) {
|
||||
try {
|
||||
const requestTokenResult = await this.requestEmailTokenCallback(
|
||||
this.inputs.emailAddress,
|
||||
this.clientSecret,
|
||||
1, // TODO: Multiple send attempts?
|
||||
this.data.session,
|
||||
);
|
||||
this.emailSid = requestTokenResult.sid;
|
||||
await this.requestEmailToken();
|
||||
// NB. promise is not resolved here - at some point, doRequest
|
||||
// will be called again and if the user has jumped through all
|
||||
// the hoops correctly, auth will be complete and the request
|
||||
@@ -491,8 +514,6 @@ export class InteractiveAuth {
|
||||
// send the email, for whatever reason.
|
||||
this.attemptAuthDeferred.reject(e);
|
||||
this.attemptAuthDeferred = null;
|
||||
} finally {
|
||||
this.requestingEmailToken = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +41,6 @@ export * from "./interactive-auth";
|
||||
export * from "./service-types";
|
||||
export * from "./store/memory";
|
||||
export * from "./store/indexeddb";
|
||||
export * from "./store/session/webstorage";
|
||||
export * from "./crypto/store/memory-crypto-store";
|
||||
export * from "./crypto/store/indexeddb-crypto-store";
|
||||
export * from "./content-repo";
|
||||
|
||||
@@ -54,8 +54,8 @@ export class Beacon extends TypedEventEmitter<Exclude<BeaconEvent, BeaconEvent.N
|
||||
public readonly roomId: string;
|
||||
private _beaconInfo: BeaconInfoState;
|
||||
private _isLive: boolean;
|
||||
private livenessWatchInterval: ReturnType<typeof setInterval>;
|
||||
private _latestLocationState: BeaconLocationState | undefined;
|
||||
private livenessWatchTimeout: ReturnType<typeof setTimeout>;
|
||||
private _latestLocationEvent: MatrixEvent | undefined;
|
||||
|
||||
constructor(
|
||||
private rootEvent: MatrixEvent,
|
||||
@@ -90,7 +90,11 @@ export class Beacon extends TypedEventEmitter<Exclude<BeaconEvent, BeaconEvent.N
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -109,8 +113,8 @@ export class Beacon extends TypedEventEmitter<Exclude<BeaconEvent, BeaconEvent.N
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
if (this.livenessWatchInterval) {
|
||||
clearInterval(this.livenessWatchInterval);
|
||||
if (this.livenessWatchTimeout) {
|
||||
clearTimeout(this.livenessWatchTimeout);
|
||||
}
|
||||
|
||||
this._isLive = false;
|
||||
@@ -122,19 +126,26 @@ export class Beacon extends TypedEventEmitter<Exclude<BeaconEvent, BeaconEvent.N
|
||||
* Emits BeaconEvent.LivenessChange when beacon expires
|
||||
*/
|
||||
public monitorLiveness(): void {
|
||||
if (this.livenessWatchInterval) {
|
||||
clearInterval(this.livenessWatchInterval);
|
||||
if (this.livenessWatchTimeout) {
|
||||
clearTimeout(this.livenessWatchTimeout);
|
||||
}
|
||||
|
||||
this.checkLiveness();
|
||||
if (this.isLive) {
|
||||
const expiryInMs = (this._beaconInfo?.timestamp + this._beaconInfo?.timeout) - Date.now();
|
||||
if (expiryInMs > 1) {
|
||||
this.livenessWatchInterval = setInterval(
|
||||
this.livenessWatchTimeout = setTimeout(
|
||||
() => { this.monitorLiveness(); },
|
||||
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];
|
||||
|
||||
if (latestLocationEvent) {
|
||||
this._latestLocationState = parseBeaconContent(latestLocationEvent.getContent());
|
||||
this._latestLocationEvent = latestLocationEvent;
|
||||
this.emit(BeaconEvent.LocationUpdate, this.latestLocationState);
|
||||
}
|
||||
}
|
||||
|
||||
private clearLatestLocation = () => {
|
||||
this._latestLocationState = undefined;
|
||||
this._latestLocationEvent = undefined;
|
||||
this.emit(BeaconEvent.LocationUpdate, this.latestLocationState);
|
||||
};
|
||||
|
||||
@@ -178,8 +189,16 @@ export class Beacon extends TypedEventEmitter<Exclude<BeaconEvent, BeaconEvent.N
|
||||
|
||||
private checkLiveness(): void {
|
||||
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 &&
|
||||
isTimestampInDuration(this._beaconInfo?.timestamp, this._beaconInfo?.timeout, Date.now());
|
||||
isTimestampInDuration(startTimestamp, this._beaconInfo?.timeout, Date.now());
|
||||
|
||||
if (prevLiveness !== this.isLive) {
|
||||
this.emit(BeaconEvent.LivenessChange, this.isLive, this);
|
||||
|
||||
@@ -18,15 +18,16 @@ limitations under the License.
|
||||
* @module models/event-timeline-set
|
||||
*/
|
||||
|
||||
import { EventTimeline } from "./event-timeline";
|
||||
import { EventStatus, MatrixEvent, MatrixEventEvent } from "./event";
|
||||
import { EventTimeline, IAddEventOptions } from "./event-timeline";
|
||||
import { MatrixEvent } from "./event";
|
||||
import { logger } from '../logger';
|
||||
import { Relations } from './relations';
|
||||
import { Room, RoomEvent } from "./room";
|
||||
import { Filter } from "../filter";
|
||||
import { EventType, RelationType } from "../@types/event";
|
||||
import { RoomState } from "./room-state";
|
||||
import { TypedEventEmitter } from "./typed-event-emitter";
|
||||
import { RelationsContainer } from "./relations-container";
|
||||
import { MatrixClient } from "../client";
|
||||
import { Thread } from "./thread";
|
||||
|
||||
const DEBUG = true;
|
||||
|
||||
@@ -41,7 +42,6 @@ if (DEBUG) {
|
||||
interface IOpts {
|
||||
timelineSupport?: boolean;
|
||||
filter?: Filter;
|
||||
unstableClientRelationAggregation?: boolean;
|
||||
pendingEvents?: boolean;
|
||||
}
|
||||
|
||||
@@ -55,6 +55,23 @@ export interface IRoomTimelineData {
|
||||
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;
|
||||
|
||||
export type EventTimelineSetHandlerMap = {
|
||||
@@ -64,14 +81,13 @@ export type EventTimelineSetHandlerMap = {
|
||||
};
|
||||
|
||||
export class EventTimelineSet extends TypedEventEmitter<EmittedEvents, EventTimelineSetHandlerMap> {
|
||||
public readonly relations?: RelationsContainer;
|
||||
private readonly timelineSupport: boolean;
|
||||
private unstableClientRelationAggregation: boolean;
|
||||
private displayPendingEvents: boolean;
|
||||
private readonly displayPendingEvents: boolean;
|
||||
private liveTimeline: EventTimeline;
|
||||
private timelines: EventTimeline[];
|
||||
private _eventIdToTimeline: Record<string, EventTimeline>;
|
||||
private filter?: Filter;
|
||||
private relations: Record<string, Record<string, Record<RelationType, Relations>>>;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* @constructor
|
||||
* @param {?Room} room
|
||||
* @param {Room=} room
|
||||
* Room for this timelineSet. May be null for non-room cases, such as the
|
||||
* notification timeline.
|
||||
* @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.
|
||||
* @param {Object} [opts.filter = null]
|
||||
* The filter object, if any, for this timelineSet.
|
||||
* @param {boolean} [opts.unstableClientRelationAggregation = false]
|
||||
* Optional. Set to true to enable client-side aggregation of event relations
|
||||
* via `getRelationsForEvent`.
|
||||
* This feature is currently unstable and the API may change without notice.
|
||||
* @param {MatrixClient=} client the Matrix client which owns this EventTimelineSet,
|
||||
* can be omitted if room is specified.
|
||||
* @param {Thread=} thread the thread to which this timeline set relates.
|
||||
*/
|
||||
constructor(public readonly room: Room, opts: IOpts) {
|
||||
constructor(
|
||||
public readonly room: Room | undefined,
|
||||
opts: IOpts = {},
|
||||
client?: MatrixClient,
|
||||
public readonly thread?: Thread,
|
||||
) {
|
||||
super();
|
||||
|
||||
this.timelineSupport = Boolean(opts.timelineSupport);
|
||||
this.liveTimeline = new EventTimeline(this);
|
||||
this.unstableClientRelationAggregation = !!opts.unstableClientRelationAggregation;
|
||||
this.displayPendingEvents = opts.pendingEvents !== false;
|
||||
|
||||
// just a list - *not* ordered.
|
||||
@@ -123,11 +142,7 @@ export class EventTimelineSet extends TypedEventEmitter<EmittedEvents, EventTime
|
||||
|
||||
this.filter = opts.filter;
|
||||
|
||||
if (this.unstableClientRelationAggregation) {
|
||||
// A tree of objects to access a set of relations for an event, as in:
|
||||
// this.relations[relatesToEventId][relationType][relationEventType]
|
||||
this.relations = {};
|
||||
}
|
||||
this.relations = this.room?.relations ?? new RelationsContainer(room?.client ?? client);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -180,6 +195,15 @@ export class EventTimelineSet extends TypedEventEmitter<EmittedEvents, EventTime
|
||||
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.
|
||||
* @param {String} eventId the eventId being sought
|
||||
@@ -430,7 +454,9 @@ export class EventTimelineSet extends TypedEventEmitter<EmittedEvents, EventTime
|
||||
|
||||
if (!existingTimeline) {
|
||||
// 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;
|
||||
didUpdate = true;
|
||||
continue;
|
||||
@@ -522,16 +548,52 @@ export class EventTimelineSet extends TypedEventEmitter<EmittedEvents, EventTime
|
||||
* Add an event to the end of this live timeline.
|
||||
*
|
||||
* @param {MatrixEvent} event Event to be added
|
||||
* @param {string?} duplicateStrategy 'ignore' or 'replace'
|
||||
* @param {boolean} fromCache whether the sync response came from cache
|
||||
* @param roomState the state events to reconcile metadata from
|
||||
* @param {IAddLiveEventOptions} options addLiveEvent options
|
||||
*/
|
||||
public addLiveEvent(
|
||||
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,
|
||||
roomState?: RoomState,
|
||||
): 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) {
|
||||
const events = this.filter.filterRoomTimeline([event]);
|
||||
if (!events.length) {
|
||||
@@ -542,8 +604,7 @@ export class EventTimelineSet extends TypedEventEmitter<EmittedEvents, EventTime
|
||||
const timeline = this._eventIdToTimeline[event.getId()];
|
||||
if (timeline) {
|
||||
if (duplicateStrategy === DuplicateStrategy.Replace) {
|
||||
debuglog("EventTimelineSet.addLiveEvent: replacing duplicate event " +
|
||||
event.getId());
|
||||
debuglog("EventTimelineSet.addLiveEvent: replacing duplicate event " + event.getId());
|
||||
const tlEvents = timeline.getEvents();
|
||||
for (let j = 0; j < tlEvents.length; j++) {
|
||||
if (tlEvents[j].getId() === event.getId()) {
|
||||
@@ -563,13 +624,17 @@ export class EventTimelineSet extends TypedEventEmitter<EmittedEvents, EventTime
|
||||
}
|
||||
}
|
||||
} else {
|
||||
debuglog("EventTimelineSet.addLiveEvent: ignoring duplicate event " +
|
||||
event.getId());
|
||||
debuglog("EventTimelineSet.addLiveEvent: ignoring duplicate event " + event.getId());
|
||||
}
|
||||
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 {EventTimeline} timeline
|
||||
* @param {boolean} toStartOfTimeline
|
||||
* @param {boolean} fromCache whether the sync response came from cache
|
||||
* @param {IAddEventToTimelineOptions} options addEventToTimeline options
|
||||
*
|
||||
* @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(
|
||||
event: MatrixEvent,
|
||||
timeline: EventTimeline,
|
||||
toStartOfTimeline: boolean,
|
||||
fromCache?: boolean,
|
||||
roomState?: RoomState,
|
||||
): void;
|
||||
public addEventToTimeline(
|
||||
event: MatrixEvent,
|
||||
timeline: EventTimeline,
|
||||
toStartOfTimelineOrOpts: boolean | IAddEventToTimelineOptions,
|
||||
fromCache = false,
|
||||
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();
|
||||
timeline.addEvent(event, toStartOfTimeline, roomState);
|
||||
timeline.addEvent(event, {
|
||||
toStartOfTimeline,
|
||||
roomState,
|
||||
timelineWasEmpty,
|
||||
});
|
||||
this._eventIdToTimeline[eventId] = timeline;
|
||||
|
||||
this.setRelationsTarget(event);
|
||||
this.aggregateRelations(event);
|
||||
this.relations.aggregateParentEvent(event);
|
||||
this.relations.aggregateChildEvent(event, this);
|
||||
|
||||
const data: IRoomTimelineData = {
|
||||
timeline: timeline,
|
||||
@@ -629,10 +732,14 @@ export class EventTimelineSet extends TypedEventEmitter<EmittedEvents, EventTime
|
||||
} else {
|
||||
if (this.filter) {
|
||||
if (this.filter.filterRoomTimeline([localEvent]).length) {
|
||||
this.addEventToTimeline(localEvent, this.liveTimeline, false);
|
||||
this.addEventToTimeline(localEvent, this.liveTimeline, {
|
||||
toStartOfTimeline: false,
|
||||
});
|
||||
}
|
||||
} 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) {
|
||||
// both events are in the same timeline - figure out their
|
||||
// relative indices
|
||||
let idx1;
|
||||
let idx2;
|
||||
let idx1: number;
|
||||
let idx2: number;
|
||||
const events = timeline1.getEvents();
|
||||
for (let idx = 0; idx < events.length &&
|
||||
(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
|
||||
* The ID of the event that you'd like to access relation events for.
|
||||
* For example, with annotations, this would be the ID of the event being annotated.
|
||||
* @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.
|
||||
* @param event {MatrixEvent} the event to check whether it belongs to this timeline set.
|
||||
* @throws {Error} if `room` was not set when constructing this timeline set.
|
||||
* @return {boolean} whether the event belongs to this timeline set.
|
||||
*/
|
||||
public getRelationsForEvent(
|
||||
eventId: string,
|
||||
relationType: RelationType | string,
|
||||
eventType: EventType | string,
|
||||
): Relations | undefined {
|
||||
if (!this.unstableClientRelationAggregation) {
|
||||
throw new Error("Client-side relation aggregation is disabled");
|
||||
public canContain(event: MatrixEvent): boolean {
|
||||
if (!this.room) {
|
||||
throw new Error("Cannot call `EventTimelineSet::canContain without a `room` set. " +
|
||||
"Set the room when creating the EventTimelineSet to call this method.");
|
||||
}
|
||||
|
||||
if (!eventId || !relationType || !eventType) {
|
||||
throw new Error("Invalid arguments for `getRelationsForEvent`");
|
||||
const { threadId, shouldLiveInRoom } = this.room.eventShouldLiveIn(event);
|
||||
|
||||
if (this.thread) {
|
||||
return this.thread.id === threadId;
|
||||
}
|
||||
|
||||
// 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);
|
||||
return shouldLiveInRoom;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,12 +18,30 @@ limitations under the License.
|
||||
* @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 { MatrixEvent } from "./event";
|
||||
import { Filter } from "../filter";
|
||||
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 {
|
||||
Backward = "b",
|
||||
Forward = "f",
|
||||
@@ -131,7 +149,7 @@ export class EventTimeline {
|
||||
* state with.
|
||||
* @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) {
|
||||
throw new Error("Cannot initialise state after events are added");
|
||||
}
|
||||
@@ -152,8 +170,12 @@ export class EventTimeline {
|
||||
Object.freeze(e);
|
||||
}
|
||||
|
||||
this.startState.setStateEvents(stateEvents);
|
||||
this.endState.setStateEvents(stateEvents);
|
||||
this.startState.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
|
||||
*
|
||||
* @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 {
|
||||
if (!stateContext) {
|
||||
stateContext = atStart ? this.startState : this.endState;
|
||||
public addEvent(
|
||||
event: MatrixEvent,
|
||||
{
|
||||
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();
|
||||
|
||||
if (timelineSet.room) {
|
||||
EventTimeline.setEventMetadata(event, stateContext, atStart);
|
||||
EventTimeline.setEventMetadata(event, roomState, toStartOfTimeline);
|
||||
|
||||
// modify state but only on unfiltered timelineSets
|
||||
if (
|
||||
event.isState() &&
|
||||
timelineSet.room.getUnfilteredTimelineSet() === timelineSet
|
||||
) {
|
||||
stateContext.setStateEvents([event]);
|
||||
roomState.setStateEvents([event], {
|
||||
timelineWasEmpty,
|
||||
});
|
||||
// it is possible that the act of setting the state event means we
|
||||
// can set more metadata (specifically sender/target props), so try
|
||||
// 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
|
||||
// member event, whereas we want to set the .sender value for the ACTUAL
|
||||
// member event itself.
|
||||
if (!event.sender || (event.getType() === "m.room.member" && !atStart)) {
|
||||
EventTimeline.setEventMetadata(event, stateContext, atStart);
|
||||
if (!event.sender || (event.getType() === "m.room.member" && !toStartOfTimeline)) {
|
||||
EventTimeline.setEventMetadata(event, roomState, toStartOfTimeline);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let insertIndex;
|
||||
let insertIndex: number;
|
||||
|
||||
if (atStart) {
|
||||
if (toStartOfTimeline) {
|
||||
insertIndex = 0;
|
||||
} else {
|
||||
insertIndex = this.events.length;
|
||||
}
|
||||
|
||||
this.events.splice(insertIndex, 0, event); // insert element
|
||||
if (atStart) {
|
||||
if (toStartOfTimeline) {
|
||||
this.baseIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -514,13 +514,6 @@ export class MatrixEvent extends TypedEventEmitter<EmittedEvents, MatrixEventHan
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @experimental
|
||||
*/
|
||||
public get isThreadRelation(): boolean {
|
||||
return !!this.threadRootId && this.threadId !== this.getId();
|
||||
}
|
||||
|
||||
/**
|
||||
* @experimental
|
||||
*/
|
||||
@@ -544,7 +537,7 @@ export class MatrixEvent extends TypedEventEmitter<EmittedEvents, MatrixEventHan
|
||||
return mRelatesTo?.['m.in_reply_to']?.event_id;
|
||||
}
|
||||
|
||||
public get relationEventId(): string {
|
||||
public get relationEventId(): string | undefined {
|
||||
return this.getWireContent()
|
||||
?.["m.relates_to"]
|
||||
?.event_id;
|
||||
@@ -830,18 +823,13 @@ export class MatrixEvent extends TypedEventEmitter<EmittedEvents, MatrixEventHan
|
||||
//
|
||||
if (this.retryDecryption) {
|
||||
// decryption error, but we have a retry queued.
|
||||
logger.log(
|
||||
`Got error decrypting event (id=${this.getId()}: ` +
|
||||
`${e}), but retrying`,
|
||||
);
|
||||
logger.log(`Got error decrypting event (id=${this.getId()}: ${e.detailedString}), but retrying`, e);
|
||||
continue;
|
||||
}
|
||||
|
||||
// decryption error, no retries queued. Warn about the error and
|
||||
// set it to m.bad.encrypted.
|
||||
logger.warn(
|
||||
`Error decrypting event (id=${this.getId()}): ${e.detailedString}`,
|
||||
);
|
||||
logger.warn(`Got error decrypting event (id=${this.getId()}: ${e.detailedString})`, e);
|
||||
|
||||
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
|
||||
* visible (if it was hidden), by making it hidden (if it was visible) or by
|
||||
* 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
|
||||
* by a visibility event being redacted).
|
||||
*/
|
||||
@@ -1065,9 +1053,7 @@ export class MatrixEvent extends TypedEventEmitter<EmittedEvents, MatrixEventHan
|
||||
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
|
||||
* `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
|
||||
* 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
|
||||
// encrypted rooms, so we have to check `getWireContent` for this.
|
||||
const relation = this.getWireContent()?.["m.relates_to"];
|
||||
return relation && relation.rel_type && relation.event_id &&
|
||||
((relType && relation.rel_type === relType) || !relType);
|
||||
if (this.isState() && relation?.rel_type === RelationType.Replace) {
|
||||
// State events cannot be m.replace relations
|
||||
return false;
|
||||
}
|
||||
return relation?.rel_type && relation.event_id && (relType ? relation.rel_type === relType : true);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
155
src/models/relations-container.ts
Normal file
155
src/models/relations-container.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -15,10 +15,11 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import { EventStatus, IAggregatedRelation, MatrixEvent, MatrixEventEvent } from './event';
|
||||
import { Room } from './room';
|
||||
import { logger } from '../logger';
|
||||
import { RelationType } from "../@types/event";
|
||||
import { TypedEventEmitter } from "./typed-event-emitter";
|
||||
import { MatrixClient } from "../client";
|
||||
import { Room } from "./room";
|
||||
|
||||
export enum RelationsEvent {
|
||||
Add = "Relations.add",
|
||||
@@ -48,6 +49,7 @@ export class Relations extends TypedEventEmitter<RelationsEvent, EventHandlerMap
|
||||
private sortedAnnotationsByKey: [string, Set<MatrixEvent>][] = [];
|
||||
private targetEvent: MatrixEvent = null;
|
||||
private creationEmitted = false;
|
||||
private readonly client: MatrixClient;
|
||||
|
||||
/**
|
||||
* @param {RelationType} relationType
|
||||
@@ -55,16 +57,16 @@ export class Relations extends TypedEventEmitter<RelationsEvent, EventHandlerMap
|
||||
* "m.replace", etc.
|
||||
* @param {String} eventType
|
||||
* The relation event's type, such as "m.reaction", etc.
|
||||
* @param {?Room} room
|
||||
* Room for this container. May be null for non-room cases, such as the
|
||||
* notification timeline.
|
||||
* @param {MatrixClient|Room} client
|
||||
* The client which created this instance. For backwards compatibility also accepts a Room.
|
||||
*/
|
||||
constructor(
|
||||
public readonly relationType: RelationType | string,
|
||||
public readonly eventType: string,
|
||||
private readonly room: Room,
|
||||
client: MatrixClient | Room,
|
||||
) {
|
||||
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) {
|
||||
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();
|
||||
this.targetEvent.makeReplaced(lastReplacement);
|
||||
}
|
||||
@@ -144,7 +146,7 @@ export class Relations extends TypedEventEmitter<RelationsEvent, EventHandlerMap
|
||||
|
||||
if (this.relationType === RelationType.Annotation) {
|
||||
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();
|
||||
this.targetEvent.makeReplaced(lastReplacement);
|
||||
}
|
||||
@@ -261,7 +263,7 @@ export class Relations extends TypedEventEmitter<RelationsEvent, EventHandlerMap
|
||||
if (this.relationType === RelationType.Annotation) {
|
||||
// Remove the redacted annotation from aggregation by key
|
||||
this.removeAnnotationFromAggregation(redactedEvent);
|
||||
} else if (this.relationType === RelationType.Replace && this.targetEvent) {
|
||||
} else if (this.relationType === RelationType.Replace && this.targetEvent && !this.targetEvent.isState()) {
|
||||
const lastReplacement = await this.getLastReplacement();
|
||||
this.targetEvent.makeReplaced(lastReplacement);
|
||||
}
|
||||
@@ -347,7 +349,7 @@ export class Relations extends TypedEventEmitter<RelationsEvent, EventHandlerMap
|
||||
}, null);
|
||||
|
||||
if (lastReplacement?.shouldAttemptDecryption()) {
|
||||
await lastReplacement.attemptDecryption(this.room.client.crypto);
|
||||
await lastReplacement.attemptDecryption(this.client.crypto);
|
||||
} else if (lastReplacement?.isBeingDecrypted()) {
|
||||
await lastReplacement.getDecryptionPromise();
|
||||
}
|
||||
@@ -364,7 +366,7 @@ export class Relations extends TypedEventEmitter<RelationsEvent, EventHandlerMap
|
||||
}
|
||||
this.targetEvent = event;
|
||||
|
||||
if (this.relationType === RelationType.Replace) {
|
||||
if (this.relationType === RelationType.Replace && !this.targetEvent.isState()) {
|
||||
const replacement = await this.getLastReplacement();
|
||||
// this is the initial update, so only call it if we already have something
|
||||
// to not emit Event.replaced needlessly
|
||||
|
||||
@@ -21,7 +21,7 @@ limitations under the License.
|
||||
import { RoomMember } from "./room-member";
|
||||
import { logger } from '../logger';
|
||||
import * as utils from "../utils";
|
||||
import { EventType } from "../@types/event";
|
||||
import { EventType, UNSTABLE_MSC2716_MARKER } from "../@types/event";
|
||||
import { MatrixEvent, MatrixEventEvent } from "./event";
|
||||
import { MatrixClient } from "../client";
|
||||
import { GuestAccess, HistoryVisibility, IJoinRuleEventContent, JoinRule } from "../@types/partials";
|
||||
@@ -30,6 +30,22 @@ import { Beacon, BeaconEvent, BeaconEventHandlerMap, getBeaconInfoIdentifier, Be
|
||||
import { TypedReEmitter } from "../ReEmitter";
|
||||
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
|
||||
enum OobStatus {
|
||||
NotStarted,
|
||||
@@ -43,6 +59,7 @@ export enum RoomStateEvent {
|
||||
NewMember = "RoomState.newMember",
|
||||
Update = "RoomState.update", // signals batches of updates without specificity
|
||||
BeaconLiveness = "RoomState.BeaconLiveness",
|
||||
Marker = "RoomState.Marker",
|
||||
}
|
||||
|
||||
export type RoomStateEventHandlerMap = {
|
||||
@@ -51,6 +68,7 @@ export type RoomStateEventHandlerMap = {
|
||||
[RoomStateEvent.NewMember]: (event: MatrixEvent, state: RoomState, member: RoomMember) => void;
|
||||
[RoomStateEvent.Update]: (state: RoomState) => void;
|
||||
[RoomStateEvent.BeaconLiveness]: (state: RoomState, hasLiveBeacons: boolean) => void;
|
||||
[RoomStateEvent.Marker]: (event: MatrixEvent, setStateOptions: IMarkerFoundOptions) => 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
|
||||
* any existing state with the same {type, stateKey} tuple. Will fire
|
||||
* "RoomState.events" for every event added. May fire "RoomState.members"
|
||||
* if there are <code>m.room.member</code> events.
|
||||
* Add an array of one or more state MatrixEvents, overwriting any existing
|
||||
* state with the same {type, stateKey} tuple. Will fire "RoomState.events"
|
||||
* for every event added. May fire "RoomState.members" if there are
|
||||
* <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 {IMarkerFoundOptions} markerFoundOptions
|
||||
* @fires module:client~MatrixClient#event:"RoomState.members"
|
||||
* @fires module:client~MatrixClient#event:"RoomState.newMember"
|
||||
* @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();
|
||||
|
||||
// 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
|
||||
this.sentinels = {};
|
||||
} else if (UNSTABLE_MSC2716_MARKER.matches(event.getType())) {
|
||||
this.emit(RoomStateEvent.Marker, event, markerFoundOptions);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ limitations under the License.
|
||||
* @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 { getHttpUriForMxc } from "../content-repo";
|
||||
import * as utils from "../utils";
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
import { TypedEventEmitter } from "./typed-event-emitter";
|
||||
import { ReceiptType } from "../@types/read_receipts";
|
||||
import { IStateEventWithRoomId } from "../@types/search";
|
||||
import { RelationsContainer } from "./relations-container";
|
||||
|
||||
// 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
|
||||
@@ -80,7 +81,6 @@ interface IOpts {
|
||||
storageToken?: string;
|
||||
pendingEventOrdering?: PendingEventOrdering;
|
||||
timelineSupport?: boolean;
|
||||
unstableClientRelationAggregation?: boolean;
|
||||
lazyLoadMembers?: boolean;
|
||||
}
|
||||
|
||||
@@ -165,6 +165,10 @@ export enum RoomEvent {
|
||||
LocalEchoUpdated = "Room.localEchoUpdated",
|
||||
Timeline = "Room.timeline",
|
||||
TimelineReset = "Room.timelineReset",
|
||||
TimelineRefresh = "Room.TimelineRefresh",
|
||||
OldStateUpdated = "Room.OldStateUpdated",
|
||||
CurrentStateUpdated = "Room.CurrentStateUpdated",
|
||||
HistoryImportedWithinTimeline = "Room.historyImportedWithinTimeline",
|
||||
}
|
||||
|
||||
type EmittedEvents = RoomEvent
|
||||
@@ -173,6 +177,10 @@ type EmittedEvents = RoomEvent
|
||||
| ThreadEvent.NewReply
|
||||
| RoomEvent.Timeline
|
||||
| RoomEvent.TimelineReset
|
||||
| RoomEvent.TimelineRefresh
|
||||
| RoomEvent.HistoryImportedWithinTimeline
|
||||
| RoomEvent.OldStateUpdated
|
||||
| RoomEvent.CurrentStateUpdated
|
||||
| MatrixEventEvent.BeforeRedaction;
|
||||
|
||||
export type RoomEventHandlerMap = {
|
||||
@@ -189,6 +197,13 @@ export type RoomEventHandlerMap = {
|
||||
oldEventId?: string,
|
||||
oldStatus?: EventStatus,
|
||||
) => 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;
|
||||
} & ThreadHandlerMap & MatrixEventHandlerMap;
|
||||
|
||||
@@ -206,6 +221,7 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
|
||||
public readonly threadsTimelineSets: EventTimelineSet[] = [];
|
||||
// any filtered timeline sets we're maintaining for this room
|
||||
private readonly filteredTimelineSets: Record<string, EventTimelineSet> = {}; // filter_id: timelineSet
|
||||
private timelineNeedsRefresh = false;
|
||||
private readonly pendingEventList?: MatrixEvent[];
|
||||
// read by megolm via getter; boolean value - null indicates "use global value"
|
||||
private blacklistUnverifiedDevices: boolean = null;
|
||||
@@ -261,6 +277,7 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
|
||||
* prefer getLiveTimeline().getState(EventTimeline.FORWARDS).
|
||||
*/
|
||||
public currentState: RoomState;
|
||||
public readonly relations = new RelationsContainer(this.client, this);
|
||||
|
||||
/**
|
||||
* @experimental
|
||||
@@ -322,10 +339,6 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
|
||||
* "chronological".
|
||||
* @param {boolean} [opts.timelineSupport = false] Set to true to enable improved
|
||||
* 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(
|
||||
public readonly roomId: string,
|
||||
@@ -355,18 +368,16 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
|
||||
|
||||
if (this.opts.pendingEventOrdering === PendingEventOrdering.Detached) {
|
||||
this.pendingEventList = [];
|
||||
const serializedPendingEventList = client.sessionStore.store.getItem(pendingEventsKey(this.roomId));
|
||||
if (serializedPendingEventList) {
|
||||
JSON.parse(serializedPendingEventList)
|
||||
.forEach(async (serializedEvent: Partial<IEvent>) => {
|
||||
const event = new MatrixEvent(serializedEvent);
|
||||
if (event.getType() === EventType.RoomMessageEncrypted) {
|
||||
await event.attemptDecryption(this.client.crypto);
|
||||
}
|
||||
event.setStatus(EventStatus.NOT_SENT);
|
||||
this.addPendingEvent(event, event.getTxnId());
|
||||
});
|
||||
}
|
||||
this.client.store.getPendingEvents(this.roomId).then(events => {
|
||||
events.forEach(async (serializedEvent: Partial<IEvent>) => {
|
||||
const event = new MatrixEvent(serializedEvent);
|
||||
if (event.getType() === EventType.RoomMessageEncrypted) {
|
||||
await event.attemptDecryption(this.client.crypto);
|
||||
}
|
||||
event.setStatus(EventStatus.NOT_SENT);
|
||||
this.addPendingEvent(event, event.getTxnId());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 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>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* @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.
|
||||
*
|
||||
@@ -924,6 +1046,9 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
|
||||
* @private
|
||||
*/
|
||||
private fixUpLegacyTimelineFields(): void {
|
||||
const previousOldState = this.oldState;
|
||||
const previousCurrentState = this.currentState;
|
||||
|
||||
// maintain this.timeline as a reference to the live timeline,
|
||||
// and this.oldState and this.currentState as references to the
|
||||
// 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);
|
||||
this.currentState = this.getLiveTimeline()
|
||||
.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();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*
|
||||
@@ -1454,7 +1608,9 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
|
||||
return event.getSender() === this.client.getUserId();
|
||||
});
|
||||
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;
|
||||
const roomState = this.getLiveTimeline().getState(EventTimeline.FORWARDS);
|
||||
for (const rootEvent of threadRoots) {
|
||||
this.threadsTimelineSets[0].addLiveEvent(
|
||||
rootEvent,
|
||||
DuplicateStrategy.Ignore,
|
||||
false,
|
||||
this.threadsTimelineSets[0].addLiveEvent(rootEvent, {
|
||||
duplicateStrategy: DuplicateStrategy.Ignore,
|
||||
fromCache: false,
|
||||
roomState,
|
||||
);
|
||||
});
|
||||
|
||||
const threadRelationship = rootEvent
|
||||
.getServerAggregatedRelation<IThreadBundledRelationship>(RelationType.Thread);
|
||||
if (threadRelationship.current_user_participated) {
|
||||
this.threadsTimelineSets[1].addLiveEvent(
|
||||
rootEvent,
|
||||
DuplicateStrategy.Ignore,
|
||||
false,
|
||||
this.threadsTimelineSets[1].addLiveEvent(rootEvent, {
|
||||
duplicateStrategy: DuplicateStrategy.Ignore,
|
||||
fromCache: false,
|
||||
roomState,
|
||||
);
|
||||
});
|
||||
latestMyThreadsRootEvent = rootEvent;
|
||||
}
|
||||
|
||||
@@ -1578,7 +1732,7 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
|
||||
}
|
||||
|
||||
// A thread relation is always only shown in a thread
|
||||
if (event.isThreadRelation) {
|
||||
if (event.isRelation(THREAD_RELATION_TYPE.name)) {
|
||||
return {
|
||||
shouldLiveInRoom: false,
|
||||
shouldLiveInThread: true,
|
||||
@@ -1657,10 +1811,11 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
|
||||
toStartOfTimeline: boolean,
|
||||
): Thread {
|
||||
if (rootEvent) {
|
||||
const tl = this.getTimelineForEvent(rootEvent.getId());
|
||||
const relatedEvents = tl?.getTimelineSet().getAllRelationsEventForEvent(rootEvent.getId());
|
||||
if (relatedEvents) {
|
||||
events = events.concat(relatedEvents);
|
||||
const relatedEvents = this.relations.getAllChildEventsForEvent(rootEvent.getId());
|
||||
if (relatedEvents?.length) {
|
||||
// Include all relations of the root event, given it'll be visible in both timelines,
|
||||
// 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".
|
||||
*
|
||||
* @param {MatrixEvent} event Event to be added
|
||||
* @param {string?} duplicateStrategy 'ignore' or 'replace'
|
||||
* @param {boolean} fromCache whether the sync response came from cache
|
||||
* @param {IAddLiveEventOptions} options addLiveEvent options
|
||||
* @fires module:client~MatrixClient#event:"Room.timeline"
|
||||
* @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
|
||||
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
|
||||
@@ -1870,11 +2030,15 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
|
||||
if (timelineSet.getFilter()) {
|
||||
if (timelineSet.getFilter().filterRoomTimeline([event]).length) {
|
||||
timelineSet.addEventToTimeline(event,
|
||||
timelineSet.getLiveTimeline(), false);
|
||||
timelineSet.getLiveTimeline(), {
|
||||
toStartOfTimeline: false,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
timelineSet.addEventToTimeline(event,
|
||||
timelineSet.getLiveTimeline(), false);
|
||||
timelineSet.getLiveTimeline(), {
|
||||
toStartOfTimeline: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1909,15 +2073,7 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
|
||||
return isEventEncrypted || !isRoomEncrypted;
|
||||
});
|
||||
|
||||
const { store } = this.client.sessionStore;
|
||||
if (this.pendingEventList.length > 0) {
|
||||
store.setItem(
|
||||
pendingEventsKey(this.roomId),
|
||||
JSON.stringify(pendingEvents),
|
||||
);
|
||||
} else {
|
||||
store.removeItem(pendingEventsKey(this.roomId));
|
||||
}
|
||||
this.client.store.setPendingEvents(this.roomId, pendingEvents);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
private aggregateNonLiveRelation(event: MatrixEvent): void {
|
||||
const { shouldLiveInRoom, threadId } = this.eventShouldLiveIn(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);
|
||||
}
|
||||
}
|
||||
}
|
||||
this.relations.aggregateChildEvent(event);
|
||||
}
|
||||
|
||||
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.
|
||||
*
|
||||
* @param {MatrixEvent[]} events A list of events to add.
|
||||
*
|
||||
* @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
|
||||
* @param {IAddLiveEventOptions} options addLiveEvent options
|
||||
* @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) {
|
||||
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);
|
||||
|
||||
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> {
|
||||
const threadRoots = new Set<string>();
|
||||
for (const event of events) {
|
||||
if (event.isThreadRelation) {
|
||||
if (event.isRelation(THREAD_RELATION_TYPE.name)) {
|
||||
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
|
||||
const ALLOWED_TRANSITIONS: Record<EventStatus, EventStatus[]> = {
|
||||
[EventStatus.ENCRYPTING]: [
|
||||
|
||||
@@ -14,6 +14,8 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { Optional } from "matrix-events-sdk";
|
||||
|
||||
import { MatrixClient, MatrixEventEvent, RelationType, RoomEvent } from "../matrix";
|
||||
import { TypedReEmitter } from "../ReEmitter";
|
||||
import { IRelationsRequestOpts } from "../@types/requests";
|
||||
@@ -79,13 +81,18 @@ export class Thread extends TypedEventEmitter<EmittedEvents, EventHandlerMap> {
|
||||
) {
|
||||
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.client = opts.client;
|
||||
this.timelineSet = new EventTimelineSet(this.room, {
|
||||
unstableClientRelationAggregation: true,
|
||||
timelineSupport: true,
|
||||
pendingEvents: true,
|
||||
});
|
||||
}, this.client, this);
|
||||
this.reEmitter = new TypedReEmitter(this);
|
||||
|
||||
this.reEmitter.reEmit(this.timelineSet, [
|
||||
@@ -160,6 +167,7 @@ export class Thread extends TypedEventEmitter<EmittedEvents, EventHandlerMap> {
|
||||
private onEcho = (event: MatrixEvent) => {
|
||||
if (event.threadRootId !== this.id) return; // ignore echoes for other timelines
|
||||
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
|
||||
// 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(
|
||||
event,
|
||||
this.liveTimeline,
|
||||
toStartOfTimeline,
|
||||
false,
|
||||
this.roomState,
|
||||
{
|
||||
toStartOfTimeline,
|
||||
fromCache: false,
|
||||
roomState: this.roomState,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -221,12 +231,6 @@ export class Thread extends TypedEventEmitter<EmittedEvents, EventHandlerMap> {
|
||||
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
|
||||
if (!Thread.hasServerSideSupport) {
|
||||
// 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.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
|
||||
@@ -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
|
||||
private async fetchEditsWhereNeeded(...events: MatrixEvent[]): Promise<unknown> {
|
||||
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(), {
|
||||
limit: 1,
|
||||
}).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--) {
|
||||
const event = this.events[i];
|
||||
if (matches(event)) {
|
||||
return event;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
|
||||
@@ -56,31 +56,6 @@ const RULEKINDS_IN_ORDER = [
|
||||
// 2. We often want to start using push rules ahead of the server supporting them,
|
||||
// and so we can put them here.
|
||||
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
|
||||
rule_id: ".m.rule.reaction",
|
||||
@@ -108,10 +83,13 @@ const DEFAULT_OVERRIDE_RULES: IPushRule[] = [
|
||||
key: "type",
|
||||
pattern: EventType.RoomServerAcl,
|
||||
},
|
||||
{
|
||||
kind: ConditionKind.EventMatch,
|
||||
key: "state_key",
|
||||
pattern: "",
|
||||
},
|
||||
],
|
||||
actions: [
|
||||
PushRuleActionName.DontNotify,
|
||||
],
|
||||
actions: [],
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ limitations under the License.
|
||||
import { EventType } from "../@types/event";
|
||||
import { Room } from "../models/room";
|
||||
import { User } from "../models/user";
|
||||
import { MatrixEvent } from "../models/event";
|
||||
import { IEvent, MatrixEvent } from "../models/event";
|
||||
import { Filter } from "../filter";
|
||||
import { RoomSummary } from "../models/room-summary";
|
||||
import { IMinimalEvent, IRooms, ISyncResponse } from "../sync-accumulator";
|
||||
@@ -218,4 +218,8 @@ export interface IStore {
|
||||
getClientOptions(): Promise<IStartClientOpts>;
|
||||
|
||||
storeClientOptions(options: IStartClientOpts): Promise<void>;
|
||||
|
||||
getPendingEvents(roomId: string): Promise<Partial<IEvent>[]>;
|
||||
|
||||
setPendingEvents(roomId: string, events: Partial<IEvent>[]): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -127,6 +127,8 @@ export class LocalIndexedDBStoreBackend implements IIndexedDBBackend {
|
||||
private db: IDBDatabase = null;
|
||||
private disconnected = true;
|
||||
private _isNewlyCreated = false;
|
||||
private isPersisting = false;
|
||||
private pendingUserPresenceData: UserTuple[] = [];
|
||||
|
||||
/**
|
||||
* Does the actual reading from and writing to the indexeddb
|
||||
@@ -266,7 +268,7 @@ export class LocalIndexedDBStoreBackend implements IIndexedDBBackend {
|
||||
reject(err);
|
||||
};
|
||||
}).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;
|
||||
});
|
||||
}
|
||||
@@ -401,11 +403,24 @@ export class LocalIndexedDBStoreBackend implements IIndexedDBBackend {
|
||||
public async syncToDatabase(userTuples: UserTuple[]): Promise<void> {
|
||||
const syncData = this.syncAccumulator.getJSON(true);
|
||||
|
||||
await Promise.all([
|
||||
this.persistUserPresenceEvents(userTuples),
|
||||
this.persistAccountData(syncData.accountData),
|
||||
this.persistSyncData(syncData.nextBatch, syncData.roomsData),
|
||||
]);
|
||||
if (this.isPersisting) {
|
||||
logger.warn("Skipping syncToDatabase() as persist already in flight");
|
||||
this.pendingUserPresenceData.push(...userTuples);
|
||||
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,
|
||||
roomsData,
|
||||
}); // put == UPSERT
|
||||
return txnAsPromise(txn).then();
|
||||
return txnAsPromise(txn).then(() => {
|
||||
logger.log("Persisted sync data up to", nextBatch);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -122,8 +122,7 @@ export class IndexedDBStoreWorker {
|
||||
result: ret,
|
||||
});
|
||||
}, (err) => {
|
||||
logger.error("Error running command: " + msg.command);
|
||||
logger.error(err);
|
||||
logger.error("Error running command: " + msg.command, err);
|
||||
this.postMessage.call(null, {
|
||||
command: 'cmd_fail',
|
||||
seq: msg.seq,
|
||||
|
||||
@@ -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 {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);
|
||||
}, "getOutOfBandMembers");
|
||||
|
||||
@@ -297,7 +297,7 @@ export class IndexedDBStore extends MemoryStore {
|
||||
|
||||
return async (...args) => {
|
||||
try {
|
||||
return func.call(this, ...args);
|
||||
return await func.call(this, ...args);
|
||||
} catch (e) {
|
||||
logger.error("IndexedDBStore failure, degrading to MemoryStore", 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
|
||||
// not overridden at all).
|
||||
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>;
|
||||
|
||||
@@ -22,7 +22,7 @@ limitations under the License.
|
||||
import { EventType } from "../@types/event";
|
||||
import { Room } from "../models/room";
|
||||
import { User } from "../models/user";
|
||||
import { MatrixEvent } from "../models/event";
|
||||
import { IEvent, MatrixEvent } from "../models/event";
|
||||
import { RoomState, RoomStateEvent } from "../models/room-state";
|
||||
import { RoomMember } from "../models/room-member";
|
||||
import { Filter } from "../filter";
|
||||
@@ -48,7 +48,7 @@ export interface IOpts {
|
||||
* Construct a new in-memory data store for the Matrix Client.
|
||||
* @constructor
|
||||
* @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.
|
||||
*/
|
||||
export class MemoryStore implements IStore {
|
||||
@@ -60,8 +60,9 @@ export class MemoryStore implements IStore {
|
||||
// }
|
||||
private filters: Record<string, Record<string, Filter>> = {};
|
||||
public accountData: Record<string, MatrixEvent> = {}; // type : content
|
||||
private readonly localStorage: Storage;
|
||||
protected readonly localStorage: Storage;
|
||||
private oobMembers: Record<string, IStateEventWithRoomId[]> = {}; // roomId: [member events]
|
||||
private pendingEvents: { [roomId: string]: Partial<IEvent>[] } = {};
|
||||
private clientOptions = {};
|
||||
|
||||
constructor(opts: IOpts = {}) {
|
||||
@@ -199,7 +200,7 @@ export class MemoryStore implements IStore {
|
||||
/**
|
||||
* Retrieve scrollback for this 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'
|
||||
* 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);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ limitations under the License.
|
||||
import { EventType } from "../@types/event";
|
||||
import { Room } from "../models/room";
|
||||
import { User } from "../models/user";
|
||||
import { MatrixEvent } from "../models/event";
|
||||
import { IEvent, MatrixEvent } from "../models/event";
|
||||
import { Filter } from "../filter";
|
||||
import { ISavedSync, IStore } from "./index";
|
||||
import { RoomSummary } from "../models/room-summary";
|
||||
@@ -262,4 +262,12 @@ export class StubStore implements IStore {
|
||||
public storeClientOptions(options: object): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
public async getPendingEvents(roomId: string): Promise<Partial<IEvent>[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
public setPendingEvents(roomId: string, events: Partial<IEvent>[]): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,7 +50,6 @@ export interface IRoomEvent extends IMinimalEvent {
|
||||
event_id: string;
|
||||
sender: string;
|
||||
origin_server_ts: number;
|
||||
unsigned?: IUnsigned;
|
||||
/** @deprecated - legacy field */
|
||||
age?: number;
|
||||
}
|
||||
|
||||
150
src/sync.ts
150
src/sync.ts
@@ -51,9 +51,11 @@ import { MatrixError, Method } from "./http-api";
|
||||
import { ISavedSync } from "./store";
|
||||
import { EventType } from "./@types/event";
|
||||
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 { BeaconEvent } from "./models/beacon";
|
||||
import { IEventsResponse } from "./@types/requests";
|
||||
import { IAbortablePromise } from "./@types/partials";
|
||||
|
||||
const DEBUG = true;
|
||||
|
||||
@@ -69,14 +71,32 @@ const BUFFER_PERIOD_MS = 80 * 1000;
|
||||
const FAILED_SYNC_ERROR_THRESHOLD = 3;
|
||||
|
||||
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",
|
||||
/** Emitted after the first sync events are ready (this could even be sync
|
||||
* events from the cache) */
|
||||
Prepared = "PREPARED",
|
||||
/** Emitted when the sync loop is no longer running */
|
||||
Stopped = "STOPPED",
|
||||
/** Emitted after each sync request happens */
|
||||
Syncing = "SYNCING",
|
||||
/** Emitted after a connectivity error and we're ready to start syncing again */
|
||||
Catchup = "CATCHUP",
|
||||
/** Emitted for each time we try reconnecting. Will switch to `Error` after
|
||||
* we reach the `FAILED_SYNC_ERROR_THRESHOLD`
|
||||
*/
|
||||
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 {
|
||||
// scope this on the user ID because people may login on many accounts
|
||||
// and they all need to be stored!
|
||||
@@ -120,11 +140,6 @@ interface ISyncParams {
|
||||
_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 & {
|
||||
room: Room;
|
||||
isBrandNewRoom: boolean;
|
||||
@@ -147,7 +162,7 @@ type WrappedRoom<T> = T & {
|
||||
*/
|
||||
export class SyncApi {
|
||||
private _peekRoom: Room = null;
|
||||
private currentSyncRequest: IRequestPromise<ISyncResponse> = null;
|
||||
private currentSyncRequest: IAbortablePromise<ISyncResponse> = null;
|
||||
private syncState: SyncState = null;
|
||||
private syncStateData: ISyncStateData = null; // additional data (eg. error object for failed sync)
|
||||
private catchingUp = false;
|
||||
@@ -187,13 +202,11 @@ export class SyncApi {
|
||||
const client = this.client;
|
||||
const {
|
||||
timelineSupport,
|
||||
unstableClientRelationAggregation,
|
||||
} = client;
|
||||
const room = new Room(roomId, client, client.getUserId(), {
|
||||
lazyLoadMembers: this.opts.lazyLoadMembers,
|
||||
pendingEventOrdering: this.opts.pendingEventOrdering,
|
||||
timelineSupport,
|
||||
unstableClientRelationAggregation,
|
||||
});
|
||||
client.reEmitter.reEmit(room, [
|
||||
RoomEvent.Name,
|
||||
@@ -208,6 +221,15 @@ export class SyncApi {
|
||||
RoomEvent.TimelineReset,
|
||||
]);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -240,17 +262,89 @@ export class SyncApi {
|
||||
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 deregisterStateListeners(room: Room): void {
|
||||
private deregisterStateListeners(roomState: RoomState): void {
|
||||
// could do with a better way of achieving this.
|
||||
room.currentState.removeAllListeners(RoomStateEvent.Events);
|
||||
room.currentState.removeAllListeners(RoomStateEvent.Members);
|
||||
room.currentState.removeAllListeners(RoomStateEvent.NewMember);
|
||||
roomState.removeAllListeners(RoomStateEvent.Events);
|
||||
roomState.removeAllListeners(RoomStateEvent.Members);
|
||||
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,
|
||||
).then(function(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,
|
||||
);
|
||||
}).then(async (data) => {
|
||||
@@ -409,8 +503,7 @@ export class SyncApi {
|
||||
}
|
||||
|
||||
// FIXME: gut wrenching; hard-coded timeout values
|
||||
// TODO types
|
||||
this.client.http.authedRequest<any>(undefined, Method.Get, "/events", {
|
||||
this.client.http.authedRequest<IEventsResponse>(undefined, Method.Get, "/events", {
|
||||
room_id: peekRoom.roomId,
|
||||
timeout: String(30 * 1000),
|
||||
from: token,
|
||||
@@ -702,9 +795,7 @@ export class SyncApi {
|
||||
global.window.removeEventListener("online", this.onOnline, false);
|
||||
}
|
||||
this.running = false;
|
||||
if (this.currentSyncRequest) {
|
||||
this.currentSyncRequest.abort();
|
||||
}
|
||||
this.currentSyncRequest?.abort();
|
||||
if (this.keepAliveTimer) {
|
||||
clearTimeout(this.keepAliveTimer);
|
||||
this.keepAliveTimer = null;
|
||||
@@ -872,9 +963,9 @@ export class SyncApi {
|
||||
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);
|
||||
return this.client.http.authedRequest( // TODO types
|
||||
return this.client.http.authedRequest<ISyncResponse>(
|
||||
undefined, Method.Get, "/sync", qps as any, undefined,
|
||||
qps.timeout + BUFFER_PERIOD_MS,
|
||||
);
|
||||
@@ -1166,6 +1257,9 @@ export class SyncApi {
|
||||
room.recalculate();
|
||||
client.store.storeRoom(room);
|
||||
client.emit(ClientEvent.Room, room);
|
||||
} else {
|
||||
// Update room state for invite->reject->invite cycles
|
||||
room.recalculate();
|
||||
}
|
||||
stateEvents.forEach(function(e) {
|
||||
client.emit(ClientEvent.Event, e);
|
||||
@@ -1251,7 +1345,6 @@ export class SyncApi {
|
||||
}
|
||||
|
||||
if (limited) {
|
||||
this.deregisterStateListeners(room);
|
||||
room.resetLiveTimeline(
|
||||
joinObj.timeline.prev_batch,
|
||||
this.opts.canResetEntireTimeline(room.roomId) ?
|
||||
@@ -1262,8 +1355,6 @@ export class SyncApi {
|
||||
// reason to stop incrementally tracking notifications and
|
||||
// reset the timeline.
|
||||
client.resetNotifTimelineSet();
|
||||
|
||||
this.registerStateListeners(room);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1587,7 +1678,9 @@ export class SyncApi {
|
||||
for (const ev of stateEventList) {
|
||||
this.client.getPushActionsForEvent(ev);
|
||||
}
|
||||
liveTimeline.initialiseState(stateEventList);
|
||||
liveTimeline.initialiseState(stateEventList, {
|
||||
timelineWasEmpty,
|
||||
});
|
||||
}
|
||||
|
||||
this.resolveInvites(room);
|
||||
@@ -1625,7 +1718,10 @@ export class SyncApi {
|
||||
// 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
|
||||
// to be decorated with sender etc.
|
||||
room.addLiveEvents(timelineEventList || [], null, fromCache);
|
||||
room.addLiveEvents(timelineEventList || [], {
|
||||
fromCache,
|
||||
timelineWasEmpty,
|
||||
});
|
||||
this.client.processBeaconEvents(room, timelineEventList);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
* 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.
|
||||
* @return {MatrixCall} the call or null if the browser doesn't support calling.
|
||||
*/
|
||||
export function createNewMatrixCall(client: any, roomId: string, options?: CallOpts): MatrixCall {
|
||||
// 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 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;
|
||||
}
|
||||
export function createNewMatrixCall(client: any, roomId: string, options?: CallOpts): MatrixCall | null {
|
||||
if (!supportsMatrixCall()) return null;
|
||||
|
||||
const optionsForceTURN = options ? options.forceTURN : false;
|
||||
|
||||
|
||||
@@ -94,7 +94,7 @@ export class CallEventHandler {
|
||||
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
|
||||
// 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)?
|
||||
let existingCall;
|
||||
let existingCall: MatrixCall;
|
||||
for (const thisCall of this.calls.values()) {
|
||||
const isCalling = [CallState.WaitLocalMedia, CallState.CreateOffer, CallState.InviteSent].includes(
|
||||
thisCall.state,
|
||||
@@ -384,7 +384,7 @@ export class CallEventHandler {
|
||||
|
||||
// The following events need a call and a peer connection
|
||||
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;
|
||||
}
|
||||
// Ignore remote echo
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user