diff --git a/res/css/_components.pcss b/res/css/_components.pcss index cc7c6a2e2a..5a263aa1e9 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -260,6 +260,7 @@ @import "./views/rooms/_AuxPanel.pcss"; @import "./views/rooms/_BasicMessageComposer.pcss"; @import "./views/rooms/_E2EIcon.pcss"; +@import "./views/rooms/_EmojiButton.pcss"; @import "./views/rooms/_EditMessageComposer.pcss"; @import "./views/rooms/_EntityTile.pcss"; @import "./views/rooms/_EventBubbleTile.pcss"; diff --git a/res/css/views/rooms/_EmojiButton.pcss b/res/css/views/rooms/_EmojiButton.pcss new file mode 100644 index 0000000000..1720a9ce0d --- /dev/null +++ b/res/css/views/rooms/_EmojiButton.pcss @@ -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; + } +} diff --git a/res/css/views/rooms/_MessageComposer.pcss b/res/css/views/rooms/_MessageComposer.pcss index 4d22f60a12..95c7e2dd74 100644 --- a/res/css/views/rooms/_MessageComposer.pcss +++ b/res/css/views/rooms/_MessageComposer.pcss @@ -15,6 +15,8 @@ See the License for the specific language governing permissions and limitations under the License. */ +@import "./_MessageComposerButton.pcss"; + .mx_MessageComposer_wrapper { vertical-align: middle; margin: auto; @@ -59,6 +61,12 @@ limitations under the License. width: 100%; } +.mx_MessageComposer_actions { + display: flex; + align-items: center; + gap: 6px; +} + .mx_MessageComposer .mx_MessageComposer_avatar { position: absolute; left: 26px; @@ -171,53 +179,16 @@ limitations under the License. } .mx_MessageComposer_button_highlight { - background: rgba($accent, 0.25); - /* make the icon the accent color too */ - &::before { - background-color: $accent !important; - } + @mixin composerButtonHighLight; } .mx_MessageComposer_button { - --size: 26px; - position: relative; - cursor: pointer; - height: var(--size); - line-height: var(--size); - width: auto; - padding-left: var(--size); - border-radius: 50%; - margin-right: 6px; + @mixin composerButton 50%,$accent; &:last-child { 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 { &::after { background: rgba($accent, 0.1); @@ -232,15 +203,43 @@ limitations under the License. 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_e2eIcon.mx_E2EIcon,.mx_MessageComposer_button, .mx_MessageComposer_sendMessage { - margin-top: 28px; + .mx_MessageComposer_wrapper { + 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'); } -.mx_MessageComposer_emoji::before { - mask-image: url('$(res)/img/element-icons/room/composer/emoji.svg'); -} - .mx_MessageComposer_plain_text::before { mask-image: url('$(res)/img/element-icons/room/composer/plain_text.svg'); } diff --git a/res/css/views/rooms/_MessageComposerButton.pcss b/res/css/views/rooms/_MessageComposerButton.pcss new file mode 100644 index 0000000000..e21c556207 --- /dev/null +++ b/res/css/views/rooms/_MessageComposerButton.pcss @@ -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; + } + } +} diff --git a/res/css/views/rooms/_VoiceRecordComposerTile.pcss b/res/css/views/rooms/_VoiceRecordComposerTile.pcss index 5443eca927..26f206f918 100644 --- a/res/css/views/rooms/_VoiceRecordComposerTile.pcss +++ b/res/css/views/rooms/_VoiceRecordComposerTile.pcss @@ -20,7 +20,7 @@ limitations under the License. height: 28px; border: 2px solid $voice-record-stop-border-color; border-radius: 32px; - margin-right: 8px; /* between us and the waveform component */ + margin-right: 2px; /* between us and the waveform component */ position: relative; &::after { @@ -39,7 +39,7 @@ limitations under the License. width: 24px; height: 24px; 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; mask-repeat: no-repeat; mask-size: contain; @@ -69,7 +69,7 @@ limitations under the License. height: 32px; 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 */ @@ -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 */ /* 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 */ diff --git a/res/css/views/rooms/wysiwyg_composer/_EditWysiwygComposer.pcss b/res/css/views/rooms/wysiwyg_composer/_EditWysiwygComposer.pcss index 73e5fef6e9..b711a634d1 100644 --- a/res/css/views/rooms/wysiwyg_composer/_EditWysiwygComposer.pcss +++ b/res/css/views/rooms/wysiwyg_composer/_EditWysiwygComposer.pcss @@ -24,7 +24,7 @@ limitations under the License. gap: 8px; padding: 8px var(--EditWysiwygComposer-padding-inline); - .mx_WysiwygComposer_content { + .mx_WysiwygComposer_Editor_content { border-radius: 4px; border: solid 1px $primary-hairline-color; background-color: $background; diff --git a/res/css/views/rooms/wysiwyg_composer/_SendWysiwygComposer.pcss b/res/css/views/rooms/wysiwyg_composer/_SendWysiwygComposer.pcss index a00f8c7e11..2eee815c3f 100644 --- a/res/css/views/rooms/wysiwyg_composer/_SendWysiwygComposer.pcss +++ b/res/css/views/rooms/wysiwyg_composer/_SendWysiwygComposer.pcss @@ -22,32 +22,65 @@ limitations under the License. /* fixed line height to prevent emoji from being taller than text */ line-height: $font-18px; justify-content: center; - margin-right: 6px; - /* don't grow wider than available space */ - min-width: 0; + margin-right: 13px; + gap: 8px; - .mx_WysiwygComposer_container { - flex: 1; + .mx_FormattingButtons { + margin-left: 12px; + } + + .mx_WysiwygComposer_Editor { + border: 1px solid; + border-color: $quinary-content; + padding: 6px 11px 6px 12px; display: flex; - flex-direction: column; - /* min-height at this level so the mx_BasicMessageComposer_input */ - /* 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; + align-items: flex-end; + gap: 10px; - .mx_WysiwygComposer_content { - border: 1px solid; - border-radius: 20px; - padding: 8px 10px; - /* 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_E2EIcon { + margin: 0 0 7px 0; + width: 12px; + height: 12px; + } + + &[data-is-expanded="true"] { + border-radius: 14px; + + .mx_WysiwygComposer_Editor_container { + margin-top: 3px; + 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; + } +} diff --git a/res/css/views/rooms/wysiwyg_composer/components/_Editor.pcss b/res/css/views/rooms/wysiwyg_composer/components/_Editor.pcss index 6a6b68af7c..00e5b220df 100644 --- a/res/css/views/rooms/wysiwyg_composer/components/_Editor.pcss +++ b/res/css/views/rooms/wysiwyg_composer/components/_Editor.pcss @@ -14,15 +14,13 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_WysiwygComposer_container { - position: relative; - +.mx_WysiwygComposer_Editor_container { @keyframes visualbell { from { background-color: $visual-bell-bg-color; } to { background-color: $background; } } - .mx_WysiwygComposer_content { + .mx_WysiwygComposer_Editor_content { white-space: pre-wrap; word-wrap: break-word; outline: none; diff --git a/res/css/views/rooms/wysiwyg_composer/components/_FormattingButtons.pcss b/res/css/views/rooms/wysiwyg_composer/components/_FormattingButtons.pcss index cd0ac38e0e..76026ff938 100644 --- a/res/css/views/rooms/wysiwyg_composer/components/_FormattingButtons.pcss +++ b/res/css/views/rooms/wysiwyg_composer/components/_FormattingButtons.pcss @@ -17,6 +17,7 @@ limitations under the License. .mx_FormattingButtons { display: flex; justify-content: flex-start; + gap: 8px; .mx_FormattingButtons_Button { --size: 28px; @@ -26,18 +27,9 @@ limitations under the License. line-height: var(--size); width: auto; padding-left: 22px; - margin-right: 8px; background-color: transparent; border: none; - &:first-child { - margin-left: 12px; - } - - &:last-child { - margin-right: auto; - } - &::before { content: ''; position: absolute; diff --git a/res/img/element-icons/room/composer/plain_text.svg b/res/img/element-icons/room/composer/plain_text.svg index d2da9d2551..874ae1a47d 100644 --- a/res/img/element-icons/room/composer/plain_text.svg +++ b/res/img/element-icons/room/composer/plain_text.svg @@ -1,10 +1,34 @@ - - - - - - - - - + + + + + + + + + + + diff --git a/res/img/element-icons/room/composer/rich_text.svg b/res/img/element-icons/room/composer/rich_text.svg index 7ff47fe085..d2da9d2551 100644 --- a/res/img/element-icons/room/composer/rich_text.svg +++ b/res/img/element-icons/room/composer/rich_text.svg @@ -1,10 +1,9 @@ - - - + + - + diff --git a/src/components/views/rooms/EmojiButton.tsx b/src/components/views/rooms/EmojiButton.tsx new file mode 100644 index 0000000000..3c99c093fc --- /dev/null +++ b/src/components/views/rooms/EmojiButton.tsx @@ -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 = { + closeMenu(); + overflowMenuCloser?.(); + }} + managed={false} + > + + ; + } + + 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 <> + + + { contextMenu } + ; +} diff --git a/src/components/views/rooms/MessageComposer.tsx b/src/components/views/rooms/MessageComposer.tsx index 4b04b87dae..7594b897e1 100644 --- a/src/components/views/rooms/MessageComposer.tsx +++ b/src/components/views/rooms/MessageComposer.tsx @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { createRef } from 'react'; +import React, { createRef, ReactNode } from 'react'; import classNames from 'classnames'; import { IEventRelation, MatrixEvent } from "matrix-js-sdk/src/models/event"; 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 E2EIcon from './E2EIcon'; import SettingsStore from "../../../settings/SettingsStore"; -import { aboveLeftOf, AboveLeftOf } from "../../structures/ContextMenu"; +import { aboveLeftOf } from "../../structures/ContextMenu"; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import ReplyPreview from "./ReplyPreview"; import { UPDATE_EVENT } from "../../../stores/AsyncStore"; @@ -420,33 +420,48 @@ export class MessageComposer extends React.Component { return this.state.showStickersButton && !isLocalRoom(this.props.room); } - public render() { - const controls = [ - this.props.e2eStatus ? - : - null, - ]; - - let menuPosition: AboveLeftOf | undefined; + private getMenuPosition() { if (this.ref.current) { + const hasFormattingButtons = this.state.isWysiwygLabEnabled && this.state.isRichTextEnabled; 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 && + ; + + const controls: ReactNode[] = []; + const menuPosition = this.getMenuPosition(); const canSendMessages = this.context.canSendMessages && !this.context.tombstone; + let composer: ReactNode; if (canSendMessages) { - if (this.state.isWysiwygLabEnabled) { - controls.push( + if (this.state.isWysiwygLabEnabled && menuPosition) { + composer = , - ); + e2eStatus={this.props.e2eStatus} + menuPosition={menuPosition} + />; } else { - controls.push( + composer = { onChange={this.onChange} disabled={this.state.haveRecording} toggleStickerPickerOpen={this.toggleStickerPickerOpen} - />, - ); + />; } controls.push( { const classes = classNames({ "mx_MessageComposer": true, "mx_MessageComposer--compact": this.props.compact, - "mx_MessageComposer_e2eStatus": this.props.e2eStatus != undefined, - "mx_MessageComposer_wysiwyg": this.state.isWysiwygLabEnabled && this.state.isRichTextEnabled, + "mx_MessageComposer_e2eStatus": hasE2EIcon, + "mx_MessageComposer_wysiwyg": this.state.isWysiwygLabEnabled, }); return ( @@ -541,45 +555,48 @@ export class MessageComposer extends React.Component { replyToEvent={this.props.replyToEvent} permalinkCreator={this.props.permalinkCreator} />
- { controls } - { canSendMessages && { - this.voiceRecordingButton.current?.onRecordStartEndClick(); - if (this.context.narrow) { + { e2eIcon } + { composer } +
+ { controls } + { canSendMessages && { + 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(); - } - }} - setStickerPickerOpen={this.setStickerPickerOpen} - showLocationButton={!window.electron} - showPollsButton={this.state.showPollsButton} - showStickersButton={this.showStickersButton} - showComposerModeButton={this.state.isWysiwygLabEnabled} - 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(); - }} - /> } - { showSendButton && ( - - ) } + }} + /> } + { showSendButton && ( + + ) } +
diff --git a/src/components/views/rooms/MessageComposerButtons.tsx b/src/components/views/rooms/MessageComposerButtons.tsx index d31f6fea27..49ac98b533 100644 --- a/src/components/views/rooms/MessageComposerButtons.tsx +++ b/src/components/views/rooms/MessageComposerButtons.tsx @@ -25,9 +25,8 @@ import { THREAD_RELATION_TYPE } from 'matrix-js-sdk/src/models/thread'; import { _t } from '../../../languageHandler'; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import { CollapsibleButton } from './CollapsibleButton'; -import ContextMenu, { aboveLeftOf, AboveLeftOf, useContextMenu } from '../../structures/ContextMenu'; +import { AboveLeftOf } from '../../structures/ContextMenu'; import dis from '../../../dispatcher/dispatcher'; -import EmojiPicker from '../emojipicker/EmojiPicker'; import ErrorDialog from "../dialogs/ErrorDialog"; import LocationButton from '../location/LocationButton'; import Modal from "../../../Modal"; @@ -39,6 +38,8 @@ import RoomContext from '../../../contexts/RoomContext'; import { useDispatcher } from "../../../hooks/useDispatcher"; import { chromeFileInputFix } from "../../../utils/BrowserWorkarounds"; import IconizedContextMenu, { IconizedContextMenuOptionList } from '../context_menus/IconizedContextMenu'; +import { EmojiButton } from './EmojiButton'; +import { useSettingValue } from '../../../hooks/useSettings'; interface IProps { addEmoji: (emoji: string) => boolean; @@ -56,7 +57,6 @@ interface IProps { showVoiceBroadcastButton: boolean; onStartVoiceBroadcastClick: () => void; isRichTextEnabled: boolean; - showComposerModeButton: boolean; onComposerModeClick: () => void; } @@ -67,6 +67,8 @@ const MessageComposerButtons: React.FC = (props: IProps) => { const matrixClient: MatrixClient = useContext(MatrixClientContext); const { room, roomId, narrow } = useContext(RoomContext); + const isWysiwygLabEnabled = useSettingValue('feature_wysiwyg_composer'); + if (props.haveRecording) { return null; } @@ -75,7 +77,9 @@ const MessageComposerButtons: React.FC = (props: IProps) => { let moreButtons: ReactElement[]; if (narrow) { mainButtons = [ - emojiButton(props), + isWysiwygLabEnabled ? + : + emojiButton(props), ]; moreButtons = [ uploadButton(), // props passed via UploadButtonContext @@ -87,9 +91,9 @@ const MessageComposerButtons: React.FC = (props: IProps) => { ]; } else { mainButtons = [ - emojiButton(props), - props.showComposerModeButton && - , + isWysiwygLabEnabled ? + : + emojiButton(props), uploadButton(), // props passed via UploadButtonContext ]; moreButtons = [ @@ -139,58 +143,10 @@ function emojiButton(props: IProps): ReactElement { key="emoji_button" addEmoji={props.addEmoji} menuPosition={props.menuPosition} + className="mx_MessageComposer_button" />; } -interface IEmojiButtonProps { - addEmoji: (unicode: string) => boolean; - menuPosition: AboveLeftOf; -} - -const EmojiButton: React.FC = ({ 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 = { - closeMenu(); - overflowMenuCloser?.(); - }} - managed={false} - > - - ; - } - - 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 - - - { contextMenu } - ; -}; - function uploadButton(): ReactElement { return ; } @@ -408,7 +364,7 @@ interface 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 ( - function Content({ disabled, composerFunctions }: ContentProps, forwardRef: RefObject) { - useWysiwygSendActionHandler(disabled, forwardRef, composerFunctions); + function Content( + { disabled = false, composerFunctions }: ContentProps, + forwardRef: ForwardedRef, + ) { + useWysiwygSendActionHandler(disabled, forwardRef as MutableRefObject, composerFunctions); return null; }, ); @@ -37,14 +44,23 @@ interface SendWysiwygComposerProps { initialContent?: string; isRichTextEnabled: boolean; disabled?: boolean; + e2eStatus?: E2EStatus; onChange: (content: string) => void; onSend: () => void; + menuPosition: AboveLeftOf; } -export function SendWysiwygComposer({ isRichTextEnabled, ...props }: SendWysiwygComposerProps) { +export function SendWysiwygComposer( + { isRichTextEnabled, e2eStatus, menuPosition, ...props }: SendWysiwygComposerProps) { const Composer = isRichTextEnabled ? WysiwygComposer : PlainTextComposer; - return + return } + // TODO add emoji support + rightComponent={ false} />} + {...props} + > { (ref, composerFunctions) => ( ) } diff --git a/src/components/views/rooms/wysiwyg_composer/components/Editor.tsx b/src/components/views/rooms/wysiwyg_composer/components/Editor.tsx index cca66f6c38..edfd679ee5 100644 --- a/src/components/views/rooms/wysiwyg_composer/components/Editor.tsx +++ b/src/components/views/rooms/wysiwyg_composer/components/Editor.tsx @@ -14,27 +14,43 @@ See the License for the specific language governing permissions and 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 { disabled: boolean; + leftComponent?: ReactNode; + rightComponent?: ReactNode; } export const Editor = memo( forwardRef( - function Editor({ disabled }: EditorProps, ref, + function Editor({ disabled, leftComponent, rightComponent }: EditorProps, ref, ) { - return
-
+ const isExpanded = useIsExpanded(ref as MutableRefObject, HEIGHT_BREAKING_POINT); + + return
+ { leftComponent } +
+
+
+ { rightComponent }
; }, ), diff --git a/src/components/views/rooms/wysiwyg_composer/components/PlainTextComposer.tsx b/src/components/views/rooms/wysiwyg_composer/components/PlainTextComposer.tsx index e15b5ef57f..e80d19ad10 100644 --- a/src/components/views/rooms/wysiwyg_composer/components/PlainTextComposer.tsx +++ b/src/components/views/rooms/wysiwyg_composer/components/PlainTextComposer.tsx @@ -14,9 +14,11 @@ See the License for the specific language governing permissions and limitations under the License. */ +import classNames from 'classnames'; import React, { MutableRefObject, ReactNode } from 'react'; import { useComposerFunctions } from '../hooks/useComposerFunctions'; +import { useIsFocused } from '../hooks/useIsFocused'; import { usePlainTextInitialization } from '../hooks/usePlainTextInitialization'; import { usePlainTextListeners } from '../hooks/usePlainTextListeners'; import { useSetCursorPosition } from '../hooks/useSetCursorPosition'; @@ -26,9 +28,11 @@ import { Editor } from "./Editor"; interface PlainTextComposerProps { disabled?: boolean; onChange?: (content: string) => void; - onSend: () => void; + onSend?: () => void; initialContent?: string; className?: string; + leftComponent?: ReactNode; + rightComponent?: ReactNode; children?: ( ref: MutableRefObject, composerFunctions: ComposerFunctions, @@ -36,21 +40,32 @@ interface PlainTextComposerProps { } 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 composerFunctions = useComposerFunctions(ref); usePlainTextInitialization(initialContent, ref); useSetCursorPosition(disabled, ref); + const { isFocused, onFocus } = useIsFocused(); return
- + { children?.(ref, composerFunctions) }
; } diff --git a/src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer.tsx b/src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer.tsx index 974e89f0ce..e687d4b3b6 100644 --- a/src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer.tsx +++ b/src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer.tsx @@ -16,11 +16,13 @@ limitations under the License. import React, { memo, MutableRefObject, ReactNode, useEffect } from 'react'; import { useWysiwyg, FormattingFunctions } from "@matrix-org/matrix-wysiwyg"; +import classNames from 'classnames'; import { FormattingButtons } from './FormattingButtons'; import { Editor } from './Editor'; import { useInputEventProcessor } from '../hooks/useInputEventProcessor'; import { useSetCursorPosition } from '../hooks/useSetCursorPosition'; +import { useIsFocused } from '../hooks/useIsFocused'; interface WysiwygComposerProps { disabled?: boolean; @@ -28,6 +30,8 @@ interface WysiwygComposerProps { onSend: () => void; initialContent?: string; className?: string; + leftComponent?: ReactNode; + rightComponent?: ReactNode; children?: ( ref: MutableRefObject, wysiwyg: FormattingFunctions, @@ -35,7 +39,16 @@ interface WysiwygComposerProps { } 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); @@ -51,10 +64,12 @@ export const WysiwygComposer = memo(function WysiwygComposer( const isReady = isWysiwygReady && !disabled; useSetCursorPosition(!isReady, ref); + const { isFocused, onFocus } = useIsFocused(); + return ( -
+
- + { children?.(ref, wysiwyg) }
); diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/useIsExpanded.ts b/src/components/views/rooms/wysiwyg_composer/hooks/useIsExpanded.ts new file mode 100644 index 0000000000..c7758917b1 --- /dev/null +++ b/src/components/views/rooms/wysiwyg_composer/hooks/useIsExpanded.ts @@ -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, 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; +} diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/useIsFocused.ts b/src/components/views/rooms/wysiwyg_composer/hooks/useIsFocused.ts new file mode 100644 index 0000000000..99e6dbd9c8 --- /dev/null +++ b/src/components/views/rooms/wysiwyg_composer/hooks/useIsFocused.ts @@ -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(); + + useEffect(() => () => clearTimeout(timeoutIDRef.current), [timeoutIDRef]); + const onFocus = useCallback((event: FocusEvent) => { + 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 }; +} diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/usePlainTextInitialization.ts b/src/components/views/rooms/wysiwyg_composer/hooks/usePlainTextInitialization.ts index abf2a6a6d2..5353b9404d 100644 --- a/src/components/views/rooms/wysiwyg_composer/hooks/usePlainTextInitialization.ts +++ b/src/components/views/rooms/wysiwyg_composer/hooks/usePlainTextInitialization.ts @@ -16,7 +16,7 @@ limitations under the License. import { RefObject, useEffect } from "react"; -export function usePlainTextInitialization(initialContent: string, ref: RefObject) { +export function usePlainTextInitialization(initialContent = '', ref: RefObject) { useEffect(() => { if (ref.current) { ref.current.innerText = initialContent; diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/usePlainTextListeners.ts b/src/components/views/rooms/wysiwyg_composer/hooks/usePlainTextListeners.ts index 02063ddcfb..b47da17368 100644 --- a/src/components/views/rooms/wysiwyg_composer/hooks/usePlainTextListeners.ts +++ b/src/components/views/rooms/wysiwyg_composer/hooks/usePlainTextListeners.ts @@ -22,18 +22,18 @@ function isDivElement(target: EventTarget): target is HTMLDivElement { return target instanceof HTMLDivElement; } -export function usePlainTextListeners(onChange: (content: string) => void, onSend: () => void) { - const ref = useRef(); +export function usePlainTextListeners(onChange?: (content: string) => void, onSend?: () => void) { + const ref = useRef(null); const send = useCallback((() => { if (ref.current) { ref.current.innerHTML = ''; } - onSend(); + onSend?.(); }), [ref, onSend]); const onInput = useCallback((event: SyntheticEvent) => { if (isDivElement(event.target)) { - onChange(event.target.innerHTML); + onChange?.(event.target.innerHTML); } }, [onChange]); diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/useWysiwygEditActionHandler.ts b/src/components/views/rooms/wysiwyg_composer/hooks/useWysiwygEditActionHandler.ts index b39fe18007..e05e04d39d 100644 --- a/src/components/views/rooms/wysiwyg_composer/hooks/useWysiwygEditActionHandler.ts +++ b/src/components/views/rooms/wysiwyg_composer/hooks/useWysiwygEditActionHandler.ts @@ -28,7 +28,7 @@ export function useWysiwygEditActionHandler( composerElement: RefObject, ) { const roomContext = useRoomContext(); - const timeoutId = useRef(); + const timeoutId = useRef(null); const handler = useCallback((payload: ActionPayload) => { // don't let the user into the composer if it is disabled - all of these branches lead diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/useWysiwygSendActionHandler.ts b/src/components/views/rooms/wysiwyg_composer/hooks/useWysiwygSendActionHandler.ts index 49c6302d5b..500f027049 100644 --- a/src/components/views/rooms/wysiwyg_composer/hooks/useWysiwygSendActionHandler.ts +++ b/src/components/views/rooms/wysiwyg_composer/hooks/useWysiwygSendActionHandler.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { RefObject, useCallback, useRef } from "react"; +import { MutableRefObject, useCallback, useRef } from "react"; import defaultDispatcher from "../../../../../dispatcher/dispatcher"; import { Action } from "../../../../../dispatcher/actions"; @@ -26,16 +26,16 @@ import { ComposerFunctions } from "../types"; export function useWysiwygSendActionHandler( disabled: boolean, - composerElement: RefObject, + composerElement: MutableRefObject, composerFunctions: ComposerFunctions, ) { const roomContext = useRoomContext(); - const timeoutId = useRef(); + const timeoutId = useRef(null); const handler = useCallback((payload: ActionPayload) => { // don't let the user into the composer if it is disabled - all of these branches lead // to the cursor being in the composer - if (disabled || !composerElement.current) return; + if (disabled || !composerElement?.current) return; const context = payload.context ?? TimelineRenderingType.Room; diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/utils.ts b/src/components/views/rooms/wysiwyg_composer/hooks/utils.ts index bfaf526f72..5b76703820 100644 --- a/src/components/views/rooms/wysiwyg_composer/hooks/utils.ts +++ b/src/components/views/rooms/wysiwyg_composer/hooks/utils.ts @@ -14,14 +14,16 @@ See the License for the specific language governing permissions and limitations under the License. */ +import { MutableRefObject } from "react"; + import { TimelineRenderingType } from "../../../../../contexts/RoomContext"; import { IRoomState } from "../../../../structures/RoomView"; export function focusComposer( - composerElement: React.MutableRefObject, + composerElement: MutableRefObject, renderingType: TimelineRenderingType, roomContext: IRoomState, - timeoutId: React.MutableRefObject, + timeoutId: MutableRefObject, ) { if (renderingType === roomContext.timelineRenderingType) { // Immediately set the focus, so if you start typing it diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 966f3c04e3..2dd52d7b03 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1829,6 +1829,7 @@ "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", "Edit message": "Edit message", + "Emoji": "Emoji", "Mod": "Mod", "From a thread": "From a thread", "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", "%(seconds)ss left": "%(seconds)ss left", "Send voice message": "Send voice message", - "Emoji": "Emoji", "Hide stickers": "Hide stickers", "Sticker": "Sticker", "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.", "Poll": "Poll", - "Show plain text": "Show plain text", + "Hide formatting": "Hide formatting", "Show formatting": "Show formatting", "Bold": "Bold", "Italics": "Italics", diff --git a/test/components/structures/__snapshots__/RoomView-test.tsx.snap b/test/components/structures/__snapshots__/RoomView-test.tsx.snap index 66481e079b..33f44f9a37 100644 --- a/test/components/structures/__snapshots__/RoomView-test.tsx.snap +++ b/test/components/structures/__snapshots__/RoomView-test.tsx.snap @@ -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`] = `"
@user:example.com
  1. End-to-end encryption isn't enabled
    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.

    @user:example.com

    Send your first message to invite @user:example.com to chat

!
Some of your messages have not been sent
Retry
"`; -exports[`RoomView for a local room in state NEW should match the snapshot 1`] = `"
@user:example.com
  1. End-to-end encryption isn't enabled
    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.

    @user:example.com

    Send your first message to invite @user:example.com to chat


"`; +exports[`RoomView for a local room in state NEW should match the snapshot 1`] = `"
@user:example.com
  1. End-to-end encryption isn't enabled
    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.

    @user:example.com

    Send your first message to invite @user:example.com to chat


"`; -exports[`RoomView for a local room in state NEW that is encrypted should match the snapshot 1`] = `"
@user:example.com
    Encryption enabled
    Messages in this chat will be end-to-end encrypted.
  1. @user:example.com

    Send your first message to invite @user:example.com to chat


"`; +exports[`RoomView for a local room in state NEW that is encrypted should match the snapshot 1`] = `"
@user:example.com
    Encryption enabled
    Messages in this chat will be end-to-end encrypted.
  1. @user:example.com

    Send your first message to invite @user:example.com to chat


"`; diff --git a/test/components/views/rooms/wysiwyg_composer/SendWysiwygComposer-test.tsx b/test/components/views/rooms/wysiwyg_composer/SendWysiwygComposer-test.tsx index c85692d221..3b5b8885d8 100644 --- a/test/components/views/rooms/wysiwyg_composer/SendWysiwygComposer-test.tsx +++ b/test/components/views/rooms/wysiwyg_composer/SendWysiwygComposer-test.tsx @@ -28,6 +28,7 @@ import { createTestClient, flushPromises, getRoomContext, mkEvent, mkStubRoom } import { SendWysiwygComposer } from "../../../../../src/components/views/rooms/wysiwyg_composer"; import * as useComposerFunctions from "../../../../../src/components/views/rooms/wysiwyg_composer/hooks/useComposerFunctions"; +import { aboveLeftOf } from "../../../../../src/components/structures/ContextMenu"; const mockClear = jest.fn(); @@ -78,7 +79,7 @@ describe('SendWysiwygComposer', () => { return render( - + , ); diff --git a/test/components/views/rooms/wysiwyg_composer/components/PlainTextComposer-test.tsx b/test/components/views/rooms/wysiwyg_composer/components/PlainTextComposer-test.tsx index 5d1b03020c..9c2e10100f 100644 --- a/test/components/views/rooms/wysiwyg_composer/components/PlainTextComposer-test.tsx +++ b/test/components/views/rooms/wysiwyg_composer/components/PlainTextComposer-test.tsx @@ -21,10 +21,6 @@ import userEvent from "@testing-library/user-event"; import { PlainTextComposer } from "../../../../../../src/components/views/rooms/wysiwyg_composer/components/PlainTextComposer"; -// Work around missing ClipboardEvent type -class MyClipboardEvent {} -window.ClipboardEvent = MyClipboardEvent as any; - describe('PlainTextComposer', () => { const customRender = ( onChange = (_content: string) => void 0, @@ -91,4 +87,46 @@ describe('PlainTextComposer', () => { // Then 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( + , + ); + + // 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(); + }); }); diff --git a/test/components/views/rooms/wysiwyg_composer/components/WysiwygComposer-test.tsx b/test/components/views/rooms/wysiwyg_composer/components/WysiwygComposer-test.tsx index 7e3db04abc..64be2edfb3 100644 --- a/test/components/views/rooms/wysiwyg_composer/components/WysiwygComposer-test.tsx +++ b/test/components/views/rooms/wysiwyg_composer/components/WysiwygComposer-test.tsx @@ -23,10 +23,6 @@ import { WysiwygComposer } from "../../../../../../src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer"; import SettingsStore from "../../../../../../src/settings/SettingsStore"; -// Work around missing ClipboardEvent type -class MyClipboardEvent {} -window.ClipboardEvent = MyClipboardEvent as any; - 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 diff --git a/test/setup/setupManualMocks.ts b/test/setup/setupManualMocks.ts index 2adda89e0f..3510ee1e8c 100644 --- a/test/setup/setupManualMocks.ts +++ b/test/setup/setupManualMocks.ts @@ -31,6 +31,29 @@ class 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 const mockMatchMedia = jest.fn().mockImplementation(query => ({ matches: false, @@ -51,6 +74,7 @@ global.URL.revokeObjectURL = jest.fn(); // polyfilling TextEncoder as it is not available on JSDOM // view https://github.com/facebook/jest/issues/9983 global.TextEncoder = TextEncoder; +// @ts-ignore global.TextDecoder = TextDecoder; // prevent errors whenever a component tries to manually scroll. @@ -60,4 +84,5 @@ window.HTMLElement.prototype.scrollIntoView = jest.fn(); fetchMock.config.overwriteRoutes = false; fetchMock.catch(""); fetchMock.get("/image-file-stub", "image file stub"); +// @ts-ignore window.fetch = fetchMock.sandbox();