mirror of
https://github.com/element-hq/element-web
synced 2024-11-23 09:46:09 +03:00
Merge remote-tracking branch 'upstream/develop' into feature/narrow-voip-tiles/18398
This commit is contained in:
commit
39bb253d1f
142 changed files with 5796 additions and 1956 deletions
31
.github/workflows/layered-build.yaml
vendored
Normal file
31
.github/workflows/layered-build.yaml
vendored
Normal file
|
@ -0,0 +1,31 @@
|
|||
name: Layered Preview Build
|
||||
on:
|
||||
pull_request:
|
||||
branches: [develop]
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Build
|
||||
run: scripts/ci/layered.sh && cd element-web && cp element.io/develop/config.json config.json && CI_PACKAGE=true yarn build
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: previewbuild
|
||||
path: element-web/webapp
|
||||
# We'll only use this in a triggered job, then we're done with it
|
||||
retention-days: 1
|
||||
- uses: actions/github-script@v3.1.0
|
||||
with:
|
||||
script: |
|
||||
var fs = require('fs');
|
||||
fs.writeFileSync('${{github.workspace}}/pr.json', JSON.stringify(context.payload.pull_request));
|
||||
- name: Upload PR Info
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: pr.json
|
||||
path: pr.json
|
||||
# We'll only use this in a triggered job, then we're done with it
|
||||
retention-days: 1
|
||||
|
80
.github/workflows/netflify.yaml
vendored
Normal file
80
.github/workflows/netflify.yaml
vendored
Normal file
|
@ -0,0 +1,80 @@
|
|||
name: Upload Preview Build to Netlify
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Layered Preview Build"]
|
||||
types:
|
||||
- completed
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
if: >
|
||||
${{ github.event.workflow_run.conclusion == 'success' }}
|
||||
steps:
|
||||
# There's a 'download artifact' action but it hasn't been updated for the
|
||||
# workflow_run action (https://github.com/actions/download-artifact/issues/60)
|
||||
# so instead we get this mess:
|
||||
- name: 'Download artifact'
|
||||
uses: actions/github-script@v3.1.0
|
||||
with:
|
||||
script: |
|
||||
var artifacts = await github.actions.listWorkflowRunArtifacts({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
run_id: ${{github.event.workflow_run.id }},
|
||||
});
|
||||
var matchArtifact = artifacts.data.artifacts.filter((artifact) => {
|
||||
return artifact.name == "previewbuild"
|
||||
})[0];
|
||||
var download = await github.actions.downloadArtifact({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
artifact_id: matchArtifact.id,
|
||||
archive_format: 'zip',
|
||||
});
|
||||
var fs = require('fs');
|
||||
fs.writeFileSync('${{github.workspace}}/previewbuild.zip', Buffer.from(download.data));
|
||||
|
||||
var prInfoArtifact = artifacts.data.artifacts.filter((artifact) => {
|
||||
return artifact.name == "pr.json"
|
||||
})[0];
|
||||
var download = await github.actions.downloadArtifact({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
artifact_id: prInfoArtifact.id,
|
||||
archive_format: 'zip',
|
||||
});
|
||||
var fs = require('fs');
|
||||
fs.writeFileSync('${{github.workspace}}/pr.json.zip', Buffer.from(download.data));
|
||||
- name: Extract Artifacts
|
||||
run: unzip -d webapp previewbuild.zip && rm previewbuild.zip && unzip pr.json.zip && rm pr.json.zip
|
||||
- name: 'Read PR Info'
|
||||
id: readctx
|
||||
uses: actions/github-script@v3.1.0
|
||||
with:
|
||||
script: |
|
||||
var fs = require('fs');
|
||||
var pr = JSON.parse(fs.readFileSync('${{github.workspace}}/pr.json'));
|
||||
console.log(`::set-output name=prnumber::${pr.number}`);
|
||||
- name: Deploy to Netlify
|
||||
id: netlify
|
||||
uses: nwtgck/actions-netlify@v1.2
|
||||
with:
|
||||
publish-dir: webapp
|
||||
deploy-message: "Deploy from GitHub Actions"
|
||||
# These don't work because we're in workflow_run
|
||||
enable-pull-request-comment: false
|
||||
enable-commit-comment: false
|
||||
env:
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
|
||||
timeout-minutes: 1
|
||||
- name: Edit PR Description
|
||||
uses: velas/pr-description@v1.0.1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
pull-request-number: ${{ steps.readctx.outputs.prnumber }}
|
||||
description-message: |
|
||||
Preview: ${{ steps.netlify.outputs.deploy-url }}
|
||||
⚠️ Do you trust the author of this PR? Maybe this build will steal your keys or give you malware. Exercise caution. Use test accounts.
|
||||
|
12
.github/workflows/preview_changelog.yaml
vendored
Normal file
12
.github/workflows/preview_changelog.yaml
vendored
Normal file
|
@ -0,0 +1,12 @@
|
|||
name: Preview Changelog
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [ opened, edited, labeled ]
|
||||
jobs:
|
||||
changelog:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Preview Changelog
|
||||
uses: matrix-org/allchange@main
|
||||
with:
|
||||
ghToken: ${{ secrets.GITHUB_TOKEN }}
|
|
@ -1,4 +1,4 @@
|
|||
Contributing code to The React SDK
|
||||
==================================
|
||||
|
||||
matrix-react-sdk follows the same pattern as https://github.com/matrix-org/matrix-js-sdk/blob/master/CONTRIBUTING.rst
|
||||
matrix-react-sdk follows the same pattern as https://github.com/matrix-org/matrix-js-sdk/blob/master/CONTRIBUTING.md
|
||||
|
|
|
@ -193,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).+$"
|
||||
|
|
55
res/css/_animations.scss
Normal file
55
res/css/_animations.scss
Normal file
|
@ -0,0 +1,55 @@
|
|||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* React Transition Group animations are prefixed with 'mx_rtg--' so that we
|
||||
* know they should not be used anywhere outside of React Transition Groups.
|
||||
*/
|
||||
|
||||
.mx_rtg--fade-enter {
|
||||
opacity: 0;
|
||||
}
|
||||
.mx_rtg--fade-enter-active {
|
||||
opacity: 1;
|
||||
transition: opacity 300ms ease;
|
||||
}
|
||||
.mx_rtg--fade-exit {
|
||||
opacity: 1;
|
||||
}
|
||||
.mx_rtg--fade-exit-active {
|
||||
opacity: 0;
|
||||
transition: opacity 300ms ease;
|
||||
}
|
||||
|
||||
|
||||
@keyframes mx--anim-pulse {
|
||||
0% { opacity: 1; }
|
||||
50% { opacity: 0.7; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
|
||||
|
||||
@media (prefers-reduced-motion) {
|
||||
@keyframes mx--anim-pulse {
|
||||
// Override all keyframes in reduced-motion
|
||||
}
|
||||
.mx_rtg--fade-enter-active {
|
||||
transition: none;
|
||||
}
|
||||
.mx_rtg--fade-exit-active {
|
||||
transition: none;
|
||||
}
|
||||
}
|
|
@ -18,6 +18,7 @@ limitations under the License.
|
|||
|
||||
@import "./_font-sizes.scss";
|
||||
@import "./_font-weights.scss";
|
||||
@import "./_animations.scss";
|
||||
|
||||
$hover-transition: 0.08s cubic-bezier(.46, .03, .52, .96); // quadratic
|
||||
|
||||
|
|
|
@ -75,6 +75,7 @@
|
|||
@import "./views/dialogs/_CreateCommunityPrototypeDialog.scss";
|
||||
@import "./views/dialogs/_CreateGroupDialog.scss";
|
||||
@import "./views/dialogs/_CreateRoomDialog.scss";
|
||||
@import "./views/dialogs/_CreateSpaceFromCommunityDialog.scss";
|
||||
@import "./views/dialogs/_CreateSubspaceDialog.scss";
|
||||
@import "./views/dialogs/_DeactivateAccountDialog.scss";
|
||||
@import "./views/dialogs/_DevtoolsDialog.scss";
|
||||
|
@ -240,6 +241,7 @@
|
|||
@import "./views/settings/_E2eAdvancedPanel.scss";
|
||||
@import "./views/settings/_EmailAddresses.scss";
|
||||
@import "./views/settings/_IntegrationManager.scss";
|
||||
@import "./views/settings/_LayoutSwitcher.scss";
|
||||
@import "./views/settings/_Notifications.scss";
|
||||
@import "./views/settings/_PhoneNumbers.scss";
|
||||
@import "./views/settings/_ProfileSettings.scss";
|
||||
|
@ -269,10 +271,12 @@
|
|||
@import "./views/toasts/_IncomingCallToast.scss";
|
||||
@import "./views/toasts/_NonUrgentEchoFailureToast.scss";
|
||||
@import "./views/verification/_VerificationShowSas.scss";
|
||||
@import "./views/voip/CallView/_CallViewButtons.scss";
|
||||
@import "./views/voip/_CallContainer.scss";
|
||||
@import "./views/voip/_CallPreview.scss";
|
||||
@import "./views/voip/_CallView.scss";
|
||||
@import "./views/voip/_CallViewForRoom.scss";
|
||||
@import "./views/voip/_CallViewHeader.scss";
|
||||
@import "./views/voip/_CallViewSidebar.scss";
|
||||
@import "./views/voip/_DialPad.scss";
|
||||
@import "./views/voip/_DialPadContextMenu.scss";
|
||||
|
|
|
@ -368,6 +368,65 @@ limitations under the License.
|
|||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
.mx_GroupView_spaceUpgradePrompt {
|
||||
padding: 16px 50px;
|
||||
background-color: $header-panel-bg-color;
|
||||
border-radius: 8px;
|
||||
max-width: 632px;
|
||||
font-size: $font-15px;
|
||||
line-height: $font-24px;
|
||||
margin-top: 24px;
|
||||
position: relative;
|
||||
|
||||
> h2 {
|
||||
font-size: inherit;
|
||||
font-weight: $font-semi-bold;
|
||||
}
|
||||
|
||||
> p, h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
height: $font-24px;
|
||||
width: 20px;
|
||||
left: 18px;
|
||||
mask-repeat: no-repeat;
|
||||
mask-position: center;
|
||||
mask-size: contain;
|
||||
mask-image: url('$(res)/img/element-icons/room/room-summary.svg');
|
||||
background-color: $secondary-fg-color;
|
||||
}
|
||||
|
||||
.mx_AccessibleButton_kind_link {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.mx_GroupView_spaceUpgradePrompt_close {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 8px;
|
||||
background-color: $input-darker-bg-color;
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
width: inherit;
|
||||
height: inherit;
|
||||
mask-repeat: no-repeat;
|
||||
mask-position: center;
|
||||
mask-size: 8px;
|
||||
mask-image: url('$(res)/img/image-view/close.svg');
|
||||
background-color: $secondary-fg-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mx_GroupView .mx_MemberInfo .mx_AutoHideScrollbar > :not(.mx_MemberInfo_avatar) {
|
||||
padding-left: 16px;
|
||||
padding-right: 16px;
|
||||
|
|
|
@ -269,7 +269,7 @@ limitations under the License.
|
|||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
&:hover, &:focus-within {
|
||||
background-color: $groupFilterPanel-bg-color;
|
||||
|
||||
.mx_AccessibleButton {
|
||||
|
@ -278,6 +278,10 @@ limitations under the License.
|
|||
}
|
||||
}
|
||||
|
||||
li.mx_SpaceRoomDirectory_roomTileWrapper {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.mx_SpaceRoomDirectory_roomTile,
|
||||
.mx_SpaceRoomDirectory_subspace_children {
|
||||
&::before {
|
||||
|
|
|
@ -180,6 +180,18 @@ $SpaceRoomViewInnerWidth: 428px;
|
|||
}
|
||||
}
|
||||
|
||||
.mx_SpaceRoomView_preview_migratedCommunity {
|
||||
margin-bottom: 16px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid $input-border-color;
|
||||
width: max-content;
|
||||
|
||||
.mx_BaseAvatar {
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.mx_SpaceRoomView_preview_inviter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
@ -342,7 +354,7 @@ $SpaceRoomViewInnerWidth: 428px;
|
|||
|
||||
.mx_SpaceFeedbackPrompt {
|
||||
padding: 7px; // 8px - 1px border
|
||||
border: 1px solid $menu-border-color;
|
||||
border: 1px solid rgba($primary-fg-color, .1);
|
||||
border-radius: 8px;
|
||||
width: max-content;
|
||||
margin: 0 0 -40px auto; // collapse its own height to not push other components down
|
||||
|
|
|
@ -28,7 +28,7 @@ limitations under the License.
|
|||
margin: 0 4px;
|
||||
grid-row: 2 / 4;
|
||||
grid-column: 1;
|
||||
background-color: $toast-bg-color;
|
||||
background-color: $system;
|
||||
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: $toast-bg-color;
|
||||
background-color: $system;
|
||||
box-shadow: 0px 4px 20px rgba(0, 0, 0, 0.5);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -51,6 +51,10 @@ limitations under the License.
|
|||
mask-image: url('$(res)/img/element-icons/hide.svg');
|
||||
}
|
||||
|
||||
.mx_TagTileContextMenu_createSpace::before {
|
||||
mask-image: url('$(res)/img/element-icons/message/fwd.svg');
|
||||
}
|
||||
|
||||
.mx_TagTileContextMenu_separator {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
|
|
187
res/css/views/dialogs/_CreateSpaceFromCommunityDialog.scss
Normal file
187
res/css/views/dialogs/_CreateSpaceFromCommunityDialog.scss
Normal file
|
@ -0,0 +1,187 @@
|
|||
/*
|
||||
Copyright 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
.mx_CreateSpaceFromCommunityDialog_wrapper {
|
||||
.mx_Dialog {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.mx_CreateSpaceFromCommunityDialog {
|
||||
width: 480px;
|
||||
color: $primary-fg-color;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-wrap: nowrap;
|
||||
min-height: 0;
|
||||
|
||||
.mx_CreateSpaceFromCommunityDialog_content {
|
||||
> p {
|
||||
font-size: $font-15px;
|
||||
line-height: $font-24px;
|
||||
|
||||
&:first-of-type {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
&.mx_CreateSpaceFromCommunityDialog_flairNotice {
|
||||
font-size: $font-12px;
|
||||
line-height: $font-15px;
|
||||
}
|
||||
}
|
||||
|
||||
.mx_SpaceBasicSettings {
|
||||
> p {
|
||||
font-size: $font-12px;
|
||||
line-height: $font-15px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.mx_Field_textarea {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.mx_JoinRuleDropdown .mx_Dropdown_menu {
|
||||
width: auto !important; // override fixed width
|
||||
}
|
||||
|
||||
.mx_CreateSpaceFromCommunityDialog_nonPublicSpacer {
|
||||
height: 63px; // balance the height of the missing room alias field to prevent modal bouncing
|
||||
}
|
||||
}
|
||||
|
||||
.mx_CreateSpaceFromCommunityDialog_footer {
|
||||
display: flex;
|
||||
margin-top: 20px;
|
||||
|
||||
> span {
|
||||
flex-grow: 1;
|
||||
font-size: $font-12px;
|
||||
line-height: $font-15px;
|
||||
color: $secondary-fg-color;
|
||||
|
||||
.mx_ProgressBar {
|
||||
height: 8px;
|
||||
width: 100%;
|
||||
|
||||
@mixin ProgressBarBorderRadius 8px;
|
||||
}
|
||||
|
||||
.mx_CreateSpaceFromCommunityDialog_progressText {
|
||||
margin-top: 8px;
|
||||
font-size: $font-15px;
|
||||
line-height: $font-24px;
|
||||
color: $primary-fg-color;
|
||||
}
|
||||
|
||||
> * {
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
|
||||
.mx_CreateSpaceFromCommunityDialog_error {
|
||||
padding-left: 12px;
|
||||
|
||||
> img {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.mx_CreateSpaceFromCommunityDialog_errorHeading {
|
||||
font-weight: $font-semi-bold;
|
||||
font-size: $font-15px;
|
||||
line-height: $font-18px;
|
||||
color: $notice-primary-color;
|
||||
}
|
||||
|
||||
.mx_CreateSpaceFromCommunityDialog_errorCaption {
|
||||
margin-top: 4px;
|
||||
font-size: $font-12px;
|
||||
line-height: $font-15px;
|
||||
color: $primary-fg-color;
|
||||
}
|
||||
}
|
||||
|
||||
.mx_AccessibleButton {
|
||||
display: inline-block;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.mx_AccessibleButton_kind_primary {
|
||||
padding: 8px 36px;
|
||||
margin-left: 24px;
|
||||
}
|
||||
|
||||
.mx_AccessibleButton_kind_primary_outline {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.mx_CreateSpaceFromCommunityDialog_retryButton {
|
||||
margin-left: 12px;
|
||||
padding-left: 24px;
|
||||
position: relative;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
background-color: $primary-fg-color;
|
||||
mask-repeat: no-repeat;
|
||||
mask-position: center;
|
||||
mask-size: contain;
|
||||
mask-image: url('$(res)/img/element-icons/retry.svg');
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.mx_AccessibleButton_kind_link {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mx_CreateSpaceFromCommunityDialog_SuccessInfoDialog {
|
||||
.mx_InfoDialog {
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.mx_AccessibleButton_kind_link {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.mx_CreateSpaceFromCommunityDialog_SuccessInfoDialog_checkmark {
|
||||
position: relative;
|
||||
border-radius: 50%;
|
||||
border: 3px solid $accent-color;
|
||||
width: 68px;
|
||||
height: 68px;
|
||||
margin: 12px auto 32px;
|
||||
|
||||
&::before {
|
||||
width: inherit;
|
||||
height: inherit;
|
||||
content: '';
|
||||
position: absolute;
|
||||
background-color: $accent-color;
|
||||
mask-repeat: no-repeat;
|
||||
mask-position: center;
|
||||
mask-image: url('$(res)/img/element-icons/roomlist/checkmark.svg');
|
||||
mask-size: 48px;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -24,35 +24,33 @@ limitations under the License.
|
|||
align-items: flex-start;
|
||||
height: 500px;
|
||||
overflow: overlay;
|
||||
}
|
||||
|
||||
.mx_desktopCapturerSourcePicker_source {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 8px;
|
||||
}
|
||||
.mx_desktopCapturerSourcePicker_source {
|
||||
width: 50%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.mx_desktopCapturerSourcePicker_source_thumbnail {
|
||||
margin: 4px;
|
||||
padding: 4px;
|
||||
width: 312px;
|
||||
border-width: 2px;
|
||||
border-radius: 8px;
|
||||
border-style: solid;
|
||||
border-color: transparent;
|
||||
.mx_desktopCapturerSourcePicker_source_thumbnail {
|
||||
margin: 4px;
|
||||
padding: 4px;
|
||||
border-width: 2px;
|
||||
border-radius: 8px;
|
||||
border-style: solid;
|
||||
border-color: transparent;
|
||||
|
||||
&.mx_desktopCapturerSourcePicker_source_thumbnail_selected,
|
||||
&:hover,
|
||||
&:focus {
|
||||
border-color: $accent-color;
|
||||
&.mx_desktopCapturerSourcePicker_source_thumbnail_selected,
|
||||
&:hover,
|
||||
&:focus {
|
||||
border-color: $accent-color;
|
||||
}
|
||||
}
|
||||
|
||||
.mx_desktopCapturerSourcePicker_source_name {
|
||||
margin: 0 4px;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mx_desktopCapturerSourcePicker_source_name {
|
||||
margin: 0 4px;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
width: 312px;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -38,6 +38,7 @@ limitations under the License.
|
|||
.mx_Field input,
|
||||
.mx_Field select,
|
||||
.mx_Field textarea {
|
||||
font-family: inherit;
|
||||
font-weight: normal;
|
||||
font-size: $font-14px;
|
||||
border: none;
|
||||
|
|
|
@ -16,6 +16,12 @@ limitations under the License.
|
|||
|
||||
$timelineImageBorderRadius: 4px;
|
||||
|
||||
.mx_MImageBody_thumbnail--blurhash {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.mx_MImageBody_thumbnail {
|
||||
object-fit: contain;
|
||||
border-radius: $timelineImageBorderRadius;
|
||||
|
@ -23,8 +29,11 @@ $timelineImageBorderRadius: 4px;
|
|||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
|
||||
> div > canvas {
|
||||
.mx_Blurhash > canvas {
|
||||
animation: mx--anim-pulse 1.75s infinite cubic-bezier(.4, 0, .6, 1);
|
||||
border-radius: $timelineImageBorderRadius;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -65,6 +65,14 @@ limitations under the License.
|
|||
font-size: $font-10-4px;
|
||||
}
|
||||
}
|
||||
|
||||
span.mx_UserPill {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
span.mx_RoomPill {
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
&.mx_BasicMessageComposer_input_disabled {
|
||||
|
|
|
@ -271,7 +271,7 @@ limitations under the License.
|
|||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: start;
|
||||
justify-content: flex-start;
|
||||
padding: 5px 0;
|
||||
|
||||
.mx_EventTile_avatar {
|
||||
|
|
|
@ -489,6 +489,10 @@ $hover-select-border: 4px;
|
|||
// https://github.com/vector-im/vector-web/issues/754
|
||||
overflow-x: overlay;
|
||||
overflow-y: visible;
|
||||
|
||||
&::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
91
res/css/views/settings/_LayoutSwitcher.scss
Normal file
91
res/css/views/settings/_LayoutSwitcher.scss
Normal file
|
@ -0,0 +1,91 @@
|
|||
/*
|
||||
Copyright 2020 - 2021 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2021 Šimon Brandner <simon.bra.ag@gmail.com>
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
.mx_LayoutSwitcher {
|
||||
.mx_LayoutSwitcher_RadioButtons {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 24px;
|
||||
|
||||
color: $primary-fg-color;
|
||||
|
||||
> .mx_LayoutSwitcher_RadioButton {
|
||||
flex-grow: 0;
|
||||
flex-shrink: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
width: 300px;
|
||||
|
||||
border: 1px solid $appearance-tab-border-color;
|
||||
border-radius: 10px;
|
||||
|
||||
.mx_EventTile_msgOption,
|
||||
.mx_MessageActionBar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mx_LayoutSwitcher_RadioButton_preview {
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.mx_RadioButton {
|
||||
flex-grow: 0;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.mx_EventTile_content {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
&.mx_LayoutSwitcher_RadioButton_selected {
|
||||
border-color: $accent-color;
|
||||
}
|
||||
}
|
||||
|
||||
.mx_RadioButton {
|
||||
border-top: 1px solid $appearance-tab-border-color;
|
||||
|
||||
> input + div {
|
||||
border-color: rgba($muted-fg-color, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.mx_RadioButton_checked {
|
||||
background-color: rgba($accent-color, 0.08);
|
||||
}
|
||||
|
||||
.mx_EventTile {
|
||||
margin: 0;
|
||||
&[data-layout=bubble] {
|
||||
margin-right: 40px;
|
||||
}
|
||||
&[data-layout=irc] {
|
||||
> a {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
.mx_EventTile_line {
|
||||
max-width: 90%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -50,15 +50,21 @@ limitations under the License.
|
|||
}
|
||||
|
||||
.mx_SettingsTab_section {
|
||||
$right-gutter: 80px;
|
||||
|
||||
margin-bottom: 24px;
|
||||
|
||||
.mx_SettingsFlag {
|
||||
margin-right: 80px;
|
||||
margin-right: $right-gutter;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
> p {
|
||||
margin-right: $right-gutter;
|
||||
}
|
||||
|
||||
&.mx_SettingsTab_subsectionText .mx_SettingsFlag {
|
||||
margin-right: 0px !important;
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -28,28 +28,32 @@ limitations under the License.
|
|||
user-select: all;
|
||||
}
|
||||
|
||||
.mx_HelpUserSettingsTab_accessToken {
|
||||
.mx_HelpUserSettingsTab_copy {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
border-radius: 5px;
|
||||
border: solid 1px $light-fg-color;
|
||||
margin-bottom: 10px;
|
||||
margin-top: 10px;
|
||||
padding: 10px;
|
||||
}
|
||||
width: max-content;
|
||||
|
||||
.mx_HelpUserSettingsTab_accessToken_copy {
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
margin-left: 20px;
|
||||
display: inherit;
|
||||
}
|
||||
.mx_HelpUserSettingsTab_copyButton {
|
||||
flex-shrink: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
cursor: pointer;
|
||||
margin-left: 20px;
|
||||
display: block;
|
||||
|
||||
.mx_HelpUserSettingsTab_accessToken_copy > div {
|
||||
mask-image: url($copy-button-url);
|
||||
background-color: $message-action-bar-fg-color;
|
||||
margin-left: 5px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background-repeat: no-repeat;
|
||||
&::before {
|
||||
content: "";
|
||||
|
||||
mask-image: url($copy-button-url);
|
||||
background-color: $message-action-bar-fg-color;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: block;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -22,4 +22,25 @@ limitations under the License.
|
|||
.mx_SettingsTab_section {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.mx_PreferencesUserSettingsTab_CommunityMigrator {
|
||||
margin-right: 200px;
|
||||
|
||||
> div {
|
||||
font-weight: $font-semi-bold;
|
||||
font-size: $font-15px;
|
||||
line-height: $font-18px;
|
||||
color: $primary-fg-color;
|
||||
margin: 16px 0;
|
||||
|
||||
.mx_BaseAvatar {
|
||||
margin-right: 12px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.mx_AccessibleButton {
|
||||
float: right;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -41,7 +41,6 @@ $spacePanelWidth: 71px;
|
|||
> p {
|
||||
font-size: $font-15px;
|
||||
color: $secondary-fg-color;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.mx_SpaceFeedbackPrompt {
|
||||
|
@ -51,13 +50,6 @@ $spacePanelWidth: 71px;
|
|||
}
|
||||
}
|
||||
|
||||
// XXX remove this when spaces leaves Beta
|
||||
.mx_BetaCard_betaPill {
|
||||
position: absolute;
|
||||
top: 24px;
|
||||
right: 24px;
|
||||
}
|
||||
|
||||
.mx_SpaceCreateMenuType {
|
||||
@mixin SpacePillButton;
|
||||
}
|
||||
|
@ -100,6 +92,11 @@ $spacePanelWidth: 71px;
|
|||
width: min-content;
|
||||
}
|
||||
|
||||
.mx_AccessibleButton_kind_link {
|
||||
padding: 0;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
.mx_AccessibleButton_disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
|
102
res/css/views/voip/CallView/_CallViewButtons.scss
Normal file
102
res/css/views/voip/CallView/_CallViewButtons.scss
Normal file
|
@ -0,0 +1,102 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2020 - 2021 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2021 Šimon Brandner <simon.bra.ag@gmail.com>
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
.mx_CallViewButtons {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
bottom: 5px;
|
||||
opacity: 1;
|
||||
transition: opacity 0.5s;
|
||||
z-index: 200; // To be above _all_ feeds
|
||||
|
||||
&.mx_CallViewButtons_hidden {
|
||||
opacity: 0.001; // opacity 0 can cause a re-layout
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.mx_CallViewButtons_button {
|
||||
cursor: pointer;
|
||||
margin-left: 2px;
|
||||
margin-right: 2px;
|
||||
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
|
||||
height: 48px;
|
||||
width: 48px;
|
||||
|
||||
background-repeat: no-repeat;
|
||||
background-size: contain;
|
||||
background-position: center;
|
||||
}
|
||||
|
||||
|
||||
&.mx_CallViewButtons_dialpad::before {
|
||||
background-image: url('$(res)/img/voip/dialpad.svg');
|
||||
}
|
||||
|
||||
&.mx_CallViewButtons_button_micOn::before {
|
||||
background-image: url('$(res)/img/voip/mic-on.svg');
|
||||
}
|
||||
|
||||
&.mx_CallViewButtons_button_micOff::before {
|
||||
background-image: url('$(res)/img/voip/mic-off.svg');
|
||||
}
|
||||
|
||||
&.mx_CallViewButtons_button_vidOn::before {
|
||||
background-image: url('$(res)/img/voip/vid-on.svg');
|
||||
}
|
||||
|
||||
&.mx_CallViewButtons_button_vidOff::before {
|
||||
background-image: url('$(res)/img/voip/vid-off.svg');
|
||||
}
|
||||
|
||||
&.mx_CallViewButtons_button_screensharingOn::before {
|
||||
background-image: url('$(res)/img/voip/screensharing-on.svg');
|
||||
}
|
||||
|
||||
&.mx_CallViewButtons_button_screensharingOff::before {
|
||||
background-image: url('$(res)/img/voip/screensharing-off.svg');
|
||||
}
|
||||
|
||||
&.mx_CallViewButtons_button_sidebarOn::before {
|
||||
background-image: url('$(res)/img/voip/sidebar-on.svg');
|
||||
}
|
||||
|
||||
&.mx_CallViewButtons_button_sidebarOff::before {
|
||||
background-image: url('$(res)/img/voip/sidebar-off.svg');
|
||||
}
|
||||
|
||||
&.mx_CallViewButtons_button_hangup::before {
|
||||
background-image: url('$(res)/img/voip/hangup.svg');
|
||||
}
|
||||
|
||||
&.mx_CallViewButtons_button_more::before {
|
||||
background-image: url('$(res)/img/voip/more.svg');
|
||||
}
|
||||
|
||||
&.mx_CallViewButtons_button_invisible {
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -28,7 +28,6 @@ limitations under the License.
|
|||
|
||||
.mx_CallPreview {
|
||||
pointer-events: initial; // restore pointer events so the user can leave/interact
|
||||
cursor: pointer;
|
||||
|
||||
.mx_VideoFeed_remote.mx_VideoFeed_voice {
|
||||
min-height: 150px;
|
||||
|
|
|
@ -39,7 +39,7 @@ limitations under the License.
|
|||
.mx_CallView_pip {
|
||||
width: 320px;
|
||||
padding-bottom: 8px;
|
||||
background-color: $toast-bg-color;
|
||||
background-color: $system;
|
||||
box-shadow: 0px 4px 20px rgba(0, 0, 0, 0.20);
|
||||
border-radius: 8px;
|
||||
|
||||
|
@ -47,11 +47,11 @@ limitations under the License.
|
|||
height: 180px;
|
||||
}
|
||||
|
||||
.mx_CallView_callControls {
|
||||
.mx_CallViewButtons {
|
||||
bottom: 0px;
|
||||
}
|
||||
|
||||
.mx_CallView_callControls_button {
|
||||
.mx_CallViewButtons_button {
|
||||
&::before {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
|
@ -75,8 +75,6 @@ limitations under the License.
|
|||
height: 100%;
|
||||
|
||||
&.mx_VideoFeed_voice {
|
||||
// We don't want to collide with the call controls that have 52px of height
|
||||
margin-bottom: 52px;
|
||||
background-color: $inverted-bg-color;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
@ -201,133 +199,6 @@ limitations under the License.
|
|||
}
|
||||
}
|
||||
|
||||
.mx_CallView_header {
|
||||
height: 44px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: left;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mx_CallView_header_callType {
|
||||
font-size: 1.2rem;
|
||||
font-weight: bold;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.mx_CallView_header_secondaryCallInfo {
|
||||
&::before {
|
||||
content: '·';
|
||||
margin-left: 6px;
|
||||
margin-right: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.mx_CallView_header_controls {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.mx_CallView_header_button {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
cursor: pointer;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
vertical-align: middle;
|
||||
background-color: $secondary-fg-color;
|
||||
mask-repeat: no-repeat;
|
||||
mask-size: contain;
|
||||
mask-position: center;
|
||||
}
|
||||
}
|
||||
|
||||
.mx_CallView_header_button_fullscreen {
|
||||
&::before {
|
||||
mask-image: url('$(res)/img/element-icons/call/fullscreen.svg');
|
||||
}
|
||||
}
|
||||
|
||||
.mx_CallView_header_button_expand {
|
||||
&::before {
|
||||
mask-image: url('$(res)/img/element-icons/call/expand.svg');
|
||||
}
|
||||
}
|
||||
|
||||
.mx_CallView_header_callInfo {
|
||||
margin-left: 12px;
|
||||
margin-right: 16px;
|
||||
}
|
||||
|
||||
.mx_CallView_header_roomName {
|
||||
font-weight: bold;
|
||||
font-size: 12px;
|
||||
line-height: initial;
|
||||
height: 15px;
|
||||
}
|
||||
|
||||
.mx_CallView_secondaryCall_roomName {
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.mx_CallView_header_callTypeSmall {
|
||||
font-size: 12px;
|
||||
color: $secondary-fg-color;
|
||||
line-height: initial;
|
||||
height: 15px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 240px;
|
||||
}
|
||||
|
||||
.mx_CallView_header_callTypeIcon {
|
||||
display: inline-block;
|
||||
margin-right: 6px;
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
vertical-align: middle;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
background-color: $secondary-fg-color;
|
||||
mask-repeat: no-repeat;
|
||||
mask-size: contain;
|
||||
mask-position: center;
|
||||
}
|
||||
|
||||
&.mx_CallView_header_callTypeIcon_voice::before {
|
||||
mask-image: url('$(res)/img/element-icons/call/voice-call.svg');
|
||||
}
|
||||
|
||||
&.mx_CallView_header_callTypeIcon_video::before {
|
||||
mask-image: url('$(res)/img/element-icons/call/video-call.svg');
|
||||
}
|
||||
}
|
||||
|
||||
.mx_CallView_callControls {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
bottom: 5px;
|
||||
opacity: 1;
|
||||
transition: opacity 0.5s;
|
||||
z-index: 200; // To be above _all_ feeds
|
||||
}
|
||||
|
||||
.mx_CallView_callControls_hidden {
|
||||
opacity: 0.001; // opacity 0 can cause a re-layout
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.mx_CallView_presenting {
|
||||
opacity: 1;
|
||||
|
@ -347,94 +218,3 @@ limitations under the License.
|
|||
opacity: 0.001; // opacity 0 can cause a re-layout
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.mx_CallView_callControls_button {
|
||||
cursor: pointer;
|
||||
margin-left: 2px;
|
||||
margin-right: 2px;
|
||||
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
|
||||
height: 48px;
|
||||
width: 48px;
|
||||
|
||||
background-repeat: no-repeat;
|
||||
background-size: contain;
|
||||
background-position: center;
|
||||
}
|
||||
}
|
||||
|
||||
.mx_CallView_callControls_dialpad {
|
||||
&::before {
|
||||
background-image: url('$(res)/img/voip/dialpad.svg');
|
||||
}
|
||||
}
|
||||
|
||||
.mx_CallView_callControls_button_micOn {
|
||||
&::before {
|
||||
background-image: url('$(res)/img/voip/mic-on.svg');
|
||||
}
|
||||
}
|
||||
|
||||
.mx_CallView_callControls_button_micOff {
|
||||
&::before {
|
||||
background-image: url('$(res)/img/voip/mic-off.svg');
|
||||
}
|
||||
}
|
||||
|
||||
.mx_CallView_callControls_button_vidOn {
|
||||
&::before {
|
||||
background-image: url('$(res)/img/voip/vid-on.svg');
|
||||
}
|
||||
}
|
||||
|
||||
.mx_CallView_callControls_button_vidOff {
|
||||
&::before {
|
||||
background-image: url('$(res)/img/voip/vid-off.svg');
|
||||
}
|
||||
}
|
||||
|
||||
.mx_CallView_callControls_button_screensharingOn {
|
||||
&::before {
|
||||
background-image: url('$(res)/img/voip/screensharing-on.svg');
|
||||
}
|
||||
}
|
||||
|
||||
.mx_CallView_callControls_button_screensharingOff {
|
||||
&::before {
|
||||
background-image: url('$(res)/img/voip/screensharing-off.svg');
|
||||
}
|
||||
}
|
||||
|
||||
.mx_CallView_callControls_button_sidebarOn {
|
||||
&::before {
|
||||
background-image: url('$(res)/img/voip/sidebar-on.svg');
|
||||
}
|
||||
}
|
||||
|
||||
.mx_CallView_callControls_button_sidebarOff {
|
||||
&::before {
|
||||
background-image: url('$(res)/img/voip/sidebar-off.svg');
|
||||
}
|
||||
}
|
||||
|
||||
.mx_CallView_callControls_button_hangup {
|
||||
&::before {
|
||||
background-image: url('$(res)/img/voip/hangup.svg');
|
||||
}
|
||||
}
|
||||
|
||||
.mx_CallView_callControls_button_more {
|
||||
&::before {
|
||||
background-image: url('$(res)/img/voip/more.svg');
|
||||
}
|
||||
}
|
||||
|
||||
.mx_CallView_callControls_button_invisible {
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
}
|
||||
|
|
129
res/css/views/voip/_CallViewHeader.scss
Normal file
129
res/css/views/voip/_CallViewHeader.scss
Normal file
|
@ -0,0 +1,129 @@
|
|||
/*
|
||||
Copyright 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
.mx_CallViewHeader {
|
||||
height: 44px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: left;
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mx_CallViewHeader_callType {
|
||||
font-size: 1.2rem;
|
||||
font-weight: bold;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.mx_CallViewHeader_secondaryCallInfo {
|
||||
&::before {
|
||||
content: '·';
|
||||
margin-left: 6px;
|
||||
margin-right: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.mx_CallViewHeader_controls {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.mx_CallViewHeader_button {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
cursor: pointer;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
vertical-align: middle;
|
||||
background-color: $secondary-fg-color;
|
||||
mask-repeat: no-repeat;
|
||||
mask-size: contain;
|
||||
mask-position: center;
|
||||
}
|
||||
}
|
||||
|
||||
.mx_CallViewHeader_button_fullscreen {
|
||||
&::before {
|
||||
mask-image: url('$(res)/img/element-icons/call/fullscreen.svg');
|
||||
}
|
||||
}
|
||||
|
||||
.mx_CallViewHeader_button_expand {
|
||||
&::before {
|
||||
mask-image: url('$(res)/img/element-icons/call/expand.svg');
|
||||
}
|
||||
}
|
||||
|
||||
.mx_CallViewHeader_callInfo {
|
||||
margin-left: 12px;
|
||||
margin-right: 16px;
|
||||
}
|
||||
|
||||
.mx_CallViewHeader_roomName {
|
||||
font-weight: bold;
|
||||
font-size: 12px;
|
||||
line-height: initial;
|
||||
height: 15px;
|
||||
}
|
||||
|
||||
.mx_CallView_secondaryCall_roomName {
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.mx_CallViewHeader_callTypeSmall {
|
||||
font-size: 12px;
|
||||
color: $secondary-fg-color;
|
||||
line-height: initial;
|
||||
height: 15px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 240px;
|
||||
}
|
||||
|
||||
.mx_CallViewHeader_callTypeIcon {
|
||||
display: inline-block;
|
||||
margin-right: 6px;
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
vertical-align: middle;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
background-color: $secondary-fg-color;
|
||||
mask-repeat: no-repeat;
|
||||
mask-size: contain;
|
||||
mask-position: center;
|
||||
}
|
||||
|
||||
&.mx_CallViewHeader_callTypeIcon_voice::before {
|
||||
mask-image: url('$(res)/img/element-icons/call/voice-call.svg');
|
||||
}
|
||||
|
||||
&.mx_CallViewHeader_callTypeIcon_video::before {
|
||||
mask-image: url('$(res)/img/element-icons/call/video-call.svg');
|
||||
}
|
||||
}
|
|
@ -40,8 +40,6 @@ limitations under the License.
|
|||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
aspect-ratio: 16 / 9;
|
||||
}
|
||||
|
||||
.mx_VideoFeed_video {
|
||||
|
|
|
@ -20,6 +20,7 @@ limitations under the License.
|
|||
|
||||
&.mx_VideoFeed_voice {
|
||||
background-color: $inverted-bg-color;
|
||||
aspect-ratio: 16 / 9;
|
||||
}
|
||||
|
||||
.mx_VideoFeed_video {
|
||||
|
|
|
@ -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 |
|
@ -1,18 +1,35 @@
|
|||
// Colors from Figma Compound https://www.figma.com/file/X4XTH9iS2KGJ2wFKDqkyed/Compound?node-id=557%3A0
|
||||
$system-dark: #21262C;
|
||||
// Colors from Figma Compound https://www.figma.com/file/X4XTH9iS2KGJ2wFKDqkyed/Compound?node-id=559%3A741
|
||||
$accent: #0DBD8B;
|
||||
$alert: #FF5B55;
|
||||
$links: #0086e6;
|
||||
$primary-content: #ffffff;
|
||||
$secondary-content: #A9B2BC;
|
||||
$tertiary-content: #8E99A4;
|
||||
$quaternary-content: #6F7882;
|
||||
$quinary-content: #394049;
|
||||
$system: #21262C;
|
||||
$background: #15191E;
|
||||
$panels: rgba($system, 0.9);
|
||||
$panel-base: #8D97A5; // This color is not intended for use in the app
|
||||
$panel-selected: rgba($panel-base, 0.3);
|
||||
$panel-hover: rgba($panel-base, 0.1);
|
||||
$panel-actions: rgba($panel-base, 0.2);
|
||||
$space-nav: rgba($panel-base, 0.1);
|
||||
|
||||
// TODO: Move userId colors here
|
||||
|
||||
// unified palette
|
||||
// try to use these colors when possible
|
||||
$bg-color: #15191E;
|
||||
$bg-color: $background;
|
||||
$base-color: $bg-color;
|
||||
$base-text-color: #ffffff;
|
||||
$base-text-color: $primary-content;
|
||||
$header-panel-bg-color: #20252B;
|
||||
$header-panel-border-color: #000000;
|
||||
$header-panel-text-primary-color: #B9BEC6;
|
||||
$header-panel-text-secondary-color: #c8c8cd;
|
||||
$text-primary-color: #ffffff;
|
||||
$text-primary-color: $primary-content;
|
||||
$text-secondary-color: #B9BEC6;
|
||||
$quaternary-fg-color: #6F7882;
|
||||
$quaternary-fg-color: $quaternary-content;
|
||||
$search-bg-color: #181b21;
|
||||
$search-placeholder-color: #61708b;
|
||||
$room-highlight-color: #343a46;
|
||||
|
@ -23,8 +40,8 @@ $primary-bg-color: $bg-color;
|
|||
$muted-fg-color: $header-panel-text-primary-color;
|
||||
|
||||
// additional text colors
|
||||
$secondary-fg-color: #A9B2BC;
|
||||
$tertiary-fg-color: #8E99A4;
|
||||
$secondary-fg-color: $secondary-content;
|
||||
$tertiary-fg-color: $tertiary-content;
|
||||
|
||||
// used for dialog box text
|
||||
$light-fg-color: $header-panel-text-secondary-color;
|
||||
|
@ -50,7 +67,7 @@ $inverted-bg-color: $base-color;
|
|||
$selected-color: $room-highlight-color;
|
||||
|
||||
// selected for hoverover & selected event tiles
|
||||
$event-selected-color: $system-dark;
|
||||
$event-selected-color: $system;
|
||||
|
||||
// used for the hairline dividers in RoomView
|
||||
$primary-hairline-color: transparent;
|
||||
|
@ -94,7 +111,7 @@ $lightbox-background-bg-color: #000;
|
|||
$lightbox-background-bg-opacity: 0.85;
|
||||
|
||||
$settings-grey-fg-color: #a2a2a2;
|
||||
$settings-profile-placeholder-bg-color: $system-dark;
|
||||
$settings-profile-placeholder-bg-color: $system;
|
||||
$settings-profile-overlay-placeholder-fg-color: #454545;
|
||||
$settings-profile-button-bg-color: #e7e7e7;
|
||||
$settings-profile-button-fg-color: $settings-profile-overlay-placeholder-fg-color;
|
||||
|
@ -108,20 +125,17 @@ $roomheader-addroom-fg-color: $text-primary-color;
|
|||
$groupFilterPanel-button-color: $header-panel-text-primary-color;
|
||||
$groupheader-button-color: $header-panel-text-primary-color;
|
||||
$rightpanel-button-color: $header-panel-text-primary-color;
|
||||
$icon-button-color: #8E99A4;
|
||||
$icon-button-color: $tertiary-content;
|
||||
$roomtopic-color: $text-secondary-color;
|
||||
$eventtile-meta-color: $roomtopic-color;
|
||||
|
||||
$header-divider-color: $header-panel-text-primary-color;
|
||||
$composer-e2e-icon-color: $header-panel-text-primary-color;
|
||||
|
||||
$quinary-content-color: #394049;
|
||||
$toast-bg-color: $quinary-content-color;
|
||||
|
||||
// ********************
|
||||
|
||||
$theme-button-bg-color: #e3e8f0;
|
||||
$dialpad-button-bg-color: #394049;
|
||||
$dialpad-button-bg-color: $quinary-content;
|
||||
|
||||
$roomlist-button-bg-color: rgba(141, 151, 165, 0.2); // Buttons include the filter box, explore button, and sublist buttons
|
||||
$roomlist-filter-active-bg-color: $bg-color;
|
||||
|
@ -164,12 +178,12 @@ $tab-label-icon-bg-color: $text-primary-color;
|
|||
$tab-label-active-icon-bg-color: $text-primary-color;
|
||||
|
||||
// Buttons
|
||||
$button-primary-fg-color: #ffffff;
|
||||
$button-primary-fg-color: $primary-content;
|
||||
$button-primary-bg-color: $accent-color;
|
||||
$button-secondary-bg-color: transparent;
|
||||
$button-danger-fg-color: #ffffff;
|
||||
$button-danger-fg-color: $primary-content;
|
||||
$button-danger-bg-color: $notice-primary-color;
|
||||
$button-danger-disabled-fg-color: #ffffff;
|
||||
$button-danger-disabled-fg-color: $primary-content;
|
||||
$button-danger-disabled-bg-color: #f5b6bb; // TODO: Verify color
|
||||
$button-link-fg-color: $accent-color;
|
||||
$button-link-bg-color: transparent;
|
||||
|
@ -178,7 +192,7 @@ $button-link-bg-color: transparent;
|
|||
$togglesw-off-color: $room-highlight-color;
|
||||
|
||||
$progressbar-fg-color: $accent-color;
|
||||
$progressbar-bg-color: $system-dark;
|
||||
$progressbar-bg-color: $system;
|
||||
|
||||
$visual-bell-bg-color: #800;
|
||||
|
||||
|
@ -201,19 +215,19 @@ $reaction-row-button-selected-border-color: $accent-color;
|
|||
$kbd-border-color: #000000;
|
||||
|
||||
$tooltip-timeline-bg-color: $groupFilterPanel-bg-color;
|
||||
$tooltip-timeline-fg-color: #ffffff;
|
||||
$tooltip-timeline-fg-color: $primary-content;
|
||||
|
||||
$interactive-tooltip-bg-color: $base-color;
|
||||
$interactive-tooltip-fg-color: #ffffff;
|
||||
$interactive-tooltip-fg-color: $primary-content;
|
||||
|
||||
$breadcrumb-placeholder-bg-color: #272c35;
|
||||
|
||||
$user-tile-hover-bg-color: $header-panel-bg-color;
|
||||
|
||||
$message-body-panel-fg-color: $secondary-fg-color;
|
||||
$message-body-panel-bg-color: #394049; // "Dark Tile"
|
||||
$message-body-panel-bg-color: $quinary-content;
|
||||
$message-body-panel-icon-fg-color: $secondary-fg-color;
|
||||
$message-body-panel-icon-bg-color: $system-dark; // "System Dark"
|
||||
$message-body-panel-icon-bg-color: $system; // "System Dark"
|
||||
|
||||
$voice-record-stop-border-color: $quaternary-fg-color;
|
||||
$voice-record-waveform-incomplete-fg-color: $quaternary-fg-color;
|
||||
|
|
|
@ -1,3 +1,6 @@
|
|||
// Colors from Figma Compound https://www.figma.com/file/X4XTH9iS2KGJ2wFKDqkyed/Compound?node-id=559%3A741
|
||||
$system: #21262C;
|
||||
|
||||
// unified palette
|
||||
// try to use these colors when possible
|
||||
$bg-color: #181b21;
|
||||
|
@ -111,9 +114,6 @@ $eventtile-meta-color: $roomtopic-color;
|
|||
$header-divider-color: $header-panel-text-primary-color;
|
||||
$composer-e2e-icon-color: $header-panel-text-primary-color;
|
||||
|
||||
$quinary-content-color: #394049;
|
||||
$toast-bg-color: $quinary-content-color;
|
||||
|
||||
// ********************
|
||||
|
||||
$theme-button-bg-color: #e3e8f0;
|
||||
|
@ -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 {
|
||||
|
|
|
@ -8,12 +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;
|
||||
$system: #F4F6FA;
|
||||
|
||||
// unified palette
|
||||
// try to use these colors when possible
|
||||
|
@ -181,8 +181,7 @@ $eventtile-meta-color: $roomtopic-color;
|
|||
$composer-e2e-icon-color: #91a1c0;
|
||||
$header-divider-color: #91a1c0;
|
||||
|
||||
$toast-bg-color: $system-light;
|
||||
$voipcall-plinth-color: $system-light;
|
||||
$voipcall-plinth-color: $system;
|
||||
|
||||
// ********************
|
||||
|
||||
|
@ -334,7 +333,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: $system-light;
|
||||
$message-body-panel-icon-bg-color: $system;
|
||||
|
||||
// See non-legacy _light for variable information
|
||||
$voice-record-stop-symbol-color: #ff4b55;
|
||||
|
@ -352,7 +351,7 @@ $composer-shadow-color: tranparent;
|
|||
|
||||
// Bubble tiles
|
||||
$eventbubble-self-bg: #F0FBF8;
|
||||
$eventbubble-others-bg: $system-light;
|
||||
$eventbubble-others-bg: $system;
|
||||
$eventbubble-bg-hover: #FAFBFD;
|
||||
$eventbubble-avatar-outline: #fff;
|
||||
$eventbubble-reply-color: #C1C6CD;
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -8,27 +8,43 @@
|
|||
/* Noto Color Emoji contains digits, in fixed-width, therefore causing
|
||||
digits in flowed text to stand out.
|
||||
TODO: Consider putting all emoji fonts to the end rather than the front. */
|
||||
$font-family: 'Inter', 'Twemoji', 'Apple Color Emoji', 'Segoe UI Emoji', 'Arial', 'Helvetica', 'Sans-Serif', 'Noto Color Emoji';
|
||||
$font-family: 'Inter', 'Twemoji', 'Apple Color Emoji', 'Segoe UI Emoji', 'Arial', 'Helvetica', sans-serif, 'Noto Color Emoji';
|
||||
|
||||
$monospace-font-family: 'Inconsolata', 'Twemoji', 'Apple Color Emoji', 'Segoe UI Emoji', 'Courier', 'monospace', 'Noto Color Emoji';
|
||||
$monospace-font-family: 'Inconsolata', 'Twemoji', 'Apple Color Emoji', 'Segoe UI Emoji', 'Courier', monospace, 'Noto Color Emoji';
|
||||
|
||||
// Colors from Figma Compound https://www.figma.com/file/X4XTH9iS2KGJ2wFKDqkyed/Compound?node-id=557%3A0
|
||||
$system-light: #F4F6FA;
|
||||
// Colors from Figma Compound https://www.figma.com/file/X4XTH9iS2KGJ2wFKDqkyed/Compound?node-id=559%3A120
|
||||
$accent: #0DBD8B;
|
||||
$alert: #FF5B55;
|
||||
$links: #0086e6;
|
||||
$primary-content: #17191C;
|
||||
$secondary-content: #737D8C;
|
||||
$tertiary-content: #8D97A5;
|
||||
$quaternary-content: #c1c6cd;
|
||||
$quinary-content: #E3E8F0;
|
||||
$system: #F4F6FA;
|
||||
$background: #ffffff;
|
||||
$panels: rgba($system, 0.9);
|
||||
$panel-selected: rgba($tertiary-content, 0.3);
|
||||
$panel-hover: rgba($tertiary-content, 0.1);
|
||||
$panel-actions: rgba($tertiary-content, 0.2);
|
||||
$space-nav: rgba($tertiary-content, 0.15);
|
||||
|
||||
// TODO: Move userId colors here
|
||||
|
||||
// unified palette
|
||||
// try to use these colors when possible
|
||||
$accent-color: #0DBD8B;
|
||||
$accent-color: $accent;
|
||||
$accent-bg-color: rgba(3, 179, 129, 0.16);
|
||||
$notice-primary-color: #ff4b55;
|
||||
$notice-primary-bg-color: rgba(255, 75, 85, 0.16);
|
||||
$primary-fg-color: #2e2f32;
|
||||
$secondary-fg-color: #737D8C;
|
||||
$secondary-fg-color: $secondary-content;
|
||||
$tertiary-fg-color: #8D99A5;
|
||||
$quaternary-fg-color: #C1C6CD;
|
||||
$quaternary-fg-color: $quaternary-content;
|
||||
$header-panel-bg-color: #f3f8fd;
|
||||
|
||||
// typical text (dark-on-white in light skin)
|
||||
$primary-bg-color: #ffffff;
|
||||
$primary-bg-color: $background;
|
||||
$muted-fg-color: #61708b; // Commonly used in headings and relevant alt text
|
||||
|
||||
// used for dialog box text
|
||||
|
@ -38,7 +54,7 @@ $light-fg-color: #747474;
|
|||
$focus-bg-color: #dddddd;
|
||||
|
||||
// button UI (white-on-green in light skin)
|
||||
$accent-fg-color: #ffffff;
|
||||
$accent-fg-color: $background;
|
||||
$accent-color-50pct: rgba($accent-color, 0.5);
|
||||
$accent-color-darker: #92caad;
|
||||
$accent-color-alt: #238CF5;
|
||||
|
@ -82,7 +98,7 @@ $primary-hairline-color: transparent;
|
|||
|
||||
// used for the border of input text fields
|
||||
$input-border-color: #e7e7e7;
|
||||
$input-darker-bg-color: #e3e8f0;
|
||||
$input-darker-bg-color: $quinary-content;
|
||||
$input-darker-fg-color: #9fa9ba;
|
||||
$input-lighter-bg-color: #f2f5f8;
|
||||
$input-lighter-fg-color: $input-darker-fg-color;
|
||||
|
@ -90,7 +106,7 @@ $input-focused-border-color: #238cf5;
|
|||
$input-valid-border-color: $accent-color;
|
||||
$input-invalid-border-color: $warning-color;
|
||||
|
||||
$field-focused-label-bg-color: #ffffff;
|
||||
$field-focused-label-bg-color: $background;
|
||||
|
||||
$button-bg-color: $accent-color;
|
||||
$button-fg-color: white;
|
||||
|
@ -112,8 +128,8 @@ $menu-bg-color: #fff;
|
|||
$menu-box-shadow-color: rgba(118, 131, 156, 0.6);
|
||||
$menu-selected-color: #f5f8fa;
|
||||
|
||||
$avatar-initial-color: #ffffff;
|
||||
$avatar-bg-color: #ffffff;
|
||||
$avatar-initial-color: $background;
|
||||
$avatar-bg-color: $background;
|
||||
|
||||
$h3-color: #3d3b39;
|
||||
|
||||
|
@ -141,7 +157,7 @@ $blockquote-bar-color: #ddd;
|
|||
$blockquote-fg-color: #777;
|
||||
|
||||
$settings-grey-fg-color: #a2a2a2;
|
||||
$settings-profile-placeholder-bg-color: $system-light;
|
||||
$settings-profile-placeholder-bg-color: $system;
|
||||
$settings-profile-overlay-placeholder-fg-color: #2e2f32;
|
||||
$settings-profile-button-bg-color: #e7e7e7;
|
||||
$settings-profile-button-fg-color: $settings-profile-overlay-placeholder-fg-color;
|
||||
|
@ -163,24 +179,23 @@ $roomheader-addroom-fg-color: #5c6470;
|
|||
$groupFilterPanel-button-color: #91A1C0;
|
||||
$groupheader-button-color: #91A1C0;
|
||||
$rightpanel-button-color: #91A1C0;
|
||||
$icon-button-color: #C1C6CD;
|
||||
$icon-button-color: $quaternary-content;
|
||||
$roomtopic-color: #9e9e9e;
|
||||
$eventtile-meta-color: $roomtopic-color;
|
||||
|
||||
$composer-e2e-icon-color: #91A1C0;
|
||||
$header-divider-color: #91A1C0;
|
||||
|
||||
$toast-bg-color: $system-light;
|
||||
$voipcall-plinth-color: $system-light;
|
||||
$voipcall-plinth-color: $system;
|
||||
|
||||
// ********************
|
||||
|
||||
$theme-button-bg-color: #e3e8f0;
|
||||
$dialpad-button-bg-color: #e3e8f0;
|
||||
$theme-button-bg-color: $quinary-content;
|
||||
$dialpad-button-bg-color: $quinary-content;
|
||||
|
||||
|
||||
$roomlist-button-bg-color: rgba(141, 151, 165, 0.2); // Buttons include the filter box, explore button, and sublist buttons
|
||||
$roomlist-filter-active-bg-color: #ffffff;
|
||||
$roomlist-filter-active-bg-color: $background;
|
||||
$roomlist-bg-color: rgba(245, 245, 245, 0.90);
|
||||
$roomlist-header-color: $tertiary-fg-color;
|
||||
$roomsublist-divider-color: $primary-fg-color;
|
||||
|
@ -194,7 +209,7 @@ $roomtile-selected-bg-color: #FFF;
|
|||
|
||||
$presence-online: $accent-color;
|
||||
$presence-away: #d9b072;
|
||||
$presence-offline: #E3E8F0;
|
||||
$presence-offline: $quinary-content;
|
||||
|
||||
// ********************
|
||||
|
||||
|
@ -257,7 +272,7 @@ $lightbox-border-color: #ffffff;
|
|||
|
||||
// Tabbed views
|
||||
$tab-label-fg-color: #45474a;
|
||||
$tab-label-active-fg-color: #ffffff;
|
||||
$tab-label-active-fg-color: $background;
|
||||
$tab-label-bg-color: transparent;
|
||||
$tab-label-active-bg-color: $accent-color;
|
||||
$tab-label-icon-bg-color: #454545;
|
||||
|
@ -267,9 +282,9 @@ $tab-label-active-icon-bg-color: $tab-label-active-fg-color;
|
|||
$button-primary-fg-color: #ffffff;
|
||||
$button-primary-bg-color: $accent-color;
|
||||
$button-secondary-bg-color: $accent-fg-color;
|
||||
$button-danger-fg-color: #ffffff;
|
||||
$button-danger-fg-color: $background;
|
||||
$button-danger-bg-color: $notice-primary-color;
|
||||
$button-danger-disabled-fg-color: #ffffff;
|
||||
$button-danger-disabled-fg-color: $background;
|
||||
$button-danger-disabled-bg-color: #f5b6bb; // TODO: Verify color
|
||||
$button-link-fg-color: $accent-color;
|
||||
$button-link-bg-color: transparent;
|
||||
|
@ -294,7 +309,7 @@ $memberstatus-placeholder-color: $muted-fg-color;
|
|||
|
||||
$authpage-bg-color: #2e3649;
|
||||
$authpage-modal-bg-color: rgba(245, 245, 245, 0.90);
|
||||
$authpage-body-bg-color: #ffffff;
|
||||
$authpage-body-bg-color: $background;
|
||||
$authpage-focus-bg-color: #dddddd;
|
||||
$authpage-lang-color: #4e5054;
|
||||
$authpage-primary-color: #232f32;
|
||||
|
@ -318,26 +333,26 @@ $kbd-border-color: $reaction-row-button-border-color;
|
|||
|
||||
$inverted-bg-color: #27303a;
|
||||
$tooltip-timeline-bg-color: $inverted-bg-color;
|
||||
$tooltip-timeline-fg-color: #ffffff;
|
||||
$tooltip-timeline-fg-color: $background;
|
||||
|
||||
$interactive-tooltip-bg-color: #27303a;
|
||||
$interactive-tooltip-fg-color: #ffffff;
|
||||
$interactive-tooltip-fg-color: $background;
|
||||
|
||||
$breadcrumb-placeholder-bg-color: #e8eef5;
|
||||
|
||||
$user-tile-hover-bg-color: $header-panel-bg-color;
|
||||
|
||||
$message-body-panel-fg-color: $secondary-fg-color;
|
||||
$message-body-panel-bg-color: #E3E8F0; // "Separator"
|
||||
$message-body-panel-bg-color: $quinary-content;
|
||||
$message-body-panel-icon-fg-color: $secondary-fg-color;
|
||||
$message-body-panel-icon-bg-color: $system-light;
|
||||
$message-body-panel-icon-bg-color: $system;
|
||||
|
||||
// These two don't change between themes. They are the $warning-color, but we don't
|
||||
// want custom themes to affect them by accident.
|
||||
$voice-record-stop-symbol-color: #ff4b55;
|
||||
$voice-record-live-circle-color: #ff4b55;
|
||||
|
||||
$voice-record-stop-border-color: #E3E8F0; // "Separator"
|
||||
$voice-record-stop-border-color: $quinary-content;
|
||||
$voice-record-waveform-incomplete-fg-color: $quaternary-fg-color;
|
||||
$voice-record-icon-color: $tertiary-fg-color;
|
||||
$voice-playback-button-bg-color: $message-body-panel-icon-bg-color;
|
||||
|
@ -354,10 +369,10 @@ $composer-shadow-color: rgba(0, 0, 0, 0.04);
|
|||
|
||||
// Bubble tiles
|
||||
$eventbubble-self-bg: #F0FBF8;
|
||||
$eventbubble-others-bg: $system-light;
|
||||
$eventbubble-others-bg: $system;
|
||||
$eventbubble-bg-hover: #FAFBFD;
|
||||
$eventbubble-avatar-outline: $primary-bg-color;
|
||||
$eventbubble-reply-color: #C1C6CD;
|
||||
$eventbubble-reply-color: $quaternary-content;
|
||||
|
||||
// ***** Mixins! *****
|
||||
|
||||
|
|
|
@ -14,8 +14,8 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import RoomViewStore from './stores/RoomViewStore';
|
||||
import { EventSubscription } from 'fbemitter';
|
||||
import RoomViewStore from './stores/RoomViewStore';
|
||||
|
||||
type Listener = (isActive: boolean) => void;
|
||||
|
||||
|
|
|
@ -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.
|
||||
|
@ -85,6 +86,8 @@ 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';
|
||||
|
@ -479,26 +482,44 @@ export default class CallHandler extends EventEmitter {
|
|||
}
|
||||
|
||||
switch (newState) {
|
||||
case CallState.Ringing:
|
||||
this.play(AudioID.Ring);
|
||||
case CallState.Ringing: {
|
||||
const incomingCallPushRule = (
|
||||
new PushProcessor(MatrixClientPeg.get()).getPushRuleById(RuleId.IncomingCall) as IPushRule
|
||||
);
|
||||
const pushRuleEnabled = incomingCallPushRule?.enabled;
|
||||
const tweakSetToRing = incomingCallPushRule?.actions.some((action: Tweaks) => (
|
||||
action.set_tweak === TweakName.Sound &&
|
||||
action.value === "ring"
|
||||
));
|
||||
|
||||
if (pushRuleEnabled && tweakSetToRing) {
|
||||
this.play(AudioID.Ring);
|
||||
} else {
|
||||
this.silenceCall(call.callId);
|
||||
}
|
||||
break;
|
||||
case CallState.InviteSent:
|
||||
}
|
||||
case CallState.InviteSent: {
|
||||
this.play(AudioID.Ringback);
|
||||
break;
|
||||
case CallState.Ended:
|
||||
{
|
||||
}
|
||||
case CallState.Ended: {
|
||||
const hangupReason = call.hangupReason;
|
||||
Analytics.trackEvent('voip', 'callEnded', 'hangupReason', hangupReason);
|
||||
this.removeCallForRoom(mappedRoomId);
|
||||
if (oldState === CallState.InviteSent && call.hangupParty === CallParty.Remote) {
|
||||
this.play(AudioID.Busy);
|
||||
|
||||
// Don't show a modal when we got rejected/the call was hung up
|
||||
if (!hangupReason || [CallErrorCode.UserHangup, "user hangup"].includes(hangupReason)) break;
|
||||
|
||||
let title;
|
||||
let description;
|
||||
// TODO: We should either do away with these or figure out a copy for each code (expect user_hangup...)
|
||||
if (call.hangupReason === CallErrorCode.UserBusy) {
|
||||
title = _t("User Busy");
|
||||
description = _t("The user you called is busy.");
|
||||
} else if (hangupReason && ![CallErrorCode.UserHangup, "user hangup"].includes(hangupReason)) {
|
||||
} else {
|
||||
title = _t("Call Failed");
|
||||
description = _t("The call could not be established");
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -150,13 +150,14 @@ const reducer = (state: IState, action: IAction) => {
|
|||
|
||||
interface IProps {
|
||||
handleHomeEnd?: boolean;
|
||||
handleUpDown?: boolean;
|
||||
children(renderProps: {
|
||||
onKeyDownHandler(ev: React.KeyboardEvent);
|
||||
});
|
||||
onKeyDown?(ev: React.KeyboardEvent, state: IState);
|
||||
}
|
||||
|
||||
export const RovingTabIndexProvider: React.FC<IProps> = ({ children, handleHomeEnd, onKeyDown }) => {
|
||||
export const RovingTabIndexProvider: React.FC<IProps> = ({ children, handleHomeEnd, handleUpDown, onKeyDown }) => {
|
||||
const [state, dispatch] = useReducer<Reducer<IState, IAction>>(reducer, {
|
||||
activeRef: null,
|
||||
refs: [],
|
||||
|
@ -167,21 +168,50 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({ children, handleHomeE
|
|||
const onKeyDownHandler = useCallback((ev) => {
|
||||
let handled = false;
|
||||
// Don't interfere with input default keydown behaviour
|
||||
if (handleHomeEnd && ev.target.tagName !== "INPUT" && ev.target.tagName !== "TEXTAREA") {
|
||||
if (ev.target.tagName !== "INPUT" && ev.target.tagName !== "TEXTAREA") {
|
||||
// check if we actually have any items
|
||||
switch (ev.key) {
|
||||
case Key.HOME:
|
||||
handled = true;
|
||||
// move focus to first item
|
||||
if (context.state.refs.length > 0) {
|
||||
context.state.refs[0].current.focus();
|
||||
if (handleHomeEnd) {
|
||||
handled = true;
|
||||
// move focus to first item
|
||||
if (context.state.refs.length > 0) {
|
||||
context.state.refs[0].current.focus();
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case Key.END:
|
||||
handled = true;
|
||||
// move focus to last item
|
||||
if (context.state.refs.length > 0) {
|
||||
context.state.refs[context.state.refs.length - 1].current.focus();
|
||||
if (handleHomeEnd) {
|
||||
handled = true;
|
||||
// move focus to last item
|
||||
if (context.state.refs.length > 0) {
|
||||
context.state.refs[context.state.refs.length - 1].current.focus();
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case Key.ARROW_UP:
|
||||
if (handleUpDown) {
|
||||
handled = true;
|
||||
if (context.state.refs.length > 0) {
|
||||
const idx = context.state.refs.indexOf(context.state.activeRef);
|
||||
if (idx > 0) {
|
||||
context.state.refs[idx - 1].current.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case Key.ARROW_DOWN:
|
||||
if (handleUpDown) {
|
||||
handled = true;
|
||||
if (context.state.refs.length > 0) {
|
||||
const idx = context.state.refs.indexOf(context.state.activeRef);
|
||||
if (idx < context.state.refs.length - 1) {
|
||||
context.state.refs[idx + 1].current.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
@ -193,7 +223,7 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({ children, handleHomeE
|
|||
} else if (onKeyDown) {
|
||||
return onKeyDown(ev, context.state);
|
||||
}
|
||||
}, [context.state, onKeyDown, handleHomeEnd]);
|
||||
}, [context.state, onKeyDown, handleHomeEnd, handleUpDown]);
|
||||
|
||||
return <RovingTabIndexContext.Provider value={context}>
|
||||
{ children({ onKeyDownHandler }) }
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -61,7 +61,9 @@ export default class AutoHideScrollbar extends React.Component<IProps> {
|
|||
style={style}
|
||||
className={["mx_AutoHideScrollbar", className].join(" ")}
|
||||
onWheel={onWheel}
|
||||
tabIndex={tabIndex}
|
||||
// Firefox sometimes makes this element focusable due to
|
||||
// overflow:scroll;, so force it out of tab order by default.
|
||||
tabIndex={tabIndex ?? -1}
|
||||
>
|
||||
{ children }
|
||||
</div>);
|
||||
|
|
|
@ -27,9 +27,15 @@ export enum CallEventGrouperEvent {
|
|||
SilencedChanged = "silenced_changed",
|
||||
}
|
||||
|
||||
const CONNECTING_STATES = [
|
||||
CallState.Connecting,
|
||||
CallState.WaitLocalMedia,
|
||||
CallState.CreateOffer,
|
||||
CallState.CreateAnswer,
|
||||
];
|
||||
|
||||
const SUPPORTED_STATES = [
|
||||
CallState.Connected,
|
||||
CallState.Connecting,
|
||||
CallState.Ringing,
|
||||
];
|
||||
|
||||
|
@ -61,6 +67,10 @@ export default class CallEventGrouper extends EventEmitter {
|
|||
return [...this.events].find((event) => event.getType() === EventType.CallReject);
|
||||
}
|
||||
|
||||
private get selectAnswer(): MatrixEvent {
|
||||
return [...this.events].find((event) => event.getType() === EventType.CallSelectAnswer);
|
||||
}
|
||||
|
||||
public get isVoice(): boolean {
|
||||
const invite = this.invite;
|
||||
if (!invite) return;
|
||||
|
@ -82,6 +92,11 @@ export default class CallEventGrouper extends EventEmitter {
|
|||
return Boolean(this.reject);
|
||||
}
|
||||
|
||||
public get duration(): Date {
|
||||
if (!this.hangup || !this.selectAnswer) return;
|
||||
return new Date(this.hangup.getDate().getTime() - this.selectAnswer.getDate().getTime());
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if there are only events from the other side - we missed the call
|
||||
*/
|
||||
|
@ -127,7 +142,9 @@ export default class CallEventGrouper extends EventEmitter {
|
|||
}
|
||||
|
||||
private setState = () => {
|
||||
if (SUPPORTED_STATES.includes(this.call?.state)) {
|
||||
if (CONNECTING_STATES.includes(this.call?.state)) {
|
||||
this.state = CallState.Connecting;
|
||||
} else if (SUPPORTED_STATES.includes(this.call?.state)) {
|
||||
this.state = this.call.state;
|
||||
} else {
|
||||
if (this.callWasMissed) this.state = CustomCallState.Missed;
|
||||
|
|
|
@ -41,6 +41,9 @@ import RightPanelStore from "../../stores/RightPanelStore";
|
|||
import AutoHideScrollbar from "./AutoHideScrollbar";
|
||||
import { mediaFromMxc } from "../../customisations/Media";
|
||||
import { replaceableComponent } from "../../utils/replaceableComponent";
|
||||
import { createSpaceFromCommunity } from "../../utils/space";
|
||||
import { Action } from "../../dispatcher/actions";
|
||||
import { RightPanelPhases } from "../../stores/RightPanelStorePhases";
|
||||
|
||||
const LONG_DESC_PLACEHOLDER = _td(
|
||||
`<h1>HTML for your community's page</h1>
|
||||
|
@ -399,6 +402,8 @@ class FeaturedUser extends React.Component {
|
|||
const GROUP_JOINPOLICY_OPEN = "open";
|
||||
const GROUP_JOINPOLICY_INVITE = "invite";
|
||||
|
||||
const UPGRADE_NOTICE_LS_KEY = "mx_hide_community_upgrade_notice";
|
||||
|
||||
@replaceableComponent("structures.GroupView")
|
||||
export default class GroupView extends React.Component {
|
||||
static propTypes = {
|
||||
|
@ -422,6 +427,7 @@ export default class GroupView extends React.Component {
|
|||
publicityBusy: false,
|
||||
inviterProfile: null,
|
||||
showRightPanel: RightPanelStore.getSharedInstance().isOpenForGroup,
|
||||
showUpgradeNotice: !localStorage.getItem(UPGRADE_NOTICE_LS_KEY),
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
|
@ -807,6 +813,22 @@ export default class GroupView extends React.Component {
|
|||
showGroupAddRoomDialog(this.props.groupId);
|
||||
};
|
||||
|
||||
_dismissUpgradeNotice = () => {
|
||||
localStorage.setItem(UPGRADE_NOTICE_LS_KEY, "true");
|
||||
this.setState({ showUpgradeNotice: false });
|
||||
}
|
||||
|
||||
_onCreateSpaceClick = () => {
|
||||
createSpaceFromCommunity(this._matrixClient, this.props.groupId);
|
||||
};
|
||||
|
||||
_onAdminsLinkClick = () => {
|
||||
dis.dispatch({
|
||||
action: Action.SetRightPanelPhase,
|
||||
phase: RightPanelPhases.GroupMemberList,
|
||||
});
|
||||
};
|
||||
|
||||
_getGroupSection() {
|
||||
const groupSettingsSectionClasses = classnames({
|
||||
"mx_GroupView_group": this.state.editing,
|
||||
|
@ -843,10 +865,46 @@ export default class GroupView extends React.Component {
|
|||
},
|
||||
) }
|
||||
</div> : <div />;
|
||||
|
||||
let communitiesUpgradeNotice;
|
||||
if (this.state.showUpgradeNotice) {
|
||||
let text;
|
||||
if (this.state.isUserPrivileged) {
|
||||
text = _t("You can create a Space from this community <a>here</a>.", {}, {
|
||||
a: sub => <AccessibleButton onClick={this._onCreateSpaceClick} kind="link">
|
||||
{ sub }
|
||||
</AccessibleButton>,
|
||||
});
|
||||
} else {
|
||||
text = _t("Ask the <a>admins</a> of this community to make it into a Space " +
|
||||
"and keep a look out for the invite.", {}, {
|
||||
a: sub => <AccessibleButton onClick={this._onAdminsLinkClick} kind="link">
|
||||
{ sub }
|
||||
</AccessibleButton>,
|
||||
});
|
||||
}
|
||||
|
||||
communitiesUpgradeNotice = <div className="mx_GroupView_spaceUpgradePrompt">
|
||||
<h2>{ _t("Communities can now be made into Spaces") }</h2>
|
||||
<p>
|
||||
{ _t("Spaces are a new way to make a community, with new features coming.") }
|
||||
|
||||
{ text }
|
||||
|
||||
{ _t("Communities won't receive further updates.") }
|
||||
</p>
|
||||
<AccessibleButton
|
||||
className="mx_GroupView_spaceUpgradePrompt_close"
|
||||
onClick={this._dismissUpgradeNotice}
|
||||
/>
|
||||
</div>;
|
||||
}
|
||||
|
||||
return <div className={groupSettingsSectionClasses}>
|
||||
{ header }
|
||||
{ hostingSignup }
|
||||
{ changeDelayWarning }
|
||||
{ communitiesUpgradeNotice }
|
||||
{ this._getJoinableNode() }
|
||||
{ this._getLongDescriptionNode() }
|
||||
{ this._getRoomsNode() }
|
||||
|
|
|
@ -392,9 +392,6 @@ export default class LeftPanel extends React.Component<IProps, IState> {
|
|||
<IndicatorScrollbar
|
||||
className="mx_LeftPanel_breadcrumbsContainer mx_AutoHideScrollbar"
|
||||
verticalScrollsHorizontally={true}
|
||||
// Firefox sometimes makes this element focusable due to
|
||||
// overflow:scroll;, so force it out of tab order.
|
||||
tabIndex={-1}
|
||||
>
|
||||
<RoomBreadcrumbs />
|
||||
</IndicatorScrollbar>
|
||||
|
|
|
@ -51,7 +51,12 @@ import EditorStateTransfer from "../../utils/EditorStateTransfer";
|
|||
|
||||
const CONTINUATION_MAX_INTERVAL = 5 * 60 * 1000; // 5 minutes
|
||||
const continuedTypes = [EventType.Sticker, EventType.RoomMessage];
|
||||
const membershipTypes = [EventType.RoomMember, EventType.RoomThirdPartyInvite, EventType.RoomServerAcl];
|
||||
const groupedEvents = [
|
||||
EventType.RoomMember,
|
||||
EventType.RoomThirdPartyInvite,
|
||||
EventType.RoomServerAcl,
|
||||
EventType.RoomPinnedEvents,
|
||||
];
|
||||
|
||||
// check if there is a previous event and it has the same sender as this event
|
||||
// and the types are the same/is in continuedTypes and the time between them is <= CONTINUATION_MAX_INTERVAL
|
||||
|
@ -1234,7 +1239,7 @@ class RedactionGrouper extends BaseGrouper {
|
|||
// Wrap consecutive member events in a ListSummary, ignore if redacted
|
||||
class MemberGrouper extends BaseGrouper {
|
||||
static canStartGroup = function(panel: MessagePanel, ev: MatrixEvent): boolean {
|
||||
return panel.shouldShowEvent(ev) && membershipTypes.includes(ev.getType() as EventType);
|
||||
return panel.shouldShowEvent(ev) && groupedEvents.includes(ev.getType() as EventType);
|
||||
};
|
||||
|
||||
constructor(
|
||||
|
@ -1252,7 +1257,7 @@ class MemberGrouper extends BaseGrouper {
|
|||
if (this.panel.wantsDateSeparator(this.events[0], ev.getDate())) {
|
||||
return false;
|
||||
}
|
||||
return membershipTypes.includes(ev.getType() as EventType);
|
||||
return groupedEvents.includes(ev.getType() as EventType);
|
||||
}
|
||||
|
||||
public add(ev: MatrixEvent, showHiddenEvents?: boolean): void {
|
||||
|
|
|
@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { ReactNode, useMemo, useState } from "react";
|
||||
import React, { ReactNode, KeyboardEvent, useMemo, useState, KeyboardEventHandler } from "react";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
import { EventType, RoomType } from "matrix-js-sdk/src/@types/event";
|
||||
import { ISpaceSummaryRoom, ISpaceSummaryEvent } from "matrix-js-sdk/src/@types/spaces";
|
||||
|
@ -46,6 +46,8 @@ import { getDisplayAliasForAliasSet } from "../../Rooms";
|
|||
import { useDispatcher } from "../../hooks/useDispatcher";
|
||||
import defaultDispatcher from "../../dispatcher/dispatcher";
|
||||
import { Action } from "../../dispatcher/actions";
|
||||
import { Key } from "../../Keyboard";
|
||||
import { IState, RovingTabIndexProvider, useRovingTabIndex } from "../../accessibility/RovingTabIndex";
|
||||
|
||||
interface IHierarchyProps {
|
||||
space: Room;
|
||||
|
@ -80,6 +82,7 @@ const Tile: React.FC<ITileProps> = ({
|
|||
|| (room.room_type === RoomType.Space ? _t("Unnamed Space") : _t("Unnamed Room"));
|
||||
|
||||
const [showChildren, toggleShowChildren] = useStateToggle(true);
|
||||
const [onFocus, isActive, ref] = useRovingTabIndex();
|
||||
|
||||
const onPreviewClick = (ev: ButtonEvent) => {
|
||||
ev.preventDefault();
|
||||
|
@ -94,11 +97,21 @@ const Tile: React.FC<ITileProps> = ({
|
|||
|
||||
let button;
|
||||
if (joinedRoom) {
|
||||
button = <AccessibleButton onClick={onPreviewClick} kind="primary_outline">
|
||||
button = <AccessibleButton
|
||||
onClick={onPreviewClick}
|
||||
kind="primary_outline"
|
||||
onFocus={onFocus}
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
>
|
||||
{ _t("View") }
|
||||
</AccessibleButton>;
|
||||
} else if (onJoinClick) {
|
||||
button = <AccessibleButton onClick={onJoinClick} kind="primary">
|
||||
button = <AccessibleButton
|
||||
onClick={onJoinClick}
|
||||
kind="primary"
|
||||
onFocus={onFocus}
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
>
|
||||
{ _t("Join") }
|
||||
</AccessibleButton>;
|
||||
}
|
||||
|
@ -106,13 +119,13 @@ const Tile: React.FC<ITileProps> = ({
|
|||
let checkbox;
|
||||
if (onToggleClick) {
|
||||
if (hasPermissions) {
|
||||
checkbox = <StyledCheckbox checked={!!selected} onChange={onToggleClick} />;
|
||||
checkbox = <StyledCheckbox checked={!!selected} onChange={onToggleClick} tabIndex={isActive ? 0 : -1} />;
|
||||
} else {
|
||||
checkbox = <TextWithTooltip
|
||||
tooltip={_t("You don't have permission")}
|
||||
onClick={ev => { ev.stopPropagation(); }}
|
||||
>
|
||||
<StyledCheckbox disabled={true} />
|
||||
<StyledCheckbox disabled={true} tabIndex={isActive ? 0 : -1} />
|
||||
</TextWithTooltip>;
|
||||
}
|
||||
}
|
||||
|
@ -172,8 +185,9 @@ const Tile: React.FC<ITileProps> = ({
|
|||
</div>
|
||||
</React.Fragment>;
|
||||
|
||||
let childToggle;
|
||||
let childSection;
|
||||
let childToggle: JSX.Element;
|
||||
let childSection: JSX.Element;
|
||||
let onKeyDown: KeyboardEventHandler;
|
||||
if (children) {
|
||||
// the chevron is purposefully a div rather than a button as it should be ignored for a11y
|
||||
childToggle = <div
|
||||
|
@ -185,25 +199,74 @@ const Tile: React.FC<ITileProps> = ({
|
|||
toggleShowChildren();
|
||||
}}
|
||||
/>;
|
||||
|
||||
if (showChildren) {
|
||||
childSection = <div className="mx_SpaceRoomDirectory_subspace_children">
|
||||
const onChildrenKeyDown = (e) => {
|
||||
if (e.key === Key.ARROW_LEFT) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
ref.current?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
childSection = <div
|
||||
className="mx_SpaceRoomDirectory_subspace_children"
|
||||
onKeyDown={onChildrenKeyDown}
|
||||
role="group"
|
||||
>
|
||||
{ children }
|
||||
</div>;
|
||||
}
|
||||
|
||||
onKeyDown = (e) => {
|
||||
let handled = false;
|
||||
|
||||
switch (e.key) {
|
||||
case Key.ARROW_LEFT:
|
||||
if (showChildren) {
|
||||
handled = true;
|
||||
toggleShowChildren();
|
||||
}
|
||||
break;
|
||||
|
||||
case Key.ARROW_RIGHT:
|
||||
handled = true;
|
||||
if (showChildren) {
|
||||
const childSection = ref.current?.nextElementSibling;
|
||||
childSection?.querySelector<HTMLDivElement>(".mx_SpaceRoomDirectory_roomTile")?.focus();
|
||||
} else {
|
||||
toggleShowChildren();
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (handled) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return <>
|
||||
return <li
|
||||
className="mx_SpaceRoomDirectory_roomTileWrapper"
|
||||
role="treeitem"
|
||||
aria-expanded={children ? showChildren : undefined}
|
||||
>
|
||||
<AccessibleButton
|
||||
className={classNames("mx_SpaceRoomDirectory_roomTile", {
|
||||
mx_SpaceRoomDirectory_subspace: room.room_type === RoomType.Space,
|
||||
})}
|
||||
onClick={(hasPermissions && onToggleClick) ? onToggleClick : onPreviewClick}
|
||||
onKeyDown={onKeyDown}
|
||||
inputRef={ref}
|
||||
onFocus={onFocus}
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
>
|
||||
{ content }
|
||||
{ childToggle }
|
||||
</AccessibleButton>
|
||||
{ childSection }
|
||||
</>;
|
||||
</li>;
|
||||
};
|
||||
|
||||
export const showRoom = (room: ISpaceSummaryRoom, viaServers?: string[], autoJoin = false) => {
|
||||
|
@ -414,176 +477,196 @@ export const SpaceHierarchy: React.FC<IHierarchyProps> = ({
|
|||
return <p>{ _t("Your server does not support showing space hierarchies.") }</p>;
|
||||
}
|
||||
|
||||
let content;
|
||||
if (roomsMap) {
|
||||
const numRooms = Array.from(roomsMap.values()).filter(r => !r.room_type).length;
|
||||
const numSpaces = roomsMap.size - numRooms - 1; // -1 at the end to exclude the space we are looking at
|
||||
|
||||
let countsStr;
|
||||
if (numSpaces > 1) {
|
||||
countsStr = _t("%(count)s rooms and %(numSpaces)s spaces", { count: numRooms, numSpaces });
|
||||
} else if (numSpaces > 0) {
|
||||
countsStr = _t("%(count)s rooms and 1 space", { count: numRooms, numSpaces });
|
||||
} else {
|
||||
countsStr = _t("%(count)s rooms", { count: numRooms, numSpaces });
|
||||
const onKeyDown = (ev: KeyboardEvent, state: IState) => {
|
||||
if (ev.key === Key.ARROW_DOWN && ev.currentTarget.classList.contains("mx_SpaceRoomDirectory_search")) {
|
||||
state.refs[0]?.current?.focus();
|
||||
}
|
||||
|
||||
let manageButtons;
|
||||
if (space.getMyMembership() === "join" && space.currentState.maySendStateEvent(EventType.SpaceChild, userId)) {
|
||||
const selectedRelations = Array.from(selected.keys()).flatMap(parentId => {
|
||||
return [...selected.get(parentId).values()].map(childId => [parentId, childId]) as [string, string][];
|
||||
});
|
||||
|
||||
const selectionAllSuggested = selectedRelations.every(([parentId, childId]) => {
|
||||
return parentChildMap.get(parentId)?.get(childId)?.content.suggested;
|
||||
});
|
||||
|
||||
const disabled = !selectedRelations.length || removing || saving;
|
||||
|
||||
let Button: React.ComponentType<React.ComponentProps<typeof AccessibleButton>> = AccessibleButton;
|
||||
let props = {};
|
||||
if (!selectedRelations.length) {
|
||||
Button = AccessibleTooltipButton;
|
||||
props = {
|
||||
tooltip: _t("Select a room below first"),
|
||||
yOffset: -40,
|
||||
};
|
||||
}
|
||||
|
||||
manageButtons = <>
|
||||
<Button
|
||||
{...props}
|
||||
onClick={async () => {
|
||||
setRemoving(true);
|
||||
try {
|
||||
for (const [parentId, childId] of selectedRelations) {
|
||||
await cli.sendStateEvent(parentId, EventType.SpaceChild, {}, childId);
|
||||
parentChildMap.get(parentId).delete(childId);
|
||||
if (parentChildMap.get(parentId).size > 0) {
|
||||
parentChildMap.set(parentId, new Map(parentChildMap.get(parentId)));
|
||||
} else {
|
||||
parentChildMap.delete(parentId);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
setError(_t("Failed to remove some rooms. Try again later"));
|
||||
}
|
||||
setRemoving(false);
|
||||
}}
|
||||
kind="danger_outline"
|
||||
disabled={disabled}
|
||||
>
|
||||
{ removing ? _t("Removing...") : _t("Remove") }
|
||||
</Button>
|
||||
<Button
|
||||
{...props}
|
||||
onClick={async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
for (const [parentId, childId] of selectedRelations) {
|
||||
const suggested = !selectionAllSuggested;
|
||||
const existingContent = parentChildMap.get(parentId)?.get(childId)?.content;
|
||||
if (!existingContent || existingContent.suggested === suggested) continue;
|
||||
|
||||
const content = {
|
||||
...existingContent,
|
||||
suggested: !selectionAllSuggested,
|
||||
};
|
||||
|
||||
await cli.sendStateEvent(parentId, EventType.SpaceChild, content, childId);
|
||||
|
||||
parentChildMap.get(parentId).get(childId).content = content;
|
||||
parentChildMap.set(parentId, new Map(parentChildMap.get(parentId)));
|
||||
}
|
||||
} catch (e) {
|
||||
setError("Failed to update some suggestions. Try again later");
|
||||
}
|
||||
setSaving(false);
|
||||
setSelected(new Map());
|
||||
}}
|
||||
kind="primary_outline"
|
||||
disabled={disabled}
|
||||
>
|
||||
{ saving
|
||||
? _t("Saving...")
|
||||
: (selectionAllSuggested ? _t("Mark as not suggested") : _t("Mark as suggested"))
|
||||
}
|
||||
</Button>
|
||||
</>;
|
||||
}
|
||||
|
||||
let results;
|
||||
if (roomsMap.size) {
|
||||
const hasPermissions = space?.currentState.maySendStateEvent(EventType.SpaceChild, cli.getUserId());
|
||||
|
||||
results = <>
|
||||
<HierarchyLevel
|
||||
spaceId={space.roomId}
|
||||
rooms={roomsMap}
|
||||
relations={parentChildMap}
|
||||
parents={new Set()}
|
||||
selectedMap={selected}
|
||||
onToggleClick={hasPermissions ? (parentId, childId) => {
|
||||
setError("");
|
||||
if (!selected.has(parentId)) {
|
||||
setSelected(new Map(selected.set(parentId, new Set([childId]))));
|
||||
return;
|
||||
}
|
||||
|
||||
const parentSet = selected.get(parentId);
|
||||
if (!parentSet.has(childId)) {
|
||||
setSelected(new Map(selected.set(parentId, new Set([...parentSet, childId]))));
|
||||
return;
|
||||
}
|
||||
|
||||
parentSet.delete(childId);
|
||||
setSelected(new Map(selected.set(parentId, new Set(parentSet))));
|
||||
} : undefined}
|
||||
onViewRoomClick={(roomId, autoJoin) => {
|
||||
showRoom(roomsMap.get(roomId), Array.from(viaMap.get(roomId) || []), autoJoin);
|
||||
}}
|
||||
/>
|
||||
{ children && <hr /> }
|
||||
</>;
|
||||
} else {
|
||||
results = <div className="mx_SpaceRoomDirectory_noResults">
|
||||
<h3>{ _t("No results found") }</h3>
|
||||
<div>{ _t("You may want to try a different search or check for typos.") }</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
content = <>
|
||||
<div className="mx_SpaceRoomDirectory_listHeader">
|
||||
{ countsStr }
|
||||
<span>
|
||||
{ additionalButtons }
|
||||
{ manageButtons }
|
||||
</span>
|
||||
</div>
|
||||
{ error && <div className="mx_SpaceRoomDirectory_error">
|
||||
{ error }
|
||||
</div> }
|
||||
<AutoHideScrollbar className="mx_SpaceRoomDirectory_list">
|
||||
{ results }
|
||||
{ children }
|
||||
</AutoHideScrollbar>
|
||||
</>;
|
||||
} else {
|
||||
content = <Spinner />;
|
||||
}
|
||||
};
|
||||
|
||||
// TODO loading state/error state
|
||||
return <>
|
||||
<SearchBox
|
||||
className="mx_textinput_icon mx_textinput_search"
|
||||
placeholder={_t("Search names and descriptions")}
|
||||
onSearch={setQuery}
|
||||
autoFocus={true}
|
||||
initialValue={initialText}
|
||||
/>
|
||||
return <RovingTabIndexProvider onKeyDown={onKeyDown} handleHomeEnd handleUpDown>
|
||||
{ ({ onKeyDownHandler }) => {
|
||||
let content;
|
||||
if (roomsMap) {
|
||||
const numRooms = Array.from(roomsMap.values()).filter(r => !r.room_type).length;
|
||||
const numSpaces = roomsMap.size - numRooms - 1; // -1 at the end to exclude the space we are looking at
|
||||
|
||||
{ content }
|
||||
</>;
|
||||
let countsStr;
|
||||
if (numSpaces > 1) {
|
||||
countsStr = _t("%(count)s rooms and %(numSpaces)s spaces", { count: numRooms, numSpaces });
|
||||
} else if (numSpaces > 0) {
|
||||
countsStr = _t("%(count)s rooms and 1 space", { count: numRooms, numSpaces });
|
||||
} else {
|
||||
countsStr = _t("%(count)s rooms", { count: numRooms, numSpaces });
|
||||
}
|
||||
|
||||
let manageButtons;
|
||||
if (space.getMyMembership() === "join" &&
|
||||
space.currentState.maySendStateEvent(EventType.SpaceChild, userId)
|
||||
) {
|
||||
const selectedRelations = Array.from(selected.keys()).flatMap(parentId => {
|
||||
return [
|
||||
...selected.get(parentId).values(),
|
||||
].map(childId => [parentId, childId]) as [string, string][];
|
||||
});
|
||||
|
||||
const selectionAllSuggested = selectedRelations.every(([parentId, childId]) => {
|
||||
return parentChildMap.get(parentId)?.get(childId)?.content.suggested;
|
||||
});
|
||||
|
||||
const disabled = !selectedRelations.length || removing || saving;
|
||||
|
||||
let Button: React.ComponentType<React.ComponentProps<typeof AccessibleButton>> = AccessibleButton;
|
||||
let props = {};
|
||||
if (!selectedRelations.length) {
|
||||
Button = AccessibleTooltipButton;
|
||||
props = {
|
||||
tooltip: _t("Select a room below first"),
|
||||
yOffset: -40,
|
||||
};
|
||||
}
|
||||
|
||||
manageButtons = <>
|
||||
<Button
|
||||
{...props}
|
||||
onClick={async () => {
|
||||
setRemoving(true);
|
||||
try {
|
||||
for (const [parentId, childId] of selectedRelations) {
|
||||
await cli.sendStateEvent(parentId, EventType.SpaceChild, {}, childId);
|
||||
parentChildMap.get(parentId).delete(childId);
|
||||
if (parentChildMap.get(parentId).size > 0) {
|
||||
parentChildMap.set(parentId, new Map(parentChildMap.get(parentId)));
|
||||
} else {
|
||||
parentChildMap.delete(parentId);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
setError(_t("Failed to remove some rooms. Try again later"));
|
||||
}
|
||||
setRemoving(false);
|
||||
}}
|
||||
kind="danger_outline"
|
||||
disabled={disabled}
|
||||
>
|
||||
{ removing ? _t("Removing...") : _t("Remove") }
|
||||
</Button>
|
||||
<Button
|
||||
{...props}
|
||||
onClick={async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
for (const [parentId, childId] of selectedRelations) {
|
||||
const suggested = !selectionAllSuggested;
|
||||
const existingContent = parentChildMap.get(parentId)?.get(childId)?.content;
|
||||
if (!existingContent || existingContent.suggested === suggested) continue;
|
||||
|
||||
const content = {
|
||||
...existingContent,
|
||||
suggested: !selectionAllSuggested,
|
||||
};
|
||||
|
||||
await cli.sendStateEvent(parentId, EventType.SpaceChild, content, childId);
|
||||
|
||||
parentChildMap.get(parentId).get(childId).content = content;
|
||||
parentChildMap.set(parentId, new Map(parentChildMap.get(parentId)));
|
||||
}
|
||||
} catch (e) {
|
||||
setError("Failed to update some suggestions. Try again later");
|
||||
}
|
||||
setSaving(false);
|
||||
setSelected(new Map());
|
||||
}}
|
||||
kind="primary_outline"
|
||||
disabled={disabled}
|
||||
>
|
||||
{ saving
|
||||
? _t("Saving...")
|
||||
: (selectionAllSuggested ? _t("Mark as not suggested") : _t("Mark as suggested"))
|
||||
}
|
||||
</Button>
|
||||
</>;
|
||||
}
|
||||
|
||||
let results;
|
||||
if (roomsMap.size) {
|
||||
const hasPermissions = space?.currentState.maySendStateEvent(EventType.SpaceChild, cli.getUserId());
|
||||
|
||||
results = <>
|
||||
<HierarchyLevel
|
||||
spaceId={space.roomId}
|
||||
rooms={roomsMap}
|
||||
relations={parentChildMap}
|
||||
parents={new Set()}
|
||||
selectedMap={selected}
|
||||
onToggleClick={hasPermissions ? (parentId, childId) => {
|
||||
setError("");
|
||||
if (!selected.has(parentId)) {
|
||||
setSelected(new Map(selected.set(parentId, new Set([childId]))));
|
||||
return;
|
||||
}
|
||||
|
||||
const parentSet = selected.get(parentId);
|
||||
if (!parentSet.has(childId)) {
|
||||
setSelected(new Map(selected.set(parentId, new Set([...parentSet, childId]))));
|
||||
return;
|
||||
}
|
||||
|
||||
parentSet.delete(childId);
|
||||
setSelected(new Map(selected.set(parentId, new Set(parentSet))));
|
||||
} : undefined}
|
||||
onViewRoomClick={(roomId, autoJoin) => {
|
||||
showRoom(roomsMap.get(roomId), Array.from(viaMap.get(roomId) || []), autoJoin);
|
||||
}}
|
||||
/>
|
||||
{ children && <hr /> }
|
||||
</>;
|
||||
} else {
|
||||
results = <div className="mx_SpaceRoomDirectory_noResults">
|
||||
<h3>{ _t("No results found") }</h3>
|
||||
<div>{ _t("You may want to try a different search or check for typos.") }</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
content = <>
|
||||
<div className="mx_SpaceRoomDirectory_listHeader">
|
||||
{ countsStr }
|
||||
<span>
|
||||
{ additionalButtons }
|
||||
{ manageButtons }
|
||||
</span>
|
||||
</div>
|
||||
{ error && <div className="mx_SpaceRoomDirectory_error">
|
||||
{ error }
|
||||
</div> }
|
||||
<AutoHideScrollbar
|
||||
className="mx_SpaceRoomDirectory_list"
|
||||
onKeyDown={onKeyDownHandler}
|
||||
role="tree"
|
||||
aria-label={_t("Space")}
|
||||
>
|
||||
{ results }
|
||||
{ children }
|
||||
</AutoHideScrollbar>
|
||||
</>;
|
||||
} else {
|
||||
content = <Spinner />;
|
||||
}
|
||||
|
||||
return <>
|
||||
<SearchBox
|
||||
className="mx_SpaceRoomDirectory_search mx_textinput_icon mx_textinput_search"
|
||||
placeholder={_t("Search names and descriptions")}
|
||||
onSearch={setQuery}
|
||||
autoFocus={true}
|
||||
initialValue={initialText}
|
||||
onKeyDown={onKeyDownHandler}
|
||||
/>
|
||||
|
||||
{ content }
|
||||
</>;
|
||||
} }
|
||||
</RovingTabIndexProvider>;
|
||||
};
|
||||
|
||||
interface IProps {
|
||||
|
|
|
@ -74,6 +74,10 @@ import { BetaPill } from "../views/beta/BetaCard";
|
|||
import { UserTab } from "../views/dialogs/UserSettingsDialog";
|
||||
import { EffectiveMembership, getEffectiveMembership } from "../../utils/membership";
|
||||
import { SpaceFeedbackPrompt } from "../views/spaces/SpaceCreateMenu";
|
||||
import { CreateEventField, IGroupSummary } from "../views/dialogs/CreateSpaceFromCommunityDialog";
|
||||
import { useAsyncMemo } from "../../hooks/useAsyncMemo";
|
||||
import Spinner from "../views/elements/Spinner";
|
||||
import GroupAvatar from "../views/avatars/GroupAvatar";
|
||||
|
||||
interface IProps {
|
||||
space: Room;
|
||||
|
@ -158,7 +162,33 @@ const onBetaClick = () => {
|
|||
});
|
||||
};
|
||||
|
||||
const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) => {
|
||||
// XXX: temporary community migration component
|
||||
const GroupTile = ({ groupId }: { groupId: string }) => {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
const groupSummary = useAsyncMemo<IGroupSummary>(() => cli.getGroupSummary(groupId), [cli, groupId]);
|
||||
|
||||
if (!groupSummary) return <Spinner />;
|
||||
|
||||
return <>
|
||||
<GroupAvatar
|
||||
groupId={groupId}
|
||||
groupName={groupSummary.profile.name}
|
||||
groupAvatarUrl={groupSummary.profile.avatar_url}
|
||||
width={16}
|
||||
height={16}
|
||||
resizeMethod='crop'
|
||||
/>
|
||||
{ groupSummary.profile.name }
|
||||
</>;
|
||||
};
|
||||
|
||||
interface ISpacePreviewProps {
|
||||
space: Room;
|
||||
onJoinButtonClicked(): void;
|
||||
onRejectButtonClicked(): void;
|
||||
}
|
||||
|
||||
const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }: ISpacePreviewProps) => {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
const myMembership = useMyRoomMembership(space);
|
||||
|
||||
|
@ -192,11 +222,11 @@ const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) =>
|
|||
|
||||
if (inviteSender) {
|
||||
inviterSection = <div className="mx_SpaceRoomView_preview_inviter">
|
||||
<MemberAvatar member={inviter} width={32} height={32} />
|
||||
<MemberAvatar member={inviter} fallbackUserId={inviteSender} width={32} height={32} />
|
||||
<div>
|
||||
<div className="mx_SpaceRoomView_preview_inviter_name">
|
||||
{ _t("<inviter/> invites you", {}, {
|
||||
inviter: () => <b>{ inviter.name || inviteSender }</b>,
|
||||
inviter: () => <b>{ inviter?.name || inviteSender }</b>,
|
||||
}) }
|
||||
</div>
|
||||
{ inviter ? <div className="mx_SpaceRoomView_preview_inviter_mxid">
|
||||
|
@ -270,8 +300,18 @@ const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) =>
|
|||
</div>;
|
||||
}
|
||||
|
||||
let migratedCommunitySection: JSX.Element;
|
||||
const createContent = space.currentState.getStateEvents(EventType.RoomCreate, "")?.getContent();
|
||||
if (createContent[CreateEventField]) {
|
||||
migratedCommunitySection = <div className="mx_SpaceRoomView_preview_migratedCommunity">
|
||||
{ _t("Created from <Community />", {}, {
|
||||
Community: () => <GroupTile groupId={createContent[CreateEventField]} />,
|
||||
}) }
|
||||
</div>;
|
||||
}
|
||||
|
||||
return <div className="mx_SpaceRoomView_preview">
|
||||
<BetaPill onClick={onBetaClick} />
|
||||
{ migratedCommunitySection }
|
||||
{ inviterSection }
|
||||
<RoomAvatar room={space} height={80} width={80} viewAvatarOnClick={true} />
|
||||
<h1 className="mx_SpaceRoomView_preview_name">
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
|
|
|
@ -14,8 +14,9 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import AccessibleButton from "../elements/AccessibleButton";
|
||||
import * as React from "react";
|
||||
import { createRef } from "react";
|
||||
import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton";
|
||||
import { ContextMenu, IProps as IContextMenuProps } from '../../structures/ContextMenu';
|
||||
import { MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
|
||||
import Field from "../elements/Field";
|
||||
|
@ -32,6 +33,8 @@ interface IState {
|
|||
|
||||
@replaceableComponent("views.context_menus.DialpadContextMenu")
|
||||
export default class DialpadContextMenu extends React.Component<IProps, IState> {
|
||||
private numberEntryFieldRef: React.RefObject<Field> = createRef();
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
|
@ -40,9 +43,16 @@ export default class DialpadContextMenu extends React.Component<IProps, IState>
|
|||
};
|
||||
}
|
||||
|
||||
onDigitPress = (digit) => {
|
||||
onDigitPress = (digit: string, ev: ButtonEvent) => {
|
||||
this.props.call.sendDtmfDigit(digit);
|
||||
this.setState({ value: this.state.value + digit });
|
||||
|
||||
// Keep the number field focused so that keyboard entry is still available
|
||||
// However, don't focus if this wasn't the result of directly clicking on the button,
|
||||
// i.e someone using keyboard navigation.
|
||||
if (ev.type === "click") {
|
||||
this.numberEntryFieldRef.current?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
onCancelClick = () => {
|
||||
|
@ -68,6 +78,7 @@ export default class DialpadContextMenu extends React.Component<IProps, IState>
|
|||
</div>
|
||||
<div className="mx_DialPadContextMenu_header">
|
||||
<Field
|
||||
ref={this.numberEntryFieldRef}
|
||||
className="mx_DialPadContextMenu_dialled"
|
||||
value={this.state.value}
|
||||
autoFocus={true}
|
||||
|
|
|
@ -24,6 +24,8 @@ import { MenuItem } from "../../structures/ContextMenu";
|
|||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import GroupFilterOrderStore from "../../../stores/GroupFilterOrderStore";
|
||||
import { createSpaceFromCommunity } from "../../../utils/space";
|
||||
import GroupStore from "../../../stores/GroupStore";
|
||||
|
||||
@replaceableComponent("views.context_menus.TagTileContextMenu")
|
||||
export default class TagTileContextMenu extends React.Component {
|
||||
|
@ -49,6 +51,11 @@ export default class TagTileContextMenu extends React.Component {
|
|||
this.props.onFinished();
|
||||
};
|
||||
|
||||
_onCreateSpaceClick = () => {
|
||||
createSpaceFromCommunity(this.context, this.props.tag);
|
||||
this.props.onFinished();
|
||||
};
|
||||
|
||||
_onMoveUp = () => {
|
||||
dis.dispatch(TagOrderActions.moveTag(this.context, this.props.tag, this.props.index - 1));
|
||||
this.props.onFinished();
|
||||
|
@ -77,6 +84,16 @@ export default class TagTileContextMenu extends React.Component {
|
|||
);
|
||||
}
|
||||
|
||||
let createSpaceOption;
|
||||
if (GroupStore.isUserPrivileged(this.props.tag)) {
|
||||
createSpaceOption = <>
|
||||
<hr className="mx_TagTileContextMenu_separator" role="separator" />
|
||||
<MenuItem className="mx_TagTileContextMenu_item mx_TagTileContextMenu_createSpace" onClick={this._onCreateSpaceClick}>
|
||||
{ _t("Create Space") }
|
||||
</MenuItem>
|
||||
</>;
|
||||
}
|
||||
|
||||
return <div>
|
||||
<MenuItem className="mx_TagTileContextMenu_item mx_TagTileContextMenu_viewCommunity" onClick={this._onViewCommunityClick}>
|
||||
{ _t('View Community') }
|
||||
|
@ -88,6 +105,7 @@ export default class TagTileContextMenu extends React.Component {
|
|||
<MenuItem className="mx_TagTileContextMenu_item mx_TagTileContextMenu_hideCommunity" onClick={this._onRemoveClick}>
|
||||
{ _t("Unpin") }
|
||||
</MenuItem>
|
||||
{ createSpaceOption }
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -116,8 +116,8 @@ export default class CreateRoomDialog extends React.Component<IProps, IState> {
|
|||
opts.associatedWithCommunity = CommunityPrototypeStore.instance.getSelectedCommunityId();
|
||||
}
|
||||
|
||||
opts.parentSpace = this.props.parentSpace;
|
||||
if (this.props.parentSpace && this.state.joinRule === JoinRule.Restricted) {
|
||||
opts.parentSpace = this.props.parentSpace;
|
||||
opts.joinRule = JoinRule.Restricted;
|
||||
}
|
||||
|
||||
|
|
340
src/components/views/dialogs/CreateSpaceFromCommunityDialog.tsx
Normal file
340
src/components/views/dialogs/CreateSpaceFromCommunityDialog.tsx
Normal file
|
@ -0,0 +1,340 @@
|
|||
/*
|
||||
Copyright 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { JoinRule } from "matrix-js-sdk/src/@types/partials";
|
||||
import { EventType } from "matrix-js-sdk/src/@types/event";
|
||||
import { MatrixClient } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { _t } from '../../../languageHandler';
|
||||
import BaseDialog from "./BaseDialog";
|
||||
import AccessibleButton from "../elements/AccessibleButton";
|
||||
import { createSpace, SpaceCreateForm } from "../spaces/SpaceCreateMenu";
|
||||
import JoinRuleDropdown from "../elements/JoinRuleDropdown";
|
||||
import Field from "../elements/Field";
|
||||
import RoomAliasField from "../elements/RoomAliasField";
|
||||
import { GroupMember } from "../right_panel/UserInfo";
|
||||
import { parseMembersResponse, parseRoomsResponse } from "../../../stores/GroupStore";
|
||||
import { calculateRoomVia, makeRoomPermalink } from "../../../utils/permalinks/Permalinks";
|
||||
import { useAsyncMemo } from "../../../hooks/useAsyncMemo";
|
||||
import Spinner from "../elements/Spinner";
|
||||
import { mediaFromMxc } from "../../../customisations/Media";
|
||||
import SpaceStore from "../../../stores/SpaceStore";
|
||||
import Modal from "../../../Modal";
|
||||
import InfoDialog from "./InfoDialog";
|
||||
import dis from "../../../dispatcher/dispatcher";
|
||||
import { Action } from "../../../dispatcher/actions";
|
||||
import { UserTab } from "./UserSettingsDialog";
|
||||
import TagOrderActions from "../../../actions/TagOrderActions";
|
||||
|
||||
interface IProps {
|
||||
matrixClient: MatrixClient;
|
||||
groupId: string;
|
||||
onFinished(spaceId?: string): void;
|
||||
}
|
||||
|
||||
export const CreateEventField = "io.element.migrated_from_community";
|
||||
|
||||
interface IGroupRoom {
|
||||
displayname: string;
|
||||
name?: string;
|
||||
roomId: string;
|
||||
canonicalAlias?: string;
|
||||
avatarUrl?: string;
|
||||
topic?: string;
|
||||
numJoinedMembers?: number;
|
||||
worldReadable?: boolean;
|
||||
guestCanJoin?: boolean;
|
||||
isPublic?: boolean;
|
||||
}
|
||||
|
||||
/* eslint-disable camelcase */
|
||||
export interface IGroupSummary {
|
||||
profile: {
|
||||
avatar_url?: string;
|
||||
is_openly_joinable?: boolean;
|
||||
is_public?: boolean;
|
||||
long_description: string;
|
||||
name: string;
|
||||
short_description: string;
|
||||
};
|
||||
rooms_section: {
|
||||
rooms: unknown[];
|
||||
categories: Record<string, unknown>;
|
||||
total_room_count_estimate: number;
|
||||
};
|
||||
user: {
|
||||
is_privileged: boolean;
|
||||
is_public: boolean;
|
||||
is_publicised: boolean;
|
||||
membership: string;
|
||||
};
|
||||
users_section: {
|
||||
users: unknown[];
|
||||
roles: Record<string, unknown>;
|
||||
total_user_count_estimate: number;
|
||||
};
|
||||
}
|
||||
/* eslint-enable camelcase */
|
||||
|
||||
const CreateSpaceFromCommunityDialog: React.FC<IProps> = ({ matrixClient: cli, groupId, onFinished }) => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const [avatar, setAvatar] = useState<File>(null); // undefined means to remove avatar
|
||||
const [name, setName] = useState("");
|
||||
const spaceNameField = useRef<Field>();
|
||||
const [alias, setAlias] = useState("#" + groupId.substring(1, groupId.indexOf(":")) + ":" + cli.getDomain());
|
||||
const spaceAliasField = useRef<RoomAliasField>();
|
||||
const [topic, setTopic] = useState("");
|
||||
const [joinRule, setJoinRule] = useState<JoinRule>(JoinRule.Public);
|
||||
|
||||
const groupSummary = useAsyncMemo<IGroupSummary>(() => cli.getGroupSummary(groupId), [groupId]);
|
||||
useEffect(() => {
|
||||
if (groupSummary) {
|
||||
setName(groupSummary.profile.name || "");
|
||||
setTopic(groupSummary.profile.short_description || "");
|
||||
setJoinRule(groupSummary.profile.is_openly_joinable ? JoinRule.Public : JoinRule.Invite);
|
||||
setLoading(false);
|
||||
}
|
||||
}, [groupSummary]);
|
||||
|
||||
if (loading) {
|
||||
return <Spinner />;
|
||||
}
|
||||
|
||||
const onCreateSpaceClick = async (e) => {
|
||||
e.preventDefault();
|
||||
if (busy) return;
|
||||
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
|
||||
// require & validate the space name field
|
||||
if (!await spaceNameField.current.validate({ allowEmpty: false })) {
|
||||
setBusy(false);
|
||||
spaceNameField.current.focus();
|
||||
spaceNameField.current.validate({ allowEmpty: false, focused: true });
|
||||
return;
|
||||
}
|
||||
// validate the space name alias field but do not require it
|
||||
if (joinRule === JoinRule.Public && !await spaceAliasField.current.validate({ allowEmpty: true })) {
|
||||
setBusy(false);
|
||||
spaceAliasField.current.focus();
|
||||
spaceAliasField.current.validate({ allowEmpty: true, focused: true });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const [rooms, members, invitedMembers] = await Promise.all([
|
||||
cli.getGroupRooms(groupId).then(parseRoomsResponse) as Promise<IGroupRoom[]>,
|
||||
cli.getGroupUsers(groupId).then(parseMembersResponse) as Promise<GroupMember[]>,
|
||||
cli.getGroupInvitedUsers(groupId).then(parseMembersResponse) as Promise<GroupMember[]>,
|
||||
]);
|
||||
|
||||
const viaMap = new Map<string, string[]>();
|
||||
for (const { roomId, canonicalAlias } of rooms) {
|
||||
const room = cli.getRoom(roomId);
|
||||
if (room) {
|
||||
viaMap.set(roomId, calculateRoomVia(room));
|
||||
} else if (canonicalAlias) {
|
||||
try {
|
||||
const { servers } = await cli.getRoomIdForAlias(canonicalAlias);
|
||||
viaMap.set(roomId, servers);
|
||||
} catch (e) {
|
||||
console.warn("Failed to resolve alias during community migration", e);
|
||||
}
|
||||
}
|
||||
|
||||
if (!viaMap.get(roomId)?.length) {
|
||||
// XXX: lets guess the via, this might end up being incorrect.
|
||||
const str = canonicalAlias || roomId;
|
||||
viaMap.set(roomId, [str.substring(1, str.indexOf(":"))]);
|
||||
}
|
||||
}
|
||||
|
||||
const spaceAvatar = avatar !== undefined ? avatar : groupSummary.profile.avatar_url;
|
||||
const roomId = await createSpace(name, joinRule === JoinRule.Public, alias, topic, spaceAvatar, {
|
||||
creation_content: {
|
||||
[CreateEventField]: groupId,
|
||||
},
|
||||
initial_state: rooms.map(({ roomId }) => ({
|
||||
type: EventType.SpaceChild,
|
||||
state_key: roomId,
|
||||
content: {
|
||||
via: viaMap.get(roomId) || [],
|
||||
},
|
||||
})),
|
||||
invite: [...members, ...invitedMembers].map(m => m.userId).filter(m => m !== cli.getUserId()),
|
||||
}, {
|
||||
andView: false,
|
||||
});
|
||||
|
||||
// eagerly remove it from the community panel
|
||||
dis.dispatch(TagOrderActions.removeTag(cli, groupId));
|
||||
|
||||
// don't bother awaiting this, as we don't hugely care if it fails
|
||||
cli.setGroupProfile(groupId, {
|
||||
...groupSummary.profile,
|
||||
long_description: `<a href="${makeRoomPermalink(roomId)}"><h1>` +
|
||||
_t("This community has been upgraded into a Space") + `</h1></a><br />`
|
||||
+ groupSummary.profile.long_description,
|
||||
} as IGroupSummary["profile"]).catch(e => {
|
||||
console.warn("Failed to update community profile during migration", e);
|
||||
});
|
||||
|
||||
onFinished(roomId);
|
||||
|
||||
const onSpaceClick = () => {
|
||||
dis.dispatch({
|
||||
action: "view_room",
|
||||
room_id: roomId,
|
||||
});
|
||||
};
|
||||
|
||||
const onPreferencesClick = () => {
|
||||
dis.dispatch({
|
||||
action: Action.ViewUserSettings,
|
||||
initialTabId: UserTab.Preferences,
|
||||
});
|
||||
};
|
||||
|
||||
let spacesDisabledCopy;
|
||||
if (!SpaceStore.spacesEnabled) {
|
||||
spacesDisabledCopy = _t("To view Spaces, hide communities in <a>Preferences</a>", {}, {
|
||||
a: sub => <AccessibleButton onClick={onPreferencesClick} kind="link">{ sub }</AccessibleButton>,
|
||||
});
|
||||
}
|
||||
|
||||
Modal.createDialog(InfoDialog, {
|
||||
title: _t("Space created"),
|
||||
description: <>
|
||||
<div className="mx_CreateSpaceFromCommunityDialog_SuccessInfoDialog_checkmark" />
|
||||
<p>
|
||||
{ _t("<SpaceName/> has been made and everyone who was a part of the community has " +
|
||||
"been invited to it.", {}, {
|
||||
SpaceName: () => <AccessibleButton onClick={onSpaceClick} kind="link">
|
||||
{ name }
|
||||
</AccessibleButton>,
|
||||
}) }
|
||||
|
||||
{ spacesDisabledCopy }
|
||||
</p>
|
||||
<p>
|
||||
{ _t("To create a Space from another community, just pick the community in Preferences.") }
|
||||
</p>
|
||||
</>,
|
||||
button: _t("Preferences"),
|
||||
onFinished: (openPreferences: boolean) => {
|
||||
if (openPreferences) {
|
||||
onPreferencesClick();
|
||||
}
|
||||
},
|
||||
}, "mx_CreateSpaceFromCommunityDialog_SuccessInfoDialog");
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setError(e);
|
||||
}
|
||||
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
let footer;
|
||||
if (error) {
|
||||
footer = <>
|
||||
<img src={require("../../../../res/img/element-icons/warning-badge.svg")} height="24" width="24" alt="" />
|
||||
|
||||
<span className="mx_CreateSpaceFromCommunityDialog_error">
|
||||
<div className="mx_CreateSpaceFromCommunityDialog_errorHeading">{ _t("Failed to migrate community") }</div>
|
||||
<div className="mx_CreateSpaceFromCommunityDialog_errorCaption">{ _t("Try again") }</div>
|
||||
</span>
|
||||
|
||||
<AccessibleButton className="mx_CreateSpaceFromCommunityDialog_retryButton" onClick={onCreateSpaceClick}>
|
||||
{ _t("Retry") }
|
||||
</AccessibleButton>
|
||||
</>;
|
||||
} else {
|
||||
footer = <>
|
||||
<AccessibleButton kind="primary_outline" disabled={busy} onClick={() => onFinished()}>
|
||||
{ _t("Cancel") }
|
||||
</AccessibleButton>
|
||||
<AccessibleButton kind="primary" disabled={busy} onClick={onCreateSpaceClick}>
|
||||
{ busy ? _t("Creating...") : _t("Create Space") }
|
||||
</AccessibleButton>
|
||||
</>;
|
||||
}
|
||||
|
||||
return <BaseDialog
|
||||
title={_t("Create Space from community")}
|
||||
className="mx_CreateSpaceFromCommunityDialog"
|
||||
onFinished={onFinished}
|
||||
fixedWidth={false}
|
||||
>
|
||||
<div className="mx_CreateSpaceFromCommunityDialog_content">
|
||||
<p>
|
||||
{ _t("A link to the Space will be put in your community description.") }
|
||||
|
||||
{ _t("All rooms will be added and all community members will be invited.") }
|
||||
</p>
|
||||
<p className="mx_CreateSpaceFromCommunityDialog_flairNotice">
|
||||
{ _t("Flair won't be available in Spaces for the foreseeable future.") }
|
||||
</p>
|
||||
|
||||
<SpaceCreateForm
|
||||
busy={busy}
|
||||
onSubmit={onCreateSpaceClick}
|
||||
avatarUrl={groupSummary.profile.avatar_url
|
||||
? mediaFromMxc(groupSummary.profile.avatar_url).getThumbnailOfSourceHttp(80, 80, "crop")
|
||||
: undefined
|
||||
}
|
||||
setAvatar={setAvatar}
|
||||
name={name}
|
||||
setName={setName}
|
||||
nameFieldRef={spaceNameField}
|
||||
topic={topic}
|
||||
setTopic={setTopic}
|
||||
alias={alias}
|
||||
setAlias={setAlias}
|
||||
showAliasField={joinRule === JoinRule.Public}
|
||||
aliasFieldRef={spaceAliasField}
|
||||
>
|
||||
<p>{ _t("This description will be shown to people when they view your space") }</p>
|
||||
<JoinRuleDropdown
|
||||
label={_t("Space visibility")}
|
||||
labelInvite={_t("Private space (invite only)")}
|
||||
labelPublic={_t("Public space")}
|
||||
value={joinRule}
|
||||
onChange={setJoinRule}
|
||||
/>
|
||||
<p>{ joinRule === JoinRule.Public
|
||||
? _t("Open space for anyone, best for communities")
|
||||
: _t("Invite only, best for yourself or teams")
|
||||
}</p>
|
||||
{ joinRule !== JoinRule.Public &&
|
||||
<div className="mx_CreateSpaceFromCommunityDialog_nonPublicSpacer" />
|
||||
}
|
||||
</SpaceCreateForm>
|
||||
</div>
|
||||
|
||||
<div className="mx_CreateSpaceFromCommunityDialog_footer">
|
||||
{ footer }
|
||||
</div>
|
||||
</BaseDialog>;
|
||||
};
|
||||
|
||||
export default CreateSpaceFromCommunityDialog;
|
||||
|
|
@ -16,8 +16,7 @@ limitations under the License.
|
|||
|
||||
import React, { useRef, useState } from "react";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
import { JoinRule, Preset } from "matrix-js-sdk/src/@types/partials";
|
||||
import { RoomType } from "matrix-js-sdk/src/@types/event";
|
||||
import { JoinRule } from "matrix-js-sdk/src/@types/partials";
|
||||
|
||||
import { _t } from '../../../languageHandler';
|
||||
import BaseDialog from "./BaseDialog";
|
||||
|
@ -27,8 +26,7 @@ import { BetaPill } from "../beta/BetaCard";
|
|||
import Field from "../elements/Field";
|
||||
import RoomAliasField from "../elements/RoomAliasField";
|
||||
import SpaceStore from "../../../stores/SpaceStore";
|
||||
import { SpaceCreateForm } from "../spaces/SpaceCreateMenu";
|
||||
import createRoom from "../../../createRoom";
|
||||
import { createSpace, SpaceCreateForm } from "../spaces/SpaceCreateMenu";
|
||||
import { SubspaceSelector } from "./AddExistingToSpaceDialog";
|
||||
import JoinRuleDropdown from "../elements/JoinRuleDropdown";
|
||||
|
||||
|
@ -81,28 +79,7 @@ const CreateSubspaceDialog: React.FC<IProps> = ({ space, onAddExistingSpaceClick
|
|||
}
|
||||
|
||||
try {
|
||||
await createRoom({
|
||||
createOpts: {
|
||||
preset: joinRule === JoinRule.Public ? Preset.PublicChat : Preset.PrivateChat,
|
||||
name,
|
||||
power_level_content_override: {
|
||||
// Only allow Admins to write to the timeline to prevent hidden sync spam
|
||||
events_default: 100,
|
||||
...joinRule === JoinRule.Public ? { invite: 0 } : {},
|
||||
},
|
||||
room_alias_name: joinRule === JoinRule.Public && alias
|
||||
? alias.substr(1, alias.indexOf(":") - 1)
|
||||
: undefined,
|
||||
topic,
|
||||
},
|
||||
avatar,
|
||||
roomType: RoomType.Space,
|
||||
parentSpace,
|
||||
spinner: false,
|
||||
encryption: false,
|
||||
andView: true,
|
||||
inlineErrors: true,
|
||||
});
|
||||
await createSpace(name, joinRule === JoinRule.Public, alias, topic, avatar, {}, { parentSpace });
|
||||
|
||||
onFinished(true);
|
||||
} catch (e) {
|
||||
|
|
|
@ -55,7 +55,7 @@ import { replaceableComponent } from "../../../utils/replaceableComponent";
|
|||
import { mediaFromMxc } from "../../../customisations/Media";
|
||||
import { getAddressType } from "../../../UserAddress";
|
||||
import BaseAvatar from '../avatars/BaseAvatar';
|
||||
import AccessibleButton from '../elements/AccessibleButton';
|
||||
import AccessibleButton, { ButtonEvent } from '../elements/AccessibleButton';
|
||||
import { compare } from '../../../utils/strings';
|
||||
import { IInvite3PID } from "matrix-js-sdk/src/@types/requests";
|
||||
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
|
||||
|
@ -394,6 +394,7 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
|
|||
private closeCopiedTooltip: () => void;
|
||||
private debounceTimer: number = null; // actually number because we're in the browser
|
||||
private editorRef = createRef<HTMLInputElement>();
|
||||
private numberEntryFieldRef: React.RefObject<Field> = createRef();
|
||||
private unmounted = false;
|
||||
|
||||
constructor(props) {
|
||||
|
@ -1283,13 +1284,27 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
|
|||
this.setState({ dialPadValue: ev.currentTarget.value });
|
||||
};
|
||||
|
||||
private onDigitPress = digit => {
|
||||
private onDigitPress = (digit: string, ev: ButtonEvent) => {
|
||||
this.setState({ dialPadValue: this.state.dialPadValue + digit });
|
||||
|
||||
// Keep the number field focused so that keyboard entry is still available
|
||||
// However, don't focus if this wasn't the result of directly clicking on the button,
|
||||
// i.e someone using keyboard navigation.
|
||||
if (ev.type === "click") {
|
||||
this.numberEntryFieldRef.current?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
private onDeletePress = () => {
|
||||
private onDeletePress = (ev: ButtonEvent) => {
|
||||
if (this.state.dialPadValue.length === 0) return;
|
||||
this.setState({ dialPadValue: this.state.dialPadValue.slice(0, -1) });
|
||||
|
||||
// Keep the number field focused so that keyboard entry is still available
|
||||
// However, don't focus if this wasn't the result of directly clicking on the button,
|
||||
// i.e someone using keyboard navigation.
|
||||
if (ev.type === "click") {
|
||||
this.numberEntryFieldRef.current?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
private onTabChange = (tabId: TabId) => {
|
||||
|
@ -1543,6 +1558,7 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
|
|||
let dialPadField;
|
||||
if (this.state.dialPadValue.length !== 0) {
|
||||
dialPadField = <Field
|
||||
ref={this.numberEntryFieldRef}
|
||||
className="mx_InviteDialog_dialPadField"
|
||||
id="dialpad_number"
|
||||
value={this.state.dialPadValue}
|
||||
|
@ -1552,6 +1568,7 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
|
|||
/>;
|
||||
} else {
|
||||
dialPadField = <Field
|
||||
ref={this.numberEntryFieldRef}
|
||||
className="mx_InviteDialog_dialPadField"
|
||||
id="dialpad_number"
|
||||
value={this.state.dialPadValue}
|
||||
|
|
|
@ -114,7 +114,7 @@ export default class UserSettingsDialog extends React.Component<IProps, IState>
|
|||
UserTab.Preferences,
|
||||
_td("Preferences"),
|
||||
"mx_UserSettingsDialog_preferencesIcon",
|
||||
<PreferencesUserSettingsTab />,
|
||||
<PreferencesUserSettingsTab closeSettingsFn={this.props.onFinished} />,
|
||||
));
|
||||
|
||||
if (SettingsStore.getValue(UIFeature.Voip)) {
|
||||
|
|
|
@ -67,7 +67,9 @@ export default function AccessibleButton({
|
|||
...restProps
|
||||
}: IProps) {
|
||||
const newProps: IAccessibleButtonProps = restProps;
|
||||
if (!disabled) {
|
||||
if (disabled) {
|
||||
newProps["aria-disabled"] = true;
|
||||
} else {
|
||||
newProps.onClick = onClick;
|
||||
// We need to consume enter onKeyDown and space onKeyUp
|
||||
// otherwise we are risking also activating other keyboard focusable elements
|
||||
|
@ -118,7 +120,7 @@ export default function AccessibleButton({
|
|||
);
|
||||
|
||||
// React.createElement expects InputHTMLAttributes
|
||||
return React.createElement(element, restProps, children);
|
||||
return React.createElement(element, newProps, children);
|
||||
}
|
||||
|
||||
AccessibleButton.defaultProps = {
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -15,11 +15,11 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import * as React from "react";
|
||||
import AccessibleButton from "./AccessibleButton";
|
||||
import AccessibleButton, { ButtonEvent } from "./AccessibleButton";
|
||||
|
||||
interface IProps {
|
||||
// Callback for when the button is pressed
|
||||
onBackspacePress: () => void;
|
||||
onBackspacePress: (ev: ButtonEvent) => void;
|
||||
}
|
||||
|
||||
export default class DialPadBackspaceButton extends React.PureComponent<IProps> {
|
||||
|
|
|
@ -18,7 +18,7 @@ limitations under the License.
|
|||
import React, { ChangeEvent, createRef, CSSProperties, ReactElement, ReactNode, Ref } from 'react';
|
||||
import classnames from 'classnames';
|
||||
|
||||
import AccessibleButton from './AccessibleButton';
|
||||
import AccessibleButton, { ButtonEvent } from './AccessibleButton';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import { Key } from "../../../Keyboard";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
|
@ -178,7 +178,7 @@ export default class Dropdown extends React.Component<IProps, IState> {
|
|||
this.ignoreEvent = ev;
|
||||
};
|
||||
|
||||
private onInputClick = (ev: React.MouseEvent) => {
|
||||
private onAccessibleButtonClick = (ev: ButtonEvent) => {
|
||||
if (this.props.disabled) return;
|
||||
|
||||
if (!this.state.expanded) {
|
||||
|
@ -186,6 +186,10 @@ export default class Dropdown extends React.Component<IProps, IState> {
|
|||
expanded: true,
|
||||
});
|
||||
ev.preventDefault();
|
||||
} else if ((ev as React.KeyboardEvent).key === Key.ENTER) {
|
||||
// the accessible button consumes enter onKeyDown for firing onClick, so handle it here
|
||||
this.props.onOptionChange(this.state.highlightedOption);
|
||||
this.close();
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -204,7 +208,7 @@ export default class Dropdown extends React.Component<IProps, IState> {
|
|||
this.props.onOptionChange(dropdownKey);
|
||||
};
|
||||
|
||||
private onInputKeyDown = (e: React.KeyboardEvent) => {
|
||||
private onKeyDown = (e: React.KeyboardEvent) => {
|
||||
let handled = true;
|
||||
|
||||
// These keys don't generate keypress events and so needs to be on keyup
|
||||
|
@ -269,7 +273,7 @@ export default class Dropdown extends React.Component<IProps, IState> {
|
|||
private prevOption(optionKey: string): string {
|
||||
const keys = Object.keys(this.childrenByKey);
|
||||
const index = keys.indexOf(optionKey);
|
||||
return keys[(index - 1) % keys.length];
|
||||
return keys[index <= 0 ? keys.length - 1 : (index - 1) % keys.length];
|
||||
}
|
||||
|
||||
private scrollIntoView(node: Element) {
|
||||
|
@ -320,7 +324,6 @@ export default class Dropdown extends React.Component<IProps, IState> {
|
|||
type="text"
|
||||
autoFocus={true}
|
||||
className="mx_Dropdown_option"
|
||||
onKeyDown={this.onInputKeyDown}
|
||||
onChange={this.onInputChange}
|
||||
value={this.state.searchQuery}
|
||||
role="combobox"
|
||||
|
@ -329,6 +332,7 @@ export default class Dropdown extends React.Component<IProps, IState> {
|
|||
aria-owns={`${this.props.id}_listbox`}
|
||||
aria-disabled={this.props.disabled}
|
||||
aria-label={this.props.label}
|
||||
onKeyDown={this.onKeyDown}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -361,13 +365,14 @@ export default class Dropdown extends React.Component<IProps, IState> {
|
|||
return <div className={classnames(dropdownClasses)} ref={this.collectRoot}>
|
||||
<AccessibleButton
|
||||
className="mx_Dropdown_input mx_no_textinput"
|
||||
onClick={this.onInputClick}
|
||||
onClick={this.onAccessibleButtonClick}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={this.state.expanded}
|
||||
disabled={this.props.disabled}
|
||||
inputRef={this.buttonRef}
|
||||
aria-label={this.props.label}
|
||||
aria-describedby={`${this.props.id}_value`}
|
||||
onKeyDown={this.onKeyDown}
|
||||
>
|
||||
{ currentValue }
|
||||
<span className="mx_Dropdown_arrow" />
|
||||
|
|
|
@ -34,7 +34,7 @@ interface IProps {
|
|||
// The list of room members for which to show avatars next to the summary
|
||||
summaryMembers?: RoomMember[];
|
||||
// The text to show as the summary of this event list
|
||||
summaryText?: string;
|
||||
summaryText?: string | JSX.Element;
|
||||
// An array of EventTiles to render when expanded
|
||||
children: ReactNode[];
|
||||
// Called when the event list expansion is toggled
|
||||
|
|
|
@ -25,8 +25,24 @@ import { formatCommaSeparatedList } from '../../../utils/FormattingUtils';
|
|||
import { isValid3pidInvite } from "../../../RoomInvite";
|
||||
import EventListSummary from "./EventListSummary";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import defaultDispatcher from '../../../dispatcher/dispatcher';
|
||||
import { RightPanelPhases } from '../../../stores/RightPanelStorePhases';
|
||||
import { Action } from '../../../dispatcher/actions';
|
||||
import { SetRightPanelPhasePayload } from '../../../dispatcher/payloads/SetRightPanelPhasePayload';
|
||||
import { jsxJoin } from '../../../utils/ReactUtils';
|
||||
import { EventType } from 'matrix-js-sdk/src/@types/event';
|
||||
import { Layout } from '../../../settings/Layout';
|
||||
|
||||
const onPinnedMessagesClick = (): void => {
|
||||
defaultDispatcher.dispatch<SetRightPanelPhasePayload>({
|
||||
action: Action.SetRightPanelPhase,
|
||||
phase: RightPanelPhases.PinnedMessages,
|
||||
allowClose: false,
|
||||
});
|
||||
};
|
||||
|
||||
const SENDER_AS_DISPLAY_NAME_EVENTS = [EventType.RoomServerAcl, EventType.RoomPinnedEvents];
|
||||
|
||||
interface IProps extends Omit<ComponentProps<typeof EventListSummary>, "summaryText" | "summaryMembers"> {
|
||||
// The maximum number of names to show in either each summary e.g. 2 would result "A, B and 234 others left"
|
||||
summaryLength?: number;
|
||||
|
@ -60,6 +76,7 @@ enum TransitionType {
|
|||
ChangedAvatar = "changed_avatar",
|
||||
NoChange = "no_change",
|
||||
ServerAcl = "server_acl",
|
||||
ChangedPins = "pinned_messages"
|
||||
}
|
||||
|
||||
const SEP = ",";
|
||||
|
@ -93,7 +110,10 @@ export default class MemberEventListSummary extends React.Component<IProps> {
|
|||
* `Object.keys(eventAggregates)`.
|
||||
* @returns {string} the textual summary of the aggregated events that occurred.
|
||||
*/
|
||||
private generateSummary(eventAggregates: Record<string, string[]>, orderedTransitionSequences: string[]) {
|
||||
private generateSummary(
|
||||
eventAggregates: Record<string, string[]>,
|
||||
orderedTransitionSequences: string[],
|
||||
): string | JSX.Element {
|
||||
const summaries = orderedTransitionSequences.map((transitions) => {
|
||||
const userNames = eventAggregates[transitions];
|
||||
const nameList = this.renderNameList(userNames);
|
||||
|
@ -122,7 +142,7 @@ export default class MemberEventListSummary extends React.Component<IProps> {
|
|||
return null;
|
||||
}
|
||||
|
||||
return summaries.join(", ");
|
||||
return jsxJoin(summaries, ", ");
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -216,7 +236,11 @@ export default class MemberEventListSummary extends React.Component<IProps> {
|
|||
* @param {number} repeats the number of times the transition was repeated in a row.
|
||||
* @returns {string} the written Human Readable equivalent of the transition.
|
||||
*/
|
||||
private static getDescriptionForTransition(t: TransitionType, userCount: number, repeats: number) {
|
||||
private static getDescriptionForTransition(
|
||||
t: TransitionType,
|
||||
userCount: number,
|
||||
repeats: number,
|
||||
): string | JSX.Element {
|
||||
// The empty interpolations 'severalUsers' and 'oneUser'
|
||||
// are there only to show translators to non-English languages
|
||||
// that the verb is conjugated to plural or singular Subject.
|
||||
|
@ -299,6 +323,15 @@ export default class MemberEventListSummary extends React.Component<IProps> {
|
|||
{ severalUsers: "", count: repeats })
|
||||
: _t("%(oneUser)schanged the server ACLs %(count)s times", { oneUser: "", count: repeats });
|
||||
break;
|
||||
case "pinned_messages":
|
||||
res = (userCount > 1)
|
||||
? _t("%(severalUsers)schanged the <a>pinned messages</a> for the room %(count)s times.",
|
||||
{ severalUsers: "", count: repeats },
|
||||
{ "a": (sub) => <a onClick={onPinnedMessagesClick}> { sub } </a> })
|
||||
: _t("%(oneUser)schanged the <a>pinned messages</a> for the room %(count)s times.",
|
||||
{ oneUser: "", count: repeats },
|
||||
{ "a": (sub) => <a onClick={onPinnedMessagesClick}> { sub } </a> });
|
||||
break;
|
||||
}
|
||||
|
||||
return res;
|
||||
|
@ -317,16 +350,18 @@ export default class MemberEventListSummary extends React.Component<IProps> {
|
|||
* if a transition is not recognised.
|
||||
*/
|
||||
private static getTransition(e: IUserEvents): TransitionType {
|
||||
if (e.mxEvent.getType() === 'm.room.third_party_invite') {
|
||||
const type = e.mxEvent.getType();
|
||||
|
||||
if (type === EventType.RoomThirdPartyInvite) {
|
||||
// Handle 3pid invites the same as invites so they get bundled together
|
||||
if (!isValid3pidInvite(e.mxEvent)) {
|
||||
return TransitionType.InviteWithdrawal;
|
||||
}
|
||||
return TransitionType.Invited;
|
||||
}
|
||||
|
||||
if (e.mxEvent.getType() === 'm.room.server_acl') {
|
||||
} else if (type === EventType.RoomServerAcl) {
|
||||
return TransitionType.ServerAcl;
|
||||
} else if (type === EventType.RoomPinnedEvents) {
|
||||
return TransitionType.ChangedPins;
|
||||
}
|
||||
|
||||
switch (e.mxEvent.getContent().membership) {
|
||||
|
@ -415,22 +450,23 @@ export default class MemberEventListSummary extends React.Component<IProps> {
|
|||
// Object mapping user IDs to an array of IUserEvents
|
||||
const userEvents: Record<string, IUserEvents[]> = {};
|
||||
eventsToRender.forEach((e, index) => {
|
||||
const userId = e.getType() === 'm.room.server_acl' ? e.getSender() : e.getStateKey();
|
||||
const type = e.getType();
|
||||
const userId = type === EventType.RoomServerAcl ? e.getSender() : e.getStateKey();
|
||||
// Initialise a user's events
|
||||
if (!userEvents[userId]) {
|
||||
userEvents[userId] = [];
|
||||
}
|
||||
|
||||
if (e.getType() === 'm.room.server_acl') {
|
||||
if (SENDER_AS_DISPLAY_NAME_EVENTS.includes(type as EventType)) {
|
||||
latestUserAvatarMember.set(userId, e.sender);
|
||||
} else if (e.target) {
|
||||
latestUserAvatarMember.set(userId, e.target);
|
||||
}
|
||||
|
||||
let displayName = userId;
|
||||
if (e.getType() === 'm.room.third_party_invite') {
|
||||
if (type === EventType.RoomThirdPartyInvite) {
|
||||
displayName = e.getContent().display_name;
|
||||
} else if (e.getType() === 'm.room.server_acl') {
|
||||
} else if (SENDER_AS_DISPLAY_NAME_EVENTS.includes(type as EventType)) {
|
||||
displayName = e.sender.name;
|
||||
} else if (e.target) {
|
||||
displayName = e.target.name;
|
||||
|
|
|
@ -25,6 +25,7 @@ import { CallErrorCode, CallState } from 'matrix-js-sdk/src/webrtc/call';
|
|||
import InfoTooltip, { InfoTooltipKind } from '../elements/InfoTooltip';
|
||||
import classNames from 'classnames';
|
||||
import AccessibleTooltipButton from '../elements/AccessibleTooltipButton';
|
||||
import { formatCallTime } from "../../../DateUtils";
|
||||
|
||||
const MAX_NON_NARROW_WIDTH = 400 / 70 * 100;
|
||||
|
||||
|
@ -161,9 +162,14 @@ export default class CallEvent extends React.PureComponent<IProps, IState> {
|
|||
// https://github.com/vector-im/riot-android/issues/2623
|
||||
// Also the correct hangup code as of VoIP v1 (with underscore)
|
||||
// Also, if we don't have a reason
|
||||
const duration = this.props.callEventGrouper.duration;
|
||||
let text = _t("Call ended");
|
||||
if (duration) {
|
||||
text += " • " + formatCallTime(duration);
|
||||
}
|
||||
return (
|
||||
<div className="mx_CallEvent_content">
|
||||
{ _t("Call ended") }
|
||||
{ text }
|
||||
</div>
|
||||
);
|
||||
} else if (hangupReason === CallErrorCode.InviteTimeout) {
|
||||
|
|
|
@ -25,12 +25,14 @@ import SettingsStore from "../../../settings/SettingsStore";
|
|||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
import InlineSpinner from '../elements/InlineSpinner';
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import { mediaFromContent } from "../../../customisations/Media";
|
||||
import { Media, mediaFromContent } from "../../../customisations/Media";
|
||||
import { BLURHASH_FIELD } from "../../../ContentMessages";
|
||||
import { IMediaEventContent } from '../../../customisations/models/IMediaEventContent';
|
||||
import ImageView from '../elements/ImageView';
|
||||
import { SyncState } from 'matrix-js-sdk/src/sync.api';
|
||||
import { IBodyProps } from "./IBodyProps";
|
||||
import classNames from 'classnames';
|
||||
import { CSSTransition, SwitchTransition } from 'react-transition-group';
|
||||
|
||||
interface IState {
|
||||
decryptedUrl?: string;
|
||||
|
@ -157,19 +159,21 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
|
|||
// this is only used as a fallback in case content.info.w/h is missing
|
||||
loadedImageDimensions = { naturalWidth, naturalHeight };
|
||||
}
|
||||
|
||||
this.setState({ imgLoaded: true, loadedImageDimensions });
|
||||
};
|
||||
|
||||
protected getContentUrl(): string {
|
||||
const media = mediaFromContent(this.props.mxEvent.getContent());
|
||||
if (media.isEncrypted) {
|
||||
if (this.media.isEncrypted) {
|
||||
return this.state.decryptedUrl;
|
||||
} else {
|
||||
return media.srcHttp;
|
||||
return this.media.srcHttp;
|
||||
}
|
||||
}
|
||||
|
||||
private get media(): Media {
|
||||
return mediaFromContent(this.props.mxEvent.getContent());
|
||||
}
|
||||
|
||||
protected getThumbUrl(): string {
|
||||
// FIXME: we let images grow as wide as you like, rather than capped to 800x600.
|
||||
// So either we need to support custom timeline widths here, or reimpose the cap, otherwise the
|
||||
|
@ -225,7 +229,7 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
|
|||
info.w > thumbWidth ||
|
||||
info.h > thumbHeight
|
||||
);
|
||||
const isLargeFileSize = info.size > 1*1024*1024; // 1mb
|
||||
const isLargeFileSize = info.size > 1 * 1024 * 1024; // 1mb
|
||||
|
||||
if (isLargeFileSize && isLargerThanThumbnail) {
|
||||
// image is too large physically and bytewise to clutter our timeline so
|
||||
|
@ -347,12 +351,21 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
|
|||
className="mx_MImageBody_thumbnail"
|
||||
src={thumbUrl}
|
||||
ref={this.image}
|
||||
style={{ maxWidth: `min(100%, ${maxWidth}px)` }}
|
||||
// Force the image to be the full size of the container, even if the
|
||||
// pixel size is smaller. The problem here is that we don't know what
|
||||
// thumbnail size the HS is going to give us, but we have to commit to
|
||||
// a container size immediately and not change it when the image loads
|
||||
// or we'll get a scroll jump (or have to leave blank space).
|
||||
// This will obviously result in an upscaled image which will be a bit
|
||||
// blurry. The best fix would be for the HS to advertise what size thumbnails
|
||||
// it guarantees to produce.
|
||||
style={{ height: '100%' }}
|
||||
alt={content.body}
|
||||
onError={this.onImageError}
|
||||
onLoad={this.onImageLoad}
|
||||
onMouseEnter={this.onImageEnter}
|
||||
onMouseLeave={this.onImageLeave} />
|
||||
onMouseLeave={this.onImageLeave}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -365,21 +378,41 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
|
|||
gifLabel = <p className="mx_MImageBody_gifLabel">GIF</p>;
|
||||
}
|
||||
|
||||
const classes = classNames({
|
||||
'mx_MImageBody_thumbnail': true,
|
||||
'mx_MImageBody_thumbnail--blurhash': this.props.mxEvent.getContent().info[BLURHASH_FIELD],
|
||||
});
|
||||
|
||||
// This has incredibly broken types.
|
||||
const C = CSSTransition as any;
|
||||
const thumbnail = (
|
||||
<div className="mx_MImageBody_thumbnail_container" style={{ maxHeight: maxHeight, maxWidth: maxWidth, aspectRatio: `${infoWidth}/${infoHeight}` }}>
|
||||
{ showPlaceholder &&
|
||||
<div
|
||||
className="mx_MImageBody_thumbnail"
|
||||
style={{
|
||||
// Constrain width here so that spinner appears central to the loaded thumbnail
|
||||
maxWidth: `min(100%, ${infoWidth}px)`,
|
||||
}}
|
||||
<SwitchTransition mode="out-in">
|
||||
<C
|
||||
classNames="mx_rtg--fade"
|
||||
key={`img-${showPlaceholder}`}
|
||||
timeout={300}
|
||||
>
|
||||
{ placeholder }
|
||||
</div>
|
||||
}
|
||||
{ /* This weirdly looking div is necessary here, otherwise SwitchTransition fails */ }
|
||||
<div>
|
||||
{ showPlaceholder && <div
|
||||
className={classes}
|
||||
style={{
|
||||
// Constrain width here so that spinner appears central to the loaded thumbnail
|
||||
maxWidth: `min(100%, ${infoWidth}px)`,
|
||||
maxHeight: maxHeight,
|
||||
aspectRatio: `${infoWidth}/${infoHeight}`,
|
||||
}}
|
||||
>
|
||||
{ placeholder }
|
||||
</div> }
|
||||
</div>
|
||||
</C>
|
||||
</SwitchTransition>
|
||||
|
||||
<div style={{ display: !showPlaceholder ? undefined : 'none' }}>
|
||||
<div style={{
|
||||
height: '100%',
|
||||
}}>
|
||||
{ img }
|
||||
{ gifLabel }
|
||||
</div>
|
||||
|
@ -401,7 +434,7 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
|
|||
// Overidden by MStickerBody
|
||||
protected getPlaceholder(width: number, height: number): JSX.Element {
|
||||
const blurhash = this.props.mxEvent.getContent().info[BLURHASH_FIELD];
|
||||
if (blurhash) return <Blurhash hash={blurhash} width={width} height={height} />;
|
||||
if (blurhash) return <Blurhash className="mx_Blurhash" hash={blurhash} width={width} height={height} />;
|
||||
return (
|
||||
<InlineSpinner w={32} h={32} />
|
||||
);
|
||||
|
@ -443,10 +476,12 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
|
|||
const thumbnail = this.messageContent(contentUrl, thumbUrl, content);
|
||||
const fileBody = this.getFileBody();
|
||||
|
||||
return <div className="mx_MImageBody">
|
||||
{ thumbnail }
|
||||
{ fileBody }
|
||||
</div>;
|
||||
return (
|
||||
<div className="mx_MImageBody">
|
||||
{ thumbnail }
|
||||
{ fileBody }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -136,7 +136,8 @@ export default class TextualBody extends React.Component<IBodyProps, IState> {
|
|||
private addCodeExpansionButton(div: HTMLDivElement, pre: HTMLPreElement): void {
|
||||
// Calculate how many percent does the pre element take up.
|
||||
// If it's less than 30% we don't add the expansion button.
|
||||
const percentageOfViewport = pre.offsetHeight / UIStore.instance.windowHeight * 100;
|
||||
// We also round the number as it sometimes can be 29.99...
|
||||
const percentageOfViewport = Math.round(pre.offsetHeight / UIStore.instance.windowHeight * 100);
|
||||
if (percentageOfViewport < 30) return;
|
||||
|
||||
const button = document.createElement("span");
|
||||
|
|
|
@ -851,7 +851,7 @@ const RoomAdminToolsContainer: React.FC<IBaseRoomProps> = ({
|
|||
return <div />;
|
||||
};
|
||||
|
||||
interface GroupMember {
|
||||
export interface GroupMember {
|
||||
userId: string;
|
||||
displayname?: string; // XXX: GroupMember objects are inconsistent :((
|
||||
avatarUrl?: string;
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
/*
|
||||
Copyright 2019 New Vector Ltd
|
||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2019 - 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -32,7 +31,7 @@ import {
|
|||
} from '../../../editor/operations';
|
||||
import { getCaretOffsetAndText, getRangeForSelection } from '../../../editor/dom';
|
||||
import Autocomplete, { generateCompletionDomId } from '../rooms/Autocomplete';
|
||||
import { getAutoCompleteCreator } from '../../../editor/parts';
|
||||
import { getAutoCompleteCreator, Type } from '../../../editor/parts';
|
||||
import { parseEvent, parsePlainTextMessage } from '../../../editor/deserialize';
|
||||
import { renderModel } from '../../../editor/render';
|
||||
import TypingStore from "../../../stores/TypingStore";
|
||||
|
@ -55,6 +54,14 @@ const REGEX_EMOTICON_WHITESPACE = new RegExp('(?:^|\\s)(' + EMOTICON_REGEX.sourc
|
|||
|
||||
const IS_MAC = navigator.platform.indexOf("Mac") !== -1;
|
||||
|
||||
const SURROUND_WITH_CHARACTERS = ["\"", "_", "`", "'", "*", "~", "$"];
|
||||
const SURROUND_WITH_DOUBLE_CHARACTERS = new Map([
|
||||
["(", ")"],
|
||||
["[", "]"],
|
||||
["{", "}"],
|
||||
["<", ">"],
|
||||
]);
|
||||
|
||||
function ctrlShortcutLabel(key: string): string {
|
||||
return (IS_MAC ? "⌘" : "Ctrl") + "+" + key;
|
||||
}
|
||||
|
@ -99,6 +106,7 @@ interface IState {
|
|||
showVisualBell?: boolean;
|
||||
autoComplete?: AutocompleteWrapperModel;
|
||||
completionIndex?: number;
|
||||
surroundWith: boolean;
|
||||
}
|
||||
|
||||
@replaceableComponent("views.rooms.BasicMessageEditor")
|
||||
|
@ -117,12 +125,14 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
|
|||
|
||||
private readonly emoticonSettingHandle: string;
|
||||
private readonly shouldShowPillAvatarSettingHandle: string;
|
||||
private readonly surroundWithHandle: string;
|
||||
private readonly historyManager = new HistoryManager();
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
showPillAvatar: SettingsStore.getValue("Pill.shouldShowPillAvatar"),
|
||||
surroundWith: SettingsStore.getValue("MessageComposerInput.surroundWith"),
|
||||
};
|
||||
|
||||
this.emoticonSettingHandle = SettingsStore.watchSetting('MessageComposerInput.autoReplaceEmoji', null,
|
||||
|
@ -130,6 +140,8 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
|
|||
this.configureEmoticonAutoReplace();
|
||||
this.shouldShowPillAvatarSettingHandle = SettingsStore.watchSetting("Pill.shouldShowPillAvatar", null,
|
||||
this.configureShouldShowPillAvatar);
|
||||
this.surroundWithHandle = SettingsStore.watchSetting("MessageComposerInput.surroundWith", null,
|
||||
this.surroundWithSettingChanged);
|
||||
}
|
||||
|
||||
public componentDidUpdate(prevProps: IProps) {
|
||||
|
@ -157,7 +169,7 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
|
|||
range.expandBackwardsWhile((index, offset) => {
|
||||
const part = model.parts[index];
|
||||
n -= 1;
|
||||
return n >= 0 && (part.type === "plain" || part.type === "pill-candidate");
|
||||
return n >= 0 && (part.type === Type.Plain || part.type === Type.PillCandidate);
|
||||
});
|
||||
const emoticonMatch = REGEX_EMOTICON_WHITESPACE.exec(range.text);
|
||||
if (emoticonMatch) {
|
||||
|
@ -422,6 +434,28 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
|
|||
private onKeyDown = (event: React.KeyboardEvent): void => {
|
||||
const model = this.props.model;
|
||||
let handled = false;
|
||||
|
||||
if (this.state.surroundWith && document.getSelection().type != "Caret") {
|
||||
// This surrounds the selected text with a character. This is
|
||||
// intentionally left out of the keybinding manager as the keybinds
|
||||
// here shouldn't be changeable
|
||||
|
||||
const selectionRange = getRangeForSelection(
|
||||
this.editorRef.current,
|
||||
this.props.model,
|
||||
document.getSelection(),
|
||||
);
|
||||
// trim the range as we want it to exclude leading/trailing spaces
|
||||
selectionRange.trim();
|
||||
|
||||
if ([...SURROUND_WITH_DOUBLE_CHARACTERS.keys(), ...SURROUND_WITH_CHARACTERS].includes(event.key)) {
|
||||
this.historyManager.ensureLastChangesPushed(this.props.model);
|
||||
this.modifiedFlag = true;
|
||||
toggleInlineFormat(selectionRange, event.key, SURROUND_WITH_DOUBLE_CHARACTERS.get(event.key));
|
||||
handled = true;
|
||||
}
|
||||
}
|
||||
|
||||
const action = getKeyBindingsManager().getMessageComposerAction(event);
|
||||
switch (action) {
|
||||
case MessageComposerAction.FormatBold:
|
||||
|
@ -524,9 +558,9 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
|
|||
const range = model.startRange(position);
|
||||
range.expandBackwardsWhile((index, offset, part) => {
|
||||
return part.text[offset] !== " " && part.text[offset] !== "+" && (
|
||||
part.type === "plain" ||
|
||||
part.type === "pill-candidate" ||
|
||||
part.type === "command"
|
||||
part.type === Type.Plain ||
|
||||
part.type === Type.PillCandidate ||
|
||||
part.type === Type.Command
|
||||
);
|
||||
});
|
||||
const { partCreator } = model;
|
||||
|
@ -574,6 +608,11 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
|
|||
this.setState({ showPillAvatar });
|
||||
};
|
||||
|
||||
private surroundWithSettingChanged = () => {
|
||||
const surroundWith = SettingsStore.getValue("MessageComposerInput.surroundWith");
|
||||
this.setState({ surroundWith });
|
||||
};
|
||||
|
||||
componentWillUnmount() {
|
||||
document.removeEventListener("selectionchange", this.onSelectionChange);
|
||||
this.editorRef.current.removeEventListener("input", this.onInput, true);
|
||||
|
@ -581,6 +620,7 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
|
|||
this.editorRef.current.removeEventListener("compositionend", this.onCompositionEnd, true);
|
||||
SettingsStore.unwatchSetting(this.emoticonSettingHandle);
|
||||
SettingsStore.unwatchSetting(this.shouldShowPillAvatarSettingHandle);
|
||||
SettingsStore.unwatchSetting(this.surroundWithHandle);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
|
@ -684,7 +724,7 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
|
|||
<MessageComposerFormatBar ref={this.formatBarRef} onAction={this.onFormatAction} shortcuts={shortcuts} />
|
||||
<div
|
||||
className={classes}
|
||||
contentEditable="true"
|
||||
contentEditable={this.props.disabled ? null : true}
|
||||
tabIndex={0}
|
||||
onBlur={this.onBlur}
|
||||
onFocus={this.onFocus}
|
||||
|
|
|
@ -25,7 +25,7 @@ import { getCaretOffsetAndText } from '../../../editor/dom';
|
|||
import { htmlSerializeIfNeeded, textSerialize, containsEmote, stripEmoteCommand } from '../../../editor/serialize';
|
||||
import { findEditableEvent } from '../../../utils/EventUtils';
|
||||
import { parseEvent } from '../../../editor/deserialize';
|
||||
import { CommandPartCreator, Part, PartCreator } from '../../../editor/parts';
|
||||
import { CommandPartCreator, Part, PartCreator, Type } from '../../../editor/parts';
|
||||
import EditorStateTransfer from '../../../utils/EditorStateTransfer';
|
||||
import BasicMessageComposer from "./BasicMessageComposer";
|
||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
|
@ -242,12 +242,12 @@ export default class EditMessageComposer extends React.Component<IProps, IState>
|
|||
const parts = this.model.parts;
|
||||
const firstPart = parts[0];
|
||||
if (firstPart) {
|
||||
if (firstPart.type === "command" && firstPart.text.startsWith("/") && !firstPart.text.startsWith("//")) {
|
||||
if (firstPart.type === Type.Command && firstPart.text.startsWith("/") && !firstPart.text.startsWith("//")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (firstPart.text.startsWith("/") && !firstPart.text.startsWith("//")
|
||||
&& (firstPart.type === "plain" || firstPart.type === "pill-candidate")) {
|
||||
&& (firstPart.type === Type.Plain || firstPart.type === Type.PillCandidate)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
@ -268,7 +268,7 @@ export default class EditMessageComposer extends React.Component<IProps, IState>
|
|||
private getSlashCommand(): [Command, string, string] {
|
||||
const commandText = this.model.parts.reduce((text, part) => {
|
||||
// use mxid to textify user pills in a command
|
||||
if (part.type === "user-pill") {
|
||||
if (part.type === Type.UserPill) {
|
||||
return text + part.resourceId;
|
||||
}
|
||||
return text + part.text;
|
||||
|
|
|
@ -58,6 +58,7 @@ function ComposerAvatar(props: IComposerAvatarProps) {
|
|||
|
||||
interface ISendButtonProps {
|
||||
onClick: () => void;
|
||||
title?: string; // defaults to something generic
|
||||
}
|
||||
|
||||
function SendButton(props: ISendButtonProps) {
|
||||
|
@ -65,7 +66,7 @@ function SendButton(props: ISendButtonProps) {
|
|||
<AccessibleTooltipButton
|
||||
className="mx_MessageComposer_sendMessage"
|
||||
onClick={props.onClick}
|
||||
title={_t('Send message')}
|
||||
title={props.title ?? _t('Send message')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -401,7 +402,11 @@ export default class MessageComposer extends React.Component<IProps, IState> {
|
|||
|
||||
if (!this.state.isComposerEmpty || this.state.haveRecording) {
|
||||
controls.push(
|
||||
<SendButton key="controls_send" onClick={this.sendMessage} />,
|
||||
<SendButton
|
||||
key="controls_send"
|
||||
onClick={this.sendMessage}
|
||||
title={this.state.haveRecording ? _t("Send voice message") : undefined}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
} else if (this.state.tombstone) {
|
||||
|
|
|
@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import React, { createRef } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import dis from '../../../dispatcher/dispatcher';
|
||||
|
@ -38,6 +38,8 @@ interface IProps {
|
|||
|
||||
@replaceableComponent("views.rooms.ReplyTile")
|
||||
export default class ReplyTile extends React.PureComponent<IProps> {
|
||||
private anchorElement = createRef<HTMLAnchorElement>();
|
||||
|
||||
static defaultProps = {
|
||||
onHeightChanged: () => {},
|
||||
};
|
||||
|
@ -71,7 +73,11 @@ export default class ReplyTile extends React.PureComponent<IProps> {
|
|||
// Following a link within a reply should not dispatch the `view_room` action
|
||||
// so that the browser can direct the user to the correct location
|
||||
// The exception being the link wrapping the reply
|
||||
if (clickTarget.tagName.toLowerCase() !== "a" || clickTarget.closest("a") === null) {
|
||||
if (
|
||||
clickTarget.tagName.toLowerCase() !== "a" ||
|
||||
clickTarget.closest("a") === null ||
|
||||
clickTarget === this.anchorElement.current
|
||||
) {
|
||||
// This allows the permalink to be opened in a new tab/window or copied as
|
||||
// matrix.to, but also for it to enable routing within Riot when clicked.
|
||||
e.preventDefault();
|
||||
|
@ -141,7 +147,7 @@ export default class ReplyTile extends React.PureComponent<IProps> {
|
|||
|
||||
return (
|
||||
<div className={classes}>
|
||||
<a href={permalink} onClick={this.onClick}>
|
||||
<a href={permalink} onClick={this.onClick} ref={this.anchorElement}>
|
||||
{ sender }
|
||||
<EventTileType
|
||||
ref="tile"
|
||||
|
|
|
@ -31,7 +31,7 @@ import {
|
|||
textSerialize,
|
||||
unescapeMessage,
|
||||
} from '../../../editor/serialize';
|
||||
import { CommandPartCreator, Part, PartCreator, SerializedPart } from '../../../editor/parts';
|
||||
import { CommandPartCreator, Part, PartCreator, SerializedPart, Type } from '../../../editor/parts';
|
||||
import BasicMessageComposer from "./BasicMessageComposer";
|
||||
import ReplyThread from "../elements/ReplyThread";
|
||||
import { findEditableEvent } from '../../../utils/EventUtils';
|
||||
|
@ -240,14 +240,14 @@ export default class SendMessageComposer extends React.Component<IProps> {
|
|||
const parts = this.model.parts;
|
||||
const firstPart = parts[0];
|
||||
if (firstPart) {
|
||||
if (firstPart.type === "command" && firstPart.text.startsWith("/") && !firstPart.text.startsWith("//")) {
|
||||
if (firstPart.type === Type.Command && firstPart.text.startsWith("/") && !firstPart.text.startsWith("//")) {
|
||||
return true;
|
||||
}
|
||||
// be extra resilient when somehow the AutocompleteWrapperModel or
|
||||
// CommandPartCreator fails to insert a command part, so we don't send
|
||||
// a command as a message
|
||||
if (firstPart.text.startsWith("/") && !firstPart.text.startsWith("//")
|
||||
&& (firstPart.type === "plain" || firstPart.type === "pill-candidate")) {
|
||||
&& (firstPart.type === Type.Plain || firstPart.type === Type.PillCandidate)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -17,10 +17,7 @@ limitations under the License.
|
|||
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import React, { ReactNode } from "react";
|
||||
import {
|
||||
RecordingState,
|
||||
VoiceRecording,
|
||||
} from "../../../audio/VoiceRecording";
|
||||
import { IUpload, RecordingState, VoiceRecording } from "../../../audio/VoiceRecording";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||
import classNames from "classnames";
|
||||
|
@ -34,6 +31,11 @@ import { MsgType } from "matrix-js-sdk/src/@types/event";
|
|||
import Modal from "../../../Modal";
|
||||
import ErrorDialog from "../dialogs/ErrorDialog";
|
||||
import MediaDeviceHandler, { MediaDeviceKindEnum } from "../../../MediaDeviceHandler";
|
||||
import NotificationBadge from "./NotificationBadge";
|
||||
import { StaticNotificationState } from "../../../stores/notifications/StaticNotificationState";
|
||||
import { NotificationColor } from "../../../stores/notifications/NotificationColor";
|
||||
import InlineSpinner from "../elements/InlineSpinner";
|
||||
import { PlaybackManager } from "../../../audio/PlaybackManager";
|
||||
|
||||
interface IProps {
|
||||
room: Room;
|
||||
|
@ -42,6 +44,7 @@ interface IProps {
|
|||
interface IState {
|
||||
recorder?: VoiceRecording;
|
||||
recordingPhase?: RecordingState;
|
||||
didUploadFail?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -69,9 +72,19 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps,
|
|||
|
||||
await this.state.recorder.stop();
|
||||
|
||||
let upload: IUpload;
|
||||
try {
|
||||
const upload = await this.state.recorder.upload(this.props.room.roomId);
|
||||
upload = await this.state.recorder.upload(this.props.room.roomId);
|
||||
} catch (e) {
|
||||
console.error("Error uploading voice message:", e);
|
||||
|
||||
// Flag error and move on. The recording phase will be reset by the upload function.
|
||||
this.setState({ didUploadFail: true });
|
||||
|
||||
return; // don't dispose the recording: the user has a chance to re-upload
|
||||
}
|
||||
|
||||
try {
|
||||
// noinspection ES6MissingAwait - we don't care if it fails, it'll get queued.
|
||||
MatrixClientPeg.get().sendMessage(this.props.room.roomId, {
|
||||
"body": "Voice message",
|
||||
|
@ -104,12 +117,11 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps,
|
|||
"org.matrix.msc3245.voice": {}, // No content, this is a rendering hint
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Error sending/uploading voice message:", e);
|
||||
Modal.createTrackedDialog('Upload failed', '', ErrorDialog, {
|
||||
title: _t('Upload Failed'),
|
||||
description: _t("The voice message failed to upload."),
|
||||
});
|
||||
return; // don't dispose the recording so the user can retry, maybe
|
||||
console.error("Error sending voice message:", e);
|
||||
|
||||
// Voice message should be in the timeline at this point, so let other things take care
|
||||
// of error handling. We also shouldn't need the recording anymore, so fall through to
|
||||
// disposal.
|
||||
}
|
||||
await this.disposeRecording();
|
||||
}
|
||||
|
@ -118,7 +130,7 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps,
|
|||
await VoiceRecordingStore.instance.disposeRecording();
|
||||
|
||||
// Reset back to no recording, which means no phase (ie: restart component entirely)
|
||||
this.setState({ recorder: null, recordingPhase: null });
|
||||
this.setState({ recorder: null, recordingPhase: null, didUploadFail: false });
|
||||
}
|
||||
|
||||
private onCancel = async () => {
|
||||
|
@ -166,6 +178,9 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps,
|
|||
}
|
||||
|
||||
try {
|
||||
// stop any noises which might be happening
|
||||
await PlaybackManager.instance.playOnly(null);
|
||||
|
||||
const recorder = VoiceRecordingStore.instance.startRecording();
|
||||
await recorder.start();
|
||||
|
||||
|
@ -200,7 +215,7 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps,
|
|||
}
|
||||
|
||||
public render(): ReactNode {
|
||||
let recordingInfo;
|
||||
let stopOrRecordBtn;
|
||||
let deleteButton;
|
||||
if (!this.state.recordingPhase || this.state.recordingPhase === RecordingState.Started) {
|
||||
const classes = classNames({
|
||||
|
@ -209,12 +224,12 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps,
|
|||
'mx_VoiceRecordComposerTile_stop': this.state.recorder?.isRecording,
|
||||
});
|
||||
|
||||
let tooltip = _t("Record a voice message");
|
||||
let tooltip = _t("Send voice message");
|
||||
if (!!this.state.recorder) {
|
||||
tooltip = _t("Stop the recording");
|
||||
tooltip = _t("Stop recording");
|
||||
}
|
||||
|
||||
let stopOrRecordBtn = <AccessibleTooltipButton
|
||||
stopOrRecordBtn = <AccessibleTooltipButton
|
||||
className={classes}
|
||||
onClick={this.onRecordStartEndClick}
|
||||
title={tooltip}
|
||||
|
@ -222,22 +237,41 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps,
|
|||
if (this.state.recorder && !this.state.recorder?.isRecording) {
|
||||
stopOrRecordBtn = null;
|
||||
}
|
||||
|
||||
recordingInfo = stopOrRecordBtn;
|
||||
}
|
||||
|
||||
if (this.state.recorder && this.state.recordingPhase !== RecordingState.Uploading) {
|
||||
deleteButton = <AccessibleTooltipButton
|
||||
className='mx_VoiceRecordComposerTile_delete'
|
||||
title={_t("Delete recording")}
|
||||
title={_t("Delete")}
|
||||
onClick={this.onCancel}
|
||||
/>;
|
||||
}
|
||||
|
||||
let uploadIndicator;
|
||||
if (this.state.recordingPhase === RecordingState.Uploading) {
|
||||
uploadIndicator = <span className='mx_VoiceRecordComposerTile_uploadingState'>
|
||||
<InlineSpinner w={16} h={16} />
|
||||
</span>;
|
||||
} else if (this.state.didUploadFail && this.state.recordingPhase === RecordingState.Ended) {
|
||||
uploadIndicator = <span className='mx_VoiceRecordComposerTile_failedState'>
|
||||
<span className='mx_VoiceRecordComposerTile_uploadState_badge'>
|
||||
{ /* Need to stick the badge in a span to ensure it doesn't create a block component */ }
|
||||
<NotificationBadge
|
||||
notification={StaticNotificationState.forSymbol("!", NotificationColor.Red)}
|
||||
/>
|
||||
</span>
|
||||
<span className='text-warning'>{ _t("Failed to send") }</span>
|
||||
</span>;
|
||||
}
|
||||
|
||||
// The record button (mic icon) is meant to be on the right edge, but we also want the
|
||||
// stop button to be left of the waveform area. Luckily, none of the surrounding UI is
|
||||
// rendered when we're not recording, so the record button ends up in the correct spot.
|
||||
return (<>
|
||||
{ uploadIndicator }
|
||||
{ deleteButton }
|
||||
{ stopOrRecordBtn }
|
||||
{ this.renderWaveformArea() }
|
||||
{ recordingInfo }
|
||||
</>);
|
||||
}
|
||||
}
|
||||
|
|
133
src/components/views/settings/LayoutSwitcher.tsx
Normal file
133
src/components/views/settings/LayoutSwitcher.tsx
Normal file
|
@ -0,0 +1,133 @@
|
|||
/*
|
||||
Copyright 2019 New Vector Ltd
|
||||
Copyright 2019 - 2021 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2021 Šimon Brandner <simon.bra.ag@gmail.com>
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import classNames from "classnames";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import EventTilePreview from "../elements/EventTilePreview";
|
||||
import StyledRadioButton from "../elements/StyledRadioButton";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import { Layout } from "../../../settings/Layout";
|
||||
import { SettingLevel } from "../../../settings/SettingLevel";
|
||||
|
||||
interface IProps {
|
||||
userId: string;
|
||||
displayName: string;
|
||||
avatarUrl: string;
|
||||
messagePreviewText: string;
|
||||
onLayoutChanged?: (layout: Layout) => void;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
layout: Layout;
|
||||
}
|
||||
|
||||
export default class LayoutSwitcher extends React.Component<IProps, IState> {
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
layout: SettingsStore.getValue("layout"),
|
||||
};
|
||||
}
|
||||
|
||||
private onLayoutChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
|
||||
const layout = e.target.value as Layout;
|
||||
|
||||
this.setState({ layout: layout });
|
||||
SettingsStore.setValue("layout", null, SettingLevel.DEVICE, layout);
|
||||
this.props.onLayoutChanged(layout);
|
||||
};
|
||||
|
||||
public render(): JSX.Element {
|
||||
const ircClasses = classNames("mx_LayoutSwitcher_RadioButton", {
|
||||
mx_LayoutSwitcher_RadioButton_selected: this.state.layout == Layout.IRC,
|
||||
});
|
||||
const groupClasses = classNames("mx_LayoutSwitcher_RadioButton", {
|
||||
mx_LayoutSwitcher_RadioButton_selected: this.state.layout == Layout.Group,
|
||||
});
|
||||
const bubbleClasses = classNames("mx_LayoutSwitcher_RadioButton", {
|
||||
mx_LayoutSwitcher_RadioButton_selected: this.state.layout === Layout.Bubble,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mx_SettingsTab_section mx_LayoutSwitcher">
|
||||
<span className="mx_SettingsTab_subheading">
|
||||
{ _t("Message layout") }
|
||||
</span>
|
||||
|
||||
<div className="mx_LayoutSwitcher_RadioButtons">
|
||||
<label className={ircClasses}>
|
||||
<EventTilePreview
|
||||
className="mx_LayoutSwitcher_RadioButton_preview"
|
||||
message={this.props.messagePreviewText}
|
||||
layout={Layout.IRC}
|
||||
userId={this.props.userId}
|
||||
displayName={this.props.displayName}
|
||||
avatarUrl={this.props.avatarUrl}
|
||||
/>
|
||||
<StyledRadioButton
|
||||
name="layout"
|
||||
value={Layout.IRC}
|
||||
checked={this.state.layout === Layout.IRC}
|
||||
onChange={this.onLayoutChange}
|
||||
>
|
||||
{ _t("IRC") }
|
||||
</StyledRadioButton>
|
||||
</label>
|
||||
<label className={groupClasses}>
|
||||
<EventTilePreview
|
||||
className="mx_LayoutSwitcher_RadioButton_preview"
|
||||
message={this.props.messagePreviewText}
|
||||
layout={Layout.Group}
|
||||
userId={this.props.userId}
|
||||
displayName={this.props.displayName}
|
||||
avatarUrl={this.props.avatarUrl}
|
||||
/>
|
||||
<StyledRadioButton
|
||||
name="layout"
|
||||
value={Layout.Group}
|
||||
checked={this.state.layout == Layout.Group}
|
||||
onChange={this.onLayoutChange}
|
||||
>
|
||||
{ _t("Modern") }
|
||||
</StyledRadioButton>
|
||||
</label>
|
||||
<label className={bubbleClasses}>
|
||||
<EventTilePreview
|
||||
className="mx_LayoutSwitcher_RadioButton_preview"
|
||||
message={this.props.messagePreviewText}
|
||||
layout={Layout.Bubble}
|
||||
userId={this.props.userId}
|
||||
displayName={this.props.displayName}
|
||||
avatarUrl={this.props.avatarUrl}
|
||||
/>
|
||||
<StyledRadioButton
|
||||
name="layout"
|
||||
value={Layout.Bubble}
|
||||
checked={this.state.layout == Layout.Bubble}
|
||||
onChange={this.onLayoutChange}
|
||||
>
|
||||
{ _t("Message bubbles") }
|
||||
</StyledRadioButton>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
/*
|
||||
Copyright 2019 New Vector Ltd
|
||||
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2019 - 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -37,10 +37,9 @@ import StyledRadioGroup from "../../../elements/StyledRadioGroup";
|
|||
import { SettingLevel } from "../../../../../settings/SettingLevel";
|
||||
import { UIFeature } from "../../../../../settings/UIFeature";
|
||||
import { Layout } from "../../../../../settings/Layout";
|
||||
import classNames from 'classnames';
|
||||
import StyledRadioButton from '../../../elements/StyledRadioButton';
|
||||
import { replaceableComponent } from "../../../../../utils/replaceableComponent";
|
||||
import { compare } from "../../../../../utils/strings";
|
||||
import LayoutSwitcher from "../../LayoutSwitcher";
|
||||
|
||||
interface IProps {
|
||||
}
|
||||
|
@ -243,17 +242,8 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I
|
|||
this.setState({ customThemeUrl: e.target.value });
|
||||
};
|
||||
|
||||
private onLayoutChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
|
||||
let layout;
|
||||
switch (e.target.value) {
|
||||
case "irc": layout = Layout.IRC; break;
|
||||
case "group": layout = Layout.Group; break;
|
||||
case "bubble": layout = Layout.Bubble; break;
|
||||
}
|
||||
|
||||
private onLayoutChanged = (layout: Layout): void => {
|
||||
this.setState({ layout: layout });
|
||||
|
||||
SettingsStore.setValue("layout", null, SettingLevel.DEVICE, layout);
|
||||
};
|
||||
|
||||
private onIRCLayoutChange = (enabled: boolean) => {
|
||||
|
@ -391,75 +381,6 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I
|
|||
</div>;
|
||||
}
|
||||
|
||||
private renderLayoutSection = () => {
|
||||
return <div className="mx_SettingsTab_section mx_AppearanceUserSettingsTab_Layout">
|
||||
<span className="mx_SettingsTab_subheading">{ _t("Message layout") }</span>
|
||||
|
||||
<div className="mx_AppearanceUserSettingsTab_Layout_RadioButtons">
|
||||
<label className={classNames("mx_AppearanceUserSettingsTab_Layout_RadioButton", {
|
||||
mx_AppearanceUserSettingsTab_Layout_RadioButton_selected: this.state.layout == Layout.IRC,
|
||||
})}>
|
||||
<EventTilePreview
|
||||
className="mx_AppearanceUserSettingsTab_Layout_RadioButton_preview"
|
||||
message={this.MESSAGE_PREVIEW_TEXT}
|
||||
layout={Layout.IRC}
|
||||
userId={this.state.userId}
|
||||
displayName={this.state.displayName}
|
||||
avatarUrl={this.state.avatarUrl}
|
||||
/>
|
||||
<StyledRadioButton
|
||||
name="layout"
|
||||
value="irc"
|
||||
checked={this.state.layout === Layout.IRC}
|
||||
onChange={this.onLayoutChange}
|
||||
>
|
||||
{ _t("IRC") }
|
||||
</StyledRadioButton>
|
||||
</label>
|
||||
<label className={classNames("mx_AppearanceUserSettingsTab_Layout_RadioButton", {
|
||||
mx_AppearanceUserSettingsTab_Layout_RadioButton_selected: this.state.layout == Layout.Group,
|
||||
})}>
|
||||
<EventTilePreview
|
||||
className="mx_AppearanceUserSettingsTab_Layout_RadioButton_preview"
|
||||
message={this.MESSAGE_PREVIEW_TEXT}
|
||||
layout={Layout.Group}
|
||||
userId={this.state.userId}
|
||||
displayName={this.state.displayName}
|
||||
avatarUrl={this.state.avatarUrl}
|
||||
/>
|
||||
<StyledRadioButton
|
||||
name="layout"
|
||||
value="group"
|
||||
checked={this.state.layout == Layout.Group}
|
||||
onChange={this.onLayoutChange}
|
||||
>
|
||||
{ _t("Modern") }
|
||||
</StyledRadioButton>
|
||||
</label>
|
||||
<label className={classNames("mx_AppearanceUserSettingsTab_Layout_RadioButton", {
|
||||
mx_AppearanceUserSettingsTab_Layout_RadioButton_selected: this.state.layout === Layout.Bubble,
|
||||
})}>
|
||||
<EventTilePreview
|
||||
className="mx_AppearanceUserSettingsTab_Layout_RadioButton_preview"
|
||||
message={this.MESSAGE_PREVIEW_TEXT}
|
||||
layout={Layout.Bubble}
|
||||
userId={this.state.userId}
|
||||
displayName={this.state.displayName}
|
||||
avatarUrl={this.state.avatarUrl}
|
||||
/>
|
||||
<StyledRadioButton
|
||||
name="layout"
|
||||
value="bubble"
|
||||
checked={this.state.layout == Layout.Bubble}
|
||||
onChange={this.onLayoutChange}
|
||||
>
|
||||
{ _t("Message bubbles") }
|
||||
</StyledRadioButton>
|
||||
</label>
|
||||
</div>
|
||||
</div>;
|
||||
};
|
||||
|
||||
private renderAdvancedSection() {
|
||||
if (!SettingsStore.getValue(UIFeature.AdvancedSettings)) return null;
|
||||
|
||||
|
@ -527,6 +448,19 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I
|
|||
render() {
|
||||
const brand = SdkConfig.get().brand;
|
||||
|
||||
let layoutSection;
|
||||
if (SettingsStore.getValue("feature_new_layout_switcher")) {
|
||||
layoutSection = (
|
||||
<LayoutSwitcher
|
||||
userId={this.state.userId}
|
||||
displayName={this.state.displayName}
|
||||
avatarUrl={this.state.avatarUrl}
|
||||
messagePreviewText={this.MESSAGE_PREVIEW_TEXT}
|
||||
onLayoutChanged={this.onLayoutChanged}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx_SettingsTab mx_AppearanceUserSettingsTab">
|
||||
<div className="mx_SettingsTab_heading">{ _t("Customise your appearance") }</div>
|
||||
|
@ -534,7 +468,7 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I
|
|||
{ _t("Appearance Settings only affect this %(brand)s session.", { brand }) }
|
||||
</div>
|
||||
{ this.renderThemeSection() }
|
||||
{ SettingsStore.getValue("feature_new_layout_switcher") ? this.renderLayoutSection() : null }
|
||||
{ layoutSection }
|
||||
{ this.renderFontSection() }
|
||||
{ this.renderAdvancedSection() }
|
||||
</div>
|
||||
|
|
|
@ -15,9 +15,9 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import AccessibleButton, { ButtonEvent } from "../../../elements/AccessibleButton";
|
||||
import { _t, getCurrentLanguage } from "../../../../../languageHandler";
|
||||
import { MatrixClientPeg } from "../../../../../MatrixClientPeg";
|
||||
import AccessibleButton from "../../../elements/AccessibleButton";
|
||||
import AccessibleTooltipButton from '../../../elements/AccessibleTooltipButton';
|
||||
import SdkConfig from "../../../../../SdkConfig";
|
||||
import createRoom from "../../../../../createRoom";
|
||||
|
@ -69,6 +69,18 @@ export default class HelpUserSettingsTab extends React.Component<IProps, IState>
|
|||
if (this.closeCopiedTooltip) this.closeCopiedTooltip();
|
||||
}
|
||||
|
||||
private getVersionInfo(): { appVersion: string, olmVersion: string } {
|
||||
const brand = SdkConfig.get().brand;
|
||||
const appVersion = this.state.appVersion || 'unknown';
|
||||
let olmVersion = MatrixClientPeg.get().olmVersion;
|
||||
olmVersion = olmVersion ? `${olmVersion[0]}.${olmVersion[1]}.${olmVersion[2]}` : '<not-enabled>';
|
||||
|
||||
return {
|
||||
appVersion: `${_t("%(brand)s version:", { brand })} ${appVersion}`,
|
||||
olmVersion: `${_t("Olm version:")} ${olmVersion}`,
|
||||
};
|
||||
}
|
||||
|
||||
private onClearCacheAndReload = (e) => {
|
||||
if (!PlatformPeg.get()) return;
|
||||
|
||||
|
@ -173,17 +185,26 @@ export default class HelpUserSettingsTab extends React.Component<IProps, IState>
|
|||
);
|
||||
}
|
||||
|
||||
onAccessTokenCopyClick = async (e) => {
|
||||
private async copy(text: string, e: ButtonEvent) {
|
||||
e.preventDefault();
|
||||
const target = e.target; // copy target before we go async and React throws it away
|
||||
const target = e.target as HTMLDivElement; // copy target before we go async and React throws it away
|
||||
|
||||
const successful = await copyPlaintext(MatrixClientPeg.get().getAccessToken());
|
||||
const successful = await copyPlaintext(text);
|
||||
const buttonRect = target.getBoundingClientRect();
|
||||
const { close } = ContextMenu.createMenu(GenericTextContextMenu, {
|
||||
...toRightOf(buttonRect, 2),
|
||||
message: successful ? _t('Copied!') : _t('Failed to copy'),
|
||||
});
|
||||
this.closeCopiedTooltip = target.onmouseleave = close;
|
||||
}
|
||||
|
||||
private onAccessTokenCopyClick = (e: ButtonEvent) => {
|
||||
this.copy(MatrixClientPeg.get().getAccessToken(), e);
|
||||
};
|
||||
|
||||
private onCopyVersionClicked = (e: ButtonEvent) => {
|
||||
const { appVersion, olmVersion } = this.getVersionInfo();
|
||||
this.copy(`${appVersion}\n${olmVersion}`, e);
|
||||
};
|
||||
|
||||
render() {
|
||||
|
@ -232,11 +253,6 @@ export default class HelpUserSettingsTab extends React.Component<IProps, IState>
|
|||
);
|
||||
}
|
||||
|
||||
const appVersion = this.state.appVersion || 'unknown';
|
||||
|
||||
let olmVersion = MatrixClientPeg.get().olmVersion;
|
||||
olmVersion = olmVersion ? `${olmVersion[0]}.${olmVersion[1]}.${olmVersion[2]}` : '<not-enabled>';
|
||||
|
||||
let updateButton = null;
|
||||
if (this.state.canUpdate) {
|
||||
updateButton = <UpdateCheckButton />;
|
||||
|
@ -275,6 +291,8 @@ export default class HelpUserSettingsTab extends React.Component<IProps, IState>
|
|||
);
|
||||
}
|
||||
|
||||
const { appVersion, olmVersion } = this.getVersionInfo();
|
||||
|
||||
return (
|
||||
<div className="mx_SettingsTab mx_HelpUserSettingsTab">
|
||||
<div className="mx_SettingsTab_heading">{ _t("Help & About") }</div>
|
||||
|
@ -291,8 +309,15 @@ export default class HelpUserSettingsTab extends React.Component<IProps, IState>
|
|||
<div className='mx_SettingsTab_section mx_HelpUserSettingsTab_versions'>
|
||||
<span className='mx_SettingsTab_subheading'>{ _t("Versions") }</span>
|
||||
<div className='mx_SettingsTab_subsectionText'>
|
||||
{ _t("%(brand)s version:", { brand }) } { appVersion }<br />
|
||||
{ _t("olm version:") } { olmVersion }<br />
|
||||
<div className="mx_HelpUserSettingsTab_copy">
|
||||
{ appVersion }<br />
|
||||
{ olmVersion }<br />
|
||||
<AccessibleTooltipButton
|
||||
title={_t("Copy")}
|
||||
onClick={this.onCopyVersionClicked}
|
||||
className="mx_HelpUserSettingsTab_copyButton"
|
||||
/>
|
||||
</div>
|
||||
{ updateButton }
|
||||
</div>
|
||||
</div>
|
||||
|
@ -308,12 +333,12 @@ export default class HelpUserSettingsTab extends React.Component<IProps, IState>
|
|||
<summary>{ _t("Access Token") }</summary><br />
|
||||
<b>{ _t("Your access token gives full access to your account."
|
||||
+ " Do not share it with anyone." ) }</b>
|
||||
<div className="mx_HelpUserSettingsTab_accessToken">
|
||||
<div className="mx_HelpUserSettingsTab_copy">
|
||||
<code>{ MatrixClientPeg.get().getAccessToken() }</code>
|
||||
<AccessibleTooltipButton
|
||||
title={_t("Copy")}
|
||||
onClick={this.onAccessTokenCopyClick}
|
||||
className="mx_HelpUserSettingsTab_accessToken_copy"
|
||||
className="mx_HelpUserSettingsTab_copyButton"
|
||||
/>
|
||||
</div>
|
||||
</details><br />
|
||||
|
|
|
@ -19,11 +19,12 @@ import { _t } from "../../../../../languageHandler";
|
|||
import PropTypes from "prop-types";
|
||||
import SettingsStore from "../../../../../settings/SettingsStore";
|
||||
import LabelledToggleSwitch from "../../../elements/LabelledToggleSwitch";
|
||||
import * as sdk from "../../../../../index";
|
||||
import { SettingLevel } from "../../../../../settings/SettingLevel";
|
||||
import { replaceableComponent } from "../../../../../utils/replaceableComponent";
|
||||
import SdkConfig from "../../../../../SdkConfig";
|
||||
import BetaCard from "../../../beta/BetaCard";
|
||||
import SettingsFlag from '../../../elements/SettingsFlag';
|
||||
import { MatrixClientPeg } from '../../../../../MatrixClientPeg';
|
||||
|
||||
export class LabsSettingToggle extends React.Component {
|
||||
static propTypes = {
|
||||
|
@ -47,6 +48,14 @@ export class LabsSettingToggle extends React.Component {
|
|||
export default class LabsUserSettingsTab extends React.Component {
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
MatrixClientPeg.get().doesServerSupportUnstableFeature("org.matrix.msc2285").then((showHiddenReadReceipts) => {
|
||||
this.setState({ showHiddenReadReceipts });
|
||||
});
|
||||
|
||||
this.state = {
|
||||
showHiddenReadReceipts: false,
|
||||
};
|
||||
}
|
||||
|
||||
render() {
|
||||
|
@ -65,15 +74,22 @@ export default class LabsUserSettingsTab extends React.Component {
|
|||
|
||||
let labsSection;
|
||||
if (SdkConfig.get()['showLabsSettings']) {
|
||||
const SettingsFlag = sdk.getComponent("views.elements.SettingsFlag");
|
||||
const flags = labs.map(f => <LabsSettingToggle featureId={f} key={f} />);
|
||||
|
||||
let hiddenReadReceipts;
|
||||
if (this.state.showHiddenReadReceipts) {
|
||||
hiddenReadReceipts = (
|
||||
<SettingsFlag name="feature_hidden_read_receipts" level={SettingLevel.DEVICE} />
|
||||
);
|
||||
}
|
||||
|
||||
labsSection = <div className="mx_SettingsTab_section">
|
||||
{ flags }
|
||||
<SettingsFlag name="enableWidgetScreenshots" level={SettingLevel.ACCOUNT} />
|
||||
<SettingsFlag name="showHiddenEventsInTimeline" level={SettingLevel.DEVICE} />
|
||||
<SettingsFlag name="lowBandwidth" level={SettingLevel.DEVICE} />
|
||||
<SettingsFlag name="advancedRoomListLogging" level={SettingLevel.DEVICE} />
|
||||
{ hiddenReadReceipts }
|
||||
</div>;
|
||||
}
|
||||
|
||||
|
|
|
@ -15,7 +15,9 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import React, { useContext, useEffect, useState } from 'react';
|
||||
import { EventType } from 'matrix-js-sdk/src/@types/event';
|
||||
|
||||
import { _t } from "../../../../../languageHandler";
|
||||
import LabelledToggleSwitch from "../../../elements/LabelledToggleSwitch";
|
||||
import SettingsStore from "../../../../../settings/SettingsStore";
|
||||
|
@ -27,6 +29,18 @@ import SettingsFlag from '../../../elements/SettingsFlag';
|
|||
import * as KeyboardShortcuts from "../../../../../accessibility/KeyboardShortcuts";
|
||||
import AccessibleButton from "../../../elements/AccessibleButton";
|
||||
import SpaceStore from "../../../../../stores/SpaceStore";
|
||||
import GroupAvatar from "../../../avatars/GroupAvatar";
|
||||
import dis from "../../../../../dispatcher/dispatcher";
|
||||
import GroupActions from "../../../../../actions/GroupActions";
|
||||
import MatrixClientContext from "../../../../../contexts/MatrixClientContext";
|
||||
import { useDispatcher } from "../../../../../hooks/useDispatcher";
|
||||
import { CreateEventField, IGroupSummary } from "../../../dialogs/CreateSpaceFromCommunityDialog";
|
||||
import { createSpaceFromCommunity } from "../../../../../utils/space";
|
||||
import Spinner from "../../../elements/Spinner";
|
||||
|
||||
interface IProps {
|
||||
closeSettingsFn(success: boolean): void;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
autoLaunch: boolean;
|
||||
|
@ -42,8 +56,86 @@ interface IState {
|
|||
readMarkerOutOfViewThresholdMs: string;
|
||||
}
|
||||
|
||||
type Community = IGroupSummary & {
|
||||
groupId: string;
|
||||
spaceId?: string;
|
||||
};
|
||||
|
||||
const CommunityMigrator = ({ onFinished }) => {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
const [communities, setCommunities] = useState<Community[]>(null);
|
||||
useEffect(() => {
|
||||
dis.dispatch(GroupActions.fetchJoinedGroups(cli));
|
||||
}, [cli]);
|
||||
useDispatcher(dis, async payload => {
|
||||
if (payload.action === "GroupActions.fetchJoinedGroups.success") {
|
||||
const communities: Community[] = [];
|
||||
|
||||
const migratedSpaceMap = new Map(cli.getRooms().map(room => {
|
||||
const createContent = room.currentState.getStateEvents(EventType.RoomCreate, "")?.getContent();
|
||||
if (createContent?.[CreateEventField]) {
|
||||
return [createContent[CreateEventField], room.roomId] as [string, string];
|
||||
}
|
||||
}).filter(Boolean));
|
||||
|
||||
for (const groupId of payload.result.groups) {
|
||||
const summary = await cli.getGroupSummary(groupId) as IGroupSummary;
|
||||
if (summary.user.is_privileged) {
|
||||
communities.push({
|
||||
...summary,
|
||||
groupId,
|
||||
spaceId: migratedSpaceMap.get(groupId),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setCommunities(communities);
|
||||
}
|
||||
});
|
||||
|
||||
if (!communities) {
|
||||
return <Spinner />;
|
||||
}
|
||||
|
||||
return <div className="mx_PreferencesUserSettingsTab_CommunityMigrator">
|
||||
{ communities.map(community => (
|
||||
<div key={community.groupId}>
|
||||
<GroupAvatar
|
||||
groupId={community.groupId}
|
||||
groupAvatarUrl={community.profile.avatar_url}
|
||||
groupName={community.profile.name}
|
||||
width={32}
|
||||
height={32}
|
||||
/>
|
||||
{ community.profile.name }
|
||||
<AccessibleButton
|
||||
kind="primary_outline"
|
||||
onClick={() => {
|
||||
if (community.spaceId) {
|
||||
dis.dispatch({
|
||||
action: "view_room",
|
||||
room_id: community.spaceId,
|
||||
});
|
||||
onFinished();
|
||||
} else {
|
||||
createSpaceFromCommunity(cli, community.groupId).then(([spaceId]) => {
|
||||
if (spaceId) {
|
||||
community.spaceId = spaceId;
|
||||
setCommunities([...communities]); // force component re-render
|
||||
}
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
{ community.spaceId ? _t("Open Space") : _t("Create Space") }
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
)) }
|
||||
</div>;
|
||||
};
|
||||
|
||||
@replaceableComponent("views.settings.tabs.user.PreferencesUserSettingsTab")
|
||||
export default class PreferencesUserSettingsTab extends React.Component<{}, IState> {
|
||||
export default class PreferencesUserSettingsTab extends React.Component<IProps, IState> {
|
||||
static ROOM_LIST_SETTINGS = [
|
||||
'breadcrumbs',
|
||||
];
|
||||
|
@ -52,6 +144,10 @@ export default class PreferencesUserSettingsTab extends React.Component<{}, ISta
|
|||
"Spaces.allRoomsInHome",
|
||||
];
|
||||
|
||||
static COMMUNITIES_SETTINGS = [
|
||||
// TODO: part of delabsing move the toggle here - https://github.com/vector-im/element-web/issues/18088
|
||||
];
|
||||
|
||||
static KEYBINDINGS_SETTINGS = [
|
||||
'ctrlFForSearch',
|
||||
];
|
||||
|
@ -61,6 +157,7 @@ export default class PreferencesUserSettingsTab extends React.Component<{}, ISta
|
|||
'MessageComposerInput.suggestEmoji',
|
||||
'sendTypingNotifications',
|
||||
'MessageComposerInput.ctrlEnterToSend',
|
||||
'MessageComposerInput.surroundWith',
|
||||
'MessageComposerInput.showStickersButton',
|
||||
];
|
||||
|
||||
|
@ -241,6 +338,19 @@ export default class PreferencesUserSettingsTab extends React.Component<{}, ISta
|
|||
{ this.renderGroup(PreferencesUserSettingsTab.SPACES_SETTINGS) }
|
||||
</div> }
|
||||
|
||||
<div className="mx_SettingsTab_section">
|
||||
<span className="mx_SettingsTab_subheading">{ _t("Communities") }</span>
|
||||
<p>{ _t("Communities have been archived to make way for Spaces but you can convert your " +
|
||||
"communities into Spaces below. Converting will ensure your conversations get the latest " +
|
||||
"features.") }</p>
|
||||
<details>
|
||||
<summary>{ _t("Show my Communities") }</summary>
|
||||
<p>{ _t("If a community isn't shown you may not have permission to convert it.") }</p>
|
||||
<CommunityMigrator onFinished={this.props.closeSettingsFn} />
|
||||
</details>
|
||||
{ this.renderGroup(PreferencesUserSettingsTab.COMMUNITIES_SETTINGS) }
|
||||
</div>
|
||||
|
||||
<div className="mx_SettingsTab_section">
|
||||
<span className="mx_SettingsTab_subheading">{ _t("Keyboard shortcuts") }</span>
|
||||
<AccessibleButton className="mx_SettingsFlag" onClick={KeyboardShortcuts.toggleDialog}>
|
||||
|
|
|
@ -65,6 +65,7 @@ export const SpaceAvatar = ({
|
|||
}}
|
||||
kind="link"
|
||||
className="mx_SpaceBasicSettings_avatar_remove"
|
||||
aria-label={_t("Delete avatar")}
|
||||
>
|
||||
{ _t("Delete") }
|
||||
</AccessibleButton>
|
||||
|
@ -72,7 +73,11 @@ export const SpaceAvatar = ({
|
|||
} else {
|
||||
avatarSection = <React.Fragment>
|
||||
<div className="mx_SpaceBasicSettings_avatar" onClick={() => avatarUploadRef.current?.click()} />
|
||||
<AccessibleButton onClick={() => avatarUploadRef.current?.click()} kind="link">
|
||||
<AccessibleButton
|
||||
onClick={() => avatarUploadRef.current?.click()}
|
||||
kind="link"
|
||||
aria-label={_t("Upload avatar")}
|
||||
>
|
||||
{ _t("Upload") }
|
||||
</AccessibleButton>
|
||||
</React.Fragment>;
|
||||
|
|
|
@ -18,22 +18,59 @@ import React, { ComponentProps, RefObject, SyntheticEvent, useContext, useRef, u
|
|||
import classNames from "classnames";
|
||||
import { RoomType } from "matrix-js-sdk/src/@types/event";
|
||||
import FocusLock from "react-focus-lock";
|
||||
import { HistoryVisibility, Preset } from "matrix-js-sdk/src/@types/partials";
|
||||
import { ICreateRoomOpts } from "matrix-js-sdk/src/@types/requests";
|
||||
|
||||
import { _t } from "../../../languageHandler";
|
||||
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
|
||||
import { ChevronFace, ContextMenu } from "../../structures/ContextMenu";
|
||||
import createRoom from "../../../createRoom";
|
||||
import createRoom, { IOpts as ICreateOpts } from "../../../createRoom";
|
||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
import SpaceBasicSettings, { SpaceAvatar } from "./SpaceBasicSettings";
|
||||
import AccessibleButton from "../elements/AccessibleButton";
|
||||
import Field from "../elements/Field";
|
||||
import withValidation from "../elements/Validation";
|
||||
import { HistoryVisibility, Preset } from "matrix-js-sdk/src/@types/partials";
|
||||
import RoomAliasField from "../elements/RoomAliasField";
|
||||
import SdkConfig from "../../../SdkConfig";
|
||||
import Modal from "../../../Modal";
|
||||
import GenericFeatureFeedbackDialog from "../dialogs/GenericFeatureFeedbackDialog";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import defaultDispatcher from "../../../dispatcher/dispatcher";
|
||||
import { Action } from "../../../dispatcher/actions";
|
||||
import { UserTab } from "../dialogs/UserSettingsDialog";
|
||||
|
||||
export const createSpace = async (
|
||||
name: string,
|
||||
isPublic: boolean,
|
||||
alias?: string,
|
||||
topic?: string,
|
||||
avatar?: string | File,
|
||||
createOpts: Partial<ICreateRoomOpts> = {},
|
||||
otherOpts: Partial<Omit<ICreateOpts, "createOpts">> = {},
|
||||
) => {
|
||||
return createRoom({
|
||||
createOpts: {
|
||||
name,
|
||||
preset: isPublic ? Preset.PublicChat : Preset.PrivateChat,
|
||||
power_level_content_override: {
|
||||
// Only allow Admins to write to the timeline to prevent hidden sync spam
|
||||
events_default: 100,
|
||||
...isPublic ? { invite: 0 } : {},
|
||||
},
|
||||
room_alias_name: isPublic && alias ? alias.substr(1, alias.indexOf(":") - 1) : undefined,
|
||||
topic,
|
||||
...createOpts,
|
||||
},
|
||||
avatar,
|
||||
roomType: RoomType.Space,
|
||||
historyVisibility: isPublic ? HistoryVisibility.WorldReadable : HistoryVisibility.Invited,
|
||||
spinner: false,
|
||||
encryption: false,
|
||||
andView: true,
|
||||
inlineErrors: true,
|
||||
...otherOpts,
|
||||
});
|
||||
};
|
||||
|
||||
const SpaceCreateMenuType = ({ title, description, className, onClick }) => {
|
||||
return (
|
||||
|
@ -92,7 +129,7 @@ export const SpaceFeedbackPrompt = ({ onClick }: { onClick?: () => void }) => {
|
|||
</div>;
|
||||
};
|
||||
|
||||
type BProps = Pick<ComponentProps<typeof SpaceBasicSettings>, "setAvatar" | "name" | "setName" | "topic" | "setTopic">;
|
||||
type BProps = Omit<ComponentProps<typeof SpaceBasicSettings>, "nameDisabled" | "topicDisabled" | "avatarDisabled">;
|
||||
interface ISpaceCreateFormProps extends BProps {
|
||||
busy: boolean;
|
||||
alias: string;
|
||||
|
@ -106,6 +143,7 @@ interface ISpaceCreateFormProps extends BProps {
|
|||
export const SpaceCreateForm: React.FC<ISpaceCreateFormProps> = ({
|
||||
busy,
|
||||
onSubmit,
|
||||
avatarUrl,
|
||||
setAvatar,
|
||||
name,
|
||||
setName,
|
||||
|
@ -122,7 +160,7 @@ export const SpaceCreateForm: React.FC<ISpaceCreateFormProps> = ({
|
|||
const domain = cli.getDomain();
|
||||
|
||||
return <form className="mx_SpaceBasicSettings" onSubmit={onSubmit}>
|
||||
<SpaceAvatar setAvatar={setAvatar} avatarDisabled={busy} />
|
||||
<SpaceAvatar avatarUrl={avatarUrl} setAvatar={setAvatar} avatarDisabled={busy} />
|
||||
|
||||
<Field
|
||||
name="spaceName"
|
||||
|
@ -200,30 +238,7 @@ const SpaceCreateMenu = ({ onFinished }) => {
|
|||
}
|
||||
|
||||
try {
|
||||
await createRoom({
|
||||
createOpts: {
|
||||
preset: visibility === Visibility.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,
|
||||
...visibility === Visibility.Public ? { invite: 0 } : {},
|
||||
},
|
||||
room_alias_name: visibility === Visibility.Public && alias
|
||||
? alias.substr(1, alias.indexOf(":") - 1)
|
||||
: undefined,
|
||||
topic,
|
||||
},
|
||||
avatar,
|
||||
roomType: RoomType.Space,
|
||||
historyVisibility: visibility === Visibility.Public
|
||||
? HistoryVisibility.WorldReadable
|
||||
: HistoryVisibility.Invited,
|
||||
spinner: false,
|
||||
encryption: false,
|
||||
andView: true,
|
||||
inlineErrors: true,
|
||||
});
|
||||
await createSpace(name, visibility === Visibility.Public, alias, topic, avatar);
|
||||
|
||||
onFinished();
|
||||
} catch (e) {
|
||||
|
@ -233,10 +248,23 @@ const SpaceCreateMenu = ({ onFinished }) => {
|
|||
|
||||
let body;
|
||||
if (visibility === null) {
|
||||
const onCreateSpaceFromCommunityClick = () => {
|
||||
defaultDispatcher.dispatch({
|
||||
action: Action.ViewUserSettings,
|
||||
initialTabId: UserTab.Preferences,
|
||||
});
|
||||
onFinished();
|
||||
};
|
||||
|
||||
body = <React.Fragment>
|
||||
<h2>{ _t("Create a space") }</h2>
|
||||
<p>{ _t("Spaces are a new way to group rooms and people. " +
|
||||
"To join an existing space you'll need an invite.") }</p>
|
||||
<p>
|
||||
{ _t("Spaces are a new way to group rooms and people.") }
|
||||
|
||||
{ _t("What kind of Space do you want to create?") }
|
||||
|
||||
{ _t("You can change this later.") }
|
||||
</p>
|
||||
|
||||
<SpaceCreateMenuType
|
||||
title={_t("Public")}
|
||||
|
@ -251,7 +279,15 @@ const SpaceCreateMenu = ({ onFinished }) => {
|
|||
onClick={() => setVisibility(Visibility.Private)}
|
||||
/>
|
||||
|
||||
<p>{ _t("You can change this later") }</p>
|
||||
<p>
|
||||
{ _t("You can also create a Space from a <a>community</a>.", {}, {
|
||||
a: sub => <AccessibleButton kind="link" onClick={onCreateSpaceFromCommunityClick}>
|
||||
{ sub }
|
||||
</AccessibleButton>,
|
||||
}) }
|
||||
<br />
|
||||
{ _t("To join an existing space you'll need an invite.") }
|
||||
</p>
|
||||
|
||||
<SpaceFeedbackPrompt onClick={onFinished} />
|
||||
</React.Fragment>;
|
||||
|
|
|
@ -100,9 +100,12 @@ const HomeButton = ({ selected, isPanelCollapsed }: IHomeButtonProps) => {
|
|||
return SpaceStore.instance.allRoomsInHome;
|
||||
});
|
||||
|
||||
return <li className={classNames("mx_SpaceItem", {
|
||||
"collapsed": isPanelCollapsed,
|
||||
})}>
|
||||
return <li
|
||||
className={classNames("mx_SpaceItem", {
|
||||
"collapsed": isPanelCollapsed,
|
||||
})}
|
||||
role="treeitem"
|
||||
>
|
||||
<SpaceButton
|
||||
className="mx_SpaceButton_home"
|
||||
onClick={() => SpaceStore.instance.setActiveSpace(null)}
|
||||
|
@ -142,9 +145,12 @@ const CreateSpaceButton = ({
|
|||
openMenu();
|
||||
};
|
||||
|
||||
return <li className={classNames("mx_SpaceItem", {
|
||||
"collapsed": isPanelCollapsed,
|
||||
})}>
|
||||
return <li
|
||||
className={classNames("mx_SpaceItem", {
|
||||
"collapsed": isPanelCollapsed,
|
||||
})}
|
||||
role="treeitem"
|
||||
>
|
||||
<SpaceButton
|
||||
className={classNames("mx_SpaceButton_new", {
|
||||
mx_SpaceButton_newCancel: menuDisplayed,
|
||||
|
@ -272,6 +278,8 @@ const SpacePanel = () => {
|
|||
<ul
|
||||
className={classNames("mx_SpacePanel", { collapsed: isPanelCollapsed })}
|
||||
onKeyDown={onKeyDownHandler}
|
||||
role="tree"
|
||||
aria-label={_t("Spaces")}
|
||||
>
|
||||
<Droppable droppableId="top-level-spaces">
|
||||
{ (provided, snapshot) => (
|
||||
|
|
|
@ -77,11 +77,17 @@ export const SpaceButton: React.FC<IButtonProps> = ({
|
|||
|
||||
let notifBadge;
|
||||
if (notificationState) {
|
||||
let ariaLabel = _t("Jump to first unread room.");
|
||||
if (space?.getMyMembership() === "invite") {
|
||||
ariaLabel = _t("Jump to first invite.");
|
||||
}
|
||||
|
||||
notifBadge = <div className="mx_SpacePanel_badgeContainer">
|
||||
<NotificationBadge
|
||||
onClick={() => SpaceStore.instance.setActiveRoomInSpace(space || null)}
|
||||
forceCount={false}
|
||||
notification={notificationState}
|
||||
aria-label={ariaLabel}
|
||||
/>
|
||||
</div>;
|
||||
}
|
||||
|
@ -107,7 +113,6 @@ export const SpaceButton: React.FC<IButtonProps> = ({
|
|||
onClick={onClick}
|
||||
onContextMenu={openMenu}
|
||||
forceHide={!isNarrow || menuDisplayed}
|
||||
role="treeitem"
|
||||
inputRef={handle}
|
||||
>
|
||||
{ children }
|
||||
|
@ -284,7 +289,7 @@ export class SpaceItem extends React.PureComponent<IItemProps, IItemState> {
|
|||
/> : null;
|
||||
|
||||
return (
|
||||
<li {...otherProps} className={itemClasses} ref={innerRef}>
|
||||
<li {...otherProps} className={itemClasses} ref={innerRef} aria-expanded={!collapsed} role="treeitem">
|
||||
<SpaceButton
|
||||
space={space}
|
||||
className={isInvite ? "mx_SpaceButton_invite" : undefined}
|
||||
|
@ -296,9 +301,7 @@ export class SpaceItem extends React.PureComponent<IItemProps, IItemState> {
|
|||
avatarSize={isNested ? 24 : 32}
|
||||
onClick={this.onClick}
|
||||
onKeyDown={this.onKeyDown}
|
||||
aria-expanded={!collapsed}
|
||||
ContextMenuComponent={this.props.space.getMyMembership() === "join"
|
||||
? SpaceContextMenu : undefined}
|
||||
ContextMenuComponent={this.props.space.getMyMembership() === "join" ? SpaceContextMenu : undefined}
|
||||
>
|
||||
{ toggleCollapseButton }
|
||||
</SpaceButton>
|
||||
|
@ -322,7 +325,7 @@ const SpaceTreeLevel: React.FC<ITreeLevelProps> = ({
|
|||
isNested,
|
||||
parents,
|
||||
}) => {
|
||||
return <ul className="mx_SpaceTreeLevel">
|
||||
return <ul className="mx_SpaceTreeLevel" role="group">
|
||||
{ spaces.map(s => {
|
||||
return (<SpaceItem
|
||||
key={s.roomId}
|
||||
|
|
|
@ -15,7 +15,7 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { createRef } from 'react';
|
||||
import React from 'react';
|
||||
|
||||
import CallView from "./CallView";
|
||||
import RoomViewStore from '../../../stores/RoomViewStore';
|
||||
|
@ -27,23 +27,8 @@ import SettingsStore from "../../../settings/SettingsStore";
|
|||
import { CallEvent, CallState, MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
|
||||
import { MatrixClientPeg } from '../../../MatrixClientPeg';
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import UIStore from '../../../stores/UIStore';
|
||||
import { lerp } from '../../../utils/AnimationUtils';
|
||||
import { MarkedExecution } from '../../../utils/MarkedExecution';
|
||||
import { EventSubscription } from 'fbemitter';
|
||||
|
||||
const PIP_VIEW_WIDTH = 336;
|
||||
const PIP_VIEW_HEIGHT = 232;
|
||||
|
||||
const MOVING_AMT = 0.2;
|
||||
const SNAPPING_AMT = 0.1;
|
||||
|
||||
const PADDING = {
|
||||
top: 58,
|
||||
bottom: 58,
|
||||
left: 76,
|
||||
right: 8,
|
||||
};
|
||||
import PictureInPictureDragger from './PictureInPictureDragger';
|
||||
|
||||
const SHOW_CALL_IN_STATES = [
|
||||
CallState.Connected,
|
||||
|
@ -66,10 +51,6 @@ interface IState {
|
|||
// Any other call we're displaying: only if the user is on two calls and not viewing either of the rooms
|
||||
// they belong to
|
||||
secondaryCall: MatrixCall;
|
||||
|
||||
// Position of the CallPreview
|
||||
translationX: number;
|
||||
translationY: number;
|
||||
}
|
||||
|
||||
// Splits a list of calls into one 'primary' one and a list
|
||||
|
@ -112,16 +93,6 @@ export default class CallPreview extends React.Component<IProps, IState> {
|
|||
private roomStoreToken: EventSubscription;
|
||||
private dispatcherRef: string;
|
||||
private settingsWatcherRef: string;
|
||||
private callViewWrapper = createRef<HTMLDivElement>();
|
||||
private initX = 0;
|
||||
private initY = 0;
|
||||
private desiredTranslationX = UIStore.instance.windowWidth - PADDING.right - PIP_VIEW_WIDTH;
|
||||
private desiredTranslationY = UIStore.instance.windowHeight - PADDING.bottom - PIP_VIEW_WIDTH;
|
||||
private moving = false;
|
||||
private scheduledUpdate = new MarkedExecution(
|
||||
() => this.animationCallback(),
|
||||
() => requestAnimationFrame(() => this.scheduledUpdate.trigger()),
|
||||
);
|
||||
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
@ -136,17 +107,12 @@ export default class CallPreview extends React.Component<IProps, IState> {
|
|||
roomId,
|
||||
primaryCall: primaryCall,
|
||||
secondaryCall: secondaryCalls[0],
|
||||
translationX: UIStore.instance.windowWidth - PADDING.right - PIP_VIEW_WIDTH,
|
||||
translationY: UIStore.instance.windowHeight - PADDING.bottom - PIP_VIEW_WIDTH,
|
||||
};
|
||||
}
|
||||
|
||||
public componentDidMount() {
|
||||
CallHandler.sharedInstance().addListener(CallHandlerEvent.CallChangeRoom, this.updateCalls);
|
||||
this.roomStoreToken = RoomViewStore.addListener(this.onRoomViewStoreUpdate);
|
||||
document.addEventListener("mousemove", this.onMoving);
|
||||
document.addEventListener("mouseup", this.onEndMoving);
|
||||
window.addEventListener("resize", this.onResize);
|
||||
this.dispatcherRef = dis.register(this.onAction);
|
||||
MatrixClientPeg.get().on(CallEvent.RemoteHoldUnhold, this.onCallRemoteHold);
|
||||
}
|
||||
|
@ -154,9 +120,6 @@ export default class CallPreview extends React.Component<IProps, IState> {
|
|||
public componentWillUnmount() {
|
||||
CallHandler.sharedInstance().removeListener(CallHandlerEvent.CallChangeRoom, this.updateCalls);
|
||||
MatrixClientPeg.get().removeListener(CallEvent.RemoteHoldUnhold, this.onCallRemoteHold);
|
||||
document.removeEventListener("mousemove", this.onMoving);
|
||||
document.removeEventListener("mouseup", this.onEndMoving);
|
||||
window.removeEventListener("resize", this.onResize);
|
||||
if (this.roomStoreToken) {
|
||||
this.roomStoreToken.remove();
|
||||
}
|
||||
|
@ -164,94 +127,6 @@ export default class CallPreview extends React.Component<IProps, IState> {
|
|||
SettingsStore.unwatchSetting(this.settingsWatcherRef);
|
||||
}
|
||||
|
||||
private onResize = (): void => {
|
||||
this.snap(false);
|
||||
};
|
||||
|
||||
private animationCallback = () => {
|
||||
// If the PiP isn't being dragged and there is only a tiny difference in
|
||||
// the desiredTranslation and translation, quit the animationCallback
|
||||
// loop. If that is the case, it means the PiP has snapped into its
|
||||
// position and there is nothing to do. Not doing this would cause an
|
||||
// infinite loop
|
||||
if (
|
||||
!this.moving &&
|
||||
Math.abs(this.state.translationX - this.desiredTranslationX) <= 1 &&
|
||||
Math.abs(this.state.translationY - this.desiredTranslationY) <= 1
|
||||
) return;
|
||||
|
||||
const amt = this.moving ? MOVING_AMT : SNAPPING_AMT;
|
||||
this.setState({
|
||||
translationX: lerp(this.state.translationX, this.desiredTranslationX, amt),
|
||||
translationY: lerp(this.state.translationY, this.desiredTranslationY, amt),
|
||||
});
|
||||
this.scheduledUpdate.mark();
|
||||
};
|
||||
|
||||
private setTranslation(inTranslationX: number, inTranslationY: number) {
|
||||
const width = this.callViewWrapper.current?.clientWidth || PIP_VIEW_WIDTH;
|
||||
const height = this.callViewWrapper.current?.clientHeight || PIP_VIEW_HEIGHT;
|
||||
|
||||
// Avoid overflow on the x axis
|
||||
if (inTranslationX + width >= UIStore.instance.windowWidth) {
|
||||
this.desiredTranslationX = UIStore.instance.windowWidth - width;
|
||||
} else if (inTranslationX <= 0) {
|
||||
this.desiredTranslationX = 0;
|
||||
} else {
|
||||
this.desiredTranslationX = inTranslationX;
|
||||
}
|
||||
|
||||
// Avoid overflow on the y axis
|
||||
if (inTranslationY + height >= UIStore.instance.windowHeight) {
|
||||
this.desiredTranslationY = UIStore.instance.windowHeight - height;
|
||||
} else if (inTranslationY <= 0) {
|
||||
this.desiredTranslationY = 0;
|
||||
} else {
|
||||
this.desiredTranslationY = inTranslationY;
|
||||
}
|
||||
}
|
||||
|
||||
private snap(animate?: boolean): void {
|
||||
const translationX = this.desiredTranslationX;
|
||||
const translationY = this.desiredTranslationY;
|
||||
// We subtract the PiP size from the window size in order to calculate
|
||||
// the position to snap to from the PiP center and not its top-left
|
||||
// corner
|
||||
const windowWidth = (
|
||||
UIStore.instance.windowWidth -
|
||||
(this.callViewWrapper.current?.clientWidth || PIP_VIEW_WIDTH)
|
||||
);
|
||||
const windowHeight = (
|
||||
UIStore.instance.windowHeight -
|
||||
(this.callViewWrapper.current?.clientHeight || PIP_VIEW_HEIGHT)
|
||||
);
|
||||
|
||||
if (translationX >= windowWidth / 2 && translationY >= windowHeight / 2) {
|
||||
this.desiredTranslationX = windowWidth - PADDING.right;
|
||||
this.desiredTranslationY = windowHeight - PADDING.bottom;
|
||||
} else if (translationX >= windowWidth / 2 && translationY <= windowHeight / 2) {
|
||||
this.desiredTranslationX = windowWidth - PADDING.right;
|
||||
this.desiredTranslationY = PADDING.top;
|
||||
} else if (translationX <= windowWidth / 2 && translationY >= windowHeight / 2) {
|
||||
this.desiredTranslationX = PADDING.left;
|
||||
this.desiredTranslationY = windowHeight - PADDING.bottom;
|
||||
} else {
|
||||
this.desiredTranslationX = PADDING.left;
|
||||
this.desiredTranslationY = PADDING.top;
|
||||
}
|
||||
|
||||
if (animate) {
|
||||
// We start animating here because we want the PiP to move when we're
|
||||
// resizing the window
|
||||
this.scheduledUpdate.mark();
|
||||
} else {
|
||||
this.setState({
|
||||
translationX: this.desiredTranslationX,
|
||||
translationY: this.desiredTranslationY,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private onRoomViewStoreUpdate = () => {
|
||||
if (RoomViewStore.getRoomId() === this.state.roomId) return;
|
||||
|
||||
|
@ -269,9 +144,10 @@ export default class CallPreview extends React.Component<IProps, IState> {
|
|||
|
||||
private onAction = (payload: ActionPayload) => {
|
||||
switch (payload.action) {
|
||||
// listen for call state changes to prod the render method, which
|
||||
// may hide the global CallView if the call it is tracking is dead
|
||||
case 'call_state': {
|
||||
// listen for call state changes to prod the render method, which
|
||||
// may hide the global CallView if the call it is tracking is dead
|
||||
|
||||
this.updateCalls();
|
||||
break;
|
||||
}
|
||||
|
@ -300,57 +176,26 @@ export default class CallPreview extends React.Component<IProps, IState> {
|
|||
});
|
||||
};
|
||||
|
||||
private onStartMoving = (event: React.MouseEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
this.moving = true;
|
||||
this.initX = event.pageX - this.desiredTranslationX;
|
||||
this.initY = event.pageY - this.desiredTranslationY;
|
||||
this.scheduledUpdate.mark();
|
||||
};
|
||||
|
||||
private onMoving = (event: React.MouseEvent | MouseEvent) => {
|
||||
if (!this.moving) return;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
this.setTranslation(event.pageX - this.initX, event.pageY - this.initY);
|
||||
};
|
||||
|
||||
private onEndMoving = () => {
|
||||
this.moving = false;
|
||||
this.snap(true);
|
||||
};
|
||||
|
||||
public render() {
|
||||
const pipMode = true;
|
||||
if (this.state.primaryCall) {
|
||||
const translatePixelsX = this.state.translationX + "px";
|
||||
const translatePixelsY = this.state.translationY + "px";
|
||||
const style = {
|
||||
transform: `translateX(${translatePixelsX})
|
||||
translateY(${translatePixelsY})`,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
<PictureInPictureDragger
|
||||
className="mx_CallPreview"
|
||||
style={style}
|
||||
ref={this.callViewWrapper}
|
||||
draggable={pipMode}
|
||||
>
|
||||
<CallView
|
||||
{ ({ onStartMoving, onResize }) => <CallView
|
||||
onMouseDownOnHeader={onStartMoving}
|
||||
call={this.state.primaryCall}
|
||||
secondaryCall={this.state.secondaryCall}
|
||||
pipMode={true}
|
||||
onMouseDownOnHeader={this.onStartMoving}
|
||||
onResize={this.onResize}
|
||||
/>
|
||||
</div>
|
||||
pipMode={pipMode}
|
||||
onResize={onResize}
|
||||
/> }
|
||||
</PictureInPictureDragger>
|
||||
|
||||
);
|
||||
}
|
||||
|
||||
return <PersistentApp />;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket 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");
|
||||
|
@ -23,40 +23,39 @@ import { MatrixClientPeg } from '../../../MatrixClientPeg';
|
|||
import { _t, _td } from '../../../languageHandler';
|
||||
import VideoFeed from './VideoFeed';
|
||||
import RoomAvatar from "../avatars/RoomAvatar";
|
||||
import { CallState, CallType, MatrixCall, CallEvent } from 'matrix-js-sdk/src/webrtc/call';
|
||||
import { CallEvent, CallState, CallType, MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
|
||||
import classNames from 'classnames';
|
||||
import AccessibleButton from '../elements/AccessibleButton';
|
||||
import { isOnlyCtrlOrCmdKeyEvent, Key } from '../../../Keyboard';
|
||||
import { alwaysAboveLeftOf, alwaysAboveRightOf, ChevronFace, ContextMenuButton } from '../../structures/ContextMenu';
|
||||
import CallContextMenu from '../context_menus/CallContextMenu';
|
||||
import { avatarUrlForMember } from '../../../Avatar';
|
||||
import DialpadContextMenu from '../context_menus/DialpadContextMenu';
|
||||
import { CallFeed } from 'matrix-js-sdk/src/webrtc/callFeed';
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import DesktopCapturerSourcePicker from "../elements/DesktopCapturerSourcePicker";
|
||||
import Modal from '../../../Modal';
|
||||
import { SDPStreamMetadataPurpose } from 'matrix-js-sdk/src/webrtc/callEventTypes';
|
||||
import CallViewSidebar from './CallViewSidebar';
|
||||
import CallViewHeader from './CallView/CallViewHeader';
|
||||
import CallViewButtons from "./CallView/CallViewButtons";
|
||||
|
||||
interface IProps {
|
||||
// The call for us to display
|
||||
call: MatrixCall;
|
||||
// The call for us to display
|
||||
call: MatrixCall;
|
||||
|
||||
// Another ongoing call to display information about
|
||||
secondaryCall?: MatrixCall;
|
||||
// Another ongoing call to display information about
|
||||
secondaryCall?: MatrixCall;
|
||||
|
||||
// a callback which is called when the content in the CallView changes
|
||||
// in a way that is likely to cause a resize.
|
||||
onResize?: any;
|
||||
// a callback which is called when the content in the CallView changes
|
||||
// in a way that is likely to cause a resize.
|
||||
onResize?: (event: Event) => void;
|
||||
|
||||
// Whether this call view is for picture-in-picture mode
|
||||
// otherwise, it's the larger call view when viewing the room the call is in.
|
||||
// This is sort of a proxy for a number of things but we currently have no
|
||||
// need to control those things separately, so this is simpler.
|
||||
pipMode?: boolean;
|
||||
// Whether this call view is for picture-in-picture mode
|
||||
// otherwise, it's the larger call view when viewing the room the call is in.
|
||||
// This is sort of a proxy for a number of things but we currently have no
|
||||
// need to control those things separately, so this is simpler.
|
||||
pipMode?: boolean;
|
||||
|
||||
// Used for dragging the PiP CallView
|
||||
onMouseDownOnHeader?: (event: React.MouseEvent) => void;
|
||||
// Used for dragging the PiP CallView
|
||||
onMouseDownOnHeader?: (event: React.MouseEvent<Element, MouseEvent>) => void;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
|
@ -103,19 +102,11 @@ function exitFullscreen() {
|
|||
if (exitMethod) exitMethod.call(document);
|
||||
}
|
||||
|
||||
const CONTROLS_HIDE_DELAY = 2000;
|
||||
// Height of the header duplicated from CSS because we need to subtract it from our max
|
||||
// height to get the max height of the video
|
||||
const CONTEXT_MENU_VPADDING = 8; // How far the context menu sits above the button (px)
|
||||
|
||||
@replaceableComponent("views.voip.CallView")
|
||||
export default class CallView extends React.Component<IProps, IState> {
|
||||
private dispatcherRef: string;
|
||||
private contentRef = createRef<HTMLDivElement>();
|
||||
private controlsHideTimer: number = null;
|
||||
private dialpadButton = createRef<HTMLDivElement>();
|
||||
private contextMenuButton = createRef<HTMLDivElement>();
|
||||
private contextMenu = createRef<HTMLDivElement>();
|
||||
private buttonsRef = createRef<CallViewButtons>();
|
||||
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
@ -231,31 +222,8 @@ export default class CallView extends React.Component<IProps, IState> {
|
|||
});
|
||||
};
|
||||
|
||||
private onFullscreenClick = () => {
|
||||
dis.dispatch({
|
||||
action: 'video_fullscreen',
|
||||
fullscreen: true,
|
||||
});
|
||||
};
|
||||
|
||||
private onExpandClick = () => {
|
||||
const userFacingRoomId = CallHandler.sharedInstance().roomIdForCall(this.props.call);
|
||||
dis.dispatch({
|
||||
action: 'view_room',
|
||||
room_id: userFacingRoomId,
|
||||
});
|
||||
};
|
||||
|
||||
private onControlsHideTimer = () => {
|
||||
if (this.state.hoveringControls || this.state.showDialpad || this.state.showMoreMenu) return;
|
||||
this.controlsHideTimer = null;
|
||||
this.setState({
|
||||
controlsVisible: false,
|
||||
});
|
||||
};
|
||||
|
||||
private onMouseMove = () => {
|
||||
this.showControls();
|
||||
this.buttonsRef.current?.showControls();
|
||||
};
|
||||
|
||||
private getOrderedFeeds(feeds: Array<CallFeed>): { primary: CallFeed, secondary: Array<CallFeed> } {
|
||||
|
@ -281,29 +249,6 @@ export default class CallView extends React.Component<IProps, IState> {
|
|||
return { primary, secondary };
|
||||
}
|
||||
|
||||
private showControls(): void {
|
||||
if (this.state.showMoreMenu || this.state.showDialpad) return;
|
||||
|
||||
if (!this.state.controlsVisible) {
|
||||
this.setState({
|
||||
controlsVisible: true,
|
||||
});
|
||||
}
|
||||
if (this.controlsHideTimer !== null) {
|
||||
clearTimeout(this.controlsHideTimer);
|
||||
}
|
||||
this.controlsHideTimer = window.setTimeout(this.onControlsHideTimer, CONTROLS_HIDE_DELAY);
|
||||
}
|
||||
|
||||
private onDialpadClick = (): void => {
|
||||
if (!this.state.showDialpad) {
|
||||
this.setState({ showDialpad: true });
|
||||
this.showControls();
|
||||
} else {
|
||||
this.setState({ showDialpad: false });
|
||||
}
|
||||
};
|
||||
|
||||
private onMicMuteClick = (): void => {
|
||||
const newVal = !this.state.micMuted;
|
||||
|
||||
|
@ -334,19 +279,6 @@ export default class CallView extends React.Component<IProps, IState> {
|
|||
});
|
||||
};
|
||||
|
||||
private onMoreClick = (): void => {
|
||||
this.setState({ showMoreMenu: true });
|
||||
this.showControls();
|
||||
};
|
||||
|
||||
private closeDialpad = (): void => {
|
||||
this.setState({ showDialpad: false });
|
||||
};
|
||||
|
||||
private closeContextMenu = (): void => {
|
||||
this.setState({ showMoreMenu: false });
|
||||
};
|
||||
|
||||
// we register global shortcuts here, they *must not conflict* with local shortcuts elsewhere or both will fire
|
||||
// Note that this assumes we always have a CallView on screen at any given time
|
||||
// CallHandler would probably be a better place for this
|
||||
|
@ -359,7 +291,7 @@ export default class CallView extends React.Component<IProps, IState> {
|
|||
if (ctrlCmdOnly) {
|
||||
this.onMicMuteClick();
|
||||
// show the controls to give feedback
|
||||
this.showControls();
|
||||
this.buttonsRef.current?.showControls();
|
||||
handled = true;
|
||||
}
|
||||
break;
|
||||
|
@ -368,7 +300,7 @@ export default class CallView extends React.Component<IProps, IState> {
|
|||
if (ctrlCmdOnly) {
|
||||
this.onVidMuteClick();
|
||||
// show the controls to give feedback
|
||||
this.showControls();
|
||||
this.buttonsRef.current?.showControls();
|
||||
handled = true;
|
||||
}
|
||||
break;
|
||||
|
@ -380,32 +312,6 @@ export default class CallView extends React.Component<IProps, IState> {
|
|||
}
|
||||
};
|
||||
|
||||
private onCallControlsMouseEnter = (): void => {
|
||||
this.setState({ hoveringControls: true });
|
||||
this.showControls();
|
||||
};
|
||||
|
||||
private onCallControlsMouseLeave = (): void => {
|
||||
this.setState({ hoveringControls: false });
|
||||
};
|
||||
|
||||
private onRoomAvatarClick = (): void => {
|
||||
const userFacingRoomId = CallHandler.sharedInstance().roomIdForCall(this.props.call);
|
||||
dis.dispatch({
|
||||
action: 'view_room',
|
||||
room_id: userFacingRoomId,
|
||||
});
|
||||
};
|
||||
|
||||
private onSecondaryRoomAvatarClick = (): void => {
|
||||
const userFacingRoomId = CallHandler.sharedInstance().roomIdForCall(this.props.secondaryCall);
|
||||
|
||||
dis.dispatch({
|
||||
action: 'view_room',
|
||||
room_id: userFacingRoomId,
|
||||
});
|
||||
};
|
||||
|
||||
private onCallResumeClick = (): void => {
|
||||
const userFacingRoomId = CallHandler.sharedInstance().roomIdForCall(this.props.call);
|
||||
CallHandler.sharedInstance().setActiveCallRoomId(userFacingRoomId);
|
||||
|
@ -424,180 +330,60 @@ export default class CallView extends React.Component<IProps, IState> {
|
|||
};
|
||||
|
||||
private onToggleSidebar = (): void => {
|
||||
this.setState({
|
||||
sidebarShown: !this.state.sidebarShown,
|
||||
});
|
||||
this.setState({ sidebarShown: !this.state.sidebarShown });
|
||||
};
|
||||
|
||||
private renderCallControls(): JSX.Element {
|
||||
const micClasses = classNames({
|
||||
mx_CallView_callControls_button: true,
|
||||
mx_CallView_callControls_button_micOn: !this.state.micMuted,
|
||||
mx_CallView_callControls_button_micOff: this.state.micMuted,
|
||||
});
|
||||
|
||||
const vidClasses = classNames({
|
||||
mx_CallView_callControls_button: true,
|
||||
mx_CallView_callControls_button_vidOn: !this.state.vidMuted,
|
||||
mx_CallView_callControls_button_vidOff: this.state.vidMuted,
|
||||
});
|
||||
|
||||
const screensharingClasses = classNames({
|
||||
mx_CallView_callControls_button: true,
|
||||
mx_CallView_callControls_button_screensharingOn: this.state.screensharing,
|
||||
mx_CallView_callControls_button_screensharingOff: !this.state.screensharing,
|
||||
});
|
||||
|
||||
const sidebarButtonClasses = classNames({
|
||||
mx_CallView_callControls_button: true,
|
||||
mx_CallView_callControls_button_sidebarOn: this.state.sidebarShown,
|
||||
mx_CallView_callControls_button_sidebarOff: !this.state.sidebarShown,
|
||||
});
|
||||
|
||||
// Put the other states of the mic/video icons in the document to make sure they're cached
|
||||
// (otherwise the icon disappears briefly when toggled)
|
||||
const micCacheClasses = classNames({
|
||||
mx_CallView_callControls_button: true,
|
||||
mx_CallView_callControls_button_micOn: this.state.micMuted,
|
||||
mx_CallView_callControls_button_micOff: !this.state.micMuted,
|
||||
mx_CallView_callControls_button_invisible: true,
|
||||
});
|
||||
|
||||
const vidCacheClasses = classNames({
|
||||
mx_CallView_callControls_button: true,
|
||||
mx_CallView_callControls_button_vidOn: this.state.micMuted,
|
||||
mx_CallView_callControls_button_vidOff: !this.state.micMuted,
|
||||
mx_CallView_callControls_button_invisible: true,
|
||||
});
|
||||
|
||||
const callControlsClasses = classNames({
|
||||
mx_CallView_callControls: true,
|
||||
mx_CallView_callControls_hidden: !this.state.controlsVisible,
|
||||
});
|
||||
|
||||
// We don't support call upgrades (yet) so hide the video mute button in voice calls
|
||||
let vidMuteButton;
|
||||
if (this.props.call.type === CallType.Video) {
|
||||
vidMuteButton = (
|
||||
<AccessibleButton
|
||||
className={vidClasses}
|
||||
onClick={this.onVidMuteClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const vidMuteButtonShown = this.props.call.type === CallType.Video;
|
||||
// Screensharing is possible, if we can send a second stream and
|
||||
// identify it using SDPStreamMetadata or if we can replace the already
|
||||
// existing usermedia track by a screensharing track. We also need to be
|
||||
// connected to know the state of the other side
|
||||
let screensharingButton;
|
||||
if (
|
||||
const screensharingButtonShown = (
|
||||
(this.props.call.opponentSupportsSDPStreamMetadata() || this.props.call.type === CallType.Video) &&
|
||||
this.props.call.state === CallState.Connected
|
||||
) {
|
||||
screensharingButton = (
|
||||
<AccessibleButton
|
||||
className={screensharingClasses}
|
||||
onClick={this.onScreenshareClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
);
|
||||
// To show the sidebar we need secondary feeds, if we don't have them,
|
||||
// we can hide this button. If we are in PiP, sidebar is also hidden, so
|
||||
// we can hide the button too
|
||||
let sidebarButton;
|
||||
if (
|
||||
!this.props.pipMode &&
|
||||
(
|
||||
this.state.primaryFeed?.purpose === SDPStreamMetadataPurpose.Screenshare ||
|
||||
this.props.call.isScreensharing()
|
||||
)
|
||||
) {
|
||||
sidebarButton = (
|
||||
<AccessibleButton
|
||||
className={sidebarButtonClasses}
|
||||
onClick={this.onToggleSidebar}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const sidebarButtonShown = (
|
||||
this.state.primaryFeed?.purpose === SDPStreamMetadataPurpose.Screenshare ||
|
||||
this.props.call.isScreensharing()
|
||||
);
|
||||
// The dial pad & 'more' button actions are only relevant in a connected call
|
||||
let contextMenuButton;
|
||||
if (this.state.callState === CallState.Connected) {
|
||||
contextMenuButton = (
|
||||
<ContextMenuButton
|
||||
className="mx_CallView_callControls_button mx_CallView_callControls_button_more"
|
||||
onClick={this.onMoreClick}
|
||||
inputRef={this.contextMenuButton}
|
||||
isExpanded={this.state.showMoreMenu}
|
||||
/>
|
||||
);
|
||||
}
|
||||
let dialpadButton;
|
||||
if (this.state.callState === CallState.Connected && this.props.call.opponentSupportsDTMF()) {
|
||||
dialpadButton = (
|
||||
<ContextMenuButton
|
||||
className="mx_CallView_callControls_button mx_CallView_callControls_dialpad"
|
||||
inputRef={this.dialpadButton}
|
||||
onClick={this.onDialpadClick}
|
||||
isExpanded={this.state.showDialpad}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
let dialPad;
|
||||
if (this.state.showDialpad) {
|
||||
dialPad = <DialpadContextMenu
|
||||
{...alwaysAboveRightOf(
|
||||
this.dialpadButton.current.getBoundingClientRect(),
|
||||
ChevronFace.None,
|
||||
CONTEXT_MENU_VPADDING,
|
||||
)}
|
||||
mountAsChild={true}
|
||||
onFinished={this.closeDialpad}
|
||||
call={this.props.call}
|
||||
/>;
|
||||
}
|
||||
|
||||
let contextMenu;
|
||||
if (this.state.showMoreMenu) {
|
||||
contextMenu = <CallContextMenu
|
||||
{...alwaysAboveLeftOf(
|
||||
this.contextMenuButton.current.getBoundingClientRect(),
|
||||
ChevronFace.None,
|
||||
CONTEXT_MENU_VPADDING,
|
||||
)}
|
||||
mountAsChild={true}
|
||||
onFinished={this.closeContextMenu}
|
||||
call={this.props.call}
|
||||
/>;
|
||||
}
|
||||
const contextMenuButtonShown = this.state.callState === CallState.Connected;
|
||||
const dialpadButtonShown = (
|
||||
this.state.callState === CallState.Connected &&
|
||||
this.props.call.opponentSupportsDTMF()
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={callControlsClasses}
|
||||
onMouseEnter={this.onCallControlsMouseEnter}
|
||||
onMouseLeave={this.onCallControlsMouseLeave}
|
||||
>
|
||||
{ dialPad }
|
||||
{ contextMenu }
|
||||
{ dialpadButton }
|
||||
<AccessibleButton
|
||||
className={micClasses}
|
||||
onClick={this.onMicMuteClick}
|
||||
/>
|
||||
{ vidMuteButton }
|
||||
<div className={micCacheClasses} />
|
||||
<div className={vidCacheClasses} />
|
||||
{ screensharingButton }
|
||||
{ sidebarButton }
|
||||
{ contextMenuButton }
|
||||
<AccessibleButton
|
||||
className="mx_CallView_callControls_button mx_CallView_callControls_button_hangup"
|
||||
onClick={this.onHangupClick}
|
||||
/>
|
||||
</div>
|
||||
<CallViewButtons
|
||||
ref={this.buttonsRef}
|
||||
call={this.props.call}
|
||||
pipMode={this.props.pipMode}
|
||||
handlers={{
|
||||
onToggleSidebarClick: this.onToggleSidebar,
|
||||
onScreenshareClick: this.onScreenshareClick,
|
||||
onHangupClick: this.onHangupClick,
|
||||
onMicMuteClick: this.onMicMuteClick,
|
||||
onVidMuteClick: this.onVidMuteClick,
|
||||
}}
|
||||
buttonsState={{
|
||||
micMuted: this.state.micMuted,
|
||||
vidMuted: this.state.vidMuted,
|
||||
sidebarShown: this.state.sidebarShown,
|
||||
screensharing: this.state.screensharing,
|
||||
}}
|
||||
buttonsVisibility={{
|
||||
vidMute: vidMuteButtonShown,
|
||||
screensharing: screensharingButtonShown,
|
||||
sidebar: sidebarButtonShown,
|
||||
contextMenu: contextMenuButtonShown,
|
||||
dialpad: dialpadButtonShown,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -692,7 +478,7 @@ export default class CallView extends React.Component<IProps, IState> {
|
|||
let onHoldBackground = null;
|
||||
const backgroundStyle: CSSProperties = {};
|
||||
const backgroundAvatarUrl = avatarUrlForMember(
|
||||
// is it worth getting the size of the div to pass here?
|
||||
// is it worth getting the size of the div to pass here?
|
||||
this.props.call.getOpponentMember(), 1024, 1024, 'crop',
|
||||
);
|
||||
backgroundStyle.backgroundImage = 'url(' + backgroundAvatarUrl + ')';
|
||||
|
@ -712,7 +498,7 @@ export default class CallView extends React.Component<IProps, IState> {
|
|||
mx_CallView_voice_hold: isOnHold,
|
||||
});
|
||||
|
||||
contentView =(
|
||||
contentView = (
|
||||
<div className={classes} onMouseMove={this.onMouseMove}>
|
||||
<div className="mx_CallView_voice_avatarsContainer">
|
||||
<div
|
||||
|
@ -814,83 +600,15 @@ export default class CallView extends React.Component<IProps, IState> {
|
|||
);
|
||||
}
|
||||
|
||||
const callTypeText = isVideoCall ? _t("Video Call") : _t("Voice Call");
|
||||
let myClassName;
|
||||
|
||||
let fullScreenButton;
|
||||
if (!this.props.pipMode) {
|
||||
fullScreenButton = (
|
||||
<div
|
||||
className="mx_CallView_header_button mx_CallView_header_button_fullscreen"
|
||||
onClick={this.onFullscreenClick}
|
||||
title={_t("Fill Screen")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
let expandButton;
|
||||
if (this.props.pipMode) {
|
||||
expandButton = <div
|
||||
className="mx_CallView_header_button mx_CallView_header_button_expand"
|
||||
onClick={this.onExpandClick}
|
||||
title={_t("Return to call")}
|
||||
/>;
|
||||
}
|
||||
|
||||
const headerControls = <div className="mx_CallView_header_controls">
|
||||
{ fullScreenButton }
|
||||
{ expandButton }
|
||||
</div>;
|
||||
|
||||
const callTypeIconClassName = classNames("mx_CallView_header_callTypeIcon", {
|
||||
"mx_CallView_header_callTypeIcon_voice": !isVideoCall,
|
||||
"mx_CallView_header_callTypeIcon_video": isVideoCall,
|
||||
});
|
||||
|
||||
let header: React.ReactNode;
|
||||
if (!this.props.pipMode) {
|
||||
header = <div className="mx_CallView_header">
|
||||
<div className={callTypeIconClassName} />
|
||||
<span className="mx_CallView_header_callType">{ callTypeText }</span>
|
||||
{ headerControls }
|
||||
</div>;
|
||||
myClassName = 'mx_CallView_large';
|
||||
} else {
|
||||
let secondaryCallInfo;
|
||||
if (this.props.secondaryCall) {
|
||||
secondaryCallInfo = <span className="mx_CallView_header_secondaryCallInfo">
|
||||
<AccessibleButton element='span' onClick={this.onSecondaryRoomAvatarClick}>
|
||||
<RoomAvatar room={secCallRoom} height={16} width={16} />
|
||||
<span className="mx_CallView_secondaryCall_roomName">
|
||||
{ _t("%(name)s on hold", { name: secCallRoom.name }) }
|
||||
</span>
|
||||
</AccessibleButton>
|
||||
</span>;
|
||||
}
|
||||
|
||||
header = (
|
||||
<div
|
||||
className="mx_CallView_header"
|
||||
onMouseDown={this.props.onMouseDownOnHeader}
|
||||
>
|
||||
<AccessibleButton onClick={this.onRoomAvatarClick}>
|
||||
<RoomAvatar room={callRoom} height={32} width={32} />
|
||||
</AccessibleButton>
|
||||
<div className="mx_CallView_header_callInfo">
|
||||
<div className="mx_CallView_header_roomName">{ callRoom.name }</div>
|
||||
<div className="mx_CallView_header_callTypeSmall">
|
||||
{ callTypeText }
|
||||
{ secondaryCallInfo }
|
||||
</div>
|
||||
</div>
|
||||
{ headerControls }
|
||||
</div>
|
||||
);
|
||||
myClassName = 'mx_CallView_pip';
|
||||
}
|
||||
const myClassName = this.props.pipMode ? 'mx_CallView_pip' : 'mx_CallView_large';
|
||||
|
||||
return <div className={"mx_CallView " + myClassName}>
|
||||
{ header }
|
||||
<CallViewHeader
|
||||
onPipMouseDown={this.props.onMouseDownOnHeader}
|
||||
pipMode={this.props.pipMode}
|
||||
type={this.props.call.type}
|
||||
callRooms={[callRoom, secCallRoom]}
|
||||
/>
|
||||
{ contentView }
|
||||
</div>;
|
||||
}
|
||||
|
|
316
src/components/views/voip/CallView/CallViewButtons.tsx
Normal file
316
src/components/views/voip/CallView/CallViewButtons.tsx
Normal file
|
@ -0,0 +1,316 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2019 - 2021 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2021 Šimon Brandner <simon.bra.ag@gmail.com>
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { createRef } from "react";
|
||||
import classNames from "classnames";
|
||||
import AccessibleTooltipButton from "../../elements/AccessibleTooltipButton";
|
||||
import CallContextMenu from "../../context_menus/CallContextMenu";
|
||||
import DialpadContextMenu from "../../context_menus/DialpadContextMenu";
|
||||
import { MatrixCall } from "matrix-js-sdk/src/webrtc/call";
|
||||
import { Alignment } from "../../elements/Tooltip";
|
||||
import {
|
||||
alwaysAboveLeftOf,
|
||||
alwaysAboveRightOf,
|
||||
ChevronFace,
|
||||
ContextMenuTooltipButton,
|
||||
} from '../../../structures/ContextMenu';
|
||||
import { _t } from "../../../../languageHandler";
|
||||
|
||||
// Height of the header duplicated from CSS because we need to subtract it from our max
|
||||
// height to get the max height of the video
|
||||
const CONTEXT_MENU_VPADDING = 8; // How far the context menu sits above the button (px)
|
||||
|
||||
const TOOLTIP_Y_OFFSET = -24;
|
||||
|
||||
const CONTROLS_HIDE_DELAY = 2000;
|
||||
|
||||
interface IProps {
|
||||
call: MatrixCall;
|
||||
pipMode: boolean;
|
||||
handlers: {
|
||||
onHangupClick: () => void;
|
||||
onScreenshareClick: () => void;
|
||||
onToggleSidebarClick: () => void;
|
||||
onMicMuteClick: () => void;
|
||||
onVidMuteClick: () => void;
|
||||
};
|
||||
buttonsState: {
|
||||
micMuted: boolean;
|
||||
vidMuted: boolean;
|
||||
sidebarShown: boolean;
|
||||
screensharing: boolean;
|
||||
};
|
||||
buttonsVisibility: {
|
||||
screensharing: boolean;
|
||||
vidMute: boolean;
|
||||
sidebar: boolean;
|
||||
dialpad: boolean;
|
||||
contextMenu: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
interface IState {
|
||||
visible: boolean;
|
||||
showDialpad: boolean;
|
||||
hoveringControls: boolean;
|
||||
showMoreMenu: boolean;
|
||||
}
|
||||
|
||||
export default class CallViewButtons extends React.Component<IProps, IState> {
|
||||
private dialpadButton = createRef<HTMLDivElement>();
|
||||
private contextMenuButton = createRef<HTMLDivElement>();
|
||||
private controlsHideTimer: number = null;
|
||||
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
showDialpad: false,
|
||||
hoveringControls: false,
|
||||
showMoreMenu: false,
|
||||
visible: true,
|
||||
};
|
||||
}
|
||||
|
||||
public componentDidMount(): void {
|
||||
this.showControls();
|
||||
}
|
||||
|
||||
public showControls(): void {
|
||||
if (this.state.showMoreMenu || this.state.showDialpad) return;
|
||||
|
||||
if (!this.state.visible) {
|
||||
this.setState({
|
||||
visible: true,
|
||||
});
|
||||
}
|
||||
if (this.controlsHideTimer !== null) {
|
||||
clearTimeout(this.controlsHideTimer);
|
||||
}
|
||||
this.controlsHideTimer = window.setTimeout(this.onControlsHideTimer, CONTROLS_HIDE_DELAY);
|
||||
}
|
||||
|
||||
private onControlsHideTimer = (): void => {
|
||||
if (this.state.hoveringControls || this.state.showDialpad || this.state.showMoreMenu) return;
|
||||
this.controlsHideTimer = null;
|
||||
this.setState({ visible: false });
|
||||
};
|
||||
|
||||
private onMouseEnter = (): void => {
|
||||
this.setState({ hoveringControls: true });
|
||||
};
|
||||
|
||||
private onMouseLeave = (): void => {
|
||||
this.setState({ hoveringControls: false });
|
||||
};
|
||||
|
||||
private onDialpadClick = (): void => {
|
||||
if (!this.state.showDialpad) {
|
||||
this.setState({ showDialpad: true });
|
||||
this.showControls();
|
||||
} else {
|
||||
this.setState({ showDialpad: false });
|
||||
}
|
||||
};
|
||||
|
||||
private onMoreClick = (): void => {
|
||||
this.setState({ showMoreMenu: true });
|
||||
this.showControls();
|
||||
};
|
||||
|
||||
private closeDialpad = (): void => {
|
||||
this.setState({ showDialpad: false });
|
||||
};
|
||||
|
||||
private closeContextMenu = (): void => {
|
||||
this.setState({ showMoreMenu: false });
|
||||
};
|
||||
|
||||
public render(): JSX.Element {
|
||||
const micClasses = classNames("mx_CallViewButtons_button", {
|
||||
mx_CallViewButtons_button_micOn: !this.props.buttonsState.micMuted,
|
||||
mx_CallViewButtons_button_micOff: this.props.buttonsState.micMuted,
|
||||
});
|
||||
|
||||
const vidClasses = classNames("mx_CallViewButtons_button", {
|
||||
mx_CallViewButtons_button_vidOn: !this.props.buttonsState.vidMuted,
|
||||
mx_CallViewButtons_button_vidOff: this.props.buttonsState.vidMuted,
|
||||
});
|
||||
|
||||
const screensharingClasses = classNames("mx_CallViewButtons_button", {
|
||||
mx_CallViewButtons_button_screensharingOn: this.props.buttonsState.screensharing,
|
||||
mx_CallViewButtons_button_screensharingOff: !this.props.buttonsState.screensharing,
|
||||
});
|
||||
|
||||
const sidebarButtonClasses = classNames("mx_CallViewButtons_button", {
|
||||
mx_CallViewButtons_button_sidebarOn: this.props.buttonsState.sidebarShown,
|
||||
mx_CallViewButtons_button_sidebarOff: !this.props.buttonsState.sidebarShown,
|
||||
});
|
||||
|
||||
// Put the other states of the mic/video icons in the document to make sure they're cached
|
||||
// (otherwise the icon disappears briefly when toggled)
|
||||
const micCacheClasses = classNames("mx_CallViewButtons_button", "mx_CallViewButtons_button_invisible", {
|
||||
mx_CallViewButtons_button_micOn: this.props.buttonsState.micMuted,
|
||||
mx_CallViewButtons_button_micOff: !this.props.buttonsState.micMuted,
|
||||
});
|
||||
|
||||
const vidCacheClasses = classNames("mx_CallViewButtons_button", "mx_CallViewButtons_button_invisible", {
|
||||
mx_CallViewButtons_button_vidOn: this.props.buttonsState.micMuted,
|
||||
mx_CallViewButtons_button_vidOff: !this.props.buttonsState.micMuted,
|
||||
});
|
||||
|
||||
const callControlsClasses = classNames("mx_CallViewButtons", {
|
||||
mx_CallViewButtons_hidden: !this.state.visible,
|
||||
});
|
||||
|
||||
let vidMuteButton;
|
||||
if (this.props.buttonsVisibility.vidMute) {
|
||||
vidMuteButton = (
|
||||
<AccessibleTooltipButton
|
||||
className={vidClasses}
|
||||
onClick={this.props.handlers.onVidMuteClick}
|
||||
title={this.props.buttonsState.vidMuted ? _t("Start the camera") : _t("Stop the camera")}
|
||||
alignment={Alignment.Top}
|
||||
yOffset={TOOLTIP_Y_OFFSET}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
let screensharingButton;
|
||||
if (this.props.buttonsVisibility.screensharing) {
|
||||
screensharingButton = (
|
||||
<AccessibleTooltipButton
|
||||
className={screensharingClasses}
|
||||
onClick={this.props.handlers.onScreenshareClick}
|
||||
title={this.props.buttonsState.screensharing
|
||||
? _t("Stop sharing your screen")
|
||||
: _t("Start sharing your screen")
|
||||
}
|
||||
alignment={Alignment.Top}
|
||||
yOffset={TOOLTIP_Y_OFFSET}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
let sidebarButton;
|
||||
if (this.props.buttonsVisibility.sidebar) {
|
||||
sidebarButton = (
|
||||
<AccessibleTooltipButton
|
||||
className={sidebarButtonClasses}
|
||||
onClick={this.props.handlers.onToggleSidebarClick}
|
||||
title={this.props.buttonsState.sidebarShown ? _t("Hide sidebar") : _t("Show sidebar")}
|
||||
alignment={Alignment.Top}
|
||||
yOffset={TOOLTIP_Y_OFFSET}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
let contextMenuButton;
|
||||
if (this.props.buttonsVisibility.contextMenu) {
|
||||
contextMenuButton = (
|
||||
<ContextMenuTooltipButton
|
||||
className="mx_CallViewButtons_button mx_CallViewButtons_button_more"
|
||||
onClick={this.onMoreClick}
|
||||
inputRef={this.contextMenuButton}
|
||||
isExpanded={this.state.showMoreMenu}
|
||||
title={_t("More")}
|
||||
alignment={Alignment.Top}
|
||||
yOffset={TOOLTIP_Y_OFFSET}
|
||||
/>
|
||||
);
|
||||
}
|
||||
let dialpadButton;
|
||||
if (this.props.buttonsVisibility.dialpad) {
|
||||
dialpadButton = (
|
||||
<ContextMenuTooltipButton
|
||||
className="mx_CallViewButtons_button mx_CallViewButtons_dialpad"
|
||||
inputRef={this.dialpadButton}
|
||||
onClick={this.onDialpadClick}
|
||||
isExpanded={this.state.showDialpad}
|
||||
title={_t("Dialpad")}
|
||||
alignment={Alignment.Top}
|
||||
yOffset={TOOLTIP_Y_OFFSET}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
let dialPad;
|
||||
if (this.state.showDialpad) {
|
||||
dialPad = <DialpadContextMenu
|
||||
{...alwaysAboveRightOf(
|
||||
this.dialpadButton.current.getBoundingClientRect(),
|
||||
ChevronFace.None,
|
||||
CONTEXT_MENU_VPADDING,
|
||||
)}
|
||||
// We mount the context menus as a as a child typically in order to include the
|
||||
// context menus when fullscreening the call content.
|
||||
// However, this does not work as well when the call is embedded in a
|
||||
// picture-in-picture frame. Thus, only mount as child when we are *not* in PiP.
|
||||
mountAsChild={!this.props.pipMode}
|
||||
onFinished={this.closeDialpad}
|
||||
call={this.props.call}
|
||||
/>;
|
||||
}
|
||||
|
||||
let contextMenu;
|
||||
if (this.state.showMoreMenu) {
|
||||
contextMenu = <CallContextMenu
|
||||
{...alwaysAboveLeftOf(
|
||||
this.contextMenuButton.current.getBoundingClientRect(),
|
||||
ChevronFace.None,
|
||||
CONTEXT_MENU_VPADDING,
|
||||
)}
|
||||
mountAsChild={!this.props.pipMode}
|
||||
onFinished={this.closeContextMenu}
|
||||
call={this.props.call}
|
||||
/>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={callControlsClasses}
|
||||
onMouseEnter={this.onMouseEnter}
|
||||
onMouseLeave={this.onMouseLeave}
|
||||
>
|
||||
{ dialPad }
|
||||
{ contextMenu }
|
||||
{ dialpadButton }
|
||||
<AccessibleTooltipButton
|
||||
className={micClasses}
|
||||
onClick={this.props.handlers.onMicMuteClick}
|
||||
title={this.props.buttonsState.micMuted ? _t("Unmute the microphone") : _t("Mute the microphone")}
|
||||
alignment={Alignment.Top}
|
||||
yOffset={TOOLTIP_Y_OFFSET}
|
||||
/>
|
||||
{ vidMuteButton }
|
||||
<div className={micCacheClasses} />
|
||||
<div className={vidCacheClasses} />
|
||||
{ screensharingButton }
|
||||
{ sidebarButton }
|
||||
{ contextMenuButton }
|
||||
<AccessibleTooltipButton
|
||||
className="mx_CallViewButtons_button mx_CallViewButtons_button_hangup"
|
||||
onClick={this.props.handlers.onHangupClick}
|
||||
title={_t("Hangup")}
|
||||
alignment={Alignment.Top}
|
||||
yOffset={TOOLTIP_Y_OFFSET}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
135
src/components/views/voip/CallView/CallViewHeader.tsx
Normal file
135
src/components/views/voip/CallView/CallViewHeader.tsx
Normal file
|
@ -0,0 +1,135 @@
|
|||
/*
|
||||
Copyright 2021 New Vector Ltd
|
||||
|
||||
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 { CallType } from 'matrix-js-sdk/src/webrtc/call';
|
||||
import { Room } from 'matrix-js-sdk/src/models/room';
|
||||
import React from 'react';
|
||||
import { _t, _td } from '../../../../languageHandler';
|
||||
import RoomAvatar from '../../avatars/RoomAvatar';
|
||||
import AccessibleButton from '../../elements/AccessibleButton';
|
||||
import dis from '../../../../dispatcher/dispatcher';
|
||||
import classNames from 'classnames';
|
||||
import AccessibleTooltipButton from '../../elements/AccessibleTooltipButton';
|
||||
|
||||
const callTypeTranslationByType: Record<CallType, string> = {
|
||||
[CallType.Video]: _td("Video Call"),
|
||||
[CallType.Voice]: _td("Voice Call"),
|
||||
};
|
||||
|
||||
interface CallViewHeaderProps {
|
||||
pipMode: boolean;
|
||||
type: CallType;
|
||||
callRooms?: Room[];
|
||||
onPipMouseDown: (event: React.MouseEvent<Element, MouseEvent>) => void;
|
||||
}
|
||||
|
||||
const onRoomAvatarClick = (roomId: string) => {
|
||||
dis.dispatch({
|
||||
action: 'view_room',
|
||||
room_id: roomId,
|
||||
});
|
||||
};
|
||||
|
||||
const onFullscreenClick = () => {
|
||||
dis.dispatch({
|
||||
action: 'video_fullscreen',
|
||||
fullscreen: true,
|
||||
});
|
||||
};
|
||||
|
||||
const onExpandClick = (roomId: string) => {
|
||||
dis.dispatch({
|
||||
action: 'view_room',
|
||||
room_id: roomId,
|
||||
});
|
||||
};
|
||||
|
||||
type CallControlsProps = Pick<CallViewHeaderProps, 'pipMode' | 'type'> & {
|
||||
roomId: string;
|
||||
};
|
||||
const CallViewHeaderControls: React.FC<CallControlsProps> = ({ pipMode = false, type, roomId }) => {
|
||||
return <div className="mx_CallViewHeader_controls">
|
||||
{ !pipMode && <AccessibleTooltipButton
|
||||
className="mx_CallViewHeader_button mx_CallViewHeader_button_fullscreen"
|
||||
onClick={onFullscreenClick}
|
||||
title={_t("Fill Screen")}
|
||||
/> }
|
||||
{ pipMode && <AccessibleTooltipButton
|
||||
className="mx_CallViewHeader_button mx_CallViewHeader_button_expand"
|
||||
onClick={() => onExpandClick(roomId)}
|
||||
title={_t("Return to call")}
|
||||
/> }
|
||||
</div>;
|
||||
};
|
||||
const SecondaryCallInfo: React.FC<{ callRoom: Room }> = ({ callRoom }) => {
|
||||
return <span className="mx_CallViewHeader_secondaryCallInfo">
|
||||
<AccessibleButton element='span' onClick={() => onRoomAvatarClick(callRoom.roomId)}>
|
||||
<RoomAvatar room={callRoom} height={16} width={16} />
|
||||
<span className="mx_CallView_secondaryCall_roomName">
|
||||
{ _t("%(name)s on hold", { name: callRoom.name }) }
|
||||
</span>
|
||||
</AccessibleButton>
|
||||
</span>;
|
||||
};
|
||||
|
||||
const CallTypeIcon: React.FC<{ type: CallType }> = ({ type }) => {
|
||||
const classes = classNames({
|
||||
'mx_CallViewHeader_callTypeIcon': true,
|
||||
'mx_CallViewHeader_callTypeIcon_video': type === CallType.Video,
|
||||
'mx_CallViewHeader_callTypeIcon_voice': type === CallType.Voice,
|
||||
});
|
||||
return <div className={classes} />;
|
||||
};
|
||||
|
||||
const CallViewHeader: React.FC<CallViewHeaderProps> = ({
|
||||
type,
|
||||
pipMode = false,
|
||||
callRooms = [],
|
||||
onPipMouseDown,
|
||||
}) => {
|
||||
const [callRoom, onHoldCallRoom] = callRooms;
|
||||
const callTypeText = _t(callTypeTranslationByType[type]);
|
||||
const callRoomName = callRoom.name;
|
||||
const { roomId } = callRoom;
|
||||
|
||||
if (!pipMode) {
|
||||
return <div className="mx_CallViewHeader">
|
||||
<CallTypeIcon type={type} />
|
||||
<span className="mx_CallViewHeader_callType">{ callTypeText }</span>
|
||||
<CallViewHeaderControls roomId={roomId} pipMode={pipMode} type={type} />
|
||||
</div>;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className="mx_CallViewHeader"
|
||||
onMouseDown={onPipMouseDown}
|
||||
>
|
||||
<AccessibleButton onClick={() => onRoomAvatarClick(roomId)}>
|
||||
<RoomAvatar room={callRoom} height={32} width={32} />
|
||||
</AccessibleButton>
|
||||
<div className="mx_CallViewHeader_callInfo">
|
||||
<div className="mx_CallViewHeader_roomName">{ callRoomName }</div>
|
||||
<div className="mx_CallViewHeader_callTypeSmall">
|
||||
{ callTypeText }
|
||||
{ onHoldCallRoom && <SecondaryCallInfo callRoom={onHoldCallRoom} /> }
|
||||
</div>
|
||||
</div>
|
||||
<CallViewHeaderControls roomId={roomId} pipMode={pipMode} type={type} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CallViewHeader;
|
|
@ -15,7 +15,7 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import * as React from "react";
|
||||
import AccessibleButton from "../elements/AccessibleButton";
|
||||
import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
|
||||
const BUTTONS = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '*', '0', '#'];
|
||||
|
@ -30,12 +30,12 @@ interface IButtonProps {
|
|||
kind: DialPadButtonKind;
|
||||
digit?: string;
|
||||
digitSubtext?: string;
|
||||
onButtonPress: (string) => void;
|
||||
onButtonPress: (digit: string, ev: ButtonEvent) => void;
|
||||
}
|
||||
|
||||
class DialPadButton extends React.PureComponent<IButtonProps> {
|
||||
onClick = () => {
|
||||
this.props.onButtonPress(this.props.digit);
|
||||
onClick = (ev: ButtonEvent) => {
|
||||
this.props.onButtonPress(this.props.digit, ev);
|
||||
};
|
||||
|
||||
render() {
|
||||
|
@ -54,10 +54,10 @@ class DialPadButton extends React.PureComponent<IButtonProps> {
|
|||
}
|
||||
|
||||
interface IProps {
|
||||
onDigitPress: (string) => void;
|
||||
onDigitPress: (digit: string, ev: ButtonEvent) => void;
|
||||
hasDial: boolean;
|
||||
onDeletePress?: (string) => void;
|
||||
onDialPress?: (string) => void;
|
||||
onDeletePress?: (ev: ButtonEvent) => void;
|
||||
onDialPress?: () => void;
|
||||
}
|
||||
|
||||
@replaceableComponent("views.voip.DialPad")
|
||||
|
|
|
@ -15,7 +15,8 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import * as React from "react";
|
||||
import AccessibleButton from "../elements/AccessibleButton";
|
||||
import { createRef } from "react";
|
||||
import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton";
|
||||
import Field from "../elements/Field";
|
||||
import DialPad from './DialPad';
|
||||
import dis from '../../../dispatcher/dispatcher';
|
||||
|
@ -34,6 +35,8 @@ interface IState {
|
|||
|
||||
@replaceableComponent("views.voip.DialPadModal")
|
||||
export default class DialpadModal extends React.PureComponent<IProps, IState> {
|
||||
private numberEntryFieldRef: React.RefObject<Field> = createRef();
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
|
@ -54,13 +57,27 @@ export default class DialpadModal extends React.PureComponent<IProps, IState> {
|
|||
this.onDialPress();
|
||||
};
|
||||
|
||||
onDigitPress = (digit) => {
|
||||
onDigitPress = (digit: string, ev: ButtonEvent) => {
|
||||
this.setState({ value: this.state.value + digit });
|
||||
|
||||
// Keep the number field focused so that keyboard entry is still available.
|
||||
// However, don't focus if this wasn't the result of directly clicking on the button,
|
||||
// i.e someone using keyboard navigation.
|
||||
if (ev.type === "click") {
|
||||
this.numberEntryFieldRef.current?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
onDeletePress = () => {
|
||||
onDeletePress = (ev: ButtonEvent) => {
|
||||
if (this.state.value.length === 0) return;
|
||||
this.setState({ value: this.state.value.slice(0, -1) });
|
||||
|
||||
// Keep the number field focused so that keyboard entry is still available
|
||||
// However, don't focus if this wasn't the result of directly clicking on the button,
|
||||
// i.e someone using keyboard navigation.
|
||||
if (ev.type === "click") {
|
||||
this.numberEntryFieldRef.current?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
onDialPress = async () => {
|
||||
|
@ -82,6 +99,7 @@ export default class DialpadModal extends React.PureComponent<IProps, IState> {
|
|||
let dialPadField;
|
||||
if (this.state.value.length !== 0) {
|
||||
dialPadField = <Field
|
||||
ref={this.numberEntryFieldRef}
|
||||
className="mx_DialPadModal_field"
|
||||
id="dialpad_number"
|
||||
value={this.state.value}
|
||||
|
@ -91,6 +109,7 @@ export default class DialpadModal extends React.PureComponent<IProps, IState> {
|
|||
/>;
|
||||
} else {
|
||||
dialPadField = <Field
|
||||
ref={this.numberEntryFieldRef}
|
||||
className="mx_DialPadModal_field"
|
||||
id="dialpad_number"
|
||||
value={this.state.value}
|
||||
|
|
229
src/components/views/voip/PictureInPictureDragger.tsx
Normal file
229
src/components/views/voip/PictureInPictureDragger.tsx
Normal file
|
@ -0,0 +1,229 @@
|
|||
/*
|
||||
Copyright 2021 New Vector Ltd
|
||||
|
||||
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, { createRef } from 'react';
|
||||
import UIStore from '../../../stores/UIStore';
|
||||
import { lerp } from '../../../utils/AnimationUtils';
|
||||
import { MarkedExecution } from '../../../utils/MarkedExecution';
|
||||
import { replaceableComponent } from '../../../utils/replaceableComponent';
|
||||
|
||||
const PIP_VIEW_WIDTH = 336;
|
||||
const PIP_VIEW_HEIGHT = 232;
|
||||
|
||||
const MOVING_AMT = 0.2;
|
||||
const SNAPPING_AMT = 0.1;
|
||||
|
||||
const PADDING = {
|
||||
top: 58,
|
||||
bottom: 58,
|
||||
left: 76,
|
||||
right: 8,
|
||||
};
|
||||
|
||||
interface IChildrenOptions {
|
||||
onStartMoving: (event: React.MouseEvent<Element, MouseEvent>) => void;
|
||||
onResize: (event: Event) => void;
|
||||
}
|
||||
|
||||
interface IProps {
|
||||
className?: string;
|
||||
children: ({ onStartMoving, onResize }: IChildrenOptions) => React.ReactNode;
|
||||
draggable: boolean;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
// Position of the PictureInPictureDragger
|
||||
translationX: number;
|
||||
translationY: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* PictureInPictureDragger shows a small version of CallView hovering over the UI in 'picture-in-picture'
|
||||
* (PiP mode). It displays the call(s) which is *not* in the room the user is currently viewing.
|
||||
*/
|
||||
@replaceableComponent("views.voip.PictureInPictureDragger")
|
||||
export default class PictureInPictureDragger extends React.Component<IProps, IState> {
|
||||
private callViewWrapper = createRef<HTMLDivElement>();
|
||||
private initX = 0;
|
||||
private initY = 0;
|
||||
private desiredTranslationX = UIStore.instance.windowWidth - PADDING.right - PIP_VIEW_WIDTH;
|
||||
private desiredTranslationY = UIStore.instance.windowHeight - PADDING.bottom - PIP_VIEW_HEIGHT;
|
||||
private moving = false;
|
||||
private scheduledUpdate = new MarkedExecution(
|
||||
() => this.animationCallback(),
|
||||
() => requestAnimationFrame(() => this.scheduledUpdate.trigger()),
|
||||
);
|
||||
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
translationX: UIStore.instance.windowWidth - PADDING.right - PIP_VIEW_WIDTH,
|
||||
translationY: UIStore.instance.windowHeight - PADDING.bottom - PIP_VIEW_HEIGHT,
|
||||
};
|
||||
}
|
||||
|
||||
public componentDidMount() {
|
||||
document.addEventListener("mousemove", this.onMoving);
|
||||
document.addEventListener("mouseup", this.onEndMoving);
|
||||
window.addEventListener("resize", this.onResize);
|
||||
}
|
||||
|
||||
public componentWillUnmount() {
|
||||
document.removeEventListener("mousemove", this.onMoving);
|
||||
document.removeEventListener("mouseup", this.onEndMoving);
|
||||
window.removeEventListener("resize", this.onResize);
|
||||
}
|
||||
|
||||
private animationCallback = () => {
|
||||
// If the PiP isn't being dragged and there is only a tiny difference in
|
||||
// the desiredTranslation and translation, quit the animationCallback
|
||||
// loop. If that is the case, it means the PiP has snapped into its
|
||||
// position and there is nothing to do. Not doing this would cause an
|
||||
// infinite loop
|
||||
if (
|
||||
!this.moving &&
|
||||
Math.abs(this.state.translationX - this.desiredTranslationX) <= 1 &&
|
||||
Math.abs(this.state.translationY - this.desiredTranslationY) <= 1
|
||||
) return;
|
||||
|
||||
const amt = this.moving ? MOVING_AMT : SNAPPING_AMT;
|
||||
this.setState({
|
||||
translationX: lerp(this.state.translationX, this.desiredTranslationX, amt),
|
||||
translationY: lerp(this.state.translationY, this.desiredTranslationY, amt),
|
||||
});
|
||||
this.scheduledUpdate.mark();
|
||||
};
|
||||
|
||||
private setTranslation(inTranslationX: number, inTranslationY: number) {
|
||||
const width = this.callViewWrapper.current?.clientWidth || PIP_VIEW_WIDTH;
|
||||
const height = this.callViewWrapper.current?.clientHeight || PIP_VIEW_HEIGHT;
|
||||
|
||||
// Avoid overflow on the x axis
|
||||
if (inTranslationX + width >= UIStore.instance.windowWidth) {
|
||||
this.desiredTranslationX = UIStore.instance.windowWidth - width;
|
||||
} else if (inTranslationX <= 0) {
|
||||
this.desiredTranslationX = 0;
|
||||
} else {
|
||||
this.desiredTranslationX = inTranslationX;
|
||||
}
|
||||
|
||||
// Avoid overflow on the y axis
|
||||
if (inTranslationY + height >= UIStore.instance.windowHeight) {
|
||||
this.desiredTranslationY = UIStore.instance.windowHeight - height;
|
||||
} else if (inTranslationY <= 0) {
|
||||
this.desiredTranslationY = 0;
|
||||
} else {
|
||||
this.desiredTranslationY = inTranslationY;
|
||||
}
|
||||
}
|
||||
|
||||
private onResize = (): void => {
|
||||
this.snap(false);
|
||||
};
|
||||
|
||||
private snap = (animate = false) => {
|
||||
const translationX = this.desiredTranslationX;
|
||||
const translationY = this.desiredTranslationY;
|
||||
// We subtract the PiP size from the window size in order to calculate
|
||||
// the position to snap to from the PiP center and not its top-left
|
||||
// corner
|
||||
const windowWidth = (
|
||||
UIStore.instance.windowWidth -
|
||||
(this.callViewWrapper.current?.clientWidth || PIP_VIEW_WIDTH)
|
||||
);
|
||||
const windowHeight = (
|
||||
UIStore.instance.windowHeight -
|
||||
(this.callViewWrapper.current?.clientHeight || PIP_VIEW_HEIGHT)
|
||||
);
|
||||
|
||||
if (translationX >= windowWidth / 2 && translationY >= windowHeight / 2) {
|
||||
this.desiredTranslationX = windowWidth - PADDING.right;
|
||||
this.desiredTranslationY = windowHeight - PADDING.bottom;
|
||||
} else if (translationX >= windowWidth / 2 && translationY <= windowHeight / 2) {
|
||||
this.desiredTranslationX = windowWidth - PADDING.right;
|
||||
this.desiredTranslationY = PADDING.top;
|
||||
} else if (translationX <= windowWidth / 2 && translationY >= windowHeight / 2) {
|
||||
this.desiredTranslationX = PADDING.left;
|
||||
this.desiredTranslationY = windowHeight - PADDING.bottom;
|
||||
} else {
|
||||
this.desiredTranslationX = PADDING.left;
|
||||
this.desiredTranslationY = PADDING.top;
|
||||
}
|
||||
|
||||
// We start animating here because we want the PiP to move when we're
|
||||
// resizing the window
|
||||
this.scheduledUpdate.mark();
|
||||
|
||||
if (animate) {
|
||||
// We start animating here because we want the PiP to move when we're
|
||||
// resizing the window
|
||||
this.scheduledUpdate.mark();
|
||||
} else {
|
||||
this.setState({
|
||||
translationX: this.desiredTranslationX,
|
||||
translationY: this.desiredTranslationY,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
private onStartMoving = (event: React.MouseEvent | MouseEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
this.moving = true;
|
||||
this.initX = event.pageX - this.desiredTranslationX;
|
||||
this.initY = event.pageY - this.desiredTranslationY;
|
||||
this.scheduledUpdate.mark();
|
||||
};
|
||||
|
||||
private onMoving = (event: React.MouseEvent | MouseEvent) => {
|
||||
if (!this.moving) return;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
this.setTranslation(event.pageX - this.initX, event.pageY - this.initY);
|
||||
};
|
||||
|
||||
private onEndMoving = () => {
|
||||
this.moving = false;
|
||||
this.snap(true);
|
||||
};
|
||||
|
||||
public render() {
|
||||
const translatePixelsX = this.state.translationX + "px";
|
||||
const translatePixelsY = this.state.translationY + "px";
|
||||
const style = {
|
||||
transform: `translateX(${translatePixelsX})
|
||||
translateY(${translatePixelsY})`,
|
||||
};
|
||||
return (
|
||||
<div
|
||||
className={this.props.className}
|
||||
style={this.props.draggable ? style : undefined}
|
||||
ref={this.callViewWrapper}
|
||||
>
|
||||
<>
|
||||
{ this.props.children({
|
||||
onStartMoving: this.onStartMoving,
|
||||
onResize: this.onResize,
|
||||
}) }
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -31,18 +31,26 @@ export interface IEncryptedFile {
|
|||
v: string;
|
||||
}
|
||||
|
||||
export interface IMediaEventContent {
|
||||
body?: string;
|
||||
url?: string; // required on unencrypted media
|
||||
file?: IEncryptedFile; // required for *encrypted* media
|
||||
info?: {
|
||||
thumbnail_url?: string; // eslint-disable-line camelcase
|
||||
thumbnail_file?: IEncryptedFile; // eslint-disable-line camelcase
|
||||
export interface IMediaEventInfo {
|
||||
thumbnail_url?: string; // eslint-disable-line camelcase
|
||||
thumbnail_file?: IEncryptedFile; // eslint-disable-line camelcase
|
||||
thumbnail_info?: { // eslint-disable-line camelcase
|
||||
mimetype: string;
|
||||
w?: number;
|
||||
h?: number;
|
||||
size?: number;
|
||||
};
|
||||
mimetype: string;
|
||||
w?: number;
|
||||
h?: number;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export interface IMediaEventContent {
|
||||
body?: string;
|
||||
url?: string; // required on unencrypted media
|
||||
file?: IEncryptedFile; // required for *encrypted* media
|
||||
info?: IMediaEventInfo;
|
||||
}
|
||||
|
||||
export interface IPreparedMedia extends IMediaObject {
|
||||
|
|
|
@ -43,7 +43,7 @@ export default class AutocompleteWrapperModel {
|
|||
) {
|
||||
}
|
||||
|
||||
public onEscape(e: KeyboardEvent) {
|
||||
public onEscape(e: KeyboardEvent): void {
|
||||
this.getAutocompleterComponent().onEscape(e);
|
||||
this.updateCallback({
|
||||
replaceParts: [this.partCreator.plain(this.queryPart.text)],
|
||||
|
@ -51,27 +51,27 @@ export default class AutocompleteWrapperModel {
|
|||
});
|
||||
}
|
||||
|
||||
public close() {
|
||||
public close(): void {
|
||||
this.updateCallback({ close: true });
|
||||
}
|
||||
|
||||
public hasSelection() {
|
||||
public hasSelection(): boolean {
|
||||
return this.getAutocompleterComponent().hasSelection();
|
||||
}
|
||||
|
||||
public hasCompletions() {
|
||||
public hasCompletions(): boolean {
|
||||
const ac = this.getAutocompleterComponent();
|
||||
return ac && ac.countCompletions() > 0;
|
||||
}
|
||||
|
||||
public onEnter() {
|
||||
public onEnter(): void {
|
||||
this.updateCallback({ close: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* If there is no current autocompletion, start one and move to the first selection.
|
||||
*/
|
||||
public async startSelection() {
|
||||
public async startSelection(): Promise<void> {
|
||||
const acComponent = this.getAutocompleterComponent();
|
||||
if (acComponent.countCompletions() === 0) {
|
||||
// Force completions to show for the text currently entered
|
||||
|
@ -81,15 +81,15 @@ export default class AutocompleteWrapperModel {
|
|||
}
|
||||
}
|
||||
|
||||
public selectPreviousSelection() {
|
||||
public selectPreviousSelection(): void {
|
||||
this.getAutocompleterComponent().moveSelection(-1);
|
||||
}
|
||||
|
||||
public selectNextSelection() {
|
||||
public selectNextSelection(): void {
|
||||
this.getAutocompleterComponent().moveSelection(+1);
|
||||
}
|
||||
|
||||
public onPartUpdate(part: Part, pos: DocumentPosition) {
|
||||
public onPartUpdate(part: Part, pos: DocumentPosition): Promise<void> {
|
||||
// cache the typed value and caret here
|
||||
// so we can restore it in onComponentSelectionChange when the value is undefined (meaning it should be the typed text)
|
||||
this.queryPart = part;
|
||||
|
@ -97,7 +97,7 @@ export default class AutocompleteWrapperModel {
|
|||
return this.updateQuery(part.text);
|
||||
}
|
||||
|
||||
public onComponentSelectionChange(completion: ICompletion) {
|
||||
public onComponentSelectionChange(completion: ICompletion): void {
|
||||
if (!completion) {
|
||||
this.updateCallback({
|
||||
replaceParts: [this.queryPart],
|
||||
|
@ -109,14 +109,14 @@ export default class AutocompleteWrapperModel {
|
|||
}
|
||||
}
|
||||
|
||||
public onComponentConfirm(completion: ICompletion) {
|
||||
public onComponentConfirm(completion: ICompletion): void {
|
||||
this.updateCallback({
|
||||
replaceParts: this.partForCompletion(completion),
|
||||
close: true,
|
||||
});
|
||||
}
|
||||
|
||||
private partForCompletion(completion: ICompletion) {
|
||||
private partForCompletion(completion: ICompletion): Part[] {
|
||||
const { completionId } = completion;
|
||||
const text = completion.completion;
|
||||
switch (completion.type) {
|
||||
|
|
|
@ -19,7 +19,7 @@ import { needsCaretNodeBefore, needsCaretNodeAfter } from "./render";
|
|||
import Range from "./range";
|
||||
import EditorModel from "./model";
|
||||
import DocumentPosition, { IPosition } from "./position";
|
||||
import { Part } from "./parts";
|
||||
import { Part, Type } from "./parts";
|
||||
|
||||
export type Caret = Range | DocumentPosition;
|
||||
|
||||
|
@ -113,7 +113,7 @@ function findNodeInLineForPart(parts: Part[], partIndex: number) {
|
|||
// to find newline parts
|
||||
for (let i = 0; i <= partIndex; ++i) {
|
||||
const part = parts[i];
|
||||
if (part.type === "newline") {
|
||||
if (part.type === Type.Newline) {
|
||||
lineIndex += 1;
|
||||
nodeIndex = -1;
|
||||
prevPart = null;
|
||||
|
@ -128,7 +128,7 @@ function findNodeInLineForPart(parts: Part[], partIndex: number) {
|
|||
// and not an adjacent caret node
|
||||
if (i < partIndex) {
|
||||
const nextPart = parts[i + 1];
|
||||
const isLastOfLine = !nextPart || nextPart.type === "newline";
|
||||
const isLastOfLine = !nextPart || nextPart.type === Type.Newline;
|
||||
if (needsCaretNodeAfter(part, isLastOfLine)) {
|
||||
nodeIndex += 1;
|
||||
}
|
||||
|
|
|
@ -20,7 +20,7 @@ import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
|||
import { walkDOMDepthFirst } from "./dom";
|
||||
import { checkBlockNode } from "../HtmlUtils";
|
||||
import { getPrimaryPermalinkEntity } from "../utils/permalinks/Permalinks";
|
||||
import { PartCreator } from "./parts";
|
||||
import { PartCreator, Type } from "./parts";
|
||||
import SdkConfig from "../SdkConfig";
|
||||
|
||||
function parseAtRoomMentions(text: string, partCreator: PartCreator) {
|
||||
|
@ -206,7 +206,7 @@ function prefixQuoteLines(isFirstNode, parts, partCreator) {
|
|||
parts.splice(0, 0, partCreator.plain(QUOTE_LINE_PREFIX));
|
||||
}
|
||||
for (let i = 0; i < parts.length; i += 1) {
|
||||
if (parts[i].type === "newline") {
|
||||
if (parts[i].type === Type.Newline) {
|
||||
parts.splice(i + 1, 0, partCreator.plain(QUOTE_LINE_PREFIX));
|
||||
i += 1;
|
||||
}
|
||||
|
|
|
@ -21,7 +21,7 @@ export interface IDiff {
|
|||
at?: number;
|
||||
}
|
||||
|
||||
function firstDiff(a: string, b: string) {
|
||||
function firstDiff(a: string, b: string): number {
|
||||
const compareLen = Math.min(a.length, b.length);
|
||||
for (let i = 0; i < compareLen; ++i) {
|
||||
if (a[i] !== b[i]) {
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue