Merge remote-tracking branch 'upstream/develop' into fix/rl-resort/110
This commit is contained in:
commit
10e42d4d48
38 changed files with 1196 additions and 833 deletions
12
.github/workflows/layered-build.yaml
vendored
12
.github/workflows/layered-build.yaml
vendored
|
@ -16,4 +16,16 @@ jobs:
|
||||||
path: element-web/webapp
|
path: element-web/webapp
|
||||||
# We'll only use this in a triggered job, then we're done with it
|
# We'll only use this in a triggered job, then we're done with it
|
||||||
retention-days: 1
|
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
|
||||||
|
|
||||||
|
|
36
.github/workflows/netflify.yaml
vendored
36
.github/workflows/netflify.yaml
vendored
|
@ -33,12 +33,33 @@ jobs:
|
||||||
});
|
});
|
||||||
var fs = require('fs');
|
var fs = require('fs');
|
||||||
fs.writeFileSync('${{github.workspace}}/previewbuild.zip', Buffer.from(download.data));
|
fs.writeFileSync('${{github.workspace}}/previewbuild.zip', Buffer.from(download.data));
|
||||||
- run: unzip previewbuild.zip && rm previewbuild.zip
|
|
||||||
|
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
|
- name: Deploy to Netlify
|
||||||
id: netlify
|
id: netlify
|
||||||
uses: nwtgck/actions-netlify@v1.2
|
uses: nwtgck/actions-netlify@v1.2
|
||||||
with:
|
with:
|
||||||
publish-dir: .
|
publish-dir: webapp
|
||||||
deploy-message: "Deploy from GitHub Actions"
|
deploy-message: "Deploy from GitHub Actions"
|
||||||
# These don't work because we're in workflow_run
|
# These don't work because we're in workflow_run
|
||||||
enable-pull-request-comment: false
|
enable-pull-request-comment: false
|
||||||
|
@ -47,12 +68,13 @@ jobs:
|
||||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
|
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
|
||||||
timeout-minutes: 1
|
timeout-minutes: 1
|
||||||
- name: Comment on PR
|
- name: Edit PR Description
|
||||||
uses: phulsechinmay/rewritable-pr-comment@v0.3.0
|
uses: velas/pr-description@v1.0.1
|
||||||
with:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
ISSUE_ID: ${{ github.event.workflow_run.pull_requests[0].number }}
|
with:
|
||||||
message: |
|
pull-request-number: ${{ steps.readctx.outputs.prnumber }}
|
||||||
|
description-message: |
|
||||||
Preview: ${{ steps.netlify.outputs.deploy-url }}
|
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.
|
⚠️ Do you trust the author of this PR? Maybe this build will steal your keys or give you malware. Exercise caution. Use test accounts.
|
||||||
|
|
||||||
|
|
|
@ -270,6 +270,7 @@
|
||||||
@import "./views/toasts/_IncomingCallToast.scss";
|
@import "./views/toasts/_IncomingCallToast.scss";
|
||||||
@import "./views/toasts/_NonUrgentEchoFailureToast.scss";
|
@import "./views/toasts/_NonUrgentEchoFailureToast.scss";
|
||||||
@import "./views/verification/_VerificationShowSas.scss";
|
@import "./views/verification/_VerificationShowSas.scss";
|
||||||
|
@import "./views/voip/CallView/_CallViewButtons.scss";
|
||||||
@import "./views/voip/_CallContainer.scss";
|
@import "./views/voip/_CallContainer.scss";
|
||||||
@import "./views/voip/_CallPreview.scss";
|
@import "./views/voip/_CallPreview.scss";
|
||||||
@import "./views/voip/_CallView.scss";
|
@import "./views/voip/_CallView.scss";
|
||||||
|
|
|
@ -269,7 +269,7 @@ limitations under the License.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover, &:focus-within {
|
||||||
background-color: $groupFilterPanel-bg-color;
|
background-color: $groupFilterPanel-bg-color;
|
||||||
|
|
||||||
.mx_AccessibleButton {
|
.mx_AccessibleButton {
|
||||||
|
@ -278,6 +278,10 @@ limitations under the License.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
li.mx_SpaceRoomDirectory_roomTileWrapper {
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
.mx_SpaceRoomDirectory_roomTile,
|
.mx_SpaceRoomDirectory_roomTile,
|
||||||
.mx_SpaceRoomDirectory_subspace_children {
|
.mx_SpaceRoomDirectory_subspace_children {
|
||||||
&::before {
|
&::before {
|
||||||
|
|
|
@ -65,6 +65,14 @@ limitations under the License.
|
||||||
font-size: $font-10-4px;
|
font-size: $font-10-4px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
span.mx_UserPill {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
span.mx_RoomPill {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.mx_BasicMessageComposer_input_disabled {
|
&.mx_BasicMessageComposer_input_disabled {
|
||||||
|
|
|
@ -489,6 +489,10 @@ $hover-select-border: 4px;
|
||||||
// https://github.com/vector-im/vector-web/issues/754
|
// https://github.com/vector-im/vector-web/issues/754
|
||||||
overflow-x: overlay;
|
overflow-x: overlay;
|
||||||
overflow-y: visible;
|
overflow-y: visible;
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-corner {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -47,11 +47,11 @@ limitations under the License.
|
||||||
height: 180px;
|
height: 180px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mx_CallView_callControls {
|
.mx_CallViewButtons {
|
||||||
bottom: 0px;
|
bottom: 0px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mx_CallView_callControls_button {
|
.mx_CallViewButtons_button {
|
||||||
&::before {
|
&::before {
|
||||||
width: 36px;
|
width: 36px;
|
||||||
height: 36px;
|
height: 36px;
|
||||||
|
@ -199,20 +199,6 @@ limitations under the License.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.mx_CallView_callControls {
|
|
||||||
position: absolute;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
bottom: 5px;
|
|
||||||
opacity: 1;
|
|
||||||
transition: opacity 0.5s;
|
|
||||||
z-index: 200; // To be above _all_ feeds
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx_CallView_callControls_hidden {
|
|
||||||
opacity: 0.001; // opacity 0 can cause a re-layout
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx_CallView_presenting {
|
.mx_CallView_presenting {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
|
@ -232,94 +218,3 @@ limitations under the License.
|
||||||
opacity: 0.001; // opacity 0 can cause a re-layout
|
opacity: 0.001; // opacity 0 can cause a re-layout
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mx_CallView_callControls_button {
|
|
||||||
cursor: pointer;
|
|
||||||
margin-left: 2px;
|
|
||||||
margin-right: 2px;
|
|
||||||
|
|
||||||
|
|
||||||
&::before {
|
|
||||||
content: '';
|
|
||||||
display: inline-block;
|
|
||||||
|
|
||||||
height: 48px;
|
|
||||||
width: 48px;
|
|
||||||
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
background-size: contain;
|
|
||||||
background-position: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx_CallView_callControls_dialpad {
|
|
||||||
&::before {
|
|
||||||
background-image: url('$(res)/img/voip/dialpad.svg');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx_CallView_callControls_button_micOn {
|
|
||||||
&::before {
|
|
||||||
background-image: url('$(res)/img/voip/mic-on.svg');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx_CallView_callControls_button_micOff {
|
|
||||||
&::before {
|
|
||||||
background-image: url('$(res)/img/voip/mic-off.svg');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx_CallView_callControls_button_vidOn {
|
|
||||||
&::before {
|
|
||||||
background-image: url('$(res)/img/voip/vid-on.svg');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx_CallView_callControls_button_vidOff {
|
|
||||||
&::before {
|
|
||||||
background-image: url('$(res)/img/voip/vid-off.svg');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx_CallView_callControls_button_screensharingOn {
|
|
||||||
&::before {
|
|
||||||
background-image: url('$(res)/img/voip/screensharing-on.svg');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx_CallView_callControls_button_screensharingOff {
|
|
||||||
&::before {
|
|
||||||
background-image: url('$(res)/img/voip/screensharing-off.svg');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx_CallView_callControls_button_sidebarOn {
|
|
||||||
&::before {
|
|
||||||
background-image: url('$(res)/img/voip/sidebar-on.svg');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx_CallView_callControls_button_sidebarOff {
|
|
||||||
&::before {
|
|
||||||
background-image: url('$(res)/img/voip/sidebar-off.svg');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx_CallView_callControls_button_hangup {
|
|
||||||
&::before {
|
|
||||||
background-image: url('$(res)/img/voip/hangup.svg');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx_CallView_callControls_button_more {
|
|
||||||
&::before {
|
|
||||||
background-image: url('$(res)/img/voip/more.svg');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx_CallView_callControls_button_invisible {
|
|
||||||
visibility: hidden;
|
|
||||||
pointer-events: none;
|
|
||||||
position: absolute;
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,18 +1,35 @@
|
||||||
// Colors from Figma Compound https://www.figma.com/file/X4XTH9iS2KGJ2wFKDqkyed/Compound?node-id=557%3A0
|
// Colors from Figma Compound https://www.figma.com/file/X4XTH9iS2KGJ2wFKDqkyed/Compound?node-id=559%3A741
|
||||||
|
$accent: #0DBD8B;
|
||||||
|
$alert: #FF5B55;
|
||||||
|
$links: #0086e6;
|
||||||
|
$primary-content: #ffffff;
|
||||||
|
$secondary-content: #A9B2BC;
|
||||||
|
$tertiary-content: #8E99A4;
|
||||||
|
$quaternary-content: #6F7882;
|
||||||
|
$quinary-content: #394049;
|
||||||
$system-dark: #21262C;
|
$system-dark: #21262C;
|
||||||
|
$background: #15191E;
|
||||||
|
$panels: rgba($system-dark, 0.9);
|
||||||
|
$panel-base: #8D97A5; // This color is not intended for use in the app
|
||||||
|
$panel-selected: rgba($panel-base, 0.3);
|
||||||
|
$panel-hover: rgba($panel-base, 0.1);
|
||||||
|
$panel-actions: rgba($panel-base, 0.2);
|
||||||
|
$space-nav: rgba($panel-base, 0.1);
|
||||||
|
|
||||||
|
// TODO: Move userId colors here
|
||||||
|
|
||||||
// unified palette
|
// unified palette
|
||||||
// try to use these colors when possible
|
// try to use these colors when possible
|
||||||
$bg-color: #15191E;
|
$bg-color: $background;
|
||||||
$base-color: $bg-color;
|
$base-color: $bg-color;
|
||||||
$base-text-color: #ffffff;
|
$base-text-color: $primary-content;
|
||||||
$header-panel-bg-color: #20252B;
|
$header-panel-bg-color: #20252B;
|
||||||
$header-panel-border-color: #000000;
|
$header-panel-border-color: #000000;
|
||||||
$header-panel-text-primary-color: #B9BEC6;
|
$header-panel-text-primary-color: #B9BEC6;
|
||||||
$header-panel-text-secondary-color: #c8c8cd;
|
$header-panel-text-secondary-color: #c8c8cd;
|
||||||
$text-primary-color: #ffffff;
|
$text-primary-color: $primary-content;
|
||||||
$text-secondary-color: #B9BEC6;
|
$text-secondary-color: #B9BEC6;
|
||||||
$quaternary-fg-color: #6F7882;
|
$quaternary-fg-color: $quaternary-content;
|
||||||
$search-bg-color: #181b21;
|
$search-bg-color: #181b21;
|
||||||
$search-placeholder-color: #61708b;
|
$search-placeholder-color: #61708b;
|
||||||
$room-highlight-color: #343a46;
|
$room-highlight-color: #343a46;
|
||||||
|
@ -23,8 +40,8 @@ $primary-bg-color: $bg-color;
|
||||||
$muted-fg-color: $header-panel-text-primary-color;
|
$muted-fg-color: $header-panel-text-primary-color;
|
||||||
|
|
||||||
// additional text colors
|
// additional text colors
|
||||||
$secondary-fg-color: #A9B2BC;
|
$secondary-fg-color: $secondary-content;
|
||||||
$tertiary-fg-color: #8E99A4;
|
$tertiary-fg-color: $tertiary-content;
|
||||||
|
|
||||||
// used for dialog box text
|
// used for dialog box text
|
||||||
$light-fg-color: $header-panel-text-secondary-color;
|
$light-fg-color: $header-panel-text-secondary-color;
|
||||||
|
@ -108,20 +125,19 @@ $roomheader-addroom-fg-color: $text-primary-color;
|
||||||
$groupFilterPanel-button-color: $header-panel-text-primary-color;
|
$groupFilterPanel-button-color: $header-panel-text-primary-color;
|
||||||
$groupheader-button-color: $header-panel-text-primary-color;
|
$groupheader-button-color: $header-panel-text-primary-color;
|
||||||
$rightpanel-button-color: $header-panel-text-primary-color;
|
$rightpanel-button-color: $header-panel-text-primary-color;
|
||||||
$icon-button-color: #8E99A4;
|
$icon-button-color: $tertiary-content;
|
||||||
$roomtopic-color: $text-secondary-color;
|
$roomtopic-color: $text-secondary-color;
|
||||||
$eventtile-meta-color: $roomtopic-color;
|
$eventtile-meta-color: $roomtopic-color;
|
||||||
|
|
||||||
$header-divider-color: $header-panel-text-primary-color;
|
$header-divider-color: $header-panel-text-primary-color;
|
||||||
$composer-e2e-icon-color: $header-panel-text-primary-color;
|
$composer-e2e-icon-color: $header-panel-text-primary-color;
|
||||||
|
|
||||||
$quinary-content-color: #394049;
|
$toast-bg-color: $quinary-content;
|
||||||
$toast-bg-color: $quinary-content-color;
|
|
||||||
|
|
||||||
// ********************
|
// ********************
|
||||||
|
|
||||||
$theme-button-bg-color: #e3e8f0;
|
$theme-button-bg-color: #e3e8f0;
|
||||||
$dialpad-button-bg-color: #394049;
|
$dialpad-button-bg-color: $quinary-content;
|
||||||
|
|
||||||
$roomlist-button-bg-color: rgba(141, 151, 165, 0.2); // Buttons include the filter box, explore button, and sublist buttons
|
$roomlist-button-bg-color: rgba(141, 151, 165, 0.2); // Buttons include the filter box, explore button, and sublist buttons
|
||||||
$roomlist-filter-active-bg-color: $bg-color;
|
$roomlist-filter-active-bg-color: $bg-color;
|
||||||
|
@ -164,12 +180,12 @@ $tab-label-icon-bg-color: $text-primary-color;
|
||||||
$tab-label-active-icon-bg-color: $text-primary-color;
|
$tab-label-active-icon-bg-color: $text-primary-color;
|
||||||
|
|
||||||
// Buttons
|
// Buttons
|
||||||
$button-primary-fg-color: #ffffff;
|
$button-primary-fg-color: $primary-content;
|
||||||
$button-primary-bg-color: $accent-color;
|
$button-primary-bg-color: $accent-color;
|
||||||
$button-secondary-bg-color: transparent;
|
$button-secondary-bg-color: transparent;
|
||||||
$button-danger-fg-color: #ffffff;
|
$button-danger-fg-color: $primary-content;
|
||||||
$button-danger-bg-color: $notice-primary-color;
|
$button-danger-bg-color: $notice-primary-color;
|
||||||
$button-danger-disabled-fg-color: #ffffff;
|
$button-danger-disabled-fg-color: $primary-content;
|
||||||
$button-danger-disabled-bg-color: #f5b6bb; // TODO: Verify color
|
$button-danger-disabled-bg-color: #f5b6bb; // TODO: Verify color
|
||||||
$button-link-fg-color: $accent-color;
|
$button-link-fg-color: $accent-color;
|
||||||
$button-link-bg-color: transparent;
|
$button-link-bg-color: transparent;
|
||||||
|
@ -201,17 +217,17 @@ $reaction-row-button-selected-border-color: $accent-color;
|
||||||
$kbd-border-color: #000000;
|
$kbd-border-color: #000000;
|
||||||
|
|
||||||
$tooltip-timeline-bg-color: $groupFilterPanel-bg-color;
|
$tooltip-timeline-bg-color: $groupFilterPanel-bg-color;
|
||||||
$tooltip-timeline-fg-color: #ffffff;
|
$tooltip-timeline-fg-color: $primary-content;
|
||||||
|
|
||||||
$interactive-tooltip-bg-color: $base-color;
|
$interactive-tooltip-bg-color: $base-color;
|
||||||
$interactive-tooltip-fg-color: #ffffff;
|
$interactive-tooltip-fg-color: $primary-content;
|
||||||
|
|
||||||
$breadcrumb-placeholder-bg-color: #272c35;
|
$breadcrumb-placeholder-bg-color: #272c35;
|
||||||
|
|
||||||
$user-tile-hover-bg-color: $header-panel-bg-color;
|
$user-tile-hover-bg-color: $header-panel-bg-color;
|
||||||
|
|
||||||
$message-body-panel-fg-color: $secondary-fg-color;
|
$message-body-panel-fg-color: $secondary-fg-color;
|
||||||
$message-body-panel-bg-color: #394049; // "Dark Tile"
|
$message-body-panel-bg-color: $quinary-content;
|
||||||
$message-body-panel-icon-fg-color: $secondary-fg-color;
|
$message-body-panel-icon-fg-color: $secondary-fg-color;
|
||||||
$message-body-panel-icon-bg-color: $system-dark; // "System Dark"
|
$message-body-panel-icon-bg-color: $system-dark; // "System Dark"
|
||||||
|
|
||||||
|
|
|
@ -12,23 +12,39 @@ $font-family: 'Inter', 'Twemoji', 'Apple Color Emoji', 'Segoe UI Emoji', 'Arial'
|
||||||
|
|
||||||
$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
|
// Colors from Figma Compound https://www.figma.com/file/X4XTH9iS2KGJ2wFKDqkyed/Compound?node-id=559%3A120
|
||||||
|
$accent: #0DBD8B;
|
||||||
|
$alert: #FF5B55;
|
||||||
|
$links: #0086e6;
|
||||||
|
$primary-content: #17191C;
|
||||||
|
$secondary-content: #737D8C;
|
||||||
|
$tertiary-content: #8D97A5;
|
||||||
|
$quaternary-content: #c1c6cd;
|
||||||
|
$quinary-content: #E3E8F0;
|
||||||
$system-light: #F4F6FA;
|
$system-light: #F4F6FA;
|
||||||
|
$background: #ffffff;
|
||||||
|
$panels: rgba($system-light, 0.9);
|
||||||
|
$panel-selected: rgba($tertiary-content, 0.3);
|
||||||
|
$panel-hover: rgba($tertiary-content, 0.1);
|
||||||
|
$panel-actions: rgba($tertiary-content, 0.2);
|
||||||
|
$space-nav: rgba($tertiary-content, 0.15);
|
||||||
|
|
||||||
|
// TODO: Move userId colors here
|
||||||
|
|
||||||
// unified palette
|
// unified palette
|
||||||
// try to use these colors when possible
|
// try to use these colors when possible
|
||||||
$accent-color: #0DBD8B;
|
$accent-color: $accent;
|
||||||
$accent-bg-color: rgba(3, 179, 129, 0.16);
|
$accent-bg-color: rgba(3, 179, 129, 0.16);
|
||||||
$notice-primary-color: #ff4b55;
|
$notice-primary-color: #ff4b55;
|
||||||
$notice-primary-bg-color: rgba(255, 75, 85, 0.16);
|
$notice-primary-bg-color: rgba(255, 75, 85, 0.16);
|
||||||
$primary-fg-color: #2e2f32;
|
$primary-fg-color: #2e2f32;
|
||||||
$secondary-fg-color: #737D8C;
|
$secondary-fg-color: $secondary-content;
|
||||||
$tertiary-fg-color: #8D99A5;
|
$tertiary-fg-color: #8D99A5;
|
||||||
$quaternary-fg-color: #C1C6CD;
|
$quaternary-fg-color: $quaternary-content;
|
||||||
$header-panel-bg-color: #f3f8fd;
|
$header-panel-bg-color: #f3f8fd;
|
||||||
|
|
||||||
// typical text (dark-on-white in light skin)
|
// typical text (dark-on-white in light skin)
|
||||||
$primary-bg-color: #ffffff;
|
$primary-bg-color: $background;
|
||||||
$muted-fg-color: #61708b; // Commonly used in headings and relevant alt text
|
$muted-fg-color: #61708b; // Commonly used in headings and relevant alt text
|
||||||
|
|
||||||
// used for dialog box text
|
// used for dialog box text
|
||||||
|
@ -38,7 +54,7 @@ $light-fg-color: #747474;
|
||||||
$focus-bg-color: #dddddd;
|
$focus-bg-color: #dddddd;
|
||||||
|
|
||||||
// button UI (white-on-green in light skin)
|
// button UI (white-on-green in light skin)
|
||||||
$accent-fg-color: #ffffff;
|
$accent-fg-color: $background;
|
||||||
$accent-color-50pct: rgba($accent-color, 0.5);
|
$accent-color-50pct: rgba($accent-color, 0.5);
|
||||||
$accent-color-darker: #92caad;
|
$accent-color-darker: #92caad;
|
||||||
$accent-color-alt: #238CF5;
|
$accent-color-alt: #238CF5;
|
||||||
|
@ -82,7 +98,7 @@ $primary-hairline-color: transparent;
|
||||||
|
|
||||||
// used for the border of input text fields
|
// used for the border of input text fields
|
||||||
$input-border-color: #e7e7e7;
|
$input-border-color: #e7e7e7;
|
||||||
$input-darker-bg-color: #e3e8f0;
|
$input-darker-bg-color: $quinary-content;
|
||||||
$input-darker-fg-color: #9fa9ba;
|
$input-darker-fg-color: #9fa9ba;
|
||||||
$input-lighter-bg-color: #f2f5f8;
|
$input-lighter-bg-color: #f2f5f8;
|
||||||
$input-lighter-fg-color: $input-darker-fg-color;
|
$input-lighter-fg-color: $input-darker-fg-color;
|
||||||
|
@ -90,7 +106,7 @@ $input-focused-border-color: #238cf5;
|
||||||
$input-valid-border-color: $accent-color;
|
$input-valid-border-color: $accent-color;
|
||||||
$input-invalid-border-color: $warning-color;
|
$input-invalid-border-color: $warning-color;
|
||||||
|
|
||||||
$field-focused-label-bg-color: #ffffff;
|
$field-focused-label-bg-color: $background;
|
||||||
|
|
||||||
$button-bg-color: $accent-color;
|
$button-bg-color: $accent-color;
|
||||||
$button-fg-color: white;
|
$button-fg-color: white;
|
||||||
|
@ -112,8 +128,8 @@ $menu-bg-color: #fff;
|
||||||
$menu-box-shadow-color: rgba(118, 131, 156, 0.6);
|
$menu-box-shadow-color: rgba(118, 131, 156, 0.6);
|
||||||
$menu-selected-color: #f5f8fa;
|
$menu-selected-color: #f5f8fa;
|
||||||
|
|
||||||
$avatar-initial-color: #ffffff;
|
$avatar-initial-color: $background;
|
||||||
$avatar-bg-color: #ffffff;
|
$avatar-bg-color: $background;
|
||||||
|
|
||||||
$h3-color: #3d3b39;
|
$h3-color: #3d3b39;
|
||||||
|
|
||||||
|
@ -163,7 +179,7 @@ $roomheader-addroom-fg-color: #5c6470;
|
||||||
$groupFilterPanel-button-color: #91A1C0;
|
$groupFilterPanel-button-color: #91A1C0;
|
||||||
$groupheader-button-color: #91A1C0;
|
$groupheader-button-color: #91A1C0;
|
||||||
$rightpanel-button-color: #91A1C0;
|
$rightpanel-button-color: #91A1C0;
|
||||||
$icon-button-color: #C1C6CD;
|
$icon-button-color: $quaternary-content;
|
||||||
$roomtopic-color: #9e9e9e;
|
$roomtopic-color: #9e9e9e;
|
||||||
$eventtile-meta-color: $roomtopic-color;
|
$eventtile-meta-color: $roomtopic-color;
|
||||||
|
|
||||||
|
@ -175,12 +191,12 @@ $voipcall-plinth-color: $system-light;
|
||||||
|
|
||||||
// ********************
|
// ********************
|
||||||
|
|
||||||
$theme-button-bg-color: #e3e8f0;
|
$theme-button-bg-color: $quinary-content;
|
||||||
$dialpad-button-bg-color: #e3e8f0;
|
$dialpad-button-bg-color: $quinary-content;
|
||||||
|
|
||||||
|
|
||||||
$roomlist-button-bg-color: rgba(141, 151, 165, 0.2); // Buttons include the filter box, explore button, and sublist buttons
|
$roomlist-button-bg-color: rgba(141, 151, 165, 0.2); // Buttons include the filter box, explore button, and sublist buttons
|
||||||
$roomlist-filter-active-bg-color: #ffffff;
|
$roomlist-filter-active-bg-color: $background;
|
||||||
$roomlist-bg-color: rgba(245, 245, 245, 0.90);
|
$roomlist-bg-color: rgba(245, 245, 245, 0.90);
|
||||||
$roomlist-header-color: $tertiary-fg-color;
|
$roomlist-header-color: $tertiary-fg-color;
|
||||||
$roomsublist-divider-color: $primary-fg-color;
|
$roomsublist-divider-color: $primary-fg-color;
|
||||||
|
@ -194,7 +210,7 @@ $roomtile-selected-bg-color: #FFF;
|
||||||
|
|
||||||
$presence-online: $accent-color;
|
$presence-online: $accent-color;
|
||||||
$presence-away: #d9b072;
|
$presence-away: #d9b072;
|
||||||
$presence-offline: #E3E8F0;
|
$presence-offline: $quinary-content;
|
||||||
|
|
||||||
// ********************
|
// ********************
|
||||||
|
|
||||||
|
@ -257,7 +273,7 @@ $lightbox-border-color: #ffffff;
|
||||||
|
|
||||||
// Tabbed views
|
// Tabbed views
|
||||||
$tab-label-fg-color: #45474a;
|
$tab-label-fg-color: #45474a;
|
||||||
$tab-label-active-fg-color: #ffffff;
|
$tab-label-active-fg-color: $background;
|
||||||
$tab-label-bg-color: transparent;
|
$tab-label-bg-color: transparent;
|
||||||
$tab-label-active-bg-color: $accent-color;
|
$tab-label-active-bg-color: $accent-color;
|
||||||
$tab-label-icon-bg-color: #454545;
|
$tab-label-icon-bg-color: #454545;
|
||||||
|
@ -267,9 +283,9 @@ $tab-label-active-icon-bg-color: $tab-label-active-fg-color;
|
||||||
$button-primary-fg-color: #ffffff;
|
$button-primary-fg-color: #ffffff;
|
||||||
$button-primary-bg-color: $accent-color;
|
$button-primary-bg-color: $accent-color;
|
||||||
$button-secondary-bg-color: $accent-fg-color;
|
$button-secondary-bg-color: $accent-fg-color;
|
||||||
$button-danger-fg-color: #ffffff;
|
$button-danger-fg-color: $background;
|
||||||
$button-danger-bg-color: $notice-primary-color;
|
$button-danger-bg-color: $notice-primary-color;
|
||||||
$button-danger-disabled-fg-color: #ffffff;
|
$button-danger-disabled-fg-color: $background;
|
||||||
$button-danger-disabled-bg-color: #f5b6bb; // TODO: Verify color
|
$button-danger-disabled-bg-color: #f5b6bb; // TODO: Verify color
|
||||||
$button-link-fg-color: $accent-color;
|
$button-link-fg-color: $accent-color;
|
||||||
$button-link-bg-color: transparent;
|
$button-link-bg-color: transparent;
|
||||||
|
@ -294,7 +310,7 @@ $memberstatus-placeholder-color: $muted-fg-color;
|
||||||
|
|
||||||
$authpage-bg-color: #2e3649;
|
$authpage-bg-color: #2e3649;
|
||||||
$authpage-modal-bg-color: rgba(245, 245, 245, 0.90);
|
$authpage-modal-bg-color: rgba(245, 245, 245, 0.90);
|
||||||
$authpage-body-bg-color: #ffffff;
|
$authpage-body-bg-color: $background;
|
||||||
$authpage-focus-bg-color: #dddddd;
|
$authpage-focus-bg-color: #dddddd;
|
||||||
$authpage-lang-color: #4e5054;
|
$authpage-lang-color: #4e5054;
|
||||||
$authpage-primary-color: #232f32;
|
$authpage-primary-color: #232f32;
|
||||||
|
@ -318,17 +334,17 @@ $kbd-border-color: $reaction-row-button-border-color;
|
||||||
|
|
||||||
$inverted-bg-color: #27303a;
|
$inverted-bg-color: #27303a;
|
||||||
$tooltip-timeline-bg-color: $inverted-bg-color;
|
$tooltip-timeline-bg-color: $inverted-bg-color;
|
||||||
$tooltip-timeline-fg-color: #ffffff;
|
$tooltip-timeline-fg-color: $background;
|
||||||
|
|
||||||
$interactive-tooltip-bg-color: #27303a;
|
$interactive-tooltip-bg-color: #27303a;
|
||||||
$interactive-tooltip-fg-color: #ffffff;
|
$interactive-tooltip-fg-color: $background;
|
||||||
|
|
||||||
$breadcrumb-placeholder-bg-color: #e8eef5;
|
$breadcrumb-placeholder-bg-color: #e8eef5;
|
||||||
|
|
||||||
$user-tile-hover-bg-color: $header-panel-bg-color;
|
$user-tile-hover-bg-color: $header-panel-bg-color;
|
||||||
|
|
||||||
$message-body-panel-fg-color: $secondary-fg-color;
|
$message-body-panel-fg-color: $secondary-fg-color;
|
||||||
$message-body-panel-bg-color: #E3E8F0; // "Separator"
|
$message-body-panel-bg-color: $quinary-content;
|
||||||
$message-body-panel-icon-fg-color: $secondary-fg-color;
|
$message-body-panel-icon-fg-color: $secondary-fg-color;
|
||||||
$message-body-panel-icon-bg-color: $system-light;
|
$message-body-panel-icon-bg-color: $system-light;
|
||||||
|
|
||||||
|
@ -337,7 +353,7 @@ $message-body-panel-icon-bg-color: $system-light;
|
||||||
$voice-record-stop-symbol-color: #ff4b55;
|
$voice-record-stop-symbol-color: #ff4b55;
|
||||||
$voice-record-live-circle-color: #ff4b55;
|
$voice-record-live-circle-color: #ff4b55;
|
||||||
|
|
||||||
$voice-record-stop-border-color: #E3E8F0; // "Separator"
|
$voice-record-stop-border-color: $quinary-content;
|
||||||
$voice-record-waveform-incomplete-fg-color: $quaternary-fg-color;
|
$voice-record-waveform-incomplete-fg-color: $quaternary-fg-color;
|
||||||
$voice-record-icon-color: $tertiary-fg-color;
|
$voice-record-icon-color: $tertiary-fg-color;
|
||||||
$voice-playback-button-bg-color: $message-body-panel-icon-bg-color;
|
$voice-playback-button-bg-color: $message-body-panel-icon-bg-color;
|
||||||
|
@ -357,7 +373,7 @@ $eventbubble-self-bg: #F0FBF8;
|
||||||
$eventbubble-others-bg: $system-light;
|
$eventbubble-others-bg: $system-light;
|
||||||
$eventbubble-bg-hover: #FAFBFD;
|
$eventbubble-bg-hover: #FAFBFD;
|
||||||
$eventbubble-avatar-outline: $primary-bg-color;
|
$eventbubble-avatar-outline: $primary-bg-color;
|
||||||
$eventbubble-reply-color: #C1C6CD;
|
$eventbubble-reply-color: $quaternary-content;
|
||||||
|
|
||||||
// ***** Mixins! *****
|
// ***** Mixins! *****
|
||||||
|
|
||||||
|
|
|
@ -14,8 +14,8 @@ See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import RoomViewStore from './stores/RoomViewStore';
|
|
||||||
import { EventSubscription } from 'fbemitter';
|
import { EventSubscription } from 'fbemitter';
|
||||||
|
import RoomViewStore from './stores/RoomViewStore';
|
||||||
|
|
||||||
type Listener = (isActive: boolean) => void;
|
type Listener = (isActive: boolean) => void;
|
||||||
|
|
||||||
|
|
|
@ -150,13 +150,14 @@ const reducer = (state: IState, action: IAction) => {
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
handleHomeEnd?: boolean;
|
handleHomeEnd?: boolean;
|
||||||
|
handleUpDown?: boolean;
|
||||||
children(renderProps: {
|
children(renderProps: {
|
||||||
onKeyDownHandler(ev: React.KeyboardEvent);
|
onKeyDownHandler(ev: React.KeyboardEvent);
|
||||||
});
|
});
|
||||||
onKeyDown?(ev: React.KeyboardEvent, state: IState);
|
onKeyDown?(ev: React.KeyboardEvent, state: IState);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RovingTabIndexProvider: React.FC<IProps> = ({ children, handleHomeEnd, onKeyDown }) => {
|
export const RovingTabIndexProvider: React.FC<IProps> = ({ children, handleHomeEnd, handleUpDown, onKeyDown }) => {
|
||||||
const [state, dispatch] = useReducer<Reducer<IState, IAction>>(reducer, {
|
const [state, dispatch] = useReducer<Reducer<IState, IAction>>(reducer, {
|
||||||
activeRef: null,
|
activeRef: null,
|
||||||
refs: [],
|
refs: [],
|
||||||
|
@ -167,21 +168,50 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({ children, handleHomeE
|
||||||
const onKeyDownHandler = useCallback((ev) => {
|
const onKeyDownHandler = useCallback((ev) => {
|
||||||
let handled = false;
|
let handled = false;
|
||||||
// Don't interfere with input default keydown behaviour
|
// Don't interfere with input default keydown behaviour
|
||||||
if (handleHomeEnd && ev.target.tagName !== "INPUT" && ev.target.tagName !== "TEXTAREA") {
|
if (ev.target.tagName !== "INPUT" && ev.target.tagName !== "TEXTAREA") {
|
||||||
// check if we actually have any items
|
// check if we actually have any items
|
||||||
switch (ev.key) {
|
switch (ev.key) {
|
||||||
case Key.HOME:
|
case Key.HOME:
|
||||||
handled = true;
|
if (handleHomeEnd) {
|
||||||
// move focus to first item
|
handled = true;
|
||||||
if (context.state.refs.length > 0) {
|
// move focus to first item
|
||||||
context.state.refs[0].current.focus();
|
if (context.state.refs.length > 0) {
|
||||||
|
context.state.refs[0].current.focus();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case Key.END:
|
case Key.END:
|
||||||
handled = true;
|
if (handleHomeEnd) {
|
||||||
// move focus to last item
|
handled = true;
|
||||||
if (context.state.refs.length > 0) {
|
// move focus to last item
|
||||||
context.state.refs[context.state.refs.length - 1].current.focus();
|
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;
|
break;
|
||||||
}
|
}
|
||||||
|
@ -193,7 +223,7 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({ children, handleHomeE
|
||||||
} else if (onKeyDown) {
|
} else if (onKeyDown) {
|
||||||
return onKeyDown(ev, context.state);
|
return onKeyDown(ev, context.state);
|
||||||
}
|
}
|
||||||
}, [context.state, onKeyDown, handleHomeEnd]);
|
}, [context.state, onKeyDown, handleHomeEnd, handleUpDown]);
|
||||||
|
|
||||||
return <RovingTabIndexContext.Provider value={context}>
|
return <RovingTabIndexContext.Provider value={context}>
|
||||||
{ children({ onKeyDownHandler }) }
|
{ children({ onKeyDownHandler }) }
|
||||||
|
|
|
@ -61,7 +61,9 @@ export default class AutoHideScrollbar extends React.Component<IProps> {
|
||||||
style={style}
|
style={style}
|
||||||
className={["mx_AutoHideScrollbar", className].join(" ")}
|
className={["mx_AutoHideScrollbar", className].join(" ")}
|
||||||
onWheel={onWheel}
|
onWheel={onWheel}
|
||||||
tabIndex={tabIndex}
|
// Firefox sometimes makes this element focusable due to
|
||||||
|
// overflow:scroll;, so force it out of tab order by default.
|
||||||
|
tabIndex={tabIndex ?? -1}
|
||||||
>
|
>
|
||||||
{ children }
|
{ children }
|
||||||
</div>);
|
</div>);
|
||||||
|
|
|
@ -392,9 +392,6 @@ export default class LeftPanel extends React.Component<IProps, IState> {
|
||||||
<IndicatorScrollbar
|
<IndicatorScrollbar
|
||||||
className="mx_LeftPanel_breadcrumbsContainer mx_AutoHideScrollbar"
|
className="mx_LeftPanel_breadcrumbsContainer mx_AutoHideScrollbar"
|
||||||
verticalScrollsHorizontally={true}
|
verticalScrollsHorizontally={true}
|
||||||
// Firefox sometimes makes this element focusable due to
|
|
||||||
// overflow:scroll;, so force it out of tab order.
|
|
||||||
tabIndex={-1}
|
|
||||||
>
|
>
|
||||||
<RoomBreadcrumbs />
|
<RoomBreadcrumbs />
|
||||||
</IndicatorScrollbar>
|
</IndicatorScrollbar>
|
||||||
|
|
|
@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
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 { Room } from "matrix-js-sdk/src/models/room";
|
||||||
import { EventType, RoomType } from "matrix-js-sdk/src/@types/event";
|
import { EventType, RoomType } from "matrix-js-sdk/src/@types/event";
|
||||||
import { ISpaceSummaryRoom, ISpaceSummaryEvent } from "matrix-js-sdk/src/@types/spaces";
|
import { ISpaceSummaryRoom, ISpaceSummaryEvent } from "matrix-js-sdk/src/@types/spaces";
|
||||||
|
@ -46,6 +46,8 @@ import { getDisplayAliasForAliasSet } from "../../Rooms";
|
||||||
import { useDispatcher } from "../../hooks/useDispatcher";
|
import { useDispatcher } from "../../hooks/useDispatcher";
|
||||||
import defaultDispatcher from "../../dispatcher/dispatcher";
|
import defaultDispatcher from "../../dispatcher/dispatcher";
|
||||||
import { Action } from "../../dispatcher/actions";
|
import { Action } from "../../dispatcher/actions";
|
||||||
|
import { Key } from "../../Keyboard";
|
||||||
|
import { IState, RovingTabIndexProvider, useRovingTabIndex } from "../../accessibility/RovingTabIndex";
|
||||||
|
|
||||||
interface IHierarchyProps {
|
interface IHierarchyProps {
|
||||||
space: Room;
|
space: Room;
|
||||||
|
@ -80,6 +82,7 @@ const Tile: React.FC<ITileProps> = ({
|
||||||
|| (room.room_type === RoomType.Space ? _t("Unnamed Space") : _t("Unnamed Room"));
|
|| (room.room_type === RoomType.Space ? _t("Unnamed Space") : _t("Unnamed Room"));
|
||||||
|
|
||||||
const [showChildren, toggleShowChildren] = useStateToggle(true);
|
const [showChildren, toggleShowChildren] = useStateToggle(true);
|
||||||
|
const [onFocus, isActive, ref] = useRovingTabIndex();
|
||||||
|
|
||||||
const onPreviewClick = (ev: ButtonEvent) => {
|
const onPreviewClick = (ev: ButtonEvent) => {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
|
@ -94,11 +97,21 @@ const Tile: React.FC<ITileProps> = ({
|
||||||
|
|
||||||
let button;
|
let button;
|
||||||
if (joinedRoom) {
|
if (joinedRoom) {
|
||||||
button = <AccessibleButton onClick={onPreviewClick} kind="primary_outline">
|
button = <AccessibleButton
|
||||||
|
onClick={onPreviewClick}
|
||||||
|
kind="primary_outline"
|
||||||
|
onFocus={onFocus}
|
||||||
|
tabIndex={isActive ? 0 : -1}
|
||||||
|
>
|
||||||
{ _t("View") }
|
{ _t("View") }
|
||||||
</AccessibleButton>;
|
</AccessibleButton>;
|
||||||
} else if (onJoinClick) {
|
} else if (onJoinClick) {
|
||||||
button = <AccessibleButton onClick={onJoinClick} kind="primary">
|
button = <AccessibleButton
|
||||||
|
onClick={onJoinClick}
|
||||||
|
kind="primary"
|
||||||
|
onFocus={onFocus}
|
||||||
|
tabIndex={isActive ? 0 : -1}
|
||||||
|
>
|
||||||
{ _t("Join") }
|
{ _t("Join") }
|
||||||
</AccessibleButton>;
|
</AccessibleButton>;
|
||||||
}
|
}
|
||||||
|
@ -106,13 +119,13 @@ const Tile: React.FC<ITileProps> = ({
|
||||||
let checkbox;
|
let checkbox;
|
||||||
if (onToggleClick) {
|
if (onToggleClick) {
|
||||||
if (hasPermissions) {
|
if (hasPermissions) {
|
||||||
checkbox = <StyledCheckbox checked={!!selected} onChange={onToggleClick} />;
|
checkbox = <StyledCheckbox checked={!!selected} onChange={onToggleClick} tabIndex={isActive ? 0 : -1} />;
|
||||||
} else {
|
} else {
|
||||||
checkbox = <TextWithTooltip
|
checkbox = <TextWithTooltip
|
||||||
tooltip={_t("You don't have permission")}
|
tooltip={_t("You don't have permission")}
|
||||||
onClick={ev => { ev.stopPropagation(); }}
|
onClick={ev => { ev.stopPropagation(); }}
|
||||||
>
|
>
|
||||||
<StyledCheckbox disabled={true} />
|
<StyledCheckbox disabled={true} tabIndex={isActive ? 0 : -1} />
|
||||||
</TextWithTooltip>;
|
</TextWithTooltip>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -172,8 +185,9 @@ const Tile: React.FC<ITileProps> = ({
|
||||||
</div>
|
</div>
|
||||||
</React.Fragment>;
|
</React.Fragment>;
|
||||||
|
|
||||||
let childToggle;
|
let childToggle: JSX.Element;
|
||||||
let childSection;
|
let childSection: JSX.Element;
|
||||||
|
let onKeyDown: KeyboardEventHandler;
|
||||||
if (children) {
|
if (children) {
|
||||||
// the chevron is purposefully a div rather than a button as it should be ignored for a11y
|
// the chevron is purposefully a div rather than a button as it should be ignored for a11y
|
||||||
childToggle = <div
|
childToggle = <div
|
||||||
|
@ -185,25 +199,74 @@ const Tile: React.FC<ITileProps> = ({
|
||||||
toggleShowChildren();
|
toggleShowChildren();
|
||||||
}}
|
}}
|
||||||
/>;
|
/>;
|
||||||
|
|
||||||
if (showChildren) {
|
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 }
|
{ children }
|
||||||
</div>;
|
</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onKeyDown = (e) => {
|
||||||
|
let handled = false;
|
||||||
|
|
||||||
|
switch (e.key) {
|
||||||
|
case Key.ARROW_LEFT:
|
||||||
|
if (showChildren) {
|
||||||
|
handled = true;
|
||||||
|
toggleShowChildren();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case Key.ARROW_RIGHT:
|
||||||
|
handled = true;
|
||||||
|
if (showChildren) {
|
||||||
|
const childSection = ref.current?.nextElementSibling;
|
||||||
|
childSection?.querySelector<HTMLDivElement>(".mx_SpaceRoomDirectory_roomTile")?.focus();
|
||||||
|
} else {
|
||||||
|
toggleShowChildren();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (handled) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return <>
|
return <li
|
||||||
|
className="mx_SpaceRoomDirectory_roomTileWrapper"
|
||||||
|
role="treeitem"
|
||||||
|
aria-expanded={children ? showChildren : undefined}
|
||||||
|
>
|
||||||
<AccessibleButton
|
<AccessibleButton
|
||||||
className={classNames("mx_SpaceRoomDirectory_roomTile", {
|
className={classNames("mx_SpaceRoomDirectory_roomTile", {
|
||||||
mx_SpaceRoomDirectory_subspace: room.room_type === RoomType.Space,
|
mx_SpaceRoomDirectory_subspace: room.room_type === RoomType.Space,
|
||||||
})}
|
})}
|
||||||
onClick={(hasPermissions && onToggleClick) ? onToggleClick : onPreviewClick}
|
onClick={(hasPermissions && onToggleClick) ? onToggleClick : onPreviewClick}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
inputRef={ref}
|
||||||
|
onFocus={onFocus}
|
||||||
|
tabIndex={isActive ? 0 : -1}
|
||||||
>
|
>
|
||||||
{ content }
|
{ content }
|
||||||
{ childToggle }
|
{ childToggle }
|
||||||
</AccessibleButton>
|
</AccessibleButton>
|
||||||
{ childSection }
|
{ childSection }
|
||||||
</>;
|
</li>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const showRoom = (room: ISpaceSummaryRoom, viaServers?: string[], autoJoin = false) => {
|
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>;
|
return <p>{ _t("Your server does not support showing space hierarchies.") }</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
let content;
|
const onKeyDown = (ev: KeyboardEvent, state: IState) => {
|
||||||
if (roomsMap) {
|
if (ev.key === Key.ARROW_DOWN && ev.currentTarget.classList.contains("mx_SpaceRoomDirectory_search")) {
|
||||||
const numRooms = Array.from(roomsMap.values()).filter(r => !r.room_type).length;
|
state.refs[0]?.current?.focus();
|
||||||
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 });
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
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
|
// TODO loading state/error state
|
||||||
return <>
|
return <RovingTabIndexProvider onKeyDown={onKeyDown} handleHomeEnd handleUpDown>
|
||||||
<SearchBox
|
{ ({ onKeyDownHandler }) => {
|
||||||
className="mx_textinput_icon mx_textinput_search"
|
let content;
|
||||||
placeholder={_t("Search names and descriptions")}
|
if (roomsMap) {
|
||||||
onSearch={setQuery}
|
const numRooms = Array.from(roomsMap.values()).filter(r => !r.room_type).length;
|
||||||
autoFocus={true}
|
const numSpaces = roomsMap.size - numRooms - 1; // -1 at the end to exclude the space we are looking at
|
||||||
initialValue={initialText}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{ 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 {
|
interface IProps {
|
||||||
|
|
|
@ -67,7 +67,9 @@ export default function AccessibleButton({
|
||||||
...restProps
|
...restProps
|
||||||
}: IProps) {
|
}: IProps) {
|
||||||
const newProps: IAccessibleButtonProps = restProps;
|
const newProps: IAccessibleButtonProps = restProps;
|
||||||
if (!disabled) {
|
if (disabled) {
|
||||||
|
newProps["aria-disabled"] = true;
|
||||||
|
} else {
|
||||||
newProps.onClick = onClick;
|
newProps.onClick = onClick;
|
||||||
// We need to consume enter onKeyDown and space onKeyUp
|
// We need to consume enter onKeyDown and space onKeyUp
|
||||||
// otherwise we are risking also activating other keyboard focusable elements
|
// otherwise we are risking also activating other keyboard focusable elements
|
||||||
|
@ -118,7 +120,7 @@ export default function AccessibleButton({
|
||||||
);
|
);
|
||||||
|
|
||||||
// React.createElement expects InputHTMLAttributes
|
// React.createElement expects InputHTMLAttributes
|
||||||
return React.createElement(element, restProps, children);
|
return React.createElement(element, newProps, children);
|
||||||
}
|
}
|
||||||
|
|
||||||
AccessibleButton.defaultProps = {
|
AccessibleButton.defaultProps = {
|
||||||
|
|
|
@ -18,7 +18,7 @@ limitations under the License.
|
||||||
import React, { ChangeEvent, createRef, CSSProperties, ReactElement, ReactNode, Ref } from 'react';
|
import React, { ChangeEvent, createRef, CSSProperties, ReactElement, ReactNode, Ref } from 'react';
|
||||||
import classnames from 'classnames';
|
import classnames from 'classnames';
|
||||||
|
|
||||||
import AccessibleButton from './AccessibleButton';
|
import AccessibleButton, { ButtonEvent } from './AccessibleButton';
|
||||||
import { _t } from '../../../languageHandler';
|
import { _t } from '../../../languageHandler';
|
||||||
import { Key } from "../../../Keyboard";
|
import { Key } from "../../../Keyboard";
|
||||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||||
|
@ -178,7 +178,7 @@ export default class Dropdown extends React.Component<IProps, IState> {
|
||||||
this.ignoreEvent = ev;
|
this.ignoreEvent = ev;
|
||||||
};
|
};
|
||||||
|
|
||||||
private onInputClick = (ev: React.MouseEvent) => {
|
private onAccessibleButtonClick = (ev: ButtonEvent) => {
|
||||||
if (this.props.disabled) return;
|
if (this.props.disabled) return;
|
||||||
|
|
||||||
if (!this.state.expanded) {
|
if (!this.state.expanded) {
|
||||||
|
@ -186,6 +186,10 @@ export default class Dropdown extends React.Component<IProps, IState> {
|
||||||
expanded: true,
|
expanded: true,
|
||||||
});
|
});
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
|
} else if ((ev as React.KeyboardEvent).key === Key.ENTER) {
|
||||||
|
// the accessible button consumes enter onKeyDown for firing onClick, so handle it here
|
||||||
|
this.props.onOptionChange(this.state.highlightedOption);
|
||||||
|
this.close();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -204,7 +208,7 @@ export default class Dropdown extends React.Component<IProps, IState> {
|
||||||
this.props.onOptionChange(dropdownKey);
|
this.props.onOptionChange(dropdownKey);
|
||||||
};
|
};
|
||||||
|
|
||||||
private onInputKeyDown = (e: React.KeyboardEvent) => {
|
private onKeyDown = (e: React.KeyboardEvent) => {
|
||||||
let handled = true;
|
let handled = true;
|
||||||
|
|
||||||
// These keys don't generate keypress events and so needs to be on keyup
|
// These keys don't generate keypress events and so needs to be on keyup
|
||||||
|
@ -269,7 +273,7 @@ export default class Dropdown extends React.Component<IProps, IState> {
|
||||||
private prevOption(optionKey: string): string {
|
private prevOption(optionKey: string): string {
|
||||||
const keys = Object.keys(this.childrenByKey);
|
const keys = Object.keys(this.childrenByKey);
|
||||||
const index = keys.indexOf(optionKey);
|
const index = keys.indexOf(optionKey);
|
||||||
return keys[(index - 1) % keys.length];
|
return keys[index <= 0 ? keys.length - 1 : (index - 1) % keys.length];
|
||||||
}
|
}
|
||||||
|
|
||||||
private scrollIntoView(node: Element) {
|
private scrollIntoView(node: Element) {
|
||||||
|
@ -320,7 +324,6 @@ export default class Dropdown extends React.Component<IProps, IState> {
|
||||||
type="text"
|
type="text"
|
||||||
autoFocus={true}
|
autoFocus={true}
|
||||||
className="mx_Dropdown_option"
|
className="mx_Dropdown_option"
|
||||||
onKeyDown={this.onInputKeyDown}
|
|
||||||
onChange={this.onInputChange}
|
onChange={this.onInputChange}
|
||||||
value={this.state.searchQuery}
|
value={this.state.searchQuery}
|
||||||
role="combobox"
|
role="combobox"
|
||||||
|
@ -329,6 +332,7 @@ export default class Dropdown extends React.Component<IProps, IState> {
|
||||||
aria-owns={`${this.props.id}_listbox`}
|
aria-owns={`${this.props.id}_listbox`}
|
||||||
aria-disabled={this.props.disabled}
|
aria-disabled={this.props.disabled}
|
||||||
aria-label={this.props.label}
|
aria-label={this.props.label}
|
||||||
|
onKeyDown={this.onKeyDown}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -361,13 +365,14 @@ export default class Dropdown extends React.Component<IProps, IState> {
|
||||||
return <div className={classnames(dropdownClasses)} ref={this.collectRoot}>
|
return <div className={classnames(dropdownClasses)} ref={this.collectRoot}>
|
||||||
<AccessibleButton
|
<AccessibleButton
|
||||||
className="mx_Dropdown_input mx_no_textinput"
|
className="mx_Dropdown_input mx_no_textinput"
|
||||||
onClick={this.onInputClick}
|
onClick={this.onAccessibleButtonClick}
|
||||||
aria-haspopup="listbox"
|
aria-haspopup="listbox"
|
||||||
aria-expanded={this.state.expanded}
|
aria-expanded={this.state.expanded}
|
||||||
disabled={this.props.disabled}
|
disabled={this.props.disabled}
|
||||||
inputRef={this.buttonRef}
|
inputRef={this.buttonRef}
|
||||||
aria-label={this.props.label}
|
aria-label={this.props.label}
|
||||||
aria-describedby={`${this.props.id}_value`}
|
aria-describedby={`${this.props.id}_value`}
|
||||||
|
onKeyDown={this.onKeyDown}
|
||||||
>
|
>
|
||||||
{ currentValue }
|
{ currentValue }
|
||||||
<span className="mx_Dropdown_arrow" />
|
<span className="mx_Dropdown_arrow" />
|
||||||
|
|
|
@ -31,7 +31,7 @@ import {
|
||||||
} from '../../../editor/operations';
|
} from '../../../editor/operations';
|
||||||
import { getCaretOffsetAndText, getRangeForSelection } from '../../../editor/dom';
|
import { getCaretOffsetAndText, getRangeForSelection } from '../../../editor/dom';
|
||||||
import Autocomplete, { generateCompletionDomId } from '../rooms/Autocomplete';
|
import Autocomplete, { generateCompletionDomId } from '../rooms/Autocomplete';
|
||||||
import { getAutoCompleteCreator } from '../../../editor/parts';
|
import { getAutoCompleteCreator, Type } from '../../../editor/parts';
|
||||||
import { parseEvent, parsePlainTextMessage } from '../../../editor/deserialize';
|
import { parseEvent, parsePlainTextMessage } from '../../../editor/deserialize';
|
||||||
import { renderModel } from '../../../editor/render';
|
import { renderModel } from '../../../editor/render';
|
||||||
import TypingStore from "../../../stores/TypingStore";
|
import TypingStore from "../../../stores/TypingStore";
|
||||||
|
@ -169,7 +169,7 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
|
||||||
range.expandBackwardsWhile((index, offset) => {
|
range.expandBackwardsWhile((index, offset) => {
|
||||||
const part = model.parts[index];
|
const part = model.parts[index];
|
||||||
n -= 1;
|
n -= 1;
|
||||||
return n >= 0 && (part.type === "plain" || part.type === "pill-candidate");
|
return n >= 0 && (part.type === Type.Plain || part.type === Type.PillCandidate);
|
||||||
});
|
});
|
||||||
const emoticonMatch = REGEX_EMOTICON_WHITESPACE.exec(range.text);
|
const emoticonMatch = REGEX_EMOTICON_WHITESPACE.exec(range.text);
|
||||||
if (emoticonMatch) {
|
if (emoticonMatch) {
|
||||||
|
@ -541,6 +541,7 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
|
||||||
handled = true;
|
handled = true;
|
||||||
} else if (event.key === Key.BACKSPACE || event.key === Key.DELETE) {
|
} else if (event.key === Key.BACKSPACE || event.key === Key.DELETE) {
|
||||||
this.formatBarRef.current.hide();
|
this.formatBarRef.current.hide();
|
||||||
|
handled = this.fakeDeletion(event.key === Key.BACKSPACE);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (handled) {
|
if (handled) {
|
||||||
|
@ -549,6 +550,29 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Because pills have contentEditable="false" there is no event emitted when
|
||||||
|
* the user tries to delete them. Therefore we need to fake what would
|
||||||
|
* normally happen
|
||||||
|
* @param direction in which to delete
|
||||||
|
* @returns handled
|
||||||
|
*/
|
||||||
|
private fakeDeletion(backward: boolean): boolean {
|
||||||
|
const selection = document.getSelection();
|
||||||
|
// Use the default handling for ranges
|
||||||
|
if (selection.type === "Range") return false;
|
||||||
|
|
||||||
|
this.modifiedFlag = true;
|
||||||
|
const { caret, text } = getCaretOffsetAndText(this.editorRef.current, selection);
|
||||||
|
|
||||||
|
// Do the deletion itself
|
||||||
|
if (backward) caret.offset--;
|
||||||
|
const newText = text.slice(0, caret.offset) + text.slice(caret.offset + 1);
|
||||||
|
|
||||||
|
this.props.model.update(newText, backward ? "deleteContentBackward" : "deleteContentForward", caret);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
private async tabCompleteName(): Promise<void> {
|
private async tabCompleteName(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await new Promise<void>(resolve => this.setState({ showVisualBell: false }, resolve));
|
await new Promise<void>(resolve => this.setState({ showVisualBell: false }, resolve));
|
||||||
|
@ -558,9 +582,9 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
|
||||||
const range = model.startRange(position);
|
const range = model.startRange(position);
|
||||||
range.expandBackwardsWhile((index, offset, part) => {
|
range.expandBackwardsWhile((index, offset, part) => {
|
||||||
return part.text[offset] !== " " && part.text[offset] !== "+" && (
|
return part.text[offset] !== " " && part.text[offset] !== "+" && (
|
||||||
part.type === "plain" ||
|
part.type === Type.Plain ||
|
||||||
part.type === "pill-candidate" ||
|
part.type === Type.PillCandidate ||
|
||||||
part.type === "command"
|
part.type === Type.Command
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
const { partCreator } = model;
|
const { partCreator } = model;
|
||||||
|
|
|
@ -25,7 +25,7 @@ import { getCaretOffsetAndText } from '../../../editor/dom';
|
||||||
import { htmlSerializeIfNeeded, textSerialize, containsEmote, stripEmoteCommand } from '../../../editor/serialize';
|
import { htmlSerializeIfNeeded, textSerialize, containsEmote, stripEmoteCommand } from '../../../editor/serialize';
|
||||||
import { findEditableEvent } from '../../../utils/EventUtils';
|
import { findEditableEvent } from '../../../utils/EventUtils';
|
||||||
import { parseEvent } from '../../../editor/deserialize';
|
import { parseEvent } from '../../../editor/deserialize';
|
||||||
import { CommandPartCreator, Part, PartCreator } from '../../../editor/parts';
|
import { CommandPartCreator, Part, PartCreator, Type } from '../../../editor/parts';
|
||||||
import EditorStateTransfer from '../../../utils/EditorStateTransfer';
|
import EditorStateTransfer from '../../../utils/EditorStateTransfer';
|
||||||
import BasicMessageComposer from "./BasicMessageComposer";
|
import BasicMessageComposer from "./BasicMessageComposer";
|
||||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||||
|
@ -242,12 +242,12 @@ export default class EditMessageComposer extends React.Component<IProps, IState>
|
||||||
const parts = this.model.parts;
|
const parts = this.model.parts;
|
||||||
const firstPart = parts[0];
|
const firstPart = parts[0];
|
||||||
if (firstPart) {
|
if (firstPart) {
|
||||||
if (firstPart.type === "command" && firstPart.text.startsWith("/") && !firstPart.text.startsWith("//")) {
|
if (firstPart.type === Type.Command && firstPart.text.startsWith("/") && !firstPart.text.startsWith("//")) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (firstPart.text.startsWith("/") && !firstPart.text.startsWith("//")
|
if (firstPart.text.startsWith("/") && !firstPart.text.startsWith("//")
|
||||||
&& (firstPart.type === "plain" || firstPart.type === "pill-candidate")) {
|
&& (firstPart.type === Type.Plain || firstPart.type === Type.PillCandidate)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -268,7 +268,7 @@ export default class EditMessageComposer extends React.Component<IProps, IState>
|
||||||
private getSlashCommand(): [Command, string, string] {
|
private getSlashCommand(): [Command, string, string] {
|
||||||
const commandText = this.model.parts.reduce((text, part) => {
|
const commandText = this.model.parts.reduce((text, part) => {
|
||||||
// use mxid to textify user pills in a command
|
// use mxid to textify user pills in a command
|
||||||
if (part.type === "user-pill") {
|
if (part.type === Type.UserPill) {
|
||||||
return text + part.resourceId;
|
return text + part.resourceId;
|
||||||
}
|
}
|
||||||
return text + part.text;
|
return text + part.text;
|
||||||
|
|
|
@ -31,7 +31,7 @@ import {
|
||||||
textSerialize,
|
textSerialize,
|
||||||
unescapeMessage,
|
unescapeMessage,
|
||||||
} from '../../../editor/serialize';
|
} from '../../../editor/serialize';
|
||||||
import { CommandPartCreator, Part, PartCreator, SerializedPart } from '../../../editor/parts';
|
import { CommandPartCreator, Part, PartCreator, SerializedPart, Type } from '../../../editor/parts';
|
||||||
import BasicMessageComposer from "./BasicMessageComposer";
|
import BasicMessageComposer from "./BasicMessageComposer";
|
||||||
import ReplyThread from "../elements/ReplyThread";
|
import ReplyThread from "../elements/ReplyThread";
|
||||||
import { findEditableEvent } from '../../../utils/EventUtils';
|
import { findEditableEvent } from '../../../utils/EventUtils';
|
||||||
|
@ -240,14 +240,14 @@ export default class SendMessageComposer extends React.Component<IProps> {
|
||||||
const parts = this.model.parts;
|
const parts = this.model.parts;
|
||||||
const firstPart = parts[0];
|
const firstPart = parts[0];
|
||||||
if (firstPart) {
|
if (firstPart) {
|
||||||
if (firstPart.type === "command" && firstPart.text.startsWith("/") && !firstPart.text.startsWith("//")) {
|
if (firstPart.type === Type.Command && firstPart.text.startsWith("/") && !firstPart.text.startsWith("//")) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
// be extra resilient when somehow the AutocompleteWrapperModel or
|
// be extra resilient when somehow the AutocompleteWrapperModel or
|
||||||
// CommandPartCreator fails to insert a command part, so we don't send
|
// CommandPartCreator fails to insert a command part, so we don't send
|
||||||
// a command as a message
|
// a command as a message
|
||||||
if (firstPart.text.startsWith("/") && !firstPart.text.startsWith("//")
|
if (firstPart.text.startsWith("/") && !firstPart.text.startsWith("//")
|
||||||
&& (firstPart.type === "plain" || firstPart.type === "pill-candidate")) {
|
&& (firstPart.type === Type.Plain || firstPart.type === Type.PillCandidate)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -65,6 +65,7 @@ export const SpaceAvatar = ({
|
||||||
}}
|
}}
|
||||||
kind="link"
|
kind="link"
|
||||||
className="mx_SpaceBasicSettings_avatar_remove"
|
className="mx_SpaceBasicSettings_avatar_remove"
|
||||||
|
aria-label={_t("Delete avatar")}
|
||||||
>
|
>
|
||||||
{ _t("Delete") }
|
{ _t("Delete") }
|
||||||
</AccessibleButton>
|
</AccessibleButton>
|
||||||
|
@ -72,7 +73,11 @@ export const SpaceAvatar = ({
|
||||||
} else {
|
} else {
|
||||||
avatarSection = <React.Fragment>
|
avatarSection = <React.Fragment>
|
||||||
<div className="mx_SpaceBasicSettings_avatar" onClick={() => avatarUploadRef.current?.click()} />
|
<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") }
|
{ _t("Upload") }
|
||||||
</AccessibleButton>
|
</AccessibleButton>
|
||||||
</React.Fragment>;
|
</React.Fragment>;
|
||||||
|
|
|
@ -100,9 +100,12 @@ const HomeButton = ({ selected, isPanelCollapsed }: IHomeButtonProps) => {
|
||||||
return SpaceStore.instance.allRoomsInHome;
|
return SpaceStore.instance.allRoomsInHome;
|
||||||
});
|
});
|
||||||
|
|
||||||
return <li className={classNames("mx_SpaceItem", {
|
return <li
|
||||||
"collapsed": isPanelCollapsed,
|
className={classNames("mx_SpaceItem", {
|
||||||
})}>
|
"collapsed": isPanelCollapsed,
|
||||||
|
})}
|
||||||
|
role="treeitem"
|
||||||
|
>
|
||||||
<SpaceButton
|
<SpaceButton
|
||||||
className="mx_SpaceButton_home"
|
className="mx_SpaceButton_home"
|
||||||
onClick={() => SpaceStore.instance.setActiveSpace(null)}
|
onClick={() => SpaceStore.instance.setActiveSpace(null)}
|
||||||
|
@ -142,9 +145,12 @@ const CreateSpaceButton = ({
|
||||||
openMenu();
|
openMenu();
|
||||||
};
|
};
|
||||||
|
|
||||||
return <li className={classNames("mx_SpaceItem", {
|
return <li
|
||||||
"collapsed": isPanelCollapsed,
|
className={classNames("mx_SpaceItem", {
|
||||||
})}>
|
"collapsed": isPanelCollapsed,
|
||||||
|
})}
|
||||||
|
role="treeitem"
|
||||||
|
>
|
||||||
<SpaceButton
|
<SpaceButton
|
||||||
className={classNames("mx_SpaceButton_new", {
|
className={classNames("mx_SpaceButton_new", {
|
||||||
mx_SpaceButton_newCancel: menuDisplayed,
|
mx_SpaceButton_newCancel: menuDisplayed,
|
||||||
|
@ -272,6 +278,8 @@ const SpacePanel = () => {
|
||||||
<ul
|
<ul
|
||||||
className={classNames("mx_SpacePanel", { collapsed: isPanelCollapsed })}
|
className={classNames("mx_SpacePanel", { collapsed: isPanelCollapsed })}
|
||||||
onKeyDown={onKeyDownHandler}
|
onKeyDown={onKeyDownHandler}
|
||||||
|
role="tree"
|
||||||
|
aria-label={_t("Spaces")}
|
||||||
>
|
>
|
||||||
<Droppable droppableId="top-level-spaces">
|
<Droppable droppableId="top-level-spaces">
|
||||||
{ (provided, snapshot) => (
|
{ (provided, snapshot) => (
|
||||||
|
|
|
@ -77,11 +77,17 @@ export const SpaceButton: React.FC<IButtonProps> = ({
|
||||||
|
|
||||||
let notifBadge;
|
let notifBadge;
|
||||||
if (notificationState) {
|
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">
|
notifBadge = <div className="mx_SpacePanel_badgeContainer">
|
||||||
<NotificationBadge
|
<NotificationBadge
|
||||||
onClick={() => SpaceStore.instance.setActiveRoomInSpace(space || null)}
|
onClick={() => SpaceStore.instance.setActiveRoomInSpace(space || null)}
|
||||||
forceCount={false}
|
forceCount={false}
|
||||||
notification={notificationState}
|
notification={notificationState}
|
||||||
|
aria-label={ariaLabel}
|
||||||
/>
|
/>
|
||||||
</div>;
|
</div>;
|
||||||
}
|
}
|
||||||
|
@ -107,7 +113,6 @@ export const SpaceButton: React.FC<IButtonProps> = ({
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
onContextMenu={openMenu}
|
onContextMenu={openMenu}
|
||||||
forceHide={!isNarrow || menuDisplayed}
|
forceHide={!isNarrow || menuDisplayed}
|
||||||
role="treeitem"
|
|
||||||
inputRef={handle}
|
inputRef={handle}
|
||||||
>
|
>
|
||||||
{ children }
|
{ children }
|
||||||
|
@ -284,7 +289,7 @@ export class SpaceItem extends React.PureComponent<IItemProps, IItemState> {
|
||||||
/> : null;
|
/> : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li {...otherProps} className={itemClasses} ref={innerRef}>
|
<li {...otherProps} className={itemClasses} ref={innerRef} aria-expanded={!collapsed} role="treeitem">
|
||||||
<SpaceButton
|
<SpaceButton
|
||||||
space={space}
|
space={space}
|
||||||
className={isInvite ? "mx_SpaceButton_invite" : undefined}
|
className={isInvite ? "mx_SpaceButton_invite" : undefined}
|
||||||
|
@ -296,9 +301,7 @@ export class SpaceItem extends React.PureComponent<IItemProps, IItemState> {
|
||||||
avatarSize={isNested ? 24 : 32}
|
avatarSize={isNested ? 24 : 32}
|
||||||
onClick={this.onClick}
|
onClick={this.onClick}
|
||||||
onKeyDown={this.onKeyDown}
|
onKeyDown={this.onKeyDown}
|
||||||
aria-expanded={!collapsed}
|
ContextMenuComponent={this.props.space.getMyMembership() === "join" ? SpaceContextMenu : undefined}
|
||||||
ContextMenuComponent={this.props.space.getMyMembership() === "join"
|
|
||||||
? SpaceContextMenu : undefined}
|
|
||||||
>
|
>
|
||||||
{ toggleCollapseButton }
|
{ toggleCollapseButton }
|
||||||
</SpaceButton>
|
</SpaceButton>
|
||||||
|
@ -322,7 +325,7 @@ const SpaceTreeLevel: React.FC<ITreeLevelProps> = ({
|
||||||
isNested,
|
isNested,
|
||||||
parents,
|
parents,
|
||||||
}) => {
|
}) => {
|
||||||
return <ul className="mx_SpaceTreeLevel">
|
return <ul className="mx_SpaceTreeLevel" role="group">
|
||||||
{ spaces.map(s => {
|
{ spaces.map(s => {
|
||||||
return (<SpaceItem
|
return (<SpaceItem
|
||||||
key={s.roomId}
|
key={s.roomId}
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
/*
|
/*
|
||||||
Copyright 2015, 2016 OpenMarket Ltd
|
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>
|
Copyright 2021 Šimon Brandner <simon.bra.ag@gmail.com>
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
@ -27,15 +27,7 @@ import { CallEvent, CallState, CallType, MatrixCall } from 'matrix-js-sdk/src/we
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import AccessibleButton from '../elements/AccessibleButton';
|
import AccessibleButton from '../elements/AccessibleButton';
|
||||||
import { isOnlyCtrlOrCmdKeyEvent, Key } from '../../../Keyboard';
|
import { isOnlyCtrlOrCmdKeyEvent, Key } from '../../../Keyboard';
|
||||||
import {
|
|
||||||
alwaysAboveLeftOf,
|
|
||||||
alwaysAboveRightOf,
|
|
||||||
ChevronFace,
|
|
||||||
ContextMenuTooltipButton,
|
|
||||||
} from '../../structures/ContextMenu';
|
|
||||||
import CallContextMenu from '../context_menus/CallContextMenu';
|
|
||||||
import { avatarUrlForMember } from '../../../Avatar';
|
import { avatarUrlForMember } from '../../../Avatar';
|
||||||
import DialpadContextMenu from '../context_menus/DialpadContextMenu';
|
|
||||||
import { CallFeed } from 'matrix-js-sdk/src/webrtc/callFeed';
|
import { CallFeed } from 'matrix-js-sdk/src/webrtc/callFeed';
|
||||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||||
import DesktopCapturerSourcePicker from "../elements/DesktopCapturerSourcePicker";
|
import DesktopCapturerSourcePicker from "../elements/DesktopCapturerSourcePicker";
|
||||||
|
@ -43,8 +35,7 @@ import Modal from '../../../Modal';
|
||||||
import { SDPStreamMetadataPurpose } from 'matrix-js-sdk/src/webrtc/callEventTypes';
|
import { SDPStreamMetadataPurpose } from 'matrix-js-sdk/src/webrtc/callEventTypes';
|
||||||
import CallViewSidebar from './CallViewSidebar';
|
import CallViewSidebar from './CallViewSidebar';
|
||||||
import CallViewHeader from './CallView/CallViewHeader';
|
import CallViewHeader from './CallView/CallViewHeader';
|
||||||
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
|
import CallViewButtons from "./CallView/CallViewButtons";
|
||||||
import { Alignment } from "../elements/Tooltip";
|
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
// The call for us to display
|
// The call for us to display
|
||||||
|
@ -83,8 +74,6 @@ interface IState {
|
||||||
sidebarShown: boolean;
|
sidebarShown: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tooltipYOffset = -24;
|
|
||||||
|
|
||||||
function getFullScreenElement() {
|
function getFullScreenElement() {
|
||||||
return (
|
return (
|
||||||
document.fullscreenElement ||
|
document.fullscreenElement ||
|
||||||
|
@ -113,18 +102,11 @@ function exitFullscreen() {
|
||||||
if (exitMethod) exitMethod.call(document);
|
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")
|
@replaceableComponent("views.voip.CallView")
|
||||||
export default class CallView extends React.Component<IProps, IState> {
|
export default class CallView extends React.Component<IProps, IState> {
|
||||||
private dispatcherRef: string;
|
private dispatcherRef: string;
|
||||||
private contentRef = createRef<HTMLDivElement>();
|
private contentRef = createRef<HTMLDivElement>();
|
||||||
private controlsHideTimer: number = null;
|
private buttonsRef = createRef<CallViewButtons>();
|
||||||
private dialpadButton = createRef<HTMLDivElement>();
|
|
||||||
private contextMenuButton = createRef<HTMLDivElement>();
|
|
||||||
|
|
||||||
constructor(props: IProps) {
|
constructor(props: IProps) {
|
||||||
super(props);
|
super(props);
|
||||||
|
@ -153,7 +135,6 @@ export default class CallView extends React.Component<IProps, IState> {
|
||||||
public componentDidMount() {
|
public componentDidMount() {
|
||||||
this.dispatcherRef = dis.register(this.onAction);
|
this.dispatcherRef = dis.register(this.onAction);
|
||||||
document.addEventListener('keydown', this.onNativeKeyDown);
|
document.addEventListener('keydown', this.onNativeKeyDown);
|
||||||
this.showControls();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public componentWillUnmount() {
|
public componentWillUnmount() {
|
||||||
|
@ -241,16 +222,8 @@ export default class CallView extends React.Component<IProps, IState> {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
private onControlsHideTimer = () => {
|
|
||||||
if (this.state.hoveringControls || this.state.showDialpad || this.state.showMoreMenu) return;
|
|
||||||
this.controlsHideTimer = null;
|
|
||||||
this.setState({
|
|
||||||
controlsVisible: false,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
private onMouseMove = () => {
|
private onMouseMove = () => {
|
||||||
this.showControls();
|
this.buttonsRef.current?.showControls();
|
||||||
};
|
};
|
||||||
|
|
||||||
private getOrderedFeeds(feeds: Array<CallFeed>): { primary: CallFeed, secondary: Array<CallFeed> } {
|
private getOrderedFeeds(feeds: Array<CallFeed>): { primary: CallFeed, secondary: Array<CallFeed> } {
|
||||||
|
@ -276,29 +249,6 @@ export default class CallView extends React.Component<IProps, IState> {
|
||||||
return { primary, secondary };
|
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 => {
|
private onMicMuteClick = (): void => {
|
||||||
const newVal = !this.state.micMuted;
|
const newVal = !this.state.micMuted;
|
||||||
|
|
||||||
|
@ -329,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
|
// 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
|
// Note that this assumes we always have a CallView on screen at any given time
|
||||||
// CallHandler would probably be a better place for this
|
// CallHandler would probably be a better place for this
|
||||||
|
@ -354,7 +291,7 @@ export default class CallView extends React.Component<IProps, IState> {
|
||||||
if (ctrlCmdOnly) {
|
if (ctrlCmdOnly) {
|
||||||
this.onMicMuteClick();
|
this.onMicMuteClick();
|
||||||
// show the controls to give feedback
|
// show the controls to give feedback
|
||||||
this.showControls();
|
this.buttonsRef.current?.showControls();
|
||||||
handled = true;
|
handled = true;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
@ -363,7 +300,7 @@ export default class CallView extends React.Component<IProps, IState> {
|
||||||
if (ctrlCmdOnly) {
|
if (ctrlCmdOnly) {
|
||||||
this.onVidMuteClick();
|
this.onVidMuteClick();
|
||||||
// show the controls to give feedback
|
// show the controls to give feedback
|
||||||
this.showControls();
|
this.buttonsRef.current?.showControls();
|
||||||
handled = true;
|
handled = true;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
@ -375,15 +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 onCallResumeClick = (): void => {
|
private onCallResumeClick = (): void => {
|
||||||
const userFacingRoomId = CallHandler.sharedInstance().roomIdForCall(this.props.call);
|
const userFacingRoomId = CallHandler.sharedInstance().roomIdForCall(this.props.call);
|
||||||
CallHandler.sharedInstance().setActiveCallRoomId(userFacingRoomId);
|
CallHandler.sharedInstance().setActiveCallRoomId(userFacingRoomId);
|
||||||
|
@ -402,206 +330,60 @@ export default class CallView extends React.Component<IProps, IState> {
|
||||||
};
|
};
|
||||||
|
|
||||||
private onToggleSidebar = (): void => {
|
private onToggleSidebar = (): void => {
|
||||||
this.setState({
|
this.setState({ sidebarShown: !this.state.sidebarShown });
|
||||||
sidebarShown: !this.state.sidebarShown,
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
private renderCallControls(): JSX.Element {
|
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
|
// We don't support call upgrades (yet) so hide the video mute button in voice calls
|
||||||
let vidMuteButton;
|
const vidMuteButtonShown = this.props.call.type === CallType.Video;
|
||||||
if (this.props.call.type === CallType.Video) {
|
|
||||||
vidMuteButton = (
|
|
||||||
<AccessibleTooltipButton
|
|
||||||
className={vidClasses}
|
|
||||||
onClick={this.onVidMuteClick}
|
|
||||||
title={this.state.vidMuted ? _t("Start the camera") : _t("Stop the camera")}
|
|
||||||
alignment={Alignment.Top}
|
|
||||||
yOffset={tooltipYOffset}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Screensharing is possible, if we can send a second stream and
|
// Screensharing is possible, if we can send a second stream and
|
||||||
// identify it using SDPStreamMetadata or if we can replace the already
|
// identify it using SDPStreamMetadata or if we can replace the already
|
||||||
// existing usermedia track by a screensharing track. We also need to be
|
// existing usermedia track by a screensharing track. We also need to be
|
||||||
// connected to know the state of the other side
|
// connected to know the state of the other side
|
||||||
let screensharingButton;
|
const screensharingButtonShown = (
|
||||||
if (
|
|
||||||
(this.props.call.opponentSupportsSDPStreamMetadata() || this.props.call.type === CallType.Video) &&
|
(this.props.call.opponentSupportsSDPStreamMetadata() || this.props.call.type === CallType.Video) &&
|
||||||
this.props.call.state === CallState.Connected
|
this.props.call.state === CallState.Connected
|
||||||
) {
|
);
|
||||||
screensharingButton = (
|
|
||||||
<AccessibleTooltipButton
|
|
||||||
className={screensharingClasses}
|
|
||||||
onClick={this.onScreenshareClick}
|
|
||||||
title={this.state.screensharing
|
|
||||||
? _t("Stop sharing your screen")
|
|
||||||
: _t("Start sharing your screen")
|
|
||||||
}
|
|
||||||
alignment={Alignment.Top}
|
|
||||||
yOffset={tooltipYOffset}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// To show the sidebar we need secondary feeds, if we don't have them,
|
// 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 this button. If we are in PiP, sidebar is also hidden, so
|
||||||
// we can hide the button too
|
// we can hide the button too
|
||||||
let sidebarButton;
|
const sidebarButtonShown = (
|
||||||
if (
|
this.state.primaryFeed?.purpose === SDPStreamMetadataPurpose.Screenshare ||
|
||||||
!this.props.pipMode &&
|
this.props.call.isScreensharing()
|
||||||
(
|
);
|
||||||
this.state.primaryFeed?.purpose === SDPStreamMetadataPurpose.Screenshare ||
|
|
||||||
this.props.call.isScreensharing()
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
sidebarButton = (
|
|
||||||
<AccessibleButton
|
|
||||||
className={sidebarButtonClasses}
|
|
||||||
onClick={this.onToggleSidebar}
|
|
||||||
aria-label={this.state.sidebarShown ? _t("Hide sidebar") : _t("Show sidebar")}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// The dial pad & 'more' button actions are only relevant in a connected call
|
// The dial pad & 'more' button actions are only relevant in a connected call
|
||||||
let contextMenuButton;
|
const contextMenuButtonShown = this.state.callState === CallState.Connected;
|
||||||
if (this.state.callState === CallState.Connected) {
|
const dialpadButtonShown = (
|
||||||
contextMenuButton = (
|
this.state.callState === CallState.Connected &&
|
||||||
<ContextMenuTooltipButton
|
this.props.call.opponentSupportsDTMF()
|
||||||
className="mx_CallView_callControls_button mx_CallView_callControls_button_more"
|
);
|
||||||
onClick={this.onMoreClick}
|
|
||||||
inputRef={this.contextMenuButton}
|
|
||||||
isExpanded={this.state.showMoreMenu}
|
|
||||||
title={_t("More")}
|
|
||||||
alignment={Alignment.Top}
|
|
||||||
yOffset={tooltipYOffset}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
let dialpadButton;
|
|
||||||
if (this.state.callState === CallState.Connected && this.props.call.opponentSupportsDTMF()) {
|
|
||||||
dialpadButton = (
|
|
||||||
<ContextMenuTooltipButton
|
|
||||||
className="mx_CallView_callControls_button mx_CallView_callControls_dialpad"
|
|
||||||
inputRef={this.dialpadButton}
|
|
||||||
onClick={this.onDialpadClick}
|
|
||||||
isExpanded={this.state.showDialpad}
|
|
||||||
title={_t("Dialpad")}
|
|
||||||
alignment={Alignment.Top}
|
|
||||||
yOffset={tooltipYOffset}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<div
|
<CallViewButtons
|
||||||
className={callControlsClasses}
|
ref={this.buttonsRef}
|
||||||
onMouseEnter={this.onCallControlsMouseEnter}
|
call={this.props.call}
|
||||||
onMouseLeave={this.onCallControlsMouseLeave}
|
pipMode={this.props.pipMode}
|
||||||
>
|
handlers={{
|
||||||
{ dialPad }
|
onToggleSidebarClick: this.onToggleSidebar,
|
||||||
{ contextMenu }
|
onScreenshareClick: this.onScreenshareClick,
|
||||||
{ dialpadButton }
|
onHangupClick: this.onHangupClick,
|
||||||
<AccessibleTooltipButton
|
onMicMuteClick: this.onMicMuteClick,
|
||||||
className={micClasses}
|
onVidMuteClick: this.onVidMuteClick,
|
||||||
onClick={this.onMicMuteClick}
|
}}
|
||||||
title={this.state.micMuted ? _t("Unmute the microphone") : _t("Mute the microphone")}
|
buttonsState={{
|
||||||
alignment={Alignment.Top}
|
micMuted: this.state.micMuted,
|
||||||
yOffset={tooltipYOffset}
|
vidMuted: this.state.vidMuted,
|
||||||
/>
|
sidebarShown: this.state.sidebarShown,
|
||||||
{ vidMuteButton }
|
screensharing: this.state.screensharing,
|
||||||
<div className={micCacheClasses} />
|
}}
|
||||||
<div className={vidCacheClasses} />
|
buttonsVisibility={{
|
||||||
{ screensharingButton }
|
vidMute: vidMuteButtonShown,
|
||||||
{ sidebarButton }
|
screensharing: screensharingButtonShown,
|
||||||
{ contextMenuButton }
|
sidebar: sidebarButtonShown,
|
||||||
<AccessibleTooltipButton
|
contextMenu: contextMenuButtonShown,
|
||||||
className="mx_CallView_callControls_button mx_CallView_callControls_button_hangup"
|
dialpad: dialpadButtonShown,
|
||||||
onClick={this.onHangupClick}
|
}}
|
||||||
title={_t("Hangup")}
|
/>
|
||||||
alignment={Alignment.Top}
|
|
||||||
yOffset={tooltipYOffset}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
315
src/components/views/voip/CallView/CallViewButtons.tsx
Normal file
315
src/components/views/voip/CallView/CallViewButtons.tsx
Normal file
|
@ -0,0 +1,315 @@
|
||||||
|
/*
|
||||||
|
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 AccessibleButton from "../../elements/AccessibleButton";
|
||||||
|
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 = (
|
||||||
|
<AccessibleButton
|
||||||
|
className={sidebarButtonClasses}
|
||||||
|
onClick={this.props.handlers.onToggleSidebarClick}
|
||||||
|
aria-label={this.props.buttonsState.sidebarShown ? _t("Hide sidebar") : _t("Show sidebar")}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -43,7 +43,7 @@ export default class AutocompleteWrapperModel {
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
public onEscape(e: KeyboardEvent) {
|
public onEscape(e: KeyboardEvent): void {
|
||||||
this.getAutocompleterComponent().onEscape(e);
|
this.getAutocompleterComponent().onEscape(e);
|
||||||
this.updateCallback({
|
this.updateCallback({
|
||||||
replaceParts: [this.partCreator.plain(this.queryPart.text)],
|
replaceParts: [this.partCreator.plain(this.queryPart.text)],
|
||||||
|
@ -51,27 +51,27 @@ export default class AutocompleteWrapperModel {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public close() {
|
public close(): void {
|
||||||
this.updateCallback({ close: true });
|
this.updateCallback({ close: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
public hasSelection() {
|
public hasSelection(): boolean {
|
||||||
return this.getAutocompleterComponent().hasSelection();
|
return this.getAutocompleterComponent().hasSelection();
|
||||||
}
|
}
|
||||||
|
|
||||||
public hasCompletions() {
|
public hasCompletions(): boolean {
|
||||||
const ac = this.getAutocompleterComponent();
|
const ac = this.getAutocompleterComponent();
|
||||||
return ac && ac.countCompletions() > 0;
|
return ac && ac.countCompletions() > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
public onEnter() {
|
public onEnter(): void {
|
||||||
this.updateCallback({ close: true });
|
this.updateCallback({ close: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* If there is no current autocompletion, start one and move to the first selection.
|
* 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();
|
const acComponent = this.getAutocompleterComponent();
|
||||||
if (acComponent.countCompletions() === 0) {
|
if (acComponent.countCompletions() === 0) {
|
||||||
// Force completions to show for the text currently entered
|
// 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);
|
this.getAutocompleterComponent().moveSelection(-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
public selectNextSelection() {
|
public selectNextSelection(): void {
|
||||||
this.getAutocompleterComponent().moveSelection(+1);
|
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
|
// 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)
|
// so we can restore it in onComponentSelectionChange when the value is undefined (meaning it should be the typed text)
|
||||||
this.queryPart = part;
|
this.queryPart = part;
|
||||||
|
@ -97,7 +97,7 @@ export default class AutocompleteWrapperModel {
|
||||||
return this.updateQuery(part.text);
|
return this.updateQuery(part.text);
|
||||||
}
|
}
|
||||||
|
|
||||||
public onComponentSelectionChange(completion: ICompletion) {
|
public onComponentSelectionChange(completion: ICompletion): void {
|
||||||
if (!completion) {
|
if (!completion) {
|
||||||
this.updateCallback({
|
this.updateCallback({
|
||||||
replaceParts: [this.queryPart],
|
replaceParts: [this.queryPart],
|
||||||
|
@ -109,14 +109,14 @@ export default class AutocompleteWrapperModel {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public onComponentConfirm(completion: ICompletion) {
|
public onComponentConfirm(completion: ICompletion): void {
|
||||||
this.updateCallback({
|
this.updateCallback({
|
||||||
replaceParts: this.partForCompletion(completion),
|
replaceParts: this.partForCompletion(completion),
|
||||||
close: true,
|
close: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private partForCompletion(completion: ICompletion) {
|
private partForCompletion(completion: ICompletion): Part[] {
|
||||||
const { completionId } = completion;
|
const { completionId } = completion;
|
||||||
const text = completion.completion;
|
const text = completion.completion;
|
||||||
switch (completion.type) {
|
switch (completion.type) {
|
||||||
|
|
|
@ -19,7 +19,7 @@ import { needsCaretNodeBefore, needsCaretNodeAfter } from "./render";
|
||||||
import Range from "./range";
|
import Range from "./range";
|
||||||
import EditorModel from "./model";
|
import EditorModel from "./model";
|
||||||
import DocumentPosition, { IPosition } from "./position";
|
import DocumentPosition, { IPosition } from "./position";
|
||||||
import { Part } from "./parts";
|
import { Part, Type } from "./parts";
|
||||||
|
|
||||||
export type Caret = Range | DocumentPosition;
|
export type Caret = Range | DocumentPosition;
|
||||||
|
|
||||||
|
@ -113,7 +113,7 @@ function findNodeInLineForPart(parts: Part[], partIndex: number) {
|
||||||
// to find newline parts
|
// to find newline parts
|
||||||
for (let i = 0; i <= partIndex; ++i) {
|
for (let i = 0; i <= partIndex; ++i) {
|
||||||
const part = parts[i];
|
const part = parts[i];
|
||||||
if (part.type === "newline") {
|
if (part.type === Type.Newline) {
|
||||||
lineIndex += 1;
|
lineIndex += 1;
|
||||||
nodeIndex = -1;
|
nodeIndex = -1;
|
||||||
prevPart = null;
|
prevPart = null;
|
||||||
|
@ -128,7 +128,7 @@ function findNodeInLineForPart(parts: Part[], partIndex: number) {
|
||||||
// and not an adjacent caret node
|
// and not an adjacent caret node
|
||||||
if (i < partIndex) {
|
if (i < partIndex) {
|
||||||
const nextPart = parts[i + 1];
|
const nextPart = parts[i + 1];
|
||||||
const isLastOfLine = !nextPart || nextPart.type === "newline";
|
const isLastOfLine = !nextPart || nextPart.type === Type.Newline;
|
||||||
if (needsCaretNodeAfter(part, isLastOfLine)) {
|
if (needsCaretNodeAfter(part, isLastOfLine)) {
|
||||||
nodeIndex += 1;
|
nodeIndex += 1;
|
||||||
}
|
}
|
||||||
|
|
|
@ -20,7 +20,7 @@ import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||||
import { walkDOMDepthFirst } from "./dom";
|
import { walkDOMDepthFirst } from "./dom";
|
||||||
import { checkBlockNode } from "../HtmlUtils";
|
import { checkBlockNode } from "../HtmlUtils";
|
||||||
import { getPrimaryPermalinkEntity } from "../utils/permalinks/Permalinks";
|
import { getPrimaryPermalinkEntity } from "../utils/permalinks/Permalinks";
|
||||||
import { PartCreator } from "./parts";
|
import { PartCreator, Type } from "./parts";
|
||||||
import SdkConfig from "../SdkConfig";
|
import SdkConfig from "../SdkConfig";
|
||||||
|
|
||||||
function parseAtRoomMentions(text: string, partCreator: PartCreator) {
|
function parseAtRoomMentions(text: string, partCreator: PartCreator) {
|
||||||
|
@ -206,7 +206,7 @@ function prefixQuoteLines(isFirstNode, parts, partCreator) {
|
||||||
parts.splice(0, 0, partCreator.plain(QUOTE_LINE_PREFIX));
|
parts.splice(0, 0, partCreator.plain(QUOTE_LINE_PREFIX));
|
||||||
}
|
}
|
||||||
for (let i = 0; i < parts.length; i += 1) {
|
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));
|
parts.splice(i + 1, 0, partCreator.plain(QUOTE_LINE_PREFIX));
|
||||||
i += 1;
|
i += 1;
|
||||||
}
|
}
|
||||||
|
|
|
@ -21,7 +21,7 @@ export interface IDiff {
|
||||||
at?: number;
|
at?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function firstDiff(a: string, b: string) {
|
function firstDiff(a: string, b: string): number {
|
||||||
const compareLen = Math.min(a.length, b.length);
|
const compareLen = Math.min(a.length, b.length);
|
||||||
for (let i = 0; i < compareLen; ++i) {
|
for (let i = 0; i < compareLen; ++i) {
|
||||||
if (a[i] !== b[i]) {
|
if (a[i] !== b[i]) {
|
||||||
|
|
|
@ -36,7 +36,7 @@ export default class HistoryManager {
|
||||||
private addedSinceLastPush = false;
|
private addedSinceLastPush = false;
|
||||||
private removedSinceLastPush = false;
|
private removedSinceLastPush = false;
|
||||||
|
|
||||||
clear() {
|
public clear(): void {
|
||||||
this.stack = [];
|
this.stack = [];
|
||||||
this.newlyTypedCharCount = 0;
|
this.newlyTypedCharCount = 0;
|
||||||
this.currentIndex = -1;
|
this.currentIndex = -1;
|
||||||
|
@ -103,7 +103,7 @@ export default class HistoryManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
// needs to persist parts and caret position
|
// needs to persist parts and caret position
|
||||||
tryPush(model: EditorModel, caret: Caret, inputType: string, diff: IDiff) {
|
public tryPush(model: EditorModel, caret: Caret, inputType: string, diff: IDiff): boolean {
|
||||||
// ignore state restoration echos.
|
// ignore state restoration echos.
|
||||||
// these respect the inputType values of the input event,
|
// these respect the inputType values of the input event,
|
||||||
// but are actually passed in from MessageEditor calling model.reset()
|
// but are actually passed in from MessageEditor calling model.reset()
|
||||||
|
@ -121,22 +121,22 @@ export default class HistoryManager {
|
||||||
return shouldPush;
|
return shouldPush;
|
||||||
}
|
}
|
||||||
|
|
||||||
ensureLastChangesPushed(model: EditorModel) {
|
public ensureLastChangesPushed(model: EditorModel): void {
|
||||||
if (this.changedSinceLastPush) {
|
if (this.changedSinceLastPush) {
|
||||||
this.pushState(model, this.lastCaret);
|
this.pushState(model, this.lastCaret);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
canUndo() {
|
public canUndo(): boolean {
|
||||||
return this.currentIndex >= 1 || this.changedSinceLastPush;
|
return this.currentIndex >= 1 || this.changedSinceLastPush;
|
||||||
}
|
}
|
||||||
|
|
||||||
canRedo() {
|
public canRedo(): boolean {
|
||||||
return this.currentIndex < (this.stack.length - 1);
|
return this.currentIndex < (this.stack.length - 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// returns state that should be applied to model
|
// returns state that should be applied to model
|
||||||
undo(model: EditorModel) {
|
public undo(model: EditorModel): IHistory {
|
||||||
if (this.canUndo()) {
|
if (this.canUndo()) {
|
||||||
this.ensureLastChangesPushed(model);
|
this.ensureLastChangesPushed(model);
|
||||||
this.currentIndex -= 1;
|
this.currentIndex -= 1;
|
||||||
|
@ -145,7 +145,7 @@ export default class HistoryManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
// returns state that should be applied to model
|
// returns state that should be applied to model
|
||||||
redo() {
|
public redo(): IHistory {
|
||||||
if (this.canRedo()) {
|
if (this.canRedo()) {
|
||||||
this.changedSinceLastPush = false;
|
this.changedSinceLastPush = false;
|
||||||
this.currentIndex += 1;
|
this.currentIndex += 1;
|
||||||
|
|
|
@ -15,16 +15,17 @@ limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import EditorModel from "./model";
|
import EditorModel from "./model";
|
||||||
|
import DocumentPosition from "./position";
|
||||||
|
|
||||||
export default class DocumentOffset {
|
export default class DocumentOffset {
|
||||||
constructor(public offset: number, public readonly atNodeEnd: boolean) {
|
constructor(public offset: number, public readonly atNodeEnd: boolean) {
|
||||||
}
|
}
|
||||||
|
|
||||||
asPosition(model: EditorModel) {
|
public asPosition(model: EditorModel): DocumentPosition {
|
||||||
return model.positionForOffset(this.offset, this.atNodeEnd);
|
return model.positionForOffset(this.offset, this.atNodeEnd);
|
||||||
}
|
}
|
||||||
|
|
||||||
add(delta: number, atNodeEnd = false) {
|
public add(delta: number, atNodeEnd = false): DocumentOffset {
|
||||||
return new DocumentOffset(this.offset + delta, atNodeEnd);
|
return new DocumentOffset(this.offset + delta, atNodeEnd);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -15,13 +15,13 @@ limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import Range from "./range";
|
import Range from "./range";
|
||||||
import { Part } from "./parts";
|
import { Part, Type } from "./parts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Some common queries and transformations on the editor model
|
* Some common queries and transformations on the editor model
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export function replaceRangeAndExpandSelection(range: Range, newParts: Part[]) {
|
export function replaceRangeAndExpandSelection(range: Range, newParts: Part[]): void {
|
||||||
const { model } = range;
|
const { model } = range;
|
||||||
model.transform(() => {
|
model.transform(() => {
|
||||||
const oldLen = range.length;
|
const oldLen = range.length;
|
||||||
|
@ -32,7 +32,7 @@ export function replaceRangeAndExpandSelection(range: Range, newParts: Part[]) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function replaceRangeAndMoveCaret(range: Range, newParts: Part[]) {
|
export function replaceRangeAndMoveCaret(range: Range, newParts: Part[]): void {
|
||||||
const { model } = range;
|
const { model } = range;
|
||||||
model.transform(() => {
|
model.transform(() => {
|
||||||
const oldLen = range.length;
|
const oldLen = range.length;
|
||||||
|
@ -43,29 +43,29 @@ export function replaceRangeAndMoveCaret(range: Range, newParts: Part[]) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function rangeStartsAtBeginningOfLine(range: Range) {
|
export function rangeStartsAtBeginningOfLine(range: Range): boolean {
|
||||||
const { model } = range;
|
const { model } = range;
|
||||||
const startsWithPartial = range.start.offset !== 0;
|
const startsWithPartial = range.start.offset !== 0;
|
||||||
const isFirstPart = range.start.index === 0;
|
const isFirstPart = range.start.index === 0;
|
||||||
const previousIsNewline = !isFirstPart && model.parts[range.start.index - 1].type === "newline";
|
const previousIsNewline = !isFirstPart && model.parts[range.start.index - 1].type === Type.Newline;
|
||||||
return !startsWithPartial && (isFirstPart || previousIsNewline);
|
return !startsWithPartial && (isFirstPart || previousIsNewline);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function rangeEndsAtEndOfLine(range: Range) {
|
export function rangeEndsAtEndOfLine(range: Range): boolean {
|
||||||
const { model } = range;
|
const { model } = range;
|
||||||
const lastPart = model.parts[range.end.index];
|
const lastPart = model.parts[range.end.index];
|
||||||
const endsWithPartial = range.end.offset !== lastPart.text.length;
|
const endsWithPartial = range.end.offset !== lastPart.text.length;
|
||||||
const isLastPart = range.end.index === model.parts.length - 1;
|
const isLastPart = range.end.index === model.parts.length - 1;
|
||||||
const nextIsNewline = !isLastPart && model.parts[range.end.index + 1].type === "newline";
|
const nextIsNewline = !isLastPart && model.parts[range.end.index + 1].type === Type.Newline;
|
||||||
return !endsWithPartial && (isLastPart || nextIsNewline);
|
return !endsWithPartial && (isLastPart || nextIsNewline);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatRangeAsQuote(range: Range) {
|
export function formatRangeAsQuote(range: Range): void {
|
||||||
const { model, parts } = range;
|
const { model, parts } = range;
|
||||||
const { partCreator } = model;
|
const { partCreator } = model;
|
||||||
for (let i = 0; i < parts.length; ++i) {
|
for (let i = 0; i < parts.length; ++i) {
|
||||||
const part = parts[i];
|
const part = parts[i];
|
||||||
if (part.type === "newline") {
|
if (part.type === Type.Newline) {
|
||||||
parts.splice(i + 1, 0, partCreator.plain("> "));
|
parts.splice(i + 1, 0, partCreator.plain("> "));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -81,10 +81,10 @@ export function formatRangeAsQuote(range: Range) {
|
||||||
replaceRangeAndExpandSelection(range, parts);
|
replaceRangeAndExpandSelection(range, parts);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatRangeAsCode(range: Range) {
|
export function formatRangeAsCode(range: Range): void {
|
||||||
const { model, parts } = range;
|
const { model, parts } = range;
|
||||||
const { partCreator } = model;
|
const { partCreator } = model;
|
||||||
const needsBlock = parts.some(p => p.type === "newline");
|
const needsBlock = parts.some(p => p.type === Type.Newline);
|
||||||
if (needsBlock) {
|
if (needsBlock) {
|
||||||
parts.unshift(partCreator.plain("```"), partCreator.newline());
|
parts.unshift(partCreator.plain("```"), partCreator.newline());
|
||||||
if (!rangeStartsAtBeginningOfLine(range)) {
|
if (!rangeStartsAtBeginningOfLine(range)) {
|
||||||
|
@ -105,9 +105,9 @@ export function formatRangeAsCode(range: Range) {
|
||||||
|
|
||||||
// parts helper methods
|
// parts helper methods
|
||||||
const isBlank = part => !part.text || !/\S/.test(part.text);
|
const isBlank = part => !part.text || !/\S/.test(part.text);
|
||||||
const isNL = part => part.type === "newline";
|
const isNL = part => part.type === Type.Newline;
|
||||||
|
|
||||||
export function toggleInlineFormat(range: Range, prefix: string, suffix = prefix) {
|
export function toggleInlineFormat(range: Range, prefix: string, suffix = prefix): void {
|
||||||
const { model, parts } = range;
|
const { model, parts } = range;
|
||||||
const { partCreator } = model;
|
const { partCreator } = model;
|
||||||
|
|
||||||
|
|
|
@ -25,6 +25,8 @@ import AutocompleteWrapperModel, {
|
||||||
UpdateQuery,
|
UpdateQuery,
|
||||||
} from "./autocomplete";
|
} from "./autocomplete";
|
||||||
import * as Avatar from "../Avatar";
|
import * as Avatar from "../Avatar";
|
||||||
|
import defaultDispatcher from "../dispatcher/dispatcher";
|
||||||
|
import { Action } from "../dispatcher/actions";
|
||||||
|
|
||||||
interface ISerializedPart {
|
interface ISerializedPart {
|
||||||
type: Type.Plain | Type.Newline | Type.Command | Type.PillCandidate;
|
type: Type.Plain | Type.Newline | Type.Command | Type.PillCandidate;
|
||||||
|
@ -39,7 +41,7 @@ interface ISerializedPillPart {
|
||||||
|
|
||||||
export type SerializedPart = ISerializedPart | ISerializedPillPart;
|
export type SerializedPart = ISerializedPart | ISerializedPillPart;
|
||||||
|
|
||||||
enum Type {
|
export enum Type {
|
||||||
Plain = "plain",
|
Plain = "plain",
|
||||||
Newline = "newline",
|
Newline = "newline",
|
||||||
Command = "command",
|
Command = "command",
|
||||||
|
@ -57,12 +59,12 @@ interface IBasePart {
|
||||||
createAutoComplete(updateCallback: UpdateCallback): void;
|
createAutoComplete(updateCallback: UpdateCallback): void;
|
||||||
|
|
||||||
serialize(): SerializedPart;
|
serialize(): SerializedPart;
|
||||||
remove(offset: number, len: number): string;
|
remove(offset: number, len: number): string | undefined;
|
||||||
split(offset: number): IBasePart;
|
split(offset: number): IBasePart;
|
||||||
validateAndInsert(offset: number, str: string, inputType: string): boolean;
|
validateAndInsert(offset: number, str: string, inputType: string): boolean;
|
||||||
appendUntilRejected(str: string, inputType: string): string;
|
appendUntilRejected(str: string, inputType: string): string | undefined;
|
||||||
updateDOMNode(node: Node);
|
updateDOMNode(node: Node): void;
|
||||||
canUpdateDOMNode(node: Node);
|
canUpdateDOMNode(node: Node): boolean;
|
||||||
toDOMNode(): Node;
|
toDOMNode(): Node;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -85,19 +87,19 @@ abstract class BasePart {
|
||||||
this._text = text;
|
this._text = text;
|
||||||
}
|
}
|
||||||
|
|
||||||
acceptsInsertion(chr: string, offset: number, inputType: string) {
|
protected acceptsInsertion(chr: string, offset: number, inputType: string): boolean {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
acceptsRemoval(position: number, chr: string) {
|
protected acceptsRemoval(position: number, chr: string): boolean {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
merge(part: Part) {
|
public merge(part: Part): boolean {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
split(offset: number) {
|
public split(offset: number): IBasePart {
|
||||||
const splitText = this.text.substr(offset);
|
const splitText = this.text.substr(offset);
|
||||||
this._text = this.text.substr(0, offset);
|
this._text = this.text.substr(0, offset);
|
||||||
return new PlainPart(splitText);
|
return new PlainPart(splitText);
|
||||||
|
@ -105,7 +107,7 @@ abstract class BasePart {
|
||||||
|
|
||||||
// removes len chars, or returns the plain text this part should be replaced with
|
// removes len chars, or returns the plain text this part should be replaced with
|
||||||
// if the part would become invalid if it removed everything.
|
// if the part would become invalid if it removed everything.
|
||||||
remove(offset: number, len: number) {
|
public remove(offset: number, len: number): string | undefined {
|
||||||
// validate
|
// validate
|
||||||
const strWithRemoval = this.text.substr(0, offset) + this.text.substr(offset + len);
|
const strWithRemoval = this.text.substr(0, offset) + this.text.substr(offset + len);
|
||||||
for (let i = offset; i < (len + offset); ++i) {
|
for (let i = offset; i < (len + offset); ++i) {
|
||||||
|
@ -118,7 +120,7 @@ abstract class BasePart {
|
||||||
}
|
}
|
||||||
|
|
||||||
// append str, returns the remaining string if a character was rejected.
|
// append str, returns the remaining string if a character was rejected.
|
||||||
appendUntilRejected(str: string, inputType: string) {
|
public appendUntilRejected(str: string, inputType: string): string | undefined {
|
||||||
const offset = this.text.length;
|
const offset = this.text.length;
|
||||||
for (let i = 0; i < str.length; ++i) {
|
for (let i = 0; i < str.length; ++i) {
|
||||||
const chr = str.charAt(i);
|
const chr = str.charAt(i);
|
||||||
|
@ -132,7 +134,7 @@ abstract class BasePart {
|
||||||
|
|
||||||
// inserts str at offset if all the characters in str were accepted, otherwise don't do anything
|
// inserts str at offset if all the characters in str were accepted, otherwise don't do anything
|
||||||
// return whether the str was accepted or not.
|
// return whether the str was accepted or not.
|
||||||
validateAndInsert(offset: number, str: string, inputType: string) {
|
public validateAndInsert(offset: number, str: string, inputType: string): boolean {
|
||||||
for (let i = 0; i < str.length; ++i) {
|
for (let i = 0; i < str.length; ++i) {
|
||||||
const chr = str.charAt(i);
|
const chr = str.charAt(i);
|
||||||
if (!this.acceptsInsertion(chr, offset + i, inputType)) {
|
if (!this.acceptsInsertion(chr, offset + i, inputType)) {
|
||||||
|
@ -145,42 +147,42 @@ abstract class BasePart {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
createAutoComplete(updateCallback: UpdateCallback): void {}
|
public createAutoComplete(updateCallback: UpdateCallback): void {}
|
||||||
|
|
||||||
trim(len: number) {
|
protected trim(len: number): string {
|
||||||
const remaining = this._text.substr(len);
|
const remaining = this._text.substr(len);
|
||||||
this._text = this._text.substr(0, len);
|
this._text = this._text.substr(0, len);
|
||||||
return remaining;
|
return remaining;
|
||||||
}
|
}
|
||||||
|
|
||||||
get text() {
|
public get text(): string {
|
||||||
return this._text;
|
return this._text;
|
||||||
}
|
}
|
||||||
|
|
||||||
abstract get type(): Type;
|
public abstract get type(): Type;
|
||||||
|
|
||||||
get canEdit() {
|
public get canEdit(): boolean {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
toString() {
|
public toString(): string {
|
||||||
return `${this.type}(${this.text})`;
|
return `${this.type}(${this.text})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
serialize(): SerializedPart {
|
public serialize(): SerializedPart {
|
||||||
return {
|
return {
|
||||||
type: this.type as ISerializedPart["type"],
|
type: this.type as ISerializedPart["type"],
|
||||||
text: this.text,
|
text: this.text,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
abstract updateDOMNode(node: Node);
|
public abstract updateDOMNode(node: Node): void;
|
||||||
abstract canUpdateDOMNode(node: Node);
|
public abstract canUpdateDOMNode(node: Node): boolean;
|
||||||
abstract toDOMNode(): Node;
|
public abstract toDOMNode(): Node;
|
||||||
}
|
}
|
||||||
|
|
||||||
abstract class PlainBasePart extends BasePart {
|
abstract class PlainBasePart extends BasePart {
|
||||||
acceptsInsertion(chr: string, offset: number, inputType: string) {
|
protected acceptsInsertion(chr: string, offset: number, inputType: string): boolean {
|
||||||
if (chr === "\n") {
|
if (chr === "\n") {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
@ -203,11 +205,11 @@ abstract class PlainBasePart extends BasePart {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
toDOMNode() {
|
public toDOMNode(): Node {
|
||||||
return document.createTextNode(this.text);
|
return document.createTextNode(this.text);
|
||||||
}
|
}
|
||||||
|
|
||||||
merge(part) {
|
public merge(part): boolean {
|
||||||
if (part.type === this.type) {
|
if (part.type === this.type) {
|
||||||
this._text = this.text + part.text;
|
this._text = this.text + part.text;
|
||||||
return true;
|
return true;
|
||||||
|
@ -215,47 +217,49 @@ abstract class PlainBasePart extends BasePart {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
updateDOMNode(node: Node) {
|
public updateDOMNode(node: Node): void {
|
||||||
if (node.textContent !== this.text) {
|
if (node.textContent !== this.text) {
|
||||||
node.textContent = this.text;
|
node.textContent = this.text;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
canUpdateDOMNode(node: Node) {
|
public canUpdateDOMNode(node: Node): boolean {
|
||||||
return node.nodeType === Node.TEXT_NODE;
|
return node.nodeType === Node.TEXT_NODE;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// exported for unit tests, should otherwise only be used through PartCreator
|
// exported for unit tests, should otherwise only be used through PartCreator
|
||||||
export class PlainPart extends PlainBasePart implements IBasePart {
|
export class PlainPart extends PlainBasePart implements IBasePart {
|
||||||
get type(): IBasePart["type"] {
|
public get type(): IBasePart["type"] {
|
||||||
return Type.Plain;
|
return Type.Plain;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
abstract class PillPart extends BasePart implements IPillPart {
|
export abstract class PillPart extends BasePart implements IPillPart {
|
||||||
constructor(public resourceId: string, label) {
|
constructor(public resourceId: string, label) {
|
||||||
super(label);
|
super(label);
|
||||||
}
|
}
|
||||||
|
|
||||||
acceptsInsertion(chr: string) {
|
protected acceptsInsertion(chr: string): boolean {
|
||||||
return chr !== " ";
|
return chr !== " ";
|
||||||
}
|
}
|
||||||
|
|
||||||
acceptsRemoval(position: number, chr: string) {
|
protected acceptsRemoval(position: number, chr: string): boolean {
|
||||||
return position !== 0; //if you remove initial # or @, pill should become plain
|
return position !== 0; //if you remove initial # or @, pill should become plain
|
||||||
}
|
}
|
||||||
|
|
||||||
toDOMNode() {
|
public toDOMNode(): Node {
|
||||||
const container = document.createElement("span");
|
const container = document.createElement("span");
|
||||||
container.setAttribute("spellcheck", "false");
|
container.setAttribute("spellcheck", "false");
|
||||||
|
container.setAttribute("contentEditable", "false");
|
||||||
|
container.onclick = this.onClick;
|
||||||
container.className = this.className;
|
container.className = this.className;
|
||||||
container.appendChild(document.createTextNode(this.text));
|
container.appendChild(document.createTextNode(this.text));
|
||||||
this.setAvatar(container);
|
this.setAvatar(container);
|
||||||
return container;
|
return container;
|
||||||
}
|
}
|
||||||
|
|
||||||
updateDOMNode(node: HTMLElement) {
|
public updateDOMNode(node: HTMLElement): void {
|
||||||
const textNode = node.childNodes[0];
|
const textNode = node.childNodes[0];
|
||||||
if (textNode.textContent !== this.text) {
|
if (textNode.textContent !== this.text) {
|
||||||
textNode.textContent = this.text;
|
textNode.textContent = this.text;
|
||||||
|
@ -263,10 +267,13 @@ abstract class PillPart extends BasePart implements IPillPart {
|
||||||
if (node.className !== this.className) {
|
if (node.className !== this.className) {
|
||||||
node.className = this.className;
|
node.className = this.className;
|
||||||
}
|
}
|
||||||
|
if (node.onclick !== this.onClick) {
|
||||||
|
node.onclick = this.onClick;
|
||||||
|
}
|
||||||
this.setAvatar(node);
|
this.setAvatar(node);
|
||||||
}
|
}
|
||||||
|
|
||||||
canUpdateDOMNode(node: HTMLElement) {
|
public canUpdateDOMNode(node: HTMLElement): boolean {
|
||||||
return node.nodeType === Node.ELEMENT_NODE &&
|
return node.nodeType === Node.ELEMENT_NODE &&
|
||||||
node.nodeName === "SPAN" &&
|
node.nodeName === "SPAN" &&
|
||||||
node.childNodes.length === 1 &&
|
node.childNodes.length === 1 &&
|
||||||
|
@ -274,7 +281,7 @@ abstract class PillPart extends BasePart implements IPillPart {
|
||||||
}
|
}
|
||||||
|
|
||||||
// helper method for subclasses
|
// helper method for subclasses
|
||||||
protected setAvatarVars(node: HTMLElement, avatarUrl: string, initialLetter: string) {
|
protected setAvatarVars(node: HTMLElement, avatarUrl: string, initialLetter: string): void {
|
||||||
const avatarBackground = `url('${avatarUrl}')`;
|
const avatarBackground = `url('${avatarUrl}')`;
|
||||||
const avatarLetter = `'${initialLetter}'`;
|
const avatarLetter = `'${initialLetter}'`;
|
||||||
// check if the value is changing,
|
// check if the value is changing,
|
||||||
|
@ -287,7 +294,7 @@ abstract class PillPart extends BasePart implements IPillPart {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
serialize(): ISerializedPillPart {
|
public serialize(): ISerializedPillPart {
|
||||||
return {
|
return {
|
||||||
type: this.type,
|
type: this.type,
|
||||||
text: this.text,
|
text: this.text,
|
||||||
|
@ -295,41 +302,43 @@ abstract class PillPart extends BasePart implements IPillPart {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
get canEdit() {
|
public get canEdit(): boolean {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
abstract get type(): IPillPart["type"];
|
public abstract get type(): IPillPart["type"];
|
||||||
|
|
||||||
abstract get className(): string;
|
protected abstract get className(): string;
|
||||||
|
|
||||||
abstract setAvatar(node: HTMLElement): void;
|
protected onClick?: () => void;
|
||||||
|
|
||||||
|
protected abstract setAvatar(node: HTMLElement): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
class NewlinePart extends BasePart implements IBasePart {
|
class NewlinePart extends BasePart implements IBasePart {
|
||||||
acceptsInsertion(chr: string, offset: number) {
|
protected acceptsInsertion(chr: string, offset: number): boolean {
|
||||||
return offset === 0 && chr === "\n";
|
return offset === 0 && chr === "\n";
|
||||||
}
|
}
|
||||||
|
|
||||||
acceptsRemoval(position: number, chr: string) {
|
protected acceptsRemoval(position: number, chr: string): boolean {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
toDOMNode() {
|
public toDOMNode(): Node {
|
||||||
return document.createElement("br");
|
return document.createElement("br");
|
||||||
}
|
}
|
||||||
|
|
||||||
merge() {
|
public merge(): boolean {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
updateDOMNode() {}
|
public updateDOMNode(): void {}
|
||||||
|
|
||||||
canUpdateDOMNode(node: HTMLElement) {
|
public canUpdateDOMNode(node: HTMLElement): boolean {
|
||||||
return node.tagName === "BR";
|
return node.tagName === "BR";
|
||||||
}
|
}
|
||||||
|
|
||||||
get type(): IBasePart["type"] {
|
public get type(): IBasePart["type"] {
|
||||||
return Type.Newline;
|
return Type.Newline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -337,7 +346,7 @@ class NewlinePart extends BasePart implements IBasePart {
|
||||||
// rather than trying to append to it, which is what we want.
|
// rather than trying to append to it, which is what we want.
|
||||||
// As a newline can also be only one character, it makes sense
|
// As a newline can also be only one character, it makes sense
|
||||||
// as it can only be one character long. This caused #9741.
|
// as it can only be one character long. This caused #9741.
|
||||||
get canEdit() {
|
public get canEdit(): boolean {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -347,7 +356,7 @@ class RoomPillPart extends PillPart {
|
||||||
super(resourceId, label);
|
super(resourceId, label);
|
||||||
}
|
}
|
||||||
|
|
||||||
setAvatar(node: HTMLElement) {
|
protected setAvatar(node: HTMLElement): void {
|
||||||
let initialLetter = "";
|
let initialLetter = "";
|
||||||
let avatarUrl = Avatar.avatarUrlForRoom(this.room, 16, 16, "crop");
|
let avatarUrl = Avatar.avatarUrlForRoom(this.room, 16, 16, "crop");
|
||||||
if (!avatarUrl) {
|
if (!avatarUrl) {
|
||||||
|
@ -357,11 +366,11 @@ class RoomPillPart extends PillPart {
|
||||||
this.setAvatarVars(node, avatarUrl, initialLetter);
|
this.setAvatarVars(node, avatarUrl, initialLetter);
|
||||||
}
|
}
|
||||||
|
|
||||||
get type(): IPillPart["type"] {
|
public get type(): IPillPart["type"] {
|
||||||
return Type.RoomPill;
|
return Type.RoomPill;
|
||||||
}
|
}
|
||||||
|
|
||||||
get className() {
|
protected get className() {
|
||||||
return "mx_RoomPill mx_Pill";
|
return "mx_RoomPill mx_Pill";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -371,11 +380,11 @@ class AtRoomPillPart extends RoomPillPart {
|
||||||
super(text, text, room);
|
super(text, text, room);
|
||||||
}
|
}
|
||||||
|
|
||||||
get type(): IPillPart["type"] {
|
public get type(): IPillPart["type"] {
|
||||||
return Type.AtRoomPill;
|
return Type.AtRoomPill;
|
||||||
}
|
}
|
||||||
|
|
||||||
serialize(): ISerializedPillPart {
|
public serialize(): ISerializedPillPart {
|
||||||
return {
|
return {
|
||||||
type: this.type,
|
type: this.type,
|
||||||
text: this.text,
|
text: this.text,
|
||||||
|
@ -388,7 +397,15 @@ class UserPillPart extends PillPart {
|
||||||
super(userId, displayName);
|
super(userId, displayName);
|
||||||
}
|
}
|
||||||
|
|
||||||
setAvatar(node: HTMLElement) {
|
public get type(): IPillPart["type"] {
|
||||||
|
return Type.UserPill;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected get className() {
|
||||||
|
return "mx_UserPill mx_Pill";
|
||||||
|
}
|
||||||
|
|
||||||
|
protected setAvatar(node: HTMLElement): void {
|
||||||
if (!this.member) {
|
if (!this.member) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -402,13 +419,12 @@ class UserPillPart extends PillPart {
|
||||||
this.setAvatarVars(node, avatarUrl, initialLetter);
|
this.setAvatarVars(node, avatarUrl, initialLetter);
|
||||||
}
|
}
|
||||||
|
|
||||||
get type(): IPillPart["type"] {
|
protected onClick = (): void => {
|
||||||
return Type.UserPill;
|
defaultDispatcher.dispatch({
|
||||||
}
|
action: Action.ViewUser,
|
||||||
|
member: this.member,
|
||||||
get className() {
|
});
|
||||||
return "mx_UserPill mx_Pill";
|
};
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class PillCandidatePart extends PlainBasePart implements IPillCandidatePart {
|
class PillCandidatePart extends PlainBasePart implements IPillCandidatePart {
|
||||||
|
@ -416,11 +432,11 @@ class PillCandidatePart extends PlainBasePart implements IPillCandidatePart {
|
||||||
super(text);
|
super(text);
|
||||||
}
|
}
|
||||||
|
|
||||||
createAutoComplete(updateCallback: UpdateCallback): AutocompleteWrapperModel {
|
public createAutoComplete(updateCallback: UpdateCallback): AutocompleteWrapperModel {
|
||||||
return this.autoCompleteCreator.create(updateCallback);
|
return this.autoCompleteCreator.create(updateCallback);
|
||||||
}
|
}
|
||||||
|
|
||||||
acceptsInsertion(chr: string, offset: number, inputType: string) {
|
protected acceptsInsertion(chr: string, offset: number, inputType: string): boolean {
|
||||||
if (offset === 0) {
|
if (offset === 0) {
|
||||||
return true;
|
return true;
|
||||||
} else {
|
} else {
|
||||||
|
@ -428,11 +444,11 @@ class PillCandidatePart extends PlainBasePart implements IPillCandidatePart {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
merge() {
|
public merge(): boolean {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
acceptsRemoval(position: number, chr: string) {
|
protected acceptsRemoval(position: number, chr: string): boolean {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -463,17 +479,21 @@ interface IAutocompleteCreator {
|
||||||
export class PartCreator {
|
export class PartCreator {
|
||||||
protected readonly autoCompleteCreator: IAutocompleteCreator;
|
protected readonly autoCompleteCreator: IAutocompleteCreator;
|
||||||
|
|
||||||
constructor(private room: Room, private client: MatrixClient, autoCompleteCreator: AutoCompleteCreator = null) {
|
constructor(
|
||||||
|
private readonly room: Room,
|
||||||
|
private readonly client: MatrixClient,
|
||||||
|
autoCompleteCreator: AutoCompleteCreator = null,
|
||||||
|
) {
|
||||||
// pre-create the creator as an object even without callback so it can already be passed
|
// pre-create the creator as an object even without callback so it can already be passed
|
||||||
// to PillCandidatePart (e.g. while deserializing) and set later on
|
// to PillCandidatePart (e.g. while deserializing) and set later on
|
||||||
this.autoCompleteCreator = { create: autoCompleteCreator && autoCompleteCreator(this) };
|
this.autoCompleteCreator = { create: autoCompleteCreator?.(this) };
|
||||||
}
|
}
|
||||||
|
|
||||||
setAutoCompleteCreator(autoCompleteCreator: AutoCompleteCreator) {
|
public setAutoCompleteCreator(autoCompleteCreator: AutoCompleteCreator): void {
|
||||||
this.autoCompleteCreator.create = autoCompleteCreator(this);
|
this.autoCompleteCreator.create = autoCompleteCreator(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
createPartForInput(input: string, partIndex: number, inputType?: string): Part {
|
public createPartForInput(input: string, partIndex: number, inputType?: string): Part {
|
||||||
switch (input[0]) {
|
switch (input[0]) {
|
||||||
case "#":
|
case "#":
|
||||||
case "@":
|
case "@":
|
||||||
|
@ -487,11 +507,11 @@ export class PartCreator {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
createDefaultPart(text: string) {
|
public createDefaultPart(text: string): Part {
|
||||||
return this.plain(text);
|
return this.plain(text);
|
||||||
}
|
}
|
||||||
|
|
||||||
deserializePart(part: SerializedPart): Part {
|
public deserializePart(part: SerializedPart): Part {
|
||||||
switch (part.type) {
|
switch (part.type) {
|
||||||
case Type.Plain:
|
case Type.Plain:
|
||||||
return this.plain(part.text);
|
return this.plain(part.text);
|
||||||
|
@ -508,19 +528,19 @@ export class PartCreator {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
plain(text: string) {
|
public plain(text: string): PlainPart {
|
||||||
return new PlainPart(text);
|
return new PlainPart(text);
|
||||||
}
|
}
|
||||||
|
|
||||||
newline() {
|
public newline(): NewlinePart {
|
||||||
return new NewlinePart("\n");
|
return new NewlinePart("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
pillCandidate(text: string) {
|
public pillCandidate(text: string): PillCandidatePart {
|
||||||
return new PillCandidatePart(text, this.autoCompleteCreator);
|
return new PillCandidatePart(text, this.autoCompleteCreator);
|
||||||
}
|
}
|
||||||
|
|
||||||
roomPill(alias: string, roomId?: string) {
|
public roomPill(alias: string, roomId?: string): RoomPillPart {
|
||||||
let room;
|
let room;
|
||||||
if (roomId || alias[0] !== "#") {
|
if (roomId || alias[0] !== "#") {
|
||||||
room = this.client.getRoom(roomId || alias);
|
room = this.client.getRoom(roomId || alias);
|
||||||
|
@ -533,16 +553,20 @@ export class PartCreator {
|
||||||
return new RoomPillPart(alias, room ? room.name : alias, room);
|
return new RoomPillPart(alias, room ? room.name : alias, room);
|
||||||
}
|
}
|
||||||
|
|
||||||
atRoomPill(text: string) {
|
public atRoomPill(text: string): AtRoomPillPart {
|
||||||
return new AtRoomPillPart(text, this.room);
|
return new AtRoomPillPart(text, this.room);
|
||||||
}
|
}
|
||||||
|
|
||||||
userPill(displayName: string, userId: string) {
|
public userPill(displayName: string, userId: string): UserPillPart {
|
||||||
const member = this.room.getMember(userId);
|
const member = this.room.getMember(userId);
|
||||||
return new UserPillPart(userId, displayName, member);
|
return new UserPillPart(userId, displayName, member);
|
||||||
}
|
}
|
||||||
|
|
||||||
createMentionParts(insertTrailingCharacter: boolean, displayName: string, userId: string) {
|
public createMentionParts(
|
||||||
|
insertTrailingCharacter: boolean,
|
||||||
|
displayName: string,
|
||||||
|
userId: string,
|
||||||
|
): [UserPillPart, PlainPart] {
|
||||||
const pill = this.userPill(displayName, userId);
|
const pill = this.userPill(displayName, userId);
|
||||||
const postfix = this.plain(insertTrailingCharacter ? ": " : " ");
|
const postfix = this.plain(insertTrailingCharacter ? ": " : " ");
|
||||||
return [pill, postfix];
|
return [pill, postfix];
|
||||||
|
@ -567,7 +591,7 @@ export class CommandPartCreator extends PartCreator {
|
||||||
}
|
}
|
||||||
|
|
||||||
public deserializePart(part: SerializedPart): Part {
|
public deserializePart(part: SerializedPart): Part {
|
||||||
if (part.type === "command") {
|
if (part.type === Type.Command) {
|
||||||
return this.command(part.text);
|
return this.command(part.text);
|
||||||
} else {
|
} else {
|
||||||
return super.deserializePart(part);
|
return super.deserializePart(part);
|
||||||
|
|
|
@ -30,7 +30,7 @@ export default class DocumentPosition implements IPosition {
|
||||||
constructor(public readonly index: number, public readonly offset: number) {
|
constructor(public readonly index: number, public readonly offset: number) {
|
||||||
}
|
}
|
||||||
|
|
||||||
compare(otherPos: DocumentPosition) {
|
public compare(otherPos: DocumentPosition): number {
|
||||||
if (this.index === otherPos.index) {
|
if (this.index === otherPos.index) {
|
||||||
return this.offset - otherPos.offset;
|
return this.offset - otherPos.offset;
|
||||||
} else {
|
} else {
|
||||||
|
@ -38,7 +38,7 @@ export default class DocumentPosition implements IPosition {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
iteratePartsBetween(other: DocumentPosition, model: EditorModel, callback: Callback) {
|
public iteratePartsBetween(other: DocumentPosition, model: EditorModel, callback: Callback): void {
|
||||||
if (this.index === -1 || other.index === -1) {
|
if (this.index === -1 || other.index === -1) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -57,7 +57,7 @@ export default class DocumentPosition implements IPosition {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
forwardsWhile(model: EditorModel, predicate: Predicate) {
|
public forwardsWhile(model: EditorModel, predicate: Predicate): DocumentPosition {
|
||||||
if (this.index === -1) {
|
if (this.index === -1) {
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
@ -82,7 +82,7 @@ export default class DocumentPosition implements IPosition {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
backwardsWhile(model: EditorModel, predicate: Predicate) {
|
public backwardsWhile(model: EditorModel, predicate: Predicate): DocumentPosition {
|
||||||
if (this.index === -1) {
|
if (this.index === -1) {
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
@ -107,7 +107,7 @@ export default class DocumentPosition implements IPosition {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
asOffset(model: EditorModel) {
|
public asOffset(model: EditorModel): DocumentOffset {
|
||||||
if (this.index === -1) {
|
if (this.index === -1) {
|
||||||
return new DocumentOffset(0, true);
|
return new DocumentOffset(0, true);
|
||||||
}
|
}
|
||||||
|
@ -121,7 +121,7 @@ export default class DocumentPosition implements IPosition {
|
||||||
return new DocumentOffset(offset, atEnd);
|
return new DocumentOffset(offset, atEnd);
|
||||||
}
|
}
|
||||||
|
|
||||||
isAtEnd(model: EditorModel) {
|
public isAtEnd(model: EditorModel): boolean {
|
||||||
if (model.parts.length === 0) {
|
if (model.parts.length === 0) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
@ -130,7 +130,7 @@ export default class DocumentPosition implements IPosition {
|
||||||
return this.index === lastPartIdx && this.offset === lastPart.text.length;
|
return this.index === lastPartIdx && this.offset === lastPart.text.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
isAtStart() {
|
public isAtStart(): boolean {
|
||||||
return this.index === 0 && this.offset === 0;
|
return this.index === 0 && this.offset === 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -32,23 +32,23 @@ export default class Range {
|
||||||
this._end = bIsLarger ? positionB : positionA;
|
this._end = bIsLarger ? positionB : positionA;
|
||||||
}
|
}
|
||||||
|
|
||||||
moveStart(delta: number) {
|
public moveStart(delta: number): void {
|
||||||
this._start = this._start.forwardsWhile(this.model, () => {
|
this._start = this._start.forwardsWhile(this.model, () => {
|
||||||
delta -= 1;
|
delta -= 1;
|
||||||
return delta >= 0;
|
return delta >= 0;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
trim() {
|
public trim(): void {
|
||||||
this._start = this._start.forwardsWhile(this.model, whitespacePredicate);
|
this._start = this._start.forwardsWhile(this.model, whitespacePredicate);
|
||||||
this._end = this._end.backwardsWhile(this.model, whitespacePredicate);
|
this._end = this._end.backwardsWhile(this.model, whitespacePredicate);
|
||||||
}
|
}
|
||||||
|
|
||||||
expandBackwardsWhile(predicate: Predicate) {
|
public expandBackwardsWhile(predicate: Predicate): void {
|
||||||
this._start = this._start.backwardsWhile(this.model, predicate);
|
this._start = this._start.backwardsWhile(this.model, predicate);
|
||||||
}
|
}
|
||||||
|
|
||||||
get text() {
|
public get text(): string {
|
||||||
let text = "";
|
let text = "";
|
||||||
this._start.iteratePartsBetween(this._end, this.model, (part, startIdx, endIdx) => {
|
this._start.iteratePartsBetween(this._end, this.model, (part, startIdx, endIdx) => {
|
||||||
const t = part.text.substring(startIdx, endIdx);
|
const t = part.text.substring(startIdx, endIdx);
|
||||||
|
@ -63,7 +63,7 @@ export default class Range {
|
||||||
* @param {Part[]} parts the parts to replace the range with
|
* @param {Part[]} parts the parts to replace the range with
|
||||||
* @return {Number} the net amount of characters added, can be negative.
|
* @return {Number} the net amount of characters added, can be negative.
|
||||||
*/
|
*/
|
||||||
replace(parts: Part[]) {
|
public replace(parts: Part[]): number {
|
||||||
const newLength = parts.reduce((sum, part) => sum + part.text.length, 0);
|
const newLength = parts.reduce((sum, part) => sum + part.text.length, 0);
|
||||||
let oldLength = 0;
|
let oldLength = 0;
|
||||||
this._start.iteratePartsBetween(this._end, this.model, (part, startIdx, endIdx) => {
|
this._start.iteratePartsBetween(this._end, this.model, (part, startIdx, endIdx) => {
|
||||||
|
@ -77,8 +77,8 @@ export default class Range {
|
||||||
* Returns a copy of the (partial) parts within the range.
|
* Returns a copy of the (partial) parts within the range.
|
||||||
* For partial parts, only the text is adjusted to the part that intersects with the range.
|
* For partial parts, only the text is adjusted to the part that intersects with the range.
|
||||||
*/
|
*/
|
||||||
get parts() {
|
public get parts(): Part[] {
|
||||||
const parts = [];
|
const parts: Part[] = [];
|
||||||
this._start.iteratePartsBetween(this._end, this.model, (part, startIdx, endIdx) => {
|
this._start.iteratePartsBetween(this._end, this.model, (part, startIdx, endIdx) => {
|
||||||
const serializedPart = part.serialize();
|
const serializedPart = part.serialize();
|
||||||
serializedPart.text = part.text.substring(startIdx, endIdx);
|
serializedPart.text = part.text.substring(startIdx, endIdx);
|
||||||
|
@ -88,7 +88,7 @@ export default class Range {
|
||||||
return parts;
|
return parts;
|
||||||
}
|
}
|
||||||
|
|
||||||
get length() {
|
public get length(): number {
|
||||||
let len = 0;
|
let len = 0;
|
||||||
this._start.iteratePartsBetween(this._end, this.model, (part, startIdx, endIdx) => {
|
this._start.iteratePartsBetween(this._end, this.model, (part, startIdx, endIdx) => {
|
||||||
len += endIdx - startIdx;
|
len += endIdx - startIdx;
|
||||||
|
@ -96,11 +96,11 @@ export default class Range {
|
||||||
return len;
|
return len;
|
||||||
}
|
}
|
||||||
|
|
||||||
get start() {
|
public get start(): DocumentPosition {
|
||||||
return this._start;
|
return this._start;
|
||||||
}
|
}
|
||||||
|
|
||||||
get end() {
|
public get end(): DocumentPosition {
|
||||||
return this._end;
|
return this._end;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -15,19 +15,19 @@ See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Part } from "./parts";
|
import { Part, Type } from "./parts";
|
||||||
import EditorModel from "./model";
|
import EditorModel from "./model";
|
||||||
|
|
||||||
export function needsCaretNodeBefore(part: Part, prevPart: Part) {
|
export function needsCaretNodeBefore(part: Part, prevPart: Part): boolean {
|
||||||
const isFirst = !prevPart || prevPart.type === "newline";
|
const isFirst = !prevPart || prevPart.type === Type.Newline;
|
||||||
return !part.canEdit && (isFirst || !prevPart.canEdit);
|
return !part.canEdit && (isFirst || !prevPart.canEdit);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function needsCaretNodeAfter(part: Part, isLastOfLine: boolean) {
|
export function needsCaretNodeAfter(part: Part, isLastOfLine: boolean): boolean {
|
||||||
return !part.canEdit && isLastOfLine;
|
return !part.canEdit && isLastOfLine;
|
||||||
}
|
}
|
||||||
|
|
||||||
function insertAfter(node: HTMLElement, nodeToInsert: HTMLElement) {
|
function insertAfter(node: HTMLElement, nodeToInsert: HTMLElement): void {
|
||||||
const next = node.nextSibling;
|
const next = node.nextSibling;
|
||||||
if (next) {
|
if (next) {
|
||||||
node.parentElement.insertBefore(nodeToInsert, next);
|
node.parentElement.insertBefore(nodeToInsert, next);
|
||||||
|
@ -44,25 +44,25 @@ export const CARET_NODE_CHAR = "\ufeff";
|
||||||
// a caret node is a node that allows the caret to be placed
|
// a caret node is a node that allows the caret to be placed
|
||||||
// where otherwise it wouldn't be possible
|
// where otherwise it wouldn't be possible
|
||||||
// (e.g. next to a pill span without adjacent text node)
|
// (e.g. next to a pill span without adjacent text node)
|
||||||
function createCaretNode() {
|
function createCaretNode(): HTMLElement {
|
||||||
const span = document.createElement("span");
|
const span = document.createElement("span");
|
||||||
span.className = "caretNode";
|
span.className = "caretNode";
|
||||||
span.appendChild(document.createTextNode(CARET_NODE_CHAR));
|
span.appendChild(document.createTextNode(CARET_NODE_CHAR));
|
||||||
return span;
|
return span;
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateCaretNode(node: HTMLElement) {
|
function updateCaretNode(node: HTMLElement): void {
|
||||||
// ensure the caret node contains only a zero-width space
|
// ensure the caret node contains only a zero-width space
|
||||||
if (node.textContent !== CARET_NODE_CHAR) {
|
if (node.textContent !== CARET_NODE_CHAR) {
|
||||||
node.textContent = CARET_NODE_CHAR;
|
node.textContent = CARET_NODE_CHAR;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isCaretNode(node: HTMLElement) {
|
export function isCaretNode(node: HTMLElement): boolean {
|
||||||
return node && node.tagName === "SPAN" && node.className === "caretNode";
|
return node && node.tagName === "SPAN" && node.className === "caretNode";
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeNextSiblings(node: ChildNode) {
|
function removeNextSiblings(node: ChildNode): void {
|
||||||
if (!node) {
|
if (!node) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -74,7 +74,7 @@ function removeNextSiblings(node: ChildNode) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeChildren(parent: HTMLElement) {
|
function removeChildren(parent: HTMLElement): void {
|
||||||
const firstChild = parent.firstChild;
|
const firstChild = parent.firstChild;
|
||||||
if (firstChild) {
|
if (firstChild) {
|
||||||
removeNextSiblings(firstChild);
|
removeNextSiblings(firstChild);
|
||||||
|
@ -82,7 +82,7 @@ function removeChildren(parent: HTMLElement) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function reconcileLine(lineContainer: ChildNode, parts: Part[]) {
|
function reconcileLine(lineContainer: ChildNode, parts: Part[]): void {
|
||||||
let currentNode;
|
let currentNode;
|
||||||
let prevPart;
|
let prevPart;
|
||||||
const lastPart = parts[parts.length - 1];
|
const lastPart = parts[parts.length - 1];
|
||||||
|
@ -131,13 +131,13 @@ function reconcileLine(lineContainer: ChildNode, parts: Part[]) {
|
||||||
removeNextSiblings(currentNode);
|
removeNextSiblings(currentNode);
|
||||||
}
|
}
|
||||||
|
|
||||||
function reconcileEmptyLine(lineContainer) {
|
function reconcileEmptyLine(lineContainer: HTMLElement): void {
|
||||||
// empty div needs to have a BR in it to give it height
|
// empty div needs to have a BR in it to give it height
|
||||||
let foundBR = false;
|
let foundBR = false;
|
||||||
let partNode = lineContainer.firstChild;
|
let partNode = lineContainer.firstChild;
|
||||||
while (partNode) {
|
while (partNode) {
|
||||||
const nextNode = partNode.nextSibling;
|
const nextNode = partNode.nextSibling;
|
||||||
if (!foundBR && partNode.tagName === "BR") {
|
if (!foundBR && (partNode as HTMLElement).tagName === "BR") {
|
||||||
foundBR = true;
|
foundBR = true;
|
||||||
} else {
|
} else {
|
||||||
partNode.remove();
|
partNode.remove();
|
||||||
|
@ -149,9 +149,9 @@ function reconcileEmptyLine(lineContainer) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderModel(editor: HTMLDivElement, model: EditorModel) {
|
export function renderModel(editor: HTMLDivElement, model: EditorModel): void {
|
||||||
const lines = model.parts.reduce((linesArr, part) => {
|
const lines = model.parts.reduce((linesArr, part) => {
|
||||||
if (part.type === "newline") {
|
if (part.type === Type.Newline) {
|
||||||
linesArr.push([]);
|
linesArr.push([]);
|
||||||
} else {
|
} else {
|
||||||
const lastLine = linesArr[linesArr.length - 1];
|
const lastLine = linesArr[linesArr.length - 1];
|
||||||
|
@ -175,7 +175,7 @@ export function renderModel(editor: HTMLDivElement, model: EditorModel) {
|
||||||
if (parts.length) {
|
if (parts.length) {
|
||||||
reconcileLine(lineContainer, parts);
|
reconcileLine(lineContainer, parts);
|
||||||
} else {
|
} else {
|
||||||
reconcileEmptyLine(lineContainer);
|
reconcileEmptyLine(lineContainer as HTMLElement);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if (lines.length) {
|
if (lines.length) {
|
||||||
|
|
|
@ -22,30 +22,31 @@ import { AllHtmlEntities } from 'html-entities';
|
||||||
import SettingsStore from '../settings/SettingsStore';
|
import SettingsStore from '../settings/SettingsStore';
|
||||||
import SdkConfig from '../SdkConfig';
|
import SdkConfig from '../SdkConfig';
|
||||||
import cheerio from 'cheerio';
|
import cheerio from 'cheerio';
|
||||||
|
import { Type } from './parts';
|
||||||
|
|
||||||
export function mdSerialize(model: EditorModel) {
|
export function mdSerialize(model: EditorModel): string {
|
||||||
return model.parts.reduce((html, part) => {
|
return model.parts.reduce((html, part) => {
|
||||||
switch (part.type) {
|
switch (part.type) {
|
||||||
case "newline":
|
case Type.Newline:
|
||||||
return html + "\n";
|
return html + "\n";
|
||||||
case "plain":
|
case Type.Plain:
|
||||||
case "command":
|
case Type.Command:
|
||||||
case "pill-candidate":
|
case Type.PillCandidate:
|
||||||
case "at-room-pill":
|
case Type.AtRoomPill:
|
||||||
return html + part.text;
|
return html + part.text;
|
||||||
case "room-pill":
|
case Type.RoomPill:
|
||||||
// Here we use the resourceId for compatibility with non-rich text clients
|
// Here we use the resourceId for compatibility with non-rich text clients
|
||||||
// See https://github.com/vector-im/element-web/issues/16660
|
// See https://github.com/vector-im/element-web/issues/16660
|
||||||
return html +
|
return html +
|
||||||
`[${part.resourceId.replace(/[[\\\]]/g, c => "\\" + c)}](${makeGenericPermalink(part.resourceId)})`;
|
`[${part.resourceId.replace(/[[\\\]]/g, c => "\\" + c)}](${makeGenericPermalink(part.resourceId)})`;
|
||||||
case "user-pill":
|
case Type.UserPill:
|
||||||
return html +
|
return html +
|
||||||
`[${part.text.replace(/[[\\\]]/g, c => "\\" + c)}](${makeGenericPermalink(part.resourceId)})`;
|
`[${part.text.replace(/[[\\\]]/g, c => "\\" + c)}](${makeGenericPermalink(part.resourceId)})`;
|
||||||
}
|
}
|
||||||
}, "");
|
}, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function htmlSerializeIfNeeded(model: EditorModel, { forceHTML = false } = {}) {
|
export function htmlSerializeIfNeeded(model: EditorModel, { forceHTML = false } = {}): string {
|
||||||
let md = mdSerialize(model);
|
let md = mdSerialize(model);
|
||||||
// copy of raw input to remove unwanted math later
|
// copy of raw input to remove unwanted math later
|
||||||
const orig = md;
|
const orig = md;
|
||||||
|
@ -156,31 +157,31 @@ export function htmlSerializeIfNeeded(model: EditorModel, { forceHTML = false }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function textSerialize(model: EditorModel) {
|
export function textSerialize(model: EditorModel): string {
|
||||||
return model.parts.reduce((text, part) => {
|
return model.parts.reduce((text, part) => {
|
||||||
switch (part.type) {
|
switch (part.type) {
|
||||||
case "newline":
|
case Type.Newline:
|
||||||
return text + "\n";
|
return text + "\n";
|
||||||
case "plain":
|
case Type.Plain:
|
||||||
case "command":
|
case Type.Command:
|
||||||
case "pill-candidate":
|
case Type.PillCandidate:
|
||||||
case "at-room-pill":
|
case Type.AtRoomPill:
|
||||||
return text + part.text;
|
return text + part.text;
|
||||||
case "room-pill":
|
case Type.RoomPill:
|
||||||
// Here we use the resourceId for compatibility with non-rich text clients
|
// Here we use the resourceId for compatibility with non-rich text clients
|
||||||
// See https://github.com/vector-im/element-web/issues/16660
|
// See https://github.com/vector-im/element-web/issues/16660
|
||||||
return text + `${part.resourceId}`;
|
return text + `${part.resourceId}`;
|
||||||
case "user-pill":
|
case Type.UserPill:
|
||||||
return text + `${part.text}`;
|
return text + `${part.text}`;
|
||||||
}
|
}
|
||||||
}, "");
|
}, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function containsEmote(model: EditorModel) {
|
export function containsEmote(model: EditorModel): boolean {
|
||||||
return startsWith(model, "/me ", false);
|
return startsWith(model, "/me ", false);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function startsWith(model: EditorModel, prefix: string, caseSensitive = true) {
|
export function startsWith(model: EditorModel, prefix: string, caseSensitive = true): boolean {
|
||||||
const firstPart = model.parts[0];
|
const firstPart = model.parts[0];
|
||||||
// part type will be "plain" while editing,
|
// part type will be "plain" while editing,
|
||||||
// and "command" while composing a message.
|
// and "command" while composing a message.
|
||||||
|
@ -190,26 +191,26 @@ export function startsWith(model: EditorModel, prefix: string, caseSensitive = t
|
||||||
text = text.toLowerCase();
|
text = text.toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
return firstPart && (firstPart.type === "plain" || firstPart.type === "command") && text.startsWith(prefix);
|
return firstPart && (firstPart.type === Type.Plain || firstPart.type === Type.Command) && text.startsWith(prefix);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function stripEmoteCommand(model: EditorModel) {
|
export function stripEmoteCommand(model: EditorModel): EditorModel {
|
||||||
// trim "/me "
|
// trim "/me "
|
||||||
return stripPrefix(model, "/me ");
|
return stripPrefix(model, "/me ");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function stripPrefix(model: EditorModel, prefix: string) {
|
export function stripPrefix(model: EditorModel, prefix: string): EditorModel {
|
||||||
model = model.clone();
|
model = model.clone();
|
||||||
model.removeText({ index: 0, offset: 0 }, prefix.length);
|
model.removeText({ index: 0, offset: 0 }, prefix.length);
|
||||||
return model;
|
return model;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function unescapeMessage(model: EditorModel) {
|
export function unescapeMessage(model: EditorModel): EditorModel {
|
||||||
const { parts } = model;
|
const { parts } = model;
|
||||||
if (parts.length) {
|
if (parts.length) {
|
||||||
const firstPart = parts[0];
|
const firstPart = parts[0];
|
||||||
// only unescape \/ to / at start of editor
|
// only unescape \/ to / at start of editor
|
||||||
if (firstPart.type === "plain" && firstPart.text.startsWith("\\/")) {
|
if (firstPart.type === Type.Plain && firstPart.text.startsWith("\\/")) {
|
||||||
model = model.clone();
|
model = model.clone();
|
||||||
model.removeText({ index: 0, offset: 0 }, 1);
|
model.removeText({ index: 0, offset: 0 }, 1);
|
||||||
}
|
}
|
||||||
|
|
|
@ -905,6 +905,16 @@
|
||||||
"sends snowfall": "sends snowfall",
|
"sends snowfall": "sends snowfall",
|
||||||
"Sends the given message with a space themed effect": "Sends the given message with a space themed effect",
|
"Sends the given message with a space themed effect": "Sends the given message with a space themed effect",
|
||||||
"sends space invaders": "sends space invaders",
|
"sends space invaders": "sends space invaders",
|
||||||
|
"unknown person": "unknown person",
|
||||||
|
"Consulting with %(transferTarget)s. <a>Transfer to %(transferee)s</a>": "Consulting with %(transferTarget)s. <a>Transfer to %(transferee)s</a>",
|
||||||
|
"You held the call <a>Switch</a>": "You held the call <a>Switch</a>",
|
||||||
|
"You held the call <a>Resume</a>": "You held the call <a>Resume</a>",
|
||||||
|
"%(peerName)s held the call": "%(peerName)s held the call",
|
||||||
|
"Connecting": "Connecting",
|
||||||
|
"You are presenting": "You are presenting",
|
||||||
|
"%(sharerName)s is presenting": "%(sharerName)s is presenting",
|
||||||
|
"Your camera is turned off": "Your camera is turned off",
|
||||||
|
"Your camera is still enabled": "Your camera is still enabled",
|
||||||
"Start the camera": "Start the camera",
|
"Start the camera": "Start the camera",
|
||||||
"Stop the camera": "Stop the camera",
|
"Stop the camera": "Stop the camera",
|
||||||
"Stop sharing your screen": "Stop sharing your screen",
|
"Stop sharing your screen": "Stop sharing your screen",
|
||||||
|
@ -916,16 +926,6 @@
|
||||||
"Unmute the microphone": "Unmute the microphone",
|
"Unmute the microphone": "Unmute the microphone",
|
||||||
"Mute the microphone": "Mute the microphone",
|
"Mute the microphone": "Mute the microphone",
|
||||||
"Hangup": "Hangup",
|
"Hangup": "Hangup",
|
||||||
"unknown person": "unknown person",
|
|
||||||
"Consulting with %(transferTarget)s. <a>Transfer to %(transferee)s</a>": "Consulting with %(transferTarget)s. <a>Transfer to %(transferee)s</a>",
|
|
||||||
"You held the call <a>Switch</a>": "You held the call <a>Switch</a>",
|
|
||||||
"You held the call <a>Resume</a>": "You held the call <a>Resume</a>",
|
|
||||||
"%(peerName)s held the call": "%(peerName)s held the call",
|
|
||||||
"Connecting": "Connecting",
|
|
||||||
"You are presenting": "You are presenting",
|
|
||||||
"%(sharerName)s is presenting": "%(sharerName)s is presenting",
|
|
||||||
"Your camera is turned off": "Your camera is turned off",
|
|
||||||
"Your camera is still enabled": "Your camera is still enabled",
|
|
||||||
"Video Call": "Video Call",
|
"Video Call": "Video Call",
|
||||||
"Voice Call": "Voice Call",
|
"Voice Call": "Voice Call",
|
||||||
"Fill Screen": "Fill Screen",
|
"Fill Screen": "Fill Screen",
|
||||||
|
@ -1015,7 +1015,9 @@
|
||||||
"Your server isn't responding to some <a>requests</a>.": "Your server isn't responding to some <a>requests</a>.",
|
"Your server isn't responding to some <a>requests</a>.": "Your server isn't responding to some <a>requests</a>.",
|
||||||
"Decline (%(counter)s)": "Decline (%(counter)s)",
|
"Decline (%(counter)s)": "Decline (%(counter)s)",
|
||||||
"Accept <policyLink /> to continue:": "Accept <policyLink /> to continue:",
|
"Accept <policyLink /> to continue:": "Accept <policyLink /> to continue:",
|
||||||
|
"Delete avatar": "Delete avatar",
|
||||||
"Delete": "Delete",
|
"Delete": "Delete",
|
||||||
|
"Upload avatar": "Upload avatar",
|
||||||
"Upload": "Upload",
|
"Upload": "Upload",
|
||||||
"Name": "Name",
|
"Name": "Name",
|
||||||
"Description": "Description",
|
"Description": "Description",
|
||||||
|
@ -1073,6 +1075,8 @@
|
||||||
"Preview Space": "Preview Space",
|
"Preview Space": "Preview Space",
|
||||||
"Allow people to preview your space before they join.": "Allow people to preview your space before they join.",
|
"Allow people to preview your space before they join.": "Allow people to preview your space before they join.",
|
||||||
"Recommended for public spaces.": "Recommended for public spaces.",
|
"Recommended for public spaces.": "Recommended for public spaces.",
|
||||||
|
"Jump to first unread room.": "Jump to first unread room.",
|
||||||
|
"Jump to first invite.": "Jump to first invite.",
|
||||||
"Expand": "Expand",
|
"Expand": "Expand",
|
||||||
"Collapse": "Collapse",
|
"Collapse": "Collapse",
|
||||||
"Space options": "Space options",
|
"Space options": "Space options",
|
||||||
|
@ -1667,8 +1671,6 @@
|
||||||
"Activity": "Activity",
|
"Activity": "Activity",
|
||||||
"A-Z": "A-Z",
|
"A-Z": "A-Z",
|
||||||
"List options": "List options",
|
"List options": "List options",
|
||||||
"Jump to first unread room.": "Jump to first unread room.",
|
|
||||||
"Jump to first invite.": "Jump to first invite.",
|
|
||||||
"Show %(count)s more|other": "Show %(count)s more",
|
"Show %(count)s more|other": "Show %(count)s more",
|
||||||
"Show %(count)s more|one": "Show %(count)s more",
|
"Show %(count)s more|one": "Show %(count)s more",
|
||||||
"Show less": "Show less",
|
"Show less": "Show less",
|
||||||
|
@ -2721,7 +2723,6 @@
|
||||||
"Everyone": "Everyone",
|
"Everyone": "Everyone",
|
||||||
"Your community hasn't got a Long Description, a HTML page to show to community members.<br />Click here to open settings and give it one!": "Your community hasn't got a Long Description, a HTML page to show to community members.<br />Click here to open settings and give it one!",
|
"Your community hasn't got a Long Description, a HTML page to show to community members.<br />Click here to open settings and give it one!": "Your community hasn't got a Long Description, a HTML page to show to community members.<br />Click here to open settings and give it one!",
|
||||||
"Long Description (HTML)": "Long Description (HTML)",
|
"Long Description (HTML)": "Long Description (HTML)",
|
||||||
"Upload avatar": "Upload avatar",
|
|
||||||
"Community %(groupId)s not found": "Community %(groupId)s not found",
|
"Community %(groupId)s not found": "Community %(groupId)s not found",
|
||||||
"This homeserver does not support communities": "This homeserver does not support communities",
|
"This homeserver does not support communities": "This homeserver does not support communities",
|
||||||
"Failed to load %(groupId)s": "Failed to load %(groupId)s",
|
"Failed to load %(groupId)s": "Failed to load %(groupId)s",
|
||||||
|
@ -2831,6 +2832,7 @@
|
||||||
"Mark as suggested": "Mark as suggested",
|
"Mark as suggested": "Mark as suggested",
|
||||||
"No results found": "No results found",
|
"No results found": "No results found",
|
||||||
"You may want to try a different search or check for typos.": "You may want to try a different search or check for typos.",
|
"You may want to try a different search or check for typos.": "You may want to try a different search or check for typos.",
|
||||||
|
"Space": "Space",
|
||||||
"Search names and descriptions": "Search names and descriptions",
|
"Search names and descriptions": "Search names and descriptions",
|
||||||
"If you can't find the room you're looking for, ask for an invite or <a>create a new room</a>.": "If you can't find the room you're looking for, ask for an invite or <a>create a new room</a>.",
|
"If you can't find the room you're looking for, ask for an invite or <a>create a new room</a>.": "If you can't find the room you're looking for, ask for an invite or <a>create a new room</a>.",
|
||||||
"Create room": "Create room",
|
"Create room": "Create room",
|
||||||
|
@ -3112,7 +3114,6 @@
|
||||||
"Page Down": "Page Down",
|
"Page Down": "Page Down",
|
||||||
"Esc": "Esc",
|
"Esc": "Esc",
|
||||||
"Enter": "Enter",
|
"Enter": "Enter",
|
||||||
"Space": "Space",
|
|
||||||
"End": "End",
|
"End": "End",
|
||||||
"[number]": "[number]"
|
"[number]": "[number]"
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue