Improve design of the rich text editor (#9533)

New design for rich text composer
This commit is contained in:
Florian Duros 2022-11-04 16:36:50 +01:00 committed by GitHub
parent 9101b42de8
commit 5ca9accce2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
31 changed files with 668 additions and 270 deletions

View file

@ -260,6 +260,7 @@
@import "./views/rooms/_AuxPanel.pcss"; @import "./views/rooms/_AuxPanel.pcss";
@import "./views/rooms/_BasicMessageComposer.pcss"; @import "./views/rooms/_BasicMessageComposer.pcss";
@import "./views/rooms/_E2EIcon.pcss"; @import "./views/rooms/_E2EIcon.pcss";
@import "./views/rooms/_EmojiButton.pcss";
@import "./views/rooms/_EditMessageComposer.pcss"; @import "./views/rooms/_EditMessageComposer.pcss";
@import "./views/rooms/_EntityTile.pcss"; @import "./views/rooms/_EntityTile.pcss";
@import "./views/rooms/_EventBubbleTile.pcss"; @import "./views/rooms/_EventBubbleTile.pcss";

View file

@ -0,0 +1,35 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
@import "./_MessageComposerButton.pcss";
.mx_EmojiButton {
@mixin composerButton 50%,$accent;
}
.mx_EmojiButton_highlight {
@mixin composerButtonHighLight;
}
.mx_EmojiButton_icon::before {
mask-image: url('$(res)/img/element-icons/room/composer/emoji.svg');
}
.mx_MessageComposer_wysiwyg {
.mx_EmojiButton {
@mixin composerButton 5px,$tertiary-content;
}
}

View file

@ -15,6 +15,8 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
@import "./_MessageComposerButton.pcss";
.mx_MessageComposer_wrapper { .mx_MessageComposer_wrapper {
vertical-align: middle; vertical-align: middle;
margin: auto; margin: auto;
@ -59,6 +61,12 @@ limitations under the License.
width: 100%; width: 100%;
} }
.mx_MessageComposer_actions {
display: flex;
align-items: center;
gap: 6px;
}
.mx_MessageComposer .mx_MessageComposer_avatar { .mx_MessageComposer .mx_MessageComposer_avatar {
position: absolute; position: absolute;
left: 26px; left: 26px;
@ -171,53 +179,16 @@ limitations under the License.
} }
.mx_MessageComposer_button_highlight { .mx_MessageComposer_button_highlight {
background: rgba($accent, 0.25); @mixin composerButtonHighLight;
/* make the icon the accent color too */
&::before {
background-color: $accent !important;
}
} }
.mx_MessageComposer_button { .mx_MessageComposer_button {
--size: 26px; @mixin composerButton 50%,$accent;
position: relative;
cursor: pointer;
height: var(--size);
line-height: var(--size);
width: auto;
padding-left: var(--size);
border-radius: 50%;
margin-right: 6px;
&:last-child { &:last-child {
margin-right: auto; margin-right: auto;
} }
&::before {
content: '';
position: absolute;
top: 3px;
left: 3px;
height: 20px;
width: 20px;
background-color: $icon-button-color;
mask-repeat: no-repeat;
mask-size: contain;
mask-position: center;
}
&::after {
content: '';
position: absolute;
left: 0;
top: 0;
z-index: 0;
width: var(--size);
height: var(--size);
border-radius: 50%;
}
&:hover,
&.mx_MessageComposer_closeButtonMenu { &.mx_MessageComposer_closeButtonMenu {
&::after { &::after {
background: rgba($accent, 0.1); background: rgba($accent, 0.1);
@ -232,15 +203,43 @@ limitations under the License.
background-color: $alert; background-color: $alert;
} }
} }
/*
The wysisyg composer increase the size of the MessageComposer. We temporary move the buttons
Soon the dom structure of the MessageComposer will change with the next evolution of the wysiwyg composer
and this workaround will disappear
*/
.mx_MessageComposer_wysiwyg { .mx_MessageComposer_wysiwyg {
.mx_MessageComposer_e2eIcon.mx_E2EIcon,.mx_MessageComposer_button, .mx_MessageComposer_sendMessage { .mx_MessageComposer_wrapper {
margin-top: 28px; padding-left: 16px;
margin-top: 6px;
margin-bottom: 12px;
}
.mx_MessageComposer_row {
align-items: flex-end;
}
.mx_MessageComposer_actions {
/* Height of the composer editor */
height: 40px;
}
.mx_MediaBody {
padding-top: 4px;
padding-bottom: 4px;
}
.mx_MessageComposer_button {
@mixin composerButton 5px,$tertiary-content;
&.mx_MessageComposer_closeButtonMenu {
&::after {
background: rgba($accent, 0.1);
}
&::before {
background-color: $accent;
}
}
&.mx_MessageComposer_hangup:not(.mx_AccessibleButton_disabled)::before {
background-color: $alert;
}
} }
} }
@ -260,10 +259,6 @@ limitations under the License.
mask-image: url('$(res)/img/element-icons/live.svg'); mask-image: url('$(res)/img/element-icons/live.svg');
} }
.mx_MessageComposer_emoji::before {
mask-image: url('$(res)/img/element-icons/room/composer/emoji.svg');
}
.mx_MessageComposer_plain_text::before { .mx_MessageComposer_plain_text::before {
mask-image: url('$(res)/img/element-icons/room/composer/plain_text.svg'); mask-image: url('$(res)/img/element-icons/room/composer/plain_text.svg');
} }

View file

@ -0,0 +1,68 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
@define-mixin composerButtonHighLight {
background: rgba($accent, 0.25);
/* make the icon the accent color too */
&::before {
background-color: $accent !important;
}
}
@define-mixin composerButton $border-radius,$hover-color {
--size: 26px;
position: relative;
cursor: pointer;
height: var(--size);
line-height: var(--size);
width: auto;
padding-left: var(--size);
border-radius: $border-radius;
&::before {
content: '';
position: absolute;
top: 3px;
left: 3px;
height: 20px;
width: 20px;
background-color: $icon-button-color;
mask-repeat: no-repeat;
mask-size: contain;
mask-position: center;
}
&::after {
content: '';
position: absolute;
left: 0;
top: 0;
z-index: 0;
width: var(--size);
height: var(--size);
border-radius: $border-radius;
}
&:hover {
&::after {
background: rgba($hover-color, 0.1);
}
&::before {
background-color: $hover-color;
}
}
}

View file

@ -20,7 +20,7 @@ limitations under the License.
height: 28px; height: 28px;
border: 2px solid $voice-record-stop-border-color; border: 2px solid $voice-record-stop-border-color;
border-radius: 32px; border-radius: 32px;
margin-right: 8px; /* between us and the waveform component */ margin-right: 2px; /* between us and the waveform component */
position: relative; position: relative;
&::after { &::after {
@ -39,7 +39,7 @@ limitations under the License.
width: 24px; width: 24px;
height: 24px; height: 24px;
vertical-align: middle; vertical-align: middle;
margin-right: 8px; /* distance from left edge of waveform container (container has some margin too) */ margin-right: 2px; /* distance from left edge of waveform container (container has some margin too) */
background-color: $voice-record-icon-color; background-color: $voice-record-icon-color;
mask-repeat: no-repeat; mask-repeat: no-repeat;
mask-size: contain; mask-size: contain;
@ -69,7 +69,7 @@ limitations under the License.
height: 32px; height: 32px;
margin: 6px; /* force the composer area to put a gutter around us */ margin: 6px; /* force the composer area to put a gutter around us */
margin-right: 12px; /* isolate from stop/send button */ margin-right: 6px; /* isolate from stop/send button */
position: relative; /* important for the live circle */ position: relative; /* important for the live circle */
@ -93,6 +93,14 @@ limitations under the License.
} }
} }
.mx_MessageComposer_wysiwyg .mx_VoiceMessagePrimaryContainer {
&.mx_VoiceRecordComposerTile_recording {
&::before {
top: 15px; /* vertically center (middle align with clock) */
}
}
}
/* The keyframes are slightly weird here to help make a ramping/punch effect */ /* The keyframes are slightly weird here to help make a ramping/punch effect */
/* for the recording dot. We start and end at 100% opacity to help make the */ /* for the recording dot. We start and end at 100% opacity to help make the */
/* dot feel a bit like a real lamp that is blinking: the animation ends up */ /* dot feel a bit like a real lamp that is blinking: the animation ends up */

View file

@ -24,7 +24,7 @@ limitations under the License.
gap: 8px; gap: 8px;
padding: 8px var(--EditWysiwygComposer-padding-inline); padding: 8px var(--EditWysiwygComposer-padding-inline);
.mx_WysiwygComposer_content { .mx_WysiwygComposer_Editor_content {
border-radius: 4px; border-radius: 4px;
border: solid 1px $primary-hairline-color; border: solid 1px $primary-hairline-color;
background-color: $background; background-color: $background;

View file

@ -22,32 +22,65 @@ limitations under the License.
/* fixed line height to prevent emoji from being taller than text */ /* fixed line height to prevent emoji from being taller than text */
line-height: $font-18px; line-height: $font-18px;
justify-content: center; justify-content: center;
margin-right: 6px; margin-right: 13px;
/* don't grow wider than available space */ gap: 8px;
min-width: 0;
.mx_WysiwygComposer_container { .mx_FormattingButtons {
flex: 1; margin-left: 12px;
}
.mx_WysiwygComposer_Editor {
border: 1px solid;
border-color: $quinary-content;
padding: 6px 11px 6px 12px;
display: flex; display: flex;
flex-direction: column; align-items: flex-end;
/* min-height at this level so the mx_BasicMessageComposer_input */ gap: 10px;
/* still stays vertically centered when less than 55px. */
/* We also set this to ensure the voice message recording widget */
/* doesn't cause a jump. */
min-height: 55px;
.mx_WysiwygComposer_content { .mx_E2EIcon {
border: 1px solid; margin: 0 0 7px 0;
border-radius: 20px; width: 12px;
padding: 8px 10px; height: 12px;
/* this will center the contenteditable */ }
/* in it's parent vertically */
/* while keeping the autocomplete at the top */ &[data-is-expanded="true"] {
/* of the composer. The parent needs to be a flex container for this to work. */ border-radius: 14px;
margin: auto 0;
/* max-height at this level so autocomplete doesn't get scrolled too */ .mx_WysiwygComposer_Editor_container {
max-height: 140px; margin-top: 3px;
overflow-y: auto; margin-bottom: 3px;
}
}
&[data-is-expanded="false"] {
border-radius: 40px;
}
.mx_WysiwygComposer_Editor_container {
flex: 1;
display: flex;
flex-direction: column;
min-height: 22px;
margin-bottom: 2px;
/* don't grow wider than available space */
width: 0;
.mx_WysiwygComposer_Editor_content {
/* this will center the contenteditable */
/* in it's parent vertically */
/* while keeping the autocomplete at the top */
/* of the composer. The parent needs to be a flex container for this to work. */
margin: auto 0;
/* max-height at this level so autocomplete doesn't get scrolled too */
max-height: 140px;
overflow-y: auto;
}
} }
} }
} }
.mx_SendWysiwygComposer-focused {
.mx_WysiwygComposer_Editor {
border-color: $quaternary-content;
}
}

