1
0
mirror of https://github.com/matrix-org/matrix-js-sdk.git synced 2025-11-25 05:23:13 +03:00

Merge branch 'develop' into matthew/fix-flaky-verif-test

This commit is contained in:
Matthew Hodgson
2022-05-23 20:53:59 +01:00
committed by GitHub
108 changed files with 6800 additions and 3963 deletions

View File

@@ -21,3 +21,6 @@ insert_final_newline = true
indent_style = space
indent_size = 4
trim_trailing_whitespace = true
[*.{yml,yaml}]
indent_size = 2

41
.git-blame-ignore-revs Normal file
View File

@@ -0,0 +1,41 @@
# Minor white-space adjustments
1d1d59c75744e1f6a2be1cb3e0d1bd9ded5f8025
# Import ordering and spacing: eslint-plugin-import
80aaa6c32b50601f82e0c991c24e5a4590f39463
# Minor white-space adjustment
8fb036ba2d01fab66dc4373802ccf19b5cac8541
# Minor white-space adjustment
b63de6a902a9e1f8ffd7697dea33820fc04f028e
3ca84cfc491b0987eec1f13f13cae58d2032bf54
# Conform to new typescript eslint rules
a87858840b57514603f63e2abbbda4f107f05a77
5cf6684129a921295f5593173f16f192336fe0a2
# Comply with new member-delimiter-style rule
b2ad957d298720d3e026b6bd91be0c403338361a
# Fix semicolons in TS files
e2ec8952e38b8fea3f0ccaa09ecb42feeba0d923
# Migrate to `eslint-plugin-matrix-org`
# and `babel/...` to `@babel/...` migration
09fac77ce0d9bcf6637088c29afab84084f0e739
102704e91a70643bcc09721e14b0d909f0ef55c6
# Eslint formatting
cec00cd303787fa9008b6c48826e75ed438036fa
# Minor eslint changes
68bb8182e4e62d8f450f80c408c4b231b8725f1b
c979ff6696e30ab8983ac416a3590996d84d3560
f4a7395e3a3751a1a8e92dd302c49175a3296ad2
# eslint --fix for dangley commas on function calls
423175f5397910b0afe3112d6fb18283fc7d27d4
# eslint ---fix for prefer-const
7bca05af644e8b997dae81e568a3913d8f18d7ca
# Fix linting on tests
cee7f7a280a8c20bafc21c0a2911f60851f7a7ca
# eslint --fix
0fa9f7c6098822db1ae214f352fd1fe5c248b02c
# eslint --fix for lots of white-space
5abf6b9f208801c5022a47023150b5846cb0b309
# eslint --fix
7ed65407e6cdf292ce3cf659310c68d19dcd52b2
# Switch to ESLint from JSHint (Google eslint rules as a base)
e057956ede9ad1a931ff8050c411aca7907e0394

6
.github/codecov.yml vendored
View File

@@ -1,6 +0,0 @@
comment:
layout: "diff, files"
behavior: default
require_changes: false
require_base: no
require_head: no

View File

@@ -0,0 +1,25 @@
name: Notify Downstream Projects
on:
push:
branches: [ develop ]
concurrency: ${{ github.workflow }}-${{ github.ref }}
jobs:
notify-downstream:
continue-on-error: true
strategy:
fail-fast: false
matrix:
include:
- repo: vector-im/element-web
event: element-web-notify
- repo: matrix-org/matrix-react-sdk
event: upstream-sdk-notify
runs-on: ubuntu-latest
steps:
- name: Notify matrix-react-sdk repo that a new SDK build is on develop so it can CI against it
uses: peter-evans/repository-dispatch@v1
with:
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
repository: ${{ matrix.repo }}
event-type: ${{ matrix.event }}

59
.github/workflows/pr_details.yml vendored Normal file
View File

@@ -0,0 +1,59 @@
# Find details about the PR associated with this ref
# Outputs:
# prnumber: the ID number of the associated PR
# headref: the name of the head branch of the PR
# baseref: the name of the base branch of the PR
name: PR Details
on:
workflow_call:
inputs:
owner:
type: string
required: true
description: The github username of the owner of the head branch
branch:
type: string
required: true
description: The name of the head branch
outputs:
pr_id:
description: The ID of the PR found
value: ${{ jobs.prdetails.outputs.pr_id }}
head_branch:
description: The head branch of the PR found
value: ${{ jobs.prdetails.outputs.head_branch }}
base_branch:
description: The base branch of the PR found
value: ${{ jobs.prdetails.outputs.base_branch }}
jobs:
prdetails:
name: Find PR Details
runs-on: ubuntu-latest
steps:
- name: "🔍 Read PR details"
id: details
# We need to find the PR number that corresponds to the branch, which we do by searching the GH API
# The workflow_run event includes a list of pull requests, but it doesn't get populated for
# forked PRs: https://docs.github.com/en/rest/reference/checks#create-a-check-run
run: |
head_branch='${{ inputs.owner }}:${{ inputs.branch }}'
echo "Head branch: $head_branch"
pulls_uri="https://api.github.com/repos/${{ github.repository }}/pulls?head=$(jq -Rr '@uri' <<<$head_branch)"
pr_data=$(curl -s -H 'Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}' "$pulls_uri")
pr_number=$(jq -r '.[] | .number' <<< "$pr_data")
echo "PR number: $pr_number"
echo "::set-output name=prnumber::$pr_number"
head_ref=$(jq -r '.[] | .head.ref' <<< "$pr_data")
echo "Head ref: $head_ref"
echo "::set-output name=headref::$head_ref"
base_ref=$(jq -r '.[] | .base.ref' <<< "$pr_data")
echo "Base ref: $base_ref"
echo "::set-output name=baseref::$base_ref"
outputs:
pr_id: ${{ steps.details.outputs.prnumber }}
head_branch: ${{ steps.details.outputs.headref }}
base_branch: ${{ steps.details.outputs.baseref }}

View File

@@ -1,12 +0,0 @@
name: Preview Changelog
on:
pull_request_target:
types: [ opened, edited, labeled ]
jobs:
changelog:
runs-on: ubuntu-latest
steps:
- name: Preview Changelog
uses: matrix-org/allchange@main
with:
ghToken: ${{ secrets.GITHUB_TOKEN }}

28
.github/workflows/pull_request.yaml vendored Normal file
View File

@@ -0,0 +1,28 @@
name: Pull Request
on:
pull_request_target:
types: [ opened, edited, labeled, unlabeled, synchronize ]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
changelog:
name: Preview Changelog
if: github.event.action != 'synchronize'
runs-on: ubuntu-latest
steps:
- uses: matrix-org/allchange@main
with:
ghToken: ${{ secrets.GITHUB_TOKEN }}
enforce-label:
name: Enforce Labels
runs-on: ubuntu-latest
permissions:
pull-requests: read
steps:
- uses: yogevbd/enforce-label-action@2.1.0
with:
REQUIRED_LABELS_ANY: "T-Defect,T-Deprecation,T-Enhancement,T-Task"
BANNED_LABELS: "X-Blocked"
BANNED_LABELS_DESCRIPTION: "Preventing merge whilst PR is marked blocked!"

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

@@ -0,0 +1,91 @@
name: SonarCloud
on:
workflow_call:
inputs:
repo:
type: string
required: true
description: The full name of the repo in org/repo format
head_branch:
type: string
required: true
description: The name of the head branch
# We cannot use ${{ github.sha }} here as for pull requests it'll be a simulated merge commit instead
revision:
type: string
required: true
description: The git revision with which this sonar run should be associated
# Coverage specific parameters, assumes coverage reports live in a /coverage/ directory
coverage_workflow_name:
type: string
required: false
description: The name of the workflow which uploaded the `coverage` artifact, if any
coverage_run_id:
type: string
required: false
description: The run_id of the workflow which upload the coverage relevant to this run
# PR specific parameters
pr_id:
type: string
required: false
description: The ID number of the PR if this workflow is being triggered due to one
base_branch:
type: string
required: false
description: The base branch of the PR if this workflow is being triggered due to one
# Org specific parameters
main_branch:
type: string
required: false
description: The default branch of the repository
default: "develop"
secrets:
SONAR_TOKEN:
required: true
jobs:
analysis:
name: Analysis
runs-on: ubuntu-latest
steps:
- name: "🧮 Checkout code"
uses: actions/checkout@v3
with:
repository: ${{ inputs.repo }}
ref: ${{ inputs.head_branch }} # checkout commit that triggered this workflow
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
# Fetch develop so that Sonar can identify new issues in PR builds
- name: "📕 Fetch ${{ inputs.main_branch }}"
if: inputs.head_branch != inputs.main_branch
run: git rev-parse HEAD && git fetch origin ${{ inputs.main_branch }}:${{ inputs.main_branch }} && git status && git rev-parse HEAD
# There's a 'download artifact' action, but it hasn't been updated for the workflow_run action
# (https://github.com/actions/download-artifact/issues/60) so instead we get this mess:
- name: "📥 Download Coverage Report"
uses: dawidd6/action-download-artifact@v2
if: inputs.coverage_workflow_name
with:
workflow: ${{ inputs.coverage_workflow_name }}
run_id: ${{ inputs.coverage_run_id }}
name: coverage
path: coverage
- name: "🔍 Read package.json version"
id: version
uses: martinbeentjes/npm-get-version-action@main
- name: "🩻 SonarCloud Scan"
uses: SonarSource/sonarcloud-github-action@master
with:
args: >
-Dsonar.projectVersion=${{ steps.version.outputs.current-version }}
-Dsonar.scm.revision=${{ inputs.revision }}
-Dsonar.pullrequest.key=${{ inputs.pr_id }}
-Dsonar.pullrequest.branch=${{ inputs.pr_id && inputs.head_branch }}
-Dsonar.pullrequest.base=${{ inputs.pr_id && inputs.base_branch }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}

37
.github/workflows/sonarqube.yml vendored Normal file
View File

@@ -0,0 +1,37 @@
name: SonarQube
on:
workflow_run:
workflows: [ "Tests" ]
types:
- completed
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
prdetails:
name: PR Details
if: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'pull_request'
uses: matrix-org/matrix-js-sdk/.github/workflows/pr_details.yml@develop
with:
owner: ${{ github.event.workflow_run.head_repository.owner.login }}
branch: ${{ github.event.workflow_run.head_branch }}
sonarqube:
name: 🩻 SonarQube
needs: prdetails
# Only wait for prdetails if it isn't skipped
if: |
always() &&
(needs.prdetails.result == 'success' || needs.prdetails.result == 'skipped') &&
github.event.workflow_run.conclusion == 'success'
uses: matrix-org/matrix-js-sdk/.github/workflows/sonarcloud.yml@develop
with:
repo: ${{ github.event.workflow_run.head_repository.full_name }}
pr_id: ${{ needs.prdetails.outputs.pr_id }}
head_branch: ${{ needs.prdetails.outputs.head_branch || github.event.workflow_run.head_branch }}
base_branch: ${{ needs.prdetails.outputs.base_branch }}
revision: ${{ github.event.workflow_run.head_sha }}
coverage_workflow_name: tests.yml
coverage_run_id: ${{ github.event.workflow_run.id }}
secrets:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}

56
.github/workflows/static_analysis.yml vendored Normal file
View File

@@ -0,0 +1,56 @@
name: Static Analysis
on:
pull_request: { }
push:
branches: [ develop, master ]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
ts_lint:
name: "Typescript Syntax Check"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v3
with:
cache: 'yarn'
- name: Install Deps
run: "yarn install"
- name: Typecheck
run: "yarn run lint:types"
js_lint:
name: "ESLint"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v3
with:
cache: 'yarn'
- name: Install Deps
run: "yarn install"
- name: Run Linter
run: "yarn run lint:js"
docs:
name: "JSDoc Checker"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v3
with:
cache: 'yarn'
- name: Install Deps
run: "yarn install"
- name: Generate Docs
run: "yarn run gendoc"

View File

@@ -1,19 +0,0 @@
name: Test coverage
on:
pull_request: {}
push:
branches: [develop, main, master]
jobs:
test-coverage:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Run tests with coverage
run: "yarn install && yarn build && yarn coverage"
- name: Upload coverage
uses: codecov/codecov-action@v2
with:
verbose: true

37
.github/workflows/tests.yml vendored Normal file
View File

@@ -0,0 +1,37 @@
name: Tests
on:
pull_request: { }
push:
branches: [ develop, master ]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
jest:
name: Jest
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Yarn cache
uses: actions/setup-node@v3
with:
cache: 'yarn'
- name: Install dependencies
run: "yarn install"
- name: Build
run: "yarn build"
- name: Run tests with coverage
run: "yarn coverage --ci"
- name: Upload Artifact
uses: actions/upload-artifact@v2
with:
name: coverage
path: |
coverage
!coverage/lcov-report

View File

@@ -0,0 +1,38 @@
name: Upgrade Dependencies
on:
workflow_dispatch: { }
workflow_call:
secrets:
ELEMENT_BOT_TOKEN:
required: true
jobs:
upgrade:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
cache: 'yarn'
- name: Upgrade
run: yarn upgrade && yarn install
- name: Create Pull Request
id: cpr
uses: peter-evans/create-pull-request@v4
with:
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
branch: actions/upgrade-deps
delete-branch: true
title: Upgrade dependencies
labels: |
Dependencies
T-Task
- name: Enable automerge
uses: peter-evans/enable-pull-request-automerge@v2
if: steps.cpr.outputs.pull-request-operation == 'created'
with:
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
pull-request-number: ${{ steps.cpr.outputs.pull-request-number }}

View File

