Merge branch 'develop' of github.com:matrix-org/matrix-react-sdk into t3chguy/fix/18089
Conflicts: res/css/structures/_SpaceHierarchy.scss src/components/structures/SpaceHierarchy.tsx src/i18n/strings/en_EN.json
This commit is contained in:
commit
0a209afdc2
196 changed files with 8563 additions and 2976 deletions
1
.github/CODEOWNERS
vendored
Normal file
1
.github/CODEOWNERS
vendored
Normal file
|
@ -0,0 +1 @@
|
||||||
|
* @matrix-org/element-web
|
31
.github/workflows/layered-build.yaml
vendored
Normal file
31
.github/workflows/layered-build.yaml
vendored
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
name: Layered Preview Build
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [develop]
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v2
|
||||||
|
- name: Build
|
||||||
|
run: scripts/ci/layered.sh && cd element-web && cp element.io/develop/config.json config.json && CI_PACKAGE=true yarn build
|
||||||
|
- name: Upload Artifact
|
||||||
|
uses: actions/upload-artifact@v2
|
||||||
|
with:
|
||||||
|
name: previewbuild
|
||||||
|
path: element-web/webapp
|
||||||
|
# We'll only use this in a triggered job, then we're done with it
|
||||||
|
retention-days: 1
|
||||||
|
- uses: actions/github-script@v3.1.0
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
var fs = require('fs');
|
||||||
|
fs.writeFileSync('${{github.workspace}}/pr.json', JSON.stringify(context.payload.pull_request));
|
||||||
|
- name: Upload PR Info
|
||||||
|
uses: actions/upload-artifact@v2
|
||||||
|
with:
|
||||||
|
name: pr.json
|
||||||
|
path: pr.json
|
||||||
|
# We'll only use this in a triggered job, then we're done with it
|
||||||
|
retention-days: 1
|
||||||
|
|
80
.github/workflows/netflify.yaml
vendored
Normal file
80
.github/workflows/netflify.yaml
vendored
Normal file
|
@ -0,0 +1,80 @@
|
||||||
|
name: Upload Preview Build to Netlify
|
||||||
|
on:
|
||||||
|
workflow_run:
|
||||||
|
workflows: ["Layered Preview Build"]
|
||||||
|
types:
|
||||||
|
- completed
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: >
|
||||||
|
${{ github.event.workflow_run.conclusion == 'success' }}
|
||||||
|
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: actions/github-script@v3.1.0
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
var artifacts = await github.actions.listWorkflowRunArtifacts({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
run_id: ${{github.event.workflow_run.id }},
|
||||||
|
});
|
||||||
|
var matchArtifact = artifacts.data.artifacts.filter((artifact) => {
|
||||||
|
return artifact.name == "previewbuild"
|
||||||
|
})[0];
|
||||||
|
var download = await github.actions.downloadArtifact({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
artifact_id: matchArtifact.id,
|
||||||
|
archive_format: 'zip',
|
||||||
|
});
|
||||||
|
var fs = require('fs');
|
||||||
|
fs.writeFileSync('${{github.workspace}}/previewbuild.zip', Buffer.from(download.data));
|
||||||
|
|
||||||
|
var prInfoArtifact = artifacts.data.artifacts.filter((artifact) => {
|
||||||
|
return artifact.name == "pr.json"
|
||||||
|
})[0];
|
||||||
|
var download = await github.actions.downloadArtifact({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
artifact_id: prInfoArtifact.id,
|
||||||
|
archive_format: 'zip',
|
||||||
|
});
|
||||||
|
var fs = require('fs');
|
||||||
|
fs.writeFileSync('${{github.workspace}}/pr.json.zip', Buffer.from(download.data));
|
||||||
|
- name: Extract Artifacts
|
||||||
|
run: unzip -d webapp previewbuild.zip && rm previewbuild.zip && unzip pr.json.zip && rm pr.json.zip
|
||||||
|
- name: 'Read PR Info'
|
||||||
|
id: readctx
|
||||||
|
uses: actions/github-script@v3.1.0
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
var fs = require('fs');
|
||||||
|
var pr = JSON.parse(fs.readFileSync('${{github.workspace}}/pr.json'));
|
||||||
|
console.log(`::set-output name=prnumber::${pr.number}`);
|
||||||
|
- name: Deploy to Netlify
|
||||||
|
id: netlify
|
||||||
|
uses: nwtgck/actions-netlify@v1.2
|
||||||
|
with:
|
||||||
|
publish-dir: webapp
|
||||||
|
deploy-message: "Deploy from GitHub Actions"
|
||||||
|
# These don't work because we're in workflow_run
|
||||||
|
enable-pull-request-comment: false
|
||||||
|
enable-commit-comment: false
|
||||||
|
env:
|
||||||
|
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||||
|
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
|
||||||
|
timeout-minutes: 1
|
||||||
|
- name: Edit PR Description
|
||||||
|
uses: velas/pr-description@v1.0.1
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
with:
|
||||||
|
pull-request-number: ${{ steps.readctx.outputs.prnumber }}
|
||||||
|
description-message: |
|
||||||
|
Preview: ${{ steps.netlify.outputs.deploy-url }}
|
||||||
|
⚠️ Do you trust the author of this PR? Maybe this build will steal your keys or give you malware. Exercise caution. Use test accounts.
|
||||||
|
|
12
.github/workflows/preview_changelog.yaml
vendored
Normal file
12
.github/workflows/preview_changelog.yaml
vendored
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
name: Preview Changelog
|
||||||
|
on:
|
||||||
|
pull_request_target:
|
||||||
|
types: [ opened, edited, labeled ]
|
||||||
|
jobs:
|
||||||
|
changelog:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Preview Changelog
|
||||||
|
uses: matrix-org/allchange@main
|
||||||
|
with:
|
||||||
|
ghToken: ${{ secrets.GITHUB_TOKEN }}
|
1
.node-version
Normal file
1
.node-version
Normal file
|
@ -0,0 +1 @@
|
||||||
|
14
|
119
CHANGELOG.md
119
CHANGELOG.md
|
@ -1,3 +1,122 @@
|
||||||
|
Changes in [3.27.0](https://github.com/vector-im/element-desktop/releases/tag/v3.27.0) (2021-07-02)
|
||||||
|
===================================================================================================
|
||||||
|
|
||||||
|
## 🔒 SECURITY FIXES
|
||||||
|
* Sanitize untrusted variables from message previews before translation
|
||||||
|
Fixes vector-im/element-web#18314
|
||||||
|
|
||||||
|
## ✨ Features
|
||||||
|
* Fix editing of `<sub>` & `<sup`> & `<u>`
|
||||||
|
[\#6469](https://github.com/matrix-org/matrix-react-sdk/pull/6469)
|
||||||
|
Fixes vector-im/element-web#18211
|
||||||
|
* Zoom images in lightbox to where the cursor points
|
||||||
|
[\#6418](https://github.com/matrix-org/matrix-react-sdk/pull/6418)
|
||||||
|
Fixes vector-im/element-web#17870
|
||||||
|
* Avoid hitting the settings store from TextForEvent
|
||||||
|
[\#6205](https://github.com/matrix-org/matrix-react-sdk/pull/6205)
|
||||||
|
Fixes vector-im/element-web#17650
|
||||||
|
* Initial MSC3083 + MSC3244 support
|
||||||
|
[\#6212](https://github.com/matrix-org/matrix-react-sdk/pull/6212)
|
||||||
|
Fixes vector-im/element-web#17686 and vector-im/element-web#17661
|
||||||
|
* Navigate to the first room with notifications when clicked on space notification dot
|
||||||
|
[\#5974](https://github.com/matrix-org/matrix-react-sdk/pull/5974)
|
||||||
|
* Add matrix: to the list of permitted URL schemes
|
||||||
|
[\#6388](https://github.com/matrix-org/matrix-react-sdk/pull/6388)
|
||||||
|
* Add "Copy Link" to room context menu
|
||||||
|
[\#6374](https://github.com/matrix-org/matrix-react-sdk/pull/6374)
|
||||||
|
* 💭 Message bubble layout
|
||||||
|
[\#6291](https://github.com/matrix-org/matrix-react-sdk/pull/6291)
|
||||||
|
Fixes vector-im/element-web#4635, vector-im/element-web#17773 vector-im/element-web#16220 and vector-im/element-web#7687
|
||||||
|
* Play only one audio file at a time
|
||||||
|
[\#6417](https://github.com/matrix-org/matrix-react-sdk/pull/6417)
|
||||||
|
Fixes vector-im/element-web#17439
|
||||||
|
* Move download button for media to the action bar
|
||||||
|
[\#6386](https://github.com/matrix-org/matrix-react-sdk/pull/6386)
|
||||||
|
Fixes vector-im/element-web#17943
|
||||||
|
* Improved display of one-to-one call history with summary boxes for each call
|
||||||
|
[\#6121](https://github.com/matrix-org/matrix-react-sdk/pull/6121)
|
||||||
|
Fixes vector-im/element-web#16409
|
||||||
|
* Notification settings UI refresh
|
||||||
|
[\#6352](https://github.com/matrix-org/matrix-react-sdk/pull/6352)
|
||||||
|
Fixes vector-im/element-web#17782
|
||||||
|
* Fix EventIndex double handling events and erroring
|
||||||
|
[\#6385](https://github.com/matrix-org/matrix-react-sdk/pull/6385)
|
||||||
|
Fixes vector-im/element-web#18008
|
||||||
|
* Improve reply rendering
|
||||||
|
[\#3553](https://github.com/matrix-org/matrix-react-sdk/pull/3553)
|
||||||
|
Fixes vector-im/riot-web#9217, vector-im/riot-web#7633, vector-im/riot-web#7530, vector-im/riot-web#7169, vector-im/riot-web#7151, vector-im/riot-web#6692 vector-im/riot-web#6579 and vector-im/element-web#17440
|
||||||
|
|
||||||
|
## 🐛 Bug Fixes
|
||||||
|
* Fix CreateRoomDialog exploding when making public room outside of a space
|
||||||
|
[\#6493](https://github.com/matrix-org/matrix-react-sdk/pull/6493)
|
||||||
|
* Fix regression where registration would soft-crash on captcha
|
||||||
|
[\#6505](https://github.com/matrix-org/matrix-react-sdk/pull/6505)
|
||||||
|
Fixes vector-im/element-web#18284
|
||||||
|
* only send join rule event if we have a join rule to put in it
|
||||||
|
[\#6517](https://github.com/matrix-org/matrix-react-sdk/pull/6517)
|
||||||
|
* Improve the new download button's discoverability and interactions.
|
||||||
|
[\#6510](https://github.com/matrix-org/matrix-react-sdk/pull/6510)
|
||||||
|
* Fix voice recording UI looking broken while microphone permissions are being requested.
|
||||||
|
[\#6479](https://github.com/matrix-org/matrix-react-sdk/pull/6479)
|
||||||
|
Fixes vector-im/element-web#18223
|
||||||
|
* Match colors of room and user avatars in DMs
|
||||||
|
[\#6393](https://github.com/matrix-org/matrix-react-sdk/pull/6393)
|
||||||
|
Fixes vector-im/element-web#2449
|
||||||
|
* Fix onPaste handler to work with copying files from Finder
|
||||||
|
[\#5389](https://github.com/matrix-org/matrix-react-sdk/pull/5389)
|
||||||
|
Fixes vector-im/element-web#15536 and vector-im/element-web#16255
|
||||||
|
* Fix infinite pagination loop when offline
|
||||||
|
[\#6478](https://github.com/matrix-org/matrix-react-sdk/pull/6478)
|
||||||
|
Fixes vector-im/element-web#18242
|
||||||
|
* Fix blurhash rounded corners missing regression
|
||||||
|
[\#6467](https://github.com/matrix-org/matrix-react-sdk/pull/6467)
|
||||||
|
Fixes vector-im/element-web#18110
|
||||||
|
* Fix position of the space hierarchy spinner
|
||||||
|
[\#6462](https://github.com/matrix-org/matrix-react-sdk/pull/6462)
|
||||||
|
Fixes vector-im/element-web#18182
|
||||||
|
* Fix display of image messages that lack thumbnails
|
||||||
|
[\#6456](https://github.com/matrix-org/matrix-react-sdk/pull/6456)
|
||||||
|
Fixes vector-im/element-web#18175
|
||||||
|
* Fix crash with large audio files.
|
||||||
|
[\#6436](https://github.com/matrix-org/matrix-react-sdk/pull/6436)
|
||||||
|
Fixes vector-im/element-web#18149
|
||||||
|
* Make diff colors in codeblocks more pleasant
|
||||||
|
[\#6355](https://github.com/matrix-org/matrix-react-sdk/pull/6355)
|
||||||
|
Fixes vector-im/element-web#17939
|
||||||
|
* Show the correct audio file duration while loading the file.
|
||||||
|
[\#6435](https://github.com/matrix-org/matrix-react-sdk/pull/6435)
|
||||||
|
Fixes vector-im/element-web#18160
|
||||||
|
* Fix various timeline settings not applying immediately.
|
||||||
|
[\#6261](https://github.com/matrix-org/matrix-react-sdk/pull/6261)
|
||||||
|
Fixes vector-im/element-web#17748
|
||||||
|
* Fix issues with room list duplication
|
||||||
|
[\#6391](https://github.com/matrix-org/matrix-react-sdk/pull/6391)
|
||||||
|
Fixes vector-im/element-web#14508
|
||||||
|
* Fix grecaptcha throwing useless error sometimes
|
||||||
|
[\#6401](https://github.com/matrix-org/matrix-react-sdk/pull/6401)
|
||||||
|
Fixes vector-im/element-web#15142
|
||||||
|
* Update Emojibase and Twemoji and switch to IamCal (Slack-style) shortcodes
|
||||||
|
[\#6347](https://github.com/matrix-org/matrix-react-sdk/pull/6347)
|
||||||
|
Fixes vector-im/element-web#13857 and vector-im/element-web#13334
|
||||||
|
* Respect compound emojis in default avatar initial generation
|
||||||
|
[\#6397](https://github.com/matrix-org/matrix-react-sdk/pull/6397)
|
||||||
|
Fixes vector-im/element-web#18040
|
||||||
|
* Fix bug where the 'other homeserver' field in the server selection dialog would become briefly focus and then unfocus when clicked.
|
||||||
|
[\#6394](https://github.com/matrix-org/matrix-react-sdk/pull/6394)
|
||||||
|
Fixes vector-im/element-web#18031
|
||||||
|
* Standardise spelling and casing of homeserver, identity server, and integration manager
|
||||||
|
[\#6365](https://github.com/matrix-org/matrix-react-sdk/pull/6365)
|
||||||
|
* Fix widgets not receiving decrypted events when they have permission.
|
||||||
|
[\#6371](https://github.com/matrix-org/matrix-react-sdk/pull/6371)
|
||||||
|
Fixes vector-im/element-web#17615
|
||||||
|
* Prevent client hangs when calculating blurhashes
|
||||||
|
[\#6366](https://github.com/matrix-org/matrix-react-sdk/pull/6366)
|
||||||
|
Fixes vector-im/element-web#17945
|
||||||
|
* Exclude state events from widgets reading room events
|
||||||
|
[\#6378](https://github.com/matrix-org/matrix-react-sdk/pull/6378)
|
||||||
|
* Cache feature_spaces\* flags to improve performance
|
||||||
|
[\#6381](https://github.com/matrix-org/matrix-react-sdk/pull/6381)
|
||||||
|
|
||||||
Changes in [3.26.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.26.0) (2021-07-19)
|
Changes in [3.26.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.26.0) (2021-07-19)
|
||||||
=====================================================================================================
|
=====================================================================================================
|
||||||
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.26.0-rc.1...v3.26.0)
|
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.26.0-rc.1...v3.26.0)
|
||||||
|
|
|
@ -34,7 +34,7 @@ All code lands on the `develop` branch - `master` is only used for stable releas
|
||||||
**Please file PRs against `develop`!!**
|
**Please file PRs against `develop`!!**
|
||||||
|
|
||||||
Please follow the standard Matrix contributor's guide:
|
Please follow the standard Matrix contributor's guide:
|
||||||
https://github.com/matrix-org/matrix-js-sdk/blob/develop/CONTRIBUTING.rst
|
https://github.com/matrix-org/matrix-js-sdk/blob/develop/CONTRIBUTING.md
|
||||||
|
|
||||||
Please follow the Matrix JS/React code style as per:
|
Please follow the Matrix JS/React code style as per:
|
||||||
https://github.com/matrix-org/matrix-react-sdk/blob/master/code_style.md
|
https://github.com/matrix-org/matrix-react-sdk/blob/master/code_style.md
|
||||||
|
|
13
package.json
13
package.json
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "matrix-react-sdk",
|
"name": "matrix-react-sdk",
|
||||||
"version": "3.26.0",
|
"version": "3.27.0",
|
||||||
"description": "SDK for matrix.org using React",
|
"description": "SDK for matrix.org using React",
|
||||||
"author": "matrix.org",
|
"author": "matrix.org",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
@ -80,13 +80,14 @@
|
||||||
"katex": "^0.12.0",
|
"katex": "^0.12.0",
|
||||||
"linkifyjs": "^2.1.9",
|
"linkifyjs": "^2.1.9",
|
||||||
"lodash": "^4.17.20",
|
"lodash": "^4.17.20",
|
||||||
"matrix-js-sdk": "12.1.0",
|
"matrix-js-sdk": "12.2.0",
|
||||||
"matrix-widget-api": "^0.1.0-beta.15",
|
"matrix-widget-api": "^0.1.0-beta.15",
|
||||||
"minimist": "^1.2.5",
|
"minimist": "^1.2.5",
|
||||||
"opus-recorder": "^8.0.3",
|
"opus-recorder": "^8.0.3",
|
||||||
"pako": "^2.0.3",
|
"pako": "^2.0.3",
|
||||||
"parse5": "^6.0.1",
|
"parse5": "^6.0.1",
|
||||||
"png-chunks-extract": "^1.0.0",
|
"png-chunks-extract": "^1.0.0",
|
||||||
|
"posthog-js": "1.12.2",
|
||||||
"prop-types": "^15.7.2",
|
"prop-types": "^15.7.2",
|
||||||
"qrcode": "^1.4.4",
|
"qrcode": "^1.4.4",
|
||||||
"re-resizable": "^6.9.0",
|
"re-resizable": "^6.9.0",
|
||||||
|
@ -123,6 +124,7 @@
|
||||||
"@babel/traverse": "^7.12.12",
|
"@babel/traverse": "^7.12.12",
|
||||||
"@matrix-org/olm": "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.3.tgz",
|
"@matrix-org/olm": "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.3.tgz",
|
||||||
"@peculiar/webcrypto": "^1.1.4",
|
"@peculiar/webcrypto": "^1.1.4",
|
||||||
|
"@sentry/types": "^6.10.0",
|
||||||
"@sinonjs/fake-timers": "^7.0.2",
|
"@sinonjs/fake-timers": "^7.0.2",
|
||||||
"@types/classnames": "^2.2.11",
|
"@types/classnames": "^2.2.11",
|
||||||
"@types/commonmark": "^0.27.4",
|
"@types/commonmark": "^0.27.4",
|
||||||
|
@ -147,13 +149,14 @@
|
||||||
"@typescript-eslint/eslint-plugin": "^4.17.0",
|
"@typescript-eslint/eslint-plugin": "^4.17.0",
|
||||||
"@typescript-eslint/parser": "^4.17.0",
|
"@typescript-eslint/parser": "^4.17.0",
|
||||||
"@wojtekmaj/enzyme-adapter-react-17": "^0.6.1",
|
"@wojtekmaj/enzyme-adapter-react-17": "^0.6.1",
|
||||||
|
"allchange": "github:matrix-org/allchange",
|
||||||
"babel-jest": "^26.6.3",
|
"babel-jest": "^26.6.3",
|
||||||
"chokidar": "^3.5.1",
|
"chokidar": "^3.5.1",
|
||||||
"concurrently": "^5.3.0",
|
"concurrently": "^5.3.0",
|
||||||
"enzyme": "^3.11.0",
|
"enzyme": "^3.11.0",
|
||||||
"eslint": "7.18.0",
|
"eslint": "7.18.0",
|
||||||
"eslint-config-google": "^0.14.0",
|
"eslint-config-google": "^0.14.0",
|
||||||
"eslint-plugin-matrix-org": "github:matrix-org/eslint-plugin-matrix-org#main",
|
"eslint-plugin-matrix-org": "github:matrix-org/eslint-plugin-matrix-org#2306b3d4da4eba908b256014b979f1d3d43d2945",
|
||||||
"eslint-plugin-react": "^7.22.0",
|
"eslint-plugin-react": "^7.22.0",
|
||||||
"eslint-plugin-react-hooks": "^4.2.0",
|
"eslint-plugin-react-hooks": "^4.2.0",
|
||||||
"glob": "^7.1.6",
|
"glob": "^7.1.6",
|
||||||
|
@ -166,6 +169,7 @@
|
||||||
"matrix-web-i18n": "github:matrix-org/matrix-web-i18n",
|
"matrix-web-i18n": "github:matrix-org/matrix-web-i18n",
|
||||||
"react-test-renderer": "^17.0.2",
|
"react-test-renderer": "^17.0.2",
|
||||||
"rimraf": "^3.0.2",
|
"rimraf": "^3.0.2",
|
||||||
|
"rrweb-snapshot": "1.1.7",
|
||||||
"stylelint": "^13.9.0",
|
"stylelint": "^13.9.0",
|
||||||
"stylelint-config-standard": "^20.0.0",
|
"stylelint-config-standard": "^20.0.0",
|
||||||
"stylelint-scss": "^3.18.0",
|
"stylelint-scss": "^3.18.0",
|
||||||
|
@ -189,7 +193,8 @@
|
||||||
"decoderWorker\\.min\\.js": "<rootDir>/__mocks__/empty.js",
|
"decoderWorker\\.min\\.js": "<rootDir>/__mocks__/empty.js",
|
||||||
"decoderWorker\\.min\\.wasm": "<rootDir>/__mocks__/empty.js",
|
"decoderWorker\\.min\\.wasm": "<rootDir>/__mocks__/empty.js",
|
||||||
"waveWorker\\.min\\.js": "<rootDir>/__mocks__/empty.js",
|
"waveWorker\\.min\\.js": "<rootDir>/__mocks__/empty.js",
|
||||||
"workers/(.+)\\.worker\\.ts": "<rootDir>/__mocks__/workerMock.js"
|
"workers/(.+)\\.worker\\.ts": "<rootDir>/__mocks__/workerMock.js",
|
||||||
|
"RecorderWorklet": "<rootDir>/__mocks__/empty.js"
|
||||||
},
|
},
|
||||||
"transformIgnorePatterns": [
|
"transformIgnorePatterns": [
|
||||||
"/node_modules/(?!matrix-js-sdk).+$"
|
"/node_modules/(?!matrix-js-sdk).+$"
|
||||||
|
|
4
release_config.yaml
Normal file
4
release_config.yaml
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
subprojects:
|
||||||
|
matrix-js-sdk:
|
||||||
|
includeByDefault: false
|
||||||
|
|
|
@ -75,6 +75,7 @@
|
||||||
@import "./views/dialogs/_CreateCommunityPrototypeDialog.scss";
|
@import "./views/dialogs/_CreateCommunityPrototypeDialog.scss";
|
||||||
@import "./views/dialogs/_CreateGroupDialog.scss";
|
@import "./views/dialogs/_CreateGroupDialog.scss";
|
||||||
@import "./views/dialogs/_CreateRoomDialog.scss";
|
@import "./views/dialogs/_CreateRoomDialog.scss";
|
||||||
|
@import "./views/dialogs/_CreateSpaceFromCommunityDialog.scss";
|
||||||
@import "./views/dialogs/_CreateSubspaceDialog.scss";
|
@import "./views/dialogs/_CreateSubspaceDialog.scss";
|
||||||
@import "./views/dialogs/_DeactivateAccountDialog.scss";
|
@import "./views/dialogs/_DeactivateAccountDialog.scss";
|
||||||
@import "./views/dialogs/_DevtoolsDialog.scss";
|
@import "./views/dialogs/_DevtoolsDialog.scss";
|
||||||
|
@ -240,6 +241,7 @@
|
||||||
@import "./views/settings/_E2eAdvancedPanel.scss";
|
@import "./views/settings/_E2eAdvancedPanel.scss";
|
||||||
@import "./views/settings/_EmailAddresses.scss";
|
@import "./views/settings/_EmailAddresses.scss";
|
||||||
@import "./views/settings/_IntegrationManager.scss";
|
@import "./views/settings/_IntegrationManager.scss";
|
||||||
|
@import "./views/settings/_LayoutSwitcher.scss";
|
||||||
@import "./views/settings/_Notifications.scss";
|
@import "./views/settings/_Notifications.scss";
|
||||||
@import "./views/settings/_PhoneNumbers.scss";
|
@import "./views/settings/_PhoneNumbers.scss";
|
||||||
@import "./views/settings/_ProfileSettings.scss";
|
@import "./views/settings/_ProfileSettings.scss";
|
||||||
|
@ -266,12 +268,15 @@
|
||||||
@import "./views/spaces/_SpacePublicShare.scss";
|
@import "./views/spaces/_SpacePublicShare.scss";
|
||||||
@import "./views/terms/_InlineTermsAgreement.scss";
|
@import "./views/terms/_InlineTermsAgreement.scss";
|
||||||
@import "./views/toasts/_AnalyticsToast.scss";
|
@import "./views/toasts/_AnalyticsToast.scss";
|
||||||
|
@import "./views/toasts/_IncomingCallToast.scss";
|
||||||
@import "./views/toasts/_NonUrgentEchoFailureToast.scss";
|
@import "./views/toasts/_NonUrgentEchoFailureToast.scss";
|
||||||
@import "./views/verification/_VerificationShowSas.scss";
|
@import "./views/verification/_VerificationShowSas.scss";
|
||||||
|
@import "./views/voip/CallView/_CallViewButtons.scss";
|
||||||
@import "./views/voip/_CallContainer.scss";
|
@import "./views/voip/_CallContainer.scss";
|
||||||
@import "./views/voip/_CallPreview.scss";
|
@import "./views/voip/_CallPreview.scss";
|
||||||
@import "./views/voip/_CallView.scss";
|
@import "./views/voip/_CallView.scss";
|
||||||
@import "./views/voip/_CallViewForRoom.scss";
|
@import "./views/voip/_CallViewForRoom.scss";
|
||||||
|
@import "./views/voip/_CallViewHeader.scss";
|
||||||
@import "./views/voip/_CallViewSidebar.scss";
|
@import "./views/voip/_CallViewSidebar.scss";
|
||||||
@import "./views/voip/_DialPad.scss";
|
@import "./views/voip/_DialPad.scss";
|
||||||
@import "./views/voip/_DialPadContextMenu.scss";
|
@import "./views/voip/_DialPadContextMenu.scss";
|
||||||
|
|
|
@ -368,6 +368,65 @@ limitations under the License.
|
||||||
padding: 40px 20px;
|
padding: 40px 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mx_GroupView_spaceUpgradePrompt {
|
||||||
|
padding: 16px 50px;
|
||||||
|
background-color: $header-panel-bg-color;
|
||||||
|
border-radius: 8px;
|
||||||
|
max-width: 632px;
|
||||||
|
font-size: $font-15px;
|
||||||
|
line-height: $font-24px;
|
||||||
|
margin-top: 24px;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
> h2 {
|
||||||
|
font-size: inherit;
|
||||||
|
font-weight: $font-semi-bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
> p, h2 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
height: $font-24px;
|
||||||
|
width: 20px;
|
||||||
|
left: 18px;
|
||||||
|
mask-repeat: no-repeat;
|
||||||
|
mask-position: center;
|
||||||
|
mask-size: contain;
|
||||||
|
mask-image: url('$(res)/img/element-icons/room/room-summary.svg');
|
||||||
|
background-color: $secondary-fg-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_AccessibleButton_kind_link {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_GroupView_spaceUpgradePrompt_close {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background-color: $input-darker-bg-color;
|
||||||
|
position: absolute;
|
||||||
|
top: 16px;
|
||||||
|
right: 16px;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
width: inherit;
|
||||||
|
height: inherit;
|
||||||
|
mask-repeat: no-repeat;
|
||||||
|
mask-position: center;
|
||||||
|
mask-size: 8px;
|
||||||
|
mask-image: url('$(res)/img/image-view/close.svg');
|
||||||
|
background-color: $secondary-fg-color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.mx_GroupView .mx_MemberInfo .mx_AutoHideScrollbar > :not(.mx_MemberInfo_avatar) {
|
.mx_GroupView .mx_MemberInfo .mx_AutoHideScrollbar > :not(.mx_MemberInfo_avatar) {
|
||||||
padding-left: 16px;
|
padding-left: 16px;
|
||||||
padding-right: 16px;
|
padding-right: 16px;
|
||||||
|
|
|
@ -255,7 +255,7 @@ limitations under the License.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover, &:focus-within {
|
||||||
background-color: $groupFilterPanel-bg-color;
|
background-color: $groupFilterPanel-bg-color;
|
||||||
|
|
||||||
.mx_AccessibleButton {
|
.mx_AccessibleButton {
|
||||||
|
@ -264,6 +264,10 @@ limitations under the License.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
li.mx_SpaceRoomDirectory_roomTileWrapper {
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
.mx_SpaceHierarchy_roomTile,
|
.mx_SpaceHierarchy_roomTile,
|
||||||
.mx_SpaceHierarchy_subspace_children {
|
.mx_SpaceHierarchy_subspace_children {
|
||||||
&::before {
|
&::before {
|
||||||
|
|
|
@ -297,7 +297,7 @@ $activeBorderColor: $secondary-fg-color;
|
||||||
.mx_SpaceButton:hover,
|
.mx_SpaceButton:hover,
|
||||||
.mx_SpaceButton:focus-within,
|
.mx_SpaceButton:focus-within,
|
||||||
.mx_SpaceButton_hasMenuOpen {
|
.mx_SpaceButton_hasMenuOpen {
|
||||||
&:not(.mx_SpaceButton_home):not(.mx_SpaceButton_invite) {
|
&:not(.mx_SpaceButton_invite) {
|
||||||
// Hide the badge container on hover because it'll be a menu button
|
// Hide the badge container on hover because it'll be a menu button
|
||||||
.mx_SpacePanel_badgeContainer {
|
.mx_SpacePanel_badgeContainer {
|
||||||
width: 0;
|
width: 0;
|
||||||
|
@ -368,6 +368,14 @@ $activeBorderColor: $secondary-fg-color;
|
||||||
.mx_SpacePanel_iconExplore::before {
|
.mx_SpacePanel_iconExplore::before {
|
||||||
mask-image: url('$(res)/img/element-icons/roomlist/browse.svg');
|
mask-image: url('$(res)/img/element-icons/roomlist/browse.svg');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mx_SpacePanel_noIcon {
|
||||||
|
display: none;
|
||||||
|
|
||||||
|
& + .mx_IconizedContextMenu_label {
|
||||||
|
padding-left: 5px !important; // override default iconized label style to align with header
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -180,6 +180,18 @@ $SpaceRoomViewInnerWidth: 428px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mx_SpaceRoomView_preview_migratedCommunity {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid $input-border-color;
|
||||||
|
width: max-content;
|
||||||
|
|
||||||
|
.mx_BaseAvatar {
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.mx_SpaceRoomView_preview_inviter {
|
.mx_SpaceRoomView_preview_inviter {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
@ -342,7 +354,7 @@ $SpaceRoomViewInnerWidth: 428px;
|
||||||
|
|
||||||
.mx_SpaceFeedbackPrompt {
|
.mx_SpaceFeedbackPrompt {
|
||||||
padding: 7px; // 8px - 1px border
|
padding: 7px; // 8px - 1px border
|
||||||
border: 1px solid $menu-border-color;
|
border: 1px solid rgba($primary-fg-color, .1);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
width: max-content;
|
width: max-content;
|
||||||
margin: 0 0 -40px auto; // collapse its own height to not push other components down
|
margin: 0 0 -40px auto; // collapse its own height to not push other components down
|
||||||
|
|
|
@ -28,7 +28,7 @@ limitations under the License.
|
||||||
margin: 0 4px;
|
margin: 0 4px;
|
||||||
grid-row: 2 / 4;
|
grid-row: 2 / 4;
|
||||||
grid-column: 1;
|
grid-column: 1;
|
||||||
background-color: $dark-panel-bg-color;
|
background-color: $toast-bg-color;
|
||||||
box-shadow: 0px 4px 20px rgba(0, 0, 0, 0.5);
|
box-shadow: 0px 4px 20px rgba(0, 0, 0, 0.5);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
|
@ -37,7 +37,7 @@ limitations under the License.
|
||||||
grid-row: 1 / 3;
|
grid-row: 1 / 3;
|
||||||
grid-column: 1;
|
grid-column: 1;
|
||||||
color: $primary-fg-color;
|
color: $primary-fg-color;
|
||||||
background-color: $dark-panel-bg-color;
|
background-color: $toast-bg-color;
|
||||||
box-shadow: 0px 4px 20px rgba(0, 0, 0, 0.5);
|
box-shadow: 0px 4px 20px rgba(0, 0, 0, 0.5);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
|
@ -85,7 +85,7 @@ limitations under the License.
|
||||||
.mx_InteractiveAuthEntryComponents_termsPolicy {
|
.mx_InteractiveAuthEntryComponents_termsPolicy {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
justify-content: start;
|
justify-content: flex-start;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -149,12 +149,17 @@ limitations under the License.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.mx_IconizedContextMenu_checked {
|
.mx_IconizedContextMenu_checked,
|
||||||
|
.mx_IconizedContextMenu_unchecked {
|
||||||
margin-left: 16px;
|
margin-left: 16px;
|
||||||
margin-right: -5px;
|
margin-right: -5px;
|
||||||
|
}
|
||||||
|
|
||||||
&::before {
|
.mx_IconizedContextMenu_checked::before {
|
||||||
mask-image: url('$(res)/img/element-icons/roomlist/checkmark.svg');
|
mask-image: url('$(res)/img/element-icons/roomlist/checkmark.svg');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mx_IconizedContextMenu_unchecked::before {
|
||||||
|
content: unset;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -51,6 +51,10 @@ limitations under the License.
|
||||||
mask-image: url('$(res)/img/element-icons/hide.svg');
|
mask-image: url('$(res)/img/element-icons/hide.svg');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mx_TagTileContextMenu_createSpace::before {
|
||||||
|
mask-image: url('$(res)/img/element-icons/message/fwd.svg');
|
||||||
|
}
|
||||||
|
|
||||||
.mx_TagTileContextMenu_separator {
|
.mx_TagTileContextMenu_separator {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
|
|
187
res/css/views/dialogs/_CreateSpaceFromCommunityDialog.scss
Normal file
187
res/css/views/dialogs/_CreateSpaceFromCommunityDialog.scss
Normal file
|
@ -0,0 +1,187 @@
|
||||||
|
/*
|
||||||
|
Copyright 2021 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.mx_CreateSpaceFromCommunityDialog_wrapper {
|
||||||
|
.mx_Dialog {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_CreateSpaceFromCommunityDialog {
|
||||||
|
width: 480px;
|
||||||
|
color: $primary-fg-color;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
min-height: 0;
|
||||||
|
|
||||||
|
.mx_CreateSpaceFromCommunityDialog_content {
|
||||||
|
> p {
|
||||||
|
font-size: $font-15px;
|
||||||
|
line-height: $font-24px;
|
||||||
|
|
||||||
|
&:first-of-type {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.mx_CreateSpaceFromCommunityDialog_flairNotice {
|
||||||
|
font-size: $font-12px;
|
||||||
|
line-height: $font-15px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_SpaceBasicSettings {
|
||||||
|
> p {
|
||||||
|
font-size: $font-12px;
|
||||||
|
line-height: $font-15px;
|
||||||
|
margin: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_Field_textarea {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_JoinRuleDropdown .mx_Dropdown_menu {
|
||||||
|
width: auto !important; // override fixed width
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_CreateSpaceFromCommunityDialog_nonPublicSpacer {
|
||||||
|
height: 63px; // balance the height of the missing room alias field to prevent modal bouncing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_CreateSpaceFromCommunityDialog_footer {
|
||||||
|
display: flex;
|
||||||
|
margin-top: 20px;
|
||||||
|
|
||||||
|
> span {
|
||||||
|
flex-grow: 1;
|
||||||
|
font-size: $font-12px;
|
||||||
|
line-height: $font-15px;
|
||||||
|
color: $secondary-fg-color;
|
||||||
|
|
||||||
|
.mx_ProgressBar {
|
||||||
|
height: 8px;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
@mixin ProgressBarBorderRadius 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_CreateSpaceFromCommunityDialog_progressText {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: $font-15px;
|
||||||
|
line-height: $font-24px;
|
||||||
|
color: $primary-fg-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
> * {
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_CreateSpaceFromCommunityDialog_error {
|
||||||
|
padding-left: 12px;
|
||||||
|
|
||||||
|
> img {
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_CreateSpaceFromCommunityDialog_errorHeading {
|
||||||
|
font-weight: $font-semi-bold;
|
||||||
|
font-size: $font-15px;
|
||||||
|
line-height: $font-18px;
|
||||||
|
color: $notice-primary-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_CreateSpaceFromCommunityDialog_errorCaption {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: $font-12px;
|
||||||
|
line-height: $font-15px;
|
||||||
|
color: $primary-fg-color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_AccessibleButton {
|
||||||
|
display: inline-block;
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_AccessibleButton_kind_primary {
|
||||||
|
padding: 8px 36px;
|
||||||
|
margin-left: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_AccessibleButton_kind_primary_outline {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_CreateSpaceFromCommunityDialog_retryButton {
|
||||||
|
margin-left: 12px;
|
||||||
|
padding-left: 24px;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
background-color: $primary-fg-color;
|
||||||
|
mask-repeat: no-repeat;
|
||||||
|
mask-position: center;
|
||||||
|
mask-size: contain;
|
||||||
|
mask-image: url('$(res)/img/element-icons/retry.svg');
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_AccessibleButton_kind_link {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_CreateSpaceFromCommunityDialog_SuccessInfoDialog {
|
||||||
|
.mx_InfoDialog {
|
||||||
|
max-width: 500px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_AccessibleButton_kind_link {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_CreateSpaceFromCommunityDialog_SuccessInfoDialog_checkmark {
|
||||||
|
position: relative;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 3px solid $accent-color;
|
||||||
|
width: 68px;
|
||||||
|
height: 68px;
|
||||||
|
margin: 12px auto 32px;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
width: inherit;
|
||||||
|
height: inherit;
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
background-color: $accent-color;
|
||||||
|
mask-repeat: no-repeat;
|
||||||
|
mask-position: center;
|
||||||
|
mask-image: url('$(res)/img/element-icons/roomlist/checkmark.svg');
|
||||||
|
mask-size: 48px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -24,18 +24,15 @@ limitations under the License.
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
height: 500px;
|
height: 500px;
|
||||||
overflow: overlay;
|
overflow: overlay;
|
||||||
}
|
|
||||||
|
|
||||||
.mx_desktopCapturerSourcePicker_source {
|
.mx_desktopCapturerSourcePicker_source {
|
||||||
|
width: 50%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
margin: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx_desktopCapturerSourcePicker_source_thumbnail {
|
.mx_desktopCapturerSourcePicker_source_thumbnail {
|
||||||
margin: 4px;
|
margin: 4px;
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
width: 312px;
|
|
||||||
border-width: 2px;
|
border-width: 2px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
border-style: solid;
|
border-style: solid;
|
||||||
|
@ -53,6 +50,7 @@ limitations under the License.
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
width: 312px;
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -38,6 +38,7 @@ limitations under the License.
|
||||||
.mx_Field input,
|
.mx_Field input,
|
||||||
.mx_Field select,
|
.mx_Field select,
|
||||||
.mx_Field textarea {
|
.mx_Field textarea {
|
||||||
|
font-family: inherit;
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
font-size: $font-14px;
|
font-size: $font-14px;
|
||||||
border: none;
|
border: none;
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
/*
|
/*
|
||||||
Copyright 2015, 2016, 2021 The Matrix.org Foundation C.I.C.
|
Copyright 2015 - 2021 The Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
you may not use this file except in compliance with the License.
|
you may not use this file except in compliance with the License.
|
||||||
|
@ -60,6 +60,8 @@ limitations under the License.
|
||||||
}
|
}
|
||||||
|
|
||||||
.mx_MFileBody_info {
|
.mx_MFileBody_info {
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
.mx_MFileBody_info_icon {
|
.mx_MFileBody_info_icon {
|
||||||
background-color: $message-body-panel-icon-bg-color;
|
background-color: $message-body-panel-icon-bg-color;
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
|
|
|
@ -65,6 +65,14 @@ limitations under the License.
|
||||||
font-size: $font-10-4px;
|
font-size: $font-10-4px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
span.mx_UserPill {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
span.mx_RoomPill {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.mx_BasicMessageComposer_input_disabled {
|
&.mx_BasicMessageComposer_input_disabled {
|
||||||
|
|
|
@ -271,7 +271,7 @@ limitations under the License.
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: start;
|
justify-content: flex-start;
|
||||||
padding: 5px 0;
|
padding: 5px 0;
|
||||||
|
|
||||||
.mx_EventTile_avatar {
|
.mx_EventTile_avatar {
|
||||||
|
|
|
@ -310,14 +310,12 @@ $hover-select-border: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mx_RoomView_timeline_rr_enabled {
|
.mx_RoomView_timeline_rr_enabled {
|
||||||
|
.mx_EventTile[data-layout=group] {
|
||||||
.mx_EventTile:not([data-layout=bubble]) {
|
|
||||||
.mx_EventTile_line {
|
.mx_EventTile_line {
|
||||||
/* ideally should be 100px, but 95px gives us a max thumbnail size of 800x600, which is nice */
|
/* ideally should be 100px, but 95px gives us a max thumbnail size of 800x600, which is nice */
|
||||||
margin-right: 110px;
|
margin-right: 110px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// on ELS we need the margin to allow interaction with the expand/collapse button which is normally in the RR gutter
|
// on ELS we need the margin to allow interaction with the expand/collapse button which is normally in the RR gutter
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -459,8 +457,14 @@ $hover-select-border: 4px;
|
||||||
|
|
||||||
/* Various markdown overrides */
|
/* Various markdown overrides */
|
||||||
|
|
||||||
.mx_EventTile_body pre {
|
.mx_EventTile_body {
|
||||||
|
a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.mx_EventTile_content .markdown-body {
|
.mx_EventTile_content .markdown-body {
|
||||||
|
@ -485,6 +489,10 @@ $hover-select-border: 4px;
|
||||||
// https://github.com/vector-im/vector-web/issues/754
|
// https://github.com/vector-im/vector-web/issues/754
|
||||||
overflow-x: overlay;
|
overflow-x: overlay;
|
||||||
overflow-y: visible;
|
overflow-y: visible;
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-corner {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -20,7 +20,7 @@ limitations under the License.
|
||||||
height: 28px;
|
height: 28px;
|
||||||
border: 2px solid $voice-record-stop-border-color;
|
border: 2px solid $voice-record-stop-border-color;
|
||||||
border-radius: 32px;
|
border-radius: 32px;
|
||||||
margin-right: 16px; // between us and the send button
|
margin-right: 8px; // between us and the waveform component
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
&::after {
|
&::after {
|
||||||
|
@ -46,9 +46,28 @@ limitations under the License.
|
||||||
mask-image: url('$(res)/img/element-icons/trashcan.svg');
|
mask-image: url('$(res)/img/element-icons/trashcan.svg');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mx_VoiceRecordComposerTile_uploadingState {
|
||||||
|
margin-right: 10px;
|
||||||
|
color: $secondary-fg-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_VoiceRecordComposerTile_failedState {
|
||||||
|
margin-right: 21px;
|
||||||
|
|
||||||
|
.mx_VoiceRecordComposerTile_uploadState_badge {
|
||||||
|
display: inline-block;
|
||||||
|
margin-right: 4px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.mx_MessageComposer_row .mx_VoiceMessagePrimaryContainer {
|
.mx_MessageComposer_row .mx_VoiceMessagePrimaryContainer {
|
||||||
// Note: remaining class properties are in the PlayerContainer CSS.
|
// Note: remaining class properties are in the PlayerContainer CSS.
|
||||||
|
|
||||||
|
// fixed height to reduce layout jumps with the play button appearing
|
||||||
|
// https://github.com/vector-im/element-web/issues/18431
|
||||||
|
height: 32px;
|
||||||
|
|
||||||
margin: 6px; // force the composer area to put a gutter around us
|
margin: 6px; // force the composer area to put a gutter around us
|
||||||
margin-right: 12px; // isolate from stop/send button
|
margin-right: 12px; // isolate from stop/send button
|
||||||
|
|
||||||
|
@ -68,7 +87,7 @@ limitations under the License.
|
||||||
height: 10px;
|
height: 10px;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 12px; // 12px from the left edge for container padding
|
left: 12px; // 12px from the left edge for container padding
|
||||||
top: 18px; // vertically center (middle align with clock)
|
top: 17px; // vertically center (middle align with clock)
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
91
res/css/views/settings/_LayoutSwitcher.scss
Normal file
91
res/css/views/settings/_LayoutSwitcher.scss
Normal file
|
@ -0,0 +1,91 @@
|
||||||
|
/*
|
||||||
|
Copyright 2020 - 2021 The Matrix.org Foundation C.I.C.
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.mx_LayoutSwitcher {
|
||||||
|
.mx_LayoutSwitcher_RadioButtons {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 24px;
|
||||||
|
|
||||||
|
color: $primary-fg-color;
|
||||||
|
|
||||||
|
> .mx_LayoutSwitcher_RadioButton {
|
||||||
|
flex-grow: 0;
|
||||||
|
flex-shrink: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
width: 300px;
|
||||||
|
|
||||||
|
border: 1px solid $appearance-tab-border-color;
|
||||||
|
border-radius: 10px;
|
||||||
|
|
||||||
|
.mx_EventTile_msgOption,
|
||||||
|
.mx_MessageActionBar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_LayoutSwitcher_RadioButton_preview {
|
||||||
|
flex-grow: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_RadioButton {
|
||||||
|
flex-grow: 0;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_EventTile_content {
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.mx_LayoutSwitcher_RadioButton_selected {
|
||||||
|
border-color: $accent-color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_RadioButton {
|
||||||
|
border-top: 1px solid $appearance-tab-border-color;
|
||||||
|
|
||||||
|
> input + div {
|
||||||
|
border-color: rgba($muted-fg-color, 0.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_RadioButton_checked {
|
||||||
|
background-color: rgba($accent-color, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_EventTile {
|
||||||
|
margin: 0;
|
||||||
|
&[data-layout=bubble] {
|
||||||
|
margin-right: 40px;
|
||||||
|
}
|
||||||
|
&[data-layout=irc] {
|
||||||
|
> a {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.mx_EventTile_line {
|
||||||
|
max-width: 90%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -16,6 +16,7 @@ limitations under the License.
|
||||||
|
|
||||||
.mx_ProfileSettings_controls_topic {
|
.mx_ProfileSettings_controls_topic {
|
||||||
& > textarea {
|
& > textarea {
|
||||||
|
font-family: inherit;
|
||||||
resize: vertical;
|
resize: vertical;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -50,15 +50,21 @@ limitations under the License.
|
||||||
}
|
}
|
||||||
|
|
||||||
.mx_SettingsTab_section {
|
.mx_SettingsTab_section {
|
||||||
|
$right-gutter: 80px;
|
||||||
|
|
||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
|
|
||||||
.mx_SettingsFlag {
|
.mx_SettingsFlag {
|
||||||
margin-right: 80px;
|
margin-right: $right-gutter;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
> p {
|
||||||
|
margin-right: $right-gutter;
|
||||||
|
}
|
||||||
|
|
||||||
&.mx_SettingsTab_subsectionText .mx_SettingsFlag {
|
&.mx_SettingsTab_subsectionText .mx_SettingsFlag {
|
||||||
margin-right: 0px !important;
|
margin-right: 0 !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -72,6 +78,13 @@ limitations under the License.
|
||||||
padding-right: 10px;
|
padding-right: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mx_SettingsTab_section .mx_SettingsFlag .mx_SettingsFlag_microcopy {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: $font-12px;
|
||||||
|
line-height: $font-15px;
|
||||||
|
color: $secondary-fg-color;
|
||||||
|
}
|
||||||
|
|
||||||
.mx_SettingsTab_section .mx_SettingsFlag .mx_ToggleSwitch {
|
.mx_SettingsTab_section .mx_SettingsFlag .mx_ToggleSwitch {
|
||||||
float: right;
|
float: right;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
/*
|
/*
|
||||||
Copyright 2020 The Matrix.org Foundation C.I.C.
|
Copyright 2020 - 2021 The Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
you may not use this file except in compliance with the License.
|
you may not use this file except in compliance with the License.
|
||||||
|
@ -155,79 +155,6 @@ limitations under the License.
|
||||||
margin-left: calc($font-16px + 10px);
|
margin-left: calc($font-16px + 10px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.mx_AppearanceUserSettingsTab_Layout_RadioButtons {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
gap: 24px;
|
|
||||||
|
|
||||||
color: $primary-fg-color;
|
|
||||||
|
|
||||||
> .mx_AppearanceUserSettingsTab_Layout_RadioButton {
|
|
||||||
flex-grow: 0;
|
|
||||||
flex-shrink: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
|
|
||||||
width: 300px;
|
|
||||||
|
|
||||||
border: 1px solid $appearance-tab-border-color;
|
|
||||||
border-radius: 10px;
|
|
||||||
|
|
||||||
.mx_EventTile_msgOption,
|
|
||||||
.mx_MessageActionBar {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx_AppearanceUserSettingsTab_Layout_RadioButton_preview {
|
|
||||||
flex-grow: 1;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 10px;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx_RadioButton {
|
|
||||||
flex-grow: 0;
|
|
||||||
padding: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx_EventTile_content {
|
|
||||||
margin-right: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.mx_AppearanceUserSettingsTab_Layout_RadioButton_selected {
|
|
||||||
border-color: $accent-color;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx_RadioButton {
|
|
||||||
border-top: 1px solid $appearance-tab-border-color;
|
|
||||||
|
|
||||||
> input + div {
|
|
||||||
border-color: rgba($muted-fg-color, 0.2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx_RadioButton_checked {
|
|
||||||
background-color: rgba($accent-color, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx_EventTile {
|
|
||||||
margin: 0;
|
|
||||||
&[data-layout=bubble] {
|
|
||||||
margin-right: 40px;
|
|
||||||
}
|
|
||||||
&[data-layout=irc] {
|
|
||||||
> a {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.mx_EventTile_line {
|
|
||||||
max-width: 90%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx_AppearanceUserSettingsTab_Advanced {
|
.mx_AppearanceUserSettingsTab_Advanced {
|
||||||
color: $primary-fg-color;
|
color: $primary-fg-color;
|
||||||
|
|
||||||
|
|
|
@ -28,28 +28,32 @@ limitations under the License.
|
||||||
user-select: all;
|
user-select: all;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mx_HelpUserSettingsTab_accessToken {
|
.mx_HelpUserSettingsTab_copy {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
border: solid 1px $light-fg-color;
|
border: solid 1px $light-fg-color;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
}
|
width: max-content;
|
||||||
|
|
||||||
.mx_HelpUserSettingsTab_accessToken_copy {
|
.mx_HelpUserSettingsTab_copyButton {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
cursor: pointer;
|
|
||||||
margin-left: 20px;
|
|
||||||
display: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx_HelpUserSettingsTab_accessToken_copy > div {
|
|
||||||
mask-image: url($copy-button-url);
|
|
||||||
background-color: $message-action-bar-fg-color;
|
|
||||||
margin-left: 5px;
|
|
||||||
width: 20px;
|
width: 20px;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-left: 20px;
|
||||||
|
display: block;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: "";
|
||||||
|
|
||||||
|
mask-image: url($copy-button-url);
|
||||||
|
background-color: $message-action-bar-fg-color;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
display: block;
|
||||||
background-repeat: no-repeat;
|
background-repeat: no-repeat;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -22,4 +22,25 @@ limitations under the License.
|
||||||
.mx_SettingsTab_section {
|
.mx_SettingsTab_section {
|
||||||
margin-bottom: 30px;
|
margin-bottom: 30px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mx_PreferencesUserSettingsTab_CommunityMigrator {
|
||||||
|
margin-right: 200px;
|
||||||
|
|
||||||
|
> div {
|
||||||
|
font-weight: $font-semi-bold;
|
||||||
|
font-size: $font-15px;
|
||||||
|
line-height: $font-18px;
|
||||||
|
color: $primary-fg-color;
|
||||||
|
margin: 16px 0;
|
||||||
|
|
||||||
|
.mx_BaseAvatar {
|
||||||
|
margin-right: 12px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_AccessibleButton {
|
||||||
|
float: right;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -41,7 +41,6 @@ $spacePanelWidth: 71px;
|
||||||
> p {
|
> p {
|
||||||
font-size: $font-15px;
|
font-size: $font-15px;
|
||||||
color: $secondary-fg-color;
|
color: $secondary-fg-color;
|
||||||
margin: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.mx_SpaceFeedbackPrompt {
|
.mx_SpaceFeedbackPrompt {
|
||||||
|
@ -51,13 +50,6 @@ $spacePanelWidth: 71px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// XXX remove this when spaces leaves Beta
|
|
||||||
.mx_BetaCard_betaPill {
|
|
||||||
position: absolute;
|
|
||||||
top: 24px;
|
|
||||||
right: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx_SpaceCreateMenuType {
|
.mx_SpaceCreateMenuType {
|
||||||
@mixin SpacePillButton;
|
@mixin SpacePillButton;
|
||||||
}
|
}
|
||||||
|
@ -100,6 +92,11 @@ $spacePanelWidth: 71px;
|
||||||
width: min-content;
|
width: min-content;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mx_AccessibleButton_kind_link {
|
||||||
|
padding: 0;
|
||||||
|
font-size: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
.mx_AccessibleButton_disabled {
|
.mx_AccessibleButton_disabled {
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
149
res/css/views/toasts/_IncomingCallToast.scss
Normal file
149
res/css/views/toasts/_IncomingCallToast.scss
Normal file
|
@ -0,0 +1,149 @@
|
||||||
|
/*
|
||||||
|
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.mx_IncomingCallToast {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
pointer-events: initial; // restore pointer events so the user can accept/decline
|
||||||
|
|
||||||
|
.mx_IncomingCallToast_content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
margin-left: 8px;
|
||||||
|
|
||||||
|
.mx_CallEvent_caller {
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: $font-15px;
|
||||||
|
line-height: $font-18px;
|
||||||
|
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_CallEvent_type {
|
||||||
|
font-size: $font-12px;
|
||||||
|
line-height: $font-15px;
|
||||||
|
color: $tertiary-fg-color;
|
||||||
|
|
||||||
|
margin-top: 4px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.mx_CallEvent_type_icon {
|
||||||
|
height: 16px;
|
||||||
|
width: 16px;
|
||||||
|
margin-right: 6px;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
height: inherit;
|
||||||
|
width: inherit;
|
||||||
|
background-color: $tertiary-fg-color;
|
||||||
|
mask-repeat: no-repeat;
|
||||||
|
mask-size: contain;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.mx_IncomingCallToast_content_voice {
|
||||||
|
.mx_CallEvent_type .mx_CallEvent_type_icon::before,
|
||||||
|
.mx_IncomingCallToast_buttons .mx_IncomingCallToast_button_accept span::before {
|
||||||
|
mask-image: url('$(res)/img/element-icons/call/voice-call.svg');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.mx_IncomingCallToast_content_video {
|
||||||
|
.mx_CallEvent_type .mx_CallEvent_type_icon::before,
|
||||||
|
.mx_IncomingCallToast_buttons .mx_IncomingCallToast_button_accept span::before {
|
||||||
|
mask-image: url('$(res)/img/element-icons/call/video-call.svg');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_IncomingCallToast_buttons {
|
||||||
|
margin-top: 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
.mx_IncomingCallToast_button {
|
||||||
|
height: 24px;
|
||||||
|
padding: 0px 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
flex-grow: 1;
|
||||||
|
margin-right: 0;
|
||||||
|
font-size: $font-15px;
|
||||||
|
line-height: $font-24px;
|
||||||
|
|
||||||
|
span {
|
||||||
|
padding: 8px 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
display: inline-block;
|
||||||
|
background-color: $button-fg-color;
|
||||||
|
mask-position: center;
|
||||||
|
mask-repeat: no-repeat;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.mx_IncomingCallToast_button_accept span::before {
|
||||||
|
mask-size: 13px;
|
||||||
|
width: 13px;
|
||||||
|
height: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.mx_IncomingCallToast_button_decline span::before {
|
||||||
|
mask-image: url('$(res)/img/element-icons/call/hangup.svg');
|
||||||
|
mask-size: 16px;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_IncomingCallToast_iconButton {
|
||||||
|
display: flex;
|
||||||
|
height: 20px;
|
||||||
|
width: 20px;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
|
||||||
|
height: inherit;
|
||||||
|
width: inherit;
|
||||||
|
background-color: $tertiary-fg-color;
|
||||||
|
mask-repeat: no-repeat;
|
||||||
|
mask-size: contain;
|
||||||
|
mask-position: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_IncomingCallToast_silence::before {
|
||||||
|
mask-image: url('$(res)/img/voip/silence.svg');
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_IncomingCallToast_unSilence::before {
|
||||||
|
mask-image: url('$(res)/img/voip/un-silence.svg');
|
||||||
|
}
|
||||||
|
}
|
102
res/css/views/voip/CallView/_CallViewButtons.scss
Normal file
102
res/css/views/voip/CallView/_CallViewButtons.scss
Normal file
|
@ -0,0 +1,102 @@
|
||||||
|
/*
|
||||||
|
Copyright 2015, 2016 OpenMarket Ltd
|
||||||
|
Copyright 2020 - 2021 The Matrix.org Foundation C.I.C.
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.mx_CallViewButtons {
|
||||||
|
position: absolute;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
bottom: 5px;
|
||||||
|
opacity: 1;
|
||||||
|
transition: opacity 0.5s;
|
||||||
|
z-index: 200; // To be above _all_ feeds
|
||||||
|
|
||||||
|
&.mx_CallViewButtons_hidden {
|
||||||
|
opacity: 0.001; // opacity 0 can cause a re-layout
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_CallViewButtons_button {
|
||||||
|
cursor: pointer;
|
||||||
|
margin-left: 2px;
|
||||||
|
margin-right: 2px;
|
||||||
|
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
display: inline-block;
|
||||||
|
|
||||||
|
height: 48px;
|
||||||
|
width: 48px;
|
||||||
|
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-size: contain;
|
||||||
|
background-position: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
&.mx_CallViewButtons_dialpad::before {
|
||||||
|
background-image: url('$(res)/img/voip/dialpad.svg');
|
||||||
|
}
|
||||||
|
|
||||||
|
&.mx_CallViewButtons_button_micOn::before {
|
||||||
|
background-image: url('$(res)/img/voip/mic-on.svg');
|
||||||
|
}
|
||||||
|
|
||||||
|
&.mx_CallViewButtons_button_micOff::before {
|
||||||
|
background-image: url('$(res)/img/voip/mic-off.svg');
|
||||||
|
}
|
||||||
|
|
||||||
|
&.mx_CallViewButtons_button_vidOn::before {
|
||||||
|
background-image: url('$(res)/img/voip/vid-on.svg');
|
||||||
|
}
|
||||||
|
|
||||||
|
&.mx_CallViewButtons_button_vidOff::before {
|
||||||
|
background-image: url('$(res)/img/voip/vid-off.svg');
|
||||||
|
}
|
||||||
|
|
||||||
|
&.mx_CallViewButtons_button_screensharingOn::before {
|
||||||
|
background-image: url('$(res)/img/voip/screensharing-on.svg');
|
||||||
|
}
|
||||||
|
|
||||||
|
&.mx_CallViewButtons_button_screensharingOff::before {
|
||||||
|
background-image: url('$(res)/img/voip/screensharing-off.svg');
|
||||||
|
}
|
||||||
|
|
||||||
|
&.mx_CallViewButtons_button_sidebarOn::before {
|
||||||
|
background-image: url('$(res)/img/voip/sidebar-on.svg');
|
||||||
|
}
|
||||||
|
|
||||||
|
&.mx_CallViewButtons_button_sidebarOff::before {
|
||||||
|
background-image: url('$(res)/img/voip/sidebar-off.svg');
|
||||||
|
}
|
||||||
|
|
||||||
|
&.mx_CallViewButtons_button_hangup::before {
|
||||||
|
background-image: url('$(res)/img/voip/hangup.svg');
|
||||||
|
}
|
||||||
|
|
||||||
|
&.mx_CallViewButtons_button_more::before {
|
||||||
|
background-image: url('$(res)/img/voip/more.svg');
|
||||||
|
}
|
||||||
|
|
||||||
|
&.mx_CallViewButtons_button_invisible {
|
||||||
|
visibility: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -28,7 +28,6 @@ limitations under the License.
|
||||||
|
|
||||||
.mx_CallPreview {
|
.mx_CallPreview {
|
||||||
pointer-events: initial; // restore pointer events so the user can leave/interact
|
pointer-events: initial; // restore pointer events so the user can leave/interact
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
.mx_VideoFeed_remote.mx_VideoFeed_voice {
|
.mx_VideoFeed_remote.mx_VideoFeed_voice {
|
||||||
min-height: 150px;
|
min-height: 150px;
|
||||||
|
@ -43,84 +42,4 @@ limitations under the License.
|
||||||
.mx_AppTile_persistedWrapper div {
|
.mx_AppTile_persistedWrapper div {
|
||||||
min-width: 350px;
|
min-width: 350px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mx_IncomingCallBox {
|
|
||||||
min-width: 250px;
|
|
||||||
background-color: $voipcall-plinth-color;
|
|
||||||
padding: 8px;
|
|
||||||
box-shadow: 0px 14px 24px rgba(0, 0, 0, 0.08);
|
|
||||||
border-radius: 8px;
|
|
||||||
|
|
||||||
pointer-events: initial; // restore pointer events so the user can accept/decline
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
.mx_IncomingCallBox_CallerInfo {
|
|
||||||
display: flex;
|
|
||||||
direction: row;
|
|
||||||
|
|
||||||
img, .mx_BaseAvatar_initial {
|
|
||||||
margin: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
> div {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1, p {
|
|
||||||
margin: 0px;
|
|
||||||
padding: 0px;
|
|
||||||
font-size: $font-14px;
|
|
||||||
line-height: $font-16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx_IncomingCallBox_buttons {
|
|
||||||
padding: 8px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
|
|
||||||
> .mx_IncomingCallBox_spacer {
|
|
||||||
width: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
> * {
|
|
||||||
flex-shrink: 0;
|
|
||||||
flex-grow: 1;
|
|
||||||
margin-right: 0;
|
|
||||||
font-size: $font-15px;
|
|
||||||
line-height: $font-24px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx_IncomingCallBox_iconButton {
|
|
||||||
position: absolute;
|
|
||||||
right: 8px;
|
|
||||||
|
|
||||||
&::before {
|
|
||||||
content: '';
|
|
||||||
|
|
||||||
height: 20px;
|
|
||||||
width: 20px;
|
|
||||||
background-color: $icon-button-color;
|
|
||||||
mask-repeat: no-repeat;
|
|
||||||
mask-size: contain;
|
|
||||||
mask-position: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx_IncomingCallBox_silence::before {
|
|
||||||
mask-image: url('$(res)/img/voip/silence.svg');
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx_IncomingCallBox_unSilence::before {
|
|
||||||
mask-image: url('$(res)/img/voip/un-silence.svg');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -39,7 +39,7 @@ limitations under the License.
|
||||||
.mx_CallView_pip {
|
.mx_CallView_pip {
|
||||||
width: 320px;
|
width: 320px;
|
||||||
padding-bottom: 8px;
|
padding-bottom: 8px;
|
||||||
background-color: $voipcall-plinth-color;
|
background-color: $toast-bg-color;
|
||||||
box-shadow: 0px 4px 20px rgba(0, 0, 0, 0.20);
|
box-shadow: 0px 4px 20px rgba(0, 0, 0, 0.20);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
|
|
||||||
|
@ -47,11 +47,11 @@ limitations under the License.
|
||||||
height: 180px;
|
height: 180px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mx_CallView_callControls {
|
.mx_CallViewButtons {
|
||||||
bottom: 0px;
|
bottom: 0px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mx_CallView_callControls_button {
|
.mx_CallViewButtons_button {
|
||||||
&::before {
|
&::before {
|
||||||
width: 36px;
|
width: 36px;
|
||||||
height: 36px;
|
height: 36px;
|
||||||
|
@ -75,17 +75,21 @@ limitations under the License.
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|
||||||
&.mx_VideoFeed_voice {
|
&.mx_VideoFeed_voice {
|
||||||
// We don't want to collide with the call controls that have 52px of height
|
|
||||||
padding-bottom: 52px;
|
|
||||||
background-color: $inverted-bg-color;
|
background-color: $inverted-bg-color;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.mx_VideoFeed_video {
|
.mx_VideoFeed_video {
|
||||||
|
height: 100%;
|
||||||
background-color: #000;
|
background-color: #000;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mx_VideoFeed_mic {
|
||||||
|
left: 10px;
|
||||||
|
bottom: 10px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -195,133 +199,6 @@ limitations under the License.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.mx_CallView_header {
|
|
||||||
height: 44px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: left;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx_CallView_header_callType {
|
|
||||||
font-size: 1.2rem;
|
|
||||||
font-weight: bold;
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx_CallView_header_secondaryCallInfo {
|
|
||||||
&::before {
|
|
||||||
content: '·';
|
|
||||||
margin-left: 6px;
|
|
||||||
margin-right: 6px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx_CallView_header_controls {
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx_CallView_header_button {
|
|
||||||
display: inline-block;
|
|
||||||
vertical-align: middle;
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
&::before {
|
|
||||||
content: '';
|
|
||||||
display: inline-block;
|
|
||||||
height: 20px;
|
|
||||||
width: 20px;
|
|
||||||
vertical-align: middle;
|
|
||||||
background-color: $secondary-fg-color;
|
|
||||||
mask-repeat: no-repeat;
|
|
||||||
mask-size: contain;
|
|
||||||
mask-position: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx_CallView_header_button_fullscreen {
|
|
||||||
&::before {
|
|
||||||
mask-image: url('$(res)/img/element-icons/call/fullscreen.svg');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx_CallView_header_button_expand {
|
|
||||||
&::before {
|
|
||||||
mask-image: url('$(res)/img/element-icons/call/expand.svg');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx_CallView_header_callInfo {
|
|
||||||
margin-left: 12px;
|
|
||||||
margin-right: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx_CallView_header_roomName {
|
|
||||||
font-weight: bold;
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: initial;
|
|
||||||
height: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx_CallView_secondaryCall_roomName {
|
|
||||||
margin-left: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx_CallView_header_callTypeSmall {
|
|
||||||
font-size: 12px;
|
|
||||||
color: $secondary-fg-color;
|
|
||||||
line-height: initial;
|
|
||||||
height: 15px;
|
|
||||||
overflow: hidden;
|
|
||||||
white-space: nowrap;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
max-width: 240px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx_CallView_header_callTypeIcon {
|
|
||||||
display: inline-block;
|
|
||||||
margin-right: 6px;
|
|
||||||
height: 16px;
|
|
||||||
width: 16px;
|
|
||||||
vertical-align: middle;
|
|
||||||
|
|
||||||
&::before {
|
|
||||||
content: '';
|
|
||||||
display: inline-block;
|
|
||||||
vertical-align: top;
|
|
||||||
|
|
||||||
height: 16px;
|
|
||||||
width: 16px;
|
|
||||||
background-color: $secondary-fg-color;
|
|
||||||
mask-repeat: no-repeat;
|
|
||||||
mask-size: contain;
|
|
||||||
mask-position: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.mx_CallView_header_callTypeIcon_voice::before {
|
|
||||||
mask-image: url('$(res)/img/element-icons/call/voice-call.svg');
|
|
||||||
}
|
|
||||||
|
|
||||||
&.mx_CallView_header_callTypeIcon_video::before {
|
|
||||||
mask-image: url('$(res)/img/element-icons/call/video-call.svg');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx_CallView_callControls {
|
|
||||||
position: absolute;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
bottom: 5px;
|
|
||||||
opacity: 1;
|
|
||||||
transition: opacity 0.5s;
|
|
||||||
z-index: 200; // To be above _all_ feeds
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx_CallView_callControls_hidden {
|
|
||||||
opacity: 0.001; // opacity 0 can cause a re-layout
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx_CallView_presenting {
|
.mx_CallView_presenting {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
|
@ -341,94 +218,3 @@ limitations under the License.
|
||||||
opacity: 0.001; // opacity 0 can cause a re-layout
|
opacity: 0.001; // opacity 0 can cause a re-layout
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mx_CallView_callControls_button {
|
|
||||||
cursor: pointer;
|
|
||||||
margin-left: 2px;
|
|
||||||
margin-right: 2px;
|
|
||||||
|
|
||||||
|
|
||||||
&::before {
|
|
||||||
content: '';
|
|
||||||
display: inline-block;
|
|
||||||
|
|
||||||
height: 48px;
|
|
||||||
width: 48px;
|
|
||||||
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
background-size: contain;
|
|
||||||
background-position: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx_CallView_callControls_dialpad {
|
|
||||||
&::before {
|
|
||||||
background-image: url('$(res)/img/voip/dialpad.svg');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx_CallView_callControls_button_micOn {
|
|
||||||
&::before {
|
|
||||||
background-image: url('$(res)/img/voip/mic-on.svg');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx_CallView_callControls_button_micOff {
|
|
||||||
&::before {
|
|
||||||
background-image: url('$(res)/img/voip/mic-off.svg');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx_CallView_callControls_button_vidOn {
|
|
||||||
&::before {
|
|
||||||
background-image: url('$(res)/img/voip/vid-on.svg');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx_CallView_callControls_button_vidOff {
|
|
||||||
&::before {
|
|
||||||
background-image: url('$(res)/img/voip/vid-off.svg');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx_CallView_callControls_button_screensharingOn {
|
|
||||||
&::before {
|
|
||||||
background-image: url('$(res)/img/voip/screensharing-on.svg');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx_CallView_callControls_button_screensharingOff {
|
|
||||||
&::before {
|
|
||||||
background-image: url('$(res)/img/voip/screensharing-off.svg');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx_CallView_callControls_button_sidebarOn {
|
|
||||||
&::before {
|
|
||||||
background-image: url('$(res)/img/voip/sidebar-on.svg');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx_CallView_callControls_button_sidebarOff {
|
|
||||||
&::before {
|
|
||||||
background-image: url('$(res)/img/voip/sidebar-off.svg');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx_CallView_callControls_button_hangup {
|
|
||||||
&::before {
|
|
||||||
background-image: url('$(res)/img/voip/hangup.svg');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx_CallView_callControls_button_more {
|
|
||||||
&::before {
|
|
||||||
background-image: url('$(res)/img/voip/more.svg');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx_CallView_callControls_button_invisible {
|
|
||||||
visibility: hidden;
|
|
||||||
pointer-events: none;
|
|
||||||
position: absolute;
|
|
||||||
}
|
|
||||||
|
|
129
res/css/views/voip/_CallViewHeader.scss
Normal file
129
res/css/views/voip/_CallViewHeader.scss
Normal file
|
@ -0,0 +1,129 @@
|
||||||
|
/*
|
||||||
|
Copyright 2021 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.mx_CallViewHeader {
|
||||||
|
height: 44px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: left;
|
||||||
|
flex-shrink: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_CallViewHeader_callType {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: bold;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_CallViewHeader_secondaryCallInfo {
|
||||||
|
&::before {
|
||||||
|
content: '·';
|
||||||
|
margin-left: 6px;
|
||||||
|
margin-right: 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_CallViewHeader_controls {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_CallViewHeader_button {
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: middle;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
display: inline-block;
|
||||||
|
height: 20px;
|
||||||
|
width: 20px;
|
||||||
|
vertical-align: middle;
|
||||||
|
background-color: $secondary-fg-color;
|
||||||
|
mask-repeat: no-repeat;
|
||||||
|
mask-size: contain;
|
||||||
|
mask-position: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_CallViewHeader_button_fullscreen {
|
||||||
|
&::before {
|
||||||
|
mask-image: url('$(res)/img/element-icons/call/fullscreen.svg');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_CallViewHeader_button_expand {
|
||||||
|
&::before {
|
||||||
|
mask-image: url('$(res)/img/element-icons/call/expand.svg');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_CallViewHeader_callInfo {
|
||||||
|
margin-left: 12px;
|
||||||
|
margin-right: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_CallViewHeader_roomName {
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: initial;
|
||||||
|
height: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_CallView_secondaryCall_roomName {
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_CallViewHeader_callTypeSmall {
|
||||||
|
font-size: 12px;
|
||||||
|
color: $secondary-fg-color;
|
||||||
|
line-height: initial;
|
||||||
|
height: 15px;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 240px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_CallViewHeader_callTypeIcon {
|
||||||
|
display: inline-block;
|
||||||
|
margin-right: 6px;
|
||||||
|
height: 16px;
|
||||||
|
width: 16px;
|
||||||
|
vertical-align: middle;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: top;
|
||||||
|
|
||||||
|
height: 16px;
|
||||||
|
width: 16px;
|
||||||
|
background-color: $secondary-fg-color;
|
||||||
|
mask-repeat: no-repeat;
|
||||||
|
mask-size: contain;
|
||||||
|
mask-position: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.mx_CallViewHeader_callTypeIcon_voice::before {
|
||||||
|
mask-image: url('$(res)/img/element-icons/call/voice-call.svg');
|
||||||
|
}
|
||||||
|
|
||||||
|
&.mx_CallViewHeader_callTypeIcon_video::before {
|
||||||
|
mask-image: url('$(res)/img/element-icons/call/video-call.svg');
|
||||||
|
}
|
||||||
|
}
|
|
@ -35,11 +35,20 @@ limitations under the License.
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
&.mx_VideoFeed_voice {
|
&.mx_VideoFeed_voice {
|
||||||
|
border-radius: 4px;
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
aspect-ratio: 16 / 9;
|
.mx_VideoFeed_video {
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_VideoFeed_mic {
|
||||||
|
left: 6px;
|
||||||
|
bottom: 6px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -69,7 +69,6 @@ limitations under the License.
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
max-width: 185px;
|
max-width: 185px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
direction: rtl;
|
|
||||||
padding: 8px 0px;
|
padding: 8px 0px;
|
||||||
background-color: rgb(0, 0, 0, 0);
|
background-color: rgb(0, 0, 0, 0);
|
||||||
}
|
}
|
||||||
|
|
|
@ -15,18 +15,53 @@ limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
.mx_VideoFeed {
|
.mx_VideoFeed {
|
||||||
border-radius: 4px;
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
&.mx_VideoFeed_voice {
|
&.mx_VideoFeed_voice {
|
||||||
background-color: $inverted-bg-color;
|
background-color: $inverted-bg-color;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.mx_VideoFeed_video {
|
.mx_VideoFeed_video {
|
||||||
|
width: 100%;
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
|
|
||||||
|
&.mx_VideoFeed_video_mirror {
|
||||||
|
transform: scale(-1, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_VideoFeed_mic {
|
||||||
|
position: absolute;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
|
||||||
|
background-color: rgba(0, 0, 0, 0.5); // Same on both themes
|
||||||
|
border-radius: 100%;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
position: absolute;
|
||||||
|
content: "";
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
mask-repeat: no-repeat;
|
||||||
|
mask-size: contain;
|
||||||
|
mask-position: center;
|
||||||
|
background-color: white; // Same on both themes
|
||||||
|
border-radius: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.mx_VideoFeed_mic_muted::before {
|
||||||
|
mask-image: url('$(res)/img/voip/mic-muted.svg');
|
||||||
|
}
|
||||||
|
|
||||||
|
&.mx_VideoFeed_mic_unmuted::before {
|
||||||
|
mask-image: url('$(res)/img/voip/mic-unmuted.svg');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.mx_VideoFeed_mirror {
|
|
||||||
transform: scale(-1, 1);
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,7 +1,3 @@
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<path d="M18.5151 20.0831L15.6941 17.2621L17.2621 15.6941L20.0831 18.5151C21.5741 20.0061 22.1529 21.7793 21.9661 21.9661C21.7793 22.1529 20.0061 21.5741 18.5151 20.0831Z" fill="#737D8C"/>
|
<path d="m11.068 2c-0.32021 4.772e-4 -0.66852 0.17244-0.96484 0.46875-2.5464 2.5435-5.0905 5.0892-7.6348 7.6348-0.79016 0.7902-0.69302 1.9462 1.1641 1.9707 1.855 0.02447 3.4407-0.56671 3.8281-0.69141l2.4355 3.1445c-0.83503 1.9462-0.86902 4.062-0.058594 5.7949 0.47213 1.0095 1.79 1.0049 2.5781 0.2168l3.2773-3.2773 2.8223 2.8223c1.491 1.491 3.2644 2.0696 3.4512 1.8828s-0.39181-1.9602-1.8828-3.4512l-2.8223-2.8223 3.2773-3.2773c0.788-0.788 0.79075-2.106-0.21875-2.5781-1.733-0.81044-3.8468-0.77643-5.793 0.058594l-3.1445-2.4355c0.1247-0.38742 0.71588-1.9731 0.69141-3.8281-0.015311-1.1607-0.47217-1.6336-1.0059-1.6328z" fill="#737d8c"/>
|
||||||
<path d="M7.46196 11.3821C7.07677 11.5059 5.49073 12.0989 3.63366 12.0744C1.77658 12.0499 1.67795 10.8941 2.46811 10.1039L6.28598 6.28602L9.42196 9.42203L7.46196 11.3821Z" fill="#737D8C"/>
|
|
||||||
<path d="M11.3821 7.46202C11.5059 7.07682 12.0989 5.49077 12.0744 3.63368C12.0499 1.77658 10.8941 1.67795 10.1039 2.46812L6.28598 6.28602L9.42196 9.42203L11.3821 7.46202Z" fill="#737D8C"/>
|
|
||||||
<path d="M7.40596 11.438L11.4379 7.40602L14.9099 10.206L10.2059 14.9101L7.40596 11.438Z" fill="#737D8C"/>
|
|
||||||
<path d="M11.774 11.774C9.31114 14.2369 8.61779 17.7115 9.83827 20.3213C10.3104 21.3308 11.6288 21.3273 12.4169 20.5392L20.5391 12.4169C21.3271 11.6289 21.3307 10.3104 20.3212 9.83829C17.7114 8.61779 14.2369 9.31115 11.774 11.774Z" fill="#737D8C"/>
|
|
||||||
</svg>
|
</svg>
|
||||||
|
|
Before Width: | Height: | Size: 1 KiB After Width: | Height: | Size: 744 B |
5
res/img/voip/mic-muted.svg
Normal file
5
res/img/voip/mic-muted.svg
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M1.9206 1.0544C1.68141 0.815201 1.29359 0.815201 1.0544 1.0544C0.815201 1.29359 0.815201 1.68141 1.0544 1.9206L4.55 5.41621V7C4.55 8.3531 5.6469 9.45 7 9.45C7.45436 9.45 7.87983 9.32632 8.24458 9.11079L9.12938 9.99558C8.52863 10.4234 7.7937 10.675 7 10.675C4.97035 10.675 3.325 9.02965 3.325 7C3.325 6.66173 3.05077 6.3875 2.7125 6.3875C2.37423 6.3875 2.1 6.66173 2.1 7C2.1 9.49877 3.97038 11.5607 6.3875 11.8621V12.5125C6.3875 12.8508 6.66173 13.125 7 13.125C7.33827 13.125 7.6125 12.8508 7.6125 12.5125V11.8621C8.50718 11.7505 9.32696 11.3978 10.0047 10.8709L12.0794 12.9456C12.3186 13.1848 12.7064 13.1848 12.9456 12.9456C13.1848 12.7064 13.1848 12.3186 12.9456 12.0794L1.9206 1.0544Z" fill="white"/>
|
||||||
|
<path d="M10.5474 7.96338L11.5073 8.92525C11.7601 8.33424 11.9 7.68346 11.9 7C11.9 6.66173 11.6258 6.3875 11.2875 6.3875C10.9492 6.3875 10.675 6.66173 10.675 7C10.675 7.33336 10.6306 7.65634 10.5474 7.96338Z" fill="white"/>
|
||||||
|
<path d="M4.81385 2.21784L9.45 6.86366V3.325C9.45 1.9719 8.3531 0.875 7 0.875C6.04532 0.875 5.21818 1.42104 4.81385 2.21784Z" fill="white"/>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 1.2 KiB |
4
res/img/voip/mic-unmuted.svg
Normal file
4
res/img/voip/mic-unmuted.svg
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M4.4645 3.29384C4.4645 1.95795 5.59973 0.875 7.0001 0.875C8.40048 0.875 9.53571 1.95795 9.53571 3.29384V6.91127C9.53571 8.24716 8.40048 9.33011 7.0001 9.33011C5.59973 9.33011 4.4645 8.24716 4.4645 6.91127V3.29384Z" fill="white"/>
|
||||||
|
<path d="M2.56269 6.1391C3.01153 6.1391 3.37539 6.4862 3.37539 6.91437C3.37539 8.81701 4.99198 10.3617 6.99032 10.3666C6.99359 10.3666 6.99686 10.3666 7.00014 10.3666C7.0034 10.3666 7.00665 10.3666 7.0099 10.3666C9.00814 10.3616 10.6246 8.81694 10.6246 6.91437C10.6246 6.4862 10.9885 6.1391 11.4373 6.1391C11.8861 6.1391 12.25 6.4862 12.25 6.91437C12.25 9.41469 10.3257 11.4854 7.81283 11.8576V12.3497C7.81283 12.7779 7.44898 13.125 7.00014 13.125C6.5513 13.125 6.18744 12.7779 6.18744 12.3497V11.8576C3.67448 11.4855 1.75 9.41478 1.75 6.91437C1.75 6.4862 2.11386 6.1391 2.56269 6.1391Z" fill="white"/>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 945 B |
|
@ -1,15 +1,35 @@
|
||||||
|
// Colors from Figma Compound https://www.figma.com/file/X4XTH9iS2KGJ2wFKDqkyed/Compound?node-id=559%3A741
|
||||||
|
$accent: #0DBD8B;
|
||||||
|
$alert: #FF5B55;
|
||||||
|
$links: #0086e6;
|
||||||
|
$primary-content: #ffffff;
|
||||||
|
$secondary-content: #A9B2BC;
|
||||||
|
$tertiary-content: #8E99A4;
|
||||||
|
$quaternary-content: #6F7882;
|
||||||
|
$quinary-content: #394049;
|
||||||
|
$system-dark: #21262C;
|
||||||
|
$background: #15191E;
|
||||||
|
$panels: rgba($system-dark, 0.9);
|
||||||
|
$panel-base: #8D97A5; // This color is not intended for use in the app
|
||||||
|
$panel-selected: rgba($panel-base, 0.3);
|
||||||
|
$panel-hover: rgba($panel-base, 0.1);
|
||||||
|
$panel-actions: rgba($panel-base, 0.2);
|
||||||
|
$space-nav: rgba($panel-base, 0.1);
|
||||||
|
|
||||||
|
// TODO: Move userId colors here
|
||||||
|
|
||||||
// unified palette
|
// unified palette
|
||||||
// try to use these colors when possible
|
// try to use these colors when possible
|
||||||
$bg-color: #15191E;
|
$bg-color: $background;
|
||||||
$base-color: $bg-color;
|
$base-color: $bg-color;
|
||||||
$base-text-color: #ffffff;
|
$base-text-color: $primary-content;
|
||||||
$header-panel-bg-color: #20252B;
|
$header-panel-bg-color: #20252B;
|
||||||
$header-panel-border-color: #000000;
|
$header-panel-border-color: #000000;
|
||||||
$header-panel-text-primary-color: #B9BEC6;
|
$header-panel-text-primary-color: #B9BEC6;
|
||||||
$header-panel-text-secondary-color: #c8c8cd;
|
$header-panel-text-secondary-color: #c8c8cd;
|
||||||
$text-primary-color: #ffffff;
|
$text-primary-color: $primary-content;
|
||||||
$text-secondary-color: #B9BEC6;
|
$text-secondary-color: #B9BEC6;
|
||||||
$quaternary-fg-color: #6F7882;
|
$quaternary-fg-color: $quaternary-content;
|
||||||
$search-bg-color: #181b21;
|
$search-bg-color: #181b21;
|
||||||
$search-placeholder-color: #61708b;
|
$search-placeholder-color: #61708b;
|
||||||
$room-highlight-color: #343a46;
|
$room-highlight-color: #343a46;
|
||||||
|
@ -20,8 +40,8 @@ $primary-bg-color: $bg-color;
|
||||||
$muted-fg-color: $header-panel-text-primary-color;
|
$muted-fg-color: $header-panel-text-primary-color;
|
||||||
|
|
||||||
// additional text colors
|
// additional text colors
|
||||||
$secondary-fg-color: #A9B2BC;
|
$secondary-fg-color: $secondary-content;
|
||||||
$tertiary-fg-color: #8E99A4;
|
$tertiary-fg-color: $tertiary-content;
|
||||||
|
|
||||||
// used for dialog box text
|
// used for dialog box text
|
||||||
$light-fg-color: $header-panel-text-secondary-color;
|
$light-fg-color: $header-panel-text-secondary-color;
|
||||||
|
@ -47,7 +67,7 @@ $inverted-bg-color: $base-color;
|
||||||
$selected-color: $room-highlight-color;
|
$selected-color: $room-highlight-color;
|
||||||
|
|
||||||
// selected for hoverover & selected event tiles
|
// selected for hoverover & selected event tiles
|
||||||
$event-selected-color: #21262c;
|
$event-selected-color: $system-dark;
|
||||||
|
|
||||||
// used for the hairline dividers in RoomView
|
// used for the hairline dividers in RoomView
|
||||||
$primary-hairline-color: transparent;
|
$primary-hairline-color: transparent;
|
||||||
|
@ -91,7 +111,7 @@ $lightbox-background-bg-color: #000;
|
||||||
$lightbox-background-bg-opacity: 0.85;
|
$lightbox-background-bg-opacity: 0.85;
|
||||||
|
|
||||||
$settings-grey-fg-color: #a2a2a2;
|
$settings-grey-fg-color: #a2a2a2;
|
||||||
$settings-profile-placeholder-bg-color: #21262c;
|
$settings-profile-placeholder-bg-color: $system-dark;
|
||||||
$settings-profile-overlay-placeholder-fg-color: #454545;
|
$settings-profile-overlay-placeholder-fg-color: #454545;
|
||||||
$settings-profile-button-bg-color: #e7e7e7;
|
$settings-profile-button-bg-color: #e7e7e7;
|
||||||
$settings-profile-button-fg-color: $settings-profile-overlay-placeholder-fg-color;
|
$settings-profile-button-fg-color: $settings-profile-overlay-placeholder-fg-color;
|
||||||
|
@ -105,20 +125,19 @@ $roomheader-addroom-fg-color: $text-primary-color;
|
||||||
$groupFilterPanel-button-color: $header-panel-text-primary-color;
|
$groupFilterPanel-button-color: $header-panel-text-primary-color;
|
||||||
$groupheader-button-color: $header-panel-text-primary-color;
|
$groupheader-button-color: $header-panel-text-primary-color;
|
||||||
$rightpanel-button-color: $header-panel-text-primary-color;
|
$rightpanel-button-color: $header-panel-text-primary-color;
|
||||||
$icon-button-color: #8E99A4;
|
$icon-button-color: $tertiary-content;
|
||||||
$roomtopic-color: $text-secondary-color;
|
$roomtopic-color: $text-secondary-color;
|
||||||
$eventtile-meta-color: $roomtopic-color;
|
$eventtile-meta-color: $roomtopic-color;
|
||||||
|
|
||||||
$header-divider-color: $header-panel-text-primary-color;
|
$header-divider-color: $header-panel-text-primary-color;
|
||||||
$composer-e2e-icon-color: $header-panel-text-primary-color;
|
$composer-e2e-icon-color: $header-panel-text-primary-color;
|
||||||
|
|
||||||
// this probably shouldn't have it's own colour
|
$toast-bg-color: $quinary-content;
|
||||||
$voipcall-plinth-color: #394049;
|
|
||||||
|
|
||||||
// ********************
|
// ********************
|
||||||
|
|
||||||
$theme-button-bg-color: #e3e8f0;
|
$theme-button-bg-color: #e3e8f0;
|
||||||
$dialpad-button-bg-color: #394049;
|
$dialpad-button-bg-color: $quinary-content;
|
||||||
|
|
||||||
$roomlist-button-bg-color: rgba(141, 151, 165, 0.2); // Buttons include the filter box, explore button, and sublist buttons
|
$roomlist-button-bg-color: rgba(141, 151, 165, 0.2); // Buttons include the filter box, explore button, and sublist buttons
|
||||||
$roomlist-filter-active-bg-color: $bg-color;
|
$roomlist-filter-active-bg-color: $bg-color;
|
||||||
|
@ -161,12 +180,12 @@ $tab-label-icon-bg-color: $text-primary-color;
|
||||||
$tab-label-active-icon-bg-color: $text-primary-color;
|
$tab-label-active-icon-bg-color: $text-primary-color;
|
||||||
|
|
||||||
// Buttons
|
// Buttons
|
||||||
$button-primary-fg-color: #ffffff;
|
$button-primary-fg-color: $primary-content;
|
||||||
$button-primary-bg-color: $accent-color;
|
$button-primary-bg-color: $accent-color;
|
||||||
$button-secondary-bg-color: transparent;
|
$button-secondary-bg-color: transparent;
|
||||||
$button-danger-fg-color: #ffffff;
|
$button-danger-fg-color: $primary-content;
|
||||||
$button-danger-bg-color: $notice-primary-color;
|
$button-danger-bg-color: $notice-primary-color;
|
||||||
$button-danger-disabled-fg-color: #ffffff;
|
$button-danger-disabled-fg-color: $primary-content;
|
||||||
$button-danger-disabled-bg-color: #f5b6bb; // TODO: Verify color
|
$button-danger-disabled-bg-color: #f5b6bb; // TODO: Verify color
|
||||||
$button-link-fg-color: $accent-color;
|
$button-link-fg-color: $accent-color;
|
||||||
$button-link-bg-color: transparent;
|
$button-link-bg-color: transparent;
|
||||||
|
@ -175,7 +194,7 @@ $button-link-bg-color: transparent;
|
||||||
$togglesw-off-color: $room-highlight-color;
|
$togglesw-off-color: $room-highlight-color;
|
||||||
|
|
||||||
$progressbar-fg-color: $accent-color;
|
$progressbar-fg-color: $accent-color;
|
||||||
$progressbar-bg-color: #21262c;
|
$progressbar-bg-color: $system-dark;
|
||||||
|
|
||||||
$visual-bell-bg-color: #800;
|
$visual-bell-bg-color: #800;
|
||||||
|
|
||||||
|
@ -198,19 +217,19 @@ $reaction-row-button-selected-border-color: $accent-color;
|
||||||
$kbd-border-color: #000000;
|
$kbd-border-color: #000000;
|
||||||
|
|
||||||
$tooltip-timeline-bg-color: $groupFilterPanel-bg-color;
|
$tooltip-timeline-bg-color: $groupFilterPanel-bg-color;
|
||||||
$tooltip-timeline-fg-color: #ffffff;
|
$tooltip-timeline-fg-color: $primary-content;
|
||||||
|
|
||||||
$interactive-tooltip-bg-color: $base-color;
|
$interactive-tooltip-bg-color: $base-color;
|
||||||
$interactive-tooltip-fg-color: #ffffff;
|
$interactive-tooltip-fg-color: $primary-content;
|
||||||
|
|
||||||
$breadcrumb-placeholder-bg-color: #272c35;
|
$breadcrumb-placeholder-bg-color: #272c35;
|
||||||
|
|
||||||
$user-tile-hover-bg-color: $header-panel-bg-color;
|
$user-tile-hover-bg-color: $header-panel-bg-color;
|
||||||
|
|
||||||
$message-body-panel-fg-color: $secondary-fg-color;
|
$message-body-panel-fg-color: $secondary-fg-color;
|
||||||
$message-body-panel-bg-color: #394049; // "Dark Tile"
|
$message-body-panel-bg-color: $quinary-content;
|
||||||
$message-body-panel-icon-fg-color: $secondary-fg-color;
|
$message-body-panel-icon-fg-color: $secondary-fg-color;
|
||||||
$message-body-panel-icon-bg-color: #21262C; // "System Dark"
|
$message-body-panel-icon-bg-color: $system-dark; // "System Dark"
|
||||||
|
|
||||||
$voice-record-stop-border-color: $quaternary-fg-color;
|
$voice-record-stop-border-color: $quaternary-fg-color;
|
||||||
$voice-record-waveform-incomplete-fg-color: $quaternary-fg-color;
|
$voice-record-waveform-incomplete-fg-color: $quaternary-fg-color;
|
||||||
|
@ -228,9 +247,9 @@ $groupFilterPanel-background-blur-amount: 30px;
|
||||||
$composer-shadow-color: rgba(0, 0, 0, 0.28);
|
$composer-shadow-color: rgba(0, 0, 0, 0.28);
|
||||||
|
|
||||||
// Bubble tiles
|
// Bubble tiles
|
||||||
$eventbubble-self-bg: #143A34;
|
$eventbubble-self-bg: #14322E;
|
||||||
$eventbubble-others-bg: #394049;
|
$eventbubble-others-bg: $event-selected-color;
|
||||||
$eventbubble-bg-hover: #433C23;
|
$eventbubble-bg-hover: #1C2026;
|
||||||
$eventbubble-avatar-outline: $bg-color;
|
$eventbubble-avatar-outline: $bg-color;
|
||||||
$eventbubble-reply-color: #C1C6CD;
|
$eventbubble-reply-color: #C1C6CD;
|
||||||
|
|
||||||
|
|
|
@ -111,8 +111,8 @@ $eventtile-meta-color: $roomtopic-color;
|
||||||
$header-divider-color: $header-panel-text-primary-color;
|
$header-divider-color: $header-panel-text-primary-color;
|
||||||
$composer-e2e-icon-color: $header-panel-text-primary-color;
|
$composer-e2e-icon-color: $header-panel-text-primary-color;
|
||||||
|
|
||||||
// this probably shouldn't have it's own colour
|
$quinary-content-color: #394049;
|
||||||
$voipcall-plinth-color: #394049;
|
$toast-bg-color: $quinary-content-color;
|
||||||
|
|
||||||
// ********************
|
// ********************
|
||||||
|
|
||||||
|
@ -222,6 +222,13 @@ $appearance-tab-border-color: $room-highlight-color;
|
||||||
|
|
||||||
$composer-shadow-color: tranparent;
|
$composer-shadow-color: tranparent;
|
||||||
|
|
||||||
|
// Bubble tiles
|
||||||
|
$eventbubble-self-bg: #14322E;
|
||||||
|
$eventbubble-others-bg: $event-selected-color;
|
||||||
|
$eventbubble-bg-hover: #1C2026;
|
||||||
|
$eventbubble-avatar-outline: $bg-color;
|
||||||
|
$eventbubble-reply-color: #C1C6CD;
|
||||||
|
|
||||||
// ***** Mixins! *****
|
// ***** Mixins! *****
|
||||||
|
|
||||||
@define-mixin mx_DialogButton {
|
@define-mixin mx_DialogButton {
|
||||||
|
|
|
@ -8,9 +8,12 @@
|
||||||
/* Noto Color Emoji contains digits, in fixed-width, therefore causing
|
/* Noto Color Emoji contains digits, in fixed-width, therefore causing
|
||||||
digits in flowed text to stand out.
|
digits in flowed text to stand out.
|
||||||
TODO: Consider putting all emoji fonts to the end rather than the front. */
|
TODO: Consider putting all emoji fonts to the end rather than the front. */
|
||||||
$font-family: Nunito, Twemoji, 'Apple Color Emoji', 'Segoe UI Emoji', Arial, Helvetica, Sans-Serif, 'Noto Color Emoji';
|
$font-family: 'Nunito', 'Twemoji', 'Apple Color Emoji', 'Segoe UI Emoji', 'Arial', 'Helvetica', sans-serif, 'Noto Color Emoji';
|
||||||
|
|
||||||
$monospace-font-family: Inconsolata, Twemoji, 'Apple Color Emoji', 'Segoe UI Emoji', Courier, monospace, 'Noto Color Emoji';
|
$monospace-font-family: 'Inconsolata', 'Twemoji', 'Apple Color Emoji', 'Segoe UI Emoji', 'Courier', monospace, 'Noto Color Emoji';
|
||||||
|
|
||||||
|
// Colors from Figma Compound https://www.figma.com/file/X4XTH9iS2KGJ2wFKDqkyed/Compound?node-id=557%3A0
|
||||||
|
$system-light: #F4F6FA;
|
||||||
|
|
||||||
// unified palette
|
// unified palette
|
||||||
// try to use these colors when possible
|
// try to use these colors when possible
|
||||||
|
@ -178,8 +181,8 @@ $eventtile-meta-color: $roomtopic-color;
|
||||||
$composer-e2e-icon-color: #91a1c0;
|
$composer-e2e-icon-color: #91a1c0;
|
||||||
$header-divider-color: #91a1c0;
|
$header-divider-color: #91a1c0;
|
||||||
|
|
||||||
// this probably shouldn't have it's own colour
|
$toast-bg-color: $system-light;
|
||||||
$voipcall-plinth-color: #F4F6FA;
|
$voipcall-plinth-color: $system-light;
|
||||||
|
|
||||||
// ********************
|
// ********************
|
||||||
|
|
||||||
|
@ -331,7 +334,7 @@ $user-tile-hover-bg-color: $header-panel-bg-color;
|
||||||
$message-body-panel-fg-color: $secondary-fg-color;
|
$message-body-panel-fg-color: $secondary-fg-color;
|
||||||
$message-body-panel-bg-color: #E3E8F0;
|
$message-body-panel-bg-color: #E3E8F0;
|
||||||
$message-body-panel-icon-fg-color: $secondary-fg-color;
|
$message-body-panel-icon-fg-color: $secondary-fg-color;
|
||||||
$message-body-panel-icon-bg-color: #F4F6FA;
|
$message-body-panel-icon-bg-color: $system-light;
|
||||||
|
|
||||||
// See non-legacy _light for variable information
|
// See non-legacy _light for variable information
|
||||||
$voice-record-stop-symbol-color: #ff4b55;
|
$voice-record-stop-symbol-color: #ff4b55;
|
||||||
|
@ -348,9 +351,9 @@ $appearance-tab-border-color: $input-darker-bg-color;
|
||||||
$composer-shadow-color: tranparent;
|
$composer-shadow-color: tranparent;
|
||||||
|
|
||||||
// Bubble tiles
|
// Bubble tiles
|
||||||
$eventbubble-self-bg: #F8FDFC;
|
$eventbubble-self-bg: #F0FBF8;
|
||||||
$eventbubble-others-bg: #F7F8F9;
|
$eventbubble-others-bg: $system-light;
|
||||||
$eventbubble-bg-hover: rgb(242, 242, 242);
|
$eventbubble-bg-hover: #FAFBFD;
|
||||||
$eventbubble-avatar-outline: #fff;
|
$eventbubble-avatar-outline: #fff;
|
||||||
$eventbubble-reply-color: #C1C6CD;
|
$eventbubble-reply-color: #C1C6CD;
|
||||||
|
|
||||||
|
@ -390,7 +393,7 @@ $eventbubble-reply-color: #C1C6CD;
|
||||||
@define-mixin mx_DialogButton_secondary {
|
@define-mixin mx_DialogButton_secondary {
|
||||||
// flip colours for the secondary ones
|
// flip colours for the secondary ones
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
border: 1px solid $accent-color ! important;
|
border: 1px solid $accent-color !important;
|
||||||
color: $accent-color;
|
color: $accent-color;
|
||||||
background-color: $button-secondary-bg-color;
|
background-color: $button-secondary-bg-color;
|
||||||
}
|
}
|
||||||
|
|
|
@ -140,3 +140,10 @@ $event-highlight-bg-color: var(--timeline-highlights-color);
|
||||||
//
|
//
|
||||||
// redirect some variables away from their hardcoded values in the light theme
|
// redirect some variables away from their hardcoded values in the light theme
|
||||||
$settings-grey-fg-color: $primary-fg-color;
|
$settings-grey-fg-color: $primary-fg-color;
|
||||||
|
|
||||||
|
// --eventbubble colors
|
||||||
|
$eventbubble-self-bg: var(--eventbubble-self-bg, $eventbubble-self-bg);
|
||||||
|
$eventbubble-others-bg: var(--eventbubble-others-bg, $eventbubble-others-bg);
|
||||||
|
$eventbubble-bg-hover: var(--eventbubble-bg-hover, $eventbubble-bg-hover);
|
||||||
|
$eventbubble-avatar-outline: var(--eventbubble-avatar-outline, $eventbubble-avatar-outline);
|
||||||
|
$eventbubble-reply-color: var(--eventbubble-reply-color, $eventbubble-reply-color);
|
||||||
|
|
|
@ -8,24 +8,43 @@
|
||||||
/* Noto Color Emoji contains digits, in fixed-width, therefore causing
|
/* Noto Color Emoji contains digits, in fixed-width, therefore causing
|
||||||
digits in flowed text to stand out.
|
digits in flowed text to stand out.
|
||||||
TODO: Consider putting all emoji fonts to the end rather than the front. */
|
TODO: Consider putting all emoji fonts to the end rather than the front. */
|
||||||
$font-family: Inter, Twemoji, 'Apple Color Emoji', 'Segoe UI Emoji', Arial, Helvetica, Sans-Serif, 'Noto Color Emoji';
|
$font-family: 'Inter', 'Twemoji', 'Apple Color Emoji', 'Segoe UI Emoji', 'Arial', 'Helvetica', sans-serif, 'Noto Color Emoji';
|
||||||
|
|
||||||
$monospace-font-family: Inconsolata, Twemoji, 'Apple Color Emoji', 'Segoe UI Emoji', Courier, monospace, 'Noto Color Emoji';
|
$monospace-font-family: 'Inconsolata', 'Twemoji', 'Apple Color Emoji', 'Segoe UI Emoji', 'Courier', monospace, 'Noto Color Emoji';
|
||||||
|
|
||||||
|
// Colors from Figma Compound https://www.figma.com/file/X4XTH9iS2KGJ2wFKDqkyed/Compound?node-id=559%3A120
|
||||||
|
$accent: #0DBD8B;
|
||||||
|
$alert: #FF5B55;
|
||||||
|
$links: #0086e6;
|
||||||
|
$primary-content: #17191C;
|
||||||
|
$secondary-content: #737D8C;
|
||||||
|
$tertiary-content: #8D97A5;
|
||||||
|
$quaternary-content: #c1c6cd;
|
||||||
|
$quinary-content: #E3E8F0;
|
||||||
|
$system-light: #F4F6FA;
|
||||||
|
$background: #ffffff;
|
||||||
|
$panels: rgba($system-light, 0.9);
|
||||||
|
$panel-selected: rgba($tertiary-content, 0.3);
|
||||||
|
$panel-hover: rgba($tertiary-content, 0.1);
|
||||||
|
$panel-actions: rgba($tertiary-content, 0.2);
|
||||||
|
$space-nav: rgba($tertiary-content, 0.15);
|
||||||
|
|
||||||
|
// TODO: Move userId colors here
|
||||||
|
|
||||||
// unified palette
|
// unified palette
|
||||||
// try to use these colors when possible
|
// try to use these colors when possible
|
||||||
$accent-color: #0DBD8B;
|
$accent-color: $accent;
|
||||||
$accent-bg-color: rgba(3, 179, 129, 0.16);
|
$accent-bg-color: rgba(3, 179, 129, 0.16);
|
||||||
$notice-primary-color: #ff4b55;
|
$notice-primary-color: #ff4b55;
|
||||||
$notice-primary-bg-color: rgba(255, 75, 85, 0.16);
|
$notice-primary-bg-color: rgba(255, 75, 85, 0.16);
|
||||||
$primary-fg-color: #2e2f32;
|
$primary-fg-color: #2e2f32;
|
||||||
$secondary-fg-color: #737D8C;
|
$secondary-fg-color: $secondary-content;
|
||||||
$tertiary-fg-color: #8D99A5;
|
$tertiary-fg-color: #8D99A5;
|
||||||
$quaternary-fg-color: #C1C6CD;
|
$quaternary-fg-color: $quaternary-content;
|
||||||
$header-panel-bg-color: #f3f8fd;
|
$header-panel-bg-color: #f3f8fd;
|
||||||
|
|
||||||
// typical text (dark-on-white in light skin)
|
// typical text (dark-on-white in light skin)
|
||||||
$primary-bg-color: #ffffff;
|
$primary-bg-color: $background;
|
||||||
$muted-fg-color: #61708b; // Commonly used in headings and relevant alt text
|
$muted-fg-color: #61708b; // Commonly used in headings and relevant alt text
|
||||||
|
|
||||||
// used for dialog box text
|
// used for dialog box text
|
||||||
|
@ -35,7 +54,7 @@ $light-fg-color: #747474;
|
||||||
$focus-bg-color: #dddddd;
|
$focus-bg-color: #dddddd;
|
||||||
|
|
||||||
// button UI (white-on-green in light skin)
|
// button UI (white-on-green in light skin)
|
||||||
$accent-fg-color: #ffffff;
|
$accent-fg-color: $background;
|
||||||
$accent-color-50pct: rgba($accent-color, 0.5);
|
$accent-color-50pct: rgba($accent-color, 0.5);
|
||||||
$accent-color-darker: #92caad;
|
$accent-color-darker: #92caad;
|
||||||
$accent-color-alt: #238CF5;
|
$accent-color-alt: #238CF5;
|
||||||
|
@ -79,7 +98,7 @@ $primary-hairline-color: transparent;
|
||||||
|
|
||||||
// used for the border of input text fields
|
// used for the border of input text fields
|
||||||
$input-border-color: #e7e7e7;
|
$input-border-color: #e7e7e7;
|
||||||
$input-darker-bg-color: #e3e8f0;
|
$input-darker-bg-color: $quinary-content;
|
||||||
$input-darker-fg-color: #9fa9ba;
|
$input-darker-fg-color: #9fa9ba;
|
||||||
$input-lighter-bg-color: #f2f5f8;
|
$input-lighter-bg-color: #f2f5f8;
|
||||||
$input-lighter-fg-color: $input-darker-fg-color;
|
$input-lighter-fg-color: $input-darker-fg-color;
|
||||||
|
@ -87,7 +106,7 @@ $input-focused-border-color: #238cf5;
|
||||||
$input-valid-border-color: $accent-color;
|
$input-valid-border-color: $accent-color;
|
||||||
$input-invalid-border-color: $warning-color;
|
$input-invalid-border-color: $warning-color;
|
||||||
|
|
||||||
$field-focused-label-bg-color: #ffffff;
|
$field-focused-label-bg-color: $background;
|
||||||
|
|
||||||
$button-bg-color: $accent-color;
|
$button-bg-color: $accent-color;
|
||||||
$button-fg-color: white;
|
$button-fg-color: white;
|
||||||
|
@ -109,8 +128,8 @@ $menu-bg-color: #fff;
|
||||||
$menu-box-shadow-color: rgba(118, 131, 156, 0.6);
|
$menu-box-shadow-color: rgba(118, 131, 156, 0.6);
|
||||||
$menu-selected-color: #f5f8fa;
|
$menu-selected-color: #f5f8fa;
|
||||||
|
|
||||||
$avatar-initial-color: #ffffff;
|
$avatar-initial-color: $background;
|
||||||
$avatar-bg-color: #ffffff;
|
$avatar-bg-color: $background;
|
||||||
|
|
||||||
$h3-color: #3d3b39;
|
$h3-color: #3d3b39;
|
||||||
|
|
||||||
|
@ -138,7 +157,7 @@ $blockquote-bar-color: #ddd;
|
||||||
$blockquote-fg-color: #777;
|
$blockquote-fg-color: #777;
|
||||||
|
|
||||||
$settings-grey-fg-color: #a2a2a2;
|
$settings-grey-fg-color: #a2a2a2;
|
||||||
$settings-profile-placeholder-bg-color: #f4f6fa;
|
$settings-profile-placeholder-bg-color: $system-light;
|
||||||
$settings-profile-overlay-placeholder-fg-color: #2e2f32;
|
$settings-profile-overlay-placeholder-fg-color: #2e2f32;
|
||||||
$settings-profile-button-bg-color: #e7e7e7;
|
$settings-profile-button-bg-color: #e7e7e7;
|
||||||
$settings-profile-button-fg-color: $settings-profile-overlay-placeholder-fg-color;
|
$settings-profile-button-fg-color: $settings-profile-overlay-placeholder-fg-color;
|
||||||
|
@ -160,24 +179,24 @@ $roomheader-addroom-fg-color: #5c6470;
|
||||||
$groupFilterPanel-button-color: #91A1C0;
|
$groupFilterPanel-button-color: #91A1C0;
|
||||||
$groupheader-button-color: #91A1C0;
|
$groupheader-button-color: #91A1C0;
|
||||||
$rightpanel-button-color: #91A1C0;
|
$rightpanel-button-color: #91A1C0;
|
||||||
$icon-button-color: #C1C6CD;
|
$icon-button-color: $quaternary-content;
|
||||||
$roomtopic-color: #9e9e9e;
|
$roomtopic-color: #9e9e9e;
|
||||||
$eventtile-meta-color: $roomtopic-color;
|
$eventtile-meta-color: $roomtopic-color;
|
||||||
|
|
||||||
$composer-e2e-icon-color: #91A1C0;
|
$composer-e2e-icon-color: #91A1C0;
|
||||||
$header-divider-color: #91A1C0;
|
$header-divider-color: #91A1C0;
|
||||||
|
|
||||||
// this probably shouldn't have it's own colour
|
$toast-bg-color: $system-light;
|
||||||
$voipcall-plinth-color: #F4F6FA;
|
$voipcall-plinth-color: $system-light;
|
||||||
|
|
||||||
// ********************
|
// ********************
|
||||||
|
|
||||||
$theme-button-bg-color: #e3e8f0;
|
$theme-button-bg-color: $quinary-content;
|
||||||
$dialpad-button-bg-color: #e3e8f0;
|
$dialpad-button-bg-color: $quinary-content;
|
||||||
|
|
||||||
|
|
||||||
$roomlist-button-bg-color: rgba(141, 151, 165, 0.2); // Buttons include the filter box, explore button, and sublist buttons
|
$roomlist-button-bg-color: rgba(141, 151, 165, 0.2); // Buttons include the filter box, explore button, and sublist buttons
|
||||||
$roomlist-filter-active-bg-color: #ffffff;
|
$roomlist-filter-active-bg-color: $background;
|
||||||
$roomlist-bg-color: rgba(245, 245, 245, 0.90);
|
$roomlist-bg-color: rgba(245, 245, 245, 0.90);
|
||||||
$roomlist-header-color: $tertiary-fg-color;
|
$roomlist-header-color: $tertiary-fg-color;
|
||||||
$roomsublist-divider-color: $primary-fg-color;
|
$roomsublist-divider-color: $primary-fg-color;
|
||||||
|
@ -191,7 +210,7 @@ $roomtile-selected-bg-color: #FFF;
|
||||||
|
|
||||||
$presence-online: $accent-color;
|
$presence-online: $accent-color;
|
||||||
$presence-away: #d9b072;
|
$presence-away: #d9b072;
|
||||||
$presence-offline: #E3E8F0;
|
$presence-offline: $quinary-content;
|
||||||
|
|
||||||
// ********************
|
// ********************
|
||||||
|
|
||||||
|
@ -254,7 +273,7 @@ $lightbox-border-color: #ffffff;
|
||||||
|
|
||||||
// Tabbed views
|
// Tabbed views
|
||||||
$tab-label-fg-color: #45474a;
|
$tab-label-fg-color: #45474a;
|
||||||
$tab-label-active-fg-color: #ffffff;
|
$tab-label-active-fg-color: $background;
|
||||||
$tab-label-bg-color: transparent;
|
$tab-label-bg-color: transparent;
|
||||||
$tab-label-active-bg-color: $accent-color;
|
$tab-label-active-bg-color: $accent-color;
|
||||||
$tab-label-icon-bg-color: #454545;
|
$tab-label-icon-bg-color: #454545;
|
||||||
|
@ -264,9 +283,9 @@ $tab-label-active-icon-bg-color: $tab-label-active-fg-color;
|
||||||
$button-primary-fg-color: #ffffff;
|
$button-primary-fg-color: #ffffff;
|
||||||
$button-primary-bg-color: $accent-color;
|
$button-primary-bg-color: $accent-color;
|
||||||
$button-secondary-bg-color: $accent-fg-color;
|
$button-secondary-bg-color: $accent-fg-color;
|
||||||
$button-danger-fg-color: #ffffff;
|
$button-danger-fg-color: $background;
|
||||||
$button-danger-bg-color: $notice-primary-color;
|
$button-danger-bg-color: $notice-primary-color;
|
||||||
$button-danger-disabled-fg-color: #ffffff;
|
$button-danger-disabled-fg-color: $background;
|
||||||
$button-danger-disabled-bg-color: #f5b6bb; // TODO: Verify color
|
$button-danger-disabled-bg-color: #f5b6bb; // TODO: Verify color
|
||||||
$button-link-fg-color: $accent-color;
|
$button-link-fg-color: $accent-color;
|
||||||
$button-link-bg-color: transparent;
|
$button-link-bg-color: transparent;
|
||||||
|
@ -291,7 +310,7 @@ $memberstatus-placeholder-color: $muted-fg-color;
|
||||||
|
|
||||||
$authpage-bg-color: #2e3649;
|
$authpage-bg-color: #2e3649;
|
||||||
$authpage-modal-bg-color: rgba(245, 245, 245, 0.90);
|
$authpage-modal-bg-color: rgba(245, 245, 245, 0.90);
|
||||||
$authpage-body-bg-color: #ffffff;
|
$authpage-body-bg-color: $background;
|
||||||
$authpage-focus-bg-color: #dddddd;
|
$authpage-focus-bg-color: #dddddd;
|
||||||
$authpage-lang-color: #4e5054;
|
$authpage-lang-color: #4e5054;
|
||||||
$authpage-primary-color: #232f32;
|
$authpage-primary-color: #232f32;
|
||||||
|
@ -315,26 +334,26 @@ $kbd-border-color: $reaction-row-button-border-color;
|
||||||
|
|
||||||
$inverted-bg-color: #27303a;
|
$inverted-bg-color: #27303a;
|
||||||
$tooltip-timeline-bg-color: $inverted-bg-color;
|
$tooltip-timeline-bg-color: $inverted-bg-color;
|
||||||
$tooltip-timeline-fg-color: #ffffff;
|
$tooltip-timeline-fg-color: $background;
|
||||||
|
|
||||||
$interactive-tooltip-bg-color: #27303a;
|
$interactive-tooltip-bg-color: #27303a;
|
||||||
$interactive-tooltip-fg-color: #ffffff;
|
$interactive-tooltip-fg-color: $background;
|
||||||
|
|
||||||
$breadcrumb-placeholder-bg-color: #e8eef5;
|
$breadcrumb-placeholder-bg-color: #e8eef5;
|
||||||
|
|
||||||
$user-tile-hover-bg-color: $header-panel-bg-color;
|
$user-tile-hover-bg-color: $header-panel-bg-color;
|
||||||
|
|
||||||
$message-body-panel-fg-color: $secondary-fg-color;
|
$message-body-panel-fg-color: $secondary-fg-color;
|
||||||
$message-body-panel-bg-color: #E3E8F0; // "Separator"
|
$message-body-panel-bg-color: $quinary-content;
|
||||||
$message-body-panel-icon-fg-color: $secondary-fg-color;
|
$message-body-panel-icon-fg-color: $secondary-fg-color;
|
||||||
$message-body-panel-icon-bg-color: #F4F6FA;
|
$message-body-panel-icon-bg-color: $system-light;
|
||||||
|
|
||||||
// These two don't change between themes. They are the $warning-color, but we don't
|
// These two don't change between themes. They are the $warning-color, but we don't
|
||||||
// want custom themes to affect them by accident.
|
// want custom themes to affect them by accident.
|
||||||
$voice-record-stop-symbol-color: #ff4b55;
|
$voice-record-stop-symbol-color: #ff4b55;
|
||||||
$voice-record-live-circle-color: #ff4b55;
|
$voice-record-live-circle-color: #ff4b55;
|
||||||
|
|
||||||
$voice-record-stop-border-color: #E3E8F0; // "Separator"
|
$voice-record-stop-border-color: $quinary-content;
|
||||||
$voice-record-waveform-incomplete-fg-color: $quaternary-fg-color;
|
$voice-record-waveform-incomplete-fg-color: $quaternary-fg-color;
|
||||||
$voice-record-icon-color: $tertiary-fg-color;
|
$voice-record-icon-color: $tertiary-fg-color;
|
||||||
$voice-playback-button-bg-color: $message-body-panel-icon-bg-color;
|
$voice-playback-button-bg-color: $message-body-panel-icon-bg-color;
|
||||||
|
@ -350,11 +369,11 @@ $groupFilterPanel-background-blur-amount: 20px;
|
||||||
$composer-shadow-color: rgba(0, 0, 0, 0.04);
|
$composer-shadow-color: rgba(0, 0, 0, 0.04);
|
||||||
|
|
||||||
// Bubble tiles
|
// Bubble tiles
|
||||||
$eventbubble-self-bg: #F8FDFC;
|
$eventbubble-self-bg: #F0FBF8;
|
||||||
$eventbubble-others-bg: #F7F8F9;
|
$eventbubble-others-bg: $system-light;
|
||||||
$eventbubble-bg-hover: #FEFCF5;
|
$eventbubble-bg-hover: #FAFBFD;
|
||||||
$eventbubble-avatar-outline: $primary-bg-color;
|
$eventbubble-avatar-outline: $primary-bg-color;
|
||||||
$eventbubble-reply-color: #C1C6CD;
|
$eventbubble-reply-color: $quaternary-content;
|
||||||
|
|
||||||
// ***** Mixins! *****
|
// ***** Mixins! *****
|
||||||
|
|
||||||
|
@ -392,7 +411,7 @@ $eventbubble-reply-color: #C1C6CD;
|
||||||
@define-mixin mx_DialogButton_secondary {
|
@define-mixin mx_DialogButton_secondary {
|
||||||
// flip colours for the secondary ones
|
// flip colours for the secondary ones
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
border: 1px solid $accent-color ! important;
|
border: 1px solid $accent-color !important;
|
||||||
color: $accent-color;
|
color: $accent-color;
|
||||||
background-color: $button-secondary-bg-color;
|
background-color: $button-secondary-bg-color;
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,8 +14,8 @@ See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import RoomViewStore from './stores/RoomViewStore';
|
|
||||||
import { EventSubscription } from 'fbemitter';
|
import { EventSubscription } from 'fbemitter';
|
||||||
|
import RoomViewStore from './stores/RoomViewStore';
|
||||||
|
|
||||||
type Listener = (isActive: boolean) => void;
|
type Listener = (isActive: boolean) => void;
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,8 @@
|
||||||
/*
|
/*
|
||||||
Copyright 2015, 2016 OpenMarket Ltd
|
Copyright 2015, 2016 OpenMarket Ltd
|
||||||
Copyright 2017, 2018 New Vector Ltd
|
Copyright 2017, 2018 New Vector Ltd
|
||||||
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
|
Copyright 2019 - 2021 The Matrix.org Foundation C.I.C.
|
||||||
|
Copyright 2021 Šimon Brandner <simon.bra.ag@gmail.com>
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
you may not use this file except in compliance with the License.
|
you may not use this file except in compliance with the License.
|
||||||
|
@ -60,7 +61,6 @@ import Modal from './Modal';
|
||||||
import { _t } from './languageHandler';
|
import { _t } from './languageHandler';
|
||||||
import dis from './dispatcher/dispatcher';
|
import dis from './dispatcher/dispatcher';
|
||||||
import WidgetUtils from './utils/WidgetUtils';
|
import WidgetUtils from './utils/WidgetUtils';
|
||||||
import WidgetEchoStore from './stores/WidgetEchoStore';
|
|
||||||
import SettingsStore from './settings/SettingsStore';
|
import SettingsStore from './settings/SettingsStore';
|
||||||
import { Jitsi } from "./widgets/Jitsi";
|
import { Jitsi } from "./widgets/Jitsi";
|
||||||
import { WidgetType } from "./widgets/WidgetType";
|
import { WidgetType } from "./widgets/WidgetType";
|
||||||
|
@ -86,6 +86,12 @@ import { randomUppercaseString, randomLowercaseString } from "matrix-js-sdk/src/
|
||||||
import EventEmitter from 'events';
|
import EventEmitter from 'events';
|
||||||
import SdkConfig from './SdkConfig';
|
import SdkConfig from './SdkConfig';
|
||||||
import { ensureDMExists, findDMForUser } from './createRoom';
|
import { ensureDMExists, findDMForUser } from './createRoom';
|
||||||
|
import { IPushRule, RuleId, TweakName, Tweaks } from "matrix-js-sdk/src/@types/PushRules";
|
||||||
|
import { PushProcessor } from 'matrix-js-sdk/src/pushprocessor';
|
||||||
|
import { WidgetLayoutStore, Container } from './stores/widgets/WidgetLayoutStore';
|
||||||
|
import { getIncomingCallToastKey } from './toasts/IncomingCallToast';
|
||||||
|
import ToastStore from './stores/ToastStore';
|
||||||
|
import IncomingCallToast from "./toasts/IncomingCallToast";
|
||||||
|
|
||||||
export const PROTOCOL_PSTN = 'm.protocol.pstn';
|
export const PROTOCOL_PSTN = 'm.protocol.pstn';
|
||||||
export const PROTOCOL_PSTN_PREFIXED = 'im.vector.protocol.pstn';
|
export const PROTOCOL_PSTN_PREFIXED = 'im.vector.protocol.pstn';
|
||||||
|
@ -476,26 +482,44 @@ export default class CallHandler extends EventEmitter {
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (newState) {
|
switch (newState) {
|
||||||
case CallState.Ringing:
|
case CallState.Ringing: {
|
||||||
|
const incomingCallPushRule = (
|
||||||
|
new PushProcessor(MatrixClientPeg.get()).getPushRuleById(RuleId.IncomingCall) as IPushRule
|
||||||
|
);
|
||||||
|
const pushRuleEnabled = incomingCallPushRule?.enabled;
|
||||||
|
const tweakSetToRing = incomingCallPushRule?.actions.some((action: Tweaks) => (
|
||||||
|
action.set_tweak === TweakName.Sound &&
|
||||||
|
action.value === "ring"
|
||||||
|
));
|
||||||
|
|
||||||
|
if (pushRuleEnabled && tweakSetToRing) {
|
||||||
this.play(AudioID.Ring);
|
this.play(AudioID.Ring);
|
||||||
|
} else {
|
||||||
|
this.silenceCall(call.callId);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case CallState.InviteSent:
|
}
|
||||||
|
case CallState.InviteSent: {
|
||||||
this.play(AudioID.Ringback);
|
this.play(AudioID.Ringback);
|
||||||
break;
|
break;
|
||||||
case CallState.Ended:
|
}
|
||||||
{
|
case CallState.Ended: {
|
||||||
const hangupReason = call.hangupReason;
|
const hangupReason = call.hangupReason;
|
||||||
Analytics.trackEvent('voip', 'callEnded', 'hangupReason', hangupReason);
|
Analytics.trackEvent('voip', 'callEnded', 'hangupReason', hangupReason);
|
||||||
this.removeCallForRoom(mappedRoomId);
|
this.removeCallForRoom(mappedRoomId);
|
||||||
if (oldState === CallState.InviteSent && call.hangupParty === CallParty.Remote) {
|
if (oldState === CallState.InviteSent && call.hangupParty === CallParty.Remote) {
|
||||||
this.play(AudioID.Busy);
|
this.play(AudioID.Busy);
|
||||||
|
|
||||||
|
// Don't show a modal when we got rejected/the call was hung up
|
||||||
|
if (!hangupReason || [CallErrorCode.UserHangup, "user hangup"].includes(hangupReason)) break;
|
||||||
|
|
||||||
let title;
|
let title;
|
||||||
let description;
|
let description;
|
||||||
// TODO: We should either do away with these or figure out a copy for each code (expect user_hangup...)
|
// TODO: We should either do away with these or figure out a copy for each code (expect user_hangup...)
|
||||||
if (call.hangupReason === CallErrorCode.UserBusy) {
|
if (call.hangupReason === CallErrorCode.UserBusy) {
|
||||||
title = _t("User Busy");
|
title = _t("User Busy");
|
||||||
description = _t("The user you called is busy.");
|
description = _t("The user you called is busy.");
|
||||||
} else if (hangupReason && ![CallErrorCode.UserHangup, "user hangup"].includes(hangupReason)) {
|
} else {
|
||||||
title = _t("Call Failed");
|
title = _t("Call Failed");
|
||||||
description = _t("The call could not be established");
|
description = _t("The call could not be established");
|
||||||
}
|
}
|
||||||
|
@ -624,6 +648,19 @@ export default class CallHandler extends EventEmitter {
|
||||||
`Call state in ${mappedRoomId} changed to ${status}`,
|
`Call state in ${mappedRoomId} changed to ${status}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const toastKey = getIncomingCallToastKey(call.callId);
|
||||||
|
if (status === CallState.Ringing) {
|
||||||
|
ToastStore.sharedInstance().addOrReplaceToast({
|
||||||
|
key: toastKey,
|
||||||
|
priority: 100,
|
||||||
|
component: IncomingCallToast,
|
||||||
|
bodyClassName: "mx_IncomingCallToast",
|
||||||
|
props: { call },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
ToastStore.sharedInstance().dismissToast(toastKey);
|
||||||
|
}
|
||||||
|
|
||||||
dis.dispatch({
|
dis.dispatch({
|
||||||
action: 'call_state',
|
action: 'call_state',
|
||||||
room_id: mappedRoomId,
|
room_id: mappedRoomId,
|
||||||
|
@ -914,6 +951,8 @@ export default class CallHandler extends EventEmitter {
|
||||||
action: 'view_room',
|
action: 'view_room',
|
||||||
room_id: roomId,
|
room_id: roomId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await this.placeCall(roomId, PlaceCallType.Voice, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async startTransferToPhoneNumber(call: MatrixCall, destination: string, consultFirst: boolean) {
|
private async startTransferToPhoneNumber(call: MatrixCall, destination: string, consultFirst: boolean) {
|
||||||
|
@ -993,14 +1032,10 @@ export default class CallHandler extends EventEmitter {
|
||||||
|
|
||||||
// prevent double clicking the call button
|
// prevent double clicking the call button
|
||||||
const room = MatrixClientPeg.get().getRoom(roomId);
|
const room = MatrixClientPeg.get().getRoom(roomId);
|
||||||
const currentJitsiWidgets = WidgetUtils.getRoomWidgetsOfType(room, WidgetType.JITSI);
|
const jitsiWidget = WidgetStore.instance.getApps(roomId).find((app) => WidgetType.JITSI.matches(app.type));
|
||||||
const hasJitsi = currentJitsiWidgets.length > 0
|
if (jitsiWidget) {
|
||||||
|| WidgetEchoStore.roomHasPendingWidgetsOfType(roomId, currentJitsiWidgets, WidgetType.JITSI);
|
// If there already is a Jitsi widget pin it
|
||||||
if (hasJitsi) {
|
WidgetLayoutStore.instance.moveToContainer(room, jitsiWidget, Container.Top);
|
||||||
Modal.createTrackedDialog('Call already in progress', '', ErrorDialog, {
|
|
||||||
title: _t('Call in Progress'),
|
|
||||||
description: _t('A call is currently being placed!'),
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -209,6 +209,14 @@ async function loadImageElement(imageFile: File) {
|
||||||
return { width, height, img };
|
return { width, height, img };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Minimum size for image files before we generate a thumbnail for them.
|
||||||
|
const IMAGE_SIZE_THRESHOLD_THUMBNAIL = 1 << 15; // 32KB
|
||||||
|
// Minimum size improvement for image thumbnails, if both are not met then don't bother uploading thumbnail.
|
||||||
|
const IMAGE_THUMBNAIL_MIN_REDUCTION_SIZE = 1 << 16; // 1MB
|
||||||
|
const IMAGE_THUMBNAIL_MIN_REDUCTION_PERCENT = 0.1; // 10%
|
||||||
|
// We don't apply these thresholds to video thumbnails as a poster image is always useful
|
||||||
|
// and videos tend to be much larger.
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read the metadata for an image file and create and upload a thumbnail of the image.
|
* Read the metadata for an image file and create and upload a thumbnail of the image.
|
||||||
*
|
*
|
||||||
|
@ -217,23 +225,33 @@ async function loadImageElement(imageFile: File) {
|
||||||
* @param {File} imageFile The image to read and thumbnail.
|
* @param {File} imageFile The image to read and thumbnail.
|
||||||
* @return {Promise} A promise that resolves with the attachment info.
|
* @return {Promise} A promise that resolves with the attachment info.
|
||||||
*/
|
*/
|
||||||
function infoForImageFile(matrixClient, roomId, imageFile) {
|
async function infoForImageFile(matrixClient: MatrixClient, roomId: string, imageFile: File) {
|
||||||
let thumbnailType = "image/png";
|
let thumbnailType = "image/png";
|
||||||
if (imageFile.type === "image/jpeg") {
|
if (imageFile.type === "image/jpeg") {
|
||||||
thumbnailType = "image/jpeg";
|
thumbnailType = "image/jpeg";
|
||||||
}
|
}
|
||||||
|
|
||||||
let imageInfo;
|
const imageElement = await loadImageElement(imageFile);
|
||||||
return loadImageElement(imageFile).then((r) => {
|
|
||||||
return createThumbnail(r.img, r.width, r.height, thumbnailType);
|
const result = await createThumbnail(imageElement.img, imageElement.width, imageElement.height, thumbnailType);
|
||||||
}).then((result) => {
|
const imageInfo = result.info;
|
||||||
imageInfo = result.info;
|
|
||||||
return uploadFile(matrixClient, roomId, result.thumbnail);
|
// we do all sizing checks here because we still rely on thumbnail generation for making a blurhash from.
|
||||||
}).then((result) => {
|
const sizeDifference = imageFile.size - imageInfo.thumbnail_info.size;
|
||||||
imageInfo.thumbnail_url = result.url;
|
if (
|
||||||
imageInfo.thumbnail_file = result.file;
|
imageFile.size <= IMAGE_SIZE_THRESHOLD_THUMBNAIL || // image is small enough already
|
||||||
|
(sizeDifference <= IMAGE_THUMBNAIL_MIN_REDUCTION_SIZE && // thumbnail is not sufficiently smaller than original
|
||||||
|
sizeDifference <= (imageFile.size * IMAGE_THUMBNAIL_MIN_REDUCTION_PERCENT))
|
||||||
|
) {
|
||||||
|
delete imageInfo["thumbnail_info"];
|
||||||
|
return imageInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadResult = await uploadFile(matrixClient, roomId, result.thumbnail);
|
||||||
|
|
||||||
|
imageInfo["thumbnail_url"] = uploadResult.url;
|
||||||
|
imageInfo["thumbnail_file"] = uploadResult.file;
|
||||||
return imageInfo;
|
return imageInfo;
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -123,6 +123,19 @@ export function formatTime(date: Date, showTwelveHour = false): string {
|
||||||
return pad(date.getHours()) + ':' + pad(date.getMinutes());
|
return pad(date.getHours()) + ':' + pad(date.getMinutes());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function formatCallTime(delta: Date): string {
|
||||||
|
const hours = delta.getUTCHours();
|
||||||
|
const minutes = delta.getUTCMinutes();
|
||||||
|
const seconds = delta.getUTCSeconds();
|
||||||
|
|
||||||
|
let output = "";
|
||||||
|
if (hours) output += `${hours}h `;
|
||||||
|
if (minutes || output) output += `${minutes}m `;
|
||||||
|
if (seconds || output) output += `${seconds}s`;
|
||||||
|
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
const MILLIS_IN_DAY = 86400000;
|
const MILLIS_IN_DAY = 86400000;
|
||||||
export function wantsDateSeparator(prevEventDate: Date, nextEventDate: Date): boolean {
|
export function wantsDateSeparator(prevEventDate: Date, nextEventDate: Date): boolean {
|
||||||
if (!nextEventDate || !prevEventDate) {
|
if (!nextEventDate || !prevEventDate) {
|
||||||
|
|
|
@ -57,7 +57,33 @@ const BIGEMOJI_REGEX = new RegExp(`^(${EMOJIBASE_REGEX.source})+$`, 'i');
|
||||||
|
|
||||||
const COLOR_REGEX = /^#[0-9a-fA-F]{6}$/;
|
const COLOR_REGEX = /^#[0-9a-fA-F]{6}$/;
|
||||||
|
|
||||||
export const PERMITTED_URL_SCHEMES = ['http', 'https', 'ftp', 'mailto', 'magnet', 'matrix'];
|
export const PERMITTED_URL_SCHEMES = [
|
||||||
|
"bitcoin",
|
||||||
|
"ftp",
|
||||||
|
"geo",
|
||||||
|
"http",
|
||||||
|
"https",
|
||||||
|
"im",
|
||||||
|
"irc",
|
||||||
|
"ircs",
|
||||||
|
"magnet",
|
||||||
|
"mailto",
|
||||||
|
"matrix",
|
||||||
|
"mms",
|
||||||
|
"news",
|
||||||
|
"nntp",
|
||||||
|
"openpgp4fpr",
|
||||||
|
"sip",
|
||||||
|
"sftp",
|
||||||
|
"sms",
|
||||||
|
"smsto",
|
||||||
|
"ssh",
|
||||||
|
"tel",
|
||||||
|
"urn",
|
||||||
|
"webcal",
|
||||||
|
"wtai",
|
||||||
|
"xmpp",
|
||||||
|
];
|
||||||
|
|
||||||
const MEDIA_API_MXC_REGEX = /\/_matrix\/media\/r0\/(?:download|thumbnail)\/(.+?)\/(.+?)(?:[?/]|$)/;
|
const MEDIA_API_MXC_REGEX = /\/_matrix\/media\/r0\/(?:download|thumbnail)\/(.+?)\/(.+?)(?:[?/]|$)/;
|
||||||
|
|
||||||
|
|
|
@ -48,6 +48,7 @@ import { Jitsi } from "./widgets/Jitsi";
|
||||||
import { SSO_HOMESERVER_URL_KEY, SSO_ID_SERVER_URL_KEY, SSO_IDP_ID_KEY } from "./BasePlatform";
|
import { SSO_HOMESERVER_URL_KEY, SSO_ID_SERVER_URL_KEY, SSO_IDP_ID_KEY } from "./BasePlatform";
|
||||||
import ThreepidInviteStore from "./stores/ThreepidInviteStore";
|
import ThreepidInviteStore from "./stores/ThreepidInviteStore";
|
||||||
import CountlyAnalytics from "./CountlyAnalytics";
|
import CountlyAnalytics from "./CountlyAnalytics";
|
||||||
|
import { PosthogAnalytics } from "./PosthogAnalytics";
|
||||||
import CallHandler from './CallHandler';
|
import CallHandler from './CallHandler';
|
||||||
import LifecycleCustomisations from "./customisations/Lifecycle";
|
import LifecycleCustomisations from "./customisations/Lifecycle";
|
||||||
import ErrorDialog from "./components/views/dialogs/ErrorDialog";
|
import ErrorDialog from "./components/views/dialogs/ErrorDialog";
|
||||||
|
@ -573,6 +574,8 @@ async function doSetLoggedIn(
|
||||||
await abortLogin();
|
await abortLogin();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PosthogAnalytics.instance.updateAnonymityFromSettings(credentials.userId);
|
||||||
|
|
||||||
Analytics.setLoggedIn(credentials.guest, credentials.homeserverUrl);
|
Analytics.setLoggedIn(credentials.guest, credentials.homeserverUrl);
|
||||||
|
|
||||||
MatrixClientPeg.replaceUsingCreds(credentials);
|
MatrixClientPeg.replaceUsingCreds(credentials);
|
||||||
|
@ -700,6 +703,8 @@ export function logout(): void {
|
||||||
CountlyAnalytics.instance.enable(/* anonymous = */ true);
|
CountlyAnalytics.instance.enable(/* anonymous = */ true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PosthogAnalytics.instance.logout();
|
||||||
|
|
||||||
if (MatrixClientPeg.get().isGuest()) {
|
if (MatrixClientPeg.get().isGuest()) {
|
||||||
// logout doesn't work for guest sessions
|
// logout doesn't work for guest sessions
|
||||||
// Also we sometimes want to re-log in a guest session if we abort the login.
|
// Also we sometimes want to re-log in a guest session if we abort the login.
|
||||||
|
|
355
src/PosthogAnalytics.ts
Normal file
355
src/PosthogAnalytics.ts
Normal file
|
@ -0,0 +1,355 @@
|
||||||
|
/*
|
||||||
|
Copyright 2021 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 posthog, { PostHog } from 'posthog-js';
|
||||||
|
import PlatformPeg from './PlatformPeg';
|
||||||
|
import SdkConfig from './SdkConfig';
|
||||||
|
import SettingsStore from './settings/SettingsStore';
|
||||||
|
|
||||||
|
/* Posthog analytics tracking.
|
||||||
|
*
|
||||||
|
* Anonymity behaviour is as follows:
|
||||||
|
*
|
||||||
|
* - If Posthog isn't configured in `config.json`, events are not sent.
|
||||||
|
* - If [Do Not Track](https://developer.mozilla.org/en-US/docs/Web/API/Navigator/doNotTrack) is
|
||||||
|
* enabled, events are not sent (this detection is built into posthog and turned on via the
|
||||||
|
* `respect_dnt` flag being passed to `posthog.init`).
|
||||||
|
* - If the `feature_pseudonymous_analytics_opt_in` labs flag is `true`, track pseudonomously, i.e.
|
||||||
|
* hash all matrix identifiers in tracking events (user IDs, room IDs etc) using SHA-256.
|
||||||
|
* - Otherwise, if the existing `analyticsOptIn` flag is `true`, track anonymously, i.e.
|
||||||
|
* redact all matrix identifiers in tracking events.
|
||||||
|
* - If both flags are false or not set, events are not sent.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface IEvent {
|
||||||
|
// The event name that will be used by PostHog. Event names should use snake_case.
|
||||||
|
eventName: string;
|
||||||
|
|
||||||
|
// The properties of the event that will be stored in PostHog. This is just a placeholder,
|
||||||
|
// extending interfaces must override this with a concrete definition to do type validation.
|
||||||
|
properties: {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum Anonymity {
|
||||||
|
Disabled,
|
||||||
|
Anonymous,
|
||||||
|
Pseudonymous
|
||||||
|
}
|
||||||
|
|
||||||
|
// If an event extends IPseudonymousEvent, the event contains pseudonymous data
|
||||||
|
// that won't be sent unless the user has explicitly consented to pseudonymous tracking.
|
||||||
|
// For example, it might contain hashed user IDs or room IDs.
|
||||||
|
// Such events will be automatically dropped if PosthogAnalytics.anonymity isn't set to Pseudonymous.
|
||||||
|
export interface IPseudonymousEvent extends IEvent {}
|
||||||
|
|
||||||
|
// If an event extends IAnonymousEvent, the event strictly contains *only* anonymous data;
|
||||||
|
// i.e. no identifiers that can be associated with the user.
|
||||||
|
export interface IAnonymousEvent extends IEvent {}
|
||||||
|
|
||||||
|
export interface IRoomEvent extends IPseudonymousEvent {
|
||||||
|
hashedRoomId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IPageView extends IAnonymousEvent {
|
||||||
|
eventName: "$pageview";
|
||||||
|
properties: {
|
||||||
|
durationMs?: number;
|
||||||
|
screen?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const hashHex = async (input: string): Promise<string> => {
|
||||||
|
const buf = new TextEncoder().encode(input);
|
||||||
|
const digestBuf = await window.crypto.subtle.digest("sha-256", buf);
|
||||||
|
return [...new Uint8Array(digestBuf)].map((b: number) => b.toString(16).padStart(2, "0")).join("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const whitelistedScreens = new Set([
|
||||||
|
"register", "login", "forgot_password", "soft_logout", "new", "settings", "welcome", "home", "start", "directory",
|
||||||
|
"start_sso", "start_cas", "groups", "complete_security", "post_registration", "room", "user", "group",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export async function getRedactedCurrentLocation(
|
||||||
|
origin: string,
|
||||||
|
hash: string,
|
||||||
|
pathname: string,
|
||||||
|
anonymity: Anonymity,
|
||||||
|
): Promise<string> {
|
||||||
|
// Redact PII from the current location.
|
||||||
|
// If anonymous is true, redact entirely, if false, substitute it with a hash.
|
||||||
|
// For known screens, assumes a URL structure of /<screen name>/might/be/pii
|
||||||
|
if (origin.startsWith('file://')) {
|
||||||
|
pathname = "/<redacted_file_scheme_url>/";
|
||||||
|
}
|
||||||
|
|
||||||
|
let hashStr;
|
||||||
|
if (hash == "") {
|
||||||
|
hashStr = "";
|
||||||
|
} else {
|
||||||
|
let [beforeFirstSlash, screen, ...parts] = hash.split("/");
|
||||||
|
|
||||||
|
if (!whitelistedScreens.has(screen)) {
|
||||||
|
screen = "<redacted_screen_name>";
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < parts.length; i++) {
|
||||||
|
parts[i] = anonymity === Anonymity.Anonymous ? `<redacted>` : await hashHex(parts[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
hashStr = `${beforeFirstSlash}/${screen}/${parts.join("/")}`;
|
||||||
|
}
|
||||||
|
return origin + pathname + hashStr;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PlatformProperties {
|
||||||
|
appVersion: string;
|
||||||
|
appPlatform: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PosthogAnalytics {
|
||||||
|
/* Wrapper for Posthog analytics.
|
||||||
|
* 3 modes of anonymity are supported, governed by this.anonymity
|
||||||
|
* - Anonymity.Disabled means *no data* is passed to posthog
|
||||||
|
* - Anonymity.Anonymous means all identifers will be redacted before being passed to posthog
|
||||||
|
* - Anonymity.Pseudonymous means all identifiers will be hashed via SHA-256 before being passed
|
||||||
|
* to Posthog
|
||||||
|
*
|
||||||
|
* To update anonymity, call updateAnonymityFromSettings() or you can set it directly via setAnonymity().
|
||||||
|
*
|
||||||
|
* To pass an event to Posthog:
|
||||||
|
*
|
||||||
|
* 1. Declare a type for the event, extending IAnonymousEvent, IPseudonymousEvent or IRoomEvent.
|
||||||
|
* 2. Call the appropriate track*() method. Pseudonymous events will be dropped when anonymity is
|
||||||
|
* Anonymous or Disabled; Anonymous events will be dropped when anonymity is Disabled.
|
||||||
|
*/
|
||||||
|
|
||||||
|
private anonymity = Anonymity.Disabled;
|
||||||
|
// set true during the constructor if posthog config is present, otherwise false
|
||||||
|
private enabled = false;
|
||||||
|
private static _instance = null;
|
||||||
|
private platformSuperProperties = {};
|
||||||
|
|
||||||
|
public static get instance(): PosthogAnalytics {
|
||||||
|
if (!this._instance) {
|
||||||
|
this._instance = new PosthogAnalytics(posthog);
|
||||||
|
}
|
||||||
|
return this._instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(private readonly posthog: PostHog) {
|
||||||
|
const posthogConfig = SdkConfig.get()["posthog"];
|
||||||
|
if (posthogConfig) {
|
||||||
|
this.posthog.init(posthogConfig.projectApiKey, {
|
||||||
|
api_host: posthogConfig.apiHost,
|
||||||
|
autocapture: false,
|
||||||
|
mask_all_text: true,
|
||||||
|
mask_all_element_attributes: true,
|
||||||
|
// This only triggers on page load, which for our SPA isn't particularly useful.
|
||||||
|
// Plus, the .capture call originating from somewhere in posthog makes it hard
|
||||||
|
// to redact URLs, which requires async code.
|
||||||
|
//
|
||||||
|
// To raise this manually, just call .capture("$pageview") or posthog.capture_pageview.
|
||||||
|
capture_pageview: false,
|
||||||
|
sanitize_properties: this.sanitizeProperties,
|
||||||
|
respect_dnt: true,
|
||||||
|
});
|
||||||
|
this.enabled = true;
|
||||||
|
} else {
|
||||||
|
this.enabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sanitizeProperties = (properties: posthog.Properties): posthog.Properties => {
|
||||||
|
// Callback from posthog to sanitize properties before sending them to the server.
|
||||||
|
//
|
||||||
|
// Here we sanitize posthog's built in properties which leak PII e.g. url reporting.
|
||||||
|
// See utils.js _.info.properties in posthog-js.
|
||||||
|
|
||||||
|
// Replace the $current_url with a redacted version.
|
||||||
|
// $redacted_current_url is injected by this class earlier in capture(), as its generation
|
||||||
|
// is async and can't be done in this non-async callback.
|
||||||
|
if (!properties['$redacted_current_url']) {
|
||||||
|
console.log("$redacted_current_url not set in sanitizeProperties, will drop $current_url entirely");
|
||||||
|
}
|
||||||
|
properties['$current_url'] = properties['$redacted_current_url'];
|
||||||
|
delete properties['$redacted_current_url'];
|
||||||
|
|
||||||
|
if (this.anonymity == Anonymity.Anonymous) {
|
||||||
|
// drop referrer information for anonymous users
|
||||||
|
properties['$referrer'] = null;
|
||||||
|
properties['$referring_domain'] = null;
|
||||||
|
properties['$initial_referrer'] = null;
|
||||||
|
properties['$initial_referring_domain'] = null;
|
||||||
|
|
||||||
|
// drop device ID, which is a UUID persisted in local storage
|
||||||
|
properties['$device_id'] = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return properties;
|
||||||
|
};
|
||||||
|
|
||||||
|
private static getAnonymityFromSettings(): Anonymity {
|
||||||
|
// determine the current anonymity level based on current user settings
|
||||||
|
|
||||||
|
// "Send anonymous usage data which helps us improve Element. This will use a cookie."
|
||||||
|
const analyticsOptIn = SettingsStore.getValue("analyticsOptIn", null, true);
|
||||||
|
|
||||||
|
// (proposed wording) "Send pseudonymous usage data which helps us improve Element. This will use a cookie."
|
||||||
|
//
|
||||||
|
// TODO: Currently, this is only a labs flag, for testing purposes.
|
||||||
|
const pseudonumousOptIn = SettingsStore.getValue("feature_pseudonymous_analytics_opt_in", null, true);
|
||||||
|
|
||||||
|
let anonymity;
|
||||||
|
if (pseudonumousOptIn) {
|
||||||
|
anonymity = Anonymity.Pseudonymous;
|
||||||
|
} else if (analyticsOptIn) {
|
||||||
|
anonymity = Anonymity.Anonymous;
|
||||||
|
} else {
|
||||||
|
anonymity = Anonymity.Disabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
return anonymity;
|
||||||
|
}
|
||||||
|
|
||||||
|
private registerSuperProperties(properties: posthog.Properties) {
|
||||||
|
if (this.enabled) {
|
||||||
|
this.posthog.register(properties);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async getPlatformProperties(): Promise<PlatformProperties> {
|
||||||
|
const platform = PlatformPeg.get();
|
||||||
|
let appVersion;
|
||||||
|
try {
|
||||||
|
appVersion = await platform.getAppVersion();
|
||||||
|
} catch (e) {
|
||||||
|
// this happens if no version is set i.e. in dev
|
||||||
|
appVersion = "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
appVersion,
|
||||||
|
appPlatform: platform.getHumanReadableName(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async capture(eventName: string, properties: posthog.Properties) {
|
||||||
|
if (!this.enabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { origin, hash, pathname } = window.location;
|
||||||
|
properties['$redacted_current_url'] = await getRedactedCurrentLocation(
|
||||||
|
origin, hash, pathname, this.anonymity);
|
||||||
|
this.posthog.capture(eventName, properties);
|
||||||
|
}
|
||||||
|
|
||||||
|
public isEnabled(): boolean {
|
||||||
|
return this.enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
public setAnonymity(anonymity: Anonymity): void {
|
||||||
|
// Update this.anonymity.
|
||||||
|
// This is public for testing purposes, typically you want to call updateAnonymityFromSettings
|
||||||
|
// to ensure this value is in step with the user's settings.
|
||||||
|
if (this.enabled && (anonymity == Anonymity.Disabled || anonymity == Anonymity.Anonymous)) {
|
||||||
|
// when transitioning to Disabled or Anonymous ensure we clear out any prior state
|
||||||
|
// set in posthog e.g. distinct ID
|
||||||
|
this.posthog.reset();
|
||||||
|
// Restore any previously set platform super properties
|
||||||
|
this.registerSuperProperties(this.platformSuperProperties);
|
||||||
|
}
|
||||||
|
this.anonymity = anonymity;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async identifyUser(userId: string): Promise<void> {
|
||||||
|
if (this.anonymity == Anonymity.Pseudonymous) {
|
||||||
|
this.posthog.identify(await hashHex(userId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public getAnonymity(): Anonymity {
|
||||||
|
return this.anonymity;
|
||||||
|
}
|
||||||
|
|
||||||
|
public logout(): void {
|
||||||
|
if (this.enabled) {
|
||||||
|
this.posthog.reset();
|
||||||
|
}
|
||||||
|
this.setAnonymity(Anonymity.Anonymous);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async trackPseudonymousEvent<E extends IPseudonymousEvent>(
|
||||||
|
eventName: E["eventName"],
|
||||||
|
properties: E["properties"] = {},
|
||||||
|
) {
|
||||||
|
if (this.anonymity == Anonymity.Anonymous || this.anonymity == Anonymity.Disabled) return;
|
||||||
|
await this.capture(eventName, properties);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async trackAnonymousEvent<E extends IAnonymousEvent>(
|
||||||
|
eventName: E["eventName"],
|
||||||
|
properties: E["properties"] = {},
|
||||||
|
): Promise<void> {
|
||||||
|
if (this.anonymity == Anonymity.Disabled) return;
|
||||||
|
await this.capture(eventName, properties);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async trackRoomEvent<E extends IRoomEvent>(
|
||||||
|
eventName: E["eventName"],
|
||||||
|
roomId: string,
|
||||||
|
properties: Omit<E["properties"], "roomId">,
|
||||||
|
): Promise<void> {
|
||||||
|
const updatedProperties = {
|
||||||
|
...properties,
|
||||||
|
hashedRoomId: roomId ? await hashHex(roomId) : null,
|
||||||
|
};
|
||||||
|
await this.trackPseudonymousEvent(eventName, updatedProperties);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async trackPageView(durationMs: number): Promise<void> {
|
||||||
|
const hash = window.location.hash;
|
||||||
|
|
||||||
|
let screen = null;
|
||||||
|
const split = hash.split("/");
|
||||||
|
if (split.length >= 2) {
|
||||||
|
screen = split[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.trackAnonymousEvent<IPageView>("$pageview", {
|
||||||
|
durationMs,
|
||||||
|
screen,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async updatePlatformSuperProperties(): Promise<void> {
|
||||||
|
// Update super properties in posthog with our platform (app version, platform).
|
||||||
|
// These properties will be subsequently passed in every event.
|
||||||
|
//
|
||||||
|
// This only needs to be done once per page lifetime. Note that getPlatformProperties
|
||||||
|
// is async and can involve a network request if we are running in a browser.
|
||||||
|
this.platformSuperProperties = await PosthogAnalytics.getPlatformProperties();
|
||||||
|
this.registerSuperProperties(this.platformSuperProperties);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async updateAnonymityFromSettings(userId?: string): Promise<void> {
|
||||||
|
// Update this.anonymity based on the user's analytics opt-in settings
|
||||||
|
// Identify the user (via hashed user ID) to posthog if anonymity is pseudonmyous
|
||||||
|
this.setAnonymity(PosthogAnalytics.getAnonymityFromSettings());
|
||||||
|
if (userId && this.getAnonymity() == Anonymity.Pseudonymous) {
|
||||||
|
await this.identifyUser(userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -25,11 +25,44 @@ import { Action } from './dispatcher/actions';
|
||||||
import defaultDispatcher from './dispatcher/dispatcher';
|
import defaultDispatcher from './dispatcher/dispatcher';
|
||||||
import { SetRightPanelPhasePayload } from './dispatcher/payloads/SetRightPanelPhasePayload';
|
import { SetRightPanelPhasePayload } from './dispatcher/payloads/SetRightPanelPhasePayload';
|
||||||
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||||
|
import { MatrixClientPeg } from "./MatrixClientPeg";
|
||||||
|
|
||||||
// These functions are frequently used just to check whether an event has
|
// These functions are frequently used just to check whether an event has
|
||||||
// any text to display at all. For this reason they return deferred values
|
// any text to display at all. For this reason they return deferred values
|
||||||
// to avoid the expense of looking up translations when they're not needed.
|
// to avoid the expense of looking up translations when they're not needed.
|
||||||
|
|
||||||
|
function textForCallInviteEvent(event: MatrixEvent): () => string | null {
|
||||||
|
const getSenderName = () => event.sender ? event.sender.name : _t('Someone');
|
||||||
|
// FIXME: Find a better way to determine this from the event?
|
||||||
|
let isVoice = true;
|
||||||
|
if (event.getContent().offer && event.getContent().offer.sdp &&
|
||||||
|
event.getContent().offer.sdp.indexOf('m=video') !== -1) {
|
||||||
|
isVoice = false;
|
||||||
|
}
|
||||||
|
const isSupported = MatrixClientPeg.get().supportsVoip();
|
||||||
|
|
||||||
|
// This ladder could be reduced down to a couple string variables, however other languages
|
||||||
|
// can have a hard time translating those strings. In an effort to make translations easier
|
||||||
|
// and more accurate, we break out the string-based variables to a couple booleans.
|
||||||
|
if (isVoice && isSupported) {
|
||||||
|
return () => _t("%(senderName)s placed a voice call.", {
|
||||||
|
senderName: getSenderName(),
|
||||||
|
});
|
||||||
|
} else if (isVoice && !isSupported) {
|
||||||
|
return () => _t("%(senderName)s placed a voice call. (not supported by this browser)", {
|
||||||
|
senderName: getSenderName(),
|
||||||
|
});
|
||||||
|
} else if (!isVoice && isSupported) {
|
||||||
|
return () => _t("%(senderName)s placed a video call.", {
|
||||||
|
senderName: getSenderName(),
|
||||||
|
});
|
||||||
|
} else if (!isVoice && !isSupported) {
|
||||||
|
return () => _t("%(senderName)s placed a video call. (not supported by this browser)", {
|
||||||
|
senderName: getSenderName(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function textForMemberEvent(ev: MatrixEvent, allowJSX: boolean, showHiddenEvents?: boolean): () => string | null {
|
function textForMemberEvent(ev: MatrixEvent, allowJSX: boolean, showHiddenEvents?: boolean): () => string | null {
|
||||||
// XXX: SYJS-16 "sender is sometimes null for join messages"
|
// XXX: SYJS-16 "sender is sometimes null for join messages"
|
||||||
const senderName = ev.sender ? ev.sender.name : ev.getSender();
|
const senderName = ev.sender ? ev.sender.name : ev.getSender();
|
||||||
|
@ -567,6 +600,7 @@ interface IHandlers {
|
||||||
|
|
||||||
const handlers: IHandlers = {
|
const handlers: IHandlers = {
|
||||||
'm.room.message': textForMessageEvent,
|
'm.room.message': textForMessageEvent,
|
||||||
|
'm.call.invite': textForCallInviteEvent,
|
||||||
};
|
};
|
||||||
|
|
||||||
const stateHandlers: IHandlers = {
|
const stateHandlers: IHandlers = {
|
||||||
|
|
|
@ -150,13 +150,14 @@ const reducer = (state: IState, action: IAction) => {
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
handleHomeEnd?: boolean;
|
handleHomeEnd?: boolean;
|
||||||
|
handleUpDown?: boolean;
|
||||||
children(renderProps: {
|
children(renderProps: {
|
||||||
onKeyDownHandler(ev: React.KeyboardEvent);
|
onKeyDownHandler(ev: React.KeyboardEvent);
|
||||||
});
|
});
|
||||||
onKeyDown?(ev: React.KeyboardEvent, state: IState);
|
onKeyDown?(ev: React.KeyboardEvent, state: IState);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RovingTabIndexProvider: React.FC<IProps> = ({ children, handleHomeEnd, onKeyDown }) => {
|
export const RovingTabIndexProvider: React.FC<IProps> = ({ children, handleHomeEnd, handleUpDown, onKeyDown }) => {
|
||||||
const [state, dispatch] = useReducer<Reducer<IState, IAction>>(reducer, {
|
const [state, dispatch] = useReducer<Reducer<IState, IAction>>(reducer, {
|
||||||
activeRef: null,
|
activeRef: null,
|
||||||
refs: [],
|
refs: [],
|
||||||
|
@ -167,22 +168,51 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({ children, handleHomeE
|
||||||
const onKeyDownHandler = useCallback((ev) => {
|
const onKeyDownHandler = useCallback((ev) => {
|
||||||
let handled = false;
|
let handled = false;
|
||||||
// Don't interfere with input default keydown behaviour
|
// Don't interfere with input default keydown behaviour
|
||||||
if (handleHomeEnd && ev.target.tagName !== "INPUT" && ev.target.tagName !== "TEXTAREA") {
|
if (ev.target.tagName !== "INPUT" && ev.target.tagName !== "TEXTAREA") {
|
||||||
// check if we actually have any items
|
// check if we actually have any items
|
||||||
switch (ev.key) {
|
switch (ev.key) {
|
||||||
case Key.HOME:
|
case Key.HOME:
|
||||||
|
if (handleHomeEnd) {
|
||||||
handled = true;
|
handled = true;
|
||||||
// move focus to first item
|
// move focus to first item
|
||||||
if (context.state.refs.length > 0) {
|
if (context.state.refs.length > 0) {
|
||||||
context.state.refs[0].current.focus();
|
context.state.refs[0].current.focus();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case Key.END:
|
case Key.END:
|
||||||
|
if (handleHomeEnd) {
|
||||||
handled = true;
|
handled = true;
|
||||||
// move focus to last item
|
// move focus to last item
|
||||||
if (context.state.refs.length > 0) {
|
if (context.state.refs.length > 0) {
|
||||||
context.state.refs[context.state.refs.length - 1].current.focus();
|
context.state.refs[context.state.refs.length - 1].current.focus();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case Key.ARROW_UP:
|
||||||
|
if (handleUpDown) {
|
||||||
|
handled = true;
|
||||||
|
if (context.state.refs.length > 0) {
|
||||||
|
const idx = context.state.refs.indexOf(context.state.activeRef);
|
||||||
|
if (idx > 0) {
|
||||||
|
context.state.refs[idx - 1].current.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case Key.ARROW_DOWN:
|
||||||
|
if (handleUpDown) {
|
||||||
|
handled = true;
|
||||||
|
if (context.state.refs.length > 0) {
|
||||||
|
const idx = context.state.refs.indexOf(context.state.activeRef);
|
||||||
|
if (idx < context.state.refs.length - 1) {
|
||||||
|
context.state.refs[idx + 1].current.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -193,7 +223,7 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({ children, handleHomeE
|
||||||
} else if (onKeyDown) {
|
} else if (onKeyDown) {
|
||||||
return onKeyDown(ev, context.state);
|
return onKeyDown(ev, context.state);
|
||||||
}
|
}
|
||||||
}, [context.state, onKeyDown, handleHomeEnd]);
|
}, [context.state, onKeyDown, handleHomeEnd, handleUpDown]);
|
||||||
|
|
||||||
return <RovingTabIndexContext.Provider value={context}>
|
return <RovingTabIndexContext.Provider value={context}>
|
||||||
{ children({ onKeyDownHandler }) }
|
{ children({ onKeyDownHandler }) }
|
||||||
|
|
|
@ -38,17 +38,9 @@ function makePlaybackWaveform(input: number[]): number[] {
|
||||||
// First, convert negative amplitudes to positive so we don't detect zero as "noisy".
|
// First, convert negative amplitudes to positive so we don't detect zero as "noisy".
|
||||||
const noiseWaveform = input.map(v => Math.abs(v));
|
const noiseWaveform = input.map(v => Math.abs(v));
|
||||||
|
|
||||||
// Next, we'll resample the waveform using a smoothing approach so we can keep the same rough shape.
|
// Then, we'll resample the waveform using a smoothing approach so we can keep the same rough shape.
|
||||||
// We also rescale the waveform to be 0-1 for the remaining function logic.
|
// We also rescale the waveform to be 0-1 so we end up with a clamped waveform to rely upon.
|
||||||
const resampled = arrayRescale(arraySmoothingResample(noiseWaveform, PLAYBACK_WAVEFORM_SAMPLES), 0, 1);
|
return arrayRescale(arraySmoothingResample(noiseWaveform, PLAYBACK_WAVEFORM_SAMPLES), 0, 1);
|
||||||
|
|
||||||
// Then, we'll do a high and low pass filter to isolate actual speaking volumes within the rescaled
|
|
||||||
// waveform. Most speech happens below the 0.5 mark.
|
|
||||||
const filtered = resampled.map(v => clamp(v, 0.1, 0.5));
|
|
||||||
|
|
||||||
// Finally, we'll rescale the filtered waveform (0.1-0.5 becomes 0-1 again) so the user sees something
|
|
||||||
// sensible. This is what we return to keep our contract of "values between zero and one".
|
|
||||||
return arrayRescale(filtered, 0, 1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Playback extends EventEmitter implements IDestroyable {
|
export class Playback extends EventEmitter implements IDestroyable {
|
||||||
|
|
|
@ -30,6 +30,7 @@ import { IEncryptedFile } from "matrix-js-sdk/src/@types/event";
|
||||||
import { uploadFile } from "../ContentMessages";
|
import { uploadFile } from "../ContentMessages";
|
||||||
import { FixedRollingArray } from "../utils/FixedRollingArray";
|
import { FixedRollingArray } from "../utils/FixedRollingArray";
|
||||||
import { clamp } from "../utils/numbers";
|
import { clamp } from "../utils/numbers";
|
||||||
|
import mxRecorderWorkletPath from "./RecorderWorklet";
|
||||||
|
|
||||||
const CHANNELS = 1; // stereo isn't important
|
const CHANNELS = 1; // stereo isn't important
|
||||||
export const SAMPLE_RATE = 48000; // 48khz is what WebRTC uses. 12khz is where we lose quality.
|
export const SAMPLE_RATE = 48000; // 48khz is what WebRTC uses. 12khz is where we lose quality.
|
||||||
|
@ -113,16 +114,10 @@ export class VoiceRecording extends EventEmitter implements IDestroyable {
|
||||||
});
|
});
|
||||||
this.recorderSource = this.recorderContext.createMediaStreamSource(this.recorderStream);
|
this.recorderSource = this.recorderContext.createMediaStreamSource(this.recorderStream);
|
||||||
|
|
||||||
// Set up our worklet. We use this for timing information and waveform analysis: the
|
|
||||||
// web audio API prefers this be done async to avoid holding the main thread with math.
|
|
||||||
const mxRecorderWorkletPath = document.body.dataset.vectorRecorderWorkletScript;
|
|
||||||
if (!mxRecorderWorkletPath) {
|
|
||||||
// noinspection ExceptionCaughtLocallyJS
|
|
||||||
throw new Error("Unable to create recorder: no worklet script registered");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Connect our inputs and outputs
|
// Connect our inputs and outputs
|
||||||
if (this.recorderContext.audioWorklet) {
|
if (this.recorderContext.audioWorklet) {
|
||||||
|
// Set up our worklet. We use this for timing information and waveform analysis: the
|
||||||
|
// web audio API prefers this be done async to avoid holding the main thread with math.
|
||||||
await this.recorderContext.audioWorklet.addModule(mxRecorderWorkletPath);
|
await this.recorderContext.audioWorklet.addModule(mxRecorderWorkletPath);
|
||||||
this.recorderWorklet = new AudioWorkletNode(this.recorderContext, WORKLET_NAME);
|
this.recorderWorklet = new AudioWorkletNode(this.recorderContext, WORKLET_NAME);
|
||||||
this.recorderSource.connect(this.recorderWorklet);
|
this.recorderSource.connect(this.recorderWorklet);
|
||||||
|
|
|
@ -61,7 +61,9 @@ export default class AutoHideScrollbar extends React.Component<IProps> {
|
||||||
style={style}
|
style={style}
|
||||||
className={["mx_AutoHideScrollbar", className].join(" ")}
|
className={["mx_AutoHideScrollbar", className].join(" ")}
|
||||||
onWheel={onWheel}
|
onWheel={onWheel}
|
||||||
tabIndex={tabIndex}
|
// Firefox sometimes makes this element focusable due to
|
||||||
|
// overflow:scroll;, so force it out of tab order by default.
|
||||||
|
tabIndex={tabIndex ?? -1}
|
||||||
>
|
>
|
||||||
{ children }
|
{ children }
|
||||||
</div>);
|
</div>);
|
||||||
|
|
|
@ -27,9 +27,15 @@ export enum CallEventGrouperEvent {
|
||||||
SilencedChanged = "silenced_changed",
|
SilencedChanged = "silenced_changed",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CONNECTING_STATES = [
|
||||||
|
CallState.Connecting,
|
||||||
|
CallState.WaitLocalMedia,
|
||||||
|
CallState.CreateOffer,
|
||||||
|
CallState.CreateAnswer,
|
||||||
|
];
|
||||||
|
|
||||||
const SUPPORTED_STATES = [
|
const SUPPORTED_STATES = [
|
||||||
CallState.Connected,
|
CallState.Connected,
|
||||||
CallState.Connecting,
|
|
||||||
CallState.Ringing,
|
CallState.Ringing,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
@ -61,6 +67,10 @@ export default class CallEventGrouper extends EventEmitter {
|
||||||
return [...this.events].find((event) => event.getType() === EventType.CallReject);
|
return [...this.events].find((event) => event.getType() === EventType.CallReject);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private get selectAnswer(): MatrixEvent {
|
||||||
|
return [...this.events].find((event) => event.getType() === EventType.CallSelectAnswer);
|
||||||
|
}
|
||||||
|
|
||||||
public get isVoice(): boolean {
|
public get isVoice(): boolean {
|
||||||
const invite = this.invite;
|
const invite = this.invite;
|
||||||
if (!invite) return;
|
if (!invite) return;
|
||||||
|
@ -82,6 +92,11 @@ export default class CallEventGrouper extends EventEmitter {
|
||||||
return Boolean(this.reject);
|
return Boolean(this.reject);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public get duration(): Date {
|
||||||
|
if (!this.hangup || !this.selectAnswer) return;
|
||||||
|
return new Date(this.hangup.getDate().getTime() - this.selectAnswer.getDate().getTime());
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns true if there are only events from the other side - we missed the call
|
* Returns true if there are only events from the other side - we missed the call
|
||||||
*/
|
*/
|
||||||
|
@ -127,7 +142,9 @@ export default class CallEventGrouper extends EventEmitter {
|
||||||
}
|
}
|
||||||
|
|
||||||
private setState = () => {
|
private setState = () => {
|
||||||
if (SUPPORTED_STATES.includes(this.call?.state)) {
|
if (CONNECTING_STATES.includes(this.call?.state)) {
|
||||||
|
this.state = CallState.Connecting;
|
||||||
|
} else if (SUPPORTED_STATES.includes(this.call?.state)) {
|
||||||
this.state = this.call.state;
|
this.state = this.call.state;
|
||||||
} else {
|
} else {
|
||||||
if (this.callWasMissed) this.state = CustomCallState.Missed;
|
if (this.callWasMissed) this.state = CustomCallState.Missed;
|
||||||
|
|
|
@ -16,7 +16,7 @@ See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { CSSProperties, RefObject, useRef, useState } from "react";
|
import React, { CSSProperties, RefObject, SyntheticEvent, useRef, useState } from "react";
|
||||||
import ReactDOM from "react-dom";
|
import ReactDOM from "react-dom";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
|
|
||||||
|
@ -471,10 +471,14 @@ type ContextMenuTuple<T> = [boolean, RefObject<T>, () => void, () => void, (val:
|
||||||
export const useContextMenu = <T extends any = HTMLElement>(): ContextMenuTuple<T> => {
|
export const useContextMenu = <T extends any = HTMLElement>(): ContextMenuTuple<T> => {
|
||||||
const button = useRef<T>(null);
|
const button = useRef<T>(null);
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const open = () => {
|
const open = (ev?: SyntheticEvent) => {
|
||||||
|
ev?.preventDefault();
|
||||||
|
ev?.stopPropagation();
|
||||||
setIsOpen(true);
|
setIsOpen(true);
|
||||||
};
|
};
|
||||||
const close = () => {
|
const close = (ev?: SyntheticEvent) => {
|
||||||
|
ev?.preventDefault();
|
||||||
|
ev?.stopPropagation();
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -41,6 +41,9 @@ import RightPanelStore from "../../stores/RightPanelStore";
|
||||||
import AutoHideScrollbar from "./AutoHideScrollbar";
|
import AutoHideScrollbar from "./AutoHideScrollbar";
|
||||||
import { mediaFromMxc } from "../../customisations/Media";
|
import { mediaFromMxc } from "../../customisations/Media";
|
||||||
import { replaceableComponent } from "../../utils/replaceableComponent";
|
import { replaceableComponent } from "../../utils/replaceableComponent";
|
||||||
|
import { createSpaceFromCommunity } from "../../utils/space";
|
||||||
|
import { Action } from "../../dispatcher/actions";
|
||||||
|
import { RightPanelPhases } from "../../stores/RightPanelStorePhases";
|
||||||
|
|
||||||
const LONG_DESC_PLACEHOLDER = _td(
|
const LONG_DESC_PLACEHOLDER = _td(
|
||||||
`<h1>HTML for your community's page</h1>
|
`<h1>HTML for your community's page</h1>
|
||||||
|
@ -399,6 +402,8 @@ class FeaturedUser extends React.Component {
|
||||||
const GROUP_JOINPOLICY_OPEN = "open";
|
const GROUP_JOINPOLICY_OPEN = "open";
|
||||||
const GROUP_JOINPOLICY_INVITE = "invite";
|
const GROUP_JOINPOLICY_INVITE = "invite";
|
||||||
|
|
||||||
|
const UPGRADE_NOTICE_LS_KEY = "mx_hide_community_upgrade_notice";
|
||||||
|
|
||||||
@replaceableComponent("structures.GroupView")
|
@replaceableComponent("structures.GroupView")
|
||||||
export default class GroupView extends React.Component {
|
export default class GroupView extends React.Component {
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
|
@ -422,6 +427,7 @@ export default class GroupView extends React.Component {
|
||||||
publicityBusy: false,
|
publicityBusy: false,
|
||||||
inviterProfile: null,
|
inviterProfile: null,
|
||||||
showRightPanel: RightPanelStore.getSharedInstance().isOpenForGroup,
|
showRightPanel: RightPanelStore.getSharedInstance().isOpenForGroup,
|
||||||
|
showUpgradeNotice: !localStorage.getItem(UPGRADE_NOTICE_LS_KEY),
|
||||||
};
|
};
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
|
@ -807,6 +813,22 @@ export default class GroupView extends React.Component {
|
||||||
showGroupAddRoomDialog(this.props.groupId);
|
showGroupAddRoomDialog(this.props.groupId);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
_dismissUpgradeNotice = () => {
|
||||||
|
localStorage.setItem(UPGRADE_NOTICE_LS_KEY, "true");
|
||||||
|
this.setState({ showUpgradeNotice: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
_onCreateSpaceClick = () => {
|
||||||
|
createSpaceFromCommunity(this._matrixClient, this.props.groupId);
|
||||||
|
};
|
||||||
|
|
||||||
|
_onAdminsLinkClick = () => {
|
||||||
|
dis.dispatch({
|
||||||
|
action: Action.SetRightPanelPhase,
|
||||||
|
phase: RightPanelPhases.GroupMemberList,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
_getGroupSection() {
|
_getGroupSection() {
|
||||||
const groupSettingsSectionClasses = classnames({
|
const groupSettingsSectionClasses = classnames({
|
||||||
"mx_GroupView_group": this.state.editing,
|
"mx_GroupView_group": this.state.editing,
|
||||||
|
@ -843,10 +865,46 @@ export default class GroupView extends React.Component {
|
||||||
},
|
},
|
||||||
) }
|
) }
|
||||||
</div> : <div />;
|
</div> : <div />;
|
||||||
|
|
||||||
|
let communitiesUpgradeNotice;
|
||||||
|
if (this.state.showUpgradeNotice) {
|
||||||
|
let text;
|
||||||
|
if (this.state.isUserPrivileged) {
|
||||||
|
text = _t("You can create a Space from this community <a>here</a>.", {}, {
|
||||||
|
a: sub => <AccessibleButton onClick={this._onCreateSpaceClick} kind="link">
|
||||||
|
{ sub }
|
||||||
|
</AccessibleButton>,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
text = _t("Ask the <a>admins</a> of this community to make it into a Space " +
|
||||||
|
"and keep a look out for the invite.", {}, {
|
||||||
|
a: sub => <AccessibleButton onClick={this._onAdminsLinkClick} kind="link">
|
||||||
|
{ sub }
|
||||||
|
</AccessibleButton>,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
communitiesUpgradeNotice = <div className="mx_GroupView_spaceUpgradePrompt">
|
||||||
|
<h2>{ _t("Communities can now be made into Spaces") }</h2>
|
||||||
|
<p>
|
||||||
|
{ _t("Spaces are a new way to make a community, with new features coming.") }
|
||||||
|
|
||||||
|
{ text }
|
||||||
|
|
||||||
|
{ _t("Communities won't receive further updates.") }
|
||||||
|
</p>
|
||||||
|
<AccessibleButton
|
||||||
|
className="mx_GroupView_spaceUpgradePrompt_close"
|
||||||
|
onClick={this._dismissUpgradeNotice}
|
||||||
|
/>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
return <div className={groupSettingsSectionClasses}>
|
return <div className={groupSettingsSectionClasses}>
|
||||||
{ header }
|
{ header }
|
||||||
{ hostingSignup }
|
{ hostingSignup }
|
||||||
{ changeDelayWarning }
|
{ changeDelayWarning }
|
||||||
|
{ communitiesUpgradeNotice }
|
||||||
{ this._getJoinableNode() }
|
{ this._getJoinableNode() }
|
||||||
{ this._getLongDescriptionNode() }
|
{ this._getLongDescriptionNode() }
|
||||||
{ this._getRoomsNode() }
|
{ this._getRoomsNode() }
|
||||||
|
|
|
@ -392,9 +392,6 @@ export default class LeftPanel extends React.Component<IProps, IState> {
|
||||||
<IndicatorScrollbar
|
<IndicatorScrollbar
|
||||||
className="mx_LeftPanel_breadcrumbsContainer mx_AutoHideScrollbar"
|
className="mx_LeftPanel_breadcrumbsContainer mx_AutoHideScrollbar"
|
||||||
verticalScrollsHorizontally={true}
|
verticalScrollsHorizontally={true}
|
||||||
// Firefox sometimes makes this element focusable due to
|
|
||||||
// overflow:scroll;, so force it out of tab order.
|
|
||||||
tabIndex={-1}
|
|
||||||
>
|
>
|
||||||
<RoomBreadcrumbs />
|
<RoomBreadcrumbs />
|
||||||
</IndicatorScrollbar>
|
</IndicatorScrollbar>
|
||||||
|
|
|
@ -107,6 +107,7 @@ import UIStore, { UI_EVENTS } from "../../stores/UIStore";
|
||||||
import SoftLogout from './auth/SoftLogout';
|
import SoftLogout from './auth/SoftLogout';
|
||||||
import { makeRoomPermalink } from "../../utils/permalinks/Permalinks";
|
import { makeRoomPermalink } from "../../utils/permalinks/Permalinks";
|
||||||
import { copyPlaintext } from "../../utils/strings";
|
import { copyPlaintext } from "../../utils/strings";
|
||||||
|
import { PosthogAnalytics } from '../../PosthogAnalytics';
|
||||||
|
|
||||||
/** constants for MatrixChat.state.view */
|
/** constants for MatrixChat.state.view */
|
||||||
export enum Views {
|
export enum Views {
|
||||||
|
@ -387,6 +388,10 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||||
if (SettingsStore.getValue("analyticsOptIn")) {
|
if (SettingsStore.getValue("analyticsOptIn")) {
|
||||||
Analytics.enable();
|
Analytics.enable();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PosthogAnalytics.instance.updateAnonymityFromSettings();
|
||||||
|
PosthogAnalytics.instance.updatePlatformSuperProperties();
|
||||||
|
|
||||||
CountlyAnalytics.instance.enable(/* anonymous = */ true);
|
CountlyAnalytics.instance.enable(/* anonymous = */ true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -443,6 +448,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||||
const durationMs = this.stopPageChangeTimer();
|
const durationMs = this.stopPageChangeTimer();
|
||||||
Analytics.trackPageChange(durationMs);
|
Analytics.trackPageChange(durationMs);
|
||||||
CountlyAnalytics.instance.trackPageChange(durationMs);
|
CountlyAnalytics.instance.trackPageChange(durationMs);
|
||||||
|
PosthogAnalytics.instance.trackPageView(durationMs);
|
||||||
}
|
}
|
||||||
if (this.focusComposer) {
|
if (this.focusComposer) {
|
||||||
dis.fire(Action.FocusSendMessageComposer);
|
dis.fire(Action.FocusSendMessageComposer);
|
||||||
|
|
|
@ -51,7 +51,12 @@ import EditorStateTransfer from "../../utils/EditorStateTransfer";
|
||||||
|
|
||||||
const CONTINUATION_MAX_INTERVAL = 5 * 60 * 1000; // 5 minutes
|
const CONTINUATION_MAX_INTERVAL = 5 * 60 * 1000; // 5 minutes
|
||||||
const continuedTypes = [EventType.Sticker, EventType.RoomMessage];
|
const continuedTypes = [EventType.Sticker, EventType.RoomMessage];
|
||||||
const membershipTypes = [EventType.RoomMember, EventType.RoomThirdPartyInvite, EventType.RoomServerAcl];
|
const groupedEvents = [
|
||||||
|
EventType.RoomMember,
|
||||||
|
EventType.RoomThirdPartyInvite,
|
||||||
|
EventType.RoomServerAcl,
|
||||||
|
EventType.RoomPinnedEvents,
|
||||||
|
];
|
||||||
|
|
||||||
// check if there is a previous event and it has the same sender as this event
|
// check if there is a previous event and it has the same sender as this event
|
||||||
// and the types are the same/is in continuedTypes and the time between them is <= CONTINUATION_MAX_INTERVAL
|
// and the types are the same/is in continuedTypes and the time between them is <= CONTINUATION_MAX_INTERVAL
|
||||||
|
@ -1234,7 +1239,7 @@ class RedactionGrouper extends BaseGrouper {
|
||||||
// Wrap consecutive member events in a ListSummary, ignore if redacted
|
// Wrap consecutive member events in a ListSummary, ignore if redacted
|
||||||
class MemberGrouper extends BaseGrouper {
|
class MemberGrouper extends BaseGrouper {
|
||||||
static canStartGroup = function(panel: MessagePanel, ev: MatrixEvent): boolean {
|
static canStartGroup = function(panel: MessagePanel, ev: MatrixEvent): boolean {
|
||||||
return panel.shouldShowEvent(ev) && membershipTypes.includes(ev.getType() as EventType);
|
return panel.shouldShowEvent(ev) && groupedEvents.includes(ev.getType() as EventType);
|
||||||
};
|
};
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
|
@ -1252,7 +1257,7 @@ class MemberGrouper extends BaseGrouper {
|
||||||
if (this.panel.wantsDateSeparator(this.events[0], ev.getDate())) {
|
if (this.panel.wantsDateSeparator(this.events[0], ev.getDate())) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return membershipTypes.includes(ev.getType() as EventType);
|
return groupedEvents.includes(ev.getType() as EventType);
|
||||||
}
|
}
|
||||||
|
|
||||||
public add(ev: MatrixEvent, showHiddenEvents?: boolean): void {
|
public add(ev: MatrixEvent, showHiddenEvents?: boolean): void {
|
||||||
|
|
|
@ -14,7 +14,16 @@ See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import React, {
|
||||||
|
ReactNode,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
KeyboardEvent,
|
||||||
|
KeyboardEventHandler,
|
||||||
|
} from "react";
|
||||||
import { Room } from "matrix-js-sdk/src/models/room";
|
import { Room } from "matrix-js-sdk/src/models/room";
|
||||||
import { RoomHierarchy } from "matrix-js-sdk/src/room-hierarchy";
|
import { RoomHierarchy } from "matrix-js-sdk/src/room-hierarchy";
|
||||||
import { EventType, RoomType } from "matrix-js-sdk/src/@types/event";
|
import { EventType, RoomType } from "matrix-js-sdk/src/@types/event";
|
||||||
|
@ -41,6 +50,8 @@ import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton";
|
||||||
import { linkifyElement } from "../../HtmlUtils";
|
import { linkifyElement } from "../../HtmlUtils";
|
||||||
import { useDispatcher } from "../../hooks/useDispatcher";
|
import { useDispatcher } from "../../hooks/useDispatcher";
|
||||||
import { Action } from "../../dispatcher/actions";
|
import { Action } from "../../dispatcher/actions";
|
||||||
|
import { Key } from "../../Keyboard";
|
||||||
|
import { IState, RovingTabIndexProvider, useRovingTabIndex } from "../../accessibility/RovingTabIndex";
|
||||||
import { getDisplayAliasForRoom } from "./RoomDirectory";
|
import { getDisplayAliasForRoom } from "./RoomDirectory";
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
|
@ -76,6 +87,7 @@ const Tile: React.FC<ITileProps> = ({
|
||||||
|| (room.room_type === RoomType.Space ? _t("Unnamed Space") : _t("Unnamed Room"));
|
|| (room.room_type === RoomType.Space ? _t("Unnamed Space") : _t("Unnamed Room"));
|
||||||
|
|
||||||
const [showChildren, toggleShowChildren] = useStateToggle(true);
|
const [showChildren, toggleShowChildren] = useStateToggle(true);
|
||||||
|
const [onFocus, isActive, ref] = useRovingTabIndex();
|
||||||
|
|
||||||
const onPreviewClick = (ev: ButtonEvent) => {
|
const onPreviewClick = (ev: ButtonEvent) => {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
|
@ -90,11 +102,21 @@ const Tile: React.FC<ITileProps> = ({
|
||||||
|
|
||||||
let button;
|
let button;
|
||||||
if (joinedRoom) {
|
if (joinedRoom) {
|
||||||
button = <AccessibleButton onClick={onPreviewClick} kind="primary_outline">
|
button = <AccessibleButton
|
||||||
|
onClick={onPreviewClick}
|
||||||
|
kind="primary_outline"
|
||||||
|
onFocus={onFocus}
|
||||||
|
tabIndex={isActive ? 0 : -1}
|
||||||
|
>
|
||||||
{ _t("View") }
|
{ _t("View") }
|
||||||
</AccessibleButton>;
|
</AccessibleButton>;
|
||||||
} else if (onJoinClick) {
|
} else if (onJoinClick) {
|
||||||
button = <AccessibleButton onClick={onJoinClick} kind="primary">
|
button = <AccessibleButton
|
||||||
|
onClick={onJoinClick}
|
||||||
|
kind="primary"
|
||||||
|
onFocus={onFocus}
|
||||||
|
tabIndex={isActive ? 0 : -1}
|
||||||
|
>
|
||||||
{ _t("Join") }
|
{ _t("Join") }
|
||||||
</AccessibleButton>;
|
</AccessibleButton>;
|
||||||
}
|
}
|
||||||
|
@ -102,13 +124,13 @@ const Tile: React.FC<ITileProps> = ({
|
||||||
let checkbox;
|
let checkbox;
|
||||||
if (onToggleClick) {
|
if (onToggleClick) {
|
||||||
if (hasPermissions) {
|
if (hasPermissions) {
|
||||||
checkbox = <StyledCheckbox checked={!!selected} onChange={onToggleClick} />;
|
checkbox = <StyledCheckbox checked={!!selected} onChange={onToggleClick} tabIndex={isActive ? 0 : -1} />;
|
||||||
} else {
|
} else {
|
||||||
checkbox = <TextWithTooltip
|
checkbox = <TextWithTooltip
|
||||||
tooltip={_t("You don't have permission")}
|
tooltip={_t("You don't have permission")}
|
||||||
onClick={ev => { ev.stopPropagation(); }}
|
onClick={ev => { ev.stopPropagation(); }}
|
||||||
>
|
>
|
||||||
<StyledCheckbox disabled={true} />
|
<StyledCheckbox disabled={true} tabIndex={isActive ? 0 : -1} />
|
||||||
</TextWithTooltip>;
|
</TextWithTooltip>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -168,8 +190,9 @@ const Tile: React.FC<ITileProps> = ({
|
||||||
</div>
|
</div>
|
||||||
</React.Fragment>;
|
</React.Fragment>;
|
||||||
|
|
||||||
let childToggle;
|
let childToggle: JSX.Element;
|
||||||
let childSection;
|
let childSection: JSX.Element;
|
||||||
|
let onKeyDown: KeyboardEventHandler;
|
||||||
if (children) {
|
if (children) {
|
||||||
// the chevron is purposefully a div rather than a button as it should be ignored for a11y
|
// the chevron is purposefully a div rather than a button as it should be ignored for a11y
|
||||||
childToggle = <div
|
childToggle = <div
|
||||||
|
@ -181,25 +204,74 @@ const Tile: React.FC<ITileProps> = ({
|
||||||
toggleShowChildren();
|
toggleShowChildren();
|
||||||
}}
|
}}
|
||||||
/>;
|
/>;
|
||||||
|
|
||||||
if (showChildren) {
|
if (showChildren) {
|
||||||
childSection = <div className="mx_SpaceHierarchy_subspace_children">
|
const onChildrenKeyDown = (e) => {
|
||||||
|
if (e.key === Key.ARROW_LEFT) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
ref.current?.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
childSection = <div
|
||||||
|
className="mx_SpaceHierarchy_subspace_children"
|
||||||
|
onKeyDown={onChildrenKeyDown}
|
||||||
|
role="group"
|
||||||
|
>
|
||||||
{ children }
|
{ children }
|
||||||
</div>;
|
</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onKeyDown = (e) => {
|
||||||
|
let handled = false;
|
||||||
|
|
||||||
|
switch (e.key) {
|
||||||
|
case Key.ARROW_LEFT:
|
||||||
|
if (showChildren) {
|
||||||
|
handled = true;
|
||||||
|
toggleShowChildren();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case Key.ARROW_RIGHT:
|
||||||
|
handled = true;
|
||||||
|
if (showChildren) {
|
||||||
|
const childSection = ref.current?.nextElementSibling;
|
||||||
|
childSection?.querySelector<HTMLDivElement>(".mx_SpaceRoomDirectory_roomTile")?.focus();
|
||||||
|
} else {
|
||||||
|
toggleShowChildren();
|
||||||
|
}
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <>
|
if (handled) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return <li
|
||||||
|
className="mx_SpaceRoomDirectory_roomTileWrapper"
|
||||||
|
role="treeitem"
|
||||||
|
aria-expanded={children ? showChildren : undefined}
|
||||||
|
>
|
||||||
<AccessibleButton
|
<AccessibleButton
|
||||||
className={classNames("mx_SpaceHierarchy_roomTile", {
|
className={classNames("mx_SpaceHierarchy_roomTile", {
|
||||||
mx_SpaceHierarchy_subspace: room.room_type === RoomType.Space,
|
mx_SpaceHierarchy_subspace: room.room_type === RoomType.Space,
|
||||||
})}
|
})}
|
||||||
onClick={(hasPermissions && onToggleClick) ? onToggleClick : onPreviewClick}
|
onClick={(hasPermissions && onToggleClick) ? onToggleClick : onPreviewClick}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
inputRef={ref}
|
||||||
|
onFocus={onFocus}
|
||||||
|
tabIndex={isActive ? 0 : -1}
|
||||||
>
|
>
|
||||||
{ content }
|
{ content }
|
||||||
{ childToggle }
|
{ childToggle }
|
||||||
</AccessibleButton>
|
</AccessibleButton>
|
||||||
{ childSection }
|
{ childSection }
|
||||||
</>;
|
</li>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const showRoom = (hierarchy: RoomHierarchy, roomId: string, autoJoin = false) => {
|
export const showRoom = (hierarchy: RoomHierarchy, roomId: string, autoJoin = false) => {
|
||||||
|
@ -439,15 +511,27 @@ const SpaceHierarchy = ({
|
||||||
return <p>{ _t("Your server does not support showing space hierarchies.") }</p>;
|
return <p>{ _t("Your server does not support showing space hierarchies.") }</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onKeyDown = (ev: KeyboardEvent, state: IState) => {
|
||||||
|
if (ev.key === Key.ARROW_DOWN && ev.currentTarget.classList.contains("mx_SpaceRoomDirectory_search")) {
|
||||||
|
state.refs[0]?.current?.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// TODO loading state/error state
|
||||||
|
return <RovingTabIndexProvider onKeyDown={onKeyDown} handleHomeEnd handleUpDown>
|
||||||
|
{ ({ onKeyDownHandler }) => {
|
||||||
let content: JSX.Element;
|
let content: JSX.Element;
|
||||||
let loader: JSX.Element;
|
let loader: JSX.Element;
|
||||||
|
|
||||||
if (loading && !rooms.length) {
|
if (loading && !rooms.length) {
|
||||||
content = <Spinner />;
|
content = <Spinner />;
|
||||||
} else {
|
} else {
|
||||||
let manageButtons;
|
let manageButtons;
|
||||||
if (space.getMyMembership() === "join" && space.currentState.maySendStateEvent(EventType.SpaceChild, userId)) {
|
if (space.getMyMembership() === "join" && space.currentState.maySendStateEvent(EventType.SpaceChild, userId)) {
|
||||||
const selectedRelations = Array.from(selected.keys()).flatMap(parentId => {
|
const selectedRelations = Array.from(selected.keys()).flatMap(parentId => {
|
||||||
return [...selected.get(parentId).values()].map(childId => [parentId, childId]) as [string, string][];
|
return [
|
||||||
|
...selected.get(parentId).values(),
|
||||||
|
].map(childId => [parentId, childId]) as [string, string][];
|
||||||
});
|
});
|
||||||
|
|
||||||
const selectionAllSuggested = selectedRelations.every(([parentId, childId]) => {
|
const selectionAllSuggested = selectedRelations.every(([parentId, childId]) => {
|
||||||
|
@ -581,23 +665,27 @@ const SpaceHierarchy = ({
|
||||||
{ error && <div className="mx_SpaceHierarchy_error">
|
{ error && <div className="mx_SpaceHierarchy_error">
|
||||||
{ error }
|
{ error }
|
||||||
</div> }
|
</div> }
|
||||||
|
<ul onKeyDown={onKeyDownHandler} role="tree" aria-label={_t("Space")}>
|
||||||
{ results }
|
{ results }
|
||||||
|
</ul>
|
||||||
{ loader }
|
{ loader }
|
||||||
</>;
|
</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <>
|
return <>
|
||||||
<SearchBox
|
<SearchBox
|
||||||
className="mx_textinput_icon mx_textinput_search"
|
className="mx_SpaceRoomDirectory_search mx_textinput_icon mx_textinput_search"
|
||||||
placeholder={_t("Search names and descriptions")}
|
placeholder={_t("Search names and descriptions")}
|
||||||
onSearch={setQuery}
|
onSearch={setQuery}
|
||||||
autoFocus={true}
|
autoFocus={true}
|
||||||
initialValue={initialText}
|
initialValue={initialText}
|
||||||
|
onKeyDown={onKeyDownHandler}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{ content }
|
{ content }
|
||||||
</>;
|
</>;
|
||||||
|
} }
|
||||||
|
</RovingTabIndexProvider>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default SpaceHierarchy;
|
export default SpaceHierarchy;
|
||||||
|
|
|
@ -74,6 +74,10 @@ import { BetaPill } from "../views/beta/BetaCard";
|
||||||
import { UserTab } from "../views/dialogs/UserSettingsDialog";
|
import { UserTab } from "../views/dialogs/UserSettingsDialog";
|
||||||
import { EffectiveMembership, getEffectiveMembership } from "../../utils/membership";
|
import { EffectiveMembership, getEffectiveMembership } from "../../utils/membership";
|
||||||
import { SpaceFeedbackPrompt } from "../views/spaces/SpaceCreateMenu";
|
import { SpaceFeedbackPrompt } from "../views/spaces/SpaceCreateMenu";
|
||||||
|
import { CreateEventField, IGroupSummary } from "../views/dialogs/CreateSpaceFromCommunityDialog";
|
||||||
|
import { useAsyncMemo } from "../../hooks/useAsyncMemo";
|
||||||
|
import Spinner from "../views/elements/Spinner";
|
||||||
|
import GroupAvatar from "../views/avatars/GroupAvatar";
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
space: Room;
|
space: Room;
|
||||||
|
@ -158,7 +162,33 @@ const onBetaClick = () => {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) => {
|
// XXX: temporary community migration component
|
||||||
|
const GroupTile = ({ groupId }: { groupId: string }) => {
|
||||||
|
const cli = useContext(MatrixClientContext);
|
||||||
|
const groupSummary = useAsyncMemo<IGroupSummary>(() => cli.getGroupSummary(groupId), [cli, groupId]);
|
||||||
|
|
||||||
|
if (!groupSummary) return <Spinner />;
|
||||||
|
|
||||||
|
return <>
|
||||||
|
<GroupAvatar
|
||||||
|
groupId={groupId}
|
||||||
|
groupName={groupSummary.profile.name}
|
||||||
|
groupAvatarUrl={groupSummary.profile.avatar_url}
|
||||||
|
width={16}
|
||||||
|
height={16}
|
||||||
|
resizeMethod='crop'
|
||||||
|
/>
|
||||||
|
{ groupSummary.profile.name }
|
||||||
|
</>;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ISpacePreviewProps {
|
||||||
|
space: Room;
|
||||||
|
onJoinButtonClicked(): void;
|
||||||
|
onRejectButtonClicked(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }: ISpacePreviewProps) => {
|
||||||
const cli = useContext(MatrixClientContext);
|
const cli = useContext(MatrixClientContext);
|
||||||
const myMembership = useMyRoomMembership(space);
|
const myMembership = useMyRoomMembership(space);
|
||||||
|
|
||||||
|
@ -192,11 +222,11 @@ const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) =>
|
||||||
|
|
||||||
if (inviteSender) {
|
if (inviteSender) {
|
||||||
inviterSection = <div className="mx_SpaceRoomView_preview_inviter">
|
inviterSection = <div className="mx_SpaceRoomView_preview_inviter">
|
||||||
<MemberAvatar member={inviter} width={32} height={32} />
|
<MemberAvatar member={inviter} fallbackUserId={inviteSender} width={32} height={32} />
|
||||||
<div>
|
<div>
|
||||||
<div className="mx_SpaceRoomView_preview_inviter_name">
|
<div className="mx_SpaceRoomView_preview_inviter_name">
|
||||||
{ _t("<inviter/> invites you", {}, {
|
{ _t("<inviter/> invites you", {}, {
|
||||||
inviter: () => <b>{ inviter.name || inviteSender }</b>,
|
inviter: () => <b>{ inviter?.name || inviteSender }</b>,
|
||||||
}) }
|
}) }
|
||||||
</div>
|
</div>
|
||||||
{ inviter ? <div className="mx_SpaceRoomView_preview_inviter_mxid">
|
{ inviter ? <div className="mx_SpaceRoomView_preview_inviter_mxid">
|
||||||
|
@ -270,8 +300,18 @@ const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) =>
|
||||||
</div>;
|
</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let migratedCommunitySection: JSX.Element;
|
||||||
|
const createContent = space.currentState.getStateEvents(EventType.RoomCreate, "")?.getContent();
|
||||||
|
if (createContent[CreateEventField]) {
|
||||||
|
migratedCommunitySection = <div className="mx_SpaceRoomView_preview_migratedCommunity">
|
||||||
|
{ _t("Created from <Community />", {}, {
|
||||||
|
Community: () => <GroupTile groupId={createContent[CreateEventField]} />,
|
||||||
|
}) }
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
return <div className="mx_SpaceRoomView_preview">
|
return <div className="mx_SpaceRoomView_preview">
|
||||||
<BetaPill onClick={onBetaClick} />
|
{ migratedCommunitySection }
|
||||||
{ inviterSection }
|
{ inviterSection }
|
||||||
<RoomAvatar room={space} height={80} width={80} viewAvatarOnClick={true} />
|
<RoomAvatar room={space} height={80} width={80} viewAvatarOnClick={true} />
|
||||||
<h1 className="mx_SpaceRoomView_preview_name">
|
<h1 className="mx_SpaceRoomView_preview_name">
|
||||||
|
|
|
@ -757,16 +757,20 @@ class TimelinePanel extends React.Component<IProps, IState> {
|
||||||
}
|
}
|
||||||
this.lastRMSentEventId = this.state.readMarkerEventId;
|
this.lastRMSentEventId = this.state.readMarkerEventId;
|
||||||
|
|
||||||
|
const roomId = this.props.timelineSet.room.roomId;
|
||||||
|
const hiddenRR = SettingsStore.getValue("feature_hidden_read_receipts", roomId);
|
||||||
|
|
||||||
debuglog('TimelinePanel: Sending Read Markers for ',
|
debuglog('TimelinePanel: Sending Read Markers for ',
|
||||||
this.props.timelineSet.room.roomId,
|
this.props.timelineSet.room.roomId,
|
||||||
'rm', this.state.readMarkerEventId,
|
'rm', this.state.readMarkerEventId,
|
||||||
lastReadEvent ? 'rr ' + lastReadEvent.getId() : '',
|
lastReadEvent ? 'rr ' + lastReadEvent.getId() : '',
|
||||||
|
' hidden:' + hiddenRR,
|
||||||
);
|
);
|
||||||
MatrixClientPeg.get().setRoomReadMarkers(
|
MatrixClientPeg.get().setRoomReadMarkers(
|
||||||
this.props.timelineSet.room.roomId,
|
roomId,
|
||||||
this.state.readMarkerEventId,
|
this.state.readMarkerEventId,
|
||||||
lastReadEvent, // Could be null, in which case no RR is sent
|
lastReadEvent, // Could be null, in which case no RR is sent
|
||||||
{},
|
{ hidden: hiddenRR },
|
||||||
).catch((e) => {
|
).catch((e) => {
|
||||||
// /read_markers API is not implemented on this HS, fallback to just RR
|
// /read_markers API is not implemented on this HS, fallback to just RR
|
||||||
if (e.errcode === 'M_UNRECOGNIZED' && lastReadEvent) {
|
if (e.errcode === 'M_UNRECOGNIZED' && lastReadEvent) {
|
||||||
|
|
|
@ -58,28 +58,39 @@ export default class ToastContainer extends React.Component<{}, IState> {
|
||||||
let containerClasses;
|
let containerClasses;
|
||||||
if (totalCount !== 0) {
|
if (totalCount !== 0) {
|
||||||
const topToast = this.state.toasts[0];
|
const topToast = this.state.toasts[0];
|
||||||
const { title, icon, key, component, className, props } = topToast;
|
const { title, icon, key, component, className, bodyClassName, props } = topToast;
|
||||||
const toastClasses = classNames("mx_Toast_toast", {
|
const bodyClasses = classNames("mx_Toast_body", bodyClassName);
|
||||||
|
const toastClasses = classNames("mx_Toast_toast", className, {
|
||||||
"mx_Toast_hasIcon": icon,
|
"mx_Toast_hasIcon": icon,
|
||||||
[`mx_Toast_icon_${icon}`]: icon,
|
[`mx_Toast_icon_${icon}`]: icon,
|
||||||
}, className);
|
});
|
||||||
|
|
||||||
let countIndicator;
|
|
||||||
if (isStacked || this.state.countSeen > 0) {
|
|
||||||
countIndicator = ` (${this.state.countSeen + 1}/${this.state.countSeen + totalCount})`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const toastProps = Object.assign({}, props, {
|
const toastProps = Object.assign({}, props, {
|
||||||
key,
|
key,
|
||||||
toastKey: key,
|
toastKey: key,
|
||||||
});
|
});
|
||||||
toast = (<div className={toastClasses}>
|
const content = React.createElement(component, toastProps);
|
||||||
|
|
||||||
|
let countIndicator;
|
||||||
|
if (title && isStacked || this.state.countSeen > 0) {
|
||||||
|
countIndicator = ` (${this.state.countSeen + 1}/${this.state.countSeen + totalCount})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let titleElement;
|
||||||
|
if (title) {
|
||||||
|
titleElement = (
|
||||||
<div className="mx_Toast_title">
|
<div className="mx_Toast_title">
|
||||||
<h2>{ title }</h2>
|
<h2>{ title }</h2>
|
||||||
<span>{ countIndicator }</span>
|
<span>{ countIndicator }</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mx_Toast_body">{ React.createElement(component, toastProps) }</div>
|
);
|
||||||
</div>);
|
}
|
||||||
|
|
||||||
|
toast = (
|
||||||
|
<div className={toastClasses}>
|
||||||
|
{ titleElement }
|
||||||
|
<div className={bodyClasses}>{ content }</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
containerClasses = classNames("mx_ToastContainer", {
|
containerClasses = classNames("mx_ToastContainer", {
|
||||||
"mx_ToastContainer_stacked": isStacked,
|
"mx_ToastContainer_stacked": isStacked,
|
||||||
|
|
|
@ -17,8 +17,7 @@ limitations under the License.
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { IRecordingUpdate, RECORDING_PLAYBACK_SAMPLES, VoiceRecording } from "../../../audio/VoiceRecording";
|
import { IRecordingUpdate, RECORDING_PLAYBACK_SAMPLES, VoiceRecording } from "../../../audio/VoiceRecording";
|
||||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||||
import { arrayFastResample } from "../../../utils/arrays";
|
import { arrayFastResample, arraySeed } from "../../../utils/arrays";
|
||||||
import { percentageOf } from "../../../utils/numbers";
|
|
||||||
import Waveform from "./Waveform";
|
import Waveform from "./Waveform";
|
||||||
import { MarkedExecution } from "../../../utils/MarkedExecution";
|
import { MarkedExecution } from "../../../utils/MarkedExecution";
|
||||||
|
|
||||||
|
@ -48,18 +47,14 @@ export default class LiveRecordingWaveform extends React.PureComponent<IProps, I
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
this.state = {
|
this.state = {
|
||||||
waveform: [],
|
waveform: arraySeed(0, RECORDING_PLAYBACK_SAMPLES),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
this.props.recorder.liveData.onUpdate((update: IRecordingUpdate) => {
|
this.props.recorder.liveData.onUpdate((update: IRecordingUpdate) => {
|
||||||
const bars = arrayFastResample(Array.from(update.waveform), RECORDING_PLAYBACK_SAMPLES);
|
// The incoming data is between zero and one, so we don't need to clamp/rescale it.
|
||||||
// The incoming data is between zero and one, but typically even screaming into a
|
this.waveform = arrayFastResample(Array.from(update.waveform), RECORDING_PLAYBACK_SAMPLES);
|
||||||
// microphone won't send you over 0.6, so we artificially adjust the gain for the
|
|
||||||
// waveform. This results in a slightly more cinematic/animated waveform for the
|
|
||||||
// user.
|
|
||||||
this.waveform = bars.map(b => percentageOf(b, 0, 0.50));
|
|
||||||
this.scheduledUpdate.mark();
|
this.scheduledUpdate.mark();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -49,6 +49,13 @@ export default class DialpadContextMenu extends React.Component<IProps, IState>
|
||||||
this.props.onFinished();
|
this.props.onFinished();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
onKeyDown = (ev) => {
|
||||||
|
// Prevent Backspace and Delete keys from functioning in the entry field
|
||||||
|
if (ev.code === "Backspace" || ev.code === "Delete") {
|
||||||
|
ev.preventDefault();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
onChange = (ev) => {
|
onChange = (ev) => {
|
||||||
this.setState({ value: ev.target.value });
|
this.setState({ value: ev.target.value });
|
||||||
};
|
};
|
||||||
|
@ -64,6 +71,7 @@ export default class DialpadContextMenu extends React.Component<IProps, IState>
|
||||||
className="mx_DialPadContextMenu_dialled"
|
className="mx_DialPadContextMenu_dialled"
|
||||||
value={this.state.value}
|
value={this.state.value}
|
||||||
autoFocus={true}
|
autoFocus={true}
|
||||||
|
onKeyDown={this.onKeyDown}
|
||||||
onChange={this.onChange}
|
onChange={this.onChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -86,7 +86,10 @@ export const IconizedContextMenuCheckbox: React.FC<ICheckboxProps> = ({
|
||||||
>
|
>
|
||||||
<span className={classNames("mx_IconizedContextMenu_icon", iconClassName)} />
|
<span className={classNames("mx_IconizedContextMenu_icon", iconClassName)} />
|
||||||
<span className="mx_IconizedContextMenu_label">{ label }</span>
|
<span className="mx_IconizedContextMenu_label">{ label }</span>
|
||||||
{ active && <span className="mx_IconizedContextMenu_icon mx_IconizedContextMenu_checked" /> }
|
<span className={classNames("mx_IconizedContextMenu_icon", {
|
||||||
|
mx_IconizedContextMenu_checked: active,
|
||||||
|
mx_IconizedContextMenu_unchecked: !active,
|
||||||
|
})} />
|
||||||
</MenuItemCheckbox>;
|
</MenuItemCheckbox>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
216
src/components/views/context_menus/SpaceContextMenu.tsx
Normal file
216
src/components/views/context_menus/SpaceContextMenu.tsx
Normal file
|
@ -0,0 +1,216 @@
|
||||||
|
/*
|
||||||
|
Copyright 2021 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 React, { useContext } from "react";
|
||||||
|
import { Room } from "matrix-js-sdk/src/models/room";
|
||||||
|
import { EventType } from "matrix-js-sdk/src/@types/event";
|
||||||
|
|
||||||
|
import {
|
||||||
|
IProps as IContextMenuProps,
|
||||||
|
} from "../../structures/ContextMenu";
|
||||||
|
import IconizedContextMenu, { IconizedContextMenuOption, IconizedContextMenuOptionList } from "./IconizedContextMenu";
|
||||||
|
import { _t } from "../../../languageHandler";
|
||||||
|
import {
|
||||||
|
leaveSpace,
|
||||||
|
shouldShowSpaceSettings,
|
||||||
|
showAddExistingRooms,
|
||||||
|
showCreateNewRoom,
|
||||||
|
showCreateNewSubspace,
|
||||||
|
showSpaceInvite,
|
||||||
|
showSpaceSettings,
|
||||||
|
} from "../../../utils/space";
|
||||||
|
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||||
|
import { ButtonEvent } from "../elements/AccessibleButton";
|
||||||
|
import defaultDispatcher from "../../../dispatcher/dispatcher";
|
||||||
|
import RoomViewStore from "../../../stores/RoomViewStore";
|
||||||
|
import { SetRightPanelPhasePayload } from "../../../dispatcher/payloads/SetRightPanelPhasePayload";
|
||||||
|
import { Action } from "../../../dispatcher/actions";
|
||||||
|
import { RightPanelPhases } from "../../../stores/RightPanelStorePhases";
|
||||||
|
import { BetaPill } from "../beta/BetaCard";
|
||||||
|
|
||||||
|
interface IProps extends IContextMenuProps {
|
||||||
|
space: Room;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SpaceContextMenu = ({ space, onFinished, ...props }: IProps) => {
|
||||||
|
const cli = useContext(MatrixClientContext);
|
||||||
|
const userId = cli.getUserId();
|
||||||
|
|
||||||
|
let inviteOption;
|
||||||
|
if (space.getJoinRule() === "public" || space.canInvite(userId)) {
|
||||||
|
const onInviteClick = (ev: ButtonEvent) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
|
||||||
|
showSpaceInvite(space);
|
||||||
|
onFinished();
|
||||||
|
};
|
||||||
|
|
||||||
|
inviteOption = (
|
||||||
|
<IconizedContextMenuOption
|
||||||
|
className="mx_SpacePanel_contextMenu_inviteButton"
|
||||||
|
iconClassName="mx_SpacePanel_iconInvite"
|
||||||
|
label={_t("Invite people")}
|
||||||
|
onClick={onInviteClick}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let settingsOption;
|
||||||
|
let leaveSection;
|
||||||
|
if (shouldShowSpaceSettings(space)) {
|
||||||
|
const onSettingsClick = (ev: ButtonEvent) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
|
||||||
|
showSpaceSettings(space);
|
||||||
|
onFinished();
|
||||||
|
};
|
||||||
|
|
||||||
|
settingsOption = (
|
||||||
|
<IconizedContextMenuOption
|
||||||
|
iconClassName="mx_SpacePanel_iconSettings"
|
||||||
|
label={_t("Settings")}
|
||||||
|
onClick={onSettingsClick}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const onLeaveClick = (ev: ButtonEvent) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
|
||||||
|
leaveSpace(space);
|
||||||
|
onFinished();
|
||||||
|
};
|
||||||
|
|
||||||
|
leaveSection = <IconizedContextMenuOptionList red first>
|
||||||
|
<IconizedContextMenuOption
|
||||||
|
iconClassName="mx_SpacePanel_iconLeave"
|
||||||
|
label={_t("Leave space")}
|
||||||
|
onClick={onLeaveClick}
|
||||||
|
/>
|
||||||
|
</IconizedContextMenuOptionList>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const canAddRooms = space.currentState.maySendStateEvent(EventType.SpaceChild, userId);
|
||||||
|
|
||||||
|
let newRoomSection;
|
||||||
|
if (space.currentState.maySendStateEvent(EventType.SpaceChild, userId)) {
|
||||||
|
const onNewRoomClick = (ev: ButtonEvent) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
|
||||||
|
showCreateNewRoom(space);
|
||||||
|
onFinished();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onAddExistingRoomClick = (ev: ButtonEvent) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
|
||||||
|
showAddExistingRooms(space);
|
||||||
|
onFinished();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onNewSubspaceClick = (ev: ButtonEvent) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
|
||||||
|
showCreateNewSubspace(space);
|
||||||
|
onFinished();
|
||||||
|
};
|
||||||
|
|
||||||
|
newRoomSection = <IconizedContextMenuOptionList first>
|
||||||
|
<IconizedContextMenuOption
|
||||||
|
iconClassName="mx_SpacePanel_iconPlus"
|
||||||
|
label={_t("Create new room")}
|
||||||
|
onClick={onNewRoomClick}
|
||||||
|
/>
|
||||||
|
<IconizedContextMenuOption
|
||||||
|
iconClassName="mx_SpacePanel_iconHash"
|
||||||
|
label={_t("Add existing room")}
|
||||||
|
onClick={onAddExistingRoomClick}
|
||||||
|
/>
|
||||||
|
<IconizedContextMenuOption
|
||||||
|
iconClassName="mx_SpacePanel_iconPlus"
|
||||||
|
label={_t("Add space")}
|
||||||
|
onClick={onNewSubspaceClick}
|
||||||
|
>
|
||||||
|
<BetaPill />
|
||||||
|
</IconizedContextMenuOption>
|
||||||
|
</IconizedContextMenuOptionList>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const onMembersClick = (ev: ButtonEvent) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
|
||||||
|
if (!RoomViewStore.getRoomId()) {
|
||||||
|
defaultDispatcher.dispatch({
|
||||||
|
action: "view_room",
|
||||||
|
room_id: space.roomId,
|
||||||
|
}, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultDispatcher.dispatch<SetRightPanelPhasePayload>({
|
||||||
|
action: Action.SetRightPanelPhase,
|
||||||
|
phase: RightPanelPhases.SpaceMemberList,
|
||||||
|
refireParams: { space: space },
|
||||||
|
});
|
||||||
|
onFinished();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onExploreRoomsClick = (ev: ButtonEvent) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
|
||||||
|
defaultDispatcher.dispatch({
|
||||||
|
action: "view_room",
|
||||||
|
room_id: space.roomId,
|
||||||
|
});
|
||||||
|
onFinished();
|
||||||
|
};
|
||||||
|
|
||||||
|
return <IconizedContextMenu
|
||||||
|
{...props}
|
||||||
|
onFinished={onFinished}
|
||||||
|
className="mx_SpacePanel_contextMenu"
|
||||||
|
compact
|
||||||
|
>
|
||||||
|
<div className="mx_SpacePanel_contextMenu_header">
|
||||||
|
{ space.name }
|
||||||
|
</div>
|
||||||
|
<IconizedContextMenuOptionList first>
|
||||||
|
{ inviteOption }
|
||||||
|
<IconizedContextMenuOption
|
||||||
|
iconClassName="mx_SpacePanel_iconMembers"
|
||||||
|
label={_t("Members")}
|
||||||
|
onClick={onMembersClick}
|
||||||
|
/>
|
||||||
|
{ settingsOption }
|
||||||
|
<IconizedContextMenuOption
|
||||||
|
iconClassName="mx_SpacePanel_iconExplore"
|
||||||
|
label={canAddRooms ? _t("Manage & explore rooms") : _t("Explore rooms")}
|
||||||
|
onClick={onExploreRoomsClick}
|
||||||
|
/>
|
||||||
|
</IconizedContextMenuOptionList>
|
||||||
|
{ newRoomSection }
|
||||||
|
{ leaveSection }
|
||||||
|
</IconizedContextMenu>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SpaceContextMenu;
|
||||||
|
|
|
@ -24,6 +24,8 @@ import { MenuItem } from "../../structures/ContextMenu";
|
||||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||||
import GroupFilterOrderStore from "../../../stores/GroupFilterOrderStore";
|
import GroupFilterOrderStore from "../../../stores/GroupFilterOrderStore";
|
||||||
|
import { createSpaceFromCommunity } from "../../../utils/space";
|
||||||
|
import GroupStore from "../../../stores/GroupStore";
|
||||||
|
|
||||||
@replaceableComponent("views.context_menus.TagTileContextMenu")
|
@replaceableComponent("views.context_menus.TagTileContextMenu")
|
||||||
export default class TagTileContextMenu extends React.Component {
|
export default class TagTileContextMenu extends React.Component {
|
||||||
|
@ -49,6 +51,11 @@ export default class TagTileContextMenu extends React.Component {
|
||||||
this.props.onFinished();
|
this.props.onFinished();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
_onCreateSpaceClick = () => {
|
||||||
|
createSpaceFromCommunity(this.context, this.props.tag);
|
||||||
|
this.props.onFinished();
|
||||||
|
};
|
||||||
|
|
||||||
_onMoveUp = () => {
|
_onMoveUp = () => {
|
||||||
dis.dispatch(TagOrderActions.moveTag(this.context, this.props.tag, this.props.index - 1));
|
dis.dispatch(TagOrderActions.moveTag(this.context, this.props.tag, this.props.index - 1));
|
||||||
this.props.onFinished();
|
this.props.onFinished();
|
||||||
|
@ -77,6 +84,16 @@ export default class TagTileContextMenu extends React.Component {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let createSpaceOption;
|
||||||
|
if (GroupStore.isUserPrivileged(this.props.tag)) {
|
||||||
|
createSpaceOption = <>
|
||||||
|
<hr className="mx_TagTileContextMenu_separator" role="separator" />
|
||||||
|
<MenuItem className="mx_TagTileContextMenu_item mx_TagTileContextMenu_createSpace" onClick={this._onCreateSpaceClick}>
|
||||||
|
{ _t("Create Space") }
|
||||||
|
</MenuItem>
|
||||||
|
</>;
|
||||||
|
}
|
||||||
|
|
||||||
return <div>
|
return <div>
|
||||||
<MenuItem className="mx_TagTileContextMenu_item mx_TagTileContextMenu_viewCommunity" onClick={this._onViewCommunityClick}>
|
<MenuItem className="mx_TagTileContextMenu_item mx_TagTileContextMenu_viewCommunity" onClick={this._onViewCommunityClick}>
|
||||||
{ _t('View Community') }
|
{ _t('View Community') }
|
||||||
|
@ -88,6 +105,7 @@ export default class TagTileContextMenu extends React.Component {
|
||||||
<MenuItem className="mx_TagTileContextMenu_item mx_TagTileContextMenu_hideCommunity" onClick={this._onRemoveClick}>
|
<MenuItem className="mx_TagTileContextMenu_item mx_TagTileContextMenu_hideCommunity" onClick={this._onRemoveClick}>
|
||||||
{ _t("Unpin") }
|
{ _t("Unpin") }
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
{ createSpaceOption }
|
||||||
</div>;
|
</div>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -116,8 +116,8 @@ export default class CreateRoomDialog extends React.Component<IProps, IState> {
|
||||||
opts.associatedWithCommunity = CommunityPrototypeStore.instance.getSelectedCommunityId();
|
opts.associatedWithCommunity = CommunityPrototypeStore.instance.getSelectedCommunityId();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.props.parentSpace && this.state.joinRule === JoinRule.Restricted) {
|
|
||||||
opts.parentSpace = this.props.parentSpace;
|
opts.parentSpace = this.props.parentSpace;
|
||||||
|
if (this.props.parentSpace && this.state.joinRule === JoinRule.Restricted) {
|
||||||
opts.joinRule = JoinRule.Restricted;
|
opts.joinRule = JoinRule.Restricted;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
340
src/components/views/dialogs/CreateSpaceFromCommunityDialog.tsx
Normal file
340
src/components/views/dialogs/CreateSpaceFromCommunityDialog.tsx
Normal file
|
@ -0,0 +1,340 @@
|
||||||
|
/*
|
||||||
|
Copyright 2021 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 React, { useEffect, useRef, useState } from "react";
|
||||||
|
import { JoinRule } from "matrix-js-sdk/src/@types/partials";
|
||||||
|
import { EventType } from "matrix-js-sdk/src/@types/event";
|
||||||
|
import { MatrixClient } from "matrix-js-sdk/src/matrix";
|
||||||
|
|
||||||
|
import { _t } from '../../../languageHandler';
|
||||||
|
import BaseDialog from "./BaseDialog";
|
||||||
|
import AccessibleButton from "../elements/AccessibleButton";
|
||||||
|
import { createSpace, SpaceCreateForm } from "../spaces/SpaceCreateMenu";
|
||||||
|
import JoinRuleDropdown from "../elements/JoinRuleDropdown";
|
||||||
|
import Field from "../elements/Field";
|
||||||
|
import RoomAliasField from "../elements/RoomAliasField";
|
||||||
|
import { GroupMember } from "../right_panel/UserInfo";
|
||||||
|
import { parseMembersResponse, parseRoomsResponse } from "../../../stores/GroupStore";
|
||||||
|
import { calculateRoomVia, makeRoomPermalink } from "../../../utils/permalinks/Permalinks";
|
||||||
|
import { useAsyncMemo } from "../../../hooks/useAsyncMemo";
|
||||||
|
import Spinner from "../elements/Spinner";
|
||||||
|
import { mediaFromMxc } from "../../../customisations/Media";
|
||||||
|
import SpaceStore from "../../../stores/SpaceStore";
|
||||||
|
import Modal from "../../../Modal";
|
||||||
|
import InfoDialog from "./InfoDialog";
|
||||||
|
import dis from "../../../dispatcher/dispatcher";
|
||||||
|
import { Action } from "../../../dispatcher/actions";
|
||||||
|
import { UserTab } from "./UserSettingsDialog";
|
||||||
|
import TagOrderActions from "../../../actions/TagOrderActions";
|
||||||
|
|
||||||
|
interface IProps {
|
||||||
|
matrixClient: MatrixClient;
|
||||||
|
groupId: string;
|
||||||
|
onFinished(spaceId?: string): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CreateEventField = "io.element.migrated_from_community";
|
||||||
|
|
||||||
|
interface IGroupRoom {
|
||||||
|
displayname: string;
|
||||||
|
name?: string;
|
||||||
|
roomId: string;
|
||||||
|
canonicalAlias?: string;
|
||||||
|
avatarUrl?: string;
|
||||||
|
topic?: string;
|
||||||
|
numJoinedMembers?: number;
|
||||||
|
worldReadable?: boolean;
|
||||||
|
guestCanJoin?: boolean;
|
||||||
|
isPublic?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* eslint-disable camelcase */
|
||||||
|
export interface IGroupSummary {
|
||||||
|
profile: {
|
||||||
|
avatar_url?: string;
|
||||||
|
is_openly_joinable?: boolean;
|
||||||
|
is_public?: boolean;
|
||||||
|
long_description: string;
|
||||||
|
name: string;
|
||||||
|
short_description: string;
|
||||||
|
};
|
||||||
|
rooms_section: {
|
||||||
|
rooms: unknown[];
|
||||||
|
categories: Record<string, unknown>;
|
||||||
|
total_room_count_estimate: number;
|
||||||
|
};
|
||||||
|
user: {
|
||||||
|
is_privileged: boolean;
|
||||||
|
is_public: boolean;
|
||||||
|
is_publicised: boolean;
|
||||||
|
membership: string;
|
||||||
|
};
|
||||||
|
users_section: {
|
||||||
|
users: unknown[];
|
||||||
|
roles: Record<string, unknown>;
|
||||||
|
total_user_count_estimate: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
/* eslint-enable camelcase */
|
||||||
|
|
||||||
|
const CreateSpaceFromCommunityDialog: React.FC<IProps> = ({ matrixClient: cli, groupId, onFinished }) => {
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const [avatar, setAvatar] = useState<File>(null); // undefined means to remove avatar
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const spaceNameField = useRef<Field>();
|
||||||
|
const [alias, setAlias] = useState("#" + groupId.substring(1, groupId.indexOf(":")) + ":" + cli.getDomain());
|
||||||
|
const spaceAliasField = useRef<RoomAliasField>();
|
||||||
|
const [topic, setTopic] = useState("");
|
||||||
|
const [joinRule, setJoinRule] = useState<JoinRule>(JoinRule.Public);
|
||||||
|
|
||||||
|
const groupSummary = useAsyncMemo<IGroupSummary>(() => cli.getGroupSummary(groupId), [groupId]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (groupSummary) {
|
||||||
|
setName(groupSummary.profile.name || "");
|
||||||
|
setTopic(groupSummary.profile.short_description || "");
|
||||||
|
setJoinRule(groupSummary.profile.is_openly_joinable ? JoinRule.Public : JoinRule.Invite);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [groupSummary]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <Spinner />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const onCreateSpaceClick = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (busy) return;
|
||||||
|
|
||||||
|
setError(null);
|
||||||
|
setBusy(true);
|
||||||
|
|
||||||
|
// require & validate the space name field
|
||||||
|
if (!await spaceNameField.current.validate({ allowEmpty: false })) {
|
||||||
|
setBusy(false);
|
||||||
|
spaceNameField.current.focus();
|
||||||
|
spaceNameField.current.validate({ allowEmpty: false, focused: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// validate the space name alias field but do not require it
|
||||||
|
if (joinRule === JoinRule.Public && !await spaceAliasField.current.validate({ allowEmpty: true })) {
|
||||||
|
setBusy(false);
|
||||||
|
spaceAliasField.current.focus();
|
||||||
|
spaceAliasField.current.validate({ allowEmpty: true, focused: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [rooms, members, invitedMembers] = await Promise.all([
|
||||||
|
cli.getGroupRooms(groupId).then(parseRoomsResponse) as Promise<IGroupRoom[]>,
|
||||||
|
cli.getGroupUsers(groupId).then(parseMembersResponse) as Promise<GroupMember[]>,
|
||||||
|
cli.getGroupInvitedUsers(groupId).then(parseMembersResponse) as Promise<GroupMember[]>,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const viaMap = new Map<string, string[]>();
|
||||||
|
for (const { roomId, canonicalAlias } of rooms) {
|
||||||
|
const room = cli.getRoom(roomId);
|
||||||
|
if (room) {
|
||||||
|
viaMap.set(roomId, calculateRoomVia(room));
|
||||||
|
} else if (canonicalAlias) {
|
||||||
|
try {
|
||||||
|
const { servers } = await cli.getRoomIdForAlias(canonicalAlias);
|
||||||
|
viaMap.set(roomId, servers);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("Failed to resolve alias during community migration", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!viaMap.get(roomId)?.length) {
|
||||||
|
// XXX: lets guess the via, this might end up being incorrect.
|
||||||
|
const str = canonicalAlias || roomId;
|
||||||
|
viaMap.set(roomId, [str.substring(1, str.indexOf(":"))]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const spaceAvatar = avatar !== undefined ? avatar : groupSummary.profile.avatar_url;
|
||||||
|
const roomId = await createSpace(name, joinRule === JoinRule.Public, alias, topic, spaceAvatar, {
|
||||||
|
creation_content: {
|
||||||
|
[CreateEventField]: groupId,
|
||||||
|
},
|
||||||
|
initial_state: rooms.map(({ roomId }) => ({
|
||||||
|
type: EventType.SpaceChild,
|
||||||
|
state_key: roomId,
|
||||||
|
content: {
|
||||||
|
via: viaMap.get(roomId) || [],
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
invite: [...members, ...invitedMembers].map(m => m.userId).filter(m => m !== cli.getUserId()),
|
||||||
|
}, {
|
||||||
|
andView: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// eagerly remove it from the community panel
|
||||||
|
dis.dispatch(TagOrderActions.removeTag(cli, groupId));
|
||||||
|
|
||||||
|
// don't bother awaiting this, as we don't hugely care if it fails
|
||||||
|
cli.setGroupProfile(groupId, {
|
||||||
|
...groupSummary.profile,
|
||||||
|
long_description: `<a href="${makeRoomPermalink(roomId)}"><h1>` +
|
||||||
|
_t("This community has been upgraded into a Space") + `</h1></a><br />`
|
||||||
|
+ groupSummary.profile.long_description,
|
||||||
|
} as IGroupSummary["profile"]).catch(e => {
|
||||||
|
console.warn("Failed to update community profile during migration", e);
|
||||||
|
});
|
||||||
|
|
||||||
|
onFinished(roomId);
|
||||||
|
|
||||||
|
const onSpaceClick = () => {
|
||||||
|
dis.dispatch({
|
||||||
|
action: "view_room",
|
||||||
|
room_id: roomId,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPreferencesClick = () => {
|
||||||
|
dis.dispatch({
|
||||||
|
action: Action.ViewUserSettings,
|
||||||
|
initialTabId: UserTab.Preferences,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
let spacesDisabledCopy;
|
||||||
|
if (!SpaceStore.spacesEnabled) {
|
||||||
|
spacesDisabledCopy = _t("To view Spaces, hide communities in <a>Preferences</a>", {}, {
|
||||||
|
a: sub => <AccessibleButton onClick={onPreferencesClick} kind="link">{ sub }</AccessibleButton>,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Modal.createDialog(InfoDialog, {
|
||||||
|
title: _t("Space created"),
|
||||||
|
description: <>
|
||||||
|
<div className="mx_CreateSpaceFromCommunityDialog_SuccessInfoDialog_checkmark" />
|
||||||
|
<p>
|
||||||
|
{ _t("<SpaceName/> has been made and everyone who was a part of the community has " +
|
||||||
|
"been invited to it.", {}, {
|
||||||
|
SpaceName: () => <AccessibleButton onClick={onSpaceClick} kind="link">
|
||||||
|
{ name }
|
||||||
|
</AccessibleButton>,
|
||||||
|
}) }
|
||||||
|
|
||||||
|
{ spacesDisabledCopy }
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
{ _t("To create a Space from another community, just pick the community in Preferences.") }
|
||||||
|
</p>
|
||||||
|
</>,
|
||||||
|
button: _t("Preferences"),
|
||||||
|
onFinished: (openPreferences: boolean) => {
|
||||||
|
if (openPreferences) {
|
||||||
|
onPreferencesClick();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}, "mx_CreateSpaceFromCommunityDialog_SuccessInfoDialog");
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
setError(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
setBusy(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
let footer;
|
||||||
|
if (error) {
|
||||||
|
footer = <>
|
||||||
|
<img src={require("../../../../res/img/element-icons/warning-badge.svg")} height="24" width="24" alt="" />
|
||||||
|
|
||||||
|
<span className="mx_CreateSpaceFromCommunityDialog_error">
|
||||||
|
<div className="mx_CreateSpaceFromCommunityDialog_errorHeading">{ _t("Failed to migrate community") }</div>
|
||||||
|
<div className="mx_CreateSpaceFromCommunityDialog_errorCaption">{ _t("Try again") }</div>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<AccessibleButton className="mx_CreateSpaceFromCommunityDialog_retryButton" onClick={onCreateSpaceClick}>
|
||||||
|
{ _t("Retry") }
|
||||||
|
</AccessibleButton>
|
||||||
|
</>;
|
||||||
|
} else {
|
||||||
|
footer = <>
|
||||||
|
<AccessibleButton kind="primary_outline" disabled={busy} onClick={() => onFinished()}>
|
||||||
|
{ _t("Cancel") }
|
||||||
|
</AccessibleButton>
|
||||||
|
<AccessibleButton kind="primary" disabled={busy} onClick={onCreateSpaceClick}>
|
||||||
|
{ busy ? _t("Creating...") : _t("Create Space") }
|
||||||
|
</AccessibleButton>
|
||||||
|
</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <BaseDialog
|
||||||
|
title={_t("Create Space from community")}
|
||||||
|
className="mx_CreateSpaceFromCommunityDialog"
|
||||||
|
onFinished={onFinished}
|
||||||
|
fixedWidth={false}
|
||||||
|
>
|
||||||
|
<div className="mx_CreateSpaceFromCommunityDialog_content">
|
||||||
|
<p>
|
||||||
|
{ _t("A link to the Space will be put in your community description.") }
|
||||||
|
|
||||||
|
{ _t("All rooms will be added and all community members will be invited.") }
|
||||||
|
</p>
|
||||||
|
<p className="mx_CreateSpaceFromCommunityDialog_flairNotice">
|
||||||
|
{ _t("Flair won't be available in Spaces for the foreseeable future.") }
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<SpaceCreateForm
|
||||||
|
busy={busy}
|
||||||
|
onSubmit={onCreateSpaceClick}
|
||||||
|
avatarUrl={groupSummary.profile.avatar_url
|
||||||
|
? mediaFromMxc(groupSummary.profile.avatar_url).getThumbnailOfSourceHttp(80, 80, "crop")
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
setAvatar={setAvatar}
|
||||||
|
name={name}
|
||||||
|
setName={setName}
|
||||||
|
nameFieldRef={spaceNameField}
|
||||||
|
topic={topic}
|
||||||
|
setTopic={setTopic}
|
||||||
|
alias={alias}
|
||||||
|
setAlias={setAlias}
|
||||||
|
showAliasField={joinRule === JoinRule.Public}
|
||||||
|
aliasFieldRef={spaceAliasField}
|
||||||
|
>
|
||||||
|
<p>{ _t("This description will be shown to people when they view your space") }</p>
|
||||||
|
<JoinRuleDropdown
|
||||||
|
label={_t("Space visibility")}
|
||||||
|
labelInvite={_t("Private space (invite only)")}
|
||||||
|
labelPublic={_t("Public space")}
|
||||||
|
value={joinRule}
|
||||||
|
onChange={setJoinRule}
|
||||||
|
/>
|
||||||
|
<p>{ joinRule === JoinRule.Public
|
||||||
|
? _t("Open space for anyone, best for communities")
|
||||||
|
: _t("Invite only, best for yourself or teams")
|
||||||
|
}</p>
|
||||||
|
{ joinRule !== JoinRule.Public &&
|
||||||
|
<div className="mx_CreateSpaceFromCommunityDialog_nonPublicSpacer" />
|
||||||
|
}
|
||||||
|
</SpaceCreateForm>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mx_CreateSpaceFromCommunityDialog_footer">
|
||||||
|
{ footer }
|
||||||
|
</div>
|
||||||
|
</BaseDialog>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CreateSpaceFromCommunityDialog;
|
||||||
|
|
|
@ -16,8 +16,7 @@ limitations under the License.
|
||||||
|
|
||||||
import React, { useRef, useState } from "react";
|
import React, { useRef, useState } from "react";
|
||||||
import { Room } from "matrix-js-sdk/src/models/room";
|
import { Room } from "matrix-js-sdk/src/models/room";
|
||||||
import { JoinRule, Preset } from "matrix-js-sdk/src/@types/partials";
|
import { JoinRule } from "matrix-js-sdk/src/@types/partials";
|
||||||
import { RoomType } from "matrix-js-sdk/src/@types/event";
|
|
||||||
|
|
||||||
import { _t } from '../../../languageHandler';
|
import { _t } from '../../../languageHandler';
|
||||||
import BaseDialog from "./BaseDialog";
|
import BaseDialog from "./BaseDialog";
|
||||||
|
@ -27,8 +26,7 @@ import { BetaPill } from "../beta/BetaCard";
|
||||||
import Field from "../elements/Field";
|
import Field from "../elements/Field";
|
||||||
import RoomAliasField from "../elements/RoomAliasField";
|
import RoomAliasField from "../elements/RoomAliasField";
|
||||||
import SpaceStore from "../../../stores/SpaceStore";
|
import SpaceStore from "../../../stores/SpaceStore";
|
||||||
import { SpaceCreateForm } from "../spaces/SpaceCreateMenu";
|
import { createSpace, SpaceCreateForm } from "../spaces/SpaceCreateMenu";
|
||||||
import createRoom from "../../../createRoom";
|
|
||||||
import { SubspaceSelector } from "./AddExistingToSpaceDialog";
|
import { SubspaceSelector } from "./AddExistingToSpaceDialog";
|
||||||
import JoinRuleDropdown from "../elements/JoinRuleDropdown";
|
import JoinRuleDropdown from "../elements/JoinRuleDropdown";
|
||||||
|
|
||||||
|
@ -81,28 +79,7 @@ const CreateSubspaceDialog: React.FC<IProps> = ({ space, onAddExistingSpaceClick
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await createRoom({
|
await createSpace(name, joinRule === JoinRule.Public, alias, topic, avatar, {}, { parentSpace });
|
||||||
createOpts: {
|
|
||||||
preset: joinRule === JoinRule.Public ? Preset.PublicChat : Preset.PrivateChat,
|
|
||||||
name,
|
|
||||||
power_level_content_override: {
|
|
||||||
// Only allow Admins to write to the timeline to prevent hidden sync spam
|
|
||||||
events_default: 100,
|
|
||||||
...joinRule === JoinRule.Public ? { invite: 0 } : {},
|
|
||||||
},
|
|
||||||
room_alias_name: joinRule === JoinRule.Public && alias
|
|
||||||
? alias.substr(1, alias.indexOf(":") - 1)
|
|
||||||
: undefined,
|
|
||||||
topic,
|
|
||||||
},
|
|
||||||
avatar,
|
|
||||||
roomType: RoomType.Space,
|
|
||||||
parentSpace,
|
|
||||||
spinner: false,
|
|
||||||
encryption: false,
|
|
||||||
andView: true,
|
|
||||||
inlineErrors: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
onFinished(true);
|
onFinished(true);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
|
@ -16,6 +16,7 @@ limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { IKeyBackupInfo } from "matrix-js-sdk/src/crypto/keybackup";
|
||||||
import Modal from '../../../Modal';
|
import Modal from '../../../Modal';
|
||||||
import * as sdk from '../../../index';
|
import * as sdk from '../../../index';
|
||||||
import dis from '../../../dispatcher/dispatcher';
|
import dis from '../../../dispatcher/dispatcher';
|
||||||
|
@ -24,19 +25,25 @@ import { MatrixClientPeg } from '../../../MatrixClientPeg';
|
||||||
import RestoreKeyBackupDialog from './security/RestoreKeyBackupDialog';
|
import RestoreKeyBackupDialog from './security/RestoreKeyBackupDialog';
|
||||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||||
|
|
||||||
|
interface IProps {
|
||||||
|
onFinished: (success: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IState {
|
||||||
|
shouldLoadBackupStatus: boolean;
|
||||||
|
loading: boolean;
|
||||||
|
backupInfo: IKeyBackupInfo;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
@replaceableComponent("views.dialogs.LogoutDialog")
|
@replaceableComponent("views.dialogs.LogoutDialog")
|
||||||
export default class LogoutDialog extends React.Component {
|
export default class LogoutDialog extends React.Component<IProps, IState> {
|
||||||
defaultProps = {
|
static defaultProps = {
|
||||||
onFinished: function() {},
|
onFinished: function() {},
|
||||||
};
|
};
|
||||||
|
|
||||||
constructor() {
|
constructor(props) {
|
||||||
super();
|
super(props);
|
||||||
this._onSettingsLinkClick = this._onSettingsLinkClick.bind(this);
|
|
||||||
this._onExportE2eKeysClicked = this._onExportE2eKeysClicked.bind(this);
|
|
||||||
this._onFinished = this._onFinished.bind(this);
|
|
||||||
this._onSetRecoveryMethodClick = this._onSetRecoveryMethodClick.bind(this);
|
|
||||||
this._onLogoutConfirm = this._onLogoutConfirm.bind(this);
|
|
||||||
|
|
||||||
const cli = MatrixClientPeg.get();
|
const cli = MatrixClientPeg.get();
|
||||||
const shouldLoadBackupStatus = cli.isCryptoEnabled() && !cli.getKeyBackupEnabled();
|
const shouldLoadBackupStatus = cli.isCryptoEnabled() && !cli.getKeyBackupEnabled();
|
||||||
|
@ -49,11 +56,11 @@ export default class LogoutDialog extends React.Component {
|
||||||
};
|
};
|
||||||
|
|
||||||
if (shouldLoadBackupStatus) {
|
if (shouldLoadBackupStatus) {
|
||||||
this._loadBackupStatus();
|
this.loadBackupStatus();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async _loadBackupStatus() {
|
private async loadBackupStatus() {
|
||||||
try {
|
try {
|
||||||
const backupInfo = await MatrixClientPeg.get().getKeyBackupVersion();
|
const backupInfo = await MatrixClientPeg.get().getKeyBackupVersion();
|
||||||
this.setState({
|
this.setState({
|
||||||
|
@ -69,29 +76,29 @@ export default class LogoutDialog extends React.Component {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_onSettingsLinkClick() {
|
private onSettingsLinkClick = (): void => {
|
||||||
// close dialog
|
// close dialog
|
||||||
this.props.onFinished();
|
this.props.onFinished(true);
|
||||||
}
|
};
|
||||||
|
|
||||||
_onExportE2eKeysClicked() {
|
private onExportE2eKeysClicked = (): void => {
|
||||||
Modal.createTrackedDialogAsync('Export E2E Keys', '',
|
Modal.createTrackedDialogAsync('Export E2E Keys', '',
|
||||||
import('../../../async-components/views/dialogs/security/ExportE2eKeysDialog'),
|
import('../../../async-components/views/dialogs/security/ExportE2eKeysDialog'),
|
||||||
{
|
{
|
||||||
matrixClient: MatrixClientPeg.get(),
|
matrixClient: MatrixClientPeg.get(),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
_onFinished(confirmed) {
|
private onFinished = (confirmed: boolean): void => {
|
||||||
if (confirmed) {
|
if (confirmed) {
|
||||||
dis.dispatch({ action: 'logout' });
|
dis.dispatch({ action: 'logout' });
|
||||||
}
|
}
|
||||||
// close dialog
|
// close dialog
|
||||||
this.props.onFinished();
|
this.props.onFinished(confirmed);
|
||||||
}
|
};
|
||||||
|
|
||||||
_onSetRecoveryMethodClick() {
|
private onSetRecoveryMethodClick = (): void => {
|
||||||
if (this.state.backupInfo) {
|
if (this.state.backupInfo) {
|
||||||
// A key backup exists for this account, but the creating device is not
|
// A key backup exists for this account, but the creating device is not
|
||||||
// verified, so restore the backup which will give us the keys from it and
|
// verified, so restore the backup which will give us the keys from it and
|
||||||
|
@ -108,15 +115,15 @@ export default class LogoutDialog extends React.Component {
|
||||||
}
|
}
|
||||||
|
|
||||||
// close dialog
|
// close dialog
|
||||||
this.props.onFinished();
|
this.props.onFinished(true);
|
||||||
}
|
};
|
||||||
|
|
||||||
_onLogoutConfirm() {
|
private onLogoutConfirm = (): void => {
|
||||||
dis.dispatch({ action: 'logout' });
|
dis.dispatch({ action: 'logout' });
|
||||||
|
|
||||||
// close dialog
|
// close dialog
|
||||||
this.props.onFinished();
|
this.props.onFinished(true);
|
||||||
}
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
if (this.state.shouldLoadBackupStatus) {
|
if (this.state.shouldLoadBackupStatus) {
|
||||||
|
@ -152,16 +159,16 @@ export default class LogoutDialog extends React.Component {
|
||||||
</div>
|
</div>
|
||||||
<DialogButtons primaryButton={setupButtonCaption}
|
<DialogButtons primaryButton={setupButtonCaption}
|
||||||
hasCancel={false}
|
hasCancel={false}
|
||||||
onPrimaryButtonClick={this._onSetRecoveryMethodClick}
|
onPrimaryButtonClick={this.onSetRecoveryMethodClick}
|
||||||
focus={true}
|
focus={true}
|
||||||
>
|
>
|
||||||
<button onClick={this._onLogoutConfirm}>
|
<button onClick={this.onLogoutConfirm}>
|
||||||
{ _t("I don't want my encrypted messages") }
|
{ _t("I don't want my encrypted messages") }
|
||||||
</button>
|
</button>
|
||||||
</DialogButtons>
|
</DialogButtons>
|
||||||
<details>
|
<details>
|
||||||
<summary>{ _t("Advanced") }</summary>
|
<summary>{ _t("Advanced") }</summary>
|
||||||
<p><button onClick={this._onExportE2eKeysClicked}>
|
<p><button onClick={this.onExportE2eKeysClicked}>
|
||||||
{ _t("Manually export keys") }
|
{ _t("Manually export keys") }
|
||||||
</button></p>
|
</button></p>
|
||||||
</details>
|
</details>
|
||||||
|
@ -174,7 +181,7 @@ export default class LogoutDialog extends React.Component {
|
||||||
title={_t("You'll lose access to your encrypted messages")}
|
title={_t("You'll lose access to your encrypted messages")}
|
||||||
contentId='mx_Dialog_content'
|
contentId='mx_Dialog_content'
|
||||||
hasCancel={true}
|
hasCancel={true}
|
||||||
onFinished={this._onFinished}
|
onFinished={this.onFinished}
|
||||||
>
|
>
|
||||||
{ dialogContent }
|
{ dialogContent }
|
||||||
</BaseDialog>);
|
</BaseDialog>);
|
||||||
|
@ -187,7 +194,7 @@ export default class LogoutDialog extends React.Component {
|
||||||
"Are you sure you want to sign out?",
|
"Are you sure you want to sign out?",
|
||||||
)}
|
)}
|
||||||
button={_t("Sign out")}
|
button={_t("Sign out")}
|
||||||
onFinished={this._onFinished}
|
onFinished={this.onFinished}
|
||||||
/>);
|
/>);
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -114,7 +114,7 @@ export default class UserSettingsDialog extends React.Component<IProps, IState>
|
||||||
UserTab.Preferences,
|
UserTab.Preferences,
|
||||||
_td("Preferences"),
|
_td("Preferences"),
|
||||||
"mx_UserSettingsDialog_preferencesIcon",
|
"mx_UserSettingsDialog_preferencesIcon",
|
||||||
<PreferencesUserSettingsTab />,
|
<PreferencesUserSettingsTab closeSettingsFn={this.props.onFinished} />,
|
||||||
));
|
));
|
||||||
|
|
||||||
if (SettingsStore.getValue(UIFeature.Voip)) {
|
if (SettingsStore.getValue(UIFeature.Voip)) {
|
||||||
|
|
|
@ -67,7 +67,9 @@ export default function AccessibleButton({
|
||||||
...restProps
|
...restProps
|
||||||
}: IProps) {
|
}: IProps) {
|
||||||
const newProps: IAccessibleButtonProps = restProps;
|
const newProps: IAccessibleButtonProps = restProps;
|
||||||
if (!disabled) {
|
if (disabled) {
|
||||||
|
newProps["aria-disabled"] = true;
|
||||||
|
} else {
|
||||||
newProps.onClick = onClick;
|
newProps.onClick = onClick;
|
||||||
// We need to consume enter onKeyDown and space onKeyUp
|
// We need to consume enter onKeyDown and space onKeyUp
|
||||||
// otherwise we are risking also activating other keyboard focusable elements
|
// otherwise we are risking also activating other keyboard focusable elements
|
||||||
|
@ -118,7 +120,7 @@ export default function AccessibleButton({
|
||||||
);
|
);
|
||||||
|
|
||||||
// React.createElement expects InputHTMLAttributes
|
// React.createElement expects InputHTMLAttributes
|
||||||
return React.createElement(element, restProps, children);
|
return React.createElement(element, newProps, children);
|
||||||
}
|
}
|
||||||
|
|
||||||
AccessibleButton.defaultProps = {
|
AccessibleButton.defaultProps = {
|
||||||
|
|
|
@ -62,10 +62,10 @@ export default class AppPermission extends React.Component<IProps, IState> {
|
||||||
|
|
||||||
// Set all this into the initial state
|
// Set all this into the initial state
|
||||||
this.state = {
|
this.state = {
|
||||||
...urlInfo,
|
|
||||||
roomMember,
|
|
||||||
isWrapped: null,
|
|
||||||
widgetDomain: null,
|
widgetDomain: null,
|
||||||
|
isWrapped: null,
|
||||||
|
roomMember,
|
||||||
|
...urlInfo,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -18,7 +18,7 @@ limitations under the License.
|
||||||
import React, { ChangeEvent, createRef, CSSProperties, ReactElement, ReactNode, Ref } from 'react';
|
import React, { ChangeEvent, createRef, CSSProperties, ReactElement, ReactNode, Ref } from 'react';
|
||||||
import classnames from 'classnames';
|
import classnames from 'classnames';
|
||||||
|
|
||||||
import AccessibleButton from './AccessibleButton';
|
import AccessibleButton, { ButtonEvent } from './AccessibleButton';
|
||||||
import { _t } from '../../../languageHandler';
|
import { _t } from '../../../languageHandler';
|
||||||
import { Key } from "../../../Keyboard";
|
import { Key } from "../../../Keyboard";
|
||||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||||
|
@ -178,7 +178,7 @@ export default class Dropdown extends React.Component<IProps, IState> {
|
||||||
this.ignoreEvent = ev;
|
this.ignoreEvent = ev;
|
||||||
};
|
};
|
||||||
|
|
||||||
private onInputClick = (ev: React.MouseEvent) => {
|
private onAccessibleButtonClick = (ev: ButtonEvent) => {
|
||||||
if (this.props.disabled) return;
|
if (this.props.disabled) return;
|
||||||
|
|
||||||
if (!this.state.expanded) {
|
if (!this.state.expanded) {
|
||||||
|
@ -186,6 +186,10 @@ export default class Dropdown extends React.Component<IProps, IState> {
|
||||||
expanded: true,
|
expanded: true,
|
||||||
});
|
});
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
|
} else if ((ev as React.KeyboardEvent).key === Key.ENTER) {
|
||||||
|
// the accessible button consumes enter onKeyDown for firing onClick, so handle it here
|
||||||
|
this.props.onOptionChange(this.state.highlightedOption);
|
||||||
|
this.close();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -204,7 +208,7 @@ export default class Dropdown extends React.Component<IProps, IState> {
|
||||||
this.props.onOptionChange(dropdownKey);
|
this.props.onOptionChange(dropdownKey);
|
||||||
};
|
};
|
||||||
|
|
||||||
private onInputKeyDown = (e: React.KeyboardEvent) => {
|
private onKeyDown = (e: React.KeyboardEvent) => {
|
||||||
let handled = true;
|
let handled = true;
|
||||||
|
|
||||||
// These keys don't generate keypress events and so needs to be on keyup
|
// These keys don't generate keypress events and so needs to be on keyup
|
||||||
|
@ -269,7 +273,7 @@ export default class Dropdown extends React.Component<IProps, IState> {
|
||||||
private prevOption(optionKey: string): string {
|
private prevOption(optionKey: string): string {
|
||||||
const keys = Object.keys(this.childrenByKey);
|
const keys = Object.keys(this.childrenByKey);
|
||||||
const index = keys.indexOf(optionKey);
|
const index = keys.indexOf(optionKey);
|
||||||
return keys[(index - 1) % keys.length];
|
return keys[index <= 0 ? keys.length - 1 : (index - 1) % keys.length];
|
||||||
}
|
}
|
||||||
|
|
||||||
private scrollIntoView(node: Element) {
|
private scrollIntoView(node: Element) {
|
||||||
|
@ -320,7 +324,6 @@ export default class Dropdown extends React.Component<IProps, IState> {
|
||||||
type="text"
|
type="text"
|
||||||
autoFocus={true}
|
autoFocus={true}
|
||||||
className="mx_Dropdown_option"
|
className="mx_Dropdown_option"
|
||||||
onKeyDown={this.onInputKeyDown}
|
|
||||||
onChange={this.onInputChange}
|
onChange={this.onInputChange}
|
||||||
value={this.state.searchQuery}
|
value={this.state.searchQuery}
|
||||||
role="combobox"
|
role="combobox"
|
||||||
|
@ -329,6 +332,7 @@ export default class Dropdown extends React.Component<IProps, IState> {
|
||||||
aria-owns={`${this.props.id}_listbox`}
|
aria-owns={`${this.props.id}_listbox`}
|
||||||
aria-disabled={this.props.disabled}
|
aria-disabled={this.props.disabled}
|
||||||
aria-label={this.props.label}
|
aria-label={this.props.label}
|
||||||
|
onKeyDown={this.onKeyDown}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -361,13 +365,14 @@ export default class Dropdown extends React.Component<IProps, IState> {
|
||||||
return <div className={classnames(dropdownClasses)} ref={this.collectRoot}>
|
return <div className={classnames(dropdownClasses)} ref={this.collectRoot}>
|
||||||
<AccessibleButton
|
<AccessibleButton
|
||||||
className="mx_Dropdown_input mx_no_textinput"
|
className="mx_Dropdown_input mx_no_textinput"
|
||||||
onClick={this.onInputClick}
|
onClick={this.onAccessibleButtonClick}
|
||||||
aria-haspopup="listbox"
|
aria-haspopup="listbox"
|
||||||
aria-expanded={this.state.expanded}
|
aria-expanded={this.state.expanded}
|
||||||
disabled={this.props.disabled}
|
disabled={this.props.disabled}
|
||||||
inputRef={this.buttonRef}
|
inputRef={this.buttonRef}
|
||||||
aria-label={this.props.label}
|
aria-label={this.props.label}
|
||||||
aria-describedby={`${this.props.id}_value`}
|
aria-describedby={`${this.props.id}_value`}
|
||||||
|
onKeyDown={this.onKeyDown}
|
||||||
>
|
>
|
||||||
{ currentValue }
|
{ currentValue }
|
||||||
<span className="mx_Dropdown_arrow" />
|
<span className="mx_Dropdown_arrow" />
|
||||||
|
|
|
@ -34,7 +34,7 @@ interface IProps {
|
||||||
// The list of room members for which to show avatars next to the summary
|
// The list of room members for which to show avatars next to the summary
|
||||||
summaryMembers?: RoomMember[];
|
summaryMembers?: RoomMember[];
|
||||||
// The text to show as the summary of this event list
|
// The text to show as the summary of this event list
|
||||||
summaryText?: string;
|
summaryText?: string | JSX.Element;
|
||||||
// An array of EventTiles to render when expanded
|
// An array of EventTiles to render when expanded
|
||||||
children: ReactNode[];
|
children: ReactNode[];
|
||||||
// Called when the event list expansion is toggled
|
// Called when the event list expansion is toggled
|
||||||
|
|
|
@ -25,8 +25,24 @@ import { formatCommaSeparatedList } from '../../../utils/FormattingUtils';
|
||||||
import { isValid3pidInvite } from "../../../RoomInvite";
|
import { isValid3pidInvite } from "../../../RoomInvite";
|
||||||
import EventListSummary from "./EventListSummary";
|
import EventListSummary from "./EventListSummary";
|
||||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||||
|
import defaultDispatcher from '../../../dispatcher/dispatcher';
|
||||||
|
import { RightPanelPhases } from '../../../stores/RightPanelStorePhases';
|
||||||
|
import { Action } from '../../../dispatcher/actions';
|
||||||
|
import { SetRightPanelPhasePayload } from '../../../dispatcher/payloads/SetRightPanelPhasePayload';
|
||||||
|
import { jsxJoin } from '../../../utils/ReactUtils';
|
||||||
|
import { EventType } from 'matrix-js-sdk/src/@types/event';
|
||||||
import { Layout } from '../../../settings/Layout';
|
import { Layout } from '../../../settings/Layout';
|
||||||
|
|
||||||
|
const onPinnedMessagesClick = (): void => {
|
||||||
|
defaultDispatcher.dispatch<SetRightPanelPhasePayload>({
|
||||||
|
action: Action.SetRightPanelPhase,
|
||||||
|
phase: RightPanelPhases.PinnedMessages,
|
||||||
|
allowClose: false,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const SENDER_AS_DISPLAY_NAME_EVENTS = [EventType.RoomServerAcl, EventType.RoomPinnedEvents];
|
||||||
|
|
||||||
interface IProps extends Omit<ComponentProps<typeof EventListSummary>, "summaryText" | "summaryMembers"> {
|
interface IProps extends Omit<ComponentProps<typeof EventListSummary>, "summaryText" | "summaryMembers"> {
|
||||||
// The maximum number of names to show in either each summary e.g. 2 would result "A, B and 234 others left"
|
// The maximum number of names to show in either each summary e.g. 2 would result "A, B and 234 others left"
|
||||||
summaryLength?: number;
|
summaryLength?: number;
|
||||||
|
@ -60,6 +76,7 @@ enum TransitionType {
|
||||||
ChangedAvatar = "changed_avatar",
|
ChangedAvatar = "changed_avatar",
|
||||||
NoChange = "no_change",
|
NoChange = "no_change",
|
||||||
ServerAcl = "server_acl",
|
ServerAcl = "server_acl",
|
||||||
|
ChangedPins = "pinned_messages"
|
||||||
}
|
}
|
||||||
|
|
||||||
const SEP = ",";
|
const SEP = ",";
|
||||||
|
@ -93,7 +110,10 @@ export default class MemberEventListSummary extends React.Component<IProps> {
|
||||||
* `Object.keys(eventAggregates)`.
|
* `Object.keys(eventAggregates)`.
|
||||||
* @returns {string} the textual summary of the aggregated events that occurred.
|
* @returns {string} the textual summary of the aggregated events that occurred.
|
||||||
*/
|
*/
|
||||||
private generateSummary(eventAggregates: Record<string, string[]>, orderedTransitionSequences: string[]) {
|
private generateSummary(
|
||||||
|
eventAggregates: Record<string, string[]>,
|
||||||
|
orderedTransitionSequences: string[],
|
||||||
|
): string | JSX.Element {
|
||||||
const summaries = orderedTransitionSequences.map((transitions) => {
|
const summaries = orderedTransitionSequences.map((transitions) => {
|
||||||
const userNames = eventAggregates[transitions];
|
const userNames = eventAggregates[transitions];
|
||||||
const nameList = this.renderNameList(userNames);
|
const nameList = this.renderNameList(userNames);
|
||||||
|
@ -122,7 +142,7 @@ export default class MemberEventListSummary extends React.Component<IProps> {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return summaries.join(", ");
|
return jsxJoin(summaries, ", ");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -216,7 +236,11 @@ export default class MemberEventListSummary extends React.Component<IProps> {
|
||||||
* @param {number} repeats the number of times the transition was repeated in a row.
|
* @param {number} repeats the number of times the transition was repeated in a row.
|
||||||
* @returns {string} the written Human Readable equivalent of the transition.
|
* @returns {string} the written Human Readable equivalent of the transition.
|
||||||
*/
|
*/
|
||||||
private static getDescriptionForTransition(t: TransitionType, userCount: number, repeats: number) {
|
private static getDescriptionForTransition(
|
||||||
|
t: TransitionType,
|
||||||
|
userCount: number,
|
||||||
|
repeats: number,
|
||||||
|
): string | JSX.Element {
|
||||||
// The empty interpolations 'severalUsers' and 'oneUser'
|
// The empty interpolations 'severalUsers' and 'oneUser'
|
||||||
// are there only to show translators to non-English languages
|
// are there only to show translators to non-English languages
|
||||||
// that the verb is conjugated to plural or singular Subject.
|
// that the verb is conjugated to plural or singular Subject.
|
||||||
|
@ -299,6 +323,15 @@ export default class MemberEventListSummary extends React.Component<IProps> {
|
||||||
{ severalUsers: "", count: repeats })
|
{ severalUsers: "", count: repeats })
|
||||||
: _t("%(oneUser)schanged the server ACLs %(count)s times", { oneUser: "", count: repeats });
|
: _t("%(oneUser)schanged the server ACLs %(count)s times", { oneUser: "", count: repeats });
|
||||||
break;
|
break;
|
||||||
|
case "pinned_messages":
|
||||||
|
res = (userCount > 1)
|
||||||
|
? _t("%(severalUsers)schanged the <a>pinned messages</a> for the room %(count)s times.",
|
||||||
|
{ severalUsers: "", count: repeats },
|
||||||
|
{ "a": (sub) => <a onClick={onPinnedMessagesClick}> { sub } </a> })
|
||||||
|
: _t("%(oneUser)schanged the <a>pinned messages</a> for the room %(count)s times.",
|
||||||
|
{ oneUser: "", count: repeats },
|
||||||
|
{ "a": (sub) => <a onClick={onPinnedMessagesClick}> { sub } </a> });
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
return res;
|
return res;
|
||||||
|
@ -317,16 +350,18 @@ export default class MemberEventListSummary extends React.Component<IProps> {
|
||||||
* if a transition is not recognised.
|
* if a transition is not recognised.
|
||||||
*/
|
*/
|
||||||
private static getTransition(e: IUserEvents): TransitionType {
|
private static getTransition(e: IUserEvents): TransitionType {
|
||||||
if (e.mxEvent.getType() === 'm.room.third_party_invite') {
|
const type = e.mxEvent.getType();
|
||||||
|
|
||||||
|
if (type === EventType.RoomThirdPartyInvite) {
|
||||||
// Handle 3pid invites the same as invites so they get bundled together
|
// Handle 3pid invites the same as invites so they get bundled together
|
||||||
if (!isValid3pidInvite(e.mxEvent)) {
|
if (!isValid3pidInvite(e.mxEvent)) {
|
||||||
return TransitionType.InviteWithdrawal;
|
return TransitionType.InviteWithdrawal;
|
||||||
}
|
}
|
||||||
return TransitionType.Invited;
|
return TransitionType.Invited;
|
||||||
}
|
} else if (type === EventType.RoomServerAcl) {
|
||||||
|
|
||||||
if (e.mxEvent.getType() === 'm.room.server_acl') {
|
|
||||||
return TransitionType.ServerAcl;
|
return TransitionType.ServerAcl;
|
||||||
|
} else if (type === EventType.RoomPinnedEvents) {
|
||||||
|
return TransitionType.ChangedPins;
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (e.mxEvent.getContent().membership) {
|
switch (e.mxEvent.getContent().membership) {
|
||||||
|
@ -415,22 +450,23 @@ export default class MemberEventListSummary extends React.Component<IProps> {
|
||||||
// Object mapping user IDs to an array of IUserEvents
|
// Object mapping user IDs to an array of IUserEvents
|
||||||
const userEvents: Record<string, IUserEvents[]> = {};
|
const userEvents: Record<string, IUserEvents[]> = {};
|
||||||
eventsToRender.forEach((e, index) => {
|
eventsToRender.forEach((e, index) => {
|
||||||
const userId = e.getType() === 'm.room.server_acl' ? e.getSender() : e.getStateKey();
|
const type = e.getType();
|
||||||
|
const userId = type === EventType.RoomServerAcl ? e.getSender() : e.getStateKey();
|
||||||
// Initialise a user's events
|
// Initialise a user's events
|
||||||
if (!userEvents[userId]) {
|
if (!userEvents[userId]) {
|
||||||
userEvents[userId] = [];
|
userEvents[userId] = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (e.getType() === 'm.room.server_acl') {
|
if (SENDER_AS_DISPLAY_NAME_EVENTS.includes(type as EventType)) {
|
||||||
latestUserAvatarMember.set(userId, e.sender);
|
latestUserAvatarMember.set(userId, e.sender);
|
||||||
} else if (e.target) {
|
} else if (e.target) {
|
||||||
latestUserAvatarMember.set(userId, e.target);
|
latestUserAvatarMember.set(userId, e.target);
|
||||||
}
|
}
|
||||||
|
|
||||||
let displayName = userId;
|
let displayName = userId;
|
||||||
if (e.getType() === 'm.room.third_party_invite') {
|
if (type === EventType.RoomThirdPartyInvite) {
|
||||||
displayName = e.getContent().display_name;
|
displayName = e.getContent().display_name;
|
||||||
} else if (e.getType() === 'm.room.server_acl') {
|
} else if (SENDER_AS_DISPLAY_NAME_EVENTS.includes(type as EventType)) {
|
||||||
displayName = e.sender.name;
|
displayName = e.sender.name;
|
||||||
} else if (e.target) {
|
} else if (e.target) {
|
||||||
displayName = e.target.name;
|
displayName = e.target.name;
|
||||||
|
|
|
@ -25,7 +25,7 @@ import { CallErrorCode, CallState } from 'matrix-js-sdk/src/webrtc/call';
|
||||||
import InfoTooltip, { InfoTooltipKind } from '../elements/InfoTooltip';
|
import InfoTooltip, { InfoTooltipKind } from '../elements/InfoTooltip';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import AccessibleTooltipButton from '../elements/AccessibleTooltipButton';
|
import AccessibleTooltipButton from '../elements/AccessibleTooltipButton';
|
||||||
import { MatrixClientPeg } from '../../../MatrixClientPeg';
|
import { formatCallTime } from "../../../DateUtils";
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
mxEvent: MatrixEvent;
|
mxEvent: MatrixEvent;
|
||||||
|
@ -117,14 +117,12 @@ export default class CallEvent extends React.Component<IProps, IState> {
|
||||||
if (state === CallState.Ended) {
|
if (state === CallState.Ended) {
|
||||||
const hangupReason = this.props.callEventGrouper.hangupReason;
|
const hangupReason = this.props.callEventGrouper.hangupReason;
|
||||||
const gotRejected = this.props.callEventGrouper.gotRejected;
|
const gotRejected = this.props.callEventGrouper.gotRejected;
|
||||||
const rejectParty = this.props.callEventGrouper.rejectParty;
|
|
||||||
|
|
||||||
if (gotRejected) {
|
if (gotRejected) {
|
||||||
const weDeclinedCall = MatrixClientPeg.get().getUserId() === rejectParty;
|
|
||||||
return (
|
return (
|
||||||
<div className="mx_CallEvent_content">
|
<div className="mx_CallEvent_content">
|
||||||
{ weDeclinedCall ? _t("You declined this call") : _t("They declined this call") }
|
{ _t("Call declined") }
|
||||||
{ this.renderCallBackButton(weDeclinedCall ? _t("Call back") : _t("Call again")) }
|
{ this.renderCallBackButton(_t("Call back")) }
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
} else if (([CallErrorCode.UserHangup, "user hangup"].includes(hangupReason) || !hangupReason)) {
|
} else if (([CallErrorCode.UserHangup, "user hangup"].includes(hangupReason) || !hangupReason)) {
|
||||||
|
@ -134,16 +132,21 @@ export default class CallEvent extends React.Component<IProps, IState> {
|
||||||
// https://github.com/vector-im/riot-android/issues/2623
|
// https://github.com/vector-im/riot-android/issues/2623
|
||||||
// Also the correct hangup code as of VoIP v1 (with underscore)
|
// Also the correct hangup code as of VoIP v1 (with underscore)
|
||||||
// Also, if we don't have a reason
|
// Also, if we don't have a reason
|
||||||
|
const duration = this.props.callEventGrouper.duration;
|
||||||
|
let text = _t("Call ended");
|
||||||
|
if (duration) {
|
||||||
|
text += " • " + formatCallTime(duration);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div className="mx_CallEvent_content">
|
<div className="mx_CallEvent_content">
|
||||||
{ _t("This call has ended") }
|
{ text }
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
} else if (hangupReason === CallErrorCode.InviteTimeout) {
|
} else if (hangupReason === CallErrorCode.InviteTimeout) {
|
||||||
return (
|
return (
|
||||||
<div className="mx_CallEvent_content">
|
<div className="mx_CallEvent_content">
|
||||||
{ _t("They didn't pick up") }
|
{ _t("Missed call") }
|
||||||
{ this.renderCallBackButton(_t("Call again")) }
|
{ this.renderCallBackButton(_t("Call back")) }
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -176,7 +179,8 @@ export default class CallEvent extends React.Component<IProps, IState> {
|
||||||
className="mx_CallEvent_content_tooltip"
|
className="mx_CallEvent_content_tooltip"
|
||||||
kind={InfoTooltipKind.Warning}
|
kind={InfoTooltipKind.Warning}
|
||||||
/>
|
/>
|
||||||
{ _t("This call has failed") }
|
{ _t("Connection failed") }
|
||||||
|
{ this.renderCallBackButton(_t("Retry")) }
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -190,7 +194,7 @@ export default class CallEvent extends React.Component<IProps, IState> {
|
||||||
if (state === CustomCallState.Missed) {
|
if (state === CustomCallState.Missed) {
|
||||||
return (
|
return (
|
||||||
<div className="mx_CallEvent_content">
|
<div className="mx_CallEvent_content">
|
||||||
{ _t("You missed this call") }
|
{ _t("Missed call") }
|
||||||
{ this.renderCallBackButton(_t("Call back")) }
|
{ this.renderCallBackButton(_t("Call back")) }
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -16,12 +16,13 @@ limitations under the License.
|
||||||
|
|
||||||
import { MatrixEvent } from "matrix-js-sdk/src";
|
import { MatrixEvent } from "matrix-js-sdk/src";
|
||||||
import { MediaEventHelper } from "../../../utils/MediaEventHelper";
|
import { MediaEventHelper } from "../../../utils/MediaEventHelper";
|
||||||
import React, { createRef } from "react";
|
import React from "react";
|
||||||
import { RovingAccessibleTooltipButton } from "../../../accessibility/RovingTabIndex";
|
import { RovingAccessibleTooltipButton } from "../../../accessibility/RovingTabIndex";
|
||||||
import Spinner from "../elements/Spinner";
|
import Spinner from "../elements/Spinner";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { _t } from "../../../languageHandler";
|
import { _t } from "../../../languageHandler";
|
||||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||||
|
import { FileDownloader } from "../../../utils/FileDownloader";
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
mxEvent: MatrixEvent;
|
mxEvent: MatrixEvent;
|
||||||
|
@ -39,7 +40,7 @@ interface IState {
|
||||||
|
|
||||||
@replaceableComponent("views.messages.DownloadActionButton")
|
@replaceableComponent("views.messages.DownloadActionButton")
|
||||||
export default class DownloadActionButton extends React.PureComponent<IProps, IState> {
|
export default class DownloadActionButton extends React.PureComponent<IProps, IState> {
|
||||||
private iframe: React.RefObject<HTMLIFrameElement> = createRef();
|
private downloader = new FileDownloader();
|
||||||
|
|
||||||
public constructor(props: IProps) {
|
public constructor(props: IProps) {
|
||||||
super(props);
|
super(props);
|
||||||
|
@ -56,27 +57,21 @@ export default class DownloadActionButton extends React.PureComponent<IProps, IS
|
||||||
|
|
||||||
if (this.state.blob) {
|
if (this.state.blob) {
|
||||||
// Cheat and trigger a download, again.
|
// Cheat and trigger a download, again.
|
||||||
return this.onFrameLoad();
|
return this.doDownload();
|
||||||
}
|
}
|
||||||
|
|
||||||
const blob = await this.props.mediaEventHelperGet().sourceBlob.value;
|
const blob = await this.props.mediaEventHelperGet().sourceBlob.value;
|
||||||
this.setState({ blob });
|
this.setState({ blob });
|
||||||
|
await this.doDownload();
|
||||||
};
|
};
|
||||||
|
|
||||||
private onFrameLoad = () => {
|
private async doDownload() {
|
||||||
this.setState({ loading: false });
|
await this.downloader.download({
|
||||||
|
|
||||||
// we aren't showing the iframe, so we can send over the bare minimum styles and such.
|
|
||||||
this.iframe.current.contentWindow.postMessage({
|
|
||||||
imgSrc: "", // no image
|
|
||||||
imgStyle: null,
|
|
||||||
style: "",
|
|
||||||
blob: this.state.blob,
|
blob: this.state.blob,
|
||||||
download: this.props.mediaEventHelperGet().fileName,
|
name: this.props.mediaEventHelperGet().fileName,
|
||||||
textContent: "",
|
});
|
||||||
auto: true, // autodownload
|
this.setState({ loading: false });
|
||||||
}, '*');
|
}
|
||||||
};
|
|
||||||
|
|
||||||
public render() {
|
public render() {
|
||||||
let spinner: JSX.Element;
|
let spinner: JSX.Element;
|
||||||
|
@ -92,18 +87,11 @@ export default class DownloadActionButton extends React.PureComponent<IProps, IS
|
||||||
|
|
||||||
return <RovingAccessibleTooltipButton
|
return <RovingAccessibleTooltipButton
|
||||||
className={classes}
|
className={classes}
|
||||||
title={spinner ? _t("Downloading") : _t("Download")}
|
title={spinner ? _t("Decrypting") : _t("Download")}
|
||||||
onClick={this.onDownloadClick}
|
onClick={this.onDownloadClick}
|
||||||
disabled={!!spinner}
|
disabled={!!spinner}
|
||||||
>
|
>
|
||||||
{ spinner }
|
{ spinner }
|
||||||
{ this.state.blob && <iframe
|
|
||||||
src="usercontent/" // XXX: Like MFileBody, this should come from the skin
|
|
||||||
ref={this.iframe}
|
|
||||||
onLoad={this.onFrameLoad}
|
|
||||||
sandbox="allow-scripts allow-downloads allow-downloads-without-user-activation"
|
|
||||||
style={{ display: "none" }}
|
|
||||||
/> }
|
|
||||||
</RovingAccessibleTooltipButton>;
|
</RovingAccessibleTooltipButton>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -26,6 +26,8 @@ import { TileShape } from "../rooms/EventTile";
|
||||||
import { presentableTextForFile } from "../../../utils/FileUtils";
|
import { presentableTextForFile } from "../../../utils/FileUtils";
|
||||||
import { IMediaEventContent } from "../../../customisations/models/IMediaEventContent";
|
import { IMediaEventContent } from "../../../customisations/models/IMediaEventContent";
|
||||||
import { IBodyProps } from "./IBodyProps";
|
import { IBodyProps } from "./IBodyProps";
|
||||||
|
import { FileDownloader } from "../../../utils/FileDownloader";
|
||||||
|
import TextWithTooltip from "../elements/TextWithTooltip";
|
||||||
|
|
||||||
export let DOWNLOAD_ICON_URL; // cached copy of the download.svg asset for the sandboxed iframe later on
|
export let DOWNLOAD_ICON_URL; // cached copy of the download.svg asset for the sandboxed iframe later on
|
||||||
|
|
||||||
|
@ -111,6 +113,7 @@ export default class MFileBody extends React.Component<IProps, IState> {
|
||||||
private iframe: React.RefObject<HTMLIFrameElement> = createRef();
|
private iframe: React.RefObject<HTMLIFrameElement> = createRef();
|
||||||
private dummyLink: React.RefObject<HTMLAnchorElement> = createRef();
|
private dummyLink: React.RefObject<HTMLAnchorElement> = createRef();
|
||||||
private userDidClick = false;
|
private userDidClick = false;
|
||||||
|
private fileDownloader: FileDownloader = new FileDownloader(() => this.iframe.current);
|
||||||
|
|
||||||
public constructor(props: IProps) {
|
public constructor(props: IProps) {
|
||||||
super(props);
|
super(props);
|
||||||
|
@ -118,6 +121,32 @@ export default class MFileBody extends React.Component<IProps, IState> {
|
||||||
this.state = {};
|
this.state = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private get content(): IMediaEventContent {
|
||||||
|
return this.props.mxEvent.getContent<IMediaEventContent>();
|
||||||
|
}
|
||||||
|
|
||||||
|
private get fileName(): string {
|
||||||
|
return this.content.body && this.content.body.length > 0 ? this.content.body : _t("Attachment");
|
||||||
|
}
|
||||||
|
|
||||||
|
private get linkText(): string {
|
||||||
|
return presentableTextForFile(this.content);
|
||||||
|
}
|
||||||
|
|
||||||
|
private downloadFile(fileName: string, text: string) {
|
||||||
|
this.fileDownloader.download({
|
||||||
|
blob: this.state.decryptedBlob,
|
||||||
|
name: fileName,
|
||||||
|
autoDownload: this.userDidClick,
|
||||||
|
opts: {
|
||||||
|
imgSrc: DOWNLOAD_ICON_URL,
|
||||||
|
imgStyle: null,
|
||||||
|
style: computedStyle(this.dummyLink.current),
|
||||||
|
textContent: _t("Download %(text)s", { text }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private getContentUrl(): string {
|
private getContentUrl(): string {
|
||||||
const media = mediaFromContent(this.props.mxEvent.getContent());
|
const media = mediaFromContent(this.props.mxEvent.getContent());
|
||||||
return media.srcHttp;
|
return media.srcHttp;
|
||||||
|
@ -129,35 +158,10 @@ export default class MFileBody extends React.Component<IProps, IState> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public render() {
|
private decryptFile = async (): Promise<void> => {
|
||||||
const content = this.props.mxEvent.getContent<IMediaEventContent>();
|
if (this.state.decryptedBlob) {
|
||||||
const text = presentableTextForFile(content);
|
return;
|
||||||
const isEncrypted = this.props.mediaEventHelper.media.isEncrypted;
|
|
||||||
const fileName = content.body && content.body.length > 0 ? content.body : _t("Attachment");
|
|
||||||
const contentUrl = this.getContentUrl();
|
|
||||||
const fileSize = content.info ? content.info.size : null;
|
|
||||||
const fileType = content.info ? content.info.mimetype : "application/octet-stream";
|
|
||||||
|
|
||||||
let placeholder = null;
|
|
||||||
if (this.props.showGenericPlaceholder) {
|
|
||||||
placeholder = (
|
|
||||||
<div className="mx_MediaBody mx_MFileBody_info">
|
|
||||||
<span className="mx_MFileBody_info_icon" />
|
|
||||||
<span className="mx_MFileBody_info_filename">
|
|
||||||
{ presentableTextForFile(content, _t("Attachment"), false) }
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const showDownloadLink = this.props.tileShape || !this.props.showGenericPlaceholder;
|
|
||||||
|
|
||||||
if (isEncrypted) {
|
|
||||||
if (!this.state.decryptedBlob) {
|
|
||||||
// Need to decrypt the attachment
|
|
||||||
// Wait for the user to click on the link before downloading
|
|
||||||
// and decrypting the attachment.
|
|
||||||
const decrypt = async () => {
|
|
||||||
try {
|
try {
|
||||||
this.userDidClick = true;
|
this.userDidClick = true;
|
||||||
this.setState({
|
this.setState({
|
||||||
|
@ -172,37 +176,63 @@ export default class MFileBody extends React.Component<IProps, IState> {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private onPlaceholderClick = async () => {
|
||||||
|
const mediaHelper = this.props.mediaEventHelper;
|
||||||
|
if (mediaHelper.media.isEncrypted) {
|
||||||
|
await this.decryptFile();
|
||||||
|
this.downloadFile(this.fileName, this.linkText);
|
||||||
|
} else {
|
||||||
|
// As a button we're missing the `download` attribute for styling reasons, so
|
||||||
|
// download with the file downloader.
|
||||||
|
this.fileDownloader.download({
|
||||||
|
blob: await mediaHelper.sourceBlob.value,
|
||||||
|
name: this.fileName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
public render() {
|
||||||
|
const isEncrypted = this.props.mediaEventHelper.media.isEncrypted;
|
||||||
|
const contentUrl = this.getContentUrl();
|
||||||
|
const fileSize = this.content.info ? this.content.info.size : null;
|
||||||
|
const fileType = this.content.info ? this.content.info.mimetype : "application/octet-stream";
|
||||||
|
|
||||||
|
let placeholder: React.ReactNode = null;
|
||||||
|
if (this.props.showGenericPlaceholder) {
|
||||||
|
placeholder = (
|
||||||
|
<AccessibleButton className="mx_MediaBody mx_MFileBody_info" onClick={this.onPlaceholderClick}>
|
||||||
|
<span className="mx_MFileBody_info_icon" />
|
||||||
|
<TextWithTooltip tooltip={presentableTextForFile(this.content, _t("Attachment"), true)}>
|
||||||
|
<span className="mx_MFileBody_info_filename">
|
||||||
|
{ presentableTextForFile(this.content, _t("Attachment"), true, true) }
|
||||||
|
</span>
|
||||||
|
</TextWithTooltip>
|
||||||
|
</AccessibleButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const showDownloadLink = this.props.tileShape || !this.props.showGenericPlaceholder;
|
||||||
|
|
||||||
|
if (isEncrypted) {
|
||||||
|
if (!this.state.decryptedBlob) {
|
||||||
|
// Need to decrypt the attachment
|
||||||
|
// Wait for the user to click on the link before downloading
|
||||||
|
// and decrypting the attachment.
|
||||||
|
|
||||||
// This button should actually Download because usercontent/ will try to click itself
|
// This button should actually Download because usercontent/ will try to click itself
|
||||||
// but it is not guaranteed between various browsers' settings.
|
// but it is not guaranteed between various browsers' settings.
|
||||||
return (
|
return (
|
||||||
<span className="mx_MFileBody">
|
<span className="mx_MFileBody">
|
||||||
{ placeholder }
|
{ placeholder }
|
||||||
{ showDownloadLink && <div className="mx_MFileBody_download">
|
{ showDownloadLink && <div className="mx_MFileBody_download">
|
||||||
<AccessibleButton onClick={decrypt}>
|
<AccessibleButton onClick={this.decryptFile}>
|
||||||
{ _t("Decrypt %(text)s", { text: text }) }
|
{ _t("Decrypt %(text)s", { text: this.linkText }) }
|
||||||
</AccessibleButton>
|
</AccessibleButton>
|
||||||
</div> }
|
</div> }
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// When the iframe loads we tell it to render a download link
|
|
||||||
const onIframeLoad = (ev) => {
|
|
||||||
ev.target.contentWindow.postMessage({
|
|
||||||
imgSrc: DOWNLOAD_ICON_URL,
|
|
||||||
imgStyle: null, // it handles this internally for us. Useful if a downstream changes the icon.
|
|
||||||
style: computedStyle(this.dummyLink.current),
|
|
||||||
blob: this.state.decryptedBlob,
|
|
||||||
// Set a download attribute for encrypted files so that the file
|
|
||||||
// will have the correct name when the user tries to download it.
|
|
||||||
// We can't provide a Content-Disposition header like we would for HTTP.
|
|
||||||
download: fileName,
|
|
||||||
textContent: _t("Download %(text)s", { text: text }),
|
|
||||||
// only auto-download if a user triggered this iframe explicitly
|
|
||||||
auto: this.userDidClick,
|
|
||||||
}, "*");
|
|
||||||
};
|
|
||||||
|
|
||||||
const url = "usercontent/"; // XXX: this path should probably be passed from the skin
|
const url = "usercontent/"; // XXX: this path should probably be passed from the skin
|
||||||
|
|
||||||
// If the attachment is encrypted then put the link inside an iframe.
|
// If the attachment is encrypted then put the link inside an iframe.
|
||||||
|
@ -218,9 +248,16 @@ export default class MFileBody extends React.Component<IProps, IState> {
|
||||||
*/ }
|
*/ }
|
||||||
<a ref={this.dummyLink} />
|
<a ref={this.dummyLink} />
|
||||||
</div>
|
</div>
|
||||||
|
{ /*
|
||||||
|
TODO: Move iframe (and dummy link) into FileDownloader.
|
||||||
|
We currently have it set up this way because of styles applied to the iframe
|
||||||
|
itself which cannot be easily handled/overridden by the FileDownloader. In
|
||||||
|
future, the download link may disappear entirely at which point it could also
|
||||||
|
be suitable to just remove this bit of code.
|
||||||
|
*/ }
|
||||||
<iframe
|
<iframe
|
||||||
src={url}
|
src={url}
|
||||||
onLoad={onIframeLoad}
|
onLoad={() => this.downloadFile(this.fileName, this.linkText)}
|
||||||
ref={this.iframe}
|
ref={this.iframe}
|
||||||
sandbox="allow-scripts allow-downloads allow-downloads-without-user-activation" />
|
sandbox="allow-scripts allow-downloads allow-downloads-without-user-activation" />
|
||||||
</div> }
|
</div> }
|
||||||
|
@ -259,7 +296,7 @@ export default class MFileBody extends React.Component<IProps, IState> {
|
||||||
|
|
||||||
// We have to create an anchor to download the file
|
// We have to create an anchor to download the file
|
||||||
const tempAnchor = document.createElement('a');
|
const tempAnchor = document.createElement('a');
|
||||||
tempAnchor.download = fileName;
|
tempAnchor.download = this.fileName;
|
||||||
tempAnchor.href = blobUrl;
|
tempAnchor.href = blobUrl;
|
||||||
document.body.appendChild(tempAnchor); // for firefox: https://stackoverflow.com/a/32226068
|
document.body.appendChild(tempAnchor); // for firefox: https://stackoverflow.com/a/32226068
|
||||||
tempAnchor.click();
|
tempAnchor.click();
|
||||||
|
@ -268,7 +305,7 @@ export default class MFileBody extends React.Component<IProps, IState> {
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
// Else we are hoping the browser will do the right thing
|
// Else we are hoping the browser will do the right thing
|
||||||
downloadProps["download"] = fileName;
|
downloadProps["download"] = this.fileName;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -277,16 +314,16 @@ export default class MFileBody extends React.Component<IProps, IState> {
|
||||||
{ showDownloadLink && <div className="mx_MFileBody_download">
|
{ showDownloadLink && <div className="mx_MFileBody_download">
|
||||||
<a {...downloadProps}>
|
<a {...downloadProps}>
|
||||||
<span className="mx_MFileBody_download_icon" />
|
<span className="mx_MFileBody_download_icon" />
|
||||||
{ _t("Download %(text)s", { text: text }) }
|
{ _t("Download %(text)s", { text: this.linkText }) }
|
||||||
</a>
|
</a>
|
||||||
{ this.props.tileShape === TileShape.FileGrid && <div className="mx_MImageBody_size">
|
{ this.props.tileShape === TileShape.FileGrid && <div className="mx_MImageBody_size">
|
||||||
{ content.info && content.info.size ? filesize(content.info.size) : "" }
|
{ this.content.info && this.content.info.size ? filesize(this.content.info.size) : "" }
|
||||||
</div> }
|
</div> }
|
||||||
</div> }
|
</div> }
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
const extra = text ? (': ' + text) : '';
|
const extra = this.linkText ? (': ' + this.linkText) : '';
|
||||||
return <span className="mx_MFileBody">
|
return <span className="mx_MFileBody">
|
||||||
{ placeholder }
|
{ placeholder }
|
||||||
{ _t("Invalid file%(extra)s", { extra: extra }) }
|
{ _t("Invalid file%(extra)s", { extra: extra }) }
|
||||||
|
|
|
@ -347,12 +347,21 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
|
||||||
className="mx_MImageBody_thumbnail"
|
className="mx_MImageBody_thumbnail"
|
||||||
src={thumbUrl}
|
src={thumbUrl}
|
||||||
ref={this.image}
|
ref={this.image}
|
||||||
style={{ maxWidth: `min(100%, ${maxWidth}px)` }}
|
// Force the image to be the full size of the container, even if the
|
||||||
|
// pixel size is smaller. The problem here is that we don't know what
|
||||||
|
// thumbnail size the HS is going to give us, but we have to commit to
|
||||||
|
// a container size immediately and not change it when the image loads
|
||||||
|
// or we'll get a scroll jump (or have to leave blank space).
|
||||||
|
// This will obviously result in an upscaled image which will be a bit
|
||||||
|
// blurry. The best fix would be for the HS to advertise what size thumbnails
|
||||||
|
// it guarantees to produce.
|
||||||
|
style={{ height: '100%' }}
|
||||||
alt={content.body}
|
alt={content.body}
|
||||||
onError={this.onImageError}
|
onError={this.onImageError}
|
||||||
onLoad={this.onImageLoad}
|
onLoad={this.onImageLoad}
|
||||||
onMouseEnter={this.onImageEnter}
|
onMouseEnter={this.onImageEnter}
|
||||||
onMouseLeave={this.onImageLeave} />
|
onMouseLeave={this.onImageLeave}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -379,7 +388,10 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
<div style={{ display: !showPlaceholder ? undefined : 'none' }}>
|
<div style={{
|
||||||
|
display: !showPlaceholder ? undefined : 'none',
|
||||||
|
height: '100%', // Also force to size of a parent to prevent scroll-jumps (see above)
|
||||||
|
}}>
|
||||||
{ img }
|
{ img }
|
||||||
{ gifLabel }
|
{ gifLabel }
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -17,7 +17,6 @@ limitations under the License.
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import MAudioBody from "./MAudioBody";
|
import MAudioBody from "./MAudioBody";
|
||||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||||
import SettingsStore from "../../../settings/SettingsStore";
|
|
||||||
import MVoiceMessageBody from "./MVoiceMessageBody";
|
import MVoiceMessageBody from "./MVoiceMessageBody";
|
||||||
import { IBodyProps } from "./IBodyProps";
|
import { IBodyProps } from "./IBodyProps";
|
||||||
|
|
||||||
|
@ -27,8 +26,7 @@ export default class MVoiceOrAudioBody extends React.PureComponent<IBodyProps> {
|
||||||
// MSC2516 is a legacy identifier. See https://github.com/matrix-org/matrix-doc/pull/3245
|
// MSC2516 is a legacy identifier. See https://github.com/matrix-org/matrix-doc/pull/3245
|
||||||
const isVoiceMessage = !!this.props.mxEvent.getContent()['org.matrix.msc2516.voice']
|
const isVoiceMessage = !!this.props.mxEvent.getContent()['org.matrix.msc2516.voice']
|
||||||
|| !!this.props.mxEvent.getContent()['org.matrix.msc3245.voice'];
|
|| !!this.props.mxEvent.getContent()['org.matrix.msc3245.voice'];
|
||||||
const voiceMessagesEnabled = SettingsStore.getValue("feature_voice_messages");
|
if (isVoiceMessage) {
|
||||||
if (isVoiceMessage && voiceMessagesEnabled) {
|
|
||||||
return <MVoiceMessageBody {...this.props} />;
|
return <MVoiceMessageBody {...this.props} />;
|
||||||
} else {
|
} else {
|
||||||
return <MAudioBody {...this.props} />;
|
return <MAudioBody {...this.props} />;
|
||||||
|
|
|
@ -136,7 +136,8 @@ export default class TextualBody extends React.Component<IBodyProps, IState> {
|
||||||
private addCodeExpansionButton(div: HTMLDivElement, pre: HTMLPreElement): void {
|
private addCodeExpansionButton(div: HTMLDivElement, pre: HTMLPreElement): void {
|
||||||
// Calculate how many percent does the pre element take up.
|
// Calculate how many percent does the pre element take up.
|
||||||
// If it's less than 30% we don't add the expansion button.
|
// If it's less than 30% we don't add the expansion button.
|
||||||
const percentageOfViewport = pre.offsetHeight / UIStore.instance.windowHeight * 100;
|
// We also round the number as it sometimes can be 29.99...
|
||||||
|
const percentageOfViewport = Math.round(pre.offsetHeight / UIStore.instance.windowHeight * 100);
|
||||||
if (percentageOfViewport < 30) return;
|
if (percentageOfViewport < 30) return;
|
||||||
|
|
||||||
const button = document.createElement("span");
|
const button = document.createElement("span");
|
||||||
|
|
|
@ -851,7 +851,7 @@ const RoomAdminToolsContainer: React.FC<IBaseRoomProps> = ({
|
||||||
return <div />;
|
return <div />;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface GroupMember {
|
export interface GroupMember {
|
||||||
userId: string;
|
userId: string;
|
||||||
displayname?: string; // XXX: GroupMember objects are inconsistent :((
|
displayname?: string; // XXX: GroupMember objects are inconsistent :((
|
||||||
avatarUrl?: string;
|
avatarUrl?: string;
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
/*
|
/*
|
||||||
Copyright 2019 New Vector Ltd
|
Copyright 2019 - 2021 The Matrix.org Foundation C.I.C.
|
||||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
you may not use this file except in compliance with the License.
|
you may not use this file except in compliance with the License.
|
||||||
|
@ -32,7 +31,7 @@ import {
|
||||||
} from '../../../editor/operations';
|
} from '../../../editor/operations';
|
||||||
import { getCaretOffsetAndText, getRangeForSelection } from '../../../editor/dom';
|
import { getCaretOffsetAndText, getRangeForSelection } from '../../../editor/dom';
|
||||||
import Autocomplete, { generateCompletionDomId } from '../rooms/Autocomplete';
|
import Autocomplete, { generateCompletionDomId } from '../rooms/Autocomplete';
|
||||||
import { getAutoCompleteCreator } from '../../../editor/parts';
|
import { getAutoCompleteCreator, Type } from '../../../editor/parts';
|
||||||
import { parseEvent, parsePlainTextMessage } from '../../../editor/deserialize';
|
import { parseEvent, parsePlainTextMessage } from '../../../editor/deserialize';
|
||||||
import { renderModel } from '../../../editor/render';
|
import { renderModel } from '../../../editor/render';
|
||||||
import TypingStore from "../../../stores/TypingStore";
|
import TypingStore from "../../../stores/TypingStore";
|
||||||
|
@ -55,6 +54,14 @@ const REGEX_EMOTICON_WHITESPACE = new RegExp('(?:^|\\s)(' + EMOTICON_REGEX.sourc
|
||||||
|
|
||||||
const IS_MAC = navigator.platform.indexOf("Mac") !== -1;
|
const IS_MAC = navigator.platform.indexOf("Mac") !== -1;
|
||||||
|
|
||||||
|
const SURROUND_WITH_CHARACTERS = ["\"", "_", "`", "'", "*", "~", "$"];
|
||||||
|
const SURROUND_WITH_DOUBLE_CHARACTERS = new Map([
|
||||||
|
["(", ")"],
|
||||||
|
["[", "]"],
|
||||||
|
["{", "}"],
|
||||||
|
["<", ">"],
|
||||||
|
]);
|
||||||
|
|
||||||
function ctrlShortcutLabel(key: string): string {
|
function ctrlShortcutLabel(key: string): string {
|
||||||
return (IS_MAC ? "⌘" : "Ctrl") + "+" + key;
|
return (IS_MAC ? "⌘" : "Ctrl") + "+" + key;
|
||||||
}
|
}
|
||||||
|
@ -99,6 +106,7 @@ interface IState {
|
||||||
showVisualBell?: boolean;
|
showVisualBell?: boolean;
|
||||||
autoComplete?: AutocompleteWrapperModel;
|
autoComplete?: AutocompleteWrapperModel;
|
||||||
completionIndex?: number;
|
completionIndex?: number;
|
||||||
|
surroundWith: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@replaceableComponent("views.rooms.BasicMessageEditor")
|
@replaceableComponent("views.rooms.BasicMessageEditor")
|
||||||
|
@ -117,12 +125,14 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
|
||||||
|
|
||||||
private readonly emoticonSettingHandle: string;
|
private readonly emoticonSettingHandle: string;
|
||||||
private readonly shouldShowPillAvatarSettingHandle: string;
|
private readonly shouldShowPillAvatarSettingHandle: string;
|
||||||
|
private readonly surroundWithHandle: string;
|
||||||
private readonly historyManager = new HistoryManager();
|
private readonly historyManager = new HistoryManager();
|
||||||
|
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
this.state = {
|
this.state = {
|
||||||
showPillAvatar: SettingsStore.getValue("Pill.shouldShowPillAvatar"),
|
showPillAvatar: SettingsStore.getValue("Pill.shouldShowPillAvatar"),
|
||||||
|
surroundWith: SettingsStore.getValue("MessageComposerInput.surroundWith"),
|
||||||
};
|
};
|
||||||
|
|
||||||
this.emoticonSettingHandle = SettingsStore.watchSetting('MessageComposerInput.autoReplaceEmoji', null,
|
this.emoticonSettingHandle = SettingsStore.watchSetting('MessageComposerInput.autoReplaceEmoji', null,
|
||||||
|
@ -130,6 +140,8 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
|
||||||
this.configureEmoticonAutoReplace();
|
this.configureEmoticonAutoReplace();
|
||||||
this.shouldShowPillAvatarSettingHandle = SettingsStore.watchSetting("Pill.shouldShowPillAvatar", null,
|
this.shouldShowPillAvatarSettingHandle = SettingsStore.watchSetting("Pill.shouldShowPillAvatar", null,
|
||||||
this.configureShouldShowPillAvatar);
|
this.configureShouldShowPillAvatar);
|
||||||
|
this.surroundWithHandle = SettingsStore.watchSetting("MessageComposerInput.surroundWith", null,
|
||||||
|
this.surroundWithSettingChanged);
|
||||||
}
|
}
|
||||||
|
|
||||||
public componentDidUpdate(prevProps: IProps) {
|
public componentDidUpdate(prevProps: IProps) {
|
||||||
|
@ -157,7 +169,7 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
|
||||||
range.expandBackwardsWhile((index, offset) => {
|
range.expandBackwardsWhile((index, offset) => {
|
||||||
const part = model.parts[index];
|
const part = model.parts[index];
|
||||||
n -= 1;
|
n -= 1;
|
||||||
return n >= 0 && (part.type === "plain" || part.type === "pill-candidate");
|
return n >= 0 && (part.type === Type.Plain || part.type === Type.PillCandidate);
|
||||||
});
|
});
|
||||||
const emoticonMatch = REGEX_EMOTICON_WHITESPACE.exec(range.text);
|
const emoticonMatch = REGEX_EMOTICON_WHITESPACE.exec(range.text);
|
||||||
if (emoticonMatch) {
|
if (emoticonMatch) {
|
||||||
|
@ -422,6 +434,28 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
|
||||||
private onKeyDown = (event: React.KeyboardEvent): void => {
|
private onKeyDown = (event: React.KeyboardEvent): void => {
|
||||||
const model = this.props.model;
|
const model = this.props.model;
|
||||||
let handled = false;
|
let handled = false;
|
||||||
|
|
||||||
|
if (this.state.surroundWith && document.getSelection().type != "Caret") {
|
||||||
|
// This surrounds the selected text with a character. This is
|
||||||
|
// intentionally left out of the keybinding manager as the keybinds
|
||||||
|
// here shouldn't be changeable
|
||||||
|
|
||||||
|
const selectionRange = getRangeForSelection(
|
||||||
|
this.editorRef.current,
|
||||||
|
this.props.model,
|
||||||
|
document.getSelection(),
|
||||||
|
);
|
||||||
|
// trim the range as we want it to exclude leading/trailing spaces
|
||||||
|
selectionRange.trim();
|
||||||
|
|
||||||
|
if ([...SURROUND_WITH_DOUBLE_CHARACTERS.keys(), ...SURROUND_WITH_CHARACTERS].includes(event.key)) {
|
||||||
|
this.historyManager.ensureLastChangesPushed(this.props.model);
|
||||||
|
this.modifiedFlag = true;
|
||||||
|
toggleInlineFormat(selectionRange, event.key, SURROUND_WITH_DOUBLE_CHARACTERS.get(event.key));
|
||||||
|
handled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const action = getKeyBindingsManager().getMessageComposerAction(event);
|
const action = getKeyBindingsManager().getMessageComposerAction(event);
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case MessageComposerAction.FormatBold:
|
case MessageComposerAction.FormatBold:
|
||||||
|
@ -524,9 +558,9 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
|
||||||
const range = model.startRange(position);
|
const range = model.startRange(position);
|
||||||
range.expandBackwardsWhile((index, offset, part) => {
|
range.expandBackwardsWhile((index, offset, part) => {
|
||||||
return part.text[offset] !== " " && part.text[offset] !== "+" && (
|
return part.text[offset] !== " " && part.text[offset] !== "+" && (
|
||||||
part.type === "plain" ||
|
part.type === Type.Plain ||
|
||||||
part.type === "pill-candidate" ||
|
part.type === Type.PillCandidate ||
|
||||||
part.type === "command"
|
part.type === Type.Command
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
const { partCreator } = model;
|
const { partCreator } = model;
|
||||||
|
@ -574,6 +608,11 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
|
||||||
this.setState({ showPillAvatar });
|
this.setState({ showPillAvatar });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private surroundWithSettingChanged = () => {
|
||||||
|
const surroundWith = SettingsStore.getValue("MessageComposerInput.surroundWith");
|
||||||
|
this.setState({ surroundWith });
|
||||||
|
};
|
||||||
|
|
||||||
componentWillUnmount() {
|
componentWillUnmount() {
|
||||||
document.removeEventListener("selectionchange", this.onSelectionChange);
|
document.removeEventListener("selectionchange", this.onSelectionChange);
|
||||||
this.editorRef.current.removeEventListener("input", this.onInput, true);
|
this.editorRef.current.removeEventListener("input", this.onInput, true);
|
||||||
|
@ -581,6 +620,7 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
|
||||||
this.editorRef.current.removeEventListener("compositionend", this.onCompositionEnd, true);
|
this.editorRef.current.removeEventListener("compositionend", this.onCompositionEnd, true);
|
||||||
SettingsStore.unwatchSetting(this.emoticonSettingHandle);
|
SettingsStore.unwatchSetting(this.emoticonSettingHandle);
|
||||||
SettingsStore.unwatchSetting(this.shouldShowPillAvatarSettingHandle);
|
SettingsStore.unwatchSetting(this.shouldShowPillAvatarSettingHandle);
|
||||||
|
SettingsStore.unwatchSetting(this.surroundWithHandle);
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
|
@ -684,7 +724,7 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
|
||||||
<MessageComposerFormatBar ref={this.formatBarRef} onAction={this.onFormatAction} shortcuts={shortcuts} />
|
<MessageComposerFormatBar ref={this.formatBarRef} onAction={this.onFormatAction} shortcuts={shortcuts} />
|
||||||
<div
|
<div
|
||||||
className={classes}
|
className={classes}
|
||||||
contentEditable="true"
|
contentEditable={this.props.disabled ? null : true}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onBlur={this.onBlur}
|
onBlur={this.onBlur}
|
||||||
onFocus={this.onFocus}
|
onFocus={this.onFocus}
|
||||||
|
|
|
@ -25,7 +25,7 @@ import { getCaretOffsetAndText } from '../../../editor/dom';
|
||||||
import { htmlSerializeIfNeeded, textSerialize, containsEmote, stripEmoteCommand } from '../../../editor/serialize';
|
import { htmlSerializeIfNeeded, textSerialize, containsEmote, stripEmoteCommand } from '../../../editor/serialize';
|
||||||
import { findEditableEvent } from '../../../utils/EventUtils';
|
import { findEditableEvent } from '../../../utils/EventUtils';
|
||||||
import { parseEvent } from '../../../editor/deserialize';
|
import { parseEvent } from '../../../editor/deserialize';
|
||||||
import { CommandPartCreator, Part, PartCreator } from '../../../editor/parts';
|
import { CommandPartCreator, Part, PartCreator, Type } from '../../../editor/parts';
|
||||||
import EditorStateTransfer from '../../../utils/EditorStateTransfer';
|
import EditorStateTransfer from '../../../utils/EditorStateTransfer';
|
||||||
import BasicMessageComposer from "./BasicMessageComposer";
|
import BasicMessageComposer from "./BasicMessageComposer";
|
||||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||||
|
@ -242,12 +242,12 @@ export default class EditMessageComposer extends React.Component<IProps, IState>
|
||||||
const parts = this.model.parts;
|
const parts = this.model.parts;
|
||||||
const firstPart = parts[0];
|
const firstPart = parts[0];
|
||||||
if (firstPart) {
|
if (firstPart) {
|
||||||
if (firstPart.type === "command" && firstPart.text.startsWith("/") && !firstPart.text.startsWith("//")) {
|
if (firstPart.type === Type.Command && firstPart.text.startsWith("/") && !firstPart.text.startsWith("//")) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (firstPart.text.startsWith("/") && !firstPart.text.startsWith("//")
|
if (firstPart.text.startsWith("/") && !firstPart.text.startsWith("//")
|
||||||
&& (firstPart.type === "plain" || firstPart.type === "pill-candidate")) {
|
&& (firstPart.type === Type.Plain || firstPart.type === Type.PillCandidate)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -268,7 +268,7 @@ export default class EditMessageComposer extends React.Component<IProps, IState>
|
||||||
private getSlashCommand(): [Command, string, string] {
|
private getSlashCommand(): [Command, string, string] {
|
||||||
const commandText = this.model.parts.reduce((text, part) => {
|
const commandText = this.model.parts.reduce((text, part) => {
|
||||||
// use mxid to textify user pills in a command
|
// use mxid to textify user pills in a command
|
||||||
if (part.type === "user-pill") {
|
if (part.type === Type.UserPill) {
|
||||||
return text + part.resourceId;
|
return text + part.resourceId;
|
||||||
}
|
}
|
||||||
return text + part.text;
|
return text + part.text;
|
||||||
|
|
|
@ -58,6 +58,7 @@ function ComposerAvatar(props: IComposerAvatarProps) {
|
||||||
|
|
||||||
interface ISendButtonProps {
|
interface ISendButtonProps {
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
|
title?: string; // defaults to something generic
|
||||||
}
|
}
|
||||||
|
|
||||||
function SendButton(props: ISendButtonProps) {
|
function SendButton(props: ISendButtonProps) {
|
||||||
|
@ -65,7 +66,7 @@ function SendButton(props: ISendButtonProps) {
|
||||||
<AccessibleTooltipButton
|
<AccessibleTooltipButton
|
||||||
className="mx_MessageComposer_sendMessage"
|
className="mx_MessageComposer_sendMessage"
|
||||||
onClick={props.onClick}
|
onClick={props.onClick}
|
||||||
title={_t('Send message')}
|
title={props.title ?? _t('Send message')}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -394,16 +395,18 @@ export default class MessageComposer extends React.Component<IProps, IState> {
|
||||||
controls.push(<Stickerpicker key="stickerpicker_controls_button" room={this.props.room} />);
|
controls.push(<Stickerpicker key="stickerpicker_controls_button" room={this.props.room} />);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (SettingsStore.getValue("feature_voice_messages")) {
|
|
||||||
controls.push(<VoiceRecordComposerTile
|
controls.push(<VoiceRecordComposerTile
|
||||||
key="controls_voice_record"
|
key="controls_voice_record"
|
||||||
ref={c => this.voiceRecordingButton = c}
|
ref={c => this.voiceRecordingButton = c}
|
||||||
room={this.props.room} />);
|
room={this.props.room} />);
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.state.isComposerEmpty || this.state.haveRecording) {
|
if (!this.state.isComposerEmpty || this.state.haveRecording) {
|
||||||
controls.push(
|
controls.push(
|
||||||
<SendButton key="controls_send" onClick={this.sendMessage} />,
|
<SendButton
|
||||||
|
key="controls_send"
|
||||||
|
onClick={this.sendMessage}
|
||||||
|
title={this.state.haveRecording ? _t("Send voice message") : undefined}
|
||||||
|
/>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else if (this.state.tombstone) {
|
} else if (this.state.tombstone) {
|
||||||
|
|
|
@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React, { createRef } from 'react';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { _t } from '../../../languageHandler';
|
import { _t } from '../../../languageHandler';
|
||||||
import dis from '../../../dispatcher/dispatcher';
|
import dis from '../../../dispatcher/dispatcher';
|
||||||
|
@ -38,6 +38,8 @@ interface IProps {
|
||||||
|
|
||||||
@replaceableComponent("views.rooms.ReplyTile")
|
@replaceableComponent("views.rooms.ReplyTile")
|
||||||
export default class ReplyTile extends React.PureComponent<IProps> {
|
export default class ReplyTile extends React.PureComponent<IProps> {
|
||||||
|
private anchorElement = createRef<HTMLAnchorElement>();
|
||||||
|
|
||||||
static defaultProps = {
|
static defaultProps = {
|
||||||
onHeightChanged: () => {},
|
onHeightChanged: () => {},
|
||||||
};
|
};
|
||||||
|
@ -71,7 +73,11 @@ export default class ReplyTile extends React.PureComponent<IProps> {
|
||||||
// Following a link within a reply should not dispatch the `view_room` action
|
// Following a link within a reply should not dispatch the `view_room` action
|
||||||
// so that the browser can direct the user to the correct location
|
// so that the browser can direct the user to the correct location
|
||||||
// The exception being the link wrapping the reply
|
// The exception being the link wrapping the reply
|
||||||
if (clickTarget.tagName.toLowerCase() !== "a" || clickTarget.closest("a") === null) {
|
if (
|
||||||
|
clickTarget.tagName.toLowerCase() !== "a" ||
|
||||||
|
clickTarget.closest("a") === null ||
|
||||||
|
clickTarget === this.anchorElement.current
|
||||||
|
) {
|
||||||
// This allows the permalink to be opened in a new tab/window or copied as
|
// This allows the permalink to be opened in a new tab/window or copied as
|
||||||
// matrix.to, but also for it to enable routing within Riot when clicked.
|
// matrix.to, but also for it to enable routing within Riot when clicked.
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
@ -141,7 +147,7 @@ export default class ReplyTile extends React.PureComponent<IProps> {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classes}>
|
<div className={classes}>
|
||||||
<a href={permalink} onClick={this.onClick}>
|
<a href={permalink} onClick={this.onClick} ref={this.anchorElement}>
|
||||||
{ sender }
|
{ sender }
|
||||||
<EventTileType
|
<EventTileType
|
||||||
ref="tile"
|
ref="tile"
|
||||||
|
|
|
@ -31,7 +31,7 @@ import {
|
||||||
textSerialize,
|
textSerialize,
|
||||||
unescapeMessage,
|
unescapeMessage,
|
||||||
} from '../../../editor/serialize';
|
} from '../../../editor/serialize';
|
||||||
import { CommandPartCreator, Part, PartCreator, SerializedPart } from '../../../editor/parts';
|
import { CommandPartCreator, Part, PartCreator, SerializedPart, Type } from '../../../editor/parts';
|
||||||
import BasicMessageComposer from "./BasicMessageComposer";
|
import BasicMessageComposer from "./BasicMessageComposer";
|
||||||
import ReplyThread from "../elements/ReplyThread";
|
import ReplyThread from "../elements/ReplyThread";
|
||||||
import { findEditableEvent } from '../../../utils/EventUtils';
|
import { findEditableEvent } from '../../../utils/EventUtils';
|
||||||
|
@ -240,14 +240,14 @@ export default class SendMessageComposer extends React.Component<IProps> {
|
||||||
const parts = this.model.parts;
|
const parts = this.model.parts;
|
||||||
const firstPart = parts[0];
|
const firstPart = parts[0];
|
||||||
if (firstPart) {
|
if (firstPart) {
|
||||||
if (firstPart.type === "command" && firstPart.text.startsWith("/") && !firstPart.text.startsWith("//")) {
|
if (firstPart.type === Type.Command && firstPart.text.startsWith("/") && !firstPart.text.startsWith("//")) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
// be extra resilient when somehow the AutocompleteWrapperModel or
|
// be extra resilient when somehow the AutocompleteWrapperModel or
|
||||||
// CommandPartCreator fails to insert a command part, so we don't send
|
// CommandPartCreator fails to insert a command part, so we don't send
|
||||||
// a command as a message
|
// a command as a message
|
||||||
if (firstPart.text.startsWith("/") && !firstPart.text.startsWith("//")
|
if (firstPart.text.startsWith("/") && !firstPart.text.startsWith("//")
|
||||||
&& (firstPart.type === "plain" || firstPart.type === "pill-candidate")) {
|
&& (firstPart.type === Type.Plain || firstPart.type === Type.PillCandidate)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -17,10 +17,7 @@ limitations under the License.
|
||||||
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
|
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
|
||||||
import { _t } from "../../../languageHandler";
|
import { _t } from "../../../languageHandler";
|
||||||
import React, { ReactNode } from "react";
|
import React, { ReactNode } from "react";
|
||||||
import {
|
import { IUpload, RecordingState, VoiceRecording } from "../../../audio/VoiceRecording";
|
||||||
RecordingState,
|
|
||||||
VoiceRecording,
|
|
||||||
} from "../../../audio/VoiceRecording";
|
|
||||||
import { Room } from "matrix-js-sdk/src/models/room";
|
import { Room } from "matrix-js-sdk/src/models/room";
|
||||||
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
|
@ -34,6 +31,11 @@ import { MsgType } from "matrix-js-sdk/src/@types/event";
|
||||||
import Modal from "../../../Modal";
|
import Modal from "../../../Modal";
|
||||||
import ErrorDialog from "../dialogs/ErrorDialog";
|
import ErrorDialog from "../dialogs/ErrorDialog";
|
||||||
import MediaDeviceHandler, { MediaDeviceKindEnum } from "../../../MediaDeviceHandler";
|
import MediaDeviceHandler, { MediaDeviceKindEnum } from "../../../MediaDeviceHandler";
|
||||||
|
import NotificationBadge from "./NotificationBadge";
|
||||||
|
import { StaticNotificationState } from "../../../stores/notifications/StaticNotificationState";
|
||||||
|
import { NotificationColor } from "../../../stores/notifications/NotificationColor";
|
||||||
|
import InlineSpinner from "../elements/InlineSpinner";
|
||||||
|
import { PlaybackManager } from "../../../audio/PlaybackManager";
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
room: Room;
|
room: Room;
|
||||||
|
@ -42,6 +44,7 @@ interface IProps {
|
||||||
interface IState {
|
interface IState {
|
||||||
recorder?: VoiceRecording;
|
recorder?: VoiceRecording;
|
||||||
recordingPhase?: RecordingState;
|
recordingPhase?: RecordingState;
|
||||||
|
didUploadFail?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -69,9 +72,19 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps,
|
||||||
|
|
||||||
await this.state.recorder.stop();
|
await this.state.recorder.stop();
|
||||||
|
|
||||||
|
let upload: IUpload;
|
||||||
try {
|
try {
|
||||||
const upload = await this.state.recorder.upload(this.props.room.roomId);
|
upload = await this.state.recorder.upload(this.props.room.roomId);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error uploading voice message:", e);
|
||||||
|
|
||||||
|
// Flag error and move on. The recording phase will be reset by the upload function.
|
||||||
|
this.setState({ didUploadFail: true });
|
||||||
|
|
||||||
|
return; // don't dispose the recording: the user has a chance to re-upload
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
// noinspection ES6MissingAwait - we don't care if it fails, it'll get queued.
|
// noinspection ES6MissingAwait - we don't care if it fails, it'll get queued.
|
||||||
MatrixClientPeg.get().sendMessage(this.props.room.roomId, {
|
MatrixClientPeg.get().sendMessage(this.props.room.roomId, {
|
||||||
"body": "Voice message",
|
"body": "Voice message",
|
||||||
|
@ -104,12 +117,11 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps,
|
||||||
"org.matrix.msc3245.voice": {}, // No content, this is a rendering hint
|
"org.matrix.msc3245.voice": {}, // No content, this is a rendering hint
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Error sending/uploading voice message:", e);
|
console.error("Error sending voice message:", e);
|
||||||
Modal.createTrackedDialog('Upload failed', '', ErrorDialog, {
|
|
||||||
title: _t('Upload Failed'),
|
// Voice message should be in the timeline at this point, so let other things take care
|
||||||
description: _t("The voice message failed to upload."),
|
// of error handling. We also shouldn't need the recording anymore, so fall through to
|
||||||
});
|
// disposal.
|
||||||
return; // don't dispose the recording so the user can retry, maybe
|
|
||||||
}
|
}
|
||||||
await this.disposeRecording();
|
await this.disposeRecording();
|
||||||
}
|
}
|
||||||
|
@ -118,7 +130,7 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps,
|
||||||
await VoiceRecordingStore.instance.disposeRecording();
|
await VoiceRecordingStore.instance.disposeRecording();
|
||||||
|
|
||||||
// Reset back to no recording, which means no phase (ie: restart component entirely)
|
// Reset back to no recording, which means no phase (ie: restart component entirely)
|
||||||
this.setState({ recorder: null, recordingPhase: null });
|
this.setState({ recorder: null, recordingPhase: null, didUploadFail: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
private onCancel = async () => {
|
private onCancel = async () => {
|
||||||
|
@ -166,6 +178,9 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps,
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// stop any noises which might be happening
|
||||||
|
await PlaybackManager.instance.playOnly(null);
|
||||||
|
|
||||||
const recorder = VoiceRecordingStore.instance.startRecording();
|
const recorder = VoiceRecordingStore.instance.startRecording();
|
||||||
await recorder.start();
|
await recorder.start();
|
||||||
|
|
||||||
|
@ -200,7 +215,7 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps,
|
||||||
}
|
}
|
||||||
|
|
||||||
public render(): ReactNode {
|
public render(): ReactNode {
|
||||||
let recordingInfo;
|
let stopOrRecordBtn;
|
||||||
let deleteButton;
|
let deleteButton;
|
||||||
if (!this.state.recordingPhase || this.state.recordingPhase === RecordingState.Started) {
|
if (!this.state.recordingPhase || this.state.recordingPhase === RecordingState.Started) {
|
||||||
const classes = classNames({
|
const classes = classNames({
|
||||||
|
@ -209,12 +224,12 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps,
|
||||||
'mx_VoiceRecordComposerTile_stop': this.state.recorder?.isRecording,
|
'mx_VoiceRecordComposerTile_stop': this.state.recorder?.isRecording,
|
||||||
});
|
});
|
||||||
|
|
||||||
let tooltip = _t("Record a voice message");
|
let tooltip = _t("Send voice message");
|
||||||
if (!!this.state.recorder) {
|
if (!!this.state.recorder) {
|
||||||
tooltip = _t("Stop the recording");
|
tooltip = _t("Stop recording");
|
||||||
}
|
}
|
||||||
|
|
||||||
let stopOrRecordBtn = <AccessibleTooltipButton
|
stopOrRecordBtn = <AccessibleTooltipButton
|
||||||
className={classes}
|
className={classes}
|
||||||
onClick={this.onRecordStartEndClick}
|
onClick={this.onRecordStartEndClick}
|
||||||
title={tooltip}
|
title={tooltip}
|
||||||
|
@ -222,22 +237,41 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps,
|
||||||
if (this.state.recorder && !this.state.recorder?.isRecording) {
|
if (this.state.recorder && !this.state.recorder?.isRecording) {
|
||||||
stopOrRecordBtn = null;
|
stopOrRecordBtn = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
recordingInfo = stopOrRecordBtn;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.state.recorder && this.state.recordingPhase !== RecordingState.Uploading) {
|
if (this.state.recorder && this.state.recordingPhase !== RecordingState.Uploading) {
|
||||||
deleteButton = <AccessibleTooltipButton
|
deleteButton = <AccessibleTooltipButton
|
||||||
className='mx_VoiceRecordComposerTile_delete'
|
className='mx_VoiceRecordComposerTile_delete'
|
||||||
title={_t("Delete recording")}
|
title={_t("Delete")}
|
||||||
onClick={this.onCancel}
|
onClick={this.onCancel}
|
||||||
/>;
|
/>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let uploadIndicator;
|
||||||
|
if (this.state.recordingPhase === RecordingState.Uploading) {
|
||||||
|
uploadIndicator = <span className='mx_VoiceRecordComposerTile_uploadingState'>
|
||||||
|
<InlineSpinner w={16} h={16} />
|
||||||
|
</span>;
|
||||||
|
} else if (this.state.didUploadFail && this.state.recordingPhase === RecordingState.Ended) {
|
||||||
|
uploadIndicator = <span className='mx_VoiceRecordComposerTile_failedState'>
|
||||||
|
<span className='mx_VoiceRecordComposerTile_uploadState_badge'>
|
||||||
|
{ /* Need to stick the badge in a span to ensure it doesn't create a block component */ }
|
||||||
|
<NotificationBadge
|
||||||
|
notification={StaticNotificationState.forSymbol("!", NotificationColor.Red)}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span className='text-warning'>{ _t("Failed to send") }</span>
|
||||||
|
</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The record button (mic icon) is meant to be on the right edge, but we also want the
|
||||||
|
// stop button to be left of the waveform area. Luckily, none of the surrounding UI is
|
||||||
|
// rendered when we're not recording, so the record button ends up in the correct spot.
|
||||||
return (<>
|
return (<>
|
||||||
|
{ uploadIndicator }
|
||||||
{ deleteButton }
|
{ deleteButton }
|
||||||
|
{ stopOrRecordBtn }
|
||||||
{ this.renderWaveformArea() }
|
{ this.renderWaveformArea() }
|
||||||
{ recordingInfo }
|
|
||||||
</>);
|
</>);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
133
src/components/views/settings/LayoutSwitcher.tsx
Normal file
133
src/components/views/settings/LayoutSwitcher.tsx
Normal file
|
@ -0,0 +1,133 @@
|
||||||
|
/*
|
||||||
|
Copyright 2019 New Vector Ltd
|
||||||
|
Copyright 2019 - 2021 The Matrix.org Foundation C.I.C.
|
||||||
|
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 React from "react";
|
||||||
|
import classNames from "classnames";
|
||||||
|
import SettingsStore from "../../../settings/SettingsStore";
|
||||||
|
import EventTilePreview from "../elements/EventTilePreview";
|
||||||
|
import StyledRadioButton from "../elements/StyledRadioButton";
|
||||||
|
import { _t } from "../../../languageHandler";
|
||||||
|
import { Layout } from "../../../settings/Layout";
|
||||||
|
import { SettingLevel } from "../../../settings/SettingLevel";
|
||||||
|
|
||||||
|
interface IProps {
|
||||||
|
userId: string;
|
||||||
|
displayName: string;
|
||||||
|
avatarUrl: string;
|
||||||
|
messagePreviewText: string;
|
||||||
|
onLayoutChanged?: (layout: Layout) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IState {
|
||||||
|
layout: Layout;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class LayoutSwitcher extends React.Component<IProps, IState> {
|
||||||
|
constructor(props: IProps) {
|
||||||
|
super(props);
|
||||||
|
|
||||||
|
this.state = {
|
||||||
|
layout: SettingsStore.getValue("layout"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private onLayoutChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
|
||||||
|
const layout = e.target.value as Layout;
|
||||||
|
|
||||||
|
this.setState({ layout: layout });
|
||||||
|
SettingsStore.setValue("layout", null, SettingLevel.DEVICE, layout);
|
||||||
|
this.props.onLayoutChanged(layout);
|
||||||
|
};
|
||||||
|
|
||||||
|
public render(): JSX.Element {
|
||||||
|
const ircClasses = classNames("mx_LayoutSwitcher_RadioButton", {
|
||||||
|
mx_LayoutSwitcher_RadioButton_selected: this.state.layout == Layout.IRC,
|
||||||
|
});
|
||||||
|
const groupClasses = classNames("mx_LayoutSwitcher_RadioButton", {
|
||||||
|
mx_LayoutSwitcher_RadioButton_selected: this.state.layout == Layout.Group,
|
||||||
|
});
|
||||||
|
const bubbleClasses = classNames("mx_LayoutSwitcher_RadioButton", {
|
||||||
|
mx_LayoutSwitcher_RadioButton_selected: this.state.layout === Layout.Bubble,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx_SettingsTab_section mx_LayoutSwitcher">
|
||||||
|
<span className="mx_SettingsTab_subheading">
|
||||||
|
{ _t("Message layout") }
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div className="mx_LayoutSwitcher_RadioButtons">
|
||||||
|
<label className={ircClasses}>
|
||||||
|
<EventTilePreview
|
||||||
|
className="mx_LayoutSwitcher_RadioButton_preview"
|
||||||
|
message={this.props.messagePreviewText}
|
||||||
|
layout={Layout.IRC}
|
||||||
|
userId={this.props.userId}
|
||||||
|
displayName={this.props.displayName}
|
||||||
|
avatarUrl={this.props.avatarUrl}
|
||||||
|
/>
|
||||||
|
<StyledRadioButton
|
||||||
|
name="layout"
|
||||||
|
value={Layout.IRC}
|
||||||
|
checked={this.state.layout === Layout.IRC}
|
||||||
|
onChange={this.onLayoutChange}
|
||||||
|
>
|
||||||
|
{ _t("IRC") }
|
||||||
|
</StyledRadioButton>
|
||||||
|
</label>
|
||||||
|
<label className={groupClasses}>
|
||||||
|
<EventTilePreview
|
||||||
|
className="mx_LayoutSwitcher_RadioButton_preview"
|
||||||
|
message={this.props.messagePreviewText}
|
||||||
|
layout={Layout.Group}
|
||||||
|
userId={this.props.userId}
|
||||||
|
displayName={this.props.displayName}
|
||||||
|
avatarUrl={this.props.avatarUrl}
|
||||||
|
/>
|
||||||
|
<StyledRadioButton
|
||||||
|
name="layout"
|
||||||
|
value={Layout.Group}
|
||||||
|
checked={this.state.layout == Layout.Group}
|
||||||
|
onChange={this.onLayoutChange}
|
||||||
|
>
|
||||||
|
{ _t("Modern") }
|
||||||
|
</StyledRadioButton>
|
||||||
|
</label>
|
||||||
|
<label className={bubbleClasses}>
|
||||||
|
<EventTilePreview
|
||||||
|
className="mx_LayoutSwitcher_RadioButton_preview"
|
||||||
|
message={this.props.messagePreviewText}
|
||||||
|
layout={Layout.Bubble}
|
||||||
|
userId={this.props.userId}
|
||||||
|
displayName={this.props.displayName}
|
||||||
|
avatarUrl={this.props.avatarUrl}
|
||||||
|
/>
|
||||||
|
<StyledRadioButton
|
||||||
|
name="layout"
|
||||||
|
value={Layout.Bubble}
|
||||||
|
checked={this.state.layout == Layout.Bubble}
|
||||||
|
onChange={this.onLayoutChange}
|
||||||
|
>
|
||||||
|
{ _t("Message bubbles") }
|
||||||
|
</StyledRadioButton>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue