Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions src/app/components/editor/Editor.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,32 @@ describe('CustomEditor', () => {
expect(container.querySelector('[aria-label="Write a message"]')).toBeTruthy();
expect(container.querySelector('.ProseMirror')).toBeTruthy();
});

it('keeps focus on the editable when the document is cleared', () => {
const { container, editor } = renderEditor();
const editable = container.querySelector('.ProseMirror') as HTMLElement;
editable.focus();

act(() => editor.insertText('some text'));
act(() => editor.clear());

expect(editor.isEmpty()).toBe(true);
expect(document.activeElement).toBe(editable);
});

it('notifies document-change consumers when cleared so autocomplete closes', () => {
const { editor } = renderEditor();
const changes: string[] = [];
editor.subscribe(() => changes.push(editor.getText()));

act(() => editor.insertText('hello'));
expect(editor.getAutocompleteQuery(['h', 'he'])).toBeDefined();

act(() => editor.clear());

expect(changes).toEqual(['hello', '']);
expect(editor.getAutocompleteQuery(['h', 'he'])).toBeUndefined();
});
});

describe('CustomEditor layout', () => {
Expand Down
27 changes: 27 additions & 0 deletions src/app/components/editor/prosemirrorController.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -234,3 +234,30 @@ describe('clipboard', () => {
expect(controller.getDocument()).toEqual(doc('first', '', 'third'));
});
});

describe('ProseMirrorEditorController clearHistory', () => {
it('keeps undo working while composing, then wipes it after the send', () => {
const { controller } = mount();

controller.insertText('hello');
controller.undo();
expect(controller.getDocument()).toEqual(doc(''));

controller.insertText('draft');
controller.clear();
controller.clearHistory();
controller.undo();
expect(controller.getDocument()).toEqual(doc(''));
});

it('reuses the focused editable so rebuilding state does not steal focus', () => {
const { controller, editable } = mount(doc('draft'));
editable.focus();

act(() => controller.clear());
act(() => controller.clearHistory());

expect(editable).toBe(document.activeElement);
expect(editable).toHaveAttribute('data-placeholder-visible', 'true');
});
});
16 changes: 12 additions & 4 deletions src/app/components/editor/prosemirrorController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,10 +123,8 @@ export class ProseMirrorEditorController {
this.setDocument(this.isEmpty() ? document : [...this.document, ...document]);
}

