diff --git a/server/chat.js b/server/chat.js
index a6dfa81..70e5999 100644
--- a/server/chat.js
+++ b/server/chat.js
@@ -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 (_) {} } }
diff --git a/server/db.js b/server/db.js
index 96a1ac5..b44229f 100644
--- a/server/db.js
+++ b/server/db.js
@@ -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 */ }
diff --git a/server/public/home.html b/server/public/home.html
index 877264a..f3f3c2a 100644
--- a/server/public/home.html
+++ b/server/public/home.html
@@ -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 @@
-
-
+
Biz Connect
Loading…
@@ -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 '
':'';
+ // #1: the sender chip is clickable → mini profile card (with a Message button that opens their DM).
+ const sender=(convoIsGroup && !mine && m.fromName)?'
';
}
+// #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='
';
+ // #8: while uploading, show a real progress bar + % so it's clear when Send is safe.
+ const prog=a.uploading
+ ? ''+(a.pct||0)+'%'
+ : '';
+ return '