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
pull/21833/head
Michael Telatynski 2021-08-12 11:41:03 +01:00
commit 0a209afdc2
196 changed files with 8563 additions and 2976 deletions

1
.github/CODEOWNERS vendored Normal file
View File

@ -0,0 +1 @@
* @matrix-org/element-web

31
.github/workflows/layered-build.yaml vendored Normal file
View 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
View 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.

View 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
View File

@ -0,0 +1 @@
14

View File

@ -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)
=====================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.26.0-rc.1...v3.26.0)

View File

@ -34,7 +34,7 @@ All code lands on the `develop` branch - `master` is only used for stable releas
**Please file PRs against `develop`!!**
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:
https://github.com/matrix-org/matrix-react-sdk/blob/master/code_style.md

View File

@ -1,6 +1,6 @@
{
"name": "matrix-react-sdk",
"version": "3.26.0",
"version": "3.27.0",
"description": "SDK for matrix.org using React",
"author": "matrix.org",
"repository": {
@ -80,13 +80,14 @@
"katex": "^0.12.0",
"linkifyjs": "^2.1.9",
"lodash": "^4.17.20",
"matrix-js-sdk": "12.1.0",
"matrix-js-sdk": "12.2.0",
"matrix-widget-api": "^0.1.0-beta.15",
"minimist": "^1.2.5",
"opus-recorder": "^8.0.3",
"pako": "^2.0.3",
"parse5": "^6.0.1",
"png-chunks-extract": "^1.0.0",
"posthog-js": "1.12.2",
"prop-types": "^15.7.2",
"qrcode": "^1.4.4",
"re-resizable": "^6.9.0",
@ -123,6 +124,7 @@
"@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",
"@peculiar/webcrypto": "^1.1.4",
"@sentry/types": "^6.10.0",
"@sinonjs/fake-timers": "^7.0.2",
"@types/classnames": "^2.2.11",
"@types/commonmark": "^0.27.4",
@ -147,13 +149,14 @@
"@typescript-eslint/eslint-plugin": "^4.17.0",
"@typescript-eslint/parser": "^4.17.0",
"@wojtekmaj/enzyme-adapter-react-17": "^0.6.1",
"allchange": "github:matrix-org/allchange",
"babel-jest": "^26.6.3",
"chokidar": "^3.5.1",
"concurrently": "^5.3.0",
"enzyme": "^3.11.0",
"eslint": "7.18.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-hooks": "^4.2.0",
"glob": "^7.1.6",
@ -166,6 +169,7 @@
"matrix-web-i18n": "github:matrix-org/matrix-web-i18n",
"react-test-renderer": "^17.0.2",
"rimraf": "^3.0.2",
"rrweb-snapshot": "1.1.7",
"stylelint": "^13.9.0",
"stylelint-config-standard": "^20.0.0",
"stylelint-scss": "^3.18.0",
@ -189,7 +193,8 @@
"decoderWorker\\.min\\.js": "<rootDir>/__mocks__/empty.js",
"decoderWorker\\.min\\.wasm": "<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": [
"/node_modules/(?!matrix-js-sdk).+$"

4
release_config.yaml Normal file
View File

@ -0,0 +1,4 @@
subprojects:
matrix-js-sdk:
includeByDefault: false

View File

@ -75,6 +75,7 @@
@import "./views/dialogs/_CreateCommunityPrototypeDialog.scss";
@import "./views/dialogs/_CreateGroupDialog.scss";
@import "./views/dialogs/_CreateRoomDialog.scss";
@import "./views/dialogs/_CreateSpaceFromCommunityDialog.scss";
@import "./views/dialogs/_CreateSubspaceDialog.scss";
@import "./views/dialogs/_DeactivateAccountDialog.scss";
@import "./views/dialogs/_DevtoolsDialog.scss";
@ -240,6 +241,7 @@
@import "./views/settings/_E2eAdvancedPanel.scss";
@import "./views/settings/_EmailAddresses.scss";
@import "./views/settings/_IntegrationManager.scss";
@import "./views/settings/_LayoutSwitcher.scss";
@import "./views/settings/_Notifications.scss";
@import "./views/settings/_PhoneNumbers.scss";
@import "./views/settings/_ProfileSettings.scss";
@ -266,12 +268,15 @@
@import "./views/spaces/_SpacePublicShare.scss";
@import "./views/terms/_InlineTermsAgreement.scss";
@import "./views/toasts/_AnalyticsToast.scss";
@import "./views/toasts/_IncomingCallToast.scss";
@import "./views/toasts/_NonUrgentEchoFailureToast.scss";
@import "./views/verification/_VerificationShowSas.scss";
@import "./views/voip/CallView/_CallViewButtons.scss";
@import "./views/voip/_CallContainer.scss";
@import "./views/voip/_CallPreview.scss";
@import "./views/voip/_CallView.scss";
@import "./views/voip/_CallViewForRoom.scss";
@import "./views/voip/_CallViewHeader.scss";
@import "./views/voip/_CallViewSidebar.scss";
@import "./views/voip/_DialPad.scss";
@import "./views/voip/_DialPadContextMenu.scss";

View File

@ -368,6 +368,65 @@ limitations under the License.
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) {
padding-left: 16px;
padding-right: 16px;

View File

@ -255,7 +255,7 @@ limitations under the License.
}
}
&:hover {
&:hover, &:focus-within {
background-color: $groupFilterPanel-bg-color;
.mx_AccessibleButton {
@ -264,6 +264,10 @@ limitations under the License.
}
}
li.mx_SpaceRoomDirectory_roomTileWrapper {
list-style: none;
}
.mx_SpaceHierarchy_roomTile,
.mx_SpaceHierarchy_subspace_children {
&::before {

View File

@ -297,7 +297,7 @@ $activeBorderColor: $secondary-fg-color;
.mx_SpaceButton:hover,
.mx_SpaceButton:focus-within,
.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
.mx_SpacePanel_badgeContainer {
width: 0;
@ -368,6 +368,14 @@ $activeBorderColor: $secondary-fg-color;
.mx_SpacePanel_iconExplore::before {
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
}
}
}

View File

@ -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 {
display: flex;
align-items: center;
@ -342,7 +354,7 @@ $SpaceRoomViewInnerWidth: 428px;
.mx_SpaceFeedbackPrompt {
padding: 7px; // 8px - 1px border
border: 1px solid $menu-border-color;
border: 1px solid rgba($primary-fg-color, .1);
border-radius: 8px;
width: max-content;
margin: 0 0 -40px auto; // collapse its own height to not push other components down

View File

@ -28,7 +28,7 @@ limitations under the License.
margin: 0 4px;
grid-row: 2 / 4;
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);
border-radius: 8px;
}
@ -37,7 +37,7 @@ limitations under the License.
grid-row: 1 / 3;
grid-column: 1;
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);
border-radius: 8px;
overflow: hidden;

View File

@ -85,7 +85,7 @@ limitations under the License.
.mx_InteractiveAuthEntryComponents_termsPolicy {
display: flex;
flex-direction: row;
justify-content: start;
justify-content: flex-start;
align-items: center;
}

View File

@ -149,12 +149,17 @@ limitations under the License.
}
}
.mx_IconizedContextMenu_checked {
.mx_IconizedContextMenu_checked,
.mx_IconizedContextMenu_unchecked {
margin-left: 16px;
margin-right: -5px;
}
&::before {
mask-image: url('$(res)/img/element-icons/roomlist/checkmark.svg');
}
.mx_IconizedContextMenu_checked::before {
mask-image: url('$(res)/img/element-icons/roomlist/checkmark.svg');
}
.mx_IconizedContextMenu_unchecked::before {
content: unset;
}
}

View File

@ -51,6 +51,10 @@ limitations under the License.
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 {
margin-top: 0;
margin-bottom: 0;

View 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;
}
}
}

View File

@ -24,35 +24,33 @@ limitations under the License.
align-items: flex-start;
height: 500px;
overflow: overlay;
}
.mx_desktopCapturerSourcePicker_source {
display: flex;
flex-direction: column;
margin: 8px;
}
.mx_desktopCapturerSourcePicker_source {
width: 50%;
display: flex;
flex-direction: column;
.mx_desktopCapturerSourcePicker_source_thumbnail {
margin: 4px;
padding: 4px;
width: 312px;
border-width: 2px;
border-radius: 8px;
border-style: solid;
border-color: transparent;
.mx_desktopCapturerSourcePicker_source_thumbnail {
margin: 4px;
padding: 4px;
border-width: 2px;
border-radius: 8px;
border-style: solid;
border-color: transparent;
&.mx_desktopCapturerSourcePicker_source_thumbnail_selected,
&:hover,
&:focus {
border-color: $accent-color;
&.mx_desktopCapturerSourcePicker_source_thumbnail_selected,
&:hover,
&:focus {
border-color: $accent-color;
}
}
.mx_desktopCapturerSourcePicker_source_name {
margin: 0 4px;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
}
}
.mx_desktopCapturerSourcePicker_source_name {
margin: 0 4px;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
width: 312px;
}
}

View File

@ -38,6 +38,7 @@ limitations under the License.
.mx_Field input,
.mx_Field select,
.mx_Field textarea {
font-family: inherit;
font-weight: normal;
font-size: $font-14px;
border: none;

View File

@ -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");
you may not use this file except in compliance with the License.
@ -60,6 +60,8 @@ limitations under the License.
}
.mx_MFileBody_info {
cursor: pointer;
.mx_MFileBody_info_icon {
background-color: $message-body-panel-icon-bg-color;
border-radius: 20px;

View File

@ -65,6 +65,14 @@ limitations under the License.
font-size: $font-10-4px;
}
}
span.mx_UserPill {
cursor: pointer;
}
span.mx_RoomPill {
cursor: default;
}
}
&.mx_BasicMessageComposer_input_disabled {

View File

@ -271,7 +271,7 @@ limitations under the License.
display: flex;
align-items: center;
justify-content: start;
justify-content: flex-start;
padding: 5px 0;
.mx_EventTile_avatar {

View File

@ -310,14 +310,12 @@ $hover-select-border: 4px;
}
.mx_RoomView_timeline_rr_enabled {
.mx_EventTile:not([data-layout=bubble]) {
.mx_EventTile[data-layout=group] {
.mx_EventTile_line {
/* ideally should be 100px, but 95px gives us a max thumbnail size of 800x600, which is nice */
margin-right: 110px;
}
}
// 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 */
.mx_EventTile_body pre {
border: 1px solid transparent;
.mx_EventTile_body {
a:hover {
text-decoration: underline;
}
pre {
border: 1px solid transparent;
}
}
.mx_EventTile_content .markdown-body {
@ -485,6 +489,10 @@ $hover-select-border: 4px;
// https://github.com/vector-im/vector-web/issues/754
overflow-x: overlay;
overflow-y: visible;
&::-webkit-scrollbar-corner {
background: transparent;
}
}
}

View File

@ -20,7 +20,7 @@ limitations under the License.
height: 28px;
border: 2px solid $voice-record-stop-border-color;
border-radius: 32px;
margin-right: 16px; // between us and the send button
margin-right: 8px; // between us and the waveform component
position: relative;
&::after {
@ -46,9 +46,28 @@ limitations under the License.
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 {
// 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-right: 12px; // isolate from stop/send button
@ -68,7 +87,7 @@ limitations under the License.
height: 10px;
position: absolute;
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;
}
}

View 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%;
}
}
}
}

View File

