feat: add type-to-focus composer

This commit is contained in:
Shakker 2026-05-08 13:56:07 +01:00
parent 4690a637e2
commit d3c8d6027e
No known key found for this signature in database
9 changed files with 404 additions and 80 deletions

View File

@ -2,6 +2,16 @@
## Unreleased
- Added type-to-focus on the chat composer: pressing a printable key while
focus is outside any text field (and no modal/menu is open) now jumps the
caret to the active composer — the thread reply textarea when a thread pane
is open, otherwise the channel/DM composer — so the keystroke lands as the
next character of your draft. The composer also auto-grows as the draft
spans multiple lines (Discord-style), capped at half the viewport before a
scrollbar appears, and shrinks back to a single row after sending. IME
composition, modifier shortcuts, text fields, menus, media controls, and
active text selections inside messages or threads are preserved untouched.
Thanks @shakkernerd.
- Added inline quote-replies in channels, DMs, and threads. Every
message-create endpoint now accepts an optional `quoted_message_id`; the
server captures a 280-rune trimmed snapshot of the quoted body plus the

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ClickClack</title>
<script type="module" crossorigin src="/assets/index-BFDV1AHN.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-ocU3Zi-h.css">
<script type="module" crossorigin src="/assets/index-Ci-0jUjM.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DnVRnSi7.css">
</head>
<body>
<div id="app"></div>

View File

