feat(chat): emoji speed, action rework, copy, phone links, image zoom, upload %, profile card, last seen (batch82)

#6 Emoji were slow because Twemoji swapped EVERY emoji for an <img> fetched individually
   from a CDN — opening the picker fired hundreds of image requests. Now uses the OS's own
   colour emoji font: instant, zero network. twemojify() kept as a no-op.
#4 Message hover row reworked: three one-tap reactions (Like/Laugh/Surprised) + the emoji
   picker + (own) Edit. Reply / Forward / Copy / Delete moved behind a ⋮ menu. Added Copy.
   The row now sits FULLY above the bubble (was top:-14px, overlapping the first text line).
#9 Phone numbers linkify to tel: — mobile gets the OS "call this number?" prompt; desktop has
   no dialer so it offers to copy. Regex kept conservative (10–15 digits, needs +/grouping) so
   it won't grab amounts, dates or 6-digit meeting codes.
#3 Image preview zooms: wheel + pinch + double-click + ± buttons, drag to pan, keys (+/-/0),
   cursor-anchored. Arrows hide while zoomed so panning isn't hijacked.
#8 Upload progress: fetch() can't report upload progress at all, so a large file just said
   "uploading…". Switched to XHR (upload.onprogress) → real bar + %, and cancel aborts in flight.
#1 Clicking a sender in a group opens a mini profile card (photo, presence, last seen) with a
   Message button that opens the 1:1 (and a view-photo button).
#2 Last seen: new users.last_seen column, stamped on connect and when the last socket drops;
   carried on the presence broadcast, so an offline contact reads "Last seen 10 minutes ago"
   instead of a bare "Offline".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 23:46:52 +05:30