mount(element: HTMLElement, attributes?: Record<string, string>): () => void {
this.view?.destroy();
this.attributes = attributes ?? {};
const state = EditorState.create({
private createState(): EditorState {
return EditorState.create({
doc: toProseMirrorDocument(this.document),
plugins: [
beginCommandPlugin,
Expand All @@ -138,6 +136,12 @@ export class ProseMirrorEditorController {
],
schema: editorSchema,
});
}

mount(element: HTMLElement, attributes?: Record<string, string>): () => void {
this.view?.destroy();
this.attributes = attributes ?? {};
const state = this.createState();
this.view = new EditorView(
{ mount: element },
{
Expand Down Expand Up @@ -186,6 +190,10 @@ export class ProseMirrorEditorController {
this.setDocument(emptyEditorDocument());
}

clearHistory(): void {
if (this.view) this.view.updateState(this.createState());
}

blur(): void {
(this.view?.dom as HTMLElement | undefined)?.blur();
}
Expand Down
104 changes: 84 additions & 20 deletions src/app/features/room/RoomInput.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -147,25 +147,29 @@ vi.mock('$components/editor', async () => {
top,
after,
bottom,
}: any) => (
<div>
{top}
{before}
<div
data-editable-name={editableName}
data-testid={editableName === 'RoomInput' ? 'room-input-editor' : undefined}
data-editor-text={editableName === 'RoomInput' ? textOf(editor.children) : undefined}
contentEditable
role="textbox"
aria-label="Room message"
tabIndex={0}
onInput={onChange}
onKeyDown={onKeyDown}
/>
{after}
{bottom}
</div>
);
}: any) => {
const [, setRevision] = useState(0);
useEffect(() => editor.subscribe(() => setRevision((value) => value + 1)), [editor]);
return (
<div>
{top}
{before}
<div
data-editable-name={editableName}
data-testid={editableName === 'RoomInput' ? 'room-input-editor' : undefined}
data-editor-text={editableName === 'RoomInput' ? textOf(editor.children) : undefined}
contentEditable
role="textbox"
aria-label="Room message"
tabIndex={0}
onInput={onChange}
onKeyDown={onKeyDown}
/>
{after}
{bottom}
</div>
);
};
return {
AutocompletePrefix: {
RoomMention: 'room-mention',
Expand Down Expand Up @@ -919,7 +923,6 @@ describe('RoomInput submit regressions', () => {
fireEvent.click(screen.getByRole('button', { name: 'Prepare two attachments' }));
fireEvent.keyDown(screen.getByTestId('room-input-editor'), { key: 'Enter', code: 'Enter' });

// Clearing the composer remounts the editor subtree, so re-query the button.
await waitFor(() => expect(testState.matrix.sendMessage).toHaveBeenCalledTimes(2));
expect(sendButton()).toBeDisabled();
fireEvent.click(sendButton());
Expand Down Expand Up @@ -1095,6 +1098,67 @@ describe('RoomInput submit regressions', () => {
);
});

it('keeps the composer focused after sending a text message', async () => {
render(<RoomInputHarness />);
fireEvent.click(screen.getByRole('button', { name: 'Compose text' }));
screen.getByTestId('room-input-editor').focus();

fireEvent.keyDown(screen.getByTestId('room-input-editor'), { key: 'Enter', code: 'Enter' });

await waitFor(() => expect(testState.matrix.sendMessage).toHaveBeenCalledOnce());
expect(document.activeElement).toBe(screen.getByTestId('room-input-editor'));
});

it('wipes the composer undo history when a message is sent', async () => {
const clearHistorySpy = vi.spyOn(ProseMirrorEditorController.prototype, 'clearHistory');
try {
render(<RoomInputHarness />);
fireEvent.click(screen.getByRole('button', { name: 'Compose text' }));

fireEvent.click(sendButton());
await waitFor(() => expect(testState.matrix.sendMessage).toHaveBeenCalledOnce());

expect(clearHistorySpy).toHaveBeenCalledOnce();
} finally {
clearHistorySpy.mockRestore();
}
});

it('keeps the composer focused when a reply is claimed by sending', async () => {
render(<RoomInputHarness initialReply />);
fireEvent.click(screen.getByRole('button', { name: 'Compose text' }));
screen.getByTestId('room-input-editor').focus();
fireEvent.keyDown(screen.getByTestId('room-input-editor'), { key: 'Enter', code: 'Enter' });

await waitFor(() => expect(testState.matrix.sendMessage).toHaveBeenCalledOnce());
expect(document.activeElement).toBe(screen.getByTestId('room-input-editor'));
});

it('keeps the composer focused when cancelling a reply on desktop', async () => {
render(<RoomInputHarness initialReply />);
screen.getByTestId('room-input-editor').focus();

fireEvent.keyDown(screen.getByTestId('room-input-editor'), { key: 'Escape', code: 'Escape' });
await act(async () => {
await new Promise((resolve) => requestAnimationFrame(resolve));
});

expect(document.activeElement).toBe(screen.getByTestId('room-input-editor'));
});

it('blurs the composer when cancelling a reply on mobile to dismiss the keyboard', async () => {
testState.isMobile = true;
render(<RoomInputHarness initialReply />);
screen.getByTestId('room-input-editor').focus();

fireEvent.keyDown(screen.getByTestId('room-input-editor'), { key: 'Escape', code: 'Escape' });
await act(async () => {
await new Promise((resolve) => requestAnimationFrame(resolve));
});

expect(document.activeElement).not.toBe(screen.getByTestId('room-input-editor'));
});

it('restores composed text when a scheduled send fails', async () => {
// Delayed events produce no local echo, so the composer is the only way back.
testState.sendDelayedMessage.mockRejectedValueOnce(new Error('schedule failed'));
Expand Down
22 changes: 15 additions & 7 deletions src/app/features/room/RoomInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -486,7 +486,6 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
};
}, [draftKey]);

const [inputKey, setInputKey] = useState(0);
const getUploadItemKey = useCallback((fileItem: TUploadItem): string => {
const existingKey = uploadItemKeysRef.current.get(fileItem.originalFile);
if (existingKey) return existingKey;
Expand Down Expand Up @@ -686,11 +685,13 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
const [silentReply, setSilentReply] = useState(!mentionInReplies);
// Clears the reply draft up front so it cannot be re-sent, keeping a snapshot to
// restore if the send never lands.
const claimedReplyEventIdRef = useRef<string | undefined>();
const claimReply = useCallback((): ReplyClaim | undefined => {
const currentReply = replyDraftRef.current;
if (!currentReply) return undefined;

const epoch = draftEpochRef.current;
claimedReplyEventIdRef.current = currentReply.eventId;
replyDraftRef.current = replyDraftBase;
setReplyDraft(replyDraftBase);
return { epoch, snapshot: structuredClone(currentReply), silentReply };
Expand All @@ -701,6 +702,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
if (replyDraftRef.current !== replyDraftBase) return;
replyDraftRef.current = claim.snapshot;
setReplyDraft(claim.snapshot);
claimedReplyEventIdRef.current = undefined;
},
[replyDraftBase, setReplyDraft]
);
Expand Down Expand Up @@ -949,7 +951,14 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
// Ignore focus errors
}
});
} else if (!newId && prevId && prevId !== threadRootId && !editId) {
} else if (
!newId &&
prevId &&
prevId !== threadRootId &&
!editId &&
prevId !== claimedReplyEventIdRef.current
) {
if (!isMobile) return;
scheduleEditorRaf(() => {
try {
editor.blur();
Expand All @@ -960,7 +969,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
});
}
}
}, [replyDraft?.eventId, threadRootId, editId, editor, scheduleEditorRaf]);
}, [replyDraft?.eventId, threadRootId, editId, isMobile, editor, scheduleEditorRaf]);

const handleFileMetadata = useCallback(
(fileItem: TUploadItem, metadata: TUploadMetadata) => {
Expand Down Expand Up @@ -1061,7 +1070,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
};
if (clearEditor) {
editor.clear();
setInputKey((prev) => prev + 1);
editor.clearHistory();
imagePacksUsedRef.current.clear();
sendTypingStatus(false);
}
Expand Down Expand Up @@ -1981,7 +1990,6 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
<CustomEditor
editableName="RoomInput"
editor={editor}
key={inputKey}
placeholder="Send a message..."
enterKeyHint={enterForNewline ? 'enter' : 'send'}
suppressBlurRefocusRef={suppressBlurRefocusRef}
Expand Down Expand Up @@ -2500,7 +2508,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
return;
}
if (sentOnPointerUpRef.current) return;
submit();
submit().catch((error) => log.error('submit failed', { roomId }, error));
return;
}
if (!editorMicButton) return;
Expand Down Expand Up @@ -2579,7 +2587,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
return;
}
sentOnPointerUpRef.current = true;
submit();
submit().catch((error) => log.error('submit failed', { roomId }, error));
}}
onPointerCancel={() => {
if (longPressTimer.current !== null) {
Expand Down
Loading