diff --git a/package.json b/package.json index f54c2264829..4d5a725671a 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 9805f6eae1c..cb025f5b8c7 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 00000000000..5bd02cc245a --- /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 00000000000..8c7429e16a2 --- /dev/null +++ b/res/img/element-icons/room/composer/link.svg @@ -0,0 +1,3 @@ + + + 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 00000000000..8c512f1cd53 --- /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(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 36264b77639..2c935b12403 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 ( - - {(ref) => ( - <> - - - - )} - + + + {(ref) => ( + <> + + + + )} + + ); } diff --git a/src/components/views/rooms/wysiwyg_composer/SendWysiwygComposer.tsx b/src/components/views/rooms/wysiwyg_composer/SendWysiwygComposer.tsx index 78b24bb5072..0067539eadf 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 ( - } - rightComponent={(selectPreviousSelection) => ( - - )} - {...props} - > - {(ref, composerFunctions) => ( - - )} - + + } + rightComponent={} + {...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 e0c6c3b394d..215d3be84fb 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, HEIGHT_BREAKING_POINT); - const { onFocus, onBlur, selectPreviousSelection, onInput } = useSelection(); + const { onFocus, onBlur, onInput } = useSelection(); return (
- {rightComponent?.(selectPreviousSelection)} + {rightComponent} ); }), diff --git a/src/components/views/rooms/wysiwyg_composer/components/Emoji.tsx b/src/components/views/rooms/wysiwyg_composer/components/Emoji.tsx index b36d5c635ea..5a4b356f349 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 ( { - selectPreviousSelection(); - dis.dispatch({ - action: Action.ComposerInsert, - text: emoji, - timelineRenderingType: roomContext.timelineRenderingType, - }); + setSelection(composerContext.selection).then(() => + dis.dispatch({ + 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 ec8157926dc..d2cafb11989 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 (
); } 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 00000000000..2dcfc43eadf --- /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 ( + { + if (isClickOnSave) { + await setSelection(composerContext.selection); + composer.link(fields.link, isTextEnabled ? fields.text : undefined); + } + onClose(); + }} + description={ +
+ {isTextEnabled && ( + ) => + setFields((fields) => ({ ...fields, text: e.target.value })) + } + /> + )} + ) => + setFields((fields) => ({ ...fields, link: e.target.value })) + } + /> +
+ } + /> + ); +} diff --git a/src/components/views/rooms/wysiwyg_composer/components/PlainTextComposer.tsx b/src/components/views/rooms/wysiwyg_composer/components/PlainTextComposer.tsx index a9cf2411d2f..868002810f8 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, 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 b41e144e0f5..eb0e3c068fe 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, 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 fc829ab9a39..29b927e4f24 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; - -function setSelectionRef(selectionRef: MutableRefObject) { +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) { } export function useSelection() { - const selectionRef = useRef({ - 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); - }, []); - - const selectPreviousSelection = useCallback(() => { - setSelection(selectionRef.current); - }, []); + setSelectionContext(composerContext); + }, [composerContext]); - 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 60367933530..6505825b286 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; diff --git a/src/components/views/rooms/wysiwyg_composer/utils/selection.ts b/src/components/views/rooms/wysiwyg_composer/utils/selection.ts index 5f36be975e7..0390a3cefa9 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) { +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 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 63dd331bbfd..37d850a12a1 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 b442640ce63..045d4bf9cbd 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 cdaf76d499c..0b49da23e39 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: "
" }, - { 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 d143e43a628..a467aa404e7 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(); 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 00000000000..c2fd1aeff20 --- /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( + , + ); + }; + + 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 bb41b18dc8b..ed421f50afd 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(); @@ -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 70f580e7e7c..0a0353d7723 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"