Merge remote-tracking branch 'upstream/develop' into fix/rl-resort/110

This commit is contained in:
Šimon Brandner 2021-08-12 09:08:02 +02:00
commit 10e42d4d48
No known key found for this signature in database
GPG key ID: 55C211A1226CB17D
38 changed files with 1196 additions and 833 deletions

View file

@ -16,4 +16,16 @@ jobs:
path: element-web/webapp
# We'll only use this in a triggered job, then we're done with it
retention-days: 1
- uses: actions/github-script@v3.1.0
with:
script: |
var fs = require('fs');
fs.writeFileSync('${{github.workspace}}/pr.json', JSON.stringify(context.payload.pull_request));
- name: Upload PR Info
uses: actions/upload-artifact@v2
with:
name: pr.json
path: pr.json
# We'll only use this in a triggered job, then we're done with it
retention-days: 1

View file

@ -33,12 +33,33 @@ jobs:
});
var fs = require('fs');
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
id: netlify
uses: nwtgck/actions-netlify@v1.2
with:
publish-dir: .
publish-dir: webapp
deploy-message: "Deploy from GitHub Actions"
# These don't work because we're in workflow_run
enable-pull-request-comment: false
@ -47,12 +68,13 @@ jobs:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
timeout-minutes: 1
- name: Comment on PR
uses: phulsechinmay/rewritable-pr-comment@v0.3.0
with:
- name: Edit PR Description
uses: velas/pr-description@v1.0.1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ISSUE_ID: ${{ github.event.workflow_run.pull_requests[0].number }}
message: |
with:
pull-request-number: ${{ steps.readctx.outputs.prnumber }}
description-message: |
Preview: ${{ steps.netlify.outputs.deploy-url }}
⚠️ Do you trust the author of this PR? Maybe this build will steal your keys or give you malware. Exercise caution. Use test accounts.

View file

@ -270,6 +270,7 @@
@import "./views/toasts/_IncomingCallToast.scss";
@import "./views/toasts/_NonUrgentEchoFailureToast.scss";
@import "./views/verification/_VerificationShowSas.scss";
@import "./views/voip/CallView/_CallViewButtons.scss";
@import "./views/voip/_CallContainer.scss";
@import "./views/voip/_CallPreview.scss";
@import "./views/voip/_CallView.scss";

View file

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

View file

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

View file

@ -489,6 +489,10 @@ $hover-select-border: 4px;
// https://github.com/vector-im/vector-web/issues/754
overflow-x: overlay;
overflow-y: visible;
&::-webkit-scrollbar-corner {
background: transparent;
}
}
}

View file

@ -0,0 +1,102 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2020 - 2021 The Matrix.org Foundation C.I.C.
Copyright 2021 Šimon Brandner <simon.bra.ag@gmail.com>
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
.mx_CallViewButtons {
position: absolute;
display: flex;
justify-content: center;
bottom: 5px;
opacity: 1;
transition: opacity 0.5s;
z-index: 200; // To be above _all_ feeds
&.mx_CallViewButtons_hidden {
opacity: 0.001; // opacity 0 can cause a re-layout
pointer-events: none;
}
.mx_CallViewButtons_button {
cursor: pointer;
margin-left: 2px;
margin-right: 2px;
&::before {
content: '';
display: inline-block;
height: 48px;
width: 48px;
background-repeat: no-repeat;
background-size: contain;
background-position: center;
}
&.mx_CallViewButtons_dialpad::before {
background-image: url('$(res)/img/voip/dialpad.svg');
}
&.mx_CallViewButtons_button_micOn::before {
background-image: url('$(res)/img/voip/mic-on.svg');
}
&.mx_CallViewButtons_button_micOff::before {
background-image: url('$(res)/img/voip/mic-off.svg');
}
&.mx_CallViewButtons_button_vidOn::before {
background-image: url('$(res)/img/voip/vid-on.svg');
}
&.mx_CallViewButtons_button_vidOff::before {
background-image: url('$(res)/img/voip/vid-off.svg');
}
&.mx_CallViewButtons_button_screensharingOn::before {
background-image: url('$(res)/img/voip/screensharing-on.svg');
}
&.mx_CallViewButtons_button_screensharingOff::before {
background-image: url('$(res)/img/voip/screensharing-off.svg');
}
&.mx_CallViewButtons_button_sidebarOn::before {
background-image: url('$(res)/img/voip/sidebar-on.svg');
}
&.mx_CallViewButtons_button_sidebarOff::before {
background-image: url('$(res)/img/voip/sidebar-off.svg');
}
&.mx_CallViewButtons_button_hangup::before {
background-image: url('$(res)/img/voip/hangup.svg');
}
&.mx_CallViewButtons_button_more::before {
background-image: url('$(res)/img/voip/more.svg');
}
&.mx_CallViewButtons_button_invisible {
visibility: hidden;
pointer-events: none;
position: absolute;
}
}
}

View file

