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 '
'+pEsc(m.body)+'
'; const mine=m.from===ME.id; if(m.deleted) return '
'+ic('trash',12)+' This message was deleted'+pEsc(fmtClock(m.created_at))+'
'; - const sender=(convoIsGroup && !mine && m.fromName)?'
'+senderAvatar(m.from, m.fromName)+''+pEsc(m.fromName)+'
':''; + // #1: the sender chip is clickable → mini profile card (with a Message button that opens their DM). + const sender=(convoIsGroup && !mine && m.fromName)?'
'+senderAvatar(m.from, m.fromName)+''+pEsc(m.fromName)+'
':''; let quote=''; if(m.reply){ const t=replyTint(m.reply.from||m.reply.fromName); quote='
'+pEsc(m.reply.fromName||'')+': '+pEsc(m.reply.body)+'
'; } const reacts=(m.reactions&&m.reactions.length)?'
'+m.reactions.map(r=>'').join('')+'
':''; @@ -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=''; } return '
' + sender + (m.fwd_from?'
'+ic('arrowRight',11)+' Forwarded'+(m.fwd_from?(' from '+pEsc(m.fwd_from)+''):'')+'
':'') + quote + att + renderMsgBody(m) + pollHTML(m) + // #4: hover row = three one-tap reactions + the picker + (own) edit; everything else lives behind ⋮. + (m.deleted?'':'
' - + '' - + ((m.body||m.attachment)&&!m.poll?'':'') - + '' + + QUICK_REACTS.map(q=>'').join('') + + '' + ((mine && m.body && !m.poll)?'':'') - + (mine?'':'') + + '' + '
') + ''+pEsc(fmtClock(m.created_at))+(m.edited_at?' · edited':'')+rcpt+'' + reacts + seen + '
'; } +// #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='
' + +''+(row.avatar?(''):'')+pEsc(initials(name))+'' + +'
'+pEsc(name)+''+pEsc(statusLabel(row))+'
' + +'
' + +'
' + +'' + +(row.avatar?'':'') + +'
'; + 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{ 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)=>'').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.offsetHeightb.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); - 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'); } + 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(); + }; + 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)?'':''+ic(a.uploading?'paperclip':(isImg?'camera':'file'),18)+''; - return '
'+lead+''+pEsc(a.name)+(a.uploading?' · uploading…':'')+''+(a.uploading?'':'')+'
'; + // #8: while uploading, show a real progress bar + % so it's clear when Send is safe. + const prog=a.uploading + ? ''+(a.pct||0)+'%' + : ''; + return '
'+lead + +''+pEsc(a.name)+''+prog+'' + +'
'; }).join(''); bar.style.display='flex'; - bar.querySelectorAll('.ap-x').forEach(b=>b.onclick=()=>{ const i=+b.dataset.ai; if(i>=0&&ib.onclick=()=>{ + const i=+b.dataset.ai; if(!(i>=0&&i'+ic('chevronLeft',18)+'':'') +'' +(multi?'':'') - +''+ic('download',20)+''; + +''+ic('download',20)+'' + +'
' + +'
'; // #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 ''+u+''+tail; }); + // #9: phone numbers → tap to call. Deliberately conservative so we don't linkify amounts, dates, IDs or + // 6-digit meeting codes: require 10–15 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 ''+raw+''; + }); s=s.replace(/`([^`\n]+)`/g,'$1'); s=s.replace(/\*\*([^*\n]+)\*\*/g,'$1'); s=s.replace(/~~([^~\n]+)~~/g,'$1'); diff --git a/server/repos.js b/server/repos.js index e650641..28ad7a7 100644 --- a/server/repos.js +++ b/server/repos.js @@ -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 diff --git a/server/routes.js b/server/routes.js index b426311..0045629 100644 --- a/server/routes.js +++ b/server/routes.js @@ -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