cheogram-www
created pr with
cheogram-www/tab-complete.1
commented
lgtm!
cmds
checkout latest patchset:
ssh pr.pico.sh pull cheogram-www:tab-complete | git am -3checkout specific patchset revision:
ssh pr.pico.sh pull cheogram-www:tab-complete [rev] | git am -3add changes to patch request:
git format-patch main --stdout | ssh pr.pico.sh cheogram-www:tab-completeadd comment to patch request:
ssh pr.pico.sh comment cheogram-www:tab-complete "lgtm!"
Patchset
cheogram-www/tab-complete.1
Tab complete nicks
Stephen Paul Weber
2026-11-07T23:30:58ZWorks 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 () => { | |
| 84 | 84 | expect(editor.getText()).toBe("\n"); | |
| 85 | 85 | expect(activeChat.typing).toHaveBeenLastCalledWith(null, ""); | |
| 86 | 86 | }); | |
| 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
#
| ... | ... | @@ -324,10 +326,34 @@ export const Quill = forwardRef( | |
| 324 | 326 | return false; | |
| 325 | 327 | }, | |
| 326 | 328 | }, | |
| 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 | + | }, | |
| 327 | 353 | }, | |
| 328 | 354 | }, | |
| 329 | 355 | mention: { | |
| 330 | - | mentionDenotationChars: ["@", ":"], | |
| 356 | + | mentionDenotationChars, | |
| 331 | 357 | isolateCharacter: true, | |
| 332 | 358 | allowedChars: (mentionChar: string) => { | |
| 333 | 359 | if (mentionChar === ":") return /^[a-zA-Z0-9_)(/$*-]*$/; |
| ... | ... | @@ -372,6 +398,11 @@ export const Quill = forwardRef( | |
| 372 | 398 | theme: "snow", | |
| 373 | 399 | }); | |
| 374 | 400 | ||
| 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 | + | ||
| 375 | 406 | ref.current = quill; | |
| 376 | 407 | if (typeof fwdRef === "function") { | |
| 377 | 408 | fwdRef(quill); |