diff --git a/package.json b/package.json
index 4e37694fbd..8b4754c83d 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.8.0",
+ "@matrix-org/matrix-wysiwyg": "^0.9.0",
"@matrix-org/react-sdk-module-api": "^0.0.3",
"@sentry/browser": "^7.0.0",
"@sentry/tracing": "^7.0.0",
diff --git a/src/components/views/rooms/wysiwyg_composer/SendWysiwygComposer.tsx b/src/components/views/rooms/wysiwyg_composer/SendWysiwygComposer.tsx
index a63a013cc4..bec2b9a08a 100644
--- a/src/components/views/rooms/wysiwyg_composer/SendWysiwygComposer.tsx
+++ b/src/components/views/rooms/wysiwyg_composer/SendWysiwygComposer.tsx
@@ -22,8 +22,8 @@ import { PlainTextComposer } from './components/PlainTextComposer';
import { ComposerFunctions } from './types';
import { E2EStatus } from '../../../../utils/ShieldUtils';
import E2EIcon from '../E2EIcon';
-import { EmojiButton } from '../EmojiButton';
import { AboveLeftOf } from '../../../structures/ContextMenu';
+import { Emoji } from './components/Emoji';
interface ContentProps {
disabled?: boolean;
@@ -58,8 +58,8 @@ export function SendWysiwygComposer(
return }
- // TODO add emoji support
- rightComponent={ false} />}
+ rightComponent={(selectPreviousSelection) =>
+ }
{...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 6ebd189089..b738847ec6 100644
--- a/src/components/views/rooms/wysiwyg_composer/components/Editor.tsx
+++ b/src/components/views/rooms/wysiwyg_composer/components/Editor.tsx
@@ -18,6 +18,7 @@ import classNames from 'classnames';
import React, { CSSProperties, forwardRef, memo, MutableRefObject, ReactNode } from 'react';
import { useIsExpanded } from '../hooks/useIsExpanded';
+import { useSelection } from '../hooks/useSelection';
const HEIGHT_BREAKING_POINT = 20;
@@ -25,7 +26,7 @@ interface EditorProps {
disabled: boolean;
placeholder?: string;
leftComponent?: ReactNode;
- rightComponent?: ReactNode;
+ rightComponent?: (selectPreviousSelection: () => void) => ReactNode;
}
export const Editor = memo(
@@ -33,6 +34,7 @@ export const Editor = memo(
function Editor({ disabled, placeholder, leftComponent, rightComponent }: EditorProps, ref,
) {
const isExpanded = useIsExpanded(ref as MutableRefObject, HEIGHT_BREAKING_POINT);
+ const { onFocus, onBlur, selectPreviousSelection } = useSelection();
return
- { rightComponent }
+ { rightComponent?.(selectPreviousSelection) }
;
},
),
diff --git a/src/components/views/rooms/wysiwyg_composer/components/Emoji.tsx b/src/components/views/rooms/wysiwyg_composer/components/Emoji.tsx
new file mode 100644
index 0000000000..d8a4d04972
--- /dev/null
+++ b/src/components/views/rooms/wysiwyg_composer/components/Emoji.tsx
@@ -0,0 +1,45 @@
+/*
+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 React from 'react';
+
+import { AboveLeftOf } from "../../../../structures/ContextMenu";
+import { EmojiButton } from "../../EmojiButton";
+import dis from '../../../../../dispatcher/dispatcher';
+import { ComposerInsertPayload } from "../../../../../dispatcher/payloads/ComposerInsertPayload";
+import { Action } from "../../../../../dispatcher/actions";
+import { useRoomContext } from "../../../../../contexts/RoomContext";
+
+interface EmojiProps {
+ selectPreviousSelection: () => void;
+ menuPosition: AboveLeftOf;
+}
+
+export function Emoji({ selectPreviousSelection, menuPosition }: EmojiProps) {
+ const roomContext = useRoomContext();
+
+ return {
+ selectPreviousSelection();
+ dis.dispatch({
+ action: Action.ComposerInsert,
+ text: emoji,
+ timelineRenderingType: roomContext.timelineRenderingType,
+ });
+ return true;
+ }}
+ />;
+}
diff --git a/src/components/views/rooms/wysiwyg_composer/components/PlainTextComposer.tsx b/src/components/views/rooms/wysiwyg_composer/components/PlainTextComposer.tsx
index f019c2e178..5339e986cd 100644
--- a/src/components/views/rooms/wysiwyg_composer/components/PlainTextComposer.tsx
+++ b/src/components/views/rooms/wysiwyg_composer/components/PlainTextComposer.tsx
@@ -33,7 +33,9 @@ interface PlainTextComposerProps {
initialContent?: string;
className?: string;
leftComponent?: ReactNode;
- rightComponent?: ReactNode;
+ rightComponent?: (
+ selectPreviousSelection: () => void
+ ) => ReactNode;
children?: (
ref: MutableRefObject,
composerFunctions: ComposerFunctions,
diff --git a/src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer.tsx b/src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer.tsx
index 05afc3d328..c346ceb1a4 100644
--- a/src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer.tsx
+++ b/src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer.tsx
@@ -32,7 +32,9 @@ interface WysiwygComposerProps {
initialContent?: string;
className?: string;
leftComponent?: ReactNode;
- rightComponent?: ReactNode;
+ rightComponent?: (
+ selectPreviousSelection: () => void
+ ) => ReactNode;
children?: (
ref: MutableRefObject,
wysiwyg: FormattingFunctions,
diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/useComposerFunctions.ts b/src/components/views/rooms/wysiwyg_composer/hooks/useComposerFunctions.ts
index 99a89589ee..abfde035a5 100644
--- a/src/components/views/rooms/wysiwyg_composer/hooks/useComposerFunctions.ts
+++ b/src/components/views/rooms/wysiwyg_composer/hooks/useComposerFunctions.ts
@@ -23,5 +23,8 @@ export function useComposerFunctions(ref: RefObject) {
ref.current.innerHTML = '';
}
},
+ insertText: (text: string) => {
+ // TODO
+ },
}), [ref]);
}
diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/useSelection.ts b/src/components/views/rooms/wysiwyg_composer/hooks/useSelection.ts
new file mode 100644
index 0000000000..2ae61790db
--- /dev/null
+++ b/src/components/views/rooms/wysiwyg_composer/hooks/useSelection.ts
@@ -0,0 +1,59 @@
+/*
+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 { useCallback, useEffect, useRef } from "react";
+
+import useFocus from "../../../../../hooks/useFocus";
+import { setSelection } from "../utils/selection";
+
+type SubSelection = Pick;
+
+export function useSelection() {
+ const selectionRef = useRef({
+ anchorNode: null,
+ anchorOffset: 0,
+ focusNode: null,
+ focusOffset: 0,
+ });
+ const [isFocused, focusProps] = useFocus();
+
+ useEffect(() => {
+ function onSelectionChange() {
+ const selection = document.getSelection();
+
+ if (selection) {
+ selectionRef.current = {
+ anchorNode: selection.anchorNode,
+ anchorOffset: selection.anchorOffset,
+ focusNode: selection.focusNode,
+ focusOffset: selection.focusOffset,
+ };
+ }
+ }
+
+ if (isFocused) {
+ document.addEventListener('selectionchange', onSelectionChange);
+ }
+
+ return () => document.removeEventListener('selectionchange', onSelectionChange);
+ }, [isFocused]);
+
+ const selectPreviousSelection = useCallback(() => {
+ setSelection(selectionRef.current);
+ }, [selectionRef]);
+
+ return { ...focusProps, selectPreviousSelection };
+}
diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/useWysiwygSendActionHandler.ts b/src/components/views/rooms/wysiwyg_composer/hooks/useWysiwygSendActionHandler.ts
index 500f027049..f2ee55ad46 100644
--- a/src/components/views/rooms/wysiwyg_composer/hooks/useWysiwygSendActionHandler.ts
+++ b/src/components/views/rooms/wysiwyg_composer/hooks/useWysiwygSendActionHandler.ts
@@ -23,6 +23,7 @@ import { TimelineRenderingType, useRoomContext } from "../../../../../contexts/R
import { useDispatcher } from "../../../../../hooks/useDispatcher";
import { focusComposer } from "./utils";
import { ComposerFunctions } from "../types";
+import { ComposerType } from "../../../../../dispatcher/payloads/ComposerInsertPayload";
export function useWysiwygSendActionHandler(
disabled: boolean,
@@ -48,7 +49,18 @@ export function useWysiwygSendActionHandler(
composerFunctions.clear();
focusComposer(composerElement, context, roomContext, timeoutId);
break;
- // TODO: case Action.ComposerInsert: - see SendMessageComposer
+ case Action.ComposerInsert:
+ if (payload.timelineRenderingType !== roomContext.timelineRenderingType) break;
+ if (payload.composerType !== ComposerType.Send) break;
+
+ if (payload.userId) {
+ // TODO insert mention - see SendMessageComposer
+ } else if (payload.event) {
+ // TODO insert quote message - see SendMessageComposer
+ } else if (payload.text) {
+ composerFunctions.insertText(payload.text);
+ }
+ break;
}
}, [disabled, composerElement, composerFunctions, timeoutId, roomContext]);
diff --git a/src/components/views/rooms/wysiwyg_composer/types.ts b/src/components/views/rooms/wysiwyg_composer/types.ts
index 96095abebf..6036793353 100644
--- a/src/components/views/rooms/wysiwyg_composer/types.ts
+++ b/src/components/views/rooms/wysiwyg_composer/types.ts
@@ -16,4 +16,5 @@ limitations under the License.
export type ComposerFunctions = {
clear: () => void;
+ insertText: (text: string) => void;
};
diff --git a/src/components/views/rooms/wysiwyg_composer/utils/selection.ts b/src/components/views/rooms/wysiwyg_composer/utils/selection.ts
new file mode 100644
index 0000000000..9e1ae0424e
--- /dev/null
+++ b/src/components/views/rooms/wysiwyg_composer/utils/selection.ts
@@ -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.
+*/
+
+export function setSelection(selection:
+ Pick,
+) {
+ if (selection.anchorNode && selection.focusNode) {
+ const range = new Range();
+ range.setStart(selection.anchorNode, selection.anchorOffset);
+ range.setEnd(selection.focusNode, selection.focusOffset);
+
+ document.getSelection()?.removeAllRanges();
+ document.getSelection()?.addRange(range);
+ }
+}
+
diff --git a/test/components/views/rooms/wysiwyg_composer/SendWysiwygComposer-test.tsx b/test/components/views/rooms/wysiwyg_composer/SendWysiwygComposer-test.tsx
index e51bd3bc6c..1b28c6ed2e 100644
--- a/test/components/views/rooms/wysiwyg_composer/SendWysiwygComposer-test.tsx
+++ b/test/components/views/rooms/wysiwyg_composer/SendWysiwygComposer-test.tsx
@@ -26,6 +26,14 @@ import { IRoomState } from "../../../../../src/components/structures/RoomView";
import { createTestClient, flushPromises, getRoomContext, mkEvent, mkStubRoom } from "../../../../test-utils";
import { SendWysiwygComposer } from "../../../../../src/components/views/rooms/wysiwyg_composer";
import { aboveLeftOf } from "../../../../../src/components/structures/ContextMenu";
+import { ComposerInsertPayload, ComposerType } from "../../../../../src/dispatcher/payloads/ComposerInsertPayload";
+import { setSelection } from "../../../../../src/components/views/rooms/wysiwyg_composer/utils/selection";
+
+jest.mock("../../../../../src/components/views/rooms/EmojiButton", () => ({
+ EmojiButton: ({ addEmoji }: {addEmoji: (emoji: string) => void}) => {
+ return ;
+ },
+}));
describe('SendWysiwygComposer', () => {
afterEach(() => {
@@ -47,9 +55,28 @@ describe('SendWysiwygComposer', () => {
const defaultRoomContext: IRoomState = getRoomContext(mockRoom, {});
+ const registerId = defaultDispatcher.register((payload) => {
+ switch (payload.action) {
+ case Action.ComposerInsert: {
+ if (payload.composerType) break;
+
+ // re-dispatch to the correct composer
+ defaultDispatcher.dispatch({
+ ...(payload as ComposerInsertPayload),
+ composerType: ComposerType.Send,
+ });
+ break;
+ }
+ }
+ });
+
+ afterAll(() => {
+ defaultDispatcher.unregister(registerId);
+ });
+
const customRender = (
- onChange = (_content: string) => void 0,
- onSend = () => void 0,
+ onChange = (_content: string): void => void 0,
+ onSend = (): void => void 0,
disabled = false,
isRichTextEnabled = true,
placeholder?: string) => {
@@ -177,7 +204,6 @@ describe('SendWysiwygComposer', () => {
it('Should not has placeholder', async () => {
// When
- console.log('here');
customRender(jest.fn(), jest.fn(), false, isRichTextEnabled);
await waitFor(() => expect(screen.getByRole('textbox')).toHaveAttribute('contentEditable', "true"));
@@ -222,5 +248,55 @@ describe('SendWysiwygComposer', () => {
);
});
});
+
+ describe.each([
+ { isRichTextEnabled: true },
+ // TODO { isRichTextEnabled: false },
+ ])('Emoji when %s', ({ isRichTextEnabled }) => {
+ let emojiButton: HTMLElement;
+
+ beforeEach(async () => {
+ customRender(jest.fn(), jest.fn(), false, isRichTextEnabled);
+ await waitFor(() => expect(screen.getByRole('textbox')).toHaveAttribute('contentEditable', "true"));
+ emojiButton = screen.getByLabelText('Emoji');
+ });
+
+ afterEach(() => {
+ jest.resetAllMocks();
+ });
+
+ it('Should add an emoji in an empty composer', async () => {
+ // When
+ emojiButton.click();
+
+ // Then
+ await waitFor(() => expect(screen.getByRole('textbox')).toHaveTextContent(/🦫/));
+ });
+
+ it('Should add an emoji in the middle of a word', async () => {
+ // When
+ screen.getByRole('textbox').focus();
+ screen.getByRole('textbox').innerHTML = 'word';
+ fireEvent.input(screen.getByRole('textbox'), {
+ data: 'word',
+ inputType: 'insertText',
+ });
+
+ const textNode = screen.getByRole('textbox').firstChild;
+ setSelection({
+ anchorNode: textNode,
+ anchorOffset: 2,
+ focusNode: textNode,
+ focusOffset: 2,
+ });
+ // the event is not automatically fired by jest
+ document.dispatchEvent(new CustomEvent('selectionchange'));
+
+ emojiButton.click();
+
+ // Then
+ await waitFor(() => expect(screen.getByRole('textbox')).toHaveTextContent(/wo🦫rd/));
+ });
+ });
});
diff --git a/yarn.lock b/yarn.lock
index 0d4d3cb4d7..4f49c74a8b 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1525,10 +1525,10 @@
resolved "https://registry.yarnpkg.com/@matrix-org/analytics-events/-/analytics-events-0.3.0.tgz#a428f7e3f164ffadf38f35bc0f0f9a3e47369ce6"
integrity sha512-f1WIMA8tjNB3V5g1C34yIpIJK47z6IJ4SLiY4j+J9Gw4X8C3TKGTAx563rMcMvW3Uk/PFqnIBXtkavHBXoYJ9A==
-"@matrix-org/matrix-wysiwyg@^0.8.0":
- version "0.8.0"
- resolved "https://registry.yarnpkg.com/@matrix-org/matrix-wysiwyg/-/matrix-wysiwyg-0.8.0.tgz#3b64c6a16cf2027e395766c950c13752b1a81282"
- integrity sha512-q3lpMNbD/GF2RPOuDR3COYDGR6BQWZBHUPtRYGaDf1i9eL/8vWD/WruwjzpI/RwNbYyPDm9Cs6vZj9BNhHB3Jw==
+"@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/olm@https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.8.tgz":
version "3.2.8"