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=''
+ 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='
'+ic('userPlus',20)+'
Add people to the call
They get an incoming-call invite
'
- +'
'+(CONTACTS.length?CONTACTS.map(c=>'
').join(''):'
No contacts available
')+'
'
- +'
';
+ +(()=>{ 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;
}));
}