Merge branch 'develop' of github.com:matrix-org/matrix-react-sdk into t3chguy/ts/12

This commit is contained in:
Michael Telatynski 2021-08-10 10:21:01 +01:00
commit cd1b315ed6
234 changed files with 8572 additions and 3202 deletions

1
.github/CODEOWNERS vendored Normal file
View file

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

19
.github/workflows/layered-build.yaml vendored Normal file
View file

@ -0,0 +1,19 @@
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

50
.github/workflows/netflify.yaml vendored Normal file
View file

@ -0,0 +1,50 @@
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));
- run: unzip previewbuild.zip && rm previewbuild.zip
- name: Deploy to Netlify
uses: nwtgck/actions-netlify@v1.2
with:
publish-dir: .
github-token: ${{ secrets.GITHUB_TOKEN }}
deploy-message: "Deploy from GitHub Actions"
enable-pull-request-comment: true
enable-commit-comment: false
overwrites-pull-request-comment: true
env:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
timeout-minutes: 1

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

@ -104,8 +104,8 @@ a:visited {
input[type=text],
input[type=search],
input[type=password] {
font-family: inherit;
padding: 9px;
font-family: $font-family;
font-size: $font-14px;
font-weight: 600;
min-width: 0;
@ -146,7 +146,6 @@ input[type=text], input[type=password], textarea {
/* Required by Firefox */
textarea {
font-family: $font-family;
color: $primary-fg-color;
}

View file

@ -67,7 +67,6 @@
@import "./views/dialogs/_AddExistingToSpaceDialog.scss";
@import "./views/dialogs/_AddressPickerDialog.scss";
@import "./views/dialogs/_Analytics.scss";
@import "./views/dialogs/_BetaFeedbackDialog.scss";
@import "./views/dialogs/_BugReportDialog.scss";
@import "./views/dialogs/_ChangelogDialog.scss";
@import "./views/dialogs/_ChatCreateOrReuseChatDialog.scss";
@ -76,16 +75,20 @@
@import "./views/dialogs/_CreateCommunityPrototypeDialog.scss";
@import "./views/dialogs/_CreateGroupDialog.scss";
@import "./views/dialogs/_CreateRoomDialog.scss";
@import "./views/dialogs/_CreateSubspaceDialog.scss";
@import "./views/dialogs/_DeactivateAccountDialog.scss";
@import "./views/dialogs/_DevtoolsDialog.scss";
@import "./views/dialogs/_EditCommunityPrototypeDialog.scss";
@import "./views/dialogs/_FeedbackDialog.scss";
@import "./views/dialogs/_ForwardDialog.scss";
@import "./views/dialogs/_GenericFeatureFeedbackDialog.scss";
@import "./views/dialogs/_GroupAddressPicker.scss";
@import "./views/dialogs/_HostSignupDialog.scss";
@import "./views/dialogs/_IncomingSasDialog.scss";
@import "./views/dialogs/_InviteDialog.scss";
@import "./views/dialogs/_JoinRuleDropdown.scss";
@import "./views/dialogs/_KeyboardShortcutsDialog.scss";
@import "./views/dialogs/_LeaveSpaceDialog.scss";
@import "./views/dialogs/_ManageRestrictedJoinRuleDialog.scss";
@import "./views/dialogs/_MessageEditHistoryDialog.scss";
@import "./views/dialogs/_ModalWidgetDialog.scss";
@ -237,6 +240,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";
@ -263,12 +267,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/_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";
@import "./views/voip/_DialPadModal.scss";

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

@ -61,6 +61,7 @@ limitations under the License.
.mx_AccessibleButton_kind_link {
padding: 0;
font-size: inherit;
}
.mx_SearchBox {
@ -190,7 +191,6 @@ limitations under the License.
position: relative;
padding: 8px 16px;
border-radius: 8px;
min-height: 56px;
box-sizing: border-box;
display: grid;

View file

@ -335,24 +335,17 @@ $SpaceRoomViewInnerWidth: 428px;
word-wrap: break-word;
}
> hr {
border: none;
height: 1px;
background-color: $groupFilterPanel-bg-color;
}
.mx_SearchBox {
margin: 0 0 20px;
flex: 0;
}
.mx_SpaceFeedbackPrompt {
margin-bottom: 16px;
// hide the HR as we have our own
& + hr {
display: none;
}
padding: 7px; // 8px - 1px border
border: 1px solid $menu-border-color;
border-radius: 8px;
width: max-content;
margin: 0 0 -40px auto; // collapse its own height to not push other components down
}
.mx_SpaceRoomDirectory_list {
@ -513,66 +506,3 @@ $SpaceRoomViewInnerWidth: 428px;
}
}
}
.mx_SpaceFeedbackPrompt {
margin-top: 18px;
margin-bottom: 12px;
> hr {
border: none;
border-top: 1px solid $input-border-color;
margin-bottom: 12px;
}
> div {
display: flex;
flex-direction: row;
font-size: $font-15px;
line-height: $font-24px;
> span {
color: $secondary-fg-color;
position: relative;
padding-left: 32px;
font-size: inherit;
line-height: inherit;
margin-right: auto;
&::before {
content: '';
position: absolute;
left: 0;
top: 2px;
height: 20px;
width: 20px;
background-color: $secondary-fg-color;
mask-repeat: no-repeat;
mask-size: contain;
mask-image: url('$(res)/img/element-icons/room/room-summary.svg');
mask-position: center;
}
}
.mx_AccessibleButton_kind_link {
color: $accent-color;
position: relative;
padding: 0 0 0 24px;
margin-left: 8px;
font-size: inherit;
line-height: inherit;
&::before {
content: '';
position: absolute;
left: 0;
height: 16px;
width: 16px;
background-color: $accent-color;
mask-repeat: no-repeat;
mask-size: contain;
mask-image: url('$(res)/img/element-icons/chat-bubbles.svg');
mask-position: center;
}
}
}
}

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

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

View file

@ -50,64 +50,11 @@ limitations under the License.
line-height: $font-15px;
}
.mx_AddExistingToSpace_entry {
display: flex;
margin-top: 12px;
// we can't target .mx_BaseAvatar here as it'll break the decorated avatar styling
.mx_DecoratedRoomAvatar {
margin-right: 12px;
}
.mx_AddExistingToSpace_entry_name {
font-size: $font-15px;
line-height: 30px;
flex-grow: 1;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
margin-right: 12px;
}
.mx_Checkbox {
align-items: center;
}
}
}
.mx_AddExistingToSpace_section_spaces {
.mx_BaseAvatar {
margin-right: 12px;
}
.mx_BaseAvatar_image {
border-radius: 8px;
}
}
.mx_AddExistingToSpace_section_experimental {
position: relative;
border-radius: 8px;
margin: 12px 0;
padding: 8px 8px 8px 42px;
background-color: $header-panel-bg-color;
.mx_AccessibleButton_kind_link {
font-size: $font-12px;
line-height: $font-15px;
color: $secondary-fg-color;
&::before {
content: '';
position: absolute;
left: 10px;
top: calc(50% - 8px); // vertical centering
height: 16px;
width: 16px;
background-color: $secondary-fg-color;
mask-repeat: no-repeat;
mask-size: contain;
mask-image: url('$(res)/img/element-icons/room/room-summary.svg');
mask-position: center;
margin-top: 8px;
padding: 0;
}
}
@ -205,7 +152,12 @@ limitations under the License.
min-height: 0;
height: 80vh;
.mx_Dialog_title {
.mx_AddExistingToSpace {
display: contents;
}
}
.mx_SubspaceSelector {
display: flex;
.mx_BaseAvatar_image {
@ -227,12 +179,6 @@ limitations under the License.
line-height: $font-22px;
margin: 0;
}
.mx_AddExistingToSpaceDialog_onlySpace {
color: $secondary-fg-color;
font-size: $font-15px;
line-height: $font-24px;
}
}
.mx_Dropdown_input {
@ -252,7 +198,7 @@ limitations under the License.
}
.mx_Dropdown_menu {
.mx_AddExistingToSpaceDialog_dropdownOptionActive {
.mx_SubspaceSelector_dropdownOptionActive {
color: $accent-color;
padding-right: 32px;
position: relative;
@ -273,9 +219,39 @@ limitations under the License.
}
}
}
.mx_SubspaceSelector_onlySpace {
color: $secondary-fg-color;
font-size: $font-15px;
line-height: $font-24px;
}
}
.mx_AddExistingToSpace {
display: contents;
.mx_AddExistingToSpace_entry {
display: flex;
margin-top: 12px;
.mx_DecoratedRoomAvatar, // we can't target .mx_BaseAvatar here as it'll break the decorated avatar styling
.mx_BaseAvatar.mx_RoomAvatar_isSpaceRoom {
margin-right: 12px;
}
img.mx_RoomAvatar_isSpaceRoom,
.mx_RoomAvatar_isSpaceRoom img {
border-radius: 8px;
}
.mx_AddExistingToSpace_entry_name {
font-size: $font-15px;
line-height: 30px;
flex-grow: 1;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
margin-right: 12px;
}
.mx_Checkbox {
align-items: center;
}
}

View file

@ -29,7 +29,6 @@ limitations under the License.
.mx_AddressPickerDialog_input:focus {
height: 26px;
font-size: $font-14px;
font-family: $font-family;
padding-left: 12px;
padding-right: 12px;
margin: 0 !important;

View file

@ -34,7 +34,6 @@ limitations under the License.
}
.mx_ConfirmUserActionDialog_reasonField {
font-family: $font-family;
font-size: $font-14px;
color: $primary-fg-color;
background-color: $primary-bg-color;

View file

@ -109,56 +109,4 @@ limitations under the License.
margin: 0 85px 0 0;
font-size: $font-12px;
}
.mx_Dropdown {
margin-bottom: 8px;
font-weight: normal;
font-family: $font-family;
font-size: $font-14px;
color: $primary-fg-color;
.mx_Dropdown_input {
border: 1px solid $input-border-color;
}
.mx_Dropdown_option {
font-size: $font-14px;
line-height: $font-32px;
height: 32px;
min-height: 32px;
> div {
padding-left: 30px;
position: relative;
&::before {
content: "";
position: absolute;
height: 16px;
width: 16px;
left: 6px;
top: 8px;
mask-repeat: no-repeat;
mask-position: center;
background-color: $secondary-fg-color;
}
}
}
.mx_CreateRoomDialog_dropdown_invite::before {
mask-image: url('$(res)/img/element-icons/lock.svg');
mask-size: contain;
}
.mx_CreateRoomDialog_dropdown_public::before {
mask-image: url('$(res)/img/globe.svg');
mask-size: 12px;
}
.mx_CreateRoomDialog_dropdown_restricted::before {
mask-image: url('$(res)/img/element-icons/community-members.svg');
mask-size: contain;
}
}
}

View file