@ -44,6 +44,9 @@
let mobileNavOpen = false;
let replyTarget: Message | null = null;
let replyContext: "channel" | "dm" | "thread" | null = null;
let messageInput: HTMLTextAreaElement | null = null;
let replyInput: HTMLTextAreaElement | null = null;
let activeComposerContext: "message" | "thread" = "message";
$: selectedWorkspace = workspaces.find((workspace) => workspace.id === selectedWorkspaceID);
$: selectedChannel = channels.find((channel) => channel.id === selectedChannelID);
@ -190,6 +193,7 @@
selectedChannelID = channels.find((channel) => channel.id === selectedChannelID)?.id || channels[0]?.id || "";
selectedThread = null;
selectedProfile = null;
activeComposerContext = "message";
replies = [];
await loadMessages();
}
@ -267,6 +271,7 @@
async function openThread(message: Message) {
selectedProfile = null;
selectedThread = message;
activeComposerContext = "thread";
const data = await api<{ root: Message; replies: Message[]; thread_state: ThreadState }>(`/api/messages/${message.id}/thread`);
selectedThread = data.root;
replies = data.replies;
@ -294,6 +299,110 @@
function setReplyTarget(message: Message, context: "channel" | "dm" | "thread") {
replyTarget = message;
replyContext = context;
activeComposerContext = context === "thread" ? "thread" : "message";
}
const KEY_CONSUMING_ROLES = new Set([
"button",
"checkbox",
"combobox",
"link",
"listbox",
"menu",
"menubar",
"menuitem",
"menuitemcheckbox",
"menuitemradio",
"option",
"radio",
"radiogroup",
"slider",
"spinbutton",
"switch",
"tab",
"tablist",
"textbox",
"tree",
"treeitem",
]);
const KEY_CONSUMING_TAGS = new Set(["INPUT", "TEXTAREA", "SELECT", "BUTTON", "A", "DETAILS", "SUMMARY", "VIDEO", "AUDIO"]);
function isModalOpen(): boolean {
return selectedImage !== null || showProfileSettings;
}
function isEditableElement(el: HTMLElement | null): boolean {
if (!el) return false;
if (el.isContentEditable) return true;
if (el instanceof HTMLInputElement) {
const t = (el.type || "text").toLowerCase();
return t !== "checkbox" && t !== "radio" && t !== "button" && t !== "submit" && t !== "reset" && t !== "file";
}
if (el instanceof HTMLTextAreaElement) return true;
return false;
}
function consumesKeystrokes(el: HTMLElement | null): boolean {
if (!el) return false;
if (isChatSurfaceAction(el)) return false;
if (KEY_CONSUMING_TAGS.has(el.tagName)) return true;
const role = el.getAttribute("role");
if (role && KEY_CONSUMING_ROLES.has(role)) return true;
const tabindex = el.getAttribute("tabindex");
if (tabindex !== null && tabindex !== "-1" && el.hasAttribute("aria-keyshortcuts")) return true;
return false;
}
function isChatSurfaceAction(el: HTMLElement): boolean {
if (!el.closest(".messages, .thread")) return false;
if (el instanceof HTMLButtonElement || el instanceof HTMLAnchorElement) return true;
const role = el.getAttribute("role");
return role === "button" || role === "link";
}
function hasMessageTextSelection(): boolean {
const sel = typeof window !== "undefined" ? window.getSelection() : null;
if (!sel || sel.isCollapsed || sel.rangeCount === 0) return false;
const node = sel.getRangeAt(0).commonAncestorContainer;
if (!node) return false;
const host = node.nodeType === Node.ELEMENT_NODE ? (node as HTMLElement) : node.parentElement;
return !!host?.closest(".messages, .thread, .markdown");
}
function shouldRedirectKeystroke(event: KeyboardEvent): boolean {
if (authRequired) return false;
if (isModalOpen()) return false;
if (event.defaultPrevented) return false;
if (event.isComposing || event.keyCode === 229) return false;
if (event.ctrlKey || event.metaKey || event.altKey) return false;
if (event.key.length !== 1) return false;
if (hasMessageTextSelection()) return false;
const active = document.activeElement as HTMLElement | null;
if (active === messageInput || active === replyInput) return false;
if (isEditableElement(active)) return false;
if (consumesKeystrokes(active)) return false;
return true;
}
function redirectTypingToComposer(event: KeyboardEvent) {
if (!shouldRedirectKeystroke(event)) return;
const target = activeComposerTarget();
if (!target || target.disabled || target.readOnly) return;
if (event.key === " ") event.preventDefault();
target.focus({ preventScroll: true });
const len = target.value.length;
target.setSelectionRange(len, len);
if (event.key === " ") {
const start = target.selectionStart ?? len;
const end = target.selectionEnd ?? len;
target.setRangeText(" ", start, end, "end");
target.dispatchEvent(new Event("input", { bubbles: true }));
}
}
function activeComposerTarget(): HTMLTextAreaElement | null {
if (activeComposerContext === "thread" && selectedThread && replyInput) return replyInput;
return messageInput;
}
function clearReplyTarget() {
@ -301,6 +410,30 @@
replyContext = null;
}
function autoGrow(node: HTMLTextAreaElement, _value: string) {
const resize = () => {
const previous = node.style.height;
node.style.height = "auto";
const next = `${node.scrollHeight}px`;
if (previous !== next) node.style.height = next;
else node.style.height = previous;
};
const onInput = () => resize();
const onWindowResize = () => resize();
requestAnimationFrame(resize);
node.addEventListener("input", onInput);
window.addEventListener("resize", onWindowResize);
return {
update() {
requestAnimationFrame(resize);
},
destroy() {
node.removeEventListener("input", onInput);
window.removeEventListener("resize", onWindowResize);
},
};
}
function quoteSnippet(text: string | undefined, max = 120): string {
if (!text) return "";
const collapsed = text.replace(/\s+/g, " ").trim();
@ -363,6 +496,7 @@
selectedChannelID = "";
selectedThread = null;
selectedProfile = null;
activeComposerContext = "message";
await loadMessages();
}
@ -376,6 +510,7 @@
selectedChannelID = "";
selectedThread = null;
selectedProfile = null;
activeComposerContext = "message";
await loadMessages();
return;
}
@ -388,6 +523,7 @@
selectedChannelID = "";
selectedThread = null;
selectedProfile = null;
activeComposerContext = "message";
await loadMessages();
}
@ -636,6 +772,7 @@
if (replyContext === "thread") clearReplyTarget();
selectedThread = null;
selectedProfile = null;
activeComposerContext = "message";
replies = [];
}
@ -652,6 +789,7 @@
<svelte:window
onkeydown={(event) => {
if (event.key === "Escape") closeModal();
redirectTypingToComposer(event);
}}
/>
@ -778,6 +916,7 @@
selectedDirectID = "";
selectedThread = null;
selectedProfile = null;
activeComposerContext = "message";
mobileNavOpen = false;
await loadMessages();
}}
@ -816,6 +955,7 @@
selectedChannelID = "";
selectedThread = null;
selectedProfile = null;
activeComposerContext = "message";
mobileNavOpen = false;
await loadMessages();
}}
@ -864,6 +1004,7 @@
selectedChannelID = "";
selectedThread = null;
selectedProfile = null;
activeComposerContext = "message";
mobileNavOpen = false;
await loadMessages();
} else {
@ -1029,6 +1170,7 @@
role="log"
aria-live="polite"
bind:this={messageList}
onpointerdown={() => (activeComposerContext = "message")}
onpointerup={handleInlineImagePointerUp}
>
{#if messages.length === 0}
@ -1246,10 +1388,13 @@
</svg>
</label>
<textarea
bind:this={messageInput}
bind:value={messageBody}
use:autoGrow={messageBody}
rows="1"
placeholder={selectedDirect ? `Message ${dmTitle(selectedDirect)}` : selectedChannel ? `Message #${selectedChannel.name}` : "Pick a channel to start"}
aria-label="Message body"
onfocus={() => (activeComposerContext = "message")}
onkeydown={handleComposerKey}
></textarea>
<button type="submit" class="send" aria-label="Send" disabled={!messageBody.trim()}>
@ -1279,7 +1424,13 @@
}}
>×</button>
</header>
<div class="thread-scroll" role="region" aria-label="Thread messages" onpointerup={handleInlineImagePointerUp}>
<div
class="thread-scroll"
role="region"
aria-label="Thread messages"
onpointerdown={() => (activeComposerContext = "thread")}
onpointerup={handleInlineImagePointerUp}
>
<article class="thread-root" data-message-id={selectedThread.id}>
<div class="avatar" style="--hue: {avatarHue(selectedThread.author?.id || selectedThread.author_id || 'x')}deg">
{#if selectedThread.author?.avatar_url}
@ -1437,10 +1588,13 @@
{/if}
<div class="composer-row">
<textarea
bind:this={replyInput}
bind:value={replyBody}
use:autoGrow={replyBody}
rows="1"
placeholder="Reply in thread"
aria-label="Reply body"
onfocus={() => (activeComposerContext = "thread")}
onkeydown={handleReplyKey}
></textarea>
<button type="submit" class="send" aria-label="Reply" disabled={!replyBody.trim()}>

View File

@ -2,7 +2,7 @@ import DOMPurify from "dompurify";
import { marked } from "marked";
export function markdown(body: string) {
return DOMPurify.sanitize(marked.parse(body, { async: false }));
return DOMPurify.sanitize(marked.parse(body, { async: false, breaks: true, gfm: true }));
}
export function time(value: string) {

View File

@ -1683,16 +1683,17 @@ button.ghost {
.composer textarea,
.reply-composer textarea {
width: 100%;
height: 36px;
height: auto;
min-height: 36px;
max-height: 160px;
max-height: 50vh;
padding: 8px 4px;
resize: none;
color: var(--text);
line-height: 1.45;
font-size: 14.5px;
align-self: end;
overflow: auto;
overflow-y: auto;
box-sizing: border-box;
}
.composer textarea::placeholder,

View File

@ -0,0 +1,159 @@
import { expect, test } from "@playwright/test";
test.describe("type-to-focus composer", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/app");
await page.getByRole("button", { name: "# general" }).click();
await expect(page.getByRole("heading", { name: "#general" })).toBeVisible();
});
test("typing while no input is focused redirects keystrokes to the channel composer", async ({
page,
}) => {
const composer = page.getByLabel("Message body");
await page.locator("body").click({ position: { x: 5, y: 5 } });
await expect(composer).not.toBeFocused();
await page.keyboard.type("hello world");
await expect(composer).toBeFocused();
await expect(composer).toHaveValue("hello world");
});
test("modifier-key combos are not redirected", async ({ page }) => {
const composer = page.getByLabel("Message body");
await page.locator(".messages").click({ position: { x: 10, y: 10 } });
await expect(composer).not.toBeFocused();
await page.keyboard.press("Control+a");
await page.keyboard.press("Meta+r").catch(() => {});
await expect(composer).not.toBeFocused();
});
test("typing in an existing input does not jump focus to the composer", async ({ page }) => {
const composer = page.getByLabel("Message body");
const search = page.getByPlaceholder(/search/i).first();
if ((await search.count()) === 0) test.skip(true, "no search input in this build");
await search.click();
await page.keyboard.type("abc");
await expect(composer).not.toBeFocused();
await expect(search).toBeFocused();
});
test("space key does not scroll the page and lands in the composer", async ({ page }) => {
const composer = page.getByLabel("Message body");
const messages = page.locator(".messages");
await messages.click({ position: { x: 10, y: 10 } });
const beforeScroll = await messages.evaluate((el) => el.scrollTop);
await page.keyboard.press("Space");
const afterScroll = await messages.evaluate((el) => el.scrollTop);
expect(afterScroll).toBe(beforeScroll);
await expect(composer).toBeFocused();
await expect(composer).toHaveValue(" ");
});
test("redirect targets the thread composer when a thread is open", async ({ page }) => {
await page.getByLabel("Message body").fill("thread root");
await page.getByRole("button", { name: "Send" }).click();
const row = page.locator(".message-row", {
has: page.locator(".markdown").filter({ hasText: "thread root" }),
});
await row.hover();
await row.getByRole("button", { name: "Open thread" }).click();
const threadComposer = page.getByLabel("Reply body");
await expect(threadComposer).toBeVisible();
await expect(threadComposer).not.toBeFocused();
await page.keyboard.type("inside thread");
await expect(threadComposer).toBeFocused();
await expect(threadComposer).toHaveValue("inside thread");
});
test("typing after chat action buttons still redirects to the active composer", async ({
page,
}) => {
await page.getByLabel("Message body").fill("button focus root");
await page.getByRole("button", { name: "Send" }).click();
const row = page.locator(".message-row", {
has: page.locator(".markdown").filter({ hasText: "button focus root" }),
});
await row.hover();
await row.getByRole("button", { name: "Open thread" }).click();
const threadComposer = page.getByLabel("Reply body");
await expect(threadComposer).toBeVisible();
await row.hover();
await row.getByRole("button", { name: "Reply" }).click();
const composer = page.getByLabel("Message body");
await expect(composer).not.toBeFocused();
await page.keyboard.type("channel draft");
await expect(composer).toBeFocused();
await expect(composer).toHaveValue("channel draft");
await composer.fill("");
await page.locator(".thread-root .reply-quote-btn").click();
await expect(threadComposer).not.toBeFocused();
await page.keyboard.type("thread draft");
await expect(threadComposer).toBeFocused();
await expect(threadComposer).toHaveValue("thread draft");
});
test("typing with selected thread quote text does not redirect to composer", async ({ page }) => {
await page.getByLabel("Message body").fill("thread quote root");
await page.getByRole("button", { name: "Send" }).click();
const row = page.locator(".message-row", {
has: page.locator(".markdown").filter({ hasText: "thread quote root" }),
});
await row.hover();
await row.getByRole("button", { name: "Open thread" }).click();
const threadComposer = page.getByLabel("Reply body");
await expect(threadComposer).toBeVisible();
await page.locator(".thread-root .reply-quote-btn").click();
await threadComposer.fill("quoted reply");
await page
.locator("form.reply-composer")
.getByRole("button", { name: "Reply", exact: true })
.click();
const quoteSnippet = page.locator(".thread .quote-block .quote-snippet", {
hasText: "thread quote root",
});
await expect(quoteSnippet).toBeVisible();
await quoteSnippet.evaluate((el) => {
const range = document.createRange();
range.selectNodeContents(el);
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
});
await page.keyboard.type("x");
await expect(threadComposer).not.toBeFocused();
await expect(threadComposer).toHaveValue("");
});
test("composer auto-grows with newlines and shrinks back after send", async ({ page }) => {
const composer = page.getByLabel("Message body");
await composer.click();
const initialHeight = await composer.evaluate((el) => el.getBoundingClientRect().height);
await composer.fill("line one\nline two\nline three\nline four\nline five");
const grownHeight = await composer.evaluate((el) => el.getBoundingClientRect().height);
expect(grownHeight).toBeGreaterThan(initialHeight + 30);
const max = await composer.evaluate((el) => parseFloat(getComputedStyle(el).maxHeight));
expect(grownHeight).toBeLessThanOrEqual(max + 1);
await composer.fill(Array.from({ length: 60 }, (_, i) => `row ${i}`).join("\n"));
const cappedHeight = await composer.evaluate((el) => el.getBoundingClientRect().height);
expect(cappedHeight).toBeLessThanOrEqual(max + 1);
const scrollable = await composer.evaluate((el) => el.scrollHeight > el.clientHeight + 1);
expect(scrollable).toBe(true);
await composer.fill("");
await page.waitForTimeout(50);
const afterClearHeight = await composer.evaluate((el) => el.getBoundingClientRect().height);
expect(Math.abs(afterClearHeight - initialHeight)).toBeLessThan(2);
});
});