@@ -1,3 +1,75 @@
Changes in [17.2.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v17.2.0) (2022-05-10)
==================================================================================================
## ✨ Features
* Live location sharing: handle encrypted messages in processBeaconEvents ([\#2327](https://github.com/matrix-org/matrix-js-sdk/pull/2327)).
## 🐛 Bug Fixes
* Fix race conditions around threads ([\#2331](https://github.com/matrix-org/matrix-js-sdk/pull/2331)). Fixes vector-im/element-web#21627.
* Ignore m.replace relations on state events, they're invalid ([\#2306](https://github.com/matrix-org/matrix-js-sdk/pull/2306)). Fixes vector-im/element-web#21851.
* fix example in readme ([\#2315](https://github.com/matrix-org/matrix-js-sdk/pull/2315)).
* Don't decrement the length count of a thread when root redacted ([\#2314](https://github.com/matrix-org/matrix-js-sdk/pull/2314)).
* Prevent attempt to create thread with id "undefined" ([\#2308](https://github.com/matrix-org/matrix-js-sdk/pull/2308)).
* Update threads handling for replies-to-thread-responses as per MSC update ([\#2305](https://github.com/matrix-org/matrix-js-sdk/pull/2305)). Fixes vector-im/element-web#19678.
Changes in [17.1.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v17.1.0) (2022-04-26)
==================================================================================================
## ✨ Features
* Add MatrixClient.doesServerSupportLogoutDevices() for MSC2457 ([\#2297](https://github.com/matrix-org/matrix-js-sdk/pull/2297)).
* Live location sharing - expose room liveBeaconIds ([\#2296](https://github.com/matrix-org/matrix-js-sdk/pull/2296)).
* Support for MSC2457 logout_devices param for setPassword() ([\#2285](https://github.com/matrix-org/matrix-js-sdk/pull/2285)).
* Stabilise token authenticated registration support ([\#2181](https://github.com/matrix-org/matrix-js-sdk/pull/2181)). Contributed by @govynnus.
* Live location sharing - Aggregate beacon locations on beacons ([\#2268](https://github.com/matrix-org/matrix-js-sdk/pull/2268)).
## 🐛 Bug Fixes
* Prevent duplicated re-emitter setups in event-mapper ([\#2293](https://github.com/matrix-org/matrix-js-sdk/pull/2293)).
* Make self membership less prone to races ([\#2277](https://github.com/matrix-org/matrix-js-sdk/pull/2277)). Fixes vector-im/element-web#21661.
Changes in [17.0.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v17.0.0) (2022-04-11)
==================================================================================================
## 🚨 BREAKING CHANGES
* Remove groups and groups-related APIs ([\#2234](https://github.com/matrix-org/matrix-js-sdk/pull/2234)).
## ✨ Features
* Add Element video room type ([\#2273](https://github.com/matrix-org/matrix-js-sdk/pull/2273)).
* Live location sharing - handle redacted beacons ([\#2269](https://github.com/matrix-org/matrix-js-sdk/pull/2269)).
## 🐛 Bug Fixes
* Fix getSessionsNeedingBackup() limit support ([\#2270](https://github.com/matrix-org/matrix-js-sdk/pull/2270)). Contributed by @adamvy.
* Fix issues with /search and /context API handling for threads ([\#2261](https://github.com/matrix-org/matrix-js-sdk/pull/2261)). Fixes vector-im/element-web#21543.
* Prevent exception 'Unable to set up secret storage' ([\#2260](https://github.com/matrix-org/matrix-js-sdk/pull/2260)).
Changes in [16.0.2-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v16.0.2-rc.1) (2022-04-05)
============================================================================================================
## 🚨 BREAKING CHANGES
* Remove groups and groups-related APIs ([\#2234](https://github.com/matrix-org/matrix-js-sdk/pull/2234)).
## ✨ Features
* Add Element video room type ([\#2273](https://github.com/matrix-org/matrix-js-sdk/pull/2273)).
* Live location sharing - handle redacted beacons ([\#2269](https://github.com/matrix-org/matrix-js-sdk/pull/2269)).
## 🐛 Bug Fixes
* Fix getSessionsNeedingBackup() limit support ([\#2270](https://github.com/matrix-org/matrix-js-sdk/pull/2270)). Contributed by @adamvy.
* Fix issues with /search and /context API handling for threads ([\#2261](https://github.com/matrix-org/matrix-js-sdk/pull/2261)). Fixes vector-im/element-web#21543.
* Prevent exception 'Unable to set up secret storage' ([\#2260](https://github.com/matrix-org/matrix-js-sdk/pull/2260)).
Changes in [16.0.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v16.0.1) (2022-03-28)
==================================================================================================
## ✨ Features
* emit aggregate room beacon liveness ([\#2241](https://github.com/matrix-org/matrix-js-sdk/pull/2241)).
* Live location sharing - create m.beacon_info events ([\#2238](https://github.com/matrix-org/matrix-js-sdk/pull/2238)).
* Beacon event types from MSC3489 ([\#2230](https://github.com/matrix-org/matrix-js-sdk/pull/2230)).
## 🐛 Bug Fixes
* Fix incorrect usage of unstable variant of `is_falling_back` ([\#2227](https://github.com/matrix-org/matrix-js-sdk/pull/2227)).
Changes in [16.0.1-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v16.0.1-rc.1) (2022-03-22)
============================================================================================================
Changes in [16.0.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v16.0.0) (2022-03-15)
==================================================================================================
@@ -1974,6 +2046,12 @@ All Changes
* [BREAKING] Refactor the entire build process
[\#1113](https://github.com/matrix-org/matrix-js-sdk/pull/1113)
Changes in [3.42.2-rc.3](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v3.42.2-rc.3) (2022-04-08)
============================================================================================================
## 🐛 Bug Fixes
* Make self membership less prone to races ([\#2277](https://github.com/matrix-org/matrix-js-sdk/pull/2277)). Fixes vector-im/element-web#21661.
Changes in [3.0.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v3.0.0) (2020-01-13)
================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v3.0.0-rc.1...v3.0.0)

View File

@@ -243,3 +243,18 @@ on Git 2.17+ you can mass signoff using rebase:
```
git rebase --signoff origin/develop
```
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.

View File

@@ -1,3 +1,11 @@
[![npm](https://img.shields.io/npm/v/matrix-js-sdk)](https://www.npmjs.com/package/matrix-js-sdk)
![Tests](https://github.com/matrix-org/matrix-js-sdk/actions/workflows/tests.yml/badge.svg)
![Static Analysis](https://github.com/matrix-org/matrix-js-sdk/actions/workflows/static_analysis.yml/badge.svg)
[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=matrix-js-sdk&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=matrix-js-sdk)
[![Coverage](https://sonarcloud.io/api/project_badges/measure?project=matrix-js-sdk&metric=coverage)](https://sonarcloud.io/summary/new_code?id=matrix-js-sdk)
[![Vulnerabilities](https://sonarcloud.io/api/project_badges/measure?project=matrix-js-sdk&metric=vulnerabilities)](https://sonarcloud.io/summary/new_code?id=matrix-js-sdk)
[![Bugs](https://sonarcloud.io/api/project_badges/measure?project=matrix-js-sdk&metric=bugs)](https://sonarcloud.io/summary/new_code?id=matrix-js-sdk)
Matrix Javascript SDK
=====================
@@ -26,7 +34,7 @@ In Node.js
Ensure you have the latest LTS version of Node.js installed.
This SDK targets Node 10 for compatibility, which translates to ES6. If you're using
This SDK targets Node 12 for compatibility, which translates to ES6. If you're using
a bundler like webpack you'll likely have to transpile dependencies, including this
SDK, to match your target browsers.
@@ -307,7 +315,7 @@ The SDK supports end-to-end encryption via the Olm and Megolm protocols, using
[libolm](https://gitlab.matrix.org/matrix-org/olm). It is left up to the
application to make libolm available, via the ``Olm`` global.
It is also necessary to call ``matrixClient.initCrypto()`` after creating a new
It is also necessary to call ``await matrixClient.initCrypto()`` after creating a new
``MatrixClient`` (but **before** calling ``matrixClient.startClient()``) to
initialise the crypto layer.

View File

@@ -341,7 +341,7 @@ function printLine(event) {
var maxNameWidth = 15;
if (name.length > maxNameWidth) {
name = name.substr(0, maxNameWidth-1) + "\u2026";
name = name.slice(0, maxNameWidth-1) + "\u2026";
}
if (event.getType() === "m.room.message") {
@@ -398,7 +398,7 @@ function print(str, formatter) {
function fixWidth(str, len) {
if (str.length > len) {
return str.substr(0, len-2) + "\u2026";
return str.substring(0, len-2) + "\u2026";
}
else if (str.length < len) {
return str + new Array(len - str.length).join(" ");

View File

@@ -1,7 +1,10 @@
{
"name": "matrix-js-sdk",
"version": "16.0.0",
"version": "17.2.0",
"description": "Matrix Client-Server SDK for Javascript",
"engines": {
"node": ">=12.9.0"
},
"scripts": {
"prepublishOnly": "yarn build",
"start": "echo THIS IS FOR LEGACY PURPOSES ONLY. && babel src -w -s -d lib --verbose --extensions \".ts,.js\"",
@@ -97,6 +100,7 @@
"fake-indexeddb": "^3.1.2",
"jest": "^26.6.3",
"jest-localstorage-mock": "^2.4.6",
"jest-sonar-reporter": "^2.0.0",
"jsdoc": "^3.6.6",
"matrix-mock-request": "^1.2.3",
"rimraf": "^3.0.2",
@@ -113,8 +117,13 @@
"<rootDir>/src/**/*.{js,ts}"
],
"coverageReporters": [
"text",
"json"
]
"text-summary",
"lcov"
],
"testResultsProcessor": "jest-sonar-reporter"
},
"jestSonar": {
"reportPath": "coverage",
"sonar56x": true
}
}

16
sonar-project.properties Normal file
View File

@@ -0,0 +1,16 @@
sonar.projectKey=matrix-js-sdk
sonar.organization=matrix-org
# Encoding of the source code. Default is default system encoding
#sonar.sourceEncoding=UTF-8
sonar.sources=src
sonar.tests=spec
sonar.exclusions=docs,examples,git-hooks
sonar.typescript.tsconfigPath=./tsconfig.json
sonar.javascript.lcov.reportPaths=coverage/lcov.info
sonar.coverage.exclusions=spec/**/*
sonar.testExecutionReportPaths=coverage/test-report.xml
sonar.lang.patterns.ts=**/*.ts,**/*.tsx

View File

@@ -47,7 +47,7 @@ describe("Browserify Test", function() {
httpBackend.stop();
});
it("Sync", async function() {
it("Sync", function() {
const event = utils.mkMembership({
room: ROOM_ID,
mship: "join",
@@ -71,7 +71,7 @@ describe("Browserify Test", function() {
};
httpBackend.when("GET", "/sync").respond(200, syncData);
return await Promise.race([
return Promise.race([
httpBackend.flushAllExpected(),
new Promise((_, reject) => {
client.once("sync.unexpectedError", reject);

View File

@@ -2,6 +2,7 @@ import * as utils from "../test-utils/test-utils";
import { EventTimeline } from "../../src/matrix";
import { logger } from "../../src/logger";
import { TestClient } from "../TestClient";
import { Thread, THREAD_RELATION_TYPE } from "../../src/models/thread";
const userId = "@alice:localhost";
const userName = "Alice";
@@ -69,6 +70,27 @@ const EVENTS = [
}),
];
const THREAD_ROOT = utils.mkMessage({
room: roomId,
user: userId,
msg: "thread root",
});
const THREAD_REPLY = utils.mkEvent({
room: roomId,
user: userId,
type: "m.room.message",
content: {
"body": "thread reply",
"msgtype": "m.text",
"m.relates_to": {
// We can't use the const here because we change server support mode for test
rel_type: "io.element.thread",
event_id: THREAD_ROOT.event_id,
},
},
});
// start the client, and wait for it to initialise
function startClient(httpBackend, client) {
httpBackend.when("GET", "/versions").respond(200, {});
@@ -116,9 +138,7 @@ describe("getEventTimeline support", function() {
return startClient(httpBackend, client).then(function() {
const room = client.getRoom(roomId);
const timelineSet = room.getTimelineSets()[0];
expect(function() {
client.getEventTimeline(timelineSet, "event");
}).toThrow();
expect(client.getEventTimeline(timelineSet, "event")).rejects.toBeTruthy();
});
});
@@ -136,16 +156,12 @@ describe("getEventTimeline support", function() {
return startClient(httpBackend, client).then(() => {
const room = client.getRoom(roomId);
const timelineSet = room.getTimelineSets()[0];
expect(function() {
client.getEventTimeline(timelineSet, "event");
}).not.toThrow();
expect(client.getEventTimeline(timelineSet, "event")).rejects.toBeFalsy();
});
});
it("scrollback should be able to scroll back to before a gappy /sync",
function() {
it("scrollback should be able to scroll back to before a gappy /sync", function() {
// need a client with timelineSupport disabled to make this work
let room;
return startClient(httpBackend, client).then(function() {
@@ -229,6 +245,7 @@ describe("MatrixClient event timelines", function() {
afterEach(function() {
httpBackend.verifyNoOutstandingExpectation();
client.stopClient();
Thread.setServerSideSupport(false);
});
describe("getEventTimeline", function() {
@@ -355,8 +372,7 @@ describe("MatrixClient event timelines", function() {
]);
});
it("should join timelines where they overlap a previous /context",
function() {
it("should join timelines where they overlap a previous /context", function() {
const room = client.getRoom(roomId);
const timelineSet = room.getTimelineSets()[0];
@@ -478,6 +494,50 @@ describe("MatrixClient event timelines", function() {
httpBackend.flushAllExpected(),
]);
});
it("should handle thread replies with server support by fetching a contiguous thread timeline", async () => {
client.clientOpts.experimentalThreadSupport = true;
Thread.setServerSideSupport(true);
client.stopClient(); // we don't need the client to be syncing at this time
const room = client.getRoom(roomId);
const timelineSet = room.getTimelineSets()[0];
httpBackend.when("GET", "/rooms/!foo%3Abar/context/" + encodeURIComponent(THREAD_REPLY.event_id))
.respond(200, function() {
return {
start: "start_token0",
events_before: [],
event: THREAD_REPLY,
events_after: [],
end: "end_token0",
state: [],
};
});
httpBackend.when("GET", "/rooms/!foo%3Abar/event/" + encodeURIComponent(THREAD_ROOT.event_id))
.respond(200, function() {
return THREAD_ROOT;
});
httpBackend.when("GET", "/rooms/!foo%3Abar/relations/" +
encodeURIComponent(THREAD_ROOT.event_id) + "/" +
encodeURIComponent(THREAD_RELATION_TYPE.name) + "?limit=20")
.respond(200, function() {
return {
original_event: THREAD_ROOT,
chunk: [THREAD_REPLY],
// no next batch as this is the oldest end of the timeline
};
});
const timelinePromise = client.getEventTimeline(timelineSet, THREAD_REPLY.event_id);
await httpBackend.flushAllExpected();
const timeline = await timelinePromise;
expect(timeline.getEvents().find(e => e.getId() === THREAD_ROOT.event_id)).toBeTruthy();
expect(timeline.getEvents().find(e => e.getId() === THREAD_REPLY.event_id)).toBeTruthy();
});
});
describe("paginateEventTimeline", function() {

View File

@@ -1,8 +1,25 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import * as utils from "../test-utils/test-utils";
import { CRYPTO_ENABLED } from "../../src/client";
import { MatrixEvent } from "../../src/models/event";
import { Filter, MemoryStore, Room } from "../../src/matrix";
import { TestClient } from "../TestClient";
import { THREAD_RELATION_TYPE } from "../../src/models/thread";
describe("MatrixClient", function() {
let client = null;
@@ -14,9 +31,7 @@ describe("MatrixClient", function() {
beforeEach(function() {
store = new MemoryStore();
const testClient = new TestClient(userId, "aliceDevice", accessToken, undefined, {
store: store,
});
const testClient = new TestClient(userId, "aliceDevice", accessToken, undefined, { store });
httpBackend = testClient.httpBackend;
client = testClient.client;
});
@@ -146,12 +161,14 @@ describe("MatrixClient", function() {
describe("joinRoom", function() {
it("should no-op if you've already joined a room", function() {
const roomId = "!foo:bar";
const room = new Room(roomId, userId);
const room = new Room(roomId, client, userId);
client.fetchRoomEvent = () => Promise.resolve({});
room.addLiveEvents([
utils.mkMembership({
user: userId, room: roomId, mship: "join", event: true,
}),
]);
httpBackend.verifyNoOutstandingRequests();
store.storeRoom(room);
client.joinRoom(roomId);
httpBackend.verifyNoOutstandingRequests();
@@ -244,14 +261,15 @@ describe("MatrixClient", function() {
});
describe("searching", function() {
it("searchMessageText should perform a /search for room_events", function() {
const response = {
search_categories: {
room_events: {
count: 24,
results: {
"$flibble:localhost": {
results: [{
rank: 0.1,
result: {
event_id: "$flibble:localhost",
type: "m.room.message",
user_id: "@alice:localhost",
room_id: "!feuiwhf:localhost",
@@ -260,13 +278,11 @@ describe("MatrixClient", function() {
msgtype: "m.text",
},
},
},
},
}],
},
},
};
it("searchMessageText should perform a /search for room_events", function(done) {
client.searchMessageText({
query: "monkeys",
});
@@ -280,8 +296,171 @@ describe("MatrixClient", function() {
});
}).respond(200, response);
httpBackend.flush().then(function() {
done();
return httpBackend.flush();
});
describe("should filter out context from different timelines (threads)", () => {
it("filters out thread replies when result is in the main timeline", async () => {
const response = {
search_categories: {
room_events: {
count: 24,
results: [{
rank: 0.1,
result: {
event_id: "$flibble:localhost",
type: "m.room.message",
user_id: "@alice:localhost",
room_id: "!feuiwhf:localhost",
content: {
body: "main timeline",
msgtype: "m.text",
},
},
context: {
events_after: [{
event_id: "$ev-after:server",
type: "m.room.message",
user_id: "@alice:localhost",
room_id: "!feuiwhf:localhost",
content: {
"body": "thread reply",
"msgtype": "m.text",
"m.relates_to": {
"event_id": "$some-thread:server",
"rel_type": THREAD_RELATION_TYPE.name,
},
},
}],
events_before: [{
event_id: "$ev-before:server",
type: "m.room.message",
user_id: "@alice:localhost",
room_id: "!feuiwhf:localhost",
content: {
body: "main timeline again",
msgtype: "m.text",
},
}],
},
}],
},
},
};
const data = {
results: [],
highlights: [],
};
client.processRoomEventsSearch(data, response);
expect(data.results).toHaveLength(1);
expect(data.results[0].context.timeline).toHaveLength(2);
expect(data.results[0].context.timeline.find(e => e.getId() === "$ev-after:server")).toBeFalsy();
});
it("filters out thread replies from threads other than the thread the result replied to", () => {
const response = {
search_categories: {
room_events: {
count: 24,
results: [{
rank: 0.1,
result: {
event_id: "$flibble:localhost",
type: "m.room.message",
user_id: "@alice:localhost",
room_id: "!feuiwhf:localhost",
content: {
"body": "thread 1 reply 1",
"msgtype": "m.text",
"m.relates_to": {
"event_id": "$thread1:server",
"rel_type": THREAD_RELATION_TYPE.name,
},
},
},
context: {
events_after: [{
event_id: "$ev-after:server",
type: "m.room.message",
user_id: "@alice:localhost",
room_id: "!feuiwhf:localhost",
content: {
"body": "thread 2 reply 2",
"msgtype": "m.text",
"m.relates_to": {
"event_id": "$thread2:server",
"rel_type": THREAD_RELATION_TYPE.name,
},
},
}],
events_before: [],
},
}],
},
},
};
const data = {
results: [],
highlights: [],
};
client.processRoomEventsSearch(data, response);
expect(data.results).toHaveLength(1);
expect(data.results[0].context.timeline).toHaveLength(1);
expect(data.results[0].context.timeline.find(e => e.getId() === "$flibble:localhost")).toBeTruthy();
});
it("filters out main timeline events when result is a thread reply", () => {
const response = {
search_categories: {
room_events: {
count: 24,
results: [{
rank: 0.1,
result: {
event_id: "$flibble:localhost",
type: "m.room.message",
user_id: "@alice:localhost",
room_id: "!feuiwhf:localhost",
content: {
"body": "thread 1 reply 1",
"msgtype": "m.text",
"m.relates_to": {
"event_id": "$thread1:server",
"rel_type": THREAD_RELATION_TYPE.name,
},
},
},
context: {
events_after: [{
event_id: "$ev-after:server",
type: "m.room.message",
user_id: "@alice:localhost",
room_id: "!feuiwhf:localhost",
content: {
"body": "main timeline",
"msgtype": "m.text",
},
}],
events_before: [],
},
}],
},
},
};
const data = {
results: [],
highlights: [],
};
client.processRoomEventsSearch(data, response);
expect(data.results).toHaveLength(1);
expect(data.results[0].context.timeline).toHaveLength(1);
expect(data.results[0].context.timeline.find(e => e.getId() === "$flibble:localhost")).toBeTruthy();
});
});
});
@@ -395,9 +574,14 @@ describe("MatrixClient", function() {
});
describe("partitionThreadedEvents", function() {
let room;
beforeEach(() => {
room = new Room("!STrMRsukXHtqQdSeHa:matrix.org", client, userId);
});
it("returns empty arrays when given an empty arrays", function() {
const events = [];
const [timeline, threaded] = client.partitionThreadedEvents(events);
const [timeline, threaded] = room.partitionThreadedEvents(events);
expect(timeline).toEqual([]);
expect(threaded).toEqual([]);
});
@@ -405,24 +589,24 @@ describe("MatrixClient", function() {
it("copies pre-thread in-timeline vote events onto both timelines", function() {
client.clientOpts = { experimentalThreadSupport: true };
const eventMessageInThread = buildEventMessageInThread();
const eventPollResponseReference = buildEventPollResponseReference();
const eventPollStartThreadRoot = buildEventPollStartThreadRoot();
const eventMessageInThread = buildEventMessageInThread(eventPollStartThreadRoot);
const events = [
eventPollStartThreadRoot,
eventMessageInThread,
eventPollResponseReference,
eventPollStartThreadRoot,
];
// Vote has no threadId yet
expect(eventPollResponseReference.threadId).toBeFalsy();
const [timeline, threaded] = client.partitionThreadedEvents(events);
const [timeline, threaded] = room.partitionThreadedEvents(events);
expect(timeline).toEqual([
// The message that was sent in a thread is missing
eventPollResponseReference,
eventPollStartThreadRoot,
eventPollResponseReference,
]);
// The vote event has been copied into the thread
@@ -431,33 +615,34 @@ describe("MatrixClient", function() {
expect(eventRefWithThreadId.threadId).toBeTruthy();
expect(threaded).toEqual([
eventPollStartThreadRoot,
eventMessageInThread,
eventRefWithThreadId,
// Thread does not see thread root
]);
});
it("copies pre-thread in-timeline reactions onto both timelines", function() {
client.clientOpts = { experimentalThreadSupport: true };
const eventMessageInThread = buildEventMessageInThread();
const eventReaction = buildEventReaction();
const eventPollStartThreadRoot = buildEventPollStartThreadRoot();
const eventMessageInThread = buildEventMessageInThread(eventPollStartThreadRoot);
const eventReaction = buildEventReaction(eventPollStartThreadRoot);
const events = [
eventPollStartThreadRoot,
eventMessageInThread,
eventReaction,
eventPollStartThreadRoot,
];
const [timeline, threaded] = client.partitionThreadedEvents(events);
const [timeline, threaded] = room.partitionThreadedEvents(events);
expect(timeline).toEqual([
eventReaction,
eventPollStartThreadRoot,
eventReaction,
]);
expect(threaded).toEqual([
eventPollStartThreadRoot,
eventMessageInThread,
withThreadId(eventReaction, eventPollStartThreadRoot.getId()),
]);
@@ -467,23 +652,24 @@ describe("MatrixClient", function() {
client.clientOpts = { experimentalThreadSupport: true };
const eventPollResponseReference = buildEventPollResponseReference();
const eventMessageInThread = buildEventMessageInThread();
const eventPollStartThreadRoot = buildEventPollStartThreadRoot();
const eventMessageInThread = buildEventMessageInThread(eventPollStartThreadRoot);
const events = [
eventPollStartThreadRoot,
eventPollResponseReference,
eventMessageInThread,
eventPollStartThreadRoot,
];
const [timeline, threaded] = client.partitionThreadedEvents(events);
const [timeline, threaded] = room.partitionThreadedEvents(events);
expect(timeline).toEqual([
eventPollResponseReference,
eventPollStartThreadRoot,
eventPollResponseReference,
]);
expect(threaded).toEqual([
eventPollStartThreadRoot,
withThreadId(eventPollResponseReference, eventPollStartThreadRoot.getId()),
eventMessageInThread,
]);
@@ -492,26 +678,27 @@ describe("MatrixClient", function() {
it("copies post-thread in-timeline reactions onto both timelines", function() {
client.clientOpts = { experimentalThreadSupport: true };
const eventReaction = buildEventReaction();
const eventMessageInThread = buildEventMessageInThread();
const eventPollStartThreadRoot = buildEventPollStartThreadRoot();
const eventMessageInThread = buildEventMessageInThread(eventPollStartThreadRoot);
const eventReaction = buildEventReaction(eventPollStartThreadRoot);
const events = [
eventReaction,
eventMessageInThread,
eventPollStartThreadRoot,
eventMessageInThread,
eventReaction,
];
const [timeline, threaded] = client.partitionThreadedEvents(events);
const [timeline, threaded] = room.partitionThreadedEvents(events);
expect(timeline).toEqual([
eventReaction,
eventPollStartThreadRoot,
eventReaction,
]);
expect(threaded).toEqual([
withThreadId(eventReaction, eventPollStartThreadRoot.getId()),
eventPollStartThreadRoot,
eventMessageInThread,
withThreadId(eventReaction, eventPollStartThreadRoot.getId()),
]);
});
@@ -519,9 +706,9 @@ describe("MatrixClient", function() {
client.clientOpts = { experimentalThreadSupport: true };
// This is based on recording the events in a real room:
const eventMessageInThread = buildEventMessageInThread();
const eventPollResponseReference = buildEventPollResponseReference();
const eventPollStartThreadRoot = buildEventPollStartThreadRoot();
const eventPollResponseReference = buildEventPollResponseReference();
const eventMessageInThread = buildEventMessageInThread(eventPollStartThreadRoot);
const eventRoomName = buildEventRoomName();
const eventEncryption = buildEventEncryption();
const eventGuestAccess = buildEventGuestAccess();
@@ -532,9 +719,9 @@ describe("MatrixClient", function() {
const eventCreate = buildEventCreate();
const events = [
eventMessageInThread,
eventPollResponseReference,
eventPollStartThreadRoot,
eventPollResponseReference,
eventMessageInThread,
eventRoomName,
eventEncryption,
eventGuestAccess,
@@ -544,12 +731,12 @@ describe("MatrixClient", function() {
eventMember,
eventCreate,
];
const [timeline, threaded] = client.partitionThreadedEvents(events);
const [timeline, threaded] = room.partitionThreadedEvents(events);
expect(timeline).toEqual([
// The message that was sent in a thread is missing
eventPollResponseReference,
eventPollStartThreadRoot,
eventPollResponseReference,
eventRoomName,
eventEncryption,
eventGuestAccess,
@@ -560,13 +747,262 @@ describe("MatrixClient", function() {
eventCreate,
]);
// Thread should contain only stuff that happened in the thread -
// no thread root, and no room state events
// Thread should contain only stuff that happened in the thread - no room state events
expect(threaded).toEqual([
eventMessageInThread,
eventPollStartThreadRoot,
withThreadId(eventPollResponseReference, eventPollStartThreadRoot.getId()),
eventMessageInThread,
]);
});
it("sends redactions of reactions to thread responses to thread timeline only", () => {
client.clientOpts = { experimentalThreadSupport: true };
const threadRootEvent = buildEventPollStartThreadRoot();
const eventMessageInThread = buildEventMessageInThread(threadRootEvent);
const threadedReaction = buildEventReaction(eventMessageInThread);
const threadedReactionRedaction = buildEventRedaction(threadedReaction);
const events = [
threadRootEvent,
eventMessageInThread,
threadedReaction,
threadedReactionRedaction,
];
const [timeline, threaded] = room.partitionThreadedEvents(events);
expect(timeline).toEqual([
threadRootEvent,
]);
expect(threaded).toEqual([
threadRootEvent,
eventMessageInThread,
threadedReaction,
threadedReactionRedaction,
]);
});
it("sends reply to reply to thread root outside of thread to main timeline only", () => {
client.clientOpts = { experimentalThreadSupport: true };
const threadRootEvent = buildEventPollStartThreadRoot();
const eventMessageInThread = buildEventMessageInThread(threadRootEvent);
const directReplyToThreadRoot = buildEventReply(threadRootEvent);
const replyToReply = buildEventReply(directReplyToThreadRoot);
const events = [
threadRootEvent,
eventMessageInThread,
directReplyToThreadRoot,
replyToReply,
];
const [timeline, threaded] = room.partitionThreadedEvents(events);
expect(timeline).toEqual([
threadRootEvent,
directReplyToThreadRoot,
replyToReply,
]);
expect(threaded).toEqual([
threadRootEvent,
eventMessageInThread,
]);
});
it("sends reply to thread responses to main timeline only", () => {
client.clientOpts = { experimentalThreadSupport: true };
const threadRootEvent = buildEventPollStartThreadRoot();
const eventMessageInThread = buildEventMessageInThread(threadRootEvent);
const replyToThreadResponse = buildEventReply(eventMessageInThread);
const events = [
threadRootEvent,
eventMessageInThread,
replyToThreadResponse,
];
const [timeline, threaded] = room.partitionThreadedEvents(events);
expect(timeline).toEqual([
threadRootEvent,
replyToThreadResponse,
]);
expect(threaded).toEqual([
threadRootEvent,
eventMessageInThread,
]);
});
});
describe("getThirdpartyUser", () => {
it("should hit the expected API endpoint", async () => {
const response = [{
userid: "@Bob",
protocol: "irc",
fields: {},
}];
const prom = client.getThirdpartyUser("irc", {});
httpBackend.when("GET", "/thirdparty/user/irc").respond(200, response);
await httpBackend.flush();
expect(await prom).toStrictEqual(response);
});
});
describe("getThirdpartyLocation", () => {
it("should hit the expected API endpoint", async () => {
const response = [{
alias: "#alias",
protocol: "irc",
fields: {},
}];
const prom = client.getThirdpartyLocation("irc", {});
httpBackend.when("GET", "/thirdparty/location/irc").respond(200, response);
await httpBackend.flush();
expect(await prom).toStrictEqual(response);
});
});
describe("getPushers", () => {
it("should hit the expected API endpoint", async () => {
const response = {
pushers: [],
};
const prom = client.getPushers();
httpBackend.when("GET", "/pushers").respond(200, response);
await httpBackend.flush();
expect(await prom).toStrictEqual(response);
});
});
describe("getKeyChanges", () => {
it("should hit the expected API endpoint", async () => {
const response = {
changed: [],
left: [],
};
const prom = client.getKeyChanges("old", "new");
httpBackend.when("GET", "/keys/changes").check((req) => {
expect(req.queryParams.from).toEqual("old");
expect(req.queryParams.to).toEqual("new");
}).respond(200, response);
await httpBackend.flush();
expect(await prom).toStrictEqual(response);
});
});
describe("getDevices", () => {
it("should hit the expected API endpoint", async () => {
const response = {
devices: [],
};
const prom = client.getDevices();
httpBackend.when("GET", "/devices").respond(200, response);
await httpBackend.flush();
expect(await prom).toStrictEqual(response);
});
});
describe("getDevice", () => {
it("should hit the expected API endpoint", async () => {
const response = {
device_id: "DEADBEEF",
display_name: "NotAPhone",
last_seen_ip: "127.0.0.1",
last_seen_ts: 1,
};
const prom = client.getDevice("DEADBEEF");
httpBackend.when("GET", "/devices/DEADBEEF").respond(200, response);
await httpBackend.flush();
expect(await prom).toStrictEqual(response);
});
});
describe("getThreePids", () => {
it("should hit the expected API endpoint", async () => {
const response = {
threepids: [],
};
const prom = client.getThreePids();
httpBackend.when("GET", "/account/3pid").respond(200, response);
await httpBackend.flush();
expect(await prom).toStrictEqual(response);
});
});
describe("deleteAlias", () => {
it("should hit the expected API endpoint", async () => {
const response = {};
const prom = client.deleteAlias("#foo:bar");
httpBackend.when("DELETE", "/directory/room/" + encodeURIComponent("#foo:bar")).respond(200, response);
await httpBackend.flush();
expect(await prom).toStrictEqual(response);
});
});
describe("deleteRoomTag", () => {
it("should hit the expected API endpoint", async () => {
const response = {};
const prom = client.deleteRoomTag("!roomId:server", "u.tag");
const url = `/user/${encodeURIComponent(userId)}/rooms/${encodeURIComponent("!roomId:server")}/tags/u.tag`;
httpBackend.when("DELETE", url).respond(200, response);
await httpBackend.flush();
expect(await prom).toStrictEqual(response);
});
});
describe("getRoomTags", () => {
it("should hit the expected API endpoint", async () => {
const response = {
tags: {
"u.tag": {
order: 0.5,
},
},
};
const prom = client.getRoomTags("!roomId:server");
const url = `/user/${encodeURIComponent(userId)}/rooms/${encodeURIComponent("!roomId:server")}/tags`;
httpBackend.when("GET", url).respond(200, response);
await httpBackend.flush();
expect(await prom).toStrictEqual(response);
});
});
describe("requestRegisterEmailToken", () => {
it("should hit the expected API endpoint", async () => {
const response = {
sid: "random_sid",
submit_url: "https://foobar.matrix/_matrix/matrix",
};
httpBackend.when("GET", "/_matrix/client/versions").respond(200, {
versions: ["r0.5.0"],
});
const prom = client.requestRegisterEmailToken("bob@email", "secret", 1);
httpBackend.when("POST", "/register/email/requestToken").check(req => {
expect(req.data).toStrictEqual({
email: "bob@email",
client_secret: "secret",
send_attempt: 1,
});
}).respond(200, response);
await httpBackend.flush();
expect(await prom).toStrictEqual(response);
});
});
});
@@ -576,16 +1012,16 @@ function withThreadId(event, newThreadId) {
return ret;
}
const buildEventMessageInThread = () => new MatrixEvent({
const buildEventMessageInThread = (root) => new MatrixEvent({
"age": 80098509,
"content": {
"algorithm": "m.megolm.v1.aes-sha2",
"ciphertext": "ENCRYPTEDSTUFF",
"device_id": "XISFUZSKHH",
"m.relates_to": {
"event_id": "$VLS2ojbPmxb6x8ECetn45hmND6cRDcjgv-j-to9m7Vo",
"event_id": root.getId(),
"m.in_reply_to": {
"event_id": "$VLS2ojbPmxb6x8ECetn45hmND6cRDcjgv-j-to9m7Vo",
"event_id": root.getId(),
},
"rel_type": "m.thread",
},
@@ -623,10 +1059,10 @@ const buildEventPollResponseReference = () => new MatrixEvent({
"user_id": "@andybalaam-test1:matrix.org",
});
const buildEventReaction = () => new MatrixEvent({
const buildEventReaction = (event) => new MatrixEvent({
"content": {
"m.relates_to": {
"event_id": "$VLS2ojbPmxb6x8ECetn45hmND6cRDcjgv-j-to9m7Vo",
"event_id": event.getId(),
"key": "🤗",
"rel_type": "m.annotation",
},
@@ -642,6 +1078,22 @@ const buildEventReaction = () => new MatrixEvent({
"room_id": "!STrMRsukXHtqQdSeHa:matrix.org",
});
const buildEventRedaction = (event) => new MatrixEvent({
"content": {
},
"origin_server_ts": 1643977249239,
"sender": "@andybalaam-test1:matrix.org",
"redacts": event.getId(),
"type": "m.room.redaction",
"unsigned": {
"age": 22597,
"transaction_id": "m1643977249073.17",
},
"event_id": "$86B2b-x3LgE4DlV4y24b7UHnt72LIA3rzjvMysTtAfB",
"room_id": "!STrMRsukXHtqQdSeHa:matrix.org",
});
const buildEventPollStartThreadRoot = () => new MatrixEvent({
"age": 80108647,
"content": {
@@ -660,6 +1112,29 @@ const buildEventPollStartThreadRoot = () => new MatrixEvent({
"user_id": "@andybalaam-test1:matrix.org",
});
const buildEventReply = (target) => new MatrixEvent({
"age": 80098509,
"content": {
"algorithm": "m.megolm.v1.aes-sha2",
"ciphertext": "ENCRYPTEDSTUFF",
"device_id": "XISFUZSKHH",
"m.relates_to": {
"m.in_reply_to": {
"event_id": target.getId(),
},
},
"sender_key": "i3N3CtG/CD2bGB8rA9fW6adLYSDvlUhf2iuU73L65Vg",
"session_id": "Ja11R/KG6ua0wdk8zAzognrxjio1Gm/RK2Gn6lFL804",
},
"event_id": target.getId() + Math.random(),
"origin_server_ts": 1643815466378,
"room_id": "!STrMRsukXHtqQdSeHa:matrix.org",
"sender": "@andybalaam-test1:matrix.org",
"type": "m.room.encrypted",
"unsigned": { "age": 80098509 },
"user_id": "@andybalaam-test1:matrix.org",
});
const buildEventRoomName = () => new MatrixEvent({
"age": 80123249,
"content": {

View File

@@ -1,5 +1,20 @@
import { MatrixEvent } from "../../src/models/event";
import { EventTimeline } from "../../src/models/event-timeline";
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { EventTimeline, MatrixEvent, RoomEvent } from "../../src";
import * as utils from "../test-utils/test-utils";
import { TestClient } from "../TestClient";
@@ -60,6 +75,112 @@ describe("MatrixClient syncing", function() {
done();
});
});
it("should emit Room.myMembership for invite->leave->invite cycles", async () => {
const roomId = "!cycles:example.org";
// First sync: an invite
const inviteSyncRoomSection = {
invite: {
[roomId]: {
invite_state: {
events: [{
type: "m.room.member",
state_key: selfUserId,
content: {
membership: "invite",
},
}],
},
},
},
};
httpBackend.when("GET", "/sync").respond(200, {
...syncData,
rooms: inviteSyncRoomSection,
});
// Second sync: a leave (reject of some kind)
httpBackend.when("POST", "/leave").respond(200, {});
httpBackend.when("GET", "/sync").respond(200, {
...syncData,
rooms: {
leave: {
[roomId]: {
account_data: { events: [] },
ephemeral: { events: [] },
state: {
events: [{
type: "m.room.member",
state_key: selfUserId,
content: {
membership: "leave",
},
prev_content: {
membership: "invite",
},
// XXX: And other fields required on an event
}],
},
timeline: {
limited: false,
events: [{
type: "m.room.member",
state_key: selfUserId,
content: {
membership: "leave",
},
prev_content: {
membership: "invite",
},
// XXX: And other fields required on an event
}],
},
},
},
},
});
// Third sync: another invite
httpBackend.when("GET", "/sync").respond(200, {
...syncData,
rooms: inviteSyncRoomSection,
});
// First fire: an initial invite
let fires = 0;
client.once(RoomEvent.MyMembership, (room, membership, oldMembership) => { // Room, string, string
fires++;
expect(room.roomId).toBe(roomId);
expect(membership).toBe("invite");
expect(oldMembership).toBeFalsy();
// Second fire: a leave
client.once(RoomEvent.MyMembership, (room, membership, oldMembership) => {
fires++;
expect(room.roomId).toBe(roomId);
expect(membership).toBe("leave");
expect(oldMembership).toBe("invite");
// Third/final fire: a second invite
client.once(RoomEvent.MyMembership, (room, membership, oldMembership) => {
fires++;
expect(room.roomId).toBe(roomId);
expect(membership).toBe("invite");
expect(oldMembership).toBe("leave");
});
});
// For maximum safety, "leave" the room after we register the handler
client.leave(roomId);
});
// noinspection ES6MissingAwait
client.startClient();
await httpBackend.flushAllExpected();
expect(fires).toBe(3);
});
});
describe("resolving invites to profile info", function() {
@@ -735,8 +856,7 @@ describe("MatrixClient syncing", function() {
expect(tok).toEqual("pagTok");
}),
// first flush the filter request; this will make syncLeftRooms
// make its /sync call
// first flush the filter request; this will make syncLeftRooms make its /sync call
httpBackend.flush("/filter").then(function() {
return httpBackend.flushAllExpected();
}),

View File

@@ -50,12 +50,14 @@ export const makeBeaconInfoEvent = (
...contentProps,
};
const event = new MatrixEvent({
type: `${M_BEACON_INFO.name}.${sender}`,
type: M_BEACON_INFO.name,
room_id: roomId,
state_key: sender,
content: makeBeaconInfoContent(timeout, isLive, description, assetType),
});
event.event.origin_server_ts = Date.now();
// live beacons use the beacon_info event id
// set or default this
event.replaceLocalEventId(eventId || `$${Math.random()}-${Math.random()}`);

View File

@@ -1,369 +0,0 @@
// load olm before the sdk if possible
import '../olm-loader';
import { logger } from '../../src/logger';
import { MatrixEvent } from "../../src/models/event";
/**
* Return a promise that is resolved when the client next emits a
* SYNCING event.
* @param {Object} client The client
* @param {Number=} count Number of syncs to wait for (default 1)
* @return {Promise} Resolves once the client has emitted a SYNCING event
*/
export function syncPromise(client, count) {
if (count === undefined) {
count = 1;
}
if (count <= 0) {
return Promise.resolve();
}
const p = new Promise((resolve, reject) => {
const cb = (state) => {
logger.log(`${Date.now()} syncPromise(${count}): ${state}`);
if (state === 'SYNCING') {
resolve();
} else {
client.once('sync', cb);
}
};
client.once('sync', cb);
});
return p.then(() => {
return syncPromise(client, count-1);
});
}
/**
* Create a spy for an object and automatically spy its methods.
* @param {*} constr The class constructor (used with 'new')
* @param {string} name The name of the class
* @return {Object} An instantiated object with spied methods/properties.
*/
export function mock(constr, name) {
// Based on
// http://eclipsesource.com/blogs/2014/03/27/mocks-in-jasmine-tests/
const HelperConstr = new Function(); // jshint ignore:line
HelperConstr.prototype = constr.prototype;
const result = new HelperConstr();
result.toString = function() {
return "mock" + (name ? " of " + name : "");
};
for (const key of Object.getOwnPropertyNames(constr.prototype)) { // eslint-disable-line guard-for-in
try {
if (constr.prototype[key] instanceof Function) {
result[key] = jest.fn();
}
} catch (ex) {
// Direct access to some non-function fields of DOM prototypes may
// cause exceptions.
// Overwriting will not work either in that case.
}
}
return result;
}
/**
* Create an Event.
* @param {Object} opts Values for the event.
* @param {string} opts.type The event.type
* @param {string} opts.room The event.room_id
* @param {string} opts.sender The event.sender
* @param {string} opts.skey Optional. The state key (auto inserts empty string)
* @param {Object} opts.content The event.content
* @param {boolean} opts.event True to make a MatrixEvent.
* @return {Object} a JSON object representing this event.
*/
export function mkEvent(opts) {
if (!opts.type || !opts.content) {
throw new Error("Missing .type or .content =>" + JSON.stringify(opts));
}
const event = {
type: opts.type,
room_id: opts.room,
sender: opts.sender || opts.user, // opts.user for backwards-compat
content: opts.content,
unsigned: opts.unsigned,
event_id: "$" + Math.random() + "-" + Math.random(),
};
if (opts.skey !== undefined) {
event.state_key = opts.skey;
} else if (["m.room.name", "m.room.topic", "m.room.create", "m.room.join_rules",
"m.room.power_levels", "m.room.topic",
"com.example.state"].includes(opts.type)) {
event.state_key = "";
}
return opts.event ? new MatrixEvent(event) : event;
}
/**
* Create an m.presence event.
* @param {Object} opts Values for the presence.
* @return {Object|MatrixEvent} The event
*/
export function mkPresence(opts) {
if (!opts.user) {
throw new Error("Missing user");
}
const event = {
event_id: "$" + Math.random() + "-" + Math.random(),
type: "m.presence",
sender: opts.sender || opts.user, // opts.user for backwards-compat
content: {
avatar_url: opts.url,
displayname: opts.name,
last_active_ago: opts.ago,
presence: opts.presence || "offline",
},
};
return opts.event ? new MatrixEvent(event) : event;
}
/**
* Create an m.room.member event.
* @param {Object} opts Values for the membership.
* @param {string} opts.room The room ID for the event.
* @param {string} opts.mship The content.membership for the event.
* @param {string} opts.sender The sender user ID for the event.
* @param {string} opts.skey The target user ID for the event if applicable
* e.g. for invites/bans.
* @param {string} opts.name The content.displayname for the event.
* @param {string} opts.url The content.avatar_url for the event.
* @param {boolean} opts.event True to make a MatrixEvent.
* @return {Object|MatrixEvent} The event
*/
export function mkMembership(opts) {
opts.type = "m.room.member";
if (!opts.skey) {
opts.skey = opts.sender || opts.user;
}
if (!opts.mship) {
throw new Error("Missing .mship => " + JSON.stringify(opts));
}
opts.content = {
membership: opts.mship,
};
if (opts.name) {
opts.content.displayname = opts.name;
}
if (opts.url) {
opts.content.avatar_url = opts.url;
}
return mkEvent(opts);
}
/**
* Create an m.room.message event.
* @param {Object} opts Values for the message
* @param {string} opts.room The room ID for the event.
* @param {string} opts.user The user ID for the event.
* @param {string} opts.msg Optional. The content.body for the event.
* @param {boolean} opts.event True to make a MatrixEvent.
* @return {Object|MatrixEvent} The event
*/
export function mkMessage(opts) {
opts.type = "m.room.message";
if (!opts.msg) {
opts.msg = "Random->" + Math.random();
}
if (!opts.room || !opts.user) {
throw new Error("Missing .room or .user from %s", opts);
}
opts.content = {
msgtype: "m.text",
body: opts.msg,
};
return mkEvent(opts);
}
/**
* A mock implementation of webstorage
*
* @constructor
*/
export function MockStorageApi() {
this.data = {};
}
MockStorageApi.prototype = {
get length() {
return Object.keys(this.data).length;
},
key: function(i) {
return Object.keys(this.data)[i];
},
setItem: function(k, v) {
this.data[k] = v;
},
getItem: function(k) {
return this.data[k] || null;
},
removeItem: function(k) {
delete this.data[k];
},
};
/**
* If an event is being decrypted, wait for it to finish being decrypted.
*
* @param {MatrixEvent} event
* @returns {Promise} promise which resolves (to `event`) when the event has been decrypted
*/
export function awaitDecryption(event) {
// An event is not always decrypted ahead of time
// getClearContent is a good signal to know whether an event has been decrypted
// already
if (event.getClearContent() !== null) {
return event;
} else {
logger.log(`${Date.now()} event ${event.getId()} is being decrypted; waiting`);
return new Promise((resolve, reject) => {
event.once('Event.decrypted', (ev) => {
logger.log(`${Date.now()} event ${event.getId()} now decrypted`);
resolve(ev);
});
});
}
}
export function HttpResponse(
httpLookups, acceptKeepalives, ignoreUnhandledSync,
) {
this.httpLookups = httpLookups;
this.acceptKeepalives = acceptKeepalives === undefined ? true : acceptKeepalives;
this.ignoreUnhandledSync = ignoreUnhandledSync;
this.pendingLookup = null;
}
HttpResponse.prototype.request = function(
cb, method, path, qp, data, prefix,
) {
if (path === HttpResponse.KEEP_ALIVE_PATH && this.acceptKeepalives) {
return Promise.resolve();
}
const next = this.httpLookups.shift();
const logLine = (
"MatrixClient[UT] RECV " + method + " " + path + " " +
"EXPECT " + (next ? next.method : next) + " " + (next ? next.path : next)
);
logger.log(logLine);
if (!next) { // no more things to return
if (method === "GET" && path === "/sync" && this.ignoreUnhandledSync) {
logger.log("MatrixClient[UT] Ignoring.");
return new Promise(() => {});
}
if (this.pendingLookup) {
if (this.pendingLookup.method === method
&& this.pendingLookup.path === path) {
return this.pendingLookup.promise;
}
// >1 pending thing, and they are different, whine.
expect(false).toBe(
true, ">1 pending request. You should probably handle them. " +
"PENDING: " + JSON.stringify(this.pendingLookup) + " JUST GOT: " +
method + " " + path,
);
}
this.pendingLookup = {
promise: new Promise(() => {}),
method: method,
path: path,
};
return this.pendingLookup.promise;
}
if (next.path === path && next.method === method) {
logger.log(
"MatrixClient[UT] Matched. Returning " +
(next.error ? "BAD" : "GOOD") + " response",
);
if (next.expectBody) {
expect(next.expectBody).toEqual(data);
}
if (next.expectQueryParams) {
Object.keys(next.expectQueryParams).forEach(function(k) {
expect(qp[k]).toEqual(next.expectQueryParams[k]);
});
}
if (next.thenCall) {
process.nextTick(next.thenCall, 0); // next tick so we return first.
}
if (next.error) {
return Promise.reject({
errcode: next.error.errcode,
httpStatus: next.error.httpStatus,
name: next.error.errcode,
message: "Expected testing error",
data: next.error,
});
}
return Promise.resolve(next.data);
} else if (method === "GET" && path === "/sync" && this.ignoreUnhandledSync) {
logger.log("MatrixClient[UT] Ignoring.");
this.httpLookups.unshift(next);
return new Promise(() => {});
}
expect(true).toBe(false, "Expected different request. " + logLine);
return new Promise(() => {});
};
HttpResponse.KEEP_ALIVE_PATH = "/_matrix/client/versions";
HttpResponse.PUSH_RULES_RESPONSE = {
method: "GET",
path: "/pushrules/",
data: {},
};
HttpResponse.PUSH_RULES_RESPONSE = {
method: "GET",
path: "/pushrules/",
data: {},
};
HttpResponse.USER_ID = "@alice:bar";
HttpResponse.filterResponse = function(userId) {
const filterPath = "/user/" + encodeURIComponent(userId) + "/filter";
return {
method: "POST",
path: filterPath,
data: { filter_id: "f1lt3r" },
};
};
HttpResponse.SYNC_DATA = {
next_batch: "s_5_3",
presence: { events: [] },
rooms: {},
};
HttpResponse.SYNC_RESPONSE = {
method: "GET",
path: "/sync",
data: HttpResponse.SYNC_DATA,
};
HttpResponse.defaultResponses = function(userId) {
return [
HttpResponse.PUSH_RULES_RESPONSE,
HttpResponse.filterResponse(userId),
HttpResponse.SYNC_RESPONSE,
];
};
export function setHttpResponses(
httpBackend, responses,
) {
responses.forEach(response => {
httpBackend
.when(response.method, response.path)
.respond(200, response.data);
});
}
export const emitPromise = (e, k) => new Promise(r => e.once(k, r));

View File

@@ -0,0 +1,292 @@
// eslint-disable-next-line no-restricted-imports
import EventEmitter from "events";
// load olm before the sdk if possible
import '../olm-loader';
import { logger } from '../../src/logger';
import { IContent, IEvent, IUnsigned, MatrixEvent, MatrixEventEvent } from "../../src/models/event";
import { ClientEvent, EventType, MatrixClient } from "../../src";
import { SyncState } from "../../src/sync";
import { eventMapperFor } from "../../src/event-mapper";
/**
* Return a promise that is resolved when the client next emits a
* SYNCING event.
* @param {Object} client The client
* @param {Number=} count Number of syncs to wait for (default 1)
* @return {Promise} Resolves once the client has emitted a SYNCING event
*/
export function syncPromise(client: MatrixClient, count = 1): Promise<void> {
if (count <= 0) {
return Promise.resolve();
}
const p = new Promise<void>((resolve) => {
const cb = (state: SyncState) => {
logger.log(`${Date.now()} syncPromise(${count}): ${state}`);
if (state === SyncState.Syncing) {
resolve();
} else {
client.once(ClientEvent.Sync, cb);
}
};
client.once(ClientEvent.Sync, cb);
});
return p.then(() => {
return syncPromise(client, count - 1);
});
}
/**
* Create a spy for an object and automatically spy its methods.
* @param {*} constr The class constructor (used with 'new')
* @param {string} name The name of the class
* @return {Object} An instantiated object with spied methods/properties.
*/
export function mock<T>(constr: { new(...args: any[]): T }, name: string): T {
// Based on http://eclipsesource.com/blogs/2014/03/27/mocks-in-jasmine-tests/
const HelperConstr = new Function(); // jshint ignore:line
HelperConstr.prototype = constr.prototype;
// @ts-ignore
const result = new HelperConstr();
result.toString = function() {
return "mock" + (name ? " of " + name : "");
};
for (const key of Object.getOwnPropertyNames(constr.prototype)) { // eslint-disable-line guard-for-in
try {
if (constr.prototype[key] instanceof Function) {
result[key] = jest.fn();
}
} catch (ex) {
// Direct access to some non-function fields of DOM prototypes may
// cause exceptions.
// Overwriting will not work either in that case.
}
}
return result;
}
interface IEventOpts {
type: EventType | string;
room: string;
sender?: string;
skey?: string;
content: IContent;
event?: boolean;
user?: string;
unsigned?: IUnsigned;
redacts?: string;
}
let testEventIndex = 1; // counter for events, easier for comparison of randomly generated events
/**
* Create an Event.
* @param {Object} opts Values for the event.
* @param {string} opts.type The event.type
* @param {string} opts.room The event.room_id
* @param {string} opts.sender The event.sender
* @param {string} opts.skey Optional. The state key (auto inserts empty string)
* @param {Object} opts.content The event.content
* @param {boolean} opts.event True to make a MatrixEvent.
* @param {MatrixClient} client If passed along with opts.event=true will be used to set up re-emitters.
* @return {Object} a JSON object representing this event.
*/
export function mkEvent(opts: IEventOpts, client?: MatrixClient): object | MatrixEvent {
if (!opts.type || !opts.content) {
throw new Error("Missing .type or .content =>" + JSON.stringify(opts));
}
const event: Partial<IEvent> = {
type: opts.type as string,
room_id: opts.room,
sender: opts.sender || opts.user, // opts.user for backwards-compat
content: opts.content,
unsigned: opts.unsigned || {},
event_id: "$" + testEventIndex++ + "-" + Math.random() + "-" + Math.random(),
txn_id: "~" + Math.random(),
redacts: opts.redacts,
};
if (opts.skey !== undefined) {
event.state_key = opts.skey;
} else if ([
EventType.RoomName,
EventType.RoomTopic,
EventType.RoomCreate,
EventType.RoomJoinRules,
EventType.RoomPowerLevels,
EventType.RoomTopic,
"com.example.state",
].includes(opts.type)) {
event.state_key = "";
}
if (opts.event && client) {
return eventMapperFor(client, {})(event);
}
return opts.event ? new MatrixEvent(event) : event;
}
interface IPresenceOpts {
user?: string;
sender?: string;
url: string;
name: string;
ago: number;
presence?: string;
event?: boolean;
}
/**
* Create an m.presence event.
* @param {Object} opts Values for the presence.
* @return {Object|MatrixEvent} The event
*/
export function mkPresence(opts: IPresenceOpts): object | MatrixEvent {
const event = {
event_id: "$" + Math.random() + "-" + Math.random(),
type: "m.presence",
sender: opts.sender || opts.user, // opts.user for backwards-compat
content: {
avatar_url: opts.url,
displayname: opts.name,
last_active_ago: opts.ago,
presence: opts.presence || "offline",
},
};
return opts.event ? new MatrixEvent(event) : event;
}
interface IMembershipOpts {
room: string;
mship: string;
sender?: string;
user?: string;
skey?: string;
name?: string;
url?: string;
event?: boolean;
}
/**
* Create an m.room.member event.
* @param {Object} opts Values for the membership.
* @param {string} opts.room The room ID for the event.
* @param {string} opts.mship The content.membership for the event.
* @param {string} opts.sender The sender user ID for the event.
* @param {string} opts.skey The target user ID for the event if applicable
* e.g. for invites/bans.
* @param {string} opts.name The content.displayname for the event.
* @param {string} opts.url The content.avatar_url for the event.
* @param {boolean} opts.event True to make a MatrixEvent.
* @return {Object|MatrixEvent} The event
*/
export function mkMembership(opts: IMembershipOpts): object | MatrixEvent {
const eventOpts: IEventOpts = {
...opts,
type: EventType.RoomMember,
content: {
membership: opts.mship,
},
};
if (!opts.skey) {
eventOpts.skey = opts.sender || opts.user;
}
if (opts.name) {
eventOpts.content.displayname = opts.name;
}
if (opts.url) {
eventOpts.content.avatar_url = opts.url;
}
return mkEvent(eventOpts);
}
interface IMessageOpts {
room: string;
user: string;
msg?: string;
event?: boolean;
}
/**
* Create an m.room.message event.
* @param {Object} opts Values for the message
* @param {string} opts.room The room ID for the event.
* @param {string} opts.user The user ID for the event.
* @param {string} opts.msg Optional. The content.body for the event.
* @param {boolean} opts.event True to make a MatrixEvent.
* @param {MatrixClient} client If passed along with opts.event=true will be used to set up re-emitters.
* @return {Object|MatrixEvent} The event
*/
export function mkMessage(opts: IMessageOpts, client?: MatrixClient): object | MatrixEvent {
const eventOpts: IEventOpts = {
...opts,
type: EventType.RoomMessage,
content: {
msgtype: "m.text",
body: opts.msg,
},
};
if (!eventOpts.content.body) {
eventOpts.content.body = "Random->" + Math.random();
}
return mkEvent(eventOpts, client);
}
/**
* A mock implementation of webstorage
*
* @constructor
*/
export class MockStorageApi {
private data: Record<string, any> = {};
public get length() {
return Object.keys(this.data).length;
}
public key(i: number): any {
return Object.keys(this.data)[i];
}
public setItem(k: string, v: any): void {
this.data[k] = v;
}
public getItem(k: string): any {
return this.data[k] || null;
}
public removeItem(k: string): void {
delete this.data[k];
}
}
/**
* If an event is being decrypted, wait for it to finish being decrypted.
*
* @param {MatrixEvent} event
* @returns {Promise} promise which resolves (to `event`) when the event has been decrypted
*/
export async function awaitDecryption(event: MatrixEvent): Promise<MatrixEvent> {
// An event is not always decrypted ahead of time
// getClearContent is a good signal to know whether an event has been decrypted
// already
if (event.getClearContent() !== null) {
return event;
} else {
logger.log(`${Date.now()} event ${event.getId()} is being decrypted; waiting`);
return new Promise((resolve) => {
event.once(MatrixEventEvent.Decrypted, (ev) => {
logger.log(`${Date.now()} event ${event.getId()} now decrypted`);
resolve(ev);
});
});
}
}
export const emitPromise = (e: EventEmitter, k: string): Promise<any> => new Promise(r => e.once(k, r));

View File

@@ -16,9 +16,14 @@ limitations under the License.
import { REFERENCE_RELATION } from "matrix-events-sdk";
import { M_BEACON_INFO } from "../../src/@types/beacon";
import { LocationAssetType, M_ASSET, M_LOCATION, M_TIMESTAMP } from "../../src/@types/location";
import { makeBeaconContent, makeBeaconInfoContent } from "../../src/content-helpers";
import { M_TOPIC } from "../../src/@types/topic";
import {
makeBeaconContent,
makeBeaconInfoContent,
makeTopicContent,
parseTopicContent,
} from "../../src/content-helpers";
describe('Beacon content helpers', () => {
describe('makeBeaconInfoContent()', () => {
@@ -36,11 +41,9 @@ describe('Beacon content helpers', () => {
'nice beacon_info',
LocationAssetType.Pin,
)).toEqual({
[M_BEACON_INFO.name]: {
description: 'nice beacon_info',
timeout: 1234,
live: true,
},
[M_TIMESTAMP.name]: mockDateNow,
[M_ASSET.name]: {
type: LocationAssetType.Pin,
@@ -125,3 +128,68 @@ describe('Beacon content helpers', () => {
});
});
});
describe('Topic content helpers', () => {
describe('makeTopicContent()', () => {
it('creates fully defined event content without html', () => {
expect(makeTopicContent("pizza")).toEqual({
topic: "pizza",
[M_TOPIC.name]: [{
body: "pizza",
mimetype: "text/plain",
}],
});
});
it('creates fully defined event content with html', () => {
expect(makeTopicContent("pizza", "<b>pizza</b>")).toEqual({
topic: "pizza",
[M_TOPIC.name]: [{
body: "pizza",
mimetype: "text/plain",
}, {
body: "<b>pizza</b>",
mimetype: "text/html",
}],
});
});
});
describe('parseTopicContent()', () => {
it('parses event content with plain text topic without mimetype', () => {
expect(parseTopicContent({
topic: "pizza",
[M_TOPIC.name]: [{
body: "pizza",
}],
})).toEqual({
text: "pizza",
});
});
it('parses event content with plain text topic', () => {
expect(parseTopicContent({
topic: "pizza",
[M_TOPIC.name]: [{
body: "pizza",
mimetype: "text/plain",
}],
})).toEqual({
text: "pizza",
});
});
it('parses event content with html topic', () => {
expect(parseTopicContent({
topic: "pizza",
[M_TOPIC.name]: [{
body: "<b>pizza</b>",
mimetype: "text/html",
}],
})).toEqual({
text: "pizza",
html: "<b>pizza</b>",
});
});
});
});

View File

@@ -13,6 +13,7 @@ import * as olmlib from "../../src/crypto/olmlib";
import { sleep } from "../../src/utils";
import { CRYPTO_ENABLED } from "../../src/client";
import { DeviceInfo } from "../../src/crypto/deviceinfo";
import { logger } from '../../src/logger';
const Olm = global.Olm;
@@ -400,4 +401,28 @@ describe("Crypto", function() {
expect(aliceClient.sendToDevice.mock.calls[2][2]).not.toBe(txnId);
});
});
describe('Secret storage', function() {
it("creates secret storage even if there is no keyInfo", async function() {
jest.spyOn(logger, 'log').mockImplementation(() => {});
jest.setTimeout(10000);
const client = (new TestClient("@a:example.com", "dev")).client;
await client.initCrypto();
client.crypto.getSecretStorageKey = async () => null;
client.crypto.isCrossSigningReady = async () => false;
client.crypto.baseApis.uploadDeviceSigningKeys = () => null;
client.crypto.baseApis.setAccountData = () => null;
client.crypto.baseApis.uploadKeySignatures = () => null;
client.crypto.baseApis.http.authedRequest = () => null;
const createSecretStorageKey = async () => {
return {
keyInfo: undefined, // Returning undefined here used to cause a crash
privateKey: Uint8Array.of(32, 33),
};
};
await client.crypto.bootstrapSecretStorage({
createSecretStorageKey,
});
});
});
});

View File

@@ -20,11 +20,33 @@ import anotherjson from 'another-json';
import * as olmlib from "../../../src/crypto/olmlib";
import { TestClient } from '../../TestClient';
import { HttpResponse, setHttpResponses } from '../../test-utils/test-utils';
import { resetCrossSigningKeys } from "./crypto-utils";
import { MatrixError } from '../../../src/http-api';
import { logger } from '../../../src/logger';
const PUSH_RULES_RESPONSE = {
method: "GET",
path: "/pushrules/",
data: {},
};
const filterResponse = function(userId) {
const filterPath = "/user/" + encodeURIComponent(userId) + "/filter";
return {
method: "POST",
path: filterPath,
data: { filter_id: "f1lt3r" },
};
};
function setHttpResponses(httpBackend, responses) {
responses.forEach(response => {
httpBackend
.when(response.method, response.path)
.respond(200, response.data);
});
}
async function makeTestClient(userInfo, options, keys) {
if (!keys) keys = {};
@@ -237,7 +259,7 @@ describe("Cross Signing", function() {
// feed sync result that includes master key, ssk, device key
const responses = [
HttpResponse.PUSH_RULES_RESPONSE,
PUSH_RULES_RESPONSE,
{
method: "POST",
path: "/keys/upload",
@@ -248,7 +270,7 @@ describe("Cross Signing", function() {
},
},
},
HttpResponse.filterResponse("@alice:example.com"),
filterResponse("@alice:example.com"),
{
method: "GET",
path: "/sync",
@@ -493,7 +515,7 @@ describe("Cross Signing", function() {
// - master key signed by her usk (pretend that it was signed by another
// of Alice's devices)
const responses = [
HttpResponse.PUSH_RULES_RESPONSE,
PUSH_RULES_RESPONSE,
{
method: "POST",
path: "/keys/upload",
@@ -504,7 +526,7 @@ describe("Cross Signing", function() {
},
},
},
HttpResponse.filterResponse("@alice:example.com"),
filterResponse("@alice:example.com"),
{
method: "GET",
path: "/sync",
@@ -861,4 +883,138 @@ describe("Cross Signing", function() {
expect(bobTrust3.isCrossSigningVerified()).toBeTruthy();
expect(bobTrust3.isTofu()).toBeTruthy();
});
it(
"should observe that our own device is cross-signed, even if this device doesn't trust the key",
async function() {
const { client: alice } = await makeTestClient(
{ userId: "@alice:example.com", deviceId: "Osborne2" },
);
alice.uploadDeviceSigningKeys = async () => {};
alice.uploadKeySignatures = async () => {};
// Generate Alice's SSK etc
const aliceMasterSigning = new global.Olm.PkSigning();
const aliceMasterPrivkey = aliceMasterSigning.generate_seed();
const aliceMasterPubkey = aliceMasterSigning.init_with_seed(aliceMasterPrivkey);
const aliceSigning = new global.Olm.PkSigning();
const alicePrivkey = aliceSigning.generate_seed();
const alicePubkey = aliceSigning.init_with_seed(alicePrivkey);
const aliceSSK = {
user_id: "@alice:example.com",
usage: ["self_signing"],
keys: {
["ed25519:" + alicePubkey]: alicePubkey,
},
};
const sskSig = aliceMasterSigning.sign(anotherjson.stringify(aliceSSK));
aliceSSK.signatures = {
"@alice:example.com": {
["ed25519:" + aliceMasterPubkey]: sskSig,
},
};
// Alice's device downloads the keys, but doesn't trust them yet
alice.crypto.deviceList.storeCrossSigningForUser("@alice:example.com", {
keys: {
master: {
user_id: "@alice:example.com",
usage: ["master"],
keys: {
["ed25519:" + aliceMasterPubkey]: aliceMasterPubkey,
},
},
self_signing: aliceSSK,
},
firstUse: 1,
unsigned: {},
});
// Alice has a second device that's cross-signed
const aliceCrossSignedDevice = {
user_id: "@alice:example.com",
device_id: "Dynabook",
algorithms: ["m.olm.curve25519-aes-sha256", "m.megolm.v1.aes-sha"],
keys: {
"curve25519:Dynabook": "somePubkey",
"ed25519:Dynabook": "someOtherPubkey",
},
};
const sig = aliceSigning.sign(anotherjson.stringify(aliceCrossSignedDevice));
aliceCrossSignedDevice.signatures = {
"@alice:example.com": {
["ed25519:" + alicePubkey]: sig,
},
};
alice.crypto.deviceList.storeDevicesForUser("@alice:example.com", {
Dynabook: aliceCrossSignedDevice,
});
// We don't trust the cross-signing keys yet...
expect(alice.checkDeviceTrust(aliceCrossSignedDevice.device_id).isCrossSigningVerified()).toBeFalsy();
// ... but we do acknowledge that the device is signed by them
expect(alice.checkIfOwnDeviceCrossSigned(aliceCrossSignedDevice.device_id)).toBeTruthy();
},
);
it("should observe that our own device isn't cross-signed", async function() {
const { client: alice } = await makeTestClient(
{ userId: "@alice:example.com", deviceId: "Osborne2" },
);
alice.uploadDeviceSigningKeys = async () => {};
alice.uploadKeySignatures = async () => {};
// Generate Alice's SSK etc
const aliceMasterSigning = new global.Olm.PkSigning();
const aliceMasterPrivkey = aliceMasterSigning.generate_seed();
const aliceMasterPubkey = aliceMasterSigning.init_with_seed(aliceMasterPrivkey);
const aliceSigning = new global.Olm.PkSigning();
const alicePrivkey = aliceSigning.generate_seed();
const alicePubkey = aliceSigning.init_with_seed(alicePrivkey);
const aliceSSK = {
user_id: "@alice:example.com",
usage: ["self_signing"],
keys: {
["ed25519:" + alicePubkey]: alicePubkey,
},
};
const sskSig = aliceMasterSigning.sign(anotherjson.stringify(aliceSSK));
aliceSSK.signatures = {
"@alice:example.com": {
["ed25519:" + aliceMasterPubkey]: sskSig,
},
};
// Alice's device downloads the keys
alice.crypto.deviceList.storeCrossSigningForUser("@alice:example.com", {
keys: {
master: {
user_id: "@alice:example.com",
usage: ["master"],
keys: {
["ed25519:" + aliceMasterPubkey]: aliceMasterPubkey,
},
},
self_signing: aliceSSK,
},
firstUse: 1,
unsigned: {},
});
// Alice has a second device that's also not cross-signed
const aliceNotCrossSignedDevice = {
user_id: "@alice:example.com",
device_id: "Dynabook",
algorithms: ["m.olm.curve25519-aes-sha256", "m.megolm.v1.aes-sha"],
keys: {
"curve25519:Dynabook": "somePubkey",
"ed25519:Dynabook": "someOtherPubkey",
},
};
alice.crypto.deviceList.storeDevicesForUser("@alice:example.com", {
Dynabook: aliceNotCrossSignedDevice,
});
expect(alice.checkIfOwnDeviceCrossSigned(aliceNotCrossSignedDevice.device_id)).toBeFalsy();
});
});

View File

@@ -0,0 +1,180 @@
/*
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 { MatrixClient, MatrixEvent, MatrixEventEvent, MatrixScheduler, Room } from "../../src";
import { eventMapperFor } from "../../src/event-mapper";
import { IStore } from "../../src/store";
describe("eventMapperFor", function() {
let rooms: Room[] = [];
const userId = "@test:example.org";
let client: MatrixClient;
beforeEach(() => {
client = new MatrixClient({
baseUrl: "https://my.home.server",
accessToken: "my.access.token",
request: function() {} as any, // NOP
store: {
getRoom(roomId: string): Room | null {
return rooms.find(r => r.roomId === roomId);
},
} as IStore,
scheduler: {
setProcessFunction: jest.fn(),
} as unknown as MatrixScheduler,
userId: userId,
});
rooms = [];
});
it("should de-duplicate MatrixEvent instances by means of findEventById on the room object", async () => {
const roomId = "!room:example.org";
const room = new Room(roomId, client, userId);
rooms.push(room);
const mapper = eventMapperFor(client, {
preventReEmit: true,
decrypt: false,
});
const eventId = "$event1:server";
const eventDefinition = {
type: "m.room.message",
room_id: roomId,
sender: userId,
content: {
body: "body",
},
unsigned: {},
event_id: eventId,
};
const event = mapper(eventDefinition);
expect(event).toBeInstanceOf(MatrixEvent);
room.addLiveEvents([event]);
expect(room.findEventById(eventId)).toBe(event);
const event2 = mapper(eventDefinition);
expect(event).toBe(event2);
});
it("should not de-duplicate state events due to directionality of sentinel members", async () => {
const roomId = "!room:example.org";
const room = new Room(roomId, client, userId);
rooms.push(room);
const mapper = eventMapperFor(client, {
preventReEmit: true,
decrypt: false,
});
const eventId = "$event1:server";
const eventDefinition = {
type: "m.room.name",
room_id: roomId,
sender: userId,
content: {
name: "Room name",
},
unsigned: {},
event_id: eventId,
state_key: "",
};
const event = mapper(eventDefinition);
expect(event).toBeInstanceOf(MatrixEvent);
room.oldState.setStateEvents([event]);
room.currentState.setStateEvents([event]);
room.addLiveEvents([event]);
expect(room.findEventById(eventId)).toBe(event);
const event2 = mapper(eventDefinition);
expect(event).not.toBe(event2);
});
it("should decrypt appropriately", async () => {
const roomId = "!room:example.org";
const room = new Room(roomId, client, userId);
rooms.push(room);
const eventId = "$event1:server";
const eventDefinition = {
type: "m.room.encrypted",
room_id: roomId,
sender: userId,
content: {
ciphertext: "",
},
unsigned: {},
event_id: eventId,
};
const decryptEventIfNeededSpy = jest.spyOn(client, "decryptEventIfNeeded");
decryptEventIfNeededSpy.mockResolvedValue(); // stub it out
const mapper = eventMapperFor(client, {
decrypt: true,
});
const event = mapper(eventDefinition);
expect(event).toBeInstanceOf(MatrixEvent);
expect(decryptEventIfNeededSpy).toHaveBeenCalledWith(event);
});
it("should configure re-emitter appropriately", async () => {
const roomId = "!room:example.org";
const room = new Room(roomId, client, userId);
rooms.push(room);
const eventId = "$event1:server";
const eventDefinition = {
type: "m.room.message",
room_id: roomId,
sender: userId,
content: {
body: "body",
},
unsigned: {},
event_id: eventId,
};
const evListener = jest.fn();
client.on(MatrixEventEvent.Replaced, evListener);
const noReEmitMapper = eventMapperFor(client, {
preventReEmit: true,
});
const event1 = noReEmitMapper(eventDefinition);
expect(event1).toBeInstanceOf(MatrixEvent);
event1.emit(MatrixEventEvent.Replaced, event1);
expect(evListener).not.toHaveBeenCalled();
const reEmitMapper = eventMapperFor(client, {
preventReEmit: false,
});
const event2 = reEmitMapper(eventDefinition);
expect(event2).toBeInstanceOf(MatrixEvent);
event2.emit(MatrixEventEvent.Replaced, event2);
expect(evListener.mock.calls[0][0]).toEqual(event2);
expect(event1).not.toBe(event2); // the event wasn't added to a room so de-duplication wouldn't occur
});
});

View File

@@ -1,6 +1,6 @@
/*
Copyright 2017 New Vector Ltd
Copyright 2019 The Matrix.org Foundaction C.I.C.
Copyright 2019 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.

View File

@@ -1,4 +1,5 @@
import {
MatrixEvent,
RelationType,
} from "../../src";
import { FilterComponent } from "../../src/filter-component";
@@ -13,7 +14,7 @@ describe("Filter Component", function() {
content: { },
room: 'roomId',
event: true,
});
}) as MatrixEvent;
const checkResult = filter.check(event);
@@ -27,7 +28,7 @@ describe("Filter Component", function() {
content: { },
room: 'roomId',
event: true,
});
}) as MatrixEvent;
const checkResult = filter.check(event);
@@ -54,7 +55,7 @@ describe("Filter Component", function() {
},
},
},
});
}) as MatrixEvent;
expect(filter.check(threadRootNotParticipated)).toBe(false);
});
@@ -79,7 +80,7 @@ describe("Filter Component", function() {
user: '@someone-else:server.org',
room: 'roomId',
event: true,
});
}) as MatrixEvent;
expect(filter.check(threadRootParticipated)).toBe(true);
});
@@ -99,7 +100,7 @@ describe("Filter Component", function() {
[RelationType.Reference]: {},
},
},
});
}) as MatrixEvent;
expect(filter.check(referenceRelationEvent)).toBe(false);
});
@@ -122,7 +123,7 @@ describe("Filter Component", function() {
},
room: 'roomId',
event: true,
});
}) as MatrixEvent;
const eventWithMultipleRelations = mkEvent({
"type": "m.room.message",
@@ -147,7 +148,7 @@ describe("Filter Component", function() {
},
"room": 'roomId',
"event": true,
});
}) as MatrixEvent;
const noMatchEvent = mkEvent({
"type": "m.room.message",
@@ -159,7 +160,7 @@ describe("Filter Component", function() {
},
"room": 'roomId',
"event": true,
});
}) as MatrixEvent;
expect(filter.check(threadRootEvent)).toBe(true);
expect(filter.check(eventWithMultipleRelations)).toBe(true);

View File

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

View File

@@ -1,3 +1,19 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { logger } from "../../src/logger";
import { MatrixClient } from "../../src/client";
import { Filter } from "../../src/filter";
@@ -13,9 +29,12 @@ import {
import { MEGOLM_ALGORITHM } from "../../src/crypto/olmlib";
import { EventStatus, MatrixEvent } from "../../src/models/event";
import { Preset } from "../../src/@types/partials";
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 { makeBeaconEvent } from "../test-utils/beacon";
jest.useFakeTimers();
@@ -72,7 +91,12 @@ describe("MatrixClient", function() {
let pendingLookup = null;
function httpReq(cb, method, path, qp, data, prefix) {
if (path === KEEP_ALIVE_PATH && acceptKeepalives) {
return Promise.resolve();
return Promise.resolve({
unstable_features: {
"org.matrix.msc3440.stable": true,
},
versions: ["r0.6.0", "r0.6.1"],
});
}
const next = httpLookups.shift();
const logLine = (
@@ -793,11 +817,12 @@ describe("MatrixClient", function() {
}
},
},
threads: {
get: jest.fn(),
},
getThread: jest.fn(),
addPendingEvent: jest.fn(),
updatePendingEvent: jest.fn(),
reEmitter: {
reEmit: jest.fn(),
},
};
beforeEach(() => {
@@ -941,6 +966,7 @@ describe("MatrixClient", function() {
it("partitions root events to room timeline and thread timeline", () => {
const supportsExperimentalThreads = client.supportsExperimentalThreads;
client.supportsExperimentalThreads = () => true;
const room = new Room("!room1:matrix.org", client, userId);
const rootEvent = new MatrixEvent({
"content": {},
@@ -963,15 +989,55 @@ describe("MatrixClient", function() {
expect(rootEvent.isThreadRoot).toBe(true);
const [room, threads] = client.partitionThreadedEvents([rootEvent]);
expect(room).toHaveLength(1);
expect(threads).toHaveLength(1);
const [roomEvents, threadEvents] = room.partitionThreadedEvents([rootEvent]);
expect(roomEvents).toHaveLength(1);
expect(threadEvents).toHaveLength(1);
// Restore method
client.supportsExperimentalThreads = supportsExperimentalThreads;
});
});
describe("read-markers and read-receipts", () => {
it("setRoomReadMarkers", () => {
client.setRoomReadMarkersHttpRequest = jest.fn();
const room = {
hasPendingEvent: jest.fn().mockReturnValue(false),
addLocalEchoReceipt: jest.fn(),
};
const rrEvent = new MatrixEvent({ event_id: "read_event_id" });
const rpEvent = new MatrixEvent({ event_id: "read_private_event_id" });
client.getRoom = () => room;
client.setRoomReadMarkers(
"room_id",
"read_marker_event_id",
rrEvent,
rpEvent,
);
expect(client.setRoomReadMarkersHttpRequest).toHaveBeenCalledWith(
"room_id",
"read_marker_event_id",
"read_event_id",
"read_private_event_id",
);
expect(room.addLocalEchoReceipt).toHaveBeenCalledTimes(2);
expect(room.addLocalEchoReceipt).toHaveBeenNthCalledWith(
1,
client.credentials.userId,
rrEvent,
ReceiptType.Read,
);
expect(room.addLocalEchoReceipt).toHaveBeenNthCalledWith(
2,
client.credentials.userId,
rpEvent,
ReceiptType.ReadPrivate,
);
});
});
describe("beacons", () => {
const roomId = '!room:server.org';
const content = makeBeaconInfoContent(100, true);
@@ -981,10 +1047,10 @@ describe("MatrixClient", function() {
});
it("creates new beacon info", async () => {
await client.unstable_createLiveBeacon(roomId, content, '123');
await client.unstable_createLiveBeacon(roomId, content);
// event type combined
const expectedEventType = `${M_BEACON_INFO.name}.${userId}.123`;
const expectedEventType = M_BEACON_INFO.name;
const [callback, method, path, queryParams, requestContent] = client.http.authedRequest.mock.calls[0];
expect(callback).toBeFalsy();
expect(method).toBe('PUT');
@@ -997,17 +1063,132 @@ describe("MatrixClient", function() {
});
it("updates beacon info with specific event type", async () => {
const eventType = `${M_BEACON_INFO.name}.${userId}.456`;
await client.unstable_setLiveBeacon(roomId, eventType, content);
await client.unstable_setLiveBeacon(roomId, content);
// event type combined
const [, , path, , requestContent] = client.http.authedRequest.mock.calls[0];
expect(path).toEqual(
`/rooms/${encodeURIComponent(roomId)}/state/` +
`${encodeURIComponent(eventType)}/${encodeURIComponent(userId)}`,
`${encodeURIComponent(M_BEACON_INFO.name)}/${encodeURIComponent(userId)}`,
);
expect(requestContent).toEqual(content);
});
describe('processBeaconEvents()', () => {
it('does nothing when events is falsy', () => {
const room = new Room(roomId, client, userId);
const roomStateProcessSpy = jest.spyOn(room.currentState, 'processBeaconEvents');
client.processBeaconEvents(room, undefined);
expect(roomStateProcessSpy).not.toHaveBeenCalled();
});
it('does nothing when events is of length 0', () => {
const room = new Room(roomId, client, userId);
const roomStateProcessSpy = jest.spyOn(room.currentState, 'processBeaconEvents');
client.processBeaconEvents(room, []);
expect(roomStateProcessSpy).not.toHaveBeenCalled();
});
it('calls room states processBeaconEvents with events', () => {
const room = new Room(roomId, client, userId);
const roomStateProcessSpy = jest.spyOn(room.currentState, 'processBeaconEvents');
const messageEvent = testUtils.mkMessage({ room: roomId, user: userId, event: true });
const beaconEvent = makeBeaconEvent(userId);
client.processBeaconEvents(room, [messageEvent, beaconEvent]);
expect(roomStateProcessSpy).toHaveBeenCalledWith([messageEvent, beaconEvent], client);
});
});
});
describe("setRoomTopic", () => {
const roomId = "!foofoofoofoofoofoo:matrix.org";
const createSendStateEventMock = (topic: string, htmlTopic?: string) => {
return jest.fn()
.mockImplementation((roomId: string, eventType: string, content: any, stateKey: string) => {
expect(roomId).toEqual(roomId);
expect(eventType).toEqual(EventType.RoomTopic);
expect(content).toMatchObject(ContentHelpers.makeTopicContent(topic, htmlTopic));
expect(stateKey).toBeUndefined();
return Promise.resolve();
});
};
it("is called with plain text topic and sends state event", async () => {
const sendStateEvent = createSendStateEventMock("pizza");
client.sendStateEvent = sendStateEvent;
await client.setRoomTopic(roomId, "pizza");
expect(sendStateEvent).toHaveBeenCalledTimes(1);
});
it("is called with plain text topic and callback and sends state event", async () => {
const sendStateEvent = createSendStateEventMock("pizza");
client.sendStateEvent = sendStateEvent;
await client.setRoomTopic(roomId, "pizza", () => {});
expect(sendStateEvent).toHaveBeenCalledTimes(1);
});
it("is called with plain text and HTML topic and sends state event", async () => {
const sendStateEvent = createSendStateEventMock("pizza", "<b>pizza</b>");
client.sendStateEvent = sendStateEvent;
await client.setRoomTopic(roomId, "pizza", "<b>pizza</b>");
expect(sendStateEvent).toHaveBeenCalledTimes(1);
});
});
describe("setPassword", () => {
const auth = { session: 'abcdef', type: 'foo' };
const newPassword = 'newpassword';
const callback = () => {};
const passwordTest = (expectedRequestContent: any, expectedCallback?: Function) => {
const [callback, method, path, queryParams, requestContent] = client.http.authedRequest.mock.calls[0];
if (expectedCallback) {
expect(callback).toBe(expectedCallback);
} else {
expect(callback).toBeFalsy();
}
expect(method).toBe('POST');
expect(path).toEqual('/account/password');
expect(queryParams).toBeFalsy();
expect(requestContent).toEqual(expectedRequestContent);
};
beforeEach(() => {
client.http.authedRequest.mockClear().mockResolvedValue({});
});
it("no logout_devices specified", async () => {
await client.setPassword(auth, newPassword);
passwordTest({ auth, new_password: newPassword });
});
it("no logout_devices specified + callback", async () => {
await client.setPassword(auth, newPassword, callback);
passwordTest({ auth, new_password: newPassword }, callback);
});
it("overload logoutDevices=true", async () => {
await client.setPassword(auth, newPassword, true);
passwordTest({ auth, new_password: newPassword, logout_devices: true });
});
it("overload logoutDevices=true + callback", async () => {
await client.setPassword(auth, newPassword, true, callback);
passwordTest({ auth, new_password: newPassword, logout_devices: true }, callback);
});
it("overload logoutDevices=false", async () => {
await client.setPassword(auth, newPassword, false);
passwordTest({ auth, new_password: newPassword, logout_devices: false });
});
it("overload logoutDevices=false + callback", async () => {
await client.setPassword(auth, newPassword, false, callback);
passwordTest({ auth, new_password: newPassword, logout_devices: false }, callback);
});
});
});

View File

@@ -14,15 +14,12 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { EventType } from "../../../src";
import { M_BEACON_INFO } from "../../../src/@types/beacon";
import {
isTimestampInDuration,
isBeaconInfoEventType,
Beacon,
BeaconEvent,
} from "../../../src/models/beacon";
import { makeBeaconInfoEvent } from "../../test-utils/beacon";
import { makeBeaconEvent, makeBeaconInfoEvent } from "../../test-utils/beacon";
jest.useFakeTimers();
@@ -57,27 +54,9 @@ describe('Beacon', () => {
});
});
describe('isBeaconInfoEventType', () => {
it.each([
EventType.CallAnswer,
`prefix.${M_BEACON_INFO.name}`,
`prefix.${M_BEACON_INFO.altName}`,
])('returns false for %s', (type) => {
expect(isBeaconInfoEventType(type)).toBe(false);
});
it.each([
M_BEACON_INFO.name,
M_BEACON_INFO.altName,
`${M_BEACON_INFO.name}.@test:server.org.12345`,
`${M_BEACON_INFO.altName}.@test:server.org.12345`,
])('returns true for %s', (type) => {
expect(isBeaconInfoEventType(type)).toBe(true);
});
});
describe('Beacon', () => {
const userId = '@user:server.org';
const userId2 = '@user2:server.org';
const roomId = '$room:server.org';
// 14.03.2022 16:15
const now = 1647270879403;
@@ -88,6 +67,7 @@ describe('Beacon', () => {
// without timeout of 3 hours
let liveBeaconEvent;
let notLiveBeaconEvent;
let user2BeaconEvent;
const advanceDateAndTime = (ms: number) => {
// bc liveness check uses Date.now we have to advance this mock
@@ -99,13 +79,30 @@ describe('Beacon', () => {
beforeEach(() => {
// go back in time to create the beacon
jest.spyOn(global.Date, 'now').mockReturnValue(now - HOUR_MS);
liveBeaconEvent = makeBeaconInfoEvent(userId, roomId, { timeout: HOUR_MS * 3, isLive: true }, '$live123');
liveBeaconEvent = makeBeaconInfoEvent(
userId,
roomId,
{
timeout: HOUR_MS * 3,
isLive: true,
},
'$live123',
);
notLiveBeaconEvent = makeBeaconInfoEvent(
userId,
roomId,
{ timeout: HOUR_MS * 3, isLive: false },
'$dead123',
);
user2BeaconEvent = makeBeaconInfoEvent(
userId2,
roomId,
{
timeout: HOUR_MS * 3,
isLive: true,
},
'$user2live123',
);
// back to now
jest.spyOn(global.Date, 'now').mockReturnValue(now);
@@ -123,6 +120,8 @@ describe('Beacon', () => {
expect(beacon.isLive).toEqual(true);
expect(beacon.beaconInfoOwner).toEqual(userId);
expect(beacon.beaconInfoEventType).toEqual(liveBeaconEvent.getType());
expect(beacon.identifier).toEqual(`${roomId}_${userId}`);
expect(beacon.beaconInfo).toBeTruthy();
});
describe('isLive()', () => {
@@ -159,8 +158,27 @@ describe('Beacon', () => {
expect(beacon.beaconInfoId).toEqual(liveBeaconEvent.getId());
expect(() => beacon.update(notLiveBeaconEvent)).toThrow();
expect(beacon.isLive).toEqual(true);
expect(() => beacon.update(user2BeaconEvent)).toThrow();
// didnt update
expect(beacon.identifier).toEqual(`${roomId}_${userId}`);
});
it('does not update with an older event', () => {
const beacon = new Beacon(liveBeaconEvent);
const emitSpy = jest.spyOn(beacon, 'emit').mockClear();
expect(beacon.beaconInfoId).toEqual(liveBeaconEvent.getId());
const oldUpdateEvent = makeBeaconInfoEvent(
userId,
roomId,
);
// less than the original event
oldUpdateEvent.event.origin_server_ts = liveBeaconEvent.event.origin_server_ts - 1000;
beacon.update(oldUpdateEvent);
// didnt update
expect(emitSpy).not.toHaveBeenCalled();
expect(beacon.beaconInfoId).toEqual(liveBeaconEvent.getId());
});
it('updates event', () => {
@@ -184,7 +202,11 @@ describe('Beacon', () => {
expect(beacon.isLive).toEqual(true);
const updatedBeaconEvent = makeBeaconInfoEvent(
userId, roomId, { timeout: HOUR_MS * 3, isLive: false }, beacon.beaconInfoId);
userId,
roomId,
{ timeout: HOUR_MS * 3, isLive: false },
beacon.beaconInfoId,
);
beacon.update(updatedBeaconEvent);
expect(beacon.isLive).toEqual(false);
@@ -223,7 +245,23 @@ describe('Beacon', () => {
expect(emitSpy).toHaveBeenCalledWith(BeaconEvent.LivenessChange, false, beacon);
});
it('destroy kills liveness monitor', () => {
it('clears monitor interval when re-monitoring liveness', () => {
// live beacon was created an hour ago
// and has a 3hr duration
const beacon = new Beacon(liveBeaconEvent);
expect(beacon.isLive).toBeTruthy();
beacon.monitorLiveness();
// @ts-ignore
const oldMonitor = beacon.livenessWatchInterval;
beacon.monitorLiveness();
// @ts-ignore
expect(beacon.livenessWatchInterval).not.toEqual(oldMonitor);
});
it('destroy kills liveness monitor and emits', () => {
// live beacon was created an hour ago
// and has a 3hr duration
const beacon = new Beacon(liveBeaconEvent);
@@ -234,9 +272,101 @@ describe('Beacon', () => {
// destroy the beacon
beacon.destroy();
expect(emitSpy).toHaveBeenCalledWith(BeaconEvent.Destroy, beacon.identifier);
// live forced to false
expect(beacon.isLive).toBe(false);
advanceDateAndTime(HOUR_MS * 2 + 1);
// no additional calls
expect(emitSpy).toHaveBeenCalledTimes(1);
});
});
describe('addLocations', () => {
it('ignores locations when beacon is not live', () => {
const beacon = new Beacon(makeBeaconInfoEvent(userId, roomId, { isLive: false }));
const emitSpy = jest.spyOn(beacon, 'emit');
beacon.addLocations([
makeBeaconEvent(userId, { beaconInfoId: beacon.beaconInfoId, timestamp: now + 1 }),
]);
expect(beacon.latestLocationState).toBeFalsy();
expect(emitSpy).not.toHaveBeenCalled();
});
it('ignores locations outside the beacon live duration', () => {
const beacon = new Beacon(makeBeaconInfoEvent(userId, roomId, { isLive: true, timeout: 60000 }));
const emitSpy = jest.spyOn(beacon, 'emit');
beacon.addLocations([
// beacon has now + 60000 live period
makeBeaconEvent(userId, { beaconInfoId: beacon.beaconInfoId, timestamp: now + 100000 }),
]);
expect(beacon.latestLocationState).toBeFalsy();
expect(emitSpy).not.toHaveBeenCalled();
});
it('sets latest location state to most recent location', () => {
const beacon = new Beacon(makeBeaconInfoEvent(userId, roomId, { isLive: true, timeout: 60000 }));
const emitSpy = jest.spyOn(beacon, 'emit');
const locations = [
// older
makeBeaconEvent(
userId, { beaconInfoId: beacon.beaconInfoId, uri: 'geo:foo', timestamp: now + 1 },
),
// newer
makeBeaconEvent(
userId, { beaconInfoId: beacon.beaconInfoId, uri: 'geo:bar', timestamp: now + 10000 },
),
// not valid
makeBeaconEvent(
userId, { beaconInfoId: beacon.beaconInfoId, uri: 'geo:baz', timestamp: now - 5 },
),
];
beacon.addLocations(locations);
const expectedLatestLocation = {
description: undefined,
timestamp: now + 10000,
uri: 'geo:bar',
};
// the newest valid location
expect(beacon.latestLocationState).toEqual(expectedLatestLocation);
expect(emitSpy).toHaveBeenCalledWith(BeaconEvent.LocationUpdate, expectedLatestLocation);
});
it('ignores locations that are less recent that the current latest location', () => {
const beacon = new Beacon(makeBeaconInfoEvent(userId, roomId, { isLive: true, timeout: 60000 }));
const olderLocation = makeBeaconEvent(
userId, { beaconInfoId: beacon.beaconInfoId, uri: 'geo:foo', timestamp: now + 1 },
);
const newerLocation = makeBeaconEvent(
userId, { beaconInfoId: beacon.beaconInfoId, uri: 'geo:bar', timestamp: now + 10000 },
);
beacon.addLocations([newerLocation]);
// latest location set to newerLocation
expect(beacon.latestLocationState).toEqual(expect.objectContaining({
uri: 'geo:bar',
}));
const emitSpy = jest.spyOn(beacon, 'emit').mockClear();
// add older location
beacon.addLocations([olderLocation]);
// no change
expect(beacon.latestLocationState).toEqual(expect.objectContaining({
uri: 'geo:bar',
}));
// no emit
expect(emitSpy).not.toHaveBeenCalled();
});
});

View File

@@ -57,4 +57,31 @@ describe('MatrixEvent', () => {
expect(a.toSnapshot().isEquivalentTo(a)).toBe(true);
expect(a.toSnapshot().isEquivalentTo(b)).toBe(false);
});
it("should prune clearEvent when being redacted", () => {
const ev = new MatrixEvent({
type: "m.room.message",
content: {
body: "Test",
},
event_id: "$event1:server",
});
expect(ev.getContent().body).toBe("Test");
expect(ev.getWireContent().body).toBe("Test");
ev.makeEncrypted("m.room.encrypted", { ciphertext: "xyz" }, "", "");
expect(ev.getContent().body).toBe("Test");
expect(ev.getWireContent().body).toBeUndefined();
expect(ev.getWireContent().ciphertext).toBe("xyz");
const redaction = new MatrixEvent({
type: "m.room.redaction",
redacts: ev.getId(),
});
ev.makeRedacted(redaction);
expect(ev.getContent().body).toBeUndefined();
expect(ev.getWireContent().body).toBeUndefined();
expect(ev.getWireContent().ciphertext).toBeUndefined();
});
});

View File

@@ -0,0 +1,28 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { Thread } from "../../../src/models/thread";
describe('Thread', () => {
describe("constructor", () => {
it("should explode for element-web#22141 logging", () => {
// Logging/debugging for https://github.com/vector-im/element-web/issues/22141
expect(() => {
new Thread("$event", undefined, {} as any); // deliberate cast to test error case
}).toThrow("element-web#22141: A thread requires a room in order to function");
});
});
});

View File

@@ -1,5 +1,6 @@
import * as utils from "../test-utils/test-utils";
import { PushProcessor } from "../../src/pushprocessor";
import { EventType } from "../../src";
describe('NotificationService', function() {
const testUserId = "@ali:matrix.org";
@@ -208,6 +209,7 @@ describe('NotificationService', function() {
msgtype: "m.text",
},
});
matrixClient.pushRules = PushProcessor.rewriteDefaultRules(matrixClient.pushRules);
pushProcessor = new PushProcessor(matrixClient);
});
@@ -295,6 +297,21 @@ describe('NotificationService', function() {
expect(actions.tweaks.highlight).toEqual(false);
});
it('should not bing on room server ACL changes', function() {
testEvent = utils.mkEvent({
type: EventType.RoomServerAcl,
room: testRoomId,
user: "@alfred:localhost",
event: true,
content: {},
});
const actions = pushProcessor.actionsForEvent(testEvent);
expect(actions.tweaks.highlight).toBeFalsy();
expect(actions.tweaks.sound).toBeFalsy();
expect(actions.notify).toBeFalsy();
});
// invalid
it('should gracefully handle bad input.', function() {

View File

@@ -130,4 +130,51 @@ describe("Relations", function() {
await relationsCreated;
}
});
it("should ignore m.replace for state events", async () => {
const userId = "@bob:example.com";
const room = new Room("room123", null, userId);
const relations = new Relations("m.replace", "m.room.topic", room);
// Create an instance of a state event with rel_type m.replace
const originalTopic = new MatrixEvent({
"sender": userId,
"type": "m.room.topic",
"event_id": "$orig",
"room_id": room.roomId,
"content": {
"topic": "orig",
},
"state_key": "",
});
const badlyEditedTopic = new MatrixEvent({
"sender": userId,
"type": "m.room.topic",
"event_id": "$orig",
"room_id": room.roomId,
"content": {
"topic": "topic",
"m.new_content": {
"topic": "edit",
},
"m.relates_to": {
"event_id": "$orig",
"rel_type": "m.replace",
},
},
"state_key": "",
});
await relations.setTargetEvent(originalTopic);
expect(originalTopic.replacingEvent()).toBe(null);
expect(originalTopic.getContent().topic).toBe("orig");
expect(badlyEditedTopic.isRelation()).toBe(false);
expect(badlyEditedTopic.isRelation("m.replace")).toBe(false);
await relations.addEvent(badlyEditedTopic);
expect(originalTopic.replacingEvent()).toBe(null);
expect(originalTopic.getContent().topic).toBe("orig");
expect(badlyEditedTopic.replacingEvent()).toBe(null);
expect(badlyEditedTopic.getContent().topic).toBe("topic");
});
});

View File

@@ -1,8 +1,14 @@
import * as utils from "../test-utils/test-utils";
import { makeBeaconInfoEvent } from "../test-utils/beacon";
import { makeBeaconEvent, makeBeaconInfoEvent } from "../test-utils/beacon";
import { filterEmitCallsByEventType } from "../test-utils/emitter";
import { RoomState, RoomStateEvent } from "../../src/models/room-state";
import { BeaconEvent } from "../../src/models/beacon";
import { BeaconEvent, getBeaconInfoIdentifier } from "../../src/models/beacon";
import { EventType, RelationType } from "../../src/@types/event";
import {
MatrixEvent,
MatrixEventEvent,
} from "../../src/models/event";
import { M_BEACON } from "../../src/@types/beacon";
describe("RoomState", function() {
const roomId = "!foo:bar";
@@ -252,6 +258,7 @@ describe("RoomState", function() {
);
});
describe('beacon events', () => {
it('adds new beacon info events to state and emits', () => {
const beaconEvent = makeBeaconInfoEvent(userA, roomId);
const emitSpy = jest.spyOn(state, 'emit');
@@ -259,31 +266,64 @@ describe("RoomState", function() {
state.setStateEvents([beaconEvent]);
expect(state.beacons.size).toEqual(1);
const beaconInstance = state.beacons.get(beaconEvent.getId());
const beaconInstance = state.beacons.get(`${roomId}_${userA}`);
expect(beaconInstance).toBeTruthy();
expect(emitSpy).toHaveBeenCalledWith(BeaconEvent.New, beaconEvent, beaconInstance);
});
it('does not add redacted beacon info events to state', () => {
const redactedBeaconEvent = makeBeaconInfoEvent(userA, roomId);
const redactionEvent = { event: { type: 'm.room.redaction' } };
redactedBeaconEvent.makeRedacted(redactionEvent);
const emitSpy = jest.spyOn(state, 'emit');
state.setStateEvents([redactedBeaconEvent]);
// no beacon added
expect(state.beacons.size).toEqual(0);
expect(state.beacons.get(getBeaconInfoIdentifier(redactedBeaconEvent))).toBeFalsy();
// no new beacon emit
expect(filterEmitCallsByEventType(BeaconEvent.New, emitSpy).length).toBeFalsy();
});
it('updates existing beacon info events in state', () => {
const beaconId = '$beacon1';
const beaconEvent = makeBeaconInfoEvent(userA, roomId, { isLive: true }, beaconId);
const updatedBeaconEvent = makeBeaconInfoEvent(userA, roomId, { isLive: false }, beaconId);
state.setStateEvents([beaconEvent]);
const beaconInstance = state.beacons.get(beaconId);
const beaconInstance = state.beacons.get(getBeaconInfoIdentifier(beaconEvent));
expect(beaconInstance.isLive).toEqual(true);
state.setStateEvents([updatedBeaconEvent]);
// same Beacon
expect(state.beacons.get(beaconId)).toBe(beaconInstance);
expect(state.beacons.get(getBeaconInfoIdentifier(beaconEvent))).toBe(beaconInstance);
// updated liveness
expect(state.beacons.get(beaconId).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() } };
redactedBeaconEvent.makeRedacted(redactionEvent);
state.setStateEvents([beaconEvent]);
const beaconInstance = state.beacons.get(getBeaconInfoIdentifier(beaconEvent));
const destroySpy = jest.spyOn(beaconInstance, 'destroy');
expect(beaconInstance.isLive).toEqual(true);
state.setStateEvents([redactedBeaconEvent]);
expect(destroySpy).toHaveBeenCalled();
expect(state.beacons.get(getBeaconInfoIdentifier(beaconEvent))).toBe(undefined);
});
it('updates live beacon ids once after setting state events', () => {
const liveBeaconEvent = makeBeaconInfoEvent(userA, roomId, { isLive: true }, '$beacon1');
const deadBeaconEvent = makeBeaconInfoEvent(userA, roomId, { isLive: false }, '$beacon2');
const deadBeaconEvent = makeBeaconInfoEvent(userB, roomId, { isLive: false }, '$beacon2');
const emitSpy = jest.spyOn(state, 'emit');
@@ -294,16 +334,17 @@ describe("RoomState", function() {
// live beacon is now not live
const updatedLiveBeaconEvent = makeBeaconInfoEvent(
userA, roomId, { isLive: false }, liveBeaconEvent.getId(),
userA, roomId, { isLive: false }, liveBeaconEvent.getId(), '$beacon1',
);
state.setStateEvents([updatedLiveBeaconEvent]);
expect(state.hasLiveBeacons).toBe(false);
expect(filterEmitCallsByEventType(RoomStateEvent.BeaconLiveness, emitSpy).length).toBe(2);
expect(filterEmitCallsByEventType(RoomStateEvent.BeaconLiveness, emitSpy).length).toBe(3);
expect(emitSpy).toHaveBeenCalledWith(RoomStateEvent.BeaconLiveness, state, false);
});
});
});
describe("setOutOfBandMembers", function() {
it("should add a new member", function() {
@@ -677,4 +718,243 @@ describe("RoomState", function() {
expect(state.maySendEvent('m.room.other_thing', userB)).toEqual(false);
});
});
describe('processBeaconEvents', () => {
const beacon1 = makeBeaconInfoEvent(userA, roomId, {}, '$beacon1', '$beacon1');
const beacon2 = makeBeaconInfoEvent(userB, roomId, {}, '$beacon2', '$beacon2');
const mockClient = { decryptEventIfNeeded: jest.fn() };
beforeEach(() => {
mockClient.decryptEventIfNeeded.mockClear();
});
it('does nothing when state has no beacons', () => {
const emitSpy = jest.spyOn(state, 'emit');
state.processBeaconEvents([makeBeaconEvent(userA, { beaconInfoId: '$beacon1' })], mockClient);
expect(emitSpy).not.toHaveBeenCalled();
expect(mockClient.decryptEventIfNeeded).not.toHaveBeenCalled();
});
it('does nothing when there are no events', () => {
state.setStateEvents([beacon1, beacon2]);
const emitSpy = jest.spyOn(state, 'emit').mockClear();
state.processBeaconEvents([], mockClient);
expect(emitSpy).not.toHaveBeenCalled();
expect(mockClient.decryptEventIfNeeded).not.toHaveBeenCalled();
});
describe('without encryption', () => {
it('discards events for beacons that are not in state', () => {
const location = makeBeaconEvent(userA, {
beaconInfoId: 'some-other-beacon',
});
const otherRelatedEvent = new MatrixEvent({
sender: userA,
type: EventType.RoomMessage,
content: {
['m.relates_to']: {
event_id: 'whatever',
},
},
});
state.setStateEvents([beacon1, beacon2]);
const emitSpy = jest.spyOn(state, 'emit').mockClear();
state.processBeaconEvents([location, otherRelatedEvent], mockClient);
expect(emitSpy).not.toHaveBeenCalled();
});
it('discards events that are not beacon type', () => {
// related to beacon1
const otherRelatedEvent = new MatrixEvent({
sender: userA,
type: EventType.RoomMessage,
content: {
['m.relates_to']: {
rel_type: RelationType.Reference,
event_id: beacon1.getId(),
},
},
});
state.setStateEvents([beacon1, beacon2]);
const emitSpy = jest.spyOn(state, 'emit').mockClear();
state.processBeaconEvents([otherRelatedEvent], mockClient);
expect(emitSpy).not.toHaveBeenCalled();
});
it('adds locations to beacons', () => {
const location1 = makeBeaconEvent(userA, {
beaconInfoId: '$beacon1', timestamp: Date.now() + 1,
});
const location2 = makeBeaconEvent(userA, {
beaconInfoId: '$beacon1', timestamp: Date.now() + 2,
});
const location3 = makeBeaconEvent(userB, {
beaconInfoId: 'some-other-beacon',
});
state.setStateEvents([beacon1, beacon2], mockClient);
expect(state.beacons.size).toEqual(2);
const beaconInstance = state.beacons.get(getBeaconInfoIdentifier(beacon1));
const addLocationsSpy = jest.spyOn(beaconInstance, 'addLocations');
state.processBeaconEvents([location1, location2, location3], mockClient);
expect(addLocationsSpy).toHaveBeenCalledTimes(2);
// only called with locations for beacon1
expect(addLocationsSpy).toHaveBeenCalledWith([location1]);
expect(addLocationsSpy).toHaveBeenCalledWith([location2]);
});
});
describe('with encryption', () => {
const beacon1RelationContent = { ['m.relates_to']: {
rel_type: RelationType.Reference,
event_id: beacon1.getId(),
} };
const relatedEncryptedEvent = new MatrixEvent({
sender: userA,
type: EventType.RoomMessageEncrypted,
content: beacon1RelationContent,
});
const decryptingRelatedEvent = new MatrixEvent({
sender: userA,
type: EventType.RoomMessageEncrypted,
content: beacon1RelationContent,
});
jest.spyOn(decryptingRelatedEvent, 'isBeingDecrypted').mockReturnValue(true);
const failedDecryptionRelatedEvent = new MatrixEvent({
sender: userA,
type: EventType.RoomMessageEncrypted,
content: beacon1RelationContent,
});
jest.spyOn(failedDecryptionRelatedEvent, 'isDecryptionFailure').mockReturnValue(true);
it('discards events without relations', () => {
const unrelatedEvent = new MatrixEvent({
sender: userA,
type: EventType.RoomMessageEncrypted,
});
state.setStateEvents([beacon1, beacon2]);
const emitSpy = jest.spyOn(state, 'emit').mockClear();
state.processBeaconEvents([unrelatedEvent], mockClient);
expect(emitSpy).not.toHaveBeenCalled();
// discard unrelated events early
expect(mockClient.decryptEventIfNeeded).not.toHaveBeenCalled();
});
it('discards events for beacons that are not in state', () => {
const location = makeBeaconEvent(userA, {
beaconInfoId: 'some-other-beacon',
});
const otherRelatedEvent = new MatrixEvent({
sender: userA,
type: EventType.RoomMessageEncrypted,
content: {
['m.relates_to']: {
rel_type: RelationType.Reference,
event_id: 'whatever',
},
},
});
state.setStateEvents([beacon1, beacon2]);
const beacon = state.beacons.get(getBeaconInfoIdentifier(beacon1));
const addLocationsSpy = jest.spyOn(beacon, 'addLocations').mockClear();
state.processBeaconEvents([location, otherRelatedEvent], mockClient);
expect(addLocationsSpy).not.toHaveBeenCalled();
// discard unrelated events early
expect(mockClient.decryptEventIfNeeded).not.toHaveBeenCalled();
});
it('decrypts related events if needed', () => {
const location = makeBeaconEvent(userA, {
beaconInfoId: beacon1.getId(),
});
state.setStateEvents([beacon1, beacon2]);
state.processBeaconEvents([location, relatedEncryptedEvent], mockClient);
// discard unrelated events early
expect(mockClient.decryptEventIfNeeded).toHaveBeenCalledTimes(2);
});
it('listens for decryption on events that are being decrypted', () => {
const decryptingRelatedEvent = new MatrixEvent({
sender: userA,
type: EventType.RoomMessageEncrypted,
content: beacon1RelationContent,
});
jest.spyOn(decryptingRelatedEvent, 'isBeingDecrypted').mockReturnValue(true);
// spy on event.once
const eventOnceSpy = jest.spyOn(decryptingRelatedEvent, 'once');
state.setStateEvents([beacon1, beacon2]);
state.processBeaconEvents([decryptingRelatedEvent], mockClient);
// listener was added
expect(eventOnceSpy).toHaveBeenCalled();
});
it('listens for decryption on events that have decryption failure', () => {
const failedDecryptionRelatedEvent = new MatrixEvent({
sender: userA,
type: EventType.RoomMessageEncrypted,
content: beacon1RelationContent,
});
jest.spyOn(failedDecryptionRelatedEvent, 'isDecryptionFailure').mockReturnValue(true);
// spy on event.once
const eventOnceSpy = jest.spyOn(decryptingRelatedEvent, 'once');
state.setStateEvents([beacon1, beacon2]);
state.processBeaconEvents([decryptingRelatedEvent], mockClient);
// listener was added
expect(eventOnceSpy).toHaveBeenCalled();
});
it('discard events that are not m.beacon type after decryption', () => {
const decryptingRelatedEvent = new MatrixEvent({
sender: userA,
type: EventType.RoomMessageEncrypted,
content: beacon1RelationContent,
});
jest.spyOn(decryptingRelatedEvent, 'isBeingDecrypted').mockReturnValue(true);
state.setStateEvents([beacon1, beacon2]);
const beacon = state.beacons.get(getBeaconInfoIdentifier(beacon1));
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);
expect(addLocationsSpy).not.toHaveBeenCalled();
});
it('adds locations to beacons after decryption', () => {
const decryptingRelatedEvent = new MatrixEvent({
sender: userA,
type: EventType.RoomMessageEncrypted,
content: beacon1RelationContent,
});
const locationEvent = makeBeaconEvent(userA, {
beaconInfoId: '$beacon1', timestamp: Date.now() + 1,
});
jest.spyOn(decryptingRelatedEvent, 'isBeingDecrypted').mockReturnValue(true);
state.setStateEvents([beacon1, beacon2]);
const beacon = state.beacons.get(getBeaconInfoIdentifier(beacon1));
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);
expect(addLocationsSpy).toHaveBeenCalledWith([decryptingRelatedEvent]);
});
});
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -15,6 +15,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { ReceiptType } from "../../src/@types/read_receipts";
import { SyncAccumulator } from "../../src/sync-accumulator";
// The event body & unsigned object get frozen to assert that they don't get altered
@@ -294,10 +295,13 @@ describe("SyncAccumulator", function() {
room_id: "!foo:bar",
content: {
"$event1:localhost": {
"m.read": {
[ReceiptType.Read]: {
"@alice:localhost": { ts: 1 },
"@bob:localhost": { ts: 2 },
},
[ReceiptType.ReadPrivate]: {
"@dan:localhost": { ts: 4 },
},
"some.other.receipt.type": {
"@should_be_ignored:localhost": { key: "val" },
},
@@ -309,7 +313,7 @@ describe("SyncAccumulator", function() {
room_id: "!foo:bar",
content: {
"$event2:localhost": {
"m.read": {
[ReceiptType.Read]: {
"@bob:localhost": { ts: 2 }, // clobbers event1 receipt
"@charlie:localhost": { ts: 3 },
},
@@ -337,12 +341,15 @@ describe("SyncAccumulator", function() {
room_id: "!foo:bar",
content: {
"$event1:localhost": {
"m.read": {
[ReceiptType.Read]: {
"@alice:localhost": { ts: 1 },
},
[ReceiptType.ReadPrivate]: {
"@dan:localhost": { ts: 4 },
},
},
"$event2:localhost": {
"m.read": {
[ReceiptType.Read]: {
"@bob:localhost": { ts: 2 },
"@charlie:localhost": { ts: 3 },
},

View File

@@ -10,8 +10,11 @@ import {
prevString,
simpleRetryOperation,
stringToBase,
sortEventsByLatestContentTimestamp,
} from "../../src/utils";
import { logger } from "../../src/logger";
import { mkMessage } from "../test-utils/test-utils";
import { makeBeaconEvent } from "../test-utils/beacon";
// TODO: Fix types throughout
@@ -506,4 +509,30 @@ describe("utils", function() {
});
});
});
describe('sortEventsByLatestContentTimestamp', () => {
const roomId = '!room:server';
const userId = '@user:server';
const eventWithoutContentTimestamp = mkMessage({ room: roomId, user: userId, event: true });
// m.beacon events have timestamp in content
const beaconEvent1 = makeBeaconEvent(userId, { timestamp: 1648804528557 });
const beaconEvent2 = makeBeaconEvent(userId, { timestamp: 1648804528558 });
const beaconEvent3 = makeBeaconEvent(userId, { timestamp: 1648804528000 });
const beaconEvent4 = makeBeaconEvent(userId, { timestamp: 0 });
it('sorts events with timestamps as later than events without', () => {
expect(
[beaconEvent4, eventWithoutContentTimestamp, beaconEvent1]
.sort(utils.sortEventsByLatestContentTimestamp),
).toEqual([
beaconEvent1, beaconEvent4, eventWithoutContentTimestamp,
]);
});
it('sorts by content timestamps correctly', () => {
expect(
[beaconEvent1, beaconEvent2, beaconEvent3].sort(sortEventsByLatestContentTimestamp),
).toEqual([beaconEvent2, beaconEvent1, beaconEvent3]);
});
});
});

View File

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

View File

@@ -0,0 +1,54 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { TestClient } from '../../TestClient';
import { ClientEvent, EventType, MatrixEvent, RoomEvent } from "../../../src";
import { CallEventHandler, CallEventHandlerEvent } from "../../../src/webrtc/callEventHandler";
import { SyncState } from "../../../src/sync";
describe("callEventHandler", () => {
it("should ignore a call if invite & hangup come within a single sync", () => {
const testClient = new TestClient();
const client = testClient.client;
client.callEventHandler = new CallEventHandler(client);
client.callEventHandler.start();
// Fire off call invite then hangup within a single sync
const callInvite = new MatrixEvent({
type: EventType.CallInvite,
content: {
call_id: "123",
},
});
client.emit(RoomEvent.Timeline, callInvite);
const callHangup = new MatrixEvent({
type: EventType.CallHangup,
content: {
call_id: "123",
},
});
client.emit(RoomEvent.Timeline, callHangup);
const incomingCallEmitted = jest.fn();
client.on(CallEventHandlerEvent.Incoming, incomingCallEmitted);
client.getSyncState = jest.fn().mockReturnValue(SyncState.Syncing);
client.emit(ClientEvent.Sync);
expect(incomingCallEmitted).not.toHaveBeenCalled();
});
});

View File

@@ -14,14 +14,14 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { EitherAnd, RELATES_TO_RELATIONSHIP, REFERENCE_RELATION } from "matrix-events-sdk";
import { RELATES_TO_RELATIONSHIP, REFERENCE_RELATION } from "matrix-events-sdk";
import { UnstableValue } from "../NamespacedValue";
import { MAssetEvent, MLocationEvent, MTimestampEvent } from "./location";
/**
* Beacon info and beacon event types as described in MSC3489
* https://github.com/matrix-org/matrix-spec-proposals/pull/3489
* Beacon info and beacon event types as described in MSC3672
* https://github.com/matrix-org/matrix-spec-proposals/pull/3672
*/
/**
@@ -60,16 +60,11 @@ import { MAssetEvent, MLocationEvent, MTimestampEvent } from "./location";
* }
*/
/**
* Variable event type for m.beacon_info
*/
export const M_BEACON_INFO_VARIABLE = new UnstableValue("m.beacon_info.*", "org.matrix.msc3489.beacon_info.*");
/**
* Non-variable type for m.beacon_info event content
*/
export const M_BEACON_INFO = new UnstableValue("m.beacon_info", "org.matrix.msc3489.beacon_info");
export const M_BEACON = new UnstableValue("m.beacon", "org.matrix.msc3489.beacon");
export const M_BEACON_INFO = new UnstableValue("m.beacon_info", "org.matrix.msc3672.beacon_info");
export const M_BEACON = new UnstableValue("m.beacon", "org.matrix.msc3672.beacon");
export type MBeaconInfoContent = {
description?: string;
@@ -80,16 +75,11 @@ export type MBeaconInfoContent = {
live?: boolean;
};
export type MBeaconInfoEvent = EitherAnd<
{ [M_BEACON_INFO.name]: MBeaconInfoContent },
{ [M_BEACON_INFO.altName]: MBeaconInfoContent }
>;
/**
* m.beacon_info Event example from the spec
* https://github.com/matrix-org/matrix-spec-proposals/pull/3489
* https://github.com/matrix-org/matrix-spec-proposals/pull/3672
* {
"type": "m.beacon_info.@matthew:matrix.org.1",
"type": "m.beacon_info",
"state_key": "@matthew:matrix.org",
"content": {
"m.beacon_info": {
@@ -108,7 +98,7 @@ export type MBeaconInfoEvent = EitherAnd<
* m.beacon_info.* event content
*/
export type MBeaconInfoEventContent = &
MBeaconInfoEvent &
MBeaconInfoContent &
// creation timestamp of the beacon on the client
MTimestampEvent &
// the type of asset being tracked as per MSC3488
@@ -116,7 +106,7 @@ export type MBeaconInfoEventContent = &
/**
* m.beacon event example
* https://github.com/matrix-org/matrix-spec-proposals/pull/3489
* https://github.com/matrix-org/matrix-spec-proposals/pull/3672
*
* {
"type": "m.beacon",

View File

@@ -93,14 +93,7 @@ export enum RelationType {
Annotation = "m.annotation",
Replace = "m.replace",
Reference = "m.reference",
/**
* Note, "io.element.thread" is hardcoded
* Should be replaced with "m.thread" once MSC3440 lands
* Can not use `UnstableValue` as TypeScript does not
* allow computed values in enums
* https://github.com/microsoft/TypeScript/issues/27976
*/
Thread = "io.element.thread",
Thread = "m.thread",
}
export enum MsgType {
@@ -119,6 +112,8 @@ export const RoomCreateTypeField = "type";
export enum RoomType {
Space = "m.space",
UnstableCall = "org.matrix.msc3417.call",
ElementVideo = "io.element.video",
}
/**

View File

@@ -23,6 +23,7 @@ declare global {
// use `number` as the return type in all cases for global.set{Interval,Timeout},
// so we don't accidentally use the methods on NodeJS.Timeout - they only exist in a subset of environments.
// The overload for clear{Interval,Timeout} is resolved as expected.
// We use `ReturnType<typeof setTimeout>` in the code to be agnostic of if this definition gets loaded.
function setInterval(handler: TimerHandler, timeout: number, ...arguments: any[]): number;
function setTimeout(handler: TimerHandler, timeout: number, ...arguments: any[]): number;

View File

@@ -0,0 +1,21 @@
/*
Copyright 2022 Šimon Brandner <simon.bra.ag@gmail.com>
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.
*/
export enum ReceiptType {
Read = "m.read",
FullyRead = "m.fully_read",
ReadPrivate = "org.matrix.msc2285.read.private"
}

View File

@@ -17,9 +17,11 @@ limitations under the License.
import { Callback } from "../client";
import { IContent, IEvent } from "../models/event";
import { Preset, Visibility } from "./partials";
import { SearchKey } from "./search";
import { IEventWithRoomId, SearchKey } from "./search";
import { IRoomEventFilter } from "../filter";
import { Direction } from "../models/event-timeline";
import { PushRuleAction } from "./PushRules";
import { IRoomEvent } from "../sync-accumulator";
// allow camelcase as these are things that go onto the wire
/* eslint-disable camelcase */
@@ -155,4 +157,50 @@ export interface IRelationsResponse {
prev_batch?: string;
}
export interface IContextResponse {
end: string;
start: string;
state: IEventWithRoomId[];
events_before: IEventWithRoomId[];
events_after: IEventWithRoomId[];
event: IEventWithRoomId;
}
export interface IEventsResponse {
chunk: IEventWithRoomId[];
end: string;
start: string;
}
export interface INotification {
actions: PushRuleAction[];
event: IRoomEvent;
profile_tag?: string;
read: boolean;
room_id: string;
ts: number;
}
export interface INotificationsResponse {
next_token: string;
notifications: INotification[];
}
export interface IFilterResponse {
filter_id: string;
}
export interface ITagsResponse {
tags: {
[tagId: string]: {
order: number;
};
};
}
export interface IStatusResponse extends IPresenceOpts {
currently_active?: boolean;
last_active_ago?: number;
}
/* eslint-enable camelcase */

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

@@ -0,0 +1,62 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { EitherAnd, IMessageRendering } from "matrix-events-sdk";
import { UnstableValue } from "../NamespacedValue";
/**
* Extensible topic event type based on MSC3765
* https://github.com/matrix-org/matrix-spec-proposals/pull/3765
*/
/**
* Eg
* {
* "type": "m.room.topic,
* "state_key": "",
* "content": {
* "topic": "All about **pizza**",
* "m.topic": [{
* "body": "All about **pizza**",
* "mimetype": "text/plain",
* }, {
* "body": "All about <b>pizza</b>",
* "mimetype": "text/html",
* }],
* }
* }
*/
/**
* The event type for an m.topic event (in content)
*/
export const M_TOPIC = new UnstableValue("m.topic", "org.matrix.msc3765.topic");
/**
* The event content for an m.topic event (in content)
*/
export type MTopicContent = IMessageRendering[];
/**
* The event definition for an m.topic event (in content)
*/
export type MTopicEvent = EitherAnd<{ [M_TOPIC.name]: MTopicContent }, { [M_TOPIC.altName]: MTopicContent }>;
/**
* The event content for an m.room.topic event
*/
export type MRoomTopicEventContent = { topic: string } & MTopicEvent;

View File

@@ -17,8 +17,6 @@ limitations under the License.
/** @module auto-discovery */
import { URL as NodeURL } from "url";
import { IClientWellKnown, IWellKnownConfig } from "./client";
import { logger } from './logger';
@@ -249,8 +247,7 @@ export class AutoDiscovery {
// Step 7: Copy any other keys directly into the clientConfig. This is for
// things like custom configuration of services.
Object.keys(wellknown)
.map((k) => {
Object.keys(wellknown).forEach((k) => {
if (k === "m.homeserver" || k === "m.identity_server") {
// Only copy selected parts of the config to avoid overwriting
// properties computed by the validation logic above.
@@ -373,16 +370,11 @@ export class AutoDiscovery {
if (!url) return false;
try {
// We have to try and parse the URL using the NodeJS URL
// library if we're on NodeJS and use the browser's URL
// library when we're in a browser. To accomplish this, we
// try the NodeJS version first and fall back to the browser.
let parsed = null;
try {
if (NodeURL) parsed = new NodeURL(url);
else parsed = new URL(url);
} catch (e) {
parsed = new URL(url);
} catch (e) {
logger.error("Could not parse url", e);
}
if (!parsed || !parsed.hostname) return false;
@@ -411,14 +403,14 @@ export class AutoDiscovery {
* the following properties:
* raw: The JSON object returned by the server.
* action: One of SUCCESS, IGNORE, or FAIL_PROMPT.
* reason: Relatively human readable description of what went wrong.
* reason: Relatively human-readable description of what went wrong.
* error: The actual Error, if one exists.
* @param {string} url The URL to fetch a JSON object from.
* @return {Promise<object>} Resolves to the returned state.
* @private
*/
private static async fetchWellKnownObject(url: string): Promise<IWellKnownConfig> {
return new Promise(function(resolve, reject) {
private static fetchWellKnownObject(url: string): Promise<IWellKnownConfig> {
return new Promise(function(resolve) {
// eslint-disable-next-line
const request = require("./matrix").getRequest();
if (!request) throw new Error("No request library available");

File diff suppressed because it is too large Load Diff

View File

@@ -16,9 +16,9 @@ limitations under the License.
/** @module ContentHelpers */
import { REFERENCE_RELATION } from "matrix-events-sdk";
import { isProvided, REFERENCE_RELATION } from "matrix-events-sdk";
import { MBeaconEventContent, MBeaconInfoContent, MBeaconInfoEventContent, M_BEACON_INFO } from "./@types/beacon";
import { MBeaconEventContent, MBeaconInfoContent, MBeaconInfoEventContent } from "./@types/beacon";
import { MsgType } from "./@types/event";
import { TEXT_NODE_TYPE } from "./@types/extensible_events";
import {
@@ -32,6 +32,7 @@ import {
MAssetContent,
LegacyLocationEventContent,
} from "./@types/location";
import { MRoomTopicEventContent, MTopicContent, M_TOPIC } from "./@types/topic";
/**
* Generates the content for a HTML Message event
@@ -190,6 +191,34 @@ export const parseLocationEvent = (wireEventContent: LocationEventWireContent):
return makeLocationContent(fallbackText, geoUri, timestamp, description, assetType);
};
/**
* Topic event helpers
*/
export type MakeTopicContent = (
topic: string,
htmlTopic?: string,
) => MRoomTopicEventContent;
export const makeTopicContent: MakeTopicContent = (topic, htmlTopic) => {
const renderings = [{ body: topic, mimetype: "text/plain" }];
if (isProvided(htmlTopic)) {
renderings.push({ body: htmlTopic, mimetype: "text/html" });
}
return { topic, [M_TOPIC.name]: renderings };
};
export type TopicState = {
text: string;
html?: string;
};
export const parseTopicContent = (content: MRoomTopicEventContent): TopicState => {
const mtopic = M_TOPIC.findIn<MTopicContent>(content);
const text = mtopic?.find(r => !isProvided(r.mimetype) || r.mimetype === "text/plain")?.body ?? content.topic;
const html = mtopic?.find(r => r.mimetype === "text/html")?.body;
return { text, html };
};
/**
* Beacon event helpers
*/
@@ -208,11 +237,9 @@ export const makeBeaconInfoContent: MakeBeaconInfoContent = (
assetType,
timestamp,
) => ({
[M_BEACON_INFO.name]: {
description,
timeout,
live: isLive,
},
[M_TIMESTAMP.name]: timestamp || Date.now(),
[M_ASSET.name]: {
type: assetType ?? LocationAssetType.Self,
@@ -227,7 +254,7 @@ export type BeaconInfoState = MBeaconInfoContent & {
* Flatten beacon info event content
*/
export const parseBeaconInfoContent = (content: MBeaconInfoEventContent): BeaconInfoState => {
const { description, timeout, live } = M_BEACON_INFO.findIn<MBeaconInfoContent>(content);
const { description, timeout, live } = content;
const { type: assetType } = M_ASSET.findIn<MAssetContent>(content);
const timestamp = M_TIMESTAMP.findIn<number>(content);
@@ -243,14 +270,14 @@ export const parseBeaconInfoContent = (content: MBeaconInfoEventContent): Beacon
export type MakeBeaconContent = (
uri: string,
timestamp: number,
beaconInfoId: string,
beaconInfoEventId: string,
description?: string,
) => MBeaconEventContent;
export const makeBeaconContent: MakeBeaconContent = (
uri,
timestamp,
beaconInfoId,
beaconInfoEventId,
description,
) => ({
[M_LOCATION.name]: {
@@ -260,6 +287,21 @@ export const makeBeaconContent: MakeBeaconContent = (
[M_TIMESTAMP.name]: timestamp,
"m.relates_to": {
rel_type: REFERENCE_RELATION.name,
event_id: beaconInfoId,
event_id: beaconInfoEventId,
},
});
export type BeaconLocationState = MLocationContent & {
timestamp: number;
};
export const parseBeaconContent = (content: MBeaconEventContent): BeaconLocationState => {
const { description, uri } = M_LOCATION.findIn<MLocationContent>(content);
const timestamp = M_TIMESTAMP.findIn<number>(content);
return {
description,
uri,
timestamp,
};
};

View File

@@ -73,8 +73,8 @@ export function getHttpUriForMxc(
const fragmentOffset = serverAndMediaId.indexOf("#");
let fragment = "";
if (fragmentOffset >= 0) {
fragment = serverAndMediaId.substr(fragmentOffset);
serverAndMediaId = serverAndMediaId.substr(0, fragmentOffset);
fragment = serverAndMediaId.slice(fragmentOffset);
serverAndMediaId = serverAndMediaId.slice(0, fragmentOffset);
}
const urlParams = (Object.keys(params).length === 0 ? "" : ("?" + utils.encodeParams(params)));

View File

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

View File

@@ -95,7 +95,7 @@ export class DeviceList extends TypedEventEmitter<EmittedEvents, CryptoEventHand
// The time the save is scheduled for
private savePromiseTime: number = null;
// The timer used to delay the save
private saveTimer: number = null;
private saveTimer: ReturnType<typeof setTimeout> = null;
// True if we have fetched data from the server or loaded a non-empty
// set of device data from the store
private hasFetched: boolean = null;
@@ -122,7 +122,7 @@ export class DeviceList extends TypedEventEmitter<EmittedEvents, CryptoEventHand
'readonly', [IndexedDBCryptoStore.STORE_DEVICE_DATA], (txn) => {
this.cryptoStore.getEndToEndDeviceData(txn, (deviceData) => {
this.hasFetched = Boolean(deviceData && deviceData.devices);
this.devices = deviceData ? deviceData.devices : {},
this.devices = deviceData ? deviceData.devices : {};
this.crossSigningInfo = deviceData ?
deviceData.crossSigningInfo || {} : {};
this.deviceTrackingStatus = deviceData ?
@@ -190,7 +190,7 @@ export class DeviceList extends TypedEventEmitter<EmittedEvents, CryptoEventHand
let savePromise = this.savePromise;
if (savePromise === null) {
savePromise = new Promise((resolve, reject) => {
savePromise = new Promise((resolve) => {
this.resolveSavePromise = resolve;
});
this.savePromise = savePromise;
@@ -309,10 +309,10 @@ export class DeviceList extends TypedEventEmitter<EmittedEvents, CryptoEventHand
*/
private getDevicesFromStore(userIds: string[]): DeviceInfoMap {
const stored: DeviceInfoMap = {};
userIds.map((u) => {
userIds.forEach((u) => {
stored[u] = {};
const devices = this.getStoredDevicesForUser(u) || [];
devices.map(function(dev) {
devices.forEach(function(dev) {
stored[u][dev.deviceId] = dev;
});
});
@@ -942,7 +942,7 @@ async function updateStoredDeviceKeysForUser(
async function storeDeviceKeys(
olmDevice: OlmDevice,
userStore: Record<string, DeviceInfo>,
deviceResult: any, // TODO types
deviceResult: IDownloadKeyResult["device_keys"]["user_id"]["device_id"],
): Promise<boolean> {
if (!deviceResult.keys) {
// no keys?

View File

@@ -909,12 +909,12 @@ export class OlmDevice {
await this.cryptoStore.storeEndToEndSessionProblem(deviceKey, type, fixed);
}
public async sessionMayHaveProblems(deviceKey: string, timestamp: number): Promise<IProblem> {
return await this.cryptoStore.getEndToEndSessionProblem(deviceKey, timestamp);
public sessionMayHaveProblems(deviceKey: string, timestamp: number): Promise<IProblem> {
return this.cryptoStore.getEndToEndSessionProblem(deviceKey, timestamp);
}
public async filterOutNotifiedErrorDevices(devices: IOlmDevice[]): Promise<IOlmDevice[]> {
return await this.cryptoStore.filterOutNotifiedErrorDevices(devices);
public filterOutNotifiedErrorDevices(devices: IOlmDevice[]): Promise<IOlmDevice[]> {
return this.cryptoStore.filterOutNotifiedErrorDevices(devices);
}
// Outbound group session

View File

@@ -78,7 +78,7 @@ export enum RoomKeyRequestState {
export class OutgoingRoomKeyRequestManager {
// handle for the delayed call to sendOutgoingRoomKeyRequests. Non-null
// if the callback has been set, or if it is still running.
private sendOutgoingRoomKeyRequestsTimer: number = null;
private sendOutgoingRoomKeyRequestsTimer: ReturnType<typeof setTimeout> = null;
// sanity check to ensure that we don't end up with two concurrent runs
// of sendOutgoingRoomKeyRequests
@@ -189,9 +189,7 @@ export class OutgoingRoomKeyRequestManager {
// in state ROOM_KEY_REQUEST_STATES.SENT, so we must have
// raced with another tab to mark the request cancelled.
// Try again, to make sure the request is resent.
return await this.queueRoomKeyRequest(
requestBody, recipients, resend,
);
return this.queueRoomKeyRequest(requestBody, recipients, resend);
}
// We don't want to wait for the timer, so we send it

View File

@@ -329,7 +329,7 @@ export class SecretStorage {
// encoded, since this is how a key would normally be stored.
if (encInfo.passthrough) return encodeBase64(decryption.get_private_key());
return await decryption.decrypt(encInfo);
return decryption.decrypt(encInfo);
} finally {
if (decryption && decryption.free) decryption.free();
}
@@ -339,21 +339,15 @@ export class SecretStorage {
* Check if a secret is stored on the server.
*
* @param {string} name the name of the secret
* @param {boolean} checkKey check if the secret is encrypted by a trusted key
*
* @return {object?} map of key name to key info the secret is encrypted
* with, or null if it is not present or not encrypted with a trusted
* key
*/
public async isStored(name: string, checkKey: boolean): Promise<Record<string, ISecretStorageKeyInfo> | null> {
public async isStored(name: string): Promise<Record<string, ISecretStorageKeyInfo> | null> {
// check if secret exists
const secretInfo = await this.accountDataAdapter.getAccountDataFromServer<ISecretInfo>(name);
if (!secretInfo) return null;
if (!secretInfo.encrypted) {
return null;
}
if (checkKey === undefined) checkKey = true;
if (!secretInfo?.encrypted) return null;
const ret = {};
@@ -598,11 +592,11 @@ export class SecretStorage {
if (keys[keyId].algorithm === SECRET_STORAGE_ALGORITHM_V1_AES) {
const decryption = {
encrypt: async function(secret: string): Promise<IEncryptedPayload> {
return await encryptAES(secret, privateKey, name);
encrypt: function(secret: string): Promise<IEncryptedPayload> {
return encryptAES(secret, privateKey, name);
},
decrypt: async function(encInfo: IEncryptedPayload): Promise<string> {
return await decryptAES(encInfo, privateKey, name);
decrypt: function(encInfo: IEncryptedPayload): Promise<string> {
return decryptAES(encInfo, privateKey, name);
},
};
return [keyId, decryption];

View File

@@ -250,7 +250,7 @@ async function deriveKeysBrowser(key: Uint8Array, name: string): Promise<[Crypto
['sign', 'verify'],
);
return await Promise.all([aesProm, hmacProm]);
return Promise.all([aesProm, hmacProm]);
}
export function encryptAES(data: string, key: Uint8Array, name: string, ivStr?: string): Promise<IEncryptedPayload> {

View File

@@ -70,7 +70,7 @@ class OlmEncryption extends EncryptionAlgorithm {
return Promise.resolve();
}
this.prepPromise = this.crypto.downloadKeys(roomMembers).then((res) => {
this.prepPromise = this.crypto.downloadKeys(roomMembers).then(() => {
return this.crypto.ensureOlmSessionsForUsers(roomMembers);
}).then(() => {
this.sessionPrepared = true;
@@ -144,7 +144,7 @@ class OlmEncryption extends EncryptionAlgorithm {
}
}
return await Promise.all(promises).then(() => encryptedContent);
return Promise.all(promises).then(() => encryptedContent);
}
}
@@ -261,7 +261,7 @@ class OlmDecryption extends DecryptionAlgorithm {
*
* @return {string} payload, if decrypted successfully.
*/
private async decryptMessage(theirDeviceIdentityKey: string, message: IMessage): Promise<string> {
private decryptMessage(theirDeviceIdentityKey: string, message: IMessage): Promise<string> {
// This is a wrapper that serialises decryptions of prekey messages, because
// otherwise we race between deciding we have no active sessions for the message
// and creating a new one, which we can only do once because it removes the OTK.
@@ -274,7 +274,7 @@ class OlmDecryption extends DecryptionAlgorithm {
});
// we want the error, but don't propagate it to the next decryption
this.olmDevice.olmPrekeyPromise = myPromise.catch(() => {});
return await myPromise;
return myPromise;
}
}

View File

@@ -132,18 +132,18 @@ export class BackupManager {
if (!Algorithm) {
throw new Error("Unknown backup algorithm: " + info.algorithm);
}
if (!(typeof info.auth_data === "object")) {
if (typeof info.auth_data !== "object") {
throw new Error("Invalid backup data returned");
}
return Algorithm.checkBackupVersion(info);
}
public static async makeAlgorithm(info: IKeyBackupInfo, getKey: GetKey): Promise<BackupAlgorithm> {
public static makeAlgorithm(info: IKeyBackupInfo, getKey: GetKey): Promise<BackupAlgorithm> {
const Algorithm = algorithmsByName[info.algorithm];
if (!Algorithm) {
throw new Error("Unknown backup algorithm");
}
return await Algorithm.init(info.auth_data, getKey);
return Algorithm.init(info.auth_data, getKey);
}
public async enableKeyBackup(info: IKeyBackupInfo): Promise<void> {
@@ -375,9 +375,7 @@ export class BackupManager {
);
if (device) {
sigInfo.device = device;
sigInfo.deviceTrust = await this.baseApis.checkDeviceTrust(
this.baseApis.getUserId(), sigInfo.deviceId,
);
sigInfo.deviceTrust = this.baseApis.checkDeviceTrust(this.baseApis.getUserId(), sigInfo.deviceId);
try {
await verifySignature(
this.baseApis.crypto.olmDevice,
@@ -430,7 +428,7 @@ export class BackupManager {
// requests from different clients hitting the server all at
// the same time when a new key is sent
const delay = Math.random() * maxDelay;
await sleep(delay, undefined);
await sleep(delay);
let numFailures = 0; // number of consecutive failures
for (;;) {
if (!this.algorithm) {
@@ -464,7 +462,7 @@ export class BackupManager {
}
if (numFailures) {
// exponential backoff if we have failures
await sleep(1000 * Math.pow(2, Math.min(numFailures - 1, 4)), undefined);
await sleep(1000 * Math.pow(2, Math.min(numFailures - 1, 4)));
}
}
} finally {
@@ -476,8 +474,8 @@ export class BackupManager {
* Take some e2e keys waiting to be backed up and send them
* to the backup.
*
* @param {integer} limit Maximum number of keys to back up
* @returns {integer} Number of sessions backed up
* @param {number} limit Maximum number of keys to back up
* @returns {number} Number of sessions backed up
*/
public async backupPendingKeys(limit: number): Promise<number> {
const sessions = await this.baseApis.crypto.cryptoStore.getSessionsNeedingBackup(limit);
@@ -495,7 +493,7 @@ export class BackupManager {
rooms[roomId] = { sessions: {} };
}
const sessionData = await this.baseApis.crypto.olmDevice.exportInboundGroupSession(
const sessionData = this.baseApis.crypto.olmDevice.exportInboundGroupSession(
session.senderKey, session.sessionId, session.sessionData,
);
sessionData.algorithm = MEGOLM_ALGORITHM;
@@ -779,15 +777,15 @@ export class Aes256 implements BackupAlgorithm {
public get untrusted() { return false; }
async encryptSession(data: Record<string, any>): Promise<any> {
public encryptSession(data: Record<string, any>): Promise<any> {
const plainText: Record<string, any> = Object.assign({}, data);
delete plainText.session_id;
delete plainText.room_id;
delete plainText.first_known_index;
return await encryptAES(JSON.stringify(plainText), this.key, data.session_id);
return encryptAES(JSON.stringify(plainText), this.key, data.session_id);
}
async decryptSessions(sessions: Record<string, IKeyBackupSession>): Promise<IMegolmSessionData[]> {
public async decryptSessions(sessions: Record<string, IKeyBackupSession>): Promise<IMegolmSessionData[]> {
const keys: IMegolmSessionData[] = [];
for (const [sessionId, sessionData] of Object.entries(sessions)) {
@@ -802,7 +800,7 @@ export class Aes256 implements BackupAlgorithm {
return keys;
}
async keyMatches(key: Uint8Array): Promise<boolean> {
public async keyMatches(key: Uint8Array): Promise<boolean> {
if (this.authData.mac) {
const { mac } = await calculateKeyCheck(key, this.authData.iv);
return this.authData.mac.replace(/=+$/g, '') === mac.replace(/=+/g, '');

View File

@@ -61,11 +61,13 @@ export class DehydrationManager {
private key: Uint8Array;
private keyInfo: {[props: string]: any};
private deviceDisplayName: string;
constructor(private readonly crypto: Crypto) {
this.getDehydrationKeyFromCache();
}
async getDehydrationKeyFromCache(): Promise<void> {
return await this.crypto.cryptoStore.doTxn(
public getDehydrationKeyFromCache(): Promise<void> {
return this.crypto.cryptoStore.doTxn(
'readonly',
[IndexedDBCryptoStore.STORE_ACCOUNT],
(txn) => {
@@ -93,7 +95,7 @@ export class DehydrationManager {
}
/** set the key, and queue periodic dehydration to the server in the background */
async setKeyAndQueueDehydration(
public async setKeyAndQueueDehydration(
key: Uint8Array, keyInfo: {[props: string]: any} = {},
deviceDisplayName: string = undefined,
): Promise<void> {
@@ -104,7 +106,7 @@ export class DehydrationManager {
}
}
async setKey(
public async setKey(
key: Uint8Array, keyInfo: {[props: string]: any} = {},
deviceDisplayName: string = undefined,
): Promise<boolean> {
@@ -148,7 +150,7 @@ export class DehydrationManager {
}
/** returns the device id of the newly created dehydrated device */
async dehydrateDevice(): Promise<string> {
public async dehydrateDevice(): Promise<string> {
if (this.inProgress) {
logger.log("Dehydration already in progress -- not starting new dehydration");
return;

View File

@@ -58,7 +58,7 @@ import { keyFromPassphrase } from './key_passphrase';
import { decodeRecoveryKey, encodeRecoveryKey } from './recoverykey';
import { VerificationRequest } from "./verification/request/VerificationRequest";
import { InRoomChannel, InRoomRequests } from "./verification/request/InRoomChannel";
import { ToDeviceChannel, ToDeviceRequests } from "./verification/request/ToDeviceChannel";
import { ToDeviceChannel, ToDeviceRequests, Request } from "./verification/request/ToDeviceChannel";
import { IllegalMethod } from "./verification/IllegalMethod";
import { KeySignatureUploadError } from "../errors";
import { calculateKeyCheck, decryptAES, encryptAES } from './aes';
@@ -309,7 +309,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
private oneTimeKeyCount: number;
private needsNewFallback: boolean;
private fallbackCleanup?: number; // setTimeout ID
private fallbackCleanup?: ReturnType<typeof setTimeout>;
/**
* Cryptography bits
@@ -402,7 +402,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
// try to get key from app
if (this.baseApis.cryptoCallbacks && this.baseApis.cryptoCallbacks.getBackupKey) {
return await this.baseApis.cryptoCallbacks.getBackupKey();
return this.baseApis.cryptoCallbacks.getBackupKey();
}
throw new Error("Unable to get private key");
@@ -690,7 +690,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
// Cross-sign own device
const device = this.deviceList.getStoredDevice(this.userId, this.deviceId);
const deviceSignature = await crossSigningInfo.signDevice(this.userId, device) as ISignedKey;
const deviceSignature = await crossSigningInfo.signDevice(this.userId, device);
builder.addKeySignature(this.userId, this.deviceId, deviceSignature);
// Sign message key backup with cross-signing master key
@@ -920,7 +920,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
// secrets using it, in theory. We could move them to the new key but a)
// that would mean we'd need to prompt for the old passphrase, and b)
// it's not clear that would be the right thing to do anyway.
const { keyInfo, privateKey } = await createSecretStorageKey();
const { keyInfo = {} as IAddSecretStorageKeyOpts, privateKey } = await createSecretStorageKey();
newKeyId = await createSSSS(keyInfo, privateKey);
} else if (!storageExists && keyBackupInfo) {
// we have an existing backup, but no SSSS
@@ -1026,7 +1026,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
const decodedBackupKey = new Uint8Array(olmlib.decodeBase64(
fixedBackupKey || sessionBackupKey,
));
await builder.addSessionBackupPrivateKeyToCache(decodedBackupKey);
builder.addSessionBackupPrivateKeyToCache(decodedBackupKey);
} else if (this.backupManager.getKeyBackupEnabled()) {
// key backup is enabled but we don't have a session backup key in SSSS: see if we have one in
// the cache or the user can provide one, and if so, write it to SSSS
@@ -1076,11 +1076,8 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
return this.secretStorage.get(name);
}
public isSecretStored(
name: string,
checkKey?: boolean,
): Promise<Record<string, ISecretStorageKeyInfo> | null> {
return this.secretStorage.isStored(name, checkKey);
public isSecretStored(name: string): Promise<Record<string, ISecretStorageKeyInfo> | null> {
return this.secretStorage.isStored(name);
}
public requestSecret(name: string, devices: string[]): ISecretRequest {
@@ -1423,6 +1420,25 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
}
}
/**
* Check whether one of our own devices is cross-signed by our
* user's stored keys, regardless of whether we trust those keys yet.
*
* @param {string} deviceId The ID of the device to check
*
* @returns {boolean} true if the device is cross-signed
*/
public checkIfOwnDeviceCrossSigned(deviceId: string): boolean {
const device = this.deviceList.getStoredDevice(this.userId, deviceId);
const userCrossSigning = this.deviceList.getStoredCrossSigningForUser(this.userId);
return userCrossSigning.checkDeviceTrust(
userCrossSigning,
device,
false,
true,
).isCrossSigningVerified();
}
/*
* Event handler for DeviceList's userNewDevices event
*/
@@ -2302,8 +2318,8 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
userId: string,
deviceId: string,
transactionId: string = null,
): any { // TODO types
let request;
): VerificationBase<any, any> {
let request: Request;
if (transactionId) {
request = this.toDeviceVerificationRequests.getRequestBySenderAndTxnId(userId, transactionId);
if (!request) {
@@ -2871,7 +2887,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
} else {
const content = event.getWireContent();
const alg = this.getRoomDecryptor(event.getRoomId(), content.algorithm);
return await alg.decryptEvent(event);
return alg.decryptEvent(event);
}
}

View File

@@ -38,7 +38,7 @@ interface IKey {
iterations: number;
}
export async function keyFromAuthData(authData: IAuthData, password: string): Promise<Uint8Array> {
export function keyFromAuthData(authData: IAuthData, password: string): Promise<Uint8Array> {
if (!global.Olm) {
throw new Error("Olm is not available");
}
@@ -50,7 +50,7 @@ export async function keyFromAuthData(authData: IAuthData, password: string): Pr
);
}
return await deriveKey(
return deriveKey(
password, authData.private_key_salt,
authData.private_key_iterations,
authData.private_key_bits || DEFAULT_BITSIZE,

View File

@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import bs58 from 'bs58';
import * as bs58 from 'bs58';
// picked arbitrarily but to try & avoid clashing with any bitcoin ones
// (which are also base58 encoded, but bitcoin's involve a lot more hashing)

View File

@@ -873,7 +873,7 @@ export class Backend implements CryptoStore {
public doTxn<T>(
mode: Mode,
stores: Iterable<string>,
stores: string | string[],
func: (txn: IDBTransaction) => T,
log: PrefixedLogger = logger,
): Promise<T> {

View File

@@ -228,8 +228,8 @@ export class LocalStorageCryptoStore extends MemoryCryptoStore {
// (hence 43 characters long).
func({
senderKey: key.substr(KEY_INBOUND_SESSION_PREFIX.length, 43),
sessionId: key.substr(KEY_INBOUND_SESSION_PREFIX.length + 44),
senderKey: key.slice(KEY_INBOUND_SESSION_PREFIX.length, KEY_INBOUND_SESSION_PREFIX.length + 43),
sessionId: key.slice(KEY_INBOUND_SESSION_PREFIX.length + 44),
sessionData: getJsonItem(this.store, key),
});
}
@@ -299,7 +299,7 @@ export class LocalStorageCryptoStore extends MemoryCryptoStore {
for (let i = 0; i < this.store.length; ++i) {
const key = this.store.key(i);
if (key.startsWith(prefix)) {
const roomId = key.substr(prefix.length);
const roomId = key.slice(prefix.length);
result[roomId] = getJsonItem(this.store, key);
}
}
@@ -313,8 +313,8 @@ export class LocalStorageCryptoStore extends MemoryCryptoStore {
for (const session in sessionsNeedingBackup) {
if (Object.prototype.hasOwnProperty.call(sessionsNeedingBackup, session)) {
// see getAllEndToEndInboundGroupSessions for the magic number explanations
const senderKey = session.substr(0, 43);
const sessionId = session.substr(44);
const senderKey = session.slice(0, 43);
const sessionId = session.slice(44);
this.getEndToEndInboundGroupSession(
senderKey, sessionId, null,
(sessionData) => {
@@ -325,7 +325,7 @@ export class LocalStorageCryptoStore extends MemoryCryptoStore {
});
},
);
if (limit && session.length >= limit) {
if (limit && sessions.length >= limit) {
break;
}
}

View File

@@ -418,8 +418,8 @@ export class MemoryCryptoStore implements CryptoStore {
// (hence 43 characters long).
func({
senderKey: key.substr(0, 43),
sessionId: key.substr(44),
senderKey: key.slice(0, 43),
sessionId: key.slice(44),
sessionData: this.inboundGroupSessions[key],
});
}
@@ -482,8 +482,8 @@ export class MemoryCryptoStore implements CryptoStore {
for (const session in this.sessionsNeedingBackup) {
if (this.inboundGroupSessions[session]) {
sessions.push({
senderKey: session.substr(0, 43),
sessionId: session.substr(44),
senderKey: session.slice(0, 43),
sessionId: session.slice(44),
sessionData: this.inboundGroupSessions[session],
});
if (limit && session.length >= limit) {

View File

@@ -55,7 +55,7 @@ export class VerificationBase<
private cancelled = false;
private _done = false;
private promise: Promise<void> = null;
private transactionTimeoutTimer: number = null;
private transactionTimeoutTimer: ReturnType<typeof setTimeout> = null;
protected expectedEvent: string;
private resolve: () => void;
private reject: (e: Error | MatrixEvent) => void;

View File

@@ -193,6 +193,7 @@ function calculateMAC(olmSAS: OlmSAS, method: string) {
}
const calculateKeyAgreement = {
// eslint-disable-next-line @typescript-eslint/naming-convention
"curve25519-hkdf-sha256": function(sas: SAS, olmSAS: OlmSAS, bytes: number): Uint8Array {
const ourInfo = `${sas.baseApis.getUserId()}|${sas.baseApis.deviceId}|`
+ `${sas.ourSASPubKey}|`;

View File

@@ -184,7 +184,7 @@ export class InRoomChannel implements IVerificationChannel {
* @param {boolean} isLiveEvent whether this is an even received through sync or not
* @returns {Promise} a promise that resolves when any requests as an answer to the passed-in event are sent.
*/
public async handleEvent(event: MatrixEvent, request: VerificationRequest, isLiveEvent = false): Promise<void> {
public handleEvent(event: MatrixEvent, request: VerificationRequest, isLiveEvent = false): Promise<void> {
// prevent processing the same event multiple times, as under
// some circumstances Room.timeline can get emitted twice for the same event
if (request.hasEventId(event.getId())) {
@@ -221,8 +221,7 @@ export class InRoomChannel implements IVerificationChannel {
const isRemoteEcho = !!event.getUnsigned().transaction_id;
const isSentByUs = event.getSender() === this.client.getUserId();
return await request.handleEvent(
type, event, isLiveEvent, isRemoteEcho, isSentByUs);
return request.handleEvent(type, event, isLiveEvent, isRemoteEcho, isSentByUs);
}
/**

View File

@@ -95,7 +95,7 @@ export class VerificationRequest<
private eventsByUs = new Map<string, MatrixEvent>();
private eventsByThem = new Map<string, MatrixEvent>();
private _observeOnly = false;
private timeoutTimer: number = null;
private timeoutTimer: ReturnType<typeof setTimeout> = null;
private _accepting = false;
private _declining = false;
private verifierHasFinished = false;
@@ -796,8 +796,7 @@ export class VerificationRequest<
}
private setupTimeout(phase: Phase): void {
const shouldTimeout = !this.timeoutTimer && !this.observeOnly &&
phase === PHASE_REQUESTED;
const shouldTimeout = !this.timeoutTimer && !this.observeOnly && phase === PHASE_REQUESTED;
if (shouldTimeout) {
this.timeoutTimer = setTimeout(this.cancelOnTimeout, this.timeout);
@@ -814,15 +813,15 @@ export class VerificationRequest<
}
}
private cancelOnTimeout = () => {
private cancelOnTimeout = async () => {
try {
if (this.initiatedByMe) {
this.cancel({
await this.cancel({
reason: "Other party didn't accept in time",
code: "m.timeout",
});
} else {
this.cancel({
await this.cancel({
reason: "User didn't accept in time",
code: "m.timeout",
});

View File

@@ -25,15 +25,31 @@ export interface MapperOpts {
}
export function eventMapperFor(client: MatrixClient, options: MapperOpts): EventMapper {
const preventReEmit = Boolean(options.preventReEmit);
let preventReEmit = Boolean(options.preventReEmit);
const decrypt = options.decrypt !== false;
function mapper(plainOldJsObject: Partial<IEvent>) {
const event = new MatrixEvent(plainOldJsObject);
const room = client.getRoom(plainOldJsObject.room_id);
const room = client.getRoom(event.getRoomId());
if (room?.threads.has(event.getId())) {
event.setThread(room.threads.get(event.getId()));
let event: MatrixEvent;
// If the event is already known to the room, let's re-use the model rather than duplicating.
// We avoid doing this to state events as they may be forward or backwards looking which tweaks behaviour.
if (room && plainOldJsObject.state_key === undefined) {
event = room.findEventById(plainOldJsObject.event_id);
}
if (!event || event.status) {
event = new MatrixEvent(plainOldJsObject);
} else {
// merge the latest unsigned data from the server
event.setUnsigned({ ...event.getUnsigned(), ...plainOldJsObject.unsigned });
// prevent doubling up re-emitters
preventReEmit = true;
}
const thread = room?.findThreadForEvent(event);
if (thread) {
event.setThread(thread);
}
if (event.isEncrypted()) {
@@ -46,11 +62,15 @@ export function eventMapperFor(client: MatrixClient, options: MapperOpts): Event
client.decryptEventIfNeeded(event);
}
}
if (!preventReEmit) {
client.reEmitter.reEmit(event, [
MatrixEventEvent.Replaced,
MatrixEventEvent.VisibilityChange,
]);
room?.reEmitter.reEmit(event, [
MatrixEventEvent.BeforeRedaction,
]);
}
return event;
}

View File

@@ -36,7 +36,7 @@ import {
function matchesWildcard(actualValue: string, filterValue: string): boolean {
if (filterValue.endsWith("*")) {
const typePrefix = filterValue.slice(0, -1);
return actualValue.substr(0, typePrefix.length) === typePrefix;
return actualValue.slice(0, typePrefix.length) === typePrefix;
} else {
return actualValue === filterValue;
}

View File

@@ -30,7 +30,7 @@ import type { Request as _Request, CoreOptions } from "request";
import * as callbacks from "./realtime-callbacks";
import { IUploadOpts } from "./@types/requests";
import { IAbortablePromise, IUsageLimit } from "./@types/partials";
import { IDeferred } from "./utils";
import { IDeferred, sleep } from "./utils";
import { Callback } from "./client";
import * as utils from "./utils";
import { logger } from './logger';
@@ -1055,7 +1055,7 @@ interface IErrorJson extends Partial<IUsageLimit> {
* @prop {string} name Same as MatrixError.errcode but with a default unknown string.
* @prop {string} message The Matrix 'error' value, e.g. "Missing token."
* @prop {Object} data The raw Matrix error JSON used to construct this object.
* @prop {integer} httpStatus The numeric HTTP status code given
* @prop {number} httpStatus The numeric HTTP status code given
*/
export class MatrixError extends Error {
public readonly errcode: string;
@@ -1105,7 +1105,7 @@ export class AbortError extends Error {
* @return {any} the result of the network operation
* @throws {ConnectionError} If after maxAttempts the callback still throws ConnectionError
*/
export async function retryNetworkOperation<T>(maxAttempts: number, callback: () => T): Promise<T> {
export async function retryNetworkOperation<T>(maxAttempts: number, callback: () => Promise<T>): Promise<T> {
let attempts = 0;
let lastConnectionError = null;
while (attempts < maxAttempts) {
@@ -1114,9 +1114,9 @@ export async function retryNetworkOperation<T>(maxAttempts: number, callback: ()
const timeout = 1000 * Math.pow(2, attempts);
logger.log(`network operation failed ${attempts} times,` +
` retrying in ${timeout}ms...`);
await new Promise(r => setTimeout(r, timeout));
await sleep(timeout);
}
return await callback();
return callback();
} catch (err) {
if (err instanceof ConnectionError) {
attempts += 1;

View File

@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import request from "request";
import * as request from "request";
import * as matrixcs from "./matrix";
import * as utils from "./utils";

View File

@@ -46,9 +46,16 @@ export interface IAuthData {
session?: string;
completed?: string[];
flows?: IFlow[];
available_flows?: IFlow[];
stages?: string[];
required_stages?: AuthType[];
params?: Record<string, Record<string, any>>;
data?: Record<string, string>;
errcode?: string;
error?: string;
user_id?: string;
device_id?: string;
access_token?: string;
}
export enum AuthType {
@@ -60,7 +67,11 @@ export enum AuthType {
Sso = "m.login.sso",
SsoUnstable = "org.matrix.login.sso",
Dummy = "m.login.dummy",
RegistrationToken = "org.matrix.msc3231.login.registration_token",
RegistrationToken = "m.login.registration_token",
// For backwards compatability with servers that have not yet updated to
// use the stable "m.login.registration_token" type.
// The authentication flow is the same in both cases.
UnstableRegistrationToken = "org.matrix.msc3231.login.registration_token",
}
export interface IAuthDict {
@@ -79,7 +90,8 @@ export interface IAuthDict {
// eslint-disable-next-line camelcase
threepid_creds?: any;
threepidCreds?: any;
registrationToken?: string;
// For m.login.registration_token type
token?: string;
}
class NoAuthFlowFoundError extends Error {
@@ -198,6 +210,8 @@ export class InteractiveAuth {
private chosenFlow: IFlow = null;
private currentStage: string = null;
private emailAttempt = 1;
// if we are currently trying to submit an auth dict (which includes polling)
// the promise the will resolve/reject when it completes
private submitPromise: Promise<void> = null;
@@ -403,6 +417,34 @@ export class InteractiveAuth {
this.emailSid = sid;
}
/**
* Requests a new email token and sets the email sid for the validation session
*/
public requestEmailToken = async () => {
if (!this.requestingEmailToken) {
logger.trace("Requesting email token. Attempt: " + this.emailAttempt);
// If we've picked a flow with email auth, we send the email
// now because we want the request to fail as soon as possible
// if the email address is not valid (ie. already taken or not
// registered, depending on what the operation is).
this.requestingEmailToken = true;
try {
const requestTokenResult = await this.requestEmailTokenCallback(
this.inputs.emailAddress,
this.clientSecret,
this.emailAttempt++,
this.data.session,
);
this.emailSid = requestTokenResult.sid;
logger.trace("Email token request succeeded");
} finally {
this.requestingEmailToken = false;
}
} else {
logger.warn("Could not request email token: Already requesting");
}
};
/**
* Fire off a request, and either resolve the promise, or call
* startAuthStage.
@@ -453,24 +495,9 @@ export class InteractiveAuth {
return;
}
if (
!this.emailSid &&
!this.requestingEmailToken &&
this.chosenFlow.stages.includes(AuthType.Email)
) {
// If we've picked a flow with email auth, we send the email
// now because we want the request to fail as soon as possible
// if the email address is not valid (ie. already taken or not
// registered, depending on what the operation is).
this.requestingEmailToken = true;
if (!this.emailSid && this.chosenFlow.stages.includes(AuthType.Email)) {
try {
const requestTokenResult = await this.requestEmailTokenCallback(
this.inputs.emailAddress,
this.clientSecret,
1, // TODO: Multiple send attempts?
this.data.session,
);
this.emailSid = requestTokenResult.sid;
await this.requestEmailToken();
// NB. promise is not resolved here - at some point, doRequest
// will be called again and if the user has jumped through all
// the hoops correctly, auth will be complete and the request
@@ -486,8 +513,6 @@ export class InteractiveAuth {
// send the email, for whatever reason.
this.attemptAuthDeferred.reject(e);
this.attemptAuthDeferred = null;
} finally {
this.requestingEmailToken = false;
}
}
}

View File

@@ -17,8 +17,7 @@ limitations under the License.
import { MemoryCryptoStore } from "./crypto/store/memory-crypto-store";
import { MemoryStore } from "./store/memory";
import { MatrixScheduler } from "./scheduler";
import { MatrixClient } from "./client";
import { ICreateClientOpts } from "./client";
import { MatrixClient, ICreateClientOpts } from "./client";
import { DeviceTrustLevel } from "./crypto/CrossSigning";
import { ISecretStorageKeyInfo } from "./crypto/api";
@@ -30,7 +29,6 @@ export * from "./errors";
export * from "./models/beacon";
export * from "./models/event";
export * from "./models/room";
export * from "./models/group";
export * from "./models/event-timeline";
export * from "./models/event-timeline-set";
export * from "./models/room-member";
@@ -154,7 +152,7 @@ export interface ICryptoCallbacks {
export function createClient(opts: ICreateClientOpts | string) {
if (typeof opts === "string") {
opts = {
"baseUrl": opts as string,
"baseUrl": opts,
};
}
opts.request = opts.request || requestInstance;

View File

@@ -14,20 +14,27 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { M_BEACON_INFO } from "../@types/beacon";
import { BeaconInfoState, parseBeaconInfoContent } from "../content-helpers";
import { MBeaconEventContent } from "../@types/beacon";
import { M_TIMESTAMP } from "../@types/location";
import { BeaconInfoState, BeaconLocationState, parseBeaconContent, parseBeaconInfoContent } from "../content-helpers";
import { MatrixEvent } from "../matrix";
import { sortEventsByLatestContentTimestamp } from "../utils";
import { TypedEventEmitter } from "./typed-event-emitter";
export enum BeaconEvent {
New = "Beacon.new",
Update = "Beacon.update",
LivenessChange = "Beacon.LivenessChange",
Destroy = "Beacon.Destroy",
LocationUpdate = "Beacon.LocationUpdate",
}
export type BeaconEventHandlerMap = {
[BeaconEvent.Update]: (event: MatrixEvent, beacon: Beacon) => void;
[BeaconEvent.LivenessChange]: (isLive: boolean, beacon: Beacon) => void;
[BeaconEvent.Destroy]: (beaconIdentifier: string) => void;
[BeaconEvent.LocationUpdate]: (locationState: BeaconLocationState) => void;
[BeaconEvent.Destroy]: (beaconIdentifier: string) => void;
};
export const isTimestampInDuration = (
@@ -36,16 +43,19 @@ export const isTimestampInDuration = (
timestamp: number,
): boolean => timestamp >= startTimestamp && startTimestamp + durationMs >= timestamp;
export const isBeaconInfoEventType = (type: string) =>
type.startsWith(M_BEACON_INFO.name) ||
type.startsWith(M_BEACON_INFO.altName);
// beacon info events are uniquely identified by
// `<roomId>_<state_key>`
export type BeaconIdentifier = string;
export const getBeaconInfoIdentifier = (event: MatrixEvent): BeaconIdentifier =>
`${event.getRoomId()}_${event.getStateKey()}`;
// https://github.com/matrix-org/matrix-spec-proposals/pull/3489
// https://github.com/matrix-org/matrix-spec-proposals/pull/3672
export class Beacon extends TypedEventEmitter<Exclude<BeaconEvent, BeaconEvent.New>, BeaconEventHandlerMap> {
public readonly roomId: string;
private _beaconInfo: BeaconInfoState;
private _isLive: boolean;
private livenessWatchInterval: number;
private livenessWatchInterval: ReturnType<typeof setInterval>;
private _latestLocationState: BeaconLocationState | undefined;
constructor(
private rootEvent: MatrixEvent,
@@ -59,6 +69,10 @@ export class Beacon extends TypedEventEmitter<Exclude<BeaconEvent, BeaconEvent.N
return this._isLive;
}
public get identifier(): BeaconIdentifier {
return getBeaconInfoIdentifier(this.rootEvent);
}
public get beaconInfoId(): string {
return this.rootEvent.getId();
}
@@ -75,20 +89,32 @@ export class Beacon extends TypedEventEmitter<Exclude<BeaconEvent, BeaconEvent.N
return this._beaconInfo;
}
public get latestLocationState(): BeaconLocationState | undefined {
return this._latestLocationState;
}
public update(beaconInfoEvent: MatrixEvent): void {
if (beaconInfoEvent.getId() !== this.beaconInfoId) {
if (getBeaconInfoIdentifier(beaconInfoEvent) !== this.identifier) {
throw new Error('Invalid updating event');
}
// don't update beacon with an older event
if (beaconInfoEvent.event.origin_server_ts < this.rootEvent.event.origin_server_ts) {
return;
}
this.rootEvent = beaconInfoEvent;
this.setBeaconInfo(this.rootEvent);
this.emit(BeaconEvent.Update, beaconInfoEvent, this);
this.clearLatestLocation();
}
public destroy(): void {
if (this.livenessWatchInterval) {
clearInterval(this.livenessWatchInterval);
}
this._isLive = false;
this.emit(BeaconEvent.Destroy, this.identifier);
}
/**
@@ -100,14 +126,51 @@ export class Beacon extends TypedEventEmitter<Exclude<BeaconEvent, BeaconEvent.N
clearInterval(this.livenessWatchInterval);
}
this.checkLiveness();
if (this.isLive) {
const expiryInMs = (this._beaconInfo?.timestamp + this._beaconInfo?.timeout + 1) - Date.now();
const expiryInMs = (this._beaconInfo?.timestamp + this._beaconInfo?.timeout) - Date.now();
if (expiryInMs > 1) {
this.livenessWatchInterval = setInterval(this.checkLiveness.bind(this), expiryInMs);
this.livenessWatchInterval = setInterval(
() => { this.monitorLiveness(); },
expiryInMs,
);
}
}
}
/**
* Process Beacon locations
* Emits BeaconEvent.LocationUpdate
*/
public addLocations(beaconLocationEvents: MatrixEvent[]): void {
// discard locations for beacons that are not live
if (!this.isLive) {
return;
}
const validLocationEvents = beaconLocationEvents.filter(event => {
const content = event.getContent<MBeaconEventContent>();
const timestamp = M_TIMESTAMP.findIn<number>(content);
return (
// only include positions that were taken inside the beacon's live period
isTimestampInDuration(this._beaconInfo.timestamp, this._beaconInfo.timeout, timestamp) &&
// ignore positions older than our current latest location
(!this.latestLocationState || timestamp > this.latestLocationState.timestamp)
);
});
const latestLocationEvent = validLocationEvents.sort(sortEventsByLatestContentTimestamp)?.[0];
if (latestLocationEvent) {
this._latestLocationState = parseBeaconContent(latestLocationEvent.getContent());
this.emit(BeaconEvent.LocationUpdate, this.latestLocationState);
}
}
private clearLatestLocation = () => {
this._latestLocationState = undefined;
this.emit(BeaconEvent.LocationUpdate, this.latestLocationState);
};
private setBeaconInfo(event: MatrixEvent): void {
this._beaconInfo = parseBeaconInfoContent(event.getContent());
this.checkLiveness();

View File

@@ -42,7 +42,7 @@ export class EventContext {
*
* @constructor
*/
constructor(ourEvent: MatrixEvent) {
constructor(public readonly ourEvent: MatrixEvent) {
this.timeline = [ourEvent];
}

View File

@@ -28,7 +28,6 @@ import { EventType, RelationType } from "../@types/event";
import { RoomState } from "./room-state";
import { TypedEventEmitter } from "./typed-event-emitter";
// var DEBUG = false;
const DEBUG = true;
let debuglog: (...args: any[]) => void;
@@ -775,7 +774,7 @@ export class EventTimelineSet extends TypedEventEmitter<EmittedEvents, EventTime
}
public getAllRelationsEventForEvent(eventId: string): MatrixEvent[] {
const relationsForEvent = this.relations[eventId] || {};
const relationsForEvent = this.relations?.[eventId] || {};
const events = [];
for (const relationsRecord of Object.values(relationsForEvent)) {
for (const relations of Object.values(relationsRecord)) {
@@ -852,14 +851,13 @@ export class EventTimelineSet extends TypedEventEmitter<EmittedEvents, EventTime
}
let relationsWithEventType = relationsWithRelType[eventType];
let relatesToEvent;
if (!relationsWithEventType) {
relationsWithEventType = relationsWithRelType[eventType] = new Relations(
relationType,
eventType,
this.room,
);
relatesToEvent = this.findEventById(relatesToEventId) || this.room.getPendingEvent(relatesToEventId);
const relatesToEvent = this.findEventById(relatesToEventId) || this.room.getPendingEvent(relatesToEventId);
if (relatesToEvent) {
relationsWithEventType.setTargetEvent(relatesToEvent);
}

View File

@@ -86,9 +86,17 @@ export interface IEvent {
unsigned: IUnsigned;
redacts?: string;
// v1 legacy fields
/**
* @deprecated
*/
user_id?: string;
/**
* @deprecated
*/
prev_content?: IContent;
/**
* @deprecated
*/
age?: number;
}
@@ -111,11 +119,6 @@ export interface IEventRelation {
key?: string;
}
export interface IVisibilityEventRelation extends IEventRelation {
visibility: "visible" | "hidden";
reason?: string;
}
/**
* When an event is a visibility change event, as per MSC3531,
* the visibility change implied by the event.
@@ -279,7 +282,7 @@ export class MatrixEvent extends TypedEventEmitter<EmittedEvents, MatrixEventHan
public target: RoomMember = null;
public status: EventStatus = null;
public error: MatrixError = null;
public forwardLooking = true;
public forwardLooking = true; // only state events may be backwards looking
/* If the event is a `m.key.verification.request` (or to_device `m.key.verification.start`) event,
* `Crypto` will set this the `VerificationRequest` for the event
@@ -478,7 +481,7 @@ export class MatrixEvent extends TypedEventEmitter<EmittedEvents, MatrixEventHan
*
* @return {Object} The event content JSON, or an empty object.
*/
public getContent<T = IContent>(): T {
public getContent<T extends IContent = IContent>(): T {
if (this._localRedactionEvent) {
return {} as T;
} else if (this._replacingEvent) {
@@ -1040,7 +1043,7 @@ export class MatrixEvent extends TypedEventEmitter<EmittedEvents, MatrixEventHan
* caused a change in the actual visibility of this event, either by making it
* visible (if it was hidden), by making it hidden (if it was visible) or by
* changing the reason (if it was hidden).
* @param visibilityEvent event holding a hide/unhide payload, or nothing
* @param visibilityChange event holding a hide/unhide payload, or nothing
* if the event is being reset to its original visibility (presumably
* by a visibility event being redacted).
*/
@@ -1062,11 +1065,9 @@ export class MatrixEvent extends TypedEventEmitter<EmittedEvents, MatrixEventHan
reason: reason,
});
}
if (change) {
this.emit(MatrixEventEvent.VisibilityChange, this, visible);
}
}
}
/**
* Return instructions to display or hide the message.
@@ -1109,23 +1110,21 @@ export class MatrixEvent extends TypedEventEmitter<EmittedEvents, MatrixEventHan
}
this.event.unsigned.redacted_because = redactionEvent.event as IEvent;
let key;
for (key in this.event) {
if (!this.event.hasOwnProperty(key)) {
continue;
}
if (!REDACT_KEEP_KEYS.has(key)) {
for (const key in this.event) {
if (this.event.hasOwnProperty(key) && !REDACT_KEEP_KEYS.has(key)) {
delete this.event[key];
}
}
// If the event is encrypted prune the decrypted bits
if (this.isEncrypted()) {
this.clearEvent = null;
}
const keeps = REDACT_KEEP_CONTENT_MAP[this.getType()] || {};
const content = this.getContent();
for (key in content) {
if (!content.hasOwnProperty(key)) {
continue;
}
if (!keeps[key]) {
for (const key in content) {
if (content.hasOwnProperty(key) && !keeps[key]) {
delete content[key];
}
}
@@ -1291,7 +1290,7 @@ export class MatrixEvent extends TypedEventEmitter<EmittedEvents, MatrixEventHan
/**
* Get whether the event is a relation event, and of a given type if
* `relType` is passed in.
* `relType` is passed in. State events cannot be relation events
*
* @param {string?} relType if given, checks that the relation is of the
* given type
@@ -1300,10 +1299,12 @@ export class MatrixEvent extends TypedEventEmitter<EmittedEvents, MatrixEventHan
public isRelation(relType: string = undefined): boolean {
// Relation info is lifted out of the encrypted content when sent to
// encrypted rooms, so we have to check `getWireContent` for this.
const content = this.getWireContent();
const relation = content && content["m.relates_to"];
return relation && relation.rel_type && relation.event_id &&
((relType && relation.rel_type === relType) || !relType);
const relation = this.getWireContent()?.["m.relates_to"];
if (this.isState() && relation?.rel_type === RelationType.Replace) {
// State events cannot be m.replace relations
return false;
}
return relation?.rel_type && relation.event_id && (relType ? relation.rel_type === relType : true);
}
/**
@@ -1333,6 +1334,10 @@ export class MatrixEvent extends TypedEventEmitter<EmittedEvents, MatrixEventHan
if (this.isRedacted() && newEvent) {
return;
}
// don't allow state events to be replaced using this mechanism as per MSC2676
if (this.isState()) {
return;
}
if (this._replacingEvent !== newEvent) {
this._replacingEvent = newEvent;
this.emit(MatrixEventEvent.Replaced, this);
@@ -1583,7 +1588,7 @@ const REDACT_KEEP_KEYS = new Set([
'content', 'unsigned', 'origin_server_ts',
]);
// a map from event type to the .content keys we keep when an event is redacted
// a map from state event type to the .content keys we keep when an event is redacted
const REDACT_KEEP_CONTENT_MAP = {
[EventType.RoomMember]: { 'membership': 1 },
[EventType.RoomCreate]: { 'creator': 1 },

View File

@@ -1,100 +0,0 @@
/*
Copyright 2017 New Vector Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
/**
* @module models/group
* @deprecated groups/communities never made it to the spec and support for them is being discontinued.
*/
// eslint-disable-next-line no-restricted-imports
import { EventEmitter } from "events";
import * as utils from "../utils";
/**
* Construct a new Group.
*
* @param {string} groupId The ID of this group.
*
* @prop {string} groupId The ID of this group.
* @prop {string} name The human-readable display name for this group.
* @prop {string} avatarUrl The mxc URL for this group's avatar.
* @prop {string} myMembership The logged in user's membership of this group
* @prop {Object} inviter Infomation about the user who invited the logged in user
* to the group, if myMembership is 'invite'.
* @prop {string} inviter.userId The user ID of the inviter
* @deprecated groups/communities never made it to the spec and support for them is being discontinued.
*/
export function Group(groupId) {
this.groupId = groupId;
this.name = null;
this.avatarUrl = null;
this.myMembership = null;
this.inviter = null;
}
utils.inherits(Group, EventEmitter);
Group.prototype.setProfile = function(name, avatarUrl) {
if (this.name === name && this.avatarUrl === avatarUrl) return;
this.name = name || this.groupId;
this.avatarUrl = avatarUrl;
this.emit("Group.profile", this);
};
Group.prototype.setMyMembership = function(membership) {
if (this.myMembership === membership) return;
this.myMembership = membership;
this.emit("Group.myMembership", this);
};
/**
* Sets the 'inviter' property. This does not emit an event (the inviter
* will only change when the user is revited / reinvited to a room),
* so set this before setting myMembership.
* @param {Object} inviter Infomation about who invited us to the room
*/
Group.prototype.setInviter = function(inviter) {
this.inviter = inviter;
};
/**
* Fires whenever a group's profile information is updated.
* This means the 'name' and 'avatarUrl' properties.
* @event module:client~MatrixClient#"Group.profile"
* @param {Group} group The group whose profile was updated.
* @deprecated groups/communities never made it to the spec and support for them is being discontinued.
* @example
* matrixClient.on("Group.profile", function(group){
* var name = group.name;
* });
*/
/**
* Fires whenever the logged in user's membership status of
* the group is updated.
* @event module:client~MatrixClient#"Group.myMembership"
* @param {Group} group The group in which the user's membership changed
* @deprecated groups/communities never made it to the spec and support for them is being discontinued.
* @example
* matrixClient.on("Group.myMembership", function(group){
* var myMembership = group.myMembership;
* });
*/

View File

@@ -103,7 +103,7 @@ export class Relations extends TypedEventEmitter<RelationsEvent, EventHandlerMap
if (this.relationType === RelationType.Annotation) {
this.addAnnotationToAggregation(event);
} else if (this.relationType === RelationType.Replace && this.targetEvent) {
} else if (this.relationType === RelationType.Replace && this.targetEvent && !this.targetEvent.isState()) {
const lastReplacement = await this.getLastReplacement();
this.targetEvent.makeReplaced(lastReplacement);
}
@@ -144,7 +144,7 @@ export class Relations extends TypedEventEmitter<RelationsEvent, EventHandlerMap
if (this.relationType === RelationType.Annotation) {
this.removeAnnotationFromAggregation(event);
} else if (this.relationType === RelationType.Replace && this.targetEvent) {
} else if (this.relationType === RelationType.Replace && this.targetEvent && !this.targetEvent.isState()) {
const lastReplacement = await this.getLastReplacement();
this.targetEvent.makeReplaced(lastReplacement);
}
@@ -261,7 +261,7 @@ export class Relations extends TypedEventEmitter<RelationsEvent, EventHandlerMap
if (this.relationType === RelationType.Annotation) {
// Remove the redacted annotation from aggregation by key
this.removeAnnotationFromAggregation(redactedEvent);
} else if (this.relationType === RelationType.Replace && this.targetEvent) {
} else if (this.relationType === RelationType.Replace && this.targetEvent && !this.targetEvent.isState()) {
const lastReplacement = await this.getLastReplacement();
this.targetEvent.makeReplaced(lastReplacement);
}
@@ -331,7 +331,7 @@ export class Relations extends TypedEventEmitter<RelationsEvent, EventHandlerMap
// the all-knowning server tells us that the event at some point had
// this timestamp for its replacement, so any following replacement should definitely not be less
const replaceRelation = this.targetEvent.getServerAggregatedRelation<IAggregatedRelation>(RelationType.Replace);
const minTs = replaceRelation && replaceRelation.origin_server_ts;
const minTs = replaceRelation?.origin_server_ts;
const lastReplacement = this.getRelations().reduce((last, event) => {
if (event.getSender() !== this.targetEvent.getSender()) {
@@ -364,7 +364,7 @@ export class Relations extends TypedEventEmitter<RelationsEvent, EventHandlerMap
}
this.targetEvent = event;
if (this.relationType === RelationType.Replace) {
if (this.relationType === RelationType.Replace && !this.targetEvent.isState()) {
const replacement = await this.getLastReplacement();
// this is the initial update, so only call it if we already have something
// to not emit Event.replaced needlessly

View File

@@ -22,12 +22,13 @@ import { RoomMember } from "./room-member";
import { logger } from '../logger';
import * as utils from "../utils";
import { EventType } from "../@types/event";
import { MatrixEvent } from "./event";
import { MatrixEvent, MatrixEventEvent } from "./event";
import { MatrixClient } from "../client";
import { GuestAccess, HistoryVisibility, IJoinRuleEventContent, JoinRule } from "../@types/partials";
import { TypedEventEmitter } from "./typed-event-emitter";
import { Beacon, BeaconEvent, isBeaconInfoEventType, BeaconEventHandlerMap } from "./beacon";
import { Beacon, BeaconEvent, BeaconEventHandlerMap, getBeaconInfoIdentifier, BeaconIdentifier } from "./beacon";
import { TypedReEmitter } from "../ReEmitter";
import { M_BEACON, M_BEACON_INFO } from "../@types/beacon";
// possible statuses for out-of-band member loading
enum OobStatus {
@@ -80,8 +81,8 @@ export class RoomState extends TypedEventEmitter<EmittedEvents, EventHandlerMap>
public events = new Map<string, Map<string, MatrixEvent>>(); // Map<eventType, Map<stateKey, MatrixEvent>>
public paginationToken: string = null;
public readonly beacons = new Map<string, Beacon>();
private liveBeaconIds: string[] = [];
public readonly beacons = new Map<BeaconIdentifier, Beacon>();
private _liveBeaconIds: BeaconIdentifier[] = [];
/**
* Construct room state.
@@ -248,6 +249,10 @@ export class RoomState extends TypedEventEmitter<EmittedEvents, EventHandlerMap>
return !!this.liveBeaconIds?.length;
}
public get liveBeaconIds(): BeaconIdentifier[] {
return this._liveBeaconIds;
}
/**
* Creates a copy of this room state so that mutations to either won't affect the other.
* @return {RoomState} the copy of the room state
@@ -330,7 +335,7 @@ export class RoomState extends TypedEventEmitter<EmittedEvents, EventHandlerMap>
return;
}
if (isBeaconInfoEventType(event.getType())) {
if (M_BEACON_INFO.matches(event.getType())) {
this.setBeacon(event);
}
@@ -404,6 +409,51 @@ export class RoomState extends TypedEventEmitter<EmittedEvents, EventHandlerMap>
this.emit(RoomStateEvent.Update, this);
}
public processBeaconEvents(events: MatrixEvent[], matrixClient: MatrixClient): void {
if (
!events.length ||
// discard locations if we have no beacons
!this.beacons.size
) {
return;
}
const beaconByEventIdDict: Record<string, Beacon> =
[...this.beacons.values()].reduce((dict, beacon) => ({ ...dict, [beacon.beaconInfoId]: beacon }), {});
const processBeaconRelation = (beaconInfoEventId: string, event: MatrixEvent): void => {
if (!M_BEACON.matches(event.getType())) {
return;
}
const beacon = beaconByEventIdDict[beaconInfoEventId];
if (beacon) {
beacon.addLocations([event]);
}
};
events.forEach((event: MatrixEvent) => {
const relatedToEventId = event.getRelation()?.event_id;
// not related to a beacon we know about
// discard
if (!beaconByEventIdDict[relatedToEventId]) {
return;
}
matrixClient.decryptEventIfNeeded(event);
if (event.isBeingDecrypted() || event.isDecryptionFailure()) {
// add an event listener for once the event is decrypted.
event.once(MatrixEventEvent.Decrypted, async () => {
processBeaconRelation(relatedToEventId, event);
});
} else {
processBeaconRelation(relatedToEventId, event);
}
});
}
/**
* Looks up a member by the given userId, and if it doesn't exist,
* create it and emit the `RoomState.newMember` event.
@@ -437,8 +487,24 @@ export class RoomState extends TypedEventEmitter<EmittedEvents, EventHandlerMap>
* @experimental
*/
private setBeacon(event: MatrixEvent): void {
if (this.beacons.has(event.getId())) {
return this.beacons.get(event.getId()).update(event);
const beaconIdentifier = getBeaconInfoIdentifier(event);
if (this.beacons.has(beaconIdentifier)) {
const beacon = this.beacons.get(beaconIdentifier);
if (event.isRedacted()) {
if (beacon.beaconInfoId === event.getRedactionEvent()?.['redacts']) {
beacon.destroy();
this.beacons.delete(beaconIdentifier);
}
return;
}
return beacon.update(event);
}
if (event.isRedacted()) {
return;
}
const beacon = new Beacon(event);
@@ -446,30 +512,28 @@ export class RoomState extends TypedEventEmitter<EmittedEvents, EventHandlerMap>
this.reEmitter.reEmit<BeaconEvent, BeaconEvent>(beacon, [
BeaconEvent.New,
BeaconEvent.Update,
BeaconEvent.Destroy,
BeaconEvent.LivenessChange,
]);
this.emit(BeaconEvent.New, event, beacon);
beacon.on(BeaconEvent.LivenessChange, this.onBeaconLivenessChange.bind(this));
this.beacons.set(beacon.beaconInfoId, beacon);
beacon.on(BeaconEvent.Destroy, this.onBeaconLivenessChange.bind(this));
this.beacons.set(beacon.identifier, beacon);
}
/**
* @experimental
* Check liveness of room beacons
* emit RoomStateEvent.BeaconLiveness when
* roomstate.hasLiveBeacons has changed
* emit RoomStateEvent.BeaconLiveness event
*/
private onBeaconLivenessChange(): void {
const prevHasLiveBeacons = !!this.liveBeaconIds?.length;
this.liveBeaconIds = Array.from(this.beacons.values())
this._liveBeaconIds = Array.from(this.beacons.values())
.filter(beacon => beacon.isLive)
.map(beacon => beacon.beaconInfoId);
.map(beacon => beacon.identifier);
const hasLiveBeacons = !!this.liveBeaconIds.length;
if (prevHasLiveBeacons !== hasLiveBeacons) {
this.emit(RoomStateEvent.BeaconLiveness, this, hasLiveBeacons);
}
this.emit(RoomStateEvent.BeaconLiveness, this, this.hasLiveBeacons);
}
private getStateEventMatching(event: MatrixEvent): MatrixEvent | null {

View File

@@ -23,7 +23,7 @@ import { Direction, EventTimeline } from "./event-timeline";
import { getHttpUriForMxc } from "../content-repo";
import * as utils from "../utils";
import { normalize } from "../utils";
import { IEvent, MatrixEvent } from "./event";
import { IEvent, IThreadBundledRelationship, MatrixEvent, MatrixEventEvent, MatrixEventHandlerMap } from "./event";
import { EventStatus } from "./event-status";
import { RoomMember } from "./room-member";
import { IRoomSummary, RoomSummary } from "./room-summary";
@@ -32,6 +32,7 @@ import { TypedReEmitter } from '../ReEmitter';
import {
EventType, RoomCreateTypeField, RoomType, UNSTABLE_ELEMENT_FUNCTIONAL_USERS,
EVENT_VISIBILITY_CHANGE_TYPE,
RelationType,
} from "../@types/event";
import { IRoomVersionsCapability, MatrixClient, PendingEventOrdering, RoomVersionStability } from "../client";
import { GuestAccess, HistoryVisibility, JoinRule, ResizeMethod } from "../@types/partials";
@@ -45,8 +46,9 @@ import {
FILTER_RELATED_BY_SENDERS,
ThreadFilterType,
} from "./thread";
import { Method } from "../http-api";
import { TypedEventEmitter } from "./typed-event-emitter";
import { ReceiptType } from "../@types/read_receipts";
import { IStateEventWithRoomId } from "../@types/search";
// These constants are used as sane defaults when the homeserver doesn't support
// the m.room_versions capability. In practice, KNOWN_SAFE_ROOM_VERSION should be
@@ -54,10 +56,10 @@ import { TypedEventEmitter } from "./typed-event-emitter";
// room versions which are considered okay for people to run without being asked
// to upgrade (ie: "stable"). Eventually, we should remove these when all homeservers
// return an m.room_versions capability.
const KNOWN_SAFE_ROOM_VERSION = '6';
const SAFE_ROOM_VERSIONS = ['1', '2', '3', '4', '5', '6'];
const KNOWN_SAFE_ROOM_VERSION = '9';
const SAFE_ROOM_VERSIONS = ['1', '2', '3', '4', '5', '6', '7', '8', '9'];
function synthesizeReceipt(userId: string, event: MatrixEvent, receiptType: string): MatrixEvent {
function synthesizeReceipt(userId: string, event: MatrixEvent, receiptType: ReceiptType): MatrixEvent {
// console.log("synthesizing receipt for "+event.getId());
return new MatrixEvent({
content: {
@@ -92,13 +94,13 @@ interface IReceipt {
ts: number;
}
interface IWrappedReceipt {
export interface IWrappedReceipt {
eventId: string;
data: IReceipt;
}
interface ICachedReceipt {
type: string;
type: ReceiptType;
userId: string;
data: IReceipt;
}
@@ -107,7 +109,7 @@ type ReceiptCache = {[eventId: string]: ICachedReceipt[]};
interface IReceiptContent {
[eventId: string]: {
[type: string]: {
[key in ReceiptType]: {
[userId: string]: IReceipt;
};
};
@@ -148,6 +150,7 @@ export interface ICreateFilterOpts {
// timeline. Useful to disable for some filters that can't be achieved by the
// client in an efficient manner
prepopulateTimeline?: boolean;
useSyncEvents?: boolean;
pendingEvents?: boolean;
}
@@ -167,8 +170,10 @@ export enum RoomEvent {
type EmittedEvents = RoomEvent
| ThreadEvent.New
| ThreadEvent.Update
| ThreadEvent.NewReply
| RoomEvent.Timeline
| RoomEvent.TimelineReset;
| RoomEvent.TimelineReset
| MatrixEventEvent.BeforeRedaction;
export type RoomEventHandlerMap = {
[RoomEvent.MyMembership]: (room: Room, membership: string, prevMembership?: string) => void;
@@ -185,10 +190,10 @@ export type RoomEventHandlerMap = {
oldStatus?: EventStatus,
) => void;
[ThreadEvent.New]: (thread: Thread, toStartOfTimeline: boolean) => void;
} & ThreadHandlerMap;
} & ThreadHandlerMap & MatrixEventHandlerMap;
export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap> {
private readonly reEmitter: TypedReEmitter<EmittedEvents, RoomEventHandlerMap>;
public readonly reEmitter: TypedReEmitter<EmittedEvents, RoomEventHandlerMap>;
private txnToEvent: Record<string, MatrixEvent> = {}; // Pending in-flight requests { string: MatrixEvent }
// receipts should clobber based on receipt_type and user_id pairs hence
// the form of this structure. This is sub-optimal for the exposed APIs
@@ -260,7 +265,7 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
/**
* @experimental
*/
public threads = new Map<string, Thread>();
private threads = new Map<string, Thread>();
public lastThread: Thread;
/**
@@ -346,15 +351,6 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
RoomEvent.TimelineReset,
]);
if (this.client?.supportsExperimentalThreads) {
Promise.all([
this.createThreadTimelineSet(),
this.createThreadTimelineSet(ThreadFilterType.My),
]).then((timelineSets) => {
this.threadsTimelineSets.push(...timelineSets);
});
}
this.fixUpLegacyTimelineFields();
if (this.opts.pendingEventOrdering === PendingEventOrdering.Detached) {
@@ -381,6 +377,26 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
}
}
private threadTimelineSetsPromise: Promise<[EventTimelineSet, EventTimelineSet]> | null = null;
public async createThreadsTimelineSets(): Promise<[EventTimelineSet, EventTimelineSet]> {
if (this.threadTimelineSetsPromise) {
return this.threadTimelineSetsPromise;
}
if (this.client?.supportsExperimentalThreads()) {
try {
this.threadTimelineSetsPromise = Promise.all([
this.createThreadTimelineSet(),
this.createThreadTimelineSet(ThreadFilterType.My),
]);
const timelineSets = await this.threadTimelineSetsPromise;
this.threadsTimelineSets.push(...timelineSets);
} catch (e) {
this.threadTimelineSetsPromise = null;
}
}
}
/**
* Bulk decrypt critical events in a room
*
@@ -771,16 +787,9 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
}
}
private async loadMembersFromServer(): Promise<IEvent[]> {
private async loadMembersFromServer(): Promise<IStateEventWithRoomId[]> {
const lastSyncToken = this.client.store.getSyncToken();
const queryString = utils.encodeParams({
not_membership: "leave",
at: lastSyncToken,
});
const path = utils.encodeUri("/rooms/$roomId/members?" + queryString,
{ $roomId: this.roomId });
const http = this.client.http;
const response = await http.authedRequest<{ chunk: IEvent[] }>(undefined, Method.Get, path);
const response = await this.client.members(this.roomId, undefined, "leave", lastSyncToken);
return response.chunk;
}
@@ -794,7 +803,8 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
// fails), since loadMembersIfNeeded always returns this.membersPromise
// if set, which will be the result of the first (successful) call.
if (rawMembersEvents === null ||
(this.client.isCryptoEnabled() && this.client.isRoomEncrypted(this.roomId))) {
(this.client.isCryptoEnabled() && this.client.isRoomEncrypted(this.roomId))
) {
fromServer = true;
rawMembersEvents = await this.loadMembersFromServer();
logger.log(`LL: got ${rawMembersEvents.length} ` +
@@ -840,7 +850,7 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
if (fromServer) {
const oobMembers = this.currentState.getMembers()
.filter((m) => m.isOutOfBand())
.map((m) => m.events.member.event as IEvent);
.map((m) => m.events.member.event as IStateEventWithRoomId);
logger.log(`LL: telling store to write ${oobMembers.length}`
+ ` members for room ${this.roomId}`);
const store = this.client.store;
@@ -991,7 +1001,7 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
}
/**
* Get an event which is stored in our unfiltered timeline set or in a thread
* Get an event which is stored in our unfiltered timeline set, or in a thread
*
* @param {string} eventId event ID to look for
* @return {?module:models/event.MatrixEvent} the given event, or undefined if unknown
@@ -999,9 +1009,7 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
public findEventById(eventId: string): MatrixEvent | undefined {
let event = this.getUnfilteredTimelineSet().findEventById(eventId);
if (event) {
return event;
} else {
if (!event) {
const threads = this.getThreads();
for (let i = 0; i < threads.length; i++) {
const thread = threads[i];
@@ -1011,6 +1019,8 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
}
}
}
return event;
}
/**
@@ -1115,14 +1125,14 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
* The aliases returned by this function may not necessarily
* still point to this room.
* @return {array} The room's alias as an array of strings
* @deprecated this uses m.room.aliases events, replaced by Room::getAltAliases()
*/
public getAliases(): string[] {
const aliasStrings: string[] = [];
const aliasEvents = this.currentState.getStateEvents(EventType.RoomAliases);
if (aliasEvents) {
for (let i = 0; i < aliasEvents.length; ++i) {
const aliasEvent = aliasEvents[i];
for (const aliasEvent of aliasEvents) {
if (Array.isArray(aliasEvent.getContent().aliases)) {
const filteredAliases = aliasEvent.getContent<{ aliases: string[] }>().aliases.filter(a => {
if (typeof(a) !== "string") return false;
@@ -1132,7 +1142,7 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
// It's probably valid by here.
return true;
});
Array.prototype.push.apply(aliasStrings, filteredAliases);
aliasStrings.push(...filteredAliases);
}
}
}
@@ -1190,19 +1200,14 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
timeline: EventTimeline,
paginationToken?: string,
): void {
timeline.getTimelineSet().addEventsToTimeline(
events, toStartOfTimeline,
timeline, paginationToken,
);
timeline.getTimelineSet().addEventsToTimeline(events, toStartOfTimeline, timeline, paginationToken);
}
/**
* @experimental
*/
public getThread(eventId: string): Thread {
return this.getThreads().find(thread => {
return thread.id === eventId;
});
return this.threads.get(eventId);
}
/**
@@ -1335,6 +1340,7 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
filter: Filter,
{
prepopulateTimeline = true,
useSyncEvents = true,
pendingEvents = true,
}: ICreateFilterOpts = {},
): EventTimelineSet {
@@ -1347,8 +1353,10 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
RoomEvent.Timeline,
RoomEvent.TimelineReset,
]);
if (useSyncEvents) {
this.filteredTimelineSets[filter.filterId] = timelineSet;
this.timelineSets.push(timelineSet);
}
const unfilteredLiveTimeline = this.getLiveTimeline();
// Not all filter are possible to replicate client-side only
@@ -1376,7 +1384,7 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
timeline.getPaginationToken(EventTimeline.BACKWARDS),
EventTimeline.BACKWARDS,
);
} else {
} else if (useSyncEvents) {
const livePaginationToken = unfilteredLiveTimeline.getPaginationToken(Direction.Forward);
timelineSet
.getLiveTimeline()
@@ -1394,9 +1402,7 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
return timelineSet;
}
private async createThreadTimelineSet(filterType?: ThreadFilterType): Promise<EventTimelineSet> {
let timelineSet: EventTimelineSet;
if (Thread.hasServerSideSupport) {
private async getThreadListFilter(filterType = ThreadFilterType.All): Promise<Filter> {
const myUserId = this.client.getUserId();
const filter = new Filter(myUserId);
@@ -1417,18 +1423,25 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
`THREAD_PANEL_${this.roomId}_${filterType}`,
filter,
);
filter.filterId = filterId;
return filter;
}
private async createThreadTimelineSet(filterType?: ThreadFilterType): Promise<EventTimelineSet> {
let timelineSet: EventTimelineSet;
if (Thread.hasServerSideSupport) {
const filter = await this.getThreadListFilter(filterType);
timelineSet = this.getOrCreateFilteredTimelineSet(
filter,
{
prepopulateTimeline: false,
useSyncEvents: false,
pendingEvents: false,
},
);
// An empty pagination token allows to paginate from the very bottom of
// the timeline set.
timelineSet.getLiveTimeline().setPaginationToken("", EventTimeline.BACKWARDS);
} else {
timelineSet = new EventTimelineSet(this, {
pendingEvents: false,
@@ -1449,6 +1462,86 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
return timelineSet;
}
public threadsReady = false;
public async fetchRoomThreads(): Promise<void> {
if (this.threadsReady || !this.client.supportsExperimentalThreads()) {
return;
}
const allThreadsFilter = await this.getThreadListFilter();
const { chunk: events } = await this.client.createMessagesRequest(
this.roomId,
"",
Number.MAX_SAFE_INTEGER,
Direction.Backward,
allThreadsFilter,
);
if (!events.length) return;
// Sorted by last_reply origin_server_ts
const threadRoots = events
.map(this.client.getEventMapper())
.sort((eventA, eventB) => {
/**
* `origin_server_ts` in a decentralised world is far from ideal
* but for lack of any better, we will have to use this
* Long term the sorting should be handled by homeservers and this
* is only meant as a short term patch
*/
const threadAMetadata = eventA
.getServerAggregatedRelation<IThreadBundledRelationship>(RelationType.Thread);
const threadBMetadata = eventB
.getServerAggregatedRelation<IThreadBundledRelationship>(RelationType.Thread);
return threadAMetadata.latest_event.origin_server_ts - threadBMetadata.latest_event.origin_server_ts;
});
let latestMyThreadsRootEvent: MatrixEvent;
const roomState = this.getLiveTimeline().getState(EventTimeline.FORWARDS);
for (const rootEvent of threadRoots) {
this.threadsTimelineSets[0].addLiveEvent(
rootEvent,
DuplicateStrategy.Ignore,
false,
roomState,
);
const threadRelationship = rootEvent
.getServerAggregatedRelation<IThreadBundledRelationship>(RelationType.Thread);
if (threadRelationship.current_user_participated) {
this.threadsTimelineSets[1].addLiveEvent(
rootEvent,
DuplicateStrategy.Ignore,
false,
roomState,
);
latestMyThreadsRootEvent = rootEvent;
}
if (!this.getThread(rootEvent.getId())) {
this.createThread(rootEvent.getId(), rootEvent, [], true);
}
}
this.client.decryptEventIfNeeded(threadRoots[threadRoots.length -1]);
if (latestMyThreadsRootEvent) {
this.client.decryptEventIfNeeded(latestMyThreadsRootEvent);
}
this.threadsReady = true;
this.on(ThreadEvent.NewReply, this.onThreadNewReply);
}
private onThreadNewReply(thread: Thread): void {
for (const timelineSet of this.threadsTimelineSets) {
timelineSet.removeEvent(thread.id);
timelineSet.addLiveEvent(thread.rootEvent);
}
}
/**
* Forget the timelineSet for this room with the given filter
*
@@ -1463,82 +1556,127 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
}
}
public findThreadForEvent(event: MatrixEvent): Thread | null {
if (!event) {
return null;
public eventShouldLiveIn(event: MatrixEvent, events?: MatrixEvent[], roots?: Set<string>): {
shouldLiveInRoom: boolean;
shouldLiveInThread: boolean;
threadId?: string;
} {
if (!this.client.supportsExperimentalThreads()) {
return {
shouldLiveInRoom: true,
shouldLiveInThread: false,
};
}
// A thread root is always shown in both timelines
if (event.isThreadRoot || roots?.has(event.getId())) {
return {
shouldLiveInRoom: true,
shouldLiveInThread: true,
threadId: event.getId(),
};
}
// A thread relation is always only shown in a thread
if (event.isThreadRelation) {
return this.threads.get(event.threadRootId);
} else if (event.isThreadRoot) {
return this.threads.get(event.getId());
return {
shouldLiveInRoom: false,
shouldLiveInThread: true,
threadId: event.threadRootId,
};
}
const parentEventId = event.getAssociatedId();
const parentEvent = this.findEventById(parentEventId) ?? events?.find(e => e.getId() === parentEventId);
// Treat relations and redactions as extensions of their parents so evaluate parentEvent instead
if (parentEvent && (event.isRelation() || event.isRedaction())) {
return this.eventShouldLiveIn(parentEvent, events, roots);
}
// Edge case where we know the event is a relation but don't have the parentEvent
if (roots?.has(event.relationEventId)) {
return {
shouldLiveInRoom: true,
shouldLiveInThread: true,
threadId: event.relationEventId,
};
}
// We've exhausted all scenarios, can safely assume that this event should live in the room timeline only
return {
shouldLiveInRoom: true,
shouldLiveInThread: false,
};
}
public findThreadForEvent(event?: MatrixEvent): Thread | null {
if (!event) return null;
const { threadId } = this.eventShouldLiveIn(event);
return threadId ? this.getThread(threadId) : null;
}
private addThreadedEvents(threadId: string, events: MatrixEvent[], toStartOfTimeline = false): void {
let thread = this.getThread(threadId);
if (thread) {
thread.addEvents(events, toStartOfTimeline);
} else {
const parentEvent = this.findEventById(event.getAssociatedId());
return this.findThreadForEvent(parentEvent);
const rootEvent = this.findEventById(threadId) ?? events.find(e => e.getId() === threadId);
thread = this.createThread(threadId, rootEvent, events, toStartOfTimeline);
this.emit(ThreadEvent.Update, thread);
}
}
/**
* Add an event to a thread's timeline. Will fire "Thread.update"
* Adds events to a thread's timeline. Will fire "Thread.update"
* @experimental
*/
public async addThreadedEvent(event: MatrixEvent, toStartOfTimeline: boolean): Promise<void> {
this.applyRedaction(event);
let thread = this.findThreadForEvent(event);
if (thread) {
thread.addEvent(event, toStartOfTimeline);
} else {
const events = [event];
let rootEvent = this.findEventById(event.threadRootId);
// If the rootEvent does not exist in the current sync, then look for
// it over the network
try {
let eventData;
if (event.threadRootId) {
eventData = await this.client.fetchRoomEvent(this.roomId, event.threadRootId);
public processThreadedEvents(events: MatrixEvent[], toStartOfTimeline: boolean): void {
events.forEach(this.applyRedaction);
const eventsByThread: { [threadId: string]: MatrixEvent[] } = {};
for (const event of events) {
const { threadId, shouldLiveInThread } = this.eventShouldLiveIn(event);
if (shouldLiveInThread && !eventsByThread[threadId]) {
eventsByThread[threadId] = [];
}
eventsByThread[threadId]?.push(event);
}
if (!rootEvent) {
rootEvent = new MatrixEvent(eventData);
} else {
rootEvent.setUnsigned(eventData.unsigned);
}
} finally {
// The root event might be not be visible to the person requesting
// it. If it wasn't fetched successfully the thread will work
// in "limited" mode and won't benefit from all the APIs a homeserver
// can provide to enhance the thread experience
thread = this.createThread(rootEvent, events, toStartOfTimeline);
}
}
this.emit(ThreadEvent.Update, thread);
Object.entries(eventsByThread).map(([threadId, threadEvents]) => (
this.addThreadedEvents(threadId, threadEvents, toStartOfTimeline)
));
}
public createThread(
threadId: string,
rootEvent: MatrixEvent | undefined,
events: MatrixEvent[] = [],
toStartOfTimeline: boolean,
): Thread | undefined {
): Thread {
if (rootEvent) {
const tl = this.getTimelineForEvent(rootEvent.getId());
const relatedEvents = tl?.getTimelineSet().getAllRelationsEventForEvent(rootEvent.getId());
if (relatedEvents) {
events = events.concat(relatedEvents);
if (relatedEvents?.length) {
// Include all relations of the root event, given it'll be visible in both timelines,
// except `m.replace` as that will already be applied atop the event using `MatrixEvent::makeReplaced`
events = events.concat(relatedEvents.filter(e => !e.isRelation(RelationType.Replace)));
}
}
const thread = new Thread(rootEvent, {
const thread = new Thread(threadId, rootEvent, {
initialEvents: events,
room: this,
client: this.client,
});
// If we managed to create a thread and figure out its `id`
// then we can use it
if (thread.id) {
// If we managed to create a thread and figure out its `id` then we can use it
this.threads.set(thread.id, thread);
this.reEmitter.reEmit(thread, [
ThreadEvent.Update,
ThreadEvent.NewReply,
RoomEvent.Timeline,
RoomEvent.TimelineReset,
]);
@@ -1549,6 +1687,7 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
this.emit(ThreadEvent.New, thread, toStartOfTimeline);
if (this.threadsReady) {
this.threadsTimelineSets.forEach(timelineSet => {
if (thread.rootEvent) {
if (Thread.hasServerSideSupport) {
@@ -1562,12 +1701,12 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
}
}
});
}
return thread;
}
}
applyRedaction(event: MatrixEvent): void {
private applyRedaction = (event: MatrixEvent): void => {
if (event.isRedaction()) {
const redactId = event.event.redacts;
@@ -1577,7 +1716,7 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
redactedEvent.makeRedacted(event);
// If this is in the current state, replace it with the redacted version
if (redactedEvent.getStateKey()) {
if (redactedEvent.isState()) {
const currentStateEvent = this.currentState.getStateEvents(
redactedEvent.getType(),
redactedEvent.getStateKey(),
@@ -1611,19 +1750,9 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
// clients can say "so and so redacted an event" if they wish to. Also
// this may be needed to trigger an update.
}
}
};
/**
* Add an event to the end of this room's live timelines. Will fire
* "Room.timeline".
*
* @param {MatrixEvent} event Event to be added
* @param {string?} duplicateStrategy 'ignore' or 'replace'
* @param {boolean} fromCache whether the sync response came from cache
* @fires module:client~MatrixClient#event:"Room.timeline"
* @private
*/
private addLiveEvent(event: MatrixEvent, duplicateStrategy?: DuplicateStrategy, fromCache = false): void {
private processLiveEvent(event: MatrixEvent): void {
this.applyRedaction(event);
// Implement MSC3531: hiding messages.
@@ -1640,10 +1769,21 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
if (existingEvent) {
// remote echo of an event we sent earlier
this.handleRemoteEcho(event, existingEvent);
return;
}
}
}
/**
* Add an event to the end of this room's live timelines. Will fire
* "Room.timeline".
*
* @param {MatrixEvent} event Event to be added
* @param {string?} duplicateStrategy 'ignore' or 'replace'
* @param {boolean} fromCache whether the sync response came from cache
* @fires module:client~MatrixClient#event:"Room.timeline"
* @private
*/
private addLiveEvent(event: MatrixEvent, duplicateStrategy: DuplicateStrategy, fromCache = false): void {
// add to our timeline sets
for (let i = 0; i < this.timelineSets.length; i++) {
this.timelineSets[i].addLiveEvent(event, duplicateStrategy, fromCache);
@@ -1655,7 +1795,7 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
// Don't synthesize RR for m.room.redaction as this causes the RR to go missing.
if (event.sender && event.getType() !== EventType.RoomRedaction) {
this.addReceipt(synthesizeReceipt(
event.sender.userId, event, "m.read",
event.sender.userId, event, ReceiptType.Read,
), true);
// Any live events from a user could be taken as implicit
@@ -1794,12 +1934,11 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
* @param {module:models/event.MatrixEvent} event the relation event that needs to be aggregated.
*/
private aggregateNonLiveRelation(event: MatrixEvent): void {
const thread = this.findThreadForEvent(event);
if (thread) {
thread.timelineSet.aggregateRelations(event);
}
const { shouldLiveInRoom, threadId } = this.eventShouldLiveIn(event);
const thread = this.getThread(threadId);
thread?.timelineSet.aggregateRelations(event);
if (thread?.id === event.getAssociatedId() || !thread) {
if (shouldLiveInRoom) {
// TODO: We should consider whether this means it would be a better
// design to lift the relations handling up to the room instead.
for (let i = 0; i < this.timelineSets.length; i++) {
@@ -1838,10 +1977,7 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
const newEventId = remoteEvent.getId();
const oldStatus = localEvent.status;
logger.debug(
`Got remote echo for event ${oldEventId} -> ${newEventId} ` +
`old status ${oldStatus}`,
);
logger.debug(`Got remote echo for event ${oldEventId} -> ${newEventId} old status ${oldStatus}`);
// no longer pending
delete this.txnToEvent[remoteEvent.getUnsigned().transaction_id];
@@ -1855,12 +1991,11 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
// any, which is good, because we don't want to try decoding it again).
localEvent.handleRemoteEcho(remoteEvent.event);
const thread = this.findThreadForEvent(remoteEvent);
if (thread) {
thread.timelineSet.handleRemoteEcho(localEvent, oldEventId, newEventId);
}
const { shouldLiveInRoom, threadId } = this.eventShouldLiveIn(remoteEvent);
const thread = this.getThread(threadId);
thread?.timelineSet.handleRemoteEcho(localEvent, oldEventId, newEventId);
if (thread?.id === remoteEvent.getAssociatedId() || !thread) {
if (shouldLiveInRoom) {
for (let i = 0; i < this.timelineSets.length; i++) {
const timelineSet = this.timelineSets[i];
@@ -1926,11 +2061,11 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
// update the event id
event.replaceLocalEventId(newEventId);
const thread = this.findThreadForEvent(event);
if (thread) {
thread.timelineSet.replaceEventId(oldEventId, newEventId);
}
if (thread?.id === event.getAssociatedId() || !thread) {
const { shouldLiveInRoom, threadId } = this.eventShouldLiveIn(event);
const thread = this.getThread(threadId);
thread?.timelineSet.replaceEventId(oldEventId, newEventId);
if (shouldLiveInRoom) {
// if the event was already in the timeline (which will be the case if
// opts.pendingEventOrdering==chronological), we need to update the
// timeline map.
@@ -1941,14 +2076,12 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
} else if (newStatus == EventStatus.CANCELLED) {
// remove it from the pending event list, or the timeline.
if (this.pendingEventList) {
const idx = this.pendingEventList.findIndex(ev => ev.getId() === oldEventId);
if (idx !== -1) {
const [removedEvent] = this.pendingEventList.splice(idx, 1);
const removedEvent = this.getPendingEvent(oldEventId);
this.removePendingEvent(oldEventId);
if (removedEvent.isRedaction()) {
this.revertRedactionLocalEcho(removedEvent);
}
}
}
this.removeEvent(oldEventId);
}
this.savePendingEvents();
@@ -1992,13 +2125,12 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
* @throws If <code>duplicateStrategy</code> is not falsey, 'replace' or 'ignore'.
*/
public addLiveEvents(events: MatrixEvent[], duplicateStrategy?: DuplicateStrategy, fromCache = false): void {
let i;
if (duplicateStrategy && ["replace", "ignore"].indexOf(duplicateStrategy) === -1) {
throw new Error("duplicateStrategy MUST be either 'replace' or 'ignore'");
}
// sanity check that the live timeline is still live
for (i = 0; i < this.timelineSets.length; i++) {
for (let i = 0; i < this.timelineSets.length; i++) {
const liveTimeline = this.timelineSets[i].getLiveTimeline();
if (liveTimeline.getPaginationToken(EventTimeline.FORWARDS)) {
throw new Error(
@@ -2007,22 +2139,85 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
);
}
if (liveTimeline.getNeighbouringTimeline(EventTimeline.FORWARDS)) {
throw new Error(
"live timeline " + i + " is no longer live - " +
"it has a neighbouring timeline",
);
throw new Error(`live timeline ${i} is no longer live - it has a neighbouring timeline`);
}
}
for (i = 0; i < events.length; i++) {
// TODO: We should have a filter to say "only add state event
// types X Y Z to the timeline".
this.addLiveEvent(events[i], duplicateStrategy, fromCache);
const thread = this.findThreadForEvent(events[i]);
if (thread) {
thread.addEvent(events[i], true);
const threadRoots = this.findThreadRoots(events);
const eventsByThread: { [threadId: string]: MatrixEvent[] } = {};
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);
const {
shouldLiveInRoom,
shouldLiveInThread,
threadId,
} = this.eventShouldLiveIn(event, events, threadRoots);
if (shouldLiveInThread && !eventsByThread[threadId]) {
eventsByThread[threadId] = [];
}
eventsByThread[threadId]?.push(event);
if (shouldLiveInRoom) {
this.addLiveEvent(event, duplicateStrategy, fromCache);
}
}
Object.entries(eventsByThread).forEach(([threadId, threadEvents]) => {
this.addThreadedEvents(threadId, threadEvents, false);
});
}
public partitionThreadedEvents(events: MatrixEvent[]): [
timelineEvents: MatrixEvent[],
threadedEvents: MatrixEvent[],
] {
// Indices to the events array, for readability
const ROOM = 0;
const THREAD = 1;
if (this.client.supportsExperimentalThreads()) {
const threadRoots = this.findThreadRoots(events);
return events.reduce((memo, event: MatrixEvent) => {
const {
shouldLiveInRoom,
shouldLiveInThread,
threadId,
} = this.eventShouldLiveIn(event, events, threadRoots);
if (shouldLiveInRoom) {
memo[ROOM].push(event);
}
if (shouldLiveInThread) {
event.setThreadId(threadId);
memo[THREAD].push(event);
}
return memo;
}, [[], []]);
} else {
// When `experimentalThreadSupport` is disabled treat all events as timelineEvents
return [
events,
[],
];
}
}
/**
* Given some events, find the IDs of all the thread roots that are referred to by them.
*/
private findThreadRoots(events: MatrixEvent[]): Set<string> {
const threadRoots = new Set<string>();
for (const event of events) {
if (event.isThreadRelation) {
threadRoots.add(event.relationEventId);
}
}
return threadRoots;
}
/**
@@ -2080,7 +2275,11 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
// set fake stripped state events if this is an invite room so logic remains
// consistent elsewhere.
const membershipEvent = this.currentState.getStateEvents(EventType.RoomMember, this.myUserId);
if (membershipEvent && membershipEvent.getContent().membership === "invite") {
if (membershipEvent) {
const membership = membershipEvent.getContent().membership;
this.updateMyMembership(membership);
if (membership === "invite") {
const strippedStateEvents = membershipEvent.getUnsigned().invite_room_state || [];
strippedStateEvents.forEach((strippedEvent) => {
const existingEvent = this.currentState.getStateEvents(strippedEvent.type, strippedEvent.state_key);
@@ -2097,6 +2296,7 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
}
});
}
}
const oldName = this.name;
this.name = this.calculateRoomName(this.myUserId);
@@ -2117,14 +2317,23 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
*/
public getUsersReadUpTo(event: MatrixEvent): string[] {
return this.getReceiptsForEvent(event).filter(function(receipt) {
return receipt.type === "m.read";
return [ReceiptType.Read, ReceiptType.ReadPrivate].includes(receipt.type);
}).map(function(receipt) {
return receipt.userId;
});
}
public getReadReceiptForUserId(userId: string, ignoreSynthesized = false): IWrappedReceipt | null {
const [realReceipt, syntheticReceipt] = this.receipts["m.read"]?.[userId] ?? [];
/**
* Gets the latest receipt for a given user in the room
* @param userId The id of the user for which we want the receipt
* @param ignoreSynthesized Whether to ignore synthesized receipts or not
* @param receiptType Optional. The type of the receipt we want to get
* @returns the latest receipts of the chosen type for the chosen user
*/
public getReadReceiptForUserId(
userId: string, ignoreSynthesized = false, receiptType = ReceiptType.Read,
): IWrappedReceipt | null {
const [realReceipt, syntheticReceipt] = this.receipts[receiptType]?.[userId] ?? [];
if (ignoreSynthesized) {
return realReceipt;
}
@@ -2142,8 +2351,25 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
* @return {String} ID of the latest event that the given user has read, or null.
*/
public getEventReadUpTo(userId: string, ignoreSynthesized = false): string | null {
const readReceipt = this.getReadReceiptForUserId(userId, ignoreSynthesized);
return readReceipt?.eventId ?? null;
const timelineSet = this.getUnfilteredTimelineSet();
const publicReadReceipt = this.getReadReceiptForUserId(userId, ignoreSynthesized, ReceiptType.Read);
const privateReadReceipt = this.getReadReceiptForUserId(userId, ignoreSynthesized, ReceiptType.ReadPrivate);
// If we have both, compare them
let comparison: number | undefined;
if (publicReadReceipt?.eventId && privateReadReceipt?.eventId) {
comparison = timelineSet.compareEventOrdering(publicReadReceipt?.eventId, privateReadReceipt?.eventId);
}
// If we didn't get a comparison try to compare the ts of the receipts
if (!comparison) comparison = publicReadReceipt?.data?.ts - privateReadReceipt?.data?.ts;
// The public receipt is more likely to drift out of date so the private
// one has precedence
if (!comparison) return privateReadReceipt?.eventId ?? publicReadReceipt?.eventId ?? null;
// If public read receipt is older, return the private one
return (comparison < 0) ? privateReadReceipt?.eventId : publicReadReceipt?.eventId;
}
/**
@@ -2296,7 +2522,7 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
}
this.receiptCacheByEventId[eventId].push({
userId: userId,
type: receiptType,
type: receiptType as ReceiptType,
data: receipt,
});
});
@@ -2309,9 +2535,9 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
* client the fact that we've sent one.
* @param {string} userId The user ID if the receipt sender
* @param {MatrixEvent} e The event that is to be acknowledged
* @param {string} receiptType The type of receipt
* @param {ReceiptType} receiptType The type of receipt
*/
public addLocalEchoReceipt(userId: string, e: MatrixEvent, receiptType: string): void {
public addLocalEchoReceipt(userId: string, e: MatrixEvent, receiptType: ReceiptType): void {
this.addReceipt(synthesizeReceipt(userId, e, receiptType), true);
}
@@ -2414,7 +2640,7 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
/**
* Returns the type of the room from the `m.room.create` event content or undefined if none is set
* @returns {?string} the type of the room. Currently only RoomType.Space is known.
* @returns {?string} the type of the room.
*/
public getType(): RoomType | string | undefined {
const createEvent = this.currentState.getStateEvents(EventType.RoomCreate, "");
@@ -2436,6 +2662,22 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
return this.getType() === RoomType.Space;
}
/**
* Returns whether the room is a call-room as defined by MSC3417.
* @returns {boolean} true if the room's type is RoomType.UnstableCall
*/
public isCallRoom(): boolean {
return this.getType() === RoomType.UnstableCall;
}
/**
* Returns whether the room is a video room.
* @returns {boolean} true if the room's type is RoomType.ElementVideo
*/
public isElementVideoRoom(): boolean {
return this.getType() === RoomType.ElementVideo;
}
/**
* This is an internal method. Calculates the name of the room from the current
* room state.

View File

@@ -33,14 +33,19 @@ export class SearchResult {
public static fromJson(jsonObj: ISearchResult, eventMapper: EventMapper): SearchResult {
const jsonContext = jsonObj.context || {} as IResultContext;
const eventsBefore = jsonContext.events_before || [];
const eventsAfter = jsonContext.events_after || [];
let eventsBefore = (jsonContext.events_before || []).map(eventMapper);
let eventsAfter = (jsonContext.events_after || []).map(eventMapper);
const context = new EventContext(eventMapper(jsonObj.result));
// Filter out any contextual events which do not correspond to the same timeline (thread or room)
const threadRootId = context.ourEvent.threadRootId;
eventsBefore = eventsBefore.filter(e => e.threadRootId === threadRootId);
eventsAfter = eventsAfter.filter(e => e.threadRootId === threadRootId);
context.setPaginateToken(jsonContext.start, true);
context.addEvents(eventsBefore.map(eventMapper), true);
context.addEvents(eventsAfter.map(eventMapper), false);
context.addEvents(eventsBefore, true);
context.addEvents(eventsAfter, false);
context.setPaginateToken(jsonContext.end, false);
return new SearchResult(jsonObj.rank, context);

View File

@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { MatrixClient, RoomEvent } from "../matrix";
import { MatrixClient, MatrixEventEvent, RelationType, RoomEvent } from "../matrix";
import { TypedReEmitter } from "../ReEmitter";
import { IRelationsRequestOpts } from "../@types/requests";
import { IThreadBundledRelationship, MatrixEvent } from "./event";
@@ -24,6 +24,7 @@ import { Room } from './room';
import { TypedEventEmitter } from "./typed-event-emitter";
import { RoomState } from "./room-state";
import { ServerControlledNamespacedValue } from "../NamespacedValue";
import { logger } from "../logger";
export enum ThreadEvent {
New = "Thread.new",
@@ -69,16 +70,21 @@ export class Thread extends TypedEventEmitter<EmittedEvents, EventHandlerMap> {
public readonly room: Room;
public readonly client: MatrixClient;
public initialEventsFetched = false;
public readonly id: string;
public initialEventsFetched = !Thread.hasServerSideSupport;
constructor(
public readonly rootEvent: MatrixEvent | undefined,
public readonly id: string,
public rootEvent: MatrixEvent | undefined,
opts: IThreadOpts,
) {
super();
if (!opts?.room) {
// Logging/debugging for https://github.com/vector-im/element-web/issues/22141
// Hope is that we end up with a more obvious stack trace.
throw new Error("element-web#22141: A thread requires a room in order to function");
}
this.room = opts.room;
this.client = opts.client;
this.timelineSet = new EventTimelineSet(this.room, {
@@ -93,20 +99,38 @@ export class Thread extends TypedEventEmitter<EmittedEvents, EventHandlerMap> {
RoomEvent.TimelineReset,
]);
// If we weren't able to find the root event, it's probably missing
// and we define the thread ID from one of the thread relation
if (!rootEvent) {
this.id = opts?.initialEvents
?.find(event => event.isThreadRelation)?.relationEventId;
} else {
this.id = rootEvent.getId();
}
this.initialiseThread(this.rootEvent);
opts?.initialEvents?.forEach(event => this.addEvent(event, false));
this.room.on(MatrixEventEvent.BeforeRedaction, this.onBeforeRedaction);
this.room.on(RoomEvent.Redaction, this.onRedaction);
this.room.on(RoomEvent.LocalEchoUpdated, this.onEcho);
this.room.on(RoomEvent.Timeline, this.onEcho);
this.timelineSet.on(RoomEvent.Timeline, this.onEcho);
if (opts.initialEvents) {
this.addEvents(opts.initialEvents, false);
}
// even if this thread is thought to be originating from this client, we initialise it as we may be in a
// gappy sync and a thread around this event may already exist.
this.initialiseThread();
this.rootEvent?.setThread(this);
}
private async fetchRootEvent(): Promise<void> {
this.rootEvent = this.room.findEventById(this.id);
// If the rootEvent does not exist in the local stores, then fetch it from the server.
try {
const eventData = await this.client.fetchRoomEvent(this.roomId, this.id);
const mapper = this.client.getEventMapper();
this.rootEvent = mapper(eventData); // will merge with existing event object if such is known
} catch (e) {
logger.error("Failed to fetch thread root to construct thread with", e);
}
// The root event might be not be visible to the person requesting it.
// If it wasn't fetched successfully the thread will work in "limited" mode and won't
// benefit from all the APIs a homeserver can provide to enhance the thread experience
this.rootEvent?.setThread(this);
this.emit(ThreadEvent.Update, this);
}
public static setServerSideSupport(hasServerSideSupport: boolean, useStable: boolean): void {
@@ -118,26 +142,59 @@ export class Thread extends TypedEventEmitter<EmittedEvents, EventHandlerMap> {
}
}
private onEcho = (event: MatrixEvent) => {
if (this.timelineSet.eventIdToTimeline(event.getId())) {
private onBeforeRedaction = (event: MatrixEvent, redaction: MatrixEvent) => {
if (event?.isRelation(THREAD_RELATION_TYPE.name) &&
this.room.eventShouldLiveIn(event).threadId === this.id &&
event.getId() !== this.id && // the root event isn't counted in the length so ignore this redaction
!redaction.status // only respect it when it succeeds
) {
this.replyCount--;
this.emit(ThreadEvent.Update, this);
}
};
private onRedaction = (event: MatrixEvent) => {
if (event.threadRootId !== this.id) return; // ignore redactions for other timelines
const events = [...this.timelineSet.getLiveTimeline().getEvents()].reverse();
this.lastEvent = events.find(e => (
!e.isRedacted() &&
e.isRelation(THREAD_RELATION_TYPE.name)
)) ?? this.rootEvent;
this.emit(ThreadEvent.Update, this);
};
private onEcho = (event: MatrixEvent) => {
if (event.threadRootId !== this.id) return; // ignore echoes for other timelines
if (this.lastEvent === event) return;
// There is a risk that the `localTimestamp` approximation will not be accurate
// when threads are used over federation. That could result in the reply
// count value drifting away from the value returned by the server
const isThreadReply = event.isRelation(THREAD_RELATION_TYPE.name);
if (!this.lastEvent || this.lastEvent.isRedacted() || (isThreadReply
&& (event.getId() !== this.lastEvent.getId())
&& (event.localTimestamp > this.lastEvent.localTimestamp))
) {
this.lastEvent = event;
if (this.lastEvent.getId() !== this.id) {
// This counting only works when server side support is enabled as we started the counting
// from the value returned within the bundled relationship
if (Thread.hasServerSideSupport) {
this.replyCount++;
}
this.emit(ThreadEvent.NewReply, this, event);
}
}
this.emit(ThreadEvent.Update, this);
};
public get roomState(): RoomState {
return this.room.getLiveTimeline().getState(EventTimeline.FORWARDS);
}
private addEventToTimeline(event: MatrixEvent, toStartOfTimeline: boolean): void {
if (event.getUnsigned().transaction_id) {
const existingEvent = this.room.getEventForTxnId(event.getUnsigned().transaction_id);
if (existingEvent) {
// remote echo of an event we sent earlier
this.room.handleRemoteEcho(event, existingEvent);
return;
}
}
if (!this.findEventById(event.getId())) {
this.timelineSet.addEventToTimeline(
event,
@@ -149,6 +206,11 @@ export class Thread extends TypedEventEmitter<EmittedEvents, EventHandlerMap> {
}
}
public addEvents(events: MatrixEvent[], toStartOfTimeline: boolean): void {
events.forEach(ev => this.addEvent(ev, toStartOfTimeline, false));
this.emit(ThreadEvent.Update, this);
}
/**
* Add an event to the thread and updates
* the tail/root references if needed
@@ -156,64 +218,60 @@ export class Thread extends TypedEventEmitter<EmittedEvents, EventHandlerMap> {
* @param event The event to add
* @param {boolean} toStartOfTimeline whether the event is being added
* to the start (and not the end) of the timeline.
* @param {boolean} emit whether to emit the Update event if the thread was updated or not.
*/
public async addEvent(event: MatrixEvent, toStartOfTimeline: boolean): Promise<void> {
public addEvent(event: MatrixEvent, toStartOfTimeline: boolean, emit = true): void {
event.setThread(this);
if (!this._currentUserParticipated && event.getSender() === this.client.getUserId()) {
this._currentUserParticipated = true;
}
if ([RelationType.Annotation, RelationType.Replace].includes(event.getRelation()?.rel_type as RelationType)) {
// Apply annotations and replace relations to the relations of the timeline only
this.timelineSet.setRelationsTarget(event);
this.timelineSet.aggregateRelations(event);
return;
}
// Add all incoming events to the thread's timeline set when there's no server support
if (!Thread.hasServerSideSupport) {
// all the relevant membership info to hydrate events with a sender
// is held in the main room timeline
// We want to fetch the room state from there and pass it down to this thread
// timeline set to let it reconcile an event with its relevant RoomMember
event.setThread(this);
this.addEventToTimeline(event, toStartOfTimeline);
await this.client.decryptEventIfNeeded(event, {});
}
if (Thread.hasServerSideSupport && this.initialEventsFetched) {
if (event.localTimestamp > this.lastReply().localTimestamp) {
this.client.decryptEventIfNeeded(event, {});
} else if (!toStartOfTimeline &&
this.initialEventsFetched &&
event.localTimestamp > this.lastReply()?.localTimestamp
) {
this.fetchEditsWhereNeeded(event);
this.addEventToTimeline(event, false);
}
}
if (!this._currentUserParticipated && event.getSender() === this.client.getUserId()) {
this._currentUserParticipated = true;
}
const isThreadReply = event.getRelation()?.rel_type === THREAD_RELATION_TYPE.name;
// If no thread support exists we want to count all thread relation
// added as a reply. We can't rely on the bundled relationships count
if (!Thread.hasServerSideSupport && isThreadReply) {
if ((!Thread.hasServerSideSupport || !this.rootEvent) && event.isRelation(THREAD_RELATION_TYPE.name)) {
this.replyCount++;
}
// There is a risk that the `localTimestamp` approximation will not be accurate
// when threads are used over federation. That could results in the reply
// count value drifting away from the value returned by the server
if (!this.lastEvent || (isThreadReply
&& (event.getId() !== this.lastEvent.getId())
&& (event.localTimestamp > this.lastEvent.localTimestamp))
) {
this.lastEvent = event;
if (this.lastEvent.getId() !== this.id) {
// This counting only works when server side support is enabled
// as we started the counting from the value returned in the
// bundled relationship
if (Thread.hasServerSideSupport) {
this.replyCount++;
}
this.emit(ThreadEvent.NewReply, this, event);
}
}
if (emit) {
this.emit(ThreadEvent.Update, this);
}
}
private initialiseThread(rootEvent: MatrixEvent | undefined): void {
const bundledRelationship = rootEvent
?.getServerAggregatedRelation<IThreadBundledRelationship>(THREAD_RELATION_TYPE.name);
private getRootEventBundledRelationship(rootEvent = this.rootEvent): IThreadBundledRelationship {
return rootEvent?.getServerAggregatedRelation<IThreadBundledRelationship>(THREAD_RELATION_TYPE.name);
}
private async initialiseThread(): Promise<void> {
let bundledRelationship = this.getRootEventBundledRelationship();
if (Thread.hasServerSideSupport && !bundledRelationship) {
await this.fetchRootEvent();
bundledRelationship = this.getRootEventBundledRelationship();
}
if (Thread.hasServerSideSupport && bundledRelationship) {
this.replyCount = bundledRelationship.count;
@@ -221,27 +279,34 @@ export class Thread extends TypedEventEmitter<EmittedEvents, EventHandlerMap> {
const event = new MatrixEvent(bundledRelationship.latest_event);
this.setEventMetadata(event);
event.setThread(this);
this.lastEvent = event;
}
this.fetchEditsWhereNeeded(event);
}
public async fetchInitialEvents(): Promise<{
originalEvent: MatrixEvent;
events: MatrixEvent[];
nextBatch?: string;
prevBatch?: string;
} | null> {
if (!Thread.hasServerSideSupport) {
this.initialEventsFetched = true;
return null;
this.emit(ThreadEvent.Update, this);
}
try {
const response = await this.fetchEvents();
this.initialEventsFetched = true;
return response;
} catch (e) {
return null;
// XXX: Workaround for https://github.com/matrix-org/matrix-spec-proposals/pull/2676/files#r827240084
private async fetchEditsWhereNeeded(...events: MatrixEvent[]): Promise<unknown> {
return Promise.all(events.filter(e => e.isEncrypted()).map((event: MatrixEvent) => {
return this.client.relations(this.roomId, event.getId(), RelationType.Replace, event.getType(), {
limit: 1,
}).then(relations => {
if (relations.events.length) {
event.makeReplaced(relations.events[0]);
}
}).catch(e => {
logger.error("Failed to load edits for encrypted thread event", e);
});
}));
}
public async fetchInitialEvents(): Promise<void> {
if (this.initialEventsFetched) return;
await this.fetchEvents();
this.initialEventsFetched = true;
}
private setEventMetadata(event: MatrixEvent): void {
@@ -253,6 +318,11 @@ export class Thread extends TypedEventEmitter<EmittedEvents, EventHandlerMap> {
* Finds an event by ID in the current thread
*/
public findEventById(eventId: string) {
// Check the lastEvent as it may have been created based on a bundled relationship and not in a timeline
if (this.lastEvent?.getId() === eventId) {
return this.lastEvent;
}
return this.timelineSet.findEventById(eventId);
}
@@ -285,7 +355,7 @@ export class Thread extends TypedEventEmitter<EmittedEvents, EventHandlerMap> {
* A getter for the last event added to the thread
*/
public get replyToEvent(): MatrixEvent {
return this.lastEvent;
return this.lastEvent ?? this.lastReply();
}
public get events(): MatrixEvent[] {
@@ -304,7 +374,7 @@ export class Thread extends TypedEventEmitter<EmittedEvents, EventHandlerMap> {
return this.timelineSet.getLiveTimeline();
}
public async fetchEvents(opts: IRelationsRequestOpts = { limit: 20 }): Promise<{
public async fetchEvents(opts: IRelationsRequestOpts = { limit: 20, direction: Direction.Backward }): Promise<{
originalEvent: MatrixEvent;
events: MatrixEvent[];
nextBatch?: string;
@@ -329,12 +399,14 @@ export class Thread extends TypedEventEmitter<EmittedEvents, EventHandlerMap> {
events = [...events, originalEvent];
}
await this.fetchEditsWhereNeeded(...events);
await Promise.all(events.map(event => {
this.setEventMetadata(event);
return this.client.decryptEventIfNeeded(event);
}));
const prependEvents = !opts.direction || opts.direction === Direction.Backward;
const prependEvents = (opts.direction ?? Direction.Backward) === Direction.Backward;
this.timelineSet.addEventsToTimeline(
events,

View File

@@ -27,8 +27,6 @@ export enum UserEvent {
Presence = "User.presence",
CurrentlyActive = "User.currentlyActive",
LastPresenceTs = "User.lastPresenceTs",
/* @deprecated */
_UnstableStatusMessage = "User.unstable_statusMessage",
}
export type UserEventHandlerMap = {
@@ -37,7 +35,6 @@ export type UserEventHandlerMap = {
[UserEvent.Presence]: (event: MatrixEvent | undefined, user: User) => void;
[UserEvent.CurrentlyActive]: (event: MatrixEvent | undefined, user: User) => void;
[UserEvent.LastPresenceTs]: (event: MatrixEvent | undefined, user: User) => void;
[UserEvent._UnstableStatusMessage]: (user: User) => void;
};
export class User extends TypedEventEmitter<UserEvent, UserEventHandlerMap> {
@@ -59,8 +56,6 @@ export class User extends TypedEventEmitter<UserEvent, UserEventHandlerMap> {
presence: null,
profile: null,
};
// eslint-disable-next-line camelcase
public unstable_statusMessage = "";
/**
* Construct a new User. A User must have an ID and can optionally have extra
@@ -81,9 +76,6 @@ export class User extends TypedEventEmitter<UserEvent, UserEventHandlerMap> {
* when a user was last active.
* @prop {Boolean} currentlyActive Whether we should consider lastActiveAgo to be
* an approximation and that the user should be seen as active 'now'
* @prop {string} unstable_statusMessage The status message for the user, if known. This is
* different from the presenceStatusMsg in that this is not tied to
* the user's presence, and should be represented differently.
* @prop {Object} events The events describing this user.
* @prop {MatrixEvent} events.presence The m.presence event for this user.
*/
@@ -219,19 +211,6 @@ export class User extends TypedEventEmitter<UserEvent, UserEventHandlerMap> {
public getLastActiveTs(): number {
return this.lastPresenceTs - this.lastActiveAgo;
}
/**
* Manually set the user's status message.
* @param {MatrixEvent} event The <code>im.vector.user_status</code> event.
* @fires module:client~MatrixClient#event:"User.unstable_statusMessage"
*/
// eslint-disable-next-line
public unstable_updateStatusMessage(event: MatrixEvent): void {
if (!event.getContent()) this.unstable_statusMessage = "";
else this.unstable_statusMessage = event.getContent()["status"];
this.updateModifiedTime();
this.emit(UserEvent._UnstableStatusMessage, this);
}
}
/**

View File

@@ -34,6 +34,7 @@ import {
PushRuleSet,
TweakName,
} from "./@types/PushRules";
import { EventType } from "./@types/event";
/**
* @module pushprocessor
@@ -55,31 +56,6 @@ const RULEKINDS_IN_ORDER = [
// 2. We often want to start using push rules ahead of the server supporting them,
// and so we can put them here.
const DEFAULT_OVERRIDE_RULES: IPushRule[] = [
{
// For homeservers which don't support MSC1930 yet
rule_id: ".m.rule.tombstone",
default: true,
enabled: true,
conditions: [
{
kind: ConditionKind.EventMatch,
key: "type",
pattern: "m.room.tombstone",
},
{
kind: ConditionKind.EventMatch,
key: "state_key",
pattern: "",
},
],
actions: [
PushRuleActionName.Notify,
{
set_tweak: TweakName.Highlight,
value: true,
},
],
},
{
// For homeservers which don't support MSC2153 yet
rule_id: ".m.rule.reaction",
@@ -96,6 +72,20 @@ const DEFAULT_OVERRIDE_RULES: IPushRule[] = [
PushRuleActionName.DontNotify,
],
},
{
// For homeservers which don't support MSC3786 yet
rule_id: ".org.matrix.msc3786.rule.room.server_acl",
default: true,
enabled: true,
conditions: [
{
kind: ConditionKind.EventMatch,
key: "type",
pattern: EventType.RoomServerAcl,
},
],
actions: [],
},
];
export interface IActionsObject {
@@ -300,7 +290,7 @@ export class PushProcessor {
const memberCount = room.currentState.getJoinedMemberCount();
const m = cond.is.match(/^([=<>]*)([0-9]*)$/);
const m = cond.is.match(/^([=<>]*)(\d*)$/);
if (!m) {
return false;
}

View File

@@ -15,19 +15,18 @@ limitations under the License.
*/
import { EventType } from "../@types/event";
import { Group } from "../models/group";
import { Room } from "../models/room";
import { User } from "../models/user";
import { IEvent, MatrixEvent } from "../models/event";
import { MatrixEvent } from "../models/event";
import { Filter } from "../filter";
import { RoomSummary } from "../models/room-summary";
import { IMinimalEvent, IGroups, IRooms, ISyncResponse } from "../sync-accumulator";
import { IMinimalEvent, IRooms, ISyncResponse } from "../sync-accumulator";
import { IStartClientOpts } from "../client";
import { IStateEventWithRoomId } from "../@types/search";
export interface ISavedSync {
nextBatch: string;
roomsData: IRooms;
groupsData: IGroups;
accountData: IMinimalEvent[];
}
@@ -38,7 +37,11 @@ export interface ISavedSync {
export interface IStore {
readonly accountData: Record<string, MatrixEvent>; // type : content
/** @return {Promise<bool>} whether or not the database was newly created in this session. */
// XXX: The indexeddb store exposes a non-standard emitter for the "degraded" event
// for when it falls back to being a memory store due to errors.
on?: (event: string, handler: (...args: any[]) => void) => void;
/** @return {Promise<boolean>} whether or not the database was newly created in this session. */
isNewlyCreated(): Promise<boolean>;
/**
@@ -53,28 +56,6 @@ export interface IStore {
*/
setSyncToken(token: string): void;
/**
* No-op.
* @param {Group} group
* @deprecated groups/communities never made it to the spec and support for them is being discontinued.
*/
storeGroup(group: Group): void;
/**
* No-op.
* @param {string} groupId
* @return {null}
* @deprecated groups/communities never made it to the spec and support for them is being discontinued.
*/
getGroup(groupId: string): Group | null;
/**
* No-op.
* @return {Array} An empty array.
* @deprecated groups/communities never made it to the spec and support for them is being discontinued.
*/
getGroups(): Group[];
/**
* No-op.
* @param {Room} room
@@ -128,7 +109,7 @@ export interface IStore {
/**
* No-op.
* @param {Room} room
* @param {integer} limit
* @param {number} limit
* @return {Array}
*/
scrollback(room: Room, limit: number): MatrixEvent[];
@@ -228,9 +209,9 @@ export interface IStore {
*/
deleteAllData(): Promise<void>;
getOutOfBandMembers(roomId: string): Promise<IEvent[] | null>;
getOutOfBandMembers(roomId: string): Promise<IStateEventWithRoomId[] | null>;
setOutOfBandMembers(roomId: string, membershipEvents: IEvent[]): Promise<void>;
setOutOfBandMembers(roomId: string, membershipEvents: IStateEventWithRoomId[]): Promise<void>;
clearOutOfBandMembers(roomId: string): Promise<void>;

View File

@@ -15,7 +15,7 @@ limitations under the License.
*/
import { ISavedSync } from "./index";
import { IEvent, IStartClientOpts, ISyncResponse } from "..";
import { IEvent, IStartClientOpts, IStateEventWithRoomId, ISyncResponse } from "..";
export interface IIndexedDBBackend {
connect(): Promise<void>;
@@ -25,8 +25,8 @@ export interface IIndexedDBBackend {
getSavedSync(): Promise<ISavedSync>;
getNextBatchToken(): Promise<string>;
clearDatabase(): Promise<void>;
getOutOfBandMembers(roomId: string): Promise<IEvent[] | null>;
setOutOfBandMembers(roomId: string, membershipEvents: IEvent[]): Promise<void>;
getOutOfBandMembers(roomId: string): Promise<IStateEventWithRoomId[] | null>;
setOutOfBandMembers(roomId: string, membershipEvents: IStateEventWithRoomId[]): Promise<void>;
clearOutOfBandMembers(roomId: string): Promise<void>;
getUserPresenceEvents(): Promise<UserTuple[]>;
getClientOptions(): Promise<IStartClientOpts>;

View File

@@ -18,7 +18,7 @@ import { IMinimalEvent, ISyncData, ISyncResponse, SyncAccumulator } from "../syn
import * as utils from "../utils";
import * as IndexedDBHelpers from "../indexeddb-helpers";
import { logger } from '../logger';
import { IEvent, IStartClientOpts } from "..";
import { IStartClientOpts, IStateEventWithRoomId } from "..";
import { ISavedSync } from "./index";
import { IIndexedDBBackend, UserTuple } from "./indexeddb-backend";
@@ -127,6 +127,8 @@ export class LocalIndexedDBStoreBackend implements IIndexedDBBackend {
private db: IDBDatabase = null;
private disconnected = true;
private _isNewlyCreated = false;
private isPersisting = false;
private pendingUserPresenceData: UserTuple[] = [];
/**
* Does the actual reading from and writing to the indexeddb
@@ -215,7 +217,6 @@ export class LocalIndexedDBStoreBackend implements IIndexedDBBackend {
this.syncAccumulator.accumulate({
next_batch: syncData.nextBatch,
rooms: syncData.roomsData,
groups: syncData.groupsData,
account_data: {
events: accountData,
},
@@ -230,15 +231,15 @@ export class LocalIndexedDBStoreBackend implements IIndexedDBBackend {
* @returns {Promise<event[]>} the events, potentially an empty array if OOB loading didn't yield any new members
* @returns {null} in case the members for this room haven't been stored yet
*/
public getOutOfBandMembers(roomId: string): Promise<IEvent[] | null> {
return new Promise<IEvent[] | null>((resolve, reject) => {
public getOutOfBandMembers(roomId: string): Promise<IStateEventWithRoomId[] | null> {
return new Promise<IStateEventWithRoomId[] | null>((resolve, reject) => {
const tx = this.db.transaction(["oob_membership_events"], "readonly");
const store = tx.objectStore("oob_membership_events");
const roomIndex = store.index("room");
const range = IDBKeyRange.only(roomId);
const request = roomIndex.openCursor(range);
const membershipEvents: IEvent[] = [];
const membershipEvents: IStateEventWithRoomId[] = [];
// did we encounter the oob_written marker object
// amongst the results? That means OOB member
// loading already happened for this room
@@ -279,7 +280,7 @@ export class LocalIndexedDBStoreBackend implements IIndexedDBBackend {
* @param {string} roomId
* @param {event[]} membershipEvents the membership events to store
*/
public async setOutOfBandMembers(roomId: string, membershipEvents: IEvent[]): Promise<void> {
public async setOutOfBandMembers(roomId: string, membershipEvents: IStateEventWithRoomId[]): Promise<void> {
logger.log(`LL: backend about to store ${membershipEvents.length}` +
` members for ${roomId}`);
const tx = this.db.transaction(["oob_membership_events"], "readwrite");
@@ -402,24 +403,35 @@ export class LocalIndexedDBStoreBackend implements IIndexedDBBackend {
public async syncToDatabase(userTuples: UserTuple[]): Promise<void> {
const syncData = this.syncAccumulator.getJSON(true);
if (this.isPersisting) {
logger.warn("Skipping syncToDatabase() as persist already in flight");
this.pendingUserPresenceData.push(...userTuples);
return;
} else {
userTuples.unshift(...this.pendingUserPresenceData);
this.isPersisting = true;
}
try {
await Promise.all([
this.persistUserPresenceEvents(userTuples),
this.persistAccountData(syncData.accountData),
this.persistSyncData(syncData.nextBatch, syncData.roomsData, syncData.groupsData),
this.persistSyncData(syncData.nextBatch, syncData.roomsData),
]);
} finally {
this.isPersisting = false;
}
}
/**
* Persist rooms /sync data along with the next batch token.
* @param {string} nextBatch The next_batch /sync value.
* @param {Object} roomsData The 'rooms' /sync data from a SyncAccumulator
* @param {Object} groupsData The 'groups' /sync data from a SyncAccumulator
* @return {Promise} Resolves if the data was persisted.
*/
private persistSyncData(
nextBatch: string,
roomsData: ISyncResponse["rooms"],
groupsData: ISyncResponse["groups"],
): Promise<void> {
logger.log("Persisting sync data up to", nextBatch);
return utils.promiseTry<void>(() => {
@@ -429,9 +441,10 @@ export class LocalIndexedDBStoreBackend implements IIndexedDBBackend {
clobber: "-", // constant key so will always clobber
nextBatch,
roomsData,
groupsData,
}); // put == UPSERT
return txnAsPromise(txn).then();
return txnAsPromise(txn).then(() => {
logger.log("Persisted sync data up to", nextBatch);
});
});
}
@@ -534,9 +547,7 @@ export class LocalIndexedDBStoreBackend implements IIndexedDBBackend {
const txn = this.db.transaction(["client_options"], "readonly");
const store = txn.objectStore("client_options");
return selectQuery(store, undefined, (cursor) => {
if (cursor.value && cursor.value && cursor.value.options) {
return cursor.value.options;
}
return cursor.value?.options;
}).then((results) => results[0]);
});
}

View File

@@ -18,7 +18,7 @@ import { logger } from "../logger";
import { defer, IDeferred } from "../utils";
import { ISavedSync } from "./index";
import { IStartClientOpts } from "../client";
import { IEvent, ISyncResponse } from "..";
import { IStateEventWithRoomId, ISyncResponse } from "..";
import { IIndexedDBBackend, UserTuple } from "./indexeddb-backend";
export class RemoteIndexedDBStoreBackend implements IIndexedDBBackend {
@@ -97,7 +97,7 @@ export class RemoteIndexedDBStoreBackend implements IIndexedDBBackend {
* @returns {event[]} the events, potentially an empty array if OOB loading didn't yield any new members
* @returns {null} in case the members for this room haven't been stored yet
*/
public getOutOfBandMembers(roomId: string): Promise<IEvent[] | null> {
public getOutOfBandMembers(roomId: string): Promise<IStateEventWithRoomId[] | null> {
return this.doCmd('getOutOfBandMembers', [roomId]);
}
@@ -109,7 +109,7 @@ export class RemoteIndexedDBStoreBackend implements IIndexedDBBackend {
* @param {event[]} membershipEvents the membership events to store
* @returns {Promise} when all members have been stored
*/
public setOutOfBandMembers(roomId: string, membershipEvents: IEvent[]): Promise<void> {
public setOutOfBandMembers(roomId: string, membershipEvents: IStateEventWithRoomId[]): Promise<void> {
return this.doCmd('setOutOfBandMembers', [roomId, membershipEvents]);
}

View File

@@ -26,6 +26,7 @@ import { ISavedSync } from "./index";
import { IIndexedDBBackend } from "./indexeddb-backend";
import { ISyncResponse } from "../sync-accumulator";
import { TypedEventEmitter } from "../models/typed-event-emitter";
import { IStateEventWithRoomId } from "../@types/search";
/**
* This is an internal module. See {@link IndexedDBStore} for the public class.
@@ -242,7 +243,7 @@ export class IndexedDBStore extends MemoryStore {
* @returns {event[]} the events, potentially an empty array if OOB loading didn't yield any new members
* @returns {null} in case the members for this room haven't been stored yet
*/
public getOutOfBandMembers = this.degradable((roomId: string): Promise<IEvent[]> => {
public getOutOfBandMembers = this.degradable((roomId: string): Promise<IStateEventWithRoomId[]> => {
return this.backend.getOutOfBandMembers(roomId);
}, "getOutOfBandMembers");
@@ -254,10 +255,13 @@ export class IndexedDBStore extends MemoryStore {
* @param {event[]} membershipEvents the membership events to store
* @returns {Promise} when all members have been stored
*/
public setOutOfBandMembers = this.degradable((roomId: string, membershipEvents: IEvent[]): Promise<void> => {
public setOutOfBandMembers = this.degradable(
(roomId: string, membershipEvents: IStateEventWithRoomId[]): Promise<void> => {
super.setOutOfBandMembers(roomId, membershipEvents);
return this.backend.setOutOfBandMembers(roomId, membershipEvents);
}, "setOutOfBandMembers");
},
"setOutOfBandMembers",
);
public clearOutOfBandMembers = this.degradable((roomId: string) => {
super.clearOutOfBandMembers(roomId);
@@ -293,7 +297,7 @@ export class IndexedDBStore extends MemoryStore {
return async (...args) => {
try {
return func.call(this, ...args);
return await func.call(this, ...args);
} catch (e) {
logger.error("IndexedDBStore failure, degrading to MemoryStore", e);
this.emitter.emit("degraded", e);

View File

@@ -20,16 +20,16 @@ limitations under the License.
*/
import { EventType } from "../@types/event";
import { Group } from "../models/group";
import { Room } from "../models/room";
import { User } from "../models/user";
import { IEvent, MatrixEvent } from "../models/event";
import { MatrixEvent } from "../models/event";
import { RoomState, RoomStateEvent } from "../models/room-state";
import { RoomMember } from "../models/room-member";
import { Filter } from "../filter";
import { ISavedSync, IStore } from "./index";
import { RoomSummary } from "../models/room-summary";
import { ISyncResponse } from "../sync-accumulator";
import { IStateEventWithRoomId } from "../@types/search";
function isValidFilterId(filterId: string): boolean {
const isValidStr = typeof filterId === "string" &&
@@ -53,7 +53,6 @@ export interface IOpts {
*/
export class MemoryStore implements IStore {
private rooms: Record<string, Room> = {}; // roomId: Room
private groups: Record<string, Group> = {}; // groupId: Group
private users: Record<string, User> = {}; // userId: User
private syncToken: string = null;
// userId: {
@@ -62,7 +61,7 @@ export class MemoryStore implements IStore {
private filters: Record<string, Record<string, Filter>> = {};
public accountData: Record<string, MatrixEvent> = {}; // type : content
private readonly localStorage: Storage;
private oobMembers: Record<string, IEvent[]> = {}; // roomId: [member events]
private oobMembers: Record<string, IStateEventWithRoomId[]> = {}; // roomId: [member events]
private clientOptions = {};
constructor(opts: IOpts = {}) {
@@ -90,34 +89,6 @@ export class MemoryStore implements IStore {
this.syncToken = token;
}
/**
* Store the given room.
* @param {Group} group The group to be stored
* @deprecated groups/communities never made it to the spec and support for them is being discontinued.
*/
public storeGroup(group: Group) {
this.groups[group.groupId] = group;
}
/**
* Retrieve a group by its group ID.
* @param {string} groupId The group ID.
* @return {Group} The group or null.
* @deprecated groups/communities never made it to the spec and support for them is being discontinued.
*/
public getGroup(groupId: string): Group | null {
return this.groups[groupId] || null;
}
/**
* Retrieve all known groups.
* @return {Group[]} A list of groups, which may be empty.
* @deprecated groups/communities never made it to the spec and support for them is being discontinued.
*/
public getGroups(): Group[] {
return Object.values(this.groups);
}
/**
* Store the given room.
* @param {Room} room The room to be stored. All properties must be stored.
@@ -228,7 +199,7 @@ export class MemoryStore implements IStore {
/**
* Retrieve scrollback for this room.
* @param {Room} room The matrix room
* @param {integer} limit The max number of old events to retrieve.
* @param {number} limit The max number of old events to retrieve.
* @return {Array<Object>} An array of objects which will be at most 'limit'
* length and at least 0. The objects are the raw event JSON.
*/
@@ -419,7 +390,7 @@ export class MemoryStore implements IStore {
* @returns {event[]} the events, potentially an empty array if OOB loading didn't yield any new members
* @returns {null} in case the members for this room haven't been stored yet
*/
public getOutOfBandMembers(roomId: string): Promise<IEvent[] | null> {
public getOutOfBandMembers(roomId: string): Promise<IStateEventWithRoomId[] | null> {
return Promise.resolve(this.oobMembers[roomId] || null);
}
@@ -431,7 +402,7 @@ export class MemoryStore implements IStore {
* @param {event[]} membershipEvents the membership events to store
* @returns {Promise} when all members have been stored
*/
public setOutOfBandMembers(roomId: string, membershipEvents: IEvent[]): Promise<void> {
public setOutOfBandMembers(roomId: string, membershipEvents: IStateEventWithRoomId[]): Promise<void> {
this.oobMembers[roomId] = membershipEvents;
return Promise.resolve();
}

View File

@@ -78,7 +78,7 @@ WebStorageSessionStore.prototype = {
const devices = {};
for (let i = 0; i < this.store.length; ++i) {
const key = this.store.key(i);
const userId = key.substr(prefix.length);
const userId = key.slice(prefix.length);
if (key.startsWith(prefix)) devices[userId] = getJsonItem(this.store, key);
}
return devices;
@@ -125,7 +125,7 @@ WebStorageSessionStore.prototype = {
const deviceKeys = getKeysWithPrefix(this.store, keyEndToEndSessions(''));
const results = {};
for (const k of deviceKeys) {
const unprefixedKey = k.substr(keyEndToEndSessions('').length);
const unprefixedKey = k.slice(keyEndToEndSessions('').length);
results[unprefixedKey] = getJsonItem(this.store, k);
}
return results;
@@ -158,8 +158,8 @@ WebStorageSessionStore.prototype = {
// (hence 43 characters long).
result.push({
senderKey: key.substr(prefix.length, 43),
sessionId: key.substr(prefix.length + 44),
senderKey: key.slice(prefix.length, prefix.length + 43),
sessionId: key.slice(prefix.length + 44),
});
}
return result;
@@ -182,7 +182,7 @@ WebStorageSessionStore.prototype = {
const roomKeys = getKeysWithPrefix(this.store, keyEndToEndRoom(''));
const results = {};
for (const k of roomKeys) {
const unprefixedKey = k.substr(keyEndToEndRoom('').length);
const unprefixedKey = k.slice(keyEndToEndRoom('').length);
results[unprefixedKey] = getJsonItem(this.store, k);
}
return results;

View File

@@ -20,14 +20,14 @@ limitations under the License.
*/
import { EventType } from "../@types/event";
import { Group } from "../models/group";
import { Room } from "../models/room";
import { User } from "../models/user";
import { IEvent, MatrixEvent } from "../models/event";
import { MatrixEvent } from "../models/event";
import { Filter } from "../filter";
import { ISavedSync, IStore } from "./index";
import { RoomSummary } from "../models/room-summary";
import { ISyncResponse } from "../sync-accumulator";
import { IStateEventWithRoomId } from "../@types/search";
/**
* Construct a stub store. This does no-ops on most store methods.
@@ -58,32 +58,6 @@ export class StubStore implements IStore {
this.fromToken = token;
}
/**
* No-op.
* @param {Group} group
* @deprecated groups/communities never made it to the spec and support for them is being discontinued.
*/
public storeGroup(group: Group) {}
/**
* No-op.
* @param {string} groupId
* @return {null}
* @deprecated groups/communities never made it to the spec and support for them is being discontinued.
*/
public getGroup(groupId: string): Group | null {
return null;
}
/**
* No-op.
* @return {Array} An empty array.
* @deprecated groups/communities never made it to the spec and support for them is being discontinued.
*/
public getGroups(): Group[] {
return [];
}
/**
* No-op.
* @param {Room} room
@@ -149,7 +123,7 @@ export class StubStore implements IStore {
/**
* No-op.
* @param {Room} room
* @param {integer} limit
* @param {number} limit
* @return {Array}
*/
public scrollback(room: Room, limit: number): MatrixEvent[] {
@@ -269,11 +243,11 @@ export class StubStore implements IStore {
return Promise.resolve();
}
public getOutOfBandMembers(): Promise<IEvent[]> {
public getOutOfBandMembers(): Promise<IStateEventWithRoomId[]> {
return Promise.resolve(null);
}
public setOutOfBandMembers(roomId: string, membershipEvents: IEvent[]): Promise<void> {
public setOutOfBandMembers(roomId: string, membershipEvents: IStateEventWithRoomId[]): Promise<void> {
return Promise.resolve();
}

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