@ -16,6 +16,7 @@ limitations under the License.
.mx_ProfileSettings_controls_topic {
& > textarea {
font-family: inherit;
resize: vertical;
}
}

View File

@ -50,15 +50,21 @@ limitations under the License.
}
.mx_SettingsTab_section {
$right-gutter: 80px;
margin-bottom: 24px;
.mx_SettingsFlag {
margin-right: 80px;
margin-right: $right-gutter;
margin-bottom: 10px;
}
> p {
margin-right: $right-gutter;
}
&.mx_SettingsTab_subsectionText .mx_SettingsFlag {
margin-right: 0px !important;
margin-right: 0 !important;
}
}
@ -72,6 +78,13 @@ limitations under the License.
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 {
float: right;
}

View File

@ -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");
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);
}
.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 {
color: $primary-fg-color;

View File

@ -28,28 +28,32 @@ limitations under the License.
user-select: all;
}
.mx_HelpUserSettingsTab_accessToken {
.mx_HelpUserSettingsTab_copy {
display: flex;
justify-content: space-between;
border-radius: 5px;
border: solid 1px $light-fg-color;
margin-bottom: 10px;
margin-top: 10px;
padding: 10px;
}
width: max-content;
.mx_HelpUserSettingsTab_accessToken_copy {
flex-shrink: 0;
cursor: pointer;
margin-left: 20px;
display: inherit;
}
.mx_HelpUserSettingsTab_copyButton {
flex-shrink: 0;
width: 20px;
height: 20px;
cursor: pointer;
margin-left: 20px;
display: block;
.mx_HelpUserSettingsTab_accessToken_copy > div {
mask-image: url($copy-button-url);
background-color: $message-action-bar-fg-color;
margin-left: 5px;
width: 20px;
height: 20px;
background-repeat: no-repeat;
&::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;
}
}
}

View File

@ -22,4 +22,25 @@ limitations under the License.
.mx_SettingsTab_section {
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;
}
}
}
}

View File

@ -41,7 +41,6 @@ $spacePanelWidth: 71px;
> p {
font-size: $font-15px;
color: $secondary-fg-color;
margin: 0;
}
.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 {
@mixin SpacePillButton;
}
@ -100,6 +92,11 @@ $spacePanelWidth: 71px;
width: min-content;
}
.mx_AccessibleButton_kind_link {
padding: 0;
font-size: inherit;
}
.mx_AccessibleButton_disabled {
cursor: not-allowed;
}

View 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');
}
}

View 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;
}
}
}

View File

@ -28,7 +28,6 @@ limitations under the License.
.mx_CallPreview {
pointer-events: initial; // restore pointer events so the user can leave/interact
cursor: pointer;
.mx_VideoFeed_remote.mx_VideoFeed_voice {
min-height: 150px;
@ -43,84 +42,4 @@ limitations under the License.
.mx_AppTile_persistedWrapper div {
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');
}
}
}

View File

