Skip to content

Commit dc5f974

Browse files
authored
fix(room): keep composer focused and clear undo history on send (#1854)
<!-- Please read https://github.com/SableClient/Sable/blob/dev/CONTRIBUTING.md before submitting your pull request --> ### Description <!-- Please include a summary of the change. Please also include relevant motivation and context. List any dependencies that are required for this change. --> Fixes # #### Type of change - [ ] Bug fix (non-breaking change which fixes an issue) - [ ] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) - [ ] This change requires a documentation update ### Checklist: - [ ] My code follows the style guidelines of this project - [ ] I have performed a self-review of my own code - [ ] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [ ] My changes generate no new warnings ### AI disclosure: - [ ] Partially AI assisted (clarify which code was AI assisted and briefly explain what it does). - [ ] Fully AI generated (explain what all the generated code does in moderate detail). <!-- Write any explanation required here, but do not generate the explanation using AI!! You must prove you understand what the code in this PR does. -->
2 parents a2dc548 + fd128b5 commit dc5f974

5 files changed

Lines changed: 164 additions & 31 deletions

File tree

src/app/components/editor/Editor.test.tsx

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,32 @@ describe('CustomEditor', () => {
5757
expect(container.querySelector('[aria-label="Write a message"]')).toBeTruthy();
5858
expect(container.querySelector('.ProseMirror')).toBeTruthy();
5959
});
60+
61+
it('keeps focus on the editable when the document is cleared', () => {
62+
const { container, editor } = renderEditor();
63+
const editable = container.querySelector('.ProseMirror') as HTMLElement;
64+
editable.focus();
65+
66+
act(() => editor.insertText('some text'));
67+
act(() => editor.clear());
68+
69+
expect(editor.isEmpty()).toBe(true);
70+
expect(document.activeElement).toBe(editable);
71+
});
72+
73+
it('notifies document-change consumers when cleared so autocomplete closes', () => {
74+
const { editor } = renderEditor();
75+
const changes: string[] = [];
76+
editor.subscribe(() => changes.push(editor.getText()));
77+
78+
act(() => editor.insertText('hello'));
79+
expect(editor.getAutocompleteQuery(['h', 'he'])).toBeDefined();
80+
81+
act(() => editor.clear());
82+
83+
expect(changes).toEqual(['hello', '']);
84+
expect(editor.getAutocompleteQuery(['h', 'he'])).toBeUndefined();
85+
});
6086
});
6187

6288
describe('CustomEditor layout', () => {

src/app/components/editor/prosemirrorController.test.tsx

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,3 +234,30 @@ describe('clipboard', () => {
234234
expect(controller.getDocument()).toEqual(doc('first', '', 'third'));
235235
});
236236
});
237+
238+
describe('ProseMirrorEditorController clearHistory', () => {
239+
it('keeps undo working while composing, then wipes it after the send', () => {
240+
const { controller } = mount();
241+
242+
controller.insertText('hello');
243+
controller.undo();
244+
expect(controller.getDocument()).toEqual(doc(''));
245+
246+
controller.insertText('draft');
247+
controller.clear();
248+
controller.clearHistory();
249+
controller.undo();
250+
expect(controller.getDocument()).toEqual(doc(''));
251+
});
252+
253+
it('reuses the focused editable so rebuilding state does not steal focus', () => {
254+
const { controller, editable } = mount(doc('draft'));
255+
editable.focus();
256+
257+
act(() => controller.clear());
258+
act(() => controller.clearHistory());
259+
260+
expect(editable).toBe(document.activeElement);
261+
expect(editable).toHaveAttribute('data-placeholder-visible', 'true');
262+
});
263+
});

src/app/components/editor/prosemirrorController.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -123,10 +123,8 @@ export class ProseMirrorEditorController {
123123
this.setDocument(this.isEmpty() ? document : [...this.document, ...document]);
124124
}
125125

