eleven / messenger Public

web: swipe-right to reply on mobile (#7)

On touch, dragging a message to the right past a threshold opens a reply to it (Messages/WhatsApp style). The bubble follows the finger and a reply arrow fades in; release past ~56px replies, otherwise it springs back. touch-action: pan-y keeps vertical scrolling native; only horizontal-dominant rightward drags engage, so it doesn't clash with scroll, double-tap, or long-press selection.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Paul Campbell 19fe5056b41181e98a38bef8b7cb41efd48c380c parent ca289ad
2 files changed, +101 −5
  • web/static/chat.js +80 −4
  • web/static/style.css +21 −1
diff --git a/web/static/chat.js b/web/static/chat.js
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
diff --git a/web/static/style.css b/web/static/style.css
index ee1fa50..8eefd6b 100644
--- a/web/static/style.css
+++ b/web/static/style.css
@@ -174,6 +174,7 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr
flex: 1;
min-height: 0;
overflow-y: auto;
+ overflow-x: hidden; /* clip messages mid swipe-to-reply; no horizontal scrollbar */
padding: 18px;
display: flex;
flex-direction: column;
@@ -184,9 +185,28 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr
content: "";
margin-top: auto;
}
-.msg { display: flex; flex-direction: column; max-width: 78%; margin-top: 10px; }
+.msg {
+ display: flex;
+ flex-direction: column;
+ max-width: 78%;
+ margin-top: 10px;
+ position: relative; /* anchors the swipe-to-reply icon */
+ touch-action: pan-y; /* vertical scroll stays native; we handle horizontal swipes */
+}
/* continuation of a same-sender, same-minute run — tighter gap (see refreshGrouping) */
.msg.cont { margin-top: 2px; }
+/* swipe-to-reply (touch): reply arrow that fades in just left of the bubble */
+.swipe-reply-icon {
+ position: absolute;
+ top: 50%;
+ left: 0;
+ transform: translate(-130%, -50%);
+ color: var(--accent);
+ opacity: 0;
+ pointer-events: none;
+ display: inline-flex;
+}
+.swipe-reply-icon svg { width: 20px; height: 20px; }
.msg .name { font-size: 12px; color: var(--muted); margin: 0 6px 3px; }
.msg .bubble {
padding: 9px 13px;