@ -39,7 +39,7 @@ limitations under the License.
.mx_CallView_pip {
width: 320px;
padding-bottom: 8px;
background-color: $voipcall-plinth-color;
background-color: $toast-bg-color;
box-shadow: 0px 4px 20px rgba(0, 0, 0, 0.20);
border-radius: 8px;
@ -47,11 +47,11 @@ limitations under the License.
height: 180px;
}
.mx_CallView_callControls {
.mx_CallViewButtons {
bottom: 0px;
}
.mx_CallView_callControls_button {
.mx_CallViewButtons_button {
&::before {
width: 36px;
height: 36px;
@ -75,17 +75,21 @@ limitations under the License.
height: 100%;
&.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;
display: flex;
justify-content: center;
align-items: center;
}
&.mx_VideoFeed_video {
.mx_VideoFeed_video {
height: 100%;
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 {
opacity: 1;
@ -341,94 +218,3 @@ limitations under the License.
opacity: 0.001; // opacity 0 can cause a re-layout
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;
}

View 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');
}
}

View File

@ -35,11 +35,20 @@ limitations under the License.
width: 100%;
&.mx_VideoFeed_voice {
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
}
aspect-ratio: 16 / 9;
.mx_VideoFeed_video {
border-radius: 4px;
}
.mx_VideoFeed_mic {
left: 6px;
bottom: 6px;
}
}

View File

@ -69,7 +69,6 @@ limitations under the License.
overflow: hidden;
max-width: 185px;
text-align: left;
direction: rtl;
padding: 8px 0px;
background-color: rgb(0, 0, 0, 0);
}

View File

@ -15,18 +15,53 @@ limitations under the License.
*/
.mx_VideoFeed {
border-radius: 4px;
overflow: hidden;
position: relative;
&.mx_VideoFeed_voice {
background-color: $inverted-bg-color;
aspect-ratio: 16 / 9;
}
&.mx_VideoFeed_video {
.mx_VideoFeed_video {
width: 100%;
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);
}

View File

@ -1,7 +1,3 @@
<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="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"/>
<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"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 744 B

View 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

View 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

View File

@ -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
// try to use these colors when possible
$bg-color: #15191E;
$bg-color: $background;
$base-color: $bg-color;
$base-text-color: #ffffff;
$base-text-color: $primary-content;
$header-panel-bg-color: #20252B;
$header-panel-border-color: #000000;
$header-panel-text-primary-color: #B9BEC6;
$header-panel-text-secondary-color: #c8c8cd;
$text-primary-color: #ffffff;
$text-primary-color: $primary-content;
$text-secondary-color: #B9BEC6;
$quaternary-fg-color: #6F7882;
$quaternary-fg-color: $quaternary-content;
$search-bg-color: #181b21;
$search-placeholder-color: #61708b;
$room-highlight-color: #343a46;
@ -20,8 +40,8 @@ $primary-bg-color: $bg-color;
$muted-fg-color: $header-panel-text-primary-color;
// additional text colors
$secondary-fg-color: #A9B2BC;
$tertiary-fg-color: #8E99A4;
$secondary-fg-color: $secondary-content;
$tertiary-fg-color: $tertiary-content;
// used for dialog box text
$light-fg-color: $header-panel-text-secondary-color;
@ -47,7 +67,7 @@ $inverted-bg-color: $base-color;
$selected-color: $room-highlight-color;
// selected for hoverover & selected event tiles
$event-selected-color: #21262c;
$event-selected-color: $system-dark;
// used for the hairline dividers in RoomView
$primary-hairline-color: transparent;
@ -91,7 +111,7 @@ $lightbox-background-bg-color: #000;
$lightbox-background-bg-opacity: 0.85;
$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-button-bg-color: #e7e7e7;
$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;
$groupheader-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;
$eventtile-meta-color: $roomtopic-color;
$header-divider-color: $header-panel-text-primary-color;
$composer-e2e-icon-color: $header-panel-text-primary-color;
// this probably shouldn't have it's own colour
$voipcall-plinth-color: #394049;
$toast-bg-color: $quinary-content;
// ********************
$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-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;
// Buttons
$button-primary-fg-color: #ffffff;
$button-primary-fg-color: $primary-content;
$button-primary-bg-color: $accent-color;
$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-disabled-fg-color: #ffffff;
$button-danger-disabled-fg-color: $primary-content;
$button-danger-disabled-bg-color: #f5b6bb; // TODO: Verify color
$button-link-fg-color: $accent-color;
$button-link-bg-color: transparent;
@ -175,7 +194,7 @@ $button-link-bg-color: transparent;
$togglesw-off-color: $room-highlight-color;
$progressbar-fg-color: $accent-color;
$progressbar-bg-color: #21262c;
$progressbar-bg-color: $system-dark;
$visual-bell-bg-color: #800;
@ -198,19 +217,19 @@ $reaction-row-button-selected-border-color: $accent-color;
$kbd-border-color: #000000;
$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-fg-color: #ffffff;
$interactive-tooltip-fg-color: $primary-content;
$breadcrumb-placeholder-bg-color: #272c35;
$user-tile-hover-bg-color: $header-panel-bg-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-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-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);
// Bubble tiles
$eventbubble-self-bg: #143A34;
$eventbubble-others-bg: #394049;
$eventbubble-bg-hover: #433C23;
$eventbubble-self-bg: #14322E;
$eventbubble-others-bg: $event-selected-color;
$eventbubble-bg-hover: #1C2026;
$eventbubble-avatar-outline: $bg-color;
$eventbubble-reply-color: #C1C6CD;

View File

@ -111,8 +111,8 @@ $eventtile-meta-color: $roomtopic-color;
$header-divider-color: $header-panel-text-primary-color;
$composer-e2e-icon-color: $header-panel-text-primary-color;
// this probably shouldn't have it's own colour
$voipcall-plinth-color: #394049;
$quinary-content-color: #394049;
$toast-bg-color: $quinary-content-color;
// ********************
@ -222,6 +222,13 @@ $appearance-tab-border-color: $room-highlight-color;
$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! *****
@define-mixin mx_DialogButton {

View File

@ -8,9 +8,12 @@
/* Noto Color Emoji contains digits, in fixed-width, therefore causing
digits in flowed text to stand out.
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
// try to use these colors when possible
@ -178,8 +181,8 @@ $eventtile-meta-color: $roomtopic-color;
$composer-e2e-icon-color: #91a1c0;
$header-divider-color: #91a1c0;
// this probably shouldn't have it's own colour
$voipcall-plinth-color: #F4F6FA;
$toast-bg-color: $system-light;
$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-bg-color: #E3E8F0;
$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
$voice-record-stop-symbol-color: #ff4b55;
@ -348,9 +351,9 @@ $appearance-tab-border-color: $input-darker-bg-color;
$composer-shadow-color: tranparent;
// Bubble tiles
$eventbubble-self-bg: #F8FDFC;
$eventbubble-others-bg: #F7F8F9;
$eventbubble-bg-hover: rgb(242, 242, 242);
$eventbubble-self-bg: #F0FBF8;
$eventbubble-others-bg: $system-light;
$eventbubble-bg-hover: #FAFBFD;
$eventbubble-avatar-outline: #fff;
$eventbubble-reply-color: #C1C6CD;
@ -390,7 +393,7 @@ $eventbubble-reply-color: #C1C6CD;
@define-mixin mx_DialogButton_secondary {
// flip colours for the secondary ones
font-weight: 600;
border: 1px solid $accent-color ! important;
border: 1px solid $accent-color !important;
color: $accent-color;
background-color: $button-secondary-bg-color;
}

View File

@ -140,3 +140,10 @@ $event-highlight-bg-color: var(--timeline-highlights-color);
//
// redirect some variables away from their hardcoded values in the light theme
$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);

View File

@ -8,24 +8,43 @@
/* Noto Color Emoji contains digits, in fixed-width, therefore causing
digits in flowed text to stand out.
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
// try to use these colors when possible
$accent-color: #0DBD8B;
$accent-color: $accent;
$accent-bg-color: rgba(3, 179, 129, 0.16);
$notice-primary-color: #ff4b55;
$notice-primary-bg-color: rgba(255, 75, 85, 0.16);
$primary-fg-color: #2e2f32;
$secondary-fg-color: #737D8C;
$secondary-fg-color: $secondary-content;
$tertiary-fg-color: #8D99A5;
$quaternary-fg-color: #C1C6CD;
$quaternary-fg-color: $quaternary-content;
$header-panel-bg-color: #f3f8fd;
// 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
// used for dialog box text
@ -35,7 +54,7 @@ $light-fg-color: #747474;
$focus-bg-color: #dddddd;
// 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-darker: #92caad;
$accent-color-alt: #238CF5;
@ -79,7 +98,7 @@ $primary-hairline-color: transparent;
// used for the border of input text fields
$input-border-color: #e7e7e7;
$input-darker-bg-color: #e3e8f0;
$input-darker-bg-color: $quinary-content;
$input-darker-fg-color: #9fa9ba;
$input-lighter-bg-color: #f2f5f8;
$input-lighter-fg-color: $input-darker-fg-color;
@ -87,7 +106,7 @@ $input-focused-border-color: #238cf5;
$input-valid-border-color: $accent-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-fg-color: white;
@ -109,8 +128,8 @@ $menu-bg-color: #fff;
$menu-box-shadow-color: rgba(118, 131, 156, 0.6);
$menu-selected-color: #f5f8fa;
$avatar-initial-color: #ffffff;
$avatar-bg-color: #ffffff;
$avatar-initial-color: $background;
$avatar-bg-color: $background;
$h3-color: #3d3b39;
@ -138,7 +157,7 @@ $blockquote-bar-color: #ddd;
$blockquote-fg-color: #777;
$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-button-bg-color: #e7e7e7;
$settings-profile-button-fg-color: $settings-profile-overlay-placeholder-fg-color;
@ -160,24 +179,24 @@ $roomheader-addroom-fg-color: #5c6470;
$groupFilterPanel-button-color: #91A1C0;
$groupheader-button-color: #91A1C0;
$rightpanel-button-color: #91A1C0;
$icon-button-color: #C1C6CD;
$icon-button-color: $quaternary-content;
$roomtopic-color: #9e9e9e;
$eventtile-meta-color: $roomtopic-color;
$composer-e2e-icon-color: #91A1C0;
$header-divider-color: #91A1C0;
// this probably shouldn't have it's own colour
$voipcall-plinth-color: #F4F6FA;
$toast-bg-color: $system-light;
$voipcall-plinth-color: $system-light;
// ********************
$theme-button-bg-color: #e3e8f0;
$dialpad-button-bg-color: #e3e8f0;
$theme-button-bg-color: $quinary-content;
$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-filter-active-bg-color: #ffffff;
$roomlist-filter-active-bg-color: $background;
$roomlist-bg-color: rgba(245, 245, 245, 0.90);
$roomlist-header-color: $tertiary-fg-color;
$roomsublist-divider-color: $primary-fg-color;
@ -191,7 +210,7 @@ $roomtile-selected-bg-color: #FFF;
$presence-online: $accent-color;
$presence-away: #d9b072;
$presence-offline: #E3E8F0;
$presence-offline: $quinary-content;
// ********************
@ -254,7 +273,7 @@ $lightbox-border-color: #ffffff;
// Tabbed views
$tab-label-fg-color: #45474a;
$tab-label-active-fg-color: #ffffff;
$tab-label-active-fg-color: $background;
$tab-label-bg-color: transparent;
$tab-label-active-bg-color: $accent-color;
$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-bg-color: $accent-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-disabled-fg-color: #ffffff;
$button-danger-disabled-fg-color: $background;
$button-danger-disabled-bg-color: #f5b6bb; // TODO: Verify color
$button-link-fg-color: $accent-color;
$button-link-bg-color: transparent;
@ -291,7 +310,7 @@ $memberstatus-placeholder-color: $muted-fg-color;
$authpage-bg-color: #2e3649;
$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-lang-color: #4e5054;
$authpage-primary-color: #232f32;
@ -315,26 +334,26 @@ $kbd-border-color: $reaction-row-button-border-color;
$inverted-bg-color: #27303a;
$tooltip-timeline-bg-color: $inverted-bg-color;
$tooltip-timeline-fg-color: #ffffff;
$tooltip-timeline-fg-color: $background;
$interactive-tooltip-bg-color: #27303a;
$interactive-tooltip-fg-color: #ffffff;
$interactive-tooltip-fg-color: $background;
$breadcrumb-placeholder-bg-color: #e8eef5;
$user-tile-hover-bg-color: $header-panel-bg-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-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
// want custom themes to affect them by accident.
$voice-record-stop-symbol-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-icon-color: $tertiary-fg-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);
// Bubble tiles
$eventbubble-self-bg: #F8FDFC;
$eventbubble-others-bg: #F7F8F9;
$eventbubble-bg-hover: #FEFCF5;
$eventbubble-self-bg: #F0FBF8;
$eventbubble-others-bg: $system-light;
$eventbubble-bg-hover: #FAFBFD;
$eventbubble-avatar-outline: $primary-bg-color;
$eventbubble-reply-color: #C1C6CD;
$eventbubble-reply-color: $quaternary-content;
// ***** Mixins! *****
@ -392,7 +411,7 @@ $eventbubble-reply-color: #C1C6CD;
@define-mixin mx_DialogButton_secondary {
// flip colours for the secondary ones
font-weight: 600;
border: 1px solid $accent-color ! important;
border: 1px solid $accent-color !important;
color: $accent-color;
background-color: $button-secondary-bg-color;
}

View File

@ -14,8 +14,8 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import RoomViewStore from './stores/RoomViewStore';
import { EventSubscription } from 'fbemitter';
import RoomViewStore from './stores/RoomViewStore';
type Listener = (isActive: boolean) => void;

View File

@ -1,7 +1,8 @@
/*
Copyright 2015, 2016 OpenMarket 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");
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 dis from './dispatcher/dispatcher';
import WidgetUtils from './utils/WidgetUtils';
import WidgetEchoStore from './stores/WidgetEchoStore';
import SettingsStore from './settings/SettingsStore';
import { Jitsi } from "./widgets/Jitsi";
import { WidgetType } from "./widgets/WidgetType";
@ -86,6 +86,12 @@ import { randomUppercaseString, randomLowercaseString } from "matrix-js-sdk/src/
import EventEmitter from 'events';
import SdkConfig from './SdkConfig';
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_PREFIXED = 'im.vector.protocol.pstn';
@ -476,26 +482,44 @@ export default class CallHandler extends EventEmitter {
}
switch (newState) {
case CallState.Ringing:
this.play(AudioID.Ring);
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);
} else {
this.silenceCall(call.callId);
}
break;
case CallState.InviteSent:
}
case CallState.InviteSent: {
this.play(AudioID.Ringback);
break;
case CallState.Ended:
{
}
case CallState.Ended: {
const hangupReason = call.hangupReason;
Analytics.trackEvent('voip', 'callEnded', 'hangupReason', hangupReason);
this.removeCallForRoom(mappedRoomId);
if (oldState === CallState.InviteSent && call.hangupParty === CallParty.Remote) {
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 description;
// TODO: We should either do away with these or figure out a copy for each code (expect user_hangup...)
if (call.hangupReason === CallErrorCode.UserBusy) {
title = _t("User Busy");
description = _t("The user you called is busy.");
} else if (hangupReason && ![CallErrorCode.UserHangup, "user hangup"].includes(hangupReason)) {
} else {
title = _t("Call Failed");
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}`,
);
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({
action: 'call_state',
room_id: mappedRoomId,
@ -914,6 +951,8 @@ export default class CallHandler extends EventEmitter {
action: 'view_room',
room_id: roomId,
});
await this.placeCall(roomId, PlaceCallType.Voice, null);
}
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
const room = MatrixClientPeg.get().getRoom(roomId);
const currentJitsiWidgets = WidgetUtils.getRoomWidgetsOfType(room, WidgetType.JITSI);
const hasJitsi = currentJitsiWidgets.length > 0
|| WidgetEchoStore.roomHasPendingWidgetsOfType(roomId, currentJitsiWidgets, WidgetType.JITSI);
if (hasJitsi) {
Modal.createTrackedDialog('Call already in progress', '', ErrorDialog, {
title: _t('Call in Progress'),
description: _t('A call is currently being placed!'),
});
const jitsiWidget = WidgetStore.instance.getApps(roomId).find((app) => WidgetType.JITSI.matches(app.type));
if (jitsiWidget) {
// If there already is a Jitsi widget pin it
WidgetLayoutStore.instance.moveToContainer(room, jitsiWidget, Container.Top);
return;
}

View File

@ -209,6 +209,14 @@ async function loadImageElement(imageFile: File) {
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.
*
@ -217,23 +225,33 @@ async function loadImageElement(imageFile: File) {
* @param {File} imageFile The image to read and thumbnail.
* @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";
if (imageFile.type === "image/jpeg") {
thumbnailType = "image/jpeg";
}
let imageInfo;
return loadImageElement(imageFile).then((r) => {
return createThumbnail(r.img, r.width, r.height, thumbnailType);
}).then((result) => {
imageInfo = result.info;
return uploadFile(matrixClient, roomId, result.thumbnail);
}).then((result) => {
imageInfo.thumbnail_url = result.url;
imageInfo.thumbnail_file = result.file;
const imageElement = await loadImageElement(imageFile);
const result = await createThumbnail(imageElement.img, imageElement.width, imageElement.height, thumbnailType);
const imageInfo = result.info;
// we do all sizing checks here because we still rely on thumbnail generation for making a blurhash from.
const sizeDifference = imageFile.size - imageInfo.thumbnail_info.size;
if (
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;
}
/**

View File

@ -123,6 +123,19 @@ export function formatTime(date: Date, showTwelveHour = false): string {
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;
export function wantsDateSeparator(prevEventDate: Date, nextEventDate: Date): boolean {
if (!nextEventDate || !prevEventDate) {

View File

@ -57,7 +57,33 @@ const BIGEMOJI_REGEX = new RegExp(`^(${EMOJIBASE_REGEX.source})+$`, 'i');
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)\/(.+?)\/(.+?)(?:[?/]|$)/;

View File

@ -146,23 +146,23 @@ export default class IdentityAuthClient {
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
const { finished } = Modal.createTrackedDialog('Default identity server terms warning', '',
QuestionDialog, {
title: _t("Identity server has no terms of service"),
description: (
<div>
<p>{ _t(
"This action requires accessing the default identity server " +
title: _t("Identity server has no terms of service"),
description: (
<div>
<p>{ _t(
"This action requires accessing the default identity server " +
"<server /> to validate an email address or phone number, " +
"but the server does not have any terms of service.", {},
{
server: () => <b>{ abbreviateUrl(identityServerUrl) }</b>,
},
) }</p>
<p>{ _t(
"Only continue if you trust the owner of the server.",
) }</p>
</div>
),
button: _t("Trust"),
{
server: () => <b>{ abbreviateUrl(identityServerUrl) }</b>,
},
) }</p>
<p>{ _t(
"Only continue if you trust the owner of the server.",
) }</p>
</div>
),
button: _t("Trust"),
});
const [confirmed] = await finished;
if (confirmed) {

View File

@ -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 ThreepidInviteStore from "./stores/ThreepidInviteStore";
import CountlyAnalytics from "./CountlyAnalytics";
import { PosthogAnalytics } from "./PosthogAnalytics";
import CallHandler from './CallHandler';
import LifecycleCustomisations from "./customisations/Lifecycle";
import ErrorDialog from "./components/views/dialogs/ErrorDialog";
@ -573,6 +574,8 @@ async function doSetLoggedIn(
await abortLogin();
}
PosthogAnalytics.instance.updateAnonymityFromSettings(credentials.userId);
Analytics.setLoggedIn(credentials.guest, credentials.homeserverUrl);
MatrixClientPeg.replaceUsingCreds(credentials);
@ -700,6 +703,8 @@ export function logout(): void {
CountlyAnalytics.instance.enable(/* anonymous = */ true);
}
PosthogAnalytics.instance.logout();
if (MatrixClientPeg.get().isGuest()) {
// logout doesn't work for guest sessions
// Also we sometimes want to re-log in a guest session if we abort the login.

355
src/PosthogAnalytics.ts Normal file
View 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);
}
}
}

