feat: hyperlinks, group seen-by for all, profile-pic preview, in-call invite fix + desktop update progress (0.1.6)

- #1: URLs in chat are now clickable links (open externally on desktop).
- #2: group 'Seen by X, Y +N more' now shows under the LATEST message to EVERYONE
  (server computes reads for all messages; click shows the names).
- #5: tapping the conversation-header avatar previews the DP/group photo full-size.
- #4: in-call invite lists only people you've messaged, excludes those already in the
  call/invited, and the Invite button is sticky.
- #3 (0.1.6 shell): auto-updater emits checking/available/downloading/ready/error to
  the web UI, which shows a progress banner (+ Restart button) so updates aren't silent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-08 15:58:09 +05:30
parent 7682e17a53
commit 49718e5d37
4 changed files with 58 additions and 13 deletions
+40 -11
View File
@@ -396,6 +396,7 @@
.meet-panel .mp-tab{flex:1;border:none;background:transparent;color:var(--muted);font-size:.8rem;font-weight:600;padding:.45rem .3rem;cursor:pointer;border-bottom:2px solid transparent;display:inline-flex;align-items:center;justify-content:center;gap:.25rem;}
.meet-panel .mp-tab.on{color:var(--blue);border-bottom-color:var(--blue);}
.meet-panel .mp-scroll{flex:1;overflow:auto;}
.meet-panel .mp-invite-sticky{position:sticky;bottom:.4rem;width:calc(100% - 1rem);margin:.5rem;background:var(--blue);color:#fff;box-shadow:0 -6px 14px rgba(255,255,255,.06);} /* #4: keep Invite reachable while the list scrolls */
.meet-panel .mp-list{padding:.4rem;}
.meet-panel .chk{display:flex;align-items:center;gap:.5rem;padding:.4rem .5rem;border-radius:8px;cursor:pointer;font-size:.88rem;}
.meet-panel .chk:hover{background:#f6f8fb;}
@@ -445,6 +446,11 @@
.convo{position:relative;}
.bubble{position:relative;}
.bubble .quote{border-left:3px solid var(--line);padding:.22rem .5rem;margin-bottom:.3rem;font-size:.78rem;border-radius:6px;color:#33384a;cursor:pointer;}
.upd-banner{position:fixed;left:50%;bottom:16px;transform:translateX(-50%) translateY(70px);z-index:9500;display:none;align-items:center;gap:.5rem;background:var(--blue);color:#fff;border-radius:999px;padding:.5rem .5rem .5rem .9rem;box-shadow:0 10px 30px rgba(20,30,60,.32);font-size:.85rem;font-weight:600;transition:transform .25s ease;}
.upd-banner.show{display:flex;transform:translateX(-50%) translateY(0);}
.upd-banner button{border:none;background:var(--brand);color:var(--blue-d);border-radius:999px;padding:.35rem .8rem;font-weight:700;cursor:pointer;}
.bubble .msg-link{color:inherit;text-decoration:underline;text-underline-offset:2px;word-break:break-word;}
.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);}
.bubble.mine .fwd-label{color:#e6edfb;} .bubble.them .fwd-label{color:var(--muted);}
@@ -827,7 +833,7 @@
<body>
<script src="/icons.js?v=5"></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-08-batch58';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD);
<script>window.__BUILD='2026-07-08-batch59';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>
@@ -1483,7 +1489,9 @@ function bubbleHTML(m){
const ttl=rc?('Seen by '+ns.slice(0,6).join(', ')+(ns.length>6?(' +'+(ns.length-6)):'')+(others>0?(' · '+rc+' of '+others):'')):'Sent';
rcpt='<span class="rcpt grp '+(all?'seen':'')+'" data-seen="'+pEsc(ns.join('|'))+'" title="'+pEsc(ttl)+'">'+ic(rc>0?'checkCheck':'check',13)+'</span>';
}
// #2: under the group's LAST message, show "Seen by X, Y +N more" — visible to everyone, click for names.
let seen='';
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)
+ (m.deleted?'':'<div class="msg-actions">'
@@ -1705,7 +1713,7 @@ function syncDmRowTick(id){
if(last.from===ME.id){ const st=last.read_at?'read':(last.delivered_at?'delivered':'sent'); if(it.last_status!==st || it.last_msg_id!==last.id){ it.last_status=st; it.last_from_me=true; it.last_msg_id=last.id; renderChats(searchVal()); } }
}
// Group read: a member opened the group → add them to "Seen by" on my messages up to that time.
function onGroupRead(d){ if(!d||!d.group) return; if(selected && selected.kind==='group' && selected.id===d.group){ THREAD.forEach(m=>{ if(m.from===ME.id && m.created_at<=d.at){ m.seenBy=m.seenBy||[]; if(d.byName && !m.seenBy.includes(d.byName)){ m.seenBy.push(d.byName); updateBubble(m); } } }); refreshGroupRowTick(d.group); } }
function onGroupRead(d){ if(!d||!d.group) return; if(selected && selected.kind==='group' && selected.id===d.group){ THREAD.forEach(m=>{ if(m.created_at<=d.at && m.from!==d.by && !m.system && !m.deleted){ m.seenBy=m.seenBy||[]; if(d.byName && !m.seenBy.includes(d.byName)){ m.seenBy.push(d.byName); if(m.id===_lastGroupId||m.from===ME.id) updateBubble(m); } } }); refreshGroupRowTick(d.group); } } // #2: track everyone's reads on every message
// Live-refresh a group's sidebar tick from the open thread (read-by-all → yellow double tick).
function refreshGroupRowTick(gid){
const row=rowFor('group',gid); if(!row||!row.last_from_me||!(selected&&selected.kind==='group'&&selected.id===gid)) return;
@@ -1809,11 +1817,13 @@ function renderMeetPanel(){
// Group call → only that group's members may be added. 1:1/ad-hoc → all team contacts.
const inGroup = meetReturn && meetReturn.kind==='group';
if(inGroup && _addPool===null){ _addPool=[]; fetch('/api/groups/members?group='+encodeURIComponent(meetReturn.id)).then(r=>r.json()).then(ms=>{ _addPool=(Array.isArray(ms)?ms:[]).map(x=>({id:x.id,name:x.name})); if(document.getElementById('meetPanel')&&meetPanelTab==='add') renderMeetPanel(); }).catch(()=>{ _addPool=[]; }); }
const pool = inGroup ? (_addPool||[]) : (CONTACTS||[]);
// #4: 1:1/ad-hoc → only people you've MESSAGED (existing DMs), not the whole team.
const pool = inGroup ? (_addPool||[]) : (ROWS||[]).filter(r=>r.kind==='dm' && !r.self).map(r=>({id:r.id, name:r.name}));
const hereUids=new Set(); meetPeerUids.forEach(uid=>hereUids.add(uid)); if(ME&&ME.id) hereUids.add(ME.id);
const here=new Set(list.map(pp=>(pp.name||'').replace(/\s*\(you\)$/,'').trim().toLowerCase()));
const myName=((ME&&ME.name)||(ME&&ME.email)||'').trim().toLowerCase();
const avail=pool.filter(c=>c.id!==(ME&&ME.id) && (c.name||'').trim().toLowerCase()!==myName && !here.has((c.name||'').trim().toLowerCase()) && !meetInvited.has(c.id));
body='<div class="mp-list">'+(avail.length?avail.map(c=>'<label class="chk"><input type="checkbox" value="'+pEsc(c.id)+'"><span class="mini-av" style="background:'+avColor(c.name)+'">'+pEsc(initials(c.name))+'</span><span class="mn">'+pEsc(c.name)+'</span></label>').join(''):'<div class="gi-noresult">'+(inGroup&&_addPool===null?'Loading…':'Everyone\'s already here')+'</div>')+'</div><button class="gobtn" id="mpInvite" style="margin:.5rem">Invite to call</button>';
const avail=pool.filter(c=>c.id!==(ME&&ME.id) && !hereUids.has(c.id) && (c.name||'').trim().toLowerCase()!==myName && !here.has((c.name||'').trim().toLowerCase()) && !meetInvited.has(c.id));
body='<div class="mp-list">'+(avail.length?avail.map(c=>'<label class="chk"><input type="checkbox" value="'+pEsc(c.id)+'"><span class="mini-av" style="background:'+avColor(c.name)+'">'+pEsc(initials(c.name))+'</span><span class="mn">'+pEsc(c.name)+'</span></label>').join(''):'<div class="gi-noresult">'+(inGroup&&_addPool===null?'Loading…':'No one left to add')+'</div>')+'</div>'+(avail.length?'<button class="gobtn mp-invite-sticky" id="mpInvite">Invite to call</button>':'');
} else {
body='<div class="mp-list">'+list.map(pp=>'<div class="mp-row"><span class="mini-av" style="background:'+avColor(pp.name)+'">'+pEsc(initials(pp.name))+'</span><span class="mn">'+pEsc(pp.name)+'</span>'+(isHostRow(pp)?'<span class="host-tag">'+ic('crown',11)+' Host</span>':'')+((pp.id==='__local'?meetScreen:meetSharers.has(pp.id))?'<span class="pp-screen" title="Sharing screen">'+ic('monitor',13)+'</span>':'')+(meetMuted.get(pp.id)?'<span class="pp-mute">'+ic('micOff',13)+'</span>':'')+((meetIsHost&&pp.id!=='__local'&&!isHostRow(pp))?'<button class="mp-makehost" data-id="'+pEsc(pp.id)+'" title="Make host">'+ic('crown',14)+'</button>':'')+'</div>').join('')+'</div>'
+(meetInvited.size?'<div class="mp-sec">Not joined yet</div><div class="mp-list">'+[...meetInvited.entries()].map(([uid,e])=>'<div class="mp-row waiting"><span class="mini-av" style="background:'+avColor(e.name)+'">'+pEsc(initials(e.name))+'</span><span class="mn">'+pEsc(e.name)+'</span><span class="pp-wait">'+ic('calendarClock',12)+' waiting…</span></div>').join('')+'</div>':'')
@@ -1832,8 +1842,8 @@ function openInvitePicker(room){
if(!room || document.getElementById('invModal')) return;
const ov=document.createElement('div'); ov.className='modal-ov'; ov.id='invModal';
ov.innerHTML='<div class="modal sched"><div class="gi-head" style="margin-bottom:.6rem"><div class="avatar grp" style="width:40px;height:40px;flex:0 0 40px;background:var(--blue)">'+ic('userPlus',20)+'</div><div class="gi-name"><div class="gi-title">Add people to the call</div><div class="gi-sub">They get an incoming-call invite</div></div><button class="iconbtn" id="invClose">'+ic('x',18)+'</button></div>'
+'<div class="gi-list" style="max-height:46vh;overflow:auto">'+(CONTACTS.length?CONTACTS.map(c=>'<label class="chk"><input type="checkbox" value="'+pEsc(c.id)+'"><span class="mini-av" style="background:'+avColor(c.name)+'">'+pEsc(initials(c.name))+'</span><span class="mn">'+pEsc(c.name)+'</span></label>').join(''):'<div class="gi-noresult">No contacts available</div>')+'</div>'
+'<button class="gobtn" id="invBtn" style="width:100%;margin-top:.7rem">Invite</button></div>';
+(()=>{ const hereUids=new Set(); meetPeerUids.forEach(uid=>hereUids.add(uid)); if(ME&&ME.id) hereUids.add(ME.id); const cand=(ROWS||[]).filter(r=>r.kind==='dm' && !r.self && !hereUids.has(r.id) && !meetInvited.has(r.id)); return '<div class="gi-list" style="max-height:46vh;overflow:auto">'+(cand.length?cand.map(c=>'<label class="chk"><input type="checkbox" value="'+pEsc(c.id)+'"><span class="mini-av" style="background:'+avColor(c.name)+'">'+pEsc(initials(c.name))+'</span><span class="mn">'+pEsc(c.name)+'</span></label>').join(''):'<div class="gi-noresult">No one left to add</div>')+'</div>'; })()
+'<button class="gobtn mp-invite-sticky" id="invBtn" style="width:100%;margin-top:.7rem;position:sticky;bottom:0">Invite</button></div>';
document.body.appendChild(ov);
ov.onclick=e=>{ if(e.target===ov) ov.remove(); };
ov.querySelector('#invClose').onclick=()=>ov.remove();
@@ -1878,11 +1888,12 @@ function onMsgsScroll(){
}
function dayKey(ts){ return new Date(ts||Date.now()).toDateString(); }
function dayLabel(ts){ const d=new Date(ts||Date.now()), n=new Date(); if(d.toDateString()===n.toDateString()) return 'Today'; const y=new Date(n); y.setDate(n.getDate()-1); if(d.toDateString()===y.toDateString()) return 'Yesterday'; return d.toLocaleDateString([], {weekday:'long', month:'long', day:'numeric', year:'numeric'}); }
let _lastDay='', _lastMineId=''; // _lastMineId: only my newest message shows the group "Seen by"
let _lastDay='', _lastMineId='', _lastGroupId=''; // _lastGroupId: the group's newest message shows the shared "Seen by"
function lastMineId(){ for(let i=THREAD.length-1;i>=0;i--){ if(THREAD[i].from===ME.id && !THREAD[i].system) return THREAD[i].id; } return ''; }
function lastGroupMsgId(){ for(let i=THREAD.length-1;i>=0;i--){ const m=THREAD[i]; if(!m.system && !m.deleted) return m.id; } return ''; } // #2: last real message in the group (any sender)
function renderThread(keepScroll){
const box=document.getElementById('msgs'); if(!box) return;
rendered.clear(); _lastDay=''; _lastMineId=lastMineId();
rendered.clear(); _lastDay=''; _lastMineId=lastMineId(); _lastGroupId=lastGroupMsgId();
if(!THREAD.length){ box.innerHTML='<div class="empty-thread">No messages yet — say hello 👋</div>'; return; }
let html='';
for(const m of THREAD){ const dk=dayKey(m.created_at); if(dk!==_lastDay){ html+='<div class="day-sep"><span>'+pEsc(dayLabel(m.created_at))+'</span></div>'; _lastDay=dk; } rendered.add(m.id); html+=bubbleHTML(m); }
@@ -1897,8 +1908,8 @@ function appendBubble(m){
if(rendered.has(m.id)) return; rendered.add(m.id);
const box=document.getElementById('msgs'); if(!box) return;
const empty=box.querySelector('.empty-thread'); if(empty) empty.remove();
// A new message of mine becomes the newest: drop the "Seen by" from the previous one.
if(m.from===ME.id && !m.system){ if(_lastMineId){ const pe=box.querySelector('.bubble[data-id="'+((window.CSS&&CSS.escape)?CSS.escape(_lastMineId):_lastMineId)+'"] .seenby'); if(pe) pe.remove(); } _lastMineId=m.id; }
// A new message becomes the newest: move the group "Seen by" off the previous last message.
if(!m.system && !m.deleted){ if(_lastGroupId){ const pe=box.querySelector('.bubble[data-id="'+((window.CSS&&CSS.escape)?CSS.escape(_lastGroupId):_lastGroupId)+'"] .seenby'); if(pe) pe.remove(); } _lastGroupId=m.id; if(m.from===ME.id) _lastMineId=m.id; }
const dk=dayKey(m.created_at); if(dk!==_lastDay){ box.insertAdjacentHTML('beforeend', '<div class="day-sep"><span>'+pEsc(dayLabel(m.created_at))+'</span></div>'); _lastDay=dk; }
box.insertAdjacentHTML('beforeend', bubbleHTML(m));
twemojify(box.lastElementChild);
@@ -1937,6 +1948,8 @@ async function openConvo(kind,id){
inpEl.addEventListener('keydown', (e)=>{ if(e.key==='Enter' && !e.shiftKey){ if(mentionItems && mentionItems.length) return; e.preventDefault(); sendMessage(); } }); // Enter sends, Shift+Enter = newline
}
refreshTyping(kind,id); // if someone's already typing in this conversation, show it right away
// #5: tapping the header avatar previews the DP / group photo full-size.
const hav=document.querySelector('#chatPanel .convo-head > .avatar'); if(hav){ hav.style.cursor='pointer'; hav.title='View photo'; hav.onclick=(e)=>{ e.stopPropagation(); const r=rowFor(kind,id)||it; if(r&&r.avatar) openLightbox(r.avatar); else toast('No profile photo'); }; }
const ci=document.getElementById('convoInfo'); if(ci) ci.onclick=()=>openGroupInfo(id);
const ct=document.getElementById('convoTitle'); if(ct) ct.onclick=()=>{ if(kind==='group') openGroupInfo(id); else { const r=rowFor(kind,id); openSharedItems('dm', id, (r&&r.name)||it.name||''); } }; // name → group: info+media; DM: media/files
const cc=document.getElementById('convoCall'); if(cc) cc.onclick=()=>(kind==='group'?startOrJoinGroupCall(id):startOrJoinDmCall(id));
@@ -2070,6 +2083,8 @@ function applyFmt(kind){
}
// Inline Markdown on an already-HTML-escaped line (code/bold/strike/italic).
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; });
s=s.replace(/`([^`\n]+)`/g,'<code>$1</code>');
s=s.replace(/\*\*([^*\n]+)\*\*/g,'<b>$1</b>');
s=s.replace(/~~([^~\n]+)~~/g,'<s>$1</s>');
@@ -2334,6 +2349,19 @@ function notifAvatarDataUrl(kind,id,fallbackName){
}
// Reply to a conversation without opening it (used by the notification quick-reply).
async function sendReplyTo(kind,id,text){ try{ await postJSON('/api/messages', kind==='group'?{group:id,body:text}:{to:id,body:text}); }catch(_){} }
// #3: desktop auto-update progress — show a banner so the user knows an update is downloading/ready.
function wireUpdateBanner(){
const n=window.bizConnectNative; if(!(n&&n.onUpdateEvent)) return;
n.onUpdateEvent((d)=>{
if(!d||!d.phase) return;
let el=document.getElementById('updBanner');
const ensure=()=>{ if(!el){ el=document.createElement('div'); el.id='updBanner'; el.className='upd-banner'; document.body.appendChild(el); } return el; };
if(d.phase==='downloading'){ ensure().innerHTML=ic('download',15)+' <span>Downloading update… '+(d.percent||0)+'%</span>'; el.classList.add('show'); el.classList.remove('ready'); }
else if(d.phase==='available'){ ensure().innerHTML=ic('download',15)+' <span>Update '+pEsc(d.version||'')+' found — downloading…</span>'; el.classList.add('show'); }
else if(d.phase==='ready'){ ensure().innerHTML=ic('check',15)+' <span>Update ready</span> <button id="updRestart">Restart now</button>'; el.classList.add('show','ready'); const b=el.querySelector('#updRestart'); if(b) b.onclick=()=>{ try{ n.restartToUpdate&&n.restartToUpdate(); }catch(_){} }; }
else if(d.phase==='current'||d.phase==='error'){ if(el){ el.classList.remove('show'); setTimeout(()=>{ if(el&&!el.classList.contains('show')) el.remove(); }, 400); } }
});
}
// #13: track shown page-notifications by conversation tag so reading elsewhere can dismiss them.
const _shownNotifs={};
function onNotifClear(d){
@@ -3473,6 +3501,7 @@ async function doRegister(){
connectChatWs();
sfuInit(); // learn whether meetings use the LiveKit SFU or the P2P mesh (before any call starts)
reportInstall(); // desktop/mobile shell: record this install against the signed-in user
wireUpdateBanner(); // #3: desktop update-progress banner
setupPush(); // register the notification service worker + subscribe to Web Push (if granted)
{ const cl=document.getElementById('chatlist'); if(cl) enablePullRefresh(cl, loadSidebar); } // pull-to-refresh the chat list
setTimeout(maybeNotifPrompt, 1500); // gentle "enable notifications" prompt if still undecided (key for iOS PWA)
+3 -2
View File
@@ -774,10 +774,11 @@ route('GET', '/api/messages/thread', async (req, res) => {
try { CHAT.pushToUser(u.id, { type: 'notif-clear', kind: 'group', id: group }); } catch (_) {} // #13
}
const rxBy = groupReactions(R.reactions.forConversation(group), u.id, names);
const reads = R.conversations.memberReads(group).filter((r) => r.user_id !== u.id); // others' read times
const reads = R.conversations.memberReads(group); // ALL members' read times (#2: seen-by visible to everyone)
return json(res, 200, rows.map((m) => {
const d = buildMsgDTO(m, names, u.id); d.fromName = names[m.sender_id] || ''; d.reactions = dtoReactions(rxBy, m.id);
if (m.sender_id === u.id) d.seenBy = reads.filter((r) => r.last_read_at >= m.created_at).map((r) => names[r.user_id] || 'Someone');
// Who has read this message (excluding its sender) — shown to every member, not just the sender.
d.seenBy = reads.filter((r) => r.user_id !== m.sender_id && r.last_read_at >= m.created_at).map((r) => names[r.user_id] || 'Someone');
return d;
}));
}