feat(chat): rich shared-media view, status selector, drag-drop upload + fixes

Chat / shared media:
- Media/Docs/Links: clean underline tabs (green active), audio & video now
  classified as Media and rendered as tiles (download + headphone/play +
  duration) instead of broken-image glyphs; image thumbnails -> lightbox
- Drag-and-drop a file/video/image onto a conversation to send it
- Fix: removed #chatPanel{position:relative} override that collapsed the
  conversation pane (messages spilled into a clipped right-edge strip)
- "Media, links & docs" row cleaned up (no folder/placeholder icon); media
  popup keeps the back arrow, drops the redundant close button

Presence / status:
- Single current-status row with an arrow that expands Available/Away/On leave
- On leave = circle with minus, In a call = solid red indicators
- Fix: selected-status tick now follows the chosen option

Icons: added headphones + play; bumped icons.js cache-bust to v4

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
이 커밋은 다음에 포함됨:
2026-06-30 17:01:15 +05:30
부모 e9e5c7f406
커밋 06f0b08a18
12개의 변경된 파일589개의 추가작업 그리고 116개의 파일을 삭제
+15
파일 보기
@@ -213,6 +213,11 @@ try { db.exec('ALTER TABLE conversations ADD COLUMN admin_only INTEGER NOT NULL
try { db.exec('ALTER TABLE messages ADD COLUMN poll_id TEXT'); } catch (e) { /* exists */ }
// Activity/event lines (e.g. 'call-start','call-end') render as centered system messages.
try { db.exec('ALTER TABLE messages ADD COLUMN msg_type TEXT'); } catch (e) { /* exists */ }
// Deleted ("delete for everyone"): the row stays so threads/ordering hold, but body+attachment
// are cleared and clients render a "This message was deleted" placeholder.
try { db.exec('ALTER TABLE messages ADD COLUMN deleted INTEGER NOT NULL DEFAULT 0'); } catch (e) { /* exists */ }
// User-set presence status: 'active' | 'away' | 'onleave'. ('incall' is derived live, not stored.)
try { db.exec("ALTER TABLE users ADD COLUMN status TEXT NOT NULL DEFAULT 'active'"); } catch (e) { /* exists */ }
db.exec(`
CREATE TABLE IF NOT EXISTS polls (
id TEXT PRIMARY KEY,
@@ -301,4 +306,14 @@ CREATE TABLE IF NOT EXISTS push_subscriptions (
CREATE INDEX IF NOT EXISTS idx_push_user ON push_subscriptions(user_id);
`);
// Favourite conversations (per user). target = 'dm:<userId>' or 'group:<groupId>'.
db.exec(`
CREATE TABLE IF NOT EXISTS favorites (
user_id TEXT NOT NULL,
target TEXT NOT NULL,
created_at INTEGER NOT NULL,
PRIMARY KEY (user_id, target)
);
`);
module.exports = db;
+3 -3
파일 보기
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>BizGaze Support — Agent Console</title>
<title>Biz Connect — Agent Console</title>
<style>
:root{ --brand:#FFC708; --brand-d:#E0AC00; --blue:#1F3B73; --blue-d:#16294f; --ink:#1f2430; --muted:#6b7280; --bg:#f6f8fb; --card:#fff; --line:#e6e9ef; }
*{box-sizing:border-box;}
@@ -63,7 +63,7 @@
<script>if(new URLSearchParams(location.search).get('embed')==='1')document.documentElement.classList.add('embed');</script>
<a href="/home" id="homeLink"><span data-ic="arrowLeft" data-sz="16"></span> Home</a>
<div class="topbar" id="topbar">
<div class="brandrow"><img src="/logo.png" alt="" style="height:46px;width:auto;max-width:190px;border-radius:8px;object-fit:contain;background:#fff;padding:5px 12px;image-rendering:-webkit-optimize-contrast" onerror="this.replaceWith(Object.assign(document.createElement('div'),{className:'logo',textContent:'B'}))"><div class="brand">BizGaze <span>Support</span></div></div>
<div class="brandrow"><img src="/logo.png" alt="" style="height:46px;width:auto;max-width:190px;border-radius:8px;object-fit:contain;background:#fff;padding:5px 12px;image-rendering:-webkit-optimize-contrast" onerror="this.replaceWith(Object.assign(document.createElement('div'),{className:'logo',textContent:'B'}))"><div class="brand">Biz <span>Connect</span></div></div>
<div class="agentchip" id="agentChip"></div>
</div>
<div class="topbar2" id="bar"><span id="barStatus"></span><button id="endBtn">End session</button></div>
@@ -254,7 +254,7 @@ function stopTranscription(){ recogActive=false; if(recog){ try{recog.stop();}ca
function buildTranscriptText(){
const lines=transcriptLines.slice().sort((a,b)=>a.t-b.t);
const pad=(n)=>String(n).padStart(2,'0');
const head='BizGaze Support — Session transcript\nSession: '+sessionId+'\nGenerated: '+new Date().toLocaleString()+'\n'+('-'.repeat(48))+'\n';
const head='Biz Connect — Session transcript\nSession: '+sessionId+'\nGenerated: '+new Date().toLocaleString()+'\n'+('-'.repeat(48))+'\n';
const body=lines.map(l=>{ const d=new Date(l.t); const ts='['+pad(d.getHours())+':'+pad(d.getMinutes())+':'+pad(d.getSeconds())+']'; const who=(l.role==='agent'?'Agent':'Customer')+(l.name?' ('+l.name+')':'')+(l.chat?' [chat]':''); return ts+' '+who+': '+l.text; }).join('\n');
return head+(body||'(no speech captured)')+'\n';
}
+11 -8
파일 보기
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>BizGaze Connect — Dashboard</title>
<title>Biz Connect — Dashboard</title>
<style>
:root{ --brand:#FFC708; --brand-d:#E0AC00; --blue:#1F3B73; --blue-d:#16294f; --blue-soft:#EAF0FB; --ink:#1f2430; --muted:#6b7280; --bg:#f6f8fb; --card:#fff; --line:#e6e9ef; --green:#16a34a; --red:#b91c1c; }
*{box-sizing:border-box;}
@@ -60,6 +60,10 @@
.profile{position:relative}
.profile .pbtn{display:flex;align-items:center;gap:.5rem;background:rgba(255,255,255,.14);color:#fff;border:1px solid #46598c;border-radius:10px;padding:.4rem .85rem .4rem .5rem;font-weight:600;font-size:.88rem;cursor:pointer}
.profile .pbtn:hover{background:rgba(255,255,255,.24)}
.profile .pbtn.icon-only{padding:.25rem;gap:0;border-radius:50%;background:transparent;border:none}
/* #12: report table scrolls horizontally on small screens instead of overflowing. */
.table-scroll{overflow-x:auto;-webkit-overflow-scrolling:touch;max-width:100%;}
.table-scroll table{min-width:560px;}
.profile .pbtn .pav{width:28px;height:28px;border-radius:50%;background:var(--brand);color:var(--blue);display:grid;place-items:center;font-weight:800;font-size:.78rem}
.profile .pmenu{position:absolute;right:0;top:calc(100% + 6px);background:#fff;border:1px solid #e6e9ef;border-radius:10px;box-shadow:0 10px 28px rgba(0,0,0,.18);min-width:210px;overflow:hidden;z-index:5000;display:none}
.profile .pmenu.open{display:block}
@@ -75,7 +79,7 @@
</head>
<body>
<header>
<div class="brandrow"><img src="/logo.png" alt="" style="height:46px;width:auto;max-width:190px;border-radius:8px;object-fit:contain;background:#fff;padding:5px 12px;image-rendering:-webkit-optimize-contrast" onerror="this.replaceWith(Object.assign(document.createElement('div'),{className:'logo',textContent:'B'}))"><div class="brand">BizGaze <span class="y">Connect</span> <span class="tag">· Dashboard</span></div></div>
<div class="brandrow"><img src="/logo.png" alt="" style="height:46px;width:auto;max-width:190px;border-radius:8px;object-fit:contain;background:#fff;padding:5px 12px;image-rendering:-webkit-optimize-contrast" onerror="this.replaceWith(Object.assign(document.createElement('div'),{className:'logo',textContent:'B'}))"><div class="brand">Biz <span class="y">Connect</span> <span class="tag">· Dashboard</span></div></div>
<div class="row" id="hdrRight"></div>
</header>
<main id="app"></main>
@@ -89,9 +93,8 @@ function pEsc(s){return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&amp;',
function initials(name){const p=String(name||'?').trim().split(/\s+/);return ((p[0]||'?')[0]+(p[1]?p[1][0]:'')).toUpperCase();}
function profileHTML(u){
const display=u.name||u.email;
return '<div class="profile"><button class="pbtn" id="pbtn">'
+ '<span class="pav">'+pEsc(initials(display))+'</span>'
+ pEsc(display)+' <span style="font-size:.65rem">&#9662;</span></button>'
return '<div class="profile"><button class="pbtn icon-only" id="pbtn" title="'+pEsc(display)+'">'
+ '<span class="pav">'+pEsc(initials(display))+'</span></button>'
+ '<div class="pmenu" id="pmenu">'
+ '<div class="phead"><div class="n">'+pEsc(display)+'</div><div class="e">'+pEsc(u.email)+(u.role?' · '+pEsc(u.role):'')+'</div></div>'
+ '<a href="/home">Home</a>'
@@ -135,7 +138,7 @@ async function authView() {
</div>
${regOpen ? `<div id="regForm" class="hidden">
<span class="lbl">Team name</span>
<input id="rg_team" placeholder="e.g. BizGaze Support">
<input id="rg_team" placeholder="e.g. Acme Inc">
<span class="lbl">Email</span>
<input id="rg_email" placeholder="you@bizgaze.com" type="email">
<span class="lbl">Password</span>
@@ -199,7 +202,7 @@ async function dashboard(me) {
<button id="fPdf" class="mini" style="padding:.6rem .9rem">${ic('download',15)} PDF</button>
</div>
${IS_ADMIN ? '<input id="repSearch" class="srch" placeholder="Search by agent or ticket">' : ''}
<table id="report"><thead><tr><th>Date</th><th>Start time</th>${IS_ADMIN ? '<th>Agent</th>' : ''}<th>Ticket</th><th>Time spent</th><th>Recording / Transcript</th></tr></thead><tbody></tbody></table>
<div class="table-scroll"><table id="report"><thead><tr><th>Date</th><th>Start time</th>${IS_ADMIN ? '<th>Agent</th>' : ''}<th>Ticket</th><th>Time spent</th><th>Recording / Transcript</th></tr></thead><tbody></tbody></table></div>
<div id="repPager" class="pager"></div>
<p id="repSummary" class="muted" style="margin-top:.6rem"></p>
</div>
@@ -421,7 +424,7 @@ function exportPdf() {
'th{background:#1F3B73;color:#fff;text-align:left;padding:6px 8px}' +
'td{padding:6px 8px;border-bottom:1px solid #e6e9ef}' +
'</style></head><body>' +
'<h1>BizGaze Connect — Connection report</h1>' +
'<h1>Biz Connect — Connection report</h1>' +
'<div class="meta">' + esc(IS_ADMIN ? 'Agent: ' + agentSel : 'Agent: ' + agentSel) + ' · Period: ' + esc(period) + ' · Generated ' + new Date().toLocaleString() + '</div>' +
'<table><tr>' + headCells.map(h => '<th>' + esc(h) + '</th>').join('') + '</tr>' +
rows.map(r => '<tr><td>' + [r.date, r.start].concat(IS_ADMIN ? [esc(r.agent)] : []).concat([esc(r.ticket), r.spent]).join('</td><td>') + '</td></tr>').join('') +
+456 -76
파일 보기
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>BizGaze Connect</title>
<title>Biz Connect</title>
<!-- PWA: installable on Android & iOS ("Add to Home Screen"); also enables iOS web push -->
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#1F3B73">
@@ -19,7 +19,8 @@
body{font-family:'Segoe UI',system-ui,sans-serif;background:var(--bg);color:var(--ink);margin:0;display:flex;flex-direction:column;height:100vh;height:100dvh;overflow:hidden;}
/* ---- Top bar ---- */
header{background:var(--blue);padding:.7rem 1.4rem;display:flex;justify-content:space-between;align-items:center;flex:0 0 auto;position:relative;z-index:1300;}
/* #4: the top blue bar is removed everywhere — bell+profile live in the chat-list header. */
header{display:none;}
.brandrow{display:flex;align-items:center;gap:.6rem;}
.logo{width:30px;height:30px;border-radius:8px;background:var(--brand);display:grid;place-items:center;font-weight:800;color:var(--blue);}
.brand{font-weight:700;color:#fff;font-size:1.05rem;} .brand span.y{color:var(--brand);font-weight:700;}
@@ -27,10 +28,15 @@
/* ---- Header actions: notification bell + profile ---- */
#hdrRight{display:flex;align-items:center;gap:.45rem;}
.bell{position:relative;}
.bellbtn{position:relative;background:rgba(255,255,255,.14);border:1px solid #46598c;color:#fff;cursor:pointer;width:40px;height:40px;border-radius:10px;display:grid;place-items:center;}
.bellbtn{position:relative;background:var(--blue-soft);border:1px solid var(--line);color:var(--blue);cursor:pointer;width:38px;height:38px;border-radius:10px;display:grid;place-items:center;}
.bellbtn:hover{background:#dbe6fb;}
.bellbtn:hover{background:rgba(255,255,255,.24);}
.bell-dot{position:absolute;top:4px;right:4px;min-width:16px;height:16px;border-radius:99px;background:var(--brand);color:var(--blue);font-size:.6rem;font-weight:800;display:grid;place-items:center;padding:0 .2rem;border:2px solid var(--blue);}
.bell-dot{position:absolute;top:2px;right:2px;min-width:16px;height:16px;border-radius:99px;background:var(--red);color:#fff;font-size:.6rem;font-weight:800;display:grid;place-items:center;padding:0 .2rem;border:2px solid var(--card);}
.bell-menu{position:absolute;right:0;top:calc(100% + 6px);width:340px;max-width:92vw;background:#fff;border:1px solid #e6e9ef;border-radius:12px;box-shadow:0 12px 30px rgba(0,0,0,.18);z-index:5000;display:none;overflow:hidden;}
/* When bell+profile live in the left rail (desktop), open their menus to the RIGHT of the rail,
bottom-aligned, so they aren't clipped off-screen. */
#rail .bell-menu{top:auto;bottom:0;right:auto;left:calc(100% + 10px);}
#rail .profile .pmenu{top:auto;bottom:0;right:auto;left:calc(100% + 10px);}
.bell-menu.open{display:block;}
.bell-head{display:flex;align-items:center;justify-content:space-between;padding:.6rem .85rem;border-bottom:1px solid #eef1f6;font-weight:700;font-size:.9rem;color:var(--ink);}
.bell-head button{background:none;border:none;color:var(--blue);font-size:.78rem;cursor:pointer;font-weight:600;}
@@ -45,7 +51,17 @@
.bell-empty{padding:1.6rem;text-align:center;color:var(--muted);font-size:.85rem;}
/* ---- Profile dropdown (from console.html) ---- */
.profile{position:relative}
.profile .pbtn.icon-only{padding:.3rem;gap:0;border-radius:50%;}
.profile .pbtn.icon-only{padding:0;gap:0;border-radius:50%;background:transparent;border:none;position:relative;}
.pav-dot{position:absolute;right:-1px;bottom:-1px;width:11px;height:11px;border-radius:50%;border:2px solid var(--card);background:#cbd2dd;}
.pav-dot.active{background:#16a34a;} .pav-dot.away{background:#f59e0b;} .pav-dot.onleave{background:#ef4444;} .pav-dot.incall{background:#2563eb;} .pav-dot.offline{background:#cbd2dd;}
.pmenu .ps-current{display:flex;align-items:center;gap:.45rem;}
.pmenu .ps-current .ps-arrow{margin-left:auto;color:var(--muted);display:inline-flex;}
.pmenu .ps-options{background:#f8fafc;border-top:1px solid #eef1f6;border-bottom:1px solid #eef1f6;}
.pmenu .ps-opt{display:flex;align-items:center;gap:.45rem;padding-left:1.3rem;}
.pmenu .ps-opt.on{font-weight:700;}
.pmenu .ps-opt .ps-check{margin-left:auto;display:none;color:#16a34a;align-items:center;}
.pmenu .ps-opt.on .ps-check{display:inline-flex;}
.pmenu .ps-div{height:1px;background:#eef1f6;margin:.3rem 0;}
.profile .pbtn{display:flex;align-items:center;gap:.5rem;background:rgba(255,255,255,.14);color:#fff;border:1px solid #46598c;border-radius:10px;padding:.4rem .85rem .4rem .5rem;font-weight:600;font-size:.88rem;cursor:pointer}
.profile .pbtn:hover{background:rgba(255,255,255,.24)}
.profile .pbtn .pav{position:relative;width:28px;height:28px;border-radius:50%;background:var(--brand);color:var(--blue);display:grid;place-items:center;font-weight:800;font-size:.78rem;overflow:hidden}
@@ -66,6 +82,9 @@
/* ---- Icon rail ---- */
.rail{width:74px;flex:0 0 74px;background:var(--card);border-right:1px solid var(--line);display:flex;flex-direction:column;align-items:center;padding:.8rem 0;gap:.4rem;}
/* Bell + profile relocated to the bottom of the rail on desktop. */
#rail #hdrRight{display:flex;flex-direction:column;align-items:center;gap:.55rem;margin-top:auto;padding:.5rem 0 .2rem;}
#rail #hdrRight .pav{width:34px;height:34px;font-size:.85rem;}
.railbtn{position:relative;width:50px;height:50px;border:none;background:transparent;border-radius:14px;color:var(--muted);cursor:pointer;display:grid;place-items:center;transition:background .12s,color .12s;}
.railbtn:hover{background:var(--blue-soft);color:var(--blue);}
.railbtn.active{background:var(--blue);color:#fff;}
@@ -93,11 +112,15 @@
.dir-row .dr-main{display:flex;flex-direction:column;min-width:0;flex:1;}
.dir-row .dr-sub{font-size:.74rem;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.dir-row .dr-tag{font-size:.62rem;font-weight:700;color:#92600b;background:#fff3cd;border-radius:99px;padding:.1rem .4rem;flex:0 0 auto;}
.side-head{padding:1rem 1rem .75rem;border-bottom:1px solid var(--line);}
.side-title{display:flex;align-items:center;justify-content:space-between;margin-bottom:.7rem;}
.side-title h2{font-size:.95rem;margin:0;color:var(--blue);}
.newchat{width:30px;height:30px;border-radius:9px;border:none;background:var(--blue-soft);color:var(--blue);font-size:1.2rem;line-height:1;cursor:pointer;font-weight:700;display:grid;place-items:center;padding:0;}
.newchat:hover{background:#dbe6fb;}
/* Chat-list header (now the app's top area — the blue bar is gone). */
.side-head{padding:.85rem 1rem .75rem;border-bottom:1px solid var(--line);background:linear-gradient(180deg,#f3f7fd 0%,var(--card) 100%);}
.side-title{display:flex;align-items:center;justify-content:space-between;gap:.5rem;margin-bottom:.7rem;}
.side-title h2{font-size:1.15rem;margin:0;color:var(--blue);font-weight:800;letter-spacing:-.01em;flex:1;}
.side-title #hdrRight{display:flex;align-items:center;gap:.45rem;flex:0 0 auto;}
.side-search-row{display:flex;align-items:center;gap:.5rem;}
.side-search-row .search{flex:1;min-width:0;}
.newchat{width:38px;height:38px;flex:0 0 auto;border-radius:10px;border:none;background:var(--blue);color:#fff;font-size:1.35rem;line-height:1;cursor:pointer;font-weight:700;display:grid;place-items:center;padding:0;box-shadow:0 2px 8px rgba(31,59,115,.25);}
.newchat:hover{filter:brightness(1.08);}
.search{position:relative;}
.search > svg{position:absolute;left:.65rem;top:50%;transform:translateY(-50%);color:var(--muted);}
.search input{width:100%;padding:.55rem .7rem .55rem 2.1rem;border-radius:10px;border:2px solid var(--line);background:#fbfcfe;color:var(--ink);font-size:.9rem;}
@@ -106,15 +129,41 @@
.search-x{position:absolute;right:.5rem;top:50%;transform:translateY(-50%);border:none;background:transparent;color:var(--muted);cursor:pointer;padding:.2rem;border-radius:6px;display:grid;place-items:center;}
.search-x:hover{color:var(--blue);background:var(--blue-soft);}
.chatlist{overflow-y:auto;flex:1 1 auto;padding:.4rem;}
.chatlist{overflow-y:auto;flex:1 1 auto;padding:.4rem;scrollbar-width:thin;scrollbar-color:transparent transparent;}
.chatlist:hover{scrollbar-color:#c7d0dd transparent;}
.chatlist::-webkit-scrollbar{width:7px;}
.chatlist::-webkit-scrollbar-button{display:none;height:0;width:0;}
.chatlist::-webkit-scrollbar-thumb{background:transparent;border-radius:8px;}
.chatlist:hover::-webkit-scrollbar-thumb{background:#c7d0dd;}
/* Pull-to-refresh indicator (mobile) */
.ptr-ind{position:absolute;top:6px;left:50%;width:32px;height:32px;margin-left:-16px;border-radius:50%;background:var(--card);box-shadow:0 2px 10px rgba(20,30,60,.18);display:grid;place-items:center;color:var(--blue);z-index:40;opacity:0;transform:translateY(-48px);transition:opacity .12s;pointer-events:none;}
.ptr-ind .ptr-g{font-size:1.15rem;font-weight:700;line-height:1;display:inline-block;}
.ptr-ind.spin .ptr-g{animation:ptrspin .7s linear infinite;}
@keyframes ptrspin{to{transform:rotate(360deg);}}
/* Enable-notifications prompt (esp. iOS PWA) */
.notif-prompt{position:fixed;left:50%;transform:translateX(-50%);bottom:calc(74px + env(safe-area-inset-bottom,0));z-index:2000;display:flex;align-items:center;gap:.7rem;width:max-content;max-width:min(460px,94vw);background:var(--blue);color:#fff;padding:.6rem .7rem .6rem 1rem;border-radius:12px;box-shadow:0 10px 30px rgba(20,30,60,.32);font-size:.86rem;}
.notif-prompt .np-actions{display:flex;align-items:center;gap:.4rem;margin-left:auto;flex:0 0 auto;}
.notif-prompt .btn.sm{background:#fff;color:var(--blue);}
.notif-prompt .np-x{background:transparent;border:none;color:#fff;opacity:.85;cursor:pointer;display:grid;place-items:center;padding:.2rem;}
@media(min-width:761px){ .notif-prompt{bottom:18px;} }
.perm-state{font-size:.68rem;font-weight:700;padding:.06rem .45rem;border-radius:99px;vertical-align:middle;}
.perm-state.granted{background:#dcfce7;color:#15803d;}
.perm-state.denied{background:#fee2e2;color:#b91c1c;}
.perm-state.default,.perm-state.unsupported{background:#f1f5f9;color:#475569;}
.chat-row{display:flex;gap:.7rem;align-items:center;padding:.6rem .65rem;border-radius:12px;cursor:pointer;position:relative;}
.chat-row:hover{background:#f3f6fb;}
.chat-row.active{background:var(--blue-soft);box-shadow:inset 3px 0 0 var(--brand);}
.chat-row.active::before{content:"";position:absolute;left:0;top:.7rem;bottom:.7rem;width:3px;border-radius:3px;background:var(--blue);}
.avatar{width:42px;height:42px;flex:0 0 42px;border-radius:50%;display:grid;place-items:center;color:#fff;font-weight:700;font-size:.92rem;position:relative;}
.avatar{width:42px;height:42px;flex:0 0 42px;border-radius:50%;display:grid;place-items:center;color:#334155;font-weight:700;font-size:.92rem;position:relative;}
.avatar .av-img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;border-radius:inherit;}
.avatar .dot{position:absolute;right:-1px;bottom:-1px;width:11px;height:11px;border-radius:50%;border:2px solid #fff;background:#cbd2dd;z-index:1;}
.avatar .dot.on{background:var(--green);}
/* presence/status dot colors (#7): in-call = solid red; on-leave = circle with a minus. */
.dot.active{background:#16a34a;} .dot.away{background:#f59e0b;} .dot.onleave{background:#dc2626;} .dot.incall{background:#dc2626;} .dot.offline{background:#cbd2dd;}
.st-dot{position:relative;display:inline-block;width:10px;height:10px;border-radius:50%;margin-right:.4rem;vertical-align:middle;background:#cbd2dd;}
.st-dot.active{background:#16a34a;} .st-dot.away{background:#f59e0b;} .st-dot.onleave{background:#dc2626;} .st-dot.incall{background:#dc2626;} .st-dot.offline{background:#cbd2dd;}
/* the "minus in circle" for On leave (on every size of dot) */
.dot.onleave::after,.st-dot.onleave::after,.pav-dot.onleave::after{content:"";position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);width:6px;height:1.6px;background:#fff;border-radius:1px;}
.chat-main{flex:1 1 auto;min-width:0;}
.chat-top{display:flex;justify-content:space-between;align-items:baseline;gap:.5rem;}
.chat-name{font-weight:400;font-size:.92rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
@@ -158,12 +207,59 @@
/* conversation placeholder (selected chat, no backend yet) */
.convo{flex-direction:column;display:flex;width:100%;height:100%;}
.convo-head{display:flex;align-items:center;gap:.7rem;padding:.9rem 1.2rem;border-bottom:1px solid var(--line);background:var(--card);}
.convo-head{display:flex;align-items:center;gap:.7rem;padding:.9rem 1.2rem;border-bottom:1px solid var(--line);background:var(--card);position:relative;}
/* NB: #chatPanel is .panel which is already position:absolute (a containing block),
so the drop overlay positions against it. Do NOT add position:relative here — an ID
selector would override .panel{position:absolute;inset:0} and collapse the pane. */
#chatPanel.drag-over::after{content:"Drop to send";position:absolute;inset:10px;border:2.5px dashed var(--blue);border-radius:14px;background:rgba(31,59,115,.07);display:grid;place-items:center;color:var(--blue);font-weight:700;font-size:1.15rem;z-index:60;pointer-events:none;}
.ic{display:inline-block;vertical-align:middle;}
.convo-back{border:none;background:var(--blue-soft);color:var(--blue);width:34px;height:34px;border-radius:9px;cursor:pointer;display:grid;place-items:center;flex:0 0 auto;}
.convo-back:hover{background:#dbe6fb;}
.convo-head .nm{font-weight:700;color:var(--ink);}
.convo-head .st{font-size:.78rem;color:var(--muted);}
/* In-chat search: the header turns into a search bar with count + up/down jump arrows. */
.convo-search-head{position:absolute;inset:0;display:flex;align-items:center;gap:.4rem;padding:0 .8rem;background:var(--card);z-index:6;}
.convo-search-head input{flex:1;min-width:0;border:none;background:transparent;font-size:.95rem;color:var(--ink);}
.convo-search-head input:focus{outline:none;}
.csh-count{font-size:.8rem;color:var(--muted);white-space:nowrap;flex:0 0 auto;min-width:2.5rem;text-align:right;}
.csh-nav{border:none;background:var(--blue-soft);color:var(--blue);width:30px;height:30px;border-radius:8px;cursor:pointer;display:grid;place-items:center;flex:0 0 auto;}
.csh-nav:hover{background:#dbe6fb;}
mark.search-hit{background:#fde68a;color:inherit;border-radius:2px;padding:0 1px;}
mark.search-current{background:#f59e0b;color:#1f2430;}
.gi-media-row{display:flex;align-items:center;justify-content:space-between;width:100%;background:#f6f8fb;border:1px solid var(--line);border-radius:10px;padding:.65rem .8rem;cursor:pointer;color:var(--ink);font-size:.92rem;font-weight:600;}
.gi-media-row:hover{background:#eef2f8;}
.gi-media-row .gmr-l{display:flex;align-items:center;gap:.6rem;}
.gi-media-row .gmr-ic{width:32px;height:32px;flex:0 0 auto;border-radius:9px;background:linear-gradient(135deg,#2563eb,#1F3B73);color:#fff;display:grid;place-items:center;box-shadow:0 2px 6px rgba(31,59,115,.25);}
.gi-media-row .gmr-ic svg{width:17px;height:17px;}
.gi-media-row .gmr-r{color:var(--muted);font-size:.85rem;font-weight:600;}
.gi-media-strip{display:flex;gap:.3rem;margin:.4rem 0 .2rem;overflow:hidden;}
.gms-thumb{width:60px;height:60px;object-fit:cover;border-radius:8px;background:#f1f5f9;cursor:pointer;flex:0 0 auto;}
/* Media / Docs / Links — clean underline tabs (active = green underline, no chip borders) */
.mv-tabs{display:flex;gap:0;border-bottom:1px solid var(--line);margin:.5rem 0 0;}
.mv-tabs .mp-tab{flex:1;border:none;background:transparent;color:var(--muted);font-size:.95rem;font-weight:600;padding:.6rem .3rem .65rem;cursor:pointer;border-bottom:2px solid transparent;margin-bottom:-1px;text-align:center;}
.mv-tabs .mp-tab:hover{color:var(--ink);}
.mv-tabs .mp-tab.on{color:var(--ink);font-weight:700;border-bottom-color:#16a34a;}
.mv-body{max-height:58vh;overflow:auto;margin-top:.5rem;}
/* Shared-media tiles: image thumbnail, or audio/video tile with download + duration */
.sh-cell{margin:0;display:flex;flex-direction:column;gap:.28rem;min-width:0;}
.sh-name{font-size:.7rem;font-weight:700;color:var(--ink);text-transform:uppercase;letter-spacing:.02em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.sh-av{position:relative;display:block;width:100%;aspect-ratio:1;border-radius:8px;overflow:hidden;text-decoration:none;background:#eef2f8;cursor:pointer;}
.sh-av.aud{background:linear-gradient(135deg,#f7b733,#ee8c1a);}
.sh-av.vid{background:#0b1220;}
.sh-poster{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;pointer-events:none;}
.sh-dl{position:absolute;left:50%;top:46%;transform:translate(-50%,-50%);width:46px;height:46px;border-radius:50%;background:rgba(20,30,60,.28);display:grid;place-items:center;color:#fff;transition:background .12s;}
.sh-av:hover .sh-dl{background:rgba(20,30,60,.45);}
.sh-dur{position:absolute;left:0;right:0;bottom:0;display:flex;align-items:center;gap:.28rem;padding:.28rem .42rem;background:linear-gradient(transparent,rgba(0,0,0,.5));color:#fff;font-size:.74rem;font-weight:600;}
.sh-dur .sh-di{display:inline-flex;} .sh-dur svg{width:13px;height:13px;}
.sh-dur i{font-style:normal;}
.fav-on svg{fill:#f59e0b;color:#f59e0b;}
.sh-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(92px,1fr));gap:.4rem;}
.sh-thumb{width:100%;aspect-ratio:1;object-fit:cover;border-radius:8px;cursor:pointer;background:#f1f5f9;}
.sh-file{display:flex;align-items:center;gap:.55rem;padding:.6rem .55rem;border-bottom:1px solid var(--line);color:var(--ink);text-decoration:none;}
.sh-file:hover{background:#f6f8fb;}
.sh-file .sh-fn{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-size:.9rem;}
.sh-file .sh-sz{font-size:.74rem;color:var(--muted);flex:0 0 auto;}
.sh-file svg{color:var(--blue);flex:0 0 auto;}
.convo-body{flex:1;display:grid;place-items:center;text-align:center;color:var(--muted);padding:2rem;}
.convo-body .big{font-size:2.4rem;margin-bottom:.4rem;}
/* message thread */
@@ -173,6 +269,8 @@
.bubble.mine{align-self:flex-end;background:var(--blue);color:#fff;border-bottom-right-radius:4px;}
.bubble .t{display:block;font-size:.64rem;opacity:.65;margin-top:.15rem;text-align:right;}
.day-sep{align-self:center;font-size:.72rem;margin:.7rem 0 .3rem;text-align:center;}
.new-sep{display:flex;align-items:center;gap:.6rem;margin:.6rem .2rem;color:var(--red);font-size:.72rem;font-weight:700;}
.new-sep::before,.new-sep::after{content:"";flex:1;height:1px;background:#f0c2c2;}
.day-sep span{background:#fde7b0;color:#7a5b05;padding:.22rem .8rem;border-radius:99px;font-weight:600;box-shadow:0 1px 3px rgba(20,30,60,.1);}
.float-date{position:absolute;top:.6rem;left:50%;transform:translateX(-50%);z-index:5;background:#fde7b0;color:#7a5b05;padding:.22rem .85rem;border-radius:99px;font-weight:600;font-size:.72rem;box-shadow:0 3px 10px rgba(20,30,60,.18);pointer-events:none;}
.jump-latest{position:absolute;right:16px;bottom:86px;z-index:5;width:42px;height:42px;border-radius:50%;border:1px solid var(--line);background:var(--card);color:var(--blue);box-shadow:0 4px 14px rgba(20,30,60,.22);cursor:pointer;display:grid;place-items:center;}
@@ -250,7 +348,8 @@
.meet-panel .mp-setting{margin:.3rem;font-size:.8rem;}
.meet-tile video{width:100%;height:100%;object-fit:cover;background:#0b1220;}
.meet-tile .nm{position:absolute;left:.5rem;bottom:.5rem;background:rgba(0,0,0,.55);color:#fff;font-size:.75rem;padding:.15rem .5rem;border-radius:6px;}
.meet-tile .meet-av{position:absolute;inset:0;margin:auto;width:84px;height:84px;border-radius:50%;display:none;align-items:center;justify-content:center;color:#fff;font-weight:700;font-size:1.9rem;}
.meet-tile .meet-av{position:absolute;inset:0;margin:auto;width:84px;height:84px;border-radius:50%;display:none;align-items:center;justify-content:center;color:#334155;font-weight:700;font-size:1.9rem;overflow:hidden;}
.meet-tile .meet-av img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;}
.meet-tile .meet-mute{position:absolute;left:.5rem;top:.5rem;width:26px;height:26px;border-radius:50%;background:#dc2626;color:#fff;place-items:center;box-shadow:0 1px 3px rgba(0,0,0,.4);}
.meet-panel{position:absolute;right:12px;top:12px;bottom:78px;width:280px;max-width:80vw;background:var(--card);border:1px solid var(--line);border-radius:14px;box-shadow:0 14px 40px rgba(0,0,0,.3);z-index:30;display:flex;flex-direction:column;overflow:hidden;}
.meet-panel .mp-head{display:flex;align-items:center;gap:.5rem;padding:.7rem .8rem;border-bottom:1px solid var(--line);font-size:.9rem;}
@@ -259,7 +358,8 @@
.meet-panel .mp-tabs{display:flex;gap:.25rem;padding:.4rem .5rem 0;border-bottom:1px solid var(--line);}
.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-list{flex:1;overflow:auto;padding:.4rem;}
.meet-panel .mp-scroll{flex:1;overflow:auto;}
.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;}
.meet-panel .chk input{width:16px;height:16px;flex:0 0 16px;accent-color:var(--blue);margin:0;}
@@ -267,6 +367,9 @@
.meet-panel .mp-row{display:flex;align-items:center;gap:.5rem;padding:.4rem .5rem;border-radius:8px;}
.meet-panel .mp-row .mn{flex:1;min-width:0;font-size:.88rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.meet-panel .mp-row .pp-mute{color:var(--red);display:inline-flex;}
.meet-panel .mp-sec{font-size:.68rem;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);font-weight:700;margin:.7rem .5rem .15rem;}
.meet-panel .mp-row.waiting{opacity:.7;}
.meet-panel .mp-row .pp-wait{margin-left:auto;font-size:.72rem;color:var(--muted);display:inline-flex;align-items:center;gap:.25rem;white-space:nowrap;}
.meet-panel .mp-makehost{border:none;background:var(--blue-soft);color:var(--blue);border-radius:7px;padding:.2rem .4rem;cursor:pointer;display:grid;place-items:center;}
.host-tag{display:inline-flex;align-items:center;gap:.2rem;font-size:.62rem;font-weight:700;background:#fff3cd;color:#7a5b05;padding:.05rem .35rem;border-radius:99px;}
.admin-tag{display:inline-flex;align-items:center;gap:.2rem;font-size:.6rem;font-weight:700;background:#fef3c7;color:#92600b;padding:.05rem .4rem;border-radius:99px;vertical-align:middle;}
@@ -304,8 +407,8 @@
.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;}
.reply-btn{position:absolute;top:-9px;right:6px;background:var(--card);color:var(--blue);border:1px solid var(--line);border-radius:50%;width:22px;height:22px;font-size:.8rem;line-height:1;cursor:pointer;opacity:0;transition:opacity .12s;box-shadow:0 1px 3px rgba(0,0,0,.12);}
.bubble:hover .reply-btn{opacity:1;}
.reply-btn{position:absolute;top:-9px;right:6px;background:var(--card);color:var(--blue);border:1px solid var(--line);border-radius:50%;width:22px;height:22px;font-size:.8rem;line-height:1;cursor:pointer;opacity:0;pointer-events:none;transition:opacity .12s;box-shadow:0 1px 3px rgba(0,0,0,.12);}
.bubble:hover .reply-btn,.bubble.show-actions .reply-btn{opacity:1;pointer-events:auto;}
.reply-bar{display:flex;align-items:center;gap:.5rem;padding:.45rem .8rem;border-top:1px solid var(--line);background:#eef3fb;font-size:.82rem;color:var(--muted);}
.reply-bar b{color:var(--ink);}
.reply-bar .rx{margin-left:auto;cursor:pointer;font-size:1rem;}
@@ -341,8 +444,13 @@
.emoji-grid{flex:1;overflow-y:auto;display:grid;grid-template-columns:repeat(8,1fr);gap:.1rem;padding:.4rem;align-content:start;}
.emoji-grid button{border:none;background:transparent;font-size:1.25rem;cursor:pointer;padding:.2rem;border-radius:6px;line-height:1.15;}
.emoji-grid button:hover{background:var(--blue-soft);}
.react-btn{position:absolute;top:-9px;right:32px;background:var(--card);color:var(--blue);border:1px solid var(--line);border-radius:50%;width:22px;height:22px;font-size:.8rem;line-height:1;cursor:pointer;opacity:0;transition:opacity .12s;box-shadow:0 1px 3px rgba(0,0,0,.12);}
.bubble:hover .react-btn{opacity:1;}
.react-btn{position:absolute;top:-9px;right:32px;background:var(--card);color:var(--blue);border:1px solid var(--line);border-radius:50%;width:22px;height:22px;font-size:.8rem;line-height:1;cursor:pointer;opacity:0;pointer-events:none;transition:opacity .12s;box-shadow:0 1px 3px rgba(0,0,0,.12);}
.bubble:hover .react-btn,.bubble.show-actions .react-btn{opacity:1;pointer-events:auto;}
.del-btn{position:absolute;top:-9px;right:58px;background:var(--card);color:var(--red);border:1px solid var(--line);border-radius:50%;width:22px;height:22px;line-height:1;cursor:pointer;opacity:0;pointer-events:none;transition:opacity .12s;box-shadow:0 1px 3px rgba(0,0,0,.12);display:grid;place-items:center;}
.bubble:hover .del-btn,.bubble.show-actions .del-btn{opacity:1;pointer-events:auto;}
.bubble.deleted{opacity:.85;}
.bubble.deleted .del-msg{font-style:italic;color:var(--muted);font-size:.9rem;display:inline-flex;align-items:center;gap:.3rem;}
.bubble.mine.deleted .del-msg{color:rgba(255,255,255,.85);}
.reacts{display:flex;flex-wrap:wrap;gap:.2rem;margin-top:.3rem;}
.react-chip{border:1px solid var(--line);background:var(--card);color:var(--ink);border-radius:999px;font-size:.74rem;padding:.05rem .4rem;cursor:pointer;line-height:1.5;}
.react-chip.mine{background:var(--blue-soft);border-color:#c7d6f0;color:var(--blue);}
@@ -358,7 +466,7 @@
.att-file .att-sz{opacity:.65;font-size:.75rem;flex:0 0 auto;}
/* groups */
.bubble .sender{font-size:.7rem;color:var(--blue);font-weight:700;margin-bottom:.12rem;display:flex;align-items:center;gap:.35rem;}
.bubble .sender .snd-av{position:relative;width:18px;height:18px;flex:0 0 18px;border-radius:50%;display:grid;place-items:center;color:#fff;font-weight:700;font-size:.55rem;overflow:hidden;}
.bubble .sender .snd-av{position:relative;width:20px;height:20px;flex:0 0 20px;border-radius:50%;display:grid;place-items:center;color:#334155;font-weight:800;font-size:.58rem;overflow:hidden;}
.bubble .sender .snd-av img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;}
.avatar.grp{border-radius:12px;font-size:1.15rem;}
.modal-ov{position:fixed;inset:0;background:rgba(15,23,42,.45);display:flex;align-items:center;justify-content:center;z-index:9800;padding:1rem;}
@@ -369,6 +477,14 @@
.avatar .mcount{position:absolute;right:-3px;bottom:-3px;min-width:16px;height:16px;border-radius:99px;background:var(--blue);color:#fff;font-size:.6rem;font-weight:800;display:grid;place-items:center;border:2px solid var(--card);padding:0 .15rem;z-index:1;}
.convo-titlewrap{flex:1;min-width:0;}
.convo-info{border:none;background:var(--blue-soft);color:var(--blue);width:32px;height:32px;border-radius:9px;font-size:1rem;cursor:pointer;flex:0 0 auto;display:grid;place-items:center;}
.convo-fav.on{color:#f59e0b;}
.convo-fav.on svg{fill:#f59e0b;}
.gi-title-row{display:flex;align-items:center;gap:.35rem;}
.fav-star{border:none;background:transparent;color:var(--muted);cursor:pointer;padding:.12rem;display:grid;place-items:center;border-radius:6px;flex:0 0 auto;}
.fav-star:hover{background:#f1f5f9;}
.fav-star.on{color:#f59e0b;} .fav-star.on svg{fill:#f59e0b;}
.status-dot{display:inline-block;width:8px;height:8px;border-radius:50%;background:#cbd5e1;margin-right:.35rem;vertical-align:middle;}
.status-dot.on{background:#16a34a;}
.convo-info:hover{background:#dbe4f0;}
.convo-call{border:none;background:var(--blue-soft);color:var(--blue);height:32px;border-radius:9px;cursor:pointer;flex:0 0 auto;display:inline-flex;align-items:center;gap:.3rem;padding:0 .6rem;font-weight:600;}
.convo-call:hover{background:#dbe4f0;}
@@ -493,7 +609,7 @@
.mrow .mn{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.mrow .iconbtn{opacity:0;}
.mrow:hover .iconbtn{opacity:1;}
.mini-av{position:relative;overflow:hidden;width:30px;height:30px;flex:0 0 30px;border-radius:50%;display:grid;place-items:center;color:#fff;font-weight:700;font-size:.72rem;}
.mini-av{position:relative;overflow:hidden;width:30px;height:30px;flex:0 0 30px;border-radius:50%;display:grid;place-items:center;color:#334155;font-weight:700;font-size:.72rem;}
.mini-av .av-img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;}
.gi-photo{position:relative;border:none;background:transparent;padding:0;cursor:pointer;flex:0 0 auto;}
.gi-photo .avatar.grp{flex:0 0 46px;}
@@ -551,7 +667,14 @@
}
/* ---- Mobile / tablet ---- */
@media (max-width:760px){
header{padding:.5rem .8rem;}
/* #7: drop the top blue bar entirely on mobile to use the full screen. bell+profile are
relocated into the chat-list header (see placeHdrRight). The shell is pushed below the
device status bar so no view overlaps it. */
header{display:none;}
.shell{padding-top:env(safe-area-inset-top,0);}
.side-title{gap:.5rem;}
.side-title h2{flex:1;}
.side-title #hdrRight{display:flex;align-items:center;gap:.4rem;flex:0 0 auto;}
.brand{font-size:.98rem;}
.navtoggle{display:none;} .rail-backdrop{display:none!important;} /* bottom nav replaces the drawer */
/* App-style bottom navigation bar */
@@ -566,12 +689,12 @@
/* Meeting room code: always fully visible, full-width, easy to read */
.meet-bar .code{flex:1 1 100%;font-size:.9rem;white-space:normal;word-break:break-word;text-align:center;background:var(--blue-soft);border-radius:8px;padding:.35rem .5rem;}
/* Chat: one pane at a time (list, then the open conversation) */
.chatcol{width:100%;flex:1 1 100%;padding-bottom:60px;}
.chatcol{width:100%;flex:1 1 100%;padding-bottom:calc(60px + env(safe-area-inset-bottom,0));}
body.chat-open .chatcol{display:none;}
.content{display:none;}
body.chat-open .content, .shell:not(.is-chat) .content{display:block;}
.shell:not(.is-chat) .chatcol{display:none;}
.content .panel{bottom:60px;} /* keep panel content above the bottom nav */
.content .panel{bottom:calc(60px + env(safe-area-inset-bottom,0));} /* clear the bottom nav + home indicator */
/* Bigger touch targets */
.chat-row{padding:.7rem .85rem;}
.convo-head{padding:.7rem .85rem;}
@@ -589,22 +712,23 @@
.md-actions > .btn{width:100%;justify-content:center;}
.md-join{flex:1 1 100%;}
.call-invite{left:8px;right:8px;max-width:none;bottom:70px;}
.bell-menu{position:fixed;left:8px;right:8px;top:58px;width:auto;}
.bell-menu{position:fixed;left:8px;right:8px;top:calc(56px + env(safe-area-inset-top,0));width:auto;}
#rail .bell-menu,#rail .profile .pmenu{left:auto;} /* desktop rail rule must not leak to the mobile bottom-nav */
/* Touch devices have no hover: keep member-row actions (make-admin / remove) always visible */
.mrow .iconbtn{opacity:1;}
}
</style>
</head>
<body>
<script src="/icons.js?v=3"></script>
<script>window.__BUILD='2026-06-25-pwa2';console.log('%cBizGaze Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD);</script>
<script src="/icons.js?v=4"></script>
<script>window.__BUILD='2026-06-30-batch14';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD);</script>
<div class="loading" id="loading">Loading…</div>
<header>
<div class="brandrow">
<button class="navtoggle" id="navToggle" title="Toggle menu" aria-label="Toggle menu"><svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg></button>
<img src="/logo.png" alt="" style="height:46px;width:auto;max-width:190px;border-radius:8px;object-fit:contain;background:#fff;padding:5px 12px;image-rendering:-webkit-optimize-contrast" onerror="this.replaceWith(Object.assign(document.createElement('div'),{className:'logo',textContent:'B'}))">
<div class="brand">BizGaze <span class="y">Connect</span></div>
<div class="brand">Biz <span class="y">Connect</span></div>
</div>
<div id="hdrRight"></div>
</header>
@@ -637,12 +761,14 @@
<div class="side-head">
<div class="side-title">
<h2>Chats</h2>
<button class="newchat" id="newChat" title="New chat" aria-label="New chat">+</button>
</div>
<div class="search">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input id="chatSearch" placeholder="Search chats" autocomplete="off">
<button class="search-x" id="chatSearchX" title="Clear" aria-label="Clear" style="display:none"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg></button>
<div class="side-search-row">
<div class="search">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input id="chatSearch" placeholder="Search chats" autocomplete="off">
<button class="search-x" id="chatSearchX" title="Clear" aria-label="Clear" style="display:none"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg></button>
</div>
<button class="newchat" id="newChat" title="New group / chat" aria-label="New group">+</button>
</div>
</div>
<div class="chatlist" id="chatlist"></div>
@@ -680,7 +806,8 @@ function autoGrow(el){ if(!el) return; el.style.height='auto'; const max=140; el
const REPLY_TINTS=[['#eef4ff','#3b6fd4'],['#eafaf2','#1f9d57'],['#fef4e9','#d98324'],['#f6eefe','#8b46c9'],['#fdeef3','#d6457f'],['#e9fafa','#179a9a'],['#fef7e6','#c9a227']];
function replyTint(key){ let h=0; const s=String(key||''); for(let i=0;i<s.length;i++) h=(h*31+s.charCodeAt(i))>>>0; return REPLY_TINTS[h%REPLY_TINTS.length]; }
function firstName(name){return String(name||'').trim().split(/\s+/)[0]||'there';}
const AV_COLORS=['#1F3B73','#2563eb','#0e7490','#7c3aed','#be185d','#b45309','#15803d','#9d174d'];
// Soft pastel avatar backgrounds (with dark lettering) for a light, professional look.
const AV_COLORS=['#dbeafe','#e0e7ff','#dcfce7','#cffafe','#fae8ff','#fce7f3','#fee2e2','#fef3c7','#ecfccb','#fde4cf'];
function avColor(name){let h=0;for(const c of String(name))h=(h*31+c.charCodeAt(0))>>>0;return AV_COLORS[h%AV_COLORS.length];}
let toastTimer=null;
@@ -690,10 +817,16 @@ function toast(msg){const t=document.getElementById('toast');t.textContent=msg;t
function profileHTML(u){
const display=u.name||u.email;
const img=u.avatarUrl?'<img src="'+pEsc(u.avatarUrl)+'" alt="" onerror="this.remove()">':'';
const cur=(u&&u.status)||'active';
const lblOf=(st)=>st==='away'?'Away':st==='onleave'?'On leave':'Available';
const opt=(st)=>'<a class="ps-opt'+(cur===st?' on':'')+'" data-st="'+st+'"><span class="st-dot '+st+'"></span> '+lblOf(st)+'<span class="ps-check">'+ic('check',14)+'</span></a>';
return '<div class="profile"><button class="pbtn icon-only" id="pbtn" title="'+pEsc(display)+'">'
+ '<span class="pav">'+pEsc(initials(display))+img+'</span></button>'
+ '<span class="pav">'+pEsc(initials(display))+img+'</span><span class="pav-dot '+statusCls({online:true,status:cur})+'"></span></button>'
+ '<div class="pmenu" id="pmenu">'
+ '<div class="phead"><div class="n">'+pEsc(display)+'</div><div class="e">'+pEsc(u.email)+(u.role?' · '+pEsc(u.role):'')+'</div></div>'
+ '<a class="ps-current" id="psStatusToggle"><span class="st-dot '+cur+'"></span><span class="ps-cur-label">'+lblOf(cur)+'</span><span class="ps-arrow">'+ic('chevronDown',14)+'</span></a>'
+ '<div class="ps-options" id="psOptions" style="display:none">'+opt('active')+opt('away')+opt('onleave')+'</div>'
+ '<div class="ps-div"></div>'
+ '<a href="/dashboard">'+ic('layoutDashboard',16)+' Dashboard</a>'
+ '<a id="psettings">'+ic('settings',16)+' Settings</a>'
+ '<a class="danger" id="plogout">'+ic('logOut',16)+' Logout</a>'
@@ -702,12 +835,20 @@ function profileHTML(u){
function wireProfile(){
const btn=document.getElementById('pbtn'),menu=document.getElementById('pmenu');
if(!btn)return;
btn.onclick=(e)=>{e.stopPropagation();menu.classList.toggle('open');};
btn.onclick=(e)=>{e.stopPropagation();const bm=document.getElementById('bellMenu');if(bm)bm.classList.remove('open');menu.classList.toggle('open');};
document.addEventListener('click',()=>menu.classList.remove('open'));
const lo=document.getElementById('plogout');
if(lo)lo.onclick=async()=>{try{await fetch('/api/logout',{method:'POST'});}catch(_){}location.href='/';};
if(lo)lo.onclick=async()=>{ await unsubscribePush(); try{await fetch('/api/logout',{method:'POST'});}catch(_){} location.href='/';};
const ps=document.getElementById('psettings');
if(ps)ps.onclick=()=>{ menu.classList.remove('open'); openSettings(); };
const stog=menu.querySelector('#psStatusToggle'); if(stog) stog.onclick=(e)=>{ e.stopPropagation(); const o=menu.querySelector('#psOptions'); if(o) o.style.display=(o.style.display==='none'?'block':'none'); };
menu.querySelectorAll('.ps-opt').forEach(a=>a.onclick=async(e)=>{ e.stopPropagation(); const st=a.dataset.st; ME.status=st;
const cd=menu.querySelector('.ps-current .st-dot'); if(cd) cd.className='st-dot '+st;
const cl=menu.querySelector('.ps-cur-label'); if(cl) cl.textContent=(st==='away'?'Away':st==='onleave'?'On leave':'Available');
menu.querySelectorAll('.ps-opt').forEach(o=>{ o.classList.toggle('on', o===a); }); const opts=menu.querySelector('#psOptions'); if(opts) opts.style.display='none';
const pd=document.querySelector('.profile .pav-dot'); if(pd) pd.className='pav-dot '+st;
try{ await postJSON('/api/me/status',{status:st}); }catch(_){}
});
}
// ---------- Notification bell (activity center) ----------
// Captures things that otherwise have no home: scheduled-meeting invites, reminders, reactions to
@@ -730,7 +871,7 @@ function openNotif(n){ NOTIFS=NOTIFS.filter(x=>x.id!==n.id); saveNotifs(); updat
renderBell();
}
function wireBell(){ const b=document.getElementById('bellBtn'), menu=document.getElementById('bellMenu'); if(!b||!menu) return;
b.onclick=(e)=>{ e.stopPropagation(); const open=menu.classList.toggle('open'); if(open){ renderBell(); NOTIFS.forEach(x=>x.read=true); saveNotifs(); updateBellBadge(); } };
b.onclick=(e)=>{ e.stopPropagation(); const pm=document.getElementById('pmenu'); if(pm) pm.classList.remove('open'); const open=menu.classList.toggle('open'); if(open){ renderBell(); NOTIFS.forEach(x=>x.read=true); saveNotifs(); updateBellBadge(); } };
document.addEventListener('click',()=>menu.classList.remove('open')); menu.onclick=(e)=>e.stopPropagation(); updateBellBadge();
}
// Settings: notification preferences (browser permission + per-type), stored per browser.
@@ -738,13 +879,15 @@ function openSettings(){
if(document.getElementById('setModal')) return;
const ov=document.createElement('div'); ov.className='modal-ov'; ov.id='setModal';
const sw=(id,label,on)=>'<label class="gi-setting"><span>'+label+'</span><span class="switch"><input type="checkbox" id="'+id+'"'+(on?' checked':'')+'><span class="slider"></span></span></label>';
const granted=('Notification' in window) && Notification.permission==='granted';
const perm=('Notification' in window) ? Notification.permission : 'unsupported';
const granted=perm==='granted';
const permLabel=granted?'Allowed':(perm==='denied'?'Denied':(perm==='default'?'Not set':'Unsupported'));
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('settings',20)+'</div>'
+'<div class="gi-name"><div class="gi-title">Settings</div><div class="gi-sub">Notifications</div></div>'
+'<button class="iconbtn" id="setClose" title="Close">'+ic('x',18)+'</button></div>'
+sw('setGroup','Group message notifications', notifOn('group'))
+sw('setDm','Direct message notifications', notifOn('dm'))
+'<label class="gi-setting"><span>Browser/desktop pop-ups</span><button class="btn sm" id="setPerm"'+(granted?' disabled':'')+'>'+(granted?'Enabled':'Enable')+'</button></label>'
+'<label class="gi-setting"><span>Notifications <span class="perm-state '+perm+'">'+permLabel+'</span><div style="font-size:.72rem;color:var(--muted);font-weight:400;margin-top:.15rem">Pop up messages &amp; calls even when this tab isn’t open</div></span>'+(granted?'':'<button class="btn sm" id="setPerm">'+(perm==='denied'?'Allow':'Enable')+'</button>')+'</label>'
+'<div class="hint" style="margin-top:.4rem">These preferences are saved on this device.</div></div>';
document.body.appendChild(ov);
ov.onclick=e=>{ if(e.target===ov) ov.remove(); };
@@ -752,7 +895,7 @@ function openSettings(){
const setPref=(k,v)=>{ try{ localStorage.setItem('notif_'+k, v?'on':'off'); }catch(_){} };
ov.querySelector('#setGroup').onchange=e=>setPref('group', e.target.checked);
ov.querySelector('#setDm').onchange=e=>setPref('dm', e.target.checked);
const perm=ov.querySelector('#setPerm'); if(perm) perm.onclick=async()=>{ try{ const r=await Notification.requestPermission(); if(r==='granted'){ perm.textContent='Enabled'; perm.disabled=true; toast('Desktop notifications enabled'); try{ await subscribePush(); }catch(_){} } else toast('Notifications blocked — allow them in your browser site settings'); }catch(_){ toast('Notifications need HTTPS or localhost'); } };
const permBtn=ov.querySelector('#setPerm'); if(permBtn) permBtn.onclick=async()=>{ try{ const r=await Notification.requestPermission(); if(r==='granted'){ try{ await subscribePush(); }catch(_){} } }catch(_){} ov.remove(); openSettings(); }; // just trigger the browser prompt + refresh the state (no toast)
}
// ---------- Chat (1:1 + groups) ----------
@@ -763,6 +906,114 @@ let selected=null; // {kind,id} or null = welcome
let convoIsGroup=false; // the open thread is a group (drives per-message sender labels)
let THREAD=[];
const THREAD_CACHE=new Map(); // key 'kind:id' -> messages[] ; lets a notification click render synchronously (paints immediately)
// Pull-to-refresh (mobile): drag down at the top of a scroll area to refresh. Touch-only, so it's
// a no-op on desktop. onRefresh is an async function.
function enablePullRefresh(el, onRefresh){
if(!el || el.__ptr) return; el.__ptr=true;
if(getComputedStyle(el).position==='static') el.style.position='relative';
const ind=document.createElement('div'); ind.className='ptr-ind'; ind.innerHTML='<span class="ptr-g">↻</span>'; el.appendChild(ind);
let startY=0, pulling=false, dist=0, busy=false; const TRIGGER=70;
el.addEventListener('touchstart',(e)=>{ if(busy||el.scrollTop>0||e.touches.length!==1) return; startY=e.touches[0].clientY; pulling=true; dist=0; },{passive:true});
el.addEventListener('touchmove',(e)=>{ if(!pulling||busy) return; dist=e.touches[0].clientY-startY; if(dist<=0||el.scrollTop>0){ pulling=false; ind.style.opacity='0'; ind.style.transform='translateY(-48px)'; return; } const d=Math.min(dist*0.5,84); ind.style.opacity=String(Math.min(d/56,1)); ind.style.transform='translateY('+(d-48)+'px)'; if(dist>10) e.preventDefault(); },{passive:false});
el.addEventListener('touchend',async()=>{ if(!pulling) return; pulling=false; if(dist>=TRIGGER){ busy=true; ind.classList.add('spin'); ind.style.opacity='1'; ind.style.transform='translateY(14px)'; try{ await onRefresh(); }catch(_){} await new Promise(r=>setTimeout(r,250)); ind.classList.remove('spin'); busy=false; } ind.style.opacity='0'; ind.style.transform='translateY(-48px)'; dist=0; });
}
async function reloadThread(){ if(!selected) return; const kind=selected.kind, id=selected.id; const url=kind==='group'?('/api/messages/thread?group='+encodeURIComponent(id)):('/api/messages/thread?with='+encodeURIComponent(id)); try{ const msgs=await fetch(url).then(r=>r.json()); if(selected&&selected.kind===kind&&selected.id===id&&Array.isArray(msgs)){ THREAD=msgs; THREAD_CACHE.set(kind+':'+id, THREAD.slice()); renderThread(); } }catch(_){} }
// #9: in-chat search — highlight every match and jump between them with up/down (no filtering).
let _searchHits=[], _searchIdx=-1;
function clearSearchHighlights(){
const box=document.getElementById('msgs'); if(box){ box.querySelectorAll('mark.search-hit').forEach(m=>{ const t=document.createTextNode(m.textContent); m.replaceWith(t); }); box.querySelectorAll('.bubble').forEach(b=>{ try{ b.normalize(); }catch(_){} }); }
_searchHits=[]; _searchIdx=-1;
}
function _highlightIn(root, ql){
const hits=[];
const walker=document.createTreeWalker(root, NodeFilter.SHOW_TEXT, { acceptNode(n){ if(!n.nodeValue || n.nodeValue.toLowerCase().indexOf(ql)===-1) return NodeFilter.FILTER_REJECT; const p=n.parentElement; if(!p || p.closest('.reply-btn,.react-btn,.del-btn,.t,.reacts,.react-chip,.seenby,.rcpt,.att-sz,button')) return NodeFilter.FILTER_REJECT; return NodeFilter.FILTER_ACCEPT; } });
const nodes=[]; while(walker.nextNode()) nodes.push(walker.currentNode);
nodes.forEach(n=>{ const txt=n.nodeValue, low=txt.toLowerCase(); let i, last=0; const frag=document.createDocumentFragment();
while((i=low.indexOf(ql,last))!==-1){ if(i>last) frag.appendChild(document.createTextNode(txt.slice(last,i))); const mk=document.createElement('mark'); mk.className='search-hit'; mk.textContent=txt.slice(i,i+ql.length); frag.appendChild(mk); hits.push(mk); last=i+ql.length; }
if(last>0){ if(last<txt.length) frag.appendChild(document.createTextNode(txt.slice(last))); n.parentNode.replaceChild(frag,n); } });
return hits;
}
function runSearch(q){
clearSearchHighlights();
const box=document.getElementById('msgs'), cnt=document.getElementById('convoSearchCount'); if(!box) return;
q=(q||'').trim(); if(!q){ if(cnt) cnt.textContent=''; return; }
_searchHits=_highlightIn(box, q.toLowerCase());
if(!_searchHits.length){ if(cnt) cnt.textContent='0/0'; return; }
gotoHit(_searchHits.length-1); // start at the most recent match
}
function gotoHit(i){
if(!_searchHits.length) return;
_searchIdx=((i%_searchHits.length)+_searchHits.length)%_searchHits.length;
_searchHits.forEach(m=>m.classList.remove('search-current'));
const m=_searchHits[_searchIdx]; m.classList.add('search-current'); try{ m.scrollIntoView({block:'center'}); }catch(_){}
const cnt=document.getElementById('convoSearchCount'); if(cnt) cnt.textContent=(_searchIdx+1)+'/'+_searchHits.length;
}
function closeSearch(){ clearSearchHighlights(); const h=document.getElementById('convoSearchHead'); if(h) h.style.display='none'; const i=document.getElementById('convoSearchInput'); if(i) i.value=''; }
// #10: "Shared" — Media (images) + Files tabs for a DM or group, opened by tapping the chat name.
// Compact "Media, links & docs" entry (shown in the conversation-info window). Clicking it opens
// the separate full Media/Docs/Links view (openMediaView).
function mediaRowHTML(){ return '<button class="gi-media-row" id="giMediaRow"><span class="gmr-l">Media, links &amp; docs</span><span class="gmr-r" id="giMediaCount"></span></button><div class="gi-media-strip" id="giMediaStrip"></div>'; }
async function wireMediaEntry(root, kind, id, name){
let data={media:[],docs:[],links:[]}; try{ const url=kind==='group'?('/api/messages/media?group='+encodeURIComponent(id)):('/api/messages/media?with='+encodeURIComponent(id)); const r=await fetch(url).then(x=>x.json()); if(r&&r.media) data=r; }catch(_){}
const total=data.media.length+data.docs.length+data.links.length;
const cnt=root.querySelector('#giMediaCount'); if(cnt) cnt.textContent=(total||0)+' ';
const strip=root.querySelector('#giMediaStrip'); if(strip){ strip.innerHTML=data.media.filter(x=>x.isImage).slice(0,4).map(x=>'<img class="gms-thumb att-img" src="/files/'+pEsc(x.id)+'" data-img="/files/'+pEsc(x.id)+'" loading="lazy">').join(''); strip.addEventListener('click',e=>{ const im=e.target.closest('.att-img'); if(im&&im.dataset.img) openLightbox(im.dataset.img); }); }
const row=root.querySelector('#giMediaRow'); if(row) row.onclick=()=>openMediaView(kind,id,name,data);
}
function stripExt(n){ n=String(n||''); const i=n.lastIndexOf('.'); return (i>0?n.slice(0,i):n)||'File'; }
function fmtDur(s){ s=Math.round(s); if(!isFinite(s)||s<0) return ''; const h=Math.floor(s/3600),m=Math.floor((s%3600)/60),ss=s%60,p=(n)=>String(n).padStart(2,'0'); return h>0?(h+':'+p(m)+':'+p(ss)):(m+':'+p(ss)); }
// One cell in the shared-Media grid: image → thumbnail; audio/video → a tile with a centred
// download button plus a headphone/▶ glyph and (lazily-loaded) duration, matching the design.
function mediaCellHTML(x){
const id=pEsc(x.id), nm=pEsc(x.name||'');
if(x.isImage) return '<figure class="sh-cell"><a class="sh-av img att-img" data-img="/files/'+id+'" href="/files/'+id+'"><img class="sh-poster" src="/files/'+id+'" loading="lazy" alt=""></a></figure>';
const isVid=!!x.isVideo, kind=isVid?'vid':'aud';
return '<figure class="sh-cell">'
+'<figcaption class="sh-name" title="'+nm+'">'+pEsc(stripExt(x.name))+'</figcaption>'
+'<a class="sh-av '+kind+'" href="/files/'+id+'" download="'+nm+'" title="Download '+nm+'">'
+(isVid?'<video class="sh-poster" src="/files/'+id+'#t=0.1" preload="metadata" muted playsinline></video>':'')
+'<span class="sh-dl">'+ic('download',20)+'</span>'
+'<span class="sh-dur"><span class="sh-di">'+ic(isVid?'play':'headphones',12)+'</span><i data-dsrc="/files/'+id+'" data-dkind="'+(isVid?'video':'audio')+'">…</i></span>'
+'</a></figure>';
}
// Server doesn't store media duration, so read each audio/video tile's metadata client-side.
function loadMediaDurations(root){ if(!root) return; root.querySelectorAll('i[data-dsrc]').forEach(el=>{ if(el.dataset.done) return; el.dataset.done='1'; try{ const m=document.createElement(el.dataset.dkind==='video'?'video':'audio'); m.preload='metadata'; m.muted=true; m.src=el.dataset.dsrc; m.addEventListener('loadedmetadata',()=>{ el.textContent=fmtDur(m.duration)||''; }); m.addEventListener('error',()=>{ el.textContent=''; }); }catch(_){ el.textContent=''; } }); }
// Separate full view: Media / Docs / Links tabs.
async function openMediaView(kind,id,name,data){
if(document.getElementById('mediaView')) return;
if(!data){ try{ const url=kind==='group'?('/api/messages/media?group='+encodeURIComponent(id)):('/api/messages/media?with='+encodeURIComponent(id)); data=await fetch(url).then(x=>x.json()); }catch(_){} }
data=data&&data.media?data:{media:[],docs:[],links:[]};
const ov=document.createElement('div'); ov.className='modal-ov'; ov.id='mediaView';
ov.innerHTML='<div class="modal gi"><div class="gi-head" style="margin-bottom:.4rem"><button class="iconbtn" id="mvBack" title="Back">'+ic('arrowLeft',18)+'</button><div class="gi-name"><div class="gi-title">Media, links &amp; docs</div><div class="gi-sub">'+pEsc(name||'')+'</div></div></div>'
+'<div class="mp-tabs mv-tabs"><button class="mp-tab on" data-t="media">Media</button><button class="mp-tab" data-t="docs">Docs</button><button class="mp-tab" data-t="links">Links</button></div>'
+'<div class="mv-body" id="mvBody"></div></div>';
document.body.appendChild(ov);
ov.onclick=e=>{ if(e.target===ov) ov.remove(); };
ov.querySelector('#mvBack').onclick=()=>ov.remove();
let tab='media';
const render=()=>{ const b=ov.querySelector('#mvBody'); if(!b) return;
if(tab==='media') b.innerHTML=data.media.length?'<div class="sh-grid">'+data.media.map(mediaCellHTML).join('')+'</div>':'<div class="gi-noresult">No media shared yet</div>';
else if(tab==='docs') b.innerHTML=data.docs.length?data.docs.map(x=>'<a class="sh-file" href="/files/'+pEsc(x.id)+'" download="'+pEsc(x.name||'file')+'">'+ic('fileText',16)+'<span class="sh-fn">'+pEsc(x.name||'file')+'</span><span class="sh-sz">'+fmtSize(x.size)+'</span></a>').join(''):'<div class="gi-noresult">No documents shared yet</div>';
else b.innerHTML=data.links.length?data.links.map(x=>'<a class="sh-file" href="'+pEsc(x.url)+'" target="_blank" rel="noopener">'+ic('link',16)+'<span class="sh-fn">'+pEsc(x.url)+'</span></a>').join(''):'<div class="gi-noresult">No links shared yet</div>';
loadMediaDurations(b);
};
ov.querySelectorAll('.mv-tabs .mp-tab').forEach(btn=>btn.onclick=()=>{ tab=btn.dataset.t; ov.querySelectorAll('.mv-tabs .mp-tab').forEach(x=>x.classList.toggle('on',x===btn)); render(); });
ov.querySelector('#mvBody').addEventListener('click',e=>{ const im=e.target.closest('.att-img'); if(im&&im.dataset.img){ e.preventDefault(); openLightbox(im.dataset.img); } });
render();
}
// DM contact-info window (no members): header + favourite + the Media/links/docs entry.
async function openSharedItems(kind,id,name){
if(document.getElementById('sharedModal')) return;
const r0=rowFor(kind,id); const fav=!!(r0&&r0.favorite); const online=!!(r0&&r0.online);
const ov=document.createElement('div'); ov.className='modal-ov'; ov.id='sharedModal';
ov.innerHTML='<div class="modal gi"><div class="gi-head" style="margin-bottom:.6rem"><span class="avatar" style="width:46px;height:46px;flex:0 0 46px;background:'+avColor(name)+'">'+pEsc(initials(name||'?'))+'</span><div class="gi-name"><div class="gi-title-row"><span class="gi-title">'+pEsc(name||'')+'</span><button class="fav-star'+(fav?' on':'')+'" id="shFav" title="'+(fav?'Remove from favourites':'Add to favourites')+'">'+ic('star',16)+'</button></div><div class="gi-sub"><span class="st-dot '+statusCls(r0)+'"></span>'+statusLabel(r0)+'</div></div><button class="iconbtn" id="shClose">'+ic('x',18)+'</button></div>'
+mediaRowHTML()+'</div>';
document.body.appendChild(ov);
ov.onclick=e=>{ if(e.target===ov) ov.remove(); };
ov.querySelector('#shClose').onclick=()=>ov.remove();
const fb=ov.querySelector('#shFav'); if(fb) fb.onclick=async()=>{ const r=rowFor(kind,id); const on=!(r&&r.favorite); if(r) r.favorite=on; fb.classList.toggle('on',on); fb.title=on?'Remove from favourites':'Add to favourites'; try{ await postJSON('/api/favorites',{kind,id,on}); }catch(_){} renderChats(searchVal()); };
wireMediaEntry(ov, kind, id, name);
}
let convoMembers=[]; // members of the currently open group (for @mentions + highlight)
let composeMentions=new Map();// token ('@Name' | 'everyone') -> userId | 'everyone' for the draft
const rendered=new Set();
@@ -779,11 +1030,14 @@ function fmtTime(ts){
if(d.toDateString()===y.toDateString()) return 'Yesterday';
return d.toLocaleDateString([], {month:'short',day:'numeric'});
}
// 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 avatarHTML(it, big){
const isG=it.kind==='group';
const sz=big?'width:38px;height:38px;flex:0 0 38px;':'';
const corner=isG?(it.members?'<span class="mcount">'+(it.members>99?'99+':it.members)+'</span>':'')
:'<span class="dot'+(it.online?' on':'')+'"></span>';
:'<span class="dot '+statusCls(it)+'"></span>';
const inner=isG?ic('users',big?20:18):pEsc(initials(it.name));
// Photo overlay (BizGaze profile picture, or an uploaded group image). If it fails to
// load it removes itself, revealing the initials/glyph underneath.
@@ -804,8 +1058,13 @@ function renderChats(filter){
const q=(filter||'').trim().toLowerCase();
const rows=ROWS.filter(it=>!q||it.name.toLowerCase().includes(q)||(it.last_body||'').toLowerCase().includes(q))
.sort((a,b)=>(b.last_at-a.last_at)||a.name.localeCompare(b.name));
let html = rows.length ? rows.map(rowHTML).join('')
: '<div class="no-results">'+(ROWS.length?('No chats match “'+pEsc(filter)+'”.'):'No conversations yet.')+'</div>';
let html;
if(!q){ // group favourites at the top when not searching
const favs=rows.filter(r=>r.favorite), rest=rows.filter(r=>!r.favorite);
html = rows.length ? ((favs.length?'<div class="side-sec">'+ic('star',12)+' Favourites</div>'+favs.map(rowHTML).join('')+(rest.length?'<div class="side-sec">All chats</div>':''):'')+rest.map(rowHTML).join('')) : '<div class="no-results">No conversations yet.</div>';
} else {
html = rows.length ? rows.map(rowHTML).join('') : '<div class="no-results">No chats match “'+pEsc(filter)+'”.</div>';
}
if(q.length>=1){
// Team contacts you haven't messaged yet → start a new DM.
const haveDm=new Set(ROWS.filter(r=>r.kind==='dm').map(r=>r.id));
@@ -857,7 +1116,7 @@ async function loadSidebar(){
function welcomeHTML(){
return '<div class="welcome">'
+ '<div class="wave">👋</div>'
+ '<h1>Hi, '+pEsc(firstName(ME.name||ME.email))+'! Welcome to BizGaze Connect</h1>'
+ '<h1>Hi, '+pEsc(firstName(ME.name||ME.email))+'! Welcome to Biz Connect</h1>'
+ '<p>Pick a conversation on the left to start chatting, or jump straight into a session from the sidebar.</p>'
+ '<div class="wcards">'
+ '<div class="wcard" data-go="share"><div class="wi"><svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg></div><h3>Share Screen</h3><p>Show your screen with a 6-digit code</p></div>'
@@ -868,14 +1127,22 @@ function welcomeHTML(){
function wireWelcome(){ document.querySelectorAll('#chatPanel .wcard').forEach(card=>{ card.onclick=()=>switchTab(card.dataset.go); }); }
function convoShellHTML(it){
const isG=it.kind==='group';
const sub=isG?((it.members||0)+' members'):(it.online?'Online':'Offline');
const sub=isG?((it.members||0)+' members'):statusLabel(it);
return '<div class="convo">'
+ '<div class="convo-head">'
+ '<button class="convo-back" id="convoBack" title="Back (Esc)" aria-label="Back">'+ic('arrowLeft',18)+'</button>'
+ avatarHTML(it,true)
+ '<div class="convo-titlewrap'+(isG?' gi-open" id="convoTitle" title="Group info"':'"')+'><div class="nm">'+pEsc(it.name)+'</div><div class="st">'+pEsc(sub)+'</div></div>'
+ '<div class="convo-titlewrap gi-open" id="convoTitle" title="Shared media &amp; files"><div class="nm">'+pEsc(it.name)+'</div><div class="st">'+pEsc(sub)+'</div></div>'
+ '<button class="convo-info" id="convoSearch" title="Search messages">'+ic('search',18)+'</button>'
+ '<button class="convo-call'+(it.callActive?' joinable':'')+'" id="convoCall" title="'+(it.callActive?'Join call':'Start call')+'">'+ic(it.callActive?'video':'phone',18)+(it.callActive?'<span>Join</span>':'')+'</button>'
+ (isG?'<button class="convo-info" id="convoInfo" title="Group info">'+ic('info',18)+'</button>':'')
+ '<div class="convo-search-head" id="convoSearchHead" style="display:none">'
+ '<button class="convo-back" id="convoSearchClose" title="Close search">'+ic('arrowLeft',18)+'</button>'
+ '<input id="convoSearchInput" placeholder="Search this chat…" autocomplete="off">'
+ '<span class="csh-count" id="convoSearchCount"></span>'
+ '<button class="csh-nav" id="convoSearchPrev" title="Older match">'+ic('chevronUp',18)+'</button>'
+ '<button class="csh-nav" id="convoSearchNext" title="Newer match">'+ic('chevronDown',18)+'</button>'
+ '</div>'
+ '</div>'
+ '<div class="convo-msgs" id="msgs"></div>'
+ '<div class="float-date" id="floatDate" style="display:none"></div>'
@@ -914,6 +1181,7 @@ function bubbleHTML(m){
if(m.evt==='call-start') return '<div class="sys-msg">📞 '+(m.from===ME.id?'You':pEsc(m.byName||'Someone'))+' started a call</div>';
if(m.system||m.from==='__system__') return '<div class="sys-msg">'+pEsc(m.body)+'</div>';
const mine=m.from===ME.id;
if(m.deleted) return '<div class="bubble '+(mine?'mine':'them')+' deleted" data-id="'+pEsc(m.id)+'"><span class="del-msg">'+ic('trash',12)+' This message was deleted</span><span class="t">'+pEsc(fmtClock(m.created_at))+'</span></div>';
const sender=(convoIsGroup && !mine && m.fromName)?'<div class="sender">'+senderAvatar(m.from, m.fromName)+'<span>'+pEsc(m.fromName)+'</span></div>':'';
let quote='';
if(m.reply){ const t=replyTint(m.reply.from||m.reply.fromName); quote='<div class="quote" style="background:'+t[0]+';border-left-color:'+t[1]+'"><b style="color:'+t[1]+'">'+pEsc(m.reply.fromName||'')+'</b>: '+pEsc(m.reply.body)+'</div>'; }
@@ -932,6 +1200,7 @@ function bubbleHTML(m){
+ sender + quote + att + renderMsgBody(m) + pollHTML(m)
+ '<button class="reply-btn" data-id="'+pEsc(m.id)+'" title="Reply">'+ic('reply',14)+'</button>'
+ '<button class="react-btn" data-id="'+pEsc(m.id)+'" title="React">'+ic('smilePlus',14)+'</button>'
+ (mine?'<button class="del-btn" data-del="'+pEsc(m.id)+'" title="Delete message">'+ic('trash',13)+'</button>':'')
+ '<span class="t">'+pEsc(fmtClock(m.created_at))+rcpt+'</span>'
+ reacts + seen + '</div>';
}
@@ -1059,6 +1328,16 @@ function onChatReaction(d){ const m=THREAD.find(x=>x.id===d.messageId); if(m &&
}
// Read receipts (DM): the other party read my messages → mark mine as seen.
function onChatRead(d){ if(!d||!d.by) return; if(selected && selected.kind==='dm' && selected.id===d.by){ let changed=false; THREAD.forEach(m=>{ if(m.from===ME.id && !m.read_at){ m.read_at=Date.now(); changed=true; } }); if(changed) renderThread(); } }
// Delete-for-everyone: confirm, tell the server, then blank the message locally (the server also
// broadcasts chat-deleted to the other side / other tabs).
async function deleteMessage(id){ if(!confirm('Delete this message for everyone?')) return; try{ await postJSON('/api/messages/delete',{id}); markMsgDeleted(id); }catch(e){ toast(e.message||'Could not delete'); } }
function markMsgDeleted(id){
let changed=false;
THREAD.forEach(m=>{ if(m.id===id && !m.deleted){ m.deleted=true; m.body=''; m.attachment=null; m.reactions=[]; m.reply=null; m.poll=null; changed=true; } });
THREAD_CACHE.forEach(arr=>arr.forEach(m=>{ if(m.id===id){ m.deleted=true; m.body=''; m.attachment=null; } }));
if(changed) renderThread();
}
function onChatDeleted(d){ if(!d||!d.id) return; markMsgDeleted(d.id); try{ loadSidebar(); }catch(_){} } // refresh last-message previews
// DM delivered: recipient's client acked → second (grey) tick.
function onChatDelivered(d){ if(!d||!d.id) return; const m=THREAD.find(x=>x.id===d.id); if(m && !m.delivered_at){ m.delivered_at=Date.now(); updateBubble(m); } }
// Group read: a member opened the group → add them to "Seen by" on my messages up to that time.
@@ -1129,6 +1408,10 @@ function showMeetingReminder(mtg){ if(!mtg||!mtg.room) return; try{ playPing();
// In-call participants panel (everyone) + host controls (mute all, transfer host).
function meetParticipantsList(){ const a=[{id:'__local', name:((ME&&ME.name)||(ME&&ME.email)||'You')+' (you)'}]; meetPeers.forEach((p,pid)=>a.push({id:pid, name:meetNames.get(pid)||p.name||'Guest'})); return a; }
function refreshMeetPanel(){ if(document.getElementById('meetPanel')) renderMeetPanel(); }
// #6: track invitees who haven't joined yet. They show under "Not joined yet"; after 30s the
// invite is re-enabled for them. When they actually join (matched by user id), they're cleared.
function meetInviteJoined(uid){ const e=meetInvited.get(uid); if(e){ if(e.timer) clearTimeout(e.timer); meetInvited.delete(uid); refreshMeetPanel(); } }
function meetAddInvited(ids){ (ids||[]).forEach(uid=>{ const c=(CONTACTS||[]).find(x=>x.id===uid); const name=(c&&c.name)||'Someone'; const prev=meetInvited.get(uid); if(prev&&prev.timer) clearTimeout(prev.timer); const timer=setTimeout(()=>{ meetInvited.delete(uid); refreshMeetPanel(); }, 30000); meetInvited.set(uid,{name,timer}); }); refreshMeetPanel(); }
let meetPanelTab='people'; // 'people' | 'add'
let _addPool=null; // cached candidates for "Add people" (group members for a group call)
function toggleMeetPanel(){ const ex=document.getElementById('meetPanel'); if(ex){ ex.remove(); return; } const meet=document.querySelector('.meet'); if(!meet) return; meetPanelTab='people'; const p=document.createElement('div'); p.id='meetPanel'; p.className='meet-panel'; meet.appendChild(p); renderMeetPanel(); }
@@ -1146,19 +1429,20 @@ function renderMeetPanel(){
const pool = inGroup ? (_addPool||[]) : (CONTACTS||[]);
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()));
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>';
} 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>':'')
+(meetIsHost?'<label class="gi-setting mp-setting"><span>'+ic('monitor',15)+' Allow multiple people to share</span><span class="switch"><input type="checkbox" id="mpMulti"'+(meetMultiShare?' checked':'')+'><span class="slider"></span></span></label>':'');
}
p.innerHTML=head+tabs+body;
p.innerHTML=head+tabs+'<div class="mp-scroll">'+body+'</div>';
const x=p.querySelector('.mp-x'); if(x) x.onclick=()=>p.remove();
p.querySelectorAll('.mp-tab').forEach(b=>b.onclick=()=>{ meetPanelTab=b.dataset.tab; renderMeetPanel(); });
const ma=p.querySelector('.mp-muteall'); if(ma) ma.onclick=()=>{ meetSend({type:'meeting-muteall'}); toast('Muted everyone'); };
const mm=p.querySelector('#mpMulti'); if(mm) mm.onchange=()=>{ meetMultiShare=mm.checked; meetSend({type:'meeting-sharemode', multi:meetMultiShare}); };
p.querySelectorAll('.mp-makehost').forEach(b=>b.onclick=()=>{ meetSend({type:'meeting-host', to:b.dataset.id}); meetHostId=b.dataset.id; meetIsHost=false; renderMeetPanel(); });
const inv=p.querySelector('#mpInvite'); if(inv) inv.onclick=async()=>{ const ids=[...p.querySelectorAll('input:checked')].map(i=>i.value); if(!ids.length){ toast('Pick people to invite'); return; } try{ const r=await postJSON('/api/calls/invite',{ room:meetRoom, userIds:ids }); toast('Invited '+r.invited+(r.invited===1?' person':' people')); meetPanelTab='people'; renderMeetPanel(); }catch(e){ toast(e.message||'Could not invite'); } };
const inv=p.querySelector('#mpInvite'); if(inv) inv.onclick=async()=>{ const ids=[...p.querySelectorAll('input:checked')].map(i=>i.value); if(!ids.length){ toast('Pick people to invite'); return; } try{ await postJSON('/api/calls/invite',{ room:meetRoom, userIds:ids }); meetAddInvited(ids); meetPanelTab='people'; toast(ids.length===1?'Waiting for them to join…':'Waiting for '+ids.length+' people to join…'); renderMeetPanel(); }catch(e){ toast(e.message||'Could not invite'); } };
}
// Add people to the current call (from the in-call bar).
function openInvitePicker(room){
@@ -1221,11 +1505,17 @@ function appendBubble(m){
box.insertAdjacentHTML('beforeend', bubbleHTML(m));
box.scrollTop=box.scrollHeight;
}
let _openUnread=0; // set by selectChat (the unread count before it's reset) so we can open at the first unread
async function openConvo(kind,id){
const it=rowFor(kind,id)||{kind,id,name:'Conversation'};
const _unreadN=_openUnread; _openUnread=0; // consume the captured unread count (#3)
convoIsGroup=(kind==='group');
const el=document.getElementById('chatPanel'); el.classList.remove('center');
el.innerHTML=convoShellHTML(it);
// #1: drag-and-drop a file/video/image anywhere on the conversation to send it.
el.ondragover=(e)=>{ if(e.dataTransfer&&Array.from(e.dataTransfer.types||[]).includes('Files')){ e.preventDefault(); el.classList.add('drag-over'); } };
el.ondragleave=(e)=>{ if(e.relatedTarget===null||!el.contains(e.relatedTarget)) el.classList.remove('drag-over'); };
el.ondrop=(e)=>{ const f=e.dataTransfer&&e.dataTransfer.files&&e.dataTransfer.files[0]; if(f){ e.preventDefault(); el.classList.remove('drag-over'); uploadFile(f); } };
const back=document.getElementById('convoBack'); if(back) back.onclick=showWelcome;
const form=document.getElementById('composer'); if(form) form.addEventListener('submit',(e)=>{ e.preventDefault(); sendMessage(); });
clearReply(); pendingAttach=null; hideAttach();
@@ -1242,18 +1532,33 @@ 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
}
const ci=document.getElementById('convoInfo'); if(ci) ci.onclick=()=>openGroupInfo(id);
const ct=document.getElementById('convoTitle'); if(ct) ct.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));
const csb=document.getElementById('convoSearch'); const cshead=document.getElementById('convoSearchHead'); const csin=document.getElementById('convoSearchInput');
if(csb) csb.onclick=()=>{ if(!cshead) return; cshead.style.display='flex'; if(csin){ csin.value=''; setTimeout(()=>csin.focus(),0); } };
if(csin) csin.oninput=()=>runSearch(csin.value);
if(csin) csin.addEventListener('keydown',e=>{ if(e.key==='Enter'){ e.preventDefault(); gotoHit(_searchIdx + (e.shiftKey?-1:1)); } if(e.key==='Escape'){ closeSearch(); } });
const csClose=document.getElementById('convoSearchClose'); if(csClose) csClose.onclick=closeSearch;
const csPrev=document.getElementById('convoSearchPrev'); if(csPrev) csPrev.onclick=()=>gotoHit(_searchIdx-1);
const csNext=document.getElementById('convoSearchNext'); if(csNext) csNext.onclick=()=>gotoHit(_searchIdx+1);
const box=document.getElementById('msgs'); if(box) box.addEventListener('click',(e)=>{
const im=e.target.closest('.att-img'); if(im && im.dataset.img){ openLightbox(im.dataset.img); return; }
const po=e.target.closest('.poll-opt'); if(po){ if(!po.disabled) votePoll(po.dataset.poll, +po.dataset.idx); return; }
const pcl=e.target.closest('.poll-close'); if(pcl){ closePoll(pcl.dataset.poll); return; }
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 dl=e.target.closest('.del-btn'); if(dl){ deleteMessage(dl.dataset.del); return; }
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'); if(sb){ const ns=(sb.dataset.seen||'').split('|').filter(Boolean); toast('Seen by: '+ns.join(', ')); return; }
// Tap-to-reveal (mobile): a tap on the bubble body (not an action) reveals its reply/react/delete
// icons; the action only fires on a SECOND tap once they're shown (icons are pointer-events:none
// until revealed). Tapping elsewhere hides them.
const bub=e.target.closest('.bubble'); const already=bub&&bub.classList.contains('show-actions');
box.querySelectorAll('.bubble.show-actions').forEach(b=>b.classList.remove('show-actions'));
if(bub && !already && !bub.classList.contains('deleted')) bub.classList.add('show-actions');
});
if(box) box.addEventListener('scroll', onMsgsScroll);
if(box) enablePullRefresh(box, reloadThread); // pull down at the top to refresh the conversation
const jl=document.getElementById('jumpLatest'); if(jl) jl.onclick=()=>{ const b=document.getElementById('msgs'); if(b) b.scrollTop=b.scrollHeight; };
composeMentions=new Map(); convoMembers=[];
// Synchronous render from cache (within the click's activation → paints immediately, even
@@ -1269,6 +1574,13 @@ async function openConvo(kind,id){
THREAD=Array.isArray(msgs)?msgs:[];
THREAD_CACHE.set(ckey, THREAD.slice());
renderThread();
// #3: if there were unread messages, drop a "New messages" divider, scroll to the first unread,
// and show the "jump to newest" arrow so the user can return to the bottom.
if(_unreadN>0 && THREAD.length>=_unreadN){
const fu=THREAD[THREAD.length-_unreadN]; const esc=(window.CSS&&CSS.escape)?CSS.escape(fu.id):fu.id;
const el=document.querySelector('#msgs .bubble[data-id="'+esc+'"]') || document.querySelector('#msgs [data-id="'+esc+'"]');
if(el){ const sep=document.createElement('div'); sep.className='new-sep'; sep.innerHTML='<span>New messages</span>'; el.parentNode.insertBefore(sep, el); el.scrollIntoView({block:'start'}); el.parentNode.scrollTop-=40; const jl=document.getElementById('jumpLatest'); if(jl) jl.style.display='grid'; }
}
const inp=document.getElementById('msgInput'); if(inp) inp.focus();
}
// ---------- @mentions (group chat) ----------
@@ -1426,7 +1738,7 @@ async function selectChat(kind,id){
ensureNotifyPermission();
selected={kind,id};
document.body.classList.add('chat-open'); // mobile: show the conversation pane
const it=rowFor(kind,id); if(it) it.unread=0;
const it=rowFor(kind,id); _openUnread=(it&&it.unread)||0; if(it) it.unread=0; // capture before reset (#3: open at first unread)
renderChats(searchVal());
updateRailUnread();
await openConvo(kind,id);
@@ -1451,10 +1763,34 @@ async function sendMessage(){
renderChats(searchVal());
}catch(e){ inp.value=text; toast(e.message||'Could not send'); }
}
function ensureNotifyPermission(){ try{ if('Notification' in window && Notification.permission==='default') Notification.requestPermission(); }catch(_){} }
// Notification preferences (set in Dashboard → Settings; stored per browser). Default ON.
// Request permission from a user gesture (e.g. opening a chat) AND subscribe on grant — the
// subscribe-on-grant is essential on iOS, where permission is granted in-session and push
// won't work until a subscription exists.
function ensureNotifyPermission(){ try{ if('Notification' in window && Notification.permission==='default') Notification.requestPermission().then(r=>{ if(r==='granted'){ try{ subscribePush(); }catch(_){} } }).catch(()=>{}); }catch(_){} }
// Notification preferences (set in Settings; stored per browser). Default ON.
function notifOn(kind){ try{ return localStorage.getItem('notif_'+kind)!=='off'; }catch(_){ return true; } }
document.addEventListener('click', ensureNotifyPermission, { once:true });
// Mobile has no top bar (#7): keep bell+profile in the chat-list header; desktop keeps them in
// the top header. Re-placed on load and on resize. Moving the node preserves its wiring.
function placeHdrRight(){
const hr=document.getElementById('hdrRight'); if(!hr) return;
// Desktop: bell+profile sit at the BOTTOM of the left rail (a clear, persistent spot).
// Mobile: top-right of the chat-list header (the main screen).
if(window.matchMedia('(max-width:760px)').matches){ const st=document.querySelector('#chatcol .side-title'); if(st && hr.parentElement!==st) st.appendChild(hr); }
else { const rail=document.getElementById('rail'); if(rail && hr.parentElement!==rail) rail.appendChild(hr); }
}
// Explicit, dismissible prompt when permission is still 'default'. This is the reliable path on
// iOS (permission must be requested from a tap inside the installed PWA).
function maybeNotifPrompt(){
try{
if(!('Notification' in window) || Notification.permission!=='default') return;
if(sessionStorage.getItem('notifPromptDismissed')==='1' || document.getElementById('notifPrompt')) return;
const b=document.createElement('div'); b.id='notifPrompt'; b.className='notif-prompt';
b.innerHTML='<span>'+ic('bell',16)+' Turn on notifications so you don’t miss messages &amp; calls.</span><span class="np-actions"><button class="btn sm" id="npEnable">Enable</button><button class="np-x" id="npX" aria-label="Dismiss">'+ic('x',16)+'</button></span>';
document.body.appendChild(b);
document.getElementById('npEnable').onclick=async()=>{ try{ const r=await Notification.requestPermission(); if(r==='granted'){ toast('Notifications enabled'); await subscribePush(); } else { toast('Notifications blocked — allow them in your browser/site settings'); } }catch(_){ toast('Notifications need HTTPS'); } b.remove(); };
document.getElementById('npX').onclick=()=>{ try{ sessionStorage.setItem('notifPromptDismissed','1'); }catch(_){} b.remove(); };
}catch(_){}
}
// ----- Web Push: reliable notifications when the tab is backgrounded / frozen / closed -----
// A notification-only service worker (sw.js, no caching) shows OS popups via the push service.
// pushActive => the page can leave hidden-tab popups to push (avoids double-notifying).
@@ -1481,6 +1817,16 @@ async function subscribePush(){
pushActive=true; console.log('[push] subscribed OK');
}catch(e){ console.warn('[push] subscribe failed:', e); } // surfaced (not swallowed) so we can see the real reason
}
// On logout, remove this device's push subscription so notifications STOP for the logged-out user.
async function unsubscribePush(){
try{
if(!_swReg){ try{ _swReg=await navigator.serviceWorker.getRegistration(); }catch(_){} }
if(!_swReg) return;
const sub=await _swReg.pushManager.getSubscription();
if(sub){ try{ await postJSON('/api/push/unsubscribe',{endpoint:sub.endpoint}); }catch(_){} try{ await sub.unsubscribe(); }catch(_){} }
pushActive=false; console.log('[push] unsubscribed (logout)');
}catch(_){}
}
// Open the chat from an in-page notification. Navigation reliably repaints across browsers (a
// notification click is not an in-page gesture, so an in-place open won't paint until you
// tap). The reload is made fast by HTTP caching + a boot fast-path that opens the chat first.
@@ -1493,14 +1839,16 @@ function notify(title, body, kind, id){
}catch(_){}
}
let _audioCtx=null;
// Message chime: a crisp, recognizable rising two-note ("ti-doo") — louder + brighter than the
// old single beep so it's catchy and noticed.
function playPing(){
try{
_audioCtx=_audioCtx||new (window.AudioContext||window.webkitAudioContext)();
if(_audioCtx.state==='suspended') _audioCtx.resume();
const t=_audioCtx.currentTime, o=_audioCtx.createOscillator(), g=_audioCtx.createGain();
o.type='sine'; o.frequency.setValueAtTime(880,t); o.frequency.setValueAtTime(660,t+0.09);
g.gain.setValueAtTime(0.0001,t); g.gain.exponentialRampToValueAtTime(0.14,t+0.012); g.gain.exponentialRampToValueAtTime(0.0001,t+0.35);
o.connect(g); g.connect(_audioCtx.destination); o.start(t); o.stop(t+0.36);
const t=_audioCtx.currentTime;
const tone=(f,start,dur,peak)=>{ const o=_audioCtx.createOscillator(), g=_audioCtx.createGain(); o.type='triangle'; o.frequency.value=f; g.gain.setValueAtTime(0.0001,t+start); g.gain.exponentialRampToValueAtTime(peak,t+start+0.015); g.gain.exponentialRampToValueAtTime(0.0001,t+start+dur); o.connect(g); g.connect(_audioCtx.destination); o.start(t+start); o.stop(t+start+dur+0.03); };
tone(784.0, 0.00, 0.16, 0.34); // G5
tone(1174.7, 0.11, 0.40, 0.34); // D6 — rises, rings out -> "ti-doo"
}catch(_){}
}
// Continuous incoming-call ring. A soft, soothing bell chime — a gentle ascending arpeggio
@@ -1521,10 +1869,10 @@ function ringOnce(){
_audioCtx=_audioCtx||new (window.AudioContext||window.webkitAudioContext)();
if(_audioCtx.state==='suspended') _audioCtx.resume();
const t=_audioCtx.currentTime;
ringTone(t, 523.25, 1.2, 0.11); // C5
ringTone(t+0.20, 659.25, 1.2, 0.10); // E5
ringTone(t+0.40, 783.99, 1.5, 0.11); // G5
ringTone(t+0.60, 1046.5, 1.9, 0.09); // C6 — rings out softly
ringTone(t, 523.25, 1.2, 0.24); // C5 (louder so an incoming call is clearly heard)
ringTone(t+0.20, 659.25, 1.2, 0.22); // E5
ringTone(t+0.40, 783.99, 1.5, 0.24); // G5
ringTone(t+0.60, 1046.5, 1.9, 0.20); // C6 — rings out
}catch(_){}
}
function startRing(){ _ringRefs++; if(_ringTimer) return; ringOnce(); _ringTimer=setInterval(ringOnce, 3500); }
@@ -1570,7 +1918,7 @@ function connectChatWs(){
try{
chatWs=new WebSocket((location.protocol==='https:'?'wss://':'ws://')+location.host+'/ws');
chatWs.onopen=()=>{ try{ chatWs.send(JSON.stringify({type:'chat-hello'})); }catch(_){} };
chatWs.onmessage=(e)=>{ let d; try{ d=JSON.parse(e.data); }catch(_){ return; } if(d.type==='chat-message' && d.message) onChatMessage(d.message); else if(d.type==='chat-reaction') onChatReaction(d); else if(d.type==='poll-update' && d.poll) onPollUpdate(d); else if(d.type==='chat-read') onChatRead(d); else if(d.type==='chat-delivered') onChatDelivered(d); else if(d.type==='group-read') onGroupRead(d); else if(d.type==='group-call') onGroupCall(d); else if(d.type==='dm-call') onDmCall(d); else if(d.type==='group-update') onGroupUpdate(d); else if(d.type==='call-invite') showCallInvite(d.room, d.byName); else if(d.type==='meeting-invite') showMeetingInvite(d.meeting); else if(d.type==='meeting-reminder') showMeetingReminder(d.meeting); else if(d.type==='meeting-cancelled') showMeetingCancelled(d.meeting); else if(d.type==='group-role') onGroupRole(d); };
chatWs.onmessage=(e)=>{ let d; try{ d=JSON.parse(e.data); }catch(_){ return; } if(d.type==='chat-message' && d.message) onChatMessage(d.message); else if(d.type==='chat-deleted') onChatDeleted(d); else if(d.type==='chat-reaction') onChatReaction(d); else if(d.type==='poll-update' && d.poll) onPollUpdate(d); else if(d.type==='chat-read') onChatRead(d); else if(d.type==='chat-delivered') onChatDelivered(d); else if(d.type==='group-read') onGroupRead(d); else if(d.type==='group-call') onGroupCall(d); else if(d.type==='dm-call') onDmCall(d); else if(d.type==='group-update') onGroupUpdate(d); else if(d.type==='call-invite') showCallInvite(d.room, d.byName); else if(d.type==='meeting-invite') showMeetingInvite(d.meeting); else if(d.type==='meeting-reminder') showMeetingReminder(d.meeting); else if(d.type==='meeting-cancelled') showMeetingCancelled(d.meeting); else if(d.type==='group-role') onGroupRole(d); };
chatWs.onclose=()=>{ setTimeout(connectChatWs, 3000); }; // auto-reconnect
}catch(_){}
}
@@ -1585,12 +1933,16 @@ function openNewGroup(){
const ov=document.createElement('div'); ov.className='modal-ov'; ov.id='groupModal';
ov.innerHTML='<div class="modal"><h3>New group</h3>'
+ '<input id="grpName" placeholder="Group name" maxlength="80">'
+ '<div class="grp-members">'+(CONTACTS.length?CONTACTS.map(c=>'<label class="chk"><input type="checkbox" value="'+pEsc(c.id)+'"> '+pEsc(c.name)+'</label>').join(''):'<div class="muted" style="padding:.5rem">No teammates to add yet.</div>')+'</div>'
+ (CONTACTS.length?'<div class="gi-search" style="margin:.55rem 0 .35rem"><input id="grpSearch" placeholder="Search people…" autocomplete="off"></div>':'')
+ '<div class="grp-members">'+(CONTACTS.length?CONTACTS.map(c=>'<label class="chk" data-name="'+pEsc((c.name||'').toLowerCase())+'"><input type="checkbox" value="'+pEsc(c.id)+'"> '+pEsc(c.name)+'</label>').join(''):'<div class="muted" style="padding:.5rem">No teammates to add yet.</div>')+'</div>'
+ '<div class="gi-noresult" id="grpNoResult" style="display:none">No people found</div>'
+ '<div class="modal-actions"><button class="gobtn" id="grpCancel" style="background:#eef1f6;color:var(--blue)">Cancel</button><button class="gobtn" id="grpCreate">Create group</button></div></div>';
document.body.appendChild(ov);
ov.onclick=(e)=>{ if(e.target===ov) ov.remove(); };
document.getElementById('grpCancel').onclick=()=>ov.remove();
document.getElementById('grpCreate').onclick=createGroup;
const gs=document.getElementById('grpSearch');
if(gs) gs.oninput=()=>{ const q=gs.value.trim().toLowerCase(); let shown=0; ov.querySelectorAll('.grp-members .chk').forEach(l=>{ const vis=!q||(l.dataset.name||'').includes(q); l.style.display=vis?'':'none'; if(vis) shown++; }); const nr=document.getElementById('grpNoResult'); if(nr) nr.style.display=shown?'none':'block'; };
const n=document.getElementById('grpName'); if(n) n.focus();
}
async function createGroup(){
@@ -1617,7 +1969,7 @@ async function openGroupInfo(gid){
+ '<input type="file" id="giPhotoInput" accept="image/*" style="display:none">'
+ '<div class="gi-head"><button class="gi-photo" id="giPhoto" title="Change group photo"><span class="avatar grp" style="width:46px;height:46px;background:'+avColor(info.name)+'">'+ic('users',24)+(info.avatar?'<img class="av-img" src="'+pEsc(info.avatar)+'" alt="" onerror="this.remove()">':'')+'</span><span class="gi-photo-cam">'+ic('camera',13)+'</span></button>'
+ '<div class="gi-name">'
+ '<div class="gi-name-row" id="giView"><span class="gi-title">'+pEsc(info.name)+'</span><button class="iconbtn sm" id="giEdit" title="Rename group">'+ic('pencil',15)+'</button></div>'
+ '<div class="gi-name-row" id="giView"><span class="gi-title">'+pEsc(info.name)+'</span><button class="fav-star'+(((rowFor('group',gid)||{}).favorite)?' on':'')+'" id="giFav" title="Favourite">'+ic('star',15)+'</button><button class="iconbtn sm" id="giEdit" title="Rename group">'+ic('pencil',15)+'</button></div>'
+ '<div class="gi-edit hidden" id="giEditRow"><input id="giName" value="'+pEsc(info.name)+'" maxlength="80"><button class="iconbtn" id="giSave" title="Save">'+ic('check',18)+'</button><button class="iconbtn" id="giCancelEdit" title="Cancel">'+ic('x',16)+'</button></div>'
+ '<div class="gi-sub">'+info.members.length+' member'+(info.members.length===1?'':'s')+'</div>'
+ '</div>'
@@ -1625,6 +1977,7 @@ async function openGroupInfo(gid){
+ '<div class="gi-actions"><button class="gi-act" id="giCall">'+ic('phone',18)+'<span>Call</span></button><button class="gi-act" id="giSchedule">'+ic('calendar',18)+'<span>Schedule</span></button></div>'
+ (info.createdByName?'<div class="gi-created">'+ic('info',13)+' Created by <b>'+pEsc(info.createdByName)+'</b> on '+pEsc(fmtDateTime(info.createdAt))+'</div>':'')
+ (info.isAdmin?'<label class="gi-setting"><span>'+ic('lock',15)+' Only admins can add or remove people</span><span class="switch"><input type="checkbox" id="giAdminOnly"'+(info.adminOnly?' checked':'')+'><span class="slider"></span></span></label>':'')
+ '<div class="gi-sec-h"><span>Shared</span></div>' + mediaRowHTML()
+ '<div class="gi-sec-h"><span>Members</span>'+(canManage?'<button class="linkbtn" id="giAddToggle">'+ic('userPlus',15)+' Add people</button>':'')+'</div>'
+ '<div class="gi-list">'+info.members.map(m=>'<div class="mrow">'+miniAv(m.name,m.avatar)+'<span class="mn">'+pEsc(m.name)+(m.isMe?' <span class="youtag">you</span>':'')+(m.admin?' <span class="admin-tag">'+ic('crown',11)+' Admin</span>':'')+'</span>'+((info.isAdmin&&!m.isMe)?'<button class="iconbtn role'+(m.admin?' is-admin':'')+'" data-role="'+pEsc(m.id)+'" data-val="'+(m.admin?'0':'1')+'" title="'+(m.admin?'Remove admin':'Make admin')+'">'+ic('crown',15)+'</button>':'')+((canManage&&!m.isMe)?'<button class="iconbtn rm" data-rm="'+pEsc(m.id)+'" title="Remove">'+ic('trash',15)+'</button>':'')+'</div>').join('')+'</div>'
+ '<div class="gi-add hidden" id="giAddWrap">'
@@ -1639,8 +1992,10 @@ async function openGroupInfo(gid){
+ '</div>';
const _old=document.getElementById('groupInfo'); if(_old) _old.remove(); // never stack group-info modals (stale close buttons)
document.body.appendChild(ov);
wireMediaEntry(ov, 'group', gid, info.name); // "Media, links & docs" entry above the members
ov.onclick=(e)=>{ if(e.target===ov) ov.remove(); };
ov.querySelector('#giClose').onclick=()=>ov.remove();
{ const gf=ov.querySelector('#giFav'); if(gf) gf.onclick=async()=>{ const r=rowFor('group',gid); const on=!(r&&r.favorite); if(r) r.favorite=on; gf.classList.toggle('on',on); gf.title=on?'Remove from favourites':'Add to favourites'; try{ await postJSON('/api/favorites',{kind:'group',id:gid,on}); }catch(_){} renderChats(searchVal()); }; }
const refresh=async()=>{ await loadSidebar(); if(selected&&selected.kind==='group'&&selected.id===gid) openConvo('group',gid); };
const view=ov.querySelector('#giView'), editRow=ov.querySelector('#giEditRow'), nameInp=ov.querySelector('#giName'), closeBtn=ov.querySelector('#giClose');
document.getElementById('giEdit').onclick=()=>{ view.classList.add('hidden'); editRow.classList.remove('hidden'); closeBtn.style.display='none'; nameInp.focus(); nameInp.select(); };
@@ -1698,6 +2053,10 @@ let meetAudioOnly=false; // audio call (no camera) — tiles show avatars ins
const meetPeers=new Map(); // peerId -> { pc, name }
const meetMuted=new Map(); // peerId|'__local' -> muted (for the tile mic-off badge)
const meetNames=new Map(); // peerId -> name (peers that arrive before their offer)
const meetAvatars=new Map(); // peerId -> avatar URL (for participant-tile profile pics)
const meetPeerUids=new Map();// peerId -> user id (to tell when an invitee has joined)
const meetCamOff=new Map(); // peerId -> camera off? (a disabled remote track still arrives, so show the avatar)
const meetInvited=new Map(); // user id -> {name, timer} for invitees who haven't joined yet (#6)
let meetReturn=null; // {kind:'dm'|'group', id} — chat to land on when the call ends (null = meetings tab)
const meetVU=new Map(); // peerId|'__local' -> {ctx,analyser,data,raf} active-speaker meters
let MEET_ICE={ iceServers:[{ urls:'stun:stun.l.google.com:19302' }] };
@@ -1919,14 +2278,15 @@ function addTile(id, stream, label, muted){
const grid=document.getElementById('meetGrid'); if(!grid) return;
let tile=document.getElementById('meet-tile-'+id);
if(!tile){ tile=document.createElement('div'); tile.className='meet-tile'; tile.id='meet-tile-'+id;
tile.innerHTML='<video autoplay playsinline'+(muted?' muted':'')+'></video><div class="meet-av" style="background:'+avColor(label||'?')+'">'+pEsc(initials(label||'?'))+'</div><div class="meet-mute" style="display:none">'+ic('micOff',14)+'</div><span class="nm">'+pEsc(label||'')+'</span>'; grid.appendChild(tile); }
const av=(id==='__local')?((ME&&ME.avatarUrl)||null):(meetAvatars.get(id)||null); // profile pic on the tile
tile.innerHTML='<video autoplay playsinline'+(muted?' muted':'')+'></video><div class="meet-av" style="background:'+avColor(label||'?')+'">'+pEsc(initials(label||'?'))+(av?'<img src="'+pEsc(av)+'" alt="" onerror="this.remove()">':'')+'</div><div class="meet-mute" style="display:none">'+ic('micOff',14)+'</div><span class="nm">'+pEsc(label||'')+'</span>'; grid.appendChild(tile); }
const v=tile.querySelector('video'); if(v && stream && v.srcObject!==stream) v.srcObject=stream;
const hasVid=!!(stream && stream.getVideoTracks && stream.getVideoTracks().some(t=>t.enabled && t.readyState!=='ended'));
tile.classList.toggle('novid', !hasVid);
tile.classList.toggle('novid', !hasVid || meetCamOff.get(id)===true); // camOff peer → avatar even if a (disabled) track arrives
if(meetMuted.has(id)) setTileMute(id, meetMuted.get(id)); // apply any known mute state
}
function setTileMute(id, muted){ meetMuted.set(id, !!muted); const t=document.getElementById('meet-tile-'+id); if(t){ const b=t.querySelector('.meet-mute'); if(b) b.style.display=muted?'grid':'none'; if(muted) t.classList.remove('speaking'); } }
function removeTile(id){ const t=document.getElementById('meet-tile-'+id); if(t) t.remove(); meetMuted.delete(id); meetUnwatch(id); }
function removeTile(id){ const t=document.getElementById('meet-tile-'+id); if(t) t.remove(); meetMuted.delete(id); meetCamOff.delete(id); meetUnwatch(id); }
// ---- Active-speaker meter: highlight a tile while its audio is above a threshold ----
function meetWatchStream(id, stream){
if(!stream || !stream.getAudioTracks || !stream.getAudioTracks().length) return; // no audio yet
@@ -2095,7 +2455,7 @@ async function onMeetMsg(e){
meetWatchStream('__local', meetLocalStream); // active-speaker detection on my own mic
// Existing peers OFFER to me (their offers carry their tracks incl. any active screen share);
// I just set up the connections and wait. Avoids the "newcomer can't receive screen" bug.
for(const p of (m.peers||[])){ meetNames.set(p.peerId,p.name); meetMakePeer(p.peerId,p.name); }
for(const p of (m.peers||[])){ meetNames.set(p.peerId,p.name); if(p.avatar) meetAvatars.set(p.peerId,p.avatar); if(p.uid){ meetPeerUids.set(p.peerId,p.uid); meetInviteJoined(p.uid); } meetMakePeer(p.peerId,p.name); }
meetSend({type:'meeting-state', muted:!meetMic, camOff:!meetCam}); // tell existing peers my state
if(meetIsHost) meetSend({type:'meeting-host', to:meetMyId}); // announce host so others know
refreshMeetPanel(); updateHostControls();
@@ -2103,7 +2463,7 @@ async function onMeetMsg(e){
}
if(m.type==='meeting-ended'){ toast('Call ended'); leaveMeeting(); return; } // 1:1 hangup, or host ended
if(m.type==='meeting-peer-joined'){
meetNames.set(m.peerId,m.name);
meetNames.set(m.peerId,m.name); if(m.avatar) meetAvatars.set(m.peerId,m.avatar); if(m.uid){ meetPeerUids.set(m.peerId,m.uid); meetInviteJoined(m.uid); }
const pc=meetMakePeer(m.peerId,m.name); // I'm an existing peer → I OFFER to the newcomer (carries my screen)
try{ const offer=await pc.createOffer(); await pc.setLocalDescription(offer); meetSend({type:'meeting-signal',to:m.peerId,data:{sdp:pc.localDescription}}); }catch(_){}
meetSend({type:'meeting-state', muted:!meetMic, camOff:!meetCam});
@@ -2111,7 +2471,7 @@ async function onMeetMsg(e){
if(meetScreen) meetSend({type:'meeting-screen', on:true});
refreshMeetPanel(); return;
}
if(m.type==='meeting-peer-state'){ setTileMute(m.peerId, !!m.muted); refreshMeetPanel(); return; }
if(m.type==='meeting-peer-state'){ setTileMute(m.peerId, !!m.muted); meetCamOff.set(m.peerId, !!m.camOff); const _t=document.getElementById('meet-tile-'+m.peerId); if(_t){ const v=_t.querySelector('video'), s=v&&v.srcObject; const hv=!!(s&&s.getVideoTracks&&s.getVideoTracks().some(tr=>tr.enabled&&tr.readyState!=='ended')); _t.classList.toggle('novid', !hv || !!m.camOff); } refreshMeetPanel(); return; } // camOff -> show avatar, not a black tile
if(m.type==='meeting-host'){ const was=meetIsHost; meetHostId=m.hostPeerId; meetIsHost=(m.hostPeerId===meetMyId); if(meetIsHost&&!was){ toast('You are now the host'); try{ speak('You are now the host'); }catch(_){} } refreshMeetPanel(); updateHostControls(); return; }
if(m.type==='meeting-transcribe-state'){ applyRoomTx(!!m.active); return; } // ≥1 subscriber → transcribe my mic
if(m.type==='meeting-recording'){ recNotice(!!m.on, m.by); return; } // someone is recording — show + announce
@@ -2183,7 +2543,7 @@ function leaveMeeting(){
if(_recTimer){ clearInterval(_recTimer); _recTimer=null; } const _rn=document.getElementById('recNotice'); if(_rn) _rn.remove(); // clear any "Recording" badge
meetSend({type:'meeting-leave'});
meetUnwatchAll(); meetSharers.clear();
meetPeers.forEach(p=>{ try{p.pc.close();}catch(_){} }); meetPeers.clear(); meetNames.clear(); meetMuted.clear();
meetPeers.forEach(p=>{ try{p.pc.close();}catch(_){} }); meetPeers.clear(); meetNames.clear(); meetAvatars.clear(); meetPeerUids.clear(); meetCamOff.clear(); meetInvited.forEach(e=>{ if(e.timer) clearTimeout(e.timer); }); meetInvited.clear(); meetMuted.clear();
if(meetLocalStream){ try{ meetLocalStream.getTracks().forEach(t=>t.stop()); }catch(_){} meetLocalStream=null; }
if(meetWs){ try{ meetWs.close(); }catch(_){} meetWs=null; }
meetRoom=null; meetMyId=null; meetState='idle'; meetIsHost=false; meetHostId=null; meetRailLive(false);
@@ -2221,7 +2581,24 @@ railBtns.forEach(btn=>{ btn.onclick=()=>{
switchTab(tab);
}; });
// Esc clears the open conversation and brings back the welcome screen.
document.addEventListener('keydown',(e)=>{ if(e.key==='Escape' && currentTab()==='chat' && selected!=null){ showWelcome(); } });
// Esc closes the topmost popup/search FIRST (capture phase, before the conversation-close below).
document.addEventListener('keydown',(e)=>{
if(e.key!=='Escape') return;
const ovs=document.querySelectorAll('.modal-ov');
if(ovs.length){ ovs[ovs.length-1].remove(); e.preventDefault(); e.stopPropagation(); return; }
const sh=document.getElementById('convoSearchHead'); if(sh && sh.style.display!=='none'){ closeSearch(); e.preventDefault(); e.stopPropagation(); return; }
}, true);
document.addEventListener('keydown',(e)=>{ if(e.key==='Escape' && !document.querySelector('.modal-ov') && currentTab()==='chat' && selected!=null){ showWelcome(); } });
// #10: the device/browser Back button closes the topmost layer (popup → search → open chat on
// mobile) instead of leaving the app. We seed a history entry and re-seed after each handled back.
function bzcBack(){
const ovs=document.querySelectorAll('.modal-ov'); if(ovs.length){ ovs[ovs.length-1].remove(); return true; }
const sh=document.getElementById('convoSearchHead'); if(sh && sh.style.display!=='none'){ closeSearch(); return true; }
if(window.matchMedia('(max-width:760px)').matches && selected){ showWelcome(); return true; }
return false;
}
try{ history.pushState(null,'',location.href); }catch(_){}
window.addEventListener('popstate', ()=>{ if(bzcBack()){ try{ history.pushState(null,'',location.href); }catch(_){} } });
// Hamburger: on mobile it slides the rail drawer in/out; on desktop it collapses the rail.
function toggleNav(){ if(window.innerWidth<=760) document.body.classList.toggle('rail-open'); else document.body.classList.toggle('rail-hidden'); }
const _navT=document.getElementById('navToggle'); if(_navT) _navT.onclick=toggleNav;
@@ -2264,7 +2641,7 @@ async function renderLogin(){
const aw=document.getElementById('authwrap'); aw.style.display='flex';
let regOpen=false; try{ regOpen=(await (await fetch('/api/setup-state')).json()).registrationOpen; }catch(_){}
aw.innerHTML=`<div class="authcard">
<h1>Welcome to BizGaze Connect</h1>
<h1>Welcome to Biz Connect</h1>
<div class="sub">Sign in to access chats, screen share and connect.</div>
${regOpen?`<div class="authtabs">
<button id="tabLogin" class="active">Sign in</button>
@@ -2278,7 +2655,7 @@ async function renderLogin(){
<p id="li_err" class="formerr"></p>
</div>
${regOpen?`<div id="regForm" class="hidden">
<span class="lbl">Team name</span><input id="rg_team" placeholder="e.g. BizGaze Support">
<span class="lbl">Team name</span><input id="rg_team" placeholder="e.g. Acme Inc">
<span class="lbl">Email</span><input id="rg_email" type="email" placeholder="you@bizgaze.com">
<span class="lbl">Password</span>${pwField('rg_pw','min 8 characters')}
<button class="gobtn" id="rg_btn">Create team</button>
@@ -2321,8 +2698,11 @@ async function doRegister(){
ME=me;
document.getElementById('hdrRight').innerHTML=bellHTML()+profileHTML(me);
loadNotifs(); wireBell(); wireProfile();
placeHdrRight(); window.addEventListener('resize', placeHdrRight); // mobile: bell+profile live in the chat-list header (no top bar)
connectChatWs();
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)
document.getElementById('loading').style.display='none';
// Fast-path: when opened from a notification, show the chat immediately (only needs the
// thread fetch) and load the sidebar in the background — don't make the reload wait on it.
+8
파일 보기
@@ -6,7 +6,12 @@
screenShare: '<path d="M13 3H4a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-3"/><path d="M8 21h8"/><path d="M12 17v4"/><path d="m17 8 5-5"/><path d="M17 3h5v5"/>',
wifi: '<path d="M12 20h.01"/><path d="M8.5 16.4a5 5 0 0 1 7 0"/><path d="M5 12.9a10 10 0 0 1 14 0"/><path d="M2 8.8a15 15 0 0 1 20 0"/>',
video: '<path d="m22 8-6 4 6 4V8Z"/><rect width="14" height="12" x="2" y="6" rx="2" ry="2"/>',
videoOff: '<path d="M10.66 6H14a2 2 0 0 1 2 2v2.34l1 1L22 8v8"/><path d="M16 16a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h2l10 10Z"/><line x1="2" x2="22" y1="2" y2="22"/>',
image: '<rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/>',
folder: '<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/>',
paperclip: '<path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"/>',
headphones: '<path d="M3 14h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-7a9 9 0 0 1 18 0v7a2 2 0 0 1-2 2h-1a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2h3"/>',
play: '<polygon points="6 3 20 12 6 21 6 3"/>',
smile: '<circle cx="12" cy="12" r="10"/><path d="M8 14s1.5 2 4 2 4-2 4-2"/><line x1="9" x2="9.01" y1="9" y2="9"/><line x1="15" x2="15.01" y1="9" y2="9"/>',
smilePlus: '<path d="M22 11v1a10 10 0 1 1-9-10"/><path d="M8 14s1.5 2 4 2 4-2 4-2"/><line x1="9" x2="9.01" y1="9" y2="9"/><line x1="15" x2="15.01" y1="9" y2="9"/><path d="M16 5h6"/><path d="M19 2v6"/>',
reply: '<polyline points="9 14 4 9 9 4"/><path d="M20 20v-7a4 4 0 0 0-4-4H4"/>',
@@ -34,6 +39,9 @@
calendar: '<path d="M8 2v4"/><path d="M16 2v4"/><rect width="18" height="18" x="3" y="4" rx="2"/><path d="M3 10h18"/>',
pencil: '<path d="M21.17 6.83a2.83 2.83 0 0 0-4-4L3.84 16.17a2 2 0 0 0-.5.83l-1.32 4.35a.5.5 0 0 0 .62.62l4.35-1.32a2 2 0 0 0 .83-.5z"/><path d="m15 5 4 4"/>',
chevronDown: '<path d="m6 9 6 6 6-6"/>',
chevronUp: '<path d="m18 15-6-6-6 6"/>',
star: '<path d="M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z"/>',
link: '<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>',
layoutDashboard:'<rect width="7" height="9" x="3" y="3" rx="1"/><rect width="7" height="5" x="14" y="3" rx="1"/><rect width="7" height="9" x="14" y="12" rx="1"/><rect width="7" height="5" x="3" y="16" rx="1"/>',
arrowRight: '<path d="M5 12h14"/><path d="m12 5 7 7-7 7"/>',
alertTriangle:'<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3z"/><path d="M12 9v4"/><path d="M12 17h.01"/>',
+13 -15
파일 보기
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>BizGaze Support</title>
<title>Biz Connect</title>
<style>
:root{ --brand:#FFC708; --brand-d:#E0AC00; --blue:#1F3B73; --blue-d:#16294f; --blue-soft:#EAF0FB; --ink:#1f2430; --muted:#6b7280; --bg:#f6f8fb; --line:#e6e9ef; }
*{box-sizing:border-box;}
@@ -47,24 +47,26 @@
<header>
<div class="brandrow">
<img src="/logo.png" alt="" style="height:40px;width:auto;max-width:170px;border-radius:8px;object-fit:contain;background:#fff;padding:4px 10px" onerror="this.style.display='none'">
<div class="brand">BizGaze <span>Support</span></div>
<div class="brand">Biz <span>Connect</span></div>
</div>
<div id="authArea"></div>
</header>
<div class="wrap">
<div class="inner">
<h1>Welcome to BizGaze Connect</h1>
<h1>Welcome to Biz Connect</h1>
<div class="sub">Chat, meetings and secure remote support — for the BizGaze ecosystem.</div>
<!-- Stub SSO: routes to staff login for now; swap href to /sso once BizGaze SSO is wired. -->
<a class="ssobtn" id="ssoBtn" href="/home"><span class="bmark">B</span> Log in with BizGaze</a>
<div class="divider">need support? no account required</div>
<!-- Customer path FIRST (no account needed): share your screen for support. -->
<div class="divider">Need support? — no account needed</div>
<div class="choices" style="max-width:400px;margin:0 auto">
<a class="choice" href="/share">
<div class="icon share"><svg viewBox="0 0 24 24" fill="none" stroke="#BA7515" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg></div>
<div><h3>Share my screen</h3><p>Get a one-time code and show your screen to a BizGaze support agent — no login, no download.</p></div>
<div><h3>Share my screen</h3><p>Get a one-time code and show your screen to a Biz Connect support agent — no login, no download.</p></div>
</a>
</div>
<div class="foot"><span data-ic="lock" data-sz="14"></span> Screen sharing only starts after you approve it, and can be stopped anytime.</div>
<div class="foot" style="margin:.7rem 0 0"><span data-ic="lock" data-sz="14"></span> Screen sharing only starts after you approve it, and can be stopped anytime.</div>
<!-- Team member path BELOW: log in to the full app. Stub SSO -> /home for now. -->
<div class="divider" style="margin-top:1.6rem">BizGaze team member?</div>
<a class="ssobtn" id="ssoBtn" href="/home"><span class="bmark">B</span> Log in with BizGaze</a>
</div>
</div>
<footer>© BizGaze · Remote Support</footer>
@@ -74,13 +76,9 @@ function profileHTML(name){return '<div class="profile"><button class="pbtn" id=
function wireProfile(){const btn=document.getElementById('pbtn'),menu=document.getElementById('pmenu');if(!btn)return;btn.onclick=(e)=>{e.stopPropagation();menu.classList.toggle('open');};document.addEventListener('click',()=>menu.classList.remove('open'));const lo=document.getElementById('plogout');if(lo)lo.onclick=async()=>{try{await fetch('/api/logout',{method:'POST'});}catch(_){}location.href='/';};}
function makeBrandClickable(){document.querySelectorAll('.brandrow,.wordmark').forEach(el=>{el.style.cursor='pointer';el.addEventListener('click',()=>{location.href='/';});});}
makeBrandClickable();
(async function(){try{const r=await fetch('/api/me');if(r.ok){const me=await r.json();
document.getElementById('authArea').innerHTML=profileHTML(me.name||me.email);wireProfile();
// Already signed in: swap the login CTA for an "enter app" CTA.
const b=document.getElementById('ssoBtn'); if(b){ b.innerHTML='Open BizGaze Connect &rarr;'; b.href='/home'; }
const h=document.querySelector('.inner h1'); if(h){ const fn=String(me.name||'').trim().split(/\s+/)[0]; h.textContent='Welcome back'+(fn?', '+fn:'')+'!'; }
const dv=document.querySelector('.divider'); if(dv) dv.textContent='need to help someone? share your screen';
}}catch(_){}})();
// Already signed in -> skip this landing entirely and go straight to the app (no redundant
// "Open Biz Connect" page). This landing only shows when logged out.
(async function(){try{const r=await fetch('/api/me');if(r.ok){ location.replace('/home'); return; }}catch(_){}})();
</script>
<script>(function(){var s=document.createElement('style');s.textContent='.ic{display:inline-block;vertical-align:middle}';document.head.appendChild(s);document.querySelectorAll('[data-ic]').forEach(function(e){e.insertAdjacentHTML('afterbegin',window.ic(e.getAttribute('data-ic'),+e.getAttribute('data-sz')||16));});})();</script>
</body>
+1 -1
파일 보기
@@ -1,5 +1,5 @@
{
"name": "BizGaze Connect",
"name": "Biz Connect",
"short_name": "Connect",
"description": "Chat, screen share, and video meetings for the BizGaze ecosystem.",
"start_url": "/home",
+2 -2
파일 보기
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>BizGaze Support — Share your screen</title>
<title>Biz Connect — Share your screen</title>
<style>
:root{ --brand:#FFC708; --brand-d:#E0AC00; --blue:#1F3B73; --blue-d:#16294f; --blue-soft:#EAF0FB; --ink:#1f2430; --muted:#6b7280; --bg:#f6f8fb; --card:#ffffff; --line:#e6e9ef; }
*{box-sizing:border-box;}
@@ -55,7 +55,7 @@
<div class="stage">
<div class="brandpanel">
<img src="/logo.png" alt="" style="width:88px;height:88px;border-radius:22px;object-fit:contain;margin-bottom:1.2rem;background:#fff;padding:8px;box-shadow:0 12px 30px rgba(0,0,0,.25)" onerror="this.replaceWith(Object.assign(document.createElement('div'),{className:'mark',textContent:'B'}))">
<div class="wordmark">BizGaze <span>Support</span></div>
<div class="wordmark">Biz <span>Connect</span></div>
<div class="tagline">Secure, instant remote support — no downloads, you stay in control.</div>
</div>
<div class="panelside">
+2 -2
파일 보기
@@ -1,4 +1,4 @@
// BizGaze Connect service worker — NOTIFICATIONS ONLY.
// Biz Connect service worker — NOTIFICATIONS ONLY.
// Intentionally has NO 'fetch' handler and NO caching, so it can never serve a stale
// version of the app. Its only job is to show push notifications when the page is in
// the background / frozen / closed, and to open the right chat when one is clicked.
@@ -12,7 +12,7 @@ self.addEventListener('fetch', () => {});
self.addEventListener('push', (event) => {
let d = {};
try { d = event.data ? event.data.json() : {}; } catch (_) {}
const title = d.title || 'BizGaze Connect';
const title = d.title || 'Biz Connect';
const options = {
body: d.body || '',
icon: '/logo.png',
+16 -1
파일 보기
@@ -41,6 +41,7 @@ const users = {
setPassword: (id, hash, salt) => db.prepare('UPDATE users SET pw_hash=?, pw_salt=? WHERE id=?').run(hash, salt, id),
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),
remove: (id) => db.prepare('DELETE FROM users WHERE id=?').run(id),
};
@@ -141,6 +142,13 @@ const messages = {
byAttachment: (attachmentId) => db.prepare('SELECT * FROM messages WHERE attachment_id=? LIMIT 1').get(attachmentId),
setPoll: (messageId, pollId) => db.prepare('UPDATE messages SET poll_id=? WHERE id=?').run(pollId, messageId),
markDelivered: (id) => db.prepare('UPDATE messages SET delivered_at=? WHERE id=? AND delivered_at IS NULL').run(now(), id),
// Delete-for-everyone: clear the content but keep the row (renders as a placeholder).
markDeleted: (id) => db.prepare("UPDATE messages SET deleted=1, body='', attachment_id=NULL, poll_id=NULL WHERE id=?").run(id),
// Shared media/files in a conversation (group) or DM — newest first.
attachmentsForConversation: (teamId, conversationId) => db.prepare(`SELECT a.id, a.name, a.mime, a.size, m.created_at FROM messages m JOIN attachments a ON a.id=m.attachment_id WHERE m.team_id=? AND m.conversation_id=? AND m.deleted=0 ORDER BY m.created_at DESC`).all(teamId, conversationId),
attachmentsForDm: (teamId, a, b) => db.prepare(`SELECT at.id, at.name, at.mime, at.size, m.created_at FROM messages m JOIN attachments at ON at.id=m.attachment_id WHERE m.team_id=? AND m.conversation_id IS NULL AND ((m.sender_id=? AND m.recipient_id=?) OR (m.sender_id=? AND m.recipient_id=?)) AND m.deleted=0 ORDER BY m.created_at DESC`).all(teamId, a, b, b, a),
linksForConversation: (teamId, conversationId) => db.prepare("SELECT body, created_at FROM messages WHERE team_id=? AND conversation_id=? AND deleted=0 AND body LIKE '%http%' ORDER BY created_at DESC").all(teamId, conversationId),
linksForDm: (teamId, a, b) => db.prepare("SELECT body, created_at FROM messages WHERE team_id=? AND conversation_id IS NULL AND ((sender_id=? AND recipient_id=?) OR (sender_id=? AND recipient_id=?)) AND deleted=0 AND body LIKE '%http%' ORDER BY created_at DESC").all(teamId, a, b, b, a),
// Full 1:1 (DM) thread between two users (both directions), oldest first.
thread: (teamId, a, b, limit = 300) =>
db.prepare(`SELECT * FROM messages WHERE team_id=? AND conversation_id IS NULL
@@ -275,4 +283,11 @@ const pushSubs = {
removeByEndpoint: (endpoint) => db.prepare('DELETE FROM push_subscriptions WHERE endpoint=?').run(endpoint),
};
module.exports = { teams, users, authSessions, machines, audit, sessionsLog, refreshTokens, apiKeys, webhooks, messages, reactions, attachments, conversations, scheduledMeetings, recordings, polls, pollVotes, pushSubs };
const favorites = {
set: (userId, target, on) => on
? db.prepare('INSERT OR IGNORE INTO favorites (user_id,target,created_at) VALUES (?,?,?)').run(userId, target, now())
: db.prepare('DELETE FROM favorites WHERE user_id=? AND target=?').run(userId, target),
forUser: (userId) => db.prepare('SELECT target FROM favorites WHERE user_id=?').all(userId).map((r) => r.target),
};
module.exports = { teams, users, authSessions, machines, audit, sessionsLog, refreshTokens, apiKeys, webhooks, messages, reactions, attachments, conversations, scheduledMeetings, recordings, polls, pollVotes, pushSubs, favorites };
+58 -5
파일 보기
@@ -11,7 +11,7 @@ const PUSH = require('./push');
const MSG_MAX = 4000;
const parseMentions = (s) => { if (!s) return []; try { const a = JSON.parse(s); return Array.isArray(a) ? a : []; } catch { return []; } };
const SYSTEM_SENDER = '__system__';
const msgDTO = (m) => ({ id: m.id, from: m.sender_id, to: m.recipient_id, conversation_id: m.conversation_id || null, body: m.body, created_at: m.created_at, read_at: m.read_at, delivered_at: m.delivered_at || null, reply_to: m.reply_to || null, mentions: parseMentions(m.mentions), evt: m.msg_type || null, system: m.sender_id === SYSTEM_SENDER || !!m.msg_type });
const msgDTO = (m) => ({ id: m.id, from: m.sender_id, to: m.recipient_id, conversation_id: m.conversation_id || null, body: m.deleted ? '' : m.body, created_at: m.created_at, read_at: m.read_at, delivered_at: m.delivered_at || null, reply_to: m.deleted ? null : (m.reply_to || null), mentions: parseMentions(m.mentions), evt: m.msg_type || null, deleted: !!m.deleted, system: m.sender_id === SYSTEM_SENDER || !!m.msg_type });
function namesFor(teamId){ const o = {}; for (const x of R.users.listByTenant(teamId)) o[x.id] = x.name || x.email; return o; }
// Next future occurrence (same time-of-day) of a weekly-recurring meeting; searches 14 days ahead.
function nextOccurrence(baseTs, days, nowTs){ const b = new Date(baseTs); const hh = b.getHours(), mm = b.getMinutes(); const s = new Date(nowTs); for (let i = 0; i <= 14; i++){ const d = new Date(s.getFullYear(), s.getMonth(), s.getDate() + i, hh, mm, 0, 0); if (days.indexOf(d.getDay()) >= 0 && d.getTime() > nowTs) return d.getTime(); } return baseTs; }
@@ -265,7 +265,16 @@ route('GET', '/api/ice', async (req, res) => {
route('GET', '/api/me', async (req, res) => {
const u = currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' });
json(res, 200, { id: u.id, email: u.email, role: u.role, teamId: u.team_id, name: u.name || null, avatarUrl: u.avatar_url || null });
json(res, 200, { id: u.id, email: u.email, role: u.role, teamId: u.team_id, name: u.name || null, avatarUrl: u.avatar_url || null, status: u.status || 'active' });
});
// Set my presence status: 'active' | 'away' | 'onleave' ('incall' is derived, not settable).
route('POST', '/api/me/status', async (req, res) => {
const u = currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' });
const { status } = await readBody(req);
if (!['active', 'away', 'onleave'].includes(status)) return json(res, 400, { error: 'invalid status' });
try { R.users.setStatus(u.id, status); } catch (_) {}
json(res, 200, { ok: true, status });
});
// --- Web Push: background/closed-tab notifications (no-op unless VAPID is configured) ---
@@ -619,7 +628,11 @@ route('GET', '/api/messages/conversations', async (req, res) => {
if (!u) return json(res, 401, { error: 'unauthorized' });
const names = {};
const avatars = {};
for (const x of R.users.listByTenant(u.team_id)) { names[x.id] = x.name || x.email; avatars[x.id] = x.avatar_url || null; }
const statuses = {};
for (const x of R.users.listByTenant(u.team_id)) { names[x.id] = x.name || x.email; avatars[x.id] = x.avatar_url || null; statuses[x.id] = x.status || 'active'; }
const favs = new Set(R.favorites.forUser(u.id));
const inCall = new Set();
for (const [, peers] of meetingRooms) { for (const [, p] of peers) { if (p.ws && p.ws._meetingUserId) inCall.add(p.ws._meetingUserId); } }
// DMs
const byOther = new Map();
for (const m of R.messages.recentFor(u.team_id, u.id)) {
@@ -632,7 +645,7 @@ route('GET', '/api/messages/conversations', async (req, res) => {
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,
callActive: !!dc, callRoom: dc ? dc.room : 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,
}; });
// Groups
@@ -640,7 +653,7 @@ route('GET', '/api/messages/conversations', async (req, res) => {
const last = R.messages.lastInConversation(g.id);
const since = R.conversations.lastReadAt(g.id, u.id);
return {
kind: 'group', id: g.id, name: g.name || 'Group', members: R.conversations.members(g.id).length, avatar: g.avatar_id ? ('/files/' + g.avatar_id) : null,
kind: 'group', id: g.id, name: g.name || 'Group', members: R.conversations.members(g.id).length, avatar: g.avatar_id ? ('/files/' + g.avatar_id) : null, favorite: favs.has('group:' + g.id),
callActive: groupCalls.has(g.id), callRoom: (groupCalls.get(g.id) || {}).room || null,
last_body: last ? (last.body || (last.attachment_id ? '📎 Attachment' : '')) : '', last_at: last ? last.created_at : g.created_at,
last_from_me: last ? last.sender_id === u.id : false, unread: last ? R.messages.unreadInConversation(g.id, u.id, since) : 0,
@@ -1188,6 +1201,46 @@ route('POST', '/api/messages', async (req, res) => {
json(res, 200, dto);
});
// Delete one of YOUR OWN messages for everyone (clears content, keeps the row as a placeholder).
route('POST', '/api/messages/delete', async (req, res) => {
const u = currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' });
const { id } = await readBody(req);
if (!id) return json(res, 400, { error: 'id required' });
const m = R.messages.byId(id);
if (!m || m.team_id !== u.team_id) return json(res, 404, { error: 'not found' });
if (m.sender_id !== u.id) return json(res, 403, { error: 'you can only delete your own messages' });
R.messages.markDeleted(id);
const evt = { type: 'chat-deleted', id, conversation_id: m.conversation_id || null };
if (m.conversation_id) { for (const mid of R.conversations.members(m.conversation_id)) { try { CHAT.pushToUser(mid, evt); } catch (_) {} } }
else { try { CHAT.pushToUser(m.recipient_id, evt); } catch (_) {} try { CHAT.pushToUser(u.id, evt); } catch (_) {} }
json(res, 200, { ok: true });
});
// Favourite/unfavourite a conversation (per user). target = 'dm:<userId>' or 'group:<groupId>'.
route('POST', '/api/favorites', async (req, res) => {
const u = currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' });
const { kind, id, on } = await readBody(req);
if (!kind || !id) return json(res, 400, { error: 'kind and id required' });
try { R.favorites.set(u.id, kind + ':' + id, !!on); } catch (_) {}
json(res, 200, { ok: true, favorite: !!on });
});
// Shared media & files in a conversation (group) or DM — for the "Shared" Media/Files view.
route('GET', '/api/messages/media', async (req, res) => {
const u = currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' });
const q = new URLSearchParams(req.url.split('?')[1] || '');
const group = q.get('group'); const other = q.get('with');
let rows = [], linkRows = [];
if (group) { if (!R.conversations.isMember(group, u.id)) return json(res, 403, { error: 'not a member' }); rows = R.messages.attachmentsForConversation(u.team_id, group); linkRows = R.messages.linksForConversation(u.team_id, group); }
else if (other) { rows = R.messages.attachmentsForDm(u.team_id, u.id, other); linkRows = R.messages.linksForDm(u.team_id, u.id, other); }
else return json(res, 400, { error: 'group or with required' });
const urlRe = /(https?:\/\/[^\s<>"']+)/gi;
const links = []; for (const m of linkRows) { const mm = (m.body || '').match(urlRe); if (mm) for (const url of mm) links.push({ url, at: m.created_at }); }
const att = rows.map((r) => ({ id: r.id, name: r.name, mime: r.mime, size: r.size, isImage: /^image\//.test(r.mime || ''), isAudio: /^audio\//.test(r.mime || ''), isVideo: /^video\//.test(r.mime || ''), at: r.created_at }));
const isMedia = (a) => a.isImage || a.isAudio || a.isVideo; // images, audio & video → "Media"; everything else → "Docs"
json(res, 200, { media: att.filter(isMedia), docs: att.filter((a) => !isMedia(a)), links });
});
route('POST', '/api/messages/read', async (req, res) => {
const u = currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' });
+4 -3
파일 보기
@@ -65,12 +65,13 @@ function handle(ws, m, req) {
if (hostUserId === undefined) { try { const s = R.scheduledMeetings.byCode(room); if (s) { hostUserId = s.created_by; roomHost.set(room, hostUserId); } } catch (_) {} }
const ju = currentUser(req);
ws._meetingUserId = ju ? ju.id : null; // for per-user transcript ownership
const avatar = (ju && ju.avatar_url) ? ju.avatar_url : null; // for participant-tile profile pics
const isHost = !!(ju && hostUserId && ju.id === hostUserId);
// Tell the newcomer who's already here (they initiate offers to existing peers)…
ws.send(JSON.stringify({ type: 'meeting-joined', room, peerId, isHost, peers: [...peers.entries()].map(([id, p]) => ({ peerId: id, name: p.name })) }));
ws.send(JSON.stringify({ type: 'meeting-joined', room, peerId, isHost, peers: [...peers.entries()].map(([id, p]) => ({ peerId: id, name: p.name, avatar: p.avatar || null, uid: p.uid || null })) }));
// …and tell existing peers a newcomer arrived.
for (const [, p] of peers) { if (p.ws.readyState === 1) p.ws.send(JSON.stringify({ type: 'meeting-peer-joined', peerId, name })); }
peers.set(peerId, { ws, name });
for (const [, p] of peers) { if (p.ws.readyState === 1) p.ws.send(JSON.stringify({ type: 'meeting-peer-joined', peerId, name, avatar, uid: ws._meetingUserId || null })); }
peers.set(peerId, { ws, name, avatar, uid: ws._meetingUserId || null });
const tsubs = transcriptSubs.get(room); if (tsubs && tsubs.size > 0) ws.send(JSON.stringify({ type: 'meeting-transcribe-state', active: true })); // catch up: already transcribing
break;
}