From 2b66cfc25f916da382250c1020ff652f56e39059 Mon Sep 17 00:00:00 2001 From: Florian Duros Date: Fri, 3 Feb 2023 17:43:02 +0100 Subject: [PATCH 1/4] Open message in editing mode when keyboard up is pressed (RTE) (#10079) Move to previous message when arrow up is pressed in the main composer (RTE) --- .../views/rooms/MessageComposer.tsx | 1 + .../rooms/wysiwyg_composer/ComposerContext.ts | 2 + .../wysiwyg_composer/SendWysiwygComposer.tsx | 5 +- .../hooks/useInitialContent.ts | 4 +- .../hooks/useInputEventProcessor.ts | 36 +- .../rooms/wysiwyg_composer/utils/event.ts | 13 + .../rooms/wysiwyg_composer/utils/selection.ts | 10 +- .../EditWysiwygComposer-test.tsx | 329 +-------------- .../SendWysiwygComposer-test.tsx | 19 +- .../components/WysiwygComposer-test.tsx | 384 +++++++++++++++++- .../views/rooms/wysiwyg_composer/utils.ts | 49 +++ 11 files changed, 487 insertions(+), 365 deletions(-) create mode 100644 test/components/views/rooms/wysiwyg_composer/utils.ts diff --git a/src/components/views/rooms/MessageComposer.tsx b/src/components/views/rooms/MessageComposer.tsx index 1e8276ba8f4..1515c408d7f 100644 --- a/src/components/views/rooms/MessageComposer.tsx +++ b/src/components/views/rooms/MessageComposer.tsx @@ -489,6 +489,7 @@ export class MessageComposer extends React.Component { e2eStatus={this.props.e2eStatus} menuPosition={menuPosition} placeholder={this.renderPlaceholderText()} + eventRelation={this.props.relation} /> ); } else { diff --git a/src/components/views/rooms/wysiwyg_composer/ComposerContext.ts b/src/components/views/rooms/wysiwyg_composer/ComposerContext.ts index 1de070216c0..19daf8fde8d 100644 --- a/src/components/views/rooms/wysiwyg_composer/ComposerContext.ts +++ b/src/components/views/rooms/wysiwyg_composer/ComposerContext.ts @@ -15,6 +15,7 @@ limitations under the License. */ import { createContext, useContext } from "react"; +import { IEventRelation } from "matrix-js-sdk/src/matrix"; import { SubSelection } from "./types"; import EditorStateTransfer from "../../../../utils/EditorStateTransfer"; @@ -29,6 +30,7 @@ export function getDefaultContextValue(defaultValue?: Partial(getDefaultContextValue()); diff --git a/src/components/views/rooms/wysiwyg_composer/SendWysiwygComposer.tsx b/src/components/views/rooms/wysiwyg_composer/SendWysiwygComposer.tsx index 2691df80dd1..d12432481db 100644 --- a/src/components/views/rooms/wysiwyg_composer/SendWysiwygComposer.tsx +++ b/src/components/views/rooms/wysiwyg_composer/SendWysiwygComposer.tsx @@ -15,6 +15,7 @@ limitations under the License. */ import React, { ForwardedRef, forwardRef, MutableRefObject, useRef } from "react"; +import { IEventRelation } from "matrix-js-sdk/src/models/event"; import { useWysiwygSendActionHandler } from "./hooks/useWysiwygSendActionHandler"; import { WysiwygComposer } from "./components/WysiwygComposer"; @@ -48,6 +49,7 @@ interface SendWysiwygComposerProps { onChange: (content: string) => void; onSend: () => void; menuPosition: MenuProps; + eventRelation?: IEventRelation; } // Default needed for React.lazy @@ -55,10 +57,11 @@ export default function SendWysiwygComposer({ isRichTextEnabled, e2eStatus, menuPosition, + eventRelation, ...props }: SendWysiwygComposerProps): JSX.Element { const Composer = isRichTextEnabled ? WysiwygComposer : PlainTextComposer; - const defaultContextValue = useRef(getDefaultContextValue()); + const defaultContextValue = useRef(getDefaultContextValue({ eventRelation })); return ( diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/useInitialContent.ts b/src/components/views/rooms/wysiwyg_composer/hooks/useInitialContent.ts index 7ec4c5a3136..f4612a097e1 100644 --- a/src/components/views/rooms/wysiwyg_composer/hooks/useInitialContent.ts +++ b/src/components/views/rooms/wysiwyg_composer/hooks/useInitialContent.ts @@ -33,7 +33,7 @@ function getFormattedContent(editorStateTransfer: EditorStateTransfer): string { ); } -function parseEditorStateTransfer( +export function parseEditorStateTransfer( editorStateTransfer: EditorStateTransfer, room: Room, mxClient: MatrixClient, @@ -64,7 +64,7 @@ function parseEditorStateTransfer( // this.saveStoredEditorState(); } -export function useInitialContent(editorStateTransfer: EditorStateTransfer): string { +export function useInitialContent(editorStateTransfer: EditorStateTransfer): string | undefined { const roomContext = useRoomContext(); const mxClient = useMatrixClientContext(); diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/useInputEventProcessor.ts b/src/components/views/rooms/wysiwyg_composer/hooks/useInputEventProcessor.ts index 405539fc709..def7d74bc01 100644 --- a/src/components/views/rooms/wysiwyg_composer/hooks/useInputEventProcessor.ts +++ b/src/components/views/rooms/wysiwyg_composer/hooks/useInputEventProcessor.ts @@ -30,7 +30,7 @@ import { ComposerContextState, useComposerContext } from "../ComposerContext"; import EditorStateTransfer from "../../../../../utils/EditorStateTransfer"; import { useMatrixClientContext } from "../../../../../contexts/MatrixClientContext"; import { isCaretAtEnd, isCaretAtStart } from "../utils/selection"; -import { getEventsFromEditorStateTransfer } from "../utils/event"; +import { getEventsFromEditorStateTransfer, getEventsFromRoom } from "../utils/event"; import { endEditing } from "../utils/editing"; export function useInputEventProcessor( @@ -87,7 +87,8 @@ function handleKeyboardEvent( mxClient: MatrixClient, ): KeyboardEvent | null { const { editorStateTransfer } = composerContext; - const isEditorModified = initialContent !== composer.content(); + const isEditing = Boolean(editorStateTransfer); + const isEditorModified = isEditing ? initialContent !== composer.content() : composer.content().length !== 0; const action = getKeyBindingsManager().getMessageComposerAction(event); switch (action) { @@ -95,14 +96,21 @@ function handleKeyboardEvent( send(); return null; case KeyBindingAction.EditPrevMessage: { - // If not in edition // Or if the caret is not at the beginning of the editor // Or the editor is modified - if (!editorStateTransfer || !isCaretAtStart(editor) || isEditorModified) { + if (!isCaretAtStart(editor) || isEditorModified) { break; } - const isDispatched = dispatchEditEvent(event, false, editorStateTransfer, roomContext, mxClient); + const isDispatched = dispatchEditEvent( + event, + false, + editorStateTransfer, + composerContext, + roomContext, + mxClient, + ); + if (isDispatched) { return null; } @@ -117,7 +125,14 @@ function handleKeyboardEvent( break; } - const isDispatched = dispatchEditEvent(event, true, editorStateTransfer, roomContext, mxClient); + const isDispatched = dispatchEditEvent( + event, + true, + editorStateTransfer, + composerContext, + roomContext, + mxClient, + ); if (!isDispatched) { endEditing(roomContext); event.preventDefault(); @@ -134,11 +149,14 @@ function handleKeyboardEvent( function dispatchEditEvent( event: KeyboardEvent, isForward: boolean, - editorStateTransfer: EditorStateTransfer, + editorStateTransfer: EditorStateTransfer | undefined, + composerContext: ComposerContextState, roomContext: IRoomState, mxClient: MatrixClient, ): boolean { - const foundEvents = getEventsFromEditorStateTransfer(editorStateTransfer, roomContext, mxClient); + const foundEvents = editorStateTransfer + ? getEventsFromEditorStateTransfer(editorStateTransfer, roomContext, mxClient) + : getEventsFromRoom(composerContext, roomContext); if (!foundEvents) { return false; } @@ -146,7 +164,7 @@ function dispatchEditEvent( const newEvent = findEditableEvent({ events: foundEvents, isForward, - fromEventId: editorStateTransfer.getEvent().getId(), + fromEventId: editorStateTransfer?.getEvent().getId(), }); if (newEvent) { dis.dispatch({ diff --git a/src/components/views/rooms/wysiwyg_composer/utils/event.ts b/src/components/views/rooms/wysiwyg_composer/utils/event.ts index 4d65497faca..2220b7d37a6 100644 --- a/src/components/views/rooms/wysiwyg_composer/utils/event.ts +++ b/src/components/views/rooms/wysiwyg_composer/utils/event.ts @@ -15,9 +15,11 @@ limitations under the License. */ import { MatrixClient, MatrixEvent } from "matrix-js-sdk/src/matrix"; +import { THREAD_RELATION_TYPE } from "matrix-js-sdk/src/models/thread"; import EditorStateTransfer from "../../../../../utils/EditorStateTransfer"; import { IRoomState } from "../../../../structures/RoomView"; +import { ComposerContextState } from "../ComposerContext"; // From EditMessageComposer private get events(): MatrixEvent[] export function getEventsFromEditorStateTransfer( @@ -44,3 +46,14 @@ export function getEventsFromEditorStateTransfer( const isInThread = Boolean(editorStateTransfer.getEvent().getThread()); return liveTimelineEvents.concat(isInThread ? [] : pendingEvents); } + +// From SendMessageComposer private onKeyDown = (event: KeyboardEvent): void +export function getEventsFromRoom( + composerContext: ComposerContextState, + roomContext: IRoomState, +): MatrixEvent[] | undefined { + const isReplyingToThread = composerContext.eventRelation?.key === THREAD_RELATION_TYPE.name; + return roomContext.liveTimeline + ?.getEvents() + .concat(isReplyingToThread ? [] : roomContext.room?.getPendingEvents() || []); +} diff --git a/src/components/views/rooms/wysiwyg_composer/utils/selection.ts b/src/components/views/rooms/wysiwyg_composer/utils/selection.ts index 4ed64154e53..4af4b00c95e 100644 --- a/src/components/views/rooms/wysiwyg_composer/utils/selection.ts +++ b/src/components/views/rooms/wysiwyg_composer/utils/selection.ts @@ -44,15 +44,21 @@ export function isCaretAtStart(editor: HTMLElement): boolean { const selection = document.getSelection(); // No selection or the caret is not at the beginning of the selected element - if (!selection || selection.anchorOffset !== 0) { + if (!selection) { return false; } + // When we are pressing keyboard up in an empty main composer, the selection is on the editor with an anchorOffset at O or 1 (yes, this is strange) + const isOnFirstElement = selection.anchorNode === editor && selection.anchorOffset <= 1; + if (isOnFirstElement) { + return true; + } + // In case of nested html elements (list, code blocks), we are going through all the first child let child = editor.firstChild; do { if (child === selection.anchorNode) { - return true; + return selection.anchorOffset === 0; } } while ((child = child?.firstChild || null)); diff --git a/test/components/views/rooms/wysiwyg_composer/EditWysiwygComposer-test.tsx b/test/components/views/rooms/wysiwyg_composer/EditWysiwygComposer-test.tsx index c2c7052e054..7b7b87e8be9 100644 --- a/test/components/views/rooms/wysiwyg_composer/EditWysiwygComposer-test.tsx +++ b/test/components/views/rooms/wysiwyg_composer/EditWysiwygComposer-test.tsx @@ -17,21 +17,12 @@ limitations under the License. import "@testing-library/jest-dom"; import React from "react"; import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; -import { EventTimeline, MatrixEvent } from "matrix-js-sdk/src/matrix"; import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext"; import RoomContext from "../../../../../src/contexts/RoomContext"; import defaultDispatcher from "../../../../../src/dispatcher/dispatcher"; import { Action } from "../../../../../src/dispatcher/actions"; -import { IRoomState } from "../../../../../src/components/structures/RoomView"; -import { - createTestClient, - flushPromises, - getRoomContext, - mkEvent, - mkStubRoom, - mockPlatformPeg, -} from "../../../../test-utils"; +import { flushPromises, mkEvent } from "../../../../test-utils"; import { EditWysiwygComposer } from "../../../../../src/components/views/rooms/wysiwyg_composer"; import EditorStateTransfer from "../../../../../src/utils/EditorStateTransfer"; import { Emoji } from "../../../../../src/components/views/rooms/wysiwyg_composer/components/Emoji"; @@ -40,43 +31,13 @@ import dis from "../../../../../src/dispatcher/dispatcher"; import { ComposerInsertPayload, ComposerType } from "../../../../../src/dispatcher/payloads/ComposerInsertPayload"; import { ActionPayload } from "../../../../../src/dispatcher/payloads"; import * as EmojiButton from "../../../../../src/components/views/rooms/EmojiButton"; -import { setSelection } from "../../../../../src/components/views/rooms/wysiwyg_composer/utils/selection"; -import * as EventUtils from "../../../../../src/utils/EventUtils"; -import { SubSelection } from "../../../../../src/components/views/rooms/wysiwyg_composer/types"; +import { createMocks } from "./utils"; describe("EditWysiwygComposer", () => { afterEach(() => { jest.resetAllMocks(); }); - function createMocks(eventContent = "Replying to this new content") { - const mockClient = createTestClient(); - const mockEvent = mkEvent({ - type: "m.room.message", - room: "myfakeroom", - user: "myfakeuser", - content: { - msgtype: "m.text", - body: "Replying to this", - format: "org.matrix.custom.html", - formatted_body: eventContent, - }, - event: true, - }); - const mockRoom = mkStubRoom("myfakeroom", "myfakeroom", mockClient) as any; - mockRoom.findEventById = jest.fn((eventId) => { - return eventId === mockEvent.getId() ? mockEvent : null; - }); - - const defaultRoomContext: IRoomState = getRoomContext(mockRoom, { - liveTimeline: { getEvents: (): MatrixEvent[] => [] } as unknown as EventTimeline, - }); - - const editorStateTransfer = new EditorStateTransfer(mockEvent); - - return { defaultRoomContext, editorStateTransfer, mockClient, mockEvent }; - } - const { editorStateTransfer, defaultRoomContext, mockClient, mockEvent } = createMocks(); const customRender = ( @@ -342,290 +303,4 @@ describe("EditWysiwygComposer", () => { await waitFor(() => expect(screen.getByRole("textbox")).toHaveTextContent(/🦫/)); dis.unregister(dispatcherRef); }); - - describe("Keyboard navigation", () => { - const setup = async ( - editorState = editorStateTransfer, - client = createTestClient(), - roomContext = defaultRoomContext, - ) => { - const spyDispatcher = jest.spyOn(defaultDispatcher, "dispatch"); - customRender(false, editorState, client, roomContext); - await waitFor(() => expect(screen.getByRole("textbox")).toHaveAttribute("contentEditable", "true")); - return { textbox: screen.getByRole("textbox"), spyDispatcher }; - }; - - beforeEach(() => { - mockPlatformPeg({ overrideBrowserShortcuts: jest.fn().mockReturnValue(false) }); - jest.spyOn(EventUtils, "findEditableEvent").mockReturnValue(mockEvent); - }); - - function select(selection: SubSelection) { - return act(async () => { - await setSelection(selection); - // the event is not automatically fired by jest - document.dispatchEvent(new CustomEvent("selectionchange")); - }); - } - - describe("Moving up", () => { - it("Should not moving when caret is not at beginning of the text", async () => { - // When - const { textbox, spyDispatcher } = await setup(); - const textNode = textbox.firstChild; - await select({ - anchorNode: textNode, - anchorOffset: 1, - focusNode: textNode, - focusOffset: 2, - isForward: true, - }); - - fireEvent.keyDown(textbox, { - key: "ArrowUp", - }); - - // Then - expect(spyDispatcher).toBeCalledTimes(0); - }); - - it("Should not moving when the content has changed", async () => { - // When - const { textbox, spyDispatcher } = await setup(); - fireEvent.input(textbox, { - data: "word", - inputType: "insertText", - }); - const textNode = textbox.firstChild; - await select({ - anchorNode: textNode, - anchorOffset: 0, - focusNode: textNode, - focusOffset: 0, - isForward: true, - }); - - fireEvent.keyDown(textbox, { - key: "ArrowUp", - }); - - // Then - expect(spyDispatcher).toBeCalledTimes(0); - }); - - it("Should moving up", async () => { - // When - const { textbox, spyDispatcher } = await setup(); - const textNode = textbox.firstChild; - await select({ - anchorNode: textNode, - anchorOffset: 0, - focusNode: textNode, - focusOffset: 0, - isForward: true, - }); - - fireEvent.keyDown(textbox, { - key: "ArrowUp", - }); - - // Wait for event dispatch to happen - await act(async () => { - await flushPromises(); - }); - - // Then - await waitFor(() => - expect(spyDispatcher).toBeCalledWith({ - action: Action.EditEvent, - event: mockEvent, - timelineRenderingType: defaultRoomContext.timelineRenderingType, - }), - ); - }); - - it("Should moving up in list", async () => { - // When - const { mockEvent, defaultRoomContext, mockClient, editorStateTransfer } = createMocks( - "
  • Content
  • Other Content
", - ); - jest.spyOn(EventUtils, "findEditableEvent").mockReturnValue(mockEvent); - const { textbox, spyDispatcher } = await setup(editorStateTransfer, mockClient, defaultRoomContext); - - const textNode = textbox.firstChild; - await select({ - anchorNode: textNode, - anchorOffset: 0, - focusNode: textNode, - focusOffset: 0, - isForward: true, - }); - - fireEvent.keyDown(textbox, { - key: "ArrowUp", - }); - - // Wait for event dispatch to happen - await act(async () => { - await flushPromises(); - }); - - // Then - expect(spyDispatcher).toBeCalledWith({ - action: Action.EditEvent, - event: mockEvent, - timelineRenderingType: defaultRoomContext.timelineRenderingType, - }); - }); - }); - - describe("Moving down", () => { - it("Should not moving when caret is not at the end of the text", async () => { - // When - const { textbox, spyDispatcher } = await setup(); - const brNode = textbox.lastChild; - await select({ - anchorNode: brNode, - anchorOffset: 0, - focusNode: brNode, - focusOffset: 0, - isForward: true, - }); - - fireEvent.keyDown(textbox, { - key: "ArrowDown", - }); - - // Then - expect(spyDispatcher).toBeCalledTimes(0); - }); - - it("Should not moving when the content has changed", async () => { - // When - const { textbox, spyDispatcher } = await setup(); - fireEvent.input(textbox, { - data: "word", - inputType: "insertText", - }); - const brNode = textbox.lastChild; - await select({ - anchorNode: brNode, - anchorOffset: 0, - focusNode: brNode, - focusOffset: 0, - isForward: true, - }); - - fireEvent.keyDown(textbox, { - key: "ArrowDown", - }); - - // Then - expect(spyDispatcher).toBeCalledTimes(0); - }); - - it("Should moving down", async () => { - // When - const { textbox, spyDispatcher } = await setup(); - // Skipping the BR tag - const textNode = textbox.childNodes[textbox.childNodes.length - 2]; - const { length } = textNode.textContent || ""; - await select({ - anchorNode: textNode, - anchorOffset: length, - focusNode: textNode, - focusOffset: length, - isForward: true, - }); - - fireEvent.keyDown(textbox, { - key: "ArrowDown", - }); - - // Wait for event dispatch to happen - await act(async () => { - await flushPromises(); - }); - - // Then - await waitFor(() => - expect(spyDispatcher).toBeCalledWith({ - action: Action.EditEvent, - event: mockEvent, - timelineRenderingType: defaultRoomContext.timelineRenderingType, - }), - ); - }); - - it("Should moving down in list", async () => { - // When - const { mockEvent, defaultRoomContext, mockClient, editorStateTransfer } = createMocks( - "
  • Content
  • Other Content
", - ); - jest.spyOn(EventUtils, "findEditableEvent").mockReturnValue(mockEvent); - const { textbox, spyDispatcher } = await setup(editorStateTransfer, mockClient, defaultRoomContext); - - // Skipping the BR tag and get the text node inside the last LI tag - const textNode = textbox.childNodes[textbox.childNodes.length - 2].lastChild?.lastChild || textbox; - const { length } = textNode.textContent || ""; - await select({ - anchorNode: textNode, - anchorOffset: length, - focusNode: textNode, - focusOffset: length, - isForward: true, - }); - - fireEvent.keyDown(textbox, { - key: "ArrowDown", - }); - - // Wait for event dispatch to happen - await act(async () => { - await flushPromises(); - }); - - // Then - expect(spyDispatcher).toBeCalledWith({ - action: Action.EditEvent, - event: mockEvent, - timelineRenderingType: defaultRoomContext.timelineRenderingType, - }); - }); - - it("Should close editing", async () => { - // When - jest.spyOn(EventUtils, "findEditableEvent").mockReturnValue(undefined); - const { textbox, spyDispatcher } = await setup(); - // Skipping the BR tag - const textNode = textbox.childNodes[textbox.childNodes.length - 2]; - const { length } = textNode.textContent || ""; - await select({ - anchorNode: textNode, - anchorOffset: length, - focusNode: textNode, - focusOffset: length, - isForward: true, - }); - - fireEvent.keyDown(textbox, { - key: "ArrowDown", - }); - - // Wait for event dispatch to happen - await act(async () => { - await flushPromises(); - }); - - // Then - await waitFor(() => - expect(spyDispatcher).toBeCalledWith({ - action: Action.EditEvent, - event: null, - timelineRenderingType: defaultRoomContext.timelineRenderingType, - }), - ); - }); - }); - }); }); diff --git a/test/components/views/rooms/wysiwyg_composer/SendWysiwygComposer-test.tsx b/test/components/views/rooms/wysiwyg_composer/SendWysiwygComposer-test.tsx index aa39de7a573..4dcf8d504ee 100644 --- a/test/components/views/rooms/wysiwyg_composer/SendWysiwygComposer-test.tsx +++ b/test/components/views/rooms/wysiwyg_composer/SendWysiwygComposer-test.tsx @@ -22,12 +22,12 @@ import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext import RoomContext from "../../../../../src/contexts/RoomContext"; import defaultDispatcher from "../../../../../src/dispatcher/dispatcher"; import { Action } from "../../../../../src/dispatcher/actions"; -import { IRoomState } from "../../../../../src/components/structures/RoomView"; -import { createTestClient, flushPromises, getRoomContext, mkEvent, mkStubRoom } from "../../../../test-utils"; +import { flushPromises } 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"; +import { createMocks } from "./utils"; jest.mock("../../../../../src/components/views/rooms/EmojiButton", () => ({ EmojiButton: ({ addEmoji }: { addEmoji: (emoji: string) => void }) => { @@ -44,20 +44,7 @@ describe("SendWysiwygComposer", () => { jest.resetAllMocks(); }); - const mockClient = createTestClient(); - const mockEvent = mkEvent({ - type: "m.room.message", - room: "myfakeroom", - user: "myfakeuser", - content: { msgtype: "m.text", body: "Replying to this" }, - event: true, - }); - const mockRoom = mkStubRoom("myfakeroom", "myfakeroom", mockClient) as any; - mockRoom.findEventById = jest.fn((eventId) => { - return eventId === mockEvent.getId() ? mockEvent : null; - }); - - const defaultRoomContext: IRoomState = getRoomContext(mockRoom, {}); + const { defaultRoomContext, mockClient } = createMocks(); const registerId = defaultDispatcher.register((payload) => { switch (payload.action) { diff --git a/test/components/views/rooms/wysiwyg_composer/components/WysiwygComposer-test.tsx b/test/components/views/rooms/wysiwyg_composer/components/WysiwygComposer-test.tsx index 4d485b5a3fa..5a41488be45 100644 --- a/test/components/views/rooms/wysiwyg_composer/components/WysiwygComposer-test.tsx +++ b/test/components/views/rooms/wysiwyg_composer/components/WysiwygComposer-test.tsx @@ -16,25 +16,38 @@ 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 userEvent from "@testing-library/user-event"; import { WysiwygComposer } from "../../../../../../src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer"; import SettingsStore from "../../../../../../src/settings/SettingsStore"; -import { mockPlatformPeg } from "../../../../../test-utils"; +import { createTestClient, flushPromises, mockPlatformPeg } from "../../../../../test-utils"; +import defaultDispatcher from "../../../../../../src/dispatcher/dispatcher"; +import * as EventUtils from "../../../../../../src/utils/EventUtils"; +import { Action } from "../../../../../../src/dispatcher/actions"; +import MatrixClientContext from "../../../../../../src/contexts/MatrixClientContext"; +import RoomContext from "../../../../../../src/contexts/RoomContext"; +import { + ComposerContext, + getDefaultContextValue, +} from "../../../../../../src/components/views/rooms/wysiwyg_composer/ComposerContext"; +import { createMocks } from "../utils"; +import EditorStateTransfer from "../../../../../../src/utils/EditorStateTransfer"; +import { SubSelection } from "../../../../../../src/components/views/rooms/wysiwyg_composer/types"; +import { setSelection } from "../../../../../../src/components/views/rooms/wysiwyg_composer/utils/selection"; +import { parseEditorStateTransfer } from "../../../../../../src/components/views/rooms/wysiwyg_composer/hooks/useInitialContent"; describe("WysiwygComposer", () => { - const customRender = ( - onChange = (_content: string) => void 0, - onSend = () => void 0, - disabled = false, - initialContent?: string, - ) => { + const customRender = (onChange = jest.fn(), onSend = jest.fn(), disabled = false, initialContent?: string) => { return render( , ); }; + afterEach(() => { + jest.resetAllMocks(); + }); + it("Should have contentEditable at false when disabled", () => { // When customRender(jest.fn(), jest.fn(), true); @@ -191,4 +204,359 @@ describe("WysiwygComposer", () => { await waitFor(() => expect(onSend).toBeCalledTimes(1)); }); }); + + describe("Keyboard navigation", () => { + const { mockClient, defaultRoomContext, mockEvent, editorStateTransfer } = createMocks(); + + const customRender = ( + client = mockClient, + roomContext = defaultRoomContext, + _editorStateTransfer?: EditorStateTransfer, + ) => { + return render( + + + + + + + , + ); + }; + + afterEach(() => { + jest.resetAllMocks(); + }); + + const setup = async ( + editorState?: EditorStateTransfer, + client = createTestClient(), + roomContext = defaultRoomContext, + ) => { + const spyDispatcher = jest.spyOn(defaultDispatcher, "dispatch"); + customRender(client, roomContext, editorState); + await waitFor(() => expect(screen.getByRole("textbox")).toHaveAttribute("contentEditable", "true")); + return { textbox: screen.getByRole("textbox"), spyDispatcher }; + }; + + beforeEach(() => { + mockPlatformPeg({ overrideBrowserShortcuts: jest.fn().mockReturnValue(false) }); + jest.spyOn(EventUtils, "findEditableEvent").mockReturnValue(mockEvent); + }); + + describe("In message creation", () => { + it("Should not moving when the composer is filled", async () => { + // When + const { textbox, spyDispatcher } = await setup(); + fireEvent.input(textbox, { + data: "word", + inputType: "insertText", + }); + + // Move at the beginning of the composer + fireEvent.keyDown(textbox, { + key: "ArrowUp", + }); + + // Then + expect(spyDispatcher).toBeCalledTimes(0); + }); + + it("Should moving when the composer is empty", async () => { + // When + const { textbox, spyDispatcher } = await setup(); + + fireEvent.keyDown(textbox, { + key: "ArrowUp", + }); + + // Then + expect(spyDispatcher).toBeCalledWith({ + action: Action.EditEvent, + event: mockEvent, + timelineRenderingType: defaultRoomContext.timelineRenderingType, + }); + }); + }); + + describe("In message editing", () => { + function select(selection: SubSelection) { + return act(async () => { + await setSelection(selection); + // the event is not automatically fired by jest + document.dispatchEvent(new CustomEvent("selectionchange")); + }); + } + + describe("Moving up", () => { + it("Should not moving when caret is not at beginning of the text", async () => { + // When + const { textbox, spyDispatcher } = await setup(editorStateTransfer); + const textNode = textbox.firstChild; + await select({ + anchorNode: textNode, + anchorOffset: 1, + focusNode: textNode, + focusOffset: 2, + isForward: true, + }); + + fireEvent.keyDown(textbox, { + key: "ArrowUp", + }); + + // Then + expect(spyDispatcher).toBeCalledTimes(0); + }); + + it("Should not moving when the content has changed", async () => { + // When + const { textbox, spyDispatcher } = await setup(editorStateTransfer); + fireEvent.input(textbox, { + data: "word", + inputType: "insertText", + }); + const textNode = textbox.firstChild; + await select({ + anchorNode: textNode, + anchorOffset: 0, + focusNode: textNode, + focusOffset: 0, + isForward: true, + }); + + fireEvent.keyDown(textbox, { + key: "ArrowUp", + }); + + // Then + expect(spyDispatcher).toBeCalledTimes(0); + }); + + it("Should moving up", async () => { + // When + const { textbox, spyDispatcher } = await setup(editorStateTransfer); + const textNode = textbox.firstChild; + await select({ + anchorNode: textNode, + anchorOffset: 0, + focusNode: textNode, + focusOffset: 0, + isForward: true, + }); + + fireEvent.keyDown(textbox, { + key: "ArrowUp", + }); + + // Wait for event dispatch to happen + await act(async () => { + await flushPromises(); + }); + + // Then + await waitFor(() => + expect(spyDispatcher).toBeCalledWith({ + action: Action.EditEvent, + event: mockEvent, + timelineRenderingType: defaultRoomContext.timelineRenderingType, + }), + ); + }); + + it("Should moving up in list", async () => { + // When + const { mockEvent, defaultRoomContext, mockClient, editorStateTransfer } = createMocks( + "
  • Content
  • Other Content
", + ); + jest.spyOn(EventUtils, "findEditableEvent").mockReturnValue(mockEvent); + const { textbox, spyDispatcher } = await setup(editorStateTransfer, mockClient, defaultRoomContext); + + const textNode = textbox.firstChild; + await select({ + anchorNode: textNode, + anchorOffset: 0, + focusNode: textNode, + focusOffset: 0, + isForward: true, + }); + + fireEvent.keyDown(textbox, { + key: "ArrowUp", + }); + + // Wait for event dispatch to happen + await act(async () => { + await flushPromises(); + }); + + // Then + expect(spyDispatcher).toBeCalledWith({ + action: Action.EditEvent, + event: mockEvent, + timelineRenderingType: defaultRoomContext.timelineRenderingType, + }); + }); + }); + + describe("Moving down", () => { + it("Should not moving when caret is not at the end of the text", async () => { + // When + const { textbox, spyDispatcher } = await setup(editorStateTransfer); + const brNode = textbox.lastChild; + await select({ + anchorNode: brNode, + anchorOffset: 0, + focusNode: brNode, + focusOffset: 0, + isForward: true, + }); + + fireEvent.keyDown(textbox, { + key: "ArrowDown", + }); + + // Then + expect(spyDispatcher).toBeCalledTimes(0); + }); + + it("Should not moving when the content has changed", async () => { + // When + const { textbox, spyDispatcher } = await setup(editorStateTransfer); + fireEvent.input(textbox, { + data: "word", + inputType: "insertText", + }); + const brNode = textbox.lastChild; + await select({ + anchorNode: brNode, + anchorOffset: 0, + focusNode: brNode, + focusOffset: 0, + isForward: true, + }); + + fireEvent.keyDown(textbox, { + key: "ArrowDown", + }); + + // Then + expect(spyDispatcher).toBeCalledTimes(0); + }); + + it("Should moving down", async () => { + // When + const { textbox, spyDispatcher } = await setup(editorStateTransfer); + // Skipping the BR tag + const textNode = textbox.childNodes[textbox.childNodes.length - 2]; + const { length } = textNode.textContent || ""; + await select({ + anchorNode: textNode, + anchorOffset: length, + focusNode: textNode, + focusOffset: length, + isForward: true, + }); + + fireEvent.keyDown(textbox, { + key: "ArrowDown", + }); + + // Wait for event dispatch to happen + await act(async () => { + await flushPromises(); + }); + + // Then + await waitFor(() => + expect(spyDispatcher).toBeCalledWith({ + action: Action.EditEvent, + event: mockEvent, + timelineRenderingType: defaultRoomContext.timelineRenderingType, + }), + ); + }); + + it("Should moving down in list", async () => { + // When + const { mockEvent, defaultRoomContext, mockClient, editorStateTransfer } = createMocks( + "
  • Content
  • Other Content
", + ); + jest.spyOn(EventUtils, "findEditableEvent").mockReturnValue(mockEvent); + const { textbox, spyDispatcher } = await setup(editorStateTransfer, mockClient, defaultRoomContext); + + // Skipping the BR tag and get the text node inside the last LI tag + const textNode = textbox.childNodes[textbox.childNodes.length - 2].lastChild?.lastChild || textbox; + const { length } = textNode.textContent || ""; + await select({ + anchorNode: textNode, + anchorOffset: length, + focusNode: textNode, + focusOffset: length, + isForward: true, + }); + + fireEvent.keyDown(textbox, { + key: "ArrowDown", + }); + + // Wait for event dispatch to happen + await act(async () => { + await flushPromises(); + }); + + // Then + expect(spyDispatcher).toBeCalledWith({ + action: Action.EditEvent, + event: mockEvent, + timelineRenderingType: defaultRoomContext.timelineRenderingType, + }); + }); + + it("Should close editing", async () => { + // When + jest.spyOn(EventUtils, "findEditableEvent").mockReturnValue(undefined); + const { textbox, spyDispatcher } = await setup(editorStateTransfer); + // Skipping the BR tag + const textNode = textbox.childNodes[textbox.childNodes.length - 2]; + const { length } = textNode.textContent || ""; + await select({ + anchorNode: textNode, + anchorOffset: length, + focusNode: textNode, + focusOffset: length, + isForward: true, + }); + + fireEvent.keyDown(textbox, { + key: "ArrowDown", + }); + + // Wait for event dispatch to happen + await act(async () => { + await flushPromises(); + }); + + // Then + await waitFor(() => + expect(spyDispatcher).toBeCalledWith({ + action: Action.EditEvent, + event: null, + timelineRenderingType: defaultRoomContext.timelineRenderingType, + }), + ); + }); + }); + }); + }); }); diff --git a/test/components/views/rooms/wysiwyg_composer/utils.ts b/test/components/views/rooms/wysiwyg_composer/utils.ts new file mode 100644 index 00000000000..0eb99b251db --- /dev/null +++ b/test/components/views/rooms/wysiwyg_composer/utils.ts @@ -0,0 +1,49 @@ +/* +Copyright 2023 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 { EventTimeline, MatrixEvent } from "matrix-js-sdk/src/matrix"; + +import { createTestClient, getRoomContext, mkEvent, mkStubRoom } from "../../../../test-utils"; +import { IRoomState } from "../../../../../src/components/structures/RoomView"; +import EditorStateTransfer from "../../../../../src/utils/EditorStateTransfer"; + +export function createMocks(eventContent = "Replying to this new content") { + const mockClient = createTestClient(); + const mockEvent = mkEvent({ + type: "m.room.message", + room: "myfakeroom", + user: "myfakeuser", + content: { + msgtype: "m.text", + body: "Replying to this", + format: "org.matrix.custom.html", + formatted_body: eventContent, + }, + event: true, + }); + const mockRoom = mkStubRoom("myfakeroom", "myfakeroom", mockClient) as any; + mockRoom.findEventById = jest.fn((eventId) => { + return eventId === mockEvent.getId() ? mockEvent : null; + }); + + const defaultRoomContext: IRoomState = getRoomContext(mockRoom, { + liveTimeline: { getEvents: (): MatrixEvent[] => [] } as unknown as EventTimeline, + }); + + const editorStateTransfer = new EditorStateTransfer(mockEvent); + + return { defaultRoomContext, editorStateTransfer, mockClient, mockEvent }; +} From a756b33fe912ea9a4349e8d5f098c2afe3a60afd Mon Sep 17 00:00:00 2001 From: Andy Balaam Date: Fri, 3 Feb 2023 16:58:52 +0000 Subject: [PATCH 2/4] Rename RoomCreate -> RoomPredecessorTile (#10047) --- ...RoomCreate.tsx => RoomPredecessorTile.tsx} | 7 +++--- src/events/EventTileFactory.tsx | 4 ++-- ...-test.tsx => RoomPredecessorTile-test.tsx} | 24 +++++++++---------- ...snap => RoomPredecessorTile-test.tsx.snap} | 2 +- 4 files changed, 19 insertions(+), 18 deletions(-) rename src/components/views/messages/{RoomCreate.tsx => RoomPredecessorTile.tsx} (92%) rename test/components/views/messages/{RoomCreate-test.tsx => RoomPredecessorTile-test.tsx} (91%) rename test/components/views/messages/__snapshots__/{RoomCreate-test.tsx.snap => RoomPredecessorTile-test.tsx.snap} (88%) diff --git a/src/components/views/messages/RoomCreate.tsx b/src/components/views/messages/RoomPredecessorTile.tsx similarity index 92% rename from src/components/views/messages/RoomCreate.tsx rename to src/components/views/messages/RoomPredecessorTile.tsx index ffeb10f3efe..5c47cda56fe 100644 --- a/src/components/views/messages/RoomCreate.tsx +++ b/src/components/views/messages/RoomPredecessorTile.tsx @@ -40,7 +40,7 @@ interface IProps { * A message tile showing that this room was created as an upgrade of a previous * room. */ -export const RoomCreate: React.FC = ({ mxEvent, timestamp }) => { +export const RoomPredecessorTile: React.FC = ({ mxEvent, timestamp }) => { const msc3946ProcessDynamicPredecessor = SettingsStore.getValue("feature_dynamic_room_predecessors"); // Note: we ask the room for its predecessor here, instead of directly using @@ -74,13 +74,14 @@ export const RoomCreate: React.FC = ({ mxEvent, timestamp }) => { if (!roomContext.room || roomContext.room.roomId !== mxEvent.getRoomId()) { logger.warn( - "RoomCreate unexpectedly used outside of the context of the room containing this m.room.create event.", + "RoomPredecessorTile unexpectedly used outside of the context of the" + + "room containing this m.room.create event.", ); return <>; } if (!predecessor) { - logger.warn("RoomCreate unexpectedly used in a room with no predecessor."); + logger.warn("RoomPredecessorTile unexpectedly used in a room with no predecessor."); return
; } diff --git a/src/events/EventTileFactory.tsx b/src/events/EventTileFactory.tsx index 95e41395ac3..7f1a31518ad 100644 --- a/src/events/EventTileFactory.tsx +++ b/src/events/EventTileFactory.tsx @@ -34,7 +34,7 @@ import LegacyCallEvent from "../components/views/messages/LegacyCallEvent"; import { CallEvent } from "../components/views/messages/CallEvent"; import TextualEvent from "../components/views/messages/TextualEvent"; import EncryptionEvent from "../components/views/messages/EncryptionEvent"; -import { RoomCreate } from "../components/views/messages/RoomCreate"; +import { RoomPredecessorTile } from "../components/views/messages/RoomPredecessorTile"; import RoomAvatarEvent from "../components/views/messages/RoomAvatarEvent"; import { WIDGET_LAYOUT_EVENT_TYPE } from "../stores/widgets/WidgetLayoutStore"; import { ALL_RULE_TYPES } from "../mjolnir/BanList"; @@ -92,7 +92,7 @@ const HiddenEventFactory: Factory = (ref, props) => ; export const JSONEventFactory: Factory = (ref, props) => ; -export const RoomCreateEventFactory: Factory = (ref, props) => ; +export const RoomCreateEventFactory: Factory = (_ref, props) => ; const EVENT_TILE_TYPES = new Map([ [EventType.RoomMessage, MessageEventFactory], // note that verification requests are handled in pickFactory() diff --git a/test/components/views/messages/RoomCreate-test.tsx b/test/components/views/messages/RoomPredecessorTile-test.tsx similarity index 91% rename from test/components/views/messages/RoomCreate-test.tsx rename to test/components/views/messages/RoomPredecessorTile-test.tsx index cd5afa7ee2e..61bf8e82c5a 100644 --- a/test/components/views/messages/RoomCreate-test.tsx +++ b/test/components/views/messages/RoomPredecessorTile-test.tsx @@ -22,7 +22,7 @@ import { EventType, MatrixEvent, Room } from "matrix-js-sdk/src/matrix"; import dis from "../../../../src/dispatcher/dispatcher"; import SettingsStore from "../../../../src/settings/SettingsStore"; -import { RoomCreate } from "../../../../src/components/views/messages/RoomCreate"; +import { RoomPredecessorTile } from "../../../../src/components/views/messages/RoomPredecessorTile"; import { stubClient, upsertRoomStateEvents } from "../../../test-utils/test-utils"; import { Action } from "../../../../src/dispatcher/actions"; import RoomContext from "../../../../src/contexts/RoomContext"; @@ -31,7 +31,7 @@ import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; jest.mock("../../../../src/dispatcher/dispatcher"); -describe("", () => { +describe("", () => { const userId = "@alice:server.org"; const roomId = "!room:server.org"; const createEvent = new MatrixEvent({ @@ -97,21 +97,21 @@ describe("", () => { jest.spyOn(SettingsStore, "setValue").mockRestore(); }); - function renderRoomCreate(room: Room) { + function renderTile(room: Room) { return render( - + , ); } it("Renders as expected", () => { - const roomCreate = renderRoomCreate(roomJustCreate); + const roomCreate = renderTile(roomJustCreate); expect(roomCreate.asFragment()).toMatchSnapshot(); }); it("Links to the old version of the room", () => { - renderRoomCreate(roomJustCreate); + renderTile(roomJustCreate); expect(screen.getByText("Click here to see older messages.")).toHaveAttribute( "href", "https://matrix.to/#/old_room_id/tombstone_event_id", @@ -119,12 +119,12 @@ describe("", () => { }); it("Shows an empty div if there is no predecessor", () => { - renderRoomCreate(roomNoPredecessors); + renderTile(roomNoPredecessors); expect(screen.queryByText("Click here to see older messages.", { exact: false })).toBeNull(); }); it("Opens the old room on click", async () => { - renderRoomCreate(roomJustCreate); + renderTile(roomJustCreate); const link = screen.getByText("Click here to see older messages."); await act(() => userEvent.click(link)); @@ -142,7 +142,7 @@ describe("", () => { }); it("Ignores m.predecessor if labs flag is off", () => { - renderRoomCreate(roomCreateAndPredecessor); + renderTile(roomCreateAndPredecessor); expect(screen.getByText("Click here to see older messages.")).toHaveAttribute( "href", "https://matrix.to/#/old_room_id/tombstone_event_id", @@ -161,7 +161,7 @@ describe("", () => { }); it("Uses the create event if there is no m.predecessor", () => { - renderRoomCreate(roomJustCreate); + renderTile(roomJustCreate); expect(screen.getByText("Click here to see older messages.")).toHaveAttribute( "href", "https://matrix.to/#/old_room_id/tombstone_event_id", @@ -169,7 +169,7 @@ describe("", () => { }); it("Uses m.predecessor when it's there", () => { - renderRoomCreate(roomCreateAndPredecessor); + renderTile(roomCreateAndPredecessor); expect(screen.getByText("Click here to see older messages.")).toHaveAttribute( "href", "https://matrix.to/#/old_room_id_from_predecessor", @@ -177,7 +177,7 @@ describe("", () => { }); it("Links to the event in the room if event ID is provided", () => { - renderRoomCreate(roomCreateAndPredecessorWithEventId); + renderTile(roomCreateAndPredecessorWithEventId); expect(screen.getByText("Click here to see older messages.")).toHaveAttribute( "href", "https://matrix.to/#/old_room_id_from_predecessor/tombstone_event_id_from_predecessor", diff --git a/test/components/views/messages/__snapshots__/RoomCreate-test.tsx.snap b/test/components/views/messages/__snapshots__/RoomPredecessorTile-test.tsx.snap similarity index 88% rename from test/components/views/messages/__snapshots__/RoomCreate-test.tsx.snap rename to test/components/views/messages/__snapshots__/RoomPredecessorTile-test.tsx.snap index 97c1cee66f6..5e692de3fd5 100644 --- a/test/components/views/messages/__snapshots__/RoomCreate-test.tsx.snap +++ b/test/components/views/messages/__snapshots__/RoomPredecessorTile-test.tsx.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[` Renders as expected 1`] = ` +exports[` Renders as expected 1`] = `
Date: Mon, 6 Feb 2023 07:50:06 -0300 Subject: [PATCH 3/4] Add border to 'reject' button on room preview card (#9205) * Add border to 'reject' button on room preview card Signed-off-by: gefgu * feat: use correct kind --------- Signed-off-by: gefgu Co-authored-by: Michael Telatynski <7t3chguy@gmail.com> --- src/components/views/rooms/RoomPreviewCard.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/views/rooms/RoomPreviewCard.tsx b/src/components/views/rooms/RoomPreviewCard.tsx index 4c0a016f85d..2714bec93ab 100644 --- a/src/components/views/rooms/RoomPreviewCard.tsx +++ b/src/components/views/rooms/RoomPreviewCard.tsx @@ -116,7 +116,7 @@ const RoomPreviewCard: FC = ({ room, onJoinButtonClicked, onRejectButton joinButtons = ( <> { setBusy(true); onRejectButtonClicked(); From 5ba8ecabb52e7f35cae01b1a356da8d8a3f29594 Mon Sep 17 00:00:00 2001 From: Richard van der Hoff <1389908+richvdh@users.noreply.github.com> Date: Mon, 6 Feb 2023 10:50:34 +0000 Subject: [PATCH 4/4] Element-R: fix rageshages (#10081) quick hacks to get rageshakes working in element R Fixes https://github.com/vector-im/element-web/issues/24430 --- src/rageshake/submit-rageshake.ts | 3 ++- src/sentry.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/rageshake/submit-rageshake.ts b/src/rageshake/submit-rageshake.ts index 1024caadf0b..09f9ae60376 100644 --- a/src/rageshake/submit-rageshake.ts +++ b/src/rageshake/submit-rageshake.ts @@ -84,7 +84,8 @@ async function collectBugReport(opts: IOpts = {}, gzipLogs = true): Promise
{ - if (!client.isCryptoEnabled()) { + // TODO: make this work with rust crypto + if (!client.isCryptoEnabled() || !client.crypto) { return {}; } const keys = [`ed25519:${client.getDeviceEd25519Key()}`];