diff --git a/package.json b/package.json index 4e37694fbdf..8b4754c83d0 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 a63a013cc47..bec2b9a08a6 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 6ebd189089c..b738847ec68 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 00000000000..d8a4d04972d --- /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 f019c2e1788..5339e986cda 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 05afc3d3283..c346ceb1a43 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 99a89589ee4..abfde035a5f 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 00000000000..2ae61790dbf --- /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 500f0270491..f2ee55ad46d 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 96095abebfd..60367933530 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 00000000000..9e1ae0424e8 --- /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 e51bd3bc6ca..1b28c6ed2e9 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 0d4d3cb4d7c..4f49c74a8bb 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"