126-
mount(element: HTMLElement, attributes?: Record<string, string>): () => void {
127-
this.view?.destroy();
128-
this.attributes = attributes ?? {};
129-
const state = EditorState.create({
126+
private createState(): EditorState {
127+
return EditorState.create({
130128
doc: toProseMirrorDocument(this.document),
131129
plugins: [
132130
beginCommandPlugin,
@@ -138,6 +136,12 @@ export class ProseMirrorEditorController {
138136
],
139137
schema: editorSchema,
140138
});
139+
}
140+
141+
mount(element: HTMLElement, attributes?: Record<string, string>): () => void {
142+
this.view?.destroy();
143+
this.attributes = attributes ?? {};
144+
const state = this.createState();
141145
this.view = new EditorView(
142146
{ mount: element },
143147
{
@@ -186,6 +190,10 @@ export class ProseMirrorEditorController {
186190
this.setDocument(emptyEditorDocument());
187191
}
188192

193+
clearHistory(): void {
194+
if (this.view) this.view.updateState(this.createState());
195+
}
196+
189197
blur(): void {
190198
(this.view?.dom as HTMLElement | undefined)?.blur();
191199
}

src/app/features/room/RoomInput.test.tsx

Lines changed: 84 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -147,25 +147,29 @@ vi.mock('$components/editor', async () => {
147147
top,
148148
after,
149149
bottom,
150-
}: any) => (
151-
<div>
152-
{top}
153-
{before}
154-
<div
155-
data-editable-name={editableName}
156-
data-testid={editableName === 'RoomInput' ? 'room-input-editor' : undefined}
157-
data-editor-text={editableName === 'RoomInput' ? textOf(editor.children) : undefined}
158-
contentEditable
159-
role="textbox"
160-
aria-label="Room message"
161-
tabIndex={0}
162-
onInput={onChange}
163-
onKeyDown={onKeyDown}
164-
/>
165-
{after}
166-
{bottom}
167-
</div>
168-
);
150+
}: any) => {
151+
const [, setRevision] = useState(0);
152+
useEffect(() => editor.subscribe(() => setRevision((value) => value + 1)), [editor]);
153+
return (
154+
<div>
155+
{top}
156+
{before}
157+
<div
158+
data-editable-name={editableName}
159+
data-testid={editableName === 'RoomInput' ? 'room-input-editor' : undefined}
160+
data-editor-text={editableName === 'RoomInput' ? textOf(editor.children) : undefined}
161+
contentEditable
162+
role="textbox"
163+
aria-label="Room message"
164+
tabIndex={0}
165+
onInput={onChange}
166+
onKeyDown={onKeyDown}
167+
/>
168+
{after}
169+
{bottom}
170+
</div>
171+
);
172+
};
169173
return {
170174
AutocompletePrefix: {
171175
RoomMention: 'room-mention',
@@ -919,7 +923,6 @@ describe('RoomInput submit regressions', () => {
919923
fireEvent.click(screen.getByRole('button', { name: 'Prepare two attachments' }));
920924
fireEvent.keyDown(screen.getByTestId('room-input-editor'), { key: 'Enter', code: 'Enter' });
921925

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

1101+
it('keeps the composer focused after sending a text message', async () => {
1102+
render(<RoomInputHarness />);
1103+
fireEvent.click(screen.getByRole('button', { name: 'Compose text' }));
1104+
screen.getByTestId('room-input-editor').focus();
1105+
1106+
fireEvent.keyDown(screen.getByTestId('room-input-editor'), { key: 'Enter', code: 'Enter' });
1107+
1108+
await waitFor(() => expect(testState.matrix.sendMessage).toHaveBeenCalledOnce());
1109+
expect(document.activeElement).toBe(screen.getByTestId('room-input-editor'));
1110+
});
1111+
1112+
it('wipes the composer undo history when a message is sent', async () => {
1113+
const clearHistorySpy = vi.spyOn(ProseMirrorEditorController.prototype, 'clearHistory');
1114+
try {
1115+
render(<RoomInputHarness />);
1116+
fireEvent.click(screen.getByRole('button', { name: 'Compose text' }));
1117+
1118+
fireEvent.click(sendButton());
1119+
await waitFor(() => expect(testState.matrix.sendMessage).toHaveBeenCalledOnce());
1120+
1121+
expect(clearHistorySpy).toHaveBeenCalledOnce();
1122+
} finally {
1123+
clearHistorySpy.mockRestore();
1124+
}
1125+
});
1126+
1127+
it('keeps the composer focused when a reply is claimed by sending', async () => {
1128+
render(<RoomInputHarness initialReply />);
1129+
fireEvent.click(screen.getByRole('button', { name: 'Compose text' }));
1130+
screen.getByTestId('room-input-editor').focus();
1131+
fireEvent.keyDown(screen.getByTestId('room-input-editor'), { key: 'Enter', code: 'Enter' });
1132+
1133+
await waitFor(() => expect(testState.matrix.sendMessage).toHaveBeenCalledOnce());
1134+
expect(document.activeElement).toBe(screen.getByTestId('room-input-editor'));
1135+
});
1136+
1137+
it('keeps the composer focused when cancelling a reply on desktop', async () => {
1138+
render(<RoomInputHarness initialReply />);
1139+
screen.getByTestId('room-input-editor').focus();
1140+
1141+
fireEvent.keyDown(screen.getByTestId('room-input-editor'), { key: 'Escape', code: 'Escape' });
1142+
await act(async () => {
1143+
await new Promise((resolve) => requestAnimationFrame(resolve));
1144+
});
1145+
1146+
expect(document.activeElement).toBe(screen.getByTestId('room-input-editor'));
1147+
});
1148+
1149+
it('blurs the composer when cancelling a reply on mobile to dismiss the keyboard', async () => {
1150+
testState.isMobile = true;
1151+
render(<RoomInputHarness initialReply />);
1152+
screen.getByTestId('room-input-editor').focus();
1153+
1154+
fireEvent.keyDown(screen.getByTestId('room-input-editor'), { key: 'Escape', code: 'Escape' });
1155+
await act(async () => {
1156+
await new Promise((resolve) => requestAnimationFrame(resolve));
1157+
});
1158+
1159+
expect(document.activeElement).not.toBe(screen.getByTestId('room-input-editor'));
1160+
});
1161+
10981162
it('restores composed text when a scheduled send fails', async () => {
10991163
// Delayed events produce no local echo, so the composer is the only way back.
11001164
testState.sendDelayedMessage.mockRejectedValueOnce(new Error('schedule failed'));

src/app/features/room/RoomInput.tsx

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -486,7 +486,6 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
486486
};
487487
}, [draftKey]);
488488

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

693693
const epoch = draftEpochRef.current;
694+
claimedReplyEventIdRef.current = currentReply.eventId;
694695
replyDraftRef.current = replyDraftBase;
695696
setReplyDraft(replyDraftBase);
696697
return { epoch, snapshot: structuredClone(currentReply), silentReply };
@@ -701,6 +702,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
701702
if (replyDraftRef.current !== replyDraftBase) return;
702703
replyDraftRef.current = claim.snapshot;
703704
setReplyDraft(claim.snapshot);
705+
claimedReplyEventIdRef.current = undefined;
704706
},
705707
[replyDraftBase, setReplyDraft]
706708
);
@@ -949,7 +951,14 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
949951
// Ignore focus errors
950952
}
951953
});
952-
} else if (!newId && prevId && prevId !== threadRootId && !editId) {
954+
} else if (
955+
!newId &&
956+
prevId &&
957+
prevId !== threadRootId &&
958+
!editId &&
959+
prevId !== claimedReplyEventIdRef.current
960+
) {
961+
if (!isMobile) return;
953962
scheduleEditorRaf(() => {
954963
try {
955964
editor.blur();
@@ -960,7 +969,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
960969
});
961970
}
962971
}
963-
}, [replyDraft?.eventId, threadRootId, editId, editor, scheduleEditorRaf]);
972+
}, [replyDraft?.eventId, threadRootId, editId, isMobile, editor, scheduleEditorRaf]);
964973

