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:
- uses: tibdex/backport@v2
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
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
run: |
ls -lah
tag="${{ github.ref_name }}"
version="${tag#v}"
echo "VERSION=$version" >> $GITHUB_ENV
@@ -51,3 +50,9 @@ jobs:
github_token: ${{ secrets.GITHUB_TOKEN }}
keep_files: true
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
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)
==================================================================================================

View File

@@ -1,284 +1,5 @@
Contributing code to matrix-js-sdk
==================================
Everyone is welcome to contribute code to matrix-js-sdk, provided that they are
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)).
matrix-js-sdk follows the same pattern as https://github.com/vector-im/element-web/blob/develop/CONTRIBUTING.md
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",
"version": "19.3.0",
"version": "19.4.0",
"description": "Matrix Client-Server SDK for Javascript",
"engines": {
"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",
"@types/bs58": "^4.0.1",
"@types/content-type": "^1.1.5",
"@types/jest": "^28.0.0",
"@types/jest": "^29.0.0",
"@types/node": "16",
"@types/request": "^2.48.5",
"@typescript-eslint/eslint-plugin": "^5.6.0",
"@typescript-eslint/parser": "^5.6.0",
"allchange": "^1.0.6",
"babel-jest": "^28.0.0",
"babel-jest": "^29.0.0",
"babelify": "^10.0.0",
"better-docs": "^2.4.0-beta.9",
"browserify": "^17.0.0",
"docdash": "^1.2.0",
"eslint": "8.20.0",
"eslint": "8.23.0",
"eslint-config-google": "^0.14.0",
"eslint-plugin-import": "^2.25.4",
"eslint-plugin-matrix-org": "^0.5.0",
"eslint-plugin-matrix-org": "^0.6.0",
"exorcist": "^2.0.0",
"fake-indexeddb": "^4.0.0",
"jest": "^28.0.0",
"jest-environment-jsdom": "^28.1.3",
"jest": "^29.0.0",
"jest-localstorage-mock": "^2.4.6",
"jest-sonar-reporter": "^2.0.0",
"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.
#
# 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/)
# 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/)
#
# 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
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_MINOR=${BASH_REMATCH[2]}
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"
exit
fi
npm --version > /dev/null || (echo "npm is required: please install it"; kill $$)
yarn --version > /dev/null || (echo "yarn is required: please install it"; kill $$)
USAGE="$0 [-x] [-c changelog_file] vX.Y.Z"
@@ -37,7 +33,6 @@ $USAGE
-c changelog_file: specify name of file containing changelog
-x: skip updating the changelog
-n: skip publish to NPM
EOF
}
@@ -59,10 +54,8 @@ if ! git diff-files --quiet; then
fi
skip_changelog=
skip_npm=
changelog_file="CHANGELOG.md"
expected_npm_user="matrixdotorg"
while getopts hc:u:xzn f; do
while getopts hc:x f; do
case $f in
h)
help
@@ -74,21 +67,58 @@ while getopts hc:u:xzn f; do
x)
skip_changelog=1
;;
n)
skip_npm=1
;;
u)
expected_npm_user="$OPTARG"
;;
esac
done
shift `expr $OPTIND - 1`
shift $(expr $OPTIND - 1)
if [ $# -ne 1 ]; then
echo "Usage: $USAGE" >&2
exit 1
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
# to have a hard time getting that right. See also
# 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
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
release="${1#v}"
tag="v${release}"
@@ -117,7 +137,7 @@ prerelease=0
# see if the version has a hyphen in it. Crude,
# but semver doesn't support postreleases so anything
# with a hyphen is a prerelease.
echo $release | grep -q '-' && prerelease=1
echo "$release" | grep -q '-' && prerelease=1
if [ $prerelease -eq 1 ]; then
echo Making a PRE-RELEASE
@@ -143,13 +163,13 @@ if [ -z "$skip_changelog" ]; then
yarn run allchange "$release"
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"
git commit "$changelog_file" -m "Prepare changelog for $tag"
fi
fi
latest_changes=`mktemp`
cat "${changelog_file}" | `dirname $0`/scripts/changelog_head.py > "${latest_changes}"
latest_changes=$(mktemp)
cat "${changelog_file}" | "$(dirname "$0")/scripts/changelog_head.py" > "${latest_changes}"
set -x
@@ -176,19 +196,19 @@ do
done
# 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
pkglock='yarn.lock'
else
pkglock=''
fi
git commit package.json $pkglock -m "$tag"
git commit package.json "$pkglock" -m "$tag"
# figure out if we should be signing this release
signing_id=
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
signing_id=$result
fi
@@ -206,8 +226,8 @@ assets=''
dodist=0
jq -e .scripts.dist package.json 2> /dev/null || dodist=$?
if [ $dodist -eq 0 ]; then
projdir=`pwd`
builddir=`mktemp -d 2>/dev/null || mktemp -d -t 'mytmpdir'`
projdir=$(pwd)
builddir=$(mktemp -d 2>/dev/null || mktemp -d -t 'mytmpdir')
echo "Building distribution copy in $builddir"
pushd "$builddir"
git clone "$projdir" .
@@ -232,7 +252,7 @@ fi
if [ -n "$signing_id" ]; then
# make a signed tag
# 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
git tag -a -F "${latest_changes}" "$tag"
fi
@@ -270,7 +290,7 @@ if [ -n "$signing_id" ]; then
curl -L "${gh_project_url}/archive/${tarfile}" -o "${tarfile}"
# 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
# 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'
fi
release_text=`mktemp`
release_text=$(mktemp)
echo "$tag" > "${release_text}"
echo >> "${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
rm -rf "$builddir"
@@ -310,19 +330,6 @@ fi
rm "${release_text}"
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 [ $prerelease -eq 1 ]; then
git checkout "$rel_branch"
@@ -339,34 +346,19 @@ git merge "$rel_branch" --no-edit
git push origin master
# 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 pull
git merge master --no-edit
# 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
[ -x ./post-release.sh ] && ./post-release.sh
if [ $has_subprojects -eq 1 ] && [ $prerelease -eq 0 ]; then
echo "Resetting subprojects to develop"
for proj in $subprojects; do
reset_dependency "$proj"
done
git push origin develop
fi

View File

@@ -1,6 +1,6 @@
/*
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");
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
* @constructor
*/
export function MockStorageApi() {
this.data = {};
this.keys = [];
this.length = 0;
export class MockStorageApi {
public data: Record<string, string> = {};
public keys: string[] = [];
public length = 0;
public setItem(k: string, v: string): void {
this.data[k] = v;
this.recalc();
}
MockStorageApi.prototype = {
setItem: function(k, v) {
this.data[k] = v;
this._recalc();
},
getItem: function(k) {
public getItem(k: string): string | null {
return this.data[k] || null;
},
removeItem: function(k) {
}
public removeItem(k: string): void {
delete this.data[k];
this._recalc();
},
key: function(index) {
this.recalc();
}
public key(index: number): string {
return this.keys[index];
},
_recalc: function() {
const keys = [];
}
private recalc(): void {
const keys: string[] = [];
for (const k in this.data) {
if (!this.data.hasOwnProperty(k)) {
continue;
@@ -50,6 +51,5 @@ MockStorageApi.prototype = {
}
this.keys = keys;
this.length = keys.length;
},
};
}
}

View File

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

View File

@@ -168,6 +168,7 @@ describe("SlidingSyncSdk", () => {
const roomD = "!d_with_notif_count:localhost";
const roomE = "!e_with_invite:localhost";
const roomF = "!f_calc_room_name:localhost";
const roomG = "!g_join_invite_counts:localhost";
const data: Record<string, MSC3575RoomData> = {
[roomA]: {
name: "A",
@@ -261,12 +262,25 @@ describe("SlidingSyncSdk", () => {
],
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", () => {
mockSlidingSync.emit(SlidingSyncEvent.RoomData, roomA, data[roomA]);
const gotRoom = client.getRoom(roomA);
expect(gotRoom).toBeDefined();
if (gotRoom == null) { return; }
expect(gotRoom.name).toEqual(data[roomA].name);
expect(gotRoom.getMyMembership()).toEqual("join");
assertTimelineEvents(gotRoom.getLiveTimeline().getEvents().slice(-2), data[roomA].timeline);
@@ -276,6 +290,7 @@ describe("SlidingSyncSdk", () => {
mockSlidingSync.emit(SlidingSyncEvent.RoomData, roomB, data[roomB]);
const gotRoom = client.getRoom(roomB);
expect(gotRoom).toBeDefined();
if (gotRoom == null) { return; }
expect(gotRoom.name).toEqual(data[roomB].name);
expect(gotRoom.getMyMembership()).toEqual("join");
assertTimelineEvents(gotRoom.getLiveTimeline().getEvents().slice(-5), data[roomB].timeline);
@@ -285,6 +300,7 @@ describe("SlidingSyncSdk", () => {
mockSlidingSync.emit(SlidingSyncEvent.RoomData, roomC, data[roomC]);
const gotRoom = client.getRoom(roomC);
expect(gotRoom).toBeDefined();
if (gotRoom == null) { return; }
expect(
gotRoom.getUnreadNotificationCount(NotificationCountType.Highlight),
).toEqual(data[roomC].highlight_count);
@@ -294,15 +310,26 @@ describe("SlidingSyncSdk", () => {
mockSlidingSync.emit(SlidingSyncEvent.RoomData, roomD, data[roomD]);
const gotRoom = client.getRoom(roomD);
expect(gotRoom).toBeDefined();
if (gotRoom == null) { return; }
expect(
gotRoom.getUnreadNotificationCount(NotificationCountType.Total),
).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", () => {
mockSlidingSync.emit(SlidingSyncEvent.RoomData, roomE, data[roomE]);
const gotRoom = client.getRoom(roomE);
expect(gotRoom).toBeDefined();
if (gotRoom == null) { return; }
expect(gotRoom.getMyMembership()).toEqual("invite");
expect(gotRoom.currentState.getJoinRule()).toEqual(JoinRule.Invite);
});
@@ -311,6 +338,7 @@ describe("SlidingSyncSdk", () => {
mockSlidingSync.emit(SlidingSyncEvent.RoomData, roomF, data[roomF]);
const gotRoom = client.getRoom(roomF);
expect(gotRoom).toBeDefined();
if (gotRoom == null) { return; }
expect(
gotRoom.name,
).toEqual(data[roomF].name);
@@ -326,6 +354,7 @@ describe("SlidingSyncSdk", () => {
});
const gotRoom = client.getRoom(roomA);
expect(gotRoom).toBeDefined();
if (gotRoom == null) { return; }
const newTimeline = data[roomA].timeline;
newTimeline.push(newEvent);
assertTimelineEvents(gotRoom.getLiveTimeline().getEvents().slice(-3), newTimeline);
@@ -333,6 +362,8 @@ describe("SlidingSyncSdk", () => {
it("can update with a new required_state event", async () => {
let gotRoom = client.getRoom(roomB);
expect(gotRoom).toBeDefined();
if (gotRoom == null) { return; }
expect(gotRoom.getJoinRule()).toEqual(JoinRule.Invite); // default
mockSlidingSync.emit(SlidingSyncEvent.RoomData, roomB, {
required_state: [
@@ -343,6 +374,7 @@ describe("SlidingSyncSdk", () => {
});
gotRoom = client.getRoom(roomB);
expect(gotRoom).toBeDefined();
if (gotRoom == null) { return; }
expect(gotRoom.getJoinRule()).toEqual(JoinRule.Restricted);
});
@@ -355,6 +387,7 @@ describe("SlidingSyncSdk", () => {
});
const gotRoom = client.getRoom(roomC);
expect(gotRoom).toBeDefined();
if (gotRoom == null) { return; }
expect(
gotRoom.getUnreadNotificationCount(NotificationCountType.Highlight),
).toEqual(1);
@@ -369,11 +402,25 @@ describe("SlidingSyncSdk", () => {
});
const gotRoom = client.getRoom(roomD);
expect(gotRoom).toBeDefined();
if (gotRoom == null) { return; }
expect(
gotRoom.getUnreadNotificationCount(NotificationCountType.Total),
).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
// 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
@@ -394,6 +441,7 @@ describe("SlidingSyncSdk", () => {
});
const gotRoom = client.getRoom(roomA);
expect(gotRoom).toBeDefined();
if (gotRoom == null) { return; }
logger.log("want:", oldTimeline.map((e) => (e.type + " : " + (e.content || {}).body)));
logger.log("got:", gotRoom.getLiveTimeline().getEvents().map(

View File

@@ -558,6 +558,153 @@ describe("SlidingSync", () => {
await httpBackend.flushAllExpected();
await responseProcessed;
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();
});
});

View File

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

View File

@@ -24,5 +24,5 @@ limitations under the License.
* 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);

View File

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

View File

@@ -15,6 +15,7 @@ import { CRYPTO_ENABLED } from "../../src/client";
import { DeviceInfo } from "../../src/crypto/deviceinfo";
import { logger } from '../../src/logger';
import { MemoryStore } from "../../src";
import { IStore } from '../../src/store';
const Olm = global.Olm;
@@ -158,8 +159,8 @@ describe("Crypto", function() {
let fakeEmitter;
beforeEach(async function() {
const mockStorage = new MockStorageApi();
const clientStore = new MemoryStore({ localStorage: mockStorage });
const mockStorage = new MockStorageApi() as unknown as Storage;
const clientStore = new MemoryStore({ localStorage: mockStorage }) as unknown as IStore;
const cryptoStore = new MemoryCryptoStore();
cryptoStore.storeEndToEndDeviceData({
@@ -469,12 +470,12 @@ describe("Crypto", function() {
jest.setTimeout(10000);
const client = (new TestClient("@a:example.com", "dev")).client;
await client.initCrypto();
client.crypto.getSecretStorageKey = async () => null;
client.crypto.getSecretStorageKey = jest.fn().mockResolvedValue(null);
client.crypto.isCrossSigningReady = async () => false;
client.crypto.baseApis.uploadDeviceSigningKeys = jest.fn().mockResolvedValue(null);
client.crypto.baseApis.setAccountData = () => null;
client.crypto.baseApis.uploadKeySignatures = () => null;
client.crypto.baseApis.http.authedRequest = () => null;
client.crypto.baseApis.setAccountData = jest.fn().mockResolvedValue(null);
client.crypto.baseApis.uploadKeySignatures = jest.fn();
client.crypto.baseApis.http.authedRequest = jest.fn();
const createSecretStorageKey = async () => {
return {
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 { DeviceTrustLevel } from '../../../../src/crypto/CrossSigning';
const MegolmDecryption = algorithms.DECRYPTION_CLASSES['m.megolm.v1.aes-sha2'];
const MegolmEncryption = algorithms.ENCRYPTION_CLASSES['m.megolm.v1.aes-sha2'];
const MegolmDecryption = algorithms.DECRYPTION_CLASSES.get('m.megolm.v1.aes-sha2');
const MegolmEncryption = algorithms.ENCRYPTION_CLASSES.get('m.megolm.v1.aes-sha2');
const ROOM_ID = '!ROOM:ID';

View File

@@ -34,7 +34,7 @@ import { IAbortablePromise, MatrixScheduler } from '../../../src';
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';

View File

@@ -14,9 +14,9 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import {
IndexedDBCryptoStore,
} from '../../../src/crypto/store/indexeddb-crypto-store';
import { CryptoStore } from '../../../src/crypto/store/base';
import { IndexedDBCryptoStore } 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 { RoomKeyRequestState } from '../../../src/crypto/OutgoingRoomKeyRequestManager';
@@ -26,36 +26,39 @@ import 'jest-localstorage-mock';
const requests = [
{
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,
recipients: [
{ userId: "@alice:example.com", deviceId: "*" },
{ userId: "@becca:example.com", deviceId: "foobarbaz" },
],
},
{
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,
recipients: [
{ userId: "@alice:example.com", deviceId: "*" },
{ userId: "@carrie:example.com", deviceId: "barbazquux" },
],
},
{
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,
recipients: [
{ userId: "@becca:example.com", deviceId: "foobarbaz" },
],
},
];
describe.each([
["IndexedDBCryptoStore",
() => new IndexedDBCryptoStore(global.indexedDB, "tests")],
["LocalStorageCryptoStore",
() => new IndexedDBCryptoStore(undefined, "tests")],
["MemoryCryptoStore", () => {
const store = new IndexedDBCryptoStore(undefined, "tests");
// @ts-ignore set private properties
store.backend = new MemoryCryptoStore();
// @ts-ignore
store.backendPromise = Promise.resolve(store.backend);
return store;
}],
["LocalStorageCryptoStore", () => new LocalStorageCryptoStore(localStorage)],
["MemoryCryptoStore", () => new MemoryCryptoStore()],
])("Outgoing room key requests [%s]", function(name, dbFactory) {
let store;
let store: CryptoStore;
beforeAll(async () => {
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",
async () => {
const r =

View File

@@ -16,14 +16,15 @@ limitations under the License.
import * as utils from "../test-utils/test-utils";
import {
DuplicateStrategy,
EventTimeline,
EventTimelineSet,
EventType,
Filter,
MatrixClient,
MatrixEvent,
MatrixEventEvent,
Room,
DuplicateStrategy,
} from '../../src';
import { Thread } from "../../src/models/thread";
import { ReEmitter } from "../../src/ReEmitter";
@@ -291,4 +292,34 @@ describe('EventTimelineSet', () => {
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 { makeBeaconInfoContent } from "../../src/content-helpers";
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 { makeBeaconEvent } from "../test-utils/beacon";
import {
IGNORE_INVITES_ACCOUNT_EVENT_KEY,
POLICIES_ACCOUNT_EVENT_TYPE,
PolicyScope,
} from "../../src/models/invites-ignorer";
jest.useFakeTimers();
@@ -427,7 +432,7 @@ describe("MatrixClient", function() {
}
});
});
await client.startClient();
await client.startClient({ filter });
await syncPromise;
});
@@ -1412,4 +1417,301 @@ describe("MatrixClient", function() {
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 { RoomMember } from "../../src/models/room-member";
import { RoomMember, RoomMemberEvent } from "../../src/models/room-member";
import { RoomState } from "../../src";
describe("RoomMember", function() {
const roomId = "!foo:bar";
const userA = "@alice:bar";
const userB = "@bertha:bar";
const userC = "@clarissa:bar";
let member;
let member = new RoomMember(roomId, userA);
beforeEach(function() {
member = new RoomMember(roomId, userA);
@@ -27,15 +44,15 @@ describe("RoomMember", function() {
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
// 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",
function() {
const url = member.getAvatarUrl(hsUrl, 64, 64, "crop", false);
const url = member.getAvatarUrl(hsUrl, 64, 64, "crop", false, false);
expect(url).toEqual(null);
});
});
@@ -82,7 +99,7 @@ describe("RoomMember", function() {
});
let emitCount = 0;
member.on("RoomMember.powerLevel", function(emitEvent, emitMember) {
member.on(RoomMemberEvent.PowerLevel, function(emitEvent, emitMember) {
emitCount += 1;
expect(emitMember).toEqual(member);
expect(emitEvent).toEqual(event);
@@ -113,7 +130,7 @@ describe("RoomMember", function() {
// set the power level to something other than zero or we
// won't get an event
member.powerLevel = 1;
member.on("RoomMember.powerLevel", function(emitEvent, emitMember) {
member.on(RoomMemberEvent.PowerLevel, function(emitEvent, emitMember) {
emitCount += 1;
expect(emitMember.userId).toEqual('@alice:bar');
expect(emitMember.powerLevel).toEqual(0);
@@ -141,7 +158,7 @@ describe("RoomMember", function() {
});
let emitCount = 0;
member.on("RoomMember.powerLevel", function(emitEvent, emitMember) {
member.on(RoomMemberEvent.PowerLevel, function(emitEvent, emitMember) {
emitCount += 1;
expect(emitMember.userId).toEqual('@alice:bar');
expect(emitMember.powerLevel).toEqual(20);
@@ -195,7 +212,7 @@ describe("RoomMember", function() {
event: true,
});
let emitCount = 0;
member.on("RoomMember.typing", function(ev, mem) {
member.on(RoomMemberEvent.Typing, function(ev, mem) {
expect(mem).toEqual(member);
expect(ev).toEqual(event);
emitCount += 1;
@@ -210,7 +227,7 @@ describe("RoomMember", function() {
describe("isOutOfBand", function() {
it("should be set by markOutOfBand", function() {
const member = new RoomMember();
const member = new RoomMember(roomId, userA);
expect(member.isOutOfBand()).toEqual(false);
member.markOutOfBand();
expect(member.isOutOfBand()).toEqual(true);
@@ -266,7 +283,7 @@ describe("RoomMember", function() {
getUserIdsWithDisplayName: function(displayName) {
return [userA, userC];
},
};
} as unknown as RoomState;
expect(member.name).toEqual(userA); // default = user_id
member.setMembershipEvent(joinEvent);
expect(member.name).toEqual("Alice"); // prefer displayname
@@ -278,7 +295,7 @@ describe("RoomMember", function() {
it("should emit 'RoomMember.membership' if the membership changes", function() {
let emitCount = 0;
member.on("RoomMember.membership", function(ev, mem) {
member.on(RoomMemberEvent.Membership, function(ev, mem) {
emitCount += 1;
expect(mem).toEqual(member);
expect(ev).toEqual(inviteEvent);
@@ -291,7 +308,7 @@ describe("RoomMember", function() {
it("should emit 'RoomMember.name' if the name changes", function() {
let emitCount = 0;
member.on("RoomMember.name", function(ev, mem) {
member.on(RoomMemberEvent.Name, function(ev, mem) {
emitCount += 1;
expect(mem).toEqual(member);
expect(ev).toEqual(joinEvent);
@@ -341,7 +358,7 @@ describe("RoomMember", function() {
getUserIdsWithDisplayName: function(displayName) {
return [userA, userC];
},
};
} as unknown as RoomState;
expect(member.name).toEqual(userA); // default = user_id
member.setMembershipEvent(joinEvent, roomState);
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 { makeBeaconEvent, makeBeaconInfoEvent } from "../test-utils/beacon";
import { filterEmitCallsByEventType } from "../test-utils/emitter";
import { RoomState, RoomStateEvent } from "../../src/models/room-state";
import { BeaconEvent, getBeaconInfoIdentifier } from "../../src/models/beacon";
import {
Beacon,
BeaconEvent,
getBeaconInfoIdentifier,
} from "../../src/models/beacon";
import { EventType, RelationType, UNSTABLE_MSC2716_MARKER } from "../../src/@types/event";
import {
MatrixEvent,
MatrixEventEvent,
} from "../../src/models/event";
import { M_BEACON } from "../../src/@types/beacon";
import { MatrixClient } from "../../src/client";
describe("RoomState", function() {
const roomId = "!foo:bar";
@@ -17,7 +40,7 @@ describe("RoomState", function() {
const userC = "@cleo:bar";
const userLazy = "@lazy:bar";
let state;
let state = new RoomState(roomId);
beforeEach(function() {
state = new RoomState(roomId);
@@ -67,8 +90,8 @@ describe("RoomState", function() {
it("should return a member which changes as state changes", function() {
const member = state.getMember(userB);
expect(member.membership).toEqual("join");
expect(member.name).toEqual(userB);
expect(member?.membership).toEqual("join");
expect(member?.name).toEqual(userB);
state.setStateEvents([
utils.mkMembership({
@@ -77,14 +100,14 @@ describe("RoomState", function() {
}),
]);
expect(member.membership).toEqual("leave");
expect(member.name).toEqual("BobGone");
expect(member?.membership).toEqual("leave");
expect(member?.name).toEqual("BobGone");
});
});
describe("getSentinelMember", 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",
@@ -98,11 +121,11 @@ describe("RoomState", function() {
]);
const postLeaveUser = state.getSentinelMember(userA);
expect(preLeaveUser.membership).toEqual("join");
expect(preLeaveUser.name).toEqual(userA);
expect(preLeaveUser?.membership).toEqual("join");
expect(preLeaveUser?.name).toEqual(userA);
expect(postLeaveUser.membership).toEqual("leave");
expect(postLeaveUser.name).toEqual("AliceIsGone");
expect(postLeaveUser?.membership).toEqual("leave");
expect(postLeaveUser?.name).toEqual("AliceIsGone");
});
});
@@ -122,8 +145,8 @@ describe("RoomState", function() {
const events = state.getStateEvents("m.room.member");
expect(events.length).toEqual(2);
// ordering unimportant
expect([userA, userB].indexOf(events[0].getStateKey())).not.toEqual(-1);
expect([userA, userB].indexOf(events[1].getStateKey())).not.toEqual(-1);
expect([userA, userB].indexOf(events[0].getStateKey() as string)).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",
@@ -146,7 +169,7 @@ describe("RoomState", function() {
}),
];
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(st).toEqual(state);
expect(mem).toEqual(state.getMember(ev.getSender()));
@@ -166,7 +189,7 @@ describe("RoomState", function() {
}),
];
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(mem.userId).toEqual(memberEvents[emitCount].getSender());
expect(mem.membership).toBeFalsy(); // not defined yet
@@ -192,7 +215,7 @@ describe("RoomState", function() {
}),
];
let emitCount = 0;
state.on("RoomState.events", function(ev, st) {
state.on(RoomStateEvent.Events, function(ev, st) {
expect(ev).toEqual(events[emitCount]);
expect(st).toEqual(state);
emitCount += 1;
@@ -272,7 +295,7 @@ describe("RoomState", function() {
}),
];
let emitCount = 0;
state.on("RoomState.Marker", function(markerEvent, markerFoundOptions) {
state.on(RoomStateEvent.Marker, function(markerEvent, markerFoundOptions) {
expect(markerEvent).toEqual(events[emitCount]);
expect(markerFoundOptions).toEqual({ timelineWasEmpty: true });
emitCount += 1;
@@ -296,7 +319,7 @@ describe("RoomState", function() {
it('does not add redacted beacon info events to state', () => {
const redactedBeaconEvent = makeBeaconInfoEvent(userA, roomId);
const redactionEvent = { event: { type: 'm.room.redaction' } };
const redactionEvent = new MatrixEvent({ type: 'm.room.redaction' });
redactedBeaconEvent.makeRedacted(redactionEvent);
const emitSpy = jest.spyOn(state, 'emit');
@@ -316,27 +339,27 @@ describe("RoomState", function() {
state.setStateEvents([beaconEvent]);
const beaconInstance = state.beacons.get(getBeaconInfoIdentifier(beaconEvent));
expect(beaconInstance.isLive).toEqual(true);
expect(beaconInstance?.isLive).toEqual(true);
state.setStateEvents([updatedBeaconEvent]);
// same Beacon
expect(state.beacons.get(getBeaconInfoIdentifier(beaconEvent))).toBe(beaconInstance);
// 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', () => {
const beaconId = '$beacon1';
const beaconEvent = 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);
state.setStateEvents([beaconEvent]);
const beaconInstance = state.beacons.get(getBeaconInfoIdentifier(beaconEvent));
const destroySpy = jest.spyOn(beaconInstance, 'destroy');
expect(beaconInstance.isLive).toEqual(true);
const destroySpy = jest.spyOn(beaconInstance as Beacon, 'destroy');
expect(beaconInstance?.isLive).toEqual(true);
state.setStateEvents([redactedBeaconEvent]);
@@ -357,7 +380,7 @@ describe("RoomState", function() {
// live beacon is now not live
const updatedLiveBeaconEvent = makeBeaconInfoEvent(
userA, roomId, { isLive: false }, liveBeaconEvent.getId(), '$beacon1',
userA, roomId, { isLive: false }, liveBeaconEvent.getId(),
);
state.setStateEvents([updatedLiveBeaconEvent]);
@@ -377,8 +400,8 @@ describe("RoomState", function() {
state.markOutOfBandMembersStarted();
state.setOutOfBandMembers([oobMemberEvent]);
const member = state.getMember(userLazy);
expect(member.userId).toEqual(userLazy);
expect(member.isOutOfBand()).toEqual(true);
expect(member?.userId).toEqual(userLazy);
expect(member?.isOutOfBand()).toEqual(true);
});
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,
});
let eventReceived = false;
state.once('RoomState.newMember', (_, __, member) => {
state.once(RoomStateEvent.NewMember, (_event, _state, member) => {
expect(member.userId).toEqual(userLazy);
eventReceived = true;
});
@@ -410,8 +433,8 @@ describe("RoomState", function() {
state.markOutOfBandMembersStarted();
state.setOutOfBandMembers([oobMemberEvent]);
const memberA = state.getMember(userA);
expect(memberA.events.member.getId()).not.toEqual(oobMemberEvent.getId());
expect(memberA.isOutOfBand()).toEqual(false);
expect(memberA?.events?.member?.getId()).not.toEqual(oobMemberEvent.getId());
expect(memberA?.isOutOfBand()).toEqual(false);
});
it("should emit members when updating a member", function() {
@@ -420,7 +443,7 @@ describe("RoomState", function() {
user: doesntExistYetUserId, mship: "join", room: roomId, event: true,
});
let eventReceived = false;
state.once('RoomState.members', (_, __, member) => {
state.once(RoomStateEvent.Members, (_event, _state, member) => {
expect(member.userId).toEqual(doesntExistYetUserId);
eventReceived = true;
});
@@ -443,8 +466,8 @@ describe("RoomState", function() {
[userA, userB, userLazy].forEach((userId) => {
const member = state.getMember(userId);
const memberCopy = copy.getMember(userId);
expect(member.name).toEqual(memberCopy.name);
expect(member.isOutOfBand()).toEqual(memberCopy.isOutOfBand());
expect(member?.name).toEqual(memberCopy?.name);
expect(member?.isOutOfBand()).toEqual(memberCopy?.isOutOfBand());
});
// check member keys
expect(Object.keys(state.members)).toEqual(Object.keys(copy.members));
@@ -503,19 +526,20 @@ describe("RoomState", function() {
it("should say members with power >=50 may send state with power level event " +
"but no state default",
function() {
const powerLevelEvent = {
type: "m.room.power_levels", room: roomId, user: userA, event: true,
const powerLevelEvent = new MatrixEvent({
type: "m.room.power_levels", room_id: roomId, sender: userA,
state_key: "",
content: {
users_default: 10,
// state_default: 50, "intentionally left blank"
events_default: 25,
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', userB)).toEqual(false);
@@ -523,20 +547,21 @@ describe("RoomState", function() {
it("should obey state_default",
function() {
const powerLevelEvent = {
type: "m.room.power_levels", room: roomId, user: userA, event: true,
const powerLevelEvent = new MatrixEvent({
type: "m.room.power_levels", room_id: roomId, sender: userA,
state_key: "",
content: {
users_default: 10,
state_default: 30,
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', userB)).toEqual(false);
@@ -544,9 +569,9 @@ describe("RoomState", function() {
it("should honour explicit event power levels in the power_levels event",
function() {
const powerLevelEvent = {
type: "m.room.power_levels", room: roomId, user: userA, event: true,
content: {
const powerLevelEvent = new MatrixEvent({
type: "m.room.power_levels", room_id: roomId, sender: userA,
state_key: "", content: {
events: {
"m.room.other_thing": 76,
},
@@ -554,13 +579,13 @@ describe("RoomState", function() {
state_default: 50,
events_default: 25,
users: {
[userA]: 80,
[userB]: 50,
},
},
};
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', userB)).toEqual(true);
@@ -689,20 +714,21 @@ describe("RoomState", function() {
it("should obey events_default",
function() {
const powerLevelEvent = {
type: "m.room.power_levels", room: roomId, user: userA, event: true,
const powerLevelEvent = new MatrixEvent({
type: "m.room.power_levels", room_id: roomId, sender: userA,
state_key: "",
content: {
users_default: 10,
state_default: 30,
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', userB)).toEqual(false);
@@ -713,8 +739,9 @@ describe("RoomState", function() {
it("should honour explicit event power levels in the power_levels event",
function() {
const powerLevelEvent = {
type: "m.room.power_levels", room: roomId, user: userA, event: true,
const powerLevelEvent = new MatrixEvent({
type: "m.room.power_levels", room_id: roomId, sender: userA,
state_key: "",
content: {
events: {
"m.room.other_thing": 33,
@@ -723,13 +750,13 @@ describe("RoomState", function() {
state_default: 50,
events_default: 25,
users: {
[userA]: 40,
[userB]: 30,
},
},
};
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', userB)).toEqual(true);
@@ -743,10 +770,10 @@ describe("RoomState", function() {
});
describe('processBeaconEvents', () => {
const beacon1 = makeBeaconInfoEvent(userA, roomId, {}, '$beacon1', '$beacon1');
const beacon2 = makeBeaconInfoEvent(userB, roomId, {}, '$beacon2', '$beacon2');
const beacon1 = makeBeaconInfoEvent(userA, roomId, {}, '$beacon1');
const beacon2 = makeBeaconInfoEvent(userB, roomId, {}, '$beacon2');
const mockClient = { decryptEventIfNeeded: jest.fn() };
const mockClient = { decryptEventIfNeeded: jest.fn() } as unknown as MockedObject<MatrixClient>;
beforeEach(() => {
mockClient.decryptEventIfNeeded.mockClear();
@@ -816,11 +843,11 @@ describe("RoomState", function() {
beaconInfoId: 'some-other-beacon',
});
state.setStateEvents([beacon1, beacon2], mockClient);
state.setStateEvents([beacon1, beacon2]);
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');
state.processBeaconEvents([location1, location2, location3], mockClient);
@@ -885,7 +912,7 @@ describe("RoomState", function() {
});
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();
state.processBeaconEvents([location, otherRelatedEvent], mockClient);
expect(addLocationsSpy).not.toHaveBeenCalled();
@@ -945,13 +972,13 @@ describe("RoomState", function() {
});
jest.spyOn(decryptingRelatedEvent, 'isBeingDecrypted').mockReturnValue(true);
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();
state.processBeaconEvents([decryptingRelatedEvent], mockClient);
// this event is a message after decryption
decryptingRelatedEvent.type = EventType.RoomMessage;
decryptingRelatedEvent.emit(MatrixEventEvent.Decrypted);
decryptingRelatedEvent.event.type = EventType.RoomMessage;
decryptingRelatedEvent.emit(MatrixEventEvent.Decrypted, decryptingRelatedEvent);
expect(addLocationsSpy).not.toHaveBeenCalled();
});
@@ -967,14 +994,14 @@ describe("RoomState", function() {
});
jest.spyOn(decryptingRelatedEvent, 'isBeingDecrypted').mockReturnValue(true);
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();
state.processBeaconEvents([decryptingRelatedEvent], mockClient);
// update type after '''decryption'''
decryptingRelatedEvent.event.type = M_BEACON.name;
decryptingRelatedEvent.event.content = locationEvent.content;
decryptingRelatedEvent.emit(MatrixEventEvent.Decrypted);
decryptingRelatedEvent.event.content = locationEvent.event.content;
decryptingRelatedEvent.emit(MatrixEventEvent.Decrypted, decryptingRelatedEvent);
expect(addLocationsSpy).toHaveBeenCalledWith([decryptingRelatedEvent]);
});

View File

@@ -288,11 +288,11 @@ describe("Room", function() {
room.addLiveEvents(events);
expect(room.currentState.setStateEvents).toHaveBeenCalledWith(
[events[0]],
{ timelineWasEmpty: undefined },
{ timelineWasEmpty: false },
);
expect(room.currentState.setStateEvents).toHaveBeenCalledWith(
[events[1]],
{ timelineWasEmpty: undefined },
{ timelineWasEmpty: false },
);
expect(events[0].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
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', () => {

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 }, { 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 }));
assert.isFalse(utils.deepCompare({ a: 1 }, { a: 1, b: 2 }));
assert.isFalse(utils.deepCompare({ a: 1 }, { b: 1 }));
assert.isTrue(utils.deepCompare({
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 { ToDeviceMessageQueue } from "./ToDeviceMessageQueue";
import { ToDeviceBatch } from "./models/ToDeviceMessage";
import { IgnoredInvites } from "./models/invites-ignorer";
export type Store = IStore;
@@ -406,8 +407,7 @@ export interface IStartClientOpts {
pollTimeout?: number;
/**
* The filter to apply to /sync calls. This will override the opts.initialSyncLimit, which would
* normally result in a timeline limit filter.
* The filter to apply to /sync calls.
*/
filter?: Filter;
@@ -974,6 +974,9 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
private useE2eForGroupCall = true;
private toDeviceMessageQueue: ToDeviceMessageQueue;
// A manager for determining which invites should be ignored.
public readonly ignoredInvites: IgnoredInvites;
constructor(opts: IMatrixClientCreateOpts) {
super();
@@ -1159,6 +1162,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
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)>}
*/
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">;
@@ -44,7 +44,7 @@ type DecryptionClassParams = Omit<IParams, "deviceId" | "config">;
*
* @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 {
userId: string;
@@ -297,6 +297,6 @@ export function registerAlgorithm(
encryptor: new (params: IParams) => EncryptionAlgorithm,
decryptor: new (params: Omit<IParams, "deviceId">) => DecryptionAlgorithm,
): void {
ENCRYPTION_CLASSES[algorithm] = encryptor;
DECRYPTION_CLASSES[algorithm] = decryptor;
ENCRYPTION_CLASSES.set(algorithm, encryptor);
DECRYPTION_CLASSES.set(algorithm, decryptor);
}

View File

@@ -1185,7 +1185,7 @@ class MegolmEncryption extends EncryptionAlgorithm {
class MegolmDecryption extends DecryptionAlgorithm {
// events which we couldn't decrypt due to unknown sessions / indexes: map from
// 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.
private olmlib = olmlib;
@@ -1337,10 +1337,10 @@ class MegolmDecryption extends DecryptionAlgorithm {
const content = event.getWireContent();
const senderKey = content.sender_key;
const sessionId = content.session_id;
if (!this.pendingEvents[senderKey]) {
this.pendingEvents[senderKey] = new Map();
if (!this.pendingEvents.has(senderKey)) {
this.pendingEvents.set(senderKey, new Map<string, Set<MatrixEvent>>());
}
const senderPendingEvents = this.pendingEvents[senderKey];
const senderPendingEvents = this.pendingEvents.get(senderKey);
if (!senderPendingEvents.has(sessionId)) {
senderPendingEvents.set(sessionId, new Set());
}
@@ -1358,7 +1358,7 @@ class MegolmDecryption extends DecryptionAlgorithm {
const content = event.getWireContent();
const senderKey = content.sender_key;
const sessionId = content.session_id;
const senderPendingEvents = this.pendingEvents[senderKey];
const senderPendingEvents = this.pendingEvents.get(senderKey);
const pendingEvents = senderPendingEvents?.get(sessionId);
if (!pendingEvents) {
return;
@@ -1369,7 +1369,7 @@ class MegolmDecryption extends DecryptionAlgorithm {
senderPendingEvents.delete(sessionId);
}
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
*/
private async retryDecryption(senderKey: string, sessionId: string): Promise<boolean> {
const senderPendingEvents = this.pendingEvents[senderKey];
const senderPendingEvents = this.pendingEvents.get(senderKey);
if (!senderPendingEvents) {
return true;
}
@@ -1731,16 +1731,16 @@ class MegolmDecryption extends DecryptionAlgorithm {
}));
// 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> {
const senderPendingEvents = this.pendingEvents[senderKey];
const senderPendingEvents = this.pendingEvents.get(senderKey);
if (!senderPendingEvents) {
return true;
}
delete this.pendingEvents[senderKey];
this.pendingEvents.delete(senderKey);
await Promise.all([...senderPendingEvents].map(async ([_sessionId, pending]) => {
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> {

View File

@@ -301,9 +301,9 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
private oneTimeKeyCheckInProgress = false;
// 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
private roomDecryptors: Record<string, Record<string, DecryptionAlgorithm>> = {};
private roomDecryptors = new Map<string, Map<string, DecryptionAlgorithm>>();
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.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(
baseApis, this.deviceId, this.cryptoStore,
@@ -2550,7 +2550,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
* This should not normally be necessary.
*/
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.forceDiscardSession === undefined) {
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.
// If it's called more than twice though,
// it signals a bug on client or server.
const existingAlg = this.roomEncryptors[roomId];
const existingAlg = this.roomEncryptors.get(roomId);
if (existingAlg) {
return;
}
@@ -2617,7 +2617,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
storeConfigPromise = this.roomList.setRoomEncryption(roomId, config);
}
const AlgClass = algorithms.ENCRYPTION_CLASSES[config.algorithm];
const AlgClass = algorithms.ENCRYPTION_CLASSES.get(config.algorithm);
if (!AlgClass) {
throw new Error("Unable to encrypt with " + config.algorithm);
}
@@ -2631,7 +2631,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
roomId,
config,
});
this.roomEncryptors[roomId] = alg;
this.roomEncryptors.set(roomId, alg);
if (storeConfigPromise) {
await storeConfigPromise;
@@ -2663,7 +2663,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
public trackRoomDevices(roomId: string): Promise<void> {
const trackMembers = async () => {
// not an encrypted room
if (!this.roomEncryptors[roomId]) {
if (!this.roomEncryptors.has(roomId)) {
return;
}
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
*/
public prepareToEncrypt(room: Room): void {
const alg = this.roomEncryptors[room.roomId];
const alg = this.roomEncryptors.get(room.roomId);
if (alg) {
alg.prepareToEncrypt(room);
}
@@ -2831,7 +2831,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
const roomId = event.getRoomId();
const alg = this.roomEncryptors[roomId];
const alg = this.roomEncryptors.get(roomId);
if (!alg) {
// MatrixClient has already checked that this room should be encrypted,
// so this is an unexpected situation.
@@ -3120,7 +3120,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
private getTrackedE2eRooms(): Room[] {
return this.clientStore.getRooms().filter((room) => {
// check for rooms with encryption enabled
const alg = this.roomEncryptors[room.roomId];
const alg = this.roomEncryptors.get(room.roomId);
if (!alg) {
return false;
}
@@ -3556,7 +3556,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
const roomId = member.roomId;
const alg = this.roomEncryptors[roomId];
const alg = this.roomEncryptors.get(roomId);
if (!alg) {
// not encrypting in this room
return;
@@ -3657,11 +3657,11 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
` for ${roomId} / ${body.session_id} (id ${req.requestId})`);
if (userId !== this.userId) {
if (!this.roomEncryptors[roomId]) {
if (!this.roomEncryptors.get(roomId)) {
logger.debug(`room key request for unencrypted room ${roomId}`);
return;
}
const encryptor = this.roomEncryptors[roomId];
const encryptor = this.roomEncryptors.get(roomId);
const device = this.deviceList.getStoredDevice(userId, deviceId);
if (!device) {
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
// 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}`);
return;
}
const decryptor = this.roomDecryptors[roomId][alg];
const decryptor = this.roomDecryptors.get(roomId).get(alg);
if (!decryptor) {
logger.log(`room key request for unknown alg ${alg} in room ${roomId}`);
return;
@@ -3768,23 +3768,24 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
* unknown
*/
public getRoomDecryptor(roomId: string, algorithm: string): DecryptionAlgorithm {
let decryptors: Record<string, DecryptionAlgorithm>;
let decryptors: Map<string, DecryptionAlgorithm>;
let alg: DecryptionAlgorithm;
roomId = roomId || null;
if (roomId) {
decryptors = this.roomDecryptors[roomId];
decryptors = this.roomDecryptors.get(roomId);
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) {
return alg;
}
}
const AlgClass = algorithms.DECRYPTION_CLASSES[algorithm];
const AlgClass = algorithms.DECRYPTION_CLASSES.get(algorithm);
if (!AlgClass) {
throw new algorithms.DecryptionError(
'UNKNOWN_ENCRYPTION_ALGORITHM',
@@ -3800,7 +3801,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
});
if (decryptors) {
decryptors[algorithm] = alg;
decryptors.set(algorithm, alg);
}
return alg;
}
@@ -3814,9 +3815,9 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
*/
private getRoomDecryptors(algorithm: string): DecryptionAlgorithm[] {
const decryptors = [];
for (const d of Object.values(this.roomDecryptors)) {
if (algorithm in d) {
decryptors.push(d[algorithm]);
for (const d of this.roomDecryptors.values()) {
if (d.has(algorithm)) {
decryptors.push(d.get(algorithm));
}
}
return decryptors;

View File

@@ -26,7 +26,7 @@ import {
Mode,
OutgoingRoomKeyRequest,
} from "./base";
import { IRoomKeyRequestBody } from "../index";
import { IRoomKeyRequestBody, IRoomKeyRequestRecipient } from "../index";
import { ICrossSigningKey } from "../../client";
import { IOlmDevice } from "../algorithms/megolm";
import { IRoomEncryption } from "../RoomList";
@@ -261,7 +261,9 @@ export class Backend implements CryptoStore {
const cursor = this.result;
if (cursor) {
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);
}
cursor.continue();

View File

@@ -191,11 +191,13 @@ export class MemoryCryptoStore implements CryptoStore {
deviceId: string,
wantedStates: number[],
): Promise<OutgoingRoomKeyRequest[]> {
const results = [];
const results: OutgoingRoomKeyRequest[] = [];
for (const req of this.outgoingRoomKeyRequests) {
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);
}
}

View File

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

View File

@@ -26,7 +26,7 @@ import { logger } from '../logger';
import { VerificationRequest } from "../crypto/verification/request/VerificationRequest";
import { EVENT_VISIBILITY_CHANGE_TYPE, EventType, MsgType, RelationType } from "../@types/event";
import { Crypto, IEventDecryptionResult } from "../crypto";
import { deepSortedObjectEntries } from "../utils";
import { deepSortedObjectEntries, internaliseString } from "../utils";
import { RoomMember } from "./room-member";
import { Thread, ThreadEvent, EventHandlerMap as ThreadEventHandlerMap, THREAD_RELATION_TYPE } from "./thread";
import { IActionsObject } from '../pushprocessor';
@@ -37,14 +37,6 @@ import { 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 */
export interface IContent {
[key: string]: any;
@@ -326,17 +318,17 @@ export class MatrixEvent extends TypedEventEmitter<EmittedEvents, MatrixEventHan
// of space if we don't intern it.
["state_key", "type", "sender", "room_id", "membership"].forEach((prop) => {
if (typeof event[prop] !== "string") return;
event[prop] = intern(event[prop]);
event[prop] = internaliseString(event[prop]);
});
["membership", "avatar_url", "displayname"].forEach((prop) => {
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) => {
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;
@@ -796,6 +788,8 @@ export class MatrixEvent extends TypedEventEmitter<EmittedEvents, MatrixEventHan
// not a decryption error: log the whole exception as an error
// (and don't bother with a retry)
const re = options.isRetry ? 're' : '';
// For find results: this can produce "Error decrypting event (id=$ev)" and
// "Error redecrypting event (id=$ev)".
logger.error(
`Error ${re}decrypting event ` +
`(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 {
// A tree of objects to access a set of related children for an event, as in:
// this.relations[parentEventId][relationType][relationEventType]
private relations: {
[parentEventId: string]: {
[relationType: RelationType | string]: {
[eventType: EventType | string]: Relations;
};
};
} = {};
// this.relations.get(parentEventId).get(relationType).get(relationEventType)
private relations = new Map<string, Map<RelationType | string, Map<EventType | string, Relations>>>();
constructor(private readonly client: MatrixClient, private readonly room?: Room) {
}
@@ -57,14 +51,15 @@ export class RelationsContainer {
relationType: RelationType | string,
eventType: EventType | string,
): Relations | undefined {
return this.relations[eventId]?.[relationType]?.[eventType];
return this.relations.get(eventId)?.get(relationType)?.get(eventType);
}
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[] = [];
for (const relationsRecord of Object.values(relationsForEvent)) {
for (const relations of Object.values(relationsRecord)) {
for (const relationsRecord of relationsForEvent.values()) {
for (const relations of relationsRecord.values()) {
events.push(...relations.getRelations());
}
}
@@ -79,11 +74,11 @@ export class RelationsContainer {
* @param {MatrixEvent} event The event to check as relation target.
*/
public aggregateParentEvent(event: MatrixEvent): void {
const relationsForEvent = this.relations[event.getId()];
const relationsForEvent = this.relations.get(event.getId());
if (!relationsForEvent) return;
for (const relationsWithRelType of Object.values(relationsForEvent)) {
for (const relationsWithEventType of Object.values(relationsWithRelType)) {
for (const relationsWithRelType of relationsForEvent.values()) {
for (const relationsWithEventType of relationsWithRelType.values()) {
relationsWithEventType.setTargetEvent(event);
}
}
@@ -123,23 +118,26 @@ export class RelationsContainer {
const { event_id: relatesToEventId, rel_type: relationType } = relation;
const eventType = event.getType();
let relationsForEvent = this.relations[relatesToEventId];
let relationsForEvent = this.relations.get(relatesToEventId);
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) {
relationsWithRelType = relationsForEvent[relationType] = {};
relationsWithRelType = new Map<EventType | string, Relations>();
relationsForEvent.set(relationType, relationsWithRelType);
}
let relationsWithEventType = relationsWithRelType[eventType];
let relationsWithEventType = relationsWithRelType.get(eventType);
if (!relationsWithEventType) {
relationsWithEventType = relationsWithRelType[eventType] = new Relations(
relationsWithEventType = new Relations(
relationType,
eventType,
this.client,
);
relationsWithRelType.set(eventType, relationsWithEventType);
const room = this.room ?? timelineSet?.room;
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);
private sentinels: Record<string, RoomMember> = {}; // userId: RoomMember
// 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 tokenToInvite: Record<string, MatrixEvent> = {}; // 3pid invite state_key to m.room.member invite
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.
*/
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.
const strippedOldName = utils.removeHiddenChars(oldName);
const existingUserIds = this.displayNameToUserIds[strippedOldName];
const existingUserIds = this.displayNameToUserIds.get(strippedOldName);
if (existingUserIds) {
// remove this user ID from this array
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);
// an empty stripped displayname (undefined/'') will be set to MXID in room-member.js
if (strippedDisplayname) {
if (!this.displayNameToUserIds[strippedDisplayname]) {
this.displayNameToUserIds[strippedDisplayname] = [];
}
this.displayNameToUserIds[strippedDisplayname].push(userId);
const arr = this.displayNameToUserIds.get(strippedDisplayname) ?? [];
arr.push(userId);
this.displayNameToUserIds.set(strippedDisplayname, arr);
}
}
}

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".
*
* @param {MatrixEvent} event Event to be added
* @param {IAddLiveEventOptions} options addLiveEvent options
* @param {IAddLiveEventOptions} addLiveEventOptions addLiveEvent options
* @fires module:client~MatrixClient#event:"Room.timeline"
* @private
*/
@@ -2344,7 +2336,7 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
fromCache = false,
): void {
let duplicateStrategy = duplicateStrategyOrOpts as DuplicateStrategy;
let timelineWasEmpty: boolean;
let timelineWasEmpty = false;
if (typeof (duplicateStrategyOrOpts) === 'object') {
({
duplicateStrategy,
@@ -2383,10 +2375,25 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
const threadRoots = this.findThreadRoots(events);
const eventsByThread: { [threadId: string]: MatrixEvent[] } = {};
const options: IAddLiveEventOptions = {
duplicateStrategy,
fromCache,
timelineWasEmpty,
};
for (const event of events) {
// TODO: We should have a filter to say "only add state event types X Y Z to the timeline".
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 {
shouldLiveInRoom,
shouldLiveInThread,
@@ -2399,11 +2406,7 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
eventsByThread[threadId]?.push(event);
if (shouldLiveInRoom) {
this.addLiveEvent(event, {
duplicateStrategy,
fromCache,
timelineWasEmpty,
});
this.addLiveEvent(event, options);
}
}

View File

@@ -296,13 +296,16 @@ export class SlidingSyncSdk {
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) {
logger.debug("onLifecycle", state, err);
}
switch (state) {
case SlidingSyncState.Complete:
this.purgeNotifications();
if (!resp) {
break;
}
// Element won't stop showing the initial loading spinner unless we fire SyncState.Prepared
if (!this.lastPos) {
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) {
const inviteStateEvents = mapEvents(this.client, room.roomId, roomData.invite_state);
this.injectRoomEvents(room, inviteStateEvents);
@@ -609,6 +619,10 @@ export class SlidingSyncSdk {
// we deliberately don't add ephemeral events to the timeline
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();
if (roomData.initial) {
client.store.storeRoom(room);
@@ -632,8 +646,6 @@ export class SlidingSyncSdk {
client.emit(ClientEvent.Event, e);
});
room.updateMyMembership("join");
// Decrypt only the last message in all rooms to make sure we can generate a preview
// And decrypt all events after the recorded read receipt to ensure an accurate
// notification count

View File

@@ -47,6 +47,8 @@ export interface MSC3575Filter {
room_types?: string[];
not_room_types?: string[];
spaces?: string[];
tags?: string[];
not_tags?: string[];
}
/**
@@ -82,6 +84,8 @@ export interface MSC3575RoomData {
timeline: (IRoomEvent | IStateEvent)[];
notification_count?: number;
highlight_count?: number;
joined_count?: number;
invited_count?: number;
invite_state?: IStateEvent[];
initial?: boolean;
limited?: boolean;
@@ -318,7 +322,9 @@ export enum SlidingSyncEvent {
export type SlidingSyncEventHandlerMap = {
[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]: (
listIndex: number, joinedCount: number, roomIndexToRoomId: Record<number, string>,
) => void;
@@ -530,6 +536,65 @@ export class SlidingSync extends TypedEventEmitter<SlidingSyncEvent, SlidingSync
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 {
let gapIndex = -1;
list.ops.forEach((op: Operation) => {
@@ -537,6 +602,10 @@ export class SlidingSync extends TypedEventEmitter<SlidingSyncEvent, SlidingSync
case "DELETE": {
logger.debug("DELETE", listIndex, 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;
break;
}
@@ -551,20 +620,9 @@ export class SlidingSync extends TypedEventEmitter<SlidingSyncEvent, SlidingSync
if (this.lists[listIndex].roomIndexToRoomId[op.index]) {
// something is in this space, shift items out of the way
if (gapIndex < 0) {
logger.debug(
"cannot work out where gap is, INSERT without previous DELETE! List: ",
listIndex,
);
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) {
// we haven't been told where to shift from, so make way for a new room entry.
this.addEntry(listIndex, op.index);
} else if (gapIndex > op.index) {
// 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:
// [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,A,B,C] i=1
// Terminate. We'll assign into op.index next.
for (let i = gapIndex; i > op.index; i--) {
if (this.lists[listIndex].isIndexInRange(i)) {
this.lists[listIndex].roomIndexToRoomId[i] =
this.lists[listIndex].roomIndexToRoomId[
i - 1
];
}
}
this.shiftRight(listIndex, gapIndex, op.index);
} else if (gapIndex < op.index) {
// 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
for (let i = gapIndex; i < op.index; i++) {
if (this.lists[listIndex].isIndexInRange(i)) {
this.lists[listIndex].roomIndexToRoomId[i] =
this.lists[listIndex].roomIndexToRoomId[
i + 1
];
}
}
this.shiftLeft(listIndex, op.index, gapIndex);
}
gapIndex = -1; // forget the gap, we don't need it anymore.
}
this.lists[listIndex].roomIndexToRoomId[op.index] = op.room_id;
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.
*/
import { Optional } from "matrix-events-sdk";
import { User, UserEvent } from "./models/user";
import { NotificationCountType, Room, RoomEvent } from "./models/room";
import * as utils from "./utils";
@@ -100,18 +102,16 @@ const MSC2716_ROOM_VERSIONS = [
function getFilterName(userId: string, suffix?: string): string {
// scope this on the user ID because people may login on many accounts
// and they all need to be stored!
return "FILTER_SYNC_" + userId + (suffix ? "_" + suffix : "");
return `FILTER_SYNC_${userId}` + (suffix ? "_" + suffix : "");
}
function debuglog(...params) {
if (!DEBUG) {
return;
}
if (!DEBUG) return;
logger.log(...params);
}
interface ISyncOptions {
filterId?: string;
filter?: string;
hasSyncedBefore?: boolean;
}
@@ -161,14 +161,14 @@ type WrappedRoom<T> = T & {
* updating presence.
*/
export class SyncApi {
private _peekRoom: Room = null;
private currentSyncRequest: IAbortablePromise<ISyncResponse> = null;
private syncState: SyncState = null;
private syncStateData: ISyncStateData = null; // additional data (eg. error object for failed sync)
private _peekRoom: Optional<Room> = null;
private currentSyncRequest: Optional<IAbortablePromise<ISyncResponse>> = null;
private syncState: Optional<SyncState> = null;
private syncStateData: Optional<ISyncStateData> = null; // additional data (eg. error object for failed sync)
private catchingUp = false;
private running = false;
private keepAliveTimer: ReturnType<typeof setTimeout> = null;
private connectionReturnedDefer: IDeferred<boolean> = null;
private keepAliveTimer: Optional<ReturnType<typeof setTimeout>> = null;
private connectionReturnedDefer: Optional<IDeferred<boolean>> = null;
private notifEvents: MatrixEvent[] = []; // accumulator of sync events in the current sync response
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
@@ -214,7 +214,7 @@ export class SyncApi {
* historical messages are shown when we paginate `/messages` again.
* @param {Room} room The room where the marker event was sent
* @param {MatrixEvent} markerEvent The new marker event
* @param {ISetStateOptions} setStateOptions When `timelineWasEmpty` is set
* @param {IMarkerFoundOptions} setStateOptions When `timelineWasEmpty` is set
* as `true`, the given marker event will be ignored
*/
private onMarkerStateEvent(
@@ -367,7 +367,7 @@ export class SyncApi {
// XXX: copypasted from /sync until we kill off this minging v1 API stuff)
// handle presence events (User objects)
if (response.presence && Array.isArray(response.presence)) {
if (Array.isArray(response.presence)) {
response.presence.map(client.getEventMapper()).forEach(
function(presenceEvent) {
let user = client.store.getUser(presenceEvent.getContent().user_id);
@@ -542,67 +542,40 @@ export class SyncApi {
return false;
}
/**
* Main entry point
*/
public sync(): void {
const client = this.client;
this.running = true;
if (global.window && global.window.addEventListener) {
global.window.addEventListener("online", this.onOnline, false);
}
let savedSyncPromise = Promise.resolve();
let savedSyncToken = null;
// We need to do one-off checks before we can begin the /sync loop.
// These are:
// 1) We need to get push rules so we can check if events should bing as we get
// them from /sync.
// 2) We need to get/create a filter which we can use for /sync.
// 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.
const getPushRules = async () => {
private getPushRules = async () => {
try {
debuglog("Getting push rules...");
const result = await client.getPushRules();
const result = await this.client.getPushRules();
debuglog("Got push rules");
client.pushRules = result;
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(savedSyncPromise, err);
getPushRules();
return;
await this.recoverFromSyncStartupError(this.savedSyncPromise, err);
return this.getPushRules(); // try again
}
checkLazyLoadStatus(); // advance to the next stage
};
const buildDefaultFilter = () => {
const filter = new Filter(client.credentials.userId);
filter.setTimelineLimit(this.opts.initialSyncLimit);
return filter;
private buildDefaultFilter = () => {
return new Filter(this.client.credentials.userId);
};
const checkLazyLoadStatus = async () => {
private checkLazyLoadStatus = async () => {
debuglog("Checking lazy load status...");
if (this.opts.lazyLoadMembers && client.isGuest()) {
if (this.opts.lazyLoadMembers && this.client.isGuest()) {
this.opts.lazyLoadMembers = false;
}
if (this.opts.lazyLoadMembers) {
debuglog("Checking server lazy load support...");
const supported = await client.doesServerSupportLazyLoading();
const supported = await this.client.doesServerSupportLazyLoading();
if (supported) {
debuglog("Enabling lazy load on sync filter...");
if (!this.opts.filter) {
this.opts.filter = buildDefaultFilter();
this.opts.filter = this.buildDefaultFilter();
}
this.opts.filter.setLazyLoadMembers(true);
} else {
@@ -626,8 +599,8 @@ export class SyncApi {
logger.warn("InvalidStoreError: store is not usable: stopping sync.");
return;
}
if (this.opts.lazyLoadMembers && this.opts.crypto) {
this.opts.crypto.enableLazyLoading();
if (this.opts.lazyLoadMembers) {
this.opts.crypto?.enableLazyLoading();
}
try {
debuglog("Storing client options...");
@@ -637,65 +610,60 @@ export class SyncApi {
logger.error("Storing client options failed", err);
throw err;
}
getFilter(); // Now get the filter and start syncing
};
const getFilter = async () => {
private getFilter = async (): Promise<{
filterId?: string;
filter?: Filter;
}> => {
debuglog("Getting filter...");
let filter;
let filter: Filter;
if (this.opts.filter) {
filter = this.opts.filter;
} else {
filter = buildDefaultFilter();
filter = this.buildDefaultFilter();
}
let filterId;
let filterId: string;
try {
filterId = await client.getOrCreateFilter(getFilterName(client.credentials.userId), filter);
filterId = await this.client.getOrCreateFilter(getFilterName(this.client.credentials.userId), filter);
} catch (err) {
logger.error("Getting filter failed", err);
if (this.shouldAbortSync(err)) return;
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;
await this.recoverFromSyncStartupError(this.savedSyncPromise, err);
return this.getFilter(); // try again
}
// 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...");
this.currentSyncRequest = this.doSyncRequest({ filterId }, savedSyncToken);
}
// Now wait for the saved sync to finish...
debuglog("Waiting for saved sync before starting sync processing...");
await savedSyncPromise;
this.doSync({ filterId });
return { filter, filterId };
};
if (client.isGuest()) {
private savedSyncPromise: Promise<void>;
/**
* Main entry point
*/
public async sync(): Promise<void> {
this.running = true;
global.window?.addEventListener?.("online", this.onOnline, false);
if (this.client.isGuest()) {
// no push rules for guests, no access to POST filter for guests.
this.doSync({});
} else {
return this.doSync({});
}
// 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) => {
const savedSyncTokenPromise = this.client.store.getSavedSyncToken().then(tok => {
debuglog("Got saved sync token");
savedSyncToken = tok;
debuglog("Getting saved sync...");
return client.store.getSavedSync();
}).then((savedSync) => {
return tok;
});
this.savedSyncPromise = this.client.store.getSavedSync().then((savedSync) => {
debuglog(`Got reply from saved sync, exists? ${!!savedSync}`);
if (savedSync) {
return this.syncFromCache(savedSync);
@@ -703,11 +671,54 @@ export class SyncApi {
}).catch(err => {
logger.error("Getting saved sync failed", err);
});
// We need to do one-off checks before we can begin the /sync loop.
// These are:
// 1) We need to get push rules so we can check if events should bing as we get
// them from /sync.
// 2) We need to get/create a filter which we can use for /sync.
// 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.
// 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();
await this.getPushRules();
await this.checkLazyLoadStatus();
const { filterId, filter } = await this.getFilter();
if (!filter) return; // bail, getFilter failed
// 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.
this.client.resetNotifTimelineSet();
if (this.currentSyncRequest === null) {
let firstSyncFilter = filterId;
const savedSyncToken = await savedSyncTokenPromise;
if (savedSyncToken) {
debuglog("Sending first sync request...");
} 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());
}
// 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.
this.currentSyncRequest = this.doSyncRequest({ filter: firstSyncFilter }, savedSyncToken);
}
// 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.
// Some platforms (e.g. React Native) register global.window,
// 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.currentSyncRequest?.abort();
if (this.keepAliveTimer) {
@@ -756,8 +765,7 @@ export class SyncApi {
this.client.store.setSyncToken(nextSyncToken);
// No previous sync, set old token to null
const syncEventData = {
oldSyncToken: null,
const syncEventData: ISyncStateData = {
nextSyncToken,
catchingUp: false,
fromCache: true,
@@ -792,21 +800,10 @@ export class SyncApi {
* @param {boolean} syncOptions.hasSyncedBefore
*/
private async doSync(syncOptions: ISyncOptions): Promise<void> {
const client = this.client;
while (this.running) {
const syncToken = this.client.store.getSyncToken();
if (!this.running) {
debuglog("Sync no longer running: exiting.");
if (this.connectionReturnedDefer) {
this.connectionReturnedDefer.reject();
this.connectionReturnedDefer = null;
}
this.updateSyncState(SyncState.Stopped);
return;
}
const syncToken = client.store.getSyncToken();
let data;
let data: ISyncResponse;
try {
//debuglog('Starting sync since=' + syncToken);
if (this.currentSyncRequest === null) {
@@ -814,8 +811,9 @@ export class SyncApi {
}
data = await this.currentSyncRequest;
} catch (e) {
this.onSyncError(e, syncOptions);
return;
const abort = await this.onSyncError(e);
if (abort) return;
continue;
} finally {
this.currentSyncRequest = null;
}
@@ -825,12 +823,12 @@ export class SyncApi {
// 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);
this.client.store.setSyncToken(data.next_batch);
// Reset after a successful sync
this.failedSyncCount = 0;
await client.store.setSyncData(data);
await this.client.store.setSyncData(data);
const syncEventData = {
oldSyncToken: syncToken,
@@ -873,7 +871,7 @@ export class SyncApi {
// keep emitting SYNCING -> SYNCING for clients who want to do bulk updates
this.updateSyncState(SyncState.Syncing, syncEventData);
if (client.store.wantsSave()) {
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
@@ -884,11 +882,18 @@ export class SyncApi {
}
// tell databases that everything is now in a consistent state and can be saved.
client.store.save();
this.client.store.save();
}
}
// Begin next sync
this.doSync(syncOptions);
if (!this.running) {
debuglog("Sync no longer running: exiting.");
if (this.connectionReturnedDefer) {
this.connectionReturnedDefer.reject();
this.connectionReturnedDefer = null;
}
this.updateSyncState(SyncState.Stopped);
}
}
private doSyncRequest(syncOptions: ISyncOptions, syncToken: string): IAbortablePromise<ISyncResponse> {
@@ -902,7 +907,7 @@ export class SyncApi {
private getSyncParams(syncOptions: ISyncOptions, syncToken: string): ISyncParams {
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
// as quickly as possible, even if there are no events queued. This
// serves two purposes:
@@ -918,13 +923,13 @@ export class SyncApi {
pollTimeout = 0;
}
let filterId = syncOptions.filterId;
if (this.client.isGuest() && !filterId) {
filterId = this.getGuestFilter();
let filter = syncOptions.filter;
if (this.client.isGuest() && !filter) {
filter = this.getGuestFilter();
}
const qps: ISyncParams = {
filter: filterId,
filter,
timeout: pollTimeout,
};
@@ -941,7 +946,7 @@ export class SyncApi {
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
// 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
@@ -952,7 +957,7 @@ export class SyncApi {
return qps;
}
private onSyncError(err: MatrixError, syncOptions: ISyncOptions): void {
private async onSyncError(err: MatrixError): Promise<boolean> {
if (!this.running) {
debuglog("Sync no longer running: exiting");
if (this.connectionReturnedDefer) {
@@ -960,14 +965,13 @@ export class SyncApi {
this.connectionReturnedDefer = null;
}
this.updateSyncState(SyncState.Stopped);
return;
return true; // abort
}
logger.error("/sync error %s", err);
logger.error(err);
if (this.shouldAbortSync(err)) {
return;
return true; // abort
}
this.failedSyncCount++;
@@ -981,20 +985,7 @@ export class SyncApi {
// erroneous. We set the state to 'reconnecting'
// instead, so that clients can observe this state
// if they wish.
this.startKeepAlives().then((connDidFail) => {
// 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);
});
const keepAlivePromise = this.startKeepAlives();
this.currentSyncRequest = null;
// Transition from RECONNECTING to ERROR after a given number of failed syncs
@@ -1003,6 +994,19 @@ export class SyncApi {
SyncState.Error : SyncState.Reconnecting,
{ 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.
// 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(
function(presenceEvent) {
let user = client.store.getUser(presenceEvent.getSender());
@@ -1077,7 +1081,7 @@ export class SyncApi {
}
// 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 prevEventsMap = events.reduce((m, c) => {
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
// server's for this case, and therefore will assume that our non-zero
// count is accurate.
if (!encrypted
|| (encrypted && room.getUnreadNotificationCount(NotificationCountType.Highlight) <= 0)) {
if (!encrypted || room.getUnreadNotificationCount(NotificationCountType.Highlight) <= 0) {
room.setUnreadNotificationCount(
NotificationCountType.Highlight,
joinObj.unread_notifications.highlight_count,
@@ -1232,8 +1235,7 @@ export class SyncApi {
if (joinObj.isBrandNewRoom) {
// set the back-pagination token. Do this *before* adding any
// events so that clients can start back-paginating.
room.getLiveTimeline().setPaginationToken(
joinObj.timeline.prev_batch, EventTimeline.BACKWARDS);
room.getLiveTimeline().setPaginationToken(joinObj.timeline.prev_batch, EventTimeline.BACKWARDS);
} else if (joinObj.timeline.limited) {
let limited = true;

View File

@@ -29,6 +29,30 @@ import { MatrixClient } from ".";
import { M_TIMESTAMP } from "./@types/location";
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.
* Omits any undefined/null values.
@@ -75,8 +99,7 @@ export function decodeParams(query: string): QueryDict {
* variables with. E.g. { "$bar": "baz" }.
* @return {string} The result of replacing all template variables e.g. '/foo/baz'.
*/
export function encodeUri(pathTemplate: string,
variables: Record<string, string>): string {
export function encodeUri(pathTemplate: string, variables: Record<string, string>): string {
for (const key in variables) {
if (!variables.hasOwnProperty(key)) {
continue;
@@ -216,33 +239,24 @@ export function deepCompare(x: any, y: any): boolean {
}
}
} 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
let p;
for (p in y) {
for (const p in y) {
if (y.hasOwnProperty(p) !== x.hasOwnProperty(p)) {
return false;
}
}
// finally, compare each of x's keys with y
for (p in y) { // eslint-disable-line guard-for-in
if (y.hasOwnProperty(p) !== x.hasOwnProperty(p)) {
return false;
}
if (!deepCompare(x[p], y[p])) {
for (const p in x) {
if (y.hasOwnProperty(p) !== x.hasOwnProperty(p) || !deepCompare(x[p], y[p])) {
return false;
}
}
}
/* jshint +W089 */
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
* sorts the result by key, recursively. The input object must
@@ -328,7 +342,7 @@ export function escapeRegExp(string: string): string {
return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
export function globToRegexp(glob: string, extended?: any): string {
export function globToRegexp(glob: string, extended = false): string {
// From
// https://github.com/matrix-org/synapse/blob/abbee6b29be80a77e05730707602f3bbfc3f38cb/synapse/push/__init__.py#L132
// 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) ])[] = [
[/\\\*/g, '.*'],
[/\?/g, '.'],
extended !== false && [
!extended && [
/\\\[(!|)(.*)\\]/g,
(_match: string, neg: string, pat: string) => [
'[',

View File

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

1277
yarn.lock

File diff suppressed because it is too large Load Diff