diff --git a/server/public/home.html b/server/public/home.html
index 4b010c9..b3cefb9 100644
--- a/server/public/home.html
+++ b/server/public/home.html
@@ -3368,13 +3368,31 @@ async function openConvo(kind,id){
const ab=e.target.closest('.react-btn'); if(ab){ openEmojiForReact(ab.dataset.id, ab); return; }
const ch=e.target.closest('.react-chip'); if(ch){ reactToMessage(ch.dataset.id, ch.dataset.emoji); return; }
const sb=e.target.closest('.seenby,.rcpt.grp'); if(sb){ const bub=sb.closest('.bubble'); const mid=bub&&bub.dataset.id; const msg=(mid&&THREAD.find(x=>x.id===mid))||{ seenBy:(sb.dataset.seen||'').split('|').filter(Boolean) }; showSeenByModal(msg); return; }
- // Tap-to-reveal (mobile): a tap on the bubble body (not an action) reveals its reply/react/delete
- // icons; the action only fires on a SECOND tap once they're shown (icons are pointer-events:none
- // until revealed). Tapping elsewhere hides them.
- const bub=e.target.closest('.bubble'); const already=bub&&bub.classList.contains('show-actions');
+ // #9 (mobile): a single tap now performs the PRIMARY action (open image/file, jump to a reply — all
+ // handled above). The reply/react/more actions are revealed by LONG-PRESS (handler below), not by tap.
+ // A tap that reaches here (empty space or a plain text bubble) just DISMISSES any revealed action bar.
box.querySelectorAll('.bubble.show-actions').forEach(b=>b.classList.remove('show-actions'));
- if(bub && !already && !bub.classList.contains('deleted')) bub.classList.add('show-actions');
});
+ // #9 (mobile): LONG-PRESS a message to reveal its actions (reply / react / more). Touch only — desktop
+ // still reveals on hover. Movement or an early release cancels; when it fires we suppress the trailing tap
+ // so the long-press doesn't also open an image / jump a reply.
+ if(box){
+ let lpT=null, lpBub=null, lpX=0, lpY=0, lpFired=false;
+ const lpCancel=()=>{ if(lpT){ clearTimeout(lpT); lpT=null; } lpBub=null; };
+ box.addEventListener('touchstart',(e)=>{
+ if(e.touches.length!==1){ lpCancel(); return; }
+ const bub=e.target.closest('.bubble'); if(!bub || bub.classList.contains('deleted')){ lpCancel(); return; }
+ lpBub=bub; lpX=e.touches[0].clientX; lpY=e.touches[0].clientY; lpFired=false;
+ lpT=setTimeout(()=>{ lpT=null; if(!lpBub) return;
+ box.querySelectorAll('.bubble.show-actions').forEach(b=>{ if(b!==lpBub) b.classList.remove('show-actions'); });
+ lpBub.classList.add('show-actions'); lpFired=true;
+ try{ if(navigator.vibrate) navigator.vibrate(12); }catch(_){}
+ }, 420);
+ },{passive:true});
+ box.addEventListener('touchmove',(e)=>{ if(lpT && e.touches.length===1){ const t=e.touches[0]; if(Math.abs(t.clientX-lpX)>10||Math.abs(t.clientY-lpY)>10) lpCancel(); } },{passive:true});
+ box.addEventListener('touchend',(e)=>{ if(lpFired){ try{ e.preventDefault(); }catch(_){} lpFired=false; lpCancel(); return; } lpCancel(); },{passive:false});
+ box.addEventListener('touchcancel',lpCancel,{passive:true});
+ }
if(box) box.addEventListener('scroll', onMsgsScroll);
if(box){ const _rel=()=>{ _forcePinOpen=false; _openScrolled=true; _openPinTimers.forEach(clearTimeout); _openPinTimers.length=0; }; box.addEventListener('touchmove', _rel, {passive:true}); box.addEventListener('wheel', _rel, {passive:true}); } // the user's first real scroll gesture hands control back: stop force-gluing to the newest AND cancel the pending open-pins so scrolling up for older history isn't yanked back down
if(box) enablePullRefresh(box, loadOlder); // pull down at the top loads OLDER history (or no-ops); never jumps to the newest/bottom