| index abeb0a6..ffba821 100644 |
| --- a/web/static/chat.js |
| +++ b/web/static/chat.js |
| @@ -809,11 +809,87 @@ async function start(session) { |
| }, 0); |
| } |
| |
| - // Touch reacts with a double-tap (leaving long-press free for text selection); |
| - // desktop reacts with a press-and-hold (which doesn't clash with drag-select). |
| + // Touch reacts with a double-tap (leaving long-press free for text selection) |
| + // and swipe-right to reply; desktop reacts with a press-and-hold. |
| function attachReact(el, msgId, ctx) { |
| - if (isCoarse) attachDoubleTap(el, msgId, ctx); |
| - else attachLongPress(el, msgId, ctx); |
| + if (isCoarse) { |
| + attachDoubleTap(el, msgId, ctx); |
| + attachSwipeReply(el, ctx.replyMeta); |
| + } else { |
| + attachLongPress(el, msgId, ctx); |
| + } |
| + } |
| + |
| + // Swipe a message to the right (touch) to reply to it — à la Messages/WhatsApp. |
| + // The message follows the finger; release past the threshold opens the reply. |
| + // touch-action: pan-y (CSS) lets vertical scrolling stay native while we take |
| + // horizontal drags; a small reply arrow fades in as you drag. |
| + function attachSwipeReply(el, replyMeta) { |
| + const TRIGGER = 56; |
| + const MAX = 80; |
| + let startX = 0; |
| + let startY = 0; |
| + let dx = 0; |
| + let mode = ""; // "" undecided, "swipe", or "no" (vertical/other) |
| + let icon = null; |
| + |
| + const cleanup = (animate) => { |
| + if (icon) { |
| + icon.remove(); |
| + icon = null; |
| + } |
| + if (animate) { |
| + el.style.transition = "transform 0.18s ease"; |
| + el.style.transform = "translateX(0)"; |
| + const done = () => { |
| + el.style.transition = ""; |
| + el.style.transform = ""; |
| + el.removeEventListener("transitionend", done); |
| + }; |
| + el.addEventListener("transitionend", done); |
| + } else { |
| + el.style.transition = ""; |
| + el.style.transform = ""; |
| + } |
| + }; |
| + |
| + el.addEventListener("pointerdown", (e) => { |
| + if (e.pointerType !== "touch") return; |
| + startX = e.clientX; |
| + startY = e.clientY; |
| + dx = 0; |
| + mode = ""; |
| + }); |
| + el.addEventListener("pointermove", (e) => { |
| + if (e.pointerType !== "touch") return; |
| + const ax = e.clientX - startX; |
| + const ay = e.clientY - startY; |
| + if (mode === "") { |
| + if (Math.abs(ax) < 10 && Math.abs(ay) < 10) return; |
| + mode = ax > 0 && Math.abs(ax) > Math.abs(ay) * 1.3 ? "swipe" : "no"; |
| + if (mode === "swipe") { |
| + icon = document.createElement("div"); |
| + icon.className = "swipe-reply-icon"; |
| + icon.innerHTML = ICON_REPLY; |
| + el.appendChild(icon); |
| + } |
| + } |
| + if (mode !== "swipe") return; |
| + dx = Math.max(0, Math.min(ax, MAX)); |
| + el.style.transform = "translateX(" + dx + "px)"; |
| + if (icon) icon.style.opacity = Math.min(1, dx / TRIGGER); |
| + }); |
| + const onEnd = (e) => { |
| + if (e.pointerType && e.pointerType !== "touch") return; |
| + if (mode === "swipe") { |
| + const trigger = dx >= TRIGGER; |
| + cleanup(true); |
| + if (trigger) startReply(replyMeta); |
| + } |
| + mode = ""; |
| + }; |
| + el.addEventListener("pointerup", onEnd); |
| + el.addEventListener("pointercancel", onEnd); |
| } |
| |
| // Open the message menu at (x, y). Edit only appears on your own messages that |