Round 4: hidden-msg pagination, iOS audio unlock, iOS long-press callout, draft hardening, pinned-by

Older-messages pagination (couple of chats wouldn't scroll back): the thread query
    returned the latest 40 rows and JS filtered out hidden (delete-for-me) messages
    AFTER the LIMIT, so a chat with a hidden recent message returned <40 → the client
    read that as "no older history." Now excluded in SQL (repos.thread /
    threadByConversation take the viewer id), so a page is always 40 VISIBLE rows.
    Verified locally: hide 3 recent → page still returns 40 (older ones fill in).
#2  iOS in-chat tone was silent: WebAudio context is created suspended and only
    resumes inside a user gesture. Added unlockAudio() on first tap/click (resume +
    0-gain blip), re-armed each gesture so a background→foreground re-suspend recovers.
#9  Long-press "works once then stops" on images was iOS's native touch-callout
    (Save Image / selection magnifier) hijacking the gesture. Disabled
    -webkit-touch-callout/user-select on #msgs bubbles; added a Save action to the
    sheet so image-saving isn't lost.
#13 Pin/unpin WAS being audited (verified: message.pin in /api/audit) — there's just
    no in-app viewer. Surfaced "Pinned by X" in the pinned bar for immediate context.
#14 Hardened draft save: it now runs BEFORE maybeAutocorrect/autoGrow (wrapped) in the
    input handler, so a throw there can't skip persisting the draft.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-13 22:29:34 +05:30
parent c3c4178227
commit 6ee424db16
3 changed files with 43 additions and 10 deletions
+29 -3
View File
@@ -710,6 +710,11 @@
/* Belt-and-suspenders: cap ANY image inside a message bubble so a message can never momentarily balloon /* Belt-and-suspenders: cap ANY image inside a message bubble so a message can never momentarily balloon
the thread to thousands of px while a large picture loads at its natural size (the "stretch" glitch). */ the thread to thousands of px while a large picture loads at its natural size (the "stretch" glitch). */
.convo-msgs img,.bubble img{max-height:340px;max-width:100%;height:auto;} .convo-msgs img,.bubble img{max-height:340px;max-width:100%;height:auto;}
/* #9: disable iOS's native touch-callout on message bubbles — the "Save Image"/text-selection magnifier
that pops on touch-and-hold was hijacking our long-press action sheet (it worked once, then iOS's callout
took over). Actions live in the long-press sheet now (incl. Save for images) and Copy for text. */
#msgs .bubble{-webkit-touch-callout:none;-webkit-user-select:none;user-select:none;}
#msgs .bubble img{-webkit-user-drag:none;}
.att-file{display:inline-flex;align-items:center;gap:.4rem;background:rgba(0,0,0,.06);border:1px solid var(--line);border-radius:8px;padding:.4rem .6rem;color:inherit;text-decoration:none;font-size:.85rem;margin:.15rem 0;max-width:240px;} .att-file{display:inline-flex;align-items:center;gap:.4rem;background:rgba(0,0,0,.06);border:1px solid var(--line);border-radius:8px;padding:.4rem .6rem;color:inherit;text-decoration:none;font-size:.85rem;margin:.15rem 0;max-width:240px;}
.att-file span{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;} .att-file span{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.att-file-native{cursor:pointer;-webkit-user-select:none;user-select:none;} .att-file-native{cursor:pointer;-webkit-user-select:none;user-select:none;}
@@ -2302,6 +2307,7 @@ function msgMenuItems(m){
if(mine && m.body && !m.poll && !m.deleted) items.push({ic:'edit', label:'Edit', fn:()=>startEdit(m)}); // #14: reachable on mobile too (long-press), not just desktop hover if(mine && m.body && !m.poll && !m.deleted) items.push({ic:'edit', label:'Edit', fn:()=>startEdit(m)}); // #14: reachable on mobile too (long-press), not just desktop hover
if((m.body||m.attachment) && !m.poll) items.push({ic:'arrowRight', label:'Forward', fn:()=>enterSelect(m.id)}); if((m.body||m.attachment) && !m.poll) items.push({ic:'arrowRight', label:'Forward', fn:()=>enterSelect(m.id)});
if(m.body) items.push({ic:'copy', label:'Copy', fn:()=>copyMessageText(m)}); if(m.body) items.push({ic:'copy', label:'Copy', fn:()=>copyMessageText(m)});
if(m.attachment && !m.deleted) items.push({ic:'download', label:'Save', fn:()=>saveAttachment(m)}); // #9: native long-press "Save" is disabled now, so offer Save explicitly
if(!m.deleted && !m.poll) items.push({ic:(m.pinned?'pinOff':'pin'), label:(m.pinned?'Unpin':'Pin'), fn:()=>pinMessage(m.id, !m.pinned)}); // #13 if(!m.deleted && !m.poll) items.push({ic:(m.pinned?'pinOff':'pin'), label:(m.pinned?'Unpin':'Pin'), fn:()=>pinMessage(m.id, !m.pinned)}); // #13
items.push({ic:'trash', label:'Delete', danger:true, fn:()=>openDeleteDialog(m)}); // #18: branded dialog offers "for me" / "for everyone" items.push({ic:'trash', label:'Delete', danger:true, fn:()=>openDeleteDialog(m)}); // #18: branded dialog offers "for me" / "for everyone"
return items; return items;
@@ -2328,6 +2334,12 @@ function openMsgActionSheet(m){
if(ab){ const it=items[+ab.dataset.i]; close(); try{ it.fn(); }catch(_){} return; } if(ab){ const it=items[+ab.dataset.i]; close(); try{ it.fn(); }catch(_){} return; }
}); });
} }
// Save/download a message's attachment. Builds a transient <a download> and clicks it — the same path the
// lightbox/download uses, so on native the Capacitor a[download] interceptor saves it to Files/Photos.
function saveAttachment(m){
if(!m||!m.attachment) return;
try{ const a=document.createElement('a'); a.href='/files/'+m.attachment.id; a.download=m.attachment.name||'file'; if(m.attachment.mime) a.setAttribute('data-mime', m.attachment.mime); document.body.appendChild(a); a.click(); setTimeout(()=>{ try{ a.remove(); }catch(_){} },0); }catch(_){}
}
function openMsgMore(msgId, anchor){ function openMsgMore(msgId, anchor){
document.querySelectorAll('.msg-more-menu').forEach(x=>x.remove()); document.querySelectorAll('.msg-more-menu').forEach(x=>x.remove());
if(anchor && anchor._open){ anchor._open=false; return; } // clicking ⋮ again closes it if(anchor && anchor._open){ anchor._open=false; return; } // clicking ⋮ again closes it
@@ -2731,8 +2743,9 @@ function renderPinnedBar(){
const prev = m.deleted ? 'Message deleted' : (isGifUrl(m.body)?'🎞️ GIF':(m.body ? (m.body.length>90?m.body.slice(0,90)+'…':m.body) : (m.attachment?('📎 '+(m.attachment.name||'Attachment')):'Message'))); const prev = m.deleted ? 'Message deleted' : (isGifUrl(m.body)?'🎞️ GIF':(m.body ? (m.body.length>90?m.body.slice(0,90)+'…':m.body) : (m.attachment?('📎 '+(m.attachment.name||'Attachment')):'Message')));
// #13: when more than one message is pinned, a 1 of n pager on the right walks through them all. // #13: when more than one message is pinned, a 1 of n pager on the right walks through them all.
const pager = n>1 ? ('<div class="pb-pager"><button class="pb-prev" title="Previous pin">'+ic('chevronLeft',15)+'</button><span class="pb-count">'+(PIN_IX+1)+' of '+n+'</span><button class="pb-next" title="Next pin">'+ic('chevronRight',15)+'</button></div>') : ''; const pager = n>1 ? ('<div class="pb-pager"><button class="pb-prev" title="Previous pin">'+ic('chevronLeft',15)+'</button><span class="pb-count">'+(PIN_IX+1)+' of '+n+'</span><button class="pb-next" title="Next pin">'+ic('chevronRight',15)+'</button></div>') : '';
const pinHdr = m.pinnedBy ? ('Pinned by '+pEsc(m.pinnedBy)) : 'Pinned'; // #13: show WHO pinned it (accountability)
el.innerHTML='<span class="pb-ic">'+ic('pin',15)+'</span>' el.innerHTML='<span class="pb-ic">'+ic('pin',15)+'</span>'
+'<div class="pb-body"><div class="pb-h">Pinned'+(m.fromName?(' · '+pEsc(m.fromName)):'')+'</div><div class="pb-txt">'+pEsc(prev)+'</div></div>' +'<div class="pb-body"><div class="pb-h">'+pinHdr+'</div><div class="pb-txt">'+pEsc(prev)+'</div></div>'
+pager +pager
+'<button class="pb-unpin" title="Unpin">'+ic('x',15)+'</button>'; +'<button class="pb-unpin" title="Unpin">'+ic('x',15)+'</button>';
el.style.display='flex'; el.style.display='flex';
@@ -3476,7 +3489,7 @@ async function openConvo(kind,id){
if(inpEl){ if(inpEl){
const _d=getDraft(kind,id); if(_d){ inpEl.value=_d; autoGrow(inpEl); } // restore unsent draft const _d=getDraft(kind,id); if(_d){ inpEl.value=_d; autoGrow(inpEl); } // restore unsent draft
inpEl.addEventListener('paste', onPaste); inpEl.addEventListener('paste', onPaste);
inpEl.addEventListener('input', ()=>{ maybeAutocorrect(inpEl); autoGrow(inpEl); if(!editTarget) setDraft(kind,id,inpEl.value); noteTyping(); }); // auto-correct common typos, keep the draft in sync (#14: not while editing) + emit "typing…" inpEl.addEventListener('input', ()=>{ if(!editTarget) setDraft(kind,id,inpEl.value); try{ maybeAutocorrect(inpEl); }catch(_){} autoGrow(inpEl); noteTyping(); }); // #14: SAVE THE DRAFT FIRST — if maybeAutocorrect/autoGrow ever throws, the draft still persists. Skips only while editing.
inpEl.addEventListener('blur', ()=>{ stopTyping(); setTimeout(()=>{ const a=document.activeElement; const stillTyping=a&&(a.tagName==='TEXTAREA'||a.tagName==='INPUT'); if(!stillTyping && window.__bzResetKb) window.__bzResetKb(); }, 120); }); // #1: composer lost focus → keyboard is closing → drop the --kb lift so no empty gap remains inpEl.addEventListener('blur', ()=>{ stopTyping(); setTimeout(()=>{ const a=document.activeElement; const stillTyping=a&&(a.tagName==='TEXTAREA'||a.tagName==='INPUT'); if(!stillTyping && window.__bzResetKb) window.__bzResetKb(); }, 120); }); // #1: composer lost focus → keyboard is closing → drop the --kb lift so no empty gap remains
inpEl.addEventListener('keydown', (e)=>{ if(e.key==='Enter' && !e.shiftKey && !isMobileUA()){ if(mentionItems && mentionItems.length) return; e.preventDefault(); sendMessage(); } }); // Desktop: Enter sends, Shift+Enter = newline. Mobile: the Return key inserts a newline (default) — you send with the send button, like every native chat app. inpEl.addEventListener('keydown', (e)=>{ if(e.key==='Enter' && !e.shiftKey && !isMobileUA()){ if(mentionItems && mentionItems.length) return; e.preventDefault(); sendMessage(); } }); // Desktop: Enter sends, Shift+Enter = newline. Mobile: the Return key inserts a newline (default) — you send with the send button, like every native chat app.
// Desktop: LEFT-click a red-squiggled word → native spelling suggestions right there (the shell // Desktop: LEFT-click a red-squiggled word → native spelling suggestions right there (the shell
@@ -4367,7 +4380,20 @@ function notify(title, body, kind, id, opts){
setTimeout(()=>{ try{ n.close(); }catch(_){} }, 8000); setTimeout(()=>{ try{ n.close(); }catch(_){} }, 8000);
}catch(_){} }catch(_){}
} }
let _audioCtx=null; let _audioCtx=null, _audioUnlocked=false;
// iOS/Safari create the AudioContext SUSPENDED and only let resume() succeed from INSIDE a real user
// gesture; a later programmatic resume() (e.g. when a message arrives with no tap) silently stays suspended,
// so playMsgTone/playPing produce nothing on iOS. Unlock it once on the first tap/click by resuming + playing
// a 0-gain blip within the gesture; keep the listeners so a background→foreground (which re-suspends the
// context on iOS) re-resumes it on the next touch. This is what makes #2's in-chat tone audible on iOS.
function unlockAudio(){
try{
_audioCtx=_audioCtx||new (window.AudioContext||window.webkitAudioContext)();
if(_audioCtx.state==='suspended') _audioCtx.resume();
if(!_audioUnlocked){ const o=_audioCtx.createOscillator(), g=_audioCtx.createGain(); g.gain.value=0.00001; o.connect(g); g.connect(_audioCtx.destination); o.start(); o.stop(_audioCtx.currentTime+0.02); _audioUnlocked=true; }
}catch(_){}
}
['touchend','pointerdown','click','keydown'].forEach(ev=>window.addEventListener(ev, unlockAudio, {passive:true}));
// Message chime: a crisp, recognizable rising two-note ("ti-doo") — louder + brighter than the // Message chime: a crisp, recognizable rising two-note ("ti-doo") — louder + brighter than the
// old single beep so it's catchy and noticed. // old single beep so it's catchy and noticed.
function playPing(){ function playPing(){
+12 -5
View File
@@ -230,12 +230,17 @@ const messages = {
// and silently dropped everything newer once a thread passed 300 — so new messages "disappeared". // and silently dropped everything newer once a thread passed 300 — so new messages "disappeared".
// The `before` cursor is added CONDITIONALLY (not as `? IS NULL OR …`): an all-NULL param has no type // The `before` cursor is added CONDITIONALLY (not as `? IS NULL OR …`): an all-NULL param has no type
// for Postgres to infer. The subquery also needs an alias (`t`) — Postgres requires it. Both portable. // for Postgres to infer. The subquery also needs an alias (`t`) — Postgres requires it. Both portable.
// `a` is the VIEWER (u.id). Exclude the viewer's "deleted for me" (message_hidden) rows in SQL — not in
// JS afterwards — so the LIMIT counts only VISIBLE messages. Filtering after the LIMIT returned < PAGE rows
// whenever a recent message had been hidden, and the client read that as "no older history" and stopped
// paginating (a chat with a deleted recent message wouldn't scroll back).
thread: (teamId, a, b, limit = 500, before = null) => { thread: (teamId, a, b, limit = 500, before = null) => {
const cond = before != null ? ' AND created_at < ?' : ''; const cond = before != null ? ' AND created_at < ?' : '';
const args = before != null ? [teamId, a, b, b, a, before, limit] : [teamId, a, b, b, a, limit]; const args = before != null ? [teamId, a, b, b, a, a, before, limit] : [teamId, a, b, b, a, a, limit];
return db.prepare(`SELECT * FROM ( return db.prepare(`SELECT * FROM (
SELECT * FROM messages WHERE team_id=? AND conversation_id IS NULL SELECT * FROM messages WHERE team_id=? AND conversation_id IS NULL
AND ((sender_id=? AND recipient_id=?) OR (sender_id=? AND recipient_id=?))${cond} AND ((sender_id=? AND recipient_id=?) OR (sender_id=? AND recipient_id=?))
AND id NOT IN (SELECT message_id FROM message_hidden WHERE user_id=?)${cond}
ORDER BY created_at DESC LIMIT ? ORDER BY created_at DESC LIMIT ?
) t ORDER BY created_at ASC`).all(...args); ) t ORDER BY created_at ASC`).all(...args);
}, },
@@ -252,11 +257,13 @@ const messages = {
db.prepare('SELECT * FROM messages WHERE team_id=? AND conversation_id IS NULL AND (sender_id=? OR recipient_id=?) ORDER BY created_at DESC LIMIT ?') db.prepare('SELECT * FROM messages WHERE team_id=? AND conversation_id IS NULL AND (sender_id=? OR recipient_id=?) ORDER BY created_at DESC LIMIT ?')
.all(teamId, userId, userId, limit), .all(teamId, userId, userId, limit),
// Group conversation helpers. // Group conversation helpers.
threadByConversation: (conversationId, limit = 500, before = null) => { threadByConversation: (conversationId, userId, limit = 500, before = null) => {
const cond = before != null ? ' AND created_at < ?' : ''; const cond = before != null ? ' AND created_at < ?' : '';
const args = before != null ? [conversationId, before, limit] : [conversationId, limit]; const args = before != null ? [conversationId, userId, before, limit] : [conversationId, userId, limit];
return db.prepare(`SELECT * FROM ( return db.prepare(`SELECT * FROM (
SELECT * FROM messages WHERE conversation_id=?${cond} ORDER BY created_at DESC LIMIT ? SELECT * FROM messages WHERE conversation_id=?
AND id NOT IN (SELECT message_id FROM message_hidden WHERE user_id=?)${cond}
ORDER BY created_at DESC LIMIT ?
) t ORDER BY created_at ASC`).all(...args); ) t ORDER BY created_at ASC`).all(...args);
}, },
searchConversation: (conversationId, like, limit = 300) => searchConversation: (conversationId, like, limit = 300) =>
+2 -2
View File
@@ -839,7 +839,7 @@ route('GET', '/api/messages/thread', async (req, res) => {
const hidden = new Set(await R.messages.hiddenForUser(u.id)); // #18: messages this user "deleted for me" const hidden = new Set(await R.messages.hiddenForUser(u.id)); // #18: messages this user "deleted for me"
if (group) { if (group) {
if (!await R.conversations.isMember(group, u.id)) return json(res, 403, { error: 'not a member of this group' }); if (!await R.conversations.isMember(group, u.id)) return json(res, 403, { error: 'not a member of this group' });
const rows = (await R.messages.threadByConversation(group, 40, before)).filter((m) => !hidden.has(m.id)); // #18 const rows = (await R.messages.threadByConversation(group, u.id, 40, before)).filter((m) => !hidden.has(m.id)); // #18 (hidden now excluded in SQL too, so the 40-row page counts only visible messages)
if (!peek && !before) { if (!peek && !before) {
await R.conversations.markRead(group, u.id); await R.conversations.markRead(group, u.id);
const evt = { type: 'group-read', group, by: u.id, byName: names[u.id] || u.email, at: now() }; const evt = { type: 'group-read', group, by: u.id, byName: names[u.id] || u.email, at: now() };
@@ -1695,7 +1695,7 @@ route('GET', '/api/messages/pinned', async (req, res) => {
rows = await R.messages.pinnedInDm(u.team_id, u.id, other); rows = await R.messages.pinnedInDm(u.team_id, u.id, other);
} }
const hidden = new Set(await R.messages.hiddenForUser(u.id)); // #18: don't surface a message you deleted-for-me const hidden = new Set(await R.messages.hiddenForUser(u.id)); // #18: don't surface a message you deleted-for-me
json(res, 200, await Promise.all(rows.filter((m) => !hidden.has(m.id)).map(async (m) => { const d = await buildMsgDTO(m, names, u.id); d.fromName = names[m.sender_id] || ''; return d; }))); json(res, 200, await Promise.all(rows.filter((m) => !hidden.has(m.id)).map(async (m) => { const d = await buildMsgDTO(m, names, u.id); d.fromName = names[m.sender_id] || ''; d.pinnedBy = names[m.pinned_by] || ''; return d; }))); // #13: who pinned it (shown in the pinned bar)
}); });
// Edit a message (sender only, text only) — updates the body + marks it edited, and pushes the // Edit a message (sender only, text only) — updates the body + marks it edited, and pushes the
// change live to the other side / other tabs (mirrors the delete broadcast). // change live to the other side / other tabs (mirrors the delete broadcast).