feat: add type-to-focus composer
This commit is contained in:
parent
4690a637e2
commit
d3c8d6027e
10
CHANGELOG.md
10
CHANGELOG.md
@ -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
72
apps/api/internal/webassets/dist/assets/index-Ci-0jUjM.js
vendored
Normal file
72
apps/api/internal/webassets/dist/assets/index-Ci-0jUjM.js
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
4
apps/api/internal/webassets/dist/index.html
vendored
4
apps/api/internal/webassets/dist/index.html
vendored
@ -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>
|
||||
|
||||
@ -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()}>
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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,
|
||||
|
||||
159
tests/e2e/type-to-focus.spec.ts
Normal file
159
tests/e2e/type-to-focus.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user