Compare commits

..

76 Commits

Author SHA1 Message Date
RiotRobot 70c6a4b567 v17.2.0-rc.1 2022-05-03 15:20:39 +01:00
RiotRobot 5cd615c495 Prepare changelog for v17.2.0-rc.1 2022-05-03 15:20:38 +01:00
Michael Telatynski 88f5ea675d Tweak sonarqube run (#2339) 2022-05-03 14:46:36 +01:00
Michael Telatynski ac5fee0a69 Fix race conditions around threads (#2331) 2022-05-03 14:25:17 +01:00
Michael Telatynski 274d6a9597 Add CI to require labels on PRs for better changelog management (#2337) 2022-05-03 10:34:55 +01:00
Michael Telatynski d190cdc307 Switch coverage to SonarQube (#2334) 2022-05-02 08:32:53 +00:00
Michael Telatynski b896111269 Apply corrections identified by SonarQube (#2336)
* Apply corrections identified by SonarQube

* Apply corrections identified by SonarQube

* Make type more flexible
2022-05-02 03:23:17 +01:00
Aaron Raimist 6137afeb28 Mark room versions 7, 8, and 9 as safe (#2318)
* Mark room versions 7, 8, and 9 as safe

Workaround for https://github.com/vector-im/element-web/issues/20166 https://github.com/vector-im/element-web/issues/11820

* Update KNOWN_SAFE_ROOM_VERSION to room version 9
2022-05-01 19:10:09 -06:00
Michael Telatynski 2ebf33544f Try SonarCloud (#2330) 2022-04-29 11:07:35 +01:00
Michael Telatynski 45f1991d4e Correctly specify minimum Node version (#2324) 2022-04-29 11:02:05 +01:00
Michael Telatynski aa283a3c9e Update CONTRIBUTING.md (#2329) 2022-04-28 15:48:21 +01:00
Kerry 34ee566d88 Live location sharing: handle encrypted messages in processBeaconEvents (#2327)
* handle encrypted locations

Signed-off-by: Kerry Archibald <kerrya@element.io>

* fix processBeaconEvents to handle encrypted events

Signed-off-by: Kerry Archibald <kerrya@element.io>
2022-04-28 16:42:37 +02:00
RiotRobot 3649cf46d3 Resetting package fields for development 2022-04-26 11:34:42 +01:00
RiotRobot 8f00eb6771 Merge branch 'master' into develop 2022-04-26 11:34:42 +01:00
Michael Telatynski 01ec51891a Merge remote-tracking branch 'origin/release-v17.1.0'
# Conflicts:
#	CHANGELOG.md
#	package.json
#	src/client.ts
#	src/models/room.ts
#	src/models/thread.ts
#	src/sync.ts
2022-04-26 11:31:20 +01:00
RiotRobot 738876a563 v17.1.0 2022-04-26 11:13:47 +01:00
RiotRobot 2d9b4b3896 Prepare changelog for v17.1.0 2022-04-26 11:13:46 +01:00
Michael Telatynski ba06e430c4 Ignore m.replace relations on state events, they're invalid (#2306)
* Ignore m.replace relations on state events, they're invalid

* Add tests

* Fix test

Co-authored-by: Kerry <kerrya@element.io>
2022-04-26 10:02:36 +00:00
Michael Telatynski ac08e52410 De-duplicate code and fix types (#2319) 2022-04-25 17:15:35 +01:00
David Baker 1bb82108b7 fix example in readme (#2315)
Co-authored-by: Matthew Hodgson <matthew@matrix.org>
2022-04-22 12:04:31 +01:00
Michael Telatynski e133005b44 Don't decrement the length count of a thread when root redacted (#2314) 2022-04-21 16:02:42 +00:00
Michael Telatynski c0cb66233a Prevent attempt to create thread with id "undefined" (#2308) 2022-04-21 08:01:24 +01:00
Michael Telatynski d82cdd3b19 Move more stuff from BK to GHA (#2309) 2022-04-20 18:09:33 +01:00
Michael Telatynski 6a51b02bed Move more stuff from BK to GHA (#2307)
* Use consistent indentation in GHA yaml files

* Prefer setup-node's dep caching

* Tidy up test_coverage job

* Move js sdk lint ci to gha

* notify react-sdk of develop merges

* Name the jobs

* test

* Update secrets

* Fixup
2022-04-20 16:25:58 +01:00
Michael Telatynski 47d2c063fa Upgrade dependencies (#2304) 2022-04-20 13:53:39 +02:00
Michael Telatynski 540514c805 Update threads handling for replies-to-thread-responses as per MSC update (#2305)
* Update threads handling for replies-to-thread-responses as per MSC update

* Update tests to match new behaviour
2022-04-19 18:15:06 +02:00
Faye Duxovni db58a66e19 Add method for checking whether our other devices are cross-signed, even when this device isn't (#2288) 2022-04-19 15:02:17 +01:00
RiotRobot 91a67bdac3 v17.1.0-rc.1 2022-04-19 14:41:27 +01:00
RiotRobot 48d3fce22d Prepare changelog for v17.1.0-rc.1 2022-04-19 14:41:26 +01:00
Hugh Nimmo-Smith 9f4598638d Add MatrixClient.doesServerSupportLogoutDevices() for MSC2457 (#2297) 2022-04-15 10:27:12 +01:00
CommanderRoot cde935629d Replace deprecated String.prototype.substr() (#2298)
.substr() is deprecated so we replace it with .slice() which works similarily but isn't deprecated

Signed-off-by: Tobias Speicher <rootcommander@gmail.com>
2022-04-14 15:23:27 -06:00
Kerry fbe81ad823 Live location sharing - expose room liveBeaconIds (#2296)
* updates rooms live beacon ids on destroy

Signed-off-by: Kerry Archibald <kerrya@element.io>

* expose live beacons ids

Signed-off-by: Kerry Archibald <kerrya@element.io>

* room state emit all the time on beacon liveness change

Signed-off-by: Kerry Archibald <kerrya@element.io>

* update comment

Signed-off-by: Kerry Archibald <kerrya@element.io>
2022-04-14 15:04:43 +02:00
Michael Telatynski c3d7a4977a Stop tracking threads if threads support is disabled (#2295) 2022-04-13 16:23:15 +01:00
Robin 9aab917836 Fix coverage diffs for PRs that aren't up to date, take 3 (#2294) 2022-04-12 20:22:14 -04:00
Jonathan de Jong 54661cca95 Change _unstable_getSharedRooms to _unstable_getMutualRooms (#2271)
* change shared to mutual

* revert name change

* use new unstable feature flag

* support both unstable endpoints
2022-04-12 17:48:48 -06:00
Michael Telatynski b58d09aa9a Prevent duplicated re-emitter setups in event-mapper (#2293) 2022-04-12 09:42:55 +01:00
RiotRobot 3b33237e51 Resetting package fields for development 2022-04-11 16:31:44 +01:00
RiotRobot b8ec62e786 Merge master 2022-04-11 16:31:21 +01:00
RiotRobot 59763a84f8 v17.0.0 2022-04-11 16:21:09 +01:00
RiotRobot 1375a4849c Prepare changelog for v17.0.0 2022-04-11 16:21:09 +01:00
RiotRobot c0e3ad4b83 v17.0.0-rc.3 2022-04-11 11:32:30 +01:00
RiotRobot 5305e373a0 Prepare changelog for v17.0.0-rc.3 2022-04-11 11:32:30 +01:00
Michael Telatynski 877e3df71b [Release] Port multiple threads fixes (#2292)
Co-authored-by: Germain Souquet <germains@element.io>
2022-04-11 11:29:22 +01:00
Michael Telatynski 286500e335 Fix issues around echo & redaction handling in threads (#2286) 2022-04-11 08:58:13 +01:00
Hugh Nimmo-Smith 5937e6a6a8 Support for MSC2457 logout_devices param for setPassword() (#2285) 2022-04-09 14:07:33 +01:00
Eric Eastwood c6c22e394b Ignore eslint --fix formatting changes in git blame (#2287)
* Ignore eslint --fix formatting changes in git blame

Docs: https://docs.github.com/en/repositories/working-with-files/using-files/viewing-a-file#ignore-commits-in-the-blame-view

* Conform on white-space spelling
2022-04-08 23:52:05 -06:00
Callum Brown 378802a5ab Stabilise token authenticated registration support (#2181)
* Stabilise token authenticated registration support

Token authenticated registration was added to the Matrix specification in v1.2:
https://spec.matrix.org/v1.2/client-server-api/#token-authenticated-registration

Signed-off-by: Callum Brown <callum@calcuode.com>

* Backwards compatibility with unstable auth type

Servers are not yet widely updated with support for the stable
version of the registration token UIA type.
Clients should check if the authentication type is either
`RegistrationToken` or `UnstableRegistrationToken`.

Signed-off-by: Callum Brown <callum@calcuode.com>
2022-04-08 22:53:57 -06:00
Kerry f963feab0f Live location sharing - Aggregate beacon locations on beacons (#2268)
* add timestamp sorting util

Signed-off-by: Kerry Archibald <kerrya@element.io>

* basic wiring

Signed-off-by: Kerry Archibald <kerrya@element.io>

* quick handle for redacted beacons

Signed-off-by: Kerry Archibald <kerrya@element.io>

* remove fdescribe

Signed-off-by: Kerry Archibald <kerrya@element.io>

* test adding locations

Signed-off-by: Kerry Archibald <kerrya@element.io>

* tidy comments

Signed-off-by: Kerry Archibald <kerrya@element.io>

* test client

Signed-off-by: Kerry Archibald <kerrya@element.io>

* fix monitorLiveness for update

Signed-off-by: Kerry Archibald <kerrya@element.io>

* lint

Signed-off-by: Kerry Archibald <kerrya@element.io>
2022-04-08 12:26:05 +01:00
RiotRobot d705a0ed9e v17.0.0-rc.2 2022-04-08 12:08:04 +01:00
RiotRobot 7023fb1c99 Prepare changelog for v17.0.0-rc.2 2022-04-08 12:08:03 +01:00
RiotRobot fe36dafcc7 v3.42.2-rc.3 2022-04-08 10:40:56 +01:00
RiotRobot 04a6dbbedf Prepare changelog for v3.42.2-rc.3 2022-04-08 10:40:56 +01:00
RiotRobot 29b427aab7 v17.0.0-rc.1 2022-04-08 10:36:14 +01:00
RiotRobot 643b783dec Prepare changelog for v17.0.0-rc.1 2022-04-08 10:36:13 +01:00
Germain 872033a552 Port #2283 to release (#2284) 2022-04-08 10:34:09 +01:00
Germain 6d0f4e537e Fix notification panel not loading (#2283) 2022-04-08 10:34:01 +01:00
Kerry 781fdf4fdc Live location sharing - update beacon_info implementation to latest MSC (#2281)
* remove M_BEACON_INFO_VARIABLE

Signed-off-by: Kerry Archibald <kerrya@element.io>

* create beacon_info events with non-variable event type

Signed-off-by: Kerry Archibald <kerrya@element.io>

* remove isBeaconInfoEventType

Signed-off-by: Kerry Archibald <kerrya@element.io>

* refer to msc3673 instead of msc3489

Signed-off-by: Kerry Archibald <kerrya@element.io>

* remove event type suffix

Signed-off-by: Kerry Archibald <kerrya@element.io>

* update beacon identifier to use state key

Signed-off-by: Kerry Archibald <kerrya@element.io>

* fix beacon spec

Signed-off-by: Kerry Archibald <kerrya@element.io>

* fix room-state tests

Signed-off-by: Kerry Archibald <kerrya@element.io>

* add beacon identifier

Signed-off-by: Kerry Archibald <kerrya@element.io>

* dont allow update to older beacon event

Signed-off-by: Kerry Archibald <kerrya@element.io>

* lint

Signed-off-by: Kerry Archibald <kerrya@element.io>

* unnest beacon_info content

Signed-off-by: Kerry Archibald <kerrya@element.io>

* lint

Signed-off-by: Kerry Archibald <kerrya@element.io>

* check redaction event id

Signed-off-by: Kerry Archibald <kerrya@element.io>
2022-04-08 10:50:06 +02:00
Michael Telatynski dde4285cdf Fix handling of threaded messages around edits & echoes (#2267) 2022-04-07 13:46:50 +01:00
Robin 3322b47b6d Make self membership less prone to races (#2277) 2022-04-06 08:49:09 -04:00
RiotRobot d457fd6db0 v16.0.2-rc.1 2022-04-05 18:07:38 +01:00
RiotRobot 03f4700bd7 Prepare changelog for v16.0.2-rc.1 2022-04-05 18:07:37 +01:00
Robin b8321290f8 Add Element video room type (#2273) 2022-04-04 10:29:35 -04:00
Kerry 71b7521f42 Live location sharing - handle redacted beacons (#2269)
* emit beacon destroy event on destroy

Signed-off-by: Kerry Archibald <kerrya@element.io>

* handle redacted beacon events in room-state

Signed-off-by: Kerry Archibald <kerrya@element.io>

* empty line

Signed-off-by: Kerry Archibald <kerrya@element.io>
2022-04-04 10:17:49 +02:00
adamvy 106f7beb48 Fix getSessionsNeedingBackup() limit support (#2270) 2022-04-01 19:56:07 -06:00
Michael Telatynski d6f1c6cfdc Fix thread & main timeline partitioning logic (#2264) 2022-03-31 13:57:37 +01:00
Robin 4360ae7ff8 Fix coverage diffs for PRs that aren't up to date (#2263) 2022-03-30 08:02:14 -04:00
Michael Telatynski 26cbe02a7f Instantiate Thread objects when running fetchRoomThreads (#2262) 2022-03-29 15:28:08 +01:00
Michael Telatynski 85b8d4f83a Fix issues with /search and /context API handling for threads (#2261) 2022-03-29 09:24:45 +01:00
RiotRobot bdc3da1fac Resetting package fields for development 2022-03-28 14:38:44 +01:00
RiotRobot 95fcd5aa26 Merge branch 'master' into develop 2022-03-28 14:38:43 +01:00
Andy Balaam f03a391f80 Prevent exception 'Unable to set up secret storage' (#2260) 2022-03-28 11:48:34 +01:00
Andy Balaam e90f12ee32 Port codecov improvements matrix-react-sdk -> matrix-js-sdk (#2258) 2022-03-25 09:09:35 +00:00
Michael Telatynski c541b3f1ce Fix issues with duplicated MatrixEvent objects around threads (#2256) 2022-03-24 12:24:19 +00:00
Germain 6192325fe0 Thread list ordering by last reply (#2253) 2022-03-23 14:43:30 +00:00
Travis Ralston d0b964837f Remove groups (#2234)
This API is due for removal in Synapse and has been deprecated for a very long time. People should move away from it soon, but just in case we'll declare this as a breaking change.

There is no impact on sync storage here: we happen to store the data in a way that is backwards-compatible for group-supporting clients, and the code guards against missing data from the stores. So, if someone were to revert, they'd be "safe" (probably lose all their group info, but the app wouldn't crash).
2022-03-22 22:20:32 +00:00
Robin 65316ffb5c Voice rooms prototype (#2249)
* Support call room type from MSC3417

Signed-off-by: Robin Townsend <robin@robin.town>

* Make it more clear that call room type is unstable

Signed-off-by: Robin Townsend <robin@robin.town>
2022-03-22 16:14:23 -06:00
75 changed files with 4652 additions and 3053 deletions
+3
View File
@@ -21,3 +21,6 @@ insert_final_newline = true
indent_style = space
indent_size = 4
trim_trailing_whitespace = true
[*.{yml,yaml}]
indent_size = 2
+41
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
View File
@@ -1,6 +0,0 @@
comment:
layout: "diff, files"
behavior: default
require_changes: false
require_base: no
require_head: no
+14
View File
@@ -0,0 +1,14 @@
name: Notify Downstream Projects
on:
push:
branches: [ develop ]
jobs:
notify-matrix-react-sdk:
runs-on: ubuntu-latest
steps:
- name: Notify matrix-react-sdk repo that a new SDK build is on develop so it can CI against it
uses: peter-evans/repository-dispatch@v1
with:
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
repository: vector-im/element-web
event-type: upstream-sdk-notify
-12
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 }}
+24
View File
@@ -0,0 +1,24 @@
name: Pull Request
on:
pull_request_target:
types: [ opened, edited, labeled, unlabeled ]
jobs:
changelog:
name: Preview Changelog
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!"
+47
View File
@@ -0,0 +1,47 @@
name: SonarQube
on:
workflow_run:
workflows: [ "Tests" ]
types:
- completed
jobs:
sonarqube:
name: SonarQube
runs-on: ubuntu-latest
if: github.event.workflow_run.conclusion == 'success'
steps:
- uses: actions/checkout@v2
with:
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
# There's a 'download artifact' action, but it hasn't been updated for the workflow_run action
# (https://github.com/actions/download-artifact/issues/60) so instead we get this mess:
- name: Download Coverage Report
uses: actions/github-script@v3.1.0
with:
script: |
const artifacts = await github.actions.listWorkflowRunArtifacts({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: ${{ github.event.workflow_run.id }},
});
const matchArtifact = artifacts.data.artifacts.filter((artifact) => {
return artifact.name == "coverage"
})[0];
const download = await github.actions.downloadArtifact({
owner: context.repo.owner,
repo: context.repo.repo,
artifact_id: matchArtifact.id,
archive_format: 'zip',
});
const fs = require('fs');
fs.writeFileSync('${{github.workspace}}/coverage.zip', Buffer.from(download.data));
- name: Extract Coverage Report
run: unzip -d coverage coverage.zip && rm coverage.zip
- name: SonarCloud Scan
uses: SonarSource/sonarcloud-github-action@master
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
+53
View File
@@ -0,0 +1,53 @@
name: Static Analysis
on:
pull_request: { }
push:
branches: [ develop, master ]
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"
-19
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
+34
View File
@@ -0,0 +1,34 @@
name: Tests
on:
pull_request: { }
push:
branches: [ develop, main, master ]
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
+66 -2
View File
@@ -1,3 +1,61 @@
Changes in [17.2.0-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v17.2.0-rc.1) (2022-05-03)
============================================================================================================
## ✨ 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)).
* 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)
==================================================================================================
@@ -640,7 +698,7 @@ Changes in [11.0.0-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/ta
BREAKING CHANGES
---
* `MatrixCall` and related APIs have been redesigned to support multiple streams
* `MatrixCall` and related APIs have been redesigned to support multiple streams
(see [\#1660](https://github.com/matrix-org/matrix-js-sdk/pull/1660) for more details)
All changes
@@ -1313,7 +1371,7 @@ BREAKING CHANGES
---
* `RoomState` events changed to use a Map instead of an object, which changes the collection APIs available to access them.
All Changes
---
@@ -1988,6 +2046,12 @@ All Changes
* [BREAKING] Refactor the entire build process
[\#1113](https://github.com/matrix-org/matrix-js-sdk/pull/1113)
Changes in [3.42.2-rc.3](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v3.42.2-rc.3) (2022-04-08)
============================================================================================================
## 🐛 Bug Fixes
* Make self membership less prone to races ([\#2277](https://github.com/matrix-org/matrix-js-sdk/pull/2277)). Fixes vector-im/element-web#21661.
Changes in [3.0.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v3.0.0) (2020-01-13)
================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v3.0.0-rc.1...v3.0.0)
+12
View File
@@ -243,3 +243,15 @@ on Git 2.17+ you can mass signoff using rebase:
```
git rebase --signoff origin/develop
```
Merge Strategy
==============
The preferred method for merging pull requests is squash merging to keep the
commit history trim, but it is up to the discretion of the team member merging
the change. 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.
+4 -4
View File
@@ -16,7 +16,7 @@ attached to ``window`` through which you can access the SDK. See below for how t
include libolm to enable end-to-end-encryption.
The browser bundle supports recent versions of browsers. Typically this is ES2015
or `> 0.5%, last 2 versions, Firefox ESR, not dead` if using
or `> 0.5%, last 2 versions, Firefox ESR, not dead` if using
[browserlists](https://github.com/browserslist/browserslist).
Please check [the working browser example](examples/browser) for more information.
@@ -26,11 +26,11 @@ In Node.js
Ensure you have the latest LTS version of Node.js installed.
This SDK targets Node 10 for compatibility, which translates to ES6. If you're using
This SDK targets Node 12 for compatibility, which translates to ES6. If you're using
a bundler like webpack you'll likely have to transpile dependencies, including this
SDK, to match your target browsers.
Using `yarn` instead of `npm` is recommended. Please see the Yarn [install guide](https://classic.yarnpkg.com/en/docs/install)
Using `yarn` instead of `npm` is recommended. Please see the Yarn [install guide](https://classic.yarnpkg.com/en/docs/install)
if you do not have it already.
``yarn add matrix-js-sdk``
@@ -307,7 +307,7 @@ The SDK supports end-to-end encryption via the Olm and Megolm protocols, using
[libolm](https://gitlab.matrix.org/matrix-org/olm). It is left up to the
application to make libolm available, via the ``Olm`` global.
It is also necessary to call ``matrixClient.initCrypto()`` after creating a new
It is also necessary to call ``await matrixClient.initCrypto()`` after creating a new
``MatrixClient`` (but **before** calling ``matrixClient.startClient()``) to
initialise the crypto layer.
+2 -2
View File
@@ -341,7 +341,7 @@ function printLine(event) {
var maxNameWidth = 15;
if (name.length > maxNameWidth) {
name = name.substr(0, maxNameWidth-1) + "\u2026";
name = name.slice(0, maxNameWidth-1) + "\u2026";
}
if (event.getType() === "m.room.message") {
@@ -398,7 +398,7 @@ function print(str, formatter) {
function fixWidth(str, len) {
if (str.length > len) {
return str.substr(0, len-2) + "\u2026";
return str.substring(0, len-2) + "\u2026";
}
else if (str.length < len) {
return str + new Array(len - str.length).join(" ");
+13 -4
View File
@@ -1,7 +1,10 @@
{
"name": "matrix-js-sdk",
"version": "16.0.1",
"version": "17.2.0-rc.1",
"description": "Matrix Client-Server SDK for Javascript",
"engines": {
"node": ">=12.9.0"
},
"scripts": {
"prepublishOnly": "yarn build",
"start": "echo THIS IS FOR LEGACY PURPOSES ONLY. && babel src -w -s -d lib --verbose --extensions \".ts,.js\"",
@@ -97,6 +100,7 @@
"fake-indexeddb": "^3.1.2",
"jest": "^26.6.3",
"jest-localstorage-mock": "^2.4.6",
"jest-sonar-reporter": "^2.0.0",
"jsdoc": "^3.6.6",
"matrix-mock-request": "^1.2.3",
"rimraf": "^3.0.2",
@@ -113,9 +117,14 @@
"<rootDir>/src/**/*.{js,ts}"
],
"coverageReporters": [
"text",
"json"
]
"text-summary",
"lcov"
],
"testResultsProcessor": "jest-sonar-reporter"
},
"jestSonar": {
"reportPath": "coverage",
"sonar56x": true
},
"typings": "./lib/index.d.ts"
}
+21
View File
@@ -0,0 +1,21 @@
sonar.projectKey=matrix-js-sdk
sonar.organization=matrix-org
# This is the name and version displayed in the SonarCloud UI.
#sonar.projectName=matrix-js-sdk
#sonar.projectVersion=1.0
# Path is relative to the sonar-project.properties file. Replace "\" by "/" on Windows.
#sonar.sources=.
# 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/*.ts
sonar.testExecutionReportPaths=coverage/test-report.xml
+72 -11
View File
@@ -2,6 +2,7 @@ import * as utils from "../test-utils/test-utils";
import { EventTimeline } from "../../src/matrix";
import { logger } from "../../src/logger";
import { TestClient } from "../TestClient";
import { Thread, THREAD_RELATION_TYPE } from "../../src/models/thread";
const userId = "@alice:localhost";
const userName = "Alice";
@@ -69,6 +70,27 @@ const EVENTS = [
}),
];
const THREAD_ROOT = utils.mkMessage({
room: roomId,
user: userId,
msg: "thread root",
});
const THREAD_REPLY = utils.mkEvent({
room: roomId,
user: userId,
type: "m.room.message",
content: {
"body": "thread reply",
"msgtype": "m.text",
"m.relates_to": {
// We can't use the const here because we change server support mode for test
rel_type: "io.element.thread",
event_id: THREAD_ROOT.event_id,
},
},
});
// start the client, and wait for it to initialise
function startClient(httpBackend, client) {
httpBackend.when("GET", "/versions").respond(200, {});
@@ -116,9 +138,7 @@ describe("getEventTimeline support", function() {
return startClient(httpBackend, client).then(function() {
const room = client.getRoom(roomId);
const timelineSet = room.getTimelineSets()[0];
expect(function() {
client.getEventTimeline(timelineSet, "event");
}).toThrow();
expect(client.getEventTimeline(timelineSet, "event")).rejects.toBeTruthy();
});
});
@@ -136,16 +156,12 @@ describe("getEventTimeline support", function() {
return startClient(httpBackend, client).then(() => {
const room = client.getRoom(roomId);
const timelineSet = room.getTimelineSets()[0];
expect(function() {
client.getEventTimeline(timelineSet, "event");
}).not.toThrow();
expect(client.getEventTimeline(timelineSet, "event")).rejects.toBeFalsy();
});
});
it("scrollback should be able to scroll back to before a gappy /sync",
function() {
it("scrollback should be able to scroll back to before a gappy /sync", function() {
// need a client with timelineSupport disabled to make this work
let room;
return startClient(httpBackend, client).then(function() {
@@ -229,6 +245,7 @@ describe("MatrixClient event timelines", function() {
afterEach(function() {
httpBackend.verifyNoOutstandingExpectation();
client.stopClient();
Thread.setServerSideSupport(false);
});
describe("getEventTimeline", function() {
@@ -355,8 +372,7 @@ describe("MatrixClient event timelines", function() {
]);
});
it("should join timelines where they overlap a previous /context",
function() {
it("should join timelines where they overlap a previous /context", function() {
const room = client.getRoom(roomId);
const timelineSet = room.getTimelineSets()[0];
@@ -478,6 +494,51 @@ describe("MatrixClient event timelines", function() {
httpBackend.flushAllExpected(),
]);
});
it("should handle thread replies with server support by fetching a contiguous thread timeline", async () => {
client.clientOpts.experimentalThreadSupport = true;
Thread.setServerSideSupport(true);
client.stopClient(); // we don't need the client to be syncing at this time
const room = client.getRoom(roomId);
const timelineSet = room.getTimelineSets()[0];
httpBackend.when("GET", "/rooms/!foo%3Abar/context/" + encodeURIComponent(THREAD_REPLY.event_id))
.respond(200, function() {
return {
start: "start_token0",
events_before: [],
event: THREAD_REPLY,
events_after: [],
end: "end_token0",
state: [],
};
});
httpBackend.when("GET", "/rooms/!foo%3Abar/event/" + encodeURIComponent(THREAD_ROOT.event_id))
.respond(200, function() {
return THREAD_ROOT;
});
httpBackend.when("GET", "/rooms/!foo%3Abar/relations/" +
encodeURIComponent(THREAD_ROOT.event_id) + "/" +
encodeURIComponent(THREAD_RELATION_TYPE.name) + "?limit=20")
.respond(200, function() {
return {
original_event: THREAD_ROOT,
chunk: [THREAD_REPLY],
next_batch: "next_batch_token0",
prev_batch: "prev_batch_token0",
};
});
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));
expect(timeline.getEvents().find(e => e.getId() === THREAD_REPLY.event_id));
});
});
describe("paginateEventTimeline", function() {
+346 -52
View File
@@ -3,6 +3,7 @@ import { CRYPTO_ENABLED } from "../../src/client";
import { MatrixEvent } from "../../src/models/event";
import { Filter, MemoryStore, Room } from "../../src/matrix";
import { TestClient } from "../TestClient";
import { THREAD_RELATION_TYPE } from "../../src/models/thread";
describe("MatrixClient", function() {
let client = null;
@@ -14,9 +15,7 @@ describe("MatrixClient", function() {
beforeEach(function() {
store = new MemoryStore();
const testClient = new TestClient(userId, "aliceDevice", accessToken, undefined, {
store: store,
});
const testClient = new TestClient(userId, "aliceDevice", accessToken, undefined, { store });
httpBackend = testClient.httpBackend;
client = testClient.client;
});
@@ -146,12 +145,14 @@ describe("MatrixClient", function() {
describe("joinRoom", function() {
it("should no-op if you've already joined a room", function() {
const roomId = "!foo:bar";
const room = new Room(roomId, userId);
const room = new Room(roomId, client, userId);
client.fetchRoomEvent = () => Promise.resolve({});
room.addLiveEvents([
utils.mkMembership({
user: userId, room: roomId, mship: "join", event: true,
}),
]);
httpBackend.verifyNoOutstandingRequests();
store.storeRoom(room);
client.joinRoom(roomId);
httpBackend.verifyNoOutstandingRequests();
@@ -244,14 +245,15 @@ describe("MatrixClient", function() {
});
describe("searching", function() {
const response = {
search_categories: {
room_events: {
count: 24,
results: {
"$flibble:localhost": {
it("searchMessageText should perform a /search for room_events", function() {
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",
@@ -260,13 +262,11 @@ describe("MatrixClient", function() {
msgtype: "m.text",
},
},
},
}],
},
},
},
};
};
it("searchMessageText should perform a /search for room_events", function(done) {
client.searchMessageText({
query: "monkeys",
});
@@ -280,8 +280,171 @@ describe("MatrixClient", function() {
});
}).respond(200, response);
httpBackend.flush().then(function() {
done();
return httpBackend.flush();
});
describe("should filter out context from different timelines (threads)", () => {
it("filters out thread replies when result is in the main timeline", async () => {
const response = {
search_categories: {
room_events: {
count: 24,
results: [{
rank: 0.1,
result: {
event_id: "$flibble:localhost",
type: "m.room.message",
user_id: "@alice:localhost",
room_id: "!feuiwhf:localhost",
content: {
body: "main timeline",
msgtype: "m.text",
},
},
context: {
events_after: [{
event_id: "$ev-after:server",
type: "m.room.message",
user_id: "@alice:localhost",
room_id: "!feuiwhf:localhost",
content: {
"body": "thread reply",
"msgtype": "m.text",
"m.relates_to": {
"event_id": "$some-thread:server",
"rel_type": THREAD_RELATION_TYPE.name,
},
},
}],
events_before: [{
event_id: "$ev-before:server",
type: "m.room.message",
user_id: "@alice:localhost",
room_id: "!feuiwhf:localhost",
content: {
body: "main timeline again",
msgtype: "m.text",
},
}],
},
}],
},
},
};
const data = {
results: [],
highlights: [],
};
client.processRoomEventsSearch(data, response);
expect(data.results).toHaveLength(1);
expect(data.results[0].context.timeline).toHaveLength(2);
expect(data.results[0].context.timeline.find(e => e.getId() === "$ev-after:server")).toBeFalsy();
});
it("filters out thread replies from threads other than the thread the result replied to", () => {
const response = {
search_categories: {
room_events: {
count: 24,
results: [{
rank: 0.1,
result: {
event_id: "$flibble:localhost",
type: "m.room.message",
user_id: "@alice:localhost",
room_id: "!feuiwhf:localhost",
content: {
"body": "thread 1 reply 1",
"msgtype": "m.text",
"m.relates_to": {
"event_id": "$thread1:server",
"rel_type": THREAD_RELATION_TYPE.name,
},
},
},
context: {
events_after: [{
event_id: "$ev-after:server",
type: "m.room.message",
user_id: "@alice:localhost",
room_id: "!feuiwhf:localhost",
content: {
"body": "thread 2 reply 2",
"msgtype": "m.text",
"m.relates_to": {
"event_id": "$thread2:server",
"rel_type": THREAD_RELATION_TYPE.name,
},
},
}],
events_before: [],
},
}],
},
},
};
const data = {
results: [],
highlights: [],
};
client.processRoomEventsSearch(data, response);
expect(data.results).toHaveLength(1);
expect(data.results[0].context.timeline).toHaveLength(1);
expect(data.results[0].context.timeline.find(e => e.getId() === "$flibble:localhost")).toBeTruthy();
});
it("filters out main timeline events when result is a thread reply", () => {
const response = {
search_categories: {
room_events: {
count: 24,
results: [{
rank: 0.1,
result: {
event_id: "$flibble:localhost",
type: "m.room.message",
user_id: "@alice:localhost",
room_id: "!feuiwhf:localhost",
content: {
"body": "thread 1 reply 1",
"msgtype": "m.text",
"m.relates_to": {
"event_id": "$thread1:server",
"rel_type": THREAD_RELATION_TYPE.name,
},
},
},
context: {
events_after: [{
event_id: "$ev-after:server",
type: "m.room.message",
user_id: "@alice:localhost",
room_id: "!feuiwhf:localhost",
content: {
"body": "main timeline",
"msgtype": "m.text",
},
}],
events_before: [],
},
}],
},
},
};
const data = {
results: [],
highlights: [],
};
client.processRoomEventsSearch(data, response);
expect(data.results).toHaveLength(1);
expect(data.results[0].context.timeline).toHaveLength(1);
expect(data.results[0].context.timeline.find(e => e.getId() === "$flibble:localhost")).toBeTruthy();
});
});
});
@@ -395,9 +558,14 @@ describe("MatrixClient", function() {
});
describe("partitionThreadedEvents", function() {
let room;
beforeEach(() => {
room = new Room("!STrMRsukXHtqQdSeHa:matrix.org", client, userId);
});
it("returns empty arrays when given an empty arrays", function() {
const events = [];
const [timeline, threaded] = client.partitionThreadedEvents(events);
const [timeline, threaded] = room.partitionThreadedEvents(events);
expect(timeline).toEqual([]);
expect(threaded).toEqual([]);
});
@@ -405,24 +573,24 @@ describe("MatrixClient", function() {
it("copies pre-thread in-timeline vote events onto both timelines", function() {
client.clientOpts = { experimentalThreadSupport: true };
const eventMessageInThread = buildEventMessageInThread();
const eventPollResponseReference = buildEventPollResponseReference();
const eventPollStartThreadRoot = buildEventPollStartThreadRoot();
const eventMessageInThread = buildEventMessageInThread(eventPollStartThreadRoot);
const events = [
eventPollStartThreadRoot,
eventMessageInThread,
eventPollResponseReference,
eventPollStartThreadRoot,
];
// Vote has no threadId yet
expect(eventPollResponseReference.threadId).toBeFalsy();
const [timeline, threaded] = client.partitionThreadedEvents(events);
const [timeline, threaded] = room.partitionThreadedEvents(events);
expect(timeline).toEqual([
// The message that was sent in a thread is missing
eventPollResponseReference,
eventPollStartThreadRoot,
eventPollResponseReference,
]);
// The vote event has been copied into the thread
@@ -431,33 +599,34 @@ describe("MatrixClient", function() {
expect(eventRefWithThreadId.threadId).toBeTruthy();
expect(threaded).toEqual([
eventPollStartThreadRoot,
eventMessageInThread,
eventRefWithThreadId,
// Thread does not see thread root
]);
});
it("copies pre-thread in-timeline reactions onto both timelines", function() {
client.clientOpts = { experimentalThreadSupport: true };
const eventMessageInThread = buildEventMessageInThread();
const eventReaction = buildEventReaction();
const eventPollStartThreadRoot = buildEventPollStartThreadRoot();
const eventMessageInThread = buildEventMessageInThread(eventPollStartThreadRoot);
const eventReaction = buildEventReaction(eventPollStartThreadRoot);
const events = [
eventPollStartThreadRoot,
eventMessageInThread,
eventReaction,
eventPollStartThreadRoot,
];
const [timeline, threaded] = client.partitionThreadedEvents(events);
const [timeline, threaded] = room.partitionThreadedEvents(events);
expect(timeline).toEqual([
eventReaction,
eventPollStartThreadRoot,
eventReaction,
]);
expect(threaded).toEqual([
eventPollStartThreadRoot,
eventMessageInThread,
withThreadId(eventReaction, eventPollStartThreadRoot.getId()),
]);
@@ -467,23 +636,24 @@ describe("MatrixClient", function() {
client.clientOpts = { experimentalThreadSupport: true };
const eventPollResponseReference = buildEventPollResponseReference();
const eventMessageInThread = buildEventMessageInThread();
const eventPollStartThreadRoot = buildEventPollStartThreadRoot();
const eventMessageInThread = buildEventMessageInThread(eventPollStartThreadRoot);
const events = [
eventPollStartThreadRoot,
eventPollResponseReference,
eventMessageInThread,
eventPollStartThreadRoot,
];
const [timeline, threaded] = client.partitionThreadedEvents(events);
const [timeline, threaded] = room.partitionThreadedEvents(events);
expect(timeline).toEqual([
eventPollResponseReference,
eventPollStartThreadRoot,
eventPollResponseReference,
]);
expect(threaded).toEqual([
eventPollStartThreadRoot,
withThreadId(eventPollResponseReference, eventPollStartThreadRoot.getId()),
eventMessageInThread,
]);
@@ -492,26 +662,27 @@ describe("MatrixClient", function() {
it("copies post-thread in-timeline reactions onto both timelines", function() {
client.clientOpts = { experimentalThreadSupport: true };
const eventReaction = buildEventReaction();
const eventMessageInThread = buildEventMessageInThread();
const eventPollStartThreadRoot = buildEventPollStartThreadRoot();
const eventMessageInThread = buildEventMessageInThread(eventPollStartThreadRoot);
const eventReaction = buildEventReaction(eventPollStartThreadRoot);
const events = [
eventReaction,
eventMessageInThread,
eventPollStartThreadRoot,
eventMessageInThread,
eventReaction,
];
const [timeline, threaded] = client.partitionThreadedEvents(events);
const [timeline, threaded] = room.partitionThreadedEvents(events);
expect(timeline).toEqual([
eventReaction,
eventPollStartThreadRoot,
eventReaction,
]);
expect(threaded).toEqual([
withThreadId(eventReaction, eventPollStartThreadRoot.getId()),
eventPollStartThreadRoot,
eventMessageInThread,
withThreadId(eventReaction, eventPollStartThreadRoot.getId()),
]);
});
@@ -519,9 +690,9 @@ describe("MatrixClient", function() {
client.clientOpts = { experimentalThreadSupport: true };
// This is based on recording the events in a real room:
const eventMessageInThread = buildEventMessageInThread();
const eventPollResponseReference = buildEventPollResponseReference();
const eventPollStartThreadRoot = buildEventPollStartThreadRoot();
const eventPollResponseReference = buildEventPollResponseReference();
const eventMessageInThread = buildEventMessageInThread(eventPollStartThreadRoot);
const eventRoomName = buildEventRoomName();
const eventEncryption = buildEventEncryption();
const eventGuestAccess = buildEventGuestAccess();
@@ -532,9 +703,9 @@ describe("MatrixClient", function() {
const eventCreate = buildEventCreate();
const events = [
eventMessageInThread,
eventPollResponseReference,
eventPollStartThreadRoot,
eventPollResponseReference,
eventMessageInThread,
eventRoomName,
eventEncryption,
eventGuestAccess,
@@ -544,12 +715,12 @@ describe("MatrixClient", function() {
eventMember,
eventCreate,
];
const [timeline, threaded] = client.partitionThreadedEvents(events);
const [timeline, threaded] = room.partitionThreadedEvents(events);
expect(timeline).toEqual([
// The message that was sent in a thread is missing
eventPollResponseReference,
eventPollStartThreadRoot,
eventPollResponseReference,
eventRoomName,
eventEncryption,
eventGuestAccess,
@@ -560,11 +731,95 @@ describe("MatrixClient", function() {
eventCreate,
]);
// Thread should contain only stuff that happened in the thread -
// no thread root, and no room state events
// Thread should contain only stuff that happened in the thread - no room state events
expect(threaded).toEqual([
eventMessageInThread,
eventPollStartThreadRoot,
withThreadId(eventPollResponseReference, eventPollStartThreadRoot.getId()),
eventMessageInThread,
]);
});
it("sends redactions of reactions to thread responses to thread timeline only", () => {
client.clientOpts = { experimentalThreadSupport: true };
const threadRootEvent = buildEventPollStartThreadRoot();
const eventMessageInThread = buildEventMessageInThread(threadRootEvent);
const threadedReaction = buildEventReaction(eventMessageInThread);
const threadedReactionRedaction = buildEventRedaction(threadedReaction);
const events = [
threadRootEvent,
eventMessageInThread,
threadedReaction,
threadedReactionRedaction,
];
const [timeline, threaded] = room.partitionThreadedEvents(events);
expect(timeline).toEqual([
threadRootEvent,
]);
expect(threaded).toEqual([
threadRootEvent,
eventMessageInThread,
threadedReaction,
threadedReactionRedaction,
]);
});
it("sends reply to reply to thread root outside of thread to main timeline only", () => {
client.clientOpts = { experimentalThreadSupport: true };
const threadRootEvent = buildEventPollStartThreadRoot();
const eventMessageInThread = buildEventMessageInThread(threadRootEvent);
const directReplyToThreadRoot = buildEventReply(threadRootEvent);
const replyToReply = buildEventReply(directReplyToThreadRoot);
const events = [
threadRootEvent,
eventMessageInThread,
directReplyToThreadRoot,
replyToReply,
];
const [timeline, threaded] = room.partitionThreadedEvents(events);
expect(timeline).toEqual([
threadRootEvent,
directReplyToThreadRoot,
replyToReply,
]);
expect(threaded).toEqual([
threadRootEvent,
eventMessageInThread,
]);
});
it("sends reply to thread responses to main timeline only", () => {
client.clientOpts = { experimentalThreadSupport: true };
const threadRootEvent = buildEventPollStartThreadRoot();
const eventMessageInThread = buildEventMessageInThread(threadRootEvent);
const replyToThreadResponse = buildEventReply(eventMessageInThread);
const events = [
threadRootEvent,
eventMessageInThread,
replyToThreadResponse,
];
const [timeline, threaded] = room.partitionThreadedEvents(events);
expect(timeline).toEqual([
threadRootEvent,
replyToThreadResponse,
]);
expect(threaded).toEqual([
threadRootEvent,
eventMessageInThread,
]);
});
});
@@ -576,16 +831,16 @@ function withThreadId(event, newThreadId) {
return ret;
}
const buildEventMessageInThread = () => new MatrixEvent({
const buildEventMessageInThread = (root) => new MatrixEvent({
"age": 80098509,
"content": {
"algorithm": "m.megolm.v1.aes-sha2",
"ciphertext": "ENCRYPTEDSTUFF",
"device_id": "XISFUZSKHH",
"m.relates_to": {
"event_id": "$VLS2ojbPmxb6x8ECetn45hmND6cRDcjgv-j-to9m7Vo",
"event_id": root.getId(),
"m.in_reply_to": {
"event_id": "$VLS2ojbPmxb6x8ECetn45hmND6cRDcjgv-j-to9m7Vo",
"event_id": root.getId(),
},
"rel_type": "m.thread",
},
@@ -623,10 +878,10 @@ const buildEventPollResponseReference = () => new MatrixEvent({
"user_id": "@andybalaam-test1:matrix.org",
});
const buildEventReaction = () => new MatrixEvent({
const buildEventReaction = (event) => new MatrixEvent({
"content": {
"m.relates_to": {
"event_id": "$VLS2ojbPmxb6x8ECetn45hmND6cRDcjgv-j-to9m7Vo",
"event_id": event.getId(),
"key": "🤗",
"rel_type": "m.annotation",
},
@@ -642,6 +897,22 @@ const buildEventReaction = () => new MatrixEvent({
"room_id": "!STrMRsukXHtqQdSeHa:matrix.org",
});
const buildEventRedaction = (event) => new MatrixEvent({
"content": {
},
"origin_server_ts": 1643977249239,
"sender": "@andybalaam-test1:matrix.org",
"redacts": event.getId(),
"type": "m.room.redaction",
"unsigned": {
"age": 22597,
"transaction_id": "m1643977249073.17",
},
"event_id": "$86B2b-x3LgE4DlV4y24b7UHnt72LIA3rzjvMysTtAfB",
"room_id": "!STrMRsukXHtqQdSeHa:matrix.org",
});
const buildEventPollStartThreadRoot = () => new MatrixEvent({
"age": 80108647,
"content": {
@@ -660,6 +931,29 @@ const buildEventPollStartThreadRoot = () => new MatrixEvent({
"user_id": "@andybalaam-test1:matrix.org",
});
const buildEventReply = (target) => new MatrixEvent({
"age": 80098509,
"content": {
"algorithm": "m.megolm.v1.aes-sha2",
"ciphertext": "ENCRYPTEDSTUFF",
"device_id": "XISFUZSKHH",
"m.relates_to": {
"m.in_reply_to": {
"event_id": target.getId(),
},
},
"sender_key": "i3N3CtG/CD2bGB8rA9fW6adLYSDvlUhf2iuU73L65Vg",
"session_id": "Ja11R/KG6ua0wdk8zAzognrxjio1Gm/RK2Gn6lFL804",
},
"event_id": target.getId() + Math.random(),
"origin_server_ts": 1643815466378,
"room_id": "!STrMRsukXHtqQdSeHa:matrix.org",
"sender": "@andybalaam-test1:matrix.org",
"type": "m.room.encrypted",
"unsigned": { "age": 80098509 },
"user_id": "@andybalaam-test1:matrix.org",
});
const buildEventRoomName = () => new MatrixEvent({
"age": 80123249,
"content": {
+1 -2
View File
@@ -735,8 +735,7 @@ describe("MatrixClient syncing", function() {
expect(tok).toEqual("pagTok");
}),
// first flush the filter request; this will make syncLeftRooms
// make its /sync call
// first flush the filter request; this will make syncLeftRooms make its /sync call
httpBackend.flush("/filter").then(function() {
return httpBackend.flushAllExpected();
}),
+3 -2
View File
@@ -42,7 +42,6 @@ export const makeBeaconInfoEvent = (
roomId: string,
contentProps: Partial<InfoContentProps> = {},
eventId?: string,
eventTypeSuffix?: string,
): MatrixEvent => {
const {
timeout, isLive, description, assetType,
@@ -51,12 +50,14 @@ export const makeBeaconInfoEvent = (
...contentProps,
};
const event = new MatrixEvent({
type: `${M_BEACON_INFO.name}.${sender}.${eventTypeSuffix || Date.now()}`,
type: M_BEACON_INFO.name,
room_id: roomId,
state_key: sender,
content: makeBeaconInfoContent(timeout, isLive, description, assetType),
});
event.event.origin_server_ts = Date.now();
// live beacons use the beacon_info event id
// set or default this
event.replaceLocalEventId(eventId || `$${Math.random()}-${Math.random()}`);
-369
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));
+292
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));
+3 -6
View File
@@ -16,7 +16,6 @@ limitations under the License.
import { REFERENCE_RELATION } from "matrix-events-sdk";
import { M_BEACON_INFO } from "../../src/@types/beacon";
import { LocationAssetType, M_ASSET, M_LOCATION, M_TIMESTAMP } from "../../src/@types/location";
import { makeBeaconContent, makeBeaconInfoContent } from "../../src/content-helpers";
@@ -36,11 +35,9 @@ describe('Beacon content helpers', () => {
'nice beacon_info',
LocationAssetType.Pin,
)).toEqual({
[M_BEACON_INFO.name]: {
description: 'nice beacon_info',
timeout: 1234,
live: true,
},
description: 'nice beacon_info',
timeout: 1234,
live: true,
[M_TIMESTAMP.name]: mockDateNow,
[M_ASSET.name]: {
type: LocationAssetType.Pin,
+25
View File
@@ -13,6 +13,7 @@ import * as olmlib from "../../src/crypto/olmlib";
import { sleep } from "../../src/utils";
import { CRYPTO_ENABLED } from "../../src/client";
import { DeviceInfo } from "../../src/crypto/deviceinfo";
import { logger } from '../../src/logger';
const Olm = global.Olm;
@@ -400,4 +401,28 @@ describe("Crypto", function() {
expect(aliceClient.sendToDevice.mock.calls[2][2]).not.toBe(txnId);
});
});
describe('Secret storage', function() {
it("creates secret storage even if there is no keyInfo", async function() {
jest.spyOn(logger, 'log').mockImplementation(() => {});
jest.setTimeout(10000);
const client = (new TestClient("@a:example.com", "dev")).client;
await client.initCrypto();
client.crypto.getSecretStorageKey = async () => null;
client.crypto.isCrossSigningReady = async () => false;
client.crypto.baseApis.uploadDeviceSigningKeys = () => null;
client.crypto.baseApis.setAccountData = () => null;
client.crypto.baseApis.uploadKeySignatures = () => null;
client.crypto.baseApis.http.authedRequest = () => null;
const createSecretStorageKey = async () => {
return {
keyInfo: undefined, // Returning undefined here used to cause a crash
privateKey: Uint8Array.of(32, 33),
};
};
await client.crypto.bootstrapSecretStorage({
createSecretStorageKey,
});
});
});
});
+161 -5
View File
@@ -20,11 +20,33 @@ import anotherjson from 'another-json';
import * as olmlib from "../../../src/crypto/olmlib";
import { TestClient } from '../../TestClient';
import { HttpResponse, setHttpResponses } from '../../test-utils/test-utils';
import { resetCrossSigningKeys } from "./crypto-utils";
import { MatrixError } from '../../../src/http-api';
import { logger } from '../../../src/logger';
const PUSH_RULES_RESPONSE = {
method: "GET",
path: "/pushrules/",
data: {},
};
const filterResponse = function(userId) {
const filterPath = "/user/" + encodeURIComponent(userId) + "/filter";
return {
method: "POST",
path: filterPath,
data: { filter_id: "f1lt3r" },
};
};
function setHttpResponses(httpBackend, responses) {
responses.forEach(response => {
httpBackend
.when(response.method, response.path)
.respond(200, response.data);
});
}
async function makeTestClient(userInfo, options, keys) {
if (!keys) keys = {};
@@ -237,7 +259,7 @@ describe("Cross Signing", function() {
// feed sync result that includes master key, ssk, device key
const responses = [
HttpResponse.PUSH_RULES_RESPONSE,
PUSH_RULES_RESPONSE,
{
method: "POST",
path: "/keys/upload",
@@ -248,7 +270,7 @@ describe("Cross Signing", function() {
},
},
},
HttpResponse.filterResponse("@alice:example.com"),
filterResponse("@alice:example.com"),
{
method: "GET",
path: "/sync",
@@ -493,7 +515,7 @@ describe("Cross Signing", function() {
// - master key signed by her usk (pretend that it was signed by another
// of Alice's devices)
const responses = [
HttpResponse.PUSH_RULES_RESPONSE,
PUSH_RULES_RESPONSE,
{
method: "POST",
path: "/keys/upload",
@@ -504,7 +526,7 @@ describe("Cross Signing", function() {
},
},
},
HttpResponse.filterResponse("@alice:example.com"),
filterResponse("@alice:example.com"),
{
method: "GET",
path: "/sync",
@@ -861,4 +883,138 @@ describe("Cross Signing", function() {
expect(bobTrust3.isCrossSigningVerified()).toBeTruthy();
expect(bobTrust3.isTofu()).toBeTruthy();
});
it(
"should observe that our own device is cross-signed, even if this device doesn't trust the key",
async function() {
const { client: alice } = await makeTestClient(
{ userId: "@alice:example.com", deviceId: "Osborne2" },
);
alice.uploadDeviceSigningKeys = async () => {};
alice.uploadKeySignatures = async () => {};
// Generate Alice's SSK etc
const aliceMasterSigning = new global.Olm.PkSigning();
const aliceMasterPrivkey = aliceMasterSigning.generate_seed();
const aliceMasterPubkey = aliceMasterSigning.init_with_seed(aliceMasterPrivkey);
const aliceSigning = new global.Olm.PkSigning();
const alicePrivkey = aliceSigning.generate_seed();
const alicePubkey = aliceSigning.init_with_seed(alicePrivkey);
const aliceSSK = {
user_id: "@alice:example.com",
usage: ["self_signing"],
keys: {
["ed25519:" + alicePubkey]: alicePubkey,
},
};
const sskSig = aliceMasterSigning.sign(anotherjson.stringify(aliceSSK));
aliceSSK.signatures = {
"@alice:example.com": {
["ed25519:" + aliceMasterPubkey]: sskSig,
},
};
// Alice's device downloads the keys, but doesn't trust them yet
alice.crypto.deviceList.storeCrossSigningForUser("@alice:example.com", {
keys: {
master: {
user_id: "@alice:example.com",
usage: ["master"],
keys: {
["ed25519:" + aliceMasterPubkey]: aliceMasterPubkey,
},
},
self_signing: aliceSSK,
},
firstUse: 1,
unsigned: {},
});
// Alice has a second device that's cross-signed
const aliceCrossSignedDevice = {
user_id: "@alice:example.com",
device_id: "Dynabook",
algorithms: ["m.olm.curve25519-aes-sha256", "m.megolm.v1.aes-sha"],
keys: {
"curve25519:Dynabook": "somePubkey",
"ed25519:Dynabook": "someOtherPubkey",
},
};
const sig = aliceSigning.sign(anotherjson.stringify(aliceCrossSignedDevice));
aliceCrossSignedDevice.signatures = {
"@alice:example.com": {
["ed25519:" + alicePubkey]: sig,
},
};
alice.crypto.deviceList.storeDevicesForUser("@alice:example.com", {
Dynabook: aliceCrossSignedDevice,
});
// We don't trust the cross-signing keys yet...
expect(alice.checkDeviceTrust(aliceCrossSignedDevice.device_id).isCrossSigningVerified()).toBeFalsy();
// ... but we do acknowledge that the device is signed by them
expect(alice.checkIfOwnDeviceCrossSigned(aliceCrossSignedDevice.device_id)).toBeTruthy();
},
);
it("should observe that our own device isn't cross-signed", async function() {
const { client: alice } = await makeTestClient(
{ userId: "@alice:example.com", deviceId: "Osborne2" },
);
alice.uploadDeviceSigningKeys = async () => {};
alice.uploadKeySignatures = async () => {};
// Generate Alice's SSK etc
const aliceMasterSigning = new global.Olm.PkSigning();
const aliceMasterPrivkey = aliceMasterSigning.generate_seed();
const aliceMasterPubkey = aliceMasterSigning.init_with_seed(aliceMasterPrivkey);
const aliceSigning = new global.Olm.PkSigning();
const alicePrivkey = aliceSigning.generate_seed();
const alicePubkey = aliceSigning.init_with_seed(alicePrivkey);
const aliceSSK = {
user_id: "@alice:example.com",
usage: ["self_signing"],
keys: {
["ed25519:" + alicePubkey]: alicePubkey,
},
};
const sskSig = aliceMasterSigning.sign(anotherjson.stringify(aliceSSK));
aliceSSK.signatures = {
"@alice:example.com": {
["ed25519:" + aliceMasterPubkey]: sskSig,
},
};
// Alice's device downloads the keys
alice.crypto.deviceList.storeCrossSigningForUser("@alice:example.com", {
keys: {
master: {
user_id: "@alice:example.com",
usage: ["master"],
keys: {
["ed25519:" + aliceMasterPubkey]: aliceMasterPubkey,
},
},
self_signing: aliceSSK,
},
firstUse: 1,
unsigned: {},
});
// Alice has a second device that's also not cross-signed
const aliceNotCrossSignedDevice = {
user_id: "@alice:example.com",
device_id: "Dynabook",
algorithms: ["m.olm.curve25519-aes-sha256", "m.megolm.v1.aes-sha"],
keys: {
"curve25519:Dynabook": "somePubkey",
"ed25519:Dynabook": "someOtherPubkey",
},
};
alice.crypto.deviceList.storeDevicesForUser("@alice:example.com", {
Dynabook: aliceNotCrossSignedDevice,
});
expect(alice.checkIfOwnDeviceCrossSigned(aliceNotCrossSignedDevice.device_id)).toBeFalsy();
});
});
+180
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
});
});
+1 -1
View File
@@ -1,6 +1,6 @@
/*
Copyright 2017 New Vector Ltd
Copyright 2019 The Matrix.org Foundaction C.I.C.
Copyright 2019 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
+9 -8
View File
@@ -1,4 +1,5 @@
import {
MatrixEvent,
RelationType,
} from "../../src";
import { FilterComponent } from "../../src/filter-component";
@@ -13,7 +14,7 @@ describe("Filter Component", function() {
content: { },
room: 'roomId',
event: true,
});
}) as MatrixEvent;
const checkResult = filter.check(event);
@@ -27,7 +28,7 @@ describe("Filter Component", function() {
content: { },
room: 'roomId',
event: true,
});
}) as MatrixEvent;
const checkResult = filter.check(event);
@@ -54,7 +55,7 @@ describe("Filter Component", function() {
},
},
},
});
}) as MatrixEvent;
expect(filter.check(threadRootNotParticipated)).toBe(false);
});
@@ -79,7 +80,7 @@ describe("Filter Component", function() {
user: '@someone-else:server.org',
room: 'roomId',
event: true,
});
}) as MatrixEvent;
expect(filter.check(threadRootParticipated)).toBe(true);
});
@@ -99,7 +100,7 @@ describe("Filter Component", function() {
[RelationType.Reference]: {},
},
},
});
}) as MatrixEvent;
expect(filter.check(referenceRelationEvent)).toBe(false);
});
@@ -122,7 +123,7 @@ describe("Filter Component", function() {
},
room: 'roomId',
event: true,
});
}) as MatrixEvent;
const eventWithMultipleRelations = mkEvent({
"type": "m.room.message",
@@ -147,7 +148,7 @@ describe("Filter Component", function() {
},
"room": 'roomId',
"event": true,
});
}) as MatrixEvent;
const noMatchEvent = mkEvent({
"type": "m.room.message",
@@ -159,7 +160,7 @@ describe("Filter Component", function() {
},
"room": 'roomId',
"event": true,
});
}) as MatrixEvent;
expect(filter.check(threadRootEvent)).toBe(true);
expect(filter.check(eventWithMultipleRelations)).toBe(true);
+112 -12
View File
@@ -1,3 +1,19 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { logger } from "../../src/logger";
import { MatrixClient } from "../../src/client";
import { Filter } from "../../src/filter";
@@ -16,6 +32,8 @@ import { Preset } from "../../src/@types/partials";
import * as testUtils from "../test-utils/test-utils";
import { makeBeaconInfoContent } from "../../src/content-helpers";
import { M_BEACON_INFO } from "../../src/@types/beacon";
import { Room } from "../../src";
import { makeBeaconEvent } from "../test-utils/beacon";
jest.useFakeTimers();
@@ -793,11 +811,12 @@ describe("MatrixClient", function() {
}
},
},
threads: {
get: jest.fn(),
},
getThread: jest.fn(),
addPendingEvent: jest.fn(),
updatePendingEvent: jest.fn(),
reEmitter: {
reEmit: jest.fn(),
},
};
beforeEach(() => {
@@ -941,6 +960,7 @@ describe("MatrixClient", function() {
it("partitions root events to room timeline and thread timeline", () => {
const supportsExperimentalThreads = client.supportsExperimentalThreads;
client.supportsExperimentalThreads = () => true;
const room = new Room("!room1:matrix.org", client, userId);
const rootEvent = new MatrixEvent({
"content": {},
@@ -963,9 +983,9 @@ describe("MatrixClient", function() {
expect(rootEvent.isThreadRoot).toBe(true);
const [room, threads] = client.partitionThreadedEvents([rootEvent]);
expect(room).toHaveLength(1);
expect(threads).toHaveLength(1);
const [roomEvents, threadEvents] = room.partitionThreadedEvents([rootEvent]);
expect(roomEvents).toHaveLength(1);
expect(threadEvents).toHaveLength(1);
// Restore method
client.supportsExperimentalThreads = supportsExperimentalThreads;
@@ -981,10 +1001,10 @@ describe("MatrixClient", function() {
});
it("creates new beacon info", async () => {
await client.unstable_createLiveBeacon(roomId, content, '123');
await client.unstable_createLiveBeacon(roomId, content);
// event type combined
const expectedEventType = `${M_BEACON_INFO.name}.${userId}.123`;
const expectedEventType = M_BEACON_INFO.name;
const [callback, method, path, queryParams, requestContent] = client.http.authedRequest.mock.calls[0];
expect(callback).toBeFalsy();
expect(method).toBe('PUT');
@@ -997,17 +1017,97 @@ describe("MatrixClient", function() {
});
it("updates beacon info with specific event type", async () => {
const eventType = `${M_BEACON_INFO.name}.${userId}.456`;
await client.unstable_setLiveBeacon(roomId, eventType, content);
await client.unstable_setLiveBeacon(roomId, content);
// event type combined
const [, , path, , requestContent] = client.http.authedRequest.mock.calls[0];
expect(path).toEqual(
`/rooms/${encodeURIComponent(roomId)}/state/` +
`${encodeURIComponent(eventType)}/${encodeURIComponent(userId)}`,
`${encodeURIComponent(M_BEACON_INFO.name)}/${encodeURIComponent(userId)}`,
);
expect(requestContent).toEqual(content);
});
describe('processBeaconEvents()', () => {
it('does nothing when events is falsy', () => {
const room = new Room(roomId, client, userId);
const roomStateProcessSpy = jest.spyOn(room.currentState, 'processBeaconEvents');
client.processBeaconEvents(room, undefined);
expect(roomStateProcessSpy).not.toHaveBeenCalled();
});
it('does nothing when events is of length 0', () => {
const room = new Room(roomId, client, userId);
const roomStateProcessSpy = jest.spyOn(room.currentState, 'processBeaconEvents');
client.processBeaconEvents(room, []);
expect(roomStateProcessSpy).not.toHaveBeenCalled();
});
it('calls room states processBeaconEvents with events', () => {
const room = new Room(roomId, client, userId);
const roomStateProcessSpy = jest.spyOn(room.currentState, 'processBeaconEvents');
const messageEvent = testUtils.mkMessage({ room: roomId, user: userId, event: true });
const beaconEvent = makeBeaconEvent(userId);
client.processBeaconEvents(room, [messageEvent, beaconEvent]);
expect(roomStateProcessSpy).toHaveBeenCalledWith([messageEvent, beaconEvent], client);
});
});
});
describe("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);
});
});
});
+128 -31
View File
@@ -14,15 +14,12 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { EventType } from "../../../src";
import { M_BEACON_INFO } from "../../../src/@types/beacon";
import {
isTimestampInDuration,
isBeaconInfoEventType,
Beacon,
BeaconEvent,
} from "../../../src/models/beacon";
import { makeBeaconInfoEvent } from "../../test-utils/beacon";
import { makeBeaconEvent, makeBeaconInfoEvent } from "../../test-utils/beacon";
jest.useFakeTimers();
@@ -57,27 +54,9 @@ describe('Beacon', () => {
});
});
describe('isBeaconInfoEventType', () => {
it.each([
EventType.CallAnswer,
`prefix.${M_BEACON_INFO.name}`,
`prefix.${M_BEACON_INFO.altName}`,
])('returns false for %s', (type) => {
expect(isBeaconInfoEventType(type)).toBe(false);
});
it.each([
M_BEACON_INFO.name,
M_BEACON_INFO.altName,
`${M_BEACON_INFO.name}.@test:server.org.12345`,
`${M_BEACON_INFO.altName}.@test:server.org.12345`,
])('returns true for %s', (type) => {
expect(isBeaconInfoEventType(type)).toBe(true);
});
});
describe('Beacon', () => {
const userId = '@user:server.org';
const userId2 = '@user2:server.org';
const roomId = '$room:server.org';
// 14.03.2022 16:15
const now = 1647270879403;
@@ -88,6 +67,7 @@ describe('Beacon', () => {
// without timeout of 3 hours
let liveBeaconEvent;
let notLiveBeaconEvent;
let user2BeaconEvent;
const advanceDateAndTime = (ms: number) => {
// bc liveness check uses Date.now we have to advance this mock
@@ -107,14 +87,21 @@ describe('Beacon', () => {
isLive: true,
},
'$live123',
'$live123',
);
notLiveBeaconEvent = makeBeaconInfoEvent(
userId,
roomId,
{ timeout: HOUR_MS * 3, isLive: false },
'$dead123',
'$dead123',
);
user2BeaconEvent = makeBeaconInfoEvent(
userId2,
roomId,
{
timeout: HOUR_MS * 3,
isLive: true,
},
'$user2live123',
);
// back to now
@@ -133,7 +120,7 @@ describe('Beacon', () => {
expect(beacon.isLive).toEqual(true);
expect(beacon.beaconInfoOwner).toEqual(userId);
expect(beacon.beaconInfoEventType).toEqual(liveBeaconEvent.getType());
expect(beacon.identifier).toEqual(liveBeaconEvent.getType());
expect(beacon.identifier).toEqual(`${roomId}_${userId}`);
expect(beacon.beaconInfo).toBeTruthy();
});
@@ -171,8 +158,27 @@ describe('Beacon', () => {
expect(beacon.beaconInfoId).toEqual(liveBeaconEvent.getId());
expect(() => beacon.update(notLiveBeaconEvent)).toThrow();
expect(beacon.isLive).toEqual(true);
expect(() => beacon.update(user2BeaconEvent)).toThrow();
// didnt update
expect(beacon.identifier).toEqual(`${roomId}_${userId}`);
});
it('does not update with an older event', () => {
const beacon = new Beacon(liveBeaconEvent);
const emitSpy = jest.spyOn(beacon, 'emit').mockClear();
expect(beacon.beaconInfoId).toEqual(liveBeaconEvent.getId());
const oldUpdateEvent = makeBeaconInfoEvent(
userId,
roomId,
);
// less than the original event
oldUpdateEvent.event.origin_server_ts = liveBeaconEvent.event.origin_server_ts - 1000;
beacon.update(oldUpdateEvent);
// didnt update
expect(emitSpy).not.toHaveBeenCalled();
expect(beacon.beaconInfoId).toEqual(liveBeaconEvent.getId());
});
it('updates event', () => {
@@ -182,7 +188,7 @@ describe('Beacon', () => {
expect(beacon.isLive).toEqual(true);
const updatedBeaconEvent = makeBeaconInfoEvent(
userId, roomId, { timeout: HOUR_MS * 3, isLive: false }, '$live123', '$live123');
userId, roomId, { timeout: HOUR_MS * 3, isLive: false }, '$live123');
beacon.update(updatedBeaconEvent);
expect(beacon.isLive).toEqual(false);
@@ -200,7 +206,6 @@ describe('Beacon', () => {
roomId,
{ timeout: HOUR_MS * 3, isLive: false },
beacon.beaconInfoId,
'$live123',
);
beacon.update(updatedBeaconEvent);
@@ -256,7 +261,7 @@ describe('Beacon', () => {
expect(beacon.livenessWatchInterval).not.toEqual(oldMonitor);
});
it('destroy kills liveness monitor', () => {
it('destroy kills liveness monitor and emits', () => {
// live beacon was created an hour ago
// and has a 3hr duration
const beacon = new Beacon(liveBeaconEvent);
@@ -267,9 +272,101 @@ describe('Beacon', () => {
// destroy the beacon
beacon.destroy();
expect(emitSpy).toHaveBeenCalledWith(BeaconEvent.Destroy, beacon.identifier);
// live forced to false
expect(beacon.isLive).toBe(false);
advanceDateAndTime(HOUR_MS * 2 + 1);
// no additional calls
expect(emitSpy).toHaveBeenCalledTimes(1);
});
});
describe('addLocations', () => {
it('ignores locations when beacon is not live', () => {
const beacon = new Beacon(makeBeaconInfoEvent(userId, roomId, { isLive: false }));
const emitSpy = jest.spyOn(beacon, 'emit');
beacon.addLocations([
makeBeaconEvent(userId, { beaconInfoId: beacon.beaconInfoId, timestamp: now + 1 }),
]);
expect(beacon.latestLocationState).toBeFalsy();
expect(emitSpy).not.toHaveBeenCalled();
});
it('ignores locations outside the beacon live duration', () => {
const beacon = new Beacon(makeBeaconInfoEvent(userId, roomId, { isLive: true, timeout: 60000 }));
const emitSpy = jest.spyOn(beacon, 'emit');
beacon.addLocations([
// beacon has now + 60000 live period
makeBeaconEvent(userId, { beaconInfoId: beacon.beaconInfoId, timestamp: now + 100000 }),
]);
expect(beacon.latestLocationState).toBeFalsy();
expect(emitSpy).not.toHaveBeenCalled();
});
it('sets latest location state to most recent location', () => {
const beacon = new Beacon(makeBeaconInfoEvent(userId, roomId, { isLive: true, timeout: 60000 }));
const emitSpy = jest.spyOn(beacon, 'emit');
const locations = [
// older
makeBeaconEvent(
userId, { beaconInfoId: beacon.beaconInfoId, uri: 'geo:foo', timestamp: now + 1 },
),
// newer
makeBeaconEvent(
userId, { beaconInfoId: beacon.beaconInfoId, uri: 'geo:bar', timestamp: now + 10000 },
),
// not valid
makeBeaconEvent(
userId, { beaconInfoId: beacon.beaconInfoId, uri: 'geo:baz', timestamp: now - 5 },
),
];
beacon.addLocations(locations);
const expectedLatestLocation = {
description: undefined,
timestamp: now + 10000,
uri: 'geo:bar',
};
// the newest valid location
expect(beacon.latestLocationState).toEqual(expectedLatestLocation);
expect(emitSpy).toHaveBeenCalledWith(BeaconEvent.LocationUpdate, expectedLatestLocation);
});
it('ignores locations that are less recent that the current latest location', () => {
const beacon = new Beacon(makeBeaconInfoEvent(userId, roomId, { isLive: true, timeout: 60000 }));
const olderLocation = makeBeaconEvent(
userId, { beaconInfoId: beacon.beaconInfoId, uri: 'geo:foo', timestamp: now + 1 },
);
const newerLocation = makeBeaconEvent(
userId, { beaconInfoId: beacon.beaconInfoId, uri: 'geo:bar', timestamp: now + 10000 },
);
beacon.addLocations([newerLocation]);
// latest location set to newerLocation
expect(beacon.latestLocationState).toEqual(expect.objectContaining({
uri: 'geo:bar',
}));
const emitSpy = jest.spyOn(beacon, 'emit').mockClear();
// add older location
beacon.addLocations([olderLocation]);
// no change
expect(beacon.latestLocationState).toEqual(expect.objectContaining({
uri: 'geo:bar',
}));
// no emit
expect(emitSpy).not.toHaveBeenCalled();
});
});
+45
View File
@@ -130,4 +130,49 @@ describe("Relations", function() {
await relationsCreated;
}
});
it("should ignore m.replace for state events", async () => {
const userId = "@bob:example.com";
const room = new Room("room123", null, userId);
const relations = new Relations("m.replace", "m.room.topic", room);
// Create an instance of a state event with rel_type m.replace
const originalTopic = new MatrixEvent({
"sender": userId,
"type": "m.room.topic",
"event_id": "$orig",
"room_id": room.roomId,
"content": {
"topic": "orig",
},
"state_key": "",
});
const badlyEditedTopic = new MatrixEvent({
"sender": userId,
"type": "m.room.topic",
"event_id": "$orig",
"room_id": room.roomId,
"content": {
"topic": "topic",
"m.new_content": {
"topic": "edit",
},
"m.relates_to": {
"event_id": "$orig",
"rel_type": "m.replace",
},
},
"state_key": "",
});
await relations.setTargetEvent(originalTopic);
expect(originalTopic.replacingEvent()).toBe(null);
expect(originalTopic.getContent().topic).toBe("orig");
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");
});
});
+319 -39
View File
@@ -1,8 +1,14 @@
import * as utils from "../test-utils/test-utils";
import { makeBeaconInfoEvent } from "../test-utils/beacon";
import { makeBeaconEvent, makeBeaconInfoEvent } from "../test-utils/beacon";
import { filterEmitCallsByEventType } from "../test-utils/emitter";
import { RoomState, RoomStateEvent } from "../../src/models/room-state";
import { BeaconEvent } from "../../src/models/beacon";
import { BeaconEvent, getBeaconInfoIdentifier } from "../../src/models/beacon";
import { EventType, RelationType } from "../../src/@types/event";
import {
MatrixEvent,
MatrixEventEvent,
} from "../../src/models/event";
import { M_BEACON } from "../../src/@types/beacon";
describe("RoomState", function() {
const roomId = "!foo:bar";
@@ -252,56 +258,91 @@ describe("RoomState", function() {
);
});
it('adds new beacon info events to state and emits', () => {
const beaconEvent = makeBeaconInfoEvent(userA, roomId);
const emitSpy = jest.spyOn(state, 'emit');
describe('beacon events', () => {
it('adds new beacon info events to state and emits', () => {
const beaconEvent = makeBeaconInfoEvent(userA, roomId);
const emitSpy = jest.spyOn(state, 'emit');
state.setStateEvents([beaconEvent]);
state.setStateEvents([beaconEvent]);
expect(state.beacons.size).toEqual(1);
const beaconInstance = state.beacons.get(beaconEvent.getType());
expect(beaconInstance).toBeTruthy();
expect(emitSpy).toHaveBeenCalledWith(BeaconEvent.New, beaconEvent, beaconInstance);
});
expect(state.beacons.size).toEqual(1);
const beaconInstance = state.beacons.get(`${roomId}_${userA}`);
expect(beaconInstance).toBeTruthy();
expect(emitSpy).toHaveBeenCalledWith(BeaconEvent.New, beaconEvent, beaconInstance);
});
it('updates existing beacon info events in state', () => {
const beaconId = '$beacon1';
const beaconEvent = makeBeaconInfoEvent(userA, roomId, { isLive: true }, beaconId, beaconId);
const updatedBeaconEvent = makeBeaconInfoEvent(userA, roomId, { isLive: false }, beaconId, beaconId);
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([beaconEvent]);
const beaconInstance = state.beacons.get(beaconEvent.getType());
expect(beaconInstance.isLive).toEqual(true);
state.setStateEvents([redactedBeaconEvent]);
state.setStateEvents([updatedBeaconEvent]);
// 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();
});
// same Beacon
expect(state.beacons.get(beaconEvent.getType())).toBe(beaconInstance);
// updated liveness
expect(state.beacons.get(beaconEvent.getType()).isLive).toEqual(false);
});
it('updates existing beacon info events in state', () => {
const beaconId = '$beacon1';
const beaconEvent = makeBeaconInfoEvent(userA, roomId, { isLive: true }, beaconId);
const updatedBeaconEvent = makeBeaconInfoEvent(userA, roomId, { isLive: false }, beaconId);
it('updates live beacon ids once after setting state events', () => {
const liveBeaconEvent = makeBeaconInfoEvent(userA, roomId, { isLive: true }, '$beacon1', '$beacon1');
const deadBeaconEvent = makeBeaconInfoEvent(userA, roomId, { isLive: false }, '$beacon2', '$beacon2');
state.setStateEvents([beaconEvent]);
const beaconInstance = state.beacons.get(getBeaconInfoIdentifier(beaconEvent));
expect(beaconInstance.isLive).toEqual(true);
const emitSpy = jest.spyOn(state, 'emit');
state.setStateEvents([updatedBeaconEvent]);
state.setStateEvents([liveBeaconEvent, deadBeaconEvent]);
// same Beacon
expect(state.beacons.get(getBeaconInfoIdentifier(beaconEvent))).toBe(beaconInstance);
// updated liveness
expect(state.beacons.get(getBeaconInfoIdentifier(beaconEvent)).isLive).toEqual(false);
});
// called once
expect(filterEmitCallsByEventType(RoomStateEvent.BeaconLiveness, emitSpy).length).toBe(1);
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);
// live beacon is now not live
const updatedLiveBeaconEvent = makeBeaconInfoEvent(
userA, roomId, { isLive: false }, liveBeaconEvent.getId(), '$beacon1',
);
state.setStateEvents([beaconEvent]);
const beaconInstance = state.beacons.get(getBeaconInfoIdentifier(beaconEvent));
const destroySpy = jest.spyOn(beaconInstance, 'destroy');
expect(beaconInstance.isLive).toEqual(true);
state.setStateEvents([updatedLiveBeaconEvent]);
state.setStateEvents([redactedBeaconEvent]);
expect(state.hasLiveBeacons).toBe(false);
expect(filterEmitCallsByEventType(RoomStateEvent.BeaconLiveness, emitSpy).length).toBe(2);
expect(emitSpy).toHaveBeenCalledWith(RoomStateEvent.BeaconLiveness, state, false);
expect(destroySpy).toHaveBeenCalled();
expect(state.beacons.get(getBeaconInfoIdentifier(beaconEvent))).toBe(undefined);
});
it('updates live beacon ids once after setting state events', () => {
const liveBeaconEvent = makeBeaconInfoEvent(userA, roomId, { isLive: true }, '$beacon1');
const deadBeaconEvent = makeBeaconInfoEvent(userB, roomId, { isLive: false }, '$beacon2');
const emitSpy = jest.spyOn(state, 'emit');
state.setStateEvents([liveBeaconEvent, deadBeaconEvent]);
// called once
expect(filterEmitCallsByEventType(RoomStateEvent.BeaconLiveness, emitSpy).length).toBe(1);
// live beacon is now not live
const updatedLiveBeaconEvent = makeBeaconInfoEvent(
userA, roomId, { isLive: false }, liveBeaconEvent.getId(), '$beacon1',
);
state.setStateEvents([updatedLiveBeaconEvent]);
expect(state.hasLiveBeacons).toBe(false);
expect(filterEmitCallsByEventType(RoomStateEvent.BeaconLiveness, emitSpy).length).toBe(3);
expect(emitSpy).toHaveBeenCalledWith(RoomStateEvent.BeaconLiveness, state, false);
});
});
});
@@ -677,4 +718,243 @@ describe("RoomState", function() {
expect(state.maySendEvent('m.room.other_thing', userB)).toEqual(false);
});
});
describe('processBeaconEvents', () => {
const beacon1 = makeBeaconInfoEvent(userA, roomId, {}, '$beacon1', '$beacon1');
const beacon2 = makeBeaconInfoEvent(userB, roomId, {}, '$beacon2', '$beacon2');
const mockClient = { decryptEventIfNeeded: jest.fn() };
beforeEach(() => {
mockClient.decryptEventIfNeeded.mockClear();
});
it('does nothing when state has no beacons', () => {
const emitSpy = jest.spyOn(state, 'emit');
state.processBeaconEvents([makeBeaconEvent(userA, { beaconInfoId: '$beacon1' })], mockClient);
expect(emitSpy).not.toHaveBeenCalled();
expect(mockClient.decryptEventIfNeeded).not.toHaveBeenCalled();
});
it('does nothing when there are no events', () => {
state.setStateEvents([beacon1, beacon2]);
const emitSpy = jest.spyOn(state, 'emit').mockClear();
state.processBeaconEvents([], mockClient);
expect(emitSpy).not.toHaveBeenCalled();
expect(mockClient.decryptEventIfNeeded).not.toHaveBeenCalled();
});
describe('without encryption', () => {
it('discards events for beacons that are not in state', () => {
const location = makeBeaconEvent(userA, {
beaconInfoId: 'some-other-beacon',
});
const otherRelatedEvent = new MatrixEvent({
sender: userA,
type: EventType.RoomMessage,
content: {
['m.relates_to']: {
event_id: 'whatever',
},
},
});
state.setStateEvents([beacon1, beacon2]);
const emitSpy = jest.spyOn(state, 'emit').mockClear();
state.processBeaconEvents([location, otherRelatedEvent], mockClient);
expect(emitSpy).not.toHaveBeenCalled();
});
it('discards events that are not beacon type', () => {
// related to beacon1
const otherRelatedEvent = new MatrixEvent({
sender: userA,
type: EventType.RoomMessage,
content: {
['m.relates_to']: {
rel_type: RelationType.Reference,
event_id: beacon1.getId(),
},
},
});
state.setStateEvents([beacon1, beacon2]);
const emitSpy = jest.spyOn(state, 'emit').mockClear();
state.processBeaconEvents([otherRelatedEvent], mockClient);
expect(emitSpy).not.toHaveBeenCalled();
});
it('adds locations to beacons', () => {
const location1 = makeBeaconEvent(userA, {
beaconInfoId: '$beacon1', timestamp: Date.now() + 1,
});
const location2 = makeBeaconEvent(userA, {
beaconInfoId: '$beacon1', timestamp: Date.now() + 2,
});
const location3 = makeBeaconEvent(userB, {
beaconInfoId: 'some-other-beacon',
});
state.setStateEvents([beacon1, beacon2], mockClient);
expect(state.beacons.size).toEqual(2);
const beaconInstance = state.beacons.get(getBeaconInfoIdentifier(beacon1));
const addLocationsSpy = jest.spyOn(beaconInstance, 'addLocations');
state.processBeaconEvents([location1, location2, location3], mockClient);
expect(addLocationsSpy).toHaveBeenCalledTimes(2);
// only called with locations for beacon1
expect(addLocationsSpy).toHaveBeenCalledWith([location1]);
expect(addLocationsSpy).toHaveBeenCalledWith([location2]);
});
});
describe('with encryption', () => {
const beacon1RelationContent = { ['m.relates_to']: {
rel_type: RelationType.Reference,
event_id: beacon1.getId(),
} };
const relatedEncryptedEvent = new MatrixEvent({
sender: userA,
type: EventType.RoomMessageEncrypted,
content: beacon1RelationContent,
});
const decryptingRelatedEvent = new MatrixEvent({
sender: userA,
type: EventType.RoomMessageEncrypted,
content: beacon1RelationContent,
});
jest.spyOn(decryptingRelatedEvent, 'isBeingDecrypted').mockReturnValue(true);
const failedDecryptionRelatedEvent = new MatrixEvent({
sender: userA,
type: EventType.RoomMessageEncrypted,
content: beacon1RelationContent,
});
jest.spyOn(failedDecryptionRelatedEvent, 'isDecryptionFailure').mockReturnValue(true);
it('discards events without relations', () => {
const unrelatedEvent = new MatrixEvent({
sender: userA,
type: EventType.RoomMessageEncrypted,
});
state.setStateEvents([beacon1, beacon2]);
const emitSpy = jest.spyOn(state, 'emit').mockClear();
state.processBeaconEvents([unrelatedEvent], mockClient);
expect(emitSpy).not.toHaveBeenCalled();
// discard unrelated events early
expect(mockClient.decryptEventIfNeeded).not.toHaveBeenCalled();
});
it('discards events for beacons that are not in state', () => {
const location = makeBeaconEvent(userA, {
beaconInfoId: 'some-other-beacon',
});
const otherRelatedEvent = new MatrixEvent({
sender: userA,
type: EventType.RoomMessageEncrypted,
content: {
['m.relates_to']: {
rel_type: RelationType.Reference,
event_id: 'whatever',
},
},
});
state.setStateEvents([beacon1, beacon2]);
const beacon = state.beacons.get(getBeaconInfoIdentifier(beacon1));
const addLocationsSpy = jest.spyOn(beacon, 'addLocations').mockClear();
state.processBeaconEvents([location, otherRelatedEvent], mockClient);
expect(addLocationsSpy).not.toHaveBeenCalled();
// discard unrelated events early
expect(mockClient.decryptEventIfNeeded).not.toHaveBeenCalled();
});
it('decrypts related events if needed', () => {
const location = makeBeaconEvent(userA, {
beaconInfoId: beacon1.getId(),
});
state.setStateEvents([beacon1, beacon2]);
state.processBeaconEvents([location, relatedEncryptedEvent], mockClient);
// discard unrelated events early
expect(mockClient.decryptEventIfNeeded).toHaveBeenCalledTimes(2);
});
it('listens for decryption on events that are being decrypted', () => {
const decryptingRelatedEvent = new MatrixEvent({
sender: userA,
type: EventType.RoomMessageEncrypted,
content: beacon1RelationContent,
});
jest.spyOn(decryptingRelatedEvent, 'isBeingDecrypted').mockReturnValue(true);
// spy on event.once
const eventOnceSpy = jest.spyOn(decryptingRelatedEvent, 'once');
state.setStateEvents([beacon1, beacon2]);
state.processBeaconEvents([decryptingRelatedEvent], mockClient);
// listener was added
expect(eventOnceSpy).toHaveBeenCalled();
});
it('listens for decryption on events that have decryption failure', () => {
const failedDecryptionRelatedEvent = new MatrixEvent({
sender: userA,
type: EventType.RoomMessageEncrypted,
content: beacon1RelationContent,
});
jest.spyOn(failedDecryptionRelatedEvent, 'isDecryptionFailure').mockReturnValue(true);
// spy on event.once
const eventOnceSpy = jest.spyOn(decryptingRelatedEvent, 'once');
state.setStateEvents([beacon1, beacon2]);
state.processBeaconEvents([decryptingRelatedEvent], mockClient);
// listener was added
expect(eventOnceSpy).toHaveBeenCalled();
});
it('discard events that are not m.beacon type after decryption', () => {
const decryptingRelatedEvent = new MatrixEvent({
sender: userA,
type: EventType.RoomMessageEncrypted,
content: beacon1RelationContent,
});
jest.spyOn(decryptingRelatedEvent, 'isBeingDecrypted').mockReturnValue(true);
state.setStateEvents([beacon1, beacon2]);
const beacon = state.beacons.get(getBeaconInfoIdentifier(beacon1));
const addLocationsSpy = jest.spyOn(beacon, 'addLocations').mockClear();
state.processBeaconEvents([decryptingRelatedEvent], mockClient);
// this event is a message after decryption
decryptingRelatedEvent.type = EventType.RoomMessage;
decryptingRelatedEvent.emit(MatrixEventEvent.Decrypted);
expect(addLocationsSpy).not.toHaveBeenCalled();
});
it('adds locations to beacons after decryption', () => {
const decryptingRelatedEvent = new MatrixEvent({
sender: userA,
type: EventType.RoomMessageEncrypted,
content: beacon1RelationContent,
});
const locationEvent = makeBeaconEvent(userA, {
beaconInfoId: '$beacon1', timestamp: Date.now() + 1,
});
jest.spyOn(decryptingRelatedEvent, 'isBeingDecrypted').mockReturnValue(true);
state.setStateEvents([beacon1, beacon2]);
const beacon = state.beacons.get(getBeaconInfoIdentifier(beacon1));
const addLocationsSpy = jest.spyOn(beacon, 'addLocations').mockClear();
state.processBeaconEvents([decryptingRelatedEvent], mockClient);
// update type after '''decryption'''
decryptingRelatedEvent.event.type = M_BEACON.name;
decryptingRelatedEvent.event.content = locationEvent.content;
decryptingRelatedEvent.emit(MatrixEventEvent.Decrypted);
expect(addLocationsSpy).toHaveBeenCalledWith([decryptingRelatedEvent]);
});
});
});
});
+967 -598
View File
File diff suppressed because it is too large Load Diff
+29
View File
@@ -10,8 +10,11 @@ import {
prevString,
simpleRetryOperation,
stringToBase,
sortEventsByLatestContentTimestamp,
} from "../../src/utils";
import { logger } from "../../src/logger";
import { mkMessage } from "../test-utils/test-utils";
import { makeBeaconEvent } from "../test-utils/beacon";
// TODO: Fix types throughout
@@ -506,4 +509,30 @@ describe("utils", function() {
});
});
});
describe('sortEventsByLatestContentTimestamp', () => {
const roomId = '!room:server';
const userId = '@user:server';
const eventWithoutContentTimestamp = mkMessage({ room: roomId, user: userId, event: true });
// m.beacon events have timestamp in content
const beaconEvent1 = makeBeaconEvent(userId, { timestamp: 1648804528557 });
const beaconEvent2 = makeBeaconEvent(userId, { timestamp: 1648804528558 });
const beaconEvent3 = makeBeaconEvent(userId, { timestamp: 1648804528000 });
const beaconEvent4 = makeBeaconEvent(userId, { timestamp: 0 });
it('sorts events with timestamps as later than events without', () => {
expect(
[beaconEvent4, eventWithoutContentTimestamp, beaconEvent1]
.sort(utils.sortEventsByLatestContentTimestamp),
).toEqual([
beaconEvent1, beaconEvent4, eventWithoutContentTimestamp,
]);
});
it('sorts by content timestamps correctly', () => {
expect(
[beaconEvent1, beaconEvent2, beaconEvent3].sort(sortEventsByLatestContentTimestamp),
).toEqual([beaconEvent2, beaconEvent1, beaconEvent3]);
});
});
});
+9 -19
View File
@@ -14,14 +14,14 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { EitherAnd, RELATES_TO_RELATIONSHIP, REFERENCE_RELATION } from "matrix-events-sdk";
import { RELATES_TO_RELATIONSHIP, REFERENCE_RELATION } from "matrix-events-sdk";
import { UnstableValue } from "../NamespacedValue";
import { MAssetEvent, MLocationEvent, MTimestampEvent } from "./location";
/**
* Beacon info and beacon event types as described in MSC3489
* https://github.com/matrix-org/matrix-spec-proposals/pull/3489
* Beacon info and beacon event types as described in MSC3672
* https://github.com/matrix-org/matrix-spec-proposals/pull/3672
*/
/**
@@ -60,16 +60,11 @@ import { MAssetEvent, MLocationEvent, MTimestampEvent } from "./location";
* }
*/
/**
* Variable event type for m.beacon_info
*/
export const M_BEACON_INFO_VARIABLE = new UnstableValue("m.beacon_info.*", "org.matrix.msc3489.beacon_info.*");
/**
* Non-variable type for m.beacon_info event content
*/
export const M_BEACON_INFO = new UnstableValue("m.beacon_info", "org.matrix.msc3489.beacon_info");
export const M_BEACON = new UnstableValue("m.beacon", "org.matrix.msc3489.beacon");
export const M_BEACON_INFO = new UnstableValue("m.beacon_info", "org.matrix.msc3672.beacon_info");
export const M_BEACON = new UnstableValue("m.beacon", "org.matrix.msc3672.beacon");
export type MBeaconInfoContent = {
description?: string;
@@ -80,16 +75,11 @@ export type MBeaconInfoContent = {
live?: boolean;
};
export type MBeaconInfoEvent = EitherAnd<
{ [M_BEACON_INFO.name]: MBeaconInfoContent },
{ [M_BEACON_INFO.altName]: MBeaconInfoContent }
>;
/**
* m.beacon_info Event example from the spec
* https://github.com/matrix-org/matrix-spec-proposals/pull/3489
* https://github.com/matrix-org/matrix-spec-proposals/pull/3672
* {
"type": "m.beacon_info.@matthew:matrix.org.1",
"type": "m.beacon_info",
"state_key": "@matthew:matrix.org",
"content": {
"m.beacon_info": {
@@ -108,7 +98,7 @@ export type MBeaconInfoEvent = EitherAnd<
* m.beacon_info.* event content
*/
export type MBeaconInfoEventContent = &
MBeaconInfoEvent &
MBeaconInfoContent &
// creation timestamp of the beacon on the client
MTimestampEvent &
// the type of asset being tracked as per MSC3488
@@ -116,7 +106,7 @@ export type MBeaconInfoEventContent = &
/**
* m.beacon event example
* https://github.com/matrix-org/matrix-spec-proposals/pull/3489
* https://github.com/matrix-org/matrix-spec-proposals/pull/3672
*
* {
"type": "m.beacon",
+3 -8
View File
@@ -93,14 +93,7 @@ export enum RelationType {
Annotation = "m.annotation",
Replace = "m.replace",
Reference = "m.reference",
/**
* Note, "io.element.thread" is hardcoded
* Should be replaced with "m.thread" once MSC3440 lands
* Can not use `UnstableValue` as TypeScript does not
* allow computed values in enums
* https://github.com/microsoft/TypeScript/issues/27976
*/
Thread = "io.element.thread",
Thread = "m.thread",
}
export enum MsgType {
@@ -119,6 +112,8 @@ export const RoomCreateTypeField = "type";
export enum RoomType {
Space = "m.space",
UnstableCall = "org.matrix.msc3417.call",
ElementVideo = "io.element.video",
}
/**
+13 -14
View File
@@ -249,21 +249,20 @@ export class AutoDiscovery {
// Step 7: Copy any other keys directly into the clientConfig. This is for
// things like custom configuration of services.
Object.keys(wellknown)
.map((k) => {
if (k === "m.homeserver" || k === "m.identity_server") {
// Only copy selected parts of the config to avoid overwriting
// properties computed by the validation logic above.
const notProps = ["error", "state", "base_url"];
for (const prop of Object.keys(wellknown[k])) {
if (notProps.includes(prop)) continue;
clientConfig[k][prop] = wellknown[k][prop];
}
} else {
// Just copy the whole thing over otherwise
clientConfig[k] = wellknown[k];
Object.keys(wellknown).forEach((k) => {
if (k === "m.homeserver" || k === "m.identity_server") {
// Only copy selected parts of the config to avoid overwriting
// properties computed by the validation logic above.
const notProps = ["error", "state", "base_url"];
for (const prop of Object.keys(wellknown[k])) {
if (notProps.includes(prop)) continue;
clientConfig[k][prop] = wellknown[k][prop];
}
});
} else {
// Just copy the whole thing over otherwise
clientConfig[k] = wellknown[k];
}
});
// Step 8: Give the config to the caller (finally)
return Promise.resolve(clientConfig);
+199 -615
View File
File diff suppressed because it is too large Load Diff
+23 -10
View File
@@ -18,7 +18,7 @@ limitations under the License.
import { REFERENCE_RELATION } from "matrix-events-sdk";
import { MBeaconEventContent, MBeaconInfoContent, MBeaconInfoEventContent, M_BEACON_INFO } from "./@types/beacon";
import { MBeaconEventContent, MBeaconInfoContent, MBeaconInfoEventContent } from "./@types/beacon";
import { MsgType } from "./@types/event";
import { TEXT_NODE_TYPE } from "./@types/extensible_events";
import {
@@ -208,11 +208,9 @@ export const makeBeaconInfoContent: MakeBeaconInfoContent = (
assetType,
timestamp,
) => ({
[M_BEACON_INFO.name]: {
description,
timeout,
live: isLive,
},
description,
timeout,
live: isLive,
[M_TIMESTAMP.name]: timestamp || Date.now(),
[M_ASSET.name]: {
type: assetType ?? LocationAssetType.Self,
@@ -227,7 +225,7 @@ export type BeaconInfoState = MBeaconInfoContent & {
* Flatten beacon info event content
*/
export const parseBeaconInfoContent = (content: MBeaconInfoEventContent): BeaconInfoState => {
const { description, timeout, live } = M_BEACON_INFO.findIn<MBeaconInfoContent>(content);
const { description, timeout, live } = content;
const { type: assetType } = M_ASSET.findIn<MAssetContent>(content);
const timestamp = M_TIMESTAMP.findIn<number>(content);
@@ -243,14 +241,14 @@ export const parseBeaconInfoContent = (content: MBeaconInfoEventContent): Beacon
export type MakeBeaconContent = (
uri: string,
timestamp: number,
beaconInfoId: string,
beaconInfoEventId: string,
description?: string,
) => MBeaconEventContent;
export const makeBeaconContent: MakeBeaconContent = (
uri,
timestamp,
beaconInfoId,
beaconInfoEventId,
description,
) => ({
[M_LOCATION.name]: {
@@ -260,6 +258,21 @@ export const makeBeaconContent: MakeBeaconContent = (
[M_TIMESTAMP.name]: timestamp,
"m.relates_to": {
rel_type: REFERENCE_RELATION.name,
event_id: beaconInfoId,
event_id: beaconInfoEventId,
},
});
export type BeaconLocationState = MLocationContent & {
timestamp: number;
};
export const parseBeaconContent = (content: MBeaconEventContent): BeaconLocationState => {
const { description, uri } = M_LOCATION.findIn<MLocationContent>(content);
const timestamp = M_TIMESTAMP.findIn<number>(content);
return {
description,
uri,
timestamp,
};
};
+2 -2
View File
@@ -73,8 +73,8 @@ export function getHttpUriForMxc(
const fragmentOffset = serverAndMediaId.indexOf("#");
let fragment = "";
if (fragmentOffset >= 0) {
fragment = serverAndMediaId.substr(fragmentOffset);
serverAndMediaId = serverAndMediaId.substr(0, fragmentOffset);
fragment = serverAndMediaId.slice(fragmentOffset);
serverAndMediaId = serverAndMediaId.slice(0, fragmentOffset);
}
const urlParams = (Object.keys(params).length === 0 ? "" : ("?" + utils.encodeParams(params)));
+2 -2
View File
@@ -309,10 +309,10 @@ export class DeviceList extends TypedEventEmitter<EmittedEvents, CryptoEventHand
*/
private getDevicesFromStore(userIds: string[]): DeviceInfoMap {
const stored: DeviceInfoMap = {};
userIds.map((u) => {
userIds.forEach((u) => {
stored[u] = {};
const devices = this.getStoredDevicesForUser(u) || [];
devices.map(function(dev) {
devices.forEach(function(dev) {
stored[u][dev.deviceId] = dev;
});
});
+2 -4
View File
@@ -375,9 +375,7 @@ export class BackupManager {
);
if (device) {
sigInfo.device = device;
sigInfo.deviceTrust = await this.baseApis.checkDeviceTrust(
this.baseApis.getUserId(), sigInfo.deviceId,
);
sigInfo.deviceTrust = this.baseApis.checkDeviceTrust(this.baseApis.getUserId(), sigInfo.deviceId);
try {
await verifySignature(
this.baseApis.crypto.olmDevice,
@@ -495,7 +493,7 @@ export class BackupManager {
rooms[roomId] = { sessions: {} };
}
const sessionData = await this.baseApis.crypto.olmDevice.exportInboundGroupSession(
const sessionData = this.baseApis.crypto.olmDevice.exportInboundGroupSession(
session.senderKey, session.sessionId, session.sessionData,
);
sessionData.algorithm = MEGOLM_ALGORITHM;
+21 -2
View File
@@ -920,7 +920,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
// secrets using it, in theory. We could move them to the new key but a)
// that would mean we'd need to prompt for the old passphrase, and b)
// it's not clear that would be the right thing to do anyway.
const { keyInfo, privateKey } = await createSecretStorageKey();
const { keyInfo = {} as IAddSecretStorageKeyOpts, privateKey } = await createSecretStorageKey();
newKeyId = await createSSSS(keyInfo, privateKey);
} else if (!storageExists && keyBackupInfo) {
// we have an existing backup, but no SSSS
@@ -1026,7 +1026,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
const decodedBackupKey = new Uint8Array(olmlib.decodeBase64(
fixedBackupKey || sessionBackupKey,
));
await builder.addSessionBackupPrivateKeyToCache(decodedBackupKey);
builder.addSessionBackupPrivateKeyToCache(decodedBackupKey);
} else if (this.backupManager.getKeyBackupEnabled()) {
// key backup is enabled but we don't have a session backup key in SSSS: see if we have one in
// the cache or the user can provide one, and if so, write it to SSSS
@@ -1423,6 +1423,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
*/
@@ -228,8 +228,8 @@ export class LocalStorageCryptoStore extends MemoryCryptoStore {
// (hence 43 characters long).
func({
senderKey: key.substr(KEY_INBOUND_SESSION_PREFIX.length, 43),
sessionId: key.substr(KEY_INBOUND_SESSION_PREFIX.length + 44),
senderKey: key.slice(KEY_INBOUND_SESSION_PREFIX.length, KEY_INBOUND_SESSION_PREFIX.length + 43),
sessionId: key.slice(KEY_INBOUND_SESSION_PREFIX.length + 44),
sessionData: getJsonItem(this.store, key),
});
}
@@ -299,7 +299,7 @@ export class LocalStorageCryptoStore extends MemoryCryptoStore {
for (let i = 0; i < this.store.length; ++i) {
const key = this.store.key(i);
if (key.startsWith(prefix)) {
const roomId = key.substr(prefix.length);
const roomId = key.slice(prefix.length);
result[roomId] = getJsonItem(this.store, key);
}
}
@@ -313,8 +313,8 @@ export class LocalStorageCryptoStore extends MemoryCryptoStore {
for (const session in sessionsNeedingBackup) {
if (Object.prototype.hasOwnProperty.call(sessionsNeedingBackup, session)) {
// see getAllEndToEndInboundGroupSessions for the magic number explanations
const senderKey = session.substr(0, 43);
const sessionId = session.substr(44);
const senderKey = session.slice(0, 43);
const sessionId = session.slice(44);
this.getEndToEndInboundGroupSession(
senderKey, sessionId, null,
(sessionData) => {
@@ -325,7 +325,7 @@ export class LocalStorageCryptoStore extends MemoryCryptoStore {
});
},
);
if (limit && session.length >= limit) {
if (limit && sessions.length >= limit) {
break;
}
}
+4 -4
View File
@@ -418,8 +418,8 @@ export class MemoryCryptoStore implements CryptoStore {
// (hence 43 characters long).
func({
senderKey: key.substr(0, 43),
sessionId: key.substr(44),
senderKey: key.slice(0, 43),
sessionId: key.slice(44),
sessionData: this.inboundGroupSessions[key],
});
}
@@ -482,8 +482,8 @@ export class MemoryCryptoStore implements CryptoStore {
for (const session in this.sessionsNeedingBackup) {
if (this.inboundGroupSessions[session]) {
sessions.push({
senderKey: session.substr(0, 43),
sessionId: session.substr(44),
senderKey: session.slice(0, 43),
sessionId: session.slice(44),
sessionData: this.inboundGroupSessions[session],
});
if (limit && session.length >= limit) {
+1
View File
@@ -193,6 +193,7 @@ function calculateMAC(olmSAS: OlmSAS, method: string) {
}
const calculateKeyAgreement = {
// eslint-disable-next-line @typescript-eslint/naming-convention
"curve25519-hkdf-sha256": function(sas: SAS, olmSAS: OlmSAS, bytes: number): Uint8Array {
const ourInfo = `${sas.baseApis.getUserId()}|${sas.baseApis.deviceId}|`
+ `${sas.ourSASPubKey}|`;
@@ -796,8 +796,7 @@ export class VerificationRequest<
}
private setupTimeout(phase: Phase): void {
const shouldTimeout = !this.timeoutTimer && !this.observeOnly &&
phase === PHASE_REQUESTED;
const shouldTimeout = !this.timeoutTimer && !this.observeOnly && phase === PHASE_REQUESTED;
if (shouldTimeout) {
this.timeoutTimer = setTimeout(this.cancelOnTimeout, this.timeout);
@@ -814,15 +813,15 @@ export class VerificationRequest<
}
}
private cancelOnTimeout = () => {
private cancelOnTimeout = async () => {
try {
if (this.initiatedByMe) {
this.cancel({
await this.cancel({
reason: "Other party didn't accept in time",
code: "m.timeout",
});
} else {
this.cancel({
await this.cancel({
reason: "User didn't accept in time",
code: "m.timeout",
});
+25 -5
View File
@@ -25,15 +25,31 @@ export interface MapperOpts {
}
export function eventMapperFor(client: MatrixClient, options: MapperOpts): EventMapper {
const preventReEmit = Boolean(options.preventReEmit);
let preventReEmit = Boolean(options.preventReEmit);
const decrypt = options.decrypt !== false;
function mapper(plainOldJsObject: Partial<IEvent>) {
const event = new MatrixEvent(plainOldJsObject);
const room = client.getRoom(plainOldJsObject.room_id);
const room = client.getRoom(event.getRoomId());
if (room?.threads.has(event.getId())) {
event.setThread(room.threads.get(event.getId()));
let event: MatrixEvent;
// If the event is already known to the room, let's re-use the model rather than duplicating.
// We avoid doing this to state events as they may be forward or backwards looking which tweaks behaviour.
if (room && plainOldJsObject.state_key === undefined) {
event = room.findEventById(plainOldJsObject.event_id);
}
if (!event || event.status) {
event = new MatrixEvent(plainOldJsObject);
} else {
// merge the latest unsigned data from the server
event.setUnsigned({ ...event.getUnsigned(), ...plainOldJsObject.unsigned });
// prevent doubling up re-emitters
preventReEmit = true;
}
const thread = room?.findThreadForEvent(event);
if (thread) {
event.setThread(thread);
}
if (event.isEncrypted()) {
@@ -46,11 +62,15 @@ export function eventMapperFor(client: MatrixClient, options: MapperOpts): Event
client.decryptEventIfNeeded(event);
}
}
if (!preventReEmit) {
client.reEmitter.reEmit(event, [
MatrixEventEvent.Replaced,
MatrixEventEvent.VisibilityChange,
]);
room?.reEmitter.reEmit(event, [
MatrixEventEvent.BeforeRedaction,
]);
}
return event;
}
+1 -1
View File
@@ -36,7 +36,7 @@ import {
function matchesWildcard(actualValue: string, filterValue: string): boolean {
if (filterValue.endsWith("*")) {
const typePrefix = filterValue.slice(0, -1);
return actualValue.substr(0, typePrefix.length) === typePrefix;
return actualValue.slice(0, typePrefix.length) === typePrefix;
} else {
return actualValue === filterValue;
}
+1 -1
View File
@@ -1105,7 +1105,7 @@ export class AbortError extends Error {
* @return {any} the result of the network operation
* @throws {ConnectionError} If after maxAttempts the callback still throws ConnectionError
*/
export async function retryNetworkOperation<T>(maxAttempts: number, callback: () => T): Promise<T> {
export async function retryNetworkOperation<T>(maxAttempts: number, callback: () => Promise<T>): Promise<T> {
let attempts = 0;
let lastConnectionError = null;
while (attempts < maxAttempts) {
+7 -2
View File
@@ -60,7 +60,11 @@ export enum AuthType {
Sso = "m.login.sso",
SsoUnstable = "org.matrix.login.sso",
Dummy = "m.login.dummy",
RegistrationToken = "org.matrix.msc3231.login.registration_token",
RegistrationToken = "m.login.registration_token",
// For backwards compatability with servers that have not yet updated to
// use the stable "m.login.registration_token" type.
// The authentication flow is the same in both cases.
UnstableRegistrationToken = "org.matrix.msc3231.login.registration_token",
}
export interface IAuthDict {
@@ -79,7 +83,8 @@ export interface IAuthDict {
// eslint-disable-next-line camelcase
threepid_creds?: any;
threepidCreds?: any;
registrationToken?: string;
// For m.login.registration_token type
token?: string;
}
class NoAuthFlowFoundError extends Error {
-1
View File
@@ -30,7 +30,6 @@ export * from "./errors";
export * from "./models/beacon";
export * from "./models/event";
export * from "./models/room";
export * from "./models/group";
export * from "./models/event-timeline";
export * from "./models/event-timeline-set";
export * from "./models/room-member";
+70 -11
View File
@@ -14,20 +14,27 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { M_BEACON_INFO } from "../@types/beacon";
import { BeaconInfoState, parseBeaconInfoContent } from "../content-helpers";
import { MBeaconEventContent } from "../@types/beacon";
import { M_TIMESTAMP } from "../@types/location";
import { BeaconInfoState, BeaconLocationState, parseBeaconContent, parseBeaconInfoContent } from "../content-helpers";
import { MatrixEvent } from "../matrix";
import { sortEventsByLatestContentTimestamp } from "../utils";
import { TypedEventEmitter } from "./typed-event-emitter";
export enum BeaconEvent {
New = "Beacon.new",
Update = "Beacon.update",
LivenessChange = "Beacon.LivenessChange",
Destroy = "Beacon.Destroy",
LocationUpdate = "Beacon.LocationUpdate",
}
export type BeaconEventHandlerMap = {
[BeaconEvent.Update]: (event: MatrixEvent, beacon: Beacon) => void;
[BeaconEvent.LivenessChange]: (isLive: boolean, beacon: Beacon) => void;
[BeaconEvent.Destroy]: (beaconIdentifier: string) => void;
[BeaconEvent.LocationUpdate]: (locationState: BeaconLocationState) => void;
[BeaconEvent.Destroy]: (beaconIdentifier: string) => void;
};
export const isTimestampInDuration = (
@@ -36,16 +43,19 @@ export const isTimestampInDuration = (
timestamp: number,
): boolean => timestamp >= startTimestamp && startTimestamp + durationMs >= timestamp;
export const isBeaconInfoEventType = (type: string) =>
type.startsWith(M_BEACON_INFO.name) ||
type.startsWith(M_BEACON_INFO.altName);
// beacon info events are uniquely identified by
// `<roomId>_<state_key>`
export type BeaconIdentifier = string;
export const getBeaconInfoIdentifier = (event: MatrixEvent): BeaconIdentifier =>
`${event.getRoomId()}_${event.getStateKey()}`;
// https://github.com/matrix-org/matrix-spec-proposals/pull/3489
// https://github.com/matrix-org/matrix-spec-proposals/pull/3672
export class Beacon extends TypedEventEmitter<Exclude<BeaconEvent, BeaconEvent.New>, BeaconEventHandlerMap> {
public readonly roomId: string;
private _beaconInfo: BeaconInfoState;
private _isLive: boolean;
private livenessWatchInterval: number;
private _latestLocationState: BeaconLocationState | undefined;
constructor(
private rootEvent: MatrixEvent,
@@ -59,8 +69,8 @@ export class Beacon extends TypedEventEmitter<Exclude<BeaconEvent, BeaconEvent.N
return this._isLive;
}
public get identifier(): string {
return this.beaconInfoEventType;
public get identifier(): BeaconIdentifier {
return getBeaconInfoIdentifier(this.rootEvent);
}
public get beaconInfoId(): string {
@@ -79,20 +89,32 @@ export class Beacon extends TypedEventEmitter<Exclude<BeaconEvent, BeaconEvent.N
return this._beaconInfo;
}
public get latestLocationState(): BeaconLocationState | undefined {
return this._latestLocationState;
}
public update(beaconInfoEvent: MatrixEvent): void {
if (beaconInfoEvent.getType() !== this.beaconInfoEventType) {
if (getBeaconInfoIdentifier(beaconInfoEvent) !== this.identifier) {
throw new Error('Invalid updating event');
}
// don't update beacon with an older event
if (beaconInfoEvent.event.origin_server_ts < this.rootEvent.event.origin_server_ts) {
return;
}
this.rootEvent = beaconInfoEvent;
this.setBeaconInfo(this.rootEvent);
this.emit(BeaconEvent.Update, beaconInfoEvent, this);
this.clearLatestLocation();
}
public destroy(): void {
if (this.livenessWatchInterval) {
clearInterval(this.livenessWatchInterval);
}
this._isLive = false;
this.emit(BeaconEvent.Destroy, this.identifier);
}
/**
@@ -104,14 +126,51 @@ export class Beacon extends TypedEventEmitter<Exclude<BeaconEvent, BeaconEvent.N
clearInterval(this.livenessWatchInterval);
}
this.checkLiveness();
if (this.isLive) {
const expiryInMs = (this._beaconInfo?.timestamp + this._beaconInfo?.timeout + 1) - Date.now();
const expiryInMs = (this._beaconInfo?.timestamp + this._beaconInfo?.timeout) - Date.now();
if (expiryInMs > 1) {
this.livenessWatchInterval = setInterval(this.checkLiveness.bind(this), expiryInMs);
this.livenessWatchInterval = setInterval(
() => { this.monitorLiveness(); },
expiryInMs,
);
}
}
}
/**
* Process Beacon locations
* Emits BeaconEvent.LocationUpdate
*/
public addLocations(beaconLocationEvents: MatrixEvent[]): void {
// discard locations for beacons that are not live
if (!this.isLive) {
return;
}
const validLocationEvents = beaconLocationEvents.filter(event => {
const content = event.getContent<MBeaconEventContent>();
const timestamp = M_TIMESTAMP.findIn<number>(content);
return (
// only include positions that were taken inside the beacon's live period
isTimestampInDuration(this._beaconInfo.timestamp, this._beaconInfo.timeout, timestamp) &&
// ignore positions older than our current latest location
(!this.latestLocationState || timestamp > this.latestLocationState.timestamp)
);
});
const latestLocationEvent = validLocationEvents.sort(sortEventsByLatestContentTimestamp)?.[0];
if (latestLocationEvent) {
this._latestLocationState = parseBeaconContent(latestLocationEvent.getContent());
this.emit(BeaconEvent.LocationUpdate, this.latestLocationState);
}
}
private clearLatestLocation = () => {
this._latestLocationState = undefined;
this.emit(BeaconEvent.LocationUpdate, this.latestLocationState);
};
private setBeaconInfo(event: MatrixEvent): void {
this._beaconInfo = parseBeaconInfoContent(event.getContent());
this.checkLiveness();
+1 -1
View File
@@ -42,7 +42,7 @@ export class EventContext {
*
* @constructor
*/
constructor(ourEvent: MatrixEvent) {
constructor(public readonly ourEvent: MatrixEvent) {
this.timeline = [ourEvent];
}
+2 -3
View File
@@ -775,7 +775,7 @@ export class EventTimelineSet extends TypedEventEmitter<EmittedEvents, EventTime
}
public getAllRelationsEventForEvent(eventId: string): MatrixEvent[] {
const relationsForEvent = this.relations[eventId] || {};
const relationsForEvent = this.relations?.[eventId] || {};
const events = [];
for (const relationsRecord of Object.values(relationsForEvent)) {
for (const relations of Object.values(relationsRecord)) {
@@ -852,14 +852,13 @@ export class EventTimelineSet extends TypedEventEmitter<EmittedEvents, EventTime
}
let relationsWithEventType = relationsWithRelType[eventType];
let relatesToEvent;
if (!relationsWithEventType) {
relationsWithEventType = relationsWithRelType[eventType] = new Relations(
relationType,
eventType,
this.room,
);
relatesToEvent = this.findEventById(relatesToEventId) || this.room.getPendingEvent(relatesToEventId);
const relatesToEvent = this.findEventById(relatesToEventId) || this.room.getPendingEvent(relatesToEventId);
if (relatesToEvent) {
relationsWithEventType.setTargetEvent(relatesToEvent);
}
+15 -9
View File
@@ -86,9 +86,17 @@ export interface IEvent {
unsigned: IUnsigned;
redacts?: string;
// v1 legacy fields
/**
* @deprecated
*/
user_id?: string;
/**
* @deprecated
*/
prev_content?: IContent;
/**
* @deprecated
*/
age?: number;
}
@@ -111,11 +119,6 @@ export interface IEventRelation {
key?: string;
}
export interface IVisibilityEventRelation extends IEventRelation {
visibility: "visible" | "hidden";
reason?: string;
}
/**
* When an event is a visibility change event, as per MSC3531,
* the visibility change implied by the event.
@@ -279,7 +282,7 @@ export class MatrixEvent extends TypedEventEmitter<EmittedEvents, MatrixEventHan
public target: RoomMember = null;
public status: EventStatus = null;
public error: MatrixError = null;
public forwardLooking = true;
public forwardLooking = true; // only state events may be backwards looking
/* If the event is a `m.key.verification.request` (or to_device `m.key.verification.start`) event,
* `Crypto` will set this the `VerificationRequest` for the event
@@ -1300,8 +1303,7 @@ export class MatrixEvent extends TypedEventEmitter<EmittedEvents, MatrixEventHan
public isRelation(relType: string = undefined): boolean {
// Relation info is lifted out of the encrypted content when sent to
// encrypted rooms, so we have to check `getWireContent` for this.
const content = this.getWireContent();
const relation = content && content["m.relates_to"];
const relation = this.getWireContent()?.["m.relates_to"];
return relation && relation.rel_type && relation.event_id &&
((relType && relation.rel_type === relType) || !relType);
}
@@ -1333,6 +1335,10 @@ export class MatrixEvent extends TypedEventEmitter<EmittedEvents, MatrixEventHan
if (this.isRedacted() && newEvent) {
return;
}
// don't allow state events to be replaced using this mechanism as per MSC2676
if (this.isState()) {
return;
}
if (this._replacingEvent !== newEvent) {
this._replacingEvent = newEvent;
this.emit(MatrixEventEvent.Replaced, this);
-100
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;
* });
*/
+1 -1
View File
@@ -331,7 +331,7 @@ export class Relations extends TypedEventEmitter<RelationsEvent, EventHandlerMap
// the all-knowning server tells us that the event at some point had
// this timestamp for its replacement, so any following replacement should definitely not be less
const replaceRelation = this.targetEvent.getServerAggregatedRelation<IAggregatedRelation>(RelationType.Replace);
const minTs = replaceRelation && replaceRelation.origin_server_ts;
const minTs = replaceRelation?.origin_server_ts;
const lastReplacement = this.getRelations().reduce((last, event) => {
if (event.getSender() !== this.targetEvent.getSender()) {
+82 -17
View File
@@ -22,12 +22,14 @@ import { RoomMember } from "./room-member";
import { logger } from '../logger';
import * as utils from "../utils";
import { EventType } from "../@types/event";
import { MatrixEvent } from "./event";
import { MatrixEvent, MatrixEventEvent } from "./event";
import { MatrixClient } from "../client";
import { GuestAccess, HistoryVisibility, IJoinRuleEventContent, JoinRule } from "../@types/partials";
import { TypedEventEmitter } from "./typed-event-emitter";
import { Beacon, BeaconEvent, isBeaconInfoEventType, BeaconEventHandlerMap } from "./beacon";
import { Beacon, BeaconEvent, BeaconEventHandlerMap } from "./beacon";
import { TypedReEmitter } from "../ReEmitter";
import { M_BEACON, M_BEACON_INFO } from "../@types/beacon";
import { getBeaconInfoIdentifier, BeaconIdentifier } from "./beacon";
// possible statuses for out-of-band member loading
enum OobStatus {
@@ -80,8 +82,8 @@ export class RoomState extends TypedEventEmitter<EmittedEvents, EventHandlerMap>
public events = new Map<string, Map<string, MatrixEvent>>(); // Map<eventType, Map<stateKey, MatrixEvent>>
public paginationToken: string = null;
public readonly beacons = new Map<string, Beacon>();
private liveBeaconIds: string[] = [];
public readonly beacons = new Map<BeaconIdentifier, Beacon>();
private _liveBeaconIds: BeaconIdentifier[] = [];
/**
* Construct room state.
@@ -248,6 +250,10 @@ export class RoomState extends TypedEventEmitter<EmittedEvents, EventHandlerMap>
return !!this.liveBeaconIds?.length;
}
public get liveBeaconIds(): BeaconIdentifier[] {
return this._liveBeaconIds;
}
/**
* Creates a copy of this room state so that mutations to either won't affect the other.
* @return {RoomState} the copy of the room state
@@ -330,7 +336,7 @@ export class RoomState extends TypedEventEmitter<EmittedEvents, EventHandlerMap>
return;
}
if (isBeaconInfoEventType(event.getType())) {
if (M_BEACON_INFO.matches(event.getType())) {
this.setBeacon(event);
}
@@ -404,6 +410,51 @@ export class RoomState extends TypedEventEmitter<EmittedEvents, EventHandlerMap>
this.emit(RoomStateEvent.Update, this);
}
public processBeaconEvents(events: MatrixEvent[], matrixClient: MatrixClient): void {
if (
!events.length ||
// discard locations if we have no beacons
!this.beacons.size
) {
return;
}
const beaconByEventIdDict: Record<string, Beacon> =
[...this.beacons.values()].reduce((dict, beacon) => ({ ...dict, [beacon.beaconInfoId]: beacon }), {});
const processBeaconRelation = (beaconInfoEventId: string, event: MatrixEvent): void => {
if (!M_BEACON.matches(event.getType())) {
return;
}
const beacon = beaconByEventIdDict[beaconInfoEventId];
if (beacon) {
beacon.addLocations([event]);
}
};
events.forEach((event: MatrixEvent) => {
const relatedToEventId = event.getRelation()?.event_id;
// not related to a beacon we know about
// discard
if (!beaconByEventIdDict[relatedToEventId]) {
return;
}
matrixClient.decryptEventIfNeeded(event);
if (event.isBeingDecrypted() || event.isDecryptionFailure()) {
// add an event listener for once the event is decrypted.
event.once(MatrixEventEvent.Decrypted, async () => {
processBeaconRelation(relatedToEventId, event);
});
} else {
processBeaconRelation(relatedToEventId, event);
}
});
}
/**
* Looks up a member by the given userId, and if it doesn't exist,
* create it and emit the `RoomState.newMember` event.
@@ -437,8 +488,24 @@ export class RoomState extends TypedEventEmitter<EmittedEvents, EventHandlerMap>
* @experimental
*/
private setBeacon(event: MatrixEvent): void {
if (this.beacons.has(event.getType())) {
return this.beacons.get(event.getType()).update(event);
const beaconIdentifier = getBeaconInfoIdentifier(event);
if (this.beacons.has(beaconIdentifier)) {
const beacon = this.beacons.get(beaconIdentifier);
if (event.isRedacted()) {
if (beacon.beaconInfoId === event.getRedactionEvent()?.['redacts']) {
beacon.destroy();
this.beacons.delete(beaconIdentifier);
}
return;
}
return beacon.update(event);
}
if (event.isRedacted()) {
return;
}
const beacon = new Beacon(event);
@@ -446,30 +513,28 @@ export class RoomState extends TypedEventEmitter<EmittedEvents, EventHandlerMap>
this.reEmitter.reEmit<BeaconEvent, BeaconEvent>(beacon, [
BeaconEvent.New,
BeaconEvent.Update,
BeaconEvent.Destroy,
BeaconEvent.LivenessChange,
]);
this.emit(BeaconEvent.New, event, beacon);
beacon.on(BeaconEvent.LivenessChange, this.onBeaconLivenessChange.bind(this));
this.beacons.set(beacon.beaconInfoEventType, beacon);
beacon.on(BeaconEvent.Destroy, this.onBeaconLivenessChange.bind(this));
this.beacons.set(beacon.identifier, beacon);
}
/**
* @experimental
* Check liveness of room beacons
* emit RoomStateEvent.BeaconLiveness when
* roomstate.hasLiveBeacons has changed
* emit RoomStateEvent.BeaconLiveness event
*/
private onBeaconLivenessChange(): void {
const prevHasLiveBeacons = !!this.liveBeaconIds?.length;
this.liveBeaconIds = Array.from(this.beacons.values())
this._liveBeaconIds = Array.from(this.beacons.values())
.filter(beacon => beacon.isLive)
.map(beacon => beacon.beaconInfoId);
.map(beacon => beacon.identifier);
const hasLiveBeacons = !!this.liveBeaconIds.length;
if (prevHasLiveBeacons !== hasLiveBeacons) {
this.emit(RoomStateEvent.BeaconLiveness, this, hasLiveBeacons);
}
this.emit(RoomStateEvent.BeaconLiveness, this, this.hasLiveBeacons);
}
private getStateEventMatching(event: MatrixEvent): MatrixEvent | null {
+385 -183
View File
@@ -23,7 +23,7 @@ import { Direction, EventTimeline } from "./event-timeline";
import { getHttpUriForMxc } from "../content-repo";
import * as utils from "../utils";
import { normalize } from "../utils";
import { IEvent, MatrixEvent } from "./event";
import { IEvent, IThreadBundledRelationship, MatrixEvent, MatrixEventEvent, MatrixEventHandlerMap } from "./event";
import { EventStatus } from "./event-status";
import { RoomMember } from "./room-member";
import { IRoomSummary, RoomSummary } from "./room-summary";
@@ -32,6 +32,7 @@ import { TypedReEmitter } from '../ReEmitter';
import {
EventType, RoomCreateTypeField, RoomType, UNSTABLE_ELEMENT_FUNCTIONAL_USERS,
EVENT_VISIBILITY_CHANGE_TYPE,
RelationType,
} from "../@types/event";
import { IRoomVersionsCapability, MatrixClient, PendingEventOrdering, RoomVersionStability } from "../client";
import { GuestAccess, HistoryVisibility, JoinRule, ResizeMethod } from "../@types/partials";
@@ -45,8 +46,8 @@ import {
FILTER_RELATED_BY_SENDERS,
ThreadFilterType,
} from "./thread";
import { Method } from "../http-api";
import { TypedEventEmitter } from "./typed-event-emitter";
import { IStateEventWithRoomId } from "../@types/search";
// These constants are used as sane defaults when the homeserver doesn't support
// the m.room_versions capability. In practice, KNOWN_SAFE_ROOM_VERSION should be
@@ -54,8 +55,8 @@ import { TypedEventEmitter } from "./typed-event-emitter";
// room versions which are considered okay for people to run without being asked
// to upgrade (ie: "stable"). Eventually, we should remove these when all homeservers
// return an m.room_versions capability.
const KNOWN_SAFE_ROOM_VERSION = '6';
const SAFE_ROOM_VERSIONS = ['1', '2', '3', '4', '5', '6'];
const KNOWN_SAFE_ROOM_VERSION = '9';
const SAFE_ROOM_VERSIONS = ['1', '2', '3', '4', '5', '6', '7', '8', '9'];
function synthesizeReceipt(userId: string, event: MatrixEvent, receiptType: string): MatrixEvent {
// console.log("synthesizing receipt for "+event.getId());
@@ -148,6 +149,7 @@ export interface ICreateFilterOpts {
// timeline. Useful to disable for some filters that can't be achieved by the
// client in an efficient manner
prepopulateTimeline?: boolean;
useSyncEvents?: boolean;
pendingEvents?: boolean;
}
@@ -167,8 +169,10 @@ export enum RoomEvent {
type EmittedEvents = RoomEvent
| ThreadEvent.New
| ThreadEvent.Update
| ThreadEvent.NewReply
| RoomEvent.Timeline
| RoomEvent.TimelineReset;
| RoomEvent.TimelineReset
| MatrixEventEvent.BeforeRedaction;
export type RoomEventHandlerMap = {
[RoomEvent.MyMembership]: (room: Room, membership: string, prevMembership?: string) => void;
@@ -185,10 +189,10 @@ export type RoomEventHandlerMap = {
oldStatus?: EventStatus,
) => void;
[ThreadEvent.New]: (thread: Thread, toStartOfTimeline: boolean) => void;
} & ThreadHandlerMap;
} & ThreadHandlerMap & MatrixEventHandlerMap;
export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap> {
private readonly reEmitter: TypedReEmitter<EmittedEvents, RoomEventHandlerMap>;
public readonly reEmitter: TypedReEmitter<EmittedEvents, RoomEventHandlerMap>;
private txnToEvent: Record<string, MatrixEvent> = {}; // Pending in-flight requests { string: MatrixEvent }
// receipts should clobber based on receipt_type and user_id pairs hence
// the form of this structure. This is sub-optimal for the exposed APIs
@@ -260,7 +264,7 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
/**
* @experimental
*/
public threads = new Map<string, Thread>();
private threads = new Map<string, Thread>();
public lastThread: Thread;
/**
@@ -378,7 +382,7 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
return this.threadTimelineSetsPromise;
}
if (this.client?.supportsExperimentalThreads) {
if (this.client?.supportsExperimentalThreads()) {
try {
this.threadTimelineSetsPromise = Promise.all([
this.createThreadTimelineSet(),
@@ -782,16 +786,9 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
}
}
private async loadMembersFromServer(): Promise<IEvent[]> {
private async loadMembersFromServer(): Promise<IStateEventWithRoomId[]> {
const lastSyncToken = this.client.store.getSyncToken();
const queryString = utils.encodeParams({
not_membership: "leave",
at: lastSyncToken,
});
const path = utils.encodeUri("/rooms/$roomId/members?" + queryString,
{ $roomId: this.roomId });
const http = this.client.http;
const response = await http.authedRequest<{ chunk: IEvent[] }>(undefined, Method.Get, path);
const response = await this.client.members(this.roomId, undefined, "leave", lastSyncToken);
return response.chunk;
}
@@ -800,12 +797,13 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
let fromServer = false;
let rawMembersEvents = await this.client.store.getOutOfBandMembers(this.roomId);
// If the room is encrypted, we always fetch members from the server at
// least once, in case the latest state wasn't persisted properly. Note
// least once, in case the latest state wasn't persisted properly. Note
// that this function is only called once (unless loading the members
// fails), since loadMembersIfNeeded always returns this.membersPromise
// if set, which will be the result of the first (successful) call.
if (rawMembersEvents === null ||
(this.client.isCryptoEnabled() && this.client.isRoomEncrypted(this.roomId))) {
(this.client.isCryptoEnabled() && this.client.isRoomEncrypted(this.roomId))
) {
fromServer = true;
rawMembersEvents = await this.loadMembersFromServer();
logger.log(`LL: got ${rawMembersEvents.length} ` +
@@ -851,7 +849,7 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
if (fromServer) {
const oobMembers = this.currentState.getMembers()
.filter((m) => m.isOutOfBand())
.map((m) => m.events.member.event as IEvent);
.map((m) => m.events.member.event as IStateEventWithRoomId);
logger.log(`LL: telling store to write ${oobMembers.length}`
+ ` members for room ${this.roomId}`);
const store = this.client.store;
@@ -1002,17 +1000,15 @@ 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
*/
public findEventById(eventId: string): MatrixEvent | undefined {
let event = this.getUnfilteredTimelineSet().findEventById(eventId);
if (event) {
return event;
} else {
if (!event) {
const threads = this.getThreads();
for (let i = 0; i < threads.length; i++) {
const thread = threads[i];
@@ -1022,6 +1018,8 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
}
}
}
return event;
}
/**
@@ -1201,19 +1199,14 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
timeline: EventTimeline,
paginationToken?: string,
): void {
timeline.getTimelineSet().addEventsToTimeline(
events, toStartOfTimeline,
timeline, paginationToken,
);
timeline.getTimelineSet().addEventsToTimeline(events, toStartOfTimeline, timeline, paginationToken);
}
/**
* @experimental
*/
public getThread(eventId: string): Thread {
return this.getThreads().find(thread => {
return thread.id === eventId;
});
return this.threads.get(eventId);
}
/**
@@ -1346,6 +1339,7 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
filter: Filter,
{
prepopulateTimeline = true,
useSyncEvents = true,
pendingEvents = true,
}: ICreateFilterOpts = {},
): EventTimelineSet {
@@ -1358,8 +1352,10 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
RoomEvent.Timeline,
RoomEvent.TimelineReset,
]);
this.filteredTimelineSets[filter.filterId] = timelineSet;
this.timelineSets.push(timelineSet);
if (useSyncEvents) {
this.filteredTimelineSets[filter.filterId] = timelineSet;
this.timelineSets.push(timelineSet);
}
const unfilteredLiveTimeline = this.getLiveTimeline();
// Not all filter are possible to replicate client-side only
@@ -1387,7 +1383,7 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
timeline.getPaginationToken(EventTimeline.BACKWARDS),
EventTimeline.BACKWARDS,
);
} else {
} else if (useSyncEvents) {
const livePaginationToken = unfilteredLiveTimeline.getPaginationToken(Direction.Forward);
timelineSet
.getLiveTimeline()
@@ -1405,41 +1401,46 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
return timelineSet;
}
private async getThreadListFilter(filterType = ThreadFilterType.All): Promise<Filter> {
const myUserId = this.client.getUserId();
const filter = new Filter(myUserId);
const definition: IFilterDefinition = {
"room": {
"timeline": {
[FILTER_RELATED_BY_REL_TYPES.name]: [THREAD_RELATION_TYPE.name],
},
},
};
if (filterType === ThreadFilterType.My) {
definition.room.timeline[FILTER_RELATED_BY_SENDERS.name] = [myUserId];
}
filter.setDefinition(definition);
const filterId = await this.client.getOrCreateFilter(
`THREAD_PANEL_${this.roomId}_${filterType}`,
filter,
);
filter.filterId = filterId;
return filter;
}
private async createThreadTimelineSet(filterType?: ThreadFilterType): Promise<EventTimelineSet> {
let timelineSet: EventTimelineSet;
if (Thread.hasServerSideSupport) {
const myUserId = this.client.getUserId();
const filter = new Filter(myUserId);
const filter = await this.getThreadListFilter(filterType);
const definition: IFilterDefinition = {
"room": {
"timeline": {
[FILTER_RELATED_BY_REL_TYPES.name]: [THREAD_RELATION_TYPE.name],
},
},
};
if (filterType === ThreadFilterType.My) {
definition.room.timeline[FILTER_RELATED_BY_SENDERS.name] = [myUserId];
}
filter.setDefinition(definition);
const filterId = await this.client.getOrCreateFilter(
`THREAD_PANEL_${this.roomId}_${filterType}`,
filter,
);
filter.filterId = filterId;
timelineSet = this.getOrCreateFilteredTimelineSet(
filter,
{
prepopulateTimeline: false,
useSyncEvents: false,
pendingEvents: false,
},
);
// An empty pagination token allows to paginate from the very bottom of
// the timeline set.
timelineSet.getLiveTimeline().setPaginationToken("", EventTimeline.BACKWARDS);
} else {
timelineSet = new EventTimelineSet(this, {
pendingEvents: false,
@@ -1460,6 +1461,86 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
return timelineSet;
}
public threadsReady = false;
public async fetchRoomThreads(): Promise<void> {
if (this.threadsReady || !this.client.supportsExperimentalThreads()) {
return;
}
const allThreadsFilter = await this.getThreadListFilter();
const { chunk: events } = await this.client.createMessagesRequest(
this.roomId,
"",
Number.MAX_SAFE_INTEGER,
Direction.Backward,
allThreadsFilter,
);
if (!events.length) return;
// Sorted by last_reply origin_server_ts
const threadRoots = events
.map(this.client.getEventMapper())
.sort((eventA, eventB) => {
/**
* `origin_server_ts` in a decentralised world is far from ideal
* but for lack of any better, we will have to use this
* Long term the sorting should be handled by homeservers and this
* is only meant as a short term patch
*/
const threadAMetadata = eventA
.getServerAggregatedRelation<IThreadBundledRelationship>(RelationType.Thread);
const threadBMetadata = eventB
.getServerAggregatedRelation<IThreadBundledRelationship>(RelationType.Thread);
return threadAMetadata.latest_event.origin_server_ts - threadBMetadata.latest_event.origin_server_ts;
});
let latestMyThreadsRootEvent: MatrixEvent;
const roomState = this.getLiveTimeline().getState(EventTimeline.FORWARDS);
for (const rootEvent of threadRoots) {
this.threadsTimelineSets[0].addLiveEvent(
rootEvent,
DuplicateStrategy.Ignore,
false,
roomState,
);
const threadRelationship = rootEvent
.getServerAggregatedRelation<IThreadBundledRelationship>(RelationType.Thread);
if (threadRelationship.current_user_participated) {
this.threadsTimelineSets[1].addLiveEvent(
rootEvent,
DuplicateStrategy.Ignore,
false,
roomState,
);
latestMyThreadsRootEvent = rootEvent;
}
if (!this.getThread(rootEvent.getId())) {
this.createThread(rootEvent.getId(), rootEvent, [], true);
}
}
this.client.decryptEventIfNeeded(threadRoots[threadRoots.length -1]);
if (latestMyThreadsRootEvent) {
this.client.decryptEventIfNeeded(latestMyThreadsRootEvent);
}
this.threadsReady = true;
this.on(ThreadEvent.NewReply, this.onThreadNewReply);
}
private onThreadNewReply(thread: Thread): void {
for (const timelineSet of this.threadsTimelineSets) {
timelineSet.removeEvent(thread.id);
timelineSet.addLiveEvent(thread.rootEvent);
}
}
/**
* Forget the timelineSet for this room with the given filter
*
@@ -1474,63 +1555,106 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
}
}
public findThreadForEvent(event: MatrixEvent): Thread | null {
if (!event) {
return null;
public eventShouldLiveIn(event: MatrixEvent, events?: MatrixEvent[], roots?: Set<string>): {
shouldLiveInRoom: boolean;
shouldLiveInThread: boolean;
threadId?: string;
} {
if (!this.client.supportsExperimentalThreads()) {
return {
shouldLiveInRoom: true,
shouldLiveInThread: false,
};
}
// A thread root is always shown in both timelines
if (event.isThreadRoot || roots?.has(event.getId())) {
return {
shouldLiveInRoom: true,
shouldLiveInThread: true,
threadId: event.getId(),
};
}
// A thread relation is always only shown in a thread
if (event.isThreadRelation) {
return this.threads.get(event.threadRootId);
} else if (event.isThreadRoot) {
return this.threads.get(event.getId());
return {
shouldLiveInRoom: false,
shouldLiveInThread: true,
threadId: event.threadRootId,
};
}
const parentEventId = event.getAssociatedId();
const parentEvent = this.findEventById(parentEventId) ?? events?.find(e => e.getId() === parentEventId);
// Treat relations and redactions as extensions of their parents so evaluate parentEvent instead
if (parentEvent && (event.isRelation() || event.isRedaction())) {
return this.eventShouldLiveIn(parentEvent, events, roots);
}
// Edge case where we know the event is a relation but don't have the parentEvent
if (roots?.has(event.relationEventId)) {
return {
shouldLiveInRoom: true,
shouldLiveInThread: true,
threadId: event.relationEventId,
};
}
// We've exhausted all scenarios, can safely assume that this event should live in the room timeline only
return {
shouldLiveInRoom: true,
shouldLiveInThread: false,
};
}
public findThreadForEvent(event?: MatrixEvent): Thread | null {
if (!event) return null;
const { threadId } = this.eventShouldLiveIn(event);
return threadId ? this.getThread(threadId) : null;
}
private addThreadedEvents(threadId: string, events: MatrixEvent[], toStartOfTimeline = false): void {
let thread = this.getThread(threadId);
if (thread) {
thread.addEvents(events, toStartOfTimeline);
} else {
const parentEvent = this.findEventById(event.getAssociatedId());
return this.findThreadForEvent(parentEvent);
const rootEvent = this.findEventById(threadId) ?? events.find(e => e.getId() === threadId);
thread = this.createThread(threadId, rootEvent, events, toStartOfTimeline);
this.emit(ThreadEvent.Update, thread);
}
}
/**
* Add an event to a thread's timeline. Will fire "Thread.update"
* Adds events to a thread's timeline. Will fire "Thread.update"
* @experimental
*/
public async addThreadedEvent(event: MatrixEvent, toStartOfTimeline: boolean): Promise<void> {
this.applyRedaction(event);
let thread = this.findThreadForEvent(event);
if (thread) {
thread.addEvent(event, toStartOfTimeline);
} else {
const events = [event];
let rootEvent = this.findEventById(event.threadRootId);
// If the rootEvent does not exist in the current sync, then look for
// it over the network
try {
let eventData;
if (event.threadRootId) {
eventData = await this.client.fetchRoomEvent(this.roomId, event.threadRootId);
}
public processThreadedEvents(events: MatrixEvent[], toStartOfTimeline: boolean): void {
events.forEach(this.applyRedaction);
if (!rootEvent) {
rootEvent = new MatrixEvent(eventData);
} else {
rootEvent.setUnsigned(eventData.unsigned);
}
} finally {
// The root event might be not be visible to the person requesting
// it. If it wasn't fetched successfully the thread will work
// in "limited" mode and won't benefit from all the APIs a homeserver
// can provide to enhance the thread experience
thread = this.createThread(rootEvent, events, toStartOfTimeline);
const eventsByThread: { [threadId: string]: MatrixEvent[] } = {};
for (const event of events) {
const { threadId, shouldLiveInThread } = this.eventShouldLiveIn(event);
if (shouldLiveInThread && !eventsByThread[threadId]) {
eventsByThread[threadId] = [];
}
eventsByThread[threadId]?.push(event);
}
this.emit(ThreadEvent.Update, thread);
Object.entries(eventsByThread).map(([threadId, events]) => (
this.addThreadedEvents(threadId, events, toStartOfTimeline)
));
}
public createThread(
threadId: string,
rootEvent: MatrixEvent | undefined,
events: MatrixEvent[] = [],
toStartOfTimeline: boolean,
): Thread | undefined {
): Thread {
if (rootEvent) {
const tl = this.getTimelineForEvent(rootEvent.getId());
const relatedEvents = tl?.getTimelineSet().getAllRelationsEventForEvent(rootEvent.getId());
@@ -1539,27 +1663,28 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
}
}
const thread = new Thread(rootEvent, {
const thread = new Thread(threadId, rootEvent, {
initialEvents: events,
room: this,
client: this.client,
});
// If we managed to create a thread and figure out its `id`
// then we can use it
if (thread.id) {
this.threads.set(thread.id, thread);
this.reEmitter.reEmit(thread, [
ThreadEvent.Update,
RoomEvent.Timeline,
RoomEvent.TimelineReset,
]);
if (!this.lastThread || this.lastThread.rootEvent?.localTimestamp < rootEvent?.localTimestamp) {
this.lastThread = thread;
}
// If we managed to create a thread and figure out its `id` then we can use it
this.threads.set(thread.id, thread);
this.reEmitter.reEmit(thread, [
ThreadEvent.Update,
ThreadEvent.NewReply,
RoomEvent.Timeline,
RoomEvent.TimelineReset,
]);
this.emit(ThreadEvent.New, thread, toStartOfTimeline);
if (!this.lastThread || this.lastThread.rootEvent?.localTimestamp < rootEvent?.localTimestamp) {
this.lastThread = thread;
}
this.emit(ThreadEvent.New, thread, toStartOfTimeline);
if (this.threadsReady) {
this.threadsTimelineSets.forEach(timelineSet => {
if (thread.rootEvent) {
if (Thread.hasServerSideSupport) {
@@ -1573,12 +1698,12 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
}
}
});
return thread;
}
return thread;
}
applyRedaction(event: MatrixEvent): void {
private applyRedaction = (event: MatrixEvent): void => {
if (event.isRedaction()) {
const redactId = event.event.redacts;
@@ -1588,7 +1713,7 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
redactedEvent.makeRedacted(event);
// If this is in the current state, replace it with the redacted version
if (redactedEvent.getStateKey()) {
if (redactedEvent.isState()) {
const currentStateEvent = this.currentState.getStateEvents(
redactedEvent.getType(),
redactedEvent.getStateKey(),
@@ -1622,19 +1747,9 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
// clients can say "so and so redacted an event" if they wish to. Also
// this may be needed to trigger an update.
}
}
};
/**
* Add an event to the end of this room's live timelines. Will fire
* "Room.timeline".
*
* @param {MatrixEvent} event Event to be added
* @param {string?} duplicateStrategy 'ignore' or 'replace'
* @param {boolean} fromCache whether the sync response came from cache
* @fires module:client~MatrixClient#event:"Room.timeline"
* @private
*/
private addLiveEvent(event: MatrixEvent, duplicateStrategy?: DuplicateStrategy, fromCache = false): void {
private processLiveEvent(event: MatrixEvent): void {
this.applyRedaction(event);
// Implement MSC3531: hiding messages.
@@ -1651,10 +1766,21 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
if (existingEvent) {
// remote echo of an event we sent earlier
this.handleRemoteEcho(event, existingEvent);
return;
}
}
}
/**
* Add an event to the end of this room's live timelines. Will fire
* "Room.timeline".
*
* @param {MatrixEvent} event Event to be added
* @param {string?} duplicateStrategy 'ignore' or 'replace'
* @param {boolean} fromCache whether the sync response came from cache
* @fires module:client~MatrixClient#event:"Room.timeline"
* @private
*/
private addLiveEvent(event: MatrixEvent, duplicateStrategy: DuplicateStrategy, fromCache = false): void {
// add to our timeline sets
for (let i = 0; i < this.timelineSets.length; i++) {
this.timelineSets[i].addLiveEvent(event, duplicateStrategy, fromCache);
@@ -1805,12 +1931,11 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
* @param {module:models/event.MatrixEvent} event the relation event that needs to be aggregated.
*/
private aggregateNonLiveRelation(event: MatrixEvent): void {
const thread = this.findThreadForEvent(event);
if (thread) {
thread.timelineSet.aggregateRelations(event);
}
const { shouldLiveInRoom, threadId } = this.eventShouldLiveIn(event);
const thread = this.getThread(threadId);
thread?.timelineSet.aggregateRelations(event);
if (thread?.id === event.getAssociatedId() || !thread) {
if (shouldLiveInRoom) {
// TODO: We should consider whether this means it would be a better
// design to lift the relations handling up to the room instead.
for (let i = 0; i < this.timelineSets.length; i++) {
@@ -1849,10 +1974,7 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
const newEventId = remoteEvent.getId();
const oldStatus = localEvent.status;
logger.debug(
`Got remote echo for event ${oldEventId} -> ${newEventId} ` +
`old status ${oldStatus}`,
);
logger.debug(`Got remote echo for event ${oldEventId} -> ${newEventId} old status ${oldStatus}`);
// no longer pending
delete this.txnToEvent[remoteEvent.getUnsigned().transaction_id];
@@ -1866,12 +1988,11 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
// any, which is good, because we don't want to try decoding it again).
localEvent.handleRemoteEcho(remoteEvent.event);
const thread = this.findThreadForEvent(remoteEvent);
if (thread) {
thread.timelineSet.handleRemoteEcho(localEvent, oldEventId, newEventId);
}
const { shouldLiveInRoom, threadId } = this.eventShouldLiveIn(remoteEvent);
const thread = this.getThread(threadId);
thread?.timelineSet.handleRemoteEcho(localEvent, oldEventId, newEventId);
if (thread?.id === remoteEvent.getAssociatedId() || !thread) {
if (shouldLiveInRoom) {
for (let i = 0; i < this.timelineSets.length; i++) {
const timelineSet = this.timelineSets[i];
@@ -1937,11 +2058,11 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
// update the event id
event.replaceLocalEventId(newEventId);
const thread = this.findThreadForEvent(event);
if (thread) {
thread.timelineSet.replaceEventId(oldEventId, newEventId);
}
if (thread?.id === event.getAssociatedId() || !thread) {
const { shouldLiveInRoom, threadId } = this.eventShouldLiveIn(event);
const thread = this.getThread(threadId);
thread?.timelineSet.replaceEventId(oldEventId, newEventId);
if (shouldLiveInRoom) {
// if the event was already in the timeline (which will be the case if
// opts.pendingEventOrdering==chronological), we need to update the
// timeline map.
@@ -1952,12 +2073,10 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
} else if (newStatus == EventStatus.CANCELLED) {
// remove it from the pending event list, or the timeline.
if (this.pendingEventList) {
const idx = this.pendingEventList.findIndex(ev => ev.getId() === oldEventId);
if (idx !== -1) {
const [removedEvent] = this.pendingEventList.splice(idx, 1);
if (removedEvent.isRedaction()) {
this.revertRedactionLocalEcho(removedEvent);
}
const removedEvent = this.getPendingEvent(oldEventId);
this.removePendingEvent(oldEventId);
if (removedEvent.isRedaction()) {
this.revertRedactionLocalEcho(removedEvent);
}
}
this.removeEvent(oldEventId);
@@ -2003,13 +2122,12 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
* @throws If <code>duplicateStrategy</code> is not falsey, 'replace' or 'ignore'.
*/
public addLiveEvents(events: MatrixEvent[], duplicateStrategy?: DuplicateStrategy, fromCache = false): void {
let i;
if (duplicateStrategy && ["replace", "ignore"].indexOf(duplicateStrategy) === -1) {
throw new Error("duplicateStrategy MUST be either 'replace' or 'ignore'");
}
// sanity check that the live timeline is still live
for (i = 0; i < this.timelineSets.length; i++) {
for (let i = 0; i < this.timelineSets.length; i++) {
const liveTimeline = this.timelineSets[i].getLiveTimeline();
if (liveTimeline.getPaginationToken(EventTimeline.FORWARDS)) {
throw new Error(
@@ -2018,22 +2136,85 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
);
}
if (liveTimeline.getNeighbouringTimeline(EventTimeline.FORWARDS)) {
throw new Error(
"live timeline " + i + " is no longer live - " +
"it has a neighbouring timeline",
);
throw new Error(`live timeline ${i} is no longer live - it has a neighbouring timeline`);
}
}
for (i = 0; i < events.length; i++) {
// TODO: We should have a filter to say "only add state event
// types X Y Z to the timeline".
this.addLiveEvent(events[i], duplicateStrategy, fromCache);
const thread = this.findThreadForEvent(events[i]);
if (thread) {
thread.addEvent(events[i], true);
const threadRoots = this.findThreadRoots(events);
const eventsByThread: { [threadId: string]: MatrixEvent[] } = {};
for (let i = 0; i < events.length; i++) {
// TODO: We should have a filter to say "only add state event types X Y Z to the timeline".
this.processLiveEvent(events[i]);
const {
shouldLiveInRoom,
shouldLiveInThread,
threadId,
} = this.eventShouldLiveIn(events[i], events, threadRoots);
if (shouldLiveInThread && !eventsByThread[threadId]) {
eventsByThread[threadId] = [];
}
eventsByThread[threadId]?.push(events[i]);
if (shouldLiveInRoom) {
this.addLiveEvent(events[i], 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;
}
/**
@@ -2091,22 +2272,27 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
// set fake stripped state events if this is an invite room so logic remains
// consistent elsewhere.
const membershipEvent = this.currentState.getStateEvents(EventType.RoomMember, this.myUserId);
if (membershipEvent && membershipEvent.getContent().membership === "invite") {
const strippedStateEvents = membershipEvent.getUnsigned().invite_room_state || [];
strippedStateEvents.forEach((strippedEvent) => {
const existingEvent = this.currentState.getStateEvents(strippedEvent.type, strippedEvent.state_key);
if (!existingEvent) {
// set the fake stripped event instead
this.currentState.setStateEvents([new MatrixEvent({
type: strippedEvent.type,
state_key: strippedEvent.state_key,
content: strippedEvent.content,
event_id: "$fake" + Date.now(),
room_id: this.roomId,
user_id: this.myUserId, // technically a lie
})]);
}
});
if (membershipEvent) {
const membership = membershipEvent.getContent().membership;
this.updateMyMembership(membership);
if (membership === "invite") {
const strippedStateEvents = membershipEvent.getUnsigned().invite_room_state || [];
strippedStateEvents.forEach((strippedEvent) => {
const existingEvent = this.currentState.getStateEvents(strippedEvent.type, strippedEvent.state_key);
if (!existingEvent) {
// set the fake stripped event instead
this.currentState.setStateEvents([new MatrixEvent({
type: strippedEvent.type,
state_key: strippedEvent.state_key,
content: strippedEvent.content,
event_id: "$fake" + Date.now(),
room_id: this.roomId,
user_id: this.myUserId, // technically a lie
})]);
}
});
}
}
const oldName = this.name;
@@ -2425,7 +2611,7 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
/**
* Returns the type of the room from the `m.room.create` event content or undefined if none is set
* @returns {?string} the type of the room. Currently only RoomType.Space is known.
* @returns {?string} the type of the room.
*/
public getType(): RoomType | string | undefined {
const createEvent = this.currentState.getStateEvents(EventType.RoomCreate, "");
@@ -2447,6 +2633,22 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
return this.getType() === RoomType.Space;
}
/**
* Returns whether the room is a call-room as defined by MSC3417.
* @returns {boolean} true if the room's type is RoomType.UnstableCall
*/
public isCallRoom(): boolean {
return this.getType() === RoomType.UnstableCall;
}
/**
* Returns whether the room is a video room.
* @returns {boolean} true if the room's type is RoomType.ElementVideo
*/
public isElementVideoRoom(): boolean {
return this.getType() === RoomType.ElementVideo;
}
/**
* This is an internal method. Calculates the name of the room from the current
* room state.
+9 -4
View File
@@ -33,14 +33,19 @@ export class SearchResult {
public static fromJson(jsonObj: ISearchResult, eventMapper: EventMapper): SearchResult {
const jsonContext = jsonObj.context || {} as IResultContext;
const eventsBefore = jsonContext.events_before || [];
const eventsAfter = jsonContext.events_after || [];
let eventsBefore = (jsonContext.events_before || []).map(eventMapper);
let eventsAfter = (jsonContext.events_after || []).map(eventMapper);
const context = new EventContext(eventMapper(jsonObj.result));
// Filter out any contextual events which do not correspond to the same timeline (thread or room)
const threadRootId = context.ourEvent.threadRootId;
eventsBefore = eventsBefore.filter(e => e.threadRootId === threadRootId);
eventsAfter = eventsAfter.filter(e => e.threadRootId === threadRootId);
context.setPaginateToken(jsonContext.start, true);
context.addEvents(eventsBefore.map(eventMapper), true);
context.addEvents(eventsAfter.map(eventMapper), false);
context.addEvents(eventsBefore, true);
context.addEvents(eventsAfter, false);
context.setPaginateToken(jsonContext.end, false);
return new SearchResult(jsonObj.rank, context);
+153 -88
View File
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { MatrixClient, RoomEvent } from "../matrix";
import { MatrixClient, MatrixEventEvent, RelationType, RoomEvent } from "../matrix";
import { TypedReEmitter } from "../ReEmitter";
import { IRelationsRequestOpts } from "../@types/requests";
import { IThreadBundledRelationship, MatrixEvent } from "./event";
@@ -24,6 +24,7 @@ import { Room } from './room';
import { TypedEventEmitter } from "./typed-event-emitter";
import { RoomState } from "./room-state";
import { ServerControlledNamespacedValue } from "../NamespacedValue";
import { logger } from "../logger";
export enum ThreadEvent {
New = "Thread.new",
@@ -69,12 +70,11 @@ export class Thread extends TypedEventEmitter<EmittedEvents, EventHandlerMap> {
public readonly room: Room;
public readonly client: MatrixClient;
public initialEventsFetched = false;
public readonly id: string;
public initialEventsFetched = !Thread.hasServerSideSupport;
constructor(
public readonly rootEvent: MatrixEvent | undefined,
public readonly id: string,
public rootEvent: MatrixEvent | undefined,
opts: IThreadOpts,
) {
super();
@@ -93,20 +93,38 @@ export class Thread extends TypedEventEmitter<EmittedEvents, EventHandlerMap> {
RoomEvent.TimelineReset,
]);
// If we weren't able to find the root event, it's probably missing
// and we define the thread ID from one of the thread relation
if (!rootEvent) {
this.id = opts?.initialEvents
?.find(event => event.isThreadRelation)?.relationEventId;
} else {
this.id = rootEvent.getId();
}
this.initialiseThread(this.rootEvent);
opts?.initialEvents?.forEach(event => this.addEvent(event, false));
this.room.on(MatrixEventEvent.BeforeRedaction, this.onBeforeRedaction);
this.room.on(RoomEvent.Redaction, this.onRedaction);
this.room.on(RoomEvent.LocalEchoUpdated, this.onEcho);
this.room.on(RoomEvent.Timeline, this.onEcho);
this.timelineSet.on(RoomEvent.Timeline, this.onEcho);
if (opts.initialEvents) {
this.addEvents(opts.initialEvents, false);
}
// even if this thread is thought to be originating from this client, we initialise it as we may be in a
// gappy sync and a thread around this event may already exist.
this.initialiseThread();
this.rootEvent?.setThread(this);
}
private async fetchRootEvent(): Promise<void> {
this.rootEvent = this.room.findEventById(this.id);
// If the rootEvent does not exist in the local stores, then fetch it from the server.
try {
const eventData = await this.client.fetchRoomEvent(this.roomId, this.id);
const mapper = this.client.getEventMapper();
this.rootEvent = mapper(eventData); // will merge with existing event object if such is known
} catch (e) {
logger.error("Failed to fetch thread root to construct thread with", e);
}
// The root event might be not be visible to the person requesting it.
// If it wasn't fetched successfully the thread will work in "limited" mode and won't
// benefit from all the APIs a homeserver can provide to enhance the thread experience
this.rootEvent?.setThread(this);
this.emit(ThreadEvent.Update, this);
}
public static setServerSideSupport(hasServerSideSupport: boolean, useStable: boolean): void {
@@ -118,26 +136,59 @@ export class Thread extends TypedEventEmitter<EmittedEvents, EventHandlerMap> {
}
}
private onEcho = (event: MatrixEvent) => {
if (this.timelineSet.eventIdToTimeline(event.getId())) {
private onBeforeRedaction = (event: MatrixEvent, redaction: MatrixEvent) => {
if (event?.isRelation(THREAD_RELATION_TYPE.name) &&
this.room.eventShouldLiveIn(event).threadId === this.id &&
event.getId() !== this.id && // the root event isn't counted in the length so ignore this redaction
!redaction.status // only respect it when it succeeds
) {
this.replyCount--;
this.emit(ThreadEvent.Update, this);
}
};
private onRedaction = (event: MatrixEvent) => {
if (event.threadRootId !== this.id) return; // ignore redactions for other timelines
const events = [...this.timelineSet.getLiveTimeline().getEvents()].reverse();
this.lastEvent = events.find(e => (
!e.isRedacted() &&
e.isRelation(THREAD_RELATION_TYPE.name)
)) ?? this.rootEvent;
this.emit(ThreadEvent.Update, this);
};
private onEcho = (event: MatrixEvent) => {
if (event.threadRootId !== this.id) return; // ignore echoes for other timelines
if (this.lastEvent === event) return;
// There is a risk that the `localTimestamp` approximation will not be accurate
// when threads are used over federation. That could result in the reply
// count value drifting away from the value returned by the server
const isThreadReply = event.isRelation(THREAD_RELATION_TYPE.name);
if (!this.lastEvent || this.lastEvent.isRedacted() || (isThreadReply
&& (event.getId() !== this.lastEvent.getId())
&& (event.localTimestamp > this.lastEvent.localTimestamp))
) {
this.lastEvent = event;
if (this.lastEvent.getId() !== this.id) {
// This counting only works when server side support is enabled as we started the counting
// from the value returned within the bundled relationship
if (Thread.hasServerSideSupport) {
this.replyCount++;
}
this.emit(ThreadEvent.NewReply, this, event);
}
}
this.emit(ThreadEvent.Update, this);
};
public get roomState(): RoomState {
return this.room.getLiveTimeline().getState(EventTimeline.FORWARDS);
}
private addEventToTimeline(event: MatrixEvent, toStartOfTimeline: boolean): void {
if (event.getUnsigned().transaction_id) {
const existingEvent = this.room.getEventForTxnId(event.getUnsigned().transaction_id);
if (existingEvent) {
// remote echo of an event we sent earlier
this.room.handleRemoteEcho(event, existingEvent);
return;
}
}
if (!this.findEventById(event.getId())) {
this.timelineSet.addEventToTimeline(
event,
@@ -149,6 +200,11 @@ export class Thread extends TypedEventEmitter<EmittedEvents, EventHandlerMap> {
}
}
public addEvents(events: MatrixEvent[], toStartOfTimeline: boolean): void {
events.forEach(ev => this.addEvent(ev, toStartOfTimeline, false));
this.emit(ThreadEvent.Update, this);
}
/**
* Add an event to the thread and updates
* the tail/root references if needed
@@ -156,64 +212,59 @@ export class Thread extends TypedEventEmitter<EmittedEvents, EventHandlerMap> {
* @param event The event to add
* @param {boolean} toStartOfTimeline whether the event is being added
* to the start (and not the end) of the timeline.
* @param {boolean} emit whether to emit the Update event if the thread was updated or not.
*/
public async addEvent(event: MatrixEvent, toStartOfTimeline: boolean): Promise<void> {
public addEvent(event: MatrixEvent, toStartOfTimeline: boolean, emit = true): void {
event.setThread(this);
if (!this._currentUserParticipated && event.getSender() === this.client.getUserId()) {
this._currentUserParticipated = true;
}
// Add all annotations and replace relations to the timeline so that the relations are processed accordingly
if ([RelationType.Annotation, RelationType.Replace].includes(event.getRelation()?.rel_type as RelationType)) {
this.addEventToTimeline(event, toStartOfTimeline);
return;
}
// Add all incoming events to the thread's timeline set when there's no server support
if (!Thread.hasServerSideSupport) {
// all the relevant membership info to hydrate events with a sender
// is held in the main room timeline
// We want to fetch the room state from there and pass it down to this thread
// timeline set to let it reconcile an event with its relevant RoomMember
event.setThread(this);
this.addEventToTimeline(event, toStartOfTimeline);
await this.client.decryptEventIfNeeded(event, {});
this.client.decryptEventIfNeeded(event, {});
} else if (!toStartOfTimeline &&
this.initialEventsFetched &&
event.localTimestamp > this.lastReply()?.localTimestamp
) {
this.fetchEditsWhereNeeded(event);
this.addEventToTimeline(event, false);
}
if (Thread.hasServerSideSupport && this.initialEventsFetched) {
if (event.localTimestamp > this.lastReply().localTimestamp) {
this.addEventToTimeline(event, false);
}
}
if (!this._currentUserParticipated && event.getSender() === this.client.getUserId()) {
this._currentUserParticipated = true;
}
const isThreadReply = event.getRelation()?.rel_type === THREAD_RELATION_TYPE.name;
// If no thread support exists we want to count all thread relation
// added as a reply. We can't rely on the bundled relationships count
if (!Thread.hasServerSideSupport && isThreadReply) {
if ((!Thread.hasServerSideSupport || !this.rootEvent) && event.isRelation(THREAD_RELATION_TYPE.name)) {
this.replyCount++;
}
// There is a risk that the `localTimestamp` approximation will not be accurate
// when threads are used over federation. That could results in the reply
// count value drifting away from the value returned by the server
if (!this.lastEvent || (isThreadReply
&& (event.getId() !== this.lastEvent.getId())
&& (event.localTimestamp > this.lastEvent.localTimestamp))
) {
this.lastEvent = event;
if (this.lastEvent.getId() !== this.id) {
// This counting only works when server side support is enabled
// as we started the counting from the value returned in the
// bundled relationship
if (Thread.hasServerSideSupport) {
this.replyCount++;
}
this.emit(ThreadEvent.NewReply, this, event);
}
if (emit) {
this.emit(ThreadEvent.Update, this);
}
this.emit(ThreadEvent.Update, this);
}
private initialiseThread(rootEvent: MatrixEvent | undefined): void {
const bundledRelationship = rootEvent
?.getServerAggregatedRelation<IThreadBundledRelationship>(THREAD_RELATION_TYPE.name);
private getRootEventBundledRelationship(rootEvent = this.rootEvent): IThreadBundledRelationship {
return rootEvent?.getServerAggregatedRelation<IThreadBundledRelationship>(THREAD_RELATION_TYPE.name);
}
private async initialiseThread(): Promise<void> {
let bundledRelationship = this.getRootEventBundledRelationship();
if (Thread.hasServerSideSupport && !bundledRelationship) {
await this.fetchRootEvent();
bundledRelationship = this.getRootEventBundledRelationship();
}
if (Thread.hasServerSideSupport && bundledRelationship) {
this.replyCount = bundledRelationship.count;
@@ -221,27 +272,34 @@ export class Thread extends TypedEventEmitter<EmittedEvents, EventHandlerMap> {
const event = new MatrixEvent(bundledRelationship.latest_event);
this.setEventMetadata(event);
event.setThread(this);
this.lastEvent = event;
this.fetchEditsWhereNeeded(event);
}
this.emit(ThreadEvent.Update, this);
}
public async fetchInitialEvents(): Promise<{
originalEvent: MatrixEvent;
events: MatrixEvent[];
nextBatch?: string;
prevBatch?: string;
} | null> {
if (!Thread.hasServerSideSupport) {
this.initialEventsFetched = true;
return null;
}
try {
const response = await this.fetchEvents();
this.initialEventsFetched = true;
return response;
} catch (e) {
return null;
}
// XXX: Workaround for https://github.com/matrix-org/matrix-spec-proposals/pull/2676/files#r827240084
private async fetchEditsWhereNeeded(...events: MatrixEvent[]): Promise<unknown> {
return Promise.all(events.filter(e => e.isEncrypted()).map((event: MatrixEvent) => {
return this.client.relations(this.roomId, event.getId(), RelationType.Replace, event.getType(), {
limit: 1,
}).then(relations => {
if (relations.events.length) {
event.makeReplaced(relations.events[0]);
}
}).catch(e => {
logger.error("Failed to load edits for encrypted thread event", e);
});
}));
}
public async fetchInitialEvents(): Promise<void> {
if (this.initialEventsFetched) return;
await this.fetchEvents();
this.initialEventsFetched = true;
}
private setEventMetadata(event: MatrixEvent): void {
@@ -253,6 +311,11 @@ export class Thread extends TypedEventEmitter<EmittedEvents, EventHandlerMap> {
* Finds an event by ID in the current thread
*/
public findEventById(eventId: string) {
// Check the lastEvent as it may have been created based on a bundled relationship and not in a timeline
if (this.lastEvent?.getId() === eventId) {
return this.lastEvent;
}
return this.timelineSet.findEventById(eventId);
}
@@ -285,7 +348,7 @@ export class Thread extends TypedEventEmitter<EmittedEvents, EventHandlerMap> {
* A getter for the last event added to the thread
*/
public get replyToEvent(): MatrixEvent {
return this.lastEvent;
return this.lastEvent ?? this.lastReply();
}
public get events(): MatrixEvent[] {
@@ -304,7 +367,7 @@ export class Thread extends TypedEventEmitter<EmittedEvents, EventHandlerMap> {
return this.timelineSet.getLiveTimeline();
}
public async fetchEvents(opts: IRelationsRequestOpts = { limit: 20 }): Promise<{
public async fetchEvents(opts: IRelationsRequestOpts = { limit: 20, direction: Direction.Backward }): Promise<{
originalEvent: MatrixEvent;
events: MatrixEvent[];
nextBatch?: string;
@@ -329,12 +392,14 @@ export class Thread extends TypedEventEmitter<EmittedEvents, EventHandlerMap> {
events = [...events, originalEvent];
}
await this.fetchEditsWhereNeeded(...events);
await Promise.all(events.map(event => {
this.setEventMetadata(event);
return this.client.decryptEventIfNeeded(event);
}));
const prependEvents = !opts.direction || opts.direction === Direction.Backward;
const prependEvents = (opts.direction ?? Direction.Backward) === Direction.Backward;
this.timelineSet.addEventsToTimeline(
events,
+5 -28
View File
@@ -15,19 +15,18 @@ limitations under the License.
*/
import { EventType } from "../@types/event";
import { Group } from "../models/group";
import { Room } from "../models/room";
import { User } from "../models/user";
import { IEvent, MatrixEvent } from "../models/event";
import { MatrixEvent } from "../models/event";
import { Filter } from "../filter";
import { RoomSummary } from "../models/room-summary";
import { IMinimalEvent, IGroups, IRooms, ISyncResponse } from "../sync-accumulator";
import { IMinimalEvent, IRooms, ISyncResponse } from "../sync-accumulator";
import { IStartClientOpts } from "../client";
import { IStateEventWithRoomId } from "../@types/search";
export interface ISavedSync {
nextBatch: string;
roomsData: IRooms;
groupsData: IGroups;
accountData: IMinimalEvent[];
}
@@ -53,28 +52,6 @@ export interface IStore {
*/
setSyncToken(token: string): void;
/**
* No-op.
* @param {Group} group
* @deprecated groups/communities never made it to the spec and support for them is being discontinued.
*/
storeGroup(group: Group): void;
/**
* No-op.
* @param {string} groupId
* @return {null}
* @deprecated groups/communities never made it to the spec and support for them is being discontinued.
*/
getGroup(groupId: string): Group | null;
/**
* No-op.
* @return {Array} An empty array.
* @deprecated groups/communities never made it to the spec and support for them is being discontinued.
*/
getGroups(): Group[];
/**
* No-op.
* @param {Room} room
@@ -228,9 +205,9 @@ export interface IStore {
*/
deleteAllData(): Promise<void>;
getOutOfBandMembers(roomId: string): Promise<IEvent[] | null>;
getOutOfBandMembers(roomId: string): Promise<IStateEventWithRoomId[] | null>;
setOutOfBandMembers(roomId: string, membershipEvents: IEvent[]): Promise<void>;
setOutOfBandMembers(roomId: string, membershipEvents: IStateEventWithRoomId[]): Promise<void>;
clearOutOfBandMembers(roomId: string): Promise<void>;
+3 -3
View File
@@ -15,7 +15,7 @@ limitations under the License.
*/
import { ISavedSync } from "./index";
import { IEvent, IStartClientOpts, ISyncResponse } from "..";
import { IEvent, IStartClientOpts, IStateEventWithRoomId, ISyncResponse } from "..";
export interface IIndexedDBBackend {
connect(): Promise<void>;
@@ -25,8 +25,8 @@ export interface IIndexedDBBackend {
getSavedSync(): Promise<ISavedSync>;
getNextBatchToken(): Promise<string>;
clearDatabase(): Promise<void>;
getOutOfBandMembers(roomId: string): Promise<IEvent[] | null>;
setOutOfBandMembers(roomId: string, membershipEvents: IEvent[]): Promise<void>;
getOutOfBandMembers(roomId: string): Promise<IStateEventWithRoomId[] | null>;
setOutOfBandMembers(roomId: string, membershipEvents: IStateEventWithRoomId[]): Promise<void>;
clearOutOfBandMembers(roomId: string): Promise<void>;
getUserPresenceEvents(): Promise<UserTuple[]>;
getClientOptions(): Promise<IStartClientOpts>;
+7 -13
View File
@@ -18,7 +18,7 @@ import { IMinimalEvent, ISyncData, ISyncResponse, SyncAccumulator } from "../syn
import * as utils from "../utils";
import * as IndexedDBHelpers from "../indexeddb-helpers";
import { logger } from '../logger';
import { IEvent, IStartClientOpts } from "..";
import { IStartClientOpts, IStateEventWithRoomId } from "..";
import { ISavedSync } from "./index";
import { IIndexedDBBackend, UserTuple } from "./indexeddb-backend";
@@ -215,7 +215,6 @@ export class LocalIndexedDBStoreBackend implements IIndexedDBBackend {
this.syncAccumulator.accumulate({
next_batch: syncData.nextBatch,
rooms: syncData.roomsData,
groups: syncData.groupsData,
account_data: {
events: accountData,
},
@@ -230,15 +229,15 @@ export class LocalIndexedDBStoreBackend implements IIndexedDBBackend {
* @returns {Promise<event[]>} the events, potentially an empty array if OOB loading didn't yield any new members
* @returns {null} in case the members for this room haven't been stored yet
*/
public getOutOfBandMembers(roomId: string): Promise<IEvent[] | null> {
return new Promise<IEvent[] | null>((resolve, reject) => {
public getOutOfBandMembers(roomId: string): Promise<IStateEventWithRoomId[] | null> {
return new Promise<IStateEventWithRoomId[] | null>((resolve, reject) => {
const tx = this.db.transaction(["oob_membership_events"], "readonly");
const store = tx.objectStore("oob_membership_events");
const roomIndex = store.index("room");
const range = IDBKeyRange.only(roomId);
const request = roomIndex.openCursor(range);
const membershipEvents: IEvent[] = [];
const membershipEvents: IStateEventWithRoomId[] = [];
// did we encounter the oob_written marker object
// amongst the results? That means OOB member
// loading already happened for this room
@@ -279,7 +278,7 @@ export class LocalIndexedDBStoreBackend implements IIndexedDBBackend {
* @param {string} roomId
* @param {event[]} membershipEvents the membership events to store
*/
public async setOutOfBandMembers(roomId: string, membershipEvents: IEvent[]): Promise<void> {
public async setOutOfBandMembers(roomId: string, membershipEvents: IStateEventWithRoomId[]): Promise<void> {
logger.log(`LL: backend about to store ${membershipEvents.length}` +
` members for ${roomId}`);
const tx = this.db.transaction(["oob_membership_events"], "readwrite");
@@ -405,7 +404,7 @@ export class LocalIndexedDBStoreBackend implements IIndexedDBBackend {
await Promise.all([
this.persistUserPresenceEvents(userTuples),
this.persistAccountData(syncData.accountData),
this.persistSyncData(syncData.nextBatch, syncData.roomsData, syncData.groupsData),
this.persistSyncData(syncData.nextBatch, syncData.roomsData),
]);
}
@@ -413,13 +412,11 @@ export class LocalIndexedDBStoreBackend implements IIndexedDBBackend {
* Persist rooms /sync data along with the next batch token.
* @param {string} nextBatch The next_batch /sync value.
* @param {Object} roomsData The 'rooms' /sync data from a SyncAccumulator
* @param {Object} groupsData The 'groups' /sync data from a SyncAccumulator
* @return {Promise} Resolves if the data was persisted.
*/
private persistSyncData(
nextBatch: string,
roomsData: ISyncResponse["rooms"],
groupsData: ISyncResponse["groups"],
): Promise<void> {
logger.log("Persisting sync data up to", nextBatch);
return utils.promiseTry<void>(() => {
@@ -429,7 +426,6 @@ export class LocalIndexedDBStoreBackend implements IIndexedDBBackend {
clobber: "-", // constant key so will always clobber
nextBatch,
roomsData,
groupsData,
}); // put == UPSERT
return txnAsPromise(txn).then();
});
@@ -534,9 +530,7 @@ export class LocalIndexedDBStoreBackend implements IIndexedDBBackend {
const txn = this.db.transaction(["client_options"], "readonly");
const store = txn.objectStore("client_options");
return selectQuery(store, undefined, (cursor) => {
if (cursor.value && cursor.value && cursor.value.options) {
return cursor.value.options;
}
return cursor.value?.options;
}).then((results) => results[0]);
});
}
+3 -3
View File
@@ -18,7 +18,7 @@ import { logger } from "../logger";
import { defer, IDeferred } from "../utils";
import { ISavedSync } from "./index";
import { IStartClientOpts } from "../client";
import { IEvent, ISyncResponse } from "..";
import { IStateEventWithRoomId, ISyncResponse } from "..";
import { IIndexedDBBackend, UserTuple } from "./indexeddb-backend";
export class RemoteIndexedDBStoreBackend implements IIndexedDBBackend {
@@ -97,7 +97,7 @@ export class RemoteIndexedDBStoreBackend implements IIndexedDBBackend {
* @returns {event[]} the events, potentially an empty array if OOB loading didn't yield any new members
* @returns {null} in case the members for this room haven't been stored yet
*/
public getOutOfBandMembers(roomId: string): Promise<IEvent[] | null> {
public getOutOfBandMembers(roomId: string): Promise<IStateEventWithRoomId[] | null> {
return this.doCmd('getOutOfBandMembers', [roomId]);
}
@@ -109,7 +109,7 @@ export class RemoteIndexedDBStoreBackend implements IIndexedDBBackend {
* @param {event[]} membershipEvents the membership events to store
* @returns {Promise} when all members have been stored
*/
public setOutOfBandMembers(roomId: string, membershipEvents: IEvent[]): Promise<void> {
public setOutOfBandMembers(roomId: string, membershipEvents: IStateEventWithRoomId[]): Promise<void> {
return this.doCmd('setOutOfBandMembers', [roomId, membershipEvents]);
}
+9 -5
View File
@@ -26,6 +26,7 @@ import { ISavedSync } from "./index";
import { IIndexedDBBackend } from "./indexeddb-backend";
import { ISyncResponse } from "../sync-accumulator";
import { TypedEventEmitter } from "../models/typed-event-emitter";
import { IStateEventWithRoomId } from "../@types/search";
/**
* This is an internal module. See {@link IndexedDBStore} for the public class.
@@ -242,7 +243,7 @@ export class IndexedDBStore extends MemoryStore {
* @returns {event[]} the events, potentially an empty array if OOB loading didn't yield any new members
* @returns {null} in case the members for this room haven't been stored yet
*/
public getOutOfBandMembers = this.degradable((roomId: string): Promise<IEvent[]> => {
public getOutOfBandMembers = this.degradable((roomId: string): Promise<IStateEventWithRoomId[]> => {
return this.backend.getOutOfBandMembers(roomId);
}, "getOutOfBandMembers");
@@ -254,10 +255,13 @@ export class IndexedDBStore extends MemoryStore {
* @param {event[]} membershipEvents the membership events to store
* @returns {Promise} when all members have been stored
*/
public setOutOfBandMembers = this.degradable((roomId: string, membershipEvents: IEvent[]): Promise<void> => {
super.setOutOfBandMembers(roomId, membershipEvents);
return this.backend.setOutOfBandMembers(roomId, membershipEvents);
}, "setOutOfBandMembers");
public setOutOfBandMembers = this.degradable(
(roomId: string, membershipEvents: IStateEventWithRoomId[]): Promise<void> => {
super.setOutOfBandMembers(roomId, membershipEvents);
return this.backend.setOutOfBandMembers(roomId, membershipEvents);
},
"setOutOfBandMembers",
);
public clearOutOfBandMembers = this.degradable((roomId: string) => {
super.clearOutOfBandMembers(roomId);
+5 -34
View File
@@ -20,16 +20,16 @@ limitations under the License.
*/
import { EventType } from "../@types/event";
import { Group } from "../models/group";
import { Room } from "../models/room";
import { User } from "../models/user";
import { IEvent, MatrixEvent } from "../models/event";
import { MatrixEvent } from "../models/event";
import { RoomState, RoomStateEvent } from "../models/room-state";
import { RoomMember } from "../models/room-member";
import { Filter } from "../filter";
import { ISavedSync, IStore } from "./index";
import { RoomSummary } from "../models/room-summary";
import { ISyncResponse } from "../sync-accumulator";
import { IStateEventWithRoomId } from "../@types/search";
function isValidFilterId(filterId: string): boolean {
const isValidStr = typeof filterId === "string" &&
@@ -53,7 +53,6 @@ export interface IOpts {
*/
export class MemoryStore implements IStore {
private rooms: Record<string, Room> = {}; // roomId: Room
private groups: Record<string, Group> = {}; // groupId: Group
private users: Record<string, User> = {}; // userId: User
private syncToken: string = null;
// userId: {
@@ -62,7 +61,7 @@ export class MemoryStore implements IStore {
private filters: Record<string, Record<string, Filter>> = {};
public accountData: Record<string, MatrixEvent> = {}; // type : content
private readonly localStorage: Storage;
private oobMembers: Record<string, IEvent[]> = {}; // roomId: [member events]
private oobMembers: Record<string, IStateEventWithRoomId[]> = {}; // roomId: [member events]
private clientOptions = {};
constructor(opts: IOpts = {}) {
@@ -90,34 +89,6 @@ export class MemoryStore implements IStore {
this.syncToken = token;
}
/**
* Store the given room.
* @param {Group} group The group to be stored
* @deprecated groups/communities never made it to the spec and support for them is being discontinued.
*/
public storeGroup(group: Group) {
this.groups[group.groupId] = group;
}
/**
* Retrieve a group by its group ID.
* @param {string} groupId The group ID.
* @return {Group} The group or null.
* @deprecated groups/communities never made it to the spec and support for them is being discontinued.
*/
public getGroup(groupId: string): Group | null {
return this.groups[groupId] || null;
}
/**
* Retrieve all known groups.
* @return {Group[]} A list of groups, which may be empty.
* @deprecated groups/communities never made it to the spec and support for them is being discontinued.
*/
public getGroups(): Group[] {
return Object.values(this.groups);
}
/**
* Store the given room.
* @param {Room} room The room to be stored. All properties must be stored.
@@ -419,7 +390,7 @@ export class MemoryStore implements IStore {
* @returns {event[]} the events, potentially an empty array if OOB loading didn't yield any new members
* @returns {null} in case the members for this room haven't been stored yet
*/
public getOutOfBandMembers(roomId: string): Promise<IEvent[] | null> {
public getOutOfBandMembers(roomId: string): Promise<IStateEventWithRoomId[] | null> {
return Promise.resolve(this.oobMembers[roomId] || null);
}
@@ -431,7 +402,7 @@ export class MemoryStore implements IStore {
* @param {event[]} membershipEvents the membership events to store
* @returns {Promise} when all members have been stored
*/
public setOutOfBandMembers(roomId: string, membershipEvents: IEvent[]): Promise<void> {
public setOutOfBandMembers(roomId: string, membershipEvents: IStateEventWithRoomId[]): Promise<void> {
this.oobMembers[roomId] = membershipEvents;
return Promise.resolve();
}
+5 -5
View File
@@ -78,7 +78,7 @@ WebStorageSessionStore.prototype = {
const devices = {};
for (let i = 0; i < this.store.length; ++i) {
const key = this.store.key(i);
const userId = key.substr(prefix.length);
const userId = key.slice(prefix.length);
if (key.startsWith(prefix)) devices[userId] = getJsonItem(this.store, key);
}
return devices;
@@ -125,7 +125,7 @@ WebStorageSessionStore.prototype = {
const deviceKeys = getKeysWithPrefix(this.store, keyEndToEndSessions(''));
const results = {};
for (const k of deviceKeys) {
const unprefixedKey = k.substr(keyEndToEndSessions('').length);
const unprefixedKey = k.slice(keyEndToEndSessions('').length);
results[unprefixedKey] = getJsonItem(this.store, k);
}
return results;
@@ -158,8 +158,8 @@ WebStorageSessionStore.prototype = {
// (hence 43 characters long).
result.push({
senderKey: key.substr(prefix.length, 43),
sessionId: key.substr(prefix.length + 44),
senderKey: key.slice(prefix.length, prefix.length + 43),
sessionId: key.slice(prefix.length + 44),
});
}
return result;
@@ -182,7 +182,7 @@ WebStorageSessionStore.prototype = {
const roomKeys = getKeysWithPrefix(this.store, keyEndToEndRoom(''));
const results = {};
for (const k of roomKeys) {
const unprefixedKey = k.substr(keyEndToEndRoom('').length);
const unprefixedKey = k.slice(keyEndToEndRoom('').length);
results[unprefixedKey] = getJsonItem(this.store, k);
}
return results;
+4 -30
View File
@@ -20,14 +20,14 @@ limitations under the License.
*/
import { EventType } from "../@types/event";
import { Group } from "../models/group";
import { Room } from "../models/room";
import { User } from "../models/user";
import { IEvent, MatrixEvent } from "../models/event";
import { MatrixEvent } from "../models/event";
import { Filter } from "../filter";
import { ISavedSync, IStore } from "./index";
import { RoomSummary } from "../models/room-summary";
import { ISyncResponse } from "../sync-accumulator";
import { IStateEventWithRoomId } from "../@types/search";
/**
* Construct a stub store. This does no-ops on most store methods.
@@ -58,32 +58,6 @@ export class StubStore implements IStore {
this.fromToken = token;
}
/**
* No-op.
* @param {Group} group
* @deprecated groups/communities never made it to the spec and support for them is being discontinued.
*/
public storeGroup(group: Group) {}
/**
* No-op.
* @param {string} groupId
* @return {null}
* @deprecated groups/communities never made it to the spec and support for them is being discontinued.
*/
public getGroup(groupId: string): Group | null {
return null;
}
/**
* No-op.
* @return {Array} An empty array.
* @deprecated groups/communities never made it to the spec and support for them is being discontinued.
*/
public getGroups(): Group[] {
return [];
}
/**
* No-op.
* @param {Room} room
@@ -269,11 +243,11 @@ export class StubStore implements IStore {
return Promise.resolve();
}
public getOutOfBandMembers(): Promise<IEvent[]> {
public getOutOfBandMembers(): Promise<IStateEventWithRoomId[]> {
return Promise.resolve(null);
}
public setOutOfBandMembers(roomId: string, membershipEvents: IEvent[]): Promise<void> {
public setOutOfBandMembers(roomId: string, membershipEvents: IStateEventWithRoomId[]): Promise<void> {
return Promise.resolve();
}
-50
View File
@@ -128,12 +128,6 @@ interface IDeviceLists {
left: string[];
}
export interface IGroups {
[Category.Join]: object;
[Category.Invite]: object;
[Category.Leave]: object;
}
export interface ISyncResponse {
next_batch: string;
rooms: IRooms;
@@ -142,8 +136,6 @@ export interface ISyncResponse {
to_device?: IToDevice;
device_lists?: IDeviceLists;
device_one_time_keys_count?: Record<string, number>;
groups: IGroups; // unspecced
}
/* eslint-enable camelcase */
@@ -174,7 +166,6 @@ export interface ISyncData {
nextBatch: string;
accountData: IMinimalEvent[];
roomsData: IRooms;
groupsData: IGroups;
}
/**
@@ -197,13 +188,6 @@ export class SyncAccumulator {
// streaming from without losing events.
private nextBatch: string = null;
// { ('invite'|'join'|'leave'): $groupId: { ... sync 'group' data } }
private groups: Record<Category, object> = {
invite: {},
join: {},
leave: {},
};
/**
* @param {Object} opts
* @param {Number=} opts.maxTimelineEntries The ideal maximum number of
@@ -219,7 +203,6 @@ export class SyncAccumulator {
public accumulate(syncResponse: ISyncResponse, fromDatabase = false): void {
this.accumulateRooms(syncResponse, fromDatabase);
this.accumulateGroups(syncResponse);
this.accumulateAccountData(syncResponse);
this.nextBatch = syncResponse.next_batch;
}
@@ -505,38 +488,6 @@ export class SyncAccumulator {
}
}
/**
* Accumulate incremental /sync group data.
* @param {Object} syncResponse the complete /sync JSON
*/
private accumulateGroups(syncResponse: ISyncResponse): void {
if (!syncResponse.groups) {
return;
}
if (syncResponse.groups.invite) {
Object.keys(syncResponse.groups.invite).forEach((groupId) => {
this.accumulateGroup(groupId, Category.Invite, syncResponse.groups.invite[groupId]);
});
}
if (syncResponse.groups.join) {
Object.keys(syncResponse.groups.join).forEach((groupId) => {
this.accumulateGroup(groupId, Category.Join, syncResponse.groups.join[groupId]);
});
}
if (syncResponse.groups.leave) {
Object.keys(syncResponse.groups.leave).forEach((groupId) => {
this.accumulateGroup(groupId, Category.Leave, syncResponse.groups.leave[groupId]);
});
}
}
private accumulateGroup(groupId: string, category: Category, data: object): void {
for (const cat of [Category.Invite, Category.Leave, Category.Join]) {
delete this.groups[cat][groupId];
}
this.groups[category][groupId] = data;
}
/**
* Return everything under the 'rooms' key from a /sync response which
* represents all room data that should be stored. This should be paired
@@ -694,7 +645,6 @@ export class SyncAccumulator {
return {
nextBatch: this.nextBatch,
roomsData: data,
groupsData: this.groups,
accountData: accData,
};
}
+24 -142
View File
@@ -25,7 +25,6 @@ limitations under the License.
import { User, UserEvent } from "./models/user";
import { NotificationCountType, Room, RoomEvent } from "./models/room";
import { Group } from "./models/group";
import * as utils from "./utils";
import { IDeferred } from "./utils";
import { Filter } from "./filter";
@@ -35,7 +34,6 @@ import { logger } from './logger';
import { InvalidStoreError } from './errors';
import { ClientEvent, IStoredClientOpts, MatrixClient, PendingEventOrdering } from "./client";
import {
Category,
IEphemeral,
IInvitedRoom,
IInviteState,
@@ -213,21 +211,6 @@ export class SyncApi {
return room;
}
/**
* @param {string} groupId
* @return {Group}
*/
public createGroup(groupId: string): Group {
const client = this.client;
const group = new Group(groupId);
client.reEmitter.reEmit(group, [
ClientEvent.GroupProfile,
ClientEvent.GroupMyMembership,
]);
client.store.storeGroup(group);
return group;
}
/**
* @param {Room} room
* @private
@@ -244,6 +227,7 @@ export class SyncApi {
RoomStateEvent.Update,
BeaconEvent.New,
BeaconEvent.Update,
BeaconEvent.Destroy,
BeaconEvent.LivenessChange,
]);
@@ -293,15 +277,13 @@ export class SyncApi {
return client.http.authedRequest<any>( // TODO types
undefined, Method.Get, "/sync", qps as any, undefined, localTimeoutMs,
);
}).then((data) => {
}).then(async (data) => {
let leaveRooms = [];
if (data.rooms?.leave) {
leaveRooms = this.mapSyncResponseToRoomArray(data.rooms.leave);
}
const rooms = [];
leaveRooms.forEach(async (leaveObj) => {
return Promise.all(leaveRooms.map(async (leaveObj) => {
const room = leaveObj.room;
rooms.push(room);
if (!leaveObj.isBrandNewRoom) {
// the intention behind syncLeftRooms is to add in rooms which were
// *omitted* from the initial /sync. Rooms the user were joined to
@@ -315,25 +297,22 @@ export class SyncApi {
}
leaveObj.timeline = leaveObj.timeline || {};
const events = this.mapSyncEventsFormat(leaveObj.timeline, room);
const [timelineEvents, threadedEvents] = this.client.partitionThreadedEvents(events);
const stateEvents = this.mapSyncEventsFormat(leaveObj.state, room);
// set the back-pagination token. Do this *before* adding any
// events so that clients can start back-paginating.
room.getLiveTimeline().setPaginationToken(leaveObj.timeline.prev_batch,
EventTimeline.BACKWARDS);
room.getLiveTimeline().setPaginationToken(leaveObj.timeline.prev_batch, EventTimeline.BACKWARDS);
this.processRoomEvents(room, stateEvents, timelineEvents);
await this.processThreadEvents(room, threadedEvents, false);
await this.processRoomEvents(room, stateEvents, events);
room.recalculate();
client.store.storeRoom(room);
client.emit(ClientEvent.Room, room);
this.processEventsForNotifs(room, events);
});
return rooms;
return room;
}));
});
}
@@ -768,7 +747,6 @@ export class SyncApi {
const data: ISyncResponse = {
next_batch: nextSyncToken,
rooms: savedSync.roomsData,
groups: savedSync.groupsData,
account_data: {
events: savedSync.accountData,
},
@@ -777,7 +755,7 @@ export class SyncApi {
try {
await this.processSyncResponse(syncEventData, data);
} catch (e) {
logger.error("Error processing cached sync", e.stack || e);
logger.error("Error processing cached sync", e);
}
// Don't emit a prepared if we've bailed because the store is invalid:
@@ -852,7 +830,7 @@ export class SyncApi {
} catch (e) {
// log the exception with stack if we have it, else fall back
// to the plain description
logger.error("Caught /sync error", e.stack || e);
logger.error("Caught /sync error", e);
// Emit the exception for client handling
this.client.emit(ClientEvent.SyncUnexpectedError, e);
@@ -1055,20 +1033,7 @@ export class SyncApi {
// timeline: { events: [], prev_batch: $token }
// }
// }
// },
// groups: {
// invite: {
// $groupId: {
// inviter: $inviter,
// profile: {
// avatar_url: $avatarUrl,
// name: $groupName,
// },
// },
// },
// join: {},
// leave: {},
// },
// }
// }
// TODO-arch:
@@ -1118,9 +1083,7 @@ export class SyncApi {
}
// handle to-device events
if (data.to_device && Array.isArray(data.to_device.events) &&
data.to_device.events.length > 0
) {
if (Array.isArray(data.to_device?.events) && data.to_device.events.length > 0) {
const cancelledKeyVerificationTxns = [];
data.to_device.events
.map(client.getEventMapper())
@@ -1172,20 +1135,6 @@ export class SyncApi {
this.catchingUp = false;
}
if (data.groups) {
if (data.groups.invite) {
this.processGroupSyncEntry(data.groups.invite, Category.Invite);
}
if (data.groups.join) {
this.processGroupSyncEntry(data.groups.join, Category.Join);
}
if (data.groups.leave) {
this.processGroupSyncEntry(data.groups.leave, Category.Leave);
}
}
// the returned json structure is a bit crap, so make it into a
// nicer form (array) after applying sanity to make sure we don't fail
// on missing keys (on the off chance)
@@ -1208,11 +1157,11 @@ export class SyncApi {
this.notifEvents = [];
// Handle invites
inviteRooms.forEach((inviteObj) => {
await utils.promiseMapSeries(inviteRooms, async (inviteObj) => {
const room = inviteObj.room;
const stateEvents = this.mapSyncEventsFormat(inviteObj.invite_state, room);
this.processRoomEvents(room, stateEvents);
await this.processRoomEvents(room, stateEvents);
if (inviteObj.isBrandNewRoom) {
room.recalculate();
client.store.storeRoom(room);
@@ -1221,7 +1170,6 @@ export class SyncApi {
stateEvents.forEach(function(e) {
client.emit(ClientEvent.Event, e);
});
room.updateMyMembership("invite");
});
// Handle joins
@@ -1319,10 +1267,7 @@ export class SyncApi {
}
}
const [timelineEvents, threadedEvents] = this.client.partitionThreadedEvents(events);
this.processRoomEvents(room, stateEvents, timelineEvents, syncEventData.fromCache);
await this.processThreadEvents(room, threadedEvents, false);
await this.processRoomEvents(room, stateEvents, events, syncEventData.fromCache);
// set summary after processing events,
// because it will trigger a name calculation
@@ -1363,8 +1308,7 @@ export class SyncApi {
};
await utils.promiseMapSeries(stateEvents, processRoomEvent);
await utils.promiseMapSeries(timelineEvents, processRoomEvent);
await utils.promiseMapSeries(threadedEvents, processRoomEvent);
await utils.promiseMapSeries(events, processRoomEvent);
ephemeralEvents.forEach(function(e) {
client.emit(ClientEvent.Event, e);
});
@@ -1372,8 +1316,6 @@ export class SyncApi {
client.emit(ClientEvent.Event, e);
});
room.updateMyMembership("join");
// Decrypt only the last message in all rooms to make sure we can generate a preview
// And decrypt all events after the recorded read receipt to ensure an accurate
// notification count
@@ -1381,16 +1323,13 @@ export class SyncApi {
});
// Handle leaves (e.g. kicked rooms)
leaveRooms.forEach(async (leaveObj) => {
await utils.promiseMapSeries(leaveRooms, async (leaveObj) => {
const room = leaveObj.room;
const stateEvents = this.mapSyncEventsFormat(leaveObj.state, room);
const events = this.mapSyncEventsFormat(leaveObj.timeline, room);
const accountDataEvents = this.mapSyncEventsFormat(leaveObj.account_data);
const [timelineEvents, threadedEvents] = this.client.partitionThreadedEvents(events);
this.processRoomEvents(room, stateEvents, timelineEvents);
await this.processThreadEvents(room, threadedEvents, false);
await this.processRoomEvents(room, stateEvents, events);
room.addAccountData(accountDataEvents);
room.recalculate();
@@ -1404,17 +1343,12 @@ export class SyncApi {
stateEvents.forEach(function(e) {
client.emit(ClientEvent.Event, e);
});
timelineEvents.forEach(function(e) {
client.emit(ClientEvent.Event, e);
});
threadedEvents.forEach(function(e) {
events.forEach(function(e) {
client.emit(ClientEvent.Event, e);
});
accountDataEvents.forEach(function(e) {
client.emit(ClientEvent.Event, e);
});
room.updateMyMembership("leave");
});
// update the notification timeline, if appropriate.
@@ -1542,35 +1476,6 @@ export class SyncApi {
});
}
/**
* @param {Object} groupsSection Groups section object, eg. response.groups.invite
* @param {string} sectionName Which section this is ('invite', 'join' or 'leave')
*/
private processGroupSyncEntry(groupsSection: object, sectionName: Category) {
// Processes entries from 'groups' section of the sync stream
for (const groupId of Object.keys(groupsSection)) {
const groupInfo = groupsSection[groupId];
let group = this.client.store.getGroup(groupId);
const isBrandNew = group === null;
if (group === null) {
group = this.createGroup(groupId);
}
if (groupInfo.profile) {
group.setProfile(
groupInfo.profile.name, groupInfo.profile.avatar_url,
);
}
if (groupInfo.inviter) {
group.setInviter({ userId: groupInfo.inviter });
}
group.setMyMembership(sectionName);
if (isBrandNew) {
// Now we've filled in all the fields, emit the Group event
this.client.emit(ClientEvent.Group, group);
}
}
}
/**
* @param {Object} obj
* @return {Object[]}
@@ -1666,16 +1571,16 @@ export class SyncApi {
* @param {Room} room
* @param {MatrixEvent[]} stateEventList A list of state events. This is the state
* at the *START* of the timeline list if it is supplied.
* @param {MatrixEvent[]} [timelineEventList] A list of timeline events. Lower index
* @param {MatrixEvent[]} [timelineEventList] A list of timeline events, including threaded. Lower index
* @param {boolean} fromCache whether the sync response came from cache
* is earlier in time. Higher index is later.
*/
private processRoomEvents(
private async processRoomEvents(
room: Room,
stateEventList: MatrixEvent[],
timelineEventList?: MatrixEvent[],
fromCache = false,
): void {
): Promise<void> {
// If there are no events in the timeline yet, initialise it with
// the given state events
const liveTimeline = room.getLiveTimeline();
@@ -1725,38 +1630,15 @@ export class SyncApi {
room.oldState.setStateEvents(stateEventList || []);
room.currentState.setStateEvents(stateEventList || []);
}
// execute the timeline events. This will continue to diverge the current state
// Execute the timeline events. This will continue to diverge the current state
// if the timeline has any state events in it.
// This also needs to be done before running push rules on the events as they need
// to be decorated with sender etc.
room.addLiveEvents(timelineEventList || [], null, fromCache);
this.client.processBeaconEvents(room, timelineEventList);
}
/**
* @experimental
*/
private processThreadEvents(
room: Room,
threadedEvents: MatrixEvent[],
toStartOfTimeline: boolean,
): Promise<void> {
return this.client.processThreadEvents(room, threadedEvents, toStartOfTimeline);
}
// extractRelatedEvents(event: MatrixEvent, events: MatrixEvent[], relatedEvents: MatrixEvent[] = []): MatrixEvent[] {
// relatedEvents.push(event);
// const parentEventId = event.getAssociatedId();
// const parentEventIndex = events.findIndex(event => event.getId() === parentEventId);
// if (parentEventIndex > -1) {
// const [relatedEvent] = events.splice(parentEventIndex, 1);
// return this.extractRelatedEvents(relatedEvent, events, relatedEvents);
// } else {
// return relatedEvents;
// }
// }
/**
* Takes a list of timelineEvents and adds and adds to notifEvents
* as appropriate.
+9 -18
View File
@@ -99,11 +99,11 @@ export class TimelineWindow {
*
* @return {Promise}
*/
public load(initialEventId?: string, initialWindowSize = 20): Promise<any> {
public load(initialEventId?: string, initialWindowSize = 20): Promise<void> {
// given an EventTimeline, find the event we were looking for, and initialise our
// fields so that the event in question is in the middle of the window.
const initFields = (timeline: EventTimeline) => {
let eventIndex;
let eventIndex: number;
const events = timeline.getEvents();
@@ -111,40 +111,31 @@ export class TimelineWindow {
// we were looking for the live timeline: initialise to the end
eventIndex = events.length;
} else {
for (let i = 0; i < events.length; i++) {
if (events[i].getId() == initialEventId) {
eventIndex = i;
break;
}
}
eventIndex = events.findIndex(e => e.getId() === initialEventId);
if (eventIndex === undefined) {
if (eventIndex < 0) {
throw new Error("getEventTimeline result didn't include requested event");
}
}
const endIndex = Math.min(events.length,
eventIndex + Math.ceil(initialWindowSize / 2));
const endIndex = Math.min(events.length, eventIndex + Math.ceil(initialWindowSize / 2));
const startIndex = Math.max(0, endIndex - initialWindowSize);
this.start = new TimelineIndex(timeline, startIndex - timeline.getBaseIndex());
this.end = new TimelineIndex(timeline, endIndex - timeline.getBaseIndex());
this.eventCount = endIndex - startIndex;
};
// We avoid delaying the resolution of the promise by a reactor tick if
// we already have the data we need, which is important to keep room-switching
// feeling snappy.
//
// We avoid delaying the resolution of the promise by a reactor tick if we already have the data we need,
// which is important to keep room-switching feeling snappy.
if (initialEventId) {
const timeline = this.timelineSet.getTimelineForEvent(initialEventId);
if (timeline) {
// hot-path optimization to save a reactor tick by replicating the sync check getTimelineForEvent does.
initFields(timeline);
return Promise.resolve(timeline);
return Promise.resolve();
}
const prom = this.client.getEventTimeline(this.timelineSet, initialEventId);
return prom.then(initFields);
return this.client.getEventTimeline(this.timelineSet, initialEventId).then(initFields);
} else {
const tl = this.timelineSet.getLiveTimeline();
initFields(tl);
+16 -2
View File
@@ -24,6 +24,8 @@ import unhomoglyph from "unhomoglyph";
import promiseRetry from "p-retry";
import type NodeCrypto from "crypto";
import { MatrixEvent } from ".";
import { M_TIMESTAMP } from "./@types/location";
/**
* Encode a dictionary of query parameters.
@@ -425,7 +427,7 @@ export function globToRegexp(glob: string, extended?: any): string {
export function ensureNoTrailingSlash(url: string): string {
if (url && url.endsWith("/")) {
return url.substr(0, url.length - 1);
return url.slice(0, -1);
} else {
return url;
}
@@ -462,7 +464,7 @@ export function defer<T = void>(): IDeferred<T> {
}
export async function promiseMapSeries<T>(
promises: T[],
promises: Array<T | Promise<T>>,
fn: (t: T) => void,
): Promise<void> {
for (const o of promises) {
@@ -708,3 +710,15 @@ export function recursivelyAssign(target: Object, source: Object, ignoreNullish
}
return target;
}
function getContentTimestampWithFallback(event: MatrixEvent): number {
return M_TIMESTAMP.findIn<number>(event.getContent()) ?? -1;
}
/**
* Sort events by their content m.ts property
* Latest timestamp first
*/
export function sortEventsByLatestContentTimestamp(left: MatrixEvent, right: MatrixEvent): number {
return getContentTimestampWithFallback(right) - getContentTimestampWithFallback(left);
}
+494 -417
View File
File diff suppressed because it is too large Load Diff