1
0
mirror of https://github.com/matrix-org/matrix-js-sdk.git synced 2025-08-05 00:42:10 +03:00

Merge branch 'develop' into robertlong/group-call

This commit is contained in:
David Baker
2022-09-08 15:03:55 +01:00
committed by GitHub
45 changed files with 3096 additions and 1948 deletions

View File

@@ -25,6 +25,6 @@ jobs:
steps: steps:
- uses: tibdex/backport@v2 - uses: tibdex/backport@v2
with: with:
labels_template: "<%= JSON.stringify(labels) %>" labels_template: "<%= JSON.stringify([...labels, 'X-Release-Blocker']) %>"
# We can't use GITHUB_TOKEN here or CI won't run on the new PR # We can't use GITHUB_TOKEN here or CI won't run on the new PR
github_token: ${{ secrets.ELEMENT_BOT_TOKEN }} github_token: ${{ secrets.ELEMENT_BOT_TOKEN }}

39
.github/workflows/release-npm.yml vendored Normal file
View File

@@ -0,0 +1,39 @@
# Must only be called from `release#published` triggers
name: Publish to npm
on:
workflow_call:
secrets:
NPM_TOKEN:
required: true
jobs:
npm:
name: Publish to npm
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: 🚀 Publish to npm
id: npm-publish
uses: JS-DevTools/npm-publish@v1
with:
token: ${{ secrets.NPM_TOKEN }}
access: public
tag: next
- name: 🎖️ Add `latest` dist-tag to final releases
if: github.event.release.prerelease == false
run: |
package=$(cat package.json | jq -er .name)
npm dist-tag add "$package@$release" latest
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
release: ${{ steps.npm-publish.outputs.version }}

View File

@@ -24,7 +24,6 @@ jobs:
- name: 📋 Copy to temp - name: 📋 Copy to temp
run: | run: |
ls -lah
tag="${{ github.ref_name }}" tag="${{ github.ref_name }}"
version="${tag#v}" version="${tag#v}"
echo "VERSION=$version" >> $GITHUB_ENV echo "VERSION=$version" >> $GITHUB_ENV
@@ -51,3 +50,9 @@ jobs:
github_token: ${{ secrets.GITHUB_TOKEN }} github_token: ${{ secrets.GITHUB_TOKEN }}
keep_files: true keep_files: true
publish_dir: . publish_dir: .
npm:
name: Publish
uses: matrix-org/matrix-js-sdk/.github/workflows/release-npm.yml@develop
secrets:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

View File

@@ -54,3 +54,38 @@ jobs:
- name: Generate Docs - name: Generate Docs
run: "yarn run gendoc" run: "yarn run gendoc"
tsc-strict:
name: Typescript Strict Error Checker
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
permissions:
pull-requests: read
checks: write
steps:
- uses: actions/checkout@v3
- name: Get diff lines
id: diff
uses: Equip-Collaboration/diff-line-numbers@v1.0.0
with:
include: '["\\.tsx?$"]'
- name: Detecting files changed
id: files
uses: futuratrepadeira/changed-files@v4.0.0
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
pattern: '^.*\.tsx?$'
- uses: t3chguy/typescript-check-action@main
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
use-check: false
check-fail-mode: added
output-behaviour: annotate
ts-extra-args: '--strict'
files-changed: ${{ steps.files.outputs.files_updated }}
files-added: ${{ steps.files.outputs.files_created }}
files-deleted: ${{ steps.files.outputs.files_deleted }}
line-numbers: ${{ steps.diff.outputs.lineNumbers }}

View File

