Compare commits
430 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3702ac56f4 | |||
| af4811b327 | |||
| d4601d9910 | |||
| 2ced5e1aa4 | |||
| 45e19e51c1 | |||
| 3f1c3392d4 | |||
| f86f67f5a5 | |||
| 4dcf54f448 | |||
| d43e664594 | |||
| 0e322848f9 | |||
| b454318684 | |||
| 692f1d49b9 | |||
| b40cf75c9d | |||
| ba6a001d67 | |||
| d0c71ec516 | |||
| 67f343d6f0 | |||
| a7f0ba97cd | |||
| 54d11e1745 | |||
| 14744fd4dc | |||
| 9b0919350c | |||
| b53ad2c081 | |||
| 6222d238e4 | |||
| c6ee258789 | |||
| a584324a0d | |||
| 059b07cfa0 | |||
| b628cabe58 | |||
| b7d925f5ec | |||
| 1c901e3137 | |||
| bd4589fcc4 | |||
| 0fbd0b3685 | |||
| 1646ea05bc | |||
| 885ec1fc73 | |||
| df2b65f111 | |||
| f09853ccb1 | |||
| 6c543382e6 | |||
| 52932f59ab | |||
| 4f63ff21ea | |||
| c8dc71eb69 | |||
| 2dda837db6 | |||
| fff4cdab7c | |||
| 4f00566b9f | |||
| c1a3b95073 | |||
| 38adbaf923 | |||
| 9459a95134 | |||
| 433b7afd71 | |||
| 777cf1f135 | |||
| 8235b65d71 | |||
| 4a33e584b0 | |||
| 6ee185e93f | |||
| 5df9705bae | |||
| 76458d3a40 | |||
| 27bb79a29a | |||
| 82d942dcc5 | |||
| ce6d0e2cb1 | |||
| db49cd8d13 | |||
| 42b08eca57 | |||
| a92c148f15 | |||
| 6cd60e32dc | |||
| b6633ad4b0 | |||
| e4dd7bcc87 | |||
| b6e97fcecb | |||
| dee2b60c3d | |||
| a07fe44565 | |||
| 0a35f2e2c7 | |||
| 32d535c2b1 | |||
| 7fb313c17c | |||
| d8f6449422 | |||
| c043e36f50 | |||
| e6524239bd | |||
| daed4b9dcc | |||
| 135d2da143 | |||
| fef53be5b4 | |||
| 7ec726e10b | |||
| 476f6f78b1 | |||
| cb8123dec7 | |||
| 6729c7d421 | |||
| 7ddd198df8 | |||
| 81681f4090 | |||
| 94072a096d | |||
| b9cccf9109 | |||
| f0d4ef7f99 | |||
| 849e3d67c2 | |||
| 4c6e1e5c21 | |||
| 9ff6b357fc | |||
| 77ef8558bd | |||
| d979302e9b | |||
| dbdaa1540a | |||
| 13c751c060 | |||
| 87115d181d | |||
| 5679c86ca6 | |||
| 4cd50e4871 | |||
| 0f1012278a | |||
| 0d211dfbad | |||
| 384116c8f5 | |||
| c374ba2367 | |||
| 450ff00c3e | |||
| b4ab7fc0b3 | |||
| 35f697a04b | |||
| 193d8a429a | |||
| 8cd5aac128 | |||
| eddd0cafe8 | |||
| 5a0787349d | |||
| c57c8978cf | |||
| dfe535bc07 | |||
| 3c33c422e6 | |||
| d521f97411 | |||
| c0a5299704 | |||
| ce3b72c850 | |||
| 935517746a | |||
| e48d919cd4 | |||
| ab39ee37d6 | |||
| af6f9d49f4 | |||
| a2981efac3 | |||
| 4625ed73cf | |||
| 6f7a72d69e | |||
| 2a0ffe1223 | |||
| 72a6ec0dd3 | |||
| 72b89fde6e | |||
| c400dd4ff8 | |||
| f41b7706e4 | |||
| de694459be | |||
| 6fc9827b10 | |||
| f52c5eb667 | |||
| c05cb3ad2b | |||
| 586a313c8d | |||
| c605310b87 | |||
| 41cee6f1cc | |||
| 3e1e99f8e5 | |||
| 276849f068 | |||
| 37118991f5 | |||
| 00629e6dc9 | |||
| 02f6a09bcf | |||
| 36a6117ee2 | |||
| aebe26db96 | |||
| 60e175a0e0 | |||
| d950cda05c | |||
| 83c848093f | |||
| fa6f70f708 | |||
| 98d119d6e1 | |||
| aca51fd8a3 | |||
| c78631bdee | |||
| 0d6a93b5f6 | |||
| 40ecfa7932 | |||
| d656b848f8 | |||
| 0981652de4 | |||
| db32420d16 | |||
| d5b82e343a | |||
| 965f4fb13b | |||
| 9e1b126854 | |||
| c527f85fb1 | |||
| 4a294c9dd3 | |||
| be94f5ea93 | |||
| 5f9369abee | |||
| e7a7ec0673 | |||
| 92cd84fc0c | |||
| 45e56f8cc3 | |||
| e95947dc73 | |||
| 448a5c9a77 | |||
| 9589a97952 | |||
| 2566c40e96 | |||
| 099cac0162 | |||
| e4cf5b26ee | |||
| c698317f3f | |||
| e8f682f452 | |||
| 020743141b | |||
| 5f5a9b1a43 | |||
| 3334c01191 | |||
| 0b8de251bf | |||
| 88ce017333 | |||
| 471f174889 | |||
| c0dacb5037 | |||
| 2cc51e0db7 | |||
| 22c5999fed | |||
| b711781f16 | |||
| 8ba2d257ae | |||
| 9e2e144530 | |||
| 38a6949e5d | |||
| e876482e62 | |||
| 544b1c6742 | |||
| 984dd26a13 | |||
| bdb91b3806 | |||
| 9a15094374 | |||
| e980c88901 | |||
| 6ea2885796 | |||
| ca5ac79927 | |||
| f9672cf307 | |||
| e7493fd417 | |||
| f553854730 | |||
| c89bbf4bf5 | |||
| ebcb26f1b3 | |||
| 5b4263bf55 | |||
| df9ffdc408 | |||
| 70449ea003 | |||
| 9192b876d2 | |||
| 04d0d61a0e | |||
| 404f8e130e | |||
| b97b862fb6 | |||
| 5e766978b8 | |||
| 34ef7bc64a | |||
| 18e2052af2 | |||
| aa0d3bd1f5 | |||
| 942a28ddf6 | |||
| 87791cd391 | |||
| 38e54ae7f2 | |||
| acef1d7dd0 | |||
| da615fd512 | |||
| f4f05550ef | |||
| f475251ddd | |||
| 83f61c96f3 | |||
| 85a6a552b5 | |||
| 9702e8a5fa | |||
| d82c041b99 | |||
| 8d9cd0fcb3 | |||
| 96ba061732 | |||
| ee4cbd1ec9 | |||
| 2a0dc39eec | |||
| 6e25b13312 | |||
| 94c5e37570 | |||
| 09fee4a2d9 | |||
| 49994ac4fc | |||
| e68cabc70e | |||
| c819ac634f | |||
| 17f5ab4191 | |||
| e270f075a4 | |||
| 0ef6c2e35f | |||
| 7a249e3ef5 | |||
| 353d6bab47 | |||
| 7f21f569d5 | |||
| fa5eae70dd | |||
| 3db056ad3e | |||
| a2a127d9a4 | |||
| d12bccd211 | |||
| d8e597ccdf | |||
| c801690e28 | |||
| b4fe00a3a8 | |||
| d42e2fe2c0 | |||
| 4a4465b9fc | |||
| 1a78301adb | |||
| bbf7020755 | |||
| 592fb0cf10 | |||
| 015eb5d5c4 | |||
| 42fef0e7aa | |||
| 28f3169a28 | |||
| d8285aad00 | |||
| eeacf8c22c | |||
| ee995cb39b | |||
| 7529af43e4 | |||
| 3fac6d7180 | |||
| 487bfc88ef | |||
| c91617a799 | |||
| 87bf115967 | |||
| 18bb5c3079 | |||
| f3f9e41787 | |||
| 7993dd7630 | |||
| bef557976b | |||
| 549f9b7e29 | |||
| 06d9d6207c | |||
| e336aceaba | |||
| fcc4b71f06 | |||
| d1a62eddfc | |||
| ffbd10a7b8 | |||
| d0e37ee323 | |||
| 96ef535ebb | |||
| 0683133d5b | |||
| 64c3ac55a4 | |||
| 5f06df8a87 | |||
| 3291846714 | |||
| 139904f297 | |||
| c2fe2ab270 | |||
| 4e26f29032 | |||
| 31391121dc | |||
| 7d48a8394d | |||
| 28da62c01c | |||
| e880cece93 | |||
| 97e8fcea75 | |||
| f28cb48fe1 | |||
| a2e255c2c9 | |||
| 74c5a20371 | |||
| 4b87907b92 | |||
| f76f708c96 | |||
| 17f7dc5463 | |||
| b253ad9e81 | |||
| c1f56ba3c4 | |||
| 7998817f7e | |||
| bdc12a2544 | |||
| 5a92597abd | |||
| 6f695c1b82 | |||
| d99428f2c1 | |||
| 4c9648a23b | |||
| 8c5f88c4a7 | |||
| 923e9c4ada | |||
| 13d62e71b6 | |||
| 32aca09f47 | |||
| 067ac62271 | |||
| 841e6e999d | |||
| a48546f60d | |||
| 2f09e9641c | |||
| f46355e7c0 | |||
| 53397ee0d1 | |||
| 5a83635ef5 | |||
| 56c0c9be4d | |||
| 24406d2411 | |||
| aeeed6ecd7 | |||
| 9f3f9990ef | |||
| 119ce2e46f | |||
| fc8a867e8e | |||
| b4d8c0b603 | |||
| 3b0d1b2696 | |||
| 5110e0b91e | |||
| 305de54106 | |||
| 0555f9db1c | |||
| 159e825877 | |||
| 8131b3900d | |||
| 431d7a0933 | |||
| e9b52e23d2 | |||
| 0148ad0766 | |||
| 213f1134b6 | |||
| 50e6a8f6b1 | |||
| 4a82e1bf05 | |||
| 843973c4da | |||
| debeb66d6f | |||
| 015d0f9135 | |||
| 5c8e7f2be0 | |||
| 411b5f111c | |||
| 2d231c0ae2 | |||
| ec37eb8b6f | |||
| 1cdcebb5db | |||
| a0f6eea363 | |||
| 18b1a44df7 | |||
| 4b6b1599a2 | |||
| a582b19435 | |||
| 4a8c3d273f | |||
| 8dc608d917 | |||
| 7ef38ed1b2 | |||
| 593f62c1c4 | |||
| 04d674b8c7 | |||
| 27eb88f4a1 | |||
| 1409a4f814 | |||
| 8232896c85 | |||
| e2ed80ffa0 | |||
| 8ac3841a2f | |||
| ba57736bf6 | |||
| 8be4ca909e | |||
| 0d964523a9 | |||
| bb504bc001 | |||
| 326aec9f9e | |||
| 688327dab5 | |||
| 3f4522ba88 | |||
| 625983a2b2 | |||
| 137fd2bd40 | |||
| 1e65bfd316 | |||
| 5da072712d | |||
| 529d61b5f4 | |||
| 5111ca622a | |||
| f627507b86 | |||
| aee4459201 | |||
| 1a824750dd | |||
| 73cb5e1ee9 | |||
| 96bde1f706 | |||
| 5251dcf67f | |||
| ce0b0ea182 | |||
| 7a142e9102 | |||
| f85aa44f28 | |||
| efbf252e22 | |||
| d873f14b6d | |||
| cf1ba12232 | |||
| df208e4de8 | |||
| d8ef7f9f63 | |||
| 2515ba31a0 | |||
| 715c4577d0 | |||
| a2f23900c9 | |||
| e9e65cf484 | |||
| 205c80ea28 | |||
| 678023717b | |||
| b535969845 | |||
| 027bc6bfc9 | |||
| 71ca424712 | |||
| 3280394bf9 | |||
| fc07530434 | |||
| f592d4dbc5 | |||
| 96f48929ac | |||
| 454da84f6e | |||
| 89bda6c2e5 | |||
| ac70dcfc91 | |||
| 9c7cb3cbea | |||
| d8d7bd548f | |||
| 55ef57ead8 | |||
| 9996afed03 | |||
| 61a80a11c9 | |||
| 6a8e8ed0a6 | |||
| 5895ce32fa | |||
| fe0a268991 | |||
| 7f189b0abd | |||
| 6e07c9e900 | |||
| bbeea51a36 | |||
| 151b54ed65 | |||
| 18986cb33a | |||
| aef5d73de4 | |||
| e4fc1f3628 | |||
| 8b1c173659 | |||
| f0916f14d1 | |||
| a291f5ab05 | |||
| 2d7e07f4ed | |||
| 2427f75f98 | |||
| d25fb71eba | |||
| c81b9d2fd9 | |||
| fb3ca90bc9 | |||
| eb2a47623f | |||
| f18d8ead08 | |||
| 2da14bd6e9 | |||
| 1dbb776e12 | |||
| 07b2c57064 | |||
| 7021f70a66 | |||
| 503e954671 | |||
| 2add1fcbcb | |||
| 4fe115b2c4 | |||
| 60e168806d | |||
| 03dfab1282 | |||
| 19302ea4fb | |||
| d5aaed67ba | |||
| 8fe6afd9ab | |||
| 782fbb115f | |||
| 3971bf34ed | |||
| 6dac6e53f7 | |||
| 7ec84e92a0 | |||
| 154e5c45a6 | |||
| 2cd5c813ac | |||
| 1c5101aa1a | |||
| 76f11bee9e | |||
| 91f409e8f4 |
@@ -1 +1,4 @@
|
||||
* @matrix-org/element-web
|
||||
|
||||
/src/webrtc @matrix-org/element-call-reviewers
|
||||
/spec/*/webrtc @matrix-org/element-call-reviewers
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
name: Deploy documentation PR preview
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: [ "Static Analysis" ]
|
||||
types:
|
||||
- completed
|
||||
|
||||
jobs:
|
||||
netlify:
|
||||
if: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# 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 artifact
|
||||
uses: dawidd6/action-download-artifact@b12b127cf24433d14b4f93cee62f5465076ba82a # v2.24.1
|
||||
with:
|
||||
workflow: static_analysis.yml
|
||||
run_id: ${{ github.event.workflow_run.id }}
|
||||
name: docs
|
||||
path: docs
|
||||
|
||||
- name: 📤 Deploy to Netlify
|
||||
uses: matrix-org/netlify-pr-preview@v1
|
||||
with:
|
||||
path: docs
|
||||
owner: ${{ github.event.workflow_run.head_repository.owner.login }}
|
||||
branch: ${{ github.event.workflow_run.head_branch }}
|
||||
revision: ${{ github.event.workflow_run.head_sha }}
|
||||
token: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
site_id: ${{ secrets.NETLIFY_SITE_ID }}
|
||||
desc: Documentation preview
|
||||
deployment_env: PR Documentation Preview
|
||||
@@ -24,10 +24,7 @@ jobs:
|
||||
|
||||
- name: 📋 Copy to temp
|
||||
run: |
|
||||
tag="${{ github.ref_name }}"
|
||||
version="${tag#v}"
|
||||
echo "VERSION=$version" >> $GITHUB_ENV
|
||||
cp -a "./.jsdoc/matrix-js-sdk/$version" $RUNNER_TEMP
|
||||
cp -a "./_docs" "$RUNNER_TEMP/"
|
||||
|
||||
- name: 🧮 Checkout gh-pages
|
||||
uses: actions/checkout@v3
|
||||
@@ -36,7 +33,10 @@ jobs:
|
||||
|
||||
- name: 🔪 Prepare
|
||||
run: |
|
||||
cp -a "$RUNNER_TEMP/$VERSION" .
|
||||
tag="${{ github.ref_name }}"
|
||||
VERSION="${tag#v}"
|
||||
[ ! -e "$VERSION" ] || rm -r $VERSION
|
||||
cp -r $RUNNER_TEMP/docs/ $VERSION
|
||||
|
||||
# Add the new directory to the index if it isn't there already
|
||||
if ! grep -q ">Version $VERSION</a>" index.html; then
|
||||
|
||||
@@ -5,6 +5,11 @@ on:
|
||||
secrets:
|
||||
SONAR_TOKEN:
|
||||
required: true
|
||||
inputs:
|
||||
extra_args:
|
||||
type: string
|
||||
required: false
|
||||
description: "Extra args to pass to SonarCloud"
|
||||
jobs:
|
||||
sonarqube:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -22,7 +27,7 @@ jobs:
|
||||
|
||||
- name: "🩻 SonarCloud Scan"
|
||||
id: sonarcloud
|
||||
uses: matrix-org/sonarcloud-workflow-action@v2.2
|
||||
uses: matrix-org/sonarcloud-workflow-action@v2.3
|
||||
with:
|
||||
repository: ${{ github.event.workflow_run.head_repository.full_name }}
|
||||
is_pr: ${{ github.event.workflow_run.event == 'pull_request' }}
|
||||
@@ -33,8 +38,8 @@ jobs:
|
||||
coverage_run_id: ${{ github.event.workflow_run.id }}
|
||||
coverage_workflow_name: tests.yml
|
||||
coverage_extract_path: coverage
|
||||
|
||||
|
||||
extra_args: ${{ inputs.extra_args }}
|
||||
|
||||
- uses: Sibz/github-status-action@v1
|
||||
if: always()
|
||||
with:
|
||||
|
||||
@@ -8,8 +8,36 @@ concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_branch }}
|
||||
cancel-in-progress: true
|
||||
jobs:
|
||||
# This is a workaround for https://github.com/SonarSource/SonarJS/issues/578
|
||||
prepare:
|
||||
name: Prepare
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
reportPaths: ${{ steps.extra_args.outputs.reportPaths }}
|
||||
testExecutionReportPaths: ${{ steps.extra_args.outputs.testExecutionReportPaths }}
|
||||
steps:
|
||||
# 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 artifact
|
||||
uses: dawidd6/action-download-artifact@v2
|
||||
with:
|
||||
workflow: tests.yaml
|
||||
run_id: ${{ github.event.workflow_run.id }}
|
||||
name: coverage
|
||||
path: coverage
|
||||
|
||||
- id: extra_args
|
||||
run: |
|
||||
coverage=$(find coverage -type f -name '*lcov.info' | tr '\n' ',' | sed 's/,$//g')
|
||||
echo "reportPaths=$coverage" >> $GITHUB_OUTPUT
|
||||
reports=$(find coverage -type f -name 'jest-sonar-report*.xml' | tr '\n' ',' | sed 's/,$//g')
|
||||
echo "testExecutionReportPaths=$reports" >> $GITHUB_OUTPUT
|
||||
|
||||
sonarqube:
|
||||
name: 🩻 SonarQube
|
||||
needs: prepare
|
||||
uses: matrix-org/matrix-js-sdk/.github/workflows/sonarcloud.yml@develop
|
||||
secrets:
|
||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||
with:
|
||||
extra_args: -Dsonar.javascript.lcov.reportPaths=${{ needs.prepare.outputs.reportPaths }} -Dsonar.testExecutionReportPaths=${{ needs.prepare.outputs.testExecutionReportPaths }}
|
||||
|
||||
@@ -64,38 +64,11 @@ jobs:
|
||||
|
||||
- name: Generate Docs
|
||||
run: "yarn run gendoc"
|
||||
|
||||
tsc-strict:
|
||||
name: Typescript Strict Error Checker
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: read
|
||||
checks: write
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Get diff lines
|
||||
id: diff
|
||||
uses: Equip-Collaboration/diff-line-numbers@v1.0.0
|
||||
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
include: '["\\.tsx?$"]'
|
||||
|
||||
- name: Detecting files changed
|
||||
id: files
|
||||
uses: futuratrepadeira/changed-files@v4.0.0
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
pattern: '^.*\.tsx?$'
|
||||
|
||||
- uses: t3chguy/typescript-check-action@main
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
use-check: false
|
||||
check-fail-mode: added
|
||||
output-behaviour: annotate
|
||||
ts-extra-args: '--strict'
|
||||
files-changed: ${{ steps.files.outputs.files_updated }}
|
||||
files-added: ${{ steps.files.outputs.files_created }}
|
||||
files-deleted: ${{ steps.files.outputs.files_deleted }}
|
||||
line-numbers: ${{ steps.diff.outputs.lineNumbers }}
|
||||
name: docs
|
||||
path: _docs
|
||||
# We'll only use this in a workflow_run, then we're done with it
|
||||
retention-days: 1
|
||||
|
||||
@@ -8,25 +8,40 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
jobs:
|
||||
jest:
|
||||
name: Jest
|
||||
name: 'Jest [${{ matrix.specs }}] (Node ${{ matrix.node }})'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
strategy:
|
||||
matrix:
|
||||
specs: [browserify, integ, unit]
|
||||
node: [16, 18, latest]
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Yarn cache
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
cache: 'yarn'
|
||||
node-version: ${{ matrix.node }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: "yarn install"
|
||||
|
||||
- name: Build
|
||||
if: matrix.specs == 'browserify'
|
||||
run: "yarn build"
|
||||
|
||||
- name: Get number of CPU cores
|
||||
id: cpu-cores
|
||||
uses: SimenB/github-actions-cpu-cores@v1
|
||||
|
||||
- name: Run tests with coverage
|
||||
run: "yarn coverage --ci --reporters github-actions"
|
||||
run: |
|
||||
yarn coverage --ci --reporters github-actions --max-workers ${{ steps.cpu-cores.outputs.count }} ./spec/${{ matrix.specs }}
|
||||
mv coverage/lcov.info coverage/${{ matrix.node }}-${{ matrix.specs }}.lcov.info
|
||||
env:
|
||||
JEST_SONAR_UNIQUE_OUTPUT_NAME: true
|
||||
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
/.jsdocbuild
|
||||
/.jsdoc
|
||||
/_docs
|
||||
.DS_Store
|
||||
|
||||
node_modules
|
||||
/.npmrc
|
||||
|
||||
@@ -1,3 +1,62 @@
|
||||
Changes in [21.2.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v21.2.0) (2022-11-22)
|
||||
==================================================================================================
|
||||
|
||||
## ✨ Features
|
||||
* Make calls go back to 'connecting' state when media lost ([\#2880](https://github.com/matrix-org/matrix-js-sdk/pull/2880)).
|
||||
* Add ability to send unthreaded receipt ([\#2878](https://github.com/matrix-org/matrix-js-sdk/pull/2878)).
|
||||
* Add way to abort search requests ([\#2877](https://github.com/matrix-org/matrix-js-sdk/pull/2877)).
|
||||
* sliding sync: add custom room subscriptions support ([\#2834](https://github.com/matrix-org/matrix-js-sdk/pull/2834)).
|
||||
* webrtc: add advanced audio settings ([\#2434](https://github.com/matrix-org/matrix-js-sdk/pull/2434)). Contributed by @MrAnno.
|
||||
* Add support for group calls using MSC3401 ([\#2553](https://github.com/matrix-org/matrix-js-sdk/pull/2553)).
|
||||
* Make the js-sdk conform to tsc --strict ([\#2835](https://github.com/matrix-org/matrix-js-sdk/pull/2835)). Fixes #2112 #2116 and #2124.
|
||||
* Let leave requests outlive the window ([\#2815](https://github.com/matrix-org/matrix-js-sdk/pull/2815)). Fixes vector-im/element-call#639.
|
||||
* Add event and message capabilities to RoomWidgetClient ([\#2797](https://github.com/matrix-org/matrix-js-sdk/pull/2797)).
|
||||
* Misc fixes for group call widgets ([\#2657](https://github.com/matrix-org/matrix-js-sdk/pull/2657)).
|
||||
* Support nested Matrix clients via the widget API ([\#2473](https://github.com/matrix-org/matrix-js-sdk/pull/2473)).
|
||||
* Set max average bitrate on PTT calls ([\#2499](https://github.com/matrix-org/matrix-js-sdk/pull/2499)). Fixes vector-im/element-call#440.
|
||||
* Add config option for e2e group call signalling ([\#2492](https://github.com/matrix-org/matrix-js-sdk/pull/2492)).
|
||||
* Enable DTX on audio tracks in calls ([\#2482](https://github.com/matrix-org/matrix-js-sdk/pull/2482)).
|
||||
* Don't ignore call member events with a distant future expiration date ([\#2466](https://github.com/matrix-org/matrix-js-sdk/pull/2466)).
|
||||
* Expire call member state events after 1 hour ([\#2446](https://github.com/matrix-org/matrix-js-sdk/pull/2446)).
|
||||
* Emit unknown device errors for group call participants without e2e ([\#2447](https://github.com/matrix-org/matrix-js-sdk/pull/2447)).
|
||||
* Mute disconnected peers in PTT mode ([\#2421](https://github.com/matrix-org/matrix-js-sdk/pull/2421)).
|
||||
* Add support for sending encrypted to-device events with OLM ([\#2322](https://github.com/matrix-org/matrix-js-sdk/pull/2322)). Contributed by @robertlong.
|
||||
* Support for PTT group call mode ([\#2338](https://github.com/matrix-org/matrix-js-sdk/pull/2338)).
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
* Fix registration add phone number not working ([\#2876](https://github.com/matrix-org/matrix-js-sdk/pull/2876)). Contributed by @bagvand.
|
||||
* Use an underride rule for Element Call notifications ([\#2873](https://github.com/matrix-org/matrix-js-sdk/pull/2873)). Fixes vector-im/element-web#23691.
|
||||
* Fixes unwanted highlight notifications with encrypted threads ([\#2862](https://github.com/matrix-org/matrix-js-sdk/pull/2862)).
|
||||
* Extra insurance that we don't mix events in the wrong timelines - v2 ([\#2856](https://github.com/matrix-org/matrix-js-sdk/pull/2856)). Contributed by @MadLittleMods.
|
||||
* Hide pending events in thread timelines ([\#2843](https://github.com/matrix-org/matrix-js-sdk/pull/2843)). Fixes vector-im/element-web#23684.
|
||||
* Fix pagination token tracking for mixed room timelines ([\#2855](https://github.com/matrix-org/matrix-js-sdk/pull/2855)). Fixes vector-im/element-web#23695.
|
||||
* Extra insurance that we don't mix events in the wrong timelines ([\#2848](https://github.com/matrix-org/matrix-js-sdk/pull/2848)). Contributed by @MadLittleMods.
|
||||
* Do not freeze state in `initialiseState()` ([\#2846](https://github.com/matrix-org/matrix-js-sdk/pull/2846)).
|
||||
* Don't remove our own member for a split second when entering a call ([\#2844](https://github.com/matrix-org/matrix-js-sdk/pull/2844)).
|
||||
* Resolve races between `initLocalCallFeed` and `leave` ([\#2826](https://github.com/matrix-org/matrix-js-sdk/pull/2826)).
|
||||
* Add throwOnFail to groupCall.setScreensharingEnabled ([\#2787](https://github.com/matrix-org/matrix-js-sdk/pull/2787)).
|
||||
* Fix connectivity regressions ([\#2780](https://github.com/matrix-org/matrix-js-sdk/pull/2780)).
|
||||
* Fix screenshare failing after several attempts ([\#2771](https://github.com/matrix-org/matrix-js-sdk/pull/2771)). Fixes vector-im/element-call#625.
|
||||
* Don't block muting/unmuting on network requests ([\#2754](https://github.com/matrix-org/matrix-js-sdk/pull/2754)). Fixes vector-im/element-call#592.
|
||||
* Fix ICE restarts ([\#2702](https://github.com/matrix-org/matrix-js-sdk/pull/2702)).
|
||||
* Target widget actions at a specific room ([\#2670](https://github.com/matrix-org/matrix-js-sdk/pull/2670)).
|
||||
* Add tests for ice candidate sending ([\#2674](https://github.com/matrix-org/matrix-js-sdk/pull/2674)).
|
||||
* Prevent exception when muting ([\#2667](https://github.com/matrix-org/matrix-js-sdk/pull/2667)). Fixes vector-im/element-call#578.
|
||||
* Fix race in creating calls ([\#2662](https://github.com/matrix-org/matrix-js-sdk/pull/2662)).
|
||||
* Add client.waitUntilRoomReadyForGroupCalls() ([\#2641](https://github.com/matrix-org/matrix-js-sdk/pull/2641)).
|
||||
* Wait for client to start syncing before making group calls ([\#2632](https://github.com/matrix-org/matrix-js-sdk/pull/2632)). Fixes #2589.
|
||||
* Add GroupCallEventHandlerEvent.Room ([\#2631](https://github.com/matrix-org/matrix-js-sdk/pull/2631)).
|
||||
* Add missing events from reemitter to GroupCall ([\#2527](https://github.com/matrix-org/matrix-js-sdk/pull/2527)). Contributed by @toger5.
|
||||
* Prevent double mute status changed events ([\#2502](https://github.com/matrix-org/matrix-js-sdk/pull/2502)).
|
||||
* Don't mute the remote side immediately in PTT calls ([\#2487](https://github.com/matrix-org/matrix-js-sdk/pull/2487)). Fixes vector-im/element-call#425.
|
||||
* Fix some MatrixCall leaks and use a shared AudioContext ([\#2484](https://github.com/matrix-org/matrix-js-sdk/pull/2484)). Fixes vector-im/element-call#412.
|
||||
* Don't block muting on determining whether the device exists ([\#2461](https://github.com/matrix-org/matrix-js-sdk/pull/2461)).
|
||||
* Only clone streams on Safari ([\#2450](https://github.com/matrix-org/matrix-js-sdk/pull/2450)). Fixes vector-im/element-call#267.
|
||||
* Set PTT mode on call correctly ([\#2445](https://github.com/matrix-org/matrix-js-sdk/pull/2445)). Fixes vector-im/element-call#382.
|
||||
* Wait for mute event to send in PTT mode ([\#2401](https://github.com/matrix-org/matrix-js-sdk/pull/2401)).
|
||||
* Handle other members having no e2e keys ([\#2383](https://github.com/matrix-org/matrix-js-sdk/pull/2383)). Fixes vector-im/element-call#338.
|
||||
* Fix races when muting/unmuting ([\#2370](https://github.com/matrix-org/matrix-js-sdk/pull/2370)).
|
||||
|
||||
Changes in [21.1.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v21.1.0) (2022-11-08)
|
||||
==================================================================================================
|
||||
|
||||
|
||||
@@ -295,12 +295,12 @@ API Reference
|
||||
A hosted reference can be found at
|
||||
http://matrix-org.github.io/matrix-js-sdk/index.html
|
||||
|
||||
This SDK uses JSDoc3 style comments. You can manually build and
|
||||
This SDK uses [Typedoc](https://typedoc.org/guides/doccomments) doc comments. You can manually build and
|
||||
host the API reference from the source files like this:
|
||||
|
||||
```
|
||||
$ yarn gendoc
|
||||
$ cd .jsdoc
|
||||
$ cd _docs
|
||||
$ python -m http.server 8005
|
||||
```
|
||||
|
||||
|
||||
-30
@@ -1,30 +0,0 @@
|
||||
{
|
||||
"tags": {
|
||||
"allowUnknownTags": true
|
||||
},
|
||||
"plugins": [
|
||||
"node_modules/better-docs/category",
|
||||
"node_modules/better-docs/typescript"
|
||||
],
|
||||
"source": {
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"includePattern": ".(ts|js)$"
|
||||
},
|
||||
"opts": {
|
||||
"encoding": "utf8",
|
||||
"destination": ".jsdoc",
|
||||
"readme": "README.md",
|
||||
"recurse": true,
|
||||
"verbose": true,
|
||||
"template": "node_modules/docdash"
|
||||
},
|
||||
"docdash": {
|
||||
"static": true,
|
||||
"private": false,
|
||||
"search": true,
|
||||
"collapse": true,
|
||||
"typedefs": true
|
||||
}
|
||||
}
|
||||
+16
-10
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "matrix-js-sdk",
|
||||
"version": "21.1.0",
|
||||
"version": "21.2.0",
|
||||
"description": "Matrix Client-Server SDK for Javascript",
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
@@ -16,7 +16,7 @@
|
||||
"build:compile": "babel -d lib --verbose --extensions \".ts,.js\" src",
|
||||
"build:compile-browser": "mkdirp dist && browserify -d src/browser-index.js -p [ tsify -p ./tsconfig-build.json ] -t [ babelify --sourceMaps=inline --presets [ @babel/preset-env @babel/preset-typescript ] ] | exorcist dist/browser-matrix.js.map > dist/browser-matrix.js",
|
||||
"build:minify-browser": "terser dist/browser-matrix.js --compress --mangle --source-map --output dist/browser-matrix.min.js",
|
||||
"gendoc": "jsdoc -c jsdoc.json -P package.json",
|
||||
"gendoc": "typedoc",
|
||||
"lint": "yarn lint:types && yarn lint:js",
|
||||
"lint:js": "eslint --max-warnings 0 src spec",
|
||||
"lint:js-fix": "eslint --fix src spec",
|
||||
@@ -54,13 +54,16 @@
|
||||
],
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@types/sdp-transform": "^2.4.5",
|
||||
"another-json": "^0.2.0",
|
||||
"bs58": "^5.0.0",
|
||||
"content-type": "^1.0.4",
|
||||
"loglevel": "^1.7.1",
|
||||
"matrix-events-sdk": "^0.0.1-beta.7",
|
||||
"matrix-events-sdk": "0.0.1",
|
||||
"matrix-widget-api": "^1.0.0",
|
||||
"p-retry": "4",
|
||||
"qs": "^6.9.6",
|
||||
"sdp-transform": "^2.14.1",
|
||||
"unhomoglyph": "^1.0.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -76,6 +79,7 @@
|
||||
"@babel/preset-env": "^7.12.11",
|
||||
"@babel/preset-typescript": "^7.12.7",
|
||||
"@babel/register": "^7.12.10",
|
||||
"@casualbot/jest-sonar-reporter": "^2.2.5",
|
||||
"@matrix-org/olm": "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.13.tgz",
|
||||
"@types/bs58": "^4.0.1",
|
||||
"@types/content-type": "^1.1.5",
|
||||
@@ -91,7 +95,7 @@
|
||||
"browserify": "^17.0.0",
|
||||
"docdash": "^1.2.0",
|
||||
"domexception": "^4.0.0",
|
||||
"eslint": "8.25.0",
|
||||
"eslint": "8.26.0",
|
||||
"eslint-config-google": "^0.14.0",
|
||||
"eslint-import-resolver-typescript": "^3.5.1",
|
||||
"eslint-plugin-import": "^2.26.0",
|
||||
@@ -100,14 +104,15 @@
|
||||
"exorcist": "^2.0.0",
|
||||
"fake-indexeddb": "^4.0.0",
|
||||
"jest": "^29.0.0",
|
||||
"jest-environment-jsdom": "^28.1.3",
|
||||
"jest-localstorage-mock": "^2.4.6",
|
||||
"jest-mock": "^29.0.0",
|
||||
"jest-sonar-reporter": "^2.0.0",
|
||||
"jsdoc": "^3.6.6",
|
||||
"matrix-mock-request": "^2.5.0",
|
||||
"rimraf": "^3.0.2",
|
||||
"terser": "^5.5.1",
|
||||
"tsify": "^5.0.2",
|
||||
"typedoc": "^0.23.20",
|
||||
"typedoc-plugin-missing-exports": "^1.0.0",
|
||||
"typescript": "^4.5.3"
|
||||
},
|
||||
"jest": {
|
||||
@@ -125,11 +130,12 @@
|
||||
"text-summary",
|
||||
"lcov"
|
||||
],
|
||||
"testResultsProcessor": "jest-sonar-reporter"
|
||||
"testResultsProcessor": "@casualbot/jest-sonar-reporter"
|
||||
},
|
||||
"jestSonar": {
|
||||
"reportPath": "coverage",
|
||||
"sonar56x": true
|
||||
"@casualbot/jest-sonar-reporter": {
|
||||
"outputDirectory": "coverage",
|
||||
"outputName": "jest-sonar-report.xml",
|
||||
"relativePaths": true
|
||||
},
|
||||
"typings": "./lib/index.d.ts"
|
||||
}
|
||||
|
||||
@@ -11,6 +11,6 @@ sonar.exclusions=docs,examples,git-hooks
|
||||
sonar.typescript.tsconfigPath=./tsconfig.json
|
||||
sonar.javascript.lcov.reportPaths=coverage/lcov.info
|
||||
sonar.coverage.exclusions=spec/**/*
|
||||
sonar.testExecutionReportPaths=coverage/test-report.xml
|
||||
sonar.testExecutionReportPaths=coverage/jest-sonar-report.xml
|
||||
|
||||
sonar.lang.patterns.ts=**/*.ts,**/*.tsx
|
||||
|
||||
@@ -14,6 +14,21 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import "../../dist/browser-matrix"; // uses browser-matrix instead of the src
|
||||
import type { MatrixClient, ClientEvent } from "../../src";
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||
namespace NodeJS {
|
||||
interface Global {
|
||||
matrixcs: {
|
||||
MatrixClient: typeof MatrixClient;
|
||||
ClientEvent: typeof ClientEvent;
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// stub for browser-matrix browserify tests
|
||||
// @ts-ignore
|
||||
global.XMLHttpRequest = jest.fn();
|
||||
@@ -23,3 +38,9 @@ afterAll(() => {
|
||||
// @ts-ignore
|
||||
global.XMLHttpRequest = undefined;
|
||||
});
|
||||
|
||||
// Akin to spec/setupTests.ts - but that won't affect the browser-matrix bundle
|
||||
global.matrixcs = {
|
||||
...global.matrixcs,
|
||||
timeoutSignal: () => new AbortController().signal,
|
||||
};
|
||||
|
||||
@@ -16,27 +16,14 @@ limitations under the License.
|
||||
|
||||
import HttpBackend from "matrix-mock-request";
|
||||
|
||||
import "./setupTests";
|
||||
import "../../dist/browser-matrix"; // uses browser-matrix instead of the src
|
||||
import type { MatrixClient, ClientEvent } from "../../src";
|
||||
import "./setupTests";// uses browser-matrix instead of the src
|
||||
import type { MatrixClient } from "../../src";
|
||||
|
||||
const USER_ID = "@user:test.server";
|
||||
const DEVICE_ID = "device_id";
|
||||
const ACCESS_TOKEN = "access_token";
|
||||
const ROOM_ID = "!room_id:server.test";
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||
namespace NodeJS {
|
||||
interface Global {
|
||||
matrixcs: {
|
||||
MatrixClient: typeof MatrixClient;
|
||||
ClientEvent: typeof ClientEvent;
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe("Browserify Test", function() {
|
||||
let client: MatrixClient;
|
||||
let httpBackend: HttpBackend;
|
||||
|
||||
@@ -18,12 +18,14 @@ import * as utils from "../test-utils/test-utils";
|
||||
import {
|
||||
ClientEvent,
|
||||
Direction,
|
||||
EventStatus,
|
||||
EventTimeline,
|
||||
EventTimelineSet,
|
||||
Filter,
|
||||
IEvent,
|
||||
MatrixClient,
|
||||
MatrixEvent,
|
||||
PendingEventOrdering,
|
||||
Room,
|
||||
} from "../../src/matrix";
|
||||
import { logger } from "../../src/logger";
|
||||
@@ -1162,6 +1164,25 @@ describe("MatrixClient event timelines", function() {
|
||||
httpBackend.when("GET", "/sync").respond(200, INITIAL_SYNC_DATA);
|
||||
await flushHttp(room.fetchRoomThreads());
|
||||
});
|
||||
|
||||
it("should prevent displaying pending events", async function() {
|
||||
const room = new Room("room123", client, "john", {
|
||||
pendingEventOrdering: PendingEventOrdering.Detached,
|
||||
});
|
||||
const timelineSets = await room!.createThreadsTimelineSets();
|
||||
expect(timelineSets).not.toBeNull();
|
||||
|
||||
const event = utils.mkMessage({
|
||||
room: roomId, user: userId, msg: "a body", event: true,
|
||||
});
|
||||
event.status = EventStatus.SENDING;
|
||||
room.addPendingEvent(event, "txn");
|
||||
|
||||
const [allThreads, myThreads] = timelineSets!;
|
||||
expect(allThreads.getPendingEvents()).toHaveLength(0);
|
||||
expect(myThreads.getPendingEvents()).toHaveLength(0);
|
||||
expect(room.getPendingEvents()).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("without server compatibility", function() {
|
||||
|
||||
@@ -707,15 +707,11 @@ describe("MatrixClient syncing", () => {
|
||||
awaitSyncEvent(2),
|
||||
]).then(() => {
|
||||
const room = client!.getRoom(roomOne)!;
|
||||
const stateAtStart = room.getLiveTimeline().getState(
|
||||
EventTimeline.BACKWARDS,
|
||||
);
|
||||
const stateAtStart = room.getLiveTimeline().getState(EventTimeline.BACKWARDS)!;
|
||||
const startRoomNameEvent = stateAtStart.getStateEvents('m.room.name', '');
|
||||
expect(startRoomNameEvent.getContent().name).toEqual('Old room name');
|
||||
|
||||
const stateAtEnd = room.getLiveTimeline().getState(
|
||||
EventTimeline.FORWARDS,
|
||||
);
|
||||
const stateAtEnd = room.getLiveTimeline().getState(EventTimeline.FORWARDS)!;
|
||||
const endRoomNameEvent = stateAtEnd.getStateEvents('m.room.name', '');
|
||||
expect(endRoomNameEvent.getContent().name).toEqual('A new room name');
|
||||
});
|
||||
|
||||
@@ -1112,6 +1112,156 @@ describe("SlidingSync", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("custom room subscriptions", () => {
|
||||
beforeAll(setupClient);
|
||||
afterAll(teardownClient);
|
||||
|
||||
const roomA = "!a";
|
||||
const roomB = "!b";
|
||||
const roomC = "!c";
|
||||
const roomD = "!d";
|
||||
|
||||
const defaultSub = {
|
||||
timeline_limit: 1,
|
||||
required_state: [["m.room.create", ""]],
|
||||
};
|
||||
|
||||
const customSubName1 = "sub1";
|
||||
const customSub1 = {
|
||||
timeline_limit: 2,
|
||||
required_state: [["*", "*"]],
|
||||
};
|
||||
|
||||
const customSubName2 = "sub2";
|
||||
const customSub2 = {
|
||||
timeline_limit: 3,
|
||||
required_state: [["*", "*"]],
|
||||
};
|
||||
|
||||
it("should be possible to use custom subscriptions on startup", async () => {
|
||||
const slidingSync = new SlidingSync(proxyBaseUrl, [], defaultSub, client!, 1);
|
||||
// the intention is for clients to set this up at startup
|
||||
slidingSync.addCustomSubscription(customSubName1, customSub1);
|
||||
slidingSync.addCustomSubscription(customSubName2, customSub2);
|
||||
// then call these depending on the kind of room / context
|
||||
slidingSync.useCustomSubscription(roomA, customSubName1);
|
||||
slidingSync.useCustomSubscription(roomB, customSubName1);
|
||||
slidingSync.useCustomSubscription(roomC, customSubName2);
|
||||
slidingSync.modifyRoomSubscriptions(new Set<string>([roomA, roomB, roomC, roomD]));
|
||||
|
||||
httpBackend!.when("POST", syncUrl).check(function(req) {
|
||||
const body = req.data;
|
||||
logger.log("custom subs", body);
|
||||
expect(body.room_subscriptions).toBeTruthy();
|
||||
expect(body.room_subscriptions[roomA]).toEqual(customSub1);
|
||||
expect(body.room_subscriptions[roomB]).toEqual(customSub1);
|
||||
expect(body.room_subscriptions[roomC]).toEqual(customSub2);
|
||||
expect(body.room_subscriptions[roomD]).toEqual(defaultSub);
|
||||
}).respond(200, {
|
||||
pos: "b",
|
||||
lists: [],
|
||||
extensions: {},
|
||||
rooms: {},
|
||||
});
|
||||
slidingSync.start();
|
||||
await httpBackend!.flushAllExpected();
|
||||
slidingSync.stop();
|
||||
});
|
||||
|
||||
it("should be possible to use custom subscriptions mid-connection", async () => {
|
||||
const slidingSync = new SlidingSync(proxyBaseUrl, [], defaultSub, client!, 1);
|
||||
// the intention is for clients to set this up at startup
|
||||
slidingSync.addCustomSubscription(customSubName1, customSub1);
|
||||
slidingSync.addCustomSubscription(customSubName2, customSub2);
|
||||
// initially no subs
|
||||
httpBackend!.when("POST", syncUrl).check(function(req) {
|
||||
const body = req.data;
|
||||
logger.log("custom subs", body);
|
||||
expect(body.room_subscriptions).toBeFalsy();
|
||||
}).respond(200, {
|
||||
pos: "b",
|
||||
lists: [],
|
||||
extensions: {},
|
||||
rooms: {},
|
||||
});
|
||||
slidingSync.start();
|
||||
await httpBackend!.flushAllExpected();
|
||||
|
||||
// now the user clicks on a room which uses the default sub
|
||||
httpBackend!.when("POST", syncUrl).check(function(req) {
|
||||
const body = req.data;
|
||||
logger.log("custom subs", body);
|
||||
expect(body.room_subscriptions).toBeTruthy();
|
||||
expect(body.room_subscriptions[roomA]).toEqual(defaultSub);
|
||||
}).respond(200, {
|
||||
pos: "b",
|
||||
lists: [],
|
||||
extensions: {},
|
||||
rooms: {},
|
||||
});
|
||||
slidingSync.modifyRoomSubscriptions(new Set<string>([roomA]));
|
||||
await httpBackend!.flushAllExpected();
|
||||
|
||||
// now the user clicks on a room which uses a custom sub
|
||||
httpBackend!.when("POST", syncUrl).check(function(req) {
|
||||
const body = req.data;
|
||||
logger.log("custom subs", body);
|
||||
expect(body.room_subscriptions).toBeTruthy();
|
||||
expect(body.room_subscriptions[roomB]).toEqual(customSub1);
|
||||
expect(body.unsubscribe_rooms).toEqual([roomA]);
|
||||
}).respond(200, {
|
||||
pos: "b",
|
||||
lists: [],
|
||||
extensions: {},
|
||||
rooms: {},
|
||||
});
|
||||
slidingSync.useCustomSubscription(roomB, customSubName1);
|
||||
slidingSync.modifyRoomSubscriptions(new Set<string>([roomB]));
|
||||
await httpBackend!.flushAllExpected();
|
||||
|
||||
// now the user uses a different sub for the same room: we don't unsub but just resend
|
||||
httpBackend!.when("POST", syncUrl).check(function(req) {
|
||||
const body = req.data;
|
||||
logger.log("custom subs", body);
|
||||
expect(body.room_subscriptions).toBeTruthy();
|
||||
expect(body.room_subscriptions[roomB]).toEqual(customSub2);
|
||||
expect(body.unsubscribe_rooms).toBeFalsy();
|
||||
}).respond(200, {
|
||||
pos: "b",
|
||||
lists: [],
|
||||
extensions: {},
|
||||
rooms: {},
|
||||
});
|
||||
slidingSync.useCustomSubscription(roomB, customSubName2);
|
||||
slidingSync.modifyRoomSubscriptions(new Set<string>([roomB]));
|
||||
await httpBackend!.flushAllExpected();
|
||||
|
||||
slidingSync.stop();
|
||||
});
|
||||
|
||||
it("uses the default subscription for unknown subscription names", async () => {
|
||||
const slidingSync = new SlidingSync(proxyBaseUrl, [], defaultSub, client!, 1);
|
||||
slidingSync.addCustomSubscription(customSubName1, customSub1);
|
||||
slidingSync.useCustomSubscription(roomA, "unknown name");
|
||||
slidingSync.modifyRoomSubscriptions(new Set<string>([roomA]));
|
||||
|
||||
httpBackend!.when("POST", syncUrl).check(function(req) {
|
||||
const body = req.data;
|
||||
logger.log("custom subs", body);
|
||||
expect(body.room_subscriptions).toBeTruthy();
|
||||
expect(body.room_subscriptions[roomA]).toEqual(defaultSub);
|
||||
}).respond(200, {
|
||||
pos: "b",
|
||||
lists: [],
|
||||
extensions: {},
|
||||
rooms: {},
|
||||
});
|
||||
slidingSync.start();
|
||||
await httpBackend!.flushAllExpected();
|
||||
slidingSync.stop();
|
||||
});
|
||||
});
|
||||
|
||||
describe("extensions", () => {
|
||||
beforeAll(setupClient);
|
||||
afterAll(teardownClient);
|
||||
|
||||
@@ -17,3 +17,9 @@ limitations under the License.
|
||||
import DOMException from "domexception";
|
||||
|
||||
global.DOMException = DOMException;
|
||||
|
||||
jest.mock("../src/http-api/utils", () => ({
|
||||
...jest.requireActual("../src/http-api/utils"),
|
||||
// We mock timeoutSignal otherwise it causes tests to leave timers running
|
||||
timeoutSignal: () => new AbortController().signal,
|
||||
}));
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
// Jest now uses @sinonjs/fake-timers which exposes tickAsync() and a number of
|
||||
// other async methods which break the event loop, letting scheduled promise
|
||||
// callbacks run. Unfortunately, Jest doesn't expose these, so we have to do
|
||||
// it manually (this is what sinon does under the hood). We do both in a loop
|
||||
// until the thing we expect happens: hopefully this is the least flakey way
|
||||
// and avoids assuming anything about the app's behaviour.
|
||||
const realSetTimeout = setTimeout;
|
||||
export function flushPromises() {
|
||||
return new Promise(r => {
|
||||
realSetTimeout(r, 1);
|
||||
});
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import EventEmitter from "events";
|
||||
import '../olm-loader';
|
||||
|
||||
import { logger } from '../../src/logger';
|
||||
import { IContent, IEvent, IUnsigned, MatrixEvent, MatrixEventEvent } from "../../src/models/event";
|
||||
import { IContent, IEvent, IEventRelation, IUnsigned, MatrixEvent, MatrixEventEvent } from "../../src/models/event";
|
||||
import { ClientEvent, EventType, IPusher, MatrixClient, MsgType } from "../../src";
|
||||
import { SyncState } from "../../src/sync";
|
||||
import { eventMapperFor } from "../../src/event-mapper";
|
||||
@@ -78,6 +78,7 @@ interface IEventOpts {
|
||||
user?: string;
|
||||
unsigned?: IUnsigned;
|
||||
redacts?: string;
|
||||
ts?: number;
|
||||
}
|
||||
|
||||
let testEventIndex = 1; // counter for events, easier for comparison of randomly generated events
|
||||
@@ -109,6 +110,7 @@ export function mkEvent(opts: IEventOpts & { event?: boolean }, client?: MatrixC
|
||||
event_id: "$" + testEventIndex++ + "-" + Math.random() + "-" + Math.random(),
|
||||
txn_id: "~" + Math.random(),
|
||||
redacts: opts.redacts,
|
||||
origin_server_ts: opts.ts ?? 0,
|
||||
};
|
||||
if (opts.skey !== undefined) {
|
||||
event.state_key = opts.skey;
|
||||
@@ -237,11 +239,13 @@ export function mkMembershipCustom<T>(
|
||||
});
|
||||
}
|
||||
|
||||
interface IMessageOpts {
|
||||
export interface IMessageOpts {
|
||||
room?: string;
|
||||
user: string;
|
||||
msg?: string;
|
||||
event?: boolean;
|
||||
relatesTo?: IEventRelation;
|
||||
ts?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -269,6 +273,10 @@ export function mkMessage(
|
||||
},
|
||||
};
|
||||
|
||||
if (opts.relatesTo) {
|
||||
eventOpts.content["m.relates_to"] = opts.relatesTo;
|
||||
}
|
||||
|
||||
if (!eventOpts.content.body) {
|
||||
eventOpts.content.body = "Random->" + Math.random();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
/*
|
||||
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 { RelationType } from "../../src/@types/event";
|
||||
import { MatrixClient } from "../../src/client";
|
||||
import { MatrixEvent, MatrixEventEvent } from "../../src/models/event";
|
||||
import { Room } from "../../src/models/room";
|
||||
import { Thread } from "../../src/models/thread";
|
||||
import { mkMessage } from "./test-utils";
|
||||
|
||||
export const makeThreadEvent = ({ rootEventId, replyToEventId, ...props }: any & {
|
||||
rootEventId: string; replyToEventId: string; event?: boolean;
|
||||
}): MatrixEvent => mkMessage({
|
||||
...props,
|
||||
relatesTo: {
|
||||
event_id: rootEventId,
|
||||
rel_type: "m.thread",
|
||||
['m.in_reply_to']: {
|
||||
event_id: replyToEventId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
type MakeThreadEventsProps = {
|
||||
roomId: Room["roomId"];
|
||||
// root message user id
|
||||
authorId: string;
|
||||
// user ids of thread replies
|
||||
// cycled through until thread length is fulfilled
|
||||
participantUserIds: string[];
|
||||
// number of messages in the thread, root message included
|
||||
// optional, default 2
|
||||
length?: number;
|
||||
ts?: number;
|
||||
// provide to set current_user_participated accurately
|
||||
currentUserId?: string;
|
||||
};
|
||||
|
||||
export const makeThreadEvents = ({
|
||||
roomId, authorId, participantUserIds, length = 2, ts = 1, currentUserId,
|
||||
}: MakeThreadEventsProps): { rootEvent: MatrixEvent, events: MatrixEvent[] } => {
|
||||
const rootEvent = mkMessage({
|
||||
user: authorId,
|
||||
room: roomId,
|
||||
msg: 'root event message ' + Math.random(),
|
||||
ts,
|
||||
event: true,
|
||||
});
|
||||
|
||||
const rootEventId = rootEvent.getId();
|
||||
const events = [rootEvent];
|
||||
|
||||
for (let i = 1; i < length; i++) {
|
||||
const prevEvent = events[i - 1];
|
||||
const replyToEventId = prevEvent.getId();
|
||||
const user = participantUserIds[i % participantUserIds.length];
|
||||
events.push(makeThreadEvent({
|
||||
user,
|
||||
room: roomId,
|
||||
event: true,
|
||||
msg: `reply ${i} by ${user}`,
|
||||
rootEventId,
|
||||
replyToEventId,
|
||||
// replies are 1ms after each other
|
||||
ts: ts + i,
|
||||
}));
|
||||
}
|
||||
|
||||
rootEvent.setUnsigned({
|
||||
"m.relations": {
|
||||
[RelationType.Thread]: {
|
||||
latest_event: events[events.length - 1],
|
||||
count: length,
|
||||
current_user_participated: [...participantUserIds, authorId].includes(currentUserId ?? ""),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return { rootEvent, events };
|
||||
};
|
||||
|
||||
type MakeThreadProps = {
|
||||
room: Room;
|
||||
client: MatrixClient;
|
||||
authorId: string;
|
||||
participantUserIds: string[];
|
||||
length?: number;
|
||||
ts?: number;
|
||||
};
|
||||
|
||||
export const mkThread = ({
|
||||
room,
|
||||
client,
|
||||
authorId,
|
||||
participantUserIds,
|
||||
length = 2,
|
||||
ts = 1,
|
||||
}: MakeThreadProps): { thread: Thread, rootEvent: MatrixEvent, events: MatrixEvent[] } => {
|
||||
const { rootEvent, events } = makeThreadEvents({
|
||||
roomId: room.roomId,
|
||||
authorId,
|
||||
participantUserIds,
|
||||
length,
|
||||
ts,
|
||||
currentUserId: client.getUserId() ?? "",
|
||||
});
|
||||
expect(rootEvent).toBeTruthy();
|
||||
|
||||
for (const evt of events) {
|
||||
room?.reEmitter.reEmit(evt, [
|
||||
MatrixEventEvent.BeforeRedaction,
|
||||
]);
|
||||
}
|
||||
|
||||
const thread = room.createThread(rootEvent.getId() ?? "", rootEvent, events, true);
|
||||
// So that we do not have to mock the thread loading
|
||||
thread.initialEventsFetched = true;
|
||||
thread.addEvents(events, true);
|
||||
|
||||
return { thread, rootEvent, events };
|
||||
};
|
||||
+386
-29
@@ -14,6 +14,32 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
ClientEvent,
|
||||
ClientEventHandlerMap,
|
||||
EventType,
|
||||
GroupCall,
|
||||
GroupCallIntent,
|
||||
GroupCallType,
|
||||
IContent,
|
||||
ISendEventResponse,
|
||||
MatrixClient,
|
||||
MatrixEvent,
|
||||
Room,
|
||||
RoomState,
|
||||
RoomStateEvent,
|
||||
RoomStateEventHandlerMap,
|
||||
} from "../../src";
|
||||
import { TypedEventEmitter } from "../../src/models/typed-event-emitter";
|
||||
import { ReEmitter } from "../../src/ReEmitter";
|
||||
import { SyncState } from "../../src/sync";
|
||||
import { CallEvent, CallEventHandlerMap, MatrixCall } from "../../src/webrtc/call";
|
||||
import { CallEventHandlerEvent, CallEventHandlerEventHandlerMap } from "../../src/webrtc/callEventHandler";
|
||||
import { CallFeed } from "../../src/webrtc/callFeed";
|
||||
import { GroupCallEventHandlerMap } from "../../src/webrtc/groupCall";
|
||||
import { GroupCallEventHandlerEvent } from "../../src/webrtc/groupCallEventHandler";
|
||||
import { IScreensharingOpts, MediaHandler } from "../../src/webrtc/mediaHandler";
|
||||
|
||||
export const DUMMY_SDP = (
|
||||
"v=0\r\n" +
|
||||
"o=- 5022425983810148698 2 IN IP4 127.0.0.1\r\n" +
|
||||
@@ -54,8 +80,50 @@ export const DUMMY_SDP = (
|
||||
"a=ssrc:3619738545 cname:2RWtmqhXLdoF4sOi\r\n"
|
||||
);
|
||||
|
||||
export const USERMEDIA_STREAM_ID = "mock_stream_from_media_handler";
|
||||
export const SCREENSHARE_STREAM_ID = "mock_screen_stream_from_media_handler";
|
||||
|
||||
class MockMediaStreamAudioSourceNode {
|
||||
public connect() {}
|
||||
}
|
||||
|
||||
class MockAnalyser {
|
||||
public getFloatFrequencyData() { return 0.0; }
|
||||
}
|
||||
|
||||
export class MockAudioContext {
|
||||
constructor() {}
|
||||
public createAnalyser() { return new MockAnalyser(); }
|
||||
public createMediaStreamSource() { return new MockMediaStreamAudioSourceNode(); }
|
||||
public close() {}
|
||||
}
|
||||
|
||||
export class MockRTCPeerConnection {
|
||||
localDescription: RTCSessionDescription;
|
||||
private static instances: MockRTCPeerConnection[] = [];
|
||||
|
||||
private negotiationNeededListener?: () => void;
|
||||
public iceCandidateListener?: (e: RTCPeerConnectionIceEvent) => void;
|
||||
public onTrackListener?: (e: RTCTrackEvent) => void;
|
||||
public needsNegotiation = false;
|
||||
public readyToNegotiate: Promise<void>;
|
||||
private onReadyToNegotiate?: () => void;
|
||||
public localDescription: RTCSessionDescription;
|
||||
public signalingState: RTCSignalingState = "stable";
|
||||
public transceivers: MockRTCRtpTransceiver[] = [];
|
||||
|
||||
public static triggerAllNegotiations(): void {
|
||||
for (const inst of this.instances) {
|
||||
inst.doNegotiation();
|
||||
}
|
||||
}
|
||||
|
||||
public static hasAnyPendingNegotiations(): boolean {
|
||||
return this.instances.some(i => i.needsNegotiation);
|
||||
}
|
||||
|
||||
public static resetInstances() {
|
||||
this.instances = [];
|
||||
}
|
||||
|
||||
constructor() {
|
||||
this.localDescription = {
|
||||
@@ -63,34 +131,133 @@ export class MockRTCPeerConnection {
|
||||
type: 'offer',
|
||||
toJSON: function() { },
|
||||
};
|
||||
|
||||
this.readyToNegotiate = new Promise<void>(resolve => {
|
||||
this.onReadyToNegotiate = resolve;
|
||||
});
|
||||
|
||||
MockRTCPeerConnection.instances.push(this);
|
||||
}
|
||||
|
||||
addEventListener() { }
|
||||
createDataChannel(label: string, opts: RTCDataChannelInit) { return { label, ...opts }; }
|
||||
createOffer() {
|
||||
return Promise.resolve({});
|
||||
public addEventListener(type: string, listener: () => void) {
|
||||
if (type === 'negotiationneeded') {
|
||||
this.negotiationNeededListener = listener;
|
||||
} else if (type == 'icecandidate') {
|
||||
this.iceCandidateListener = listener;
|
||||
} else if (type == 'track') {
|
||||
this.onTrackListener = listener;
|
||||
}
|
||||
}
|
||||
setRemoteDescription() {
|
||||
public createDataChannel(label: string, opts: RTCDataChannelInit) { return { label, ...opts }; }
|
||||
public createOffer() {
|
||||
return Promise.resolve({
|
||||
type: 'offer',
|
||||
sdp: DUMMY_SDP,
|
||||
});
|
||||
}
|
||||
public createAnswer() {
|
||||
return Promise.resolve({
|
||||
type: 'answer',
|
||||
sdp: DUMMY_SDP,
|
||||
});
|
||||
}
|
||||
public setRemoteDescription() {
|
||||
return Promise.resolve();
|
||||
}
|
||||
setLocalDescription() {
|
||||
public setLocalDescription() {
|
||||
return Promise.resolve();
|
||||
}
|
||||
close() { }
|
||||
getStats() { return []; }
|
||||
addTrack(track: MockMediaStreamTrack) { return new MockRTCRtpSender(track); }
|
||||
public close() { }
|
||||
public getStats() { return []; }
|
||||
public addTransceiver(track: MockMediaStreamTrack): MockRTCRtpTransceiver {
|
||||
this.needsNegotiation = true;
|
||||
if (this.onReadyToNegotiate) this.onReadyToNegotiate();
|
||||
|
||||
const newSender = new MockRTCRtpSender(track);
|
||||
const newReceiver = new MockRTCRtpReceiver(track);
|
||||
|
||||
const newTransceiver = new MockRTCRtpTransceiver(this);
|
||||
newTransceiver.sender = newSender as unknown as RTCRtpSender;
|
||||
newTransceiver.receiver = newReceiver as unknown as RTCRtpReceiver;
|
||||
|
||||
this.transceivers.push(newTransceiver);
|
||||
|
||||
return newTransceiver;
|
||||
}
|
||||
public addTrack(track: MockMediaStreamTrack): MockRTCRtpSender {
|
||||
return this.addTransceiver(track).sender as unknown as MockRTCRtpSender;
|
||||
}
|
||||
|
||||
public removeTrack() {
|
||||
this.needsNegotiation = true;
|
||||
if (this.onReadyToNegotiate) this.onReadyToNegotiate();
|
||||
}
|
||||
|
||||
public getTransceivers(): MockRTCRtpTransceiver[] { return this.transceivers; }
|
||||
public getSenders(): MockRTCRtpSender[] {
|
||||
return this.transceivers.map(t => t.sender as unknown as MockRTCRtpSender);
|
||||
}
|
||||
|
||||
public doNegotiation() {
|
||||
if (this.needsNegotiation && this.negotiationNeededListener) {
|
||||
this.needsNegotiation = false;
|
||||
this.negotiationNeededListener();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class MockRTCRtpSender {
|
||||
constructor(public track: MockMediaStreamTrack) { }
|
||||
|
||||
replaceTrack(track: MockMediaStreamTrack) { this.track = track; }
|
||||
public replaceTrack(track: MockMediaStreamTrack) { this.track = track; }
|
||||
}
|
||||
|
||||
export class MockRTCRtpReceiver {
|
||||
constructor(public track: MockMediaStreamTrack) { }
|
||||
}
|
||||
|
||||
export class MockRTCRtpTransceiver {
|
||||
constructor(private peerConn: MockRTCPeerConnection) {}
|
||||
|
||||
public sender?: RTCRtpSender;
|
||||
public receiver?: RTCRtpReceiver;
|
||||
|
||||
public set direction(_: string) {
|
||||
this.peerConn.needsNegotiation = true;
|
||||
}
|
||||
|
||||
public setCodecPreferences = jest.fn<void, RTCRtpCodecCapability[]>();
|
||||
}
|
||||
|
||||
export class MockMediaStreamTrack {
|
||||
constructor(public readonly id: string, public readonly kind: "audio" | "video", public enabled = true) { }
|
||||
|
||||
stop() { }
|
||||
public stop = jest.fn<void, []>();
|
||||
|
||||
public listeners: [string, (...args: any[]) => any][] = [];
|
||||
public isStopped = false;
|
||||
public settings?: MediaTrackSettings;
|
||||
|
||||
public getSettings(): MediaTrackSettings { return this.settings!; }
|
||||
|
||||
// XXX: Using EventTarget in jest doesn't seem to work, so we write our own
|
||||
// implementation
|
||||
public dispatchEvent(eventType: string) {
|
||||
this.listeners.forEach(([t, c]) => {
|
||||
if (t !== eventType) return;
|
||||
c();
|
||||
});
|
||||
}
|
||||
public addEventListener(eventType: string, callback: (...args: any[]) => any) {
|
||||
this.listeners.push([eventType, callback]);
|
||||
}
|
||||
public removeEventListener(eventType: string, callback: (...args: any[]) => any) {
|
||||
this.listeners.filter(([t, c]) => {
|
||||
return t !== eventType || c !== callback;
|
||||
});
|
||||
}
|
||||
|
||||
public typed(): MediaStreamTrack { return this as unknown as MediaStreamTrack; }
|
||||
}
|
||||
|
||||
// XXX: Using EventTarget in jest doesn't seem to work, so we write our own
|
||||
@@ -101,46 +268,236 @@ export class MockMediaStream {
|
||||
private tracks: MockMediaStreamTrack[] = [],
|
||||
) {}
|
||||
|
||||
listeners: [string, (...args: any[]) => any][] = [];
|
||||
public listeners: [string, (...args: any[]) => any][] = [];
|
||||
public isStopped = false;
|
||||
|
||||
dispatchEvent(eventType: string) {
|
||||
public dispatchEvent(eventType: string) {
|
||||
this.listeners.forEach(([t, c]) => {
|
||||
if (t !== eventType) return;
|
||||
c();
|
||||
});
|
||||
}
|
||||
getTracks() { return this.tracks; }
|
||||
getAudioTracks() { return this.tracks.filter((track) => track.kind === "audio"); }
|
||||
getVideoTracks() { return this.tracks.filter((track) => track.kind === "video"); }
|
||||
addEventListener(eventType: string, callback: (...args: any[]) => any) {
|
||||
public getTracks() { return this.tracks; }
|
||||
public getAudioTracks() { return this.tracks.filter((track) => track.kind === "audio"); }
|
||||
public getVideoTracks() { return this.tracks.filter((track) => track.kind === "video"); }
|
||||
public addEventListener(eventType: string, callback: (...args: any[]) => any) {
|
||||
this.listeners.push([eventType, callback]);
|
||||
}
|
||||
removeEventListener(eventType: string, callback: (...args: any[]) => any) {
|
||||
public removeEventListener(eventType: string, callback: (...args: any[]) => any) {
|
||||
this.listeners.filter(([t, c]) => {
|
||||
return t !== eventType || c !== callback;
|
||||
});
|
||||
}
|
||||
addTrack(track: MockMediaStreamTrack) {
|
||||
public addTrack(track: MockMediaStreamTrack) {
|
||||
this.tracks.push(track);
|
||||
this.dispatchEvent("addtrack");
|
||||
}
|
||||
removeTrack(track: MockMediaStreamTrack) { this.tracks.splice(this.tracks.indexOf(track), 1); }
|
||||
public removeTrack(track: MockMediaStreamTrack) { this.tracks.splice(this.tracks.indexOf(track), 1); }
|
||||
|
||||
public clone(): MediaStream {
|
||||
return new MockMediaStream(this.id + ".clone", this.tracks).typed();
|
||||
}
|
||||
|
||||
public isCloneOf(stream: MediaStream) {
|
||||
return this.id === stream.id + ".clone";
|
||||
}
|
||||
|
||||
// syntactic sugar for typing
|
||||
public typed(): MediaStream {
|
||||
return this as unknown as MediaStream;
|
||||
}
|
||||
}
|
||||
|
||||
export class MockMediaDeviceInfo {
|
||||
constructor(
|
||||
public kind: "audio" | "video",
|
||||
public kind: "audioinput" | "videoinput" | "audiooutput",
|
||||
) { }
|
||||
|
||||
public typed(): MediaDeviceInfo { return this as unknown as MediaDeviceInfo; }
|
||||
}
|
||||
|
||||
export class MockMediaHandler {
|
||||
getUserMediaStream(audio: boolean, video: boolean) {
|
||||
const tracks: MockMediaStreamTrack[] = [];
|
||||
if (audio) tracks.push(new MockMediaStreamTrack("audio_track", "audio"));
|
||||
if (video) tracks.push(new MockMediaStreamTrack("video_track", "video"));
|
||||
public userMediaStreams: MockMediaStream[] = [];
|
||||
public screensharingStreams: MockMediaStream[] = [];
|
||||
|
||||
return new MockMediaStream("mock_stream_from_media_handler", tracks);
|
||||
public getUserMediaStream(audio: boolean, video: boolean) {
|
||||
const tracks: MockMediaStreamTrack[] = [];
|
||||
if (audio) tracks.push(new MockMediaStreamTrack("usermedia_audio_track", "audio"));
|
||||
if (video) tracks.push(new MockMediaStreamTrack("usermedia_video_track", "video"));
|
||||
|
||||
const stream = new MockMediaStream(USERMEDIA_STREAM_ID, tracks);
|
||||
this.userMediaStreams.push(stream);
|
||||
return stream;
|
||||
}
|
||||
stopUserMediaStream() { }
|
||||
hasAudioDevice() { return true; }
|
||||
public stopUserMediaStream(stream: MockMediaStream) {
|
||||
stream.isStopped = true;
|
||||
}
|
||||
public getScreensharingStream = jest.fn((opts?: IScreensharingOpts) => {
|
||||
const tracks = [new MockMediaStreamTrack("screenshare_video_track", "video")];
|
||||
if (opts?.audio) tracks.push(new MockMediaStreamTrack("screenshare_audio_track", "audio"));
|
||||
|
||||
const stream = new MockMediaStream(SCREENSHARE_STREAM_ID, tracks);
|
||||
this.screensharingStreams.push(stream);
|
||||
return stream;
|
||||
});
|
||||
public stopScreensharingStream(stream: MockMediaStream) {
|
||||
stream.isStopped = true;
|
||||
}
|
||||
public hasAudioDevice() { return true; }
|
||||
public hasVideoDevice() { return true; }
|
||||
public stopAllStreams() {}
|
||||
|
||||
public typed(): MediaHandler { return this as unknown as MediaHandler; }
|
||||
}
|
||||
|
||||
export class MockMediaDevices {
|
||||
public enumerateDevices = jest.fn<Promise<MediaDeviceInfo[]>, []>().mockResolvedValue([
|
||||
new MockMediaDeviceInfo("audioinput").typed(),
|
||||
new MockMediaDeviceInfo("videoinput").typed(),
|
||||
]);
|
||||
|
||||
public getUserMedia = jest.fn<Promise<MediaStream>, [MediaStreamConstraints]>().mockReturnValue(
|
||||
Promise.resolve(new MockMediaStream("local_stream").typed()),
|
||||
);
|
||||
|
||||
public getDisplayMedia = jest.fn<Promise<MediaStream>, [DisplayMediaStreamConstraints]>().mockReturnValue(
|
||||
Promise.resolve(new MockMediaStream("local_display_stream").typed()),
|
||||
);
|
||||
|
||||
public typed(): MediaDevices { return this as unknown as MediaDevices; }
|
||||
}
|
||||
|
||||
type EmittedEvents = CallEventHandlerEvent | CallEvent | ClientEvent | RoomStateEvent | GroupCallEventHandlerEvent;
|
||||
type EmittedEventMap = CallEventHandlerEventHandlerMap &
|
||||
CallEventHandlerMap &
|
||||
ClientEventHandlerMap &
|
||||
RoomStateEventHandlerMap &
|
||||
GroupCallEventHandlerMap;
|
||||
|
||||
export class MockCallMatrixClient extends TypedEventEmitter<EmittedEvents, EmittedEventMap> {
|
||||
public mediaHandler = new MockMediaHandler();
|
||||
|
||||
constructor(public userId: string, public deviceId: string, public sessionId: string) {
|
||||
super();
|
||||
}
|
||||
|
||||
public groupCallEventHandler = {
|
||||
groupCalls: new Map<string, GroupCall>(),
|
||||
};
|
||||
|
||||
public callEventHandler = {
|
||||
calls: new Map<string, MatrixCall>(),
|
||||
};
|
||||
|
||||
public sendStateEvent = jest.fn<Promise<ISendEventResponse>, [
|
||||
roomId: string, eventType: EventType, content: any, statekey: string,
|
||||
]>();
|
||||
public sendToDevice = jest.fn<Promise<{}>, [
|
||||
eventType: string,
|
||||
contentMap: { [userId: string]: { [deviceId: string]: Record<string, any> } },
|
||||
txnId?: string,
|
||||
]>();
|
||||
|
||||
public getMediaHandler(): MediaHandler { return this.mediaHandler.typed(); }
|
||||
|
||||
public getUserId(): string { return this.userId; }
|
||||
|
||||
public getDeviceId(): string { return this.deviceId; }
|
||||
public getSessionId(): string { return this.sessionId; }
|
||||
|
||||
public getTurnServers = () => [];
|
||||
public isFallbackICEServerAllowed = () => false;
|
||||
public reEmitter = new ReEmitter(new TypedEventEmitter());
|
||||
public getUseE2eForGroupCall = () => false;
|
||||
public checkTurnServers = () => null;
|
||||
|
||||
public getSyncState = jest.fn<SyncState | null, []>().mockReturnValue(SyncState.Syncing);
|
||||
|
||||
public getRooms = jest.fn<Room[], []>().mockReturnValue([]);
|
||||
public getRoom = jest.fn();
|
||||
|
||||
public typed(): MatrixClient { return this as unknown as MatrixClient; }
|
||||
|
||||
public emitRoomState(event: MatrixEvent, state: RoomState): void {
|
||||
this.emit(
|
||||
RoomStateEvent.Events,
|
||||
event,
|
||||
state,
|
||||
null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class MockCallFeed {
|
||||
constructor(
|
||||
public userId: string,
|
||||
public stream: MockMediaStream,
|
||||
) {}
|
||||
|
||||
public measureVolumeActivity(val: boolean) {}
|
||||
public dispose() {}
|
||||
|
||||
public typed(): CallFeed {
|
||||
return this as unknown as CallFeed;
|
||||
}
|
||||
}
|
||||
|
||||
export function installWebRTCMocks() {
|
||||
global.navigator = {
|
||||
mediaDevices: new MockMediaDevices().typed(),
|
||||
} as unknown as Navigator;
|
||||
|
||||
global.window = {
|
||||
// @ts-ignore Mock
|
||||
RTCPeerConnection: MockRTCPeerConnection,
|
||||
// @ts-ignore Mock
|
||||
RTCSessionDescription: {},
|
||||
// @ts-ignore Mock
|
||||
RTCIceCandidate: {},
|
||||
getUserMedia: () => new MockMediaStream("local_stream"),
|
||||
};
|
||||
// @ts-ignore Mock
|
||||
global.document = {};
|
||||
|
||||
// @ts-ignore Mock
|
||||
global.AudioContext = MockAudioContext;
|
||||
|
||||
// @ts-ignore Mock
|
||||
global.RTCRtpReceiver = {
|
||||
getCapabilities: jest.fn<RTCRtpCapabilities, [string]>().mockReturnValue({
|
||||
codecs: [],
|
||||
headerExtensions: [],
|
||||
}),
|
||||
};
|
||||
|
||||
// @ts-ignore Mock
|
||||
global.RTCRtpSender = {
|
||||
getCapabilities: jest.fn<RTCRtpCapabilities, [string]>().mockReturnValue({
|
||||
codecs: [],
|
||||
headerExtensions: [],
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function makeMockGroupCallStateEvent(roomId: string, groupCallId: string, content: IContent = {
|
||||
"m.type": GroupCallType.Video,
|
||||
"m.intent": GroupCallIntent.Prompt,
|
||||
}): MatrixEvent {
|
||||
return {
|
||||
getType: jest.fn().mockReturnValue(EventType.GroupCallPrefix),
|
||||
getRoomId: jest.fn().mockReturnValue(roomId),
|
||||
getTs: jest.fn().mockReturnValue(0),
|
||||
getContent: jest.fn().mockReturnValue(content),
|
||||
getStateKey: jest.fn().mockReturnValue(groupCallId),
|
||||
} as unknown as MatrixEvent;
|
||||
}
|
||||
|
||||
export function makeMockGroupCallMemberStateEvent(roomId: string, groupCallId: string): MatrixEvent {
|
||||
return {
|
||||
getType: jest.fn().mockReturnValue(EventType.GroupCallMemberPrefix),
|
||||
getRoomId: jest.fn().mockReturnValue(roomId),
|
||||
getTs: jest.fn().mockReturnValue(0),
|
||||
getContent: jest.fn().mockReturnValue({}),
|
||||
getStateKey: jest.fn().mockReturnValue(groupCallId),
|
||||
} as unknown as MatrixEvent;
|
||||
}
|
||||
|
||||
@@ -1122,4 +1122,22 @@ describe("Crypto", function() {
|
||||
expect(free).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("start", () => {
|
||||
let client: TestClient;
|
||||
|
||||
beforeEach(async () => {
|
||||
client = new TestClient("@alice:example.org", "aliceweb");
|
||||
await client.client.initCrypto();
|
||||
});
|
||||
|
||||
afterEach(async function() {
|
||||
await client!.stop();
|
||||
});
|
||||
|
||||
// start() is a no-op nowadays, so there's not much to test here.
|
||||
it("should complete successfully", async () => {
|
||||
await client!.client.crypto!.start();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
/*
|
||||
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 '../../olm-loader';
|
||||
import { TestClient } from '../../TestClient';
|
||||
import { logger } from '../../../src/logger';
|
||||
import { DEHYDRATION_ALGORITHM } from '../../../src/crypto/dehydration';
|
||||
|
||||
const Olm = global.Olm;
|
||||
|
||||
describe("Dehydration", () => {
|
||||
if (!global.Olm) {
|
||||
logger.warn('Not running dehydration unit tests: libolm not present');
|
||||
return;
|
||||
}
|
||||
|
||||
beforeAll(function() {
|
||||
return global.Olm.init();
|
||||
});
|
||||
|
||||
it("should rehydrate a dehydrated device", async () => {
|
||||
const key = new Uint8Array([1, 2, 3]);
|
||||
const alice = new TestClient(
|
||||
"@alice:example.com", "Osborne2", undefined, undefined,
|
||||
{
|
||||
cryptoCallbacks: {
|
||||
getDehydrationKey: async t => key,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const dehydratedDevice = new Olm.Account();
|
||||
dehydratedDevice.create();
|
||||
|
||||
alice.httpBackend.when("GET", "/dehydrated_device").respond(200, {
|
||||
device_id: "ABCDEFG",
|
||||
device_data: {
|
||||
algorithm: DEHYDRATION_ALGORITHM,
|
||||
account: dehydratedDevice.pickle(new Uint8Array(key)),
|
||||
},
|
||||
});
|
||||
alice.httpBackend.when("POST", "/dehydrated_device/claim").respond(200, {
|
||||
success: true,
|
||||
});
|
||||
|
||||
expect((await Promise.all([
|
||||
alice.client.rehydrateDevice(),
|
||||
alice.httpBackend.flushAllExpected(),
|
||||
]))[0])
|
||||
.toEqual("ABCDEFG");
|
||||
|
||||
expect(alice.client.getDeviceId()).toEqual("ABCDEFG");
|
||||
});
|
||||
|
||||
it("should dehydrate a device", async () => {
|
||||
const key = new Uint8Array([1, 2, 3]);
|
||||
const alice = new TestClient(
|
||||
"@alice:example.com", "Osborne2", undefined, undefined,
|
||||
{
|
||||
cryptoCallbacks: {
|
||||
getDehydrationKey: async t => key,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await alice.client.initCrypto();
|
||||
|
||||
alice.httpBackend.when("GET", "/room_keys/version").respond(404, {
|
||||
errcode: "M_NOT_FOUND",
|
||||
});
|
||||
|
||||
let pickledAccount = "";
|
||||
|
||||
alice.httpBackend.when("PUT", "/dehydrated_device")
|
||||
.check((req) => {
|
||||
expect(req.data.device_data).toMatchObject({
|
||||
algorithm: DEHYDRATION_ALGORITHM,
|
||||
account: expect.any(String),
|
||||
});
|
||||
pickledAccount = req.data.device_data.account;
|
||||
})
|
||||
.respond(200, {
|
||||
device_id: "ABCDEFG",
|
||||
});
|
||||
alice.httpBackend.when("POST", "/keys/upload/ABCDEFG")
|
||||
.check((req) => {
|
||||
expect(req.data).toMatchObject({
|
||||
"device_keys": expect.objectContaining({
|
||||
algorithms: expect.any(Array),
|
||||
device_id: "ABCDEFG",
|
||||
user_id: "@alice:example.com",
|
||||
keys: expect.objectContaining({
|
||||
"ed25519:ABCDEFG": expect.any(String),
|
||||
"curve25519:ABCDEFG": expect.any(String),
|
||||
}),
|
||||
signatures: expect.objectContaining({
|
||||
"@alice:example.com": expect.objectContaining({
|
||||
"ed25519:ABCDEFG": expect.any(String),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
"one_time_keys": expect.any(Object),
|
||||
"org.matrix.msc2732.fallback_keys": expect.any(Object),
|
||||
});
|
||||
})
|
||||
.respond(200, {});
|
||||
|
||||
try {
|
||||
const deviceId =
|
||||
(await Promise.all([
|
||||
alice.client.createDehydratedDevice(new Uint8Array(key), {}),
|
||||
alice.httpBackend.flushAllExpected(),
|
||||
]))[0];
|
||||
|
||||
expect(deviceId).toEqual("ABCDEFG");
|
||||
expect(deviceId).not.toEqual("");
|
||||
|
||||
// try to rehydrate the dehydrated device
|
||||
const rehydrated = new Olm.Account();
|
||||
try {
|
||||
rehydrated.unpickle(new Uint8Array(key), pickledAccount);
|
||||
} finally {
|
||||
rehydrated.free();
|
||||
}
|
||||
} finally {
|
||||
alice.client?.crypto?.dehydrationManager?.stop();
|
||||
alice.client?.crypto?.deviceList.stop();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -18,7 +18,7 @@ import "../../../olm-loader";
|
||||
import { CryptoEvent, verificationMethods } from "../../../../src/crypto";
|
||||
import { logger } from "../../../../src/logger";
|
||||
import { SAS } from "../../../../src/crypto/verification/SAS";
|
||||
import { makeTestClients, setupWebcrypto, teardownWebcrypto } from './util';
|
||||
import { makeTestClients } from './util';
|
||||
|
||||
const Olm = global.Olm;
|
||||
|
||||
@@ -31,14 +31,9 @@ describe("verification request integration tests with crypto layer", function()
|
||||
}
|
||||
|
||||
beforeAll(function() {
|
||||
setupWebcrypto();
|
||||
return Olm.init();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
teardownWebcrypto();
|
||||
});
|
||||
|
||||
it("should request and accept a verification", async function() {
|
||||
const [[alice, bob], clearTestClientTimeouts] = await makeTestClients(
|
||||
[
|
||||
|
||||
@@ -15,7 +15,7 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
import "../../../olm-loader";
|
||||
import { makeTestClients, setupWebcrypto, teardownWebcrypto } from './util';
|
||||
import { makeTestClients } from './util';
|
||||
import { MatrixEvent } from "../../../../src/models/event";
|
||||
import { ISasEvent, SAS, SasEvent } from "../../../../src/crypto/verification/SAS";
|
||||
import { DeviceInfo } from "../../../../src/crypto/deviceinfo";
|
||||
@@ -41,14 +41,9 @@ describe("SAS verification", function() {
|
||||
}
|
||||
|
||||
beforeAll(function() {
|
||||
setupWebcrypto();
|
||||
return Olm.init();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
teardownWebcrypto();
|
||||
});
|
||||
|
||||
it("should error on an unexpected event", async function() {
|
||||
//channel, baseApis, userId, deviceId, startEvent, request
|
||||
const request = {
|
||||
|
||||
@@ -14,13 +14,14 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { CrossSigningInfo } from '../../../../src/crypto/CrossSigning';
|
||||
import '../../../olm-loader';
|
||||
import { MatrixClient, MatrixEvent } from '../../../../src/matrix';
|
||||
import { encodeBase64 } from "../../../../src/crypto/olmlib";
|
||||
import { setupWebcrypto, teardownWebcrypto } from './util';
|
||||
import { VerificationBase } from '../../../../src/crypto/verification/Base';
|
||||
import { MatrixClient, MatrixEvent } from '../../../../src';
|
||||
import "../../../../src/crypto"; // import this to cycle-break
|
||||
import { CrossSigningInfo } from '../../../../src/crypto/CrossSigning';
|
||||
import { VerificationRequest } from '../../../../src/crypto/verification/request/VerificationRequest';
|
||||
import { IVerificationChannel } from '../../../../src/crypto/verification/request/Channel';
|
||||
import { VerificationBase } from '../../../../src/crypto/verification/Base';
|
||||
|
||||
jest.useFakeTimers();
|
||||
|
||||
@@ -35,14 +36,9 @@ const testKeyPub = "nqOvzeuGWT/sRx3h7+MHoInYj3Uk2LD/unI9kDYcHwk";
|
||||
|
||||
describe("self-verifications", () => {
|
||||
beforeAll(function() {
|
||||
setupWebcrypto();
|
||||
return global.Olm.init();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
teardownWebcrypto();
|
||||
});
|
||||
|
||||
it("triggers a request for key sharing upon completion", async () => {
|
||||
const userId = "@test:localhost";
|
||||
|
||||
|
||||
@@ -15,8 +15,6 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import nodeCrypto from "crypto";
|
||||
|
||||
import { TestClient } from '../../../TestClient';
|
||||
import { MatrixEvent } from "../../../../src/models/event";
|
||||
import { IRoomTimelineData } from "../../../../src/models/event-timeline-set";
|
||||
@@ -118,16 +116,3 @@ export async function makeTestClients(userInfos, options): Promise<[TestClient[]
|
||||
|
||||
return [clients, destroy];
|
||||
}
|
||||
|
||||
export function setupWebcrypto() {
|
||||
global.crypto = {
|
||||
getRandomValues: (buf) => {
|
||||
return nodeCrypto.randomFillSync(buf as any);
|
||||
},
|
||||
} as unknown as Crypto;
|
||||
}
|
||||
|
||||
export function teardownWebcrypto() {
|
||||
// @ts-ignore undefined != Crypto
|
||||
global.crypto = undefined;
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ import { ToDeviceChannel } from
|
||||
"../../../../src/crypto/verification/request/ToDeviceChannel";
|
||||
import { MatrixEvent } from "../../../../src/models/event";
|
||||
import { MatrixClient } from "../../../../src/client";
|
||||
import { setupWebcrypto, teardownWebcrypto } from "./util";
|
||||
import { IVerificationChannel } from "../../../../src/crypto/verification/request/Channel";
|
||||
import { VerificationBase } from "../../../../src/crypto/verification/Base";
|
||||
|
||||
@@ -147,14 +146,6 @@ async function distributeEvent(
|
||||
jest.useFakeTimers();
|
||||
|
||||
describe("verification request unit tests", function() {
|
||||
beforeAll(function() {
|
||||
setupWebcrypto();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
teardownWebcrypto();
|
||||
});
|
||||
|
||||
it("transition from UNSENT to DONE through happy path", async function() {
|
||||
const alice = makeMockClient("@alice:matrix.tld", "device1");
|
||||
const bob = makeMockClient("@bob:matrix.tld", "device1");
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
/**
|
||||
* @jest-environment jsdom
|
||||
*/
|
||||
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
// We have to use EventEmitter here to mock part of the matrix-widget-api
|
||||
// project, which doesn't know about our TypeEventEmitter implementation at all
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { EventEmitter } from "events";
|
||||
import { MockedObject } from "jest-mock";
|
||||
import {
|
||||
WidgetApi,
|
||||
WidgetApiToWidgetAction,
|
||||
MatrixCapabilities,
|
||||
ITurnServer,
|
||||
IRoomEvent,
|
||||
} from "matrix-widget-api";
|
||||
|
||||
import { createRoomWidgetClient, MsgType } from "../../src/matrix";
|
||||
import { MatrixClient, ClientEvent, ITurnServer as IClientTurnServer } from "../../src/client";
|
||||
import { SyncState } from "../../src/sync";
|
||||
import { ICapabilities } from "../../src/embedded";
|
||||
import { MatrixEvent } from "../../src/models/event";
|
||||
import { ToDeviceBatch } from "../../src/models/ToDeviceMessage";
|
||||
import { DeviceInfo } from "../../src/crypto/deviceinfo";
|
||||
|
||||
class MockWidgetApi extends EventEmitter {
|
||||
public start = jest.fn();
|
||||
public requestCapability = jest.fn();
|
||||
public requestCapabilities = jest.fn();
|
||||
public requestCapabilityForRoomTimeline = jest.fn();
|
||||
public requestCapabilityToSendEvent = jest.fn();
|
||||
public requestCapabilityToReceiveEvent = jest.fn();
|
||||
public requestCapabilityToSendMessage = jest.fn();
|
||||
public requestCapabilityToReceiveMessage = jest.fn();
|
||||
public requestCapabilityToSendState = jest.fn();
|
||||
public requestCapabilityToReceiveState = jest.fn();
|
||||
public requestCapabilityToSendToDevice = jest.fn();
|
||||
public requestCapabilityToReceiveToDevice = jest.fn();
|
||||
public sendRoomEvent = jest.fn(() => ({ event_id: `$${Math.random()}` }));
|
||||
public sendStateEvent = jest.fn();
|
||||
public sendToDevice = jest.fn();
|
||||
public readStateEvents = jest.fn(() => []);
|
||||
public getTurnServers = jest.fn(() => []);
|
||||
|
||||
public transport = { reply: jest.fn() };
|
||||
}
|
||||
|
||||
describe("RoomWidgetClient", () => {
|
||||
let widgetApi: MockedObject<WidgetApi>;
|
||||
let client: MatrixClient;
|
||||
|
||||
beforeEach(() => {
|
||||
widgetApi = new MockWidgetApi() as unknown as MockedObject<WidgetApi>;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
client.stopClient();
|
||||
});
|
||||
|
||||
const makeClient = async (capabilities: ICapabilities): Promise<void> => {
|
||||
const baseUrl = "https://example.org";
|
||||
client = createRoomWidgetClient(widgetApi, capabilities, "!1:example.org", { baseUrl });
|
||||
expect(widgetApi.start).toHaveBeenCalled(); // needs to have been called early in order to not miss messages
|
||||
widgetApi.emit("ready");
|
||||
await client.startClient();
|
||||
};
|
||||
|
||||
describe("events", () => {
|
||||
it("sends", async () => {
|
||||
await makeClient({ sendEvent: ["org.matrix.rageshake_request"] });
|
||||
expect(widgetApi.requestCapabilityForRoomTimeline).toHaveBeenCalledWith("!1:example.org");
|
||||
expect(widgetApi.requestCapabilityToSendEvent).toHaveBeenCalledWith("org.matrix.rageshake_request");
|
||||
await client.sendEvent("!1:example.org", "org.matrix.rageshake_request", { request_id: 123 });
|
||||
expect(widgetApi.sendRoomEvent).toHaveBeenCalledWith(
|
||||
"org.matrix.rageshake_request", { request_id: 123 }, "!1:example.org",
|
||||
);
|
||||
});
|
||||
|
||||
it("receives", async () => {
|
||||
const event = new MatrixEvent({
|
||||
type: "org.matrix.rageshake_request",
|
||||
event_id: "$pduhfiidph",
|
||||
room_id: "!1:example.org",
|
||||
sender: "@alice:example.org",
|
||||
content: { request_id: 123 },
|
||||
}).getEffectiveEvent();
|
||||
|
||||
await makeClient({ receiveEvent: ["org.matrix.rageshake_request"] });
|
||||
expect(widgetApi.requestCapabilityForRoomTimeline).toHaveBeenCalledWith("!1:example.org");
|
||||
expect(widgetApi.requestCapabilityToReceiveEvent).toHaveBeenCalledWith("org.matrix.rageshake_request");
|
||||
|
||||
const emittedEvent = new Promise<MatrixEvent>(resolve => client.once(ClientEvent.Event, resolve));
|
||||
const emittedSync = new Promise<SyncState>(resolve => client.once(ClientEvent.Sync, resolve));
|
||||
widgetApi.emit(
|
||||
`action:${WidgetApiToWidgetAction.SendEvent}`,
|
||||
new CustomEvent(`action:${WidgetApiToWidgetAction.SendEvent}`, { detail: { data: event } }),
|
||||
);
|
||||
|
||||
// The client should've emitted about the received event
|
||||
expect((await emittedEvent).getEffectiveEvent()).toEqual(event);
|
||||
expect(await emittedSync).toEqual(SyncState.Syncing);
|
||||
// It should've also inserted the event into the room object
|
||||
const room = client.getRoom("!1:example.org");
|
||||
expect(room).not.toBeNull();
|
||||
expect(room!.getLiveTimeline().getEvents().map(e => e.getEffectiveEvent())).toEqual([event]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("messages", () => {
|
||||
it("requests permissions for specific message types", async () => {
|
||||
await makeClient({ sendMessage: [MsgType.Text], receiveMessage: [MsgType.Text] });
|
||||
expect(widgetApi.requestCapabilityForRoomTimeline).toHaveBeenCalledWith("!1:example.org");
|
||||
expect(widgetApi.requestCapabilityToSendMessage).toHaveBeenCalledWith(MsgType.Text);
|
||||
expect(widgetApi.requestCapabilityToReceiveMessage).toHaveBeenCalledWith(MsgType.Text);
|
||||
});
|
||||
|
||||
it("requests permissions for all message types", async () => {
|
||||
await makeClient({ sendMessage: true, receiveMessage: true });
|
||||
expect(widgetApi.requestCapabilityForRoomTimeline).toHaveBeenCalledWith("!1:example.org");
|
||||
expect(widgetApi.requestCapabilityToSendMessage).toHaveBeenCalledWith();
|
||||
expect(widgetApi.requestCapabilityToReceiveMessage).toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
// No point in testing sending and receiving since it's done exactly the
|
||||
// same way as non-message events
|
||||
});
|
||||
|
||||
describe("state events", () => {
|
||||
const event = new MatrixEvent({
|
||||
type: "org.example.foo",
|
||||
event_id: "$sfkjfsksdkfsd",
|
||||
room_id: "!1:example.org",
|
||||
sender: "@alice:example.org",
|
||||
state_key: "bar",
|
||||
content: { hello: "world" },
|
||||
}).getEffectiveEvent();
|
||||
|
||||
it("sends", async () => {
|
||||
await makeClient({ sendState: [{ eventType: "org.example.foo", stateKey: "bar" }] });
|
||||
expect(widgetApi.requestCapabilityForRoomTimeline).toHaveBeenCalledWith("!1:example.org");
|
||||
expect(widgetApi.requestCapabilityToSendState).toHaveBeenCalledWith("org.example.foo", "bar");
|
||||
await client.sendStateEvent("!1:example.org", "org.example.foo", { hello: "world" }, "bar");
|
||||
expect(widgetApi.sendStateEvent).toHaveBeenCalledWith(
|
||||
"org.example.foo", "bar", { hello: "world" }, "!1:example.org",
|
||||
);
|
||||
});
|
||||
|
||||
it("receives", async () => {
|
||||
await makeClient({ receiveState: [{ eventType: "org.example.foo", stateKey: "bar" }] });
|
||||
expect(widgetApi.requestCapabilityForRoomTimeline).toHaveBeenCalledWith("!1:example.org");
|
||||
expect(widgetApi.requestCapabilityToReceiveState).toHaveBeenCalledWith("org.example.foo", "bar");
|
||||
|
||||
const emittedEvent = new Promise<MatrixEvent>(resolve => client.once(ClientEvent.Event, resolve));
|
||||
const emittedSync = new Promise<SyncState>(resolve => client.once(ClientEvent.Sync, resolve));
|
||||
widgetApi.emit(
|
||||
`action:${WidgetApiToWidgetAction.SendEvent}`,
|
||||
new CustomEvent(`action:${WidgetApiToWidgetAction.SendEvent}`, { detail: { data: event } }),
|
||||
);
|
||||
|
||||
// The client should've emitted about the received event
|
||||
expect((await emittedEvent).getEffectiveEvent()).toEqual(event);
|
||||
expect(await emittedSync).toEqual(SyncState.Syncing);
|
||||
// It should've also inserted the event into the room object
|
||||
const room = client.getRoom("!1:example.org");
|
||||
expect(room).not.toBeNull();
|
||||
expect(room!.currentState.getStateEvents("org.example.foo", "bar").getEffectiveEvent()).toEqual(event);
|
||||
});
|
||||
|
||||
it("backfills", async () => {
|
||||
widgetApi.readStateEvents.mockImplementation(async (eventType, limit, stateKey) =>
|
||||
eventType === "org.example.foo" && (limit ?? Infinity) > 0 && stateKey === "bar"
|
||||
? [event as IRoomEvent]
|
||||
: [],
|
||||
);
|
||||
|
||||
await makeClient({ receiveState: [{ eventType: "org.example.foo", stateKey: "bar" }] });
|
||||
expect(widgetApi.requestCapabilityForRoomTimeline).toHaveBeenCalledWith("!1:example.org");
|
||||
expect(widgetApi.requestCapabilityToReceiveState).toHaveBeenCalledWith("org.example.foo", "bar");
|
||||
|
||||
const room = client.getRoom("!1:example.org");
|
||||
expect(room).not.toBeNull();
|
||||
expect(room!.currentState.getStateEvents("org.example.foo", "bar").getEffectiveEvent()).toEqual(event);
|
||||
});
|
||||
});
|
||||
|
||||
describe("to-device messages", () => {
|
||||
const unencryptedContentMap = {
|
||||
"@alice:example.org": { "*": { hello: "alice!" } },
|
||||
"@bob:example.org": { bobDesktop: { hello: "bob!" } },
|
||||
};
|
||||
|
||||
it("sends unencrypted (sendToDevice)", async () => {
|
||||
await makeClient({ sendToDevice: ["org.example.foo"] });
|
||||
expect(widgetApi.requestCapabilityToSendToDevice).toHaveBeenCalledWith("org.example.foo");
|
||||
|
||||
await client.sendToDevice("org.example.foo", unencryptedContentMap);
|
||||
expect(widgetApi.sendToDevice).toHaveBeenCalledWith("org.example.foo", false, unencryptedContentMap);
|
||||
});
|
||||
|
||||
it("sends unencrypted (queueToDevice)", async () => {
|
||||
await makeClient({ sendToDevice: ["org.example.foo"] });
|
||||
expect(widgetApi.requestCapabilityToSendToDevice).toHaveBeenCalledWith("org.example.foo");
|
||||
|
||||
const batch: ToDeviceBatch = {
|
||||
eventType: "org.example.foo",
|
||||
batch: [
|
||||
{ userId: "@alice:example.org", deviceId: "*", payload: { hello: "alice!" } },
|
||||
{ userId: "@bob:example.org", deviceId: "bobDesktop", payload: { hello: "bob!" } },
|
||||
],
|
||||
};
|
||||
await client.queueToDevice(batch);
|
||||
expect(widgetApi.sendToDevice).toHaveBeenCalledWith("org.example.foo", false, unencryptedContentMap);
|
||||
});
|
||||
|
||||
it("sends encrypted (encryptAndSendToDevices)", async () => {
|
||||
await makeClient({ sendToDevice: ["org.example.foo"] });
|
||||
expect(widgetApi.requestCapabilityToSendToDevice).toHaveBeenCalledWith("org.example.foo");
|
||||
|
||||
const payload = { type: "org.example.foo", hello: "world" };
|
||||
await client.encryptAndSendToDevices(
|
||||
[
|
||||
{ userId: "@alice:example.org", deviceInfo: new DeviceInfo("aliceWeb") },
|
||||
{ userId: "@bob:example.org", deviceInfo: new DeviceInfo("bobDesktop") },
|
||||
],
|
||||
payload,
|
||||
);
|
||||
expect(widgetApi.sendToDevice).toHaveBeenCalledWith("org.example.foo", true, {
|
||||
"@alice:example.org": { aliceWeb: payload },
|
||||
"@bob:example.org": { bobDesktop: payload },
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ encrypted: false, title: "unencrypted" },
|
||||
{ encrypted: true, title: "encrypted" },
|
||||
])("receives $title", async ({ encrypted }) => {
|
||||
await makeClient({ receiveToDevice: ["org.example.foo"] });
|
||||
expect(widgetApi.requestCapabilityToReceiveToDevice).toHaveBeenCalledWith("org.example.foo");
|
||||
|
||||
const event = {
|
||||
type: "org.example.foo",
|
||||
sender: "@alice:example.org",
|
||||
encrypted,
|
||||
content: { hello: "world" },
|
||||
};
|
||||
|
||||
const emittedEvent = new Promise<MatrixEvent>(resolve => client.once(ClientEvent.ToDeviceEvent, resolve));
|
||||
const emittedSync = new Promise<SyncState>(resolve => client.once(ClientEvent.Sync, resolve));
|
||||
widgetApi.emit(
|
||||
`action:${WidgetApiToWidgetAction.SendToDevice}`,
|
||||
new CustomEvent(`action:${WidgetApiToWidgetAction.SendToDevice}`, { detail: { data: event } }),
|
||||
);
|
||||
|
||||
expect((await emittedEvent).getEffectiveEvent()).toEqual({
|
||||
type: event.type,
|
||||
sender: event.sender,
|
||||
content: event.content,
|
||||
});
|
||||
expect((await emittedEvent).isEncrypted()).toEqual(encrypted);
|
||||
expect(await emittedSync).toEqual(SyncState.Syncing);
|
||||
});
|
||||
});
|
||||
|
||||
it("gets TURN servers", async () => {
|
||||
const server1: ITurnServer = {
|
||||
uris: [
|
||||
"turn:turn.example.com:3478?transport=udp",
|
||||
"turn:10.20.30.40:3478?transport=tcp",
|
||||
"turns:10.20.30.40:443?transport=tcp",
|
||||
],
|
||||
username: "1443779631:@user:example.com",
|
||||
password: "JlKfBy1QwLrO20385QyAtEyIv0=",
|
||||
};
|
||||
const server2: ITurnServer = {
|
||||
uris: [
|
||||
"turn:turn.example.com:3478?transport=udp",
|
||||
"turn:10.20.30.40:3478?transport=tcp",
|
||||
"turns:10.20.30.40:443?transport=tcp",
|
||||
],
|
||||
username: "1448999322:@user:example.com",
|
||||
password: "hunter2",
|
||||
};
|
||||
const clientServer1: IClientTurnServer = {
|
||||
urls: server1.uris,
|
||||
username: server1.username,
|
||||
credential: server1.password,
|
||||
};
|
||||
const clientServer2: IClientTurnServer = {
|
||||
urls: server2.uris,
|
||||
username: server2.username,
|
||||
credential: server2.password,
|
||||
};
|
||||
|
||||
let emitServer2: () => void;
|
||||
const getServer2 = new Promise<ITurnServer>(resolve => emitServer2 = () => resolve(server2));
|
||||
widgetApi.getTurnServers.mockImplementation(async function* () {
|
||||
yield server1;
|
||||
yield await getServer2;
|
||||
});
|
||||
|
||||
await makeClient({ turnServers: true });
|
||||
expect(widgetApi.requestCapability).toHaveBeenCalledWith(MatrixCapabilities.MSC3846TurnServers);
|
||||
|
||||
// The first server should've arrived immediately
|
||||
expect(client.getTurnServers()).toEqual([clientServer1]);
|
||||
|
||||
// Subsequent servers arrive asynchronously and should emit an event
|
||||
const emittedServer = new Promise<IClientTurnServer[]>(resolve =>
|
||||
client.once(ClientEvent.TurnServers, resolve),
|
||||
);
|
||||
emitServer2!();
|
||||
expect(await emittedServer).toEqual([clientServer2]);
|
||||
expect(client.getTurnServers()).toEqual([clientServer2]);
|
||||
});
|
||||
});
|
||||
@@ -55,6 +55,23 @@ describe('EventTimelineSet', () => {
|
||||
});
|
||||
};
|
||||
|
||||
const mkThreadResponse = (root: MatrixEvent) => utils.mkEvent({
|
||||
event: true,
|
||||
type: EventType.RoomMessage,
|
||||
user: userA,
|
||||
room: roomId,
|
||||
content: {
|
||||
"body": "Thread response :: " + Math.random(),
|
||||
"m.relates_to": {
|
||||
"event_id": root.getId(),
|
||||
"m.in_reply_to": {
|
||||
"event_id": root.getId(),
|
||||
},
|
||||
"rel_type": "m.thread",
|
||||
},
|
||||
},
|
||||
}, room.client);
|
||||
|
||||
beforeEach(() => {
|
||||
client = utils.mock(MatrixClient, 'MatrixClient');
|
||||
client.reEmitter = utils.mock(ReEmitter, 'ReEmitter');
|
||||
@@ -117,6 +134,13 @@ describe('EventTimelineSet', () => {
|
||||
});
|
||||
|
||||
describe('addEventToTimeline', () => {
|
||||
let thread: Thread;
|
||||
|
||||
beforeEach(() => {
|
||||
(client.supportsExperimentalThreads as jest.Mock).mockReturnValue(true);
|
||||
thread = new Thread("!thread_id:server", messageEvent, { room, client });
|
||||
});
|
||||
|
||||
it("Adds event to timeline", () => {
|
||||
const liveTimeline = eventTimelineSet.getLiveTimeline();
|
||||
expect(liveTimeline.getEvents().length).toStrictEqual(0);
|
||||
@@ -144,6 +168,58 @@ describe('EventTimelineSet', () => {
|
||||
);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it("should not add an event to a timeline that does not belong to the timelineSet", () => {
|
||||
const eventTimelineSet2 = new EventTimelineSet(room);
|
||||
const liveTimeline2 = eventTimelineSet2.getLiveTimeline();
|
||||
expect(liveTimeline2.getEvents().length).toStrictEqual(0);
|
||||
|
||||
expect(() => {
|
||||
eventTimelineSet.addEventToTimeline(messageEvent, liveTimeline2, {
|
||||
toStartOfTimeline: true,
|
||||
});
|
||||
}).toThrowError();
|
||||
});
|
||||
|
||||
it("should not add a threaded reply to the main room timeline", () => {
|
||||
const liveTimeline = eventTimelineSet.getLiveTimeline();
|
||||
expect(liveTimeline.getEvents().length).toStrictEqual(0);
|
||||
|
||||
const threadedReplyEvent = mkThreadResponse(messageEvent);
|
||||
|
||||
eventTimelineSet.addEventToTimeline(threadedReplyEvent, liveTimeline, {
|
||||
toStartOfTimeline: true,
|
||||
});
|
||||
expect(liveTimeline.getEvents().length).toStrictEqual(0);
|
||||
});
|
||||
|
||||
it("should not add a normal message to the timelineSet representing a thread", () => {
|
||||
const eventTimelineSetForThread = new EventTimelineSet(room, {}, client, thread);
|
||||
const liveTimeline = eventTimelineSetForThread.getLiveTimeline();
|
||||
expect(liveTimeline.getEvents().length).toStrictEqual(0);
|
||||
|
||||
eventTimelineSetForThread.addEventToTimeline(messageEvent, liveTimeline, {
|
||||
toStartOfTimeline: true,
|
||||
});
|
||||
expect(liveTimeline.getEvents().length).toStrictEqual(0);
|
||||
});
|
||||
|
||||
describe('non-room timeline', () => {
|
||||
it('Adds event to timeline', () => {
|
||||
const nonRoomEventTimelineSet = new EventTimelineSet(
|
||||
// This is what we're specifically testing against, a timeline
|
||||
// without a `room` defined
|
||||
undefined,
|
||||
);
|
||||
const nonRoomEventTimeline = new EventTimeline(nonRoomEventTimelineSet);
|
||||
|
||||
expect(nonRoomEventTimeline.getEvents().length).toStrictEqual(0);
|
||||
nonRoomEventTimelineSet.addEventToTimeline(messageEvent, nonRoomEventTimeline, {
|
||||
toStartOfTimeline: true,
|
||||
});
|
||||
expect(nonRoomEventTimeline.getEvents().length).toStrictEqual(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('aggregateRelations', () => {
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
import { mocked } from 'jest-mock';
|
||||
|
||||
import * as utils from "../test-utils/test-utils";
|
||||
import { EventTimeline } from "../../src/models/event-timeline";
|
||||
import { Direction, EventTimeline } from "../../src/models/event-timeline";
|
||||
import { RoomState } from "../../src/models/room-state";
|
||||
import { MatrixClient } from "../../src/matrix";
|
||||
import { Room } from "../../src/models/room";
|
||||
import { RoomMember } from "../../src/models/room-member";
|
||||
import { EventTimelineSet } from "../../src/models/event-timeline-set";
|
||||
|
||||
jest.mock("../../src/models/room-state");
|
||||
|
||||
describe("EventTimeline", function() {
|
||||
const roomId = "!foo:bar";
|
||||
const userA = "@alice:bar";
|
||||
@@ -23,7 +21,14 @@ describe("EventTimeline", function() {
|
||||
const timelineSet = new EventTimelineSet(room);
|
||||
jest.spyOn(room, 'getUnfilteredTimelineSet').mockReturnValue(timelineSet);
|
||||
|
||||
return new EventTimeline(timelineSet);
|
||||
const timeline = new EventTimeline(timelineSet);
|
||||
// We manually stub the methods we'll be mocking out later instead of mocking the whole module
|
||||
// otherwise the default member property values (e.g. paginationToken) will be incorrect
|
||||
timeline.getState(Direction.Backward)!.setStateEvents = jest.fn();
|
||||
timeline.getState(Direction.Backward)!.getSentinelMember = jest.fn();
|
||||
timeline.getState(Direction.Forward)!.setStateEvents = jest.fn();
|
||||
timeline.getState(Direction.Forward)!.getSentinelMember = jest.fn();
|
||||
return timeline;
|
||||
};
|
||||
|
||||
beforeEach(function() {
|
||||
@@ -55,13 +60,13 @@ describe("EventTimeline", function() {
|
||||
];
|
||||
timeline.initialiseState(events);
|
||||
// @ts-ignore private prop
|
||||
const timelineStartState = timeline.startState;
|
||||
const timelineStartState = timeline.startState!;
|
||||
expect(mocked(timelineStartState).setStateEvents).toHaveBeenCalledWith(
|
||||
events,
|
||||
{ timelineWasEmpty: undefined },
|
||||
);
|
||||
// @ts-ignore private prop
|
||||
const timelineEndState = timeline.endState;
|
||||
const timelineEndState = timeline.endState!;
|
||||
expect(mocked(timelineEndState).setStateEvents).toHaveBeenCalledWith(
|
||||
events,
|
||||
{ timelineWasEmpty: undefined },
|
||||
@@ -98,7 +103,17 @@ describe("EventTimeline", function() {
|
||||
expect(timeline.getPaginationToken(EventTimeline.FORWARDS)).toBe(null);
|
||||
});
|
||||
|
||||
it("setPaginationToken should set token", function() {
|
||||
it("setPaginationToken should set token", function() {
|
||||
timeline.setPaginationToken("back", EventTimeline.BACKWARDS);
|
||||
timeline.setPaginationToken("fwd", EventTimeline.FORWARDS);
|
||||
expect(timeline.getPaginationToken(EventTimeline.BACKWARDS)).toEqual("back");
|
||||
expect(timeline.getPaginationToken(EventTimeline.FORWARDS)).toEqual("fwd");
|
||||
});
|
||||
|
||||
it("should be able to store pagination tokens for mixed room timelines", () => {
|
||||
const timelineSet = new EventTimelineSet(undefined);
|
||||
const timeline = new EventTimeline(timelineSet);
|
||||
|
||||
timeline.setPaginationToken("back", EventTimeline.BACKWARDS);
|
||||
timeline.setPaginationToken("fwd", EventTimeline.FORWARDS);
|
||||
expect(timeline.getPaginationToken(EventTimeline.BACKWARDS)).toEqual("back");
|
||||
@@ -185,14 +200,14 @@ describe("EventTimeline", function() {
|
||||
sentinel.name = "Old Alice";
|
||||
sentinel.membership = "join";
|
||||
|
||||
mocked(timeline.getState(EventTimeline.FORWARDS)).getSentinelMember
|
||||
mocked(timeline.getState(EventTimeline.FORWARDS)!).getSentinelMember
|
||||
.mockImplementation(function(uid) {
|
||||
if (uid === userA) {
|
||||
return sentinel;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
mocked(timeline.getState(EventTimeline.BACKWARDS)).getSentinelMember
|
||||
mocked(timeline.getState(EventTimeline.BACKWARDS)!).getSentinelMember
|
||||
.mockImplementation(function(uid) {
|
||||
if (uid === userA) {
|
||||
return oldSentinel;
|
||||
@@ -225,14 +240,14 @@ describe("EventTimeline", function() {
|
||||
sentinel.name = "Old Alice";
|
||||
sentinel.membership = "join";
|
||||
|
||||
mocked(timeline.getState(EventTimeline.FORWARDS)).getSentinelMember
|
||||
mocked(timeline.getState(EventTimeline.FORWARDS)!).getSentinelMember
|
||||
.mockImplementation(function(uid) {
|
||||
if (uid === userA) {
|
||||
return sentinel;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
mocked(timeline.getState(EventTimeline.BACKWARDS)).getSentinelMember
|
||||
mocked(timeline.getState(EventTimeline.BACKWARDS)!).getSentinelMember
|
||||
.mockImplementation(function(uid) {
|
||||
if (uid === userA) {
|
||||
return oldSentinel;
|
||||
@@ -269,15 +284,15 @@ describe("EventTimeline", function() {
|
||||
timeline.addEvent(events[0], { toStartOfTimeline: false });
|
||||
timeline.addEvent(events[1], { toStartOfTimeline: false });
|
||||
|
||||
expect(timeline.getState(EventTimeline.FORWARDS).setStateEvents).
|
||||
expect(timeline.getState(EventTimeline.FORWARDS)!.setStateEvents).
|
||||
toHaveBeenCalledWith([events[0]], { timelineWasEmpty: undefined });
|
||||
expect(timeline.getState(EventTimeline.FORWARDS).setStateEvents).
|
||||
expect(timeline.getState(EventTimeline.FORWARDS)!.setStateEvents).
|
||||
toHaveBeenCalledWith([events[1]], { timelineWasEmpty: undefined });
|
||||
|
||||
expect(events[0].forwardLooking).toBe(true);
|
||||
expect(events[1].forwardLooking).toBe(true);
|
||||
|
||||
expect(timeline.getState(EventTimeline.BACKWARDS).setStateEvents).
|
||||
expect(timeline.getState(EventTimeline.BACKWARDS)!.setStateEvents).
|
||||
not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -298,15 +313,15 @@ describe("EventTimeline", function() {
|
||||
timeline.addEvent(events[0], { toStartOfTimeline: true });
|
||||
timeline.addEvent(events[1], { toStartOfTimeline: true });
|
||||
|
||||
expect(timeline.getState(EventTimeline.BACKWARDS).setStateEvents).
|
||||
expect(timeline.getState(EventTimeline.BACKWARDS)!.setStateEvents).
|
||||
toHaveBeenCalledWith([events[0]], { timelineWasEmpty: undefined });
|
||||
expect(timeline.getState(EventTimeline.BACKWARDS).setStateEvents).
|
||||
expect(timeline.getState(EventTimeline.BACKWARDS)!.setStateEvents).
|
||||
toHaveBeenCalledWith([events[1]], { timelineWasEmpty: undefined });
|
||||
|
||||
expect(events[0].forwardLooking).toBe(false);
|
||||
expect(events[1].forwardLooking).toBe(false);
|
||||
|
||||
expect(timeline.getState(EventTimeline.FORWARDS).setStateEvents).
|
||||
expect(timeline.getState(EventTimeline.FORWARDS)!.setStateEvents).
|
||||
not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -28,6 +28,8 @@ import {
|
||||
import { sleep } from "../../../src/utils";
|
||||
|
||||
jest.mock("../../../src/utils");
|
||||
// setupTests mocks `timeoutSignal` due to hanging timers
|
||||
jest.unmock("../../../src/http-api/utils");
|
||||
|
||||
describe("timeoutSignal", () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
@@ -1703,4 +1703,39 @@ describe("MatrixClient", function() {
|
||||
expect(newSourceRoom._state.get(PolicyScope.User)?.[eventId]).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("using E2EE in group calls", () => {
|
||||
const opts = {
|
||||
baseUrl: "https://my.home.server",
|
||||
idBaseUrl: identityServerUrl,
|
||||
accessToken: "my.access.token",
|
||||
store: store,
|
||||
scheduler: scheduler,
|
||||
userId: userId,
|
||||
};
|
||||
|
||||
it("enables E2EE by default", () => {
|
||||
const client = new MatrixClient(opts);
|
||||
|
||||
expect(client.getUseE2eForGroupCall()).toBe(true);
|
||||
});
|
||||
|
||||
it("enables E2EE when enabled explicitly", () => {
|
||||
const client = new MatrixClient({
|
||||
useE2eForGroupCall: true,
|
||||
...opts,
|
||||
});
|
||||
|
||||
expect(client.getUseE2eForGroupCall()).toBe(true);
|
||||
});
|
||||
|
||||
it("disables E2EE if disabled explicitly", () => {
|
||||
const client = new MatrixClient({
|
||||
useE2eForGroupCall: false,
|
||||
...opts,
|
||||
});
|
||||
|
||||
expect(client.getUseE2eForGroupCall()).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,7 +14,11 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { MatrixClient } from "../../../src/client";
|
||||
import { Room } from "../../../src/models/room";
|
||||
import { Thread } from "../../../src/models/thread";
|
||||
import { mkThread } from "../../test-utils/thread";
|
||||
import { TestClient } from "../../TestClient";
|
||||
|
||||
describe('Thread', () => {
|
||||
describe("constructor", () => {
|
||||
@@ -25,4 +29,52 @@ describe('Thread', () => {
|
||||
}).toThrow("element-web#22141: A thread requires a room in order to function");
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasUserReadEvent", () => {
|
||||
const myUserId = "@bob:example.org";
|
||||
let client: MatrixClient;
|
||||
let room: Room;
|
||||
|
||||
beforeEach(() => {
|
||||
const testClient = new TestClient(
|
||||
myUserId,
|
||||
"DEVICE",
|
||||
"ACCESS_TOKEN",
|
||||
undefined,
|
||||
{ timelineSupport: false },
|
||||
);
|
||||
client = testClient.client;
|
||||
room = new Room("123", client, myUserId);
|
||||
|
||||
jest.spyOn(client, "getRoom").mockReturnValue(room);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it("considers own events with no RR as read", () => {
|
||||
const { thread, events } = mkThread({
|
||||
room,
|
||||
client,
|
||||
authorId: myUserId,
|
||||
participantUserIds: [myUserId],
|
||||
length: 2,
|
||||
});
|
||||
|
||||
expect(thread.hasUserReadEvent(myUserId, events.at(-1)!.getId() ?? "")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("considers other events with no RR as unread", () => {
|
||||
const { thread, events } = mkThread({
|
||||
room,
|
||||
client,
|
||||
authorId: myUserId,
|
||||
participantUserIds: ["@alice:example.org"],
|
||||
length: 2,
|
||||
});
|
||||
|
||||
expect(thread.hasUserReadEvent("@alice:example.org", events.at(-1)!.getId() ?? "")).toBeFalsy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,6 +22,8 @@ import { MatrixClient } from "../../src/client";
|
||||
import { ToDeviceBatch } from '../../src/models/ToDeviceMessage';
|
||||
import { logger } from '../../src/logger';
|
||||
import { IStore } from '../../src/store';
|
||||
import { flushPromises } from '../test-utils/flushPromises';
|
||||
import { removeElement } from "../../src/utils";
|
||||
|
||||
const FAKE_USER = "@alice:example.org";
|
||||
const FAKE_DEVICE_ID = "AAAAAAAA";
|
||||
@@ -47,19 +49,6 @@ enum StoreType {
|
||||
IndexedDB = 'IndexedDB',
|
||||
}
|
||||
|
||||
// Jest now uses @sinonjs/fake-timers which exposes tickAsync() and a number of
|
||||
// other async methods which break the event loop, letting scheduled promise
|
||||
// callbacks run. Unfortunately, Jest doesn't expose these, so we have to do
|
||||
// it manually (this is what sinon does under the hood). We do both in a loop
|
||||
// until the thing we expect happens: hopefully this is the least flakey way
|
||||
// and avoids assuming anything about the app's behaviour.
|
||||
const realSetTimeout = setTimeout;
|
||||
function flushPromises() {
|
||||
return new Promise(r => {
|
||||
realSetTimeout(r, 1);
|
||||
});
|
||||
}
|
||||
|
||||
async function flushAndRunTimersUntil(cond: () => boolean) {
|
||||
while (!cond()) {
|
||||
await flushPromises();
|
||||
@@ -75,6 +64,8 @@ describe.each([
|
||||
let client: MatrixClient;
|
||||
|
||||
beforeEach(async function() {
|
||||
jest.runOnlyPendingTimers();
|
||||
jest.useRealTimers();
|
||||
httpBackend = new MockHttpBackend();
|
||||
|
||||
let store: IStore;
|
||||
@@ -300,7 +291,7 @@ describe.each([
|
||||
],
|
||||
});
|
||||
|
||||
expect(await httpBackend.flush(undefined, 1, 1)).toEqual(1);
|
||||
expect(await httpBackend.flush(undefined, 1, 20)).toEqual(1);
|
||||
await flushPromises();
|
||||
|
||||
const dummyEvent = new MatrixEvent({
|
||||
@@ -328,12 +319,12 @@ describe.each([
|
||||
});
|
||||
}
|
||||
|
||||
const expectedCounts = [20, 1];
|
||||
httpBackend.when(
|
||||
"PUT", "/sendToDevice/org.example.foo/",
|
||||
).check((request) => {
|
||||
expect(Object.keys(request.data.messages).length).toEqual(20);
|
||||
expect(removeElement(expectedCounts, c => c === Object.keys(request.data.messages).length)).toBeTruthy();
|
||||
}).respond(200, {});
|
||||
|
||||
httpBackend.when(
|
||||
"PUT", "/sendToDevice/org.example.foo/",
|
||||
).check((request) => {
|
||||
|
||||
@@ -18,6 +18,7 @@ import MockHttpBackend from 'matrix-mock-request';
|
||||
|
||||
import { ReceiptType } from '../../src/@types/read_receipts';
|
||||
import { MatrixClient } from "../../src/client";
|
||||
import { Feature, ServerSupport } from '../../src/feature';
|
||||
import { EventType } from '../../src/matrix';
|
||||
import { MAIN_ROOM_TIMELINE } from '../../src/models/read-receipt';
|
||||
import { encodeUri } from '../../src/utils';
|
||||
@@ -69,15 +70,8 @@ const roomEvent = utils.mkEvent({
|
||||
},
|
||||
});
|
||||
|
||||
function mockServerSideSupport(client, hasServerSideSupport) {
|
||||
const doesServerSupportUnstableFeature = client.doesServerSupportUnstableFeature;
|
||||
client.doesServerSupportUnstableFeature = (unstableFeature) => {
|
||||
if (unstableFeature === "org.matrix.msc3771") {
|
||||
return Promise.resolve(hasServerSideSupport);
|
||||
} else {
|
||||
return doesServerSupportUnstableFeature(unstableFeature);
|
||||
}
|
||||
};
|
||||
function mockServerSideSupport(client, serverSideSupport: ServerSupport) {
|
||||
client.canSupport.set(Feature.ThreadUnreadNotifications, serverSideSupport);
|
||||
}
|
||||
|
||||
describe("Read receipt", () => {
|
||||
@@ -103,13 +97,31 @@ describe("Read receipt", () => {
|
||||
expect(request.data.thread_id).toEqual(THREAD_ID);
|
||||
}).respond(200, {});
|
||||
|
||||
mockServerSideSupport(client, true);
|
||||
mockServerSideSupport(client, ServerSupport.Stable);
|
||||
client.sendReceipt(threadEvent, ReceiptType.Read, {});
|
||||
|
||||
await httpBackend.flushAllExpected();
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
it("sends an unthreaded receipt", async () => {
|
||||
httpBackend.when(
|
||||
"POST", encodeUri("/rooms/$roomId/receipt/$receiptType/$eventId", {
|
||||
$roomId: ROOM_ID,
|
||||
$receiptType: ReceiptType.Read,
|
||||
$eventId: threadEvent.getId()!,
|
||||
}),
|
||||
).check((request) => {
|
||||
expect(request.data.thread_id).toBeUndefined();
|
||||
}).respond(200, {});
|
||||
|
||||
mockServerSideSupport(client, ServerSupport.Stable);
|
||||
client.sendReadReceipt(threadEvent, ReceiptType.Read, true);
|
||||
|
||||
await httpBackend.flushAllExpected();
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
it("sends a room read receipt", async () => {
|
||||
httpBackend.when(
|
||||
"POST", encodeUri("/rooms/$roomId/receipt/$receiptType/$eventId", {
|
||||
@@ -121,7 +133,7 @@ describe("Read receipt", () => {
|
||||
expect(request.data.thread_id).toEqual(MAIN_ROOM_TIMELINE);
|
||||
}).respond(200, {});
|
||||
|
||||
mockServerSideSupport(client, true);
|
||||
mockServerSideSupport(client, ServerSupport.Stable);
|
||||
client.sendReceipt(roomEvent, ReceiptType.Read, {});
|
||||
|
||||
await httpBackend.flushAllExpected();
|
||||
@@ -139,7 +151,7 @@ describe("Read receipt", () => {
|
||||
expect(request.data.thread_id).toBeUndefined();
|
||||
}).respond(200, {});
|
||||
|
||||
mockServerSideSupport(client, false);
|
||||
mockServerSideSupport(client, ServerSupport.Unsupported);
|
||||
client.sendReceipt(threadEvent, ReceiptType.Read, {});
|
||||
|
||||
await httpBackend.flushAllExpected();
|
||||
@@ -157,7 +169,7 @@ describe("Read receipt", () => {
|
||||
expect(request.data).toEqual({});
|
||||
}).respond(200, {});
|
||||
|
||||
mockServerSideSupport(client, false);
|
||||
mockServerSideSupport(client, ServerSupport.Unsupported);
|
||||
client.sendReceipt(threadEvent, ReceiptType.Read, undefined);
|
||||
|
||||
await httpBackend.flushAllExpected();
|
||||
|
||||
+1019
-333
File diff suppressed because it is too large
Load Diff
@@ -20,22 +20,117 @@ import {
|
||||
EventTimeline,
|
||||
EventTimelineSet,
|
||||
EventType,
|
||||
GroupCallIntent,
|
||||
GroupCallType,
|
||||
IRoomTimelineData,
|
||||
MatrixCall,
|
||||
MatrixEvent,
|
||||
Room,
|
||||
RoomEvent,
|
||||
RoomMember,
|
||||
} from "../../../src";
|
||||
import { MatrixClient } from "../../../src/client";
|
||||
import { CallEventHandler, CallEventHandlerEvent } from "../../../src/webrtc/callEventHandler";
|
||||
import { GroupCallEventHandler } from "../../../src/webrtc/groupCallEventHandler";
|
||||
import { SyncState } from "../../../src/sync";
|
||||
import { installWebRTCMocks, MockRTCPeerConnection } from "../../test-utils/webrtc";
|
||||
import { sleep } from "../../../src/utils";
|
||||
|
||||
describe("callEventHandler", () => {
|
||||
it("should ignore a call if invite & hangup come within a single sync", () => {
|
||||
const testClient = new TestClient();
|
||||
const client = testClient.client;
|
||||
const room = new Room("!room:id", client, "@user:id");
|
||||
const timelineData: IRoomTimelineData = { timeline: new EventTimeline(new EventTimelineSet(room, {})) };
|
||||
describe("CallEventHandler", () => {
|
||||
let client: MatrixClient;
|
||||
beforeEach(() => {
|
||||
installWebRTCMocks();
|
||||
|
||||
client = new TestClient("@alice:foo", "somedevice", "token", undefined, {}).client;
|
||||
client.callEventHandler = new CallEventHandler(client);
|
||||
client.callEventHandler.start();
|
||||
client.groupCallEventHandler = new GroupCallEventHandler(client);
|
||||
client.groupCallEventHandler.start();
|
||||
client.sendStateEvent = jest.fn().mockResolvedValue({});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
client.callEventHandler!.stop();
|
||||
client.groupCallEventHandler!.stop();
|
||||
});
|
||||
|
||||
const sync = async () => {
|
||||
client.getSyncState = jest.fn().mockReturnValue(SyncState.Syncing);
|
||||
client.emit(ClientEvent.Sync, SyncState.Syncing, SyncState.Prepared);
|
||||
|
||||
// We can't await the event processing
|
||||
await sleep(10);
|
||||
};
|
||||
|
||||
it("should enforce inbound toDevice message ordering", async () => {
|
||||
const callEventHandler = client.callEventHandler!;
|
||||
const event1 = new MatrixEvent({
|
||||
type: EventType.CallInvite,
|
||||
content: {
|
||||
call_id: "123",
|
||||
seq: 0,
|
||||
},
|
||||
});
|
||||
callEventHandler["onToDeviceEvent"](event1);
|
||||
|
||||
expect(callEventHandler.callEventBuffer.length).toBe(1);
|
||||
expect(callEventHandler.callEventBuffer[0]).toBe(event1);
|
||||
|
||||
const event2 = new MatrixEvent({
|
||||
type: EventType.CallCandidates,
|
||||
content: {
|
||||
call_id: "123",
|
||||
seq: 1,
|
||||
},
|
||||
});
|
||||
callEventHandler["onToDeviceEvent"](event2);
|
||||
|
||||
expect(callEventHandler.callEventBuffer.length).toBe(2);
|
||||
expect(callEventHandler.callEventBuffer[1]).toBe(event2);
|
||||
|
||||
const event3 = new MatrixEvent({
|
||||
type: EventType.CallCandidates,
|
||||
content: {
|
||||
call_id: "123",
|
||||
seq: 3,
|
||||
},
|
||||
});
|
||||
callEventHandler["onToDeviceEvent"](event3);
|
||||
|
||||
expect(callEventHandler.callEventBuffer.length).toBe(2);
|
||||
expect(callEventHandler.nextSeqByCall.get("123")).toBe(2);
|
||||
expect(callEventHandler.toDeviceEventBuffers.get("123")?.length).toBe(1);
|
||||
|
||||
const event4 = new MatrixEvent({
|
||||
type: EventType.CallCandidates,
|
||||
content: {
|
||||
call_id: "123",
|
||||
seq: 4,
|
||||
},
|
||||
});
|
||||
callEventHandler["onToDeviceEvent"](event4);
|
||||
|
||||
expect(callEventHandler.callEventBuffer.length).toBe(2);
|
||||
expect(callEventHandler.nextSeqByCall.get("123")).toBe(2);
|
||||
expect(callEventHandler.toDeviceEventBuffers.get("123")?.length).toBe(2);
|
||||
|
||||
const event5 = new MatrixEvent({
|
||||
type: EventType.CallCandidates,
|
||||
content: {
|
||||
call_id: "123",
|
||||
seq: 2,
|
||||
},
|
||||
});
|
||||
callEventHandler["onToDeviceEvent"](event5);
|
||||
|
||||
expect(callEventHandler.callEventBuffer.length).toBe(5);
|
||||
expect(callEventHandler.nextSeqByCall.get("123")).toBe(5);
|
||||
expect(callEventHandler.toDeviceEventBuffers.get("123")?.length).toBe(0);
|
||||
});
|
||||
|
||||
it("should ignore a call if invite & hangup come within a single sync", () => {
|
||||
const room = new Room("!room:id", client, "@user:id");
|
||||
const timelineData: IRoomTimelineData = { timeline: new EventTimeline(new EventTimelineSet(room, {})) };
|
||||
|
||||
// Fire off call invite then hangup within a single sync
|
||||
const callInvite = new MatrixEvent({
|
||||
@@ -62,4 +157,117 @@ describe("callEventHandler", () => {
|
||||
|
||||
expect(incomingCallEmitted).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should ignore non-call events", async () => {
|
||||
// @ts-ignore Mock handleCallEvent is private
|
||||
jest.spyOn(client.callEventHandler, "handleCallEvent");
|
||||
jest.spyOn(client, "checkTurnServers").mockReturnValue(Promise.resolve(true));
|
||||
|
||||
const room = new Room("!room:id", client, "@user:id");
|
||||
const timelineData: IRoomTimelineData = { timeline: new EventTimeline(new EventTimelineSet(room, {})) };
|
||||
|
||||
client.emit(RoomEvent.Timeline, new MatrixEvent({
|
||||
type: EventType.RoomMessage,
|
||||
room_id: "!room:id",
|
||||
content: {
|
||||
text: "hello",
|
||||
|
||||
},
|
||||
}), room, false, false, timelineData);
|
||||
await sync();
|
||||
|
||||
// @ts-ignore Mock handleCallEvent is private
|
||||
expect(client.callEventHandler.handleCallEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("handleCallEvent()", () => {
|
||||
const incomingCallListener = jest.fn();
|
||||
let timelineData: IRoomTimelineData;
|
||||
let room: Room;
|
||||
|
||||
beforeEach(() => {
|
||||
room = new Room("!room:id", client, client.getUserId()!);
|
||||
timelineData = { timeline: new EventTimeline(new EventTimelineSet(room, {})) };
|
||||
|
||||
jest.spyOn(client, "checkTurnServers").mockReturnValue(Promise.resolve(true));
|
||||
jest.spyOn(client, "getRoom").mockReturnValue(room);
|
||||
jest.spyOn(room, "getMember").mockReturnValue({ user_id: client.getUserId() } as unknown as RoomMember);
|
||||
|
||||
client.on(CallEventHandlerEvent.Incoming, incomingCallListener);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
MockRTCPeerConnection.resetInstances();
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it("should create a call when receiving an invite", async () => {
|
||||
client.emit(RoomEvent.Timeline, new MatrixEvent({
|
||||
type: EventType.CallInvite,
|
||||
room_id: "!room:id",
|
||||
content: {
|
||||
call_id: "123",
|
||||
},
|
||||
}), room, false, false, timelineData);
|
||||
await sync();
|
||||
|
||||
expect(incomingCallListener).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle group call event", async () => {
|
||||
let call: MatrixCall;
|
||||
const groupCall = await client.createGroupCall(
|
||||
room.roomId,
|
||||
GroupCallType.Voice,
|
||||
false,
|
||||
GroupCallIntent.Ring,
|
||||
);
|
||||
const SESSION_ID = "sender_session_id";
|
||||
const GROUP_CALL_ID = "group_call_id";
|
||||
const DEVICE_ID = "device_id";
|
||||
|
||||
incomingCallListener.mockImplementation((c) => call = c);
|
||||
jest.spyOn(client.groupCallEventHandler!, "getGroupCallById").mockReturnValue(groupCall);
|
||||
// @ts-ignore Mock onIncomingCall is private
|
||||
jest.spyOn(groupCall, "onIncomingCall");
|
||||
|
||||
await groupCall.enter();
|
||||
client.emit(RoomEvent.Timeline, new MatrixEvent({
|
||||
type: EventType.CallInvite,
|
||||
room_id: "!room:id",
|
||||
content: {
|
||||
call_id: "123",
|
||||
conf_id: GROUP_CALL_ID,
|
||||
device_id: DEVICE_ID,
|
||||
sender_session_id: SESSION_ID,
|
||||
dest_session_id: client.getSessionId(),
|
||||
},
|
||||
}), room, false, false, timelineData);
|
||||
await sync();
|
||||
|
||||
expect(incomingCallListener).toHaveBeenCalled();
|
||||
expect(call!.groupCallId).toBe(GROUP_CALL_ID);
|
||||
// @ts-ignore Mock opponentDeviceId is private
|
||||
expect(call.opponentDeviceId).toBe(DEVICE_ID);
|
||||
expect(call!.getOpponentSessionId()).toBe(SESSION_ID);
|
||||
// @ts-ignore Mock onIncomingCall is private
|
||||
expect(groupCall.onIncomingCall).toHaveBeenCalledWith(call);
|
||||
|
||||
groupCall.terminate(false);
|
||||
});
|
||||
|
||||
it("ignores a call with a different invitee than us", async () => {
|
||||
client.emit(RoomEvent.Timeline, new MatrixEvent({
|
||||
type: EventType.CallInvite,
|
||||
room_id: "!room:id",
|
||||
content: {
|
||||
call_id: "123",
|
||||
invitee: "@bob:bar",
|
||||
},
|
||||
}), room, false, false, timelineData);
|
||||
await sync();
|
||||
|
||||
expect(incomingCallListener).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,316 @@
|
||||
/*
|
||||
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 { mocked } from "jest-mock";
|
||||
|
||||
import {
|
||||
ClientEvent,
|
||||
GroupCall,
|
||||
GroupCallIntent,
|
||||
GroupCallState,
|
||||
GroupCallType,
|
||||
IContent,
|
||||
MatrixEvent,
|
||||
Room,
|
||||
RoomState,
|
||||
} from "../../../src";
|
||||
import { SyncState } from "../../../src/sync";
|
||||
import { GroupCallTerminationReason } from "../../../src/webrtc/groupCall";
|
||||
import { GroupCallEventHandler, GroupCallEventHandlerEvent } from "../../../src/webrtc/groupCallEventHandler";
|
||||
import { flushPromises } from "../../test-utils/flushPromises";
|
||||
import {
|
||||
makeMockGroupCallMemberStateEvent,
|
||||
makeMockGroupCallStateEvent,
|
||||
MockCallMatrixClient,
|
||||
} from "../../test-utils/webrtc";
|
||||
|
||||
const FAKE_USER_ID = "@alice:test.dummy";
|
||||
const FAKE_DEVICE_ID = "AAAAAAA";
|
||||
const FAKE_SESSION_ID = "session1";
|
||||
const FAKE_ROOM_ID = "!roomid:test.dummy";
|
||||
const FAKE_GROUP_CALL_ID = "fakegroupcallid";
|
||||
|
||||
describe('Group Call Event Handler', function() {
|
||||
let groupCallEventHandler: GroupCallEventHandler;
|
||||
let mockClient: MockCallMatrixClient;
|
||||
let mockRoom: Room;
|
||||
|
||||
beforeEach(() => {
|
||||
mockClient = new MockCallMatrixClient(
|
||||
FAKE_USER_ID, FAKE_DEVICE_ID, FAKE_SESSION_ID,
|
||||
);
|
||||
groupCallEventHandler = new GroupCallEventHandler(mockClient.typed());
|
||||
|
||||
mockRoom = {
|
||||
roomId: FAKE_ROOM_ID,
|
||||
currentState: {
|
||||
getStateEvents: jest.fn().mockReturnValue([makeMockGroupCallStateEvent(
|
||||
FAKE_ROOM_ID, FAKE_GROUP_CALL_ID,
|
||||
)]),
|
||||
},
|
||||
} as unknown as Room;
|
||||
|
||||
(mockClient as any).getRoom = jest.fn().mockReturnValue(mockRoom);
|
||||
});
|
||||
|
||||
describe("reacts to state changes", () => {
|
||||
it("terminates call", async () => {
|
||||
await groupCallEventHandler.start();
|
||||
mockClient.emitRoomState(
|
||||
makeMockGroupCallStateEvent(FAKE_ROOM_ID, FAKE_GROUP_CALL_ID),
|
||||
{ roomId: FAKE_ROOM_ID } as unknown as RoomState,
|
||||
);
|
||||
|
||||
const groupCall = groupCallEventHandler.groupCalls.get(FAKE_ROOM_ID)!;
|
||||
|
||||
expect(groupCall.state).toBe(GroupCallState.LocalCallFeedUninitialized);
|
||||
|
||||
mockClient.emitRoomState(
|
||||
makeMockGroupCallStateEvent(
|
||||
FAKE_ROOM_ID, FAKE_GROUP_CALL_ID, {
|
||||
"m.type": GroupCallType.Video,
|
||||
"m.intent": GroupCallIntent.Prompt,
|
||||
"m.terminated": GroupCallTerminationReason.CallEnded,
|
||||
},
|
||||
),
|
||||
{
|
||||
roomId: FAKE_ROOM_ID,
|
||||
} as unknown as RoomState,
|
||||
);
|
||||
|
||||
expect(groupCall.state).toBe(GroupCallState.Ended);
|
||||
});
|
||||
});
|
||||
|
||||
it("waits until client starts syncing", async () => {
|
||||
mockClient.getSyncState.mockReturnValue(null);
|
||||
let isStarted = false;
|
||||
(async () => {
|
||||
await groupCallEventHandler.start();
|
||||
isStarted = true;
|
||||
})();
|
||||
|
||||
const setSyncState = async (newState: SyncState) => {
|
||||
const oldState = mockClient.getSyncState();
|
||||
mockClient.getSyncState.mockReturnValue(newState);
|
||||
mockClient.emit(ClientEvent.Sync, newState, oldState, undefined);
|
||||
await flushPromises();
|
||||
};
|
||||
|
||||
await flushPromises();
|
||||
expect(isStarted).toEqual(false);
|
||||
|
||||
await setSyncState(SyncState.Prepared);
|
||||
expect(isStarted).toEqual(false);
|
||||
|
||||
await setSyncState(SyncState.Syncing);
|
||||
expect(isStarted).toEqual(true);
|
||||
});
|
||||
|
||||
it("finds existing group calls when started", async () => {
|
||||
const mockClientEmit = mockClient.emit = jest.fn();
|
||||
|
||||
mockClient.getRooms.mockReturnValue([mockRoom]);
|
||||
await groupCallEventHandler.start();
|
||||
|
||||
expect(mockClientEmit).toHaveBeenCalledWith(
|
||||
GroupCallEventHandlerEvent.Incoming,
|
||||
expect.objectContaining({
|
||||
groupCallId: FAKE_GROUP_CALL_ID,
|
||||
}),
|
||||
);
|
||||
|
||||
groupCallEventHandler.stop();
|
||||
});
|
||||
|
||||
it("can wait until a room is ready for group calls", async () => {
|
||||
await groupCallEventHandler.start();
|
||||
|
||||
const prom = groupCallEventHandler.waitUntilRoomReadyForGroupCalls(FAKE_ROOM_ID);
|
||||
let resolved = false;
|
||||
|
||||
(async () => {
|
||||
await prom;
|
||||
resolved = true;
|
||||
})();
|
||||
|
||||
expect(resolved).toEqual(false);
|
||||
mockClient.emit(ClientEvent.Room, mockRoom);
|
||||
|
||||
await prom;
|
||||
expect(resolved).toEqual(true);
|
||||
|
||||
groupCallEventHandler.stop();
|
||||
});
|
||||
|
||||
it("fires events for incoming calls", async () => {
|
||||
const onIncomingGroupCall = jest.fn();
|
||||
mockClient.on(GroupCallEventHandlerEvent.Incoming, onIncomingGroupCall);
|
||||
await groupCallEventHandler.start();
|
||||
|
||||
mockClient.emitRoomState(
|
||||
makeMockGroupCallStateEvent(
|
||||
FAKE_ROOM_ID, FAKE_GROUP_CALL_ID,
|
||||
),
|
||||
{
|
||||
roomId: FAKE_ROOM_ID,
|
||||
} as unknown as RoomState,
|
||||
);
|
||||
|
||||
expect(onIncomingGroupCall).toHaveBeenCalledWith(expect.objectContaining({
|
||||
groupCallId: FAKE_GROUP_CALL_ID,
|
||||
}));
|
||||
|
||||
mockClient.off(GroupCallEventHandlerEvent.Incoming, onIncomingGroupCall);
|
||||
});
|
||||
|
||||
it("handles data channel", async () => {
|
||||
await groupCallEventHandler.start();
|
||||
|
||||
const dataChannelOptions = {
|
||||
"maxPacketLifeTime": "life_time",
|
||||
"maxRetransmits": "retransmits",
|
||||
"ordered": "ordered",
|
||||
"protocol": "protocol",
|
||||
};
|
||||
|
||||
mockClient.emitRoomState(
|
||||
makeMockGroupCallStateEvent(
|
||||
FAKE_ROOM_ID,
|
||||
FAKE_GROUP_CALL_ID,
|
||||
{
|
||||
"m.type": GroupCallType.Video,
|
||||
"m.intent": GroupCallIntent.Prompt,
|
||||
"dataChannelsEnabled": true,
|
||||
dataChannelOptions,
|
||||
},
|
||||
),
|
||||
{
|
||||
roomId: FAKE_ROOM_ID,
|
||||
} as unknown as RoomState,
|
||||
);
|
||||
|
||||
// @ts-ignore Mock dataChannelsEnabled is private
|
||||
expect(groupCallEventHandler.groupCalls.get(FAKE_ROOM_ID)?.dataChannelsEnabled).toBe(true);
|
||||
// @ts-ignore Mock dataChannelOptions is private
|
||||
expect(groupCallEventHandler.groupCalls.get(FAKE_ROOM_ID)?.dataChannelOptions).toStrictEqual(
|
||||
dataChannelOptions,
|
||||
);
|
||||
});
|
||||
|
||||
it("sends member events to group calls", async () => {
|
||||
await groupCallEventHandler.start();
|
||||
|
||||
const mockGroupCall = {
|
||||
onMemberStateChanged: jest.fn(),
|
||||
};
|
||||
|
||||
groupCallEventHandler.groupCalls.set(FAKE_ROOM_ID, mockGroupCall as unknown as GroupCall);
|
||||
|
||||
const mockStateEvent = makeMockGroupCallMemberStateEvent(FAKE_ROOM_ID, FAKE_GROUP_CALL_ID);
|
||||
|
||||
mockClient.emitRoomState(
|
||||
mockStateEvent,
|
||||
{
|
||||
roomId: FAKE_ROOM_ID,
|
||||
} as unknown as RoomState,
|
||||
);
|
||||
|
||||
expect(mockGroupCall.onMemberStateChanged).toHaveBeenCalledWith(mockStateEvent);
|
||||
});
|
||||
|
||||
describe("ignoring invalid group call state events", () => {
|
||||
let mockClientEmit: jest.Func;
|
||||
|
||||
beforeEach(() => {
|
||||
mockClientEmit = mockClient.emit = jest.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
groupCallEventHandler.stop();
|
||||
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
const setupCallAndStart = async (content?: IContent) => {
|
||||
mocked(mockRoom.currentState.getStateEvents).mockReturnValue([
|
||||
makeMockGroupCallStateEvent(
|
||||
FAKE_ROOM_ID,
|
||||
FAKE_GROUP_CALL_ID,
|
||||
content,
|
||||
),
|
||||
] as unknown as MatrixEvent);
|
||||
mockClient.getRooms.mockReturnValue([mockRoom]);
|
||||
await groupCallEventHandler.start();
|
||||
};
|
||||
|
||||
it("ignores terminated calls", async () => {
|
||||
await setupCallAndStart({
|
||||
"m.type": GroupCallType.Video,
|
||||
"m.intent": GroupCallIntent.Prompt,
|
||||
"m.terminated": GroupCallTerminationReason.CallEnded,
|
||||
});
|
||||
|
||||
expect(mockClientEmit).not.toHaveBeenCalledWith(
|
||||
GroupCallEventHandlerEvent.Incoming,
|
||||
expect.objectContaining({
|
||||
groupCallId: FAKE_GROUP_CALL_ID,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores calls with invalid type", async () => {
|
||||
await setupCallAndStart({
|
||||
"m.type": "fake_type",
|
||||
"m.intent": GroupCallIntent.Prompt,
|
||||
});
|
||||
|
||||
expect(mockClientEmit).not.toHaveBeenCalledWith(
|
||||
GroupCallEventHandlerEvent.Incoming,
|
||||
expect.objectContaining({
|
||||
groupCallId: FAKE_GROUP_CALL_ID,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores calls with invalid intent", async () => {
|
||||
await setupCallAndStart({
|
||||
"m.type": GroupCallType.Video,
|
||||
"m.intent": "fake_intent",
|
||||
});
|
||||
|
||||
expect(mockClientEmit).not.toHaveBeenCalledWith(
|
||||
GroupCallEventHandlerEvent.Incoming,
|
||||
expect.objectContaining({
|
||||
groupCallId: FAKE_GROUP_CALL_ID,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores calls without a room", async () => {
|
||||
mockClient.getRoom.mockReturnValue(undefined);
|
||||
|
||||
await setupCallAndStart();
|
||||
|
||||
expect(mockClientEmit).not.toHaveBeenCalledWith(
|
||||
GroupCallEventHandlerEvent.Incoming,
|
||||
expect.objectContaining({
|
||||
groupCallId: FAKE_GROUP_CALL_ID,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,462 @@
|
||||
/*
|
||||
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 { GroupCall, MatrixCall, MatrixClient } from "../../../src";
|
||||
import { MediaHandler, MediaHandlerEvent } from "../../../src/webrtc/mediaHandler";
|
||||
import { MockMediaDeviceInfo, MockMediaDevices, MockMediaStream, MockMediaStreamTrack } from "../../test-utils/webrtc";
|
||||
|
||||
const FAKE_AUDIO_INPUT_ID = "aaaaaaaa";
|
||||
const FAKE_VIDEO_INPUT_ID = "vvvvvvvv";
|
||||
const FAKE_DESKTOP_SOURCE_ID = "ddddddd";
|
||||
|
||||
describe('Media Handler', function() {
|
||||
let mockMediaDevices: MockMediaDevices;
|
||||
let mediaHandler: MediaHandler;
|
||||
let calls: Map<string, MatrixCall>;
|
||||
let groupCalls: Map<string, GroupCall>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockMediaDevices = new MockMediaDevices();
|
||||
|
||||
global.navigator = {
|
||||
mediaDevices: mockMediaDevices.typed(),
|
||||
} as unknown as Navigator;
|
||||
|
||||
calls = new Map();
|
||||
groupCalls = new Map();
|
||||
|
||||
mediaHandler = new MediaHandler({
|
||||
callEventHandler: {
|
||||
calls,
|
||||
},
|
||||
groupCallEventHandler: {
|
||||
groupCalls,
|
||||
},
|
||||
} as unknown as MatrixClient);
|
||||
});
|
||||
|
||||
it("does not trigger update after restore media settings ", () => {
|
||||
mediaHandler.restoreMediaSettings(FAKE_AUDIO_INPUT_ID, FAKE_VIDEO_INPUT_ID);
|
||||
|
||||
expect(mockMediaDevices.getUserMedia).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sets device IDs on restore media settings", async () => {
|
||||
mediaHandler.restoreMediaSettings(FAKE_AUDIO_INPUT_ID, FAKE_VIDEO_INPUT_ID);
|
||||
|
||||
await mediaHandler.getUserMediaStream(true, true);
|
||||
expect(mockMediaDevices.getUserMedia).toHaveBeenCalledWith(expect.objectContaining({
|
||||
audio: expect.objectContaining({
|
||||
deviceId: { ideal: FAKE_AUDIO_INPUT_ID },
|
||||
}),
|
||||
video: expect.objectContaining({
|
||||
deviceId: { ideal: FAKE_VIDEO_INPUT_ID },
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
it("sets audio device ID", async () => {
|
||||
await mediaHandler.setAudioInput(FAKE_AUDIO_INPUT_ID);
|
||||
|
||||
await mediaHandler.getUserMediaStream(true, false);
|
||||
expect(mockMediaDevices.getUserMedia).toHaveBeenCalledWith(expect.objectContaining({
|
||||
audio: expect.objectContaining({
|
||||
deviceId: { ideal: FAKE_AUDIO_INPUT_ID },
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
it("sets audio settings", async () => {
|
||||
await mediaHandler.setAudioSettings({
|
||||
autoGainControl: false,
|
||||
echoCancellation: true,
|
||||
noiseSuppression: false,
|
||||
});
|
||||
|
||||
await mediaHandler.getUserMediaStream(true, false);
|
||||
expect(mockMediaDevices.getUserMedia).toHaveBeenCalledWith(expect.objectContaining({
|
||||
audio: expect.objectContaining({
|
||||
autoGainControl: { ideal: false },
|
||||
echoCancellation: { ideal: true },
|
||||
noiseSuppression: { ideal: false },
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
it("sets video device ID", async () => {
|
||||
await mediaHandler.setVideoInput(FAKE_VIDEO_INPUT_ID);
|
||||
|
||||
await mediaHandler.getUserMediaStream(false, true);
|
||||
expect(mockMediaDevices.getUserMedia).toHaveBeenCalledWith(expect.objectContaining({
|
||||
video: expect.objectContaining({
|
||||
deviceId: { ideal: FAKE_VIDEO_INPUT_ID },
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
it("sets media inputs", async () => {
|
||||
await mediaHandler.setMediaInputs(FAKE_AUDIO_INPUT_ID, FAKE_VIDEO_INPUT_ID);
|
||||
|
||||
await mediaHandler.getUserMediaStream(true, true);
|
||||
expect(mockMediaDevices.getUserMedia).toHaveBeenCalledWith(expect.objectContaining({
|
||||
audio: expect.objectContaining({
|
||||
deviceId: { ideal: FAKE_AUDIO_INPUT_ID },
|
||||
}),
|
||||
video: expect.objectContaining({
|
||||
deviceId: { ideal: FAKE_VIDEO_INPUT_ID },
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
describe("updateLocalUsermediaStreams", () => {
|
||||
let localStreamsChangedHandler: jest.Mock<void, []>;
|
||||
|
||||
beforeEach(() => {
|
||||
localStreamsChangedHandler = jest.fn();
|
||||
mediaHandler.on(MediaHandlerEvent.LocalStreamsChanged, localStreamsChangedHandler);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mediaHandler.off(MediaHandlerEvent.LocalStreamsChanged, localStreamsChangedHandler);
|
||||
});
|
||||
|
||||
it("does nothing if it has no streams", async () => {
|
||||
mediaHandler.updateLocalUsermediaStreams();
|
||||
expect(mockMediaDevices.getUserMedia).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not emit LocalStreamsChanged if it had no streams", async () => {
|
||||
await mediaHandler.updateLocalUsermediaStreams();
|
||||
|
||||
expect(localStreamsChangedHandler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("with existing streams", () => {
|
||||
let stopTrack: jest.Mock<void, []>;
|
||||
let updateLocalUsermediaStream: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
stopTrack = jest.fn();
|
||||
|
||||
mediaHandler.userMediaStreams = [
|
||||
{
|
||||
getTracks: () => [{
|
||||
stop: stopTrack,
|
||||
} as unknown as MediaStreamTrack],
|
||||
} as unknown as MediaStream,
|
||||
];
|
||||
|
||||
updateLocalUsermediaStream = jest.fn();
|
||||
});
|
||||
|
||||
it("stops existing streams", async () => {
|
||||
mediaHandler.updateLocalUsermediaStreams();
|
||||
expect(stopTrack).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("replaces streams on calls", async () => {
|
||||
calls.set("some_call", {
|
||||
hasLocalUserMediaAudioTrack: true,
|
||||
hasLocalUserMediaVideoTrack: true,
|
||||
callHasEnded: jest.fn().mockReturnValue(false),
|
||||
updateLocalUsermediaStream,
|
||||
} as unknown as MatrixCall);
|
||||
|
||||
await mediaHandler.updateLocalUsermediaStreams();
|
||||
expect(updateLocalUsermediaStream).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("doesn't replace streams on ended calls", async () => {
|
||||
calls.set("some_call", {
|
||||
hasLocalUserMediaAudioTrack: true,
|
||||
hasLocalUserMediaVideoTrack: true,
|
||||
callHasEnded: jest.fn().mockReturnValue(true),
|
||||
updateLocalUsermediaStream,
|
||||
} as unknown as MatrixCall);
|
||||
|
||||
await mediaHandler.updateLocalUsermediaStreams();
|
||||
expect(updateLocalUsermediaStream).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("replaces streams on group calls", async () => {
|
||||
groupCalls.set("some_group_call", {
|
||||
localCallFeed: {},
|
||||
updateLocalUsermediaStream,
|
||||
} as unknown as GroupCall);
|
||||
|
||||
await mediaHandler.updateLocalUsermediaStreams();
|
||||
expect(updateLocalUsermediaStream).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("doesn't replace streams on group calls with no localCallFeed", async () => {
|
||||
groupCalls.set("some_group_call", {
|
||||
localCallFeed: null,
|
||||
updateLocalUsermediaStream,
|
||||
} as unknown as GroupCall);
|
||||
|
||||
await mediaHandler.updateLocalUsermediaStreams();
|
||||
expect(updateLocalUsermediaStream).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("emits LocalStreamsChanged", async () => {
|
||||
await mediaHandler.updateLocalUsermediaStreams();
|
||||
|
||||
expect(localStreamsChangedHandler).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasAudioDevice", () => {
|
||||
it("returns true if the system has audio inputs", async () => {
|
||||
expect(await mediaHandler.hasAudioDevice()).toEqual(true);
|
||||
});
|
||||
|
||||
it("returns false if the system has no audio inputs", async () => {
|
||||
mockMediaDevices.enumerateDevices.mockReturnValue(Promise.resolve([
|
||||
new MockMediaDeviceInfo("videoinput").typed(),
|
||||
]));
|
||||
expect(await mediaHandler.hasAudioDevice()).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasVideoDevice", () => {
|
||||
it("returns true if the system has video inputs", async () => {
|
||||
expect(await mediaHandler.hasVideoDevice()).toEqual(true);
|
||||
});
|
||||
|
||||
it("returns false if the system has no video inputs", async () => {
|
||||
mockMediaDevices.enumerateDevices.mockReturnValue(Promise.resolve([
|
||||
new MockMediaDeviceInfo("audioinput").typed(),
|
||||
]));
|
||||
expect(await mediaHandler.hasVideoDevice()).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getUserMediaStream", () => {
|
||||
beforeEach(() => {
|
||||
// replace this with one that returns a new object each time so we can
|
||||
// tell whether we've ended up with the same stream
|
||||
mockMediaDevices.getUserMedia.mockImplementation((constraints: MediaStreamConstraints) => {
|
||||
const stream = new MockMediaStream("local_stream");
|
||||
if (constraints.audio) {
|
||||
const track = new MockMediaStreamTrack("audio_track", "audio");
|
||||
track.settings = { deviceId: FAKE_AUDIO_INPUT_ID };
|
||||
stream.addTrack(track);
|
||||
}
|
||||
if (constraints.video) {
|
||||
const track = new MockMediaStreamTrack("video_track", "video");
|
||||
track.settings = { deviceId: FAKE_VIDEO_INPUT_ID };
|
||||
stream.addTrack(track);
|
||||
}
|
||||
|
||||
return Promise.resolve(stream.typed());
|
||||
});
|
||||
|
||||
mediaHandler.restoreMediaSettings(FAKE_AUDIO_INPUT_ID, FAKE_VIDEO_INPUT_ID);
|
||||
});
|
||||
|
||||
it("returns the same stream for reusable streams", async () => {
|
||||
const stream1 = await mediaHandler.getUserMediaStream(true, false);
|
||||
const stream2 = await mediaHandler.getUserMediaStream(true, false) as unknown as MockMediaStream;
|
||||
|
||||
expect(stream2.isCloneOf(stream1)).toEqual(true);
|
||||
});
|
||||
|
||||
it("doesn't re-use stream if reusable is false", async () => {
|
||||
const stream1 = await mediaHandler.getUserMediaStream(true, false, false);
|
||||
const stream2 = await mediaHandler.getUserMediaStream(true, false) as unknown as MockMediaStream;
|
||||
|
||||
expect(stream2.isCloneOf(stream1)).toEqual(false);
|
||||
});
|
||||
|
||||
it("doesn't re-use stream if existing stream lacks audio", async () => {
|
||||
const stream1 = await mediaHandler.getUserMediaStream(false, true);
|
||||
const stream2 = await mediaHandler.getUserMediaStream(true, false) as unknown as MockMediaStream;
|
||||
|
||||
expect(stream2.isCloneOf(stream1)).toEqual(false);
|
||||
});
|
||||
|
||||
it("doesn't re-use stream if existing stream lacks video", async () => {
|
||||
const stream1 = await mediaHandler.getUserMediaStream(true, false);
|
||||
const stream2 = await mediaHandler.getUserMediaStream(false, true) as unknown as MockMediaStream;
|
||||
|
||||
expect(stream2.isCloneOf(stream1)).toEqual(false);
|
||||
});
|
||||
|
||||
it("strips unwanted audio tracks from re-used stream", async () => {
|
||||
const stream1 = await mediaHandler.getUserMediaStream(true, true);
|
||||
const stream2 = await mediaHandler.getUserMediaStream(false, true) as unknown as MockMediaStream;
|
||||
|
||||
expect(stream2.isCloneOf(stream1)).toEqual(true);
|
||||
expect(stream2.getAudioTracks().length).toEqual(0);
|
||||
});
|
||||
|
||||
it("strips unwanted video tracks from re-used stream", async () => {
|
||||
const stream1 = await mediaHandler.getUserMediaStream(true, true);
|
||||
const stream2 = await mediaHandler.getUserMediaStream(true, false) as unknown as MockMediaStream;
|
||||
|
||||
expect(stream2.isCloneOf(stream1)).toEqual(true);
|
||||
expect(stream2.getVideoTracks().length).toEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getScreensharingStream", () => {
|
||||
it("gets any screen sharing stream when called with no args", async () => {
|
||||
const stream = await mediaHandler.getScreensharingStream();
|
||||
expect(stream).toBeTruthy();
|
||||
expect(stream.getTracks()).toBeTruthy();
|
||||
});
|
||||
|
||||
it("re-uses streams", async () => {
|
||||
const stream = await mediaHandler.getScreensharingStream(undefined, true);
|
||||
|
||||
expect(mockMediaDevices.getDisplayMedia).toHaveBeenCalled();
|
||||
mockMediaDevices.getDisplayMedia.mockClear();
|
||||
|
||||
const stream2 = await mediaHandler.getScreensharingStream() as unknown as MockMediaStream;
|
||||
|
||||
expect(mockMediaDevices.getDisplayMedia).not.toHaveBeenCalled();
|
||||
|
||||
expect(stream2.isCloneOf(stream)).toEqual(true);
|
||||
});
|
||||
|
||||
it("passes through desktopCapturerSourceId for Electron", async () => {
|
||||
await mediaHandler.getScreensharingStream({
|
||||
desktopCapturerSourceId: FAKE_DESKTOP_SOURCE_ID,
|
||||
});
|
||||
|
||||
expect(mockMediaDevices.getUserMedia).toHaveBeenCalledWith(expect.objectContaining({
|
||||
video: {
|
||||
mandatory: expect.objectContaining({
|
||||
chromeMediaSource: "desktop",
|
||||
chromeMediaSourceId: FAKE_DESKTOP_SOURCE_ID,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
it("emits LocalStreamsChanged", async () => {
|
||||
const onLocalStreamChanged = jest.fn();
|
||||
mediaHandler.on(MediaHandlerEvent.LocalStreamsChanged, onLocalStreamChanged);
|
||||
await mediaHandler.getScreensharingStream();
|
||||
expect(onLocalStreamChanged).toHaveBeenCalled();
|
||||
|
||||
mediaHandler.off(MediaHandlerEvent.LocalStreamsChanged, onLocalStreamChanged);
|
||||
});
|
||||
});
|
||||
|
||||
describe("stopUserMediaStream", () => {
|
||||
let stream: MediaStream;
|
||||
|
||||
beforeEach(async () => {
|
||||
stream = await mediaHandler.getUserMediaStream(true, false);
|
||||
});
|
||||
|
||||
it("stops tracks on streams", async () => {
|
||||
const mockTrack = new MockMediaStreamTrack("audio_track", "audio");
|
||||
stream.addTrack(mockTrack.typed());
|
||||
|
||||
mediaHandler.stopUserMediaStream(stream);
|
||||
expect(mockTrack.stop).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("removes stopped streams", async () => {
|
||||
expect(mediaHandler.userMediaStreams).toContain(stream);
|
||||
mediaHandler.stopUserMediaStream(stream);
|
||||
expect(mediaHandler.userMediaStreams).not.toContain(stream);
|
||||
});
|
||||
|
||||
it("emits LocalStreamsChanged", async () => {
|
||||
const onLocalStreamChanged = jest.fn();
|
||||
mediaHandler.on(MediaHandlerEvent.LocalStreamsChanged, onLocalStreamChanged);
|
||||
mediaHandler.stopUserMediaStream(stream);
|
||||
expect(onLocalStreamChanged).toHaveBeenCalled();
|
||||
|
||||
mediaHandler.off(MediaHandlerEvent.LocalStreamsChanged, onLocalStreamChanged);
|
||||
});
|
||||
});
|
||||
|
||||
describe("stopUserMediaStream", () => {
|
||||
let stream: MediaStream;
|
||||
|
||||
beforeEach(async () => {
|
||||
stream = await mediaHandler.getScreensharingStream();
|
||||
});
|
||||
|
||||
it("stops tracks on streams", async () => {
|
||||
const mockTrack = new MockMediaStreamTrack("audio_track", "audio");
|
||||
stream.addTrack(mockTrack.typed());
|
||||
|
||||
mediaHandler.stopScreensharingStream(stream);
|
||||
expect(mockTrack.stop).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("removes stopped streams", async () => {
|
||||
expect(mediaHandler.screensharingStreams).toContain(stream);
|
||||
mediaHandler.stopScreensharingStream(stream);
|
||||
expect(mediaHandler.screensharingStreams).not.toContain(stream);
|
||||
});
|
||||
|
||||
it("emits LocalStreamsChanged", async () => {
|
||||
const onLocalStreamChanged = jest.fn();
|
||||
mediaHandler.on(MediaHandlerEvent.LocalStreamsChanged, onLocalStreamChanged);
|
||||
mediaHandler.stopScreensharingStream(stream);
|
||||
expect(onLocalStreamChanged).toHaveBeenCalled();
|
||||
|
||||
mediaHandler.off(MediaHandlerEvent.LocalStreamsChanged, onLocalStreamChanged);
|
||||
});
|
||||
});
|
||||
|
||||
describe("stopAllStreams", () => {
|
||||
let userMediaStream: MediaStream;
|
||||
let screenSharingStream: MediaStream;
|
||||
|
||||
beforeEach(async () => {
|
||||
userMediaStream = await mediaHandler.getUserMediaStream(true, false);
|
||||
screenSharingStream = await mediaHandler.getScreensharingStream();
|
||||
});
|
||||
|
||||
it("stops tracks on streams", async () => {
|
||||
const mockUserMediaTrack = new MockMediaStreamTrack("audio_track", "audio");
|
||||
userMediaStream.addTrack(mockUserMediaTrack.typed());
|
||||
|
||||
const mockScreenshareTrack = new MockMediaStreamTrack("audio_track", "audio");
|
||||
screenSharingStream.addTrack(mockScreenshareTrack.typed());
|
||||
|
||||
mediaHandler.stopAllStreams();
|
||||
|
||||
expect(mockUserMediaTrack.stop).toHaveBeenCalled();
|
||||
expect(mockScreenshareTrack.stop).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("removes stopped streams", async () => {
|
||||
expect(mediaHandler.userMediaStreams).toContain(userMediaStream);
|
||||
expect(mediaHandler.screensharingStreams).toContain(screenSharingStream);
|
||||
mediaHandler.stopAllStreams();
|
||||
expect(mediaHandler.userMediaStreams).not.toContain(userMediaStream);
|
||||
expect(mediaHandler.screensharingStreams).not.toContain(screenSharingStream);
|
||||
});
|
||||
|
||||
it("emits LocalStreamsChanged", async () => {
|
||||
const onLocalStreamChanged = jest.fn();
|
||||
mediaHandler.on(MediaHandlerEvent.LocalStreamsChanged, onLocalStreamChanged);
|
||||
mediaHandler.stopAllStreams();
|
||||
expect(onLocalStreamChanged).toHaveBeenCalled();
|
||||
|
||||
mediaHandler.off(MediaHandlerEvent.LocalStreamsChanged, onLocalStreamChanged);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -30,7 +30,7 @@ export enum TweakName {
|
||||
|
||||
export type Tweak<N extends TweakName, V> = {
|
||||
set_tweak: N;
|
||||
value: V;
|
||||
value?: V;
|
||||
};
|
||||
|
||||
export type TweakHighlight = Tweak<TweakName.Highlight, boolean>;
|
||||
@@ -76,7 +76,8 @@ export interface IPushRuleCondition<N extends ConditionKind | string> {
|
||||
|
||||
export interface IEventMatchCondition extends IPushRuleCondition<ConditionKind.EventMatch> {
|
||||
key: string;
|
||||
pattern: string;
|
||||
pattern?: string;
|
||||
value?: string;
|
||||
}
|
||||
|
||||
export interface IContainsDisplayNameCondition extends IPushRuleCondition<ConditionKind.ContainsDisplayName> {
|
||||
@@ -168,8 +169,8 @@ export interface IPusher {
|
||||
lang: string;
|
||||
profile_tag?: string;
|
||||
pushkey: string;
|
||||
enabled?: boolean | null | undefined;
|
||||
"org.matrix.msc3881.enabled"?: boolean | null | undefined;
|
||||
enabled?: boolean | null;
|
||||
"org.matrix.msc3881.enabled"?: boolean | null;
|
||||
device_id?: string | null;
|
||||
"org.matrix.msc3881.device_id"?: string | null;
|
||||
}
|
||||
|
||||
@@ -87,6 +87,10 @@ export enum EventType {
|
||||
RoomKeyRequest = "m.room_key_request",
|
||||
ForwardedRoomKey = "m.forwarded_room_key",
|
||||
Dummy = "m.dummy",
|
||||
|
||||
// Group call events
|
||||
GroupCallPrefix = "org.matrix.msc3401.call",
|
||||
GroupCallMemberPrefix = "org.matrix.msc3401.call.member",
|
||||
}
|
||||
|
||||
export enum RelationType {
|
||||
|
||||
@@ -114,5 +114,6 @@ export interface ISearchResults {
|
||||
count?: number;
|
||||
next_batch?: string;
|
||||
pendingRequest?: Promise<ISearchResults>;
|
||||
abortSignal?: AbortSignal;
|
||||
}
|
||||
/* eslint-enable camelcase */
|
||||
|
||||
+48
-32
@@ -32,6 +32,28 @@ export enum AutoDiscoveryAction {
|
||||
FAIL_ERROR = "FAIL_ERROR",
|
||||
}
|
||||
|
||||
enum AutoDiscoveryError {
|
||||
Invalid = "Invalid homeserver discovery response",
|
||||
GenericFailure = "Failed to get autodiscovery configuration from server",
|
||||
InvalidHsBaseUrl = "Invalid base_url for m.homeserver",
|
||||
InvalidHomeserver = "Homeserver URL does not appear to be a valid Matrix homeserver",
|
||||
InvalidIsBaseUrl = "Invalid base_url for m.identity_server",
|
||||
InvalidIdentityServer = "Identity server URL does not appear to be a valid identity server",
|
||||
InvalidIs = "Invalid identity server discovery response",
|
||||
MissingWellknown = "No .well-known JSON file found",
|
||||
InvalidJson = "Invalid JSON",
|
||||
}
|
||||
|
||||
interface WellKnownConfig extends Omit<IWellKnownConfig, "error"> {
|
||||
state: AutoDiscoveryAction;
|
||||
error?: IWellKnownConfig["error"] | null;
|
||||
}
|
||||
|
||||
interface ClientConfig {
|
||||
"m.homeserver": WellKnownConfig;
|
||||
"m.identity_server": WellKnownConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Utilities for automatically discovery resources, such as homeservers
|
||||
* for users to log in to.
|
||||
@@ -42,36 +64,25 @@ export class AutoDiscovery {
|
||||
// translate the meaning of the states in the spec, but also
|
||||
// support our own if needed.
|
||||
|
||||
public static readonly ERROR_INVALID = "Invalid homeserver discovery response";
|
||||
public static readonly ERROR_INVALID = AutoDiscoveryError.Invalid;
|
||||
|
||||
public static readonly ERROR_GENERIC_FAILURE = "Failed to get autodiscovery configuration from server";
|
||||
public static readonly ERROR_GENERIC_FAILURE = AutoDiscoveryError.GenericFailure;
|
||||
|
||||
public static readonly ERROR_INVALID_HS_BASE_URL = "Invalid base_url for m.homeserver";
|
||||
public static readonly ERROR_INVALID_HS_BASE_URL = AutoDiscoveryError.InvalidHsBaseUrl;
|
||||
|
||||
public static readonly ERROR_INVALID_HOMESERVER = "Homeserver URL does not appear to be a valid Matrix homeserver";
|
||||
public static readonly ERROR_INVALID_HOMESERVER = AutoDiscoveryError.InvalidHomeserver;
|
||||
|
||||
public static readonly ERROR_INVALID_IS_BASE_URL = "Invalid base_url for m.identity_server";
|
||||
public static readonly ERROR_INVALID_IS_BASE_URL = AutoDiscoveryError.InvalidIsBaseUrl;
|
||||
|
||||
// eslint-disable-next-line
|
||||
public static readonly ERROR_INVALID_IDENTITY_SERVER = "Identity server URL does not appear to be a valid identity server";
|
||||
public static readonly ERROR_INVALID_IDENTITY_SERVER = AutoDiscoveryError.InvalidIdentityServer;
|
||||
|
||||
public static readonly ERROR_INVALID_IS = "Invalid identity server discovery response";
|
||||
public static readonly ERROR_INVALID_IS = AutoDiscoveryError.InvalidIs;
|
||||
|
||||
public static readonly ERROR_MISSING_WELLKNOWN = "No .well-known JSON file found";
|
||||
public static readonly ERROR_MISSING_WELLKNOWN = AutoDiscoveryError.MissingWellknown;
|
||||
|
||||
public static readonly ERROR_INVALID_JSON = "Invalid JSON";
|
||||
public static readonly ERROR_INVALID_JSON = AutoDiscoveryError.InvalidJson;
|
||||
|
||||
public static readonly ALL_ERRORS = [
|
||||
AutoDiscovery.ERROR_INVALID,
|
||||
AutoDiscovery.ERROR_GENERIC_FAILURE,
|
||||
AutoDiscovery.ERROR_INVALID_HS_BASE_URL,
|
||||
AutoDiscovery.ERROR_INVALID_HOMESERVER,
|
||||
AutoDiscovery.ERROR_INVALID_IS_BASE_URL,
|
||||
AutoDiscovery.ERROR_INVALID_IDENTITY_SERVER,
|
||||
AutoDiscovery.ERROR_INVALID_IS,
|
||||
AutoDiscovery.ERROR_MISSING_WELLKNOWN,
|
||||
AutoDiscovery.ERROR_INVALID_JSON,
|
||||
];
|
||||
public static readonly ALL_ERRORS = Object.keys(AutoDiscoveryError);
|
||||
|
||||
/**
|
||||
* The auto discovery failed. The client is expected to communicate
|
||||
@@ -120,13 +131,13 @@ export class AutoDiscovery {
|
||||
* configuration, which may include error states. Rejects on unexpected
|
||||
* failure, not when verification fails.
|
||||
*/
|
||||
public static async fromDiscoveryConfig(wellknown: any): Promise<IClientWellKnown> {
|
||||
public static async fromDiscoveryConfig(wellknown: any): Promise<ClientConfig> {
|
||||
// Step 1 is to get the config, which is provided to us here.
|
||||
|
||||
// We default to an error state to make the first few checks easier to
|
||||
// write. We'll update the properties of this object over the duration
|
||||
// of this function.
|
||||
const clientConfig = {
|
||||
const clientConfig: ClientConfig = {
|
||||
"m.homeserver": {
|
||||
state: AutoDiscovery.FAIL_ERROR,
|
||||
error: AutoDiscovery.ERROR_INVALID,
|
||||
@@ -197,7 +208,7 @@ export class AutoDiscovery {
|
||||
if (wellknown["m.identity_server"]) {
|
||||
// We prepare a failing identity server response to save lines later
|
||||
// in this branch.
|
||||
const failingClientConfig = {
|
||||
const failingClientConfig: ClientConfig = {
|
||||
"m.homeserver": clientConfig["m.homeserver"],
|
||||
"m.identity_server": {
|
||||
state: AutoDiscovery.FAIL_PROMPT,
|
||||
@@ -279,7 +290,7 @@ export class AutoDiscovery {
|
||||
* configuration, which may include error states. Rejects on unexpected
|
||||
* failure, not when discovery fails.
|
||||
*/
|
||||
public static async findClientConfig(domain: string): Promise<IClientWellKnown> {
|
||||
public static async findClientConfig(domain: string): Promise<ClientConfig> {
|
||||
if (!domain || typeof(domain) !== "string" || domain.length === 0) {
|
||||
throw new Error("'domain' must be a string of non-zero length");
|
||||
}
|
||||
@@ -298,7 +309,7 @@ export class AutoDiscovery {
|
||||
// We default to an error state to make the first few checks easier to
|
||||
// write. We'll update the properties of this object over the duration
|
||||
// of this function.
|
||||
const clientConfig = {
|
||||
const clientConfig: ClientConfig = {
|
||||
"m.homeserver": {
|
||||
state: AutoDiscovery.FAIL_ERROR,
|
||||
error: AutoDiscovery.ERROR_INVALID,
|
||||
@@ -367,18 +378,18 @@ export class AutoDiscovery {
|
||||
* @return {string|boolean} The sanitized URL or a falsey value if the URL is invalid.
|
||||
* @private
|
||||
*/
|
||||
private static sanitizeWellKnownUrl(url: string): string | boolean {
|
||||
private static sanitizeWellKnownUrl(url: string): string | false {
|
||||
if (!url) return false;
|
||||
|
||||
try {
|
||||
let parsed = null;
|
||||
let parsed: URL | undefined;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch (e) {
|
||||
logger.error("Could not parse url", e);
|
||||
}
|
||||
|
||||
if (!parsed || !parsed.hostname) return false;
|
||||
if (!parsed?.hostname) return false;
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false;
|
||||
|
||||
const port = parsed.port ? `:${parsed.port}` : "";
|
||||
@@ -448,12 +459,17 @@ export class AutoDiscovery {
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
const error = err as Error | string | undefined;
|
||||
const error = err as AutoDiscoveryError | string | undefined;
|
||||
let reason = "";
|
||||
if (typeof error === "object") {
|
||||
reason = (<Error>error)?.message;
|
||||
}
|
||||
|
||||
return {
|
||||
error,
|
||||
raw: {},
|
||||
action: AutoDiscoveryAction.FAIL_PROMPT,
|
||||
reason: (<Error>error)?.message || "General failure",
|
||||
reason: reason || "General failure",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -463,7 +479,7 @@ export class AutoDiscovery {
|
||||
action: AutoDiscoveryAction.SUCCESS,
|
||||
};
|
||||
} catch (err) {
|
||||
const error = err as Error | string | undefined;
|
||||
const error = err as Error;
|
||||
return {
|
||||
error,
|
||||
raw: {},
|
||||
|
||||
+192
-88
@@ -35,6 +35,7 @@ import { StubStore } from "./store/stub";
|
||||
import { CallEvent, CallEventHandlerMap, createNewMatrixCall, MatrixCall, supportsMatrixCall } from "./webrtc/call";
|
||||
import { Filter, IFilterDefinition, IRoomEventFilter } from "./filter";
|
||||
import { CallEventHandlerEvent, CallEventHandler, CallEventHandlerEventHandlerMap } from './webrtc/callEventHandler';
|
||||
import { GroupCallEventHandlerEvent, GroupCallEventHandlerEventHandlerMap } from './webrtc/groupCallEventHandler';
|
||||
import * as utils from './utils';
|
||||
import { replaceParam, QueryDict, sleep } from './utils';
|
||||
import { Direction, EventTimeline } from "./models/event-timeline";
|
||||
@@ -64,12 +65,14 @@ import {
|
||||
FileType,
|
||||
UploadResponse,
|
||||
HTTPError,
|
||||
IRequestOpts,
|
||||
} from "./http-api";
|
||||
import {
|
||||
Crypto,
|
||||
CryptoEvent,
|
||||
CryptoEventHandlerMap,
|
||||
fixBackupKey,
|
||||
ICryptoCallbacks,
|
||||
IBootstrapCrossSigningOpts,
|
||||
ICheckOwnCrossSigningTrustOpts,
|
||||
IMegolmSessionData,
|
||||
@@ -99,29 +102,9 @@ import {
|
||||
} from "./crypto/keybackup";
|
||||
import { IIdentityServerProvider } from "./@types/IIdentityServerProvider";
|
||||
import { MatrixScheduler } from "./scheduler";
|
||||
import {
|
||||
IAuthData,
|
||||
ICryptoCallbacks,
|
||||
IMinimalEvent,
|
||||
IRoomEvent,
|
||||
IStateEvent,
|
||||
NotificationCountType,
|
||||
BeaconEvent,
|
||||
BeaconEventHandlerMap,
|
||||
RoomEvent,
|
||||
RoomEventHandlerMap,
|
||||
RoomMemberEvent,
|
||||
RoomMemberEventHandlerMap,
|
||||
RoomStateEvent,
|
||||
RoomStateEventHandlerMap,
|
||||
INotificationsResponse,
|
||||
IFilterResponse,
|
||||
ITagsResponse,
|
||||
IStatusResponse,
|
||||
IPushRule,
|
||||
PushRuleActionName,
|
||||
IAuthDict,
|
||||
} from "./matrix";
|
||||
import { BeaconEvent, BeaconEventHandlerMap } from "./models/beacon";
|
||||
import { IAuthData, IAuthDict } from "./interactive-auth";
|
||||
import { IMinimalEvent, IRoomEvent, IStateEvent } from "./sync-accumulator";
|
||||
import {
|
||||
CrossSigningKey,
|
||||
IAddSecretStorageKeyOpts,
|
||||
@@ -136,7 +119,9 @@ import { VerificationRequest } from "./crypto/verification/request/VerificationR
|
||||
import { VerificationBase as Verification } from "./crypto/verification/Base";
|
||||
import * as ContentHelpers from "./content-helpers";
|
||||
import { CrossSigningInfo, DeviceTrustLevel, ICacheCallbacks, UserTrustLevel } from "./crypto/CrossSigning";
|
||||
import { Room, RoomNameState } from "./models/room";
|
||||
import { Room, NotificationCountType, RoomEvent, RoomEventHandlerMap, RoomNameState } from "./models/room";
|
||||
import { RoomMemberEvent, RoomMemberEventHandlerMap } from "./models/room-member";
|
||||
import { RoomStateEvent, RoomStateEventHandlerMap } from "./models/room-state";
|
||||
import {
|
||||
IAddThreePidOnlyBody,
|
||||
IBindThreePidBody,
|
||||
@@ -153,6 +138,10 @@ import {
|
||||
IRoomDirectoryOptions,
|
||||
ISearchOpts,
|
||||
ISendEventResponse,
|
||||
INotificationsResponse,
|
||||
IFilterResponse,
|
||||
ITagsResponse,
|
||||
IStatusResponse,
|
||||
} from "./@types/requests";
|
||||
import {
|
||||
EventType,
|
||||
@@ -184,10 +173,26 @@ import {
|
||||
} from "./@types/search";
|
||||
import { ISynapseAdminDeactivateResponse, ISynapseAdminWhoisResponse } from "./@types/synapse";
|
||||
import { IHierarchyRoom } from "./@types/spaces";
|
||||
import { IPusher, IPusherRequest, IPushRules, PushRuleAction, PushRuleKind, RuleId } from "./@types/PushRules";
|
||||
import {
|
||||
IPusher,
|
||||
IPusherRequest,
|
||||
IPushRule,
|
||||
IPushRules,
|
||||
PushRuleAction,
|
||||
PushRuleActionName,
|
||||
PushRuleKind,
|
||||
RuleId,
|
||||
} from "./@types/PushRules";
|
||||
import { IThreepid } from "./@types/threepids";
|
||||
import { CryptoStore } from "./crypto/store/base";
|
||||
import {
|
||||
GroupCall,
|
||||
IGroupCallDataChannelOptions,
|
||||
GroupCallIntent,
|
||||
GroupCallType,
|
||||
} from "./webrtc/groupCall";
|
||||
import { MediaHandler } from "./webrtc/mediaHandler";
|
||||
import { GroupCallEventHandler } from "./webrtc/groupCallEventHandler";
|
||||
import { LoginTokenPostResponse, ILoginFlowsResponse, IRefreshTokenResponse, SSOAction } from "./@types/auth";
|
||||
import { TypedEventEmitter } from "./models/typed-event-emitter";
|
||||
import { ReceiptType } from "./@types/read_receipts";
|
||||
@@ -359,6 +364,12 @@ export interface ICreateClientOpts {
|
||||
*/
|
||||
fallbackICEServerAllowed?: boolean;
|
||||
|
||||
/**
|
||||
* If true, to-device signalling for group calls will be encrypted
|
||||
* with Olm. Default: true.
|
||||
*/
|
||||
useE2eForGroupCall?: boolean;
|
||||
|
||||
cryptoCallbacks?: ICryptoCallbacks;
|
||||
|
||||
/**
|
||||
@@ -507,7 +518,7 @@ export interface IUploadKeySignaturesResponse {
|
||||
}
|
||||
|
||||
export interface IPreviewUrlResponse {
|
||||
[key: string]: string | number;
|
||||
[key: string]: undefined | string | number;
|
||||
"og:title": string;
|
||||
"og:type": string;
|
||||
"og:url": string;
|
||||
@@ -705,8 +716,9 @@ export interface IMyDevice {
|
||||
display_name?: string;
|
||||
last_seen_ip?: string;
|
||||
last_seen_ts?: number;
|
||||
[UNSTABLE_MSC3852_LAST_SEEN_UA.stable]?: string;
|
||||
[UNSTABLE_MSC3852_LAST_SEEN_UA.unstable]?: string;
|
||||
// UNSTABLE_MSC3852_LAST_SEEN_UA
|
||||
last_seen_user_agent?: string;
|
||||
"org.matrix.msc3852.last_seen_user_agent"?: string;
|
||||
}
|
||||
|
||||
export interface Keys {
|
||||
@@ -824,6 +836,7 @@ export enum ClientEvent {
|
||||
DeleteRoom = "deleteRoom",
|
||||
SyncUnexpectedError = "sync.unexpectedError",
|
||||
ClientWellKnown = "WellKnown.client",
|
||||
ReceivedVoipEvent = "received_voip_event",
|
||||
TurnServers = "turnServers",
|
||||
TurnServersError = "turnServers.error",
|
||||
}
|
||||
@@ -883,6 +896,9 @@ export type EmittedEvents = ClientEvent
|
||||
| UserEvents
|
||||
| CallEvent // re-emitted by call.ts using Object.values
|
||||
| CallEventHandlerEvent.Incoming
|
||||
| GroupCallEventHandlerEvent.Incoming
|
||||
| GroupCallEventHandlerEvent.Ended
|
||||
| GroupCallEventHandlerEvent.Participants
|
||||
| HttpApiEvent.SessionLoggedOut
|
||||
| HttpApiEvent.NoConsent
|
||||
| BeaconEvent;
|
||||
@@ -896,6 +912,7 @@ export type ClientEventHandlerMap = {
|
||||
[ClientEvent.DeleteRoom]: (roomId: string) => void;
|
||||
[ClientEvent.SyncUnexpectedError]: (error: Error) => void;
|
||||
[ClientEvent.ClientWellKnown]: (data: IClientWellKnown) => void;
|
||||
[ClientEvent.ReceivedVoipEvent]: (event: MatrixEvent) => void;
|
||||
[ClientEvent.TurnServers]: (servers: ITurnServer[]) => void;
|
||||
[ClientEvent.TurnServersError]: (error: Error, fatal: boolean) => void;
|
||||
} & RoomEventHandlerMap
|
||||
@@ -905,6 +922,7 @@ export type ClientEventHandlerMap = {
|
||||
& RoomMemberEventHandlerMap
|
||||
& UserEventHandlerMap
|
||||
& CallEventHandlerEventHandlerMap
|
||||
& GroupCallEventHandlerEventHandlerMap
|
||||
& CallEventHandlerMap
|
||||
& HttpApiEventHandlerMap
|
||||
& BeaconEventHandlerMap;
|
||||
@@ -935,6 +953,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
public crypto?: Crypto; // XXX: Intended private, used in code.
|
||||
public cryptoCallbacks: ICryptoCallbacks; // XXX: Intended private, used in code.
|
||||
public callEventHandler?: CallEventHandler; // XXX: Intended private, used in code.
|
||||
public groupCallEventHandler?: GroupCallEventHandler;
|
||||
public supportsCallTransfer = false; // XXX: Intended private, used in code.
|
||||
public forceTURN = false; // XXX: Intended private, used in code.
|
||||
public iceCandidatePoolSize = 0; // XXX: Intended private, used in code.
|
||||
@@ -983,8 +1002,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
protected exportedOlmDeviceToImport?: IExportedOlmDevice;
|
||||
protected txnCtr = 0;
|
||||
protected mediaHandler = new MediaHandler(this);
|
||||
protected sessionId: string;
|
||||
protected pendingEventEncryption = new Map<string, Promise<void>>();
|
||||
|
||||
private useE2eForGroupCall = true;
|
||||
private toDeviceMessageQueue: ToDeviceMessageQueue;
|
||||
|
||||
// A manager for determining which invites should be ignored.
|
||||
@@ -1003,6 +1024,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
this.usingExternalCrypto = opts.usingExternalCrypto ?? false;
|
||||
this.store = opts.store || new StubStore();
|
||||
this.deviceId = opts.deviceId || null;
|
||||
this.sessionId = randomString(10);
|
||||
|
||||
const userId = opts.userId || null;
|
||||
this.credentials = { userId };
|
||||
@@ -1061,6 +1083,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
|
||||
if (supportsMatrixCall()) {
|
||||
this.callEventHandler = new CallEventHandler(this);
|
||||
this.groupCallEventHandler = new GroupCallEventHandler(this);
|
||||
this.canSupportVoip = true;
|
||||
// Start listening for calls after the initial sync is done
|
||||
// We do not need to backfill the call event buffer
|
||||
@@ -1079,6 +1102,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
this.supportsCallTransfer = opts.supportsCallTransfer || false;
|
||||
this.fallbackICEServerAllowed = opts.fallbackICEServerAllowed || false;
|
||||
|
||||
if (opts.useE2eForGroupCall !== undefined) this.useE2eForGroupCall = opts.useE2eForGroupCall;
|
||||
|
||||
// List of which rooms have encryption enabled: separate from crypto because
|
||||
// we still want to know which rooms are encrypted even if crypto is disabled:
|
||||
// we don't want to start sending unencrypted events to them.
|
||||
@@ -1175,7 +1200,6 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
|
||||
if (this.crypto) {
|
||||
this.crypto.uploadDeviceKeys();
|
||||
this.crypto.start();
|
||||
}
|
||||
|
||||
// periodically poll for turn servers if we support voip
|
||||
@@ -1251,7 +1275,9 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
this.peekSync?.stopPeeking();
|
||||
|
||||
this.callEventHandler?.stop();
|
||||
this.groupCallEventHandler?.stop();
|
||||
this.callEventHandler = undefined;
|
||||
this.groupCallEventHandler = undefined;
|
||||
|
||||
global.clearInterval(this.checkTurnServersIntervalID);
|
||||
this.checkTurnServersIntervalID = undefined;
|
||||
@@ -1482,6 +1508,14 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
return this.deviceId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the session ID of this client
|
||||
* @return {string} session ID
|
||||
*/
|
||||
public getSessionId(): string {
|
||||
return this.sessionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the runtime environment supports VoIP calling.
|
||||
* @return {boolean} True if VoIP is supported.
|
||||
@@ -1515,6 +1549,15 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
this.supportsCallTransfer = support;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if to-device signalling for group calls will be encrypted with Olm.
|
||||
* If false, it will be sent unencrypted.
|
||||
* @returns boolean Whether group call signalling will be encrypted
|
||||
*/
|
||||
public getUseE2eForGroupCall(): boolean {
|
||||
return this.useE2eForGroupCall;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new call.
|
||||
* The place*Call methods on the returned call can be used to actually place a call
|
||||
@@ -1526,6 +1569,67 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
return createNewMatrixCall(this, roomId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new group call and sends the associated state event
|
||||
* to alert other members that the room now has a group call.
|
||||
*
|
||||
* @param {string} roomId The room the call is to be placed in.
|
||||
* @return {GroupCall}
|
||||
*/
|
||||
public async createGroupCall(
|
||||
roomId: string,
|
||||
type: GroupCallType,
|
||||
isPtt: boolean,
|
||||
intent: GroupCallIntent,
|
||||
dataChannelsEnabled?: boolean,
|
||||
dataChannelOptions?: IGroupCallDataChannelOptions,
|
||||
): Promise<GroupCall> {
|
||||
if (this.getGroupCallForRoom(roomId)) {
|
||||
throw new Error(`${roomId} already has an existing group call`);
|
||||
}
|
||||
|
||||
const room = this.getRoom(roomId);
|
||||
|
||||
if (!room) {
|
||||
throw new Error(`Cannot find room ${roomId}`);
|
||||
}
|
||||
|
||||
return new GroupCall(
|
||||
this,
|
||||
room,
|
||||
type,
|
||||
isPtt,
|
||||
intent,
|
||||
undefined,
|
||||
dataChannelsEnabled,
|
||||
dataChannelOptions,
|
||||
).create();
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait until an initial state for the given room has been processed by the
|
||||
* client and the client is aware of any ongoing group calls. Awaiting on
|
||||
* the promise returned by this method before calling getGroupCallForRoom()
|
||||
* avoids races where getGroupCallForRoom is called before the state for that
|
||||
* room has been processed. It does not, however, fix other races, eg. two
|
||||
* clients both creating a group call at the same time.
|
||||
* @param roomId The room ID to wait for
|
||||
* @returns A promise that resolves once existing group calls in the room
|
||||
* have been processed.
|
||||
*/
|
||||
public waitUntilRoomReadyForGroupCalls(roomId: string): Promise<void> {
|
||||
return this.groupCallEventHandler!.waitUntilRoomReadyForGroupCalls(roomId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an existing group call for the provided room.
|
||||
* @param roomId
|
||||
* @returns {GroupCall} The group call or null if it doesn't already exist.
|
||||
*/
|
||||
public getGroupCallForRoom(roomId: string): GroupCall | null {
|
||||
return this.groupCallEventHandler!.groupCalls.get(roomId) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current sync state.
|
||||
* @return {?SyncState} the sync state, which may be null.
|
||||
@@ -2003,11 +2107,12 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
*
|
||||
* @param {boolean} value whether to blacklist all unverified devices by default
|
||||
*/
|
||||
public setGlobalBlacklistUnverifiedDevices(value: boolean) {
|
||||
public setGlobalBlacklistUnverifiedDevices(value: boolean): boolean {
|
||||
if (!this.crypto) {
|
||||
throw new Error("End-to-end encryption disabled");
|
||||
}
|
||||
return this.crypto.setGlobalBlacklistUnverifiedDevices(value);
|
||||
this.crypto.globalBlacklistUnverifiedDevices = value;
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2017,7 +2122,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
if (!this.crypto) {
|
||||
throw new Error("End-to-end encryption disabled");
|
||||
}
|
||||
return this.crypto.getGlobalBlacklistUnverifiedDevices();
|
||||
return this.crypto.globalBlacklistUnverifiedDevices;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2030,11 +2135,11 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
*
|
||||
* @param {boolean} value whether error on unknown devices
|
||||
*/
|
||||
public setGlobalErrorOnUnknownDevices(value: boolean) {
|
||||
public setGlobalErrorOnUnknownDevices(value: boolean): void {
|
||||
if (!this.crypto) {
|
||||
throw new Error("End-to-end encryption disabled");
|
||||
}
|
||||
return this.crypto.setGlobalErrorOnUnknownDevices(value);
|
||||
this.crypto.globalErrorOnUnknownDevices = value;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2046,7 +2151,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
if (!this.crypto) {
|
||||
throw new Error("End-to-end encryption disabled");
|
||||
}
|
||||
return this.crypto.getGlobalErrorOnUnknownDevices();
|
||||
return this.crypto.globalErrorOnUnknownDevices;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2929,7 +3034,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
throw new Error("End-to-end encryption disabled");
|
||||
}
|
||||
|
||||
const path = this.makeKeyBackupPath(roomId!, sessionId!, version!);
|
||||
const path = this.makeKeyBackupPath(roomId!, sessionId!, version);
|
||||
await this.http.authedRequest(
|
||||
Method.Put, path.path, path.queryData, data,
|
||||
{ prefix: ClientPrefix.V3 },
|
||||
@@ -3121,7 +3226,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
opts: IKeyBackupRestoreOpts,
|
||||
): Promise<IKeyBackupRestoreResult> {
|
||||
const privKey = decodeRecoveryKey(recoveryKey);
|
||||
return this.restoreKeyBackup(privKey, targetRoomId, targetSessionId, backupInfo, opts);
|
||||
return this.restoreKeyBackup(privKey, targetRoomId!, targetSessionId!, backupInfo, opts);
|
||||
}
|
||||
|
||||
public async restoreKeyBackupWithCache(
|
||||
@@ -3283,7 +3388,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
throw new Error("End-to-end encryption disabled");
|
||||
}
|
||||
|
||||
const path = this.makeKeyBackupPath(roomId, sessionId, version);
|
||||
const path = this.makeKeyBackupPath(roomId!, sessionId!, version);
|
||||
await this.http.authedRequest(
|
||||
Method.Delete, path.path, path.queryData, undefined,
|
||||
{ prefix: ClientPrefix.V3 },
|
||||
@@ -3775,7 +3880,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
roomId: string,
|
||||
threadId: string | null,
|
||||
eventType: string | IContent,
|
||||
content: IContent | string,
|
||||
content?: IContent | string,
|
||||
txnId?: string,
|
||||
): Promise<ISendEventResponse> {
|
||||
if (!threadId?.startsWith(EVENT_ID_PREFIX) && threadId !== null) {
|
||||
@@ -3787,10 +3892,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
|
||||
// If we expect that an event is part of a thread but is missing the relation
|
||||
// we need to add it manually, as well as the reply fallback
|
||||
if (threadId && !content["m.relates_to"]?.rel_type) {
|
||||
const isReply = !!content["m.relates_to"]?.["m.in_reply_to"];
|
||||
content["m.relates_to"] = {
|
||||
...content["m.relates_to"],
|
||||
if (threadId && !content!["m.relates_to"]?.rel_type) {
|
||||
const isReply = !!content!["m.relates_to"]?.["m.in_reply_to"];
|
||||
content!["m.relates_to"] = {
|
||||
...content!["m.relates_to"],
|
||||
"rel_type": THREAD_RELATION_TYPE.name,
|
||||
"event_id": threadId,
|
||||
// Set is_falling_back to true unless this is actually intended to be a reply
|
||||
@@ -3798,7 +3903,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
};
|
||||
const thread = this.getRoom(roomId)?.getThread(threadId);
|
||||
if (thread && !isReply) {
|
||||
content["m.relates_to"]["m.in_reply_to"] = {
|
||||
content!["m.relates_to"]["m.in_reply_to"] = {
|
||||
"event_id": thread.lastReply((ev: MatrixEvent) => {
|
||||
return ev.isRelation(THREAD_RELATION_TYPE.name) && !ev.status;
|
||||
})?.getId() ?? threadId,
|
||||
@@ -3888,9 +3993,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* @param room
|
||||
* @param event
|
||||
* @returns {Promise} returns a promise which resolves with the result of the send request
|
||||
* @private
|
||||
*/
|
||||
private encryptAndSendEvent(room: Room | null, event: MatrixEvent): Promise<ISendEventResponse> {
|
||||
protected encryptAndSendEvent(room: Room | null, event: MatrixEvent): Promise<ISendEventResponse> {
|
||||
let cancelled = false;
|
||||
// Add an extra Promise.resolve() to turn synchronous exceptions into promise rejections,
|
||||
// so that we can handle synchronous and asynchronous exceptions with the
|
||||
@@ -4018,7 +4122,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
return this.isRoomEncrypted(roomId) ? EventType.RoomMessageEncrypted : eventType;
|
||||
}
|
||||
|
||||
private updatePendingEventStatus(room: Room | null, event: MatrixEvent, newStatus: EventStatus) {
|
||||
protected updatePendingEventStatus(room: Room | null, event: MatrixEvent, newStatus: EventStatus) {
|
||||
if (room) {
|
||||
room.updatePendingEvent(event, newStatus);
|
||||
} else {
|
||||
@@ -4097,7 +4201,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
if (!eventId?.startsWith(EVENT_ID_PREFIX)) {
|
||||
opts = txnId as IRedactOpts;
|
||||
txnId = eventId;
|
||||
eventId = threadId;
|
||||
eventId = threadId!;
|
||||
threadId = null;
|
||||
}
|
||||
const reason = opts?.reason;
|
||||
@@ -4130,7 +4234,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
public sendMessage(
|
||||
roomId: string,
|
||||
threadId: string | null | IContent,
|
||||
content: IContent | string,
|
||||
content?: IContent | string,
|
||||
txnId?: string,
|
||||
): Promise<ISendEventResponse> {
|
||||
if (typeof threadId !== "string" && threadId !== null) {
|
||||
@@ -4180,7 +4284,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
|
||||
return this.sendEvent(
|
||||
roomId,
|
||||
threadId as (string | null),
|
||||
threadId as string | null,
|
||||
eventType,
|
||||
sendContent,
|
||||
txnId,
|
||||
@@ -4314,7 +4418,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
public sendImageMessage(
|
||||
roomId: string,
|
||||
threadId: string | null,
|
||||
url: string | IImageInfo,
|
||||
url?: string | IImageInfo,
|
||||
info?: IImageInfo | string,
|
||||
text = "Image",
|
||||
): Promise<ISendEventResponse> {
|
||||
@@ -4358,7 +4462,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
public sendStickerMessage(
|
||||
roomId: string,
|
||||
threadId: string | null,
|
||||
url: string | IImageInfo,
|
||||
url?: string | IImageInfo,
|
||||
info?: IImageInfo | string,
|
||||
text = "Sticker",
|
||||
): Promise<ISendEventResponse> {
|
||||
@@ -4484,6 +4588,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* @param {ReceiptType} receiptType The kind of receipt e.g. "m.read". Other than
|
||||
* ReceiptType.Read are experimental!
|
||||
* @param {object} body Additional content to send alongside the receipt.
|
||||
* @param {boolean} unthreaded An unthreaded receipt will clear room+thread notifications
|
||||
* @return {Promise} Resolves: to an empty object {}
|
||||
* @return {module:http-api.MatrixError} Rejects: with an error response.
|
||||
*/
|
||||
@@ -4491,6 +4596,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
event: MatrixEvent,
|
||||
receiptType: ReceiptType,
|
||||
body: any,
|
||||
unthreaded = false,
|
||||
): Promise<{}> {
|
||||
if (this.isGuest()) {
|
||||
return Promise.resolve({}); // guests cannot send receipts so don't bother.
|
||||
@@ -4502,12 +4608,15 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
$eventId: event.getId()!,
|
||||
});
|
||||
|
||||
// TODO: Add a check for which spec version this will be released in
|
||||
if (await this.doesServerSupportUnstableFeature("org.matrix.msc3771")) {
|
||||
const supportsThreadRR = this.canSupport.get(Feature.ThreadUnreadNotifications) !== ServerSupport.Unsupported;
|
||||
if (supportsThreadRR && !unthreaded) {
|
||||
const isThread = !!event.threadRootId;
|
||||
body.thread_id = isThread
|
||||
? event.threadRootId
|
||||
: MAIN_ROOM_TIMELINE;
|
||||
body = {
|
||||
...body,
|
||||
thread_id: isThread
|
||||
? event.threadRootId
|
||||
: MAIN_ROOM_TIMELINE,
|
||||
};
|
||||
}
|
||||
|
||||
const promise = this.http.authedRequest<{}>(Method.Post, path, undefined, body || {});
|
||||
@@ -4529,6 +4638,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
public async sendReadReceipt(
|
||||
event: MatrixEvent | null,
|
||||
receiptType = ReceiptType.Read,
|
||||
unthreaded = false,
|
||||
): Promise<{} | undefined> {
|
||||
if (!event) return;
|
||||
const eventId = event.getId()!;
|
||||
@@ -4537,7 +4647,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
throw new Error(`Cannot set read receipt to a pending event (${eventId})`);
|
||||
}
|
||||
|
||||
return this.sendReceipt(event, receiptType, {});
|
||||
return this.sendReceipt(event, receiptType, {}, unthreaded);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -5224,13 +5334,13 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
];
|
||||
|
||||
// Here we handle non-thread timelines only, but still process any thread events to populate thread summaries.
|
||||
let timeline = timelineSet.getTimelineForEvent(events[0].getId()!);
|
||||
let timeline = timelineSet.getTimelineForEvent(events[0].getId());
|
||||
if (timeline) {
|
||||
timeline.getState(EventTimeline.BACKWARDS).setUnknownStateEvents(res.state.map(mapper));
|
||||
timeline.getState(EventTimeline.BACKWARDS)!.setUnknownStateEvents(res.state.map(mapper));
|
||||
} else {
|
||||
timeline = timelineSet.addTimeline();
|
||||
timeline.initialiseState(res.state.map(mapper));
|
||||
timeline.getState(EventTimeline.FORWARDS).paginationToken = res.end;
|
||||
timeline.getState(EventTimeline.FORWARDS)!.paginationToken = res.end;
|
||||
}
|
||||
|
||||
const [timelineEvents, threadedEvents] = timelineSet.room.partitionThreadedEvents(events);
|
||||
@@ -5319,7 +5429,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
// Here we handle non-thread timelines only, but still process any thread events to populate thread summaries.
|
||||
let timeline = timelineSet.getTimelineForEvent(event.getId());
|
||||
if (timeline) {
|
||||
timeline.getState(EventTimeline.BACKWARDS).setUnknownStateEvents(res.state.map(mapper));
|
||||
timeline.getState(EventTimeline.BACKWARDS)!.setUnknownStateEvents(res.state.map(mapper));
|
||||
} else {
|
||||
timeline = timelineSet.addTimeline();
|
||||
timeline.initialiseState(res.state.map(mapper));
|
||||
@@ -5380,7 +5490,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
// Here we handle non-thread timelines only, but still process any thread events to populate thread
|
||||
// summaries.
|
||||
const timeline = timelineSet.getLiveTimeline();
|
||||
timeline.getState(EventTimeline.BACKWARDS).setUnknownStateEvents(res.state.map(mapper));
|
||||
timeline.getState(EventTimeline.BACKWARDS)!.setUnknownStateEvents(res.state.map(mapper));
|
||||
|
||||
timelineSet.addEventsToTimeline(events, true, timeline, null);
|
||||
if (!resOlder.next_batch) {
|
||||
@@ -5683,7 +5793,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
eventTimeline.getFilter(),
|
||||
).then((res) => {
|
||||
if (res.state) {
|
||||
const roomState = eventTimeline.getState(dir);
|
||||
const roomState = eventTimeline.getState(dir)!;
|
||||
const stateEvents = res.state.map(this.getEventMapper());
|
||||
roomState.setUnknownStateEvents(stateEvents);
|
||||
}
|
||||
@@ -5758,7 +5868,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
eventTimeline.getFilter(),
|
||||
).then((res) => {
|
||||
if (res.state) {
|
||||
const roomState = eventTimeline.getState(dir);
|
||||
const roomState = eventTimeline.getState(dir)!;
|
||||
const stateEvents = res.state.map(this.getEventMapper());
|
||||
roomState.setUnknownStateEvents(stateEvents);
|
||||
}
|
||||
@@ -6114,15 +6224,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
// There can be only room-kind push rule per room
|
||||
// and its id is the room id.
|
||||
if (this.pushRules) {
|
||||
if (!this.pushRules[scope] || !this.pushRules[scope].room) {
|
||||
return;
|
||||
}
|
||||
for (let i = 0; i < this.pushRules[scope].room.length; i++) {
|
||||
const rule = this.pushRules[scope].room[i];
|
||||
if (rule.rule_id === roomId) {
|
||||
return rule;
|
||||
}
|
||||
}
|
||||
return this.pushRules[scope]?.room?.find(rule => rule.rule_id === roomId);
|
||||
} else {
|
||||
throw new Error(
|
||||
"SyncApi.sync() must be done before accessing to push rules.",
|
||||
@@ -6293,7 +6395,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
next_batch: searchResults.next_batch,
|
||||
};
|
||||
|
||||
const promise = this.search(searchOpts)
|
||||
const promise = this.search(searchOpts, searchResults.abortSignal)
|
||||
.then(res => this.processRoomEventsSearch(searchResults, res))
|
||||
.finally(() => {
|
||||
searchResults.pendingRequest = undefined;
|
||||
@@ -6472,8 +6574,6 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
// create a new filter
|
||||
const createdFilter = await this.createFilter(filter.getDefinition());
|
||||
|
||||
// debuglog("Created new filter ID %s: %s", createdFilter.filterId,
|
||||
// JSON.stringify(createdFilter.getDefinition()));
|
||||
this.store.setFilterIdByName(filterName, createdFilter.filterId);
|
||||
return createdFilter.filterId!;
|
||||
}
|
||||
@@ -6495,7 +6595,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
|
||||
private startCallEventHandler = (): void => {
|
||||
if (this.isInitialSyncComplete()) {
|
||||
this.callEventHandler?.start();
|
||||
this.callEventHandler!.start();
|
||||
this.groupCallEventHandler!.start();
|
||||
this.off(ClientEvent.Sync, this.startCallEventHandler);
|
||||
}
|
||||
};
|
||||
@@ -7640,6 +7741,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* @param {string} eventType
|
||||
* @param {Object} content
|
||||
* @param {string} stateKey
|
||||
* @param {IRequestOpts} opts Options for the request function.
|
||||
* @return {Promise} Resolves: TODO
|
||||
* @return {module:http-api.MatrixError} Rejects: with an error response.
|
||||
*/
|
||||
@@ -7648,6 +7750,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
eventType: string,
|
||||
content: any,
|
||||
stateKey = "",
|
||||
opts: IRequestOpts = {},
|
||||
): Promise<ISendEventResponse> {
|
||||
const pathParams = {
|
||||
$roomId: roomId,
|
||||
@@ -7658,7 +7761,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
if (stateKey !== undefined) {
|
||||
path = utils.encodeUri(path + "/$stateKey", pathParams);
|
||||
}
|
||||
return this.http.authedRequest(Method.Put, path, undefined, content);
|
||||
return this.http.authedRequest(Method.Put, path, undefined, content, opts);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -8342,17 +8445,19 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
* @param {Object} opts
|
||||
* @param {string} opts.next_batch the batch token to pass in the query string
|
||||
* @param {Object} opts.body the JSON object to pass to the request body.
|
||||
* @param {AbortSignal=} abortSignal optional signal used to cancel the http request.
|
||||
* @return {Promise} Resolves: TODO
|
||||
* @return {module:http-api.MatrixError} Rejects: with an error response.
|
||||
*/
|
||||
public search(
|
||||
opts: { body: ISearchRequestBody, next_batch?: string }, // eslint-disable-line camelcase
|
||||
abortSignal?: AbortSignal,
|
||||
): Promise<ISearchResponse> {
|
||||
const queryParams: any = {};
|
||||
if (opts.next_batch) {
|
||||
queryParams.next_batch = opts.next_batch;
|
||||
}
|
||||
return this.http.authedRequest(Method.Post, "/search", queryParams, opts.body);
|
||||
return this.http.authedRequest(Method.Post, "/search", queryParams, opts.body, { abortSignal });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -8433,9 +8538,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
keyAlgorithm = "signed_curve25519";
|
||||
}
|
||||
|
||||
for (let i = 0; i < devices.length; ++i) {
|
||||
const userId = devices[i][0];
|
||||
const deviceId = devices[i][1];
|
||||
for (const [userId, deviceId] of devices) {
|
||||
const query = queries[userId] || {};
|
||||
queries[userId] = query;
|
||||
query[deviceId] = keyAlgorithm;
|
||||
@@ -8654,11 +8757,12 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
||||
clientSecret: string,
|
||||
msisdnToken: string,
|
||||
): Promise<any> { // TODO: Types
|
||||
const u = new URL(url);
|
||||
u.searchParams.set("sid", sid);
|
||||
u.searchParams.set("client_secret", clientSecret);
|
||||
u.searchParams.set("token", msisdnToken);
|
||||
return this.http.requestOtherUrl(Method.Post, u);
|
||||
const params = {
|
||||
sid: sid,
|
||||
client_secret: clientSecret,
|
||||
token: msisdnToken,
|
||||
};
|
||||
return this.http.requestOtherUrl(Method.Post, url, params);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+14
-14
@@ -118,12 +118,12 @@ export function makeEmoteMessage(body: string) {
|
||||
/** Location content helpers */
|
||||
|
||||
export const getTextForLocationEvent = (
|
||||
uri: string,
|
||||
uri: string | undefined,
|
||||
assetType: LocationAssetType,
|
||||
timestamp: number,
|
||||
description?: string,
|
||||
timestamp?: number,
|
||||
description?: string | null,
|
||||
): string => {
|
||||
const date = `at ${new Date(timestamp).toISOString()}`;
|
||||
const date = `at ${new Date(timestamp!).toISOString()}`;
|
||||
const assetName = assetType === LocationAssetType.Self ? 'User' : undefined;
|
||||
const quotedDescription = description ? `"${description}"` : undefined;
|
||||
|
||||
@@ -147,10 +147,10 @@ export const getTextForLocationEvent = (
|
||||
export const makeLocationContent = (
|
||||
// this is first but optional
|
||||
// to avoid a breaking change
|
||||
text: string | undefined,
|
||||
uri: string,
|
||||
timestamp: number,
|
||||
description?: string,
|
||||
text?: string,
|
||||
uri?: string,
|
||||
timestamp?: number,
|
||||
description?: string | null,
|
||||
assetType?: LocationAssetType,
|
||||
): LegacyLocationEventContent & MLocationEventContent => {
|
||||
const defaultedText = text ??
|
||||
@@ -187,7 +187,7 @@ export const parseLocationEvent = (wireEventContent: LocationEventWireContent):
|
||||
const assetType = asset?.type ?? LocationAssetType.Self;
|
||||
const fallbackText = text ?? wireEventContent.body;
|
||||
|
||||
return makeLocationContent(fallbackText, geoUri, timestamp, description, assetType);
|
||||
return makeLocationContent(fallbackText, geoUri, timestamp ?? undefined, description, assetType);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -201,7 +201,7 @@ export type MakeTopicContent = (
|
||||
export const makeTopicContent: MakeTopicContent = (topic, htmlTopic) => {
|
||||
const renderings = [{ body: topic, mimetype: "text/plain" }];
|
||||
if (isProvided(htmlTopic)) {
|
||||
renderings.push({ body: htmlTopic, mimetype: "text/html" });
|
||||
renderings.push({ body: htmlTopic!, mimetype: "text/html" });
|
||||
}
|
||||
return { topic, [M_TOPIC.name]: renderings };
|
||||
};
|
||||
@@ -247,14 +247,14 @@ export const makeBeaconInfoContent: MakeBeaconInfoContent = (
|
||||
|
||||
export type BeaconInfoState = MBeaconInfoContent & {
|
||||
assetType?: LocationAssetType;
|
||||
timestamp: number;
|
||||
timestamp?: number;
|
||||
};
|
||||
/**
|
||||
* Flatten beacon info event content
|
||||
*/
|
||||
export const parseBeaconInfoContent = (content: MBeaconInfoEventContent): BeaconInfoState => {
|
||||
const { description, timeout, live } = content;
|
||||
const timestamp = M_TIMESTAMP.findIn<number>(content);
|
||||
const timestamp = M_TIMESTAMP.findIn<number>(content) ?? undefined;
|
||||
const asset = M_ASSET.findIn<MAssetContent>(content);
|
||||
|
||||
return {
|
||||
@@ -290,14 +290,14 @@ export const makeBeaconContent: MakeBeaconContent = (
|
||||
},
|
||||
});
|
||||
|
||||
export type BeaconLocationState = MLocationContent & {
|
||||
export type BeaconLocationState = Omit<MLocationContent, "uri"> & {
|
||||
uri?: string; // override from MLocationContent to allow optionals
|
||||
timestamp?: number;
|
||||
};
|
||||
|
||||
export const parseBeaconContent = (content: MBeaconEventContent): BeaconLocationState => {
|
||||
const location = M_LOCATION.findIn<MLocationContent>(content);
|
||||
const timestamp = M_TIMESTAMP.findIn<number>(content);
|
||||
const timestamp = M_TIMESTAMP.findIn<number>(content) ?? undefined;
|
||||
|
||||
return {
|
||||
description: location?.description,
|
||||
|
||||
@@ -29,7 +29,7 @@ import { DeviceInfo } from "./deviceinfo";
|
||||
import { SecretStorage } from "./SecretStorage";
|
||||
import { ICrossSigningKey, ISignedKey, MatrixClient } from "../client";
|
||||
import { OlmDevice } from "./OlmDevice";
|
||||
import { ICryptoCallbacks } from "../matrix";
|
||||
import { ICryptoCallbacks } from ".";
|
||||
import { ISignatures } from "../@types/signed";
|
||||
import { CryptoStore, SecretStorePrivateKeys } from "./store/base";
|
||||
import { ISecretStorageKeyInfo } from "./api";
|
||||
|
||||
@@ -230,7 +230,7 @@ export class DeviceList extends TypedEventEmitter<EmittedEvents, CryptoEventHand
|
||||
}, delay);
|
||||
}
|
||||
|
||||
return savePromise!;
|
||||
return savePromise;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -19,16 +19,15 @@ import { IContent, MatrixEvent } from "../models/event";
|
||||
import { createCryptoStoreCacheCallbacks, ICacheCallbacks } from "./CrossSigning";
|
||||
import { IndexedDBCryptoStore } from './store/indexeddb-crypto-store';
|
||||
import { Method, ClientPrefix } from "../http-api";
|
||||
import { Crypto, IBootstrapCrossSigningOpts } from "./index";
|
||||
import { Crypto, ICryptoCallbacks, IBootstrapCrossSigningOpts } from "./index";
|
||||
import {
|
||||
ClientEvent,
|
||||
CrossSigningKeys,
|
||||
ClientEventHandlerMap,
|
||||
CrossSigningKeys,
|
||||
ICrossSigningKey,
|
||||
ICryptoCallbacks,
|
||||
ISignedKey,
|
||||
KeySignatures,
|
||||
} from "../matrix";
|
||||
} from "../client";
|
||||
import { ISecretStorageKeyInfo } from "./api";
|
||||
import { IKeyBackupInfo } from "./keybackup";
|
||||
import { TypedEventEmitter } from "../models/typed-event-emitter";
|
||||
|
||||
@@ -804,7 +804,7 @@ export class OlmDevice {
|
||||
log,
|
||||
);
|
||||
|
||||
return info!;
|
||||
return info;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -100,7 +100,7 @@ export class OutgoingRoomKeyRequestManager {
|
||||
// of sendOutgoingRoomKeyRequests
|
||||
private sendOutgoingRoomKeyRequestsRunning = false;
|
||||
|
||||
private clientRunning = false;
|
||||
private clientRunning = true;
|
||||
|
||||
constructor(
|
||||
private readonly baseApis: MatrixClient,
|
||||
@@ -108,13 +108,6 @@ export class OutgoingRoomKeyRequestManager {
|
||||
private readonly cryptoStore: CryptoStore,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Called when the client is started. Sets background processes running.
|
||||
*/
|
||||
public start(): void {
|
||||
this.clientRunning = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the client is stopped. Stops any running background processes.
|
||||
*/
|
||||
|
||||
@@ -19,8 +19,9 @@ import * as olmlib from './olmlib';
|
||||
import { encodeBase64 } from './olmlib';
|
||||
import { randomString } from '../randomstring';
|
||||
import { calculateKeyCheck, decryptAES, encryptAES, IEncryptedPayload } from './aes';
|
||||
import { ClientEvent, IContent, ICryptoCallbacks, MatrixEvent } from '../matrix';
|
||||
import { ClientEventHandlerMap, MatrixClient } from "../client";
|
||||
import { ICryptoCallbacks } from ".";
|
||||
import { IContent, MatrixEvent } from "../models/event";
|
||||
import { ClientEvent, ClientEventHandlerMap, MatrixClient } from "../client";
|
||||
import { IAddSecretStorageKeyOpts, ISecretStorageKeyInfo } from './api';
|
||||
import { TypedEventEmitter } from '../models/typed-event-emitter';
|
||||
import { defer, IDeferred } from "../utils";
|
||||
|
||||
@@ -38,7 +38,7 @@ import { IOlmSessionResult } from "../olmlib";
|
||||
import { DeviceInfoMap } from "../DeviceList";
|
||||
import { MatrixEvent } from "../../models/event";
|
||||
import { EventType, MsgType } from '../../@types/event';
|
||||
import { IEventDecryptionResult, IMegolmSessionData, IncomingRoomKeyRequest } from "../index";
|
||||
import { IEncryptedContent, IEventDecryptionResult, IMegolmSessionData, IncomingRoomKeyRequest } from "../index";
|
||||
import { RoomKeyRequestState } from '../OutgoingRoomKeyRequestManager';
|
||||
import { OlmGroupSessionExtraData } from "../../@types/crypto";
|
||||
import { MatrixError } from "../../http-api";
|
||||
@@ -105,12 +105,6 @@ interface IPayload extends Partial<IMessage> {
|
||||
algorithm?: string;
|
||||
sender_key?: string;
|
||||
}
|
||||
|
||||
interface IEncryptedContent {
|
||||
algorithm: string;
|
||||
sender_key: string;
|
||||
ciphertext: Record<string, string>;
|
||||
}
|
||||
/* eslint-enable camelcase */
|
||||
|
||||
interface SharedWithData {
|
||||
@@ -981,7 +975,7 @@ class MegolmEncryption extends EncryptionAlgorithm {
|
||||
logger.debug(`Getting devices in ${this.roomId}`);
|
||||
const [devicesInRoom, blocked] = await this.getDevicesInRoom(room);
|
||||
|
||||
if (this.crypto.getGlobalErrorOnUnknownDevices()) {
|
||||
if (this.crypto.globalErrorOnUnknownDevices) {
|
||||
// Drop unknown devices for now. When the message gets sent, we'll
|
||||
// throw an error, but we'll still be prepared to send to the known
|
||||
// devices.
|
||||
@@ -1034,7 +1028,7 @@ class MegolmEncryption extends EncryptionAlgorithm {
|
||||
|
||||
// check if any of these devices are not yet known to the user.
|
||||
// if so, warn the user so they can verify or ignore.
|
||||
if (this.crypto.getGlobalErrorOnUnknownDevices()) {
|
||||
if (this.crypto.globalErrorOnUnknownDevices) {
|
||||
this.checkForUnknownDevices(devicesInRoom);
|
||||
}
|
||||
|
||||
@@ -1169,7 +1163,7 @@ class MegolmEncryption extends EncryptionAlgorithm {
|
||||
});
|
||||
|
||||
// The global value is treated as a default for when rooms don't specify a value.
|
||||
let isBlacklisting = this.crypto.getGlobalBlacklistUnverifiedDevices();
|
||||
let isBlacklisting = this.crypto.globalBlacklistUnverifiedDevices;
|
||||
const isRoomBlacklisting = room.getBlacklistUnverifiedDevices();
|
||||
if (typeof isRoomBlacklisting === 'boolean') {
|
||||
isBlacklisting = isRoomBlacklisting;
|
||||
@@ -1660,6 +1654,9 @@ class MegolmDecryption extends DecryptionAlgorithm {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// XXX: switch this to use encryptAndSendToDevices() rather than duplicating it?
|
||||
|
||||
await olmlib.ensureOlmSessionsForDevices(
|
||||
this.olmDevice, this.baseApis, { [sender]: [device] }, false,
|
||||
);
|
||||
@@ -1717,6 +1714,8 @@ class MegolmDecryption extends DecryptionAlgorithm {
|
||||
const deviceInfo = this.crypto.getStoredDevice(userId, deviceId)!;
|
||||
const body = keyRequest.requestBody;
|
||||
|
||||
// XXX: switch this to use encryptAndSendToDevices()?
|
||||
|
||||
this.olmlib.ensureOlmSessionsForDevices(
|
||||
this.olmDevice, this.baseApis, {
|
||||
[userId]: [deviceInfo],
|
||||
@@ -1910,6 +1909,7 @@ class MegolmDecryption extends DecryptionAlgorithm {
|
||||
for (const [senderKey, sessionId] of sharedHistorySessions) {
|
||||
const payload = await this.buildKeyForwardingMessage(this.roomId, senderKey, sessionId);
|
||||
|
||||
// FIXME: use encryptAndSendToDevices() rather than duplicating it here.
|
||||
const promises: Promise<unknown>[] = [];
|
||||
const contentMap: Record<string, Record<string, IEncryptedContent>> = {};
|
||||
for (const [userId, devices] of Object.entries(devicesByUser)) {
|
||||
|
||||
@@ -119,12 +119,10 @@ class OlmEncryption extends EncryptionAlgorithm {
|
||||
|
||||
const promises: Promise<void>[] = [];
|
||||
|
||||
for (let i = 0; i < users.length; ++i) {
|
||||
const userId = users[i];
|
||||
for (const userId of users) {
|
||||
const devices = this.crypto.getStoredDevicesForUser(userId) || [];
|
||||
|
||||
for (let j = 0; j < devices.length; ++j) {
|
||||
const deviceInfo = devices[j];
|
||||
for (const deviceInfo of devices) {
|
||||
const key = deviceInfo.getIdentityKey();
|
||||
if (key == this.olmDevice.deviceCurve25519Key) {
|
||||
// don't bother sending to ourself
|
||||
@@ -304,8 +302,7 @@ class OlmDecryption extends DecryptionAlgorithm {
|
||||
|
||||
// try each session in turn.
|
||||
const decryptionErrors: Record<string, string> = {};
|
||||
for (let i = 0; i < sessionIds.length; i++) {
|
||||
const sessionId = sessionIds[i];
|
||||
for (const sessionId of sessionIds) {
|
||||
try {
|
||||
const payload = await this.olmDevice.decryptMessage(
|
||||
theirDeviceIdentityKey, sessionId, message.type, message.body,
|
||||
|
||||
@@ -302,7 +302,7 @@ export class BackupManager {
|
||||
|| now - this.sessionLastCheckAttemptedTime[targetSessionId!] > KEY_BACKUP_CHECK_RATE_LIMIT
|
||||
) {
|
||||
this.sessionLastCheckAttemptedTime[targetSessionId!] = now;
|
||||
await this.baseApis.restoreKeyBackupWithCache(targetRoomId, targetSessionId, this.backupInfo, {});
|
||||
await this.baseApis.restoreKeyBackupWithCache(targetRoomId!, targetSessionId!, this.backupInfo, {});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -680,8 +680,7 @@ export class Curve25519 implements BackupAlgorithm {
|
||||
const backupPubKey = decryption.init_with_private_key(privKey);
|
||||
|
||||
if (backupPubKey !== this.authData.public_key) {
|
||||
// eslint-disable-next-line no-throw-literal
|
||||
throw { errcode: MatrixClient.RESTORE_BACKUP_ERROR_BAD_KEY };
|
||||
throw new MatrixError({ errcode: MatrixClient.RESTORE_BACKUP_ERROR_BAD_KEY });
|
||||
}
|
||||
|
||||
const keys: IMegolmSessionData[] = [];
|
||||
|
||||
+39
-35
@@ -127,6 +127,29 @@ export interface IBootstrapCrossSigningOpts {
|
||||
authUploadDeviceSigningKeys?(makeRequest: (authData: any) => Promise<{}>): Promise<void>;
|
||||
}
|
||||
|
||||
export interface ICryptoCallbacks {
|
||||
getCrossSigningKey?: (keyType: string, pubKey: string) => Promise<Uint8Array | null>;
|
||||
saveCrossSigningKeys?: (keys: Record<string, Uint8Array>) => void;
|
||||
shouldUpgradeDeviceVerifications?: (
|
||||
users: Record<string, any>
|
||||
) => Promise<string[]>;
|
||||
getSecretStorageKey?: (
|
||||
keys: {keys: Record<string, ISecretStorageKeyInfo>}, name: string
|
||||
) => Promise<[string, Uint8Array] | null>;
|
||||
cacheSecretStorageKey?: (
|
||||
keyId: string, keyInfo: ISecretStorageKeyInfo, key: Uint8Array
|
||||
) => void;
|
||||
onSecretRequested?: (
|
||||
userId: string, deviceId: string,
|
||||
requestId: string, secretName: string, deviceTrust: DeviceTrustLevel
|
||||
) => Promise<string>;
|
||||
getDehydrationKey?: (
|
||||
keyInfo: ISecretStorageKeyInfo,
|
||||
checkFunc: (key: Uint8Array) => void,
|
||||
) => Promise<Uint8Array>;
|
||||
getBackupKey?: () => Promise<Uint8Array>;
|
||||
}
|
||||
|
||||
/* eslint-disable camelcase */
|
||||
interface IRoomKey {
|
||||
room_id: string;
|
||||
@@ -285,8 +308,8 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
|
||||
private deviceKeys: Record<string, string> = {}; // type: key
|
||||
|
||||
private globalBlacklistUnverifiedDevices = false;
|
||||
private globalErrorOnUnknownDevices = true;
|
||||
public globalBlacklistUnverifiedDevices = false;
|
||||
public globalErrorOnUnknownDevices = true;
|
||||
|
||||
// list of IncomingRoomKeyRequests/IncomingRoomKeyRequestCancellations
|
||||
// we received in the current sync.
|
||||
@@ -1752,9 +1775,11 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
eventEmitter.on(MatrixEventEvent.Decrypted, this.onTimelineEvent);
|
||||
}
|
||||
|
||||
/** Start background processes related to crypto */
|
||||
/**
|
||||
* @deprecated this does nothing and will be removed in a future version
|
||||
*/
|
||||
public start(): void {
|
||||
this.outgoingRoomKeyRequestManager.start();
|
||||
logger.warn("MatrixClient.crypto.start() is deprecated");
|
||||
}
|
||||
|
||||
/** Stop background processes related to crypto */
|
||||
@@ -1788,6 +1813,9 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
* do not specify a value.
|
||||
*
|
||||
* @param {boolean} value whether to blacklist all unverified devices by default
|
||||
*
|
||||
* @deprecated For external code, use {@link MatrixClient#setGlobalBlacklistUnverifiedDevices}. For
|
||||
* internal code, set {@link #globalBlacklistUnverifiedDevices} directly.
|
||||
*/
|
||||
public setGlobalBlacklistUnverifiedDevices(value: boolean): void {
|
||||
this.globalBlacklistUnverifiedDevices = value;
|
||||
@@ -1795,34 +1823,14 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
|
||||
/**
|
||||
* @return {boolean} whether to blacklist all unverified devices by default
|
||||
*
|
||||
* @deprecated For external code, use {@link MatrixClient#getGlobalBlacklistUnverifiedDevices}. For
|
||||
* internal code, reference {@link #globalBlacklistUnverifiedDevices} directly.
|
||||
*/
|
||||
public getGlobalBlacklistUnverifiedDevices(): boolean {
|
||||
return this.globalBlacklistUnverifiedDevices;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set whether sendMessage in a room with unknown and unverified devices
|
||||
* should throw an error and not send them message. This has 'Global' for
|
||||
* symmetry with setGlobalBlacklistUnverifiedDevices but there is currently
|
||||
* no room-level equivalent for this setting.
|
||||
*
|
||||
* This API is currently UNSTABLE and may change or be removed without notice.
|
||||
*
|
||||
* @param {boolean} value whether error on unknown devices
|
||||
*/
|
||||
public setGlobalErrorOnUnknownDevices(value: boolean): void {
|
||||
this.globalErrorOnUnknownDevices = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {boolean} whether to error on unknown devices
|
||||
*
|
||||
* This API is currently UNSTABLE and may change or be removed without notice.
|
||||
*/
|
||||
public getGlobalErrorOnUnknownDevices(): boolean {
|
||||
return this.globalErrorOnUnknownDevices;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload the device keys to the homeserver.
|
||||
* @return {object} A promise that will resolve when the keys are uploaded.
|
||||
@@ -2379,9 +2387,8 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
*/
|
||||
public async getOlmSessionsForUser(userId: string): Promise<Record<string, IUserOlmSession>> {
|
||||
const devices = this.getStoredDevicesForUser(userId) || [];
|
||||
const result = {};
|
||||
for (let j = 0; j < devices.length; ++j) {
|
||||
const device = devices[j];
|
||||
const result: { [deviceId: string]: IUserOlmSession } = {};
|
||||
for (const device of devices) {
|
||||
const deviceKey = device.getIdentityKey();
|
||||
const sessions = await this.olmDevice.getSessionInfoForDevice(deviceKey);
|
||||
|
||||
@@ -2682,14 +2689,11 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
|
||||
): Promise<Record<string, Record<string, olmlib.IOlmSessionResult>>> {
|
||||
const devicesByUser: Record<string, DeviceInfo[]> = {};
|
||||
|
||||
for (let i = 0; i < users.length; ++i) {
|
||||
const userId = users[i];
|
||||
for (const userId of users) {
|
||||
devicesByUser[userId] = [];
|
||||
|
||||
const devices = this.getStoredDevicesForUser(userId) || [];
|
||||
for (let j = 0; j < devices.length; ++j) {
|
||||
const deviceInfo = devices[j];
|
||||
|
||||
for (const deviceInfo of devices) {
|
||||
const key = deviceInfo.getIdentityKey();
|
||||
if (key == this.olmDevice.deviceCurve25519Key) {
|
||||
// don't bother setting up session to ourself
|
||||
|
||||
@@ -88,12 +88,16 @@ export async function encryptMessageForDevice(
|
||||
if (sessionId === null) {
|
||||
// If we don't have a session for a device then
|
||||
// we can't encrypt a message for it.
|
||||
logger.log(
|
||||
`[olmlib.encryptMessageForDevice] Unable to find Olm session for device ` +
|
||||
`${recipientUserId}:${recipientDevice.deviceId}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.log(
|
||||
"Using sessionid " + sessionId + " for device " +
|
||||
recipientUserId + ":" + recipientDevice.deviceId,
|
||||
`[olmlib.encryptMessageForDevice] Using Olm session ${sessionId} for device ` +
|
||||
`${recipientUserId}:${recipientDevice.deviceId}`,
|
||||
);
|
||||
|
||||
const payload = {
|
||||
@@ -335,8 +339,7 @@ export async function ensureOlmSessionsForDevices(
|
||||
const promises: Promise<void>[] = [];
|
||||
for (const [userId, devices] of Object.entries(devicesByUser)) {
|
||||
const userRes = otkResult[userId] || {};
|
||||
for (let j = 0; j < devices.length; j++) {
|
||||
const deviceInfo = devices[j];
|
||||
for (const deviceInfo of devices) {
|
||||
const deviceId = deviceInfo.deviceId;
|
||||
const key = deviceInfo.getIdentityKey();
|
||||
|
||||
|
||||
@@ -201,7 +201,7 @@ export class Backend implements CryptoStore {
|
||||
let stateIndex = 0;
|
||||
let result: OutgoingRoomKeyRequest;
|
||||
|
||||
function onsuccess(this: IDBRequest<IDBCursorWithValue>) {
|
||||
function onsuccess(this: IDBRequest<IDBCursorWithValue | null>) {
|
||||
const cursor = this.result;
|
||||
if (cursor) {
|
||||
// got a match
|
||||
@@ -256,7 +256,7 @@ export class Backend implements CryptoStore {
|
||||
let stateIndex = 0;
|
||||
const results: OutgoingRoomKeyRequest[] = [];
|
||||
|
||||
function onsuccess(this: IDBRequest<IDBCursorWithValue>) {
|
||||
function onsuccess(this: IDBRequest<IDBCursorWithValue | null>) {
|
||||
const cursor = this.result;
|
||||
if (cursor) {
|
||||
const keyReq = cursor.value;
|
||||
@@ -309,7 +309,7 @@ export class Backend implements CryptoStore {
|
||||
): Promise<OutgoingRoomKeyRequest | null> {
|
||||
let result: OutgoingRoomKeyRequest | null = null;
|
||||
|
||||
function onsuccess(this: IDBRequest<IDBCursorWithValue>) {
|
||||
function onsuccess(this: IDBRequest<IDBCursorWithValue | null>) {
|
||||
const cursor = this.result;
|
||||
if (!cursor) {
|
||||
return;
|
||||
|
||||
+357
@@ -0,0 +1,357 @@
|
||||
/*
|
||||
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 {
|
||||
WidgetApi,
|
||||
WidgetApiToWidgetAction,
|
||||
MatrixCapabilities,
|
||||
IWidgetApiRequest,
|
||||
IWidgetApiAcknowledgeResponseData,
|
||||
ISendEventToWidgetActionRequest,
|
||||
ISendToDeviceToWidgetActionRequest,
|
||||
ISendEventFromWidgetResponseData,
|
||||
} from "matrix-widget-api";
|
||||
|
||||
import { IEvent, IContent, EventStatus } from "./models/event";
|
||||
import { ISendEventResponse } from "./@types/requests";
|
||||
import { EventType } from "./@types/event";
|
||||
import { logger } from "./logger";
|
||||
import { MatrixClient, ClientEvent, IMatrixClientCreateOpts, IStartClientOpts } from "./client";
|
||||
import { SyncApi, SyncState } from "./sync";
|
||||
import { SlidingSyncSdk } from "./sliding-sync-sdk";
|
||||
import { MatrixEvent } from "./models/event";
|
||||
import { User } from "./models/user";
|
||||
import { Room } from "./models/room";
|
||||
import { ToDeviceBatch } from "./models/ToDeviceMessage";
|
||||
import { DeviceInfo } from "./crypto/deviceinfo";
|
||||
import { IOlmDevice } from "./crypto/algorithms/megolm";
|
||||
|
||||
interface IStateEventRequest {
|
||||
eventType: string;
|
||||
stateKey?: string;
|
||||
}
|
||||
|
||||
export interface ICapabilities {
|
||||
/**
|
||||
* Event types that this client expects to send.
|
||||
*/
|
||||
sendEvent?: string[];
|
||||
/**
|
||||
* Event types that this client expects to receive.
|
||||
*/
|
||||
receiveEvent?: string[];
|
||||
|
||||
/**
|
||||
* Message types that this client expects to send, or true for all message
|
||||
* types.
|
||||
*/
|
||||
sendMessage?: string[] | true;
|
||||
/**
|
||||
* Message types that this client expects to receive, or true for all
|
||||
* message types.
|
||||
*/
|
||||
receiveMessage?: string[] | true;
|
||||
|
||||
/**
|
||||
* Types of state events that this client expects to send.
|
||||
*/
|
||||
sendState?: IStateEventRequest[];
|
||||
/**
|
||||
* Types of state events that this client expects to receive.
|
||||
*/
|
||||
receiveState?: IStateEventRequest[];
|
||||
|
||||
/**
|
||||
* To-device event types that this client expects to send.
|
||||
*/
|
||||
sendToDevice?: string[];
|
||||
/**
|
||||
* To-device event types that this client expects to receive.
|
||||
*/
|
||||
receiveToDevice?: string[];
|
||||
|
||||
/**
|
||||
* Whether this client needs access to TURN servers.
|
||||
* @default false
|
||||
*/
|
||||
turnServers?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A MatrixClient that routes its requests through the widget API instead of the
|
||||
* real CS API.
|
||||
* @experimental This class is considered unstable!
|
||||
*/
|
||||
export class RoomWidgetClient extends MatrixClient {
|
||||
private room?: Room;
|
||||
private widgetApiReady = new Promise<void>(resolve => this.widgetApi.once("ready", resolve));
|
||||
private lifecycle?: AbortController;
|
||||
private syncState: SyncState | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly widgetApi: WidgetApi,
|
||||
private readonly capabilities: ICapabilities,
|
||||
private readonly roomId: string,
|
||||
opts: IMatrixClientCreateOpts,
|
||||
) {
|
||||
super(opts);
|
||||
|
||||
// Request capabilities for the functionality this client needs to support
|
||||
if (
|
||||
capabilities.sendEvent?.length
|
||||
|| capabilities.receiveEvent?.length
|
||||
|| capabilities.sendMessage === true
|
||||
|| (Array.isArray(capabilities.sendMessage) && capabilities.sendMessage.length)
|
||||
|| capabilities.receiveMessage === true
|
||||
|| (Array.isArray(capabilities.receiveMessage) && capabilities.receiveMessage.length)
|
||||
|| capabilities.sendState?.length
|
||||
|| capabilities.receiveState?.length
|
||||
) {
|
||||
widgetApi.requestCapabilityForRoomTimeline(roomId);
|
||||
}
|
||||
capabilities.sendEvent?.forEach(eventType =>
|
||||
widgetApi.requestCapabilityToSendEvent(eventType),
|
||||
);
|
||||
capabilities.receiveEvent?.forEach(eventType =>
|
||||
widgetApi.requestCapabilityToReceiveEvent(eventType),
|
||||
);
|
||||
if (capabilities.sendMessage === true) {
|
||||
widgetApi.requestCapabilityToSendMessage();
|
||||
} else if (Array.isArray(capabilities.sendMessage)) {
|
||||
capabilities.sendMessage.forEach(msgType =>
|
||||
widgetApi.requestCapabilityToSendMessage(msgType),
|
||||
);
|
||||
}
|
||||
if (capabilities.receiveMessage === true) {
|
||||
widgetApi.requestCapabilityToReceiveMessage();
|
||||
} else if (Array.isArray(capabilities.receiveMessage)) {
|
||||
capabilities.receiveMessage.forEach(msgType =>
|
||||
widgetApi.requestCapabilityToReceiveMessage(msgType),
|
||||
);
|
||||
}
|
||||
capabilities.sendState?.forEach(({ eventType, stateKey }) =>
|
||||
widgetApi.requestCapabilityToSendState(eventType, stateKey),
|
||||
);
|
||||
capabilities.receiveState?.forEach(({ eventType, stateKey }) =>
|
||||
widgetApi.requestCapabilityToReceiveState(eventType, stateKey),
|
||||
);
|
||||
capabilities.sendToDevice?.forEach(eventType =>
|
||||
widgetApi.requestCapabilityToSendToDevice(eventType),
|
||||
);
|
||||
capabilities.receiveToDevice?.forEach(eventType =>
|
||||
widgetApi.requestCapabilityToReceiveToDevice(eventType),
|
||||
);
|
||||
if (capabilities.turnServers) {
|
||||
widgetApi.requestCapability(MatrixCapabilities.MSC3846TurnServers);
|
||||
}
|
||||
|
||||
widgetApi.on(`action:${WidgetApiToWidgetAction.SendEvent}`, this.onEvent);
|
||||
widgetApi.on(`action:${WidgetApiToWidgetAction.SendToDevice}`, this.onToDevice);
|
||||
|
||||
// Open communication with the host
|
||||
widgetApi.start();
|
||||
}
|
||||
|
||||
public async startClient(opts: IStartClientOpts = {}): Promise<void> {
|
||||
this.lifecycle = new AbortController();
|
||||
|
||||
// Create our own user object artificially (instead of waiting for sync)
|
||||
// so it's always available, even if the user is not in any rooms etc.
|
||||
const userId = this.getUserId();
|
||||
if (userId) {
|
||||
this.store.storeUser(new User(userId));
|
||||
}
|
||||
|
||||
// Even though we have no access token and cannot sync, the sync class
|
||||
// still has some valuable helper methods that we make use of, so we
|
||||
// instantiate it anyways
|
||||
if (opts.slidingSync) {
|
||||
this.syncApi = new SlidingSyncSdk(opts.slidingSync, this, opts);
|
||||
} else {
|
||||
this.syncApi = new SyncApi(this, opts);
|
||||
}
|
||||
|
||||
this.room = this.syncApi.createRoom(this.roomId);
|
||||
this.store.storeRoom(this.room);
|
||||
|
||||
await this.widgetApiReady;
|
||||
|
||||
// Backfill the requested events
|
||||
// We only get the most recent event for every type + state key combo,
|
||||
// so it doesn't really matter what order we inject them in
|
||||
await Promise.all(
|
||||
this.capabilities.receiveState?.map(async ({ eventType, stateKey }) => {
|
||||
const rawEvents = await this.widgetApi.readStateEvents(eventType, undefined, stateKey, [this.roomId]);
|
||||
const events = rawEvents.map(rawEvent => new MatrixEvent(rawEvent as Partial<IEvent>));
|
||||
|
||||
await this.syncApi!.injectRoomEvents(this.room!, [], events);
|
||||
events.forEach(event => {
|
||||
this.emit(ClientEvent.Event, event);
|
||||
logger.info(`Backfilled event ${event.getId()} ${event.getType()} ${event.getStateKey()}`);
|
||||
});
|
||||
}) ?? [],
|
||||
);
|
||||
this.setSyncState(SyncState.Syncing);
|
||||
logger.info("Finished backfilling events");
|
||||
|
||||
// Watch for TURN servers, if requested
|
||||
if (this.capabilities.turnServers) this.watchTurnServers();
|
||||
}
|
||||
|
||||
public stopClient() {
|
||||
this.widgetApi.off(`action:${WidgetApiToWidgetAction.SendEvent}`, this.onEvent);
|
||||
this.widgetApi.off(`action:${WidgetApiToWidgetAction.SendToDevice}`, this.onToDevice);
|
||||
|
||||
super.stopClient();
|
||||
this.lifecycle!.abort(); // Signal to other async tasks that the client has stopped
|
||||
}
|
||||
|
||||
public async joinRoom(roomIdOrAlias: string): Promise<Room> {
|
||||
if (roomIdOrAlias === this.roomId) return this.room!;
|
||||
throw new Error(`Unknown room: ${roomIdOrAlias}`);
|
||||
}
|
||||
|
||||
protected async encryptAndSendEvent(room: Room, event: MatrixEvent): Promise<ISendEventResponse> {
|
||||
let response: ISendEventFromWidgetResponseData;
|
||||
try {
|
||||
response = await this.widgetApi.sendRoomEvent(event.getType(), event.getContent(), room.roomId);
|
||||
} catch (e) {
|
||||
this.updatePendingEventStatus(room, event, EventStatus.NOT_SENT);
|
||||
throw e;
|
||||
}
|
||||
|
||||
room.updatePendingEvent(event, EventStatus.SENT, response.event_id);
|
||||
return { event_id: response.event_id };
|
||||
}
|
||||
|
||||
public async sendStateEvent(
|
||||
roomId: string,
|
||||
eventType: string,
|
||||
content: any,
|
||||
stateKey = "",
|
||||
): Promise<ISendEventResponse> {
|
||||
return await this.widgetApi.sendStateEvent(eventType, stateKey, content, roomId);
|
||||
}
|
||||
|
||||
public async sendToDevice(
|
||||
eventType: string,
|
||||
contentMap: { [userId: string]: { [deviceId: string]: Record<string, any> } },
|
||||
): Promise<{}> {
|
||||
await this.widgetApi.sendToDevice(eventType, false, contentMap);
|
||||
return {};
|
||||
}
|
||||
|
||||
public async queueToDevice({ eventType, batch }: ToDeviceBatch): Promise<void> {
|
||||
const contentMap: { [userId: string]: { [deviceId: string]: object } } = {};
|
||||
for (const { userId, deviceId, payload } of batch) {
|
||||
if (!contentMap[userId]) contentMap[userId] = {};
|
||||
contentMap[userId][deviceId] = payload;
|
||||
}
|
||||
|
||||
await this.widgetApi.sendToDevice(eventType, false, contentMap);
|
||||
}
|
||||
|
||||
public async encryptAndSendToDevices(
|
||||
userDeviceInfoArr: IOlmDevice<DeviceInfo>[],
|
||||
payload: object,
|
||||
): Promise<void> {
|
||||
const contentMap: { [userId: string]: { [deviceId: string]: object } } = {};
|
||||
for (const { userId, deviceInfo: { deviceId } } of userDeviceInfoArr) {
|
||||
if (!contentMap[userId]) contentMap[userId] = {};
|
||||
contentMap[userId][deviceId] = payload;
|
||||
}
|
||||
|
||||
await this.widgetApi.sendToDevice((payload as { type: string }).type, true, contentMap);
|
||||
}
|
||||
|
||||
// Overridden since we get TURN servers automatically over the widget API,
|
||||
// and this method would otherwise complain about missing an access token
|
||||
public async checkTurnServers(): Promise<boolean> {
|
||||
return this.turnServers.length > 0;
|
||||
}
|
||||
|
||||
// Overridden since we 'sync' manually without the sync API
|
||||
public getSyncState(): SyncState | null {
|
||||
return this.syncState;
|
||||
}
|
||||
|
||||
private setSyncState(state: SyncState) {
|
||||
const oldState = this.syncState;
|
||||
this.syncState = state;
|
||||
this.emit(ClientEvent.Sync, state, oldState);
|
||||
}
|
||||
|
||||
private async ack(ev: CustomEvent<IWidgetApiRequest>): Promise<void> {
|
||||
await this.widgetApi.transport.reply<IWidgetApiAcknowledgeResponseData>(ev.detail, {});
|
||||
}
|
||||
|
||||
private onEvent = async (ev: CustomEvent<ISendEventToWidgetActionRequest>) => {
|
||||
ev.preventDefault();
|
||||
|
||||
// Verify the room ID matches, since it's possible for the client to
|
||||
// send us events from other rooms if this widget is always on screen
|
||||
if (ev.detail.data.room_id === this.roomId) {
|
||||
const event = new MatrixEvent(ev.detail.data as Partial<IEvent>);
|
||||
await this.syncApi!.injectRoomEvents(this.room!, [], [event]);
|
||||
this.emit(ClientEvent.Event, event);
|
||||
this.setSyncState(SyncState.Syncing);
|
||||
logger.info(`Received event ${event.getId()} ${event.getType()} ${event.getStateKey()}`);
|
||||
} else {
|
||||
const { event_id: eventId, room_id: roomId } = ev.detail.data;
|
||||
logger.info(`Received event ${eventId} for a different room ${roomId}; discarding`);
|
||||
}
|
||||
|
||||
await this.ack(ev);
|
||||
};
|
||||
|
||||
private onToDevice = async (ev: CustomEvent<ISendToDeviceToWidgetActionRequest>) => {
|
||||
ev.preventDefault();
|
||||
|
||||
const event = new MatrixEvent({
|
||||
type: ev.detail.data.type,
|
||||
sender: ev.detail.data.sender,
|
||||
content: ev.detail.data.content as IContent,
|
||||
});
|
||||
// Mark the event as encrypted if it was, using fake contents and keys since those are unknown to us
|
||||
if (ev.detail.data.encrypted) event.makeEncrypted(EventType.RoomMessageEncrypted, {}, "", "");
|
||||
|
||||
this.emit(ClientEvent.ToDeviceEvent, event);
|
||||
this.setSyncState(SyncState.Syncing);
|
||||
await this.ack(ev);
|
||||
};
|
||||
|
||||
private async watchTurnServers() {
|
||||
const servers = this.widgetApi.getTurnServers();
|
||||
const onClientStopped = () => servers.return(undefined);
|
||||
this.lifecycle!.signal.addEventListener("abort", onClientStopped);
|
||||
|
||||
try {
|
||||
for await (const server of servers) {
|
||||
this.turnServers = [{
|
||||
urls: server.uris,
|
||||
username: server.username,
|
||||
credential: server.password,
|
||||
}];
|
||||
this.emit(ClientEvent.TurnServers, this.turnServers);
|
||||
logger.log(`Received TURN server: ${server.uris}`);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn("Error watching TURN servers", e);
|
||||
} finally {
|
||||
this.lifecycle!.signal.removeEventListener("abort", onClientStopped);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -150,8 +150,7 @@ export class FilterComponent {
|
||||
},
|
||||
};
|
||||
|
||||
for (let n = 0; n < Object.keys(literalKeys).length; n++) {
|
||||
const name = Object.keys(literalKeys)[n];
|
||||
for (const name in literalKeys) {
|
||||
const matchFunc = literalKeys[name];
|
||||
const notName = "not_" + name;
|
||||
const disallowedValues: string[] = this.filterJson[notName];
|
||||
|
||||
@@ -49,7 +49,7 @@ export class HTTPError extends Error {
|
||||
*/
|
||||
export class MatrixError extends HTTPError {
|
||||
public readonly errcode?: string;
|
||||
public readonly data: IErrorJson;
|
||||
public data: IErrorJson;
|
||||
|
||||
constructor(
|
||||
errorJson: IErrorJson = {},
|
||||
|
||||
@@ -236,7 +236,7 @@ export class FetchHttpApi<O extends IHttpOpts> {
|
||||
method: Method,
|
||||
url: URL | string,
|
||||
body?: Body,
|
||||
opts: Pick<IRequestOpts, "headers" | "json" | "localTimeoutMs" | "abortSignal"> = {},
|
||||
opts: Pick<IRequestOpts, "headers" | "json" | "localTimeoutMs" | "keepAlive" | "abortSignal"> = {},
|
||||
): Promise<ResponseType<T, O>> {
|
||||
const headers = Object.assign({}, opts.headers || {});
|
||||
const json = opts.json ?? true;
|
||||
@@ -254,6 +254,7 @@ export class FetchHttpApi<O extends IHttpOpts> {
|
||||
}
|
||||
|
||||
const timeout = opts.localTimeoutMs ?? this.opts.localTimeoutMs;
|
||||
const keepAlive = opts.keepAlive ?? false;
|
||||
const signals = [
|
||||
this.abortController.signal,
|
||||
];
|
||||
@@ -286,6 +287,7 @@ export class FetchHttpApi<O extends IHttpOpts> {
|
||||
referrerPolicy: "no-referrer",
|
||||
cache: "no-cache",
|
||||
credentials: "omit", // we send credentials via headers
|
||||
keepalive: keepAlive,
|
||||
});
|
||||
} catch (e) {
|
||||
if ((<Error>e).name === "AbortError") {
|
||||
|
||||
@@ -38,6 +38,7 @@ export interface IRequestOpts {
|
||||
headers?: Record<string, string>;
|
||||
abortSignal?: AbortSignal;
|
||||
localTimeoutMs?: number;
|
||||
keepAlive?: boolean; // defaults to false
|
||||
json?: boolean; // defaults to true
|
||||
|
||||
// Set to true to prevent the request function from emitting a Session.logged_out event.
|
||||
|
||||
+19
-20
@@ -21,6 +21,7 @@ limitations under the License.
|
||||
import { logger } from './logger';
|
||||
import { MatrixClient } from "./client";
|
||||
import { defer, IDeferred } from "./utils";
|
||||
import { MatrixError } from "./http-api";
|
||||
|
||||
const EMAIL_STAGE_TYPE = "m.login.email.identity";
|
||||
const MSISDN_STAGE_TYPE = "m.login.msisdn";
|
||||
@@ -111,7 +112,7 @@ interface IOpts {
|
||||
sessionId?: string;
|
||||
clientSecret?: string;
|
||||
emailSid?: string;
|
||||
doRequest(auth: IAuthData, background: boolean): Promise<IAuthData>;
|
||||
doRequest(auth: IAuthData | null, background: boolean): Promise<IAuthData>;
|
||||
stateUpdated(nextStage: AuthType, status: IStageStatus): void;
|
||||
requestEmailToken(email: string, secret: string, attempt: number, session: string): Promise<{ sid: string }>;
|
||||
busyChanged?(busy: boolean): void;
|
||||
@@ -328,7 +329,7 @@ export class InteractiveAuth {
|
||||
* @param {string} loginType login type for the stage
|
||||
* @return {object?} any parameters from the server for this stage
|
||||
*/
|
||||
public getStageParams(loginType: string): Record<string, any> {
|
||||
public getStageParams(loginType: string): Record<string, any> | undefined {
|
||||
return this.data.params?.[loginType];
|
||||
}
|
||||
|
||||
@@ -428,10 +429,10 @@ export class InteractiveAuth {
|
||||
this.requestingEmailToken = true;
|
||||
try {
|
||||
const requestTokenResult = await this.requestEmailTokenCallback(
|
||||
this.inputs.emailAddress,
|
||||
this.inputs.emailAddress!,
|
||||
this.clientSecret,
|
||||
this.emailAttempt++,
|
||||
this.data.session,
|
||||
this.data.session!,
|
||||
);
|
||||
this.emailSid = requestTokenResult.sid;
|
||||
logger.trace("Email token request succeeded");
|
||||
@@ -454,16 +455,16 @@ export class InteractiveAuth {
|
||||
* This can be set to true for requests that just poll to see if auth has
|
||||
* been completed elsewhere.
|
||||
*/
|
||||
private async doRequest(auth: IAuthData, background = false): Promise<void> {
|
||||
private async doRequest(auth: IAuthData | null, background = false): Promise<void> {
|
||||
try {
|
||||
const result = await this.requestCallback(auth, background);
|
||||
this.attemptAuthDeferred!.resolve(result);
|
||||
this.attemptAuthDeferred = null;
|
||||
} catch (error) {
|
||||
// sometimes UI auth errors don't come with flows
|
||||
const errorFlows = error.data?.flows ?? null;
|
||||
const errorFlows = (<MatrixError>error).data?.flows ?? null;
|
||||
const haveFlows = this.data.flows || Boolean(errorFlows);
|
||||
if (error.httpStatus !== 401 || !error.data || !haveFlows) {
|
||||
if ((<MatrixError>error).httpStatus !== 401 || !(<MatrixError>error).data || !haveFlows) {
|
||||
// doesn't look like an interactive-auth failure.
|
||||
if (!background) {
|
||||
this.attemptAuthDeferred?.reject(error);
|
||||
@@ -474,20 +475,23 @@ export class InteractiveAuth {
|
||||
logger.log("Background poll request failed doing UI auth: ignoring", error);
|
||||
}
|
||||
}
|
||||
if (!error.data) {
|
||||
error.data = {};
|
||||
if (!(<MatrixError>error).data) {
|
||||
(<MatrixError>error).data = {};
|
||||
}
|
||||
// if the error didn't come with flows, completed flows or session ID,
|
||||
// copy over the ones we have. Synapse sometimes sends responses without
|
||||
// any UI auth data (eg. when polling for email validation, if the email
|
||||
// has not yet been validated). This appears to be a Synapse bug, which
|
||||
// we workaround here.
|
||||
if (!error.data.flows && !error.data.completed && !error.data.session) {
|
||||
error.data.flows = this.data.flows;
|
||||
error.data.completed = this.data.completed;
|
||||
error.data.session = this.data.session;
|
||||
if (!(<MatrixError>error).data.flows &&
|
||||
!(<MatrixError>error).data.completed &&
|
||||
!(<MatrixError>error).data.session
|
||||
) {
|
||||
(<MatrixError>error).data.flows = this.data.flows;
|
||||
(<MatrixError>error).data.completed = this.data.completed;
|
||||
(<MatrixError>error).data.session = this.data.session;
|
||||
}
|
||||
this.data = error.data;
|
||||
this.data = (<MatrixError>error).data;
|
||||
try {
|
||||
this.startNextAuthStage();
|
||||
} catch (e) {
|
||||
@@ -627,11 +631,6 @@ export class InteractiveAuth {
|
||||
*/
|
||||
private firstUncompletedStage(flow: IFlow): AuthType | undefined {
|
||||
const completed = this.data.completed || [];
|
||||
for (let i = 0; i < flow.stages.length; ++i) {
|
||||
const stageType = flow.stages[i];
|
||||
if (completed.indexOf(stageType) === -1) {
|
||||
return stageType;
|
||||
}
|
||||
}
|
||||
return flow.stages.find(stageType => !completed.includes(stageType));
|
||||
}
|
||||
}
|
||||
|
||||
+33
-56
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright 2015-2021 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2015-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.
|
||||
@@ -14,14 +14,16 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { WidgetApi } from "matrix-widget-api";
|
||||
|
||||
import { MemoryCryptoStore } from "./crypto/store/memory-crypto-store";
|
||||
import { MemoryStore } from "./store/memory";
|
||||
import { MatrixScheduler } from "./scheduler";
|
||||
import { MatrixClient, ICreateClientOpts } from "./client";
|
||||
import { DeviceTrustLevel } from "./crypto/CrossSigning";
|
||||
import { ISecretStorageKeyInfo } from "./crypto/api";
|
||||
import { RoomWidgetClient, ICapabilities } from "./embedded";
|
||||
|
||||
export * from "./client";
|
||||
export * from "./embedded";
|
||||
export * from "./http-api";
|
||||
export * from "./autodiscovery";
|
||||
export * from "./sync-accumulator";
|
||||
@@ -51,9 +53,16 @@ export * from './@types/requests';
|
||||
export * from './@types/search';
|
||||
export * from './models/room-summary';
|
||||
export * as ContentHelpers from "./content-helpers";
|
||||
export type { ICryptoCallbacks } from "./crypto"; // used to be located here
|
||||
export { createNewMatrixCall } from "./webrtc/call";
|
||||
export type { MatrixCall } from "./webrtc/call";
|
||||
export {
|
||||
createNewMatrixCall,
|
||||
} from "./webrtc/call";
|
||||
GroupCallEvent,
|
||||
GroupCallIntent,
|
||||
GroupCallState,
|
||||
GroupCallType,
|
||||
} from "./webrtc/groupCall";
|
||||
export type { GroupCall } from "./webrtc/groupCall";
|
||||
|
||||
let cryptoStoreFactory = () => new MemoryCryptoStore;
|
||||
|
||||
@@ -67,34 +76,20 @@ export function setCryptoStoreFactory(fac) {
|
||||
cryptoStoreFactory = fac;
|
||||
}
|
||||
|
||||
export interface ICryptoCallbacks {
|
||||
getCrossSigningKey?: (keyType: string, pubKey: string) => Promise<Uint8Array | null>;
|
||||
saveCrossSigningKeys?: (keys: Record<string, Uint8Array>) => void;
|
||||
shouldUpgradeDeviceVerifications?: (
|
||||
users: Record<string, any>
|
||||
) => Promise<string[]>;
|
||||
getSecretStorageKey?: (
|
||||
keys: {keys: Record<string, ISecretStorageKeyInfo>}, name: string
|
||||
) => Promise<[string, Uint8Array] | null>;
|
||||
cacheSecretStorageKey?: (
|
||||
keyId: string, keyInfo: ISecretStorageKeyInfo, key: Uint8Array
|
||||
) => void;
|
||||
onSecretRequested?: (
|
||||
userId: string, deviceId: string,
|
||||
requestId: string, secretName: string, deviceTrust: DeviceTrustLevel
|
||||
) => Promise<string>;
|
||||
getDehydrationKey?: (
|
||||
keyInfo: ISecretStorageKeyInfo,
|
||||
checkFunc: (key: Uint8Array) => void,
|
||||
) => Promise<Uint8Array>;
|
||||
getBackupKey?: () => Promise<Uint8Array>;
|
||||
function amendClientOpts(opts: ICreateClientOpts): ICreateClientOpts {
|
||||
opts.store = opts.store ?? new MemoryStore({
|
||||
localStorage: global.localStorage,
|
||||
});
|
||||
opts.scheduler = opts.scheduler ?? new MatrixScheduler();
|
||||
opts.cryptoStore = opts.cryptoStore ?? cryptoStoreFactory();
|
||||
|
||||
return opts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct a Matrix Client. Similar to {@link module:client.MatrixClient}
|
||||
* except that the 'request', 'store' and 'scheduler' dependencies are satisfied.
|
||||
* @param {(Object)} opts The configuration options for this client. If
|
||||
* this is a string, it is assumed to be the base URL. These configuration
|
||||
* @param {Object} opts The configuration options for this client. These configuration
|
||||
* options will be passed directly to {@link module:client.MatrixClient}.
|
||||
* @param {Object} opts.store If not set, defaults to
|
||||
* {@link module:store/memory.MemoryStore}.
|
||||
@@ -111,33 +106,15 @@ export interface ICryptoCallbacks {
|
||||
* @see {@link module:client.MatrixClient} for the full list of options for
|
||||
* <code>opts</code>.
|
||||
*/
|
||||
export function createClient(opts: ICreateClientOpts) {
|
||||
opts.store = opts.store || new MemoryStore({
|
||||
localStorage: global.localStorage,
|
||||
});
|
||||
opts.scheduler = opts.scheduler || new MatrixScheduler();
|
||||
opts.cryptoStore = opts.cryptoStore || cryptoStoreFactory();
|
||||
return new MatrixClient(opts);
|
||||
export function createClient(opts: ICreateClientOpts): MatrixClient {
|
||||
return new MatrixClient(amendClientOpts(opts));
|
||||
}
|
||||
|
||||
/**
|
||||
* A wrapper for the request function interface.
|
||||
* @callback requestWrapperFunction
|
||||
* @param {requestFunction} origRequest The underlying request function being
|
||||
* wrapped
|
||||
* @param {Object} opts The options for this HTTP request, given in the same
|
||||
* form as {@link requestFunction}.
|
||||
* @param {requestCallback} callback The request callback.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The request callback interface for performing HTTP requests. This matches the
|
||||
* API for the {@link https://github.com/request/request#requestoptions-callback|
|
||||
* request NPM module}. The SDK will implement a callback which meets this
|
||||
* interface in order to handle the HTTP response.
|
||||
* @callback requestCallback
|
||||
* @param {Error} err The error if one occurred, else falsey.
|
||||
* @param {Object} response The HTTP response which consists of
|
||||
* <code>{statusCode: {Number}, headers: {Object}}</code>
|
||||
* @param {Object} body The parsed HTTP response body.
|
||||
*/
|
||||
export function createRoomWidgetClient(
|
||||
widgetApi: WidgetApi,
|
||||
capabilities: ICapabilities,
|
||||
roomId: string,
|
||||
opts: ICreateClientOpts,
|
||||
): MatrixClient {
|
||||
return new RoomWidgetClient(widgetApi, capabilities, roomId, amendClientOpts(opts));
|
||||
}
|
||||
|
||||
@@ -145,7 +145,7 @@ export class MSC3089Branch {
|
||||
let event: MatrixEvent | undefined = room.getUnfilteredTimelineSet().findEventById(this.id);
|
||||
|
||||
// keep scrolling back if needed until we find the event or reach the start of the room:
|
||||
while (!event && room.getLiveTimeline().getState(EventTimeline.BACKWARDS).paginationToken) {
|
||||
while (!event && room.getLiveTimeline().getState(EventTimeline.BACKWARDS)!.paginationToken) {
|
||||
await this.client.scrollback(room, 100);
|
||||
event = room.getUnfilteredTimelineSet().findEventById(this.id);
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ limitations under the License.
|
||||
|
||||
import { MBeaconEventContent } from "../@types/beacon";
|
||||
import { BeaconInfoState, BeaconLocationState, parseBeaconContent, parseBeaconInfoContent } from "../content-helpers";
|
||||
import { MatrixEvent } from "../matrix";
|
||||
import { MatrixEvent } from "./event";
|
||||
import { sortEventsByLatestContentTimestamp } from "../utils";
|
||||
import { TypedEventEmitter } from "./typed-event-emitter";
|
||||
|
||||
@@ -132,19 +132,19 @@ export class Beacon extends TypedEventEmitter<Exclude<BeaconEvent, BeaconEvent.N
|
||||
this.checkLiveness();
|
||||
if (!this.beaconInfo) return;
|
||||
if (this.isLive) {
|
||||
const expiryInMs = (this.beaconInfo.timestamp + this.beaconInfo.timeout) - Date.now();
|
||||
const expiryInMs = (this.beaconInfo.timestamp! + this.beaconInfo.timeout) - Date.now();
|
||||
if (expiryInMs > 1) {
|
||||
this.livenessWatchTimeout = setTimeout(
|
||||
() => { this.monitorLiveness(); },
|
||||
expiryInMs,
|
||||
);
|
||||
}
|
||||
} else if (this.beaconInfo.timestamp > Date.now()) {
|
||||
} else if (this.beaconInfo.timestamp! > Date.now()) {
|
||||
// beacon start timestamp is in the future
|
||||
// check liveness again then
|
||||
this.livenessWatchTimeout = setTimeout(
|
||||
() => { this.monitorLiveness(); },
|
||||
this.beaconInfo.timestamp - Date.now(),
|
||||
this.beaconInfo.timestamp! - Date.now(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -165,6 +165,7 @@ export class Beacon extends TypedEventEmitter<Exclude<BeaconEvent, BeaconEvent.N
|
||||
if (!parsed.uri || !parsed.timestamp) return false; // we won't be able to process these
|
||||
const { timestamp } = parsed;
|
||||
return (
|
||||
this._beaconInfo!.timestamp &&
|
||||
// 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
|
||||
@@ -197,10 +198,10 @@ export class Beacon extends TypedEventEmitter<Exclude<BeaconEvent, BeaconEvent.N
|
||||
// may have a start timestamp in the future from Bob's POV
|
||||
// handle this by adding 6min of leniency to the start timestamp when it is in the future
|
||||
if (!this.beaconInfo) return;
|
||||
const startTimestamp = this.beaconInfo.timestamp > Date.now() ?
|
||||
this.beaconInfo.timestamp - 360000 /* 6min */ :
|
||||
const startTimestamp = this.beaconInfo.timestamp! > Date.now() ?
|
||||
this.beaconInfo.timestamp! - 360000 /* 6min */ :
|
||||
this.beaconInfo.timestamp;
|
||||
this._isLive = !!this._beaconInfo?.live &&
|
||||
this._isLive = !!this._beaconInfo?.live && !!startTimestamp &&
|
||||
isTimestampInDuration(startTimestamp, this._beaconInfo?.timeout, Date.now());
|
||||
|
||||
if (prevLiveness !== this.isLive) {
|
||||
|
||||
@@ -457,8 +457,7 @@ export class EventTimelineSet extends TypedEventEmitter<EmittedEvents, EventTime
|
||||
|
||||
let didUpdate = false;
|
||||
let lastEventWasNew = false;
|
||||
for (let i = 0; i < events.length; i++) {
|
||||
const event = events[i];
|
||||
for (const event of events) {
|
||||
const eventId = event.getId()!;
|
||||
|
||||
const existingTimeline = this._eventIdToTimeline.get(eventId);
|
||||
@@ -625,7 +624,7 @@ export class EventTimelineSet extends TypedEventEmitter<EmittedEvents, EventTime
|
||||
}
|
||||
EventTimeline.setEventMetadata(
|
||||
event,
|
||||
roomState,
|
||||
roomState!,
|
||||
false,
|
||||
);
|
||||
tlEvents[j] = event;
|
||||
@@ -702,6 +701,28 @@ export class EventTimelineSet extends TypedEventEmitter<EmittedEvents, EventTime
|
||||
);
|
||||
}
|
||||
|
||||
if (timeline.getTimelineSet() !== this) {
|
||||
throw new Error(`EventTimelineSet.addEventToTimeline: Timeline=${timeline.toString()} does not belong " +
|
||||
"in timelineSet(threadId=${this.thread?.id})`);
|
||||
}
|
||||
|
||||
// Make sure events don't get mixed in timelines they shouldn't be in (e.g. a
|
||||
// threaded message should not be in the main timeline).
|
||||
//
|
||||
// We can only run this check for timelines with a `room` because `canContain`
|
||||
// requires it
|
||||
if (this.room && !this.canContain(event)) {
|
||||
let eventDebugString = `event=${event.getId()}`;
|
||||
if (event.threadRootId) {
|
||||
eventDebugString += `(belongs to thread=${event.threadRootId})`;
|
||||
}
|
||||
logger.warn(
|
||||
`EventTimelineSet.addEventToTimeline: Ignoring ${eventDebugString} that does not belong ` +
|
||||
`in timeline=${timeline.toString()} timelineSet(threadId=${this.thread?.id})`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const eventId = event.getId()!;
|
||||
timeline.addEvent(event, {
|
||||
toStartOfTimeline,
|
||||
|
||||
@@ -19,7 +19,7 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import { logger } from '../logger';
|
||||
import { RoomState, IMarkerFoundOptions } from "./room-state";
|
||||
import { IMarkerFoundOptions, RoomState } from "./room-state";
|
||||
import { EventTimelineSet } from "./event-timeline-set";
|
||||
import { MatrixEvent } from "./event";
|
||||
import { Filter } from "../filter";
|
||||
@@ -95,8 +95,14 @@ export class EventTimeline {
|
||||
private readonly name: string;
|
||||
private events: MatrixEvent[] = [];
|
||||
private baseIndex = 0;
|
||||
private startState: RoomState;
|
||||
private endState: RoomState;
|
||||
|
||||
private startState?: RoomState;
|
||||
private endState?: RoomState;
|
||||
// If we have a roomId then we delegate pagination token storage to the room state objects `startState` and
|
||||
// `endState`, but for things like the notification timeline which mix multiple rooms we store the tokens ourselves.
|
||||
private startToken: string | null = null;
|
||||
private endToken: string | null = null;
|
||||
|
||||
private prevTimeline: EventTimeline | null = null;
|
||||
private nextTimeline: EventTimeline | null = null;
|
||||
public paginationRequests: Record<Direction, Promise<boolean> | null> = {
|
||||
@@ -126,10 +132,10 @@ export class EventTimeline {
|
||||
*/
|
||||
constructor(private readonly eventTimelineSet: EventTimelineSet) {
|
||||
this.roomId = eventTimelineSet.room?.roomId ?? null;
|
||||
this.startState = new RoomState(this.roomId);
|
||||
this.startState.paginationToken = null;
|
||||
this.endState = new RoomState(this.roomId);
|
||||
this.endState.paginationToken = null;
|
||||
if (this.roomId) {
|
||||
this.startState = new RoomState(this.roomId);
|
||||
this.endState = new RoomState(this.roomId);
|
||||
}
|
||||
|
||||
// this is used by client.js
|
||||
this.paginationRequests = { 'b': null, 'f': null };
|
||||
@@ -151,28 +157,8 @@ export class EventTimeline {
|
||||
throw new Error("Cannot initialise state after events are added");
|
||||
}
|
||||
|
||||
// We previously deep copied events here and used different copies in
|
||||
// the oldState and state events: this decision seems to date back
|
||||
// quite a way and was apparently made to fix a bug where modifications
|
||||
// made to the start state leaked through to the end state.
|
||||
// This really shouldn't be possible though: the events themselves should
|
||||
// not change. Duplicating the events uses a lot of extra memory,
|
||||
// so we now no longer do it. To assert that they really do never change,
|
||||
// freeze them! Note that we can't do this for events in general:
|
||||
// although it looks like the only things preventing us are the
|
||||
// 'status' flag, forwardLooking (which is only set once when adding to the
|
||||
// timeline) and possibly the sender (which seems like it should never be
|
||||
// reset but in practice causes a lot of the tests to break).
|
||||
for (const e of stateEvents) {
|
||||
Object.freeze(e);
|
||||
}
|
||||
|
||||
this.startState.setStateEvents(stateEvents, {
|
||||
timelineWasEmpty,
|
||||
});
|
||||
this.endState.setStateEvents(stateEvents, {
|
||||
timelineWasEmpty,
|
||||
});
|
||||
this.startState?.setStateEvents(stateEvents, { timelineWasEmpty });
|
||||
this.endState?.setStateEvents(stateEvents, { timelineWasEmpty });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -190,7 +176,7 @@ export class EventTimeline {
|
||||
public forkLive(direction: Direction): EventTimeline {
|
||||
const forkState = this.getState(direction);
|
||||
const timeline = new EventTimeline(this.eventTimelineSet);
|
||||
timeline.startState = forkState.clone();
|
||||
timeline.startState = forkState?.clone();
|
||||
// Now clobber the end state of the new live timeline with that from the
|
||||
// previous live timeline. It will be identical except that we'll keep
|
||||
// using the same RoomMember objects for the 'live' set of members with any
|
||||
@@ -198,7 +184,7 @@ export class EventTimeline {
|
||||
timeline.endState = forkState;
|
||||
// Firstly, we just stole the current timeline's end state, so it needs a new one.
|
||||
// Make an immutable copy of the state so back pagination will get the correct sentinels.
|
||||
this.endState = forkState.clone();
|
||||
this.endState = forkState?.clone();
|
||||
return timeline;
|
||||
}
|
||||
|
||||
@@ -214,8 +200,8 @@ export class EventTimeline {
|
||||
public fork(direction: Direction): EventTimeline {
|
||||
const forkState = this.getState(direction);
|
||||
const timeline = new EventTimeline(this.eventTimelineSet);
|
||||
timeline.startState = forkState.clone();
|
||||
timeline.endState = forkState.clone();
|
||||
timeline.startState = forkState?.clone();
|
||||
timeline.endState = forkState?.clone();
|
||||
return timeline;
|
||||
}
|
||||
|
||||
@@ -276,7 +262,7 @@ export class EventTimeline {
|
||||
*
|
||||
* @return {RoomState} state at the start/end of the timeline
|
||||
*/
|
||||
public getState(direction: Direction): RoomState {
|
||||
public getState(direction: Direction): RoomState | undefined {
|
||||
if (direction == EventTimeline.BACKWARDS) {
|
||||
return this.startState;
|
||||
} else if (direction == EventTimeline.FORWARDS) {
|
||||
@@ -296,7 +282,13 @@ export class EventTimeline {
|
||||
* @return {?string} pagination token
|
||||
*/
|
||||
public getPaginationToken(direction: Direction): string | null {
|
||||
return this.getState(direction).paginationToken;
|
||||
if (this.roomId) {
|
||||
return this.getState(direction)!.paginationToken;
|
||||
} else if (direction === Direction.Backward) {
|
||||
return this.startToken;
|
||||
} else {
|
||||
return this.endToken;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -304,12 +296,18 @@ export class EventTimeline {
|
||||
*
|
||||
* @param {?string} token pagination token
|
||||
*
|
||||
* @param {string} direction EventTimeline.BACKWARDS to set the paginatio
|
||||
* @param {string} direction EventTimeline.BACKWARDS to set the pagination
|
||||
* token for going backwards in time; EventTimeline.FORWARDS to set the
|
||||
* pagination token for going forwards in time.
|
||||
*/
|
||||
public setPaginationToken(token: string | null, direction: Direction): void {
|
||||
this.getState(direction).paginationToken = token;
|
||||
if (this.roomId) {
|
||||
this.getState(direction)!.paginationToken = token;
|
||||
} else if (direction === Direction.Backward) {
|
||||
this.startToken = token;
|
||||
} else {
|
||||
this.endToken = token;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -408,16 +406,14 @@ export class EventTimeline {
|
||||
const timelineSet = this.getTimelineSet();
|
||||
|
||||
if (timelineSet.room) {
|
||||
EventTimeline.setEventMetadata(event, roomState, toStartOfTimeline);
|
||||
EventTimeline.setEventMetadata(event, roomState!, toStartOfTimeline);
|
||||
|
||||
// modify state but only on unfiltered timelineSets
|
||||
if (
|
||||
event.isState() &&
|
||||
timelineSet.room.getUnfilteredTimelineSet() === timelineSet
|
||||
) {
|
||||
roomState.setStateEvents([event], {
|
||||
timelineWasEmpty,
|
||||
});
|
||||
roomState?.setStateEvents([event], { timelineWasEmpty });
|
||||
// it is possible that the act of setting the state event means we
|
||||
// can set more metadata (specifically sender/target props), so try
|
||||
// it again if the prop wasn't previously set. It may also mean that
|
||||
@@ -428,8 +424,8 @@ export class EventTimeline {
|
||||
// back in time, else we'll set the .sender value for BEFORE the given
|
||||
// member event, whereas we want to set the .sender value for the ACTUAL
|
||||
// member event itself.
|
||||
if (!event.sender || (event.getType() === "m.room.member" && !toStartOfTimeline)) {
|
||||
EventTimeline.setEventMetadata(event, roomState, toStartOfTimeline);
|
||||
if (!event.sender || (event.getType() === EventType.RoomMember && !toStartOfTimeline)) {
|
||||
EventTimeline.setEventMetadata(event, roomState!, toStartOfTimeline);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+44
-22
@@ -107,7 +107,7 @@ export interface IEventRelation {
|
||||
event_id?: string;
|
||||
is_falling_back?: boolean;
|
||||
"m.in_reply_to"?: {
|
||||
event_id: string;
|
||||
event_id?: string;
|
||||
};
|
||||
key?: string;
|
||||
}
|
||||
@@ -413,7 +413,7 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
|
||||
if (this.clearEvent) {
|
||||
return this.clearEvent.type;
|
||||
}
|
||||
return this.event.type;
|
||||
return this.event.type!;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -423,7 +423,7 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
|
||||
* @return {string} The event type.
|
||||
*/
|
||||
public getWireType(): EventType | string {
|
||||
return this.event.type;
|
||||
return this.event.type!;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -441,7 +441,7 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
|
||||
* @return {Number} The event timestamp, e.g. <code>1433502692297</code>
|
||||
*/
|
||||
public getTs(): number {
|
||||
return this.event.origin_server_ts;
|
||||
return this.event.origin_server_ts!;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -452,6 +452,26 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
|
||||
return this.event.origin_server_ts ? new Date(this.event.origin_server_ts) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a string containing details of this event
|
||||
*
|
||||
* This is intended for logging, to help trace errors. Example output:
|
||||
*
|
||||
* id=$HjnOHV646n0SjLDAqFrgIjim7RCpB7cdMXFrekWYAn type=m.room.encrypted sender=@user:example.com room=!room:example.com ts=2022-10-25T17:30:28.404Z
|
||||
*/
|
||||
public getDetails(): string {
|
||||
let details = `id=${this.getId()} type=${this.getWireType()} sender=${this.getSender()}`;
|
||||
const room = this.getRoomId();
|
||||
if (room) {
|
||||
details += ` room=${room}`;
|
||||
}
|
||||
const date = this.getDate();
|
||||
if (date) {
|
||||
details += ` ts=${date.toISOString()}`;
|
||||
}
|
||||
return details;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the (decrypted, if necessary) event content JSON, even if the event
|
||||
* was replaced by another event.
|
||||
@@ -625,8 +645,8 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
|
||||
): void {
|
||||
// keep the plain-text data for 'view source'
|
||||
this.clearEvent = {
|
||||
type: this.event.type,
|
||||
content: this.event.content,
|
||||
type: this.event.type!,
|
||||
content: this.event.content!,
|
||||
};
|
||||
this.event.type = cryptoType;
|
||||
this.event.content = cryptoContent;
|
||||
@@ -730,7 +750,7 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
|
||||
const wireContent = this.getWireContent();
|
||||
return crypto.requestRoomKey({
|
||||
algorithm: wireContent.algorithm,
|
||||
room_id: this.getRoomId(),
|
||||
room_id: this.getRoomId()!,
|
||||
session_id: wireContent.session_id,
|
||||
sender_key: wireContent.sender_key,
|
||||
}, this.getKeyRequestRecipients(userId), true);
|
||||
@@ -780,7 +800,7 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
|
||||
} else {
|
||||
res = await crypto.decryptEvent(this);
|
||||
if (options.isRetry === true) {
|
||||
logger.info(`Decrypted event on retry (id=${this.getId()})`);
|
||||
logger.info(`Decrypted event on retry (${this.getDetails()})`);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -790,10 +810,7 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
|
||||
const re = options.isRetry ? 're' : '';
|
||||
// For find results: this can produce "Error decrypting event (id=$ev)" and
|
||||
// "Error redecrypting event (id=$ev)".
|
||||
logger.error(
|
||||
`Error ${re}decrypting event ` +
|
||||
`(id=${this.getId()}): ${e.stack || e}`,
|
||||
);
|
||||
logger.error(`Error ${re}decrypting event (${this.getDetails()})`, e);
|
||||
this.decryptionPromise = null;
|
||||
this.retryDecryption = false;
|
||||
return;
|
||||
@@ -817,16 +834,21 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
|
||||
//
|
||||
if (this.retryDecryption) {
|
||||
// decryption error, but we have a retry queued.
|
||||
logger.log(`Got error decrypting event (id=${this.getId()}: ` +
|
||||
`${(<DecryptionError>e).detailedString}), but retrying`, e);
|
||||
logger.log(
|
||||
`Error decrypting event (${this.getDetails()}), but retrying: ` +
|
||||
(<DecryptionError>e).detailedString,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// decryption error, no retries queued. Warn about the error and
|
||||
// set it to m.bad.encrypted.
|
||||
//
|
||||
// the detailedString already includes the name and message of the error, and the stack isn't much use,
|
||||
// so we don't bother to log `e` separately.
|
||||
logger.warn(
|
||||
`Got error decrypting event (id=${this.getId()}: ${(<DecryptionError>e).detailedString})`,
|
||||
e,
|
||||
`Error decrypting event (${this.getDetails()}): ` +
|
||||
(<DecryptionError>e).detailedString,
|
||||
);
|
||||
|
||||
res = this.badEncryptedMessage((<DecryptionError>e).message);
|
||||
@@ -1007,7 +1029,7 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
|
||||
const value = this._localRedactionEvent;
|
||||
this._localRedactionEvent = null;
|
||||
if (this.event.unsigned) {
|
||||
this.event.unsigned.redacted_because = null;
|
||||
this.event.unsigned.redacted_because = undefined;
|
||||
}
|
||||
return !!value;
|
||||
}
|
||||
@@ -1194,8 +1216,8 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
|
||||
if (!this.isRedacted()) return null;
|
||||
|
||||
if (this.clearEvent?.unsigned) {
|
||||
return this.clearEvent?.unsigned.redacted_because;
|
||||
} else if (this.event.unsigned.redacted_because) {
|
||||
return this.clearEvent?.unsigned.redacted_because ?? null;
|
||||
} else if (this.event.unsigned?.redacted_because) {
|
||||
return this.event.unsigned.redacted_because;
|
||||
} else {
|
||||
return {};
|
||||
@@ -1246,7 +1268,7 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
|
||||
this.emit(MatrixEventEvent.LocalEventIdReplaced, this);
|
||||
}
|
||||
|
||||
this.localTimestamp = Date.now() - this.getAge();
|
||||
this.localTimestamp = Date.now() - this.getAge()!;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1290,7 +1312,7 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
|
||||
// State events cannot be m.replace relations
|
||||
return false;
|
||||
}
|
||||
return relation?.rel_type && relation.event_id && (relType ? relation.rel_type === relType : true);
|
||||
return !!(relation?.rel_type && relation.event_id && (relType ? relation.rel_type === relType : true));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1302,7 +1324,7 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
|
||||
if (!this.isRelation()) {
|
||||
return null;
|
||||
}
|
||||
return this.getWireContent()["m.relates_to"];
|
||||
return this.getWireContent()["m.relates_to"] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -164,7 +164,7 @@ export class IgnoredInvites {
|
||||
const senderServer = sender.split(":")[1];
|
||||
const roomServer = roomId.split(":")[1];
|
||||
for (const room of policyRooms) {
|
||||
const state = room.getUnfilteredTimelineSet().getLiveTimeline().getState(EventTimeline.FORWARDS);
|
||||
const state = room.getUnfilteredTimelineSet().getLiveTimeline().getState(EventTimeline.FORWARDS)!;
|
||||
|
||||
for (const { scope, entities } of [
|
||||
{ scope: PolicyScope.Room, entities: [roomId] },
|
||||
|
||||
@@ -12,9 +12,11 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import { ReceiptType } from "../@types/read_receipts";
|
||||
import { EventTimelineSet, EventType, MatrixEvent } from "../matrix";
|
||||
import { ListenerMap, TypedEventEmitter } from "./typed-event-emitter";
|
||||
import * as utils from "../utils";
|
||||
import { MatrixEvent } from "./event";
|
||||
import { EventType } from "../@types/event";
|
||||
import { EventTimelineSet } from "./event-timeline-set";
|
||||
|
||||
export const MAIN_ROOM_TIMELINE = "main";
|
||||
|
||||
|
||||
+29
-32
@@ -1008,8 +1008,8 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
* Removing just the old live timeline whilst preserving previous ones is not supported.
|
||||
*/
|
||||
public resetLiveTimeline(backPaginationToken?: string | null, forwardPaginationToken?: string | null): void {
|
||||
for (let i = 0; i < this.timelineSets.length; i++) {
|
||||
this.timelineSets[i].resetLiveTimeline(
|
||||
for (const timelineSet of this.timelineSets) {
|
||||
timelineSet.resetLiveTimeline(
|
||||
backPaginationToken ?? undefined,
|
||||
forwardPaginationToken ?? undefined,
|
||||
);
|
||||
@@ -1032,10 +1032,8 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
// state at the start and end of that timeline. These are more
|
||||
// for backwards-compatibility than anything else.
|
||||
this.timeline = this.getLiveTimeline().getEvents();
|
||||
this.oldState = this.getLiveTimeline()
|
||||
.getState(EventTimeline.BACKWARDS);
|
||||
this.currentState = this.getLiveTimeline()
|
||||
.getState(EventTimeline.FORWARDS);
|
||||
this.oldState = this.getLiveTimeline().getState(EventTimeline.BACKWARDS)!;
|
||||
this.currentState = this.getLiveTimeline().getState(EventTimeline.FORWARDS)!;
|
||||
|
||||
// Let people know to register new listeners for the new state
|
||||
// references. The reference won't necessarily change every time so only
|
||||
@@ -1564,8 +1562,8 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
pendingEvents = true,
|
||||
}: ICreateFilterOpts = {},
|
||||
): EventTimelineSet {
|
||||
if (this.filteredTimelineSets[filter.filterId]) {
|
||||
return this.filteredTimelineSets[filter.filterId];
|
||||
if (this.filteredTimelineSets[filter.filterId!]) {
|
||||
return this.filteredTimelineSets[filter.filterId!];
|
||||
}
|
||||
const opts = Object.assign({ filter, pendingEvents }, this.opts);
|
||||
const timelineSet = new EventTimelineSet(this, opts);
|
||||
@@ -1574,7 +1572,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
RoomEvent.TimelineReset,
|
||||
]);
|
||||
if (useSyncEvents) {
|
||||
this.filteredTimelineSets[filter.filterId] = timelineSet;
|
||||
this.filteredTimelineSets[filter.filterId!] = timelineSet;
|
||||
this.timelineSets.push(timelineSet);
|
||||
}
|
||||
|
||||
@@ -1623,7 +1621,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
}
|
||||
|
||||
private async getThreadListFilter(filterType = ThreadFilterType.All): Promise<Filter> {
|
||||
const myUserId = this.client.getUserId();
|
||||
const myUserId = this.client.getUserId()!;
|
||||
const filter = new Filter(myUserId);
|
||||
|
||||
const definition: IFilterDefinition = {
|
||||
@@ -1635,7 +1633,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
};
|
||||
|
||||
if (filterType === ThreadFilterType.My) {
|
||||
definition.room.timeline[FILTER_RELATED_BY_SENDERS.name] = [myUserId];
|
||||
definition!.room!.timeline![FILTER_RELATED_BY_SENDERS.name] = [myUserId];
|
||||
}
|
||||
|
||||
filter.setDefinition(definition);
|
||||
@@ -1653,7 +1651,10 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
let timelineSet: EventTimelineSet;
|
||||
if (Thread.hasServerSideListSupport) {
|
||||
timelineSet =
|
||||
new EventTimelineSet(this, this.opts, undefined, undefined, filterType ?? ThreadFilterType.All);
|
||||
new EventTimelineSet(this, {
|
||||
...this.opts,
|
||||
pendingEvents: false,
|
||||
}, undefined, undefined, filterType ?? ThreadFilterType.All);
|
||||
this.reEmitter.reEmit(timelineSet, [
|
||||
RoomEvent.Timeline,
|
||||
RoomEvent.TimelineReset,
|
||||
@@ -1681,7 +1682,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
return event.getSender() === this.client.getUserId();
|
||||
});
|
||||
if (filterType !== ThreadFilterType.My || currentUserParticipated) {
|
||||
timelineSet.getLiveTimeline().addEvent(thread.rootEvent, {
|
||||
timelineSet.getLiveTimeline().addEvent(thread.rootEvent!, {
|
||||
toStartOfTimeline: false,
|
||||
});
|
||||
}
|
||||
@@ -1851,8 +1852,8 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
* @param {Filter} filter the filter whose timelineSet is to be forgotten
|
||||
*/
|
||||
public removeFilteredTimelineSet(filter: Filter): void {
|
||||
const timelineSet = this.filteredTimelineSets[filter.filterId];
|
||||
delete this.filteredTimelineSets[filter.filterId];
|
||||
const timelineSet = this.filteredTimelineSets[filter.filterId!];
|
||||
delete this.filteredTimelineSets[filter.filterId!];
|
||||
const i = this.timelineSets.indexOf(timelineSet);
|
||||
if (i > -1) {
|
||||
this.timelineSets.splice(i, 1);
|
||||
@@ -1864,7 +1865,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
shouldLiveInThread: boolean;
|
||||
threadId?: string;
|
||||
} {
|
||||
if (!this.client.supportsExperimentalThreads()) {
|
||||
if (!this.client?.supportsExperimentalThreads()) {
|
||||
return {
|
||||
shouldLiveInRoom: true,
|
||||
shouldLiveInThread: false,
|
||||
@@ -2130,8 +2131,8 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
const { duplicateStrategy, timelineWasEmpty, fromCache } = addLiveEventOptions;
|
||||
|
||||
// add to our timeline sets
|
||||
for (let i = 0; i < this.timelineSets.length; i++) {
|
||||
this.timelineSets[i].addLiveEvent(event, {
|
||||
for (const timelineSet of this.timelineSets) {
|
||||
timelineSet.addLiveEvent(event, {
|
||||
duplicateStrategy,
|
||||
fromCache,
|
||||
timelineWasEmpty,
|
||||
@@ -2187,7 +2188,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
// call setEventMetadata to set up event.sender etc
|
||||
// as event is shared over all timelineSets, we set up its metadata based
|
||||
// on the unfiltered timelineSet.
|
||||
EventTimeline.setEventMetadata(event, this.getLiveTimeline().getState(EventTimeline.FORWARDS), false);
|
||||
EventTimeline.setEventMetadata(event, this.getLiveTimeline().getState(EventTimeline.FORWARDS)!, false);
|
||||
|
||||
this.txnToEvent[txnId] = event;
|
||||
if (this.pendingEventList) {
|
||||
@@ -2216,8 +2217,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (let i = 0; i < this.timelineSets.length; i++) {
|
||||
const timelineSet = this.timelineSets[i];
|
||||
for (const timelineSet of this.timelineSets) {
|
||||
if (timelineSet.getFilter()) {
|
||||
if (timelineSet.getFilter()!.filterRoomTimeline([event]).length) {
|
||||
timelineSet.addEventToTimeline(event,
|
||||
@@ -2324,9 +2324,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
thread?.timelineSet.handleRemoteEcho(localEvent, oldEventId, newEventId);
|
||||
|
||||
if (shouldLiveInRoom) {
|
||||
for (let i = 0; i < this.timelineSets.length; i++) {
|
||||
const timelineSet = this.timelineSets[i];
|
||||
|
||||
for (const timelineSet of this.timelineSets) {
|
||||
// if it's already in the timeline, update the timeline map. If it's not, add it.
|
||||
timelineSet.handleRemoteEcho(localEvent, oldEventId, newEventId);
|
||||
}
|
||||
@@ -2409,8 +2407,8 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
// if the event was already in the timeline (which will be the case if
|
||||
// opts.pendingEventOrdering==chronological), we need to update the
|
||||
// timeline map.
|
||||
for (let i = 0; i < this.timelineSets.length; i++) {
|
||||
this.timelineSets[i].replaceEventId(oldEventId, newEventId!);
|
||||
for (const timelineSet of this.timelineSets) {
|
||||
timelineSet.replaceEventId(oldEventId, newEventId!);
|
||||
}
|
||||
}
|
||||
} else if (newStatus == EventStatus.CANCELLED) {
|
||||
@@ -2645,8 +2643,8 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
* @param {String[]} eventIds A list of eventIds to remove.
|
||||
*/
|
||||
public removeEvents(eventIds: string[]): void {
|
||||
for (let i = 0; i < eventIds.length; ++i) {
|
||||
this.removeEvent(eventIds[i]);
|
||||
for (const eventId of eventIds) {
|
||||
this.removeEvent(eventId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2659,8 +2657,8 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
*/
|
||||
public removeEvent(eventId: string): boolean {
|
||||
let removedAny = false;
|
||||
for (let i = 0; i < this.timelineSets.length; i++) {
|
||||
const removed = this.timelineSets[i].removeEvent(eventId);
|
||||
for (const timelineSet of this.timelineSets) {
|
||||
const removed = timelineSet.removeEvent(eventId);
|
||||
if (removed) {
|
||||
if (removed.isRedaction()) {
|
||||
this.revertRedactionLocalEcho(removed);
|
||||
@@ -2742,8 +2740,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
||||
* @param {Array<MatrixEvent>} events an array of account_data events to add
|
||||
*/
|
||||
public addAccountData(events: MatrixEvent[]): void {
|
||||
for (let i = 0; i < events.length; i++) {
|
||||
const event = events[i];
|
||||
for (const event of events) {
|
||||
if (event.getType() === "m.tag") {
|
||||
this.addTags(event);
|
||||
}
|
||||
|
||||
+24
-4
@@ -16,16 +16,18 @@ limitations under the License.
|
||||
|
||||
import { Optional } from "matrix-events-sdk";
|
||||
|
||||
import { MatrixClient, MatrixEventEvent, RelationType, RoomEvent } from "../matrix";
|
||||
import { MatrixClient } from "../client";
|
||||
import { TypedReEmitter } from "../ReEmitter";
|
||||
import { IThreadBundledRelationship, MatrixEvent } from "./event";
|
||||
import { RelationType } from "../@types/event";
|
||||
import { IThreadBundledRelationship, MatrixEvent, MatrixEventEvent } from "./event";
|
||||
import { EventTimeline } from "./event-timeline";
|
||||
import { EventTimelineSet, EventTimelineSetHandlerMap } from './event-timeline-set';
|
||||
import { Room } from './room';
|
||||
import { NotificationCountType, Room, RoomEvent } from './room';
|
||||
import { RoomState } from "./room-state";
|
||||
import { ServerControlledNamespacedValue } from "../NamespacedValue";
|
||||
import { logger } from "../logger";
|
||||
import { ReadReceipt } from "./read-receipt";
|
||||
import { ReceiptType } from "../@types/read_receipts";
|
||||
|
||||
export enum ThreadEvent {
|
||||
New = "Thread.new",
|
||||
@@ -200,7 +202,7 @@ export class Thread extends ReadReceipt<EmittedEvents, EventHandlerMap> {
|
||||
};
|
||||
|
||||
public get roomState(): RoomState {
|
||||
return this.room.getLiveTimeline().getState(EventTimeline.FORWARDS);
|
||||
return this.room.getLiveTimeline().getState(EventTimeline.FORWARDS)!;
|
||||
}
|
||||
|
||||
private addEventToTimeline(event: MatrixEvent, toStartOfTimeline: boolean): void {
|
||||
@@ -416,6 +418,24 @@ export class Thread extends ReadReceipt<EmittedEvents, EventHandlerMap> {
|
||||
public addReceipt(event: MatrixEvent, synthetic: boolean): void {
|
||||
throw new Error("Unsupported function on the thread model");
|
||||
}
|
||||
|
||||
public hasUserReadEvent(userId: string, eventId: string): boolean {
|
||||
if (userId === this.client.getUserId()) {
|
||||
const publicReadReceipt = this.getReadReceiptForUserId(userId, false, ReceiptType.Read);
|
||||
const privateReadReceipt = this.getReadReceiptForUserId(userId, false, ReceiptType.ReadPrivate);
|
||||
const hasUnreads = this.room.getThreadUnreadNotificationCount(this.id, NotificationCountType.Total) > 0;
|
||||
|
||||
if (!publicReadReceipt && !privateReadReceipt && !hasUnreads) {
|
||||
// Consider an event read if it's part of a thread that has no
|
||||
// read receipts and has no notifications. It is likely that it is
|
||||
// part of a thread that was created before read receipts for threads
|
||||
// were supported (via MSC3771)
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return super.hasUserReadEvent(userId, eventId);
|
||||
}
|
||||
}
|
||||
|
||||
export const FILTER_RELATED_BY_SENDERS = new ServerControlledNamespacedValue(
|
||||
|
||||
+2
-2
@@ -134,8 +134,8 @@ export class User extends TypedEventEmitter<UserEvent, UserEventHandlerMap> {
|
||||
|
||||
this.updateModifiedTime();
|
||||
|
||||
for (let i = 0; i < eventsToFire.length; i++) {
|
||||
this.emit(eventsToFire[i], event, this);
|
||||
for (const eventToFire of eventsToFire) {
|
||||
this.emit(eventToFire, event, this);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+35
-22
@@ -93,6 +93,9 @@ const DEFAULT_OVERRIDE_RULES: IPushRule[] = [
|
||||
],
|
||||
actions: [],
|
||||
},
|
||||
];
|
||||
|
||||
const DEFAULT_UNDERRIDE_RULES: IPushRule[] = [
|
||||
{
|
||||
// For homeservers which don't support MSC3914 yet
|
||||
rule_id: ".org.matrix.msc3914.rule.room.call",
|
||||
@@ -135,8 +138,7 @@ export class PushProcessor {
|
||||
*/
|
||||
public static actionListToActionsObject(actionList: PushRuleAction[]): IActionsObject {
|
||||
const actionObj: IActionsObject = { notify: false, tweaks: {} };
|
||||
for (let i = 0; i < actionList.length; ++i) {
|
||||
const action = actionList[i];
|
||||
for (const action of actionList) {
|
||||
if (action === PushRuleActionName.Notify) {
|
||||
actionObj.notify = true;
|
||||
} else if (typeof action === 'object') {
|
||||
@@ -164,6 +166,7 @@ export class PushProcessor {
|
||||
if (!newRules) newRules = {} as IPushRules;
|
||||
if (!newRules.global) newRules.global = {} as PushRuleSet;
|
||||
if (!newRules.global.override) newRules.global.override = [];
|
||||
if (!newRules.global.override) newRules.global.underride = [];
|
||||
|
||||
// Merge the client-level defaults with the ones from the server
|
||||
const globalOverrides = newRules.global.override;
|
||||
@@ -184,21 +187,37 @@ export class PushProcessor {
|
||||
}
|
||||
}
|
||||
|
||||
const globalUnderrides = newRules.global.underride ?? [];
|
||||
for (const underride of DEFAULT_UNDERRIDE_RULES) {
|
||||
const existingRule = globalUnderrides
|
||||
.find((r) => r.rule_id === underride.rule_id);
|
||||
|
||||
if (existingRule) {
|
||||
// Copy over the actions, default, and conditions. Don't touch the user's preference.
|
||||
existingRule.default = underride.default;
|
||||
existingRule.conditions = underride.conditions;
|
||||
existingRule.actions = underride.actions;
|
||||
} else {
|
||||
// Add the rule
|
||||
const ruleId = underride.rule_id;
|
||||
logger.warn(`Adding default global underride for ${ruleId}`);
|
||||
globalUnderrides.push(underride);
|
||||
}
|
||||
}
|
||||
|
||||
return newRules;
|
||||
}
|
||||
|
||||
private static cachedGlobToRegex: Record<string, RegExp> = {}; // $glob: RegExp
|
||||
|
||||
private matchingRuleFromKindSet(ev: MatrixEvent, kindset: PushRuleSet): IAnnotatedPushRule | null {
|
||||
for (let ruleKindIndex = 0; ruleKindIndex < RULEKINDS_IN_ORDER.length; ++ruleKindIndex) {
|
||||
const kind = RULEKINDS_IN_ORDER[ruleKindIndex];
|
||||
for (const kind of RULEKINDS_IN_ORDER) {
|
||||
const ruleset = kindset[kind];
|
||||
if (!ruleset) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (let ruleIndex = 0; ruleIndex < ruleset.length; ++ruleIndex) {
|
||||
const rule = ruleset[ruleIndex];
|
||||
for (const rule of ruleset) {
|
||||
if (!rule.enabled) {
|
||||
continue;
|
||||
}
|
||||
@@ -219,8 +238,11 @@ export class PushProcessor {
|
||||
return null;
|
||||
}
|
||||
|
||||
private templateRuleToRaw(kind: PushRuleKind, tprule: any): any {
|
||||
const rawrule = {
|
||||
private templateRuleToRaw(
|
||||
kind: PushRuleKind,
|
||||
tprule: IPushRule,
|
||||
): Pick<IPushRule, "rule_id" | "actions" | "conditions"> | null {
|
||||
const rawrule: Pick<IPushRule, "rule_id" | "actions" | "conditions"> = {
|
||||
'rule_id': tprule.rule_id,
|
||||
'actions': tprule.actions,
|
||||
'conditions': [],
|
||||
@@ -234,7 +256,7 @@ export class PushProcessor {
|
||||
if (!tprule.rule_id) {
|
||||
return null;
|
||||
}
|
||||
rawrule.conditions.push({
|
||||
rawrule.conditions!.push({
|
||||
'kind': ConditionKind.EventMatch,
|
||||
'key': 'room_id',
|
||||
'value': tprule.rule_id,
|
||||
@@ -244,7 +266,7 @@ export class PushProcessor {
|
||||
if (!tprule.rule_id) {
|
||||
return null;
|
||||
}
|
||||
rawrule.conditions.push({
|
||||
rawrule.conditions!.push({
|
||||
'kind': ConditionKind.EventMatch,
|
||||
'key': 'user_id',
|
||||
'value': tprule.rule_id,
|
||||
@@ -254,7 +276,7 @@ export class PushProcessor {
|
||||
if (!tprule.pattern) {
|
||||
return null;
|
||||
}
|
||||
rawrule.conditions.push({
|
||||
rawrule.conditions!.push({
|
||||
'kind': ConditionKind.EventMatch,
|
||||
'key': 'content.body',
|
||||
'pattern': tprule.pattern,
|
||||
@@ -474,17 +496,8 @@ export class PushProcessor {
|
||||
return actionObj;
|
||||
}
|
||||
|
||||
public ruleMatchesEvent(rule: IPushRule, ev: MatrixEvent): boolean {
|
||||
if (!rule.conditions?.length) return true;
|
||||
|
||||
let ret = true;
|
||||
for (let i = 0; i < rule.conditions.length; ++i) {
|
||||
const cond = rule.conditions[i];
|
||||
// @ts-ignore
|
||||
ret &= this.eventFulfillsCondition(cond, ev);
|
||||
}
|
||||
//console.log("Rule "+rule.rule_id+(ret ? " matches" : " doesn't match"));
|
||||
return ret;
|
||||
public ruleMatchesEvent(rule: Partial<IPushRule> & Pick<IPushRule, "conditions">, ev: MatrixEvent): boolean {
|
||||
return !rule.conditions?.some(cond => !this.eventFulfillsCondition(cond, ev));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -101,7 +101,7 @@ export function clearTimeout(key: number): void {
|
||||
}
|
||||
|
||||
// remove the element from the list
|
||||
let i;
|
||||
let i: number;
|
||||
for (i = 0; i < callbackList.length; i++) {
|
||||
const cb = callbackList[i];
|
||||
if (cb.key == key) {
|
||||
@@ -137,7 +137,6 @@ function scheduleRealCallback(): void {
|
||||
}
|
||||
|
||||
function runCallbacks(): void {
|
||||
let cb: Callback;
|
||||
const timestamp = Date.now();
|
||||
debuglog("runCallbacks: now:", timestamp);
|
||||
|
||||
@@ -149,7 +148,7 @@ function runCallbacks(): void {
|
||||
if (!first || first.runAt > timestamp) {
|
||||
break;
|
||||
}
|
||||
cb = callbackList.shift()!;
|
||||
const cb = callbackList.shift()!;
|
||||
debuglog("runCallbacks: popping", cb.key);
|
||||
callbacksToRun.push(cb);
|
||||
}
|
||||
@@ -159,8 +158,7 @@ function runCallbacks(): void {
|
||||
// register their own setTimeouts.
|
||||
scheduleRealCallback();
|
||||
|
||||
for (let i = 0; i < callbacksToRun.length; i++) {
|
||||
cb = callbacksToRun[i];
|
||||
for (const cb of callbacksToRun) {
|
||||
try {
|
||||
cb.func.apply(global, cb.params);
|
||||
} catch (e) {
|
||||
|
||||
@@ -16,14 +16,13 @@ limitations under the License.
|
||||
|
||||
import { UnstableValue } from "matrix-events-sdk";
|
||||
|
||||
import { RendezvousChannel } from ".";
|
||||
import { RendezvousChannel, RendezvousFailureListener, RendezvousFailureReason, RendezvousIntent } from ".";
|
||||
import { MatrixClient } from "../client";
|
||||
import { CrossSigningInfo } from "../crypto/CrossSigning";
|
||||
import { DeviceInfo } from "../crypto/deviceinfo";
|
||||
import { buildFeatureSupportMap, Feature, ServerSupport } from "../feature";
|
||||
import { logger } from "../logger";
|
||||
import { sleep } from "../utils";
|
||||
import { RendezvousFailureListener, RendezvousFailureReason, RendezvousIntent } from ".";
|
||||
|
||||
enum PayloadType {
|
||||
Start = 'm.login.start',
|
||||
|
||||
+63
-5
@@ -33,8 +33,11 @@ import {
|
||||
SlidingSyncEvent,
|
||||
SlidingSyncState,
|
||||
} from "./sliding-sync";
|
||||
import { EventType, IPushRules } from "./matrix";
|
||||
import { EventType } from "./@types/event";
|
||||
import { IPushRules } from "./@types/PushRules";
|
||||
import { PushProcessor } from "./pushprocessor";
|
||||
import { RoomStateEvent } from "./models/room-state";
|
||||
import { RoomMemberEvent } from "./models/room-member";
|
||||
|
||||
// Number of consecutive failed syncs that will lead to a syncState of ERROR as opposed
|
||||
// to RECONNECTING. This is needed to inform the client of server issues when the
|
||||
@@ -389,6 +392,60 @@ export class SlidingSyncSdk {
|
||||
return this.syncStateData ?? null;
|
||||
}
|
||||
|
||||
// Helper functions which set up JS SDK structs are below and are identical to the sync v2 counterparts
|
||||
|
||||
public createRoom(roomId: string): Room { // XXX cargoculted from sync.ts
|
||||
const { timelineSupport } = this.client;
|
||||
const room = new Room(roomId, this.client, this.client.getUserId()!, {
|
||||
lazyLoadMembers: this.opts.lazyLoadMembers,
|
||||
pendingEventOrdering: this.opts.pendingEventOrdering,
|
||||
timelineSupport,
|
||||
});
|
||||
this.client.reEmitter.reEmit(room, [
|
||||
RoomEvent.Name,
|
||||
RoomEvent.Redaction,
|
||||
RoomEvent.RedactionCancelled,
|
||||
RoomEvent.Receipt,
|
||||
RoomEvent.Tags,
|
||||
RoomEvent.LocalEchoUpdated,
|
||||
RoomEvent.AccountData,
|
||||
RoomEvent.MyMembership,
|
||||
RoomEvent.Timeline,
|
||||
RoomEvent.TimelineReset,
|
||||
]);
|
||||
this.registerStateListeners(room);
|
||||
return room;
|
||||
}
|
||||
|
||||
private registerStateListeners(room: Room): void { // XXX cargoculted from sync.ts
|
||||
// we need to also re-emit room state and room member events, so hook it up
|
||||
// to the client now. We need to add a listener for RoomState.members in
|
||||
// order to hook them correctly.
|
||||
this.client.reEmitter.reEmit(room.currentState, [
|
||||
RoomStateEvent.Events,
|
||||
RoomStateEvent.Members,
|
||||
RoomStateEvent.NewMember,
|
||||
RoomStateEvent.Update,
|
||||
]);
|
||||
room.currentState.on(RoomStateEvent.NewMember, (event, state, member) => {
|
||||
member.user = this.client.getUser(member.userId) ?? undefined;
|
||||
this.client.reEmitter.reEmit(member, [
|
||||
RoomMemberEvent.Name,
|
||||
RoomMemberEvent.Typing,
|
||||
RoomMemberEvent.PowerLevel,
|
||||
RoomMemberEvent.Membership,
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
private deregisterStateListeners(room: Room): void { // XXX cargoculted from sync.ts
|
||||
// could do with a better way of achieving this.
|
||||
room.currentState.removeAllListeners(RoomStateEvent.Events);
|
||||
room.currentState.removeAllListeners(RoomStateEvent.Members);
|
||||
room.currentState.removeAllListeners(RoomStateEvent.NewMember);
|
||||
} */
|
||||
|
||||
private shouldAbortSync(error: MatrixError): boolean {
|
||||
if (error.errcode === "M_UNKNOWN_TOKEN") {
|
||||
// The logout already happened, we just need to stop.
|
||||
@@ -484,7 +541,7 @@ export class SlidingSyncSdk {
|
||||
|
||||
if (roomData.invite_state) {
|
||||
const inviteStateEvents = mapEvents(this.client, room.roomId, roomData.invite_state);
|
||||
this.processRoomEvents(room, inviteStateEvents);
|
||||
this.injectRoomEvents(room, inviteStateEvents);
|
||||
if (roomData.initial) {
|
||||
room.recalculate();
|
||||
this.client.store.storeRoom(room);
|
||||
@@ -552,10 +609,11 @@ export class SlidingSyncSdk {
|
||||
// reason to stop incrementally tracking notifications and
|
||||
// reset the timeline.
|
||||
this.client.resetNotifTimelineSet();
|
||||
this.registerStateListeners(room);
|
||||
}
|
||||
} */
|
||||
|
||||
this.processRoomEvents(room, stateEvents, timelineEvents, false);
|
||||
this.injectRoomEvents(room, stateEvents, timelineEvents, false);
|
||||
|
||||
// we deliberately don't add ephemeral events to the timeline
|
||||
room.addEphemeralEvents(ephemeralEvents);
|
||||
@@ -594,6 +652,7 @@ export class SlidingSyncSdk {
|
||||
}
|
||||
|
||||
/**
|
||||
* Injects events into a room's model.
|
||||
* @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.
|
||||
@@ -601,7 +660,7 @@ export class SlidingSyncSdk {
|
||||
* @param {boolean} fromCache whether the sync response came from cache
|
||||
* is earlier in time. Higher index is later.
|
||||
*/
|
||||
private processRoomEvents(
|
||||
public injectRoomEvents(
|
||||
room: Room,
|
||||
stateEventList: MatrixEvent[],
|
||||
timelineEventList?: MatrixEvent[],
|
||||
@@ -822,7 +881,6 @@ function ensureNameEvent(client: MatrixClient, roomId: string, roomData: MSC3575
|
||||
|
||||
// Helper functions which set up JS SDK structs are below and are identical to the sync v2 counterparts,
|
||||
// just outside the class.
|
||||
|
||||
function mapEvents(client: MatrixClient, roomId: string | undefined, events: object[], decrypt = true): MatrixEvent[] {
|
||||
const mapper = client.getEventMapper({ decrypt });
|
||||
return (events as Array<IStrippedState | IRoomEvent | IStateEvent | IMinimalEvent>).map(function(e) {
|
||||
|
||||
+35
-1
@@ -353,6 +353,11 @@ export class SlidingSync extends TypedEventEmitter<SlidingSyncEvent, SlidingSync
|
||||
private desiredRoomSubscriptions = new Set<string>(); // the *desired* room subscriptions
|
||||
private confirmedRoomSubscriptions = new Set<string>();
|
||||
|
||||
// map of custom subscription name to the subscription
|
||||
private customSubscriptions: Map<string, MSC3575RoomSubscription> = new Map();
|
||||
// map of room ID to custom subscription name
|
||||
private roomIdToCustomSubscription: Map<string, string> = new Map();
|
||||
|
||||
private pendingReq?: Promise<MSC3575SlidingSyncResponse>;
|
||||
private abortController?: AbortController;
|
||||
|
||||
@@ -375,6 +380,30 @@ export class SlidingSync extends TypedEventEmitter<SlidingSyncEvent, SlidingSync
|
||||
this.lists = lists.map((l) => new SlidingList(l));
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a custom room subscription, referred to by an arbitrary name. If a subscription with this
|
||||
* name already exists, it is replaced. No requests are sent by calling this method.
|
||||
* @param name The name of the subscription. Only used to reference this subscription in
|
||||
* useCustomSubscription.
|
||||
* @param sub The subscription information.
|
||||
*/
|
||||
public addCustomSubscription(name: string, sub: MSC3575RoomSubscription) {
|
||||
this.customSubscriptions.set(name, sub);
|
||||
}
|
||||
|
||||
/**
|
||||
* Use a custom subscription previously added via addCustomSubscription. No requests are sent
|
||||
* by calling this method. Use modifyRoomSubscriptions to resend subscription information.
|
||||
* @param roomId The room to use the subscription in.
|
||||
* @param name The name of the subscription. If this name is unknown, the default subscription
|
||||
* will be used.
|
||||
*/
|
||||
public useCustomSubscription(roomId: string, name: string) {
|
||||
this.roomIdToCustomSubscription.set(roomId, name);
|
||||
// unconfirm this subscription so a resend() will send it up afresh.
|
||||
this.confirmedRoomSubscriptions.delete(roomId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the length of the sliding lists.
|
||||
* @returns The number of lists in the sync request
|
||||
@@ -806,7 +835,12 @@ export class SlidingSync extends TypedEventEmitter<SlidingSyncEvent, SlidingSync
|
||||
if (newSubscriptions.size > 0) {
|
||||
reqBody.room_subscriptions = {};
|
||||
for (const roomId of newSubscriptions) {
|
||||
reqBody.room_subscriptions[roomId] = this.roomSubscriptionInfo;
|
||||
const customSubName = this.roomIdToCustomSubscription.get(roomId);
|
||||
let sub = this.roomSubscriptionInfo;
|
||||
if (customSubName && this.customSubscriptions.has(customSubName)) {
|
||||
sub = this.customSubscriptions.get(customSubName)!;
|
||||
}
|
||||
reqBody.room_subscriptions[roomId] = sub;
|
||||
}
|
||||
}
|
||||
if (this.txnId) {
|
||||
|
||||
@@ -454,8 +454,8 @@ export class LocalIndexedDBStoreBackend implements IIndexedDBBackend {
|
||||
return utils.promiseTry<void>(() => {
|
||||
const txn = this.db!.transaction(["accountData"], "readwrite");
|
||||
const store = txn.objectStore("accountData");
|
||||
for (let i = 0; i < accountData.length; i++) {
|
||||
store.put(accountData[i]); // put == UPSERT
|
||||
for (const event of accountData) {
|
||||
store.put(event); // put == UPSERT
|
||||
}
|
||||
return txnAsPromise(txn).then();
|
||||
});
|
||||
|
||||
+42
-38
@@ -400,47 +400,51 @@ export class SyncAccumulator {
|
||||
acc[INVITED_COUNT_KEY] = sum[INVITED_COUNT_KEY] || acc[INVITED_COUNT_KEY];
|
||||
}
|
||||
|
||||
if (data.ephemeral && data.ephemeral.events) {
|
||||
data.ephemeral.events.forEach((e) => {
|
||||
// We purposefully do not persist m.typing events.
|
||||
// Technically you could refresh a browser before the timer on a
|
||||
// typing event is up, so it'll look like you aren't typing when
|
||||
// you really still are. However, the alternative is worse. If
|
||||
// we do persist typing events, it will look like people are
|
||||
// typing forever until someone really does start typing (which
|
||||
// will prompt Synapse to send down an actual m.typing event to
|
||||
// clobber the one we persisted).
|
||||
if (e.type !== EventType.Receipt || !e.content) {
|
||||
// This means we'll drop unknown ephemeral events but that
|
||||
// seems okay.
|
||||
return;
|
||||
}
|
||||
// Handle m.receipt events. They clobber based on:
|
||||
// (user_id, receipt_type)
|
||||
// but they are keyed in the event as:
|
||||
// content:{ $event_id: { $receipt_type: { $user_id: {json} }}}
|
||||
// so store them in the former so we can accumulate receipt deltas
|
||||
// quickly and efficiently (we expect a lot of them). Fold the
|
||||
// receipt type into the key name since we only have 1 at the
|
||||
// moment (m.read) and nested JSON objects are slower and more
|
||||
// of a hassle to work with. We'll inflate this back out when
|
||||
// getJSON() is called.
|
||||
Object.keys(e.content).forEach((eventId) => {
|
||||
Object.entries(e.content[eventId]).forEach(([key, value]) => {
|
||||
if (!isSupportedReceiptType(key)) return;
|
||||
data.ephemeral?.events?.forEach((e) => {
|
||||
// We purposefully do not persist m.typing events.
|
||||
// Technically you could refresh a browser before the timer on a
|
||||
// typing event is up, so it'll look like you aren't typing when
|
||||
// you really still are. However, the alternative is worse. If
|
||||
// we do persist typing events, it will look like people are
|
||||
// typing forever until someone really does start typing (which
|
||||
// will prompt Synapse to send down an actual m.typing event to
|
||||
// clobber the one we persisted).
|
||||
if (e.type !== EventType.Receipt || !e.content) {
|
||||
// This means we'll drop unknown ephemeral events but that
|
||||
// seems okay.
|
||||
return;
|
||||
}
|
||||
// Handle m.receipt events. They clobber based on:
|
||||
// (user_id, receipt_type)
|
||||
// but they are keyed in the event as:
|
||||
// content:{ $event_id: { $receipt_type: { $user_id: {json} }}}
|
||||
// so store them in the former so we can accumulate receipt deltas
|
||||
// quickly and efficiently (we expect a lot of them). Fold the
|
||||
// receipt type into the key name since we only have 1 at the
|
||||
// moment (m.read) and nested JSON objects are slower and more
|
||||
// of a hassle to work with. We'll inflate this back out when
|
||||
// getJSON() is called.
|
||||
Object.keys(e.content).forEach((eventId) => {
|
||||
Object.entries<{
|
||||
[eventId: string]: {
|
||||
[receiptType: string]: {
|
||||
[userId: string]: IMinimalEvent;
|
||||
};
|
||||
};
|
||||
}>(e.content[eventId]).forEach(([key, value]) => {
|
||||
if (!isSupportedReceiptType(key)) return;
|
||||
|
||||
Object.keys(value!).forEach((userId) => {
|
||||
// clobber on user ID
|
||||
currentData._readReceipts[userId] = {
|
||||
data: e.content[eventId][key][userId],
|
||||
type: key as ReceiptType,
|
||||
eventId: eventId,
|
||||
};
|
||||
});
|
||||
Object.keys(value).forEach((userId) => {
|
||||
// clobber on user ID
|
||||
currentData._readReceipts[userId] = {
|
||||
data: e.content[eventId][key][userId],
|
||||
type: key as ReceiptType,
|
||||
eventId: eventId,
|
||||
};
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// if we got a limited sync, we need to remove all timeline entries or else
|
||||
// we will have gaps in the timeline.
|
||||
@@ -551,7 +555,7 @@ export class SyncAccumulator {
|
||||
};
|
||||
// Add account data
|
||||
Object.keys(roomData._accountData).forEach((evType) => {
|
||||
roomJson.account_data.events.push(roomData._accountData[evType] as IMinimalEvent);
|
||||
roomJson.account_data.events.push(roomData._accountData[evType]);
|
||||
});
|
||||
|
||||
// Add receipt data
|
||||
|
||||
+13
-15
@@ -334,7 +334,7 @@ export class SyncApi {
|
||||
// events so that clients can start back-paginating.
|
||||
room.getLiveTimeline().setPaginationToken(leaveObj.timeline.prev_batch, EventTimeline.BACKWARDS);
|
||||
|
||||
await this.processRoomEvents(room, stateEvents, events);
|
||||
await this.injectRoomEvents(room, stateEvents, events);
|
||||
|
||||
room.recalculate();
|
||||
client.store.storeRoom(room);
|
||||
@@ -367,7 +367,7 @@ export class SyncApi {
|
||||
response.messages.chunk = response.messages.chunk || [];
|
||||
response.state = response.state || [];
|
||||
|
||||
// FIXME: Mostly duplicated from processRoomEvents but not entirely
|
||||
// FIXME: Mostly duplicated from injectRoomEvents but not entirely
|
||||
// because "state" in this API is at the BEGINNING of the chunk
|
||||
const oldStateEvents = utils.deepCopy(response.state)
|
||||
.map(client.getEventMapper());
|
||||
@@ -821,7 +821,6 @@ export class SyncApi {
|
||||
|
||||
let data: ISyncResponse;
|
||||
try {
|
||||
//debuglog('Starting sync since=' + syncToken);
|
||||
if (!this.currentSyncRequest) {
|
||||
this.currentSyncRequest = this.doSyncRequest(syncOptions, syncToken);
|
||||
}
|
||||
@@ -834,8 +833,6 @@ export class SyncApi {
|
||||
this.currentSyncRequest = undefined;
|
||||
}
|
||||
|
||||
//debuglog('Completed sync, next_batch=' + data.next_batch);
|
||||
|
||||
// set the sync token NOW *before* processing the events. We do this so
|
||||
// if something barfs on an event we can skip it rather than constantly
|
||||
// polling with the same token.
|
||||
@@ -1210,7 +1207,7 @@ export class SyncApi {
|
||||
const room = inviteObj.room;
|
||||
const stateEvents = this.mapSyncEventsFormat(inviteObj.invite_state, room);
|
||||
|
||||
await this.processRoomEvents(room, stateEvents);
|
||||
await this.injectRoomEvents(room, stateEvents);
|
||||
|
||||
const inviter = room.currentState.getStateEvents(EventType.RoomMember, client.getUserId()!)?.getSender();
|
||||
|
||||
@@ -1366,7 +1363,7 @@ export class SyncApi {
|
||||
}
|
||||
|
||||
try {
|
||||
await this.processRoomEvents(room, stateEvents, events, syncEventData.fromCache);
|
||||
await this.injectRoomEvents(room, stateEvents, events, syncEventData.fromCache);
|
||||
} catch (e) {
|
||||
logger.error(`Failed to process events on room ${room.roomId}:`, e);
|
||||
}
|
||||
@@ -1421,7 +1418,7 @@ export class SyncApi {
|
||||
const events = this.mapSyncEventsFormat(leaveObj.timeline, room);
|
||||
const accountDataEvents = this.mapSyncEventsFormat(leaveObj.account_data);
|
||||
|
||||
await this.processRoomEvents(room, stateEvents, events);
|
||||
await this.injectRoomEvents(room, stateEvents, events);
|
||||
room.addAccountData(accountDataEvents);
|
||||
|
||||
room.recalculate();
|
||||
@@ -1539,6 +1536,7 @@ export class SyncApi {
|
||||
{
|
||||
prefix: '',
|
||||
localTimeoutMs: 15 * 1000,
|
||||
abortSignal: this.abortController?.signal,
|
||||
},
|
||||
).then(() => {
|
||||
success();
|
||||
@@ -1659,14 +1657,15 @@ export class SyncApi {
|
||||
}
|
||||
|
||||
/**
|
||||
* Injects events into a room's model.
|
||||
* @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, including threaded. Lower index
|
||||
* @param {boolean} fromCache whether the sync response came from cache
|
||||
* is earlier in time. Higher index is later.
|
||||
* @param {boolean} fromCache whether the sync response came from cache
|
||||
*/
|
||||
private async processRoomEvents(
|
||||
public async injectRoomEvents(
|
||||
room: Room,
|
||||
stateEventList: MatrixEvent[],
|
||||
timelineEventList?: MatrixEvent[],
|
||||
@@ -1747,11 +1746,10 @@ export class SyncApi {
|
||||
private processEventsForNotifs(room: Room, timelineEventList: MatrixEvent[]): void {
|
||||
// gather our notifications into this.notifEvents
|
||||
if (this.client.getNotifTimelineSet()) {
|
||||
for (let i = 0; i < timelineEventList.length; i++) {
|
||||
const pushActions = this.client.getPushActionsForEvent(timelineEventList[i]);
|
||||
if (pushActions && pushActions.notify &&
|
||||
pushActions.tweaks && pushActions.tweaks.highlight) {
|
||||
this.notifEvents.push(timelineEventList[i]);
|
||||
for (const event of timelineEventList) {
|
||||
const pushActions = this.client.getPushActionsForEvent(event);
|
||||
if (pushActions?.notify && pushActions.tweaks?.highlight) {
|
||||
this.notifEvents.push(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -190,9 +190,9 @@ export function isFunction(value: any) {
|
||||
*/
|
||||
// note using 'keys' here would shadow the 'keys' function defined above
|
||||
export function checkObjectHasKeys(obj: object, keys: string[]) {
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
if (!obj.hasOwnProperty(keys[i])) {
|
||||
throw new Error("Missing required key: " + keys[i]);
|
||||
for (const key of keys) {
|
||||
if (!obj.hasOwnProperty(key)) {
|
||||
throw new Error("Missing required key: " + key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
let audioContext: AudioContext | null = null;
|
||||
let refCount = 0;
|
||||
|
||||
/**
|
||||
* Acquires a reference to the shared AudioContext.
|
||||
* It's highly recommended to reuse this AudioContext rather than creating your
|
||||
* own, because multiple AudioContexts can be problematic in some browsers.
|
||||
* Make sure to call releaseContext when you're done using it.
|
||||
* @returns {AudioContext} The shared AudioContext
|
||||
*/
|
||||
export const acquireContext = (): AudioContext => {
|
||||
if (audioContext === null) audioContext = new AudioContext();
|
||||
refCount++;
|
||||
return audioContext;
|
||||
};
|
||||
|
||||
/**
|
||||
* Signals that one of the references to the shared AudioContext has been
|
||||
* released, allowing the context and associated hardware resources to be
|
||||
* cleaned up if nothing else is using it.
|
||||
*/
|
||||
export const releaseContext = () => {
|
||||
refCount--;
|
||||
if (refCount === 0) {
|
||||
audioContext?.close();
|
||||
audioContext = null;
|
||||
}
|
||||
};
|
||||
+728
-247
File diff suppressed because it is too large
Load Diff
+180
-83
@@ -14,13 +14,13 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { MatrixEvent, MatrixEventEvent } from '../models/event';
|
||||
import { MatrixEvent } from '../models/event';
|
||||
import { logger } from '../logger';
|
||||
import { CallDirection, CallErrorCode, CallState, createNewMatrixCall, MatrixCall } from './call';
|
||||
import { CallDirection, CallError, CallErrorCode, CallState, createNewMatrixCall, MatrixCall } from './call';
|
||||
import { EventType } from '../@types/event';
|
||||
import { ClientEvent, MatrixClient } from '../client';
|
||||
import { MCallAnswer, MCallHangupReject } from "./callEventTypes";
|
||||
import { SyncState } from "../sync";
|
||||
import { GroupCall, GroupCallErrorCode, GroupCallEvent, GroupCallUnknownDeviceError } from './groupCall';
|
||||
import { RoomEvent } from "../models/room";
|
||||
|
||||
// Don't ring unless we'd be ringing for at least 3 seconds: the user needs some
|
||||
@@ -36,10 +36,15 @@ export type CallEventHandlerEventHandlerMap = {
|
||||
};
|
||||
|
||||
export class CallEventHandler {
|
||||
client: MatrixClient;
|
||||
calls: Map<string, MatrixCall>;
|
||||
callEventBuffer: MatrixEvent[];
|
||||
candidateEventsByCall: Map<string, Array<MatrixEvent>>;
|
||||
// XXX: Most of these are only public because of the tests
|
||||
public calls: Map<string, MatrixCall>;
|
||||
public callEventBuffer: MatrixEvent[];
|
||||
public nextSeqByCall: Map<string, number> = new Map();
|
||||
public toDeviceEventBuffers: Map<string, Array<MatrixEvent>> = new Map();
|
||||
|
||||
private client: MatrixClient;
|
||||
private candidateEventsByCall: Map<string, Array<MatrixEvent>>;
|
||||
private eventBufferPromiseChain?: Promise<void>;
|
||||
|
||||
constructor(client: MatrixClient) {
|
||||
this.client = client;
|
||||
@@ -57,90 +62,165 @@ export class CallEventHandler {
|
||||
}
|
||||
|
||||
public start() {
|
||||
this.client.on(ClientEvent.Sync, this.evaluateEventBuffer);
|
||||
this.client.on(ClientEvent.Sync, this.onSync);
|
||||
this.client.on(RoomEvent.Timeline, this.onRoomTimeline);
|
||||
this.client.on(ClientEvent.ToDeviceEvent, this.onToDeviceEvent);
|
||||
}
|
||||
|
||||
public stop() {
|
||||
this.client.removeListener(ClientEvent.Sync, this.evaluateEventBuffer);
|
||||
this.client.removeListener(ClientEvent.Sync, this.onSync);
|
||||
this.client.removeListener(RoomEvent.Timeline, this.onRoomTimeline);
|
||||
this.client.removeListener(ClientEvent.ToDeviceEvent, this.onToDeviceEvent);
|
||||
}
|
||||
|
||||
private evaluateEventBuffer = async () => {
|
||||
if (this.client.getSyncState() === SyncState.Syncing) {
|
||||
await Promise.all(this.callEventBuffer.map(event => {
|
||||
this.client.decryptEventIfNeeded(event);
|
||||
}));
|
||||
private onSync = (): void => {
|
||||
// Process the current event buffer and start queuing into a new one.
|
||||
const currentEventBuffer = this.callEventBuffer;
|
||||
this.callEventBuffer = [];
|
||||
|
||||
const ignoreCallIds = new Set<string>();
|
||||
// inspect the buffer and mark all calls which have been answered
|
||||
// or hung up before passing them to the call event handler.
|
||||
for (const ev of this.callEventBuffer) {
|
||||
if (ev.getType() === EventType.CallAnswer || ev.getType() === EventType.CallHangup) {
|
||||
ignoreCallIds.add(ev.getContent().call_id);
|
||||
}
|
||||
}
|
||||
// now loop through the buffer chronologically and inject them
|
||||
for (const e of this.callEventBuffer) {
|
||||
if (e.getType() === EventType.CallInvite && ignoreCallIds.has(e.getContent().call_id)) {
|
||||
// This call has previously been answered or hung up: ignore it
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await this.handleCallEvent(e);
|
||||
} catch (e) {
|
||||
logger.error("Caught exception handling call event", e);
|
||||
}
|
||||
}
|
||||
this.callEventBuffer = [];
|
||||
// Ensure correct ordering by only processing this queue after the previous one has finished processing
|
||||
if (this.eventBufferPromiseChain) {
|
||||
this.eventBufferPromiseChain =
|
||||
this.eventBufferPromiseChain.then(() => this.evaluateEventBuffer(currentEventBuffer));
|
||||
} else {
|
||||
this.eventBufferPromiseChain = this.evaluateEventBuffer(currentEventBuffer);
|
||||
}
|
||||
};
|
||||
|
||||
private async evaluateEventBuffer(eventBuffer: MatrixEvent[]) {
|
||||
await Promise.all(eventBuffer.map((event) => this.client.decryptEventIfNeeded(event)));
|
||||
|
||||
const callEvents = eventBuffer.filter((event) => {
|
||||
const eventType = event.getType();
|
||||
return eventType.startsWith("m.call.") || eventType.startsWith("org.matrix.call.");
|
||||
});
|
||||
|
||||
const ignoreCallIds = new Set<string>();
|
||||
|
||||
// inspect the buffer and mark all calls which have been answered
|
||||
// or hung up before passing them to the call event handler.
|
||||
for (const event of callEvents) {
|
||||
const eventType = event.getType();
|
||||
|
||||
if (eventType=== EventType.CallAnswer || eventType === EventType.CallHangup) {
|
||||
ignoreCallIds.add(event.getContent().call_id);
|
||||
}
|
||||
}
|
||||
|
||||
// Process call events in the order that they were received
|
||||
for (const event of callEvents) {
|
||||
const eventType = event.getType();
|
||||
const callId = event.getContent().call_id;
|
||||
|
||||
if (eventType === EventType.CallInvite && ignoreCallIds.has(callId)) {
|
||||
// This call has previously been answered or hung up: ignore it
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.handleCallEvent(event);
|
||||
} catch (e) {
|
||||
logger.error("Caught exception handling call event", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private onRoomTimeline = (event: MatrixEvent) => {
|
||||
this.client.decryptEventIfNeeded(event);
|
||||
// any call events or ones that might be once they're decrypted
|
||||
if (this.eventIsACall(event) || event.isBeingDecrypted()) {
|
||||
// queue up for processing once all events from this sync have been
|
||||
// processed (see above).
|
||||
this.callEventBuffer.push(event);
|
||||
};
|
||||
|
||||
private onToDeviceEvent = (event: MatrixEvent): void => {
|
||||
const content = event.getContent();
|
||||
|
||||
if (!content.call_id) {
|
||||
this.callEventBuffer.push(event);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.isBeingDecrypted() || event.isDecryptionFailure()) {
|
||||
// add an event listener for once the event is decrypted.
|
||||
event.once(MatrixEventEvent.Decrypted, async () => {
|
||||
if (!this.eventIsACall(event)) return;
|
||||
if (!this.nextSeqByCall.has(content.call_id)) {
|
||||
this.nextSeqByCall.set(content.call_id, 0);
|
||||
}
|
||||
|
||||
if (this.callEventBuffer.includes(event)) {
|
||||
// we were waiting for that event to decrypt, so recheck the buffer
|
||||
this.evaluateEventBuffer();
|
||||
} else {
|
||||
// This one wasn't buffered so just run the event handler for it
|
||||
// straight away
|
||||
try {
|
||||
await this.handleCallEvent(event);
|
||||
} catch (e) {
|
||||
logger.error("Caught exception handling call event", e);
|
||||
}
|
||||
}
|
||||
});
|
||||
if (content.seq === undefined) {
|
||||
this.callEventBuffer.push(event);
|
||||
return;
|
||||
}
|
||||
|
||||
const nextSeq = this.nextSeqByCall.get(content.call_id) || 0;
|
||||
|
||||
if (content.seq !== nextSeq) {
|
||||
if (!this.toDeviceEventBuffers.has(content.call_id)) {
|
||||
this.toDeviceEventBuffers.set(content.call_id, []);
|
||||
}
|
||||
|
||||
const buffer = this.toDeviceEventBuffers.get(content.call_id)!;
|
||||
const index = buffer.findIndex((e) => e.getContent().seq > content.seq);
|
||||
|
||||
if (index === -1) {
|
||||
buffer.push(event);
|
||||
} else {
|
||||
buffer.splice(index, 0, event);
|
||||
}
|
||||
} else {
|
||||
const callId = content.call_id;
|
||||
this.callEventBuffer.push(event);
|
||||
this.nextSeqByCall.set(callId, content.seq + 1);
|
||||
|
||||
const buffer = this.toDeviceEventBuffers.get(callId);
|
||||
|
||||
let nextEvent = buffer && buffer.shift();
|
||||
|
||||
while (nextEvent && nextEvent.getContent().seq === this.nextSeqByCall.get(callId)) {
|
||||
this.callEventBuffer.push(nextEvent);
|
||||
this.nextSeqByCall.set(callId, nextEvent.getContent().seq + 1);
|
||||
nextEvent = buffer!.shift();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private eventIsACall(event: MatrixEvent): boolean {
|
||||
const type = event.getType();
|
||||
/**
|
||||
* Unstable prefixes:
|
||||
* - org.matrix.call. : MSC3086 https://github.com/matrix-org/matrix-doc/pull/3086
|
||||
*/
|
||||
return type.startsWith("m.call.") || type.startsWith("org.matrix.call.");
|
||||
}
|
||||
|
||||
private async handleCallEvent(event: MatrixEvent) {
|
||||
this.client.emit(ClientEvent.ReceivedVoipEvent, event);
|
||||
|
||||
const content = event.getContent();
|
||||
const callRoomId = (
|
||||
event.getRoomId() ||
|
||||
this.client.groupCallEventHandler!.getGroupCallById(content.conf_id)?.room?.roomId
|
||||
);
|
||||
const groupCallId = content.conf_id;
|
||||
const type = event.getType() as EventType;
|
||||
const weSentTheEvent = event.getSender() === this.client.credentials.userId;
|
||||
const senderId = event.getSender()!;
|
||||
const weSentTheEvent = senderId === this.client.credentials.userId;
|
||||
let call = content.call_id ? this.calls.get(content.call_id) : undefined;
|
||||
//console.info("RECV %s content=%s", type, JSON.stringify(content));
|
||||
|
||||
let opponentDeviceId: string | undefined;
|
||||
|
||||
let groupCall: GroupCall | undefined;
|
||||
if (groupCallId) {
|
||||
groupCall = this.client.groupCallEventHandler!.getGroupCallById(groupCallId);
|
||||
|
||||
if (!groupCall) {
|
||||
logger.warn(`Cannot find a group call ${groupCallId} for event ${type}. Ignoring event.`);
|
||||
return;
|
||||
}
|
||||
|
||||
opponentDeviceId = content.device_id;
|
||||
|
||||
if (!opponentDeviceId) {
|
||||
logger.warn(`Cannot find a device id for ${senderId}. Ignoring event.`);
|
||||
groupCall.emit(
|
||||
GroupCallEvent.Error,
|
||||
new GroupCallUnknownDeviceError(senderId),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (content.dest_session_id !== this.client.getSessionId()) {
|
||||
logger.warn("Call event does not match current session id, ignoring.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!callRoomId) return;
|
||||
|
||||
if (type === EventType.CallInvite) {
|
||||
// ignore invites you send
|
||||
@@ -157,12 +237,20 @@ export class CallEventHandler {
|
||||
);
|
||||
}
|
||||
|
||||
const timeUntilTurnCresExpire = this.client.getTurnServersExpiry() - Date.now();
|
||||
if (content.invitee && content.invitee !== this.client.getUserId()) {
|
||||
return; // This invite was meant for another user in the room
|
||||
}
|
||||
|
||||
const timeUntilTurnCresExpire = (this.client.getTurnServersExpiry() ?? 0) - Date.now();
|
||||
logger.info("Current turn creds expire in " + timeUntilTurnCresExpire + " ms");
|
||||
call = createNewMatrixCall(
|
||||
this.client,
|
||||
event.getRoomId()!,
|
||||
{ forceTURN: this.client.forceTURN },
|
||||
callRoomId,
|
||||
{
|
||||
forceTURN: this.client.forceTURN, opponentDeviceId,
|
||||
groupCallId,
|
||||
opponentSessionId: content.sender_session_id,
|
||||
},
|
||||
) ?? undefined;
|
||||
if (!call) {
|
||||
logger.log(
|
||||
@@ -176,7 +264,17 @@ export class CallEventHandler {
|
||||
}
|
||||
|
||||
call.callId = content.call_id;
|
||||
await call.initWithInvite(event);
|
||||
try {
|
||||
await call.initWithInvite(event);
|
||||
} catch (e) {
|
||||
if (e instanceof CallError) {
|
||||
if (e.code === GroupCallErrorCode.UnknownDevice) {
|
||||
groupCall?.emit(GroupCallEvent.Error, e);
|
||||
} else {
|
||||
logger.error(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
this.calls.set(call.callId, call);
|
||||
|
||||
// if we stashed candidate events for that call ID, play them back now
|
||||
@@ -196,6 +294,7 @@ export class CallEventHandler {
|
||||
if (
|
||||
call.roomId === thisCall.roomId &&
|
||||
thisCall.direction === CallDirection.Outbound &&
|
||||
call.getOpponentMember()?.userId === thisCall.invitee &&
|
||||
isCalling
|
||||
) {
|
||||
existingCall = thisCall;
|
||||
@@ -204,21 +303,12 @@ export class CallEventHandler {
|
||||
}
|
||||
|
||||
if (existingCall) {
|
||||
// If we've only got to wait_local_media or create_offer and
|
||||
// we've got an invite, pick the incoming call because we know
|
||||
// we haven't sent our invite yet otherwise, pick whichever
|
||||
// call has the lowest call ID (by string comparison)
|
||||
if (
|
||||
existingCall.state === CallState.WaitLocalMedia ||
|
||||
existingCall.state === CallState.CreateOffer ||
|
||||
existingCall.callId > call.callId
|
||||
) {
|
||||
if (existingCall.callId > call.callId) {
|
||||
logger.log(
|
||||
"Glare detected: answering incoming call " + call.callId +
|
||||
" and canceling outgoing call " + existingCall.callId,
|
||||
);
|
||||
existingCall.replacedBy(call);
|
||||
call.answer();
|
||||
} else {
|
||||
logger.log(
|
||||
"Glare detected: rejecting incoming call " + call.callId +
|
||||
@@ -250,7 +340,14 @@ export class CallEventHandler {
|
||||
// if not live, store the fact that the call has ended because
|
||||
// we're probably getting events backwards so
|
||||
// the hangup will come before the invite
|
||||
call = createNewMatrixCall(this.client, event.getRoomId()!) ?? undefined;
|
||||
call = createNewMatrixCall(
|
||||
this.client,
|
||||
callRoomId,
|
||||
{
|
||||
opponentDeviceId,
|
||||
opponentSessionId: content.sender_session_id,
|
||||
},
|
||||
) ?? undefined;
|
||||
if (call) {
|
||||
call.callId = content.call_id;
|
||||
call.initWithHangup(event);
|
||||
|
||||
@@ -36,6 +36,8 @@ export interface MCallBase {
|
||||
call_id: string;
|
||||
version: string | number;
|
||||
party_id?: string;
|
||||
sender_session_id?: string;
|
||||
dest_session_id?: string;
|
||||
}
|
||||
|
||||
export interface MCallAnswer extends MCallBase {
|
||||
@@ -53,6 +55,9 @@ export interface MCallInviteNegotiate extends MCallBase {
|
||||
description: RTCSessionDescription;
|
||||
lifetime: number;
|
||||
capabilities?: CallCapabilities;
|
||||
invitee?: string;
|
||||
sender_session_id?: string;
|
||||
dest_session_id?: string;
|
||||
[SDPStreamMetadataKey]: SDPStreamMetadata;
|
||||
}
|
||||
|
||||
|
||||
+77
-13
@@ -15,8 +15,10 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import { SDPStreamMetadataPurpose } from "./callEventTypes";
|
||||
import { acquireContext, releaseContext } from "./audioContext";
|
||||
import { MatrixClient } from "../client";
|
||||
import { RoomMember } from "../models/room-member";
|
||||
import { logger } from "../logger";
|
||||
import { TypedEventEmitter } from "../models/typed-event-emitter";
|
||||
|
||||
const POLLING_INTERVAL = 200; // ms
|
||||
@@ -25,7 +27,7 @@ const SPEAKING_SAMPLE_COUNT = 8; // samples
|
||||
|
||||
export interface ICallFeedOpts {
|
||||
client: MatrixClient;
|
||||
roomId: string;
|
||||
roomId?: string;
|
||||
userId: string;
|
||||
stream: MediaStream;
|
||||
purpose: SDPStreamMetadataPurpose;
|
||||
@@ -42,27 +44,33 @@ export interface ICallFeedOpts {
|
||||
export enum CallFeedEvent {
|
||||
NewStream = "new_stream",
|
||||
MuteStateChanged = "mute_state_changed",
|
||||
LocalVolumeChanged = "local_volume_changed",
|
||||
VolumeChanged = "volume_changed",
|
||||
Speaking = "speaking",
|
||||
Disposed = "disposed",
|
||||
}
|
||||
|
||||
type EventHandlerMap = {
|
||||
[CallFeedEvent.NewStream]: (stream: MediaStream) => void;
|
||||
[CallFeedEvent.MuteStateChanged]: (audioMuted: boolean, videoMuted: boolean) => void;
|
||||
[CallFeedEvent.LocalVolumeChanged]: (localVolume: number) => void;
|
||||
[CallFeedEvent.VolumeChanged]: (volume: number) => void;
|
||||
[CallFeedEvent.Speaking]: (speaking: boolean) => void;
|
||||
[CallFeedEvent.Disposed]: () => void;
|
||||
};
|
||||
|
||||
export class CallFeed extends TypedEventEmitter<CallFeedEvent, EventHandlerMap> {
|
||||
public stream: MediaStream;
|
||||
public sdpMetadataStreamId: string;
|
||||
public userId: string;
|
||||
public purpose: SDPStreamMetadataPurpose;
|
||||
public speakingVolumeSamples: number[];
|
||||
|
||||
private client: MatrixClient;
|
||||
private roomId: string;
|
||||
private roomId?: string;
|
||||
private audioMuted: boolean;
|
||||
private videoMuted: boolean;
|
||||
private localVolume = 1;
|
||||
private measuringVolumeActivity = false;
|
||||
private audioContext?: AudioContext;
|
||||
private analyser?: AnalyserNode;
|
||||
@@ -70,6 +78,7 @@ export class CallFeed extends TypedEventEmitter<CallFeedEvent, EventHandlerMap>
|
||||
private speakingThreshold = SPEAKING_THRESHOLD;
|
||||
private speaking = false;
|
||||
private volumeLooperTimeout?: ReturnType<typeof setTimeout>;
|
||||
private _disposed = false;
|
||||
|
||||
constructor(opts: ICallFeedOpts) {
|
||||
super();
|
||||
@@ -81,6 +90,7 @@ export class CallFeed extends TypedEventEmitter<CallFeedEvent, EventHandlerMap>
|
||||
this.audioMuted = opts.audioMuted;
|
||||
this.videoMuted = opts.videoMuted;
|
||||
this.speakingVolumeSamples = new Array(SPEAKING_SAMPLE_COUNT).fill(-Infinity);
|
||||
this.sdpMetadataStreamId = opts.stream.id;
|
||||
|
||||
this.updateStream(null, opts.stream);
|
||||
this.stream = opts.stream; // updateStream does this, but this makes TS happier
|
||||
@@ -115,10 +125,8 @@ export class CallFeed extends TypedEventEmitter<CallFeedEvent, EventHandlerMap>
|
||||
}
|
||||
|
||||
private initVolumeMeasuring(): void {
|
||||
const AudioContext = window.AudioContext || window.webkitAudioContext;
|
||||
if (!this.hasAudioTrack || !AudioContext) return;
|
||||
|
||||
this.audioContext = new AudioContext();
|
||||
if (!this.hasAudioTrack) return;
|
||||
if (!this.audioContext) this.audioContext = acquireContext();
|
||||
|
||||
this.analyser = this.audioContext.createAnalyser();
|
||||
this.analyser.fftSize = 512;
|
||||
@@ -174,6 +182,17 @@ export class CallFeed extends TypedEventEmitter<CallFeedEvent, EventHandlerMap>
|
||||
return this.speaking;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the current MediaStream with a new one.
|
||||
* The stream will be different and new stream as remore parties are
|
||||
* concerned, but this can be used for convenience locally to set up
|
||||
* volume listeners automatically on the new stream etc.
|
||||
* @param newStream new stream with which to replace the current one
|
||||
*/
|
||||
public setNewStream(newStream: MediaStream): void {
|
||||
this.updateStream(this.stream, newStream);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set one or both of feed's internal audio and video video mute state
|
||||
* Either value may be null to leave it as-is
|
||||
@@ -197,7 +216,7 @@ export class CallFeed extends TypedEventEmitter<CallFeedEvent, EventHandlerMap>
|
||||
*/
|
||||
public measureVolumeActivity(enabled: boolean): void {
|
||||
if (enabled) {
|
||||
if (!this.audioContext || !this.analyser || !this.frequencyBinCount || !this.hasAudioTrack) return;
|
||||
if (!this.analyser || !this.frequencyBinCount || !this.hasAudioTrack) return;
|
||||
|
||||
this.measuringVolumeActivity = true;
|
||||
this.volumeLooper();
|
||||
@@ -220,9 +239,9 @@ export class CallFeed extends TypedEventEmitter<CallFeedEvent, EventHandlerMap>
|
||||
this.analyser.getFloatFrequencyData(this.frequencyBinCount!);
|
||||
|
||||
let maxVolume = -Infinity;
|
||||
for (let i = 0; i < this.frequencyBinCount!.length; i++) {
|
||||
if (this.frequencyBinCount![i] > maxVolume) {
|
||||
maxVolume = this.frequencyBinCount![i];
|
||||
for (const volume of this.frequencyBinCount!) {
|
||||
if (volume > maxVolume) {
|
||||
maxVolume = volume;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,9 +252,7 @@ export class CallFeed extends TypedEventEmitter<CallFeedEvent, EventHandlerMap>
|
||||
|
||||
let newSpeaking = false;
|
||||
|
||||
for (let i = 0; i < this.speakingVolumeSamples.length; i++) {
|
||||
const volume = this.speakingVolumeSamples[i];
|
||||
|
||||
for (const volume of this.speakingVolumeSamples) {
|
||||
if (volume > this.speakingThreshold) {
|
||||
newSpeaking = true;
|
||||
break;
|
||||
@@ -250,7 +267,54 @@ export class CallFeed extends TypedEventEmitter<CallFeedEvent, EventHandlerMap>
|
||||
this.volumeLooperTimeout = setTimeout(this.volumeLooper, POLLING_INTERVAL);
|
||||
};
|
||||
|
||||
public clone(): CallFeed {
|
||||
const mediaHandler = this.client.getMediaHandler();
|
||||
const stream = this.stream.clone();
|
||||
logger.log(`callFeed cloning stream ${this.stream.id} newStream ${stream.id}`);
|
||||
|
||||
if (this.purpose === SDPStreamMetadataPurpose.Usermedia) {
|
||||
mediaHandler.userMediaStreams.push(stream);
|
||||
} else {
|
||||
mediaHandler.screensharingStreams.push(stream);
|
||||
}
|
||||
|
||||
return new CallFeed({
|
||||
client: this.client,
|
||||
roomId: this.roomId,
|
||||
userId: this.userId,
|
||||
stream,
|
||||
purpose: this.purpose,
|
||||
audioMuted: this.audioMuted,
|
||||
videoMuted: this.videoMuted,
|
||||
});
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
clearTimeout(this.volumeLooperTimeout);
|
||||
this.stream?.removeEventListener("addtrack", this.onAddTrack);
|
||||
if (this.audioContext) {
|
||||
this.audioContext = undefined;
|
||||
this.analyser = undefined;
|
||||
releaseContext();
|
||||
}
|
||||
this._disposed = true;
|
||||
this.emit(CallFeedEvent.Disposed);
|
||||
}
|
||||
|
||||
public get disposed(): boolean {
|
||||
return this._disposed;
|
||||
}
|
||||
|
||||
private set disposed(value: boolean) {
|
||||
this._disposed = value;
|
||||
}
|
||||
|
||||
public getLocalVolume(): number {
|
||||
return this.localVolume;
|
||||
}
|
||||
|
||||
public setLocalVolume(localVolume: number): void {
|
||||
this.localVolume = localVolume;
|
||||
this.emit(CallFeedEvent.LocalVolumeChanged, localVolume);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,233 @@
|
||||
/*
|
||||
Copyright 2021 Šimon Brandner <simon.bra.ag@gmail.com>
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { MatrixEvent } from '../models/event';
|
||||
import { MatrixClient, ClientEvent } from '../client';
|
||||
import {
|
||||
GroupCall,
|
||||
GroupCallIntent,
|
||||
GroupCallType,
|
||||
IGroupCallDataChannelOptions,
|
||||
} from "./groupCall";
|
||||
import { Room } from "../models/room";
|
||||
import { RoomState, RoomStateEvent } from "../models/room-state";
|
||||
import { RoomMember } from "../models/room-member";
|
||||
import { logger } from '../logger';
|
||||
import { EventType } from "../@types/event";
|
||||
import { SyncState } from '../sync';
|
||||
|
||||
export enum GroupCallEventHandlerEvent {
|
||||
Incoming = "GroupCall.incoming",
|
||||
Ended = "GroupCall.ended",
|
||||
Participants = "GroupCall.participants",
|
||||
}
|
||||
|
||||
export type GroupCallEventHandlerEventHandlerMap = {
|
||||
[GroupCallEventHandlerEvent.Incoming]: (call: GroupCall) => void;
|
||||
[GroupCallEventHandlerEvent.Ended]: (call: GroupCall) => void;
|
||||
[GroupCallEventHandlerEvent.Participants]: (participants: RoomMember[], call: GroupCall) => void;
|
||||
};
|
||||
|
||||
interface RoomDeferred {
|
||||
prom: Promise<void>;
|
||||
resolve?: () => void;
|
||||
}
|
||||
|
||||
export class GroupCallEventHandler {
|
||||
public groupCalls = new Map<string, GroupCall>(); // roomId -> GroupCall
|
||||
|
||||
// All rooms we know about and whether we've seen a 'Room' event
|
||||
// for them. The promise will be fulfilled once we've processed that
|
||||
// event which means we're "up to date" on what calls are in a room
|
||||
// and get
|
||||
private roomDeferreds = new Map<string, RoomDeferred>();
|
||||
|
||||
constructor(private client: MatrixClient) { }
|
||||
|
||||
public async start(): Promise<void> {
|
||||
// We wait until the client has started syncing for real.
|
||||
// This is because we only support one call at a time, and want
|
||||
// the latest. We therefore want the latest state of the room before
|
||||
// we create a group call for the room so we can be fairly sure that
|
||||
// the group call we create is really the latest one.
|
||||
if (this.client.getSyncState() !== SyncState.Syncing) {
|
||||
logger.debug("Waiting for client to start syncing...");
|
||||
await new Promise<void>(resolve => {
|
||||
const onSync = () => {
|
||||
if (this.client.getSyncState() === SyncState.Syncing) {
|
||||
this.client.off(ClientEvent.Sync, onSync);
|
||||
return resolve();
|
||||
}
|
||||
};
|
||||
this.client.on(ClientEvent.Sync, onSync);
|
||||
});
|
||||
}
|
||||
|
||||
const rooms = this.client.getRooms();
|
||||
|
||||
for (const room of rooms) {
|
||||
this.createGroupCallForRoom(room);
|
||||
}
|
||||
|
||||
this.client.on(ClientEvent.Room, this.onRoomsChanged);
|
||||
this.client.on(RoomStateEvent.Events, this.onRoomStateChanged);
|
||||
}
|
||||
|
||||
public stop(): void {
|
||||
this.client.removeListener(RoomStateEvent.Events, this.onRoomStateChanged);
|
||||
}
|
||||
|
||||
private getRoomDeferred(roomId: string): RoomDeferred {
|
||||
let deferred = this.roomDeferreds.get(roomId);
|
||||
if (deferred === undefined) {
|
||||
let resolveFunc: () => void;
|
||||
deferred = {
|
||||
prom: new Promise<void>(resolve => {
|
||||
resolveFunc = resolve;
|
||||
}),
|
||||
};
|
||||
deferred.resolve = resolveFunc!;
|
||||
this.roomDeferreds.set(roomId, deferred);
|
||||
}
|
||||
|
||||
return deferred;
|
||||
}
|
||||
|
||||
public waitUntilRoomReadyForGroupCalls(roomId: string): Promise<void> {
|
||||
return this.getRoomDeferred(roomId).prom;
|
||||
}
|
||||
|
||||
public getGroupCallById(groupCallId: string): GroupCall | undefined {
|
||||
return [...this.groupCalls.values()].find((groupCall) => groupCall.groupCallId === groupCallId);
|
||||
}
|
||||
|
||||
private createGroupCallForRoom(room: Room): void {
|
||||
const callEvents = room.currentState.getStateEvents(EventType.GroupCallPrefix);
|
||||
const sortedCallEvents = callEvents.sort((a, b) => b.getTs() - a.getTs());
|
||||
|
||||
for (const callEvent of sortedCallEvents) {
|
||||
const content = callEvent.getContent();
|
||||
|
||||
if (content["m.terminated"]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`Choosing group call ${callEvent.getStateKey()} with TS ` +
|
||||
`${callEvent.getTs()} for room ${room.roomId} from ${callEvents.length} possible calls.`,
|
||||
);
|
||||
|
||||
this.createGroupCallFromRoomStateEvent(callEvent);
|
||||
break;
|
||||
}
|
||||
|
||||
logger.info("Group call event handler processed room", room.roomId);
|
||||
this.getRoomDeferred(room.roomId).resolve!();
|
||||
}
|
||||
|
||||
private createGroupCallFromRoomStateEvent(event: MatrixEvent): GroupCall | undefined {
|
||||
const roomId = event.getRoomId();
|
||||
const content = event.getContent();
|
||||
|
||||
const room = this.client.getRoom(roomId);
|
||||
|
||||
if (!room) {
|
||||
logger.warn(`Couldn't find room ${roomId} for GroupCall`);
|
||||
return;
|
||||
}
|
||||
|
||||
const groupCallId = event.getStateKey();
|
||||
|
||||
const callType = content["m.type"];
|
||||
|
||||
if (!Object.values(GroupCallType).includes(callType)) {
|
||||
logger.warn(`Received invalid group call type ${callType} for room ${roomId}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const callIntent = content["m.intent"];
|
||||
|
||||
if (!Object.values(GroupCallIntent).includes(callIntent)) {
|
||||
logger.warn(`Received invalid group call intent ${callType} for room ${roomId}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const isPtt = Boolean(content["io.element.ptt"]);
|
||||
|
||||
let dataChannelOptions: IGroupCallDataChannelOptions | undefined;
|
||||
|
||||
if (content?.dataChannelsEnabled && content?.dataChannelOptions) {
|
||||
// Pull out just the dataChannelOptions we want to support.
|
||||
const { ordered, maxPacketLifeTime, maxRetransmits, protocol } = content.dataChannelOptions;
|
||||
dataChannelOptions = { ordered, maxPacketLifeTime, maxRetransmits, protocol };
|
||||
}
|
||||
|
||||
const groupCall = new GroupCall(
|
||||
this.client,
|
||||
room,
|
||||
callType,
|
||||
isPtt,
|
||||
callIntent,
|
||||
groupCallId,
|
||||
content?.dataChannelsEnabled,
|
||||
dataChannelOptions,
|
||||
);
|
||||
|
||||
this.groupCalls.set(room.roomId, groupCall);
|
||||
this.client.emit(GroupCallEventHandlerEvent.Incoming, groupCall);
|
||||
|
||||
return groupCall;
|
||||
}
|
||||
|
||||
private onRoomsChanged = (room: Room) => {
|
||||
this.createGroupCallForRoom(room);
|
||||
};
|
||||
|
||||
private onRoomStateChanged = (event: MatrixEvent, state: RoomState): void => {
|
||||
const eventType = event.getType();
|
||||
|
||||
if (eventType === EventType.GroupCallPrefix) {
|
||||
const groupCallId = event.getStateKey();
|
||||
const content = event.getContent();
|
||||
|
||||
const currentGroupCall = this.groupCalls.get(state.roomId);
|
||||
|
||||
if (!currentGroupCall && !content["m.terminated"]) {
|
||||
this.createGroupCallFromRoomStateEvent(event);
|
||||
} else if (currentGroupCall && currentGroupCall.groupCallId === groupCallId) {
|
||||
if (content["m.terminated"]) {
|
||||
currentGroupCall.terminate(false);
|
||||
} else if (content["m.type"] !== currentGroupCall.type) {
|
||||
// TODO: Handle the callType changing when the room state changes
|
||||
logger.warn(`The group call type changed for room: ${
|
||||
state.roomId}. Changing the group call type is currently unsupported.`);
|
||||
}
|
||||
} else if (currentGroupCall && currentGroupCall.groupCallId !== groupCallId) {
|
||||
// TODO: Handle new group calls and multiple group calls
|
||||
logger.warn(`Multiple group calls detected for room: ${
|
||||
state.roomId}. Multiple group calls are currently unsupported.`);
|
||||
}
|
||||
} else if (eventType === EventType.GroupCallMemberPrefix) {
|
||||
const groupCall = this.groupCalls.get(state.roomId);
|
||||
|
||||
if (!groupCall) {
|
||||
return;
|
||||
}
|
||||
|
||||
groupCall.onMemberStateChanged(event);
|
||||
}
|
||||
};
|
||||
}
|
||||
+158
-30
@@ -17,18 +17,53 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { TypedEventEmitter } from "../models/typed-event-emitter";
|
||||
import { GroupCallType, GroupCallState } from "../webrtc/groupCall";
|
||||
import { logger } from "../logger";
|
||||
import { MatrixClient } from "../client";
|
||||
import { CallState } from "./call";
|
||||
|
||||
export class MediaHandler {
|
||||
export enum MediaHandlerEvent {
|
||||
LocalStreamsChanged = "local_streams_changed"
|
||||
}
|
||||
|
||||
export type MediaHandlerEventHandlerMap = {
|
||||
[MediaHandlerEvent.LocalStreamsChanged]: () => void;
|
||||
};
|
||||
|
||||
export interface IScreensharingOpts {
|
||||
desktopCapturerSourceId?: string;
|
||||
audio?: boolean;
|
||||
// For electron screen capture, there are very few options for detecting electron
|
||||
// apart from inspecting the user agent or just trying getDisplayMedia() and
|
||||
// catching the failure, so we do the latter - this flag tells the function to just
|
||||
// throw an error so we can catch it in this case, rather than logging and emitting.
|
||||
throwOnFail?: boolean;
|
||||
}
|
||||
|
||||
export interface AudioSettings {
|
||||
autoGainControl: boolean;
|
||||
echoCancellation: boolean;
|
||||
noiseSuppression: boolean;
|
||||
}
|
||||
|
||||
export class MediaHandler extends TypedEventEmitter<
|
||||
MediaHandlerEvent.LocalStreamsChanged, MediaHandlerEventHandlerMap
|
||||
> {
|
||||
private audioInput?: string;
|
||||
private audioSettings?: AudioSettings;
|
||||
private videoInput?: string;
|
||||
private localUserMediaStream?: MediaStream;
|
||||
public userMediaStreams: MediaStream[] = [];
|
||||
public screensharingStreams: MediaStream[] = [];
|
||||
|
||||
constructor(private client: MatrixClient) { }
|
||||
constructor(private client: MatrixClient) {
|
||||
super();
|
||||
}
|
||||
|
||||
public restoreMediaSettings(audioInput: string, videoInput: string) {
|
||||
this.audioInput = audioInput;
|
||||
this.videoInput = videoInput;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set an audio input device to use for MatrixCalls
|
||||
@@ -36,7 +71,7 @@ export class MediaHandler {
|
||||
* undefined treated as unset
|
||||
*/
|
||||
public async setAudioInput(deviceId: string): Promise<void> {
|
||||
logger.info("LOG setting audio input to", deviceId);
|
||||
logger.info("Setting audio input to", deviceId);
|
||||
|
||||
if (this.audioInput === deviceId) return;
|
||||
|
||||
@@ -44,13 +79,24 @@ export class MediaHandler {
|
||||
await this.updateLocalUsermediaStreams();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set audio settings for MatrixCalls
|
||||
* @param {AudioSettings} opts audio options to set
|
||||
*/
|
||||
public async setAudioSettings(opts: AudioSettings): Promise<void> {
|
||||
logger.info("Setting audio settings to", opts);
|
||||
|
||||
this.audioSettings = Object.assign({}, opts) as AudioSettings;
|
||||
await this.updateLocalUsermediaStreams();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a video input device to use for MatrixCalls
|
||||
* @param {string} deviceId the identifier for the device
|
||||
* undefined treated as unset
|
||||
*/
|
||||
public async setVideoInput(deviceId: string): Promise<void> {
|
||||
logger.info("LOG setting video input to", deviceId);
|
||||
logger.info("Setting video input to", deviceId);
|
||||
|
||||
if (this.videoInput === deviceId) return;
|
||||
|
||||
@@ -59,6 +105,19 @@ export class MediaHandler {
|
||||
}
|
||||
|
||||
/**
|
||||
* Set media input devices to use for MatrixCalls
|
||||
* @param {string} audioInput the identifier for the audio device
|
||||
* @param {string} videoInput the identifier for the video device
|
||||
* undefined treated as unset
|
||||
*/
|
||||
public async setMediaInputs(audioInput: string, videoInput: string): Promise<void> {
|
||||
logger.log(`mediaHandler setMediaInputs audioInput: ${audioInput} videoInput: ${videoInput}`);
|
||||
this.audioInput = audioInput;
|
||||
this.videoInput = videoInput;
|
||||
await this.updateLocalUsermediaStreams();
|
||||
}
|
||||
|
||||
/*
|
||||
* Requests new usermedia streams and replace the old ones
|
||||
*/
|
||||
public async updateLocalUsermediaStreams(): Promise<void> {
|
||||
@@ -72,16 +131,53 @@ export class MediaHandler {
|
||||
});
|
||||
}
|
||||
|
||||
for (const stream of this.userMediaStreams) {
|
||||
logger.log(`mediaHandler stopping all tracks for stream ${stream.id}`);
|
||||
for (const track of stream.getTracks()) {
|
||||
track.stop();
|
||||
}
|
||||
}
|
||||
|
||||
this.userMediaStreams = [];
|
||||
this.localUserMediaStream = undefined;
|
||||
|
||||
for (const call of this.client.callEventHandler!.calls.values()) {
|
||||
if (call.state === CallState.Ended || !callMediaStreamParams.has(call.callId)) continue;
|
||||
if (call.callHasEnded() || !callMediaStreamParams.has(call.callId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const { audio, video } = callMediaStreamParams.get(call.callId)!;
|
||||
|
||||
// This stream won't be reusable as we will replace the tracks of the old stream
|
||||
const stream = await this.getUserMediaStream(audio, video, false);
|
||||
logger.log(`mediaHandler updateLocalUsermediaStreams getUserMediaStream call ${call.callId}`);
|
||||
const stream = await this.getUserMediaStream(audio, video);
|
||||
|
||||
if (call.callHasEnded()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await call.updateLocalUsermediaStream(stream);
|
||||
}
|
||||
|
||||
for (const groupCall of this.client.groupCallEventHandler!.groupCalls.values()) {
|
||||
if (!groupCall.localCallFeed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
logger.log(`mediaHandler updateLocalUsermediaStreams getUserMediaStream groupCall ${
|
||||
groupCall.groupCallId}`);
|
||||
const stream = await this.getUserMediaStream(
|
||||
true,
|
||||
groupCall.type === GroupCallType.Video,
|
||||
);
|
||||
|
||||
if (groupCall.state === GroupCallState.Ended) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await groupCall.updateLocalUsermediaStream(stream);
|
||||
}
|
||||
|
||||
this.emit(MediaHandlerEvent.LocalStreamsChanged);
|
||||
}
|
||||
|
||||
public async hasAudioDevice(): Promise<boolean> {
|
||||
@@ -106,16 +202,35 @@ export class MediaHandler {
|
||||
|
||||
let stream: MediaStream;
|
||||
|
||||
if (
|
||||
!this.localUserMediaStream ||
|
||||
(this.localUserMediaStream.getAudioTracks().length === 0 && shouldRequestAudio) ||
|
||||
(this.localUserMediaStream.getVideoTracks().length === 0 && shouldRequestVideo) ||
|
||||
(this.localUserMediaStream.getAudioTracks()[0]?.getSettings()?.deviceId !== this.audioInput) ||
|
||||
(this.localUserMediaStream.getVideoTracks()[0]?.getSettings()?.deviceId !== this.videoInput)
|
||||
) {
|
||||
let canReuseStream = true;
|
||||
if (this.localUserMediaStream) {
|
||||
// This code checks that the device ID is the same as the localUserMediaStream stream, but we update
|
||||
// the localUserMediaStream whenever the device ID changes (apart from when restoring) so it's not
|
||||
// clear why this would ever be different, unless there's a race.
|
||||
if (shouldRequestAudio) {
|
||||
if (
|
||||
this.localUserMediaStream.getAudioTracks().length === 0 ||
|
||||
this.localUserMediaStream.getAudioTracks()[0]?.getSettings()?.deviceId !== this.audioInput
|
||||
) {
|
||||
canReuseStream = false;
|
||||
}
|
||||
}
|
||||
if (shouldRequestVideo) {
|
||||
if (
|
||||
this.localUserMediaStream.getVideoTracks().length === 0 ||
|
||||
this.localUserMediaStream.getVideoTracks()[0]?.getSettings()?.deviceId !== this.videoInput) {
|
||||
canReuseStream = false;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
canReuseStream = false;
|
||||
}
|
||||
|
||||
if (!canReuseStream) {
|
||||
const constraints = this.getUserMediaContraints(shouldRequestAudio, shouldRequestVideo);
|
||||
logger.log("Getting user media with constraints", constraints);
|
||||
stream = await navigator.mediaDevices.getUserMedia(constraints);
|
||||
logger.log(`mediaHandler getUserMediaStream streamId ${stream.id} shouldRequestAudio ${
|
||||
shouldRequestAudio} shouldRequestVideo ${shouldRequestVideo}`, constraints);
|
||||
|
||||
for (const track of stream.getTracks()) {
|
||||
const settings = track.getSettings();
|
||||
@@ -131,7 +246,9 @@ export class MediaHandler {
|
||||
this.localUserMediaStream = stream;
|
||||
}
|
||||
} else {
|
||||
stream = this.localUserMediaStream.clone();
|
||||
stream = this.localUserMediaStream!.clone();
|
||||
logger.log(`mediaHandler clone userMediaStream ${this.localUserMediaStream?.id} new stream ${
|
||||
stream.id} shouldRequestAudio ${shouldRequestAudio} shouldRequestVideo ${shouldRequestVideo}`);
|
||||
|
||||
if (!shouldRequestAudio) {
|
||||
for (const track of stream.getAudioTracks()) {
|
||||
@@ -150,6 +267,8 @@ export class MediaHandler {
|
||||
this.userMediaStreams.push(stream);
|
||||
}
|
||||
|
||||
this.emit(MediaHandlerEvent.LocalStreamsChanged);
|
||||
|
||||
return stream;
|
||||
}
|
||||
|
||||
@@ -157,7 +276,7 @@ export class MediaHandler {
|
||||
* Stops all tracks on the provided usermedia stream
|
||||
*/
|
||||
public stopUserMediaStream(mediaStream: MediaStream) {
|
||||
logger.debug("Stopping usermedia stream", mediaStream.id);
|
||||
logger.log(`mediaHandler stopUserMediaStream stopping stream ${mediaStream.id}`);
|
||||
for (const track of mediaStream.getTracks()) {
|
||||
track.stop();
|
||||
}
|
||||
@@ -169,6 +288,8 @@ export class MediaHandler {
|
||||
this.userMediaStreams.splice(index, 1);
|
||||
}
|
||||
|
||||
this.emit(MediaHandlerEvent.LocalStreamsChanged);
|
||||
|
||||
if (this.localUserMediaStream === mediaStream) {
|
||||
this.localUserMediaStream = undefined;
|
||||
}
|
||||
@@ -179,23 +300,19 @@ export class MediaHandler {
|
||||
* @param reusable is allowed to be reused by the MediaHandler
|
||||
* @returns {MediaStream} based on passed parameters
|
||||
*/
|
||||
public async getScreensharingStream(
|
||||
desktopCapturerSourceId?: string,
|
||||
reusable = true,
|
||||
): Promise<MediaStream | null> {
|
||||
public async getScreensharingStream(opts: IScreensharingOpts = {}, reusable = true): Promise<MediaStream> {
|
||||
let stream: MediaStream;
|
||||
|
||||
if (this.screensharingStreams.length === 0) {
|
||||
const screenshareConstraints = this.getScreenshareContraints(desktopCapturerSourceId);
|
||||
if (!screenshareConstraints) return null;
|
||||
const screenshareConstraints = this.getScreenshareContraints(opts);
|
||||
|
||||
if (desktopCapturerSourceId) {
|
||||
if (opts.desktopCapturerSourceId) {
|
||||
// We are using Electron
|
||||
logger.debug("Getting screensharing stream using getUserMedia()", desktopCapturerSourceId);
|
||||
logger.debug("Getting screensharing stream using getUserMedia()", opts);
|
||||
stream = await navigator.mediaDevices.getUserMedia(screenshareConstraints);
|
||||
} else {
|
||||
// We are not using Electron
|
||||
logger.debug("Getting screensharing stream using getDisplayMedia()");
|
||||
logger.debug("Getting screensharing stream using getDisplayMedia()", opts);
|
||||
stream = await navigator.mediaDevices.getDisplayMedia(screenshareConstraints);
|
||||
}
|
||||
} else {
|
||||
@@ -208,6 +325,8 @@ export class MediaHandler {
|
||||
this.screensharingStreams.push(stream);
|
||||
}
|
||||
|
||||
this.emit(MediaHandlerEvent.LocalStreamsChanged);
|
||||
|
||||
return stream;
|
||||
}
|
||||
|
||||
@@ -226,6 +345,8 @@ export class MediaHandler {
|
||||
logger.debug("Splicing screensharing stream out stream array", mediaStream.id);
|
||||
this.screensharingStreams.splice(index, 1);
|
||||
}
|
||||
|
||||
this.emit(MediaHandlerEvent.LocalStreamsChanged);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -233,6 +354,7 @@ export class MediaHandler {
|
||||
*/
|
||||
public stopAllStreams() {
|
||||
for (const stream of this.userMediaStreams) {
|
||||
logger.log(`mediaHandler stopAllStreams stopping stream ${stream.id}`);
|
||||
for (const track of stream.getTracks()) {
|
||||
track.stop();
|
||||
}
|
||||
@@ -247,6 +369,8 @@ export class MediaHandler {
|
||||
this.userMediaStreams = [];
|
||||
this.screensharingStreams = [];
|
||||
this.localUserMediaStream = undefined;
|
||||
|
||||
this.emit(MediaHandlerEvent.LocalStreamsChanged);
|
||||
}
|
||||
|
||||
private getUserMediaContraints(audio: boolean, video: boolean): MediaStreamConstraints {
|
||||
@@ -256,6 +380,9 @@ export class MediaHandler {
|
||||
audio: audio
|
||||
? {
|
||||
deviceId: this.audioInput ? { ideal: this.audioInput } : undefined,
|
||||
autoGainControl: this.audioSettings ? { ideal: this.audioSettings.autoGainControl } : undefined,
|
||||
echoCancellation: this.audioSettings ? { ideal: this.audioSettings.echoCancellation } : undefined,
|
||||
noiseSuppression: this.audioSettings ? { ideal: this.audioSettings.noiseSuppression } : undefined,
|
||||
}
|
||||
: false,
|
||||
video: video
|
||||
@@ -273,11 +400,12 @@ export class MediaHandler {
|
||||
};
|
||||
}
|
||||
|
||||
private getScreenshareContraints(desktopCapturerSourceId?: string): DesktopCapturerConstraints {
|
||||
private getScreenshareContraints(opts: IScreensharingOpts): DesktopCapturerConstraints {
|
||||
const { desktopCapturerSourceId, audio } = opts;
|
||||
if (desktopCapturerSourceId) {
|
||||
logger.debug("Using desktop capturer source", desktopCapturerSourceId);
|
||||
return {
|
||||
audio: false,
|
||||
audio: audio ?? false,
|
||||
video: {
|
||||
mandatory: {
|
||||
chromeMediaSource: "desktop",
|
||||
@@ -288,7 +416,7 @@ export class MediaHandler {
|
||||
} else {
|
||||
logger.debug("Not using desktop capturer source");
|
||||
return {
|
||||
audio: false,
|
||||
audio: audio ?? false,
|
||||
video: true,
|
||||
};
|
||||
}
|
||||
|
||||
+8
-2
@@ -8,10 +8,16 @@
|
||||
"noImplicitAny": false,
|
||||
"noUnusedLocals": true,
|
||||
"noEmit": true,
|
||||
"declaration": true
|
||||
"declaration": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": [
|
||||
"./src/**/*.ts",
|
||||
"./spec/**/*.ts"
|
||||
]
|
||||
],
|
||||
"typedocOptions": {
|
||||
"entryPoints": ["src/index.ts"],
|
||||
"excludeExternals": true,
|
||||
"out": "_docs"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user