From 49718e5d374905ee1c39a703ad2452f514c927dd Mon Sep 17 00:00:00 2001 From: sravan Date: Wed, 8 Jul 2026 15:58:09 +0530 Subject: [PATCH] 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 --- desktop/main.js | 11 +++++++++ desktop/preload.js | 4 ++++ server/public/home.html | 51 ++++++++++++++++++++++++++++++++--------- server/routes.js | 5 ++-- 4 files changed, 58 insertions(+), 13 deletions(-) diff --git a/desktop/main.js b/desktop/main.js index 14a1e0f..25eb6e7 100644 --- a/desktop/main.js +++ b/desktop/main.js @@ -46,6 +46,8 @@ ipcMain.handle('check-updates', async () => { return (v && v !== current) ? { status: 'available', version: v, current } : { status: 'current', current }; } catch (e) { return { status: 'error', message: String((e && e.message) || e), current }; } }); +// #3: restart-and-install, triggered from the web update banner's "Restart" button. +ipcMain.handle('restart-to-update', () => { try { if (autoUpdater) autoUpdater.quitAndInstall(); } catch (_) {} }); // Chat toast: sender/group avatar + message; clicking it raises the app and opens that chat. // Uses Electron's OWN Notification (native, no SnoreToast) whose 'click' event fires reliably. const APP_ID = 'com.bizgaze.connect.desktop'; @@ -236,10 +238,19 @@ app.whenReady().then(() => { app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createWindow(); }); // Check for shell updates on launch, then every 6 hours. Only in packaged builds. if (app.isPackaged && autoUpdater) { + // #3: surface update progress to the web UI so the user can SEE an update is downloading / + // installing, instead of it happening silently in the background. + const sendUpdate = (data) => { try { if (win && !win.isDestroyed()) win.webContents.send('update-event', data); } catch (_) {} }; + autoUpdater.on('checking-for-update', () => sendUpdate({ phase: 'checking' })); + autoUpdater.on('update-available', (info) => sendUpdate({ phase: 'available', version: info && info.version })); + autoUpdater.on('update-not-available', () => sendUpdate({ phase: 'current' })); + autoUpdater.on('download-progress', (p) => sendUpdate({ phase: 'downloading', percent: Math.round((p && p.percent) || 0) })); + autoUpdater.on('error', () => sendUpdate({ phase: 'error' })); // When an update finishes downloading (auto or via the Settings "Check for updates"), offer a // clear restart prompt instead of only the silent on-next-launch install. let promptedForUpdate = false; autoUpdater.on('update-downloaded', async (info) => { + sendUpdate({ phase: 'ready', version: info && info.version }); if (promptedForUpdate) return; promptedForUpdate = true; try { const { dialog } = require('electron'); diff --git a/desktop/preload.js b/desktop/preload.js index 48faa14..783ae15 100644 --- a/desktop/preload.js +++ b/desktop/preload.js @@ -25,4 +25,8 @@ contextBridge.exposeInMainWorld('bizConnectNative', Object.freeze({ // Manual "Check for updates" from Settings. Resolves {status:'available'|'current'|'dev'|'error', version?}. // On 'available' the shell downloads in the background and prompts to restart when ready. checkForUpdates: () => ipcRenderer.invoke('check-updates'), + // #3: subscribe to auto-update lifecycle events so the UI can show download progress + "ready". + // cb receives {phase:'checking'|'available'|'downloading'|'ready'|'current'|'error', percent?, version?}. + onUpdateEvent: (cb) => { try { ipcRenderer.on('update-event', (_e, data) => { try { cb(data); } catch (_) {} }); } catch (_) {} }, + restartToUpdate: () => ipcRenderer.invoke('restart-to-update'), })); diff --git a/server/public/home.html b/server/public/home.html index 0e901c2..5fea6bd 100644 --- a/server/public/home.html +++ b/server/public/home.html @@ -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 @@ - @@ -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=''+ic(rc>0?'checkCheck':'check',13)+''; } + // #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=''; } return '
' + sender + (m.fwd_from?'
'+ic('arrowRight',11)+' Forwarded'+(m.fwd_from?(' from '+pEsc(m.fwd_from)+''):'')+'
':'') + quote + att + renderMsgBody(m) + pollHTML(m) + (m.deleted?'':'
' @@ -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='
'+(avail.length?avail.map(c=>'').join(''):'
'+(inGroup&&_addPool===null?'Loading…':'Everyone\'s already here')+'
')+'
'; + 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='
'+(avail.length?avail.map(c=>'').join(''):'
'+(inGroup&&_addPool===null?'Loading…':'No one left to add')+'
')+'
'+(avail.length?'':''); } else { body='
'+list.map(pp=>'
'+pEsc(initials(pp.name))+''+pEsc(pp.name)+''+(isHostRow(pp)?''+ic('crown',11)+' Host':'')+((pp.id==='__local'?meetScreen:meetSharers.has(pp.id))?''+ic('monitor',13)+'':'')+(meetMuted.get(pp.id)?''+ic('micOff',13)+'':'')+((meetIsHost&&pp.id!=='__local'&&!isHostRow(pp))?'':'')+'
').join('')+'
' +(meetInvited.size?'
Not joined yet
'+[...meetInvited.entries()].map(([uid,e])=>'
'+pEsc(initials(e.name))+''+pEsc(e.name)+''+ic('calendarClock',12)+' waiting…
').join('')+'
':'') @@ -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=''; + +(()=>{ 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 '
'+(cand.length?cand.map(c=>'').join(''):'
No one left to add
')+'
'; })() + +'
'; 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='
No messages yet — say hello 👋
'; return; } let html=''; for(const m of THREAD){ const dk=dayKey(m.created_at); if(dk!==_lastDay){ html+='
'+pEsc(dayLabel(m.created_at))+'
'; _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', '
'+pEsc(dayLabel(m.created_at))+'
'); _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 ''+u+''+tail; }); s=s.replace(/`([^`\n]+)`/g,'$1'); s=s.replace(/\*\*([^*\n]+)\*\*/g,'$1'); s=s.replace(/~~([^~\n]+)~~/g,'$1'); @@ -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)+' Downloading update… '+(d.percent||0)+'%'; el.classList.add('show'); el.classList.remove('ready'); } + else if(d.phase==='available'){ ensure().innerHTML=ic('download',15)+' Update '+pEsc(d.version||'')+' found — downloading…'; el.classList.add('show'); } + else if(d.phase==='ready'){ ensure().innerHTML=ic('check',15)+' Update ready '; 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) diff --git a/server/routes.js b/server/routes.js index e8d43e3..a09aa24 100644 --- a/server/routes.js +++ b/server/routes.js @@ -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; })); }