parent 667b4c69d5
commit 860a7bd6cf
5 changed files with 279 additions and 33 deletions
+11 -2
View File
@@ -8,13 +8,18 @@ function register(userId, ws) {
if (!chatClients.has(userId)) chatClients.set(userId, new Set());
chatClients.get(userId).add(ws);
ws._chatUserId = userId;
try { repos().users.touchSeen(userId); } catch (_) {} // "last seen" (#2)
}
function unregister(ws) {
const id = ws && ws._chatUserId;
if (!id) return;
const set = chatClients.get(id);
if (set) { set.delete(ws); if (!set.size) chatClients.delete(id); }
if (set) {
set.delete(ws);
// Their LAST socket went away → stamp when they were last here, so contacts can show "last seen …".
if (!set.size) { chatClients.delete(id); try { repos().users.touchSeen(id); } catch (_) {} }
}
}
function isOnline(userId) {
@@ -45,7 +50,11 @@ function effectiveStatus(userId) {
}
function broadcastPresence(userId) {
if (!userId) return;
const payload = JSON.stringify({ type: 'presence', userId, online: isOnline(userId), status: effectiveStatus(userId) });
// Carry last_seen too, so a contact going offline immediately reads "Last seen just now" instead of a
// bare "Offline" until the next sidebar reload (#2).
let lastSeen = null;
try { const u = repos().users.byId(userId); lastSeen = (u && u.last_seen) || null; } catch (_) {}
const payload = JSON.stringify({ type: 'presence', userId, online: isOnline(userId), status: effectiveStatus(userId), lastSeen });
for (const [uid, set] of chatClients) {
if (uid === userId) continue; // no need to tell someone about their own status
for (const ws of set) { if (ws.readyState === 1) { try { ws.send(payload); } catch (_) {} } }
+3
View File
@@ -225,6 +225,9 @@ try { db.exec("ALTER TABLE users ADD COLUMN status TEXT NOT NULL DEFAULT 'active
// their mobile number, so this — not the typed login identifier — is the stable identity key.
// Provisioning matches on it to keep one Biz Connect account per person (#2 account merge).
try { db.exec('ALTER TABLE users ADD COLUMN bizgaze_user_id TEXT'); } catch (e) { /* exists */ }
// #2: when this user was last connected (stamped on connect + on their last socket closing), so contacts
// can show "last seen 10 minutes ago" instead of a bare "Offline".
try { db.exec('ALTER TABLE users ADD COLUMN last_seen INTEGER'); } catch (e) { /* exists */ }
// External (non-Connect) invitee emails on a scheduled meeting (#4) — JSON array. They get an emailed
// guest join link instead of an in-app invite.
try { db.exec('ALTER TABLE scheduled_meetings ADD COLUMN guest_emails TEXT'); } catch (e) { /* exists */ }
+257 -26
View File
@@ -343,6 +343,14 @@
.lightbox .lb-dl{right:calc(74px + env(safe-area-inset-right,0px));}
.lightbox .lb-close:hover,.lightbox .lb-dl:hover{background:rgba(255,255,255,.28);}
.lightbox img{max-width:82vw;}
/* #3 zoom: transform-based, anchored at the cursor; arrows hide while zoomed so panning isn't hijacked */
.lightbox img{transition:transform .08s linear;transform-origin:center center;will-change:transform;}
.lightbox img.zoomed{cursor:grab;} .lightbox img.grabbing{cursor:grabbing;transition:none;}
.lightbox.zooming .lb-nav{display:none;}
.lightbox .lb-zoom{position:absolute;left:50%;transform:translateX(-50%);bottom:calc(18px + env(safe-area-inset-bottom,0px));display:flex;gap:.4rem;z-index:2;}
.lightbox .lb-zoom button{position:relative;border:none;background:rgba(255,255,255,.14);color:#fff;width:44px;height:36px;border-radius:10px;display:grid;place-items:center;cursor:pointer;}
.lightbox .lb-zoom button:hover{background:rgba(255,255,255,.28);}
.lightbox .lb-zoom button b{position:absolute;right:7px;bottom:4px;font-size:.72rem;line-height:1;}
.lightbox .lb-nav{position:absolute;top:50%;transform:translateY(-50%);border:none;background:rgba(255,255,255,.16);color:#fff;width:32px;height:32px;border-radius:50%;display:grid;place-items:center;cursor:pointer;}
.lightbox .lb-nav:hover{background:rgba(255,255,255,.34);}
.lightbox .lb-prev{left:14px;} .lightbox .lb-next{right:14px;}
@@ -359,6 +367,13 @@
.ap-thumb{width:42px;height:42px;border-radius:8px;object-fit:cover;flex:0 0 auto;}
.ap-ic{display:grid;place-items:center;color:var(--blue);flex:0 0 auto;}
.ap-name{font-size:.82rem;color:var(--ink);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:240px;}
/* #8: upload progress — a real bar + % so the user knows when Send is safe */
.ap-main{display:flex;flex-direction:column;gap:.2rem;min-width:0;}
.ap-prog{display:flex;align-items:center;gap:.4rem;}
.ap-track{flex:1;min-width:90px;height:4px;border-radius:99px;background:#e2e8f0;overflow:hidden;}
.ap-fill{display:block;height:100%;width:0;border-radius:99px;background:var(--blue);transition:width .15s linear;}
.ap-pct{font-size:.68rem;color:var(--muted);font-weight:600;min-width:30px;text-align:right;}
.ap-item.uploading{border-color:var(--blue);background:var(--blue-soft);}
.ap-x{border:none;background:transparent;color:var(--muted);cursor:pointer;display:grid;place-items:center;padding:.15rem;border-radius:6px;flex:0 0 auto;}
.ap-x:hover{color:var(--red);background:#fee2e2;}
/* meetings */
@@ -511,6 +526,7 @@
#setUpd.has-update{background:var(--brand);color:var(--blue-d);}
.set-ver-i{display:inline-grid;place-items:center;width:15px;height:15px;border-radius:50%;background:var(--brand);color:var(--blue-d);font-size:.66rem;font-weight:800;font-style:italic;margin-left:.35rem;vertical-align:middle;}
.bubble .msg-link{color:inherit;text-decoration:underline;text-underline-offset:2px;word-break:break-word;}
.bubble .msg-tel{color:inherit;text-decoration:underline;text-underline-offset:2px;white-space:nowrap;cursor:pointer;} /* #9 */
.bubble.them .msg-link{color:var(--blue);} .bubble.mine .msg-link{color:#dbe9ff;}
.bubble .fwd-label{display:flex;align-items:center;gap:.2rem;font-size:.72rem;font-style:italic;opacity:.7;margin-bottom:.2rem;}
.bubble .fwd-label svg{transform:scaleX(-1) rotate(0deg);}
@@ -518,10 +534,16 @@
/* All message actions live in ONE hover pill anchored to the bubble's top-right corner. It
overlaps the bubble slightly so there's no dead gap — on a short message the icons can't float
off into empty space where the hover (and the buttons) would vanish before you can click. */
.msg-actions{position:absolute;top:-14px;right:6px;z-index:6;display:flex;gap:1px;background:var(--card);border:1px solid var(--line);border-radius:999px;padding:2px;box-shadow:0 2px 8px rgba(20,30,60,.18);opacity:0;pointer-events:none;transition:opacity .12s;}
/* #4: sit FULLY above the bubble (was top:-14px, which overlapped the first line of text). */
.msg-actions{position:absolute;bottom:calc(100% - 2px);right:6px;z-index:6;display:flex;align-items:center;gap:1px;background:var(--card);border:1px solid var(--line);border-radius:999px;padding:2px 3px;box-shadow:0 4px 12px rgba(20,30,60,.2);opacity:0;pointer-events:none;transition:opacity .12s;}
.bubble:hover .msg-actions,.bubble.show-actions .msg-actions{opacity:1;pointer-events:auto;}
.msg-actions button{position:static;width:26px;height:26px;border:none;background:none;border-radius:50%;display:grid;place-items:center;cursor:pointer;color:var(--blue);opacity:1;pointer-events:auto;box-shadow:none;padding:0;transition:background .12s;}
.msg-actions button:hover{background:var(--blue-soft);}
/* one-tap reactions render as the emoji glyph itself */
.msg-actions .qr-btn{font-size:15px;line-height:1;}
.msg-actions .qr-btn:hover{background:var(--blue-soft);transform:scale(1.15);}
.msg-more-menu .spk-opt.danger{color:var(--red);} .msg-more-menu .spk-opt.danger svg{color:var(--red);}
.msg-more-menu .spk-opt.danger:hover{background:#fee2e2;}
.msg-actions .del-btn{color:var(--red);} .msg-actions .del-btn:hover{background:#fee2e2;}
/* #1 Forward: message selection mode + target picker */
/* Selection mode: a checkbox appears on every message; selected ones highlight in green (distinct
@@ -723,6 +745,20 @@
.meet-bar .spk-btn.off{background:#e2e8f0;color:#94a3b8;}
.gi-list .mrow.dm-able{cursor:pointer;border-radius:9px;}
.gi-list .mrow.dm-able:hover{background:var(--blue-soft);}
/* #1 mini profile card */
.bubble .sender.profile-open{cursor:pointer;border-radius:8px;padding:1px 4px;margin-left:-4px;}
.bubble .sender.profile-open:hover{background:rgba(31,59,115,.07);}
.mini-profile{position:fixed;z-index:9750;width:250px;background:var(--card);border:1px solid var(--line);border-radius:14px;box-shadow:0 16px 40px rgba(20,30,60,.28);padding:.8rem;}
.mini-profile .mp-top{display:flex;align-items:center;gap:.6rem;margin-bottom:.7rem;}
.mini-profile .mp-av{position:relative;overflow:hidden;width:44px;height:44px;flex:0 0 44px;border-radius:50%;display:grid;place-items:center;color:#334155;font-weight:700;font-size:.9rem;}
.mini-profile .mp-av img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;}
.mini-profile .mp-id{min-width:0;}
.mini-profile .mp-id b{display:block;font-size:.92rem;color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.mini-profile .mp-id span{font-size:.74rem;color:var(--muted);}
.mini-profile .mp-acts{display:flex;gap:.4rem;}
.mini-profile .mp-msg{flex:1;display:inline-flex;align-items:center;justify-content:center;gap:.35rem;border:none;border-radius:9px;background:var(--blue);color:#fff;font:inherit;font-size:.84rem;font-weight:600;padding:.5rem;cursor:pointer;}
.mini-profile .mp-msg:hover{filter:brightness(1.08);}
.mini-profile .mp-dp{flex:0 0 auto;border:1px solid var(--line);background:var(--card);color:var(--blue);border-radius:9px;width:38px;display:grid;place-items:center;cursor:pointer;}
.spk-menu .mm-opt{display:flex;align-items:center;gap:.55rem;}
.spk-menu .mm-opt svg{flex:0 0 auto;color:var(--blue);}
/* ---- Mobile meeting bar (#8c/#8e): only Mic / Camera / Speaker / End; the rest behind ⋮ More ---- */
@@ -1001,11 +1037,15 @@
</head>
<body>
<script src="/icons.js?v=6"></script>
<script src="https://cdn.jsdelivr.net/npm/@twemoji/api@15.1.0/dist/twemoji.min.js" crossorigin="anonymous"></script>
<script>window.__BUILD='2026-07-14-batch81';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD);
// Render modern (Twemoji) emojis in place of the OS's flat ones. No-op if the CDN didn't load
// (emojis stay as plain Unicode). (#5)
function twemojify(el){ try{ if(el && window.twemoji) window.twemoji.parse(el, { folder:'svg', ext:'.svg' }); }catch(_){} }</script>
<script>window.__BUILD='2026-07-14-batch82';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD);
// Emoji are rendered with the OS's own (colour) emoji font — instant, zero network.
//
// We used to run Twemoji over every emoji, which swapped each one for an <img> pulled INDIVIDUALLY from
// a CDN. Opening the picker therefore fired hundreds of image requests, and every re-render of a long
// thread fired more — that's why emoji "loaded very slowly" (#6). Modern Windows/macOS/iOS/Android all
// ship full-colour emoji, so the native glyphs look right and cost nothing. Kept as a no-op so the
// existing call sites don't have to change.
function twemojify(_el){ /* native emoji: nothing to do */ }</script>
<div class="loading" id="loading"><div class="ld-inner"><img src="/loaders/loader-orbit-dark.svg" alt="" width="72" height="72"><div class="ld-wm">Biz <b>Connect</b></div><span class="ld-sub">Loading…</span></div></div>
<header>
@@ -1523,7 +1563,23 @@ function fmtTime(ts){
}
// Presence/status helpers (#7): 'incall' (auto) overrides; offline if not connected.
function statusCls(it){ const s=it&&it.status; if(s==='incall') return 'incall'; if(!it||!it.online) return 'offline'; if(s==='away') return 'away'; if(s==='onleave') return 'onleave'; return 'active'; }
function statusLabel(it){ const c=statusCls(it); return c==='incall'?'In a call':c==='away'?'Away':c==='onleave'?'On leave':c==='active'?'Available':'Offline'; }
function statusLabel(it){ const c=statusCls(it); return c==='incall'?'In a call':c==='away'?'Away':c==='onleave'?'On leave':c==='active'?'Available':lastSeenLabel(it); }
// #2: when someone is offline, "Offline" alone is unhelpful — say when they were last around.
function lastSeenLabel(it){
const ts=it&&it.lastSeen; if(!ts) return 'Offline';
const d=Date.now()-ts;
if(d<60*1000) return 'Last seen just now';
const mins=Math.floor(d/60000);
if(mins<60) return 'Last seen '+mins+(mins===1?' minute ago':' minutes ago');
const hrs=Math.floor(mins/60);
if(hrs<24) return 'Last seen '+hrs+(hrs===1?' hour ago':' hours ago');
const t=new Date(ts), now=new Date();
const days=Math.floor((new Date(now.getFullYear(),now.getMonth(),now.getDate())-new Date(t.getFullYear(),t.getMonth(),t.getDate()))/86400000);
const clock=t.toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'});
if(days===1) return 'Last seen yesterday at '+clock;
if(days<7) return 'Last seen '+t.toLocaleDateString([],{weekday:'long'})+' at '+clock;
return 'Last seen '+t.toLocaleDateString([],{month:'short',day:'numeric'})+' at '+clock;
}
function avatarHTML(it, big){
const isG=it.kind==='group';
const sz=big?'width:38px;height:38px;flex:0 0 38px;':'';
@@ -1652,7 +1708,7 @@ async function loadSidebar(){
// A contact you haven't messaged YET still needs their profile photo/status: this row was being built
// with only name+online, silently dropping `avatar` — so the same person showed their DP in a group
// (which reads /api/groups/members) but fell back to initials in the 1:1. Carry the whole contact.
for(const c of CONTACTS){ if(!dmIds.has(c.id)) items.push({ kind:'dm', id:c.id, name:c.name, email:c.email||'', avatar:c.avatar||null, online:!!c.online, status:c.status||'active', last_body:'', last_at:0, last_from_me:false, unread:0 }); }
for(const c of CONTACTS){ if(!dmIds.has(c.id)) items.push({ kind:'dm', id:c.id, name:c.name, email:c.email||'', avatar:c.avatar||null, online:!!c.online, status:c.status||'active', lastSeen:c.lastSeen||null, last_body:'', last_at:0, last_from_me:false, unread:0 }); }
const onlineById={}; CONTACTS.forEach(c=>onlineById[c.id]=!!c.online);
items.forEach(it=>{ if(it.kind==='dm') it.online=!!onlineById[it.id]; });
// Always-available "You" chat (note to self), pinned to the top (#4).
@@ -1753,7 +1809,8 @@ function bubbleHTML(m){
if(m.system||m.from==='__system__') return '<div class="sys-msg">'+pEsc(m.body)+'</div>';
const mine=m.from===ME.id;
if(m.deleted) return '<div class="bubble '+(mine?'mine':'them')+' deleted" data-id="'+pEsc(m.id)+'"><span class="del-msg">'+ic('trash',12)+' This message was deleted</span><span class="t">'+pEsc(fmtClock(m.created_at))+'</span></div>';
const sender=(convoIsGroup && !mine && m.fromName)?'<div class="sender">'+senderAvatar(m.from, m.fromName)+'<span>'+pEsc(m.fromName)+'</span></div>':'';
// #1: the sender chip is clickable → mini profile card (with a Message button that opens their DM).
const sender=(convoIsGroup && !mine && m.fromName)?'<div class="sender profile-open" data-uid="'+pEsc(m.from)+'" title="View profile">'+senderAvatar(m.from, m.fromName)+'<span>'+pEsc(m.fromName)+'</span></div>':'';
let quote='';
if(m.reply){ const t=replyTint(m.reply.from||m.reply.fromName); quote='<div class="quote" data-jid="'+pEsc(m.reply.id||'')+'" data-jat="'+(m.reply.at||0)+'" title="Go to message" style="background:'+t[0]+';border-left-color:'+t[1]+'"><b style="color:'+t[1]+'">'+pEsc(m.reply.fromName||'')+'</b>: '+pEsc(m.reply.body)+'</div>'; }
const reacts=(m.reactions&&m.reactions.length)?'<div class="reacts">'+m.reactions.map(r=>'<button class="react-chip'+(r.mine?' mine':'')+'" data-id="'+pEsc(m.id)+'" data-emoji="'+pEsc(r.emoji)+'" title="'+pEsc((r.who||[]).join(', '))+'">'+pEsc(r.emoji)+' '+r.count+'</button>').join('')+'</div>':'';
@@ -1779,16 +1836,74 @@ function bubbleHTML(m){
if(convoIsGroup && m.id===_lastGroupId && Array.isArray(m.seenBy) && m.seenBy.length){ const ns=m.seenBy, head=ns.slice(0,2).join(', '), more=ns.length>2?(' +'+(ns.length-2)+' more'):''; seen='<button class="seenby" data-seen="'+pEsc(ns.join('|'))+'">'+ic('checkCheck',12)+' Seen by '+pEsc(head)+more+'</button>'; }
return '<div class="bubble '+(mine?'mine':'them')+(mentionsMe?' mention-me':'')+(m.poll?' has-poll':'')+'" data-id="'+pEsc(m.id)+'">'
+ sender + (m.fwd_from?'<div class="fwd-label">'+ic('arrowRight',11)+' Forwarded'+(m.fwd_from?(' from <b>'+pEsc(m.fwd_from)+'</b>'):'')+'</div>':'') + quote + att + renderMsgBody(m) + pollHTML(m)
// #4: hover row = three one-tap reactions + the picker + (own) edit; everything else lives behind ⋮.
+ (m.deleted?'':'<div class="msg-actions">'
+ '<button class="reply-btn" data-id="'+pEsc(m.id)+'" title="Reply">'+ic('reply',14)+'</button>'
+ ((m.body||m.attachment)&&!m.poll?'<button class="fwd-btn" data-fwd="'+pEsc(m.id)+'" title="Forward">'+ic('arrowRight',14)+'</button>':'')
+ '<button class="react-btn" data-id="'+pEsc(m.id)+'" title="React">'+ic('smilePlus',14)+'</button>'
+ QUICK_REACTS.map(q=>'<button class="qr-btn" data-id="'+pEsc(m.id)+'" data-emoji="'+q.e+'" title="'+q.t+'">'+q.e+'</button>').join('')
+ '<button class="react-btn" data-id="'+pEsc(m.id)+'" title="More reactions">'+ic('smilePlus',14)+'</button>'
+ ((mine && m.body && !m.poll)?'<button class="edit-btn" data-edit="'+pEsc(m.id)+'" title="Edit message">'+ic('edit',13)+'</button>':'')
+ (mine?'<button class="del-btn" data-del="'+pEsc(m.id)+'" title="Delete message">'+ic('trash',13)+'</button>':'')
+ '<button class="more-btn" data-more="'+pEsc(m.id)+'" title="More">'+ic('moreVertical',14)+'</button>'
+ '</div>')
+ '<span class="t">'+pEsc(fmtClock(m.created_at))+(m.edited_at?'<span class="edited"> · edited</span>':'')+rcpt+'</span>'
+ reacts + seen + '</div>';
}
// #1: mini profile card for a person in a group — photo, name, presence/last-seen, and a Message button
// that drops straight into the 1:1 with them.
function openMiniProfile(uid, anchor){
document.querySelectorAll('.mini-profile').forEach(x=>x.remove());
if(!uid || uid===(ME&&ME.id)) return;
const row=rowFor('dm',uid) || (CONTACTS||[]).find(c=>c.id===uid) || (convoMembers||[]).find(m=>m.id===uid) || {};
const name=row.name || 'Someone';
const card=document.createElement('div'); card.className='mini-profile';
card.innerHTML='<div class="mp-top">'
+'<span class="mp-av" style="background:'+avColor(name)+'">'+(row.avatar?('<img src="'+pEsc(row.avatar)+'" alt="" onerror="this.remove()">'):'')+pEsc(initials(name))+'</span>'
+'<div class="mp-id"><b>'+pEsc(name)+'</b><span>'+pEsc(statusLabel(row))+'</span></div>'
+'</div>'
+'<div class="mp-acts">'
+'<button class="mp-msg" id="mpMsg">'+ic('chat',15)+' Message</button>'
+(row.avatar?'<button class="mp-dp" id="mpDp" title="View photo">'+ic('image',15)+'</button>':'')
+'</div>';
document.body.appendChild(card);
const r=anchor.getBoundingClientRect();
card.style.left=Math.max(8, Math.min(r.left, window.innerWidth-card.offsetWidth-8))+'px';
const below=r.bottom+8, above=r.top-card.offsetHeight-8;
card.style.top=((below+card.offsetHeight<window.innerHeight-8)?below:Math.max(8,above))+'px';
card.querySelector('#mpMsg').onclick=()=>{ card.remove(); switchTab('chat'); selectChat('dm', uid); };
const dp=card.querySelector('#mpDp'); if(dp) dp.onclick=()=>{ card.remove(); openLightbox(row.avatar); };
const close=(e)=>{ if(!card.contains(e.target)){ card.remove(); document.removeEventListener('mousedown',close); } };
setTimeout(()=>document.addEventListener('mousedown',close),0);
}
// #4: the three one-tap reactions shown on hover (Like / Laugh / 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
// longer buries it under a wall of icons.
function openMsgMore(msgId, anchor){
document.querySelectorAll('.msg-more-menu').forEach(x=>x.remove());
if(anchor && anchor._open){ anchor._open=false; return; } // clicking ⋮ again closes it
const m=THREAD.find(x=>x.id===msgId); if(!m) return;
const mine=m.from===ME.id;
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(mine) items.push({ic:'trash', label:'Delete', danger:true, fn:()=>deleteMessage(m.id)});
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('');
document.body.appendChild(menu);
const r=anchor.getBoundingClientRect();
menu.style.left=Math.max(8,Math.min(r.right-menu.offsetWidth, window.innerWidth-menu.offsetWidth-8))+'px';
const below=r.bottom+6, above=r.top-menu.offsetHeight-6;
menu.style.top=((below+menu.offsetHeight<window.innerHeight-8)?below:Math.max(8,above))+'px';
menu.querySelectorAll('.mm-opt').forEach(b=>b.onclick=()=>{ const it=items[+b.dataset.i]; menu.remove(); try{ it.fn(); }catch(_){} });
const close=(e)=>{ if(!menu.contains(e.target)){ menu.remove(); document.removeEventListener('mousedown',close); } };
setTimeout(()=>document.addEventListener('mousedown',close),0);
}
// Copy a message's text to the clipboard (#4).
function copyMessageText(m){
const txt=String(m&&m.body||''); if(!txt){ toast('Nothing to copy'); return; }
const done=()=>toast('Message copied');
try{ if(navigator.clipboard&&navigator.clipboard.writeText) return void navigator.clipboard.writeText(txt).then(done).catch(()=>fallbackCopy(txt,done)); }catch(_){}
fallbackCopy(txt,done);
}
// reply + emoji state/helpers
let replyTarget=null;
// Categorized emoji set (covers the common ones used across Slack/Teams/WhatsApp).
@@ -1848,18 +1963,45 @@ function onChatEdited(d){
// attachments
let pendingAttachs=[]; // #5: multiple files can be queued + sent together (each item: {id,name,mime,uploading})
function fmtSize(b){ b=+b||0; if(b<1024) return b+' B'; if(b<1048576) return Math.round(b/1024)+' KB'; return (b/1048576).toFixed(1)+' MB'; }
async function uploadFile(file){
// #8: real upload PROGRESS. fetch() can't report upload progress at all, so a big file just said
// "uploading…" with no idea how long to wait. XHR exposes upload.onprogress, so we can show a real bar
// (and a %), making it obvious when it's safe to hit Send.
function uploadFile(file){
if(!file) return;
if(file.size>25*1024*1024){ toast('“'+file.name+'” is too large (max 25 MB)'); return; }
const ph={ id:'up-'+Math.random().toString(36).slice(2), name:file.name, mime:file.type||'', uploading:true };
const ph={ id:'up-'+Math.random().toString(36).slice(2), name:file.name, mime:file.type||'', uploading:true, pct:0, size:file.size };
pendingAttachs.push(ph); renderAttachBar();
const fail=(msg)=>{ const i=pendingAttachs.indexOf(ph); if(i>=0) pendingAttachs.splice(i,1); renderAttachBar(); toast(msg||'Upload failed'); };
try{
const r=await fetch('/api/messages/upload',{ method:'POST', headers:{ 'Content-Type':file.type||'application/octet-stream', 'X-Filename':encodeURIComponent(file.name) }, body:file });
const d=await r.json(); if(!r.ok) throw new Error(d.error||'upload failed');
const i=pendingAttachs.indexOf(ph); const item={ ...d, uploading:false }; if(i>=0) pendingAttachs[i]=item; else pendingAttachs.push(item);
const xhr=new XMLHttpRequest();
ph.xhr=xhr; // so the user can cancel an in-flight upload
xhr.open('POST','/api/messages/upload',true);
xhr.setRequestHeader('Content-Type', file.type||'application/octet-stream');
xhr.setRequestHeader('X-Filename', encodeURIComponent(file.name));
xhr.upload.onprogress=(e)=>{
if(!e.lengthComputable) return;
const pct=Math.max(1, Math.min(99, Math.round((e.loaded/e.total)*100)));
if(pct!==ph.pct){ ph.pct=pct; updateAttachProgress(ph); } // cheap in-place update, no full re-render
};
xhr.onload=()=>{
let d={}; try{ d=JSON.parse(xhr.responseText||'{}'); }catch(_){}
if(xhr.status<200||xhr.status>=300){ return fail(d.error||'Upload failed'); }
const i=pendingAttachs.indexOf(ph); const item={ ...d, uploading:false };
if(i>=0) pendingAttachs[i]=item; else pendingAttachs.push(item);
renderAttachBar();
const inp=document.getElementById('msgInput'); if(inp) inp.focus();
}catch(e){ const i=pendingAttachs.indexOf(ph); if(i>=0) pendingAttachs.splice(i,1); renderAttachBar(); toast(e.message||'Upload failed'); }
};
xhr.onerror=()=>fail('Upload failed');
xhr.onabort=()=>{ const i=pendingAttachs.indexOf(ph); if(i>=0) pendingAttachs.splice(i,1); renderAttachBar(); };
xhr.send(file);
}catch(e){ fail(e.message); }
}
// Update just the progress bar/label of one pending attachment (called on every progress tick).
function updateAttachProgress(a){
const bar=document.getElementById('attachBar'); if(!bar) return;
const el=bar.querySelector('.ap-item[data-up="'+a.id+'"]'); if(!el) return;
const fill=el.querySelector('.ap-fill'); if(fill) fill.style.width=(a.pct||0)+'%';
const pc=el.querySelector('.ap-pct'); if(pc) pc.textContent=(a.pct||0)+'%';
}
function renderAttachBar(){
const bar=document.getElementById('attachBar'); if(!bar) return;
@@ -1867,10 +2009,21 @@ function renderAttachBar(){
bar.innerHTML=pendingAttachs.map((a,idx)=>{
const isImg=/^image\//.test(a.mime||'');
const lead=(!a.uploading && isImg)?'<img class="ap-thumb" src="/files/'+pEsc(a.id)+'" alt="">':'<span class="ap-ic">'+ic(a.uploading?'paperclip':(isImg?'camera':'file'),18)+'</span>';
return '<div class="ap-item">'+lead+'<span class="ap-name">'+pEsc(a.name)+(a.uploading?' · uploading…':'')+'</span>'+(a.uploading?'':'<button type="button" class="ap-x" data-ai="'+idx+'" title="Remove">'+ic('x',15)+'</button>')+'</div>';
// #8: while uploading, show a real progress bar + % so it's clear when Send is safe.
const prog=a.uploading
? '<span class="ap-prog"><span class="ap-track"><span class="ap-fill" style="width:'+(a.pct||0)+'%"></span></span><span class="ap-pct">'+(a.pct||0)+'%</span></span>'
: '';
return '<div class="ap-item'+(a.uploading?' uploading':'')+'" data-up="'+pEsc(a.id)+'">'+lead
+'<span class="ap-main"><span class="ap-name">'+pEsc(a.name)+'</span>'+prog+'</span>'
+'<button type="button" class="ap-x" data-ai="'+idx+'" title="'+(a.uploading?'Cancel upload':'Remove')+'">'+ic('x',15)+'</button></div>';
}).join('');
bar.style.display='flex';
bar.querySelectorAll('.ap-x').forEach(b=>b.onclick=()=>{ const i=+b.dataset.ai; if(i>=0&&i<pendingAttachs.length){ pendingAttachs.splice(i,1); renderAttachBar(); } });
bar.querySelectorAll('.ap-x').forEach(b=>b.onclick=()=>{
const i=+b.dataset.ai; if(!(i>=0&&i<pendingAttachs.length)) return;
const a=pendingAttachs[i];
if(a.uploading && a.xhr){ try{ a.xhr.abort(); }catch(_){} return; } // abort in-flight (onabort cleans up)
pendingAttachs.splice(i,1); renderAttachBar();
});
}
function hideAttach(){ pendingAttachs=[]; const bar=document.getElementById('attachBar'); if(bar){ bar.style.display='none'; bar.innerHTML=''; } const fi=document.getElementById('fileInput'); if(fi) fi.value=''; }
// Paste an image from the clipboard (e.g. a screenshot) straight into the composer.
@@ -2066,7 +2219,7 @@ async function startOrJoinDmCall(otherId){
function onPresence(d){
if(!d||!d.userId) return;
const it=rowFor('dm', d.userId);
if(it){ it.online=!!d.online; it.status=d.status||'active'; renderChats(searchVal()); }
if(it){ it.online=!!d.online; it.status=d.status||'active'; if(d.lastSeen) it.lastSeen=d.lastSeen; renderChats(searchVal()); } // #2: keep last-seen fresh
if(selected && selected.kind==='dm' && selected.id===d.userId && it){
const sub=document.querySelector('#convoTitle .st'); if(sub) sub.textContent=dmSubLabel(it);
const dot=document.querySelector('.convo-head .avatar .dot'); if(dot) dot.className='dot '+statusCls(it);
@@ -2251,13 +2404,69 @@ function openLightbox(src){
+(multi?'<button class="lb-nav lb-prev" title="Previous (←)">'+ic('chevronLeft',18)+'</button>':'')
+'<img src="'+pEsc(src)+'" alt="">'
+(multi?'<button class="lb-nav lb-next" title="Next (→)">'+ic('chevronRight',18)+'</button>':'')
+'<a class="lb-dl" href="'+pEsc(src)+'" download title="Download">'+ic('download',20)+'</a>';
+'<a class="lb-dl" href="'+pEsc(src)+'" download title="Download">'+ic('download',20)+'</a>'
+'<div class="lb-zoom"><button class="lb-zout" title="Zoom out ()">'+ic('search',15)+'<b></b></button>'
+'<button class="lb-zin" title="Zoom in (+)">'+ic('search',15)+'<b>+</b></button></div>'; // #3
document.body.appendChild(ov);
const img=ov.querySelector('img'), dl=ov.querySelector('.lb-dl');
const show=(i)=>{ if(!multi) return; idx=((i%gallery.length)+gallery.length)%gallery.length; const u=gallery[idx]; img.src=u; if(dl) dl.setAttribute('href',u); };
// ---- #3: zoom & pan. Wheel / pinch to zoom, double-click or +/ to step, drag to pan when zoomed. ----
let z=1, tx=0, ty=0;
const MINZ=1, MAXZ=6;
const apply=()=>{
if(z<=1){ z=1; tx=0; ty=0; }
img.style.transform='translate('+tx+'px,'+ty+'px) scale('+z+')';
img.classList.toggle('zoomed', z>1);
ov.classList.toggle('zooming', z>1); // hide the ←/→ arrows while zoomed so panning isn't hijacked
};
const zoomAt=(factor, cx, cy)=>{
const prev=z; z=Math.min(MAXZ, Math.max(MINZ, z*factor));
if(z===prev) return;
const r=img.getBoundingClientRect();
const ox=(cx===undefined?r.left+r.width/2:cx)-(r.left+r.width/2);
const oy=(cy===undefined?r.top+r.height/2:cy)-(r.top+r.height/2);
const k=z/prev;
tx=(tx-ox)*k+ox; ty=(ty-oy)*k+oy; // keep the point under the cursor anchored
apply();
};
const resetZoom=()=>{ z=1; tx=0; ty=0; apply(); };
ov.addEventListener('wheel',(e)=>{ e.preventDefault(); zoomAt(e.deltaY<0?1.18:1/1.18, e.clientX, e.clientY); },{passive:false});
img.addEventListener('dblclick',(e)=>{ e.preventDefault(); if(z>1) resetZoom(); else zoomAt(2.5, e.clientX, e.clientY); });
// drag to pan (only meaningful when zoomed in)
let dragging=false, sx=0, sy=0, stx=0, sty=0;
img.addEventListener('mousedown',(e)=>{ if(z<=1) return; e.preventDefault(); dragging=true; sx=e.clientX; sy=e.clientY; stx=tx; sty=ty; img.classList.add('grabbing'); });
window.addEventListener('mousemove',(e)=>{ if(!dragging) return; tx=stx+(e.clientX-sx); ty=sty+(e.clientY-sy); apply(); });
window.addEventListener('mouseup',()=>{ dragging=false; img.classList.remove('grabbing'); });
// touch: pinch to zoom, one-finger drag to pan when zoomed
let pinchD=0, pz=1, ptx=0, pty=0, tsx=0, tsy=0;
const dist=(t)=>Math.hypot(t[0].clientX-t[1].clientX, t[0].clientY-t[1].clientY);
img.addEventListener('touchstart',(e)=>{
if(e.touches.length===2){ pinchD=dist(e.touches); pz=z; }
else if(e.touches.length===1 && z>1){ tsx=e.touches[0].clientX; tsy=e.touches[0].clientY; ptx=tx; pty=ty; }
},{passive:true});
img.addEventListener('touchmove',(e)=>{
if(e.touches.length===2 && pinchD){ e.preventDefault(); const d=dist(e.touches); z=Math.min(MAXZ,Math.max(MINZ, pz*(d/pinchD))); apply(); }
else if(e.touches.length===1 && z>1){ e.preventDefault(); tx=ptx+(e.touches[0].clientX-tsx); ty=pty+(e.touches[0].clientY-tsy); apply(); }
},{passive:false});
img.addEventListener('touchend',()=>{ pinchD=0; });
const show=(i)=>{ if(!multi) return; idx=((i%gallery.length)+gallery.length)%gallery.length; const u=gallery[idx]; img.src=u; if(dl) dl.setAttribute('href',u); resetZoom(); };
const close=()=>{ ov.remove(); document.removeEventListener('keydown', onKey); };
const onKey=(e)=>{ if(e.key==='Escape'){ e.preventDefault(); close(); } else if(e.key==='ArrowLeft'){ e.preventDefault(); show(idx-1); } else if(e.key==='ArrowRight'){ e.preventDefault(); show(idx+1); } };
ov.addEventListener('click',(e)=>{ if(e.target.closest('.lb-prev')){ show(idx-1); return; } if(e.target.closest('.lb-next')){ show(idx+1); return; } if(e.target===ov || e.target.closest('.lb-close')) close(); });
const onKey=(e)=>{
if(e.key==='Escape'){ e.preventDefault(); if(z>1){ resetZoom(); return; } close(); }
else if(e.key==='ArrowLeft'){ e.preventDefault(); show(idx-1); }
else if(e.key==='ArrowRight'){ e.preventDefault(); show(idx+1); }
else if(e.key==='+'||e.key==='='){ e.preventDefault(); zoomAt(1.3); }
else if(e.key==='-'||e.key==='_'){ e.preventDefault(); zoomAt(1/1.3); }
else if(e.key==='0'){ e.preventDefault(); resetZoom(); }
};
ov.addEventListener('click',(e)=>{
if(e.target.closest('.lb-zin')){ zoomAt(1.3); return; }
if(e.target.closest('.lb-zout')){ zoomAt(1/1.3); return; }
if(e.target.closest('.lb-prev')){ show(idx-1); return; }
if(e.target.closest('.lb-next')){ show(idx+1); return; }
if(e.target.closest('.lb-close')) return close();
if(e.target===ov){ if(z>1){ resetZoom(); return; } close(); } // clicking the backdrop un-zooms first
});
document.addEventListener('keydown', onKey);
}
function updateBubble(m){
@@ -2382,6 +2591,19 @@ async function openConvo(kind,id){
const rb=e.target.closest('.reply-btn'); if(rb){ const mm=THREAD.find(x=>x.id===rb.dataset.id); if(mm) setReply(mm); return; }
const ed=e.target.closest('.edit-btn'); if(ed){ const mm=THREAD.find(x=>x.id===ed.dataset.edit); if(mm) startEdit(mm); return; }
const dl=e.target.closest('.del-btn'); if(dl){ deleteMessage(dl.dataset.del); return; }
// #9: a phone number. On mobile the tel: link lets the OS ask "Call this number?" — just let it through.
// On desktop there's no dialer, so offer to copy it instead of doing nothing.
const tel=e.target.closest('.msg-tel');
if(tel){
if(isMobileUA()) return; // OS handles the call prompt
e.preventDefault();
const num=tel.dataset.tel||'';
bzConfirm(num, {title:'Phone number', okText:'Copy number'}).then(ok=>{ if(ok) fallbackCopy(num, ()=>toast('Number copied')); });
return;
}
const pf=e.target.closest('.profile-open'); if(pf){ e.stopPropagation(); openMiniProfile(pf.dataset.uid, pf); return; } // #1: sender → mini profile
const qr=e.target.closest('.qr-btn'); if(qr){ reactToMessage(qr.dataset.id, qr.dataset.emoji); return; } // #4: one-tap reaction
const mb=e.target.closest('.more-btn'); if(mb){ e.stopPropagation(); openMsgMore(mb.dataset.more, mb); return; } // #4: reply/forward/copy/delete
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; }
@@ -2497,6 +2719,15 @@ function applyFmt(kind){
function fmtInline(s){
// #1: make URLs clickable (on the already-escaped text). Trailing punctuation is left outside the link.
s=s.replace(/(https?:\/\/[^\s<]+|www\.[^\s<]+)/g,(u)=>{ let tail=''; const mt=u.match(/[.,;:!?)\]}'"]+$/); if(mt){ tail=mt[0]; u=u.slice(0,-tail.length); } const href=/^https?:\/\//i.test(u)?u:('https://'+u); return '<a href="'+href+'" target="_blank" rel="noopener noreferrer" class="msg-link">'+u+'</a>'+tail; });
// #9: phone numbers → tap to call. Deliberately conservative so we don't linkify amounts, dates, IDs or
// 6-digit meeting codes: require 1015 digits, and either a leading + / 0 or grouping separators. Skip
// anything already inside a link (e.g. a tel:/http URL we just wrapped).
s=s.replace(/(?<!["'>=\/\w])((?:\+\d{1,3}[\s-]?)?(?:\(\d{2,4}\)[\s-]?)?\d[\d\s-]{8,16}\d)(?![\w<])/g,(raw)=>{
const digits=raw.replace(/\D/g,'');
if(digits.length<10 || digits.length>15) return raw; // too short/long to be a phone
if(!/^\+|[\s-]/.test(raw.trim()) && digits.length!==10) return raw; // a bare digit run must look like a 10-digit number
return '<a href="tel:'+digits+'" class="msg-tel" data-tel="'+digits+'">'+raw+'</a>';
});
s=s.replace(/`([^`\n]+)`/g,'<code>$1</code>');
s=s.replace(/\*\*([^*\n]+)\*\*/g,'<b>$1</b>');
s=s.replace(/~~([^~\n]+)~~/g,'<s>$1</s>');
+2
View File
@@ -48,6 +48,8 @@ const users = {
setActive: (id, active) => db.prepare('UPDATE users SET active=? WHERE id=?').run(active ? 1 : 0, id),
setAvatar: (id, url) => db.prepare('UPDATE users SET avatar_url=? WHERE id=?').run(url || null, id),
setStatus: (id, status) => db.prepare('UPDATE users SET status=? WHERE id=?').run(status, id),
// #2 "last seen": stamped when a user connects and when their last socket drops.
touchSeen: (id) => db.prepare('UPDATE users SET last_seen=? WHERE id=?').run(now(), id),
remove: (id) => db.prepare('DELETE FROM users WHERE id=?').run(id),
// Fold one account (fromId) into another (intoId): reassign everything the merged-away user
// owns/authored to the survivor, then delete the empty row. Used when a person turns out to
+4 -3
View File
@@ -716,7 +716,7 @@ route('GET', '/api/messages/contacts', async (req, res) => {
if (!u) return json(res, 401, { error: 'unauthorized' });
const rows = R.users.listByTenant(u.team_id).filter((x) => x.id !== u.id && x.active !== 0);
const cAv = avatarsFor(u.team_id); // duplicate-row DP fallback
json(res, 200, rows.map((x) => ({ id: x.id, name: x.name || x.email, email: x.email, online: CHAT.isOnline(x.id), avatar: cAv[x.id] || null })));
json(res, 200, rows.map((x) => ({ id: x.id, name: x.name || x.email, email: x.email, online: CHAT.isOnline(x.id), avatar: cAv[x.id] || null, lastSeen: x.last_seen || null, status: x.status || 'active' })));
});
// Cross-tenant people search via the BizGaze directory (token stays server-side). Results are
@@ -745,7 +745,8 @@ route('GET', '/api/messages/conversations', async (req, res) => {
const names = {};
const avatars = {};
const statuses = {};
for (const x of R.users.listByTenant(u.team_id)) { names[x.id] = x.name || x.email; statuses[x.id] = x.status || 'active'; }
const seen = {};
for (const x of R.users.listByTenant(u.team_id)) { names[x.id] = x.name || x.email; statuses[x.id] = x.status || 'active'; seen[x.id] = x.last_seen || null; }
Object.assign(avatars, avatarsFor(u.team_id)); // same person / two rows → borrow the DP (see avatarsFor)
const favs = new Set(R.favorites.forUser(u.id));
const inCall = new Set();
@@ -764,7 +765,7 @@ route('GET', '/api/messages/conversations', async (req, res) => {
const dmItems = [...byOther.values()].map((c) => {
const dc = dmCalls.get(CALLS.pairKey(u.id, c.other));
return {
kind: 'dm', id: c.other, contactId: c.other, name: names[c.other] || 'Unknown', online: CHAT.isOnline(c.other), avatar: avatars[c.other] || null,
kind: 'dm', id: c.other, contactId: c.other, name: names[c.other] || 'Unknown', online: CHAT.isOnline(c.other), avatar: avatars[c.other] || null, lastSeen: seen[c.other] || null,
callActive: !!dc, callRoom: dc ? dc.room : null, favorite: favs.has('dm:' + c.other), status: inCall.has(c.other) ? 'incall' : (statuses[c.other] || 'active'),
last_body: c.last.body || (c.last.attachment_id ? '📎 Attachment' : ''), last_at: c.last.created_at, last_from_me: c.last.sender_id === u.id, unread: c.unread,
last_status: c.last.sender_id === u.id ? (c.last.read_at ? 'read' : (c.last.delivered_at ? 'delivered' : 'sent')) : null, // tick for my last message