Merge pull request #9661 from matrix-org/feat/emoji-picker-rich-text-mode
Add emoji handling for rich text mode
This commit is contained in:
commit
982c83d2a8
13 changed files with 249 additions and 16 deletions
|
@ -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",
|
||||
|
|
|
@ -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 <Composer
|
||||
className="mx_SendWysiwygComposer"
|
||||
leftComponent={e2eStatus && <E2EIcon status={e2eStatus} />}
|
||||
// TODO add emoji support
|
||||
rightComponent={<EmojiButton menuPosition={menuPosition} addEmoji={() => false} />}
|
||||
rightComponent={(selectPreviousSelection) =>
|
||||
<Emoji menuPosition={menuPosition} selectPreviousSelection={selectPreviousSelection} />}
|
||||
{...props}
|
||||
>
|
||||
{ (ref, composerFunctions) => (
|
||||
|
|
|
@ -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<HTMLDivElement | null>, HEIGHT_BREAKING_POINT);
|
||||
const { onFocus, onBlur, selectPreviousSelection } = useSelection();
|
||||
|
||||
return <div
|
||||
data-testid="WysiwygComposerEditor"
|
||||
|
@ -55,9 +57,11 @@ export const Editor = memo(
|
|||
aria-haspopup="listbox"
|
||||
dir="auto"
|
||||
aria-disabled={disabled}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
/>
|
||||
</div>
|
||||
{ rightComponent }
|
||||
{ rightComponent?.(selectPreviousSelection) }
|
||||
</div>;
|
||||
},
|
||||
),
|
||||
|
|
|
@ -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 <EmojiButton menuPosition={menuPosition}
|
||||
addEmoji={(emoji) => {
|
||||
selectPreviousSelection();
|
||||
dis.dispatch<ComposerInsertPayload>({
|
||||
action: Action.ComposerInsert,
|
||||
text: emoji,
|
||||
timelineRenderingType: roomContext.timelineRenderingType,
|
||||
});
|
||||
return true;
|
||||
}}
|
||||
/>;
|
||||
}
|
|
@ -33,7 +33,9 @@ interface PlainTextComposerProps {
|
|||
initialContent?: string;
|
||||
className?: string;
|
||||
leftComponent?: ReactNode;
|
||||
rightComponent?: ReactNode;
|
||||
rightComponent?: (
|
||||
selectPreviousSelection: () => void
|
||||
) => ReactNode;
|
||||
children?: (
|
||||
ref: MutableRefObject<HTMLDivElement | null>,
|
||||
composerFunctions: ComposerFunctions,
|
||||
|
|
|
@ -32,7 +32,9 @@ interface WysiwygComposerProps {
|
|||
initialContent?: string;
|
||||
className?: string;
|
||||
leftComponent?: ReactNode;
|
||||
rightComponent?: ReactNode;
|
||||
rightComponent?: (
|
||||
selectPreviousSelection: () => void
|
||||
) => ReactNode;
|
||||
children?: (
|
||||
ref: MutableRefObject<HTMLDivElement | null>,
|
||||
wysiwyg: FormattingFunctions,
|
||||
|
|
|
@ -23,5 +23,8 @@ export function useComposerFunctions(ref: RefObject<HTMLDivElement>) {
|
|||
ref.current.innerHTML = '';
|
||||
}
|
||||
},
|
||||
insertText: (text: string) => {
|
||||
// TODO
|
||||
},
|
||||
}), [ref]);
|
||||
}
|
||||
|
|
|
@ -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<Selection, 'anchorNode' | 'anchorOffset' | 'focusNode' | 'focusOffset'>;
|
||||
|
||||
export function useSelection() {
|
||||
const selectionRef = useRef<SubSelection>({
|
||||
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 };
|
||||
}
|
|
@ -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]);
|
||||
|
||||
|
|
|
@ -16,4 +16,5 @@ limitations under the License.
|
|||
|
||||
export type ComposerFunctions = {
|
||||
clear: () => void;
|
||||
insertText: (text: string) => void;
|
||||
};
|
||||
|
|
|
@ -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<Selection, 'anchorNode' | 'anchorOffset' | 'focusNode' | 'focusOffset'>,
|
||||
) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 <button aria-label="Emoji" type="button" onClick={() => addEmoji('🦫')}>Emoji</button>;
|
||||
},
|
||||
}));
|
||||
|
||||
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<ComposerInsertPayload>({
|
||||
...(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/));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Reference in a new issue