cheogram-www

created pr with cheogram-www/tab-complete.1 on 2026-11-07T23:32:25Z · by 9e834e0d
commented on 2026-11-09T14:32:48Z · by 3c31bc8c
lgtm!
cmds
checkout latest patchset:
ssh pr.pico.sh pull cheogram-www:tab-complete | git am -3
checkout specific patchset revision:
ssh pr.pico.sh pull cheogram-www:tab-complete [rev] | git am -3
add changes to patch request:
git format-patch main --stdout | ssh pr.pico.sh cheogram-www:tab-complete
add comment to patch request:
ssh pr.pico.sh comment cheogram-www:tab-complete "lgtm!"

Patchset cheogram-www/tab-complete.1 on 2026-11-07T23:32:25Z · commit e9af6bf

Tab complete nicks
Stephen Paul Weber 2026-11-07T23:30:58Z
Works by inserting the @ when you press tab, which triggers the
@complete menu. Then you can press tab or enter again to select from the
menu (per usual) and the @ goes away again.
Semantic diff summary
3 added, 3 modified, 0 signature changed, 0 removed across 2 analyzed files
+88 -0 src/components/Chat.test.tsx #
......@@ -84,3 +84,91 @@ it("reports an empty typing string when the composer is cleared", async () => {
8484 expect(editor.getText()).toBe("\n");
8585 expect(activeChat.typing).toHaveBeenLastCalledWith(null, "");
8686 });
87+
88+it("opens the mention menu for the word before the cursor when Tab is pressed", async () => {
89+ const createRange = document.createRange.bind(document);
90+ vi.spyOn(document, "createRange").mockImplementation(() =>
91+ Object.assign(createRange(), {
92+ getBoundingClientRect: () => new DOMRect(),
93+ getClientRects: () => [],
94+ }),
95+ );
96+ vi.stubGlobal(
97+ "ResizeObserver",
98+ class {
99+ observe() {}
100+ unobserve() {}
101+ disconnect() {}
102+ },
103+ );
104+ const members = ["Bob", "Alice", "Carol", "Dave"].map((displayName, i) => ({
105+ id: `user${i}`,
106+ displayName,
107+ roles: [],
108+ showPresence: 0,
109+ }));
110+ const activeChat = mock<Chat>({
111+ chatId: "bob@example.com",
112+ getDisplayName: () => "Bob",
113+ isTrusted: () => true,
114+ callStatus: () => 0,
115+ canSend: () => true,
116+ members: (async () => members) as unknown as Chat["members"],
117+ });
118+ const context = {
119+ active: true,
120+ activeChat,
121+ client: mock<Client>(),
122+ getChatExtras: () => undefined,
123+ setChatExtras: vi.fn(),
124+ innerChatRef: { current: { addMention: vi.fn(), moderateRecent: vi.fn() } },
125+ isInvite: false,
126+ persistence: mock<Persistence & persistence.KeyValueStore>({
127+ get: async () => null,
128+ }),
129+ typingMembers: [],
130+ };
131+ const { container } = render(
132+ <MemoryRouter>
133+ <Routes>
134+ <Route element={<Outlet context={context} />}>
135+ <Route
136+ index
137+ element={
138+ <Await resolve={{ messages: [], members: new Map() }}>
139+ <ChatInner />
140+ </Await>
141+ }
142+ />
143+ </Route>
144+ </Routes>
145+ </MemoryRouter>,
146+ );
147+ const editorContainer = container.querySelector(".ql-container");
148+ if (!editorContainer) throw new Error("Composer container was not rendered");
149+
150+ const editor = Quill.find(editorContainer);
151+ if (!(editor instanceof Quill))
152+ throw new Error("Composer is not a Quill instance");
153+ vi.spyOn(editor, "hasFocus").mockReturnValue(true);
154+
155+ await act(async () => {
156+ editor.setText("bo", "user");
157+ editor.setSelection(2, 0, "user");
158+ });
159+ await act(async () => {
160+ editor.root.dispatchEvent(
161+ new KeyboardEvent("keydown", {
162+ key: "Tab",
163+ bubbles: true,
164+ cancelable: true,
165+ }),
166+ );
167+ await new Promise((resolve) => setTimeout(resolve, 80));
168+ });
169+
170+ expect(editor.getText()).toBe("@bo\n");
171+ const items = container.querySelectorAll("#quill-mention-list li");
172+ expect(items.length).toBeGreaterThan(0);
173+ expect(items[0]?.getAttribute("data-value")).toBe("Bob");
174+});
+32 -1 src/components/Quill.tsx #
......@@ -143,6 +143,8 @@ export const Quill = forwardRef(
143143 quill.setSelection(range.index + 1, QuillInner.sources.SILENT);
144144 };
145145
146+ const mentionDenotationChars = ["@", ":"];
147+
146148 const quill = new QuillInner(editorContainer, {
147149 modules: {
148150 clipboard: true,
......@@ -324,10 +326,34 @@ export const Quill = forwardRef(
324326 return false;
325327 },
326328 },
329+ "tab complete": {
330+ key: "Tab",
331+ collapsed: true,
332+ shiftKey: false,
333+ handler: (range: { index: number }) => {
334+ const start = Math.max(0, range.index - 30);
335+ const before = quill.getText(start, range.index - start);
336+ const match = before.match(/[a-zA-Z0-9_]+$/);
337+ if (!match) return true;
338+
339+ const prefixStart = range.index - match[0].length;
340+ const preceding =
341+ prefixStart > 0 ? quill.getText(prefixStart - 1, 1) : "";
342+ if (preceding !== "" && !/\b/.test(preceding)) return true;
343+
344+ quill.insertText(prefixStart, "@", QuillInner.sources.USER);
345+ quill.setSelection(
346+ prefixStart + 1 + match[0].length,
347+ 0,
348+ QuillInner.sources.SILENT,
349+ );
350+ return false;
351+ },
352+ },
327353 },
328354 },
329355 mention: {
330- mentionDenotationChars: ["@", ":"],
356+ mentionDenotationChars,
331357 isolateCharacter: true,
332358 allowedChars: (mentionChar: string) => {
333359 if (mentionChar === ":") return /^[a-zA-Z0-9_)(/$*-]*$/;
......@@ -372,6 +398,11 @@ export const Quill = forwardRef(
372398 theme: "snow",
373399 });
374400
401+ // Put our tab handler after the quill-mention one
402+ const tabBindings = quill.keyboard.bindings["Tab"];
403+ const tabMentionBinding = tabBindings.pop();
404+ if (tabMentionBinding) tabBindings.splice(1, 0, tabMentionBinding);
405+
375406 ref.current = quill;
376407 if (typeof fwdRef === "function") {
377408 fwdRef(quill);
Back to top