965974
const handleFileMetadata = useCallback(
966975
(fileItem: TUploadItem, metadata: TUploadMetadata) => {
@@ -1061,7 +1070,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
10611070
};
10621071
if (clearEditor) {
10631072
editor.clear();
1064-
setInputKey((prev) => prev + 1);
1073+
editor.clearHistory();
10651074
imagePacksUsedRef.current.clear();
10661075
sendTypingStatus(false);
10671076
}
@@ -1981,7 +1990,6 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
19811990
<CustomEditor
19821991
editableName="RoomInput"
19831992
editor={editor}
1984-
key={inputKey}
19851993
placeholder="Send a message..."
19861994
enterKeyHint={enterForNewline ? 'enter' : 'send'}
19871995
suppressBlurRefocusRef={suppressBlurRefocusRef}
@@ -2500,7 +2508,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
25002508
return;
25012509
}
25022510
if (sentOnPointerUpRef.current) return;
2503-
submit();
2511+
submit().catch((error) => log.error('submit failed', { roomId }, error));
25042512
return;
25052513
}
25062514
if (!editorMicButton) return;
@@ -2579,7 +2587,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
25792587
return;
25802588
}
25812589
sentOnPointerUpRef.current = true;
2582-
submit();
2590+
submit().catch((error) => log.error('submit failed', { roomId }, error));
25832591
}}
25842592
onPointerCancel={() => {
25852593
if (longPressTimer.current !== null) {

0 commit comments

Comments
 (0)