@ -0,0 +1,81 @@
/*
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_CreateSubspaceDialog_wrapper {
.mx_Dialog {
display: flex;
flex-direction: column;
}
}
.mx_CreateSubspaceDialog {
width: 480px;
color: $primary-fg-color;
display: flex;
flex-direction: column;
flex-wrap: nowrap;
min-height: 0;
.mx_CreateSubspaceDialog_content {
flex-grow: 1;
.mx_CreateSubspaceDialog_betaNotice {
padding: 12px 16px;
border-radius: 8px;
background-color: $header-panel-bg-color;
.mx_BetaCard_betaPill {
margin-right: 8px;
vertical-align: middle;
}
}
.mx_JoinRuleDropdown + p {
color: $muted-fg-color;
font-size: $font-12px;
}
}
.mx_CreateSubspaceDialog_footer {
display: flex;
margin-top: 20px;
.mx_CreateSubspaceDialog_footer_prompt {
flex-grow: 1;
font-size: $font-12px;
line-height: $font-15px;
color: $secondary-fg-color;
> * {
vertical-align: middle;
}
}
.mx_AccessibleButton {
display: inline-block;
align-self: center;
}
.mx_AccessibleButton_kind_primary {
margin-left: 16px;
padding: 8px 36px;
}
.mx_AccessibleButton_kind_link {
padding: 0;
}
}
}

View file

@ -55,22 +55,6 @@ limitations under the License.
padding-right: 24px;
}
.mx_DevTools_inputCell {
display: table-cell;
width: 240px;
}
.mx_DevTools_inputCell input {
display: inline-block;
border: 0;
border-bottom: 1px solid $input-underline-color;
padding: 0;
width: 240px;
color: $input-fg-color;
font-family: $font-family;
font-size: $font-16px;
}
.mx_DevTools_textarea {
font-size: $font-12px;
max-width: 684px;
@ -139,7 +123,6 @@ limitations under the License.
+ .mx_DevTools_tgl-btn {
padding: 2px;
transition: all .2s ease;
font-family: sans-serif;
perspective: 100px;
&::after,
&::before {

View file

@ -14,8 +14,8 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
.mx_BetaFeedbackDialog {
.mx_BetaFeedbackDialog_subheading {
.mx_GenericFeatureFeedbackDialog {
.mx_GenericFeatureFeedbackDialog_subheading {
color: $primary-fg-color;
font-size: $font-14px;
line-height: $font-20px;

View file

@ -0,0 +1,67 @@
/*
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_JoinRuleDropdown {
margin-bottom: 8px;
font-weight: normal;
font-family: $font-family;
font-size: $font-14px;
color: $primary-fg-color;
.mx_Dropdown_input {
border: 1px solid $input-border-color;
}
.mx_Dropdown_option {
font-size: $font-14px;
line-height: $font-32px;
height: 32px;
min-height: 32px;
> div {
padding-left: 30px;
position: relative;
&::before {
content: "";
position: absolute;
height: 16px;
width: 16px;
left: 6px;
top: 8px;
mask-repeat: no-repeat;
mask-position: center;
background-color: $secondary-fg-color;
}
}
}
.mx_JoinRuleDropdown_invite::before {
mask-image: url('$(res)/img/element-icons/lock.svg');
mask-size: contain;
}
.mx_JoinRuleDropdown_public::before {
mask-image: url('$(res)/img/globe.svg');
mask-size: 12px;
}
.mx_JoinRuleDropdown_restricted::before {
mask-image: url('$(res)/img/element-icons/community-members.svg');
mask-size: contain;
}
}

View file

@ -0,0 +1,96 @@
/*
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_LeaveSpaceDialog_wrapper {
.mx_Dialog {
display: flex;
flex-direction: column;
padding: 24px 32px;
}
}
.mx_LeaveSpaceDialog {
width: 440px;
display: flex;
flex-direction: column;
flex-wrap: nowrap;
max-height: 520px;
.mx_Dialog_content {
flex-grow: 1;
margin: 0;
overflow-y: auto;
.mx_RadioButton + .mx_RadioButton {
margin-top: 16px;
}
.mx_SearchBox {
// To match the space around the title
margin: 0 0 15px 0;
flex-grow: 0;
border-radius: 8px;
}
.mx_LeaveSpaceDialog_noResults {
display: block;
margin-top: 24px;
}
.mx_LeaveSpaceDialog_section {
margin: 16px 0;
}
.mx_LeaveSpaceDialog_section_warning {
position: relative;
border-radius: 8px;
margin: 12px 0 0;
padding: 12px 8px 12px 42px;
background-color: $header-panel-bg-color;
font-size: $font-12px;
line-height: $font-15px;
color: $secondary-fg-color;
&::before {
content: '';
position: absolute;
left: 10px;
top: calc(50% - 8px); // vertical centering
height: 16px;
width: 16px;
background-color: $secondary-fg-color;
mask-repeat: no-repeat;
mask-size: contain;
mask-image: url('$(res)/img/element-icons/room/room-summary.svg');
mask-position: center;
}
}
> p {
color: $primary-fg-color;
}
}
.mx_Dialog_buttons {
margin-top: 20px;
.mx_Dialog_primary {
background-color: $notice-primary-color !important; // override default colour
border-color: $notice-primary-color;
}
}
}

View file

@ -16,57 +16,41 @@ limitations under the License.
.mx_desktopCapturerSourcePicker {
overflow: hidden;
}
.mx_desktopCapturerSourcePicker_tabLabels {
display: flex;
padding: 0 0 8px 0;
}
.mx_desktopCapturerSourcePicker_tabLabel,
.mx_desktopCapturerSourcePicker_tabLabel_selected {
width: 100%;
text-align: center;
border-radius: 8px;
padding: 8px 0;
font-size: $font-13px;
}
.mx_desktopCapturerSourcePicker_tabLabel_selected {
background-color: $tab-label-active-bg-color;
color: $tab-label-active-fg-color;
}
.mx_desktopCapturerSourcePicker_panel {
.mx_desktopCapturerSourcePicker_tab {
display: flex;
flex-wrap: wrap;
justify-content: center;
align-items: flex-start;
height: 500px;
overflow: overlay;
}
.mx_desktopCapturerSourcePicker_stream_button {
.mx_desktopCapturerSourcePicker_source {
width: 50%;
display: flex;
flex-direction: column;
margin: 8px;
border-radius: 4px;
}
.mx_desktopCapturerSourcePicker_stream_button:hover,
.mx_desktopCapturerSourcePicker_stream_button:focus {
background: $roomtile-selected-bg-color;
}
.mx_desktopCapturerSourcePicker_stream_thumbnail {
.mx_desktopCapturerSourcePicker_source_thumbnail {
margin: 4px;
width: 312px;
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_stream_name {
.mx_desktopCapturerSourcePicker_source_name {
margin: 0 4px;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
width: 312px;
}
}
}
}

View file

@ -39,7 +39,6 @@ limitations under the License.
.mx_Field select,
.mx_Field textarea {
font-weight: normal;
font-family: $font-family;
font-size: $font-14px;
border: none;
// Even without a border here, we still need this avoid overlapping the rounded

View file

@ -23,7 +23,7 @@ limitations under the License.
background-color: $dark-panel-bg-color;
border-radius: 8px;
margin: 10px auto;
max-width: 75%;
width: 75%;
box-sizing: border-box;
height: 60px;
@ -43,6 +43,14 @@ limitations under the License.
}
}
&.mx_CallEvent_voice.mx_CallEvent_missed .mx_CallEvent_type_icon::before {
mask-image: url('$(res)/img/voip/missed-voice.svg');
}
&.mx_CallEvent_video.mx_CallEvent_missed .mx_CallEvent_type_icon::before {
mask-image: url('$(res)/img/voip/missed-video.svg');
}
.mx_CallEvent_info {
display: flex;
flex-direction: row;

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

@ -43,8 +43,10 @@ limitations under the License.
margin-bottom: 7px;
mask-image: url('$(res)/img/feather-customised/minimise.svg');
}
}
&:hover .mx_ViewSourceEvent_toggle {
.mx_EventTile:hover {
.mx_ViewSourceEvent_toggle {
visibility: visible;
}
}

View file

@ -15,7 +15,7 @@ limitations under the License.
*/
.mx_EventTile[data-layout=bubble],
.mx_EventTile[data-layout=bubble] ~ .mx_EventListSummary {
.mx_EventListSummary[data-layout=bubble] {
--avatarSize: 32px;
--gutterSize: 11px;
--cornerRadius: 12px;
@ -38,8 +38,6 @@ limitations under the License.
padding-top: 0;
}
&:hover,
&.mx_EventTile_selected {
&::before {
content: '';
position: absolute;
@ -48,10 +46,16 @@ limitations under the License.
left: -60px;
right: -60px;
z-index: -1;
background: $eventbubble-bg-hover;
border-radius: 4px;
}
&:hover,
&.mx_EventTile_selected {
&::before {
background: $eventbubble-bg-hover;
}
.mx_EventTile_avatar {
img {
box-shadow: 0 0 0 3px $eventbubble-bg-hover;
@ -155,12 +159,20 @@ limitations under the License.
.mx_EventTile_avatar {
position: absolute;
top: 0;
line-height: 1;
z-index: 9;
img {
box-shadow: 0 0 0 3px $eventbubble-avatar-outline;
border-radius: 50%;
}
}
&.mx_EventTile_noSender {
.mx_EventTile_avatar {
top: -19px;
}
}
.mx_BaseAvatar,
.mx_EventTile_avatar {
line-height: 1;
@ -216,90 +228,6 @@ limitations under the License.
border-left-color: $eventbubble-reply-color;
}
&.mx_EventTile_bubbleContainer,
&.mx_EventTile_info,
& ~ .mx_EventListSummary[data-expanded=false] {
--backgroundColor: transparent;
--gutterSize: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 5px 0;
.mx_EventTile_avatar {
position: static;
order: -1;
margin-right: 5px;
}
}
& ~ .mx_EventListSummary {
--maxWidth: 80%;
margin-left: calc(var(--avatarSize) + var(--gutterSize));
margin-right: calc(var(--gutterSize) + var(--avatarSize));
.mx_EventListSummary_toggle {
float: none;
margin: 0;
order: 9;
margin-left: 5px;
}
.mx_EventListSummary_avatars {
padding-top: 0;
}
&::after {
content: "";
clear: both;
}
.mx_EventTile {
margin: 0 6px;
}
.mx_EventTile_line {
margin: 0 5px;
> a {
left: auto;
right: 0;
transform: translateX(calc(100% + 5px));
}
}
.mx_MessageActionBar {
transform: translate3d(90%, 0, 0);
}
}
& ~ .mx_EventListSummary[data-expanded=false] {
padding: 0 34px;
}
/* events that do not require bubble layout */
& ~ .mx_EventListSummary,
&.mx_EventTile_bad {
.mx_EventTile_line {
background: transparent;
}
&:hover {
&::before {
background: transparent;
}
}
}
& + .mx_EventListSummary {
.mx_EventTile {
margin-top: 0;
padding: 2px 0;
}
}
.mx_EventListSummary_toggle {
margin-right: 55px;
}
/* Special layout scenario for "Unable To Decrypt (UTD)" events */
&.mx_EventTile_bad > .mx_EventTile_line {
display: grid;
@ -334,3 +262,93 @@ limitations under the License.
max-width: 100%;
}
}
.mx_EventTile.mx_EventTile_bubbleContainer[data-layout=bubble],
.mx_EventTile.mx_EventTile_info[data-layout=bubble],
.mx_EventListSummary[data-layout=bubble][data-expanded=false] {
--backgroundColor: transparent;
--gutterSize: 0;
display: flex;
align-items: center;
justify-content: flex-start;
padding: 5px 0;
.mx_EventTile_avatar {
position: static;
order: -1;
margin-right: 5px;
}
.mx_EventTile_line,
.mx_EventTile_info {
min-width: 100%;
}
.mx_EventTile_e2eIcon {
margin-left: 9px;
}
.mx_EventTile_line > a {
right: auto;
top: -15px;
left: -68px;
}
}
.mx_EventListSummary[data-layout=bubble] {
--maxWidth: 70%;
margin-left: calc(var(--avatarSize) + var(--gutterSize));
margin-right: 94px;
.mx_EventListSummary_toggle {
float: none;
margin: 0;
order: 9;
margin-left: 5px;
margin-right: 55px;
}
.mx_EventListSummary_avatars {
padding-top: 0;
}
&::after {
content: "";
clear: both;
}
.mx_EventTile {
margin: 0 6px;
padding: 2px 0;
}
.mx_EventTile_line {
margin: 0 5px;
> a {
left: auto;
right: 0;
transform: translateX(calc(100% + 5px));
}
}
.mx_MessageActionBar {
transform: translate3d(90%, 0, 0);
}
}
.mx_EventListSummary[data-expanded=false][data-layout=bubble] {
padding: 0 34px;
}
/* events that do not require bubble layout */
.mx_EventListSummary[data-layout=bubble],
.mx_EventTile.mx_EventTile_bad[data-layout=bubble] {
.mx_EventTile_line {
background: transparent;
}
&:hover {
&::before {
background: transparent;
}
}
}

View file

@ -59,7 +59,6 @@ $hover-select-border: 4px;
font-size: $font-14px;
display: inline-block; /* anti-zalgo, with overflow hidden */
overflow: hidden;
cursor: pointer;
padding-bottom: 0px;
padding-top: 0px;
margin: 0px;
@ -132,15 +131,6 @@ $hover-select-border: 4px;
}
}
&.mx_EventTile_info .mx_EventTile_line,
& ~ .mx_EventListSummary .mx_EventTile_avatar ~ .mx_EventTile_line {
padding-left: calc($left-gutter + 18px);
}
& ~ .mx_EventListSummary .mx_EventTile_line {
padding-left: calc($left-gutter);
}
&.mx_EventTile_selected.mx_EventTile_info .mx_EventTile_line {
padding-left: calc($left-gutter + 18px - $hover-select-border);
}
@ -276,10 +266,19 @@ $hover-select-border: 4px;
.mx_ReactionsRow {
margin: 0;
padding: 6px 60px;
padding: 4px 64px;
}
}
.mx_EventTile:not([data-layout=bubble]).mx_EventTile_info .mx_EventTile_line,
.mx_EventListSummary:not([data-layout=bubble]) > :not(.mx_EventTile) .mx_EventTile_avatar ~ .mx_EventTile_line {
padding-left: calc($left-gutter + 18px);
}
.mx_EventListSummary:not([data-layout=bubble]) .mx_EventTile_line {
padding-left: calc($left-gutter);
}
/* all the overflow-y: hidden; are to trap Zalgos -
but they introduce an implicit overflow-x: auto.
so make that explicitly hidden too to avoid random
@ -311,17 +310,19 @@ $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
}
.mx_SenderProfile {
cursor: pointer;
}
.mx_EventTile_bubbleContainer {
display: grid;
grid-template-columns: 1fr 100px;
@ -456,9 +457,15 @@ $hover-select-border: 4px;
/* Various markdown overrides */
.mx_EventTile_body pre {
.mx_EventTile_body {
a:hover {
text-decoration: underline;
}
pre {
border: 1px solid transparent;
}
}
.mx_EventTile_content .markdown-body {
font-family: inherit !important;
@ -573,6 +580,12 @@ $hover-select-border: 4px;
color: $accent-color-alt;
}
.mx_EventTile_content .markdown-body blockquote {
border-left: 2px solid $blockquote-bar-color;
border-radius: 2px;
padding: 0 10px;
}
.mx_EventTile_content .markdown-body .hljs {
display: inline !important;
}

View file

@ -116,6 +116,11 @@ $irc-line-height: $font-18px;
.mx_EditMessageComposer_buttons {
position: relative;
}
.mx_ReactionsRow {
padding-left: 0;
padding-right: 0;
}
}
.mx_EventTile_emote {

View file

@ -165,8 +165,6 @@ limitations under the License.
font-size: $font-14px;
max-height: 120px;
overflow: auto;
/* needed for FF */
font-family: $font-family;
}
/* hack for FF as vertical alignment of custom placeholder text is broken */

View file

@ -60,8 +60,6 @@ limitations under the License.
$reply-lines: 2;
$line-height: $font-22px;
pointer-events: none;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-box-orient: vertical;

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

@ -36,7 +36,6 @@ limitations under the License.
.mx_SettingsTab_subheading {
font-size: $font-16px;
display: block;
font-family: $font-family;
font-weight: 600;
color: $primary-fg-color;
margin-bottom: 10px;
@ -73,6 +72,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 {
.mx_HelpUserSettingsTab_copyButton {
flex-shrink: 0;
cursor: pointer;
margin-left: 20px;
display: inherit;
}
.mx_HelpUserSettingsTab_accessToken_copy > div {
mask-image: url($copy-button-url);
background-color: $message-action-bar-fg-color;
margin-left: 5px;
width: 20px;
height: 20px;
cursor: pointer;
margin-left: 20px;
display: block;
&::before {
content: "";
mask-image: url($copy-button-url);
background-color: $message-action-bar-fg-color;
width: 20px;
height: 20px;
display: block;
background-repeat: no-repeat;
}
}
}

View file

@ -43,6 +43,12 @@ $spacePanelWidth: 71px;
color: $secondary-fg-color;
margin: 0;
}
.mx_SpaceFeedbackPrompt {
border-top: 1px solid $input-border-color;
padding-top: 12px;
margin-top: 16px;
}
}
// XXX remove this when spaces leaves Beta
@ -99,3 +105,25 @@ $spacePanelWidth: 71px;
}
}
}
.mx_SpaceFeedbackPrompt {
font-size: $font-15px;
line-height: $font-24px;
> span {
color: $secondary-fg-color;
position: relative;
font-size: inherit;
line-height: inherit;
margin-right: auto;
}
.mx_AccessibleButton_kind_link {
color: $accent-color;
position: relative;
padding: 0;
margin-left: 8px;
font-size: inherit;
line-height: inherit;
}
}

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

