Round 3: session longevity, in-chat tone, pin audit, older-msg loader, long-press sheet, draft fix
Session (New): stop the ~24h auto-logout. SESSION_TTL 24h -> 90d, and /api/me now
SLIDES the session forward + re-stamps the cookie on every app load / focus /
6h heartbeat — so an actively-used session never lapses; you only log out by
choosing to. Login no longer depends on "remember me".
#2 A new message in the chat you're actively viewing now plays a soft, distinct
in-chat tone (playMsgTone) — no popup — instead of being silent. A different
chat / a backgrounded chat still gets the alert ping + notification.
#13 Pin/unpin is now written to the audit log (actor + which message, and whose pin
was removed on an unpin) — the accountability gap when anyone can unpin.
Pagination: a floating "Loading earlier messages…" pill now shows while older
history is being fetched (loadOlder had no visible indicator).
#9 Mobile long-press now opens a dimmed + blurred bottom ACTION SHEET (quick
reactions + reply/edit/forward/copy/pin/delete) instead of the flaky hover-style
reveal that hid behind images and broke after the lightbox opened.
#14 editTarget is cleared on conversation switch — starting an edit then switching
chats used to leave editTarget set, which silently stopped ALL draft saving.
Also added Edit to the shared action list so mobile long-press can edit too.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+4
-1
@@ -70,6 +70,9 @@ module.exports = {
|
|||||||
TRANS_DIR,
|
TRANS_DIR,
|
||||||
UPLOADS_DIR,
|
UPLOADS_DIR,
|
||||||
DOWNLOADS_DIR,
|
DOWNLOADS_DIR,
|
||||||
SESSION_TTL: 1000 * 60 * 60 * 24, // 24h access-token / cookie lifetime
|
// Access-token / web-cookie lifetime. Long by design + SLID FORWARD on every /api/me (app load / focus /
|
||||||
|
// heartbeat), so an actively-used session never lapses — you only get logged out by choosing to log out.
|
||||||
|
// (Was 24h, which logged people out overnight.)
|
||||||
|
SESSION_TTL: 1000 * 60 * 60 * 24 * 90, // 90d
|
||||||
REFRESH_TTL: 1000 * 60 * 60 * 24 * 90, // 90d refresh-token lifetime (native clients)
|
REFRESH_TTL: 1000 * 60 * 60 * 24 * 90, // 90d refresh-token lifetime (native clients)
|
||||||
};
|
};
|
||||||
|
|||||||
+91
-17
@@ -335,6 +335,7 @@
|
|||||||
.new-sep::before,.new-sep::after{content:"";flex:1;height:1px;background:#f0c2c2;}
|
.new-sep::before,.new-sep::after{content:"";flex:1;height:1px;background:#f0c2c2;}
|
||||||
.day-sep span{background:#fde7b0;color:#7a5b05;padding:.22rem .8rem;border-radius:99px;font-weight:600;box-shadow:0 1px 3px rgba(20,30,60,.1);}
|
.day-sep span{background:#fde7b0;color:#7a5b05;padding:.22rem .8rem;border-radius:99px;font-weight:600;box-shadow:0 1px 3px rgba(20,30,60,.1);}
|
||||||
.float-date{position:absolute;top:.6rem;left:50%;transform:translateX(-50%);z-index:5;background:#fde7b0;color:#7a5b05;padding:.22rem .85rem;border-radius:99px;font-weight:600;font-size:.72rem;box-shadow:0 3px 10px rgba(20,30,60,.18);pointer-events:none;}
|
.float-date{position:absolute;top:.6rem;left:50%;transform:translateX(-50%);z-index:5;background:#fde7b0;color:#7a5b05;padding:.22rem .85rem;border-radius:99px;font-weight:600;font-size:.72rem;box-shadow:0 3px 10px rgba(20,30,60,.18);pointer-events:none;}
|
||||||
|
.older-loading{position:absolute;top:.6rem;left:50%;transform:translateX(-50%);z-index:6;background:var(--card);color:var(--muted);padding:.28rem .8rem;border-radius:99px;font-weight:600;font-size:.73rem;box-shadow:0 3px 10px rgba(20,30,60,.18);display:flex;align-items:center;gap:.4rem;pointer-events:none;} /* pagination: "loading earlier messages" pill */
|
||||||
.jump-latest{position:absolute;right:16px;bottom:86px;z-index:5;width:42px;height:42px;border-radius:50%;border:1px solid var(--line);background:var(--card);color:var(--blue);box-shadow:0 4px 14px rgba(20,30,60,.22);cursor:pointer;display:grid;place-items:center;}
|
.jump-latest{position:absolute;right:16px;bottom:86px;z-index:5;width:42px;height:42px;border-radius:50%;border:1px solid var(--line);background:var(--card);color:var(--blue);box-shadow:0 4px 14px rgba(20,30,60,.22);cursor:pointer;display:grid;place-items:center;}
|
||||||
.jump-latest:hover{background:var(--brand);color:var(--blue);border-color:var(--brand-d);}
|
.jump-latest:hover{background:var(--brand);color:var(--blue);border-color:var(--brand-d);}
|
||||||
.empty-thread{align-self:center;font-size:.85rem;color:var(--muted);margin:auto;}
|
.empty-thread{align-self:center;font-size:.85rem;color:var(--muted);margin:auto;}
|
||||||
@@ -841,6 +842,20 @@
|
|||||||
.del-dlg .del-opt-tx b{font-size:.95rem;color:var(--ink);}
|
.del-dlg .del-opt-tx b{font-size:.95rem;color:var(--ink);}
|
||||||
.del-dlg .del-opt.danger .del-opt-tx b{color:var(--red);}
|
.del-dlg .del-opt.danger .del-opt-tx b{color:var(--red);}
|
||||||
.del-dlg .del-opt-tx small{font-size:.78rem;color:var(--muted);line-height:1.4;}
|
.del-dlg .del-opt-tx small{font-size:.78rem;color:var(--muted);line-height:1.4;}
|
||||||
|
/* #9: mobile long-press action sheet — dimmed + blurred backdrop, bottom sheet of actions. */
|
||||||
|
.msg-sheet-ov{position:fixed;inset:0;background:rgba(15,23,42,.5);backdrop-filter:blur(3px);-webkit-backdrop-filter:blur(3px);z-index:9600;display:flex;flex-direction:column;justify-content:flex-end;}
|
||||||
|
.msg-sheet{background:var(--card);border-radius:18px 18px 0 0;padding:.5rem .5rem calc(.6rem + env(safe-area-inset-bottom));box-shadow:0 -8px 30px rgba(0,0,0,.28);animation:msSheetUp .18s ease-out;max-width:520px;width:100%;margin:0 auto;}
|
||||||
|
@keyframes msSheetUp{from{transform:translateY(100%);}to{transform:translateY(0);}}
|
||||||
|
.msg-sheet .ms-reacts{display:flex;gap:.5rem;justify-content:center;padding:.5rem .3rem .6rem;border-bottom:1px solid var(--line);margin-bottom:.35rem;}
|
||||||
|
.msg-sheet .ms-reacts button{width:46px;height:46px;border-radius:50%;border:none;background:var(--blue-soft);font-size:1.4rem;cursor:pointer;display:grid;place-items:center;line-height:1;}
|
||||||
|
.msg-sheet .ms-reacts button.more{background:#eef1f6;color:var(--blue);}
|
||||||
|
.msg-sheet .ms-reacts button:active{transform:scale(.92);}
|
||||||
|
.msg-sheet .ms-list{display:flex;flex-direction:column;}
|
||||||
|
.msg-sheet .ms-act{display:flex;align-items:center;gap:.85rem;width:100%;border:none;background:transparent;padding:.85rem 1rem;font-size:.98rem;color:var(--ink);cursor:pointer;border-radius:12px;font-family:inherit;text-align:left;}
|
||||||
|
.msg-sheet .ms-act:active{background:var(--blue-soft);}
|
||||||
|
.msg-sheet .ms-act.danger{color:var(--red);}
|
||||||
|
.msg-sheet .ms-act.danger:active{background:rgba(220,38,38,.08);}
|
||||||
|
@media (hover:hover){ .msg-sheet .ms-act:hover{background:var(--blue-soft);} .msg-sheet .ms-act.danger:hover{background:rgba(220,38,38,.08);} }
|
||||||
/* "typing…" indicator inside the conversation, just above the composer. */
|
/* "typing…" indicator inside the conversation, just above the composer. */
|
||||||
.typing-row{padding:.1rem .95rem .3rem;min-height:0;color:var(--blue);font-size:.78rem;font-style:italic;font-weight:600;display:none;align-items:center;gap:.4rem;}
|
.typing-row{padding:.1rem .95rem .3rem;min-height:0;color:var(--blue);font-size:.78rem;font-style:italic;font-weight:600;display:none;align-items:center;gap:.4rem;}
|
||||||
.typing-row.show{display:flex;}
|
.typing-row.show{display:flex;}
|
||||||
@@ -1708,6 +1723,8 @@ async function loadOlder(){
|
|||||||
_loadingOlder=true;
|
_loadingOlder=true;
|
||||||
const kind=selected.kind, id=selected.id, before=THREAD[0].created_at;
|
const kind=selected.kind, id=selected.id, before=THREAD[0].created_at;
|
||||||
const box=document.getElementById('msgs');
|
const box=document.getElementById('msgs');
|
||||||
|
const _ldr=document.getElementById('olderLoading'); if(_ldr) _ldr.style.display='flex'; // pagination: show the "loading earlier messages" pill while we fetch
|
||||||
|
const _hideLdr=()=>{ const l=document.getElementById('olderLoading'); if(l) l.style.display='none'; };
|
||||||
const _esc=(s)=>(window.CSS&&CSS.escape)?CSS.escape(String(s)):String(s);
|
const _esc=(s)=>(window.CSS&&CSS.escape)?CSS.escape(String(s)):String(s);
|
||||||
// Anchor on the CURRENT oldest-loaded message: after we prepend older ones (and as THEIR images load),
|
// Anchor on the CURRENT oldest-loaded message: after we prepend older ones (and as THEIR images load),
|
||||||
// keep this exact message at the same viewport position, so scrolling up never jumps. (The old
|
// keep this exact message at the same viewport position, so scrolling up never jumps. (The old
|
||||||
@@ -1718,7 +1735,7 @@ async function loadOlder(){
|
|||||||
const _aTop = _aEl ? (_aEl.getBoundingClientRect().top - _boxTop) : 0;
|
const _aTop = _aEl ? (_aEl.getBoundingClientRect().top - _boxTop) : 0;
|
||||||
const _restore=()=>{ if(!box||!anchorId) return; const el=box.querySelector('.bubble[data-id="'+_esc(anchorId)+'"]'); if(el){ const cur=el.getBoundingClientRect().top - box.getBoundingClientRect().top; box.scrollTop += (cur - _aTop); } };
|
const _restore=()=>{ if(!box||!anchorId) return; const el=box.querySelector('.bubble[data-id="'+_esc(anchorId)+'"]'); if(el){ const cur=el.getBoundingClientRect().top - box.getBoundingClientRect().top; box.scrollTop += (cur - _aTop); } };
|
||||||
let older=null; try{ const r=await fetch(threadUrl(kind,id,before)); if(r.ok) older=await r.json(); }catch(_){}
|
let older=null; try{ const r=await fetch(threadUrl(kind,id,before)); if(r.ok) older=await r.json(); }catch(_){}
|
||||||
if(!selected||selected.kind!==kind||selected.id!==id){ _loadingOlder=false; return; }
|
if(!selected||selected.kind!==kind||selected.id!==id){ _hideLdr(); _loadingOlder=false; return; }
|
||||||
if(Array.isArray(older) && older.length){
|
if(Array.isArray(older) && older.length){
|
||||||
const have=new Set(THREAD.map(m=>m.id)); const add=older.filter(m=>!have.has(m.id));
|
const have=new Set(THREAD.map(m=>m.id)); const add=older.filter(m=>!have.has(m.id));
|
||||||
if(add.length){ THREAD=add.concat(THREAD); THREAD_CACHE.set(kind+':'+id, THREAD.slice()); }
|
if(add.length){ THREAD=add.concat(THREAD); THREAD_CACHE.set(kind+':'+id, THREAD.slice()); }
|
||||||
@@ -1727,6 +1744,7 @@ async function loadOlder(){
|
|||||||
_restore(); // put the anchor message back at its exact viewport position
|
_restore(); // put the anchor message back at its exact viewport position
|
||||||
if(box){ const imgs=[...box.querySelectorAll('.bubble img')].filter(im=>!im.complete); imgs.forEach(im=>im.addEventListener('load', _restore, {once:true})); setTimeout(()=>imgs.forEach(im=>{ try{ im.removeEventListener('load', _restore); }catch(_){} }), 3000); } // re-anchor as prepended images load (they'd shift it down)
|
if(box){ const imgs=[...box.querySelectorAll('.bubble img')].filter(im=>!im.complete); imgs.forEach(im=>im.addEventListener('load', _restore, {once:true})); setTimeout(()=>imgs.forEach(im=>{ try{ im.removeEventListener('load', _restore); }catch(_){} }), 3000); } // re-anchor as prepended images load (they'd shift it down)
|
||||||
} else { _hasMoreOlder=false; }
|
} else { _hasMoreOlder=false; }
|
||||||
|
_hideLdr();
|
||||||
_loadingOlder=false; _olderCd=Date.now()+500; // brief cooldown so it can't re-fire and fight the scroll
|
_loadingOlder=false; _olderCd=Date.now()+500; // brief cooldown so it can't re-fire and fight the scroll
|
||||||
}
|
}
|
||||||
// Keep loading older pages until a target timestamp is in the loaded window (used by search jump-to).
|
// Keep loading older pages until a target timestamp is in the loaded window (used by search jump-to).
|
||||||
@@ -2134,6 +2152,7 @@ function convoShellHTML(it){
|
|||||||
+ '</div>'
|
+ '</div>'
|
||||||
+ '<div class="pinned-bar" id="pinnedBar" style="display:none"></div>' // #13: pinned-message strip below the header
|
+ '<div class="pinned-bar" id="pinnedBar" style="display:none"></div>' // #13: pinned-message strip below the header
|
||||||
+ '<div class="convo-msgs" id="msgs"></div>'
|
+ '<div class="convo-msgs" id="msgs"></div>'
|
||||||
|
+ '<div class="older-loading" id="olderLoading" style="display:none"><img src="/loaders/loader-ring.svg" width="16" height="16" alt=""><span>Loading earlier messages…</span></div>' // pagination: floating "loading older" pill
|
||||||
+ '<div class="float-date" id="floatDate" style="display:none"></div>'
|
+ '<div class="float-date" id="floatDate" style="display:none"></div>'
|
||||||
+ '<button class="jump-latest" id="jumpLatest" title="Jump to latest" style="display:none">'+ic('chevronDown',20)+'</button>'
|
+ '<button class="jump-latest" id="jumpLatest" title="Jump to latest" style="display:none">'+ic('chevronDown',20)+'</button>'
|
||||||
+ '<div class="reply-bar" id="replyBar" style="display:none"></div>'
|
+ '<div class="reply-bar" id="replyBar" style="display:none"></div>'
|
||||||
@@ -2275,17 +2294,45 @@ function openMiniProfile(uid, anchor){
|
|||||||
const QUICK_REACTS=[{e:'👍',t:'Like'},{e:'😂',t:'Laugh'},{e:'😮',t:'Surprised'}];
|
const QUICK_REACTS=[{e:'👍',t:'Like'},{e:'😂',t:'Laugh'},{e:'😮',t:'Surprised'}];
|
||||||
// Everything that isn't a reaction or an edit now lives behind the ⋮ menu, so hovering a message no
|
// Everything that isn't a reaction or an edit now lives behind the ⋮ menu, so hovering a message no
|
||||||
// longer buries it under a wall of icons.
|
// longer buries it under a wall of icons.
|
||||||
|
// Shared action list for a message — used by BOTH the desktop ⋮ menu and the mobile long-press sheet (#9).
|
||||||
|
function msgMenuItems(m){
|
||||||
|
const mine=m.from===ME.id;
|
||||||
|
const items=[];
|
||||||
|
items.push({ic:'reply', label:'Reply', fn:()=>setReply(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((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.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"
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
// #9 (mobile): long-press a message → a dimmed + blurred backdrop and a bottom action sheet with quick
|
||||||
|
// reactions and every action (reply / forward / copy / pin / delete). This replaces the old hover-style
|
||||||
|
// ".show-actions" reveal on touch — which could sit hidden behind an image and stopped working after the
|
||||||
|
// lightbox opened. Reuses reactToMessage / the ⋮-menu actions, so nothing new to maintain.
|
||||||
|
function openMsgActionSheet(m){
|
||||||
|
if(!m) return;
|
||||||
|
document.querySelectorAll('.msg-sheet-ov').forEach(x=>x.remove());
|
||||||
|
const items=msgMenuItems(m);
|
||||||
|
const reactRow = m.deleted ? '' : ('<div class="ms-reacts">'+QUICK_REACTS.map(q=>'<button data-e="'+q.e+'" title="'+pEsc(q.t)+'">'+q.e+'</button>').join('')+'<button class="more" data-more="1" title="More emoji">'+ic('smilePlus',18)+'</button></div>');
|
||||||
|
const acts=items.map((it,i)=>'<button class="ms-act'+(it.danger?' danger':'')+'" data-i="'+i+'">'+ic(it.ic,18)+'<span>'+pEsc(it.label)+'</span></button>').join('');
|
||||||
|
const ov=document.createElement('div'); ov.className='msg-sheet-ov';
|
||||||
|
ov.innerHTML='<div class="msg-sheet">'+reactRow+'<div class="ms-list">'+acts+'</div></div>';
|
||||||
|
document.body.appendChild(ov);
|
||||||
|
const close=()=>{ if(document.body.contains(ov)) ov.remove(); };
|
||||||
|
ov.addEventListener('click',(e)=>{
|
||||||
|
if(e.target===ov){ close(); return; } // tap the blurred backdrop → dismiss
|
||||||
|
const rb=e.target.closest('.ms-reacts button');
|
||||||
|
if(rb){ close(); if(rb.dataset.more){ setTimeout(()=>{ try{ openEmojiForReact(m.id, document.getElementById('msgs')||document.body); }catch(_){} },0); } else if(rb.dataset.e){ reactToMessage(m.id, rb.dataset.e); } return; }
|
||||||
|
const ab=e.target.closest('.ms-act');
|
||||||
|
if(ab){ const it=items[+ab.dataset.i]; close(); try{ it.fn(); }catch(_){} return; }
|
||||||
|
});
|
||||||
|
}
|
||||||
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
|
||||||
const m=THREAD.find(x=>x.id===msgId); if(!m) return;
|
const m=THREAD.find(x=>x.id===msgId); if(!m) return;
|
||||||
const mine=m.from===ME.id;
|
const items=msgMenuItems(m);
|
||||||
const items=[];
|
|
||||||
items.push({ic:'reply', label:'Reply', fn:()=>setReply(m)});
|
|
||||||
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.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: one entry → branded dialog offers "for me" / "for everyone"
|
|
||||||
const menu=document.createElement('div'); menu.className='spk-menu msg-more-menu';
|
const menu=document.createElement('div'); menu.className='spk-menu msg-more-menu';
|
||||||
menu.innerHTML=items.map((it,i)=>'<button class="spk-opt mm-opt'+(it.danger?' danger':'')+'" data-i="'+i+'">'+ic(it.ic,15)+'<span>'+pEsc(it.label)+'</span></button>').join('');
|
menu.innerHTML=items.map((it,i)=>'<button class="spk-opt mm-opt'+(it.danger?' danger':'')+'" data-i="'+i+'">'+ic(it.ic,15)+'<span>'+pEsc(it.label)+'</span></button>').join('');
|
||||||
document.body.appendChild(menu);
|
document.body.appendChild(menu);
|
||||||
@@ -3396,6 +3443,10 @@ function armOpenSlide(){
|
|||||||
}
|
}
|
||||||
async function openConvo(kind,id){
|
async function openConvo(kind,id){
|
||||||
const it=rowFor(kind,id)||{kind,id,name:'Conversation'};
|
const it=rowFor(kind,id)||{kind,id,name:'Conversation'};
|
||||||
|
// #14: abandon any in-progress edit when switching conversations. Otherwise editTarget stayed set and the
|
||||||
|
// composer's `if(!editTarget) setDraft(...)` guard silently stopped saving drafts in EVERY chat until you
|
||||||
|
// canceled the edit. The message being edited keeps its stored draft (editing never overwrote it).
|
||||||
|
editTarget=null; _editSavedDraft='';
|
||||||
const _unreadN=_openUnread; _openUnread=0; // consume the captured unread count (#3)
|
const _unreadN=_openUnread; _openUnread=0; // consume the captured unread count (#3)
|
||||||
_convoRevealed=false; // #7: this open hasn't revealed yet
|
_convoRevealed=false; // #7: this open hasn't revealed yet
|
||||||
_forcePinOpen=true; // glue to newest until the user actually scrolls (cleared on the first touch/wheel below)
|
_forcePinOpen=true; // glue to newest until the user actually scrolls (cleared on the first touch/wheel below)
|
||||||
@@ -3499,9 +3550,10 @@ async function openConvo(kind,id){
|
|||||||
const bub=e.target.closest('.bubble'); if(!bub || bub.classList.contains('deleted')){ 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;
|
lpBub=bub; lpX=e.touches[0].clientX; lpY=e.touches[0].clientY; lpFired=false;
|
||||||
lpT=setTimeout(()=>{ lpT=null; if(!lpBub) return;
|
lpT=setTimeout(()=>{ lpT=null; if(!lpBub) return;
|
||||||
box.querySelectorAll('.bubble.show-actions').forEach(b=>{ if(b!==lpBub) b.classList.remove('show-actions'); });
|
const mid=lpBub.dataset.id; const m=mid&&THREAD.find(x=>x.id===mid);
|
||||||
lpBub.classList.add('show-actions'); lpFired=true;
|
lpFired=true;
|
||||||
try{ if(navigator.vibrate) navigator.vibrate(12); }catch(_){}
|
try{ if(navigator.vibrate) navigator.vibrate(12); }catch(_){}
|
||||||
|
if(m) openMsgActionSheet(m); // #9: long-press → dimmed/blurred action sheet (works on images too)
|
||||||
}, 420);
|
}, 420);
|
||||||
},{passive:true});
|
},{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('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});
|
||||||
@@ -4235,6 +4287,11 @@ async function hardReloadApp(){
|
|||||||
// Cheap by design: one ~35-byte request, answered from a variable the server read at startup. The
|
// Cheap by design: one ~35-byte request, answered from a variable the server read at startup. The
|
||||||
// throttle above means refocusing the app repeatedly costs nothing.
|
// throttle above means refocusing the app repeatedly costs nothing.
|
||||||
setInterval(()=>checkWebBuild(true), 5*60*1000); // every 5 min (the interval IS the schedule)
|
setInterval(()=>checkWebBuild(true), 5*60*1000); // every 5 min (the interval IS the schedule)
|
||||||
|
// Keep the (now sliding) session alive: an always-open desktop/mobile app that never reloads still pings
|
||||||
|
// /api/me periodically, which re-stamps the cookie + extends the session server-side. Combined with the
|
||||||
|
// on-resume ping, a session you actually use never expires — you only log out by choosing to.
|
||||||
|
function touchSession(){ try{ if(!ME||ME.guest) return; fetch('/api/me').catch(()=>{}); }catch(_){} }
|
||||||
|
setInterval(touchSession, 6*60*60*1000); // every 6 hours
|
||||||
// #2: when we come back to the app, mark the OPEN chat read (we deliberately skip that while hidden so a
|
// #2: when we come back to the app, mark the OPEN chat read (we deliberately skip that while hidden so a
|
||||||
// backgrounded message keeps its notification). Only runs when actually visible + a chat is open.
|
// backgrounded message keeps its notification). Only runs when actually visible + a chat is open.
|
||||||
function markOpenChatRead(){
|
function markOpenChatRead(){
|
||||||
@@ -4323,6 +4380,20 @@ function playPing(){
|
|||||||
tone(1174.7, 0.11, 0.40, 0.34); // D6 — rises, rings out -> "ti-doo"
|
tone(1174.7, 0.11, 0.40, 0.34); // D6 — rises, rings out -> "ti-doo"
|
||||||
}catch(_){}
|
}catch(_){}
|
||||||
}
|
}
|
||||||
|
// #2: a soft, single "blip" for a message that lands in the chat you're ALREADY looking at (app open +
|
||||||
|
// chat open). Deliberately subtler and DIFFERENT from playPing's alerting two-note rise — like WhatsApp's
|
||||||
|
// in-chat tone: you hear the message arrive, but it isn't a notification.
|
||||||
|
function playMsgTone(){
|
||||||
|
try{
|
||||||
|
_audioCtx=_audioCtx||new (window.AudioContext||window.webkitAudioContext)();
|
||||||
|
if(_audioCtx.state==='suspended') _audioCtx.resume();
|
||||||
|
const t=_audioCtx.currentTime;
|
||||||
|
const o=_audioCtx.createOscillator(), g=_audioCtx.createGain();
|
||||||
|
o.type='sine'; o.frequency.setValueAtTime(660,t); o.frequency.exponentialRampToValueAtTime(990,t+0.08); // quick soft upward blip
|
||||||
|
g.gain.setValueAtTime(0.0001,t); g.gain.exponentialRampToValueAtTime(0.11,t+0.02); g.gain.exponentialRampToValueAtTime(0.0001,t+0.17);
|
||||||
|
o.connect(g); g.connect(_audioCtx.destination); o.start(t); o.stop(t+0.2);
|
||||||
|
}catch(_){}
|
||||||
|
}
|
||||||
// #7: a soft, friendly two-note chime when a NEW participant joins the meeting (distinct from the message ping).
|
// #7: a soft, friendly two-note chime when a NEW participant joins the meeting (distinct from the message ping).
|
||||||
function playJoinChime(){
|
function playJoinChime(){
|
||||||
try{
|
try{
|
||||||
@@ -4386,12 +4457,15 @@ function onChatMessage(m){
|
|||||||
// same chat while the app is minimised (document.hidden) — that backgrounded case is exactly the one
|
// same chat while the app is minimised (document.hidden) — that backgrounded case is exactly the one
|
||||||
// that used to stay silent. When hidden with push active, let the SW/native push show it instead.
|
// that used to stay silent. When hidden with push active, let the SW/native push show it instead.
|
||||||
const activelyViewing = isOpen && !document.hidden;
|
const activelyViewing = isOpen && !document.hidden;
|
||||||
if(notifOn(kind) && !activelyViewing){ playPing();
|
if(notifOn(kind)){
|
||||||
if(!(document.hidden && pushActive)){
|
if(activelyViewing){ playMsgTone(); } // #2: message landed in the chat you're looking at → soft in-chat tone, NO popup
|
||||||
const prev=isGifUrl(m.body)?'🎞️ GIF':(m.body?(m.body.length>80?m.body.slice(0,80)+'…':m.body):'Sent an attachment');
|
else { playPing(); // a different chat, OR this chat while minimised → alert + popup
|
||||||
// Group: title = GROUP name, body = "Sender: message" (so you know which group it's from).
|
if(!(document.hidden && pushActive)){
|
||||||
if(kind==='group') notify((it&&it.name)||m.groupName||'Group', (m.fromName?m.fromName+': ':'')+prev, kind, rid);
|
const prev=isGifUrl(m.body)?'🎞️ GIF':(m.body?(m.body.length>80?m.body.slice(0,80)+'…':m.body):'Sent an attachment');
|
||||||
else notify((m.fromName||'New message'), prev, kind, rid);
|
// Group: title = GROUP name, body = "Sender: message" (so you know which group it's from).
|
||||||
|
if(kind==='group') notify((it&&it.name)||m.groupName||'Group', (m.fromName?m.fromName+': ':'')+prev, kind, rid);
|
||||||
|
else notify((m.fromName||'New message'), prev, kind, rid);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Activity-center entries for things easy to miss.
|
// Activity-center entries for things easy to miss.
|
||||||
@@ -4464,7 +4538,7 @@ let _lastPresenceRefresh=0;
|
|||||||
function refreshPresenceOnResume(){
|
function refreshPresenceOnResume(){
|
||||||
try{
|
try{
|
||||||
if(!chatWs || chatWs.readyState>1){ connectChatWs(); } // dead/closed → force reconnect (skip if OPEN/CONNECTING)
|
if(!chatWs || chatWs.readyState>1){ connectChatWs(); } // dead/closed → force reconnect (skip if OPEN/CONNECTING)
|
||||||
const t=Date.now(); if(t-_lastPresenceRefresh>4000){ _lastPresenceRefresh=t; try{ loadSidebar(); }catch(_){} } // throttle so rapid tab switches don't spam
|
const t=Date.now(); if(t-_lastPresenceRefresh>4000){ _lastPresenceRefresh=t; try{ loadSidebar(); }catch(_){} try{ touchSession(); }catch(_){} } // throttle so rapid tab switches don't spam; also slide the session forward
|
||||||
}catch(_){}
|
}catch(_){}
|
||||||
}
|
}
|
||||||
function connectChatWs(){
|
function connectChatWs(){
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ const authSessions = {
|
|||||||
db.prepare('INSERT INTO sessions_auth (token,user_id,mfa_passed,created_at,expires_at) VALUES (?,?,?,?,?)')
|
db.prepare('INSERT INTO sessions_auth (token,user_id,mfa_passed,created_at,expires_at) VALUES (?,?,?,?,?)')
|
||||||
.run(token, userId, mfaPassed ? 1 : 0, now(), now() + ttl),
|
.run(token, userId, mfaPassed ? 1 : 0, now(), now() + ttl),
|
||||||
markMfaPassed: (token) => db.prepare('UPDATE sessions_auth SET mfa_passed=1 WHERE token=?').run(token),
|
markMfaPassed: (token) => db.prepare('UPDATE sessions_auth SET mfa_passed=1 WHERE token=?').run(token),
|
||||||
|
touch: (token, ttl) => db.prepare('UPDATE sessions_auth SET expires_at=? WHERE token=?').run(now() + ttl, token), // slide the expiry forward on activity
|
||||||
deleteByToken: (token) => db.prepare('DELETE FROM sessions_auth WHERE token=?').run(token),
|
deleteByToken: (token) => db.prepare('DELETE FROM sessions_auth WHERE token=?').run(token),
|
||||||
deleteByUser: (userId) => db.prepare('DELETE FROM sessions_auth WHERE user_id=?').run(userId),
|
deleteByUser: (userId) => db.prepare('DELETE FROM sessions_auth WHERE user_id=?').run(userId),
|
||||||
};
|
};
|
||||||
|
|||||||
+19
-1
@@ -267,7 +267,7 @@ route('POST', '/api/login', async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const tok = A.token();
|
const tok = A.token();
|
||||||
const ttl = remember ? 1000 * 60 * 60 * 24 * 30 : SESSION_TTL; // 30 days if remembered, else 24h
|
const ttl = SESSION_TTL; // long-lived (90d) and slid forward on /api/me — no more 24h overnight logout (remember-me is now moot)
|
||||||
await R.authSessions.create({ token: tok, userId: u.id, mfaPassed: true, ttl });
|
await R.authSessions.create({ token: tok, userId: u.id, mfaPassed: true, ttl });
|
||||||
res.setHeader('Set-Cookie', `sid=${tok}; HttpOnly; Path=/; Max-Age=${ttl / 1000}`);
|
res.setHeader('Set-Cookie', `sid=${tok}; HttpOnly; Path=/; Max-Age=${ttl / 1000}`);
|
||||||
audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'login' });
|
audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'login' });
|
||||||
@@ -359,6 +359,17 @@ route('GET', '/api/ice', async (req, res) => {
|
|||||||
route('GET', '/api/me', async (req, res) => {
|
route('GET', '/api/me', async (req, res) => {
|
||||||
const u = await currentUser(req);
|
const u = await currentUser(req);
|
||||||
if (!u) return json(res, 401, { error: 'unauthorized' });
|
if (!u) return json(res, 401, { error: 'unauthorized' });
|
||||||
|
// Sliding session: a web (cookie) client hitting /api/me — on app load, focus, or the periodic heartbeat —
|
||||||
|
// pushes its expiry out to a fresh full window and re-stamps the cookie. So any regular use keeps you logged
|
||||||
|
// in indefinitely; you only lapse after SESSION_TTL of NO use at all, or by logging out. (Native clients use
|
||||||
|
// the refresh-token flow, so we only renew here when the request actually carried the sid cookie.)
|
||||||
|
try {
|
||||||
|
const tok = parseCookies(req).sid;
|
||||||
|
if (tok && u._session && u._session.token === tok) {
|
||||||
|
await R.authSessions.touch(tok, SESSION_TTL);
|
||||||
|
res.setHeader('Set-Cookie', `sid=${tok}; HttpOnly; Path=/; Max-Age=${SESSION_TTL / 1000}`);
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
json(res, 200, { id: u.id, email: u.email, role: u.role, teamId: u.team_id, name: u.name || null, avatarUrl: u.avatar_url || null, status: u.status || 'active' });
|
json(res, 200, { id: u.id, email: u.email, role: u.role, teamId: u.team_id, name: u.name || null, avatarUrl: u.avatar_url || null, status: u.status || 'active' });
|
||||||
});
|
});
|
||||||
// Set my presence status: 'active' | 'away' | 'onleave' ('incall' is derived, not settable).
|
// Set my presence status: 'active' | 'away' | 'onleave' ('incall' is derived, not settable).
|
||||||
@@ -1655,6 +1666,13 @@ route('POST', '/api/messages/pin', async (req, res) => {
|
|||||||
if (!canSee) return json(res, 403, { error: 'not allowed' });
|
if (!canSee) return json(res, 403, { error: 'not allowed' });
|
||||||
const pin = on !== false; // default true
|
const pin = on !== false; // default true
|
||||||
await R.messages.setPinned(id, pin ? now() : null, pin ? u.id : null);
|
await R.messages.setPinned(id, pin ? now() : null, pin ? u.id : null);
|
||||||
|
// #13: log every pin/unpin so there's an accountable trail — anyone can unpin anyone's pin, but who did it
|
||||||
|
// (and, for an unpin, whose pin they removed) is now recorded in the audit log.
|
||||||
|
try {
|
||||||
|
const where = m.conversation_id ? ('group ' + m.conversation_id) : ('dm with ' + (m.sender_id === u.id ? m.recipient_id : m.sender_id));
|
||||||
|
const whose = (!pin && m.pinned_by && m.pinned_by !== u.id) ? (' (originally pinned by ' + m.pinned_by + ')') : '';
|
||||||
|
audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: pin ? 'message.pin' : 'message.unpin', detail: where + ' · message ' + id + whose });
|
||||||
|
} catch (_) {}
|
||||||
const evt = { type: 'chat-pinned', id, on: pin, by: u.name || u.email, conversation_id: m.conversation_id || null };
|
const evt = { type: 'chat-pinned', id, on: pin, by: u.name || u.email, conversation_id: m.conversation_id || null };
|
||||||
if (m.conversation_id) { for (const mid of await R.conversations.members(m.conversation_id)) { try { CHAT.pushToUser(mid, evt); } catch (_) {} } }
|
if (m.conversation_id) { for (const mid of await R.conversations.members(m.conversation_id)) { try { CHAT.pushToUser(mid, evt); } catch (_) {} } }
|
||||||
else { try { CHAT.pushToUser(m.recipient_id, evt); } catch (_) {} try { CHAT.pushToUser(m.sender_id, evt); } catch (_) {} }
|
else { try { CHAT.pushToUser(m.recipient_id, evt); } catch (_) {} try { CHAT.pushToUser(m.sender_id, evt); } catch (_) {} }
|
||||||
|
|||||||
Reference in New Issue
Block a user