View file

@ -14,15 +14,13 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
.mx_WysiwygComposer_container { .mx_WysiwygComposer_Editor_container {
position: relative;
@keyframes visualbell { @keyframes visualbell {
from { background-color: $visual-bell-bg-color; } from { background-color: $visual-bell-bg-color; }
to { background-color: $background; } to { background-color: $background; }
} }
.mx_WysiwygComposer_content { .mx_WysiwygComposer_Editor_content {
white-space: pre-wrap; white-space: pre-wrap;
word-wrap: break-word; word-wrap: break-word;
outline: none; outline: none;

View file

@ -17,6 +17,7 @@ limitations under the License.
.mx_FormattingButtons { .mx_FormattingButtons {
display: flex; display: flex;
justify-content: flex-start; justify-content: flex-start;
gap: 8px;
.mx_FormattingButtons_Button { .mx_FormattingButtons_Button {
--size: 28px; --size: 28px;
@ -26,18 +27,9 @@ limitations under the License.
line-height: var(--size); line-height: var(--size);
width: auto; width: auto;
padding-left: 22px; padding-left: 22px;
margin-right: 8px;
background-color: transparent; background-color: transparent;
border: none; border: none;
&:first-child {
margin-left: 12px;
}
&:last-child {
margin-right: auto;
}
&::before { &::before {
content: ''; content: '';
position: absolute; position: absolute;

View file

@ -1,10 +1,34 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"> <?xml version="1.0" encoding="UTF-8" standalone="no"?>
<g clip-path="url(#clip0_1456_146350)"> <svg
<path d="M1 18.6667C1 19.4 1.6 20 2.33333 20H18.3333C19.0667 20 19.6667 19.4 19.6667 18.6667C19.6667 17.9333 19.0667 17.3333 18.3333 17.3333H2.33333C1.6 17.3333 1 17.9333 1 18.6667ZM7 11.7333H13.6667L14.5467 13.8667C14.7467 14.3467 15.2133 14.6667 15.7333 14.6667C16.6533 14.6667 17.2667 13.72 16.9067 12.88L11.7333 0.92C11.4933 0.36 10.9467 0 10.3333 0C9.72 0 9.17333 0.36 8.93333 0.92L3.76 12.88C3.4 13.72 4.02667 14.6667 4.94667 14.6667C5.46667 14.6667 5.93333 14.3467 6.13333 13.8667L7 11.7333ZM10.3333 2.64L12.8267 9.33333H7.84L10.3333 2.64Z" fill="#C1C6CD"/> width="20"
</g> height="20"
<defs> viewBox="0 0 20 20"
<clipPath id="clip0_1456_146350"> fill="none"
<rect width="20" height="20" fill="white"/> xmlns="http://www.w3.org/2000/svg"
</clipPath> >
</defs> <g
clip-path="url(#clip0_1456_146365)"
id="g53">
<path
d="M7.00042 13.7333H13.6671L14.5471 15.8667C14.7471 16.3467 15.2137 16.6667 15.7337 16.6667C16.6537 16.6667 17.2671 15.72 16.9071 14.88L11.7337 2.92C11.4937 2.36 10.9471 2 10.3337 2C9.72042 2 9.17375 2.36 8.93375 2.92L3.76042 14.88C3.40042 15.72 4.02708 16.6667 4.94708 16.6667C5.46708 16.6667 5.93375 16.3467 6.13375 15.8667L7.00042 13.7333ZM10.3337 4.64L12.8271 11.3333H7.84042L10.3337 4.64Z"
fill="#C1C6CD"
id="path49" />
<path
d="m 1.497495,8.96927 c 0,0.793654 0.7402877,1.441437 1.6473569,1.441437 H 17.521786 c 0.907096,0 1.647419,-0.647783 1.647419,-1.441437 0,-0.7936857 -0.740323,-1.4414375 -1.647419,-1.4414375 H 11.127487 3.1448519 c -0.4734211,0 -0.9014103,0.1764504 -1.2024293,0.4580061 C 1.7722258,8.1450309 1.6426187,8.3378225 1.568339,8.5513189 1.522281,8.6837006 1.497495,8.8240421 1.497495,8.96927 Z"
fill="#c1c6cd"
stroke="#ffffff"
id="path51"
style="stroke:none;stroke-width:0.840525;stroke-opacity:1" />
</g>
<defs
id="defs58">
<clipPath
id="clip0_1456_146365">
<rect
width="20"
height="20"
fill="white"
id="rect55" />
</clipPath>
</defs>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 818 B

After

Width:  |  Height:  |  Size: 1.4 KiB

View file

@ -1,10 +1,9 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1456_146365)"> <g clip-path="url(#clip0_1456_146350)">
<path d="M7.00042 13.7333H13.6671L14.5471 15.8667C14.7471 16.3467 15.2137 16.6667 15.7337 16.6667C16.6537 16.6667 17.2671 15.72 16.9071 14.88L11.7337 2.92C11.4937 2.36 10.9471 2 10.3337 2C9.72042 2 9.17375 2.36 8.93375 2.92L3.76042 14.88C3.40042 15.72 4.02708 16.6667 4.94708 16.6667C5.46708 16.6667 5.93375 16.3467 6.13375 15.8667L7.00042 13.7333ZM10.3337 4.64L12.8271 11.3333H7.84042L10.3337 4.64Z" fill="#C1C6CD"/> <path d="M1 18.6667C1 19.4 1.6 20 2.33333 20H18.3333C19.0667 20 19.6667 19.4 19.6667 18.6667C19.6667 17.9333 19.0667 17.3333 18.3333 17.3333H2.33333C1.6 17.3333 1 17.9333 1 18.6667ZM7 11.7333H13.6667L14.5467 13.8667C14.7467 14.3467 15.2133 14.6667 15.7333 14.6667C16.6533 14.6667 17.2667 13.72 16.9067 12.88L11.7333 0.92C11.4933 0.36 10.9467 0 10.3333 0C9.72 0 9.17333 0.36 8.93333 0.92L3.76 12.88C3.4 13.72 4.02667 14.6667 4.94667 14.6667C5.46667 14.6667 5.93333 14.3467 6.13333 13.8667L7 11.7333ZM10.3333 2.64L12.8267 9.33333H7.84L10.3333 2.64Z" fill="#C1C6CD"/>
<path d="M0.5 9.66927C0.5 10.6787 1.32386 11.5026 2.33333 11.5026H18.3333C19.3428 11.5026 20.1667 10.6787 20.1667 9.66927C20.1667 8.6598 19.3428 7.83594 18.3333 7.83594H2.33333C1.32386 7.83594 0.5 8.6598 0.5 9.66927Z" fill="#C1C6CD" stroke="white"/>
</g> </g>
<defs> <defs>
<clipPath id="clip0_1456_146365"> <clipPath id="clip0_1456_146350">
<rect width="20" height="20" fill="white"/> <rect width="20" height="20" fill="white"/>
</clipPath> </clipPath>
</defs> </defs>

Before

Width:  |  Height:  |  Size: 921 B

After

Width:  |  Height:  |  Size: 818 B

View file

@ -0,0 +1,75 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import classNames from "classnames";
import React, { useContext } from "react";
import { _t } from "../../../languageHandler";
import ContextMenu, { aboveLeftOf, AboveLeftOf, useContextMenu } from "../../structures/ContextMenu";
import EmojiPicker from "../emojipicker/EmojiPicker";
import { CollapsibleButton } from "./CollapsibleButton";
import { OverflowMenuContext } from "./MessageComposerButtons";
interface IEmojiButtonProps {
addEmoji: (unicode: string) => boolean;
menuPosition: AboveLeftOf;
className?: string;
}
export function EmojiButton({ addEmoji, menuPosition, className }: IEmojiButtonProps) {
const overflowMenuCloser = useContext(OverflowMenuContext);
const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu();
let contextMenu: React.ReactElement | null = null;
if (menuDisplayed && button.current) {
const position = (
menuPosition ?? aboveLeftOf(button.current.getBoundingClientRect())
);
contextMenu = <ContextMenu
{...position}
onFinished={() => {
closeMenu();
overflowMenuCloser?.();
}}
managed={false}
>
<EmojiPicker onChoose={addEmoji} showQuickReactions={true} />
</ContextMenu>;
}
const computedClassName = classNames(
"mx_EmojiButton",
className,
{
"mx_EmojiButton_highlight": menuDisplayed,
},
);
// TODO: replace ContextMenuTooltipButton with a unified representation of
// the header buttons and the right panel buttons
return <>
<CollapsibleButton
className={computedClassName}
iconClassName="mx_EmojiButton_icon"
onClick={openMenu}
title={_t("Emoji")}
inputRef={button}
/>
{ contextMenu }
</>;
}

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React, { createRef } from 'react'; import React, { createRef, ReactNode } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { IEventRelation, MatrixEvent } from "matrix-js-sdk/src/models/event"; import { IEventRelation, MatrixEvent } from "matrix-js-sdk/src/models/event";
import { Room } from "matrix-js-sdk/src/models/room"; import { Room } from "matrix-js-sdk/src/models/room";
@ -31,7 +31,7 @@ import Stickerpicker from './Stickerpicker';
import { makeRoomPermalink, RoomPermalinkCreator } from '../../../utils/permalinks/Permalinks'; import { makeRoomPermalink, RoomPermalinkCreator } from '../../../utils/permalinks/Permalinks';
import E2EIcon from './E2EIcon'; import E2EIcon from './E2EIcon';
import SettingsStore from "../../../settings/SettingsStore"; import SettingsStore from "../../../settings/SettingsStore";
import { aboveLeftOf, AboveLeftOf } from "../../structures/ContextMenu"; import { aboveLeftOf } from "../../structures/ContextMenu";
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import ReplyPreview from "./ReplyPreview"; import ReplyPreview from "./ReplyPreview";
import { UPDATE_EVENT } from "../../../stores/AsyncStore"; import { UPDATE_EVENT } from "../../../stores/AsyncStore";
@ -420,33 +420,48 @@ export class MessageComposer extends React.Component<IProps, IState> {
return this.state.showStickersButton && !isLocalRoom(this.props.room); return this.state.showStickersButton && !isLocalRoom(this.props.room);
} }
public render() { private getMenuPosition() {
const controls = [
this.props.e2eStatus ?
<E2EIcon key="e2eIcon" status={this.props.e2eStatus} className="mx_MessageComposer_e2eIcon" /> :
null,
];
let menuPosition: AboveLeftOf | undefined;
if (this.ref.current) { if (this.ref.current) {
const hasFormattingButtons = this.state.isWysiwygLabEnabled && this.state.isRichTextEnabled;
const contentRect = this.ref.current.getBoundingClientRect(); const contentRect = this.ref.current.getBoundingClientRect();
menuPosition = aboveLeftOf(contentRect); // Here we need to remove the all the extra space above the editor
// Instead of doing a querySelector or pass a ref to find the compute the height formatting buttons
// We are using an arbitrary value, the formatting buttons height doesn't change during the lifecycle of the component
// It's easier to just use a constant here instead of an over-engineering way to find the height
const heightToRemove = hasFormattingButtons ? 36 : 0;
const fixedRect = new DOMRect(
contentRect.x,
contentRect.y + heightToRemove,
contentRect.width,
contentRect.height - heightToRemove);
return aboveLeftOf(fixedRect);
} }
}
public render() {
const hasE2EIcon = Boolean(!this.state.isWysiwygLabEnabled && this.props.e2eStatus);
const e2eIcon = hasE2EIcon &&
<E2EIcon key="e2eIcon" status={this.props.e2eStatus} className="mx_MessageComposer_e2eIcon" />;
const controls: ReactNode[] = [];
const menuPosition = this.getMenuPosition();
const canSendMessages = this.context.canSendMessages && !this.context.tombstone; const canSendMessages = this.context.canSendMessages && !this.context.tombstone;
let composer: ReactNode;
if (canSendMessages) { if (canSendMessages) {
if (this.state.isWysiwygLabEnabled) { if (this.state.isWysiwygLabEnabled && menuPosition) {
controls.push( composer =
<SendWysiwygComposer key="controls_input" <SendWysiwygComposer key="controls_input"
disabled={this.state.haveRecording} disabled={this.state.haveRecording}
onChange={this.onWysiwygChange} onChange={this.onWysiwygChange}
onSend={this.sendMessage} onSend={this.sendMessage}
isRichTextEnabled={this.state.isRichTextEnabled} isRichTextEnabled={this.state.isRichTextEnabled}
initialContent={this.state.initialComposerContent} initialContent={this.state.initialComposerContent}
/>, e2eStatus={this.props.e2eStatus}
); menuPosition={menuPosition}
/>;
} else { } else {
controls.push( composer =
<SendMessageComposer <SendMessageComposer
ref={this.messageComposerInput} ref={this.messageComposerInput}
key="controls_input" key="controls_input"
@ -458,8 +473,7 @@ export class MessageComposer extends React.Component<IProps, IState> {
onChange={this.onChange} onChange={this.onChange}
disabled={this.state.haveRecording} disabled={this.state.haveRecording}
toggleStickerPickerOpen={this.toggleStickerPickerOpen} toggleStickerPickerOpen={this.toggleStickerPickerOpen}
/>, />;
);
} }
controls.push(<VoiceRecordComposerTile controls.push(<VoiceRecordComposerTile
@ -529,8 +543,8 @@ export class MessageComposer extends React.Component<IProps, IState> {
const classes = classNames({ const classes = classNames({
"mx_MessageComposer": true, "mx_MessageComposer": true,
"mx_MessageComposer--compact": this.props.compact, "mx_MessageComposer--compact": this.props.compact,
"mx_MessageComposer_e2eStatus": this.props.e2eStatus != undefined, "mx_MessageComposer_e2eStatus": hasE2EIcon,
"mx_MessageComposer_wysiwyg": this.state.isWysiwygLabEnabled && this.state.isRichTextEnabled, "mx_MessageComposer_wysiwyg": this.state.isWysiwygLabEnabled,
}); });
return ( return (
@ -541,45 +555,48 @@ export class MessageComposer extends React.Component<IProps, IState> {
replyToEvent={this.props.replyToEvent} replyToEvent={this.props.replyToEvent}
permalinkCreator={this.props.permalinkCreator} /> permalinkCreator={this.props.permalinkCreator} />
<div className="mx_MessageComposer_row"> <div className="mx_MessageComposer_row">
{ controls } { e2eIcon }
{ canSendMessages && <MessageComposerButtons { composer }
addEmoji={this.addEmoji} <div className="mx_MessageComposer_actions">
haveRecording={this.state.haveRecording} { controls }
isMenuOpen={this.state.isMenuOpen} { canSendMessages && <MessageComposerButtons
isStickerPickerOpen={this.state.isStickerPickerOpen} addEmoji={this.addEmoji}
menuPosition={menuPosition} haveRecording={this.state.haveRecording}
relation={this.props.relation} isMenuOpen={this.state.isMenuOpen}
onRecordStartEndClick={() => { isStickerPickerOpen={this.state.isStickerPickerOpen}
this.voiceRecordingButton.current?.onRecordStartEndClick(); menuPosition={menuPosition}
if (this.context.narrow) { relation={this.props.relation}
onRecordStartEndClick={() => {
this.voiceRecordingButton.current?.onRecordStartEndClick();
if (this.context.narrow) {
this.toggleButtonMenu();
}
}}
setStickerPickerOpen={this.setStickerPickerOpen}
showLocationButton={!window.electron}
showPollsButton={this.state.showPollsButton}
showStickersButton={this.showStickersButton}
isRichTextEnabled={this.state.isRichTextEnabled}
onComposerModeClick={this.onRichTextToggle}
toggleButtonMenu={this.toggleButtonMenu}
showVoiceBroadcastButton={this.state.showVoiceBroadcastButton}
onStartVoiceBroadcastClick={() => {
startNewVoiceBroadcastRecording(
this.props.room,
MatrixClientPeg.get(),
VoiceBroadcastRecordingsStore.instance(),
);
this.toggleButtonMenu(); this.toggleButtonMenu();
} }}
}} /> }
setStickerPickerOpen={this.setStickerPickerOpen} { showSendButton && (
showLocationButton={!window.electron} <SendButton
showPollsButton={this.state.showPollsButton} key="controls_send"
showStickersButton={this.showStickersButton} onClick={this.sendMessage}
showComposerModeButton={this.state.isWysiwygLabEnabled} title={this.state.haveRecording ? _t("Send voice message") : undefined}
isRichTextEnabled={this.state.isRichTextEnabled} />
onComposerModeClick={this.onRichTextToggle} ) }
toggleButtonMenu={this.toggleButtonMenu} </div>
showVoiceBroadcastButton={this.state.showVoiceBroadcastButton}
onStartVoiceBroadcastClick={() => {
startNewVoiceBroadcastRecording(
this.props.room,
MatrixClientPeg.get(),
VoiceBroadcastRecordingsStore.instance(),
);
this.toggleButtonMenu();
}}
/> }
{ showSendButton && (
<SendButton
key="controls_send"
onClick={this.sendMessage}
title={this.state.haveRecording ? _t("Send voice message") : undefined}
/>
) }
</div> </div>
</div> </div>
</div> </div>

View file

@ -25,9 +25,8 @@ import { THREAD_RELATION_TYPE } from 'matrix-js-sdk/src/models/thread';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import { CollapsibleButton } from './CollapsibleButton'; import { CollapsibleButton } from './CollapsibleButton';
import ContextMenu, { aboveLeftOf, AboveLeftOf, useContextMenu } from '../../structures/ContextMenu'; import { AboveLeftOf } from '../../structures/ContextMenu';
import dis from '../../../dispatcher/dispatcher'; import dis from '../../../dispatcher/dispatcher';
import EmojiPicker from '../emojipicker/EmojiPicker';
import ErrorDialog from "../dialogs/ErrorDialog"; import ErrorDialog from "../dialogs/ErrorDialog";
import LocationButton from '../location/LocationButton'; import LocationButton from '../location/LocationButton';
import Modal from "../../../Modal"; import Modal from "../../../Modal";
@ -39,6 +38,8 @@ import RoomContext from '../../../contexts/RoomContext';
import { useDispatcher } from "../../../hooks/useDispatcher"; import { useDispatcher } from "../../../hooks/useDispatcher";
import { chromeFileInputFix } from "../../../utils/BrowserWorkarounds"; import { chromeFileInputFix } from "../../../utils/BrowserWorkarounds";
import IconizedContextMenu, { IconizedContextMenuOptionList } from '../context_menus/IconizedContextMenu'; import IconizedContextMenu, { IconizedContextMenuOptionList } from '../context_menus/IconizedContextMenu';
import { EmojiButton } from './EmojiButton';
import { useSettingValue } from '../../../hooks/useSettings';
interface IProps { interface IProps {
addEmoji: (emoji: string) => boolean; addEmoji: (emoji: string) => boolean;
@ -56,7 +57,6 @@ interface IProps {
showVoiceBroadcastButton: boolean; showVoiceBroadcastButton: boolean;
onStartVoiceBroadcastClick: () => void; onStartVoiceBroadcastClick: () => void;
isRichTextEnabled: boolean; isRichTextEnabled: boolean;
showComposerModeButton: boolean;
onComposerModeClick: () => void; onComposerModeClick: () => void;
} }
@ -67,6 +67,8 @@ const MessageComposerButtons: React.FC<IProps> = (props: IProps) => {
const matrixClient: MatrixClient = useContext(MatrixClientContext); const matrixClient: MatrixClient = useContext(MatrixClientContext);
const { room, roomId, narrow } = useContext(RoomContext); const { room, roomId, narrow } = useContext(RoomContext);
const isWysiwygLabEnabled = useSettingValue<boolean>('feature_wysiwyg_composer');
if (props.haveRecording) { if (props.haveRecording) {
return null; return null;
} }
@ -75,7 +77,9 @@ const MessageComposerButtons: React.FC<IProps> = (props: IProps) => {
let moreButtons: ReactElement[]; let moreButtons: ReactElement[];
if (narrow) { if (narrow) {
mainButtons = [ mainButtons = [
emojiButton(props), isWysiwygLabEnabled ?
<ComposerModeButton key="composerModeButton" isRichTextEnabled={props.isRichTextEnabled} onClick={props.onComposerModeClick} /> :
emojiButton(props),
]; ];
moreButtons = [ moreButtons = [
uploadButton(), // props passed via UploadButtonContext uploadButton(), // props passed via UploadButtonContext
@ -87,9 +91,9 @@ const MessageComposerButtons: React.FC<IProps> = (props: IProps) => {
]; ];
} else { } else {
mainButtons = [ mainButtons = [
emojiButton(props), isWysiwygLabEnabled ?
props.showComposerModeButton && <ComposerModeButton key="composerModeButton" isRichTextEnabled={props.isRichTextEnabled} onClick={props.onComposerModeClick} /> :
<ComposerModeButton key="composerModeButton" isRichTextEnabled={props.isRichTextEnabled} onClick={props.onComposerModeClick} />, emojiButton(props),
uploadButton(), // props passed via UploadButtonContext uploadButton(), // props passed via UploadButtonContext
]; ];
moreButtons = [ moreButtons = [
@ -139,58 +143,10 @@ function emojiButton(props: IProps): ReactElement {
key="emoji_button" key="emoji_button"
addEmoji={props.addEmoji} addEmoji={props.addEmoji}
menuPosition={props.menuPosition} menuPosition={props.menuPosition}
className="mx_MessageComposer_button"
/>; />;
} }
interface IEmojiButtonProps {
addEmoji: (unicode: string) => boolean;
menuPosition: AboveLeftOf;
}
const EmojiButton: React.FC<IEmojiButtonProps> = ({ addEmoji, menuPosition }) => {
const overflowMenuCloser = useContext(OverflowMenuContext);
const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu();
let contextMenu: React.ReactElement | null = null;
if (menuDisplayed) {
const position = (
menuPosition ?? aboveLeftOf(button.current.getBoundingClientRect())
);
contextMenu = <ContextMenu
{...position}
onFinished={() => {
closeMenu();
overflowMenuCloser?.();
}}
managed={false}
>
<EmojiPicker onChoose={addEmoji} showQuickReactions={true} />
</ContextMenu>;
}
const className = classNames(
"mx_MessageComposer_button",
{
"mx_MessageComposer_button_highlight": menuDisplayed,
},
);
// TODO: replace ContextMenuTooltipButton with a unified representation of
// the header buttons and the right panel buttons
return <React.Fragment>
<CollapsibleButton
className={className}
iconClassName="mx_MessageComposer_emoji"
onClick={openMenu}
title={_t("Emoji")}
inputRef={button}
/>
{ contextMenu }
</React.Fragment>;
};
function uploadButton(): ReactElement { function uploadButton(): ReactElement {
return <UploadButton key="controls_upload" />; return <UploadButton key="controls_upload" />;
} }
@ -408,7 +364,7 @@ interface WysiwygToggleButtonProps {
} }
function ComposerModeButton({ isRichTextEnabled, onClick }: WysiwygToggleButtonProps) { function ComposerModeButton({ isRichTextEnabled, onClick }: WysiwygToggleButtonProps) {
const title = isRichTextEnabled ? _t("Show plain text") : _t("Show formatting"); const title = isRichTextEnabled ? _t("Hide formatting") : _t("Show formatting");
return <CollapsibleButton return <CollapsibleButton
className="mx_MessageComposer_button" className="mx_MessageComposer_button"

View file

@ -14,21 +14,28 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React, { forwardRef, RefObject } from 'react'; import React, { ForwardedRef, forwardRef, MutableRefObject } from 'react';
import { useWysiwygSendActionHandler } from './hooks/useWysiwygSendActionHandler'; import { useWysiwygSendActionHandler } from './hooks/useWysiwygSendActionHandler';
import { WysiwygComposer } from './components/WysiwygComposer'; import { WysiwygComposer } from './components/WysiwygComposer';
import { PlainTextComposer } from './components/PlainTextComposer'; import { PlainTextComposer } from './components/PlainTextComposer';
import { ComposerFunctions } from './types'; import { ComposerFunctions } from './types';
import { E2EStatus } from '../../../../utils/ShieldUtils';
import E2EIcon from '../E2EIcon';
import { EmojiButton } from '../EmojiButton';
import { AboveLeftOf } from '../../../structures/ContextMenu';
interface ContentProps { interface ContentProps {
disabled: boolean; disabled?: boolean;
composerFunctions: ComposerFunctions; composerFunctions: ComposerFunctions;
} }
const Content = forwardRef<HTMLElement, ContentProps>( const Content = forwardRef<HTMLElement, ContentProps>(
function Content({ disabled, composerFunctions }: ContentProps, forwardRef: RefObject<HTMLElement>) { function Content(
useWysiwygSendActionHandler(disabled, forwardRef, composerFunctions); { disabled = false, composerFunctions }: ContentProps,
forwardRef: ForwardedRef<HTMLElement>,
) {
useWysiwygSendActionHandler(disabled, forwardRef as MutableRefObject<HTMLElement>, composerFunctions);
return null; return null;
}, },
); );
@ -37,14 +44,23 @@ interface SendWysiwygComposerProps {
initialContent?: string; initialContent?: string;
isRichTextEnabled: boolean; isRichTextEnabled: boolean;
disabled?: boolean; disabled?: boolean;
e2eStatus?: E2EStatus;
onChange: (content: string) => void; onChange: (content: string) => void;
onSend: () => void; onSend: () => void;
menuPosition: AboveLeftOf;
} }
export function SendWysiwygComposer({ isRichTextEnabled, ...props }: SendWysiwygComposerProps) { export function SendWysiwygComposer(
{ isRichTextEnabled, e2eStatus, menuPosition, ...props }: SendWysiwygComposerProps) {
const Composer = isRichTextEnabled ? WysiwygComposer : PlainTextComposer; const Composer = isRichTextEnabled ? WysiwygComposer : PlainTextComposer;
return <Composer className="mx_SendWysiwygComposer" {...props}> return <Composer
className="mx_SendWysiwygComposer"
leftComponent={e2eStatus && <E2EIcon status={e2eStatus} />}
// TODO add emoji support
rightComponent={<EmojiButton menuPosition={menuPosition} addEmoji={() => false} />}
{...props}
>
{ (ref, composerFunctions) => ( { (ref, composerFunctions) => (
<Content disabled={props.disabled} ref={ref} composerFunctions={composerFunctions} /> <Content disabled={props.disabled} ref={ref} composerFunctions={composerFunctions} />
) } ) }

View file

@ -14,27 +14,43 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React, { forwardRef, memo } from 'react'; import React, { forwardRef, memo, MutableRefObject, ReactNode } from 'react';
import { useIsExpanded } from '../hooks/useIsExpanded';
const HEIGHT_BREAKING_POINT = 20;
interface EditorProps { interface EditorProps {
disabled: boolean; disabled: boolean;
leftComponent?: ReactNode;
rightComponent?: ReactNode;
} }
export const Editor = memo( export const Editor = memo(
forwardRef<HTMLDivElement, EditorProps>( forwardRef<HTMLDivElement, EditorProps>(
function Editor({ disabled }: EditorProps, ref, function Editor({ disabled, leftComponent, rightComponent }: EditorProps, ref,
) { ) {
return <div className="mx_WysiwygComposer_container"> const isExpanded = useIsExpanded(ref as MutableRefObject<HTMLDivElement | null>, HEIGHT_BREAKING_POINT);
<div className="mx_WysiwygComposer_content"
ref={ref!} return <div
contentEditable={!disabled} data-testid="WysiwygComposerEditor"
role="textbox" className="mx_WysiwygComposer_Editor"
aria-multiline="true" data-is-expanded={isExpanded}
aria-autocomplete="list" >
aria-haspopup="listbox" { leftComponent }
dir="auto" <div className="mx_WysiwygComposer_Editor_container">
aria-disabled={disabled} <div className="mx_WysiwygComposer_Editor_content"
/> ref={ref}
contentEditable={!disabled}
role="textbox"
aria-multiline="true"
aria-autocomplete="list"
aria-haspopup="listbox"
dir="auto"
aria-disabled={disabled}
/>
</div>
{ rightComponent }
</div>; </div>;
}, },
), ),

View file

@ -14,9 +14,11 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import classNames from 'classnames';
import React, { MutableRefObject, ReactNode } from 'react'; import React, { MutableRefObject, ReactNode } from 'react';
import { useComposerFunctions } from '../hooks/useComposerFunctions'; import { useComposerFunctions } from '../hooks/useComposerFunctions';
import { useIsFocused } from '../hooks/useIsFocused';
import { usePlainTextInitialization } from '../hooks/usePlainTextInitialization'; import { usePlainTextInitialization } from '../hooks/usePlainTextInitialization';
import { usePlainTextListeners } from '../hooks/usePlainTextListeners'; import { usePlainTextListeners } from '../hooks/usePlainTextListeners';
import { useSetCursorPosition } from '../hooks/useSetCursorPosition'; import { useSetCursorPosition } from '../hooks/useSetCursorPosition';
@ -26,9 +28,11 @@ import { Editor } from "./Editor";
interface PlainTextComposerProps { interface PlainTextComposerProps {
disabled?: boolean; disabled?: boolean;
onChange?: (content: string) => void; onChange?: (content: string) => void;
onSend: () => void; onSend?: () => void;
initialContent?: string; initialContent?: string;
className?: string; className?: string;
leftComponent?: ReactNode;
rightComponent?: ReactNode;
children?: ( children?: (
ref: MutableRefObject<HTMLDivElement | null>, ref: MutableRefObject<HTMLDivElement | null>,
composerFunctions: ComposerFunctions, composerFunctions: ComposerFunctions,
@ -36,21 +40,32 @@ interface PlainTextComposerProps {
} }
export function PlainTextComposer({ export function PlainTextComposer({
className, disabled, onSend, onChange, children, initialContent }: PlainTextComposerProps, className,
disabled = false,
onSend,
onChange,
children,
initialContent,
leftComponent,
rightComponent,
}: PlainTextComposerProps,
) { ) {
const { ref, onInput, onPaste, onKeyDown } = usePlainTextListeners(onChange, onSend); const { ref, onInput, onPaste, onKeyDown } = usePlainTextListeners(onChange, onSend);
const composerFunctions = useComposerFunctions(ref); const composerFunctions = useComposerFunctions(ref);
usePlainTextInitialization(initialContent, ref); usePlainTextInitialization(initialContent, ref);
useSetCursorPosition(disabled, ref); useSetCursorPosition(disabled, ref);
const { isFocused, onFocus } = useIsFocused();
return <div return <div
data-testid="PlainTextComposer" data-testid="PlainTextComposer"
className={className} className={classNames(className, { [`${className}-focused`]: isFocused })}
onFocus={onFocus}
onBlur={onFocus}
onInput={onInput} onInput={onInput}
onPaste={onPaste} onPaste={onPaste}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
> >
<Editor ref={ref} disabled={disabled} /> <Editor ref={ref} disabled={disabled} leftComponent={leftComponent} rightComponent={rightComponent} />
{ children?.(ref, composerFunctions) } { children?.(ref, composerFunctions) }
</div>; </div>;
} }

View file

@ -16,11 +16,13 @@ limitations under the License.
import React, { memo, MutableRefObject, ReactNode, useEffect } from 'react'; import React, { memo, MutableRefObject, ReactNode, useEffect } from 'react';
import { useWysiwyg, FormattingFunctions } from "@matrix-org/matrix-wysiwyg"; import { useWysiwyg, FormattingFunctions } from "@matrix-org/matrix-wysiwyg";
import classNames from 'classnames';
import { FormattingButtons } from './FormattingButtons'; import { FormattingButtons } from './FormattingButtons';
import { Editor } from './Editor'; import { Editor } from './Editor';
import { useInputEventProcessor } from '../hooks/useInputEventProcessor'; import { useInputEventProcessor } from '../hooks/useInputEventProcessor';
import { useSetCursorPosition } from '../hooks/useSetCursorPosition'; import { useSetCursorPosition } from '../hooks/useSetCursorPosition';
import { useIsFocused } from '../hooks/useIsFocused';
interface WysiwygComposerProps { interface WysiwygComposerProps {
disabled?: boolean; disabled?: boolean;
@ -28,6 +30,8 @@ interface WysiwygComposerProps {
onSend: () => void; onSend: () => void;
initialContent?: string; initialContent?: string;
className?: string; className?: string;
leftComponent?: ReactNode;
rightComponent?: ReactNode;
children?: ( children?: (
ref: MutableRefObject<HTMLDivElement | null>, ref: MutableRefObject<HTMLDivElement | null>,
wysiwyg: FormattingFunctions, wysiwyg: FormattingFunctions,
@ -35,7 +39,16 @@ interface WysiwygComposerProps {
} }
export const WysiwygComposer = memo(function WysiwygComposer( export const WysiwygComposer = memo(function WysiwygComposer(
{ disabled = false, onChange, onSend, initialContent, className, children }: WysiwygComposerProps, {
disabled = false,
onChange,
onSend,
initialContent,
className,
leftComponent,
rightComponent,
children,
}: WysiwygComposerProps,
) { ) {
const inputEventProcessor = useInputEventProcessor(onSend); const inputEventProcessor = useInputEventProcessor(onSend);
@ -51,10 +64,12 @@ export const WysiwygComposer = memo(function WysiwygComposer(
const isReady = isWysiwygReady && !disabled; const isReady = isWysiwygReady && !disabled;
useSetCursorPosition(!isReady, ref); useSetCursorPosition(!isReady, ref);
const { isFocused, onFocus } = useIsFocused();
return ( return (
<div data-testid="WysiwygComposer" className={className}> <div data-testid="WysiwygComposer" className={classNames(className, { [`${className}-focused`]: isFocused })} onFocus={onFocus} onBlur={onFocus}>
<FormattingButtons composer={wysiwyg} formattingStates={formattingStates} /> <FormattingButtons composer={wysiwyg} formattingStates={formattingStates} />
<Editor ref={ref} disabled={!isReady} /> <Editor ref={ref} disabled={!isReady} leftComponent={leftComponent} rightComponent={rightComponent} />
{ children?.(ref, wysiwyg) } { children?.(ref, wysiwyg) }
</div> </div>
); );

View file

@ -0,0 +1,37 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { MutableRefObject, useEffect, useState } from "react";
export function useIsExpanded(ref: MutableRefObject<HTMLElement | null>, breakingPoint: number) {
const [isExpanded, setIsExpanded] = useState(false);
useEffect(() => {
if (ref.current) {
const editor = ref.current;
const resizeObserver = new ResizeObserver(entries => {
requestAnimationFrame(() => {
const height = entries[0]?.contentBoxSize?.[0].blockSize;
setIsExpanded(height >= breakingPoint);
});
});
resizeObserver.observe(editor);
return () => resizeObserver.unobserve(editor);
}
}, [ref, breakingPoint]);
return isExpanded;
}

View file

@ -0,0 +1,36 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { FocusEvent, useCallback, useEffect, useRef, useState } from "react";
export function useIsFocused() {
const [isFocused, setIsFocused] = useState(false);
const timeoutIDRef = useRef<number>();
useEffect(() => () => clearTimeout(timeoutIDRef.current), [timeoutIDRef]);
const onFocus = useCallback((event: FocusEvent<HTMLElement>) => {
clearTimeout(timeoutIDRef.current);
if (event.type === 'focus') {
setIsFocused(true);
} else {
// To avoid a blink when we switch mode between plain text and rich text mode
// We delay the unfocused action
timeoutIDRef.current = setTimeout(() => setIsFocused(false), 100);
}
}, [setIsFocused, timeoutIDRef]);
return { isFocused, onFocus };
}

View file

@ -16,7 +16,7 @@ limitations under the License.
import { RefObject, useEffect } from "react"; import { RefObject, useEffect } from "react";
export function usePlainTextInitialization(initialContent: string, ref: RefObject<HTMLElement>) { export function usePlainTextInitialization(initialContent = '', ref: RefObject<HTMLElement>) {
useEffect(() => { useEffect(() => {
if (ref.current) { if (ref.current) {
ref.current.innerText = initialContent; ref.current.innerText = initialContent;

View file

@ -22,18 +22,18 @@ function isDivElement(target: EventTarget): target is HTMLDivElement {
return target instanceof HTMLDivElement; return target instanceof HTMLDivElement;
} }
export function usePlainTextListeners(onChange: (content: string) => void, onSend: () => void) { export function usePlainTextListeners(onChange?: (content: string) => void, onSend?: () => void) {
const ref = useRef<HTMLDivElement>(); const ref = useRef<HTMLDivElement | null>(null);
const send = useCallback((() => { const send = useCallback((() => {
if (ref.current) { if (ref.current) {
ref.current.innerHTML = ''; ref.current.innerHTML = '';
} }
onSend(); onSend?.();
}), [ref, onSend]); }), [ref, onSend]);
const onInput = useCallback((event: SyntheticEvent<HTMLDivElement, InputEvent | ClipboardEvent>) => { const onInput = useCallback((event: SyntheticEvent<HTMLDivElement, InputEvent | ClipboardEvent>) => {
if (isDivElement(event.target)) { if (isDivElement(event.target)) {
onChange(event.target.innerHTML); onChange?.(event.target.innerHTML);
} }
}, [onChange]); }, [onChange]);

View file

@ -28,7 +28,7 @@ export function useWysiwygEditActionHandler(
composerElement: RefObject<HTMLElement>, composerElement: RefObject<HTMLElement>,
) { ) {
const roomContext = useRoomContext(); const roomContext = useRoomContext();
const timeoutId = useRef<number>(); const timeoutId = useRef<number | null>(null);
const handler = useCallback((payload: ActionPayload) => { const handler = useCallback((payload: ActionPayload) => {
// don't let the user into the composer if it is disabled - all of these branches lead // don't let the user into the composer if it is disabled - all of these branches lead

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { RefObject, useCallback, useRef } from "react"; import { MutableRefObject, useCallback, useRef } from "react";
import defaultDispatcher from "../../../../../dispatcher/dispatcher"; import defaultDispatcher from "../../../../../dispatcher/dispatcher";
import { Action } from "../../../../../dispatcher/actions"; import { Action } from "../../../../../dispatcher/actions";
@ -26,16 +26,16 @@ import { ComposerFunctions } from "../types";
export function useWysiwygSendActionHandler( export function useWysiwygSendActionHandler(
disabled: boolean, disabled: boolean,
composerElement: RefObject<HTMLElement>, composerElement: MutableRefObject<HTMLElement>,
composerFunctions: ComposerFunctions, composerFunctions: ComposerFunctions,
) { ) {
const roomContext = useRoomContext(); const roomContext = useRoomContext();
const timeoutId = useRef<number>(); const timeoutId = useRef<number | null>(null);
const handler = useCallback((payload: ActionPayload) => { const handler = useCallback((payload: ActionPayload) => {
// don't let the user into the composer if it is disabled - all of these branches lead // don't let the user into the composer if it is disabled - all of these branches lead
// to the cursor being in the composer // to the cursor being in the composer
if (disabled || !composerElement.current) return; if (disabled || !composerElement?.current) return;
const context = payload.context ?? TimelineRenderingType.Room; const context = payload.context ?? TimelineRenderingType.Room;

View file

@ -14,14 +14,16 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { MutableRefObject } from "react";
import { TimelineRenderingType } from "../../../../../contexts/RoomContext"; import { TimelineRenderingType } from "../../../../../contexts/RoomContext";
import { IRoomState } from "../../../../structures/RoomView"; import { IRoomState } from "../../../../structures/RoomView";
export function focusComposer( export function focusComposer(
composerElement: React.MutableRefObject<HTMLElement>, composerElement: MutableRefObject<HTMLElement | null>,
renderingType: TimelineRenderingType, renderingType: TimelineRenderingType,
roomContext: IRoomState, roomContext: IRoomState,
timeoutId: React.MutableRefObject<number>, timeoutId: MutableRefObject<number | null>,
) { ) {
if (renderingType === roomContext.timelineRenderingType) { if (renderingType === roomContext.timelineRenderingType) {
// Immediately set the focus, so if you start typing it // Immediately set the focus, so if you start typing it

View file

@ -1829,6 +1829,7 @@
"This room is end-to-end encrypted": "This room is end-to-end encrypted", "This room is end-to-end encrypted": "This room is end-to-end encrypted",
"Everyone in this room is verified": "Everyone in this room is verified", "Everyone in this room is verified": "Everyone in this room is verified",
"Edit message": "Edit message", "Edit message": "Edit message",
"Emoji": "Emoji",
"Mod": "Mod", "Mod": "Mod",
"From a thread": "From a thread", "From a thread": "From a thread",
"This event could not be displayed": "This event could not be displayed", "This event could not be displayed": "This event could not be displayed",
@ -1878,13 +1879,12 @@
"You do not have permission to post to this room": "You do not have permission to post to this room", "You do not have permission to post to this room": "You do not have permission to post to this room",
"%(seconds)ss left": "%(seconds)ss left", "%(seconds)ss left": "%(seconds)ss left",
"Send voice message": "Send voice message", "Send voice message": "Send voice message",
"Emoji": "Emoji",
"Hide stickers": "Hide stickers", "Hide stickers": "Hide stickers",
"Sticker": "Sticker", "Sticker": "Sticker",
"Voice Message": "Voice Message", "Voice Message": "Voice Message",
"You do not have permission to start polls in this room.": "You do not have permission to start polls in this room.", "You do not have permission to start polls in this room.": "You do not have permission to start polls in this room.",
"Poll": "Poll", "Poll": "Poll",
"Show plain text": "Show plain text", "Hide formatting": "Hide formatting",
"Show formatting": "Show formatting", "Show formatting": "Show formatting",
"Bold": "Bold", "Bold": "Bold",
"Italics": "Italics", "Italics": "Italics",

View file

@ -4,6 +4,6 @@ exports[`RoomView for a local room in state CREATING should match the snapshot 1
exports[`RoomView for a local room in state ERROR should match the snapshot 1`] = `"<div class="mx_RoomView mx_RoomView--local"><header class="mx_RoomHeader light-panel"><div class="mx_RoomHeader_wrapper"><div class="mx_RoomHeader_avatar"><div class="mx_DecoratedRoomAvatar"><span class="mx_BaseAvatar" role="presentation"><span class="mx_BaseAvatar_initial" aria-hidden="true" style="font-size: 15.600000000000001px; width: 24px; line-height: 24px;">U</span><img class="mx_BaseAvatar_image" src="data:image/png;base64,00" alt="" style="width: 24px; height: 24px;" aria-hidden="true"></span></div></div><div class="mx_E2EIcon mx_E2EIcon_normal mx_RoomHeader_icon"></div><div class="mx_RoomHeader_name mx_RoomHeader_name--textonly"><div dir="auto" class="mx_RoomHeader_nametext" title="@user:example.com" role="heading" aria-level="1">@user:example.com</div></div><div class="mx_RoomHeader_topic mx_RoomTopic" dir="auto"><div tabindex="0"><div><span dir="auto"></span></div></div></div></div></header><main class="mx_RoomView_body"><div class="mx_RoomView_timeline"><div class="mx_AutoHideScrollbar mx_ScrollPanel mx_RoomView_messagePanel" tabindex="-1"><div class="mx_RoomView_messageListWrapper"><ol class="mx_RoomView_MessageList" aria-live="polite" style="height: 400px;"><li class="mx_NewRoomIntro"><div class="mx_EventTileBubble mx_cryptoEvent mx_cryptoEvent_icon_warning"><div class="mx_EventTileBubble_title">End-to-end encryption isn't enabled</div><div class="mx_EventTileBubble_subtitle"><span> Your private messages are normally encrypted, but this room isn't. Usually this is due to an unsupported device or method being used, like email invites. </span></div></div><span aria-label="Avatar" aria-live="off" role="button" tabindex="0" class="mx_AccessibleButton mx_BaseAvatar"><span class="mx_BaseAvatar_initial" aria-hidden="true" style="font-size: 33.800000000000004px; width: 52px; line-height: 52px;">U</span><img class="mx_BaseAvatar_image" src="data:image/png;base64,00" alt="" style="width: 52px; height: 52px;" aria-hidden="true"></span><h2>@user:example.com</h2><p><span>Send your first message to invite <b>@user:example.com</b> to chat</span></p></li></ol></div></div></div><div class="mx_RoomStatusBar mx_RoomStatusBar_unsentMessages"><div role="alert"><div class="mx_RoomStatusBar_unsentBadge"><div class="mx_NotificationBadge mx_NotificationBadge_visible mx_NotificationBadge_highlighted mx_NotificationBadge_2char"><span class="mx_NotificationBadge_count">!</span></div></div><div><div class="mx_RoomStatusBar_unsentTitle">Some of your messages have not been sent</div></div><div class="mx_RoomStatusBar_unsentButtonBar"><div role="button" tabindex="0" class="mx_AccessibleButton mx_RoomStatusBar_unsentRetry">Retry</div></div></div></div></main></div>"`; exports[`RoomView for a local room in state ERROR should match the snapshot 1`] = `"<div class="mx_RoomView mx_RoomView--local"><header class="mx_RoomHeader light-panel"><div class="mx_RoomHeader_wrapper"><div class="mx_RoomHeader_avatar"><div class="mx_DecoratedRoomAvatar"><span class="mx_BaseAvatar" role="presentation"><span class="mx_BaseAvatar_initial" aria-hidden="true" style="font-size: 15.600000000000001px; width: 24px; line-height: 24px;">U</span><img class="mx_BaseAvatar_image" src="data:image/png;base64,00" alt="" style="width: 24px; height: 24px;" aria-hidden="true"></span></div></div><div class="mx_E2EIcon mx_E2EIcon_normal mx_RoomHeader_icon"></div><div class="mx_RoomHeader_name mx_RoomHeader_name--textonly"><div dir="auto" class="mx_RoomHeader_nametext" title="@user:example.com" role="heading" aria-level="1">@user:example.com</div></div><div class="mx_RoomHeader_topic mx_RoomTopic" dir="auto"><div tabindex="0"><div><span dir="auto"></span></div></div></div></div></header><main class="mx_RoomView_body"><div class="mx_RoomView_timeline"><div class="mx_AutoHideScrollbar mx_ScrollPanel mx_RoomView_messagePanel" tabindex="-1"><div class="mx_RoomView_messageListWrapper"><ol class="mx_RoomView_MessageList" aria-live="polite" style="height: 400px;"><li class="mx_NewRoomIntro"><div class="mx_EventTileBubble mx_cryptoEvent mx_cryptoEvent_icon_warning"><div class="mx_EventTileBubble_title">End-to-end encryption isn't enabled</div><div class="mx_EventTileBubble_subtitle"><span> Your private messages are normally encrypted, but this room isn't. Usually this is due to an unsupported device or method being used, like email invites. </span></div></div><span aria-label="Avatar" aria-live="off" role="button" tabindex="0" class="mx_AccessibleButton mx_BaseAvatar"><span class="mx_BaseAvatar_initial" aria-hidden="true" style="font-size: 33.800000000000004px; width: 52px; line-height: 52px;">U</span><img class="mx_BaseAvatar_image" src="data:image/png;base64,00" alt="" style="width: 52px; height: 52px;" aria-hidden="true"></span><h2>@user:example.com</h2><p><span>Send your first message to invite <b>@user:example.com</b> to chat</span></p></li></ol></div></div></div><div class="mx_RoomStatusBar mx_RoomStatusBar_unsentMessages"><div role="alert"><div class="mx_RoomStatusBar_unsentBadge"><div class="mx_NotificationBadge mx_NotificationBadge_visible mx_NotificationBadge_highlighted mx_NotificationBadge_2char"><span class="mx_NotificationBadge_count">!</span></div></div><div><div class="mx_RoomStatusBar_unsentTitle">Some of your messages have not been sent</div></div><div class="mx_RoomStatusBar_unsentButtonBar"><div role="button" tabindex="0" class="mx_AccessibleButton mx_RoomStatusBar_unsentRetry">Retry</div></div></div></div></main></div>"`;
exports[`RoomView for a local room in state NEW should match the snapshot 1`] = `"<div class="mx_RoomView mx_RoomView--local"><header class="mx_RoomHeader light-panel"><div class="mx_RoomHeader_wrapper"><div class="mx_RoomHeader_avatar"><div class="mx_DecoratedRoomAvatar"><span class="mx_BaseAvatar" role="presentation"><span class="mx_BaseAvatar_initial" aria-hidden="true" style="font-size: 15.600000000000001px; width: 24px; line-height: 24px;">U</span><img class="mx_BaseAvatar_image" src="data:image/png;base64,00" alt="" style="width: 24px; height: 24px;" aria-hidden="true"></span></div></div><div class="mx_E2EIcon mx_E2EIcon_normal mx_RoomHeader_icon"></div><div class="mx_RoomHeader_name mx_RoomHeader_name--textonly"><div dir="auto" class="mx_RoomHeader_nametext" title="@user:example.com" role="heading" aria-level="1">@user:example.com</div></div><div class="mx_RoomHeader_topic mx_RoomTopic" dir="auto"><div tabindex="0"><div><span dir="auto"></span></div></div></div></div></header><main class="mx_RoomView_body"><div class="mx_RoomView_timeline"><div class="mx_AutoHideScrollbar mx_ScrollPanel mx_RoomView_messagePanel" tabindex="-1"><div class="mx_RoomView_messageListWrapper"><ol class="mx_RoomView_MessageList" aria-live="polite" style="height: 400px;"><li class="mx_NewRoomIntro"><div class="mx_EventTileBubble mx_cryptoEvent mx_cryptoEvent_icon_warning"><div class="mx_EventTileBubble_title">End-to-end encryption isn't enabled</div><div class="mx_EventTileBubble_subtitle"><span> Your private messages are normally encrypted, but this room isn't. Usually this is due to an unsupported device or method being used, like email invites. </span></div></div><span aria-label="Avatar" aria-live="off" role="button" tabindex="0" class="mx_AccessibleButton mx_BaseAvatar"><span class="mx_BaseAvatar_initial" aria-hidden="true" style="font-size: 33.800000000000004px; width: 52px; line-height: 52px;">U</span><img class="mx_BaseAvatar_image" src="data:image/png;base64,00" alt="" style="width: 52px; height: 52px;" aria-hidden="true"></span><h2>@user:example.com</h2><p><span>Send your first message to invite <b>@user:example.com</b> to chat</span></p></li></ol></div></div></div><div class="mx_MessageComposer"><div class="mx_MessageComposer_wrapper"><div class="mx_MessageComposer_row"><div class="mx_SendMessageComposer"><div class="mx_BasicMessageComposer"><div class="mx_MessageComposerFormatBar"><button type="button" aria-label="Bold" role="button" tabindex="0" class="mx_AccessibleButton mx_MessageComposerFormatBar_button mx_MessageComposerFormatBar_buttonIconBold"></button><button type="button" aria-label="Italics" role="button" tabindex="0" class="mx_AccessibleButton mx_MessageComposerFormatBar_button mx_MessageComposerFormatBar_buttonIconItalic"></button><button type="button" aria-label="Strikethrough" role="button" tabindex="0" class="mx_AccessibleButton mx_MessageComposerFormatBar_button mx_MessageComposerFormatBar_buttonIconStrikethrough"></button><button type="button" aria-label="Code block" role="button" tabindex="0" class="mx_AccessibleButton mx_MessageComposerFormatBar_button mx_MessageComposerFormatBar_buttonIconCode"></button><button type="button" aria-label="Quote" role="button" tabindex="0" class="mx_AccessibleButton mx_MessageComposerFormatBar_button mx_MessageComposerFormatBar_buttonIconQuote"></button><button type="button" aria-label="Insert link" role="button" tabindex="0" class="mx_AccessibleButton mx_MessageComposerFormatBar_button mx_MessageComposerFormatBar_buttonIconInsertLink"></button></div><div class="mx_BasicMessageComposer_input mx_BasicMessageComposer_input_shouldShowPillAvatar mx_BasicMessageComposer_inputEmpty" contenteditable="true" tabindex="0" aria-label="Send a message…" role="textbox" aria-multiline="true" aria-autocomplete="list" aria-haspopup="listbox" dir="auto" aria-disabled="false" data-testid="basicmessagecomposer" style="--placeholder: 'Send a message…';"><div><br></div></div></div></div><div aria-label="Emoji" role="button" tabindex="0" class="mx_AccessibleButton mx_MessageComposer_button mx_MessageComposer_emoji"></div><div aria-label="Attachment" role="button" tabindex="0" class="mx_AccessibleButton mx_MessageComposer_button mx_MessageComposer_upload"></div><div aria-label="More options" role="button" tabindex="0" class="mx_AccessibleButton mx_MessageComposer_button mx_MessageComposer_buttonMenu"></div><input type="file" style="display: none;" multiple=""></div></div></div></main></div>"`; exports[`RoomView for a local room in state NEW should match the snapshot 1`] = `"<div class="mx_RoomView mx_RoomView--local"><header class="mx_RoomHeader light-panel"><div class="mx_RoomHeader_wrapper"><div class="mx_RoomHeader_avatar"><div class="mx_DecoratedRoomAvatar"><span class="mx_BaseAvatar" role="presentation"><span class="mx_BaseAvatar_initial" aria-hidden="true" style="font-size: 15.600000000000001px; width: 24px; line-height: 24px;">U</span><img class="mx_BaseAvatar_image" src="data:image/png;base64,00" alt="" style="width: 24px; height: 24px;" aria-hidden="true"></span></div></div><div class="mx_E2EIcon mx_E2EIcon_normal mx_RoomHeader_icon"></div><div class="mx_RoomHeader_name mx_RoomHeader_name--textonly"><div dir="auto" class="mx_RoomHeader_nametext" title="@user:example.com" role="heading" aria-level="1">@user:example.com</div></div><div class="mx_RoomHeader_topic mx_RoomTopic" dir="auto"><div tabindex="0"><div><span dir="auto"></span></div></div></div></div></header><main class="mx_RoomView_body"><div class="mx_RoomView_timeline"><div class="mx_AutoHideScrollbar mx_ScrollPanel mx_RoomView_messagePanel" tabindex="-1"><div class="mx_RoomView_messageListWrapper"><ol class="mx_RoomView_MessageList" aria-live="polite" style="height: 400px;"><li class="mx_NewRoomIntro"><div class="mx_EventTileBubble mx_cryptoEvent mx_cryptoEvent_icon_warning"><div class="mx_EventTileBubble_title">End-to-end encryption isn't enabled</div><div class="mx_EventTileBubble_subtitle"><span> Your private messages are normally encrypted, but this room isn't. Usually this is due to an unsupported device or method being used, like email invites. </span></div></div><span aria-label="Avatar" aria-live="off" role="button" tabindex="0" class="mx_AccessibleButton mx_BaseAvatar"><span class="mx_BaseAvatar_initial" aria-hidden="true" style="font-size: 33.800000000000004px; width: 52px; line-height: 52px;">U</span><img class="mx_BaseAvatar_image" src="data:image/png;base64,00" alt="" style="width: 52px; height: 52px;" aria-hidden="true"></span><h2>@user:example.com</h2><p><span>Send your first message to invite <b>@user:example.com</b> to chat</span></p></li></ol></div></div></div><div class="mx_MessageComposer"><div class="mx_MessageComposer_wrapper"><div class="mx_MessageComposer_row"><div class="mx_SendMessageComposer"><div class="mx_BasicMessageComposer"><div class="mx_MessageComposerFormatBar"><button type="button" aria-label="Bold" role="button" tabindex="0" class="mx_AccessibleButton mx_MessageComposerFormatBar_button mx_MessageComposerFormatBar_buttonIconBold"></button><button type="button" aria-label="Italics" role="button" tabindex="0" class="mx_AccessibleButton mx_MessageComposerFormatBar_button mx_MessageComposerFormatBar_buttonIconItalic"></button><button type="button" aria-label="Strikethrough" role="button" tabindex="0" class="mx_AccessibleButton mx_MessageComposerFormatBar_button mx_MessageComposerFormatBar_buttonIconStrikethrough"></button><button type="button" aria-label="Code block" role="button" tabindex="0" class="mx_AccessibleButton mx_MessageComposerFormatBar_button mx_MessageComposerFormatBar_buttonIconCode"></button><button type="button" aria-label="Quote" role="button" tabindex="0" class="mx_AccessibleButton mx_MessageComposerFormatBar_button mx_MessageComposerFormatBar_buttonIconQuote"></button><button type="button" aria-label="Insert link" role="button" tabindex="0" class="mx_AccessibleButton mx_MessageComposerFormatBar_button mx_MessageComposerFormatBar_buttonIconInsertLink"></button></div><div class="mx_BasicMessageComposer_input mx_BasicMessageComposer_input_shouldShowPillAvatar mx_BasicMessageComposer_inputEmpty" contenteditable="true" tabindex="0" aria-label="Send a message…" role="textbox" aria-multiline="true" aria-autocomplete="list" aria-haspopup="listbox" dir="auto" aria-disabled="false" data-testid="basicmessagecomposer" style="--placeholder: 'Send a message…';"><div><br></div></div></div></div><div class="mx_MessageComposer_actions"><div aria-label="Emoji" role="button" tabindex="0" class="mx_AccessibleButton mx_EmojiButton mx_MessageComposer_button mx_EmojiButton_icon"></div><div aria-label="Attachment" role="button" tabindex="0" class="mx_AccessibleButton mx_MessageComposer_button mx_MessageComposer_upload"></div><div aria-label="More options" role="button" tabindex="0" class="mx_AccessibleButton mx_MessageComposer_button mx_MessageComposer_buttonMenu"></div><input type="file" style="display: none;" multiple=""></div></div></div></div></main></div>"`;
exports[`RoomView for a local room in state NEW that is encrypted should match the snapshot 1`] = `"<div class="mx_RoomView mx_RoomView--local"><header class="mx_RoomHeader light-panel"><div class="mx_RoomHeader_wrapper"><div class="mx_RoomHeader_avatar"><div class="mx_DecoratedRoomAvatar"><span class="mx_BaseAvatar" role="presentation"><span class="mx_BaseAvatar_initial" aria-hidden="true" style="font-size: 15.600000000000001px; width: 24px; line-height: 24px;">U</span><img class="mx_BaseAvatar_image" src="data:image/png;base64,00" alt="" style="width: 24px; height: 24px;" aria-hidden="true"></span></div></div><div class="mx_E2EIcon mx_E2EIcon_normal mx_RoomHeader_icon"></div><div class="mx_RoomHeader_name mx_RoomHeader_name--textonly"><div dir="auto" class="mx_RoomHeader_nametext" title="@user:example.com" role="heading" aria-level="1">@user:example.com</div></div><div class="mx_RoomHeader_topic mx_RoomTopic" dir="auto"><div tabindex="0"><div><span dir="auto"></span></div></div></div></div></header><main class="mx_RoomView_body"><div class="mx_RoomView_timeline"><div class="mx_AutoHideScrollbar mx_ScrollPanel mx_RoomView_messagePanel" tabindex="-1"><div class="mx_RoomView_messageListWrapper"><ol class="mx_RoomView_MessageList" aria-live="polite" style="height: 400px;"><div class="mx_EventTileBubble mx_cryptoEvent mx_cryptoEvent_icon"><div class="mx_EventTileBubble_title">Encryption enabled</div><div class="mx_EventTileBubble_subtitle">Messages in this chat will be end-to-end encrypted.</div></div><li class="mx_NewRoomIntro"><span aria-label="Avatar" aria-live="off" role="button" tabindex="0" class="mx_AccessibleButton mx_BaseAvatar"><span class="mx_BaseAvatar_initial" aria-hidden="true" style="font-size: 33.800000000000004px; width: 52px; line-height: 52px;">U</span><img class="mx_BaseAvatar_image" src="data:image/png;base64,00" alt="" style="width: 52px; height: 52px;" aria-hidden="true"></span><h2>@user:example.com</h2><p><span>Send your first message to invite <b>@user:example.com</b> to chat</span></p></li></ol></div></div></div><div class="mx_MessageComposer"><div class="mx_MessageComposer_wrapper"><div class="mx_MessageComposer_row"><div class="mx_SendMessageComposer"><div class="mx_BasicMessageComposer"><div class="mx_MessageComposerFormatBar"><button type="button" aria-label="Bold" role="button" tabindex="0" class="mx_AccessibleButton mx_MessageComposerFormatBar_button mx_MessageComposerFormatBar_buttonIconBold"></button><button type="button" aria-label="Italics" role="button" tabindex="0" class="mx_AccessibleButton mx_MessageComposerFormatBar_button mx_MessageComposerFormatBar_buttonIconItalic"></button><button type="button" aria-label="Strikethrough" role="button" tabindex="0" class="mx_AccessibleButton mx_MessageComposerFormatBar_button mx_MessageComposerFormatBar_buttonIconStrikethrough"></button><button type="button" aria-label="Code block" role="button" tabindex="0" class="mx_AccessibleButton mx_MessageComposerFormatBar_button mx_MessageComposerFormatBar_buttonIconCode"></button><button type="button" aria-label="Quote" role="button" tabindex="0" class="mx_AccessibleButton mx_MessageComposerFormatBar_button mx_MessageComposerFormatBar_buttonIconQuote"></button><button type="button" aria-label="Insert link" role="button" tabindex="0" class="mx_AccessibleButton mx_MessageComposerFormatBar_button mx_MessageComposerFormatBar_buttonIconInsertLink"></button></div><div class="mx_BasicMessageComposer_input mx_BasicMessageComposer_input_shouldShowPillAvatar mx_BasicMessageComposer_inputEmpty" contenteditable="true" tabindex="0" aria-label="Send a message…" role="textbox" aria-multiline="true" aria-autocomplete="list" aria-haspopup="listbox" dir="auto" aria-disabled="false" data-testid="basicmessagecomposer" style="--placeholder: 'Send a message…';"><div><br></div></div></div></div><div aria-label="Emoji" role="button" tabindex="0" class="mx_AccessibleButton mx_MessageComposer_button mx_MessageComposer_emoji"></div><div aria-label="Attachment" role="button" tabindex="0" class="mx_AccessibleButton mx_MessageComposer_button mx_MessageComposer_upload"></div><div aria-label="More options" role="button" tabindex="0" class="mx_AccessibleButton mx_MessageComposer_button mx_MessageComposer_buttonMenu"></div><input type="file" style="display: none;" multiple=""></div></div></div></main></div>"`; exports[`RoomView for a local room in state NEW that is encrypted should match the snapshot 1`] = `"<div class="mx_RoomView mx_RoomView--local"><header class="mx_RoomHeader light-panel"><div class="mx_RoomHeader_wrapper"><div class="mx_RoomHeader_avatar"><div class="mx_DecoratedRoomAvatar"><span class="mx_BaseAvatar" role="presentation"><span class="mx_BaseAvatar_initial" aria-hidden="true" style="font-size: 15.600000000000001px; width: 24px; line-height: 24px;">U</span><img class="mx_BaseAvatar_image" src="data:image/png;base64,00" alt="" style="width: 24px; height: 24px;" aria-hidden="true"></span></div></div><div class="mx_E2EIcon mx_E2EIcon_normal mx_RoomHeader_icon"></div><div class="mx_RoomHeader_name mx_RoomHeader_name--textonly"><div dir="auto" class="mx_RoomHeader_nametext" title="@user:example.com" role="heading" aria-level="1">@user:example.com</div></div><div class="mx_RoomHeader_topic mx_RoomTopic" dir="auto"><div tabindex="0"><div><span dir="auto"></span></div></div></div></div></header><main class="mx_RoomView_body"><div class="mx_RoomView_timeline"><div class="mx_AutoHideScrollbar mx_ScrollPanel mx_RoomView_messagePanel" tabindex="-1"><div class="mx_RoomView_messageListWrapper"><ol class="mx_RoomView_MessageList" aria-live="polite" style="height: 400px;"><div class="mx_EventTileBubble mx_cryptoEvent mx_cryptoEvent_icon"><div class="mx_EventTileBubble_title">Encryption enabled</div><div class="mx_EventTileBubble_subtitle">Messages in this chat will be end-to-end encrypted.</div></div><li class="mx_NewRoomIntro"><span aria-label="Avatar" aria-live="off" role="button" tabindex="0" class="mx_AccessibleButton mx_BaseAvatar"><span class="mx_BaseAvatar_initial" aria-hidden="true" style="font-size: 33.800000000000004px; width: 52px; line-height: 52px;">U</span><img class="mx_BaseAvatar_image" src="data:image/png;base64,00" alt="" style="width: 52px; height: 52px;" aria-hidden="true"></span><h2>@user:example.com</h2><p><span>Send your first message to invite <b>@user:example.com</b> to chat</span></p></li></ol></div></div></div><div class="mx_MessageComposer"><div class="mx_MessageComposer_wrapper"><div class="mx_MessageComposer_row"><div class="mx_SendMessageComposer"><div class="mx_BasicMessageComposer"><div class="mx_MessageComposerFormatBar"><button type="button" aria-label="Bold" role="button" tabindex="0" class="mx_AccessibleButton mx_MessageComposerFormatBar_button mx_MessageComposerFormatBar_buttonIconBold"></button><button type="button" aria-label="Italics" role="button" tabindex="0" class="mx_AccessibleButton mx_MessageComposerFormatBar_button mx_MessageComposerFormatBar_buttonIconItalic"></button><button type="button" aria-label="Strikethrough" role="button" tabindex="0" class="mx_AccessibleButton mx_MessageComposerFormatBar_button mx_MessageComposerFormatBar_buttonIconStrikethrough"></button><button type="button" aria-label="Code block" role="button" tabindex="0" class="mx_AccessibleButton mx_MessageComposerFormatBar_button mx_MessageComposerFormatBar_buttonIconCode"></button><button type="button" aria-label="Quote" role="button" tabindex="0" class="mx_AccessibleButton mx_MessageComposerFormatBar_button mx_MessageComposerFormatBar_buttonIconQuote"></button><button type="button" aria-label="Insert link" role="button" tabindex="0" class="mx_AccessibleButton mx_MessageComposerFormatBar_button mx_MessageComposerFormatBar_buttonIconInsertLink"></button></div><div class="mx_BasicMessageComposer_input mx_BasicMessageComposer_input_shouldShowPillAvatar mx_BasicMessageComposer_inputEmpty" contenteditable="true" tabindex="0" aria-label="Send a message…" role="textbox" aria-multiline="true" aria-autocomplete="list" aria-haspopup="listbox" dir="auto" aria-disabled="false" data-testid="basicmessagecomposer" style="--placeholder: 'Send a message…';"><div><br></div></div></div></div><div class="mx_MessageComposer_actions"><div aria-label="Emoji" role="button" tabindex="0" class="mx_AccessibleButton mx_EmojiButton mx_MessageComposer_button mx_EmojiButton_icon"></div><div aria-label="Attachment" role="button" tabindex="0" class="mx_AccessibleButton mx_MessageComposer_button mx_MessageComposer_upload"></div><div aria-label="More options" role="button" tabindex="0" class="mx_AccessibleButton mx_MessageComposer_button mx_MessageComposer_buttonMenu"></div><input type="file" style="display: none;" multiple=""></div></div></div></div></main></div>"`;

View file

@ -28,6 +28,7 @@ import { createTestClient, flushPromises, getRoomContext, mkEvent, mkStubRoom }
import { SendWysiwygComposer } from "../../../../../src/components/views/rooms/wysiwyg_composer"; import { SendWysiwygComposer } from "../../../../../src/components/views/rooms/wysiwyg_composer";
import * as useComposerFunctions import * as useComposerFunctions
from "../../../../../src/components/views/rooms/wysiwyg_composer/hooks/useComposerFunctions"; from "../../../../../src/components/views/rooms/wysiwyg_composer/hooks/useComposerFunctions";
import { aboveLeftOf } from "../../../../../src/components/structures/ContextMenu";
const mockClear = jest.fn(); const mockClear = jest.fn();
@ -78,7 +79,7 @@ describe('SendWysiwygComposer', () => {
return render( return render(
<MatrixClientContext.Provider value={mockClient}> <MatrixClientContext.Provider value={mockClient}>
<RoomContext.Provider value={defaultRoomContext}> <RoomContext.Provider value={defaultRoomContext}>
<SendWysiwygComposer onChange={onChange} onSend={onSend} disabled={disabled} isRichTextEnabled={isRichTextEnabled} /> <SendWysiwygComposer onChange={onChange} onSend={onSend} disabled={disabled} isRichTextEnabled={isRichTextEnabled} menuPosition={aboveLeftOf({ top: 0, bottom: 0, right: 0 })} />
</RoomContext.Provider> </RoomContext.Provider>
</MatrixClientContext.Provider>, </MatrixClientContext.Provider>,
); );

View file

@ -21,10 +21,6 @@ import userEvent from "@testing-library/user-event";
import { PlainTextComposer } import { PlainTextComposer }
from "../../../../../../src/components/views/rooms/wysiwyg_composer/components/PlainTextComposer"; from "../../../../../../src/components/views/rooms/wysiwyg_composer/components/PlainTextComposer";
// Work around missing ClipboardEvent type
class MyClipboardEvent {}
window.ClipboardEvent = MyClipboardEvent as any;
describe('PlainTextComposer', () => { describe('PlainTextComposer', () => {
const customRender = ( const customRender = (
onChange = (_content: string) => void 0, onChange = (_content: string) => void 0,
@ -91,4 +87,46 @@ describe('PlainTextComposer', () => {
// Then // Then
expect(screen.getByRole('textbox').innerHTML).toBeFalsy(); expect(screen.getByRole('textbox').innerHTML).toBeFalsy();
}); });
it('Should have data-is-expanded when it has two lines', async () => {
let resizeHandler: ResizeObserverCallback = jest.fn();
let editor: Element | null = null;
jest.spyOn(global, 'ResizeObserver').mockImplementation((handler) => {
resizeHandler = handler;
return {
observe: (element) => {
editor = element;
},
unobserve: jest.fn(),
disconnect: jest.fn(),
};
},
);
jest.spyOn(global, 'requestAnimationFrame').mockImplementation(cb => {
cb(0);
return 0;
});
//When
render(
<PlainTextComposer onChange={jest.fn()} onSend={jest.fn()} />,
);
// Then
expect(screen.getByTestId('WysiwygComposerEditor').attributes['data-is-expanded'].value).toBe('false');
expect(editor).toBe(screen.getByRole('textbox'));
// When
resizeHandler(
[{ contentBoxSize: [{ blockSize: 100 }] } as unknown as ResizeObserverEntry],
{} as ResizeObserver,
);
jest.runAllTimers();
// Then
expect(screen.getByTestId('WysiwygComposerEditor').attributes['data-is-expanded'].value).toBe('true');
(global.ResizeObserver as jest.Mock).mockRestore();
(global.requestAnimationFrame as jest.Mock).mockRestore();
});
}); });

View file

@ -23,10 +23,6 @@ import { WysiwygComposer }
from "../../../../../../src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer"; from "../../../../../../src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer";
import SettingsStore from "../../../../../../src/settings/SettingsStore"; import SettingsStore from "../../../../../../src/settings/SettingsStore";
// Work around missing ClipboardEvent type
class MyClipboardEvent {}
window.ClipboardEvent = MyClipboardEvent as any;
let inputEventProcessor: InputEventProcessor | null = null; let inputEventProcessor: InputEventProcessor | null = null;
// The wysiwyg fetch wasm bytes and a specific workaround is needed to make it works in a node (jest) environnement // The wysiwyg fetch wasm bytes and a specific workaround is needed to make it works in a node (jest) environnement

View file

@ -31,6 +31,29 @@ class ResizeObserver {
} }
window.ResizeObserver = ResizeObserver; window.ResizeObserver = ResizeObserver;
// Stub DOMRect
class DOMRect {
x = 0;
y = 0;
top = 0;
bottom = 0;
left = 0;
right = 0;
height = 0;
width = 0;
static fromRect() {
return new DOMRect();
}
toJSON() {}
}
window.DOMRect = DOMRect;
// Work around missing ClipboardEvent type
class MyClipboardEvent {}
window.ClipboardEvent = MyClipboardEvent as any;
// matchMedia is not included in jsdom // matchMedia is not included in jsdom
const mockMatchMedia = jest.fn().mockImplementation(query => ({ const mockMatchMedia = jest.fn().mockImplementation(query => ({
matches: false, matches: false,
@ -51,6 +74,7 @@ global.URL.revokeObjectURL = jest.fn();
// polyfilling TextEncoder as it is not available on JSDOM // polyfilling TextEncoder as it is not available on JSDOM
// view https://github.com/facebook/jest/issues/9983 // view https://github.com/facebook/jest/issues/9983
global.TextEncoder = TextEncoder; global.TextEncoder = TextEncoder;
// @ts-ignore
global.TextDecoder = TextDecoder; global.TextDecoder = TextDecoder;
// prevent errors whenever a component tries to manually scroll. // prevent errors whenever a component tries to manually scroll.
@ -60,4 +84,5 @@ window.HTMLElement.prototype.scrollIntoView = jest.fn();
fetchMock.config.overwriteRoutes = false; fetchMock.config.overwriteRoutes = false;
fetchMock.catch(""); fetchMock.catch("");
fetchMock.get("/image-file-stub", "image file stub"); fetchMock.get("/image-file-stub", "image file stub");
// @ts-ignore
window.fetch = fetchMock.sandbox(); window.fetch = fetchMock.sandbox();