| index 0b56f82..d8bd06f 100644 |
| --- a/web/static/chat.js |
| +++ b/web/static/chat.js |
| @@ -606,7 +606,7 @@ function agentTag(muted) { |
| t.textContent = "BOT"; |
| t.title = "This is an agent (bot), not a person"; |
| t.style.cssText = |
| - "margin-left:6px;font-size:0.7em;font-weight:600;letter-spacing:0.03em;padding:1px 5px;border-radius:6px;vertical-align:middle;" + |
| + "margin-left:6px;font-size:0.7em;font-weight:600;letter-spacing:0.03em;padding:1px 5px;border-radius:6px;vertical-align:middle;transform:translateY(-1px);" + |
| (muted ? "background:var(--border,#ddd);color:var(--muted,#666);" : "background:var(--accent,#888);color:#fff;"); |
| return t; |
| } |
| @@ -1151,6 +1151,79 @@ function renderApp() { |
| if (isCoarse) text.focus(); // keep the on-screen keyboard up after sending |
| }; |
| |
| + // Keyboard shortcuts: cmd-T opens message menu, cmd-R starts quick reply. |
| + // When the menu is open, number keys trigger reactions or actions. |
| + let reactionSheetKeyListener = null; |
| + |
| + function getLatestMessage(othersOnly = false) { |
| + const feed = document.getElementById("feed"); |
| + if (!feed) return null; |
| + const messages = feed.querySelectorAll(".msg"); |
| + if (!messages.length) return null; |
| + // Find the latest message, optionally filtering to exclude own messages |
| + for (let i = messages.length - 1; i >= 0; i--) { |
| + const msgId = messages[i].dataset.id; |
| + if (!msgId) continue; |
| + if (othersOnly && msgData[msgId]?.mine) continue; |
| + return { id: msgId, el: messages[i] }; |
| + } |
| + return null; |
| + } |
| + |
| + function attachReactionSheetKeyHandler(msgId, wrap) { |
| + // Wait a tick for the sheet to be in the DOM |
| + setTimeout(() => { |
| + const sheet = document.querySelector(".msg-sheet"); |
| + if (!sheet) return; |
| + |
| + if (reactionSheetKeyListener) { |
| + document.removeEventListener("keydown", reactionSheetKeyListener, true); |
| + reactionSheetKeyListener = null; |
| + } |
| + |
| + reactionSheetKeyListener = (e) => { |
| + const num = parseInt(e.key, 10); |
| + if (isNaN(num) || num < 1 || num > 6) return; |
| + |
| + e.preventDefault(); |
| + e.stopImmediatePropagation(); |
| + const emoji = REACTIONS[num - 1]; |
| + if (emoji) { |
| + toggleReaction(msgId, emoji); |
| + const currentSheet = document.querySelector(".msg-sheet"); |
| + if (currentSheet) currentSheet.remove(); |
| + wrap.style.visibility = ""; |
| + document.removeEventListener("keydown", reactionSheetKeyListener, true); |
| + reactionSheetKeyListener = null; |
| + } |
| + }; |
| + |
| + // Attach to document with capture, but also to the sheet itself |
| + document.addEventListener("keydown", reactionSheetKeyListener, true); |
| + sheet.addEventListener("keydown", reactionSheetKeyListener); |
| + }, 0); |
| + } |
| + |
| + document.addEventListener("keydown", (e) => { |
| + // Cmd/Ctrl+T: open message menu on latest message from someone else |
| + if ((e.metaKey || e.ctrlKey) && e.key === "t" && !text.value) { |
| + e.preventDefault(); |
| + const msg = getLatestMessage(true); // othersOnly=true |
| + if (msg) { |
| + openMessageSheet(msg.id, msg.el); |
| + attachReactionSheetKeyHandler(msg.id, msg.el); |
| + } |
| + } |
| + // Cmd/Ctrl+R: start reply to latest message (including own) |
| + if ((e.metaKey || e.ctrlKey) && e.key === "r" && !text.value) { |
| + e.preventDefault(); |
| + const msg = getLatestMessage(false); // othersOnly=false (all messages) |
| + if (msg && msgData[msg.id]?.replyMeta) { |
| + startReply(msgData[msg.id].replyMeta); |
| + } |
| + } |
| + }); |
| + |
| // Attachments: pick / drag-drop / paste an image into the open thread. |
| const fileInput = document.getElementById("file-input"); |
| document.getElementById("attach-btn").onclick = () => fileInput.click(); |
| @@ -2071,7 +2144,7 @@ function toggleReaction(msgId, emoji) { |
| const mine = myReactions.has(key); |
| if (mine) myReactions.delete(key); |
| else myReactions.add(key); |
| - ws.send(JSON.stringify({ type: mine ? "unreact" : "react", thread_id: activeId, message_id: msgId, emoji })); |
| + ws.send(JSON.stringify({ type: mine ? "unreact" : "react", thread_id: activeId, message_id: Number(msgId), emoji })); |
| } |
| |
| // Open the emoji picker over a message; press-and-hold (desktop) or double-tap |
| @@ -2280,12 +2353,22 @@ function openMessageSheet(msgId, wrap) { |
| wrap.style.visibility = ""; |
| backdrop.remove(); |
| document.removeEventListener("pointerdown", onDoc, true); |
| + document.removeEventListener("keydown", onEsc); |
| } |
| const onDoc = (e) => { |
| if (!reactions.contains(e.target) && !menu.contains(e.target)) close(); |
| }; |
| + const onEsc = (e) => { |
| + if (e.key === "Escape") { |
| + e.preventDefault(); |
| + close(); |
| + } |
| + }; |
| // Deferred so the press that opened the sheet doesn't immediately close it. |
| - setTimeout(() => document.addEventListener("pointerdown", onDoc, true), 0); |
| + setTimeout(() => { |
| + document.addEventListener("pointerdown", onDoc, true); |
| + document.addEventListener("keydown", onEsc); |
| + }, 0); |
| } |
| |
| // Double-tap (touch) → a quick emoji reactions bar. Just the emojis: the actions |
| @@ -2582,9 +2665,24 @@ function startReply(meta) { |
| // embed mode) and focuses its own composer. |
| if (embed) nativePost({ replying: { name: meta.name || "", snippet: meta.snippet || "" } }); |
| else document.getElementById("text").focus(); |
| + // ESC key cancels reply but preserves text |
| + const replyEscHandler = (e) => { |
| + if (e.key === "Escape" && replyingTo) { |
| + e.preventDefault(); |
| + cancelReply(); |
| + } |
| + }; |
| + document.addEventListener("keydown", replyEscHandler); |
| + // Store handler so it can be removed if a new reply starts or edit begins |
| + replyEscHandler._bound = true; |
| + cancelReply._escHandler = replyEscHandler; |
| } |
| |
| function cancelReply() { |
| + if (cancelReply._escHandler) { |
| + document.removeEventListener("keydown", cancelReply._escHandler); |
| + cancelReply._escHandler = null; |
| + } |
| replyingTo = null; |
| const box = document.getElementById("composer-reply"); |
| if (box) { |