({
+ 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"