@ -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;
@ -67,7 +67,30 @@ limitations under the License.
.mx_CallView_content {
position: relative;
display: flex;
justify-content: center;
border-radius: 8px;
> .mx_VideoFeed {
width: 100%;
height: 100%;
&.mx_VideoFeed_voice {
background-color: $inverted-bg-color;
display: flex;
justify-content: center;
align-items: center;
}
.mx_VideoFeed_video {
height: 100%;
background-color: #000;
}
.mx_VideoFeed_mic {
left: 10px;
bottom: 10px;
}
}
}
.mx_CallView_voice {
@ -176,120 +199,14 @@ 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_phoneIcon {
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: $warning-color;
mask-repeat: no-repeat;
mask-size: contain;
mask-position: center;
mask-image: url('$(res)/img/element-icons/call/voice-call.svg');
}
}
.mx_CallView_callControls {
position: absolute;
display: flex;
justify-content: center;
bottom: 5px;
width: 100%;
opacity: 1;
transition: opacity 0.5s;
z-index: 200; // To be above _all_ feeds
}
.mx_CallView_callControls_hidden {
@ -297,10 +214,29 @@ limitations under the License.
pointer-events: none;
}
.mx_CallView_presenting {
opacity: 1;
transition: opacity 0.5s;
position: absolute;
margin-top: 18px;
padding: 4px 8px;
border-radius: 4px;
// Same on both themes
color: white;
background-color: #17191c;
}
.mx_CallView_presenting_hidden {
opacity: 0.001; // opacity 0 can cause a re-layout
pointer-events: none;
}
.mx_CallView_callControls_button {
cursor: pointer;
margin-left: 8px;
margin-right: 8px;
margin-left: 2px;
margin-right: 2px;
&::before {
@ -317,17 +253,11 @@ limitations under the License.
}
.mx_CallView_callControls_dialpad {
margin-right: auto;
&::before {
background-image: url('$(res)/img/voip/dialpad.svg');
}
}
.mx_CallView_callControls_button_dialpad_hidden {
margin-right: auto;
cursor: initial;
}
.mx_CallView_callControls_button_micOn {
&::before {
background-image: url('$(res)/img/voip/mic-on.svg');
@ -352,6 +282,30 @@ limitations under the License.
}
}
.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');
@ -359,17 +313,11 @@ limitations under the License.
}
.mx_CallView_callControls_button_more {
margin-left: auto;
&::before {
background-image: url('$(res)/img/voip/more.svg');
}
}
.mx_CallView_callControls_button_more_hidden {
margin-left: auto;
cursor: initial;
}
.mx_CallView_callControls_button_invisible {
visibility: hidden;
pointer-events: none;

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

@ -0,0 +1,61 @@
/*
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_CallViewSidebar {
position: absolute;
right: 16px;
bottom: 16px;
z-index: 100; // To be above the primary feed
overflow: auto;
height: calc(100% - 32px); // Subtract the top and bottom padding
width: 20%;
display: flex;
flex-direction: column-reverse;
justify-content: flex-start;
align-items: flex-end;
gap: 12px;
> .mx_VideoFeed {
width: 100%;
&.mx_VideoFeed_voice {
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
}
.mx_VideoFeed_video {
border-radius: 4px;
}
.mx_VideoFeed_mic {
left: 6px;
bottom: 6px;
}
}
&.mx_CallViewSidebar_pipMode {
top: 16px;
bottom: unset;
justify-content: flex-end;
gap: 4px;
}
}

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

@ -14,37 +14,54 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
.mx_VideoFeed_voice {
.mx_VideoFeed {
overflow: hidden;
position: relative;
&.mx_VideoFeed_voice {
background-color: $inverted-bg-color;
aspect-ratio: 16 / 9;
}
.mx_VideoFeed_remote {
.mx_VideoFeed_video {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
&.mx_VideoFeed_video {
background-color: #000;
}
}
.mx_VideoFeed_local {
max-width: 25%;
max-height: 25%;
position: absolute;
right: 10px;
top: 10px;
z-index: 100;
border-radius: 4px;
&.mx_VideoFeed_video {
background-color: transparent;
}
}
.mx_VideoFeed_mirror {
&.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');
}
}
}

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

@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 4.81815C0 3.76379 0.89543 2.90906 2 2.90906H9.33333C10.4379 2.90906 11.3333 3.76379 11.3333 4.81815V11.1818C11.3333 12.2361 10.4379 13.0909 9.33333 13.0909H2C0.895429 13.0909 0 12.2361 0 11.1818V4.81815ZM12.6667 6.09089L14.9169 4.37255C15.3534 4.03921 16 4.33587 16 4.86947V11.1305C16 11.6641 15.3534 11.9607 14.9169 11.6274L12.6667 9.90907V6.09089ZM3.68584 8.54792C3.68584 8.82819 3.45653 9.05751 3.17625 9.05751C2.89598 9.05751 2.66667 8.82819 2.66667 8.54792V6.50957C2.66667 6.22929 2.89598 5.99998 3.17625 5.99998H5.2146C5.49488 5.99998 5.72419 6.22929 5.72419 6.50957C5.72419 6.78984 5.49488 7.01916 5.2146 7.01916H4.39926L6.2083 8.82819L8.73076 6.30573C8.9295 6.10699 9.25054 6.10699 9.44928 6.30573C9.64802 6.50447 9.64802 6.82551 9.44928 7.02425L6.56501 9.90852C6.36627 10.1073 6.04523 10.1073 5.84649 9.90852L3.68584 7.74787V8.54792Z" fill="#8D97A5"/>
</svg>

After

Width:  |  Height:  |  Size: 1,016 B

View file

@ -0,0 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.00016 6C4.36683 6 4.66683 5.7 4.66683 5.33333V4.28667L7.4935 7.11333C7.7535 7.37333 8.1735 7.37333 8.4335 7.11333L12.2068 3.34C12.4668 3.08 12.4668 2.66 12.2068 2.4C11.9468 2.14 11.5268 2.14 11.2668 2.4L7.96683 5.7L5.60016 3.33333H6.66683C7.0335 3.33333 7.3335 3.03333 7.3335 2.66667C7.3335 2.3 7.0335 2 6.66683 2H4.00016C3.6335 2 3.3335 2.3 3.3335 2.66667V5.33333C3.3335 5.7 3.6335 6 4.00016 6Z" fill="#8D97A5"/>
<path d="M8.00557 8.67107C6.88076 8.62784 4.56757 8.91974 4.0052 9.06763C3.97195 9.07638 3.93363 9.08616 3.89078 9.0971C3.02734 9.31746 0.321813 10.008 0.0294949 12.1958C-0.196977 13.8909 0.937169 14.4039 1.50412 14.3258C1.89653 14.2766 3.02006 14.0989 4.05816 13.9127C5.07753 13.7298 5.07701 13.0573 5.07666 12.6026C5.07665 12.5943 5.07664 12.586 5.07664 12.5778L5.07665 11.6636C5.07665 11.4308 5.29543 11.2962 5.5972 11.2598C6.66548 11.1147 7.5573 11.1143 8.00369 11.1143L8.00745 11.1143C8.45377 11.1143 9.33453 11.1147 10.4028 11.2598C10.7046 11.2962 10.9234 11.4308 10.9234 11.6636L10.9234 12.5778C10.9234 12.586 10.9233 12.5943 10.9233 12.6026C10.923 13.0573 10.9225 13.7298 11.9418 13.9127C12.9799 14.099 14.1035 14.2766 14.4959 14.3258C15.0628 14.4039 16.197 13.8909 15.9705 12.1958C15.6782 10.008 12.9727 9.31747 12.1092 9.0971C12.0664 9.08617 12.0281 9.07639 11.9948 9.06764C11.4324 8.91975 9.13037 8.62783 8.00557 8.67107Z" fill="#8D97A5"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View file

@ -0,0 +1,18 @@
<svg width="50" height="49" viewBox="0 0 50 49" fill="none" xmlns="http://www.w3.org/2000/svg">
<g filter="url(#filter0_d)">
<circle cx="25" cy="20" r="20" fill="white"/>
</g>
<rect x="14.6008" y="12.8" width="20.8" height="14.4" rx="1.6" fill="white" stroke="#737D8C" stroke-width="1.6"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M24.3617 23.36C24.3617 23.7135 24.6483 24 25.0017 24C25.3552 24 25.6417 23.7135 25.6417 23.36L25.6417 18.1851L27.6692 20.2125C27.9191 20.4625 28.3243 20.4625 28.5743 20.2125C28.8242 19.9626 28.8242 19.5574 28.5743 19.3075L25.4543 16.1875C25.2043 15.9375 24.7991 15.9375 24.5492 16.1875L21.4292 19.3075C21.1792 19.5574 21.1792 19.9626 21.4292 20.2125C21.6791 20.4625 22.0843 20.4625 22.3343 20.2125L24.3617 18.1851L24.3617 23.36Z" fill="#737D8C"/>
<defs>
<filter id="filter0_d" x="0.947663" y="0" width="48.1047" height="48.1047" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/>
<feOffset dy="4.05234"/>
<feGaussianBlur stdDeviation="2.02617"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.15 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow" result="shape"/>
</filter>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View file

@ -0,0 +1,18 @@
<svg width="50" height="49" viewBox="0 0 50 49" fill="none" xmlns="http://www.w3.org/2000/svg">
<g filter="url(#filter0_d)">
<circle cx="25" cy="20" r="20" fill="#0DBD8B"/>
</g>
<rect x="14.6008" y="12.8" width="20.8" height="14.4" rx="1.6" fill="#0DBD8B" stroke="white" stroke-width="1.6"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M24.3617 23.36C24.3617 23.7135 24.6483 24 25.0017 24C25.3552 24 25.6417 23.7135 25.6417 23.36L25.6417 18.1851L27.6692 20.2125C27.9191 20.4625 28.3243 20.4625 28.5743 20.2125C28.8242 19.9626 28.8242 19.5574 28.5743 19.3075L25.4543 16.1875C25.2043 15.9375 24.7991 15.9375 24.5492 16.1875L21.4292 19.3075C21.1792 19.5574 21.1792 19.9626 21.4292 20.2125C21.6791 20.4625 22.0843 20.4625 22.3343 20.2125L24.3617 18.1851L24.3617 23.36Z" fill="white"/>
<defs>
<filter id="filter0_d" x="0.947663" y="0" width="48.1047" height="48.1047" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/>
<feOffset dy="4.05234"/>
<feGaussianBlur stdDeviation="2.02617"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.15 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow" result="shape"/>
</filter>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View file

@ -0,0 +1,20 @@
<svg width="48" height="47" viewBox="0 0 48 47" fill="none" xmlns="http://www.w3.org/2000/svg">
<g filter="url(#filter0_d)">
<circle cx="24" cy="20" r="20" fill="#737D8C"/>
</g>
<rect x="12.5618" y="12.8992" width="20.3525" height="14.4496" rx="2.43819" fill="white" stroke="#737D8C" stroke-width="1.12362"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M31.9132 20.5009C33.2675 20.5009 34.3655 19.4205 34.3655 18.0876C34.3655 16.7548 33.2675 15.6743 31.9132 15.6743C30.5589 15.6743 29.4609 16.7548 29.4609 18.0876C29.4609 19.4205 30.5589 20.5009 31.9132 20.5009ZM27.8242 26.132C27.8282 23.7187 28.976 21.3054 31.9113 21.3054C34.7818 21.3054 35.9984 23.7187 35.9984 26.132C35.9984 28.5453 32.7288 28.5453 31.9113 28.5453C31.0939 28.5453 27.8206 28.3403 27.8242 26.132Z" fill="white"/>
<path d="M27.8242 26.132L28.386 26.1329L27.8242 26.132ZM35.9984 26.132H35.4366H35.9984ZM33.8037 18.0876C33.8037 19.1017 32.9658 19.9391 31.9132 19.9391V21.0627C33.5693 21.0627 34.9273 19.7392 34.9273 18.0876H33.8037ZM31.9132 16.2361C32.9658 16.2361 33.8037 17.0735 33.8037 18.0876H34.9273C34.9273 16.4361 33.5693 15.1125 31.9132 15.1125V16.2361ZM30.0227 18.0876C30.0227 17.0735 30.8606 16.2361 31.9132 16.2361V15.1125C30.2571 15.1125 28.8991 16.4361 28.8991 18.0876H30.0227ZM31.9132 19.9391C30.8606 19.9391 30.0227 19.1017 30.0227 18.0876H28.8991C28.8991 19.7392 30.2571 21.0627 31.9132 21.0627V19.9391ZM31.9113 20.7436C30.2659 20.7436 29.0747 21.4314 28.3132 22.4845C27.5693 23.5133 27.2645 24.8471 27.2624 26.1311L28.386 26.1329C28.3879 25.0036 28.659 23.924 29.2238 23.1429C29.771 22.386 30.6214 21.8672 31.9113 21.8672V20.7436ZM36.5602 26.132C36.5602 24.8414 36.2364 23.5081 35.4845 22.4817C34.7168 21.4338 33.5275 20.7436 31.9113 20.7436V21.8672C33.1657 21.8672 34.0199 22.3836 34.5781 23.1457C35.1521 23.9293 35.4366 25.0093 35.4366 26.132H36.5602ZM31.9113 29.1071C32.3157 29.1071 33.4213 29.1105 34.4365 28.7775C34.9481 28.6096 35.4778 28.3438 35.8839 27.9122C36.3025 27.4673 36.5602 26.8767 36.5602 26.132H35.4366C35.4366 26.594 35.2857 26.9083 35.0656 27.1422C34.8331 27.3893 34.4943 27.576 34.0863 27.7098C33.2623 27.9801 32.3244 27.9835 31.9113 27.9835V29.1071ZM27.2624 26.1311C27.26 27.5996 28.3757 28.3418 29.3716 28.6961C30.3797 29.0547 31.4763 29.1071 31.9113 29.1071V27.9835C31.5289 27.9835 30.5802 27.9334 29.7482 27.6375C28.9039 27.3371 28.3848 26.8728 28.386 26.1329L27.2624 26.1311Z" fill="#737D8C"/>
<rect x="0.0339116" y="-0.787426" width="29.1443" height="3.36793" rx="1.68396" transform="matrix(0.681883 0.731461 -0.742244 0.670129 13.0943 8.71545)" fill="white" stroke="#737D8C" stroke-width="1.12362"/>
<defs>
<filter id="filter0_d" x="0.589744" y="0" width="46.8205" height="46.8205" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/>
<feOffset dy="3.41026"/>
<feGaussianBlur stdDeviation="1.70513"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.15 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow" result="shape"/>
</filter>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

View file

@ -0,0 +1,19 @@
<svg width="48" height="47" viewBox="0 0 48 47" fill="none" xmlns="http://www.w3.org/2000/svg">
<g filter="url(#filter0_d)">
<circle cx="24" cy="20" r="20" fill="white"/>
</g>
<rect x="12.5" y="12.5" width="20.4763" height="15.3319" rx="2.5" fill="#737D8C" stroke="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M31.912 20.5618C33.2664 20.5618 34.3643 19.4287 34.3643 18.0309C34.3643 16.6331 33.2664 15.5 31.912 15.5C30.5577 15.5 29.4598 16.6331 29.4598 18.0309C29.4598 19.4287 30.5577 20.5618 31.912 20.5618ZM27.8242 26.467C27.8282 23.9361 28.976 21.4052 31.9113 21.4052C34.7818 21.4052 35.9985 23.9361 35.9985 26.467C35.9985 28.9978 32.7288 28.9978 31.9114 28.9978C31.0939 28.9978 27.8206 28.7829 27.8242 26.467Z" fill="#737D8C"/>
<path d="M27.8242 26.467L27.3242 26.4662L27.8242 26.467ZM35.9985 26.467H36.4985H35.9985ZM33.8643 18.0309C33.8643 19.1675 32.9755 20.0618 31.912 20.0618V21.0618C33.5573 21.0618 34.8643 19.6898 34.8643 18.0309H33.8643ZM31.912 16C32.9755 16 33.8643 16.8943 33.8643 18.0309H34.8643C34.8643 16.372 33.5573 15 31.912 15V16ZM29.9598 18.0309C29.9598 16.8943 30.8486 16 31.912 16V15C30.2668 15 28.9598 16.372 28.9598 18.0309H29.9598ZM31.912 20.0618C30.8486 20.0618 29.9598 19.1675 29.9598 18.0309H28.9598C28.9598 19.6898 30.2668 21.0618 31.912 21.0618V20.0618ZM31.9113 20.9052C30.2753 20.9052 29.1023 21.622 28.3569 22.7032C27.6274 23.7612 27.3263 25.1361 27.3242 26.4662L28.3242 26.4677C28.3261 25.2669 28.6009 24.1109 29.1802 23.2708C29.7434 22.4538 30.612 21.9052 31.9113 21.9052V20.9052ZM36.4985 26.467C36.4985 25.1313 36.1789 23.7567 35.4412 22.7007C34.6893 21.6242 33.5177 20.9052 31.9113 20.9052V21.9052C33.1755 21.9052 34.0475 22.4516 34.6214 23.2733C35.2097 24.1154 35.4985 25.2717 35.4985 26.467H36.4985ZM31.9114 29.4978C32.3162 29.4978 33.416 29.5011 34.4241 29.1543C34.9326 28.9794 35.4519 28.7044 35.847 28.264C36.2515 27.8131 36.4985 27.2184 36.4985 26.467H35.4985C35.4985 26.9809 35.3367 27.3354 35.1026 27.5962C34.8591 27.8677 34.5099 28.0673 34.0988 28.2087C33.2677 28.4946 32.3239 28.4978 31.9114 28.4978V29.4978ZM27.3242 26.4662C27.3219 27.9345 28.3854 28.6964 29.3851 29.0693C30.3864 29.4429 31.4779 29.4978 31.9114 29.4978V28.4978C31.5274 28.4978 30.5735 28.4453 29.7346 28.1324C28.8943 27.8189 28.3229 27.3153 28.3242 26.4677L27.3242 26.4662Z" fill="white"/>
<defs>
<filter id="filter0_d" x="0.589744" y="0" width="46.8205" height="46.8205" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/>
<feOffset dy="3.41026"/>
<feGaussianBlur stdDeviation="1.70513"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.15 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow" result="shape"/>
</filter>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

View file

@ -1,3 +1,6 @@
// Colors from Figma Compound https://www.figma.com/file/X4XTH9iS2KGJ2wFKDqkyed/Compound?node-id=557%3A0
$system-dark: #21262C;
// unified palette
// try to use these colors when possible
$bg-color: #15191E;
@ -47,7 +50,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 +94,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;
@ -112,8 +115,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;
// ********************
@ -175,7 +178,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;
@ -210,7 +213,7 @@ $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-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 +231,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;

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,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: 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=557%3A0
$system-light: #F4F6FA;
// unified palette
// try to use these colors when possible
@ -138,7 +141,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;
@ -167,8 +170,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;
// ********************
@ -327,7 +330,7 @@ $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-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.
@ -350,9 +353,9 @@ $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;

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.
@ -56,12 +57,10 @@ limitations under the License.
import React from 'react';
import { MatrixClientPeg } from './MatrixClientPeg';
import PlatformPeg from './PlatformPeg';
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";
@ -80,7 +79,6 @@ import CountlyAnalytics from "./CountlyAnalytics";
import { UIFeature } from "./settings/UIFeature";
import { CallError } from "matrix-js-sdk/src/webrtc/call";
import { logger } from 'matrix-js-sdk/src/logger';
import DesktopCapturerSourcePicker from "./components/views/elements/DesktopCapturerSourcePicker";
import { Action } from './dispatcher/actions';
import VoipUserMapper from './VoipUserMapper';
import { addManagedHybridWidget, isManagedHybridWidgetEnabled } from './widgets/ManagedHybrid';
@ -88,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';
@ -129,14 +133,9 @@ interface ThirdpartyLookupResponse {
fields: ThirdpartyLookupResponseFields;
}
// Unlike 'CallType' in js-sdk, this one includes screen sharing
// (because a screen sharing call is only a screen sharing call to the caller,
// to the callee it's just a video call, at least as far as the current impl
// is concerned).
export enum PlaceCallType {
Voice = 'voice',
Video = 'video',
ScreenSharing = 'screensharing',
}
export enum CallHandlerEvent {
@ -483,35 +482,43 @@ export default class CallHandler extends EventEmitter {
}
switch (newState) {
case CallState.Ringing:
case CallState.Ringing: {
const incomingCallPushRule = (
new PushProcessor(MatrixClientPeg.get()).getPushRuleById(RuleId.IncomingCall) as IPushRule
);
const pushRuleEnabled = incomingCallPushRule?.enabled;
const tweakSetToRing = incomingCallPushRule?.actions.some((action: Tweaks) => (
action.set_tweak === TweakName.Sound &&
action.value === "ring"
));
if (pushRuleEnabled && tweakSetToRing) {
this.play(AudioID.Ring);
} else {
this.silenceCall(call.callId);
}
break;
case CallState.InviteSent:
}
case CallState.InviteSent: {
this.play(AudioID.Ringback);
break;
case CallState.Ended:
{
Analytics.trackEvent('voip', 'callEnded', 'hangupReason', call.hangupReason);
}
case CallState.Ended: {
const hangupReason = call.hangupReason;
Analytics.trackEvent('voip', 'callEnded', 'hangupReason', hangupReason);
this.removeCallForRoom(mappedRoomId);
if (oldState === CallState.InviteSent && (
call.hangupParty === CallParty.Remote ||
(call.hangupParty === CallParty.Local && call.hangupReason === CallErrorCode.InviteTimeout)
)) {
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;
if (call.hangupReason === CallErrorCode.UserHangup) {
title = _t("Call Declined");
description = _t("The other party declined the call.");
} else if (call.hangupReason === CallErrorCode.UserBusy) {
// 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 (call.hangupReason === CallErrorCode.InviteTimeout) {
title = _t("Call Failed");
// XXX: full stop appended as some relic here, but these
// strings need proper input from design anyway, so let's
// not change this string until we have a proper one.
description = _t('The remote side failed to pick up') + '.';
} else {
title = _t("Call Failed");
description = _t("The call could not be established");
@ -521,7 +528,7 @@ export default class CallHandler extends EventEmitter {
title, description,
});
} else if (
call.hangupReason === CallErrorCode.AnsweredElsewhere && oldState === CallState.Connecting
hangupReason === CallErrorCode.AnsweredElsewhere && oldState === CallState.Connecting
) {
Modal.createTrackedDialog('Call Handler', 'Call Failed', ErrorDialog, {
title: _t("Answered Elsewhere"),
@ -641,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,
@ -738,25 +758,6 @@ export default class CallHandler extends EventEmitter {
call.placeVoiceCall();
} else if (type === 'video') {
call.placeVideoCall();
} else if (type === PlaceCallType.ScreenSharing) {
const screenCapErrorString = PlatformPeg.get().screenCaptureErrorString();
if (screenCapErrorString) {
this.removeCallForRoom(roomId);
console.log("Can't capture screen: " + screenCapErrorString);
Modal.createTrackedDialog('Call Handler', 'Unable to capture screen', ErrorDialog, {
title: _t('Unable to capture screen'),
description: screenCapErrorString,
});
return;
}
call.placeScreenSharingCall(
async (): Promise<DesktopCapturerSource> => {
const { finished } = Modal.createDialog(DesktopCapturerSourcePicker);
const [source] = await finished;
return source;
},
);
} else {
console.error("Unknown conf call type: " + type);
}
@ -950,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) {
@ -1029,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

@ -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

@ -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

@ -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;
@ -74,6 +84,19 @@ export default class CallEventGrouper extends EventEmitter {
return this.hangup?.getContent()?.reason;
}
public get rejectParty(): string {
return this.reject?.getSender();
}
public get gotRejected(): boolean {
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
*/
@ -119,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";
@ -80,6 +80,10 @@ export interface IProps extends IPosition {
managed?: boolean;
wrapperClassName?: string;
// If true, this context menu will be mounted as a child to the parent container. Otherwise
// it will be mounted to a container at the root of the DOM.
mountAsChild?: boolean;
// Function to be called on menu close
onFinished();
// on resize callback
@ -390,9 +394,15 @@ export class ContextMenu extends React.PureComponent<IProps, IState> {
}
render(): React.ReactChild {
if (this.props.mountAsChild) {
// Render as a child of the current parent
return this.renderMenu();
} else {
// Render as a child of a container at the root of the DOM
return ReactDOM.createPortal(this.renderMenu(), getOrCreateContainer());
}
}
}
// Placement method for <ContextMenu /> to position context menu to right of elementRect with chevronOffset
export const toRightOf = (elementRect: Pick<DOMRect, "right" | "top" | "height">, chevronOffset = 12) => {
@ -461,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

@ -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
@ -618,7 +623,15 @@ export default class MessagePanel extends React.Component<IProps, IState> {
for (const Grouper of groupers) {
if (Grouper.canStartGroup(this, mxEv)) {
grouper = new Grouper(this, mxEv, prevEvent, lastShownEvent, nextEvent, nextTile);
grouper = new Grouper(
this,
mxEv,
prevEvent,
lastShownEvent,
this.props.layout,
nextEvent,
nextTile,
);
}
}
if (!grouper) {
@ -981,6 +994,7 @@ abstract class BaseGrouper {
public readonly event: MatrixEvent,
public readonly prevEvent: MatrixEvent,
public readonly lastShownEvent: MatrixEvent,
protected readonly layout: Layout,
public readonly nextEvent?: MatrixEvent,
public readonly nextEventTile?: MatrixEvent,
) {
@ -1107,6 +1121,7 @@ class CreationGrouper extends BaseGrouper {
onToggle={panel.onHeightChanged} // Update scroll state
summaryMembers={[ev.sender]}
summaryText={summaryText}
layout={this.layout}
>
{ eventTiles }
</EventListSummary>,
@ -1134,10 +1149,11 @@ class RedactionGrouper extends BaseGrouper {
ev: MatrixEvent,
prevEvent: MatrixEvent,
lastShownEvent: MatrixEvent,
layout: Layout,
nextEvent: MatrixEvent,
nextEventTile: MatrixEvent,
) {
super(panel, ev, prevEvent, lastShownEvent, nextEvent, nextEventTile);
super(panel, ev, prevEvent, lastShownEvent, layout, nextEvent, nextEventTile);
this.events = [ev];
}
@ -1202,6 +1218,7 @@ class RedactionGrouper extends BaseGrouper {
onToggle={panel.onHeightChanged} // Update scroll state
summaryMembers={Array.from(senders)}
summaryText={_t("%(count)s messages deleted.", { count: eventTiles.length })}
layout={this.layout}
>
{ eventTiles }
</EventListSummary>,
@ -1222,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(
@ -1230,8 +1247,9 @@ class MemberGrouper extends BaseGrouper {
public readonly event: MatrixEvent,
public readonly prevEvent: MatrixEvent,
public readonly lastShownEvent: MatrixEvent,
protected readonly layout: Layout,
) {
super(panel, event, prevEvent, lastShownEvent);
super(panel, event, prevEvent, lastShownEvent, layout);
this.events = [event];
}
@ -1239,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 {
@ -1306,6 +1324,7 @@ class MemberGrouper extends BaseGrouper {
events={this.events}
onToggle={panel.onHeightChanged} // Update scroll state
startExpanded={highlightInMels}
layout={this.layout}
>
{ eventTiles }
</MemberEventListSummary>,

View file

@ -183,8 +183,14 @@ export default class ScrollPanel extends React.Component<IProps> {
private readonly itemlist = createRef<HTMLOListElement>();
private unmounted = false;
private scrollTimeout: Timer;
// Are we currently trying to backfill?
private isFilling: boolean;
// Is the current fill request caused by a props update?
private isFillingDueToPropsUpdate = false;
// Did another request to check the fill state arrive while we were trying to backfill?
private fillRequestWhileRunning: boolean;
// Is that next fill request scheduled because of a props update?
private pendingFillDueToPropsUpdate: boolean;
private scrollState: IScrollState;
private preventShrinkingState: IPreventShrinkingState;
private unfillDebouncer: number;
@ -213,7 +219,7 @@ export default class ScrollPanel extends React.Component<IProps> {
// adding events to the top).
//
// This will also re-check the fill state, in case the paginate was inadequate
this.checkScroll();
this.checkScroll(true);
this.updatePreventShrinking();
}
@ -251,12 +257,12 @@ export default class ScrollPanel extends React.Component<IProps> {
// after an update to the contents of the panel, check that the scroll is
// where it ought to be, and set off pagination requests if necessary.
public checkScroll = () => {
public checkScroll = (isFromPropsUpdate = false) => {
if (this.unmounted) {
return;
}
this.restoreSavedScrollState();
this.checkFillState();
this.checkFillState(0, isFromPropsUpdate);
};
// return true if the content is fully scrolled down right now; else false.
@ -319,7 +325,7 @@ export default class ScrollPanel extends React.Component<IProps> {
}
// check the scroll state and send out backfill requests if necessary.
public checkFillState = async (depth = 0): Promise<void> => {
public checkFillState = async (depth = 0, isFromPropsUpdate = false): Promise<void> => {
if (this.unmounted) {
return;
}
@ -355,14 +361,20 @@ export default class ScrollPanel extends React.Component<IProps> {
// don't allow more than 1 chain of calls concurrently
// do make a note when a new request comes in while already running one,
// so we can trigger a new chain of calls once done.
// However, we make an exception for when we're already filling due to a
// props (or children) update, because very often the children include
// spinners to say whether we're paginating or not, so this would cause
// infinite paginating.
if (isFirstCall) {
if (this.isFilling) {
if (this.isFilling && !this.isFillingDueToPropsUpdate) {
debuglog("isFilling: not entering while request is ongoing, marking for a subsequent request");
this.fillRequestWhileRunning = true;
this.pendingFillDueToPropsUpdate = isFromPropsUpdate;
return;
}
debuglog("isFilling: setting");
this.isFilling = true;
this.isFillingDueToPropsUpdate = isFromPropsUpdate;
}
const itemlist = this.itemlist.current;
@ -393,11 +405,14 @@ export default class ScrollPanel extends React.Component<IProps> {
if (isFirstCall) {
debuglog("isFilling: clearing");
this.isFilling = false;
this.isFillingDueToPropsUpdate = false;
}
if (this.fillRequestWhileRunning) {
const refillDueToPropsUpdate = this.pendingFillDueToPropsUpdate;
this.fillRequestWhileRunning = false;
this.checkFillState();
this.pendingFillDueToPropsUpdate = false;
this.checkFillState(0, refillDueToPropsUpdate);
}
};

View file

@ -16,7 +16,6 @@ limitations under the License.
import React, { ReactNode, useMemo, useState } from "react";
import { Room } from "matrix-js-sdk/src/models/room";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { EventType, RoomType } from "matrix-js-sdk/src/@types/event";
import { ISpaceSummaryRoom, ISpaceSummaryEvent } from "matrix-js-sdk/src/@types/spaces";
import classNames from "classnames";
@ -44,11 +43,13 @@ import { getChildOrder } from "../../stores/SpaceStore";
import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton";
import { linkifyElement } from "../../HtmlUtils";
import { getDisplayAliasForAliasSet } from "../../Rooms";
import { useDispatcher } from "../../hooks/useDispatcher";
import defaultDispatcher from "../../dispatcher/dispatcher";
import { Action } from "../../dispatcher/actions";
interface IHierarchyProps {
space: Room;
initialText?: string;
refreshToken?: any;
additionalButtons?: ReactNode;
showRoom(room: ISpaceSummaryRoom, viaServers?: string[], autoJoin?: boolean): void;
}
@ -315,18 +316,25 @@ export const HierarchyLevel = ({
</React.Fragment>;
};
// mutate argument refreshToken to force a reload
export const useSpaceSummary = (cli: MatrixClient, space: Room, refreshToken?: any): [
export const useSpaceSummary = (space: Room): [
null,
ISpaceSummaryRoom[],
Map<string, Map<string, ISpaceSummaryEvent>>?,
Map<string, Set<string>>?,
Map<string, Set<string>>?,
] | [Error] => {
// crude temporary refresh token approach until we have pagination and rework the data flow here
const [refreshToken, setRefreshToken] = useState(0);
useDispatcher(defaultDispatcher, (payload => {
if (payload.action === Action.UpdateSpaceHierarchy) {
setRefreshToken(t => t + 1);
}
}));
// TODO pagination
return useAsyncMemo(async () => {
try {
const data = await cli.getSpaceSummary(space.roomId);
const data = await space.client.getSpaceSummary(space.roomId);
const parentChildRelations = new EnhancedMap<string, Map<string, ISpaceSummaryEvent>>();
const childParentRelations = new EnhancedMap<string, Set<string>>();
@ -354,7 +362,6 @@ export const SpaceHierarchy: React.FC<IHierarchyProps> = ({
space,
initialText = "",
showRoom,
refreshToken,
additionalButtons,
children,
}) => {
@ -364,7 +371,7 @@ export const SpaceHierarchy: React.FC<IHierarchyProps> = ({
const [selected, setSelected] = useState(new Map<string, Set<string>>()); // Map<parentId, Set<childId>>
const [summaryError, rooms, parentChildMap, viaMap, childParentMap] = useSpaceSummary(cli, space, refreshToken);
const [summaryError, rooms, parentChildMap, viaMap, childParentMap] = useSpaceSummary(space);
const roomsMap = useMemo(() => {
if (!rooms) return null;

View file

@ -47,13 +47,23 @@ import { RightPanelPhases } from "../../stores/RightPanelStorePhases";
import { SetRightPanelPhasePayload } from "../../dispatcher/payloads/SetRightPanelPhasePayload";
import { useStateArray } from "../../hooks/useStateArray";
import SpacePublicShare from "../views/spaces/SpacePublicShare";
import { shouldShowSpaceSettings, showAddExistingRooms, showCreateNewRoom, showSpaceSettings } from "../../utils/space";
import {
shouldShowSpaceSettings,
showAddExistingRooms,
showCreateNewRoom,
showCreateNewSubspace,
showSpaceSettings,
} from "../../utils/space";
import { showRoom, SpaceHierarchy } from "./SpaceRoomDirectory";
import MemberAvatar from "../views/avatars/MemberAvatar";
import { useStateToggle } from "../../hooks/useStateToggle";
import SpaceStore from "../../stores/SpaceStore";
import FacePile from "../views/elements/FacePile";
import { AddExistingToSpace } from "../views/dialogs/AddExistingToSpaceDialog";
import {
AddExistingToSpace,
defaultDmsRenderer,
defaultRoomsRenderer,
defaultSpacesRenderer,
} from "../views/dialogs/AddExistingToSpaceDialog";
import { ChevronFace, ContextMenuButton, useContextMenu } from "./ContextMenu";
import IconizedContextMenu, {
IconizedContextMenuOption,
@ -62,10 +72,8 @@ import IconizedContextMenu, {
import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton";
import { BetaPill } from "../views/beta/BetaCard";
import { UserTab } from "../views/dialogs/UserSettingsDialog";
import Modal from "../../Modal";
import BetaFeedbackDialog from "../views/dialogs/BetaFeedbackDialog";
import SdkConfig from "../../SdkConfig";
import { EffectiveMembership, getEffectiveMembership } from "../../utils/membership";
import { SpaceFeedbackPrompt } from "../views/spaces/SpaceCreateMenu";
interface IProps {
space: Room;
@ -92,28 +100,6 @@ enum Phase {
PrivateExistingRooms,
}
// XXX: Temporary for the Spaces Beta only
export const SpaceFeedbackPrompt = ({ onClick }: { onClick?: () => void }) => {
if (!SdkConfig.get().bug_report_endpoint_url) return null;
return <div className="mx_SpaceFeedbackPrompt">
<hr />
<div>
<span className="mx_SpaceFeedbackPrompt_text">{ _t("Spaces are a beta feature.") }</span>
<AccessibleButton
kind="link"
onClick={() => {
if (onClick) onClick();
Modal.createTrackedDialog("Beta Feedback", "feature_spaces", BetaFeedbackDialog, {
featureId: "feature_spaces",
});
}}>
{ _t("Feedback") }
</AccessibleButton>
</div>
</div>;
};
const RoomMemberCount = ({ room, children }) => {
const members = useRoomMembers(room);
const count = members.length;
@ -206,11 +192,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">
@ -307,7 +293,7 @@ const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) =>
</div>;
};
const SpaceLandingAddButton = ({ space, onNewRoomAdded }) => {
const SpaceLandingAddButton = ({ space }) => {
const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu();
let contextMenu;
@ -331,24 +317,32 @@ const SpaceLandingAddButton = ({ space, onNewRoomAdded }) => {
closeMenu();
if (await showCreateNewRoom(space)) {
onNewRoomAdded();
defaultDispatcher.fire(Action.UpdateSpaceHierarchy);
}
}}
/>
<IconizedContextMenuOption
label={_t("Add existing room")}
iconClassName="mx_RoomList_iconHash"
onClick={async (e) => {
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
closeMenu();
const [added] = await showAddExistingRooms(space);
if (added) {
onNewRoomAdded();
}
showAddExistingRooms(space);
}}
/>
<IconizedContextMenuOption
label={_t("Add space")}
iconClassName="mx_RoomList_iconPlus"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
closeMenu();
showCreateNewSubspace(space);
}}
>
<BetaPill />
</IconizedContextMenuOption>
</IconizedContextMenuOptionList>
</IconizedContextMenu>;
}
@ -389,11 +383,9 @@ const SpaceLanding = ({ space }) => {
const canAddRooms = myMembership === "join" && space.currentState.maySendStateEvent(EventType.SpaceChild, userId);
const [refreshToken, forceUpdate] = useStateToggle(false);
let addRoomButton;
if (canAddRooms) {
addRoomButton = <SpaceLandingAddButton space={space} onNewRoomAdded={forceUpdate} />;
addRoomButton = <SpaceLandingAddButton space={space} />;
}
let settingsButton;
@ -416,6 +408,7 @@ const SpaceLanding = ({ space }) => {
};
return <div className="mx_SpaceRoomView_landing">
<SpaceFeedbackPrompt />
<RoomAvatar room={space} height={80} width={80} viewAvatarOnClick={true} />
<div className="mx_SpaceRoomView_landing_name">
<RoomName room={space}>
@ -440,15 +433,8 @@ const SpaceLanding = ({ space }) => {
</div>
) }
</RoomTopic>
<SpaceFeedbackPrompt />
<hr />
<SpaceHierarchy
space={space}
showRoom={showRoom}
refreshToken={refreshToken}
additionalButtons={addRoomButton}
/>
<SpaceHierarchy space={space} showRoom={showRoom} additionalButtons={addRoomButton} />
</div>;
};
@ -531,7 +517,6 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => {
value={buttonLabel}
/>
</div>
<SpaceFeedbackPrompt />
</div>;
};
@ -550,11 +535,12 @@ const SpaceAddExistingRooms = ({ space, onFinished }) => {
{ _t("Skip for now") }
</AccessibleButton>
}
filterPlaceholder={_t("Search for rooms or spaces")}
onFinished={onFinished}
roomsRenderer={defaultRoomsRenderer}
spacesRenderer={defaultSpacesRenderer}
dmsRenderer={defaultDmsRenderer}
/>
<div className="mx_SpaceRoomView_buttons" />
<SpaceFeedbackPrompt />
</div>;
};
@ -574,7 +560,6 @@ const SpaceSetupPublicShare = ({ justCreatedOpts, space, onFinished, createdRoom
{ createdRooms ? _t("Go to my first room") : _t("Go to my space") }
</AccessibleButton>
</div>
<SpaceFeedbackPrompt />
</div>;
};
@ -603,9 +588,8 @@ const SpaceSetupPrivateScope = ({ space, justCreatedOpts, onFinished }) => {
</AccessibleButton>
<div className="mx_SpaceRoomView_betaWarning">
<h3>{ _t("Teammates might not be able to view or join any private rooms you make.") }</h3>
<p>{ _t("We're working on this as part of the beta, but just want to let you know.") }</p>
<p>{ _t("We're working on this, but just want to let you know.") }</p>
</div>
<SpaceFeedbackPrompt />
</div>;
};
@ -728,7 +712,6 @@ const SpaceSetupPrivateInvite = ({ space, onFinished }) => {
value={buttonLabel}
/>
</div>
<SpaceFeedbackPrompt />
</div>;
};

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}>
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>
<div className="mx_Toast_body">{ React.createElement(component, toastProps) }</div>
</div>);
);
}
toast = (
<div className={toastClasses}>
{ titleElement }
<div className={bodyClasses}>{ content }</div>
</div>
);
containerClasses = classNames("mx_ToastContainer", {
"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

@ -103,8 +103,8 @@ export default class CaptchaForm extends React.Component<ICaptchaFormProps, ICap
}
private resetRecaptcha() {
if (this.captchaWidgetId !== null) {
global.grecaptcha.reset(this.captchaWidgetId);
if (this.captchaWidgetId) {
global?.grecaptcha?.reset(this.captchaWidgetId);
}
}

View file

@ -27,6 +27,8 @@ import BetaFeedbackDialog from "../dialogs/BetaFeedbackDialog";
import SdkConfig from "../../../SdkConfig";
import SettingsFlag from "../elements/SettingsFlag";
// XXX: Keep this around for re-use in future Betas
interface IProps {
title?: string;
featureId: string;

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,14 +86,18 @@ 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>;
};
export const IconizedContextMenuOption: React.FC<IOptionProps> = ({ label, iconClassName, ...props }) => {
export const IconizedContextMenuOption: React.FC<IOptionProps> = ({ label, iconClassName, children, ...props }) => {
return <MenuItem {...props} label={label}>
{ iconClassName && <span className={classNames("mx_IconizedContextMenu_icon", iconClassName)} /> }
<span className="mx_IconizedContextMenu_label">{ label }</span>
{ children }
</MenuItem>;
};

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

@ -0,0 +1,67 @@
/*
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, { useState } from "react";
import { Room } from "matrix-js-sdk/src/models/room";
import { _t } from '../../../languageHandler';
import BaseDialog from "./BaseDialog";
import AccessibleButton from "../elements/AccessibleButton";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { AddExistingToSpace, defaultSpacesRenderer, SubspaceSelector } from "./AddExistingToSpaceDialog";
interface IProps {
space: Room;
onCreateSubspaceClick(): void;
onFinished(added?: boolean): void;
}
const AddExistingSubspaceDialog: React.FC<IProps> = ({ space, onCreateSubspaceClick, onFinished }) => {
const [selectedSpace, setSelectedSpace] = useState(space);
return <BaseDialog
title={(
<SubspaceSelector
title={_t("Add existing space")}
space={space}
value={selectedSpace}
onChange={setSelectedSpace}
/>
)}
className="mx_AddExistingToSpaceDialog"
contentId="mx_AddExistingToSpace"
onFinished={onFinished}
fixedWidth={false}
>
<MatrixClientContext.Provider value={space.client}>
<AddExistingToSpace
space={space}
onFinished={onFinished}
footerPrompt={<>
<div>{ _t("Want to add a new space instead?") }</div>
<AccessibleButton onClick={onCreateSubspaceClick} kind="link">
{ _t("Create a new space") }
</AccessibleButton>
</>}
filterPlaceholder={_t("Search for spaces")}
spacesRenderer={defaultSpacesRenderer}
/>
</MatrixClientContext.Provider>
</BaseDialog>;
};
export default AddExistingSubspaceDialog;

View file

@ -18,9 +18,9 @@ import React, { ReactNode, useContext, useMemo, useState } from "react";
import classNames from "classnames";
import { Room } from "matrix-js-sdk/src/models/room";
import { sleep } from "matrix-js-sdk/src/utils";
import { EventType } from "matrix-js-sdk/src/@types/event";
import { _t } from '../../../languageHandler';
import { IDialogProps } from "./IDialogProps";
import BaseDialog from "./BaseDialog";
import Dropdown from "../elements/Dropdown";
import SearchBox from "../../structures/SearchBox";
@ -35,19 +35,20 @@ import StyledCheckbox from "../elements/StyledCheckbox";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { sortRooms } from "../../../stores/room-list/algorithms/tag-sorting/RecentAlgorithm";
import ProgressBar from "../elements/ProgressBar";
import { SpaceFeedbackPrompt } from "../../structures/SpaceRoomView";
import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar";
import QueryMatcher from "../../../autocomplete/QueryMatcher";
import TruncatedList from "../elements/TruncatedList";
import EntityTile from "../rooms/EntityTile";
import BaseAvatar from "../avatars/BaseAvatar";
interface IProps extends IDialogProps {
interface IProps {
space: Room;
onCreateRoomClick(space: Room): void;
onCreateRoomClick(): void;
onAddSubspaceClick(): void;
onFinished(added?: boolean): void;
}
const Entry = ({ room, checked, onChange }) => {
export const Entry = ({ room, checked, onChange }) => {
return <label className="mx_AddExistingToSpace_entry">
{ room?.isSpaceRoom()
? <RoomAvatar room={room} height={32} width={32} />
@ -65,14 +66,36 @@ const Entry = ({ room, checked, onChange }) => {
interface IAddExistingToSpaceProps {
space: Room;
footerPrompt?: ReactNode;
filterPlaceholder: string;
emptySelectionButton?: ReactNode;
onFinished(added: boolean): void;
roomsRenderer?(
rooms: Room[],
selectedToAdd: Set<Room>,
onChange: undefined | ((checked: boolean, room: Room) => void),
truncateAt: number,
overflowTile: (overflowCount: number, totalCount: number) => JSX.Element,
): ReactNode;
spacesRenderer?(
spaces: Room[],
selectedToAdd: Set<Room>,
onChange?: (checked: boolean, room: Room) => void,
): ReactNode;
dmsRenderer?(
dms: Room[],
selectedToAdd: Set<Room>,
onChange?: (checked: boolean, room: Room) => void,
): ReactNode;
}
export const AddExistingToSpace: React.FC<IAddExistingToSpaceProps> = ({
space,
footerPrompt,
emptySelectionButton,
filterPlaceholder,
roomsRenderer,
dmsRenderer,
spacesRenderer,
onFinished,
}) => {
const cli = useContext(MatrixClientContext);
@ -196,7 +219,7 @@ export const AddExistingToSpace: React.FC<IAddExistingToSpaceProps> = ({
</>;
}
const onChange = !busy && !error ? (checked, room) => {
const onChange = !busy && !error ? (checked: boolean, room: Room) => {
if (checked) {
selectedToAdd.add(room);
} else {
@ -206,7 +229,7 @@ export const AddExistingToSpace: React.FC<IAddExistingToSpaceProps> = ({
} : null;
const [truncateAt, setTruncateAt] = useState(20);
function overflowTile(overflowCount, totalCount) {
function overflowTile(overflowCount: number, totalCount: number): JSX.Element {
const text = _t("and %(count)s others...", { count: overflowCount });
return (
<EntityTile
@ -222,16 +245,49 @@ export const AddExistingToSpace: React.FC<IAddExistingToSpaceProps> = ({
);
}
let noResults = true;
if ((roomsRenderer && rooms.length > 0) ||
(dmsRenderer && dms.length > 0) ||
(!roomsRenderer && !dmsRenderer && spacesRenderer && spaces.length > 0) // only count spaces when alone
) {
noResults = false;
}
return <div className="mx_AddExistingToSpace">
<SearchBox
className="mx_textinput_icon mx_textinput_search"
placeholder={_t("Filter your rooms and spaces")}
placeholder={filterPlaceholder}
onSearch={setQuery}
autoComplete={true}
autoFocus={true}
/>
<AutoHideScrollbar className="mx_AddExistingToSpace_content">
{ rooms.length > 0 ? (
{ rooms.length > 0 && roomsRenderer ? (
roomsRenderer(rooms, selectedToAdd, onChange, truncateAt, overflowTile)
) : undefined }
{ spaces.length > 0 && spacesRenderer ? (
spacesRenderer(spaces, selectedToAdd, onChange)
) : null }
{ dms.length > 0 && dmsRenderer ? (
dmsRenderer(dms, selectedToAdd, onChange)
) : null }
{ noResults ? <span className="mx_AddExistingToSpace_noResults">
{ _t("No results") }
</span> : undefined }
</AutoHideScrollbar>
<div className="mx_AddExistingToSpace_footer">
{ footer }
</div>
</div>;
};
export const defaultRoomsRenderer: IAddExistingToSpaceProps["roomsRenderer"] = (
rooms, selectedToAdd, onChange, truncateAt, overflowTile,
) => (
<div className="mx_AddExistingToSpace_section">
<h3>{ _t("Rooms") }</h3>
<TruncatedList
@ -242,7 +298,7 @@ export const AddExistingToSpace: React.FC<IAddExistingToSpaceProps> = ({
key={room.roomId}
room={room}
checked={selectedToAdd.has(room)}
onChange={onChange ? (checked) => {
onChange={onChange ? (checked: boolean) => {
onChange(checked, room);
} : null}
/>,
@ -250,15 +306,10 @@ export const AddExistingToSpace: React.FC<IAddExistingToSpaceProps> = ({
getChildCount={() => rooms.length}
/>
</div>
) : undefined }
);
{ spaces.length > 0 ? (
<div className="mx_AddExistingToSpace_section mx_AddExistingToSpace_section_spaces">
<h3>{ _t("Spaces") }</h3>
<div className="mx_AddExistingToSpace_section_experimental">
<div>{ _t("Feeling experimental?") }</div>
<div>{ _t("You can add existing spaces to a space.") }</div>
</div>
export const defaultSpacesRenderer: IAddExistingToSpaceProps["spacesRenderer"] = (spaces, selectedToAdd, onChange) => (
<div className="mx_AddExistingToSpace_section">
{ spaces.map(space => {
return <Entry
key={space.roomId}
@ -270,9 +321,9 @@ export const AddExistingToSpace: React.FC<IAddExistingToSpaceProps> = ({
/>;
}) }
</div>
) : null }
);
{ dms.length > 0 ? (
export const defaultDmsRenderer: IAddExistingToSpaceProps["dmsRenderer"] = (dms, selectedToAdd, onChange) => (
<div className="mx_AddExistingToSpace_section">
<h3>{ _t("Direct Messages") }</h3>
{ dms.map(room => {
@ -280,69 +331,80 @@ export const AddExistingToSpace: React.FC<IAddExistingToSpaceProps> = ({
key={room.roomId}
room={room}
checked={selectedToAdd.has(room)}
onChange={onChange ? (checked) => {
onChange={onChange ? (checked: boolean) => {
onChange(checked, room);
} : null}
/>;
}) }
</div>
) : null }
);
{ spaces.length + rooms.length + dms.length < 1 ? <span className="mx_AddExistingToSpace_noResults">
{ _t("No results") }
</span> : undefined }
</AutoHideScrollbar>
interface ISubspaceSelectorProps {
title: string;
space: Room;
value: Room;
onChange(space: Room): void;
}
<div className="mx_AddExistingToSpace_footer">
{ footer }
</div>
</div>;
};
export const SubspaceSelector = ({ title, space, value, onChange }: ISubspaceSelectorProps) => {
const options = useMemo(() => {
return [space, ...SpaceStore.instance.getChildSpaces(space.roomId).filter(space => {
return space.currentState.maySendStateEvent(EventType.SpaceChild, space.client.credentials.userId);
})];
}, [space]);
const AddExistingToSpaceDialog: React.FC<IProps> = ({ space, onCreateRoomClick, onFinished }) => {
const [selectedSpace, setSelectedSpace] = useState(space);
const existingSubspaces = SpaceStore.instance.getChildSpaces(space.roomId);
let spaceOptionSection;
if (existingSubspaces.length > 0) {
const options = [space, ...existingSubspaces].map((space) => {
const classes = classNames("mx_AddExistingToSpaceDialog_dropdownOption", {
mx_AddExistingToSpaceDialog_dropdownOptionActive: space === selectedSpace,
let body;
if (options.length > 1) {
body = (
<Dropdown
id="mx_SpaceSelectDropdown"
className="mx_SpaceSelectDropdown"
onOptionChange={(key: string) => {
onChange(options.find(space => space.roomId === key) || space);
}}
value={value.roomId}
label={_t("Space selection")}
>
{ options.map((space) => {
const classes = classNames({
mx_SubspaceSelector_dropdownOptionActive: space === value,
});
return <div key={space.roomId} className={classes}>
<RoomAvatar room={space} width={24} height={24} />
{ space.name || getDisplayAliasForRoom(space) || space.roomId }
</div>;
});
spaceOptionSection = (
<Dropdown
id="mx_SpaceSelectDropdown"
onOptionChange={(key: string) => {
setSelectedSpace(existingSubspaces.find(space => space.roomId === key) || space);
}}
value={selectedSpace.roomId}
label={_t("Space selection")}
>
{ options }
}) }
</Dropdown>
);
} else {
spaceOptionSection = <div className="mx_AddExistingToSpaceDialog_onlySpace">
body = (
<div className="mx_SubspaceSelector_onlySpace">
{ space.name || getDisplayAliasForRoom(space) || space.roomId }
</div>;
</div>
);
}
const title = <React.Fragment>
<RoomAvatar room={selectedSpace} height={40} width={40} />
return <div className="mx_SubspaceSelector">
<RoomAvatar room={value} height={40} width={40} />
<div>
<h1>{ _t("Add existing rooms") }</h1>
{ spaceOptionSection }
<h1>{ title }</h1>
{ body }
</div>
</React.Fragment>;
</div>;
};
const AddExistingToSpaceDialog: React.FC<IProps> = ({ space, onCreateRoomClick, onAddSubspaceClick, onFinished }) => {
const [selectedSpace, setSelectedSpace] = useState(space);
return <BaseDialog
title={title}
title={(
<SubspaceSelector
title={_t("Add existing rooms")}
space={space}
value={selectedSpace}
onChange={setSelectedSpace}
/>
)}
className="mx_AddExistingToSpaceDialog"
contentId="mx_AddExistingToSpace"
onFinished={onFinished}
@ -354,14 +416,35 @@ const AddExistingToSpaceDialog: React.FC<IProps> = ({ space, onCreateRoomClick,
onFinished={onFinished}
footerPrompt={<>
<div>{ _t("Want to add a new room instead?") }</div>
<AccessibleButton onClick={() => onCreateRoomClick(space)} kind="link">
<AccessibleButton
kind="link"
onClick={() => {
onCreateRoomClick();
onFinished();
}}
>
{ _t("Create a new room") }
</AccessibleButton>
</>}
filterPlaceholder={_t("Search for rooms")}
roomsRenderer={defaultRoomsRenderer}
spacesRenderer={() => (
<div className="mx_AddExistingToSpace_section">
<h3>{ _t("Spaces") }</h3>
<AccessibleButton
kind="link"
onClick={() => {
onAddSubspaceClick();
onFinished();
}}
>
{ _t("Adding spaces has moved.") }
</AccessibleButton>
</div>
)}
dmsRenderer={defaultDmsRenderer}
/>
</MatrixClientContext.Provider>
<SpaceFeedbackPrompt onClick={() => onFinished(false)} />
</BaseDialog>;
};

View file

@ -14,22 +14,18 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useState } from "react";
import React from "react";
import QuestionDialog from './QuestionDialog';
import { _t } from '../../../languageHandler';
import Field from "../elements/Field";
import SdkConfig from "../../../SdkConfig";
import { IDialogProps } from "./IDialogProps";
import SettingsStore from "../../../settings/SettingsStore";
import { submitFeedback } from "../../../rageshake/submit-rageshake";
import StyledCheckbox from "../elements/StyledCheckbox";
import Modal from "../../../Modal";
import InfoDialog from "./InfoDialog";
import AccessibleButton from "../elements/AccessibleButton";
import defaultDispatcher from "../../../dispatcher/dispatcher";
import { Action } from "../../../dispatcher/actions";
import { UserTab } from "./UserSettingsDialog";
import GenericFeatureFeedbackDialog from "./GenericFeatureFeedbackDialog";
// XXX: Keep this around for re-use in future Betas
interface IProps extends IDialogProps {
featureId: string;
@ -38,39 +34,15 @@ interface IProps extends IDialogProps {
const BetaFeedbackDialog: React.FC<IProps> = ({ featureId, onFinished }) => {
const info = SettingsStore.getBetaInfo(featureId);
const [comment, setComment] = useState("");
const [canContact, setCanContact] = useState(false);
const sendFeedback = async (ok: boolean) => {
if (!ok) return onFinished(false);
const extraData = SettingsStore.getBetaInfo(featureId)?.extraSettings.reduce((o, k) => {
o[k] = SettingsStore.getValue(k);
return o;
}, {});
submitFeedback(SdkConfig.get().bug_report_endpoint_url, info.feedbackLabel, comment, canContact, extraData);
onFinished(true);
Modal.createTrackedDialog("Beta Dialog Sent", featureId, InfoDialog, {
title: _t("Beta feedback"),
description: _t("Thank you for your feedback, we really appreciate it."),
button: _t("Done"),
hasCloseButton: false,
fixedWidth: false,
});
};
return (<QuestionDialog
className="mx_BetaFeedbackDialog"
hasCancelButton={true}
return <GenericFeatureFeedbackDialog
title={_t("%(featureName)s beta feedback", { featureName: info.title })}
description={<React.Fragment>
<div className="mx_BetaFeedbackDialog_subheading">
{ _t(info.feedbackSubheading) }
&nbsp;
{ _t("Your platform and username will be noted to help us use your feedback as much as we can.") }
subheading={_t(info.feedbackSubheading)}
onFinished={onFinished}
rageshakeLabel={info.feedbackLabel}
rageshakeData={Object.fromEntries((SettingsStore.getBetaInfo(featureId)?.extraSettings || []).map(k => {
return SettingsStore.getValue(k);
}))}
>
<AccessibleButton
kind="link"
onClick={() => {
@ -83,32 +55,7 @@ const BetaFeedbackDialog: React.FC<IProps> = ({ featureId, onFinished }) => {
>
{ _t("To leave the beta, visit your settings.") }
</AccessibleButton>
</div>
<Field
id="feedbackComment"
label={_t("Feedback")}
type="text"
autoComplete="off"
value={comment}
element="textarea"
onChange={(ev) => {
setComment(ev.target.value);
}}
autoFocus={true}
/>
<StyledCheckbox
checked={canContact}
onClick={e => setCanContact((e.target as HTMLInputElement).checked)}
>
{ _t("You may contact me if you have any follow up questions") }
</StyledCheckbox>
</React.Fragment>}
button={_t("Send feedback")}
buttonDisabled={!comment}
onFinished={sendFeedback}
/>);
</GenericFeatureFeedbackDialog>;
};
export default BetaFeedbackDialog;

View file

@ -32,8 +32,8 @@ import RoomAliasField from "../elements/RoomAliasField";
import LabelledToggleSwitch from "../elements/LabelledToggleSwitch";
import DialogButtons from "../elements/DialogButtons";
import BaseDialog from "../dialogs/BaseDialog";
import Dropdown from "../elements/Dropdown";
import SpaceStore from "../../../stores/SpaceStore";
import JoinRuleDropdown from "../elements/JoinRuleDropdown";
interface IProps {
defaultPublic?: boolean;
@ -250,7 +250,7 @@ export default class CreateRoomDialog extends React.Component<IProps, IState> {
&nbsp;
{ _t("You can change this at any time from room settings.") }
</p>;
} else if (this.state.joinRule === JoinRule.Public) {
} else if (this.state.joinRule === JoinRule.Public && this.props.parentSpace) {
publicPrivateLabel = <p>
{ _t(
"Anyone will be able to find and join this room, not just members of <SpaceName/>.", {}, {
@ -260,6 +260,12 @@ export default class CreateRoomDialog extends React.Component<IProps, IState> {
&nbsp;
{ _t("You can change this at any time from room settings.") }
</p>;
} else if (this.state.joinRule === JoinRule.Public) {
publicPrivateLabel = <p>
{ _t("Anyone will be able to find and join this room.") }
&nbsp;
{ _t("You can change this at any time from room settings.") }
</p>;
} else if (this.state.joinRule === JoinRule.Invite) {
publicPrivateLabel = <p>
{ _t(
@ -316,21 +322,6 @@ export default class CreateRoomDialog extends React.Component<IProps, IState> {
title = this.state.joinRule === JoinRule.Public ? _t('Create a public room') : _t('Create a private room');
}
const options = [
<div key={JoinRule.Invite} className="mx_CreateRoomDialog_dropdown_invite">
{ _t("Private room (invite only)") }
</div>,
<div key={JoinRule.Public} className="mx_CreateRoomDialog_dropdown_public">
{ _t("Public room") }
</div>,
];
if (this.supportsRestricted) {
options.unshift(<div key={JoinRule.Restricted} className="mx_CreateRoomDialog_dropdown_restricted">
{ _t("Visible to space members") }
</div>);
}
return (
<BaseDialog className="mx_CreateRoomDialog" onFinished={this.props.onFinished} title={title}>
<form onSubmit={this.onOk} onKeyDown={this.onKeyDown}>
@ -350,16 +341,14 @@ export default class CreateRoomDialog extends React.Component<IProps, IState> {
className="mx_CreateRoomDialog_topic"
/>
<Dropdown
id="mx_CreateRoomDialog_typeDropdown"
className="mx_CreateRoomDialog_typeDropdown"
onOptionChange={this.onJoinRuleChange}
menuWidth={448}
value={this.state.joinRule}
<JoinRuleDropdown
label={_t("Room visibility")}
>
{ options }
</Dropdown>
labelInvite={_t("Private room (invite only)")}
labelPublic={_t("Public room")}
labelRestricted={this.supportsRestricted ? _t("Visible to space members") : undefined}
value={this.state.joinRule}
onChange={this.onJoinRuleChange}
/>
{ publicPrivateLabel }
{ e2eeSection }

View file

@ -0,0 +1,210 @@
/*
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, { 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 { _t } from '../../../languageHandler';
import BaseDialog from "./BaseDialog";
import AccessibleButton from "../elements/AccessibleButton";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
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 { SubspaceSelector } from "./AddExistingToSpaceDialog";
import JoinRuleDropdown from "../elements/JoinRuleDropdown";
interface IProps {
space: Room;
onAddExistingSpaceClick(): void;
onFinished(added?: boolean): void;
}
const CreateSubspaceDialog: React.FC<IProps> = ({ space, onAddExistingSpaceClick, onFinished }) => {
const [parentSpace, setParentSpace] = useState(space);
const [busy, setBusy] = useState<boolean>(false);
const [name, setName] = useState("");
const spaceNameField = useRef<Field>();
const [alias, setAlias] = useState("");
const spaceAliasField = useRef<RoomAliasField>();
const [avatar, setAvatar] = useState<File>(null);
const [topic, setTopic] = useState<string>("");
const supportsRestricted = !!SpaceStore.instance.restrictedJoinRuleSupport?.preferred;
const spaceJoinRule = space.getJoinRule();
let defaultJoinRule = JoinRule.Invite;
if (spaceJoinRule === JoinRule.Public) {
defaultJoinRule = JoinRule.Public;
} else if (supportsRestricted) {
defaultJoinRule = JoinRule.Restricted;
}
const [joinRule, setJoinRule] = useState<JoinRule>(defaultJoinRule);
const onCreateSubspaceClick = async (e) => {
e.preventDefault();
if (busy) return;
setBusy(true);
// require & validate the space name field
if (!await spaceNameField.current.validate({ allowEmpty: false })) {
spaceNameField.current.focus();
spaceNameField.current.validate({ allowEmpty: false, focused: true });
setBusy(false);
return;
}
// validate the space name alias field but do not require it
if (joinRule === JoinRule.Public && !await spaceAliasField.current.validate({ allowEmpty: true })) {
spaceAliasField.current.focus();
spaceAliasField.current.validate({ allowEmpty: true, focused: true });
setBusy(false);
return;
}
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,
});
onFinished(true);
} catch (e) {
console.error(e);
}
};
let joinRuleMicrocopy: JSX.Element;
if (joinRule === JoinRule.Restricted) {
joinRuleMicrocopy = <p>
{ _t(
"Anyone in <SpaceName/> will be able to find and join.", {}, {
SpaceName: () => <b>{ parentSpace.name }</b>,
},
) }
</p>;
} else if (joinRule === JoinRule.Public) {
joinRuleMicrocopy = <p>
{ _t(
"Anyone will be able to find and join this space, not just members of <SpaceName/>.", {}, {
SpaceName: () => <b>{ parentSpace.name }</b>,
},
) }
</p>;
} else if (joinRule === JoinRule.Invite) {
joinRuleMicrocopy = <p>
{ _t("Only people invited will be able to find and join this space.") }
</p>;
}
return <BaseDialog
title={(
<SubspaceSelector
title={_t("Create a space")}
space={space}
value={parentSpace}
onChange={setParentSpace}
/>
)}
className="mx_CreateSubspaceDialog"
contentId="mx_CreateSubspaceDialog"
onFinished={onFinished}
fixedWidth={false}
>
<MatrixClientContext.Provider value={space.client}>
<div className="mx_CreateSubspaceDialog_content">
<div className="mx_CreateSubspaceDialog_betaNotice">
<BetaPill />
{ _t("Add a space to a space you manage.") }
</div>
<SpaceCreateForm
busy={busy}
onSubmit={onCreateSubspaceClick}
setAvatar={setAvatar}
name={name}
setName={setName}
nameFieldRef={spaceNameField}
topic={topic}
setTopic={setTopic}
alias={alias}
setAlias={setAlias}
showAliasField={joinRule === JoinRule.Public}
aliasFieldRef={spaceAliasField}
>
<JoinRuleDropdown
label={_t("Space visibility")}
labelInvite={_t("Private space (invite only)")}
labelPublic={_t("Public space")}
labelRestricted={supportsRestricted ? _t("Visible to space members") : undefined}
width={478}
value={joinRule}
onChange={setJoinRule}
/>
{ joinRuleMicrocopy }
</SpaceCreateForm>
</div>
<div className="mx_CreateSubspaceDialog_footer">
<div className="mx_CreateSubspaceDialog_footer_prompt">
<div>{ _t("Want to add an existing space instead?") }</div>
<AccessibleButton
kind="link"
onClick={() => {
onAddExistingSpaceClick();
onFinished();
}}
>
{ _t("Add existing space") }
</AccessibleButton>
</div>
<AccessibleButton kind="primary_outline" disabled={busy} onClick={() => onFinished(false)}>
{ _t("Cancel") }
</AccessibleButton>
<AccessibleButton kind="primary" disabled={busy} onClick={onCreateSubspaceClick}>
{ busy ? _t("Adding...") : _t("Add") }
</AccessibleButton>
</div>
</MatrixClientContext.Provider>
</BaseDialog>;
};
export default CreateSubspaceDialog;

View file

@ -0,0 +1,101 @@
/*
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, { useState } from "react";
import QuestionDialog from './QuestionDialog';
import { _t } from '../../../languageHandler';
import Field from "../elements/Field";
import SdkConfig from "../../../SdkConfig";
import { IDialogProps } from "./IDialogProps";
import { submitFeedback } from "../../../rageshake/submit-rageshake";
import StyledCheckbox from "../elements/StyledCheckbox";
import Modal from "../../../Modal";
import InfoDialog from "./InfoDialog";
interface IProps extends IDialogProps {
title: string;
subheading: string;
rageshakeLabel: string;
rageshakeData?: Record<string, string>;
}
const GenericFeatureFeedbackDialog: React.FC<IProps> = ({
title,
subheading,
children,
rageshakeLabel,
rageshakeData = {},
onFinished,
}) => {
const [comment, setComment] = useState("");
const [canContact, setCanContact] = useState(false);
const sendFeedback = async (ok: boolean) => {
if (!ok) return onFinished(false);
submitFeedback(SdkConfig.get().bug_report_endpoint_url, rageshakeLabel, comment, canContact, rageshakeData);
onFinished(true);
Modal.createTrackedDialog("Feedback Sent", rageshakeLabel, InfoDialog, {
title,
description: _t("Thank you for your feedback, we really appreciate it."),
button: _t("Done"),
hasCloseButton: false,
fixedWidth: false,
});
};
return (<QuestionDialog
className="mx_GenericFeatureFeedbackDialog"
hasCancelButton={true}
title={title}
description={<React.Fragment>
<div className="mx_GenericFeatureFeedbackDialog_subheading">
{ subheading }
&nbsp;
{ _t("Your platform and username will be noted to help us use your feedback as much as we can.") }
{ children }
</div>
<Field
id="feedbackComment"
label={_t("Feedback")}
type="text"
autoComplete="off"
value={comment}
element="textarea"
onChange={(ev) => {
setComment(ev.target.value);
}}
autoFocus={true}
/>
<StyledCheckbox
checked={canContact}
onChange={e => setCanContact((e.target as HTMLInputElement).checked)}
>
{ _t("You may contact me if you have any follow up questions") }
</StyledCheckbox>
</React.Fragment>}
button={_t("Send feedback")}
buttonDisabled={!comment}
onFinished={sendFeedback}
/>);
};
export default GenericFeatureFeedbackDialog;

View file

@ -0,0 +1,197 @@
/*
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, useMemo, useState } from "react";
import { Room } from "matrix-js-sdk/src/models/room";
import { JoinRule } from "matrix-js-sdk/src/@types/partials";
import { _t } from '../../../languageHandler';
import DialogButtons from "../elements/DialogButtons";
import BaseDialog from "../dialogs/BaseDialog";
import SpaceStore from "../../../stores/SpaceStore";
import AutoHideScrollbar from "../../structures/AutoHideScrollbar";
import { Entry } from "./AddExistingToSpaceDialog";
import SearchBox from "../../structures/SearchBox";
import QueryMatcher from "../../../autocomplete/QueryMatcher";
import StyledRadioGroup from "../elements/StyledRadioGroup";
enum RoomsToLeave {
All = "All",
Specific = "Specific",
None = "None",
}
const SpaceChildPicker = ({ filterPlaceholder, rooms, selected, onChange }) => {
const [query, setQuery] = useState("");
const lcQuery = query.toLowerCase().trim();
const filteredRooms = useMemo(() => {
if (!lcQuery) {
return rooms;
}
const matcher = new QueryMatcher<Room>(rooms, {
keys: ["name"],
funcs: [r => [r.getCanonicalAlias(), ...r.getAltAliases()].filter(Boolean)],
shouldMatchWordsOnly: false,
});
return matcher.match(lcQuery);
}, [rooms, lcQuery]);
return <div className="mx_LeaveSpaceDialog_section">
<SearchBox
className="mx_textinput_icon mx_textinput_search"
placeholder={filterPlaceholder}
onSearch={setQuery}
autoComplete={true}
autoFocus={true}
/>
<AutoHideScrollbar className="mx_LeaveSpaceDialog_content">
{ filteredRooms.map(room => {
return <Entry
key={room.roomId}
room={room}
checked={selected.has(room)}
onChange={(checked) => {
onChange(checked, room);
}}
/>;
}) }
{ filteredRooms.length < 1 ? <span className="mx_LeaveSpaceDialog_noResults">
{ _t("No results") }
</span> : undefined }
</AutoHideScrollbar>
</div>;
};
const LeaveRoomsPicker = ({ space, spaceChildren, roomsToLeave, setRoomsToLeave }) => {
const selected = useMemo(() => new Set(roomsToLeave), [roomsToLeave]);
const [state, setState] = useState<string>(RoomsToLeave.All);
useEffect(() => {
if (state === RoomsToLeave.All) {
setRoomsToLeave(spaceChildren);
} else {
setRoomsToLeave([]);
}
}, [setRoomsToLeave, state, spaceChildren]);
return <div className="mx_LeaveSpaceDialog_section">
<StyledRadioGroup
name="roomsToLeave"
value={state}
onChange={setState}
definitions={[
{
value: RoomsToLeave.All,
label: _t("Leave all rooms and spaces"),
}, {
value: RoomsToLeave.None,
label: _t("Don't leave any"),
}, {
value: RoomsToLeave.Specific,
label: _t("Leave specific rooms and spaces"),
},
]}
/>
{ state === RoomsToLeave.Specific && (
<SpaceChildPicker
filterPlaceholder={_t("Search %(spaceName)s", { spaceName: space.name })}
rooms={spaceChildren}
selected={selected}
onChange={(selected: boolean, room: Room) => {
if (selected) {
setRoomsToLeave([room, ...roomsToLeave]);
} else {
setRoomsToLeave(roomsToLeave.filter(r => r !== room));
}
}}
/>
) }
</div>;
};
interface IProps {
space: Room;
onFinished(leave: boolean, rooms?: Room[]): void;
}
const isOnlyAdmin = (room: Room): boolean => {
return !room.getJoinedMembers().some(member => {
return member.userId !== room.client.credentials.userId && member.powerLevelNorm === 100;
});
};
const LeaveSpaceDialog: React.FC<IProps> = ({ space, onFinished }) => {
const spaceChildren = useMemo(() => SpaceStore.instance.getChildren(space.roomId), [space.roomId]);
const [roomsToLeave, setRoomsToLeave] = useState<Room[]>([]);
let rejoinWarning;
if (space.getJoinRule() !== JoinRule.Public) {
rejoinWarning = _t("You won't be able to rejoin unless you are re-invited.");
}
let onlyAdminWarning;
if (isOnlyAdmin(space)) {
onlyAdminWarning = _t("You're the only admin of this space. " +
"Leaving it will mean no one has control over it.");
} else {
const numChildrenOnlyAdminIn = roomsToLeave.filter(isOnlyAdmin).length;
if (numChildrenOnlyAdminIn > 0) {
onlyAdminWarning = _t("You're the only admin of some of the rooms or spaces you wish to leave. " +
"Leaving them will leave them without any admins.");
}
}
return <BaseDialog
title={_t("Leave %(spaceName)s", { spaceName: space.name })}
className="mx_LeaveSpaceDialog"
contentId="mx_LeaveSpaceDialog"
onFinished={() => onFinished(false)}
fixedWidth={false}
>
<div className="mx_Dialog_content" id="mx_LeaveSpaceDialog">
<p>
{ _t("Are you sure you want to leave <spaceName/>?", {}, {
spaceName: () => <b>{ space.name }</b>,
}) }
&nbsp;
{ rejoinWarning }
</p>
{ spaceChildren.length > 0 && <LeaveRoomsPicker
space={space}
spaceChildren={spaceChildren}
roomsToLeave={roomsToLeave}
setRoomsToLeave={setRoomsToLeave}
/> }
{ onlyAdminWarning && <div className="mx_LeaveSpaceDialog_section_warning">
{ onlyAdminWarning }
</div> }
</div>
<DialogButtons
primaryButton={_t("Leave space")}
onPrimaryButtonClick={() => onFinished(true, roomsToLeave)}
hasCancel={true}
onCancel={onFinished}
/>
</BaseDialog>;
};
export default LeaveSpaceDialog;

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

@ -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

@ -17,9 +17,12 @@ limitations under the License.
import React from 'react';
import { _t } from '../../../languageHandler';
import BaseDialog from "..//dialogs/BaseDialog";
import DialogButtons from "./DialogButtons";
import classNames from 'classnames';
import AccessibleButton from './AccessibleButton';
import { getDesktopCapturerSources } from "matrix-js-sdk/src/webrtc/call";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import TabbedView, { Tab, TabLocation } from '../../structures/TabbedView';
export interface DesktopCapturerSource {
id: string;
@ -28,62 +31,70 @@ export interface DesktopCapturerSource {
}
export enum Tabs {
Screens = "screens",
Windows = "windows",
Screens = "screen",
Windows = "window",
}
export interface DesktopCapturerSourceIProps {
export interface ExistingSourceIProps {
source: DesktopCapturerSource;
onSelect(source: DesktopCapturerSource): void;
selected: boolean;
}
export class ExistingSource extends React.Component<DesktopCapturerSourceIProps> {
constructor(props) {
export class ExistingSource extends React.Component<ExistingSourceIProps> {
constructor(props: ExistingSourceIProps) {
super(props);
}
onClick = (ev) => {
private onClick = (): void => {
this.props.onSelect(this.props.source);
};
render() {
const thumbnailClasses = classNames({
mx_desktopCapturerSourcePicker_source_thumbnail: true,
mx_desktopCapturerSourcePicker_source_thumbnail_selected: this.props.selected,
});
return (
<AccessibleButton
className="mx_desktopCapturerSourcePicker_stream_button"
className="mx_desktopCapturerSourcePicker_source"
title={this.props.source.name}
onClick={this.onClick}
>
<img
className="mx_desktopCapturerSourcePicker_stream_thumbnail"
className={thumbnailClasses}
src={this.props.source.thumbnailURL}
/>
<span className="mx_desktopCapturerSourcePicker_stream_name">{ this.props.source.name }</span>
<span className="mx_desktopCapturerSourcePicker_source_name">{ this.props.source.name }</span>
</AccessibleButton>
);
}
}
export interface DesktopCapturerSourcePickerIState {
export interface PickerIState {
selectedTab: Tabs;
sources: Array<DesktopCapturerSource>;
selectedSource: DesktopCapturerSource | null;
}
export interface DesktopCapturerSourcePickerIProps {
export interface PickerIProps {
onFinished(source: DesktopCapturerSource): void;
}
@replaceableComponent("views.elements.DesktopCapturerSourcePicker")
export default class DesktopCapturerSourcePicker extends React.Component<
DesktopCapturerSourcePickerIProps,
DesktopCapturerSourcePickerIState
PickerIProps,
PickerIState
> {
interval;
interval: number;
constructor(props) {
constructor(props: PickerIProps) {
super(props);
this.state = {
selectedTab: Tabs.Screens,
sources: [],
selectedSource: null,
};
}
@ -107,69 +118,61 @@ export default class DesktopCapturerSourcePicker extends React.Component<
clearInterval(this.interval);
}
onSelect = (source) => {
this.props.onFinished(source);
private onSelect = (source: DesktopCapturerSource): void => {
this.setState({ selectedSource: source });
};
onScreensClick = (ev) => {
this.setState({ selectedTab: Tabs.Screens });
private onShare = (): void => {
this.props.onFinished(this.state.selectedSource);
};
onWindowsClick = (ev) => {
this.setState({ selectedTab: Tabs.Windows });
private onTabChange = (): void => {
this.setState({ selectedSource: null });
};
onCloseClick = (ev) => {
private onCloseClick = (): void => {
this.props.onFinished(null);
};
render() {
let sources;
if (this.state.selectedTab === Tabs.Screens) {
sources = this.state.sources
.filter((source) => {
return source.id.startsWith("screen");
})
.map((source) => {
return <ExistingSource source={source} onSelect={this.onSelect} key={source.id} />;
});
} else {
sources = this.state.sources
.filter((source) => {
return source.id.startsWith("window");
})
.map((source) => {
return <ExistingSource source={source} onSelect={this.onSelect} key={source.id} />;
private getTab(type: "screen" | "window", label: string): Tab {
const sources = this.state.sources.filter((source) => source.id.startsWith(type)).map((source) => {
return (
<ExistingSource
selected={this.state.selectedSource?.id === source.id}
source={source}
onSelect={this.onSelect}
key={source.id}
/>
);
});
return new Tab(type, label, null, (
<div className="mx_desktopCapturerSourcePicker_tab">
{ sources }
</div>
));
}
const buttonStyle = "mx_desktopCapturerSourcePicker_tabLabel";
const screensButtonStyle = buttonStyle + ((this.state.selectedTab === Tabs.Screens) ? "_selected" : "");
const windowsButtonStyle = buttonStyle + ((this.state.selectedTab === Tabs.Windows) ? "_selected" : "");
render() {
const tabs = [
this.getTab("screen", _t("Share entire screen")),
this.getTab("window", _t("Application window")),
];
return (
<BaseDialog
className="mx_desktopCapturerSourcePicker"
onFinished={this.onCloseClick}
title={_t("Share your screen")}
title={_t("Share content")}
>
<div className="mx_desktopCapturerSourcePicker_tabLabels">
<AccessibleButton
className={screensButtonStyle}
onClick={this.onScreensClick}
>
{ _t("Screens") }
</AccessibleButton>
<AccessibleButton
className={windowsButtonStyle}
onClick={this.onWindowsClick}
>
{ _t("Windows") }
</AccessibleButton>
</div>
<div className="mx_desktopCapturerSourcePicker_panel">
{ sources }
</div>
<TabbedView tabs={tabs} tabLocation={TabLocation.TOP} onChange={this.onTabChange} />
<DialogButtons
primaryButton={_t("Share")}
hasCancel={true}
onCancel={this.onCloseClick}
onPrimaryButtonClick={this.onShare}
primaryDisabled={!this.state.selectedSource}
/>
</BaseDialog>
);
}

View file

@ -22,6 +22,7 @@ import MemberAvatar from '../avatars/MemberAvatar';
import { _t } from '../../../languageHandler';
import { useStateToggle } from "../../../hooks/useStateToggle";
import AccessibleButton from "./AccessibleButton";
import { Layout } from '../../../settings/Layout';
interface IProps {
// An array of member events to summarise
@ -33,11 +34,13 @@ 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
onToggle?(): void;
// The layout currently used
layout?: Layout;
}
const EventListSummary: React.FC<IProps> = ({
@ -48,6 +51,7 @@ const EventListSummary: React.FC<IProps> = ({
startExpanded,
summaryMembers = [],
summaryText,
layout,
}) => {
const [expanded, toggleExpanded] = useStateToggle(startExpanded);
@ -63,7 +67,7 @@ const EventListSummary: React.FC<IProps> = ({
// If we are only given few events then just pass them through
if (events.length < threshold) {
return (
<li className="mx_EventListSummary" data-scroll-tokens={eventIds} data-expanded={true}>
<li className="mx_EventListSummary" data-scroll-tokens={eventIds} data-expanded={true} data-layout={layout}>
{ children }
</li>
);
@ -92,7 +96,7 @@ const EventListSummary: React.FC<IProps> = ({
}
return (
<li className="mx_EventListSummary" data-scroll-tokens={eventIds} data-expanded={expanded + ""}>
<li className="mx_EventListSummary" data-scroll-tokens={eventIds} data-expanded={expanded + ""} data-layout={layout}>
<AccessibleButton className="mx_EventListSummary_toggle" onClick={toggleExpanded} aria-expanded={expanded}>
{ expanded ? _t('collapse') : _t('expand') }
</AccessibleButton>
@ -103,6 +107,7 @@ const EventListSummary: React.FC<IProps> = ({
EventListSummary.defaultProps = {
startExpanded: false,
layout: Layout.Group,
};
export default EventListSummary;

View file

@ -0,0 +1,68 @@
/*
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 from 'react';
import { JoinRule } from 'matrix-js-sdk/src/@types/partials';
import Dropdown from "./Dropdown";
interface IProps {
value: JoinRule;
label: string;
width?: number;
labelInvite: string;
labelPublic: string;
labelRestricted?: string; // if omitted then this option will be hidden, e.g if unsupported
onChange(value: JoinRule): void;
}
const JoinRuleDropdown = ({
label,
labelInvite,
labelPublic,
labelRestricted,
value,
width = 448,
onChange,
}: IProps) => {
const options = [
<div key={JoinRule.Invite} className="mx_JoinRuleDropdown_invite">
{ labelInvite }
</div>,
<div key={JoinRule.Public} className="mx_JoinRuleDropdown_public">
{ labelPublic }
</div>,
];
if (labelRestricted) {
options.unshift(<div key={JoinRule.Restricted} className="mx_JoinRuleDropdown_restricted">
{ labelRestricted }
</div>);
}
return <Dropdown
id="mx_JoinRuleDropdown"
className="mx_JoinRuleDropdown"
onOptionChange={onChange}
menuWidth={width}
value={value}
label={label}
>
{ options }
</Dropdown>;
};
export default JoinRuleDropdown;

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