1
0
mirror of https://github.com/matrix-org/matrix-js-sdk.git synced 2025-11-26 17:03:12 +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_style = space
indent_size = 4 indent_size = 4
trim_trailing_whitespace = true 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) 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 * [BREAKING] Refactor the entire build process
[\#1113](https://github.com/matrix-org/matrix-js-sdk/pull/1113) [\#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) 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) [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 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 Matrix Javascript SDK
===================== =====================
@@ -26,7 +34,7 @@ In Node.js
Ensure you have the latest LTS version of Node.js installed. 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 a bundler like webpack you'll likely have to transpile dependencies, including this
SDK, to match your target browsers. 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 [libolm](https://gitlab.matrix.org/matrix-org/olm). It is left up to the
application to make libolm available, via the ``Olm`` global. 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 ``MatrixClient`` (but **before** calling ``matrixClient.startClient()``) to
initialise the crypto layer. initialise the crypto layer.

View File

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

View File

@@ -1,7 +1,10 @@
{ {
"name": "matrix-js-sdk", "name": "matrix-js-sdk",
"version": "16.0.0", "version": "17.2.0",
"description": "Matrix Client-Server SDK for Javascript", "description": "Matrix Client-Server SDK for Javascript",
"engines": {
"node": ">=12.9.0"
},
"scripts": { "scripts": {
"prepublishOnly": "yarn build", "prepublishOnly": "yarn build",
"start": "echo THIS IS FOR LEGACY PURPOSES ONLY. && babel src -w -s -d lib --verbose --extensions \".ts,.js\"", "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", "fake-indexeddb": "^3.1.2",
"jest": "^26.6.3", "jest": "^26.6.3",
"jest-localstorage-mock": "^2.4.6", "jest-localstorage-mock": "^2.4.6",
"jest-sonar-reporter": "^2.0.0",
"jsdoc": "^3.6.6", "jsdoc": "^3.6.6",
"matrix-mock-request": "^1.2.3", "matrix-mock-request": "^1.2.3",
"rimraf": "^3.0.2", "rimraf": "^3.0.2",
@@ -113,8 +117,13 @@
"<rootDir>/src/**/*.{js,ts}" "<rootDir>/src/**/*.{js,ts}"
], ],
"coverageReporters": [ "coverageReporters": [
"text", "text-summary",
"json" "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(); httpBackend.stop();
}); });
it("Sync", async function() { it("Sync", function() {
const event = utils.mkMembership({ const event = utils.mkMembership({
room: ROOM_ID, room: ROOM_ID,
mship: "join", mship: "join",
@@ -71,7 +71,7 @@ describe("Browserify Test", function() {
}; };
httpBackend.when("GET", "/sync").respond(200, syncData); httpBackend.when("GET", "/sync").respond(200, syncData);
return await Promise.race([ return Promise.race([
httpBackend.flushAllExpected(), httpBackend.flushAllExpected(),
new Promise((_, reject) => { new Promise((_, reject) => {
client.once("sync.unexpectedError", 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 { EventTimeline } from "../../src/matrix";
import { logger } from "../../src/logger"; import { logger } from "../../src/logger";
import { TestClient } from "../TestClient"; import { TestClient } from "../TestClient";
import { Thread, THREAD_RELATION_TYPE } from "../../src/models/thread";
const userId = "@alice:localhost"; const userId = "@alice:localhost";
const userName = "Alice"; 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 // start the client, and wait for it to initialise
function startClient(httpBackend, client) { function startClient(httpBackend, client) {
httpBackend.when("GET", "/versions").respond(200, {}); httpBackend.when("GET", "/versions").respond(200, {});
@@ -116,9 +138,7 @@ describe("getEventTimeline support", function() {
return startClient(httpBackend, client).then(function() { return startClient(httpBackend, client).then(function() {
const room = client.getRoom(roomId); const room = client.getRoom(roomId);
const timelineSet = room.getTimelineSets()[0]; const timelineSet = room.getTimelineSets()[0];
expect(function() { expect(client.getEventTimeline(timelineSet, "event")).rejects.toBeTruthy();
client.getEventTimeline(timelineSet, "event");
}).toThrow();
}); });
}); });
@@ -136,16 +156,12 @@ describe("getEventTimeline support", function() {
return startClient(httpBackend, client).then(() => { return startClient(httpBackend, client).then(() => {
const room = client.getRoom(roomId); const room = client.getRoom(roomId);
const timelineSet = room.getTimelineSets()[0]; const timelineSet = room.getTimelineSets()[0];
expect(function() { expect(client.getEventTimeline(timelineSet, "event")).rejects.toBeFalsy();
client.getEventTimeline(timelineSet, "event");
}).not.toThrow();
}); });
}); });
it("scrollback should be able to scroll back to before a gappy /sync", it("scrollback should be able to scroll back to before a gappy /sync", function() {
function() {
// need a client with timelineSupport disabled to make this work // need a client with timelineSupport disabled to make this work
let room; let room;
return startClient(httpBackend, client).then(function() { return startClient(httpBackend, client).then(function() {
@@ -229,6 +245,7 @@ describe("MatrixClient event timelines", function() {
afterEach(function() { afterEach(function() {
httpBackend.verifyNoOutstandingExpectation(); httpBackend.verifyNoOutstandingExpectation();
client.stopClient(); client.stopClient();
Thread.setServerSideSupport(false);
}); });
describe("getEventTimeline", function() { describe("getEventTimeline", function() {
@@ -355,8 +372,7 @@ describe("MatrixClient event timelines", function() {
]); ]);
}); });
it("should join timelines where they overlap a previous /context", it("should join timelines where they overlap a previous /context", function() {
function() {
const room = client.getRoom(roomId); const room = client.getRoom(roomId);
const timelineSet = room.getTimelineSets()[0]; const timelineSet = room.getTimelineSets()[0];
@@ -478,6 +494,50 @@ describe("MatrixClient event timelines", function() {
httpBackend.flushAllExpected(), 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() { 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 * as utils from "../test-utils/test-utils";
import { CRYPTO_ENABLED } from "../../src/client"; import { CRYPTO_ENABLED } from "../../src/client";
import { MatrixEvent } from "../../src/models/event"; import { MatrixEvent } from "../../src/models/event";
import { Filter, MemoryStore, Room } from "../../src/matrix"; import { Filter, MemoryStore, Room } from "../../src/matrix";
import { TestClient } from "../TestClient"; import { TestClient } from "../TestClient";
import { THREAD_RELATION_TYPE } from "../../src/models/thread";
describe("MatrixClient", function() { describe("MatrixClient", function() {
let client = null; let client = null;
@@ -14,9 +31,7 @@ describe("MatrixClient", function() {
beforeEach(function() { beforeEach(function() {
store = new MemoryStore(); store = new MemoryStore();
const testClient = new TestClient(userId, "aliceDevice", accessToken, undefined, { const testClient = new TestClient(userId, "aliceDevice", accessToken, undefined, { store });
store: store,
});
httpBackend = testClient.httpBackend; httpBackend = testClient.httpBackend;
client = testClient.client; client = testClient.client;
}); });
@@ -146,12 +161,14 @@ describe("MatrixClient", function() {
describe("joinRoom", function() { describe("joinRoom", function() {
it("should no-op if you've already joined a room", function() { it("should no-op if you've already joined a room", function() {
const roomId = "!foo:bar"; const roomId = "!foo:bar";
const room = new Room(roomId, userId); const room = new Room(roomId, client, userId);
client.fetchRoomEvent = () => Promise.resolve({});
room.addLiveEvents([ room.addLiveEvents([
utils.mkMembership({ utils.mkMembership({
user: userId, room: roomId, mship: "join", event: true, user: userId, room: roomId, mship: "join", event: true,
}), }),
]); ]);
httpBackend.verifyNoOutstandingRequests();
store.storeRoom(room); store.storeRoom(room);
client.joinRoom(roomId); client.joinRoom(roomId);
httpBackend.verifyNoOutstandingRequests(); httpBackend.verifyNoOutstandingRequests();
@@ -244,14 +261,15 @@ describe("MatrixClient", function() {
}); });
describe("searching", function() { describe("searching", function() {
it("searchMessageText should perform a /search for room_events", function() {
const response = { const response = {
search_categories: { search_categories: {
room_events: { room_events: {
count: 24, count: 24,
results: { results: [{
"$flibble:localhost": {
rank: 0.1, rank: 0.1,
result: { result: {
event_id: "$flibble:localhost",
type: "m.room.message", type: "m.room.message",
user_id: "@alice:localhost", user_id: "@alice:localhost",
room_id: "!feuiwhf:localhost", room_id: "!feuiwhf:localhost",
@@ -260,13 +278,11 @@ describe("MatrixClient", function() {
msgtype: "m.text", msgtype: "m.text",
}, },
}, },
}, }],
},
}, },
}, },
}; };
it("searchMessageText should perform a /search for room_events", function(done) {
client.searchMessageText({ client.searchMessageText({
query: "monkeys", query: "monkeys",
}); });
@@ -280,8 +296,171 @@ describe("MatrixClient", function() {
}); });
}).respond(200, response); }).respond(200, response);
httpBackend.flush().then(function() { return httpBackend.flush();
done(); });
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() { describe("partitionThreadedEvents", function() {
let room;
beforeEach(() => {
room = new Room("!STrMRsukXHtqQdSeHa:matrix.org", client, userId);
});
it("returns empty arrays when given an empty arrays", function() { it("returns empty arrays when given an empty arrays", function() {
const events = []; const events = [];
const [timeline, threaded] = client.partitionThreadedEvents(events); const [timeline, threaded] = room.partitionThreadedEvents(events);
expect(timeline).toEqual([]); expect(timeline).toEqual([]);
expect(threaded).toEqual([]); expect(threaded).toEqual([]);
}); });
@@ -405,24 +589,24 @@ describe("MatrixClient", function() {
it("copies pre-thread in-timeline vote events onto both timelines", function() { it("copies pre-thread in-timeline vote events onto both timelines", function() {
client.clientOpts = { experimentalThreadSupport: true }; client.clientOpts = { experimentalThreadSupport: true };
const eventMessageInThread = buildEventMessageInThread();
const eventPollResponseReference = buildEventPollResponseReference(); const eventPollResponseReference = buildEventPollResponseReference();
const eventPollStartThreadRoot = buildEventPollStartThreadRoot(); const eventPollStartThreadRoot = buildEventPollStartThreadRoot();
const eventMessageInThread = buildEventMessageInThread(eventPollStartThreadRoot);
const events = [ const events = [
eventPollStartThreadRoot,
eventMessageInThread, eventMessageInThread,
eventPollResponseReference, eventPollResponseReference,
eventPollStartThreadRoot,
]; ];
// Vote has no threadId yet // Vote has no threadId yet
expect(eventPollResponseReference.threadId).toBeFalsy(); expect(eventPollResponseReference.threadId).toBeFalsy();
const [timeline, threaded] = client.partitionThreadedEvents(events); const [timeline, threaded] = room.partitionThreadedEvents(events);
expect(timeline).toEqual([ expect(timeline).toEqual([
// The message that was sent in a thread is missing // The message that was sent in a thread is missing
eventPollResponseReference,
eventPollStartThreadRoot, eventPollStartThreadRoot,
eventPollResponseReference,
]); ]);
// The vote event has been copied into the thread // The vote event has been copied into the thread
@@ -431,33 +615,34 @@ describe("MatrixClient", function() {
expect(eventRefWithThreadId.threadId).toBeTruthy(); expect(eventRefWithThreadId.threadId).toBeTruthy();
expect(threaded).toEqual([ expect(threaded).toEqual([
eventPollStartThreadRoot,
eventMessageInThread, eventMessageInThread,
eventRefWithThreadId, eventRefWithThreadId,
// Thread does not see thread root
]); ]);
}); });
it("copies pre-thread in-timeline reactions onto both timelines", function() { it("copies pre-thread in-timeline reactions onto both timelines", function() {
client.clientOpts = { experimentalThreadSupport: true }; client.clientOpts = { experimentalThreadSupport: true };
const eventMessageInThread = buildEventMessageInThread();
const eventReaction = buildEventReaction();
const eventPollStartThreadRoot = buildEventPollStartThreadRoot(); const eventPollStartThreadRoot = buildEventPollStartThreadRoot();
const eventMessageInThread = buildEventMessageInThread(eventPollStartThreadRoot);
const eventReaction = buildEventReaction(eventPollStartThreadRoot);
const events = [ const events = [
eventPollStartThreadRoot,
eventMessageInThread, eventMessageInThread,
eventReaction, eventReaction,
eventPollStartThreadRoot,
]; ];
const [timeline, threaded] = client.partitionThreadedEvents(events); const [timeline, threaded] = room.partitionThreadedEvents(events);
expect(timeline).toEqual([ expect(timeline).toEqual([
eventReaction,
eventPollStartThreadRoot, eventPollStartThreadRoot,
eventReaction,
]); ]);
expect(threaded).toEqual([ expect(threaded).toEqual([
eventPollStartThreadRoot,
eventMessageInThread, eventMessageInThread,
withThreadId(eventReaction, eventPollStartThreadRoot.getId()), withThreadId(eventReaction, eventPollStartThreadRoot.getId()),
]); ]);
@@ -467,23 +652,24 @@ describe("MatrixClient", function() {
client.clientOpts = { experimentalThreadSupport: true }; client.clientOpts = { experimentalThreadSupport: true };
const eventPollResponseReference = buildEventPollResponseReference(); const eventPollResponseReference = buildEventPollResponseReference();
const eventMessageInThread = buildEventMessageInThread();
const eventPollStartThreadRoot = buildEventPollStartThreadRoot(); const eventPollStartThreadRoot = buildEventPollStartThreadRoot();
const eventMessageInThread = buildEventMessageInThread(eventPollStartThreadRoot);
const events = [ const events = [
eventPollStartThreadRoot,
eventPollResponseReference, eventPollResponseReference,
eventMessageInThread, eventMessageInThread,
eventPollStartThreadRoot,
]; ];
const [timeline, threaded] = client.partitionThreadedEvents(events); const [timeline, threaded] = room.partitionThreadedEvents(events);
expect(timeline).toEqual([ expect(timeline).toEqual([
eventPollResponseReference,
eventPollStartThreadRoot, eventPollStartThreadRoot,
eventPollResponseReference,
]); ]);
expect(threaded).toEqual([ expect(threaded).toEqual([
eventPollStartThreadRoot,
withThreadId(eventPollResponseReference, eventPollStartThreadRoot.getId()), withThreadId(eventPollResponseReference, eventPollStartThreadRoot.getId()),
eventMessageInThread, eventMessageInThread,
]); ]);
@@ -492,26 +678,27 @@ describe("MatrixClient", function() {
it("copies post-thread in-timeline reactions onto both timelines", function() { it("copies post-thread in-timeline reactions onto both timelines", function() {
client.clientOpts = { experimentalThreadSupport: true }; client.clientOpts = { experimentalThreadSupport: true };
const eventReaction = buildEventReaction();
const eventMessageInThread = buildEventMessageInThread();
const eventPollStartThreadRoot = buildEventPollStartThreadRoot(); const eventPollStartThreadRoot = buildEventPollStartThreadRoot();
const eventMessageInThread = buildEventMessageInThread(eventPollStartThreadRoot);
const eventReaction = buildEventReaction(eventPollStartThreadRoot);
const events = [ const events = [
eventReaction,
eventMessageInThread,
eventPollStartThreadRoot, eventPollStartThreadRoot,
eventMessageInThread,
eventReaction,
]; ];
const [timeline, threaded] = client.partitionThreadedEvents(events); const [timeline, threaded] = room.partitionThreadedEvents(events);
expect(timeline).toEqual([ expect(timeline).toEqual([
eventReaction,
eventPollStartThreadRoot, eventPollStartThreadRoot,
eventReaction,
]); ]);
expect(threaded).toEqual([ expect(threaded).toEqual([
withThreadId(eventReaction, eventPollStartThreadRoot.getId()), eventPollStartThreadRoot,
eventMessageInThread, eventMessageInThread,
withThreadId(eventReaction, eventPollStartThreadRoot.getId()),
]); ]);
}); });
@@ -519,9 +706,9 @@ describe("MatrixClient", function() {
client.clientOpts = { experimentalThreadSupport: true }; client.clientOpts = { experimentalThreadSupport: true };
// This is based on recording the events in a real room: // This is based on recording the events in a real room:
const eventMessageInThread = buildEventMessageInThread();
const eventPollResponseReference = buildEventPollResponseReference();
const eventPollStartThreadRoot = buildEventPollStartThreadRoot(); const eventPollStartThreadRoot = buildEventPollStartThreadRoot();
const eventPollResponseReference = buildEventPollResponseReference();
const eventMessageInThread = buildEventMessageInThread(eventPollStartThreadRoot);
const eventRoomName = buildEventRoomName(); const eventRoomName = buildEventRoomName();
const eventEncryption = buildEventEncryption(); const eventEncryption = buildEventEncryption();
const eventGuestAccess = buildEventGuestAccess(); const eventGuestAccess = buildEventGuestAccess();
@@ -532,9 +719,9 @@ describe("MatrixClient", function() {
const eventCreate = buildEventCreate(); const eventCreate = buildEventCreate();
const events = [ const events = [
eventMessageInThread,
eventPollResponseReference,
eventPollStartThreadRoot, eventPollStartThreadRoot,
eventPollResponseReference,
eventMessageInThread,
eventRoomName, eventRoomName,
eventEncryption, eventEncryption,
eventGuestAccess, eventGuestAccess,
@@ -544,12 +731,12 @@ describe("MatrixClient", function() {
eventMember, eventMember,
eventCreate, eventCreate,
]; ];
const [timeline, threaded] = client.partitionThreadedEvents(events); const [timeline, threaded] = room.partitionThreadedEvents(events);
expect(timeline).toEqual([ expect(timeline).toEqual([
// The message that was sent in a thread is missing // The message that was sent in a thread is missing
eventPollResponseReference,
eventPollStartThreadRoot, eventPollStartThreadRoot,
eventPollResponseReference,
eventRoomName, eventRoomName,
eventEncryption, eventEncryption,
eventGuestAccess, eventGuestAccess,
@@ -560,13 +747,262 @@ describe("MatrixClient", function() {
eventCreate, eventCreate,
]); ]);
// Thread should contain only stuff that happened in the thread - // Thread should contain only stuff that happened in the thread - no room state events
// no thread root, and no room state events
expect(threaded).toEqual([ expect(threaded).toEqual([
eventMessageInThread, eventPollStartThreadRoot,
withThreadId(eventPollResponseReference, eventPollStartThreadRoot.getId()), 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; return ret;
} }
const buildEventMessageInThread = () => new MatrixEvent({ const buildEventMessageInThread = (root) => new MatrixEvent({
"age": 80098509, "age": 80098509,
"content": { "content": {
"algorithm": "m.megolm.v1.aes-sha2", "algorithm": "m.megolm.v1.aes-sha2",
"ciphertext": "ENCRYPTEDSTUFF", "ciphertext": "ENCRYPTEDSTUFF",
"device_id": "XISFUZSKHH", "device_id": "XISFUZSKHH",
"m.relates_to": { "m.relates_to": {
"event_id": "$VLS2ojbPmxb6x8ECetn45hmND6cRDcjgv-j-to9m7Vo", "event_id": root.getId(),
"m.in_reply_to": { "m.in_reply_to": {
"event_id": "$VLS2ojbPmxb6x8ECetn45hmND6cRDcjgv-j-to9m7Vo", "event_id": root.getId(),
}, },
"rel_type": "m.thread", "rel_type": "m.thread",
}, },
@@ -623,10 +1059,10 @@ const buildEventPollResponseReference = () => new MatrixEvent({
"user_id": "@andybalaam-test1:matrix.org", "user_id": "@andybalaam-test1:matrix.org",
}); });
const buildEventReaction = () => new MatrixEvent({ const buildEventReaction = (event) => new MatrixEvent({
"content": { "content": {
"m.relates_to": { "m.relates_to": {
"event_id": "$VLS2ojbPmxb6x8ECetn45hmND6cRDcjgv-j-to9m7Vo", "event_id": event.getId(),
"key": "🤗", "key": "🤗",
"rel_type": "m.annotation", "rel_type": "m.annotation",
}, },
@@ -642,6 +1078,22 @@ const buildEventReaction = () => new MatrixEvent({
"room_id": "!STrMRsukXHtqQdSeHa:matrix.org", "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({ const buildEventPollStartThreadRoot = () => new MatrixEvent({
"age": 80108647, "age": 80108647,
"content": { "content": {
@@ -660,6 +1112,29 @@ const buildEventPollStartThreadRoot = () => new MatrixEvent({
"user_id": "@andybalaam-test1:matrix.org", "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({ const buildEventRoomName = () => new MatrixEvent({
"age": 80123249, "age": 80123249,
"content": { "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 * as utils from "../test-utils/test-utils";
import { TestClient } from "../TestClient"; import { TestClient } from "../TestClient";
@@ -60,6 +75,112 @@ describe("MatrixClient syncing", function() {
done(); 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() { describe("resolving invites to profile info", function() {
@@ -735,8 +856,7 @@ describe("MatrixClient syncing", function() {
expect(tok).toEqual("pagTok"); expect(tok).toEqual("pagTok");
}), }),
// first flush the filter request; this will make syncLeftRooms // first flush the filter request; this will make syncLeftRooms make its /sync call
// make its /sync call
httpBackend.flush("/filter").then(function() { httpBackend.flush("/filter").then(function() {
return httpBackend.flushAllExpected(); return httpBackend.flushAllExpected();
}), }),

View File

@@ -50,12 +50,14 @@ export const makeBeaconInfoEvent = (
...contentProps, ...contentProps,
}; };
const event = new MatrixEvent({ const event = new MatrixEvent({
type: `${M_BEACON_INFO.name}.${sender}`, type: M_BEACON_INFO.name,
room_id: roomId, room_id: roomId,
state_key: sender, state_key: sender,
content: makeBeaconInfoContent(timeout, isLive, description, assetType), content: makeBeaconInfoContent(timeout, isLive, description, assetType),
}); });
event.event.origin_server_ts = Date.now();
// live beacons use the beacon_info event id // live beacons use the beacon_info event id
// set or default this // set or default this
event.replaceLocalEventId(eventId || `$${Math.random()}-${Math.random()}`); 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 { 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 { LocationAssetType, M_ASSET, M_LOCATION, M_TIMESTAMP } from "../../src/@types/location";
import { makeBeaconContent, makeBeaconInfoContent } from "../../src/content-helpers"; import { M_TOPIC } from "../../src/@types/topic";
import {
makeBeaconContent,
makeBeaconInfoContent,
makeTopicContent,
parseTopicContent,
} from "../../src/content-helpers";
describe('Beacon content helpers', () => { describe('Beacon content helpers', () => {
describe('makeBeaconInfoContent()', () => { describe('makeBeaconInfoContent()', () => {
@@ -36,11 +41,9 @@ describe('Beacon content helpers', () => {
'nice beacon_info', 'nice beacon_info',
LocationAssetType.Pin, LocationAssetType.Pin,
)).toEqual({ )).toEqual({
[M_BEACON_INFO.name]: {
description: 'nice beacon_info', description: 'nice beacon_info',
timeout: 1234, timeout: 1234,
live: true, live: true,
},
[M_TIMESTAMP.name]: mockDateNow, [M_TIMESTAMP.name]: mockDateNow,
[M_ASSET.name]: { [M_ASSET.name]: {
type: LocationAssetType.Pin, 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 { sleep } from "../../src/utils";
import { CRYPTO_ENABLED } from "../../src/client"; import { CRYPTO_ENABLED } from "../../src/client";
import { DeviceInfo } from "../../src/crypto/deviceinfo"; import { DeviceInfo } from "../../src/crypto/deviceinfo";
import { logger } from '../../src/logger';
const Olm = global.Olm; const Olm = global.Olm;
@@ -400,4 +401,28 @@ describe("Crypto", function() {
expect(aliceClient.sendToDevice.mock.calls[2][2]).not.toBe(txnId); 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 * as olmlib from "../../../src/crypto/olmlib";
import { TestClient } from '../../TestClient'; import { TestClient } from '../../TestClient';
import { HttpResponse, setHttpResponses } from '../../test-utils/test-utils';
import { resetCrossSigningKeys } from "./crypto-utils"; import { resetCrossSigningKeys } from "./crypto-utils";
import { MatrixError } from '../../../src/http-api'; import { MatrixError } from '../../../src/http-api';
import { logger } from '../../../src/logger'; import { logger } from '../../../src/logger';
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) { async function makeTestClient(userInfo, options, keys) {
if (!keys) keys = {}; if (!keys) keys = {};
@@ -237,7 +259,7 @@ describe("Cross Signing", function() {
// feed sync result that includes master key, ssk, device key // feed sync result that includes master key, ssk, device key
const responses = [ const responses = [
HttpResponse.PUSH_RULES_RESPONSE, PUSH_RULES_RESPONSE,
{ {
method: "POST", method: "POST",
path: "/keys/upload", path: "/keys/upload",
@@ -248,7 +270,7 @@ describe("Cross Signing", function() {
}, },
}, },
}, },
HttpResponse.filterResponse("@alice:example.com"), filterResponse("@alice:example.com"),
{ {
method: "GET", method: "GET",
path: "/sync", path: "/sync",
@@ -493,7 +515,7 @@ describe("Cross Signing", function() {
// - master key signed by her usk (pretend that it was signed by another // - master key signed by her usk (pretend that it was signed by another
// of Alice's devices) // of Alice's devices)
const responses = [ const responses = [
HttpResponse.PUSH_RULES_RESPONSE, PUSH_RULES_RESPONSE,
{ {
method: "POST", method: "POST",
path: "/keys/upload", path: "/keys/upload",
@@ -504,7 +526,7 @@ describe("Cross Signing", function() {
}, },
}, },
}, },
HttpResponse.filterResponse("@alice:example.com"), filterResponse("@alice:example.com"),
{ {
method: "GET", method: "GET",
path: "/sync", path: "/sync",
@@ -861,4 +883,138 @@ describe("Cross Signing", function() {
expect(bobTrust3.isCrossSigningVerified()).toBeTruthy(); expect(bobTrust3.isCrossSigningVerified()).toBeTruthy();
expect(bobTrust3.isTofu()).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 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"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.

View File

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

View File

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

View File

@@ -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 { logger } from "../../src/logger";
import { MatrixClient } from "../../src/client"; import { MatrixClient } from "../../src/client";
import { Filter } from "../../src/filter"; import { Filter } from "../../src/filter";
@@ -13,9 +29,12 @@ import {
import { MEGOLM_ALGORITHM } from "../../src/crypto/olmlib"; import { MEGOLM_ALGORITHM } from "../../src/crypto/olmlib";
import { EventStatus, MatrixEvent } from "../../src/models/event"; import { EventStatus, MatrixEvent } from "../../src/models/event";
import { Preset } from "../../src/@types/partials"; import { Preset } from "../../src/@types/partials";
import { ReceiptType } from "../../src/@types/read_receipts";
import * as testUtils from "../test-utils/test-utils"; import * as testUtils from "../test-utils/test-utils";
import { makeBeaconInfoContent } from "../../src/content-helpers"; import { makeBeaconInfoContent } from "../../src/content-helpers";
import { M_BEACON_INFO } from "../../src/@types/beacon"; import { M_BEACON_INFO } from "../../src/@types/beacon";
import { ContentHelpers, Room } from "../../src";
import { makeBeaconEvent } from "../test-utils/beacon";
jest.useFakeTimers(); jest.useFakeTimers();
@@ -72,7 +91,12 @@ describe("MatrixClient", function() {
let pendingLookup = null; let pendingLookup = null;
function httpReq(cb, method, path, qp, data, prefix) { function httpReq(cb, method, path, qp, data, prefix) {
if (path === KEEP_ALIVE_PATH && acceptKeepalives) { if (path === KEEP_ALIVE_PATH && acceptKeepalives) {
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 next = httpLookups.shift();
const logLine = ( const logLine = (
@@ -793,11 +817,12 @@ describe("MatrixClient", function() {
} }
}, },
}, },
threads: { getThread: jest.fn(),
get: jest.fn(),
},
addPendingEvent: jest.fn(), addPendingEvent: jest.fn(),
updatePendingEvent: jest.fn(), updatePendingEvent: jest.fn(),
reEmitter: {
reEmit: jest.fn(),
},
}; };
beforeEach(() => { beforeEach(() => {
@@ -941,6 +966,7 @@ describe("MatrixClient", function() {
it("partitions root events to room timeline and thread timeline", () => { it("partitions root events to room timeline and thread timeline", () => {
const supportsExperimentalThreads = client.supportsExperimentalThreads; const supportsExperimentalThreads = client.supportsExperimentalThreads;
client.supportsExperimentalThreads = () => true; client.supportsExperimentalThreads = () => true;
const room = new Room("!room1:matrix.org", client, userId);
const rootEvent = new MatrixEvent({ const rootEvent = new MatrixEvent({
"content": {}, "content": {},
@@ -963,15 +989,55 @@ describe("MatrixClient", function() {
expect(rootEvent.isThreadRoot).toBe(true); expect(rootEvent.isThreadRoot).toBe(true);
const [room, threads] = client.partitionThreadedEvents([rootEvent]); const [roomEvents, threadEvents] = room.partitionThreadedEvents([rootEvent]);
expect(room).toHaveLength(1); expect(roomEvents).toHaveLength(1);
expect(threads).toHaveLength(1); expect(threadEvents).toHaveLength(1);
// Restore method // Restore method
client.supportsExperimentalThreads = supportsExperimentalThreads; 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", () => { describe("beacons", () => {
const roomId = '!room:server.org'; const roomId = '!room:server.org';
const content = makeBeaconInfoContent(100, true); const content = makeBeaconInfoContent(100, true);
@@ -981,10 +1047,10 @@ describe("MatrixClient", function() {
}); });
it("creates new beacon info", async () => { it("creates new beacon info", async () => {
await client.unstable_createLiveBeacon(roomId, content, '123'); await client.unstable_createLiveBeacon(roomId, content);
// event type combined // 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]; const [callback, method, path, queryParams, requestContent] = client.http.authedRequest.mock.calls[0];
expect(callback).toBeFalsy(); expect(callback).toBeFalsy();
expect(method).toBe('PUT'); expect(method).toBe('PUT');
@@ -997,17 +1063,132 @@ describe("MatrixClient", function() {
}); });
it("updates beacon info with specific event type", async () => { it("updates beacon info with specific event type", async () => {
const eventType = `${M_BEACON_INFO.name}.${userId}.456`; await client.unstable_setLiveBeacon(roomId, content);
await client.unstable_setLiveBeacon(roomId, eventType, content);
// event type combined // event type combined
const [, , path, , requestContent] = client.http.authedRequest.mock.calls[0]; const [, , path, , requestContent] = client.http.authedRequest.mock.calls[0];
expect(path).toEqual( expect(path).toEqual(
`/rooms/${encodeURIComponent(roomId)}/state/` + `/rooms/${encodeURIComponent(roomId)}/state/` +
`${encodeURIComponent(eventType)}/${encodeURIComponent(userId)}`, `${encodeURIComponent(M_BEACON_INFO.name)}/${encodeURIComponent(userId)}`,
); );
expect(requestContent).toEqual(content); 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. limitations under the License.
*/ */
import { EventType } from "../../../src";
import { M_BEACON_INFO } from "../../../src/@types/beacon";
import { import {
isTimestampInDuration, isTimestampInDuration,
isBeaconInfoEventType,
Beacon, Beacon,
BeaconEvent, BeaconEvent,
} from "../../../src/models/beacon"; } from "../../../src/models/beacon";
import { makeBeaconInfoEvent } from "../../test-utils/beacon"; import { makeBeaconEvent, makeBeaconInfoEvent } from "../../test-utils/beacon";
jest.useFakeTimers(); 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', () => { describe('Beacon', () => {
const userId = '@user:server.org'; const userId = '@user:server.org';
const userId2 = '@user2:server.org';
const roomId = '$room:server.org'; const roomId = '$room:server.org';
// 14.03.2022 16:15 // 14.03.2022 16:15
const now = 1647270879403; const now = 1647270879403;
@@ -88,6 +67,7 @@ describe('Beacon', () => {
// without timeout of 3 hours // without timeout of 3 hours
let liveBeaconEvent; let liveBeaconEvent;
let notLiveBeaconEvent; let notLiveBeaconEvent;
let user2BeaconEvent;
const advanceDateAndTime = (ms: number) => { const advanceDateAndTime = (ms: number) => {
// bc liveness check uses Date.now we have to advance this mock // bc liveness check uses Date.now we have to advance this mock
@@ -99,13 +79,30 @@ describe('Beacon', () => {
beforeEach(() => { beforeEach(() => {
// go back in time to create the beacon // go back in time to create the beacon
jest.spyOn(global.Date, 'now').mockReturnValue(now - HOUR_MS); 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( notLiveBeaconEvent = makeBeaconInfoEvent(
userId, userId,
roomId, roomId,
{ timeout: HOUR_MS * 3, isLive: false }, { timeout: HOUR_MS * 3, isLive: false },
'$dead123', '$dead123',
); );
user2BeaconEvent = makeBeaconInfoEvent(
userId2,
roomId,
{
timeout: HOUR_MS * 3,
isLive: true,
},
'$user2live123',
);
// back to now // back to now
jest.spyOn(global.Date, 'now').mockReturnValue(now); jest.spyOn(global.Date, 'now').mockReturnValue(now);
@@ -123,6 +120,8 @@ describe('Beacon', () => {
expect(beacon.isLive).toEqual(true); expect(beacon.isLive).toEqual(true);
expect(beacon.beaconInfoOwner).toEqual(userId); expect(beacon.beaconInfoOwner).toEqual(userId);
expect(beacon.beaconInfoEventType).toEqual(liveBeaconEvent.getType()); expect(beacon.beaconInfoEventType).toEqual(liveBeaconEvent.getType());
expect(beacon.identifier).toEqual(`${roomId}_${userId}`);
expect(beacon.beaconInfo).toBeTruthy();
}); });
describe('isLive()', () => { describe('isLive()', () => {
@@ -159,8 +158,27 @@ describe('Beacon', () => {
expect(beacon.beaconInfoId).toEqual(liveBeaconEvent.getId()); expect(beacon.beaconInfoId).toEqual(liveBeaconEvent.getId());
expect(() => beacon.update(notLiveBeaconEvent)).toThrow(); expect(() => beacon.update(user2BeaconEvent)).toThrow();
expect(beacon.isLive).toEqual(true); // 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', () => { it('updates event', () => {
@@ -184,7 +202,11 @@ describe('Beacon', () => {
expect(beacon.isLive).toEqual(true); expect(beacon.isLive).toEqual(true);
const updatedBeaconEvent = makeBeaconInfoEvent( 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); beacon.update(updatedBeaconEvent);
expect(beacon.isLive).toEqual(false); expect(beacon.isLive).toEqual(false);
@@ -223,7 +245,23 @@ describe('Beacon', () => {
expect(emitSpy).toHaveBeenCalledWith(BeaconEvent.LivenessChange, false, 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 // live beacon was created an hour ago
// and has a 3hr duration // and has a 3hr duration
const beacon = new Beacon(liveBeaconEvent); const beacon = new Beacon(liveBeaconEvent);
@@ -234,9 +272,101 @@ describe('Beacon', () => {
// destroy the beacon // destroy the beacon
beacon.destroy(); beacon.destroy();
expect(emitSpy).toHaveBeenCalledWith(BeaconEvent.Destroy, beacon.identifier);
// live forced to false
expect(beacon.isLive).toBe(false);
advanceDateAndTime(HOUR_MS * 2 + 1); 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(); expect(emitSpy).not.toHaveBeenCalled();
}); });
}); });

View File

@@ -57,4 +57,31 @@ describe('MatrixEvent', () => {
expect(a.toSnapshot().isEquivalentTo(a)).toBe(true); expect(a.toSnapshot().isEquivalentTo(a)).toBe(true);
expect(a.toSnapshot().isEquivalentTo(b)).toBe(false); 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 * as utils from "../test-utils/test-utils";
import { PushProcessor } from "../../src/pushprocessor"; import { PushProcessor } from "../../src/pushprocessor";
import { EventType } from "../../src";
describe('NotificationService', function() { describe('NotificationService', function() {
const testUserId = "@ali:matrix.org"; const testUserId = "@ali:matrix.org";
@@ -208,6 +209,7 @@ describe('NotificationService', function() {
msgtype: "m.text", msgtype: "m.text",
}, },
}); });
matrixClient.pushRules = PushProcessor.rewriteDefaultRules(matrixClient.pushRules);
pushProcessor = new PushProcessor(matrixClient); pushProcessor = new PushProcessor(matrixClient);
}); });
@@ -295,6 +297,21 @@ describe('NotificationService', function() {
expect(actions.tweaks.highlight).toEqual(false); 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 // invalid
it('should gracefully handle bad input.', function() { it('should gracefully handle bad input.', function() {

View File

@@ -130,4 +130,51 @@ describe("Relations", function() {
await relationsCreated; 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 * 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 { filterEmitCallsByEventType } from "../test-utils/emitter";
import { RoomState, RoomStateEvent } from "../../src/models/room-state"; 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() { describe("RoomState", function() {
const roomId = "!foo:bar"; const roomId = "!foo:bar";
@@ -252,6 +258,7 @@ describe("RoomState", function() {
); );
}); });
describe('beacon events', () => {
it('adds new beacon info events to state and emits', () => { it('adds new beacon info events to state and emits', () => {
const beaconEvent = makeBeaconInfoEvent(userA, roomId); const beaconEvent = makeBeaconInfoEvent(userA, roomId);
const emitSpy = jest.spyOn(state, 'emit'); const emitSpy = jest.spyOn(state, 'emit');
@@ -259,31 +266,64 @@ describe("RoomState", function() {
state.setStateEvents([beaconEvent]); state.setStateEvents([beaconEvent]);
expect(state.beacons.size).toEqual(1); expect(state.beacons.size).toEqual(1);
const beaconInstance = state.beacons.get(beaconEvent.getId()); const beaconInstance = state.beacons.get(`${roomId}_${userA}`);
expect(beaconInstance).toBeTruthy(); expect(beaconInstance).toBeTruthy();
expect(emitSpy).toHaveBeenCalledWith(BeaconEvent.New, beaconEvent, beaconInstance); 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', () => { it('updates existing beacon info events in state', () => {
const beaconId = '$beacon1'; const beaconId = '$beacon1';
const beaconEvent = makeBeaconInfoEvent(userA, roomId, { isLive: true }, beaconId); const beaconEvent = makeBeaconInfoEvent(userA, roomId, { isLive: true }, beaconId);
const updatedBeaconEvent = makeBeaconInfoEvent(userA, roomId, { isLive: false }, beaconId); const updatedBeaconEvent = makeBeaconInfoEvent(userA, roomId, { isLive: false }, beaconId);
state.setStateEvents([beaconEvent]); state.setStateEvents([beaconEvent]);
const beaconInstance = state.beacons.get(beaconId); const beaconInstance = state.beacons.get(getBeaconInfoIdentifier(beaconEvent));
expect(beaconInstance.isLive).toEqual(true); expect(beaconInstance.isLive).toEqual(true);
state.setStateEvents([updatedBeaconEvent]); state.setStateEvents([updatedBeaconEvent]);
// same Beacon // same Beacon
expect(state.beacons.get(beaconId)).toBe(beaconInstance); expect(state.beacons.get(getBeaconInfoIdentifier(beaconEvent))).toBe(beaconInstance);
// updated liveness // 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', () => { it('updates live beacon ids once after setting state events', () => {
const liveBeaconEvent = makeBeaconInfoEvent(userA, roomId, { isLive: true }, '$beacon1'); 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'); const emitSpy = jest.spyOn(state, 'emit');
@@ -294,16 +334,17 @@ describe("RoomState", function() {
// live beacon is now not live // live beacon is now not live
const updatedLiveBeaconEvent = makeBeaconInfoEvent( const updatedLiveBeaconEvent = makeBeaconInfoEvent(
userA, roomId, { isLive: false }, liveBeaconEvent.getId(), userA, roomId, { isLive: false }, liveBeaconEvent.getId(), '$beacon1',
); );
state.setStateEvents([updatedLiveBeaconEvent]); state.setStateEvents([updatedLiveBeaconEvent]);
expect(state.hasLiveBeacons).toBe(false); 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); expect(emitSpy).toHaveBeenCalledWith(RoomStateEvent.BeaconLiveness, state, false);
}); });
}); });
});
describe("setOutOfBandMembers", function() { describe("setOutOfBandMembers", function() {
it("should add a new member", 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); 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. limitations under the License.
*/ */
import { ReceiptType } from "../../src/@types/read_receipts";
import { SyncAccumulator } from "../../src/sync-accumulator"; import { SyncAccumulator } from "../../src/sync-accumulator";
// The event body & unsigned object get frozen to assert that they don't get altered // 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", room_id: "!foo:bar",
content: { content: {
"$event1:localhost": { "$event1:localhost": {
"m.read": { [ReceiptType.Read]: {
"@alice:localhost": { ts: 1 }, "@alice:localhost": { ts: 1 },
"@bob:localhost": { ts: 2 }, "@bob:localhost": { ts: 2 },
}, },
[ReceiptType.ReadPrivate]: {
"@dan:localhost": { ts: 4 },
},
"some.other.receipt.type": { "some.other.receipt.type": {
"@should_be_ignored:localhost": { key: "val" }, "@should_be_ignored:localhost": { key: "val" },
}, },
@@ -309,7 +313,7 @@ describe("SyncAccumulator", function() {
room_id: "!foo:bar", room_id: "!foo:bar",
content: { content: {
"$event2:localhost": { "$event2:localhost": {
"m.read": { [ReceiptType.Read]: {
"@bob:localhost": { ts: 2 }, // clobbers event1 receipt "@bob:localhost": { ts: 2 }, // clobbers event1 receipt
"@charlie:localhost": { ts: 3 }, "@charlie:localhost": { ts: 3 },
}, },
@@ -337,12 +341,15 @@ describe("SyncAccumulator", function() {
room_id: "!foo:bar", room_id: "!foo:bar",
content: { content: {
"$event1:localhost": { "$event1:localhost": {
"m.read": { [ReceiptType.Read]: {
"@alice:localhost": { ts: 1 }, "@alice:localhost": { ts: 1 },
}, },
[ReceiptType.ReadPrivate]: {
"@dan:localhost": { ts: 4 },
},
}, },
"$event2:localhost": { "$event2:localhost": {
"m.read": { [ReceiptType.Read]: {
"@bob:localhost": { ts: 2 }, "@bob:localhost": { ts: 2 },
"@charlie:localhost": { ts: 3 }, "@charlie:localhost": { ts: 3 },
}, },

View File

@@ -10,8 +10,11 @@ import {
prevString, prevString,
simpleRetryOperation, simpleRetryOperation,
stringToBase, stringToBase,
sortEventsByLatestContentTimestamp,
} from "../../src/utils"; } from "../../src/utils";
import { logger } from "../../src/logger"; import { logger } from "../../src/logger";
import { mkMessage } from "../test-utils/test-utils";
import { makeBeaconEvent } from "../test-utils/beacon";
// TODO: Fix types throughout // 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 { TestClient } from '../../TestClient';
import { MatrixCall, CallErrorCode, CallEvent } from '../../../src/webrtc/call'; import { MatrixCall, CallErrorCode, CallEvent, supportsMatrixCall } from '../../../src/webrtc/call';
import { SDPStreamMetadataKey, SDPStreamMetadataPurpose } from '../../../src/webrtc/callEventTypes'; import { SDPStreamMetadataKey, SDPStreamMetadataPurpose } from '../../../src/webrtc/callEventTypes';
import { RoomMember } from "../../../src"; import { RoomMember } from "../../../src";
@@ -505,4 +505,40 @@ describe('Call', function() {
return sender?.track?.kind === "video"; return sender?.track?.kind === "video";
}).track.id).toBe("video_track"); }).track.id).toBe("video_track");
}); });
describe("supportsMatrixCall", () => {
it("should return true when the environment is right", () => {
expect(supportsMatrixCall()).toBe(true);
});
it("should return false if window or document are undefined", () => {
global.window = undefined;
expect(supportsMatrixCall()).toBe(false);
global.window = prevWindow;
global.document = undefined;
expect(supportsMatrixCall()).toBe(false);
});
it("should return false if RTCPeerConnection throws", () => {
// @ts-ignore - writing to window as we are simulating browser edge-cases
global.window = {};
Object.defineProperty(global.window, "RTCPeerConnection", {
get: () => {
throw Error("Secure mode, naaah!");
},
});
expect(supportsMatrixCall()).toBe(false);
});
it("should return false if RTCPeerConnection & RTCSessionDescription " +
"& RTCIceCandidate & mediaDevices are unavailable",
() => {
global.window.RTCPeerConnection = undefined;
global.window.RTCSessionDescription = undefined;
global.window.RTCIceCandidate = undefined;
// @ts-ignore - writing to a read-only property as we are simulating faulty browsers
global.navigator.mediaDevices = undefined;
expect(supportsMatrixCall()).toBe(false);
});
});
}); });

View File

@@ -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. 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 { UnstableValue } from "../NamespacedValue";
import { MAssetEvent, MLocationEvent, MTimestampEvent } from "./location"; import { MAssetEvent, MLocationEvent, MTimestampEvent } from "./location";
/** /**
* Beacon info and beacon event types as described in MSC3489 * Beacon info and beacon event types as described in MSC3672
* https://github.com/matrix-org/matrix-spec-proposals/pull/3489 * 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 * 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_INFO = new UnstableValue("m.beacon_info", "org.matrix.msc3672.beacon_info");
export const M_BEACON = new UnstableValue("m.beacon", "org.matrix.msc3489.beacon"); export const M_BEACON = new UnstableValue("m.beacon", "org.matrix.msc3672.beacon");
export type MBeaconInfoContent = { export type MBeaconInfoContent = {
description?: string; description?: string;
@@ -80,16 +75,11 @@ export type MBeaconInfoContent = {
live?: boolean; live?: boolean;
}; };
export type MBeaconInfoEvent = EitherAnd<
{ [M_BEACON_INFO.name]: MBeaconInfoContent },
{ [M_BEACON_INFO.altName]: MBeaconInfoContent }
>;
/** /**
* m.beacon_info Event example from the spec * 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", "state_key": "@matthew:matrix.org",
"content": { "content": {
"m.beacon_info": { "m.beacon_info": {
@@ -108,7 +98,7 @@ export type MBeaconInfoEvent = EitherAnd<
* m.beacon_info.* event content * m.beacon_info.* event content
*/ */
export type MBeaconInfoEventContent = & export type MBeaconInfoEventContent = &
MBeaconInfoEvent & MBeaconInfoContent &
// creation timestamp of the beacon on the client // creation timestamp of the beacon on the client
MTimestampEvent & MTimestampEvent &
// the type of asset being tracked as per MSC3488 // the type of asset being tracked as per MSC3488
@@ -116,7 +106,7 @@ export type MBeaconInfoEventContent = &
/** /**
* m.beacon event example * 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", "type": "m.beacon",

View File

@@ -93,14 +93,7 @@ export enum RelationType {
Annotation = "m.annotation", Annotation = "m.annotation",
Replace = "m.replace", Replace = "m.replace",
Reference = "m.reference", Reference = "m.reference",
/** Thread = "m.thread",
* 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",
} }
export enum MsgType { export enum MsgType {
@@ -119,6 +112,8 @@ export const RoomCreateTypeField = "type";
export enum RoomType { export enum RoomType {
Space = "m.space", 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}, // 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. // 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. // 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 setInterval(handler: TimerHandler, timeout: number, ...arguments: any[]): number;
function setTimeout(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 { Callback } from "../client";
import { IContent, IEvent } from "../models/event"; import { IContent, IEvent } from "../models/event";
import { Preset, Visibility } from "./partials"; import { Preset, Visibility } from "./partials";
import { SearchKey } from "./search"; import { IEventWithRoomId, SearchKey } from "./search";
import { IRoomEventFilter } from "../filter"; import { IRoomEventFilter } from "../filter";
import { Direction } from "../models/event-timeline"; import { Direction } from "../models/event-timeline";
import { PushRuleAction } from "./PushRules";
import { IRoomEvent } from "../sync-accumulator";
// allow camelcase as these are things that go onto the wire // allow camelcase as these are things that go onto the wire
/* eslint-disable camelcase */ /* eslint-disable camelcase */
@@ -155,4 +157,50 @@ export interface IRelationsResponse {
prev_batch?: string; prev_batch?: string;
} }
export interface IContextResponse {
end: string;
start: string;
state: IEventWithRoomId[];
events_before: IEventWithRoomId[];
events_after: IEventWithRoomId[];
event: IEventWithRoomId;
}
export interface IEventsResponse {
chunk: IEventWithRoomId[];
end: string;
start: string;
}
export interface INotification {
actions: PushRuleAction[];
event: IRoomEvent;
profile_tag?: string;
read: boolean;
room_id: string;
ts: number;
}
export interface INotificationsResponse {
next_token: string;
notifications: INotification[];
}
export interface IFilterResponse {
filter_id: string;
}
export interface ITagsResponse {
tags: {
[tagId: string]: {
order: number;
};
};
}
export interface IStatusResponse extends IPresenceOpts {
currently_active?: boolean;
last_active_ago?: number;
}
/* eslint-enable camelcase */ /* eslint-enable camelcase */

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

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

View File

@@ -17,8 +17,6 @@ limitations under the License.
/** @module auto-discovery */ /** @module auto-discovery */
import { URL as NodeURL } from "url";
import { IClientWellKnown, IWellKnownConfig } from "./client"; import { IClientWellKnown, IWellKnownConfig } from "./client";
import { logger } from './logger'; import { logger } from './logger';
@@ -249,8 +247,7 @@ export class AutoDiscovery {
// Step 7: Copy any other keys directly into the clientConfig. This is for // Step 7: Copy any other keys directly into the clientConfig. This is for
// things like custom configuration of services. // things like custom configuration of services.
Object.keys(wellknown) Object.keys(wellknown).forEach((k) => {
.map((k) => {
if (k === "m.homeserver" || k === "m.identity_server") { if (k === "m.homeserver" || k === "m.identity_server") {
// Only copy selected parts of the config to avoid overwriting // Only copy selected parts of the config to avoid overwriting
// properties computed by the validation logic above. // properties computed by the validation logic above.
@@ -373,16 +370,11 @@ export class AutoDiscovery {
if (!url) return false; if (!url) return false;
try { try {
// We have to try and parse the URL using the NodeJS URL
// library if we're on NodeJS and use the browser's URL
// library when we're in a browser. To accomplish this, we
// try the NodeJS version first and fall back to the browser.
let parsed = null; let parsed = null;
try { try {
if (NodeURL) parsed = new NodeURL(url);
else parsed = new URL(url);
} catch (e) {
parsed = new URL(url); parsed = new URL(url);
} catch (e) {
logger.error("Could not parse url", e);
} }
if (!parsed || !parsed.hostname) return false; if (!parsed || !parsed.hostname) return false;
@@ -411,14 +403,14 @@ export class AutoDiscovery {
* the following properties: * the following properties:
* raw: The JSON object returned by the server. * raw: The JSON object returned by the server.
* action: One of SUCCESS, IGNORE, or FAIL_PROMPT. * 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. * error: The actual Error, if one exists.
* @param {string} url The URL to fetch a JSON object from. * @param {string} url The URL to fetch a JSON object from.
* @return {Promise<object>} Resolves to the returned state. * @return {Promise<object>} Resolves to the returned state.
* @private * @private
*/ */
private static async fetchWellKnownObject(url: string): Promise<IWellKnownConfig> { private static fetchWellKnownObject(url: string): Promise<IWellKnownConfig> {
return new Promise(function(resolve, reject) { return new Promise(function(resolve) {
// eslint-disable-next-line // eslint-disable-next-line
const request = require("./matrix").getRequest(); const request = require("./matrix").getRequest();
if (!request) throw new Error("No request library available"); 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 */ /** @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 { MsgType } from "./@types/event";
import { TEXT_NODE_TYPE } from "./@types/extensible_events"; import { TEXT_NODE_TYPE } from "./@types/extensible_events";
import { import {
@@ -32,6 +32,7 @@ import {
MAssetContent, MAssetContent,
LegacyLocationEventContent, LegacyLocationEventContent,
} from "./@types/location"; } from "./@types/location";
import { MRoomTopicEventContent, MTopicContent, M_TOPIC } from "./@types/topic";
/** /**
* Generates the content for a HTML Message event * Generates the content for a HTML Message event
@@ -190,6 +191,34 @@ export const parseLocationEvent = (wireEventContent: LocationEventWireContent):
return makeLocationContent(fallbackText, geoUri, timestamp, description, assetType); return makeLocationContent(fallbackText, geoUri, timestamp, description, assetType);
}; };
/**
* Topic event helpers
*/
export type MakeTopicContent = (
topic: string,
htmlTopic?: string,
) => MRoomTopicEventContent;
export const makeTopicContent: MakeTopicContent = (topic, htmlTopic) => {
const renderings = [{ body: topic, mimetype: "text/plain" }];
if (isProvided(htmlTopic)) {
renderings.push({ body: htmlTopic, mimetype: "text/html" });
}
return { topic, [M_TOPIC.name]: renderings };
};
export type TopicState = {
text: string;
html?: string;
};
export const parseTopicContent = (content: MRoomTopicEventContent): TopicState => {
const mtopic = M_TOPIC.findIn<MTopicContent>(content);
const text = mtopic?.find(r => !isProvided(r.mimetype) || r.mimetype === "text/plain")?.body ?? content.topic;
const html = mtopic?.find(r => r.mimetype === "text/html")?.body;
return { text, html };
};
/** /**
* Beacon event helpers * Beacon event helpers
*/ */
@@ -208,11 +237,9 @@ export const makeBeaconInfoContent: MakeBeaconInfoContent = (
assetType, assetType,
timestamp, timestamp,
) => ({ ) => ({
[M_BEACON_INFO.name]: {
description, description,
timeout, timeout,
live: isLive, live: isLive,
},
[M_TIMESTAMP.name]: timestamp || Date.now(), [M_TIMESTAMP.name]: timestamp || Date.now(),
[M_ASSET.name]: { [M_ASSET.name]: {
type: assetType ?? LocationAssetType.Self, type: assetType ?? LocationAssetType.Self,
@@ -227,7 +254,7 @@ export type BeaconInfoState = MBeaconInfoContent & {
* Flatten beacon info event content * Flatten beacon info event content
*/ */
export const parseBeaconInfoContent = (content: MBeaconInfoEventContent): BeaconInfoState => { 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 { type: assetType } = M_ASSET.findIn<MAssetContent>(content);
const timestamp = M_TIMESTAMP.findIn<number>(content); const timestamp = M_TIMESTAMP.findIn<number>(content);
@@ -243,14 +270,14 @@ export const parseBeaconInfoContent = (content: MBeaconInfoEventContent): Beacon
export type MakeBeaconContent = ( export type MakeBeaconContent = (
uri: string, uri: string,
timestamp: number, timestamp: number,
beaconInfoId: string, beaconInfoEventId: string,
description?: string, description?: string,
) => MBeaconEventContent; ) => MBeaconEventContent;
export const makeBeaconContent: MakeBeaconContent = ( export const makeBeaconContent: MakeBeaconContent = (
uri, uri,
timestamp, timestamp,
beaconInfoId, beaconInfoEventId,
description, description,
) => ({ ) => ({
[M_LOCATION.name]: { [M_LOCATION.name]: {
@@ -260,6 +287,21 @@ export const makeBeaconContent: MakeBeaconContent = (
[M_TIMESTAMP.name]: timestamp, [M_TIMESTAMP.name]: timestamp,
"m.relates_to": { "m.relates_to": {
rel_type: REFERENCE_RELATION.name, 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("#"); const fragmentOffset = serverAndMediaId.indexOf("#");
let fragment = ""; let fragment = "";
if (fragmentOffset >= 0) { if (fragmentOffset >= 0) {
fragment = serverAndMediaId.substr(fragmentOffset); fragment = serverAndMediaId.slice(fragmentOffset);
serverAndMediaId = serverAndMediaId.substr(0, fragmentOffset); serverAndMediaId = serverAndMediaId.slice(0, fragmentOffset);
} }
const urlParams = (Object.keys(params).length === 0 ? "" : ("?" + utils.encodeParams(params))); 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>> { public async isStoredInSecretStorage(secretStorage: SecretStorage): Promise<Record<string, object>> {
// check what SSSS keys have encrypted the master key (if any) // check what SSSS keys have encrypted the master key (if any)
const stored = await secretStorage.isStored("m.cross_signing.master", false) || {}; const stored = await secretStorage.isStored("m.cross_signing.master") || {};
// then check which of those SSSS keys have also encrypted the SSK and USK // then check which of those SSSS keys have also encrypted the SSK and USK
function intersect(s: Record<string, ISecretStorageKeyInfo>) { function intersect(s: Record<string, ISecretStorageKeyInfo>) {
for (const k of Object.keys(stored)) { for (const k of Object.keys(stored)) {
@@ -181,7 +181,7 @@ export class CrossSigningInfo {
} }
} }
for (const type of ["self_signing", "user_signing"]) { for (const type of ["self_signing", "user_signing"]) {
intersect(await secretStorage.isStored(`m.cross_signing.${type}`, false) || {}); intersect(await secretStorage.isStored(`m.cross_signing.${type}`) || {});
} }
return Object.keys(stored).length ? stored : null; return Object.keys(stored).length ? stored : null;
} }

View File

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

View File

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

View File

@@ -78,7 +78,7 @@ export enum RoomKeyRequestState {
export class OutgoingRoomKeyRequestManager { export class OutgoingRoomKeyRequestManager {
// handle for the delayed call to sendOutgoingRoomKeyRequests. Non-null // handle for the delayed call to sendOutgoingRoomKeyRequests. Non-null
// if the callback has been set, or if it is still running. // 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 // sanity check to ensure that we don't end up with two concurrent runs
// of sendOutgoingRoomKeyRequests // of sendOutgoingRoomKeyRequests
@@ -189,9 +189,7 @@ export class OutgoingRoomKeyRequestManager {
// in state ROOM_KEY_REQUEST_STATES.SENT, so we must have // in state ROOM_KEY_REQUEST_STATES.SENT, so we must have
// raced with another tab to mark the request cancelled. // raced with another tab to mark the request cancelled.
// Try again, to make sure the request is resent. // Try again, to make sure the request is resent.
return await this.queueRoomKeyRequest( return this.queueRoomKeyRequest(requestBody, recipients, resend);
requestBody, recipients, resend,
);
} }
// We don't want to wait for the timer, so we send it // 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. // encoded, since this is how a key would normally be stored.
if (encInfo.passthrough) return encodeBase64(decryption.get_private_key()); if (encInfo.passthrough) return encodeBase64(decryption.get_private_key());
return await decryption.decrypt(encInfo); return decryption.decrypt(encInfo);
} finally { } finally {
if (decryption && decryption.free) decryption.free(); if (decryption && decryption.free) decryption.free();
} }
@@ -339,21 +339,15 @@ export class SecretStorage {
* Check if a secret is stored on the server. * Check if a secret is stored on the server.
* *
* @param {string} name the name of the secret * @param {string} name the name of the secret
* @param {boolean} checkKey check if the secret is encrypted by a trusted key
* *
* @return {object?} map of key name to key info the secret is encrypted * @return {object?} map of key name to key info the secret is encrypted
* with, or null if it is not present or not encrypted with a trusted * with, or null if it is not present or not encrypted with a trusted
* key * key
*/ */
public async isStored(name: string, checkKey: boolean): Promise<Record<string, ISecretStorageKeyInfo> | null> { public async isStored(name: string): Promise<Record<string, ISecretStorageKeyInfo> | null> {
// check if secret exists // check if secret exists
const secretInfo = await this.accountDataAdapter.getAccountDataFromServer<ISecretInfo>(name); const secretInfo = await this.accountDataAdapter.getAccountDataFromServer<ISecretInfo>(name);
if (!secretInfo) return null; if (!secretInfo?.encrypted) return null;
if (!secretInfo.encrypted) {
return null;
}
if (checkKey === undefined) checkKey = true;
const ret = {}; const ret = {};
@@ -598,11 +592,11 @@ export class SecretStorage {
if (keys[keyId].algorithm === SECRET_STORAGE_ALGORITHM_V1_AES) { if (keys[keyId].algorithm === SECRET_STORAGE_ALGORITHM_V1_AES) {
const decryption = { const decryption = {
encrypt: async function(secret: string): Promise<IEncryptedPayload> { encrypt: function(secret: string): Promise<IEncryptedPayload> {
return await encryptAES(secret, privateKey, name); return encryptAES(secret, privateKey, name);
}, },
decrypt: async function(encInfo: IEncryptedPayload): Promise<string> { decrypt: function(encInfo: IEncryptedPayload): Promise<string> {
return await decryptAES(encInfo, privateKey, name); return decryptAES(encInfo, privateKey, name);
}, },
}; };
return [keyId, decryption]; return [keyId, decryption];

View File

@@ -250,7 +250,7 @@ async function deriveKeysBrowser(key: Uint8Array, name: string): Promise<[Crypto
['sign', 'verify'], ['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> { 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(); return Promise.resolve();
} }
this.prepPromise = this.crypto.downloadKeys(roomMembers).then((res) => { this.prepPromise = this.crypto.downloadKeys(roomMembers).then(() => {
return this.crypto.ensureOlmSessionsForUsers(roomMembers); return this.crypto.ensureOlmSessionsForUsers(roomMembers);
}).then(() => { }).then(() => {
this.sessionPrepared = true; 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. * @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 // This is a wrapper that serialises decryptions of prekey messages, because
// otherwise we race between deciding we have no active sessions for the message // 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. // 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 // we want the error, but don't propagate it to the next decryption
this.olmDevice.olmPrekeyPromise = myPromise.catch(() => {}); this.olmDevice.olmPrekeyPromise = myPromise.catch(() => {});
return await myPromise; return myPromise;
} }
} }

View File

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

View File

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

View File

@@ -58,7 +58,7 @@ import { keyFromPassphrase } from './key_passphrase';
import { decodeRecoveryKey, encodeRecoveryKey } from './recoverykey'; import { decodeRecoveryKey, encodeRecoveryKey } from './recoverykey';
import { VerificationRequest } from "./verification/request/VerificationRequest"; import { VerificationRequest } from "./verification/request/VerificationRequest";
import { InRoomChannel, InRoomRequests } from "./verification/request/InRoomChannel"; import { InRoomChannel, InRoomRequests } from "./verification/request/InRoomChannel";
import { ToDeviceChannel, ToDeviceRequests } from "./verification/request/ToDeviceChannel"; import { ToDeviceChannel, ToDeviceRequests, Request } from "./verification/request/ToDeviceChannel";
import { IllegalMethod } from "./verification/IllegalMethod"; import { IllegalMethod } from "./verification/IllegalMethod";
import { KeySignatureUploadError } from "../errors"; import { KeySignatureUploadError } from "../errors";
import { calculateKeyCheck, decryptAES, encryptAES } from './aes'; import { calculateKeyCheck, decryptAES, encryptAES } from './aes';
@@ -309,7 +309,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
private oneTimeKeyCount: number; private oneTimeKeyCount: number;
private needsNewFallback: boolean; private needsNewFallback: boolean;
private fallbackCleanup?: number; // setTimeout ID private fallbackCleanup?: ReturnType<typeof setTimeout>;
/** /**
* Cryptography bits * Cryptography bits
@@ -402,7 +402,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
// try to get key from app // try to get key from app
if (this.baseApis.cryptoCallbacks && this.baseApis.cryptoCallbacks.getBackupKey) { 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"); throw new Error("Unable to get private key");
@@ -690,7 +690,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
// Cross-sign own device // Cross-sign own device
const device = this.deviceList.getStoredDevice(this.userId, this.deviceId); 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); builder.addKeySignature(this.userId, this.deviceId, deviceSignature);
// Sign message key backup with cross-signing master key // 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) // 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) // 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. // 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); newKeyId = await createSSSS(keyInfo, privateKey);
} else if (!storageExists && keyBackupInfo) { } else if (!storageExists && keyBackupInfo) {
// we have an existing backup, but no SSSS // 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( const decodedBackupKey = new Uint8Array(olmlib.decodeBase64(
fixedBackupKey || sessionBackupKey, fixedBackupKey || sessionBackupKey,
)); ));
await builder.addSessionBackupPrivateKeyToCache(decodedBackupKey); builder.addSessionBackupPrivateKeyToCache(decodedBackupKey);
} else if (this.backupManager.getKeyBackupEnabled()) { } 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 // 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 // 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); return this.secretStorage.get(name);
} }
public isSecretStored( public isSecretStored(name: string): Promise<Record<string, ISecretStorageKeyInfo> | null> {
name: string, return this.secretStorage.isStored(name);
checkKey?: boolean,
): Promise<Record<string, ISecretStorageKeyInfo> | null> {
return this.secretStorage.isStored(name, checkKey);
} }
public requestSecret(name: string, devices: string[]): ISecretRequest { public requestSecret(name: string, devices: string[]): ISecretRequest {
@@ -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 * Event handler for DeviceList's userNewDevices event
*/ */
@@ -2302,8 +2318,8 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
userId: string, userId: string,
deviceId: string, deviceId: string,
transactionId: string = null, transactionId: string = null,
): any { // TODO types ): VerificationBase<any, any> {
let request; let request: Request;
if (transactionId) { if (transactionId) {
request = this.toDeviceVerificationRequests.getRequestBySenderAndTxnId(userId, transactionId); request = this.toDeviceVerificationRequests.getRequestBySenderAndTxnId(userId, transactionId);
if (!request) { if (!request) {
@@ -2871,7 +2887,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
} else { } else {
const content = event.getWireContent(); const content = event.getWireContent();
const alg = this.getRoomDecryptor(event.getRoomId(), content.algorithm); 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; iterations: number;
} }
export async function keyFromAuthData(authData: IAuthData, password: string): Promise<Uint8Array> { export function keyFromAuthData(authData: IAuthData, password: string): Promise<Uint8Array> {
if (!global.Olm) { if (!global.Olm) {
throw new Error("Olm is not available"); 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, password, authData.private_key_salt,
authData.private_key_iterations, authData.private_key_iterations,
authData.private_key_bits || DEFAULT_BITSIZE, 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. limitations under the License.
*/ */
import bs58 from 'bs58'; import * as bs58 from 'bs58';
// picked arbitrarily but to try & avoid clashing with any bitcoin ones // picked arbitrarily but to try & avoid clashing with any bitcoin ones
// (which are also base58 encoded, but bitcoin's involve a lot more hashing) // (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>( public doTxn<T>(
mode: Mode, mode: Mode,
stores: Iterable<string>, stores: string | string[],
func: (txn: IDBTransaction) => T, func: (txn: IDBTransaction) => T,
log: PrefixedLogger = logger, log: PrefixedLogger = logger,
): Promise<T> { ): Promise<T> {

View File

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

View File

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

View File

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

View File

@@ -193,6 +193,7 @@ function calculateMAC(olmSAS: OlmSAS, method: string) {
} }
const calculateKeyAgreement = { const calculateKeyAgreement = {
// eslint-disable-next-line @typescript-eslint/naming-convention
"curve25519-hkdf-sha256": function(sas: SAS, olmSAS: OlmSAS, bytes: number): Uint8Array { "curve25519-hkdf-sha256": function(sas: SAS, olmSAS: OlmSAS, bytes: number): Uint8Array {
const ourInfo = `${sas.baseApis.getUserId()}|${sas.baseApis.deviceId}|` const ourInfo = `${sas.baseApis.getUserId()}|${sas.baseApis.deviceId}|`
+ `${sas.ourSASPubKey}|`; + `${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 * @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. * @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 // prevent processing the same event multiple times, as under
// some circumstances Room.timeline can get emitted twice for the same event // some circumstances Room.timeline can get emitted twice for the same event
if (request.hasEventId(event.getId())) { if (request.hasEventId(event.getId())) {
@@ -221,8 +221,7 @@ export class InRoomChannel implements IVerificationChannel {
const isRemoteEcho = !!event.getUnsigned().transaction_id; const isRemoteEcho = !!event.getUnsigned().transaction_id;
const isSentByUs = event.getSender() === this.client.getUserId(); const isSentByUs = event.getSender() === this.client.getUserId();
return await request.handleEvent( return request.handleEvent(type, event, isLiveEvent, isRemoteEcho, isSentByUs);
type, event, isLiveEvent, isRemoteEcho, isSentByUs);
} }
/** /**

View File

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

View File

@@ -25,15 +25,31 @@ export interface MapperOpts {
} }
export function eventMapperFor(client: MatrixClient, options: MapperOpts): EventMapper { export function eventMapperFor(client: MatrixClient, options: MapperOpts): EventMapper {
const preventReEmit = Boolean(options.preventReEmit); let preventReEmit = Boolean(options.preventReEmit);
const decrypt = options.decrypt !== false; const decrypt = options.decrypt !== false;
function mapper(plainOldJsObject: Partial<IEvent>) { function mapper(plainOldJsObject: Partial<IEvent>) {
const event = new MatrixEvent(plainOldJsObject); const room = client.getRoom(plainOldJsObject.room_id);
const room = client.getRoom(event.getRoomId()); let event: MatrixEvent;
if (room?.threads.has(event.getId())) { // If the event is already known to the room, let's re-use the model rather than duplicating.
event.setThread(room.threads.get(event.getId())); // 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()) { if (event.isEncrypted()) {
@@ -46,11 +62,15 @@ export function eventMapperFor(client: MatrixClient, options: MapperOpts): Event
client.decryptEventIfNeeded(event); client.decryptEventIfNeeded(event);
} }
} }
if (!preventReEmit) { if (!preventReEmit) {
client.reEmitter.reEmit(event, [ client.reEmitter.reEmit(event, [
MatrixEventEvent.Replaced, MatrixEventEvent.Replaced,
MatrixEventEvent.VisibilityChange, MatrixEventEvent.VisibilityChange,
]); ]);
room?.reEmitter.reEmit(event, [
MatrixEventEvent.BeforeRedaction,
]);
} }
return event; return event;
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -17,8 +17,7 @@ limitations under the License.
import { MemoryCryptoStore } from "./crypto/store/memory-crypto-store"; import { MemoryCryptoStore } from "./crypto/store/memory-crypto-store";
import { MemoryStore } from "./store/memory"; import { MemoryStore } from "./store/memory";
import { MatrixScheduler } from "./scheduler"; import { MatrixScheduler } from "./scheduler";
import { MatrixClient } from "./client"; import { MatrixClient, ICreateClientOpts } from "./client";
import { ICreateClientOpts } from "./client";
import { DeviceTrustLevel } from "./crypto/CrossSigning"; import { DeviceTrustLevel } from "./crypto/CrossSigning";
import { ISecretStorageKeyInfo } from "./crypto/api"; import { ISecretStorageKeyInfo } from "./crypto/api";
@@ -30,7 +29,6 @@ export * from "./errors";
export * from "./models/beacon"; export * from "./models/beacon";
export * from "./models/event"; export * from "./models/event";
export * from "./models/room"; export * from "./models/room";
export * from "./models/group";
export * from "./models/event-timeline"; export * from "./models/event-timeline";
export * from "./models/event-timeline-set"; export * from "./models/event-timeline-set";
export * from "./models/room-member"; export * from "./models/room-member";
@@ -154,7 +152,7 @@ export interface ICryptoCallbacks {
export function createClient(opts: ICreateClientOpts | string) { export function createClient(opts: ICreateClientOpts | string) {
if (typeof opts === "string") { if (typeof opts === "string") {
opts = { opts = {
"baseUrl": opts as string, "baseUrl": opts,
}; };
} }
opts.request = opts.request || requestInstance; 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. limitations under the License.
*/ */
import { M_BEACON_INFO } from "../@types/beacon"; import { MBeaconEventContent } from "../@types/beacon";
import { BeaconInfoState, parseBeaconInfoContent } from "../content-helpers"; import { M_TIMESTAMP } from "../@types/location";
import { BeaconInfoState, BeaconLocationState, parseBeaconContent, parseBeaconInfoContent } from "../content-helpers";
import { MatrixEvent } from "../matrix"; import { MatrixEvent } from "../matrix";
import { sortEventsByLatestContentTimestamp } from "../utils";
import { TypedEventEmitter } from "./typed-event-emitter"; import { TypedEventEmitter } from "./typed-event-emitter";
export enum BeaconEvent { export enum BeaconEvent {
New = "Beacon.new", New = "Beacon.new",
Update = "Beacon.update", Update = "Beacon.update",
LivenessChange = "Beacon.LivenessChange", LivenessChange = "Beacon.LivenessChange",
Destroy = "Beacon.Destroy",
LocationUpdate = "Beacon.LocationUpdate",
} }
export type BeaconEventHandlerMap = { export type BeaconEventHandlerMap = {
[BeaconEvent.Update]: (event: MatrixEvent, beacon: Beacon) => void; [BeaconEvent.Update]: (event: MatrixEvent, beacon: Beacon) => void;
[BeaconEvent.LivenessChange]: (isLive: boolean, 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 = ( export const isTimestampInDuration = (
@@ -36,16 +43,19 @@ export const isTimestampInDuration = (
timestamp: number, timestamp: number,
): boolean => timestamp >= startTimestamp && startTimestamp + durationMs >= timestamp; ): boolean => timestamp >= startTimestamp && startTimestamp + durationMs >= timestamp;
export const isBeaconInfoEventType = (type: string) => // beacon info events are uniquely identified by
type.startsWith(M_BEACON_INFO.name) || // `<roomId>_<state_key>`
type.startsWith(M_BEACON_INFO.altName); 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> { export class Beacon extends TypedEventEmitter<Exclude<BeaconEvent, BeaconEvent.New>, BeaconEventHandlerMap> {
public readonly roomId: string; public readonly roomId: string;
private _beaconInfo: BeaconInfoState; private _beaconInfo: BeaconInfoState;
private _isLive: boolean; private _isLive: boolean;
private livenessWatchInterval: number; private livenessWatchInterval: ReturnType<typeof setInterval>;
private _latestLocationState: BeaconLocationState | undefined;
constructor( constructor(
private rootEvent: MatrixEvent, private rootEvent: MatrixEvent,
@@ -59,6 +69,10 @@ export class Beacon extends TypedEventEmitter<Exclude<BeaconEvent, BeaconEvent.N
return this._isLive; return this._isLive;
} }
public get identifier(): BeaconIdentifier {
return getBeaconInfoIdentifier(this.rootEvent);
}
public get beaconInfoId(): string { public get beaconInfoId(): string {
return this.rootEvent.getId(); return this.rootEvent.getId();
} }
@@ -75,20 +89,32 @@ export class Beacon extends TypedEventEmitter<Exclude<BeaconEvent, BeaconEvent.N
return this._beaconInfo; return this._beaconInfo;
} }
public get latestLocationState(): BeaconLocationState | undefined {
return this._latestLocationState;
}
public update(beaconInfoEvent: MatrixEvent): void { public update(beaconInfoEvent: MatrixEvent): void {
if (beaconInfoEvent.getId() !== this.beaconInfoId) { if (getBeaconInfoIdentifier(beaconInfoEvent) !== this.identifier) {
throw new Error('Invalid updating event'); 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.rootEvent = beaconInfoEvent;
this.setBeaconInfo(this.rootEvent); this.setBeaconInfo(this.rootEvent);
this.emit(BeaconEvent.Update, beaconInfoEvent, this); this.emit(BeaconEvent.Update, beaconInfoEvent, this);
this.clearLatestLocation();
} }
public destroy(): void { public destroy(): void {
if (this.livenessWatchInterval) { if (this.livenessWatchInterval) {
clearInterval(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); clearInterval(this.livenessWatchInterval);
} }
this.checkLiveness();
if (this.isLive) { 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) { 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 { private setBeaconInfo(event: MatrixEvent): void {
this._beaconInfo = parseBeaconInfoContent(event.getContent()); this._beaconInfo = parseBeaconInfoContent(event.getContent());
this.checkLiveness(); this.checkLiveness();

View File

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

View File

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

View File

@@ -86,9 +86,17 @@ export interface IEvent {
unsigned: IUnsigned; unsigned: IUnsigned;
redacts?: string; redacts?: string;
// v1 legacy fields /**
* @deprecated
*/
user_id?: string; user_id?: string;
/**
* @deprecated
*/
prev_content?: IContent; prev_content?: IContent;
/**
* @deprecated
*/
age?: number; age?: number;
} }
@@ -111,11 +119,6 @@ export interface IEventRelation {
key?: string; key?: string;
} }
export interface IVisibilityEventRelation extends IEventRelation {
visibility: "visible" | "hidden";
reason?: string;
}
/** /**
* When an event is a visibility change event, as per MSC3531, * When an event is a visibility change event, as per MSC3531,
* the visibility change implied by the event. * the visibility change implied by the event.
@@ -279,7 +282,7 @@ export class MatrixEvent extends TypedEventEmitter<EmittedEvents, MatrixEventHan
public target: RoomMember = null; public target: RoomMember = null;
public status: EventStatus = null; public status: EventStatus = null;
public error: MatrixError = 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, /* 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 * `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. * @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) { if (this._localRedactionEvent) {
return {} as T; return {} as T;
} else if (this._replacingEvent) { } 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 * caused a change in the actual visibility of this event, either by making it
* visible (if it was hidden), by making it hidden (if it was visible) or by * visible (if it was hidden), by making it hidden (if it was visible) or by
* changing the reason (if it was hidden). * changing the reason (if it was hidden).
* @param visibilityEvent event holding a hide/unhide payload, or nothing * @param visibilityChange event holding a hide/unhide payload, or nothing
* if the event is being reset to its original visibility (presumably * if the event is being reset to its original visibility (presumably
* by a visibility event being redacted). * by a visibility event being redacted).
*/ */
@@ -1062,11 +1065,9 @@ export class MatrixEvent extends TypedEventEmitter<EmittedEvents, MatrixEventHan
reason: reason, reason: reason,
}); });
} }
if (change) {
this.emit(MatrixEventEvent.VisibilityChange, this, visible); this.emit(MatrixEventEvent.VisibilityChange, this, visible);
} }
} }
}
/** /**
* Return instructions to display or hide the message. * 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; this.event.unsigned.redacted_because = redactionEvent.event as IEvent;
let key; for (const key in this.event) {
for (key in this.event) { if (this.event.hasOwnProperty(key) && !REDACT_KEEP_KEYS.has(key)) {
if (!this.event.hasOwnProperty(key)) {
continue;
}
if (!REDACT_KEEP_KEYS.has(key)) {
delete this.event[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 keeps = REDACT_KEEP_CONTENT_MAP[this.getType()] || {};
const content = this.getContent(); const content = this.getContent();
for (key in content) { for (const key in content) {
if (!content.hasOwnProperty(key)) { if (content.hasOwnProperty(key) && !keeps[key]) {
continue;
}
if (!keeps[key]) {
delete content[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 * Get whether the event is a relation event, and of a given type if
* `relType` is passed in. * `relType` is passed in. State events cannot be relation events
* *
* @param {string?} relType if given, checks that the relation is of the * @param {string?} relType if given, checks that the relation is of the
* given type * given type
@@ -1300,10 +1299,12 @@ export class MatrixEvent extends TypedEventEmitter<EmittedEvents, MatrixEventHan
public isRelation(relType: string = undefined): boolean { public isRelation(relType: string = undefined): boolean {
// Relation info is lifted out of the encrypted content when sent to // Relation info is lifted out of the encrypted content when sent to
// encrypted rooms, so we have to check `getWireContent` for this. // encrypted rooms, so we have to check `getWireContent` for this.
const content = this.getWireContent(); const relation = this.getWireContent()?.["m.relates_to"];
const relation = content && content["m.relates_to"]; if (this.isState() && relation?.rel_type === RelationType.Replace) {
return relation && relation.rel_type && relation.event_id && // State events cannot be m.replace relations
((relType && relation.rel_type === relType) || !relType); 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) { if (this.isRedacted() && newEvent) {
return; return;
} }
// don't allow state events to be replaced using this mechanism as per MSC2676
if (this.isState()) {
return;
}
if (this._replacingEvent !== newEvent) { if (this._replacingEvent !== newEvent) {
this._replacingEvent = newEvent; this._replacingEvent = newEvent;
this.emit(MatrixEventEvent.Replaced, this); this.emit(MatrixEventEvent.Replaced, this);
@@ -1583,7 +1588,7 @@ const REDACT_KEEP_KEYS = new Set([
'content', 'unsigned', 'origin_server_ts', '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 = { const REDACT_KEEP_CONTENT_MAP = {
[EventType.RoomMember]: { 'membership': 1 }, [EventType.RoomMember]: { 'membership': 1 },
[EventType.RoomCreate]: { 'creator': 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) { if (this.relationType === RelationType.Annotation) {
this.addAnnotationToAggregation(event); this.addAnnotationToAggregation(event);
} else if (this.relationType === RelationType.Replace && this.targetEvent) { } else if (this.relationType === RelationType.Replace && this.targetEvent && !this.targetEvent.isState()) {
const lastReplacement = await this.getLastReplacement(); const lastReplacement = await this.getLastReplacement();
this.targetEvent.makeReplaced(lastReplacement); this.targetEvent.makeReplaced(lastReplacement);
} }
@@ -144,7 +144,7 @@ export class Relations extends TypedEventEmitter<RelationsEvent, EventHandlerMap
if (this.relationType === RelationType.Annotation) { if (this.relationType === RelationType.Annotation) {
this.removeAnnotationFromAggregation(event); this.removeAnnotationFromAggregation(event);
} else if (this.relationType === RelationType.Replace && this.targetEvent) { } else if (this.relationType === RelationType.Replace && this.targetEvent && !this.targetEvent.isState()) {
const lastReplacement = await this.getLastReplacement(); const lastReplacement = await this.getLastReplacement();
this.targetEvent.makeReplaced(lastReplacement); this.targetEvent.makeReplaced(lastReplacement);
} }
@@ -261,7 +261,7 @@ export class Relations extends TypedEventEmitter<RelationsEvent, EventHandlerMap
if (this.relationType === RelationType.Annotation) { if (this.relationType === RelationType.Annotation) {
// Remove the redacted annotation from aggregation by key // Remove the redacted annotation from aggregation by key
this.removeAnnotationFromAggregation(redactedEvent); this.removeAnnotationFromAggregation(redactedEvent);
} else if (this.relationType === RelationType.Replace && this.targetEvent) { } else if (this.relationType === RelationType.Replace && this.targetEvent && !this.targetEvent.isState()) {
const lastReplacement = await this.getLastReplacement(); const lastReplacement = await this.getLastReplacement();
this.targetEvent.makeReplaced(lastReplacement); this.targetEvent.makeReplaced(lastReplacement);
} }
@@ -331,7 +331,7 @@ export class Relations extends TypedEventEmitter<RelationsEvent, EventHandlerMap
// the all-knowning server tells us that the event at some point had // 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 // this timestamp for its replacement, so any following replacement should definitely not be less
const replaceRelation = this.targetEvent.getServerAggregatedRelation<IAggregatedRelation>(RelationType.Replace); 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) => { const lastReplacement = this.getRelations().reduce((last, event) => {
if (event.getSender() !== this.targetEvent.getSender()) { if (event.getSender() !== this.targetEvent.getSender()) {
@@ -364,7 +364,7 @@ export class Relations extends TypedEventEmitter<RelationsEvent, EventHandlerMap
} }
this.targetEvent = event; this.targetEvent = event;
if (this.relationType === RelationType.Replace) { if (this.relationType === RelationType.Replace && !this.targetEvent.isState()) {
const replacement = await this.getLastReplacement(); const replacement = await this.getLastReplacement();
// this is the initial update, so only call it if we already have something // this is the initial update, so only call it if we already have something
// to not emit Event.replaced needlessly // to not emit Event.replaced needlessly

View File

@@ -22,12 +22,13 @@ import { RoomMember } from "./room-member";
import { logger } from '../logger'; import { logger } from '../logger';
import * as utils from "../utils"; import * as utils from "../utils";
import { EventType } from "../@types/event"; import { EventType } from "../@types/event";
import { MatrixEvent } from "./event"; import { MatrixEvent, MatrixEventEvent } from "./event";
import { MatrixClient } from "../client"; import { MatrixClient } from "../client";
import { GuestAccess, HistoryVisibility, IJoinRuleEventContent, JoinRule } from "../@types/partials"; import { GuestAccess, HistoryVisibility, IJoinRuleEventContent, JoinRule } from "../@types/partials";
import { TypedEventEmitter } from "./typed-event-emitter"; 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 { TypedReEmitter } from "../ReEmitter";
import { M_BEACON, M_BEACON_INFO } from "../@types/beacon";
// possible statuses for out-of-band member loading // possible statuses for out-of-band member loading
enum OobStatus { 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 events = new Map<string, Map<string, MatrixEvent>>(); // Map<eventType, Map<stateKey, MatrixEvent>>
public paginationToken: string = null; public paginationToken: string = null;
public readonly beacons = new Map<string, Beacon>(); public readonly beacons = new Map<BeaconIdentifier, Beacon>();
private liveBeaconIds: string[] = []; private _liveBeaconIds: BeaconIdentifier[] = [];
/** /**
* Construct room state. * Construct room state.
@@ -248,6 +249,10 @@ export class RoomState extends TypedEventEmitter<EmittedEvents, EventHandlerMap>
return !!this.liveBeaconIds?.length; 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. * 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 * @return {RoomState} the copy of the room state
@@ -330,7 +335,7 @@ export class RoomState extends TypedEventEmitter<EmittedEvents, EventHandlerMap>
return; return;
} }
if (isBeaconInfoEventType(event.getType())) { if (M_BEACON_INFO.matches(event.getType())) {
this.setBeacon(event); this.setBeacon(event);
} }
@@ -404,6 +409,51 @@ export class RoomState extends TypedEventEmitter<EmittedEvents, EventHandlerMap>
this.emit(RoomStateEvent.Update, this); 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, * Looks up a member by the given userId, and if it doesn't exist,
* create it and emit the `RoomState.newMember` event. * create it and emit the `RoomState.newMember` event.
@@ -437,8 +487,24 @@ export class RoomState extends TypedEventEmitter<EmittedEvents, EventHandlerMap>
* @experimental * @experimental
*/ */
private setBeacon(event: MatrixEvent): void { private setBeacon(event: MatrixEvent): void {
if (this.beacons.has(event.getId())) { const beaconIdentifier = getBeaconInfoIdentifier(event);
return this.beacons.get(event.getId()).update(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); const beacon = new Beacon(event);
@@ -446,30 +512,28 @@ export class RoomState extends TypedEventEmitter<EmittedEvents, EventHandlerMap>
this.reEmitter.reEmit<BeaconEvent, BeaconEvent>(beacon, [ this.reEmitter.reEmit<BeaconEvent, BeaconEvent>(beacon, [
BeaconEvent.New, BeaconEvent.New,
BeaconEvent.Update, BeaconEvent.Update,
BeaconEvent.Destroy,
BeaconEvent.LivenessChange, BeaconEvent.LivenessChange,
]); ]);
this.emit(BeaconEvent.New, event, beacon); this.emit(BeaconEvent.New, event, beacon);
beacon.on(BeaconEvent.LivenessChange, this.onBeaconLivenessChange.bind(this)); 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 * @experimental
* Check liveness of room beacons * Check liveness of room beacons
* emit RoomStateEvent.BeaconLiveness when * emit RoomStateEvent.BeaconLiveness event
* roomstate.hasLiveBeacons has changed
*/ */
private onBeaconLivenessChange(): void { 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) .filter(beacon => beacon.isLive)
.map(beacon => beacon.beaconInfoId); .map(beacon => beacon.identifier);
const hasLiveBeacons = !!this.liveBeaconIds.length; this.emit(RoomStateEvent.BeaconLiveness, this, this.hasLiveBeacons);
if (prevHasLiveBeacons !== hasLiveBeacons) {
this.emit(RoomStateEvent.BeaconLiveness, this, hasLiveBeacons);
}
} }
private getStateEventMatching(event: MatrixEvent): MatrixEvent | null { private getStateEventMatching(event: MatrixEvent): MatrixEvent | null {

View File

@@ -23,7 +23,7 @@ import { Direction, EventTimeline } from "./event-timeline";
import { getHttpUriForMxc } from "../content-repo"; import { getHttpUriForMxc } from "../content-repo";
import * as utils from "../utils"; import * as utils from "../utils";
import { normalize } 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 { EventStatus } from "./event-status";
import { RoomMember } from "./room-member"; import { RoomMember } from "./room-member";
import { IRoomSummary, RoomSummary } from "./room-summary"; import { IRoomSummary, RoomSummary } from "./room-summary";
@@ -32,6 +32,7 @@ import { TypedReEmitter } from '../ReEmitter';
import { import {
EventType, RoomCreateTypeField, RoomType, UNSTABLE_ELEMENT_FUNCTIONAL_USERS, EventType, RoomCreateTypeField, RoomType, UNSTABLE_ELEMENT_FUNCTIONAL_USERS,
EVENT_VISIBILITY_CHANGE_TYPE, EVENT_VISIBILITY_CHANGE_TYPE,
RelationType,
} from "../@types/event"; } from "../@types/event";
import { IRoomVersionsCapability, MatrixClient, PendingEventOrdering, RoomVersionStability } from "../client"; import { IRoomVersionsCapability, MatrixClient, PendingEventOrdering, RoomVersionStability } from "../client";
import { GuestAccess, HistoryVisibility, JoinRule, ResizeMethod } from "../@types/partials"; import { GuestAccess, HistoryVisibility, JoinRule, ResizeMethod } from "../@types/partials";
@@ -45,8 +46,9 @@ import {
FILTER_RELATED_BY_SENDERS, FILTER_RELATED_BY_SENDERS,
ThreadFilterType, ThreadFilterType,
} from "./thread"; } from "./thread";
import { Method } from "../http-api";
import { TypedEventEmitter } from "./typed-event-emitter"; 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 // These constants are used as sane defaults when the homeserver doesn't support
// the m.room_versions capability. In practice, KNOWN_SAFE_ROOM_VERSION should be // the m.room_versions capability. In practice, KNOWN_SAFE_ROOM_VERSION should be
@@ -54,10 +56,10 @@ import { TypedEventEmitter } from "./typed-event-emitter";
// room versions which are considered okay for people to run without being asked // 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 // to upgrade (ie: "stable"). Eventually, we should remove these when all homeservers
// return an m.room_versions capability. // return an m.room_versions capability.
const KNOWN_SAFE_ROOM_VERSION = '6'; const KNOWN_SAFE_ROOM_VERSION = '9';
const SAFE_ROOM_VERSIONS = ['1', '2', '3', '4', '5', '6']; 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()); // console.log("synthesizing receipt for "+event.getId());
return new MatrixEvent({ return new MatrixEvent({
content: { content: {
@@ -92,13 +94,13 @@ interface IReceipt {
ts: number; ts: number;
} }
interface IWrappedReceipt { export interface IWrappedReceipt {
eventId: string; eventId: string;
data: IReceipt; data: IReceipt;
} }
interface ICachedReceipt { interface ICachedReceipt {
type: string; type: ReceiptType;
userId: string; userId: string;
data: IReceipt; data: IReceipt;
} }
@@ -107,7 +109,7 @@ type ReceiptCache = {[eventId: string]: ICachedReceipt[]};
interface IReceiptContent { interface IReceiptContent {
[eventId: string]: { [eventId: string]: {
[type: string]: { [key in ReceiptType]: {
[userId: string]: IReceipt; [userId: string]: IReceipt;
}; };
}; };
@@ -148,6 +150,7 @@ export interface ICreateFilterOpts {
// timeline. Useful to disable for some filters that can't be achieved by the // timeline. Useful to disable for some filters that can't be achieved by the
// client in an efficient manner // client in an efficient manner
prepopulateTimeline?: boolean; prepopulateTimeline?: boolean;
useSyncEvents?: boolean;
pendingEvents?: boolean; pendingEvents?: boolean;
} }
@@ -167,8 +170,10 @@ export enum RoomEvent {
type EmittedEvents = RoomEvent type EmittedEvents = RoomEvent
| ThreadEvent.New | ThreadEvent.New
| ThreadEvent.Update | ThreadEvent.Update
| ThreadEvent.NewReply
| RoomEvent.Timeline | RoomEvent.Timeline
| RoomEvent.TimelineReset; | RoomEvent.TimelineReset
| MatrixEventEvent.BeforeRedaction;
export type RoomEventHandlerMap = { export type RoomEventHandlerMap = {
[RoomEvent.MyMembership]: (room: Room, membership: string, prevMembership?: string) => void; [RoomEvent.MyMembership]: (room: Room, membership: string, prevMembership?: string) => void;
@@ -185,10 +190,10 @@ export type RoomEventHandlerMap = {
oldStatus?: EventStatus, oldStatus?: EventStatus,
) => void; ) => void;
[ThreadEvent.New]: (thread: Thread, toStartOfTimeline: boolean) => void; [ThreadEvent.New]: (thread: Thread, toStartOfTimeline: boolean) => void;
} & ThreadHandlerMap; } & ThreadHandlerMap & MatrixEventHandlerMap;
export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap> { 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 } private txnToEvent: Record<string, MatrixEvent> = {}; // Pending in-flight requests { string: MatrixEvent }
// receipts should clobber based on receipt_type and user_id pairs hence // 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 // 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 * @experimental
*/ */
public threads = new Map<string, Thread>(); private threads = new Map<string, Thread>();
public lastThread: Thread; public lastThread: Thread;
/** /**
@@ -346,15 +351,6 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
RoomEvent.TimelineReset, RoomEvent.TimelineReset,
]); ]);
if (this.client?.supportsExperimentalThreads) {
Promise.all([
this.createThreadTimelineSet(),
this.createThreadTimelineSet(ThreadFilterType.My),
]).then((timelineSets) => {
this.threadsTimelineSets.push(...timelineSets);
});
}
this.fixUpLegacyTimelineFields(); this.fixUpLegacyTimelineFields();
if (this.opts.pendingEventOrdering === PendingEventOrdering.Detached) { 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 * 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 lastSyncToken = this.client.store.getSyncToken();
const queryString = utils.encodeParams({ const response = await this.client.members(this.roomId, undefined, "leave", lastSyncToken);
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);
return response.chunk; return response.chunk;
} }
@@ -794,7 +803,8 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
// fails), since loadMembersIfNeeded always returns this.membersPromise // fails), since loadMembersIfNeeded always returns this.membersPromise
// if set, which will be the result of the first (successful) call. // if set, which will be the result of the first (successful) call.
if (rawMembersEvents === null || if (rawMembersEvents === null ||
(this.client.isCryptoEnabled() && this.client.isRoomEncrypted(this.roomId))) { (this.client.isCryptoEnabled() && this.client.isRoomEncrypted(this.roomId))
) {
fromServer = true; fromServer = true;
rawMembersEvents = await this.loadMembersFromServer(); rawMembersEvents = await this.loadMembersFromServer();
logger.log(`LL: got ${rawMembersEvents.length} ` + logger.log(`LL: got ${rawMembersEvents.length} ` +
@@ -840,7 +850,7 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
if (fromServer) { if (fromServer) {
const oobMembers = this.currentState.getMembers() const oobMembers = this.currentState.getMembers()
.filter((m) => m.isOutOfBand()) .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}` logger.log(`LL: telling store to write ${oobMembers.length}`
+ ` members for room ${this.roomId}`); + ` members for room ${this.roomId}`);
const store = this.client.store; 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 * @param {string} eventId event ID to look for
* @return {?module:models/event.MatrixEvent} the given event, or undefined if unknown * @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 { public findEventById(eventId: string): MatrixEvent | undefined {
let event = this.getUnfilteredTimelineSet().findEventById(eventId); let event = this.getUnfilteredTimelineSet().findEventById(eventId);
if (event) { if (!event) {
return event;
} else {
const threads = this.getThreads(); const threads = this.getThreads();
for (let i = 0; i < threads.length; i++) { for (let i = 0; i < threads.length; i++) {
const thread = threads[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 * The aliases returned by this function may not necessarily
* still point to this room. * still point to this room.
* @return {array} The room's alias as an array of strings * @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[] { public getAliases(): string[] {
const aliasStrings: string[] = []; const aliasStrings: string[] = [];
const aliasEvents = this.currentState.getStateEvents(EventType.RoomAliases); const aliasEvents = this.currentState.getStateEvents(EventType.RoomAliases);
if (aliasEvents) { if (aliasEvents) {
for (let i = 0; i < aliasEvents.length; ++i) { for (const aliasEvent of aliasEvents) {
const aliasEvent = aliasEvents[i];
if (Array.isArray(aliasEvent.getContent().aliases)) { if (Array.isArray(aliasEvent.getContent().aliases)) {
const filteredAliases = aliasEvent.getContent<{ aliases: string[] }>().aliases.filter(a => { const filteredAliases = aliasEvent.getContent<{ aliases: string[] }>().aliases.filter(a => {
if (typeof(a) !== "string") return false; if (typeof(a) !== "string") return false;
@@ -1132,7 +1142,7 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
// It's probably valid by here. // It's probably valid by here.
return true; return true;
}); });
Array.prototype.push.apply(aliasStrings, filteredAliases); aliasStrings.push(...filteredAliases);
} }
} }
} }
@@ -1190,19 +1200,14 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
timeline: EventTimeline, timeline: EventTimeline,
paginationToken?: string, paginationToken?: string,
): void { ): void {
timeline.getTimelineSet().addEventsToTimeline( timeline.getTimelineSet().addEventsToTimeline(events, toStartOfTimeline, timeline, paginationToken);
events, toStartOfTimeline,
timeline, paginationToken,
);
} }
/** /**
* @experimental * @experimental
*/ */
public getThread(eventId: string): Thread { public getThread(eventId: string): Thread {
return this.getThreads().find(thread => { return this.threads.get(eventId);
return thread.id === eventId;
});
} }
/** /**
@@ -1335,6 +1340,7 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
filter: Filter, filter: Filter,
{ {
prepopulateTimeline = true, prepopulateTimeline = true,
useSyncEvents = true,
pendingEvents = true, pendingEvents = true,
}: ICreateFilterOpts = {}, }: ICreateFilterOpts = {},
): EventTimelineSet { ): EventTimelineSet {
@@ -1347,8 +1353,10 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
RoomEvent.Timeline, RoomEvent.Timeline,
RoomEvent.TimelineReset, RoomEvent.TimelineReset,
]); ]);
if (useSyncEvents) {
this.filteredTimelineSets[filter.filterId] = timelineSet; this.filteredTimelineSets[filter.filterId] = timelineSet;
this.timelineSets.push(timelineSet); this.timelineSets.push(timelineSet);
}
const unfilteredLiveTimeline = this.getLiveTimeline(); const unfilteredLiveTimeline = this.getLiveTimeline();
// Not all filter are possible to replicate client-side only // 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), timeline.getPaginationToken(EventTimeline.BACKWARDS),
EventTimeline.BACKWARDS, EventTimeline.BACKWARDS,
); );
} else { } else if (useSyncEvents) {
const livePaginationToken = unfilteredLiveTimeline.getPaginationToken(Direction.Forward); const livePaginationToken = unfilteredLiveTimeline.getPaginationToken(Direction.Forward);
timelineSet timelineSet
.getLiveTimeline() .getLiveTimeline()
@@ -1394,9 +1402,7 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
return timelineSet; return timelineSet;
} }
private async createThreadTimelineSet(filterType?: ThreadFilterType): Promise<EventTimelineSet> { private async getThreadListFilter(filterType = ThreadFilterType.All): Promise<Filter> {
let timelineSet: EventTimelineSet;
if (Thread.hasServerSideSupport) {
const myUserId = this.client.getUserId(); const myUserId = this.client.getUserId();
const filter = new Filter(myUserId); const filter = new Filter(myUserId);
@@ -1417,18 +1423,25 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
`THREAD_PANEL_${this.roomId}_${filterType}`, `THREAD_PANEL_${this.roomId}_${filterType}`,
filter, filter,
); );
filter.filterId = filterId; 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( timelineSet = this.getOrCreateFilteredTimelineSet(
filter, filter,
{ {
prepopulateTimeline: false, prepopulateTimeline: false,
useSyncEvents: false,
pendingEvents: false, pendingEvents: false,
}, },
); );
// An empty pagination token allows to paginate from the very bottom of
// the timeline set.
timelineSet.getLiveTimeline().setPaginationToken("", EventTimeline.BACKWARDS);
} else { } else {
timelineSet = new EventTimelineSet(this, { timelineSet = new EventTimelineSet(this, {
pendingEvents: false, pendingEvents: false,
@@ -1449,6 +1462,86 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
return timelineSet; 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 * 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 { public eventShouldLiveIn(event: MatrixEvent, events?: MatrixEvent[], roots?: Set<string>): {
if (!event) { shouldLiveInRoom: boolean;
return null; 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) { if (event.isThreadRelation) {
return this.threads.get(event.threadRootId); return {
} else if (event.isThreadRoot) { shouldLiveInRoom: false,
return this.threads.get(event.getId()); 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 { } else {
const parentEvent = this.findEventById(event.getAssociatedId()); const rootEvent = this.findEventById(threadId) ?? events.find(e => e.getId() === threadId);
return this.findThreadForEvent(parentEvent); 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 * @experimental
*/ */
public async addThreadedEvent(event: MatrixEvent, toStartOfTimeline: boolean): Promise<void> { public processThreadedEvents(events: MatrixEvent[], toStartOfTimeline: boolean): void {
this.applyRedaction(event); events.forEach(this.applyRedaction);
let thread = this.findThreadForEvent(event);
if (thread) { const eventsByThread: { [threadId: string]: MatrixEvent[] } = {};
thread.addEvent(event, toStartOfTimeline); for (const event of events) {
} else { const { threadId, shouldLiveInThread } = this.eventShouldLiveIn(event);
const events = [event]; if (shouldLiveInThread && !eventsByThread[threadId]) {
let rootEvent = this.findEventById(event.threadRootId); eventsByThread[threadId] = [];
// If the rootEvent does not exist in the current sync, then look for }
// it over the network eventsByThread[threadId]?.push(event);
try {
let eventData;
if (event.threadRootId) {
eventData = await this.client.fetchRoomEvent(this.roomId, event.threadRootId);
} }
if (!rootEvent) { Object.entries(eventsByThread).map(([threadId, threadEvents]) => (
rootEvent = new MatrixEvent(eventData); this.addThreadedEvents(threadId, threadEvents, toStartOfTimeline)
} 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);
} }
public createThread( public createThread(
threadId: string,
rootEvent: MatrixEvent | undefined, rootEvent: MatrixEvent | undefined,
events: MatrixEvent[] = [], events: MatrixEvent[] = [],
toStartOfTimeline: boolean, toStartOfTimeline: boolean,
): Thread | undefined { ): Thread {
if (rootEvent) { if (rootEvent) {
const tl = this.getTimelineForEvent(rootEvent.getId()); const tl = this.getTimelineForEvent(rootEvent.getId());
const relatedEvents = tl?.getTimelineSet().getAllRelationsEventForEvent(rootEvent.getId()); const relatedEvents = tl?.getTimelineSet().getAllRelationsEventForEvent(rootEvent.getId());
if (relatedEvents) { if (relatedEvents?.length) {
events = events.concat(relatedEvents); // 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, initialEvents: events,
room: this, room: this,
client: this.client, client: this.client,
}); });
// If we managed to create a thread and figure out its `id`
// then we can use it // If we managed to create a thread and figure out its `id` then we can use it
if (thread.id) {
this.threads.set(thread.id, thread); this.threads.set(thread.id, thread);
this.reEmitter.reEmit(thread, [ this.reEmitter.reEmit(thread, [
ThreadEvent.Update, ThreadEvent.Update,
ThreadEvent.NewReply,
RoomEvent.Timeline, RoomEvent.Timeline,
RoomEvent.TimelineReset, RoomEvent.TimelineReset,
]); ]);
@@ -1549,6 +1687,7 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
this.emit(ThreadEvent.New, thread, toStartOfTimeline); this.emit(ThreadEvent.New, thread, toStartOfTimeline);
if (this.threadsReady) {
this.threadsTimelineSets.forEach(timelineSet => { this.threadsTimelineSets.forEach(timelineSet => {
if (thread.rootEvent) { if (thread.rootEvent) {
if (Thread.hasServerSideSupport) { if (Thread.hasServerSideSupport) {
@@ -1562,12 +1701,12 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
} }
} }
}); });
}
return thread; return thread;
} }
}
applyRedaction(event: MatrixEvent): void { private applyRedaction = (event: MatrixEvent): void => {
if (event.isRedaction()) { if (event.isRedaction()) {
const redactId = event.event.redacts; const redactId = event.event.redacts;
@@ -1577,7 +1716,7 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
redactedEvent.makeRedacted(event); redactedEvent.makeRedacted(event);
// If this is in the current state, replace it with the redacted version // If this is in the current state, replace it with the redacted version
if (redactedEvent.getStateKey()) { if (redactedEvent.isState()) {
const currentStateEvent = this.currentState.getStateEvents( const currentStateEvent = this.currentState.getStateEvents(
redactedEvent.getType(), redactedEvent.getType(),
redactedEvent.getStateKey(), 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 // clients can say "so and so redacted an event" if they wish to. Also
// this may be needed to trigger an update. // this may be needed to trigger an update.
} }
} };
/** private processLiveEvent(event: MatrixEvent): void {
* 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 {
this.applyRedaction(event); this.applyRedaction(event);
// Implement MSC3531: hiding messages. // Implement MSC3531: hiding messages.
@@ -1640,10 +1769,21 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
if (existingEvent) { if (existingEvent) {
// remote echo of an event we sent earlier // remote echo of an event we sent earlier
this.handleRemoteEcho(event, existingEvent); 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 // add to our timeline sets
for (let i = 0; i < this.timelineSets.length; i++) { for (let i = 0; i < this.timelineSets.length; i++) {
this.timelineSets[i].addLiveEvent(event, duplicateStrategy, fromCache); this.timelineSets[i].addLiveEvent(event, duplicateStrategy, fromCache);
@@ -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. // Don't synthesize RR for m.room.redaction as this causes the RR to go missing.
if (event.sender && event.getType() !== EventType.RoomRedaction) { if (event.sender && event.getType() !== EventType.RoomRedaction) {
this.addReceipt(synthesizeReceipt( this.addReceipt(synthesizeReceipt(
event.sender.userId, event, "m.read", event.sender.userId, event, ReceiptType.Read,
), true); ), true);
// Any live events from a user could be taken as implicit // 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. * @param {module:models/event.MatrixEvent} event the relation event that needs to be aggregated.
*/ */
private aggregateNonLiveRelation(event: MatrixEvent): void { private aggregateNonLiveRelation(event: MatrixEvent): void {
const thread = this.findThreadForEvent(event); const { shouldLiveInRoom, threadId } = this.eventShouldLiveIn(event);
if (thread) { const thread = this.getThread(threadId);
thread.timelineSet.aggregateRelations(event); thread?.timelineSet.aggregateRelations(event);
}
if (thread?.id === event.getAssociatedId() || !thread) { if (shouldLiveInRoom) {
// TODO: We should consider whether this means it would be a better // TODO: We should consider whether this means it would be a better
// design to lift the relations handling up to the room instead. // design to lift the relations handling up to the room instead.
for (let i = 0; i < this.timelineSets.length; i++) { 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 newEventId = remoteEvent.getId();
const oldStatus = localEvent.status; const oldStatus = localEvent.status;
logger.debug( logger.debug(`Got remote echo for event ${oldEventId} -> ${newEventId} old status ${oldStatus}`);
`Got remote echo for event ${oldEventId} -> ${newEventId} ` +
`old status ${oldStatus}`,
);
// no longer pending // no longer pending
delete this.txnToEvent[remoteEvent.getUnsigned().transaction_id]; 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). // any, which is good, because we don't want to try decoding it again).
localEvent.handleRemoteEcho(remoteEvent.event); localEvent.handleRemoteEcho(remoteEvent.event);
const thread = this.findThreadForEvent(remoteEvent); const { shouldLiveInRoom, threadId } = this.eventShouldLiveIn(remoteEvent);
if (thread) { const thread = this.getThread(threadId);
thread.timelineSet.handleRemoteEcho(localEvent, oldEventId, newEventId); thread?.timelineSet.handleRemoteEcho(localEvent, oldEventId, newEventId);
}
if (thread?.id === remoteEvent.getAssociatedId() || !thread) { if (shouldLiveInRoom) {
for (let i = 0; i < this.timelineSets.length; i++) { for (let i = 0; i < this.timelineSets.length; i++) {
const timelineSet = this.timelineSets[i]; const timelineSet = this.timelineSets[i];
@@ -1926,11 +2061,11 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
// update the event id // update the event id
event.replaceLocalEventId(newEventId); event.replaceLocalEventId(newEventId);
const thread = this.findThreadForEvent(event); const { shouldLiveInRoom, threadId } = this.eventShouldLiveIn(event);
if (thread) { const thread = this.getThread(threadId);
thread.timelineSet.replaceEventId(oldEventId, newEventId); thread?.timelineSet.replaceEventId(oldEventId, newEventId);
}
if (thread?.id === event.getAssociatedId() || !thread) { if (shouldLiveInRoom) {
// if the event was already in the timeline (which will be the case if // if the event was already in the timeline (which will be the case if
// opts.pendingEventOrdering==chronological), we need to update the // opts.pendingEventOrdering==chronological), we need to update the
// timeline map. // timeline map.
@@ -1941,14 +2076,12 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
} else if (newStatus == EventStatus.CANCELLED) { } else if (newStatus == EventStatus.CANCELLED) {
// remove it from the pending event list, or the timeline. // remove it from the pending event list, or the timeline.
if (this.pendingEventList) { if (this.pendingEventList) {
const idx = this.pendingEventList.findIndex(ev => ev.getId() === oldEventId); const removedEvent = this.getPendingEvent(oldEventId);
if (idx !== -1) { this.removePendingEvent(oldEventId);
const [removedEvent] = this.pendingEventList.splice(idx, 1);
if (removedEvent.isRedaction()) { if (removedEvent.isRedaction()) {
this.revertRedactionLocalEcho(removedEvent); this.revertRedactionLocalEcho(removedEvent);
} }
} }
}
this.removeEvent(oldEventId); this.removeEvent(oldEventId);
} }
this.savePendingEvents(); this.savePendingEvents();
@@ -1992,13 +2125,12 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
* @throws If <code>duplicateStrategy</code> is not falsey, 'replace' or 'ignore'. * @throws If <code>duplicateStrategy</code> is not falsey, 'replace' or 'ignore'.
*/ */
public addLiveEvents(events: MatrixEvent[], duplicateStrategy?: DuplicateStrategy, fromCache = false): void { public addLiveEvents(events: MatrixEvent[], duplicateStrategy?: DuplicateStrategy, fromCache = false): void {
let i;
if (duplicateStrategy && ["replace", "ignore"].indexOf(duplicateStrategy) === -1) { if (duplicateStrategy && ["replace", "ignore"].indexOf(duplicateStrategy) === -1) {
throw new Error("duplicateStrategy MUST be either 'replace' or 'ignore'"); throw new Error("duplicateStrategy MUST be either 'replace' or 'ignore'");
} }
// sanity check that the live timeline is still live // 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(); const liveTimeline = this.timelineSets[i].getLiveTimeline();
if (liveTimeline.getPaginationToken(EventTimeline.FORWARDS)) { if (liveTimeline.getPaginationToken(EventTimeline.FORWARDS)) {
throw new Error( throw new Error(
@@ -2007,22 +2139,85 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
); );
} }
if (liveTimeline.getNeighbouringTimeline(EventTimeline.FORWARDS)) { if (liveTimeline.getNeighbouringTimeline(EventTimeline.FORWARDS)) {
throw new Error( throw new Error(`live timeline ${i} is no longer live - it has a neighbouring timeline`);
"live timeline " + i + " is no longer live - " +
"it has a neighbouring timeline",
);
} }
} }
for (i = 0; i < events.length; i++) { const threadRoots = this.findThreadRoots(events);
// TODO: We should have a filter to say "only add state event const eventsByThread: { [threadId: string]: MatrixEvent[] } = {};
// types X Y Z to the timeline".
this.addLiveEvent(events[i], duplicateStrategy, fromCache); for (const event of events) {
const thread = this.findThreadForEvent(events[i]); // TODO: We should have a filter to say "only add state event types X Y Z to the timeline".
if (thread) { this.processLiveEvent(event);
thread.addEvent(events[i], true);
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 // set fake stripped state events if this is an invite room so logic remains
// consistent elsewhere. // consistent elsewhere.
const membershipEvent = this.currentState.getStateEvents(EventType.RoomMember, this.myUserId); 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 || []; const strippedStateEvents = membershipEvent.getUnsigned().invite_room_state || [];
strippedStateEvents.forEach((strippedEvent) => { strippedStateEvents.forEach((strippedEvent) => {
const existingEvent = this.currentState.getStateEvents(strippedEvent.type, strippedEvent.state_key); 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; const oldName = this.name;
this.name = this.calculateRoomName(this.myUserId); this.name = this.calculateRoomName(this.myUserId);
@@ -2117,14 +2317,23 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
*/ */
public getUsersReadUpTo(event: MatrixEvent): string[] { public getUsersReadUpTo(event: MatrixEvent): string[] {
return this.getReceiptsForEvent(event).filter(function(receipt) { return this.getReceiptsForEvent(event).filter(function(receipt) {
return receipt.type === "m.read"; return [ReceiptType.Read, ReceiptType.ReadPrivate].includes(receipt.type);
}).map(function(receipt) { }).map(function(receipt) {
return receipt.userId; 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) { if (ignoreSynthesized) {
return realReceipt; 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. * @return {String} ID of the latest event that the given user has read, or null.
*/ */
public getEventReadUpTo(userId: string, ignoreSynthesized = false): string | null { public getEventReadUpTo(userId: string, ignoreSynthesized = false): string | null {
const readReceipt = this.getReadReceiptForUserId(userId, ignoreSynthesized); const timelineSet = this.getUnfilteredTimelineSet();
return readReceipt?.eventId ?? null; 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({ this.receiptCacheByEventId[eventId].push({
userId: userId, userId: userId,
type: receiptType, type: receiptType as ReceiptType,
data: receipt, data: receipt,
}); });
}); });
@@ -2309,9 +2535,9 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
* client the fact that we've sent one. * client the fact that we've sent one.
* @param {string} userId The user ID if the receipt sender * @param {string} userId The user ID if the receipt sender
* @param {MatrixEvent} e The event that is to be acknowledged * @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); 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 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 { public getType(): RoomType | string | undefined {
const createEvent = this.currentState.getStateEvents(EventType.RoomCreate, ""); const createEvent = this.currentState.getStateEvents(EventType.RoomCreate, "");
@@ -2436,6 +2662,22 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
return this.getType() === RoomType.Space; 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 * This is an internal method. Calculates the name of the room from the current
* room state. * room state.

View File

@@ -33,14 +33,19 @@ export class SearchResult {
public static fromJson(jsonObj: ISearchResult, eventMapper: EventMapper): SearchResult { public static fromJson(jsonObj: ISearchResult, eventMapper: EventMapper): SearchResult {
const jsonContext = jsonObj.context || {} as IResultContext; const jsonContext = jsonObj.context || {} as IResultContext;
const eventsBefore = jsonContext.events_before || []; let eventsBefore = (jsonContext.events_before || []).map(eventMapper);
const eventsAfter = jsonContext.events_after || []; let eventsAfter = (jsonContext.events_after || []).map(eventMapper);
const context = new EventContext(eventMapper(jsonObj.result)); 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.setPaginateToken(jsonContext.start, true);
context.addEvents(eventsBefore.map(eventMapper), true); context.addEvents(eventsBefore, true);
context.addEvents(eventsAfter.map(eventMapper), false); context.addEvents(eventsAfter, false);
context.setPaginateToken(jsonContext.end, false); context.setPaginateToken(jsonContext.end, false);
return new SearchResult(jsonObj.rank, context); 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. limitations under the License.
*/ */
import { MatrixClient, RoomEvent } from "../matrix"; import { MatrixClient, MatrixEventEvent, RelationType, RoomEvent } from "../matrix";
import { TypedReEmitter } from "../ReEmitter"; import { TypedReEmitter } from "../ReEmitter";
import { IRelationsRequestOpts } from "../@types/requests"; import { IRelationsRequestOpts } from "../@types/requests";
import { IThreadBundledRelationship, MatrixEvent } from "./event"; import { IThreadBundledRelationship, MatrixEvent } from "./event";
@@ -24,6 +24,7 @@ import { Room } from './room';
import { TypedEventEmitter } from "./typed-event-emitter"; import { TypedEventEmitter } from "./typed-event-emitter";
import { RoomState } from "./room-state"; import { RoomState } from "./room-state";
import { ServerControlledNamespacedValue } from "../NamespacedValue"; import { ServerControlledNamespacedValue } from "../NamespacedValue";
import { logger } from "../logger";
export enum ThreadEvent { export enum ThreadEvent {
New = "Thread.new", New = "Thread.new",
@@ -69,16 +70,21 @@ export class Thread extends TypedEventEmitter<EmittedEvents, EventHandlerMap> {
public readonly room: Room; public readonly room: Room;
public readonly client: MatrixClient; public readonly client: MatrixClient;
public initialEventsFetched = false; public initialEventsFetched = !Thread.hasServerSideSupport;
public readonly id: string;
constructor( constructor(
public readonly rootEvent: MatrixEvent | undefined, public readonly id: string,
public rootEvent: MatrixEvent | undefined,
opts: IThreadOpts, opts: IThreadOpts,
) { ) {
super(); super();
if (!opts?.room) {
// Logging/debugging for https://github.com/vector-im/element-web/issues/22141
// Hope is that we end up with a more obvious stack trace.
throw new Error("element-web#22141: A thread requires a room in order to function");
}
this.room = opts.room; this.room = opts.room;
this.client = opts.client; this.client = opts.client;
this.timelineSet = new EventTimelineSet(this.room, { this.timelineSet = new EventTimelineSet(this.room, {
@@ -93,20 +99,38 @@ export class Thread extends TypedEventEmitter<EmittedEvents, EventHandlerMap> {
RoomEvent.TimelineReset, RoomEvent.TimelineReset,
]); ]);
// If we weren't able to find the root event, it's probably missing this.room.on(MatrixEventEvent.BeforeRedaction, this.onBeforeRedaction);
// and we define the thread ID from one of the thread relation this.room.on(RoomEvent.Redaction, this.onRedaction);
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(RoomEvent.LocalEchoUpdated, this.onEcho); 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 { public static setServerSideSupport(hasServerSideSupport: boolean, useStable: boolean): void {
@@ -118,26 +142,59 @@ export class Thread extends TypedEventEmitter<EmittedEvents, EventHandlerMap> {
} }
} }
private onEcho = (event: MatrixEvent) => { private onBeforeRedaction = (event: MatrixEvent, redaction: MatrixEvent) => {
if (this.timelineSet.eventIdToTimeline(event.getId())) { 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); 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 { public get roomState(): RoomState {
return this.room.getLiveTimeline().getState(EventTimeline.FORWARDS); return this.room.getLiveTimeline().getState(EventTimeline.FORWARDS);
} }
private addEventToTimeline(event: MatrixEvent, toStartOfTimeline: boolean): void { 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())) { if (!this.findEventById(event.getId())) {
this.timelineSet.addEventToTimeline( this.timelineSet.addEventToTimeline(
event, 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 * Add an event to the thread and updates
* the tail/root references if needed * the tail/root references if needed
@@ -156,64 +218,60 @@ export class Thread extends TypedEventEmitter<EmittedEvents, EventHandlerMap> {
* @param event The event to add * @param event The event to add
* @param {boolean} toStartOfTimeline whether the event is being added * @param {boolean} toStartOfTimeline whether the event is being added
* to the start (and not the end) of the timeline. * 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 // Add all incoming events to the thread's timeline set when there's no server support
if (!Thread.hasServerSideSupport) { if (!Thread.hasServerSideSupport) {
// all the relevant membership info to hydrate events with a sender // all the relevant membership info to hydrate events with a sender
// is held in the main room timeline // is held in the main room timeline
// We want to fetch the room state from there and pass it down to this thread // 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 // timeline set to let it reconcile an event with its relevant RoomMember
event.setThread(this);
this.addEventToTimeline(event, toStartOfTimeline); this.addEventToTimeline(event, toStartOfTimeline);
await this.client.decryptEventIfNeeded(event, {}); this.client.decryptEventIfNeeded(event, {});
} } else if (!toStartOfTimeline &&
this.initialEventsFetched &&
if (Thread.hasServerSideSupport && this.initialEventsFetched) { event.localTimestamp > this.lastReply()?.localTimestamp
if (event.localTimestamp > this.lastReply().localTimestamp) { ) {
this.fetchEditsWhereNeeded(event);
this.addEventToTimeline(event, false); 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 // 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 // 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++; this.replyCount++;
} }
// There is a risk that the `localTimestamp` approximation will not be accurate if (emit) {
// 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);
}
}
this.emit(ThreadEvent.Update, this); this.emit(ThreadEvent.Update, this);
} }
}
private initialiseThread(rootEvent: MatrixEvent | undefined): void { private getRootEventBundledRelationship(rootEvent = this.rootEvent): IThreadBundledRelationship {
const bundledRelationship = rootEvent return rootEvent?.getServerAggregatedRelation<IThreadBundledRelationship>(THREAD_RELATION_TYPE.name);
?.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) { if (Thread.hasServerSideSupport && bundledRelationship) {
this.replyCount = bundledRelationship.count; this.replyCount = bundledRelationship.count;
@@ -221,27 +279,34 @@ export class Thread extends TypedEventEmitter<EmittedEvents, EventHandlerMap> {
const event = new MatrixEvent(bundledRelationship.latest_event); const event = new MatrixEvent(bundledRelationship.latest_event);
this.setEventMetadata(event); this.setEventMetadata(event);
event.setThread(this);
this.lastEvent = event; this.lastEvent = event;
}
this.fetchEditsWhereNeeded(event);
} }
public async fetchInitialEvents(): Promise<{ this.emit(ThreadEvent.Update, this);
originalEvent: MatrixEvent;
events: MatrixEvent[];
nextBatch?: string;
prevBatch?: string;
} | null> {
if (!Thread.hasServerSideSupport) {
this.initialEventsFetched = true;
return null;
} }
try {
const response = await this.fetchEvents(); // XXX: Workaround for https://github.com/matrix-org/matrix-spec-proposals/pull/2676/files#r827240084
this.initialEventsFetched = true; private async fetchEditsWhereNeeded(...events: MatrixEvent[]): Promise<unknown> {
return response; return Promise.all(events.filter(e => e.isEncrypted()).map((event: MatrixEvent) => {
} catch (e) { return this.client.relations(this.roomId, event.getId(), RelationType.Replace, event.getType(), {
return null; 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 { 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 * Finds an event by ID in the current thread
*/ */
public findEventById(eventId: string) { 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); 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 * A getter for the last event added to the thread
*/ */
public get replyToEvent(): MatrixEvent { public get replyToEvent(): MatrixEvent {
return this.lastEvent; return this.lastEvent ?? this.lastReply();
} }
public get events(): MatrixEvent[] { public get events(): MatrixEvent[] {
@@ -304,7 +374,7 @@ export class Thread extends TypedEventEmitter<EmittedEvents, EventHandlerMap> {
return this.timelineSet.getLiveTimeline(); 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; originalEvent: MatrixEvent;
events: MatrixEvent[]; events: MatrixEvent[];
nextBatch?: string; nextBatch?: string;
@@ -329,12 +399,14 @@ export class Thread extends TypedEventEmitter<EmittedEvents, EventHandlerMap> {
events = [...events, originalEvent]; events = [...events, originalEvent];
} }
await this.fetchEditsWhereNeeded(...events);
await Promise.all(events.map(event => { await Promise.all(events.map(event => {
this.setEventMetadata(event); this.setEventMetadata(event);
return this.client.decryptEventIfNeeded(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( this.timelineSet.addEventsToTimeline(
events, events,

View File

@@ -27,8 +27,6 @@ export enum UserEvent {
Presence = "User.presence", Presence = "User.presence",
CurrentlyActive = "User.currentlyActive", CurrentlyActive = "User.currentlyActive",
LastPresenceTs = "User.lastPresenceTs", LastPresenceTs = "User.lastPresenceTs",
/* @deprecated */
_UnstableStatusMessage = "User.unstable_statusMessage",
} }
export type UserEventHandlerMap = { export type UserEventHandlerMap = {
@@ -37,7 +35,6 @@ export type UserEventHandlerMap = {
[UserEvent.Presence]: (event: MatrixEvent | undefined, user: User) => void; [UserEvent.Presence]: (event: MatrixEvent | undefined, user: User) => void;
[UserEvent.CurrentlyActive]: (event: MatrixEvent | undefined, user: User) => void; [UserEvent.CurrentlyActive]: (event: MatrixEvent | undefined, user: User) => void;
[UserEvent.LastPresenceTs]: (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> { export class User extends TypedEventEmitter<UserEvent, UserEventHandlerMap> {
@@ -59,8 +56,6 @@ export class User extends TypedEventEmitter<UserEvent, UserEventHandlerMap> {
presence: null, presence: null,
profile: 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 * 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. * when a user was last active.
* @prop {Boolean} currentlyActive Whether we should consider lastActiveAgo to be * @prop {Boolean} currentlyActive Whether we should consider lastActiveAgo to be
* an approximation and that the user should be seen as active 'now' * 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 {Object} events The events describing this user.
* @prop {MatrixEvent} events.presence The m.presence event for 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 { public getLastActiveTs(): number {
return this.lastPresenceTs - this.lastActiveAgo; 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, PushRuleSet,
TweakName, TweakName,
} from "./@types/PushRules"; } from "./@types/PushRules";
import { EventType } from "./@types/event";
/** /**
* @module pushprocessor * @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, // 2. We often want to start using push rules ahead of the server supporting them,
// and so we can put them here. // and so we can put them here.
const DEFAULT_OVERRIDE_RULES: IPushRule[] = [ const DEFAULT_OVERRIDE_RULES: IPushRule[] = [
{
// For homeservers which don't support MSC1930 yet
rule_id: ".m.rule.tombstone",
default: true,
enabled: true,
conditions: [
{
kind: ConditionKind.EventMatch,
key: "type",
pattern: "m.room.tombstone",
},
{
kind: ConditionKind.EventMatch,
key: "state_key",
pattern: "",
},
],
actions: [
PushRuleActionName.Notify,
{
set_tweak: TweakName.Highlight,
value: true,
},
],
},
{ {
// For homeservers which don't support MSC2153 yet // For homeservers which don't support MSC2153 yet
rule_id: ".m.rule.reaction", rule_id: ".m.rule.reaction",
@@ -96,6 +72,20 @@ const DEFAULT_OVERRIDE_RULES: IPushRule[] = [
PushRuleActionName.DontNotify, 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 { export interface IActionsObject {
@@ -300,7 +290,7 @@ export class PushProcessor {
const memberCount = room.currentState.getJoinedMemberCount(); const memberCount = room.currentState.getJoinedMemberCount();
const m = cond.is.match(/^([=<>]*)([0-9]*)$/); const m = cond.is.match(/^([=<>]*)(\d*)$/);
if (!m) { if (!m) {
return false; return false;
} }

View File

@@ -15,19 +15,18 @@ limitations under the License.
*/ */
import { EventType } from "../@types/event"; import { EventType } from "../@types/event";
import { Group } from "../models/group";
import { Room } from "../models/room"; import { Room } from "../models/room";
import { User } from "../models/user"; import { User } from "../models/user";
import { IEvent, MatrixEvent } from "../models/event"; import { MatrixEvent } from "../models/event";
import { Filter } from "../filter"; import { Filter } from "../filter";
import { RoomSummary } from "../models/room-summary"; import { RoomSummary } from "../models/room-summary";
import { IMinimalEvent, IGroups, IRooms, ISyncResponse } from "../sync-accumulator"; import { IMinimalEvent, IRooms, ISyncResponse } from "../sync-accumulator";
import { IStartClientOpts } from "../client"; import { IStartClientOpts } from "../client";
import { IStateEventWithRoomId } from "../@types/search";
export interface ISavedSync { export interface ISavedSync {
nextBatch: string; nextBatch: string;
roomsData: IRooms; roomsData: IRooms;
groupsData: IGroups;
accountData: IMinimalEvent[]; accountData: IMinimalEvent[];
} }
@@ -38,7 +37,11 @@ export interface ISavedSync {
export interface IStore { export interface IStore {
readonly accountData: Record<string, MatrixEvent>; // type : content 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>; isNewlyCreated(): Promise<boolean>;
/** /**
@@ -53,28 +56,6 @@ export interface IStore {
*/ */
setSyncToken(token: string): void; 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. * No-op.
* @param {Room} room * @param {Room} room
@@ -128,7 +109,7 @@ export interface IStore {
/** /**
* No-op. * No-op.
* @param {Room} room * @param {Room} room
* @param {integer} limit * @param {number} limit
* @return {Array} * @return {Array}
*/ */
scrollback(room: Room, limit: number): MatrixEvent[]; scrollback(room: Room, limit: number): MatrixEvent[];
@@ -228,9 +209,9 @@ export interface IStore {
*/ */
deleteAllData(): Promise<void>; 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>; clearOutOfBandMembers(roomId: string): Promise<void>;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -20,14 +20,14 @@ limitations under the License.
*/ */
import { EventType } from "../@types/event"; import { EventType } from "../@types/event";
import { Group } from "../models/group";
import { Room } from "../models/room"; import { Room } from "../models/room";
import { User } from "../models/user"; import { User } from "../models/user";
import { IEvent, MatrixEvent } from "../models/event"; import { MatrixEvent } from "../models/event";
import { Filter } from "../filter"; import { Filter } from "../filter";
import { ISavedSync, IStore } from "./index"; import { ISavedSync, IStore } from "./index";
import { RoomSummary } from "../models/room-summary"; import { RoomSummary } from "../models/room-summary";
import { ISyncResponse } from "../sync-accumulator"; import { ISyncResponse } from "../sync-accumulator";
import { IStateEventWithRoomId } from "../@types/search";
/** /**
* Construct a stub store. This does no-ops on most store methods. * Construct a stub store. This does no-ops on most store methods.
@@ -58,32 +58,6 @@ export class StubStore implements IStore {
this.fromToken = token; 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. * No-op.
* @param {Room} room * @param {Room} room
@@ -149,7 +123,7 @@ export class StubStore implements IStore {
/** /**
* No-op. * No-op.
* @param {Room} room * @param {Room} room
* @param {integer} limit * @param {number} limit
* @return {Array} * @return {Array}
*/ */
public scrollback(room: Room, limit: number): MatrixEvent[] { public scrollback(room: Room, limit: number): MatrixEvent[] {
@@ -269,11 +243,11 @@ export class StubStore implements IStore {
return Promise.resolve(); return Promise.resolve();
} }
public getOutOfBandMembers(): Promise<IEvent[]> { public getOutOfBandMembers(): Promise<IStateEventWithRoomId[]> {
return Promise.resolve(null); return Promise.resolve(null);
} }
public setOutOfBandMembers(roomId: string, membershipEvents: IEvent[]): Promise<void> { public setOutOfBandMembers(roomId: string, membershipEvents: IStateEventWithRoomId[]): Promise<void> {
return Promise.resolve(); return Promise.resolve();
} }

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