@@ -1,3 +1,25 @@
Changes in [19.4.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v19.4.0) (2022-08-31)
==================================================================================================
## 🔒 Security
* Fix for [CVE-2022-36059](https://cve.mitre.org/cgi-bin/cvekey.cgi?keyword=CVE%2D2022%2D36059)
Find more details at https://matrix.org/blog/2022/08/31/security-releases-matrix-js-sdk-19-4-0-and-matrix-react-sdk-3-53-0
## ✨ Features
* Re-emit room state events on rooms ([\#2607](https://github.com/matrix-org/matrix-js-sdk/pull/2607)).
* Add ability to override built in room name generator for an i18n'able one ([\#2609](https://github.com/matrix-org/matrix-js-sdk/pull/2609)).
* Add txn_id support to sliding sync ([\#2567](https://github.com/matrix-org/matrix-js-sdk/pull/2567)).
## 🐛 Bug Fixes
* Refactor Sync and fix `initialSyncLimit` ([\#2587](https://github.com/matrix-org/matrix-js-sdk/pull/2587)).
* Use deep equality comparisons when searching for outgoing key requests by target ([\#2623](https://github.com/matrix-org/matrix-js-sdk/pull/2623)). Contributed by @duxovni.
* Fix room membership race with PREPARED event ([\#2613](https://github.com/matrix-org/matrix-js-sdk/pull/2613)). Contributed by @jotto.
* fixed a sliding sync bug which could cause the `roomIndexToRoomId` map to be incorrect when a new room is added in the middle of the list or when an existing room is deleted from the middle of the list. ([\#2610](https://github.com/matrix-org/matrix-js-sdk/pull/2610)).
* Fix: Handle parsing of a beacon info event without asset ([\#2591](https://github.com/matrix-org/matrix-js-sdk/pull/2591)). Fixes vector-im/element-web#23078. Contributed by @kerryarchibald.
* Fix finding event read up to if stable private read receipts is missing ([\#2585](https://github.com/matrix-org/matrix-js-sdk/pull/2585)). Fixes vector-im/element-web#23027.
* fixed a sliding sync issue where history could be interpreted as live events. ([\#2583](https://github.com/matrix-org/matrix-js-sdk/pull/2583)).
Changes in [19.3.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v19.3.0) (2022-08-16) Changes in [19.3.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v19.3.0) (2022-08-16)
================================================================================================== ==================================================================================================

View File

@@ -1,284 +1,5 @@
Contributing code to matrix-js-sdk Contributing code to matrix-js-sdk
================================== ==================================
Everyone is welcome to contribute code to matrix-js-sdk, provided that they are matrix-js-sdk follows the same pattern as https://github.com/vector-im/element-web/blob/develop/CONTRIBUTING.md
willing to license their contributions under the same license as the project
itself. We follow a simple 'inbound=outbound' model for contributions: the act
of submitting an 'inbound' contribution means that the contributor agrees to
license the code under the same terms as the project's overall 'outbound'
license - in this case, Apache Software License v2 (see
[LICENSE](LICENSE)).
How to contribute
-----------------
The preferred and easiest way to contribute changes to the project is to fork
it on github, and then create a pull request to ask us to pull your changes
into our repo (https://help.github.com/articles/using-pull-requests/)
We use GitHub's pull request workflow to review the contribution, and either
ask you to make any refinements needed or merge it and make them ourselves.
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. 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
code locally and easily get to the point of testing your change.
* 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.
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
the `Notes:` annotation in the description. By default, the PR title will be
used for the changelog entry, but you can specify more options, as follows.
To add a longer, more detailed description of the change for the changelog:
*Fix llama herding bug*
```
Notes: Fix a bug (https://github.com/matrix-org/notaproject/issues/123) where the 'Herd' button would not herd more than 8 Llamas if the moon was in the waxing gibbous phase
```
For some PRs, it's not useful to have an entry in the user-facing changelog (this is
the default for PRs labelled with `T-Task`):
*Remove outdated comment from `Ungulates.ts`*
```
Notes: none
```
Sometimes, you're fixing a bug in a downstream project, in which case you want
an entry in that project's changelog. You can do that too:
*Fix another herding bug*
```
Notes: Fix a bug where the `herd()` function would only work on Tuesdays
element-web notes: Fix a bug where the 'Herd' button only worked on Tuesdays
```
This example is for Element Web. You can specify:
* matrix-react-sdk
* element-web
* element-desktop
If your PR introduces a breaking change, use the `Notes` section in the same
way, additionally adding the `X-Breaking-Change` label (see below). There's no need
to specify in the notes that it's a breaking change - this will be added
automatically based on the label - but remember to tell the developer how to
migrate:
*Remove legacy class*
```
Notes: Remove legacy `Camelopard` class. `Giraffe` should be used instead.
```
Other metadata can be added using labels.
* `X-Breaking-Change`: A breaking change - adding this label will mean the change causes a *major* version bump.
* `T-Enhancement`: A new feature - adding this label will mean the change causes a *minor* version bump.
* `T-Defect`: A bug fix (in either code or docs).
* `T-Task`: No user-facing changes, eg. code comments, CI fixes, refactors or tests. Won't have a changelog entry unless you specify one.
If you don't have permission to add labels, your PR reviewer(s) can work with you
to add them: ask in the PR description or comments.
We use continuous integration, and all pull requests get automatically tested:
if your change breaks the build, then the PR will show that there are failed
checks, so please check back after a few minutes.
Tests
-----
Your PR should include tests.
For new user facing features in `matrix-react-sdk` or `element-web`, you
must include:
1. Comprehensive unit tests written in Jest. These are located in `/test`.
2. "happy path" end-to-end tests.
These are located in `/test/end-to-end-tests` in `matrix-react-sdk`, and
are run using `element-web`. Ideally, you would also include tests for edge
and error cases.
Unit tests are expected even when the feature is in labs. It's good practice
to write tests alongside the code as it ensures the code is testable from
the start, and gives you a fast feedback loop while you're developing the
functionality. End-to-end tests should be added prior to the feature
leaving labs, but don't have to be present from the start (although it might
be beneficial to have some running early, so you can test things faster).
For bugs in those repos, your change must include at least one unit test or
end-to-end test; which is best depends on what sort of test most concisely
exercises the area.
Changes to `matrix-js-sdk` must be accompanied by unit tests written in Jest.
These are located in `/spec/`.
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
find this simpler to achieve if you write the tests first.
If you're spiking some code that's experimental and not being used to support
production features, exceptions can be made to requirements for tests.
Note that tests will still be required in order to ship the feature, and it's
strongly encouraged to think about tests early in the process, as adding
tests later will become progressively more difficult.
If you're not sure how to approach writing tests for your change, ask for help
in [#element-dev](https://matrix.to/#/#element-dev:matrix.org).
Code style
----------
The js-sdk aims to target TypeScript/ES6. All new files should be written in
TypeScript and existing files should use ES6 principles where possible.
Members should not be exported as a default export in general - it causes problems
with the architecture of the SDK (index file becomes less clear) and could
introduce naming problems (as default exports get aliased upon import). In
general, avoid using `export default`.
The remaining code-style for matrix-js-sdk is not formally documented, but
contributors are encouraged to read the
[code style document for matrix-react-sdk](https://github.com/matrix-org/matrix-react-sdk/blob/master/code_style.md)
and follow the principles set out there.
Please ensure your changes match the cosmetic style of the existing project,
and ***never*** mix cosmetic and functional changes in the same commit, as it
makes it horribly hard to review otherwise.
Attribution
-----------
Everyone who contributes anything to Matrix is welcome to be listed in the
AUTHORS.rst file for the project in question. Please feel free to include a
change to AUTHORS.rst in your pull request to list yourself and a short
description of the area(s) you've worked on. Also, we sometimes have swag to
give away to contributors - if you feel that Matrix-branded apparel is missing
from your life, please mail us your shipping address to matrix at matrix.org
and we'll try to fix it :)
Sign off
--------
In order to have a concrete record that your contribution is intentional
and you agree to license it under the same terms as the project's license, we've
adopted the same lightweight approach that the Linux Kernel
(https://www.kernel.org/doc/Documentation/SubmittingPatches), Docker
(https://github.com/docker/docker/blob/master/CONTRIBUTING.md), and many other
projects use: the DCO (Developer Certificate of Origin:
http://developercertificate.org/). This is a simple declaration that you wrote
the contribution or otherwise have the right to contribute it to Matrix:
```
Developer Certificate of Origin
Version 1.1
Copyright (C) 2004, 2006 The Linux Foundation and its contributors.
660 York Street, Suite 102,
San Francisco, CA 94110 USA
Everyone is permitted to copy and distribute verbatim copies of this
license document, but changing it is not allowed.
Developer's Certificate of Origin 1.1
By making a contribution to this project, I certify that:
(a) The contribution was created in whole or in part by me and I
have the right to submit it under the open source license
indicated in the file; or
(b) The contribution is based upon previous work that, to the best
of my knowledge, is covered under an appropriate open source
license and I have the right under that license to submit that
work with modifications, whether created in whole or in part
by me, under the same open source license (unless I am
permitted to submit under a different license), as indicated
in the file; or
(c) The contribution was provided directly to me by some other
person who certified (a), (b) or (c) and I have not modified
it.
(d) I understand and agree that this project and the contribution
are public and that a record of the contribution (including all
personal information I submit with it, including my sign-off) is
maintained indefinitely and may be redistributed consistent with
this project or the open source license(s) involved.
```
If you agree to this for your contribution, then all that's needed is to
include the line in your commit or pull request comment:
```
Signed-off-by: Your Name <your@email.example.org>
```
We accept contributions under a legally identifiable name, such as your name on
government documentation or common-law names (names claimed by legitimate usage
or repute). Unfortunately, we cannot accept anonymous contributions at this
time.
Git allows you to add this signoff automatically when using the `-s` flag to
`git commit`, which uses the name and email set in your `user.name` and
`user.email` git configs.
If you forgot to sign off your commits before making your pull request and are
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. 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

View File

@@ -1,6 +1,6 @@
{ {
"name": "matrix-js-sdk", "name": "matrix-js-sdk",
"version": "19.3.0", "version": "19.4.0",
"description": "Matrix Client-Server SDK for Javascript", "description": "Matrix Client-Server SDK for Javascript",
"engines": { "engines": {
"node": ">=12.9.0" "node": ">=12.9.0"
@@ -84,25 +84,25 @@
"@matrix-org/olm": "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.12.tgz", "@matrix-org/olm": "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.12.tgz",
"@types/bs58": "^4.0.1", "@types/bs58": "^4.0.1",
"@types/content-type": "^1.1.5", "@types/content-type": "^1.1.5",
"@types/jest": "^28.0.0", "@types/jest": "^29.0.0",
"@types/node": "16", "@types/node": "16",
"@types/request": "^2.48.5", "@types/request": "^2.48.5",
"@typescript-eslint/eslint-plugin": "^5.6.0", "@typescript-eslint/eslint-plugin": "^5.6.0",
"@typescript-eslint/parser": "^5.6.0", "@typescript-eslint/parser": "^5.6.0",
"allchange": "^1.0.6", "allchange": "^1.0.6",
"babel-jest": "^28.0.0", "babel-jest": "^29.0.0",
"babelify": "^10.0.0", "babelify": "^10.0.0",
"better-docs": "^2.4.0-beta.9", "better-docs": "^2.4.0-beta.9",
"browserify": "^17.0.0", "browserify": "^17.0.0",
"docdash": "^1.2.0", "docdash": "^1.2.0",
"eslint": "8.20.0", "eslint": "8.23.0",
"eslint-config-google": "^0.14.0", "eslint-config-google": "^0.14.0",
"eslint-plugin-import": "^2.25.4", "eslint-plugin-import": "^2.25.4",
"eslint-plugin-matrix-org": "^0.5.0", "eslint-plugin-matrix-org": "^0.6.0",
"exorcist": "^2.0.0", "exorcist": "^2.0.0",
"fake-indexeddb": "^4.0.0", "fake-indexeddb": "^4.0.0",
"jest": "^28.0.0",
"jest-environment-jsdom": "^28.1.3", "jest-environment-jsdom": "^28.1.3",
"jest": "^29.0.0",
"jest-localstorage-mock": "^2.4.6", "jest-localstorage-mock": "^2.4.6",
"jest-sonar-reporter": "^2.0.0", "jest-sonar-reporter": "^2.0.0",
"jsdoc": "^3.6.6", "jsdoc": "^3.6.6",

37
post-release.sh Executable file
View File

@@ -0,0 +1,37 @@
#!/bin/bash
#
# Script to perform a post-release steps of matrix-js-sdk.
#
# Requires:
# jq; install from your distribution's package manager (https://stedolan.github.io/jq/)
set -e
jq --version > /dev/null || (echo "jq is required: please install it"; kill $$)
if [ "$(git branch -lr | grep origin/develop -c)" -ge 1 ]; then
# When merging to develop, we need revert the `main` and `typings` fields if we adjusted them previously.
for i in main typings
do
# If a `lib` prefixed value is present, it means we adjusted the field
# earlier at publish time, so we should revert it now.
if [ "$(jq -r ".matrix_lib_$i" package.json)" != "null" ]; then
# If there's a `src` prefixed value, use that, otherwise delete.
# This is used to delete the `typings` field and reset `main` back
# to the TypeScript source.
src_value=$(jq -r ".matrix_src_$i" package.json)
if [ "$src_value" != "null" ]; then
jq ".$i = .matrix_src_$i" package.json > package.json.new && mv package.json.new package.json
else
jq "del(.$i)" package.json > package.json.new && mv package.json.new package.json
fi
fi
done
if [ -n "$(git ls-files --modified package.json)" ]; then
echo "Committing develop package.json"
git commit package.json -m "Resetting package fields for development"
fi
git push origin develop
fi

View File

@@ -3,19 +3,16 @@
# Script to perform a release of matrix-js-sdk and downstream projects. # Script to perform a release of matrix-js-sdk and downstream projects.
# #
# Requires: # Requires:
# github-changelog-generator; install via:
# pip install git+https://github.com/matrix-org/github-changelog-generator.git
# jq; install from your distribution's package manager (https://stedolan.github.io/jq/) # jq; install from your distribution's package manager (https://stedolan.github.io/jq/)
# hub; install via brew (macOS) or source/pre-compiled binaries (debian) (https://github.com/github/hub) - Tested on v2.2.9 # hub; install via brew (macOS) or source/pre-compiled binaries (debian) (https://github.com/github/hub) - Tested on v2.2.9
# npm; typically installed by Node.js
# yarn; install via brew (macOS) or similar (https://yarnpkg.com/docs/install/) # yarn; install via brew (macOS) or similar (https://yarnpkg.com/docs/install/)
# #
# Note: this script is also used to release matrix-react-sdk and element-web. # Note: this script is also used to release matrix-react-sdk, element-web, and element-desktop.
set -e set -e
jq --version > /dev/null || (echo "jq is required: please install it"; kill $$) jq --version > /dev/null || (echo "jq is required: please install it"; kill $$)
if [[ `command -v hub` ]] && [[ `hub --version` =~ hub[[:space:]]version[[:space:]]([0-9]*).([0-9]*) ]]; then if [[ $(command -v hub) ]] && [[ $(hub --version) =~ hub[[:space:]]version[[:space:]]([0-9]*).([0-9]*) ]]; then
HUB_VERSION_MAJOR=${BASH_REMATCH[1]} HUB_VERSION_MAJOR=${BASH_REMATCH[1]}
HUB_VERSION_MINOR=${BASH_REMATCH[2]} HUB_VERSION_MINOR=${BASH_REMATCH[2]}
if [[ $HUB_VERSION_MAJOR -lt 2 ]] || [[ $HUB_VERSION_MAJOR -eq 2 && $HUB_VERSION_MINOR -lt 5 ]]; then if [[ $HUB_VERSION_MAJOR -lt 2 ]] || [[ $HUB_VERSION_MAJOR -eq 2 && $HUB_VERSION_MINOR -lt 5 ]]; then
@@ -26,7 +23,6 @@ else
echo "hub is required: please install it" echo "hub is required: please install it"
exit exit
fi 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 $$) yarn --version > /dev/null || (echo "yarn is required: please install it"; kill $$)
USAGE="$0 [-x] [-c changelog_file] vX.Y.Z" USAGE="$0 [-x] [-c changelog_file] vX.Y.Z"
@@ -37,7 +33,6 @@ $USAGE
-c changelog_file: specify name of file containing changelog -c changelog_file: specify name of file containing changelog
-x: skip updating the changelog -x: skip updating the changelog
-n: skip publish to NPM
EOF EOF
} }
@@ -59,10 +54,8 @@ if ! git diff-files --quiet; then
fi fi
skip_changelog= skip_changelog=
skip_npm=
changelog_file="CHANGELOG.md" changelog_file="CHANGELOG.md"
expected_npm_user="matrixdotorg" while getopts hc:x f; do
while getopts hc:u:xzn f; do
case $f in case $f in
h) h)
help help
@@ -74,21 +67,58 @@ while getopts hc:u:xzn f; do
x) x)
skip_changelog=1 skip_changelog=1
;; ;;
n)
skip_npm=1
;;
u)
expected_npm_user="$OPTARG"
;;
esac esac
done done
shift `expr $OPTIND - 1` shift $(expr $OPTIND - 1)
if [ $# -ne 1 ]; then if [ $# -ne 1 ]; then
echo "Usage: $USAGE" >&2 echo "Usage: $USAGE" >&2
exit 1 exit 1
fi fi
function check_dependency {
echo "Checking version of $1..."
local depver=$(cat package.json | jq -r .dependencies[\"$1\"])
local latestver=$(yarn info -s "$1" dist-tags.next)
if [ "$depver" != "$latestver" ]
then
echo "The latest version of $1 is $latestver but package.json depends on $depver."
echo -n "Type 'u' to auto-upgrade, 'c' to continue anyway, or 'a' to abort:"
read resp
if [ "$resp" != "u" ] && [ "$resp" != "c" ]
then
echo "Aborting."
exit 1
fi
if [ "$resp" == "u" ]
then
echo "Upgrading $1 to $latestver..."
yarn add -E "$1@$latestver"
git add -u
git commit -m "Upgrade $1 to $latestver"
fi
fi
}
function reset_dependency {
echo "Resetting $1 to develop branch..."
yarn add "github:matrix-org/$1#develop"
git add -u
git commit -m "Reset $1 back to develop branch"
}
has_subprojects=0
if [ -f release_config.yaml ]; then
subprojects=$(cat release_config.yaml | python -c "import yaml; import sys; print(' '.join(list(yaml.load(sys.stdin)['subprojects'].keys())))" 2> /dev/null)
if [ "$?" -eq 0 ]; then
has_subprojects=1
echo "Checking subprojects for upgrades"
for proj in $subprojects; do
check_dependency "$proj"
done
fi
fi
# We use Git branch / commit dependencies for some packages, and Yarn seems # We use Git branch / commit dependencies for some packages, and Yarn seems
# to have a hard time getting that right. See also # to have a hard time getting that right. See also
# https://github.com/yarnpkg/yarn/issues/4734. As a workaround, we clean the # https://github.com/yarnpkg/yarn/issues/4734. As a workaround, we clean the
@@ -97,16 +127,6 @@ yarn cache clean
# Ensure all dependencies are updated # Ensure all dependencies are updated
yarn install --ignore-scripts --pure-lockfile yarn install --ignore-scripts --pure-lockfile
# Login and publish continues to use `npm`, as it seems to have more clearly
# defined options and semantics than `yarn` for writing to the registry.
if [ -z "$skip_npm" ]; then
actual_npm_user=`npm whoami`;
if [ $expected_npm_user != $actual_npm_user ]; then
echo "you need to be logged into npm as $expected_npm_user, but you are logged in as $actual_npm_user" >&2
exit 1
fi
fi
# ignore leading v on release # ignore leading v on release
release="${1#v}" release="${1#v}"
tag="v${release}" tag="v${release}"
@@ -117,7 +137,7 @@ prerelease=0
# see if the version has a hyphen in it. Crude, # see if the version has a hyphen in it. Crude,
# but semver doesn't support postreleases so anything # but semver doesn't support postreleases so anything
# with a hyphen is a prerelease. # with a hyphen is a prerelease.
echo $release | grep -q '-' && prerelease=1 echo "$release" | grep -q '-' && prerelease=1
if [ $prerelease -eq 1 ]; then if [ $prerelease -eq 1 ]; then
echo Making a PRE-RELEASE echo Making a PRE-RELEASE
@@ -143,13 +163,13 @@ if [ -z "$skip_changelog" ]; then
yarn run allchange "$release" yarn run allchange "$release"
read -p "Edit $changelog_file manually, or press enter to continue " REPLY read -p "Edit $changelog_file manually, or press enter to continue " REPLY
if [ -n "$(git ls-files --modified $changelog_file)" ]; then if [ -n "$(git ls-files --modified "$changelog_file")" ]; then
echo "Committing updated changelog" echo "Committing updated changelog"
git commit "$changelog_file" -m "Prepare changelog for $tag" git commit "$changelog_file" -m "Prepare changelog for $tag"
fi fi
fi fi
latest_changes=`mktemp` latest_changes=$(mktemp)
cat "${changelog_file}" | `dirname $0`/scripts/changelog_head.py > "${latest_changes}" cat "${changelog_file}" | "$(dirname "$0")/scripts/changelog_head.py" > "${latest_changes}"
set -x set -x
@@ -176,19 +196,19 @@ do
done done
# commit yarn.lock if it exists, is versioned, and is modified # commit yarn.lock if it exists, is versioned, and is modified
if [[ -f yarn.lock && `git status --porcelain yarn.lock | grep '^ M'` ]]; if [[ -f yarn.lock && $(git status --porcelain yarn.lock | grep '^ M') ]];
then then
pkglock='yarn.lock' pkglock='yarn.lock'
else else
pkglock='' pkglock=''
fi fi
git commit package.json $pkglock -m "$tag" git commit package.json "$pkglock" -m "$tag"
# figure out if we should be signing this release # figure out if we should be signing this release
signing_id= signing_id=
if [ -f release_config.yaml ]; then if [ -f release_config.yaml ]; then
result=`cat release_config.yaml | python -c "import yaml; import sys; print yaml.load(sys.stdin)['signing_id']" 2> /dev/null || true` result=$(cat release_config.yaml | python -c "import yaml; import sys; print(yaml.load(sys.stdin)['signing_id'])" 2> /dev/null || true)
if [ "$?" -eq 0 ]; then if [ "$?" -eq 0 ]; then
signing_id=$result signing_id=$result
fi fi
@@ -206,8 +226,8 @@ assets=''
dodist=0 dodist=0
jq -e .scripts.dist package.json 2> /dev/null || dodist=$? jq -e .scripts.dist package.json 2> /dev/null || dodist=$?
if [ $dodist -eq 0 ]; then if [ $dodist -eq 0 ]; then
projdir=`pwd` projdir=$(pwd)
builddir=`mktemp -d 2>/dev/null || mktemp -d -t 'mytmpdir'` builddir=$(mktemp -d 2>/dev/null || mktemp -d -t 'mytmpdir')
echo "Building distribution copy in $builddir" echo "Building distribution copy in $builddir"
pushd "$builddir" pushd "$builddir"
git clone "$projdir" . git clone "$projdir" .
@@ -232,7 +252,7 @@ fi
if [ -n "$signing_id" ]; then if [ -n "$signing_id" ]; then
# make a signed tag # make a signed tag
# gnupg seems to fail to get the right tty device unless we set it here # gnupg seems to fail to get the right tty device unless we set it here
GIT_COMMITTER_EMAIL="$signing_id" GPG_TTY=`tty` git tag -u "$signing_id" -F "${latest_changes}" "$tag" GIT_COMMITTER_EMAIL="$signing_id" GPG_TTY=$(tty) git tag -u "$signing_id" -F "${latest_changes}" "$tag"
else else
git tag -a -F "${latest_changes}" "$tag" git tag -a -F "${latest_changes}" "$tag"
fi fi
@@ -270,7 +290,7 @@ if [ -n "$signing_id" ]; then
curl -L "${gh_project_url}/archive/${tarfile}" -o "${tarfile}" curl -L "${gh_project_url}/archive/${tarfile}" -o "${tarfile}"
# unzip it and compare it with the tar we would generate # unzip it and compare it with the tar we would generate
if ! cmp --silent <(gunzip -c $tarfile) \ if ! cmp --silent <(gunzip -c "$tarfile") \
<(git archive --format tar --prefix="${project_name}-${release}/" "$tag"); then <(git archive --format tar --prefix="${project_name}-${release}/" "$tag"); then
# we don't bail out here, because really it's more likely that our comparison # we don't bail out here, because really it's more likely that our comparison
@@ -298,11 +318,11 @@ if [ $prerelease -eq 1 ]; then
hubflags='-p' hubflags='-p'
fi fi
release_text=`mktemp` release_text=$(mktemp)
echo "$tag" > "${release_text}" echo "$tag" > "${release_text}"
echo >> "${release_text}" echo >> "${release_text}"
cat "${latest_changes}" >> "${release_text}" cat "${latest_changes}" >> "${release_text}"
hub release create $hubflags $assets -F "${release_text}" "$tag" hub release create $hubflags "$assets" -F "${release_text}" "$tag"
if [ $dodist -eq 0 ]; then if [ $dodist -eq 0 ]; then
rm -rf "$builddir" rm -rf "$builddir"
@@ -310,19 +330,6 @@ fi
rm "${release_text}" rm "${release_text}"
rm "${latest_changes}" rm "${latest_changes}"
# Login and publish continues to use `npm`, as it seems to have more clearly
# defined options and semantics than `yarn` for writing to the registry.
# Tag both releases and prereleases as `next` so the last stable release remains
# the default.
if [ -z "$skip_npm" ]; then
npm publish --tag next
if [ $prerelease -eq 0 ]; then
# For a release, also add the default `latest` tag.
package=$(cat package.json | jq -er .name)
npm dist-tag add "$package@$release" latest
fi
fi
# if it is a pre-release, leave it on the release branch for now. # if it is a pre-release, leave it on the release branch for now.
if [ $prerelease -eq 1 ]; then if [ $prerelease -eq 1 ]; then
git checkout "$rel_branch" git checkout "$rel_branch"
@@ -339,34 +346,19 @@ git merge "$rel_branch" --no-edit
git push origin master git push origin master
# finally, merge master back onto develop (if it exists) # finally, merge master back onto develop (if it exists)
if [ $(git branch -lr | grep origin/develop -c) -ge 1 ]; then if [ "$(git branch -lr | grep origin/develop -c)" -ge 1 ]; then
git checkout develop git checkout develop
git pull git pull
git merge master --no-edit git merge master --no-edit
git push origin develop
# When merging to develop, we need revert the `main` and `typings` fields if fi
# we adjusted them previously.
for i in main typings [ -x ./post-release.sh ] && ./post-release.sh
do
# If a `lib` prefixed value is present, it means we adjusted the field if [ $has_subprojects -eq 1 ] && [ $prerelease -eq 0 ]; then
# earlier at publish time, so we should revert it now. echo "Resetting subprojects to develop"
if [ "$(jq -r ".matrix_lib_$i" package.json)" != "null" ]; then for proj in $subprojects; do
# If there's a `src` prefixed value, use that, otherwise delete. reset_dependency "$proj"
# This is used to delete the `typings` field and reset `main` back done
# to the TypeScript source.
src_value=$(jq -r ".matrix_src_$i" package.json)
if [ "$src_value" != "null" ]; then
jq ".$i = .matrix_src_$i" package.json > package.json.new && mv package.json.new package.json
else
jq "del(.$i)" package.json > package.json.new && mv package.json.new package.json
fi
fi
done
if [ -n "$(git ls-files --modified package.json)" ]; then
echo "Committing develop package.json"
git commit package.json -m "Resetting package fields for development"
fi
git push origin develop git push origin develop
fi fi

View File

@@ -1,6 +1,6 @@
/* /*
Copyright 2015, 2016 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Copyright 2019 The Matrix.org Foundation C.I.C. Copyright 2019, 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@@ -17,31 +17,32 @@ limitations under the License.
/** /**
* A mock implementation of the webstorage api * A mock implementation of the webstorage api
* @constructor
*/ */
export function MockStorageApi() { export class MockStorageApi {
this.data = {}; public data: Record<string, string> = {};
this.keys = []; public keys: string[] = [];
this.length = 0; public length = 0;
}
MockStorageApi.prototype = { public setItem(k: string, v: string): void {
setItem: function(k, v) {
this.data[k] = v; this.data[k] = v;
this._recalc(); this.recalc();
}, }
getItem: function(k) {
public getItem(k: string): string | null {
return this.data[k] || null; return this.data[k] || null;
}, }
removeItem: function(k) {
public removeItem(k: string): void {
delete this.data[k]; delete this.data[k];
this._recalc(); this.recalc();
}, }
key: function(index) {
public key(index: number): string {
return this.keys[index]; return this.keys[index];
}, }
_recalc: function() {
const keys = []; private recalc(): void {
const keys: string[] = [];
for (const k in this.data) { for (const k in this.data) {
if (!this.data.hasOwnProperty(k)) { if (!this.data.hasOwnProperty(k)) {
continue; continue;
@@ -50,6 +51,5 @@ MockStorageApi.prototype = {
} }
this.keys = keys; this.keys = keys;
this.length = keys.length; this.length = keys.length;
}, }
}; }

View File

@@ -50,7 +50,7 @@ export class TestClient {
options?: Partial<ICreateClientOpts>, options?: Partial<ICreateClientOpts>,
) { ) {
if (sessionStoreBackend === undefined) { if (sessionStoreBackend === undefined) {
sessionStoreBackend = new MockStorageApi(); sessionStoreBackend = new MockStorageApi() as unknown as Storage;
} }
this.httpBackend = new MockHttpBackend(); this.httpBackend = new MockHttpBackend();

View File

@@ -168,6 +168,7 @@ describe("SlidingSyncSdk", () => {
const roomD = "!d_with_notif_count:localhost"; const roomD = "!d_with_notif_count:localhost";
const roomE = "!e_with_invite:localhost"; const roomE = "!e_with_invite:localhost";
const roomF = "!f_calc_room_name:localhost"; const roomF = "!f_calc_room_name:localhost";
const roomG = "!g_join_invite_counts:localhost";
const data: Record<string, MSC3575RoomData> = { const data: Record<string, MSC3575RoomData> = {
[roomA]: { [roomA]: {
name: "A", name: "A",
@@ -261,12 +262,25 @@ describe("SlidingSyncSdk", () => {
], ],
initial: true, initial: true,
}, },
[roomG]: {
name: "G",
required_state: [],
timeline: [
mkOwnStateEvent(EventType.RoomCreate, { creator: selfUserId }, ""),
mkOwnStateEvent(EventType.RoomMember, { membership: "join" }, selfUserId),
mkOwnStateEvent(EventType.RoomPowerLevels, { users: { [selfUserId]: 100 } }, ""),
],
joined_count: 5,
invited_count: 2,
initial: true,
},
}; };
it("can be created with required_state and timeline", () => { it("can be created with required_state and timeline", () => {
mockSlidingSync.emit(SlidingSyncEvent.RoomData, roomA, data[roomA]); mockSlidingSync.emit(SlidingSyncEvent.RoomData, roomA, data[roomA]);
const gotRoom = client.getRoom(roomA); const gotRoom = client.getRoom(roomA);
expect(gotRoom).toBeDefined(); expect(gotRoom).toBeDefined();
if (gotRoom == null) { return; }
expect(gotRoom.name).toEqual(data[roomA].name); expect(gotRoom.name).toEqual(data[roomA].name);
expect(gotRoom.getMyMembership()).toEqual("join"); expect(gotRoom.getMyMembership()).toEqual("join");
assertTimelineEvents(gotRoom.getLiveTimeline().getEvents().slice(-2), data[roomA].timeline); assertTimelineEvents(gotRoom.getLiveTimeline().getEvents().slice(-2), data[roomA].timeline);
@@ -276,6 +290,7 @@ describe("SlidingSyncSdk", () => {
mockSlidingSync.emit(SlidingSyncEvent.RoomData, roomB, data[roomB]); mockSlidingSync.emit(SlidingSyncEvent.RoomData, roomB, data[roomB]);
const gotRoom = client.getRoom(roomB); const gotRoom = client.getRoom(roomB);
expect(gotRoom).toBeDefined(); expect(gotRoom).toBeDefined();
if (gotRoom == null) { return; }
expect(gotRoom.name).toEqual(data[roomB].name); expect(gotRoom.name).toEqual(data[roomB].name);
expect(gotRoom.getMyMembership()).toEqual("join"); expect(gotRoom.getMyMembership()).toEqual("join");
assertTimelineEvents(gotRoom.getLiveTimeline().getEvents().slice(-5), data[roomB].timeline); assertTimelineEvents(gotRoom.getLiveTimeline().getEvents().slice(-5), data[roomB].timeline);
@@ -285,6 +300,7 @@ describe("SlidingSyncSdk", () => {
mockSlidingSync.emit(SlidingSyncEvent.RoomData, roomC, data[roomC]); mockSlidingSync.emit(SlidingSyncEvent.RoomData, roomC, data[roomC]);
const gotRoom = client.getRoom(roomC); const gotRoom = client.getRoom(roomC);
expect(gotRoom).toBeDefined(); expect(gotRoom).toBeDefined();
if (gotRoom == null) { return; }
expect( expect(
gotRoom.getUnreadNotificationCount(NotificationCountType.Highlight), gotRoom.getUnreadNotificationCount(NotificationCountType.Highlight),
).toEqual(data[roomC].highlight_count); ).toEqual(data[roomC].highlight_count);
@@ -294,15 +310,26 @@ describe("SlidingSyncSdk", () => {
mockSlidingSync.emit(SlidingSyncEvent.RoomData, roomD, data[roomD]); mockSlidingSync.emit(SlidingSyncEvent.RoomData, roomD, data[roomD]);
const gotRoom = client.getRoom(roomD); const gotRoom = client.getRoom(roomD);
expect(gotRoom).toBeDefined(); expect(gotRoom).toBeDefined();
if (gotRoom == null) { return; }
expect( expect(
gotRoom.getUnreadNotificationCount(NotificationCountType.Total), gotRoom.getUnreadNotificationCount(NotificationCountType.Total),
).toEqual(data[roomD].notification_count); ).toEqual(data[roomD].notification_count);
}); });
it("can be created with an invited/joined_count", () => {
mockSlidingSync.emit(SlidingSyncEvent.RoomData, roomG, data[roomG]);
const gotRoom = client.getRoom(roomG);
expect(gotRoom).toBeDefined();
if (gotRoom == null) { return; }
expect(gotRoom.getInvitedMemberCount()).toEqual(data[roomG].invited_count);
expect(gotRoom.getJoinedMemberCount()).toEqual(data[roomG].joined_count);
});
it("can be created with invite_state", () => { it("can be created with invite_state", () => {
mockSlidingSync.emit(SlidingSyncEvent.RoomData, roomE, data[roomE]); mockSlidingSync.emit(SlidingSyncEvent.RoomData, roomE, data[roomE]);
const gotRoom = client.getRoom(roomE); const gotRoom = client.getRoom(roomE);
expect(gotRoom).toBeDefined(); expect(gotRoom).toBeDefined();
if (gotRoom == null) { return; }
expect(gotRoom.getMyMembership()).toEqual("invite"); expect(gotRoom.getMyMembership()).toEqual("invite");
expect(gotRoom.currentState.getJoinRule()).toEqual(JoinRule.Invite); expect(gotRoom.currentState.getJoinRule()).toEqual(JoinRule.Invite);
}); });
@@ -311,6 +338,7 @@ describe("SlidingSyncSdk", () => {
mockSlidingSync.emit(SlidingSyncEvent.RoomData, roomF, data[roomF]); mockSlidingSync.emit(SlidingSyncEvent.RoomData, roomF, data[roomF]);
const gotRoom = client.getRoom(roomF); const gotRoom = client.getRoom(roomF);
expect(gotRoom).toBeDefined(); expect(gotRoom).toBeDefined();
if (gotRoom == null) { return; }
expect( expect(
gotRoom.name, gotRoom.name,
).toEqual(data[roomF].name); ).toEqual(data[roomF].name);
@@ -326,6 +354,7 @@ describe("SlidingSyncSdk", () => {
}); });
const gotRoom = client.getRoom(roomA); const gotRoom = client.getRoom(roomA);
expect(gotRoom).toBeDefined(); expect(gotRoom).toBeDefined();
if (gotRoom == null) { return; }
const newTimeline = data[roomA].timeline; const newTimeline = data[roomA].timeline;
newTimeline.push(newEvent); newTimeline.push(newEvent);
assertTimelineEvents(gotRoom.getLiveTimeline().getEvents().slice(-3), newTimeline); assertTimelineEvents(gotRoom.getLiveTimeline().getEvents().slice(-3), newTimeline);
@@ -333,6 +362,8 @@ describe("SlidingSyncSdk", () => {
it("can update with a new required_state event", async () => { it("can update with a new required_state event", async () => {
let gotRoom = client.getRoom(roomB); let gotRoom = client.getRoom(roomB);
expect(gotRoom).toBeDefined();
if (gotRoom == null) { return; }
expect(gotRoom.getJoinRule()).toEqual(JoinRule.Invite); // default expect(gotRoom.getJoinRule()).toEqual(JoinRule.Invite); // default
mockSlidingSync.emit(SlidingSyncEvent.RoomData, roomB, { mockSlidingSync.emit(SlidingSyncEvent.RoomData, roomB, {
required_state: [ required_state: [
@@ -343,6 +374,7 @@ describe("SlidingSyncSdk", () => {
}); });
gotRoom = client.getRoom(roomB); gotRoom = client.getRoom(roomB);
expect(gotRoom).toBeDefined(); expect(gotRoom).toBeDefined();
if (gotRoom == null) { return; }
expect(gotRoom.getJoinRule()).toEqual(JoinRule.Restricted); expect(gotRoom.getJoinRule()).toEqual(JoinRule.Restricted);
}); });
@@ -355,6 +387,7 @@ describe("SlidingSyncSdk", () => {
}); });
const gotRoom = client.getRoom(roomC); const gotRoom = client.getRoom(roomC);
expect(gotRoom).toBeDefined(); expect(gotRoom).toBeDefined();
if (gotRoom == null) { return; }
expect( expect(
gotRoom.getUnreadNotificationCount(NotificationCountType.Highlight), gotRoom.getUnreadNotificationCount(NotificationCountType.Highlight),
).toEqual(1); ).toEqual(1);
@@ -369,11 +402,25 @@ describe("SlidingSyncSdk", () => {
}); });
const gotRoom = client.getRoom(roomD); const gotRoom = client.getRoom(roomD);
expect(gotRoom).toBeDefined(); expect(gotRoom).toBeDefined();
if (gotRoom == null) { return; }
expect( expect(
gotRoom.getUnreadNotificationCount(NotificationCountType.Total), gotRoom.getUnreadNotificationCount(NotificationCountType.Total),
).toEqual(1); ).toEqual(1);
}); });
it("can update with a new joined_count", () => {
mockSlidingSync.emit(SlidingSyncEvent.RoomData, roomG, {
name: data[roomD].name,
required_state: [],
timeline: [],
joined_count: 1,
});
const gotRoom = client.getRoom(roomG);
expect(gotRoom).toBeDefined();
if (gotRoom == null) { return; }
expect(gotRoom.getJoinedMemberCount()).toEqual(1);
});
// Regression test for a bug which caused the timeline entries to be out-of-order // Regression test for a bug which caused the timeline entries to be out-of-order
// when the same room appears twice with different timeline limits. E.g appears in // when the same room appears twice with different timeline limits. E.g appears in
// the list with timeline_limit:1 then appears again as a room subscription with // the list with timeline_limit:1 then appears again as a room subscription with
@@ -394,6 +441,7 @@ describe("SlidingSyncSdk", () => {
}); });
const gotRoom = client.getRoom(roomA); const gotRoom = client.getRoom(roomA);
expect(gotRoom).toBeDefined(); expect(gotRoom).toBeDefined();
if (gotRoom == null) { return; }
logger.log("want:", oldTimeline.map((e) => (e.type + " : " + (e.content || {}).body))); logger.log("want:", oldTimeline.map((e) => (e.type + " : " + (e.content || {}).body)));
logger.log("got:", gotRoom.getLiveTimeline().getEvents().map( logger.log("got:", gotRoom.getLiveTimeline().getEvents().map(

View File

@@ -558,6 +558,153 @@ describe("SlidingSync", () => {
await httpBackend.flushAllExpected(); await httpBackend.flushAllExpected();
await responseProcessed; await responseProcessed;
await listPromise; await listPromise;
});
// this refers to a set of operations where the end result is no change.
it("should handle net zero operations correctly", async () => {
const indexToRoomId = {
0: roomB,
1: roomC,
};
expect(slidingSync.getListData(0).roomIndexToRoomId).toEqual(indexToRoomId);
httpBackend.when("POST", syncUrl).respond(200, {
pos: "f",
// currently the list is [B,C] so we will insert D then immediately delete it
lists: [{
count: 500,
ops: [
{
op: "DELETE", index: 2,
},
{
op: "INSERT", index: 0, room_id: roomA,
},
{
op: "DELETE", index: 0,
},
],
},
{
count: 50,
}],
});
const listPromise = listenUntil(slidingSync, "SlidingSync.List",
(listIndex, joinedCount, roomIndexToRoomId) => {
expect(listIndex).toEqual(0);
expect(joinedCount).toEqual(500);
expect(roomIndexToRoomId).toEqual(indexToRoomId);
return true;
});
const responseProcessed = listenUntil(slidingSync, "SlidingSync.Lifecycle", (state) => {
return state === SlidingSyncState.Complete;
});
await httpBackend.flushAllExpected();
await responseProcessed;
await listPromise;
});
it("should handle deletions correctly", async () => {
expect(slidingSync.getListData(0).roomIndexToRoomId).toEqual({
0: roomB,
1: roomC,
});
httpBackend.when("POST", syncUrl).respond(200, {
pos: "g",
lists: [{
count: 499,
ops: [
{
op: "DELETE", index: 0,
},
],
},
{
count: 50,
}],
});
const listPromise = listenUntil(slidingSync, "SlidingSync.List",
(listIndex, joinedCount, roomIndexToRoomId) => {
expect(listIndex).toEqual(0);
expect(joinedCount).toEqual(499);
expect(roomIndexToRoomId).toEqual({
0: roomC,
});
return true;
});
const responseProcessed = listenUntil(slidingSync, "SlidingSync.Lifecycle", (state) => {
return state === SlidingSyncState.Complete;
});
await httpBackend.flushAllExpected();
await responseProcessed;
await listPromise;
});
it("should handle insertions correctly", async () => {
expect(slidingSync.getListData(0).roomIndexToRoomId).toEqual({
0: roomC,
});
httpBackend.when("POST", syncUrl).respond(200, {
pos: "h",
lists: [{
count: 500,
ops: [
{
op: "INSERT", index: 1, room_id: roomA,
},
],
},
{
count: 50,
}],
});
let listPromise = listenUntil(slidingSync, "SlidingSync.List",
(listIndex, joinedCount, roomIndexToRoomId) => {
expect(listIndex).toEqual(0);
expect(joinedCount).toEqual(500);
expect(roomIndexToRoomId).toEqual({
0: roomC,
1: roomA,
});
return true;
});
let responseProcessed = listenUntil(slidingSync, "SlidingSync.Lifecycle", (state) => {
return state === SlidingSyncState.Complete;
});
await httpBackend.flushAllExpected();
await responseProcessed;
await listPromise;
httpBackend.when("POST", syncUrl).respond(200, {
pos: "h",
lists: [{
count: 501,
ops: [
{
op: "INSERT", index: 1, room_id: roomB,
},
],
},
{
count: 50,
}],
});
listPromise = listenUntil(slidingSync, "SlidingSync.List",
(listIndex, joinedCount, roomIndexToRoomId) => {
expect(listIndex).toEqual(0);
expect(joinedCount).toEqual(501);
expect(roomIndexToRoomId).toEqual({
0: roomC,
1: roomB,
2: roomA,
});
return true;
});
responseProcessed = listenUntil(slidingSync, "SlidingSync.Lifecycle", (state) => {
return state === SlidingSyncState.Complete;
});
await httpBackend.flushAllExpected();
await responseProcessed;
await listPromise;
slidingSync.stop(); slidingSync.stop();
}); });
}); });

View File

@@ -20,6 +20,7 @@ import * as utils from "../src/utils";
// try to load the olm library. // try to load the olm library.
try { try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
global.Olm = require('@matrix-org/olm'); global.Olm = require('@matrix-org/olm');
logger.log('loaded libolm'); logger.log('loaded libolm');
} catch (e) { } catch (e) {
@@ -28,6 +29,7 @@ try {
// also try to set node crypto // also try to set node crypto
try { try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const crypto = require('crypto'); const crypto = require('crypto');
utils.setCrypto(crypto); utils.setCrypto(crypto);
} catch (err) { } catch (err) {

View File

@@ -24,5 +24,5 @@ limitations under the License.
* expect(beaconLivenessEmits.length).toBe(1); * expect(beaconLivenessEmits.length).toBe(1);
* ``` * ```
*/ */
export const filterEmitCallsByEventType = (eventType: string, spy: jest.SpyInstance<any, unknown[]>) => export const filterEmitCallsByEventType = (eventType: string, spy: jest.SpyInstance<any, any[]>) =>
spy.mock.calls.filter((args) => args[0] === eventType); spy.mock.calls.filter((args) => args[0] === eventType);

View File

@@ -147,9 +147,9 @@ export function mkEventCustom<T>(base: T): T & GeneratedMetadata {
interface IPresenceOpts { interface IPresenceOpts {
user?: string; user?: string;
sender?: string; sender?: string;
url: string; url?: string;
name: string; name?: string;
ago: number; ago?: number;
presence?: string; presence?: string;
event?: boolean; event?: boolean;
} }

View File

@@ -15,6 +15,7 @@ import { CRYPTO_ENABLED } from "../../src/client";
import { DeviceInfo } from "../../src/crypto/deviceinfo"; import { DeviceInfo } from "../../src/crypto/deviceinfo";
import { logger } from '../../src/logger'; import { logger } from '../../src/logger';
import { MemoryStore } from "../../src"; import { MemoryStore } from "../../src";
import { IStore } from '../../src/store';
const Olm = global.Olm; const Olm = global.Olm;
@@ -158,8 +159,8 @@ describe("Crypto", function() {
let fakeEmitter; let fakeEmitter;
beforeEach(async function() { beforeEach(async function() {
const mockStorage = new MockStorageApi(); const mockStorage = new MockStorageApi() as unknown as Storage;
const clientStore = new MemoryStore({ localStorage: mockStorage }); const clientStore = new MemoryStore({ localStorage: mockStorage }) as unknown as IStore;
const cryptoStore = new MemoryCryptoStore(); const cryptoStore = new MemoryCryptoStore();
cryptoStore.storeEndToEndDeviceData({ cryptoStore.storeEndToEndDeviceData({
@@ -469,12 +470,12 @@ describe("Crypto", function() {
jest.setTimeout(10000); jest.setTimeout(10000);
const client = (new TestClient("@a:example.com", "dev")).client; const client = (new TestClient("@a:example.com", "dev")).client;
await client.initCrypto(); await client.initCrypto();
client.crypto.getSecretStorageKey = async () => null; client.crypto.getSecretStorageKey = jest.fn().mockResolvedValue(null);
client.crypto.isCrossSigningReady = async () => false; client.crypto.isCrossSigningReady = async () => false;
client.crypto.baseApis.uploadDeviceSigningKeys = jest.fn().mockResolvedValue(null); client.crypto.baseApis.uploadDeviceSigningKeys = jest.fn().mockResolvedValue(null);
client.crypto.baseApis.setAccountData = () => null; client.crypto.baseApis.setAccountData = jest.fn().mockResolvedValue(null);
client.crypto.baseApis.uploadKeySignatures = () => null; client.crypto.baseApis.uploadKeySignatures = jest.fn();
client.crypto.baseApis.http.authedRequest = () => null; client.crypto.baseApis.http.authedRequest = jest.fn();
const createSecretStorageKey = async () => { const createSecretStorageKey = async () => {
return { return {
keyInfo: undefined, // Returning undefined here used to cause a crash keyInfo: undefined, // Returning undefined here used to cause a crash

View File

@@ -32,8 +32,8 @@ import { ClientEvent, MatrixClient, RoomMember } from '../../../../src';
import { DeviceInfo, IDevice } from '../../../../src/crypto/deviceinfo'; import { DeviceInfo, IDevice } from '../../../../src/crypto/deviceinfo';
import { DeviceTrustLevel } from '../../../../src/crypto/CrossSigning'; import { DeviceTrustLevel } from '../../../../src/crypto/CrossSigning';
const MegolmDecryption = algorithms.DECRYPTION_CLASSES['m.megolm.v1.aes-sha2']; const MegolmDecryption = algorithms.DECRYPTION_CLASSES.get('m.megolm.v1.aes-sha2');
const MegolmEncryption = algorithms.ENCRYPTION_CLASSES['m.megolm.v1.aes-sha2']; const MegolmEncryption = algorithms.ENCRYPTION_CLASSES.get('m.megolm.v1.aes-sha2');
const ROOM_ID = '!ROOM:ID'; const ROOM_ID = '!ROOM:ID';

View File

@@ -34,7 +34,7 @@ import { IAbortablePromise, MatrixScheduler } from '../../../src';
const Olm = global.Olm; const Olm = global.Olm;
const MegolmDecryption = algorithms.DECRYPTION_CLASSES['m.megolm.v1.aes-sha2']; const MegolmDecryption = algorithms.DECRYPTION_CLASSES.get('m.megolm.v1.aes-sha2');
const ROOM_ID = '!ROOM:ID'; const ROOM_ID = '!ROOM:ID';

View File

@@ -14,9 +14,9 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { import { CryptoStore } from '../../../src/crypto/store/base';
IndexedDBCryptoStore, import { IndexedDBCryptoStore } from '../../../src/crypto/store/indexeddb-crypto-store';
} from '../../../src/crypto/store/indexeddb-crypto-store'; import { LocalStorageCryptoStore } from '../../../src/crypto/store/localStorage-crypto-store';
import { MemoryCryptoStore } from '../../../src/crypto/store/memory-crypto-store'; import { MemoryCryptoStore } from '../../../src/crypto/store/memory-crypto-store';
import { RoomKeyRequestState } from '../../../src/crypto/OutgoingRoomKeyRequestManager'; import { RoomKeyRequestState } from '../../../src/crypto/OutgoingRoomKeyRequestManager';
@@ -26,36 +26,39 @@ import 'jest-localstorage-mock';
const requests = [ const requests = [
{ {
requestId: "A", requestId: "A",
requestBody: { session_id: "A", room_id: "A" }, requestBody: { session_id: "A", room_id: "A", sender_key: "A", algorithm: "m.megolm.v1.aes-sha2" },
state: RoomKeyRequestState.Sent, state: RoomKeyRequestState.Sent,
recipients: [
{ userId: "@alice:example.com", deviceId: "*" },
{ userId: "@becca:example.com", deviceId: "foobarbaz" },
],
}, },
{ {
requestId: "B", requestId: "B",
requestBody: { session_id: "B", room_id: "B" }, requestBody: { session_id: "B", room_id: "B", sender_key: "B", algorithm: "m.megolm.v1.aes-sha2" },
state: RoomKeyRequestState.Sent, state: RoomKeyRequestState.Sent,
recipients: [
{ userId: "@alice:example.com", deviceId: "*" },
{ userId: "@carrie:example.com", deviceId: "barbazquux" },
],
}, },
{ {
requestId: "C", requestId: "C",
requestBody: { session_id: "C", room_id: "C" }, requestBody: { session_id: "C", room_id: "C", sender_key: "B", algorithm: "m.megolm.v1.aes-sha2" },
state: RoomKeyRequestState.Unsent, state: RoomKeyRequestState.Unsent,
recipients: [
{ userId: "@becca:example.com", deviceId: "foobarbaz" },
],
}, },
]; ];
describe.each([ describe.each([
["IndexedDBCryptoStore", ["IndexedDBCryptoStore",
() => new IndexedDBCryptoStore(global.indexedDB, "tests")], () => new IndexedDBCryptoStore(global.indexedDB, "tests")],
["LocalStorageCryptoStore", ["LocalStorageCryptoStore", () => new LocalStorageCryptoStore(localStorage)],
() => new IndexedDBCryptoStore(undefined, "tests")], ["MemoryCryptoStore", () => new MemoryCryptoStore()],
["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;
}],
])("Outgoing room key requests [%s]", function(name, dbFactory) { ])("Outgoing room key requests [%s]", function(name, dbFactory) {
let store; let store: CryptoStore;
beforeAll(async () => { beforeAll(async () => {
store = dbFactory(); store = dbFactory();
@@ -75,6 +78,15 @@ describe.each([
}); });
}); });
it("getOutgoingRoomKeyRequestsByTarget retrieves all entries with a given target",
async () => {
const r = await store.getOutgoingRoomKeyRequestsByTarget(
"@becca:example.com", "foobarbaz", [RoomKeyRequestState.Sent],
);
expect(r).toHaveLength(1);
expect(r[0]).toEqual(requests[0]);
});
test("getOutgoingRoomKeyRequestByState retrieves any entry in a given state", test("getOutgoingRoomKeyRequestByState retrieves any entry in a given state",
async () => { async () => {
const r = const r =

View File

@@ -16,14 +16,15 @@ limitations under the License.
import * as utils from "../test-utils/test-utils"; import * as utils from "../test-utils/test-utils";
import { import {
DuplicateStrategy,
EventTimeline, EventTimeline,
EventTimelineSet, EventTimelineSet,
EventType, EventType,
Filter,
MatrixClient, MatrixClient,
MatrixEvent, MatrixEvent,
MatrixEventEvent, MatrixEventEvent,
Room, Room,
DuplicateStrategy,
} from '../../src'; } from '../../src';
import { Thread } from "../../src/models/thread"; import { Thread } from "../../src/models/thread";
import { ReEmitter } from "../../src/ReEmitter"; import { ReEmitter } from "../../src/ReEmitter";
@@ -291,4 +292,34 @@ describe('EventTimelineSet', () => {
expect(eventTimelineSet.canContain(event)).toBeTruthy(); expect(eventTimelineSet.canContain(event)).toBeTruthy();
}); });
}); });
describe("handleRemoteEcho", () => {
it("should add to liveTimeline only if the event matches the filter", () => {
const filter = new Filter(client.getUserId()!, "test_filter");
filter.setDefinition({
room: {
timeline: {
types: [EventType.RoomMessage],
},
},
});
const eventTimelineSet = new EventTimelineSet(room, { filter }, client);
const roomMessageEvent = new MatrixEvent({
type: EventType.RoomMessage,
content: { body: "test" },
event_id: "!test1:server",
});
eventTimelineSet.handleRemoteEcho(roomMessageEvent, "~!local-event-id:server", roomMessageEvent.getId());
expect(eventTimelineSet.getLiveTimeline().getEvents()).toContain(roomMessageEvent);
const roomFilteredEvent = new MatrixEvent({
type: "other_event_type",
content: { body: "test" },
event_id: "!test2:server",
});
eventTimelineSet.handleRemoteEcho(roomFilteredEvent, "~!local-event-id:server", roomFilteredEvent.getId());
expect(eventTimelineSet.getLiveTimeline().getEvents()).not.toContain(roomFilteredEvent);
});
});
}); });

View File

@@ -36,9 +36,14 @@ import { ReceiptType } from "../../src/@types/read_receipts";
import * as testUtils from "../test-utils/test-utils"; import * as testUtils from "../test-utils/test-utils";
import { makeBeaconInfoContent } from "../../src/content-helpers"; import { makeBeaconInfoContent } from "../../src/content-helpers";
import { M_BEACON_INFO } from "../../src/@types/beacon"; import { M_BEACON_INFO } from "../../src/@types/beacon";
import { ContentHelpers, Room } from "../../src"; import { ContentHelpers, EventTimeline, Room } from "../../src";
import { supportsMatrixCall } from "../../src/webrtc/call"; import { supportsMatrixCall } from "../../src/webrtc/call";
import { makeBeaconEvent } from "../test-utils/beacon"; import { makeBeaconEvent } from "../test-utils/beacon";
import {
IGNORE_INVITES_ACCOUNT_EVENT_KEY,
POLICIES_ACCOUNT_EVENT_TYPE,
PolicyScope,
} from "../../src/models/invites-ignorer";
jest.useFakeTimers(); jest.useFakeTimers();
@@ -427,7 +432,7 @@ describe("MatrixClient", function() {
} }
}); });
}); });
await client.startClient(); await client.startClient({ filter });
await syncPromise; await syncPromise;
}); });
@@ -1412,4 +1417,301 @@ describe("MatrixClient", function() {
expect(client.crypto.encryptAndSendToDevices).toHaveBeenLastCalledWith(deviceInfos, payload); expect(client.crypto.encryptAndSendToDevices).toHaveBeenLastCalledWith(deviceInfos, payload);
}); });
}); });
describe("support for ignoring invites", () => {
beforeEach(() => {
// Mockup `getAccountData`/`setAccountData`.
const dataStore = new Map();
client.setAccountData = function(eventType, content) {
dataStore.set(eventType, content);
return Promise.resolve();
};
client.getAccountData = function(eventType) {
const data = dataStore.get(eventType);
return new MatrixEvent({
content: data,
});
};
// Mockup `createRoom`/`getRoom`/`joinRoom`, including state.
const rooms = new Map();
client.createRoom = function(options = {}) {
const roomId = options["_roomId"] || `!room-${rooms.size}:example.org`;
const state = new Map();
const room = {
roomId,
_options: options,
_state: state,
getUnfilteredTimelineSet: function() {
return {
getLiveTimeline: function() {
return {
getState: function(direction) {
expect(direction).toBe(EventTimeline.FORWARDS);
return {
getStateEvents: function(type) {
const store = state.get(type) || {};
return Object.keys(store).map(key => store[key]);
},
};
},
};
},
};
},
};
rooms.set(roomId, room);
return Promise.resolve({ room_id: roomId });
};
client.getRoom = function(roomId) {
return rooms.get(roomId);
};
client.joinRoom = function(roomId) {
return this.getRoom(roomId) || this.createRoom({ _roomId: roomId });
};
// Mockup state events
client.sendStateEvent = function(roomId, type, content) {
const room = this.getRoom(roomId);
const state: Map<string, any> = room._state;
let store = state.get(type);
if (!store) {
store = {};
state.set(type, store);
}
const eventId = `$event-${Math.random()}:example.org`;
store[eventId] = {
getId: function() {
return eventId;
},
getRoomId: function() {
return roomId;
},
getContent: function() {
return content;
},
};
return { event_id: eventId };
};
client.redactEvent = function(roomId, eventId) {
const room = this.getRoom(roomId);
const state: Map<string, any> = room._state;
for (const store of state.values()) {
delete store[eventId];
}
};
});
it("should initialize and return the same `target` consistently", async () => {
const target1 = await client.ignoredInvites.getOrCreateTargetRoom();
const target2 = await client.ignoredInvites.getOrCreateTargetRoom();
expect(target1).toBeTruthy();
expect(target1).toBe(target2);
});
it("should initialize and return the same `sources` consistently", async () => {
const sources1 = await client.ignoredInvites.getOrCreateSourceRooms();
const sources2 = await client.ignoredInvites.getOrCreateSourceRooms();
expect(sources1).toBeTruthy();
expect(sources1).toHaveLength(1);
expect(sources1).toEqual(sources2);
});
it("should initially not reject any invite", async () => {
const rule = await client.ignoredInvites.getRuleForInvite({
sender: "@foobar:example.org",
roomId: "!snafu:somewhere.org",
});
expect(rule).toBeFalsy();
});
it("should reject invites once we have added a matching rule in the target room (scope: user)", async () => {
await client.ignoredInvites.addRule(PolicyScope.User, "*:example.org", "just a test");
// We should reject this invite.
const ruleMatch = await client.ignoredInvites.getRuleForInvite({
sender: "@foobar:example.org",
roomId: "!snafu:somewhere.org",
});
expect(ruleMatch).toBeTruthy();
expect(ruleMatch.getContent()).toMatchObject({
recommendation: "m.ban",
reason: "just a test",
});
// We should let these invites go through.
const ruleWrongServer = await client.ignoredInvites.getRuleForInvite({
sender: "@foobar:somewhere.org",
roomId: "!snafu:somewhere.org",
});
expect(ruleWrongServer).toBeFalsy();
const ruleWrongServerRoom = await client.ignoredInvites.getRuleForInvite({
sender: "@foobar:somewhere.org",
roomId: "!snafu:example.org",
});
expect(ruleWrongServerRoom).toBeFalsy();
});
it("should reject invites once we have added a matching rule in the target room (scope: server)", async () => {
const REASON = `Just a test ${Math.random()}`;
await client.ignoredInvites.addRule(PolicyScope.Server, "example.org", REASON);
// We should reject these invites.
const ruleSenderMatch = await client.ignoredInvites.getRuleForInvite({
sender: "@foobar:example.org",
roomId: "!snafu:somewhere.org",
});
expect(ruleSenderMatch).toBeTruthy();
expect(ruleSenderMatch.getContent()).toMatchObject({
recommendation: "m.ban",
reason: REASON,
});
const ruleRoomMatch = await client.ignoredInvites.getRuleForInvite({
sender: "@foobar:somewhere.org",
roomId: "!snafu:example.org",
});
expect(ruleRoomMatch).toBeTruthy();
expect(ruleRoomMatch.getContent()).toMatchObject({
recommendation: "m.ban",
reason: REASON,
});
// We should let these invites go through.
const ruleWrongServer = await client.ignoredInvites.getRuleForInvite({
sender: "@foobar:somewhere.org",
roomId: "!snafu:somewhere.org",
});
expect(ruleWrongServer).toBeFalsy();
});
it("should reject invites once we have added a matching rule in the target room (scope: room)", async () => {
const REASON = `Just a test ${Math.random()}`;
const BAD_ROOM_ID = "!bad:example.org";
const GOOD_ROOM_ID = "!good:example.org";
await client.ignoredInvites.addRule(PolicyScope.Room, BAD_ROOM_ID, REASON);
// We should reject this invite.
const ruleSenderMatch = await client.ignoredInvites.getRuleForInvite({
sender: "@foobar:example.org",
roomId: BAD_ROOM_ID,
});
expect(ruleSenderMatch).toBeTruthy();
expect(ruleSenderMatch.getContent()).toMatchObject({
recommendation: "m.ban",
reason: REASON,
});
// We should let these invites go through.
const ruleWrongRoom = await client.ignoredInvites.getRuleForInvite({
sender: BAD_ROOM_ID,
roomId: GOOD_ROOM_ID,
});
expect(ruleWrongRoom).toBeFalsy();
});
it("should reject invites once we have added a matching rule in a non-target source room", async () => {
const NEW_SOURCE_ROOM_ID = "!another-source:example.org";
// Make sure that everything is initialized.
await client.ignoredInvites.getOrCreateSourceRooms();
await client.joinRoom(NEW_SOURCE_ROOM_ID);
await client.ignoredInvites.addSource(NEW_SOURCE_ROOM_ID);
// Add a rule in the new source room.
await client.sendStateEvent(NEW_SOURCE_ROOM_ID, PolicyScope.User, {
entity: "*:example.org",
reason: "just a test",
recommendation: "m.ban",
});
// We should reject this invite.
const ruleMatch = await client.ignoredInvites.getRuleForInvite({
sender: "@foobar:example.org",
roomId: "!snafu:somewhere.org",
});
expect(ruleMatch).toBeTruthy();
expect(ruleMatch.getContent()).toMatchObject({
recommendation: "m.ban",
reason: "just a test",
});
// We should let these invites go through.
const ruleWrongServer = await client.ignoredInvites.getRuleForInvite({
sender: "@foobar:somewhere.org",
roomId: "!snafu:somewhere.org",
});
expect(ruleWrongServer).toBeFalsy();
const ruleWrongServerRoom = await client.ignoredInvites.getRuleForInvite({
sender: "@foobar:somewhere.org",
roomId: "!snafu:example.org",
});
expect(ruleWrongServerRoom).toBeFalsy();
});
it("should not reject invites anymore once we have removed a rule", async () => {
await client.ignoredInvites.addRule(PolicyScope.User, "*:example.org", "just a test");
// We should reject this invite.
const ruleMatch = await client.ignoredInvites.getRuleForInvite({
sender: "@foobar:example.org",
roomId: "!snafu:somewhere.org",
});
expect(ruleMatch).toBeTruthy();
expect(ruleMatch.getContent()).toMatchObject({
recommendation: "m.ban",
reason: "just a test",
});
// After removing the invite, we shouldn't reject it anymore.
await client.ignoredInvites.removeRule(ruleMatch);
const ruleMatch2 = await client.ignoredInvites.getRuleForInvite({
sender: "@foobar:example.org",
roomId: "!snafu:somewhere.org",
});
expect(ruleMatch2).toBeFalsy();
});
it("should add new rules in the target room, rather than any other source room", async () => {
const NEW_SOURCE_ROOM_ID = "!another-source:example.org";
// Make sure that everything is initialized.
await client.ignoredInvites.getOrCreateSourceRooms();
await client.joinRoom(NEW_SOURCE_ROOM_ID);
const newSourceRoom = client.getRoom(NEW_SOURCE_ROOM_ID);
// Fetch the list of sources and check that we do not have the new room yet.
const policies = await client.getAccountData(POLICIES_ACCOUNT_EVENT_TYPE.name).getContent();
expect(policies).toBeTruthy();
const ignoreInvites = policies[IGNORE_INVITES_ACCOUNT_EVENT_KEY.name];
expect(ignoreInvites).toBeTruthy();
expect(ignoreInvites.sources).toBeTruthy();
expect(ignoreInvites.sources).not.toContain(NEW_SOURCE_ROOM_ID);
// Add a source.
const added = await client.ignoredInvites.addSource(NEW_SOURCE_ROOM_ID);
expect(added).toBe(true);
const added2 = await client.ignoredInvites.addSource(NEW_SOURCE_ROOM_ID);
expect(added2).toBe(false);
// Fetch the list of sources and check that we have added the new room.
const policies2 = await client.getAccountData(POLICIES_ACCOUNT_EVENT_TYPE.name).getContent();
expect(policies2).toBeTruthy();
const ignoreInvites2 = policies2[IGNORE_INVITES_ACCOUNT_EVENT_KEY.name];
expect(ignoreInvites2).toBeTruthy();
expect(ignoreInvites2.sources).toBeTruthy();
expect(ignoreInvites2.sources).toContain(NEW_SOURCE_ROOM_ID);
// Add a rule.
const eventId = await client.ignoredInvites.addRule(PolicyScope.User, "*:example.org", "just a test");
// Check where it shows up.
const targetRoomId = ignoreInvites2.target;
const targetRoom = client.getRoom(targetRoomId);
expect(targetRoom._state.get(PolicyScope.User)[eventId]).toBeTruthy();
expect(newSourceRoom._state.get(PolicyScope.User)?.[eventId]).toBeFalsy();
});
});
}); });

View File

@@ -1,12 +1,29 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import * as utils from "../test-utils/test-utils"; import * as utils from "../test-utils/test-utils";
import { RoomMember } from "../../src/models/room-member"; import { RoomMember, RoomMemberEvent } from "../../src/models/room-member";
import { RoomState } from "../../src";
describe("RoomMember", function() { describe("RoomMember", function() {
const roomId = "!foo:bar"; const roomId = "!foo:bar";
const userA = "@alice:bar"; const userA = "@alice:bar";
const userB = "@bertha:bar"; const userB = "@bertha:bar";
const userC = "@clarissa:bar"; const userC = "@clarissa:bar";
let member; let member = new RoomMember(roomId, userA);
beforeEach(function() { beforeEach(function() {
member = new RoomMember(roomId, userA); member = new RoomMember(roomId, userA);
@@ -27,17 +44,17 @@ describe("RoomMember", function() {
avatar_url: "mxc://flibble/wibble", avatar_url: "mxc://flibble/wibble",
}, },
}); });
const url = member.getAvatarUrl(hsUrl); const url = member.getAvatarUrl(hsUrl, 1, 1, '', false, false);
// we don't care about how the mxc->http conversion is done, other // we don't care about how the mxc->http conversion is done, other
// than it contains the mxc body. // than it contains the mxc body.
expect(url.indexOf("flibble/wibble")).not.toEqual(-1); expect(url?.indexOf("flibble/wibble")).not.toEqual(-1);
}); });
it("should return nothing if there is no m.room.member and allowDefault=false", it("should return nothing if there is no m.room.member and allowDefault=false",
function() { function() {
const url = member.getAvatarUrl(hsUrl, 64, 64, "crop", false); const url = member.getAvatarUrl(hsUrl, 64, 64, "crop", false, false);
expect(url).toEqual(null); expect(url).toEqual(null);
}); });
}); });
describe("setPowerLevelEvent", function() { describe("setPowerLevelEvent", function() {
@@ -66,92 +83,92 @@ describe("RoomMember", function() {
}); });
it("should emit 'RoomMember.powerLevel' if the power level changes.", it("should emit 'RoomMember.powerLevel' if the power level changes.",
function() { function() {
const event = utils.mkEvent({ const event = utils.mkEvent({
type: "m.room.power_levels", type: "m.room.power_levels",
room: roomId, room: roomId,
user: userA, user: userA,
content: { content: {
users_default: 20, users_default: 20,
users: { users: {
"@bertha:bar": 200, "@bertha:bar": 200,
"@invalid:user": 10, // shouldn't barf on this. "@invalid:user": 10, // shouldn't barf on this.
},
}, },
}, event: true,
event: true, });
}); let emitCount = 0;
let emitCount = 0;
member.on("RoomMember.powerLevel", function(emitEvent, emitMember) { member.on(RoomMemberEvent.PowerLevel, function(emitEvent, emitMember) {
emitCount += 1; emitCount += 1;
expect(emitMember).toEqual(member); expect(emitMember).toEqual(member);
expect(emitEvent).toEqual(event); expect(emitEvent).toEqual(event);
}); });
member.setPowerLevelEvent(event); member.setPowerLevelEvent(event);
expect(emitCount).toEqual(1); expect(emitCount).toEqual(1);
member.setPowerLevelEvent(event); // no-op member.setPowerLevelEvent(event); // no-op
expect(emitCount).toEqual(1); expect(emitCount).toEqual(1);
}); });
it("should honour power levels of zero.", it("should honour power levels of zero.",
function() { function() {
const event = utils.mkEvent({ const event = utils.mkEvent({
type: "m.room.power_levels", type: "m.room.power_levels",
room: roomId, room: roomId,
user: userA, user: userA,
content: { content: {
users_default: 20, users_default: 20,
users: { users: {
"@alice:bar": 0, "@alice:bar": 0,
},
}, },
}, event: true,
event: true, });
}); let emitCount = 0;
let emitCount = 0;
// set the power level to something other than zero or we // set the power level to something other than zero or we
// won't get an event // won't get an event
member.powerLevel = 1; member.powerLevel = 1;
member.on("RoomMember.powerLevel", function(emitEvent, emitMember) { member.on(RoomMemberEvent.PowerLevel, function(emitEvent, emitMember) {
emitCount += 1; emitCount += 1;
expect(emitMember.userId).toEqual('@alice:bar'); expect(emitMember.userId).toEqual('@alice:bar');
expect(emitMember.powerLevel).toEqual(0); expect(emitMember.powerLevel).toEqual(0);
expect(emitEvent).toEqual(event); expect(emitEvent).toEqual(event);
}); });
member.setPowerLevelEvent(event); member.setPowerLevelEvent(event);
expect(member.powerLevel).toEqual(0); expect(member.powerLevel).toEqual(0);
expect(emitCount).toEqual(1); expect(emitCount).toEqual(1);
}); });
it("should not honor string power levels.", it("should not honor string power levels.",
function() { function() {
const event = utils.mkEvent({ const event = utils.mkEvent({
type: "m.room.power_levels", type: "m.room.power_levels",
room: roomId, room: roomId,
user: userA, user: userA,
content: { content: {
users_default: 20, users_default: 20,
users: { users: {
"@alice:bar": "5", "@alice:bar": "5",
},
}, },
}, event: true,
event: true, });
}); let emitCount = 0;
let emitCount = 0;
member.on("RoomMember.powerLevel", function(emitEvent, emitMember) { member.on(RoomMemberEvent.PowerLevel, function(emitEvent, emitMember) {
emitCount += 1; emitCount += 1;
expect(emitMember.userId).toEqual('@alice:bar'); expect(emitMember.userId).toEqual('@alice:bar');
expect(emitMember.powerLevel).toEqual(20); expect(emitMember.powerLevel).toEqual(20);
expect(emitEvent).toEqual(event); expect(emitEvent).toEqual(event);
}); });
member.setPowerLevelEvent(event); member.setPowerLevelEvent(event);
expect(member.powerLevel).toEqual(20); expect(member.powerLevel).toEqual(20);
expect(emitCount).toEqual(1); expect(emitCount).toEqual(1);
}); });
}); });
describe("setTypingEvent", function() { describe("setTypingEvent", function() {
@@ -183,34 +200,34 @@ describe("RoomMember", function() {
}); });
it("should emit 'RoomMember.typing' if the typing state changes", it("should emit 'RoomMember.typing' if the typing state changes",
function() { function() {
const event = utils.mkEvent({ const event = utils.mkEvent({
type: "m.typing", type: "m.typing",
room: roomId, room: roomId,
content: { content: {
user_ids: [ user_ids: [
userA, userC, userA, userC,
], ],
}, },
event: true, event: true,
});
let emitCount = 0;
member.on(RoomMemberEvent.Typing, function(ev, mem) {
expect(mem).toEqual(member);
expect(ev).toEqual(event);
emitCount += 1;
});
member.typing = false;
member.setTypingEvent(event);
expect(emitCount).toEqual(1);
member.setTypingEvent(event); // no-op
expect(emitCount).toEqual(1);
}); });
let emitCount = 0;
member.on("RoomMember.typing", function(ev, mem) {
expect(mem).toEqual(member);
expect(ev).toEqual(event);
emitCount += 1;
});
member.typing = false;
member.setTypingEvent(event);
expect(emitCount).toEqual(1);
member.setTypingEvent(event); // no-op
expect(emitCount).toEqual(1);
});
}); });
describe("isOutOfBand", function() { describe("isOutOfBand", function() {
it("should be set by markOutOfBand", function() { it("should be set by markOutOfBand", function() {
const member = new RoomMember(); const member = new RoomMember(roomId, userA);
expect(member.isOutOfBand()).toEqual(false); expect(member.isOutOfBand()).toEqual(false);
member.markOutOfBand(); member.markOutOfBand();
expect(member.isOutOfBand()).toEqual(true); expect(member.isOutOfBand()).toEqual(true);
@@ -235,50 +252,50 @@ describe("RoomMember", function() {
}); });
it("should set 'membership' and assign the event to 'events.member'.", it("should set 'membership' and assign the event to 'events.member'.",
function() { function() {
member.setMembershipEvent(inviteEvent); member.setMembershipEvent(inviteEvent);
expect(member.membership).toEqual("invite"); expect(member.membership).toEqual("invite");
expect(member.events.member).toEqual(inviteEvent); expect(member.events.member).toEqual(inviteEvent);
member.setMembershipEvent(joinEvent); member.setMembershipEvent(joinEvent);
expect(member.membership).toEqual("join"); expect(member.membership).toEqual("join");
expect(member.events.member).toEqual(joinEvent); expect(member.events.member).toEqual(joinEvent);
}); });
it("should set 'name' based on user_id, displayname and room state", it("should set 'name' based on user_id, displayname and room state",
function() { function() {
const roomState = { const roomState = {
getStateEvents: function(type) { getStateEvents: function(type) {
if (type !== "m.room.member") { if (type !== "m.room.member") {
return []; return [];
} }
return [ return [
utils.mkMembership({ utils.mkMembership({
event: true, mship: "join", room: roomId, event: true, mship: "join", room: roomId,
user: userB, user: userB,
}), }),
utils.mkMembership({ utils.mkMembership({
event: true, mship: "join", room: roomId, event: true, mship: "join", room: roomId,
user: userC, name: "Alice", user: userC, name: "Alice",
}), }),
joinEvent, joinEvent,
]; ];
}, },
getUserIdsWithDisplayName: function(displayName) { getUserIdsWithDisplayName: function(displayName) {
return [userA, userC]; return [userA, userC];
}, },
}; } as unknown as RoomState;
expect(member.name).toEqual(userA); // default = user_id expect(member.name).toEqual(userA); // default = user_id
member.setMembershipEvent(joinEvent); member.setMembershipEvent(joinEvent);
expect(member.name).toEqual("Alice"); // prefer displayname expect(member.name).toEqual("Alice"); // prefer displayname
member.setMembershipEvent(joinEvent, roomState); member.setMembershipEvent(joinEvent, roomState);
expect(member.name).not.toEqual("Alice"); // it should disambig. expect(member.name).not.toEqual("Alice"); // it should disambig.
// user_id should be there somewhere // user_id should be there somewhere
expect(member.name.indexOf(userA)).not.toEqual(-1); expect(member.name.indexOf(userA)).not.toEqual(-1);
}); });
it("should emit 'RoomMember.membership' if the membership changes", function() { it("should emit 'RoomMember.membership' if the membership changes", function() {
let emitCount = 0; let emitCount = 0;
member.on("RoomMember.membership", function(ev, mem) { member.on(RoomMemberEvent.Membership, function(ev, mem) {
emitCount += 1; emitCount += 1;
expect(mem).toEqual(member); expect(mem).toEqual(member);
expect(ev).toEqual(inviteEvent); expect(ev).toEqual(inviteEvent);
@@ -291,7 +308,7 @@ describe("RoomMember", function() {
it("should emit 'RoomMember.name' if the name changes", function() { it("should emit 'RoomMember.name' if the name changes", function() {
let emitCount = 0; let emitCount = 0;
member.on("RoomMember.name", function(ev, mem) { member.on(RoomMemberEvent.Name, function(ev, mem) {
emitCount += 1; emitCount += 1;
expect(mem).toEqual(member); expect(mem).toEqual(member);
expect(ev).toEqual(joinEvent); expect(ev).toEqual(joinEvent);
@@ -341,7 +358,7 @@ describe("RoomMember", function() {
getUserIdsWithDisplayName: function(displayName) { getUserIdsWithDisplayName: function(displayName) {
return [userA, userC]; return [userA, userC];
}, },
}; } as unknown as RoomState;
expect(member.name).toEqual(userA); // default = user_id expect(member.name).toEqual(userA); // default = user_id
member.setMembershipEvent(joinEvent, roomState); member.setMembershipEvent(joinEvent, roomState);
expect(member.name).not.toEqual("Alíce"); // it should disambig. expect(member.name).not.toEqual("Alíce"); // it should disambig.

View File

@@ -1,14 +1,37 @@
/*
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 { MockedObject } from 'jest-mock';
import * as utils from "../test-utils/test-utils"; import * as utils from "../test-utils/test-utils";
import { makeBeaconEvent, makeBeaconInfoEvent } from "../test-utils/beacon"; import { makeBeaconEvent, makeBeaconInfoEvent } from "../test-utils/beacon";
import { filterEmitCallsByEventType } from "../test-utils/emitter"; import { filterEmitCallsByEventType } from "../test-utils/emitter";
import { RoomState, RoomStateEvent } from "../../src/models/room-state"; import { RoomState, RoomStateEvent } from "../../src/models/room-state";
import { BeaconEvent, getBeaconInfoIdentifier } from "../../src/models/beacon"; import {
Beacon,
BeaconEvent,
getBeaconInfoIdentifier,
} from "../../src/models/beacon";
import { EventType, RelationType, UNSTABLE_MSC2716_MARKER } from "../../src/@types/event"; import { EventType, RelationType, UNSTABLE_MSC2716_MARKER } from "../../src/@types/event";
import { import {
MatrixEvent, MatrixEvent,
MatrixEventEvent, MatrixEventEvent,
} from "../../src/models/event"; } from "../../src/models/event";
import { M_BEACON } from "../../src/@types/beacon"; import { M_BEACON } from "../../src/@types/beacon";
import { MatrixClient } from "../../src/client";
describe("RoomState", function() { describe("RoomState", function() {
const roomId = "!foo:bar"; const roomId = "!foo:bar";
@@ -17,7 +40,7 @@ describe("RoomState", function() {
const userC = "@cleo:bar"; const userC = "@cleo:bar";
const userLazy = "@lazy:bar"; const userLazy = "@lazy:bar";
let state; let state = new RoomState(roomId);
beforeEach(function() { beforeEach(function() {
state = new RoomState(roomId); state = new RoomState(roomId);
@@ -67,8 +90,8 @@ describe("RoomState", function() {
it("should return a member which changes as state changes", function() { it("should return a member which changes as state changes", function() {
const member = state.getMember(userB); const member = state.getMember(userB);
expect(member.membership).toEqual("join"); expect(member?.membership).toEqual("join");
expect(member.name).toEqual(userB); expect(member?.name).toEqual(userB);
state.setStateEvents([ state.setStateEvents([
utils.mkMembership({ utils.mkMembership({
@@ -77,40 +100,40 @@ describe("RoomState", function() {
}), }),
]); ]);
expect(member.membership).toEqual("leave"); expect(member?.membership).toEqual("leave");
expect(member.name).toEqual("BobGone"); expect(member?.name).toEqual("BobGone");
}); });
}); });
describe("getSentinelMember", function() { describe("getSentinelMember", function() {
it("should return a member with the user id as name", function() { it("should return a member with the user id as name", function() {
expect(state.getSentinelMember("@no-one:here").name).toEqual("@no-one:here"); expect(state.getSentinelMember("@no-one:here")?.name).toEqual("@no-one:here");
}); });
it("should return a member which doesn't change when the state is updated", it("should return a member which doesn't change when the state is updated",
function() { function() {
const preLeaveUser = state.getSentinelMember(userA); const preLeaveUser = state.getSentinelMember(userA);
state.setStateEvents([ state.setStateEvents([
utils.mkMembership({ utils.mkMembership({
room: roomId, user: userA, mship: "leave", event: true, room: roomId, user: userA, mship: "leave", event: true,
name: "AliceIsGone", name: "AliceIsGone",
}), }),
]); ]);
const postLeaveUser = state.getSentinelMember(userA); const postLeaveUser = state.getSentinelMember(userA);
expect(preLeaveUser.membership).toEqual("join"); expect(preLeaveUser?.membership).toEqual("join");
expect(preLeaveUser.name).toEqual(userA); expect(preLeaveUser?.name).toEqual(userA);
expect(postLeaveUser.membership).toEqual("leave"); expect(postLeaveUser?.membership).toEqual("leave");
expect(postLeaveUser.name).toEqual("AliceIsGone"); expect(postLeaveUser?.name).toEqual("AliceIsGone");
}); });
}); });
describe("getStateEvents", function() { describe("getStateEvents", function() {
it("should return null if a state_key was specified and there was no match", it("should return null if a state_key was specified and there was no match",
function() { function() {
expect(state.getStateEvents("foo.bar.baz", "keyname")).toEqual(null); expect(state.getStateEvents("foo.bar.baz", "keyname")).toEqual(null);
}); });
it("should return an empty list if a state_key was not specified and there" + it("should return an empty list if a state_key was not specified and there" +
" was no match", function() { " was no match", function() {
@@ -118,21 +141,21 @@ describe("RoomState", function() {
}); });
it("should return a list of matching events if no state_key was specified", it("should return a list of matching events if no state_key was specified",
function() { function() {
const events = state.getStateEvents("m.room.member"); const events = state.getStateEvents("m.room.member");
expect(events.length).toEqual(2); expect(events.length).toEqual(2);
// ordering unimportant // ordering unimportant
expect([userA, userB].indexOf(events[0].getStateKey())).not.toEqual(-1); expect([userA, userB].indexOf(events[0].getStateKey() as string)).not.toEqual(-1);
expect([userA, userB].indexOf(events[1].getStateKey())).not.toEqual(-1); expect([userA, userB].indexOf(events[1].getStateKey() as string)).not.toEqual(-1);
}); });
it("should return a single MatrixEvent if a state_key was specified", it("should return a single MatrixEvent if a state_key was specified",
function() { function() {
const event = state.getStateEvents("m.room.member", userA); const event = state.getStateEvents("m.room.member", userA);
expect(event.getContent()).toMatchObject({ expect(event.getContent()).toMatchObject({
membership: "join", membership: "join",
});
}); });
});
}); });
describe("setStateEvents", function() { describe("setStateEvents", function() {
@@ -146,7 +169,7 @@ describe("RoomState", function() {
}), }),
]; ];
let emitCount = 0; let emitCount = 0;
state.on("RoomState.members", function(ev, st, mem) { state.on(RoomStateEvent.Members, function(ev, st, mem) {
expect(ev).toEqual(memberEvents[emitCount]); expect(ev).toEqual(memberEvents[emitCount]);
expect(st).toEqual(state); expect(st).toEqual(state);
expect(mem).toEqual(state.getMember(ev.getSender())); expect(mem).toEqual(state.getMember(ev.getSender()));
@@ -166,7 +189,7 @@ describe("RoomState", function() {
}), }),
]; ];
let emitCount = 0; let emitCount = 0;
state.on("RoomState.newMember", function(ev, st, mem) { state.on(RoomStateEvent.NewMember, function(ev, st, mem) {
expect(state.getMember(mem.userId)).toEqual(mem); expect(state.getMember(mem.userId)).toEqual(mem);
expect(mem.userId).toEqual(memberEvents[emitCount].getSender()); expect(mem.userId).toEqual(memberEvents[emitCount].getSender());
expect(mem.membership).toBeFalsy(); // not defined yet expect(mem.membership).toBeFalsy(); // not defined yet
@@ -192,7 +215,7 @@ describe("RoomState", function() {
}), }),
]; ];
let emitCount = 0; let emitCount = 0;
state.on("RoomState.events", function(ev, st) { state.on(RoomStateEvent.Events, function(ev, st) {
expect(ev).toEqual(events[emitCount]); expect(ev).toEqual(events[emitCount]);
expect(st).toEqual(state); expect(st).toEqual(state);
emitCount += 1; emitCount += 1;
@@ -272,7 +295,7 @@ describe("RoomState", function() {
}), }),
]; ];
let emitCount = 0; let emitCount = 0;
state.on("RoomState.Marker", function(markerEvent, markerFoundOptions) { state.on(RoomStateEvent.Marker, function(markerEvent, markerFoundOptions) {
expect(markerEvent).toEqual(events[emitCount]); expect(markerEvent).toEqual(events[emitCount]);
expect(markerFoundOptions).toEqual({ timelineWasEmpty: true }); expect(markerFoundOptions).toEqual({ timelineWasEmpty: true });
emitCount += 1; emitCount += 1;
@@ -296,7 +319,7 @@ describe("RoomState", function() {
it('does not add redacted beacon info events to state', () => { it('does not add redacted beacon info events to state', () => {
const redactedBeaconEvent = makeBeaconInfoEvent(userA, roomId); const redactedBeaconEvent = makeBeaconInfoEvent(userA, roomId);
const redactionEvent = { event: { type: 'm.room.redaction' } }; const redactionEvent = new MatrixEvent({ type: 'm.room.redaction' });
redactedBeaconEvent.makeRedacted(redactionEvent); redactedBeaconEvent.makeRedacted(redactionEvent);
const emitSpy = jest.spyOn(state, 'emit'); const emitSpy = jest.spyOn(state, 'emit');
@@ -316,27 +339,27 @@ describe("RoomState", function() {
state.setStateEvents([beaconEvent]); state.setStateEvents([beaconEvent]);
const beaconInstance = state.beacons.get(getBeaconInfoIdentifier(beaconEvent)); const beaconInstance = state.beacons.get(getBeaconInfoIdentifier(beaconEvent));
expect(beaconInstance.isLive).toEqual(true); expect(beaconInstance?.isLive).toEqual(true);
state.setStateEvents([updatedBeaconEvent]); state.setStateEvents([updatedBeaconEvent]);
// same Beacon // same Beacon
expect(state.beacons.get(getBeaconInfoIdentifier(beaconEvent))).toBe(beaconInstance); expect(state.beacons.get(getBeaconInfoIdentifier(beaconEvent))).toBe(beaconInstance);
// updated liveness // updated liveness
expect(state.beacons.get(getBeaconInfoIdentifier(beaconEvent)).isLive).toEqual(false); expect(state.beacons.get(getBeaconInfoIdentifier(beaconEvent))?.isLive).toEqual(false);
}); });
it('destroys and removes redacted beacon events', () => { it('destroys and removes redacted beacon events', () => {
const beaconId = '$beacon1'; const beaconId = '$beacon1';
const beaconEvent = makeBeaconInfoEvent(userA, roomId, { isLive: true }, beaconId); const beaconEvent = makeBeaconInfoEvent(userA, roomId, { isLive: true }, beaconId);
const redactedBeaconEvent = makeBeaconInfoEvent(userA, roomId, { isLive: true }, beaconId); const redactedBeaconEvent = makeBeaconInfoEvent(userA, roomId, { isLive: true }, beaconId);
const redactionEvent = { event: { type: 'm.room.redaction', redacts: beaconEvent.getId() } }; const redactionEvent = new MatrixEvent({ type: 'm.room.redaction', redacts: beaconEvent.getId() });
redactedBeaconEvent.makeRedacted(redactionEvent); redactedBeaconEvent.makeRedacted(redactionEvent);
state.setStateEvents([beaconEvent]); state.setStateEvents([beaconEvent]);
const beaconInstance = state.beacons.get(getBeaconInfoIdentifier(beaconEvent)); const beaconInstance = state.beacons.get(getBeaconInfoIdentifier(beaconEvent));
const destroySpy = jest.spyOn(beaconInstance, 'destroy'); const destroySpy = jest.spyOn(beaconInstance as Beacon, 'destroy');
expect(beaconInstance.isLive).toEqual(true); expect(beaconInstance?.isLive).toEqual(true);
state.setStateEvents([redactedBeaconEvent]); state.setStateEvents([redactedBeaconEvent]);
@@ -357,7 +380,7 @@ describe("RoomState", function() {
// live beacon is now not live // live beacon is now not live
const updatedLiveBeaconEvent = makeBeaconInfoEvent( const updatedLiveBeaconEvent = makeBeaconInfoEvent(
userA, roomId, { isLive: false }, liveBeaconEvent.getId(), '$beacon1', userA, roomId, { isLive: false }, liveBeaconEvent.getId(),
); );
state.setStateEvents([updatedLiveBeaconEvent]); state.setStateEvents([updatedLiveBeaconEvent]);
@@ -377,8 +400,8 @@ describe("RoomState", function() {
state.markOutOfBandMembersStarted(); state.markOutOfBandMembersStarted();
state.setOutOfBandMembers([oobMemberEvent]); state.setOutOfBandMembers([oobMemberEvent]);
const member = state.getMember(userLazy); const member = state.getMember(userLazy);
expect(member.userId).toEqual(userLazy); expect(member?.userId).toEqual(userLazy);
expect(member.isOutOfBand()).toEqual(true); expect(member?.isOutOfBand()).toEqual(true);
}); });
it("should have no effect when not in correct status", function() { it("should have no effect when not in correct status", function() {
@@ -394,7 +417,7 @@ describe("RoomState", function() {
user: userLazy, mship: "join", room: roomId, event: true, user: userLazy, mship: "join", room: roomId, event: true,
}); });
let eventReceived = false; let eventReceived = false;
state.once('RoomState.newMember', (_, __, member) => { state.once(RoomStateEvent.NewMember, (_event, _state, member) => {
expect(member.userId).toEqual(userLazy); expect(member.userId).toEqual(userLazy);
eventReceived = true; eventReceived = true;
}); });
@@ -410,8 +433,8 @@ describe("RoomState", function() {
state.markOutOfBandMembersStarted(); state.markOutOfBandMembersStarted();
state.setOutOfBandMembers([oobMemberEvent]); state.setOutOfBandMembers([oobMemberEvent]);
const memberA = state.getMember(userA); const memberA = state.getMember(userA);
expect(memberA.events.member.getId()).not.toEqual(oobMemberEvent.getId()); expect(memberA?.events?.member?.getId()).not.toEqual(oobMemberEvent.getId());
expect(memberA.isOutOfBand()).toEqual(false); expect(memberA?.isOutOfBand()).toEqual(false);
}); });
it("should emit members when updating a member", function() { it("should emit members when updating a member", function() {
@@ -420,7 +443,7 @@ describe("RoomState", function() {
user: doesntExistYetUserId, mship: "join", room: roomId, event: true, user: doesntExistYetUserId, mship: "join", room: roomId, event: true,
}); });
let eventReceived = false; let eventReceived = false;
state.once('RoomState.members', (_, __, member) => { state.once(RoomStateEvent.Members, (_event, _state, member) => {
expect(member.userId).toEqual(doesntExistYetUserId); expect(member.userId).toEqual(doesntExistYetUserId);
eventReceived = true; eventReceived = true;
}); });
@@ -443,8 +466,8 @@ describe("RoomState", function() {
[userA, userB, userLazy].forEach((userId) => { [userA, userB, userLazy].forEach((userId) => {
const member = state.getMember(userId); const member = state.getMember(userId);
const memberCopy = copy.getMember(userId); const memberCopy = copy.getMember(userId);
expect(member.name).toEqual(memberCopy.name); expect(member?.name).toEqual(memberCopy?.name);
expect(member.isOutOfBand()).toEqual(memberCopy.isOutOfBand()); expect(member?.isOutOfBand()).toEqual(memberCopy?.isOutOfBand());
}); });
// check member keys // check member keys
expect(Object.keys(state.members)).toEqual(Object.keys(copy.members)); expect(Object.keys(state.members)).toEqual(Object.keys(copy.members));
@@ -496,78 +519,80 @@ describe("RoomState", function() {
describe("maySendStateEvent", function() { describe("maySendStateEvent", function() {
it("should say any member may send state with no power level event", it("should say any member may send state with no power level event",
function() { function() {
expect(state.maySendStateEvent('m.room.name', userA)).toEqual(true); expect(state.maySendStateEvent('m.room.name', userA)).toEqual(true);
}); });
it("should say members with power >=50 may send state with power level event " + it("should say members with power >=50 may send state with power level event " +
"but no state default", "but no state default",
function() { function() {
const powerLevelEvent = { const powerLevelEvent = new MatrixEvent({
type: "m.room.power_levels", room: roomId, user: userA, event: true, type: "m.room.power_levels", room_id: roomId, sender: userA,
state_key: "",
content: { content: {
users_default: 10, users_default: 10,
// state_default: 50, "intentionally left blank" // state_default: 50, "intentionally left blank"
events_default: 25, events_default: 25,
users: { users: {
[userA]: 50,
}, },
}, },
}; });
powerLevelEvent.content.users[userA] = 50;
state.setStateEvents([utils.mkEvent(powerLevelEvent)]); state.setStateEvents([powerLevelEvent]);
expect(state.maySendStateEvent('m.room.name', userA)).toEqual(true); expect(state.maySendStateEvent('m.room.name', userA)).toEqual(true);
expect(state.maySendStateEvent('m.room.name', userB)).toEqual(false); expect(state.maySendStateEvent('m.room.name', userB)).toEqual(false);
}); });
it("should obey state_default", it("should obey state_default",
function() { function() {
const powerLevelEvent = { const powerLevelEvent = new MatrixEvent({
type: "m.room.power_levels", room: roomId, user: userA, event: true, type: "m.room.power_levels", room_id: roomId, sender: userA,
content: { state_key: "",
users_default: 10, content: {
state_default: 30, users_default: 10,
events_default: 25, state_default: 30,
users: { events_default: 25,
users: {
[userA]: 30,
[userB]: 29,
},
}, },
}, });
};
powerLevelEvent.content.users[userA] = 30;
powerLevelEvent.content.users[userB] = 29;
state.setStateEvents([utils.mkEvent(powerLevelEvent)]); state.setStateEvents([powerLevelEvent]);
expect(state.maySendStateEvent('m.room.name', userA)).toEqual(true); expect(state.maySendStateEvent('m.room.name', userA)).toEqual(true);
expect(state.maySendStateEvent('m.room.name', userB)).toEqual(false); expect(state.maySendStateEvent('m.room.name', userB)).toEqual(false);
}); });
it("should honour explicit event power levels in the power_levels event", it("should honour explicit event power levels in the power_levels event",
function() { function() {
const powerLevelEvent = { const powerLevelEvent = new MatrixEvent({
type: "m.room.power_levels", room: roomId, user: userA, event: true, type: "m.room.power_levels", room_id: roomId, sender: userA,
content: { state_key: "", content: {
events: { events: {
"m.room.other_thing": 76, "m.room.other_thing": 76,
},
users_default: 10,
state_default: 50,
events_default: 25,
users: {
[userA]: 80,
[userB]: 50,
},
}, },
users_default: 10, });
state_default: 50,
events_default: 25,
users: {
},
},
};
powerLevelEvent.content.users[userA] = 80;
powerLevelEvent.content.users[userB] = 50;
state.setStateEvents([utils.mkEvent(powerLevelEvent)]); state.setStateEvents([powerLevelEvent]);
expect(state.maySendStateEvent('m.room.name', userA)).toEqual(true); expect(state.maySendStateEvent('m.room.name', userA)).toEqual(true);
expect(state.maySendStateEvent('m.room.name', userB)).toEqual(true); expect(state.maySendStateEvent('m.room.name', userB)).toEqual(true);
expect(state.maySendStateEvent('m.room.other_thing', userA)).toEqual(true); expect(state.maySendStateEvent('m.room.other_thing', userA)).toEqual(true);
expect(state.maySendStateEvent('m.room.other_thing', userB)).toEqual(false); expect(state.maySendStateEvent('m.room.other_thing', userB)).toEqual(false);
}); });
}); });
describe("getJoinedMemberCount", function() { describe("getJoinedMemberCount", function() {
@@ -682,71 +707,73 @@ describe("RoomState", function() {
describe("maySendEvent", function() { describe("maySendEvent", function() {
it("should say any member may send events with no power level event", it("should say any member may send events with no power level event",
function() { function() {
expect(state.maySendEvent('m.room.message', userA)).toEqual(true); expect(state.maySendEvent('m.room.message', userA)).toEqual(true);
expect(state.maySendMessage(userA)).toEqual(true); expect(state.maySendMessage(userA)).toEqual(true);
}); });
it("should obey events_default", it("should obey events_default",
function() { function() {
const powerLevelEvent = { const powerLevelEvent = new MatrixEvent({
type: "m.room.power_levels", room: roomId, user: userA, event: true, type: "m.room.power_levels", room_id: roomId, sender: userA,
content: { state_key: "",
users_default: 10, content: {
state_default: 30, users_default: 10,
events_default: 25, state_default: 30,
users: { events_default: 25,
users: {
[userA]: 26,
[userB]: 24,
},
}, },
}, });
};
powerLevelEvent.content.users[userA] = 26;
powerLevelEvent.content.users[userB] = 24;
state.setStateEvents([utils.mkEvent(powerLevelEvent)]); state.setStateEvents([powerLevelEvent]);
expect(state.maySendEvent('m.room.message', userA)).toEqual(true); expect(state.maySendEvent('m.room.message', userA)).toEqual(true);
expect(state.maySendEvent('m.room.message', userB)).toEqual(false); expect(state.maySendEvent('m.room.message', userB)).toEqual(false);
expect(state.maySendMessage(userA)).toEqual(true); expect(state.maySendMessage(userA)).toEqual(true);
expect(state.maySendMessage(userB)).toEqual(false); expect(state.maySendMessage(userB)).toEqual(false);
}); });
it("should honour explicit event power levels in the power_levels event", it("should honour explicit event power levels in the power_levels event",
function() { function() {
const powerLevelEvent = { const powerLevelEvent = new MatrixEvent({
type: "m.room.power_levels", room: roomId, user: userA, event: true, type: "m.room.power_levels", room_id: roomId, sender: userA,
content: { state_key: "",
events: { content: {
"m.room.other_thing": 33, events: {
"m.room.other_thing": 33,
},
users_default: 10,
state_default: 50,
events_default: 25,
users: {
[userA]: 40,
[userB]: 30,
},
}, },
users_default: 10, });
state_default: 50,
events_default: 25,
users: {
},
},
};
powerLevelEvent.content.users[userA] = 40;
powerLevelEvent.content.users[userB] = 30;
state.setStateEvents([utils.mkEvent(powerLevelEvent)]); state.setStateEvents([powerLevelEvent]);
expect(state.maySendEvent('m.room.message', userA)).toEqual(true); expect(state.maySendEvent('m.room.message', userA)).toEqual(true);
expect(state.maySendEvent('m.room.message', userB)).toEqual(true); expect(state.maySendEvent('m.room.message', userB)).toEqual(true);
expect(state.maySendMessage(userA)).toEqual(true); expect(state.maySendMessage(userA)).toEqual(true);
expect(state.maySendMessage(userB)).toEqual(true); expect(state.maySendMessage(userB)).toEqual(true);
expect(state.maySendEvent('m.room.other_thing', userA)).toEqual(true); expect(state.maySendEvent('m.room.other_thing', userA)).toEqual(true);
expect(state.maySendEvent('m.room.other_thing', userB)).toEqual(false); expect(state.maySendEvent('m.room.other_thing', userB)).toEqual(false);
}); });
}); });
describe('processBeaconEvents', () => { describe('processBeaconEvents', () => {
const beacon1 = makeBeaconInfoEvent(userA, roomId, {}, '$beacon1', '$beacon1'); const beacon1 = makeBeaconInfoEvent(userA, roomId, {}, '$beacon1');
const beacon2 = makeBeaconInfoEvent(userB, roomId, {}, '$beacon2', '$beacon2'); const beacon2 = makeBeaconInfoEvent(userB, roomId, {}, '$beacon2');
const mockClient = { decryptEventIfNeeded: jest.fn() }; const mockClient = { decryptEventIfNeeded: jest.fn() } as unknown as MockedObject<MatrixClient>;
beforeEach(() => { beforeEach(() => {
mockClient.decryptEventIfNeeded.mockClear(); mockClient.decryptEventIfNeeded.mockClear();
@@ -816,11 +843,11 @@ describe("RoomState", function() {
beaconInfoId: 'some-other-beacon', beaconInfoId: 'some-other-beacon',
}); });
state.setStateEvents([beacon1, beacon2], mockClient); state.setStateEvents([beacon1, beacon2]);
expect(state.beacons.size).toEqual(2); expect(state.beacons.size).toEqual(2);
const beaconInstance = state.beacons.get(getBeaconInfoIdentifier(beacon1)); const beaconInstance = state.beacons.get(getBeaconInfoIdentifier(beacon1)) as Beacon;
const addLocationsSpy = jest.spyOn(beaconInstance, 'addLocations'); const addLocationsSpy = jest.spyOn(beaconInstance, 'addLocations');
state.processBeaconEvents([location1, location2, location3], mockClient); state.processBeaconEvents([location1, location2, location3], mockClient);
@@ -885,7 +912,7 @@ describe("RoomState", function() {
}); });
state.setStateEvents([beacon1, beacon2]); state.setStateEvents([beacon1, beacon2]);
const beacon = state.beacons.get(getBeaconInfoIdentifier(beacon1)); const beacon = state.beacons.get(getBeaconInfoIdentifier(beacon1)) as Beacon;
const addLocationsSpy = jest.spyOn(beacon, 'addLocations').mockClear(); const addLocationsSpy = jest.spyOn(beacon, 'addLocations').mockClear();
state.processBeaconEvents([location, otherRelatedEvent], mockClient); state.processBeaconEvents([location, otherRelatedEvent], mockClient);
expect(addLocationsSpy).not.toHaveBeenCalled(); expect(addLocationsSpy).not.toHaveBeenCalled();
@@ -945,13 +972,13 @@ describe("RoomState", function() {
}); });
jest.spyOn(decryptingRelatedEvent, 'isBeingDecrypted').mockReturnValue(true); jest.spyOn(decryptingRelatedEvent, 'isBeingDecrypted').mockReturnValue(true);
state.setStateEvents([beacon1, beacon2]); state.setStateEvents([beacon1, beacon2]);
const beacon = state.beacons.get(getBeaconInfoIdentifier(beacon1)); const beacon = state.beacons.get(getBeaconInfoIdentifier(beacon1)) as Beacon;
const addLocationsSpy = jest.spyOn(beacon, 'addLocations').mockClear(); const addLocationsSpy = jest.spyOn(beacon, 'addLocations').mockClear();
state.processBeaconEvents([decryptingRelatedEvent], mockClient); state.processBeaconEvents([decryptingRelatedEvent], mockClient);
// this event is a message after decryption // this event is a message after decryption
decryptingRelatedEvent.type = EventType.RoomMessage; decryptingRelatedEvent.event.type = EventType.RoomMessage;
decryptingRelatedEvent.emit(MatrixEventEvent.Decrypted); decryptingRelatedEvent.emit(MatrixEventEvent.Decrypted, decryptingRelatedEvent);
expect(addLocationsSpy).not.toHaveBeenCalled(); expect(addLocationsSpy).not.toHaveBeenCalled();
}); });
@@ -967,14 +994,14 @@ describe("RoomState", function() {
}); });
jest.spyOn(decryptingRelatedEvent, 'isBeingDecrypted').mockReturnValue(true); jest.spyOn(decryptingRelatedEvent, 'isBeingDecrypted').mockReturnValue(true);
state.setStateEvents([beacon1, beacon2]); state.setStateEvents([beacon1, beacon2]);
const beacon = state.beacons.get(getBeaconInfoIdentifier(beacon1)); const beacon = state.beacons.get(getBeaconInfoIdentifier(beacon1)) as Beacon;
const addLocationsSpy = jest.spyOn(beacon, 'addLocations').mockClear(); const addLocationsSpy = jest.spyOn(beacon, 'addLocations').mockClear();
state.processBeaconEvents([decryptingRelatedEvent], mockClient); state.processBeaconEvents([decryptingRelatedEvent], mockClient);
// update type after '''decryption''' // update type after '''decryption'''
decryptingRelatedEvent.event.type = M_BEACON.name; decryptingRelatedEvent.event.type = M_BEACON.name;
decryptingRelatedEvent.event.content = locationEvent.content; decryptingRelatedEvent.event.content = locationEvent.event.content;
decryptingRelatedEvent.emit(MatrixEventEvent.Decrypted); decryptingRelatedEvent.emit(MatrixEventEvent.Decrypted, decryptingRelatedEvent);
expect(addLocationsSpy).toHaveBeenCalledWith([decryptingRelatedEvent]); expect(addLocationsSpy).toHaveBeenCalledWith([decryptingRelatedEvent]);
}); });

View File

@@ -288,11 +288,11 @@ describe("Room", function() {
room.addLiveEvents(events); room.addLiveEvents(events);
expect(room.currentState.setStateEvents).toHaveBeenCalledWith( expect(room.currentState.setStateEvents).toHaveBeenCalledWith(
[events[0]], [events[0]],
{ timelineWasEmpty: undefined }, { timelineWasEmpty: false },
); );
expect(room.currentState.setStateEvents).toHaveBeenCalledWith( expect(room.currentState.setStateEvents).toHaveBeenCalledWith(
[events[1]], [events[1]],
{ timelineWasEmpty: undefined }, { timelineWasEmpty: false },
); );
expect(events[0].forwardLooking).toBe(true); expect(events[0].forwardLooking).toBe(true);
expect(events[1].forwardLooking).toBe(true); expect(events[1].forwardLooking).toBe(true);
@@ -426,6 +426,17 @@ describe("Room", function() {
// but without the event ID matching we will still have the local event in pending events // but without the event ID matching we will still have the local event in pending events
expect(room.getEventForTxnId(txnId)).toBeUndefined(); expect(room.getEventForTxnId(txnId)).toBeUndefined();
}); });
it("should correctly handle remote echoes from other devices", () => {
const remoteEvent = utils.mkMessage({
room: roomId, user: userA, event: true,
});
remoteEvent.event.unsigned = { transaction_id: "TXN_ID" };
// add the remoteEvent
room.addLiveEvents([remoteEvent]);
expect(room.timeline.length).toEqual(1);
});
}); });
describe('addEphemeralEvents', () => { describe('addEphemeralEvents', () => {

View File

@@ -152,6 +152,9 @@ describe("utils", function() {
assert.isTrue(utils.deepCompare({ a: 1, b: 2 }, { a: 1, b: 2 })); assert.isTrue(utils.deepCompare({ a: 1, b: 2 }, { a: 1, b: 2 }));
assert.isTrue(utils.deepCompare({ a: 1, b: 2 }, { b: 2, a: 1 })); assert.isTrue(utils.deepCompare({ a: 1, b: 2 }, { b: 2, a: 1 }));
assert.isFalse(utils.deepCompare({ a: 1, b: 2 }, { a: 1, b: 3 })); assert.isFalse(utils.deepCompare({ a: 1, b: 2 }, { a: 1, b: 3 }));
assert.isFalse(utils.deepCompare({ a: 1, b: 2 }, { a: 1 }));
assert.isFalse(utils.deepCompare({ a: 1 }, { a: 1, b: 2 }));
assert.isFalse(utils.deepCompare({ a: 1 }, { b: 1 }));
assert.isTrue(utils.deepCompare({ assert.isTrue(utils.deepCompare({
1: { name: "mhc", age: 28 }, 1: { name: "mhc", age: 28 },

View File

@@ -201,6 +201,7 @@ import { Thread, THREAD_RELATION_TYPE } from "./models/thread";
import { MBeaconInfoEventContent, M_BEACON_INFO } from "./@types/beacon"; import { MBeaconInfoEventContent, M_BEACON_INFO } from "./@types/beacon";
import { ToDeviceMessageQueue } from "./ToDeviceMessageQueue"; import { ToDeviceMessageQueue } from "./ToDeviceMessageQueue";
import { ToDeviceBatch } from "./models/ToDeviceMessage"; import { ToDeviceBatch } from "./models/ToDeviceMessage";
import { IgnoredInvites } from "./models/invites-ignorer";
export type Store = IStore; export type Store = IStore;
@@ -406,8 +407,7 @@ export interface IStartClientOpts {
pollTimeout?: number; pollTimeout?: number;
/** /**
* The filter to apply to /sync calls. This will override the opts.initialSyncLimit, which would * The filter to apply to /sync calls.
* normally result in a timeline limit filter.
*/ */
filter?: Filter; filter?: Filter;
@@ -974,6 +974,9 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
private useE2eForGroupCall = true; private useE2eForGroupCall = true;
private toDeviceMessageQueue: ToDeviceMessageQueue; private toDeviceMessageQueue: ToDeviceMessageQueue;
// A manager for determining which invites should be ignored.
public readonly ignoredInvites: IgnoredInvites;
constructor(opts: IMatrixClientCreateOpts) { constructor(opts: IMatrixClientCreateOpts) {
super(); super();
@@ -1159,6 +1162,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
room.setUnreadNotificationCount(NotificationCountType.Highlight, highlightCount); room.setUnreadNotificationCount(NotificationCountType.Highlight, highlightCount);
} }
}); });
this.ignoredInvites = new IgnoredInvites(this);
} }
/** /**

View File

@@ -34,7 +34,7 @@ import { IRoomEncryption } from "../RoomList";
* *
* @type {Object.<string, function(new: module:crypto/algorithms/base.EncryptionAlgorithm)>} * @type {Object.<string, function(new: module:crypto/algorithms/base.EncryptionAlgorithm)>}
*/ */
export const ENCRYPTION_CLASSES: Record<string, new (params: IParams) => EncryptionAlgorithm> = {}; export const ENCRYPTION_CLASSES = new Map<string, new (params: IParams) => EncryptionAlgorithm>();
type DecryptionClassParams = Omit<IParams, "deviceId" | "config">; type DecryptionClassParams = Omit<IParams, "deviceId" | "config">;
@@ -44,7 +44,7 @@ type DecryptionClassParams = Omit<IParams, "deviceId" | "config">;
* *
* @type {Object.<string, function(new: module:crypto/algorithms/base.DecryptionAlgorithm)>} * @type {Object.<string, function(new: module:crypto/algorithms/base.DecryptionAlgorithm)>}
*/ */
export const DECRYPTION_CLASSES: Record<string, new (params: DecryptionClassParams) => DecryptionAlgorithm> = {}; export const DECRYPTION_CLASSES = new Map<string, new (params: DecryptionClassParams) => DecryptionAlgorithm>();
export interface IParams { export interface IParams {
userId: string; userId: string;
@@ -297,6 +297,6 @@ export function registerAlgorithm(
encryptor: new (params: IParams) => EncryptionAlgorithm, encryptor: new (params: IParams) => EncryptionAlgorithm,
decryptor: new (params: Omit<IParams, "deviceId">) => DecryptionAlgorithm, decryptor: new (params: Omit<IParams, "deviceId">) => DecryptionAlgorithm,
): void { ): void {
ENCRYPTION_CLASSES[algorithm] = encryptor; ENCRYPTION_CLASSES.set(algorithm, encryptor);
DECRYPTION_CLASSES[algorithm] = decryptor; DECRYPTION_CLASSES.set(algorithm, decryptor);
} }

View File

@@ -1185,7 +1185,7 @@ class MegolmEncryption extends EncryptionAlgorithm {
class MegolmDecryption extends DecryptionAlgorithm { class MegolmDecryption extends DecryptionAlgorithm {
// events which we couldn't decrypt due to unknown sessions / indexes: map from // events which we couldn't decrypt due to unknown sessions / indexes: map from
// senderKey|sessionId to Set of MatrixEvents // senderKey|sessionId to Set of MatrixEvents
private pendingEvents: Record<string, Map<string, Set<MatrixEvent>>> = {}; private pendingEvents = new Map<string, Map<string, Set<MatrixEvent>>>();
// this gets stubbed out by the unit tests. // this gets stubbed out by the unit tests.
private olmlib = olmlib; private olmlib = olmlib;
@@ -1337,10 +1337,10 @@ class MegolmDecryption extends DecryptionAlgorithm {
const content = event.getWireContent(); const content = event.getWireContent();
const senderKey = content.sender_key; const senderKey = content.sender_key;
const sessionId = content.session_id; const sessionId = content.session_id;
if (!this.pendingEvents[senderKey]) { if (!this.pendingEvents.has(senderKey)) {
this.pendingEvents[senderKey] = new Map(); this.pendingEvents.set(senderKey, new Map<string, Set<MatrixEvent>>());
} }
const senderPendingEvents = this.pendingEvents[senderKey]; const senderPendingEvents = this.pendingEvents.get(senderKey);
if (!senderPendingEvents.has(sessionId)) { if (!senderPendingEvents.has(sessionId)) {
senderPendingEvents.set(sessionId, new Set()); senderPendingEvents.set(sessionId, new Set());
} }
@@ -1358,7 +1358,7 @@ class MegolmDecryption extends DecryptionAlgorithm {
const content = event.getWireContent(); const content = event.getWireContent();
const senderKey = content.sender_key; const senderKey = content.sender_key;
const sessionId = content.session_id; const sessionId = content.session_id;
const senderPendingEvents = this.pendingEvents[senderKey]; const senderPendingEvents = this.pendingEvents.get(senderKey);
const pendingEvents = senderPendingEvents?.get(sessionId); const pendingEvents = senderPendingEvents?.get(sessionId);
if (!pendingEvents) { if (!pendingEvents) {
return; return;
@@ -1369,7 +1369,7 @@ class MegolmDecryption extends DecryptionAlgorithm {
senderPendingEvents.delete(sessionId); senderPendingEvents.delete(sessionId);
} }
if (senderPendingEvents.size === 0) { if (senderPendingEvents.size === 0) {
delete this.pendingEvents[senderKey]; this.pendingEvents.delete(senderKey);
} }
} }
@@ -1710,7 +1710,7 @@ class MegolmDecryption extends DecryptionAlgorithm {
* @return {Boolean} whether all messages were successfully decrypted * @return {Boolean} whether all messages were successfully decrypted
*/ */
private async retryDecryption(senderKey: string, sessionId: string): Promise<boolean> { private async retryDecryption(senderKey: string, sessionId: string): Promise<boolean> {
const senderPendingEvents = this.pendingEvents[senderKey]; const senderPendingEvents = this.pendingEvents.get(senderKey);
if (!senderPendingEvents) { if (!senderPendingEvents) {
return true; return true;
} }
@@ -1731,16 +1731,16 @@ class MegolmDecryption extends DecryptionAlgorithm {
})); }));
// If decrypted successfully, they'll have been removed from pendingEvents // If decrypted successfully, they'll have been removed from pendingEvents
return !this.pendingEvents[senderKey]?.has(sessionId); return !this.pendingEvents.get(senderKey)?.has(sessionId);
} }
public async retryDecryptionFromSender(senderKey: string): Promise<boolean> { public async retryDecryptionFromSender(senderKey: string): Promise<boolean> {
const senderPendingEvents = this.pendingEvents[senderKey]; const senderPendingEvents = this.pendingEvents.get(senderKey);
if (!senderPendingEvents) { if (!senderPendingEvents) {
return true; return true;
} }
delete this.pendingEvents[senderKey]; this.pendingEvents.delete(senderKey);
await Promise.all([...senderPendingEvents].map(async ([_sessionId, pending]) => { await Promise.all([...senderPendingEvents].map(async ([_sessionId, pending]) => {
await Promise.all([...pending].map(async (ev) => { await Promise.all([...pending].map(async (ev) => {
@@ -1752,7 +1752,7 @@ class MegolmDecryption extends DecryptionAlgorithm {
})); }));
})); }));
return !this.pendingEvents[senderKey]; return !this.pendingEvents.has(senderKey);
} }
public async sendSharedHistoryInboundSessions(devicesByUser: Record<string, DeviceInfo[]>): Promise<void> { public async sendSharedHistoryInboundSessions(devicesByUser: Record<string, DeviceInfo[]>): Promise<void> {

View File

@@ -301,9 +301,9 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
private oneTimeKeyCheckInProgress = false; private oneTimeKeyCheckInProgress = false;
// EncryptionAlgorithm instance for each room // EncryptionAlgorithm instance for each room
private roomEncryptors: Record<string, EncryptionAlgorithm> = {}; private roomEncryptors = new Map<string, EncryptionAlgorithm>();
// map from algorithm to DecryptionAlgorithm instance, for each room // map from algorithm to DecryptionAlgorithm instance, for each room
private roomDecryptors: Record<string, Record<string, DecryptionAlgorithm>> = {}; private roomDecryptors = new Map<string, Map<string, DecryptionAlgorithm>>();
private deviceKeys: Record<string, string> = {}; // type: key private deviceKeys: Record<string, string> = {}; // type: key
@@ -445,7 +445,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
this.deviceList.on(CryptoEvent.UserCrossSigningUpdated, this.onDeviceListUserCrossSigningUpdated); this.deviceList.on(CryptoEvent.UserCrossSigningUpdated, this.onDeviceListUserCrossSigningUpdated);
this.reEmitter.reEmit(this.deviceList, [CryptoEvent.DevicesUpdated, CryptoEvent.WillUpdateDevices]); this.reEmitter.reEmit(this.deviceList, [CryptoEvent.DevicesUpdated, CryptoEvent.WillUpdateDevices]);
this.supportedAlgorithms = Object.keys(algorithms.DECRYPTION_CLASSES); this.supportedAlgorithms = Array.from(algorithms.DECRYPTION_CLASSES.keys());
this.outgoingRoomKeyRequestManager = new OutgoingRoomKeyRequestManager( this.outgoingRoomKeyRequestManager = new OutgoingRoomKeyRequestManager(
baseApis, this.deviceId, this.cryptoStore, baseApis, this.deviceId, this.cryptoStore,
@@ -2550,7 +2550,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
* This should not normally be necessary. * This should not normally be necessary.
*/ */
public forceDiscardSession(roomId: string): void { public forceDiscardSession(roomId: string): void {
const alg = this.roomEncryptors[roomId]; const alg = this.roomEncryptors.get(roomId);
if (alg === undefined) throw new Error("Room not encrypted"); if (alg === undefined) throw new Error("Room not encrypted");
if (alg.forceDiscardSession === undefined) { if (alg.forceDiscardSession === undefined) {
throw new Error("Room encryption algorithm doesn't support session discarding"); throw new Error("Room encryption algorithm doesn't support session discarding");
@@ -2603,7 +2603,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
// the encryption event would appear in both. // the encryption event would appear in both.
// If it's called more than twice though, // If it's called more than twice though,
// it signals a bug on client or server. // it signals a bug on client or server.
const existingAlg = this.roomEncryptors[roomId]; const existingAlg = this.roomEncryptors.get(roomId);
if (existingAlg) { if (existingAlg) {
return; return;
} }
@@ -2617,7 +2617,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
storeConfigPromise = this.roomList.setRoomEncryption(roomId, config); storeConfigPromise = this.roomList.setRoomEncryption(roomId, config);
} }
const AlgClass = algorithms.ENCRYPTION_CLASSES[config.algorithm]; const AlgClass = algorithms.ENCRYPTION_CLASSES.get(config.algorithm);
if (!AlgClass) { if (!AlgClass) {
throw new Error("Unable to encrypt with " + config.algorithm); throw new Error("Unable to encrypt with " + config.algorithm);
} }
@@ -2631,7 +2631,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
roomId, roomId,
config, config,
}); });
this.roomEncryptors[roomId] = alg; this.roomEncryptors.set(roomId, alg);
if (storeConfigPromise) { if (storeConfigPromise) {
await storeConfigPromise; await storeConfigPromise;
@@ -2663,7 +2663,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
public trackRoomDevices(roomId: string): Promise<void> { public trackRoomDevices(roomId: string): Promise<void> {
const trackMembers = async () => { const trackMembers = async () => {
// not an encrypted room // not an encrypted room
if (!this.roomEncryptors[roomId]) { if (!this.roomEncryptors.has(roomId)) {
return; return;
} }
const room = this.clientStore.getRoom(roomId); const room = this.clientStore.getRoom(roomId);
@@ -2808,7 +2808,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
* @param {module:models/room} room the room the event is in * @param {module:models/room} room the room the event is in
*/ */
public prepareToEncrypt(room: Room): void { public prepareToEncrypt(room: Room): void {
const alg = this.roomEncryptors[room.roomId]; const alg = this.roomEncryptors.get(room.roomId);
if (alg) { if (alg) {
alg.prepareToEncrypt(room); alg.prepareToEncrypt(room);
} }
@@ -2831,7 +2831,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
const roomId = event.getRoomId(); const roomId = event.getRoomId();
const alg = this.roomEncryptors[roomId]; const alg = this.roomEncryptors.get(roomId);
if (!alg) { if (!alg) {
// MatrixClient has already checked that this room should be encrypted, // MatrixClient has already checked that this room should be encrypted,
// so this is an unexpected situation. // so this is an unexpected situation.
@@ -3120,7 +3120,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
private getTrackedE2eRooms(): Room[] { private getTrackedE2eRooms(): Room[] {
return this.clientStore.getRooms().filter((room) => { return this.clientStore.getRooms().filter((room) => {
// check for rooms with encryption enabled // check for rooms with encryption enabled
const alg = this.roomEncryptors[room.roomId]; const alg = this.roomEncryptors.get(room.roomId);
if (!alg) { if (!alg) {
return false; return false;
} }
@@ -3556,7 +3556,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
const roomId = member.roomId; const roomId = member.roomId;
const alg = this.roomEncryptors[roomId]; const alg = this.roomEncryptors.get(roomId);
if (!alg) { if (!alg) {
// not encrypting in this room // not encrypting in this room
return; return;
@@ -3657,11 +3657,11 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
` for ${roomId} / ${body.session_id} (id ${req.requestId})`); ` for ${roomId} / ${body.session_id} (id ${req.requestId})`);
if (userId !== this.userId) { if (userId !== this.userId) {
if (!this.roomEncryptors[roomId]) { if (!this.roomEncryptors.get(roomId)) {
logger.debug(`room key request for unencrypted room ${roomId}`); logger.debug(`room key request for unencrypted room ${roomId}`);
return; return;
} }
const encryptor = this.roomEncryptors[roomId]; const encryptor = this.roomEncryptors.get(roomId);
const device = this.deviceList.getStoredDevice(userId, deviceId); const device = this.deviceList.getStoredDevice(userId, deviceId);
if (!device) { if (!device) {
logger.debug(`Ignoring keyshare for unknown device ${userId}:${deviceId}`); logger.debug(`Ignoring keyshare for unknown device ${userId}:${deviceId}`);
@@ -3697,12 +3697,12 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
// if we don't have a decryptor for this room/alg, we don't have // if we don't have a decryptor for this room/alg, we don't have
// the keys for the requested events, and can drop the requests. // the keys for the requested events, and can drop the requests.
if (!this.roomDecryptors[roomId]) { if (!this.roomDecryptors.has(roomId)) {
logger.log(`room key request for unencrypted room ${roomId}`); logger.log(`room key request for unencrypted room ${roomId}`);
return; return;
} }
const decryptor = this.roomDecryptors[roomId][alg]; const decryptor = this.roomDecryptors.get(roomId).get(alg);
if (!decryptor) { if (!decryptor) {
logger.log(`room key request for unknown alg ${alg} in room ${roomId}`); logger.log(`room key request for unknown alg ${alg} in room ${roomId}`);
return; return;
@@ -3768,23 +3768,24 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
* unknown * unknown
*/ */
public getRoomDecryptor(roomId: string, algorithm: string): DecryptionAlgorithm { public getRoomDecryptor(roomId: string, algorithm: string): DecryptionAlgorithm {
let decryptors: Record<string, DecryptionAlgorithm>; let decryptors: Map<string, DecryptionAlgorithm>;
let alg: DecryptionAlgorithm; let alg: DecryptionAlgorithm;
roomId = roomId || null; roomId = roomId || null;
if (roomId) { if (roomId) {
decryptors = this.roomDecryptors[roomId]; decryptors = this.roomDecryptors.get(roomId);
if (!decryptors) { if (!decryptors) {
this.roomDecryptors[roomId] = decryptors = {}; decryptors = new Map<string, DecryptionAlgorithm>();
this.roomDecryptors.set(roomId, decryptors);
} }
alg = decryptors[algorithm]; alg = decryptors.get(algorithm);
if (alg) { if (alg) {
return alg; return alg;
} }
} }
const AlgClass = algorithms.DECRYPTION_CLASSES[algorithm]; const AlgClass = algorithms.DECRYPTION_CLASSES.get(algorithm);
if (!AlgClass) { if (!AlgClass) {
throw new algorithms.DecryptionError( throw new algorithms.DecryptionError(
'UNKNOWN_ENCRYPTION_ALGORITHM', 'UNKNOWN_ENCRYPTION_ALGORITHM',
@@ -3800,7 +3801,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
}); });
if (decryptors) { if (decryptors) {
decryptors[algorithm] = alg; decryptors.set(algorithm, alg);
} }
return alg; return alg;
} }
@@ -3814,9 +3815,9 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
*/ */
private getRoomDecryptors(algorithm: string): DecryptionAlgorithm[] { private getRoomDecryptors(algorithm: string): DecryptionAlgorithm[] {
const decryptors = []; const decryptors = [];
for (const d of Object.values(this.roomDecryptors)) { for (const d of this.roomDecryptors.values()) {
if (algorithm in d) { if (d.has(algorithm)) {
decryptors.push(d[algorithm]); decryptors.push(d.get(algorithm));
} }
} }
return decryptors; return decryptors;

View File

@@ -26,7 +26,7 @@ import {
Mode, Mode,
OutgoingRoomKeyRequest, OutgoingRoomKeyRequest,
} from "./base"; } from "./base";
import { IRoomKeyRequestBody } from "../index"; import { IRoomKeyRequestBody, IRoomKeyRequestRecipient } from "../index";
import { ICrossSigningKey } from "../../client"; import { ICrossSigningKey } from "../../client";
import { IOlmDevice } from "../algorithms/megolm"; import { IOlmDevice } from "../algorithms/megolm";
import { IRoomEncryption } from "../RoomList"; import { IRoomEncryption } from "../RoomList";
@@ -261,7 +261,9 @@ export class Backend implements CryptoStore {
const cursor = this.result; const cursor = this.result;
if (cursor) { if (cursor) {
const keyReq = cursor.value; const keyReq = cursor.value;
if (keyReq.recipients.includes({ userId, deviceId })) { if (keyReq.recipients.some((recipient: IRoomKeyRequestRecipient) =>
recipient.userId === userId && recipient.deviceId === deviceId,
)) {
results.push(keyReq); results.push(keyReq);
} }
cursor.continue(); cursor.continue();

View File

@@ -191,11 +191,13 @@ export class MemoryCryptoStore implements CryptoStore {
deviceId: string, deviceId: string,
wantedStates: number[], wantedStates: number[],
): Promise<OutgoingRoomKeyRequest[]> { ): Promise<OutgoingRoomKeyRequest[]> {
const results = []; const results: OutgoingRoomKeyRequest[] = [];
for (const req of this.outgoingRoomKeyRequests) { for (const req of this.outgoingRoomKeyRequests) {
for (const state of wantedStates) { for (const state of wantedStates) {
if (req.state === state && req.recipients.includes({ userId, deviceId })) { if (req.state === state && req.recipients.some(
(recipient) => recipient.userId === userId && recipient.deviceId === deviceId,
)) {
results.push(req); results.push(req);
} }
} }

View File

@@ -86,7 +86,7 @@ export class EventTimelineSet extends TypedEventEmitter<EmittedEvents, EventTime
private readonly displayPendingEvents: boolean; private readonly displayPendingEvents: boolean;
private liveTimeline: EventTimeline; private liveTimeline: EventTimeline;
private timelines: EventTimeline[]; private timelines: EventTimeline[];
private _eventIdToTimeline: Record<string, EventTimeline>; private _eventIdToTimeline = new Map<string, EventTimeline>();
private filter?: Filter; private filter?: Filter;
/** /**
@@ -138,7 +138,7 @@ export class EventTimelineSet extends TypedEventEmitter<EmittedEvents, EventTime
// just a list - *not* ordered. // just a list - *not* ordered.
this.timelines = [this.liveTimeline]; this.timelines = [this.liveTimeline];
this._eventIdToTimeline = {}; this._eventIdToTimeline = new Map<string, EventTimeline>();
this.filter = opts.filter; this.filter = opts.filter;
@@ -210,7 +210,7 @@ export class EventTimelineSet extends TypedEventEmitter<EmittedEvents, EventTime
* @return {module:models/event-timeline~EventTimeline} timeline * @return {module:models/event-timeline~EventTimeline} timeline
*/ */
public eventIdToTimeline(eventId: string): EventTimeline { public eventIdToTimeline(eventId: string): EventTimeline {
return this._eventIdToTimeline[eventId]; return this._eventIdToTimeline.get(eventId);
} }
/** /**
@@ -220,10 +220,10 @@ export class EventTimelineSet extends TypedEventEmitter<EmittedEvents, EventTime
* @param {String} newEventId event ID of the replacement event * @param {String} newEventId event ID of the replacement event
*/ */
public replaceEventId(oldEventId: string, newEventId: string): void { public replaceEventId(oldEventId: string, newEventId: string): void {
const existingTimeline = this._eventIdToTimeline[oldEventId]; const existingTimeline = this._eventIdToTimeline.get(oldEventId);
if (existingTimeline) { if (existingTimeline) {
delete this._eventIdToTimeline[oldEventId]; this._eventIdToTimeline.delete(oldEventId);
this._eventIdToTimeline[newEventId] = existingTimeline; this._eventIdToTimeline.set(newEventId, existingTimeline);
} }
} }
@@ -257,7 +257,7 @@ export class EventTimelineSet extends TypedEventEmitter<EmittedEvents, EventTime
if (resetAllTimelines) { if (resetAllTimelines) {
this.timelines = [newTimeline]; this.timelines = [newTimeline];
this._eventIdToTimeline = {}; this._eventIdToTimeline = new Map<string, EventTimeline>();
} else { } else {
this.timelines.push(newTimeline); this.timelines.push(newTimeline);
} }
@@ -288,7 +288,7 @@ export class EventTimelineSet extends TypedEventEmitter<EmittedEvents, EventTime
* the given event, or null if unknown * the given event, or null if unknown
*/ */
public getTimelineForEvent(eventId: string): EventTimeline | null { public getTimelineForEvent(eventId: string): EventTimeline | null {
const res = this._eventIdToTimeline[eventId]; const res = this._eventIdToTimeline.get(eventId);
return (res === undefined) ? null : res; return (res === undefined) ? null : res;
} }
@@ -450,7 +450,7 @@ export class EventTimelineSet extends TypedEventEmitter<EmittedEvents, EventTime
const event = events[i]; const event = events[i];
const eventId = event.getId(); const eventId = event.getId();
const existingTimeline = this._eventIdToTimeline[eventId]; const existingTimeline = this._eventIdToTimeline.get(eventId);
if (!existingTimeline) { if (!existingTimeline) {
// we don't know about this event yet. Just add it to the timeline. // we don't know about this event yet. Just add it to the timeline.
@@ -601,7 +601,7 @@ export class EventTimelineSet extends TypedEventEmitter<EmittedEvents, EventTime
} }
} }
const timeline = this._eventIdToTimeline[event.getId()]; const timeline = this._eventIdToTimeline.get(event.getId());
if (timeline) { if (timeline) {
if (duplicateStrategy === DuplicateStrategy.Replace) { if (duplicateStrategy === DuplicateStrategy.Replace) {
debuglog("EventTimelineSet.addLiveEvent: replacing duplicate event " + event.getId()); debuglog("EventTimelineSet.addLiveEvent: replacing duplicate event " + event.getId());
@@ -697,7 +697,7 @@ export class EventTimelineSet extends TypedEventEmitter<EmittedEvents, EventTime
roomState, roomState,
timelineWasEmpty, timelineWasEmpty,
}); });
this._eventIdToTimeline[eventId] = timeline; this._eventIdToTimeline.set(eventId, timeline);
this.relations.aggregateParentEvent(event); this.relations.aggregateParentEvent(event);
this.relations.aggregateChildEvent(event, this); this.relations.aggregateChildEvent(event, this);
@@ -725,22 +725,14 @@ export class EventTimelineSet extends TypedEventEmitter<EmittedEvents, EventTime
newEventId: string, newEventId: string,
): void { ): void {
// XXX: why don't we infer newEventId from localEvent? // XXX: why don't we infer newEventId from localEvent?
const existingTimeline = this._eventIdToTimeline[oldEventId]; const existingTimeline = this._eventIdToTimeline.get(oldEventId);
if (existingTimeline) { if (existingTimeline) {
delete this._eventIdToTimeline[oldEventId]; this._eventIdToTimeline.delete(oldEventId);
this._eventIdToTimeline[newEventId] = existingTimeline; this._eventIdToTimeline.set(newEventId, existingTimeline);
} else { } else if (!this.filter || this.filter.filterRoomTimeline([localEvent]).length) {
if (this.filter) { this.addEventToTimeline(localEvent, this.liveTimeline, {
if (this.filter.filterRoomTimeline([localEvent]).length) { toStartOfTimeline: false,
this.addEventToTimeline(localEvent, this.liveTimeline, { });
toStartOfTimeline: false,
});
}
} else {
this.addEventToTimeline(localEvent, this.liveTimeline, {
toStartOfTimeline: false,
});
}
} }
} }
@@ -753,14 +745,14 @@ export class EventTimelineSet extends TypedEventEmitter<EmittedEvents, EventTime
* in this room. * in this room.
*/ */
public removeEvent(eventId: string): MatrixEvent | null { public removeEvent(eventId: string): MatrixEvent | null {
const timeline = this._eventIdToTimeline[eventId]; const timeline = this._eventIdToTimeline.get(eventId);
if (!timeline) { if (!timeline) {
return null; return null;
} }
const removed = timeline.removeEvent(eventId); const removed = timeline.removeEvent(eventId);
if (removed) { if (removed) {
delete this._eventIdToTimeline[eventId]; this._eventIdToTimeline.delete(eventId);
const data = { const data = {
timeline: timeline, timeline: timeline,
}; };
@@ -787,8 +779,8 @@ export class EventTimelineSet extends TypedEventEmitter<EmittedEvents, EventTime
return 0; return 0;
} }
const timeline1 = this._eventIdToTimeline[eventId1]; const timeline1 = this._eventIdToTimeline.get(eventId1);
const timeline2 = this._eventIdToTimeline[eventId2]; const timeline2 = this._eventIdToTimeline.get(eventId2);
if (timeline1 === undefined) { if (timeline1 === undefined) {
return null; return null;

View File

@@ -26,7 +26,7 @@ import { logger } from '../logger';
import { VerificationRequest } from "../crypto/verification/request/VerificationRequest"; import { VerificationRequest } from "../crypto/verification/request/VerificationRequest";
import { EVENT_VISIBILITY_CHANGE_TYPE, EventType, MsgType, RelationType } from "../@types/event"; import { EVENT_VISIBILITY_CHANGE_TYPE, EventType, MsgType, RelationType } from "../@types/event";
import { Crypto, IEventDecryptionResult } from "../crypto"; import { Crypto, IEventDecryptionResult } from "../crypto";
import { deepSortedObjectEntries } from "../utils"; import { deepSortedObjectEntries, internaliseString } from "../utils";
import { RoomMember } from "./room-member"; import { RoomMember } from "./room-member";
import { Thread, ThreadEvent, EventHandlerMap as ThreadEventHandlerMap, THREAD_RELATION_TYPE } from "./thread"; import { Thread, ThreadEvent, EventHandlerMap as ThreadEventHandlerMap, THREAD_RELATION_TYPE } from "./thread";
import { IActionsObject } from '../pushprocessor'; import { IActionsObject } from '../pushprocessor';
@@ -37,14 +37,6 @@ import { EventStatus } from "./event-status";
export { EventStatus } from "./event-status"; export { EventStatus } from "./event-status";
const interns: Record<string, string> = {};
function intern(str: string): string {
if (!interns[str]) {
interns[str] = str;
}
return interns[str];
}
/* eslint-disable camelcase */ /* eslint-disable camelcase */
export interface IContent { export interface IContent {
[key: string]: any; [key: string]: any;
@@ -326,17 +318,17 @@ export class MatrixEvent extends TypedEventEmitter<EmittedEvents, MatrixEventHan
// of space if we don't intern it. // of space if we don't intern it.
["state_key", "type", "sender", "room_id", "membership"].forEach((prop) => { ["state_key", "type", "sender", "room_id", "membership"].forEach((prop) => {
if (typeof event[prop] !== "string") return; if (typeof event[prop] !== "string") return;
event[prop] = intern(event[prop]); event[prop] = internaliseString(event[prop]);
}); });
["membership", "avatar_url", "displayname"].forEach((prop) => { ["membership", "avatar_url", "displayname"].forEach((prop) => {
if (typeof event.content?.[prop] !== "string") return; if (typeof event.content?.[prop] !== "string") return;
event.content[prop] = intern(event.content[prop]); event.content[prop] = internaliseString(event.content[prop]);
}); });
["rel_type"].forEach((prop) => { ["rel_type"].forEach((prop) => {
if (typeof event.content?.["m.relates_to"]?.[prop] !== "string") return; if (typeof event.content?.["m.relates_to"]?.[prop] !== "string") return;
event.content["m.relates_to"][prop] = intern(event.content["m.relates_to"][prop]); event.content["m.relates_to"][prop] = internaliseString(event.content["m.relates_to"][prop]);
}); });
this.txnId = event.txn_id || null; this.txnId = event.txn_id || null;
@@ -796,6 +788,8 @@ export class MatrixEvent extends TypedEventEmitter<EmittedEvents, MatrixEventHan
// not a decryption error: log the whole exception as an error // not a decryption error: log the whole exception as an error
// (and don't bother with a retry) // (and don't bother with a retry)
const re = options.isRetry ? 're' : ''; const re = options.isRetry ? 're' : '';
// For find results: this can produce "Error decrypting event (id=$ev)" and
// "Error redecrypting event (id=$ev)".
logger.error( logger.error(
`Error ${re}decrypting event ` + `Error ${re}decrypting event ` +
`(id=${this.getId()}): ${e.stack || e}`, `(id=${this.getId()}): ${e.stack || e}`,

View File

@@ -0,0 +1,359 @@
/*
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 { UnstableValue } from "matrix-events-sdk";
import { MatrixClient } from "../client";
import { EventTimeline, MatrixEvent, Preset } from "../matrix";
import { globToRegexp } from "../utils";
import { Room } from "./room";
/// The event type storing the user's individual policies.
///
/// Exported for testing purposes.
export const POLICIES_ACCOUNT_EVENT_TYPE = new UnstableValue("m.policies", "org.matrix.msc3847.policies");
/// The key within the user's individual policies storing the user's ignored invites.
///
/// Exported for testing purposes.
export const IGNORE_INVITES_ACCOUNT_EVENT_KEY = new UnstableValue("m.ignore.invites",
"org.matrix.msc3847.ignore.invites");
/// The types of recommendations understood.
enum PolicyRecommendation {
Ban = "m.ban",
}
/**
* The various scopes for policies.
*/
export enum PolicyScope {
/**
* The policy deals with an individual user, e.g. reject invites
* from this user.
*/
User = "m.policy.user",
/**
* The policy deals with a room, e.g. reject invites towards
* a specific room.
*/
Room = "m.policy.room",
/**
* The policy deals with a server, e.g. reject invites from
* this server.
*/
Server = "m.policy.server",
}
/**
* A container for ignored invites.
*
* # Performance
*
* This implementation is extremely naive. It expects that we are dealing
* with a very short list of sources (e.g. only one). If real-world
* applications turn out to require longer lists, we may need to rework
* our data structures.
*/
export class IgnoredInvites {
constructor(
private readonly client: MatrixClient,
) {
}
/**
* Add a new rule.
*
* @param scope The scope for this rule.
* @param entity The entity covered by this rule. Globs are supported.
* @param reason A human-readable reason for introducing this new rule.
* @return The event id for the new rule.
*/
public async addRule(scope: PolicyScope, entity: string, reason: string): Promise<string> {
const target = await this.getOrCreateTargetRoom();
const response = await this.client.sendStateEvent(target.roomId, scope, {
entity,
reason,
recommendation: PolicyRecommendation.Ban,
});
return response.event_id;
}
/**
* Remove a rule.
*/
public async removeRule(event: MatrixEvent) {
await this.client.redactEvent(event.getRoomId()!, event.getId()!);
}
/**
* Add a new room to the list of sources. If the user isn't a member of the
* room, attempt to join it.
*
* @param roomId A valid room id. If this room is already in the list
* of sources, it will not be duplicated.
* @return `true` if the source was added, `false` if it was already present.
* @throws If `roomId` isn't the id of a room that the current user is already
* member of or can join.
*
* # Safety
*
* This method will rewrite the `Policies` object in the user's account data.
* This rewrite is inherently racy and could overwrite or be overwritten by
* other concurrent rewrites of the same object.
*/
public async addSource(roomId: string): Promise<boolean> {
// We attempt to join the room *before* calling
// `await this.getOrCreateSourceRooms()` to decrease the duration
// of the racy section.
await this.client.joinRoom(roomId);
// Race starts.
const sources = (await this.getOrCreateSourceRooms())
.map(room => room.roomId);
if (sources.includes(roomId)) {
return false;
}
sources.push(roomId);
await this.withIgnoreInvitesPolicies(ignoreInvitesPolicies => {
ignoreInvitesPolicies.sources = sources;
});
// Race ends.
return true;
}
/**
* Find out whether an invite should be ignored.
*
* @param sender The user id for the user who issued the invite.
* @param roomId The room to which the user is invited.
* @returns A rule matching the entity, if any was found, `null` otherwise.
*/
public async getRuleForInvite({ sender, roomId }: {
sender: string;
roomId: string;
}): Promise<Readonly<MatrixEvent | null>> {
// In this implementation, we perform a very naive lookup:
// - search in each policy room;
// - turn each (potentially glob) rule entity into a regexp.
//
// Real-world testing will tell us whether this is performant enough.
// In the (unfortunately likely) case it isn't, there are several manners
// in which we could optimize this:
// - match several entities per go;
// - pre-compile each rule entity into a regexp;
// - pre-compile entire rooms into a single regexp.
const policyRooms = await this.getOrCreateSourceRooms();
const senderServer = sender.split(":")[1];
const roomServer = roomId.split(":")[1];
for (const room of policyRooms) {
const state = room.getUnfilteredTimelineSet().getLiveTimeline().getState(EventTimeline.FORWARDS);
for (const { scope, entities } of [
{ scope: PolicyScope.Room, entities: [roomId] },
{ scope: PolicyScope.User, entities: [sender] },
{ scope: PolicyScope.Server, entities: [senderServer, roomServer] },
]) {
const events = state.getStateEvents(scope);
for (const event of events) {
const content = event.getContent();
if (content?.recommendation != PolicyRecommendation.Ban) {
// Ignoring invites only looks at `m.ban` recommendations.
continue;
}
const glob = content?.entity;
if (!glob) {
// Invalid event.
continue;
}
let regexp: RegExp;
try {
regexp = new RegExp(globToRegexp(glob, false));
} catch (ex) {
// Assume invalid event.
continue;
}
for (const entity of entities) {
if (entity && regexp.test(entity)) {
return event;
}
}
// No match.
}
}
}
return null;
}
/**
* Get the target room, i.e. the room in which any new rule should be written.
*
* If there is no target room setup, a target room is created.
*
* Note: This method is public for testing reasons. Most clients should not need
* to call it directly.
*
* # Safety
*
* This method will rewrite the `Policies` object in the user's account data.
* This rewrite is inherently racy and could overwrite or be overwritten by
* other concurrent rewrites of the same object.
*/
public async getOrCreateTargetRoom(): Promise<Room> {
const ignoreInvitesPolicies = this.getIgnoreInvitesPolicies();
let target = ignoreInvitesPolicies.target;
// Validate `target`. If it is invalid, trash out the current `target`
// and create a new room.
if (typeof target !== "string") {
target = null;
}
if (target) {
// Check that the room exists and is valid.
const room = this.client.getRoom(target);
if (room) {
return room;
} else {
target = null;
}
}
// We need to create our own policy room for ignoring invites.
target = (await this.client.createRoom({
name: "Individual Policy Room",
preset: Preset.PrivateChat,
})).room_id;
await this.withIgnoreInvitesPolicies(ignoreInvitesPolicies => {
ignoreInvitesPolicies.target = target;
});
// Since we have just called `createRoom`, `getRoom` should not be `null`.
return this.client.getRoom(target)!;
}
/**
* Get the list of source rooms, i.e. the rooms from which rules need to be read.
*
* If no source rooms are setup, the target room is used as sole source room.
*
* Note: This method is public for testing reasons. Most clients should not need
* to call it directly.
*
* # Safety
*
* This method will rewrite the `Policies` object in the user's account data.
* This rewrite is inherently racy and could overwrite or be overwritten by
* other concurrent rewrites of the same object.
*/
public async getOrCreateSourceRooms(): Promise<Room[]> {
const ignoreInvitesPolicies = this.getIgnoreInvitesPolicies();
let sources = ignoreInvitesPolicies.sources;
// Validate `sources`. If it is invalid, trash out the current `sources`
// and create a new list of sources from `target`.
let hasChanges = false;
if (!Array.isArray(sources)) {
// `sources` could not be an array.
hasChanges = true;
sources = [];
}
let sourceRooms: Room[] = sources
// `sources` could contain non-string / invalid room ids
.filter(roomId => typeof roomId === "string")
.map(roomId => this.client.getRoom(roomId))
.filter(room => !!room);
if (sourceRooms.length != sources.length) {
hasChanges = true;
}
if (sourceRooms.length == 0) {
// `sources` could be empty (possibly because we've removed
// invalid content)
const target = await this.getOrCreateTargetRoom();
hasChanges = true;
sourceRooms = [target];
}
if (hasChanges) {
// Reload `policies`/`ignoreInvitesPolicies` in case it has been changed
// during or by our call to `this.getTargetRoom()`.
await this.withIgnoreInvitesPolicies(ignoreInvitesPolicies => {
ignoreInvitesPolicies.sources = sources;
});
}
return sourceRooms;
}
/**
* Fetch the `IGNORE_INVITES_POLICIES` object from account data.
*
* If both an unstable prefix version and a stable prefix version are available,
* it will return the stable prefix version preferentially.
*
* The result is *not* validated but is guaranteed to be a non-null object.
*
* @returns A non-null object.
*/
private getIgnoreInvitesPolicies(): {[key: string]: any} {
return this.getPoliciesAndIgnoreInvitesPolicies().ignoreInvitesPolicies;
}
/**
* Modify in place the `IGNORE_INVITES_POLICIES` object from account data.
*/
private async withIgnoreInvitesPolicies(cb: (ignoreInvitesPolicies: {[key: string]: any}) => void) {
const { policies, ignoreInvitesPolicies } = this.getPoliciesAndIgnoreInvitesPolicies();
cb(ignoreInvitesPolicies);
policies[IGNORE_INVITES_ACCOUNT_EVENT_KEY.name] = ignoreInvitesPolicies;
await this.client.setAccountData(POLICIES_ACCOUNT_EVENT_TYPE.name, policies);
}
/**
* As `getIgnoreInvitesPolicies` but also return the `POLICIES_ACCOUNT_EVENT_TYPE`
* object.
*/
private getPoliciesAndIgnoreInvitesPolicies():
{policies: {[key: string]: any}, ignoreInvitesPolicies: {[key: string]: any}} {
let policies = {};
for (const key of [POLICIES_ACCOUNT_EVENT_TYPE.name, POLICIES_ACCOUNT_EVENT_TYPE.altName]) {
if (!key) {
continue;
}
const value = this.client.getAccountData(key)?.getContent();
if (value) {
policies = value;
break;
}
}
let ignoreInvitesPolicies = {};
let hasIgnoreInvitesPolicies = false;
for (const key of [IGNORE_INVITES_ACCOUNT_EVENT_KEY.name, IGNORE_INVITES_ACCOUNT_EVENT_KEY.altName]) {
if (!key) {
continue;
}
const value = policies[key];
if (value && typeof value == "object") {
ignoreInvitesPolicies = value;
hasIgnoreInvitesPolicies = true;
break;
}
}
if (!hasIgnoreInvitesPolicies) {
policies[IGNORE_INVITES_ACCOUNT_EVENT_KEY.name] = ignoreInvitesPolicies;
}
return { policies, ignoreInvitesPolicies };
}
}

View File

@@ -23,14 +23,8 @@ import { Room } from "./room";
export class RelationsContainer { export class RelationsContainer {
// A tree of objects to access a set of related children for an event, as in: // A tree of objects to access a set of related children for an event, as in:
// this.relations[parentEventId][relationType][relationEventType] // this.relations.get(parentEventId).get(relationType).get(relationEventType)
private relations: { private relations = new Map<string, Map<RelationType | string, Map<EventType | string, Relations>>>();
[parentEventId: string]: {
[relationType: RelationType | string]: {
[eventType: EventType | string]: Relations;
};
};
} = {};
constructor(private readonly client: MatrixClient, private readonly room?: Room) { constructor(private readonly client: MatrixClient, private readonly room?: Room) {
} }
@@ -57,14 +51,15 @@ export class RelationsContainer {
relationType: RelationType | string, relationType: RelationType | string,
eventType: EventType | string, eventType: EventType | string,
): Relations | undefined { ): Relations | undefined {
return this.relations[eventId]?.[relationType]?.[eventType]; return this.relations.get(eventId)?.get(relationType)?.get(eventType);
} }
public getAllChildEventsForEvent(parentEventId: string): MatrixEvent[] { public getAllChildEventsForEvent(parentEventId: string): MatrixEvent[] {
const relationsForEvent = this.relations[parentEventId] ?? {}; const relationsForEvent = this.relations.get(parentEventId)
?? new Map<RelationType | string, Map<EventType | string, Relations>>();
const events: MatrixEvent[] = []; const events: MatrixEvent[] = [];
for (const relationsRecord of Object.values(relationsForEvent)) { for (const relationsRecord of relationsForEvent.values()) {
for (const relations of Object.values(relationsRecord)) { for (const relations of relationsRecord.values()) {
events.push(...relations.getRelations()); events.push(...relations.getRelations());
} }
} }
@@ -79,11 +74,11 @@ export class RelationsContainer {
* @param {MatrixEvent} event The event to check as relation target. * @param {MatrixEvent} event The event to check as relation target.
*/ */
public aggregateParentEvent(event: MatrixEvent): void { public aggregateParentEvent(event: MatrixEvent): void {
const relationsForEvent = this.relations[event.getId()]; const relationsForEvent = this.relations.get(event.getId());
if (!relationsForEvent) return; if (!relationsForEvent) return;
for (const relationsWithRelType of Object.values(relationsForEvent)) { for (const relationsWithRelType of relationsForEvent.values()) {
for (const relationsWithEventType of Object.values(relationsWithRelType)) { for (const relationsWithEventType of relationsWithRelType.values()) {
relationsWithEventType.setTargetEvent(event); relationsWithEventType.setTargetEvent(event);
} }
} }
@@ -123,23 +118,26 @@ export class RelationsContainer {
const { event_id: relatesToEventId, rel_type: relationType } = relation; const { event_id: relatesToEventId, rel_type: relationType } = relation;
const eventType = event.getType(); const eventType = event.getType();
let relationsForEvent = this.relations[relatesToEventId]; let relationsForEvent = this.relations.get(relatesToEventId);
if (!relationsForEvent) { if (!relationsForEvent) {
relationsForEvent = this.relations[relatesToEventId] = {}; relationsForEvent = new Map<RelationType | string, Map<EventType | string, Relations>>();
this.relations.set(relatesToEventId, relationsForEvent);
} }
let relationsWithRelType = relationsForEvent[relationType]; let relationsWithRelType = relationsForEvent.get(relationType);
if (!relationsWithRelType) { if (!relationsWithRelType) {
relationsWithRelType = relationsForEvent[relationType] = {}; relationsWithRelType = new Map<EventType | string, Relations>();
relationsForEvent.set(relationType, relationsWithRelType);
} }
let relationsWithEventType = relationsWithRelType[eventType]; let relationsWithEventType = relationsWithRelType.get(eventType);
if (!relationsWithEventType) { if (!relationsWithEventType) {
relationsWithEventType = relationsWithRelType[eventType] = new Relations( relationsWithEventType = new Relations(
relationType, relationType,
eventType, eventType,
this.client, this.client,
); );
relationsWithRelType.set(eventType, relationsWithEventType);
const room = this.room ?? timelineSet?.room; const room = this.room ?? timelineSet?.room;
const relatesToEvent = timelineSet?.findEventById(relatesToEventId) const relatesToEvent = timelineSet?.findEventById(relatesToEventId)

View File

@@ -79,7 +79,7 @@ export class RoomState extends TypedEventEmitter<EmittedEvents, EventHandlerMap>
public readonly reEmitter = new TypedReEmitter<EmittedEvents, EventHandlerMap>(this); public readonly reEmitter = new TypedReEmitter<EmittedEvents, EventHandlerMap>(this);
private sentinels: Record<string, RoomMember> = {}; // userId: RoomMember private sentinels: Record<string, RoomMember> = {}; // userId: RoomMember
// stores fuzzy matches to a list of userIDs (applies utils.removeHiddenChars to keys) // stores fuzzy matches to a list of userIDs (applies utils.removeHiddenChars to keys)
private displayNameToUserIds: Record<string, string[]> = {}; private displayNameToUserIds = new Map<string, string[]>();
private userIdsToDisplayNames: Record<string, string> = {}; private userIdsToDisplayNames: Record<string, string> = {};
private tokenToInvite: Record<string, MatrixEvent> = {}; // 3pid invite state_key to m.room.member invite private tokenToInvite: Record<string, MatrixEvent> = {}; // 3pid invite state_key to m.room.member invite
private joinedMemberCount: number = null; // cache of the number of joined members private joinedMemberCount: number = null; // cache of the number of joined members
@@ -709,7 +709,7 @@ export class RoomState extends TypedEventEmitter<EmittedEvents, EventHandlerMap>
* @return {string[]} An array of user IDs or an empty array. * @return {string[]} An array of user IDs or an empty array.
*/ */
public getUserIdsWithDisplayName(displayName: string): string[] { public getUserIdsWithDisplayName(displayName: string): string[] {
return this.displayNameToUserIds[utils.removeHiddenChars(displayName)] || []; return this.displayNameToUserIds.get(utils.removeHiddenChars(displayName)) ?? [];
} }
/** /**
@@ -941,11 +941,11 @@ export class RoomState extends TypedEventEmitter<EmittedEvents, EventHandlerMap>
// the lot. // the lot.
const strippedOldName = utils.removeHiddenChars(oldName); const strippedOldName = utils.removeHiddenChars(oldName);
const existingUserIds = this.displayNameToUserIds[strippedOldName]; const existingUserIds = this.displayNameToUserIds.get(strippedOldName);
if (existingUserIds) { if (existingUserIds) {
// remove this user ID from this array // remove this user ID from this array
const filteredUserIDs = existingUserIds.filter((id) => id !== userId); const filteredUserIDs = existingUserIds.filter((id) => id !== userId);
this.displayNameToUserIds[strippedOldName] = filteredUserIDs; this.displayNameToUserIds.set(strippedOldName, filteredUserIDs);
} }
} }
@@ -954,10 +954,9 @@ export class RoomState extends TypedEventEmitter<EmittedEvents, EventHandlerMap>
const strippedDisplayname = displayName && utils.removeHiddenChars(displayName); const strippedDisplayname = displayName && utils.removeHiddenChars(displayName);
// an empty stripped displayname (undefined/'') will be set to MXID in room-member.js // an empty stripped displayname (undefined/'') will be set to MXID in room-member.js
if (strippedDisplayname) { if (strippedDisplayname) {
if (!this.displayNameToUserIds[strippedDisplayname]) { const arr = this.displayNameToUserIds.get(strippedDisplayname) ?? [];
this.displayNameToUserIds[strippedDisplayname] = []; arr.push(userId);
} this.displayNameToUserIds.set(strippedDisplayname, arr);
this.displayNameToUserIds[strippedDisplayname].push(userId);
} }
} }
} }

View File

@@ -1981,14 +1981,6 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
} }
} }
} }
if (event.getUnsigned().transaction_id) {
const existingEvent = this.txnToEvent[event.getUnsigned().transaction_id];
if (existingEvent) {
// remote echo of an event we sent earlier
this.handleRemoteEcho(event, existingEvent);
}
}
} }
/** /**
@@ -1996,7 +1988,7 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
* "Room.timeline". * "Room.timeline".
* *
* @param {MatrixEvent} event Event to be added * @param {MatrixEvent} event Event to be added
* @param {IAddLiveEventOptions} options addLiveEvent options * @param {IAddLiveEventOptions} addLiveEventOptions addLiveEvent options
* @fires module:client~MatrixClient#event:"Room.timeline" * @fires module:client~MatrixClient#event:"Room.timeline"
* @private * @private
*/ */
@@ -2344,7 +2336,7 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
fromCache = false, fromCache = false,
): void { ): void {
let duplicateStrategy = duplicateStrategyOrOpts as DuplicateStrategy; let duplicateStrategy = duplicateStrategyOrOpts as DuplicateStrategy;
let timelineWasEmpty: boolean; let timelineWasEmpty = false;
if (typeof (duplicateStrategyOrOpts) === 'object') { if (typeof (duplicateStrategyOrOpts) === 'object') {
({ ({
duplicateStrategy, duplicateStrategy,
@@ -2383,10 +2375,25 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
const threadRoots = this.findThreadRoots(events); const threadRoots = this.findThreadRoots(events);
const eventsByThread: { [threadId: string]: MatrixEvent[] } = {}; const eventsByThread: { [threadId: string]: MatrixEvent[] } = {};
const options: IAddLiveEventOptions = {
duplicateStrategy,
fromCache,
timelineWasEmpty,
};
for (const event of events) { for (const event of events) {
// TODO: We should have a filter to say "only add state event types X Y Z to the timeline". // TODO: We should have a filter to say "only add state event types X Y Z to the timeline".
this.processLiveEvent(event); this.processLiveEvent(event);
if (event.getUnsigned().transaction_id) {
const existingEvent = this.txnToEvent[event.getUnsigned().transaction_id!];
if (existingEvent) {
// remote echo of an event we sent earlier
this.handleRemoteEcho(event, existingEvent);
continue; // we can skip adding the event to the timeline sets, it is already there
}
}
const { const {
shouldLiveInRoom, shouldLiveInRoom,
shouldLiveInThread, shouldLiveInThread,
@@ -2399,11 +2406,7 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
eventsByThread[threadId]?.push(event); eventsByThread[threadId]?.push(event);
if (shouldLiveInRoom) { if (shouldLiveInRoom) {
this.addLiveEvent(event, { this.addLiveEvent(event, options);
duplicateStrategy,
fromCache,
timelineWasEmpty,
});
} }
} }

View File

@@ -296,13 +296,16 @@ export class SlidingSyncSdk {
this.processRoomData(this.client, room, roomData); this.processRoomData(this.client, room, roomData);
} }
private onLifecycle(state: SlidingSyncState, resp: MSC3575SlidingSyncResponse, err?: Error): void { private onLifecycle(state: SlidingSyncState, resp: MSC3575SlidingSyncResponse | null, err: Error | null): void {
if (err) { if (err) {
logger.debug("onLifecycle", state, err); logger.debug("onLifecycle", state, err);
} }
switch (state) { switch (state) {
case SlidingSyncState.Complete: case SlidingSyncState.Complete:
this.purgeNotifications(); this.purgeNotifications();
if (!resp) {
break;
}
// Element won't stop showing the initial loading spinner unless we fire SyncState.Prepared // Element won't stop showing the initial loading spinner unless we fire SyncState.Prepared
if (!this.lastPos) { if (!this.lastPos) {
this.updateSyncState(SyncState.Prepared, { this.updateSyncState(SyncState.Prepared, {
@@ -529,6 +532,13 @@ export class SlidingSyncSdk {
} }
} }
if (Number.isInteger(roomData.invited_count)) {
room.currentState.setInvitedMemberCount(roomData.invited_count!);
}
if (Number.isInteger(roomData.joined_count)) {
room.currentState.setJoinedMemberCount(roomData.joined_count!);
}
if (roomData.invite_state) { if (roomData.invite_state) {
const inviteStateEvents = mapEvents(this.client, room.roomId, roomData.invite_state); const inviteStateEvents = mapEvents(this.client, room.roomId, roomData.invite_state);
this.injectRoomEvents(room, inviteStateEvents); this.injectRoomEvents(room, inviteStateEvents);
@@ -609,6 +619,10 @@ export class SlidingSyncSdk {
// we deliberately don't add ephemeral events to the timeline // we deliberately don't add ephemeral events to the timeline
room.addEphemeralEvents(ephemeralEvents); room.addEphemeralEvents(ephemeralEvents);
// local fields must be set before any async calls because call site assumes
// synchronous execution prior to emitting SlidingSyncState.Complete
room.updateMyMembership("join");
room.recalculate(); room.recalculate();
if (roomData.initial) { if (roomData.initial) {
client.store.storeRoom(room); client.store.storeRoom(room);
@@ -632,8 +646,6 @@ export class SlidingSyncSdk {
client.emit(ClientEvent.Event, e); client.emit(ClientEvent.Event, e);
}); });
room.updateMyMembership("join");
// Decrypt only the last message in all rooms to make sure we can generate a preview // Decrypt only the last message in all rooms to make sure we can generate a preview
// And decrypt all events after the recorded read receipt to ensure an accurate // And decrypt all events after the recorded read receipt to ensure an accurate
// notification count // notification count

View File

@@ -47,6 +47,8 @@ export interface MSC3575Filter {
room_types?: string[]; room_types?: string[];
not_room_types?: string[]; not_room_types?: string[];
spaces?: string[]; spaces?: string[];
tags?: string[];
not_tags?: string[];
} }
/** /**
@@ -82,6 +84,8 @@ export interface MSC3575RoomData {
timeline: (IRoomEvent | IStateEvent)[]; timeline: (IRoomEvent | IStateEvent)[];
notification_count?: number; notification_count?: number;
highlight_count?: number; highlight_count?: number;
joined_count?: number;
invited_count?: number;
invite_state?: IStateEvent[]; invite_state?: IStateEvent[];
initial?: boolean; initial?: boolean;
limited?: boolean; limited?: boolean;
@@ -318,7 +322,9 @@ export enum SlidingSyncEvent {
export type SlidingSyncEventHandlerMap = { export type SlidingSyncEventHandlerMap = {
[SlidingSyncEvent.RoomData]: (roomId: string, roomData: MSC3575RoomData) => void; [SlidingSyncEvent.RoomData]: (roomId: string, roomData: MSC3575RoomData) => void;
[SlidingSyncEvent.Lifecycle]: (state: SlidingSyncState, resp: MSC3575SlidingSyncResponse, err: Error) => void; [SlidingSyncEvent.Lifecycle]: (
state: SlidingSyncState, resp: MSC3575SlidingSyncResponse | null, err: Error | null,
) => void;
[SlidingSyncEvent.List]: ( [SlidingSyncEvent.List]: (
listIndex: number, joinedCount: number, roomIndexToRoomId: Record<number, string>, listIndex: number, joinedCount: number, roomIndexToRoomId: Record<number, string>,
) => void; ) => void;
@@ -530,6 +536,65 @@ export class SlidingSync extends TypedEventEmitter<SlidingSyncEvent, SlidingSync
this.emit(SlidingSyncEvent.Lifecycle, state, resp, err); this.emit(SlidingSyncEvent.Lifecycle, state, resp, err);
} }
private shiftRight(listIndex: number, hi: number, low: number) {
// l h
// 0,1,2,3,4 <- before
// 0,1,2,2,3 <- after, hi is deleted and low is duplicated
for (let i = hi; i > low; i--) {
if (this.lists[listIndex].isIndexInRange(i)) {
this.lists[listIndex].roomIndexToRoomId[i] =
this.lists[listIndex].roomIndexToRoomId[
i - 1
];
}
}
}
private shiftLeft(listIndex: number, hi: number, low: number) {
// l h
// 0,1,2,3,4 <- before
// 0,1,3,4,4 <- after, low is deleted and hi is duplicated
for (let i = low; i < hi; i++) {
if (this.lists[listIndex].isIndexInRange(i)) {
this.lists[listIndex].roomIndexToRoomId[i] =
this.lists[listIndex].roomIndexToRoomId[
i + 1
];
}
}
}
private removeEntry(listIndex: number, index: number) {
// work out the max index
let max = -1;
for (const n in this.lists[listIndex].roomIndexToRoomId) {
if (Number(n) > max) {
max = Number(n);
}
}
if (max < 0 || index > max) {
return;
}
// Everything higher than the gap needs to be shifted left.
this.shiftLeft(listIndex, max, index);
delete this.lists[listIndex].roomIndexToRoomId[max];
}
private addEntry(listIndex: number, index: number) {
// work out the max index
let max = -1;
for (const n in this.lists[listIndex].roomIndexToRoomId) {
if (Number(n) > max) {
max = Number(n);
}
}
if (max < 0 || index > max) {
return;
}
// Everything higher than the gap needs to be shifted right, +1 so we don't delete the highest element
this.shiftRight(listIndex, max+1, index);
}
private processListOps(list: ListResponse, listIndex: number): void { private processListOps(list: ListResponse, listIndex: number): void {
let gapIndex = -1; let gapIndex = -1;
list.ops.forEach((op: Operation) => { list.ops.forEach((op: Operation) => {
@@ -537,6 +602,10 @@ export class SlidingSync extends TypedEventEmitter<SlidingSyncEvent, SlidingSync
case "DELETE": { case "DELETE": {
logger.debug("DELETE", listIndex, op.index, ";"); logger.debug("DELETE", listIndex, op.index, ";");
delete this.lists[listIndex].roomIndexToRoomId[op.index]; delete this.lists[listIndex].roomIndexToRoomId[op.index];
if (gapIndex !== -1) {
// we already have a DELETE operation to process, so process it.
this.removeEntry(listIndex, gapIndex);
}
gapIndex = op.index; gapIndex = op.index;
break; break;
} }
@@ -551,20 +620,9 @@ export class SlidingSync extends TypedEventEmitter<SlidingSyncEvent, SlidingSync
if (this.lists[listIndex].roomIndexToRoomId[op.index]) { if (this.lists[listIndex].roomIndexToRoomId[op.index]) {
// something is in this space, shift items out of the way // something is in this space, shift items out of the way
if (gapIndex < 0) { if (gapIndex < 0) {
logger.debug( // we haven't been told where to shift from, so make way for a new room entry.
"cannot work out where gap is, INSERT without previous DELETE! List: ", this.addEntry(listIndex, op.index);
listIndex, } else if (gapIndex > op.index) {
);
return;
}
// 0,1,2,3 index
// [A,B,C,D]
// DEL 3
// [A,B,C,_]
// INSERT E 0
// [E,A,B,C]
// gapIndex=3, op.index=0
if (gapIndex > op.index) {
// the gap is further down the list, shift every element to the right // the gap is further down the list, shift every element to the right
// starting at the gap so we can just shift each element in turn: // starting at the gap so we can just shift each element in turn:
// [A,B,C,_] gapIndex=3, op.index=0 // [A,B,C,_] gapIndex=3, op.index=0
@@ -572,26 +630,13 @@ export class SlidingSync extends TypedEventEmitter<SlidingSyncEvent, SlidingSync
// [A,B,B,C] i=2 // [A,B,B,C] i=2
// [A,A,B,C] i=1 // [A,A,B,C] i=1
// Terminate. We'll assign into op.index next. // Terminate. We'll assign into op.index next.
for (let i = gapIndex; i > op.index; i--) { this.shiftRight(listIndex, gapIndex, op.index);
if (this.lists[listIndex].isIndexInRange(i)) {
this.lists[listIndex].roomIndexToRoomId[i] =
this.lists[listIndex].roomIndexToRoomId[
i - 1
];
}
}
} else if (gapIndex < op.index) { } else if (gapIndex < op.index) {
// the gap is further up the list, shift every element to the left // the gap is further up the list, shift every element to the left
// starting at the gap so we can just shift each element in turn // starting at the gap so we can just shift each element in turn
for (let i = gapIndex; i < op.index; i++) { this.shiftLeft(listIndex, op.index, gapIndex);
if (this.lists[listIndex].isIndexInRange(i)) {
this.lists[listIndex].roomIndexToRoomId[i] =
this.lists[listIndex].roomIndexToRoomId[
i + 1
];
}
}
} }
gapIndex = -1; // forget the gap, we don't need it anymore.
} }
this.lists[listIndex].roomIndexToRoomId[op.index] = op.room_id; this.lists[listIndex].roomIndexToRoomId[op.index] = op.room_id;
break; break;
@@ -631,6 +676,11 @@ export class SlidingSync extends TypedEventEmitter<SlidingSyncEvent, SlidingSync
} }
} }
}); });
if (gapIndex !== -1) {
// we already have a DELETE operation to process, so process it
// Everything higher than the gap needs to be shifted left.
this.removeEntry(listIndex, gapIndex);
}
} }
/** /**

View File

@@ -23,6 +23,8 @@ limitations under the License.
* for HTTP and WS at some point. * for HTTP and WS at some point.
*/ */
import { Optional } from "matrix-events-sdk";
import { User, UserEvent } from "./models/user"; import { User, UserEvent } from "./models/user";
import { NotificationCountType, Room, RoomEvent } from "./models/room"; import { NotificationCountType, Room, RoomEvent } from "./models/room";
import * as utils from "./utils"; import * as utils from "./utils";
@@ -100,18 +102,16 @@ const MSC2716_ROOM_VERSIONS = [
function getFilterName(userId: string, suffix?: string): string { function getFilterName(userId: string, suffix?: string): string {
// scope this on the user ID because people may login on many accounts // scope this on the user ID because people may login on many accounts
// and they all need to be stored! // and they all need to be stored!
return "FILTER_SYNC_" + userId + (suffix ? "_" + suffix : ""); return `FILTER_SYNC_${userId}` + (suffix ? "_" + suffix : "");
} }
function debuglog(...params) { function debuglog(...params) {
if (!DEBUG) { if (!DEBUG) return;
return;
}
logger.log(...params); logger.log(...params);
} }
interface ISyncOptions { interface ISyncOptions {
filterId?: string; filter?: string;
hasSyncedBefore?: boolean; hasSyncedBefore?: boolean;
} }
@@ -161,14 +161,14 @@ type WrappedRoom<T> = T & {
* updating presence. * updating presence.
*/ */
export class SyncApi { export class SyncApi {
private _peekRoom: Room = null; private _peekRoom: Optional<Room> = null;
private currentSyncRequest: IAbortablePromise<ISyncResponse> = null; private currentSyncRequest: Optional<IAbortablePromise<ISyncResponse>> = null;
private syncState: SyncState = null; private syncState: Optional<SyncState> = null;
private syncStateData: ISyncStateData = null; // additional data (eg. error object for failed sync) private syncStateData: Optional<ISyncStateData> = null; // additional data (eg. error object for failed sync)
private catchingUp = false; private catchingUp = false;
private running = false; private running = false;
private keepAliveTimer: ReturnType<typeof setTimeout> = null; private keepAliveTimer: Optional<ReturnType<typeof setTimeout>> = null;
private connectionReturnedDefer: IDeferred<boolean> = null; private connectionReturnedDefer: Optional<IDeferred<boolean>> = null;
private notifEvents: MatrixEvent[] = []; // accumulator of sync events in the current sync response private notifEvents: MatrixEvent[] = []; // accumulator of sync events in the current sync response
private failedSyncCount = 0; // Number of consecutive failed /sync requests private failedSyncCount = 0; // Number of consecutive failed /sync requests
private storeIsInvalid = false; // flag set if the store needs to be cleared before we can start private storeIsInvalid = false; // flag set if the store needs to be cleared before we can start
@@ -214,7 +214,7 @@ export class SyncApi {
* historical messages are shown when we paginate `/messages` again. * historical messages are shown when we paginate `/messages` again.
* @param {Room} room The room where the marker event was sent * @param {Room} room The room where the marker event was sent
* @param {MatrixEvent} markerEvent The new marker event * @param {MatrixEvent} markerEvent The new marker event
* @param {ISetStateOptions} setStateOptions When `timelineWasEmpty` is set * @param {IMarkerFoundOptions} setStateOptions When `timelineWasEmpty` is set
* as `true`, the given marker event will be ignored * as `true`, the given marker event will be ignored
*/ */
private onMarkerStateEvent( private onMarkerStateEvent(
@@ -367,7 +367,7 @@ export class SyncApi {
// XXX: copypasted from /sync until we kill off this minging v1 API stuff) // XXX: copypasted from /sync until we kill off this minging v1 API stuff)
// handle presence events (User objects) // handle presence events (User objects)
if (response.presence && Array.isArray(response.presence)) { if (Array.isArray(response.presence)) {
response.presence.map(client.getEventMapper()).forEach( response.presence.map(client.getEventMapper()).forEach(
function(presenceEvent) { function(presenceEvent) {
let user = client.store.getUser(presenceEvent.getContent().user_id); let user = client.store.getUser(presenceEvent.getContent().user_id);
@@ -542,20 +542,135 @@ export class SyncApi {
return false; return false;
} }
private getPushRules = async () => {
try {
debuglog("Getting push rules...");
const result = await this.client.getPushRules();
debuglog("Got push rules");
this.client.pushRules = result;
} catch (err) {
logger.error("Getting push rules failed", err);
if (this.shouldAbortSync(err)) return;
// wait for saved sync to complete before doing anything else,
// otherwise the sync state will end up being incorrect
debuglog("Waiting for saved sync before retrying push rules...");
await this.recoverFromSyncStartupError(this.savedSyncPromise, err);
return this.getPushRules(); // try again
}
};
private buildDefaultFilter = () => {
return new Filter(this.client.credentials.userId);
};
private checkLazyLoadStatus = async () => {
debuglog("Checking lazy load status...");
if (this.opts.lazyLoadMembers && this.client.isGuest()) {
this.opts.lazyLoadMembers = false;
}
if (this.opts.lazyLoadMembers) {
debuglog("Checking server lazy load support...");
const supported = await this.client.doesServerSupportLazyLoading();
if (supported) {
debuglog("Enabling lazy load on sync filter...");
if (!this.opts.filter) {
this.opts.filter = this.buildDefaultFilter();
}
this.opts.filter.setLazyLoadMembers(true);
} else {
debuglog("LL: lazy loading requested but not supported " +
"by server, so disabling");
this.opts.lazyLoadMembers = false;
}
}
// need to vape the store when enabling LL and wasn't enabled before
debuglog("Checking whether lazy loading has changed in store...");
const shouldClear = await this.wasLazyLoadingToggled(this.opts.lazyLoadMembers);
if (shouldClear) {
this.storeIsInvalid = true;
const reason = InvalidStoreError.TOGGLED_LAZY_LOADING;
const error = new InvalidStoreError(reason, !!this.opts.lazyLoadMembers);
this.updateSyncState(SyncState.Error, { error });
// bail out of the sync loop now: the app needs to respond to this error.
// we leave the state as 'ERROR' which isn't great since this normally means
// we're retrying. The client must be stopped before clearing the stores anyway
// so the app should stop the client, clear the store and start it again.
logger.warn("InvalidStoreError: store is not usable: stopping sync.");
return;
}
if (this.opts.lazyLoadMembers) {
this.opts.crypto?.enableLazyLoading();
}
try {
debuglog("Storing client options...");
await this.client.storeClientOptions();
debuglog("Stored client options");
} catch (err) {
logger.error("Storing client options failed", err);
throw err;
}
};
private getFilter = async (): Promise<{
filterId?: string;
filter?: Filter;
}> => {
debuglog("Getting filter...");
let filter: Filter;
if (this.opts.filter) {
filter = this.opts.filter;
} else {
filter = this.buildDefaultFilter();
}
let filterId: string;
try {
filterId = await this.client.getOrCreateFilter(getFilterName(this.client.credentials.userId), filter);
} catch (err) {
logger.error("Getting filter failed", err);
if (this.shouldAbortSync(err)) return {};
// wait for saved sync to complete before doing anything else,
// otherwise the sync state will end up being incorrect
debuglog("Waiting for saved sync before retrying filter...");
await this.recoverFromSyncStartupError(this.savedSyncPromise, err);
return this.getFilter(); // try again
}
return { filter, filterId };
};
private savedSyncPromise: Promise<void>;
/** /**
* Main entry point * Main entry point
*/ */
public sync(): void { public async sync(): Promise<void> {
const client = this.client;
this.running = true; this.running = true;
if (global.window && global.window.addEventListener) { global.window?.addEventListener?.("online", this.onOnline, false);
global.window.addEventListener("online", this.onOnline, false);
if (this.client.isGuest()) {
// no push rules for guests, no access to POST filter for guests.
return this.doSync({});
} }
let savedSyncPromise = Promise.resolve(); // Pull the saved sync token out first, before the worker starts sending
let savedSyncToken = null; // all the sync data which could take a while. This will let us send our
// first incremental sync request before we've processed our saved data.
debuglog("Getting saved sync token...");
const savedSyncTokenPromise = this.client.store.getSavedSyncToken().then(tok => {
debuglog("Got saved sync token");
return tok;
});
this.savedSyncPromise = this.client.store.getSavedSync().then((savedSync) => {
debuglog(`Got reply from saved sync, exists? ${!!savedSync}`);
if (savedSync) {
return this.syncFromCache(savedSync);
}
}).catch(err => {
logger.error("Getting saved sync failed", err);
});
// We need to do one-off checks before we can begin the /sync loop. // We need to do one-off checks before we can begin the /sync loop.
// These are: // These are:
@@ -565,149 +680,45 @@ export class SyncApi {
// 3) We need to check the lazy loading option matches what was used in the // 3) We need to check the lazy loading option matches what was used in the
// stored sync. If it doesn't, we can't use the stored sync. // stored sync. If it doesn't, we can't use the stored sync.
const getPushRules = async () => { // Now start the first incremental sync request: this can also
try { // take a while so if we set it going now, we can wait for it
debuglog("Getting push rules..."); // to finish while we process our saved sync data.
const result = await client.getPushRules(); await this.getPushRules();
debuglog("Got push rules"); await this.checkLazyLoadStatus();
const { filterId, filter } = await this.getFilter();
if (!filter) return; // bail, getFilter failed
client.pushRules = result; // reset the notifications timeline to prepare it to paginate from
} catch (err) { // the current point in time.
logger.error("Getting push rules failed", err); // The right solution would be to tie /sync pagination tokens into
if (this.shouldAbortSync(err)) return; // /notifications API somehow.
// wait for saved sync to complete before doing anything else, this.client.resetNotifTimelineSet();
// otherwise the sync state will end up being incorrect
debuglog("Waiting for saved sync before retrying push rules...");
await this.recoverFromSyncStartupError(savedSyncPromise, err);
getPushRules();
return;
}
checkLazyLoadStatus(); // advance to the next stage
};
const buildDefaultFilter = () => { if (this.currentSyncRequest === null) {
const filter = new Filter(client.credentials.userId); let firstSyncFilter = filterId;
filter.setTimelineLimit(this.opts.initialSyncLimit); const savedSyncToken = await savedSyncTokenPromise;
return filter;
};
const checkLazyLoadStatus = async () => { if (savedSyncToken) {
debuglog("Checking lazy load status...");
if (this.opts.lazyLoadMembers && client.isGuest()) {
this.opts.lazyLoadMembers = false;
}
if (this.opts.lazyLoadMembers) {
debuglog("Checking server lazy load support...");
const supported = await client.doesServerSupportLazyLoading();
if (supported) {
debuglog("Enabling lazy load on sync filter...");
if (!this.opts.filter) {
this.opts.filter = buildDefaultFilter();
}
this.opts.filter.setLazyLoadMembers(true);
} else {
debuglog("LL: lazy loading requested but not supported " +
"by server, so disabling");
this.opts.lazyLoadMembers = false;
}
}
// need to vape the store when enabling LL and wasn't enabled before
debuglog("Checking whether lazy loading has changed in store...");
const shouldClear = await this.wasLazyLoadingToggled(this.opts.lazyLoadMembers);
if (shouldClear) {
this.storeIsInvalid = true;
const reason = InvalidStoreError.TOGGLED_LAZY_LOADING;
const error = new InvalidStoreError(reason, !!this.opts.lazyLoadMembers);
this.updateSyncState(SyncState.Error, { error });
// bail out of the sync loop now: the app needs to respond to this error.
// we leave the state as 'ERROR' which isn't great since this normally means
// we're retrying. The client must be stopped before clearing the stores anyway
// so the app should stop the client, clear the store and start it again.
logger.warn("InvalidStoreError: store is not usable: stopping sync.");
return;
}
if (this.opts.lazyLoadMembers && this.opts.crypto) {
this.opts.crypto.enableLazyLoading();
}
try {
debuglog("Storing client options...");
await this.client.storeClientOptions();
debuglog("Stored client options");
} catch (err) {
logger.error("Storing client options failed", err);
throw err;
}
getFilter(); // Now get the filter and start syncing
};
const getFilter = async () => {
debuglog("Getting filter...");
let filter;
if (this.opts.filter) {
filter = this.opts.filter;
} else {
filter = buildDefaultFilter();
}
let filterId;
try {
filterId = await client.getOrCreateFilter(getFilterName(client.credentials.userId), filter);
} catch (err) {
logger.error("Getting filter failed", err);
if (this.shouldAbortSync(err)) return;
// wait for saved sync to complete before doing anything else,
// otherwise the sync state will end up being incorrect
debuglog("Waiting for saved sync before retrying filter...");
await this.recoverFromSyncStartupError(savedSyncPromise, err);
getFilter();
return;
}
// reset the notifications timeline to prepare it to paginate from
// the current point in time.
// The right solution would be to tie /sync pagination tokens into
// /notifications API somehow.
client.resetNotifTimelineSet();
if (this.currentSyncRequest === null) {
// Send this first sync request here so we can then wait for the saved
// sync data to finish processing before we process the results of this one.
debuglog("Sending first sync request..."); debuglog("Sending first sync request...");
this.currentSyncRequest = this.doSyncRequest({ filterId }, savedSyncToken); } else {
debuglog("Sending initial sync request...");
const initialFilter = this.buildDefaultFilter();
initialFilter.setDefinition(filter.getDefinition());
initialFilter.setTimelineLimit(this.opts.initialSyncLimit);
// Use an inline filter, no point uploading it for a single usage
firstSyncFilter = JSON.stringify(initialFilter.getDefinition());
} }
// Now wait for the saved sync to finish... // Send this first sync request here so we can then wait for the saved
debuglog("Waiting for saved sync before starting sync processing..."); // sync data to finish processing before we process the results of this one.
await savedSyncPromise; this.currentSyncRequest = this.doSyncRequest({ filter: firstSyncFilter }, savedSyncToken);
this.doSync({ filterId });
};
if (client.isGuest()) {
// no push rules for guests, no access to POST filter for guests.
this.doSync({});
} else {
// Pull the saved sync token out first, before the worker starts sending
// all the sync data which could take a while. This will let us send our
// first incremental sync request before we've processed our saved data.
debuglog("Getting saved sync token...");
savedSyncPromise = client.store.getSavedSyncToken().then((tok) => {
debuglog("Got saved sync token");
savedSyncToken = tok;
debuglog("Getting saved sync...");
return client.store.getSavedSync();
}).then((savedSync) => {
debuglog(`Got reply from saved sync, exists? ${!!savedSync}`);
if (savedSync) {
return this.syncFromCache(savedSync);
}
}).catch(err => {
logger.error("Getting saved sync failed", err);
});
// Now start the first incremental sync request: this can also
// take a while so if we set it going now, we can wait for it
// to finish while we process our saved sync data.
getPushRules();
} }
// Now wait for the saved sync to finish...
debuglog("Waiting for saved sync before starting sync processing...");
await this.savedSyncPromise;
// process the first sync request and continue syncing with the normal filterId
return this.doSync({ filter: filterId });
} }
/** /**
@@ -719,9 +730,7 @@ export class SyncApi {
// global.window AND global.window.removeEventListener. // global.window AND global.window.removeEventListener.
// Some platforms (e.g. React Native) register global.window, // Some platforms (e.g. React Native) register global.window,
// but do not have global.window.removeEventListener. // but do not have global.window.removeEventListener.
if (global.window && global.window.removeEventListener) { global.window?.removeEventListener?.("online", this.onOnline, false);
global.window.removeEventListener("online", this.onOnline, false);
}
this.running = false; this.running = false;
this.currentSyncRequest?.abort(); this.currentSyncRequest?.abort();
if (this.keepAliveTimer) { if (this.keepAliveTimer) {
@@ -756,8 +765,7 @@ export class SyncApi {
this.client.store.setSyncToken(nextSyncToken); this.client.store.setSyncToken(nextSyncToken);
// No previous sync, set old token to null // No previous sync, set old token to null
const syncEventData = { const syncEventData: ISyncStateData = {
oldSyncToken: null,
nextSyncToken, nextSyncToken,
catchingUp: false, catchingUp: false,
fromCache: true, fromCache: true,
@@ -792,7 +800,91 @@ export class SyncApi {
* @param {boolean} syncOptions.hasSyncedBefore * @param {boolean} syncOptions.hasSyncedBefore
*/ */
private async doSync(syncOptions: ISyncOptions): Promise<void> { private async doSync(syncOptions: ISyncOptions): Promise<void> {
const client = this.client; while (this.running) {
const syncToken = this.client.store.getSyncToken();
let data: ISyncResponse;
try {
//debuglog('Starting sync since=' + syncToken);
if (this.currentSyncRequest === null) {
this.currentSyncRequest = this.doSyncRequest(syncOptions, syncToken);
}
data = await this.currentSyncRequest;
} catch (e) {
const abort = await this.onSyncError(e);
if (abort) return;
continue;
} finally {
this.currentSyncRequest = null;
}
//debuglog('Completed sync, next_batch=' + data.next_batch);
// set the sync token NOW *before* processing the events. We do this so
// if something barfs on an event we can skip it rather than constantly
// polling with the same token.
this.client.store.setSyncToken(data.next_batch);
// Reset after a successful sync
this.failedSyncCount = 0;
await this.client.store.setSyncData(data);
const syncEventData = {
oldSyncToken: syncToken,
nextSyncToken: data.next_batch,
catchingUp: this.catchingUp,
};
if (this.opts.crypto) {
// tell the crypto module we're about to process a sync
// response
await this.opts.crypto.onSyncWillProcess(syncEventData);
}
try {
await this.processSyncResponse(syncEventData, data);
} catch (e) {
// log the exception with stack if we have it, else fall back
// to the plain description
logger.error("Caught /sync error", e);
// Emit the exception for client handling
this.client.emit(ClientEvent.SyncUnexpectedError, e);
}
// update this as it may have changed
syncEventData.catchingUp = this.catchingUp;
// emit synced events
if (!syncOptions.hasSyncedBefore) {
this.updateSyncState(SyncState.Prepared, syncEventData);
syncOptions.hasSyncedBefore = true;
}
// tell the crypto module to do its processing. It may block (to do a
// /keys/changes request).
if (this.opts.crypto) {
await this.opts.crypto.onSyncCompleted(syncEventData);
}
// keep emitting SYNCING -> SYNCING for clients who want to do bulk updates
this.updateSyncState(SyncState.Syncing, syncEventData);
if (this.client.store.wantsSave()) {
// We always save the device list (if it's dirty) before saving the sync data:
// this means we know the saved device list data is at least as fresh as the
// stored sync data which means we don't have to worry that we may have missed
// device changes. We can also skip the delay since we're not calling this very
// frequently (and we don't really want to delay the sync for it).
if (this.opts.crypto) {
await this.opts.crypto.saveDeviceList(0);
}
// tell databases that everything is now in a consistent state and can be saved.
this.client.store.save();
}
}
if (!this.running) { if (!this.running) {
debuglog("Sync no longer running: exiting."); debuglog("Sync no longer running: exiting.");
@@ -801,94 +893,7 @@ export class SyncApi {
this.connectionReturnedDefer = null; this.connectionReturnedDefer = null;
} }
this.updateSyncState(SyncState.Stopped); this.updateSyncState(SyncState.Stopped);
return;
} }
const syncToken = client.store.getSyncToken();
let data;
try {
//debuglog('Starting sync since=' + syncToken);
if (this.currentSyncRequest === null) {
this.currentSyncRequest = this.doSyncRequest(syncOptions, syncToken);
}
data = await this.currentSyncRequest;
} catch (e) {
this.onSyncError(e, syncOptions);
return;
} finally {
this.currentSyncRequest = null;
}
//debuglog('Completed sync, next_batch=' + data.next_batch);
// set the sync token NOW *before* processing the events. We do this so
// if something barfs on an event we can skip it rather than constantly
// polling with the same token.
client.store.setSyncToken(data.next_batch);
// Reset after a successful sync
this.failedSyncCount = 0;
await client.store.setSyncData(data);
const syncEventData = {
oldSyncToken: syncToken,
nextSyncToken: data.next_batch,
catchingUp: this.catchingUp,
};
if (this.opts.crypto) {
// tell the crypto module we're about to process a sync
// response
await this.opts.crypto.onSyncWillProcess(syncEventData);
}
try {
await this.processSyncResponse(syncEventData, data);
} catch (e) {
// log the exception with stack if we have it, else fall back
// to the plain description
logger.error("Caught /sync error", e);
// Emit the exception for client handling
this.client.emit(ClientEvent.SyncUnexpectedError, e);
}
// update this as it may have changed
syncEventData.catchingUp = this.catchingUp;
// emit synced events
if (!syncOptions.hasSyncedBefore) {
this.updateSyncState(SyncState.Prepared, syncEventData);
syncOptions.hasSyncedBefore = true;
}
// tell the crypto module to do its processing. It may block (to do a
// /keys/changes request).
if (this.opts.crypto) {
await this.opts.crypto.onSyncCompleted(syncEventData);
}
// keep emitting SYNCING -> SYNCING for clients who want to do bulk updates
this.updateSyncState(SyncState.Syncing, syncEventData);
if (client.store.wantsSave()) {
// We always save the device list (if it's dirty) before saving the sync data:
// this means we know the saved device list data is at least as fresh as the
// stored sync data which means we don't have to worry that we may have missed
// device changes. We can also skip the delay since we're not calling this very
// frequently (and we don't really want to delay the sync for it).
if (this.opts.crypto) {
await this.opts.crypto.saveDeviceList(0);
}
// tell databases that everything is now in a consistent state and can be saved.
client.store.save();
}
// Begin next sync
this.doSync(syncOptions);
} }
private doSyncRequest(syncOptions: ISyncOptions, syncToken: string): IAbortablePromise<ISyncResponse> { private doSyncRequest(syncOptions: ISyncOptions, syncToken: string): IAbortablePromise<ISyncResponse> {
@@ -902,7 +907,7 @@ export class SyncApi {
private getSyncParams(syncOptions: ISyncOptions, syncToken: string): ISyncParams { private getSyncParams(syncOptions: ISyncOptions, syncToken: string): ISyncParams {
let pollTimeout = this.opts.pollTimeout; let pollTimeout = this.opts.pollTimeout;
if (this.getSyncState() !== 'SYNCING' || this.catchingUp) { if (this.getSyncState() !== SyncState.Syncing || this.catchingUp) {
// unless we are happily syncing already, we want the server to return // unless we are happily syncing already, we want the server to return
// as quickly as possible, even if there are no events queued. This // as quickly as possible, even if there are no events queued. This
// serves two purposes: // serves two purposes:
@@ -918,13 +923,13 @@ export class SyncApi {
pollTimeout = 0; pollTimeout = 0;
} }
let filterId = syncOptions.filterId; let filter = syncOptions.filter;
if (this.client.isGuest() && !filterId) { if (this.client.isGuest() && !filter) {
filterId = this.getGuestFilter(); filter = this.getGuestFilter();
} }
const qps: ISyncParams = { const qps: ISyncParams = {
filter: filterId, filter,
timeout: pollTimeout, timeout: pollTimeout,
}; };
@@ -941,7 +946,7 @@ export class SyncApi {
qps._cacheBuster = Date.now(); qps._cacheBuster = Date.now();
} }
if (this.getSyncState() == 'ERROR' || this.getSyncState() == 'RECONNECTING') { if ([SyncState.Reconnecting, SyncState.Error].includes(this.getSyncState())) {
// we think the connection is dead. If it comes back up, we won't know // we think the connection is dead. If it comes back up, we won't know
// about it till /sync returns. If the timeout= is high, this could // about it till /sync returns. If the timeout= is high, this could
// be a long time. Set it to 0 when doing retries so we don't have to wait // be a long time. Set it to 0 when doing retries so we don't have to wait
@@ -952,7 +957,7 @@ export class SyncApi {
return qps; return qps;
} }
private onSyncError(err: MatrixError, syncOptions: ISyncOptions): void { private async onSyncError(err: MatrixError): Promise<boolean> {
if (!this.running) { if (!this.running) {
debuglog("Sync no longer running: exiting"); debuglog("Sync no longer running: exiting");
if (this.connectionReturnedDefer) { if (this.connectionReturnedDefer) {
@@ -960,14 +965,13 @@ export class SyncApi {
this.connectionReturnedDefer = null; this.connectionReturnedDefer = null;
} }
this.updateSyncState(SyncState.Stopped); this.updateSyncState(SyncState.Stopped);
return; return true; // abort
} }
logger.error("/sync error %s", err); logger.error("/sync error %s", err);
logger.error(err);
if (this.shouldAbortSync(err)) { if (this.shouldAbortSync(err)) {
return; return true; // abort
} }
this.failedSyncCount++; this.failedSyncCount++;
@@ -981,20 +985,7 @@ export class SyncApi {
// erroneous. We set the state to 'reconnecting' // erroneous. We set the state to 'reconnecting'
// instead, so that clients can observe this state // instead, so that clients can observe this state
// if they wish. // if they wish.
this.startKeepAlives().then((connDidFail) => { const keepAlivePromise = this.startKeepAlives();
// Only emit CATCHUP if we detected a connectivity error: if we didn't,
// it's quite likely the sync will fail again for the same reason and we
// want to stay in ERROR rather than keep flip-flopping between ERROR
// and CATCHUP.
if (connDidFail && this.getSyncState() === SyncState.Error) {
this.updateSyncState(SyncState.Catchup, {
oldSyncToken: null,
nextSyncToken: null,
catchingUp: true,
});
}
this.doSync(syncOptions);
});
this.currentSyncRequest = null; this.currentSyncRequest = null;
// Transition from RECONNECTING to ERROR after a given number of failed syncs // Transition from RECONNECTING to ERROR after a given number of failed syncs
@@ -1003,6 +994,19 @@ export class SyncApi {
SyncState.Error : SyncState.Reconnecting, SyncState.Error : SyncState.Reconnecting,
{ error: err }, { error: err },
); );
const connDidFail = await keepAlivePromise;
// Only emit CATCHUP if we detected a connectivity error: if we didn't,
// it's quite likely the sync will fail again for the same reason and we
// want to stay in ERROR rather than keep flip-flopping between ERROR
// and CATCHUP.
if (connDidFail && this.getSyncState() === SyncState.Error) {
this.updateSyncState(SyncState.Catchup, {
catchingUp: true,
});
}
return false;
} }
/** /**
@@ -1061,7 +1065,7 @@ export class SyncApi {
// - The isBrandNewRoom boilerplate is boilerplatey. // - The isBrandNewRoom boilerplate is boilerplatey.
// handle presence events (User objects) // handle presence events (User objects)
if (data.presence && Array.isArray(data.presence.events)) { if (Array.isArray(data.presence?.events)) {
data.presence.events.map(client.getEventMapper()).forEach( data.presence.events.map(client.getEventMapper()).forEach(
function(presenceEvent) { function(presenceEvent) {
let user = client.store.getUser(presenceEvent.getSender()); let user = client.store.getUser(presenceEvent.getSender());
@@ -1077,7 +1081,7 @@ export class SyncApi {
} }
// handle non-room account_data // handle non-room account_data
if (data.account_data && Array.isArray(data.account_data.events)) { if (Array.isArray(data.account_data?.events)) {
const events = data.account_data.events.map(client.getEventMapper()); const events = data.account_data.events.map(client.getEventMapper());
const prevEventsMap = events.reduce((m, c) => { const prevEventsMap = events.reduce((m, c) => {
m[c.getId()] = client.store.getAccountData(c.getType()); m[c.getId()] = client.store.getAccountData(c.getType());
@@ -1218,8 +1222,7 @@ export class SyncApi {
// bother setting it here. We trust our calculations better than the // bother setting it here. We trust our calculations better than the
// server's for this case, and therefore will assume that our non-zero // server's for this case, and therefore will assume that our non-zero
// count is accurate. // count is accurate.
if (!encrypted if (!encrypted || room.getUnreadNotificationCount(NotificationCountType.Highlight) <= 0) {
|| (encrypted && room.getUnreadNotificationCount(NotificationCountType.Highlight) <= 0)) {
room.setUnreadNotificationCount( room.setUnreadNotificationCount(
NotificationCountType.Highlight, NotificationCountType.Highlight,
joinObj.unread_notifications.highlight_count, joinObj.unread_notifications.highlight_count,
@@ -1232,8 +1235,7 @@ export class SyncApi {
if (joinObj.isBrandNewRoom) { if (joinObj.isBrandNewRoom) {
// set the back-pagination token. Do this *before* adding any // set the back-pagination token. Do this *before* adding any
// events so that clients can start back-paginating. // events so that clients can start back-paginating.
room.getLiveTimeline().setPaginationToken( room.getLiveTimeline().setPaginationToken(joinObj.timeline.prev_batch, EventTimeline.BACKWARDS);
joinObj.timeline.prev_batch, EventTimeline.BACKWARDS);
} else if (joinObj.timeline.limited) { } else if (joinObj.timeline.limited) {
let limited = true; let limited = true;

View File

@@ -29,6 +29,30 @@ import { MatrixClient } from ".";
import { M_TIMESTAMP } from "./@types/location"; import { M_TIMESTAMP } from "./@types/location";
import { ReceiptType } from "./@types/read_receipts"; import { ReceiptType } from "./@types/read_receipts";
const interns = new Map<string, string>();
/**
* Internalises a string, reusing a known pointer or storing the pointer
* if needed for future strings.
* @param str The string to internalise.
* @returns The internalised string.
*/
export function internaliseString(str: string): string {
// Unwrap strings before entering the map, if we somehow got a wrapped
// string as our input. This should only happen from tests.
if ((str as unknown) instanceof String) {
str = str.toString();
}
// Check the map to see if we can store the value
if (!interns.has(str)) {
interns.set(str, str);
}
// Return any cached string reference
return interns.get(str);
}
/** /**
* Encode a dictionary of query parameters. * Encode a dictionary of query parameters.
* Omits any undefined/null values. * Omits any undefined/null values.
@@ -75,8 +99,7 @@ export function decodeParams(query: string): QueryDict {
* variables with. E.g. { "$bar": "baz" }. * variables with. E.g. { "$bar": "baz" }.
* @return {string} The result of replacing all template variables e.g. '/foo/baz'. * @return {string} The result of replacing all template variables e.g. '/foo/baz'.
*/ */
export function encodeUri(pathTemplate: string, export function encodeUri(pathTemplate: string, variables: Record<string, string>): string {
variables: Record<string, string>): string {
for (const key in variables) { for (const key in variables) {
if (!variables.hasOwnProperty(key)) { if (!variables.hasOwnProperty(key)) {
continue; continue;
@@ -216,33 +239,24 @@ export function deepCompare(x: any, y: any): boolean {
} }
} }
} else { } else {
// disable jshint "The body of a for in should be wrapped in an if
// statement"
/* jshint -W089 */
// check that all of y's direct keys are in x // check that all of y's direct keys are in x
let p; for (const p in y) {
for (p in y) {
if (y.hasOwnProperty(p) !== x.hasOwnProperty(p)) { if (y.hasOwnProperty(p) !== x.hasOwnProperty(p)) {
return false; return false;
} }
} }
// finally, compare each of x's keys with y // finally, compare each of x's keys with y
for (p in y) { // eslint-disable-line guard-for-in for (const p in x) {
if (y.hasOwnProperty(p) !== x.hasOwnProperty(p)) { if (y.hasOwnProperty(p) !== x.hasOwnProperty(p) || !deepCompare(x[p], y[p])) {
return false;
}
if (!deepCompare(x[p], y[p])) {
return false; return false;
} }
} }
} }
/* jshint +W089 */
return true; return true;
} }
// Dev note: This returns a tuple, but jsdoc doesn't like that. https://github.com/jsdoc/jsdoc/issues/1703 // Dev note: This returns an array of tuples, but jsdoc doesn't like that. https://github.com/jsdoc/jsdoc/issues/1703
/** /**
* Creates an array of object properties/values (entries) then * Creates an array of object properties/values (entries) then
* sorts the result by key, recursively. The input object must * sorts the result by key, recursively. The input object must
@@ -328,7 +342,7 @@ export function escapeRegExp(string: string): string {
return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
} }
export function globToRegexp(glob: string, extended?: any): string { export function globToRegexp(glob: string, extended = false): string {
// From // From
// https://github.com/matrix-org/synapse/blob/abbee6b29be80a77e05730707602f3bbfc3f38cb/synapse/push/__init__.py#L132 // https://github.com/matrix-org/synapse/blob/abbee6b29be80a77e05730707602f3bbfc3f38cb/synapse/push/__init__.py#L132
// Because micromatch is about 130KB with dependencies, // Because micromatch is about 130KB with dependencies,
@@ -336,7 +350,7 @@ export function globToRegexp(glob: string, extended?: any): string {
const replacements: ([RegExp, string | ((substring: string, ...args: any[]) => string) ])[] = [ const replacements: ([RegExp, string | ((substring: string, ...args: any[]) => string) ])[] = [
[/\\\*/g, '.*'], [/\\\*/g, '.*'],
[/\?/g, '.'], [/\?/g, '.'],
extended !== false && [ !extended && [
/\\\[(!|)(.*)\\]/g, /\\\[(!|)(.*)\\]/g,
(_match: string, neg: string, pat: string) => [ (_match: string, neg: string, pat: string) => [
'[', '[',

View File

@@ -12,6 +12,6 @@
}, },
"include": [ "include": [
"./src/**/*.ts", "./src/**/*.ts",
"./spec/**/*.ts", "./spec/**/*.ts"
] ]
} }

1277
yarn.lock

File diff suppressed because it is too large Load Diff