eleven / messenger Public

web: double-tap to react on touch, freeing long-press for text selection

Touch now reacts via double-tap (≤320ms, same spot) instead of long-press, so the native long-press selection/copy works again on mobile. Desktop keeps press-and-hold (which already coexists with drag-select). Dropped the touch-only user-select:none entirely.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Paul Campbell 3b2c611b39407bdee0d7083d96fcd7ec339ffa6c parent 695b46a
2 files changed, +36 −16
  • web/static/chat.js +33 −6
  • web/static/style.css +3 −10
diff --git a/web/static/chat.js b/web/static/chat.js
index 4d7144a..c916782 100644
--- a/web/static/chat.js
+++ b/web/static/chat.js
@@ -363,6 +363,8 @@ async function start(session) {
const key = await importKey(session.key);
const feed = document.getElementById("feed");
+ const isCoarse =
+ window.matchMedia && window.matchMedia("(pointer: coarse)").matches;
setupMenu(session);
@@ -423,8 +425,8 @@ async function start(session) {
bubbleRow.appendChild(content);
el.appendChild(bubbleRow);
- // Press-and-hold a message to react (no per-message button).
- attachLongPress(el, msg.id);
+ // React: double-tap on touch, press-and-hold on desktop (no per-message button).
+ attachReact(el, msg.id);
const reactions = document.createElement("div");
reactions.className = "reactions";
@@ -536,7 +538,34 @@ async function start(session) {
}, 0);
}
- // Press-and-hold (touch or mouse) on a message opens the reaction picker.
+ // 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).
+ function attachReact(el, msgId) {
+ if (isCoarse) attachDoubleTap(el, msgId);
+ else attachLongPress(el, msgId);
+ }
+
+ // Double-tap to react (touch). Two taps within 320ms near the same spot.
+ function attachDoubleTap(el, msgId) {
+ let lastT = 0;
+ let lx = 0;
+ let ly = 0;
+ el.addEventListener("pointerup", (e) => {
+ if (e.pointerType === "mouse") return;
+ const t = e.timeStamp;
+ if (t - lastT < 320 && Math.abs(e.clientX - lx) < 30 && Math.abs(e.clientY - ly) < 30) {
+ e.preventDefault();
+ lastT = 0;
+ openReactionPicker(e.clientX, e.clientY, (emoji) => toggleReaction(msgId, emoji));
+ } else {
+ lastT = t;
+ lx = e.clientX;
+ ly = e.clientY;
+ }
+ });
+ }
+
+ // Press-and-hold (mouse) on a message opens the reaction picker.
function attachLongPress(el, msgId) {
let timer = null;
let sx = 0;
@@ -783,8 +812,6 @@ async function start(session) {
const attachBtn = document.getElementById("attach-btn");
const fileInput = document.getElementById("file-input");
const previewBox = document.getElementById("composer-preview");
- const coarse =
- window.matchMedia && window.matchMedia("(pointer: coarse)").matches;
// The attachment staged for the next send (uploaded already), or null.
let pendingAttachment = null;
@@ -999,7 +1026,7 @@ async function start(session) {
syncSendBtn();
});
input.addEventListener("keydown", (e) => {
- if (shouldSend(e, coarse)) {
+ if (shouldSend(e, isCoarse)) {
e.preventDefault();
send();
}
diff --git a/web/static/style.css b/web/static/style.css
index 58004a8..e7d8147 100644
--- a/web/static/style.css
+++ b/web/static/style.css
@@ -336,16 +336,9 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr
/* reactions */
.bubble-row { display: flex; align-items: center; gap: 6px; max-width: 100%; min-width: 0; }
.msg.me .bubble-row { flex-direction: row-reverse; }
-/* Desktop keeps normal text selection: drag-to-select cancels the hold-to-react
- timer, so the two don't clash. On touch, long-press IS the react gesture, so
- there we suppress the native selection/callout that would fight it. */
-@media (pointer: coarse) {
- .bubble-row {
- -webkit-touch-callout: none;
- -webkit-user-select: none;
- user-select: none;
- }
-}
+/* Text selection works everywhere now: desktop reacts with a stationary hold
+ (drag-select cancels it) and touch reacts with a double-tap, so long-press is
+ free for native selection/copy on mobile. */
.reactions { display: flex; flex-wrap: wrap; gap: 4px; margin: 3px 2px 0; }
.msg.me .reactions { justify-content: flex-end; }
.reactions:empty { display: none; }