diff --git a/package.json b/package.json index f54c226482..4d5a725671 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,7 @@ "dependencies": { "@babel/runtime": "^7.12.5", "@matrix-org/analytics-events": "^0.3.0", - "@matrix-org/matrix-wysiwyg": "^0.9.0", + "@matrix-org/matrix-wysiwyg": "^0.11.0", "@matrix-org/react-sdk-module-api": "^0.0.3", "@sentry/browser": "^7.0.0", "@sentry/tracing": "^7.0.0", diff --git a/res/css/_components.pcss b/res/css/_components.pcss index 9805f6eae1..cb025f5b8c 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -308,6 +308,7 @@ @import "./views/rooms/wysiwyg_composer/_SendWysiwygComposer.pcss"; @import "./views/rooms/wysiwyg_composer/components/_Editor.pcss"; @import "./views/rooms/wysiwyg_composer/components/_FormattingButtons.pcss"; +@import "./views/rooms/wysiwyg_composer/components/_LinkModal.pcss"; @import "./views/settings/_AvatarSetting.pcss"; @import "./views/settings/_CrossSigningPanel.pcss"; @import "./views/settings/_CryptographyPanel.pcss"; diff --git a/res/css/views/rooms/wysiwyg_composer/components/_LinkModal.pcss b/res/css/views/rooms/wysiwyg_composer/components/_LinkModal.pcss new file mode 100644 index 0000000000..5bd02cc245 --- /dev/null +++ b/res/css/views/rooms/wysiwyg_composer/components/_LinkModal.pcss @@ -0,0 +1,29 @@ +/* +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. +*/ + +.mx_LinkModal { + padding: $spacing-32; + + .mx_Dialog_content { + margin-top: 30px; + margin-bottom: 42px; + } + + .mx_LinkModal_content { + display: flex; + flex-direction: column; + } +} diff --git a/res/img/element-icons/room/composer/link.svg b/res/img/element-icons/room/composer/link.svg new file mode 100644 index 0000000000..8c7429e16a --- /dev/null +++ b/res/img/element-icons/room/composer/link.svg @@ -0,0 +1,3 @@ +<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M8.37751 4.10063L8.73382 3.74433C9.718 2.76019 11.3042 2.75074 12.2767 3.72324C13.2493 4.69573 13.2398 6.2819 12.2556 7.26605L10.5627 8.9589C9.57856 9.94304 7.99234 9.95249 7.01981 8.98M7.62266 11.8992L7.26619 12.2557C6.28201 13.2398 4.69578 13.2493 3.72325 12.2768C2.75073 11.3043 2.76018 9.7181 3.74436 8.73395L5.43717 7.0412C6.42134 6.05706 8.00756 6.04761 8.98009 7.0201" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/> +</svg> diff --git a/src/components/views/rooms/wysiwyg_composer/ComposerContext.ts b/src/components/views/rooms/wysiwyg_composer/ComposerContext.ts new file mode 100644 index 0000000000..8c512f1cd5 --- /dev/null +++ b/src/components/views/rooms/wysiwyg_composer/ComposerContext.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 { createContext, useContext } from "react"; + +import { SubSelection } from "./types"; + +export function getDefaultContextValue(): { selection: SubSelection } { + return { + selection: { anchorNode: null, anchorOffset: 0, focusNode: null, focusOffset: 0 }, + }; +} + +export interface ComposerContextState { + selection: SubSelection; +} + +export const ComposerContext = createContext<ComposerContextState>(getDefaultContextValue()); +ComposerContext.displayName = "ComposerContext"; + +export function useComposerContext() { + return useContext(ComposerContext); +} diff --git a/src/components/views/rooms/wysiwyg_composer/EditWysiwygComposer.tsx b/src/components/views/rooms/wysiwyg_composer/EditWysiwygComposer.tsx index 36264b7763..2c935b1240 100644 --- a/src/components/views/rooms/wysiwyg_composer/EditWysiwygComposer.tsx +++ b/src/components/views/rooms/wysiwyg_composer/EditWysiwygComposer.tsx @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { forwardRef, RefObject } from "react"; +import React, { forwardRef, RefObject, useRef } from "react"; import classNames from "classnames"; import EditorStateTransfer from "../../../../utils/EditorStateTransfer"; @@ -23,6 +23,7 @@ import { EditionButtons } from "./components/EditionButtons"; import { useWysiwygEditActionHandler } from "./hooks/useWysiwygEditActionHandler"; import { useEditing } from "./hooks/useEditing"; import { useInitialContent } from "./hooks/useInitialContent"; +import { ComposerContext, getDefaultContextValue } from "./ComposerContext"; interface ContentProps { disabled: boolean; @@ -45,6 +46,7 @@ interface EditWysiwygComposerProps { // Default needed for React.lazy export default function EditWysiwygComposer({ editorStateTransfer, className, ...props }: EditWysiwygComposerProps) { + const defaultContextValue = useRef(getDefaultContextValue()); const initialContent = useInitialContent(editorStateTransfer); const isReady = !editorStateTransfer || initialContent !== undefined; @@ -55,23 +57,25 @@ export default function EditWysiwygComposer({ editorStateTransfer, className, .. } return ( - <WysiwygComposer - className={classNames("mx_EditWysiwygComposer", className)} - initialContent={initialContent} - onChange={onChange} - onSend={editMessage} - {...props} - > - {(ref) => ( - <> - <Content disabled={props.disabled} ref={ref} /> - <EditionButtons - onCancelClick={endEditing} - onSaveClick={editMessage} - isSaveDisabled={isSaveDisabled} - /> - </> - )} - </WysiwygComposer> + <ComposerContext.Provider value={defaultContextValue.current}> + <WysiwygComposer + className={classNames("mx_EditWysiwygComposer", className)} + initialContent={initialContent} + onChange={onChange} + onSend={editMessage} + {...props} + > + {(ref) => ( + <> + <Content disabled={props.disabled} ref={ref} /> + <EditionButtons + onCancelClick={endEditing} + onSaveClick={editMessage} + isSaveDisabled={isSaveDisabled} + /> + </> + )} + </WysiwygComposer> + </ComposerContext.Provider> ); } diff --git a/src/components/views/rooms/wysiwyg_composer/SendWysiwygComposer.tsx b/src/components/views/rooms/wysiwyg_composer/SendWysiwygComposer.tsx index 78b24bb507..0067539ead 100644 --- a/src/components/views/rooms/wysiwyg_composer/SendWysiwygComposer.tsx +++ b/src/components/views/rooms/wysiwyg_composer/SendWysiwygComposer.tsx @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { ForwardedRef, forwardRef, MutableRefObject } from "react"; +import React, { ForwardedRef, forwardRef, MutableRefObject, useRef } from "react"; import { useWysiwygSendActionHandler } from "./hooks/useWysiwygSendActionHandler"; import { WysiwygComposer } from "./components/WysiwygComposer"; @@ -24,6 +24,7 @@ import { E2EStatus } from "../../../../utils/ShieldUtils"; import E2EIcon from "../E2EIcon"; import { AboveLeftOf } from "../../../structures/ContextMenu"; import { Emoji } from "./components/Emoji"; +import { ComposerContext, getDefaultContextValue } from "./ComposerContext"; interface ContentProps { disabled?: boolean; @@ -57,19 +58,20 @@ export default function SendWysiwygComposer({ ...props }: SendWysiwygComposerProps) { const Composer = isRichTextEnabled ? WysiwygComposer : PlainTextComposer; + const defaultContextValue = useRef(getDefaultContextValue()); return ( - <Composer - className="mx_SendWysiwygComposer" - leftComponent={e2eStatus && <E2EIcon status={e2eStatus} />} - rightComponent={(selectPreviousSelection) => ( - <Emoji menuPosition={menuPosition} selectPreviousSelection={selectPreviousSelection} /> - )} - {...props} - > - {(ref, composerFunctions) => ( - <Content disabled={props.disabled} ref={ref} composerFunctions={composerFunctions} /> - )} - </Composer> + <ComposerContext.Provider value={defaultContextValue.current}> + <Composer + className="mx_SendWysiwygComposer" + leftComponent={e2eStatus && <E2EIcon status={e2eStatus} />} + rightComponent={<Emoji menuPosition={menuPosition} />} + {...props} + > + {(ref, composerFunctions) => ( + <Content disabled={props.disabled} ref={ref} composerFunctions={composerFunctions} /> + )} + </Composer> + </ComposerContext.Provider> ); } diff --git a/src/components/views/rooms/wysiwyg_composer/components/Editor.tsx b/src/components/views/rooms/wysiwyg_composer/components/Editor.tsx index e0c6c3b394..215d3be84f 100644 --- a/src/components/views/rooms/wysiwyg_composer/components/Editor.tsx +++ b/src/components/views/rooms/wysiwyg_composer/components/Editor.tsx @@ -26,7 +26,7 @@ interface EditorProps { disabled: boolean; placeholder?: string; leftComponent?: ReactNode; - rightComponent?: (selectPreviousSelection: () => void) => ReactNode; + rightComponent?: ReactNode; } export const Editor = memo( @@ -35,7 +35,7 @@ export const Editor = memo( ref, ) { const isExpanded = useIsExpanded(ref as MutableRefObject<HTMLDivElement | null>, HEIGHT_BREAKING_POINT); - const { onFocus, onBlur, selectPreviousSelection, onInput } = useSelection(); + const { onFocus, onBlur, onInput } = useSelection(); return ( <div @@ -63,7 +63,7 @@ export const Editor = memo( onInput={onInput} /> </div> - {rightComponent?.(selectPreviousSelection)} + {rightComponent} </div> ); }), diff --git a/src/components/views/rooms/wysiwyg_composer/components/Emoji.tsx b/src/components/views/rooms/wysiwyg_composer/components/Emoji.tsx index b36d5c635e..5a4b356f34 100644 --- a/src/components/views/rooms/wysiwyg_composer/components/Emoji.tsx +++ b/src/components/views/rooms/wysiwyg_composer/components/Emoji.tsx @@ -22,25 +22,28 @@ import dis from "../../../../../dispatcher/dispatcher"; import { ComposerInsertPayload } from "../../../../../dispatcher/payloads/ComposerInsertPayload"; import { Action } from "../../../../../dispatcher/actions"; import { useRoomContext } from "../../../../../contexts/RoomContext"; +import { useComposerContext } from "../ComposerContext"; +import { setSelection } from "../utils/selection"; interface EmojiProps { - selectPreviousSelection: () => void; menuPosition: AboveLeftOf; } -export function Emoji({ selectPreviousSelection, menuPosition }: EmojiProps) { +export function Emoji({ menuPosition }: EmojiProps) { const roomContext = useRoomContext(); + const composerContext = useComposerContext(); return ( <EmojiButton menuPosition={menuPosition} addEmoji={(emoji) => { - selectPreviousSelection(); - dis.dispatch<ComposerInsertPayload>({ - action: Action.ComposerInsert, - text: emoji, - timelineRenderingType: roomContext.timelineRenderingType, - }); + setSelection(composerContext.selection).then(() => + dis.dispatch<ComposerInsertPayload>({ + action: Action.ComposerInsert, + text: emoji, + timelineRenderingType: roomContext.timelineRenderingType, + }), + ); return true; }} /> diff --git a/src/components/views/rooms/wysiwyg_composer/components/FormattingButtons.tsx b/src/components/views/rooms/wysiwyg_composer/components/FormattingButtons.tsx index ec8157926d..d2cafb1198 100644 --- a/src/components/views/rooms/wysiwyg_composer/components/FormattingButtons.tsx +++ b/src/components/views/rooms/wysiwyg_composer/components/FormattingButtons.tsx @@ -23,12 +23,15 @@ import { Icon as ItalicIcon } from "../../../../../../res/img/element-icons/room import { Icon as UnderlineIcon } from "../../../../../../res/img/element-icons/room/composer/underline.svg"; import { Icon as StrikeThroughIcon } from "../../../../../../res/img/element-icons/room/composer/strikethrough.svg"; import { Icon as InlineCodeIcon } from "../../../../../../res/img/element-icons/room/composer/inline_code.svg"; +import { Icon as LinkIcon } from "../../../../../../res/img/element-icons/room/composer/link.svg"; import AccessibleTooltipButton from "../../../elements/AccessibleTooltipButton"; import { Alignment } from "../../../elements/Tooltip"; import { KeyboardShortcut } from "../../../settings/KeyboardShortcut"; import { KeyCombo } from "../../../../../KeyBindingsManager"; import { _td } from "../../../../../languageHandler"; import { ButtonEvent } from "../../../elements/AccessibleButton"; +import { openLinkModal } from "./LinkModal"; +import { useComposerContext } from "../ComposerContext"; interface TooltipProps { label: string; @@ -76,6 +79,8 @@ interface FormattingButtonsProps { } export function FormattingButtons({ composer, actionStates }: FormattingButtonsProps) { + const composerContext = useComposerContext(); + return ( <div className="mx_FormattingButtons"> <Button @@ -112,6 +117,12 @@ export function FormattingButtons({ composer, actionStates }: FormattingButtonsP onClick={() => composer.inlineCode()} icon={<InlineCodeIcon className="mx_FormattingButtons_Icon" />} /> + <Button + isActive={actionStates.link === "reversed"} + label={_td("Link")} + onClick={() => openLinkModal(composer, composerContext)} + icon={<LinkIcon className="mx_FormattingButtons_Icon" />} + /> </div> ); } diff --git a/src/components/views/rooms/wysiwyg_composer/components/LinkModal.tsx b/src/components/views/rooms/wysiwyg_composer/components/LinkModal.tsx new file mode 100644 index 0000000000..2dcfc43ead --- /dev/null +++ b/src/components/views/rooms/wysiwyg_composer/components/LinkModal.tsx @@ -0,0 +1,90 @@ +/* +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 { FormattingFunctions } from "@matrix-org/matrix-wysiwyg"; +import React, { ChangeEvent, useState } from "react"; + +import { _td } from "../../../../../languageHandler"; +import Modal from "../../../../../Modal"; +import QuestionDialog from "../../../dialogs/QuestionDialog"; +import Field from "../../../elements/Field"; +import { ComposerContextState } from "../ComposerContext"; +import { isSelectionEmpty, setSelection } from "../utils/selection"; + +export function openLinkModal(composer: FormattingFunctions, composerContext: ComposerContextState) { + const modal = Modal.createDialog( + LinkModal, + { composerContext, composer, onClose: () => modal.close(), isTextEnabled: isSelectionEmpty() }, + "mx_CompoundDialog", + false, + true, + ); +} + +function isEmpty(text: string) { + return text.length < 1; +} + +interface LinkModalProps { + composer: FormattingFunctions; + isTextEnabled: boolean; + onClose: () => void; + composerContext: ComposerContextState; +} + +export function LinkModal({ composer, isTextEnabled, onClose, composerContext }: LinkModalProps) { + const [fields, setFields] = useState({ text: "", link: "" }); + const isSaveDisabled = (isTextEnabled && isEmpty(fields.text)) || isEmpty(fields.link); + + return ( + <QuestionDialog + className="mx_LinkModal" + title={_td("Create a link")} + button={_td("Save")} + buttonDisabled={isSaveDisabled} + hasCancelButton={true} + onFinished={async (isClickOnSave: boolean) => { + if (isClickOnSave) { + await setSelection(composerContext.selection); + composer.link(fields.link, isTextEnabled ? fields.text : undefined); + } + onClose(); + }} + description={ + <div className="mx_LinkModal_content"> + {isTextEnabled && ( + <Field + autoFocus={true} + label={_td("Text")} + value={fields.text} + onChange={(e: ChangeEvent<HTMLInputElement>) => + setFields((fields) => ({ ...fields, text: e.target.value })) + } + /> + )} + <Field + autoFocus={!isTextEnabled} + label={_td("Link")} + value={fields.link} + onChange={(e: ChangeEvent<HTMLInputElement>) => + setFields((fields) => ({ ...fields, link: e.target.value })) + } + /> + </div> + } + /> + ); +} diff --git a/src/components/views/rooms/wysiwyg_composer/components/PlainTextComposer.tsx b/src/components/views/rooms/wysiwyg_composer/components/PlainTextComposer.tsx index a9cf2411d2..868002810f 100644 --- a/src/components/views/rooms/wysiwyg_composer/components/PlainTextComposer.tsx +++ b/src/components/views/rooms/wysiwyg_composer/components/PlainTextComposer.tsx @@ -33,7 +33,7 @@ interface PlainTextComposerProps { initialContent?: string; className?: string; leftComponent?: ReactNode; - rightComponent?: (selectPreviousSelection: () => void) => ReactNode; + rightComponent?: ReactNode; children?: (ref: MutableRefObject<HTMLDivElement | null>, composerFunctions: ComposerFunctions) => ReactNode; } diff --git a/src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer.tsx b/src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer.tsx index b41e144e0f..eb0e3c068f 100644 --- a/src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer.tsx +++ b/src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer.tsx @@ -32,7 +32,7 @@ interface WysiwygComposerProps { initialContent?: string; className?: string; leftComponent?: ReactNode; - rightComponent?: (selectPreviousSelection: () => void) => ReactNode; + rightComponent?: ReactNode; children?: (ref: MutableRefObject<HTMLDivElement | null>, wysiwyg: FormattingFunctions) => ReactNode; } diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/useSelection.ts b/src/components/views/rooms/wysiwyg_composer/hooks/useSelection.ts index fc829ab9a3..29b927e4f2 100644 --- a/src/components/views/rooms/wysiwyg_composer/hooks/useSelection.ts +++ b/src/components/views/rooms/wysiwyg_composer/hooks/useSelection.ts @@ -14,18 +14,16 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { MutableRefObject, useCallback, useEffect, useRef } from "react"; +import { useCallback, useEffect } from "react"; import useFocus from "../../../../../hooks/useFocus"; -import { setSelection } from "../utils/selection"; +import { useComposerContext, ComposerContextState } from "../ComposerContext"; -type SubSelection = Pick<Selection, "anchorNode" | "anchorOffset" | "focusNode" | "focusOffset">; - -function setSelectionRef(selectionRef: MutableRefObject<SubSelection>) { +function setSelectionContext(composerContext: ComposerContextState) { const selection = document.getSelection(); if (selection) { - selectionRef.current = { + composerContext.selection = { anchorNode: selection.anchorNode, anchorOffset: selection.anchorOffset, focusNode: selection.focusNode, @@ -35,17 +33,12 @@ function setSelectionRef(selectionRef: MutableRefObject<SubSelection>) { } export function useSelection() { - const selectionRef = useRef<SubSelection>({ - anchorNode: null, - anchorOffset: 0, - focusNode: null, - focusOffset: 0, - }); + const composerContext = useComposerContext(); const [isFocused, focusProps] = useFocus(); useEffect(() => { function onSelectionChange() { - setSelectionRef(selectionRef); + setSelectionContext(composerContext); } if (isFocused) { @@ -53,15 +46,11 @@ export function useSelection() { } return () => document.removeEventListener("selectionchange", onSelectionChange); - }, [isFocused]); + }, [isFocused, composerContext]); const onInput = useCallback(() => { - setSelectionRef(selectionRef); - }, []); + setSelectionContext(composerContext); + }, [composerContext]); - const selectPreviousSelection = useCallback(() => { - setSelection(selectionRef.current); - }, []); - - return { ...focusProps, selectPreviousSelection, onInput }; + return { ...focusProps, onInput }; } diff --git a/src/components/views/rooms/wysiwyg_composer/types.ts b/src/components/views/rooms/wysiwyg_composer/types.ts index 6036793353..6505825b28 100644 --- a/src/components/views/rooms/wysiwyg_composer/types.ts +++ b/src/components/views/rooms/wysiwyg_composer/types.ts @@ -18,3 +18,5 @@ export type ComposerFunctions = { clear: () => void; insertText: (text: string) => void; }; + +export type SubSelection = Pick<Selection, "anchorNode" | "anchorOffset" | "focusNode" | "focusOffset">; diff --git a/src/components/views/rooms/wysiwyg_composer/utils/selection.ts b/src/components/views/rooms/wysiwyg_composer/utils/selection.ts index 5f36be975e..0390a3cefa 100644 --- a/src/components/views/rooms/wysiwyg_composer/utils/selection.ts +++ b/src/components/views/rooms/wysiwyg_composer/utils/selection.ts @@ -14,7 +14,9 @@ See the License for the specific language governing permissions and limitations under the License. */ -export function setSelection(selection: Pick<Selection, "anchorNode" | "anchorOffset" | "focusNode" | "focusOffset">) { +import { SubSelection } from "../types"; + +export function setSelection(selection: SubSelection) { if (selection.anchorNode && selection.focusNode) { const range = new Range(); range.setStart(selection.anchorNode, selection.anchorOffset); @@ -23,4 +25,12 @@ export function setSelection(selection: Pick<Selection, "anchorNode" | "anchorOf document.getSelection()?.removeAllRanges(); document.getSelection()?.addRange(range); } + + // Waiting for the next loop to ensure that the selection is effective + return new Promise((resolve) => setTimeout(resolve, 0)); +} + +export function isSelectionEmpty() { + const selection = document.getSelection(); + return Boolean(selection?.isCollapsed); } diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 63dd331bbf..37d850a12a 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -2130,6 +2130,9 @@ "Italic": "Italic", "Underline": "Underline", "Code": "Code", + "Link": "Link", + "Create a link": "Create a link", + "Text": "Text", "Message Actions": "Message Actions", "View in room": "View in room", "Copy link to thread": "Copy link to thread", diff --git a/test/components/views/rooms/wysiwyg_composer/EditWysiwygComposer-test.tsx b/test/components/views/rooms/wysiwyg_composer/EditWysiwygComposer-test.tsx index b442640ce6..045d4bf9cb 100644 --- a/test/components/views/rooms/wysiwyg_composer/EditWysiwygComposer-test.tsx +++ b/test/components/views/rooms/wysiwyg_composer/EditWysiwygComposer-test.tsx @@ -251,20 +251,20 @@ describe("EditWysiwygComposer", () => { expect(screen.getByRole("textbox")).not.toHaveFocus(); // When we send an action that would cause us to get focus - act(() => { - defaultDispatcher.dispatch({ - action: Action.FocusEditMessageComposer, - context: null, - }); - // (Send a second event to exercise the clearTimeout logic) - defaultDispatcher.dispatch({ - action: Action.FocusEditMessageComposer, - context: null, - }); + defaultDispatcher.dispatch({ + action: Action.FocusEditMessageComposer, + context: null, + }); + // (Send a second event to exercise the clearTimeout logic) + defaultDispatcher.dispatch({ + action: Action.FocusEditMessageComposer, + context: null, }); // Wait for event dispatch to happen - await flushPromises(); + await act(async () => { + await flushPromises(); + }); // Then we don't get it because we are disabled expect(screen.getByRole("textbox")).not.toHaveFocus(); diff --git a/test/components/views/rooms/wysiwyg_composer/SendWysiwygComposer-test.tsx b/test/components/views/rooms/wysiwyg_composer/SendWysiwygComposer-test.tsx index cdaf76d499..0b49da23e3 100644 --- a/test/components/views/rooms/wysiwyg_composer/SendWysiwygComposer-test.tsx +++ b/test/components/views/rooms/wysiwyg_composer/SendWysiwygComposer-test.tsx @@ -16,7 +16,7 @@ limitations under the License. import "@testing-library/jest-dom"; import React from "react"; -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext"; import RoomContext from "../../../../../src/contexts/RoomContext"; @@ -117,12 +117,9 @@ describe("SendWysiwygComposer", () => { expect(screen.getByTestId("PlainTextComposer")).toBeTruthy(); }); - describe.each([ - { isRichTextEnabled: true, emptyContent: "<br>" }, - { isRichTextEnabled: false, emptyContent: "" }, - ])( + describe.each([{ isRichTextEnabled: true }, { isRichTextEnabled: false }])( "Should focus when receiving an Action.FocusSendMessageComposer action", - ({ isRichTextEnabled, emptyContent }) => { + ({ isRichTextEnabled }) => { afterEach(() => { jest.resetAllMocks(); }); @@ -198,7 +195,9 @@ describe("SendWysiwygComposer", () => { }); // Wait for event dispatch to happen - await flushPromises(); + await act(async () => { + await flushPromises(); + }); // Then we don't get it because we are disabled expect(screen.getByRole("textbox")).not.toHaveFocus(); diff --git a/test/components/views/rooms/wysiwyg_composer/components/FormattingButtons-test.tsx b/test/components/views/rooms/wysiwyg_composer/components/FormattingButtons-test.tsx index d143e43a62..a467aa404e 100644 --- a/test/components/views/rooms/wysiwyg_composer/components/FormattingButtons-test.tsx +++ b/test/components/views/rooms/wysiwyg_composer/components/FormattingButtons-test.tsx @@ -20,6 +20,7 @@ import userEvent from "@testing-library/user-event"; import { AllActionStates, FormattingFunctions } from "@matrix-org/matrix-wysiwyg"; import { FormattingButtons } from "../../../../../../src/components/views/rooms/wysiwyg_composer/components/FormattingButtons"; +import * as LinkModal from "../../../../../../src/components/views/rooms/wysiwyg_composer/components/LinkModal"; describe("FormattingButtons", () => { const wysiwyg = { @@ -28,6 +29,7 @@ describe("FormattingButtons", () => { underline: jest.fn(), strikeThrough: jest.fn(), inlineCode: jest.fn(), + link: jest.fn(), } as unknown as FormattingFunctions; const actionStates = { @@ -36,6 +38,7 @@ describe("FormattingButtons", () => { underline: "enabled", strikeThrough: "enabled", inlineCode: "enabled", + link: "enabled", } as AllActionStates; afterEach(() => { @@ -52,16 +55,19 @@ describe("FormattingButtons", () => { expect(screen.getByLabelText("Underline")).not.toHaveClass("mx_FormattingButtons_active"); expect(screen.getByLabelText("Strikethrough")).not.toHaveClass("mx_FormattingButtons_active"); expect(screen.getByLabelText("Code")).not.toHaveClass("mx_FormattingButtons_active"); + expect(screen.getByLabelText("Link")).not.toHaveClass("mx_FormattingButtons_active"); }); it("Should call wysiwyg function on button click", () => { // When + const spy = jest.spyOn(LinkModal, "openLinkModal"); render(<FormattingButtons composer={wysiwyg} actionStates={actionStates} />); screen.getByLabelText("Bold").click(); screen.getByLabelText("Italic").click(); screen.getByLabelText("Underline").click(); screen.getByLabelText("Strikethrough").click(); screen.getByLabelText("Code").click(); + screen.getByLabelText("Link").click(); // Then expect(wysiwyg.bold).toHaveBeenCalledTimes(1); @@ -69,6 +75,7 @@ describe("FormattingButtons", () => { expect(wysiwyg.underline).toHaveBeenCalledTimes(1); expect(wysiwyg.strikeThrough).toHaveBeenCalledTimes(1); expect(wysiwyg.inlineCode).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledTimes(1); }); it("Should display the tooltip on mouse over", async () => { diff --git a/test/components/views/rooms/wysiwyg_composer/components/LinkModal-test.tsx b/test/components/views/rooms/wysiwyg_composer/components/LinkModal-test.tsx new file mode 100644 index 0000000000..c2fd1aeff2 --- /dev/null +++ b/test/components/views/rooms/wysiwyg_composer/components/LinkModal-test.tsx @@ -0,0 +1,132 @@ +/* +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 { FormattingFunctions } from "@matrix-org/matrix-wysiwyg"; +import { render, screen, waitFor } from "@testing-library/react"; +import React from "react"; +import userEvent from "@testing-library/user-event"; + +import { LinkModal } from "../../../../../../src/components/views/rooms/wysiwyg_composer/components/LinkModal"; +import { mockPlatformPeg } from "../../../../../test-utils"; +import * as selection from "../../../../../../src/components/views/rooms/wysiwyg_composer/utils/selection"; +import { SubSelection } from "../../../../../../src/components/views/rooms/wysiwyg_composer/types"; + +describe("LinkModal", () => { + const formattingFunctions = { + link: jest.fn(), + } as unknown as FormattingFunctions; + const defaultValue: SubSelection = { + focusNode: null, + anchorNode: null, + focusOffset: 3, + anchorOffset: 4, + }; + + const customRender = (isTextEnabled: boolean, onClose: () => void) => { + return render( + <LinkModal + composer={formattingFunctions} + isTextEnabled={isTextEnabled} + onClose={onClose} + composerContext={{ selection: defaultValue }} + />, + ); + }; + + const selectionSpy = jest.spyOn(selection, "setSelection"); + + beforeEach(() => mockPlatformPeg({ overrideBrowserShortcuts: jest.fn().mockReturnValue(false) })); + afterEach(() => { + jest.clearAllMocks(); + jest.useRealTimers(); + }); + + it("Should create a link", async () => { + // When + const onClose = jest.fn(); + customRender(false, onClose); + + // Then + expect(screen.getByLabelText("Link")).toBeTruthy(); + expect(screen.getByText("Save")).toBeDisabled(); + + // When + await userEvent.type(screen.getByLabelText("Link"), "l"); + + // Then + await waitFor(() => { + expect(screen.getByText("Save")).toBeEnabled(); + expect(screen.getByLabelText("Link")).toHaveAttribute("value", "l"); + }); + + // When + jest.useFakeTimers(); + screen.getByText("Save").click(); + + // Then + expect(selectionSpy).toHaveBeenCalledWith(defaultValue); + await waitFor(() => expect(onClose).toBeCalledTimes(1)); + + // When + jest.runAllTimers(); + + // Then + expect(formattingFunctions.link).toHaveBeenCalledWith("l", undefined); + }); + + it("Should create a link with text", async () => { + // When + const onClose = jest.fn(); + customRender(true, onClose); + + // Then + expect(screen.getByLabelText("Text")).toBeTruthy(); + expect(screen.getByLabelText("Link")).toBeTruthy(); + expect(screen.getByText("Save")).toBeDisabled(); + + // When + await userEvent.type(screen.getByLabelText("Text"), "t"); + + // Then + await waitFor(() => { + expect(screen.getByText("Save")).toBeDisabled(); + expect(screen.getByLabelText("Text")).toHaveAttribute("value", "t"); + }); + + // When + await userEvent.type(screen.getByLabelText("Link"), "l"); + + // Then + await waitFor(() => { + expect(screen.getByText("Save")).toBeEnabled(); + expect(screen.getByLabelText("Link")).toHaveAttribute("value", "l"); + }); + + // When + jest.useFakeTimers(); + screen.getByText("Save").click(); + + // Then + expect(selectionSpy).toHaveBeenCalledWith(defaultValue); + await waitFor(() => expect(onClose).toBeCalledTimes(1)); + + // When + jest.runAllTimers(); + + // Then + expect(formattingFunctions.link).toHaveBeenCalledWith("l", "t"); + }); +}); 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 bb41b18dc8..ed421f50af 100644 --- a/test/components/views/rooms/wysiwyg_composer/components/PlainTextComposer-test.tsx +++ b/test/components/views/rooms/wysiwyg_composer/components/PlainTextComposer-test.tsx @@ -15,7 +15,7 @@ limitations under the License. */ import React from "react"; -import { render, screen } from "@testing-library/react"; +import { act, render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { PlainTextComposer } from "../../../../../../src/components/views/rooms/wysiwyg_composer/components/PlainTextComposer"; @@ -106,10 +106,7 @@ describe("PlainTextComposer", () => { disconnect: jest.fn(), }; }); - jest.spyOn(global, "requestAnimationFrame").mockImplementation((cb) => { - cb(0); - return 0; - }); + jest.useFakeTimers(); //When render(<PlainTextComposer onChange={jest.fn()} onSend={jest.fn()} />); @@ -123,12 +120,15 @@ describe("PlainTextComposer", () => { [{ contentBoxSize: [{ blockSize: 100 }] } as unknown as ResizeObserverEntry], {} as ResizeObserver, ); - jest.runAllTimers(); + + act(() => { + jest.runAllTimers(); + }); // Then expect(screen.getByTestId("WysiwygComposerEditor").attributes["data-is-expanded"].value).toBe("true"); + jest.useRealTimers(); (global.ResizeObserver as jest.Mock).mockRestore(); - (global.requestAnimationFrame as jest.Mock).mockRestore(); }); }); diff --git a/yarn.lock b/yarn.lock index 70f580e7e7..0a0353d772 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1525,10 +1525,10 @@ resolved "https://registry.yarnpkg.com/@matrix-org/matrix-sdk-crypto-js/-/matrix-sdk-crypto-js-0.1.0-alpha.2.tgz#a09d0fea858e817da971a3c9f904632ef7b49eb6" integrity sha512-oVkBCh9YP7H9i4gAoQbZzswniczfo/aIptNa4dxRi4Ff9lSvUCFv6Hvzi7C+90c0/PWZLXjIDTIAWZYmwyd2fA== -"@matrix-org/matrix-wysiwyg@^0.9.0": - version "0.9.0" - resolved "https://registry.yarnpkg.com/@matrix-org/matrix-wysiwyg/-/matrix-wysiwyg-0.9.0.tgz#8651eacdc0bbfa313501e4feeb713c74dbf099cc" - integrity sha512-utxLZPSmBR/oKFeLLteAfqprhSW8prrH9IKzeMK1VswQYganPusYYO8u86kCQt4SuDz/1Zc8C7r76xmOiVJ9JQ== +"@matrix-org/matrix-wysiwyg@^0.11.0": + version "0.11.0" + resolved "https://registry.yarnpkg.com/@matrix-org/matrix-wysiwyg/-/matrix-wysiwyg-0.11.0.tgz#3000ee809a3e38242c5da47bef17c572582f2f6b" + integrity sha512-B16iLfNnW4PKG4fpDuwJVc0QUrUUqTkhwJ/kxzawcxwVNmWbsPCWJ3hkextYrN2gqRL1d4CNASkNbWLCNNiXhA== "@matrix-org/olm@https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.14.tgz": version "3.2.14"