View File

@ -25,11 +25,44 @@ import { Action } from './dispatcher/actions';
import defaultDispatcher from './dispatcher/dispatcher';
import { SetRightPanelPhasePayload } from './dispatcher/payloads/SetRightPanelPhasePayload';
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
// 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.
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 {
// XXX: SYJS-16 "sender is sometimes null for join messages"
const senderName = ev.sender ? ev.sender.name : ev.getSender();
@ -567,6 +600,7 @@ interface IHandlers {
const handlers: IHandlers = {
'm.room.message': textForMessageEvent,
'm.call.invite': textForCallInviteEvent,
};
const stateHandlers: IHandlers = {

View File

@ -163,7 +163,7 @@ const shortcuts: Record<Categories, IShortcut[]> = {
modifiers: [Modifiers.SHIFT],
key: Key.PAGE_UP,
}],
description: _td("Jump to oldest unread message"),
description: _td("Jump to oldest unread message"),
}, {
keybinds: [{
modifiers: [CMD_OR_CTRL, Modifiers.SHIFT],

View File

@ -150,13 +150,14 @@ const reducer = (state: IState, action: IAction) => {
interface IProps {
handleHomeEnd?: boolean;
handleUpDown?: boolean;
children(renderProps: {
onKeyDownHandler(ev: React.KeyboardEvent);
});
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, {
activeRef: null,
refs: [],
@ -167,21 +168,50 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({ children, handleHomeE
const onKeyDownHandler = useCallback((ev) => {
let handled = false;
// 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
switch (ev.key) {
case Key.HOME:
handled = true;
// move focus to first item
if (context.state.refs.length > 0) {
context.state.refs[0].current.focus();
if (handleHomeEnd) {
handled = true;
// move focus to first item
if (context.state.refs.length > 0) {
context.state.refs[0].current.focus();
}
}
break;
case Key.END:
handled = true;
// move focus to last item
if (context.state.refs.length > 0) {
context.state.refs[context.state.refs.length - 1].current.focus();
if (handleHomeEnd) {
handled = true;
// move focus to last item
if (context.state.refs.length > 0) {
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;
}
@ -193,7 +223,7 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({ children, handleHomeE
} else if (onKeyDown) {
return onKeyDown(ev, context.state);
}
}, [context.state, onKeyDown, handleHomeEnd]);
}, [context.state, onKeyDown, handleHomeEnd, handleUpDown]);
return <RovingTabIndexContext.Provider value={context}>
{ children({ onKeyDownHandler }) }

View File

@ -38,17 +38,9 @@ function makePlaybackWaveform(input: number[]): number[] {
// First, convert negative amplitudes to positive so we don't detect zero as "noisy".
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.
// We also rescale the waveform to be 0-1 for the remaining function logic.
const resampled = 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);
// 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 so we end up with a clamped waveform to rely upon.
return arrayRescale(arraySmoothingResample(noiseWaveform, PLAYBACK_WAVEFORM_SAMPLES), 0, 1);
}
export class Playback extends EventEmitter implements IDestroyable {

View File

@ -30,6 +30,7 @@ import { IEncryptedFile } from "matrix-js-sdk/src/@types/event";
import { uploadFile } from "../ContentMessages";
import { FixedRollingArray } from "../utils/FixedRollingArray";
import { clamp } from "../utils/numbers";
import mxRecorderWorkletPath from "./RecorderWorklet";
const CHANNELS = 1; // stereo isn't important
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);
// 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
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);
this.recorderWorklet = new AudioWorkletNode(this.recorderContext, WORKLET_NAME);
this.recorderSource.connect(this.recorderWorklet);

View File

@ -61,7 +61,9 @@ export default class AutoHideScrollbar extends React.Component<IProps> {
style={style}
className={["mx_AutoHideScrollbar", className].join(" ")}
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 }
</div>);

View File

@ -27,9 +27,15 @@ export enum CallEventGrouperEvent {
SilencedChanged = "silenced_changed",
}
const CONNECTING_STATES = [
CallState.Connecting,
CallState.WaitLocalMedia,
CallState.CreateOffer,
CallState.CreateAnswer,
];
const SUPPORTED_STATES = [
CallState.Connected,
CallState.Connecting,
CallState.Ringing,
];
@ -61,6 +67,10 @@ export default class CallEventGrouper extends EventEmitter {
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 {
const invite = this.invite;
if (!invite) return;
@ -82,6 +92,11 @@ export default class CallEventGrouper extends EventEmitter {
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
*/
@ -127,7 +142,9 @@ export default class CallEventGrouper extends EventEmitter {
}
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;
} else {
if (this.callWasMissed) this.state = CustomCallState.Missed;

View File

@ -16,7 +16,7 @@ See the License for the specific language governing permissions and
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 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> => {
const button = useRef<T>(null);
const [isOpen, setIsOpen] = useState(false);
const open = () => {
const open = (ev?: SyntheticEvent) => {
ev?.preventDefault();
ev?.stopPropagation();
setIsOpen(true);
};
const close = () => {
const close = (ev?: SyntheticEvent) => {
ev?.preventDefault();
ev?.stopPropagation();
setIsOpen(false);
};

View File

@ -41,6 +41,9 @@ import RightPanelStore from "../../stores/RightPanelStore";
import AutoHideScrollbar from "./AutoHideScrollbar";
import { mediaFromMxc } from "../../customisations/Media";
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(
`<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_INVITE = "invite";
const UPGRADE_NOTICE_LS_KEY = "mx_hide_community_upgrade_notice";
@replaceableComponent("structures.GroupView")
export default class GroupView extends React.Component {
static propTypes = {
@ -422,6 +427,7 @@ export default class GroupView extends React.Component {
publicityBusy: false,
inviterProfile: null,
showRightPanel: RightPanelStore.getSharedInstance().isOpenForGroup,
showUpgradeNotice: !localStorage.getItem(UPGRADE_NOTICE_LS_KEY),
};
componentDidMount() {
@ -807,6 +813,22 @@ export default class GroupView extends React.Component {
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() {
const groupSettingsSectionClasses = classnames({
"mx_GroupView_group": this.state.editing,
@ -843,10 +865,46 @@ export default class GroupView extends React.Component {
},
) }
</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.") }
&nbsp;
{ text }
&nbsp;
{ _t("Communities won't receive further updates.") }
</p>
<AccessibleButton
className="mx_GroupView_spaceUpgradePrompt_close"
onClick={this._dismissUpgradeNotice}
/>
</div>;
}
return <div className={groupSettingsSectionClasses}>
{ header }
{ hostingSignup }
{ changeDelayWarning }
{ communitiesUpgradeNotice }
{ this._getJoinableNode() }
{ this._getLongDescriptionNode() }
{ this._getRoomsNode() }

View File

@ -392,9 +392,6 @@ export default class LeftPanel extends React.Component<IProps, IState> {
<IndicatorScrollbar
className="mx_LeftPanel_breadcrumbsContainer mx_AutoHideScrollbar"
verticalScrollsHorizontally={true}
// Firefox sometimes makes this element focusable due to
// overflow:scroll;, so force it out of tab order.
tabIndex={-1}
>
<RoomBreadcrumbs />
</IndicatorScrollbar>

View File

@ -107,6 +107,7 @@ import UIStore, { UI_EVENTS } from "../../stores/UIStore";
import SoftLogout from './auth/SoftLogout';
import { makeRoomPermalink } from "../../utils/permalinks/Permalinks";
import { copyPlaintext } from "../../utils/strings";
import { PosthogAnalytics } from '../../PosthogAnalytics';
/** constants for MatrixChat.state.view */
export enum Views {
@ -387,6 +388,10 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
if (SettingsStore.getValue("analyticsOptIn")) {
Analytics.enable();
}
PosthogAnalytics.instance.updateAnonymityFromSettings();
PosthogAnalytics.instance.updatePlatformSuperProperties();
CountlyAnalytics.instance.enable(/* anonymous = */ true);
}
@ -443,6 +448,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
const durationMs = this.stopPageChangeTimer();
Analytics.trackPageChange(durationMs);
CountlyAnalytics.instance.trackPageChange(durationMs);
PosthogAnalytics.instance.trackPageView(durationMs);
}
if (this.focusComposer) {
dis.fire(Action.FocusSendMessageComposer);

View File

@ -51,7 +51,12 @@ import EditorStateTransfer from "../../utils/EditorStateTransfer";
const CONTINUATION_MAX_INTERVAL = 5 * 60 * 1000; // 5 minutes
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
// 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
class MemberGrouper extends BaseGrouper {
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(
@ -1252,7 +1257,7 @@ class MemberGrouper extends BaseGrouper {
if (this.panel.wantsDateSeparator(this.events[0], ev.getDate())) {
return false;
}
return membershipTypes.includes(ev.getType() as EventType);
return groupedEvents.includes(ev.getType() as EventType);
}
public add(ev: MatrixEvent, showHiddenEvents?: boolean): void {

View File

@ -14,7 +14,16 @@ See the License for the specific language governing permissions and
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 { RoomHierarchy } from "matrix-js-sdk/src/room-hierarchy";
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 { useDispatcher } from "../../hooks/useDispatcher";
import { Action } from "../../dispatcher/actions";
import { Key } from "../../Keyboard";
import { IState, RovingTabIndexProvider, useRovingTabIndex } from "../../accessibility/RovingTabIndex";
import { getDisplayAliasForRoom } from "./RoomDirectory";
interface IProps {
@ -76,6 +87,7 @@ const Tile: React.FC<ITileProps> = ({
|| (room.room_type === RoomType.Space ? _t("Unnamed Space") : _t("Unnamed Room"));
const [showChildren, toggleShowChildren] = useStateToggle(true);
const [onFocus, isActive, ref] = useRovingTabIndex();
const onPreviewClick = (ev: ButtonEvent) => {
ev.preventDefault();
@ -90,11 +102,21 @@ const Tile: React.FC<ITileProps> = ({
let button;
if (joinedRoom) {
button = <AccessibleButton onClick={onPreviewClick} kind="primary_outline">
button = <AccessibleButton
onClick={onPreviewClick}
kind="primary_outline"
onFocus={onFocus}
tabIndex={isActive ? 0 : -1}
>
{ _t("View") }
</AccessibleButton>;
} else if (onJoinClick) {
button = <AccessibleButton onClick={onJoinClick} kind="primary">
button = <AccessibleButton
onClick={onJoinClick}
kind="primary"
onFocus={onFocus}
tabIndex={isActive ? 0 : -1}
>
{ _t("Join") }
</AccessibleButton>;
}
@ -102,13 +124,13 @@ const Tile: React.FC<ITileProps> = ({
let checkbox;
if (onToggleClick) {
if (hasPermissions) {
checkbox = <StyledCheckbox checked={!!selected} onChange={onToggleClick} />;
checkbox = <StyledCheckbox checked={!!selected} onChange={onToggleClick} tabIndex={isActive ? 0 : -1} />;
} else {
checkbox = <TextWithTooltip
tooltip={_t("You don't have permission")}
onClick={ev => { ev.stopPropagation(); }}
>
<StyledCheckbox disabled={true} />
<StyledCheckbox disabled={true} tabIndex={isActive ? 0 : -1} />
</TextWithTooltip>;
}
}
@ -168,8 +190,9 @@ const Tile: React.FC<ITileProps> = ({
</div>
</React.Fragment>;
let childToggle;
let childSection;
let childToggle: JSX.Element;
let childSection: JSX.Element;
let onKeyDown: KeyboardEventHandler;
if (children) {
// the chevron is purposefully a div rather than a button as it should be ignored for a11y
childToggle = <div
@ -181,25 +204,74 @@ const Tile: React.FC<ITileProps> = ({
toggleShowChildren();
}}
/>;
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 }
</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;
}
if (handled) {
e.preventDefault();
e.stopPropagation();
}
};
}
return <>
return <li
className="mx_SpaceRoomDirectory_roomTileWrapper"
role="treeitem"
aria-expanded={children ? showChildren : undefined}
>
<AccessibleButton
className={classNames("mx_SpaceHierarchy_roomTile", {
mx_SpaceHierarchy_subspace: room.room_type === RoomType.Space,
})}
onClick={(hasPermissions && onToggleClick) ? onToggleClick : onPreviewClick}
onKeyDown={onKeyDown}
inputRef={ref}
onFocus={onFocus}
tabIndex={isActive ? 0 : -1}
>
{ content }
{ childToggle }
</AccessibleButton>
{ childSection }
</>;
</li>;
};
export const showRoom = (hierarchy: RoomHierarchy, roomId: string, autoJoin = false) => {
@ -439,165 +511,181 @@ const SpaceHierarchy = ({
return <p>{ _t("Your server does not support showing space hierarchies.") }</p>;
}
let content: JSX.Element;
let loader: JSX.Element;
if (loading && !rooms.length) {
content = <Spinner />;
} else {
let manageButtons;
if (space.getMyMembership() === "join" && space.currentState.maySendStateEvent(EventType.SpaceChild, userId)) {
const selectedRelations = Array.from(selected.keys()).flatMap(parentId => {
return [...selected.get(parentId).values()].map(childId => [parentId, childId]) as [string, string][];
});
const onKeyDown = (ev: KeyboardEvent, state: IState) => {
if (ev.key === Key.ARROW_DOWN && ev.currentTarget.classList.contains("mx_SpaceRoomDirectory_search")) {
state.refs[0]?.current?.focus();
}
};
const selectionAllSuggested = selectedRelations.every(([parentId, childId]) => {
return hierarchy.isSuggested(parentId, childId);
});
// TODO loading state/error state
return <RovingTabIndexProvider onKeyDown={onKeyDown} handleHomeEnd handleUpDown>
{ ({ onKeyDownHandler }) => {
let content: JSX.Element;
let loader: JSX.Element;
const disabled = !selectedRelations.length || removing || saving;
if (loading && !rooms.length) {
content = <Spinner />;
} else {
let manageButtons;
if (space.getMyMembership() === "join" && space.currentState.maySendStateEvent(EventType.SpaceChild, userId)) {
const selectedRelations = Array.from(selected.keys()).flatMap(parentId => {
return [
...selected.get(parentId).values(),
].map(childId => [parentId, childId]) as [string, string][];
});
let Button: React.ComponentType<React.ComponentProps<typeof AccessibleButton>> = AccessibleButton;
let props = {};
if (!selectedRelations.length) {
Button = AccessibleTooltipButton;
props = {
tooltip: _t("Select a room below first"),
yOffset: -40,
};
}
const selectionAllSuggested = selectedRelations.every(([parentId, childId]) => {
return hierarchy.isSuggested(parentId, childId);
});
manageButtons = <>
<Button
{...props}
onClick={async () => {
setRemoving(true);
try {
for (const [parentId, childId] of selectedRelations) {
await cli.sendStateEvent(parentId, EventType.SpaceChild, {}, childId);
const disabled = !selectedRelations.length || removing || saving;
hierarchy.removeRelation(parentId, childId);
}
} catch (e) {
setError(_t("Failed to remove some rooms. Try again later"));
}
setRemoving(false);
setSelected(new Map());
}}
kind="danger_outline"
disabled={disabled}
>
{ removing ? _t("Removing...") : _t("Remove") }
</Button>
<Button
{...props}
onClick={async () => {
setSaving(true);
try {
for (const [parentId, childId] of selectedRelations) {
const suggested = !selectionAllSuggested;
const existingContent = hierarchy.getRelation(parentId, childId)?.content;
if (!existingContent || existingContent.suggested === suggested) continue;
const content = {
...existingContent,
suggested: !selectionAllSuggested,
};
await cli.sendStateEvent(parentId, EventType.SpaceChild, content, childId);
// mutate the local state to save us having to refetch the world
existingContent.suggested = content.suggested;
}
} catch (e) {
setError("Failed to update some suggestions. Try again later");
}
setSaving(false);
setSelected(new Map());
}}
kind="primary_outline"
disabled={disabled}
>
{ saving
? _t("Saving...")
: (selectionAllSuggested ? _t("Mark as not suggested") : _t("Mark as suggested"))
let Button: React.ComponentType<React.ComponentProps<typeof AccessibleButton>> = AccessibleButton;
let props = {};
if (!selectedRelations.length) {
Button = AccessibleTooltipButton;
props = {
tooltip: _t("Select a room below first"),
yOffset: -40,
};
}
</Button>
</>;
}
let results: JSX.Element;
if (filteredRoomSet.size) {
const hasPermissions = space?.currentState.maySendStateEvent(EventType.SpaceChild, cli.getUserId());
manageButtons = <>
<Button
{...props}
onClick={async () => {
setRemoving(true);
try {
for (const [parentId, childId] of selectedRelations) {
await cli.sendStateEvent(parentId, EventType.SpaceChild, {}, childId);
results = <>
<HierarchyLevel
root={hierarchy.roomMap.get(space.roomId)}
roomSet={filteredRoomSet}
hierarchy={hierarchy}
parents={new Set()}
selectedMap={selected}
onToggleClick={hasPermissions ? (parentId, childId) => {
setError("");
if (!selected.has(parentId)) {
setSelected(new Map(selected.set(parentId, new Set([childId]))));
return;
}
hierarchy.removeRelation(parentId, childId);
}
} catch (e) {
setError(_t("Failed to remove some rooms. Try again later"));
}
setRemoving(false);
setSelected(new Map());
}}
kind="danger_outline"
disabled={disabled}
>
{ removing ? _t("Removing...") : _t("Remove") }
</Button>
<Button
{...props}
onClick={async () => {
setSaving(true);
try {
for (const [parentId, childId] of selectedRelations) {
const suggested = !selectionAllSuggested;
const existingContent = hierarchy.getRelation(parentId, childId)?.content;
if (!existingContent || existingContent.suggested === suggested) continue;
const parentSet = selected.get(parentId);
if (!parentSet.has(childId)) {
setSelected(new Map(selected.set(parentId, new Set([...parentSet, childId]))));
return;
}
const content = {
...existingContent,
suggested: !selectionAllSuggested,
};
parentSet.delete(childId);
setSelected(new Map(selected.set(parentId, new Set(parentSet))));
} : undefined}
onViewRoomClick={(roomId, autoJoin) => {
showRoom(hierarchy, roomId, autoJoin);
}}
/>
</>;
await cli.sendStateEvent(parentId, EventType.SpaceChild, content, childId);
if (hierarchy.canLoadMore) {
loader = <div ref={loaderRef}>
<Spinner />
</div>;
// mutate the local state to save us having to refetch the world
existingContent.suggested = content.suggested;
}
} catch (e) {
setError("Failed to update some suggestions. Try again later");
}
setSaving(false);
setSelected(new Map());
}}
kind="primary_outline"
disabled={disabled}
>
{ saving
? _t("Saving...")
: (selectionAllSuggested ? _t("Mark as not suggested") : _t("Mark as suggested"))
}
</Button>
</>;
}
let results: JSX.Element;
if (filteredRoomSet.size) {
const hasPermissions = space?.currentState.maySendStateEvent(EventType.SpaceChild, cli.getUserId());
results = <>
<HierarchyLevel
root={hierarchy.roomMap.get(space.roomId)}
roomSet={filteredRoomSet}
hierarchy={hierarchy}
parents={new Set()}
selectedMap={selected}
onToggleClick={hasPermissions ? (parentId, childId) => {
setError("");
if (!selected.has(parentId)) {
setSelected(new Map(selected.set(parentId, new Set([childId]))));
return;
}
const parentSet = selected.get(parentId);
if (!parentSet.has(childId)) {
setSelected(new Map(selected.set(parentId, new Set([...parentSet, childId]))));
return;
}
parentSet.delete(childId);
setSelected(new Map(selected.set(parentId, new Set(parentSet))));
} : undefined}
onViewRoomClick={(roomId, autoJoin) => {
showRoom(hierarchy, roomId, autoJoin);
}}
/>
</>;
if (hierarchy.canLoadMore) {
loader = <div ref={loaderRef}>
<Spinner />
</div>;
}
} else {
results = <div className="mx_SpaceHierarchy_noResults">
<h3>{ _t("No results found") }</h3>
<div>{ _t("You may want to try a different search or check for typos.") }</div>
</div>;
}
content = <>
<div className="mx_SpaceHierarchy_listHeader">
<h4>{ query.trim() ? _t("Results") : _t("Rooms and spaces") }</h4>
<span>
{ additionalButtons }
{ manageButtons }
</span>
</div>
{ error && <div className="mx_SpaceHierarchy_error">
{ error }
</div> }
<ul onKeyDown={onKeyDownHandler} role="tree" aria-label={_t("Space")}>
{ results }
</ul>
{ loader }
</>;
}
} else {
results = <div className="mx_SpaceHierarchy_noResults">
<h3>{ _t("No results found") }</h3>
<div>{ _t("You may want to try a different search or check for typos.") }</div>
</div>;
}
content = <>
<div className="mx_SpaceHierarchy_listHeader">
<h4>{ query.trim() ? _t("Results") : _t("Rooms and spaces") }</h4>
<span>
{ additionalButtons }
{ manageButtons }
</span>
</div>
{ error && <div className="mx_SpaceHierarchy_error">
{ error }
</div> }
return <>
<SearchBox
className="mx_SpaceRoomDirectory_search mx_textinput_icon mx_textinput_search"
placeholder={_t("Search names and descriptions")}
onSearch={setQuery}
autoFocus={true}
initialValue={initialText}
onKeyDown={onKeyDownHandler}
/>
{ results }
{ loader }
</>;
}
return <>
<SearchBox
className="mx_textinput_icon mx_textinput_search"
placeholder={_t("Search names and descriptions")}
onSearch={setQuery}
autoFocus={true}
initialValue={initialText}
/>
{ content }
</>;
{ content }
</>;
} }
</RovingTabIndexProvider>;
};
export default SpaceHierarchy;

View File

@ -74,6 +74,10 @@ import { BetaPill } from "../views/beta/BetaCard";
import { UserTab } from "../views/dialogs/UserSettingsDialog";
import { EffectiveMembership, getEffectiveMembership } from "../../utils/membership";
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 {
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 myMembership = useMyRoomMembership(space);
@ -192,11 +222,11 @@ const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) =>
if (inviteSender) {
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 className="mx_SpaceRoomView_preview_inviter_name">
{ _t("<inviter/> invites you", {}, {
inviter: () => <b>{ inviter.name || inviteSender }</b>,
inviter: () => <b>{ inviter?.name || inviteSender }</b>,
}) }
</div>
{ inviter ? <div className="mx_SpaceRoomView_preview_inviter_mxid">
@ -270,8 +300,18 @@ const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) =>
</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">
<BetaPill onClick={onBetaClick} />
{ migratedCommunitySection }
{ inviterSection }
<RoomAvatar room={space} height={80} width={80} viewAvatarOnClick={true} />
<h1 className="mx_SpaceRoomView_preview_name">

View File

@ -757,16 +757,20 @@ class TimelinePanel extends React.Component<IProps, IState> {
}
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 ',
this.props.timelineSet.room.roomId,
'rm', this.state.readMarkerEventId,
lastReadEvent ? 'rr ' + lastReadEvent.getId() : '',
' hidden:' + hiddenRR,
);
MatrixClientPeg.get().setRoomReadMarkers(
this.props.timelineSet.room.roomId,
roomId,
this.state.readMarkerEventId,
lastReadEvent, // Could be null, in which case no RR is sent
{},
{ hidden: hiddenRR },
).catch((e) => {
// /read_markers API is not implemented on this HS, fallback to just RR
if (e.errcode === 'M_UNRECOGNIZED' && lastReadEvent) {

View File

@ -58,28 +58,39 @@ export default class ToastContainer extends React.Component<{}, IState> {
let containerClasses;
if (totalCount !== 0) {
const topToast = this.state.toasts[0];
const { title, icon, key, component, className, props } = topToast;
const toastClasses = classNames("mx_Toast_toast", {
const { title, icon, key, component, className, bodyClassName, props } = topToast;
const bodyClasses = classNames("mx_Toast_body", bodyClassName);
const toastClasses = classNames("mx_Toast_toast", className, {
"mx_Toast_hasIcon": 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, {
key,
toastKey: key,
});
toast = (<div className={toastClasses}>
<div className="mx_Toast_title">
<h2>{ title }</h2>
<span>{ countIndicator }</span>
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">
<h2>{ title }</h2>
<span>{ countIndicator }</span>
</div>
);
}
toast = (
<div className={toastClasses}>
{ titleElement }
<div className={bodyClasses}>{ content }</div>
</div>
<div className="mx_Toast_body">{ React.createElement(component, toastProps) }</div>
</div>);
);
containerClasses = classNames("mx_ToastContainer", {
"mx_ToastContainer_stacked": isStacked,

View File

@ -17,8 +17,7 @@ limitations under the License.
import React from "react";
import { IRecordingUpdate, RECORDING_PLAYBACK_SAMPLES, VoiceRecording } from "../../../audio/VoiceRecording";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { arrayFastResample } from "../../../utils/arrays";
import { percentageOf } from "../../../utils/numbers";
import { arrayFastResample, arraySeed } from "../../../utils/arrays";
import Waveform from "./Waveform";
import { MarkedExecution } from "../../../utils/MarkedExecution";
@ -48,18 +47,14 @@ export default class LiveRecordingWaveform extends React.PureComponent<IProps, I
constructor(props) {
super(props);
this.state = {
waveform: [],
waveform: arraySeed(0, RECORDING_PLAYBACK_SAMPLES),
};
}
componentDidMount() {
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, but typically even screaming into a
// 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));
// The incoming data is between zero and one, so we don't need to clamp/rescale it.
this.waveform = arrayFastResample(Array.from(update.waveform), RECORDING_PLAYBACK_SAMPLES);
this.scheduledUpdate.mark();
});
}

View File

@ -49,6 +49,13 @@ export default class DialpadContextMenu extends React.Component<IProps, IState>
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) => {
this.setState({ value: ev.target.value });
};
@ -64,6 +71,7 @@ export default class DialpadContextMenu extends React.Component<IProps, IState>
className="mx_DialPadContextMenu_dialled"
value={this.state.value}
autoFocus={true}
onKeyDown={this.onKeyDown}
onChange={this.onChange}
/>
</div>

View File

@ -86,7 +86,10 @@ export const IconizedContextMenuCheckbox: React.FC<ICheckboxProps> = ({
>
<span className={classNames("mx_IconizedContextMenu_icon", iconClassName)} />
<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>;
};

View 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;

View File

@ -24,6 +24,8 @@ import { MenuItem } from "../../structures/ContextMenu";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import GroupFilterOrderStore from "../../../stores/GroupFilterOrderStore";
import { createSpaceFromCommunity } from "../../../utils/space";
import GroupStore from "../../../stores/GroupStore";
@replaceableComponent("views.context_menus.TagTileContextMenu")
export default class TagTileContextMenu extends React.Component {
@ -49,6 +51,11 @@ export default class TagTileContextMenu extends React.Component {
this.props.onFinished();
};
_onCreateSpaceClick = () => {
createSpaceFromCommunity(this.context, this.props.tag);
this.props.onFinished();
};
_onMoveUp = () => {
dis.dispatch(TagOrderActions.moveTag(this.context, this.props.tag, this.props.index - 1));
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>
<MenuItem className="mx_TagTileContextMenu_item mx_TagTileContextMenu_viewCommunity" onClick={this._onViewCommunityClick}>
{ _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}>
{ _t("Unpin") }
</MenuItem>
{ createSpaceOption }
</div>;
}
}

View File

@ -116,8 +116,8 @@ export default class CreateRoomDialog extends React.Component<IProps, IState> {
opts.associatedWithCommunity = CommunityPrototypeStore.instance.getSelectedCommunityId();
}
opts.parentSpace = this.props.parentSpace;
if (this.props.parentSpace && this.state.joinRule === JoinRule.Restricted) {
opts.parentSpace = this.props.parentSpace;
opts.joinRule = JoinRule.Restricted;
}

View 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>,
}) }
&nbsp;
{ 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.") }
&nbsp;
{ _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;

View File

@ -16,8 +16,7 @@ limitations under the License.
import React, { useRef, useState } from "react";
import { Room } from "matrix-js-sdk/src/models/room";
import { JoinRule, Preset } from "matrix-js-sdk/src/@types/partials";
import { RoomType } from "matrix-js-sdk/src/@types/event";
import { JoinRule } from "matrix-js-sdk/src/@types/partials";
import { _t } from '../../../languageHandler';
import BaseDialog from "./BaseDialog";
@ -27,8 +26,7 @@ import { BetaPill } from "../beta/BetaCard";
import Field from "../elements/Field";
import RoomAliasField from "../elements/RoomAliasField";
import SpaceStore from "../../../stores/SpaceStore";
import { SpaceCreateForm } from "../spaces/SpaceCreateMenu";
import createRoom from "../../../createRoom";
import { createSpace, SpaceCreateForm } from "../spaces/SpaceCreateMenu";
import { SubspaceSelector } from "./AddExistingToSpaceDialog";
import JoinRuleDropdown from "../elements/JoinRuleDropdown";
@ -81,28 +79,7 @@ const CreateSubspaceDialog: React.FC<IProps> = ({ space, onAddExistingSpaceClick
}
try {
await createRoom({
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,
});
await createSpace(name, joinRule === JoinRule.Public, alias, topic, avatar, {}, { parentSpace });
onFinished(true);
} catch (e) {

View File

@ -490,9 +490,9 @@ class RoomStateExplorer extends React.PureComponent<IExplorerProps, IRoomStateEx
forceStateEvent={true}
onBack={this.onBack}
inputs={{
eventType: this.state.event.getType(),
evContent: JSON.stringify(this.state.event.getContent(), null, '\t'),
stateKey: this.state.event.getStateKey(),
eventType: this.state.event.getType(),
evContent: JSON.stringify(this.state.event.getContent(), null, '\t'),
stateKey: this.state.event.getStateKey(),
}}
/>;
}

View File

@ -20,10 +20,10 @@ import { _t } from '../../../languageHandler';
import SdkConfig from '../../../SdkConfig';
export default function KeySignatureUploadFailedDialog({
failures,
source,
continuation,
onFinished,
failures,
source,
continuation,
onFinished,
}) {
const RETRIES = 2;
const BaseDialog = sdk.getComponent('dialogs.BaseDialog');

View File

@ -16,6 +16,7 @@ limitations under the License.
*/
import React from 'react';
import { IKeyBackupInfo } from "matrix-js-sdk/src/crypto/keybackup";
import Modal from '../../../Modal';
import * as sdk from '../../../index';
import dis from '../../../dispatcher/dispatcher';
@ -24,19 +25,25 @@ import { MatrixClientPeg } from '../../../MatrixClientPeg';
import RestoreKeyBackupDialog from './security/RestoreKeyBackupDialog';
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")
export default class LogoutDialog extends React.Component {
defaultProps = {
export default class LogoutDialog extends React.Component<IProps, IState> {
static defaultProps = {
onFinished: function() {},
};
constructor() {
super();
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);
constructor(props) {
super(props);
const cli = MatrixClientPeg.get();
const shouldLoadBackupStatus = cli.isCryptoEnabled() && !cli.getKeyBackupEnabled();
@ -49,11 +56,11 @@ export default class LogoutDialog extends React.Component {
};
if (shouldLoadBackupStatus) {
this._loadBackupStatus();
this.loadBackupStatus();
}
}
async _loadBackupStatus() {
private async loadBackupStatus() {
try {
const backupInfo = await MatrixClientPeg.get().getKeyBackupVersion();
this.setState({
@ -69,29 +76,29 @@ export default class LogoutDialog extends React.Component {
}
}
_onSettingsLinkClick() {
private onSettingsLinkClick = (): void => {
// close dialog
this.props.onFinished();
}
this.props.onFinished(true);
};
_onExportE2eKeysClicked() {
private onExportE2eKeysClicked = (): void => {
Modal.createTrackedDialogAsync('Export E2E Keys', '',
import('../../../async-components/views/dialogs/security/ExportE2eKeysDialog'),
{
matrixClient: MatrixClientPeg.get(),
},
);
}
};
_onFinished(confirmed) {
private onFinished = (confirmed: boolean): void => {
if (confirmed) {
dis.dispatch({ action: 'logout' });
}
// close dialog
this.props.onFinished();
}
this.props.onFinished(confirmed);
};
_onSetRecoveryMethodClick() {
private onSetRecoveryMethodClick = (): void => {
if (this.state.backupInfo) {
// 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
@ -108,15 +115,15 @@ export default class LogoutDialog extends React.Component {
}
// close dialog
this.props.onFinished();
}
this.props.onFinished(true);
};
_onLogoutConfirm() {
private onLogoutConfirm = (): void => {
dis.dispatch({ action: 'logout' });
// close dialog
this.props.onFinished();
}
this.props.onFinished(true);
};
render() {
if (this.state.shouldLoadBackupStatus) {
@ -152,16 +159,16 @@ export default class LogoutDialog extends React.Component {
</div>
<DialogButtons primaryButton={setupButtonCaption}
hasCancel={false}
onPrimaryButtonClick={this._onSetRecoveryMethodClick}
onPrimaryButtonClick={this.onSetRecoveryMethodClick}
focus={true}
>
<button onClick={this._onLogoutConfirm}>
<button onClick={this.onLogoutConfirm}>
{ _t("I don't want my encrypted messages") }
</button>
</DialogButtons>
<details>
<summary>{ _t("Advanced") }</summary>
<p><button onClick={this._onExportE2eKeysClicked}>
<p><button onClick={this.onExportE2eKeysClicked}>
{ _t("Manually export keys") }
</button></p>
</details>
@ -174,7 +181,7 @@ export default class LogoutDialog extends React.Component {
title={_t("You'll lose access to your encrypted messages")}
contentId='mx_Dialog_content'
hasCancel={true}
onFinished={this._onFinished}
onFinished={this.onFinished}
>
{ dialogContent }
</BaseDialog>);
@ -187,7 +194,7 @@ export default class LogoutDialog extends React.Component {
"Are you sure you want to sign out?",
)}
button={_t("Sign out")}
onFinished={this._onFinished}
onFinished={this.onFinished}
/>);
}
}

View File

@ -114,7 +114,7 @@ export default class UserSettingsDialog extends React.Component<IProps, IState>
UserTab.Preferences,
_td("Preferences"),
"mx_UserSettingsDialog_preferencesIcon",
<PreferencesUserSettingsTab />,
<PreferencesUserSettingsTab closeSettingsFn={this.props.onFinished} />,
));
if (SettingsStore.getValue(UIFeature.Voip)) {

View File

@ -67,7 +67,9 @@ export default function AccessibleButton({
...restProps
}: IProps) {
const newProps: IAccessibleButtonProps = restProps;
if (!disabled) {
if (disabled) {
newProps["aria-disabled"] = true;
} else {
newProps.onClick = onClick;
// We need to consume enter onKeyDown and space onKeyUp
// otherwise we are risking also activating other keyboard focusable elements
@ -118,7 +120,7 @@ export default function AccessibleButton({
);
// React.createElement expects InputHTMLAttributes
return React.createElement(element, restProps, children);
return React.createElement(element, newProps, children);
}
AccessibleButton.defaultProps = {

View File

@ -62,10 +62,10 @@ export default class AppPermission extends React.Component<IProps, IState> {
// Set all this into the initial state
this.state = {
...urlInfo,
roomMember,
isWrapped: null,
widgetDomain: null,
isWrapped: null,
roomMember,
...urlInfo,
};
}

View File

@ -18,7 +18,7 @@ limitations under the License.
import React, { ChangeEvent, createRef, CSSProperties, ReactElement, ReactNode, Ref } from 'react';
import classnames from 'classnames';
import AccessibleButton from './AccessibleButton';
import AccessibleButton, { ButtonEvent } from './AccessibleButton';
import { _t } from '../../../languageHandler';
import { Key } from "../../../Keyboard";
import { replaceableComponent } from "../../../utils/replaceableComponent";
@ -178,7 +178,7 @@ export default class Dropdown extends React.Component<IProps, IState> {
this.ignoreEvent = ev;
};
private onInputClick = (ev: React.MouseEvent) => {
private onAccessibleButtonClick = (ev: ButtonEvent) => {
if (this.props.disabled) return;
if (!this.state.expanded) {
@ -186,6 +186,10 @@ export default class Dropdown extends React.Component<IProps, IState> {
expanded: true,
});
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);
};
private onInputKeyDown = (e: React.KeyboardEvent) => {
private onKeyDown = (e: React.KeyboardEvent) => {
let handled = true;
// 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 {
const keys = Object.keys(this.childrenByKey);
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) {
@ -320,7 +324,6 @@ export default class Dropdown extends React.Component<IProps, IState> {
type="text"
autoFocus={true}
className="mx_Dropdown_option"
onKeyDown={this.onInputKeyDown}
onChange={this.onInputChange}
value={this.state.searchQuery}
role="combobox"
@ -329,6 +332,7 @@ export default class Dropdown extends React.Component<IProps, IState> {
aria-owns={`${this.props.id}_listbox`}
aria-disabled={this.props.disabled}
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}>
<AccessibleButton
className="mx_Dropdown_input mx_no_textinput"
onClick={this.onInputClick}
onClick={this.onAccessibleButtonClick}
aria-haspopup="listbox"
aria-expanded={this.state.expanded}
disabled={this.props.disabled}
inputRef={this.buttonRef}
aria-label={this.props.label}
aria-describedby={`${this.props.id}_value`}
onKeyDown={this.onKeyDown}
>
{ currentValue }
<span className="mx_Dropdown_arrow" />

View File

@ -34,7 +34,7 @@ interface IProps {
// The list of room members for which to show avatars next to the summary
summaryMembers?: RoomMember[];
// 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
children: ReactNode[];
// Called when the event list expansion is toggled

View File

@ -25,8 +25,24 @@ import { formatCommaSeparatedList } from '../../../utils/FormattingUtils';
import { isValid3pidInvite } from "../../../RoomInvite";
import EventListSummary from "./EventListSummary";
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';
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"> {
// The maximum number of names to show in either each summary e.g. 2 would result "A, B and 234 others left"
summaryLength?: number;
@ -60,6 +76,7 @@ enum TransitionType {
ChangedAvatar = "changed_avatar",
NoChange = "no_change",
ServerAcl = "server_acl",
ChangedPins = "pinned_messages"
}
const SEP = ",";
@ -93,7 +110,10 @@ export default class MemberEventListSummary extends React.Component<IProps> {
* `Object.keys(eventAggregates)`.
* @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 userNames = eventAggregates[transitions];
const nameList = this.renderNameList(userNames);
@ -122,7 +142,7 @@ export default class MemberEventListSummary extends React.Component<IProps> {
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.
* @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'
// are there only to show translators to non-English languages
// 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 })
: _t("%(oneUser)schanged the server ACLs %(count)s times", { oneUser: "", count: repeats });
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;
@ -317,16 +350,18 @@ export default class MemberEventListSummary extends React.Component<IProps> {
* if a transition is not recognised.
*/
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
if (!isValid3pidInvite(e.mxEvent)) {
return TransitionType.InviteWithdrawal;
}
return TransitionType.Invited;
}
if (e.mxEvent.getType() === 'm.room.server_acl') {
} else if (type === EventType.RoomServerAcl) {
return TransitionType.ServerAcl;
} else if (type === EventType.RoomPinnedEvents) {
return TransitionType.ChangedPins;
}
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
const userEvents: Record<string, IUserEvents[]> = {};
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
if (!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);
} else if (e.target) {
latestUserAvatarMember.set(userId, e.target);
}
let displayName = userId;
if (e.getType() === 'm.room.third_party_invite') {
if (type === EventType.RoomThirdPartyInvite) {
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;
} else if (e.target) {
displayName = e.target.name;

View File

@ -25,7 +25,7 @@ import { CallErrorCode, CallState } from 'matrix-js-sdk/src/webrtc/call';
import InfoTooltip, { InfoTooltipKind } from '../elements/InfoTooltip';
import classNames from 'classnames';
import AccessibleTooltipButton from '../elements/AccessibleTooltipButton';
import { MatrixClientPeg } from '../../../MatrixClientPeg';
import { formatCallTime } from "../../../DateUtils";
interface IProps {
mxEvent: MatrixEvent;
@ -117,14 +117,12 @@ export default class CallEvent extends React.Component<IProps, IState> {
if (state === CallState.Ended) {
const hangupReason = this.props.callEventGrouper.hangupReason;
const gotRejected = this.props.callEventGrouper.gotRejected;
const rejectParty = this.props.callEventGrouper.rejectParty;
if (gotRejected) {
const weDeclinedCall = MatrixClientPeg.get().getUserId() === rejectParty;
return (
<div className="mx_CallEvent_content">
{ weDeclinedCall ? _t("You declined this call") : _t("They declined this call") }
{ this.renderCallBackButton(weDeclinedCall ? _t("Call back") : _t("Call again")) }
{ _t("Call declined") }
{ this.renderCallBackButton(_t("Call back")) }
</div>
);
} 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
// Also the correct hangup code as of VoIP v1 (with underscore)
// 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 (
<div className="mx_CallEvent_content">
{ _t("This call has ended") }
{ text }
</div>
);
} else if (hangupReason === CallErrorCode.InviteTimeout) {
return (
<div className="mx_CallEvent_content">
{ _t("They didn't pick up") }
{ this.renderCallBackButton(_t("Call again")) }
{ _t("Missed call") }
{ this.renderCallBackButton(_t("Call back")) }
</div>
);
}
@ -176,7 +179,8 @@ export default class CallEvent extends React.Component<IProps, IState> {
className="mx_CallEvent_content_tooltip"
kind={InfoTooltipKind.Warning}
/>
{ _t("This call has failed") }
{ _t("Connection failed") }
{ this.renderCallBackButton(_t("Retry")) }
</div>
);
}
@ -190,7 +194,7 @@ export default class CallEvent extends React.Component<IProps, IState> {
if (state === CustomCallState.Missed) {
return (
<div className="mx_CallEvent_content">
{ _t("You missed this call") }
{ _t("Missed call") }
{ this.renderCallBackButton(_t("Call back")) }
</div>
);

View File

@ -16,12 +16,13 @@ limitations under the License.
import { MatrixEvent } from "matrix-js-sdk/src";
import { MediaEventHelper } from "../../../utils/MediaEventHelper";
import React, { createRef } from "react";
import React from "react";
import { RovingAccessibleTooltipButton } from "../../../accessibility/RovingTabIndex";
import Spinner from "../elements/Spinner";
import classNames from "classnames";
import { _t } from "../../../languageHandler";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { FileDownloader } from "../../../utils/FileDownloader";
interface IProps {
mxEvent: MatrixEvent;
@ -39,7 +40,7 @@ interface IState {
@replaceableComponent("views.messages.DownloadActionButton")
export default class DownloadActionButton extends React.PureComponent<IProps, IState> {
private iframe: React.RefObject<HTMLIFrameElement> = createRef();
private downloader = new FileDownloader();
public constructor(props: IProps) {
super(props);
@ -56,27 +57,21 @@ export default class DownloadActionButton extends React.PureComponent<IProps, IS
if (this.state.blob) {
// Cheat and trigger a download, again.
return this.onFrameLoad();
return this.doDownload();
}
const blob = await this.props.mediaEventHelperGet().sourceBlob.value;
this.setState({ blob });
await this.doDownload();
};
private onFrameLoad = () => {
this.setState({ loading: false });
// 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: "",
private async doDownload() {
await this.downloader.download({
blob: this.state.blob,
download: this.props.mediaEventHelperGet().fileName,
textContent: "",
auto: true, // autodownload
}, '*');
};
name: this.props.mediaEventHelperGet().fileName,
});
this.setState({ loading: false });
}
public render() {
let spinner: JSX.Element;
@ -92,18 +87,11 @@ export default class DownloadActionButton extends React.PureComponent<IProps, IS
return <RovingAccessibleTooltipButton
className={classes}
title={spinner ? _t("Downloading") : _t("Download")}
title={spinner ? _t("Decrypting") : _t("Download")}
onClick={this.onDownloadClick}
disabled={!!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>;
}
}

View File

@ -26,6 +26,8 @@ import { TileShape } from "../rooms/EventTile";
import { presentableTextForFile } from "../../../utils/FileUtils";
import { IMediaEventContent } from "../../../customisations/models/IMediaEventContent";
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
@ -111,6 +113,7 @@ export default class MFileBody extends React.Component<IProps, IState> {
private iframe: React.RefObject<HTMLIFrameElement> = createRef();
private dummyLink: React.RefObject<HTMLAnchorElement> = createRef();
private userDidClick = false;
private fileDownloader: FileDownloader = new FileDownloader(() => this.iframe.current);
public constructor(props: IProps) {
super(props);
@ -118,6 +121,32 @@ export default class MFileBody extends React.Component<IProps, IState> {
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 {
const media = mediaFromContent(this.props.mxEvent.getContent());
return media.srcHttp;
@ -129,24 +158,56 @@ export default class MFileBody extends React.Component<IProps, IState> {
}
}
public render() {
const content = this.props.mxEvent.getContent<IMediaEventContent>();
const text = presentableTextForFile(content);
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";
private decryptFile = async (): Promise<void> => {
if (this.state.decryptedBlob) {
return;
}
try {
this.userDidClick = true;
this.setState({
decryptedBlob: await this.props.mediaEventHelper.sourceBlob.value,
});
} catch (err) {
console.warn("Unable to decrypt attachment: ", err);
Modal.createTrackedDialog('Error decrypting attachment', '', ErrorDialog, {
title: _t("Error"),
description: _t("Error decrypting attachment"),
});
}
};
let placeholder = null;
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 = (
<div className="mx_MediaBody mx_MFileBody_info">
<AccessibleButton className="mx_MediaBody mx_MFileBody_info" onClick={this.onPlaceholderClick}>
<span className="mx_MFileBody_info_icon" />
<span className="mx_MFileBody_info_filename">
{ presentableTextForFile(content, _t("Attachment"), false) }
</span>
</div>
<TextWithTooltip tooltip={presentableTextForFile(this.content, _t("Attachment"), true)}>
<span className="mx_MFileBody_info_filename">
{ presentableTextForFile(this.content, _t("Attachment"), true, true) }
</span>
</TextWithTooltip>
</AccessibleButton>
);
}
@ -157,20 +218,6 @@ export default class MFileBody extends React.Component<IProps, IState> {
// Need to decrypt the attachment
// Wait for the user to click on the link before downloading
// and decrypting the attachment.
const decrypt = async () => {
try {
this.userDidClick = true;
this.setState({
decryptedBlob: await this.props.mediaEventHelper.sourceBlob.value,
});
} catch (err) {
console.warn("Unable to decrypt attachment: ", err);
Modal.createTrackedDialog('Error decrypting attachment', '', ErrorDialog, {
title: _t("Error"),
description: _t("Error decrypting attachment"),
});
}
};
// This button should actually Download because usercontent/ will try to click itself
// but it is not guaranteed between various browsers' settings.
@ -178,31 +225,14 @@ export default class MFileBody extends React.Component<IProps, IState> {
<span className="mx_MFileBody">
{ placeholder }
{ showDownloadLink && <div className="mx_MFileBody_download">
<AccessibleButton onClick={decrypt}>
{ _t("Decrypt %(text)s", { text: text }) }
<AccessibleButton onClick={this.decryptFile}>
{ _t("Decrypt %(text)s", { text: this.linkText }) }
</AccessibleButton>
</div> }
</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
// 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} />
</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
src={url}
onLoad={onIframeLoad}
onLoad={() => this.downloadFile(this.fileName, this.linkText)}
ref={this.iframe}
sandbox="allow-scripts allow-downloads allow-downloads-without-user-activation" />
</div> }
@ -259,7 +296,7 @@ export default class MFileBody extends React.Component<IProps, IState> {
// We have to create an anchor to download the file
const tempAnchor = document.createElement('a');
tempAnchor.download = fileName;
tempAnchor.download = this.fileName;
tempAnchor.href = blobUrl;
document.body.appendChild(tempAnchor); // for firefox: https://stackoverflow.com/a/32226068
tempAnchor.click();
@ -268,7 +305,7 @@ export default class MFileBody extends React.Component<IProps, IState> {
};
} else {
// Else we are hoping the browser will do the right thing
downloadProps["download"] = fileName;
downloadProps["download"] = this.fileName;
}
return (
@ -277,16 +314,16 @@ export default class MFileBody extends React.Component<IProps, IState> {
{ showDownloadLink && <div className="mx_MFileBody_download">
<a {...downloadProps}>
<span className="mx_MFileBody_download_icon" />
{ _t("Download %(text)s", { text: text }) }
{ _t("Download %(text)s", { text: this.linkText }) }
</a>
{ 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> }
</span>
);
} else {
const extra = text ? (': ' + text) : '';
const extra = this.linkText ? (': ' + this.linkText) : '';
return <span className="mx_MFileBody">
{ placeholder }
{ _t("Invalid file%(extra)s", { extra: extra }) }

View File

@ -347,12 +347,21 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
className="mx_MImageBody_thumbnail"
src={thumbUrl}
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}
onError={this.onImageError}
onLoad={this.onImageLoad}
onMouseEnter={this.onImageEnter}
onMouseLeave={this.onImageLeave} />
onMouseLeave={this.onImageLeave}
/>
);
}
@ -379,7 +388,10 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
</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 }
{ gifLabel }
</div>

View File

@ -17,7 +17,6 @@ limitations under the License.
import React from "react";
import MAudioBody from "./MAudioBody";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import SettingsStore from "../../../settings/SettingsStore";
import MVoiceMessageBody from "./MVoiceMessageBody";
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
const isVoiceMessage = !!this.props.mxEvent.getContent()['org.matrix.msc2516.voice']
|| !!this.props.mxEvent.getContent()['org.matrix.msc3245.voice'];
const voiceMessagesEnabled = SettingsStore.getValue("feature_voice_messages");
if (isVoiceMessage && voiceMessagesEnabled) {
if (isVoiceMessage) {
return <MVoiceMessageBody {...this.props} />;
} else {
return <MAudioBody {...this.props} />;

View File

@ -136,7 +136,8 @@ export default class TextualBody extends React.Component<IBodyProps, IState> {
private addCodeExpansionButton(div: HTMLDivElement, pre: HTMLPreElement): void {
// Calculate how many percent does the pre element take up.
// 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;
const button = document.createElement("span");

View File

@ -152,7 +152,7 @@ const PinnedMessagesCard = ({ room, onClose }: IProps) => {
<h2>{ _t("Nothing pinned, yet") }</h2>
{ _t("If you have permissions, open the menu on any message and select " +
"<b>Pin</b> to stick them here.", {}, {
b: sub => <b>{ sub }</b>,
b: sub => <b>{ sub }</b>,
}) }
</div>
</div>;

View File

@ -851,7 +851,7 @@ const RoomAdminToolsContainer: React.FC<IBaseRoomProps> = ({
return <div />;
};
interface GroupMember {
export interface GroupMember {
userId: string;
displayname?: string; // XXX: GroupMember objects are inconsistent :((
avatarUrl?: string;
@ -1381,8 +1381,8 @@ const BasicUserInfo: React.FC<{
className="mx_UserInfo_field"
onClick={() => {
dis.dispatch({
action: Action.ViewUserSettings,
initialTabId: UserTab.Security,
action: Action.ViewUserSettings,
initialTabId: UserTab.Security,
});
}}
>

View File

@ -1,6 +1,5 @@
/*
Copyright 2019 New Vector Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2019 - 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.
@ -32,7 +31,7 @@ import {
} from '../../../editor/operations';
import { getCaretOffsetAndText, getRangeForSelection } from '../../../editor/dom';
import Autocomplete, { generateCompletionDomId } from '../rooms/Autocomplete';
import { getAutoCompleteCreator } from '../../../editor/parts';
import { getAutoCompleteCreator, Type } from '../../../editor/parts';
import { parseEvent, parsePlainTextMessage } from '../../../editor/deserialize';
import { renderModel } from '../../../editor/render';
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 SURROUND_WITH_CHARACTERS = ["\"", "_", "`", "'", "*", "~", "$"];
const SURROUND_WITH_DOUBLE_CHARACTERS = new Map([
["(", ")"],
["[", "]"],
["{", "}"],
["<", ">"],
]);
function ctrlShortcutLabel(key: string): string {
return (IS_MAC ? "⌘" : "Ctrl") + "+" + key;
}
@ -99,6 +106,7 @@ interface IState {
showVisualBell?: boolean;
autoComplete?: AutocompleteWrapperModel;
completionIndex?: number;
surroundWith: boolean;
}
@replaceableComponent("views.rooms.BasicMessageEditor")
@ -117,12 +125,14 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
private readonly emoticonSettingHandle: string;
private readonly shouldShowPillAvatarSettingHandle: string;
private readonly surroundWithHandle: string;
private readonly historyManager = new HistoryManager();
constructor(props) {
super(props);
this.state = {
showPillAvatar: SettingsStore.getValue("Pill.shouldShowPillAvatar"),
surroundWith: SettingsStore.getValue("MessageComposerInput.surroundWith"),
};
this.emoticonSettingHandle = SettingsStore.watchSetting('MessageComposerInput.autoReplaceEmoji', null,
@ -130,6 +140,8 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
this.configureEmoticonAutoReplace();
this.shouldShowPillAvatarSettingHandle = SettingsStore.watchSetting("Pill.shouldShowPillAvatar", null,
this.configureShouldShowPillAvatar);
this.surroundWithHandle = SettingsStore.watchSetting("MessageComposerInput.surroundWith", null,
this.surroundWithSettingChanged);
}
public componentDidUpdate(prevProps: IProps) {
@ -157,7 +169,7 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
range.expandBackwardsWhile((index, offset) => {
const part = model.parts[index];
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);
if (emoticonMatch) {
@ -422,6 +434,28 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
private onKeyDown = (event: React.KeyboardEvent): void => {
const model = this.props.model;
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);
switch (action) {
case MessageComposerAction.FormatBold:
@ -524,9 +558,9 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
const range = model.startRange(position);
range.expandBackwardsWhile((index, offset, part) => {
return part.text[offset] !== " " && part.text[offset] !== "+" && (
part.type === "plain" ||
part.type === "pill-candidate" ||
part.type === "command"
part.type === Type.Plain ||
part.type === Type.PillCandidate ||
part.type === Type.Command
);
});
const { partCreator } = model;
@ -574,6 +608,11 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
this.setState({ showPillAvatar });
};
private surroundWithSettingChanged = () => {
const surroundWith = SettingsStore.getValue("MessageComposerInput.surroundWith");
this.setState({ surroundWith });
};
componentWillUnmount() {
document.removeEventListener("selectionchange", this.onSelectionChange);
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);
SettingsStore.unwatchSetting(this.emoticonSettingHandle);
SettingsStore.unwatchSetting(this.shouldShowPillAvatarSettingHandle);
SettingsStore.unwatchSetting(this.surroundWithHandle);
}
componentDidMount() {
@ -684,7 +724,7 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
<MessageComposerFormatBar ref={this.formatBarRef} onAction={this.onFormatAction} shortcuts={shortcuts} />
<div
className={classes}
contentEditable="true"
contentEditable={this.props.disabled ? null : true}
tabIndex={0}
onBlur={this.onBlur}
onFocus={this.onFocus}

View File

@ -25,7 +25,7 @@ import { getCaretOffsetAndText } from '../../../editor/dom';
import { htmlSerializeIfNeeded, textSerialize, containsEmote, stripEmoteCommand } from '../../../editor/serialize';
import { findEditableEvent } from '../../../utils/EventUtils';
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 BasicMessageComposer from "./BasicMessageComposer";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
@ -242,12 +242,12 @@ export default class EditMessageComposer extends React.Component<IProps, IState>
const parts = this.model.parts;
const firstPart = parts[0];
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;
}
if (firstPart.text.startsWith("/") && !firstPart.text.startsWith("//")
&& (firstPart.type === "plain" || firstPart.type === "pill-candidate")) {
&& (firstPart.type === Type.Plain || firstPart.type === Type.PillCandidate)) {
return true;
}
}
@ -268,7 +268,7 @@ export default class EditMessageComposer extends React.Component<IProps, IState>
private getSlashCommand(): [Command, string, string] {
const commandText = this.model.parts.reduce((text, part) => {
// 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.text;

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