@ -47,11 +47,11 @@ limitations under the License.
height: 180px;
}
.mx_CallView_callControls {
.mx_CallViewButtons {
bottom: 0px;
}
.mx_CallView_callControls_button {
.mx_CallViewButtons_button {
&::before {
width: 36px;
height: 36px;
@ -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 {
opacity: 1;
@ -232,94 +218,3 @@ limitations under the License.
opacity: 0.001; // opacity 0 can cause a re-layout
pointer-events: none;
}
.mx_CallView_callControls_button {
cursor: pointer;
margin-left: 2px;
margin-right: 2px;
&::before {
content: '';
display: inline-block;
height: 48px;
width: 48px;
background-repeat: no-repeat;
background-size: contain;
background-position: center;
}
}
.mx_CallView_callControls_dialpad {
&::before {
background-image: url('$(res)/img/voip/dialpad.svg');
}
}
.mx_CallView_callControls_button_micOn {
&::before {
background-image: url('$(res)/img/voip/mic-on.svg');
}
}
.mx_CallView_callControls_button_micOff {
&::before {
background-image: url('$(res)/img/voip/mic-off.svg');
}
}
.mx_CallView_callControls_button_vidOn {
&::before {
background-image: url('$(res)/img/voip/vid-on.svg');
}
}
.mx_CallView_callControls_button_vidOff {
&::before {
background-image: url('$(res)/img/voip/vid-off.svg');
}
}
.mx_CallView_callControls_button_screensharingOn {
&::before {
background-image: url('$(res)/img/voip/screensharing-on.svg');
}
}
.mx_CallView_callControls_button_screensharingOff {
&::before {
background-image: url('$(res)/img/voip/screensharing-off.svg');
}
}
.mx_CallView_callControls_button_sidebarOn {
&::before {
background-image: url('$(res)/img/voip/sidebar-on.svg');
}
}
.mx_CallView_callControls_button_sidebarOff {
&::before {
background-image: url('$(res)/img/voip/sidebar-off.svg');
}
}
.mx_CallView_callControls_button_hangup {
&::before {
background-image: url('$(res)/img/voip/hangup.svg');
}
}
.mx_CallView_callControls_button_more {
&::before {
background-image: url('$(res)/img/voip/more.svg');
}
}
.mx_CallView_callControls_button_invisible {
visibility: hidden;
pointer-events: none;
position: absolute;
}

View file

@ -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;
$background: #15191E;
$panels: rgba($system-dark, 0.9);
$panel-base: #8D97A5; // This color is not intended for use in the app
$panel-selected: rgba($panel-base, 0.3);
$panel-hover: rgba($panel-base, 0.1);
$panel-actions: rgba($panel-base, 0.2);
$space-nav: rgba($panel-base, 0.1);
// TODO: Move userId colors here
// unified palette
// try to use these colors when possible
$bg-color: #15191E;
$bg-color: $background;
$base-color: $bg-color;
$base-text-color: #ffffff;
$base-text-color: $primary-content;
$header-panel-bg-color: #20252B;
$header-panel-border-color: #000000;
$header-panel-text-primary-color: #B9BEC6;
$header-panel-text-secondary-color: #c8c8cd;
$text-primary-color: #ffffff;
$text-primary-color: $primary-content;
$text-secondary-color: #B9BEC6;
$quaternary-fg-color: #6F7882;
$quaternary-fg-color: $quaternary-content;
$search-bg-color: #181b21;
$search-placeholder-color: #61708b;
$room-highlight-color: #343a46;
@ -23,8 +40,8 @@ $primary-bg-color: $bg-color;
$muted-fg-color: $header-panel-text-primary-color;
// additional text colors
$secondary-fg-color: #A9B2BC;
$tertiary-fg-color: #8E99A4;
$secondary-fg-color: $secondary-content;
$tertiary-fg-color: $tertiary-content;
// used for dialog box text
$light-fg-color: $header-panel-text-secondary-color;
@ -108,20 +125,19 @@ $roomheader-addroom-fg-color: $text-primary-color;
$groupFilterPanel-button-color: $header-panel-text-primary-color;
$groupheader-button-color: $header-panel-text-primary-color;
$rightpanel-button-color: $header-panel-text-primary-color;
$icon-button-color: #8E99A4;
$icon-button-color: $tertiary-content;
$roomtopic-color: $text-secondary-color;
$eventtile-meta-color: $roomtopic-color;
$header-divider-color: $header-panel-text-primary-color;
$composer-e2e-icon-color: $header-panel-text-primary-color;
$quinary-content-color: #394049;
$toast-bg-color: $quinary-content-color;
$toast-bg-color: $quinary-content;
// ********************
$theme-button-bg-color: #e3e8f0;
$dialpad-button-bg-color: #394049;
$dialpad-button-bg-color: $quinary-content;
$roomlist-button-bg-color: rgba(141, 151, 165, 0.2); // Buttons include the filter box, explore button, and sublist buttons
$roomlist-filter-active-bg-color: $bg-color;
@ -164,12 +180,12 @@ $tab-label-icon-bg-color: $text-primary-color;
$tab-label-active-icon-bg-color: $text-primary-color;
// Buttons
$button-primary-fg-color: #ffffff;
$button-primary-fg-color: $primary-content;
$button-primary-bg-color: $accent-color;
$button-secondary-bg-color: transparent;
$button-danger-fg-color: #ffffff;
$button-danger-fg-color: $primary-content;
$button-danger-bg-color: $notice-primary-color;
$button-danger-disabled-fg-color: #ffffff;
$button-danger-disabled-fg-color: $primary-content;
$button-danger-disabled-bg-color: #f5b6bb; // TODO: Verify color
$button-link-fg-color: $accent-color;
$button-link-bg-color: transparent;
@ -201,17 +217,17 @@ $reaction-row-button-selected-border-color: $accent-color;
$kbd-border-color: #000000;
$tooltip-timeline-bg-color: $groupFilterPanel-bg-color;
$tooltip-timeline-fg-color: #ffffff;
$tooltip-timeline-fg-color: $primary-content;
$interactive-tooltip-bg-color: $base-color;
$interactive-tooltip-fg-color: #ffffff;
$interactive-tooltip-fg-color: $primary-content;
$breadcrumb-placeholder-bg-color: #272c35;
$user-tile-hover-bg-color: $header-panel-bg-color;
$message-body-panel-fg-color: $secondary-fg-color;
$message-body-panel-bg-color: #394049; // "Dark Tile"
$message-body-panel-bg-color: $quinary-content;
$message-body-panel-icon-fg-color: $secondary-fg-color;
$message-body-panel-icon-bg-color: $system-dark; // "System Dark"

View file

@ -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';
// 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;
$background: #ffffff;
$panels: rgba($system-light, 0.9);
$panel-selected: rgba($tertiary-content, 0.3);
$panel-hover: rgba($tertiary-content, 0.1);
$panel-actions: rgba($tertiary-content, 0.2);
$space-nav: rgba($tertiary-content, 0.15);
// TODO: Move userId colors here
// unified palette
// try to use these colors when possible
$accent-color: #0DBD8B;
$accent-color: $accent;
$accent-bg-color: rgba(3, 179, 129, 0.16);
$notice-primary-color: #ff4b55;
$notice-primary-bg-color: rgba(255, 75, 85, 0.16);
$primary-fg-color: #2e2f32;
$secondary-fg-color: #737D8C;
$secondary-fg-color: $secondary-content;
$tertiary-fg-color: #8D99A5;
$quaternary-fg-color: #C1C6CD;
$quaternary-fg-color: $quaternary-content;
$header-panel-bg-color: #f3f8fd;
// typical text (dark-on-white in light skin)
$primary-bg-color: #ffffff;
$primary-bg-color: $background;
$muted-fg-color: #61708b; // Commonly used in headings and relevant alt text
// used for dialog box text
@ -38,7 +54,7 @@ $light-fg-color: #747474;
$focus-bg-color: #dddddd;
// button UI (white-on-green in light skin)
$accent-fg-color: #ffffff;
$accent-fg-color: $background;
$accent-color-50pct: rgba($accent-color, 0.5);
$accent-color-darker: #92caad;
$accent-color-alt: #238CF5;
@ -82,7 +98,7 @@ $primary-hairline-color: transparent;
// used for the border of input text fields
$input-border-color: #e7e7e7;
$input-darker-bg-color: #e3e8f0;
$input-darker-bg-color: $quinary-content;
$input-darker-fg-color: #9fa9ba;
$input-lighter-bg-color: #f2f5f8;
$input-lighter-fg-color: $input-darker-fg-color;
@ -90,7 +106,7 @@ $input-focused-border-color: #238cf5;
$input-valid-border-color: $accent-color;
$input-invalid-border-color: $warning-color;
$field-focused-label-bg-color: #ffffff;
$field-focused-label-bg-color: $background;
$button-bg-color: $accent-color;
$button-fg-color: white;
@ -112,8 +128,8 @@ $menu-bg-color: #fff;
$menu-box-shadow-color: rgba(118, 131, 156, 0.6);
$menu-selected-color: #f5f8fa;
$avatar-initial-color: #ffffff;
$avatar-bg-color: #ffffff;
$avatar-initial-color: $background;
$avatar-bg-color: $background;
$h3-color: #3d3b39;
@ -163,7 +179,7 @@ $roomheader-addroom-fg-color: #5c6470;
$groupFilterPanel-button-color: #91A1C0;
$groupheader-button-color: #91A1C0;
$rightpanel-button-color: #91A1C0;
$icon-button-color: #C1C6CD;
$icon-button-color: $quaternary-content;
$roomtopic-color: #9e9e9e;
$eventtile-meta-color: $roomtopic-color;
@ -175,12 +191,12 @@ $voipcall-plinth-color: $system-light;
// ********************
$theme-button-bg-color: #e3e8f0;
$dialpad-button-bg-color: #e3e8f0;
$theme-button-bg-color: $quinary-content;
$dialpad-button-bg-color: $quinary-content;
$roomlist-button-bg-color: rgba(141, 151, 165, 0.2); // Buttons include the filter box, explore button, and sublist buttons
$roomlist-filter-active-bg-color: #ffffff;
$roomlist-filter-active-bg-color: $background;
$roomlist-bg-color: rgba(245, 245, 245, 0.90);
$roomlist-header-color: $tertiary-fg-color;
$roomsublist-divider-color: $primary-fg-color;
@ -194,7 +210,7 @@ $roomtile-selected-bg-color: #FFF;
$presence-online: $accent-color;
$presence-away: #d9b072;
$presence-offline: #E3E8F0;
$presence-offline: $quinary-content;
// ********************
@ -257,7 +273,7 @@ $lightbox-border-color: #ffffff;
// Tabbed views
$tab-label-fg-color: #45474a;
$tab-label-active-fg-color: #ffffff;
$tab-label-active-fg-color: $background;
$tab-label-bg-color: transparent;
$tab-label-active-bg-color: $accent-color;
$tab-label-icon-bg-color: #454545;
@ -267,9 +283,9 @@ $tab-label-active-icon-bg-color: $tab-label-active-fg-color;
$button-primary-fg-color: #ffffff;
$button-primary-bg-color: $accent-color;
$button-secondary-bg-color: $accent-fg-color;
$button-danger-fg-color: #ffffff;
$button-danger-fg-color: $background;
$button-danger-bg-color: $notice-primary-color;
$button-danger-disabled-fg-color: #ffffff;
$button-danger-disabled-fg-color: $background;
$button-danger-disabled-bg-color: #f5b6bb; // TODO: Verify color
$button-link-fg-color: $accent-color;
$button-link-bg-color: transparent;
@ -294,7 +310,7 @@ $memberstatus-placeholder-color: $muted-fg-color;
$authpage-bg-color: #2e3649;
$authpage-modal-bg-color: rgba(245, 245, 245, 0.90);
$authpage-body-bg-color: #ffffff;
$authpage-body-bg-color: $background;
$authpage-focus-bg-color: #dddddd;
$authpage-lang-color: #4e5054;
$authpage-primary-color: #232f32;
@ -318,17 +334,17 @@ $kbd-border-color: $reaction-row-button-border-color;
$inverted-bg-color: #27303a;
$tooltip-timeline-bg-color: $inverted-bg-color;
$tooltip-timeline-fg-color: #ffffff;
$tooltip-timeline-fg-color: $background;
$interactive-tooltip-bg-color: #27303a;
$interactive-tooltip-fg-color: #ffffff;
$interactive-tooltip-fg-color: $background;
$breadcrumb-placeholder-bg-color: #e8eef5;
$user-tile-hover-bg-color: $header-panel-bg-color;
$message-body-panel-fg-color: $secondary-fg-color;
$message-body-panel-bg-color: #E3E8F0; // "Separator"
$message-body-panel-bg-color: $quinary-content;
$message-body-panel-icon-fg-color: $secondary-fg-color;
$message-body-panel-icon-bg-color: $system-light;
@ -337,7 +353,7 @@ $message-body-panel-icon-bg-color: $system-light;
$voice-record-stop-symbol-color: #ff4b55;
$voice-record-live-circle-color: #ff4b55;
$voice-record-stop-border-color: #E3E8F0; // "Separator"
$voice-record-stop-border-color: $quinary-content;
$voice-record-waveform-incomplete-fg-color: $quaternary-fg-color;
$voice-record-icon-color: $tertiary-fg-color;
$voice-playback-button-bg-color: $message-body-panel-icon-bg-color;
@ -357,7 +373,7 @@ $eventbubble-self-bg: #F0FBF8;
$eventbubble-others-bg: $system-light;
$eventbubble-bg-hover: #FAFBFD;
$eventbubble-avatar-outline: $primary-bg-color;
$eventbubble-reply-color: #C1C6CD;
$eventbubble-reply-color: $quaternary-content;
// ***** Mixins! *****

View file

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

View file

@ -150,13 +150,14 @@ const reducer = (state: IState, action: IAction) => {
interface IProps {
handleHomeEnd?: boolean;
handleUpDown?: boolean;
children(renderProps: {
onKeyDownHandler(ev: React.KeyboardEvent);
});
onKeyDown?(ev: React.KeyboardEvent, state: IState);
}
export const RovingTabIndexProvider: React.FC<IProps> = ({ children, handleHomeEnd, onKeyDown }) => {
export const RovingTabIndexProvider: React.FC<IProps> = ({ children, handleHomeEnd, handleUpDown, onKeyDown }) => {
const [state, dispatch] = useReducer<Reducer<IState, IAction>>(reducer, {
activeRef: null,
refs: [],
@ -167,22 +168,51 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({ children, handleHomeE
const onKeyDownHandler = useCallback((ev) => {
let handled = false;
// Don't interfere with input default keydown behaviour
if (handleHomeEnd && ev.target.tagName !== "INPUT" && ev.target.tagName !== "TEXTAREA") {
if (ev.target.tagName !== "INPUT" && ev.target.tagName !== "TEXTAREA") {
// check if we actually have any items
switch (ev.key) {
case Key.HOME:
if (handleHomeEnd) {
handled = true;
// move focus to first item
if (context.state.refs.length > 0) {
context.state.refs[0].current.focus();
}
}
break;
case Key.END:
if (handleHomeEnd) {
handled = true;
// move focus to last item
if (context.state.refs.length > 0) {
context.state.refs[context.state.refs.length - 1].current.focus();
}
}
break;
case Key.ARROW_UP:
if (handleUpDown) {
handled = true;
if (context.state.refs.length > 0) {
const idx = context.state.refs.indexOf(context.state.activeRef);
if (idx > 0) {
context.state.refs[idx - 1].current.focus();
}
}
}
break;
case Key.ARROW_DOWN:
if (handleUpDown) {
handled = true;
if (context.state.refs.length > 0) {
const idx = context.state.refs.indexOf(context.state.activeRef);
if (idx < context.state.refs.length - 1) {
context.state.refs[idx + 1].current.focus();
}
}
}
break;
}
}
@ -193,7 +223,7 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({ children, handleHomeE
} else if (onKeyDown) {
return onKeyDown(ev, context.state);
}
}, [context.state, onKeyDown, handleHomeEnd]);
}, [context.state, onKeyDown, handleHomeEnd, handleUpDown]);
return <RovingTabIndexContext.Provider value={context}>
{ children({ onKeyDownHandler }) }

View file

@ -61,7 +61,9 @@ export default class AutoHideScrollbar extends React.Component<IProps> {
style={style}
className={["mx_AutoHideScrollbar", className].join(" ")}
onWheel={onWheel}
tabIndex={tabIndex}
// Firefox sometimes makes this element focusable due to
// overflow:scroll;, so force it out of tab order by default.
tabIndex={tabIndex ?? -1}
>
{ children }
</div>);

View file

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

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { ReactNode, useMemo, useState } from "react";
import React, { ReactNode, KeyboardEvent, useMemo, useState, KeyboardEventHandler } from "react";
import { Room } from "matrix-js-sdk/src/models/room";
import { EventType, RoomType } from "matrix-js-sdk/src/@types/event";
import { ISpaceSummaryRoom, ISpaceSummaryEvent } from "matrix-js-sdk/src/@types/spaces";
@ -46,6 +46,8 @@ import { getDisplayAliasForAliasSet } from "../../Rooms";
import { useDispatcher } from "../../hooks/useDispatcher";
import defaultDispatcher from "../../dispatcher/dispatcher";
import { Action } from "../../dispatcher/actions";
import { Key } from "../../Keyboard";
import { IState, RovingTabIndexProvider, useRovingTabIndex } from "../../accessibility/RovingTabIndex";
interface IHierarchyProps {
space: Room;
@ -80,6 +82,7 @@ const Tile: React.FC<ITileProps> = ({
|| (room.room_type === RoomType.Space ? _t("Unnamed Space") : _t("Unnamed Room"));
const [showChildren, toggleShowChildren] = useStateToggle(true);
const [onFocus, isActive, ref] = useRovingTabIndex();
const onPreviewClick = (ev: ButtonEvent) => {
ev.preventDefault();
@ -94,11 +97,21 @@ const Tile: React.FC<ITileProps> = ({
let button;
if (joinedRoom) {
button = <AccessibleButton onClick={onPreviewClick} kind="primary_outline">
button = <AccessibleButton
onClick={onPreviewClick}
kind="primary_outline"
onFocus={onFocus}
tabIndex={isActive ? 0 : -1}
>
{ _t("View") }
</AccessibleButton>;
} else if (onJoinClick) {
button = <AccessibleButton onClick={onJoinClick} kind="primary">
button = <AccessibleButton
onClick={onJoinClick}
kind="primary"
onFocus={onFocus}
tabIndex={isActive ? 0 : -1}
>
{ _t("Join") }
</AccessibleButton>;
}
@ -106,13 +119,13 @@ const Tile: React.FC<ITileProps> = ({
let checkbox;
if (onToggleClick) {
if (hasPermissions) {
checkbox = <StyledCheckbox checked={!!selected} onChange={onToggleClick} />;
checkbox = <StyledCheckbox checked={!!selected} onChange={onToggleClick} tabIndex={isActive ? 0 : -1} />;
} else {
checkbox = <TextWithTooltip
tooltip={_t("You don't have permission")}
onClick={ev => { ev.stopPropagation(); }}
>
<StyledCheckbox disabled={true} />
<StyledCheckbox disabled={true} tabIndex={isActive ? 0 : -1} />
</TextWithTooltip>;
}
}
@ -172,8 +185,9 @@ const Tile: React.FC<ITileProps> = ({
</div>
</React.Fragment>;
let childToggle;
let childSection;
let childToggle: JSX.Element;
let childSection: JSX.Element;
let onKeyDown: KeyboardEventHandler;
if (children) {
// the chevron is purposefully a div rather than a button as it should be ignored for a11y
childToggle = <div
@ -185,25 +199,74 @@ const Tile: React.FC<ITileProps> = ({
toggleShowChildren();
}}
/>;
if (showChildren) {
childSection = <div className="mx_SpaceRoomDirectory_subspace_children">
const onChildrenKeyDown = (e) => {
if (e.key === Key.ARROW_LEFT) {
e.preventDefault();
e.stopPropagation();
ref.current?.focus();
}
};
childSection = <div
className="mx_SpaceRoomDirectory_subspace_children"
onKeyDown={onChildrenKeyDown}
role="group"
>
{ children }
</div>;
}
onKeyDown = (e) => {
let handled = false;
switch (e.key) {
case Key.ARROW_LEFT:
if (showChildren) {
handled = true;
toggleShowChildren();
}
break;
case Key.ARROW_RIGHT:
handled = true;
if (showChildren) {
const childSection = ref.current?.nextElementSibling;
childSection?.querySelector<HTMLDivElement>(".mx_SpaceRoomDirectory_roomTile")?.focus();
} else {
toggleShowChildren();
}
break;
}
return <>
if (handled) {
e.preventDefault();
e.stopPropagation();
}
};
}
return <li
className="mx_SpaceRoomDirectory_roomTileWrapper"
role="treeitem"
aria-expanded={children ? showChildren : undefined}
>
<AccessibleButton
className={classNames("mx_SpaceRoomDirectory_roomTile", {
mx_SpaceRoomDirectory_subspace: room.room_type === RoomType.Space,
})}
onClick={(hasPermissions && onToggleClick) ? onToggleClick : onPreviewClick}
onKeyDown={onKeyDown}
inputRef={ref}
onFocus={onFocus}
tabIndex={isActive ? 0 : -1}
>
{ content }
{ childToggle }
</AccessibleButton>
{ childSection }
</>;
</li>;
};
export const showRoom = (room: ISpaceSummaryRoom, viaServers?: string[], autoJoin = false) => {
@ -414,6 +477,15 @@ export const SpaceHierarchy: React.FC<IHierarchyProps> = ({
return <p>{ _t("Your server does not support showing space hierarchies.") }</p>;
}
const onKeyDown = (ev: KeyboardEvent, state: IState) => {
if (ev.key === Key.ARROW_DOWN && ev.currentTarget.classList.contains("mx_SpaceRoomDirectory_search")) {
state.refs[0]?.current?.focus();
}
};
// TODO loading state/error state
return <RovingTabIndexProvider onKeyDown={onKeyDown} handleHomeEnd handleUpDown>
{ ({ onKeyDownHandler }) => {
let content;
if (roomsMap) {
const numRooms = Array.from(roomsMap.values()).filter(r => !r.room_type).length;
@ -429,9 +501,13 @@ export const SpaceHierarchy: React.FC<IHierarchyProps> = ({
}
let manageButtons;
if (space.getMyMembership() === "join" && space.currentState.maySendStateEvent(EventType.SpaceChild, userId)) {
if (space.getMyMembership() === "join" &&
space.currentState.maySendStateEvent(EventType.SpaceChild, userId)
) {
const selectedRelations = Array.from(selected.keys()).flatMap(parentId => {
return [...selected.get(parentId).values()].map(childId => [parentId, childId]) as [string, string][];
return [
...selected.get(parentId).values(),
].map(childId => [parentId, childId]) as [string, string][];
});
const selectionAllSuggested = selectedRelations.every(([parentId, childId]) => {
@ -563,7 +639,12 @@ export const SpaceHierarchy: React.FC<IHierarchyProps> = ({
{ error && <div className="mx_SpaceRoomDirectory_error">
{ error }
</div> }
<AutoHideScrollbar className="mx_SpaceRoomDirectory_list">
<AutoHideScrollbar
className="mx_SpaceRoomDirectory_list"
onKeyDown={onKeyDownHandler}
role="tree"
aria-label={_t("Space")}
>
{ results }
{ children }
</AutoHideScrollbar>
@ -572,18 +653,20 @@ export const SpaceHierarchy: React.FC<IHierarchyProps> = ({
content = <Spinner />;
}
// TODO loading state/error state
return <>
<SearchBox
className="mx_textinput_icon mx_textinput_search"
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 {

View file

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

View file

@ -18,7 +18,7 @@ limitations under the License.
import React, { ChangeEvent, createRef, CSSProperties, ReactElement, ReactNode, Ref } from 'react';
import classnames from 'classnames';
import AccessibleButton from './AccessibleButton';
import AccessibleButton, { ButtonEvent } from './AccessibleButton';
import { _t } from '../../../languageHandler';
import { Key } from "../../../Keyboard";
import { replaceableComponent } from "../../../utils/replaceableComponent";
@ -178,7 +178,7 @@ export default class Dropdown extends React.Component<IProps, IState> {
this.ignoreEvent = ev;
};
private onInputClick = (ev: React.MouseEvent) => {
private onAccessibleButtonClick = (ev: ButtonEvent) => {
if (this.props.disabled) return;
if (!this.state.expanded) {
@ -186,6 +186,10 @@ export default class Dropdown extends React.Component<IProps, IState> {
expanded: true,
});
ev.preventDefault();
} else if ((ev as React.KeyboardEvent).key === Key.ENTER) {
// the accessible button consumes enter onKeyDown for firing onClick, so handle it here
this.props.onOptionChange(this.state.highlightedOption);
this.close();
}
};
@ -204,7 +208,7 @@ export default class Dropdown extends React.Component<IProps, IState> {
this.props.onOptionChange(dropdownKey);
};
private onInputKeyDown = (e: React.KeyboardEvent) => {
private onKeyDown = (e: React.KeyboardEvent) => {
let handled = true;
// These keys don't generate keypress events and so needs to be on keyup
@ -269,7 +273,7 @@ export default class Dropdown extends React.Component<IProps, IState> {
private prevOption(optionKey: string): string {
const keys = Object.keys(this.childrenByKey);
const index = keys.indexOf(optionKey);
return keys[(index - 1) % keys.length];
return keys[index <= 0 ? keys.length - 1 : (index - 1) % keys.length];
}
private scrollIntoView(node: Element) {
@ -320,7 +324,6 @@ export default class Dropdown extends React.Component<IProps, IState> {
type="text"
autoFocus={true}
className="mx_Dropdown_option"
onKeyDown={this.onInputKeyDown}
onChange={this.onInputChange}
value={this.state.searchQuery}
role="combobox"
@ -329,6 +332,7 @@ export default class Dropdown extends React.Component<IProps, IState> {
aria-owns={`${this.props.id}_listbox`}
aria-disabled={this.props.disabled}
aria-label={this.props.label}
onKeyDown={this.onKeyDown}
/>
);
}
@ -361,13 +365,14 @@ export default class Dropdown extends React.Component<IProps, IState> {
return <div className={classnames(dropdownClasses)} ref={this.collectRoot}>
<AccessibleButton
className="mx_Dropdown_input mx_no_textinput"
onClick={this.onInputClick}
onClick={this.onAccessibleButtonClick}
aria-haspopup="listbox"
aria-expanded={this.state.expanded}
disabled={this.props.disabled}
inputRef={this.buttonRef}
aria-label={this.props.label}
aria-describedby={`${this.props.id}_value`}
onKeyDown={this.onKeyDown}
>
{ currentValue }
<span className="mx_Dropdown_arrow" />

View file

@ -31,7 +31,7 @@ import {
} from '../../../editor/operations';
import { getCaretOffsetAndText, getRangeForSelection } from '../../../editor/dom';
import Autocomplete, { generateCompletionDomId } from '../rooms/Autocomplete';
import { getAutoCompleteCreator } from '../../../editor/parts';
import { getAutoCompleteCreator, Type } from '../../../editor/parts';
import { parseEvent, parsePlainTextMessage } from '../../../editor/deserialize';
import { renderModel } from '../../../editor/render';
import TypingStore from "../../../stores/TypingStore";
@ -169,7 +169,7 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
range.expandBackwardsWhile((index, offset) => {
const part = model.parts[index];
n -= 1;
return n >= 0 && (part.type === "plain" || part.type === "pill-candidate");
return n >= 0 && (part.type === Type.Plain || part.type === Type.PillCandidate);
});
const emoticonMatch = REGEX_EMOTICON_WHITESPACE.exec(range.text);
if (emoticonMatch) {
@ -541,6 +541,7 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
handled = true;
} else if (event.key === Key.BACKSPACE || event.key === Key.DELETE) {
this.formatBarRef.current.hide();
handled = this.fakeDeletion(event.key === Key.BACKSPACE);
}
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> {
try {
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);
range.expandBackwardsWhile((index, offset, part) => {
return part.text[offset] !== " " && part.text[offset] !== "+" && (
part.type === "plain" ||
part.type === "pill-candidate" ||
part.type === "command"
part.type === Type.Plain ||
part.type === Type.PillCandidate ||
part.type === Type.Command
);
});
const { partCreator } = model;

View file

@ -25,7 +25,7 @@ import { getCaretOffsetAndText } from '../../../editor/dom';
import { htmlSerializeIfNeeded, textSerialize, containsEmote, stripEmoteCommand } from '../../../editor/serialize';
import { findEditableEvent } from '../../../utils/EventUtils';
import { parseEvent } from '../../../editor/deserialize';
import { CommandPartCreator, Part, PartCreator } from '../../../editor/parts';
import { CommandPartCreator, Part, PartCreator, Type } from '../../../editor/parts';
import EditorStateTransfer from '../../../utils/EditorStateTransfer';
import BasicMessageComposer from "./BasicMessageComposer";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
@ -242,12 +242,12 @@ export default class EditMessageComposer extends React.Component<IProps, IState>
const parts = this.model.parts;
const firstPart = parts[0];
if (firstPart) {
if (firstPart.type === "command" && firstPart.text.startsWith("/") && !firstPart.text.startsWith("//")) {
if (firstPart.type === Type.Command && firstPart.text.startsWith("/") && !firstPart.text.startsWith("//")) {
return true;
}
if (firstPart.text.startsWith("/") && !firstPart.text.startsWith("//")
&& (firstPart.type === "plain" || firstPart.type === "pill-candidate")) {
&& (firstPart.type === Type.Plain || firstPart.type === Type.PillCandidate)) {
return true;
}
}
@ -268,7 +268,7 @@ export default class EditMessageComposer extends React.Component<IProps, IState>
private getSlashCommand(): [Command, string, string] {
const commandText = this.model.parts.reduce((text, part) => {
// use mxid to textify user pills in a command
if (part.type === "user-pill") {
if (part.type === Type.UserPill) {
return text + part.resourceId;
}
return text + part.text;

View file

@ -31,7 +31,7 @@ import {
textSerialize,
unescapeMessage,
} from '../../../editor/serialize';
import { CommandPartCreator, Part, PartCreator, SerializedPart } from '../../../editor/parts';
import { CommandPartCreator, Part, PartCreator, SerializedPart, Type } from '../../../editor/parts';
import BasicMessageComposer from "./BasicMessageComposer";
import ReplyThread from "../elements/ReplyThread";
import { findEditableEvent } from '../../../utils/EventUtils';
@ -240,14 +240,14 @@ export default class SendMessageComposer extends React.Component<IProps> {
const parts = this.model.parts;
const firstPart = parts[0];
if (firstPart) {
if (firstPart.type === "command" && firstPart.text.startsWith("/") && !firstPart.text.startsWith("//")) {
if (firstPart.type === Type.Command && firstPart.text.startsWith("/") && !firstPart.text.startsWith("//")) {
return true;
}
// be extra resilient when somehow the AutocompleteWrapperModel or
// CommandPartCreator fails to insert a command part, so we don't send
// a command as a message
if (firstPart.text.startsWith("/") && !firstPart.text.startsWith("//")
&& (firstPart.type === "plain" || firstPart.type === "pill-candidate")) {
&& (firstPart.type === Type.Plain || firstPart.type === Type.PillCandidate)) {
return true;
}
}

View file

@ -65,6 +65,7 @@ export const SpaceAvatar = ({
}}
kind="link"
className="mx_SpaceBasicSettings_avatar_remove"
aria-label={_t("Delete avatar")}
>
{ _t("Delete") }
</AccessibleButton>
@ -72,7 +73,11 @@ export const SpaceAvatar = ({
} else {
avatarSection = <React.Fragment>
<div className="mx_SpaceBasicSettings_avatar" onClick={() => avatarUploadRef.current?.click()} />
<AccessibleButton onClick={() => avatarUploadRef.current?.click()} kind="link">
<AccessibleButton
onClick={() => avatarUploadRef.current?.click()}
kind="link"
aria-label={_t("Upload avatar")}
>
{ _t("Upload") }
</AccessibleButton>
</React.Fragment>;

View file

@ -100,9 +100,12 @@ const HomeButton = ({ selected, isPanelCollapsed }: IHomeButtonProps) => {
return SpaceStore.instance.allRoomsInHome;
});
return <li className={classNames("mx_SpaceItem", {
return <li
className={classNames("mx_SpaceItem", {
"collapsed": isPanelCollapsed,
})}>
})}
role="treeitem"
>
<SpaceButton
className="mx_SpaceButton_home"
onClick={() => SpaceStore.instance.setActiveSpace(null)}
@ -142,9 +145,12 @@ const CreateSpaceButton = ({
openMenu();
};
return <li className={classNames("mx_SpaceItem", {
return <li
className={classNames("mx_SpaceItem", {
"collapsed": isPanelCollapsed,
})}>
})}
role="treeitem"
>
<SpaceButton
className={classNames("mx_SpaceButton_new", {
mx_SpaceButton_newCancel: menuDisplayed,
@ -272,6 +278,8 @@ const SpacePanel = () => {
<ul
className={classNames("mx_SpacePanel", { collapsed: isPanelCollapsed })}
onKeyDown={onKeyDownHandler}
role="tree"
aria-label={_t("Spaces")}
>
<Droppable droppableId="top-level-spaces">
{ (provided, snapshot) => (

View file

@ -77,11 +77,17 @@ export const SpaceButton: React.FC<IButtonProps> = ({
let notifBadge;
if (notificationState) {
let ariaLabel = _t("Jump to first unread room.");
if (space?.getMyMembership() === "invite") {
ariaLabel = _t("Jump to first invite.");
}
notifBadge = <div className="mx_SpacePanel_badgeContainer">
<NotificationBadge
onClick={() => SpaceStore.instance.setActiveRoomInSpace(space || null)}
forceCount={false}
notification={notificationState}
aria-label={ariaLabel}
/>
</div>;
}
@ -107,7 +113,6 @@ export const SpaceButton: React.FC<IButtonProps> = ({
onClick={onClick}
onContextMenu={openMenu}
forceHide={!isNarrow || menuDisplayed}
role="treeitem"
inputRef={handle}
>
{ children }
@ -284,7 +289,7 @@ export class SpaceItem extends React.PureComponent<IItemProps, IItemState> {
/> : null;
return (
<li {...otherProps} className={itemClasses} ref={innerRef}>
<li {...otherProps} className={itemClasses} ref={innerRef} aria-expanded={!collapsed} role="treeitem">
<SpaceButton
space={space}
className={isInvite ? "mx_SpaceButton_invite" : undefined}
@ -296,9 +301,7 @@ export class SpaceItem extends React.PureComponent<IItemProps, IItemState> {
avatarSize={isNested ? 24 : 32}
onClick={this.onClick}
onKeyDown={this.onKeyDown}
aria-expanded={!collapsed}
ContextMenuComponent={this.props.space.getMyMembership() === "join"
? SpaceContextMenu : undefined}
ContextMenuComponent={this.props.space.getMyMembership() === "join" ? SpaceContextMenu : undefined}
>
{ toggleCollapseButton }
</SpaceButton>
@ -322,7 +325,7 @@ const SpaceTreeLevel: React.FC<ITreeLevelProps> = ({
isNested,
parents,
}) => {
return <ul className="mx_SpaceTreeLevel">
return <ul className="mx_SpaceTreeLevel" role="group">
{ spaces.map(s => {
return (<SpaceItem
key={s.roomId}

View file

@ -1,6 +1,6 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
Copyright 2019 - 2021 The Matrix.org Foundation C.I.C.
Copyright 2021 Šimon Brandner <simon.bra.ag@gmail.com>
Licensed under the Apache License, Version 2.0 (the "License");
@ -27,15 +27,7 @@ import { CallEvent, CallState, CallType, MatrixCall } from 'matrix-js-sdk/src/we
import classNames from 'classnames';
import AccessibleButton from '../elements/AccessibleButton';
import { isOnlyCtrlOrCmdKeyEvent, Key } from '../../../Keyboard';
import {
alwaysAboveLeftOf,
alwaysAboveRightOf,
ChevronFace,
ContextMenuTooltipButton,
} from '../../structures/ContextMenu';
import CallContextMenu from '../context_menus/CallContextMenu';
import { avatarUrlForMember } from '../../../Avatar';
import DialpadContextMenu from '../context_menus/DialpadContextMenu';
import { CallFeed } from 'matrix-js-sdk/src/webrtc/callFeed';
import { replaceableComponent } from "../../../utils/replaceableComponent";
import DesktopCapturerSourcePicker from "../elements/DesktopCapturerSourcePicker";
@ -43,8 +35,7 @@ import Modal from '../../../Modal';
import { SDPStreamMetadataPurpose } from 'matrix-js-sdk/src/webrtc/callEventTypes';
import CallViewSidebar from './CallViewSidebar';
import CallViewHeader from './CallView/CallViewHeader';
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import { Alignment } from "../elements/Tooltip";
import CallViewButtons from "./CallView/CallViewButtons";
interface IProps {
// The call for us to display
@ -83,8 +74,6 @@ interface IState {
sidebarShown: boolean;
}
const tooltipYOffset = -24;
function getFullScreenElement() {
return (
document.fullscreenElement ||
@ -113,18 +102,11 @@ function exitFullscreen() {
if (exitMethod) exitMethod.call(document);
}
const CONTROLS_HIDE_DELAY = 2000;
// Height of the header duplicated from CSS because we need to subtract it from our max
// height to get the max height of the video
const CONTEXT_MENU_VPADDING = 8; // How far the context menu sits above the button (px)
@replaceableComponent("views.voip.CallView")
export default class CallView extends React.Component<IProps, IState> {
private dispatcherRef: string;
private contentRef = createRef<HTMLDivElement>();
private controlsHideTimer: number = null;
private dialpadButton = createRef<HTMLDivElement>();
private contextMenuButton = createRef<HTMLDivElement>();
private buttonsRef = createRef<CallViewButtons>();
constructor(props: IProps) {
super(props);
@ -153,7 +135,6 @@ export default class CallView extends React.Component<IProps, IState> {
public componentDidMount() {
this.dispatcherRef = dis.register(this.onAction);
document.addEventListener('keydown', this.onNativeKeyDown);
this.showControls();
}
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 = () => {
this.showControls();
this.buttonsRef.current?.showControls();
};
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 };
}
private showControls(): void {
if (this.state.showMoreMenu || this.state.showDialpad) return;
if (!this.state.controlsVisible) {
this.setState({
controlsVisible: true,
});
}
if (this.controlsHideTimer !== null) {
clearTimeout(this.controlsHideTimer);
}
this.controlsHideTimer = window.setTimeout(this.onControlsHideTimer, CONTROLS_HIDE_DELAY);
}
private onDialpadClick = (): void => {
if (!this.state.showDialpad) {
this.setState({ showDialpad: true });
this.showControls();
} else {
this.setState({ showDialpad: false });
}
};
private onMicMuteClick = (): void => {
const newVal = !this.state.micMuted;
@ -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
// Note that this assumes we always have a CallView on screen at any given time
// CallHandler would probably be a better place for this
@ -354,7 +291,7 @@ export default class CallView extends React.Component<IProps, IState> {
if (ctrlCmdOnly) {
this.onMicMuteClick();
// show the controls to give feedback
this.showControls();
this.buttonsRef.current?.showControls();
handled = true;
}
break;
@ -363,7 +300,7 @@ export default class CallView extends React.Component<IProps, IState> {
if (ctrlCmdOnly) {
this.onVidMuteClick();
// show the controls to give feedback
this.showControls();
this.buttonsRef.current?.showControls();
handled = true;
}
break;
@ -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 => {
const userFacingRoomId = CallHandler.sharedInstance().roomIdForCall(this.props.call);
CallHandler.sharedInstance().setActiveCallRoomId(userFacingRoomId);
@ -402,206 +330,60 @@ export default class CallView extends React.Component<IProps, IState> {
};
private onToggleSidebar = (): void => {
this.setState({
sidebarShown: !this.state.sidebarShown,
});
this.setState({ sidebarShown: !this.state.sidebarShown });
};
private renderCallControls(): JSX.Element {
const micClasses = classNames({
mx_CallView_callControls_button: true,
mx_CallView_callControls_button_micOn: !this.state.micMuted,
mx_CallView_callControls_button_micOff: this.state.micMuted,
});
const vidClasses = classNames({
mx_CallView_callControls_button: true,
mx_CallView_callControls_button_vidOn: !this.state.vidMuted,
mx_CallView_callControls_button_vidOff: this.state.vidMuted,
});
const screensharingClasses = classNames({
mx_CallView_callControls_button: true,
mx_CallView_callControls_button_screensharingOn: this.state.screensharing,
mx_CallView_callControls_button_screensharingOff: !this.state.screensharing,
});
const sidebarButtonClasses = classNames({
mx_CallView_callControls_button: true,
mx_CallView_callControls_button_sidebarOn: this.state.sidebarShown,
mx_CallView_callControls_button_sidebarOff: !this.state.sidebarShown,
});
// Put the other states of the mic/video icons in the document to make sure they're cached
// (otherwise the icon disappears briefly when toggled)
const micCacheClasses = classNames({
mx_CallView_callControls_button: true,
mx_CallView_callControls_button_micOn: this.state.micMuted,
mx_CallView_callControls_button_micOff: !this.state.micMuted,
mx_CallView_callControls_button_invisible: true,
});
const vidCacheClasses = classNames({
mx_CallView_callControls_button: true,
mx_CallView_callControls_button_vidOn: this.state.micMuted,
mx_CallView_callControls_button_vidOff: !this.state.micMuted,
mx_CallView_callControls_button_invisible: true,
});
const callControlsClasses = classNames({
mx_CallView_callControls: true,
mx_CallView_callControls_hidden: !this.state.controlsVisible,
});
// We don't support call upgrades (yet) so hide the video mute button in voice calls
let vidMuteButton;
if (this.props.call.type === CallType.Video) {
vidMuteButton = (
<AccessibleTooltipButton
className={vidClasses}
onClick={this.onVidMuteClick}
title={this.state.vidMuted ? _t("Start the camera") : _t("Stop the camera")}
alignment={Alignment.Top}
yOffset={tooltipYOffset}
/>
);
}
const vidMuteButtonShown = this.props.call.type === CallType.Video;
// Screensharing is possible, if we can send a second stream and
// identify it using SDPStreamMetadata or if we can replace the already
// existing usermedia track by a screensharing track. We also need to be
// connected to know the state of the other side
let screensharingButton;
if (
const screensharingButtonShown = (
(this.props.call.opponentSupportsSDPStreamMetadata() || this.props.call.type === CallType.Video) &&
this.props.call.state === CallState.Connected
) {
screensharingButton = (
<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,
// we can hide this button. If we are in PiP, sidebar is also hidden, so
// we can hide the button too
let sidebarButton;
if (
!this.props.pipMode &&
(
const sidebarButtonShown = (
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
let contextMenuButton;
if (this.state.callState === CallState.Connected) {
contextMenuButton = (
<ContextMenuTooltipButton
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}
/>
const contextMenuButtonShown = this.state.callState === CallState.Connected;
const dialpadButtonShown = (
this.state.callState === CallState.Connected &&
this.props.call.opponentSupportsDTMF()
);
}
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 (
<div
className={callControlsClasses}
onMouseEnter={this.onCallControlsMouseEnter}
onMouseLeave={this.onCallControlsMouseLeave}
>
{ dialPad }
{ contextMenu }
{ dialpadButton }
<AccessibleTooltipButton
className={micClasses}
onClick={this.onMicMuteClick}
title={this.state.micMuted ? _t("Unmute the microphone") : _t("Mute the microphone")}
alignment={Alignment.Top}
yOffset={tooltipYOffset}
<CallViewButtons
ref={this.buttonsRef}
call={this.props.call}
pipMode={this.props.pipMode}
handlers={{
onToggleSidebarClick: this.onToggleSidebar,
onScreenshareClick: this.onScreenshareClick,
onHangupClick: this.onHangupClick,
onMicMuteClick: this.onMicMuteClick,
onVidMuteClick: this.onVidMuteClick,
}}
buttonsState={{
micMuted: this.state.micMuted,
vidMuted: this.state.vidMuted,
sidebarShown: this.state.sidebarShown,
screensharing: this.state.screensharing,
}}
buttonsVisibility={{
vidMute: vidMuteButtonShown,
screensharing: screensharingButtonShown,
sidebar: sidebarButtonShown,
contextMenu: contextMenuButtonShown,
dialpad: dialpadButtonShown,
}}
/>
{ vidMuteButton }
<div className={micCacheClasses} />
<div className={vidCacheClasses} />
{ screensharingButton }
{ sidebarButton }
{ contextMenuButton }
<AccessibleTooltipButton
className="mx_CallView_callControls_button mx_CallView_callControls_button_hangup"
onClick={this.onHangupClick}
title={_t("Hangup")}
alignment={Alignment.Top}
yOffset={tooltipYOffset}
/>
</div>
);
}

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

View file

@ -43,7 +43,7 @@ export default class AutocompleteWrapperModel {
) {
}
public onEscape(e: KeyboardEvent) {
public onEscape(e: KeyboardEvent): void {
this.getAutocompleterComponent().onEscape(e);
this.updateCallback({
replaceParts: [this.partCreator.plain(this.queryPart.text)],
@ -51,27 +51,27 @@ export default class AutocompleteWrapperModel {
});
}
public close() {
public close(): void {
this.updateCallback({ close: true });
}
public hasSelection() {
public hasSelection(): boolean {
return this.getAutocompleterComponent().hasSelection();
}
public hasCompletions() {
public hasCompletions(): boolean {
const ac = this.getAutocompleterComponent();
return ac && ac.countCompletions() > 0;
}
public onEnter() {
public onEnter(): void {
this.updateCallback({ close: true });
}
/**
* If there is no current autocompletion, start one and move to the first selection.
*/
public async startSelection() {
public async startSelection(): Promise<void> {
const acComponent = this.getAutocompleterComponent();
if (acComponent.countCompletions() === 0) {
// Force completions to show for the text currently entered
@ -81,15 +81,15 @@ export default class AutocompleteWrapperModel {
}
}
public selectPreviousSelection() {
public selectPreviousSelection(): void {
this.getAutocompleterComponent().moveSelection(-1);
}
public selectNextSelection() {
public selectNextSelection(): void {
this.getAutocompleterComponent().moveSelection(+1);
}
public onPartUpdate(part: Part, pos: DocumentPosition) {
public onPartUpdate(part: Part, pos: DocumentPosition): Promise<void> {
// cache the typed value and caret here
// so we can restore it in onComponentSelectionChange when the value is undefined (meaning it should be the typed text)
this.queryPart = part;
@ -97,7 +97,7 @@ export default class AutocompleteWrapperModel {
return this.updateQuery(part.text);
}
public onComponentSelectionChange(completion: ICompletion) {
public onComponentSelectionChange(completion: ICompletion): void {
if (!completion) {
this.updateCallback({
replaceParts: [this.queryPart],
@ -109,14 +109,14 @@ export default class AutocompleteWrapperModel {
}
}
public onComponentConfirm(completion: ICompletion) {
public onComponentConfirm(completion: ICompletion): void {
this.updateCallback({
replaceParts: this.partForCompletion(completion),
close: true,
});
}
private partForCompletion(completion: ICompletion) {
private partForCompletion(completion: ICompletion): Part[] {
const { completionId } = completion;
const text = completion.completion;
switch (completion.type) {

View file

@ -19,7 +19,7 @@ import { needsCaretNodeBefore, needsCaretNodeAfter } from "./render";
import Range from "./range";
import EditorModel from "./model";
import DocumentPosition, { IPosition } from "./position";
import { Part } from "./parts";
import { Part, Type } from "./parts";
export type Caret = Range | DocumentPosition;
@ -113,7 +113,7 @@ function findNodeInLineForPart(parts: Part[], partIndex: number) {
// to find newline parts
for (let i = 0; i <= partIndex; ++i) {
const part = parts[i];
if (part.type === "newline") {
if (part.type === Type.Newline) {
lineIndex += 1;
nodeIndex = -1;
prevPart = null;
@ -128,7 +128,7 @@ function findNodeInLineForPart(parts: Part[], partIndex: number) {
// and not an adjacent caret node
if (i < partIndex) {
const nextPart = parts[i + 1];
const isLastOfLine = !nextPart || nextPart.type === "newline";
const isLastOfLine = !nextPart || nextPart.type === Type.Newline;
if (needsCaretNodeAfter(part, isLastOfLine)) {
nodeIndex += 1;
}

View file

@ -20,7 +20,7 @@ import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { walkDOMDepthFirst } from "./dom";
import { checkBlockNode } from "../HtmlUtils";
import { getPrimaryPermalinkEntity } from "../utils/permalinks/Permalinks";
import { PartCreator } from "./parts";
import { PartCreator, Type } from "./parts";
import SdkConfig from "../SdkConfig";
function parseAtRoomMentions(text: string, partCreator: PartCreator) {
@ -206,7 +206,7 @@ function prefixQuoteLines(isFirstNode, parts, partCreator) {
parts.splice(0, 0, partCreator.plain(QUOTE_LINE_PREFIX));
}
for (let i = 0; i < parts.length; i += 1) {
if (parts[i].type === "newline") {
if (parts[i].type === Type.Newline) {
parts.splice(i + 1, 0, partCreator.plain(QUOTE_LINE_PREFIX));
i += 1;
}

View file

@ -21,7 +21,7 @@ export interface IDiff {
at?: number;
}
function firstDiff(a: string, b: string) {
function firstDiff(a: string, b: string): number {
const compareLen = Math.min(a.length, b.length);
for (let i = 0; i < compareLen; ++i) {
if (a[i] !== b[i]) {

View file

@ -36,7 +36,7 @@ export default class HistoryManager {
private addedSinceLastPush = false;
private removedSinceLastPush = false;
clear() {
public clear(): void {
this.stack = [];
this.newlyTypedCharCount = 0;
this.currentIndex = -1;
@ -103,7 +103,7 @@ export default class HistoryManager {
}
// 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.
// these respect the inputType values of the input event,
// but are actually passed in from MessageEditor calling model.reset()
@ -121,22 +121,22 @@ export default class HistoryManager {
return shouldPush;
}
ensureLastChangesPushed(model: EditorModel) {
public ensureLastChangesPushed(model: EditorModel): void {
if (this.changedSinceLastPush) {
this.pushState(model, this.lastCaret);
}
}
canUndo() {
public canUndo(): boolean {
return this.currentIndex >= 1 || this.changedSinceLastPush;
}
canRedo() {
public canRedo(): boolean {
return this.currentIndex < (this.stack.length - 1);
}
// returns state that should be applied to model
undo(model: EditorModel) {
public undo(model: EditorModel): IHistory {
if (this.canUndo()) {
this.ensureLastChangesPushed(model);
this.currentIndex -= 1;
@ -145,7 +145,7 @@ export default class HistoryManager {
}
// returns state that should be applied to model
redo() {
public redo(): IHistory {
if (this.canRedo()) {
this.changedSinceLastPush = false;
this.currentIndex += 1;

View file

@ -15,16 +15,17 @@ limitations under the License.
*/
import EditorModel from "./model";
import DocumentPosition from "./position";
export default class DocumentOffset {
constructor(public offset: number, public readonly atNodeEnd: boolean) {
}
asPosition(model: EditorModel) {
public asPosition(model: EditorModel): DocumentPosition {
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);
}
}

View file

@ -15,13 +15,13 @@ limitations under the License.
*/
import Range from "./range";
import { Part } from "./parts";
import { Part, Type } from "./parts";
/**
* 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;
model.transform(() => {
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;
model.transform(() => {
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 startsWithPartial = range.start.offset !== 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);
}
export function rangeEndsAtEndOfLine(range: Range) {
export function rangeEndsAtEndOfLine(range: Range): boolean {
const { model } = range;
const lastPart = model.parts[range.end.index];
const endsWithPartial = range.end.offset !== lastPart.text.length;
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);
}
export function formatRangeAsQuote(range: Range) {
export function formatRangeAsQuote(range: Range): void {
const { model, parts } = range;
const { partCreator } = model;
for (let i = 0; i < parts.length; ++i) {
const part = parts[i];
if (part.type === "newline") {
if (part.type === Type.Newline) {
parts.splice(i + 1, 0, partCreator.plain("> "));
}
}
@ -81,10 +81,10 @@ export function formatRangeAsQuote(range: Range) {
replaceRangeAndExpandSelection(range, parts);
}
export function formatRangeAsCode(range: Range) {
export function formatRangeAsCode(range: Range): void {
const { model, parts } = range;
const { partCreator } = model;
const needsBlock = parts.some(p => p.type === "newline");
const needsBlock = parts.some(p => p.type === Type.Newline);
if (needsBlock) {
parts.unshift(partCreator.plain("```"), partCreator.newline());
if (!rangeStartsAtBeginningOfLine(range)) {
@ -105,9 +105,9 @@ export function formatRangeAsCode(range: Range) {
// parts helper methods
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 { partCreator } = model;

View file

@ -25,6 +25,8 @@ import AutocompleteWrapperModel, {
UpdateQuery,
} from "./autocomplete";
import * as Avatar from "../Avatar";
import defaultDispatcher from "../dispatcher/dispatcher";
import { Action } from "../dispatcher/actions";
interface ISerializedPart {
type: Type.Plain | Type.Newline | Type.Command | Type.PillCandidate;
@ -39,7 +41,7 @@ interface ISerializedPillPart {
export type SerializedPart = ISerializedPart | ISerializedPillPart;
enum Type {
export enum Type {
Plain = "plain",
Newline = "newline",
Command = "command",
@ -57,12 +59,12 @@ interface IBasePart {
createAutoComplete(updateCallback: UpdateCallback): void;
serialize(): SerializedPart;
remove(offset: number, len: number): string;
remove(offset: number, len: number): string | undefined;
split(offset: number): IBasePart;
validateAndInsert(offset: number, str: string, inputType: string): boolean;
appendUntilRejected(str: string, inputType: string): string;
updateDOMNode(node: Node);
canUpdateDOMNode(node: Node);
appendUntilRejected(str: string, inputType: string): string | undefined;
updateDOMNode(node: Node): void;
canUpdateDOMNode(node: Node): boolean;
toDOMNode(): Node;
}
@ -85,19 +87,19 @@ abstract class BasePart {
this._text = text;
}
acceptsInsertion(chr: string, offset: number, inputType: string) {
protected acceptsInsertion(chr: string, offset: number, inputType: string): boolean {
return true;
}
acceptsRemoval(position: number, chr: string) {
protected acceptsRemoval(position: number, chr: string): boolean {
return true;
}
merge(part: Part) {
public merge(part: Part): boolean {
return false;
}
split(offset: number) {
public split(offset: number): IBasePart {
const splitText = this.text.substr(offset);
this._text = this.text.substr(0, offset);
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
// if the part would become invalid if it removed everything.
remove(offset: number, len: number) {
public remove(offset: number, len: number): string | undefined {
// validate
const strWithRemoval = this.text.substr(0, offset) + this.text.substr(offset + len);
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.
appendUntilRejected(str: string, inputType: string) {
public appendUntilRejected(str: string, inputType: string): string | undefined {
const offset = this.text.length;
for (let i = 0; i < str.length; ++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
// 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) {
const chr = str.charAt(i);
if (!this.acceptsInsertion(chr, offset + i, inputType)) {
@ -145,42 +147,42 @@ abstract class BasePart {
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);
this._text = this._text.substr(0, len);
return remaining;
}
get text() {
public get text(): string {
return this._text;
}
abstract get type(): Type;
public abstract get type(): Type;
get canEdit() {
public get canEdit(): boolean {
return true;
}
toString() {
public toString(): string {
return `${this.type}(${this.text})`;
}
serialize(): SerializedPart {
public serialize(): SerializedPart {
return {
type: this.type as ISerializedPart["type"],
text: this.text,
};
}
abstract updateDOMNode(node: Node);
abstract canUpdateDOMNode(node: Node);
abstract toDOMNode(): Node;
public abstract updateDOMNode(node: Node): void;
public abstract canUpdateDOMNode(node: Node): boolean;
public abstract toDOMNode(): Node;
}
abstract class PlainBasePart extends BasePart {
acceptsInsertion(chr: string, offset: number, inputType: string) {
protected acceptsInsertion(chr: string, offset: number, inputType: string): boolean {
if (chr === "\n") {
return false;
}
@ -203,11 +205,11 @@ abstract class PlainBasePart extends BasePart {
return true;
}
toDOMNode() {
public toDOMNode(): Node {
return document.createTextNode(this.text);
}
merge(part) {
public merge(part): boolean {
if (part.type === this.type) {
this._text = this.text + part.text;
return true;
@ -215,47 +217,49 @@ abstract class PlainBasePart extends BasePart {
return false;
}
updateDOMNode(node: Node) {
public updateDOMNode(node: Node): void {
if (node.textContent !== this.text) {
node.textContent = this.text;
}
}
canUpdateDOMNode(node: Node) {
public canUpdateDOMNode(node: Node): boolean {
return node.nodeType === Node.TEXT_NODE;
}
}
// exported for unit tests, should otherwise only be used through PartCreator
export class PlainPart extends PlainBasePart implements IBasePart {
get type(): IBasePart["type"] {
public get type(): IBasePart["type"] {
return Type.Plain;
}
}
abstract class PillPart extends BasePart implements IPillPart {
export abstract class PillPart extends BasePart implements IPillPart {
constructor(public resourceId: string, label) {
super(label);
}
acceptsInsertion(chr: string) {
protected acceptsInsertion(chr: string): boolean {
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
}
toDOMNode() {
public toDOMNode(): Node {
const container = document.createElement("span");
container.setAttribute("spellcheck", "false");
container.setAttribute("contentEditable", "false");
container.onclick = this.onClick;
container.className = this.className;
container.appendChild(document.createTextNode(this.text));
this.setAvatar(container);
return container;
}
updateDOMNode(node: HTMLElement) {
public updateDOMNode(node: HTMLElement): void {
const textNode = node.childNodes[0];
if (textNode.textContent !== this.text) {
textNode.textContent = this.text;
@ -263,10 +267,13 @@ abstract class PillPart extends BasePart implements IPillPart {
if (node.className !== this.className) {
node.className = this.className;
}
if (node.onclick !== this.onClick) {
node.onclick = this.onClick;
}
this.setAvatar(node);
}
canUpdateDOMNode(node: HTMLElement) {
public canUpdateDOMNode(node: HTMLElement): boolean {
return node.nodeType === Node.ELEMENT_NODE &&
node.nodeName === "SPAN" &&
node.childNodes.length === 1 &&
@ -274,7 +281,7 @@ abstract class PillPart extends BasePart implements IPillPart {
}
// 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 avatarLetter = `'${initialLetter}'`;
// check if the value is changing,
@ -287,7 +294,7 @@ abstract class PillPart extends BasePart implements IPillPart {
}
}
serialize(): ISerializedPillPart {
public serialize(): ISerializedPillPart {
return {
type: this.type,
text: this.text,
@ -295,41 +302,43 @@ abstract class PillPart extends BasePart implements IPillPart {
};
}
get canEdit() {
public get canEdit(): boolean {
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 {
acceptsInsertion(chr: string, offset: number) {
protected acceptsInsertion(chr: string, offset: number): boolean {
return offset === 0 && chr === "\n";
}
acceptsRemoval(position: number, chr: string) {
protected acceptsRemoval(position: number, chr: string): boolean {
return true;
}
toDOMNode() {
public toDOMNode(): Node {
return document.createElement("br");
}
merge() {
public merge(): boolean {
return false;
}
updateDOMNode() {}
public updateDOMNode(): void {}
canUpdateDOMNode(node: HTMLElement) {
public canUpdateDOMNode(node: HTMLElement): boolean {
return node.tagName === "BR";
}
get type(): IBasePart["type"] {
public get type(): IBasePart["type"] {
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.
// As a newline can also be only one character, it makes sense
// as it can only be one character long. This caused #9741.
get canEdit() {
public get canEdit(): boolean {
return false;
}
}
@ -347,7 +356,7 @@ class RoomPillPart extends PillPart {
super(resourceId, label);
}
setAvatar(node: HTMLElement) {
protected setAvatar(node: HTMLElement): void {
let initialLetter = "";
let avatarUrl = Avatar.avatarUrlForRoom(this.room, 16, 16, "crop");
if (!avatarUrl) {
@ -357,11 +366,11 @@ class RoomPillPart extends PillPart {
this.setAvatarVars(node, avatarUrl, initialLetter);
}
get type(): IPillPart["type"] {
public get type(): IPillPart["type"] {
return Type.RoomPill;
}
get className() {
protected get className() {
return "mx_RoomPill mx_Pill";
}
}
@ -371,11 +380,11 @@ class AtRoomPillPart extends RoomPillPart {
super(text, text, room);
}
get type(): IPillPart["type"] {
public get type(): IPillPart["type"] {
return Type.AtRoomPill;
}
serialize(): ISerializedPillPart {
public serialize(): ISerializedPillPart {
return {
type: this.type,
text: this.text,
@ -388,7 +397,15 @@ class UserPillPart extends PillPart {
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) {
return;
}
@ -402,13 +419,12 @@ class UserPillPart extends PillPart {
this.setAvatarVars(node, avatarUrl, initialLetter);
}
get type(): IPillPart["type"] {
return Type.UserPill;
}
get className() {
return "mx_UserPill mx_Pill";
}
protected onClick = (): void => {
defaultDispatcher.dispatch({
action: Action.ViewUser,
member: this.member,
});
};
}
class PillCandidatePart extends PlainBasePart implements IPillCandidatePart {
@ -416,11 +432,11 @@ class PillCandidatePart extends PlainBasePart implements IPillCandidatePart {
super(text);
}
createAutoComplete(updateCallback: UpdateCallback): AutocompleteWrapperModel {
public createAutoComplete(updateCallback: UpdateCallback): AutocompleteWrapperModel {
return this.autoCompleteCreator.create(updateCallback);
}
acceptsInsertion(chr: string, offset: number, inputType: string) {
protected acceptsInsertion(chr: string, offset: number, inputType: string): boolean {
if (offset === 0) {
return true;
} else {
@ -428,11 +444,11 @@ class PillCandidatePart extends PlainBasePart implements IPillCandidatePart {
}
}
merge() {
public merge(): boolean {
return false;
}
acceptsRemoval(position: number, chr: string) {
protected acceptsRemoval(position: number, chr: string): boolean {
return true;
}
@ -463,17 +479,21 @@ interface IAutocompleteCreator {
export class PartCreator {
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
// 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);
}
createPartForInput(input: string, partIndex: number, inputType?: string): Part {
public createPartForInput(input: string, partIndex: number, inputType?: string): Part {
switch (input[0]) {
case "#":
case "@":
@ -487,11 +507,11 @@ export class PartCreator {
}
}
createDefaultPart(text: string) {
public createDefaultPart(text: string): Part {
return this.plain(text);
}
deserializePart(part: SerializedPart): Part {
public deserializePart(part: SerializedPart): Part {
switch (part.type) {
case Type.Plain:
return this.plain(part.text);
@ -508,19 +528,19 @@ export class PartCreator {
}
}
plain(text: string) {
public plain(text: string): PlainPart {
return new PlainPart(text);
}
newline() {
public newline(): NewlinePart {
return new NewlinePart("\n");
}
pillCandidate(text: string) {
public pillCandidate(text: string): PillCandidatePart {
return new PillCandidatePart(text, this.autoCompleteCreator);
}
roomPill(alias: string, roomId?: string) {
public roomPill(alias: string, roomId?: string): RoomPillPart {
let room;
if (roomId || alias[0] !== "#") {
room = this.client.getRoom(roomId || alias);
@ -533,16 +553,20 @@ export class PartCreator {
return new RoomPillPart(alias, room ? room.name : alias, room);
}
atRoomPill(text: string) {
public atRoomPill(text: string): AtRoomPillPart {
return new AtRoomPillPart(text, this.room);
}
userPill(displayName: string, userId: string) {
public userPill(displayName: string, userId: string): UserPillPart {
const member = this.room.getMember(userId);
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 postfix = this.plain(insertTrailingCharacter ? ": " : " ");
return [pill, postfix];
@ -567,7 +591,7 @@ export class CommandPartCreator extends PartCreator {
}
public deserializePart(part: SerializedPart): Part {
if (part.type === "command") {
if (part.type === Type.Command) {
return this.command(part.text);
} else {
return super.deserializePart(part);

View file

@ -30,7 +30,7 @@ export default class DocumentPosition implements IPosition {
constructor(public readonly index: number, public readonly offset: number) {
}
compare(otherPos: DocumentPosition) {
public compare(otherPos: DocumentPosition): number {
if (this.index === otherPos.index) {
return this.offset - otherPos.offset;
} 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) {
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) {
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) {
return this;
}
@ -107,7 +107,7 @@ export default class DocumentPosition implements IPosition {
}
}
asOffset(model: EditorModel) {
public asOffset(model: EditorModel): DocumentOffset {
if (this.index === -1) {
return new DocumentOffset(0, true);
}
@ -121,7 +121,7 @@ export default class DocumentPosition implements IPosition {
return new DocumentOffset(offset, atEnd);
}
isAtEnd(model: EditorModel) {
public isAtEnd(model: EditorModel): boolean {
if (model.parts.length === 0) {
return true;
}
@ -130,7 +130,7 @@ export default class DocumentPosition implements IPosition {
return this.index === lastPartIdx && this.offset === lastPart.text.length;
}
isAtStart() {
public isAtStart(): boolean {
return this.index === 0 && this.offset === 0;
}
}

View file

@ -32,23 +32,23 @@ export default class Range {
this._end = bIsLarger ? positionB : positionA;
}
moveStart(delta: number) {
public moveStart(delta: number): void {
this._start = this._start.forwardsWhile(this.model, () => {
delta -= 1;
return delta >= 0;
});
}
trim() {
public trim(): void {
this._start = this._start.forwardsWhile(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);
}
get text() {
public get text(): string {
let text = "";
this._start.iteratePartsBetween(this._end, this.model, (part, 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
* @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);
let oldLength = 0;
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.
* For partial parts, only the text is adjusted to the part that intersects with the range.
*/
get parts() {
const parts = [];
public get parts(): Part[] {
const parts: Part[] = [];
this._start.iteratePartsBetween(this._end, this.model, (part, startIdx, endIdx) => {
const serializedPart = part.serialize();
serializedPart.text = part.text.substring(startIdx, endIdx);
@ -88,7 +88,7 @@ export default class Range {
return parts;
}
get length() {
public get length(): number {
let len = 0;
this._start.iteratePartsBetween(this._end, this.model, (part, startIdx, endIdx) => {
len += endIdx - startIdx;
@ -96,11 +96,11 @@ export default class Range {
return len;
}
get start() {
public get start(): DocumentPosition {
return this._start;
}
get end() {
public get end(): DocumentPosition {
return this._end;
}
}

View file

@ -15,19 +15,19 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { Part } from "./parts";
import { Part, Type } from "./parts";
import EditorModel from "./model";
export function needsCaretNodeBefore(part: Part, prevPart: Part) {
const isFirst = !prevPart || prevPart.type === "newline";
export function needsCaretNodeBefore(part: Part, prevPart: Part): boolean {
const isFirst = !prevPart || prevPart.type === Type.Newline;
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;
}
function insertAfter(node: HTMLElement, nodeToInsert: HTMLElement) {
function insertAfter(node: HTMLElement, nodeToInsert: HTMLElement): void {
const next = node.nextSibling;
if (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
// where otherwise it wouldn't be possible
// (e.g. next to a pill span without adjacent text node)
function createCaretNode() {
function createCaretNode(): HTMLElement {
const span = document.createElement("span");
span.className = "caretNode";
span.appendChild(document.createTextNode(CARET_NODE_CHAR));
return span;
}
function updateCaretNode(node: HTMLElement) {
function updateCaretNode(node: HTMLElement): void {
// ensure the caret node contains only a zero-width space
if (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";
}
function removeNextSiblings(node: ChildNode) {
function removeNextSiblings(node: ChildNode): void {
if (!node) {
return;
}
@ -74,7 +74,7 @@ function removeNextSiblings(node: ChildNode) {
}
}
function removeChildren(parent: HTMLElement) {
function removeChildren(parent: HTMLElement): void {
const firstChild = parent.firstChild;
if (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 prevPart;
const lastPart = parts[parts.length - 1];
@ -131,13 +131,13 @@ function reconcileLine(lineContainer: ChildNode, parts: Part[]) {
removeNextSiblings(currentNode);
}
function reconcileEmptyLine(lineContainer) {
function reconcileEmptyLine(lineContainer: HTMLElement): void {
// empty div needs to have a BR in it to give it height
let foundBR = false;
let partNode = lineContainer.firstChild;
while (partNode) {
const nextNode = partNode.nextSibling;
if (!foundBR && partNode.tagName === "BR") {
if (!foundBR && (partNode as HTMLElement).tagName === "BR") {
foundBR = true;
} else {
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) => {
if (part.type === "newline") {
if (part.type === Type.Newline) {
linesArr.push([]);
} else {
const lastLine = linesArr[linesArr.length - 1];
@ -175,7 +175,7 @@ export function renderModel(editor: HTMLDivElement, model: EditorModel) {
if (parts.length) {
reconcileLine(lineContainer, parts);
} else {
reconcileEmptyLine(lineContainer);
reconcileEmptyLine(lineContainer as HTMLElement);
}
});
if (lines.length) {

View file

@ -22,30 +22,31 @@ import { AllHtmlEntities } from 'html-entities';
import SettingsStore from '../settings/SettingsStore';
import SdkConfig from '../SdkConfig';
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) => {
switch (part.type) {
case "newline":
case Type.Newline:
return html + "\n";
case "plain":
case "command":
case "pill-candidate":
case "at-room-pill":
case Type.Plain:
case Type.Command:
case Type.PillCandidate:
case Type.AtRoomPill:
return html + part.text;
case "room-pill":
case Type.RoomPill:
// Here we use the resourceId for compatibility with non-rich text clients
// See https://github.com/vector-im/element-web/issues/16660
return html +
`[${part.resourceId.replace(/[[\\\]]/g, c => "\\" + c)}](${makeGenericPermalink(part.resourceId)})`;
case "user-pill":
case Type.UserPill:
return html +
`[${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);
// copy of raw input to remove unwanted math later
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) => {
switch (part.type) {
case "newline":
case Type.Newline:
return text + "\n";
case "plain":
case "command":
case "pill-candidate":
case "at-room-pill":
case Type.Plain:
case Type.Command:
case Type.PillCandidate:
case Type.AtRoomPill:
return text + part.text;
case "room-pill":
case Type.RoomPill:
// Here we use the resourceId for compatibility with non-rich text clients
// See https://github.com/vector-im/element-web/issues/16660
return text + `${part.resourceId}`;
case "user-pill":
case Type.UserPill:
return text + `${part.text}`;
}
}, "");
}
export function containsEmote(model: EditorModel) {
export function containsEmote(model: EditorModel): boolean {
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];
// part type will be "plain" while editing,
// and "command" while composing a message.
@ -190,26 +191,26 @@ export function startsWith(model: EditorModel, prefix: string, caseSensitive = t
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 "
return stripPrefix(model, "/me ");
}
export function stripPrefix(model: EditorModel, prefix: string) {
export function stripPrefix(model: EditorModel, prefix: string): EditorModel {
model = model.clone();
model.removeText({ index: 0, offset: 0 }, prefix.length);
return model;
}
export function unescapeMessage(model: EditorModel) {
export function unescapeMessage(model: EditorModel): EditorModel {
const { parts } = model;
if (parts.length) {
const firstPart = parts[0];
// 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.removeText({ index: 0, offset: 0 }, 1);
}

View file

@ -905,6 +905,16 @@
"sends snowfall": "sends snowfall",
"Sends the given message with a space themed effect": "Sends the given message with a space themed effect",
"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",
"Stop the camera": "Stop the camera",
"Stop sharing your screen": "Stop sharing your screen",
@ -916,16 +926,6 @@
"Unmute the microphone": "Unmute the microphone",
"Mute the microphone": "Mute the microphone",
"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",
"Voice Call": "Voice Call",
"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>.",
"Decline (%(counter)s)": "Decline (%(counter)s)",
"Accept <policyLink /> to continue:": "Accept <policyLink /> to continue:",
"Delete avatar": "Delete avatar",
"Delete": "Delete",
"Upload avatar": "Upload avatar",
"Upload": "Upload",
"Name": "Name",
"Description": "Description",
@ -1073,6 +1075,8 @@
"Preview Space": "Preview Space",
"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.",
"Jump to first unread room.": "Jump to first unread room.",
"Jump to first invite.": "Jump to first invite.",
"Expand": "Expand",
"Collapse": "Collapse",
"Space options": "Space options",
@ -1667,8 +1671,6 @@
"Activity": "Activity",
"A-Z": "A-Z",
"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|one": "Show %(count)s more",
"Show less": "Show less",
@ -2721,7 +2723,6 @@
"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!",
"Long Description (HTML)": "Long Description (HTML)",
"Upload avatar": "Upload avatar",
"Community %(groupId)s not found": "Community %(groupId)s not found",
"This homeserver does not support communities": "This homeserver does not support communities",
"Failed to load %(groupId)s": "Failed to load %(groupId)s",
@ -2831,6 +2832,7 @@
"Mark as suggested": "Mark as suggested",
"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.",
"Space": "Space",
"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>.",
"Create room": "Create room",
@ -3112,7 +3114,6 @@
"Page Down": "Page Down",
"Esc": "Esc",
"Enter": "Enter",
"Space": "Space",
"End": "End",
"[number]": "[number]"
}